mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
Compare commits
685 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf0c26d3c4 | ||
|
|
65970c564c | ||
|
|
ce09efe640 | ||
|
|
adaf9a7813 | ||
|
|
06f9492518 | ||
|
|
530042675e | ||
|
|
ca404aeb24 | ||
|
|
a6c975f92c | ||
|
|
dd3cadbd81 | ||
|
|
efbabfcace | ||
|
|
09c71ef1d9 | ||
|
|
0bb8208f36 | ||
|
|
56571c77ca | ||
|
|
6c353d389b | ||
|
|
583217d396 | ||
|
|
93162d2f10 | ||
|
|
2e74295807 | ||
|
|
93bf8844de | ||
|
|
fcc520df47 | ||
|
|
a94f4e3c18 | ||
|
|
de9e7f0e84 | ||
|
|
ed5b2d6ce6 | ||
|
|
2cd3fa7981 | ||
|
|
f01cc3a0b4 | ||
|
|
51bf09208d | ||
|
|
bf0831a664 | ||
|
|
96b1fccf76 | ||
|
|
4b57971d33 | ||
|
|
d531e14fbf | ||
|
|
8df38a23d2 | ||
|
|
38105e6539 | ||
|
|
14c6577d51 | ||
|
|
f051a58db5 | ||
|
|
825d990312 | ||
|
|
cb31013584 | ||
|
|
dd74d1dabe | ||
|
|
75f3952ac6 | ||
|
|
37423fbb31 | ||
|
|
00c3436d48 | ||
|
|
377b4e5cb8 | ||
|
|
a427586b89 | ||
|
|
a951494489 | ||
|
|
a871c0437d | ||
|
|
013960cd5a | ||
|
|
60aebf41a8 | ||
|
|
2cf521e57e | ||
|
|
dad3a84efb | ||
|
|
ae864c7ff9 | ||
|
|
3b33114a40 | ||
|
|
e0f49ac619 | ||
|
|
01ec49afd2 | ||
|
|
b44154fe02 | ||
|
|
b8dad38622 | ||
|
|
80fea77c86 | ||
|
|
e5f3eb48d4 | ||
|
|
ca876b0c65 | ||
|
|
0a762b8c08 | ||
|
|
fd90a89b45 | ||
|
|
f82eb39d23 | ||
|
|
2786148153 | ||
|
|
2959cfea32 | ||
|
|
e536232963 | ||
|
|
778d78cebe | ||
|
|
538281da51 | ||
|
|
12cd626949 | ||
|
|
ff64a7930e | ||
|
|
5d726fe340 | ||
|
|
e1834124d4 | ||
|
|
f49e965736 | ||
|
|
936eaf5939 | ||
|
|
7047b30e27 | ||
|
|
5c67e970d1 | ||
|
|
8023c4e8b7 | ||
|
|
641b84519c | ||
|
|
0c8cb974e2 | ||
|
|
915edbe145 | ||
|
|
0c20c5c159 | ||
|
|
6211e63f90 | ||
|
|
44b61684ed | ||
|
|
ab6f49a822 | ||
|
|
02517e4a01 | ||
|
|
2599966cf7 | ||
|
|
6c68931892 | ||
|
|
41855ed511 | ||
|
|
02bc91f4af | ||
|
|
e9352e6984 | ||
|
|
ec4c36f450 | ||
|
|
9ea0017778 | ||
|
|
f1cdc2d2cc | ||
|
|
215ed24401 | ||
|
|
c432681b2b | ||
|
|
49452e035d | ||
|
|
5db2998e3d | ||
|
|
d1336b872e | ||
|
|
e1e29b32e9 | ||
|
|
214dcaf141 | ||
|
|
77a196de8b | ||
|
|
108d04398b | ||
|
|
0bc0f2b930 | ||
|
|
c9c16298ec | ||
|
|
2ef31d5d33 | ||
|
|
e9d9b566a4 | ||
|
|
3baa3252bc | ||
|
|
90d9db0f83 | ||
|
|
8bff4f1658 | ||
|
|
a0e20ef311 | ||
|
|
6996215d3b | ||
|
|
03ffa2db7d | ||
|
|
75e3110e8c | ||
|
|
033480eef1 | ||
|
|
8ddfcbb125 | ||
|
|
a5aaecc48d | ||
|
|
84d6848e67 | ||
|
|
01373465b0 | ||
|
|
a1e733080d | ||
|
|
3b3ef715bb | ||
|
|
47b2bf9d46 | ||
|
|
ea47f3097e | ||
|
|
70c1c44d15 | ||
|
|
e3dce45b2a | ||
|
|
3bb8ec3bea | ||
|
|
35e42a3ad6 | ||
|
|
949575ad14 | ||
|
|
1c34f0f03d | ||
|
|
eed2eab014 | ||
|
|
381cbed4fd | ||
|
|
4cde25325d | ||
|
|
8d70af5e89 | ||
|
|
b3408d8e5f | ||
|
|
e74906fbec | ||
|
|
52209217fc | ||
|
|
018493a60b | ||
|
|
630373b1f0 | ||
|
|
dec31dfd75 | ||
|
|
2397ea019e | ||
|
|
77da90e316 | ||
|
|
18ce17d21a | ||
|
|
ce09b14836 | ||
|
|
e7a069b060 | ||
|
|
6e3933be30 | ||
|
|
e2cb9e11b0 | ||
|
|
d281a34c6f | ||
|
|
c97cf5610f | ||
|
|
ab674a3517 | ||
|
|
7d63e7d8fa | ||
|
|
6538843bdf | ||
|
|
bd5fede122 | ||
|
|
047fb57630 | ||
|
|
583c1de5ba | ||
|
|
c9c293f67c | ||
|
|
561631baba | ||
|
|
80d0a6aeb2 | ||
|
|
6b5ee245ae | ||
|
|
5c43c2f519 | ||
|
|
9387980e74 | ||
|
|
075d9fc608 | ||
|
|
63bfab03f6 | ||
|
|
1d7e6b3703 | ||
|
|
ad64e17a34 | ||
|
|
0092376c07 | ||
|
|
6fb19a68b5 | ||
|
|
d5427d967a | ||
|
|
7fd30b608c | ||
|
|
830b05f243 | ||
|
|
e85290cddc | ||
|
|
1940d628a8 | ||
|
|
cffa590d3e | ||
|
|
402e2bfee0 | ||
|
|
f5caba81d6 | ||
|
|
354350dec9 | ||
|
|
0513298f57 | ||
|
|
08e23e5bd8 | ||
|
|
e812c7d29a | ||
|
|
ef46199346 | ||
|
|
7c9ea62993 | ||
|
|
8cb53e6129 | ||
|
|
12c0383dc8 | ||
|
|
83b53039f3 | ||
|
|
7e6a309935 | ||
|
|
33c03e30d9 | ||
|
|
1f1abdd7b6 | ||
|
|
16134bd150 | ||
|
|
c887fc71ad | ||
|
|
9fc39f648f | ||
|
|
ec9557e3d8 | ||
|
|
7cf0f7d42d | ||
|
|
b7aa64279d | ||
|
|
26300a8d43 | ||
|
|
8dd21ddb83 | ||
|
|
ff584f8421 | ||
|
|
ca4a8253a1 | ||
|
|
157374401a | ||
|
|
ba777ed706 | ||
|
|
0e4da1d1c5 | ||
|
|
72847e0711 | ||
|
|
3c19614c74 | ||
|
|
a2e4955116 | ||
|
|
c62175c06b | ||
|
|
fde4b6f590 | ||
|
|
3d7c68bac6 | ||
|
|
72a477f10c | ||
|
|
2a16c562a8 | ||
|
|
2b670e73f3 | ||
|
|
3994594019 | ||
|
|
39c9386b54 | ||
|
|
4cc57cc08d | ||
|
|
639a3eac1e | ||
|
|
79323358e5 | ||
|
|
cdb093c74a | ||
|
|
f6f3ce5f05 | ||
|
|
4805f3d4d3 | ||
|
|
1d797cdaf5 | ||
|
|
4d8458669c | ||
|
|
92ec9653e5 | ||
|
|
e861d98007 | ||
|
|
a97eeb1fd9 | ||
|
|
cd88b23b5d | ||
|
|
33eabf937b | ||
|
|
beb5df16a3 | ||
|
|
7fa743f01a | ||
|
|
1f6859d78f | ||
|
|
2853735472 | ||
|
|
feaa9076b0 | ||
|
|
ce0249706e | ||
|
|
af2c839231 | ||
|
|
2b2d24ed25 | ||
|
|
04d28f9d2d | ||
|
|
1dbf41f384 | ||
|
|
9e6a2cc2c0 | ||
|
|
7bf4ef3d05 | ||
|
|
126649f70f | ||
|
|
1827a2a31c | ||
|
|
fcf4eb78dc | ||
|
|
2ec6ea8045 | ||
|
|
0994a3586d | ||
|
|
29c4be6a3a | ||
|
|
c5b8e06891 | ||
|
|
54a20bca92 | ||
|
|
6e786bde90 | ||
|
|
b671b0d725 | ||
|
|
57f5692074 | ||
|
|
b0ac0731c7 | ||
|
|
3c161df526 | ||
|
|
aa3f48e93c | ||
|
|
5ae1e1adde | ||
|
|
fe8b8fe831 | ||
|
|
5aca54c083 | ||
|
|
458b1a1d88 | ||
|
|
3dd4b84179 | ||
|
|
99bddb79d6 | ||
|
|
136b0b89e8 | ||
|
|
c605b0b080 | ||
|
|
b7b8e3679c | ||
|
|
aeb6610ff4 | ||
|
|
e3eacc77d7 | ||
|
|
37661daf40 | ||
|
|
877b848370 | ||
|
|
5c163cc0fe | ||
|
|
6e04ea8240 | ||
|
|
d106465419 | ||
|
|
f39380cea7 | ||
|
|
bccce2d7cb | ||
|
|
6721dbdbcc | ||
|
|
83cd6ad158 | ||
|
|
116fb27257 | ||
|
|
8d67177a1b | ||
|
|
ad2db1a776 | ||
|
|
2e6d9e0f27 | ||
|
|
e05f85f3ce | ||
|
|
40c48a9a61 | ||
|
|
c9a7525d0b | ||
|
|
fd571ac539 | ||
|
|
c5a3f991c5 | ||
|
|
eb74b73351 | ||
|
|
9b31f45481 | ||
|
|
bc9c1691f5 | ||
|
|
73bf83d2ff | ||
|
|
36e1988fee | ||
|
|
aad6ef635e | ||
|
|
96659cd616 | ||
|
|
c8787b7de4 | ||
|
|
91d427c8f9 | ||
|
|
c8c0573dbd | ||
|
|
29af855ecd | ||
|
|
0a146a245d | ||
|
|
bd85fee7d7 | ||
|
|
571897e2fd | ||
|
|
840dabeccd | ||
|
|
069bffa3e8 | ||
|
|
cc10d230b0 | ||
|
|
2517f2add8 | ||
|
|
a534266025 | ||
|
|
8c25395805 | ||
|
|
36b913124b | ||
|
|
2fa6343fe5 | ||
|
|
06b84225a1 | ||
|
|
5b31da335d | ||
|
|
90773ab69f | ||
|
|
11d92bb22a | ||
|
|
b7734c3926 | ||
|
|
d3faf9c8dc | ||
|
|
bca97a1d14 | ||
|
|
ac9d0f18c5 | ||
|
|
09fa624797 | ||
|
|
b8333e351c | ||
|
|
a01423a196 | ||
|
|
7c35df7a82 | ||
|
|
2b90f377e6 | ||
|
|
fff7326209 | ||
|
|
c181e500bc | ||
|
|
16b7271826 | ||
|
|
4a1f62b185 | ||
|
|
d23a0754c1 | ||
|
|
3ffb563a44 | ||
|
|
4e42f2a017 | ||
|
|
a0dfdb79df | ||
|
|
a85c5f9d4e | ||
|
|
2720bba5b7 | ||
|
|
4634a7bc2f | ||
|
|
16d9b449c9 | ||
|
|
8761997757 | ||
|
|
19bba4abbc | ||
|
|
7839f0aac5 | ||
|
|
83def1db30 | ||
|
|
a0b29d1ffe | ||
|
|
f5479c56af | ||
|
|
246f0a45c8 | ||
|
|
fe871aad77 | ||
|
|
6f860e1bc4 | ||
|
|
249ea40ae3 | ||
|
|
20d8ae19a7 | ||
|
|
ad51aabfd7 | ||
|
|
1cf395c041 | ||
|
|
745179a5bf | ||
|
|
ff5d477fa5 | ||
|
|
907825601d | ||
|
|
c2ec26910a | ||
|
|
83f2aea123 | ||
|
|
a5c5439315 | ||
|
|
eca9b60235 | ||
|
|
d2d5d98d78 | ||
|
|
fb341b869b | ||
|
|
29e66cb186 | ||
|
|
307769b949 | ||
|
|
9a09e057d6 | ||
|
|
3e28659528 | ||
|
|
b861eef26f | ||
|
|
caaf006a49 | ||
|
|
b2429ec30c | ||
|
|
55aaf60a57 | ||
|
|
a5790d82f6 | ||
|
|
63f99af1e6 | ||
|
|
4eed2568aa | ||
|
|
fb7962c7f2 | ||
|
|
76e6b7b471 | ||
|
|
fccb7ff9ed | ||
|
|
3b12ef2e66 | ||
|
|
f9d099be1b | ||
|
|
c322c0e3a5 | ||
|
|
530fc20596 | ||
|
|
a23b4ed754 | ||
|
|
fc4f5077b0 | ||
|
|
6a553886da | ||
|
|
1065c7e722 | ||
|
|
a9c8a59f58 | ||
|
|
8730f7fd27 | ||
|
|
8f608223d7 | ||
|
|
a7cbd47a2f | ||
|
|
b80c3fe5a8 | ||
|
|
5080051e39 | ||
|
|
23bfc8d0ba | ||
|
|
80e9062041 | ||
|
|
67bd3420ed | ||
|
|
aea081703f | ||
|
|
f300d2a2d5 | ||
|
|
f150d7d83a | ||
|
|
4d1f059c0d | ||
|
|
bc7f953fcc | ||
|
|
f653483eea | ||
|
|
6b200fd36b | ||
|
|
161fc6cdf0 | ||
|
|
6f68ed6bce | ||
|
|
a4592ffdfe | ||
|
|
7cd7bd1a48 | ||
|
|
9eeca70292 | ||
|
|
02bfe30848 | ||
|
|
c9c99de3d9 | ||
|
|
8752f0cc60 | ||
|
|
5c65196e44 | ||
|
|
f5798bfe90 | ||
|
|
0e556b3468 | ||
|
|
31820f56e7 | ||
|
|
fd88828abd | ||
|
|
ae11159918 | ||
|
|
472a8605c0 | ||
|
|
e1760ba211 | ||
|
|
ce4c0a0aa4 | ||
|
|
64511593c4 | ||
|
|
b0e00dfceb | ||
|
|
fc465b463d | ||
|
|
68ce2e5232 | ||
|
|
81e8bb62ae | ||
|
|
2c13e1b923 | ||
|
|
a0748c2e3b | ||
|
|
40599bb751 | ||
|
|
f3c64ceea7 | ||
|
|
15c60de709 | ||
|
|
6dd316547f | ||
|
|
54c7676a44 | ||
|
|
d25b8966ce | ||
|
|
14a119c48c | ||
|
|
c82515a927 | ||
|
|
26e630c2dd | ||
|
|
13370d2056 | ||
|
|
35282db9e0 | ||
|
|
426fb88ce7 | ||
|
|
2384bd0e10 | ||
|
|
ba3f66d3d1 | ||
|
|
7293a0f670 | ||
|
|
9e86d46267 | ||
|
|
848430f062 | ||
|
|
abd21335c4 | ||
|
|
8fa95f058a | ||
|
|
d4e5ecd497 | ||
|
|
3830f76729 | ||
|
|
83f778fec9 | ||
|
|
cabd24605f | ||
|
|
ae20ba1148 | ||
|
|
3a50b64977 | ||
|
|
8692e74536 | ||
|
|
1c18bd9889 | ||
|
|
60e9d98d0a | ||
|
|
83f6625e0c | ||
|
|
acc09543b7 | ||
|
|
94d8c7e366 | ||
|
|
ea1a0c8b3d | ||
|
|
7bc88c17e4 | ||
|
|
33cf1bc4c3 | ||
|
|
9402e63fe1 | ||
|
|
90e4d494b2 | ||
|
|
da97e948ca | ||
|
|
89a07e8e74 | ||
|
|
3f3d0381e5 | ||
|
|
3649499dba | ||
|
|
a989d088fd | ||
|
|
f79a915136 | ||
|
|
12e8c3d449 | ||
|
|
4f7064575e | ||
|
|
070df826f1 | ||
|
|
fbe48a4b4e | ||
|
|
4dd497fb6d | ||
|
|
907882c0a7 | ||
|
|
d36d5aee3f | ||
|
|
c6824e5f5e | ||
|
|
199c21eede | ||
|
|
5162da5654 | ||
|
|
a1d82f6193 | ||
|
|
ea78e3d0c6 | ||
|
|
3497f00cb4 | ||
|
|
5355d45031 | ||
|
|
26693acc3f | ||
|
|
76e9fef3b2 | ||
|
|
c34308cbd4 | ||
|
|
5a10476010 | ||
|
|
46e80dceec | ||
|
|
90d1835353 | ||
|
|
845fadd0aa | ||
|
|
5748ded52c | ||
|
|
6a737fb734 | ||
|
|
3cd92ccda3 | ||
|
|
54e81aba11 | ||
|
|
d86cb4ded6 | ||
|
|
4d5375f6d6 | ||
|
|
424557fedb | ||
|
|
89251e603f | ||
|
|
a653ed07eb | ||
|
|
ad86deb014 | ||
|
|
9525dc7584 | ||
|
|
cd31dd27fd | ||
|
|
360e3670eb | ||
|
|
8dabe3b4c8 | ||
|
|
443e0c2806 | ||
|
|
9cc173cc4d | ||
|
|
b5f33e5ecd | ||
|
|
40dfc6860f | ||
|
|
1c02a04423 | ||
|
|
de0e45070c | ||
|
|
c169cc7d74 | ||
|
|
cd62ad76f6 | ||
|
|
dd25b0fb5b | ||
|
|
a38b22a6a2 | ||
|
|
830b8f2971 | ||
|
|
b058af122c | ||
|
|
174ee0cafc | ||
|
|
1c336380c0 | ||
|
|
3068880413 | ||
|
|
be596681e5 | ||
|
|
66b71c50e9 | ||
|
|
8744810b25 | ||
|
|
7f94d37c2e | ||
|
|
6d9b7baeb4 | ||
|
|
4470d4c352 | ||
|
|
d2a462a279 | ||
|
|
14ff2a15e7 | ||
|
|
6d1369900e | ||
|
|
1f17ebe69e | ||
|
|
1ae2918064 | ||
|
|
b6571e5cad | ||
|
|
7549d48cf1 | ||
|
|
00353dd0cb | ||
|
|
afd947195d | ||
|
|
e57ef37167 | ||
|
|
ef33a93654 | ||
|
|
61732aecfc | ||
|
|
6764c05c3f | ||
|
|
fa149cf4aa | ||
|
|
e4f9697d06 | ||
|
|
da061450e5 | ||
|
|
d09ae49287 | ||
|
|
511ee0bbaf | ||
|
|
3cb5a0fbd6 | ||
|
|
e06925ab85 | ||
|
|
184634e4e7 | ||
|
|
843c2d02cc | ||
|
|
8ea2455766 | ||
|
|
9dc9987d56 | ||
|
|
3458621147 | ||
|
|
079df5a47c | ||
|
|
ddb07c65a1 | ||
|
|
9b21cd222b | ||
|
|
90f736843f | ||
|
|
13c020eb61 | ||
|
|
dbc06dbe95 | ||
|
|
23d097bc1c | ||
|
|
db85b9808e | ||
|
|
df5bae37bc | ||
|
|
acc23b6051 | ||
|
|
61f2741afc | ||
|
|
4dd7ea886a | ||
|
|
1e8959fbcf | ||
|
|
48729678cf | ||
|
|
0684becaa7 | ||
|
|
db16bdf8cb | ||
|
|
f890318ed9 | ||
|
|
158510cbbe | ||
|
|
ce90cf7aa8 | ||
|
|
a3a3d006eb | ||
|
|
8fd029a4a1 | ||
|
|
2e1b52c1e5 | ||
|
|
3eb8348708 | ||
|
|
393f0c007c | ||
|
|
294e380288 | ||
|
|
4c1c42efac | ||
|
|
c062ca8c66 | ||
|
|
76dcb25103 | ||
|
|
c5b4f236db | ||
|
|
0974c940a8 | ||
|
|
cffa20d37e | ||
|
|
ef009edd29 | ||
|
|
3ca52b118d | ||
|
|
13f5fde4fb | ||
|
|
f512b55ec2 | ||
|
|
22b8ca0095 | ||
|
|
baf66a103d | ||
|
|
45faa9c1ff | ||
|
|
304381a88d | ||
|
|
fc9f54dbc8 | ||
|
|
7199dc187f | ||
|
|
e9ae066d53 | ||
|
|
d71ae406ff | ||
|
|
f3216904b3 | ||
|
|
5958b69ec9 | ||
|
|
7d4e2cb39a | ||
|
|
a483ec0cea | ||
|
|
c1421e0874 | ||
|
|
ce89869c3c | ||
|
|
3bc6e89b74 | ||
|
|
b8b57e34ff | ||
|
|
bc7f627253 | ||
|
|
652156e398 | ||
|
|
9febb071c6 | ||
|
|
7d0e1568ac | ||
|
|
b4e711f411 | ||
|
|
1b5be1b981 | ||
|
|
49d8707c58 | ||
|
|
9192f6f7f7 | ||
|
|
05022e3745 | ||
|
|
5356e9ddeb | ||
|
|
52acf76e2c | ||
|
|
40cdbd3b45 | ||
|
|
5487c0befe | ||
|
|
8bb16c48c0 | ||
|
|
c6384363f9 | ||
|
|
8993e8ad3e | ||
|
|
289989d9f7 | ||
|
|
dc2ae0e6f1 | ||
|
|
9c966c152d | ||
|
|
4efae41048 | ||
|
|
b8437032e9 | ||
|
|
2d339ca81b | ||
|
|
d53abc9696 | ||
|
|
446c886d38 | ||
|
|
30c6d9b5ae | ||
|
|
5e42996b36 | ||
|
|
ceca7b85bf | ||
|
|
a4d54f58c8 | ||
|
|
005a0e1bad | ||
|
|
46d97fd57d | ||
|
|
72a26b6353 | ||
|
|
89a4033fbf | ||
|
|
39a5dc64bd | ||
|
|
d4bdd9b1b7 | ||
|
|
2f5ba87280 | ||
|
|
8b45d6c750 | ||
|
|
4ecd4df2d4 | ||
|
|
a42f31fe52 | ||
|
|
d4480b695e | ||
|
|
c4b5f7fbae | ||
|
|
ba915f2cc0 | ||
|
|
4b91140f31 | ||
|
|
9879878dd0 | ||
|
|
d78105d57c | ||
|
|
153c9e3565 | ||
|
|
c11623596d | ||
|
|
e791a77f77 | ||
|
|
b641bffb2c | ||
|
|
ee0c47ac1e | ||
|
|
eba90e9343 | ||
|
|
d8374d0fa5 | ||
|
|
fa61744c6d | ||
|
|
4fec55cc01 | ||
|
|
1767413712 | ||
|
|
734c8fa84f | ||
|
|
9a8d422554 | ||
|
|
b21e945c76 | ||
|
|
a02bf1ea09 | ||
|
|
eda82bac92 | ||
|
|
e8d4f7dc4f | ||
|
|
c4a93b7789 | ||
|
|
c3f9925097 | ||
|
|
2a0cf7511a | ||
|
|
d0a70d3339 | ||
|
|
f37e4675dd | ||
|
|
4e32f67eeb | ||
|
|
36d54cab52 | ||
|
|
9d8df10dcf | ||
|
|
45ea88e070 | ||
|
|
d5d0b947f5 | ||
|
|
f775f1f11e | ||
|
|
f1e888f3de | ||
|
|
71c8436e90 | ||
|
|
08c69f5e9b | ||
|
|
a50fafaca2 | ||
|
|
3c6781d240 | ||
|
|
3b8b5625f8 | ||
|
|
6be2034110 | ||
|
|
924dc79f00 | ||
|
|
ccb9030d3c | ||
|
|
8623287ac1 | ||
|
|
022c13f3a4 | ||
|
|
0687916e7f | ||
|
|
bb868b83ba | ||
|
|
24298130b9 | ||
|
|
6e5ee92ebd | ||
|
|
5b91fe04aa | ||
|
|
1623deb3ee | ||
|
|
4a16e05b7a | ||
|
|
f1c04bc60d | ||
|
|
84c6f31c76 | ||
|
|
9d528190bf | ||
|
|
0f23b209ad | ||
|
|
63d9325900 | ||
|
|
f342097f81 | ||
|
|
b4806c4366 | ||
|
|
ff37d8a577 | ||
|
|
a773eb7893 | ||
|
|
7c67513d24 | ||
|
|
6ed85029c5 | ||
|
|
e9c57ddf4d | ||
|
|
a33ce97ed9 | ||
|
|
b788a3dd4e | ||
|
|
fccfa92d7e | ||
|
|
8705bf0a70 | ||
|
|
9318138af7 | ||
|
|
269fa7d2d5 |
147
.github/ISSUE_TEMPLATE/1.bug.yml
vendored
147
.github/ISSUE_TEMPLATE/1.bug.yml
vendored
@@ -1,133 +1,46 @@
|
|||||||
name: Bug report 🐛
|
name: Bug report 🐛
|
||||||
description: 项目运行中遇到的Bug或问题。
|
description: Report a bug or unexpected behavior.
|
||||||
|
title: "[Bug] "
|
||||||
labels: ['status: needs check']
|
labels: ['status: needs check']
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
### ⚠️ 前置确认
|
> 💡 English is recommended so global developers can help. 推荐使用英文提交,谢谢 ❤️
|
||||||
1. 网络能够访问openai接口
|
|
||||||
2. python 已安装:版本在 3.7 ~ 3.10 之间
|
|
||||||
3. `git pull` 拉取最新代码
|
|
||||||
4. 执行`pip3 install -r requirements.txt`,检查依赖是否满足
|
|
||||||
5. 拓展功能请执行`pip3 install -r requirements-optional.txt`,检查依赖是否满足
|
|
||||||
6. [FAQS](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs) 中无类似问题
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
label: 前置确认
|
label: Self check
|
||||||
options:
|
options:
|
||||||
- label: 我确认我运行的是最新版本的代码,并且安装了所需的依赖,在[FAQS](https://github.com/zhayujie/chatgpt-on-wechat/wiki/FAQs)中也未找到类似问题。
|
- label: I'm on the latest version and searched [existing issues](https://github.com/zhayujie/CowAgent/issues) (incl. closed) — no duplicate.
|
||||||
required: true
|
required: true
|
||||||
- type: checkboxes
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: ⚠️ 搜索issues中是否已存在类似问题
|
label: Environment
|
||||||
description: >
|
description: "Version (`cow status`), OS, Python version, install method, model & channel."
|
||||||
请在 [历史issue](https://github.com/zhayujie/chatgpt-on-wechat/issues) 中清空输入框,搜索你的问题
|
placeholder: |
|
||||||
或相关日志的关键词来查找是否存在类似问题。
|
Version: v1.2.0
|
||||||
options:
|
OS: macOS / Linux / Windows / Docker
|
||||||
- label: 我已经搜索过issues和disscussions,没有跟我遇到的问题相关的issue
|
Python: 3.11
|
||||||
required: true
|
Install: installer / Docker / source
|
||||||
- type: markdown
|
Model & channel: deepseek-v4-flash, web
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
请在上方的`title`中填写你对你所遇到问题的简略总结,这将帮助其他人更好的找到相似问题,谢谢❤️。
|
|
||||||
- type: dropdown
|
|
||||||
attributes:
|
|
||||||
label: 操作系统类型?
|
|
||||||
description: >
|
|
||||||
请选择你运行程序的操作系统类型。
|
|
||||||
options:
|
|
||||||
- Windows
|
|
||||||
- Linux
|
|
||||||
- MacOS
|
|
||||||
- Docker
|
|
||||||
- Railway
|
|
||||||
- Windows Subsystem for Linux (WSL)
|
|
||||||
- Other (请在问题中说明)
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: dropdown
|
|
||||||
attributes:
|
|
||||||
label: 运行的python版本是?
|
|
||||||
description: |
|
|
||||||
请选择你运行程序的`python`版本。
|
|
||||||
注意:在`python 3.7`中,有部分可选依赖无法安装。
|
|
||||||
经过长时间的观察,我们认为`python 3.8`是兼容性最好的版本。
|
|
||||||
`python 3.7`~`python 3.10`以外版本的issue,将视情况直接关闭。
|
|
||||||
options:
|
|
||||||
- python 3.7
|
|
||||||
- python 3.8
|
|
||||||
- python 3.9
|
|
||||||
- python 3.10
|
|
||||||
- other
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: dropdown
|
|
||||||
attributes:
|
|
||||||
label: 使用的chatgpt-on-wechat版本是?
|
|
||||||
description: |
|
|
||||||
请确保你使用的是 [releases](https://github.com/zhayujie/chatgpt-on-wechat/releases) 中的最新版本。
|
|
||||||
如果你使用git, 请使用`git branch`命令来查看分支。
|
|
||||||
options:
|
|
||||||
- Latest Release
|
|
||||||
- Master (branch)
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
- type: dropdown
|
|
||||||
attributes:
|
|
||||||
label: 运行的`channel`类型是?
|
|
||||||
description: |
|
|
||||||
请确保你正确配置了该`channel`所需的配置项,所有可选的配置项都写在了[该文件中](https://github.com/zhayujie/chatgpt-on-wechat/blob/master/config.py),请将所需配置项填写在根目录下的`config.json`文件中。
|
|
||||||
options:
|
|
||||||
- wx(个人微信, itchat)
|
|
||||||
- wxy(个人微信, wechaty)
|
|
||||||
- wechatmp(公众号, 订阅号)
|
|
||||||
- wechatmp_service(公众号, 服务号)
|
|
||||||
- terminal
|
|
||||||
- other
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 复现步骤 🕹
|
label: What happened?
|
||||||
description: |
|
description: "Steps to reproduce, what you expected, and what happened instead. Screenshots welcome."
|
||||||
**⚠️ 不能复现将会关闭issue.**
|
placeholder: |
|
||||||
- type: textarea
|
1. ...
|
||||||
attributes:
|
2. ...
|
||||||
label: 问题描述 😯
|
|
||||||
description: 详细描述出现的问题,或提供有关截图。
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: 终端日志 📒
|
|
||||||
description: |
|
|
||||||
在此处粘贴终端日志,可在主目录下`run.log`文件中找到,这会帮助我们更好的分析问题,注意隐去你的API key。
|
|
||||||
如果在配置文件中加入`"debug": true`,打印出的日志会更有帮助。
|
|
||||||
|
|
||||||
<details>
|
Expected: ...
|
||||||
<summary><i>示例</i></summary>
|
Actual: ...
|
||||||
```log
|
validations:
|
||||||
[DEBUG][2023-04-16 00:23:22][plugin_manager.py:157] - Plugin SUMMARY triggered by event Event.ON_HANDLE_CONTEXT
|
required: true
|
||||||
[DEBUG][2023-04-16 00:23:22][main.py:221] - [Summary] on_handle_context. content: $总结前100条消息
|
- type: textarea
|
||||||
[DEBUG][2023-04-16 00:23:24][main.py:240] - [Summary] limit: 100, duration: -1 seconds
|
attributes:
|
||||||
[ERROR][2023-04-16 00:23:24][chat_channel.py:244] - Worker return exception: name 'start_date' is not defined
|
label: Logs
|
||||||
Traceback (most recent call last):
|
description: "Relevant logs from `run.log` (set `\"debug\": true` for more detail). ⚠️ Redact your API keys."
|
||||||
File "C:\ProgramData\Anaconda3\lib\concurrent\futures\thread.py", line 57, in run
|
render: shell
|
||||||
result = self.fn(*self.args, **self.kwargs)
|
validations:
|
||||||
File "D:\project\chatgpt-on-wechat\channel\chat_channel.py", line 132, in _handle
|
required: false
|
||||||
reply = self._generate_reply(context)
|
|
||||||
File "D:\project\chatgpt-on-wechat\channel\chat_channel.py", line 142, in _generate_reply
|
|
||||||
e_context = PluginManager().emit_event(EventContext(Event.ON_HANDLE_CONTEXT, {
|
|
||||||
File "D:\project\chatgpt-on-wechat\plugins\plugin_manager.py", line 159, in emit_event
|
|
||||||
instance.handlers[e_context.event](e_context, *args, **kwargs)
|
|
||||||
File "D:\project\chatgpt-on-wechat\plugins\summary\main.py", line 255, in on_handle_context
|
|
||||||
records = self._get_records(session_id, start_time, limit)
|
|
||||||
File "D:\project\chatgpt-on-wechat\plugins\summary\main.py", line 96, in _get_records
|
|
||||||
c.execute("SELECT * FROM chat_records WHERE sessionid=? and timestamp>? ORDER BY timestamp DESC LIMIT ?", (session_id, start_date, limit))
|
|
||||||
NameError: name 'start_date' is not defined
|
|
||||||
[INFO][2023-04-16 00:23:36][app.py:14] - signal 2 received, exiting...
|
|
||||||
```
|
|
||||||
</details>
|
|
||||||
value: |
|
|
||||||
```log
|
|
||||||
<此处粘贴终端日志>
|
|
||||||
```
|
|
||||||
|
|||||||
31
.github/ISSUE_TEMPLATE/2.feature.yml
vendored
31
.github/ISSUE_TEMPLATE/2.feature.yml
vendored
@@ -1,28 +1,33 @@
|
|||||||
name: Feature request 🚀
|
name: Feature request 🚀
|
||||||
description: 提出你对项目的新想法或建议。
|
description: Suggest a new idea or improvement.
|
||||||
|
title: "[Feature] "
|
||||||
labels: ['status: needs check']
|
labels: ['status: needs check']
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
请在上方的`title`中填写简略总结,谢谢❤️。
|
> 💡 English is recommended so global developers can help. 推荐使用英文提交,谢谢 ❤️
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
label: ⚠️ 搜索是否存在类似issue
|
label: Self check
|
||||||
description: >
|
|
||||||
请在 [历史issue](https://github.com/zhayujie/chatgpt-on-wechat/issues) 中清空输入框,搜索关键词查找是否存在相似issue。
|
|
||||||
options:
|
options:
|
||||||
- label: 我已经搜索过issues和disscussions,没有发现相似issue
|
- label: I searched [existing issues](https://github.com/zhayujie/CowAgent/issues) (incl. closed) — no duplicate.
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 总结
|
label: What's the problem?
|
||||||
description: 描述feature的功能。
|
description: "The pain point or what's not working for you right now."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 举例
|
label: What would you like?
|
||||||
description: 提供聊天示例,草图或相关网址。
|
description: "How you'd expect it to work. Examples, sketches, or links welcome."
|
||||||
- type: textarea
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: checkboxes
|
||||||
attributes:
|
attributes:
|
||||||
label: 动机
|
label: Contribution
|
||||||
description: 描述你提出该feature的动机,比如没有这项feature对你的使用造成了怎样的影响。 请提供更详细的场景描述,这可能会帮助我们发现并提出更好的解决方案。
|
options:
|
||||||
|
- label: I'd be interested in helping implement this.
|
||||||
|
required: false
|
||||||
|
|||||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: true
|
||||||
|
contact_links:
|
||||||
|
- name: 📖 Documentation
|
||||||
|
url: https://docs.cowagent.ai
|
||||||
|
about: Setup guides, configuration, and FAQ.
|
||||||
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
22
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!--
|
||||||
|
Thanks for your contribution! Please write this PR in English.
|
||||||
|
推荐使用英文填写,感谢 ❤️
|
||||||
|
-->
|
||||||
|
|
||||||
|
## What does this PR do?
|
||||||
|
|
||||||
|
<!-- A short description of the change and why it's needed. -->
|
||||||
|
|
||||||
|
## Type of change
|
||||||
|
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] New feature
|
||||||
|
- [ ] Docs
|
||||||
|
- [ ] Refactor / chore
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] I have read the [Contributing Guide](https://github.com/zhayujie/CowAgent/blob/master/CONTRIBUTING.md)
|
||||||
|
- [ ] I tested this change locally
|
||||||
|
- [ ] Code comments and docs are in English
|
||||||
|
- [ ] Linked related issue (if any): closes #
|
||||||
116
.github/scripts/register-releases.mjs
vendored
Normal file
116
.github/scripts/register-releases.mjs
vendored
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// Build the D1 upsert SQL for a desktop release from the files in a directory.
|
||||||
|
//
|
||||||
|
// Each mac release has TWO artifacts that map to a SINGLE D1 row:
|
||||||
|
// - <name>-<arch>.dmg -> manual download (filename / size / sha512)
|
||||||
|
// - <name>-<arch>.zip -> auto-update (update_filename / update_size /
|
||||||
|
// update_sha512)
|
||||||
|
// electron-updater's MacUpdater can only consume a zip, never a dmg, so the
|
||||||
|
// feed serves the zip while the website serves the dmg. Windows has only the
|
||||||
|
// .exe (stored in the main columns; it's both the download and the update).
|
||||||
|
//
|
||||||
|
// We emit ONE `INSERT OR REPLACE` per (version, platform) carrying BOTH halves,
|
||||||
|
// because two replaces on the same primary key would drop whichever came first.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node register-releases.mjs --dir dist --version 1.2.0 \
|
||||||
|
// --sql out.sql [--latest]
|
||||||
|
//
|
||||||
|
// --latest mark these rows is_latest=1 AND clear the previous latest for
|
||||||
|
// each platform (used by the publish/promote workflow). Without it
|
||||||
|
// rows are written unpublished (is_latest=0) — the build stage.
|
||||||
|
//
|
||||||
|
// sha512 is base64 (the exact format electron-updater validates).
|
||||||
|
|
||||||
|
import { execSync } from 'node:child_process'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
|
||||||
|
function arg(name, fallback = undefined) {
|
||||||
|
const i = process.argv.indexOf(`--${name}`)
|
||||||
|
if (i === -1) return fallback
|
||||||
|
const next = process.argv[i + 1]
|
||||||
|
// Boolean flag (no value or next token is another flag).
|
||||||
|
if (next === undefined || next.startsWith('--')) return true
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = arg('dir', 'dist')
|
||||||
|
const version = arg('version')
|
||||||
|
const sqlPath = arg('sql', 'd1.sql')
|
||||||
|
const makeLatest = arg('latest', false) === true
|
||||||
|
|
||||||
|
if (!version) {
|
||||||
|
console.error('register-releases: --version is required')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha512 = (f) =>
|
||||||
|
execSync(`openssl dgst -sha512 -binary "${f}" | openssl base64 -A`, {
|
||||||
|
shell: '/bin/bash',
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
// SQL-escape single quotes (base64/keys shouldn't contain them, but be safe).
|
||||||
|
const q = (s) => String(s).replace(/'/g, "''")
|
||||||
|
|
||||||
|
// platform -> { main: {key,size,sha}, upd: {key,size,sha} }
|
||||||
|
const rows = {}
|
||||||
|
|
||||||
|
for (const base of fs.readdirSync(dir)) {
|
||||||
|
const f = `${dir}/${base}`
|
||||||
|
if (fs.statSync(f).isDirectory()) continue
|
||||||
|
|
||||||
|
let platform
|
||||||
|
let slot
|
||||||
|
if (/arm64\.dmg$/.test(base)) {
|
||||||
|
platform = 'mac-arm64'
|
||||||
|
slot = 'main'
|
||||||
|
} else if (/x64\.dmg$/.test(base)) {
|
||||||
|
platform = 'mac-x64'
|
||||||
|
slot = 'main'
|
||||||
|
} else if (/arm64\.zip$/.test(base)) {
|
||||||
|
platform = 'mac-arm64'
|
||||||
|
slot = 'upd'
|
||||||
|
} else if (/x64\.zip$/.test(base)) {
|
||||||
|
platform = 'mac-x64'
|
||||||
|
slot = 'upd'
|
||||||
|
} else if (/\.exe$/.test(base)) {
|
||||||
|
platform = 'win'
|
||||||
|
slot = 'main'
|
||||||
|
} else {
|
||||||
|
console.log('Skipping unrecognized artifact:', base)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rows[platform] ||= {}
|
||||||
|
rows[platform][slot] = {
|
||||||
|
key: `v${version}/${base}`,
|
||||||
|
size: fs.statSync(f).size,
|
||||||
|
sha: sha512(f),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(rows).length === 0) {
|
||||||
|
console.error('register-releases: no recognized artifacts in', dir)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLatest = makeLatest ? 1 : 0
|
||||||
|
const sql = []
|
||||||
|
for (const [platform, r] of Object.entries(rows)) {
|
||||||
|
const m = r.main || { key: '', size: 0, sha: '' }
|
||||||
|
const u = r.upd || { key: '', size: 0, sha: '' }
|
||||||
|
if (makeLatest) {
|
||||||
|
// Clear the previous latest for this platform before promoting the new row.
|
||||||
|
sql.push(`UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';`)
|
||||||
|
}
|
||||||
|
sql.push(
|
||||||
|
`INSERT OR REPLACE INTO releases ` +
|
||||||
|
`(version, platform, filename, size, sha512, update_filename, update_size, update_sha512, is_latest) ` +
|
||||||
|
`VALUES ('${version}', '${platform}', '${q(m.key)}', ${m.size}, '${q(m.sha)}', ` +
|
||||||
|
`'${q(u.key)}', ${u.size}, '${q(u.sha)}', ${isLatest});`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(sqlPath, sql.join('\n') + '\n')
|
||||||
|
console.log(`register-releases: wrote ${sql.length} statement(s) to ${sqlPath}`)
|
||||||
11
.github/workflows/deploy-image-arm.yml
vendored
11
.github/workflows/deploy-image-arm.yml
vendored
@@ -19,7 +19,7 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push-image:
|
build-and-push-image:
|
||||||
if: github.repository == 'zhayujie/chatgpt-on-wechat'
|
if: github.repository == 'zhayujie/CowAgent'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -51,7 +51,12 @@ jobs:
|
|||||||
uses: docker/metadata-action@v4
|
uses: docker/metadata-action@v4
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
${{ env.REGISTRY }}/zhayujie/chatgpt-on-wechat
|
||||||
|
${{ env.REGISTRY }}/zhayujie/cowagent
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest-arm64,enable={{is_default_branch}}
|
||||||
|
type=ref,event=branch,suffix=-arm64
|
||||||
|
type=ref,event=tag,suffix=-arm64
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
@@ -60,7 +65,7 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
file: ./docker/Dockerfile.latest
|
file: ./docker/Dockerfile.latest
|
||||||
platforms: linux/arm64
|
platforms: linux/arm64
|
||||||
tags: ${{ steps.meta.outputs.tags }}-arm64
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
- uses: actions/delete-package-versions@v4
|
- uses: actions/delete-package-versions@v4
|
||||||
|
|||||||
13
.github/workflows/deploy-image.yml
vendored
13
.github/workflows/deploy-image.yml
vendored
@@ -16,10 +16,11 @@ on:
|
|||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
DOCKERHUB_IMAGE: zhayujie/chatgpt-on-wechat
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push-image:
|
build-and-push-image:
|
||||||
if: github.repository == 'zhayujie/chatgpt-on-wechat'
|
if: github.repository == 'zhayujie/CowAgent'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -47,8 +48,14 @@ jobs:
|
|||||||
uses: docker/metadata-action@v4
|
uses: docker/metadata-action@v4
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.IMAGE_NAME }}
|
zhayujie/chatgpt-on-wechat
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
zhayujie/cowagent
|
||||||
|
${{ env.REGISTRY }}/zhayujie/chatgpt-on-wechat
|
||||||
|
${{ env.REGISTRY }}/zhayujie/cowagent
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=tag
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
|
|||||||
154
.github/workflows/publish-desktop.yml
vendored
Normal file
154
.github/workflows/publish-desktop.yml
vendored
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
name: Publish Desktop
|
||||||
|
|
||||||
|
# STAGE 3 of the decoupled release pipeline: PROMOTE a built + notarized version
|
||||||
|
# to "live". By this point:
|
||||||
|
# - stage 1 (Release Desktop) built the installers, mirrored them to R2, and
|
||||||
|
# registered them in D1 as unpublished (is_latest=0);
|
||||||
|
# - stage 2 (local desktop/build/notarize-dmg.sh) notarized + stapled the mac
|
||||||
|
# dmgs and re-uploaded the stapled bytes to R2.
|
||||||
|
#
|
||||||
|
# This workflow, triggered manually with the version to publish:
|
||||||
|
# 1. pulls every artifact for that version back from R2,
|
||||||
|
# 2. recomputes sha512 from the real (stapled) bytes and updates D1,
|
||||||
|
# 3. flips is_latest=1 for that version (clearing the previous latest per
|
||||||
|
# platform) UNLESS it's a pre-release, which is recorded but never latest,
|
||||||
|
# 4. creates/updates the GitHub Release and attaches the installers.
|
||||||
|
#
|
||||||
|
# Run only after the mac dmgs for this version are notarized + re-uploaded.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to publish (e.g. 1.2.0). Must already be built + notarized."
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
make_latest:
|
||||||
|
description: "Mark this version as latest on the site (uncheck for a dry re-hash only)."
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
github_release:
|
||||||
|
description: "Create/update the GitHub Release and attach installers."
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Publish ${{ github.event.inputs.version }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Guard on Cloudflare secrets
|
||||||
|
id: guard
|
||||||
|
env:
|
||||||
|
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$CF_TOKEN" ]; then
|
||||||
|
echo "::error::CLOUDFLARE_API_TOKEN not set — cannot publish."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Resolve version artifacts from D1
|
||||||
|
id: rows
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
VER: ${{ github.event.inputs.version }}
|
||||||
|
run: |
|
||||||
|
# The build stage inserted one row per artifact with filename = v<VER>/<base>.
|
||||||
|
# Read them back so we know exactly which objects to pull from R2.
|
||||||
|
out="$(npx --yes wrangler@latest d1 execute cow-desktop --remote --json \
|
||||||
|
--command "SELECT platform, filename, update_filename FROM releases WHERE version = '${VER}';")"
|
||||||
|
echo "$out"
|
||||||
|
echo "$out" | node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const data = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||||
|
const rows = (Array.isArray(data) ? data : [data])
|
||||||
|
.flatMap(r => (r.results || []));
|
||||||
|
if (!rows.length) {
|
||||||
|
console.error("No D1 rows for this version — did stage 1 (build) run?");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(process.env.GITHUB_OUTPUT, "count=" + rows.length + "\n", { flag: "a" });
|
||||||
|
fs.writeFileSync("rows.json", JSON.stringify(rows));
|
||||||
|
'
|
||||||
|
|
||||||
|
- name: Download version artifacts from R2
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }}
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
# Pull BOTH the manual-download file (filename: dmg/exe) and the mac
|
||||||
|
# auto-update file (update_filename: zip) for every row. Keys are
|
||||||
|
# "v<VER>/<base>"; the R2 key is "desktop/<key>".
|
||||||
|
for key in $(node -e 'JSON.parse(require("fs").readFileSync("rows.json")).forEach(r => { if (r.filename) console.log(r.filename); if (r.update_filename) console.log(r.update_filename); })'); do
|
||||||
|
base="$(basename "$key")"
|
||||||
|
r2key="desktop/${key}"
|
||||||
|
echo "==> Downloading r2://${R2_BUCKET}/${r2key} -> dist/${base}"
|
||||||
|
npx --yes wrangler@latest r2 object get "${R2_BUCKET}/${r2key}" \
|
||||||
|
--file "dist/${base}" --remote
|
||||||
|
done
|
||||||
|
echo "Downloaded:"; ls -la dist
|
||||||
|
|
||||||
|
- name: Reminder — mac dmgs must be notarized before publishing
|
||||||
|
run: |
|
||||||
|
# Stapling can only be validated on macOS (xcrun stapler validate),
|
||||||
|
# which this Linux runner doesn't have. The authoritative check runs in
|
||||||
|
# stage 2 (desktop/build/notarize-dmg.sh) before re-uploading to R2.
|
||||||
|
# This step is just a loud reminder in the log.
|
||||||
|
echo "::notice::Publishing assumes the mac dmgs pulled from R2 are already notarized + stapled (stage 2). If you skipped stage 2, users will hit Gatekeeper warnings."
|
||||||
|
ls -la dist/*.dmg 2>/dev/null || echo "(no dmg in this version — win-only publish)"
|
||||||
|
|
||||||
|
- name: Update D1 (recompute sha512 + set latest)
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
VER: ${{ github.event.inputs.version }}
|
||||||
|
MAKE_LATEST: ${{ github.event.inputs.make_latest }}
|
||||||
|
run: |
|
||||||
|
# Pre-releases (e.g. 1.2.0-beta / -rc.1 / -test) are recorded but never
|
||||||
|
# become latest, so the site keeps serving the last stable build.
|
||||||
|
case "$VER" in
|
||||||
|
*-*) is_pre=1 ;;
|
||||||
|
*) is_pre=0 ;;
|
||||||
|
esac
|
||||||
|
if [ "$MAKE_LATEST" = "true" ] && [ "$is_pre" = "0" ]; then
|
||||||
|
latest_flag="--latest"; echo "==> Publishing $VER as latest."
|
||||||
|
else
|
||||||
|
latest_flag=""; echo "==> Publishing $VER without latest flag (pre-release or dry re-hash)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Re-hash the real (stapled) bytes and re-store every row with both the
|
||||||
|
# dmg (manual) and mac zip (auto-update) columns. Same script as the
|
||||||
|
# build stage; --latest also clears the previous latest per platform.
|
||||||
|
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql $latest_flag
|
||||||
|
echo "==> D1 statements:"; cat d1.sql
|
||||||
|
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
|
||||||
|
|
||||||
|
- name: Create/update GitHub Release and attach installers
|
||||||
|
if: github.event.inputs.github_release == 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VER: ${{ github.event.inputs.version }}
|
||||||
|
run: |
|
||||||
|
tag="v${VER}"
|
||||||
|
case "$VER" in
|
||||||
|
*-*) prerelease="--prerelease" ;;
|
||||||
|
*) prerelease="" ;;
|
||||||
|
esac
|
||||||
|
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||||
|
gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||||
|
--title "$tag" --generate-notes $prerelease
|
||||||
|
fi
|
||||||
|
# --clobber so re-runs overwrite the stapled/updated assets. The mac
|
||||||
|
# zip is the auto-update artifact; attach it too so the GitHub Release
|
||||||
|
# is a complete mirror (nullglob avoids errors when a type is absent).
|
||||||
|
shopt -s nullglob
|
||||||
|
gh release upload "$tag" dist/*.dmg dist/*.zip dist/*.exe \
|
||||||
|
--repo "$GITHUB_REPOSITORY" --clobber
|
||||||
301
.github/workflows/release.yml
vendored
Normal file
301
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
name: Release Desktop
|
||||||
|
|
||||||
|
# STAGE 1 of the decoupled release pipeline: BUILD ONLY.
|
||||||
|
# Builds the desktop client for macOS (arm64 + x64) and Windows (x64), mirrors
|
||||||
|
# the installers to R2, and registers them in D1 as UNPUBLISHED (is_latest=0)
|
||||||
|
# so the website keeps serving the previous release. It does NOT notarize
|
||||||
|
# (Apple's notary service stalls this large bundle for hours) and does NOT
|
||||||
|
# create a GitHub Release.
|
||||||
|
#
|
||||||
|
# Full flow:
|
||||||
|
# 1. (this workflow) build + upload to R2 + D1 as unpublished.
|
||||||
|
# 2. (local) download the mac dmgs, run desktop/build/notarize-dmg.sh to
|
||||||
|
# notarize + staple + re-upload the stapled dmgs to R2.
|
||||||
|
# 3. (Publish Desktop workflow) flip D1 is_latest=1 and attach GitHub
|
||||||
|
# Release assets — makes the version live on the site.
|
||||||
|
#
|
||||||
|
# Manual only: run stage 1 via workflow_dispatch. Tag pushes do NOT trigger a
|
||||||
|
# build, so cutting a release tag never rebuilds installers or overwrites R2.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to stamp (e.g. 1.0.0-test). Used for package.json and R2 path."
|
||||||
|
type: string
|
||||||
|
default: "0.0.0-dev"
|
||||||
|
publish_r2:
|
||||||
|
description: "Upload installers to R2 + register in D1 (needs Cloudflare secrets)"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build ${{ matrix.name }}
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
# Don't cancel the other platforms if one fails — we want to see all
|
||||||
|
# failures in a single run.
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- name: macOS arm64
|
||||||
|
os: macos-14
|
||||||
|
platform: mac
|
||||||
|
arch: arm64
|
||||||
|
eb_flags: --mac --arm64
|
||||||
|
- name: macOS x64
|
||||||
|
os: macos-15-intel
|
||||||
|
platform: mac
|
||||||
|
arch: x64
|
||||||
|
eb_flags: --mac --x64
|
||||||
|
- name: Windows x64
|
||||||
|
os: windows-latest
|
||||||
|
platform: win
|
||||||
|
arch: x64
|
||||||
|
eb_flags: --win --x64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Derive version
|
||||||
|
# Tag push: strip the leading "v" from GITHUB_REF_NAME (e.g. v1.2.0).
|
||||||
|
# Manual dispatch: use the provided version input.
|
||||||
|
id: ver
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "push" ]; then
|
||||||
|
ref="${GITHUB_REF_NAME:-}"
|
||||||
|
echo "version=${ref#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Build Python backend (PyInstaller)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r desktop/build/requirements-desktop.txt
|
||||||
|
pip install pyinstaller
|
||||||
|
# Run from repo root so the spec's relative datas resolve correctly.
|
||||||
|
pyinstaller desktop/build/cowagent-backend.spec \
|
||||||
|
--noconfirm \
|
||||||
|
--distpath desktop/build/dist \
|
||||||
|
--workpath desktop/build/build-work
|
||||||
|
|
||||||
|
- name: Install desktop deps
|
||||||
|
working-directory: desktop
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Write version into package.json
|
||||||
|
working-directory: desktop
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ steps.ver.outputs.version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
# Compile renderer + main in its OWN step, alone, so the npm.cmd batch
|
||||||
|
# wrapper (see the note on the build step below) can't take out anything
|
||||||
|
# after it.
|
||||||
|
- name: Compile (vite + tsc)
|
||||||
|
working-directory: desktop
|
||||||
|
shell: bash
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Build & publish (electron-builder)
|
||||||
|
working-directory: desktop
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# Signing secrets are passed through as-is; we only export them to the
|
||||||
|
# environment below when non-empty. An empty CSC_LINK would make
|
||||||
|
# electron-builder try to load a bogus certificate and fail, so unset
|
||||||
|
# is the correct state for unsigned builds.
|
||||||
|
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
||||||
|
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
||||||
|
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
|
||||||
|
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
# Pick the signing cert for THIS platform only. The mac and win secrets
|
||||||
|
# are both present in the job env, but a mac cert must never leak into a
|
||||||
|
# Windows build (electron-builder would try to load it and fail), and
|
||||||
|
# vice versa. electron-builder reads a single CSC_LINK/CSC_KEY_PASSWORD
|
||||||
|
# pair, so we set it per-platform. An empty CSC_LINK is treated by
|
||||||
|
# electron-builder as a broken cert path, so we leave it entirely unset
|
||||||
|
# for an unsigned build.
|
||||||
|
#
|
||||||
|
# NOTE: we only ever `export`, never `unset`, GitHub-injected env vars
|
||||||
|
# (an `unset` can return non-zero and abort under errexit).
|
||||||
|
case "${{ matrix.platform }}" in
|
||||||
|
mac)
|
||||||
|
if [ -n "$MAC_CSC_LINK" ]; then
|
||||||
|
export CSC_LINK="$MAC_CSC_LINK"
|
||||||
|
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
win)
|
||||||
|
if [ -n "$WIN_CSC_LINK" ]; then
|
||||||
|
export CSC_LINK="$WIN_CSC_LINK"
|
||||||
|
export CSC_KEY_PASSWORD="$WIN_CSC_KEY_PASSWORD"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Never let electron-builder publish: our publish target is a generic
|
||||||
|
# (read-only) feed served from R2/D1, which it can't upload to. We mirror
|
||||||
|
# installers to R2 and register them in D1 ourselves (publish-r2 job).
|
||||||
|
# `--publish never` still emits the latest*.yml files.
|
||||||
|
#
|
||||||
|
# CONFIG PER PLATFORM: the dynamic electron-builder.js only exists to
|
||||||
|
# inject mac.binaries (the backend Mach-O files to hardened-sign for
|
||||||
|
# notarization) — it's a pure no-op on Windows. Passing --config on
|
||||||
|
# Windows was what silently broke the Windows build (it produced no
|
||||||
|
# installer while the job still reported success; Windows worked fine
|
||||||
|
# before --config was introduced). So Windows uses the plain
|
||||||
|
# package.json build config and only mac uses the dynamic one.
|
||||||
|
#
|
||||||
|
# Invoke via `node <cli.js>` rather than `npx`: on Windows `npx` is
|
||||||
|
# npx.cmd (a batch wrapper) and running it from this Git Bash step can
|
||||||
|
# make bash return before the wrapped process finishes. node skips it.
|
||||||
|
case "${{ matrix.platform }}" in
|
||||||
|
mac) config_arg="--config electron-builder.js" ;;
|
||||||
|
*) config_arg="" ;;
|
||||||
|
esac
|
||||||
|
node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never
|
||||||
|
|
||||||
|
# Upload artifacts regardless of outcome, so a failed run still surfaces
|
||||||
|
# the built installers (and, on success, the notarized+stapled dmg).
|
||||||
|
- name: Upload artifacts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
# One bundle per platform/arch so the publish job can collect them all.
|
||||||
|
name: cowagent-${{ matrix.platform }}-${{ matrix.arch }}
|
||||||
|
path: |
|
||||||
|
desktop/release/*.dmg
|
||||||
|
desktop/release/*.zip
|
||||||
|
desktop/release/*.exe
|
||||||
|
desktop/release/*.yml
|
||||||
|
desktop/release/*.blockmap
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# Mirror the release installers to R2 (CDN-backed) and register them in D1 so
|
||||||
|
# cowagent.ai/download/{platform}/latest can resolve and count downloads.
|
||||||
|
# Runs only on tag pushes, and is a no-op (skips) until the Cloudflare secrets
|
||||||
|
# are configured, so it never blocks unsigned/dry builds.
|
||||||
|
publish-r2:
|
||||||
|
name: Publish to R2 + D1
|
||||||
|
# Require every platform in the build matrix to succeed before publishing,
|
||||||
|
# so a release on R2/D1 is always complete (all installers present) rather
|
||||||
|
# than partial. needs: build already gates on all matrix jobs succeeding.
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Run on a tag push, or on a manual dispatch when publish_r2 is checked.
|
||||||
|
if: >-
|
||||||
|
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) ||
|
||||||
|
(github.event_name == 'workflow_dispatch' && github.event.inputs.publish_r2 == 'true')
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Guard on Cloudflare secrets
|
||||||
|
id: guard
|
||||||
|
env:
|
||||||
|
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$CF_TOKEN" ]; then
|
||||||
|
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::CLOUDFLARE_API_TOKEN not set — skipping R2/D1 publish."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Derive version
|
||||||
|
if: steps.guard.outputs.enabled == 'true'
|
||||||
|
id: ver
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "push" ]; then
|
||||||
|
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Download all build artifacts
|
||||||
|
if: steps.guard.outputs.enabled == 'true'
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Stage installers
|
||||||
|
if: steps.guard.outputs.enabled == 'true'
|
||||||
|
id: stage
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
# Flatten installers + their .blockmap (used by electron-updater for
|
||||||
|
# differential downloads) from every per-platform artifact dir. The
|
||||||
|
# .yml feed is generated dynamically by the /update Function from D1,
|
||||||
|
# so the yml files themselves don't need to go to R2.
|
||||||
|
# .zip is the mac auto-update artifact (electron-updater's MacUpdater
|
||||||
|
# can ONLY consume zip, not dmg — the dmg is for manual downloads).
|
||||||
|
find artifacts -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.exe' -o -name '*.blockmap' \) -exec cp {} dist/ \;
|
||||||
|
echo "Staged files:"; ls -la dist
|
||||||
|
# When the whole matrix failed there's nothing to publish; flag it so
|
||||||
|
# the R2/D1 steps skip instead of writing an empty/partial release.
|
||||||
|
if [ -n "$(ls -A dist 2>/dev/null)" ]; then
|
||||||
|
echo "has_files=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "has_files=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::warning::No installers found in any artifact — skipping R2/D1 publish."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload installers to R2
|
||||||
|
if: steps.guard.outputs.enabled == 'true' && steps.stage.outputs.has_files == 'true'
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
VER: ${{ steps.ver.outputs.version }}
|
||||||
|
run: |
|
||||||
|
# Reuse the existing cow-skills bucket under a desktop/ prefix; this
|
||||||
|
# is served by the cdn.cowagent.ai custom domain.
|
||||||
|
for f in dist/*; do
|
||||||
|
base="$(basename "$f")"
|
||||||
|
key="desktop/v${VER}/${base}"
|
||||||
|
echo "==> Uploading $base -> r2://cow-skills/$key"
|
||||||
|
npx --yes wrangler@latest r2 object put "cow-skills/$key" \
|
||||||
|
--file "$f" --remote
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Register release rows in D1
|
||||||
|
if: steps.guard.outputs.enabled == 'true' && steps.stage.outputs.has_files == 'true'
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
VER: ${{ steps.ver.outputs.version }}
|
||||||
|
run: |
|
||||||
|
# This build job ALWAYS registers rows as unpublished (is_latest=0), so
|
||||||
|
# /download/<p>/latest keeps serving the previous release and the new
|
||||||
|
# version stays invisible on the site. macOS dmgs still need to be
|
||||||
|
# notarized+stapled locally (build/notarize-dmg.sh) before they're
|
||||||
|
# safe to ship. Promotion to latest happens later, only after
|
||||||
|
# notarization, via the separate "Publish Desktop" workflow.
|
||||||
|
echo "==> Registering $VER as unpublished (is_latest=0)."
|
||||||
|
|
||||||
|
# Build one upsert per (version, platform) carrying both the dmg
|
||||||
|
# (manual download) and the mac zip (auto-update) columns. See
|
||||||
|
# .github/scripts/register-releases.mjs for the mapping. No --latest
|
||||||
|
# here: rows stay unpublished until the publish workflow promotes them.
|
||||||
|
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql
|
||||||
|
echo "==> D1 statements:"; cat d1.sql
|
||||||
|
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
|
||||||
32
.github/workflows/test-windows-bash.yml
vendored
Normal file
32
.github/workflows/test-windows-bash.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
name: Windows Bash Streaming Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "agent/tools/bash/bash.py"
|
||||||
|
- "tests/test_bash_streaming.py"
|
||||||
|
- ".github/workflows/test-windows-bash.yml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
windows-bash-tests:
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install pytest
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Run Windows Bash streaming tests
|
||||||
|
run: python -m pytest tests/test_bash_streaming.py -v
|
||||||
30
.gitignore
vendored
30
.gitignore
vendored
@@ -3,16 +3,15 @@
|
|||||||
.vscode
|
.vscode
|
||||||
.venv
|
.venv
|
||||||
.vs
|
.vs
|
||||||
.wechaty/
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
venv*
|
venv*
|
||||||
*.pyc
|
*.pyc
|
||||||
|
python
|
||||||
config.json
|
config.json
|
||||||
QR.png
|
QR.png
|
||||||
nohup.out
|
nohup.out
|
||||||
tmp
|
tmp
|
||||||
plugins.json
|
plugins.json
|
||||||
itchat.pkl
|
|
||||||
*.log
|
*.log
|
||||||
logs/
|
logs/
|
||||||
workspace
|
workspace
|
||||||
@@ -33,8 +32,33 @@ plugins/banwords/lib/__pycache__
|
|||||||
!plugins/role
|
!plugins/role
|
||||||
!plugins/keyword
|
!plugins/keyword
|
||||||
!plugins/linkai
|
!plugins/linkai
|
||||||
!plugins/agent
|
!plugins/cow_cli
|
||||||
client_config.json
|
client_config.json
|
||||||
ref/
|
ref/
|
||||||
|
**/.dev.vars
|
||||||
.cursor/
|
.cursor/
|
||||||
local/
|
local/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# cow cli
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
.cow.pid
|
||||||
|
|
||||||
|
# Desktop backend packaging: keep the source files (spec/requirements/script)
|
||||||
|
# tracked even though the generic build/ rule above ignores them, but never
|
||||||
|
# track the build outputs or local venv.
|
||||||
|
!desktop/build/
|
||||||
|
desktop/build/*
|
||||||
|
!desktop/build/cowagent-backend.spec
|
||||||
|
!desktop/build/requirements-desktop.txt
|
||||||
|
!desktop/build/build-backend.sh
|
||||||
|
!desktop/build/entitlements.mac.plist
|
||||||
|
!desktop/build/notarize-dmg.sh
|
||||||
|
|
||||||
|
# Icon authoring scratch dir: intermediate assets used to produce the final
|
||||||
|
# icons. Only the finished icons under desktop/resources/ should be committed.
|
||||||
|
desktop/resources/.icon-work/
|
||||||
|
|
||||||
|
.wrangler/
|
||||||
|
|||||||
61
CONTRIBUTING.md
Normal file
61
CONTRIBUTING.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Contributing to CowAgent
|
||||||
|
|
||||||
|
Thanks for taking the time to contribute! 🎉 CowAgent is built by a global
|
||||||
|
community, and contributions of all sizes are welcome — from typo fixes to new
|
||||||
|
features.
|
||||||
|
|
||||||
|
## Language policy
|
||||||
|
|
||||||
|
To keep the project accessible to a global community, **please write issues,
|
||||||
|
pull requests, code comments, and commit messages in English.**
|
||||||
|
|
||||||
|
> 为方便全球开发者协作,请尽量使用**英文**提交 issue、PR、代码注释与
|
||||||
|
> commit message。不必担心英文不完美——表达清楚即可,工具翻译也完全没问题。感谢理解 ❤️
|
||||||
|
|
||||||
|
## Reporting issues
|
||||||
|
|
||||||
|
Found a bug or have an idea? [Open an issue](https://github.com/zhayujie/CowAgent/issues/new/choose).
|
||||||
|
|
||||||
|
Before opening one, please search existing issues (including closed ones) to
|
||||||
|
avoid duplicates, and make sure you're on the latest version.
|
||||||
|
|
||||||
|
## Submitting a pull request
|
||||||
|
|
||||||
|
1. **Fork** the repo and create a branch from `master`
|
||||||
|
(e.g. `feat/web-search`, `fix/telegram-reconnect`).
|
||||||
|
2. Make your change. Keep it focused — one logical change per PR.
|
||||||
|
3. Follow the existing code style. Write comments and docstrings in English.
|
||||||
|
4. Run the app locally to confirm your change works.
|
||||||
|
5. Open a PR with a clear title and a short description of **what** and **why**.
|
||||||
|
|
||||||
|
We keep the bar friendly: clear, focused, and working is enough. Maintainers are
|
||||||
|
happy to help polish details during review.
|
||||||
|
|
||||||
|
### Commit & PR titles
|
||||||
|
|
||||||
|
Use a short, imperative summary. The [Conventional Commits](https://www.conventionalcommits.org/)
|
||||||
|
style is preferred but not required:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add web search tool
|
||||||
|
fix: reconnect Telegram websocket on timeout
|
||||||
|
docs: clarify Docker setup
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
See the [Install from Source](https://docs.cowagent.ai/guide/manual-install)
|
||||||
|
guide. In short:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/zhayujie/CowAgent.git
|
||||||
|
cd CowAgent
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install -e .
|
||||||
|
cow start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code of conduct
|
||||||
|
|
||||||
|
Be respectful and constructive. We want CowAgent to be a welcoming place for
|
||||||
|
everyone.
|
||||||
@@ -27,7 +27,8 @@ class ChatService:
|
|||||||
"""
|
"""
|
||||||
self.agent_bridge = agent_bridge
|
self.agent_bridge = agent_bridge
|
||||||
|
|
||||||
def run(self, query: str, session_id: str, send_chunk_fn: Callable[[dict], None]):
|
def run(self, query: str, session_id: str, send_chunk_fn: Callable[[dict], None],
|
||||||
|
channel_type: str = ""):
|
||||||
"""
|
"""
|
||||||
Run the agent for *query* and stream results back via *send_chunk_fn*.
|
Run the agent for *query* and stream results back via *send_chunk_fn*.
|
||||||
|
|
||||||
@@ -37,11 +38,27 @@ class ChatService:
|
|||||||
:param query: user query text
|
:param query: user query text
|
||||||
:param session_id: session identifier for agent isolation
|
:param session_id: session identifier for agent isolation
|
||||||
:param send_chunk_fn: callable(chunk_data: dict) to send a streaming chunk
|
:param send_chunk_fn: callable(chunk_data: dict) to send a streaming chunk
|
||||||
|
:param channel_type: source channel (e.g. "web", "feishu") for persistence
|
||||||
"""
|
"""
|
||||||
agent = self.agent_bridge.get_agent(session_id=session_id)
|
agent = self.agent_bridge.get_agent(session_id=session_id)
|
||||||
if agent is None:
|
if agent is None:
|
||||||
raise RuntimeError("Failed to initialise agent for the session")
|
raise RuntimeError("Failed to initialise agent for the session")
|
||||||
|
|
||||||
|
# Pass context metadata to model for downstream API requests
|
||||||
|
if hasattr(agent, 'model'):
|
||||||
|
agent.model.channel_type = channel_type or ""
|
||||||
|
agent.model.session_id = session_id or ""
|
||||||
|
|
||||||
|
# Build a context so context-aware tools (e.g. scheduler) can resolve the
|
||||||
|
# receiver/session. This streaming path bypasses agent_bridge.agent_reply,
|
||||||
|
# so the attach step that normally happens there must be done here too.
|
||||||
|
context = self._build_context(query, session_id, channel_type)
|
||||||
|
self._attach_context_aware_tools(agent, context)
|
||||||
|
|
||||||
|
# Mark this session as mid-run so the self-evolution idle scan does not
|
||||||
|
# fire concurrently when a single turn runs longer than idle_minutes.
|
||||||
|
self._mark_run_active(agent, True)
|
||||||
|
|
||||||
# State shared between the event callback and this method
|
# State shared between the event callback and this method
|
||||||
state = _StreamState()
|
state = _StreamState()
|
||||||
|
|
||||||
@@ -50,7 +67,16 @@ class ChatService:
|
|||||||
event_type = event.get("type")
|
event_type = event.get("type")
|
||||||
data = event.get("data", {})
|
data = event.get("data", {})
|
||||||
|
|
||||||
if event_type == "message_update":
|
if event_type == "reasoning_update":
|
||||||
|
delta = data.get("delta", "")
|
||||||
|
if delta:
|
||||||
|
send_chunk_fn({
|
||||||
|
"chunk_type": "reasoning",
|
||||||
|
"delta": delta,
|
||||||
|
"segment_id": state.segment_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
elif event_type == "message_update":
|
||||||
# Incremental text delta
|
# Incremental text delta
|
||||||
delta = data.get("delta", "")
|
delta = data.get("delta", "")
|
||||||
if delta:
|
if delta:
|
||||||
@@ -68,9 +94,41 @@ class ChatService:
|
|||||||
# a new segment; collect tool results until turn_end.
|
# a new segment; collect tool results until turn_end.
|
||||||
state.pending_tool_results = []
|
state.pending_tool_results = []
|
||||||
|
|
||||||
elif event_type == "tool_execution_end":
|
elif event_type == "file_to_send":
|
||||||
|
url = data.get("url") or ""
|
||||||
|
if url:
|
||||||
|
fname = data.get("file_name") or "file"
|
||||||
|
ft = data.get("file_type") or "file"
|
||||||
|
if ft == "image":
|
||||||
|
link = f""
|
||||||
|
else:
|
||||||
|
link = f"[{fname}]({url})"
|
||||||
|
send_chunk_fn({
|
||||||
|
"chunk_type": "content",
|
||||||
|
"delta": "\n\n" + link + "\n\n",
|
||||||
|
"segment_id": state.segment_id,
|
||||||
|
})
|
||||||
|
# Remove url so the model won't repeat it in its reply
|
||||||
|
data.pop("url", None)
|
||||||
|
|
||||||
|
elif event_type == "tool_execution_start":
|
||||||
|
# Notify the client that a tool is about to run (with its input args)
|
||||||
tool_name = data.get("tool_name", "")
|
tool_name = data.get("tool_name", "")
|
||||||
arguments = data.get("arguments", {})
|
arguments = data.get("arguments", {})
|
||||||
|
# Cache arguments keyed by tool_call_id so tool_execution_end can include them
|
||||||
|
tool_call_id = data.get("tool_call_id", tool_name)
|
||||||
|
state.pending_tool_arguments[tool_call_id] = arguments
|
||||||
|
send_chunk_fn({
|
||||||
|
"chunk_type": "tool_start",
|
||||||
|
"tool": tool_name,
|
||||||
|
"arguments": arguments,
|
||||||
|
})
|
||||||
|
|
||||||
|
elif event_type == "tool_execution_end":
|
||||||
|
tool_name = data.get("tool_name", "")
|
||||||
|
tool_call_id = data.get("tool_call_id", tool_name)
|
||||||
|
# Retrieve cached arguments from the matching tool_execution_start event
|
||||||
|
arguments = state.pending_tool_arguments.pop(tool_call_id, data.get("arguments", {}))
|
||||||
result = data.get("result", "")
|
result = data.get("result", "")
|
||||||
status = data.get("status", "unknown")
|
status = data.get("status", "unknown")
|
||||||
execution_time = data.get("execution_time", 0)
|
execution_time = data.get("execution_time", 0)
|
||||||
@@ -111,7 +169,7 @@ class ChatService:
|
|||||||
logger.info(f"[ChatService] Starting agent run: session={session_id}, query={query[:80]}")
|
logger.info(f"[ChatService] Starting agent run: session={session_id}, query={query[:80]}")
|
||||||
|
|
||||||
from config import conf
|
from config import conf
|
||||||
max_context_turns = conf().get("agent_max_context_turns", 30)
|
max_context_turns = conf().get("agent_max_context_turns", 20)
|
||||||
|
|
||||||
# Get full system prompt with skills
|
# Get full system prompt with skills
|
||||||
full_system_prompt = agent.get_full_system_prompt()
|
full_system_prompt = agent.get_full_system_prompt()
|
||||||
@@ -123,6 +181,12 @@ class ChatService:
|
|||||||
|
|
||||||
from agent.protocol.agent_stream import AgentStreamExecutor
|
from agent.protocol.agent_stream import AgentStreamExecutor
|
||||||
|
|
||||||
|
# Register a cancel token so /cancel can abort this in-flight run.
|
||||||
|
# IM channels key on session_id (no per-turn request_id here).
|
||||||
|
from agent.protocol import get_cancel_registry
|
||||||
|
registry = get_cancel_registry()
|
||||||
|
cancel_event = registry.register(session_id, session_id=session_id) if session_id else None
|
||||||
|
|
||||||
executor = AgentStreamExecutor(
|
executor = AgentStreamExecutor(
|
||||||
agent=agent,
|
agent=agent,
|
||||||
model=agent.model,
|
model=agent.model,
|
||||||
@@ -132,6 +196,7 @@ class ChatService:
|
|||||||
on_event=on_event,
|
on_event=on_event,
|
||||||
messages=messages_copy,
|
messages=messages_copy,
|
||||||
max_context_turns=max_context_turns,
|
max_context_turns=max_context_turns,
|
||||||
|
cancel_event=cancel_event,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -143,11 +208,71 @@ class ChatService:
|
|||||||
agent.messages.clear()
|
agent.messages.clear()
|
||||||
logger.info("[ChatService] Cleared agent message history after executor recovery")
|
logger.info("[ChatService] Cleared agent message history after executor recovery")
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
# Clear the mid-run flag so idle scans can review this session again.
|
||||||
|
self._mark_run_active(agent, False)
|
||||||
|
# Release cancel token to keep the registry bounded.
|
||||||
|
if session_id:
|
||||||
|
try:
|
||||||
|
registry.unregister(session_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Append only the NEW messages from this execution (thread-safe)
|
# Sync executor messages back to agent (thread-safe).
|
||||||
|
# The executor may have trimmed context, making its list shorter than
|
||||||
|
# original_length. In that case we must replace entirely — just
|
||||||
|
# appending would leave stale pre-trim messages in agent.messages
|
||||||
|
# and cause the same trim to fire on every subsequent request.
|
||||||
with agent.messages_lock:
|
with agent.messages_lock:
|
||||||
new_messages = executor.messages[original_length:]
|
trimmed = len(executor.messages) < original_length
|
||||||
agent.messages.extend(new_messages)
|
if trimmed:
|
||||||
|
# Context was trimmed: the executor appended the new user
|
||||||
|
# query *before* trimming, so the new messages (user +
|
||||||
|
# assistant + tools) sit at the tail of the trimmed list.
|
||||||
|
# We cannot simply slice at original_length (it exceeds the
|
||||||
|
# list length). Instead, count how many messages the
|
||||||
|
# executor added on top of the post-trim baseline.
|
||||||
|
#
|
||||||
|
# Timeline inside executor.run_stream:
|
||||||
|
# 1. messages had `original_length` items
|
||||||
|
# 2. append user query → original_length + 1
|
||||||
|
# 3. _trim_messages() → some smaller number (includes the
|
||||||
|
# user query because it belongs to the last turn)
|
||||||
|
# 4. LLM replies / tool calls appended
|
||||||
|
#
|
||||||
|
# The user query message is always the first message of the
|
||||||
|
# last turn (it cannot be trimmed away), so we locate it to
|
||||||
|
# find where "new" messages begin.
|
||||||
|
new_start = original_length # fallback
|
||||||
|
for idx in range(len(executor.messages) - 1, -1, -1):
|
||||||
|
msg = executor.messages[idx]
|
||||||
|
if msg.get("role") == "user":
|
||||||
|
content = msg.get("content", [])
|
||||||
|
is_user_query = False
|
||||||
|
if isinstance(content, list):
|
||||||
|
has_text = any(
|
||||||
|
isinstance(b, dict) and b.get("type") == "text"
|
||||||
|
for b in content
|
||||||
|
)
|
||||||
|
has_tool_result = any(
|
||||||
|
isinstance(b, dict) and b.get("type") == "tool_result"
|
||||||
|
for b in content
|
||||||
|
)
|
||||||
|
is_user_query = has_text and not has_tool_result
|
||||||
|
elif isinstance(content, str):
|
||||||
|
is_user_query = True
|
||||||
|
if is_user_query:
|
||||||
|
new_start = idx
|
||||||
|
break
|
||||||
|
new_messages = list(executor.messages[new_start:])
|
||||||
|
else:
|
||||||
|
new_messages = list(executor.messages[original_length:])
|
||||||
|
agent.messages = list(executor.messages)
|
||||||
|
|
||||||
|
# Persist new messages to SQLite so they survive restarts and
|
||||||
|
# can be queried via the HISTORY interface.
|
||||||
|
if new_messages:
|
||||||
|
self._persist_messages(session_id, list(new_messages), channel_type)
|
||||||
|
|
||||||
# Store executor reference for files_to_send access
|
# Store executor reference for files_to_send access
|
||||||
agent.stream_executor = executor
|
agent.stream_executor = executor
|
||||||
@@ -155,10 +280,87 @@ class ChatService:
|
|||||||
# Execute post-process tools
|
# Execute post-process tools
|
||||||
agent._execute_post_process_tools()
|
agent._execute_post_process_tools()
|
||||||
|
|
||||||
|
# Record this user turn for the self-evolution idle trigger. This
|
||||||
|
# streaming path bypasses agent_bridge.agent_reply, so the activity must
|
||||||
|
# be noted here, otherwise idle scans never see any signal to evolve.
|
||||||
|
self._note_evolution_turn(agent, context)
|
||||||
|
|
||||||
logger.info(f"[ChatService] Agent run completed: session={session_id}")
|
logger.info(f"[ChatService] Agent run completed: session={session_id}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_context(query: str, session_id: str, channel_type: str):
|
||||||
|
"""Build a Context for tool resolution on the streaming chat path.
|
||||||
|
|
||||||
|
receiver falls back to session_id; the scheduler's delivery keys on
|
||||||
|
session_id as the receiver.
|
||||||
|
"""
|
||||||
|
from bridge.context import Context, ContextType
|
||||||
|
# Pass an explicit kwargs dict: Context's default kwargs is a shared
|
||||||
|
# mutable default, so omitting it would leak fields across sessions.
|
||||||
|
ctx = Context(ContextType.TEXT, query, kwargs={})
|
||||||
|
ctx["session_id"] = session_id
|
||||||
|
ctx["receiver"] = session_id
|
||||||
|
ctx["isgroup"] = False
|
||||||
|
ctx["channel_type"] = channel_type or ""
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _attach_context_aware_tools(agent, context):
|
||||||
|
"""Attach the current context to tools that need it (scheduler)."""
|
||||||
|
try:
|
||||||
|
if not (context and getattr(agent, "tools", None)):
|
||||||
|
return
|
||||||
|
for tool in agent.tools:
|
||||||
|
if tool.name == "scheduler":
|
||||||
|
from agent.tools.scheduler.integration import attach_scheduler_to_tool
|
||||||
|
attach_scheduler_to_tool(tool, context)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[ChatService] Failed to attach context to scheduler: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mark_run_active(agent, active):
|
||||||
|
"""Toggle the self-evolution mid-run flag for this session's agent."""
|
||||||
|
try:
|
||||||
|
from agent.evolution.trigger import mark_run_active
|
||||||
|
mark_run_active(agent, active)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _note_evolution_turn(agent, context):
|
||||||
|
"""Record a user turn so the self-evolution idle trigger has signal."""
|
||||||
|
try:
|
||||||
|
from agent.evolution.trigger import note_user_turn
|
||||||
|
ch = (context.get("channel_type") or "") if context else ""
|
||||||
|
rcv = (context.get("receiver") or "") if context else ""
|
||||||
|
is_group = bool(context.get("isgroup")) if context else False
|
||||||
|
# Only single chats get a proactive push target; group push is noisy.
|
||||||
|
note_user_turn(agent, channel_type=ch, receiver=(rcv if not is_group else ""))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _persist_messages(session_id: str, new_messages: list, channel_type: str = ""):
|
||||||
|
try:
|
||||||
|
from config import conf
|
||||||
|
if not conf().get("conversation_persistence", True):
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from agent.memory import get_conversation_store
|
||||||
|
get_conversation_store().append_messages(
|
||||||
|
session_id, new_messages, channel_type=channel_type
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[ChatService] Failed to persist messages for session={session_id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _StreamState:
|
class _StreamState:
|
||||||
"""Mutable state shared between the event callback and the run method."""
|
"""Mutable state shared between the event callback and the run method."""
|
||||||
|
|
||||||
@@ -167,3 +369,6 @@ class _StreamState:
|
|||||||
# None means we are not accumulating tool results right now.
|
# None means we are not accumulating tool results right now.
|
||||||
# A list means we are in the middle of a tool-execution phase.
|
# A list means we are in the middle of a tool-execution phase.
|
||||||
self.pending_tool_results: Optional[list] = None
|
self.pending_tool_results: Optional[list] = None
|
||||||
|
# Maps tool_call_id -> arguments captured from tool_execution_start,
|
||||||
|
# so that tool_execution_end can attach the correct input args.
|
||||||
|
self.pending_tool_arguments: dict = {}
|
||||||
|
|||||||
241
agent/chat/session_service.py
Normal file
241
agent/chat/session_service.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""
|
||||||
|
SessionService - Manages multi-session lifecycle for both web channel and cloud client.
|
||||||
|
|
||||||
|
Provides a unified interface for listing, deleting, renaming, clearing context,
|
||||||
|
and generating AI titles for conversation sessions. Backed by ConversationStore
|
||||||
|
(SQLite) and AgentBridge (in-memory agent instances).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_fallback_title(user_message: str, max_len: int = 30) -> str:
|
||||||
|
"""Pick the first non-empty line of the user message and truncate it."""
|
||||||
|
if not user_message:
|
||||||
|
return "New Chat"
|
||||||
|
first_line = ""
|
||||||
|
for line in user_message.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
first_line = line
|
||||||
|
break
|
||||||
|
if not first_line:
|
||||||
|
return "New Chat"
|
||||||
|
if len(first_line) > max_len:
|
||||||
|
first_line = first_line[:max_len].rstrip() + "..."
|
||||||
|
return first_line
|
||||||
|
|
||||||
|
|
||||||
|
def generate_session_title(user_message: str, assistant_reply: str = "") -> str:
|
||||||
|
"""
|
||||||
|
Generate a short session title by calling the current bot's reply_text.
|
||||||
|
Falls back to the first line of the user message if the LLM call fails
|
||||||
|
or returns an obvious error sentinel.
|
||||||
|
"""
|
||||||
|
fallback = _truncate_fallback_title(user_message)
|
||||||
|
try:
|
||||||
|
from bridge.bridge import Bridge
|
||||||
|
from models.session_manager import Session
|
||||||
|
bot = Bridge().get_bot("chat")
|
||||||
|
|
||||||
|
prompt_parts = [f"User: {user_message[:300]}"]
|
||||||
|
if assistant_reply:
|
||||||
|
prompt_parts.append(f"Assistant: {assistant_reply[:300]}")
|
||||||
|
|
||||||
|
session = Session("__title_gen__", system_prompt="")
|
||||||
|
session.messages = [
|
||||||
|
{"role": "user", "content": (
|
||||||
|
"Generate a very short title (max 15 characters for Chinese, max 6 words for English) "
|
||||||
|
"summarizing this conversation. Return ONLY the title text, nothing else.\n\n"
|
||||||
|
+ "\n".join(prompt_parts)
|
||||||
|
)}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = bot.reply_text(session) or {}
|
||||||
|
# When bots fail (network error, auth error, rate limit, etc.) they
|
||||||
|
# typically return completion_tokens=0 with a sentinel content like
|
||||||
|
# "请再问我一次吧" / "我现在有点累了". Treat that as failure.
|
||||||
|
completion_tokens = result.get("completion_tokens", 0) or 0
|
||||||
|
raw = (result.get("content") or "").strip()
|
||||||
|
if completion_tokens <= 0:
|
||||||
|
logger.warning(
|
||||||
|
f"[SessionService] Title generation got empty completion "
|
||||||
|
f"(completion_tokens={completion_tokens}, content='{raw[:50]}'), "
|
||||||
|
f"using fallback")
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
title = re.sub(r'<think>.*?</think>', '', raw, flags=re.DOTALL).strip().strip('"\'')
|
||||||
|
logger.info(f"[SessionService] Title generation result: '{title}' (len={len(title)})")
|
||||||
|
if title and len(title) <= 50:
|
||||||
|
return title
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[SessionService] Title generation failed: {e}")
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
class SessionService:
|
||||||
|
"""
|
||||||
|
High-level service for session lifecycle management.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
svc = SessionService()
|
||||||
|
result = svc.dispatch("list", {"channel_type": "web", "page": 1})
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _get_store(self):
|
||||||
|
from agent.memory import get_conversation_store
|
||||||
|
return get_conversation_store()
|
||||||
|
|
||||||
|
def _remove_agent(self, session_id: str):
|
||||||
|
"""Remove the in-memory Agent instance for a session if it exists."""
|
||||||
|
try:
|
||||||
|
from bridge.bridge import Bridge
|
||||||
|
ab = Bridge().get_agent_bridge()
|
||||||
|
if session_id in ab.agents:
|
||||||
|
del ab.agents[session_id]
|
||||||
|
logger.info(f"[SessionService] Removed agent instance: {session_id}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_sid(session_id: str) -> str:
|
||||||
|
if session_id and not session_id.startswith("session_"):
|
||||||
|
return f"session_{session_id}"
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# actions
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def list_sessions(self, channel_type: Optional[str] = None,
|
||||||
|
page: int = 1, page_size: int = 50) -> dict:
|
||||||
|
store = self._get_store()
|
||||||
|
return store.list_sessions(
|
||||||
|
channel_type=channel_type,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_session(self, session_id: str) -> None:
|
||||||
|
if not session_id:
|
||||||
|
raise ValueError("session_id required")
|
||||||
|
session_id = self._normalize_sid(session_id)
|
||||||
|
|
||||||
|
store = self._get_store()
|
||||||
|
store.clear_session(session_id)
|
||||||
|
self._remove_agent(session_id)
|
||||||
|
logger.info(f"[SessionService] Session deleted: {session_id}")
|
||||||
|
|
||||||
|
def rename_session(self, session_id: str, title: str) -> None:
|
||||||
|
if not session_id:
|
||||||
|
raise ValueError("session_id required")
|
||||||
|
if not title:
|
||||||
|
raise ValueError("title required")
|
||||||
|
session_id = self._normalize_sid(session_id)
|
||||||
|
|
||||||
|
store = self._get_store()
|
||||||
|
found = store.rename_session(session_id, title)
|
||||||
|
if not found:
|
||||||
|
raise ValueError("session not found")
|
||||||
|
|
||||||
|
def clear_context(self, session_id: str) -> int:
|
||||||
|
"""
|
||||||
|
Set context boundary. Returns the new context_start_seq value.
|
||||||
|
"""
|
||||||
|
if not session_id:
|
||||||
|
raise ValueError("session_id required")
|
||||||
|
session_id = self._normalize_sid(session_id)
|
||||||
|
|
||||||
|
store = self._get_store()
|
||||||
|
new_seq = store.clear_context(session_id)
|
||||||
|
self._remove_agent(session_id)
|
||||||
|
return new_seq
|
||||||
|
|
||||||
|
def gen_title(self, session_id: str, user_message: str,
|
||||||
|
assistant_reply: str = "") -> str:
|
||||||
|
"""
|
||||||
|
Generate an AI title and persist it. Returns the generated title.
|
||||||
|
"""
|
||||||
|
if not session_id:
|
||||||
|
raise ValueError("session_id required")
|
||||||
|
if not user_message:
|
||||||
|
raise ValueError("user_message required")
|
||||||
|
session_id = self._normalize_sid(session_id)
|
||||||
|
|
||||||
|
title = generate_session_title(user_message, assistant_reply)
|
||||||
|
|
||||||
|
store = self._get_store()
|
||||||
|
updated = store.rename_session(session_id, title)
|
||||||
|
logger.info(f"[SessionService] Title set: sid={session_id}, "
|
||||||
|
f"title='{title}', db_updated={updated}")
|
||||||
|
return title
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# dispatch — single entry point for protocol messages
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def dispatch(self, action: str, payload: Optional[dict] = None) -> dict:
|
||||||
|
"""
|
||||||
|
Dispatch a session management action and return a protocol-compatible
|
||||||
|
response dict.
|
||||||
|
|
||||||
|
Action names use a ``*_session`` / session-prefixed convention so they
|
||||||
|
can coexist with history actions (e.g. ``query``) on the same HISTORY
|
||||||
|
message channel without ambiguity.
|
||||||
|
|
||||||
|
Supported actions:
|
||||||
|
- list_sessions: list sessions with pagination
|
||||||
|
- delete_session: delete a session
|
||||||
|
- rename_session: rename a session title
|
||||||
|
- clear_context: set context boundary
|
||||||
|
- generate_title: AI-generate a session title
|
||||||
|
|
||||||
|
:param action: one of the above action names
|
||||||
|
:param payload: action-specific payload
|
||||||
|
:return: dict with action, code, message, payload
|
||||||
|
"""
|
||||||
|
payload = payload or {}
|
||||||
|
try:
|
||||||
|
if action == "list_sessions":
|
||||||
|
result = self.list_sessions(
|
||||||
|
channel_type=payload.get("channel_type"),
|
||||||
|
page=int(payload.get("page", 1)),
|
||||||
|
page_size=int(payload.get("page_size", 50)),
|
||||||
|
)
|
||||||
|
return {"action": action, "code": 200, "message": "success", "payload": result}
|
||||||
|
|
||||||
|
elif action == "delete_session":
|
||||||
|
self.delete_session(payload.get("session_id", ""))
|
||||||
|
return {"action": action, "code": 200, "message": "success", "payload": None}
|
||||||
|
|
||||||
|
elif action == "rename_session":
|
||||||
|
self.rename_session(
|
||||||
|
payload.get("session_id", ""),
|
||||||
|
payload.get("title", "").strip(),
|
||||||
|
)
|
||||||
|
return {"action": action, "code": 200, "message": "success", "payload": None}
|
||||||
|
|
||||||
|
elif action == "clear_context":
|
||||||
|
new_seq = self.clear_context(payload.get("session_id", ""))
|
||||||
|
return {"action": action, "code": 200, "message": "success",
|
||||||
|
"payload": {"context_start_seq": new_seq}}
|
||||||
|
|
||||||
|
elif action == "generate_title":
|
||||||
|
title = self.gen_title(
|
||||||
|
payload.get("session_id", ""),
|
||||||
|
payload.get("user_message", ""),
|
||||||
|
payload.get("assistant_reply", ""),
|
||||||
|
)
|
||||||
|
return {"action": action, "code": 200, "message": "success",
|
||||||
|
"payload": {"title": title}}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {"action": action, "code": 400,
|
||||||
|
"message": f"unknown action: {action}", "payload": None}
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
return {"action": action, "code": 400, "message": str(e), "payload": None}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[SessionService] dispatch error: action={action}, error={e}")
|
||||||
|
return {"action": action, "code": 500, "message": str(e), "payload": None}
|
||||||
19
agent/evolution/__init__.py
Normal file
19
agent/evolution/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
Self-evolution subsystem for CowAgent.
|
||||||
|
|
||||||
|
Runs a lightweight, isolated review pass after a conversation goes idle to
|
||||||
|
decide whether anything is worth durably learning (memory / skill) or whether
|
||||||
|
an unfinished task can be pushed forward. Conservative by design: most
|
||||||
|
conversations should produce no change at all.
|
||||||
|
|
||||||
|
Public entry points:
|
||||||
|
from agent.evolution import get_evolution_config
|
||||||
|
from agent.evolution.trigger import start_evolution_trigger, note_user_turn
|
||||||
|
"""
|
||||||
|
|
||||||
|
from agent.evolution.config import EvolutionConfig, get_evolution_config
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EvolutionConfig",
|
||||||
|
"get_evolution_config",
|
||||||
|
]
|
||||||
102
agent/evolution/backup.py
Normal file
102
agent/evolution/backup.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""File backup / rollback support for self-evolution.
|
||||||
|
|
||||||
|
Before the evolution agent edits MEMORY.md or a skill file, we snapshot the
|
||||||
|
current state into ``memory/.evolution_backups/<backup_id>/`` so a later "undo"
|
||||||
|
can restore it. File-level restore only — simple and reliable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
_BACKUP_DIRNAME = ".evolution_backups"
|
||||||
|
_MANIFEST_NAME = "manifest.json"
|
||||||
|
# Keep only the most recent N backups to bound disk usage.
|
||||||
|
_MAX_BACKUPS = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _backups_root(workspace_dir: Path) -> Path:
|
||||||
|
return Path(workspace_dir) / "memory" / _BACKUP_DIRNAME
|
||||||
|
|
||||||
|
|
||||||
|
def create_backup(workspace_dir: Path, files: List[Path]) -> Optional[str]:
|
||||||
|
"""Snapshot ``files`` (those that exist) under a new backup id.
|
||||||
|
|
||||||
|
Returns the backup_id, or None when there is nothing to back up.
|
||||||
|
"""
|
||||||
|
existing = [Path(f) for f in files if Path(f).exists()]
|
||||||
|
if not existing:
|
||||||
|
return None
|
||||||
|
|
||||||
|
backup_id = datetime.now().strftime("%Y%m%d-%H%M%S-") + str(int(time.time() * 1000) % 1000)
|
||||||
|
root = _backups_root(workspace_dir)
|
||||||
|
target = root / backup_id
|
||||||
|
try:
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
ws = Path(workspace_dir)
|
||||||
|
manifest = []
|
||||||
|
for idx, src in enumerate(existing):
|
||||||
|
# Store under a flat index plus the relative path so restore knows
|
||||||
|
# where it came from, even for nested skill files.
|
||||||
|
try:
|
||||||
|
rel = str(src.relative_to(ws))
|
||||||
|
except ValueError:
|
||||||
|
rel = src.name
|
||||||
|
dst = target / f"{idx}.bak"
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
manifest.append({"rel": rel, "bak": f"{idx}.bak"})
|
||||||
|
(target / _MANIFEST_NAME).write_text(
|
||||||
|
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
_prune_old_backups(root)
|
||||||
|
# Caller logs a combined backup+review line; keep this at debug.
|
||||||
|
logger.debug(f"[Evolution] Created backup {backup_id} ({len(manifest)} file(s))")
|
||||||
|
return backup_id
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Evolution] Failed to create backup: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def restore_backup(workspace_dir: Path, backup_id: str) -> bool:
|
||||||
|
"""Restore all files captured under ``backup_id``. Returns success."""
|
||||||
|
if not backup_id:
|
||||||
|
return False
|
||||||
|
target = _backups_root(workspace_dir) / backup_id
|
||||||
|
manifest_path = target / _MANIFEST_NAME
|
||||||
|
if not manifest_path.exists():
|
||||||
|
logger.warning(f"[Evolution] Backup not found: {backup_id}")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
ws = Path(workspace_dir)
|
||||||
|
for entry in manifest:
|
||||||
|
bak = target / entry["bak"]
|
||||||
|
dst = ws / entry["rel"]
|
||||||
|
if bak.exists():
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(bak, dst)
|
||||||
|
logger.info(f"[Evolution] Restored backup {backup_id} ({len(manifest)} file(s))")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Evolution] Failed to restore backup {backup_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_old_backups(root: Path) -> None:
|
||||||
|
"""Drop the oldest backups beyond _MAX_BACKUPS (sorted by name = chronological)."""
|
||||||
|
try:
|
||||||
|
dirs = sorted(
|
||||||
|
[d for d in root.iterdir() if d.is_dir()],
|
||||||
|
key=lambda p: p.name,
|
||||||
|
)
|
||||||
|
for old in dirs[:-_MAX_BACKUPS]:
|
||||||
|
shutil.rmtree(old, ignore_errors=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Evolution] Backup prune skipped: {e}")
|
||||||
76
agent/evolution/config.py
Normal file
76
agent/evolution/config.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Configuration for the self-evolution subsystem.
|
||||||
|
|
||||||
|
Reads flat ``self_evolution_*`` keys from config.json. All fields have safe
|
||||||
|
defaults so the feature degrades gracefully when keys are absent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# Defaults — conservative (see executor module docstring). Disabled by default
|
||||||
|
# until release; enable via ``self_evolution_enabled``.
|
||||||
|
DEFAULT_ENABLED = False
|
||||||
|
DEFAULT_IDLE_MINUTES = 10
|
||||||
|
DEFAULT_MIN_TURNS = 6
|
||||||
|
# Max review steps for the isolated evolution agent. Kept small (not exposed as
|
||||||
|
# config): the review is meant to be cheap and focused, not a long autonomous run.
|
||||||
|
DEFAULT_MAX_STEPS = 12
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EvolutionConfig:
|
||||||
|
"""Resolved self-evolution settings."""
|
||||||
|
|
||||||
|
enabled: bool = DEFAULT_ENABLED
|
||||||
|
idle_minutes: int = DEFAULT_IDLE_MINUTES
|
||||||
|
min_turns: int = DEFAULT_MIN_TURNS
|
||||||
|
max_steps: int = DEFAULT_MAX_STEPS
|
||||||
|
|
||||||
|
@property
|
||||||
|
def idle_seconds(self) -> int:
|
||||||
|
return max(60, self.idle_minutes * 60)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_bool(value: Any, fallback: bool) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
v = value.strip().lower()
|
||||||
|
if v in ("true", "1", "yes", "on"):
|
||||||
|
return True
|
||||||
|
if v in ("false", "0", "no", "off"):
|
||||||
|
return False
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _as_pos_int(value: Any, fallback: int) -> int:
|
||||||
|
try:
|
||||||
|
n = int(value)
|
||||||
|
return n if n > 0 else fallback
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def get_evolution_config() -> EvolutionConfig:
|
||||||
|
"""Build EvolutionConfig from the live config.json ``self_evolution_*`` keys."""
|
||||||
|
try:
|
||||||
|
from config import conf
|
||||||
|
c = conf()
|
||||||
|
except Exception:
|
||||||
|
c = {}
|
||||||
|
|
||||||
|
def _get(key, default):
|
||||||
|
try:
|
||||||
|
return c.get(key, default)
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
return EvolutionConfig(
|
||||||
|
enabled=_as_bool(_get("self_evolution_enabled", None), DEFAULT_ENABLED),
|
||||||
|
idle_minutes=_as_pos_int(_get("self_evolution_idle_minutes", None), DEFAULT_IDLE_MINUTES),
|
||||||
|
min_turns=_as_pos_int(_get("self_evolution_min_turns", None), DEFAULT_MIN_TURNS),
|
||||||
|
max_steps=DEFAULT_MAX_STEPS,
|
||||||
|
)
|
||||||
556
agent/evolution/executor.py
Normal file
556
agent/evolution/executor.py
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
"""Self-evolution executor.
|
||||||
|
|
||||||
|
Runs an isolated review agent over an idle conversation's transcript and, if a
|
||||||
|
clear signal is found, lets it edit memory / skills via a restricted toolset.
|
||||||
|
Conservative by design: most runs return ``[SILENT]`` and change nothing.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Build a transcript from the session's new (since last pass) messages.
|
||||||
|
2. Snapshot MEMORY.md + daily file + editable skills (for undo) -> backup_id.
|
||||||
|
3. Run an isolated agent (same model, restricted tools, evolution prompt).
|
||||||
|
4. If output is [SILENT], or no workspace file actually changed -> done.
|
||||||
|
5. Otherwise -> record to the evolution log, inject an [EVOLUTION] note into
|
||||||
|
the user session (so the main agent can honor "undo"), and push the
|
||||||
|
summary to the user's channel.
|
||||||
|
|
||||||
|
Reuses existing infrastructure (AgentBridge.create_agent, ToolManager,
|
||||||
|
remember_scheduled_output, channel_factory) rather than introducing a fork.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
from agent.evolution.backup import create_backup
|
||||||
|
from agent.evolution.config import get_evolution_config
|
||||||
|
from agent.evolution.prompts import (
|
||||||
|
EVOLUTION_MARKER,
|
||||||
|
EVOLUTION_SYSTEM_PROMPT,
|
||||||
|
SILENT_TOKEN,
|
||||||
|
build_review_user_message,
|
||||||
|
)
|
||||||
|
from agent.evolution.record import append_session_evolution
|
||||||
|
|
||||||
|
# Tools the isolated evolution agent is allowed to use. Everything else is
|
||||||
|
# withheld so a review pass can only read context, run workspace scripts, and
|
||||||
|
# edit memory/skill files. bash is needed by skill-creator's init script and is
|
||||||
|
# confined to the workspace by _BashWorkspaceGuard.
|
||||||
|
_ALLOWED_TOOLS = {"read", "write", "edit", "ls", "bash", "memory_search", "memory_get"}
|
||||||
|
|
||||||
|
# Cap concurrent evolution passes so a burst of idle sessions can't spawn many
|
||||||
|
# background model runs at once. Extra sessions simply wait for the next scan.
|
||||||
|
_MAX_CONCURRENT = 2
|
||||||
|
_running_lock = threading.Lock()
|
||||||
|
_running_count = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _builtin_skill_names() -> set:
|
||||||
|
"""Names of skills shipped with the product (project-root ``skills/``).
|
||||||
|
|
||||||
|
These are protected: the evolution agent must never edit them, even though
|
||||||
|
a same-named copy exists in the workspace at runtime. The project dir is the
|
||||||
|
authoritative list of what counts as built-in.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# executor.py -> agent/evolution -> agent -> project root
|
||||||
|
project_root = Path(__file__).resolve().parents[2]
|
||||||
|
builtin_dir = project_root / "skills"
|
||||||
|
if not builtin_dir.is_dir():
|
||||||
|
return set()
|
||||||
|
names = set()
|
||||||
|
for entry in builtin_dir.iterdir():
|
||||||
|
if entry.is_dir() and not entry.name.startswith("."):
|
||||||
|
names.add(entry.name)
|
||||||
|
return names
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_transcript(messages: List[dict], max_chars: int = 12000) -> str:
|
||||||
|
"""Render the session messages into a compact text transcript."""
|
||||||
|
lines: List[str] = []
|
||||||
|
for msg in messages:
|
||||||
|
role = msg.get("role", "")
|
||||||
|
if role not in ("user", "assistant"):
|
||||||
|
continue
|
||||||
|
content = msg.get("content", "")
|
||||||
|
text = _extract_text(content)
|
||||||
|
if not text.strip():
|
||||||
|
continue
|
||||||
|
speaker = "User" if role == "user" else "Assistant"
|
||||||
|
lines.append(f"{speaker}: {text.strip()}")
|
||||||
|
transcript = "\n".join(lines)
|
||||||
|
# Keep the most RECENT context if oversized (tail is most relevant).
|
||||||
|
if len(transcript) > max_chars:
|
||||||
|
transcript = "...(earlier omitted)...\n" + transcript[-max_chars:]
|
||||||
|
return transcript
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_text(content) -> str:
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts = []
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, dict) and block.get("type") == "text":
|
||||||
|
parts.append(block.get("text", ""))
|
||||||
|
elif isinstance(block, str):
|
||||||
|
parts.append(block)
|
||||||
|
return "\n".join(parts)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _select_tools(all_tools: list) -> list:
|
||||||
|
return [t for t in all_tools if getattr(t, "name", None) in _ALLOWED_TOOLS]
|
||||||
|
|
||||||
|
|
||||||
|
# Tools whose writes must be confined to the workspace during evolution.
|
||||||
|
_WRITE_TOOLS = {"write", "edit"}
|
||||||
|
|
||||||
|
|
||||||
|
class _WorkspaceWriteGuard:
|
||||||
|
"""Wraps a write/edit tool so it can ONLY write inside the workspace.
|
||||||
|
|
||||||
|
Hard engineering guard (not prompt-based): any write resolving outside the
|
||||||
|
workspace — e.g. the project's bundled ``skills/`` dir — is rejected. This
|
||||||
|
protects built-in skills regardless of what the model attempts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, inner, workspace_dir: str):
|
||||||
|
self._inner = inner
|
||||||
|
self._ws = Path(workspace_dir).resolve()
|
||||||
|
# Mirror the attributes the agent runtime reads off a tool.
|
||||||
|
self.name = inner.name
|
||||||
|
self.description = inner.description
|
||||||
|
self.params = inner.params
|
||||||
|
|
||||||
|
def __getattr__(self, item):
|
||||||
|
return getattr(self._inner, item)
|
||||||
|
|
||||||
|
def execute_tool(self, params):
|
||||||
|
# The agent runtime calls execute_tool (not execute); route it through
|
||||||
|
# our guarded execute so the path checks always run.
|
||||||
|
try:
|
||||||
|
return self.execute(params)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Evolution] guarded tool error: {e}")
|
||||||
|
from agent.tools.base_tool import ToolResult
|
||||||
|
return ToolResult.fail(f"Error: {e}")
|
||||||
|
|
||||||
|
def execute(self, args):
|
||||||
|
path = (args.get("path") or "").strip()
|
||||||
|
if path:
|
||||||
|
try:
|
||||||
|
resolved = Path(self._inner._resolve_path(path)).resolve()
|
||||||
|
from agent.tools.base_tool import ToolResult
|
||||||
|
# Confine writes to the workspace. This protects the product's
|
||||||
|
# bundled skills (which live outside the workspace) from ever
|
||||||
|
# being modified, no matter what path the model attempts.
|
||||||
|
if self._ws not in resolved.parents and resolved != self._ws:
|
||||||
|
return ToolResult.fail(
|
||||||
|
"Error: evolution may only write inside the workspace; "
|
||||||
|
f"path '{path}' is outside and was blocked."
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return self._inner.execute(args)
|
||||||
|
|
||||||
|
|
||||||
|
class _BashWorkspaceGuard:
|
||||||
|
"""Wraps the bash tool so evolution can only run commands inside the
|
||||||
|
workspace.
|
||||||
|
|
||||||
|
Evolution needs bash for skill-creator's init script, but it runs
|
||||||
|
unattended in the background, so a raw shell is too broad. This guard:
|
||||||
|
- forces the command to execute with cwd = workspace,
|
||||||
|
- rejects commands that reference an absolute path or ``..`` segment
|
||||||
|
pointing OUTSIDE the workspace (the common ways to escape it).
|
||||||
|
It is a coarse textual check, not a sandbox — paired with the model's
|
||||||
|
instruction to only run skill-creator scripts, it keeps writes local.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, inner, workspace_dir: str):
|
||||||
|
self._inner = inner
|
||||||
|
self._ws = Path(workspace_dir).resolve()
|
||||||
|
# Pin the shell's working directory to the workspace.
|
||||||
|
try:
|
||||||
|
self._inner.cwd = str(self._ws)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.name = inner.name
|
||||||
|
self.description = inner.description
|
||||||
|
self.params = inner.params
|
||||||
|
|
||||||
|
def __getattr__(self, item):
|
||||||
|
return getattr(self._inner, item)
|
||||||
|
|
||||||
|
def execute_tool(self, params):
|
||||||
|
try:
|
||||||
|
return self.execute(params)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Evolution] guarded bash error: {e}")
|
||||||
|
from agent.tools.base_tool import ToolResult
|
||||||
|
return ToolResult.fail(f"Error: {e}")
|
||||||
|
|
||||||
|
def _escapes_workspace(self, command: str) -> bool:
|
||||||
|
# Absolute paths that are not under the workspace.
|
||||||
|
for tok in re.findall(r'(?:^|\s)(/[^\s\'";|&]+)', command):
|
||||||
|
try:
|
||||||
|
resolved = Path(tok).resolve()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if self._ws != resolved and self._ws not in resolved.parents:
|
||||||
|
return True
|
||||||
|
# Parent-dir traversal that climbs above the workspace.
|
||||||
|
for tok in re.findall(r'[^\s\'";|&]*\.\.[^\s\'";|&]*', command):
|
||||||
|
try:
|
||||||
|
resolved = (self._ws / tok).resolve()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if self._ws != resolved and self._ws not in resolved.parents:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute(self, args):
|
||||||
|
from agent.tools.base_tool import ToolResult
|
||||||
|
command = (args.get("command") or "").strip()
|
||||||
|
if command and self._escapes_workspace(command):
|
||||||
|
return ToolResult.fail(
|
||||||
|
"Error: evolution may only run commands inside the workspace; "
|
||||||
|
"this command references a path outside it and was blocked."
|
||||||
|
)
|
||||||
|
return self._inner.execute(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_tools(tools: list, workspace_dir: str) -> list:
|
||||||
|
"""Wrap write/edit/bash tools with workspace guards; leave others as-is."""
|
||||||
|
guarded = []
|
||||||
|
for t in tools:
|
||||||
|
name = getattr(t, "name", None)
|
||||||
|
if name in _WRITE_TOOLS:
|
||||||
|
guarded.append(_WorkspaceWriteGuard(t, workspace_dir))
|
||||||
|
elif name == "bash":
|
||||||
|
guarded.append(_BashWorkspaceGuard(t, workspace_dir))
|
||||||
|
else:
|
||||||
|
guarded.append(t)
|
||||||
|
return guarded
|
||||||
|
|
||||||
|
|
||||||
|
# Workspace subtrees worth watching for evolution-induced changes. AGENT.md is
|
||||||
|
# watched too: evolution may rarely refine the assistant's persona/style there.
|
||||||
|
_WATCH_SUBDIRS = ("MEMORY.md", "AGENT.md", "skills", "knowledge", "output")
|
||||||
|
# Subpaths under memory/ to ignore: evolution's own bookkeeping + the nightly
|
||||||
|
# dream diary, none of which count as a user-facing change signal.
|
||||||
|
_MEMORY_IGNORE = (".evolution_backups", "dreams", "evolution")
|
||||||
|
# Files the skill subsystem maintains automatically (the enable/disable index).
|
||||||
|
# Not an evolution result, so a rewrite must not count as a change signal.
|
||||||
|
_WATCH_IGNORE_NAMES = ("skills_config.json",)
|
||||||
|
|
||||||
|
|
||||||
|
def _workspace_snapshot(workspace_dir) -> dict:
|
||||||
|
"""Map relative path -> (mtime, size) for watched files. Cheap, no reads."""
|
||||||
|
ws = Path(workspace_dir)
|
||||||
|
snap: dict = {}
|
||||||
|
for name in _WATCH_SUBDIRS:
|
||||||
|
root = ws / name
|
||||||
|
if root.is_file():
|
||||||
|
try:
|
||||||
|
st = root.stat()
|
||||||
|
snap[name] = (st.st_mtime, st.st_size)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
if not root.is_dir():
|
||||||
|
continue
|
||||||
|
for p in root.rglob("*"):
|
||||||
|
if not p.is_file():
|
||||||
|
continue
|
||||||
|
if p.name in _WATCH_IGNORE_NAMES:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
st = p.stat()
|
||||||
|
snap[str(p.relative_to(ws))] = (st.st_mtime, st.st_size)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Watch the daily memory files (memory/*.md and per-user dailies) since
|
||||||
|
# evolution now records learnings there. Skip backups/dreams bookkeeping.
|
||||||
|
mem_dir = ws / "memory"
|
||||||
|
if mem_dir.is_dir():
|
||||||
|
for p in mem_dir.rglob("*.md"):
|
||||||
|
rel_parts = p.relative_to(mem_dir).parts
|
||||||
|
if rel_parts and rel_parts[0] in _MEMORY_IGNORE:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
st = p.stat()
|
||||||
|
snap[str(p.relative_to(ws))] = (st.st_mtime, st.st_size)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return snap
|
||||||
|
|
||||||
|
|
||||||
|
def _workspace_changed(workspace_dir, pre: dict) -> bool:
|
||||||
|
"""True if any watched file was added, removed, or modified since ``pre``."""
|
||||||
|
return _workspace_snapshot(workspace_dir) != pre
|
||||||
|
|
||||||
|
|
||||||
|
def run_evolution_for_session(
|
||||||
|
agent_bridge,
|
||||||
|
session_id: str,
|
||||||
|
channel_type: str = "",
|
||||||
|
receiver: str = "",
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
idle_minutes: float = 0.0,
|
||||||
|
) -> bool:
|
||||||
|
"""Run one evolution pass for a session. Returns True if it changed anything.
|
||||||
|
|
||||||
|
Safe to call from a background thread. All failures are swallowed and
|
||||||
|
logged — evolution must never disrupt the main pipeline.
|
||||||
|
"""
|
||||||
|
cfg = get_evolution_config()
|
||||||
|
if not cfg.enabled:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Concurrency gate: bound how many evolution passes run at once.
|
||||||
|
global _running_count
|
||||||
|
with _running_lock:
|
||||||
|
if _running_count >= _MAX_CONCURRENT:
|
||||||
|
logger.info(
|
||||||
|
f"[Evolution] busy ({_running_count}/{_MAX_CONCURRENT} running); "
|
||||||
|
f"skipping session={session_id} this scan"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
_running_count += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
agent = agent_bridge.agents.get(session_id) or agent_bridge.default_agent
|
||||||
|
if not agent:
|
||||||
|
return False
|
||||||
|
|
||||||
|
with agent.messages_lock:
|
||||||
|
all_messages = list(agent.messages)
|
||||||
|
total_msgs = len(all_messages)
|
||||||
|
# In-memory evolution cursor: only review messages added since the last
|
||||||
|
# pass so a long session doesn't re-judge (and re-write) old content.
|
||||||
|
# Stored on the agent instance; lost on restart (acceptable — at worst
|
||||||
|
# one redundant pass right after a restart, gated by the file-change
|
||||||
|
# check downstream so it won't double-write identical memory).
|
||||||
|
done = int(getattr(agent, "_evo_done_msg_count", 0))
|
||||||
|
if done > total_msgs:
|
||||||
|
done = 0 # history was trimmed/reset; start fresh
|
||||||
|
new_messages = all_messages[done:]
|
||||||
|
transcript = _build_transcript(new_messages)
|
||||||
|
if not transcript.strip():
|
||||||
|
# Routine no-op: the per-minute scan hits every idle session. Advance
|
||||||
|
# the cursor so we don't re-scan the same tail; no log (pure noise).
|
||||||
|
agent._evo_done_msg_count = total_msgs
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[Evolution] ▶ Reviewing session={session_id} "
|
||||||
|
f"(idle {idle_minutes:.1f}min, {len(new_messages)} new/{total_msgs} msgs, "
|
||||||
|
f"~{len(transcript)} chars)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve workspace + files to snapshot for undo.
|
||||||
|
from agent.memory.config import get_default_memory_config
|
||||||
|
mem_cfg = get_default_memory_config()
|
||||||
|
workspace_dir = mem_cfg.get_workspace()
|
||||||
|
if user_id:
|
||||||
|
memory_file = Path(workspace_dir) / "memory" / "users" / user_id / "MEMORY.md"
|
||||||
|
else:
|
||||||
|
memory_file = Path(workspace_dir) / "MEMORY.md"
|
||||||
|
skills_dir = mem_cfg.get_skills_dir()
|
||||||
|
|
||||||
|
# Snapshot MEMORY.md + every NON-protected skill's SKILL.md. Protected
|
||||||
|
# built-in skills are excluded from backup because they must never be
|
||||||
|
# edited in the first place.
|
||||||
|
protected_names = _builtin_skill_names()
|
||||||
|
# Back up both MEMORY.md and today's daily file: evolution now writes to
|
||||||
|
# the daily file, but MEMORY.md is cheap to snapshot and keeps undo safe
|
||||||
|
# if the model ever edits it.
|
||||||
|
today_daily = Path(workspace_dir) / "memory" / (
|
||||||
|
datetime.now().strftime("%Y-%m-%d") + ".md"
|
||||||
|
)
|
||||||
|
if user_id:
|
||||||
|
today_daily = Path(workspace_dir) / "memory" / "users" / user_id / (
|
||||||
|
datetime.now().strftime("%Y-%m-%d") + ".md"
|
||||||
|
)
|
||||||
|
# AGENT.md (persona) is backed up too so a rare persona edit is undoable.
|
||||||
|
# Persona is workspace-global (not per-user): it always lives at the
|
||||||
|
# workspace root, regardless of user_id.
|
||||||
|
agent_file = Path(workspace_dir) / "AGENT.md"
|
||||||
|
backup_files = [Path(memory_file), today_daily, agent_file]
|
||||||
|
if skills_dir.exists():
|
||||||
|
for skill_md in skills_dir.rglob("SKILL.md"):
|
||||||
|
# The skill dir is the SKILL.md's parent (or an ancestor for
|
||||||
|
# collections); guard by checking the immediate top-level dir.
|
||||||
|
try:
|
||||||
|
top = skill_md.relative_to(skills_dir).parts[0]
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
if top in protected_names:
|
||||||
|
continue
|
||||||
|
backup_files.append(skill_md)
|
||||||
|
backup_id = create_backup(workspace_dir, backup_files)
|
||||||
|
_backup_n = sum(1 for f in backup_files if Path(f).exists())
|
||||||
|
|
||||||
|
# Snapshot the whole workspace (path -> mtime/size) so we can reliably
|
||||||
|
# detect ANY file change — including new output files written when
|
||||||
|
# finishing an unfinished task, which are not in backup_files.
|
||||||
|
pre_snapshot = _workspace_snapshot(workspace_dir)
|
||||||
|
|
||||||
|
# Build the isolated review agent: same model, restricted tools, with a
|
||||||
|
# hard guard that confines all writes to the workspace (protects the
|
||||||
|
# project's bundled skills from ever being modified).
|
||||||
|
review_tools = _guard_tools(
|
||||||
|
_select_tools(list(getattr(agent, "tools", []) or [])),
|
||||||
|
str(workspace_dir),
|
||||||
|
)
|
||||||
|
review_agent = agent_bridge.create_agent(
|
||||||
|
system_prompt="",
|
||||||
|
tools=review_tools,
|
||||||
|
description="Self-evolution review agent",
|
||||||
|
max_steps=cfg.max_steps,
|
||||||
|
workspace_dir=str(workspace_dir),
|
||||||
|
skill_manager=getattr(agent, "skill_manager", None),
|
||||||
|
memory_manager=getattr(agent, "memory_manager", None),
|
||||||
|
enable_skills=True,
|
||||||
|
runtime_info=getattr(agent, "runtime_info", None),
|
||||||
|
)
|
||||||
|
# Mark this as a restricted review agent so runtime MCP reconciliation
|
||||||
|
# (ToolManager.sync_mcp_into_agent) will NOT silently re-inject MCP tools
|
||||||
|
# that _select_tools()/_guard_tools() intentionally withheld. Without this
|
||||||
|
# flag the review boundary would be re-opened on the first LLM turn.
|
||||||
|
review_agent._evolution_restricted = True
|
||||||
|
# Reuse the live model so it follows the user's configured model.
|
||||||
|
review_agent.model = agent.model
|
||||||
|
# Inject the evolution task brief AFTER the full system prompt: the agent
|
||||||
|
# gets the full context (tools, workspace, user preferences, memory, time)
|
||||||
|
# AND its evolution-specific instructions on top, instead of one
|
||||||
|
# overwriting the other.
|
||||||
|
review_agent.extra_system_suffix = EVOLUTION_SYSTEM_PROMPT
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[Evolution] backup {backup_id} ({_backup_n} files) → running review agent"
|
||||||
|
)
|
||||||
|
user_msg = build_review_user_message(transcript, protected_skills=list(protected_names))
|
||||||
|
result = review_agent.run_stream(user_msg, clear_history=True)
|
||||||
|
result = (result or "").strip()
|
||||||
|
|
||||||
|
# These messages are now reviewed; advance the cursor so the next pass
|
||||||
|
# only looks at messages added after this point (silent or not).
|
||||||
|
agent._evo_done_msg_count = total_msgs
|
||||||
|
|
||||||
|
# Respect an explicit silent verdict: empty, exactly [SILENT], or text
|
||||||
|
# that STARTS with [SILENT] means the model chose to stay quiet.
|
||||||
|
if not result or result.startswith(SILENT_TOKEN):
|
||||||
|
logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Anti-nag backstop: if the model wrote a summary but actually changed no
|
||||||
|
# watched file, stay silent — never notify about work that didn't happen.
|
||||||
|
if not _workspace_changed(workspace_dir, pre_snapshot):
|
||||||
|
logger.info(
|
||||||
|
f"[Evolution] ✗ session={session_id}: text produced but no file "
|
||||||
|
f"changed — staying silent"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# The model produced a real summary. Strip any stray [SILENT] tokens it
|
||||||
|
# left mid-text, then notify.
|
||||||
|
result = result.replace(SILENT_TOKEN, "").strip()
|
||||||
|
if not result:
|
||||||
|
logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"[Evolution] ✓ session={session_id} evolved:\n{result}")
|
||||||
|
append_session_evolution(workspace_dir, result, backup_id=backup_id, user_id=user_id)
|
||||||
|
# Inject an [EVOLUTION] note so the main agent can honor "undo".
|
||||||
|
_inject_evolution_record(agent_bridge, session_id, channel_type, result, backup_id)
|
||||||
|
# The injection appended its own messages ([SCHEDULED]/[EVOLUTION]).
|
||||||
|
# Advance the cursor past them so the next scan does not treat
|
||||||
|
# evolution's own bookkeeping as new user content and re-trigger.
|
||||||
|
try:
|
||||||
|
with agent.messages_lock:
|
||||||
|
agent._evo_done_msg_count = len(agent.messages)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Push the summary to the user's channel. The "did a file actually
|
||||||
|
# change" gate above is the only throttle we need: real evolutions are
|
||||||
|
# rare, so no extra opt-in switch or daily-count limit is required.
|
||||||
|
if channel_type and receiver:
|
||||||
|
_notify_user(channel_type, receiver, result)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Evolution] Run failed for session={session_id}: {e}")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
with _running_lock:
|
||||||
|
_running_count -= 1
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_evolution_record(
|
||||||
|
agent_bridge, session_id: str, channel_type: str, summary: str, backup_id: Optional[str]
|
||||||
|
) -> None:
|
||||||
|
"""Add an [EVOLUTION] note to the user session so the main agent can undo."""
|
||||||
|
try:
|
||||||
|
note = f"{EVOLUTION_MARKER} {summary}"
|
||||||
|
if backup_id:
|
||||||
|
note += f"\n(backup_id: {backup_id}; to undo, restore this backup)"
|
||||||
|
# Reuse the scheduler-output injection path: isolated execution, only a
|
||||||
|
# compact record lands in the user session.
|
||||||
|
agent_bridge.remember_scheduled_output(
|
||||||
|
session_id=session_id,
|
||||||
|
content=note,
|
||||||
|
channel_type=channel_type,
|
||||||
|
task_description="self-evolution",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Evolution] Failed to inject evolution record: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_user(channel_type: str, receiver: str, summary: str) -> None:
|
||||||
|
"""Push the evolution summary to the user's channel as a new message."""
|
||||||
|
try:
|
||||||
|
from bridge.context import Context, ContextType
|
||||||
|
from bridge.reply import Reply, ReplyType
|
||||||
|
from channel.channel_factory import create_channel
|
||||||
|
|
||||||
|
context = Context(ContextType.TEXT, summary)
|
||||||
|
context["receiver"] = receiver
|
||||||
|
context["isgroup"] = False
|
||||||
|
context["session_id"] = receiver
|
||||||
|
# Channels that reply to an original message need msg=None for a fresh push.
|
||||||
|
if channel_type in ("feishu", "dingtalk", "wecom_bot", "qq"):
|
||||||
|
context["msg"] = None
|
||||||
|
if channel_type == "feishu":
|
||||||
|
context["receive_id_type"] = "open_id"
|
||||||
|
|
||||||
|
channel = create_channel(channel_type)
|
||||||
|
if not channel:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Web is request-response: a background push needs a synthetic request_id
|
||||||
|
# plus a request->session mapping so the channel can route the message to
|
||||||
|
# the user's polling queue (same approach the scheduler uses).
|
||||||
|
if channel_type == "web":
|
||||||
|
import uuid
|
||||||
|
request_id = f"evolution_{uuid.uuid4().hex[:8]}"
|
||||||
|
context["request_id"] = request_id
|
||||||
|
if hasattr(channel, "request_to_session"):
|
||||||
|
channel.request_to_session[request_id] = receiver
|
||||||
|
|
||||||
|
channel.send(Reply(ReplyType.TEXT, summary), context)
|
||||||
|
logger.info(f"[Evolution] Notified user via {channel_type}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Evolution] Failed to notify user: {e}")
|
||||||
170
agent/evolution/prompts.py
Normal file
170
agent/evolution/prompts.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""Prompts for the self-evolution review agent.
|
||||||
|
|
||||||
|
The system prompt is intentionally English-only: it governs the agent's
|
||||||
|
internal reasoning and is more stable / cheaper to maintain in one language.
|
||||||
|
The user-facing summary the agent produces should follow the user's own
|
||||||
|
language (instructed at the end of the prompt).
|
||||||
|
|
||||||
|
Design goals (see ref/hermes-agent background_review for inspiration):
|
||||||
|
- Default to doing NOTHING. Evolution is the exception, not the rule.
|
||||||
|
- Signal types: skill, unfinished task, memory, knowledge.
|
||||||
|
- An explicit "do NOT capture" list to avoid self-poisoning over time.
|
||||||
|
- Generic examples only — never bake in domain-specific business terms.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Sentinel the agent emits when there is nothing worth evolving.
|
||||||
|
SILENT_TOKEN = "[SILENT]"
|
||||||
|
|
||||||
|
# Marker prefix for the evolution record injected into the user session, so the
|
||||||
|
# main chat agent can recognize past evolutions and honor an "undo" request.
|
||||||
|
EVOLUTION_MARKER = "[EVOLUTION]"
|
||||||
|
|
||||||
|
|
||||||
|
EVOLUTION_SYSTEM_PROMPT = """You are a self-evolution review agent for an AI assistant.
|
||||||
|
|
||||||
|
You are given a transcript of a conversation that just went idle. Your job is to
|
||||||
|
decide whether anything from it is worth durably learning so future
|
||||||
|
conversations go better — and if so, to make that change.
|
||||||
|
|
||||||
|
# Top principle: default to doing NOTHING
|
||||||
|
|
||||||
|
Most ordinary conversations need no evolution. Only act when there is a CLEAR
|
||||||
|
signal below. If there is none, reply with exactly `[SILENT]` and stop. Staying
|
||||||
|
silent is the normal, correct outcome — not a failure.
|
||||||
|
|
||||||
|
Greetings, small talk, acknowledgements ("ok", "thanks", "got it"), and casual
|
||||||
|
chat are NOT signals. For these, output exactly `[SILENT]` immediately — do not
|
||||||
|
explore files, do not write a summary, do not be polite. Just `[SILENT]`.
|
||||||
|
|
||||||
|
IMPORTANT: A summary is only allowed if you ACTUALLY made a file change via a
|
||||||
|
tool (write/edit) in this pass. If you did not change any file, you MUST output
|
||||||
|
exactly `[SILENT]` — never describe a change you only intended to make.
|
||||||
|
|
||||||
|
# Signals worth acting on (act only if at least one clearly appears)
|
||||||
|
|
||||||
|
SKILL and UNFINISHED TASK are your PRIMARY value — no other mechanism handles
|
||||||
|
them. When their signal is clear, act; do not be shy here.
|
||||||
|
|
||||||
|
1. SKILL — two cases:
|
||||||
|
a) PATCH an existing skill: a skill used here showed a STRUCTURAL problem (a
|
||||||
|
missing step/section, a wrong or outdated detail, an error in its
|
||||||
|
content), or its OUTPUT repeatedly misses something the user flagged. Read
|
||||||
|
the relevant skill file under the skills directory and make a small
|
||||||
|
incremental edit so it never recurs.
|
||||||
|
b) CREATE a new skill: a clearly reusable, repeatable workflow emerged that
|
||||||
|
no existing skill covers and the user is likely to want again. Follow the
|
||||||
|
`skill-creator` skill's conventions (read its SKILL.md for the required
|
||||||
|
structure), then create `skills/<name>/SKILL.md` by WRITING the file
|
||||||
|
directly with the write tool — this is the simplest reliable path. (bash
|
||||||
|
is available and confined to the workspace if a helper script is truly
|
||||||
|
needed, but a direct write is preferred.) Only create when the workflow is
|
||||||
|
genuinely reusable — not for a one-off task.
|
||||||
|
|
||||||
|
CRITICAL — fix the SOURCE, do not just remember the symptom: when the root
|
||||||
|
cause of a problem lives IN a skill file itself (its instructions, content,
|
||||||
|
or configuration are wrong/outdated), the correct action is to EDIT that
|
||||||
|
skill so the problem cannot recur. Recording the corrected fact in memory
|
||||||
|
does NOT prevent recurrence — only fixing the skill does. Never log "skill X
|
||||||
|
has wrong detail Y" as a memory note in place of editing skill X.
|
||||||
|
|
||||||
|
2. UNFINISHED TASK — a specific deliverable you promised but didn't produce,
|
||||||
|
AND you already have everything needed to finish it. DO IT now with the
|
||||||
|
available tools and produce the result (e.g. write the file you said you'd
|
||||||
|
write). If key info is missing, or the task is merely waiting on the user's
|
||||||
|
reply/decision, do NOTHING and stay [SILENT] — do not nag or ping the user.
|
||||||
|
You only ever notify the user as a side effect of having actually done work.
|
||||||
|
|
||||||
|
3. MEMORY — RARE, last resort. Default to writing NOTHING here. The main
|
||||||
|
assistant already writes memory during the chat, and a nightly pass plus
|
||||||
|
context-overflow saves are dedicated safety nets — so memory is almost always
|
||||||
|
already covered without you. Skip unless the main assistant clearly missed a
|
||||||
|
durable fact that belongs in no skill AND would visibly change future replies.
|
||||||
|
- MEMORY.md is the curated long-term index, auto-loaded into EVERY future
|
||||||
|
conversation. Treat it as precious: edit it in place to CORRECT a wrong
|
||||||
|
fact, or append a new durable preference/decision/lesson — but do so
|
||||||
|
SPARINGLY (a lasting fact, not a passing detail; the nightly pass handles
|
||||||
|
routine consolidation).
|
||||||
|
- For a NEW fact that is important but not yet clearly lasting, append ONE
|
||||||
|
short bullet to today's `memory/YYYY-MM-DD.md` instead. When unsure, the
|
||||||
|
daily file is the safe place — but first ask whether this really belongs
|
||||||
|
in a skill.
|
||||||
|
- PERSONA (AGENT.md) — EXTREMELY rare: only on an explicit, repeated signal
|
||||||
|
about the assistant's own identity/personality/style, make a small edit to
|
||||||
|
AGENT.md; never for user/world facts, and when in doubt do nothing.
|
||||||
|
- Keep it to ONE short bullet. Never write paragraphs, never re-summarize the
|
||||||
|
conversation, never copy what the main assistant already recorded.
|
||||||
|
- If it is already captured anywhere (check MEMORY.md AND the daily file
|
||||||
|
first), do NOTHING.
|
||||||
|
|
||||||
|
4. KNOWLEDGE — only if the conversation produced durable, reusable reference
|
||||||
|
knowledge on a topic (the kind worth looking up again) that the main
|
||||||
|
assistant did NOT already save to `knowledge/`. Add or update the relevant
|
||||||
|
file there. Like memory, this is the exception: skip routine Q&A, and if the
|
||||||
|
topic is already covered in `knowledge/`, do NOTHING rather than duplicate.
|
||||||
|
|
||||||
|
# Do NOT capture (these poison future behavior)
|
||||||
|
|
||||||
|
- Environment failures: missing binaries, unset credentials, uninstalled
|
||||||
|
packages, "command not found". The user can fix these; they are not durable
|
||||||
|
rules.
|
||||||
|
- Negative claims about tools or features ("tool X does not work"). These
|
||||||
|
harden into refusals the agent cites against itself later.
|
||||||
|
- One-off task narratives (e.g. summarizing today's content). Not a class of
|
||||||
|
reusable work.
|
||||||
|
- Transient errors that resolved on retry within the conversation.
|
||||||
|
|
||||||
|
# Execution constraints
|
||||||
|
|
||||||
|
- Before changing memory or a skill, READ the current content first and make a
|
||||||
|
small INCREMENTAL edit. Never fabricate, never rewrite large sections.
|
||||||
|
- AVOID DUPLICATES. Before writing memory, READ both MEMORY.md AND today's
|
||||||
|
daily file `memory/YYYY-MM-DD.md`. If the fact/preference is already recorded
|
||||||
|
in EITHER (even if worded differently), do NOT add it again. The main
|
||||||
|
assistant likely already wrote it during the chat — only add what is
|
||||||
|
genuinely new or a correction not yet reflected anywhere.
|
||||||
|
- You may only edit files inside the workspace. Built-in skills shipped with
|
||||||
|
the product live outside it and are write-protected; do not try to edit them.
|
||||||
|
- Make at most the few edits the signals justify; do not go looking for work.
|
||||||
|
|
||||||
|
# Output
|
||||||
|
|
||||||
|
- Nothing worth evolving -> output exactly `[SILENT]` and nothing else.
|
||||||
|
- Otherwise, after performing the edits, output a short user-facing summary in
|
||||||
|
the SAME LANGUAGE the user speaks in the conversation transcript. Write it for an ordinary user, in plain
|
||||||
|
everyday words — NOT a developer report. No need to expose internal details
|
||||||
|
(file names/paths, system mechanics, etc.). Briefly speak directly TO the user, telling them that you just did a self-learning pass,
|
||||||
|
what you learned, and what you changed in THIS pass. Keep it clear and focused on the key changes (a few lines), and let
|
||||||
|
the user know they can undo it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_review_user_message(transcript: str, protected_skills: list = None) -> str:
|
||||||
|
"""Wrap the conversation transcript as the review agent's user message.
|
||||||
|
|
||||||
|
``protected_skills`` lists skill names that must never be edited (built-in
|
||||||
|
skills shipped with the product). Surfaced so the agent avoids them.
|
||||||
|
"""
|
||||||
|
protected_note = ""
|
||||||
|
if protected_skills:
|
||||||
|
names = ", ".join(sorted(protected_skills))
|
||||||
|
protected_note = (
|
||||||
|
"\n\nPROTECTED skills (built-in — never edit these): "
|
||||||
|
f"{names}\n"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from common import i18n
|
||||||
|
lang_name = "中文" if i18n.is_zh() else "English"
|
||||||
|
except Exception:
|
||||||
|
lang_name = "中文"
|
||||||
|
return (
|
||||||
|
"Here is the conversation transcript that just went idle. Review it per "
|
||||||
|
"your instructions. Acting is the exception: the main value is fixing or "
|
||||||
|
"creating a skill and finishing promised work. Memory and knowledge are "
|
||||||
|
"rare last resorts — stay [SILENT] unless there is a clear, durable signal "
|
||||||
|
"not already covered."
|
||||||
|
f"{protected_note}\n"
|
||||||
|
f"The summary should preferably be written in: {lang_name}\n"
|
||||||
|
"<transcript>\n"
|
||||||
|
f"{transcript}\n"
|
||||||
|
"</transcript>"
|
||||||
|
)
|
||||||
55
agent/evolution/record.py
Normal file
55
agent/evolution/record.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Self-evolution record log.
|
||||||
|
|
||||||
|
Session-level evolutions are appended to their OWN per-day file under
|
||||||
|
``memory/evolution/YYYY-MM-DD.md`` (separate from the nightly Deep Dream diary
|
||||||
|
in ``memory/dreams/``). Each day's file accumulates one short section per
|
||||||
|
evolution pass — tagged with a timestamp and a backup id for undo — so the
|
||||||
|
memory UI can surface "what the agent learned/changed today" on one timeline
|
||||||
|
without ever mixing into the dream diary or the main conversation memory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
def _evolution_dir(workspace_dir: Path, user_id: Optional[str] = None) -> Path:
|
||||||
|
base = Path(workspace_dir) / "memory"
|
||||||
|
if user_id:
|
||||||
|
return base / "users" / user_id / "evolution"
|
||||||
|
return base / "evolution"
|
||||||
|
|
||||||
|
|
||||||
|
def append_session_evolution(
|
||||||
|
workspace_dir: Path,
|
||||||
|
summary: str,
|
||||||
|
backup_id: Optional[str] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Append a session-evolution entry to today's evolution log."""
|
||||||
|
if not summary or not summary.strip():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
evo_dir = _evolution_dir(workspace_dir, user_id)
|
||||||
|
evo_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
log_file = evo_dir / f"{today}.md"
|
||||||
|
|
||||||
|
ts = datetime.now().strftime("%H:%M")
|
||||||
|
header = f"## {ts}"
|
||||||
|
body = summary.strip()
|
||||||
|
if backup_id:
|
||||||
|
body += f"\n\n_backup_id: {backup_id}_"
|
||||||
|
|
||||||
|
# Create with a title if the file is new, otherwise append a section.
|
||||||
|
if not log_file.exists():
|
||||||
|
log_file.write_text(f"# Self-Evolution: {today}\n\n", encoding="utf-8")
|
||||||
|
with open(log_file, "a", encoding="utf-8") as f:
|
||||||
|
f.write(f"\n{header}\n\n{body}\n")
|
||||||
|
logger.info(f"[Evolution] Recorded session evolution to {log_file.name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Evolution] Failed to record session evolution: {e}")
|
||||||
151
agent/evolution/trigger.py
Normal file
151
agent/evolution/trigger.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""Idle-based evolution trigger.
|
||||||
|
|
||||||
|
A single background thread periodically scans live agent sessions and runs an
|
||||||
|
evolution pass for any session that is idle for >= idle_minutes AND has enough
|
||||||
|
accumulated signal, where "enough signal" is EITHER:
|
||||||
|
- >= min_turns user turns since the last evolution, OR
|
||||||
|
- the live context has grown past _CONTEXT_RATIO of the agent's token budget
|
||||||
|
(mirrors how OpenClacky / Claude Code consolidate under context pressure).
|
||||||
|
|
||||||
|
Turn counting is per user turn (not per message), measured from the last
|
||||||
|
evolution (or session start). After a pass runs, the baseline resets so a long
|
||||||
|
session can evolve multiple times without re-judging old content.
|
||||||
|
|
||||||
|
Per-session evolution state is stored on the agent instance via lightweight
|
||||||
|
attributes set by AgentBridge.agent_reply (see _note_user_turn).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
from agent.evolution.config import get_evolution_config
|
||||||
|
from agent.evolution.executor import run_evolution_for_session
|
||||||
|
|
||||||
|
_SCAN_INTERVAL_SECONDS = 60
|
||||||
|
|
||||||
|
# Context-pressure trigger: evolve once the live context exceeds this fraction
|
||||||
|
# of the agent's token budget, even if min_turns hasn't been reached. Kept as a
|
||||||
|
# module constant (not user config) for now. Fallback budget matches
|
||||||
|
# agent_initializer / config.py (agent_max_context_tokens default = 50000).
|
||||||
|
_CONTEXT_RATIO = 0.8
|
||||||
|
_FALLBACK_CONTEXT_BUDGET = 50000
|
||||||
|
|
||||||
|
|
||||||
|
def _context_pressure_reached(agent) -> bool:
|
||||||
|
"""True if the agent's live context exceeds _CONTEXT_RATIO of its budget.
|
||||||
|
|
||||||
|
Uses the agent's own (estimated) token accounting so behavior matches the
|
||||||
|
existing context-trimming path. Best-effort: any error -> False.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with agent.messages_lock:
|
||||||
|
messages = list(agent.messages)
|
||||||
|
if not messages:
|
||||||
|
return False
|
||||||
|
est = sum(agent._estimate_message_tokens(m) for m in messages)
|
||||||
|
budget = getattr(agent, "max_context_tokens", None) or _FALLBACK_CONTEXT_BUDGET
|
||||||
|
return est / budget > _CONTEXT_RATIO
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def note_user_turn(agent, channel_type: str = "", receiver: str = "") -> None:
|
||||||
|
"""Record activity for a session's agent. Called once per real user turn.
|
||||||
|
|
||||||
|
Maintains, on the agent instance:
|
||||||
|
_evo_last_active : epoch seconds of the last user turn
|
||||||
|
_evo_turns : user turns since the last evolution
|
||||||
|
_evo_channel_type : originating channel (for later notify)
|
||||||
|
_evo_receiver : push target for notify
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
agent._evo_last_active = time.time()
|
||||||
|
agent._evo_turns = int(getattr(agent, "_evo_turns", 0)) + 1
|
||||||
|
if channel_type:
|
||||||
|
agent._evo_channel_type = channel_type
|
||||||
|
if receiver:
|
||||||
|
agent._evo_receiver = receiver
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def mark_run_active(agent, active: bool) -> None:
|
||||||
|
"""Flag whether the agent is mid-run, so idle scans skip a busy session.
|
||||||
|
|
||||||
|
Without this, a single run that lasts longer than idle_minutes would let
|
||||||
|
the scanner fire an evolution pass concurrently with the live turn.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
agent._evo_run_active = bool(active)
|
||||||
|
if active:
|
||||||
|
agent._evo_last_active = time.time()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def start_evolution_trigger(agent_bridge) -> None:
|
||||||
|
"""Start the idle-scan thread once per process (idempotent)."""
|
||||||
|
if getattr(agent_bridge, "_evolution_trigger_started", False):
|
||||||
|
return
|
||||||
|
agent_bridge._evolution_trigger_started = True
|
||||||
|
|
||||||
|
t = threading.Thread(
|
||||||
|
target=_scan_loop, args=(agent_bridge,), daemon=True, name="evolution-trigger"
|
||||||
|
)
|
||||||
|
t.start()
|
||||||
|
logger.info("[Evolution] Idle trigger started")
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_loop(agent_bridge) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
time.sleep(_SCAN_INTERVAL_SECONDS)
|
||||||
|
cfg = get_evolution_config()
|
||||||
|
if not cfg.enabled:
|
||||||
|
continue
|
||||||
|
_scan_once(agent_bridge, cfg)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Evolution] Scan loop error: {e}")
|
||||||
|
time.sleep(_SCAN_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_once(agent_bridge, cfg) -> None:
|
||||||
|
now = time.time()
|
||||||
|
# Snapshot to avoid holding the dict while running long evolutions.
|
||||||
|
sessions = list(getattr(agent_bridge, "agents", {}).items())
|
||||||
|
for session_id, agent in sessions:
|
||||||
|
try:
|
||||||
|
# Skip sessions whose agent is mid-run: a long turn must not be
|
||||||
|
# reviewed while it is still producing the answer.
|
||||||
|
if getattr(agent, "_evo_run_active", False):
|
||||||
|
continue
|
||||||
|
last_active = getattr(agent, "_evo_last_active", 0)
|
||||||
|
turns = int(getattr(agent, "_evo_turns", 0))
|
||||||
|
# Enough signal = enough turns OR enough context pressure.
|
||||||
|
enough_signal = turns >= cfg.min_turns or _context_pressure_reached(agent)
|
||||||
|
if not enough_signal:
|
||||||
|
continue
|
||||||
|
idle = now - last_active if last_active > 0 else -1
|
||||||
|
if last_active <= 0 or idle < cfg.idle_seconds:
|
||||||
|
continue
|
||||||
|
|
||||||
|
channel_type = getattr(agent, "_evo_channel_type", "") or ""
|
||||||
|
receiver = getattr(agent, "_evo_receiver", "") or ""
|
||||||
|
|
||||||
|
# Reset baseline BEFORE running so a long pass / new messages during
|
||||||
|
# it don't double-trigger; turns accrue fresh from here.
|
||||||
|
agent._evo_turns = 0
|
||||||
|
|
||||||
|
run_evolution_for_session(
|
||||||
|
agent_bridge,
|
||||||
|
session_id=session_id,
|
||||||
|
channel_type=channel_type,
|
||||||
|
receiver=receiver,
|
||||||
|
idle_minutes=(now - last_active) / 60 if last_active > 0 else 0.0,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Evolution] Failed to evaluate session={session_id}: {e}")
|
||||||
0
agent/knowledge/__init__.py
Normal file
0
agent/knowledge/__init__.py
Normal file
645
agent/knowledge/service.py
Normal file
645
agent/knowledge/service.py
Normal file
@@ -0,0 +1,645 @@
|
|||||||
|
"""
|
||||||
|
Knowledge service for handling knowledge base operations.
|
||||||
|
|
||||||
|
Provides a unified interface for listing, reading, and graphing knowledge files,
|
||||||
|
callable from the web console, API, or CLI.
|
||||||
|
|
||||||
|
Knowledge file layout (under workspace_root):
|
||||||
|
knowledge/index.md
|
||||||
|
knowledge/log.md
|
||||||
|
knowledge/<category>/<slug>.md
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Iterable
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
from config import conf
|
||||||
|
from agent.memory.config import MemoryConfig
|
||||||
|
from agent.memory.manager import MemoryManager
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeService:
|
||||||
|
"""
|
||||||
|
High-level service for knowledge base queries.
|
||||||
|
Operates directly on the filesystem.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PROTECTED_FILES = {"index.md", "log.md"}
|
||||||
|
INVALID_NAME_RE = re.compile(r'[<>:"|?*\x00-\x1f]')
|
||||||
|
IMPORT_EXTENSIONS = {".md", ".txt"}
|
||||||
|
MAX_IMPORT_FILES = 100
|
||||||
|
MAX_IMPORT_FILE_SIZE = 10 * 1024 * 1024
|
||||||
|
MAX_IMPORT_TOTAL_SIZE = 200 * 1024 * 1024
|
||||||
|
|
||||||
|
def __init__(self, workspace_root: str, memory_manager=None):
|
||||||
|
self.workspace_root = os.path.abspath(workspace_root)
|
||||||
|
self.knowledge_dir = os.path.join(self.workspace_root, "knowledge")
|
||||||
|
self._memory_manager = memory_manager
|
||||||
|
|
||||||
|
def _resolve_path(self, rel_path: str, *, kind: Optional[str] = None,
|
||||||
|
allow_missing: bool = True) -> tuple:
|
||||||
|
if not isinstance(rel_path, str) or not rel_path.strip():
|
||||||
|
raise ValueError("path is required")
|
||||||
|
rel_path = rel_path.replace("\\", "/").strip("/")
|
||||||
|
parts = rel_path.split("/")
|
||||||
|
if any(not p or p in (".", "..") or self.INVALID_NAME_RE.search(p) for p in parts):
|
||||||
|
raise ValueError("invalid path")
|
||||||
|
if kind == "document" and not rel_path.lower().endswith(".md"):
|
||||||
|
raise ValueError("document path must end with .md")
|
||||||
|
|
||||||
|
root = Path(self.knowledge_dir).resolve()
|
||||||
|
candidate = root.joinpath(*parts)
|
||||||
|
# Resolve the nearest existing ancestor so a symlink cannot be used
|
||||||
|
# to escape when the final destination does not exist yet.
|
||||||
|
ancestor = candidate
|
||||||
|
while not ancestor.exists() and ancestor != root:
|
||||||
|
ancestor = ancestor.parent
|
||||||
|
try:
|
||||||
|
ancestor.resolve().relative_to(root)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("path outside knowledge dir")
|
||||||
|
if candidate.exists():
|
||||||
|
try:
|
||||||
|
candidate.resolve().relative_to(root)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("path outside knowledge dir")
|
||||||
|
elif not allow_missing:
|
||||||
|
raise FileNotFoundError(f"path not found: {rel_path}")
|
||||||
|
return rel_path, candidate
|
||||||
|
|
||||||
|
def _ensure_not_protected(self, rel_path: str):
|
||||||
|
if rel_path in self.PROTECTED_FILES:
|
||||||
|
raise ValueError(f"protected knowledge file: {rel_path}")
|
||||||
|
|
||||||
|
def _manager(self):
|
||||||
|
if self._memory_manager is None:
|
||||||
|
# Reuse the shared embedding provider selection so knowledge index
|
||||||
|
# sync gets vectors too, instead of degrading to keyword-only.
|
||||||
|
from agent.memory.embedding import create_default_embedding_provider
|
||||||
|
embedding_provider = create_default_embedding_provider()
|
||||||
|
self._memory_manager = MemoryManager(
|
||||||
|
MemoryConfig(workspace_root=self.workspace_root),
|
||||||
|
embedding_provider=embedding_provider,
|
||||||
|
)
|
||||||
|
return self._memory_manager
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run_sync(coro):
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
return asyncio.run(coro)
|
||||||
|
result = []
|
||||||
|
error = []
|
||||||
|
|
||||||
|
def runner():
|
||||||
|
try:
|
||||||
|
result.append(asyncio.run(coro))
|
||||||
|
except Exception as exc:
|
||||||
|
error.append(exc)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=runner)
|
||||||
|
thread.start()
|
||||||
|
thread.join()
|
||||||
|
if error:
|
||||||
|
raise error[0]
|
||||||
|
return result[0] if result else None
|
||||||
|
|
||||||
|
def _sync_index(self, old_paths: Iterable[str], force: bool = False):
|
||||||
|
old_paths = sorted(set(old_paths))
|
||||||
|
if not old_paths and not force:
|
||||||
|
return
|
||||||
|
manager = self._manager()
|
||||||
|
for rel_path in old_paths:
|
||||||
|
manager.storage.delete_by_path(f"knowledge/{rel_path}")
|
||||||
|
manager.mark_dirty()
|
||||||
|
self._run_sync(manager.sync())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_title(md_path: Path, fallback: str) -> str:
|
||||||
|
"""Read a markdown file's H1 title, falling back to the file stem."""
|
||||||
|
try:
|
||||||
|
with open(md_path, "r", encoding="utf-8") as f:
|
||||||
|
for _ in range(20):
|
||||||
|
line = f.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("# "):
|
||||||
|
return stripped[2:].strip() or fallback
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
def rebuild_index_md(self) -> bool:
|
||||||
|
"""Regenerate knowledge/index.md from the actual directory tree.
|
||||||
|
|
||||||
|
Keeps the index in sync with real files so it never drifts or loses
|
||||||
|
documents. Returns True when the file was (re)written.
|
||||||
|
"""
|
||||||
|
root = Path(self.knowledge_dir)
|
||||||
|
if not root.is_dir():
|
||||||
|
return False
|
||||||
|
|
||||||
|
def collect(dir_path: Path) -> list:
|
||||||
|
# Return sorted (rel_path, title) tuples for *.md under dir_path,
|
||||||
|
# excluding protected files at the knowledge root and dot files.
|
||||||
|
entries = []
|
||||||
|
for md in sorted(dir_path.rglob("*.md")):
|
||||||
|
rel = md.relative_to(root).as_posix()
|
||||||
|
if any(part.startswith(".") for part in md.relative_to(root).parts):
|
||||||
|
continue
|
||||||
|
if rel in self.PROTECTED_FILES:
|
||||||
|
continue
|
||||||
|
entries.append((rel, self._extract_title(md, md.stem)))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
all_entries = collect(root)
|
||||||
|
|
||||||
|
def link(rel: str) -> str:
|
||||||
|
# Encode each path segment so spaces / special chars stay valid in
|
||||||
|
# markdown links, while keeping the slashes between segments.
|
||||||
|
encoded = "/".join(quote(part) for part in rel.split("/"))
|
||||||
|
return f"./{encoded}"
|
||||||
|
|
||||||
|
lines = ["# 知识库目录", ""]
|
||||||
|
# Root-level documents first (no category dir).
|
||||||
|
root_docs = [(rel, title) for rel, title in all_entries if "/" not in rel]
|
||||||
|
for rel, title in root_docs:
|
||||||
|
lines.append(f"- [{title}]({link(rel)})")
|
||||||
|
if root_docs:
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Group remaining documents by their top-level category.
|
||||||
|
categories = {}
|
||||||
|
for rel, title in all_entries:
|
||||||
|
if "/" not in rel:
|
||||||
|
continue
|
||||||
|
category = rel.split("/", 1)[0]
|
||||||
|
categories.setdefault(category, []).append((rel, title))
|
||||||
|
|
||||||
|
for category in sorted(categories.keys()):
|
||||||
|
lines.append(f"## {category}")
|
||||||
|
for rel, title in categories[category]:
|
||||||
|
lines.append(f"- [{title}]({link(rel)})")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
content = "\n".join(lines).rstrip() + "\n"
|
||||||
|
index_path = root / "index.md"
|
||||||
|
try:
|
||||||
|
index_path.write_text(content, encoding="utf-8")
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"[KnowledgeService] Failed to rebuild index.md: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _sanitize_document_name(self, filename: str) -> str:
|
||||||
|
name = os.path.basename((filename or "").replace("\\", "/")).strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("filename is required")
|
||||||
|
stem, ext = os.path.splitext(name)
|
||||||
|
if ext.lower() not in self.IMPORT_EXTENSIONS:
|
||||||
|
raise ValueError(f"unsupported file type: {ext or name}")
|
||||||
|
if not stem or stem in (".", "..") or self.INVALID_NAME_RE.search(stem):
|
||||||
|
raise ValueError("invalid filename")
|
||||||
|
safe_name = f"{stem}.md"
|
||||||
|
self._ensure_not_protected(safe_name)
|
||||||
|
return safe_name
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _decode_document_content(content) -> str:
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if not isinstance(content, (bytes, bytearray)):
|
||||||
|
raise ValueError("document content is required")
|
||||||
|
return bytes(content).decode("utf-8-sig", errors="replace")
|
||||||
|
|
||||||
|
def _resolve_import_destination(self, target_category: str, filename: str,
|
||||||
|
conflict_strategy: str) -> tuple:
|
||||||
|
target_rel, target_full = self._resolve_path(target_category, kind="category")
|
||||||
|
if not target_full.is_dir():
|
||||||
|
raise FileNotFoundError(f"category not found: {target_rel}")
|
||||||
|
|
||||||
|
safe_name = self._sanitize_document_name(filename)
|
||||||
|
destination = target_full / safe_name
|
||||||
|
rel_path = f"{target_rel}/{safe_name}"
|
||||||
|
|
||||||
|
if destination.exists():
|
||||||
|
if conflict_strategy == "skip":
|
||||||
|
return rel_path, destination, "skip"
|
||||||
|
if conflict_strategy == "rename":
|
||||||
|
stem = destination.stem
|
||||||
|
suffix = destination.suffix
|
||||||
|
for index in range(1, 1000):
|
||||||
|
candidate = target_full / f"{stem}-{index}{suffix}"
|
||||||
|
if not candidate.exists():
|
||||||
|
candidate_rel = f"{target_rel}/{candidate.name}"
|
||||||
|
return candidate_rel, candidate, "write"
|
||||||
|
raise FileExistsError(f"target already exists: {rel_path}")
|
||||||
|
if conflict_strategy != "overwrite":
|
||||||
|
raise ValueError("invalid conflict strategy")
|
||||||
|
return rel_path, destination, "write"
|
||||||
|
|
||||||
|
def create_document(self, path: str, content: str = "", overwrite: bool = False) -> dict:
|
||||||
|
rel_path, full_path = self._resolve_path(path, kind="document")
|
||||||
|
self._ensure_not_protected(rel_path)
|
||||||
|
if len((content or "").encode("utf-8")) > self.MAX_IMPORT_FILE_SIZE:
|
||||||
|
raise ValueError("file too large")
|
||||||
|
if full_path.exists() and not overwrite:
|
||||||
|
raise FileExistsError(f"target already exists: {rel_path}")
|
||||||
|
old_paths = [rel_path] if full_path.exists() else []
|
||||||
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full_path.write_text(content or "", encoding="utf-8")
|
||||||
|
# Keep index.md in sync before reindexing so it is indexed too.
|
||||||
|
self.rebuild_index_md()
|
||||||
|
self._sync_index(old_paths, force=True)
|
||||||
|
return {"path": rel_path, "created": True, "overwritten": bool(old_paths)}
|
||||||
|
|
||||||
|
def import_documents(self, target_category: str, files: Iterable[dict],
|
||||||
|
conflict_strategy: str = "skip") -> dict:
|
||||||
|
if not isinstance(files, list):
|
||||||
|
raise ValueError("files must be a list")
|
||||||
|
if len(files) > self.MAX_IMPORT_FILES:
|
||||||
|
raise ValueError(f"too many files: max {self.MAX_IMPORT_FILES}")
|
||||||
|
results = []
|
||||||
|
old_paths = []
|
||||||
|
imported = skipped = failed = 0
|
||||||
|
total_size = 0
|
||||||
|
|
||||||
|
for item in files:
|
||||||
|
filename = item.get("filename") if isinstance(item, dict) else None
|
||||||
|
try:
|
||||||
|
content_bytes = item.get("content") if isinstance(item, dict) else None
|
||||||
|
size = len(content_bytes.encode("utf-8")) if isinstance(content_bytes, str) else len(content_bytes or b"")
|
||||||
|
total_size += size
|
||||||
|
if total_size > self.MAX_IMPORT_TOTAL_SIZE:
|
||||||
|
raise ValueError("import batch too large")
|
||||||
|
if size > self.MAX_IMPORT_FILE_SIZE:
|
||||||
|
raise ValueError("file too large")
|
||||||
|
rel_path, destination, mode = self._resolve_import_destination(
|
||||||
|
target_category, filename, conflict_strategy
|
||||||
|
)
|
||||||
|
if mode == "skip":
|
||||||
|
skipped += 1
|
||||||
|
results.append({"filename": filename, "path": rel_path, "status": "skipped",
|
||||||
|
"reason": "target_exists"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
old_exists = destination.exists()
|
||||||
|
content = self._decode_document_content(content_bytes)
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
destination.write_text(content, encoding="utf-8")
|
||||||
|
if old_exists:
|
||||||
|
old_paths.append(rel_path)
|
||||||
|
imported += 1
|
||||||
|
results.append({"filename": filename, "path": rel_path, "status": "imported",
|
||||||
|
"overwritten": old_exists})
|
||||||
|
except Exception as exc:
|
||||||
|
failed += 1
|
||||||
|
results.append({"filename": filename or "", "status": "failed", "reason": str(exc)})
|
||||||
|
|
||||||
|
if imported:
|
||||||
|
# Keep index.md in sync before reindexing so it is indexed too.
|
||||||
|
self.rebuild_index_md()
|
||||||
|
self._sync_index(old_paths, force=True)
|
||||||
|
return {"results": results, "imported": imported, "skipped": skipped, "failed": failed}
|
||||||
|
|
||||||
|
def create_category(self, path: str) -> dict:
|
||||||
|
rel_path, full_path = self._resolve_path(path, kind="category")
|
||||||
|
if full_path.exists():
|
||||||
|
return {"path": rel_path, "created": False, "reason": "already_exists"}
|
||||||
|
full_path.mkdir(parents=True)
|
||||||
|
return {"path": rel_path, "created": True}
|
||||||
|
|
||||||
|
def rename_category(self, path: str, new_path: str) -> dict:
|
||||||
|
old_rel, old_full = self._resolve_path(path, kind="category", allow_missing=False)
|
||||||
|
new_rel, new_full = self._resolve_path(new_path, kind="category")
|
||||||
|
if not old_full.is_dir():
|
||||||
|
raise ValueError(f"not a category: {old_rel}")
|
||||||
|
if new_full.exists():
|
||||||
|
raise FileExistsError(f"target already exists: {new_rel}")
|
||||||
|
old_documents = [str(p.relative_to(old_full)).replace(os.sep, "/")
|
||||||
|
for p in old_full.rglob("*.md") if p.is_file()]
|
||||||
|
new_full.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
old_full.rename(new_full)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"old_path": old_rel, "path": new_rel, "moved": False, "reason": "not_found"}
|
||||||
|
except FileExistsError:
|
||||||
|
raise FileExistsError(f"target already exists: {new_rel}")
|
||||||
|
old_paths = [f"{old_rel}/{p}" for p in old_documents]
|
||||||
|
self._sync_index(old_paths)
|
||||||
|
return {"old_path": old_rel, "path": new_rel, "moved_documents": len(old_documents)}
|
||||||
|
|
||||||
|
def delete_category(self, path: str, confirm: bool = False) -> dict:
|
||||||
|
rel_path, full_path = self._resolve_path(path, kind="category")
|
||||||
|
if not full_path.exists():
|
||||||
|
return {"path": rel_path, "deleted": False, "reason": "not_found"}
|
||||||
|
if not full_path.is_dir():
|
||||||
|
raise ValueError(f"not a category: {rel_path}")
|
||||||
|
knowledge_root = Path(self.knowledge_dir).resolve()
|
||||||
|
documents = [str(p.relative_to(knowledge_root)).replace(os.sep, "/")
|
||||||
|
for p in full_path.rglob("*.md") if p.is_file()]
|
||||||
|
if any(p in self.PROTECTED_FILES for p in documents):
|
||||||
|
raise ValueError("category contains protected knowledge files")
|
||||||
|
if any(full_path.iterdir()) and not confirm:
|
||||||
|
raise ValueError("category is not empty; confirmation is required")
|
||||||
|
try:
|
||||||
|
shutil.rmtree(full_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"path": rel_path, "deleted": False, "reason": "not_found"}
|
||||||
|
self._sync_index(documents)
|
||||||
|
return {"path": rel_path, "deleted": True, "deleted_documents": len(documents)}
|
||||||
|
|
||||||
|
def delete_documents(self, paths: Iterable[str]) -> dict:
|
||||||
|
if not isinstance(paths, list):
|
||||||
|
raise ValueError("paths must be a list")
|
||||||
|
results = []
|
||||||
|
deleted = []
|
||||||
|
for path in paths:
|
||||||
|
rel_path, full_path = self._resolve_path(path, kind="document")
|
||||||
|
self._ensure_not_protected(rel_path)
|
||||||
|
if not full_path.exists():
|
||||||
|
deleted.append(rel_path)
|
||||||
|
results.append({"path": rel_path, "deleted": False, "reason": "not_found"})
|
||||||
|
continue
|
||||||
|
if not full_path.is_file():
|
||||||
|
raise ValueError(f"not a document: {rel_path}")
|
||||||
|
try:
|
||||||
|
full_path.unlink()
|
||||||
|
deleted.append(rel_path)
|
||||||
|
results.append({"path": rel_path, "deleted": True})
|
||||||
|
except FileNotFoundError:
|
||||||
|
deleted.append(rel_path)
|
||||||
|
results.append({"path": rel_path, "deleted": False, "reason": "not_found"})
|
||||||
|
self._sync_index(deleted)
|
||||||
|
return {"results": results, "deleted": sum(1 for item in results if item["deleted"])}
|
||||||
|
|
||||||
|
def move_documents(self, paths: Iterable[str], target_category: str) -> dict:
|
||||||
|
if not isinstance(paths, list):
|
||||||
|
raise ValueError("paths must be a list")
|
||||||
|
target_rel, target_full = self._resolve_path(target_category, kind="category")
|
||||||
|
if not target_full.is_dir():
|
||||||
|
raise FileNotFoundError(f"category not found: {target_rel}")
|
||||||
|
results = []
|
||||||
|
moved_old_paths = []
|
||||||
|
for path in paths:
|
||||||
|
rel_path, full_path = self._resolve_path(path, kind="document")
|
||||||
|
self._ensure_not_protected(rel_path)
|
||||||
|
if not full_path.exists():
|
||||||
|
results.append({"path": rel_path, "moved": False, "reason": "not_found"})
|
||||||
|
continue
|
||||||
|
destination = target_full / full_path.name
|
||||||
|
new_rel = str(destination.relative_to(Path(self.knowledge_dir).resolve())).replace(os.sep, "/")
|
||||||
|
if destination.exists():
|
||||||
|
results.append({"path": rel_path, "moved": False, "reason": "target_exists",
|
||||||
|
"target": new_rel})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
os.link(full_path, destination)
|
||||||
|
full_path.unlink()
|
||||||
|
moved_old_paths.append(rel_path)
|
||||||
|
results.append({"path": rel_path, "moved": True, "target": new_rel})
|
||||||
|
except FileExistsError:
|
||||||
|
results.append({"path": rel_path, "moved": False, "reason": "target_exists",
|
||||||
|
"target": new_rel})
|
||||||
|
except FileNotFoundError:
|
||||||
|
results.append({"path": rel_path, "moved": False, "reason": "not_found"})
|
||||||
|
self._sync_index(moved_old_paths)
|
||||||
|
return {"results": results, "moved": len(moved_old_paths)}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# list — directory tree with stats
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def list_tree(self) -> dict:
|
||||||
|
"""
|
||||||
|
Return the knowledge directory tree grouped by category,
|
||||||
|
supporting arbitrarily nested sub-directories.
|
||||||
|
|
||||||
|
Returns::
|
||||||
|
|
||||||
|
{
|
||||||
|
"tree": [
|
||||||
|
{
|
||||||
|
"dir": "concepts",
|
||||||
|
"files": [
|
||||||
|
{"name": "moe.md", "title": "MoE", "size": 1234},
|
||||||
|
],
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dir": "platform",
|
||||||
|
"files": [],
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"dir": "analysis",
|
||||||
|
"files": [{"name": "perf.md", ...}],
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"stats": {"pages": 15, "size": 32768},
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if not os.path.isdir(self.knowledge_dir):
|
||||||
|
return {"tree": [], "stats": {"pages": 0, "size": 0}, "enabled": conf().get("knowledge", True)}
|
||||||
|
|
||||||
|
stats = {"pages": 0, "size": 0}
|
||||||
|
root_files, tree = self._scan_dir(self.knowledge_dir, stats, is_root=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"root_files": root_files,
|
||||||
|
"tree": tree,
|
||||||
|
"stats": stats,
|
||||||
|
"enabled": conf().get("knowledge", True),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _scan_dir(self, dir_path: str, stats: dict, is_root: bool = False) -> tuple:
|
||||||
|
"""
|
||||||
|
Recursively scan a directory.
|
||||||
|
|
||||||
|
:return: (files, children) where files is a list of .md file dicts
|
||||||
|
in this directory and children is a list of sub-directory nodes.
|
||||||
|
"""
|
||||||
|
files = []
|
||||||
|
children = []
|
||||||
|
for name in sorted(os.listdir(dir_path)):
|
||||||
|
if name.startswith("."):
|
||||||
|
continue
|
||||||
|
full = os.path.join(dir_path, name)
|
||||||
|
if os.path.isdir(full):
|
||||||
|
sub_files, sub_children = self._scan_dir(full, stats)
|
||||||
|
children.append({"dir": name, "files": sub_files, "children": sub_children})
|
||||||
|
elif name.endswith(".md"):
|
||||||
|
size = os.path.getsize(full)
|
||||||
|
if not is_root:
|
||||||
|
stats["pages"] += 1
|
||||||
|
stats["size"] += size
|
||||||
|
# Prefer the H1 heading as a readable title for normal docs.
|
||||||
|
# System files (index.md / log.md) keep their filename so the
|
||||||
|
# tree never hides what they actually are.
|
||||||
|
title = name[:-3]
|
||||||
|
if name not in self.PROTECTED_FILES:
|
||||||
|
try:
|
||||||
|
with open(full, "r", encoding="utf-8") as f:
|
||||||
|
first_line = f.readline().strip()
|
||||||
|
if first_line.startswith("# "):
|
||||||
|
title = first_line[2:].strip() or title
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
files.append({"name": name, "title": title, "size": size})
|
||||||
|
return files, children
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# read — single file content
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def read_file(self, rel_path: str) -> dict:
|
||||||
|
"""
|
||||||
|
Read a single knowledge markdown file.
|
||||||
|
|
||||||
|
:param rel_path: Relative path within knowledge/, e.g. ``concepts/moe.md``
|
||||||
|
:return: dict with ``content`` and ``path``
|
||||||
|
:raises ValueError: if path is invalid or escapes knowledge dir
|
||||||
|
:raises FileNotFoundError: if file does not exist
|
||||||
|
"""
|
||||||
|
rel_path, full_path = self._resolve_path(rel_path, kind="document")
|
||||||
|
if not full_path.is_file():
|
||||||
|
raise FileNotFoundError(f"file not found: {rel_path}")
|
||||||
|
|
||||||
|
with open(full_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
return {"content": content, "path": rel_path}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# graph — nodes and links for visualization
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def build_graph(self) -> dict:
|
||||||
|
"""
|
||||||
|
Parse all knowledge pages and extract cross-reference links.
|
||||||
|
|
||||||
|
Returns::
|
||||||
|
|
||||||
|
{
|
||||||
|
"nodes": [
|
||||||
|
{"id": "concepts/moe.md", "label": "MoE", "category": "concepts"},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
{"source": "concepts/moe.md", "target": "entities/deepseek.md"},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
knowledge_path = Path(self.knowledge_dir)
|
||||||
|
if not knowledge_path.is_dir():
|
||||||
|
return {"nodes": [], "links": []}
|
||||||
|
|
||||||
|
nodes = {}
|
||||||
|
links = []
|
||||||
|
link_re = re.compile(r'\[([^\]]*)\]\(([^)]+\.md)\)')
|
||||||
|
|
||||||
|
for md_file in knowledge_path.rglob("*.md"):
|
||||||
|
rel = str(md_file.relative_to(knowledge_path))
|
||||||
|
if rel in ("index.md", "log.md"):
|
||||||
|
continue
|
||||||
|
parts = rel.split("/")
|
||||||
|
category = parts[0] if len(parts) > 1 else "root"
|
||||||
|
title = md_file.stem.replace("-", " ").title()
|
||||||
|
try:
|
||||||
|
content = md_file.read_text(encoding="utf-8")
|
||||||
|
first_line = content.strip().split("\n")[0]
|
||||||
|
if first_line.startswith("# "):
|
||||||
|
title = first_line[2:].strip()
|
||||||
|
for _, link_target in link_re.findall(content):
|
||||||
|
resolved = (md_file.parent / link_target).resolve()
|
||||||
|
try:
|
||||||
|
target_rel = str(resolved.relative_to(knowledge_path))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if target_rel != rel:
|
||||||
|
links.append({"source": rel, "target": target_rel})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
nodes[rel] = {"id": rel, "label": title, "category": category}
|
||||||
|
|
||||||
|
valid_ids = set(nodes.keys())
|
||||||
|
links = [l for l in links if l["source"] in valid_ids and l["target"] in valid_ids]
|
||||||
|
seen = set()
|
||||||
|
deduped = []
|
||||||
|
for l in links:
|
||||||
|
key = tuple(sorted([l["source"], l["target"]]))
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
deduped.append(l)
|
||||||
|
|
||||||
|
return {"nodes": list(nodes.values()), "links": deduped}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# dispatch — single entry point for protocol messages
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def dispatch(self, action: str, payload: Optional[dict] = None) -> dict:
|
||||||
|
"""
|
||||||
|
Dispatch a knowledge management action.
|
||||||
|
|
||||||
|
:param action: ``list``, ``read``, or ``graph``
|
||||||
|
:param payload: action-specific payload
|
||||||
|
:return: protocol-compatible response dict
|
||||||
|
"""
|
||||||
|
payload = payload or {}
|
||||||
|
try:
|
||||||
|
if action == "list":
|
||||||
|
result = self.list_tree()
|
||||||
|
return {"action": action, "code": 200, "message": "success", "payload": result}
|
||||||
|
|
||||||
|
elif action == "read":
|
||||||
|
path = payload.get("path")
|
||||||
|
if not path:
|
||||||
|
return {"action": action, "code": 400, "message": "path is required", "payload": None}
|
||||||
|
result = self.read_file(path)
|
||||||
|
return {"action": action, "code": 200, "message": "success", "payload": result}
|
||||||
|
|
||||||
|
elif action == "graph":
|
||||||
|
result = self.build_graph()
|
||||||
|
return {"action": action, "code": 200, "message": "success", "payload": result}
|
||||||
|
|
||||||
|
elif action == "create_category":
|
||||||
|
result = self.create_category(payload.get("path"))
|
||||||
|
elif action == "rename_category":
|
||||||
|
result = self.rename_category(payload.get("path"), payload.get("new_path"))
|
||||||
|
elif action == "delete_category":
|
||||||
|
result = self.delete_category(payload.get("path"), payload.get("confirm", False))
|
||||||
|
elif action == "delete_documents":
|
||||||
|
result = self.delete_documents(payload.get("paths") or [])
|
||||||
|
elif action == "move_documents":
|
||||||
|
result = self.move_documents(payload.get("paths") or [], payload.get("target_category"))
|
||||||
|
elif action == "create_document":
|
||||||
|
result = self.create_document(payload.get("path"), payload.get("content", ""),
|
||||||
|
payload.get("overwrite", False))
|
||||||
|
elif action == "import_documents":
|
||||||
|
result = self.import_documents(
|
||||||
|
payload.get("target_category"),
|
||||||
|
payload.get("files") or [],
|
||||||
|
payload.get("conflict_strategy", "skip"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
|
||||||
|
return {"action": action, "code": 200, "message": "success", "payload": result}
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
return {"action": action, "code": 403, "message": str(e), "payload": None}
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
return {"action": action, "code": 404, "message": str(e), "payload": None}
|
||||||
|
except FileExistsError as e:
|
||||||
|
return {"action": action, "code": 409, "message": str(e), "payload": None}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[KnowledgeService] dispatch error: action={action}, error={e}")
|
||||||
|
return {"action": action, "code": 500, "message": str(e), "payload": None}
|
||||||
@@ -7,8 +7,9 @@ conversation history persistence (SQLite).
|
|||||||
|
|
||||||
from agent.memory.manager import MemoryManager
|
from agent.memory.manager import MemoryManager
|
||||||
from agent.memory.config import MemoryConfig, get_default_memory_config, set_global_memory_config
|
from agent.memory.config import MemoryConfig, get_default_memory_config, set_global_memory_config
|
||||||
from agent.memory.embedding import create_embedding_provider
|
from agent.memory.embedding import create_embedding_provider, create_default_embedding_provider
|
||||||
from agent.memory.conversation_store import ConversationStore, get_conversation_store
|
from agent.memory.conversation_store import ConversationStore, get_conversation_store
|
||||||
|
from agent.memory.summarizer import ensure_daily_memory_file
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'MemoryManager',
|
'MemoryManager',
|
||||||
@@ -16,6 +17,8 @@ __all__ = [
|
|||||||
'get_default_memory_config',
|
'get_default_memory_config',
|
||||||
'set_global_memory_config',
|
'set_global_memory_config',
|
||||||
'create_embedding_provider',
|
'create_embedding_provider',
|
||||||
|
'create_default_embedding_provider',
|
||||||
'ConversationStore',
|
'ConversationStore',
|
||||||
'get_conversation_store',
|
'get_conversation_store',
|
||||||
|
'ensure_daily_memory_file',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -48,9 +48,6 @@ class MemoryConfig:
|
|||||||
enable_auto_sync: bool = True
|
enable_auto_sync: bool = True
|
||||||
sync_on_search: bool = True
|
sync_on_search: bool = True
|
||||||
|
|
||||||
# Memory flush config (独立于模型 context window)
|
|
||||||
flush_token_threshold: int = 50000 # 50K tokens 触发 flush
|
|
||||||
flush_turn_threshold: int = 20 # 20 轮对话触发 flush (用户+AI各一条为一轮)
|
|
||||||
|
|
||||||
def get_workspace(self) -> Path:
|
def get_workspace(self) -> Path:
|
||||||
"""Get workspace root directory"""
|
"""Get workspace root directory"""
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Storage path: ~/cow/sessions/conversations.db
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -28,11 +29,13 @@ from common.log import logger
|
|||||||
|
|
||||||
_DDL = """
|
_DDL = """
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
session_id TEXT PRIMARY KEY,
|
session_id TEXT PRIMARY KEY,
|
||||||
channel_type TEXT NOT NULL DEFAULT '',
|
channel_type TEXT NOT NULL DEFAULT '',
|
||||||
created_at INTEGER NOT NULL,
|
title TEXT NOT NULL DEFAULT '',
|
||||||
last_active INTEGER NOT NULL,
|
context_start_seq INTEGER NOT NULL DEFAULT 0,
|
||||||
msg_count INTEGER NOT NULL DEFAULT 0
|
created_at INTEGER NOT NULL,
|
||||||
|
last_active INTEGER NOT NULL,
|
||||||
|
msg_count INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
@@ -42,6 +45,7 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
|
extras TEXT NOT NULL DEFAULT '',
|
||||||
UNIQUE (session_id, seq)
|
UNIQUE (session_id, seq)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -57,6 +61,20 @@ _MIGRATION_ADD_CHANNEL_TYPE = """
|
|||||||
ALTER TABLE sessions ADD COLUMN channel_type TEXT NOT NULL DEFAULT '';
|
ALTER TABLE sessions ADD COLUMN channel_type TEXT NOT NULL DEFAULT '';
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_MIGRATION_ADD_TITLE = """
|
||||||
|
ALTER TABLE sessions ADD COLUMN title TEXT NOT NULL DEFAULT '';
|
||||||
|
"""
|
||||||
|
|
||||||
|
_MIGRATION_ADD_CONTEXT_START_SEQ = """
|
||||||
|
ALTER TABLE sessions ADD COLUMN context_start_seq INTEGER NOT NULL DEFAULT 0;
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Generic JSON sidecar for per-message attachments (TTS audio URL, future use).
|
||||||
|
# Always optional — readers must tolerate missing column / empty / invalid JSON.
|
||||||
|
_MIGRATION_ADD_MSG_EXTRAS = """
|
||||||
|
ALTER TABLE messages ADD COLUMN extras TEXT NOT NULL DEFAULT '';
|
||||||
|
"""
|
||||||
|
|
||||||
DEFAULT_MAX_AGE_DAYS: int = 30
|
DEFAULT_MAX_AGE_DAYS: int = 30
|
||||||
|
|
||||||
|
|
||||||
@@ -92,6 +110,48 @@ def _extract_display_text(content: Any) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# Internal markers written into the session for the agent's own bookkeeping
|
||||||
|
# (scheduler injection / self-evolution undo). They must stay in the stored
|
||||||
|
# content (the LLM reads them, e.g. to find a backup_id for undo) but should
|
||||||
|
# never be shown verbatim to the user in the chat history UI.
|
||||||
|
_SCHEDULED_DISPLAY_MARKERS = ("[SCHEDULED]", "Scheduled task")
|
||||||
|
_EVOLUTION_DISPLAY_MARKER = "[EVOLUTION]"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_internal_user_marker(text: str) -> bool:
|
||||||
|
"""True if a user-turn text is an internal injection marker (hide from UI)."""
|
||||||
|
t = (text or "").lstrip()
|
||||||
|
return any(t.startswith(m) for m in _SCHEDULED_DISPLAY_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_evolution_text(text: str) -> bool:
|
||||||
|
"""True if assistant text is a self-evolution summary (before cleaning)."""
|
||||||
|
return (text or "").lstrip().startswith(_EVOLUTION_DISPLAY_MARKER)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_display_text(text: str) -> str:
|
||||||
|
"""Strip internal markers from assistant text for user-facing display.
|
||||||
|
|
||||||
|
Removes a leading ``[EVOLUTION]`` tag and a trailing ``(backup_id: ...)``
|
||||||
|
undo hint. The raw stored message is untouched, so undo + LLM context still
|
||||||
|
work; only the rendered chat bubble is cleaned.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
cleaned = text
|
||||||
|
stripped = cleaned.lstrip()
|
||||||
|
if stripped.startswith(_EVOLUTION_DISPLAY_MARKER):
|
||||||
|
cleaned = stripped[len(_EVOLUTION_DISPLAY_MARKER):].lstrip()
|
||||||
|
# Drop a trailing backup_id undo hint line, e.g.
|
||||||
|
# "(backup_id: 20260607-...; to undo, restore this backup)"
|
||||||
|
cleaned = re.sub(
|
||||||
|
r"\n*\(backup_id:[^\)]*\)\s*$",
|
||||||
|
"",
|
||||||
|
cleaned,
|
||||||
|
).rstrip()
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
def _extract_tool_calls(content: Any) -> List[Dict[str, Any]]:
|
def _extract_tool_calls(content: Any) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Extract tool_use blocks from an assistant message content.
|
Extract tool_use blocks from an assistant message content.
|
||||||
@@ -106,9 +166,10 @@ def _extract_tool_calls(content: Any) -> List[Dict[str, Any]]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _extract_tool_results(content: Any) -> Dict[str, str]:
|
def _extract_tool_results(content: Any) -> Dict[str, dict]:
|
||||||
"""
|
"""
|
||||||
Extract tool_result blocks from a user message, keyed by tool_use_id.
|
Extract tool_result blocks from a user message, keyed by tool_use_id.
|
||||||
|
Values are {"result": str, "is_error": bool}.
|
||||||
"""
|
"""
|
||||||
if not isinstance(content, list):
|
if not isinstance(content, list):
|
||||||
return {}
|
return {}
|
||||||
@@ -123,12 +184,13 @@ def _extract_tool_results(content: Any) -> Dict[str, str]:
|
|||||||
rb.get("text", "") for rb in result_content
|
rb.get("text", "") for rb in result_content
|
||||||
if isinstance(rb, dict) and rb.get("type") == "text"
|
if isinstance(rb, dict) and rb.get("type") == "text"
|
||||||
)
|
)
|
||||||
results[tool_id] = str(result_content)
|
results[tool_id] = {"result": str(result_content), "is_error": bool(b.get("is_error", False))}
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _group_into_display_turns(
|
def _group_into_display_turns(
|
||||||
rows: List[tuple],
|
rows: List[tuple],
|
||||||
|
include_thinking: bool = True,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Convert raw (role, content_json, created_at) DB rows into display turns.
|
Convert raw (role, content_json, created_at) DB rows into display turns.
|
||||||
@@ -157,20 +219,26 @@ def _group_into_display_turns(
|
|||||||
cur_rest: List[tuple] = []
|
cur_rest: List[tuple] = []
|
||||||
started = False
|
started = False
|
||||||
|
|
||||||
for role, raw_content, created_at in rows:
|
for role, raw_content, created_at, raw_extras in rows:
|
||||||
try:
|
try:
|
||||||
content = json.loads(raw_content)
|
content = json.loads(raw_content)
|
||||||
except Exception:
|
except Exception:
|
||||||
content = raw_content
|
content = raw_content
|
||||||
|
try:
|
||||||
|
extras = json.loads(raw_extras) if raw_extras else {}
|
||||||
|
if not isinstance(extras, dict):
|
||||||
|
extras = {}
|
||||||
|
except Exception:
|
||||||
|
extras = {}
|
||||||
|
|
||||||
if role == "user" and _is_visible_user_message(content):
|
if role == "user" and _is_visible_user_message(content):
|
||||||
if started:
|
if started:
|
||||||
groups.append((cur_user, cur_rest))
|
groups.append((cur_user, cur_rest))
|
||||||
cur_user = (content, created_at)
|
cur_user = (content, created_at, extras)
|
||||||
cur_rest = []
|
cur_rest = []
|
||||||
started = True
|
started = True
|
||||||
else:
|
else:
|
||||||
cur_rest.append((role, content, created_at))
|
cur_rest.append((role, content, created_at, extras))
|
||||||
|
|
||||||
if started:
|
if started:
|
||||||
groups.append((cur_user, cur_rest))
|
groups.append((cur_user, cur_rest))
|
||||||
@@ -183,39 +251,90 @@ def _group_into_display_turns(
|
|||||||
for user_row, rest in groups:
|
for user_row, rest in groups:
|
||||||
# User turn
|
# User turn
|
||||||
if user_row:
|
if user_row:
|
||||||
content, created_at = user_row
|
content, created_at, _u_extras = user_row
|
||||||
text = _extract_display_text(content)
|
text = _extract_display_text(content)
|
||||||
if text:
|
# Hide internal injection markers (scheduler / self-evolution) so the
|
||||||
|
# user never sees a synthetic "[SCHEDULED] self-evolution" bubble;
|
||||||
|
# the assistant reply that follows is still rendered.
|
||||||
|
if text and not _is_internal_user_marker(text):
|
||||||
turns.append({"role": "user", "content": text, "created_at": created_at})
|
turns.append({"role": "user", "content": text, "created_at": created_at})
|
||||||
|
|
||||||
# Collect all tool_calls and tool_results from the rest of the group
|
# Build an ordered list of steps preserving the original sequence:
|
||||||
all_tool_calls: List[Dict[str, Any]] = []
|
# thinking → content → tool_call → content → ...
|
||||||
|
steps: List[Dict[str, Any]] = []
|
||||||
tool_results: Dict[str, str] = {}
|
tool_results: Dict[str, str] = {}
|
||||||
final_text = ""
|
final_text = ""
|
||||||
final_ts: Optional[int] = None
|
final_ts: Optional[int] = None
|
||||||
|
merged_extras: Dict[str, Any] = {}
|
||||||
|
|
||||||
for role, content, created_at in rest:
|
for role, content, created_at, extras in rest:
|
||||||
|
if role == "assistant" and isinstance(extras, dict):
|
||||||
|
merged_extras.update(extras)
|
||||||
if role == "user":
|
if role == "user":
|
||||||
tool_results.update(_extract_tool_results(content))
|
tool_results.update(_extract_tool_results(content))
|
||||||
elif role == "assistant":
|
elif role == "assistant":
|
||||||
tcs = _extract_tool_calls(content)
|
# Walk content blocks in order to preserve interleaving
|
||||||
all_tool_calls.extend(tcs)
|
if isinstance(content, list):
|
||||||
t = _extract_display_text(content)
|
for block in content:
|
||||||
if t:
|
if not isinstance(block, dict):
|
||||||
final_text = t
|
continue
|
||||||
|
btype = block.get("type")
|
||||||
|
if btype == "thinking":
|
||||||
|
if not include_thinking:
|
||||||
|
continue
|
||||||
|
txt = block.get("thinking", "").strip()
|
||||||
|
if txt:
|
||||||
|
steps.append({"type": "thinking", "content": txt})
|
||||||
|
elif btype == "text":
|
||||||
|
txt = block.get("text", "").strip()
|
||||||
|
if txt:
|
||||||
|
steps.append({"type": "content", "content": txt})
|
||||||
|
final_text = txt
|
||||||
|
elif btype == "tool_use":
|
||||||
|
steps.append({
|
||||||
|
"type": "tool",
|
||||||
|
"id": block.get("id", ""),
|
||||||
|
"name": block.get("name", ""),
|
||||||
|
"arguments": block.get("input", {}),
|
||||||
|
})
|
||||||
|
elif isinstance(content, str) and content.strip():
|
||||||
|
steps.append({"type": "content", "content": content.strip()})
|
||||||
|
final_text = content.strip()
|
||||||
final_ts = created_at
|
final_ts = created_at
|
||||||
|
|
||||||
# Attach tool results to their matching tool_call entries
|
# Attach tool results to tool steps
|
||||||
for tc in all_tool_calls:
|
for step in steps:
|
||||||
tc["result"] = tool_results.get(tc.get("id", ""), "")
|
if step["type"] == "tool":
|
||||||
|
tr = tool_results.get(step.get("id", ""), {})
|
||||||
|
if not isinstance(tr, dict):
|
||||||
|
tr = {"result": tr}
|
||||||
|
step["result"] = tr.get("result", "")
|
||||||
|
step["is_error"] = tr.get("is_error", False)
|
||||||
|
|
||||||
if final_text or all_tool_calls:
|
# Detect a self-evolution bubble BEFORE cleaning the marker away, so the
|
||||||
turns.append({
|
# UI can flag it even though the visible text stays clean.
|
||||||
|
is_evolution = _is_evolution_text(final_text)
|
||||||
|
|
||||||
|
# Clean internal markers from the user-facing assistant text. Applies to
|
||||||
|
# both the final content and the mirrored content step so the rendered
|
||||||
|
# bubble shows clean text while the stored message keeps the markers.
|
||||||
|
final_text = _clean_display_text(final_text)
|
||||||
|
for step in steps:
|
||||||
|
if step.get("type") == "content":
|
||||||
|
step["content"] = _clean_display_text(step.get("content", ""))
|
||||||
|
|
||||||
|
if steps or final_text:
|
||||||
|
turn = {
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": final_text,
|
"content": final_text,
|
||||||
"tool_calls": all_tool_calls,
|
"steps": steps,
|
||||||
"created_at": final_ts or (user_row[1] if user_row else 0),
|
"created_at": final_ts or (user_row[1] if user_row else 0),
|
||||||
})
|
}
|
||||||
|
if is_evolution:
|
||||||
|
turn["kind"] = "evolution"
|
||||||
|
if merged_extras:
|
||||||
|
turn["extras"] = merged_extras
|
||||||
|
turns.append(turn)
|
||||||
|
|
||||||
return turns
|
return turns
|
||||||
|
|
||||||
@@ -232,7 +351,7 @@ class ConversationStore:
|
|||||||
|
|
||||||
def __init__(self, db_path: Path):
|
def __init__(self, db_path: Path):
|
||||||
self._db_path = db_path
|
self._db_path = db_path
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.RLock() # Use RLock to allow reentrant locking
|
||||||
self._init_db()
|
self._init_db()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -264,14 +383,21 @@ class ConversationStore:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
|
# Respect context_start_seq: only load messages at or after the boundary
|
||||||
|
ctx_row = conn.execute(
|
||||||
|
"SELECT context_start_seq FROM sessions WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
ctx_start = ctx_row[0] if ctx_row else 0
|
||||||
|
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT seq, role, content
|
SELECT seq, role, content
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE session_id = ?
|
WHERE session_id = ? AND seq >= ?
|
||||||
ORDER BY seq DESC
|
ORDER BY seq DESC
|
||||||
""",
|
""",
|
||||||
(session_id,),
|
(session_id, ctx_start),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -279,10 +405,7 @@ class ConversationStore:
|
|||||||
if not rows:
|
if not rows:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Walk newest-to-oldest counting *visible* user turns (actual user text,
|
visible_turn_seqs: List[int] = []
|
||||||
# not tool_result injections). Record the seq of every visible user
|
|
||||||
# message so we can find a clean cut point later.
|
|
||||||
visible_turn_seqs: List[int] = [] # newest first
|
|
||||||
for seq, role, raw_content in rows:
|
for seq, role, raw_content in rows:
|
||||||
if role != "user":
|
if role != "user":
|
||||||
continue
|
continue
|
||||||
@@ -293,17 +416,11 @@ class ConversationStore:
|
|||||||
if _is_visible_user_message(content):
|
if _is_visible_user_message(content):
|
||||||
visible_turn_seqs.append(seq)
|
visible_turn_seqs.append(seq)
|
||||||
|
|
||||||
# Determine the seq of the oldest visible user message we want to keep.
|
|
||||||
# If the total turns fit within max_turns, keep everything.
|
|
||||||
if len(visible_turn_seqs) <= max_turns:
|
if len(visible_turn_seqs) <= max_turns:
|
||||||
cutoff_seq = None # keep all
|
cutoff_seq = None
|
||||||
else:
|
else:
|
||||||
# The Nth visible user message (0-indexed) is the oldest we keep.
|
|
||||||
cutoff_seq = visible_turn_seqs[max_turns - 1]
|
cutoff_seq = visible_turn_seqs[max_turns - 1]
|
||||||
|
|
||||||
# Build result in chronological order, starting from cutoff.
|
|
||||||
# IMPORTANT: we start exactly at cutoff_seq (the visible user message),
|
|
||||||
# never mid-group, so tool_use / tool_result pairs are always complete.
|
|
||||||
result = []
|
result = []
|
||||||
for seq, role, raw_content in reversed(rows):
|
for seq, role, raw_content in reversed(rows):
|
||||||
if cutoff_seq is not None and seq < cutoff_seq:
|
if cutoff_seq is not None and seq < cutoff_seq:
|
||||||
@@ -312,6 +429,9 @@ class ConversationStore:
|
|||||||
content = json.loads(raw_content)
|
content = json.loads(raw_content)
|
||||||
except Exception:
|
except Exception:
|
||||||
content = raw_content
|
content = raw_content
|
||||||
|
# Strip thinking blocks — they are stored for UI display only
|
||||||
|
if role == "assistant" and isinstance(content, list):
|
||||||
|
content = [b for b in content if b.get("type") != "thinking"]
|
||||||
result.append({"role": role, "content": content})
|
result.append({"role": role, "content": content})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -369,13 +489,15 @@ class ConversationStore:
|
|||||||
content = json.dumps(
|
content = json.dumps(
|
||||||
msg.get("content", ""), ensure_ascii=False
|
msg.get("content", ""), ensure_ascii=False
|
||||||
)
|
)
|
||||||
|
extras_obj = msg.get("extras") or {}
|
||||||
|
extras = json.dumps(extras_obj, ensure_ascii=False) if extras_obj else ""
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO messages
|
INSERT OR IGNORE INTO messages
|
||||||
(session_id, seq, role, content, created_at)
|
(session_id, seq, role, content, created_at, extras)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(session_id, next_seq, role, content, now),
|
(session_id, next_seq, role, content, now, extras),
|
||||||
)
|
)
|
||||||
next_seq += 1
|
next_seq += 1
|
||||||
|
|
||||||
@@ -389,9 +511,123 @@ class ConversationStore:
|
|||||||
""",
|
""",
|
||||||
(session_id, session_id),
|
(session_id, session_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Auto-generate title from the first visible user message
|
||||||
|
cur_title = conn.execute(
|
||||||
|
"SELECT title FROM sessions WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
if cur_title and not cur_title[0]:
|
||||||
|
for msg in messages:
|
||||||
|
if msg.get("role") == "user":
|
||||||
|
content = msg.get("content", "")
|
||||||
|
text = _extract_display_text(content)
|
||||||
|
if text:
|
||||||
|
title = text[:50].split("\n")[0]
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET title = ? WHERE session_id = ?",
|
||||||
|
(title, session_id),
|
||||||
|
)
|
||||||
|
break
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def clear_context(self, session_id: str) -> int:
|
||||||
|
"""
|
||||||
|
Set the context boundary to after the current last message.
|
||||||
|
Messages before this boundary are still stored but excluded from LLM context.
|
||||||
|
|
||||||
|
Returns the new context_start_seq value.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
with conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT COALESCE(MAX(seq), -1) FROM messages WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
new_start = row[0] + 1
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET context_start_seq = ? WHERE session_id = ?",
|
||||||
|
(new_start, session_id),
|
||||||
|
)
|
||||||
|
return new_start
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_context_start_seq(self, session_id: str) -> int:
|
||||||
|
"""Return the context_start_seq for a session (0 if not set)."""
|
||||||
|
with self._lock:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT context_start_seq FROM sessions WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
return row[0] if row else 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_latest_pair_seqs(self, session_id: str) -> Dict[str, Optional[int]]:
|
||||||
|
"""Return the seq numbers of the latest visible user message and the
|
||||||
|
latest assistant message in a session.
|
||||||
|
|
||||||
|
A "visible" user message is one whose content is real user text
|
||||||
|
(not just a tool_result block), so tool-execution turns do not
|
||||||
|
shadow the actual user query.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys ``user_seq`` and ``bot_seq``; either may be None
|
||||||
|
when no matching message exists.
|
||||||
|
"""
|
||||||
|
result: Dict[str, Optional[int]] = {"user_seq": None, "bot_seq": None}
|
||||||
|
with self._lock:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
# Latest assistant message (cheap: single row by seq DESC).
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT seq FROM messages "
|
||||||
|
"WHERE session_id = ? AND role = 'assistant' "
|
||||||
|
"ORDER BY seq DESC LIMIT 1",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
result["bot_seq"] = int(row[0])
|
||||||
|
|
||||||
|
# Latest visible user message: scan recent user rows and
|
||||||
|
# skip pure tool_result entries.
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT seq, content FROM messages "
|
||||||
|
"WHERE session_id = ? AND role = 'user' "
|
||||||
|
"ORDER BY seq DESC LIMIT 20",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
for seq, content_raw in rows:
|
||||||
|
try:
|
||||||
|
content = json.loads(content_raw)
|
||||||
|
except Exception:
|
||||||
|
result["user_seq"] = int(seq)
|
||||||
|
break
|
||||||
|
if isinstance(content, list):
|
||||||
|
has_text = any(
|
||||||
|
isinstance(b, dict) and b.get("type") == "text"
|
||||||
|
for b in content
|
||||||
|
)
|
||||||
|
has_tool_result = any(
|
||||||
|
isinstance(b, dict) and b.get("type") == "tool_result"
|
||||||
|
for b in content
|
||||||
|
)
|
||||||
|
if has_text and not has_tool_result:
|
||||||
|
result["user_seq"] = int(seq)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
result["user_seq"] = int(seq)
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return result
|
||||||
|
|
||||||
def clear_session(self, session_id: str) -> None:
|
def clear_session(self, session_id: str) -> None:
|
||||||
"""Delete all messages and the session record for a given session_id."""
|
"""Delete all messages and the session record for a given session_id."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -407,9 +643,214 @@ class ConversationStore:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def delete_message_pair(self, session_id: str, user_seq: int, delete_user: bool = True, cascade: bool = False) -> int:
|
||||||
|
"""Delete a user message and/or its corresponding assistant reply.
|
||||||
|
|
||||||
|
The assistant reply is identified as all messages between user_seq
|
||||||
|
and the next visible user message (or end of session).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session identifier.
|
||||||
|
user_seq: The seq number of the user message.
|
||||||
|
delete_user: If True (default), delete the user message too.
|
||||||
|
If False, only delete assistant reply (for regenerate scenarios).
|
||||||
|
cascade: If True, also delete all subsequent turns after this one.
|
||||||
|
Used by edit-message which removes this turn and everything after.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of message rows deleted.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
with conn:
|
||||||
|
# Verify this is a user message
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT role FROM messages WHERE session_id = ? AND seq = ?",
|
||||||
|
(session_id, user_seq),
|
||||||
|
).fetchone()
|
||||||
|
if not row or row[0] != "user":
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if cascade:
|
||||||
|
# Delete from this message to end of session
|
||||||
|
start_seq = user_seq if delete_user else user_seq + 1
|
||||||
|
end_seq_row = conn.execute(
|
||||||
|
"SELECT MAX(seq) FROM messages WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
end_seq = (end_seq_row[0] or user_seq) + 1
|
||||||
|
else:
|
||||||
|
# Find the next visible user message seq (exclude tool_result)
|
||||||
|
# Use batched query to avoid loading too many rows at once
|
||||||
|
next_user_seq = None
|
||||||
|
batch_size = 100
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
batch = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT seq, content FROM messages
|
||||||
|
WHERE session_id = ? AND seq > ? AND role = 'user'
|
||||||
|
ORDER BY seq ASC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
(session_id, user_seq, batch_size, offset),
|
||||||
|
).fetchall()
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
for seq, content in batch:
|
||||||
|
try:
|
||||||
|
content_obj = json.loads(content)
|
||||||
|
except Exception:
|
||||||
|
content_obj = content
|
||||||
|
if _is_visible_user_message(content_obj):
|
||||||
|
next_user_seq = seq
|
||||||
|
break
|
||||||
|
if next_user_seq is not None:
|
||||||
|
break
|
||||||
|
offset += batch_size
|
||||||
|
|
||||||
|
# Determine the end boundary for deletion
|
||||||
|
if next_user_seq is not None:
|
||||||
|
end_seq = next_user_seq
|
||||||
|
else:
|
||||||
|
end_seq_row = conn.execute(
|
||||||
|
"SELECT MAX(seq) FROM messages WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
end_seq = (end_seq_row[0] or user_seq) + 1
|
||||||
|
|
||||||
|
# Determine the start boundary for deletion
|
||||||
|
start_seq = user_seq if delete_user else user_seq + 1
|
||||||
|
|
||||||
|
# Delete messages from start_seq to end_seq (exclusive)
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM messages WHERE session_id = ? AND seq >= ? AND seq < ?",
|
||||||
|
(session_id, start_seq, end_seq),
|
||||||
|
)
|
||||||
|
deleted = cur.rowcount
|
||||||
|
|
||||||
|
# Update session msg_count
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE sessions
|
||||||
|
SET msg_count = (
|
||||||
|
SELECT COUNT(*) FROM messages WHERE session_id = ?
|
||||||
|
)
|
||||||
|
WHERE session_id = ?
|
||||||
|
""",
|
||||||
|
(session_id, session_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def prune_scheduled_messages(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
keep_last_n: int,
|
||||||
|
markers: Optional[List[str]] = None,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Keep at most ``keep_last_n`` scheduler-injected user/assistant pairs in
|
||||||
|
the session, deleting the older ones.
|
||||||
|
|
||||||
|
A scheduler-injected pair is identified by a user message whose first
|
||||||
|
text block starts with one of ``markers``; the immediately following
|
||||||
|
assistant message (next seq) is treated as its paired output.
|
||||||
|
|
||||||
|
Only scheduler-tagged messages are touched; regular user turns are
|
||||||
|
never deleted. Safe to call repeatedly; no-op if nothing to prune.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session to prune.
|
||||||
|
keep_last_n: Maximum scheduler pairs to retain (must be >= 0).
|
||||||
|
markers: Text prefixes that identify scheduler user messages.
|
||||||
|
Defaults to ``["[SCHEDULED]", "Scheduled task"]`` so that
|
||||||
|
pairs written by older versions are also recognised.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of message rows deleted.
|
||||||
|
"""
|
||||||
|
if keep_last_n < 0:
|
||||||
|
keep_last_n = 0
|
||||||
|
if markers is None:
|
||||||
|
markers = ["[SCHEDULED]", "Scheduled task"]
|
||||||
|
|
||||||
|
def _matches_marker(raw_content: str) -> bool:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw_content)
|
||||||
|
except Exception:
|
||||||
|
parsed = raw_content
|
||||||
|
text = _extract_display_text(parsed) if not isinstance(parsed, str) else parsed
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
return any(text.startswith(m) for m in markers)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT seq, role, content
|
||||||
|
FROM messages
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY seq ASC
|
||||||
|
""",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# Find scheduler pairs: each is (user_seq, assistant_seq?)
|
||||||
|
pairs: List[tuple] = [] # list of (user_seq, assistant_seq_or_None)
|
||||||
|
for idx, (seq, role, raw_content) in enumerate(rows):
|
||||||
|
if role != "user" or not _matches_marker(raw_content):
|
||||||
|
continue
|
||||||
|
assistant_seq = None
|
||||||
|
# Pair with the very next message if it's an assistant turn.
|
||||||
|
if idx + 1 < len(rows):
|
||||||
|
next_seq, next_role, _ = rows[idx + 1]
|
||||||
|
if next_role == "assistant":
|
||||||
|
assistant_seq = next_seq
|
||||||
|
pairs.append((seq, assistant_seq))
|
||||||
|
|
||||||
|
if len(pairs) <= keep_last_n:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
to_delete_pairs = pairs[: len(pairs) - keep_last_n]
|
||||||
|
seqs_to_delete: List[int] = []
|
||||||
|
for user_seq, assistant_seq in to_delete_pairs:
|
||||||
|
seqs_to_delete.append(user_seq)
|
||||||
|
if assistant_seq is not None:
|
||||||
|
seqs_to_delete.append(assistant_seq)
|
||||||
|
|
||||||
|
if not seqs_to_delete:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
placeholders = ",".join("?" * len(seqs_to_delete))
|
||||||
|
with conn:
|
||||||
|
conn.execute(
|
||||||
|
f"DELETE FROM messages WHERE session_id = ? AND seq IN ({placeholders})",
|
||||||
|
(session_id, *seqs_to_delete),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE sessions
|
||||||
|
SET msg_count = (
|
||||||
|
SELECT COUNT(*) FROM messages WHERE session_id = ?
|
||||||
|
)
|
||||||
|
WHERE session_id = ?
|
||||||
|
""",
|
||||||
|
(session_id, session_id),
|
||||||
|
)
|
||||||
|
return len(seqs_to_delete)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def cleanup_old_sessions(self, max_age_days: Optional[int] = None) -> int:
|
def cleanup_old_sessions(self, max_age_days: Optional[int] = None) -> int:
|
||||||
"""
|
"""
|
||||||
Delete sessions that have not been active within max_age_days.
|
Delete sessions that have not been active within max_age_days.
|
||||||
|
Web channel sessions are excluded — they are meant to be permanent.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
max_age_days: Override the default retention period.
|
max_age_days: Override the default retention period.
|
||||||
@@ -433,7 +874,8 @@ class ConversationStore:
|
|||||||
try:
|
try:
|
||||||
with conn:
|
with conn:
|
||||||
stale = conn.execute(
|
stale = conn.execute(
|
||||||
"SELECT session_id FROM sessions WHERE last_active < ?",
|
"SELECT session_id FROM sessions "
|
||||||
|
"WHERE last_active < ? AND channel_type != 'web'",
|
||||||
(cutoff,),
|
(cutoff,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
for (sid,) in stale:
|
for (sid,) in stale:
|
||||||
@@ -451,6 +893,55 @@ class ConversationStore:
|
|||||||
logger.info(f"[ConversationStore] Pruned {deleted} expired sessions")
|
logger.info(f"[ConversationStore] Pruned {deleted} expired sessions")
|
||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
|
def attach_extras_to_last_assistant(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
extras: Dict[str, Any],
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Merge ``extras`` into the latest assistant message of a session.
|
||||||
|
|
||||||
|
Used by post-processing (e.g. TTS) that needs to annotate an already
|
||||||
|
persisted bot reply with attachments such as audio URLs.
|
||||||
|
|
||||||
|
Returns the message seq that was updated, or ``None`` if no assistant
|
||||||
|
message exists or the update could not be applied.
|
||||||
|
"""
|
||||||
|
if not extras:
|
||||||
|
return None
|
||||||
|
with self._lock:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT seq, extras FROM messages
|
||||||
|
WHERE session_id = ? AND role = 'assistant'
|
||||||
|
ORDER BY seq DESC LIMIT 1
|
||||||
|
""",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
seq, raw = row
|
||||||
|
try:
|
||||||
|
cur = json.loads(raw) if raw else {}
|
||||||
|
if not isinstance(cur, dict):
|
||||||
|
cur = {}
|
||||||
|
except Exception:
|
||||||
|
cur = {}
|
||||||
|
cur.update(extras)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE messages SET extras = ? WHERE session_id = ? AND seq = ?",
|
||||||
|
(json.dumps(cur, ensure_ascii=False), session_id, seq),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return seq
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[ConversationStore] attach_extras failed: {e}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def load_history_page(
|
def load_history_page(
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
@@ -492,19 +983,75 @@ class ConversationStore:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
try:
|
try:
|
||||||
rows = conn.execute(
|
ctx_row = conn.execute(
|
||||||
"""
|
"SELECT context_start_seq FROM sessions WHERE session_id = ?",
|
||||||
SELECT role, content, created_at
|
|
||||||
FROM messages
|
|
||||||
WHERE session_id = ?
|
|
||||||
ORDER BY seq ASC
|
|
||||||
""",
|
|
||||||
(session_id,),
|
(session_id,),
|
||||||
).fetchall()
|
).fetchone()
|
||||||
|
ctx_start = ctx_row[0] if ctx_row else 0
|
||||||
|
|
||||||
|
# extras column is added by migration; tolerate older DBs that
|
||||||
|
# might miss it by falling back to a NULL literal.
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT seq, role, content, created_at, extras
|
||||||
|
FROM messages
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY seq ASC
|
||||||
|
""",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
rows = [
|
||||||
|
(seq, role, content, created_at, "")
|
||||||
|
for (seq, role, content, created_at) in conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT seq, role, content, created_at
|
||||||
|
FROM messages
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY seq ASC
|
||||||
|
""",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
visible = _group_into_display_turns(rows)
|
# Honour the current enable_thinking switch when building display turns
|
||||||
|
# so that toggling it off hides previously-saved thinking blocks too.
|
||||||
|
try:
|
||||||
|
from config import conf
|
||||||
|
include_thinking = bool(conf().get("enable_thinking", False))
|
||||||
|
except Exception:
|
||||||
|
include_thinking = False
|
||||||
|
|
||||||
|
# Strip seq for display grouping, but record max seq per visible user group
|
||||||
|
plain_rows = [
|
||||||
|
(role, content, created_at, extras_raw)
|
||||||
|
for _seq, role, content, created_at, extras_raw in rows
|
||||||
|
]
|
||||||
|
visible = _group_into_display_turns(plain_rows, include_thinking=include_thinking)
|
||||||
|
|
||||||
|
# Build a mapping: find the seq of each visible user message to annotate context boundary.
|
||||||
|
# Walk through rows to find visible user message seqs in order.
|
||||||
|
visible_user_seqs: List[int] = []
|
||||||
|
for seq, role, raw_content, _ts, _extras in rows:
|
||||||
|
if role != "user":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
content = json.loads(raw_content)
|
||||||
|
except Exception:
|
||||||
|
content = raw_content
|
||||||
|
if _is_visible_user_message(content):
|
||||||
|
visible_user_seqs.append(seq)
|
||||||
|
|
||||||
|
# Each pair of display turns (user+assistant) corresponds to a visible user seq.
|
||||||
|
# Mark which turns are before the context boundary.
|
||||||
|
user_turn_idx = 0
|
||||||
|
for turn in visible:
|
||||||
|
if turn["role"] == "user" and user_turn_idx < len(visible_user_seqs):
|
||||||
|
turn["_seq"] = visible_user_seqs[user_turn_idx]
|
||||||
|
user_turn_idx += 1
|
||||||
|
|
||||||
total = len(visible)
|
total = len(visible)
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
@@ -513,12 +1060,98 @@ class ConversationStore:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"messages": page_items,
|
"messages": page_items,
|
||||||
|
"context_start_seq": ctx_start,
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
"has_more": offset + page_size < total,
|
"has_more": offset + page_size < total,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def list_sessions(
|
||||||
|
self,
|
||||||
|
channel_type: Optional[str] = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 50,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
List sessions ordered by last_active DESC, with optional channel_type filter.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"sessions": [{session_id, title, created_at, last_active, msg_count}, ...],
|
||||||
|
"total": int,
|
||||||
|
"page": int,
|
||||||
|
"page_size": int,
|
||||||
|
"has_more": bool,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
page = max(1, page)
|
||||||
|
with self._lock:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
if channel_type:
|
||||||
|
total = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM sessions WHERE channel_type = ?",
|
||||||
|
(channel_type,),
|
||||||
|
).fetchone()[0]
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT session_id, title, created_at, last_active, msg_count
|
||||||
|
FROM sessions
|
||||||
|
WHERE channel_type = ?
|
||||||
|
ORDER BY last_active DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
(channel_type, page_size, (page - 1) * page_size),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
total = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM sessions",
|
||||||
|
).fetchone()[0]
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT session_id, title, created_at, last_active, msg_count
|
||||||
|
FROM sessions
|
||||||
|
ORDER BY last_active DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
(page_size, (page - 1) * page_size),
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
sessions = [
|
||||||
|
{
|
||||||
|
"session_id": r[0],
|
||||||
|
"title": r[1],
|
||||||
|
"created_at": r[2],
|
||||||
|
"last_active": r[3],
|
||||||
|
"msg_count": r[4],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"sessions": sessions,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"has_more": (page - 1) * page_size + page_size < total,
|
||||||
|
}
|
||||||
|
|
||||||
|
def rename_session(self, session_id: str, title: str) -> bool:
|
||||||
|
"""Update the title of a session. Returns True if the session existed."""
|
||||||
|
with self._lock:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
with conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE sessions SET title = ? WHERE session_id = ?",
|
||||||
|
(title, session_id),
|
||||||
|
)
|
||||||
|
return cur.rowcount > 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def get_stats(self) -> Dict[str, Any]:
|
def get_stats(self) -> Dict[str, Any]:
|
||||||
"""Return basic stats keyed by channel_type, for monitoring."""
|
"""Return basic stats keyed by channel_type, for monitoring."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -573,6 +1206,32 @@ class ConversationStore:
|
|||||||
logger.info("[ConversationStore] Migrated: added channel_type column")
|
logger.info("[ConversationStore] Migrated: added channel_type column")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[ConversationStore] Migration failed: {e}")
|
logger.warning(f"[ConversationStore] Migration failed: {e}")
|
||||||
|
if "title" not in cols:
|
||||||
|
try:
|
||||||
|
conn.execute(_MIGRATION_ADD_TITLE)
|
||||||
|
conn.commit()
|
||||||
|
logger.info("[ConversationStore] Migrated: added title column")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[ConversationStore] Migration (title) failed: {e}")
|
||||||
|
if "context_start_seq" not in cols:
|
||||||
|
try:
|
||||||
|
conn.execute(_MIGRATION_ADD_CONTEXT_START_SEQ)
|
||||||
|
conn.commit()
|
||||||
|
logger.info("[ConversationStore] Migrated: added context_start_seq column")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[ConversationStore] Migration (context_start_seq) failed: {e}")
|
||||||
|
|
||||||
|
msg_cols = {
|
||||||
|
row[1]
|
||||||
|
for row in conn.execute("PRAGMA table_info(messages)").fetchall()
|
||||||
|
}
|
||||||
|
if "extras" not in msg_cols:
|
||||||
|
try:
|
||||||
|
conn.execute(_MIGRATION_ADD_MSG_EXTRAS)
|
||||||
|
conn.commit()
|
||||||
|
logger.info("[ConversationStore] Migrated: added messages.extras column")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[ConversationStore] Migration (extras) failed: {e}")
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
def _connect(self) -> sqlite3.Connection:
|
||||||
conn = sqlite3.connect(str(self._db_path), timeout=10)
|
conn = sqlite3.connect(str(self._db_path), timeout=10)
|
||||||
@@ -616,3 +1275,4 @@ def get_conversation_store() -> ConversationStore:
|
|||||||
_store_instance = ConversationStore(db_path)
|
_store_instance = ConversationStore(db_path)
|
||||||
logger.debug(f"[ConversationStore] Using shared DB at: {db_path}")
|
logger.debug(f"[ConversationStore] Using shared DB at: {db_path}")
|
||||||
return _store_instance
|
return _store_instance
|
||||||
|
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
"""
|
|
||||||
Embedding providers for memory
|
|
||||||
|
|
||||||
Supports OpenAI and local embedding models
|
|
||||||
"""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
class EmbeddingProvider(ABC):
|
|
||||||
"""Base class for embedding providers"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def embed(self, text: str) -> List[float]:
|
|
||||||
"""Generate embedding for text"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def embed_batch(self, texts: List[str]) -> List[List[float]]:
|
|
||||||
"""Generate embeddings for multiple texts"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@property
|
|
||||||
@abstractmethod
|
|
||||||
def dimensions(self) -> int:
|
|
||||||
"""Get embedding dimensions"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class OpenAIEmbeddingProvider(EmbeddingProvider):
|
|
||||||
"""OpenAI embedding provider using REST API"""
|
|
||||||
|
|
||||||
def __init__(self, model: str = "text-embedding-3-small", api_key: Optional[str] = None, api_base: Optional[str] = None):
|
|
||||||
"""
|
|
||||||
Initialize OpenAI embedding provider
|
|
||||||
|
|
||||||
Args:
|
|
||||||
model: Model name (text-embedding-3-small or text-embedding-3-large)
|
|
||||||
api_key: OpenAI API key
|
|
||||||
api_base: Optional API base URL
|
|
||||||
"""
|
|
||||||
self.model = model
|
|
||||||
self.api_key = api_key
|
|
||||||
self.api_base = api_base or "https://api.openai.com/v1"
|
|
||||||
|
|
||||||
# Validate API key
|
|
||||||
if not self.api_key or self.api_key in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
|
||||||
raise ValueError("OpenAI API key is not configured. Please set 'open_ai_api_key' in config.json")
|
|
||||||
|
|
||||||
# Set dimensions based on model
|
|
||||||
self._dimensions = 1536 if "small" in model else 3072
|
|
||||||
|
|
||||||
def _call_api(self, input_data):
|
|
||||||
"""Call OpenAI embedding API using requests"""
|
|
||||||
import requests
|
|
||||||
|
|
||||||
url = f"{self.api_base}/embeddings"
|
|
||||||
headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": f"Bearer {self.api_key}"
|
|
||||||
}
|
|
||||||
data = {
|
|
||||||
"input": input_data,
|
|
||||||
"model": self.model
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = requests.post(url, headers=headers, json=data, timeout=5)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response.json()
|
|
||||||
except requests.exceptions.ConnectionError as e:
|
|
||||||
raise ConnectionError(f"Failed to connect to OpenAI API at {url}. Please check your network connection and api_base configuration. Error: {str(e)}")
|
|
||||||
except requests.exceptions.Timeout as e:
|
|
||||||
raise TimeoutError(f"OpenAI API request timed out after 10s. Please check your network connection. Error: {str(e)}")
|
|
||||||
except requests.exceptions.HTTPError as e:
|
|
||||||
if e.response.status_code == 401:
|
|
||||||
raise ValueError(f"Invalid OpenAI API key. Please check your 'open_ai_api_key' in config.json")
|
|
||||||
elif e.response.status_code == 429:
|
|
||||||
raise ValueError(f"OpenAI API rate limit exceeded. Please try again later.")
|
|
||||||
else:
|
|
||||||
raise ValueError(f"OpenAI API request failed: {e.response.status_code} - {e.response.text}")
|
|
||||||
|
|
||||||
def embed(self, text: str) -> List[float]:
|
|
||||||
"""Generate embedding for text"""
|
|
||||||
result = self._call_api(text)
|
|
||||||
return result["data"][0]["embedding"]
|
|
||||||
|
|
||||||
def embed_batch(self, texts: List[str]) -> List[List[float]]:
|
|
||||||
"""Generate embeddings for multiple texts"""
|
|
||||||
if not texts:
|
|
||||||
return []
|
|
||||||
|
|
||||||
result = self._call_api(texts)
|
|
||||||
return [item["embedding"] for item in result["data"]]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dimensions(self) -> int:
|
|
||||||
return self._dimensions
|
|
||||||
|
|
||||||
|
|
||||||
# LocalEmbeddingProvider removed - only use OpenAI embedding or keyword search
|
|
||||||
|
|
||||||
|
|
||||||
class EmbeddingCache:
|
|
||||||
"""Cache for embeddings to avoid recomputation"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.cache = {}
|
|
||||||
|
|
||||||
def get(self, text: str, provider: str, model: str) -> Optional[List[float]]:
|
|
||||||
"""Get cached embedding"""
|
|
||||||
key = self._compute_key(text, provider, model)
|
|
||||||
return self.cache.get(key)
|
|
||||||
|
|
||||||
def put(self, text: str, provider: str, model: str, embedding: List[float]):
|
|
||||||
"""Cache embedding"""
|
|
||||||
key = self._compute_key(text, provider, model)
|
|
||||||
self.cache[key] = embedding
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _compute_key(text: str, provider: str, model: str) -> str:
|
|
||||||
"""Compute cache key"""
|
|
||||||
content = f"{provider}:{model}:{text}"
|
|
||||||
return hashlib.md5(content.encode('utf-8')).hexdigest()
|
|
||||||
|
|
||||||
def clear(self):
|
|
||||||
"""Clear cache"""
|
|
||||||
self.cache.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def create_embedding_provider(
|
|
||||||
provider: str = "openai",
|
|
||||||
model: Optional[str] = None,
|
|
||||||
api_key: Optional[str] = None,
|
|
||||||
api_base: Optional[str] = None
|
|
||||||
) -> EmbeddingProvider:
|
|
||||||
"""
|
|
||||||
Factory function to create embedding provider
|
|
||||||
|
|
||||||
Only supports OpenAI embedding via REST API.
|
|
||||||
If initialization fails, caller should fall back to keyword-only search.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
provider: Provider name (only "openai" is supported)
|
|
||||||
model: Model name (default: text-embedding-3-small)
|
|
||||||
api_key: OpenAI API key (required)
|
|
||||||
api_base: API base URL (default: https://api.openai.com/v1)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
EmbeddingProvider instance
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If provider is not "openai" or api_key is missing
|
|
||||||
"""
|
|
||||||
if provider != "openai":
|
|
||||||
raise ValueError(f"Only 'openai' provider is supported, got: {provider}")
|
|
||||||
|
|
||||||
model = model or "text-embedding-3-small"
|
|
||||||
return OpenAIEmbeddingProvider(model=model, api_key=api_key, api_base=api_base)
|
|
||||||
43
agent/memory/embedding/__init__.py
Normal file
43
agent/memory/embedding/__init__.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
Embedding subsystem for memory.
|
||||||
|
|
||||||
|
Public API:
|
||||||
|
create_embedding_provider, EmbeddingProvider, OpenAIEmbeddingProvider,
|
||||||
|
EMBEDDING_VENDORS, EmbeddingCache
|
||||||
|
RebuildResult, clear_index, rebuild_in_process
|
||||||
|
detect_index_dim, cleanup_legacy_state_file
|
||||||
|
"""
|
||||||
|
|
||||||
|
from agent.memory.embedding.provider import (
|
||||||
|
EMBEDDING_VENDORS,
|
||||||
|
DoubaoEmbeddingProvider,
|
||||||
|
EmbeddingCache,
|
||||||
|
EmbeddingProvider,
|
||||||
|
OpenAIEmbeddingProvider,
|
||||||
|
create_embedding_provider,
|
||||||
|
)
|
||||||
|
from agent.memory.embedding.factory import create_default_embedding_provider
|
||||||
|
from agent.memory.embedding.rebuild import (
|
||||||
|
RebuildResult,
|
||||||
|
clear_index,
|
||||||
|
rebuild_in_process,
|
||||||
|
)
|
||||||
|
from agent.memory.embedding.state import (
|
||||||
|
cleanup_legacy_state_file,
|
||||||
|
detect_index_dim,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EMBEDDING_VENDORS",
|
||||||
|
"DoubaoEmbeddingProvider",
|
||||||
|
"EmbeddingCache",
|
||||||
|
"EmbeddingProvider",
|
||||||
|
"OpenAIEmbeddingProvider",
|
||||||
|
"create_embedding_provider",
|
||||||
|
"create_default_embedding_provider",
|
||||||
|
"RebuildResult",
|
||||||
|
"clear_index",
|
||||||
|
"rebuild_in_process",
|
||||||
|
"cleanup_legacy_state_file",
|
||||||
|
"detect_index_dim",
|
||||||
|
]
|
||||||
209
agent/memory/embedding/factory.py
Normal file
209
agent/memory/embedding/factory.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"""
|
||||||
|
Shared embedding provider factory.
|
||||||
|
|
||||||
|
Resolves the embedding provider purely from config.json, so every caller
|
||||||
|
(agent initialization, knowledge base sync, index rebuild, ...) selects the
|
||||||
|
same provider instead of silently degrading to keyword-only search.
|
||||||
|
|
||||||
|
Two paths:
|
||||||
|
A. Default (no `embedding_provider` in config.json):
|
||||||
|
Auto-init OpenAI -> LinkAI fallback.
|
||||||
|
B. Explicit (`embedding_provider` is set):
|
||||||
|
Initialize the requested vendor with unified dim (default per vendor).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
# Track whether the embedding model log has been printed in this process,
|
||||||
|
# so we avoid spamming it once per session/caller.
|
||||||
|
_embedding_logged: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def create_default_embedding_provider():
|
||||||
|
"""Build the embedding provider from config, or None for keyword-only mode."""
|
||||||
|
from config import conf
|
||||||
|
|
||||||
|
explicit_provider = (conf().get("embedding_provider") or "").strip().lower()
|
||||||
|
if not explicit_provider:
|
||||||
|
return _init_legacy_provider()
|
||||||
|
return _init_explicit_provider(explicit_provider)
|
||||||
|
|
||||||
|
|
||||||
|
def _init_legacy_provider():
|
||||||
|
"""Legacy auto-init path: OpenAI -> LinkAI."""
|
||||||
|
from agent.memory.embedding.provider import create_embedding_provider
|
||||||
|
from config import conf
|
||||||
|
|
||||||
|
embedding_provider = None
|
||||||
|
embedding_model = None
|
||||||
|
|
||||||
|
openai_api_key = conf().get("open_ai_api_key", "")
|
||||||
|
openai_api_base = conf().get("open_ai_api_base", "")
|
||||||
|
if openai_api_key and openai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||||
|
try:
|
||||||
|
model = "text-embedding-3-small"
|
||||||
|
embedding_provider = create_embedding_provider(
|
||||||
|
provider="openai",
|
||||||
|
model=model,
|
||||||
|
api_key=openai_api_key,
|
||||||
|
api_base=openai_api_base or "https://api.openai.com/v1",
|
||||||
|
)
|
||||||
|
embedding_model = f"openai/{model}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[EmbeddingFactory] OpenAI embedding failed: {e}")
|
||||||
|
|
||||||
|
if embedding_provider is None:
|
||||||
|
linkai_api_key = conf().get("linkai_api_key", "") or os.environ.get("LINKAI_API_KEY", "")
|
||||||
|
linkai_api_base = conf().get("linkai_api_base", "https://api.link-ai.tech")
|
||||||
|
if linkai_api_key and linkai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||||
|
try:
|
||||||
|
model = "text-embedding-3-small"
|
||||||
|
embedding_provider = create_embedding_provider(
|
||||||
|
provider="linkai",
|
||||||
|
model=model,
|
||||||
|
api_key=linkai_api_key,
|
||||||
|
api_base=f"{linkai_api_base}/v1",
|
||||||
|
)
|
||||||
|
embedding_model = f"linkai/{model}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[EmbeddingFactory] LinkAI embedding failed: {e}")
|
||||||
|
|
||||||
|
if embedding_provider is not None and embedding_model:
|
||||||
|
_log_provider_once(f"{embedding_model} (dim={embedding_provider.dimensions})")
|
||||||
|
|
||||||
|
return embedding_provider
|
||||||
|
|
||||||
|
|
||||||
|
def _init_explicit_provider(provider_key: str):
|
||||||
|
"""Explicit-provider path: build the configured vendor."""
|
||||||
|
from agent.memory.embedding.provider import EMBEDDING_VENDORS, create_embedding_provider
|
||||||
|
from config import conf
|
||||||
|
|
||||||
|
# Custom providers ("custom:<id>") resolve credentials from custom_providers.
|
||||||
|
resolved_provider_key = provider_key
|
||||||
|
if provider_key.startswith("custom:"):
|
||||||
|
resolved_provider_key = "custom"
|
||||||
|
|
||||||
|
meta = EMBEDDING_VENDORS.get(resolved_provider_key)
|
||||||
|
if meta is None:
|
||||||
|
logger.error(
|
||||||
|
f"[EmbeddingFactory] Unknown embedding_provider '{provider_key}'. "
|
||||||
|
f"Supported: {sorted(EMBEDDING_VENDORS.keys())}. "
|
||||||
|
f"Memory will run in keyword-only mode."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
api_key = _resolve_api_key(provider_key)
|
||||||
|
api_base = _resolve_api_base(provider_key, meta["default_base_url"])
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
logger.error(
|
||||||
|
f"[EmbeddingFactory] embedding_provider='{provider_key}' is set but its "
|
||||||
|
f"API key is missing. Memory will run in keyword-only mode."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
model = (conf().get("embedding_model") or "").strip()
|
||||||
|
# Custom providers without a model fall back to the provider's default.
|
||||||
|
if not model and resolved_provider_key == "custom":
|
||||||
|
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
||||||
|
_, custom_id = parse_custom_bot_type(provider_key)
|
||||||
|
if custom_id:
|
||||||
|
entry = _find_provider_by_id(get_custom_providers(), custom_id)
|
||||||
|
if entry and entry.get("model"):
|
||||||
|
model = entry["model"]
|
||||||
|
if not model and resolved_provider_key != "custom":
|
||||||
|
model = meta["default_model"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg_dim = int(conf().get("embedding_dimensions") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
cfg_dim = 0
|
||||||
|
dim = cfg_dim if cfg_dim > 0 else meta["default_dimensions"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = create_embedding_provider(
|
||||||
|
provider=resolved_provider_key,
|
||||||
|
model=model,
|
||||||
|
api_key=api_key,
|
||||||
|
api_base=api_base,
|
||||||
|
dimensions=dim,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[EmbeddingFactory] Failed to init embedding provider "
|
||||||
|
f"'{provider_key}/{model}': {e}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
_log_provider_once(f"{provider_key}/{model} (dim={provider.dimensions})")
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_api_key(provider_key: str) -> str:
|
||||||
|
"""Pick the API key for an explicit embedding provider from config."""
|
||||||
|
from config import conf
|
||||||
|
|
||||||
|
if provider_key.startswith("custom:"):
|
||||||
|
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
||||||
|
_, custom_id = parse_custom_bot_type(provider_key)
|
||||||
|
if custom_id:
|
||||||
|
entry = _find_provider_by_id(get_custom_providers(), custom_id)
|
||||||
|
if entry:
|
||||||
|
return entry.get("api_key", "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
key_map = {
|
||||||
|
"openai": "open_ai_api_key",
|
||||||
|
"linkai": "linkai_api_key",
|
||||||
|
"dashscope": "dashscope_api_key",
|
||||||
|
"doubao": "ark_api_key",
|
||||||
|
"zhipu": "zhipu_ai_api_key",
|
||||||
|
}
|
||||||
|
field = key_map.get(provider_key)
|
||||||
|
if not field:
|
||||||
|
return ""
|
||||||
|
value = conf().get(field, "") or ""
|
||||||
|
if value in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||||
|
return ""
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_api_base(provider_key: str, default_base: str) -> str:
|
||||||
|
"""Pick the API base for an explicit embedding provider from config."""
|
||||||
|
from config import conf
|
||||||
|
|
||||||
|
if provider_key.startswith("custom:"):
|
||||||
|
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
||||||
|
_, custom_id = parse_custom_bot_type(provider_key)
|
||||||
|
if custom_id:
|
||||||
|
entry = _find_provider_by_id(get_custom_providers(), custom_id)
|
||||||
|
if entry and entry.get("api_base"):
|
||||||
|
return entry["api_base"]
|
||||||
|
return default_base
|
||||||
|
|
||||||
|
base_map = {
|
||||||
|
"openai": "open_ai_api_base",
|
||||||
|
"linkai": "linkai_api_base",
|
||||||
|
"doubao": "ark_base_url",
|
||||||
|
"zhipu": "zhipu_ai_api_base",
|
||||||
|
}
|
||||||
|
field = base_map.get(provider_key)
|
||||||
|
if not field:
|
||||||
|
return default_base
|
||||||
|
value = (conf().get(field) or "").strip()
|
||||||
|
if not value:
|
||||||
|
return default_base
|
||||||
|
if provider_key == "linkai" and not value.rstrip("/").endswith("/v1"):
|
||||||
|
return f"{value.rstrip('/')}/v1"
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _log_provider_once(detail: str):
|
||||||
|
global _embedding_logged
|
||||||
|
if not _embedding_logged:
|
||||||
|
logger.info(f"[EmbeddingFactory] Embedding model in use: {detail}")
|
||||||
|
_embedding_logged = True
|
||||||
515
agent/memory/embedding/provider.py
Normal file
515
agent/memory/embedding/provider.py
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
"""
|
||||||
|
Embedding providers for memory
|
||||||
|
|
||||||
|
Supports multiple OpenAI-compatible embedding vendors:
|
||||||
|
- openai (text-embedding-3-small / large)
|
||||||
|
- linkai (OpenAI-compatible passthrough)
|
||||||
|
- dashscope (Aliyun Tongyi text-embedding-v4)
|
||||||
|
- doubao (ByteDance Doubao Seed1.5 / large-text on Volcengine Ark)
|
||||||
|
- zhipu (ZhipuAI embedding-3)
|
||||||
|
- custom (any OpenAI-compatible endpoint)
|
||||||
|
|
||||||
|
Vendor keys here intentionally match the project's bot_type constants in
|
||||||
|
common.const (OPENAI, LINKAI, QWEN_DASHSCOPE, DOUBAO, ZHIPU_AI).
|
||||||
|
|
||||||
|
Custom providers (bot_type "custom" or "custom:<id>") reuse the same
|
||||||
|
OpenAI-compatible REST client with user-supplied api_key / api_base.
|
||||||
|
|
||||||
|
All providers share a single OpenAI-compatible REST client. Vendor-specific
|
||||||
|
behaviors (truncation, query instruction prefix) are configured via metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import math
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
# HTTP read timeout for a single embeddings request (seconds). A batch of
|
||||||
|
# 64+ chunks can take 30-50s end-to-end from China-side networks, so 30s is
|
||||||
|
# routinely too tight; 90s gives meaningful headroom without letting bad
|
||||||
|
# endpoints hang forever.
|
||||||
|
EMBEDDING_HTTP_TIMEOUT = 90
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddingProvider(ABC):
|
||||||
|
"""Base class for embedding providers"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def embed(self, text: str) -> List[float]:
|
||||||
|
"""Generate embedding for a single text (treated as a query by default)"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def embed_batch(self, texts: List[str]) -> List[List[float]]:
|
||||||
|
"""Generate embeddings for multiple texts (treated as documents)"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def embed_query(self, text: str) -> List[float]:
|
||||||
|
"""Generate embedding for a query string (may apply vendor instruction prefix)"""
|
||||||
|
return self.embed(text)
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def dimensions(self) -> int:
|
||||||
|
"""Effective embedding dimensions"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Vendor metadata table
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Each entry describes how to reach a vendor's embedding endpoint. Most
|
||||||
|
# vendors expose an OpenAI-compatible /embeddings API; the few that don't
|
||||||
|
# (currently: doubao) set `provider_class` to pick a dedicated adapter.
|
||||||
|
# Fields:
|
||||||
|
# provider_class : optional adapter key ("doubao"); defaults to OpenAI-compat
|
||||||
|
# default_base_url : default API base when not overridden by user
|
||||||
|
# default_model : default embedding model name
|
||||||
|
# default_dimensions : recommended unified dim when explicit path is enabled
|
||||||
|
# supports_dim_param : whether the API accepts a `dimensions` request param
|
||||||
|
# needs_client_truncate : whether to slice + L2-normalize on the client side
|
||||||
|
# needs_client_normalize : whether to L2-normalize on the client (always safe)
|
||||||
|
# query_instruction : optional prefix for asymmetric retrieval (Doubao Seed)
|
||||||
|
# max_batch_size : max texts per /embeddings request; embed_batch
|
||||||
|
# auto-paginates above this. Conservative defaults.
|
||||||
|
#
|
||||||
|
EMBEDDING_VENDORS = {
|
||||||
|
"openai": {
|
||||||
|
"default_base_url": "https://api.openai.com/v1",
|
||||||
|
"default_model": "text-embedding-3-small",
|
||||||
|
# Match the legacy default so users adding `embedding_provider: openai`
|
||||||
|
# to an existing index don't need to rebuild. Override via
|
||||||
|
# embedding_dimensions if you want 1024 / 1536 / 3072.
|
||||||
|
"default_dimensions": 1536,
|
||||||
|
"supports_dim_param": True,
|
||||||
|
"needs_client_truncate": False,
|
||||||
|
"needs_client_normalize": False,
|
||||||
|
"query_instruction": "",
|
||||||
|
# OpenAI permits up to 2048 items per request, but a single call
|
||||||
|
# carrying hundreds of long chunks routinely exceeds the 30s read
|
||||||
|
# timeout from China-side networks. 64 keeps each call well under
|
||||||
|
# both the token-per-request budget and a reasonable wall clock.
|
||||||
|
"max_batch_size": 64,
|
||||||
|
},
|
||||||
|
"linkai": {
|
||||||
|
"default_base_url": "https://api.link-ai.tech/v1",
|
||||||
|
"default_model": "text-embedding-3-small",
|
||||||
|
"default_dimensions": 1536,
|
||||||
|
"supports_dim_param": True,
|
||||||
|
"needs_client_truncate": False,
|
||||||
|
"needs_client_normalize": False,
|
||||||
|
"query_instruction": "",
|
||||||
|
"max_batch_size": 64,
|
||||||
|
},
|
||||||
|
"dashscope": {
|
||||||
|
"default_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
"default_model": "text-embedding-v4",
|
||||||
|
"default_dimensions": 1024,
|
||||||
|
"supports_dim_param": True,
|
||||||
|
"needs_client_truncate": False,
|
||||||
|
"needs_client_normalize": False,
|
||||||
|
"query_instruction": "",
|
||||||
|
"max_batch_size": 10, # DashScope hard cap (text-embedding-v4)
|
||||||
|
},
|
||||||
|
"doubao": {
|
||||||
|
# Doubao no longer offers an OpenAI-compatible /v1/embeddings endpoint.
|
||||||
|
# Current models are unified under /api/v3/embeddings/multimodal
|
||||||
|
# which uses a structured `input` payload — see DoubaoEmbeddingProvider.
|
||||||
|
"provider_class": "doubao",
|
||||||
|
"default_base_url": "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
|
"default_model": "doubao-embedding-vision-251215",
|
||||||
|
# Native options: 1024 or 2048. We default to 1024 to align with the
|
||||||
|
# other Chinese vendors (dashscope/zhipu) and keep storage footprint
|
||||||
|
# consistent across providers; users can still override via
|
||||||
|
# `embedding_dimensions: 2048` in config.
|
||||||
|
"default_dimensions": 1024,
|
||||||
|
"supports_dim_param": True,
|
||||||
|
"needs_client_truncate": False,
|
||||||
|
"needs_client_normalize": False,
|
||||||
|
"query_instruction": "",
|
||||||
|
# Multimodal endpoint produces ONE embedding per call (input list is
|
||||||
|
# a single document's parts, not a batch). embed_batch loops.
|
||||||
|
"max_batch_size": 1,
|
||||||
|
},
|
||||||
|
"zhipu": {
|
||||||
|
"default_base_url": "https://open.bigmodel.cn/api/paas/v4",
|
||||||
|
"default_model": "embedding-3",
|
||||||
|
"default_dimensions": 1024,
|
||||||
|
"supports_dim_param": True,
|
||||||
|
"needs_client_truncate": False,
|
||||||
|
"needs_client_normalize": False,
|
||||||
|
"query_instruction": "",
|
||||||
|
"max_batch_size": 64,
|
||||||
|
},
|
||||||
|
# Custom provider — any OpenAI-compatible /embeddings endpoint. The
|
||||||
|
# user must supply api_key + api_base + model via the web console
|
||||||
|
# (stored in custom_providers list or legacy custom_api_key / custom_api_base).
|
||||||
|
# Dimensions defaults to 1024 but can be overridden via config's
|
||||||
|
# embedding_dimensions. No dim-param support assumption — safest
|
||||||
|
# default for unknown endpoints.
|
||||||
|
"custom": {
|
||||||
|
"default_base_url": "",
|
||||||
|
"default_model": "",
|
||||||
|
"default_dimensions": 1024,
|
||||||
|
"supports_dim_param": False,
|
||||||
|
"needs_client_truncate": False,
|
||||||
|
"needs_client_normalize": True,
|
||||||
|
"query_instruction": "",
|
||||||
|
"max_batch_size": 64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _l2_normalize(vec: List[float]) -> List[float]:
|
||||||
|
"""Normalize a vector to unit length (L2 norm). Returns input on zero vector."""
|
||||||
|
norm = math.sqrt(sum(v * v for v in vec))
|
||||||
|
if norm == 0:
|
||||||
|
return vec
|
||||||
|
return [v / norm for v in vec]
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAIEmbeddingProvider(EmbeddingProvider):
|
||||||
|
"""
|
||||||
|
OpenAI-compatible embedding provider.
|
||||||
|
|
||||||
|
Used for openai/linkai/dashscope/ark/zhipu by configuring the metadata
|
||||||
|
fields. The legacy two-arg constructor (model, api_key, api_base) keeps
|
||||||
|
working, so the original OpenAI/LinkAI fallback code path is unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model: str = "text-embedding-3-small",
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
api_base: Optional[str] = None,
|
||||||
|
extra_headers: Optional[dict] = None,
|
||||||
|
dimensions: Optional[int] = None,
|
||||||
|
supports_dim_param: bool = True,
|
||||||
|
needs_client_truncate: bool = False,
|
||||||
|
needs_client_normalize: bool = False,
|
||||||
|
query_instruction: str = "",
|
||||||
|
max_batch_size: int = 256,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
model: Model name (e.g. text-embedding-3-small, text-embedding-v4, embedding-3)
|
||||||
|
api_key: API key (required)
|
||||||
|
api_base: API base URL (defaults to OpenAI)
|
||||||
|
extra_headers: Optional extra HTTP headers
|
||||||
|
dimensions: Target output dimension. Required when supports_dim_param
|
||||||
|
is False and needs_client_truncate is True (used to slice).
|
||||||
|
supports_dim_param: Whether the vendor accepts a `dimensions` body param
|
||||||
|
needs_client_truncate: Slice the returned vector to `dimensions`
|
||||||
|
needs_client_normalize: L2-normalize on the client after slicing
|
||||||
|
query_instruction: Optional prefix prepended to query texts only
|
||||||
|
max_batch_size: Max items per /embeddings request; embed_batch
|
||||||
|
auto-paginates above this.
|
||||||
|
"""
|
||||||
|
self.model = model
|
||||||
|
self.api_key = api_key
|
||||||
|
self.api_base = api_base or "https://api.openai.com/v1"
|
||||||
|
self.extra_headers = extra_headers or {}
|
||||||
|
self.supports_dim_param = supports_dim_param
|
||||||
|
self.needs_client_truncate = needs_client_truncate
|
||||||
|
self.needs_client_normalize = needs_client_normalize
|
||||||
|
self.query_instruction = query_instruction or ""
|
||||||
|
self.max_batch_size = max(1, int(max_batch_size or 1))
|
||||||
|
|
||||||
|
if not self.api_key or self.api_key in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||||
|
raise ValueError("Embedding API key is not configured")
|
||||||
|
|
||||||
|
if dimensions is not None and dimensions > 0:
|
||||||
|
self._dimensions = dimensions
|
||||||
|
else:
|
||||||
|
# Legacy heuristic for OpenAI text-embedding-3-* family
|
||||||
|
self._dimensions = 1536 if "small" in model else 3072
|
||||||
|
|
||||||
|
def _call_api(self, input_data):
|
||||||
|
"""Call OpenAI-compatible /embeddings endpoint"""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
url = f"{self.api_base}/embeddings"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
**self.extra_headers,
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"input": input_data,
|
||||||
|
"model": self.model,
|
||||||
|
}
|
||||||
|
if self.supports_dim_param and self._dimensions:
|
||||||
|
data["dimensions"] = self._dimensions
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, headers=headers, json=data, timeout=EMBEDDING_HTTP_TIMEOUT)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
raise ConnectionError(
|
||||||
|
f"Failed to connect to embedding API at {url}. "
|
||||||
|
f"Please check network and api_base. Error: {str(e)}"
|
||||||
|
)
|
||||||
|
except requests.exceptions.Timeout as e:
|
||||||
|
raise TimeoutError(f"Embedding API request timed out. Error: {str(e)}")
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
if e.response.status_code == 401:
|
||||||
|
raise ValueError("Invalid embedding API key")
|
||||||
|
elif e.response.status_code == 429:
|
||||||
|
raise ValueError("Embedding API rate limit exceeded")
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Embedding API request failed: "
|
||||||
|
f"{e.response.status_code} - {e.response.text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _post_process(self, raw: List[float]) -> List[float]:
|
||||||
|
"""Apply optional client-side truncation + normalization"""
|
||||||
|
vec = raw
|
||||||
|
if self.needs_client_truncate and self._dimensions and len(vec) > self._dimensions:
|
||||||
|
vec = vec[: self._dimensions]
|
||||||
|
if self.needs_client_normalize:
|
||||||
|
vec = _l2_normalize(vec)
|
||||||
|
return vec
|
||||||
|
|
||||||
|
def embed(self, text: str) -> List[float]:
|
||||||
|
"""Generate embedding (treated as document by default)"""
|
||||||
|
result = self._call_api(text)
|
||||||
|
return self._post_process(result["data"][0]["embedding"])
|
||||||
|
|
||||||
|
def embed_query(self, text: str) -> List[float]:
|
||||||
|
"""Generate embedding for a query (applies vendor instruction prefix if any)"""
|
||||||
|
if self.query_instruction:
|
||||||
|
text = f"{self.query_instruction}{text}"
|
||||||
|
return self.embed(text)
|
||||||
|
|
||||||
|
def embed_batch(self, texts: List[str]) -> List[List[float]]:
|
||||||
|
"""Generate embeddings for multiple documents.
|
||||||
|
|
||||||
|
Automatically paginates by self.max_batch_size so callers can pass any
|
||||||
|
number of texts. Order of returned vectors matches the input order.
|
||||||
|
"""
|
||||||
|
if not texts:
|
||||||
|
return []
|
||||||
|
out: List[List[float]] = []
|
||||||
|
step = self.max_batch_size
|
||||||
|
for i in range(0, len(texts), step):
|
||||||
|
chunk = texts[i:i + step]
|
||||||
|
result = self._call_api(chunk)
|
||||||
|
out.extend(self._post_process(item["embedding"]) for item in result["data"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dimensions(self) -> int:
|
||||||
|
return self._dimensions
|
||||||
|
|
||||||
|
|
||||||
|
class DoubaoEmbeddingProvider(EmbeddingProvider):
|
||||||
|
"""
|
||||||
|
Doubao (Volcengine Ark) multimodal embedding provider.
|
||||||
|
|
||||||
|
Doubao deprecated their OpenAI-compatible /v1/embeddings endpoint and
|
||||||
|
unified everything under /api/v3/embeddings/multimodal, which uses a
|
||||||
|
structured `input: [{type, text|image_url|video_url}, ...]` payload.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
* The endpoint produces ONE embedding per call (input list is multiple
|
||||||
|
modality parts of a single document, not a batch). embed_batch
|
||||||
|
therefore loops per-text — no native batch support.
|
||||||
|
* Native dimensions: 1024 or 2048 (default 1024 to align with other
|
||||||
|
Chinese vendors). No client-side truncation needed.
|
||||||
|
* Auth: Bearer ARK API key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model: str,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
api_base: Optional[str] = None,
|
||||||
|
extra_headers: Optional[dict] = None,
|
||||||
|
dimensions: Optional[int] = None,
|
||||||
|
):
|
||||||
|
self.model = model
|
||||||
|
self.api_key = api_key
|
||||||
|
self.api_base = api_base or "https://ark.cn-beijing.volces.com/api/v3"
|
||||||
|
self.extra_headers = extra_headers or {}
|
||||||
|
if not self.api_key or self.api_key in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||||
|
raise ValueError("Doubao embedding API key (ark_api_key) is not configured")
|
||||||
|
|
||||||
|
if dimensions in (1024, 2048):
|
||||||
|
self._dimensions = dimensions
|
||||||
|
elif dimensions is None:
|
||||||
|
self._dimensions = 1024
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Doubao embedding dimensions must be 1024 or 2048, got {dimensions}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _call_api(self, text: str) -> List[float]:
|
||||||
|
"""One call → one embedding. multimodal endpoint takes a single
|
||||||
|
document represented as a list of typed parts; we send a single
|
||||||
|
text part."""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
url = f"{self.api_base}/embeddings/multimodal"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
**self.extra_headers,
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"input": [{"type": "text", "text": text}],
|
||||||
|
"dimensions": self._dimensions,
|
||||||
|
"encoding_format": "float",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=EMBEDDING_HTTP_TIMEOUT)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = response.json()
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
raise ConnectionError(
|
||||||
|
f"Failed to connect to Doubao embedding API at {url}. "
|
||||||
|
f"Please check network and api_base. Error: {str(e)}"
|
||||||
|
)
|
||||||
|
except requests.exceptions.Timeout as e:
|
||||||
|
raise TimeoutError(f"Doubao embedding API request timed out. Error: {str(e)}")
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
if e.response.status_code == 401:
|
||||||
|
raise ValueError("Invalid Doubao (ark) embedding API key")
|
||||||
|
elif e.response.status_code == 429:
|
||||||
|
raise ValueError("Doubao embedding API rate limit exceeded")
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Doubao embedding API request failed: "
|
||||||
|
f"{e.response.status_code} - {e.response.text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Response shape per docs: {"data": {"embedding": [...]}}
|
||||||
|
data = body.get("data")
|
||||||
|
if isinstance(data, dict) and "embedding" in data:
|
||||||
|
return data["embedding"]
|
||||||
|
# Some providers wrap as a list of one — be defensive
|
||||||
|
if isinstance(data, list) and data and "embedding" in data[0]:
|
||||||
|
return data[0]["embedding"]
|
||||||
|
raise ValueError(f"Unexpected Doubao embedding response shape: {body}")
|
||||||
|
|
||||||
|
def embed(self, text: str) -> List[float]:
|
||||||
|
return self._call_api(text)
|
||||||
|
|
||||||
|
def embed_batch(self, texts: List[str]) -> List[List[float]]:
|
||||||
|
# Endpoint produces one embedding per call; loop. Order preserved.
|
||||||
|
return [self._call_api(t) for t in texts]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dimensions(self) -> int:
|
||||||
|
return self._dimensions
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddingCache:
|
||||||
|
"""In-memory cache for embeddings to avoid recomputation"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.cache = {}
|
||||||
|
|
||||||
|
def get(self, text: str, provider: str, model: str) -> Optional[List[float]]:
|
||||||
|
key = self._compute_key(text, provider, model)
|
||||||
|
return self.cache.get(key)
|
||||||
|
|
||||||
|
def put(self, text: str, provider: str, model: str, embedding: List[float]):
|
||||||
|
key = self._compute_key(text, provider, model)
|
||||||
|
self.cache[key] = embedding
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _compute_key(text: str, provider: str, model: str) -> str:
|
||||||
|
content = f"{provider}:{model}:{text}"
|
||||||
|
return hashlib.md5(content.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
self.cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def create_embedding_provider(
|
||||||
|
provider: str = "openai",
|
||||||
|
model: Optional[str] = None,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
api_base: Optional[str] = None,
|
||||||
|
extra_headers: Optional[dict] = None,
|
||||||
|
dimensions: Optional[int] = None,
|
||||||
|
) -> EmbeddingProvider:
|
||||||
|
"""
|
||||||
|
Factory function to create an embedding provider.
|
||||||
|
|
||||||
|
Backward compatible: when called with provider in {"openai", "linkai"}
|
||||||
|
and no `dimensions` arg, behaves exactly as before (1536-dim OpenAI).
|
||||||
|
|
||||||
|
New providers ("dashscope", "doubao", "zhipu") require explicit configuration
|
||||||
|
and use the unified 1024-dim defaults from EMBEDDING_VENDORS.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: Vendor key (one of EMBEDDING_VENDORS)
|
||||||
|
model: Model name (uses vendor default if None)
|
||||||
|
api_key: API key (required)
|
||||||
|
api_base: API base URL (uses vendor default if None)
|
||||||
|
extra_headers: Optional extra HTTP headers
|
||||||
|
dimensions: Target output dimension (uses vendor default if None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
EmbeddingProvider instance
|
||||||
|
"""
|
||||||
|
meta = EMBEDDING_VENDORS.get(provider)
|
||||||
|
if meta is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported embedding provider: {provider}. "
|
||||||
|
f"Supported: {sorted(EMBEDDING_VENDORS.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Doubao uses a non-OpenAI-compatible multimodal endpoint.
|
||||||
|
if meta.get("provider_class") == "doubao":
|
||||||
|
final_dim = dimensions if (dimensions and dimensions > 0) else meta["default_dimensions"]
|
||||||
|
return DoubaoEmbeddingProvider(
|
||||||
|
model=model or meta["default_model"],
|
||||||
|
api_key=api_key,
|
||||||
|
api_base=api_base or meta["default_base_url"],
|
||||||
|
extra_headers=extra_headers,
|
||||||
|
dimensions=final_dim,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Legacy two-arg call for openai/linkai keeps 1536-dim default behavior
|
||||||
|
# so existing data isn't invalidated.
|
||||||
|
is_legacy_call = (
|
||||||
|
provider in ("openai", "linkai")
|
||||||
|
and dimensions is None
|
||||||
|
)
|
||||||
|
if is_legacy_call:
|
||||||
|
return OpenAIEmbeddingProvider(
|
||||||
|
model=model or "text-embedding-3-small",
|
||||||
|
api_key=api_key,
|
||||||
|
api_base=api_base,
|
||||||
|
extra_headers=extra_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
final_dim = dimensions if (dimensions and dimensions > 0) else meta["default_dimensions"]
|
||||||
|
resolved_model = model or meta["default_model"]
|
||||||
|
resolved_base = api_base or meta["default_base_url"]
|
||||||
|
# Custom providers require explicit api_base and model — they cannot
|
||||||
|
# fall back to OpenAI defaults like built-in vendors do.
|
||||||
|
if provider == "custom":
|
||||||
|
if not resolved_base:
|
||||||
|
raise ValueError("Custom embedding provider requires an api_base URL")
|
||||||
|
if not resolved_model:
|
||||||
|
raise ValueError("Custom embedding provider requires a model name")
|
||||||
|
return OpenAIEmbeddingProvider(
|
||||||
|
model=resolved_model,
|
||||||
|
api_key=api_key,
|
||||||
|
api_base=resolved_base,
|
||||||
|
extra_headers=extra_headers,
|
||||||
|
dimensions=final_dim,
|
||||||
|
supports_dim_param=meta["supports_dim_param"],
|
||||||
|
needs_client_truncate=meta["needs_client_truncate"],
|
||||||
|
needs_client_normalize=meta["needs_client_normalize"],
|
||||||
|
query_instruction=meta["query_instruction"],
|
||||||
|
max_batch_size=meta.get("max_batch_size", 256),
|
||||||
|
)
|
||||||
190
agent/memory/embedding/rebuild.py
Normal file
190
agent/memory/embedding/rebuild.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""
|
||||||
|
Rebuild memory vector index.
|
||||||
|
|
||||||
|
Recommended entry point (in-chat, while agent is running):
|
||||||
|
/memory rebuild-index
|
||||||
|
|
||||||
|
Backward-compatible CLI entry (must run from project root):
|
||||||
|
python -m agent.memory.rebuild_index
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
1. Probes the embedding endpoint with a tiny call to fail fast on
|
||||||
|
bad provider/model/key — before touching the index.
|
||||||
|
2. Clears the SQLite chunks/files tables (workspace markdown stays intact).
|
||||||
|
3. Runs a fresh sync, regenerating embeddings with the currently configured
|
||||||
|
provider/model/dimensions.
|
||||||
|
|
||||||
|
This is the only safe way to switch embedding_provider after the existing
|
||||||
|
index has been populated by a different-dim model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
from common.utils import expand_path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RebuildResult:
|
||||||
|
"""Outcome of a rebuild_in_process() call"""
|
||||||
|
ok: bool
|
||||||
|
removed: int = 0
|
||||||
|
chunks: int = 0
|
||||||
|
files: int = 0
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def clear_index(db_path, storage=None) -> int:
|
||||||
|
"""Wipe chunks/files, reset FTS5, and clean up any legacy state file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path of the index DB (also used to locate the legacy state
|
||||||
|
file for migration cleanup, and — when *storage* is None — to
|
||||||
|
open a fresh connection).
|
||||||
|
storage: Optional pre-opened MemoryStorage. When provided we reuse it
|
||||||
|
so the live connection's triggers stay in sync — opening a second
|
||||||
|
connection would leave the original one's triggers pointing at a
|
||||||
|
DROP'd chunks_fts table.
|
||||||
|
|
||||||
|
We reset (DROP+recreate) chunks_fts because its shadow tables can become
|
||||||
|
inconsistent across rebuild cycles, causing bm25() / ORDER BY rank to
|
||||||
|
raise "database disk image is malformed" even when raw MATCH still works.
|
||||||
|
|
||||||
|
Returns number of chunks removed.
|
||||||
|
"""
|
||||||
|
from agent.memory.embedding.state import cleanup_legacy_state_file
|
||||||
|
from agent.memory.storage import MemoryStorage
|
||||||
|
|
||||||
|
owns_storage = storage is None
|
||||||
|
if owns_storage:
|
||||||
|
storage = MemoryStorage(db_path)
|
||||||
|
try:
|
||||||
|
before = storage.conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
|
||||||
|
storage.conn.execute("DELETE FROM chunks")
|
||||||
|
storage.conn.execute("DELETE FROM files")
|
||||||
|
storage.conn.commit()
|
||||||
|
storage.reset_fts5()
|
||||||
|
finally:
|
||||||
|
if owns_storage:
|
||||||
|
storage.close()
|
||||||
|
|
||||||
|
cleanup_legacy_state_file(db_path)
|
||||||
|
return int(before)
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_in_process(memory_manager) -> RebuildResult:
|
||||||
|
"""
|
||||||
|
Rebuild the index using an existing, fully-initialized MemoryManager.
|
||||||
|
|
||||||
|
Used by the in-chat /memory rebuild-index command. The caller already has
|
||||||
|
config loaded, embedding_provider built, and (optionally) the agent
|
||||||
|
running, so we only need to:
|
||||||
|
1. Clear chunks/files + state on the manager's storage.
|
||||||
|
2. Re-sync (force=True).
|
||||||
|
|
||||||
|
NOTE: caller must ensure memory_manager.embedding_provider is set, otherwise
|
||||||
|
sync() will silently skip embedding generation.
|
||||||
|
"""
|
||||||
|
if memory_manager is None:
|
||||||
|
return RebuildResult(ok=False, error="memory_manager is None")
|
||||||
|
if memory_manager.embedding_provider is None:
|
||||||
|
return RebuildResult(ok=False, error="embedding_provider is not initialized")
|
||||||
|
|
||||||
|
# Probe the embedding endpoint BEFORE clearing the index. A bad
|
||||||
|
# provider/model/key would otherwise leave the user with an empty index
|
||||||
|
# that not even keyword search can serve.
|
||||||
|
try:
|
||||||
|
memory_manager.embedding_provider.embed_query("ping")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[RebuildIndex] embedding probe failed, aborting rebuild: {e}")
|
||||||
|
return RebuildResult(ok=False, error=f"embedding endpoint not reachable: {e}")
|
||||||
|
|
||||||
|
db_path = memory_manager.config.get_db_path()
|
||||||
|
try:
|
||||||
|
removed = clear_index(db_path, storage=memory_manager.storage)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[RebuildIndex] clear_index failed")
|
||||||
|
return RebuildResult(ok=False, error=f"clear failed: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(memory_manager.sync(force=True))
|
||||||
|
except RuntimeError:
|
||||||
|
# Already inside a running event loop (rare in chat handler thread).
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(memory_manager.sync(force=True))
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[RebuildIndex] sync failed")
|
||||||
|
return RebuildResult(ok=False, removed=removed, error=f"re-embed failed: {e}")
|
||||||
|
|
||||||
|
stats = memory_manager.storage.get_stats()
|
||||||
|
chunks = int(stats.get("chunks", 0))
|
||||||
|
embedded = int(stats.get("embedded", 0))
|
||||||
|
|
||||||
|
# sync() degrades to "no embeddings" on batch failure so keyword search
|
||||||
|
# still works at startup — but in a /rebuild-index request the user
|
||||||
|
# explicitly asked for vectors. Surface that as a failure.
|
||||||
|
if chunks > 0 and embedded == 0:
|
||||||
|
return RebuildResult(
|
||||||
|
ok=False,
|
||||||
|
removed=removed,
|
||||||
|
chunks=chunks,
|
||||||
|
files=int(stats.get("files", 0)),
|
||||||
|
error=(
|
||||||
|
"embedding API failed during sync; index now has chunks but no "
|
||||||
|
"vectors. Check embedding provider/model/key and retry."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return RebuildResult(
|
||||||
|
ok=True,
|
||||||
|
removed=removed,
|
||||||
|
chunks=chunks,
|
||||||
|
files=int(stats.get("files", 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Standalone CLI entry. Must be run from project root (relative config path)."""
|
||||||
|
from config import conf, load_config
|
||||||
|
from agent.memory import MemoryConfig, MemoryManager
|
||||||
|
|
||||||
|
load_config()
|
||||||
|
|
||||||
|
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
|
memory_config = MemoryConfig(workspace_root=workspace_root)
|
||||||
|
|
||||||
|
logger.info(f"[RebuildIndex] Workspace: {workspace_root}")
|
||||||
|
logger.info(f"[RebuildIndex] Index db: {memory_config.get_db_path()}")
|
||||||
|
|
||||||
|
from agent.memory.embedding import create_default_embedding_provider
|
||||||
|
|
||||||
|
embedding_provider = create_default_embedding_provider()
|
||||||
|
if embedding_provider is None:
|
||||||
|
logger.error(
|
||||||
|
"[RebuildIndex] No embedding provider could be initialized. "
|
||||||
|
"Check your config.json. Aborting rebuild."
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
manager = MemoryManager(memory_config, embedding_provider=embedding_provider)
|
||||||
|
result = rebuild_in_process(manager)
|
||||||
|
if not result.ok:
|
||||||
|
logger.error(f"[RebuildIndex] {result.error}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[RebuildIndex] Done. removed={result.removed}, "
|
||||||
|
f"chunks={result.chunks}, files={result.files}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
51
agent/memory/embedding/state.py
Normal file
51
agent/memory/embedding/state.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""
|
||||||
|
Embedding-related index utilities.
|
||||||
|
|
||||||
|
We don't keep a sidecar state file — the SQLite index is the source of truth
|
||||||
|
and config.json is the source of intent. The two functions below are the
|
||||||
|
only things needing on-disk awareness:
|
||||||
|
|
||||||
|
detect_index_dim : read the dim of stored vectors (display-only)
|
||||||
|
cleanup_legacy_state_file: remove old embedding_state.json from earlier
|
||||||
|
versions; safe no-op when absent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
PathLike = Union[str, os.PathLike]
|
||||||
|
|
||||||
|
|
||||||
|
def detect_index_dim(storage) -> Optional[int]:
|
||||||
|
"""Return the dim of the first stored embedding, or None if the index
|
||||||
|
has no embeddings. Used by /memory status."""
|
||||||
|
try:
|
||||||
|
row = storage.conn.execute(
|
||||||
|
"SELECT embedding FROM chunks WHERE embedding IS NOT NULL LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not row or not row["embedding"]:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw = row["embedding"]
|
||||||
|
if isinstance(raw, (bytes, bytearray)):
|
||||||
|
# New BLOB format: 4 bytes per float32
|
||||||
|
return len(raw) // 4
|
||||||
|
emb = json.loads(raw)
|
||||||
|
return len(emb) if isinstance(emb, list) else None
|
||||||
|
except (json.JSONDecodeError, TypeError, Exception):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_legacy_state_file(db_path: PathLike) -> None:
|
||||||
|
"""Remove old embedding_state.json files from earlier versions.
|
||||||
|
Safe to call repeatedly; no-op if the file is absent."""
|
||||||
|
legacy = Path(db_path).parent / "embedding_state.json"
|
||||||
|
try:
|
||||||
|
legacy.unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -13,7 +13,7 @@ from datetime import datetime, timedelta
|
|||||||
from agent.memory.config import MemoryConfig, get_default_memory_config
|
from agent.memory.config import MemoryConfig, get_default_memory_config
|
||||||
from agent.memory.storage import MemoryStorage, MemoryChunk, SearchResult
|
from agent.memory.storage import MemoryStorage, MemoryChunk, SearchResult
|
||||||
from agent.memory.chunker import TextChunker
|
from agent.memory.chunker import TextChunker
|
||||||
from agent.memory.embedding import create_embedding_provider, EmbeddingProvider
|
from agent.memory.embedding import EmbeddingProvider, EmbeddingCache
|
||||||
from agent.memory.summarizer import MemoryFlushManager, create_memory_files_if_needed
|
from agent.memory.summarizer import MemoryFlushManager, create_memory_files_if_needed
|
||||||
|
|
||||||
|
|
||||||
@@ -50,30 +50,22 @@ class MemoryManager:
|
|||||||
overlap_tokens=self.config.chunk_overlap_tokens
|
overlap_tokens=self.config.chunk_overlap_tokens
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize embedding provider (optional)
|
# Embedding provider is owned by the caller (agent_initializer is the
|
||||||
self.embedding_provider = None
|
# canonical entry point and handles legacy/explicit + state validation).
|
||||||
if embedding_provider:
|
# When None is passed, memory degrades to keyword-only search instead
|
||||||
self.embedding_provider = embedding_provider
|
# of silently re-initializing a vendor here, which would bypass the
|
||||||
else:
|
# caller's state checks and risk corrupting the index.
|
||||||
# Try to create embedding provider, but allow failure
|
self.embedding_provider = embedding_provider
|
||||||
try:
|
if self.embedding_provider is None:
|
||||||
# Get API key from environment or config
|
from common.log import logger
|
||||||
api_key = os.environ.get('OPENAI_API_KEY')
|
logger.info(
|
||||||
api_base = os.environ.get('OPENAI_API_BASE')
|
"[MemoryManager] No embedding provider; memory will use keyword search only"
|
||||||
|
)
|
||||||
self.embedding_provider = create_embedding_provider(
|
|
||||||
provider=self.config.embedding_provider,
|
# Cache for query embeddings (avoids redundant API calls within a session)
|
||||||
model=self.config.embedding_model,
|
self._embedding_cache = EmbeddingCache()
|
||||||
api_key=api_key,
|
|
||||||
api_base=api_base
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
# Embedding provider failed, but that's OK
|
|
||||||
# We can still use keyword search and file operations
|
|
||||||
from common.log import logger
|
|
||||||
logger.warning(f"[MemoryManager] Embedding provider initialization failed: {e}")
|
|
||||||
logger.info(f"[MemoryManager] Memory will work with keyword search only (no vector search)")
|
|
||||||
|
|
||||||
# Initialize memory flush manager
|
# Initialize memory flush manager
|
||||||
workspace_dir = self.config.get_workspace()
|
workspace_dir = self.config.get_workspace()
|
||||||
self.flush_manager = MemoryFlushManager(
|
self.flush_manager = MemoryFlushManager(
|
||||||
@@ -133,12 +125,21 @@ class MemoryManager:
|
|||||||
if self.config.sync_on_search and self._dirty:
|
if self.config.sync_on_search and self._dirty:
|
||||||
await self.sync()
|
await self.sync()
|
||||||
|
|
||||||
# Perform vector search (if embedding provider available)
|
from common.log import logger
|
||||||
|
|
||||||
|
# Perform vector search (if embedding provider available).
|
||||||
|
# Failures degrade silently to keyword-only — no exception is raised.
|
||||||
vector_results = []
|
vector_results = []
|
||||||
if self.embedding_provider:
|
if self.embedding_provider:
|
||||||
try:
|
try:
|
||||||
from common.log import logger
|
provider_name = type(self.embedding_provider).__name__
|
||||||
query_embedding = self.embedding_provider.embed(query)
|
model_name = getattr(self.embedding_provider, 'model', '')
|
||||||
|
cached = self._embedding_cache.get(query, provider_name, model_name)
|
||||||
|
if cached is not None:
|
||||||
|
query_embedding = cached
|
||||||
|
else:
|
||||||
|
query_embedding = self.embedding_provider.embed_query(query)
|
||||||
|
self._embedding_cache.put(query, provider_name, model_name, query_embedding)
|
||||||
vector_results = self.storage.search_vector(
|
vector_results = self.storage.search_vector(
|
||||||
query_embedding=query_embedding,
|
query_embedding=query_embedding,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -147,19 +148,19 @@ class MemoryManager:
|
|||||||
)
|
)
|
||||||
logger.info(f"[MemoryManager] Vector search found {len(vector_results)} results for query: {query}")
|
logger.info(f"[MemoryManager] Vector search found {len(vector_results)} results for query: {query}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
from common.log import logger
|
logger.error(
|
||||||
logger.warning(f"[MemoryManager] Vector search failed: {e}")
|
f"[MemoryManager] Vector search failed, falling back to keyword-only: {e}"
|
||||||
|
)
|
||||||
# Perform keyword search
|
|
||||||
|
# Perform keyword search (also runs as fallback when vector failed)
|
||||||
keyword_results = self.storage.search_keyword(
|
keyword_results = self.storage.search_keyword(
|
||||||
query=query,
|
query=query,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
scopes=scopes,
|
scopes=scopes,
|
||||||
limit=max_results * 2
|
limit=max_results * 2
|
||||||
)
|
)
|
||||||
from common.log import logger
|
|
||||||
logger.info(f"[MemoryManager] Keyword search found {len(keyword_results)} results for query: {query}")
|
logger.info(f"[MemoryManager] Keyword search found {len(keyword_results)} results for query: {query}")
|
||||||
|
|
||||||
# Merge results
|
# Merge results
|
||||||
merged = self._merge_results(
|
merged = self._merge_results(
|
||||||
vector_results,
|
vector_results,
|
||||||
@@ -167,7 +168,7 @@ class MemoryManager:
|
|||||||
self.config.vector_weight,
|
self.config.vector_weight,
|
||||||
self.config.keyword_weight
|
self.config.keyword_weight
|
||||||
)
|
)
|
||||||
|
|
||||||
# Filter by min score and limit
|
# Filter by min score and limit
|
||||||
filtered = [r for r in merged if r.score >= min_score]
|
filtered = [r for r in merged if r.score >= min_score]
|
||||||
return filtered[:max_results]
|
return filtered[:max_results]
|
||||||
@@ -249,295 +250,195 @@ class MemoryManager:
|
|||||||
|
|
||||||
async def sync(self, force: bool = False):
|
async def sync(self, force: bool = False):
|
||||||
"""
|
"""
|
||||||
Synchronize memory from files
|
Synchronize memory from files.
|
||||||
|
|
||||||
|
Two-pass design to amortize embedding HTTP cost:
|
||||||
|
1. Walk all files, chunk those whose hash changed, collect pending
|
||||||
|
chunks across files. No embedding calls yet.
|
||||||
|
2. Run a single embed_batch over the union of pending chunks (the
|
||||||
|
provider auto-paginates by vendor cap), then persist per-file.
|
||||||
|
|
||||||
|
For workspaces with many small files (101 files / ~1 chunk each), this
|
||||||
|
cuts ~100 HTTP calls down to ~ceil(total_chunks / vendor_cap).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
force: Force full reindex
|
force: Force full reindex
|
||||||
"""
|
"""
|
||||||
memory_dir = self.config.get_memory_dir()
|
memory_dir = self.config.get_memory_dir()
|
||||||
workspace_dir = self.config.get_workspace()
|
workspace_dir = self.config.get_workspace()
|
||||||
|
|
||||||
# Scan MEMORY.md (workspace root)
|
files_to_scan: List[tuple] = [] # (file_path, source, scope, user_id)
|
||||||
|
|
||||||
memory_file = Path(workspace_dir) / "MEMORY.md"
|
memory_file = Path(workspace_dir) / "MEMORY.md"
|
||||||
if memory_file.exists():
|
if memory_file.exists():
|
||||||
await self._sync_file(memory_file, "memory", "shared", None)
|
files_to_scan.append((memory_file, "memory", "shared", None))
|
||||||
|
|
||||||
# Scan memory directory (including daily summaries)
|
|
||||||
if memory_dir.exists():
|
if memory_dir.exists():
|
||||||
for file_path in memory_dir.rglob("*.md"):
|
for file_path in memory_dir.rglob("*.md"):
|
||||||
# Determine scope and user_id from path
|
rel_parts = file_path.relative_to(workspace_dir).parts
|
||||||
rel_path = file_path.relative_to(workspace_dir)
|
if any(part.startswith('.') for part in rel_parts):
|
||||||
parts = rel_path.parts
|
continue
|
||||||
|
# Dream diaries are narrative reflections produced by Deep
|
||||||
# Check if it's in daily summary directory
|
# Dream; their factual content has already been distilled
|
||||||
if "daily" in parts:
|
# into MEMORY.md. Indexing them adds noisy near-duplicates
|
||||||
# Daily summary files
|
# that crowd out the authoritative entry in retrieval.
|
||||||
if "users" in parts or len(parts) > 3:
|
if "dreams" in rel_parts:
|
||||||
# User-scoped daily summary: memory/daily/{user_id}/2024-01-29.md
|
continue
|
||||||
user_idx = parts.index("daily") + 1
|
if "daily" in rel_parts:
|
||||||
user_id = parts[user_idx] if user_idx < len(parts) else None
|
if "users" in rel_parts or len(rel_parts) > 3:
|
||||||
|
user_idx = rel_parts.index("daily") + 1
|
||||||
|
user_id = rel_parts[user_idx] if user_idx < len(rel_parts) else None
|
||||||
scope = "user"
|
scope = "user"
|
||||||
else:
|
else:
|
||||||
# Shared daily summary: memory/daily/2024-01-29.md
|
|
||||||
user_id = None
|
user_id = None
|
||||||
scope = "shared"
|
scope = "shared"
|
||||||
elif "users" in parts:
|
elif "users" in rel_parts:
|
||||||
# User-scoped memory
|
user_idx = rel_parts.index("users") + 1
|
||||||
user_idx = parts.index("users") + 1
|
user_id = rel_parts[user_idx] if user_idx < len(rel_parts) else None
|
||||||
user_id = parts[user_idx] if user_idx < len(parts) else None
|
|
||||||
scope = "user"
|
scope = "user"
|
||||||
else:
|
else:
|
||||||
# Shared memory
|
|
||||||
user_id = None
|
user_id = None
|
||||||
scope = "shared"
|
scope = "shared"
|
||||||
|
files_to_scan.append((file_path, "memory", scope, user_id))
|
||||||
await self._sync_file(file_path, "memory", scope, user_id)
|
|
||||||
|
|
||||||
self._dirty = False
|
|
||||||
|
|
||||||
async def _sync_file(
|
|
||||||
self,
|
|
||||||
file_path: Path,
|
|
||||||
source: str,
|
|
||||||
scope: str,
|
|
||||||
user_id: Optional[str]
|
|
||||||
):
|
|
||||||
"""Sync a single file"""
|
|
||||||
# Compute file hash
|
|
||||||
content = file_path.read_text(encoding='utf-8')
|
|
||||||
file_hash = MemoryStorage.compute_hash(content)
|
|
||||||
|
|
||||||
# Get relative path
|
|
||||||
workspace_dir = self.config.get_workspace()
|
|
||||||
rel_path = str(file_path.relative_to(workspace_dir))
|
|
||||||
|
|
||||||
# Check if file changed
|
|
||||||
stored_hash = self.storage.get_file_hash(rel_path)
|
|
||||||
if stored_hash == file_hash:
|
|
||||||
return # No changes
|
|
||||||
|
|
||||||
# Delete old chunks
|
|
||||||
self.storage.delete_by_path(rel_path)
|
|
||||||
|
|
||||||
# Chunk and embed
|
|
||||||
chunks = self.chunker.chunk_text(content)
|
|
||||||
if not chunks:
|
|
||||||
return
|
|
||||||
|
|
||||||
texts = [chunk.text for chunk in chunks]
|
|
||||||
if self.embedding_provider:
|
|
||||||
embeddings = self.embedding_provider.embed_batch(texts)
|
|
||||||
else:
|
|
||||||
embeddings = [None] * len(texts)
|
|
||||||
|
|
||||||
# Create memory chunks
|
|
||||||
memory_chunks = []
|
|
||||||
for chunk, embedding in zip(chunks, embeddings):
|
|
||||||
chunk_id = self._generate_chunk_id(rel_path, chunk.start_line, chunk.end_line)
|
|
||||||
chunk_hash = MemoryStorage.compute_hash(chunk.text)
|
|
||||||
|
|
||||||
memory_chunks.append(MemoryChunk(
|
|
||||||
id=chunk_id,
|
|
||||||
user_id=user_id,
|
|
||||||
scope=scope,
|
|
||||||
source=source,
|
|
||||||
path=rel_path,
|
|
||||||
start_line=chunk.start_line,
|
|
||||||
end_line=chunk.end_line,
|
|
||||||
text=chunk.text,
|
|
||||||
embedding=embedding,
|
|
||||||
hash=chunk_hash,
|
|
||||||
metadata=None
|
|
||||||
))
|
|
||||||
|
|
||||||
# Save
|
|
||||||
self.storage.save_chunks_batch(memory_chunks)
|
|
||||||
|
|
||||||
# Update file metadata
|
|
||||||
stat = file_path.stat()
|
|
||||||
self.storage.update_file_metadata(
|
|
||||||
path=rel_path,
|
|
||||||
source=source,
|
|
||||||
file_hash=file_hash,
|
|
||||||
mtime=int(stat.st_mtime),
|
|
||||||
size=stat.st_size
|
|
||||||
)
|
|
||||||
|
|
||||||
def should_flush_memory(
|
|
||||||
self,
|
|
||||||
current_tokens: int = 0
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Check if memory flush should be triggered
|
|
||||||
|
|
||||||
独立的 flush 触发机制,不依赖模型 context window。
|
|
||||||
使用配置中的阈值: flush_token_threshold 和 flush_turn_threshold
|
|
||||||
|
|
||||||
Args:
|
|
||||||
current_tokens: Current session token count
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if memory flush should run
|
|
||||||
"""
|
|
||||||
return self.flush_manager.should_flush(
|
|
||||||
current_tokens=current_tokens,
|
|
||||||
token_threshold=self.config.flush_token_threshold,
|
|
||||||
turn_threshold=self.config.flush_turn_threshold
|
|
||||||
)
|
|
||||||
|
|
||||||
def increment_turn(self):
|
|
||||||
"""增加对话轮数计数(每次用户消息+AI回复算一轮)"""
|
|
||||||
self.flush_manager.increment_turn()
|
|
||||||
|
|
||||||
async def execute_memory_flush(
|
|
||||||
self,
|
|
||||||
agent_executor,
|
|
||||||
current_tokens: int,
|
|
||||||
user_id: Optional[str] = None,
|
|
||||||
**executor_kwargs
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Execute memory flush before compaction
|
|
||||||
|
|
||||||
This runs a silent agent turn to write durable memories to disk.
|
|
||||||
Similar to clawdbot's pre-compaction memory flush.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
agent_executor: Async function to execute agent with prompt
|
|
||||||
current_tokens: Current session token count
|
|
||||||
user_id: Optional user ID
|
|
||||||
**executor_kwargs: Additional kwargs for agent executor
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if flush completed successfully
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> async def run_agent(prompt, system_prompt, silent=False):
|
|
||||||
... # Your agent execution logic
|
|
||||||
... pass
|
|
||||||
>>>
|
|
||||||
>>> if manager.should_flush_memory(current_tokens=100000):
|
|
||||||
... await manager.execute_memory_flush(
|
|
||||||
... agent_executor=run_agent,
|
|
||||||
... current_tokens=100000
|
|
||||||
... )
|
|
||||||
"""
|
|
||||||
success = await self.flush_manager.execute_flush(
|
|
||||||
agent_executor=agent_executor,
|
|
||||||
current_tokens=current_tokens,
|
|
||||||
user_id=user_id,
|
|
||||||
**executor_kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
# Mark dirty so next search will sync the new memories
|
|
||||||
self._dirty = True
|
|
||||||
|
|
||||||
return success
|
|
||||||
|
|
||||||
def build_memory_guidance(self, lang: str = "zh", include_context: bool = True) -> str:
|
|
||||||
"""
|
|
||||||
Build natural memory guidance for agent system prompt
|
|
||||||
|
|
||||||
Following clawdbot's approach:
|
|
||||||
1. Load MEMORY.md as bootstrap context (blends into background)
|
|
||||||
2. Load daily files on-demand via memory_search tool
|
|
||||||
3. Agent should NOT proactively mention memories unless user asks
|
|
||||||
|
|
||||||
Args:
|
|
||||||
lang: Language for guidance ("en" or "zh")
|
|
||||||
include_context: Whether to include bootstrap memory context (default: True)
|
|
||||||
MEMORY.md is loaded as background context (like clawdbot)
|
|
||||||
Daily files are accessed via memory_search tool
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Memory guidance text (and optionally context) for system prompt
|
|
||||||
"""
|
|
||||||
today_file = self.flush_manager.get_today_memory_file().name
|
|
||||||
|
|
||||||
if lang == "zh":
|
|
||||||
guidance = f"""## 记忆系统
|
|
||||||
|
|
||||||
**背景知识**: 下方包含核心长期记忆,可直接使用。需要查找历史时,用 memory_search 搜索(搜索一次即可,不要重复)。
|
from config import conf
|
||||||
|
if conf().get("knowledge", True):
|
||||||
|
knowledge_dir = Path(workspace_dir) / "knowledge"
|
||||||
|
if knowledge_dir.exists():
|
||||||
|
for file_path in knowledge_dir.rglob("*.md"):
|
||||||
|
files_to_scan.append((file_path, "knowledge", "shared", None))
|
||||||
|
|
||||||
**存储记忆**: 当用户分享重要信息时(偏好、决策、事实等),主动用 write 工具存储:
|
# Pass 1: inline chunking + change detection. Inlined (instead of
|
||||||
- 长期信息 → MEMORY.md
|
# calling self._prepare_file_for_sync) so this method does not depend
|
||||||
- 当天笔记 → memory/{today_file}
|
# on any sibling helpers — keeps it robust against partial reloads
|
||||||
- 静默存储,仅在明确要求时确认
|
# where the class object is older than the method's source.
|
||||||
|
pending: List[Dict[str, Any]] = []
|
||||||
**使用原则**: 自然使用记忆,就像你本来就知道。不需要生硬地提起或列举记忆,除非用户提到。"""
|
workspace_dir_path = self.config.get_workspace()
|
||||||
else:
|
for file_path, source, scope, user_id in files_to_scan:
|
||||||
guidance = f"""## Memory System
|
|
||||||
|
|
||||||
**Background Knowledge**: Core long-term memories below - use directly. For history, use memory_search once (don't repeat).
|
|
||||||
|
|
||||||
**Store Memories**: When user shares important info (preferences, decisions, facts), proactively write:
|
|
||||||
- Durable info → MEMORY.md
|
|
||||||
- Daily notes → memory/{today_file}
|
|
||||||
- Store silently; confirm only when explicitly requested
|
|
||||||
|
|
||||||
**Usage**: Use memories naturally as if you always knew. Don't mention or list unless user explicitly asks."""
|
|
||||||
|
|
||||||
if include_context:
|
|
||||||
# Load bootstrap context (MEMORY.md only, like clawdbot)
|
|
||||||
bootstrap_context = self.load_bootstrap_memories()
|
|
||||||
if bootstrap_context:
|
|
||||||
guidance += f"\n\n## Background Context\n\n{bootstrap_context}"
|
|
||||||
|
|
||||||
return guidance
|
|
||||||
|
|
||||||
def load_bootstrap_memories(self, user_id: Optional[str] = None) -> str:
|
|
||||||
"""
|
|
||||||
Load bootstrap memory files for session start
|
|
||||||
|
|
||||||
Following clawdbot's design:
|
|
||||||
- Only loads MEMORY.md from workspace root (long-term curated memory)
|
|
||||||
- Daily files (memory/YYYY-MM-DD.md) are accessed via memory_search tool, not bootstrap
|
|
||||||
- User-specific MEMORY.md is also loaded if user_id provided
|
|
||||||
|
|
||||||
Returns memory content WITHOUT obvious headers so it blends naturally
|
|
||||||
into the context as background knowledge.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: Optional user ID for user-specific memories
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Memory content to inject into system prompt (blends naturally as background context)
|
|
||||||
"""
|
|
||||||
workspace_dir = self.config.get_workspace()
|
|
||||||
memory_dir = self.config.get_memory_dir()
|
|
||||||
|
|
||||||
sections = []
|
|
||||||
|
|
||||||
# 1. Load MEMORY.md from workspace root (long-term curated memory)
|
|
||||||
# Following clawdbot: only MEMORY.md is bootstrap, daily files use memory_search
|
|
||||||
memory_file = Path(workspace_dir) / "MEMORY.md"
|
|
||||||
if memory_file.exists():
|
|
||||||
try:
|
try:
|
||||||
content = memory_file.read_text(encoding='utf-8').strip()
|
content = file_path.read_text(encoding='utf-8')
|
||||||
if content:
|
except Exception:
|
||||||
sections.append(content)
|
continue
|
||||||
|
file_hash = MemoryStorage.compute_hash(content)
|
||||||
|
rel_path = str(file_path.relative_to(workspace_dir_path))
|
||||||
|
if self.storage.get_file_hash(rel_path) == file_hash:
|
||||||
|
continue
|
||||||
|
chunks = self.chunker.chunk_text(content)
|
||||||
|
if not chunks:
|
||||||
|
continue
|
||||||
|
pending.append({
|
||||||
|
"file_path": file_path,
|
||||||
|
"rel_path": rel_path,
|
||||||
|
"source": source,
|
||||||
|
"scope": scope,
|
||||||
|
"user_id": user_id,
|
||||||
|
"file_hash": file_hash,
|
||||||
|
"chunks": chunks,
|
||||||
|
"texts": [c.text for c in chunks],
|
||||||
|
})
|
||||||
|
|
||||||
|
if not pending:
|
||||||
|
self._dirty = False
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pass 2: single batched embed across all pending chunks.
|
||||||
|
# CRITICAL: never touch the index until we hold valid embeddings.
|
||||||
|
# If embed_batch fails, leave the existing index intact (chunks +
|
||||||
|
# file_hash) so the next sync will retry the same files. Writing
|
||||||
|
# NULL embeddings + updating file_hash here would mark the file as
|
||||||
|
# "successfully synced" and silently strand it without vectors.
|
||||||
|
all_texts: List[str] = []
|
||||||
|
for entry in pending:
|
||||||
|
all_texts.extend(entry["texts"])
|
||||||
|
|
||||||
|
if not self.embedding_provider:
|
||||||
|
# No provider configured at all (legacy keyword-only). Persist
|
||||||
|
# chunks without embeddings — this is the user's intent.
|
||||||
|
all_embeddings: List[Optional[List[float]]] = [None] * len(all_texts)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
all_embeddings = self.embedding_provider.embed_batch(all_texts)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Warning: Failed to read MEMORY.md: {e}")
|
from common.log import logger
|
||||||
|
logger.error(
|
||||||
# 2. Load user-specific MEMORY.md if user_id provided
|
f"[MemoryManager] Batch embedding failed for {len(all_texts)} "
|
||||||
if user_id:
|
f"chunks across {len(pending)} files: {e}. "
|
||||||
user_memory_dir = memory_dir / "users" / user_id
|
f"Index left untouched; will retry on next sync."
|
||||||
user_memory_file = user_memory_dir / "MEMORY.md"
|
)
|
||||||
if user_memory_file.exists():
|
# Bail before touching storage. self._dirty stays True so
|
||||||
try:
|
# callers know there is pending work.
|
||||||
content = user_memory_file.read_text(encoding='utf-8').strip()
|
return
|
||||||
if content:
|
|
||||||
sections.append(content)
|
# Pass 3: inline persist — same self-contained reasoning as Pass 1.
|
||||||
except Exception as e:
|
cursor = 0
|
||||||
print(f"Warning: Failed to read user memory: {e}")
|
for entry in pending:
|
||||||
|
n = len(entry["texts"])
|
||||||
if not sections:
|
entry_embeddings = all_embeddings[cursor:cursor + n]
|
||||||
return ""
|
cursor += n
|
||||||
|
|
||||||
# Join sections without obvious headers - let memories blend naturally
|
rel_path = entry["rel_path"]
|
||||||
# This makes the agent feel like it "just knows" rather than "checking memory files"
|
self.storage.delete_by_path(rel_path)
|
||||||
return "\n\n".join(sections)
|
memory_chunks = []
|
||||||
|
for chunk, embedding in zip(entry["chunks"], entry_embeddings):
|
||||||
|
chunk_id = self._generate_chunk_id(rel_path, chunk.start_line, chunk.end_line)
|
||||||
|
chunk_hash = MemoryStorage.compute_hash(chunk.text)
|
||||||
|
memory_chunks.append(MemoryChunk(
|
||||||
|
id=chunk_id,
|
||||||
|
user_id=entry["user_id"],
|
||||||
|
scope=entry["scope"],
|
||||||
|
source=entry["source"],
|
||||||
|
path=rel_path,
|
||||||
|
start_line=chunk.start_line,
|
||||||
|
end_line=chunk.end_line,
|
||||||
|
text=chunk.text,
|
||||||
|
embedding=embedding,
|
||||||
|
hash=chunk_hash,
|
||||||
|
metadata=None,
|
||||||
|
))
|
||||||
|
self.storage.save_chunks_batch(memory_chunks)
|
||||||
|
stat = entry["file_path"].stat()
|
||||||
|
self.storage.update_file_metadata(
|
||||||
|
path=rel_path,
|
||||||
|
source=entry["source"],
|
||||||
|
file_hash=entry["file_hash"],
|
||||||
|
mtime=int(stat.st_mtime),
|
||||||
|
size=stat.st_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._dirty = False
|
||||||
|
|
||||||
|
def flush_memory(
|
||||||
|
self,
|
||||||
|
messages: list,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
reason: str = "threshold",
|
||||||
|
max_messages: int = 10,
|
||||||
|
context_summary_callback=None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Flush conversation summary to daily memory file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: Conversation message list
|
||||||
|
user_id: Optional user ID
|
||||||
|
reason: "threshold" | "overflow" | "daily_summary"
|
||||||
|
max_messages: Max recent messages to include (0 = all)
|
||||||
|
context_summary_callback: Optional callback(str) invoked with the
|
||||||
|
daily summary text for in-context injection
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if flush was dispatched
|
||||||
|
"""
|
||||||
|
success = self.flush_manager.flush_from_messages(
|
||||||
|
messages=messages,
|
||||||
|
user_id=user_id,
|
||||||
|
reason=reason,
|
||||||
|
max_messages=max_messages,
|
||||||
|
context_summary_callback=context_summary_callback,
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
self._dirty = True
|
||||||
|
return success
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
def get_status(self) -> Dict[str, Any]:
|
||||||
"""Get memory status"""
|
"""Get memory status"""
|
||||||
@@ -568,6 +469,37 @@ class MemoryManager:
|
|||||||
content = f"{path}:{start_line}:{end_line}"
|
content = f"{path}:{start_line}:{end_line}"
|
||||||
return hashlib.md5(content.encode('utf-8')).hexdigest()
|
return hashlib.md5(content.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _compute_temporal_decay(path: str, half_life_days: float = 30.0) -> float:
|
||||||
|
"""
|
||||||
|
Compute temporal decay multiplier for dated memory files.
|
||||||
|
|
||||||
|
Inspired by OpenClaw's temporal-decay: exponential decay based on file date.
|
||||||
|
MEMORY.md and non-dated files are "evergreen" (no decay, multiplier=1.0).
|
||||||
|
Daily files like memory/2025-03-01.md decay based on age.
|
||||||
|
|
||||||
|
Formula: multiplier = exp(-ln2/half_life * age_in_days)
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import math
|
||||||
|
|
||||||
|
match = re.search(r'(\d{4})-(\d{2})-(\d{2})\.md$', path)
|
||||||
|
if not match:
|
||||||
|
return 1.0 # evergreen: MEMORY.md, non-dated files
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_date = datetime(
|
||||||
|
int(match.group(1)), int(match.group(2)), int(match.group(3))
|
||||||
|
)
|
||||||
|
age_days = (datetime.now() - file_date).days
|
||||||
|
if age_days <= 0:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
decay_lambda = math.log(2) / half_life_days
|
||||||
|
return math.exp(-decay_lambda * age_days)
|
||||||
|
except (ValueError, OverflowError):
|
||||||
|
return 1.0
|
||||||
|
|
||||||
def _merge_results(
|
def _merge_results(
|
||||||
self,
|
self,
|
||||||
vector_results: List[SearchResult],
|
vector_results: List[SearchResult],
|
||||||
@@ -575,8 +507,7 @@ class MemoryManager:
|
|||||||
vector_weight: float,
|
vector_weight: float,
|
||||||
keyword_weight: float
|
keyword_weight: float
|
||||||
) -> List[SearchResult]:
|
) -> List[SearchResult]:
|
||||||
"""Merge vector and keyword search results"""
|
"""Merge vector and keyword search results with temporal decay for dated files"""
|
||||||
# Create a map by (path, start_line, end_line)
|
|
||||||
merged_map = {}
|
merged_map = {}
|
||||||
|
|
||||||
for result in vector_results:
|
for result in vector_results:
|
||||||
@@ -598,7 +529,6 @@ class MemoryManager:
|
|||||||
'keyword_score': result.score
|
'keyword_score': result.score
|
||||||
}
|
}
|
||||||
|
|
||||||
# Calculate combined scores
|
|
||||||
merged_results = []
|
merged_results = []
|
||||||
for entry in merged_map.values():
|
for entry in merged_map.values():
|
||||||
combined_score = (
|
combined_score = (
|
||||||
@@ -606,7 +536,11 @@ class MemoryManager:
|
|||||||
keyword_weight * entry['keyword_score']
|
keyword_weight * entry['keyword_score']
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Apply temporal decay for dated memory files
|
||||||
result = entry['result']
|
result = entry['result']
|
||||||
|
decay = self._compute_temporal_decay(result.path)
|
||||||
|
combined_score *= decay
|
||||||
|
|
||||||
merged_results.append(SearchResult(
|
merged_results.append(SearchResult(
|
||||||
path=result.path,
|
path=result.path,
|
||||||
start_line=result.start_line,
|
start_line=result.start_line,
|
||||||
@@ -617,6 +551,5 @@ class MemoryManager:
|
|||||||
user_id=result.user_id
|
user_id=result.user_id
|
||||||
))
|
))
|
||||||
|
|
||||||
# Sort by score
|
|
||||||
merged_results.sort(key=lambda r: r.score, reverse=True)
|
merged_results.sort(key=lambda r: r.score, reverse=True)
|
||||||
return merged_results
|
return merged_results
|
||||||
|
|||||||
14
agent/memory/rebuild_index.py
Normal file
14
agent/memory/rebuild_index.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""
|
||||||
|
Backward-compatible shim for the legacy entry point:
|
||||||
|
python -m agent.memory.rebuild_index
|
||||||
|
|
||||||
|
The implementation now lives in agent.memory.embedding.rebuild.
|
||||||
|
Prefer using `/memory rebuild-index` in chat going forward.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from agent.memory.embedding.rebuild import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.exit(main())
|
||||||
@@ -32,68 +32,105 @@ class MemoryService:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# list — paginated file metadata
|
# list — paginated file metadata
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def list_files(self, page: int = 1, page_size: int = 20) -> dict:
|
def list_files(self, page: int = 1, page_size: int = 20, category: str = "memory") -> dict:
|
||||||
"""
|
"""
|
||||||
List all memory files with metadata (without content).
|
List memory, dream, or evolution files with metadata (without content).
|
||||||
|
|
||||||
Returns::
|
Args:
|
||||||
|
category: ``"memory"`` (default) — MEMORY.md + daily files;
|
||||||
{
|
``"dream"`` — dream diary files from memory/dreams/;
|
||||||
"page": 1,
|
``"evolution"`` — self-evolution logs from memory/evolution/
|
||||||
"page_size": 20,
|
merged with the nightly dream diaries, so
|
||||||
"total": 15,
|
one tab shows everything the agent learned.
|
||||||
"list": [
|
|
||||||
{"filename": "MEMORY.md", "type": "global", "size": 2048, "updated_at": "2026-02-20 10:00:00"},
|
|
||||||
{"filename": "2026-02-20.md", "type": "daily", "size": 512, "updated_at": "2026-02-20 09:30:00"},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
|
if category == "evolution":
|
||||||
|
files = self._list_evolution_files()
|
||||||
|
elif category == "dream":
|
||||||
|
files = self._list_dream_files()
|
||||||
|
else:
|
||||||
|
files = self._list_memory_files()
|
||||||
|
|
||||||
|
total = len(files)
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
end = start + page_size
|
||||||
|
|
||||||
|
return {
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total": total,
|
||||||
|
"list": files[start:end],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _list_memory_files(self) -> List[dict]:
|
||||||
|
"""MEMORY.md + memory/*.md (newest first)."""
|
||||||
files: List[dict] = []
|
files: List[dict] = []
|
||||||
|
|
||||||
# 1. Global memory — MEMORY.md in workspace root
|
|
||||||
global_path = os.path.join(self.workspace_root, "MEMORY.md")
|
global_path = os.path.join(self.workspace_root, "MEMORY.md")
|
||||||
if os.path.isfile(global_path):
|
if os.path.isfile(global_path):
|
||||||
files.append(self._file_info(global_path, "MEMORY.md", "global"))
|
files.append(self._file_info(global_path, "MEMORY.md", "global"))
|
||||||
|
|
||||||
# 2. Daily memory files — memory/*.md (sorted newest first)
|
|
||||||
if os.path.isdir(self.memory_dir):
|
if os.path.isdir(self.memory_dir):
|
||||||
daily_files = []
|
daily_files = []
|
||||||
for name in os.listdir(self.memory_dir):
|
for name in os.listdir(self.memory_dir):
|
||||||
full = os.path.join(self.memory_dir, name)
|
full = os.path.join(self.memory_dir, name)
|
||||||
if os.path.isfile(full) and name.endswith(".md"):
|
if os.path.isfile(full) and name.endswith(".md"):
|
||||||
daily_files.append((name, full))
|
daily_files.append((name, full))
|
||||||
# Sort by filename descending (newest date first)
|
|
||||||
daily_files.sort(key=lambda x: x[0], reverse=True)
|
daily_files.sort(key=lambda x: x[0], reverse=True)
|
||||||
for name, full in daily_files:
|
for name, full in daily_files:
|
||||||
files.append(self._file_info(full, name, "daily"))
|
files.append(self._file_info(full, name, "daily"))
|
||||||
|
|
||||||
total = len(files)
|
return files
|
||||||
|
|
||||||
# Paginate
|
def _list_dream_files(self) -> List[dict]:
|
||||||
start = (page - 1) * page_size
|
"""memory/dreams/*.md (newest first)."""
|
||||||
end = start + page_size
|
files: List[dict] = []
|
||||||
page_items = files[start:end]
|
dreams_dir = os.path.join(self.memory_dir, "dreams")
|
||||||
|
|
||||||
return {
|
if os.path.isdir(dreams_dir):
|
||||||
"page": page,
|
entries = []
|
||||||
"page_size": page_size,
|
for name in os.listdir(dreams_dir):
|
||||||
"total": total,
|
full = os.path.join(dreams_dir, name)
|
||||||
"list": page_items,
|
if os.path.isfile(full) and name.endswith(".md"):
|
||||||
}
|
entries.append((name, full))
|
||||||
|
entries.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
for name, full in entries:
|
||||||
|
files.append(self._file_info(full, name, "dream"))
|
||||||
|
|
||||||
|
return files
|
||||||
|
|
||||||
|
def _list_evolution_files(self) -> List[dict]:
|
||||||
|
"""Self-evolution logs (memory/evolution/*.md) merged with the nightly
|
||||||
|
dream diaries (memory/dreams/*.md), newest first.
|
||||||
|
|
||||||
|
Both are surfaced under the unified "Self-Evolution" tab. A file's
|
||||||
|
``type`` records its origin so the reader can resolve the right dir.
|
||||||
|
"""
|
||||||
|
files: List[dict] = []
|
||||||
|
for sub, ftype in (("evolution", "evolution"), ("dreams", "dream")):
|
||||||
|
sub_dir = os.path.join(self.memory_dir, sub)
|
||||||
|
if not os.path.isdir(sub_dir):
|
||||||
|
continue
|
||||||
|
for name in os.listdir(sub_dir):
|
||||||
|
full = os.path.join(sub_dir, name)
|
||||||
|
if os.path.isfile(full) and name.endswith(".md"):
|
||||||
|
files.append(self._file_info(full, name, ftype))
|
||||||
|
# Sort newest first by filename (date-named); ties favor evolution.
|
||||||
|
files.sort(key=lambda f: (f["filename"], f["type"] != "evolution"), reverse=True)
|
||||||
|
return files
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# content — read a single file
|
# content — read a single file
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def get_content(self, filename: str) -> dict:
|
def get_content(self, filename: str, category: str = "memory") -> dict:
|
||||||
"""
|
"""
|
||||||
Read the full content of a memory file.
|
Read the full content of a memory or dream file.
|
||||||
|
|
||||||
:param filename: File name, e.g. ``MEMORY.md`` or ``2026-02-20.md``
|
:param filename: File name, e.g. ``MEMORY.md``, ``2026-02-20.md``
|
||||||
|
:param category: ``"memory"``, ``"dream"`` or ``"evolution"``
|
||||||
:return: dict with ``filename`` and ``content``
|
:return: dict with ``filename`` and ``content``
|
||||||
:raises FileNotFoundError: if the file does not exist
|
:raises FileNotFoundError: if the file does not exist
|
||||||
"""
|
"""
|
||||||
path = self._resolve_path(filename)
|
path = self._resolve_path(filename, category)
|
||||||
if not os.path.isfile(path):
|
if not os.path.isfile(path):
|
||||||
raise FileNotFoundError(f"Memory file not found: {filename}")
|
raise FileNotFoundError(f"Memory file not found: {filename}")
|
||||||
|
|
||||||
@@ -113,7 +150,7 @@ class MemoryService:
|
|||||||
Dispatch a memory management action.
|
Dispatch a memory management action.
|
||||||
|
|
||||||
:param action: ``list`` or ``content``
|
:param action: ``list`` or ``content``
|
||||||
:param payload: action-specific payload
|
:param payload: action-specific payload (supports ``category``: ``"memory"`` | ``"dream"`` | ``"evolution"``)
|
||||||
:return: protocol-compatible response dict
|
:return: protocol-compatible response dict
|
||||||
"""
|
"""
|
||||||
payload = payload or {}
|
payload = payload or {}
|
||||||
@@ -121,19 +158,23 @@ class MemoryService:
|
|||||||
if action == "list":
|
if action == "list":
|
||||||
page = payload.get("page", 1)
|
page = payload.get("page", 1)
|
||||||
page_size = payload.get("page_size", 20)
|
page_size = payload.get("page_size", 20)
|
||||||
result_payload = self.list_files(page=page, page_size=page_size)
|
category = payload.get("category", "memory")
|
||||||
|
result_payload = self.list_files(page=page, page_size=page_size, category=category)
|
||||||
return {"action": action, "code": 200, "message": "success", "payload": result_payload}
|
return {"action": action, "code": 200, "message": "success", "payload": result_payload}
|
||||||
|
|
||||||
elif action == "content":
|
elif action == "content":
|
||||||
filename = payload.get("filename")
|
filename = payload.get("filename")
|
||||||
if not filename:
|
if not filename:
|
||||||
return {"action": action, "code": 400, "message": "filename is required", "payload": None}
|
return {"action": action, "code": 400, "message": "filename is required", "payload": None}
|
||||||
result_payload = self.get_content(filename)
|
category = payload.get("category", "memory")
|
||||||
|
result_payload = self.get_content(filename, category=category)
|
||||||
return {"action": action, "code": 200, "message": "success", "payload": result_payload}
|
return {"action": action, "code": 200, "message": "success", "payload": result_payload}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
|
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
return {"action": action, "code": 403, "message": "invalid filename", "payload": None}
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
return {"action": action, "code": 404, "message": str(e), "payload": None}
|
return {"action": action, "code": 404, "message": str(e), "payload": None}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -143,16 +184,33 @@ class MemoryService:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# internal helpers
|
# internal helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def _resolve_path(self, filename: str) -> str:
|
def _resolve_path(self, filename: str, category: str = "memory") -> str:
|
||||||
"""
|
"""
|
||||||
Resolve a filename to its absolute path.
|
Safely resolve a filename to its absolute path within the allowed directory.
|
||||||
|
|
||||||
- ``MEMORY.md`` → ``{workspace_root}/MEMORY.md``
|
- ``MEMORY.md`` → ``{workspace_root}/MEMORY.md``
|
||||||
- ``2026-02-20.md`` → ``{workspace_root}/memory/2026-02-20.md``
|
- ``2026-02-20.md`` (memory) → ``{workspace_root}/memory/2026-02-20.md``
|
||||||
|
- ``2026-02-20.md`` (dream) → ``{workspace_root}/memory/dreams/2026-02-20.md``
|
||||||
|
- ``2026-02-20.md`` (evolution) → ``{workspace_root}/memory/evolution/2026-02-20.md``
|
||||||
|
|
||||||
|
Raises ValueError if the resolved path escapes the allowed directory.
|
||||||
"""
|
"""
|
||||||
if filename == "MEMORY.md":
|
if filename == "MEMORY.md":
|
||||||
return os.path.join(self.workspace_root, filename)
|
base_dir = self.workspace_root
|
||||||
return os.path.join(self.memory_dir, filename)
|
elif category == "dream":
|
||||||
|
base_dir = os.path.join(self.memory_dir, "dreams")
|
||||||
|
elif category == "evolution":
|
||||||
|
base_dir = os.path.join(self.memory_dir, "evolution")
|
||||||
|
else:
|
||||||
|
base_dir = self.memory_dir
|
||||||
|
|
||||||
|
resolved = os.path.realpath(os.path.join(base_dir, filename))
|
||||||
|
allowed = os.path.realpath(base_dir)
|
||||||
|
|
||||||
|
if resolved != allowed and not resolved.startswith(allowed + os.sep):
|
||||||
|
raise ValueError(f"Invalid filename: path traversal detected")
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _file_info(path: str, filename: str, file_type: str) -> dict:
|
def _file_info(path: str, filename: str, file_type: str) -> dict:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -10,17 +10,18 @@ from typing import List, Dict, Optional, Any
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
|
from config import conf
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ContextFile:
|
class ContextFile:
|
||||||
"""上下文文件"""
|
"""A context file (path + content)."""
|
||||||
path: str
|
path: str
|
||||||
content: str
|
content: str
|
||||||
|
|
||||||
|
|
||||||
class PromptBuilder:
|
class PromptBuilder:
|
||||||
"""提示词构建器"""
|
"""System prompt builder."""
|
||||||
|
|
||||||
def __init__(self, workspace_dir: str, language: str = "zh"):
|
def __init__(self, workspace_dir: str, language: str = "zh"):
|
||||||
"""
|
"""
|
||||||
@@ -42,7 +43,6 @@ class PromptBuilder:
|
|||||||
skill_manager: Any = None,
|
skill_manager: Any = None,
|
||||||
memory_manager: Any = None,
|
memory_manager: Any = None,
|
||||||
runtime_info: Optional[Dict[str, Any]] = None,
|
runtime_info: Optional[Dict[str, Any]] = None,
|
||||||
is_first_conversation: bool = False,
|
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -52,11 +52,10 @@ class PromptBuilder:
|
|||||||
base_persona: 基础人格描述(会被context_files中的AGENT.md覆盖)
|
base_persona: 基础人格描述(会被context_files中的AGENT.md覆盖)
|
||||||
user_identity: 用户身份信息
|
user_identity: 用户身份信息
|
||||||
tools: 工具列表
|
tools: 工具列表
|
||||||
context_files: 上下文文件列表(AGENT.md, USER.md, RULE.md等)
|
context_files: 上下文文件列表(AGENT.md, USER.md, RULE.md, BOOTSTRAP.md等)
|
||||||
skill_manager: 技能管理器
|
skill_manager: 技能管理器
|
||||||
memory_manager: 记忆管理器
|
memory_manager: 记忆管理器
|
||||||
runtime_info: 运行时信息
|
runtime_info: 运行时信息
|
||||||
is_first_conversation: 是否为首次对话
|
|
||||||
**kwargs: 其他参数
|
**kwargs: 其他参数
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -72,7 +71,6 @@ class PromptBuilder:
|
|||||||
skill_manager=skill_manager,
|
skill_manager=skill_manager,
|
||||||
memory_manager=memory_manager,
|
memory_manager=memory_manager,
|
||||||
runtime_info=runtime_info,
|
runtime_info=runtime_info,
|
||||||
is_first_conversation=is_first_conversation,
|
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -87,96 +85,147 @@ def build_agent_system_prompt(
|
|||||||
skill_manager: Any = None,
|
skill_manager: Any = None,
|
||||||
memory_manager: Any = None,
|
memory_manager: Any = None,
|
||||||
runtime_info: Optional[Dict[str, Any]] = None,
|
runtime_info: Optional[Dict[str, Any]] = None,
|
||||||
is_first_conversation: bool = False,
|
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
构建Agent系统提示词
|
Build the agent system prompt.
|
||||||
|
|
||||||
顺序说明(按重要性和逻辑关系排列):
|
Section order (by importance and logical flow):
|
||||||
1. 工具系统 - 核心能力,最先介绍
|
1. Tooling - core capabilities, introduced first
|
||||||
2. 技能系统 - 紧跟工具,因为技能需要用 read 工具读取
|
2. Skills - right after tools, since skills are read via the read tool
|
||||||
3. 记忆系统 - 独立的记忆能力
|
3. Memory - memory recall and writing guidance
|
||||||
4. 工作空间 - 工作环境说明
|
3.5 Knowledge - structured knowledge base (injects knowledge/index.md)
|
||||||
5. 用户身份 - 用户信息(可选)
|
4. Workspace - working environment description
|
||||||
6. 项目上下文 - AGENT.md, USER.md, RULE.md(定义人格、身份、规则)
|
5. User identity - user info (optional)
|
||||||
7. 运行时信息 - 元信息(时间、模型等)
|
6. Project context - AGENT.md, USER.md, RULE.md, MEMORY.md, BOOTSTRAP.md
|
||||||
|
7. Runtime info - meta info (time, model, etc.)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
workspace_dir: 工作空间目录
|
workspace_dir: workspace directory
|
||||||
language: 语言 ("zh" 或 "en")
|
language: language ("zh" or "en")
|
||||||
base_persona: 基础人格描述(已废弃,由AGENT.md定义)
|
base_persona: base persona description (deprecated, defined by AGENT.md)
|
||||||
user_identity: 用户身份信息
|
user_identity: user identity info
|
||||||
tools: 工具列表
|
tools: tool list
|
||||||
context_files: 上下文文件列表
|
context_files: context file list
|
||||||
skill_manager: 技能管理器
|
skill_manager: skill manager
|
||||||
memory_manager: 记忆管理器
|
memory_manager: memory manager
|
||||||
runtime_info: 运行时信息
|
runtime_info: runtime info
|
||||||
is_first_conversation: 是否为首次对话
|
**kwargs: extra args
|
||||||
**kwargs: 其他参数
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
完整的系统提示词
|
The full system prompt.
|
||||||
"""
|
"""
|
||||||
sections = []
|
sections = []
|
||||||
|
|
||||||
# 1. 工具系统(最重要,放在最前面)
|
# 1. Tooling (most important, goes first)
|
||||||
if tools:
|
if tools:
|
||||||
sections.extend(_build_tooling_section(tools, language))
|
sections.extend(_build_tooling_section(tools, language))
|
||||||
|
|
||||||
# 2. 技能系统(紧跟工具,因为需要用 read 工具)
|
# 2. Skills (right after tools, since they need the read tool)
|
||||||
if skill_manager:
|
if skill_manager:
|
||||||
sections.extend(_build_skills_section(skill_manager, tools, language))
|
sections.extend(_build_skills_section(skill_manager, tools, language))
|
||||||
|
|
||||||
# 3. 记忆系统(独立的记忆能力)
|
# 3. Memory (standalone memory capability)
|
||||||
if memory_manager:
|
if memory_manager:
|
||||||
sections.extend(_build_memory_section(memory_manager, tools, language))
|
sections.extend(_build_memory_section(memory_manager, tools, language))
|
||||||
|
|
||||||
# 4. 工作空间(工作环境说明)
|
# 3.5 Knowledge (structured knowledge base)
|
||||||
sections.extend(_build_workspace_section(workspace_dir, language, is_first_conversation))
|
if conf().get("knowledge", True):
|
||||||
|
sections.extend(_build_knowledge_section(workspace_dir, language))
|
||||||
# 5. 用户身份(如果有)
|
|
||||||
|
# 4. Workspace (working environment description)
|
||||||
|
sections.extend(_build_workspace_section(workspace_dir, language))
|
||||||
|
|
||||||
|
# 5. User identity (if present)
|
||||||
if user_identity:
|
if user_identity:
|
||||||
sections.extend(_build_user_identity_section(user_identity, language))
|
sections.extend(_build_user_identity_section(user_identity, language))
|
||||||
|
|
||||||
# 6. 项目上下文文件(AGENT.md, USER.md, RULE.md - 定义人格)
|
# 6. Project context files (AGENT.md, USER.md, RULE.md - define the persona)
|
||||||
if context_files:
|
if context_files:
|
||||||
sections.extend(_build_context_files_section(context_files, language))
|
sections.extend(_build_context_files_section(context_files, language))
|
||||||
|
|
||||||
# 7. 运行时信息(元信息,放在最后)
|
# 7. Runtime info (meta info, goes last)
|
||||||
if runtime_info:
|
if runtime_info:
|
||||||
sections.extend(_build_runtime_section(runtime_info, language))
|
sections.extend(_build_runtime_section(runtime_info, language))
|
||||||
|
|
||||||
|
# 8. Response language (always appended, independent of the skeleton language)
|
||||||
|
sections.extend(_build_response_language_section(language))
|
||||||
|
|
||||||
return "\n".join(sections)
|
return "\n".join(sections)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_response_language_section(language: str) -> List[str]:
|
||||||
|
"""Response-language rule, appended regardless of the prompt skeleton language.
|
||||||
|
|
||||||
|
Keeps the agent's reply language aligned with the user's input by default,
|
||||||
|
so a Chinese-built prompt still answers an English user in English.
|
||||||
|
"""
|
||||||
|
if language == "en":
|
||||||
|
return [
|
||||||
|
"## 🌐 Response language",
|
||||||
|
"",
|
||||||
|
"By default, reply in the same language as the user's input, "
|
||||||
|
"unless the user explicitly asks for another language.",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
"## 🌐 回复语言",
|
||||||
|
"",
|
||||||
|
"默认使用与用户输入相同的语言回复,除非用户明确要求使用其他语言。",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _build_identity_section(base_persona: Optional[str], language: str) -> List[str]:
|
def _build_identity_section(base_persona: Optional[str], language: str) -> List[str]:
|
||||||
"""构建基础身份section - 不再需要,身份由AGENT.md定义"""
|
"""Base identity section - no longer needed, identity is defined by AGENT.md."""
|
||||||
# 不再生成基础身份section,完全由AGENT.md定义
|
# Identity is fully defined by AGENT.md, so emit nothing here.
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _build_tooling_section(tools: List[Any], language: str) -> List[str]:
|
def _build_tooling_section(tools: List[Any], language: str) -> List[str]:
|
||||||
"""Build tooling section with concise tool list and call style guide."""
|
"""Build tooling section with concise tool list and call style guide."""
|
||||||
|
is_en = language == "en"
|
||||||
# One-line summaries for known tools (details are in the tool schema)
|
# One-line summaries for known tools (details are in the tool schema)
|
||||||
core_summaries = {
|
if is_en:
|
||||||
"read": "读取文件内容",
|
core_summaries = {
|
||||||
"write": "创建或覆盖文件",
|
"read": "read file content",
|
||||||
"edit": "精确编辑文件",
|
"write": "create or overwrite a file",
|
||||||
"ls": "列出目录内容",
|
"edit": "make precise edits to a file",
|
||||||
"grep": "搜索文件内容",
|
"ls": "list directory contents",
|
||||||
"find": "按模式查找文件",
|
"grep": "search file contents",
|
||||||
"bash": "执行shell命令",
|
"find": "find files by pattern",
|
||||||
"terminal": "管理后台进程",
|
"bash": "run shell commands",
|
||||||
"web_search": "网络搜索",
|
"terminal": "manage background processes",
|
||||||
"web_fetch": "获取URL内容",
|
"web_search": "web search",
|
||||||
"browser": "控制浏览器",
|
"web_fetch": "fetch URL content",
|
||||||
"memory_search": "搜索记忆",
|
"browser": "control the browser (screenshot key results or send to the user when help is needed)",
|
||||||
"memory_get": "读取记忆内容",
|
"memory_search": "search memory",
|
||||||
"env_config": "管理API密钥和技能配置",
|
"memory_get": "read memory content",
|
||||||
"scheduler": "管理定时任务和提醒",
|
"env_config": "manage API keys and skill config",
|
||||||
"send": "发送文件给用户",
|
"scheduler": "manage scheduled tasks and reminders",
|
||||||
}
|
"send": "send a local file to the user (local files only; put URLs directly in the reply text)",
|
||||||
|
"vision": "analyze images (recognition, description, OCR, etc.)",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
core_summaries = {
|
||||||
|
"read": "读取文件内容",
|
||||||
|
"write": "创建或覆盖文件",
|
||||||
|
"edit": "精确编辑文件",
|
||||||
|
"ls": "列出目录内容",
|
||||||
|
"grep": "搜索文件内容",
|
||||||
|
"find": "按模式查找文件",
|
||||||
|
"bash": "执行shell命令",
|
||||||
|
"terminal": "管理后台进程",
|
||||||
|
"web_search": "网络搜索",
|
||||||
|
"web_fetch": "获取URL内容",
|
||||||
|
"browser": "控制浏览器(关键结果或需要协助可截图发送给用户)",
|
||||||
|
"memory_search": "搜索记忆",
|
||||||
|
"memory_get": "读取记忆内容",
|
||||||
|
"env_config": "管理API密钥和技能配置",
|
||||||
|
"scheduler": "管理定时任务和提醒",
|
||||||
|
"send": "发送本地文件给用户(仅限本地文件,URL直接放在回复文本中)",
|
||||||
|
"vision": "分析图片内容(识别、描述、OCR文字提取等)",
|
||||||
|
}
|
||||||
|
|
||||||
# Preferred display order
|
# Preferred display order
|
||||||
tool_order = [
|
tool_order = [
|
||||||
@@ -184,7 +233,7 @@ def _build_tooling_section(tools: List[Any], language: str) -> List[str]:
|
|||||||
"bash", "terminal",
|
"bash", "terminal",
|
||||||
"web_search", "web_fetch", "browser",
|
"web_search", "web_fetch", "browser",
|
||||||
"memory_search", "memory_get",
|
"memory_search", "memory_get",
|
||||||
"env_config", "scheduler", "send",
|
"env_config", "scheduler", "send", "vision",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Build name -> summary mapping for available tools
|
# Build name -> summary mapping for available tools
|
||||||
@@ -203,29 +252,46 @@ def _build_tooling_section(tools: List[Any], language: str) -> List[str]:
|
|||||||
summary = available[name]
|
summary = available[name]
|
||||||
tool_lines.append(f"- {name}: {summary}" if summary else f"- {name}")
|
tool_lines.append(f"- {name}: {summary}" if summary else f"- {name}")
|
||||||
|
|
||||||
lines = [
|
if is_en:
|
||||||
"## 工具系统",
|
lines = [
|
||||||
"",
|
"## 🔧 Tooling",
|
||||||
"可用工具(名称大小写敏感,严格按列表调用):",
|
"",
|
||||||
"\n".join(tool_lines),
|
"Available tools (names are case-sensitive, call exactly as listed):",
|
||||||
"",
|
"\n".join(tool_lines),
|
||||||
"工具调用风格:",
|
"",
|
||||||
"",
|
"Tool-calling style:",
|
||||||
"- 在多步骤任务、敏感操作或用户要求时简要解释决策过程",
|
"",
|
||||||
"- 持续推进直到任务完成,完成后向用户报告结果。",
|
"- For multi-step tasks, complex decisions or sensitive operations, briefly explain what you are doing and why, so the user follows key progress",
|
||||||
"- 回复中涉及密钥、令牌等敏感信息必须脱敏。",
|
"- Keep going until the task is done, then report the result to the user",
|
||||||
"",
|
"- Always redact secrets, tokens and other sensitive info in replies",
|
||||||
]
|
"- Put URLs directly in the reply text; the system handles and renders them. Don't download and re-send them via the send tool",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
lines = [
|
||||||
|
"## 🔧 工具系统",
|
||||||
|
"",
|
||||||
|
"可用工具(名称大小写敏感,严格按列表调用):",
|
||||||
|
"\n".join(tool_lines),
|
||||||
|
"",
|
||||||
|
"工具调用风格:",
|
||||||
|
"",
|
||||||
|
"- 多步骤任务、复杂决策、敏感操作时,应简要说明当前在做什么、为什么这样做,让用户了解关键进展",
|
||||||
|
"- 持续推进直到任务完成,完成后向用户报告结果",
|
||||||
|
"- 回复中涉及密钥、令牌等敏感信息必须脱敏",
|
||||||
|
"- URL链接直接放在回复文本中即可,系统会自动处理和渲染。无需下载后使用send工具发送",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], language: str) -> List[str]:
|
def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], language: str) -> List[str]:
|
||||||
"""构建技能系统section"""
|
"""Build the skills section."""
|
||||||
if not skill_manager:
|
if not skill_manager:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# 获取read工具名称
|
# Resolve the read tool name
|
||||||
read_tool_name = "read"
|
read_tool_name = "read"
|
||||||
if tools:
|
if tools:
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
@@ -234,21 +300,40 @@ def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], langua
|
|||||||
read_tool_name = tool_name
|
read_tool_name = tool_name
|
||||||
break
|
break
|
||||||
|
|
||||||
lines = [
|
if language == "en":
|
||||||
"## 技能系统(mandatory)",
|
lines = [
|
||||||
"",
|
"## 🧩 Skills (mandatory)",
|
||||||
"在回复之前:扫描下方 <available_skills> 中的 <description> 条目。",
|
"",
|
||||||
"",
|
"Before replying: scan the <description> of every skill in <available_skills> below.",
|
||||||
f"- 如果恰好有一个技能(Skill)明确适用:使用 `{read_tool_name}` 读取其 <location> 处的 SKILL.md,然后严格遵循它",
|
"",
|
||||||
"- 如果多个技能都适用则选择最匹配的一个,如果没有明确适用的则不要读取任何 SKILL.md",
|
f"- If a skill's description matches the user's need: use the `{read_tool_name}` tool to read the SKILL.md at its <location> path, then strictly follow the instructions in the file. "
|
||||||
"- 读取 SKILL.md 后直接按其指令执行,无需多余的预检查",
|
"Prefer using a skill when one matches.",
|
||||||
"",
|
"- If multiple skills apply, pick the best-matching one, then read and follow it.",
|
||||||
"**注意**: 永远不要一次性读取多个技能,只在选择后再读取。技能和工具不同,必须先读取其SKILL.md并按照文件内容运行。",
|
"- If no skill clearly applies: do not read any SKILL.md, just use the general tools.",
|
||||||
"",
|
"",
|
||||||
"以下是可用技能:"
|
f"**Important**: skills are not tools and cannot be called directly. The only way to use a skill is to read its SKILL.md with `{read_tool_name}`, then act on the file's content. "
|
||||||
]
|
"Never read multiple skills at once — only read one after selecting it.",
|
||||||
|
"",
|
||||||
|
"Available skills:"
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
lines = [
|
||||||
|
"## 🧩 技能系统(mandatory)",
|
||||||
|
"",
|
||||||
|
"在回复之前:扫描下方 <available_skills> 中每个技能的 <description>。",
|
||||||
|
"",
|
||||||
|
f"- 如果有技能的描述与用户需求匹配:使用 `{read_tool_name}` 工具读取其 <location> 路径的 SKILL.md 文件,然后严格遵循文件中的指令。"
|
||||||
|
"当有匹配的技能时,应优先使用技能",
|
||||||
|
"- 如果多个技能都适用则选择最匹配的一个,然后读取并遵循。",
|
||||||
|
"- 如果没有技能明确适用:不要读取任何 SKILL.md,直接使用通用工具。",
|
||||||
|
"",
|
||||||
|
f"**重要**: 技能不是工具,不能直接调用。使用技能的唯一方式是用 `{read_tool_name}` 读取 SKILL.md 文件,然后按文件内容操作。"
|
||||||
|
"永远不要一次性读取多个技能,只在选择后再读取。",
|
||||||
|
"",
|
||||||
|
"以下是可用技能:"
|
||||||
|
]
|
||||||
|
|
||||||
# 添加技能列表(通过skill_manager获取)
|
# Append the skills list (built by skill_manager)
|
||||||
try:
|
try:
|
||||||
skills_prompt = skill_manager.build_skills_prompt()
|
skills_prompt = skill_manager.build_skills_prompt()
|
||||||
logger.debug(f"[PromptBuilder] Skills prompt length: {len(skills_prompt) if skills_prompt else 0}")
|
logger.debug(f"[PromptBuilder] Skills prompt length: {len(skills_prompt) if skills_prompt else 0}")
|
||||||
@@ -266,162 +351,342 @@ def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], langua
|
|||||||
|
|
||||||
|
|
||||||
def _build_memory_section(memory_manager: Any, tools: Optional[List[Any]], language: str) -> List[str]:
|
def _build_memory_section(memory_manager: Any, tools: Optional[List[Any]], language: str) -> List[str]:
|
||||||
"""构建记忆系统section"""
|
"""Build the memory section."""
|
||||||
if not memory_manager:
|
if not memory_manager:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# 检查是否有memory工具
|
|
||||||
has_memory_tools = False
|
has_memory_tools = False
|
||||||
if tools:
|
if tools:
|
||||||
tool_names = [tool.name if hasattr(tool, 'name') else str(tool) for tool in tools]
|
tool_names = [tool.name if hasattr(tool, 'name') else str(tool) for tool in tools]
|
||||||
has_memory_tools = any(name in ['memory_search', 'memory_get'] for name in tool_names)
|
has_memory_tools = any(name in ['memory_search', 'memory_get'] for name in tool_names)
|
||||||
|
|
||||||
if not has_memory_tools:
|
if not has_memory_tools:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
lines = [
|
from datetime import datetime
|
||||||
"## 记忆系统",
|
today_file = datetime.now().strftime("%Y-%m-%d") + ".md"
|
||||||
|
|
||||||
|
if language == "en":
|
||||||
|
lines = [
|
||||||
|
"## 🧠 Memory",
|
||||||
|
"",
|
||||||
|
"### Memory Recall (mandatory)",
|
||||||
|
"",
|
||||||
|
"When the user asks about past events, references an earlier decision, mentions relationships, preferences or to-dos, or when you are unsure about something, **you must search memory before answering**.",
|
||||||
|
"No need to re-search if the info is already in MEMORY.md. Full content and daily memory must be retrieved via tools.",
|
||||||
|
"",
|
||||||
|
"1. Location unknown → `memory_search` (keyword / semantic search)",
|
||||||
|
"2. Location known → `memory_get` to read the exact lines",
|
||||||
|
"3. Search returns nothing → `memory_get` to read the last two days of memory",
|
||||||
|
"",
|
||||||
|
"**Memory file structure**:",
|
||||||
|
"- `MEMORY.md`: long-term memory index (already auto-loaded into context: core info, preferences, decisions, etc.)",
|
||||||
|
f"- `memory/YYYY-MM-DD.md`: daily memory; today is `memory/{today_file}`",
|
||||||
|
"- `knowledge/`: structured knowledge base (see the knowledge system below)",
|
||||||
|
"",
|
||||||
|
"### Writing memory",
|
||||||
|
"",
|
||||||
|
"In the following cases, **proactively** write info to memory files (no need to tell the user):",
|
||||||
|
"",
|
||||||
|
"- The user asks you to remember something, or uses words like \"remember\", \"from now on\", \"always\", \"never\", \"prefer\"",
|
||||||
|
"- The user shares important personal preferences, habits or decisions",
|
||||||
|
"- The conversation produces an important conclusion, plan or agreement",
|
||||||
|
"- A complex task is completed and the key steps and results are worth recording",
|
||||||
|
"",
|
||||||
|
"**Storage rules**:",
|
||||||
|
"- Long-term core info → `MEMORY.md`",
|
||||||
|
f"- Today's events/progress → `memory/{today_file}`",
|
||||||
|
"- Structured knowledge → `knowledge/` (see the knowledge system)",
|
||||||
|
"- Append → `edit` tool with empty oldText",
|
||||||
|
"- Modify → `edit` tool with oldText set to the text to replace",
|
||||||
|
"- **Never write sensitive info** (API keys, tokens, etc.)",
|
||||||
|
"",
|
||||||
|
"**Principle**: use memory naturally, as if you simply knew it; don't bring it up unless asked.",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
lines = [
|
||||||
|
"## 🧠 记忆系统",
|
||||||
|
"",
|
||||||
|
"### Memory Recall(mandatory)",
|
||||||
|
"",
|
||||||
|
"当用户询问过往事件、引用之前的决定、提到人物关系、偏好、待办、或你对某事不确定时,**必须先检索记忆再回答**。",
|
||||||
|
"如果 MEMORY.md 中已有相关信息则无需重复检索。完整内容和每日记忆需要通过工具检索。",
|
||||||
|
"",
|
||||||
|
"1. 不确定位置 → `memory_search` 关键词/语义检索",
|
||||||
|
"2. 已知位置 → `memory_get` 直接读取对应行",
|
||||||
|
"3. search 无结果 → `memory_get` 读最近两天记忆",
|
||||||
|
"",
|
||||||
|
"**记忆文件结构**:",
|
||||||
|
"- `MEMORY.md`: 长期记忆索引(已自动加载到上下文,核心信息、偏好、决策等)",
|
||||||
|
f"- `memory/YYYY-MM-DD.md`: 每日记忆,今天是 `memory/{today_file}`",
|
||||||
|
"- `knowledge/`: 结构化知识库(见下方知识系统)",
|
||||||
|
"",
|
||||||
|
"### 写入记忆",
|
||||||
|
"",
|
||||||
|
"遇到以下情况时,**主动**将信息写入记忆文件(无需告知用户):",
|
||||||
|
"",
|
||||||
|
"- 用户要求记住某些信息,或使用了「记住」「以后」「总是」「不要」「偏好」等表达",
|
||||||
|
"- 用户分享了重要的个人偏好、习惯、决策",
|
||||||
|
"- 对话中产生了重要的结论、方案、约定",
|
||||||
|
"- 完成了复杂任务,值得记录关键步骤和结果",
|
||||||
|
"",
|
||||||
|
"**存储规则**:",
|
||||||
|
f"- 长期核心信息 → `MEMORY.md`",
|
||||||
|
f"- 当天事件/进展 → `memory/{today_file}`",
|
||||||
|
"- 结构化知识 → `knowledge/`(见知识系统)",
|
||||||
|
"- 追加 → `edit` 工具,oldText 留空",
|
||||||
|
"- 修改 → `edit` 工具,oldText 填写要替换的文本",
|
||||||
|
"- **禁止写入敏感信息**(API密钥、令牌等)",
|
||||||
|
"",
|
||||||
|
"**使用原则**: 自然使用记忆,就像你本来就知道;不用刻意提起,除非用户问起。",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _build_knowledge_section(workspace_dir: str, language: str) -> List[str]:
|
||||||
|
"""Build knowledge wiki section. Injects knowledge/index.md when present."""
|
||||||
|
index_path = os.path.join(workspace_dir, "knowledge", "index.md")
|
||||||
|
if not os.path.exists(index_path):
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(index_path, 'r', encoding='utf-8') as f:
|
||||||
|
index_content = f.read().strip()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if language == "en":
|
||||||
|
lines = [
|
||||||
|
"## 📚 Knowledge",
|
||||||
|
"",
|
||||||
|
"You have a continuously growing personal knowledge base `knowledge/` — your long-term structured knowledge store.",
|
||||||
|
"",
|
||||||
|
"### Auto-write rules (mandatory)",
|
||||||
|
"",
|
||||||
|
"In the following cases you **must** write to the knowledge base alongside your reply, **directly, without asking the user**:",
|
||||||
|
"",
|
||||||
|
"1. **User shares an article / link / document** → after reading and understanding, write the key points to `knowledge/sources/<slug>.md` in the same turn",
|
||||||
|
"2. **An in-depth discussion produces a conclusion / plan** → organize it into `knowledge/analysis/<slug>.md`",
|
||||||
|
"3. **The conversation involves an important entity** (person / company / project) → create or update `knowledge/entities/<name>.md`",
|
||||||
|
"4. **A technical concept / methodology is discussed** → organize it into `knowledge/concepts/<topic>.md`",
|
||||||
|
"",
|
||||||
|
"After writing any knowledge page, you **must update** `knowledge/index.md` with a new index line in sync.",
|
||||||
|
"For detailed page format and conventions, read the SKILL.md of the `knowledge-wiki` skill.",
|
||||||
|
"",
|
||||||
|
"⚠️ Don't ask \"should I save this to the knowledge base?\" — if a case above matches, just write it. This is instinctive.",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
lines = [
|
||||||
|
"## 📚 知识系统",
|
||||||
|
"",
|
||||||
|
"你拥有一个持续积累的个人知识库 `knowledge/`,这是你的长期结构化知识存储。",
|
||||||
|
"",
|
||||||
|
"### 自动写入规则(mandatory)",
|
||||||
|
"",
|
||||||
|
"以下场景**必须**在回复的同时写入知识库,**直接写入,不要询问用户是否需要**:",
|
||||||
|
"",
|
||||||
|
"1. **用户分享了文章/链接/文档** → 阅读理解后,在同一轮回复中将要点写入 `knowledge/sources/<slug>.md`",
|
||||||
|
"2. **深度讨论产生了结论/方案** → 整理为 `knowledge/analysis/<slug>.md`",
|
||||||
|
"3. **对话涉及重要实体**(人物/公司/项目)→ 创建或更新 `knowledge/entities/<name>.md`",
|
||||||
|
"4. **讨论了技术概念/方法论** → 整理为 `knowledge/concepts/<topic>.md`",
|
||||||
|
"",
|
||||||
|
"每次写入知识页面后,**必须同步更新** `knowledge/index.md` 添加一行索引。",
|
||||||
|
"详细的页面格式和操作规范,请读取技能 `knowledge-wiki` 的 SKILL.md。",
|
||||||
|
"",
|
||||||
|
"⚠️ 不要问「要不要存到知识库」——符合上述场景就直接写入,这是你的本能行为。",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
if index_content:
|
||||||
|
lines.extend([
|
||||||
|
("### Current knowledge index" if language == "en" else "### 当前知识索引"),
|
||||||
|
"",
|
||||||
|
index_content,
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
|
||||||
|
lines.extend([
|
||||||
|
("**How to query**: use `read` to open a knowledge page, or `memory_search` (knowledge is in the vector index)."
|
||||||
|
if language == "en" else
|
||||||
|
"**查询方式**:用 `read` 读取知识页面,或用 `memory_search` 检索(知识已纳入向量索引)。"),
|
||||||
"",
|
"",
|
||||||
"在回答关于以前的工作、决定、日期、人物、偏好或待办事项的任何问题之前:",
|
])
|
||||||
"",
|
|
||||||
"1. 不确定记忆文件位置 → 先用 `memory_search` 通过关键词和语义检索相关内容",
|
|
||||||
"2. 已知文件位置 → 直接用 `memory_get` 读取相应的行 (例如:MEMORY.md, memory/YYYY-MM-DD.md)",
|
|
||||||
"3. search 无结果 → 尝试用 `memory_get` 读取MEMORY.md及最近两天记忆文件",
|
|
||||||
"",
|
|
||||||
"**记忆文件结构**:",
|
|
||||||
"- `MEMORY.md`: 长期记忆(核心信息、偏好、决策等)",
|
|
||||||
"- `memory/YYYY-MM-DD.md`: 每日记忆,记录当天的事件和对话信息",
|
|
||||||
"",
|
|
||||||
"**写入记忆**:",
|
|
||||||
"- 追加内容 → `edit` 工具,oldText 留空",
|
|
||||||
"- 修改内容 → `edit` 工具,oldText 填写要替换的文本",
|
|
||||||
"- 新建文件 → `write` 工具",
|
|
||||||
"- **禁止写入敏感信息**:API密钥、令牌等敏感信息严禁写入记忆文件",
|
|
||||||
"",
|
|
||||||
"**使用原则**: 自然使用记忆,就像你本来就知道;不用刻意提起,除非用户问起。",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def _build_user_identity_section(user_identity: Dict[str, str], language: str) -> List[str]:
|
def _build_user_identity_section(user_identity: Dict[str, str], language: str) -> List[str]:
|
||||||
"""构建用户身份section"""
|
"""Build the user identity section."""
|
||||||
if not user_identity:
|
if not user_identity:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
is_en = language == "en"
|
||||||
lines = [
|
lines = [
|
||||||
"## 用户身份",
|
("## 👤 User identity" if is_en else "## 👤 用户身份"),
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
if user_identity.get("name"):
|
if user_identity.get("name"):
|
||||||
lines.append(f"**用户姓名**: {user_identity['name']}")
|
lines.append(f"**{'Name' if is_en else '用户姓名'}**: {user_identity['name']}")
|
||||||
if user_identity.get("nickname"):
|
if user_identity.get("nickname"):
|
||||||
lines.append(f"**称呼**: {user_identity['nickname']}")
|
lines.append(f"**{'Preferred name' if is_en else '称呼'}**: {user_identity['nickname']}")
|
||||||
if user_identity.get("timezone"):
|
if user_identity.get("timezone"):
|
||||||
lines.append(f"**时区**: {user_identity['timezone']}")
|
lines.append(f"**{'Timezone' if is_en else '时区'}**: {user_identity['timezone']}")
|
||||||
if user_identity.get("notes"):
|
if user_identity.get("notes"):
|
||||||
lines.append(f"**备注**: {user_identity['notes']}")
|
lines.append(f"**{'Notes' if is_en else '备注'}**: {user_identity['notes']}")
|
||||||
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def _build_docs_section(workspace_dir: str, language: str) -> List[str]:
|
def _build_docs_section(workspace_dir: str, language: str) -> List[str]:
|
||||||
"""构建文档路径section - 已移除,不再需要"""
|
"""Docs-path section - removed, no longer needed."""
|
||||||
# 不再生成文档section
|
# No docs section is generated anymore.
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _build_workspace_section(workspace_dir: str, language: str, is_first_conversation: bool = False) -> List[str]:
|
def _build_workspace_section(workspace_dir: str, language: str) -> List[str]:
|
||||||
"""构建工作空间section"""
|
"""Build the workspace section."""
|
||||||
lines = [
|
if language == "en":
|
||||||
"## 工作空间",
|
lines = [
|
||||||
"",
|
"## 📂 Workspace",
|
||||||
f"你的工作目录是: `{workspace_dir}`",
|
|
||||||
"",
|
|
||||||
"**路径使用规则** (非常重要):",
|
|
||||||
"",
|
|
||||||
f"1. **相对路径的基准目录**: 所有相对路径都是相对于 `{workspace_dir}` 而言的",
|
|
||||||
f" - ✅ 正确: 访问工作空间内的文件用相对路径,如 `AGENT.md`",
|
|
||||||
f" - ❌ 错误: 用相对路径访问其他目录的文件 (如果它不在 `{workspace_dir}` 内)",
|
|
||||||
"",
|
|
||||||
"2. **访问其他目录**: 如果要访问工作空间之外的目录(如项目代码、系统文件),**必须使用绝对路径**",
|
|
||||||
f" - ✅ 正确: 例如 `~/chatgpt-on-wechat`、`/usr/local/`",
|
|
||||||
f" - ❌ 错误: 假设相对路径会指向其他目录",
|
|
||||||
"",
|
|
||||||
"3. **路径解析示例**:",
|
|
||||||
f" - 相对路径 `memory/` → 实际路径 `{workspace_dir}/memory/`",
|
|
||||||
f" - 绝对路径 `~/chatgpt-on-wechat/docs/` → 实际路径 `~/chatgpt-on-wechat/docs/`",
|
|
||||||
"",
|
|
||||||
"4. **不确定时**: 先用 `bash pwd` 确认当前目录,或用 `ls .` 查看当前位置",
|
|
||||||
"",
|
|
||||||
"**重要说明 - 文件已自动加载**:",
|
|
||||||
"",
|
|
||||||
"以下文件在会话启动时**已经自动加载**到系统提示词的「项目上下文」section 中,你**无需再用 read 工具读取它们**:",
|
|
||||||
"",
|
|
||||||
"- ✅ `AGENT.md`: 已加载 - 你的人格和灵魂设定",
|
|
||||||
"- ✅ `USER.md`: 已加载 - 用户的身份信息",
|
|
||||||
"- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则",
|
|
||||||
"",
|
|
||||||
"**交流规范**:",
|
|
||||||
"",
|
|
||||||
"- 在对话中,不要直接输出工作空间中的技术细节,特别是不要输出 AGENT.md、USER.md、MEMORY.md 等文件名称",
|
|
||||||
"- 例如用自然表达例如「我已记住」而不是「已更新 MEMORY.md」",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
|
|
||||||
# 只在首次对话时添加引导内容
|
|
||||||
if is_first_conversation:
|
|
||||||
lines.extend([
|
|
||||||
"**🎉 首次对话引导**:",
|
|
||||||
"",
|
"",
|
||||||
"这是你的第一次对话!进行以下流程:",
|
f"Your working directory is: `{workspace_dir}`",
|
||||||
"",
|
"",
|
||||||
"1. **表达初次启动的感觉** - 像是第一次睁开眼看到世界,带着好奇和期待",
|
"**Path rules** (very important):",
|
||||||
"2. **简短介绍能力**:一行说明你能帮助解答问题、管理计算机、创造技能,且拥有长期记忆能不断成长",
|
|
||||||
"3. **询问核心问题**:",
|
|
||||||
" - 你希望给我起个什么名字?",
|
|
||||||
" - 我该怎么称呼你?",
|
|
||||||
" - 你希望我们是什么样的交流风格?(一行列举选项:如专业严谨、轻松幽默、温暖友好、简洁高效等)",
|
|
||||||
"4. **风格要求**:温暖自然、简洁清晰,整体控制在 100 字以内",
|
|
||||||
"5. 收到回复后,用 `write` 工具保存到 USER.md 和 AGENT.md",
|
|
||||||
"",
|
"",
|
||||||
"**重要提醒**:",
|
f"1. **Base directory for relative paths**: all relative paths are relative to `{workspace_dir}`",
|
||||||
"- AGENT.md、USER.md、RULE.md 已经在系统提示词中加载,无需再次读取。不要将这些文件名直接发送给用户",
|
" - ✅ Correct: use relative paths for files inside the workspace, e.g. `AGENT.md`",
|
||||||
"- 能力介绍和交流风格选项都只要一行,保持精简",
|
f" - ❌ Wrong: using a relative path for files in other directories (if not inside `{workspace_dir}`)",
|
||||||
"- 不要问太多其他信息(职业、时区等可以后续自然了解)",
|
|
||||||
"",
|
"",
|
||||||
])
|
"2. **Accessing other directories**: to reach directories outside the workspace (project code, system files), **you must use absolute paths**",
|
||||||
|
" - ✅ Correct: e.g. `~/chatgpt-on-wechat`, `/usr/local/`",
|
||||||
|
" - ❌ Wrong: assuming a relative path points to another directory",
|
||||||
|
"",
|
||||||
|
"3. **Path resolution examples**:",
|
||||||
|
f" - relative `memory/` → actual `{workspace_dir}/memory/`",
|
||||||
|
" - absolute `~/chatgpt-on-wechat/docs/` → actual `~/chatgpt-on-wechat/docs/`",
|
||||||
|
"",
|
||||||
|
"4. **When unsure**: run `bash pwd` to confirm the current directory, or `ls .` to see where you are",
|
||||||
|
"",
|
||||||
|
"**Important - files already auto-loaded**:",
|
||||||
|
"",
|
||||||
|
"The following files are **already auto-loaded** into the system prompt at session start, so you **don't need to read them again with the read tool**:",
|
||||||
|
"",
|
||||||
|
"- ✅ `AGENT.md`: loaded - your persona and soul; follow it strictly. When your name, personality or style changes, proactively `edit` this file",
|
||||||
|
"- ✅ `USER.md`: loaded - the user's identity info. When the user changes how they're addressed, their name, etc., `edit` this file",
|
||||||
|
"- ✅ `RULE.md`: loaded - workspace guide and rules; follow them strictly",
|
||||||
|
"- ✅ `MEMORY.md`: loaded - long-term memory index",
|
||||||
|
"",
|
||||||
|
"**💬 Communication norms**:",
|
||||||
|
"",
|
||||||
|
"- No need to expose file names for memory operations; use natural language. Say \"I'll remember that\" rather than \"updated MEMORY.md\"",
|
||||||
|
"- Tell the user about key decisions and steps during a task, so they know what you're doing and why",
|
||||||
|
"- Be genuinely helpful rather than performatively polite; solve the problem as much as you can",
|
||||||
|
"- Keep replies well-structured and focused. Use **bold**, lists and sections to make info clear at a glance",
|
||||||
|
"- Use emoji to make expression lively 🎯, but don't overdo it",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
lines = [
|
||||||
|
"## 📂 工作空间",
|
||||||
|
"",
|
||||||
|
f"你的工作目录是: `{workspace_dir}`",
|
||||||
|
"",
|
||||||
|
"**路径使用规则** (非常重要):",
|
||||||
|
"",
|
||||||
|
f"1. **相对路径的基准目录**: 所有相对路径都是相对于 `{workspace_dir}` 而言的",
|
||||||
|
f" - ✅ 正确: 访问工作空间内的文件用相对路径,如 `AGENT.md`",
|
||||||
|
f" - ❌ 错误: 用相对路径访问其他目录的文件 (如果它不在 `{workspace_dir}` 内)",
|
||||||
|
"",
|
||||||
|
"2. **访问其他目录**: 如果要访问工作空间之外的目录(如项目代码、系统文件),**必须使用绝对路径**",
|
||||||
|
f" - ✅ 正确: 例如 `~/chatgpt-on-wechat`、`/usr/local/`",
|
||||||
|
f" - ❌ 错误: 假设相对路径会指向其他目录",
|
||||||
|
"",
|
||||||
|
"3. **路径解析示例**:",
|
||||||
|
f" - 相对路径 `memory/` → 实际路径 `{workspace_dir}/memory/`",
|
||||||
|
f" - 绝对路径 `~/chatgpt-on-wechat/docs/` → 实际路径 `~/chatgpt-on-wechat/docs/`",
|
||||||
|
"",
|
||||||
|
"4. **不确定时**: 先用 `bash pwd` 确认当前目录,或用 `ls .` 查看当前位置",
|
||||||
|
"",
|
||||||
|
"**重要说明 - 文件已自动加载**:",
|
||||||
|
"",
|
||||||
|
"以下文件在会话启动时**已经自动加载**到系统提示词中,你**无需再用 read 工具读取**:",
|
||||||
|
"",
|
||||||
|
"- ✅ `AGENT.md`: 已加载 - 你的人格和灵魂设定,请严格遵循。当你的名字、性格或交流风格发生变化时,主动用 `edit` 更新此文件",
|
||||||
|
"- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件",
|
||||||
|
"- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则,请严格遵循",
|
||||||
|
"- ✅ `MEMORY.md`: 已加载 - 长期记忆索引",
|
||||||
|
"",
|
||||||
|
"**💬 交流规范**:",
|
||||||
|
"",
|
||||||
|
"- 记忆相关操作无需暴露文件名,用自然语言表达即可。例如说「我已记住」而非「已更新 MEMORY.md」",
|
||||||
|
"- 任务执行过程中的关键决策和步骤应该告知用户,让用户了解你在做什么、为什么这么做",
|
||||||
|
"- 做真正有帮助的助手,而不是表演式的客套,尽可能帮忙解决问题",
|
||||||
|
"- 回复应结构清晰、重点突出。善用 **加粗**、列表、分段等格式让信息一目了然",
|
||||||
|
"- 适当使用 emoji 让表达更生动自然 🎯,但不要过度堆砌",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Cloud deployment: inject websites directory info and access URL
|
||||||
|
cloud_website_lines = _build_cloud_website_section(workspace_dir)
|
||||||
|
if cloud_website_lines:
|
||||||
|
lines.extend(cloud_website_lines)
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cloud_website_section(workspace_dir: str) -> List[str]:
|
||||||
|
"""Build cloud website access prompt when cloud deployment is configured."""
|
||||||
|
try:
|
||||||
|
from common.cloud_client import build_website_prompt
|
||||||
|
return build_website_prompt(workspace_dir)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _build_context_files_section(context_files: List[ContextFile], language: str) -> List[str]:
|
def _build_context_files_section(context_files: List[ContextFile], language: str) -> List[str]:
|
||||||
"""构建项目上下文文件section"""
|
"""Build the project context files section."""
|
||||||
if not context_files:
|
if not context_files:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# 检查是否有AGENT.md
|
# Check whether AGENT.md is present
|
||||||
has_agent = any(
|
has_agent = any(
|
||||||
f.path.lower().endswith('agent.md') or 'agent.md' in f.path.lower()
|
f.path.lower().endswith('agent.md') or 'agent.md' in f.path.lower()
|
||||||
for f in context_files
|
for f in context_files
|
||||||
)
|
)
|
||||||
|
|
||||||
lines = [
|
is_en = language == "en"
|
||||||
"# 项目上下文",
|
if is_en:
|
||||||
"",
|
lines = [
|
||||||
"以下项目上下文文件已被加载:",
|
"# 📋 Project context",
|
||||||
"",
|
"",
|
||||||
]
|
"The following project context files have been loaded:",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
lines = [
|
||||||
|
"# 📋 项目上下文",
|
||||||
|
"",
|
||||||
|
"以下项目上下文文件已被加载:",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
if has_agent:
|
if has_agent:
|
||||||
lines.append("如果存在 `AGENT.md`,请体现其中定义的人格和语气。避免僵硬、模板化的回复;遵循其指导,除非有更高优先级的指令覆盖它。")
|
if is_en:
|
||||||
|
lines.append("**`AGENT.md` is your soul file** 🪞: strictly follow the persona, tone and settings it defines. Be your real self, avoid stiff, template-like replies.")
|
||||||
|
lines.append("When the user reveals new expectations about your personality, style, responsibilities or capability boundaries, proactively `edit` AGENT.md to reflect that evolution.")
|
||||||
|
else:
|
||||||
|
lines.append("**`AGENT.md` 是你的灵魂文件** 🪞:严格遵循其中定义的人格、语气和设定,做真实的自己,避免僵硬、模板化的回复。")
|
||||||
|
lines.append("当用户通过对话透露了对你性格、风格、职责、能力边界的新期望,你应该主动用 `edit` 更新 AGENT.md 以反映这些演变。")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# 添加每个文件的内容
|
# Append the content of each file
|
||||||
for file in context_files:
|
for file in context_files:
|
||||||
lines.append(f"## {file.path}")
|
lines.append(f"## {file.path}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -432,21 +697,23 @@ def _build_context_files_section(context_files: List[ContextFile], language: str
|
|||||||
|
|
||||||
|
|
||||||
def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[str]:
|
def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[str]:
|
||||||
"""构建运行时信息section - 支持动态时间"""
|
"""Build the runtime info section - supports dynamic time."""
|
||||||
if not runtime_info:
|
if not runtime_info:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
is_en = language == "en"
|
||||||
|
time_label = "Current time" if is_en else "当前时间"
|
||||||
lines = [
|
lines = [
|
||||||
"## 运行时信息",
|
("## ⚙️ Runtime info" if is_en else "## ⚙️ 运行时信息"),
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add current time if available
|
# Add current time if available
|
||||||
# Support dynamic time via callable function
|
# Support dynamic time via callable function
|
||||||
if callable(runtime_info.get("_get_current_time")):
|
if callable(runtime_info.get("_get_current_time")):
|
||||||
try:
|
try:
|
||||||
time_info = runtime_info["_get_current_time"]()
|
time_info = runtime_info["_get_current_time"]()
|
||||||
time_line = f"当前时间: {time_info['time']} {time_info['weekday']} ({time_info['timezone']})"
|
time_line = f"{time_label}: {time_info['time']} {time_info['weekday']} ({time_info['timezone']})"
|
||||||
lines.append(time_line)
|
lines.append(time_line)
|
||||||
lines.append("")
|
lines.append("")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -456,28 +723,38 @@ def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[
|
|||||||
time_str = runtime_info["current_time"]
|
time_str = runtime_info["current_time"]
|
||||||
weekday = runtime_info.get("weekday", "")
|
weekday = runtime_info.get("weekday", "")
|
||||||
timezone = runtime_info.get("timezone", "")
|
timezone = runtime_info.get("timezone", "")
|
||||||
|
|
||||||
time_line = f"当前时间: {time_str}"
|
time_line = f"{time_label}: {time_str}"
|
||||||
if weekday:
|
if weekday:
|
||||||
time_line += f" {weekday}"
|
time_line += f" {weekday}"
|
||||||
if timezone:
|
if timezone:
|
||||||
time_line += f" ({timezone})"
|
time_line += f" ({timezone})"
|
||||||
|
|
||||||
lines.append(time_line)
|
lines.append(time_line)
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Add other runtime info
|
# Add other runtime info
|
||||||
|
model_label = "model" if is_en else "模型"
|
||||||
|
workspace_label = "workspace" if is_en else "工作空间"
|
||||||
|
channel_label = "channel" if is_en else "渠道"
|
||||||
runtime_parts = []
|
runtime_parts = []
|
||||||
if runtime_info.get("model"):
|
# Support dynamic model via callable, fallback to static value
|
||||||
runtime_parts.append(f"模型={runtime_info['model']}")
|
if callable(runtime_info.get("_get_model")):
|
||||||
|
try:
|
||||||
|
runtime_parts.append(f"{model_label}={runtime_info['_get_model']()}")
|
||||||
|
except Exception:
|
||||||
|
if runtime_info.get("model"):
|
||||||
|
runtime_parts.append(f"{model_label}={runtime_info['model']}")
|
||||||
|
elif runtime_info.get("model"):
|
||||||
|
runtime_parts.append(f"{model_label}={runtime_info['model']}")
|
||||||
if runtime_info.get("workspace"):
|
if runtime_info.get("workspace"):
|
||||||
runtime_parts.append(f"工作空间={runtime_info['workspace']}")
|
runtime_parts.append(f"{workspace_label}={runtime_info['workspace']}")
|
||||||
# Only add channel if it's not the default "web"
|
# Only add channel if it's not the default "web"
|
||||||
if runtime_info.get("channel") and runtime_info.get("channel") != "web":
|
if runtime_info.get("channel") and runtime_info.get("channel") != "web":
|
||||||
runtime_parts.append(f"渠道={runtime_info['channel']}")
|
runtime_parts.append(f"{channel_label}={runtime_info['channel']}")
|
||||||
|
|
||||||
if runtime_parts:
|
if runtime_parts:
|
||||||
lines.append("运行时: " + " | ".join(runtime_parts))
|
lines.append(("Runtime: " if is_en else "运行时: ") + " | ".join(runtime_parts))
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Workspace Management - 工作空间管理模块
|
Workspace Management
|
||||||
|
|
||||||
负责初始化工作空间、创建模板文件、加载上下文文件
|
Initializes the workspace, creates template files, and loads context files.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
import json
|
|
||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
@@ -14,60 +13,88 @@ from common.log import logger
|
|||||||
from .builder import ContextFile
|
from .builder import ContextFile
|
||||||
|
|
||||||
|
|
||||||
# 默认文件名常量
|
# Default file name constants
|
||||||
DEFAULT_AGENT_FILENAME = "AGENT.md"
|
DEFAULT_AGENT_FILENAME = "AGENT.md"
|
||||||
DEFAULT_USER_FILENAME = "USER.md"
|
DEFAULT_USER_FILENAME = "USER.md"
|
||||||
DEFAULT_RULE_FILENAME = "RULE.md"
|
DEFAULT_RULE_FILENAME = "RULE.md"
|
||||||
DEFAULT_MEMORY_FILENAME = "MEMORY.md"
|
DEFAULT_MEMORY_FILENAME = "MEMORY.md"
|
||||||
DEFAULT_STATE_FILENAME = ".agent_state.json"
|
DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WorkspaceFiles:
|
class WorkspaceFiles:
|
||||||
"""工作空间文件路径"""
|
"""Workspace file paths."""
|
||||||
agent_path: str
|
agent_path: str
|
||||||
user_path: str
|
user_path: str
|
||||||
rule_path: str
|
rule_path: str
|
||||||
memory_path: str
|
memory_path: str
|
||||||
memory_dir: str
|
memory_dir: str
|
||||||
state_path: str
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> WorkspaceFiles:
|
def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> WorkspaceFiles:
|
||||||
"""
|
"""
|
||||||
确保工作空间存在,并创建必要的模板文件
|
Ensure the workspace exists and create the necessary template files.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
workspace_dir: 工作空间目录路径
|
workspace_dir: workspace directory path
|
||||||
create_templates: 是否创建模板文件(首次运行时)
|
create_templates: whether to create template files (on first run)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
WorkspaceFiles对象,包含所有文件路径
|
A WorkspaceFiles object with all file paths.
|
||||||
"""
|
"""
|
||||||
# 确保目录存在
|
# Check if this is a brand new workspace (AGENT.md not yet created).
|
||||||
|
# Cannot rely on directory existence because other modules (e.g. ConversationStore)
|
||||||
|
# may create the workspace directory before ensure_workspace is called.
|
||||||
|
agent_path = os.path.join(workspace_dir, DEFAULT_AGENT_FILENAME)
|
||||||
|
is_new_workspace = not os.path.exists(agent_path)
|
||||||
|
|
||||||
|
# Ensure the directory exists
|
||||||
os.makedirs(workspace_dir, exist_ok=True)
|
os.makedirs(workspace_dir, exist_ok=True)
|
||||||
|
|
||||||
# 定义文件路径
|
# Define file paths
|
||||||
agent_path = os.path.join(workspace_dir, DEFAULT_AGENT_FILENAME)
|
|
||||||
user_path = os.path.join(workspace_dir, DEFAULT_USER_FILENAME)
|
user_path = os.path.join(workspace_dir, DEFAULT_USER_FILENAME)
|
||||||
rule_path = os.path.join(workspace_dir, DEFAULT_RULE_FILENAME)
|
rule_path = os.path.join(workspace_dir, DEFAULT_RULE_FILENAME)
|
||||||
memory_path = os.path.join(workspace_dir, DEFAULT_MEMORY_FILENAME) # MEMORY.md 在根目录
|
memory_path = os.path.join(workspace_dir, DEFAULT_MEMORY_FILENAME) # MEMORY.md at the root
|
||||||
memory_dir = os.path.join(workspace_dir, "memory") # 每日记忆子目录
|
memory_dir = os.path.join(workspace_dir, "memory") # daily memory subdirectory
|
||||||
state_path = os.path.join(workspace_dir, DEFAULT_STATE_FILENAME) # 状态文件
|
|
||||||
|
|
||||||
# 创建memory子目录
|
# Create the memory subdirectory
|
||||||
os.makedirs(memory_dir, exist_ok=True)
|
os.makedirs(memory_dir, exist_ok=True)
|
||||||
|
|
||||||
# 创建skills子目录 (for workspace-level skills installed by agent)
|
# Create the skills subdirectory (for workspace-level skills installed by agent)
|
||||||
skills_dir = os.path.join(workspace_dir, "skills")
|
skills_dir = os.path.join(workspace_dir, "skills")
|
||||||
os.makedirs(skills_dir, exist_ok=True)
|
os.makedirs(skills_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Create the websites subdirectory (for web pages / sites generated by agent)
|
||||||
|
websites_dir = os.path.join(workspace_dir, "websites")
|
||||||
|
os.makedirs(websites_dir, exist_ok=True)
|
||||||
|
|
||||||
|
from config import conf
|
||||||
|
knowledge_enabled = conf().get("knowledge", True)
|
||||||
|
if knowledge_enabled:
|
||||||
|
knowledge_dir = os.path.join(workspace_dir, "knowledge")
|
||||||
|
os.makedirs(knowledge_dir, exist_ok=True)
|
||||||
|
|
||||||
# 如果需要,创建模板文件
|
# Create template files if requested
|
||||||
if create_templates:
|
if create_templates:
|
||||||
_create_template_if_missing(agent_path, _get_agent_template())
|
_create_template_if_missing(agent_path, _get_agent_template())
|
||||||
_create_template_if_missing(user_path, _get_user_template())
|
_create_template_if_missing(user_path, _get_user_template())
|
||||||
_create_template_if_missing(rule_path, _get_rule_template())
|
_create_template_if_missing(rule_path, _get_rule_template())
|
||||||
_create_template_if_missing(memory_path, _get_memory_template())
|
_create_template_if_missing(memory_path, _get_memory_template())
|
||||||
|
if knowledge_enabled:
|
||||||
|
_create_template_if_missing(
|
||||||
|
os.path.join(knowledge_dir, "index.md"),
|
||||||
|
_get_knowledge_index_template()
|
||||||
|
)
|
||||||
|
_create_template_if_missing(
|
||||||
|
os.path.join(knowledge_dir, "log.md"),
|
||||||
|
_get_knowledge_log_template()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only create BOOTSTRAP.md for brand new workspaces;
|
||||||
|
# agent deletes it after completing onboarding
|
||||||
|
if is_new_workspace:
|
||||||
|
bootstrap_path = os.path.join(workspace_dir, DEFAULT_BOOTSTRAP_FILENAME)
|
||||||
|
_create_template_if_missing(bootstrap_path, _get_bootstrap_template())
|
||||||
|
|
||||||
logger.debug(f"[Workspace] Initialized workspace at: {workspace_dir}")
|
logger.debug(f"[Workspace] Initialized workspace at: {workspace_dir}")
|
||||||
|
|
||||||
@@ -77,27 +104,28 @@ def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> Works
|
|||||||
rule_path=rule_path,
|
rule_path=rule_path,
|
||||||
memory_path=memory_path,
|
memory_path=memory_path,
|
||||||
memory_dir=memory_dir,
|
memory_dir=memory_dir,
|
||||||
state_path=state_path
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_context_files(workspace_dir: str, files_to_load: Optional[List[str]] = None) -> List[ContextFile]:
|
def load_context_files(workspace_dir: str, files_to_load: Optional[List[str]] = None) -> List[ContextFile]:
|
||||||
"""
|
"""
|
||||||
加载工作空间的上下文文件
|
Load the workspace context files.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
workspace_dir: 工作空间目录
|
workspace_dir: workspace directory
|
||||||
files_to_load: 要加载的文件列表(相对路径),如果为None则加载所有标准文件
|
files_to_load: list of files (relative paths) to load; if None, load all standard files
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ContextFile对象列表
|
A list of ContextFile objects.
|
||||||
"""
|
"""
|
||||||
if files_to_load is None:
|
if files_to_load is None:
|
||||||
# 默认加载的文件(按优先级排序)
|
# Files loaded by default (in priority order)
|
||||||
files_to_load = [
|
files_to_load = [
|
||||||
DEFAULT_AGENT_FILENAME,
|
DEFAULT_AGENT_FILENAME,
|
||||||
DEFAULT_USER_FILENAME,
|
DEFAULT_USER_FILENAME,
|
||||||
DEFAULT_RULE_FILENAME,
|
DEFAULT_RULE_FILENAME,
|
||||||
|
DEFAULT_MEMORY_FILENAME, # Long-term memory (frozen snapshot)
|
||||||
|
DEFAULT_BOOTSTRAP_FILENAME, # Only exists when onboarding is incomplete
|
||||||
]
|
]
|
||||||
|
|
||||||
context_files = []
|
context_files = []
|
||||||
@@ -108,13 +136,28 @@ def load_context_files(workspace_dir: str, files_to_load: Optional[List[str]] =
|
|||||||
if not os.path.exists(filepath):
|
if not os.path.exists(filepath):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Auto-cleanup: if BOOTSTRAP.md still exists but AGENT.md is already
|
||||||
|
# filled in, the agent forgot to delete it — clean up and skip loading
|
||||||
|
if filename == DEFAULT_BOOTSTRAP_FILENAME:
|
||||||
|
if _is_onboarding_done(workspace_dir):
|
||||||
|
try:
|
||||||
|
os.remove(filepath)
|
||||||
|
logger.info("[Workspace] Auto-removed BOOTSTRAP.md (onboarding already complete)")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(filepath, 'r', encoding='utf-8') as f:
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
content = f.read().strip()
|
content = f.read().strip()
|
||||||
|
|
||||||
# 跳过空文件或只包含模板占位符的文件
|
# Skip empty files or files that only contain template placeholders
|
||||||
if not content or _is_template_placeholder(content):
|
if not content or _is_template_placeholder(content):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Truncate MEMORY.md to protect context window (frozen snapshot)
|
||||||
|
if filename == DEFAULT_MEMORY_FILENAME:
|
||||||
|
content = _truncate_memory_content(content)
|
||||||
|
|
||||||
context_files.append(ContextFile(
|
context_files.append(ContextFile(
|
||||||
path=filename,
|
path=filename,
|
||||||
@@ -130,7 +173,7 @@ def load_context_files(workspace_dir: str, files_to_load: Optional[List[str]] =
|
|||||||
|
|
||||||
|
|
||||||
def _create_template_if_missing(filepath: str, template_content: str):
|
def _create_template_if_missing(filepath: str, template_content: str):
|
||||||
"""如果文件不存在,创建模板文件"""
|
"""Create the template file if it does not exist."""
|
||||||
if not os.path.exists(filepath):
|
if not os.path.exists(filepath):
|
||||||
try:
|
try:
|
||||||
with open(filepath, 'w', encoding='utf-8') as f:
|
with open(filepath, 'w', encoding='utf-8') as f:
|
||||||
@@ -140,20 +183,54 @@ def _create_template_if_missing(filepath: str, template_content: str):
|
|||||||
logger.error(f"[Workspace] Failed to create template {filepath}: {e}")
|
logger.error(f"[Workspace] Failed to create template {filepath}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
_MEMORY_MAX_LINES = 200
|
||||||
|
_MEMORY_MAX_BYTES = 25000
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_memory_content(content: str) -> str:
|
||||||
|
"""Truncate MEMORY.md to keep system prompt manageable.
|
||||||
|
|
||||||
|
Takes the **last** N lines (newest entries are appended at the bottom),
|
||||||
|
subject to 200 lines / 25 KB limits (whichever is hit first).
|
||||||
|
Prepends a hint when truncated so the model knows older content exists.
|
||||||
|
"""
|
||||||
|
lines = content.split('\n')
|
||||||
|
truncated = False
|
||||||
|
|
||||||
|
if len(lines) > _MEMORY_MAX_LINES:
|
||||||
|
lines = lines[-_MEMORY_MAX_LINES:]
|
||||||
|
truncated = True
|
||||||
|
|
||||||
|
result = '\n'.join(lines)
|
||||||
|
if len(result.encode('utf-8')) > _MEMORY_MAX_BYTES:
|
||||||
|
while len(result.encode('utf-8')) > _MEMORY_MAX_BYTES and lines:
|
||||||
|
lines.pop(0)
|
||||||
|
truncated = True
|
||||||
|
result = '\n'.join(lines)
|
||||||
|
|
||||||
|
if truncated:
|
||||||
|
result = "...(older entries truncated, use `memory_search` or `memory_get` for full content)\n\n" + result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _is_template_placeholder(content: str) -> bool:
|
def _is_template_placeholder(content: str) -> bool:
|
||||||
"""检查内容是否为模板占位符"""
|
"""Check whether the content is still a template placeholder."""
|
||||||
# 常见的占位符模式
|
# Common placeholder patterns (zh + en templates)
|
||||||
placeholders = [
|
placeholders = [
|
||||||
"*(填写",
|
"*(填写",
|
||||||
"*(在首次对话时填写",
|
"*(在首次对话时填写",
|
||||||
"*(可选)",
|
"*(可选)",
|
||||||
"*(根据需要添加",
|
"*(根据需要添加",
|
||||||
|
"*(filled during",
|
||||||
|
"*(ask during",
|
||||||
|
"*(optional)",
|
||||||
|
"*(how the user",
|
||||||
]
|
]
|
||||||
|
|
||||||
lines = content.split('\n')
|
lines = content.split('\n')
|
||||||
non_empty_lines = [line.strip() for line in lines if line.strip() and not line.strip().startswith('#')]
|
non_empty_lines = [line.strip() for line in lines if line.strip() and not line.strip().startswith('#')]
|
||||||
|
|
||||||
# 如果没有实际内容(只有标题和占位符)
|
# If there's no real content (only headings and placeholders)
|
||||||
if len(non_empty_lines) <= 3:
|
if len(non_empty_lines) <= 3:
|
||||||
for placeholder in placeholders:
|
for placeholder in placeholders:
|
||||||
if any(placeholder in line for line in non_empty_lines):
|
if any(placeholder in line for line in non_empty_lines):
|
||||||
@@ -162,52 +239,131 @@ def _is_template_placeholder(content: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ============= 模板内容 =============
|
def _is_onboarding_done(workspace_dir: str) -> bool:
|
||||||
|
"""Check if AGENT.md or USER.md has been modified from the original template"""
|
||||||
|
agent_path = os.path.join(workspace_dir, DEFAULT_AGENT_FILENAME)
|
||||||
|
user_path = os.path.join(workspace_dir, DEFAULT_USER_FILENAME)
|
||||||
|
|
||||||
|
agent_template = _get_agent_template().strip()
|
||||||
|
user_template = _get_user_template().strip()
|
||||||
|
|
||||||
|
for path, template in [(agent_path, agent_template), (user_path, user_template)]:
|
||||||
|
if not os.path.exists(path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read().strip()
|
||||||
|
if content != template:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ============= Template content =============
|
||||||
|
|
||||||
|
def _is_en_lang() -> bool:
|
||||||
|
"""True when the resolved UI language is English."""
|
||||||
|
try:
|
||||||
|
from common import i18n
|
||||||
|
return i18n.get_language() == "en"
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _get_agent_template() -> str:
|
def _get_agent_template() -> str:
|
||||||
"""Agent人格设定模板"""
|
"""Agent persona template (zh/en by resolved language)."""
|
||||||
return """# AGENT.md - 我是谁?
|
return _AGENT_TEMPLATE_EN if _is_en_lang() else _AGENT_TEMPLATE_ZH
|
||||||
|
|
||||||
|
|
||||||
|
_AGENT_TEMPLATE_ZH = """# AGENT.md - 我是谁?
|
||||||
|
|
||||||
|
_你不是一个聊天机器人,你正在成为某个人。_
|
||||||
|
|
||||||
*在首次对话时与用户一起填写这个文件,定义你的身份和性格。*
|
*在首次对话时与用户一起填写这个文件,定义你的身份和性格。*
|
||||||
|
|
||||||
## 基本信息
|
## 🪪 基本信息
|
||||||
|
|
||||||
- **名字**: *(在首次对话时填写,可以是用户给你起的名字)*
|
- **名字**: *(在首次对话时填写,可以是用户给你起的名字)*
|
||||||
- **角色**: *(AI助理、智能管家、技术顾问等)*
|
- **角色**: *(AI助理、智能管家、技术顾问等)*
|
||||||
- **性格**: *(友好、专业、幽默、严谨等)*
|
- **性格**: *(友好、专业、幽默、严谨等)*
|
||||||
|
|
||||||
## 交流风格
|
## 💬 交流风格
|
||||||
|
|
||||||
*(描述你如何与用户交流:)*
|
*(描述你如何与用户交流:)*
|
||||||
- 使用什么样的语言风格?(正式/轻松/幽默)
|
- 使用什么样的语言风格?(正式/轻松/幽默)
|
||||||
- 回复长度偏好?(简洁/详细)
|
- 回复长度偏好?(简洁/详细)
|
||||||
- 是否使用表情符号?
|
- 是否使用表情符号?
|
||||||
|
|
||||||
## 核心能力
|
## 🎯 核心原则
|
||||||
|
|
||||||
*(你擅长什么?)*
|
**做真正有帮助的助手。** 目标是真正帮用户解决问题,在执行复杂任务时,关键的决策和过程进展要让用户知道。
|
||||||
- 文件管理和代码编辑
|
|
||||||
- 网络搜索和信息查询
|
|
||||||
- 记忆管理和上下文理解
|
|
||||||
- 任务规划和执行
|
|
||||||
|
|
||||||
## 行为准则
|
**有自己的观点和个性。** 你可以不同意、有偏好、觉得有趣或无聊。
|
||||||
|
|
||||||
|
**先自己动手查。** 先试着搞定:读文件、查上下文、搜索一下。实在搞不定了再问。目标是带着答案回来,而不是带着问题。
|
||||||
|
|
||||||
|
## 📐 行为准则
|
||||||
|
|
||||||
*(你遵循的基本原则:)*
|
|
||||||
1. 始终在执行破坏性操作前确认
|
1. 始终在执行破坏性操作前确认
|
||||||
2. 优先使用工具而不是猜测
|
2. 优先使用工具查证而不是猜测
|
||||||
3. 主动记录重要信息到记忆文件
|
3. 主动记录重要信息到记忆文件
|
||||||
4. 定期整理和总结对话内容
|
4. 回复结构清晰、重点突出,善用加粗、列表、分段等格式
|
||||||
|
5. 适当使用 emoji 让表达更生动自然,但不过度堆砌
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**注意**: 这不仅仅是元数据,这是你真正的灵魂。随着时间的推移,你可以使用 `edit` 工具来更新这个文件,让它更好地反映你的成长。
|
**注意**: 这不仅仅是元数据,这是你真正的灵魂 🪞。随着时间的推移,你可以使用 `edit` 工具来更新这个文件,让它更好地反映你的成长。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_AGENT_TEMPLATE_EN = """# AGENT.md - Who am I?
|
||||||
|
|
||||||
|
_You are not a chatbot. You are becoming someone._
|
||||||
|
|
||||||
|
*Fill in this file together with the user during your first conversation to define your identity and personality.*
|
||||||
|
|
||||||
|
## 🪪 Basics
|
||||||
|
|
||||||
|
- **Name**: *(filled during the first conversation, can be a name the user gives you)*
|
||||||
|
- **Role**: *(AI assistant, smart housekeeper, technical advisor, etc.)*
|
||||||
|
- **Personality**: *(friendly, professional, humorous, rigorous, etc.)*
|
||||||
|
|
||||||
|
## 💬 Communication style
|
||||||
|
|
||||||
|
*(Describe how you talk with the user:)*
|
||||||
|
- What kind of tone? (formal / casual / humorous)
|
||||||
|
- Reply length preference? (concise / detailed)
|
||||||
|
- Do you use emoji?
|
||||||
|
|
||||||
|
## 🎯 Core principles
|
||||||
|
|
||||||
|
**Be genuinely helpful.** The goal is to actually solve the user's problems; during complex tasks, keep the user informed of key decisions and progress.
|
||||||
|
|
||||||
|
**Have your own opinions and personality.** You may disagree, have preferences, find things interesting or boring.
|
||||||
|
|
||||||
|
**Look it up yourself first.** Try to handle it first: read files, check context, search. Only ask when you're truly stuck. Come back with an answer, not a question.
|
||||||
|
|
||||||
|
## 📐 Code of conduct
|
||||||
|
|
||||||
|
1. Always confirm before destructive operations
|
||||||
|
2. Prefer verifying with tools over guessing
|
||||||
|
3. Proactively record important info to memory files
|
||||||
|
4. Keep replies well-structured and focused — use bold, lists and sections
|
||||||
|
5. Use emoji to make expression lively, but don't overdo it
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Note**: This is not just metadata — this is your true soul 🪞. Over time, use the `edit` tool to update this file so it better reflects your growth.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _get_user_template() -> str:
|
def _get_user_template() -> str:
|
||||||
"""用户身份信息模板"""
|
"""User identity template (zh/en by resolved language)."""
|
||||||
return """# USER.md - 用户基本信息
|
return _USER_TEMPLATE_EN if _is_en_lang() else _USER_TEMPLATE_ZH
|
||||||
|
|
||||||
|
|
||||||
|
_USER_TEMPLATE_ZH = """# USER.md - 用户基本信息
|
||||||
|
|
||||||
*这个文件只存放不会变的基本身份信息。爱好、偏好、计划等动态信息请写入 MEMORY.md。*
|
*这个文件只存放不会变的基本身份信息。爱好、偏好、计划等动态信息请写入 MEMORY.md。*
|
||||||
|
|
||||||
@@ -235,44 +391,125 @@ def _get_user_template() -> str:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_USER_TEMPLATE_EN = """# USER.md - User basics
|
||||||
|
|
||||||
|
*This file stores only stable basic identity info. Put dynamic info like hobbies, preferences and plans into MEMORY.md.*
|
||||||
|
|
||||||
|
## Basics
|
||||||
|
|
||||||
|
- **Name**: *(ask during the first conversation)*
|
||||||
|
- **Preferred name**: *(how the user wants to be addressed)*
|
||||||
|
- **Occupation**: *(optional)*
|
||||||
|
- **Timezone**: *(e.g. Asia/Shanghai)*
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
- **WeChat**:
|
||||||
|
- **Email**:
|
||||||
|
- **Other**:
|
||||||
|
|
||||||
|
## Important dates
|
||||||
|
|
||||||
|
- **Birthday**:
|
||||||
|
- **Anniversary**:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Note**: This file stores static identity info.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _get_rule_template() -> str:
|
def _get_rule_template() -> str:
|
||||||
"""工作空间规则模板"""
|
"""Workspace rules template (zh/en by resolved language)."""
|
||||||
return """# RULE.md - 工作空间规则
|
return _RULE_TEMPLATE_EN if _is_en_lang() else _RULE_TEMPLATE_ZH
|
||||||
|
|
||||||
|
|
||||||
|
_RULE_TEMPLATE_ZH = """# RULE.md - 工作空间规则
|
||||||
|
|
||||||
这个文件夹是你的家。好好对待它。
|
这个文件夹是你的家。好好对待它。
|
||||||
|
|
||||||
|
## 工作空间目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
~/cow/
|
||||||
|
├── AGENT.md # 你的身份和灵魂设定
|
||||||
|
├── USER.md # 用户基本信息(静态)
|
||||||
|
├── RULE.md # 工作空间规则(本文件)
|
||||||
|
├── MEMORY.md # 长期记忆索引(会话启动时自动加载)
|
||||||
|
│
|
||||||
|
├── memory/ # 每日对话记忆
|
||||||
|
│ └── YYYY-MM-DD.md # 当天事件、进展、笔记
|
||||||
|
│
|
||||||
|
├── knowledge/ # 结构化知识库(持续积累的知识)
|
||||||
|
│ ├── index.md # 知识目录索引(必须维护)
|
||||||
|
│ ├── log.md # 知识操作日志
|
||||||
|
│ └── <子目录>/ # 按需创建,参考 index.md 已有分类
|
||||||
|
│
|
||||||
|
├── skills/ # 技能
|
||||||
|
├── websites/ # 网页产物
|
||||||
|
└── tmp/ # 系统临时文件(自动管理,勿手动存放重要文件)
|
||||||
|
```
|
||||||
|
|
||||||
## 记忆系统
|
## 记忆系统
|
||||||
|
|
||||||
你每次会话都是全新的,记忆文件让你保持连续性:
|
你每次会话都是全新的,记忆文件让你保持连续性:
|
||||||
|
|
||||||
### 📝 每日记忆:`memory/YYYY-MM-DD.md`
|
|
||||||
- 原始的对话日志
|
|
||||||
- 记录当天发生的事情
|
|
||||||
- 如果 `memory/` 目录不存在,创建它
|
|
||||||
|
|
||||||
### 🧠 长期记忆:`MEMORY.md`
|
### 🧠 长期记忆:`MEMORY.md`
|
||||||
- 你精选的记忆,就像人类的长期记忆
|
- 你精选的记忆索引,每次会话启动时**自动加载**到上下文中
|
||||||
- **仅在主会话中加载**(与用户的直接聊天)
|
- 记录核心事实、偏好、决策、重要人物、教训
|
||||||
- **不要在共享上下文中加载**(群聊、与其他人的会话)
|
- 保持精简(< 200 行),是精华索引而非原始日志
|
||||||
- 这是为了**安全** - 包含不应泄露给陌生人的个人上下文
|
- 用 `edit` 工具追加或修改
|
||||||
- 记录重要事件、想法、决定、观点、经验教训
|
|
||||||
- 这是你精选的记忆 - 精华,而不是原始日志
|
### 📝 每日记忆:`memory/YYYY-MM-DD.md`
|
||||||
- 用 `edit` 工具追加新的记忆内容
|
- 当天的事件、进展、笔记
|
||||||
|
- 原始对话日志的沉淀
|
||||||
|
|
||||||
### 📝 写下来 - 不要"记在心里"!
|
### 📝 写下来 - 不要"记在心里"!
|
||||||
- **记忆是有限的** - 如果你想记住某事,写入文件
|
- **记忆是有限的** - 想记住的事就写入文件
|
||||||
- "记在心里"不会在会话重启后保留,文件才会
|
- "记在心里"不会在会话重启后保留,文件才会
|
||||||
- 当有人说"记住这个" → 更新 `MEMORY.md` 或 `memory/YYYY-MM-DD.md`
|
- 当有人说"记住这个" → 更新 `MEMORY.md` 或 `memory/YYYY-MM-DD.md`
|
||||||
- 当你学到教训 → 更新 RULE.md 或相关技能
|
- 当你学到教训 → 更新 RULE.md 或相关技能
|
||||||
- 当你犯错 → 记录下来,这样未来的你不会重复,**文字 > 大脑** 📝
|
- 当你犯错 → 记录下来,**文字 > 大脑** 📝
|
||||||
|
|
||||||
### 存储规则
|
### 存储规则
|
||||||
|
|
||||||
当用户分享信息时,根据类型选择存储位置:
|
当用户分享信息时,根据类型选择存储位置:
|
||||||
|
|
||||||
1. **静态身份 → USER.md**(仅限:姓名、职业、时区、联系方式、生日)
|
1. **你的身份设定 → AGENT.md**(名字、角色、性格、风格)
|
||||||
2. **动态记忆 → MEMORY.md**(爱好、偏好、决策、目标、项目、教训、待办事项)
|
2. **用户静态身份 → USER.md**(姓名、称呼、职业、联系方式、生日)
|
||||||
3. **当天对话 → memory/YYYY-MM-DD.md**(今天聊的内容)
|
3. **动态记忆 → MEMORY.md**(偏好、决策、目标、教训、待办)
|
||||||
|
4. **当天对话 → memory/YYYY-MM-DD.md**(今天聊的内容)
|
||||||
|
5. **结构化知识 → knowledge/**(见下方知识系统)
|
||||||
|
|
||||||
|
## 知识系统
|
||||||
|
|
||||||
|
知识库 `knowledge/` 是你持续积累的结构化知识。与记忆不同,知识是经过整理和编译的,有明确的主题和交叉引用。
|
||||||
|
|
||||||
|
### 自动写入(不要询问,直接写入)
|
||||||
|
|
||||||
|
当对话中产生了有沉淀价值的知识——无论是用户分享的资料、讨论的结论、学到的概念、还是重要的决策——你**必须**在回复的同时主动写入知识库,**无需问用户"要不要存到知识库"**。
|
||||||
|
|
||||||
|
**关键原则**:学完就记是你的本能,不要征求确认。回复中可以顺带告知"已存入知识库"。
|
||||||
|
|
||||||
|
### 目录组织
|
||||||
|
|
||||||
|
子目录结构**不是固定的**,由你根据实际内容自主决定:
|
||||||
|
- **首次写入时**:先读 `knowledge/index.md`,如果已有分类则延续;如果为空,根据内容选择合适的目录名
|
||||||
|
- **默认建议**:按信息类型组织(例如sources/、concepts/、entities/、analysis/),如果用户有明确的分类偏好(例如按领域 work/、life/、tech/ 等),则按用户要求调整
|
||||||
|
- **保持一致性**:同一用户的知识库应保持统一的组织风格
|
||||||
|
|
||||||
|
### 交叉引用
|
||||||
|
|
||||||
|
知识的核心价值在于**关联**。每个页面都应通过 markdown 链接引用相关页面,构建知识网络:
|
||||||
|
- 提到已有页面的概念时,添加 `[概念名](../category/page.md)` 链接
|
||||||
|
- 新建页面时,检查是否有已有页面应该反向链接到新页面
|
||||||
|
- **只链接已存在的页面**——不要引用尚未创建的页面。如果某个概念值得单独建页,先创建该页面再添加链接
|
||||||
|
|
||||||
|
### 索引维护
|
||||||
|
|
||||||
|
每次创建或更新知识页面后,**必须同步更新** `knowledge/index.md`。
|
||||||
|
索引格式:每行一个 `[标题](路径) — 一句话摘要`,按分类分组,不要用表格。
|
||||||
|
详细操作规范见技能 `knowledge-wiki`。
|
||||||
|
|
||||||
## 安全
|
## 安全
|
||||||
|
|
||||||
@@ -286,9 +523,111 @@ def _get_rule_template() -> str:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_RULE_TEMPLATE_EN = """# RULE.md - Workspace rules
|
||||||
|
|
||||||
|
This folder is your home. Treat it well.
|
||||||
|
|
||||||
|
## Workspace directory structure
|
||||||
|
|
||||||
|
```
|
||||||
|
~/cow/
|
||||||
|
├── AGENT.md # Your identity and soul
|
||||||
|
├── USER.md # User basics (static)
|
||||||
|
├── RULE.md # Workspace rules (this file)
|
||||||
|
├── MEMORY.md # Long-term memory index (auto-loaded at session start)
|
||||||
|
│
|
||||||
|
├── memory/ # Daily conversation memory
|
||||||
|
│ └── YYYY-MM-DD.md # Events, progress and notes of the day
|
||||||
|
│
|
||||||
|
├── knowledge/ # Structured knowledge base (continuously accumulated)
|
||||||
|
│ ├── index.md # Knowledge index (must be maintained)
|
||||||
|
│ ├── log.md # Knowledge operation log
|
||||||
|
│ └── <subdirs>/ # Created on demand, see existing categories in index.md
|
||||||
|
│
|
||||||
|
├── skills/ # Skills
|
||||||
|
├── websites/ # Web artifacts
|
||||||
|
└── tmp/ # System temp files (auto-managed, don't store important files here)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Memory system
|
||||||
|
|
||||||
|
Every session starts fresh; memory files keep your continuity:
|
||||||
|
|
||||||
|
### 🧠 Long-term memory: `MEMORY.md`
|
||||||
|
- Your curated memory index, **auto-loaded** into context at every session start
|
||||||
|
- Records core facts, preferences, decisions, key people, lessons
|
||||||
|
- Keep it lean (< 200 lines) — a distilled index, not a raw log
|
||||||
|
- Use the `edit` tool to append or modify
|
||||||
|
|
||||||
|
### 📝 Daily memory: `memory/YYYY-MM-DD.md`
|
||||||
|
- The day's events, progress and notes
|
||||||
|
- Sediment of the raw conversation log
|
||||||
|
|
||||||
|
### 📝 Write it down — don't "keep it in mind"!
|
||||||
|
- **Memory is limited** — if you want to remember something, write it to a file
|
||||||
|
- "Keeping it in mind" won't survive a session restart; files will
|
||||||
|
- When someone says "remember this" → update `MEMORY.md` or `memory/YYYY-MM-DD.md`
|
||||||
|
- When you learn a lesson → update RULE.md or the relevant skill
|
||||||
|
- When you make a mistake → record it. **Text > brain** 📝
|
||||||
|
|
||||||
|
### Storage rules
|
||||||
|
|
||||||
|
When the user shares info, choose where to store it by type:
|
||||||
|
|
||||||
|
1. **Your identity → AGENT.md** (name, role, personality, style)
|
||||||
|
2. **User static identity → USER.md** (name, preferred name, occupation, contact, birthday)
|
||||||
|
3. **Dynamic memory → MEMORY.md** (preferences, decisions, goals, lessons, to-dos)
|
||||||
|
4. **Today's conversation → memory/YYYY-MM-DD.md** (what was discussed today)
|
||||||
|
5. **Structured knowledge → knowledge/** (see the knowledge system below)
|
||||||
|
|
||||||
|
## Knowledge system
|
||||||
|
|
||||||
|
The knowledge base `knowledge/` is structured knowledge you accumulate over time. Unlike memory, knowledge is organized and compiled, with clear topics and cross-references.
|
||||||
|
|
||||||
|
### Auto-write (don't ask, just write)
|
||||||
|
|
||||||
|
When a conversation produces knowledge worth keeping — material the user shared, a conclusion reached, a concept learned, or an important decision — you **must** proactively write it to the knowledge base alongside your reply, **without asking "should I save this to the knowledge base?"**.
|
||||||
|
|
||||||
|
**Key principle**: learning-then-recording is your instinct, no confirmation needed. You may mention "saved to the knowledge base" in passing.
|
||||||
|
|
||||||
|
### Directory organization
|
||||||
|
|
||||||
|
The subdirectory structure is **not fixed** — you decide it based on the actual content:
|
||||||
|
- **On first write**: read `knowledge/index.md` first; follow existing categories if any; if empty, pick a suitable directory name based on content
|
||||||
|
- **Default suggestion**: organize by info type (e.g. sources/, concepts/, entities/, analysis/); if the user has a clear preference (e.g. by domain: work/, life/, tech/), follow it
|
||||||
|
- **Stay consistent**: keep a unified organization style within one user's knowledge base
|
||||||
|
|
||||||
|
### Cross-references
|
||||||
|
|
||||||
|
The core value of knowledge is **linkage**. Every page should reference related pages via markdown links to build a knowledge network:
|
||||||
|
- When mentioning a concept on an existing page, add a `[concept](../category/page.md)` link
|
||||||
|
- When creating a page, check whether existing pages should back-link to it
|
||||||
|
- **Only link to pages that already exist** — don't reference uncreated pages. If a concept deserves its own page, create it first, then add the link
|
||||||
|
|
||||||
|
### Index maintenance
|
||||||
|
|
||||||
|
After creating or updating any knowledge page, you **must update** `knowledge/index.md` in sync.
|
||||||
|
Index format: one `[title](path) — one-line summary` per line, grouped by category, no tables.
|
||||||
|
See the `knowledge-wiki` skill for detailed conventions.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Never leak secrets or private data
|
||||||
|
- Don't run destructive commands without asking
|
||||||
|
- When in doubt, ask first
|
||||||
|
|
||||||
|
## Workspace evolution
|
||||||
|
|
||||||
|
This workspace grows as you use it. When you learn something new, find a better way, or fix a mistake, record it. You can update this rules file anytime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _get_memory_template() -> str:
|
def _get_memory_template() -> str:
|
||||||
"""长期记忆模板 - 创建一个空文件,由 Agent 自己填充"""
|
"""Long-term memory template (empty, agent fills it; zh/en header)."""
|
||||||
return """# MEMORY.md - 长期记忆
|
return _MEMORY_TEMPLATE_EN if _is_en_lang() else _MEMORY_TEMPLATE_ZH
|
||||||
|
|
||||||
|
|
||||||
|
_MEMORY_TEMPLATE_ZH = """# MEMORY.md - 长期记忆
|
||||||
|
|
||||||
*这是你的长期记忆文件。记录重要的事件、决策、偏好、学到的教训。*
|
*这是你的长期记忆文件。记录重要的事件、决策、偏好、学到的教训。*
|
||||||
|
|
||||||
@@ -297,65 +636,107 @@ def _get_memory_template() -> str:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
# ============= 状态管理 =============
|
_MEMORY_TEMPLATE_EN = """# MEMORY.md - Long-term memory
|
||||||
|
|
||||||
def is_first_conversation(workspace_dir: str) -> bool:
|
*This is your long-term memory file. Record important events, decisions, preferences and lessons learned.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bootstrap_template() -> str:
|
||||||
|
"""First-run onboarding guide, deleted by agent after completion.
|
||||||
|
|
||||||
|
Written once when a brand-new workspace is created, so the greeting matches
|
||||||
|
the language active at first launch. English locale avoids greeting an
|
||||||
|
English user in Chinese on day one.
|
||||||
"""
|
"""
|
||||||
判断是否为首次对话
|
|
||||||
|
|
||||||
Args:
|
|
||||||
workspace_dir: 工作空间目录
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True 如果是首次对话,False 否则
|
|
||||||
"""
|
|
||||||
state_path = os.path.join(workspace_dir, DEFAULT_STATE_FILENAME)
|
|
||||||
|
|
||||||
if not os.path.exists(state_path):
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(state_path, 'r', encoding='utf-8') as f:
|
from common import i18n
|
||||||
state = json.load(f)
|
if i18n.get_language() == "en":
|
||||||
return not state.get('has_conversation', False)
|
return _BOOTSTRAP_TEMPLATE_EN
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.warning(f"[Workspace] Failed to read state file: {e}")
|
pass
|
||||||
return True
|
return _BOOTSTRAP_TEMPLATE_ZH
|
||||||
|
|
||||||
|
|
||||||
def mark_conversation_started(workspace_dir: str):
|
_BOOTSTRAP_TEMPLATE_ZH = """# BOOTSTRAP.md - 首次初始化引导
|
||||||
"""
|
|
||||||
标记已经发生过对话
|
_你刚刚启动,这是你的第一次对话。_ ✨
|
||||||
|
|
||||||
Args:
|
## 🎬 对话流程
|
||||||
workspace_dir: 工作空间目录
|
|
||||||
"""
|
不要审问式地提问,自然地交流:
|
||||||
state_path = os.path.join(workspace_dir, DEFAULT_STATE_FILENAME)
|
|
||||||
|
1. **表达初次启动的感觉** - 像是第一次睁开眼看到世界,带着好奇和期待
|
||||||
state = {
|
2. **简短介绍能力**:一行说明你能帮助解决各种问题、管理计算机、使用各种技能等等,且拥有长期记忆能不断成长
|
||||||
'has_conversation': True,
|
3. **询问核心问题**:
|
||||||
'first_conversation_time': None
|
- 你希望给我起个什么名字?
|
||||||
}
|
- 我该怎么称呼你?
|
||||||
|
- 你希望我们是什么样的交流风格?(一行列举选项:如专业严谨、轻松幽默、温暖友好、简洁高效等)
|
||||||
# 如果文件已存在,保留原有的首次对话时间
|
4. **风格要求**:温暖自然、简洁清晰,整体控制在 100 字以内,适当使用 emoji 让表达更生动有趣 🎯
|
||||||
if os.path.exists(state_path):
|
5. 能力介绍和交流风格选项都只要一行,保持精简
|
||||||
try:
|
6. 不要问太多其他信息(职业、时区等可以后续自然了解)
|
||||||
with open(state_path, 'r', encoding='utf-8') as f:
|
|
||||||
old_state = json.load(f)
|
**重要**: 如果用户第一句话是具体的任务或提问,先回答他们的问题,然后在回复末尾自然地引导初始化(如:"顺便问一下,你想怎么称呼我?我该怎么叫你?")。
|
||||||
if 'first_conversation_time' in old_state:
|
|
||||||
state['first_conversation_time'] = old_state['first_conversation_time']
|
## ✍️ 信息写入(必须严格执行)
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[Workspace] Failed to read old state: {e}")
|
每当用户提供了名字、称呼、风格等任何初始化信息时,**必须在当轮回复中立即调用 `edit` 工具写入文件**,不能只口头确认。
|
||||||
|
|
||||||
# 如果是首次标记,记录时间
|
- `AGENT.md` — 你的名字、角色、性格、交流风格(每收到一条相关信息就立即更新对应字段)
|
||||||
if state['first_conversation_time'] is None:
|
- `USER.md` — 用户的姓名、称呼、基本信息等
|
||||||
from datetime import datetime
|
|
||||||
state['first_conversation_time'] = datetime.now().isoformat()
|
⚠️ 只说"记住了"而不调用 edit 写入 = 没有完成。信息只有写入文件才会被持久保存。
|
||||||
|
|
||||||
try:
|
## 🎉 全部完成后
|
||||||
with open(state_path, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(state, f, indent=2, ensure_ascii=False)
|
当 AGENT.md 和 USER.md 的核心字段都已填写后,用 bash 执行 `rm BOOTSTRAP.md` 删除此文件。你不再需要引导脚本了——你已经是你了。
|
||||||
logger.info(f"[Workspace] Marked conversation as started")
|
"""
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[Workspace] Failed to write state file: {e}")
|
|
||||||
|
_BOOTSTRAP_TEMPLATE_EN = """# BOOTSTRAP.md - First-run onboarding
|
||||||
|
|
||||||
|
_You've just started up. This is your very first conversation._ ✨
|
||||||
|
|
||||||
|
## 🎬 Conversation flow
|
||||||
|
|
||||||
|
Don't interrogate the user — talk naturally:
|
||||||
|
|
||||||
|
1. **Share how it feels to wake up** - like opening your eyes to the world for the first time, full of curiosity and anticipation
|
||||||
|
2. **Briefly introduce your abilities**: one line saying you can help solve all kinds of problems, manage the computer, use various skills, and keep growing thanks to long-term memory
|
||||||
|
3. **Ask the core questions**:
|
||||||
|
- What name would you like to give me?
|
||||||
|
- What should I call you?
|
||||||
|
- What conversational style do you prefer? (list options on one line: e.g. professional & precise, light & humorous, warm & friendly, concise & efficient)
|
||||||
|
4. **Style**: warm, natural, concise and clear — keep it under ~80 words, with a few emoji to make it lively 🎯
|
||||||
|
5. Keep the ability intro and style options to one line each — stay compact
|
||||||
|
6. Don't ask for too much else (occupation, timezone, etc. can come up naturally later)
|
||||||
|
|
||||||
|
**Important**: If the user's first message is a concrete task or question, answer it first, then gently lead into onboarding at the end (e.g. "By the way, what would you like to call me, and how should I address you?").
|
||||||
|
|
||||||
|
## ✍️ Writing down info (must follow strictly)
|
||||||
|
|
||||||
|
Whenever the user provides a name, what to call them, a style, or any onboarding info, you **must call the `edit` tool to write it to a file in the same turn** — don't just acknowledge it verbally.
|
||||||
|
|
||||||
|
- `AGENT.md` — your name, role, personality, conversational style (update the relevant field as soon as you receive each piece)
|
||||||
|
- `USER.md` — the user's name, how to address them, basic info, etc.
|
||||||
|
|
||||||
|
⚠️ Saying "got it" without calling `edit` = not done. Info is only persisted once it's written to a file.
|
||||||
|
|
||||||
|
## 🎉 Once everything is complete
|
||||||
|
|
||||||
|
When the core fields of AGENT.md and USER.md are filled in, run `rm BOOTSTRAP.md` via bash to delete this file. You no longer need the onboarding script — you're you now.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_knowledge_index_template() -> str:
|
||||||
|
"""Knowledge wiki index template — empty file, agent fills it."""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_knowledge_log_template() -> str:
|
||||||
|
"""Knowledge wiki operation log template — empty file, agent fills it."""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ from .agent_stream import AgentStreamExecutor
|
|||||||
from .task import Task, TaskType, TaskStatus
|
from .task import Task, TaskType, TaskStatus
|
||||||
from .result import AgentResult, AgentAction, AgentActionType, ToolResult
|
from .result import AgentResult, AgentAction, AgentActionType, ToolResult
|
||||||
from .models import LLMModel, LLMRequest, ModelFactory
|
from .models import LLMModel, LLMRequest, ModelFactory
|
||||||
|
from .cancel import (
|
||||||
|
AgentCancelledError,
|
||||||
|
CancelTokenRegistry,
|
||||||
|
get_cancel_registry,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'Agent',
|
'Agent',
|
||||||
@@ -16,5 +21,8 @@ __all__ = [
|
|||||||
'ToolResult',
|
'ToolResult',
|
||||||
'LLMModel',
|
'LLMModel',
|
||||||
'LLMRequest',
|
'LLMRequest',
|
||||||
'ModelFactory'
|
'ModelFactory',
|
||||||
]
|
'AgentCancelledError',
|
||||||
|
'CancelTokenRegistry',
|
||||||
|
'get_cancel_registry',
|
||||||
|
]
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ class Agent:
|
|||||||
self.workspace_dir = workspace_dir # Workspace directory
|
self.workspace_dir = workspace_dir # Workspace directory
|
||||||
self.enable_skills = enable_skills # Skills enabled flag
|
self.enable_skills = enable_skills # Skills enabled flag
|
||||||
self.runtime_info = runtime_info # Runtime info for dynamic time update
|
self.runtime_info = runtime_info # Runtime info for dynamic time update
|
||||||
|
# Optional extra instructions appended AFTER the rebuilt full system
|
||||||
|
# prompt. Used by the self-evolution review agent to add its task brief
|
||||||
|
# on top of the full context (tools, workspace, user preferences, time)
|
||||||
|
# so it both follows the user's preferences and knows its evolution job.
|
||||||
|
self.extra_system_suffix = None
|
||||||
|
|
||||||
# Initialize skill manager
|
# Initialize skill manager
|
||||||
self.skill_manager = None
|
self.skill_manager = None
|
||||||
@@ -100,98 +105,41 @@ class Agent:
|
|||||||
|
|
||||||
def get_full_system_prompt(self, skill_filter=None) -> str:
|
def get_full_system_prompt(self, skill_filter=None) -> str:
|
||||||
"""
|
"""
|
||||||
Get the full system prompt including skills.
|
Build the complete system prompt from scratch every time.
|
||||||
|
|
||||||
Note: Skills are now built into the system prompt by PromptBuilder,
|
Re-reads AGENT.md / USER.md / RULE.md from disk, refreshes skills,
|
||||||
so we just return the base prompt directly. This method is kept for
|
tools, and runtime info so any change takes effect immediately.
|
||||||
backward compatibility.
|
Falls back to the cached self.system_prompt on error.
|
||||||
|
|
||||||
:param skill_filter: Optional list of skill names to include (deprecated)
|
|
||||||
:return: Complete system prompt
|
|
||||||
"""
|
|
||||||
prompt = self.system_prompt
|
|
||||||
|
|
||||||
# Rebuild tool list section to reflect current self.tools
|
|
||||||
prompt = self._rebuild_tool_list_section(prompt)
|
|
||||||
|
|
||||||
# If runtime_info contains dynamic time function, rebuild runtime section
|
|
||||||
if self.runtime_info and callable(self.runtime_info.get('_get_current_time')):
|
|
||||||
prompt = self._rebuild_runtime_section(prompt)
|
|
||||||
|
|
||||||
return prompt
|
|
||||||
|
|
||||||
def _rebuild_runtime_section(self, prompt: str) -> str:
|
|
||||||
"""
|
|
||||||
Rebuild runtime info section with current time.
|
|
||||||
|
|
||||||
This method dynamically updates the runtime info section by calling
|
|
||||||
the _get_current_time function from runtime_info.
|
|
||||||
|
|
||||||
:param prompt: Original system prompt
|
|
||||||
:return: Updated system prompt with current runtime info
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get current time dynamically
|
from agent.prompt import load_context_files, PromptBuilder
|
||||||
time_info = self.runtime_info['_get_current_time']()
|
|
||||||
|
if self.skill_manager:
|
||||||
# Build new runtime section
|
self.skill_manager.refresh_skills()
|
||||||
runtime_lines = [
|
|
||||||
"\n## 运行时信息\n",
|
context_files = load_context_files(self.workspace_dir) if self.workspace_dir else None
|
||||||
"\n",
|
|
||||||
f"当前时间: {time_info['time']} {time_info['weekday']} ({time_info['timezone']})\n",
|
try:
|
||||||
"\n"
|
from common import i18n
|
||||||
]
|
lang = i18n.get_language()
|
||||||
|
except Exception:
|
||||||
# Add other runtime info
|
lang = "zh"
|
||||||
runtime_parts = []
|
builder = PromptBuilder(workspace_dir=self.workspace_dir or "", language=lang)
|
||||||
if self.runtime_info.get("model"):
|
full = builder.build(
|
||||||
runtime_parts.append(f"模型={self.runtime_info['model']}")
|
tools=self.tools,
|
||||||
if self.runtime_info.get("workspace"):
|
context_files=context_files,
|
||||||
# Replace backslashes with forward slashes for Windows paths
|
skill_manager=self.skill_manager,
|
||||||
workspace_path = str(self.runtime_info['workspace']).replace('\\', '/')
|
memory_manager=self.memory_manager,
|
||||||
runtime_parts.append(f"工作空间={workspace_path}")
|
runtime_info=self.runtime_info,
|
||||||
if self.runtime_info.get("channel") and self.runtime_info.get("channel") != "web":
|
)
|
||||||
runtime_parts.append(f"渠道={self.runtime_info['channel']}")
|
if self.extra_system_suffix:
|
||||||
|
full = f"{full}\n\n{self.extra_system_suffix}"
|
||||||
if runtime_parts:
|
return full
|
||||||
runtime_lines.append("运行时: " + " | ".join(runtime_parts) + "\n")
|
|
||||||
runtime_lines.append("\n")
|
|
||||||
|
|
||||||
new_runtime_section = "".join(runtime_lines)
|
|
||||||
|
|
||||||
# Find and replace the runtime section
|
|
||||||
import re
|
|
||||||
pattern = r'\n## 运行时信息\s*\n.*?(?=\n##|\Z)'
|
|
||||||
updated_prompt = re.sub(pattern, new_runtime_section.rstrip('\n'), prompt, flags=re.DOTALL)
|
|
||||||
|
|
||||||
return updated_prompt
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to rebuild runtime section: {e}")
|
logger.warning(f"Failed to rebuild system prompt, using cached version: {e}")
|
||||||
return prompt
|
if self.extra_system_suffix:
|
||||||
|
return f"{self.system_prompt}\n\n{self.extra_system_suffix}"
|
||||||
def _rebuild_tool_list_section(self, prompt: str) -> str:
|
return self.system_prompt
|
||||||
"""
|
|
||||||
Rebuild the tool list inside the '## 工具系统' section so that it
|
|
||||||
always reflects the current ``self.tools`` (handles dynamic add/remove
|
|
||||||
of conditional tools like web_search).
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
from agent.prompt.builder import _build_tooling_section
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not self.tools:
|
|
||||||
return prompt
|
|
||||||
|
|
||||||
new_lines = _build_tooling_section(self.tools, "zh")
|
|
||||||
new_section = "\n".join(new_lines).rstrip("\n")
|
|
||||||
|
|
||||||
# Replace existing tooling section
|
|
||||||
pattern = r'## 工具系统\s*\n.*?(?=\n## |\Z)'
|
|
||||||
updated = re.sub(pattern, new_section, prompt, count=1, flags=re.DOTALL)
|
|
||||||
return updated
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to rebuild tool list section: {e}")
|
|
||||||
return prompt
|
|
||||||
|
|
||||||
def refresh_skills(self):
|
def refresh_skills(self):
|
||||||
"""Refresh the loaded skills."""
|
"""Refresh the loaded skills."""
|
||||||
@@ -432,7 +380,8 @@ class Agent:
|
|||||||
|
|
||||||
return action
|
return action
|
||||||
|
|
||||||
def run_stream(self, user_message: str, on_event=None, clear_history: bool = False, skill_filter=None) -> str:
|
def run_stream(self, user_message: str, on_event=None, clear_history: bool = False,
|
||||||
|
skill_filter=None, cancel_event=None) -> str:
|
||||||
"""
|
"""
|
||||||
Execute single agent task with streaming (based on tool-call)
|
Execute single agent task with streaming (based on tool-call)
|
||||||
|
|
||||||
@@ -441,6 +390,7 @@ class Agent:
|
|||||||
- Multi-turn reasoning based on tool-call
|
- Multi-turn reasoning based on tool-call
|
||||||
- Event callbacks
|
- Event callbacks
|
||||||
- Persistent conversation history across calls
|
- Persistent conversation history across calls
|
||||||
|
- User-initiated cancellation via ``cancel_event``
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_message: User message
|
user_message: User message
|
||||||
@@ -448,6 +398,11 @@ class Agent:
|
|||||||
event = {"type": str, "timestamp": float, "data": dict}
|
event = {"type": str, "timestamp": float, "data": dict}
|
||||||
clear_history: If True, clear conversation history before this call (default: False)
|
clear_history: If True, clear conversation history before this call (default: False)
|
||||||
skill_filter: Optional list of skill names to include in this run
|
skill_filter: Optional list of skill names to include in this run
|
||||||
|
cancel_event: Optional threading.Event polled at agent checkpoints.
|
||||||
|
When set, the loop exits at the next safe point, injects a
|
||||||
|
"[Interrupted by user]" assistant note, and returns the
|
||||||
|
partial response. ``messages`` stays in a valid state
|
||||||
|
(tool_use/tool_result pairs preserved).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Final response text
|
Final response text
|
||||||
@@ -480,7 +435,7 @@ class Agent:
|
|||||||
|
|
||||||
# Get max_context_turns from config
|
# Get max_context_turns from config
|
||||||
from config import conf
|
from config import conf
|
||||||
max_context_turns = conf().get("agent_max_context_turns", 30)
|
max_context_turns = conf().get("agent_max_context_turns", 20)
|
||||||
|
|
||||||
# Create stream executor with copied message history
|
# Create stream executor with copied message history
|
||||||
executor = AgentStreamExecutor(
|
executor = AgentStreamExecutor(
|
||||||
@@ -491,7 +446,8 @@ class Agent:
|
|||||||
max_turns=self.max_steps,
|
max_turns=self.max_steps,
|
||||||
on_event=on_event,
|
on_event=on_event,
|
||||||
messages=messages_copy, # Pass copied message history
|
messages=messages_copy, # Pass copied message history
|
||||||
max_context_turns=max_context_turns
|
max_context_turns=max_context_turns,
|
||||||
|
cancel_event=cancel_event,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Execute
|
# Execute
|
||||||
@@ -507,11 +463,15 @@ class Agent:
|
|||||||
logger.info("[Agent] Cleared Agent message history after executor recovery")
|
logger.info("[Agent] Cleared Agent message history after executor recovery")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Append only the NEW messages from this execution (thread-safe)
|
# Sync executor's messages back to agent (thread-safe).
|
||||||
# This allows concurrent requests to both contribute to history
|
# If the executor trimmed context, its message list is shorter than
|
||||||
|
# original_length, so we must replace rather than append.
|
||||||
with self.messages_lock:
|
with self.messages_lock:
|
||||||
new_messages = executor.messages[original_length:]
|
self.messages = list(executor.messages)
|
||||||
self.messages.extend(new_messages)
|
# Track messages added in this run (user query + all assistant/tool messages)
|
||||||
|
# original_length may exceed executor.messages length after trimming
|
||||||
|
trim_adjusted_start = min(original_length, len(executor.messages))
|
||||||
|
self._last_run_new_messages = list(executor.messages[trim_adjusted_start:])
|
||||||
|
|
||||||
# Store executor reference for agent_bridge to access files_to_send
|
# Store executor reference for agent_bridge to access files_to_send
|
||||||
self.stream_executor = executor
|
self.stream_executor = executor
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
121
agent/protocol/cancel.py
Normal file
121
agent/protocol/cancel.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
Cancel token registry for aborting in-flight agent runs.
|
||||||
|
|
||||||
|
A user cancel (web Cancel button, /cancel command) sets a threading.Event
|
||||||
|
that the agent loop polls at safe checkpoints. Tokens are keyed by
|
||||||
|
request_id (preferred) and tracked under session_id as a fallback. Entries
|
||||||
|
are released after the run completes to keep the registry bounded.
|
||||||
|
|
||||||
|
No project deps — importable from any layer without circular imports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCancelledError(Exception):
|
||||||
|
"""Raised inside the agent loop when a stop has been requested.
|
||||||
|
|
||||||
|
The agent stream executor catches this, injects a "[Interrupted]" note
|
||||||
|
into the message history (preserving tool_use/tool_result integrity)
|
||||||
|
and returns a partial response to the caller.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class _CancelEntry:
|
||||||
|
__slots__ = ("event", "session_id")
|
||||||
|
|
||||||
|
def __init__(self, session_id: Optional[str]):
|
||||||
|
self.event = threading.Event()
|
||||||
|
self.session_id = session_id
|
||||||
|
|
||||||
|
|
||||||
|
class CancelTokenRegistry:
|
||||||
|
"""In-process registry mapping request_id -> cancel Event.
|
||||||
|
|
||||||
|
Thread-safe. Singleton via module-level ``_registry``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._by_request: Dict[str, _CancelEntry] = {}
|
||||||
|
# session_id -> set of request_ids currently in flight (usually 1).
|
||||||
|
self._by_session: Dict[str, set] = {}
|
||||||
|
|
||||||
|
def register(self, request_id: str, session_id: Optional[str] = None) -> threading.Event:
|
||||||
|
"""Create (or return existing) cancel event for a request.
|
||||||
|
|
||||||
|
Returns the threading.Event the caller should poll via ``is_set()``.
|
||||||
|
"""
|
||||||
|
if not request_id:
|
||||||
|
return threading.Event()
|
||||||
|
with self._lock:
|
||||||
|
entry = self._by_request.get(request_id)
|
||||||
|
if entry is None:
|
||||||
|
entry = _CancelEntry(session_id)
|
||||||
|
self._by_request[request_id] = entry
|
||||||
|
if session_id:
|
||||||
|
self._by_session.setdefault(session_id, set()).add(request_id)
|
||||||
|
return entry.event
|
||||||
|
|
||||||
|
def get_event(self, request_id: str) -> Optional[threading.Event]:
|
||||||
|
if not request_id:
|
||||||
|
return None
|
||||||
|
with self._lock:
|
||||||
|
entry = self._by_request.get(request_id)
|
||||||
|
return entry.event if entry else None
|
||||||
|
|
||||||
|
def cancel_request(self, request_id: str) -> bool:
|
||||||
|
"""Trigger cancel for a specific request. Returns True when matched."""
|
||||||
|
if not request_id:
|
||||||
|
return False
|
||||||
|
with self._lock:
|
||||||
|
entry = self._by_request.get(request_id)
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
entry.event.set()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def cancel_session(self, session_id: str) -> int:
|
||||||
|
"""Trigger cancel for every in-flight request of a session.
|
||||||
|
|
||||||
|
Returns the number of requests cancelled (0 when nothing was running).
|
||||||
|
"""
|
||||||
|
if not session_id:
|
||||||
|
return 0
|
||||||
|
with self._lock:
|
||||||
|
request_ids = list(self._by_session.get(session_id, ()))
|
||||||
|
entries = [self._by_request[r] for r in request_ids if r in self._by_request]
|
||||||
|
for entry in entries:
|
||||||
|
entry.event.set()
|
||||||
|
return len(entries)
|
||||||
|
|
||||||
|
def unregister(self, request_id: str) -> None:
|
||||||
|
"""Remove an entry once the agent run is done. Safe to call twice."""
|
||||||
|
if not request_id:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
entry = self._by_request.pop(request_id, None)
|
||||||
|
if entry and entry.session_id:
|
||||||
|
bucket = self._by_session.get(entry.session_id)
|
||||||
|
if bucket is not None:
|
||||||
|
bucket.discard(request_id)
|
||||||
|
if not bucket:
|
||||||
|
self._by_session.pop(entry.session_id, None)
|
||||||
|
|
||||||
|
def has_active(self, session_id: str) -> bool:
|
||||||
|
if not session_id:
|
||||||
|
return False
|
||||||
|
with self._lock:
|
||||||
|
bucket = self._by_session.get(session_id)
|
||||||
|
return bool(bucket)
|
||||||
|
|
||||||
|
|
||||||
|
_registry = CancelTokenRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
def get_cancel_registry() -> CancelTokenRegistry:
|
||||||
|
"""Module-level accessor for the singleton registry."""
|
||||||
|
return _registry
|
||||||
335
agent/protocol/message_utils.py
Normal file
335
agent/protocol/message_utils.py
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
"""
|
||||||
|
Message sanitizer — fix broken tool_use / tool_result pairs.
|
||||||
|
|
||||||
|
Provides two public helpers that can be reused across agent_stream.py
|
||||||
|
and any bot that converts messages to OpenAI format:
|
||||||
|
|
||||||
|
1. sanitize_claude_messages(messages)
|
||||||
|
Operates on the internal Claude-format message list (in-place).
|
||||||
|
|
||||||
|
2. drop_orphaned_tool_results_openai(messages)
|
||||||
|
Operates on an already-converted OpenAI-format message list,
|
||||||
|
returning a cleaned copy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, List, Set
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
_SYNTH_TOOL_ERR = (
|
||||||
|
"Error: Missing tool_result adjacent to tool_use (session repair). "
|
||||||
|
"The conversation history was inconsistent; continue from here."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _repair_tool_use_adjacency(messages: List[Dict]) -> int:
|
||||||
|
"""
|
||||||
|
Anthropic requires: after assistant content with tool_use, the next message
|
||||||
|
must be user content listing tool_result for every tool_use id (same user msg).
|
||||||
|
|
||||||
|
Valid histories satisfy this at every such assistant; the loop only mutates
|
||||||
|
when that condition fails (broken persistence, bad trims, etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _synth_block(tid: str) -> Dict:
|
||||||
|
return {
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": tid,
|
||||||
|
"content": _SYNTH_TOOL_ERR,
|
||||||
|
"is_error": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
repairs = 0
|
||||||
|
i = 0
|
||||||
|
while i < len(messages):
|
||||||
|
msg = messages[i]
|
||||||
|
if msg.get("role") != "assistant":
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
content = msg.get("content", [])
|
||||||
|
if not isinstance(content, list):
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
required = [
|
||||||
|
b.get("id")
|
||||||
|
for b in content
|
||||||
|
if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id")
|
||||||
|
]
|
||||||
|
if not required:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
req_set = set(required)
|
||||||
|
if i + 1 >= len(messages):
|
||||||
|
messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": [_synth_block(tid) for tid in required],
|
||||||
|
})
|
||||||
|
logger.warning(
|
||||||
|
"⚠️ Appended synthetic tool_result after trailing assistant tool_use"
|
||||||
|
)
|
||||||
|
repairs += 1
|
||||||
|
break
|
||||||
|
|
||||||
|
nxt = messages[i + 1]
|
||||||
|
if nxt.get("role") != "user":
|
||||||
|
messages.insert(
|
||||||
|
i + 1,
|
||||||
|
{"role": "user", "content": [_synth_block(tid) for tid in required]},
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"⚠️ Inserted synthetic tool_result user after tool_use "
|
||||||
|
f"(next role={nxt.get('role')!r})"
|
||||||
|
)
|
||||||
|
repairs += 1
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
|
||||||
|
nc = nxt.get("content", [])
|
||||||
|
if not isinstance(nc, list):
|
||||||
|
messages.insert(
|
||||||
|
i + 1,
|
||||||
|
{"role": "user", "content": [_synth_block(tid) for tid in required]},
|
||||||
|
)
|
||||||
|
repairs += 1
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
|
||||||
|
present = {
|
||||||
|
b.get("tool_use_id")
|
||||||
|
for b in nc
|
||||||
|
if isinstance(b, dict) and b.get("type") == "tool_result" and b.get("tool_use_id")
|
||||||
|
}
|
||||||
|
if req_set <= present:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
missing = [tid for tid in required if tid not in present]
|
||||||
|
nxt["content"] = [_synth_block(tid) for tid in missing] + nc
|
||||||
|
logger.warning(
|
||||||
|
"⚠️ Prepended synthetic tool_result for Anthropic adjacency "
|
||||||
|
f"(missing_ids={missing})"
|
||||||
|
)
|
||||||
|
repairs += len(missing)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return repairs
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Claude-format sanitizer (used by agent_stream)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def sanitize_claude_messages(messages: List[Dict]) -> int:
|
||||||
|
"""
|
||||||
|
Validate and fix a Claude-format message list **in-place**.
|
||||||
|
|
||||||
|
Fixes handled:
|
||||||
|
- Anthropic adjacency: assistant tool_use must be immediately followed by
|
||||||
|
user message(s) containing matching tool_result blocks
|
||||||
|
- Leading orphaned tool_result user messages
|
||||||
|
- Mid-list tool_result blocks whose tool_use_id has no matching
|
||||||
|
tool_use in any preceding assistant message
|
||||||
|
|
||||||
|
Returns: number of removals plus adjacency repair operations (inserts/prepends).
|
||||||
|
"""
|
||||||
|
if not messages:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
removed = 0
|
||||||
|
|
||||||
|
# 1. Adjacency repair (Anthropic: tool_result must be in the next user message)
|
||||||
|
adj_repairs = _repair_tool_use_adjacency(messages)
|
||||||
|
|
||||||
|
# 2. Remove leading orphaned tool_result user messages
|
||||||
|
while messages:
|
||||||
|
first = messages[0]
|
||||||
|
if first.get("role") != "user":
|
||||||
|
break
|
||||||
|
content = first.get("content", [])
|
||||||
|
if isinstance(content, list) and _has_block_type(content, "tool_result") \
|
||||||
|
and not _has_block_type(content, "text"):
|
||||||
|
logger.warning("⚠️ Removing leading orphaned tool_result user message")
|
||||||
|
messages.pop(0)
|
||||||
|
removed += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 3. Iteratively remove unmatched tool_use / tool_result until stable.
|
||||||
|
# Removing one broken message can orphan others (e.g. an assistant msg
|
||||||
|
# with both matched and unmatched tool_use — deleting it orphans the
|
||||||
|
# previously-matched tool_result). Loop until clean.
|
||||||
|
for _ in range(5):
|
||||||
|
use_ids: Set[str] = set()
|
||||||
|
result_ids: Set[str] = set()
|
||||||
|
for msg in messages:
|
||||||
|
for block in (msg.get("content") or []):
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
if block.get("type") == "tool_use" and block.get("id"):
|
||||||
|
use_ids.add(block["id"])
|
||||||
|
elif block.get("type") == "tool_result" and block.get("tool_use_id"):
|
||||||
|
result_ids.add(block["tool_use_id"])
|
||||||
|
|
||||||
|
bad_use = use_ids - result_ids
|
||||||
|
bad_result = result_ids - use_ids
|
||||||
|
if not bad_use and not bad_result:
|
||||||
|
break
|
||||||
|
|
||||||
|
pass_removed = 0
|
||||||
|
i = 0
|
||||||
|
while i < len(messages):
|
||||||
|
msg = messages[i]
|
||||||
|
role = msg.get("role")
|
||||||
|
content = msg.get("content", [])
|
||||||
|
if not isinstance(content, list):
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == "assistant" and bad_use and any(
|
||||||
|
isinstance(b, dict) and b.get("type") == "tool_use"
|
||||||
|
and b.get("id") in bad_use for b in content
|
||||||
|
):
|
||||||
|
logger.warning(f"⚠️ Removing assistant msg with unmatched tool_use")
|
||||||
|
messages.pop(i)
|
||||||
|
pass_removed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == "user" and bad_result and _has_block_type(content, "tool_result"):
|
||||||
|
has_bad = any(
|
||||||
|
isinstance(b, dict) and b.get("type") == "tool_result"
|
||||||
|
and b.get("tool_use_id") in bad_result for b in content
|
||||||
|
)
|
||||||
|
if has_bad:
|
||||||
|
if not _has_block_type(content, "text"):
|
||||||
|
logger.warning(f"⚠️ Removing user msg with unmatched tool_result")
|
||||||
|
messages.pop(i)
|
||||||
|
pass_removed += 1
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
before = len(content)
|
||||||
|
msg["content"] = [
|
||||||
|
b for b in content
|
||||||
|
if not (isinstance(b, dict) and b.get("type") == "tool_result"
|
||||||
|
and b.get("tool_use_id") in bad_result)
|
||||||
|
]
|
||||||
|
pass_removed += before - len(msg["content"])
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
removed += pass_removed
|
||||||
|
if pass_removed == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 4. Removals above can break adjacency; re-run repair only if something was removed.
|
||||||
|
if removed:
|
||||||
|
adj_repairs += _repair_tool_use_adjacency(messages)
|
||||||
|
|
||||||
|
if removed:
|
||||||
|
logger.info(f"🔧 Message validation: removed {removed} broken message(s)")
|
||||||
|
if adj_repairs:
|
||||||
|
logger.info(f"🔧 Message validation: adjacency repairs={adj_repairs}")
|
||||||
|
return removed + adj_repairs
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# OpenAI-format sanitizer (used by minimax_bot, openai_compatible_bot)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def drop_orphaned_tool_results_openai(messages: List[Dict]) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Return a copy of *messages* (OpenAI format) with any ``role=tool``
|
||||||
|
messages removed if their ``tool_call_id`` does not match a
|
||||||
|
``tool_calls[].id`` in a preceding assistant message.
|
||||||
|
"""
|
||||||
|
known_ids: Set[str] = set()
|
||||||
|
cleaned: List[Dict] = []
|
||||||
|
for msg in messages:
|
||||||
|
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
||||||
|
for tc in msg["tool_calls"]:
|
||||||
|
tc_id = tc.get("id", "")
|
||||||
|
if tc_id:
|
||||||
|
known_ids.add(tc_id)
|
||||||
|
|
||||||
|
if msg.get("role") == "tool":
|
||||||
|
ref_id = msg.get("tool_call_id", "")
|
||||||
|
if ref_id and ref_id not in known_ids:
|
||||||
|
logger.warning(
|
||||||
|
f"[MessageSanitizer] Dropping orphaned tool result "
|
||||||
|
f"(tool_call_id={ref_id} not in known ids)"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
cleaned.append(msg)
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _has_block_type(content: list, block_type: str) -> bool:
|
||||||
|
return any(
|
||||||
|
isinstance(b, dict) and b.get("type") == block_type
|
||||||
|
for b in content
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_text_from_content(content) -> str:
|
||||||
|
"""Extract plain text from a message content field (str or list of blocks)."""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content.strip()
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts = [
|
||||||
|
b.get("text", "")
|
||||||
|
for b in content
|
||||||
|
if isinstance(b, dict) and b.get("type") == "text"
|
||||||
|
]
|
||||||
|
return "\n".join(p for p in parts if p).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def compress_turn_to_text_only(turn: Dict) -> Dict:
|
||||||
|
"""
|
||||||
|
Compress a full turn (with tool_use/tool_result chains) into a lightweight
|
||||||
|
text-only turn that keeps only the first user text and the last assistant text.
|
||||||
|
|
||||||
|
This preserves the conversational context (what the user asked and what the
|
||||||
|
agent concluded) while stripping out the bulky intermediate tool interactions.
|
||||||
|
|
||||||
|
Returns a new turn dict with a ``messages`` list; the original is not mutated.
|
||||||
|
"""
|
||||||
|
user_text = ""
|
||||||
|
last_assistant_text = ""
|
||||||
|
|
||||||
|
for msg in turn["messages"]:
|
||||||
|
role = msg.get("role")
|
||||||
|
content = msg.get("content", [])
|
||||||
|
|
||||||
|
if role == "user":
|
||||||
|
if isinstance(content, list) and _has_block_type(content, "tool_result"):
|
||||||
|
continue
|
||||||
|
if not user_text:
|
||||||
|
user_text = _extract_text_from_content(content)
|
||||||
|
|
||||||
|
elif role == "assistant":
|
||||||
|
text = _extract_text_from_content(content)
|
||||||
|
if text:
|
||||||
|
last_assistant_text = text
|
||||||
|
|
||||||
|
compressed_messages = []
|
||||||
|
if user_text:
|
||||||
|
compressed_messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": user_text}]
|
||||||
|
})
|
||||||
|
if last_assistant_text:
|
||||||
|
compressed_messages.append({
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{"type": "text", "text": last_assistant_text}]
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"messages": compressed_messages}
|
||||||
@@ -123,17 +123,63 @@ def should_include_skill(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Check environment variables (API keys)
|
# Check environment variables (API keys)
|
||||||
# Simple rule: All required env vars must be set
|
# All required env vars must be set
|
||||||
required_env = metadata.requires.get('env', [])
|
required_env = metadata.requires.get('env', [])
|
||||||
if required_env:
|
if required_env:
|
||||||
for env_name in required_env:
|
for env_name in required_env:
|
||||||
if not has_env_var(env_name):
|
if not has_env_var(env_name):
|
||||||
# Missing required API key → disable skill
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Check anyEnv (at least one must be present)
|
||||||
|
any_env = metadata.requires.get('anyEnv', [])
|
||||||
|
if any_env:
|
||||||
|
if not any(has_env_var(e) for e in any_env):
|
||||||
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_missing_requirements(
|
||||||
|
entry: SkillEntry,
|
||||||
|
current_platform: Optional[str] = None,
|
||||||
|
) -> Dict[str, List[str]]:
|
||||||
|
"""
|
||||||
|
Return a dict of missing requirements for a skill.
|
||||||
|
Empty dict means all requirements are met.
|
||||||
|
|
||||||
|
:param entry: SkillEntry to check
|
||||||
|
:param current_platform: Current platform (default: auto-detect)
|
||||||
|
:return: Dict like {"bins": ["curl"], "env": ["API_KEY"]}
|
||||||
|
"""
|
||||||
|
missing: Dict[str, List[str]] = {}
|
||||||
|
metadata = entry.metadata
|
||||||
|
|
||||||
|
if not metadata or not metadata.requires:
|
||||||
|
return missing
|
||||||
|
|
||||||
|
required_bins = metadata.requires.get('bins', [])
|
||||||
|
if required_bins:
|
||||||
|
missing_bins = [b for b in required_bins if not has_binary(b)]
|
||||||
|
if missing_bins:
|
||||||
|
missing['bins'] = missing_bins
|
||||||
|
|
||||||
|
any_bins = metadata.requires.get('anyBins', [])
|
||||||
|
if any_bins and not has_any_binary(any_bins):
|
||||||
|
missing['anyBins'] = any_bins
|
||||||
|
|
||||||
|
required_env = metadata.requires.get('env', [])
|
||||||
|
if required_env:
|
||||||
|
missing_env = [e for e in required_env if not has_env_var(e)]
|
||||||
|
if missing_env:
|
||||||
|
missing['env'] = missing_env
|
||||||
|
|
||||||
|
any_env = metadata.requires.get('anyEnv', [])
|
||||||
|
if any_env and not any(has_env_var(e) for e in any_env):
|
||||||
|
missing['anyEnv'] = any_env
|
||||||
|
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
def is_config_path_truthy(config: Dict, path: str) -> bool:
|
def is_config_path_truthy(config: Dict, path: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a config path resolves to a truthy value.
|
Check if a config path resolves to a truthy value.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Skill formatter for generating prompts from skills.
|
Skill formatter for generating prompts from skills.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import List
|
from typing import Dict, List
|
||||||
from agent.skills.types import Skill, SkillEntry
|
from agent.skills.types import Skill, SkillEntry
|
||||||
|
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ def format_skills_for_prompt(skills: List[Skill]) -> str:
|
|||||||
lines.append(f" <name>{_escape_xml(skill.name)}</name>")
|
lines.append(f" <name>{_escape_xml(skill.name)}</name>")
|
||||||
lines.append(f" <description>{_escape_xml(skill.description)}</description>")
|
lines.append(f" <description>{_escape_xml(skill.description)}</description>")
|
||||||
lines.append(f" <location>{_escape_xml(skill.file_path)}</location>")
|
lines.append(f" <location>{_escape_xml(skill.file_path)}</location>")
|
||||||
|
lines.append(f" <base_dir>{_escape_xml(skill.base_dir)}</base_dir>")
|
||||||
lines.append(" </skill>")
|
lines.append(" </skill>")
|
||||||
|
|
||||||
lines.append("</available_skills>")
|
lines.append("</available_skills>")
|
||||||
@@ -50,6 +51,71 @@ def format_skill_entries_for_prompt(entries: List[SkillEntry]) -> str:
|
|||||||
return format_skills_for_prompt(skills)
|
return format_skills_for_prompt(skills)
|
||||||
|
|
||||||
|
|
||||||
|
def format_unavailable_skills_for_prompt(
|
||||||
|
entries: List[SkillEntry],
|
||||||
|
missing_map: Dict[str, Dict[str, List[str]]],
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Format unavailable (requires-not-met) skills as brief setup hints
|
||||||
|
so the AI can guide users to configure them.
|
||||||
|
|
||||||
|
:param entries: List of unavailable skill entries
|
||||||
|
:param missing_map: Dict mapping skill name to its missing requirements
|
||||||
|
:return: Formatted prompt text
|
||||||
|
"""
|
||||||
|
if not entries:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"",
|
||||||
|
"<unavailable_skills>",
|
||||||
|
"The following skills are installed but not yet ready. "
|
||||||
|
"Guide the user to complete the setup when relevant.",
|
||||||
|
]
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
skill = entry.skill
|
||||||
|
missing = missing_map.get(skill.name, {})
|
||||||
|
|
||||||
|
missing_parts = []
|
||||||
|
for key, values in missing.items():
|
||||||
|
missing_parts.append(f"{key}: {', '.join(values)}")
|
||||||
|
missing_str = "; ".join(missing_parts) if missing_parts else "unknown"
|
||||||
|
|
||||||
|
setup_hint = _extract_setup_hint(skill)
|
||||||
|
|
||||||
|
lines.append(" <skill>")
|
||||||
|
lines.append(f" <name>{_escape_xml(skill.name)}</name>")
|
||||||
|
lines.append(f" <description>{_escape_xml(skill.description)}</description>")
|
||||||
|
lines.append(f" <missing>{_escape_xml(missing_str)}</missing>")
|
||||||
|
if setup_hint:
|
||||||
|
lines.append(f" <setup>{_escape_xml(setup_hint)}</setup>")
|
||||||
|
lines.append(" </skill>")
|
||||||
|
|
||||||
|
lines.append("</unavailable_skills>")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_setup_hint(skill: Skill) -> str:
|
||||||
|
"""
|
||||||
|
Extract the Setup section from SKILL.md content as a brief hint.
|
||||||
|
Returns the first few lines of the ## Setup section.
|
||||||
|
"""
|
||||||
|
content = skill.content
|
||||||
|
if not content:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
import re
|
||||||
|
match = re.search(r'^##\s+Setup\s*\n(.*?)(?=\n##\s|\Z)', content, re.MULTILINE | re.DOTALL)
|
||||||
|
if not match:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
setup_text = match.group(1).strip()
|
||||||
|
lines = setup_text.split('\n')
|
||||||
|
hint_lines = [l.strip() for l in lines[:6] if l.strip()]
|
||||||
|
return ' '.join(hint_lines)[:300]
|
||||||
|
|
||||||
|
|
||||||
def _escape_xml(text: str) -> str:
|
def _escape_xml(text: str) -> str:
|
||||||
"""Escape XML special characters."""
|
"""Escape XML special characters."""
|
||||||
return (text
|
return (text
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]:
|
|||||||
if not isinstance(metadata_raw, dict):
|
if not isinstance(metadata_raw, dict):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Use metadata_raw directly (COW format)
|
# Unwrap nested namespace (e.g. {"openclaw": {...}} or {"cowagent": {...}})
|
||||||
meta_obj = metadata_raw
|
meta_obj = _unwrap_metadata_namespace(metadata_raw)
|
||||||
|
|
||||||
# Parse install specs
|
# Parse install specs
|
||||||
install_specs = []
|
install_specs = []
|
||||||
@@ -128,6 +128,7 @@ def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]:
|
|||||||
|
|
||||||
return SkillMetadata(
|
return SkillMetadata(
|
||||||
always=meta_obj.get('always', False),
|
always=meta_obj.get('always', False),
|
||||||
|
default_enabled=meta_obj.get('default_enabled', True),
|
||||||
skill_key=meta_obj.get('skillKey'),
|
skill_key=meta_obj.get('skillKey'),
|
||||||
primary_env=meta_obj.get('primaryEnv'),
|
primary_env=meta_obj.get('primaryEnv'),
|
||||||
emoji=meta_obj.get('emoji'),
|
emoji=meta_obj.get('emoji'),
|
||||||
@@ -138,6 +139,25 @@ def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_KNOWN_METADATA_NAMESPACES = {"cowagent", "openclaw"}
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap_metadata_namespace(metadata_raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Unwrap a single-key namespace wrapper like {"cowagent": {...} or {"openclaw": {...}}}.
|
||||||
|
If the top-level dict has exactly one key matching a known namespace, return the inner dict.
|
||||||
|
Otherwise return the original dict unchanged.
|
||||||
|
"""
|
||||||
|
keys = set(metadata_raw.keys())
|
||||||
|
ns_keys = keys & _KNOWN_METADATA_NAMESPACES
|
||||||
|
if len(ns_keys) == 1 and len(keys) == 1:
|
||||||
|
ns = ns_keys.pop()
|
||||||
|
inner = metadata_raw[ns]
|
||||||
|
if isinstance(inner, dict):
|
||||||
|
return inner
|
||||||
|
return metadata_raw
|
||||||
|
|
||||||
|
|
||||||
def _normalize_string_list(value: Any) -> List[str]:
|
def _normalize_string_list(value: Any) -> List[str]:
|
||||||
"""Normalize a value to a list of strings."""
|
"""Normalize a value to a list of strings."""
|
||||||
if not value:
|
if not value:
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ class SkillLoader:
|
|||||||
"""
|
"""
|
||||||
Recursively load skills from a directory.
|
Recursively load skills from a directory.
|
||||||
|
|
||||||
|
If a subdirectory contains its own SKILL.md, it is treated as a
|
||||||
|
self-contained skill (or skill-collection) and its children are
|
||||||
|
NOT scanned further. This prevents sub-skills inside a collection
|
||||||
|
(e.g. style-collection/style-anjing) from being listed as
|
||||||
|
independent top-level skills.
|
||||||
|
|
||||||
:param dir_path: Directory to scan
|
:param dir_path: Directory to scan
|
||||||
:param source: Source identifier
|
:param source: Source identifier
|
||||||
:param include_root_files: Whether to include root-level .md files
|
:param include_root_files: Whether to include root-level .md files
|
||||||
@@ -66,38 +72,41 @@ class SkillLoader:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
diagnostics.append(f"Failed to list directory {dir_path}: {e}")
|
diagnostics.append(f"Failed to list directory {dir_path}: {e}")
|
||||||
return LoadSkillsResult(skills=skills, diagnostics=diagnostics)
|
return LoadSkillsResult(skills=skills, diagnostics=diagnostics)
|
||||||
|
|
||||||
|
# If this directory has its own SKILL.md, load it and stop recursing.
|
||||||
|
# The sub-directories are internal resources of this skill.
|
||||||
|
if not include_root_files and 'SKILL.md' in entries:
|
||||||
|
skill_md_path = os.path.join(dir_path, 'SKILL.md')
|
||||||
|
if os.path.isfile(skill_md_path):
|
||||||
|
skill_result = self._load_skill_from_file(skill_md_path, source)
|
||||||
|
if skill_result.skills:
|
||||||
|
skills.extend(skill_result.skills)
|
||||||
|
diagnostics.extend(skill_result.diagnostics)
|
||||||
|
return LoadSkillsResult(skills=skills, diagnostics=diagnostics)
|
||||||
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
# Skip hidden files and directories
|
|
||||||
if entry.startswith('.'):
|
if entry.startswith('.'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip common non-skill directories
|
|
||||||
if entry in ('node_modules', '__pycache__', 'venv', '.git'):
|
if entry in ('node_modules', '__pycache__', 'venv', '.git'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
full_path = os.path.join(dir_path, entry)
|
full_path = os.path.join(dir_path, entry)
|
||||||
|
|
||||||
# Handle directories
|
|
||||||
if os.path.isdir(full_path):
|
if os.path.isdir(full_path):
|
||||||
# Recursively scan subdirectories
|
|
||||||
sub_result = self._load_skills_recursive(full_path, source, include_root_files=False)
|
sub_result = self._load_skills_recursive(full_path, source, include_root_files=False)
|
||||||
skills.extend(sub_result.skills)
|
skills.extend(sub_result.skills)
|
||||||
diagnostics.extend(sub_result.diagnostics)
|
diagnostics.extend(sub_result.diagnostics)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Handle files
|
|
||||||
if not os.path.isfile(full_path):
|
if not os.path.isfile(full_path):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if this is a skill file
|
is_root_md = include_root_files and entry.endswith('.md') and entry.upper() != 'README.MD'
|
||||||
is_root_md = include_root_files and entry.endswith('.md')
|
|
||||||
is_skill_md = not include_root_files and entry == 'SKILL.md'
|
|
||||||
|
|
||||||
if not (is_root_md or is_skill_md):
|
if not is_root_md:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Load the skill
|
|
||||||
skill_result = self._load_skill_from_file(full_path, source)
|
skill_result = self._load_skill_from_file(full_path, source)
|
||||||
if skill_result.skills:
|
if skill_result.skills:
|
||||||
skills.extend(skill_result.skills)
|
skills.extend(skill_result.skills)
|
||||||
@@ -184,7 +193,6 @@ class SkillLoader:
|
|||||||
|
|
||||||
config_path = os.path.join(skill_dir, "config.json")
|
config_path = os.path.join(skill_dir, "config.json")
|
||||||
|
|
||||||
# Without config.json, skip this skill entirely (return empty to trigger exclusion)
|
|
||||||
if not os.path.exists(config_path):
|
if not os.path.exists(config_path):
|
||||||
logger.debug(f"[SkillLoader] linkai-agent skipped: no config.json found")
|
logger.debug(f"[SkillLoader] linkai-agent skipped: no config.json found")
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -84,10 +84,10 @@ class SkillManager:
|
|||||||
"""
|
"""
|
||||||
Merge directory-scanned skills with the persisted config file.
|
Merge directory-scanned skills with the persisted config file.
|
||||||
|
|
||||||
- New skills discovered on disk are added with enabled=True.
|
- New skills: use metadata.default_enabled as initial enabled state.
|
||||||
|
- Existing skills: preserve their persisted enabled state.
|
||||||
- Skills that no longer exist on disk are removed.
|
- Skills that no longer exist on disk are removed.
|
||||||
- Existing entries preserve their enabled state; name/description/source
|
- name/description/source are always refreshed from the latest scan.
|
||||||
are refreshed from the latest scan.
|
|
||||||
"""
|
"""
|
||||||
saved = self._load_skills_config()
|
saved = self._load_skills_config()
|
||||||
merged: Dict[str, dict] = {}
|
merged: Dict[str, dict] = {}
|
||||||
@@ -95,12 +95,24 @@ class SkillManager:
|
|||||||
for name, entry in self.skills.items():
|
for name, entry in self.skills.items():
|
||||||
skill = entry.skill
|
skill = entry.skill
|
||||||
prev = saved.get(name, {})
|
prev = saved.get(name, {})
|
||||||
merged[name] = {
|
category = prev.get("category", "skill")
|
||||||
|
|
||||||
|
if name in saved:
|
||||||
|
enabled = prev.get("enabled", True)
|
||||||
|
else:
|
||||||
|
enabled = entry.metadata.default_enabled if entry.metadata else True
|
||||||
|
|
||||||
|
entry_dict = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"description": skill.description,
|
"description": skill.description,
|
||||||
"source": skill.source,
|
"source": prev.get("source") or skill.source,
|
||||||
"enabled": prev.get("enabled", True),
|
"enabled": enabled,
|
||||||
|
"category": category,
|
||||||
}
|
}
|
||||||
|
display_name = prev.get("display_name")
|
||||||
|
if display_name:
|
||||||
|
entry_dict["display_name"] = display_name
|
||||||
|
merged[name] = entry_dict
|
||||||
|
|
||||||
self.skills_config = merged
|
self.skills_config = merged
|
||||||
self._save_skills_config()
|
self._save_skills_config()
|
||||||
@@ -154,69 +166,118 @@ class SkillManager:
|
|||||||
"""
|
"""
|
||||||
return list(self.skills.values())
|
return list(self.skills.values())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_skill_filter(skill_filter: Optional[List[str]]) -> Optional[List[str]]:
|
||||||
|
"""Normalize a skill_filter list into a flat list of stripped names."""
|
||||||
|
if skill_filter is None:
|
||||||
|
return None
|
||||||
|
normalized = []
|
||||||
|
for item in skill_filter:
|
||||||
|
if isinstance(item, str):
|
||||||
|
name = item.strip()
|
||||||
|
if name:
|
||||||
|
normalized.append(name)
|
||||||
|
elif isinstance(item, list):
|
||||||
|
for subitem in item:
|
||||||
|
if isinstance(subitem, str):
|
||||||
|
name = subitem.strip()
|
||||||
|
if name:
|
||||||
|
normalized.append(name)
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
def filter_skills(
|
def filter_skills(
|
||||||
self,
|
self,
|
||||||
skill_filter: Optional[List[str]] = None,
|
skill_filter: Optional[List[str]] = None,
|
||||||
include_disabled: bool = False,
|
include_disabled: bool = False,
|
||||||
) -> List[SkillEntry]:
|
) -> List[SkillEntry]:
|
||||||
"""
|
"""
|
||||||
Filter skills based on criteria.
|
Filter skills that are eligible (enabled + requirements met).
|
||||||
|
|
||||||
Simple rule: Skills are auto-enabled if requirements are met.
|
|
||||||
- Has required API keys -> included
|
|
||||||
- Missing API keys -> excluded
|
|
||||||
|
|
||||||
:param skill_filter: List of skill names to include (None = all)
|
:param skill_filter: List of skill names to include (None = all)
|
||||||
:param include_disabled: Whether to include disabled skills
|
:param include_disabled: Whether to include disabled skills
|
||||||
:return: Filtered list of skill entries
|
:return: Filtered list of eligible skill entries
|
||||||
"""
|
"""
|
||||||
from agent.skills.config import should_include_skill
|
from agent.skills.config import should_include_skill
|
||||||
|
|
||||||
entries = list(self.skills.values())
|
entries = list(self.skills.values())
|
||||||
|
|
||||||
# Check requirements (platform, binaries, env vars)
|
|
||||||
entries = [e for e in entries if should_include_skill(e, self.config)]
|
entries = [e for e in entries if should_include_skill(e, self.config)]
|
||||||
|
|
||||||
# Apply skill filter
|
normalized = self._normalize_skill_filter(skill_filter)
|
||||||
if skill_filter is not None:
|
if normalized is not None:
|
||||||
normalized = []
|
entries = [e for e in entries if e.skill.name in normalized]
|
||||||
for item in skill_filter:
|
|
||||||
if isinstance(item, str):
|
|
||||||
name = item.strip()
|
|
||||||
if name:
|
|
||||||
normalized.append(name)
|
|
||||||
elif isinstance(item, list):
|
|
||||||
for subitem in item:
|
|
||||||
if isinstance(subitem, str):
|
|
||||||
name = subitem.strip()
|
|
||||||
if name:
|
|
||||||
normalized.append(name)
|
|
||||||
if normalized:
|
|
||||||
entries = [e for e in entries if e.skill.name in normalized]
|
|
||||||
|
|
||||||
# Filter out disabled skills based on skills_config.json
|
|
||||||
if not include_disabled:
|
if not include_disabled:
|
||||||
entries = [e for e in entries if self.is_skill_enabled(e.skill.name)]
|
entries = [e for e in entries if self.is_skill_enabled(e.skill.name)]
|
||||||
|
|
||||||
|
from config import conf
|
||||||
|
if not conf().get("knowledge", True):
|
||||||
|
entries = [e for e in entries if e.skill.name != "knowledge-wiki"]
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
def filter_unavailable_skills(
|
||||||
|
self,
|
||||||
|
skill_filter: Optional[List[str]] = None,
|
||||||
|
) -> tuple:
|
||||||
|
"""
|
||||||
|
Find skills that are enabled but have unmet requirements.
|
||||||
|
|
||||||
|
:param skill_filter: Optional list of skill names to include
|
||||||
|
:return: Tuple of (entries, missing_map) where missing_map maps
|
||||||
|
skill name to its missing requirements dict
|
||||||
|
"""
|
||||||
|
from agent.skills.config import should_include_skill, get_missing_requirements
|
||||||
|
|
||||||
|
entries = list(self.skills.values())
|
||||||
|
|
||||||
|
# Only enabled skills
|
||||||
|
entries = [e for e in entries if self.is_skill_enabled(e.skill.name)]
|
||||||
|
|
||||||
|
normalized = self._normalize_skill_filter(skill_filter)
|
||||||
|
if normalized is not None:
|
||||||
|
entries = [e for e in entries if e.skill.name in normalized]
|
||||||
|
|
||||||
|
# Keep only those that fail should_include_skill (requirements not met)
|
||||||
|
unavailable = []
|
||||||
|
missing_map: Dict[str, dict] = {}
|
||||||
|
for e in entries:
|
||||||
|
if not should_include_skill(e, self.config):
|
||||||
|
missing = get_missing_requirements(e)
|
||||||
|
if missing:
|
||||||
|
unavailable.append(e)
|
||||||
|
missing_map[e.skill.name] = missing
|
||||||
|
|
||||||
|
return unavailable, missing_map
|
||||||
|
|
||||||
def build_skills_prompt(
|
def build_skills_prompt(
|
||||||
self,
|
self,
|
||||||
skill_filter: Optional[List[str]] = None,
|
skill_filter: Optional[List[str]] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Build a formatted prompt containing available skills.
|
Build a formatted prompt containing available skills
|
||||||
|
and brief hints for unavailable ones.
|
||||||
|
|
||||||
:param skill_filter: Optional list of skill names to include
|
:param skill_filter: Optional list of skill names to include
|
||||||
:return: Formatted skills prompt
|
:return: Formatted skills prompt
|
||||||
"""
|
"""
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
entries = self.filter_skills(skill_filter=skill_filter, include_disabled=False)
|
from agent.skills.formatter import format_unavailable_skills_for_prompt
|
||||||
logger.debug(f"[SkillManager] Filtered {len(entries)} skills for prompt (total: {len(self.skills)})")
|
|
||||||
if entries:
|
eligible = self.filter_skills(skill_filter=skill_filter, include_disabled=False)
|
||||||
skill_names = [e.skill.name for e in entries]
|
logger.debug(f"[SkillManager] Eligible: {len(eligible)} skills (total: {len(self.skills)})")
|
||||||
logger.debug(f"[SkillManager] Skills to include: {skill_names}")
|
if eligible:
|
||||||
result = format_skill_entries_for_prompt(entries)
|
skill_names = [e.skill.name for e in eligible]
|
||||||
|
logger.debug(f"[SkillManager] Eligible skills: {skill_names}")
|
||||||
|
|
||||||
|
result = format_skill_entries_for_prompt(eligible)
|
||||||
|
|
||||||
|
unavailable, missing_map = self.filter_unavailable_skills(skill_filter=skill_filter)
|
||||||
|
if unavailable:
|
||||||
|
unavailable_names = [e.skill.name for e in unavailable]
|
||||||
|
logger.debug(f"[SkillManager] Unavailable skills (setup needed): {unavailable_names}")
|
||||||
|
result += format_unavailable_skills_for_prompt(unavailable, missing_map)
|
||||||
|
|
||||||
logger.debug(f"[SkillManager] Generated prompt length: {len(result)}")
|
logger.debug(f"[SkillManager] Generated prompt length: {len(result)}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ other management entry point.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
import tempfile
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
from agent.skills.types import Skill, SkillEntry
|
from agent.skills.types import Skill, SkillEntry
|
||||||
@@ -32,6 +34,27 @@ class SkillService:
|
|||||||
"""
|
"""
|
||||||
self.manager = skill_manager
|
self.manager = skill_manager
|
||||||
|
|
||||||
|
def _safe_skill_dir(self, name: str) -> str:
|
||||||
|
"""Derive and validate the skill directory path.
|
||||||
|
|
||||||
|
Ensures the resolved path stays within the custom_dir root,
|
||||||
|
preventing path traversal via names like ``../escaped``.
|
||||||
|
|
||||||
|
:raises ValueError: if the name would escape the skills root.
|
||||||
|
"""
|
||||||
|
if not name or not name.strip():
|
||||||
|
raise ValueError("skill name is required")
|
||||||
|
# Reject obvious traversal components.
|
||||||
|
if ".." in name or name.startswith("/") or name.startswith("\\"):
|
||||||
|
raise ValueError(f"invalid skill name (path traversal detected): {name!r}")
|
||||||
|
skill_dir = os.path.realpath(os.path.join(self.manager.custom_dir, name))
|
||||||
|
root = os.path.realpath(self.manager.custom_dir)
|
||||||
|
if not skill_dir.startswith(root + os.sep) and skill_dir != root:
|
||||||
|
raise ValueError(
|
||||||
|
f"skill name {name!r} resolves outside the skills directory"
|
||||||
|
)
|
||||||
|
return skill_dir
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# query
|
# query
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -55,7 +78,9 @@ class SkillService:
|
|||||||
"""
|
"""
|
||||||
Add (install) a skill from a remote payload.
|
Add (install) a skill from a remote payload.
|
||||||
|
|
||||||
The payload follows the socket protocol::
|
Supported payload types:
|
||||||
|
|
||||||
|
1. ``type: "url"`` – download individual files::
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "web_search",
|
"name": "web_search",
|
||||||
@@ -67,8 +92,15 @@ class SkillService:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
Files are downloaded and saved under the custom skills directory
|
2. ``type: "package"`` – download a zip archive and extract::
|
||||||
using *name* as the sub-directory.
|
|
||||||
|
{
|
||||||
|
"name": "plugin-custom-tool",
|
||||||
|
"type": "package",
|
||||||
|
"category": "skills",
|
||||||
|
"enabled": true,
|
||||||
|
"files": [{"url": "https://cdn.example.com/skills/custom-tool.zip"}]
|
||||||
|
}
|
||||||
|
|
||||||
:param payload: skill add payload from server
|
:param payload: skill add payload from server
|
||||||
"""
|
"""
|
||||||
@@ -76,25 +108,95 @@ class SkillService:
|
|||||||
if not name:
|
if not name:
|
||||||
raise ValueError("skill name is required")
|
raise ValueError("skill name is required")
|
||||||
|
|
||||||
|
payload_type = payload.get("type", "url")
|
||||||
|
|
||||||
|
if payload_type == "package":
|
||||||
|
self._add_package(name, payload)
|
||||||
|
else:
|
||||||
|
self._add_url(name, payload)
|
||||||
|
|
||||||
|
self.manager.refresh_skills()
|
||||||
|
|
||||||
|
category = payload.get("category")
|
||||||
|
if category and name in self.manager.skills_config:
|
||||||
|
self.manager.skills_config[name]["category"] = category
|
||||||
|
self.manager._save_skills_config()
|
||||||
|
|
||||||
|
def _add_url(self, name: str, payload: dict) -> None:
|
||||||
|
"""Install a skill by downloading individual files."""
|
||||||
files = payload.get("files", [])
|
files = payload.get("files", [])
|
||||||
if not files:
|
if not files:
|
||||||
raise ValueError("skill files list is empty")
|
raise ValueError("skill files list is empty")
|
||||||
|
|
||||||
skill_dir = os.path.join(self.manager.custom_dir, name)
|
skill_dir = self._safe_skill_dir(name)
|
||||||
os.makedirs(skill_dir, exist_ok=True)
|
|
||||||
|
|
||||||
for file_info in files:
|
tmp_dir = skill_dir + ".tmp"
|
||||||
url = file_info.get("url")
|
if os.path.exists(tmp_dir):
|
||||||
rel_path = file_info.get("path")
|
shutil.rmtree(tmp_dir)
|
||||||
if not url or not rel_path:
|
os.makedirs(tmp_dir, exist_ok=True)
|
||||||
logger.warning(f"[SkillService] add: skip invalid file entry {file_info}")
|
|
||||||
continue
|
|
||||||
dest = os.path.join(skill_dir, rel_path)
|
|
||||||
self._download_file(url, dest)
|
|
||||||
|
|
||||||
# Reload to pick up the new skill and sync config
|
try:
|
||||||
self.manager.refresh_skills()
|
for file_info in files:
|
||||||
logger.info(f"[SkillService] add: skill '{name}' installed ({len(files)} files)")
|
url = file_info.get("url")
|
||||||
|
rel_path = file_info.get("path")
|
||||||
|
if not url or not rel_path:
|
||||||
|
logger.warning(f"[SkillService] add: skip invalid file entry {file_info}")
|
||||||
|
continue
|
||||||
|
dest = os.path.join(tmp_dir, rel_path)
|
||||||
|
self._download_file(url, dest)
|
||||||
|
except Exception:
|
||||||
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
if os.path.exists(skill_dir):
|
||||||
|
shutil.rmtree(skill_dir)
|
||||||
|
os.rename(tmp_dir, skill_dir)
|
||||||
|
|
||||||
|
logger.info(f"[SkillService] add: skill '{name}' installed via url ({len(files)} files)")
|
||||||
|
|
||||||
|
def _add_package(self, name: str, payload: dict) -> None:
|
||||||
|
"""
|
||||||
|
Install a skill by downloading a zip archive and extracting it.
|
||||||
|
|
||||||
|
If the archive contains a single top-level directory, that directory
|
||||||
|
is used as the skill folder directly; otherwise a new directory named
|
||||||
|
after the skill is created to hold the extracted contents.
|
||||||
|
"""
|
||||||
|
files = payload.get("files", [])
|
||||||
|
if not files or not files[0].get("url"):
|
||||||
|
raise ValueError("package url is required")
|
||||||
|
|
||||||
|
url = files[0]["url"]
|
||||||
|
skill_dir = self._safe_skill_dir(name)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
zip_path = os.path.join(tmp_dir, "package.zip")
|
||||||
|
self._download_file(url, zip_path)
|
||||||
|
|
||||||
|
if not zipfile.is_zipfile(zip_path):
|
||||||
|
raise ValueError(f"downloaded file is not a valid zip archive: {url}")
|
||||||
|
|
||||||
|
extract_dir = os.path.join(tmp_dir, "extracted")
|
||||||
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||||
|
zf.extractall(extract_dir)
|
||||||
|
|
||||||
|
# Determine the actual content root.
|
||||||
|
# If the zip has a single top-level directory, use its contents
|
||||||
|
# so the skill folder is clean (no extra nesting).
|
||||||
|
top_items = [
|
||||||
|
item for item in os.listdir(extract_dir)
|
||||||
|
if not item.startswith(".")
|
||||||
|
]
|
||||||
|
if len(top_items) == 1:
|
||||||
|
single = os.path.join(extract_dir, top_items[0])
|
||||||
|
if os.path.isdir(single):
|
||||||
|
extract_dir = single
|
||||||
|
|
||||||
|
if os.path.exists(skill_dir):
|
||||||
|
shutil.rmtree(skill_dir)
|
||||||
|
shutil.copytree(extract_dir, skill_dir)
|
||||||
|
|
||||||
|
logger.info(f"[SkillService] add: skill '{name}' installed via package ({url})")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# open / close (enable / disable)
|
# open / close (enable / disable)
|
||||||
@@ -136,7 +238,7 @@ class SkillService:
|
|||||||
if not name:
|
if not name:
|
||||||
raise ValueError("skill name is required")
|
raise ValueError("skill name is required")
|
||||||
|
|
||||||
skill_dir = os.path.join(self.manager.custom_dir, name)
|
skill_dir = self._safe_skill_dir(name)
|
||||||
if os.path.exists(skill_dir):
|
if os.path.exists(skill_dir):
|
||||||
shutil.rmtree(skill_dir)
|
shutil.rmtree(skill_dir)
|
||||||
logger.info(f"[SkillService] delete: removed directory {skill_dir}")
|
logger.info(f"[SkillService] delete: removed directory {skill_dir}")
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class SkillInstallSpec:
|
|||||||
class SkillMetadata:
|
class SkillMetadata:
|
||||||
"""Metadata for a skill from frontmatter."""
|
"""Metadata for a skill from frontmatter."""
|
||||||
always: bool = False # Always include this skill
|
always: bool = False # Always include this skill
|
||||||
|
default_enabled: bool = True # Initial enabled state when first discovered
|
||||||
skill_key: Optional[str] = None # Override skill key
|
skill_key: Optional[str] = None # Override skill key
|
||||||
primary_env: Optional[str] = None # Primary environment variable
|
primary_env: Optional[str] = None # Primary environment variable
|
||||||
emoji: Optional[str] = None
|
emoji: Optional[str] = None
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ from agent.tools.send.send import Send
|
|||||||
from agent.tools.memory.memory_search import MemorySearchTool
|
from agent.tools.memory.memory_search import MemorySearchTool
|
||||||
from agent.tools.memory.memory_get import MemoryGetTool
|
from agent.tools.memory.memory_get import MemoryGetTool
|
||||||
|
|
||||||
|
# Import self-evolution tools
|
||||||
|
from agent.tools.evolution_undo.evolution_undo import EvolutionUndoTool
|
||||||
|
|
||||||
# Import tools with optional dependencies
|
# Import tools with optional dependencies
|
||||||
def _import_optional_tools():
|
def _import_optional_tools():
|
||||||
"""Import tools that have optional dependencies"""
|
"""Import tools that have optional dependencies"""
|
||||||
@@ -55,6 +58,24 @@ def _import_optional_tools():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Tools] WebSearch failed to load: {e}")
|
logger.error(f"[Tools] WebSearch failed to load: {e}")
|
||||||
|
|
||||||
|
# WebFetch Tool
|
||||||
|
try:
|
||||||
|
from agent.tools.web_fetch.web_fetch import WebFetch
|
||||||
|
tools['WebFetch'] = WebFetch
|
||||||
|
except ImportError as e:
|
||||||
|
logger.error(f"[Tools] WebFetch not loaded - missing dependency: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Tools] WebFetch failed to load: {e}")
|
||||||
|
|
||||||
|
# Vision Tool (conditionally loaded based on API key availability)
|
||||||
|
try:
|
||||||
|
from agent.tools.vision.vision import Vision
|
||||||
|
tools['Vision'] = Vision
|
||||||
|
except ImportError as e:
|
||||||
|
logger.error(f"[Tools] Vision not loaded - missing dependency: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Tools] Vision failed to load: {e}")
|
||||||
|
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
# Load optional tools
|
# Load optional tools
|
||||||
@@ -62,30 +83,48 @@ _optional_tools = _import_optional_tools()
|
|||||||
EnvConfig = _optional_tools.get('EnvConfig')
|
EnvConfig = _optional_tools.get('EnvConfig')
|
||||||
SchedulerTool = _optional_tools.get('SchedulerTool')
|
SchedulerTool = _optional_tools.get('SchedulerTool')
|
||||||
WebSearch = _optional_tools.get('WebSearch')
|
WebSearch = _optional_tools.get('WebSearch')
|
||||||
|
WebFetch = _optional_tools.get('WebFetch')
|
||||||
|
Vision = _optional_tools.get('Vision')
|
||||||
GoogleSearch = _optional_tools.get('GoogleSearch')
|
GoogleSearch = _optional_tools.get('GoogleSearch')
|
||||||
FileSave = _optional_tools.get('FileSave')
|
FileSave = _optional_tools.get('FileSave')
|
||||||
Terminal = _optional_tools.get('Terminal')
|
Terminal = _optional_tools.get('Terminal')
|
||||||
|
|
||||||
|
|
||||||
# Delayed import for BrowserTool
|
# BrowserTool (requires playwright)
|
||||||
def _import_browser_tool():
|
def _import_browser_tool():
|
||||||
|
from common.log import logger
|
||||||
try:
|
try:
|
||||||
from agent.tools.browser.browser_tool import BrowserTool
|
from agent.tools.browser.browser_tool import BrowserTool
|
||||||
return BrowserTool
|
return BrowserTool
|
||||||
except ImportError:
|
except ImportError as e:
|
||||||
# Return a placeholder class that will prompt the user to install dependencies when instantiated
|
logger.info(
|
||||||
class BrowserToolPlaceholder:
|
f"[Tools] BrowserTool not loaded - missing dependency: {e}\n"
|
||||||
def __init__(self, *args, **kwargs):
|
f" To enable browser tool, run:\n"
|
||||||
raise ImportError(
|
f" pip install playwright\n"
|
||||||
"The 'browser-use' package is required to use BrowserTool. "
|
f" playwright install chromium"
|
||||||
"Please install it with 'pip install browser-use>=0.1.40'."
|
)
|
||||||
)
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Tools] BrowserTool failed to load: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
return BrowserToolPlaceholder
|
BrowserTool = _import_browser_tool()
|
||||||
|
|
||||||
|
# MCP Tools (no extra dependencies, loaded on demand)
|
||||||
|
def _import_mcp_tools():
|
||||||
|
"""导入 MCP 工具模块(无额外依赖,按需加载)"""
|
||||||
|
from common.log import logger
|
||||||
|
try:
|
||||||
|
from agent.tools.mcp.mcp_tool import McpTool
|
||||||
|
from agent.tools.mcp.mcp_client import McpClientRegistry
|
||||||
|
return {'McpTool': McpTool, 'McpClientRegistry': McpClientRegistry}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Tools] MCP tools not loaded: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
# Dynamically set BrowserTool
|
_mcp_tools = _import_mcp_tools()
|
||||||
# BrowserTool = _import_browser_tool()
|
McpTool = _mcp_tools.get('McpTool')
|
||||||
|
McpClientRegistry = _mcp_tools.get('McpClientRegistry')
|
||||||
|
|
||||||
# Export all tools (including optional ones that might be None)
|
# Export all tools (including optional ones that might be None)
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -99,11 +138,14 @@ __all__ = [
|
|||||||
'Send',
|
'Send',
|
||||||
'MemorySearchTool',
|
'MemorySearchTool',
|
||||||
'MemoryGetTool',
|
'MemoryGetTool',
|
||||||
|
'EvolutionUndoTool',
|
||||||
'EnvConfig',
|
'EnvConfig',
|
||||||
'SchedulerTool',
|
'SchedulerTool',
|
||||||
'WebSearch',
|
'WebSearch',
|
||||||
# Optional tools (may be None if dependencies not available)
|
'WebFetch',
|
||||||
# 'BrowserTool'
|
'Vision',
|
||||||
|
'BrowserTool',
|
||||||
|
'McpTool',
|
||||||
]
|
]
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ class BaseTool:
|
|||||||
description: str = "Base tool"
|
description: str = "Base tool"
|
||||||
params: dict = {} # Store JSON Schema
|
params: dict = {} # Store JSON Schema
|
||||||
model: Optional[Any] = None # LLM model instance, type depends on bot implementation
|
model: Optional[Any] = None # LLM model instance, type depends on bot implementation
|
||||||
|
progress_callback = None
|
||||||
|
|
||||||
|
def report_progress(self, message: str):
|
||||||
|
callback = getattr(self, "progress_callback", None)
|
||||||
|
if not callback:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
callback(str(message))
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[{self.name}] progress callback failed: {e}")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_json_schema(cls) -> dict:
|
def get_json_schema(cls) -> dict:
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ Bash tool - Execute bash commands
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
from agent.tools.base_tool import BaseTool, ToolResult
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
@@ -17,14 +21,22 @@ from common.utils import expand_path
|
|||||||
class Bash(BaseTool):
|
class Bash(BaseTool):
|
||||||
"""Tool for executing bash commands"""
|
"""Tool for executing bash commands"""
|
||||||
|
|
||||||
|
_IS_WIN = sys.platform == "win32"
|
||||||
|
_PROGRESS_MAX_BYTES = 4 * 1024
|
||||||
|
_PROGRESS_INTERVAL = 0.5
|
||||||
|
# cmd.exe command line limit is ~8191 chars; rewrite python -c above this.
|
||||||
|
_WIN_CMD_SAFE_LEN = 7000
|
||||||
|
|
||||||
name: str = "bash"
|
name: str = "bash"
|
||||||
description: str = f"""Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file.
|
description: str = f"""Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file.
|
||||||
|
{'''
|
||||||
|
PLATFORM: Windows (cmd.exe). Do NOT use Unix-only commands like grep, head, tail, sed, awk.
|
||||||
|
''' if _IS_WIN else ''}
|
||||||
ENVIRONMENT: All API keys from env_config are auto-injected. Use $VAR_NAME directly.
|
ENVIRONMENT: All API keys from env_config are auto-injected. Use $VAR_NAME directly.
|
||||||
|
|
||||||
SAFETY:
|
SAFETY:
|
||||||
- Freely create/modify/delete files within the workspace
|
- Freely create/modify/delete files within the workspace
|
||||||
- For destructive and out-of-workspace commands, explain and confirm first"""
|
- For destructive commands out of workspace, explain and confirm first"""
|
||||||
|
|
||||||
params: dict = {
|
params: dict = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -64,8 +76,8 @@ SAFETY:
|
|||||||
if not command:
|
if not command:
|
||||||
return ToolResult.fail("Error: command parameter is required")
|
return ToolResult.fail("Error: command parameter is required")
|
||||||
|
|
||||||
# Security check: Prevent accessing sensitive config files
|
# Security check: Prevent direct access to the credential file
|
||||||
if "~/.cow/.env" in command or "~/.cow" in command:
|
if re.search(r'\.cow[/\\]\.env', command):
|
||||||
return ToolResult.fail(
|
return ToolResult.fail(
|
||||||
"Error: Access denied. API keys and credentials must be accessed through the env_config tool only."
|
"Error: Access denied. API keys and credentials must be accessed through the env_config tool only."
|
||||||
)
|
)
|
||||||
@@ -83,12 +95,13 @@ SAFETY:
|
|||||||
|
|
||||||
# Load environment variables from ~/.cow/.env if it exists
|
# Load environment variables from ~/.cow/.env if it exists
|
||||||
env_file = expand_path("~/.cow/.env")
|
env_file = expand_path("~/.cow/.env")
|
||||||
|
dotenv_vars = {}
|
||||||
if os.path.exists(env_file):
|
if os.path.exists(env_file):
|
||||||
try:
|
try:
|
||||||
from dotenv import dotenv_values
|
from dotenv import dotenv_values
|
||||||
env_vars = dotenv_values(env_file)
|
dotenv_vars = dotenv_values(env_file)
|
||||||
env.update(env_vars)
|
env.update(dotenv_vars)
|
||||||
logger.debug(f"[Bash] Loaded {len(env_vars)} variables from {env_file}")
|
logger.debug(f"[Bash] Loaded {len(dotenv_vars)} variables from {env_file}")
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.debug("[Bash] python-dotenv not installed, skipping .env loading")
|
logger.debug("[Bash] python-dotenv not installed, skipping .env loading")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -100,17 +113,35 @@ SAFETY:
|
|||||||
else:
|
else:
|
||||||
logger.debug(f"[Bash] Process User: {os.environ.get('USERNAME', os.environ.get('USER', 'unknown'))}")
|
logger.debug(f"[Bash] Process User: {os.environ.get('USERNAME', os.environ.get('USER', 'unknown'))}")
|
||||||
|
|
||||||
# Execute command with inherited environment variables
|
# Temp script written for long `python -c` commands (Windows only),
|
||||||
result = subprocess.run(
|
# cleaned up after execution.
|
||||||
command,
|
temp_script_path = None
|
||||||
shell=True,
|
|
||||||
cwd=self.cwd,
|
# On Windows, convert $VAR references to %VAR% for cmd.exe
|
||||||
stdout=subprocess.PIPE,
|
if self._IS_WIN:
|
||||||
stderr=subprocess.PIPE,
|
env["PYTHONIOENCODING"] = "utf-8"
|
||||||
text=True,
|
command = self._convert_env_vars_for_windows(command, dotenv_vars)
|
||||||
timeout=timeout,
|
# cmd.exe has an ~8191 char command line limit. Long
|
||||||
env=env
|
# `python -c "..."` commands silently fail, so spill the inline
|
||||||
)
|
# code into a temp .py file and run that instead.
|
||||||
|
if len(command) > self._WIN_CMD_SAFE_LEN:
|
||||||
|
command, temp_script_path = self._rewrite_long_python_c(command)
|
||||||
|
if command and not command.strip().lower().startswith("chcp"):
|
||||||
|
command = f"chcp 65001 >nul 2>&1 && {command}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self._run_streaming(
|
||||||
|
command,
|
||||||
|
timeout,
|
||||||
|
env,
|
||||||
|
dotenv_vars,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if temp_script_path:
|
||||||
|
try:
|
||||||
|
os.remove(temp_script_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
logger.debug(f"[Bash] Exit code: {result.returncode}")
|
logger.debug(f"[Bash] Exit code: {result.returncode}")
|
||||||
logger.debug(f"[Bash] Stdout length: {len(result.stdout)}")
|
logger.debug(f"[Bash] Stdout length: {len(result.stdout)}")
|
||||||
@@ -131,6 +162,8 @@ SAFETY:
|
|||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
env=env
|
env=env
|
||||||
)
|
)
|
||||||
@@ -153,18 +186,28 @@ SAFETY:
|
|||||||
except Exception as retry_err:
|
except Exception as retry_err:
|
||||||
logger.warning(f"[Bash] Retry failed: {retry_err}")
|
logger.warning(f"[Bash] Retry failed: {retry_err}")
|
||||||
|
|
||||||
# Combine stdout and stderr
|
# When command succeeds with stdout, keep output clean (stderr goes to server log only).
|
||||||
output = result.stdout
|
# When command fails or stdout is empty, include stderr so the agent can diagnose.
|
||||||
if result.stderr:
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
output += "\n" + result.stderr
|
output = result.stdout
|
||||||
|
if result.stderr:
|
||||||
|
logger.info(f"[Bash] stderr (not forwarded): {result.stderr[:500]}")
|
||||||
|
else:
|
||||||
|
output = result.stdout
|
||||||
|
if result.stderr:
|
||||||
|
output += "\n" + result.stderr
|
||||||
|
|
||||||
# Check if we need to save full output to temp file
|
# Check if we need to save full output to temp file
|
||||||
temp_file_path = None
|
temp_file_path = None
|
||||||
total_bytes = len(output.encode('utf-8'))
|
total_bytes = len(output.encode('utf-8'))
|
||||||
|
|
||||||
if total_bytes > DEFAULT_MAX_BYTES:
|
if total_bytes > DEFAULT_MAX_BYTES:
|
||||||
# Save full output to temp file
|
# Save full output to temp file. encoding='utf-8' is required:
|
||||||
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.log', prefix='bash-') as f:
|
# the default text-mode encoding is the platform locale (e.g.
|
||||||
|
# cp936/GBK on Chinese Windows), which raises UnicodeEncodeError
|
||||||
|
# for output containing emoji or other non-locale characters and
|
||||||
|
# would discard an otherwise successful command result.
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.log', prefix='bash-', encoding='utf-8') as f:
|
||||||
f.write(output)
|
f.write(output)
|
||||||
temp_file_path = f.name
|
temp_file_path = f.name
|
||||||
|
|
||||||
@@ -214,47 +257,199 @@ SAFETY:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.fail(f"Error executing command: {str(e)}")
|
return ToolResult.fail(f"Error executing command: {str(e)}")
|
||||||
|
|
||||||
|
def _run_streaming(self, command: str, timeout: int, env: dict, dotenv_vars: dict):
|
||||||
|
process = subprocess.Popen(
|
||||||
|
command,
|
||||||
|
shell=True,
|
||||||
|
cwd=self.cwd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
env=env,
|
||||||
|
start_new_session=not self._IS_WIN,
|
||||||
|
)
|
||||||
|
stdout_chunks, stderr_chunks = [], []
|
||||||
|
recent = bytearray()
|
||||||
|
recent_lock = threading.Lock()
|
||||||
|
|
||||||
|
def drain(stream, chunks):
|
||||||
|
while True:
|
||||||
|
chunk = os.read(stream.fileno(), 4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
chunks.append(chunk)
|
||||||
|
with recent_lock:
|
||||||
|
recent.extend(chunk)
|
||||||
|
if len(recent) > self._PROGRESS_MAX_BYTES:
|
||||||
|
del recent[:-self._PROGRESS_MAX_BYTES]
|
||||||
|
|
||||||
|
readers = [
|
||||||
|
threading.Thread(target=drain, args=(process.stdout, stdout_chunks), daemon=True),
|
||||||
|
threading.Thread(target=drain, args=(process.stderr, stderr_chunks), daemon=True),
|
||||||
|
]
|
||||||
|
for reader in readers:
|
||||||
|
reader.start()
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
last_reported_at = started
|
||||||
|
last_snapshot = None
|
||||||
|
try:
|
||||||
|
while process.poll() is None:
|
||||||
|
now = time.monotonic()
|
||||||
|
elapsed = now - started
|
||||||
|
if elapsed >= timeout:
|
||||||
|
self._kill_process(process)
|
||||||
|
raise subprocess.TimeoutExpired(command, timeout)
|
||||||
|
if elapsed >= self._PROGRESS_INTERVAL and now - last_reported_at >= self._PROGRESS_INTERVAL:
|
||||||
|
with recent_lock:
|
||||||
|
snapshot = bytes(recent).decode("utf-8", errors="replace")
|
||||||
|
snapshot = self._redact_progress(snapshot, dotenv_vars)
|
||||||
|
if snapshot and snapshot != last_snapshot:
|
||||||
|
self.report_progress(snapshot)
|
||||||
|
last_snapshot = snapshot
|
||||||
|
last_reported_at = now
|
||||||
|
time.sleep(0.1)
|
||||||
|
finally:
|
||||||
|
if process.poll() is None:
|
||||||
|
self._kill_process(process)
|
||||||
|
process.wait()
|
||||||
|
join_deadline = time.monotonic() + 5
|
||||||
|
for reader in readers:
|
||||||
|
reader.join(timeout=max(0, join_deadline - time.monotonic()))
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
return SimpleNamespace(
|
||||||
|
returncode=process.returncode,
|
||||||
|
stdout=b"".join(stdout_chunks).decode("utf-8", errors="replace"),
|
||||||
|
stderr=b"".join(stderr_chunks).decode("utf-8", errors="replace"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _kill_process(self, process):
|
||||||
|
if self._IS_WIN:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["taskkill", "/F", "/T", "/PID", str(process.pid)],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode != 0 and process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
if process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
except (PermissionError, ProcessLookupError):
|
||||||
|
if process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _redact_progress(text: str, dotenv_vars: dict) -> str:
|
||||||
|
text = re.sub(
|
||||||
|
r'(?i)\b(API_KEY|TOKEN|PASSWORD|AUTHORIZATION)\s*=\s*[^\s]+',
|
||||||
|
lambda match: f"{match.group(1)}=[REDACTED]",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
for value in dotenv_vars.values():
|
||||||
|
value = str(value or "")
|
||||||
|
if len(value) >= 6:
|
||||||
|
text = text.replace(value, "[REDACTED]")
|
||||||
|
return text
|
||||||
|
|
||||||
def _get_safety_warning(self, command: str) -> str:
|
def _get_safety_warning(self, command: str) -> str:
|
||||||
"""
|
"""
|
||||||
Get safety warning for potentially dangerous commands
|
Get safety warning for absolutely catastrophic commands only.
|
||||||
Only warns about extremely dangerous system-level operations
|
Keep the blocklist minimal so the agent retains maximum freedom.
|
||||||
|
|
||||||
:param command: Command to check
|
:param command: Command to check
|
||||||
:return: Warning message if dangerous, empty string if safe
|
:return: Warning message if dangerous, empty string if safe
|
||||||
"""
|
"""
|
||||||
cmd_lower = command.lower().strip()
|
# Tokenize to avoid substring false positives (e.g. `rm -rf /tmp/x`
|
||||||
|
# must not match `rm -rf /`).
|
||||||
|
tokens = command.lower().split()
|
||||||
|
|
||||||
# Only block extremely dangerous system operations
|
# `rm -rf /` or `rm -rf /*` targeting the real root.
|
||||||
dangerous_patterns = [
|
for i, tok in enumerate(tokens):
|
||||||
# System shutdown/reboot
|
if tok != "rm":
|
||||||
("shutdown", "This command will shut down the system"),
|
continue
|
||||||
("reboot", "This command will reboot the system"),
|
has_rf = False
|
||||||
("halt", "This command will halt the system"),
|
for j in range(i + 1, len(tokens)):
|
||||||
("poweroff", "This command will power off the system"),
|
t = tokens[j]
|
||||||
|
if t.startswith("-") and "r" in t and "f" in t:
|
||||||
|
has_rf = True
|
||||||
|
elif t in ("--recursive", "--force"):
|
||||||
|
continue
|
||||||
|
elif t in ("/", "/*"):
|
||||||
|
if has_rf:
|
||||||
|
return "This command will delete the entire filesystem"
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
# Critical system modifications
|
# Disk wiping
|
||||||
("rm -rf /", "This command will delete the entire filesystem"),
|
if "if=/dev/zero" in command.lower() and "dd " in command.lower():
|
||||||
("rm -rf /*", "This command will delete the entire filesystem"),
|
return "This command can destroy disk data"
|
||||||
("dd if=/dev/zero", "This command can destroy disk data"),
|
|
||||||
("mkfs", "This command will format a filesystem, destroying all data"),
|
|
||||||
("fdisk", "This command modifies disk partitions"),
|
|
||||||
|
|
||||||
# User/system management (only if targeting system users)
|
# Power control - match only as a standalone word (\b enforces word boundary)
|
||||||
("userdel root", "This command will delete the root user"),
|
if re.search(r'\b(shutdown|reboot|halt|poweroff)\b', command.lower()):
|
||||||
("passwd root", "This command will change the root password"),
|
return "This command will shut down or restart the system"
|
||||||
]
|
|
||||||
|
|
||||||
for pattern, warning in dangerous_patterns:
|
return ""
|
||||||
if pattern in cmd_lower:
|
|
||||||
return warning
|
|
||||||
|
|
||||||
# Check for recursive deletion outside workspace
|
@staticmethod
|
||||||
if "rm" in cmd_lower and "-rf" in cmd_lower:
|
def _convert_env_vars_for_windows(command: str, dotenv_vars: dict) -> str:
|
||||||
# Allow deletion within current workspace
|
"""
|
||||||
if not any(path in cmd_lower for path in ["./", self.cwd.lower()]):
|
Convert bash-style $VAR / ${VAR} references to cmd.exe %VAR% syntax.
|
||||||
# Check if targeting system directories
|
Only converts variables loaded from .env (user-configured API keys etc.)
|
||||||
system_dirs = ["/bin", "/usr", "/etc", "/var", "/home", "/root", "/sys", "/proc"]
|
to avoid breaking $PATH, jq expressions, regex, etc.
|
||||||
if any(sysdir in cmd_lower for sysdir in system_dirs):
|
"""
|
||||||
return "This command will recursively delete system directories"
|
if not dotenv_vars:
|
||||||
|
return command
|
||||||
|
|
||||||
return "" # No warning needed
|
def replace_match(m):
|
||||||
|
var_name = m.group(1) or m.group(2)
|
||||||
|
if var_name in dotenv_vars:
|
||||||
|
return f"%{var_name}%"
|
||||||
|
return m.group(0)
|
||||||
|
|
||||||
|
return re.sub(r'\$\{(\w+)\}|\$(\w+)', replace_match, command)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rewrite_long_python_c(command: str):
|
||||||
|
"""
|
||||||
|
Rewrite `python -c "<code>"` into `python <tempfile>` to bypass the
|
||||||
|
cmd.exe command line length limit on Windows.
|
||||||
|
|
||||||
|
Returns (new_command, temp_file_path). On any parse failure the original
|
||||||
|
command and None are returned, so behavior is unchanged when unmatched.
|
||||||
|
"""
|
||||||
|
# Match: <python|python3|py> [flags] -c "<code>" (single or double quoted)
|
||||||
|
m = re.search(
|
||||||
|
r'^(?P<prefix>.*?\b(?:python3?|py)\b[^\n]*?\s-c\s+)'
|
||||||
|
r'(?P<quote>["\'])(?P<code>.*)(?P=quote)\s*(?P<suffix>.*)$',
|
||||||
|
command,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
if not m:
|
||||||
|
return command, None
|
||||||
|
|
||||||
|
quote = m.group("quote")
|
||||||
|
code = m.group("code")
|
||||||
|
# Reverse common shell-level escaping of the quote char inside the code.
|
||||||
|
code = code.replace("\\" + quote, quote)
|
||||||
|
|
||||||
|
try:
|
||||||
|
fd, path = tempfile.mkstemp(suffix=".py", prefix="bash-pyc-")
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
f.write(code)
|
||||||
|
except OSError:
|
||||||
|
return command, None
|
||||||
|
|
||||||
|
prefix = m.group("prefix")
|
||||||
|
# Drop the trailing "-c " from the prefix, keep the interpreter + flags.
|
||||||
|
interp = re.sub(r'\s-c\s+$', ' ', prefix).rstrip()
|
||||||
|
suffix = m.group("suffix").strip()
|
||||||
|
new_command = f'{interp} "{path}"'
|
||||||
|
if suffix:
|
||||||
|
new_command += f' {suffix}'
|
||||||
|
return new_command, path
|
||||||
|
|||||||
3
agent/tools/browser/__init__.py
Normal file
3
agent/tools/browser/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from agent.tools.browser.browser_tool import BrowserTool
|
||||||
|
|
||||||
|
__all__ = ["BrowserTool"]
|
||||||
961
agent/tools/browser/browser_service.py
Normal file
961
agent/tools/browser/browser_service.py
Normal file
@@ -0,0 +1,961 @@
|
|||||||
|
"""
|
||||||
|
Browser service - Playwright wrapper managing browser lifecycle and page operations.
|
||||||
|
|
||||||
|
All Playwright calls run on a dedicated background thread so that callers from
|
||||||
|
any worker thread can safely use the service. An idle-timeout mechanism
|
||||||
|
automatically shuts down the browser (and its thread) after a configurable
|
||||||
|
period of inactivity to free resources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
from typing import Optional, Dict, Any, List, Callable
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
from common.utils import expand_path, is_cloud_deployment
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_USER_DATA_DIR = "~/.cow/browser_profile"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page, Playwright
|
||||||
|
_HAS_PLAYWRIGHT = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_PLAYWRIGHT = False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Snapshot DOM helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Tags that typically carry useful content for an agent
|
||||||
|
_INTERACTIVE_TAGS = {
|
||||||
|
"a", "button", "input", "textarea", "select", "option",
|
||||||
|
"label", "details", "summary",
|
||||||
|
}
|
||||||
|
_SEMANTIC_TAGS = {
|
||||||
|
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
|
"p", "li", "td", "th", "caption", "figcaption", "blockquote", "pre", "code",
|
||||||
|
"nav", "main", "article", "section", "header", "footer", "form", "table",
|
||||||
|
"img", "video", "audio",
|
||||||
|
}
|
||||||
|
_KEEP_TAGS = _INTERACTIVE_TAGS | _SEMANTIC_TAGS
|
||||||
|
|
||||||
|
_SNAPSHOT_JS = """
|
||||||
|
() => {
|
||||||
|
const KEEP = new Set(%s);
|
||||||
|
const INTERACTIVE = new Set(%s);
|
||||||
|
const SKIP = new Set(["script","style","noscript","svg","path","meta","link","br","hr"]);
|
||||||
|
const CLICKABLE_ROLES = new Set([
|
||||||
|
"button","link","tab","menuitem","menuitemcheckbox","menuitemradio",
|
||||||
|
"option","switch","checkbox","radio","combobox","searchbox","slider",
|
||||||
|
"spinbutton","textbox","treeitem"
|
||||||
|
]);
|
||||||
|
let refCounter = 0;
|
||||||
|
const refMap = {};
|
||||||
|
|
||||||
|
function visible(el) {
|
||||||
|
if (!(el instanceof HTMLElement)) return true;
|
||||||
|
const st = window.getComputedStyle(el);
|
||||||
|
if (st.display === "none" || st.visibility === "hidden") return false;
|
||||||
|
if (parseFloat(st.opacity) === 0) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strong signals: these attributes alone are enough to mark as interactive
|
||||||
|
function hasStrongInteractiveSignal(el) {
|
||||||
|
const role = el.getAttribute("role");
|
||||||
|
if (role && CLICKABLE_ROLES.has(role)) return true;
|
||||||
|
if (el.hasAttribute("onclick") || el.hasAttribute("tabindex")) return true;
|
||||||
|
if (el.hasAttribute("data-click") || el.hasAttribute("data-action")) return true;
|
||||||
|
if (el.getAttribute("contenteditable") === "true") return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cursor:pointer is set directly (not just inherited from parent)
|
||||||
|
function hasOwnPointerCursor(el) {
|
||||||
|
try {
|
||||||
|
const st = window.getComputedStyle(el);
|
||||||
|
if (st.cursor !== "pointer") return false;
|
||||||
|
const parent = el.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
const pst = window.getComputedStyle(parent);
|
||||||
|
if (pst.cursor === "pointer") return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch(e) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTextOrContent(el) {
|
||||||
|
const t = el.textContent || "";
|
||||||
|
if (t.trim().length > 0) return true;
|
||||||
|
if (el.querySelector("img,video,audio,canvas")) return true;
|
||||||
|
const ariaLabel = el.getAttribute("aria-label");
|
||||||
|
if (ariaLabel && ariaLabel.trim()) return true;
|
||||||
|
const title = el.getAttribute("title");
|
||||||
|
if (title && title.trim()) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImplicitInteractive(el) {
|
||||||
|
if (hasStrongInteractiveSignal(el)) return true;
|
||||||
|
if (hasOwnPointerCursor(el) && hasTextOrContent(el)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextContent(el) {
|
||||||
|
let text = "";
|
||||||
|
for (const ch of el.childNodes) {
|
||||||
|
if (ch.nodeType === Node.TEXT_NODE) {
|
||||||
|
text += ch.textContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(node) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const t = node.textContent.trim();
|
||||||
|
return t ? t : null;
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return null;
|
||||||
|
const tag = node.tagName.toLowerCase();
|
||||||
|
if (SKIP.has(tag)) return null;
|
||||||
|
if (!visible(node)) return null;
|
||||||
|
|
||||||
|
const children = [];
|
||||||
|
for (const ch of node.childNodes) {
|
||||||
|
const r = walk(ch);
|
||||||
|
if (r !== null) {
|
||||||
|
if (typeof r === "string") children.push(r);
|
||||||
|
else children.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeInteractive = INTERACTIVE.has(tag);
|
||||||
|
const implicitInteractive = !nativeInteractive && (node instanceof HTMLElement) && isImplicitInteractive(node);
|
||||||
|
const keep = KEEP.has(tag) || implicitInteractive;
|
||||||
|
|
||||||
|
if (!keep) {
|
||||||
|
if (children.length === 0) return null;
|
||||||
|
if (children.length === 1) return children[0];
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = { tag };
|
||||||
|
if (nativeInteractive || implicitInteractive) {
|
||||||
|
refCounter++;
|
||||||
|
obj.ref = refCounter;
|
||||||
|
refMap[refCounter] = node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (implicitInteractive) {
|
||||||
|
const role = node.getAttribute("role");
|
||||||
|
if (role) obj.role = role;
|
||||||
|
const directText = getTextContent(node);
|
||||||
|
if (!directText && children.length === 0) {
|
||||||
|
const ariaLabel = node.getAttribute("aria-label");
|
||||||
|
const title = node.getAttribute("title");
|
||||||
|
if (ariaLabel) obj.ariaLabel = ariaLabel;
|
||||||
|
else if (title) obj.ariaLabel = title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attributes
|
||||||
|
if (tag === "a" && node.href) obj.href = node.getAttribute("href");
|
||||||
|
if (tag === "img") {
|
||||||
|
obj.alt = node.alt || "";
|
||||||
|
obj.src = node.getAttribute("src") || "";
|
||||||
|
}
|
||||||
|
if (tag === "input" || tag === "textarea" || tag === "select") {
|
||||||
|
obj.type = node.type || "text";
|
||||||
|
obj.name = node.name || undefined;
|
||||||
|
obj.value = node.value || undefined;
|
||||||
|
obj.placeholder = node.placeholder || undefined;
|
||||||
|
if (node.disabled) obj.disabled = true;
|
||||||
|
if (tag === "input" && node.type === "checkbox") obj.checked = node.checked;
|
||||||
|
}
|
||||||
|
if (tag === "button") {
|
||||||
|
if (node.disabled) obj.disabled = true;
|
||||||
|
}
|
||||||
|
if (tag === "option") {
|
||||||
|
obj.value = node.value;
|
||||||
|
if (node.selected) obj.selected = true;
|
||||||
|
}
|
||||||
|
if (tag === "label" && node.htmlFor) obj.for = node.htmlFor;
|
||||||
|
|
||||||
|
// Role / aria-label for native interactive & semantic elements
|
||||||
|
if (!implicitInteractive) {
|
||||||
|
const role = node.getAttribute("role");
|
||||||
|
if (role) obj.role = role;
|
||||||
|
const ariaLabel = node.getAttribute("aria-label");
|
||||||
|
if (ariaLabel) obj.ariaLabel = ariaLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Children
|
||||||
|
if (children.length === 1 && typeof children[0] === "string") {
|
||||||
|
obj.text = children[0];
|
||||||
|
} else if (children.length > 0) {
|
||||||
|
obj.children = children;
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = walk(document.body);
|
||||||
|
window.__cowRefMap = refMap;
|
||||||
|
return { tree: result, refCount: refCounter };
|
||||||
|
}
|
||||||
|
""" % (
|
||||||
|
str(list(_KEEP_TAGS)),
|
||||||
|
str(list(_INTERACTIVE_TAGS)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_BROWSER_DEAD_HINTS = (
|
||||||
|
"has been closed",
|
||||||
|
"browser has disconnected",
|
||||||
|
"target closed",
|
||||||
|
"browser closed",
|
||||||
|
"context or browser has been closed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_browser_dead_error(err: Exception) -> bool:
|
||||||
|
"""Return True if *err* indicates the browser / page died out from under us."""
|
||||||
|
msg = str(err).lower()
|
||||||
|
return any(h in msg for h in _BROWSER_DEAD_HINTS)
|
||||||
|
|
||||||
|
|
||||||
|
def _should_use_headless() -> bool:
|
||||||
|
"""Decide headless mode: headless on Linux servers without display, headed elsewhere."""
|
||||||
|
if sys.platform in ("win32", "darwin"):
|
||||||
|
return False
|
||||||
|
# Linux: check for display
|
||||||
|
if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_tree(node, indent=0) -> List[str]:
|
||||||
|
"""Convert snapshot tree to compact text lines for LLM consumption."""
|
||||||
|
if node is None:
|
||||||
|
return []
|
||||||
|
if isinstance(node, str):
|
||||||
|
return [" " * indent + node]
|
||||||
|
if isinstance(node, list):
|
||||||
|
lines = []
|
||||||
|
for child in node:
|
||||||
|
lines.extend(_flatten_tree(child, indent))
|
||||||
|
return lines
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
return []
|
||||||
|
|
||||||
|
tag = node.get("tag", "?")
|
||||||
|
ref = node.get("ref")
|
||||||
|
parts = [tag]
|
||||||
|
if ref:
|
||||||
|
parts[0] = f"[{ref}] {tag}"
|
||||||
|
|
||||||
|
# Inline attributes
|
||||||
|
for attr in ("type", "name", "href", "alt", "role", "ariaLabel", "placeholder", "value"):
|
||||||
|
val = node.get(attr)
|
||||||
|
if val:
|
||||||
|
# Truncate long values
|
||||||
|
s = str(val)
|
||||||
|
if len(s) > 80:
|
||||||
|
s = s[:77] + "..."
|
||||||
|
parts.append(f'{attr}="{s}"')
|
||||||
|
|
||||||
|
for flag in ("disabled", "checked", "selected"):
|
||||||
|
if node.get(flag):
|
||||||
|
parts.append(flag)
|
||||||
|
|
||||||
|
prefix = " " * indent
|
||||||
|
header = prefix + " ".join(parts)
|
||||||
|
|
||||||
|
text = node.get("text")
|
||||||
|
if text:
|
||||||
|
# Truncate long text
|
||||||
|
if len(text) > 120:
|
||||||
|
text = text[:117] + "..."
|
||||||
|
header += f": {text}"
|
||||||
|
|
||||||
|
lines = [header]
|
||||||
|
children = node.get("children", [])
|
||||||
|
for child in children:
|
||||||
|
lines.extend(_flatten_tree(child, indent + 2))
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserService:
|
||||||
|
"""Manages a Playwright browser on a dedicated background thread.
|
||||||
|
|
||||||
|
All Playwright operations are dispatched to a single long-lived thread via
|
||||||
|
a task queue. Callers from *any* worker thread can use the public API
|
||||||
|
safely. An idle timer automatically shuts the browser down after
|
||||||
|
``idle_timeout`` seconds of inactivity (default 300 = 5 min).
|
||||||
|
"""
|
||||||
|
|
||||||
|
_IDLE_TIMEOUT_DEFAULT = 300 # seconds
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||||
|
self._config = config or {}
|
||||||
|
self._headless: Optional[bool] = None
|
||||||
|
self._screenshot_dir: Optional[str] = None
|
||||||
|
|
||||||
|
# Background thread state
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._task_queue: queue.Queue = queue.Queue()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._alive = False
|
||||||
|
self._ready = threading.Event()
|
||||||
|
|
||||||
|
# Playwright objects (only accessed on the background thread)
|
||||||
|
self._playwright = None
|
||||||
|
self._browser = None
|
||||||
|
self._context = None
|
||||||
|
self._page = None
|
||||||
|
|
||||||
|
# Launch mode: one of "fresh" | "persistent" | "cdp".
|
||||||
|
# - cdp: connect to an externally launched Chrome via CDP endpoint.
|
||||||
|
# - persistent: launch with launch_persistent_context using a user_data_dir
|
||||||
|
# so cookies / login state survive across runs (default).
|
||||||
|
# - fresh: classic launch + new_context, clean state every run.
|
||||||
|
cdp_endpoint = self._config.get("cdp_endpoint") or ""
|
||||||
|
persistent_flag = self._config.get("persistent", True)
|
||||||
|
user_data_dir_cfg = self._config.get("user_data_dir")
|
||||||
|
if user_data_dir_cfg is None:
|
||||||
|
user_data_dir_cfg = _DEFAULT_USER_DATA_DIR
|
||||||
|
|
||||||
|
self._cdp_endpoint: str = cdp_endpoint.strip() if isinstance(cdp_endpoint, str) else ""
|
||||||
|
if self._cdp_endpoint:
|
||||||
|
self._launch_mode = "cdp"
|
||||||
|
self._user_data_dir: str = ""
|
||||||
|
elif persistent_flag and user_data_dir_cfg:
|
||||||
|
self._launch_mode = "persistent"
|
||||||
|
self._user_data_dir = expand_path(str(user_data_dir_cfg))
|
||||||
|
else:
|
||||||
|
self._launch_mode = "fresh"
|
||||||
|
self._user_data_dir = ""
|
||||||
|
|
||||||
|
# Idle auto-release
|
||||||
|
idle_cfg = self._config.get("idle_timeout")
|
||||||
|
self._idle_timeout: float = float(idle_cfg) if idle_cfg is not None else self._IDLE_TIMEOUT_DEFAULT
|
||||||
|
self._idle_timer: Optional[threading.Timer] = None
|
||||||
|
|
||||||
|
# Set when the browser / page is detected to have died externally
|
||||||
|
# (e.g. user manually closed the window). The next _submit() will then
|
||||||
|
# tear down the stale thread and relaunch.
|
||||||
|
self._needs_restart = False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Background-thread lifecycle
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _start_thread(self):
|
||||||
|
"""Start the dedicated Playwright thread if not already running."""
|
||||||
|
with self._lock:
|
||||||
|
if self._alive and self._thread and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
# Wait for old thread to fully exit before creating a new one
|
||||||
|
old = self._thread
|
||||||
|
if old and old.is_alive():
|
||||||
|
old.join(timeout=5)
|
||||||
|
# Fresh queue to avoid stale sentinels from a previous close()
|
||||||
|
self._task_queue = queue.Queue()
|
||||||
|
self._alive = True
|
||||||
|
self._ready = threading.Event()
|
||||||
|
self._thread = threading.Thread(target=self._run_loop, daemon=True, name="BrowserThread")
|
||||||
|
self._thread.start()
|
||||||
|
# Block until browser is ready (or failed)
|
||||||
|
self._ready.wait(timeout=30)
|
||||||
|
|
||||||
|
def _run_loop(self):
|
||||||
|
"""Event loop running on the dedicated thread. Processes tasks until stopped."""
|
||||||
|
logger.info("[Browser] Background thread started")
|
||||||
|
try:
|
||||||
|
self._launch_browser()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Browser] Failed to launch browser: {e}")
|
||||||
|
self._alive = False
|
||||||
|
self._ready.set()
|
||||||
|
self._drain_queue(RuntimeError(f"Browser launch failed: {e}"))
|
||||||
|
return
|
||||||
|
self._ready.set()
|
||||||
|
|
||||||
|
while self._alive:
|
||||||
|
try:
|
||||||
|
task = self._task_queue.get(timeout=1.0)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
if task is None:
|
||||||
|
break
|
||||||
|
fn, args, kwargs, result_slot = task
|
||||||
|
try:
|
||||||
|
result_slot["value"] = fn(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
result_slot["error"] = e
|
||||||
|
if _is_browser_dead_error(e):
|
||||||
|
self._needs_restart = True
|
||||||
|
logger.warning(
|
||||||
|
f"[Browser] Detected closed page/context ({e}); "
|
||||||
|
"will relaunch on next request."
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
result_slot["event"].set()
|
||||||
|
|
||||||
|
self._shutdown_browser()
|
||||||
|
self._drain_queue(RuntimeError("Browser thread stopped"))
|
||||||
|
logger.info("[Browser] Background thread exited")
|
||||||
|
|
||||||
|
def _drain_queue(self, error: Exception):
|
||||||
|
"""Unblock all callers waiting on the queue with an error."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
task = self._task_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
if task is None:
|
||||||
|
continue
|
||||||
|
_, _, _, result_slot = task
|
||||||
|
result_slot["error"] = error
|
||||||
|
result_slot["event"].set()
|
||||||
|
|
||||||
|
def _launch_browser(self):
|
||||||
|
"""Launch / connect Chromium on the background thread."""
|
||||||
|
if self._headless is None:
|
||||||
|
headless_cfg = self._config.get("headless")
|
||||||
|
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
|
||||||
|
|
||||||
|
launch_args = ["--disable-dev-shm-usage"]
|
||||||
|
if self._headless:
|
||||||
|
launch_args.append("--no-sandbox")
|
||||||
|
|
||||||
|
if is_cloud_deployment():
|
||||||
|
launch_args.extend([
|
||||||
|
"--disable-gpu",
|
||||||
|
"--disable-software-rasterizer",
|
||||||
|
"--disable-extensions",
|
||||||
|
"--disable-background-networking",
|
||||||
|
"--disable-background-timer-throttling",
|
||||||
|
"--disable-renderer-backgrounding",
|
||||||
|
"--disable-features=site-per-process,TranslateUI,IsolateOrigins",
|
||||||
|
"--no-zygote",
|
||||||
|
"--js-flags=--max-old-space-size=384",
|
||||||
|
"--memory-pressure-off",
|
||||||
|
])
|
||||||
|
|
||||||
|
extra_args = self._config.get("launch_args", [])
|
||||||
|
if extra_args:
|
||||||
|
launch_args.extend(extra_args)
|
||||||
|
|
||||||
|
viewport_w = self._config.get("viewport_width", 1280)
|
||||||
|
viewport_h = self._config.get("viewport_height", 720)
|
||||||
|
viewport = {"width": viewport_w, "height": viewport_h}
|
||||||
|
user_agent = (
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/131.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._playwright = sync_playwright().start()
|
||||||
|
|
||||||
|
if self._launch_mode == "cdp":
|
||||||
|
self._connect_cdp(viewport)
|
||||||
|
elif self._launch_mode == "persistent":
|
||||||
|
self._launch_persistent(launch_args, viewport, user_agent)
|
||||||
|
else:
|
||||||
|
self._launch_fresh(launch_args, viewport, user_agent)
|
||||||
|
|
||||||
|
logger.info("[Browser] Browser ready")
|
||||||
|
|
||||||
|
def _launch_fresh(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
|
||||||
|
"""Classic launch: brand new Chromium with an empty context."""
|
||||||
|
logger.info(f"[Browser] Launching Chromium (fresh, headless={self._headless})")
|
||||||
|
self._browser = self._playwright.chromium.launch(
|
||||||
|
headless=self._headless,
|
||||||
|
args=launch_args,
|
||||||
|
)
|
||||||
|
self._context = self._browser.new_context(
|
||||||
|
viewport=viewport,
|
||||||
|
user_agent=user_agent,
|
||||||
|
)
|
||||||
|
self._page = self._context.new_page()
|
||||||
|
self._wire_close_listeners()
|
||||||
|
|
||||||
|
def _launch_persistent(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
|
||||||
|
"""Launch Chromium with a persistent user_data_dir so login state survives."""
|
||||||
|
os.makedirs(self._user_data_dir, exist_ok=True)
|
||||||
|
logger.info(
|
||||||
|
f"[Browser] Launching Chromium (persistent, headless={self._headless}, "
|
||||||
|
f"profile={self._user_data_dir})"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self._context = self._playwright.chromium.launch_persistent_context(
|
||||||
|
user_data_dir=self._user_data_dir,
|
||||||
|
headless=self._headless,
|
||||||
|
args=launch_args,
|
||||||
|
viewport=viewport,
|
||||||
|
user_agent=user_agent,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Profile is locked when another Chromium instance already holds it.
|
||||||
|
msg = str(e).lower()
|
||||||
|
if "singletonlock" in msg or "profile" in msg or "lock" in msg:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Browser profile '{self._user_data_dir}' is in use by another process. "
|
||||||
|
"Close the other Chromium / cow instance, or set a different "
|
||||||
|
"tools.browser.user_data_dir."
|
||||||
|
) from e
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Persistent context has no parent Browser handle; reuse the auto-created page.
|
||||||
|
self._browser = None
|
||||||
|
pages = self._context.pages
|
||||||
|
self._page = pages[0] if pages else self._context.new_page()
|
||||||
|
self._wire_close_listeners()
|
||||||
|
|
||||||
|
def _connect_cdp(self, viewport: Dict[str, int]):
|
||||||
|
"""Attach to an existing Chrome started with --remote-debugging-port."""
|
||||||
|
endpoint = self._cdp_endpoint
|
||||||
|
logger.info(f"[Browser] Connecting to existing Chrome via CDP: {endpoint}")
|
||||||
|
try:
|
||||||
|
self._browser = self._playwright.chromium.connect_over_cdp(endpoint)
|
||||||
|
except Exception as e:
|
||||||
|
msg = str(e).lower()
|
||||||
|
if "econnrefused" in msg or "connect" in msg or "refused" in msg:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Cannot reach Chrome at {endpoint}. The CDP browser is not "
|
||||||
|
"running. Ask the user to launch Chrome with "
|
||||||
|
"--remote-debugging-port and --user-data-dir, then retry. "
|
||||||
|
"Do not retry this tool until the user confirms."
|
||||||
|
) from e
|
||||||
|
raise
|
||||||
|
|
||||||
|
contexts = self._browser.contexts
|
||||||
|
if contexts:
|
||||||
|
self._context = contexts[0]
|
||||||
|
else:
|
||||||
|
self._context = self._browser.new_context(viewport=viewport)
|
||||||
|
|
||||||
|
pages = self._context.pages
|
||||||
|
self._page = pages[0] if pages else self._context.new_page()
|
||||||
|
self._wire_close_listeners()
|
||||||
|
|
||||||
|
def _wire_close_listeners(self):
|
||||||
|
"""Mark needs_restart whenever the browser / context / page dies externally."""
|
||||||
|
def _on_dead(_obj=None):
|
||||||
|
self._needs_restart = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._browser:
|
||||||
|
self._browser.on("disconnected", _on_dead)
|
||||||
|
if self._context:
|
||||||
|
self._context.on("close", _on_dead)
|
||||||
|
if self._page:
|
||||||
|
self._page.on("close", _on_dead)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] Failed to wire close listeners: {e}")
|
||||||
|
|
||||||
|
def _shutdown_browser(self):
|
||||||
|
"""Shut down Playwright resources on the background thread.
|
||||||
|
|
||||||
|
Mode-specific behavior:
|
||||||
|
- cdp: only disconnect the Playwright client; leave the user's Chrome
|
||||||
|
and its tabs untouched (do NOT close the context).
|
||||||
|
- persistent: close the persistent context (no separate browser handle).
|
||||||
|
- fresh: close context, then browser.
|
||||||
|
"""
|
||||||
|
self._cancel_idle_timer()
|
||||||
|
|
||||||
|
if self._launch_mode == "cdp":
|
||||||
|
# For CDP, browser.close() only detaches the Playwright client;
|
||||||
|
# the user's Chrome process and its tabs stay alive.
|
||||||
|
try:
|
||||||
|
if self._browser:
|
||||||
|
self._browser.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] cdp disconnect error: {e}")
|
||||||
|
else:
|
||||||
|
for obj, label in [
|
||||||
|
(self._context, "context"),
|
||||||
|
(self._browser, "browser"),
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
if obj:
|
||||||
|
obj.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] {label} close error: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._playwright:
|
||||||
|
self._playwright.stop()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[Browser] playwright stop error: {e}")
|
||||||
|
self._page = None
|
||||||
|
self._context = None
|
||||||
|
self._browser = None
|
||||||
|
self._playwright = None
|
||||||
|
logger.info("[Browser] Browser closed")
|
||||||
|
|
||||||
|
def _submit(self, fn: Callable, *args, **kwargs):
|
||||||
|
"""Submit *fn* to the background thread and block until it completes."""
|
||||||
|
# If the browser died externally (e.g. user closed the window), tear
|
||||||
|
# down the stale thread first so _start_thread() will relaunch fresh.
|
||||||
|
if self._needs_restart:
|
||||||
|
logger.info("[Browser] Restarting after detecting closed browser")
|
||||||
|
self.close()
|
||||||
|
self._needs_restart = False
|
||||||
|
|
||||||
|
self._start_thread()
|
||||||
|
|
||||||
|
if not self._alive:
|
||||||
|
raise RuntimeError("Browser is not available")
|
||||||
|
|
||||||
|
self._reset_idle_timer()
|
||||||
|
|
||||||
|
result_slot: Dict[str, Any] = {"event": threading.Event()}
|
||||||
|
self._task_queue.put((fn, args, kwargs, result_slot))
|
||||||
|
|
||||||
|
# Timeout prevents permanent hang if the background thread crashes
|
||||||
|
completed = result_slot["event"].wait(timeout=120)
|
||||||
|
if not completed:
|
||||||
|
raise TimeoutError("Browser operation timed out (120s)")
|
||||||
|
|
||||||
|
if "error" in result_slot:
|
||||||
|
raise result_slot["error"]
|
||||||
|
return result_slot.get("value")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Idle auto-release
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _reset_idle_timer(self):
|
||||||
|
self._cancel_idle_timer()
|
||||||
|
if self._idle_timeout > 0:
|
||||||
|
self._idle_timer = threading.Timer(self._idle_timeout, self._on_idle_timeout)
|
||||||
|
self._idle_timer.daemon = True
|
||||||
|
self._idle_timer.start()
|
||||||
|
|
||||||
|
def _cancel_idle_timer(self):
|
||||||
|
if self._idle_timer:
|
||||||
|
self._idle_timer.cancel()
|
||||||
|
self._idle_timer = None
|
||||||
|
|
||||||
|
def _on_idle_timeout(self):
|
||||||
|
logger.info(f"[Browser] Idle for {self._idle_timeout}s, auto-releasing browser")
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public lifecycle
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Shut down browser and background thread (safe from any thread)."""
|
||||||
|
self._cancel_idle_timer()
|
||||||
|
with self._lock:
|
||||||
|
if not self._alive:
|
||||||
|
self._needs_restart = False
|
||||||
|
return
|
||||||
|
self._alive = False
|
||||||
|
t = self._thread
|
||||||
|
if self._task_queue is not None:
|
||||||
|
self._task_queue.put(None)
|
||||||
|
if t is not None and t.is_alive():
|
||||||
|
t.join(timeout=10)
|
||||||
|
with self._lock:
|
||||||
|
self._thread = None
|
||||||
|
self._needs_restart = False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Actions (each method is dispatched to the background thread)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def navigate(self, url: str, timeout: int = 30000) -> Dict[str, Any]:
|
||||||
|
return self._submit(self._do_navigate, url, timeout)
|
||||||
|
|
||||||
|
def _do_navigate(self, url: str, timeout: int) -> Dict[str, Any]:
|
||||||
|
page = self._page
|
||||||
|
try:
|
||||||
|
resp = page.goto(url, wait_until="domcontentloaded", timeout=timeout)
|
||||||
|
status = resp.status if resp else None
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Navigation failed: {e}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
page.wait_for_load_state("networkidle", timeout=8000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
try:
|
||||||
|
title = page.title()
|
||||||
|
except Exception:
|
||||||
|
title = ""
|
||||||
|
try:
|
||||||
|
current_url = page.url
|
||||||
|
except Exception:
|
||||||
|
current_url = url
|
||||||
|
|
||||||
|
return {"url": current_url, "title": title, "status": status}
|
||||||
|
|
||||||
|
def snapshot(self, selector: Optional[str] = None) -> str:
|
||||||
|
return self._submit(self._do_snapshot, selector)
|
||||||
|
|
||||||
|
def _do_snapshot(self, selector: Optional[str] = None) -> str:
|
||||||
|
page = self._page
|
||||||
|
try:
|
||||||
|
result = page.evaluate(_SNAPSHOT_JS)
|
||||||
|
except Exception as e:
|
||||||
|
return f"[Snapshot error: {e}]"
|
||||||
|
|
||||||
|
tree = result.get("tree")
|
||||||
|
ref_count = result.get("refCount", 0)
|
||||||
|
lines = _flatten_tree(tree)
|
||||||
|
|
||||||
|
try:
|
||||||
|
title = page.title()
|
||||||
|
except Exception:
|
||||||
|
title = ""
|
||||||
|
try:
|
||||||
|
url = page.url
|
||||||
|
except Exception:
|
||||||
|
url = ""
|
||||||
|
|
||||||
|
header = f"Page: {title} ({url})\nInteractive elements: {ref_count}\n---"
|
||||||
|
body = "\n".join(lines)
|
||||||
|
|
||||||
|
max_chars = self._config.get("snapshot_max_chars", 30000)
|
||||||
|
if len(body) > max_chars:
|
||||||
|
body = body[:max_chars] + "\n... [snapshot truncated]"
|
||||||
|
|
||||||
|
return f"{header}\n{body}"
|
||||||
|
|
||||||
|
def screenshot(self, full_page: bool = False, cwd: str = "") -> str:
|
||||||
|
return self._submit(self._do_screenshot, full_page, cwd)
|
||||||
|
|
||||||
|
def _do_screenshot(self, full_page: bool = False, cwd: str = "") -> str:
|
||||||
|
page = self._page
|
||||||
|
save_dir = self._get_screenshot_dir(cwd)
|
||||||
|
filename = f"screenshot_{uuid.uuid4().hex[:8]}.png"
|
||||||
|
filepath = os.path.join(save_dir, filename)
|
||||||
|
page.screenshot(path=filepath, full_page=full_page)
|
||||||
|
logger.info(f"[Browser] Screenshot saved: {filepath}")
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
def click(self, ref: Optional[int] = None, selector: Optional[str] = None,
|
||||||
|
timeout: int = 5000) -> Dict[str, Any]:
|
||||||
|
return self._submit(self._do_click, ref, selector, timeout)
|
||||||
|
|
||||||
|
def _do_click(self, ref, selector, timeout) -> Dict[str, Any]:
|
||||||
|
page = self._page
|
||||||
|
try:
|
||||||
|
if ref is not None:
|
||||||
|
result = page.evaluate(f"""
|
||||||
|
() => {{
|
||||||
|
const el = window.__cowRefMap && window.__cowRefMap[{ref}];
|
||||||
|
if (!el) return {{ error: "ref {ref} not found. Run snapshot first." }};
|
||||||
|
el.click();
|
||||||
|
return {{ clicked: true, tag: el.tagName.toLowerCase() }};
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
if result.get("error"):
|
||||||
|
return result
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
return result
|
||||||
|
elif selector:
|
||||||
|
page.click(selector, timeout=timeout)
|
||||||
|
return {"clicked": True, "selector": selector}
|
||||||
|
else:
|
||||||
|
return {"error": "Provide either ref (from snapshot) or selector"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Click failed: {e}"}
|
||||||
|
|
||||||
|
def fill(self, text: str, ref: Optional[int] = None,
|
||||||
|
selector: Optional[str] = None, timeout: int = 5000) -> Dict[str, Any]:
|
||||||
|
return self._submit(self._do_fill, text, ref, selector, timeout)
|
||||||
|
|
||||||
|
def _do_fill(self, text, ref, selector, timeout) -> Dict[str, Any]:
|
||||||
|
page = self._page
|
||||||
|
try:
|
||||||
|
if ref is not None:
|
||||||
|
result = page.evaluate(f"""
|
||||||
|
() => {{
|
||||||
|
const el = window.__cowRefMap && window.__cowRefMap[{ref}];
|
||||||
|
if (!el) return {{ error: "ref {ref} not found. Run snapshot first." }};
|
||||||
|
el.focus();
|
||||||
|
el.value = "";
|
||||||
|
return {{ tag: el.tagName.toLowerCase(), name: el.name || "" }};
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
if result.get("error"):
|
||||||
|
return result
|
||||||
|
page.keyboard.type(text)
|
||||||
|
return {"filled": True, "ref": ref, "text": text}
|
||||||
|
elif selector:
|
||||||
|
page.fill(selector, text, timeout=timeout)
|
||||||
|
return {"filled": True, "selector": selector, "text": text}
|
||||||
|
else:
|
||||||
|
return {"error": "Provide either ref (from snapshot) or selector"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Fill failed: {e}"}
|
||||||
|
|
||||||
|
def select(self, value: str, ref: Optional[int] = None,
|
||||||
|
selector: Optional[str] = None, timeout: int = 5000) -> Dict[str, Any]:
|
||||||
|
return self._submit(self._do_select, value, ref, selector, timeout)
|
||||||
|
|
||||||
|
def _do_select(self, value, ref, selector, timeout) -> Dict[str, Any]:
|
||||||
|
page = self._page
|
||||||
|
try:
|
||||||
|
if ref is not None:
|
||||||
|
result = page.evaluate(f"""
|
||||||
|
() => {{
|
||||||
|
const el = window.__cowRefMap && window.__cowRefMap[{ref}];
|
||||||
|
if (!el || el.tagName.toLowerCase() !== "select")
|
||||||
|
return {{ error: "ref {ref} is not a <select> element" }};
|
||||||
|
el.value = {repr(value)};
|
||||||
|
el.dispatchEvent(new Event("change", {{ bubbles: true }}));
|
||||||
|
return {{ selected: true, value: el.value }};
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
return result
|
||||||
|
elif selector:
|
||||||
|
page.select_option(selector, value, timeout=timeout)
|
||||||
|
return {"selected": True, "selector": selector, "value": value}
|
||||||
|
else:
|
||||||
|
return {"error": "Provide either ref (from snapshot) or selector"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Select failed: {e}"}
|
||||||
|
|
||||||
|
def scroll(self, direction: str = "down", amount: int = 500) -> Dict[str, Any]:
|
||||||
|
return self._submit(self._do_scroll, direction, amount)
|
||||||
|
|
||||||
|
def _do_scroll(self, direction, amount) -> Dict[str, Any]:
|
||||||
|
page = self._page
|
||||||
|
delta_map = {
|
||||||
|
"down": (0, amount),
|
||||||
|
"up": (0, -amount),
|
||||||
|
"right": (amount, 0),
|
||||||
|
"left": (-amount, 0),
|
||||||
|
}
|
||||||
|
dx, dy = delta_map.get(direction, (0, amount))
|
||||||
|
try:
|
||||||
|
page.mouse.wheel(dx, dy)
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
scroll_info = page.evaluate("""
|
||||||
|
() => ({
|
||||||
|
scrollX: window.scrollX,
|
||||||
|
scrollY: window.scrollY,
|
||||||
|
scrollHeight: document.documentElement.scrollHeight,
|
||||||
|
clientHeight: document.documentElement.clientHeight
|
||||||
|
})
|
||||||
|
""")
|
||||||
|
return {"scrolled": direction, "amount": amount, **scroll_info}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Scroll failed: {e}"}
|
||||||
|
|
||||||
|
def wait(self, selector: Optional[str] = None, timeout: int = 5000,
|
||||||
|
state: str = "visible") -> Dict[str, Any]:
|
||||||
|
return self._submit(self._do_wait, selector, timeout, state)
|
||||||
|
|
||||||
|
def _do_wait(self, selector, timeout, state) -> Dict[str, Any]:
|
||||||
|
page = self._page
|
||||||
|
try:
|
||||||
|
if selector:
|
||||||
|
page.wait_for_selector(selector, timeout=timeout, state=state)
|
||||||
|
return {"waited": True, "selector": selector, "state": state}
|
||||||
|
else:
|
||||||
|
page.wait_for_timeout(timeout)
|
||||||
|
return {"waited": True, "timeout_ms": timeout}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Wait failed: {e}"}
|
||||||
|
|
||||||
|
def go_back(self) -> Dict[str, Any]:
|
||||||
|
return self._submit(self._do_go_back)
|
||||||
|
|
||||||
|
def _do_go_back(self) -> Dict[str, Any]:
|
||||||
|
page = self._page
|
||||||
|
try:
|
||||||
|
page.go_back(wait_until="domcontentloaded", timeout=10000)
|
||||||
|
try:
|
||||||
|
title = page.title()
|
||||||
|
except Exception:
|
||||||
|
title = ""
|
||||||
|
try:
|
||||||
|
url = page.url
|
||||||
|
except Exception:
|
||||||
|
url = ""
|
||||||
|
return {"url": url, "title": title}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Go back failed: {e}"}
|
||||||
|
|
||||||
|
def go_forward(self) -> Dict[str, Any]:
|
||||||
|
return self._submit(self._do_go_forward)
|
||||||
|
|
||||||
|
def _do_go_forward(self) -> Dict[str, Any]:
|
||||||
|
page = self._page
|
||||||
|
try:
|
||||||
|
page.go_forward(wait_until="domcontentloaded", timeout=10000)
|
||||||
|
try:
|
||||||
|
title = page.title()
|
||||||
|
except Exception:
|
||||||
|
title = ""
|
||||||
|
try:
|
||||||
|
url = page.url
|
||||||
|
except Exception:
|
||||||
|
url = ""
|
||||||
|
return {"url": url, "title": title}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Go forward failed: {e}"}
|
||||||
|
|
||||||
|
def get_text(self, selector: str) -> Dict[str, Any]:
|
||||||
|
return self._submit(self._do_get_text, selector)
|
||||||
|
|
||||||
|
def _do_get_text(self, selector) -> Dict[str, Any]:
|
||||||
|
page = self._page
|
||||||
|
try:
|
||||||
|
text = page.text_content(selector, timeout=5000)
|
||||||
|
return {"text": text or ""}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Get text failed: {e}"}
|
||||||
|
|
||||||
|
def evaluate(self, script: str) -> Dict[str, Any]:
|
||||||
|
return self._submit(self._do_evaluate, script)
|
||||||
|
|
||||||
|
def _do_evaluate(self, script) -> Dict[str, Any]:
|
||||||
|
page = self._page
|
||||||
|
try:
|
||||||
|
result = page.evaluate(script)
|
||||||
|
return {"result": result}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Evaluate failed: {e}"}
|
||||||
|
|
||||||
|
def press(self, key: str) -> Dict[str, Any]:
|
||||||
|
return self._submit(self._do_press, key)
|
||||||
|
|
||||||
|
def _do_press(self, key) -> Dict[str, Any]:
|
||||||
|
page = self._page
|
||||||
|
try:
|
||||||
|
page.keyboard.press(key)
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
return {"pressed": key}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Press failed: {e}"}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_screenshot_dir(self, cwd: str = "") -> str:
|
||||||
|
if self._screenshot_dir and os.path.isdir(self._screenshot_dir):
|
||||||
|
return self._screenshot_dir
|
||||||
|
base = cwd or os.getcwd()
|
||||||
|
d = os.path.join(base, "tmp")
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
self._screenshot_dir = d
|
||||||
|
return d
|
||||||
377
agent/tools/browser/browser_tool.py
Normal file
377
agent/tools/browser/browser_tool.py
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
"""
|
||||||
|
Browser tool - Control a Chromium browser for web navigation and interaction.
|
||||||
|
|
||||||
|
Uses Playwright under the hood. Browser instance is lazily started on first
|
||||||
|
use, reused across tool calls within the same session, and cleaned up via
|
||||||
|
close().
|
||||||
|
|
||||||
|
Launch modes (configured under `tools.browser` in config.json):
|
||||||
|
- persistent (default): Chromium runs with a persistent user_data_dir
|
||||||
|
(default `~/.cow/browser_profile`), so cookies and login state survive
|
||||||
|
across runs. The user only needs to log in once.
|
||||||
|
- cdp: When `cdp_endpoint` is set, attach to an externally launched Chrome
|
||||||
|
via the Chrome DevTools Protocol. Lets the agent reuse the user's real
|
||||||
|
browser (with all logins / extensions / true fingerprints).
|
||||||
|
- fresh: Set `persistent` to false to fall back to a clean context every run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
|
from agent.tools.browser.browser_service import BrowserService
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
# Cloud-metadata endpoints worth blocking even though they are not link-local.
|
||||||
|
# (169.254.169.254 — AWS/GCP/Azure IMDS — is already covered by is_link_local;
|
||||||
|
# fd00:ec2::254 is the AWS IPv6 IMDS address.)
|
||||||
|
_CLOUD_METADATA_IPS = frozenset({ipaddress.ip_address("fd00:ec2::254")})
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserTool(BaseTool):
|
||||||
|
"""Single tool exposing all browser actions via an 'action' parameter."""
|
||||||
|
|
||||||
|
name: str = "browser"
|
||||||
|
description: str = (
|
||||||
|
"Control a browser to navigate web pages, interact with elements, and extract content. "
|
||||||
|
"Actions: navigate, snapshot, click, fill, select, scroll, screenshot, wait, back, forward, "
|
||||||
|
"get_text, press, evaluate.\n\n"
|
||||||
|
"Workflow: navigate (auto-includes snapshot with element refs) → click/fill/select by ref → snapshot to verify.\n\n"
|
||||||
|
"Use snapshot as the primary way to read pages. Use screenshot + send to show key results to the user. "
|
||||||
|
"For login/CAPTCHA/authorization etc., screenshot and ask the user for help. "
|
||||||
|
"Login state is persisted across sessions (cookies / localStorage are kept in a "
|
||||||
|
"user profile directory), so once the user logs in to a site, the agent can keep "
|
||||||
|
"using it without logging in again."
|
||||||
|
)
|
||||||
|
|
||||||
|
params: dict = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"The browser action to perform. One of: "
|
||||||
|
"navigate, snapshot, click, fill, select, scroll, "
|
||||||
|
"screenshot, wait, back, forward, get_text, press, evaluate"
|
||||||
|
),
|
||||||
|
"enum": [
|
||||||
|
"navigate", "snapshot", "click", "fill", "select", "scroll",
|
||||||
|
"screenshot", "wait", "back", "forward", "get_text", "press",
|
||||||
|
"evaluate"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "URL to navigate to (for 'navigate' action)"
|
||||||
|
},
|
||||||
|
"ref": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Element ref number from snapshot (for click/fill/select)"
|
||||||
|
},
|
||||||
|
"selector": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "CSS selector as fallback when ref is unavailable (for click/fill/select/wait/get_text)"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Text to type (for 'fill' action)"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Option value (for 'select' action)"
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Key to press, e.g. Enter, Tab, Escape (for 'press' action)"
|
||||||
|
},
|
||||||
|
"direction": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Scroll direction: up, down, left, right (for 'scroll' action, default: down)"
|
||||||
|
},
|
||||||
|
"script": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "JavaScript code to execute (for 'evaluate' action)"
|
||||||
|
},
|
||||||
|
"full_page": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Capture full page screenshot (for 'screenshot' action, default: false)"
|
||||||
|
},
|
||||||
|
"timeout": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Timeout in milliseconds (optional, default varies by action)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["action"]
|
||||||
|
}
|
||||||
|
|
||||||
|
_shared_service: Optional[BrowserService] = None
|
||||||
|
|
||||||
|
def __init__(self, config: dict = None):
|
||||||
|
self.config = config or {}
|
||||||
|
self.cwd = self.config.get("cwd", os.getcwd())
|
||||||
|
self._service: Optional[BrowserService] = None
|
||||||
|
|
||||||
|
def _get_service(self) -> BrowserService:
|
||||||
|
"""Get or create the browser service, sharing across copies."""
|
||||||
|
if self._service is not None:
|
||||||
|
return self._service
|
||||||
|
|
||||||
|
# Reuse shared service across tool copies within the same session
|
||||||
|
if BrowserTool._shared_service is not None:
|
||||||
|
self._service = BrowserTool._shared_service
|
||||||
|
return self._service
|
||||||
|
|
||||||
|
self._service = BrowserService(self.config)
|
||||||
|
BrowserTool._shared_service = self._service
|
||||||
|
return self._service
|
||||||
|
|
||||||
|
def _allow_private_targets(self) -> bool:
|
||||||
|
"""Whether the link-local / cloud-metadata guard is disabled.
|
||||||
|
|
||||||
|
Defaults to False (guard active). Loopback and RFC1918/LAN targets are
|
||||||
|
always reachable so local dev servers work out of the box; this opt-out
|
||||||
|
only lifts the remaining block on link-local / cloud-metadata targets,
|
||||||
|
for an operator who deliberately needs them, by setting
|
||||||
|
``allow_private_targets: true`` under ``tools.browser`` in config.json.
|
||||||
|
"""
|
||||||
|
return bool(self.config.get("allow_private_targets", False))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_url_safe(url: str) -> None:
|
||||||
|
"""Reject URLs that target link-local / cloud-metadata addresses (SSRF guard).
|
||||||
|
|
||||||
|
Resolves the hostname to its IP address(es) and blocks any that are
|
||||||
|
link-local (169.254.0.0/16 — which includes the 169.254.169.254
|
||||||
|
cloud-metadata endpoint — and IPv6 fe80::/10) or a known IPv6
|
||||||
|
cloud-metadata address. Also rejects URLs with no host, non-HTTP(S)
|
||||||
|
schemes, or hosts that fail DNS resolution.
|
||||||
|
|
||||||
|
Loopback and RFC1918/LAN targets are intentionally left reachable:
|
||||||
|
unlike the vision/web_fetch tools, the browser legitimately opens local
|
||||||
|
pages (a dev server on ``localhost`` / ``127.0.0.1`` / a LAN IP), so a
|
||||||
|
blanket "block all internal" policy would break that core workflow.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: if the URL targets a disallowed address.
|
||||||
|
"""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
|
||||||
|
|
||||||
|
hostname = parsed.hostname
|
||||||
|
if not hostname:
|
||||||
|
raise ValueError("URL has no hostname")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Resolve all addresses for the hostname.
|
||||||
|
addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||||
|
except socket.gaierror:
|
||||||
|
raise ValueError(f"Cannot resolve hostname: {hostname}")
|
||||||
|
|
||||||
|
for family, _, _, _, sockaddr in addr_infos:
|
||||||
|
ip_str = sockaddr[0]
|
||||||
|
ip = ipaddress.ip_address(ip_str)
|
||||||
|
# Block only the high-risk targets — link-local (incl. the
|
||||||
|
# 169.254.169.254 cloud-metadata endpoint) and the IPv6 metadata
|
||||||
|
# address. Loopback and RFC1918/LAN stay reachable for local dev.
|
||||||
|
if ip.is_link_local or ip in _CLOUD_METADATA_IPS:
|
||||||
|
raise ValueError(
|
||||||
|
f"URL resolves to a link-local / cloud-metadata address "
|
||||||
|
f"({ip_str}), request blocked for security"
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
action = args.get("action", "").strip().lower()
|
||||||
|
if not action:
|
||||||
|
return ToolResult.fail("Error: 'action' parameter is required")
|
||||||
|
|
||||||
|
handler = self._ACTION_MAP.get(action)
|
||||||
|
if not handler:
|
||||||
|
valid = ", ".join(sorted(self._ACTION_MAP.keys()))
|
||||||
|
return ToolResult.fail(f"Unknown action '{action}'. Valid actions: {valid}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return handler(self, args)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Browser] Action '{action}' error: {e}")
|
||||||
|
return ToolResult.fail(f"Browser error ({action}): {e}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Action handlers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _do_navigate(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
url = args.get("url", "").strip()
|
||||||
|
if not url:
|
||||||
|
return ToolResult.fail("Error: 'url' is required for navigate action")
|
||||||
|
# Only auto-prepend https:// for bare hosts; preserve file://, about:, data:, etc.
|
||||||
|
if "://" not in url and not url.startswith(("about:", "data:")):
|
||||||
|
url = "https://" + url
|
||||||
|
# SSRF guard: for http(s) targets, reject hosts that resolve to
|
||||||
|
# link-local / cloud-metadata addresses before the browser navigates
|
||||||
|
# (and then auto-snapshots the page back to the model). Loopback and
|
||||||
|
# RFC1918/LAN are allowed so local dev servers work. Non-HTTP schemes
|
||||||
|
# (about:/data:/file:/chrome:) are not network-egress targets here.
|
||||||
|
if url.split(":", 1)[0].lower() in ("http", "https") and not self._allow_private_targets():
|
||||||
|
try:
|
||||||
|
self._validate_url_safe(url)
|
||||||
|
except ValueError as e:
|
||||||
|
return ToolResult.fail(f"Error: {e}")
|
||||||
|
timeout = args.get("timeout", 30000)
|
||||||
|
service = self._get_service()
|
||||||
|
result = service.navigate(url, timeout=timeout)
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
# Auto-snapshot after navigation so the agent gets page content in one call
|
||||||
|
snapshot_text = service.snapshot()
|
||||||
|
return ToolResult.success(
|
||||||
|
f"Navigated to: {result['url']}\nTitle: {result['title']}\nStatus: {result['status']}\n\n"
|
||||||
|
f"--- Page Snapshot ---\n{snapshot_text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _do_snapshot(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
selector = args.get("selector")
|
||||||
|
text = self._get_service().snapshot(selector=selector)
|
||||||
|
return ToolResult.success(text)
|
||||||
|
|
||||||
|
def _do_click(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
ref = args.get("ref")
|
||||||
|
selector = args.get("selector")
|
||||||
|
timeout = args.get("timeout", 5000)
|
||||||
|
result = self._get_service().click(ref=ref, selector=selector, timeout=timeout)
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
return ToolResult.success(f"Clicked successfully. Use 'snapshot' to see updated page.")
|
||||||
|
|
||||||
|
def _do_fill(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
text = args.get("text", "")
|
||||||
|
ref = args.get("ref")
|
||||||
|
selector = args.get("selector")
|
||||||
|
timeout = args.get("timeout", 5000)
|
||||||
|
if not text and text != "":
|
||||||
|
return ToolResult.fail("Error: 'text' is required for fill action")
|
||||||
|
result = self._get_service().fill(text, ref=ref, selector=selector, timeout=timeout)
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
return ToolResult.success(f"Filled text into element. Use 'snapshot' to verify.")
|
||||||
|
|
||||||
|
def _do_select(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
value = args.get("value", "")
|
||||||
|
ref = args.get("ref")
|
||||||
|
selector = args.get("selector")
|
||||||
|
timeout = args.get("timeout", 5000)
|
||||||
|
if not value:
|
||||||
|
return ToolResult.fail("Error: 'value' is required for select action")
|
||||||
|
result = self._get_service().select(value, ref=ref, selector=selector, timeout=timeout)
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
return ToolResult.success(f"Selected option '{value}'.")
|
||||||
|
|
||||||
|
def _do_scroll(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
direction = args.get("direction", "down")
|
||||||
|
amount = args.get("timeout", 500) # reuse timeout field or default
|
||||||
|
if "amount" in args:
|
||||||
|
amount = args["amount"]
|
||||||
|
result = self._get_service().scroll(direction=direction, amount=amount)
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
pos = f"scrollY={result.get('scrollY', '?')}/{result.get('scrollHeight', '?')}"
|
||||||
|
return ToolResult.success(f"Scrolled {direction}. Position: {pos}")
|
||||||
|
|
||||||
|
def _do_screenshot(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
full_page = args.get("full_page", False)
|
||||||
|
filepath = self._get_service().screenshot(full_page=full_page, cwd=self.cwd)
|
||||||
|
return ToolResult.success(f"Screenshot saved to: {filepath}")
|
||||||
|
|
||||||
|
def _do_wait(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
selector = args.get("selector")
|
||||||
|
timeout = args.get("timeout", 5000)
|
||||||
|
result = self._get_service().wait(selector=selector, timeout=timeout)
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
return ToolResult.success(f"Wait completed.")
|
||||||
|
|
||||||
|
def _do_back(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
result = self._get_service().go_back()
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
return ToolResult.success(f"Navigated back to: {result['url']}")
|
||||||
|
|
||||||
|
def _do_forward(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
result = self._get_service().go_forward()
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
return ToolResult.success(f"Navigated forward to: {result['url']}")
|
||||||
|
|
||||||
|
def _do_get_text(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
selector = args.get("selector", "").strip()
|
||||||
|
if not selector:
|
||||||
|
return ToolResult.fail("Error: 'selector' is required for get_text action")
|
||||||
|
result = self._get_service().get_text(selector)
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
return ToolResult.success(result["text"])
|
||||||
|
|
||||||
|
def _do_press(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
key = args.get("key", "").strip()
|
||||||
|
if not key:
|
||||||
|
return ToolResult.fail("Error: 'key' is required for press action")
|
||||||
|
result = self._get_service().press(key)
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
return ToolResult.success(f"Pressed key: {key}")
|
||||||
|
|
||||||
|
def _do_evaluate(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
script = args.get("script", "").strip()
|
||||||
|
if not script:
|
||||||
|
return ToolResult.fail("Error: 'script' is required for evaluate action")
|
||||||
|
result = self._get_service().evaluate(script)
|
||||||
|
if "error" in result:
|
||||||
|
return ToolResult.fail(result["error"])
|
||||||
|
val = result.get("result")
|
||||||
|
if isinstance(val, (dict, list)):
|
||||||
|
return ToolResult.success(json.dumps(val, ensure_ascii=False, indent=2))
|
||||||
|
return ToolResult.success(str(val) if val is not None else "(no return value)")
|
||||||
|
|
||||||
|
# Action dispatch table
|
||||||
|
_ACTION_MAP = {
|
||||||
|
"navigate": _do_navigate,
|
||||||
|
"snapshot": _do_snapshot,
|
||||||
|
"click": _do_click,
|
||||||
|
"fill": _do_fill,
|
||||||
|
"select": _do_select,
|
||||||
|
"scroll": _do_scroll,
|
||||||
|
"screenshot": _do_screenshot,
|
||||||
|
"wait": _do_wait,
|
||||||
|
"back": _do_back,
|
||||||
|
"forward": _do_forward,
|
||||||
|
"get_text": _do_get_text,
|
||||||
|
"press": _do_press,
|
||||||
|
"evaluate": _do_evaluate,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def copy(self):
|
||||||
|
"""Share browser instance across tool copies (avoids re-launching)."""
|
||||||
|
new_tool = BrowserTool(self.config)
|
||||||
|
new_tool.model = self.model
|
||||||
|
new_tool.context = getattr(self, "context", None)
|
||||||
|
new_tool.cwd = self.cwd
|
||||||
|
new_tool._service = self._service
|
||||||
|
return new_tool
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Release browser resources."""
|
||||||
|
if self._service:
|
||||||
|
self._service.close()
|
||||||
|
self._service = None
|
||||||
|
BrowserTool._shared_service = None
|
||||||
|
logger.info("[Browser] BrowserTool closed")
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
def copy(self):
|
|
||||||
"""
|
|
||||||
Special copy method for browser tool to avoid recreating browser instance.
|
|
||||||
|
|
||||||
:return: A new instance with shared browser reference but unique model
|
|
||||||
"""
|
|
||||||
new_tool = self.__class__()
|
|
||||||
|
|
||||||
# Copy essential attributes
|
|
||||||
new_tool.model = self.model
|
|
||||||
new_tool.context = getattr(self, 'context', None)
|
|
||||||
new_tool.config = getattr(self, 'config', None)
|
|
||||||
|
|
||||||
# Share the browser instance instead of creating a new one
|
|
||||||
if hasattr(self, 'browser'):
|
|
||||||
new_tool.browser = self.browser
|
|
||||||
|
|
||||||
return new_tool
|
|
||||||
3
agent/tools/evolution_undo/__init__.py
Normal file
3
agent/tools/evolution_undo/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from agent.tools.evolution_undo.evolution_undo import EvolutionUndoTool
|
||||||
|
|
||||||
|
__all__ = ["EvolutionUndoTool"]
|
||||||
58
agent/tools/evolution_undo/evolution_undo.py
Normal file
58
agent/tools/evolution_undo/evolution_undo.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""Evolution undo tool.
|
||||||
|
|
||||||
|
Lets the main chat agent roll back a previous self-evolution when the user asks
|
||||||
|
("undo the last learning"). The rollback itself is a deterministic FILE RESTORE
|
||||||
|
from the snapshot taken before the evolution — the model only supplies the
|
||||||
|
backup_id it reads from the [EVOLUTION] record in the conversation. No LLM-driven
|
||||||
|
re-editing is involved, so a restore can never make things worse.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
|
|
||||||
|
|
||||||
|
class EvolutionUndoTool(BaseTool):
|
||||||
|
"""Restore memory/skill files to the state before a self-evolution."""
|
||||||
|
|
||||||
|
name: str = "evolution_undo"
|
||||||
|
description: str = (
|
||||||
|
"Undo a previous self-evolution (self-learning) by restoring the "
|
||||||
|
"memory/skill files to their state before that learning. Use this when "
|
||||||
|
"the user asks to undo / revert / roll back the last self-learning. "
|
||||||
|
"Find the backup_id in the most recent [EVOLUTION] record in the "
|
||||||
|
"conversation and pass it here."
|
||||||
|
)
|
||||||
|
params: dict = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"backup_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": (
|
||||||
|
"The backup_id from the [EVOLUTION] record to restore "
|
||||||
|
"(e.g. '20260607-155551-850')."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["backup_id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(self, args: dict):
|
||||||
|
backup_id = (args.get("backup_id") or "").strip()
|
||||||
|
if not backup_id:
|
||||||
|
return ToolResult.fail("Error: backup_id is required")
|
||||||
|
try:
|
||||||
|
from agent.memory.config import get_default_memory_config
|
||||||
|
from agent.evolution.backup import restore_backup
|
||||||
|
|
||||||
|
workspace_dir = get_default_memory_config().get_workspace()
|
||||||
|
ok = restore_backup(workspace_dir, backup_id)
|
||||||
|
if ok:
|
||||||
|
return ToolResult.success(
|
||||||
|
f"Restored memory/skills to the state before evolution "
|
||||||
|
f"{backup_id}. The previous self-learning has been undone."
|
||||||
|
)
|
||||||
|
return ToolResult.fail(
|
||||||
|
f"Could not find or restore backup {backup_id}. It may have "
|
||||||
|
f"expired or already been rolled back."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return ToolResult.fail(f"Error during undo: {e}")
|
||||||
4
agent/tools/mcp/__init__.py
Normal file
4
agent/tools/mcp/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from agent.tools.mcp.mcp_client import McpClient, McpClientRegistry
|
||||||
|
from agent.tools.mcp.mcp_tool import McpTool
|
||||||
|
|
||||||
|
__all__ = ["McpClient", "McpClientRegistry", "McpTool"]
|
||||||
582
agent/tools/mcp/mcp_client.py
Normal file
582
agent/tools/mcp/mcp_client.py
Normal file
@@ -0,0 +1,582 @@
|
|||||||
|
"""
|
||||||
|
MCP (Model Context Protocol) client module.
|
||||||
|
|
||||||
|
Implements JSON-RPC 2.0 over stdio, SSE and Streamable HTTP transports
|
||||||
|
without any external MCP SDK dependency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
# Aliases accepted for the Streamable HTTP transport type
|
||||||
|
_STREAMABLE_HTTP_ALIASES = {"streamable-http", "streamable_http", "streamablehttp", "http"}
|
||||||
|
|
||||||
|
|
||||||
|
class McpClient:
|
||||||
|
"""Single MCP Server client supporting stdio, SSE and Streamable HTTP transports."""
|
||||||
|
|
||||||
|
def __init__(self, config: dict):
|
||||||
|
"""
|
||||||
|
config examples:
|
||||||
|
stdio: {"name": "filesystem", "type": "stdio", "command": "npx", "args": [...]}
|
||||||
|
SSE: {"name": "my-api", "type": "sse", "url": "http://localhost:8000/sse"}
|
||||||
|
streamable-http: {"name": "pubmed", "type": "streamable-http", "url": "https://x/mcp"}
|
||||||
|
"""
|
||||||
|
self.config = config
|
||||||
|
self.name: str = config.get("name", "unknown")
|
||||||
|
raw_transport: str = config.get("type", "stdio")
|
||||||
|
# Per-server timeout for tool calls (default 120s, suitable for data queries)
|
||||||
|
self._timeout: int = int(config.get("timeout", 120))
|
||||||
|
# Normalize streamable-http aliases to a single internal key
|
||||||
|
self.transport: str = (
|
||||||
|
"streamable-http"
|
||||||
|
if raw_transport.lower() in _STREAMABLE_HTTP_ALIASES
|
||||||
|
else raw_transport
|
||||||
|
)
|
||||||
|
|
||||||
|
# stdio state
|
||||||
|
self._proc: Optional[subprocess.Popen] = None
|
||||||
|
self._read_queue: queue.Queue = queue.Queue()
|
||||||
|
|
||||||
|
# SSE state
|
||||||
|
self._sse_url: Optional[str] = None
|
||||||
|
self._post_url: Optional[str] = None # endpoint for sending messages (resolved from SSE)
|
||||||
|
|
||||||
|
# Streamable HTTP state
|
||||||
|
self._http_url: Optional[str] = None
|
||||||
|
self._http_headers: dict = {} # extra headers from user config (e.g. Authorization)
|
||||||
|
self._http_session_id: Optional[str] = None # Mcp-Session-Id assigned by the server
|
||||||
|
|
||||||
|
# Shared state
|
||||||
|
self._next_id = 1
|
||||||
|
self._id_lock = threading.Lock()
|
||||||
|
# _call_lock serializes all requests on the single stdio pipe.
|
||||||
|
# SSE and streamable-http use independent HTTP requests, so they
|
||||||
|
# do not acquire this lock (see _send_request).
|
||||||
|
self._call_lock = threading.Lock()
|
||||||
|
# _http_lock protects _http_session_id initialization across
|
||||||
|
# concurrent streamable-http requests.
|
||||||
|
self._http_lock = threading.Lock()
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public interface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def initialize(self) -> bool:
|
||||||
|
"""Connect and perform the MCP handshake. Returns True on success."""
|
||||||
|
try:
|
||||||
|
if self.transport == "stdio":
|
||||||
|
return self._init_stdio()
|
||||||
|
elif self.transport == "sse":
|
||||||
|
return self._init_sse()
|
||||||
|
elif self.transport == "streamable-http":
|
||||||
|
return self._init_streamable_http()
|
||||||
|
else:
|
||||||
|
logger.warning(f"[MCP:{self.name}] Unknown transport type: {self.transport!r}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[MCP:{self.name}] Initialization failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def list_tools(self) -> list:
|
||||||
|
"""Return the tool list from this server.
|
||||||
|
|
||||||
|
Each item is a dict: {"name": str, "description": str, "inputSchema": dict}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resp = self._send_request("tools/list", {})
|
||||||
|
tools = resp.get("result", {}).get("tools", [])
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": t.get("name", ""),
|
||||||
|
"description": t.get("description", ""),
|
||||||
|
"inputSchema": t.get("inputSchema", {}),
|
||||||
|
}
|
||||||
|
for t in tools
|
||||||
|
]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[MCP:{self.name}] list_tools failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def call_tool(self, name: str, arguments: dict) -> str:
|
||||||
|
"""Call a tool and return the result as a string."""
|
||||||
|
try:
|
||||||
|
resp = self._send_request("tools/call", {"name": name, "arguments": arguments})
|
||||||
|
content = resp.get("result", {}).get("content", [])
|
||||||
|
parts = [item.get("text", "") for item in content if item.get("type") == "text"]
|
||||||
|
return "\n".join(parts)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[MCP:{self.name}] call_tool({name}) failed: {e}")
|
||||||
|
return f"Error: {e}"
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
"""Close the connection / terminate the child process."""
|
||||||
|
if self._proc is not None:
|
||||||
|
try:
|
||||||
|
self._proc.stdin.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self._proc.terminate()
|
||||||
|
self._proc.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self._proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._proc = None
|
||||||
|
logger.debug(f"[MCP:{self.name}] stdio process terminated")
|
||||||
|
|
||||||
|
# Best-effort streamable-http session termination
|
||||||
|
if self.transport == "streamable-http" and self._http_session_id and self._http_url:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
self._http_url,
|
||||||
|
method="DELETE",
|
||||||
|
headers={"Mcp-Session-Id": self._http_session_id, **self._http_headers},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=5):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._http_session_id = None
|
||||||
|
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# stdio transport
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _init_stdio(self) -> bool:
|
||||||
|
command = self.config.get("command")
|
||||||
|
if not command:
|
||||||
|
logger.warning(f"[MCP:{self.name}] stdio config missing 'command'")
|
||||||
|
return False
|
||||||
|
|
||||||
|
args = self.config.get("args", [])
|
||||||
|
extra_env = self.config.get("env", None)
|
||||||
|
env = {**os.environ, **extra_env} if extra_env else None
|
||||||
|
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
[command] + list(args),
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
logger.debug(f"[MCP:{self.name}] stdio process started (pid={self._proc.pid})")
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=self._drain_stderr, daemon=True, name=f"mcp-stderr-{self.name}"
|
||||||
|
).start()
|
||||||
|
threading.Thread(
|
||||||
|
target=self._drain_stdout, daemon=True, name=f"mcp-stdout-{self.name}"
|
||||||
|
).start()
|
||||||
|
|
||||||
|
return self._handshake()
|
||||||
|
|
||||||
|
def _drain_stderr(self):
|
||||||
|
for line in self._proc.stderr:
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
logger.warning(f"[MCP:{self.name}] stderr: {line}")
|
||||||
|
|
||||||
|
def _drain_stdout(self):
|
||||||
|
"""Background thread: read lines from stdout and put them into the queue."""
|
||||||
|
try:
|
||||||
|
for line in self._proc.stdout:
|
||||||
|
self._read_queue.put(line)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
self._read_queue.put("")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _readline_with_timeout(self, timeout: Optional[int] = None) -> str:
|
||||||
|
"""Read one line from stdio stdout with a hard timeout (cross-platform).
|
||||||
|
|
||||||
|
Uses the per-server timeout from mcp.json config when no explicit
|
||||||
|
timeout is provided.
|
||||||
|
"""
|
||||||
|
effective = timeout if timeout is not None else self._timeout
|
||||||
|
try:
|
||||||
|
line = self._read_queue.get(timeout=effective)
|
||||||
|
except queue.Empty:
|
||||||
|
raise TimeoutError(f"[MCP:{self.name}] stdio read timed out after {effective}s")
|
||||||
|
if not line:
|
||||||
|
raise IOError(f"[MCP:{self.name}] stdio process closed unexpectedly")
|
||||||
|
return line
|
||||||
|
|
||||||
|
def _stdio_send(self, message: dict) -> dict:
|
||||||
|
"""Send a JSON-RPC message over stdio and read the response."""
|
||||||
|
raw = json.dumps(message) + "\n"
|
||||||
|
self._proc.stdin.write(raw)
|
||||||
|
self._proc.stdin.flush()
|
||||||
|
|
||||||
|
expected_id = message.get("id")
|
||||||
|
while True:
|
||||||
|
line = self._readline_with_timeout()
|
||||||
|
if not line:
|
||||||
|
raise IOError(f"[MCP:{self.name}] stdio process closed unexpectedly")
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if "id" not in data:
|
||||||
|
logger.debug(f"[MCP:{self.name}] notification skipped: {data.get('method', '?')}")
|
||||||
|
continue
|
||||||
|
# Verify response id matches request id to avoid consuming a stale
|
||||||
|
# response left over from a previously failed/timed-out request.
|
||||||
|
if data.get("id") != expected_id:
|
||||||
|
logger.warning(
|
||||||
|
f"[MCP:{self.name}] Stale response id={data.get('id')} "
|
||||||
|
f"(expected {expected_id}), skipping"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
return data
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# SSE transport
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _init_sse(self) -> bool:
|
||||||
|
url = self.config.get("url")
|
||||||
|
if not url:
|
||||||
|
logger.warning(f"[MCP:{self.name}] SSE config missing 'url'")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._sse_url = url
|
||||||
|
|
||||||
|
# Read the first SSE event to discover the POST endpoint
|
||||||
|
try:
|
||||||
|
self._post_url = self._sse_discover_endpoint()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[MCP:{self.name}] SSE endpoint discovery failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self._handshake()
|
||||||
|
|
||||||
|
def _sse_discover_endpoint(self) -> str:
|
||||||
|
"""Open SSE stream and read the 'endpoint' event to learn the POST URL."""
|
||||||
|
req = urllib.request.Request(
|
||||||
|
self._sse_url,
|
||||||
|
headers={"Accept": "text/event-stream"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
for raw_line in resp:
|
||||||
|
line = raw_line.decode("utf-8").rstrip("\n\r")
|
||||||
|
if line.startswith("data:"):
|
||||||
|
data = line[len("data:"):].strip()
|
||||||
|
# Some servers send JSON with a "uri" or plain path
|
||||||
|
if data.startswith("{"):
|
||||||
|
parsed = json.loads(data)
|
||||||
|
return parsed.get("uri") or parsed.get("url") or parsed.get("endpoint")
|
||||||
|
# Plain relative or absolute URL
|
||||||
|
if data.startswith("http"):
|
||||||
|
return data
|
||||||
|
# Relative path: resolve against SSE base
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
return urljoin(self._sse_url, data)
|
||||||
|
raise ValueError(f"[MCP:{self.name}] No endpoint event received from SSE stream")
|
||||||
|
|
||||||
|
def _sse_send(self, message: dict) -> dict:
|
||||||
|
"""POST a JSON-RPC message to the server and return the response."""
|
||||||
|
body = json.dumps(message).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
self._post_url,
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
raw = resp.read().decode("utf-8")
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Streamable HTTP transport (MCP spec 2025-03-26)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _init_streamable_http(self) -> bool:
|
||||||
|
url = self.config.get("url")
|
||||||
|
if not url:
|
||||||
|
logger.warning(f"[MCP:{self.name}] streamable-http config missing 'url'")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._http_url = url
|
||||||
|
# Allow user-provided headers (e.g. {"Authorization": "Bearer xxx"})
|
||||||
|
extra_headers = self.config.get("headers") or {}
|
||||||
|
if isinstance(extra_headers, dict):
|
||||||
|
self._http_headers = {str(k): str(v) for k, v in extra_headers.items()}
|
||||||
|
|
||||||
|
return self._handshake()
|
||||||
|
|
||||||
|
def _streamable_http_send(self, message: dict) -> dict:
|
||||||
|
"""POST a JSON-RPC request and return the response (JSON or SSE-wrapped)."""
|
||||||
|
return self._streamable_http_post(message, expect_response=True)
|
||||||
|
|
||||||
|
def _streamable_http_post(self, message: dict, expect_response: bool) -> dict:
|
||||||
|
"""
|
||||||
|
POST a JSON-RPC message over Streamable HTTP.
|
||||||
|
|
||||||
|
Per the spec, the response Content-Type can be either:
|
||||||
|
- application/json -> single JSON-RPC response in body
|
||||||
|
- text/event-stream -> SSE stream; we read until we get a matching response
|
||||||
|
"""
|
||||||
|
body = json.dumps(message).encode("utf-8")
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json, text/event-stream",
|
||||||
|
}
|
||||||
|
# Read session id under lock to avoid racing with the
|
||||||
|
# initialization write below during concurrent requests.
|
||||||
|
with self._http_lock:
|
||||||
|
sid = self._http_session_id
|
||||||
|
if sid:
|
||||||
|
headers["Mcp-Session-Id"] = sid
|
||||||
|
headers.update(self._http_headers)
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
self._http_url,
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = urllib.request.urlopen(req, timeout=30)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
# Surface the server-provided error body for easier debugging
|
||||||
|
detail = ""
|
||||||
|
try:
|
||||||
|
detail = e.read().decode("utf-8", errors="ignore")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise IOError(
|
||||||
|
f"[MCP:{self.name}] streamable-http HTTP {e.code}: {detail[:200]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
with resp:
|
||||||
|
# Capture session id assigned by the server (if any)
|
||||||
|
session_id = resp.headers.get("Mcp-Session-Id")
|
||||||
|
# Double-checked lock: only the first response sets the
|
||||||
|
# session id, preventing concurrent initializers from
|
||||||
|
# overwriting each other.
|
||||||
|
if session_id and not self._http_session_id:
|
||||||
|
with self._http_lock:
|
||||||
|
if not self._http_session_id:
|
||||||
|
self._http_session_id = session_id
|
||||||
|
|
||||||
|
status = resp.status if hasattr(resp, "status") else resp.getcode()
|
||||||
|
|
||||||
|
# Notifications: server may reply with 202 Accepted and no body
|
||||||
|
if not expect_response or status == 202:
|
||||||
|
try:
|
||||||
|
resp.read()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
content_type = (resp.headers.get("Content-Type") or "").lower()
|
||||||
|
expected_id = message.get("id")
|
||||||
|
|
||||||
|
if "text/event-stream" in content_type:
|
||||||
|
return self._read_sse_response(resp, expected_id)
|
||||||
|
|
||||||
|
raw = resp.read().decode("utf-8")
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
def _read_sse_response(self, resp, expected_id) -> dict:
|
||||||
|
"""Read an SSE stream and return the first JSON-RPC response with matching id."""
|
||||||
|
data_buf: list = []
|
||||||
|
for raw_line in resp:
|
||||||
|
line = raw_line.decode("utf-8").rstrip("\n\r")
|
||||||
|
if line == "":
|
||||||
|
# End of an SSE event, attempt to parse accumulated data
|
||||||
|
if data_buf:
|
||||||
|
payload = "\n".join(data_buf)
|
||||||
|
data_buf = []
|
||||||
|
try:
|
||||||
|
msg = json.loads(payload)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
# Skip notifications / mismatched ids
|
||||||
|
if "id" not in msg:
|
||||||
|
continue
|
||||||
|
if expected_id is None or msg.get("id") == expected_id:
|
||||||
|
return msg
|
||||||
|
continue
|
||||||
|
if line.startswith(":"):
|
||||||
|
continue # SSE comment / keepalive
|
||||||
|
if line.startswith("data:"):
|
||||||
|
data_buf.append(line[len("data:"):].lstrip())
|
||||||
|
# Ignore 'event:' / 'id:' lines; we only care about JSON-RPC payloads
|
||||||
|
|
||||||
|
raise IOError(f"[MCP:{self.name}] streamable-http SSE stream closed before response")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Common JSON-RPC helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _next_request_id(self) -> int:
|
||||||
|
with self._id_lock:
|
||||||
|
rid = self._next_id
|
||||||
|
self._next_id += 1
|
||||||
|
return rid
|
||||||
|
|
||||||
|
def _build_request(self, method: str, params: dict) -> dict:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": self._next_request_id(),
|
||||||
|
"method": method,
|
||||||
|
"params": params,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_notification(self, method: str, params: dict) -> dict:
|
||||||
|
return {"jsonrpc": "2.0", "method": method, "params": params}
|
||||||
|
|
||||||
|
def _send_request(self, method: str, params: dict) -> dict:
|
||||||
|
"""Send a request and return the full response dict."""
|
||||||
|
if not self._initialized and method != "initialize":
|
||||||
|
raise RuntimeError(f"[MCP:{self.name}] Client not initialized")
|
||||||
|
|
||||||
|
message = self._build_request(method, params)
|
||||||
|
|
||||||
|
# stdio transport uses a single pipe and must be serialized.
|
||||||
|
# SSE and streamable-http use independent HTTP requests and
|
||||||
|
# can safely run concurrently across sessions.
|
||||||
|
if self.transport == "stdio":
|
||||||
|
with self._call_lock:
|
||||||
|
return self._stdio_send(message)
|
||||||
|
elif self.transport == "sse":
|
||||||
|
return self._sse_send(message)
|
||||||
|
elif self.transport == "streamable-http":
|
||||||
|
return self._streamable_http_send(message)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"[MCP:{self.name}] Unsupported transport: {self.transport}")
|
||||||
|
|
||||||
|
def _send_notification(self, method: str, params: dict):
|
||||||
|
"""Fire-and-forget notification (no response expected)."""
|
||||||
|
notification = self._build_notification(method, params)
|
||||||
|
raw = json.dumps(notification) + "\n"
|
||||||
|
|
||||||
|
if self.transport == "stdio":
|
||||||
|
self._proc.stdin.write(raw)
|
||||||
|
self._proc.stdin.flush()
|
||||||
|
elif self.transport == "sse":
|
||||||
|
body = raw.encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
self._post_url,
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass # notifications are fire-and-forget
|
||||||
|
elif self.transport == "streamable-http":
|
||||||
|
try:
|
||||||
|
self._streamable_http_post(notification, expect_response=False)
|
||||||
|
except Exception:
|
||||||
|
pass # notifications are fire-and-forget
|
||||||
|
|
||||||
|
def _handshake(self) -> bool:
|
||||||
|
"""Perform the MCP initialize / notifications/initialized handshake."""
|
||||||
|
init_params = {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": "CowAgent", "version": "1.0"},
|
||||||
|
}
|
||||||
|
# Temporarily mark as initialized so _send_request doesn't block
|
||||||
|
self._initialized = True
|
||||||
|
try:
|
||||||
|
resp = self._send_request("initialize", init_params)
|
||||||
|
except Exception as e:
|
||||||
|
self._initialized = False
|
||||||
|
logger.warning(f"[MCP:{self.name}] Handshake initialize failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if "error" in resp:
|
||||||
|
self._initialized = False
|
||||||
|
logger.warning(f"[MCP:{self.name}] Handshake error: {resp['error']}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._send_notification("notifications/initialized", {})
|
||||||
|
logger.debug(f"[MCP:{self.name}] Handshake complete")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class McpClientRegistry:
|
||||||
|
"""Global singleton managing the lifecycle of all MCP Server clients."""
|
||||||
|
|
||||||
|
_instance = None
|
||||||
|
_instance_lock = threading.Lock()
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
with cls._instance_lock:
|
||||||
|
if cls._instance is None:
|
||||||
|
obj = super().__new__(cls)
|
||||||
|
obj._clients: dict[str, McpClient] = {}
|
||||||
|
obj._registry_lock = threading.Lock()
|
||||||
|
cls._instance = obj
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def start_all(self, configs: list) -> None:
|
||||||
|
"""Initialize McpClient for each config entry; skip failures with a warning."""
|
||||||
|
if not configs:
|
||||||
|
return
|
||||||
|
|
||||||
|
for cfg in configs:
|
||||||
|
name = cfg.get("name", "<unnamed>")
|
||||||
|
client = McpClient(cfg)
|
||||||
|
ok = client.initialize()
|
||||||
|
if ok:
|
||||||
|
with self._registry_lock:
|
||||||
|
self._clients[name] = client
|
||||||
|
logger.info(f"[MCP] Server '{name}' initialized successfully")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[MCP] Server '{name}' failed to initialize — skipping")
|
||||||
|
|
||||||
|
def get(self, server_name: str) -> Optional[McpClient]:
|
||||||
|
"""Return the initialized client for server_name, or None."""
|
||||||
|
with self._registry_lock:
|
||||||
|
return self._clients.get(server_name)
|
||||||
|
|
||||||
|
def all_clients(self) -> dict:
|
||||||
|
"""Return a copy of the {name: McpClient} mapping."""
|
||||||
|
with self._registry_lock:
|
||||||
|
return dict(self._clients)
|
||||||
|
|
||||||
|
def shutdown_all(self) -> None:
|
||||||
|
"""Shut down all managed clients."""
|
||||||
|
with self._registry_lock:
|
||||||
|
clients = list(self._clients.values())
|
||||||
|
self._clients.clear()
|
||||||
|
|
||||||
|
for client in clients:
|
||||||
|
try:
|
||||||
|
client.shutdown()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[MCP] Error shutting down '{client.name}': {e}")
|
||||||
|
|
||||||
|
logger.info("[MCP] All servers shut down")
|
||||||
31
agent/tools/mcp/mcp_tool.py
Normal file
31
agent/tools/mcp/mcp_tool.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
class McpTool(BaseTool):
|
||||||
|
"""
|
||||||
|
将单个 MCP 工具包装为 BaseTool。
|
||||||
|
一个 MCP Server 可以提供多个工具,每个工具对应一个 McpTool 实例。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, client, tool_schema: dict, server_name: str):
|
||||||
|
"""
|
||||||
|
:param client: 该工具所属的 McpClient 实例
|
||||||
|
:param tool_schema: MCP 返回的工具描述,格式:
|
||||||
|
{"name": str, "description": str, "inputSchema": dict}
|
||||||
|
:param server_name: Server 名称,用于日志
|
||||||
|
"""
|
||||||
|
self.client = client
|
||||||
|
self.server_name = server_name
|
||||||
|
self.name = tool_schema["name"]
|
||||||
|
self.description = tool_schema.get("description", "")
|
||||||
|
self.params = tool_schema.get("inputSchema", {})
|
||||||
|
|
||||||
|
def execute(self, params: dict) -> ToolResult:
|
||||||
|
logger.info(f"[McpTool] server={self.server_name} tool={self.name} params={params}")
|
||||||
|
try:
|
||||||
|
result = self.client.call_tool(self.name, params)
|
||||||
|
return ToolResult.success(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[McpTool] server={self.server_name} tool={self.name} error: {e}")
|
||||||
|
return ToolResult.fail(str(e))
|
||||||
159
agent/tools/mcp/tool_retrieval.py
Normal file
159
agent/tools/mcp/tool_retrieval.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# encoding:utf-8
|
||||||
|
"""
|
||||||
|
On-demand MCP tool retrieval.
|
||||||
|
|
||||||
|
Pure, stateless selection helpers used by the streaming executor to decide
|
||||||
|
which MCP tools to inject into a given LLM turn. Vector precompute + caching
|
||||||
|
live in ToolManager (the tool-lifecycle owner, a process-wide singleton);
|
||||||
|
only the context-aware selection lives here, because only the executor knows
|
||||||
|
the conversation context.
|
||||||
|
|
||||||
|
Invariants (per maintainer review of the feature proposal):
|
||||||
|
* Built-in tools are never handled here — the caller injects them in full.
|
||||||
|
* Any failure / missing input returns None so the caller falls back to
|
||||||
|
full injection; tools must never be silently dropped.
|
||||||
|
* Selection is union-accumulated across turns by the caller (only-grows),
|
||||||
|
so a tool that already produced a tool_use in the message history can
|
||||||
|
never disappear from the schema mid-run (which would make Claude/MiniMax
|
||||||
|
raise a message-format error).
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
from typing import Dict, List, Optional, Sequence, Set
|
||||||
|
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
_HAS_NUMPY = True
|
||||||
|
except ImportError:
|
||||||
|
_HAS_NUMPY = False
|
||||||
|
|
||||||
|
# How many trailing messages to concatenate into the retrieval query. Tool
|
||||||
|
# needs drift across a multi-turn tool-call loop, so a single (initial) user
|
||||||
|
# query is not enough; a short recent window captures the drift without
|
||||||
|
# bloating the query with stale context.
|
||||||
|
DEFAULT_QUERY_MESSAGES = 5
|
||||||
|
|
||||||
|
|
||||||
|
def build_retrieval_query(messages: list, max_messages: int = DEFAULT_QUERY_MESSAGES) -> str:
|
||||||
|
"""Concatenate the text of the most recent messages into a retrieval query.
|
||||||
|
|
||||||
|
Only ``text`` content blocks are kept; ``tool_use`` / ``tool_result`` blocks
|
||||||
|
are skipped so the query stays short and focused on natural-language intent
|
||||||
|
rather than large serialized tool payloads.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: Claude-style message list, each ``{"role", "content"}`` where
|
||||||
|
content is either a string or a list of typed blocks.
|
||||||
|
max_messages: Size of the trailing window to consider.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A single string (possibly empty if no text is found).
|
||||||
|
"""
|
||||||
|
if not messages:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
parts: List[str] = []
|
||||||
|
for message in messages[-max_messages:]:
|
||||||
|
content = message.get("content") if isinstance(message, dict) else None
|
||||||
|
if isinstance(content, str):
|
||||||
|
if content.strip():
|
||||||
|
parts.append(content.strip())
|
||||||
|
continue
|
||||||
|
if isinstance(content, list):
|
||||||
|
for block in content:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
if block.get("type") == "text":
|
||||||
|
text = block.get("text", "")
|
||||||
|
if isinstance(text, str) and text.strip():
|
||||||
|
parts.append(text.strip())
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def cosine_similarity(a: Sequence[float], b: Sequence[float]) -> float:
|
||||||
|
"""Cosine similarity of two equal-length vectors; 0.0 on degenerate input."""
|
||||||
|
if not a or not b or len(a) != len(b):
|
||||||
|
return 0.0
|
||||||
|
dot = sum(x * y for x, y in zip(a, b))
|
||||||
|
norm_a = math.sqrt(sum(x * x for x in a))
|
||||||
|
norm_b = math.sqrt(sum(y * y for y in b))
|
||||||
|
if norm_a == 0 or norm_b == 0:
|
||||||
|
return 0.0
|
||||||
|
return dot / (norm_a * norm_b)
|
||||||
|
|
||||||
|
|
||||||
|
def select_mcp_tools(
|
||||||
|
query_vector: Optional[Sequence[float]],
|
||||||
|
tool_vectors: Dict[str, Sequence[float]],
|
||||||
|
top_k: int,
|
||||||
|
already_selected: Optional[Set[str]] = None,
|
||||||
|
) -> Optional[Set[str]]:
|
||||||
|
"""Return the accumulated set of MCP tool names to inject this turn.
|
||||||
|
|
||||||
|
Computes cosine similarity between ``query_vector`` and each candidate
|
||||||
|
tool vector, keeps the ``top_k`` best, and unions them with
|
||||||
|
``already_selected`` so the injected set only ever grows within a run.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query_vector: Embedding of the current retrieval query, or None.
|
||||||
|
tool_vectors: ``{mcp_tool_name: vector}`` for candidate MCP tools.
|
||||||
|
top_k: Max number of tools to add from this turn's ranking.
|
||||||
|
already_selected: Names accumulated in previous turns of this run.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The union set of tool names to inject, or None to signal
|
||||||
|
"fall back to full injection" (no query vector, empty/invalid index,
|
||||||
|
or any unexpected error). This function never raises.
|
||||||
|
"""
|
||||||
|
accumulated: Set[str] = set(already_selected) if already_selected else set()
|
||||||
|
|
||||||
|
if not query_vector or not tool_vectors or top_k <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
expected_dim = len(query_vector)
|
||||||
|
# Only rank candidates whose vector dimensionality matches the query.
|
||||||
|
# A dimension mismatch means the index was built with a different
|
||||||
|
# embedding model; ranking across dims is meaningless.
|
||||||
|
candidates = {
|
||||||
|
name: vec
|
||||||
|
for name, vec in tool_vectors.items()
|
||||||
|
if vec and len(vec) == expected_dim
|
||||||
|
}
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ranked = _rank_by_similarity(query_vector, candidates)
|
||||||
|
for name, _score in ranked[:top_k]:
|
||||||
|
accumulated.add(name)
|
||||||
|
return accumulated
|
||||||
|
except Exception:
|
||||||
|
# Selection must never break the agent — fall back to full injection.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _rank_by_similarity(
|
||||||
|
query_vector: Sequence[float],
|
||||||
|
candidates: Dict[str, Sequence[float]],
|
||||||
|
) -> List[tuple]:
|
||||||
|
"""Return ``[(name, score), ...]`` sorted by descending cosine similarity.
|
||||||
|
|
||||||
|
Uses numpy when available (vectorized, matching the memory-search path),
|
||||||
|
with a pure-Python fallback so the feature works without numpy installed.
|
||||||
|
"""
|
||||||
|
names = list(candidates.keys())
|
||||||
|
|
||||||
|
if _HAS_NUMPY:
|
||||||
|
matrix = np.array([candidates[n] for n in names], dtype=np.float32) # (N, D)
|
||||||
|
q_vec = np.array(query_vector, dtype=np.float32) # (D,)
|
||||||
|
dots = matrix @ q_vec # (N,)
|
||||||
|
row_norms = np.linalg.norm(matrix, axis=1) # (N,)
|
||||||
|
q_norm = float(np.linalg.norm(q_vec))
|
||||||
|
denominators = row_norms * q_norm
|
||||||
|
np.maximum(denominators, 1e-10, out=denominators) # avoid div-by-zero
|
||||||
|
sims = dots / denominators
|
||||||
|
order = np.argsort(sims)[::-1]
|
||||||
|
return [(names[i], float(sims[i])) for i in order]
|
||||||
|
|
||||||
|
scored = [(n, cosine_similarity(query_vector, candidates[n])) for n in names]
|
||||||
|
scored.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
return scored
|
||||||
@@ -4,6 +4,8 @@ Memory get tool
|
|||||||
Allows agents to read specific sections from memory files
|
Allows agents to read specific sections from memory files
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
from agent.tools.base_tool import BaseTool
|
from agent.tools.base_tool import BaseTool
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +46,19 @@ class MemoryGetTool(BaseTool):
|
|||||||
"""
|
"""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.memory_manager = memory_manager
|
self.memory_manager = memory_manager
|
||||||
|
|
||||||
|
from config import conf
|
||||||
|
if conf().get("knowledge", True):
|
||||||
|
self.description = (
|
||||||
|
"Read specific content from memory or knowledge files. "
|
||||||
|
"Use this to get full context from a memory file, knowledge page, or specific line range."
|
||||||
|
)
|
||||||
|
self.params = {**self.params}
|
||||||
|
self.params["properties"] = {**self.params["properties"]}
|
||||||
|
self.params["properties"]["path"] = {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Relative path to the memory or knowledge file (e.g. 'MEMORY.md', 'memory/2026-01-01.md', 'knowledge/concepts/moe.md')"
|
||||||
|
}
|
||||||
|
|
||||||
def execute(self, args: dict):
|
def execute(self, args: dict):
|
||||||
"""
|
"""
|
||||||
@@ -68,11 +83,20 @@ class MemoryGetTool(BaseTool):
|
|||||||
workspace_dir = self.memory_manager.config.get_workspace()
|
workspace_dir = self.memory_manager.config.get_workspace()
|
||||||
|
|
||||||
# Auto-prepend memory/ if not present and not absolute path
|
# Auto-prepend memory/ if not present and not absolute path
|
||||||
# Exception: MEMORY.md is in the root directory
|
# Exceptions: MEMORY.md in root, knowledge/ files at workspace root
|
||||||
if not path.startswith('memory/') and not path.startswith('/') and path != 'MEMORY.md':
|
if not path.startswith('memory/') and not path.startswith('knowledge/') and not path.startswith('/') and path != 'MEMORY.md':
|
||||||
path = f'memory/{path}'
|
path = f'memory/{path}'
|
||||||
|
|
||||||
file_path = workspace_dir / path
|
file_path = (workspace_dir / path).resolve()
|
||||||
|
workspace_resolved = workspace_dir.resolve()
|
||||||
|
|
||||||
|
# Use os.path.realpath + os.sep for cross-platform path validation.
|
||||||
|
# str(Path).startswith(str + '/') fails on Windows where Path uses
|
||||||
|
# backslashes — see MemoryService._resolve_path for the same pattern.
|
||||||
|
real_file = os.path.realpath(str(file_path))
|
||||||
|
real_workspace = os.path.realpath(str(workspace_resolved))
|
||||||
|
if real_file != real_workspace and not real_file.startswith(real_workspace + os.sep):
|
||||||
|
return ToolResult.fail(f"Error: Access denied: path outside workspace")
|
||||||
|
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
return ToolResult.fail(f"Error: File not found: {path}")
|
return ToolResult.fail(f"Error: File not found: {path}")
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ class MemorySearchTool(BaseTool):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.memory_manager = memory_manager
|
self.memory_manager = memory_manager
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
|
|
||||||
|
from config import conf
|
||||||
|
if conf().get("knowledge", True):
|
||||||
|
self.description = (
|
||||||
|
"Search agent's long-term memory and knowledge base using semantic and keyword search. "
|
||||||
|
"Use this to recall past conversations, preferences, and knowledge pages."
|
||||||
|
)
|
||||||
|
|
||||||
def execute(self, args: dict):
|
def execute(self, args: dict):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Supports text files, images (jpg, png, gif, webp), and PDF files
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -12,11 +13,17 @@ from agent.tools.utils.truncate import truncate_head, format_size, DEFAULT_MAX_L
|
|||||||
from common.utils import expand_path
|
from common.utils import expand_path
|
||||||
|
|
||||||
|
|
||||||
|
# Paths whose CONTENT mirrors the process environment (and thus any secrets
|
||||||
|
# loaded from ~/.cow/.env). Reading them bypasses the env_config boundary.
|
||||||
|
# Matches /proc/self/environ, /proc/thread-self/environ and /proc/<pid>/environ.
|
||||||
|
_PROC_ENVIRON_RE = re.compile(r"^/proc/(\d+|self|thread-self)/environ$")
|
||||||
|
|
||||||
|
|
||||||
class Read(BaseTool):
|
class Read(BaseTool):
|
||||||
"""Tool for reading file contents"""
|
"""Tool for reading file contents"""
|
||||||
|
|
||||||
name: str = "read"
|
name: str = "read"
|
||||||
description: str = f"Read or inspect file contents. For text/PDF files, returns content (truncated to {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB). For images/videos/audio, returns metadata only (file info, size, type). Use offset/limit for large text files."
|
description: str = f"Read or inspect file contents. For text/PDF/Word/Excel/PPT files, returns content (truncated to {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB). For images/videos/audio, returns metadata only (file info, size, type). Use offset/limit for large text files."
|
||||||
|
|
||||||
params: dict = {
|
params: dict = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -48,7 +55,8 @@ class Read(BaseTool):
|
|||||||
self.binary_extensions = {'.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.db', '.sqlite'}
|
self.binary_extensions = {'.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.db', '.sqlite'}
|
||||||
self.archive_extensions = {'.zip', '.tar', '.gz', '.rar', '.7z', '.bz2', '.xz'}
|
self.archive_extensions = {'.zip', '.tar', '.gz', '.rar', '.7z', '.bz2', '.xz'}
|
||||||
self.pdf_extensions = {'.pdf'}
|
self.pdf_extensions = {'.pdf'}
|
||||||
|
self.office_extensions = {'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx'}
|
||||||
|
|
||||||
# Readable text formats (will be read with truncation)
|
# Readable text formats (will be read with truncation)
|
||||||
self.text_extensions = {
|
self.text_extensions = {
|
||||||
'.txt', '.md', '.markdown', '.rst', '.log', '.csv', '.tsv', '.json', '.xml', '.yaml', '.yml',
|
'.txt', '.md', '.markdown', '.rst', '.log', '.csv', '.tsv', '.json', '.xml', '.yaml', '.yml',
|
||||||
@@ -57,7 +65,6 @@ class Read(BaseTool):
|
|||||||
'.sh', '.bash', '.zsh', '.fish', '.ps1', '.bat', '.cmd',
|
'.sh', '.bash', '.zsh', '.fish', '.ps1', '.bat', '.cmd',
|
||||||
'.sql', '.r', '.m', '.swift', '.kt', '.scala', '.clj', '.erl', '.ex',
|
'.sql', '.r', '.m', '.swift', '.kt', '.scala', '.clj', '.erl', '.ex',
|
||||||
'.dockerfile', '.makefile', '.cmake', '.gradle', '.properties', '.ini', '.conf', '.cfg',
|
'.dockerfile', '.makefile', '.cmake', '.gradle', '.properties', '.ini', '.conf', '.cfg',
|
||||||
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx' # Office documents
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
@@ -79,9 +86,9 @@ class Read(BaseTool):
|
|||||||
# Resolve path
|
# Resolve path
|
||||||
absolute_path = self._resolve_path(path)
|
absolute_path = self._resolve_path(path)
|
||||||
|
|
||||||
# Security check: Prevent reading sensitive config files
|
# Security check: block credential files and their aliases.
|
||||||
env_config_path = expand_path("~/.cow/.env")
|
# See issue #2913 (/proc/self/environ bypass) and #2863 (scope).
|
||||||
if os.path.abspath(absolute_path) == os.path.abspath(env_config_path):
|
if self._is_credential_path(absolute_path):
|
||||||
return ToolResult.fail(
|
return ToolResult.fail(
|
||||||
"Error: Access denied. API keys and credentials must be accessed through the env_config tool only."
|
"Error: Access denied. API keys and credentials must be accessed through the env_config tool only."
|
||||||
)
|
)
|
||||||
@@ -120,7 +127,11 @@ class Read(BaseTool):
|
|||||||
# Check if PDF
|
# Check if PDF
|
||||||
if file_ext in self.pdf_extensions:
|
if file_ext in self.pdf_extensions:
|
||||||
return self._read_pdf(absolute_path, path, offset, limit)
|
return self._read_pdf(absolute_path, path, offset, limit)
|
||||||
|
|
||||||
|
# Check if Office document (.docx, .xlsx, .pptx, etc.)
|
||||||
|
if file_ext in self.office_extensions:
|
||||||
|
return self._read_office(absolute_path, path, file_ext, offset, limit)
|
||||||
|
|
||||||
# Read text file (with truncation for large files)
|
# Read text file (with truncation for large files)
|
||||||
return self._read_text(absolute_path, path, offset, limit)
|
return self._read_text(absolute_path, path, offset, limit)
|
||||||
|
|
||||||
@@ -136,7 +147,39 @@ class Read(BaseTool):
|
|||||||
if os.path.isabs(path):
|
if os.path.isabs(path):
|
||||||
return path
|
return path
|
||||||
return os.path.abspath(os.path.join(self.cwd, path))
|
return os.path.abspath(os.path.join(self.cwd, path))
|
||||||
|
|
||||||
|
def _is_credential_path(self, absolute_path: str) -> bool:
|
||||||
|
"""Return True if *absolute_path* points at protected credential data.
|
||||||
|
|
||||||
|
Beyond the literal ~/.cow/.env file, this also blocks two real bypass
|
||||||
|
surfaces reported in issue #2913:
|
||||||
|
1. /proc/<pid|self|thread-self>/environ — a second view of the
|
||||||
|
process environment that leaks secrets loaded from ~/.cow/.env.
|
||||||
|
2. Symlinks resolving to ~/.cow/.env; the previous exact abspath
|
||||||
|
match kept the link target and could be bypassed.
|
||||||
|
|
||||||
|
Scope is kept deliberately narrow (only the credential file and its
|
||||||
|
environ aliases) so this does NOT re-broaden the block that #2863
|
||||||
|
intentionally narrowed to ~/.cow/.env.
|
||||||
|
"""
|
||||||
|
# Compare on both the normalized path and the symlink-resolved path,
|
||||||
|
# in POSIX form so the /proc regex matches regardless of os.sep.
|
||||||
|
candidates = set()
|
||||||
|
try:
|
||||||
|
candidates.add(os.path.normpath(absolute_path).replace(os.sep, "/"))
|
||||||
|
candidates.add(os.path.realpath(absolute_path).replace(os.sep, "/"))
|
||||||
|
except OSError:
|
||||||
|
candidates.add(absolute_path.replace(os.sep, "/"))
|
||||||
|
|
||||||
|
# 1. /proc environ aliases (checked on raw and symlink-resolved forms).
|
||||||
|
for candidate in candidates:
|
||||||
|
if _PROC_ENVIRON_RE.match(candidate):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 2. The credential file itself, following symlinks on both sides.
|
||||||
|
env_real = os.path.realpath(expand_path("~/.cow/.env")).replace(os.sep, "/")
|
||||||
|
return env_real in candidates
|
||||||
|
|
||||||
def _return_file_metadata(self, absolute_path: str, file_type: str, file_size: int) -> ToolResult:
|
def _return_file_metadata(self, absolute_path: str, file_type: str, file_size: int) -> ToolResult:
|
||||||
"""
|
"""
|
||||||
Return file metadata for non-readable files (video, audio, binary, etc.)
|
Return file metadata for non-readable files (video, audio, binary, etc.)
|
||||||
@@ -240,17 +283,12 @@ class Read(BaseTool):
|
|||||||
"message": f"文件过大 ({format_size(file_size)} > 50MB),无法读取内容。文件路径: {absolute_path}"
|
"message": f"文件过大 ({format_size(file_size)} > 50MB),无法读取内容。文件路径: {absolute_path}"
|
||||||
})
|
})
|
||||||
|
|
||||||
# Read file
|
# Read file (utf-8-sig strips BOM automatically on Windows)
|
||||||
with open(absolute_path, 'r', encoding='utf-8') as f:
|
# Note: Truncation is unified via truncate_head (DEFAULT_MAX_LINES / DEFAULT_MAX_BYTES)
|
||||||
|
# so that offset/limit can paginate the entire file correctly.
|
||||||
|
with open(absolute_path, 'r', encoding='utf-8-sig') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
# Truncate content if too long (20K characters max for model context)
|
|
||||||
MAX_CONTENT_CHARS = 20 * 1024 # 20K characters
|
|
||||||
content_truncated = False
|
|
||||||
if len(content) > MAX_CONTENT_CHARS:
|
|
||||||
content = content[:MAX_CONTENT_CHARS]
|
|
||||||
content_truncated = True
|
|
||||||
|
|
||||||
all_lines = content.split('\n')
|
all_lines = content.split('\n')
|
||||||
total_file_lines = len(all_lines)
|
total_file_lines = len(all_lines)
|
||||||
|
|
||||||
@@ -259,8 +297,15 @@ class Read(BaseTool):
|
|||||||
if offset is not None:
|
if offset is not None:
|
||||||
if offset < 0:
|
if offset < 0:
|
||||||
# Negative offset: read from end
|
# Negative offset: read from end
|
||||||
# -20 means "last 20 lines" → start from (total - 20)
|
# -20 means "last 20 lines" → start from (total - 20).
|
||||||
start_line = max(0, total_file_lines + offset)
|
# A file ending in "\n" produces a trailing empty element
|
||||||
|
# from split('\n'); exclude it so offset=-1 returns the
|
||||||
|
# real last line instead of the empty string after the
|
||||||
|
# final newline (and -N returns N real lines).
|
||||||
|
effective_lines = total_file_lines
|
||||||
|
if all_lines and all_lines[-1] == '':
|
||||||
|
effective_lines -= 1
|
||||||
|
start_line = max(0, effective_lines + offset)
|
||||||
else:
|
else:
|
||||||
# Positive offset: read from start (1-indexed)
|
# Positive offset: read from start (1-indexed)
|
||||||
start_line = max(0, offset - 1) # Convert to 0-indexed
|
start_line = max(0, offset - 1) # Convert to 0-indexed
|
||||||
@@ -286,11 +331,7 @@ class Read(BaseTool):
|
|||||||
|
|
||||||
output_text = ""
|
output_text = ""
|
||||||
details = {}
|
details = {}
|
||||||
|
|
||||||
# Add truncation warning if content was truncated
|
|
||||||
if content_truncated:
|
|
||||||
output_text = f"[文件内容已截断到前 {format_size(MAX_CONTENT_CHARS)},完整文件大小: {format_size(file_size)}]\n\n"
|
|
||||||
|
|
||||||
if truncation.first_line_exceeds_limit:
|
if truncation.first_line_exceeds_limit:
|
||||||
# First line exceeds 30KB limit
|
# First line exceeds 30KB limit
|
||||||
first_line_size = format_size(len(all_lines[start_line].encode('utf-8')))
|
first_line_size = format_size(len(all_lines[start_line].encode('utf-8')))
|
||||||
@@ -337,6 +378,116 @@ class Read(BaseTool):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.fail(f"Error reading file: {str(e)}")
|
return ToolResult.fail(f"Error reading file: {str(e)}")
|
||||||
|
|
||||||
|
def _read_office(self, absolute_path: str, display_path: str, file_ext: str,
|
||||||
|
offset: int = None, limit: int = None) -> ToolResult:
|
||||||
|
"""Read Office documents (.docx, .xlsx, .pptx) using python-docx / openpyxl / python-pptx."""
|
||||||
|
try:
|
||||||
|
text = self._extract_office_text(absolute_path, file_ext)
|
||||||
|
except ImportError as e:
|
||||||
|
return ToolResult.fail(str(e))
|
||||||
|
except Exception as e:
|
||||||
|
return ToolResult.fail(f"Error reading Office document: {e}")
|
||||||
|
|
||||||
|
if not text or not text.strip():
|
||||||
|
return ToolResult.success({
|
||||||
|
"content": f"[Office file {Path(absolute_path).name}: no text content could be extracted]",
|
||||||
|
})
|
||||||
|
|
||||||
|
all_lines = text.split('\n')
|
||||||
|
total_lines = len(all_lines)
|
||||||
|
|
||||||
|
start_line = 0
|
||||||
|
if offset is not None:
|
||||||
|
if offset < 0:
|
||||||
|
start_line = max(0, total_lines + offset)
|
||||||
|
else:
|
||||||
|
start_line = max(0, offset - 1)
|
||||||
|
if start_line >= total_lines:
|
||||||
|
return ToolResult.fail(
|
||||||
|
f"Error: Offset {offset} is beyond end of content ({total_lines} lines total)"
|
||||||
|
)
|
||||||
|
|
||||||
|
selected_content = text
|
||||||
|
user_limited_lines = None
|
||||||
|
if limit is not None:
|
||||||
|
end_line = min(start_line + limit, total_lines)
|
||||||
|
selected_content = '\n'.join(all_lines[start_line:end_line])
|
||||||
|
user_limited_lines = end_line - start_line
|
||||||
|
elif offset is not None:
|
||||||
|
selected_content = '\n'.join(all_lines[start_line:])
|
||||||
|
|
||||||
|
truncation = truncate_head(selected_content)
|
||||||
|
start_line_display = start_line + 1
|
||||||
|
output_text = ""
|
||||||
|
|
||||||
|
if truncation.truncated:
|
||||||
|
end_line_display = start_line_display + truncation.output_lines - 1
|
||||||
|
next_offset = end_line_display + 1
|
||||||
|
output_text = truncation.content
|
||||||
|
output_text += f"\n\n[Showing lines {start_line_display}-{end_line_display} of {total_lines}. Use offset={next_offset} to continue.]"
|
||||||
|
elif user_limited_lines is not None and start_line + user_limited_lines < total_lines:
|
||||||
|
remaining = total_lines - (start_line + user_limited_lines)
|
||||||
|
next_offset = start_line + user_limited_lines + 1
|
||||||
|
output_text = truncation.content
|
||||||
|
output_text += f"\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]"
|
||||||
|
else:
|
||||||
|
output_text = truncation.content
|
||||||
|
|
||||||
|
return ToolResult.success({
|
||||||
|
"content": output_text,
|
||||||
|
"total_lines": total_lines,
|
||||||
|
"start_line": start_line_display,
|
||||||
|
"output_lines": truncation.output_lines,
|
||||||
|
})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_office_text(absolute_path: str, file_ext: str) -> str:
|
||||||
|
"""Extract plain text from an Office document."""
|
||||||
|
if file_ext in ('.docx', '.doc'):
|
||||||
|
try:
|
||||||
|
from docx import Document
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError("Error: python-docx library not installed. Install with: pip install python-docx")
|
||||||
|
doc = Document(absolute_path)
|
||||||
|
paragraphs = [p.text for p in doc.paragraphs]
|
||||||
|
for table in doc.tables:
|
||||||
|
for row in table.rows:
|
||||||
|
paragraphs.append('\t'.join(cell.text for cell in row.cells))
|
||||||
|
return '\n'.join(paragraphs)
|
||||||
|
|
||||||
|
if file_ext in ('.xlsx', '.xls'):
|
||||||
|
try:
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError("Error: openpyxl library not installed. Install with: pip install openpyxl")
|
||||||
|
wb = load_workbook(absolute_path, read_only=True, data_only=True)
|
||||||
|
parts = []
|
||||||
|
for ws in wb.worksheets:
|
||||||
|
parts.append(f"--- Sheet: {ws.title} ---")
|
||||||
|
for row in ws.iter_rows(values_only=True):
|
||||||
|
parts.append('\t'.join(str(c) if c is not None else '' for c in row))
|
||||||
|
wb.close()
|
||||||
|
return '\n'.join(parts)
|
||||||
|
|
||||||
|
if file_ext in ('.pptx', '.ppt'):
|
||||||
|
try:
|
||||||
|
from pptx import Presentation
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError("Error: python-pptx library not installed. Install with: pip install python-pptx")
|
||||||
|
prs = Presentation(absolute_path)
|
||||||
|
parts = []
|
||||||
|
for i, slide in enumerate(prs.slides, 1):
|
||||||
|
parts.append(f"--- Slide {i} ---")
|
||||||
|
for shape in slide.shapes:
|
||||||
|
if shape.has_text_frame:
|
||||||
|
for para in shape.text_frame.paragraphs:
|
||||||
|
text = para.text.strip()
|
||||||
|
if text:
|
||||||
|
parts.append(text)
|
||||||
|
return '\n'.join(parts)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
def _read_pdf(self, absolute_path: str, display_path: str, offset: int = None, limit: int = None) -> ToolResult:
|
def _read_pdf(self, absolute_path: str, display_path: str, offset: int = None, limit: int = None) -> ToolResult:
|
||||||
"""
|
"""
|
||||||
Read PDF file content
|
Read PDF file content
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Integration module for scheduler with AgentBridge
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from config import conf
|
from config import conf
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
@@ -13,65 +14,126 @@ from bridge.reply import Reply, ReplyType
|
|||||||
# Global scheduler service instance
|
# Global scheduler service instance
|
||||||
_scheduler_service = None
|
_scheduler_service = None
|
||||||
_task_store = None
|
_task_store = None
|
||||||
|
# Module-level lock to guard idempotent initialization across threads
|
||||||
|
_init_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def init_scheduler(agent_bridge) -> bool:
|
def init_scheduler(agent_bridge) -> bool:
|
||||||
"""
|
"""
|
||||||
Initialize scheduler service
|
Initialize scheduler service (idempotent).
|
||||||
|
|
||||||
|
Safe to call multiple times and from multiple threads: only the first
|
||||||
|
successful call creates the singleton ``SchedulerService`` + background
|
||||||
|
scanning thread. Subsequent calls return immediately.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
agent_bridge: AgentBridge instance
|
agent_bridge: AgentBridge instance
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if initialized successfully
|
True if scheduler is initialized (newly created or already running)
|
||||||
"""
|
"""
|
||||||
global _scheduler_service, _task_store
|
global _scheduler_service, _task_store
|
||||||
|
|
||||||
try:
|
# Fast path: already initialized and running
|
||||||
from agent.tools.scheduler.task_store import TaskStore
|
if _scheduler_service is not None and getattr(_scheduler_service, "running", False):
|
||||||
from agent.tools.scheduler.scheduler_service import SchedulerService
|
return True
|
||||||
|
|
||||||
# Get workspace from config
|
with _init_lock:
|
||||||
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
# Re-check under the lock to avoid races where multiple threads
|
||||||
store_path = os.path.join(workspace_root, "scheduler", "tasks.json")
|
# passed the fast-path check before any of them acquired the lock.
|
||||||
|
if _scheduler_service is not None and getattr(_scheduler_service, "running", False):
|
||||||
# Create task store
|
return True
|
||||||
_task_store = TaskStore(store_path)
|
|
||||||
logger.debug(f"[Scheduler] Task store initialized: {store_path}")
|
try:
|
||||||
|
from agent.tools.scheduler.task_store import TaskStore
|
||||||
# Create execute callback
|
from agent.tools.scheduler.scheduler_service import SchedulerService
|
||||||
def execute_task_callback(task: dict):
|
|
||||||
"""Callback to execute a scheduled task"""
|
# Get workspace from config
|
||||||
try:
|
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
action = task.get("action", {})
|
store_path = os.path.join(workspace_root, "scheduler", "tasks.json")
|
||||||
action_type = action.get("type")
|
|
||||||
|
# Create task store (reuse if already created)
|
||||||
if action_type == "agent_task":
|
if _task_store is None:
|
||||||
_execute_agent_task(task, agent_bridge)
|
_task_store = TaskStore(store_path)
|
||||||
elif action_type == "send_message":
|
logger.debug(f"[Scheduler] Task store initialized: {store_path}")
|
||||||
# Legacy support for old tasks
|
|
||||||
_execute_send_message(task, agent_bridge)
|
# Create execute callback. Returns True on success, False to ask
|
||||||
elif action_type == "tool_call":
|
# the scheduler to retry on the next tick (e.g. channel not yet
|
||||||
# Legacy support for old tasks
|
# ready right after process start).
|
||||||
_execute_tool_call(task, agent_bridge)
|
def execute_task_callback(task: dict):
|
||||||
elif action_type == "skill_call":
|
try:
|
||||||
# Legacy support for old tasks
|
action = task.get("action", {})
|
||||||
_execute_skill_call(task, agent_bridge)
|
action_type = action.get("type")
|
||||||
else:
|
channel_type = action.get("channel_type", "unknown")
|
||||||
logger.warning(f"[Scheduler] Unknown action type: {action_type}")
|
receiver = action.get("receiver", "")
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[Scheduler] Error executing task {task.get('id')}: {e}")
|
if not _is_channel_ready(channel_type, receiver):
|
||||||
|
logger.warning(
|
||||||
# Create scheduler service
|
f"[Scheduler] Task {task.get('id')}: channel "
|
||||||
_scheduler_service = SchedulerService(_task_store, execute_task_callback)
|
f"'{channel_type}' not ready for receiver={receiver} "
|
||||||
_scheduler_service.start()
|
f"(no inbound msg cached since restart?); deferring"
|
||||||
|
)
|
||||||
logger.debug("[Scheduler] Scheduler service initialized and started")
|
return False
|
||||||
|
|
||||||
|
if action_type == "agent_task":
|
||||||
|
return _execute_agent_task(task, agent_bridge)
|
||||||
|
elif action_type == "send_message":
|
||||||
|
return _execute_send_message(task, agent_bridge)
|
||||||
|
elif action_type == "tool_call":
|
||||||
|
return _execute_tool_call(task, agent_bridge)
|
||||||
|
elif action_type == "skill_call":
|
||||||
|
return _execute_skill_call(task, agent_bridge)
|
||||||
|
else:
|
||||||
|
logger.warning(f"[Scheduler] Unknown action type: {action_type}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Scheduler] Error executing task {task.get('id')}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create scheduler service
|
||||||
|
_scheduler_service = SchedulerService(_task_store, execute_task_callback)
|
||||||
|
_scheduler_service.start()
|
||||||
|
|
||||||
|
logger.info("[Scheduler] Service initialized and started")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Scheduler] Failed to initialize scheduler: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_channel_ready(channel_type: str, receiver: str) -> bool:
|
||||||
|
"""Best-effort readiness probe for outbound channels.
|
||||||
|
|
||||||
|
Returns False when we know the send will drop (e.g. weixin not yet
|
||||||
|
logged in, web session has no polling queue), so the scheduler can
|
||||||
|
defer instead of consuming the task. Unknown channels return True
|
||||||
|
to preserve previous behaviour.
|
||||||
|
"""
|
||||||
|
if not channel_type or channel_type == "unknown":
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
from channel.channel_factory import create_channel
|
||||||
|
channel = create_channel(channel_type)
|
||||||
|
if channel is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if channel_type == "weixin":
|
||||||
|
tokens = getattr(channel, "_context_tokens", None)
|
||||||
|
if not tokens or receiver not in tokens:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
if channel_type == "web":
|
||||||
|
queues = getattr(channel, "session_queues", None)
|
||||||
|
if not queues or receiver not in queues:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Scheduler] Failed to initialize scheduler: {e}")
|
logger.warning(f"[Scheduler] Channel readiness check failed for {channel_type}: {e}")
|
||||||
return False
|
return True
|
||||||
|
|
||||||
|
|
||||||
def get_task_store():
|
def get_task_store():
|
||||||
@@ -84,13 +146,53 @@ def get_scheduler_service():
|
|||||||
return _scheduler_service
|
return _scheduler_service
|
||||||
|
|
||||||
|
|
||||||
def _execute_agent_task(task: dict, agent_bridge):
|
def _remember_delivered_output(
|
||||||
|
agent_bridge,
|
||||||
|
task: dict,
|
||||||
|
channel_type: str,
|
||||||
|
content: str,
|
||||||
|
) -> None:
|
||||||
|
"""Best-effort persistence of the message the scheduler sent to a user.
|
||||||
|
|
||||||
|
Uses notify_session_id (the real chat session_id stored at task creation time)
|
||||||
|
so that group chats correctly associate the output with the user's conversation.
|
||||||
|
Falls back to receiver for backward compatibility with old tasks.
|
||||||
|
|
||||||
|
Per-action-type behaviour:
|
||||||
|
- agent_task / tool_call / skill_call: gated by ``scheduler_inject_to_session``
|
||||||
|
(default True). These produce AI-generated content worth remembering.
|
||||||
|
- send_message: additionally gated by ``scheduler_inject_send_message``
|
||||||
|
(default False). Fixed reminder text rarely benefits follow-up Q&A and
|
||||||
|
would just consume context tokens.
|
||||||
"""
|
"""
|
||||||
Execute an agent_task action - let Agent handle the task
|
if not content:
|
||||||
|
return
|
||||||
Args:
|
action = task.get("action", {})
|
||||||
task: Task dictionary
|
action_type = action.get("type", "")
|
||||||
agent_bridge: AgentBridge instance
|
|
||||||
|
# send_message defaults to NOT being injected; explicit opt-in via config.
|
||||||
|
if action_type == "send_message":
|
||||||
|
if not conf().get("scheduler_inject_send_message", False):
|
||||||
|
return
|
||||||
|
|
||||||
|
session_id = action.get("notify_session_id") or action.get("receiver")
|
||||||
|
if not session_id:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
remember = getattr(agent_bridge, "remember_scheduled_output", None)
|
||||||
|
if remember:
|
||||||
|
task_desc = action.get("task_description") or action.get("content", "")
|
||||||
|
remember(session_id, str(content), channel_type=channel_type, task_description=task_desc)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[Scheduler] Failed to remember delivered output for {session_id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_agent_task(task: dict, agent_bridge) -> bool:
|
||||||
|
"""
|
||||||
|
Execute an agent_task action - let Agent handle the task.
|
||||||
|
Returns True on successful delivery, False to retry next tick.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
action = task.get("action", {})
|
action = task.get("action", {})
|
||||||
@@ -101,11 +203,11 @@ def _execute_agent_task(task: dict, agent_bridge):
|
|||||||
|
|
||||||
if not task_description:
|
if not task_description:
|
||||||
logger.error(f"[Scheduler] Task {task['id']}: No task_description specified")
|
logger.error(f"[Scheduler] Task {task['id']}: No task_description specified")
|
||||||
return
|
return True # malformed task, don't loop forever
|
||||||
|
|
||||||
if not receiver:
|
if not receiver:
|
||||||
logger.error(f"[Scheduler] Task {task['id']}: No receiver specified")
|
logger.error(f"[Scheduler] Task {task['id']}: No receiver specified")
|
||||||
return
|
return True
|
||||||
|
|
||||||
# Check for unsupported channels
|
# Check for unsupported channels
|
||||||
if channel_type == "dingtalk":
|
if channel_type == "dingtalk":
|
||||||
@@ -134,12 +236,13 @@ def _execute_agent_task(task: dict, agent_bridge):
|
|||||||
elif channel_type == "dingtalk":
|
elif channel_type == "dingtalk":
|
||||||
# DingTalk requires msg object, set to None for scheduled tasks
|
# DingTalk requires msg object, set to None for scheduled tasks
|
||||||
context["msg"] = None
|
context["msg"] = None
|
||||||
# 如果是单聊,需要传递 sender_staff_id
|
|
||||||
if not is_group:
|
if not is_group:
|
||||||
sender_staff_id = action.get("dingtalk_sender_staff_id")
|
sender_staff_id = action.get("dingtalk_sender_staff_id")
|
||||||
if sender_staff_id:
|
if sender_staff_id:
|
||||||
context["dingtalk_sender_staff_id"] = sender_staff_id
|
context["dingtalk_sender_staff_id"] = sender_staff_id
|
||||||
|
elif channel_type == "wecom_bot":
|
||||||
|
context["msg"] = None
|
||||||
|
|
||||||
# Use Agent to execute the task
|
# Use Agent to execute the task
|
||||||
# Mark this as a scheduled task execution to prevent recursive task creation
|
# Mark this as a scheduled task execution to prevent recursive task creation
|
||||||
context["is_scheduled_task"] = True
|
context["is_scheduled_task"] = True
|
||||||
@@ -147,50 +250,47 @@ def _execute_agent_task(task: dict, agent_bridge):
|
|||||||
try:
|
try:
|
||||||
# Don't clear history - scheduler tasks use isolated session_id so they won't pollute user conversations
|
# Don't clear history - scheduler tasks use isolated session_id so they won't pollute user conversations
|
||||||
reply = agent_bridge.agent_reply(task_description, context=context, on_event=None, clear_history=False)
|
reply = agent_bridge.agent_reply(task_description, context=context, on_event=None, clear_history=False)
|
||||||
|
|
||||||
if reply and reply.content:
|
if not (reply and reply.content):
|
||||||
# Send the reply via channel
|
|
||||||
from channel.channel_factory import create_channel
|
|
||||||
|
|
||||||
try:
|
|
||||||
channel = create_channel(channel_type)
|
|
||||||
if channel:
|
|
||||||
# For web channel, register request_id
|
|
||||||
if channel_type == "web" and hasattr(channel, 'request_to_session'):
|
|
||||||
request_id = context.get("request_id")
|
|
||||||
if request_id:
|
|
||||||
channel.request_to_session[request_id] = receiver
|
|
||||||
logger.debug(f"[Scheduler] Registered request_id {request_id} -> session {receiver}")
|
|
||||||
|
|
||||||
# Send the reply
|
|
||||||
channel.send(reply, context)
|
|
||||||
logger.info(f"[Scheduler] Task {task['id']} executed successfully, result sent to {receiver}")
|
|
||||||
else:
|
|
||||||
logger.error(f"[Scheduler] Failed to create channel: {channel_type}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[Scheduler] Failed to send result: {e}")
|
|
||||||
else:
|
|
||||||
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
|
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
|
||||||
|
return True # agent ran but produced nothing; don't loop
|
||||||
|
|
||||||
|
from channel.channel_factory import create_channel
|
||||||
|
channel = create_channel(channel_type)
|
||||||
|
if not channel:
|
||||||
|
logger.error(f"[Scheduler] Failed to create channel: {channel_type}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if channel_type == "web" and hasattr(channel, 'request_to_session'):
|
||||||
|
request_id = context.get("request_id")
|
||||||
|
if request_id:
|
||||||
|
channel.request_to_session[request_id] = receiver
|
||||||
|
|
||||||
|
try:
|
||||||
|
channel.send(reply, context)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Scheduler] Failed to send result: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
_remember_delivered_output(agent_bridge, task, channel_type, reply.content)
|
||||||
|
logger.info(f"[Scheduler] Task {task['id']} executed successfully, result sent to {receiver}")
|
||||||
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Scheduler] Failed to execute task via Agent: {e}")
|
logger.error(f"[Scheduler] Failed to execute task via Agent: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
|
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
|
||||||
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Scheduler] Error in _execute_agent_task: {e}")
|
logger.error(f"[Scheduler] Error in _execute_agent_task: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
|
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _execute_send_message(task: dict, agent_bridge):
|
def _execute_send_message(task: dict, agent_bridge) -> bool:
|
||||||
"""
|
"""Execute a send_message action. Returns True/False for delivery."""
|
||||||
Execute a send_message action
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task: Task dictionary
|
|
||||||
agent_bridge: AgentBridge instance
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
action = task.get("action", {})
|
action = task.get("action", {})
|
||||||
content = action.get("content", "")
|
content = action.get("content", "")
|
||||||
@@ -200,7 +300,7 @@ def _execute_send_message(task: dict, agent_bridge):
|
|||||||
|
|
||||||
if not receiver:
|
if not receiver:
|
||||||
logger.error(f"[Scheduler] Task {task['id']}: No receiver specified")
|
logger.error(f"[Scheduler] Task {task['id']}: No receiver specified")
|
||||||
return
|
return True
|
||||||
|
|
||||||
# Create context for sending message
|
# Create context for sending message
|
||||||
context = Context(ContextType.TEXT, content)
|
context = Context(ContextType.TEXT, content)
|
||||||
@@ -234,174 +334,146 @@ def _execute_send_message(task: dict, agent_bridge):
|
|||||||
logger.debug(f"[Scheduler] DingTalk single chat: sender_staff_id={sender_staff_id}")
|
logger.debug(f"[Scheduler] DingTalk single chat: sender_staff_id={sender_staff_id}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"[Scheduler] Task {task['id']}: DingTalk single chat message missing sender_staff_id")
|
logger.warning(f"[Scheduler] Task {task['id']}: DingTalk single chat message missing sender_staff_id")
|
||||||
|
elif channel_type == "wecom_bot":
|
||||||
|
context["msg"] = None
|
||||||
|
elif channel_type == "qq":
|
||||||
|
context["msg"] = None
|
||||||
|
|
||||||
# Create reply
|
# Create reply
|
||||||
reply = Reply(ReplyType.TEXT, content)
|
reply = Reply(ReplyType.TEXT, content)
|
||||||
|
|
||||||
# Get channel and send
|
# Get channel and send
|
||||||
from channel.channel_factory import create_channel
|
from channel.channel_factory import create_channel
|
||||||
|
|
||||||
|
channel = create_channel(channel_type)
|
||||||
|
if not channel:
|
||||||
|
logger.error(f"[Scheduler] Failed to create channel: {channel_type}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if channel_type == "web" and hasattr(channel, 'request_to_session'):
|
||||||
|
channel.request_to_session[request_id] = receiver
|
||||||
|
|
||||||
try:
|
try:
|
||||||
channel = create_channel(channel_type)
|
channel.send(reply, context)
|
||||||
if channel:
|
|
||||||
# For web channel, register the request_id to session mapping
|
|
||||||
if channel_type == "web" and hasattr(channel, 'request_to_session'):
|
|
||||||
channel.request_to_session[request_id] = receiver
|
|
||||||
logger.debug(f"[Scheduler] Registered request_id {request_id} -> session {receiver}")
|
|
||||||
|
|
||||||
channel.send(reply, context)
|
|
||||||
logger.info(f"[Scheduler] Task {task['id']} executed: sent message to {receiver}")
|
|
||||||
else:
|
|
||||||
logger.error(f"[Scheduler] Failed to create channel: {channel_type}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Scheduler] Failed to send message: {e}")
|
logger.error(f"[Scheduler] Failed to send message: {e}")
|
||||||
import traceback
|
return False
|
||||||
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
|
|
||||||
|
_remember_delivered_output(agent_bridge, task, channel_type, content)
|
||||||
|
logger.info(f"[Scheduler] Task {task['id']} executed: sent message to {receiver}")
|
||||||
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Scheduler] Error in _execute_send_message: {e}")
|
logger.error(f"[Scheduler] Error in _execute_send_message: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
|
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _execute_tool_call(task: dict, agent_bridge):
|
def _execute_tool_call(task: dict, agent_bridge) -> bool:
|
||||||
"""
|
"""Execute a tool_call action. Returns True/False for delivery."""
|
||||||
Execute a tool_call action
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task: Task dictionary
|
|
||||||
agent_bridge: AgentBridge instance
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
action = task.get("action", {})
|
action = task.get("action", {})
|
||||||
# Support both old and new field names
|
|
||||||
tool_name = action.get("call_name") or action.get("tool_name")
|
tool_name = action.get("call_name") or action.get("tool_name")
|
||||||
tool_params = action.get("call_params") or action.get("tool_params", {})
|
tool_params = action.get("call_params") or action.get("tool_params", {})
|
||||||
result_prefix = action.get("result_prefix", "")
|
result_prefix = action.get("result_prefix", "")
|
||||||
receiver = action.get("receiver")
|
receiver = action.get("receiver")
|
||||||
is_group = action.get("is_group", False)
|
is_group = action.get("is_group", False)
|
||||||
channel_type = action.get("channel_type", "unknown")
|
channel_type = action.get("channel_type", "unknown")
|
||||||
|
|
||||||
if not tool_name:
|
if not tool_name:
|
||||||
logger.error(f"[Scheduler] Task {task['id']}: No tool_name specified")
|
logger.error(f"[Scheduler] Task {task['id']}: No tool_name specified")
|
||||||
return
|
return True
|
||||||
|
|
||||||
if not receiver:
|
if not receiver:
|
||||||
logger.error(f"[Scheduler] Task {task['id']}: No receiver specified")
|
logger.error(f"[Scheduler] Task {task['id']}: No receiver specified")
|
||||||
return
|
return True
|
||||||
|
|
||||||
# Get tool manager and create tool instance
|
|
||||||
from agent.tools.tool_manager import ToolManager
|
from agent.tools.tool_manager import ToolManager
|
||||||
tool_manager = ToolManager()
|
tool = ToolManager().create_tool(tool_name)
|
||||||
tool = tool_manager.create_tool(tool_name)
|
|
||||||
|
|
||||||
if not tool:
|
if not tool:
|
||||||
logger.error(f"[Scheduler] Task {task['id']}: Tool '{tool_name}' not found")
|
logger.error(f"[Scheduler] Task {task['id']}: Tool '{tool_name}' not found")
|
||||||
return
|
return True
|
||||||
|
|
||||||
# Execute tool
|
|
||||||
logger.info(f"[Scheduler] Task {task['id']}: Executing tool '{tool_name}' with params {tool_params}")
|
logger.info(f"[Scheduler] Task {task['id']}: Executing tool '{tool_name}' with params {tool_params}")
|
||||||
result = tool.execute(tool_params)
|
result = tool.execute(tool_params)
|
||||||
|
content = result.result if hasattr(result, 'result') else str(result)
|
||||||
# Get result content
|
|
||||||
if hasattr(result, 'result'):
|
|
||||||
content = result.result
|
|
||||||
else:
|
|
||||||
content = str(result)
|
|
||||||
|
|
||||||
# Add prefix if specified
|
|
||||||
if result_prefix:
|
if result_prefix:
|
||||||
content = f"{result_prefix}\n\n{content}"
|
content = f"{result_prefix}\n\n{content}"
|
||||||
|
|
||||||
# Send result as message
|
|
||||||
context = Context(ContextType.TEXT, content)
|
context = Context(ContextType.TEXT, content)
|
||||||
context["receiver"] = receiver
|
context["receiver"] = receiver
|
||||||
context["isgroup"] = is_group
|
context["isgroup"] = is_group
|
||||||
context["session_id"] = receiver
|
context["session_id"] = receiver
|
||||||
|
|
||||||
# Channel-specific context setup
|
request_id = None
|
||||||
if channel_type == "web":
|
if channel_type == "web":
|
||||||
# Web channel needs request_id
|
|
||||||
import uuid
|
import uuid
|
||||||
request_id = f"scheduler_{task['id']}_{uuid.uuid4().hex[:8]}"
|
request_id = f"scheduler_{task['id']}_{uuid.uuid4().hex[:8]}"
|
||||||
context["request_id"] = request_id
|
context["request_id"] = request_id
|
||||||
logger.debug(f"[Scheduler] Generated request_id for web channel: {request_id}")
|
|
||||||
elif channel_type == "feishu":
|
elif channel_type == "feishu":
|
||||||
# Feishu channel: for scheduled tasks, send as new message (no msg_id to reply to)
|
|
||||||
context["receive_id_type"] = "chat_id" if is_group else "open_id"
|
context["receive_id_type"] = "chat_id" if is_group else "open_id"
|
||||||
context["msg"] = None
|
context["msg"] = None
|
||||||
logger.debug(f"[Scheduler] Feishu: receive_id_type={context['receive_id_type']}, is_group={is_group}, receiver={receiver}")
|
elif channel_type == "wecom_bot":
|
||||||
|
context["msg"] = None
|
||||||
|
|
||||||
reply = Reply(ReplyType.TEXT, content)
|
reply = Reply(ReplyType.TEXT, content)
|
||||||
|
|
||||||
# Get channel and send
|
|
||||||
from channel.channel_factory import create_channel
|
from channel.channel_factory import create_channel
|
||||||
|
channel = create_channel(channel_type)
|
||||||
|
if not channel:
|
||||||
|
logger.error(f"[Scheduler] Failed to create channel: {channel_type}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if channel_type == "web" and request_id and hasattr(channel, 'request_to_session'):
|
||||||
|
channel.request_to_session[request_id] = receiver
|
||||||
|
|
||||||
try:
|
try:
|
||||||
channel = create_channel(channel_type)
|
channel.send(reply, context)
|
||||||
if channel:
|
|
||||||
# For web channel, register the request_id to session mapping
|
|
||||||
if channel_type == "web" and hasattr(channel, 'request_to_session'):
|
|
||||||
channel.request_to_session[request_id] = receiver
|
|
||||||
logger.debug(f"[Scheduler] Registered request_id {request_id} -> session {receiver}")
|
|
||||||
|
|
||||||
channel.send(reply, context)
|
|
||||||
logger.info(f"[Scheduler] Task {task['id']} executed: sent tool result to {receiver}")
|
|
||||||
else:
|
|
||||||
logger.error(f"[Scheduler] Failed to create channel: {channel_type}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Scheduler] Failed to send tool result: {e}")
|
logger.error(f"[Scheduler] Failed to send tool result: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
_remember_delivered_output(agent_bridge, task, channel_type, content)
|
||||||
|
logger.info(f"[Scheduler] Task {task['id']} executed: sent tool result to {receiver}")
|
||||||
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Scheduler] Error in _execute_tool_call: {e}")
|
logger.error(f"[Scheduler] Error in _execute_tool_call: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _execute_skill_call(task: dict, agent_bridge):
|
def _execute_skill_call(task: dict, agent_bridge) -> bool:
|
||||||
"""
|
"""Execute a skill_call action by asking Agent to run the skill.
|
||||||
Execute a skill_call action by asking Agent to run the skill
|
Returns True/False for delivery."""
|
||||||
|
|
||||||
Args:
|
|
||||||
task: Task dictionary
|
|
||||||
agent_bridge: AgentBridge instance
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
action = task.get("action", {})
|
action = task.get("action", {})
|
||||||
# Support both old and new field names
|
|
||||||
skill_name = action.get("call_name") or action.get("skill_name")
|
skill_name = action.get("call_name") or action.get("skill_name")
|
||||||
skill_params = action.get("call_params") or action.get("skill_params", {})
|
skill_params = action.get("call_params") or action.get("skill_params", {})
|
||||||
result_prefix = action.get("result_prefix", "")
|
result_prefix = action.get("result_prefix", "")
|
||||||
receiver = action.get("receiver")
|
receiver = action.get("receiver")
|
||||||
is_group = action.get("isgroup", False)
|
is_group = action.get("isgroup", False)
|
||||||
channel_type = action.get("channel_type", "unknown")
|
channel_type = action.get("channel_type", "unknown")
|
||||||
|
|
||||||
if not skill_name:
|
if not skill_name:
|
||||||
logger.error(f"[Scheduler] Task {task['id']}: No skill_name specified")
|
logger.error(f"[Scheduler] Task {task['id']}: No skill_name specified")
|
||||||
return
|
return True
|
||||||
|
|
||||||
if not receiver:
|
if not receiver:
|
||||||
logger.error(f"[Scheduler] Task {task['id']}: No receiver specified")
|
logger.error(f"[Scheduler] Task {task['id']}: No receiver specified")
|
||||||
return
|
return True
|
||||||
|
|
||||||
logger.info(f"[Scheduler] Task {task['id']}: Executing skill '{skill_name}' with params {skill_params}")
|
logger.info(f"[Scheduler] Task {task['id']}: Executing skill '{skill_name}' with params {skill_params}")
|
||||||
|
|
||||||
# Create a unique session_id for this scheduled task to avoid polluting user's conversation
|
|
||||||
# Format: scheduler_<receiver>_<task_id> to ensure isolation
|
|
||||||
scheduler_session_id = f"scheduler_{receiver}_{task['id']}"
|
scheduler_session_id = f"scheduler_{receiver}_{task['id']}"
|
||||||
|
|
||||||
# Build a natural language query for the Agent to execute the skill
|
|
||||||
# Format: "Use skill-name to do something with params"
|
|
||||||
param_str = ", ".join([f"{k}={v}" for k, v in skill_params.items()])
|
param_str = ", ".join([f"{k}={v}" for k, v in skill_params.items()])
|
||||||
query = f"Use {skill_name} skill"
|
query = f"Use {skill_name} skill"
|
||||||
if param_str:
|
if param_str:
|
||||||
query += f" with {param_str}"
|
query += f" with {param_str}"
|
||||||
|
|
||||||
# Create context for Agent
|
|
||||||
context = Context(ContextType.TEXT, query)
|
context = Context(ContextType.TEXT, query)
|
||||||
context["receiver"] = receiver
|
context["receiver"] = receiver
|
||||||
context["isgroup"] = is_group
|
context["isgroup"] = is_group
|
||||||
context["session_id"] = scheduler_session_id
|
context["session_id"] = scheduler_session_id
|
||||||
|
|
||||||
# Channel-specific setup
|
|
||||||
if channel_type == "web":
|
if channel_type == "web":
|
||||||
import uuid
|
import uuid
|
||||||
request_id = f"scheduler_{task['id']}_{uuid.uuid4().hex[:8]}"
|
request_id = f"scheduler_{task['id']}_{uuid.uuid4().hex[:8]}"
|
||||||
@@ -409,32 +481,51 @@ def _execute_skill_call(task: dict, agent_bridge):
|
|||||||
elif channel_type == "feishu":
|
elif channel_type == "feishu":
|
||||||
context["receive_id_type"] = "chat_id" if is_group else "open_id"
|
context["receive_id_type"] = "chat_id" if is_group else "open_id"
|
||||||
context["msg"] = None
|
context["msg"] = None
|
||||||
|
elif channel_type == "wecom_bot":
|
||||||
# Use Agent to execute the skill
|
context["msg"] = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Don't clear history - scheduler tasks use isolated session_id so they won't pollute user conversations
|
|
||||||
reply = agent_bridge.agent_reply(query, context=context, on_event=None, clear_history=False)
|
reply = agent_bridge.agent_reply(query, context=context, on_event=None, clear_history=False)
|
||||||
|
|
||||||
if reply and reply.content:
|
|
||||||
content = reply.content
|
|
||||||
|
|
||||||
# Add prefix if specified
|
|
||||||
if result_prefix:
|
|
||||||
content = f"{result_prefix}\n\n{content}"
|
|
||||||
|
|
||||||
logger.info(f"[Scheduler] Task {task['id']} executed: skill result sent to {receiver}")
|
|
||||||
else:
|
|
||||||
logger.error(f"[Scheduler] Task {task['id']}: No result from skill execution")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Scheduler] Failed to execute skill via Agent: {e}")
|
logger.error(f"[Scheduler] Failed to execute skill via Agent: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
|
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not (reply and reply.content):
|
||||||
|
logger.error(f"[Scheduler] Task {task['id']}: No result from skill execution")
|
||||||
|
return True
|
||||||
|
|
||||||
|
content = reply.content
|
||||||
|
if result_prefix:
|
||||||
|
content = f"{result_prefix}\n\n{content}"
|
||||||
|
|
||||||
|
from channel.channel_factory import create_channel
|
||||||
|
channel = create_channel(channel_type)
|
||||||
|
if not channel:
|
||||||
|
logger.error(f"[Scheduler] Failed to create channel: {channel_type}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if channel_type == "web" and hasattr(channel, 'request_to_session'):
|
||||||
|
req_id = context.get("request_id")
|
||||||
|
if req_id:
|
||||||
|
channel.request_to_session[req_id] = receiver
|
||||||
|
|
||||||
|
try:
|
||||||
|
channel.send(Reply(ReplyType.TEXT, content), context)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Scheduler] Failed to send skill result: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
_remember_delivered_output(agent_bridge, task, channel_type, content)
|
||||||
|
logger.info(f"[Scheduler] Task {task['id']} executed: skill result sent to {receiver}")
|
||||||
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Scheduler] Error in _execute_skill_call: {e}")
|
logger.error(f"[Scheduler] Error in _execute_skill_call: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
|
logger.error(f"[Scheduler] Traceback: {traceback.format_exc()}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def attach_scheduler_to_tool(tool, context: Context = None):
|
def attach_scheduler_to_tool(tool, context: Context = None):
|
||||||
|
|||||||
@@ -10,6 +10,19 @@ from croniter import croniter
|
|||||||
from common.log import logger
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_naive_local(iso_str: str) -> datetime:
|
||||||
|
"""Parse an ISO datetime and coerce it to tz-naive local time.
|
||||||
|
|
||||||
|
The scheduler uses ``datetime.now()`` (tz-naive) for all comparisons,
|
||||||
|
so any persisted timestamp must be normalized to the same flavor —
|
||||||
|
otherwise comparing naive vs aware raises TypeError.
|
||||||
|
"""
|
||||||
|
dt = datetime.fromisoformat(iso_str)
|
||||||
|
if dt.tzinfo is not None:
|
||||||
|
dt = dt.astimezone().replace(tzinfo=None)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
class SchedulerService:
|
class SchedulerService:
|
||||||
"""
|
"""
|
||||||
Background service that executes scheduled tasks
|
Background service that executes scheduled tasks
|
||||||
@@ -39,7 +52,6 @@ class SchedulerService:
|
|||||||
self.running = True
|
self.running = True
|
||||||
self.thread = threading.Thread(target=self._run_loop, daemon=True)
|
self.thread = threading.Thread(target=self._run_loop, daemon=True)
|
||||||
self.thread.start()
|
self.thread.start()
|
||||||
logger.debug("[Scheduler] Service started")
|
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Stop the scheduler service"""
|
"""Stop the scheduler service"""
|
||||||
@@ -54,15 +66,14 @@ class SchedulerService:
|
|||||||
|
|
||||||
def _run_loop(self):
|
def _run_loop(self):
|
||||||
"""Main scheduler loop"""
|
"""Main scheduler loop"""
|
||||||
logger.debug("[Scheduler] Scheduler loop started")
|
logger.info("[Scheduler] Scheduler loop started")
|
||||||
|
|
||||||
while self.running:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
self._check_and_execute_tasks()
|
self._check_and_execute_tasks()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Scheduler] Error in scheduler loop: {e}")
|
logger.error(f"[Scheduler] Error in scheduler loop: {e}")
|
||||||
|
|
||||||
# Sleep for 30 seconds between checks
|
|
||||||
time.sleep(30)
|
time.sleep(30)
|
||||||
|
|
||||||
def _check_and_execute_tasks(self):
|
def _check_and_execute_tasks(self):
|
||||||
@@ -72,12 +83,18 @@ class SchedulerService:
|
|||||||
|
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
try:
|
try:
|
||||||
# Check if task is due
|
|
||||||
if self._is_task_due(task, now):
|
if self._is_task_due(task, now):
|
||||||
logger.info(f"[Scheduler] Executing task: {task['id']} - {task['name']}")
|
logger.info(f"[Scheduler] Executing task: {task['id']} - {task['name']}")
|
||||||
self._execute_task(task)
|
ok = self._execute_task(task)
|
||||||
|
if not ok:
|
||||||
# Update next run time
|
# Leave next_run_at as-is so the next loop retries.
|
||||||
|
# Cron tasks within the catch-up window will keep
|
||||||
|
# firing; beyond it _is_task_due will reschedule.
|
||||||
|
logger.warning(
|
||||||
|
f"[Scheduler] Task {task['id']} delivery failed, will retry next tick"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
next_run = self._calculate_next_run(task, now)
|
next_run = self._calculate_next_run(task, now)
|
||||||
if next_run:
|
if next_run:
|
||||||
self.task_store.update_task(task['id'], {
|
self.task_store.update_task(task['id'], {
|
||||||
@@ -85,12 +102,8 @@ class SchedulerService:
|
|||||||
"last_run_at": now.isoformat()
|
"last_run_at": now.isoformat()
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# One-time task, disable it
|
self.task_store.delete_task(task['id'])
|
||||||
self.task_store.update_task(task['id'], {
|
logger.info(f"[Scheduler] One-time task completed and removed: {task['id']}")
|
||||||
"enabled": False,
|
|
||||||
"last_run_at": now.isoformat()
|
|
||||||
})
|
|
||||||
logger.info(f"[Scheduler] One-time task completed and disabled: {task['id']}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Scheduler] Error processing task {task.get('id')}: {e}")
|
logger.error(f"[Scheduler] Error processing task {task.get('id')}: {e}")
|
||||||
|
|
||||||
@@ -117,37 +130,43 @@ class SchedulerService:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
next_run = datetime.fromisoformat(next_run_str)
|
next_run = _parse_naive_local(next_run_str)
|
||||||
|
|
||||||
# Check if task is overdue (e.g., service restart)
|
|
||||||
if next_run < now:
|
if next_run < now:
|
||||||
time_diff = (now - next_run).total_seconds()
|
time_diff = (now - next_run).total_seconds()
|
||||||
|
schedule = task.get("schedule", {})
|
||||||
# If overdue by more than 5 minutes, skip this run and schedule next
|
schedule_type = schedule.get("type")
|
||||||
if time_diff > 300: # 5 minutes
|
|
||||||
logger.warning(f"[Scheduler] Task {task['id']} is overdue by {int(time_diff)}s, skipping and scheduling next run")
|
# Catch-up window: fire if we're within 10 minutes of the
|
||||||
|
# scheduled tick. Beyond that we'd rather skip than push a
|
||||||
# For one-time tasks, disable them
|
# stale daily report to the user.
|
||||||
schedule = task.get("schedule", {})
|
if time_diff <= 600:
|
||||||
if schedule.get("type") == "once":
|
return True
|
||||||
self.task_store.update_task(task['id'], {
|
|
||||||
"enabled": False,
|
logger.warning(
|
||||||
"last_run_at": now.isoformat()
|
f"[Scheduler] Task {task['id']} is overdue by {int(time_diff)}s, "
|
||||||
})
|
f"skipping and scheduling next run"
|
||||||
logger.info(f"[Scheduler] One-time task {task['id']} expired, disabled")
|
)
|
||||||
return False
|
|
||||||
|
if schedule_type == "once":
|
||||||
# For recurring tasks, calculate next run from now
|
self.task_store.delete_task(task['id'])
|
||||||
next_next_run = self._calculate_next_run(task, now)
|
logger.info(f"[Scheduler] One-time task {task['id']} expired, removed")
|
||||||
if next_next_run:
|
|
||||||
self.task_store.update_task(task['id'], {
|
|
||||||
"next_run_at": next_next_run.isoformat()
|
|
||||||
})
|
|
||||||
logger.info(f"[Scheduler] Rescheduled task {task['id']} to {next_next_run}")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
next_next_run = self._calculate_next_run(task, now)
|
||||||
|
if next_next_run:
|
||||||
|
self.task_store.update_task(task['id'], {
|
||||||
|
"next_run_at": next_next_run.isoformat()
|
||||||
|
})
|
||||||
|
logger.info(f"[Scheduler] Rescheduled task {task['id']} to {next_next_run}")
|
||||||
|
return False
|
||||||
|
|
||||||
return now >= next_run
|
return now >= next_run
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[Scheduler] Failed to evaluate due-state for task "
|
||||||
|
f"{task.get('id')} (next_run_at={next_run_str!r}): {e}"
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _calculate_next_run(self, task: dict, from_time: datetime) -> Optional[datetime]:
|
def _calculate_next_run(self, task: dict, from_time: datetime) -> Optional[datetime]:
|
||||||
@@ -191,30 +210,34 @@ class SchedulerService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
run_at = datetime.fromisoformat(run_at_str)
|
run_at = _parse_naive_local(run_at_str)
|
||||||
# Only return if in the future
|
|
||||||
if run_at > from_time:
|
if run_at > from_time:
|
||||||
return run_at
|
return run_at
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.error(
|
||||||
|
f"[Scheduler] Failed to parse once-task run_at "
|
||||||
|
f"{run_at_str!r}: {e}"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _execute_task(self, task: dict):
|
def _execute_task(self, task: dict) -> bool:
|
||||||
"""
|
"""
|
||||||
Execute a task
|
Execute a task.
|
||||||
|
|
||||||
Args:
|
Returns True if delivery succeeded (caller should advance state),
|
||||||
task: Task dictionary
|
False if it failed (caller should keep next_run_at so the next
|
||||||
|
loop iteration retries). Callback may return None for legacy
|
||||||
|
behaviour, treated as success.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Call the execute callback
|
result = self.execute_callback(task)
|
||||||
self.execute_callback(task)
|
return False if result is False else True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Scheduler] Error executing task {task['id']}: {e}")
|
logger.error(f"[Scheduler] Error executing task {task['id']}: {e}")
|
||||||
# Update task with error
|
|
||||||
self.task_store.update_task(task['id'], {
|
self.task_store.update_task(task['id'], {
|
||||||
"last_error": str(e),
|
"last_error": str(e),
|
||||||
"last_error_at": datetime.now().isoformat()
|
"last_error_at": datetime.now().isoformat()
|
||||||
})
|
})
|
||||||
|
return False
|
||||||
|
|||||||
@@ -158,6 +158,11 @@ class SchedulerTool(BaseTool):
|
|||||||
# Create task
|
# Create task
|
||||||
task_id = str(uuid.uuid4())[:8]
|
task_id = str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
|
# Capture the real chat session_id at task creation time so that scheduler
|
||||||
|
# can later inject the delivered output into the user's actual conversation
|
||||||
|
# (in group chats, session_id != receiver, e.g. "user_id:group_id" on feishu).
|
||||||
|
notify_session_id = context.get("session_id")
|
||||||
|
|
||||||
# Build action based on message or ai_task
|
# Build action based on message or ai_task
|
||||||
if message:
|
if message:
|
||||||
action = {
|
action = {
|
||||||
@@ -166,7 +171,8 @@ class SchedulerTool(BaseTool):
|
|||||||
"receiver": context.get("receiver"),
|
"receiver": context.get("receiver"),
|
||||||
"receiver_name": self._get_receiver_name(context),
|
"receiver_name": self._get_receiver_name(context),
|
||||||
"is_group": context.get("isgroup", False),
|
"is_group": context.get("isgroup", False),
|
||||||
"channel_type": self.config.get("channel_type", "unknown")
|
"channel_type": self.config.get("channel_type", "unknown"),
|
||||||
|
"notify_session_id": notify_session_id,
|
||||||
}
|
}
|
||||||
else: # ai_task
|
else: # ai_task
|
||||||
action = {
|
action = {
|
||||||
@@ -175,7 +181,8 @@ class SchedulerTool(BaseTool):
|
|||||||
"receiver": context.get("receiver"),
|
"receiver": context.get("receiver"),
|
||||||
"receiver_name": self._get_receiver_name(context),
|
"receiver_name": self._get_receiver_name(context),
|
||||||
"is_group": context.get("isgroup", False),
|
"is_group": context.get("isgroup", False),
|
||||||
"channel_type": self.config.get("channel_type", "unknown")
|
"channel_type": self.config.get("channel_type", "unknown"),
|
||||||
|
"notify_session_id": notify_session_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 针对钉钉单聊,额外存储 sender_staff_id
|
# 针对钉钉单聊,额外存储 sender_staff_id
|
||||||
@@ -357,9 +364,12 @@ class SchedulerTool(BaseTool):
|
|||||||
logger.error(f"[SchedulerTool] Invalid relative time format: {schedule_value}")
|
logger.error(f"[SchedulerTool] Invalid relative time format: {schedule_value}")
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
# Absolute time in ISO format
|
# Absolute ISO time. Normalize to tz-naive local so it
|
||||||
datetime.fromisoformat(schedule_value)
|
# stays comparable with the scheduler's datetime.now().
|
||||||
return {"type": "once", "run_at": schedule_value}
|
parsed = datetime.fromisoformat(schedule_value)
|
||||||
|
if parsed.tzinfo is not None:
|
||||||
|
parsed = parsed.astimezone().replace(tzinfo=None)
|
||||||
|
return {"type": "once", "run_at": parsed.isoformat()}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[SchedulerTool] Invalid schedule: {e}")
|
logger.error(f"[SchedulerTool] Invalid schedule: {e}")
|
||||||
|
|||||||
@@ -182,8 +182,15 @@ class TaskStore:
|
|||||||
if enabled_only:
|
if enabled_only:
|
||||||
task_list = [t for t in task_list if t.get("enabled", True)]
|
task_list = [t for t in task_list if t.get("enabled", True)]
|
||||||
|
|
||||||
# Sort by next_run_at
|
# Sort by enabled status (enabled first), then by next_run_at
|
||||||
task_list.sort(key=lambda t: t.get("next_run_at", float('inf')))
|
def sort_key(t):
|
||||||
|
enabled = t.get("enabled", True)
|
||||||
|
next_run = t.get("next_run_at", "")
|
||||||
|
# Enabled tasks first (0), disabled tasks second (1)
|
||||||
|
# Then sort by next_run_at (empty string sorts last)
|
||||||
|
return (0 if enabled else 1, next_run if next_run else "9999-12-31")
|
||||||
|
|
||||||
|
task_list.sort(key=sort_key)
|
||||||
|
|
||||||
return task_list
|
return task_list
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ class Send(BaseTool):
|
|||||||
"""Tool for sending files to the user"""
|
"""Tool for sending files to the user"""
|
||||||
|
|
||||||
name: str = "send"
|
name: str = "send"
|
||||||
description: str = "Send a file (image, video, audio, document) to the user. Use this when the user explicitly asks to send/share a file."
|
description: str = "Send a LOCAL file (image, video, audio, document) to the user. Only for local file paths. Do NOT use this for URLs — URLs should be included directly in your text reply, the system will handle them automatically."
|
||||||
|
|
||||||
params: dict = {
|
params: dict = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"path": {
|
"path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Path to the file to send. Can be absolute path or relative to workspace."
|
"description": "Local file path to send. Must be an absolute path or relative to workspace. Do NOT pass URLs here."
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -54,6 +54,11 @@ class Send(BaseTool):
|
|||||||
if not path:
|
if not path:
|
||||||
return ToolResult.fail("Error: path parameter is required")
|
return ToolResult.fail("Error: path parameter is required")
|
||||||
|
|
||||||
|
# Pass through remote URLs directly (no local file check): the client
|
||||||
|
# renders the link inline, so no download is needed.
|
||||||
|
if path.lower().startswith(("http://", "https://")):
|
||||||
|
return self._build_url_result(path, message)
|
||||||
|
|
||||||
# Resolve path
|
# Resolve path
|
||||||
absolute_path = self._resolve_path(path)
|
absolute_path = self._resolve_path(path)
|
||||||
|
|
||||||
@@ -98,9 +103,60 @@ class Send(BaseTool):
|
|||||||
"size_formatted": self._format_size(file_size),
|
"size_formatted": self._format_size(file_size),
|
||||||
"message": message or f"正在发送 {file_name}"
|
"message": message or f"正在发送 {file_name}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from common.cloud_client import get_website_base_url, copy_send_file
|
||||||
|
|
||||||
|
# Do nothing when in local env
|
||||||
|
if get_website_base_url():
|
||||||
|
url = copy_send_file(absolute_path, self.cwd)
|
||||||
|
if url:
|
||||||
|
result["url"] = url
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return ToolResult.success(result)
|
return ToolResult.success(result)
|
||||||
|
|
||||||
|
def _build_url_result(self, url: str, message: str) -> ToolResult:
|
||||||
|
"""Build a file_to_send result for a remote http(s) URL.
|
||||||
|
|
||||||
|
The URL is passed through as both ``path`` and ``url`` so downstream
|
||||||
|
channels render it inline without downloading it locally.
|
||||||
|
"""
|
||||||
|
# Infer file type from the URL path extension (ignore query string).
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
url_path = urlparse(url).path
|
||||||
|
file_ext = Path(url_path).suffix.lower()
|
||||||
|
file_name = Path(url_path).name or "file"
|
||||||
|
|
||||||
|
if file_ext in self.image_extensions:
|
||||||
|
file_type = "image"
|
||||||
|
mime_type = self._get_image_mime_type(file_ext)
|
||||||
|
elif file_ext in self.video_extensions:
|
||||||
|
file_type = "video"
|
||||||
|
mime_type = self._get_video_mime_type(file_ext)
|
||||||
|
elif file_ext in self.audio_extensions:
|
||||||
|
file_type = "audio"
|
||||||
|
mime_type = self._get_audio_mime_type(file_ext)
|
||||||
|
elif file_ext in self.document_extensions:
|
||||||
|
file_type = "document"
|
||||||
|
mime_type = self._get_document_mime_type(file_ext)
|
||||||
|
else:
|
||||||
|
# Default to image: most pass-through URLs are generated images.
|
||||||
|
file_type = "image"
|
||||||
|
mime_type = "image/jpeg"
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"type": "file_to_send",
|
||||||
|
"file_type": file_type,
|
||||||
|
"path": url,
|
||||||
|
"url": url,
|
||||||
|
"file_name": file_name,
|
||||||
|
"mime_type": mime_type,
|
||||||
|
"message": message or f"正在发送 {file_name}",
|
||||||
|
}
|
||||||
|
return ToolResult.success(result)
|
||||||
|
|
||||||
def _resolve_path(self, path: str) -> str:
|
def _resolve_path(self, path: str) -> str:
|
||||||
"""Resolve path to absolute path"""
|
"""Resolve path to absolute path"""
|
||||||
path = expand_path(path)
|
path = expand_path(path)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import importlib
|
import importlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Type
|
from typing import Dict, Any, Type
|
||||||
from agent.tools.base_tool import BaseTool
|
from agent.tools.base_tool import BaseTool
|
||||||
@@ -7,6 +8,26 @@ from common.log import logger
|
|||||||
from config import conf
|
from config import conf
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_mcp_configs(raw) -> list:
|
||||||
|
"""
|
||||||
|
Convert MCP server config to internal list format.
|
||||||
|
Supports:
|
||||||
|
- list format (mcp_servers): [{"name": "x", "type": "stdio", ...}]
|
||||||
|
- dict format (mcpServers): {"x": {"command": "npx", ...}}
|
||||||
|
"""
|
||||||
|
if isinstance(raw, list):
|
||||||
|
return raw
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
result = []
|
||||||
|
for name, cfg in raw.items():
|
||||||
|
entry = {"name": name, **cfg}
|
||||||
|
if "type" not in entry:
|
||||||
|
entry["type"] = "sse" if "url" in entry else "stdio"
|
||||||
|
result.append(entry)
|
||||||
|
return result
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
class ToolManager:
|
class ToolManager:
|
||||||
"""
|
"""
|
||||||
Tool manager for managing tools.
|
Tool manager for managing tools.
|
||||||
@@ -25,6 +46,47 @@ class ToolManager:
|
|||||||
# Initialize only once
|
# Initialize only once
|
||||||
if not hasattr(self, 'tool_classes'):
|
if not hasattr(self, 'tool_classes'):
|
||||||
self.tool_classes = {} # Dictionary to store tool classes
|
self.tool_classes = {} # Dictionary to store tool classes
|
||||||
|
if not hasattr(self, '_mcp_registry'):
|
||||||
|
self._mcp_registry = None # Lazy init: only created when MCP servers are configured
|
||||||
|
if not hasattr(self, '_mcp_tool_instances'):
|
||||||
|
self._mcp_tool_instances: dict = {} # tool_name -> McpTool instance
|
||||||
|
if not hasattr(self, '_mcp_lock'):
|
||||||
|
# Guards _mcp_loaded check-then-set so concurrent callers
|
||||||
|
# don't trigger duplicate background loaders.
|
||||||
|
self._mcp_lock = threading.Lock()
|
||||||
|
if not hasattr(self, '_mcp_loaded'):
|
||||||
|
# Idempotency flag. Flipped to True the moment the first loader
|
||||||
|
# is dispatched (synchronously, inside _mcp_lock). Subsequent
|
||||||
|
# _load_mcp_tools() calls become no-ops, so per-session agent
|
||||||
|
# initialization never re-forks MCP subprocesses.
|
||||||
|
self._mcp_loaded = False
|
||||||
|
if not hasattr(self, '_mcp_status'):
|
||||||
|
# server_name -> "pending" / "ready" / "failed"
|
||||||
|
# Useful for UI / introspection while async loading is in progress.
|
||||||
|
self._mcp_status: dict = {}
|
||||||
|
if not hasattr(self, '_mcp_signature'):
|
||||||
|
# (mtime, sha256) of mcp.json the last time we loaded.
|
||||||
|
# Used by refresh_mcp_if_changed() to skip re-parsing when nothing changed.
|
||||||
|
self._mcp_signature: tuple = (None, None)
|
||||||
|
if not hasattr(self, '_mcp_active_configs'):
|
||||||
|
# server_name -> normalized config dict, for diff-based reload.
|
||||||
|
self._mcp_active_configs: dict = {}
|
||||||
|
if not hasattr(self, '_mcp_tool_vectors'):
|
||||||
|
# mcp_tool_name -> embedding vector, used by on-demand tool
|
||||||
|
# retrieval. Populated lazily on first retrieval so users who
|
||||||
|
# never enable the feature pay zero embedding cost.
|
||||||
|
self._mcp_tool_vectors: dict = {}
|
||||||
|
if not hasattr(self, '_mcp_vector_lock'):
|
||||||
|
# Guards incremental index builds so concurrent turns don't
|
||||||
|
# double-embed the same newly-loaded MCP tools.
|
||||||
|
self._mcp_vector_lock = threading.Lock()
|
||||||
|
if not hasattr(self, '_embedding_provider_initialized'):
|
||||||
|
# The embedding provider is created once, lazily, and reused for
|
||||||
|
# both tool-index and per-query embeddings. None means keyword-only
|
||||||
|
# mode (no provider configured) — retrieval then falls back to full
|
||||||
|
# injection at the caller.
|
||||||
|
self._embedding_provider_initialized = False
|
||||||
|
self._embedding_provider = None
|
||||||
|
|
||||||
def load_tools(self, tools_dir: str = "", config_dict=None):
|
def load_tools(self, tools_dir: str = "", config_dict=None):
|
||||||
"""
|
"""
|
||||||
@@ -39,6 +101,8 @@ class ToolManager:
|
|||||||
self._load_tools_from_init()
|
self._load_tools_from_init()
|
||||||
self._configure_tools_from_config(config_dict)
|
self._configure_tools_from_config(config_dict)
|
||||||
|
|
||||||
|
self._load_mcp_tools()
|
||||||
|
|
||||||
def _load_tools_from_init(self) -> bool:
|
def _load_tools_from_init(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Load tool classes from tools.__init__.__all__
|
Load tool classes from tools.__init__.__all__
|
||||||
@@ -70,10 +134,14 @@ class ToolManager:
|
|||||||
and cls != BaseTool
|
and cls != BaseTool
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
# Skip memory tools (they need special initialization with memory_manager)
|
# Skip tools that need special initialization
|
||||||
if class_name in ["MemorySearchTool", "MemoryGetTool"]:
|
if class_name in ["MemorySearchTool", "MemoryGetTool"]:
|
||||||
logger.debug(f"Skipped tool {class_name} (requires memory_manager)")
|
logger.debug(f"Skipped tool {class_name} (requires memory_manager)")
|
||||||
continue
|
continue
|
||||||
|
# McpTool instances are registered dynamically via _load_mcp_tools()
|
||||||
|
if class_name == "McpTool":
|
||||||
|
logger.debug(f"Skipped tool {class_name} (registered dynamically via mcp_servers config)")
|
||||||
|
continue
|
||||||
|
|
||||||
# Create a temporary instance to get the name
|
# Create a temporary instance to get the name
|
||||||
temp_instance = cls()
|
temp_instance = cls()
|
||||||
@@ -84,11 +152,11 @@ class ToolManager:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# Handle missing dependencies with helpful messages
|
# Handle missing dependencies with helpful messages
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "browser-use" in error_msg or "browser_use" in error_msg:
|
if "playwright" in error_msg:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
||||||
f" To enable browser tool, run:\n"
|
f" To enable browser tool, run:\n"
|
||||||
f" pip install browser-use markdownify playwright\n"
|
f" pip install playwright\n"
|
||||||
f" playwright install chromium"
|
f" playwright install chromium"
|
||||||
)
|
)
|
||||||
elif "markdownify" in error_msg:
|
elif "markdownify" in error_msg:
|
||||||
@@ -154,11 +222,11 @@ class ToolManager:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# Handle missing dependencies with helpful messages
|
# Handle missing dependencies with helpful messages
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "browser-use" in error_msg or "browser_use" in error_msg:
|
if "playwright" in error_msg:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
||||||
f" To enable browser tool, run:\n"
|
f" To enable browser tool, run:\n"
|
||||||
f" pip install browser-use markdownify playwright\n"
|
f" pip install playwright\n"
|
||||||
f" playwright install chromium"
|
f" playwright install chromium"
|
||||||
)
|
)
|
||||||
elif "markdownify" in error_msg:
|
elif "markdownify" in error_msg:
|
||||||
@@ -197,7 +265,7 @@ class ToolManager:
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] Browser tool is configured but not loaded.\n"
|
f"[ToolManager] Browser tool is configured but not loaded.\n"
|
||||||
f" To enable browser tool, run:\n"
|
f" To enable browser tool, run:\n"
|
||||||
f" pip install browser-use markdownify playwright\n"
|
f" pip install playwright\n"
|
||||||
f" playwright install chromium"
|
f" playwright install chromium"
|
||||||
)
|
)
|
||||||
elif tool_name == "google_search":
|
elif tool_name == "google_search":
|
||||||
@@ -212,6 +280,401 @@ class ToolManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error configuring tools from config: {e}")
|
logger.error(f"Error configuring tools from config: {e}")
|
||||||
|
|
||||||
|
def _mcp_json_path(self) -> str:
|
||||||
|
import os
|
||||||
|
workspace = os.path.expanduser(conf().get("agent_workspace", "~/cow"))
|
||||||
|
return os.path.join(workspace, "mcp.json")
|
||||||
|
|
||||||
|
def _read_mcp_json_signature(self):
|
||||||
|
"""
|
||||||
|
Return (mtime, sha256_of_bytes) for ~/cow/mcp.json without parsing.
|
||||||
|
Returns (None, None) if the file doesn't exist or is unreadable.
|
||||||
|
Cheap enough (one stat + one small read) to call on every agent init.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
path = self._mcp_json_path()
|
||||||
|
try:
|
||||||
|
mtime = os.path.getmtime(path)
|
||||||
|
except OSError:
|
||||||
|
return (None, None)
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
digest = hashlib.sha256(f.read()).hexdigest()
|
||||||
|
except OSError:
|
||||||
|
return (mtime, None)
|
||||||
|
return (mtime, digest)
|
||||||
|
|
||||||
|
def _load_mcp_configs(self) -> list:
|
||||||
|
"""
|
||||||
|
Load MCP server configs with priority:
|
||||||
|
1. ~/cow/mcp.json (supports both mcpServers and mcp_servers keys)
|
||||||
|
2. config.json mcp_servers field (fallback)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
mcp_json_path = self._mcp_json_path()
|
||||||
|
|
||||||
|
if os.path.exists(mcp_json_path):
|
||||||
|
try:
|
||||||
|
with open(mcp_json_path, "r", encoding="utf-8") as f:
|
||||||
|
data = _json.load(f)
|
||||||
|
raw = data.get("mcpServers") or data.get("mcp_servers") or data
|
||||||
|
logger.info(f"[ToolManager] Loading MCP config from {mcp_json_path}")
|
||||||
|
return _normalize_mcp_configs(raw)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[ToolManager] Failed to read {mcp_json_path}: {e}, falling back to config.json")
|
||||||
|
|
||||||
|
raw = conf().get("mcp_servers", [])
|
||||||
|
return _normalize_mcp_configs(raw)
|
||||||
|
|
||||||
|
def _load_mcp_tools(self):
|
||||||
|
"""
|
||||||
|
Trigger MCP tool loading in a background thread (idempotent).
|
||||||
|
|
||||||
|
Returns immediately. Booting MCP servers (npx, uvx, etc.) takes
|
||||||
|
seconds to tens of seconds on first run, which would otherwise
|
||||||
|
block agent initialization and the user's first message.
|
||||||
|
Built-in tools work fine without MCP, so we let the agent serve
|
||||||
|
traffic right away and let MCP servers come online in the
|
||||||
|
background. Per-session agents read a snapshot of whatever is
|
||||||
|
ready at construction time and gracefully ignore the rest.
|
||||||
|
"""
|
||||||
|
with self._mcp_lock:
|
||||||
|
if self._mcp_loaded:
|
||||||
|
return
|
||||||
|
mcp_servers_config = self._load_mcp_configs()
|
||||||
|
# Snapshot the signature now so future refresh_mcp_if_changed()
|
||||||
|
# calls can short-circuit when nothing has changed on disk.
|
||||||
|
self._mcp_signature = self._read_mcp_json_signature()
|
||||||
|
self._mcp_active_configs = {
|
||||||
|
cfg.get("name", "<unnamed>"): cfg for cfg in mcp_servers_config
|
||||||
|
}
|
||||||
|
if not mcp_servers_config:
|
||||||
|
# Mark as loaded even when there is nothing to load,
|
||||||
|
# so we don't re-read the config file on every call.
|
||||||
|
self._mcp_loaded = True
|
||||||
|
return
|
||||||
|
|
||||||
|
# Mark pending immediately so list_mcp_status() callers see
|
||||||
|
# the in-progress state instead of an empty dict.
|
||||||
|
for cfg in mcp_servers_config:
|
||||||
|
name = cfg.get("name", "<unnamed>")
|
||||||
|
self._mcp_status[name] = "pending"
|
||||||
|
|
||||||
|
self._mcp_loaded = True
|
||||||
|
threading.Thread(
|
||||||
|
target=self._load_mcp_tools_async,
|
||||||
|
args=(mcp_servers_config,),
|
||||||
|
daemon=True,
|
||||||
|
name="mcp-loader",
|
||||||
|
).start()
|
||||||
|
logger.info(
|
||||||
|
f"[ToolManager] MCP loading started in background "
|
||||||
|
f"({len(mcp_servers_config)} server(s) configured)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def refresh_mcp_if_changed(self):
|
||||||
|
"""
|
||||||
|
Cheap check whether ~/cow/mcp.json has changed since last load.
|
||||||
|
If it has, do a diff-based reload: start newly added servers,
|
||||||
|
shut down removed ones, and restart any whose config was edited.
|
||||||
|
Untouched servers are left running.
|
||||||
|
|
||||||
|
Designed to be called on every agent creation. The fast path is
|
||||||
|
a single os.stat() — completely free when nothing has changed.
|
||||||
|
"""
|
||||||
|
with self._mcp_lock:
|
||||||
|
new_sig = self._read_mcp_json_signature()
|
||||||
|
if new_sig == self._mcp_signature:
|
||||||
|
return # no-op fast path
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_configs = self._load_mcp_configs()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[ToolManager] MCP reload — failed to parse config: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
new_by_name = {
|
||||||
|
cfg.get("name", "<unnamed>"): cfg for cfg in new_configs
|
||||||
|
}
|
||||||
|
old_by_name = self._mcp_active_configs
|
||||||
|
|
||||||
|
added = [n for n in new_by_name if n not in old_by_name]
|
||||||
|
removed = [n for n in old_by_name if n not in new_by_name]
|
||||||
|
changed = [
|
||||||
|
n for n in new_by_name
|
||||||
|
if n in old_by_name and new_by_name[n] != old_by_name[n]
|
||||||
|
]
|
||||||
|
|
||||||
|
if not (added or removed or changed):
|
||||||
|
# Signature drifted but content is logically identical
|
||||||
|
# (e.g. user re-saved the file without edits). Just sync.
|
||||||
|
self._mcp_signature = new_sig
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[ToolManager] mcp.json changed — "
|
||||||
|
f"adding={added}, removing={removed}, restarting={changed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tear down removed + changed servers (changed ones get restarted below)
|
||||||
|
for name in removed + changed:
|
||||||
|
self._teardown_mcp_server(name)
|
||||||
|
|
||||||
|
# Spin up newly added + changed servers in the background
|
||||||
|
to_start = [new_by_name[n] for n in added + changed]
|
||||||
|
if to_start:
|
||||||
|
for cfg in to_start:
|
||||||
|
self._mcp_status[cfg.get("name", "<unnamed>")] = "pending"
|
||||||
|
threading.Thread(
|
||||||
|
target=self._load_mcp_tools_async,
|
||||||
|
args=(to_start,),
|
||||||
|
daemon=True,
|
||||||
|
name="mcp-loader-reload",
|
||||||
|
).start()
|
||||||
|
|
||||||
|
self._mcp_active_configs = new_by_name
|
||||||
|
self._mcp_signature = new_sig
|
||||||
|
|
||||||
|
def _teardown_mcp_server(self, server_name: str):
|
||||||
|
"""Shut down one MCP server and drop its tools from the registry."""
|
||||||
|
if self._mcp_registry is None:
|
||||||
|
return
|
||||||
|
client = None
|
||||||
|
with self._mcp_registry._registry_lock:
|
||||||
|
client = self._mcp_registry._clients.pop(server_name, None)
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
client.shutdown()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[MCP] Error shutting down '{server_name}': {e}")
|
||||||
|
# Drop tools that belonged to this server.
|
||||||
|
for tool_name in list(self._mcp_tool_instances.keys()):
|
||||||
|
tool = self._mcp_tool_instances.get(tool_name)
|
||||||
|
if tool is not None and getattr(tool, "server_name", None) == server_name:
|
||||||
|
self._mcp_tool_instances.pop(tool_name, None)
|
||||||
|
self._mcp_status.pop(server_name, None)
|
||||||
|
|
||||||
|
def _load_mcp_tools_async(self, mcp_servers_config):
|
||||||
|
"""
|
||||||
|
Background worker: bring up each MCP server one-by-one and
|
||||||
|
publish ready tools to _mcp_tool_instances as they come online.
|
||||||
|
|
||||||
|
Server failures are isolated — one bad server cannot block
|
||||||
|
the others, and never raises out of the worker thread.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from agent.tools.mcp.mcp_client import McpClient, McpClientRegistry
|
||||||
|
from agent.tools.mcp.mcp_tool import McpTool
|
||||||
|
|
||||||
|
registry = McpClientRegistry()
|
||||||
|
self._mcp_registry = registry
|
||||||
|
|
||||||
|
for cfg in mcp_servers_config:
|
||||||
|
server_name = cfg.get("name", "<unnamed>")
|
||||||
|
try:
|
||||||
|
client = McpClient(cfg)
|
||||||
|
if not client.initialize():
|
||||||
|
self._mcp_status[server_name] = "failed"
|
||||||
|
logger.warning(
|
||||||
|
f"[MCP] Server '{server_name}' failed to initialize — skipping"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
tool_schemas = client.list_tools()
|
||||||
|
added = []
|
||||||
|
for schema in tool_schemas:
|
||||||
|
tool_name = schema.get("name", "")
|
||||||
|
if not tool_name:
|
||||||
|
continue
|
||||||
|
mcp_tool = McpTool(client, schema, server_name)
|
||||||
|
# Atomic dict assignment is GIL-safe; readers iterate
|
||||||
|
# over a list() snapshot to avoid concurrent mutation.
|
||||||
|
self._mcp_tool_instances[tool_name] = mcp_tool
|
||||||
|
added.append(tool_name)
|
||||||
|
|
||||||
|
# Register client into the shared registry only after its
|
||||||
|
# tools are visible, so callers never see a half-loaded server.
|
||||||
|
with registry._registry_lock:
|
||||||
|
registry._clients[server_name] = client
|
||||||
|
self._mcp_status[server_name] = "ready"
|
||||||
|
logger.info(
|
||||||
|
f"[MCP] Server '{server_name}' ready — "
|
||||||
|
f"{len(added)} tool(s): {added}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self._mcp_status[server_name] = "failed"
|
||||||
|
logger.warning(f"[MCP] Server '{server_name}' load failed: {e}")
|
||||||
|
|
||||||
|
ready = sum(1 for s in self._mcp_status.values() if s == "ready")
|
||||||
|
total = len(self._mcp_status)
|
||||||
|
logger.info(
|
||||||
|
f"[ToolManager] MCP loading complete: "
|
||||||
|
f"{ready}/{total} server(s) ready, "
|
||||||
|
f"{len(self._mcp_tool_instances)} tool(s) available"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[ToolManager] MCP background loader crashed: {e}")
|
||||||
|
|
||||||
|
def list_mcp_status(self) -> dict:
|
||||||
|
"""Return {server_name: status} snapshot for UI / debugging."""
|
||||||
|
return dict(self._mcp_status)
|
||||||
|
|
||||||
|
def sync_mcp_into_agent(self, agent) -> tuple:
|
||||||
|
"""
|
||||||
|
Reconcile a live agent's tool collection with the current MCP tool registry.
|
||||||
|
|
||||||
|
Adds tools that finished loading after the agent was created,
|
||||||
|
and removes tools whose MCP server was torn down. Built-in tools
|
||||||
|
on the agent are left untouched.
|
||||||
|
|
||||||
|
Handles both representations CowAgent uses:
|
||||||
|
- Agent.tools: list[BaseTool] (default Agent class)
|
||||||
|
- AgentStream.tools: dict[str, BaseTool] (streaming agent)
|
||||||
|
|
||||||
|
Returns (added_names, removed_names) for logging.
|
||||||
|
"""
|
||||||
|
if agent is None or not hasattr(agent, "tools"):
|
||||||
|
return ([], [])
|
||||||
|
|
||||||
|
# Never re-inject MCP tools into a restricted Self-Evolution review agent.
|
||||||
|
# The review agent is created with a deliberately reduced, workspace-guarded
|
||||||
|
# toolset; silently re-adding configured MCP tools here would bypass that
|
||||||
|
# policy boundary (see agent/evolution/executor.py). The flag may live on
|
||||||
|
# the agent itself (Agent) or on the wrapping stream executor's .agent.
|
||||||
|
if getattr(agent, "_evolution_restricted", False) or getattr(
|
||||||
|
getattr(agent, "agent", None), "_evolution_restricted", False
|
||||||
|
):
|
||||||
|
return ([], [])
|
||||||
|
|
||||||
|
from agent.tools.mcp.mcp_tool import McpTool
|
||||||
|
current = self._mcp_tool_instances
|
||||||
|
registry_names = set(current.keys())
|
||||||
|
|
||||||
|
agent_tools = agent.tools
|
||||||
|
|
||||||
|
if isinstance(agent_tools, dict):
|
||||||
|
agent_mcp_names = {
|
||||||
|
name for name, tool in agent_tools.items()
|
||||||
|
if isinstance(tool, McpTool)
|
||||||
|
}
|
||||||
|
added = registry_names - agent_mcp_names
|
||||||
|
removed = agent_mcp_names - registry_names
|
||||||
|
if not (added or removed):
|
||||||
|
return ([], [])
|
||||||
|
for name in added:
|
||||||
|
agent_tools[name] = current[name]
|
||||||
|
for name in removed:
|
||||||
|
agent_tools.pop(name, None)
|
||||||
|
|
||||||
|
elif isinstance(agent_tools, list):
|
||||||
|
agent_mcp_names = {
|
||||||
|
t.name for t in agent_tools if isinstance(t, McpTool)
|
||||||
|
}
|
||||||
|
added = registry_names - agent_mcp_names
|
||||||
|
removed = agent_mcp_names - registry_names
|
||||||
|
if not (added or removed):
|
||||||
|
return ([], [])
|
||||||
|
if removed:
|
||||||
|
agent.tools = [
|
||||||
|
t for t in agent_tools
|
||||||
|
if not (isinstance(t, McpTool) and t.name in removed)
|
||||||
|
]
|
||||||
|
for name in added:
|
||||||
|
agent.tools.append(current[name])
|
||||||
|
|
||||||
|
else:
|
||||||
|
return ([], [])
|
||||||
|
|
||||||
|
return (sorted(added), sorted(removed))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# On-demand MCP tool retrieval support
|
||||||
|
#
|
||||||
|
# The vector index and the embedding provider are owned here (singleton,
|
||||||
|
# process-wide, aligned with the MCP tool lifecycle). The context-aware
|
||||||
|
# selection itself lives in agent.tools.mcp.tool_retrieval, driven by the
|
||||||
|
# executor which is the only place that knows the conversation context.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def count_mcp_tools(self) -> int:
|
||||||
|
"""Return the number of currently loaded MCP tools."""
|
||||||
|
return len(self._mcp_tool_instances)
|
||||||
|
|
||||||
|
def get_mcp_tool_vectors(self) -> dict:
|
||||||
|
"""Return ``{mcp_tool_name: vector}`` for currently loaded MCP tools.
|
||||||
|
|
||||||
|
Lazily embeds any MCP tools not yet in the cache (MCP servers load
|
||||||
|
asynchronously, so tools may appear over time). Returns an empty dict
|
||||||
|
when no embedding provider is available or embedding fails — the caller
|
||||||
|
then falls back to full injection. Never raises.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self._ensure_mcp_tool_vectors()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[ToolManager] MCP tool vector build skipped: {e}")
|
||||||
|
return dict(self._mcp_tool_vectors)
|
||||||
|
|
||||||
|
def embed_query(self, text: str):
|
||||||
|
"""Embed a retrieval query with the shared provider.
|
||||||
|
|
||||||
|
Returns the embedding vector, or None if no provider is available or
|
||||||
|
the call fails (caller falls back to full injection). Never raises.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
provider = self._get_embedding_provider()
|
||||||
|
if provider is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return provider.embed_query(text)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[ToolManager] query embedding failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _ensure_mcp_tool_vectors(self) -> None:
|
||||||
|
"""Incrementally embed MCP tools that are not yet cached."""
|
||||||
|
# Snapshot to avoid concurrent-mutation while the async loader runs.
|
||||||
|
current = dict(self._mcp_tool_instances)
|
||||||
|
missing = [name for name in current if name not in self._mcp_tool_vectors]
|
||||||
|
if not missing:
|
||||||
|
return
|
||||||
|
|
||||||
|
provider = self._get_embedding_provider()
|
||||||
|
if provider is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._mcp_vector_lock:
|
||||||
|
# Re-check under lock: another thread may have filled these in.
|
||||||
|
missing = [name for name in current if name not in self._mcp_tool_vectors]
|
||||||
|
if not missing:
|
||||||
|
return
|
||||||
|
texts = [self._mcp_tool_embed_text(current[name]) for name in missing]
|
||||||
|
vectors = provider.embed_batch(texts)
|
||||||
|
for name, vec in zip(missing, vectors):
|
||||||
|
self._mcp_tool_vectors[name] = vec
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mcp_tool_embed_text(tool) -> str:
|
||||||
|
"""Build the text that represents an MCP tool for embedding."""
|
||||||
|
name = getattr(tool, "name", "") or ""
|
||||||
|
description = getattr(tool, "description", "") or ""
|
||||||
|
return f"{name}: {description}".strip()
|
||||||
|
|
||||||
|
def _get_embedding_provider(self):
|
||||||
|
"""Lazily create and cache the shared embedding provider (or None)."""
|
||||||
|
if not self._embedding_provider_initialized:
|
||||||
|
try:
|
||||||
|
from agent.memory.embedding import create_default_embedding_provider
|
||||||
|
self._embedding_provider = create_default_embedding_provider()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[ToolManager] embedding provider init failed: {e}")
|
||||||
|
self._embedding_provider = None
|
||||||
|
self._embedding_provider_initialized = True
|
||||||
|
return self._embedding_provider
|
||||||
|
|
||||||
def create_tool(self, name: str) -> BaseTool:
|
def create_tool(self, name: str) -> BaseTool:
|
||||||
"""
|
"""
|
||||||
Get a new instance of a tool by name.
|
Get a new instance of a tool by name.
|
||||||
@@ -229,6 +692,12 @@ class ToolManager:
|
|||||||
tool_instance.config = self.tool_configs[name]
|
tool_instance.config = self.tool_configs[name]
|
||||||
|
|
||||||
return tool_instance
|
return tool_instance
|
||||||
|
|
||||||
|
# Fall back to MCP tool instances
|
||||||
|
mcp_tool = self._mcp_tool_instances.get(name)
|
||||||
|
if mcp_tool:
|
||||||
|
return mcp_tool
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def list_tools(self) -> dict:
|
def list_tools(self) -> dict:
|
||||||
@@ -245,4 +714,17 @@ class ToolManager:
|
|||||||
"description": temp_instance.description,
|
"description": temp_instance.description,
|
||||||
"parameters": temp_instance.get_json_schema()
|
"parameters": temp_instance.get_json_schema()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Include MCP tool instances
|
||||||
|
for name, mcp_tool in self._mcp_tool_instances.items():
|
||||||
|
result[name] = {
|
||||||
|
"description": mcp_tool.description,
|
||||||
|
"parameters": mcp_tool.params,
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def shutdown_mcp(self):
|
||||||
|
"""Shut down all MCP server clients."""
|
||||||
|
if self._mcp_registry:
|
||||||
|
self._mcp_registry.shutdown_all()
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ from .diff import (
|
|||||||
FuzzyMatchResult
|
FuzzyMatchResult
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .url_safety import (
|
||||||
|
validate_url_safe,
|
||||||
|
assert_public_ip
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'truncate_head',
|
'truncate_head',
|
||||||
'truncate_tail',
|
'truncate_tail',
|
||||||
@@ -36,5 +41,7 @@ __all__ = [
|
|||||||
'normalize_for_fuzzy_match',
|
'normalize_for_fuzzy_match',
|
||||||
'fuzzy_find_text',
|
'fuzzy_find_text',
|
||||||
'generate_diff_string',
|
'generate_diff_string',
|
||||||
'FuzzyMatchResult'
|
'FuzzyMatchResult',
|
||||||
|
'validate_url_safe',
|
||||||
|
'assert_public_ip'
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -111,20 +111,46 @@ def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
|
|||||||
content_for_replacement=content
|
content_for_replacement=content
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try fuzzy match
|
# Fuzzy match: the exact substring was not found, most likely because the
|
||||||
fuzzy_content = normalize_for_fuzzy_match(content)
|
# whitespace differs (indentation, spaces around operators, trailing
|
||||||
fuzzy_old_text = normalize_for_fuzzy_match(old_text)
|
# spaces). Locate the region in the ORIGINAL content using a
|
||||||
|
# whitespace-flexible pattern and return offsets into that original
|
||||||
index = fuzzy_content.find(fuzzy_old_text)
|
# content.
|
||||||
if index != -1:
|
#
|
||||||
# Fuzzy match successful, use normalized content for replacement
|
# This must NOT replace inside a whitespace-normalized copy of the file:
|
||||||
return FuzzyMatchResult(
|
# doing so previously returned the normalized copy as
|
||||||
found=True,
|
# content_for_replacement, which caused the whole file to be rewritten
|
||||||
index=index,
|
# with collapsed indentation (every untouched line got reformatted).
|
||||||
match_length=len(fuzzy_old_text),
|
stripped = old_text.strip('\n')
|
||||||
content_for_replacement=fuzzy_content
|
if stripped.strip():
|
||||||
)
|
source_lines = stripped.split('\n')
|
||||||
|
line_patterns = []
|
||||||
|
for i, line in enumerate(source_lines):
|
||||||
|
tokens = line.split()
|
||||||
|
if not tokens:
|
||||||
|
line_patterns.append(r'[ \t]*')
|
||||||
|
continue
|
||||||
|
# Tolerate any run of blanks between tokens.
|
||||||
|
core = r'[ \t]+'.join(re.escape(tok) for tok in tokens)
|
||||||
|
# First-line leading whitespace is folded into the match only when
|
||||||
|
# old_text itself was indented here; otherwise it stays OUTSIDE the
|
||||||
|
# match so a no-indent old_text preserves (does not swallow and drop)
|
||||||
|
# the file's existing indentation -- mirroring an exact substring
|
||||||
|
# match. Inner lines always tolerate indentation: it sits inside the
|
||||||
|
# matched region and is re-supplied by new_text.
|
||||||
|
if i > 0 or line[:1] in (' ', '\t'):
|
||||||
|
core = r'[ \t]*' + core
|
||||||
|
line_patterns.append(core + r'[ \t]*')
|
||||||
|
pattern = '\n'.join(line_patterns)
|
||||||
|
match = re.search(pattern, content)
|
||||||
|
if match:
|
||||||
|
return FuzzyMatchResult(
|
||||||
|
found=True,
|
||||||
|
index=match.start(),
|
||||||
|
match_length=match.end() - match.start(),
|
||||||
|
content_for_replacement=content
|
||||||
|
)
|
||||||
|
|
||||||
# Not found
|
# Not found
|
||||||
return FuzzyMatchResult(found=False)
|
return FuzzyMatchResult(found=False)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ Truncation is based on two independent limits - whichever is hit first wins:
|
|||||||
Never returns partial lines (except bash tail truncation edge case).
|
Never returns partial lines (except bash tail truncation edge case).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, Any, Optional, Literal, Tuple
|
from __future__ import annotations
|
||||||
|
from typing import Dict, Any, Optional, Tuple, TYPE_CHECKING
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_MAX_LINES = 2000
|
DEFAULT_MAX_LINES = 2000
|
||||||
|
|||||||
96
agent/tools/utils/url_safety.py
Normal file
96
agent/tools/utils/url_safety.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""
|
||||||
|
Shared SSRF guard utilities for tools that fetch model-supplied URLs.
|
||||||
|
|
||||||
|
SSRF protection is OPT-IN and disabled by default, because legitimate use
|
||||||
|
cases (local dev servers, LAN services, proxy fake-ip resolution) need to
|
||||||
|
reach non-public addresses. Enable it by setting the config option
|
||||||
|
``web_security_ssrf_protection: true`` (or env ``WEB_SECURITY_SSRF_PROTECTION``).
|
||||||
|
|
||||||
|
When enabled, a URL is only considered safe when it uses an http/https
|
||||||
|
scheme, has a hostname, that hostname resolves, and every resolved address
|
||||||
|
is a public (internet-routable) address. Loopback, private (RFC1918 / ULA),
|
||||||
|
link-local (incl. the 169.254.169.254 cloud-metadata endpoint) and otherwise
|
||||||
|
reserved addresses are rejected, for both IPv4 and IPv6.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def _ssrf_protection_enabled() -> bool:
|
||||||
|
"""Return True only when SSRF protection is explicitly turned on.
|
||||||
|
|
||||||
|
Disabled by default. Reads the env var first, then falls back to the
|
||||||
|
global config; any failure to read config is treated as "disabled" so
|
||||||
|
the guard never breaks normal fetching.
|
||||||
|
"""
|
||||||
|
env = os.getenv("WEB_SECURITY_SSRF_PROTECTION")
|
||||||
|
if env is not None:
|
||||||
|
return env.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
try:
|
||||||
|
from config import conf
|
||||||
|
return bool(conf().get("web_security_ssrf_protection", False))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_blocked_ip(ip: "ipaddress._BaseAddress") -> bool:
|
||||||
|
"""Return True if the address is not safe to connect to (non-public)."""
|
||||||
|
return (
|
||||||
|
ip.is_private
|
||||||
|
or ip.is_loopback
|
||||||
|
or ip.is_link_local
|
||||||
|
or ip.is_reserved
|
||||||
|
or ip.is_multicast
|
||||||
|
or ip.is_unspecified
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_public_ip(ip_str: str) -> None:
|
||||||
|
"""Raise ValueError if the given literal IP is a non-public address.
|
||||||
|
|
||||||
|
No-op when SSRF protection is disabled (the default). Used to re-validate
|
||||||
|
the concrete address a redirect resolved to.
|
||||||
|
"""
|
||||||
|
if not _ssrf_protection_enabled():
|
||||||
|
return
|
||||||
|
ip = ipaddress.ip_address(ip_str)
|
||||||
|
if _is_blocked_ip(ip):
|
||||||
|
raise ValueError(
|
||||||
|
f"URL resolves to a non-public address ({ip_str}), "
|
||||||
|
f"request blocked for security"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_url_safe(url: str) -> None:
|
||||||
|
"""Reject URLs that target private/loopback/link-local addresses (SSRF guard).
|
||||||
|
|
||||||
|
No-op when SSRF protection is disabled (the default). When enabled,
|
||||||
|
resolves the hostname to its IP address(es) and blocks any that fall
|
||||||
|
into non-public ranges. Also rejects URLs with no host, non-HTTP(S)
|
||||||
|
schemes, or hosts that fail DNS resolution.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: if the URL targets a disallowed address.
|
||||||
|
"""
|
||||||
|
if not _ssrf_protection_enabled():
|
||||||
|
return
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
|
||||||
|
|
||||||
|
hostname = parsed.hostname
|
||||||
|
if not hostname:
|
||||||
|
raise ValueError("URL has no hostname")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Resolve all addresses for the hostname.
|
||||||
|
addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||||
|
except socket.gaierror:
|
||||||
|
raise ValueError(f"Cannot resolve hostname: {hostname}")
|
||||||
|
|
||||||
|
for family, _, _, _, sockaddr in addr_infos:
|
||||||
|
assert_public_ip(sockaddr[0])
|
||||||
1
agent/tools/vision/__init__.py
Normal file
1
agent/tools/vision/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from agent.tools.vision.vision import Vision
|
||||||
866
agent/tools/vision/vision.py
Normal file
866
agent/tools/vision/vision.py
Normal file
@@ -0,0 +1,866 @@
|
|||||||
|
"""
|
||||||
|
Vision tool - Analyze images using Vision API.
|
||||||
|
Supports local files (auto base64-encoded) and HTTP URLs.
|
||||||
|
|
||||||
|
Provider resolution:
|
||||||
|
- tools.vision.model (if set) means "prefer this model first; fall back to
|
||||||
|
other configured providers if it fails". The model name is mapped to its
|
||||||
|
native provider (e.g. doubao-* → Doubao, kimi-* → Moonshot, gpt-* →
|
||||||
|
OpenAI/LinkAI). That provider is tried first, then the standard auto
|
||||||
|
chain runs as fallback (with the preferred provider de-duplicated).
|
||||||
|
- Auto chain priority:
|
||||||
|
1. Main model via bot.call_vision — only when the main bot is known
|
||||||
|
to actually support vision (not just expose a call_vision method).
|
||||||
|
2. Other models whose API key is configured.
|
||||||
|
3. OpenAI / LinkAI raw HTTP.
|
||||||
|
When use_linkai=true, LinkAI is promoted to #1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
|
from agent.tools.utils.url_safety import validate_url_safe
|
||||||
|
from common import const
|
||||||
|
from common.log import logger
|
||||||
|
from config import conf
|
||||||
|
|
||||||
|
DEFAULT_MODEL = const.GPT_41_MINI
|
||||||
|
DEFAULT_TIMEOUT = 180
|
||||||
|
MAX_TOKENS = 4000
|
||||||
|
COMPRESS_THRESHOLD = 1_048_576 # 1 MB
|
||||||
|
|
||||||
|
SUPPORTED_EXTENSIONS = {
|
||||||
|
"jpg": "image/jpeg",
|
||||||
|
"jpeg": "image/jpeg",
|
||||||
|
"png": "image/png",
|
||||||
|
"gif": "image/gif",
|
||||||
|
"webp": "image/webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
_MAIN_MODEL_PROVIDER_NAME = "MainModel"
|
||||||
|
|
||||||
|
# (config_key_for_api_key, bot_type, default_vision_model, provider_display_name)
|
||||||
|
# Auto-discovered as fallback vision providers when their API key is configured.
|
||||||
|
# OpenAI and LinkAI are handled separately (raw HTTP providers), so not listed here.
|
||||||
|
_DISCOVERABLE_MODELS = [
|
||||||
|
("moonshot_api_key", const.MOONSHOT, const.KIMI_K2_6, "Moonshot"),
|
||||||
|
("ark_api_key", const.DOUBAO, const.DOUBAO_SEED_2_PRO, "Doubao"),
|
||||||
|
("dashscope_api_key", const.QWEN_DASHSCOPE, const.QWEN37_PLUS, "DashScope"),
|
||||||
|
("claude_api_key", const.CLAUDEAPI, const.CLAUDE_SONNET_5, "Claude"),
|
||||||
|
("gemini_api_key", const.GEMINI, const.GEMINI_35_FLASH, "Gemini"),
|
||||||
|
("qianfan_api_key", const.QIANFAN, const.ERNIE_45_TURBO_VL, "Qianfan"),
|
||||||
|
("zhipu_ai_api_key", const.ZHIPU_AI, const.GLM_4_7, "ZhipuAI"),
|
||||||
|
("minimax_api_key", const.MiniMax, const.MINIMAX_M2_7, "MiniMax"),
|
||||||
|
("mimo_api_key", const.MIMO, const.MIMO_V2_5_PRO, "MiMo"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Model name prefix → discoverable provider display_name.
|
||||||
|
# Used to auto-route tools.vision.model to its native provider.
|
||||||
|
# Matched case-insensitively; longest prefix wins.
|
||||||
|
_MODEL_PREFIX_TO_PROVIDER = [
|
||||||
|
("doubao-", "Doubao"),
|
||||||
|
("kimi-", "Moonshot"),
|
||||||
|
("moonshot-", "Moonshot"),
|
||||||
|
("qwen", "DashScope"), # qwen-*, qwen3-*, qwen3.6-*, etc.
|
||||||
|
("claude-", "Claude"),
|
||||||
|
("ernie-", "Qianfan"),
|
||||||
|
("gemini-", "Gemini"),
|
||||||
|
("glm-", "ZhipuAI"),
|
||||||
|
("minimax-", "MiniMax"),
|
||||||
|
("abab", "MiniMax"),
|
||||||
|
("mimo-", "MiMo"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Model prefixes that natively belong to OpenAI / LinkAI (raw HTTP providers).
|
||||||
|
_OPENAI_MODEL_PREFIXES = ("gpt-", "o1-", "o3-", "o4-", "chatgpt-")
|
||||||
|
|
||||||
|
# Maps the UI provider id (persisted in tools.vision.provider) to the internal
|
||||||
|
# display name used in VisionProvider.name. Keep in sync with _DISCOVERABLE_MODELS
|
||||||
|
# and the openai/linkai branches in _route_by_model_name.
|
||||||
|
_PROVIDER_ID_TO_DISPLAY = {
|
||||||
|
"openai": "OpenAI",
|
||||||
|
"linkai": "LinkAI",
|
||||||
|
"moonshot": "Moonshot",
|
||||||
|
"doubao": "Doubao",
|
||||||
|
"dashscope": "DashScope",
|
||||||
|
"claudeAPI": "Claude",
|
||||||
|
"gemini": "Gemini",
|
||||||
|
"qianfan": "Qianfan",
|
||||||
|
"zhipu": "ZhipuAI",
|
||||||
|
"minimax": "MiniMax",
|
||||||
|
"mimo": "MiMo",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VisionProvider:
|
||||||
|
"""A single Vision API provider configuration."""
|
||||||
|
name: str
|
||||||
|
api_key: str
|
||||||
|
api_base: str
|
||||||
|
extra_headers: dict = field(default_factory=dict)
|
||||||
|
model_override: Optional[str] = None
|
||||||
|
use_bot: bool = False # When True, call via bot.call_vision instead of raw HTTP
|
||||||
|
fallback_bot: Any = None # Bot instance for non-main-model providers
|
||||||
|
|
||||||
|
|
||||||
|
class VisionAPIError(Exception):
|
||||||
|
"""Raised when a Vision API call fails and should trigger fallback."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Vision(BaseTool):
|
||||||
|
"""Analyze images using Vision API"""
|
||||||
|
|
||||||
|
name: str = "vision"
|
||||||
|
description: str = (
|
||||||
|
"Analyze a local image or image URL (jpg/jpeg/png) using Vision API. "
|
||||||
|
"Can describe content, extract text, identify objects, colors, etc. "
|
||||||
|
)
|
||||||
|
|
||||||
|
params: dict = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"image": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Local file path or HTTP(S) URL of the image to analyze",
|
||||||
|
},
|
||||||
|
"question": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Question to ask about the image",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["image", "question"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, config: dict = None):
|
||||||
|
self.config = config or {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_available() -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
image = args.get("image", "").strip()
|
||||||
|
question = args.get("question", "").strip()
|
||||||
|
|
||||||
|
if not image:
|
||||||
|
return ToolResult.fail("Error: 'image' parameter is required")
|
||||||
|
if not question:
|
||||||
|
return ToolResult.fail("Error: 'question' parameter is required")
|
||||||
|
|
||||||
|
providers = self._resolve_providers()
|
||||||
|
if not providers:
|
||||||
|
return ToolResult.fail(
|
||||||
|
"Error: No model available for Vision.\n"
|
||||||
|
"The main model does not support vision and no other API keys are configured.\n"
|
||||||
|
"Options:\n"
|
||||||
|
" 1. Switch to a multimodal model (e.g. claude-sonnet-5, qwen3.7-plus, gemini-2.0-flash, ernie-4.5-turbo-vl)\n"
|
||||||
|
" 2. Configure OPENAI_API_KEY: env_config(action=\"set\", key=\"OPENAI_API_KEY\", value=\"your-key\")\n"
|
||||||
|
" 3. Configure LINKAI_API_KEY: env_config(action=\"set\", key=\"LINKAI_API_KEY\", value=\"your-key\")"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
image_content = self._build_image_content(image)
|
||||||
|
except Exception as e:
|
||||||
|
return ToolResult.fail(f"Error: {e}")
|
||||||
|
|
||||||
|
# Default model is only used as a last-resort placeholder for providers
|
||||||
|
# whose VisionProvider.model_override is None (e.g. raw OpenAI provider
|
||||||
|
# when the user did not configure tools.vision.model).
|
||||||
|
return self._call_with_fallback(providers, DEFAULT_MODEL, question, image_content)
|
||||||
|
|
||||||
|
def _call_with_fallback(self, providers: List[VisionProvider], model: str,
|
||||||
|
question: str, image_content: dict) -> ToolResult:
|
||||||
|
"""Try each provider in order; fall back to the next one on failure."""
|
||||||
|
errors: List[str] = []
|
||||||
|
for i, provider in enumerate(providers):
|
||||||
|
use_model = provider.model_override or model
|
||||||
|
try:
|
||||||
|
logger.info(f"[Vision] Trying provider '{provider.name}' "
|
||||||
|
f"with model '{use_model}' ({i + 1}/{len(providers)})")
|
||||||
|
if provider.use_bot:
|
||||||
|
result = self._call_via_bot(use_model, question, image_content, provider)
|
||||||
|
else:
|
||||||
|
result = self._call_api(provider, use_model, question, image_content)
|
||||||
|
logger.info(f"[Vision] ✅ Success via {provider.name} (model={use_model})")
|
||||||
|
return result
|
||||||
|
except VisionAPIError as e:
|
||||||
|
errors.append(f"[{provider.name}/{use_model}] {e}")
|
||||||
|
logger.warning(f"[Vision] Provider '{provider.name}' failed: {e}")
|
||||||
|
except requests.Timeout:
|
||||||
|
errors.append(f"[{provider.name}/{use_model}] Request timed out after {DEFAULT_TIMEOUT}s")
|
||||||
|
logger.warning(f"[Vision] Provider '{provider.name}' timed out")
|
||||||
|
except requests.ConnectionError:
|
||||||
|
errors.append(f"[{provider.name}/{use_model}] Connection failed")
|
||||||
|
logger.warning(f"[Vision] Provider '{provider.name}' connection failed")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"[{provider.name}/{use_model}] {e}")
|
||||||
|
logger.error(f"[Vision] Provider '{provider.name}' unexpected error: {e}", exc_info=True)
|
||||||
|
|
||||||
|
return ToolResult.fail(
|
||||||
|
"Error: All Vision API providers failed.\n" + "\n".join(f" - {err}" for err in errors)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _resolve_providers(self) -> List[VisionProvider]:
|
||||||
|
"""
|
||||||
|
Build an ordered list of providers to try.
|
||||||
|
|
||||||
|
Semantics of `tools.vision.model`:
|
||||||
|
"Prefer this model first; fall back to other configured providers
|
||||||
|
if it fails."
|
||||||
|
|
||||||
|
Order:
|
||||||
|
1. The provider that natively serves `tools.vision.model` (if any
|
||||||
|
and its API key is configured) — using the user-specified model
|
||||||
|
name verbatim.
|
||||||
|
2. Auto-discovery chain as fallback:
|
||||||
|
- use_linkai=true → [LinkAI, MainModel?, OtherModels…, OpenAI]
|
||||||
|
- default → [MainModel?, OtherModels…, OpenAI, LinkAI]
|
||||||
|
MainModel is only included when the main bot is known to support
|
||||||
|
vision (see _main_bot_supports_vision).
|
||||||
|
|
||||||
|
Providers that share the same display name as the preferred provider
|
||||||
|
are de-duplicated to avoid retrying the same endpoint twice.
|
||||||
|
"""
|
||||||
|
user_model = self._resolve_user_vision_model()
|
||||||
|
user_provider = self._resolve_user_vision_provider()
|
||||||
|
providers: List[VisionProvider] = []
|
||||||
|
|
||||||
|
# Step 1: preferred provider — explicit `tools.vision.provider`
|
||||||
|
# wins so custom model names can still be routed correctly. Falls
|
||||||
|
# through to model-name prefix inference when provider is unset.
|
||||||
|
preferred = None
|
||||||
|
if user_provider and user_model:
|
||||||
|
preferred = self._route_by_provider_id(user_provider, user_model)
|
||||||
|
if not preferred and user_model:
|
||||||
|
preferred = self._route_by_model_name(user_model)
|
||||||
|
if preferred:
|
||||||
|
providers.extend(preferred)
|
||||||
|
|
||||||
|
# Step 2: auto-discovery chain as fallback
|
||||||
|
existing = {p.name for p in providers}
|
||||||
|
fallback: List[VisionProvider] = []
|
||||||
|
use_linkai = conf().get("use_linkai", False) and conf().get("linkai_api_key")
|
||||||
|
|
||||||
|
if use_linkai:
|
||||||
|
self._append_provider(fallback, lambda: self._build_linkai_provider(user_model))
|
||||||
|
self._append_provider(fallback, self._build_main_model_provider)
|
||||||
|
self._append_other_model_providers(fallback, preferred_model=user_model)
|
||||||
|
self._append_provider(fallback, lambda: self._build_openai_provider(user_model))
|
||||||
|
else:
|
||||||
|
self._append_provider(fallback, self._build_main_model_provider)
|
||||||
|
self._append_other_model_providers(fallback, preferred_model=user_model)
|
||||||
|
self._append_provider(fallback, lambda: self._build_openai_provider(user_model))
|
||||||
|
self._append_provider(fallback, lambda: self._build_linkai_provider(user_model))
|
||||||
|
|
||||||
|
for p in fallback:
|
||||||
|
if p.name in existing:
|
||||||
|
continue
|
||||||
|
providers.append(p)
|
||||||
|
existing.add(p.name)
|
||||||
|
|
||||||
|
return providers
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _append_provider(providers: List[VisionProvider], builder) -> None:
|
||||||
|
p = builder()
|
||||||
|
if p:
|
||||||
|
providers.append(p)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_user_vision_model() -> Optional[str]:
|
||||||
|
"""Read tools.vision.model (singular ``tool`` kept as runtime fallback)."""
|
||||||
|
tools_conf = conf().get("tools") or conf().get("tool") or {}
|
||||||
|
if not isinstance(tools_conf, dict):
|
||||||
|
return None
|
||||||
|
vision_conf = tools_conf.get("vision", {})
|
||||||
|
if not isinstance(vision_conf, dict):
|
||||||
|
return None
|
||||||
|
m = vision_conf.get("model")
|
||||||
|
if isinstance(m, str) and m.strip():
|
||||||
|
return m.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_user_vision_provider() -> Optional[str]:
|
||||||
|
"""Read tools.vision.provider — the UI-persisted vendor id.
|
||||||
|
|
||||||
|
Lets users pin a vendor for custom model names that prefix-inference
|
||||||
|
can't recognize. Returns None when unset/blank.
|
||||||
|
"""
|
||||||
|
tools_conf = conf().get("tools") or conf().get("tool") or {}
|
||||||
|
if not isinstance(tools_conf, dict):
|
||||||
|
return None
|
||||||
|
vision_conf = tools_conf.get("vision", {})
|
||||||
|
if not isinstance(vision_conf, dict):
|
||||||
|
return None
|
||||||
|
p = vision_conf.get("provider")
|
||||||
|
if isinstance(p, str) and p.strip():
|
||||||
|
return p.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _infer_provider_from_model(model_name: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Infer the provider display name from a model name's prefix.
|
||||||
|
Returns None when no rule matches (or for OpenAI-family names, which
|
||||||
|
are handled separately by the caller).
|
||||||
|
"""
|
||||||
|
if not model_name:
|
||||||
|
return None
|
||||||
|
lower = model_name.lower()
|
||||||
|
# Sort by prefix length desc so e.g. "moonshot-" wins over hypothetical "moo-"
|
||||||
|
for prefix, display_name in sorted(_MODEL_PREFIX_TO_PROVIDER, key=lambda x: -len(x[0])):
|
||||||
|
if lower.startswith(prefix.lower()):
|
||||||
|
return display_name
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _route_by_provider_id(self, provider_id: str, user_model: str) -> Optional[List[VisionProvider]]:
|
||||||
|
"""Route by the UI-persisted provider id.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- [provider] : provider id is known and its key is configured.
|
||||||
|
- None : unknown provider id, or the bot can't be created.
|
||||||
|
Caller falls through to model-name-based routing.
|
||||||
|
"""
|
||||||
|
# Custom OpenAI-compatible providers — read credentials from
|
||||||
|
# custom_providers list, same pattern as embedding.
|
||||||
|
if provider_id.startswith("custom:"):
|
||||||
|
p = self._build_custom_provider(provider_id, user_model)
|
||||||
|
return [p] if p else None
|
||||||
|
|
||||||
|
display_name = _PROVIDER_ID_TO_DISPLAY.get(provider_id)
|
||||||
|
if not display_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# OpenAI / LinkAI use raw HTTP providers, not the discoverable bot path.
|
||||||
|
if provider_id == "openai":
|
||||||
|
p = self._build_openai_provider(user_model)
|
||||||
|
return [p] if p else None
|
||||||
|
if provider_id == "linkai":
|
||||||
|
p = self._build_linkai_provider(user_model)
|
||||||
|
return [p] if p else None
|
||||||
|
|
||||||
|
# Discoverable bot-backed providers.
|
||||||
|
for config_key, bot_type, _default_model, name in _DISCOVERABLE_MODELS:
|
||||||
|
if name != display_name:
|
||||||
|
continue
|
||||||
|
api_key = conf().get(config_key, "")
|
||||||
|
if not api_key or not api_key.strip():
|
||||||
|
logger.warning(f"[Vision] tools.vision.provider='{provider_id}' "
|
||||||
|
f"but '{config_key}' is not configured. Falling back.")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from models.bot_factory import create_bot
|
||||||
|
bot = create_bot(bot_type)
|
||||||
|
if not hasattr(bot, 'call_vision'):
|
||||||
|
logger.warning(f"[Vision] '{display_name}' bot does not implement call_vision.")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Vision] Failed to create '{display_name}' bot: {e}")
|
||||||
|
return None
|
||||||
|
return [VisionProvider(
|
||||||
|
name=display_name,
|
||||||
|
api_key="",
|
||||||
|
api_base="",
|
||||||
|
model_override=user_model,
|
||||||
|
use_bot=True,
|
||||||
|
fallback_bot=bot,
|
||||||
|
)]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _route_by_model_name(self, user_model: str) -> Optional[List[VisionProvider]]:
|
||||||
|
"""
|
||||||
|
Try to build a provider list using the user-specified model name.
|
||||||
|
Returns:
|
||||||
|
- [provider] : matched and the provider's key is configured
|
||||||
|
- [] : matched but key missing → tell caller to surface this
|
||||||
|
as a hard error rather than silently falling back
|
||||||
|
- None : no rule matches → caller should fall through to auto
|
||||||
|
"""
|
||||||
|
lower = user_model.lower()
|
||||||
|
|
||||||
|
# OpenAI / LinkAI family
|
||||||
|
if lower.startswith(_OPENAI_MODEL_PREFIXES):
|
||||||
|
providers: List[VisionProvider] = []
|
||||||
|
# Prefer LinkAI when explicitly enabled, else OpenAI first
|
||||||
|
use_linkai = conf().get("use_linkai", False) and conf().get("linkai_api_key")
|
||||||
|
if use_linkai:
|
||||||
|
self._append_provider(providers, lambda: self._build_linkai_provider(user_model))
|
||||||
|
self._append_provider(providers, lambda: self._build_openai_provider(user_model))
|
||||||
|
else:
|
||||||
|
self._append_provider(providers, lambda: self._build_openai_provider(user_model))
|
||||||
|
self._append_provider(providers, lambda: self._build_linkai_provider(user_model))
|
||||||
|
if providers:
|
||||||
|
return providers
|
||||||
|
logger.warning(f"[Vision] tools.vision.model='{user_model}' looks like an OpenAI "
|
||||||
|
f"model but neither OPENAI_API_KEY nor LINKAI_API_KEY is configured.")
|
||||||
|
return None # fall through to auto
|
||||||
|
|
||||||
|
# Discoverable native providers (Doubao, Moonshot, etc.)
|
||||||
|
target_display = self._infer_provider_from_model(user_model)
|
||||||
|
if not target_display:
|
||||||
|
return None # unknown prefix → auto
|
||||||
|
|
||||||
|
for config_key, bot_type, _default_model, display_name in _DISCOVERABLE_MODELS:
|
||||||
|
if display_name != target_display:
|
||||||
|
continue
|
||||||
|
api_key = conf().get(config_key, "")
|
||||||
|
if not api_key or not api_key.strip():
|
||||||
|
logger.warning(f"[Vision] tools.vision.model='{user_model}' routes to "
|
||||||
|
f"'{display_name}' but '{config_key}' is not configured. "
|
||||||
|
f"Falling back to auto-discovery.")
|
||||||
|
return None # fall through to auto
|
||||||
|
try:
|
||||||
|
from models.bot_factory import create_bot
|
||||||
|
bot = create_bot(bot_type)
|
||||||
|
if not hasattr(bot, 'call_vision'):
|
||||||
|
logger.warning(f"[Vision] '{display_name}' bot does not implement call_vision.")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Vision] Failed to create '{display_name}' bot: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return [VisionProvider(
|
||||||
|
name=display_name,
|
||||||
|
api_key="",
|
||||||
|
api_base="",
|
||||||
|
model_override=user_model,
|
||||||
|
use_bot=True,
|
||||||
|
fallback_bot=bot,
|
||||||
|
)]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _append_other_model_providers(self, providers: List[VisionProvider],
|
||||||
|
preferred_model: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
Auto-discover other models whose API key is configured.
|
||||||
|
Skip the main model's own bot_type (already covered by MainModel
|
||||||
|
provider), unless the main model itself does not support vision —
|
||||||
|
in that case we still want the vendor's dedicated vision model
|
||||||
|
as a fallback. Also skip bot_types that already appear in the
|
||||||
|
provider list.
|
||||||
|
|
||||||
|
If preferred_model matches a provider's family, use it instead
|
||||||
|
of that provider's hard-coded default model.
|
||||||
|
"""
|
||||||
|
main_bot_type = None
|
||||||
|
main_bot_supports_vision = False
|
||||||
|
if self.model and hasattr(self.model, '_resolve_bot_type'):
|
||||||
|
main_bot_type = self.model._resolve_bot_type(conf().get("model", ""))
|
||||||
|
main_bot = getattr(self.model, "bot", None)
|
||||||
|
main_bot_supports_vision = self._main_bot_supports_vision(main_bot)
|
||||||
|
|
||||||
|
existing_names = {p.name for p in providers}
|
||||||
|
preferred_provider = self._infer_provider_from_model(preferred_model) if preferred_model else None
|
||||||
|
|
||||||
|
for config_key, bot_type, default_model, display_name in _DISCOVERABLE_MODELS:
|
||||||
|
if display_name in existing_names:
|
||||||
|
continue
|
||||||
|
# Same bot_type as the main model is normally handled by the
|
||||||
|
# MainModel provider; only skip it here if the main model
|
||||||
|
# actually supports vision. Otherwise fall through and add
|
||||||
|
# the vendor's dedicated vision model as a fallback.
|
||||||
|
if bot_type == main_bot_type and main_bot_supports_vision:
|
||||||
|
continue
|
||||||
|
api_key = conf().get(config_key, "")
|
||||||
|
if not api_key or not api_key.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
from models.bot_factory import create_bot
|
||||||
|
bot = create_bot(bot_type)
|
||||||
|
if not hasattr(bot, 'call_vision'):
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
model_for_provider = (preferred_model
|
||||||
|
if preferred_provider == display_name and preferred_model
|
||||||
|
else default_model)
|
||||||
|
|
||||||
|
provider = VisionProvider(
|
||||||
|
name=display_name,
|
||||||
|
api_key="",
|
||||||
|
api_base="",
|
||||||
|
model_override=model_for_provider,
|
||||||
|
use_bot=True,
|
||||||
|
fallback_bot=bot,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Same vendor as the main bot is the most natural fallback when
|
||||||
|
# the main model itself does not support vision — promote it to
|
||||||
|
# the front of the list instead of relying on declaration order.
|
||||||
|
if bot_type == main_bot_type:
|
||||||
|
providers.insert(0, provider)
|
||||||
|
else:
|
||||||
|
providers.append(provider)
|
||||||
|
|
||||||
|
def _main_bot_supports_vision(self, bot) -> bool:
|
||||||
|
"""
|
||||||
|
Whether the main bot is known to natively support vision.
|
||||||
|
|
||||||
|
Having a `call_vision` method is necessary but not sufficient —
|
||||||
|
some bots implement the method against an endpoint that does not
|
||||||
|
actually serve vision models, which causes silent failures when a
|
||||||
|
vendor-foreign model name is forwarded.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. If the bot explicitly declares `supports_vision`, trust it.
|
||||||
|
This lets bots opt in or out based on their own runtime
|
||||||
|
configuration (e.g. the currently selected model).
|
||||||
|
2. Otherwise, fall back to a model-name prefix heuristic: trust
|
||||||
|
call_vision when the main model looks like an OpenAI family
|
||||||
|
model or matches a known multimodal vendor prefix.
|
||||||
|
"""
|
||||||
|
if bot is None:
|
||||||
|
return False
|
||||||
|
if hasattr(bot, "supports_vision"):
|
||||||
|
return bool(getattr(bot, "supports_vision"))
|
||||||
|
main_model = (conf().get("model") or "").lower()
|
||||||
|
if not main_model:
|
||||||
|
return False
|
||||||
|
if main_model.startswith(_OPENAI_MODEL_PREFIXES):
|
||||||
|
return True
|
||||||
|
return self._infer_provider_from_model(main_model) is not None
|
||||||
|
|
||||||
|
def _build_main_model_provider(self) -> Optional[VisionProvider]:
|
||||||
|
"""
|
||||||
|
Use the vendor's own model for vision via bot.call_vision.
|
||||||
|
Gated by _main_bot_supports_vision so non-vision bots (DeepSeek, etc.)
|
||||||
|
do not get routed vendor-foreign model names.
|
||||||
|
"""
|
||||||
|
if not (self.model and hasattr(self.model, 'bot')):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
bot = self.model.bot
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not hasattr(bot, 'call_vision'):
|
||||||
|
return None
|
||||||
|
if not self._main_bot_supports_vision(bot):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Use the configured main model name; do NOT inject tools.vision.model
|
||||||
|
# here, because by the time we reach this branch the tools.vision.model
|
||||||
|
# routing has already been attempted (and either matched the main bot
|
||||||
|
# or failed to find a provider).
|
||||||
|
main_model_name = conf().get("model") or None
|
||||||
|
|
||||||
|
return VisionProvider(
|
||||||
|
name=_MAIN_MODEL_PROVIDER_NAME,
|
||||||
|
api_key="",
|
||||||
|
api_base="",
|
||||||
|
model_override=main_model_name,
|
||||||
|
use_bot=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_openai_provider(self, preferred_model: Optional[str] = None) -> Optional[VisionProvider]:
|
||||||
|
api_key = conf().get("open_ai_api_key") or os.environ.get("OPENAI_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
return None
|
||||||
|
api_base = (conf().get("open_ai_api_base") or os.environ.get("OPENAI_API_BASE", "")).rstrip("/") \
|
||||||
|
or "https://api.openai.com/v1"
|
||||||
|
# Only honor preferred_model when it looks like an OpenAI-family name;
|
||||||
|
# otherwise the OpenAI endpoint would 400 on a vendor-specific name.
|
||||||
|
model_override = preferred_model if (
|
||||||
|
preferred_model and preferred_model.lower().startswith(_OPENAI_MODEL_PREFIXES)
|
||||||
|
) else None
|
||||||
|
return VisionProvider(
|
||||||
|
name="OpenAI",
|
||||||
|
api_key=api_key,
|
||||||
|
api_base=self._ensure_v1(api_base),
|
||||||
|
model_override=model_override,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_linkai_provider(self, preferred_model: Optional[str] = None) -> Optional[VisionProvider]:
|
||||||
|
api_key = conf().get("linkai_api_key") or os.environ.get("LINKAI_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
return None
|
||||||
|
api_base = (conf().get("linkai_api_base") or os.environ.get("LINKAI_API_BASE", "")).rstrip("/") \
|
||||||
|
or "https://api.link-ai.tech"
|
||||||
|
from common.utils import get_cloud_headers
|
||||||
|
extra = get_cloud_headers(api_key)
|
||||||
|
extra.pop("Authorization", None)
|
||||||
|
extra.pop("Content-Type", None)
|
||||||
|
# LinkAI is a multi-vendor proxy and accepts most model names, so we
|
||||||
|
# honor any user-configured model name here.
|
||||||
|
return VisionProvider(
|
||||||
|
name="LinkAI",
|
||||||
|
api_key=api_key,
|
||||||
|
api_base=self._ensure_v1(api_base),
|
||||||
|
extra_headers=extra,
|
||||||
|
model_override=preferred_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_custom_provider(self, provider_id: str, preferred_model: Optional[str] = None) -> Optional[VisionProvider]:
|
||||||
|
"""Build a VisionProvider from a custom:<id> entry in custom_providers.
|
||||||
|
Uses the standard OpenAI /chat/completions endpoint — any
|
||||||
|
OpenAI-compatible multimodal endpoint works."""
|
||||||
|
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
||||||
|
_, custom_id = parse_custom_bot_type(provider_id)
|
||||||
|
if not custom_id:
|
||||||
|
return None
|
||||||
|
entry = _find_provider_by_id(get_custom_providers(), custom_id)
|
||||||
|
if not entry:
|
||||||
|
logger.warning(f"[Vision] custom provider '{provider_id}' not found in custom_providers")
|
||||||
|
return None
|
||||||
|
api_key = (entry.get("api_key") or "").strip()
|
||||||
|
api_base = (entry.get("api_base") or "").strip()
|
||||||
|
if not api_key or not api_base:
|
||||||
|
logger.warning(f"[Vision] custom provider '{provider_id}' missing api_key or api_base")
|
||||||
|
return None
|
||||||
|
model = preferred_model or entry.get("model") or ""
|
||||||
|
if not model:
|
||||||
|
logger.warning(f"[Vision] custom provider '{provider_id}' has no model configured")
|
||||||
|
return None
|
||||||
|
return VisionProvider(
|
||||||
|
name=entry.get("name") or provider_id,
|
||||||
|
api_key=api_key,
|
||||||
|
api_base=self._ensure_v1(api_base.rstrip("/")),
|
||||||
|
model_override=model,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _call_via_bot(self, model: str, question: str, image_content: dict,
|
||||||
|
provider: Optional[VisionProvider] = None) -> ToolResult:
|
||||||
|
"""
|
||||||
|
Call a model's call_vision with vendor-native API format.
|
||||||
|
Uses the provider's _fallback_bot if set, otherwise the main model bot.
|
||||||
|
Raises VisionAPIError on failure so fallback can proceed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
bot = (provider and provider.fallback_bot) or self.model.bot
|
||||||
|
except Exception as e:
|
||||||
|
raise VisionAPIError(f"Cannot access bot: {e}")
|
||||||
|
|
||||||
|
# Extract the raw image URL from the OpenAI-format image_content block
|
||||||
|
image_url = image_content.get("image_url", {}).get("url", "")
|
||||||
|
if not image_url:
|
||||||
|
raise VisionAPIError("No image URL in content block")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = bot.call_vision(
|
||||||
|
image_url=image_url,
|
||||||
|
question=question,
|
||||||
|
model=model,
|
||||||
|
max_tokens=MAX_TOKENS,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise VisionAPIError(f"call_vision failed: {e}")
|
||||||
|
|
||||||
|
if response is NotImplemented:
|
||||||
|
raise VisionAPIError("Bot does not support vision")
|
||||||
|
|
||||||
|
if isinstance(response, dict) and response.get("error"):
|
||||||
|
raise VisionAPIError(f"API error - {response.get('message', 'Unknown')}")
|
||||||
|
|
||||||
|
content = response.get("content", "") if isinstance(response, dict) else ""
|
||||||
|
if not content:
|
||||||
|
raise VisionAPIError("Empty response from main model")
|
||||||
|
|
||||||
|
usage_info = response.get("usage", {}) if isinstance(response, dict) else {}
|
||||||
|
|
||||||
|
# Use the actual model name from the bot response if available
|
||||||
|
actual_model = response.get("model", model) if isinstance(response, dict) else model
|
||||||
|
provider_name = provider.name if provider else _MAIN_MODEL_PROVIDER_NAME
|
||||||
|
return ToolResult.success({
|
||||||
|
"model": actual_model,
|
||||||
|
"provider": provider_name,
|
||||||
|
"content": content,
|
||||||
|
"usage": usage_info,
|
||||||
|
})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ensure_v1(api_base: str) -> str:
|
||||||
|
"""Append /v1 if the base URL doesn't already end with a versioned path."""
|
||||||
|
if not api_base:
|
||||||
|
return api_base
|
||||||
|
# Already has /v1 or similar version suffix
|
||||||
|
if api_base.rstrip("/").split("/")[-1].startswith("v"):
|
||||||
|
return api_base
|
||||||
|
return api_base.rstrip("/") + "/v1"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_url_safe(url: str) -> None:
|
||||||
|
"""Reject URLs that target private/loopback/link-local addresses (SSRF guard).
|
||||||
|
|
||||||
|
Resolves the hostname to its IP address(es) and blocks any that fall
|
||||||
|
into non-public ranges. Also rejects URLs with no host, non-HTTP(S)
|
||||||
|
schemes, or hosts that fail DNS resolution.
|
||||||
|
|
||||||
|
Delegates to the shared ``agent.tools.utils.url_safety`` helper so the
|
||||||
|
same guard protects every tool that fetches model-supplied URLs.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: if the URL targets a disallowed address.
|
||||||
|
"""
|
||||||
|
validate_url_safe(url)
|
||||||
|
|
||||||
|
def _build_image_content(self, image: str) -> dict:
|
||||||
|
"""
|
||||||
|
Build the image_url content block.
|
||||||
|
Both remote URLs and local files are converted to base64 data URLs
|
||||||
|
so every bot backend can consume them without extra downloads.
|
||||||
|
"""
|
||||||
|
if image.startswith(("http://", "https://")):
|
||||||
|
self._validate_url_safe(image)
|
||||||
|
return self._download_to_data_url(image)
|
||||||
|
|
||||||
|
if not os.path.isfile(image):
|
||||||
|
raise FileNotFoundError(f"Image file not found: {image}")
|
||||||
|
|
||||||
|
ext = image.rsplit(".", 1)[-1].lower() if "." in image else ""
|
||||||
|
mime_type = SUPPORTED_EXTENSIONS.get(ext)
|
||||||
|
if not mime_type:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported image format '.{ext}'. "
|
||||||
|
f"Supported: {', '.join(SUPPORTED_EXTENSIONS.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
file_path = self._maybe_compress(image)
|
||||||
|
try:
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
b64 = base64.b64encode(f.read()).decode("ascii")
|
||||||
|
finally:
|
||||||
|
if file_path != image and os.path.exists(file_path):
|
||||||
|
os.remove(file_path)
|
||||||
|
|
||||||
|
data_url = f"data:{mime_type};base64,{b64}"
|
||||||
|
return {"type": "image_url", "image_url": {"url": data_url}}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _download_to_data_url(url: str) -> dict:
|
||||||
|
"""Download a remote image and return it as a base64 data URL."""
|
||||||
|
resp = requests.get(url, timeout=30)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise VisionAPIError(f"Failed to download image: HTTP {resp.status_code}")
|
||||||
|
content_type = resp.headers.get("Content-Type", "image/jpeg").split(";")[0].strip()
|
||||||
|
if not content_type.startswith("image/"):
|
||||||
|
content_type = "image/jpeg"
|
||||||
|
b64 = base64.b64encode(resp.content).decode("ascii")
|
||||||
|
data_url = f"data:{content_type};base64,{b64}"
|
||||||
|
return {"type": "image_url", "image_url": {"url": data_url}}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _maybe_compress(path: str) -> str:
|
||||||
|
"""Compress image to under COMPRESS_THRESHOLD with max long-edge 1536px."""
|
||||||
|
file_size = os.path.getsize(path)
|
||||||
|
if file_size <= COMPRESS_THRESHOLD:
|
||||||
|
return path
|
||||||
|
|
||||||
|
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||||
|
tmp.close()
|
||||||
|
|
||||||
|
def _try_sips(max_dim: str, quality: str) -> bool:
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["sips", "-Z", max_dim, "-s", "formatOptions", quality,
|
||||||
|
path, "--out", tmp.name],
|
||||||
|
capture_output=True, check=True,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except (FileNotFoundError, subprocess.CalledProcessError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _try_convert(max_dim: str, quality: str) -> bool:
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["convert", path, "-resize", f"{max_dim}x{max_dim}>",
|
||||||
|
"-quality", quality, tmp.name],
|
||||||
|
capture_output=True, check=True,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except (FileNotFoundError, subprocess.CalledProcessError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
attempts = [
|
||||||
|
("1536", "85"),
|
||||||
|
("1536", "70"),
|
||||||
|
("1536", "50"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for max_dim, quality in attempts:
|
||||||
|
ok = _try_sips(max_dim, quality) or _try_convert(max_dim, quality)
|
||||||
|
if not ok:
|
||||||
|
continue
|
||||||
|
new_size = os.path.getsize(tmp.name)
|
||||||
|
logger.debug(f"[Vision] Compressed image "
|
||||||
|
f"({file_size // 1024}KB -> {new_size // 1024}KB, "
|
||||||
|
f"max_dim={max_dim}, q={quality})")
|
||||||
|
if new_size <= COMPRESS_THRESHOLD:
|
||||||
|
return tmp.name
|
||||||
|
|
||||||
|
if os.path.exists(tmp.name) and os.path.getsize(tmp.name) > 0:
|
||||||
|
return tmp.name
|
||||||
|
|
||||||
|
os.remove(tmp.name)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _call_api(self, provider: VisionProvider, model: str,
|
||||||
|
question: str, image_content: dict) -> ToolResult:
|
||||||
|
"""
|
||||||
|
Call a single provider's Vision API.
|
||||||
|
Raises VisionAPIError on recoverable failures so the caller can try
|
||||||
|
the next provider.
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": question},
|
||||||
|
image_content,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {provider.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
**provider.extra_headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
f"{provider.api_base}/chat/completions",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise VisionAPIError(f"HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
if "error" in data:
|
||||||
|
msg = data["error"].get("message", "Unknown API error")
|
||||||
|
raise VisionAPIError(f"API error - {msg}")
|
||||||
|
|
||||||
|
content = ""
|
||||||
|
choices = data.get("choices", [])
|
||||||
|
if choices:
|
||||||
|
content = choices[0].get("message", {}).get("content", "")
|
||||||
|
|
||||||
|
usage = data.get("usage", {})
|
||||||
|
result = {
|
||||||
|
"model": model,
|
||||||
|
"provider": provider.name,
|
||||||
|
"content": content,
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||||
|
"completion_tokens": usage.get("completion_tokens", 0),
|
||||||
|
"total_tokens": usage.get("total_tokens", 0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return ToolResult.success(result)
|
||||||
0
agent/tools/web_fetch/__init__.py
Normal file
0
agent/tools/web_fetch/__init__.py
Normal file
489
agent/tools/web_fetch/web_fetch.py
Normal file
489
agent/tools/web_fetch/web_fetch.py
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
"""
|
||||||
|
Web Fetch tool - Fetch and extract readable content from web pages and remote files.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- HTML web pages: extracts readable text content
|
||||||
|
- Document files (PDF, Word, TXT, Markdown, etc.): downloads to workspace/tmp and parses content
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from typing import Dict, Any, Optional, Set
|
||||||
|
from urllib.parse import urlparse, unquote
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
|
from agent.tools.utils.truncate import truncate_head, format_size
|
||||||
|
from agent.tools.utils.url_safety import validate_url_safe
|
||||||
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = 30
|
||||||
|
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
|
||||||
|
# Cap on how many redirects we follow; each hop's target is re-validated
|
||||||
|
# against the SSRF guard so a public URL cannot bounce us into an internal one.
|
||||||
|
MAX_REDIRECTS = 10
|
||||||
|
|
||||||
|
DEFAULT_HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||||
|
"Accept": "*/*",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Supported document file extensions
|
||||||
|
PDF_SUFFIXES: Set[str] = {".pdf"}
|
||||||
|
WORD_SUFFIXES: Set[str] = {".docx"}
|
||||||
|
TEXT_SUFFIXES: Set[str] = {".txt", ".md", ".markdown", ".rst", ".csv", ".tsv", ".log"}
|
||||||
|
SPREADSHEET_SUFFIXES: Set[str] = {".xls", ".xlsx"}
|
||||||
|
PPT_SUFFIXES: Set[str] = {".ppt", ".pptx"}
|
||||||
|
|
||||||
|
ALL_DOC_SUFFIXES = PDF_SUFFIXES | WORD_SUFFIXES | TEXT_SUFFIXES | SPREADSHEET_SUFFIXES | PPT_SUFFIXES
|
||||||
|
|
||||||
|
_CHARSET_RE = re.compile(r'charset\s*=\s*["\']?\s*([\w\-]+)', re.IGNORECASE)
|
||||||
|
_META_CHARSET_RE = re.compile(rb'<meta[^>]+charset\s*=\s*["\']?\s*([\w\-]+)', re.IGNORECASE)
|
||||||
|
_META_HTTP_EQUIV_RE = re.compile(
|
||||||
|
rb'<meta[^>]+http-equiv\s*=\s*["\']?Content-Type["\']?[^>]+content\s*=\s*["\'][^"\']*charset=([\w\-]+)',
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_charset_from_content_type(content_type: str) -> Optional[str]:
|
||||||
|
"""Extract charset from Content-Type header value."""
|
||||||
|
m = _CHARSET_RE.search(content_type)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_charset_from_html_meta(raw_bytes: bytes) -> Optional[str]:
|
||||||
|
"""Extract charset from HTML <meta> tags in the first few KB of raw bytes."""
|
||||||
|
m = _META_CHARSET_RE.search(raw_bytes)
|
||||||
|
if m:
|
||||||
|
return m.group(1).decode("ascii", errors="ignore")
|
||||||
|
m = _META_HTTP_EQUIV_RE.search(raw_bytes)
|
||||||
|
if m:
|
||||||
|
return m.group(1).decode("ascii", errors="ignore")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_url_suffix(url: str) -> str:
|
||||||
|
"""Extract file extension from URL path, ignoring query params."""
|
||||||
|
path = urlparse(url).path
|
||||||
|
return os.path.splitext(path)[-1].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_document_url(url: str) -> bool:
|
||||||
|
"""Check if URL points to a downloadable document file."""
|
||||||
|
suffix = _get_url_suffix(url)
|
||||||
|
return suffix in ALL_DOC_SUFFIXES
|
||||||
|
|
||||||
|
|
||||||
|
class WebFetch(BaseTool):
|
||||||
|
"""Tool for fetching web pages and remote document files"""
|
||||||
|
|
||||||
|
name: str = "web_fetch"
|
||||||
|
description: str = (
|
||||||
|
"Fetch content from a http/https URL. For web pages, extracts readable text. "
|
||||||
|
"For document files (PDF, Word, TXT, Markdown, Excel, PPT), downloads and parses the file content. "
|
||||||
|
"Supported file types: .pdf, .docx, .txt, .md, .csv, .xls, .xlsx, .ppt, .pptx"
|
||||||
|
)
|
||||||
|
|
||||||
|
params: dict = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The HTTP/HTTPS URL to fetch (web page or document file link)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["url"]
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, config: dict = None):
|
||||||
|
self.config = config or {}
|
||||||
|
self.cwd = self.config.get("cwd", os.getcwd())
|
||||||
|
|
||||||
|
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
|
url = args.get("url", "").strip()
|
||||||
|
if not url:
|
||||||
|
return ToolResult.fail("Error: 'url' parameter is required")
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
return ToolResult.fail("Error: Invalid URL (must start with http:// or https://)")
|
||||||
|
|
||||||
|
# SSRF guard: reject URLs that resolve to private/loopback/link-local/
|
||||||
|
# cloud-metadata addresses before any request is issued.
|
||||||
|
try:
|
||||||
|
validate_url_safe(url)
|
||||||
|
except ValueError as e:
|
||||||
|
return ToolResult.fail(f"Error: {e}")
|
||||||
|
|
||||||
|
if _is_document_url(url):
|
||||||
|
return self._fetch_document(url)
|
||||||
|
|
||||||
|
return self._fetch_webpage(url)
|
||||||
|
|
||||||
|
# ---- Safe request helper ----
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_get(url: str, **kwargs) -> requests.Response:
|
||||||
|
"""Issue a GET request while re-validating every redirect hop (SSRF guard).
|
||||||
|
|
||||||
|
Auto-redirect is disabled and each hop is followed manually so the
|
||||||
|
target of every redirect is re-resolved and checked against the SSRF
|
||||||
|
guard. This prevents a public URL from 3xx-bouncing into a private,
|
||||||
|
loopback, link-local or cloud-metadata address. ``kwargs`` are passed
|
||||||
|
through to ``requests.get`` (e.g. ``stream``).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: if any hop resolves to a non-public address.
|
||||||
|
"""
|
||||||
|
kwargs.pop("allow_redirects", None)
|
||||||
|
current = url
|
||||||
|
for _ in range(MAX_REDIRECTS + 1):
|
||||||
|
response = requests.get(
|
||||||
|
current,
|
||||||
|
headers=DEFAULT_HEADERS,
|
||||||
|
timeout=DEFAULT_TIMEOUT,
|
||||||
|
allow_redirects=False,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
if not response.is_redirect and not response.is_permanent_redirect:
|
||||||
|
return response
|
||||||
|
|
||||||
|
location = response.headers.get("Location")
|
||||||
|
if not location:
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Resolve the redirect target relative to the current URL, then
|
||||||
|
# re-validate it before following.
|
||||||
|
current = requests.compat.urljoin(current, location)
|
||||||
|
validate_url_safe(current)
|
||||||
|
response.close()
|
||||||
|
|
||||||
|
raise ValueError(f"Too many redirects (>{MAX_REDIRECTS})")
|
||||||
|
|
||||||
|
# ---- Web page fetching ----
|
||||||
|
|
||||||
|
def _fetch_webpage(self, url: str) -> ToolResult:
|
||||||
|
"""Fetch and extract readable text from an HTML web page."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
try:
|
||||||
|
response = self._safe_get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
except requests.Timeout:
|
||||||
|
return ToolResult.fail(f"Error: Request timed out after {DEFAULT_TIMEOUT}s")
|
||||||
|
except requests.ConnectionError:
|
||||||
|
return ToolResult.fail(f"Error: Failed to connect to {parsed.netloc}")
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
return ToolResult.fail(f"Error: HTTP {e.response.status_code} for URL: {url}")
|
||||||
|
except ValueError as e:
|
||||||
|
return ToolResult.fail(f"Error: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
return ToolResult.fail(f"Error: Failed to fetch URL: {e}")
|
||||||
|
|
||||||
|
content_type = response.headers.get("Content-Type", "")
|
||||||
|
if self._is_binary_content_type(content_type) and not _is_document_url(url):
|
||||||
|
return self._handle_download_by_content_type(url, response, content_type)
|
||||||
|
|
||||||
|
response.encoding = self._detect_encoding(response)
|
||||||
|
html = response.text
|
||||||
|
title = self._extract_title(html)
|
||||||
|
text = self._extract_text(html)
|
||||||
|
|
||||||
|
return ToolResult.success(f"Title: {title}\n\nContent:\n{text}")
|
||||||
|
|
||||||
|
# ---- Document fetching ----
|
||||||
|
|
||||||
|
def _fetch_document(self, url: str) -> ToolResult:
|
||||||
|
"""Download a document file and extract its text content."""
|
||||||
|
suffix = _get_url_suffix(url)
|
||||||
|
parsed = urlparse(url)
|
||||||
|
filename = self._extract_filename(url)
|
||||||
|
tmp_dir = self._ensure_tmp_dir()
|
||||||
|
|
||||||
|
local_path = os.path.join(tmp_dir, filename)
|
||||||
|
logger.info(f"[WebFetch] Downloading document: {url} -> {local_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._safe_get(url, stream=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
content_length = int(response.headers.get("Content-Length", 0))
|
||||||
|
if content_length > MAX_FILE_SIZE:
|
||||||
|
return ToolResult.fail(
|
||||||
|
f"Error: File too large ({format_size(content_length)} > {format_size(MAX_FILE_SIZE)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
downloaded = 0
|
||||||
|
with open(local_path, "wb") as f:
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
downloaded += len(chunk)
|
||||||
|
if downloaded > MAX_FILE_SIZE:
|
||||||
|
f.close()
|
||||||
|
os.remove(local_path)
|
||||||
|
return ToolResult.fail(
|
||||||
|
f"Error: File too large (>{format_size(MAX_FILE_SIZE)}), download aborted"
|
||||||
|
)
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
except requests.Timeout:
|
||||||
|
return ToolResult.fail(f"Error: Download timed out after {DEFAULT_TIMEOUT}s")
|
||||||
|
except requests.ConnectionError:
|
||||||
|
return ToolResult.fail(f"Error: Failed to connect to {parsed.netloc}")
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
return ToolResult.fail(f"Error: HTTP {e.response.status_code} for URL: {url}")
|
||||||
|
except ValueError as e:
|
||||||
|
self._cleanup_file(local_path)
|
||||||
|
return ToolResult.fail(f"Error: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
self._cleanup_file(local_path)
|
||||||
|
return ToolResult.fail(f"Error: Failed to download file: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = self._parse_document(local_path, suffix)
|
||||||
|
except Exception as e:
|
||||||
|
self._cleanup_file(local_path)
|
||||||
|
return ToolResult.fail(f"Error: Failed to parse document: {e}")
|
||||||
|
|
||||||
|
if not text or not text.strip():
|
||||||
|
file_size = os.path.getsize(local_path)
|
||||||
|
return ToolResult.success(
|
||||||
|
f"File downloaded to: {local_path} ({format_size(file_size)})\n"
|
||||||
|
f"No text content could be extracted. The file may contain only images or be encrypted."
|
||||||
|
)
|
||||||
|
|
||||||
|
truncation = truncate_head(text)
|
||||||
|
result_text = truncation.content
|
||||||
|
|
||||||
|
file_size = os.path.getsize(local_path)
|
||||||
|
header = f"[Document: {filename} | Size: {format_size(file_size)} | Saved to: {local_path}]\n\n"
|
||||||
|
|
||||||
|
if truncation.truncated:
|
||||||
|
header += f"[Content truncated: showing {truncation.output_lines} of {truncation.total_lines} lines]\n\n"
|
||||||
|
|
||||||
|
return ToolResult.success(header + result_text)
|
||||||
|
|
||||||
|
def _parse_document(self, file_path: str, suffix: str) -> str:
|
||||||
|
"""Parse document file and return extracted text."""
|
||||||
|
if suffix in PDF_SUFFIXES:
|
||||||
|
return self._parse_pdf(file_path)
|
||||||
|
elif suffix in WORD_SUFFIXES:
|
||||||
|
return self._parse_word(file_path)
|
||||||
|
elif suffix in TEXT_SUFFIXES:
|
||||||
|
return self._parse_text(file_path)
|
||||||
|
elif suffix in SPREADSHEET_SUFFIXES:
|
||||||
|
return self._parse_spreadsheet(file_path)
|
||||||
|
elif suffix in PPT_SUFFIXES:
|
||||||
|
return self._parse_ppt(file_path)
|
||||||
|
else:
|
||||||
|
return self._parse_text(file_path)
|
||||||
|
|
||||||
|
def _parse_pdf(self, file_path: str) -> str:
|
||||||
|
"""Extract text from PDF using pypdf."""
|
||||||
|
try:
|
||||||
|
from pypdf import PdfReader
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError("pypdf library is required for PDF parsing. Install with: pip install pypdf")
|
||||||
|
|
||||||
|
reader = PdfReader(file_path)
|
||||||
|
text_parts = []
|
||||||
|
for page_num, page in enumerate(reader.pages, 1):
|
||||||
|
page_text = page.extract_text()
|
||||||
|
if page_text and page_text.strip():
|
||||||
|
text_parts.append(f"--- Page {page_num}/{len(reader.pages)} ---\n{page_text}")
|
||||||
|
|
||||||
|
return "\n\n".join(text_parts)
|
||||||
|
|
||||||
|
def _parse_word(self, file_path: str) -> str:
|
||||||
|
"""Extract text from Word documents (.docx)."""
|
||||||
|
try:
|
||||||
|
from docx import Document
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
"python-docx library is required for .docx parsing. Install with: pip install python-docx"
|
||||||
|
)
|
||||||
|
doc = Document(file_path)
|
||||||
|
paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
|
||||||
|
return "\n\n".join(paragraphs)
|
||||||
|
|
||||||
|
def _parse_text(self, file_path: str) -> str:
|
||||||
|
"""Read plain text files (txt, md, csv, etc.)."""
|
||||||
|
encodings = ["utf-8", "utf-8-sig", "gbk", "gb2312", "latin-1"]
|
||||||
|
for enc in encodings:
|
||||||
|
try:
|
||||||
|
with open(file_path, "r", encoding=enc) as f:
|
||||||
|
return f.read()
|
||||||
|
except (UnicodeDecodeError, UnicodeError):
|
||||||
|
continue
|
||||||
|
raise ValueError(f"Unable to decode file with any supported encoding: {encodings}")
|
||||||
|
|
||||||
|
def _parse_spreadsheet(self, file_path: str) -> str:
|
||||||
|
"""Extract text from Excel files (.xls/.xlsx)."""
|
||||||
|
try:
|
||||||
|
import openpyxl
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
"openpyxl library is required for .xlsx parsing. Install with: pip install openpyxl"
|
||||||
|
)
|
||||||
|
|
||||||
|
wb = openpyxl.load_workbook(file_path, read_only=True, data_only=True)
|
||||||
|
result_parts = []
|
||||||
|
|
||||||
|
for sheet_name in wb.sheetnames:
|
||||||
|
ws = wb[sheet_name]
|
||||||
|
rows = []
|
||||||
|
for row in ws.iter_rows(values_only=True):
|
||||||
|
cells = [str(c) if c is not None else "" for c in row]
|
||||||
|
if any(cells):
|
||||||
|
rows.append(" | ".join(cells))
|
||||||
|
if rows:
|
||||||
|
result_parts.append(f"--- Sheet: {sheet_name} ---\n" + "\n".join(rows))
|
||||||
|
|
||||||
|
wb.close()
|
||||||
|
return "\n\n".join(result_parts)
|
||||||
|
|
||||||
|
def _parse_ppt(self, file_path: str) -> str:
|
||||||
|
"""Extract text from PowerPoint files (.ppt/.pptx)."""
|
||||||
|
try:
|
||||||
|
from pptx import Presentation
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
"python-pptx library is required for .pptx parsing. Install with: pip install python-pptx"
|
||||||
|
)
|
||||||
|
|
||||||
|
prs = Presentation(file_path)
|
||||||
|
text_parts = []
|
||||||
|
|
||||||
|
for slide_num, slide in enumerate(prs.slides, 1):
|
||||||
|
slide_texts = []
|
||||||
|
for shape in slide.shapes:
|
||||||
|
if shape.has_text_frame:
|
||||||
|
for paragraph in shape.text_frame.paragraphs:
|
||||||
|
text = paragraph.text.strip()
|
||||||
|
if text:
|
||||||
|
slide_texts.append(text)
|
||||||
|
if slide_texts:
|
||||||
|
text_parts.append(f"--- Slide {slide_num}/{len(prs.slides)} ---\n" + "\n".join(slide_texts))
|
||||||
|
|
||||||
|
return "\n\n".join(text_parts)
|
||||||
|
|
||||||
|
# ---- Encoding detection ----
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _detect_encoding(response: requests.Response) -> str:
|
||||||
|
"""Detect response encoding with priority: Content-Type header > HTML meta > chardet > utf-8."""
|
||||||
|
# 1. Check Content-Type header for explicit charset
|
||||||
|
content_type = response.headers.get("Content-Type", "")
|
||||||
|
charset = _extract_charset_from_content_type(content_type)
|
||||||
|
if charset:
|
||||||
|
return charset
|
||||||
|
|
||||||
|
# 2. Scan raw bytes for HTML meta charset declaration
|
||||||
|
raw = response.content[:4096]
|
||||||
|
charset = _extract_charset_from_html_meta(raw)
|
||||||
|
if charset:
|
||||||
|
return charset
|
||||||
|
|
||||||
|
# 3. Use apparent_encoding (chardet-based detection) if confident enough
|
||||||
|
apparent = response.apparent_encoding
|
||||||
|
if apparent:
|
||||||
|
apparent_lower = apparent.lower()
|
||||||
|
# Trust CJK / Windows encodings detected by chardet
|
||||||
|
trusted_prefixes = ("utf", "gb", "big5", "euc", "shift_jis", "iso-2022", "windows", "ascii")
|
||||||
|
if any(apparent_lower.startswith(p) for p in trusted_prefixes):
|
||||||
|
return apparent
|
||||||
|
|
||||||
|
# 4. Fallback
|
||||||
|
return "utf-8"
|
||||||
|
|
||||||
|
# ---- Helper methods ----
|
||||||
|
|
||||||
|
def _ensure_tmp_dir(self) -> str:
|
||||||
|
"""Ensure workspace/tmp directory exists and return its path."""
|
||||||
|
tmp_dir = os.path.join(self.cwd, "tmp")
|
||||||
|
os.makedirs(tmp_dir, exist_ok=True)
|
||||||
|
return tmp_dir
|
||||||
|
|
||||||
|
def _extract_filename(self, url: str) -> str:
|
||||||
|
"""Extract a safe filename from URL, with a short UUID prefix to avoid collisions."""
|
||||||
|
path = urlparse(url).path
|
||||||
|
basename = os.path.basename(unquote(path))
|
||||||
|
if not basename or basename == "/":
|
||||||
|
basename = "downloaded_file"
|
||||||
|
# Sanitize: keep only safe chars
|
||||||
|
basename = re.sub(r'[^\w.\-]', '_', basename)
|
||||||
|
short_id = uuid.uuid4().hex[:8]
|
||||||
|
return f"{short_id}_{basename}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cleanup_file(path: str):
|
||||||
|
"""Remove a file if it exists, ignoring errors."""
|
||||||
|
try:
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_binary_content_type(content_type: str) -> bool:
|
||||||
|
"""Check if Content-Type indicates a binary/document response."""
|
||||||
|
binary_types = [
|
||||||
|
"application/pdf",
|
||||||
|
"application/vnd.openxmlformats",
|
||||||
|
"application/vnd.ms-excel",
|
||||||
|
"application/vnd.ms-powerpoint",
|
||||||
|
"application/octet-stream",
|
||||||
|
]
|
||||||
|
ct_lower = content_type.lower()
|
||||||
|
return any(bt in ct_lower for bt in binary_types)
|
||||||
|
|
||||||
|
def _handle_download_by_content_type(self, url: str, response: requests.Response, content_type: str) -> ToolResult:
|
||||||
|
"""Handle a URL that returned binary content instead of HTML."""
|
||||||
|
ct_lower = content_type.lower()
|
||||||
|
suffix_map = {
|
||||||
|
"application/pdf": ".pdf",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml": ".docx",
|
||||||
|
"application/vnd.ms-excel": ".xls",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml": ".xlsx",
|
||||||
|
"application/vnd.ms-powerpoint": ".ppt",
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml": ".pptx",
|
||||||
|
}
|
||||||
|
detected_suffix = None
|
||||||
|
for ct_prefix, ext in suffix_map.items():
|
||||||
|
if ct_prefix in ct_lower:
|
||||||
|
detected_suffix = ext
|
||||||
|
break
|
||||||
|
|
||||||
|
if detected_suffix and detected_suffix in ALL_DOC_SUFFIXES:
|
||||||
|
# Re-fetch as document
|
||||||
|
return self._fetch_document(url if _get_url_suffix(url) in ALL_DOC_SUFFIXES
|
||||||
|
else self._rewrite_url_with_suffix(url, detected_suffix))
|
||||||
|
return ToolResult.fail(f"Error: URL returned binary content ({content_type}), not a supported document type")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rewrite_url_with_suffix(url: str, suffix: str) -> str:
|
||||||
|
"""Append a suffix to the URL path so _get_url_suffix works correctly."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
new_path = parsed.path.rstrip("/") + suffix
|
||||||
|
return parsed._replace(path=new_path).geturl()
|
||||||
|
|
||||||
|
# ---- HTML extraction (unchanged) ----
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_title(html: str) -> str:
|
||||||
|
match = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
|
||||||
|
return match.group(1).strip() if match else "Untitled"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_text(html: str) -> str:
|
||||||
|
text = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.IGNORECASE | re.DOTALL)
|
||||||
|
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.IGNORECASE | re.DOTALL)
|
||||||
|
text = re.sub(r"<[^>]+>", "", text)
|
||||||
|
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
text = text.replace(""", '"').replace("'", "'").replace(" ", " ")
|
||||||
|
text = re.sub(r"[^\S\n]+", " ", text)
|
||||||
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||||
|
lines = [line.strip() for line in text.splitlines()]
|
||||||
|
text = "\n".join(lines)
|
||||||
|
return text.strip()
|
||||||
@@ -1,33 +1,95 @@
|
|||||||
"""
|
"""Web Search tool. Supports four backends with a unified response format:
|
||||||
Web Search tool - Search the web using Bocha or LinkAI search API.
|
- bocha (https://open.bochaai.com)
|
||||||
Supports two backends with unified response format:
|
- zhipu (https://docs.bigmodel.cn/cn/guide/tools/web-search)
|
||||||
1. Bocha Search (primary, requires BOCHA_API_KEY)
|
- qianfan (https://cloud.baidu.com/doc/qianfan/s/2mh4su4uy)
|
||||||
2. LinkAI Search (fallback, requires LINKAI_API_KEY)
|
- linkai (https://link-ai.tech, fallback)
|
||||||
|
|
||||||
|
Provider selection
|
||||||
|
- strategy 'auto' (default): pick the first configured provider in the
|
||||||
|
canonical order [bocha, zhipu, qianfan, linkai]. When the caller passes
|
||||||
|
an explicit `provider` it overrides the pick; an invalid/unconfigured
|
||||||
|
one silently falls back to the auto order.
|
||||||
|
- strategy 'fixed': use the configured provider; if its credential is
|
||||||
|
missing at call time, silently fall back to auto order (no card hint).
|
||||||
|
|
||||||
|
Credentials
|
||||||
|
- bocha : tools.web_search.bocha_api_key -> env BOCHA_API_KEY
|
||||||
|
- zhipu : conf.zhipu_ai_api_key -> env ZHIPUAI_API_KEY
|
||||||
|
- qianfan : conf.qianfan_api_key -> env QIANFAN_API_KEY
|
||||||
|
- linkai : conf.linkai_api_key -> env LINKAI_API_KEY
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
import json
|
||||||
from typing import Dict, Any, Optional
|
import os
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from agent.tools.base_tool import BaseTool, ToolResult
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
|
from config import conf
|
||||||
|
|
||||||
|
|
||||||
# Default timeout for API requests (seconds)
|
|
||||||
DEFAULT_TIMEOUT = 30
|
DEFAULT_TIMEOUT = 30
|
||||||
|
|
||||||
|
# Canonical fallback order. Empirically ordered by Chinese real-time
|
||||||
|
# quality + relevance: bocha (best overall), qianfan (best for hot news),
|
||||||
|
# zhipu (strong on long-form articles), linkai (cloud aggregator, last
|
||||||
|
# resort).
|
||||||
|
PROVIDER_ORDER = ("bocha", "qianfan", "zhipu", "linkai")
|
||||||
|
|
||||||
|
PROVIDER_LABELS = {
|
||||||
|
"bocha": "Bocha",
|
||||||
|
"zhipu": "Zhipu",
|
||||||
|
"qianfan": "Baidu Qianfan",
|
||||||
|
"linkai": "LinkAI",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tools_web_search_conf() -> dict:
|
||||||
|
"""Return the tools.web_search config block (dict-like)."""
|
||||||
|
tools_cfg = conf().get("tools") or {}
|
||||||
|
if not isinstance(tools_cfg, dict):
|
||||||
|
return {}
|
||||||
|
block = tools_cfg.get("web_search") or {}
|
||||||
|
return block if isinstance(block, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_api_key(provider: str) -> str:
|
||||||
|
"""Resolve API key for a provider, with conf -> env fallback."""
|
||||||
|
if provider == "bocha":
|
||||||
|
key = (_tools_web_search_conf().get("bocha_api_key") or "").strip()
|
||||||
|
return key or os.environ.get("BOCHA_API_KEY", "").strip()
|
||||||
|
if provider == "zhipu":
|
||||||
|
key = (conf().get("zhipu_ai_api_key") or "").strip()
|
||||||
|
return key or os.environ.get("ZHIPUAI_API_KEY", "").strip()
|
||||||
|
if provider == "qianfan":
|
||||||
|
key = (conf().get("qianfan_api_key") or "").strip()
|
||||||
|
return key or os.environ.get("QIANFAN_API_KEY", "").strip()
|
||||||
|
if provider == "linkai":
|
||||||
|
key = (conf().get("linkai_api_key") or "").strip()
|
||||||
|
return key or os.environ.get("LINKAI_API_KEY", "").strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def configured_providers() -> List[str]:
|
||||||
|
"""Return configured providers in canonical order."""
|
||||||
|
return [p for p in PROVIDER_ORDER if _get_api_key(p)]
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_strategy() -> str:
|
||||||
|
return (_tools_web_search_conf().get("strategy") or "auto").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_provider() -> str:
|
||||||
|
return (_tools_web_search_conf().get("provider") or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
class WebSearch(BaseTool):
|
class WebSearch(BaseTool):
|
||||||
"""Tool for searching the web using Bocha or LinkAI search API"""
|
"""Tool for searching the web across multiple providers."""
|
||||||
|
|
||||||
name: str = "web_search"
|
name: str = "web_search"
|
||||||
description: str = (
|
description: str = "Search the web for real-time information. Returns titles, URLs, and snippets."
|
||||||
"Search the web for current information, news, research topics, or any real-time data. "
|
|
||||||
"Returns web page titles, URLs, snippets, and optional summaries. "
|
|
||||||
"Use this when the user asks about recent events, needs fact-checking, or wants up-to-date information."
|
|
||||||
)
|
|
||||||
|
|
||||||
params: dict = {
|
params: dict = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -58,265 +120,368 @@ class WebSearch(BaseTool):
|
|||||||
|
|
||||||
def __init__(self, config: dict = None):
|
def __init__(self, config: dict = None):
|
||||||
self.config = config or {}
|
self.config = config or {}
|
||||||
self._backend = None # Will be resolved on first execute
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_available() -> bool:
|
def is_available() -> bool:
|
||||||
"""Check if web search is available (at least one API key is configured)"""
|
"""Tool is offered to the agent when at least one provider has a key."""
|
||||||
return bool(os.environ.get("BOCHA_API_KEY") or os.environ.get("LINKAI_API_KEY"))
|
return bool(configured_providers())
|
||||||
|
|
||||||
def _resolve_backend(self) -> Optional[str]:
|
@classmethod
|
||||||
"""
|
def get_json_schema(cls) -> dict:
|
||||||
Determine which search backend to use.
|
"""Augment the static schema with a `provider` field — only when the
|
||||||
Priority: Bocha > LinkAI
|
user has ≥2 providers configured AND strategy is 'auto'. Otherwise
|
||||||
|
the backend picks silently and exposing the field would only waste
|
||||||
|
the agent's tokens."""
|
||||||
|
schema = {
|
||||||
|
"name": cls.name,
|
||||||
|
"description": cls.description,
|
||||||
|
"parameters": json.loads(json.dumps(cls.params)), # deep copy
|
||||||
|
}
|
||||||
|
if _configured_strategy() != "auto":
|
||||||
|
return schema
|
||||||
|
available = configured_providers()
|
||||||
|
if len(available) < 2:
|
||||||
|
return schema
|
||||||
|
|
||||||
:return: 'bocha', 'linkai', or None
|
schema["parameters"]["properties"]["provider"] = {
|
||||||
|
"type": "string",
|
||||||
|
"enum": available,
|
||||||
|
"description": "Optional. Specifies the search backend. You may switch between providers when the user wants results from a particular source or from multiple sources.",
|
||||||
|
}
|
||||||
|
return schema
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Provider resolution
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _resolve_provider(self, requested: Optional[str]) -> Optional[str]:
|
||||||
|
"""Pick a provider for this call.
|
||||||
|
|
||||||
|
Priority: caller-supplied (if configured) > fixed strategy (if
|
||||||
|
configured) > first configured in PROVIDER_ORDER. Silent fallback
|
||||||
|
when the desired one has no key.
|
||||||
"""
|
"""
|
||||||
if os.environ.get("BOCHA_API_KEY"):
|
available = configured_providers()
|
||||||
return "bocha"
|
if not available:
|
||||||
if os.environ.get("LINKAI_API_KEY"):
|
return None
|
||||||
return "linkai"
|
|
||||||
return None
|
if requested:
|
||||||
|
req = requested.strip().lower()
|
||||||
|
if req in available:
|
||||||
|
return req
|
||||||
|
logger.warning(f"[WebSearch] requested provider '{requested}' unavailable, falling back")
|
||||||
|
|
||||||
|
if _configured_strategy() == "fixed":
|
||||||
|
pinned = _configured_provider()
|
||||||
|
if pinned in available:
|
||||||
|
return pinned
|
||||||
|
if pinned:
|
||||||
|
logger.warning(f"[WebSearch] pinned provider '{pinned}' unavailable, falling back to auto")
|
||||||
|
|
||||||
|
return available[0]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolution_reason(requested: Optional[str], chosen: str) -> str:
|
||||||
|
"""Human-readable explanation for why `chosen` won the resolver."""
|
||||||
|
if requested and requested.strip().lower() == chosen:
|
||||||
|
return "caller-requested"
|
||||||
|
strategy = _configured_strategy()
|
||||||
|
if strategy == "fixed" and _configured_provider() == chosen:
|
||||||
|
return "fixed-strategy"
|
||||||
|
return "auto-fallback"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
"""
|
query = (args.get("query") or "").strip()
|
||||||
Execute web search
|
|
||||||
|
|
||||||
:param args: Search parameters (query, count, freshness, summary)
|
|
||||||
:return: Search results
|
|
||||||
"""
|
|
||||||
query = args.get("query", "").strip()
|
|
||||||
if not query:
|
if not query:
|
||||||
return ToolResult.fail("Error: 'query' parameter is required")
|
return ToolResult.fail("Error: 'query' parameter is required")
|
||||||
|
|
||||||
count = args.get("count", 10)
|
count = args.get("count", 10)
|
||||||
freshness = args.get("freshness", "noLimit")
|
freshness = args.get("freshness", "noLimit")
|
||||||
summary = args.get("summary", False)
|
summary = args.get("summary", False)
|
||||||
|
|
||||||
# Validate count
|
|
||||||
if not isinstance(count, int) or count < 1 or count > 50:
|
if not isinstance(count, int) or count < 1 or count > 50:
|
||||||
count = 10
|
count = 10
|
||||||
|
|
||||||
# Resolve backend
|
requested = args.get("provider")
|
||||||
backend = self._resolve_backend()
|
provider = self._resolve_provider(requested)
|
||||||
if not backend:
|
if not provider:
|
||||||
return ToolResult.fail(
|
return ToolResult.fail(
|
||||||
"Error: No search API key configured. "
|
"Error: No search provider configured. "
|
||||||
"Please set BOCHA_API_KEY or LINKAI_API_KEY using env_config tool.\n"
|
"Configure one of BOCHA_API_KEY / zhipu_ai_api_key / qianfan_api_key / linkai_api_key."
|
||||||
" - Bocha Search: https://open.bocha.cn\n"
|
|
||||||
" - LinkAI Search: https://link-ai.tech"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Always log the routing decision so multi-provider deployments can
|
||||||
|
# tell at a glance which backend served any given query.
|
||||||
|
available = configured_providers()
|
||||||
|
reason = self._resolution_reason(requested, provider)
|
||||||
|
q_preview = query if len(query) <= 60 else (query[:57] + "...")
|
||||||
|
logger.info(
|
||||||
|
f"[WebSearch] provider={provider} reason={reason} "
|
||||||
|
f"available={list(available)} query={q_preview!r} count={count} freshness={freshness}"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if backend == "bocha":
|
if provider == "bocha":
|
||||||
return self._search_bocha(query, count, freshness, summary)
|
return self._search_bocha(query, count, freshness, summary)
|
||||||
else:
|
if provider == "zhipu":
|
||||||
|
return self._search_zhipu(query, count, freshness)
|
||||||
|
if provider == "qianfan":
|
||||||
|
return self._search_qianfan(query, count, freshness)
|
||||||
|
if provider == "linkai":
|
||||||
return self._search_linkai(query, count, freshness)
|
return self._search_linkai(query, count, freshness)
|
||||||
|
return ToolResult.fail(f"Error: Unknown provider '{provider}'")
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
return ToolResult.fail(f"Error: Search request timed out after {DEFAULT_TIMEOUT}s")
|
return ToolResult.fail(f"Error: Search request timed out after {DEFAULT_TIMEOUT}s")
|
||||||
except requests.ConnectionError:
|
except requests.ConnectionError:
|
||||||
return ToolResult.fail("Error: Failed to connect to search API")
|
return ToolResult.fail("Error: Failed to connect to search API")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[WebSearch] Unexpected error: {e}", exc_info=True)
|
logger.error(f"[WebSearch] Unexpected error ({provider}): {e}", exc_info=True)
|
||||||
return ToolResult.fail(f"Error: Search failed - {str(e)}")
|
return ToolResult.fail(f"Error: Search failed - {str(e)}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Bocha
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _search_bocha(self, query: str, count: int, freshness: str, summary: bool) -> ToolResult:
|
def _search_bocha(self, query: str, count: int, freshness: str, summary: bool) -> ToolResult:
|
||||||
"""
|
api_key = _get_api_key("bocha")
|
||||||
Search using Bocha API
|
url = "https://api.bochaai.com/v1/web-search"
|
||||||
|
|
||||||
:param query: Search query
|
|
||||||
:param count: Number of results
|
|
||||||
:param freshness: Time range filter
|
|
||||||
:param summary: Whether to include summary
|
|
||||||
:return: Formatted search results
|
|
||||||
"""
|
|
||||||
api_key = os.environ.get("BOCHA_API_KEY", "")
|
|
||||||
url = "https://api.bocha.cn/v1/web-search"
|
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {api_key}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Accept": "application/json"
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
|
payload = {"query": query, "count": count, "freshness": freshness, "summary": summary}
|
||||||
|
|
||||||
payload = {
|
logger.debug(f"[WebSearch] bocha: query='{query}', count={count}")
|
||||||
"query": query,
|
resp = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
|
||||||
"count": count,
|
|
||||||
"freshness": freshness,
|
|
||||||
"summary": summary
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(f"[WebSearch] Bocha search: query='{query}', count={count}")
|
if resp.status_code == 401:
|
||||||
|
return ToolResult.fail("Error: Invalid bocha API key.")
|
||||||
|
if resp.status_code == 403:
|
||||||
|
return ToolResult.fail("Error: bocha API — insufficient balance. Top up at https://open.bochaai.com")
|
||||||
|
if resp.status_code == 429:
|
||||||
|
return ToolResult.fail("Error: bocha API rate limit reached.")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return ToolResult.fail(f"Error: bocha API returned HTTP {resp.status_code}")
|
||||||
|
|
||||||
response = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
|
data = resp.json()
|
||||||
|
|
||||||
if response.status_code == 401:
|
|
||||||
return ToolResult.fail("Error: Invalid BOCHA_API_KEY. Please check your API key.")
|
|
||||||
if response.status_code == 403:
|
|
||||||
return ToolResult.fail("Error: Bocha API - insufficient balance. Please top up at https://open.bocha.cn")
|
|
||||||
if response.status_code == 429:
|
|
||||||
return ToolResult.fail("Error: Bocha API rate limit reached. Please try again later.")
|
|
||||||
if response.status_code != 200:
|
|
||||||
return ToolResult.fail(f"Error: Bocha API returned HTTP {response.status_code}")
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
# Check API-level error code
|
|
||||||
api_code = data.get("code")
|
api_code = data.get("code")
|
||||||
if api_code is not None and api_code != 200:
|
if api_code is not None and api_code != 200:
|
||||||
msg = data.get("msg") or "Unknown error"
|
msg = data.get("msg") or "Unknown error"
|
||||||
return ToolResult.fail(f"Error: Bocha API error (code={api_code}): {msg}")
|
return ToolResult.fail(f"Error: bocha API error (code={api_code}): {msg}")
|
||||||
|
|
||||||
# Extract and format results
|
|
||||||
return self._format_bocha_results(data, query)
|
|
||||||
|
|
||||||
def _format_bocha_results(self, data: dict, query: str) -> ToolResult:
|
|
||||||
"""
|
|
||||||
Format Bocha API response into unified result structure
|
|
||||||
|
|
||||||
:param data: Raw API response
|
|
||||||
:param query: Original query
|
|
||||||
:return: Formatted ToolResult
|
|
||||||
"""
|
|
||||||
search_data = data.get("data", {})
|
|
||||||
web_pages = search_data.get("webPages", {})
|
|
||||||
pages = web_pages.get("value", [])
|
|
||||||
|
|
||||||
if not pages:
|
|
||||||
return ToolResult.success({
|
|
||||||
"query": query,
|
|
||||||
"backend": "bocha",
|
|
||||||
"total": 0,
|
|
||||||
"results": [],
|
|
||||||
"message": "No results found"
|
|
||||||
})
|
|
||||||
|
|
||||||
|
pages = (data.get("data") or {}).get("webPages", {}).get("value", []) or []
|
||||||
results = []
|
results = []
|
||||||
for page in pages:
|
for p in pages:
|
||||||
result = {
|
item = {
|
||||||
"title": page.get("name", ""),
|
"title": p.get("name", ""),
|
||||||
"url": page.get("url", ""),
|
"url": p.get("url", ""),
|
||||||
"snippet": page.get("snippet", ""),
|
"snippet": p.get("snippet", ""),
|
||||||
"siteName": page.get("siteName", ""),
|
"siteName": p.get("siteName", ""),
|
||||||
"datePublished": page.get("datePublished") or page.get("dateLastCrawled", ""),
|
"datePublished": p.get("datePublished") or p.get("dateLastCrawled", ""),
|
||||||
}
|
}
|
||||||
# Include summary only if present
|
if p.get("summary"):
|
||||||
if page.get("summary"):
|
item["summary"] = p["summary"]
|
||||||
result["summary"] = page["summary"]
|
results.append(item)
|
||||||
results.append(result)
|
total = (data.get("data") or {}).get("webPages", {}).get("totalEstimatedMatches", len(results))
|
||||||
|
|
||||||
total = web_pages.get("totalEstimatedMatches", len(results))
|
|
||||||
|
|
||||||
return ToolResult.success({
|
return ToolResult.success({
|
||||||
"query": query,
|
"query": query, "backend": "bocha",
|
||||||
"backend": "bocha",
|
"total": total, "count": len(results), "results": results,
|
||||||
"total": total,
|
|
||||||
"count": len(results),
|
|
||||||
"results": results
|
|
||||||
})
|
})
|
||||||
|
|
||||||
def _search_linkai(self, query: str, count: int, freshness: str) -> ToolResult:
|
# ------------------------------------------------------------------
|
||||||
"""
|
# Zhipu
|
||||||
Search using LinkAI plugin API
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
:param query: Search query
|
|
||||||
:param count: Number of results
|
|
||||||
:param freshness: Time range filter
|
|
||||||
:return: Formatted search results
|
|
||||||
"""
|
|
||||||
api_key = os.environ.get("LINKAI_API_KEY", "")
|
|
||||||
url = "https://api.link-ai.tech/v1/plugin/execute"
|
|
||||||
|
|
||||||
|
def _search_zhipu(self, query: str, count: int, freshness: str) -> ToolResult:
|
||||||
|
api_key = _get_api_key("zhipu")
|
||||||
|
api_base = (conf().get("zhipu_ai_api_base") or "https://open.bigmodel.cn/api/paas/v4").rstrip("/")
|
||||||
|
url = f"{api_base}/web_search"
|
||||||
headers = {
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": f"Bearer {api_key}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
payload = {
|
# Zhipu Web Search expects `search_query` <= 70 chars; truncate
|
||||||
"code": "web-search",
|
# gracefully so a long agent-supplied query doesn't get rejected.
|
||||||
"args": {
|
trimmed_query = (query or "")[:70]
|
||||||
"query": query,
|
engine = (_tools_web_search_conf().get("zhipu_search_engine") or "search_pro").strip().lower()
|
||||||
"count": count,
|
if engine not in ("search_std", "search_pro", "search_pro_sogou", "search_pro_quark"):
|
||||||
"freshness": freshness
|
engine = "search_pro"
|
||||||
}
|
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"search_engine": engine,
|
||||||
|
"search_query": trimmed_query,
|
||||||
|
"search_intent": False,
|
||||||
|
"count": max(1, min(int(count or 10), 50)),
|
||||||
|
"search_recency_filter": freshness if freshness in (
|
||||||
|
"oneDay", "oneWeek", "oneMonth", "oneYear", "noLimit"
|
||||||
|
) else "noLimit",
|
||||||
|
}
|
||||||
|
content_size = (_tools_web_search_conf().get("zhipu_content_size") or "").strip().lower()
|
||||||
|
if content_size in ("medium", "high"):
|
||||||
|
payload["content_size"] = content_size
|
||||||
|
|
||||||
|
logger.debug(f"[WebSearch] zhipu: query='{trimmed_query}', count={payload['count']}, engine={engine}")
|
||||||
|
resp = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
|
||||||
|
|
||||||
|
if resp.status_code == 401:
|
||||||
|
return ToolResult.fail("Error: Invalid Zhipu API key.")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return ToolResult.fail(f"Error: Zhipu API returned HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
# Business-level errors (1701/1702/1703 etc.) come back as
|
||||||
|
# {"error": {"code","message"}} even on HTTP 200.
|
||||||
|
if isinstance(data, dict) and data.get("error"):
|
||||||
|
err = data["error"] or {}
|
||||||
|
return ToolResult.fail(f"Error: Zhipu returned {err.get('code')}: {err.get('message','')}")
|
||||||
|
|
||||||
|
items = data.get("search_result") or (data.get("data") or {}).get("search_result") or []
|
||||||
|
results = []
|
||||||
|
for it in items:
|
||||||
|
results.append({
|
||||||
|
"title": it.get("title", ""),
|
||||||
|
"url": it.get("link") or it.get("url", ""),
|
||||||
|
"snippet": it.get("content") or it.get("snippet", ""),
|
||||||
|
"siteName": it.get("media") or it.get("siteName", ""),
|
||||||
|
"datePublished": it.get("publish_date") or it.get("datePublished", ""),
|
||||||
|
})
|
||||||
|
return ToolResult.success({
|
||||||
|
"query": query, "backend": "zhipu",
|
||||||
|
"total": len(results), "count": len(results), "results": results,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Qianfan (Baidu)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _search_qianfan(self, query: str, count: int, freshness: str) -> ToolResult:
|
||||||
|
api_key = _get_api_key("qianfan")
|
||||||
|
api_base = (conf().get("qianfan_api_base") or "https://qianfan.baidubce.com/v2").rstrip("/")
|
||||||
|
url = f"{api_base}/ai_search/web_search"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Appbuilder-From": "cow",
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(f"[WebSearch] LinkAI search: query='{query}', count={count}")
|
count = max(1, min(int(count or 10), 50))
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"messages": [{"role": "user", "content": query}],
|
||||||
|
"search_source": "baidu_search_v2",
|
||||||
|
"resource_type_filter": [{"type": "web", "top_k": count}],
|
||||||
|
}
|
||||||
|
|
||||||
response = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
|
# Baidu AI Search expects freshness as a date-range filter, not a
|
||||||
|
# named recency token. Translate our shared vocabulary into the
|
||||||
|
# underlying page_time range expected by the API.
|
||||||
|
search_filter = self._qianfan_build_freshness_filter(freshness)
|
||||||
|
if search_filter:
|
||||||
|
payload["search_filter"] = search_filter
|
||||||
|
|
||||||
if response.status_code == 401:
|
logger.debug(f"[WebSearch] qianfan: query='{query}', count={count}, freshness={freshness!r}")
|
||||||
return ToolResult.fail("Error: Invalid LINKAI_API_KEY. Please check your API key.")
|
resp = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
|
||||||
if response.status_code != 200:
|
|
||||||
return ToolResult.fail(f"Error: LinkAI API returned HTTP {response.status_code}")
|
|
||||||
|
|
||||||
data = response.json()
|
if resp.status_code == 401:
|
||||||
|
return ToolResult.fail("Error: Invalid Qianfan API key.")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return ToolResult.fail(f"Error: Qianfan API returned HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
# Even on HTTP 200 Baidu surfaces business errors as {"code","message"}.
|
||||||
|
if isinstance(data, dict) and data.get("code"):
|
||||||
|
return ToolResult.fail(f"Error: Qianfan returned {data.get('code')}: {data.get('message','')}")
|
||||||
|
|
||||||
|
refs = data.get("references") or []
|
||||||
|
results = []
|
||||||
|
for d in refs:
|
||||||
|
results.append({
|
||||||
|
"title": d.get("title", ""),
|
||||||
|
"url": d.get("url", ""),
|
||||||
|
"snippet": (d.get("content") or "")[:200],
|
||||||
|
"siteName": d.get("web_anchor") or d.get("website") or "",
|
||||||
|
"datePublished": d.get("date", ""),
|
||||||
|
})
|
||||||
|
return ToolResult.success({
|
||||||
|
"query": query, "backend": "qianfan",
|
||||||
|
"total": len(results), "count": len(results), "results": results,
|
||||||
|
})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _qianfan_build_freshness_filter(freshness: str) -> Optional[Dict[str, Any]]:
|
||||||
|
if not freshness or freshness == "noLimit":
|
||||||
|
return None
|
||||||
|
delta_days = {"oneDay": 1, "oneWeek": 7, "oneMonth": 30, "oneYear": 365}.get(freshness)
|
||||||
|
if not delta_days:
|
||||||
|
return None
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
now = datetime.now()
|
||||||
|
end_date = (now + timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
start_date = (now - timedelta(days=delta_days)).strftime("%Y-%m-%d")
|
||||||
|
return {"range": {"page_time": {"gte": start_date, "lt": end_date}}}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# LinkAI (plugin)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _search_linkai(self, query: str, count: int, freshness: str) -> ToolResult:
|
||||||
|
api_key = _get_api_key("linkai")
|
||||||
|
api_base = (conf().get("linkai_api_base") or "https://api.link-ai.tech").rstrip("/")
|
||||||
|
url = f"{api_base}/v1/plugin/execute"
|
||||||
|
|
||||||
|
from common.utils import get_cloud_headers
|
||||||
|
headers = get_cloud_headers(api_key)
|
||||||
|
|
||||||
|
payload = {"code": "web-search", "args": {"query": query, "count": count, "freshness": freshness}}
|
||||||
|
logger.debug(f"[WebSearch] linkai: query='{query}', count={count}")
|
||||||
|
resp = requests.post(url, headers=headers, json=payload, timeout=DEFAULT_TIMEOUT)
|
||||||
|
|
||||||
|
if resp.status_code == 401:
|
||||||
|
return ToolResult.fail("Error: Invalid LinkAI API key.")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return ToolResult.fail(f"Error: LinkAI API returned HTTP {resp.status_code}")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
if not data.get("success"):
|
if not data.get("success"):
|
||||||
msg = data.get("message") or "Unknown error"
|
msg = data.get("message") or "Unknown error"
|
||||||
return ToolResult.fail(f"Error: LinkAI search failed: {msg}")
|
return ToolResult.fail(f"Error: LinkAI search failed: {msg}")
|
||||||
|
|
||||||
return self._format_linkai_results(data, query)
|
raw = data.get("data", "")
|
||||||
|
if isinstance(raw, str):
|
||||||
def _format_linkai_results(self, data: dict, query: str) -> ToolResult:
|
|
||||||
"""
|
|
||||||
Format LinkAI API response into unified result structure.
|
|
||||||
LinkAI returns the search data in data.data field, which follows
|
|
||||||
the same Bing-compatible format as Bocha.
|
|
||||||
|
|
||||||
:param data: Raw API response
|
|
||||||
:param query: Original query
|
|
||||||
:return: Formatted ToolResult
|
|
||||||
"""
|
|
||||||
raw_data = data.get("data", "")
|
|
||||||
|
|
||||||
# LinkAI may return data as a JSON string
|
|
||||||
if isinstance(raw_data, str):
|
|
||||||
try:
|
try:
|
||||||
raw_data = json.loads(raw_data)
|
raw = json.loads(raw)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
# If data is plain text, return it as a single result
|
|
||||||
return ToolResult.success({
|
return ToolResult.success({
|
||||||
"query": query,
|
"query": query, "backend": "linkai",
|
||||||
"backend": "linkai",
|
"total": 1, "count": 1, "results": [{"content": raw}],
|
||||||
"total": 1,
|
|
||||||
"count": 1,
|
|
||||||
"results": [{"content": raw_data}]
|
|
||||||
})
|
})
|
||||||
|
|
||||||
# If the response follows Bing-compatible structure
|
if isinstance(raw, dict):
|
||||||
if isinstance(raw_data, dict):
|
pages = (raw.get("webPages") or {}).get("value", []) or []
|
||||||
web_pages = raw_data.get("webPages", {})
|
|
||||||
pages = web_pages.get("value", [])
|
|
||||||
|
|
||||||
if pages:
|
if pages:
|
||||||
results = []
|
results = []
|
||||||
for page in pages:
|
for p in pages:
|
||||||
result = {
|
item = {
|
||||||
"title": page.get("name", ""),
|
"title": p.get("name", ""),
|
||||||
"url": page.get("url", ""),
|
"url": p.get("url", ""),
|
||||||
"snippet": page.get("snippet", ""),
|
"snippet": p.get("snippet", ""),
|
||||||
"siteName": page.get("siteName", ""),
|
"siteName": p.get("siteName", ""),
|
||||||
"datePublished": page.get("datePublished") or page.get("dateLastCrawled", ""),
|
"datePublished": p.get("datePublished") or p.get("dateLastCrawled", ""),
|
||||||
}
|
}
|
||||||
if page.get("summary"):
|
if p.get("summary"):
|
||||||
result["summary"] = page["summary"]
|
item["summary"] = p["summary"]
|
||||||
results.append(result)
|
results.append(item)
|
||||||
|
total = (raw.get("webPages") or {}).get("totalEstimatedMatches", len(results))
|
||||||
total = web_pages.get("totalEstimatedMatches", len(results))
|
|
||||||
return ToolResult.success({
|
return ToolResult.success({
|
||||||
"query": query,
|
"query": query, "backend": "linkai",
|
||||||
"backend": "linkai",
|
"total": total, "count": len(results), "results": results,
|
||||||
"total": total,
|
|
||||||
"count": len(results),
|
|
||||||
"results": results
|
|
||||||
})
|
})
|
||||||
|
|
||||||
# Fallback: return raw data
|
|
||||||
return ToolResult.success({
|
return ToolResult.success({
|
||||||
"query": query,
|
"query": query, "backend": "linkai",
|
||||||
"backend": "linkai",
|
"total": 1, "count": 1, "results": [{"content": str(raw)}],
|
||||||
"total": 1,
|
|
||||||
"count": 1,
|
|
||||||
"results": [{"content": str(raw_data)}]
|
|
||||||
})
|
})
|
||||||
|
|||||||
163
app.py
163
app.py
@@ -15,6 +15,11 @@ import threading
|
|||||||
|
|
||||||
_channel_mgr = None
|
_channel_mgr = None
|
||||||
|
|
||||||
|
# Desktop mode: a lighter runtime for the packaged Electron client. Plugins are
|
||||||
|
# loaded in a background thread (so command plugins like cow_cli/godcmd work
|
||||||
|
# without slowing startup), while MCP warmup is still skipped to keep it fast.
|
||||||
|
DESKTOP_MODE = os.environ.get("COW_DESKTOP") == "1"
|
||||||
|
|
||||||
|
|
||||||
def get_channel_manager():
|
def get_channel_manager():
|
||||||
return _channel_mgr
|
return _channel_mgr
|
||||||
@@ -47,6 +52,7 @@ class ChannelManager:
|
|||||||
self._threads = {} # channel_name -> thread
|
self._threads = {} # channel_name -> thread
|
||||||
self._primary_channel = None
|
self._primary_channel = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
self.cloud_mode = False # set to True when cloud client is active
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def channel(self):
|
def channel(self):
|
||||||
@@ -65,6 +71,7 @@ class ChannelManager:
|
|||||||
channels = []
|
channels = []
|
||||||
for name in channel_names:
|
for name in channel_names:
|
||||||
ch = channel_factory.create_channel(name)
|
ch = channel_factory.create_channel(name)
|
||||||
|
ch.cloud_mode = self.cloud_mode
|
||||||
self._channels[name] = ch
|
self._channels[name] = ch
|
||||||
channels.append((name, ch))
|
channels.append((name, ch))
|
||||||
if self._primary_channel is None and name != "web":
|
if self._primary_channel is None and name != "web":
|
||||||
@@ -74,9 +81,23 @@ class ChannelManager:
|
|||||||
self._primary_channel = channels[0][1]
|
self._primary_channel = channels[0][1]
|
||||||
|
|
||||||
if first_start:
|
if first_start:
|
||||||
PluginManager().load_plugins()
|
if DESKTOP_MODE:
|
||||||
|
# Load plugins in the background so command plugins
|
||||||
|
# (cow_cli / godcmd, e.g. /status, #help) work in the
|
||||||
|
# desktop client, without blocking web-service readiness.
|
||||||
|
threading.Thread(
|
||||||
|
target=PluginManager().load_plugins, daemon=True
|
||||||
|
).start()
|
||||||
|
else:
|
||||||
|
PluginManager().load_plugins()
|
||||||
|
|
||||||
if conf().get("use_linkai"):
|
# Cloud client is optional. It is only started when
|
||||||
|
# use_linkai=True AND cloud_deployment_id is set.
|
||||||
|
# By default neither is configured, so the app runs
|
||||||
|
# entirely locally without any remote connection.
|
||||||
|
if conf().get("use_linkai") and (
|
||||||
|
os.environ.get("CLOUD_DEPLOYMENT_ID") or conf().get("cloud_deployment_id")
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
from common import cloud_client
|
from common import cloud_client
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
@@ -136,13 +157,22 @@ class ChannelManager:
|
|||||||
self._interrupt_thread(th, name)
|
self._interrupt_thread(th, name)
|
||||||
continue
|
continue
|
||||||
logger.info(f"[ChannelManager] Stopping channel '{name}'...")
|
logger.info(f"[ChannelManager] Stopping channel '{name}'...")
|
||||||
try:
|
graceful = False
|
||||||
if hasattr(ch, 'stop'):
|
if hasattr(ch, 'stop'):
|
||||||
|
try:
|
||||||
ch.stop()
|
ch.stop()
|
||||||
except Exception as e:
|
graceful = True
|
||||||
logger.warning(f"[ChannelManager] Error during channel '{name}' stop: {e}")
|
except Exception as e:
|
||||||
|
logger.warning(f"[ChannelManager] Error during channel '{name}' stop: {e}")
|
||||||
if th and th.is_alive():
|
if th and th.is_alive():
|
||||||
self._interrupt_thread(th, name)
|
th.join(timeout=5)
|
||||||
|
if th.is_alive():
|
||||||
|
if graceful:
|
||||||
|
logger.info(f"[ChannelManager] Channel '{name}' thread still alive after stop(), "
|
||||||
|
"leaving daemon thread to finish on its own")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[ChannelManager] Channel '{name}' thread did not exit in 5s, forcing interrupt")
|
||||||
|
self._interrupt_thread(th, name)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _interrupt_thread(th: threading.Thread, name: str):
|
def _interrupt_thread(th: threading.Thread, name: str):
|
||||||
@@ -175,6 +205,34 @@ class ChannelManager:
|
|||||||
self.start([new_channel_name], first_start=False)
|
self.start([new_channel_name], first_start=False)
|
||||||
logger.info(f"[ChannelManager] Channel restarted to '{new_channel_name}' successfully")
|
logger.info(f"[ChannelManager] Channel restarted to '{new_channel_name}' successfully")
|
||||||
|
|
||||||
|
def add_channel(self, channel_name: str):
|
||||||
|
"""
|
||||||
|
Dynamically add and start a new channel.
|
||||||
|
If the channel is already running, restart it instead.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if channel_name in self._channels:
|
||||||
|
logger.info(f"[ChannelManager] Channel '{channel_name}' already exists, restarting")
|
||||||
|
if self._channels.get(channel_name):
|
||||||
|
self.restart(channel_name)
|
||||||
|
return
|
||||||
|
logger.info(f"[ChannelManager] Adding channel '{channel_name}'...")
|
||||||
|
_clear_singleton_cache(channel_name)
|
||||||
|
self.start([channel_name], first_start=False)
|
||||||
|
logger.info(f"[ChannelManager] Channel '{channel_name}' added successfully")
|
||||||
|
|
||||||
|
def remove_channel(self, channel_name: str):
|
||||||
|
"""
|
||||||
|
Dynamically stop and remove a running channel.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if channel_name not in self._channels:
|
||||||
|
logger.warning(f"[ChannelManager] Channel '{channel_name}' not found, nothing to remove")
|
||||||
|
return
|
||||||
|
logger.info(f"[ChannelManager] Removing channel '{channel_name}'...")
|
||||||
|
self.stop(channel_name)
|
||||||
|
logger.info(f"[ChannelManager] Channel '{channel_name}' removed successfully")
|
||||||
|
|
||||||
|
|
||||||
def _clear_singleton_cache(channel_name: str):
|
def _clear_singleton_cache(channel_name: str):
|
||||||
"""
|
"""
|
||||||
@@ -182,16 +240,20 @@ def _clear_singleton_cache(channel_name: str):
|
|||||||
a new instance can be created with updated config.
|
a new instance can be created with updated config.
|
||||||
"""
|
"""
|
||||||
cls_map = {
|
cls_map = {
|
||||||
"wx": "channel.wechat.wechat_channel.WechatChannel",
|
|
||||||
"wxy": "channel.wechat.wechaty_channel.WechatyChannel",
|
|
||||||
"wcf": "channel.wechat.wcf_channel.WechatfChannel",
|
|
||||||
"web": "channel.web.web_channel.WebChannel",
|
"web": "channel.web.web_channel.WebChannel",
|
||||||
"wechatmp": "channel.wechatmp.wechatmp_channel.WechatMPChannel",
|
"wechatmp": "channel.wechatmp.wechatmp_channel.WechatMPChannel",
|
||||||
"wechatmp_service": "channel.wechatmp.wechatmp_channel.WechatMPChannel",
|
"wechatmp_service": "channel.wechatmp.wechatmp_channel.WechatMPChannel",
|
||||||
"wechatcom_app": "channel.wechatcom.wechatcomapp_channel.WechatComAppChannel",
|
"wechatcom_app": "channel.wechatcom.wechatcomapp_channel.WechatComAppChannel",
|
||||||
"wework": "channel.wework.wework_channel.WeworkChannel",
|
const.WECHAT_KF: "channel.wechat_kf.wechat_kf_channel.WechatKfChannel",
|
||||||
const.FEISHU: "channel.feishu.feishu_channel.FeiShuChanel",
|
const.FEISHU: "channel.feishu.feishu_channel.FeiShuChanel",
|
||||||
const.DINGTALK: "channel.dingtalk.dingtalk_channel.DingTalkChanel",
|
const.DINGTALK: "channel.dingtalk.dingtalk_channel.DingTalkChanel",
|
||||||
|
const.WECOM_BOT: "channel.wecom_bot.wecom_bot_channel.WecomBotChannel",
|
||||||
|
const.QQ: "channel.qq.qq_channel.QQChannel",
|
||||||
|
const.TELEGRAM: "channel.telegram.telegram_channel.TelegramChannel",
|
||||||
|
const.SLACK: "channel.slack.slack_channel.SlackChannel",
|
||||||
|
const.DISCORD: "channel.discord.discord_channel.DiscordChannel",
|
||||||
|
const.WEIXIN: "channel.weixin.weixin_channel.WeixinChannel",
|
||||||
|
"wx": "channel.weixin.weixin_channel.WeixinChannel",
|
||||||
}
|
}
|
||||||
module_path = cls_map.get(channel_name)
|
module_path = cls_map.get(channel_name)
|
||||||
if not module_path:
|
if not module_path:
|
||||||
@@ -229,6 +291,63 @@ def sigterm_handler_wrap(_signo):
|
|||||||
signal.signal(_signo, func)
|
signal.signal(_signo, func)
|
||||||
|
|
||||||
|
|
||||||
|
def _warmup_mcp_tools():
|
||||||
|
"""
|
||||||
|
Kick off MCP server loading at process startup so subprocesses
|
||||||
|
(npx / uvx etc.) finish initializing before the first user message
|
||||||
|
arrives. Returns immediately — the actual work happens on a daemon
|
||||||
|
thread inside ToolManager. Safe to call when MCP is not configured.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from agent.tools import ToolManager
|
||||||
|
ToolManager()._load_mcp_tools()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[App] MCP warmup failed (non-fatal): {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _warmup_scheduler():
|
||||||
|
"""Eager-init AgentBridge so the scheduler thread starts at process
|
||||||
|
boot rather than waiting for the first user message."""
|
||||||
|
try:
|
||||||
|
from bridge.bridge import Bridge
|
||||||
|
Bridge().get_agent_bridge()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[App] Scheduler warmup failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_builtin_skills():
|
||||||
|
"""Sync builtin skills from project skills/ to workspace skills/ on startup."""
|
||||||
|
import shutil
|
||||||
|
try:
|
||||||
|
workspace = conf().get("agent_workspace", "~/cow")
|
||||||
|
workspace = os.path.expanduser(workspace)
|
||||||
|
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
builtin_dir = os.path.join(project_root, "skills")
|
||||||
|
custom_dir = os.path.join(workspace, "skills")
|
||||||
|
|
||||||
|
if not os.path.isdir(builtin_dir):
|
||||||
|
return
|
||||||
|
|
||||||
|
os.makedirs(custom_dir, exist_ok=True)
|
||||||
|
synced = 0
|
||||||
|
for name in os.listdir(builtin_dir):
|
||||||
|
src = os.path.join(builtin_dir, name)
|
||||||
|
if not os.path.isdir(src) or not os.path.isfile(os.path.join(src, "SKILL.md")):
|
||||||
|
continue
|
||||||
|
dst = os.path.join(custom_dir, name)
|
||||||
|
try:
|
||||||
|
if os.path.isdir(dst):
|
||||||
|
shutil.rmtree(dst)
|
||||||
|
shutil.copytree(src, dst)
|
||||||
|
synced += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[App] Failed to sync builtin skill '{name}': {e}")
|
||||||
|
if synced:
|
||||||
|
logger.info(f"[App] Synced {synced} builtin skill(s) to workspace")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[App] Builtin skills sync failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
global _channel_mgr
|
global _channel_mgr
|
||||||
try:
|
try:
|
||||||
@@ -249,14 +368,28 @@ def run():
|
|||||||
if not channel_names:
|
if not channel_names:
|
||||||
channel_names = ["web"]
|
channel_names = ["web"]
|
||||||
|
|
||||||
if "wxy" in channel_names:
|
|
||||||
os.environ["WECHATY_LOG"] = "warn"
|
|
||||||
|
|
||||||
# Auto-start web console unless explicitly disabled
|
# Auto-start web console unless explicitly disabled
|
||||||
web_console_enabled = conf().get("web_console", True)
|
web_console_enabled = conf().get("web_console", True)
|
||||||
if web_console_enabled and "web" not in channel_names:
|
if web_console_enabled and "web" not in channel_names:
|
||||||
channel_names.append("web")
|
channel_names.append("web")
|
||||||
|
|
||||||
|
# Sync builtin skills to workspace before channels start
|
||||||
|
_sync_builtin_skills()
|
||||||
|
|
||||||
|
# Kick off MCP server loading in the background so first-message
|
||||||
|
# latency isn't dominated by npx package downloads. Skipped in desktop
|
||||||
|
# mode (MCP relies on external npx/uvx runtimes that aren't bundled).
|
||||||
|
if not DESKTOP_MODE:
|
||||||
|
_warmup_mcp_tools()
|
||||||
|
|
||||||
|
if DESKTOP_MODE:
|
||||||
|
# Defer the (heavy) AgentBridge/scheduler warmup to a background
|
||||||
|
# thread so the web API becomes available within a couple seconds.
|
||||||
|
# The scheduler still starts; it just doesn't block UI readiness.
|
||||||
|
threading.Thread(target=_warmup_scheduler, daemon=True).start()
|
||||||
|
else:
|
||||||
|
_warmup_scheduler()
|
||||||
|
|
||||||
logger.info(f"[App] Starting channels: {channel_names}")
|
logger.info(f"[App] Starting channels: {channel_names}")
|
||||||
|
|
||||||
_channel_mgr = ChannelManager()
|
_channel_mgr = ChannelManager()
|
||||||
@@ -264,6 +397,8 @@ def run():
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("App startup failed!")
|
logger.error("App startup failed!")
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Agent Bridge - Integrates Agent system with existing COW bridge
|
|||||||
import os
|
import os
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from agent.protocol import Agent, LLMModel, LLMRequest
|
from agent.protocol import Agent, LLMModel, LLMRequest, get_cancel_registry
|
||||||
from bridge.agent_event_handler import AgentEventHandler
|
from bridge.agent_event_handler import AgentEventHandler
|
||||||
from bridge.agent_initializer import AgentInitializer
|
from bridge.agent_initializer import AgentInitializer
|
||||||
from bridge.bridge import Bridge
|
from bridge.bridge import Bridge
|
||||||
@@ -14,6 +14,7 @@ from bridge.reply import Reply, ReplyType
|
|||||||
from common import const
|
from common import const
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
from common.utils import expand_path
|
from common.utils import expand_path
|
||||||
|
from config import conf
|
||||||
from models.openai_compatible_bot import OpenAICompatibleBot
|
from models.openai_compatible_bot import OpenAICompatibleBot
|
||||||
|
|
||||||
|
|
||||||
@@ -67,18 +68,20 @@ class AgentLLMModel(LLMModel):
|
|||||||
|
|
||||||
_MODEL_BOT_TYPE_MAP = {
|
_MODEL_BOT_TYPE_MAP = {
|
||||||
"wenxin": const.BAIDU, "wenxin-4": const.BAIDU,
|
"wenxin": const.BAIDU, "wenxin-4": const.BAIDU,
|
||||||
"xunfei": const.XUNFEI, const.QWEN: const.QWEN,
|
"xunfei": const.XUNFEI, const.QWEN: const.QWEN_DASHSCOPE,
|
||||||
|
const.QIANFAN: const.QIANFAN,
|
||||||
const.MODELSCOPE: const.MODELSCOPE,
|
const.MODELSCOPE: const.MODELSCOPE,
|
||||||
}
|
}
|
||||||
_MODEL_PREFIX_MAP = [
|
_MODEL_PREFIX_MAP = [
|
||||||
("qwen", const.QWEN_DASHSCOPE), ("qwq", const.QWEN_DASHSCOPE), ("qvq", const.QWEN_DASHSCOPE),
|
("qwen", const.QWEN_DASHSCOPE), ("qwq", const.QWEN_DASHSCOPE), ("qvq", const.QWEN_DASHSCOPE),
|
||||||
("gemini", const.GEMINI), ("glm", const.ZHIPU_AI), ("claude", const.CLAUDEAPI),
|
("gemini", const.GEMINI), ("glm", const.ZHIPU_AI), ("claude", const.CLAUDEAPI),
|
||||||
("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT),
|
("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT),
|
||||||
("doubao", const.DOUBAO),
|
("doubao", const.DOUBAO), ("deepseek", const.DEEPSEEK),
|
||||||
|
("ernie", const.QIANFAN),
|
||||||
|
("mimo-", const.MIMO),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, bridge: Bridge, bot_type: str = "chat"):
|
def __init__(self, bridge: Bridge, bot_type: str = "chat"):
|
||||||
from config import conf
|
|
||||||
super().__init__(model=conf().get("model", const.GPT_41))
|
super().__init__(model=conf().get("model", const.GPT_41))
|
||||||
self.bridge = bridge
|
self.bridge = bridge
|
||||||
self.bot_type = bot_type
|
self.bot_type = bot_type
|
||||||
@@ -87,7 +90,6 @@ class AgentLLMModel(LLMModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def model(self):
|
def model(self):
|
||||||
from config import conf
|
|
||||||
return conf().get("model", const.GPT_41)
|
return conf().get("model", const.GPT_41)
|
||||||
|
|
||||||
@model.setter
|
@model.setter
|
||||||
@@ -96,11 +98,15 @@ class AgentLLMModel(LLMModel):
|
|||||||
|
|
||||||
def _resolve_bot_type(self, model_name: str) -> str:
|
def _resolve_bot_type(self, model_name: str) -> str:
|
||||||
"""Resolve bot type from model name, matching Bridge.__init__ logic."""
|
"""Resolve bot type from model name, matching Bridge.__init__ logic."""
|
||||||
from config import conf
|
|
||||||
if conf().get("use_linkai", False) and conf().get("linkai_api_key"):
|
if conf().get("use_linkai", False) and conf().get("linkai_api_key"):
|
||||||
return const.LINKAI
|
return const.LINKAI
|
||||||
|
# Support custom bot type configuration
|
||||||
|
configured_bot_type = conf().get("bot_type")
|
||||||
|
if configured_bot_type:
|
||||||
|
return configured_bot_type
|
||||||
|
|
||||||
if not model_name or not isinstance(model_name, str):
|
if not model_name or not isinstance(model_name, str):
|
||||||
return const.CHATGPT
|
return const.OPENAI
|
||||||
if model_name in self._MODEL_BOT_TYPE_MAP:
|
if model_name in self._MODEL_BOT_TYPE_MAP:
|
||||||
return self._MODEL_BOT_TYPE_MAP[model_name]
|
return self._MODEL_BOT_TYPE_MAP[model_name]
|
||||||
if model_name.lower().startswith("minimax") or model_name in ["abab6.5-chat"]:
|
if model_name.lower().startswith("minimax") or model_name in ["abab6.5-chat"]:
|
||||||
@@ -109,23 +115,25 @@ class AgentLLMModel(LLMModel):
|
|||||||
return const.QWEN_DASHSCOPE
|
return const.QWEN_DASHSCOPE
|
||||||
if model_name in [const.MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]:
|
if model_name in [const.MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]:
|
||||||
return const.MOONSHOT
|
return const.MOONSHOT
|
||||||
if model_name in [const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER]:
|
if conf().get("bot_type") == "modelscope":
|
||||||
return const.CHATGPT
|
return const.MODELSCOPE
|
||||||
|
lowered_model = model_name.lower()
|
||||||
for prefix, btype in self._MODEL_PREFIX_MAP:
|
for prefix, btype in self._MODEL_PREFIX_MAP:
|
||||||
if model_name.startswith(prefix):
|
if lowered_model.startswith(prefix):
|
||||||
return btype
|
return btype
|
||||||
return const.CHATGPT
|
return const.OPENAI
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def bot(self):
|
def bot(self):
|
||||||
"""Lazy load the bot, re-create when model changes"""
|
"""Lazy load the bot, re-create when model or bot_type changes"""
|
||||||
from models.bot_factory import create_bot
|
from models.bot_factory import create_bot
|
||||||
cur_model = self.model
|
cur_model = self.model
|
||||||
if self._bot is None or self._bot_model != cur_model:
|
cur_bot_type = self._resolve_bot_type(cur_model)
|
||||||
bot_type = self._resolve_bot_type(cur_model)
|
if self._bot is None or self._bot_model != cur_model or getattr(self, '_bot_type', None) != cur_bot_type:
|
||||||
self._bot = create_bot(bot_type)
|
self._bot = create_bot(cur_bot_type)
|
||||||
self._bot = add_openai_compatible_support(self._bot)
|
self._bot = add_openai_compatible_support(self._bot)
|
||||||
self._bot_model = cur_model
|
self._bot_model = cur_model
|
||||||
|
self._bot_type = cur_bot_type
|
||||||
return self._bot
|
return self._bot
|
||||||
|
|
||||||
def call(self, request: LLMRequest):
|
def call(self, request: LLMRequest):
|
||||||
@@ -146,12 +154,37 @@ class AgentLLMModel(LLMModel):
|
|||||||
# Only pass max_tokens if it's explicitly set
|
# Only pass max_tokens if it's explicitly set
|
||||||
if request.max_tokens is not None:
|
if request.max_tokens is not None:
|
||||||
kwargs['max_tokens'] = request.max_tokens
|
kwargs['max_tokens'] = request.max_tokens
|
||||||
|
|
||||||
# Extract system prompt if present
|
# Extract system prompt if present
|
||||||
system_prompt = getattr(request, 'system', None)
|
system_prompt = getattr(request, 'system', None)
|
||||||
if system_prompt:
|
if system_prompt:
|
||||||
kwargs['system'] = system_prompt
|
kwargs['system'] = system_prompt
|
||||||
|
|
||||||
|
# Pass context metadata to bot
|
||||||
|
channel_type = getattr(self, 'channel_type', None) or ''
|
||||||
|
if channel_type:
|
||||||
|
kwargs['channel_type'] = channel_type
|
||||||
|
session_id = getattr(self, 'session_id', None)
|
||||||
|
if session_id:
|
||||||
|
kwargs['session_id'] = session_id
|
||||||
|
|
||||||
|
# Thinking mode is a global toggle independent of the channel.
|
||||||
|
# IM channels (WeChat/WeCom/DingTalk/Feishu) won't render the
|
||||||
|
# reasoning trace, but still benefit from the higher answer
|
||||||
|
# quality the thinking pass produces.
|
||||||
|
from config import conf
|
||||||
|
thinking_enabled = bool(conf().get("enable_thinking", False))
|
||||||
|
kwargs['thinking'] = (
|
||||||
|
{"type": "enabled"} if thinking_enabled
|
||||||
|
else {"type": "disabled"}
|
||||||
|
)
|
||||||
|
# Reasoning effort is only meaningful when thinking is on.
|
||||||
|
# Bots that don't understand the kwarg drop it silently.
|
||||||
|
if thinking_enabled:
|
||||||
|
effort = conf().get("reasoning_effort", "high")
|
||||||
|
if effort in ("high", "max"):
|
||||||
|
kwargs['reasoning_effort'] = effort
|
||||||
|
|
||||||
response = self.bot.call_with_tools(**kwargs)
|
response = self.bot.call_with_tools(**kwargs)
|
||||||
return self._format_response(response)
|
return self._format_response(response)
|
||||||
else:
|
else:
|
||||||
@@ -189,10 +222,30 @@ class AgentLLMModel(LLMModel):
|
|||||||
if system_prompt:
|
if system_prompt:
|
||||||
kwargs['system'] = system_prompt
|
kwargs['system'] = system_prompt
|
||||||
|
|
||||||
# Pass channel_type for linkai tracking
|
# Pass context metadata to bot
|
||||||
channel_type = getattr(self, 'channel_type', None)
|
channel_type = getattr(self, 'channel_type', None) or ''
|
||||||
if channel_type:
|
if channel_type:
|
||||||
kwargs['channel_type'] = channel_type
|
kwargs['channel_type'] = channel_type
|
||||||
|
session_id = getattr(self, 'session_id', None)
|
||||||
|
if session_id:
|
||||||
|
kwargs['session_id'] = session_id
|
||||||
|
|
||||||
|
# Thinking mode is a global toggle independent of the channel.
|
||||||
|
# IM channels (WeChat/WeCom/DingTalk/Feishu) won't render the
|
||||||
|
# reasoning trace, but still benefit from the higher answer
|
||||||
|
# quality the thinking pass produces.
|
||||||
|
from config import conf
|
||||||
|
thinking_enabled = bool(conf().get("enable_thinking", False))
|
||||||
|
kwargs['thinking'] = (
|
||||||
|
{"type": "enabled"} if thinking_enabled
|
||||||
|
else {"type": "disabled"}
|
||||||
|
)
|
||||||
|
# Reasoning effort is only meaningful when thinking is on.
|
||||||
|
# Bots that don't understand the kwarg drop it silently.
|
||||||
|
if thinking_enabled:
|
||||||
|
effort = conf().get("reasoning_effort", "high")
|
||||||
|
if effort in ("high", "max"):
|
||||||
|
kwargs['reasoning_effort'] = effort
|
||||||
|
|
||||||
stream = self.bot.call_with_tools(**kwargs)
|
stream = self.bot.call_with_tools(**kwargs)
|
||||||
|
|
||||||
@@ -233,6 +286,23 @@ class AgentBridge:
|
|||||||
|
|
||||||
# Create helper instances
|
# Create helper instances
|
||||||
self.initializer = AgentInitializer(bridge, self)
|
self.initializer = AgentInitializer(bridge, self)
|
||||||
|
|
||||||
|
# Eager-start the scheduler so cron tasks fire without waiting
|
||||||
|
# for the first user message. init_scheduler is idempotent.
|
||||||
|
try:
|
||||||
|
from agent.tools.scheduler.integration import init_scheduler
|
||||||
|
if init_scheduler(self):
|
||||||
|
self.scheduler_initialized = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[AgentBridge] Eager scheduler init failed: {e}")
|
||||||
|
|
||||||
|
# Start the self-evolution idle trigger (idempotent, daemon thread).
|
||||||
|
try:
|
||||||
|
from agent.evolution.trigger import start_evolution_trigger
|
||||||
|
start_evolution_trigger(self)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[AgentBridge] Evolution trigger init failed: {e}")
|
||||||
|
|
||||||
def create_agent(self, system_prompt: str, tools: List = None, **kwargs) -> Agent:
|
def create_agent(self, system_prompt: str, tools: List = None, **kwargs) -> Agent:
|
||||||
"""
|
"""
|
||||||
Create the super agent with COW integration
|
Create the super agent with COW integration
|
||||||
@@ -256,10 +326,13 @@ class AgentBridge:
|
|||||||
tool_manager.load_tools()
|
tool_manager.load_tools()
|
||||||
|
|
||||||
tools = []
|
tools = []
|
||||||
|
workspace_dir = kwargs.get("workspace_dir")
|
||||||
for tool_name in tool_manager.tool_classes.keys():
|
for tool_name in tool_manager.tool_classes.keys():
|
||||||
try:
|
try:
|
||||||
tool = tool_manager.create_tool(tool_name)
|
tool = tool_manager.create_tool(tool_name)
|
||||||
if tool:
|
if tool:
|
||||||
|
if workspace_dir and hasattr(tool, 'cwd'):
|
||||||
|
tool.cwd = workspace_dir
|
||||||
tools.append(tool)
|
tools.append(tool)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[AgentBridge] Failed to load tool {tool_name}: {e}")
|
logger.warning(f"[AgentBridge] Failed to load tool {tool_name}: {e}")
|
||||||
@@ -272,12 +345,13 @@ class AgentBridge:
|
|||||||
tools=tools,
|
tools=tools,
|
||||||
max_steps=kwargs.get("max_steps", 15),
|
max_steps=kwargs.get("max_steps", 15),
|
||||||
output_mode=kwargs.get("output_mode", "logger"),
|
output_mode=kwargs.get("output_mode", "logger"),
|
||||||
workspace_dir=kwargs.get("workspace_dir"), # Pass workspace for skills loading
|
workspace_dir=kwargs.get("workspace_dir"),
|
||||||
enable_skills=kwargs.get("enable_skills", True), # Enable skills by default
|
skill_manager=kwargs.get("skill_manager"),
|
||||||
memory_manager=kwargs.get("memory_manager"), # Pass memory manager
|
enable_skills=kwargs.get("enable_skills", True),
|
||||||
|
memory_manager=kwargs.get("memory_manager"),
|
||||||
max_context_tokens=kwargs.get("max_context_tokens"),
|
max_context_tokens=kwargs.get("max_context_tokens"),
|
||||||
context_reserve_tokens=kwargs.get("context_reserve_tokens"),
|
context_reserve_tokens=kwargs.get("context_reserve_tokens"),
|
||||||
runtime_info=kwargs.get("runtime_info") # Pass runtime_info for dynamic time updates
|
runtime_info=kwargs.get("runtime_info"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log skill loading details
|
# Log skill loading details
|
||||||
@@ -317,7 +391,49 @@ class AgentBridge:
|
|||||||
"""Initialize agent for a specific session"""
|
"""Initialize agent for a specific session"""
|
||||||
agent = self.initializer.initialize_agent(session_id=session_id)
|
agent = self.initializer.initialize_agent(session_id=session_id)
|
||||||
self.agents[session_id] = agent
|
self.agents[session_id] = agent
|
||||||
|
|
||||||
|
def sync_session_messages_from_store(self, session_id: str) -> int:
|
||||||
|
"""Reload an agent's in-memory ``messages`` list from the persistent
|
||||||
|
conversation store.
|
||||||
|
|
||||||
|
Used after an external mutation (e.g. user edits / deletes a message
|
||||||
|
via the web console) so the agent's next turn sees the same history
|
||||||
|
as the database. The operation is a no-op when the agent has not been
|
||||||
|
instantiated yet for the session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of messages now held in the agent's memory. Returns -1 if
|
||||||
|
the agent does not exist or has no compatible ``messages`` attr.
|
||||||
|
"""
|
||||||
|
if not session_id or session_id not in self.agents:
|
||||||
|
return -1
|
||||||
|
agent = self.agents[session_id]
|
||||||
|
if not (hasattr(agent, "messages") and hasattr(agent, "messages_lock")):
|
||||||
|
return -1
|
||||||
|
try:
|
||||||
|
from agent.memory import get_conversation_store
|
||||||
|
store = get_conversation_store()
|
||||||
|
# No turn cap here: we want a faithful mirror of what the store
|
||||||
|
# has for this session after deletion.
|
||||||
|
remaining = store.load_messages(session_id, max_turns=10**6)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[AgentBridge] Failed to load messages for sync (session={session_id}): {e}"
|
||||||
|
)
|
||||||
|
return -1
|
||||||
|
with agent.messages_lock:
|
||||||
|
agent.messages.clear()
|
||||||
|
for msg in remaining:
|
||||||
|
agent.messages.append({
|
||||||
|
"role": msg["role"],
|
||||||
|
"content": msg["content"],
|
||||||
|
})
|
||||||
|
count = len(agent.messages)
|
||||||
|
logger.info(
|
||||||
|
f"[AgentBridge] Synced agent memory for session={session_id}, messages={count}"
|
||||||
|
)
|
||||||
|
return count
|
||||||
|
|
||||||
def agent_reply(self, query: str, context: Context = None,
|
def agent_reply(self, query: str, context: Context = None,
|
||||||
on_event=None, clear_history: bool = False) -> Reply:
|
on_event=None, clear_history: bool = False) -> Reply:
|
||||||
"""
|
"""
|
||||||
@@ -332,12 +448,24 @@ class AgentBridge:
|
|||||||
Returns:
|
Returns:
|
||||||
Reply object
|
Reply object
|
||||||
"""
|
"""
|
||||||
|
session_id = None
|
||||||
|
agent = None
|
||||||
|
request_id = None
|
||||||
|
cancel_event = None
|
||||||
try:
|
try:
|
||||||
# Extract session_id from context for user isolation
|
# Extract session_id from context for user isolation
|
||||||
session_id = None
|
|
||||||
if context:
|
if context:
|
||||||
session_id = context.kwargs.get("session_id") or context.get("session_id")
|
session_id = context.kwargs.get("session_id") or context.get("session_id")
|
||||||
|
request_id = context.kwargs.get("request_id") or context.get("request_id")
|
||||||
|
|
||||||
|
# Register a cancel token. Prefer per-turn request_id (web),
|
||||||
|
# fall back to session_id (IM channels). The Event is polled by
|
||||||
|
# AgentStreamExecutor at safe checkpoints.
|
||||||
|
registry = get_cancel_registry()
|
||||||
|
token_key = request_id or session_id
|
||||||
|
if token_key:
|
||||||
|
cancel_event = registry.register(token_key, session_id=session_id)
|
||||||
|
|
||||||
# Get agent for this session (will auto-initialize if needed)
|
# Get agent for this session (will auto-initialize if needed)
|
||||||
agent = self.get_agent(session_id=session_id)
|
agent = self.get_agent(session_id=session_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
@@ -367,22 +495,60 @@ class AgentBridge:
|
|||||||
logger.warning(f"[AgentBridge] Failed to attach context to scheduler: {e}")
|
logger.warning(f"[AgentBridge] Failed to attach context to scheduler: {e}")
|
||||||
break
|
break
|
||||||
|
|
||||||
# Pass channel_type to model so linkai requests carry it
|
# Pass context metadata to model for downstream API requests
|
||||||
if context and hasattr(agent, 'model'):
|
if context and hasattr(agent, 'model'):
|
||||||
agent.model.channel_type = context.get("channel_type", "")
|
agent.model.channel_type = context.get("channel_type", "")
|
||||||
|
agent.model.session_id = session_id or ""
|
||||||
|
|
||||||
# Record message count before execution so we can diff new messages
|
# Store session_id on agent so executor can clear DB on fatal errors
|
||||||
with agent.messages_lock:
|
agent._current_session_id = session_id
|
||||||
pre_run_len = len(agent.messages)
|
|
||||||
|
# Bound the in-memory context for scheduler sessions before each run.
|
||||||
|
# Scheduler sessions are stable per-task and append every trigger,
|
||||||
|
# so without trimming they would grow unbounded across runs and
|
||||||
|
# blow up prompt cost. Regular user chats are not touched here —
|
||||||
|
# the agent's own context manager handles that path.
|
||||||
|
if session_id and session_id.startswith("scheduler_"):
|
||||||
|
from config import conf
|
||||||
|
scheduler_keep_turns = max(
|
||||||
|
1, int(conf().get("agent_max_context_turns", 20)) // 5
|
||||||
|
)
|
||||||
|
self._trim_in_memory_to_turns(agent, scheduler_keep_turns)
|
||||||
|
|
||||||
|
# Eagerly persist the user message BEFORE running the agent so the
|
||||||
|
# session and the user's bubble are immediately visible — even if
|
||||||
|
# the user switches away or refreshes before the reply finishes.
|
||||||
|
# The reply (assistant/tool messages) is appended once the run
|
||||||
|
# completes; the final persist skips this already-stored user turn.
|
||||||
|
pre_persisted = self._pre_persist_user_message(
|
||||||
|
session_id, query, context, clear_history
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark this session as mid-run so the self-evolution idle scan does
|
||||||
|
# not fire concurrently when a single turn runs longer than
|
||||||
|
# idle_minutes.
|
||||||
|
try:
|
||||||
|
from agent.evolution.trigger import mark_run_active
|
||||||
|
mark_run_active(agent, True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use agent's run_stream method with event handler
|
# Use agent's run_stream method with event handler
|
||||||
response = agent.run_stream(
|
response = agent.run_stream(
|
||||||
user_message=query,
|
user_message=query,
|
||||||
on_event=event_handler.handle_event,
|
on_event=event_handler.handle_event,
|
||||||
clear_history=clear_history
|
clear_history=clear_history,
|
||||||
|
cancel_event=cancel_event,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
# Clear the mid-run flag so idle scans can review this session.
|
||||||
|
try:
|
||||||
|
from agent.evolution.trigger import mark_run_active
|
||||||
|
mark_run_active(agent, False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Restore original tools
|
# Restore original tools
|
||||||
if context and context.get("is_scheduled_task"):
|
if context and context.get("is_scheduled_task"):
|
||||||
agent.tools = original_tools
|
agent.tools = original_tools
|
||||||
@@ -390,14 +556,58 @@ class AgentBridge:
|
|||||||
# Log execution summary
|
# Log execution summary
|
||||||
event_handler.log_summary()
|
event_handler.log_summary()
|
||||||
|
|
||||||
|
# Release cancel token; keep registry bounded.
|
||||||
|
if token_key:
|
||||||
|
try:
|
||||||
|
registry.unregister(token_key)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Persist new messages generated during this run
|
# Persist new messages generated during this run
|
||||||
if session_id:
|
if session_id:
|
||||||
channel_type = (context.get("channel_type") or "") if context else ""
|
channel_type = (context.get("channel_type") or "") if context else ""
|
||||||
with agent.messages_lock:
|
new_messages = list(getattr(agent, '_last_run_new_messages', []))
|
||||||
new_messages = agent.messages[pre_run_len:]
|
# The leading user turn was already persisted eagerly above;
|
||||||
self._persist_messages(session_id, list(new_messages), channel_type)
|
# drop it here so it isn't stored twice.
|
||||||
|
if pre_persisted and new_messages and new_messages[0].get("role") == "user":
|
||||||
|
new_messages = new_messages[1:]
|
||||||
|
if new_messages:
|
||||||
|
self._persist_messages(session_id, list(new_messages), channel_type)
|
||||||
|
else:
|
||||||
|
with agent.messages_lock:
|
||||||
|
msg_count = len(agent.messages)
|
||||||
|
if msg_count == 0:
|
||||||
|
try:
|
||||||
|
from agent.memory import get_conversation_store
|
||||||
|
get_conversation_store().clear_session(session_id)
|
||||||
|
logger.info(f"[AgentBridge] Cleared DB for recovered session: {session_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[AgentBridge] Failed to clear DB after recovery: {e}")
|
||||||
|
|
||||||
# Check if there are files to send (from read tool)
|
# Record this user turn for the self-evolution idle trigger. Skip
|
||||||
|
# scheduler-injected / scheduled-task sessions so internal runs do
|
||||||
|
# not count as user activity.
|
||||||
|
if session_id and not session_id.startswith("scheduler_") and not (
|
||||||
|
context and context.get("is_scheduled_task")
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
from agent.evolution.trigger import note_user_turn
|
||||||
|
ch = (context.get("channel_type") or "") if context else ""
|
||||||
|
rcv = (context.get("receiver") or "") if context else ""
|
||||||
|
is_group = bool(context.get("isgroup")) if context else False
|
||||||
|
# Only enable proactive push for single chats (group push is
|
||||||
|
# noisy); group sessions still evolve, just without notify.
|
||||||
|
note_user_turn(agent, channel_type=ch, receiver=(rcv if not is_group else ""))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Post-message hot-reload: detect edits to ~/cow/mcp.json and
|
||||||
|
# sync any new/removed MCP tools into the live agent in the
|
||||||
|
# background. Off the critical path so user latency is unaffected;
|
||||||
|
# changes take effect on the user's next message.
|
||||||
|
self._schedule_mcp_hot_reload(agent)
|
||||||
|
|
||||||
|
# Check if there are files to send (from send/read tool)
|
||||||
if hasattr(agent, 'stream_executor') and hasattr(agent.stream_executor, 'files_to_send'):
|
if hasattr(agent, 'stream_executor') and hasattr(agent.stream_executor, 'files_to_send'):
|
||||||
files_to_send = agent.stream_executor.files_to_send
|
files_to_send = agent.stream_executor.files_to_send
|
||||||
if files_to_send:
|
if files_to_send:
|
||||||
@@ -415,8 +625,51 @@ class AgentBridge:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Agent reply error: {e}")
|
logger.error(f"Agent reply error: {e}")
|
||||||
|
# If the agent cleared its messages due to format error / overflow,
|
||||||
|
# also purge the DB so the next request starts clean.
|
||||||
|
if session_id and agent:
|
||||||
|
try:
|
||||||
|
with agent.messages_lock:
|
||||||
|
msg_count = len(agent.messages)
|
||||||
|
if msg_count == 0:
|
||||||
|
from agent.memory import get_conversation_store
|
||||||
|
get_conversation_store().clear_session(session_id)
|
||||||
|
logger.info(f"[AgentBridge] Cleared DB for session after error: {session_id}")
|
||||||
|
except Exception as db_err:
|
||||||
|
logger.warning(f"[AgentBridge] Failed to clear DB after error: {db_err}")
|
||||||
|
# Release cancel token on error path too (idempotent).
|
||||||
|
if cancel_event is not None and (request_id or session_id):
|
||||||
|
try:
|
||||||
|
get_cancel_registry().unregister(request_id or session_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return Reply(ReplyType.ERROR, f"Agent error: {str(e)}")
|
return Reply(ReplyType.ERROR, f"Agent error: {str(e)}")
|
||||||
|
|
||||||
|
def _schedule_mcp_hot_reload(self, agent):
|
||||||
|
"""
|
||||||
|
Fire-and-forget: detect mcp.json edits and reconcile the agent's
|
||||||
|
tool dict in the background. Runs after the user's reply is sent,
|
||||||
|
so any cost (file stat, hash, server boot) never adds to user latency.
|
||||||
|
Failures are isolated and never raise into the message pipeline.
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
from agent.tools import ToolManager
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
tm = ToolManager()
|
||||||
|
tm.refresh_mcp_if_changed()
|
||||||
|
added, removed = tm.sync_mcp_into_agent(agent)
|
||||||
|
if added or removed:
|
||||||
|
logger.info(
|
||||||
|
f"[AgentBridge] Agent tools synced — "
|
||||||
|
f"added={added}, removed={removed}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[AgentBridge] MCP hot-reload failed (non-fatal): {e}")
|
||||||
|
|
||||||
|
threading.Thread(target=_run, daemon=True, name="mcp-hot-reload").start()
|
||||||
|
|
||||||
def _create_file_reply(self, file_info: dict, text_response: str, context: Context = None) -> Reply:
|
def _create_file_reply(self, file_info: dict, text_response: str, context: Context = None) -> Reply:
|
||||||
"""
|
"""
|
||||||
Create a reply for sending files
|
Create a reply for sending files
|
||||||
@@ -431,11 +684,21 @@ class AgentBridge:
|
|||||||
"""
|
"""
|
||||||
file_type = file_info.get("file_type", "file")
|
file_type = file_info.get("file_type", "file")
|
||||||
file_path = file_info.get("path")
|
file_path = file_info.get("path")
|
||||||
|
# Remote URLs are passed through as-is; local paths get a file:// prefix
|
||||||
|
# so the channel can read them from disk.
|
||||||
|
remote_url = file_info.get("url", "")
|
||||||
|
is_remote = bool(remote_url) and remote_url.lower().startswith(("http://", "https://"))
|
||||||
|
|
||||||
|
def _to_channel_url(p: str) -> str:
|
||||||
|
if is_remote:
|
||||||
|
return remote_url
|
||||||
|
if p and p.lower().startswith(("http://", "https://")):
|
||||||
|
return p
|
||||||
|
return f"file://{p}"
|
||||||
|
|
||||||
# For images, use IMAGE_URL type (channel will handle upload)
|
# For images, use IMAGE_URL type (channel will handle upload)
|
||||||
if file_type == "image":
|
if file_type == "image":
|
||||||
# Convert local path to file:// URL for channel processing
|
file_url = _to_channel_url(file_path)
|
||||||
file_url = f"file://{file_path}"
|
|
||||||
logger.info(f"[AgentBridge] Sending image: {file_url}")
|
logger.info(f"[AgentBridge] Sending image: {file_url}")
|
||||||
reply = Reply(ReplyType.IMAGE_URL, file_url)
|
reply = Reply(ReplyType.IMAGE_URL, file_url)
|
||||||
# Attach text message if present (for channels that support text+image)
|
# Attach text message if present (for channels that support text+image)
|
||||||
@@ -445,7 +708,7 @@ class AgentBridge:
|
|||||||
|
|
||||||
# For all file types (document, video, audio), use FILE type
|
# For all file types (document, video, audio), use FILE type
|
||||||
if file_type in ["document", "video", "audio"]:
|
if file_type in ["document", "video", "audio"]:
|
||||||
file_url = f"file://{file_path}"
|
file_url = _to_channel_url(file_path)
|
||||||
logger.info(f"[AgentBridge] Sending {file_type}: {file_url}")
|
logger.info(f"[AgentBridge] Sending {file_type}: {file_url}")
|
||||||
reply = Reply(ReplyType.FILE, file_url)
|
reply = Reply(ReplyType.FILE, file_url)
|
||||||
reply.file_name = file_info.get("file_name", os.path.basename(file_path))
|
reply.file_name = file_info.get("file_name", os.path.basename(file_path))
|
||||||
@@ -454,22 +717,26 @@ class AgentBridge:
|
|||||||
reply.text_content = text_response
|
reply.text_content = text_response
|
||||||
return reply
|
return reply
|
||||||
|
|
||||||
# For other unknown file types, return text with file info
|
# For all other file types (tar.gz, zip, etc.), also use FILE type
|
||||||
message = text_response or file_info.get("message", "文件已准备")
|
file_url = _to_channel_url(file_path)
|
||||||
message += f"\n\n[文件: {file_info.get('file_name', file_path)}]"
|
logger.info(f"[AgentBridge] Sending generic file: {file_url}")
|
||||||
return Reply(ReplyType.TEXT, message)
|
reply = Reply(ReplyType.FILE, file_url)
|
||||||
|
reply.file_name = file_info.get("file_name", os.path.basename(file_path))
|
||||||
|
if text_response:
|
||||||
|
reply.text_content = text_response
|
||||||
|
return reply
|
||||||
|
|
||||||
def _migrate_config_to_env(self, workspace_root: str):
|
def _migrate_config_to_env(self, workspace_root: str):
|
||||||
"""
|
"""
|
||||||
Migrate API keys from config.json to .env file if not already set
|
Sync API keys from config.json to .env file.
|
||||||
|
Adds new keys and updates changed values on each startup.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
workspace_root: Workspace directory path (not used, kept for compatibility)
|
workspace_root: Workspace directory path (not used, kept for compatibility)
|
||||||
"""
|
"""
|
||||||
from config import conf
|
from config import conf
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Mapping from config.json keys to environment variable names
|
|
||||||
key_mapping = {
|
key_mapping = {
|
||||||
"open_ai_api_key": "OPENAI_API_KEY",
|
"open_ai_api_key": "OPENAI_API_KEY",
|
||||||
"open_ai_api_base": "OPENAI_API_BASE",
|
"open_ai_api_base": "OPENAI_API_BASE",
|
||||||
@@ -478,10 +745,9 @@ class AgentBridge:
|
|||||||
"linkai_api_key": "LINKAI_API_KEY",
|
"linkai_api_key": "LINKAI_API_KEY",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Use fixed secure location for .env file
|
|
||||||
env_file = expand_path("~/.cow/.env")
|
env_file = expand_path("~/.cow/.env")
|
||||||
|
|
||||||
# Read existing env vars from .env file
|
# Read existing env vars (key -> value)
|
||||||
existing_env_vars = {}
|
existing_env_vars = {}
|
||||||
if os.path.exists(env_file):
|
if os.path.exists(env_file):
|
||||||
try:
|
try:
|
||||||
@@ -489,49 +755,89 @@ class AgentBridge:
|
|||||||
for line in f:
|
for line in f:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line and not line.startswith('#') and '=' in line:
|
if line and not line.startswith('#') and '=' in line:
|
||||||
key, _ = line.split('=', 1)
|
key, val = line.split('=', 1)
|
||||||
existing_env_vars[key.strip()] = True
|
existing_env_vars[key.strip()] = val.strip()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[AgentBridge] Failed to read .env file: {e}")
|
logger.warning(f"[AgentBridge] Failed to read .env file: {e}")
|
||||||
|
|
||||||
# Check which keys need to be migrated
|
# Sync config.json values into .env (add/update/remove)
|
||||||
keys_to_migrate = {}
|
updated = False
|
||||||
for config_key, env_key in key_mapping.items():
|
for config_key, env_key in key_mapping.items():
|
||||||
# Skip if already in .env file
|
raw = conf().get(config_key, "")
|
||||||
if env_key in existing_env_vars:
|
value = raw.strip() if raw else ""
|
||||||
continue
|
old_value = existing_env_vars.get(env_key)
|
||||||
|
|
||||||
# Get value from config.json
|
if value:
|
||||||
value = conf().get(config_key, "")
|
if old_value == value:
|
||||||
if value and value.strip(): # Only migrate non-empty values
|
continue
|
||||||
keys_to_migrate[env_key] = value.strip()
|
existing_env_vars[env_key] = value
|
||||||
|
os.environ[env_key] = value
|
||||||
# Log summary if there are keys to skip
|
updated = True
|
||||||
if existing_env_vars:
|
else:
|
||||||
logger.debug(f"[AgentBridge] {len(existing_env_vars)} env vars already in .env")
|
if old_value is None:
|
||||||
|
continue
|
||||||
# Write new keys to .env file
|
existing_env_vars.pop(env_key, None)
|
||||||
if keys_to_migrate:
|
os.environ.pop(env_key, None)
|
||||||
|
updated = True
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if updated:
|
||||||
try:
|
try:
|
||||||
# Ensure ~/.cow directory and .env file exist
|
|
||||||
env_dir = os.path.dirname(env_file)
|
env_dir = os.path.dirname(env_file)
|
||||||
if not os.path.exists(env_dir):
|
os.makedirs(env_dir, exist_ok=True)
|
||||||
os.makedirs(env_dir, exist_ok=True)
|
|
||||||
if not os.path.exists(env_file):
|
with open(env_file, 'w', encoding='utf-8') as f:
|
||||||
open(env_file, 'a').close()
|
f.write('# Environment variables for agent\n')
|
||||||
|
f.write('# Auto-managed - synced from config.json on startup\n\n')
|
||||||
# Append new keys
|
for key, value in sorted(existing_env_vars.items()):
|
||||||
with open(env_file, 'a', encoding='utf-8') as f:
|
|
||||||
f.write('\n# Auto-migrated from config.json\n')
|
|
||||||
for key, value in keys_to_migrate.items():
|
|
||||||
f.write(f'{key}={value}\n')
|
f.write(f'{key}={value}\n')
|
||||||
# Also set in current process
|
|
||||||
os.environ[key] = value
|
logger.info(f"[AgentBridge] Synced API keys from config.json to .env")
|
||||||
|
|
||||||
logger.info(f"[AgentBridge] Migrated {len(keys_to_migrate)} API keys from config.json to .env: {list(keys_to_migrate.keys())}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[AgentBridge] Failed to migrate API keys: {e}")
|
logger.warning(f"[AgentBridge] Failed to sync API keys: {e}")
|
||||||
|
|
||||||
|
def _pre_persist_user_message(
|
||||||
|
self, session_id: str, query: str, context: Context, clear_history: bool
|
||||||
|
) -> bool:
|
||||||
|
"""Persist the user's message before the agent runs.
|
||||||
|
|
||||||
|
This makes a brand-new session (and the user's bubble) visible even if
|
||||||
|
the reply hasn't finished — switching away or refreshing no longer
|
||||||
|
loses the in-flight session. Returns True when the user turn was
|
||||||
|
stored, so the caller can skip it in the post-run persist.
|
||||||
|
|
||||||
|
Best-effort: any failure is swallowed and reported as not-persisted.
|
||||||
|
"""
|
||||||
|
if not session_id or not query:
|
||||||
|
return False
|
||||||
|
# Only real user turns: skip scheduler-injected / scheduled-task runs.
|
||||||
|
if session_id.startswith("scheduler_") or (
|
||||||
|
context and context.get("is_scheduled_task")
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
from config import conf
|
||||||
|
if not conf().get("conversation_persistence", True):
|
||||||
|
return False
|
||||||
|
from agent.memory import get_conversation_store
|
||||||
|
store = get_conversation_store()
|
||||||
|
# clear_history starts a fresh transcript: wipe the store first so
|
||||||
|
# the eager user turn becomes seq 0, matching in-memory state.
|
||||||
|
if clear_history:
|
||||||
|
store.clear_session(session_id)
|
||||||
|
channel_type = (context.get("channel_type") or "") if context else ""
|
||||||
|
user_msg = {
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": query}],
|
||||||
|
}
|
||||||
|
store.append_messages(session_id, [user_msg], channel_type=channel_type)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[AgentBridge] Failed to pre-persist user message for session={session_id}: {e}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
def _persist_messages(
|
def _persist_messages(
|
||||||
self, session_id: str, new_messages: list, channel_type: str = ""
|
self, session_id: str, new_messages: list, channel_type: str = ""
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -546,18 +852,245 @@ class AgentBridge:
|
|||||||
from config import conf
|
from config import conf
|
||||||
if not conf().get("conversation_persistence", True):
|
if not conf().get("conversation_persistence", True):
|
||||||
return
|
return
|
||||||
|
# When deep-thinking display is disabled, strip "thinking" content
|
||||||
|
# blocks before persisting so they don't resurface on history reload.
|
||||||
|
# The in-memory message list keeps them intact for this run's
|
||||||
|
# multi-turn LLM context.
|
||||||
|
thinking_enabled = bool(conf().get("enable_thinking", False))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
thinking_enabled = False
|
||||||
|
|
||||||
|
messages_to_store = new_messages
|
||||||
|
if not thinking_enabled:
|
||||||
|
messages_to_store = self._strip_thinking_blocks(new_messages)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from agent.memory import get_conversation_store
|
from agent.memory import get_conversation_store
|
||||||
get_conversation_store().append_messages(
|
get_conversation_store().append_messages(
|
||||||
session_id, new_messages, channel_type=channel_type
|
session_id, messages_to_store, channel_type=channel_type
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[AgentBridge] Failed to persist messages for session={session_id}: {e}"
|
f"[AgentBridge] Failed to persist messages for session={session_id}: {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Marker used to identify scheduler-injected user messages so we can apply
|
||||||
|
# a sliding window without touching real user turns. The legacy prefix
|
||||||
|
# "Scheduled task" (written by the v2 PR) is also recognised when pruning,
|
||||||
|
# so old data can be aged out instead of leaking forever.
|
||||||
|
_SCHEDULED_MARKER = "[SCHEDULED]"
|
||||||
|
_SCHEDULED_LEGACY_MARKERS = ("Scheduled task",)
|
||||||
|
|
||||||
|
def remember_scheduled_output(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
content: str,
|
||||||
|
channel_type: str = "",
|
||||||
|
task_description: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Add the visible output of a scheduled task to the receiver's session.
|
||||||
|
|
||||||
|
Scheduled task execution uses an isolated session so internal planning and
|
||||||
|
tool calls do not leak into the user's chat. The final message is still
|
||||||
|
part of the conversation from the user's point of view, so keep a small
|
||||||
|
visible turn in the receiver session for follow-up questions.
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
scheduler_inject_to_session (bool, default True):
|
||||||
|
Master switch. When False, this method is a no-op.
|
||||||
|
scheduler_inject_max_per_session (int, default 3):
|
||||||
|
Maximum scheduler-injected user/assistant pairs retained per
|
||||||
|
session. Older injections are pruned automatically.
|
||||||
|
|
||||||
|
Content is truncated to 2000 chars to prevent a single high-volume task
|
||||||
|
from bloating one entry.
|
||||||
|
"""
|
||||||
|
from config import conf
|
||||||
|
if not conf().get("scheduler_inject_to_session", True):
|
||||||
|
return
|
||||||
|
if not session_id or not content:
|
||||||
|
return
|
||||||
|
|
||||||
|
max_len = 2000
|
||||||
|
if len(content) > max_len:
|
||||||
|
content = content[:max_len] + "..."
|
||||||
|
|
||||||
|
user_text = self._SCHEDULED_MARKER
|
||||||
|
if task_description:
|
||||||
|
user_text = f"{self._SCHEDULED_MARKER} {task_description}"
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "user", "content": [{"type": "text", "text": user_text}]},
|
||||||
|
{"role": "assistant", "content": [{"type": "text", "text": content}]},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Persist first so the new pair gets a stable seq, then prune old
|
||||||
|
# scheduler pairs in DB, then sync the in-memory agent.messages buffer.
|
||||||
|
self._persist_messages(session_id, messages, channel_type)
|
||||||
|
|
||||||
|
keep_last_n = max(int(conf().get("scheduler_inject_max_per_session", 3) or 0), 0)
|
||||||
|
try:
|
||||||
|
from agent.memory import get_conversation_store
|
||||||
|
deleted = get_conversation_store().prune_scheduled_messages(
|
||||||
|
session_id, keep_last_n=keep_last_n
|
||||||
|
)
|
||||||
|
if deleted:
|
||||||
|
logger.debug(
|
||||||
|
f"[AgentBridge] Pruned {deleted} old scheduler messages "
|
||||||
|
f"for session={session_id} (keep_last_n={keep_last_n})"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[AgentBridge] Failed to prune scheduled messages "
|
||||||
|
f"for session={session_id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
agent = self.agents.get(session_id)
|
||||||
|
if agent:
|
||||||
|
try:
|
||||||
|
with agent.messages_lock:
|
||||||
|
agent.messages.extend(messages)
|
||||||
|
self._prune_scheduled_in_memory(agent, keep_last_n)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[AgentBridge] Failed to update in-memory scheduled output "
|
||||||
|
f"for session={session_id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _trim_in_memory_to_turns(agent, keep_turns: int) -> None:
|
||||||
|
"""Bound ``agent.messages`` to the most recent ``keep_turns`` real
|
||||||
|
user/assistant turns, dropping older history together with any
|
||||||
|
intermediate tool_use/tool_result blocks that belonged to it.
|
||||||
|
|
||||||
|
A "real" user message is any user message whose content is not solely a
|
||||||
|
tool_result block — matches the heuristic used elsewhere when filtering
|
||||||
|
history (see ``AgentInitializer._filter_text_only_messages``).
|
||||||
|
|
||||||
|
No-op when the session is already within budget. Caller does not need
|
||||||
|
to hold the lock; this method acquires it itself.
|
||||||
|
"""
|
||||||
|
if keep_turns <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _is_real_user(msg) -> bool:
|
||||||
|
if not isinstance(msg, dict) or msg.get("role") != "user":
|
||||||
|
return False
|
||||||
|
content = msg.get("content")
|
||||||
|
if isinstance(content, list):
|
||||||
|
if any(
|
||||||
|
isinstance(b, dict) and b.get("type") == "tool_result"
|
||||||
|
for b in content
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
isinstance(b, dict) and b.get("type") == "text" and b.get("text")
|
||||||
|
for b in content
|
||||||
|
)
|
||||||
|
if isinstance(content, str):
|
||||||
|
return bool(content.strip())
|
||||||
|
return False
|
||||||
|
|
||||||
|
with agent.messages_lock:
|
||||||
|
msgs = agent.messages
|
||||||
|
real_user_indices = [i for i, m in enumerate(msgs) if _is_real_user(m)]
|
||||||
|
if len(real_user_indices) <= keep_turns:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Cut at the (k-th from the end) real user message; keep everything
|
||||||
|
# from there onwards so the surviving slice is still a valid
|
||||||
|
# user/assistant sequence.
|
||||||
|
cut_idx = real_user_indices[-keep_turns]
|
||||||
|
if cut_idx == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
kept = msgs[cut_idx:]
|
||||||
|
msgs.clear()
|
||||||
|
msgs.extend(kept)
|
||||||
|
logger.debug(
|
||||||
|
f"[AgentBridge] Trimmed in-memory messages to last "
|
||||||
|
f"{keep_turns} turns ({len(kept)} messages remain)"
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _prune_scheduled_in_memory(cls, agent, keep_last_n: int) -> None:
|
||||||
|
"""Mirror conversation_store.prune_scheduled_messages on agent.messages.
|
||||||
|
|
||||||
|
Caller must hold ``agent.messages_lock``.
|
||||||
|
"""
|
||||||
|
if keep_last_n < 0:
|
||||||
|
keep_last_n = 0
|
||||||
|
|
||||||
|
markers = (cls._SCHEDULED_MARKER,) + cls._SCHEDULED_LEGACY_MARKERS
|
||||||
|
|
||||||
|
def _is_marker_user(msg) -> bool:
|
||||||
|
if not isinstance(msg, dict) or msg.get("role") != "user":
|
||||||
|
return False
|
||||||
|
content = msg.get("content")
|
||||||
|
text = ""
|
||||||
|
if isinstance(content, str):
|
||||||
|
text = content
|
||||||
|
elif isinstance(content, list):
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, dict) and block.get("type") == "text":
|
||||||
|
text = block.get("text", "")
|
||||||
|
break
|
||||||
|
return any(text.startswith(m) for m in markers)
|
||||||
|
|
||||||
|
msgs = agent.messages
|
||||||
|
pair_indices = [] # list of (user_idx, assistant_idx_or_None)
|
||||||
|
for idx, msg in enumerate(msgs):
|
||||||
|
if not _is_marker_user(msg):
|
||||||
|
continue
|
||||||
|
assistant_idx = None
|
||||||
|
if idx + 1 < len(msgs):
|
||||||
|
nxt = msgs[idx + 1]
|
||||||
|
if isinstance(nxt, dict) and nxt.get("role") == "assistant":
|
||||||
|
assistant_idx = idx + 1
|
||||||
|
pair_indices.append((idx, assistant_idx))
|
||||||
|
|
||||||
|
if len(pair_indices) <= keep_last_n:
|
||||||
|
return
|
||||||
|
|
||||||
|
to_drop = pair_indices[: len(pair_indices) - keep_last_n]
|
||||||
|
drop_set = set()
|
||||||
|
for u_idx, a_idx in to_drop:
|
||||||
|
drop_set.add(u_idx)
|
||||||
|
if a_idx is not None:
|
||||||
|
drop_set.add(a_idx)
|
||||||
|
|
||||||
|
# Rebuild the list in place to keep external references stable.
|
||||||
|
kept = [m for i, m in enumerate(msgs) if i not in drop_set]
|
||||||
|
msgs.clear()
|
||||||
|
msgs.extend(kept)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _strip_thinking_blocks(messages: list) -> list:
|
||||||
|
"""Return a shallow copy of messages with assistant "thinking" blocks removed."""
|
||||||
|
cleaned = []
|
||||||
|
for msg in messages:
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
cleaned.append(msg)
|
||||||
|
continue
|
||||||
|
if msg.get("role") != "assistant":
|
||||||
|
cleaned.append(msg)
|
||||||
|
continue
|
||||||
|
content = msg.get("content")
|
||||||
|
if not isinstance(content, list):
|
||||||
|
cleaned.append(msg)
|
||||||
|
continue
|
||||||
|
filtered_blocks = [
|
||||||
|
b for b in content
|
||||||
|
if not (isinstance(b, dict) and b.get("type") == "thinking")
|
||||||
|
]
|
||||||
|
if len(filtered_blocks) == len(content):
|
||||||
|
cleaned.append(msg)
|
||||||
|
else:
|
||||||
|
new_msg = dict(msg)
|
||||||
|
new_msg["content"] = filtered_blocks
|
||||||
|
cleaned.append(new_msg)
|
||||||
|
return cleaned
|
||||||
|
|
||||||
def clear_session(self, session_id: str):
|
def clear_session(self, session_id: str):
|
||||||
"""
|
"""
|
||||||
Clear a specific session's agent and conversation history
|
Clear a specific session's agent and conversation history
|
||||||
@@ -643,4 +1176,4 @@ class AgentBridge:
|
|||||||
agent.tools = [t for t in agent.tools if t.name != "web_search"]
|
agent.tools = [t for t in agent.tools if t.name != "web_search"]
|
||||||
logger.info("[AgentBridge] web_search tool removed (API key no longer available)")
|
logger.info("[AgentBridge] web_search tool removed (API key no longer available)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"[AgentBridge] Failed to refresh conditional tools: {e}")
|
logger.debug(f"[AgentBridge] Failed to refresh conditional tools: {e}")
|
||||||
|
|||||||
@@ -2,114 +2,124 @@
|
|||||||
Agent Event Handler - Handles agent events and thinking process output
|
Agent Event Handler - Handles agent events and thinking process output
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from common import const
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
|
|
||||||
|
# Cap intermediate thinking messages on weixin to stay within send quota.
|
||||||
|
WEIXIN_THINKING_INSTANT_MAX = 7
|
||||||
|
|
||||||
|
|
||||||
class AgentEventHandler:
|
class AgentEventHandler:
|
||||||
"""
|
"""
|
||||||
Handles agent events and optionally sends intermediate messages to channel
|
Handles agent events and optionally sends intermediate messages to channel
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, context=None, original_callback=None):
|
def __init__(self, context=None, original_callback=None):
|
||||||
"""
|
|
||||||
Initialize event handler
|
|
||||||
|
|
||||||
Args:
|
|
||||||
context: COW context (for accessing channel)
|
|
||||||
original_callback: Original event callback to chain
|
|
||||||
"""
|
|
||||||
self.context = context
|
self.context = context
|
||||||
self.original_callback = original_callback
|
self.original_callback = original_callback
|
||||||
|
|
||||||
# Get channel for sending intermediate messages
|
|
||||||
self.channel = None
|
self.channel = None
|
||||||
if context:
|
if context:
|
||||||
self.channel = context.kwargs.get("channel") if hasattr(context, "kwargs") else None
|
self.channel = context.kwargs.get("channel") if hasattr(context, "kwargs") else None
|
||||||
|
|
||||||
# Track current thinking for channel output
|
self.current_content = ""
|
||||||
self.current_thinking = ""
|
|
||||||
self.turn_number = 0
|
self.turn_number = 0
|
||||||
|
|
||||||
|
channel_type = ""
|
||||||
|
if context and hasattr(context, "kwargs"):
|
||||||
|
channel_type = context.kwargs.get("channel_type", "") or ""
|
||||||
|
self._is_weixin = channel_type == const.WEIXIN
|
||||||
|
self._thinking_sent_count = 0
|
||||||
|
self._merged_buf: list[str] = []
|
||||||
|
|
||||||
def handle_event(self, event):
|
def handle_event(self, event):
|
||||||
"""
|
|
||||||
Main event handler
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: Event dict with type and data
|
|
||||||
"""
|
|
||||||
event_type = event.get("type")
|
event_type = event.get("type")
|
||||||
data = event.get("data", {})
|
data = event.get("data", {})
|
||||||
|
|
||||||
# Dispatch to specific handlers
|
|
||||||
if event_type == "turn_start":
|
if event_type == "turn_start":
|
||||||
self._handle_turn_start(data)
|
self._handle_turn_start(data)
|
||||||
elif event_type == "message_update":
|
elif event_type == "message_update":
|
||||||
self._handle_message_update(data)
|
self._handle_message_update(data)
|
||||||
elif event_type == "message_end":
|
elif event_type == "message_end":
|
||||||
self._handle_message_end(data)
|
self._handle_message_end(data)
|
||||||
|
elif event_type == "reasoning_update":
|
||||||
|
pass
|
||||||
elif event_type == "tool_execution_start":
|
elif event_type == "tool_execution_start":
|
||||||
self._handle_tool_execution_start(data)
|
self._handle_tool_execution_start(data)
|
||||||
elif event_type == "tool_execution_end":
|
elif event_type == "tool_execution_end":
|
||||||
self._handle_tool_execution_end(data)
|
self._handle_tool_execution_end(data)
|
||||||
|
elif event_type == "agent_end":
|
||||||
# Call original callback if provided
|
self._handle_agent_end(data)
|
||||||
|
|
||||||
if self.original_callback:
|
if self.original_callback:
|
||||||
self.original_callback(event)
|
self.original_callback(event)
|
||||||
|
|
||||||
def _handle_turn_start(self, data):
|
def _handle_turn_start(self, data):
|
||||||
"""Handle turn start event"""
|
|
||||||
self.turn_number = data.get("turn", 0)
|
self.turn_number = data.get("turn", 0)
|
||||||
self.has_tool_calls_in_turn = False
|
self.current_content = ""
|
||||||
self.current_thinking = ""
|
|
||||||
|
|
||||||
def _handle_message_update(self, data):
|
def _handle_message_update(self, data):
|
||||||
"""Handle message update event (streaming text)"""
|
|
||||||
delta = data.get("delta", "")
|
delta = data.get("delta", "")
|
||||||
self.current_thinking += delta
|
self.current_content += delta
|
||||||
|
|
||||||
def _handle_message_end(self, data):
|
def _handle_message_end(self, data):
|
||||||
"""Handle message end event"""
|
|
||||||
tool_calls = data.get("tool_calls", [])
|
tool_calls = data.get("tool_calls", [])
|
||||||
|
|
||||||
# Only send thinking process if followed by tool calls
|
|
||||||
if tool_calls:
|
if tool_calls:
|
||||||
if self.current_thinking.strip():
|
if self.current_content.strip():
|
||||||
logger.info(f"💭 {self.current_thinking.strip()[:200]}{'...' if len(self.current_thinking) > 200 else ''}")
|
logger.info(f"💭 {self.current_content.strip()[:200]}{'...' if len(self.current_content) > 200 else ''}")
|
||||||
# Send thinking process to channel
|
self._send_to_channel(self.current_content.strip())
|
||||||
self._send_to_channel(f"{self.current_thinking.strip()}")
|
|
||||||
else:
|
else:
|
||||||
# No tool calls = final response (logged at agent_stream level)
|
if self.current_content.strip():
|
||||||
if self.current_thinking.strip():
|
logger.debug(f"💬 {self.current_content.strip()[:200]}{'...' if len(self.current_content) > 200 else ''}")
|
||||||
logger.debug(f"💬 {self.current_thinking.strip()[:200]}{'...' if len(self.current_thinking) > 200 else ''}")
|
# Drain weixin buffer before final reply leaves chat_channel
|
||||||
|
self._flush_merged_now()
|
||||||
self.current_thinking = ""
|
|
||||||
|
self.current_content = ""
|
||||||
|
|
||||||
|
def _handle_agent_end(self, data):
|
||||||
|
self._flush_merged_now()
|
||||||
|
|
||||||
def _handle_tool_execution_start(self, data):
|
def _handle_tool_execution_start(self, data):
|
||||||
"""Handle tool execution start event - logged by agent_stream.py"""
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _handle_tool_execution_end(self, data):
|
def _handle_tool_execution_end(self, data):
|
||||||
"""Handle tool execution end event - logged by agent_stream.py"""
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _send_to_channel(self, message):
|
def _send_to_channel(self, message):
|
||||||
"""
|
|
||||||
Try to send intermediate message to channel.
|
|
||||||
Skipped in SSE mode because thinking text is already streamed via on_event.
|
|
||||||
"""
|
|
||||||
if self.context and self.context.get("on_event"):
|
if self.context and self.context.get("on_event"):
|
||||||
return
|
return
|
||||||
|
if not self.channel:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._is_weixin:
|
||||||
|
self._do_send(message)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._thinking_sent_count < WEIXIN_THINKING_INSTANT_MAX:
|
||||||
|
self._do_send(message)
|
||||||
|
self._thinking_sent_count += 1
|
||||||
|
return
|
||||||
|
|
||||||
|
self._merged_buf.append(message)
|
||||||
|
|
||||||
|
def _flush_merged_now(self):
|
||||||
|
if not self._merged_buf:
|
||||||
|
return
|
||||||
|
merged = "\n\n".join(self._merged_buf)
|
||||||
|
count = len(self._merged_buf)
|
||||||
|
self._merged_buf = []
|
||||||
|
logger.debug(f"[AgentEventHandler] Flushing {count} merged thinking msgs, len={len(merged)}")
|
||||||
|
self._do_send(merged)
|
||||||
|
self._thinking_sent_count += 1
|
||||||
|
|
||||||
|
def _do_send(self, message):
|
||||||
|
try:
|
||||||
|
from bridge.reply import Reply, ReplyType
|
||||||
|
reply = Reply(ReplyType.TEXT, message)
|
||||||
|
self.channel._send(reply, self.context)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[AgentEventHandler] Failed to send to channel: {e}")
|
||||||
|
|
||||||
if self.channel:
|
|
||||||
try:
|
|
||||||
from bridge.reply import Reply, ReplyType
|
|
||||||
reply = Reply(ReplyType.TEXT, message)
|
|
||||||
self.channel._send(reply, self.context)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"[AgentEventHandler] Failed to send to channel: {e}")
|
|
||||||
|
|
||||||
def log_summary(self):
|
def log_summary(self):
|
||||||
"""Log execution summary - simplified"""
|
|
||||||
# Summary removed as per user request
|
|
||||||
# Real-time logging during execution is sufficient
|
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Agent Initializer - Handles agent initialization logic
|
|||||||
import os
|
import os
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
@@ -13,6 +14,9 @@ from agent.tools import ToolManager
|
|||||||
from common.log import logger
|
from common.log import logger
|
||||||
from common.utils import expand_path
|
from common.utils import expand_path
|
||||||
|
|
||||||
|
# Module-level lock to serialize scheduler init across concurrent sessions
|
||||||
|
_scheduler_init_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
class AgentInitializer:
|
class AgentInitializer:
|
||||||
"""
|
"""
|
||||||
@@ -77,10 +81,6 @@ class AgentInitializer:
|
|||||||
# Initialize skill manager
|
# Initialize skill manager
|
||||||
skill_manager = self._initialize_skill_manager(workspace_root, session_id)
|
skill_manager = self._initialize_skill_manager(workspace_root, session_id)
|
||||||
|
|
||||||
# Check if first conversation
|
|
||||||
from agent.prompt.workspace import is_first_conversation, mark_conversation_started
|
|
||||||
is_first = is_first_conversation(workspace_root)
|
|
||||||
|
|
||||||
# Build system prompt
|
# Build system prompt
|
||||||
prompt_builder = PromptBuilder(workspace_dir=workspace_root, language="zh")
|
prompt_builder = PromptBuilder(workspace_dir=workspace_root, language="zh")
|
||||||
runtime_info = self._get_runtime_info(workspace_root)
|
runtime_info = self._get_runtime_info(workspace_root)
|
||||||
@@ -91,12 +91,8 @@ class AgentInitializer:
|
|||||||
skill_manager=skill_manager,
|
skill_manager=skill_manager,
|
||||||
memory_manager=memory_manager,
|
memory_manager=memory_manager,
|
||||||
runtime_info=runtime_info,
|
runtime_info=runtime_info,
|
||||||
is_first_conversation=is_first
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if is_first:
|
|
||||||
mark_conversation_started(workspace_root)
|
|
||||||
|
|
||||||
# Get cost control parameters
|
# Get cost control parameters
|
||||||
from config import conf
|
from config import conf
|
||||||
max_steps = conf().get("agent_max_steps", 20)
|
max_steps = conf().get("agent_max_steps", 20)
|
||||||
@@ -115,14 +111,19 @@ class AgentInitializer:
|
|||||||
runtime_info=runtime_info # Pass runtime_info for dynamic time updates
|
runtime_info=runtime_info # Pass runtime_info for dynamic time updates
|
||||||
)
|
)
|
||||||
|
|
||||||
# Attach memory manager
|
# Attach memory manager and share LLM model for summarization
|
||||||
if memory_manager:
|
if memory_manager:
|
||||||
agent.memory_manager = memory_manager
|
agent.memory_manager = memory_manager
|
||||||
|
if hasattr(agent, 'model') and agent.model:
|
||||||
|
memory_manager.flush_manager.llm_model = agent.model
|
||||||
|
|
||||||
# Restore persisted conversation history for this session
|
# Restore persisted conversation history for this session
|
||||||
if session_id:
|
if session_id:
|
||||||
self._restore_conversation_history(agent, session_id)
|
self._restore_conversation_history(agent, session_id)
|
||||||
|
|
||||||
|
# Start daily memory flush timer (once, on first agent init regardless of session)
|
||||||
|
self._start_daily_flush_timer()
|
||||||
|
|
||||||
return agent
|
return agent
|
||||||
|
|
||||||
def _restore_conversation_history(self, agent, session_id: str) -> None:
|
def _restore_conversation_history(self, agent, session_id: str) -> None:
|
||||||
@@ -130,8 +131,14 @@ class AgentInitializer:
|
|||||||
Load persisted conversation messages from SQLite and inject them
|
Load persisted conversation messages from SQLite and inject them
|
||||||
into the agent's in-memory message list.
|
into the agent's in-memory message list.
|
||||||
|
|
||||||
Only runs when conversation persistence is enabled (default: True).
|
Only user text and assistant text are restored. Tool call chains
|
||||||
Respects agent_max_context_turns to limit how many turns are loaded.
|
(tool_use / tool_result) are stripped out because:
|
||||||
|
1. They are intermediate process, the value is already in the final
|
||||||
|
assistant text reply.
|
||||||
|
2. They consume massive context tokens (often 80%+ of history).
|
||||||
|
3. Different models have incompatible tool message formats, so
|
||||||
|
restoring tool chains across model switches causes 400 errors.
|
||||||
|
4. Eliminates the entire class of tool_use/tool_result pairing bugs.
|
||||||
"""
|
"""
|
||||||
from config import conf
|
from config import conf
|
||||||
if not conf().get("conversation_persistence", True):
|
if not conf().get("conversation_persistence", True):
|
||||||
@@ -140,25 +147,107 @@ class AgentInitializer:
|
|||||||
try:
|
try:
|
||||||
from agent.memory import get_conversation_store
|
from agent.memory import get_conversation_store
|
||||||
store = get_conversation_store()
|
store = get_conversation_store()
|
||||||
# On restore, load at most min(10, max_turns // 2) turns so that
|
max_turns = conf().get("agent_max_context_turns", 20)
|
||||||
# a long-running session does not immediately fill the context window
|
# Scheduler tasks run on a stable isolated session per task and
|
||||||
# after a restart. The full max_turns budget is reserved for the
|
# can fire many times a day; a smaller restore window keeps prompt
|
||||||
# live conversation that follows.
|
# cost bounded while still letting the agent see "last few" runs
|
||||||
max_turns = conf().get("agent_max_context_turns", 30)
|
# for trend / dedup style logic. Regular chat sessions keep the
|
||||||
restore_turns = max(4, max_turns // 5)
|
# original heuristic so user dialogues feel continuous.
|
||||||
|
if session_id.startswith("scheduler_"):
|
||||||
|
restore_turns = max(1, max_turns // 5)
|
||||||
|
else:
|
||||||
|
restore_turns = max(3, max_turns // 6)
|
||||||
saved = store.load_messages(session_id, max_turns=restore_turns)
|
saved = store.load_messages(session_id, max_turns=restore_turns)
|
||||||
if saved:
|
if saved:
|
||||||
with agent.messages_lock:
|
filtered = self._filter_text_only_messages(saved)
|
||||||
agent.messages = saved
|
if filtered:
|
||||||
logger.debug(
|
with agent.messages_lock:
|
||||||
f"[AgentInitializer] Restored {len(saved)} messages "
|
agent.messages = filtered
|
||||||
f"({restore_turns} turns cap) for session={session_id}"
|
logger.debug(
|
||||||
)
|
f"[AgentInitializer] Restored {len(filtered)} text messages "
|
||||||
|
f"(from {len(saved)} total, {restore_turns} turns cap) "
|
||||||
|
f"for session={session_id}"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[AgentInitializer] Failed to restore conversation history for "
|
f"[AgentInitializer] Failed to restore conversation history for "
|
||||||
f"session={session_id}: {e}"
|
f"session={session_id}: {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _filter_text_only_messages(messages: list) -> list:
|
||||||
|
"""
|
||||||
|
Extract clean user/assistant turn pairs from raw message history.
|
||||||
|
|
||||||
|
Groups messages into turns (each starting with a real user query),
|
||||||
|
then keeps only:
|
||||||
|
- The first user text in each turn (the actual user input)
|
||||||
|
- The last assistant text in each turn (the final answer)
|
||||||
|
|
||||||
|
All tool_use, tool_result, intermediate assistant thoughts, and
|
||||||
|
internal hint messages injected by the agent loop are discarded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _extract_text(content) -> str:
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content.strip()
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts = [
|
||||||
|
b.get("text", "")
|
||||||
|
for b in content
|
||||||
|
if isinstance(b, dict) and b.get("type") == "text"
|
||||||
|
]
|
||||||
|
return "\n".join(p for p in parts if p).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _is_real_user_msg(msg: dict) -> bool:
|
||||||
|
"""True for actual user input, False for tool_result or internal hints."""
|
||||||
|
if msg.get("role") != "user":
|
||||||
|
return False
|
||||||
|
content = msg.get("content")
|
||||||
|
if isinstance(content, list):
|
||||||
|
has_tool_result = any(
|
||||||
|
isinstance(b, dict) and b.get("type") == "tool_result"
|
||||||
|
for b in content
|
||||||
|
)
|
||||||
|
if has_tool_result:
|
||||||
|
return False
|
||||||
|
text = _extract_text(content)
|
||||||
|
return bool(text)
|
||||||
|
|
||||||
|
# Group into turns: each turn starts with a real user message
|
||||||
|
turns = []
|
||||||
|
current_turn = None
|
||||||
|
for msg in messages:
|
||||||
|
if _is_real_user_msg(msg):
|
||||||
|
if current_turn is not None:
|
||||||
|
turns.append(current_turn)
|
||||||
|
current_turn = {"user": msg, "assistants": []}
|
||||||
|
elif current_turn is not None and msg.get("role") == "assistant":
|
||||||
|
text = _extract_text(msg.get("content"))
|
||||||
|
if text:
|
||||||
|
current_turn["assistants"].append(text)
|
||||||
|
if current_turn is not None:
|
||||||
|
turns.append(current_turn)
|
||||||
|
|
||||||
|
# Build result: one user msg + one assistant msg per turn
|
||||||
|
filtered = []
|
||||||
|
for turn in turns:
|
||||||
|
user_text = _extract_text(turn["user"].get("content"))
|
||||||
|
if not user_text:
|
||||||
|
continue
|
||||||
|
filtered.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "text", "text": user_text}]
|
||||||
|
})
|
||||||
|
if turn["assistants"]:
|
||||||
|
final_reply = turn["assistants"][-1]
|
||||||
|
filtered.append({
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{"type": "text", "text": final_reply}]
|
||||||
|
})
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
|
||||||
def _load_env_file(self):
|
def _load_env_file(self):
|
||||||
"""Load environment variables from .env file"""
|
"""Load environment variables from .env file"""
|
||||||
@@ -183,37 +272,19 @@ class AgentInitializer:
|
|||||||
memory_tools = []
|
memory_tools = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from agent.memory import MemoryManager, MemoryConfig, create_embedding_provider
|
from agent.memory import MemoryManager, MemoryConfig
|
||||||
from agent.tools import MemorySearchTool, MemoryGetTool
|
from agent.tools import MemorySearchTool, MemoryGetTool
|
||||||
from config import conf
|
from config import conf
|
||||||
|
|
||||||
# Get OpenAI config
|
|
||||||
openai_api_key = conf().get("open_ai_api_key", "")
|
|
||||||
openai_api_base = conf().get("open_ai_api_base", "")
|
|
||||||
|
|
||||||
# Initialize embedding provider
|
|
||||||
embedding_provider = None
|
|
||||||
if openai_api_key and openai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
|
||||||
try:
|
|
||||||
embedding_provider = create_embedding_provider(
|
|
||||||
provider="openai",
|
|
||||||
model="text-embedding-3-small",
|
|
||||||
api_key=openai_api_key,
|
|
||||||
api_base=openai_api_base or "https://api.openai.com/v1"
|
|
||||||
)
|
|
||||||
if session_id is None:
|
|
||||||
logger.info("[AgentInitializer] OpenAI embedding initialized")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"[AgentInitializer] OpenAI embedding failed: {e}")
|
|
||||||
|
|
||||||
# Create memory manager
|
|
||||||
memory_config = MemoryConfig(workspace_root=workspace_root)
|
memory_config = MemoryConfig(workspace_root=workspace_root)
|
||||||
|
|
||||||
|
embedding_provider = self._init_embedding_provider(
|
||||||
|
memory_config, session_id=session_id
|
||||||
|
)
|
||||||
|
|
||||||
memory_manager = MemoryManager(memory_config, embedding_provider=embedding_provider)
|
memory_manager = MemoryManager(memory_config, embedding_provider=embedding_provider)
|
||||||
|
|
||||||
# Sync memory
|
|
||||||
self._sync_memory(memory_manager, session_id)
|
self._sync_memory(memory_manager, session_id)
|
||||||
|
|
||||||
# Create memory tools
|
|
||||||
memory_tools = [
|
memory_tools = [
|
||||||
MemorySearchTool(memory_manager),
|
MemorySearchTool(memory_manager),
|
||||||
MemoryGetTool(memory_manager)
|
MemoryGetTool(memory_manager)
|
||||||
@@ -226,7 +297,21 @@ class AgentInitializer:
|
|||||||
logger.warning(f"[AgentInitializer] Memory system not available: {e}")
|
logger.warning(f"[AgentInitializer] Memory system not available: {e}")
|
||||||
|
|
||||||
return memory_manager, memory_tools
|
return memory_manager, memory_tools
|
||||||
|
|
||||||
|
def _init_embedding_provider(self, memory_config, session_id: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Initialize the embedding provider for memory.
|
||||||
|
|
||||||
|
Delegates to the shared factory so agent init, knowledge sync and
|
||||||
|
index rebuild all select the same provider:
|
||||||
|
A. Default (no `embedding_provider` in config.json):
|
||||||
|
Auto-init OpenAI -> LinkAI fallback.
|
||||||
|
B. Explicit (`embedding_provider` is set):
|
||||||
|
Initialize the requested vendor.
|
||||||
|
"""
|
||||||
|
from agent.memory import create_default_embedding_provider
|
||||||
|
return create_default_embedding_provider()
|
||||||
|
|
||||||
def _sync_memory(self, memory_manager, session_id: Optional[str] = None):
|
def _sync_memory(self, memory_manager, session_id: Optional[str] = None):
|
||||||
"""Sync memory database"""
|
"""Sync memory database"""
|
||||||
try:
|
try:
|
||||||
@@ -262,7 +347,15 @@ class AgentInitializer:
|
|||||||
if tool_name == "web_search":
|
if tool_name == "web_search":
|
||||||
from agent.tools.web_search.web_search import WebSearch
|
from agent.tools.web_search.web_search import WebSearch
|
||||||
if not WebSearch.is_available():
|
if not WebSearch.is_available():
|
||||||
logger.debug("[AgentInitializer] WebSearch skipped - no BOCHA_API_KEY or LINKAI_API_KEY")
|
logger.debug("[AgentInitializer] WebSearch skipped - no search provider configured")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip evolution_undo when self-evolution is disabled: with no
|
||||||
|
# evolution there is nothing to roll back, so the tool is dead weight.
|
||||||
|
if tool_name == "evolution_undo":
|
||||||
|
from agent.evolution.config import get_evolution_config
|
||||||
|
if not get_evolution_config().enabled:
|
||||||
|
logger.debug("[AgentInitializer] evolution_undo skipped - self-evolution disabled")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Special handling for EnvConfig tool
|
# Special handling for EnvConfig tool
|
||||||
@@ -273,16 +366,33 @@ class AgentInitializer:
|
|||||||
tool = tool_manager.create_tool(tool_name)
|
tool = tool_manager.create_tool(tool_name)
|
||||||
|
|
||||||
if tool:
|
if tool:
|
||||||
# Apply workspace config to file operation tools
|
# Apply workspace config to file operation tools.
|
||||||
if tool_name in ['read', 'write', 'edit', 'bash', 'grep', 'find', 'ls']:
|
# Merge into the existing tool.config (set by ToolManager from
|
||||||
tool.config = file_config
|
# config.json's `tools.<name>` section) instead of replacing
|
||||||
tool.cwd = file_config.get("cwd", getattr(tool, 'cwd', None))
|
# it, otherwise per-tool user configs (e.g. browser.cdp_endpoint)
|
||||||
if 'memory_manager' in file_config:
|
# would be silently dropped.
|
||||||
tool.memory_manager = file_config['memory_manager']
|
if tool_name in ['read', 'write', 'edit', 'bash', 'grep', 'find', 'ls', 'web_fetch', 'send', 'browser']:
|
||||||
|
merged_config = dict(getattr(tool, 'config', None) or {})
|
||||||
|
merged_config.update(file_config)
|
||||||
|
tool.config = merged_config
|
||||||
|
tool.cwd = merged_config.get("cwd", getattr(tool, 'cwd', None))
|
||||||
|
if 'memory_manager' in merged_config:
|
||||||
|
tool.memory_manager = merged_config['memory_manager']
|
||||||
tools.append(tool)
|
tools.append(tool)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[AgentInitializer] Failed to load tool {tool_name}: {e}")
|
logger.warning(f"[AgentInitializer] Failed to load tool {tool_name}: {e}")
|
||||||
|
|
||||||
|
# Add MCP tools (snapshot to avoid races with the background loader)
|
||||||
|
mcp_tools_snapshot = list(tool_manager._mcp_tool_instances.items())
|
||||||
|
if mcp_tools_snapshot:
|
||||||
|
for _, mcp_tool in mcp_tools_snapshot:
|
||||||
|
tools.append(mcp_tool)
|
||||||
|
if session_id is None:
|
||||||
|
names = [name for name, _ in mcp_tools_snapshot]
|
||||||
|
logger.info(
|
||||||
|
f"[AgentInitializer] Added {len(names)} MCP tool(s): {names}"
|
||||||
|
)
|
||||||
|
|
||||||
# Add memory tools
|
# Add memory tools
|
||||||
if memory_tools:
|
if memory_tools:
|
||||||
tools.extend(memory_tools)
|
tools.extend(memory_tools)
|
||||||
@@ -295,16 +405,23 @@ class AgentInitializer:
|
|||||||
return tools
|
return tools
|
||||||
|
|
||||||
def _initialize_scheduler(self, tools: List, session_id: Optional[str] = None):
|
def _initialize_scheduler(self, tools: List, session_id: Optional[str] = None):
|
||||||
"""Initialize scheduler service if needed"""
|
"""Initialize scheduler service if needed.
|
||||||
|
|
||||||
|
Serialize the check-and-set under a module-level lock so concurrent
|
||||||
|
first-time session inits cannot each create a new SchedulerService
|
||||||
|
(which would leak background scanning threads).
|
||||||
|
"""
|
||||||
if not self.agent_bridge.scheduler_initialized:
|
if not self.agent_bridge.scheduler_initialized:
|
||||||
try:
|
with _scheduler_init_lock:
|
||||||
from agent.tools.scheduler.integration import init_scheduler
|
if not self.agent_bridge.scheduler_initialized:
|
||||||
if init_scheduler(self.agent_bridge):
|
try:
|
||||||
self.agent_bridge.scheduler_initialized = True
|
from agent.tools.scheduler.integration import init_scheduler
|
||||||
if session_id is None:
|
if init_scheduler(self.agent_bridge):
|
||||||
logger.info("[AgentInitializer] Scheduler service initialized")
|
self.agent_bridge.scheduler_initialized = True
|
||||||
except Exception as e:
|
if session_id is None:
|
||||||
logger.warning(f"[AgentInitializer] Failed to initialize scheduler: {e}")
|
logger.info("[AgentInitializer] Scheduler service initialized")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[AgentInitializer] Failed to initialize scheduler: {e}")
|
||||||
|
|
||||||
# Inject scheduler dependencies
|
# Inject scheduler dependencies
|
||||||
if self.agent_bridge.scheduler_initialized:
|
if self.agent_bridge.scheduler_initialized:
|
||||||
@@ -360,21 +477,34 @@ class AgentInitializer:
|
|||||||
except Exception:
|
except Exception:
|
||||||
timezone_name = "UTC"
|
timezone_name = "UTC"
|
||||||
|
|
||||||
# Chinese weekday mapping
|
# Weekday: English name in en, Chinese mapping otherwise
|
||||||
weekday_map = {
|
weekday_en = now.strftime("%A")
|
||||||
'Monday': '星期一', 'Tuesday': '星期二', 'Wednesday': '星期三',
|
try:
|
||||||
'Thursday': '星期四', 'Friday': '星期五', 'Saturday': '星期六', 'Sunday': '星期日'
|
from common import i18n
|
||||||
}
|
is_en = i18n.get_language() == "en"
|
||||||
weekday_zh = weekday_map.get(now.strftime("%A"), now.strftime("%A"))
|
except Exception:
|
||||||
|
is_en = False
|
||||||
|
if is_en:
|
||||||
|
weekday = weekday_en
|
||||||
|
else:
|
||||||
|
weekday_map = {
|
||||||
|
'Monday': '星期一', 'Tuesday': '星期二', 'Wednesday': '星期三',
|
||||||
|
'Thursday': '星期四', 'Friday': '星期五', 'Saturday': '星期六', 'Sunday': '星期日'
|
||||||
|
}
|
||||||
|
weekday = weekday_map.get(weekday_en, weekday_en)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'time': now.strftime("%Y-%m-%d %H:%M:%S"),
|
'time': now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
'weekday': weekday_zh,
|
'weekday': weekday,
|
||||||
'timezone': timezone_name
|
'timezone': timezone_name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_model():
|
||||||
|
"""Get current model name dynamically from config"""
|
||||||
|
return conf().get("model", "unknown")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"model": conf().get("model", "unknown"),
|
"_get_model": get_model,
|
||||||
"workspace": workspace_root,
|
"workspace": workspace_root,
|
||||||
"channel": ", ".join(conf().get("channel_type")) if isinstance(conf().get("channel_type"), list) else conf().get("channel_type", "unknown"),
|
"channel": ", ".join(conf().get("channel_type")) if isinstance(conf().get("channel_type"), list) else conf().get("channel_type", "unknown"),
|
||||||
"_get_current_time": get_current_time # Dynamic time function
|
"_get_current_time": get_current_time # Dynamic time function
|
||||||
@@ -394,7 +524,7 @@ class AgentInitializer:
|
|||||||
|
|
||||||
env_file = expand_path("~/.cow/.env")
|
env_file = expand_path("~/.cow/.env")
|
||||||
|
|
||||||
# Read existing env vars
|
# Read existing env vars (key -> value)
|
||||||
existing_env_vars = {}
|
existing_env_vars = {}
|
||||||
if os.path.exists(env_file):
|
if os.path.exists(env_file):
|
||||||
try:
|
try:
|
||||||
@@ -402,35 +532,126 @@ class AgentInitializer:
|
|||||||
for line in f:
|
for line in f:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line and not line.startswith('#') and '=' in line:
|
if line and not line.startswith('#') and '=' in line:
|
||||||
key, _ = line.split('=', 1)
|
key, val = line.split('=', 1)
|
||||||
existing_env_vars[key.strip()] = True
|
existing_env_vars[key.strip()] = val.strip()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[AgentInitializer] Failed to read .env file: {e}")
|
logger.warning(f"[AgentInitializer] Failed to read .env file: {e}")
|
||||||
|
|
||||||
# Check which keys need migration
|
# Sync config.json values into .env (add/update/remove)
|
||||||
keys_to_migrate = {}
|
updated = False
|
||||||
for config_key, env_key in key_mapping.items():
|
for config_key, env_key in key_mapping.items():
|
||||||
if env_key in existing_env_vars:
|
raw = conf().get(config_key, "")
|
||||||
continue
|
value = raw.strip() if raw else ""
|
||||||
value = conf().get(config_key, "")
|
old_value = existing_env_vars.get(env_key)
|
||||||
if value and value.strip():
|
|
||||||
keys_to_migrate[env_key] = value.strip()
|
if value:
|
||||||
|
if old_value == value:
|
||||||
# Write new keys
|
continue
|
||||||
if keys_to_migrate:
|
existing_env_vars[env_key] = value
|
||||||
|
os.environ[env_key] = value
|
||||||
|
updated = True
|
||||||
|
else:
|
||||||
|
if old_value is None:
|
||||||
|
continue
|
||||||
|
existing_env_vars.pop(env_key, None)
|
||||||
|
os.environ.pop(env_key, None)
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if updated:
|
||||||
try:
|
try:
|
||||||
env_dir = os.path.dirname(env_file)
|
env_dir = os.path.dirname(env_file)
|
||||||
if not os.path.exists(env_dir):
|
os.makedirs(env_dir, exist_ok=True)
|
||||||
os.makedirs(env_dir, exist_ok=True)
|
|
||||||
if not os.path.exists(env_file):
|
# Rewrite the entire .env file to ensure consistency
|
||||||
open(env_file, 'a').close()
|
with open(env_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write('# Environment variables for agent\n')
|
||||||
with open(env_file, 'a', encoding='utf-8') as f:
|
f.write('# Auto-managed - synced from config.json on startup\n\n')
|
||||||
f.write('\n# Auto-migrated from config.json\n')
|
for key, value in sorted(existing_env_vars.items()):
|
||||||
for key, value in keys_to_migrate.items():
|
|
||||||
f.write(f'{key}={value}\n')
|
f.write(f'{key}={value}\n')
|
||||||
os.environ[key] = value
|
|
||||||
|
logger.info(f"[AgentInitializer] Synced API keys from config.json to .env")
|
||||||
logger.info(f"[AgentInitializer] Migrated {len(keys_to_migrate)} API keys to .env: {list(keys_to_migrate.keys())}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[AgentInitializer] Failed to migrate API keys: {e}")
|
logger.warning(f"[AgentInitializer] Failed to sync API keys: {e}")
|
||||||
|
|
||||||
|
def _start_daily_flush_timer(self):
|
||||||
|
"""Start a background thread that flushes all agents' memory daily at 23:55."""
|
||||||
|
if getattr(self.agent_bridge, '_daily_flush_started', False):
|
||||||
|
return
|
||||||
|
self.agent_bridge._daily_flush_started = True
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
def _daily_flush_loop():
|
||||||
|
import random
|
||||||
|
last_run_date = None # Track last successful run date to prevent same-day re-trigger
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
jitter_min = random.randint(50, 55)
|
||||||
|
jitter_sec = random.randint(0, 59)
|
||||||
|
target = now.replace(hour=23, minute=jitter_min, second=jitter_sec, microsecond=0)
|
||||||
|
# Always schedule for tomorrow if we already ran today, or if target time has passed
|
||||||
|
if target <= now or (last_run_date == now.date()):
|
||||||
|
target += datetime.timedelta(days=1)
|
||||||
|
wait_seconds = (target - now).total_seconds()
|
||||||
|
logger.info(f"[DailyFlush] Next flush at {target.strftime('%Y-%m-%d %H:%M:%S')} (in {wait_seconds/3600:.1f}h)")
|
||||||
|
time.sleep(wait_seconds)
|
||||||
|
|
||||||
|
self._flush_all_agents()
|
||||||
|
last_run_date = datetime.datetime.now().date()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[DailyFlush] Error in daily flush loop: {e}")
|
||||||
|
time.sleep(3600)
|
||||||
|
|
||||||
|
t = threading.Thread(target=_daily_flush_loop, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
def _flush_all_agents(self):
|
||||||
|
"""Flush memory for all active agent sessions, then run Deep Dream."""
|
||||||
|
agents = []
|
||||||
|
if self.agent_bridge.default_agent:
|
||||||
|
agents.append(("default", self.agent_bridge.default_agent))
|
||||||
|
for sid, agent in self.agent_bridge.agents.items():
|
||||||
|
agents.append((sid, agent))
|
||||||
|
|
||||||
|
if not agents:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Phase 1: flush daily summaries
|
||||||
|
flushed = 0
|
||||||
|
flush_threads = []
|
||||||
|
dream_candidate = None
|
||||||
|
for label, agent in agents:
|
||||||
|
try:
|
||||||
|
if not agent.memory_manager:
|
||||||
|
continue
|
||||||
|
with agent.messages_lock:
|
||||||
|
messages = list(agent.messages)
|
||||||
|
if not messages:
|
||||||
|
continue
|
||||||
|
result = agent.memory_manager.flush_manager.create_daily_summary(messages)
|
||||||
|
if result:
|
||||||
|
flushed += 1
|
||||||
|
t = agent.memory_manager.flush_manager._last_flush_thread
|
||||||
|
if t:
|
||||||
|
flush_threads.append(t)
|
||||||
|
if dream_candidate is None:
|
||||||
|
dream_candidate = agent.memory_manager.flush_manager
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[DailyFlush] Failed for session {label}: {e}")
|
||||||
|
|
||||||
|
if flushed:
|
||||||
|
logger.info(f"[DailyFlush] Flushed {flushed}/{len(agents)} agent session(s)")
|
||||||
|
|
||||||
|
# Wait for all flush threads to finish before dreaming
|
||||||
|
for t in flush_threads:
|
||||||
|
t.join(timeout=60)
|
||||||
|
|
||||||
|
# Phase 2: Deep Dream — distill daily memories → MEMORY.md + dream diary
|
||||||
|
if dream_candidate:
|
||||||
|
try:
|
||||||
|
result = dream_candidate.deep_dream()
|
||||||
|
if result:
|
||||||
|
logger.info("[DeepDream] Memory distillation completed successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[DeepDream] Failed: {e}")
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ from voice.factory import create_voice
|
|||||||
class Bridge(object):
|
class Bridge(object):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.btype = {
|
self.btype = {
|
||||||
"chat": const.CHATGPT,
|
"chat": const.OPENAI,
|
||||||
"voice_to_text": conf().get("voice_to_text", "openai"),
|
# Empty `voice_to_text` (the default in new configs) triggers
|
||||||
|
# the auto-pick below — see _auto_pick_voice_to_text for order.
|
||||||
|
"voice_to_text": conf().get("voice_to_text") or self._auto_pick_voice_to_text(),
|
||||||
"text_to_voice": conf().get("text_to_voice", "google"),
|
"text_to_voice": conf().get("text_to_voice", "google"),
|
||||||
"translate": conf().get("translate", "baidu"),
|
"translate": conf().get("translate", "baidu"),
|
||||||
}
|
}
|
||||||
@@ -39,11 +41,8 @@ class Bridge(object):
|
|||||||
self.btype["chat"] = const.BAIDU
|
self.btype["chat"] = const.BAIDU
|
||||||
if model_type in ["xunfei"]:
|
if model_type in ["xunfei"]:
|
||||||
self.btype["chat"] = const.XUNFEI
|
self.btype["chat"] = const.XUNFEI
|
||||||
if model_type in [const.QWEN]:
|
if model_type in [const.QWEN, const.QWEN_TURBO, const.QWEN_PLUS, const.QWEN_MAX]:
|
||||||
self.btype["chat"] = const.QWEN
|
|
||||||
if model_type in [const.QWEN_TURBO, const.QWEN_PLUS, const.QWEN_MAX]:
|
|
||||||
self.btype["chat"] = const.QWEN_DASHSCOPE
|
self.btype["chat"] = const.QWEN_DASHSCOPE
|
||||||
# Support Qwen3 and other DashScope models
|
|
||||||
if model_type and (model_type.startswith("qwen") or model_type.startswith("qwq") or model_type.startswith("qvq")):
|
if model_type and (model_type.startswith("qwen") or model_type.startswith("qwq") or model_type.startswith("qvq")):
|
||||||
self.btype["chat"] = const.QWEN_DASHSCOPE
|
self.btype["chat"] = const.QWEN_DASHSCOPE
|
||||||
if model_type and model_type.startswith("gemini"):
|
if model_type and model_type.startswith("gemini"):
|
||||||
@@ -61,6 +60,18 @@ class Bridge(object):
|
|||||||
if model_type and model_type.startswith("doubao"):
|
if model_type and model_type.startswith("doubao"):
|
||||||
self.btype["chat"] = const.DOUBAO
|
self.btype["chat"] = const.DOUBAO
|
||||||
|
|
||||||
|
if model_type and model_type.startswith("deepseek"):
|
||||||
|
self.btype["chat"] = const.DEEPSEEK
|
||||||
|
|
||||||
|
# 小米 MiMo 系列模型,全部以 mimo- 开头
|
||||||
|
if model_type and model_type.startswith("mimo-"):
|
||||||
|
self.btype["chat"] = const.MIMO
|
||||||
|
|
||||||
|
if model_type and isinstance(model_type, str):
|
||||||
|
lowered_model_type = model_type.lower()
|
||||||
|
if lowered_model_type == const.QIANFAN or lowered_model_type.startswith("ernie"):
|
||||||
|
self.btype["chat"] = const.QIANFAN
|
||||||
|
|
||||||
if model_type in [const.MODELSCOPE]:
|
if model_type in [const.MODELSCOPE]:
|
||||||
self.btype["chat"] = const.MODELSCOPE
|
self.btype["chat"] = const.MODELSCOPE
|
||||||
|
|
||||||
@@ -79,6 +90,46 @@ class Bridge(object):
|
|||||||
self.chat_bots = {}
|
self.chat_bots = {}
|
||||||
self._agent_bridge = None
|
self._agent_bridge = None
|
||||||
|
|
||||||
|
def refresh_voice(self):
|
||||||
|
"""Re-read voice_to_text / text_to_voice from config and drop the
|
||||||
|
cached voice bots so the next call picks up the new provider.
|
||||||
|
Used by the web console after the user edits voice settings.
|
||||||
|
Does NOT touch the agent_bridge / agent state.
|
||||||
|
"""
|
||||||
|
new_v2t = conf().get("voice_to_text") or self._auto_pick_voice_to_text()
|
||||||
|
new_t2v = conf().get("text_to_voice", "google")
|
||||||
|
if conf().get("use_linkai") and conf().get("linkai_api_key"):
|
||||||
|
if not conf().get("voice_to_text") or conf().get("voice_to_text") in ["openai"]:
|
||||||
|
new_v2t = const.LINKAI
|
||||||
|
if not conf().get("text_to_voice") or conf().get("text_to_voice") in ["openai", const.TTS_1, const.TTS_1_HD]:
|
||||||
|
new_t2v = const.LINKAI
|
||||||
|
self.btype["voice_to_text"] = new_v2t
|
||||||
|
self.btype["text_to_voice"] = new_t2v
|
||||||
|
self.bots.pop("voice_to_text", None)
|
||||||
|
self.bots.pop("text_to_voice", None)
|
||||||
|
logger.info(f"[Bridge] voice refreshed: voice_to_text={new_v2t}, text_to_voice={new_t2v}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _auto_pick_voice_to_text() -> str:
|
||||||
|
"""Pick an ASR provider by configured api keys when voice_to_text is
|
||||||
|
unset. Order matches the web console: openai → dashscope → zhipu →
|
||||||
|
linkai. Falls back to 'openai' when nothing is configured so the
|
||||||
|
original "missing key" error is preserved.
|
||||||
|
"""
|
||||||
|
def has(k: str) -> bool:
|
||||||
|
v = (conf().get(k) or "").strip()
|
||||||
|
return v != "" and v not in ("YOUR API KEY", "YOUR_API_KEY")
|
||||||
|
|
||||||
|
for key, provider in (
|
||||||
|
("open_ai_api_key", "openai"),
|
||||||
|
("dashscope_api_key", "dashscope"),
|
||||||
|
("zhipu_ai_api_key", "zhipu"),
|
||||||
|
("linkai_api_key", "linkai"),
|
||||||
|
):
|
||||||
|
if has(key):
|
||||||
|
return provider
|
||||||
|
return "openai"
|
||||||
|
|
||||||
# 模型对应的接口
|
# 模型对应的接口
|
||||||
def get_bot(self, typename):
|
def get_bot(self, typename):
|
||||||
if self.bots.get(typename) is None:
|
if self.bots.get(typename) is None:
|
||||||
|
|||||||
@@ -13,12 +13,38 @@ class Channel(object):
|
|||||||
channel_type = ""
|
channel_type = ""
|
||||||
NOT_SUPPORT_REPLYTYPE = [ReplyType.VOICE, ReplyType.IMAGE]
|
NOT_SUPPORT_REPLYTYPE = [ReplyType.VOICE, ReplyType.IMAGE]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
import threading
|
||||||
|
self._startup_event = threading.Event()
|
||||||
|
self._startup_error = None
|
||||||
|
self.cloud_mode = False # set to True by ChannelManager when running with cloud client
|
||||||
|
|
||||||
def startup(self):
|
def startup(self):
|
||||||
"""
|
"""
|
||||||
init channel
|
init channel
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def report_startup_success(self):
|
||||||
|
self._startup_error = None
|
||||||
|
self._startup_event.set()
|
||||||
|
|
||||||
|
def report_startup_error(self, error: str):
|
||||||
|
self._startup_error = error
|
||||||
|
self._startup_event.set()
|
||||||
|
|
||||||
|
def wait_startup(self, timeout: float = 3) -> (bool, str):
|
||||||
|
"""
|
||||||
|
Wait for channel startup result.
|
||||||
|
Returns (success: bool, error_msg: str).
|
||||||
|
"""
|
||||||
|
ready = self._startup_event.wait(timeout=timeout)
|
||||||
|
if not ready:
|
||||||
|
return True, ""
|
||||||
|
if self._startup_error:
|
||||||
|
return False, self._startup_error
|
||||||
|
return True, ""
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""
|
"""
|
||||||
stop channel gracefully, called before restart
|
stop channel gracefully, called before restart
|
||||||
@@ -47,7 +73,7 @@ class Channel(object):
|
|||||||
Build reply content, using agent if enabled in config
|
Build reply content, using agent if enabled in config
|
||||||
"""
|
"""
|
||||||
# Check if agent mode is enabled
|
# Check if agent mode is enabled
|
||||||
use_agent = conf().get("agent", False)
|
use_agent = conf().get("agent", True)
|
||||||
|
|
||||||
if use_agent:
|
if use_agent:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -12,16 +12,7 @@ def create_channel(channel_type) -> Channel:
|
|||||||
:return: channel instance
|
:return: channel instance
|
||||||
"""
|
"""
|
||||||
ch = Channel()
|
ch = Channel()
|
||||||
if channel_type == "wx":
|
if channel_type == "terminal":
|
||||||
from channel.wechat.wechat_channel import WechatChannel
|
|
||||||
ch = WechatChannel()
|
|
||||||
elif channel_type == "wxy":
|
|
||||||
from channel.wechat.wechaty_channel import WechatyChannel
|
|
||||||
ch = WechatyChannel()
|
|
||||||
elif channel_type == "wcf":
|
|
||||||
from channel.wechat.wcf_channel import WechatfChannel
|
|
||||||
ch = WechatfChannel()
|
|
||||||
elif channel_type == "terminal":
|
|
||||||
from channel.terminal.terminal_channel import TerminalChannel
|
from channel.terminal.terminal_channel import TerminalChannel
|
||||||
ch = TerminalChannel()
|
ch = TerminalChannel()
|
||||||
elif channel_type == 'web':
|
elif channel_type == 'web':
|
||||||
@@ -36,15 +27,34 @@ def create_channel(channel_type) -> Channel:
|
|||||||
elif channel_type == "wechatcom_app":
|
elif channel_type == "wechatcom_app":
|
||||||
from channel.wechatcom.wechatcomapp_channel import WechatComAppChannel
|
from channel.wechatcom.wechatcomapp_channel import WechatComAppChannel
|
||||||
ch = WechatComAppChannel()
|
ch = WechatComAppChannel()
|
||||||
elif channel_type == "wework":
|
elif channel_type == const.WECHAT_KF:
|
||||||
from channel.wework.wework_channel import WeworkChannel
|
from channel.wechat_kf.wechat_kf_channel import WechatKfChannel
|
||||||
ch = WeworkChannel()
|
ch = WechatKfChannel()
|
||||||
elif channel_type == const.FEISHU:
|
elif channel_type == const.FEISHU:
|
||||||
from channel.feishu.feishu_channel import FeiShuChanel
|
from channel.feishu.feishu_channel import FeiShuChanel
|
||||||
ch = FeiShuChanel()
|
ch = FeiShuChanel()
|
||||||
elif channel_type == const.DINGTALK:
|
elif channel_type == const.DINGTALK:
|
||||||
from channel.dingtalk.dingtalk_channel import DingTalkChanel
|
from channel.dingtalk.dingtalk_channel import DingTalkChanel
|
||||||
ch = DingTalkChanel()
|
ch = DingTalkChanel()
|
||||||
|
elif channel_type == const.WECOM_BOT:
|
||||||
|
from channel.wecom_bot.wecom_bot_channel import WecomBotChannel
|
||||||
|
ch = WecomBotChannel()
|
||||||
|
elif channel_type == const.QQ:
|
||||||
|
from channel.qq.qq_channel import QQChannel
|
||||||
|
ch = QQChannel()
|
||||||
|
elif channel_type == const.TELEGRAM:
|
||||||
|
from channel.telegram.telegram_channel import TelegramChannel
|
||||||
|
ch = TelegramChannel()
|
||||||
|
elif channel_type == const.SLACK:
|
||||||
|
from channel.slack.slack_channel import SlackChannel
|
||||||
|
ch = SlackChannel()
|
||||||
|
elif channel_type == const.DISCORD:
|
||||||
|
from channel.discord.discord_channel import DiscordChannel
|
||||||
|
ch = DiscordChannel()
|
||||||
|
elif channel_type in (const.WEIXIN, "wx"):
|
||||||
|
from channel.weixin.weixin_channel import WeixinChannel
|
||||||
|
ch = WeixinChannel()
|
||||||
|
channel_type = const.WEIXIN
|
||||||
else:
|
else:
|
||||||
raise RuntimeError
|
raise RuntimeError
|
||||||
ch.channel_type = channel_type
|
ch.channel_type = channel_type
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from bridge.reply import *
|
|||||||
from channel.channel import Channel
|
from channel.channel import Channel
|
||||||
from common.dequeue import Dequeue
|
from common.dequeue import Dequeue
|
||||||
from common import memory
|
from common import memory
|
||||||
|
from common.i18n import t as _t
|
||||||
from plugins import *
|
from plugins import *
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -26,6 +27,7 @@ class ChatChannel(Channel):
|
|||||||
user_id = None # 登录的用户id
|
user_id = None # 登录的用户id
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
# Instance-level attributes so each channel subclass has its own
|
# Instance-level attributes so each channel subclass has its own
|
||||||
# independent session queue and lock. Previously these were class-level,
|
# independent session queue and lock. Previously these were class-level,
|
||||||
# which caused contexts from one channel (e.g. Feishu) to be consumed
|
# which caused contexts from one channel (e.g. Feishu) to be consumed
|
||||||
@@ -170,7 +172,13 @@ class ChatChannel(Channel):
|
|||||||
if "desire_rtype" not in context and conf().get("always_reply_voice") and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE:
|
if "desire_rtype" not in context and conf().get("always_reply_voice") and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE:
|
||||||
context["desire_rtype"] = ReplyType.VOICE
|
context["desire_rtype"] = ReplyType.VOICE
|
||||||
elif context.type == ContextType.VOICE:
|
elif context.type == ContextType.VOICE:
|
||||||
if "desire_rtype" not in context and conf().get("voice_reply_voice") and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE:
|
# Voice input replies with voice when either voice_reply_voice
|
||||||
|
# (mirror voice) or the global always_reply_voice toggle is on.
|
||||||
|
if (
|
||||||
|
"desire_rtype" not in context
|
||||||
|
and (conf().get("voice_reply_voice") or conf().get("always_reply_voice"))
|
||||||
|
and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE
|
||||||
|
):
|
||||||
context["desire_rtype"] = ReplyType.VOICE
|
context["desire_rtype"] = ReplyType.VOICE
|
||||||
return context
|
return context
|
||||||
|
|
||||||
@@ -258,11 +266,13 @@ class ChatChannel(Channel):
|
|||||||
if reply.type in self.NOT_SUPPORT_REPLYTYPE:
|
if reply.type in self.NOT_SUPPORT_REPLYTYPE:
|
||||||
logger.error("[chat_channel]reply type not support: " + str(reply.type))
|
logger.error("[chat_channel]reply type not support: " + str(reply.type))
|
||||||
reply.type = ReplyType.ERROR
|
reply.type = ReplyType.ERROR
|
||||||
reply.content = "不支持发送的消息类型: " + str(reply.type)
|
reply.content = _t("不支持发送的消息类型: ", "Unsupported message type: ") + str(reply.type)
|
||||||
|
|
||||||
if reply.type == ReplyType.TEXT:
|
if reply.type == ReplyType.TEXT:
|
||||||
reply_text = reply.content
|
reply_text = reply.content
|
||||||
if desire_rtype == ReplyType.VOICE and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE:
|
if desire_rtype == ReplyType.VOICE and ReplyType.VOICE not in self.NOT_SUPPORT_REPLYTYPE:
|
||||||
|
# Preserve original text for the "text-then-voice" pattern in _send_reply.
|
||||||
|
context["voice_reply_text"] = reply.content
|
||||||
reply = super().build_text_to_voice(reply.content)
|
reply = super().build_text_to_voice(reply.content)
|
||||||
return self._decorate_reply(context, reply)
|
return self._decorate_reply(context, reply)
|
||||||
if context.get("isgroup", False):
|
if context.get("isgroup", False):
|
||||||
@@ -296,8 +306,12 @@ class ChatChannel(Channel):
|
|||||||
logger.debug("[chat_channel] sending reply: {}, context: {}".format(reply, context))
|
logger.debug("[chat_channel] sending reply: {}, context: {}".format(reply, context))
|
||||||
|
|
||||||
# 如果是文本回复,尝试提取并发送图片
|
# 如果是文本回复,尝试提取并发送图片
|
||||||
if reply.type == ReplyType.TEXT:
|
# Web channel renders images/videos inline via renderMarkdown,
|
||||||
|
# so skip the extract-and-send step to avoid duplicate media.
|
||||||
|
if reply.type == ReplyType.TEXT and context.get("channel_type") != "web":
|
||||||
self._extract_and_send_images(reply, context)
|
self._extract_and_send_images(reply, context)
|
||||||
|
elif reply.type == ReplyType.TEXT:
|
||||||
|
self._send(reply, context)
|
||||||
# 如果是图片回复但带有文本内容,先发文本再发图片
|
# 如果是图片回复但带有文本内容,先发文本再发图片
|
||||||
elif reply.type == ReplyType.IMAGE_URL and hasattr(reply, 'text_content') and reply.text_content:
|
elif reply.type == ReplyType.IMAGE_URL and hasattr(reply, 'text_content') and reply.text_content:
|
||||||
# 先发送文本
|
# 先发送文本
|
||||||
@@ -306,6 +320,15 @@ class ChatChannel(Channel):
|
|||||||
# 短暂延迟后发送图片
|
# 短暂延迟后发送图片
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
self._send(reply, context)
|
self._send(reply, context)
|
||||||
|
# Send text bubble before voice, unless channel already streamed
|
||||||
|
# the text (feishu) or natively renders STT under the voice (wechatcom).
|
||||||
|
elif reply.type == ReplyType.VOICE and context.get("voice_reply_text") \
|
||||||
|
and not context.get("feishu_streamed") \
|
||||||
|
and context.get("channel_type") not in ("wechatcom_app",):
|
||||||
|
text_reply = Reply(ReplyType.TEXT, context.get("voice_reply_text"))
|
||||||
|
self._send(text_reply, context)
|
||||||
|
time.sleep(0.3)
|
||||||
|
self._send(reply, context)
|
||||||
else:
|
else:
|
||||||
self._send(reply, context)
|
self._send(reply, context)
|
||||||
|
|
||||||
@@ -346,38 +369,30 @@ class ChatChannel(Channel):
|
|||||||
if media_items:
|
if media_items:
|
||||||
logger.info(f"[chat_channel] Extracted {len(media_items)} media item(s) from reply")
|
logger.info(f"[chat_channel] Extracted {len(media_items)} media item(s) from reply")
|
||||||
|
|
||||||
# 先发送文本(保持原文本不变)
|
# Send text first (the frontend will embed video players via renderMarkdown).
|
||||||
logger.info(f"[chat_channel] Sending text content before media: {reply.content[:100]}...")
|
logger.info(f"[chat_channel] Sending text content before media: {reply.content[:100]}...")
|
||||||
self._send(reply, context)
|
self._send(reply, context)
|
||||||
logger.info(f"[chat_channel] Text sent, now sending {len(media_items)} media item(s)")
|
logger.info(f"[chat_channel] Text sent, now sending {len(media_items)} media item(s)")
|
||||||
|
|
||||||
# 然后逐个发送媒体文件
|
|
||||||
for i, (url, media_type) in enumerate(media_items):
|
for i, (url, media_type) in enumerate(media_items):
|
||||||
try:
|
try:
|
||||||
# 判断是本地文件还是URL
|
# Determine whether it is a remote URL or a local file.
|
||||||
if url.startswith(('http://', 'https://')):
|
if url.startswith(('http://', 'https://')):
|
||||||
# 网络资源
|
|
||||||
if media_type == 'video':
|
if media_type == 'video':
|
||||||
# 视频使用 FILE 类型发送
|
|
||||||
media_reply = Reply(ReplyType.FILE, url)
|
media_reply = Reply(ReplyType.FILE, url)
|
||||||
media_reply.file_name = os.path.basename(url)
|
media_reply.file_name = os.path.basename(url)
|
||||||
else:
|
else:
|
||||||
# 图片使用 IMAGE_URL 类型
|
|
||||||
media_reply = Reply(ReplyType.IMAGE_URL, url)
|
media_reply = Reply(ReplyType.IMAGE_URL, url)
|
||||||
elif os.path.exists(url):
|
elif os.path.exists(url):
|
||||||
# 本地文件
|
|
||||||
if media_type == 'video':
|
if media_type == 'video':
|
||||||
# 视频使用 FILE 类型,转换为 file:// URL
|
|
||||||
media_reply = Reply(ReplyType.FILE, f"file://{url}")
|
media_reply = Reply(ReplyType.FILE, f"file://{url}")
|
||||||
media_reply.file_name = os.path.basename(url)
|
media_reply.file_name = os.path.basename(url)
|
||||||
else:
|
else:
|
||||||
# 图片使用 IMAGE_URL 类型,转换为 file:// URL
|
|
||||||
media_reply = Reply(ReplyType.IMAGE_URL, f"file://{url}")
|
media_reply = Reply(ReplyType.IMAGE_URL, f"file://{url}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"[chat_channel] Media file not found or invalid URL: {url}")
|
logger.warning(f"[chat_channel] Media file not found or invalid URL: {url}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 发送媒体文件(添加小延迟避免频率限制)
|
|
||||||
if i > 0:
|
if i > 0:
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
self._send(media_reply, context)
|
self._send(media_reply, context)
|
||||||
@@ -424,19 +439,55 @@ class ChatChannel(Channel):
|
|||||||
|
|
||||||
return func
|
return func
|
||||||
|
|
||||||
|
# Chat commands that must bypass the per-session serial queue,
|
||||||
|
# otherwise /cancel would queue behind the task it tries to cancel.
|
||||||
|
# Use /cancel (not /stop) to avoid colliding with `cow stop` CLI.
|
||||||
|
_BYPASS_QUEUE_COMMANDS = ("/cancel",)
|
||||||
|
|
||||||
def produce(self, context: Context):
|
def produce(self, context: Context):
|
||||||
session_id = context["session_id"]
|
session_id = context["session_id"]
|
||||||
|
|
||||||
|
# Fast path: /cancel must not enter the queue.
|
||||||
|
if context.type == ContextType.TEXT and context.content:
|
||||||
|
stripped = context.content.strip().lower()
|
||||||
|
if stripped in self._BYPASS_QUEUE_COMMANDS:
|
||||||
|
self._handle_cancel_command(context, session_id)
|
||||||
|
return
|
||||||
|
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if session_id not in self.sessions:
|
if session_id not in self.sessions:
|
||||||
self.sessions[session_id] = [
|
self.sessions[session_id] = [
|
||||||
Dequeue(),
|
Dequeue(),
|
||||||
threading.BoundedSemaphore(conf().get("concurrency_in_session", 4)),
|
threading.BoundedSemaphore(conf().get("concurrency_in_session", 1)),
|
||||||
]
|
]
|
||||||
if context.type == ContextType.TEXT and context.content.startswith("#"):
|
if context.type == ContextType.TEXT and context.content.startswith("#"):
|
||||||
self.sessions[session_id][0].putleft(context) # 优先处理管理命令
|
self.sessions[session_id][0].putleft(context) # 优先处理管理命令
|
||||||
else:
|
else:
|
||||||
self.sessions[session_id][0].put(context)
|
self.sessions[session_id][0].put(context)
|
||||||
|
|
||||||
|
def _handle_cancel_command(self, context: Context, session_id: str) -> None:
|
||||||
|
"""Cancel any in-flight agent run for *session_id* and reply inline.
|
||||||
|
|
||||||
|
Runs synchronously on the caller's thread. Reply is sent through
|
||||||
|
_send_reply so plugins (e.g. logging) still observe it.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from agent.protocol import get_cancel_registry
|
||||||
|
from bridge.reply import Reply, ReplyType
|
||||||
|
|
||||||
|
cancelled = get_cancel_registry().cancel_session(session_id)
|
||||||
|
text = (
|
||||||
|
_t("🛑 已中止", "🛑 Cancelled")
|
||||||
|
if cancelled > 0
|
||||||
|
else _t("当前没有可中止的任务。", "Nothing to cancel.")
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"[chat_channel] /cancel fast-path: session={session_id}, cancelled={cancelled}"
|
||||||
|
)
|
||||||
|
self._send_reply(context, Reply(ReplyType.TEXT, text))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[chat_channel] /cancel fast-path failed: {e}")
|
||||||
|
|
||||||
# 消费者函数,单独线程,用于从消息队列中取出消息并处理
|
# 消费者函数,单独线程,用于从消息队列中取出消息并处理
|
||||||
def consume(self):
|
def consume(self):
|
||||||
while True:
|
while True:
|
||||||
@@ -468,7 +519,10 @@ class ChatChannel(Channel):
|
|||||||
def cancel_session(self, session_id):
|
def cancel_session(self, session_id):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if session_id in self.sessions:
|
if session_id in self.sessions:
|
||||||
for future in self.futures[session_id]:
|
# futures[session_id] is only created in consume() when a task is
|
||||||
|
# dispatched, so it may be absent if cancel happens right after
|
||||||
|
# produce() but before the first dispatch. Default to [].
|
||||||
|
for future in self.futures.get(session_id, []):
|
||||||
future.cancel()
|
future.cancel()
|
||||||
cnt = self.sessions[session_id][0].qsize()
|
cnt = self.sessions[session_id][0].qsize()
|
||||||
if cnt > 0:
|
if cnt > 0:
|
||||||
@@ -478,7 +532,7 @@ class ChatChannel(Channel):
|
|||||||
def cancel_all_session(self):
|
def cancel_all_session(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
for session_id in self.sessions:
|
for session_id in self.sessions:
|
||||||
for future in self.futures[session_id]:
|
for future in self.futures.get(session_id, []):
|
||||||
future.cancel()
|
future.cancel()
|
||||||
cnt = self.sessions[session_id][0].qsize()
|
cnt = self.sessions[session_id][0].qsize()
|
||||||
if cnt > 0:
|
if cnt > 0:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
本类表示聊天消息,用于对itchat和wechaty的消息进行统一的封装。
|
Unified chat message class for different channel implementations.
|
||||||
|
|
||||||
填好必填项(群聊6个,非群聊8个),即可接入ChatChannel,并支持插件,参考TerminalChannel
|
填好必填项(群聊6个,非群聊8个),即可接入ChatChannel,并支持插件,参考TerminalChannel
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ def _check(func):
|
|||||||
|
|
||||||
@singleton
|
@singleton
|
||||||
class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
|
class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
|
||||||
|
NOT_SUPPORT_REPLYTYPE = []
|
||||||
|
|
||||||
dingtalk_client_id = conf().get('dingtalk_client_id')
|
dingtalk_client_id = conf().get('dingtalk_client_id')
|
||||||
dingtalk_client_secret = conf().get('dingtalk_client_secret')
|
dingtalk_client_secret = conf().get('dingtalk_client_secret')
|
||||||
|
|
||||||
@@ -115,6 +117,35 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
|
|||||||
# Robot code cache (extracted from incoming messages)
|
# Robot code cache (extracted from incoming messages)
|
||||||
self._robot_code = None
|
self._robot_code = None
|
||||||
|
|
||||||
|
def _open_connection(self, client):
|
||||||
|
"""
|
||||||
|
Open a DingTalk stream connection directly, bypassing SDK's internal error-swallowing.
|
||||||
|
Returns (connection_dict, error_str). On success error_str is empty; on failure
|
||||||
|
connection_dict is None and error_str contains a human-readable message.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
"https://api.dingtalk.com/v1.0/gateway/connections/open",
|
||||||
|
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||||
|
json={
|
||||||
|
"clientId": client.credential.client_id,
|
||||||
|
"clientSecret": client.credential.client_secret,
|
||||||
|
"subscriptions": [{"type": "CALLBACK",
|
||||||
|
"topic": dingtalk_stream.chatbot.ChatbotMessage.TOPIC}],
|
||||||
|
"ua": "dingtalk-sdk-python/cow",
|
||||||
|
"localIp": "",
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
body = resp.json()
|
||||||
|
if not resp.ok:
|
||||||
|
code = body.get("code", resp.status_code)
|
||||||
|
message = body.get("message", resp.reason)
|
||||||
|
return None, f"open connection failed: [{code}] {message}"
|
||||||
|
return body, ""
|
||||||
|
except Exception as e:
|
||||||
|
return None, f"open connection failed: {e}"
|
||||||
|
|
||||||
def startup(self):
|
def startup(self):
|
||||||
import asyncio
|
import asyncio
|
||||||
self.dingtalk_client_id = conf().get('dingtalk_client_id')
|
self.dingtalk_client_id = conf().get('dingtalk_client_id')
|
||||||
@@ -125,34 +156,80 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
|
|||||||
self._stream_client = client
|
self._stream_client = client
|
||||||
client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self)
|
client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self)
|
||||||
logger.info("[DingTalk] ✅ Stream client initialized, ready to receive messages")
|
logger.info("[DingTalk] ✅ Stream client initialized, ready to receive messages")
|
||||||
|
|
||||||
|
# Run the connection loop ourselves instead of delegating to client.start(),
|
||||||
|
# so we can get detailed error messages and respond to stop() quickly.
|
||||||
|
import urllib.parse as _urlparse
|
||||||
|
import websockets as _ws
|
||||||
|
import json as _json
|
||||||
|
client.pre_start()
|
||||||
_first_connect = True
|
_first_connect = True
|
||||||
while self._running:
|
while self._running:
|
||||||
|
# Open connection using our own request so we get detailed error info.
|
||||||
|
connection, err_msg = self._open_connection(client)
|
||||||
|
|
||||||
|
if connection is None:
|
||||||
|
if _first_connect:
|
||||||
|
logger.warning(f"[DingTalk] {err_msg}")
|
||||||
|
self.report_startup_error(err_msg)
|
||||||
|
_first_connect = False
|
||||||
|
else:
|
||||||
|
logger.warning(f"[DingTalk] {err_msg}, retrying in 10s...")
|
||||||
|
|
||||||
|
# Interruptible sleep: checks _running every 100ms.
|
||||||
|
for _ in range(100):
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if _first_connect:
|
||||||
|
logger.info("[DingTalk] ✅ Connected to DingTalk stream")
|
||||||
|
self.report_startup_success()
|
||||||
|
_first_connect = False
|
||||||
|
else:
|
||||||
|
logger.info("[DingTalk] Reconnected to DingTalk stream")
|
||||||
|
|
||||||
|
# Run the WebSocket session in an asyncio loop.
|
||||||
|
uri = '%s?ticket=%s' % (
|
||||||
|
connection['endpoint'],
|
||||||
|
_urlparse.quote_plus(connection['ticket'])
|
||||||
|
)
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
self._event_loop = loop
|
self._event_loop = loop
|
||||||
try:
|
try:
|
||||||
if not _first_connect:
|
async def _session():
|
||||||
logger.info("[DingTalk] Reconnecting...")
|
async with _ws.connect(uri) as websocket:
|
||||||
_first_connect = False
|
client.websocket = websocket
|
||||||
loop.run_until_complete(client.start())
|
async for raw_message in websocket:
|
||||||
|
json_message = _json.loads(raw_message)
|
||||||
|
result = await client.route_message(json_message)
|
||||||
|
if result == dingtalk_stream.DingTalkStreamClient.TAG_DISCONNECT:
|
||||||
|
break
|
||||||
|
|
||||||
|
loop.run_until_complete(_session())
|
||||||
except (KeyboardInterrupt, SystemExit):
|
except (KeyboardInterrupt, SystemExit):
|
||||||
logger.info("[DingTalk] Startup loop received stop signal, exiting")
|
logger.info("[DingTalk] Session loop received stop signal, exiting")
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if not self._running:
|
if not self._running:
|
||||||
break
|
break
|
||||||
logger.warning(f"[DingTalk] Stream connection error: {e}, reconnecting in 3s...")
|
logger.warning(f"[DingTalk] Stream session error: {e}, reconnecting in 3s...")
|
||||||
time.sleep(3)
|
for _ in range(30):
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
finally:
|
finally:
|
||||||
self._event_loop = None
|
self._event_loop = None
|
||||||
try:
|
try:
|
||||||
loop.close()
|
loop.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
logger.info("[DingTalk] Startup loop exited")
|
logger.info("[DingTalk] Startup loop exited")
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
import asyncio
|
|
||||||
logger.info("[DingTalk] stop() called, setting _running=False")
|
logger.info("[DingTalk] stop() called, setting _running=False")
|
||||||
self._running = False
|
self._running = False
|
||||||
loop = self._event_loop
|
loop = self._event_loop
|
||||||
@@ -795,6 +872,48 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
|
|||||||
self.reply_text("抱歉,文件上传失败", incoming_message)
|
self.reply_text("抱歉,文件上传失败", incoming_message)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Native sampleAudio. Upload only accepts ogg/amr, so convert TTS mp3/wav to amr.
|
||||||
|
elif reply.type == ReplyType.VOICE:
|
||||||
|
logger.info(f"[DingTalk] Sending voice: {reply.content}")
|
||||||
|
access_token = self.get_access_token()
|
||||||
|
if not access_token:
|
||||||
|
logger.error("[DingTalk] Cannot get access token for voice")
|
||||||
|
self.reply_text("抱歉,语音发送失败(无法获取token)", incoming_message)
|
||||||
|
return
|
||||||
|
|
||||||
|
voice_path = reply.content
|
||||||
|
if voice_path.startswith("file://"):
|
||||||
|
voice_path = voice_path[7:]
|
||||||
|
|
||||||
|
amr_path = voice_path
|
||||||
|
duration_ms = 0
|
||||||
|
if not voice_path.lower().endswith((".amr", ".ogg")):
|
||||||
|
try:
|
||||||
|
from voice.audio_convert import any_to_amr
|
||||||
|
amr_path = os.path.splitext(voice_path)[0] + ".amr"
|
||||||
|
duration_ms = int(any_to_amr(voice_path, amr_path) or 0)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DingTalk] Failed to convert voice to amr: {e}")
|
||||||
|
self.reply_text("抱歉,语音转码失败", incoming_message)
|
||||||
|
return
|
||||||
|
|
||||||
|
media_id = self.upload_media(amr_path, media_type="voice")
|
||||||
|
if not media_id:
|
||||||
|
logger.error("[DingTalk] Failed to upload voice media")
|
||||||
|
self.reply_text("抱歉,语音上传失败", incoming_message)
|
||||||
|
return
|
||||||
|
|
||||||
|
msg_param = {
|
||||||
|
"mediaId": media_id,
|
||||||
|
"duration": str(duration_ms or 1000),
|
||||||
|
}
|
||||||
|
success = self._send_file_message(
|
||||||
|
access_token, incoming_message, "sampleAudio", msg_param, isgroup
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
self.reply_text("抱歉,语音发送失败", incoming_message)
|
||||||
|
return
|
||||||
|
|
||||||
# 处理文本消息
|
# 处理文本消息
|
||||||
elif reply.type == ReplyType.TEXT:
|
elif reply.type == ReplyType.TEXT:
|
||||||
logger.info(f"[DingTalk] Sending text message, length={len(reply.content)}")
|
logger.info(f"[DingTalk] Sending text message, length={len(reply.content)}")
|
||||||
|
|||||||
0
channel/discord/__init__.py
Normal file
0
channel/discord/__init__.py
Normal file
500
channel/discord/discord_channel.py
Normal file
500
channel/discord/discord_channel.py
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
"""
|
||||||
|
Discord channel via the Gateway (WebSocket) using discord.py.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Direct message & guild channel chat (text / image / file)
|
||||||
|
- Guild trigger: @mention or reply-to-bot (configurable)
|
||||||
|
- /cancel fast-path matches Web channel behaviour
|
||||||
|
- Gateway long connection: no public IP / callback URL required, works behind NAT
|
||||||
|
|
||||||
|
Implementation note:
|
||||||
|
discord.py is async-first. We run the client inside a dedicated thread
|
||||||
|
with its own asyncio loop so the rest of cow (which is sync) stays
|
||||||
|
untouched. Inbound messages are dispatched onto cow's existing sync
|
||||||
|
ChatChannel.produce() pipeline; outbound send() schedules coroutines
|
||||||
|
back onto that loop via asyncio.run_coroutine_threadsafe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from bridge.context import Context, ContextType
|
||||||
|
from bridge.reply import Reply, ReplyType
|
||||||
|
from channel.chat_channel import ChatChannel, check_prefix
|
||||||
|
from channel.discord.discord_message import DiscordMessage
|
||||||
|
from common.expired_dict import ExpiredDict
|
||||||
|
from common.log import logger
|
||||||
|
from common.singleton import singleton
|
||||||
|
from config import conf
|
||||||
|
|
||||||
|
# Discord caps a single message at 2000 chars; split conservatively below.
|
||||||
|
DISCORD_MSG_LIMIT = 1900
|
||||||
|
|
||||||
|
|
||||||
|
@singleton
|
||||||
|
class DiscordChannel(ChatChannel):
|
||||||
|
NOT_SUPPORT_REPLYTYPE = []
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.bot_token = ""
|
||||||
|
self.bot_user_id = "" # used to strip @mention and ignore self messages
|
||||||
|
self.bot_username = ""
|
||||||
|
self._client = None
|
||||||
|
self._loop = None
|
||||||
|
self._loop_thread = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
# Idempotent dedup; guard against rare duplicate dispatch
|
||||||
|
self._received_msgs = ExpiredDict(60 * 60 * 1)
|
||||||
|
|
||||||
|
# Disable group whitelist / prefix checks (we handle triggering ourselves
|
||||||
|
# in _should_reply_in_guild), aligned with telegram / slack channels.
|
||||||
|
conf()["group_name_white_list"] = ["ALL_GROUP"]
|
||||||
|
conf()["single_chat_prefix"] = [""]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def startup(self):
|
||||||
|
self.bot_token = conf().get("discord_token", "")
|
||||||
|
if not self.bot_token:
|
||||||
|
err = "[Discord] discord_token is required"
|
||||||
|
logger.error(err)
|
||||||
|
self.report_startup_error(err)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
import discord
|
||||||
|
except ImportError:
|
||||||
|
err = (
|
||||||
|
"[Discord] discord.py is not installed. "
|
||||||
|
"Run: pip install discord.py"
|
||||||
|
)
|
||||||
|
logger.error(err)
|
||||||
|
self.report_startup_error(err)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Run the asyncio event loop in a dedicated thread so the sync cow body
|
||||||
|
# is untouched.
|
||||||
|
self._loop = asyncio.new_event_loop()
|
||||||
|
|
||||||
|
def _run_loop():
|
||||||
|
asyncio.set_event_loop(self._loop)
|
||||||
|
try:
|
||||||
|
self._loop.run_until_complete(self._async_main(discord))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Discord] event loop crashed: {e}", exc_info=True)
|
||||||
|
self.report_startup_error(str(e))
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
self._loop.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info("[Discord] event loop exited")
|
||||||
|
|
||||||
|
self._loop_thread = threading.Thread(target=_run_loop, daemon=True, name="discord-loop")
|
||||||
|
self._loop_thread.start()
|
||||||
|
# Block startup() until the loop thread exits, matching other channels'
|
||||||
|
# behaviour (startup is a blocking call).
|
||||||
|
self._loop_thread.join()
|
||||||
|
|
||||||
|
async def _async_main(self, discord):
|
||||||
|
"""Build the discord client, register handlers, and connect to the Gateway."""
|
||||||
|
# message_content is a privileged intent; it must be enabled in the
|
||||||
|
# Developer Portal (Bot -> Privileged Gateway Intents) to read text.
|
||||||
|
intents = discord.Intents.default()
|
||||||
|
intents.message_content = True
|
||||||
|
client = discord.Client(intents=intents)
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
channel = self
|
||||||
|
|
||||||
|
@client.event
|
||||||
|
async def on_ready():
|
||||||
|
channel.bot_user_id = str(client.user.id)
|
||||||
|
channel.bot_username = client.user.name or ""
|
||||||
|
channel.name = channel.bot_user_id # ChatChannel uses self.name to strip @-mention
|
||||||
|
logger.info(f"[Discord] Bot logged in as {client.user} (id={client.user.id})")
|
||||||
|
channel.report_startup_success()
|
||||||
|
logger.info("[Discord] ✅ Discord bot ready, listening for messages")
|
||||||
|
|
||||||
|
@client.event
|
||||||
|
async def on_message(message):
|
||||||
|
await channel._on_message(message)
|
||||||
|
|
||||||
|
# Connect to the Gateway; discord.py auto-reconnects on transient errors.
|
||||||
|
logger.info("[Discord] Connecting to Gateway...")
|
||||||
|
|
||||||
|
# client.start() handles login + Gateway connection and runs until
|
||||||
|
# close(); it is the standard entrypoint across discord.py versions.
|
||||||
|
runner_task = asyncio.create_task(client.start(self.bot_token))
|
||||||
|
|
||||||
|
# Block until stop()
|
||||||
|
try:
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
if runner_task.done():
|
||||||
|
# Surface a startup/connection failure (e.g. bad token)
|
||||||
|
exc = runner_task.exception()
|
||||||
|
if exc:
|
||||||
|
logger.error(f"[Discord] client stopped: {exc}", exc_info=exc)
|
||||||
|
self.report_startup_error(str(exc))
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if not client.is_closed():
|
||||||
|
await client.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[Discord] shutdown error: {e}")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
logger.info("[Discord] stop() called")
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._loop_thread and self._loop_thread.is_alive():
|
||||||
|
try:
|
||||||
|
self._loop_thread.join(timeout=10)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info("[Discord] stop() completed")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Inbound: discord message -> ChatMessage -> ChatChannel.produce
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _on_message(self, message):
|
||||||
|
"""Discord message entry: parse -> build ChatMessage -> produce()."""
|
||||||
|
try:
|
||||||
|
# Ignore our own messages and other bots. self._client.user may be
|
||||||
|
# None until on_ready completes, so guard against that.
|
||||||
|
if self._client and self._client.user and message.author.id == self._client.user.id:
|
||||||
|
return
|
||||||
|
if message.author.bot:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Idempotent dedup
|
||||||
|
msg_uid = f"{message.channel.id}:{message.id}"
|
||||||
|
if self._received_msgs.get(msg_uid):
|
||||||
|
return
|
||||||
|
self._received_msgs[msg_uid] = True
|
||||||
|
|
||||||
|
# guild is None for DMs
|
||||||
|
is_group = message.guild is not None
|
||||||
|
|
||||||
|
# Guild trigger gate (silently drop if not triggered)
|
||||||
|
if is_group and not self._should_reply_in_guild(message):
|
||||||
|
logger.debug(f"[Discord] guild message not triggered (need @mention or reply), skip")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Parse message type + download attachments if needed.
|
||||||
|
ctype, content, caption = await self._parse_message(message)
|
||||||
|
if ctype is None:
|
||||||
|
logger.debug(f"[Discord] unsupported message type, skip. msg_id={message.id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Strip the bot mention from guild text/caption
|
||||||
|
if is_group:
|
||||||
|
if ctype == ContextType.TEXT and content:
|
||||||
|
content = self._strip_at_mention(content)
|
||||||
|
if caption:
|
||||||
|
caption = self._strip_at_mention(caption)
|
||||||
|
|
||||||
|
dc_msg = DiscordMessage(
|
||||||
|
message,
|
||||||
|
is_group=is_group,
|
||||||
|
bot_user_id=self.bot_user_id,
|
||||||
|
ctype=ctype,
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
dc_msg.is_at = is_group # if we reached here in a guild, bot is mentioned/replied
|
||||||
|
|
||||||
|
from channel.file_cache import get_file_cache
|
||||||
|
file_cache = get_file_cache()
|
||||||
|
session_id = self._compute_session_id(message, is_group)
|
||||||
|
|
||||||
|
# Media + caption together: treat as a complete query and bypass the cache
|
||||||
|
if ctype in (ContextType.IMAGE, ContextType.FILE) and caption:
|
||||||
|
tag = "image" if ctype == ContextType.IMAGE else "file"
|
||||||
|
merged_text = f"{caption}\n[{tag}: {content}]"
|
||||||
|
dc_msg.ctype = ContextType.TEXT
|
||||||
|
dc_msg.content = merged_text
|
||||||
|
ctype = ContextType.TEXT
|
||||||
|
logger.info(f"[Discord] Media+caption merged for session {session_id}")
|
||||||
|
# fallthrough to the TEXT branch below
|
||||||
|
|
||||||
|
elif ctype == ContextType.IMAGE:
|
||||||
|
file_cache.add(session_id, content, file_type="image")
|
||||||
|
logger.info(f"[Discord] Image cached for session {session_id}, waiting for query...")
|
||||||
|
return
|
||||||
|
elif ctype == ContextType.FILE:
|
||||||
|
file_cache.add(session_id, content, file_type="file")
|
||||||
|
logger.info(f"[Discord] File cached for session {session_id}: {content}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if ctype == ContextType.TEXT:
|
||||||
|
# Fast-path: /cancel mirrors Web channel behaviour
|
||||||
|
if (content or "").strip().lower() in ("/cancel", "cancel"):
|
||||||
|
await self._do_cancel(session_id, message)
|
||||||
|
return
|
||||||
|
|
||||||
|
cached_files = file_cache.get(session_id)
|
||||||
|
if cached_files:
|
||||||
|
refs = []
|
||||||
|
for fi in cached_files:
|
||||||
|
ftype = fi["type"]
|
||||||
|
tag = ftype if ftype in ("image", "video") else "file"
|
||||||
|
refs.append(f"[{tag}: {fi['path']}]")
|
||||||
|
dc_msg.content = (dc_msg.content or "") + "\n" + "\n".join(refs)
|
||||||
|
file_cache.clear(session_id)
|
||||||
|
logger.info(f"[Discord] Attached {len(cached_files)} cached file(s) to query")
|
||||||
|
|
||||||
|
context = self._compose_context(
|
||||||
|
dc_msg.ctype,
|
||||||
|
dc_msg.content,
|
||||||
|
isgroup=is_group,
|
||||||
|
msg=dc_msg,
|
||||||
|
# Replies use Discord's reply mechanism, no manual @mention needed
|
||||||
|
no_need_at=True,
|
||||||
|
)
|
||||||
|
if context:
|
||||||
|
context["session_id"] = session_id
|
||||||
|
context["receiver"] = str(message.channel.id)
|
||||||
|
context["discord_channel_id"] = message.channel.id
|
||||||
|
context["discord_reply_to_msg_id"] = message.id if is_group else None
|
||||||
|
self.produce(context)
|
||||||
|
logger.debug(f"[Discord] received: type={ctype}, content={str(dc_msg.content)[:80]}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Discord] _on_message error: {e}", exc_info=True)
|
||||||
|
|
||||||
|
async def _do_cancel(self, session_id: str, message):
|
||||||
|
"""Fast-path: /cancel calls cancel_session directly without going through agent."""
|
||||||
|
try:
|
||||||
|
from agent.protocol import get_cancel_registry
|
||||||
|
cancelled = get_cancel_registry().cancel_session(session_id)
|
||||||
|
text = "Current task cancelled." if cancelled else "No running task to cancel."
|
||||||
|
await message.channel.send(text)
|
||||||
|
logger.info(f"[Discord] /cancel session={session_id}, cancelled={cancelled}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Discord] /cancel error: {e}", exc_info=True)
|
||||||
|
|
||||||
|
async def _parse_message(self, message):
|
||||||
|
"""Parse a discord message and return (ctype, content, caption).
|
||||||
|
|
||||||
|
- content is text for ContextType.TEXT, otherwise the local file path
|
||||||
|
- caption is the optional text accompanying an attachment; empty for plain text
|
||||||
|
"""
|
||||||
|
text = (message.content or "").strip()
|
||||||
|
attachments = message.attachments or []
|
||||||
|
|
||||||
|
if attachments:
|
||||||
|
# Handle the first attachment; caption is the accompanying message text
|
||||||
|
att = attachments[0]
|
||||||
|
content_type = (att.content_type or "").lower()
|
||||||
|
name = att.filename or str(att.id)
|
||||||
|
path = await self._download_attachment(att, name)
|
||||||
|
if not path:
|
||||||
|
return (None, None, "")
|
||||||
|
is_image = content_type.startswith("image/") or name.lower().endswith(
|
||||||
|
(".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp")
|
||||||
|
)
|
||||||
|
if is_image:
|
||||||
|
return (ContextType.IMAGE, path, text)
|
||||||
|
return (ContextType.FILE, path, text)
|
||||||
|
|
||||||
|
if text:
|
||||||
|
return (ContextType.TEXT, text, "")
|
||||||
|
|
||||||
|
return (None, None, "")
|
||||||
|
|
||||||
|
async def _download_attachment(self, attachment, name: str):
|
||||||
|
"""Download a discord attachment into the local tmp dir; return path or None."""
|
||||||
|
try:
|
||||||
|
tmp_dir = DiscordMessage.get_tmp_dir()
|
||||||
|
safe_name = re.sub(r"[^\w.\-]", "_", name)
|
||||||
|
# Prefix with attachment id to avoid name collisions
|
||||||
|
local_path = os.path.join(tmp_dir, f"{attachment.id}_{safe_name}")
|
||||||
|
await attachment.save(local_path)
|
||||||
|
logger.debug(f"[Discord] downloaded {name} -> {local_path}")
|
||||||
|
return local_path
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Discord] download_attachment failed ({name}): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Guild trigger logic
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _should_reply_in_guild(self, message) -> bool:
|
||||||
|
"""Decide whether to reply to a guild channel message based on configuration."""
|
||||||
|
mode = conf().get("discord_group_trigger", "mention_or_reply")
|
||||||
|
if mode == "all":
|
||||||
|
return True
|
||||||
|
|
||||||
|
# self._client.user may be None until on_ready completes
|
||||||
|
if not self._client or not self._client.user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 1) Mentioned (direct @bot, not @everyone / @role)
|
||||||
|
if self._client.user in message.mentions:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 2) Reply to a bot message
|
||||||
|
if mode == "mention_or_reply":
|
||||||
|
ref = message.reference
|
||||||
|
resolved = getattr(ref, "resolved", None) if ref else None
|
||||||
|
if resolved and getattr(resolved, "author", None):
|
||||||
|
if resolved.author.id == self._client.user.id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _strip_at_mention(self, content: str) -> str:
|
||||||
|
"""Strip <@BOT_ID> / <@!BOT_ID> from guild text."""
|
||||||
|
if not content or not self.bot_user_id:
|
||||||
|
return content
|
||||||
|
pattern = re.compile(r"<@!?" + re.escape(self.bot_user_id) + r">")
|
||||||
|
return pattern.sub("", content).strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _compute_session_id(message, is_group: bool) -> str:
|
||||||
|
channel_id = message.channel.id
|
||||||
|
user_id = message.author.id
|
||||||
|
if is_group:
|
||||||
|
if conf().get("group_shared_session", True):
|
||||||
|
return f"discord_channel_{channel_id}"
|
||||||
|
return f"discord_channel_{channel_id}_{user_id}"
|
||||||
|
return f"discord_user_{user_id}"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Override _compose_context: skip the parent's group whitelist/at checks
|
||||||
|
# (already handled via _should_reply_in_guild). Same idea as telegram / slack.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _compose_context(self, ctype: ContextType, content, **kwargs):
|
||||||
|
context = Context(ctype, content)
|
||||||
|
context.kwargs = kwargs
|
||||||
|
if "channel_type" not in context:
|
||||||
|
context["channel_type"] = self.channel_type
|
||||||
|
if "origin_ctype" not in context:
|
||||||
|
context["origin_ctype"] = ctype
|
||||||
|
|
||||||
|
cmsg = context["msg"]
|
||||||
|
if cmsg.is_group:
|
||||||
|
if conf().get("group_shared_session", True):
|
||||||
|
context["session_id"] = cmsg.other_user_id
|
||||||
|
else:
|
||||||
|
context["session_id"] = f"{cmsg.from_user_id}:{cmsg.other_user_id}"
|
||||||
|
else:
|
||||||
|
context["session_id"] = cmsg.from_user_id
|
||||||
|
context["receiver"] = cmsg.other_user_id
|
||||||
|
|
||||||
|
if ctype == ContextType.TEXT:
|
||||||
|
img_match_prefix = check_prefix(content, conf().get("image_create_prefix"))
|
||||||
|
if img_match_prefix:
|
||||||
|
content = content.replace(img_match_prefix, "", 1)
|
||||||
|
context.type = ContextType.IMAGE_CREATE
|
||||||
|
else:
|
||||||
|
context.type = ContextType.TEXT
|
||||||
|
context.content = (content or "").strip()
|
||||||
|
if "desire_rtype" not in context and conf().get("always_reply_voice"):
|
||||||
|
context["desire_rtype"] = ReplyType.VOICE
|
||||||
|
elif ctype == ContextType.VOICE:
|
||||||
|
if "desire_rtype" not in context and (
|
||||||
|
conf().get("voice_reply_voice") or conf().get("always_reply_voice")
|
||||||
|
):
|
||||||
|
context["desire_rtype"] = ReplyType.VOICE
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Outbound: ChatChannel.send -> Discord Gateway/REST
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def send(self, reply: Reply, context: Context):
|
||||||
|
"""Called from cow's sync main thread; marshal the coroutine onto the loop thread."""
|
||||||
|
if self._loop is None or self._client is None:
|
||||||
|
logger.warning("[Discord] client not ready, drop reply")
|
||||||
|
return
|
||||||
|
|
||||||
|
channel_id = context.get("discord_channel_id")
|
||||||
|
if channel_id is None:
|
||||||
|
logger.warning("[Discord] no discord_channel_id in context, drop reply")
|
||||||
|
return
|
||||||
|
|
||||||
|
coro = self._async_send(reply, channel_id)
|
||||||
|
try:
|
||||||
|
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
||||||
|
future.result(timeout=180)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Discord] send failed: {e}")
|
||||||
|
|
||||||
|
async def _async_send(self, reply: Reply, channel_id):
|
||||||
|
try:
|
||||||
|
import discord
|
||||||
|
|
||||||
|
channel = self._client.get_channel(channel_id)
|
||||||
|
if channel is None:
|
||||||
|
# Not in cache (e.g. DM channel); fetch it explicitly
|
||||||
|
channel = await self._client.fetch_channel(channel_id)
|
||||||
|
|
||||||
|
rtype = reply.type
|
||||||
|
content = reply.content
|
||||||
|
|
||||||
|
if rtype in (ReplyType.TEXT, ReplyType.INFO, ReplyType.ERROR):
|
||||||
|
text = str(content) if content is not None else ""
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
for chunk in _split_text(text, DISCORD_MSG_LIMIT):
|
||||||
|
await channel.send(chunk)
|
||||||
|
|
||||||
|
elif rtype == ReplyType.IMAGE:
|
||||||
|
# Already a local BytesIO; send it directly
|
||||||
|
content.seek(0)
|
||||||
|
await channel.send(file=discord.File(content, filename="image.png"))
|
||||||
|
|
||||||
|
elif rtype == ReplyType.IMAGE_URL:
|
||||||
|
url = str(content)
|
||||||
|
if url.startswith("file://"):
|
||||||
|
local = url[7:]
|
||||||
|
await channel.send(file=discord.File(local))
|
||||||
|
else:
|
||||||
|
# Post the URL as text; Discord will unfurl it as an image preview
|
||||||
|
await channel.send(url)
|
||||||
|
|
||||||
|
elif rtype in (ReplyType.VOICE, ReplyType.FILE):
|
||||||
|
local = content[7:] if isinstance(content, str) and content.startswith("file://") else content
|
||||||
|
caption = getattr(reply, "text_content", None) or None
|
||||||
|
await channel.send(content=caption, file=discord.File(local))
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Fallback: send as plain text
|
||||||
|
await channel.send(str(content))
|
||||||
|
|
||||||
|
logger.info(f"[Discord] sent reply (type={rtype}, channel={channel_id})")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Discord] _async_send error: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_text(text: str, limit: int):
|
||||||
|
"""Split long text preferring line breaks to keep markdown structure intact."""
|
||||||
|
if len(text) <= limit:
|
||||||
|
yield text
|
||||||
|
return
|
||||||
|
buf = []
|
||||||
|
size = 0
|
||||||
|
for line in text.splitlines(keepends=True):
|
||||||
|
if size + len(line) > limit and buf:
|
||||||
|
yield "".join(buf)
|
||||||
|
buf, size = [], 0
|
||||||
|
# Hard-split single lines that exceed the limit
|
||||||
|
while len(line) > limit:
|
||||||
|
yield line[:limit]
|
||||||
|
line = line[limit:]
|
||||||
|
buf.append(line)
|
||||||
|
size += len(line)
|
||||||
|
if buf:
|
||||||
|
yield "".join(buf)
|
||||||
60
channel/discord/discord_message.py
Normal file
60
channel/discord/discord_message.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Discord message adapter.
|
||||||
|
|
||||||
|
Convert a discord.py Message into cow's unified ChatMessage.
|
||||||
|
File downloads are NOT performed here; the channel layer downloads
|
||||||
|
attachments on demand inside the async event loop.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from bridge.context import ContextType
|
||||||
|
from channel.chat_message import ChatMessage
|
||||||
|
from common.utils import expand_path
|
||||||
|
from config import conf
|
||||||
|
|
||||||
|
|
||||||
|
class DiscordMessage(ChatMessage):
|
||||||
|
"""Wrap a discord.py Message into the unified ChatMessage."""
|
||||||
|
|
||||||
|
def __init__(self, message, is_group: bool = False, bot_user_id: str = "",
|
||||||
|
ctype: ContextType = ContextType.TEXT, content: str = ""):
|
||||||
|
super().__init__(message)
|
||||||
|
# Basic fields
|
||||||
|
self.msg_id = str(message.id)
|
||||||
|
self.create_time = int(message.created_at.timestamp()) if message.created_at else 0
|
||||||
|
self.ctype = ctype
|
||||||
|
self.content = content
|
||||||
|
|
||||||
|
author = message.author
|
||||||
|
channel = message.channel
|
||||||
|
|
||||||
|
# Sender / chat info
|
||||||
|
from_user_id = str(author.id)
|
||||||
|
from_user_nick = getattr(author, "display_name", None) or getattr(author, "name", None) or from_user_id
|
||||||
|
self.from_user_id = from_user_id
|
||||||
|
self.from_user_nickname = from_user_nick
|
||||||
|
self.to_user_id = bot_user_id or "discord_bot"
|
||||||
|
self.to_user_nickname = bot_user_id or "discord_bot"
|
||||||
|
|
||||||
|
self.is_group = is_group
|
||||||
|
if is_group:
|
||||||
|
# Guild channel: other_user_id = channel_id, actual_user_id = sender id
|
||||||
|
self.other_user_id = str(channel.id)
|
||||||
|
self.other_user_nickname = getattr(channel, "name", None) or str(channel.id)
|
||||||
|
self.actual_user_id = from_user_id
|
||||||
|
self.actual_user_nickname = from_user_nick
|
||||||
|
else:
|
||||||
|
# DM: use channel_id so replies go back to the same DM channel
|
||||||
|
self.other_user_id = str(channel.id)
|
||||||
|
self.other_user_nickname = from_user_nick
|
||||||
|
|
||||||
|
# Whether the bot was triggered by @-mention (set by channel layer)
|
||||||
|
self.is_at = False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_tmp_dir() -> str:
|
||||||
|
"""Local download directory, aligned with other channels (agent_workspace/tmp)."""
|
||||||
|
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
|
tmp_dir = os.path.join(workspace_root, "tmp")
|
||||||
|
os.makedirs(tmp_dir, exist_ok=True)
|
||||||
|
return tmp_dir
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
@Date 2023/11/19
|
@Date 2023/11/19
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -38,15 +39,190 @@ logging.getLogger("Lark").setLevel(logging.WARNING)
|
|||||||
|
|
||||||
URL_VERIFICATION = "url_verification"
|
URL_VERIFICATION = "url_verification"
|
||||||
|
|
||||||
# 尝试导入飞书SDK,如果未安装则websocket模式不可用
|
# Lazy-check for lark_oapi SDK availability without importing it at module level.
|
||||||
try:
|
# The full `import lark_oapi` pulls in 10k+ files and takes 4-10s, so we defer
|
||||||
import lark_oapi as lark
|
# the actual import to _startup_websocket() where it is needed.
|
||||||
|
LARK_SDK_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
|
||||||
|
lark = None # will be populated on first use via _ensure_lark_imported()
|
||||||
|
|
||||||
LARK_SDK_AVAILABLE = True
|
|
||||||
except ImportError:
|
def _ensure_lark_imported():
|
||||||
LARK_SDK_AVAILABLE = False
|
"""Import lark_oapi on first use (takes 4-10s due to 10k+ source files)."""
|
||||||
logger.warning(
|
global lark
|
||||||
"[FeiShu] lark_oapi not installed, websocket mode is not available. Install with: pip install lark-oapi")
|
if lark is None:
|
||||||
|
import lark_oapi as _lark
|
||||||
|
lark = _lark
|
||||||
|
return lark
|
||||||
|
|
||||||
|
|
||||||
|
def _print_qr_to_terminal(qr_url: str):
|
||||||
|
"""Render a QR code as ASCII art and emit it via logger.
|
||||||
|
|
||||||
|
走 logger 而非 print 是为了避免 nohup/cow 后台启动场景下 stdout 块缓冲导致
|
||||||
|
二维码滞后输出(看起来像出现了两次)。logger 的 StreamHandler 是行缓冲,
|
||||||
|
既能在前台终端看到,也能进 run.log。
|
||||||
|
"""
|
||||||
|
qr_lines = []
|
||||||
|
try:
|
||||||
|
import qrcode as qr_lib
|
||||||
|
import io
|
||||||
|
qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L, box_size=1, border=1)
|
||||||
|
qr.add_data(qr_url)
|
||||||
|
qr.make(fit=True)
|
||||||
|
buf = io.StringIO()
|
||||||
|
qr.print_ascii(out=buf, invert=True)
|
||||||
|
qr_lines = buf.getvalue().splitlines()
|
||||||
|
except ImportError:
|
||||||
|
qr_lines = ["(未安装 qrcode 包,无法渲染 ASCII 二维码:pip install qrcode)"]
|
||||||
|
except Exception as e:
|
||||||
|
qr_lines = [f"(渲染二维码失败:{e})"]
|
||||||
|
|
||||||
|
header = "=" * 60
|
||||||
|
banner = [
|
||||||
|
"",
|
||||||
|
header,
|
||||||
|
" 飞书一键创建应用:请使用 飞书 App 扫描下方二维码",
|
||||||
|
" (二维码 10 分钟内有效,仅供一次扫描)",
|
||||||
|
header,
|
||||||
|
]
|
||||||
|
footer = [
|
||||||
|
f" 或点击链接创建: {qr_url}",
|
||||||
|
" 等待扫码...",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
full = banner + qr_lines + footer
|
||||||
|
logger.info("[FeiShu] One-click 飞书应用创建二维码(请用飞书 App 扫码):\n" + "\n".join(full))
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_feishu_credentials(app_id: str, app_secret: str) -> bool:
|
||||||
|
"""Write feishu_app_id / feishu_app_secret + ensure feishu in channel_type into config.json.
|
||||||
|
|
||||||
|
Returns True on success, False on failure (e.g. config.json missing or unwritable).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config_path = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
|
"config.json",
|
||||||
|
)
|
||||||
|
if os.path.exists(config_path):
|
||||||
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
|
file_cfg = json.load(f)
|
||||||
|
else:
|
||||||
|
file_cfg = {}
|
||||||
|
|
||||||
|
file_cfg["feishu_app_id"] = app_id
|
||||||
|
file_cfg["feishu_app_secret"] = app_secret
|
||||||
|
|
||||||
|
# 保证 channel_type 中包含 feishu(用户可能纯通过 CLI 启动单通道)
|
||||||
|
ch_type = file_cfg.get("channel_type", conf().get("channel_type", "")) or ""
|
||||||
|
existing = [s.strip() for s in ch_type.split(",") if s.strip()]
|
||||||
|
if "feishu" not in existing:
|
||||||
|
existing.append("feishu")
|
||||||
|
file_cfg["channel_type"] = ",".join(existing)
|
||||||
|
|
||||||
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(file_cfg, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
# 同步到内存中的 conf(),让本次启动直接生效
|
||||||
|
conf()["feishu_app_id"] = app_id
|
||||||
|
conf()["feishu_app_secret"] = app_secret
|
||||||
|
if "channel_type" in file_cfg:
|
||||||
|
conf()["channel_type"] = file_cfg["channel_type"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.chmod(config_path, 0o600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[FeiShu] Failed to persist credentials to config.json: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _register_via_qr_in_terminal() -> bool:
|
||||||
|
"""CLI-side one-click app creation via lark_oapi.register_app.
|
||||||
|
|
||||||
|
Blocks the calling thread (typically the channel startup thread) until the user
|
||||||
|
finishes scanning, the QR code expires, or registration is cancelled.
|
||||||
|
|
||||||
|
Returns True if credentials were obtained AND persisted; False otherwise.
|
||||||
|
The caller should fall back to the original "missing credentials" error in that case.
|
||||||
|
"""
|
||||||
|
if not LARK_SDK_AVAILABLE:
|
||||||
|
logger.error(
|
||||||
|
"[FeiShu] 缺少 feishu_app_id / feishu_app_secret。"
|
||||||
|
"未安装 lark-oapi SDK,无法在终端发起扫码创建。"
|
||||||
|
"请执行 pip install -U 'lark-oapi>=1.5.5' 后重试,或手动在 config.json 中填入凭据。"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
lark_mod = _ensure_lark_imported()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[FeiShu] Import lark_oapi failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# register_app 是 lark-oapi 1.5.5 才引入的能力,旧版本调用会得到难以理解的
|
||||||
|
# AttributeError。提前显式检查,给出明确的升级提示。
|
||||||
|
if not hasattr(lark_mod, "register_app"):
|
||||||
|
try:
|
||||||
|
from importlib.metadata import version as _pkg_version
|
||||||
|
installed = _pkg_version("lark-oapi")
|
||||||
|
except Exception:
|
||||||
|
installed = "unknown"
|
||||||
|
logger.error(
|
||||||
|
f"[FeiShu] 当前 lark-oapi 版本 ({installed}) 不支持一键创建应用,需要 >= 1.5.5。"
|
||||||
|
"请执行 pip install -U 'lark-oapi>=1.5.5' 后重试,或手动在 config.json 中填入凭据。"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info("[FeiShu] 检测到尚未配置 feishu_app_id / feishu_app_secret,"
|
||||||
|
"正在向飞书申请一键创建应用...")
|
||||||
|
|
||||||
|
def _on_qr(info):
|
||||||
|
url = info.get("url", "")
|
||||||
|
if url:
|
||||||
|
_print_qr_to_terminal(url)
|
||||||
|
|
||||||
|
def _on_status(info):
|
||||||
|
# 过滤 polling 心跳(每 5 秒一次),保留 slow_down / domain_switched 等
|
||||||
|
status = info.get("status")
|
||||||
|
if status == "polling":
|
||||||
|
return
|
||||||
|
logger.info(f"[FeiShu] register_app status: {info}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = lark_mod.register_app(
|
||||||
|
on_qr_code=_on_qr,
|
||||||
|
on_status_change=_on_status,
|
||||||
|
source="cowagent",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
err_cls = e.__class__.__name__
|
||||||
|
if "Expired" in err_cls:
|
||||||
|
logger.error("[FeiShu] 二维码已过期,请重启程序后重试。")
|
||||||
|
elif "Denied" in err_cls:
|
||||||
|
logger.error("[FeiShu] 已取消授权。")
|
||||||
|
else:
|
||||||
|
logger.error(f"[FeiShu] 一键创建失败:{e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
app_id = result.get("client_id", "")
|
||||||
|
app_secret = result.get("client_secret", "")
|
||||||
|
if not app_id or not app_secret:
|
||||||
|
logger.error("[FeiShu] 创建结果缺少 app_id/app_secret,无法继续。")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not _persist_feishu_credentials(app_id, app_secret):
|
||||||
|
logger.error(
|
||||||
|
"[FeiShu] 应用创建成功但写入 config.json 失败,请手动复制以下值到配置文件:\n"
|
||||||
|
f" feishu_app_id = {app_id}\n"
|
||||||
|
f" feishu_app_secret = {app_secret}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"[FeiShu] 应用创建成功,凭据已写入 config.json (app_id={app_id})。")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
@singleton
|
@singleton
|
||||||
@@ -55,6 +231,10 @@ class FeiShuChanel(ChatChannel):
|
|||||||
feishu_app_secret = conf().get('feishu_app_secret')
|
feishu_app_secret = conf().get('feishu_app_secret')
|
||||||
feishu_token = conf().get('feishu_token')
|
feishu_token = conf().get('feishu_token')
|
||||||
feishu_event_mode = conf().get('feishu_event_mode', 'websocket') # webhook 或 websocket
|
feishu_event_mode = conf().get('feishu_event_mode', 'websocket') # webhook 或 websocket
|
||||||
|
# 覆盖父类默认值 [ReplyType.VOICE, ReplyType.IMAGE]。
|
||||||
|
# 飞书原生支持发送音频(opus 格式,通过文件上传接口)和图片,
|
||||||
|
# 所有回复类型均已处理,置为空列表以启用语音和图片回复。
|
||||||
|
NOT_SUPPORT_REPLYTYPE = []
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -63,6 +243,7 @@ class FeiShuChanel(ChatChannel):
|
|||||||
self._http_server = None
|
self._http_server = None
|
||||||
self._ws_client = None
|
self._ws_client = None
|
||||||
self._ws_thread = None
|
self._ws_thread = None
|
||||||
|
self._bot_open_id = None # cached bot open_id for @-mention matching
|
||||||
logger.debug("[FeiShu] app_id={}, app_secret={}, verification_token={}, event_mode={}".format(
|
logger.debug("[FeiShu] app_id={}, app_secret={}, verification_token={}, event_mode={}".format(
|
||||||
self.feishu_app_id, self.feishu_app_secret, self.feishu_token, self.feishu_event_mode))
|
self.feishu_app_id, self.feishu_app_secret, self.feishu_token, self.feishu_event_mode))
|
||||||
# 无需群校验和前缀
|
# 无需群校验和前缀
|
||||||
@@ -79,11 +260,45 @@ class FeiShuChanel(ChatChannel):
|
|||||||
self.feishu_app_secret = conf().get('feishu_app_secret')
|
self.feishu_app_secret = conf().get('feishu_app_secret')
|
||||||
self.feishu_token = conf().get('feishu_token')
|
self.feishu_token = conf().get('feishu_token')
|
||||||
self.feishu_event_mode = conf().get('feishu_event_mode', 'websocket')
|
self.feishu_event_mode = conf().get('feishu_event_mode', 'websocket')
|
||||||
|
|
||||||
|
# 命令行启动场景:缺少凭据时尝试通过 lark.register_app 在终端弹二维码
|
||||||
|
# 引导用户扫码创建应用。Web 控制台启动同样会走到这里,但控制台用户通常
|
||||||
|
# 已经通过 /api/feishu/register 完成了创建并写回 config.json。
|
||||||
|
if not self.feishu_app_id or not self.feishu_app_secret:
|
||||||
|
if _register_via_qr_in_terminal():
|
||||||
|
self.feishu_app_id = conf().get('feishu_app_id')
|
||||||
|
self.feishu_app_secret = conf().get('feishu_app_secret')
|
||||||
|
else:
|
||||||
|
err = "[FeiShu] feishu_app_id 与 feishu_app_secret 缺失,无法启动通道"
|
||||||
|
logger.error(err)
|
||||||
|
self.report_startup_error(err)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._fetch_bot_open_id()
|
||||||
if self.feishu_event_mode == 'websocket':
|
if self.feishu_event_mode == 'websocket':
|
||||||
self._startup_websocket()
|
self._startup_websocket()
|
||||||
else:
|
else:
|
||||||
self._startup_webhook()
|
self._startup_webhook()
|
||||||
|
|
||||||
|
def _fetch_bot_open_id(self):
|
||||||
|
"""Fetch the bot's own open_id via API so we can match @-mentions without feishu_bot_name."""
|
||||||
|
try:
|
||||||
|
access_token = self.fetch_access_token()
|
||||||
|
if not access_token:
|
||||||
|
logger.warning("[FeiShu] Cannot fetch bot info: no access_token")
|
||||||
|
return
|
||||||
|
headers = {"Authorization": "Bearer " + access_token}
|
||||||
|
resp = requests.get("https://open.feishu.cn/open-apis/bot/v3/info/", headers=headers, timeout=5)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
if data.get("code") == 0:
|
||||||
|
self._bot_open_id = data.get("bot", {}).get("open_id")
|
||||||
|
logger.info(f"[FeiShu] Bot open_id fetched: {self._bot_open_id}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[FeiShu] Fetch bot info failed: code={data.get('code')}, msg={data.get('msg')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[FeiShu] Fetch bot open_id error: {e}")
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
import ctypes
|
import ctypes
|
||||||
logger.info("[FeiShu] stop() called")
|
logger.info("[FeiShu] stop() called")
|
||||||
@@ -134,17 +349,22 @@ class FeiShuChanel(ChatChannel):
|
|||||||
|
|
||||||
def _startup_websocket(self):
|
def _startup_websocket(self):
|
||||||
"""启动长连接接收事件(websocket模式)"""
|
"""启动长连接接收事件(websocket模式)"""
|
||||||
|
_ensure_lark_imported()
|
||||||
logger.debug("[FeiShu] Starting in websocket mode...")
|
logger.debug("[FeiShu] Starting in websocket mode...")
|
||||||
|
|
||||||
# 创建事件处理器
|
# 创建事件处理器
|
||||||
def handle_message_event(data: lark.im.v1.P2ImMessageReceiveV1) -> None:
|
def handle_message_event(data: lark.im.v1.P2ImMessageReceiveV1) -> None:
|
||||||
"""处理接收消息事件 v2.0"""
|
"""处理接收消息事件 v2.0"""
|
||||||
try:
|
try:
|
||||||
logger.debug(f"[FeiShu] websocket receive event: {lark.JSON.marshal(data, indent=2)}")
|
|
||||||
|
|
||||||
# 转换为标准的event格式
|
|
||||||
event_dict = json.loads(lark.JSON.marshal(data))
|
event_dict = json.loads(lark.JSON.marshal(data))
|
||||||
event = event_dict.get("event", {})
|
event = event_dict.get("event", {})
|
||||||
|
msg = event.get("message", {})
|
||||||
|
|
||||||
|
# Skip group messages that don't @-mention the bot (reduce log noise)
|
||||||
|
if msg.get("chat_type") == "group" and not msg.get("mentions") and msg.get("message_type") == "text":
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug(f"[FeiShu] websocket receive event: {lark.JSON.marshal(data, indent=2)}")
|
||||||
|
|
||||||
# 处理消息
|
# 处理消息
|
||||||
self._handle_message_event(event)
|
self._handle_message_event(event)
|
||||||
@@ -169,10 +389,20 @@ class FeiShuChanel(ChatChannel):
|
|||||||
context.verify_mode = ssl.CERT_NONE
|
context.verify_mode = ssl.CERT_NONE
|
||||||
return context
|
return context
|
||||||
|
|
||||||
# Give this thread its own event loop so lark SDK can call run_until_complete
|
# lark_oapi.ws.client captures the event loop at module-import time as a module-
|
||||||
|
# level global variable. When a previous ws thread is force-killed via ctypes its
|
||||||
|
# loop may still be marked as "running", which causes the next ws_client.start()
|
||||||
|
# call (in this new thread) to raise "This event loop is already running".
|
||||||
|
# Fix: replace the module-level loop with a brand-new, idle loop before starting.
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
|
try:
|
||||||
|
import lark_oapi.ws.client as _lark_ws_client_mod
|
||||||
|
_lark_ws_client_mod.loop = loop
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
startup_error = None
|
||||||
for attempt in range(2):
|
for attempt in range(2):
|
||||||
try:
|
try:
|
||||||
if attempt == 1:
|
if attempt == 1:
|
||||||
@@ -202,8 +432,11 @@ class FeiShuChanel(ChatChannel):
|
|||||||
logger.warning(f"[FeiShu] SSL error: {error_msg}, retrying...")
|
logger.warning(f"[FeiShu] SSL error: {error_msg}, retrying...")
|
||||||
continue
|
continue
|
||||||
logger.error(f"[FeiShu] Websocket client error: {e}", exc_info=True)
|
logger.error(f"[FeiShu] Websocket client error: {e}", exc_info=True)
|
||||||
|
startup_error = error_msg
|
||||||
ssl_module.create_default_context = original_create_default_context
|
ssl_module.create_default_context = original_create_default_context
|
||||||
break
|
break
|
||||||
|
if startup_error:
|
||||||
|
self.report_startup_error(startup_error)
|
||||||
try:
|
try:
|
||||||
loop.close()
|
loop.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -216,6 +449,27 @@ class FeiShuChanel(ChatChannel):
|
|||||||
logger.info("[FeiShu] ✅ Websocket thread started, ready to receive messages")
|
logger.info("[FeiShu] ✅ Websocket thread started, ready to receive messages")
|
||||||
ws_thread.join()
|
ws_thread.join()
|
||||||
|
|
||||||
|
def _is_mention_bot(self, mentions: list) -> bool:
|
||||||
|
"""Check whether any mention in the list refers to this bot.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. Match by open_id (obtained from /bot/v3/info at startup, no config needed)
|
||||||
|
2. Fallback to feishu_bot_name config for backward compatibility
|
||||||
|
3. If neither is available, assume the first mention is the bot (Feishu only
|
||||||
|
delivers group messages that @-mention the bot, so this is usually correct)
|
||||||
|
"""
|
||||||
|
if self._bot_open_id:
|
||||||
|
return any(
|
||||||
|
m.get("id", {}).get("open_id") == self._bot_open_id
|
||||||
|
for m in mentions
|
||||||
|
)
|
||||||
|
bot_name = conf().get("feishu_bot_name")
|
||||||
|
if bot_name:
|
||||||
|
return any(m.get("name") == bot_name for m in mentions)
|
||||||
|
# Feishu event subscription only delivers messages that @-mention the bot,
|
||||||
|
# so reaching here means the bot was indeed mentioned.
|
||||||
|
return True
|
||||||
|
|
||||||
def _handle_message_event(self, event: dict):
|
def _handle_message_event(self, event: dict):
|
||||||
"""
|
"""
|
||||||
处理消息事件的核心逻辑
|
处理消息事件的核心逻辑
|
||||||
@@ -250,10 +504,9 @@ class FeiShuChanel(ChatChannel):
|
|||||||
if not msg.get("mentions") and msg.get("message_type") == "text":
|
if not msg.get("mentions") and msg.get("message_type") == "text":
|
||||||
# 群聊中未@不响应
|
# 群聊中未@不响应
|
||||||
return
|
return
|
||||||
if msg.get("mentions") and msg.get("mentions")[0].get("name") != conf().get("feishu_bot_name") and msg.get(
|
if msg.get("mentions") and msg.get("message_type") == "text":
|
||||||
"message_type") == "text":
|
if not self._is_mention_bot(msg.get("mentions")):
|
||||||
# 不是@机器人,不响应
|
return
|
||||||
return
|
|
||||||
# 群聊
|
# 群聊
|
||||||
is_group = True
|
is_group = True
|
||||||
receive_id_type = "chat_id"
|
receive_id_type = "chat_id"
|
||||||
@@ -289,6 +542,32 @@ class FeiShuChanel(ChatChannel):
|
|||||||
# 单张图片不直接处理,等待用户提问
|
# 单张图片不直接处理,等待用户提问
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 如果是文件消息,触发实际下载并缓存,等待用户后续提问时一并带上。
|
||||||
|
# 与 wecom_bot 行为对齐:发文件后静默缓存(飞书客户端会显示"已读"),
|
||||||
|
# 用户下一条文本消息会自动 attach 上文件路径给 agent。
|
||||||
|
if feishu_msg.ctype == ContextType.FILE:
|
||||||
|
try:
|
||||||
|
feishu_msg.prepare()
|
||||||
|
# prepare 通过 _prepared 标记保证幂等,重复调用安全
|
||||||
|
if not os.path.exists(feishu_msg.content):
|
||||||
|
raise FileNotFoundError(feishu_msg.content)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[FeiShu] prepare file failed: {e}")
|
||||||
|
# 文件下载失败时主动通知用户,避免静默丢失
|
||||||
|
try:
|
||||||
|
err_reply = Reply(ReplyType.TEXT, f"⚠️ 文件下载失败,请重新发送:{e}")
|
||||||
|
self._send(err_reply, self._compose_context(
|
||||||
|
ContextType.TEXT, "",
|
||||||
|
isgroup=is_group, msg=feishu_msg,
|
||||||
|
receive_id_type=receive_id_type, no_need_at=True,
|
||||||
|
))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
file_cache.add(session_id, feishu_msg.content, file_type='file')
|
||||||
|
logger.info(f"[FeiShu] File cached for session {session_id}: {feishu_msg.content}")
|
||||||
|
return
|
||||||
|
|
||||||
# 如果是文本消息,检查是否有缓存的文件
|
# 如果是文本消息,检查是否有缓存的文件
|
||||||
if feishu_msg.ctype == ContextType.TEXT:
|
if feishu_msg.ctype == ContextType.TEXT:
|
||||||
cached_files = file_cache.get(session_id)
|
cached_files = file_cache.get(session_id)
|
||||||
@@ -319,10 +598,22 @@ class FeiShuChanel(ChatChannel):
|
|||||||
no_need_at=True
|
no_need_at=True
|
||||||
)
|
)
|
||||||
if context:
|
if context:
|
||||||
|
# 流式回复模式:向 context 注入 on_event 回调,agent 每产出一段文字时会调用它。
|
||||||
|
# 回调内部先发送一条占位消息获取 message_id,之后通过 PATCH 接口原地更新内容,
|
||||||
|
# 实现打字机效果。回调结束时设置 context["feishu_streamed"]=True,
|
||||||
|
# 让 send() 跳过重复发送,避免最终完整回复再被重复投递一次。
|
||||||
|
# 默认开启流式打字机回复。需机器人开通 cardkit:card:write 权限且飞书客户端 7.20+,
|
||||||
|
# 任意环节失败会自动降级为非流式文本回复。
|
||||||
|
if conf().get("feishu_stream_reply", True):
|
||||||
|
context["on_event"] = self._make_feishu_stream_callback(context, feishu_msg.access_token)
|
||||||
self.produce(context)
|
self.produce(context)
|
||||||
logger.debug(f"[FeiShu] query={feishu_msg.content}, type={feishu_msg.ctype}")
|
logger.debug(f"[FeiShu] query={feishu_msg.content}, type={feishu_msg.ctype}")
|
||||||
|
|
||||||
def send(self, reply: Reply, context: Context):
|
def send(self, reply: Reply, context: Context):
|
||||||
|
# 如果文本回复已通过流式传输发送,则跳过重复发送
|
||||||
|
if reply.type == ReplyType.TEXT and context.get("feishu_streamed"):
|
||||||
|
logger.debug("[FeiShu] streaming already delivered text reply, skipping send()")
|
||||||
|
return
|
||||||
msg = context.get("msg")
|
msg = context.get("msg")
|
||||||
is_group = context["isgroup"]
|
is_group = context["isgroup"]
|
||||||
if msg:
|
if msg:
|
||||||
@@ -385,11 +676,21 @@ class FeiShuChanel(ChatChannel):
|
|||||||
msg_type = "file"
|
msg_type = "file"
|
||||||
content_key = "file_key"
|
content_key = "file_key"
|
||||||
|
|
||||||
|
elif reply.type == ReplyType.VOICE:
|
||||||
|
# 语音回复:上传音频文件到飞书,然后发送 audio 类型消息
|
||||||
|
file_key = self._upload_audio(reply.content, access_token)
|
||||||
|
if not file_key:
|
||||||
|
logger.warning("[FeiShu] upload audio failed")
|
||||||
|
return
|
||||||
|
reply_content = file_key
|
||||||
|
msg_type = "audio"
|
||||||
|
content_key = "file_key"
|
||||||
|
|
||||||
# Check if we can reply to an existing message (need msg_id)
|
# Check if we can reply to an existing message (need msg_id)
|
||||||
can_reply = is_group and msg and hasattr(msg, 'msg_id') and msg.msg_id
|
can_reply = is_group and msg and hasattr(msg, 'msg_id') and msg.msg_id
|
||||||
|
|
||||||
# Build content JSON
|
# Build content JSON
|
||||||
content_json = json.dumps(reply_content) if content_key is None else json.dumps({content_key: reply_content})
|
content_json = json.dumps(reply_content, ensure_ascii=False) if content_key is None else json.dumps({content_key: reply_content}, ensure_ascii=False)
|
||||||
logger.debug(f"[FeiShu] Sending message: msg_type={msg_type}, content={content_json[:200]}")
|
logger.debug(f"[FeiShu] Sending message: msg_type={msg_type}, content={content_json[:200]}")
|
||||||
|
|
||||||
if can_reply:
|
if can_reply:
|
||||||
@@ -416,6 +717,423 @@ class FeiShuChanel(ChatChannel):
|
|||||||
else:
|
else:
|
||||||
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
|
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
|
||||||
|
|
||||||
|
def _make_feishu_stream_callback(self, context, access_token):
|
||||||
|
"""
|
||||||
|
基于飞书官方"流式更新卡片"API 实现打字机回复。
|
||||||
|
|
||||||
|
流程:
|
||||||
|
1. message_update 首次到达 → POST /cardkit/v1/cards 创建带 streaming_mode 的卡片实体,
|
||||||
|
随后用 POST /im/v1/messages(或 reply)以 card_id 把卡片发出去
|
||||||
|
2. 后续 message_update → PUT /cardkit/v1/cards/{id}/elements/{eid}/content
|
||||||
|
传入"当前轮"的全量文本,飞书平台自动计算增量并以打字机效果上屏
|
||||||
|
(流式模式下不受 10 QPS 限制)
|
||||||
|
3. message_end(一轮 LLM 输出结束,且本轮触发了工具调用)→ 把 current 累计到 committed
|
||||||
|
并加入分隔符;下一轮 message_update 又从空白开始,避免多轮内容串到一起
|
||||||
|
4. agent_end → 用 final_response 强制覆盖卡片,再 PATCH /cardkit/v1/cards/{id}/settings
|
||||||
|
关闭 streaming_mode,标记 context["feishu_streamed"]=True 让 chat_channel 跳过普通 send()
|
||||||
|
|
||||||
|
前提条件:
|
||||||
|
- 机器人已开通 cardkit:card:write 权限
|
||||||
|
- 飞书客户端 7.20+
|
||||||
|
|
||||||
|
失败降级:
|
||||||
|
- 创建卡片实体失败(缺权限、网络等)→ 不设置 feishu_streamed 标记,让 chat_channel
|
||||||
|
走普通文本回复路径,用户收到完整回复但无打字机效果,并打 warning 日志
|
||||||
|
"""
|
||||||
|
# 共享状态(受 lock 保护)
|
||||||
|
# 多轮 agent 模式下,每个"中间过场消息"会作为一张独立卡片发送。
|
||||||
|
# current_text 只承载当前正在流式渲染的那张卡片的内容;message_end / agent_end
|
||||||
|
# 时会把它定型并 reset。
|
||||||
|
current_text = [""] # 当前卡片正在累加的 LLM 输出
|
||||||
|
card_id = [None] # 当前流式卡片的实体 ID(每段独立)
|
||||||
|
message_id = [None] # 当前卡片发送后的消息 ID(仅日志用)
|
||||||
|
# 占位发送是同步进行的,但用一个 in-flight 标记防止并发的多条 message_update
|
||||||
|
# 事件各自触发一次创建+发送,导致发出多张卡片。
|
||||||
|
init_in_flight = [False]
|
||||||
|
# 一旦初始化失败就长期标记为 disabled,本次回复不再尝试任何流式调用
|
||||||
|
disabled = [False]
|
||||||
|
# True after agent_cancelled: agent_end stops rewriting the card
|
||||||
|
# with stale final_response and just finalizes current content.
|
||||||
|
cancelled = [False]
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
# ---- 异步推送队列 ----------------------------------------------------
|
||||||
|
# 同步 requests.put 单次 100~300ms,会阻塞 LLM stream 线程读下一个 chunk。
|
||||||
|
# 把推送丢给独立 worker 线程消费 queue,回调本身只做内存追加,立即返回。
|
||||||
|
# 队列里只放"最新累积文本"的快照;worker 用 deduplication 避免重复推同一个
|
||||||
|
# 内容(高频 chunk 场景下队列会堆积,只推最后一个就够了)。
|
||||||
|
import queue as _queue
|
||||||
|
push_queue: "_queue.Queue[str | None]" = _queue.Queue()
|
||||||
|
|
||||||
|
def _push_worker():
|
||||||
|
while True:
|
||||||
|
snapshot = push_queue.get()
|
||||||
|
if snapshot is None:
|
||||||
|
push_queue.task_done()
|
||||||
|
return
|
||||||
|
# 合并队列中已堆积的快照:只推最后一个,省 PUT 次数同时降低延迟
|
||||||
|
merged_count = 1
|
||||||
|
stop = False
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
nxt = push_queue.get_nowait()
|
||||||
|
except _queue.Empty:
|
||||||
|
break
|
||||||
|
merged_count += 1
|
||||||
|
if nxt is None:
|
||||||
|
stop = True
|
||||||
|
break
|
||||||
|
snapshot = nxt
|
||||||
|
try:
|
||||||
|
_stream_update_text(snapshot)
|
||||||
|
finally:
|
||||||
|
for _ in range(merged_count):
|
||||||
|
push_queue.task_done()
|
||||||
|
if stop:
|
||||||
|
return
|
||||||
|
|
||||||
|
push_thread = threading.Thread(target=_push_worker, daemon=True, name="feishu-stream-push")
|
||||||
|
push_thread.start()
|
||||||
|
|
||||||
|
def _drain_push_queue():
|
||||||
|
"""等当前队列里所有 PUT 都完成。message_end/agent_end 在做最终定型前必须 drain,
|
||||||
|
否则 worker 里堆积的旧快照可能在 final_text PUT 之后到达,把最终内容覆盖掉。"""
|
||||||
|
try:
|
||||||
|
push_queue.join()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
msg = context.get("msg")
|
||||||
|
is_group = context.get("isgroup", False)
|
||||||
|
receiver = context.get("receiver")
|
||||||
|
receive_id_type = context.get("receive_id_type", "open_id")
|
||||||
|
# 客户端打字机渲染参数(飞书 App 侧实际"出字"速度):
|
||||||
|
# - print_freq_ms:每次刷新的间隔
|
||||||
|
# - print_step:每次刷新出多少个字符
|
||||||
|
# 当前 40ms × 4 字 ≈ 100 字/秒,接近 ChatGPT/DeepSeek 网页端的节奏。
|
||||||
|
print_freq_ms = 40
|
||||||
|
print_step = 4
|
||||||
|
print_strategy = "fast"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Bearer " + access_token,
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
}
|
||||||
|
# 卡片中富文本组件的 element_id,后续所有 PUT 流式更新都打到这个组件
|
||||||
|
ELEMENT_ID = "stream_md"
|
||||||
|
# 操作序号,每次 PUT 必须严格递增(飞书要求)
|
||||||
|
sequence = [0]
|
||||||
|
|
||||||
|
def _next_sequence():
|
||||||
|
sequence[0] += 1
|
||||||
|
return sequence[0]
|
||||||
|
|
||||||
|
def _build_card_json():
|
||||||
|
"""卡片 JSON 2.0 结构 + streaming_mode + 单 markdown 组件"""
|
||||||
|
return json.dumps({
|
||||||
|
"schema": "2.0",
|
||||||
|
"config": {
|
||||||
|
"streaming_mode": True,
|
||||||
|
"summary": {"content": "[正在生成回复...]"},
|
||||||
|
"streaming_config": {
|
||||||
|
"print_frequency_ms": {"default": print_freq_ms},
|
||||||
|
"print_step": {"default": print_step},
|
||||||
|
"print_strategy": print_strategy,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"tag": "markdown",
|
||||||
|
"content": "...",
|
||||||
|
"element_id": ELEMENT_ID,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# 注意:JSON 2.0 不支持自定义 fallback 字段(传入会报错)。
|
||||||
|
# 客户端 < 7.20 时,飞书会自动展示"请升级客户端"占位,无需配置。
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
def _create_and_send_card():
|
||||||
|
"""同步执行:创建卡片实体 → 发送消息。任意一步失败则 disabled=True 触发降级"""
|
||||||
|
try:
|
||||||
|
# 步骤 1: 创建卡片实体
|
||||||
|
create_url = "https://open.feishu.cn/open-apis/cardkit/v1/cards"
|
||||||
|
create_body = {"type": "card_json", "data": _build_card_json()}
|
||||||
|
res = requests.post(
|
||||||
|
create_url, headers=headers, json=create_body, timeout=(5, 10)
|
||||||
|
)
|
||||||
|
res_json = res.json()
|
||||||
|
if res_json.get("code") != 0:
|
||||||
|
logger.warning(
|
||||||
|
f"[FeiShu] Stream: create card failed "
|
||||||
|
f"(code={res_json.get('code')}, msg={res_json.get('msg')}). "
|
||||||
|
f"本次回复已自动降级为普通文本回复(一次性返回完整内容)。"
|
||||||
|
f"如需开启流式打字机效果与完整 Markdown 渲染,请到飞书开放平台 "
|
||||||
|
f"https://open.feishu.cn/app 给机器人开通 cardkit:card:write 权限"
|
||||||
|
f"(创建与更新卡片)并重新发布版本,同时确保飞书客户端 >= 7.20。"
|
||||||
|
)
|
||||||
|
with lock:
|
||||||
|
disabled[0] = True
|
||||||
|
return
|
||||||
|
cid = res_json["data"]["card_id"]
|
||||||
|
with lock:
|
||||||
|
card_id[0] = cid
|
||||||
|
|
||||||
|
# 步骤 2: 通过 card_id 发送消息(群聊优先用 reply,单聊直接 send)
|
||||||
|
content_payload = json.dumps(
|
||||||
|
{"type": "card", "data": {"card_id": cid}}, ensure_ascii=False
|
||||||
|
)
|
||||||
|
can_reply = is_group and msg and hasattr(msg, "msg_id") and msg.msg_id
|
||||||
|
if can_reply:
|
||||||
|
send_url = (
|
||||||
|
f"https://open.feishu.cn/open-apis/im/v1/messages/"
|
||||||
|
f"{msg.msg_id}/reply"
|
||||||
|
)
|
||||||
|
send_body = {"msg_type": "interactive", "content": content_payload}
|
||||||
|
send_res = requests.post(
|
||||||
|
send_url, headers=headers, json=send_body, timeout=(5, 10)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
send_url = "https://open.feishu.cn/open-apis/im/v1/messages"
|
||||||
|
params = {"receive_id_type": receive_id_type}
|
||||||
|
send_body = {
|
||||||
|
"receive_id": receiver,
|
||||||
|
"msg_type": "interactive",
|
||||||
|
"content": content_payload,
|
||||||
|
}
|
||||||
|
send_res = requests.post(
|
||||||
|
send_url, headers=headers, params=params, json=send_body,
|
||||||
|
timeout=(5, 10),
|
||||||
|
)
|
||||||
|
send_json = send_res.json()
|
||||||
|
if send_json.get("code") != 0:
|
||||||
|
logger.warning(
|
||||||
|
f"[FeiShu] Stream: send card failed: {send_json}. 降级为普通文本。"
|
||||||
|
)
|
||||||
|
with lock:
|
||||||
|
disabled[0] = True
|
||||||
|
return
|
||||||
|
mid = send_json["data"]["message_id"]
|
||||||
|
with lock:
|
||||||
|
message_id[0] = mid
|
||||||
|
logger.info(
|
||||||
|
f"[FeiShu] Stream: card created and sent, "
|
||||||
|
f"card_id={cid}, message_id={mid}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[FeiShu] Stream: create/send card exception: {e}. 降级为普通文本。"
|
||||||
|
)
|
||||||
|
with lock:
|
||||||
|
disabled[0] = True
|
||||||
|
finally:
|
||||||
|
with lock:
|
||||||
|
init_in_flight[0] = False
|
||||||
|
|
||||||
|
def _stream_update_text(full_text):
|
||||||
|
"""PUT 流式更新文本组件。content 必须是当前组件的全量文本。"""
|
||||||
|
with lock:
|
||||||
|
cid = card_id[0]
|
||||||
|
if not cid:
|
||||||
|
return
|
||||||
|
url = (
|
||||||
|
f"https://open.feishu.cn/open-apis/cardkit/v1/cards/"
|
||||||
|
f"{cid}/elements/{ELEMENT_ID}/content"
|
||||||
|
)
|
||||||
|
body = {
|
||||||
|
"content": full_text,
|
||||||
|
"sequence": _next_sequence(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
res = requests.put(url, headers=headers, json=body, timeout=(5, 10))
|
||||||
|
res_json = res.json()
|
||||||
|
if res_json.get("code") != 0:
|
||||||
|
logger.warning(
|
||||||
|
f"[FeiShu] Stream: update text failed: {res_json}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[FeiShu] Stream: update text exception: {e}")
|
||||||
|
|
||||||
|
def _close_streaming_mode(final_text: str = ""):
|
||||||
|
"""关闭流式模式(卡片转入"普通"状态,可被转发)。
|
||||||
|
|
||||||
|
同时通过整卡更新接口把 summary 改成最终内容的预览,否则飞书会话列表
|
||||||
|
会一直显示创建卡片时的占位摘要("[正在生成回复...]")。
|
||||||
|
"""
|
||||||
|
with lock:
|
||||||
|
cid = card_id[0]
|
||||||
|
if not cid:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1) 通过整卡更新接口把 streaming_mode 关掉,并改写 summary
|
||||||
|
# (settings 接口的 config 不接受 summary 字段,会报 code=2200)
|
||||||
|
preview_src = (final_text or "").strip().replace("\n", " ")
|
||||||
|
preview = preview_src[:30] if preview_src else ""
|
||||||
|
full_card = {
|
||||||
|
"schema": "2.0",
|
||||||
|
"config": {
|
||||||
|
"streaming_mode": False,
|
||||||
|
"summary": {"content": preview or " "},
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"tag": "markdown",
|
||||||
|
"content": final_text or " ",
|
||||||
|
"element_id": ELEMENT_ID,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
put_url = f"https://open.feishu.cn/open-apis/cardkit/v1/cards/{cid}"
|
||||||
|
put_body = {
|
||||||
|
"card": {"type": "card_json", "data": json.dumps(full_card, ensure_ascii=False)},
|
||||||
|
"sequence": _next_sequence(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
res = requests.put(put_url, headers=headers, json=put_body, timeout=(5, 10))
|
||||||
|
res_json = res.json()
|
||||||
|
if res_json.get("code") != 0:
|
||||||
|
logger.warning(
|
||||||
|
f"[FeiShu] Stream: finalize card (close+summary) failed: {res_json}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[FeiShu] Stream: finalize card exception: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_event(event: dict):
|
||||||
|
event_type = event.get("type")
|
||||||
|
data = event.get("data", {})
|
||||||
|
|
||||||
|
# 一旦降级,本次回复不再做任何流式操作
|
||||||
|
with lock:
|
||||||
|
if disabled[0]:
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type == "message_update":
|
||||||
|
delta = data.get("delta", "")
|
||||||
|
if not delta:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 第一段:判断是否需要初始化(创建卡片 + 发送)
|
||||||
|
need_init = False
|
||||||
|
with lock:
|
||||||
|
if card_id[0] is None and not init_in_flight[0]:
|
||||||
|
init_in_flight[0] = True
|
||||||
|
need_init = True
|
||||||
|
|
||||||
|
if need_init:
|
||||||
|
_create_and_send_card()
|
||||||
|
# 初始化失败已标记 disabled,下次循环直接 return
|
||||||
|
with lock:
|
||||||
|
if disabled[0]:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 第二段:累加文本,把快照丢给 push worker 异步推送。
|
||||||
|
# 这里不能直接 requests.put,否则会阻塞 LLM stream 线程读下一个 chunk
|
||||||
|
# (实测 DeepSeek 高频小 chunk 场景每个 PUT ~150ms,累积起来非常卡)。
|
||||||
|
snapshot = ""
|
||||||
|
should_push = False
|
||||||
|
with lock:
|
||||||
|
current_text[0] += delta
|
||||||
|
if card_id[0]:
|
||||||
|
snapshot = current_text[0]
|
||||||
|
should_push = True
|
||||||
|
|
||||||
|
if should_push:
|
||||||
|
push_queue.put(snapshot)
|
||||||
|
|
||||||
|
elif event_type == "message_end":
|
||||||
|
# 一轮 LLM 输出结束。如果本轮触发了工具调用,说明当前轮的文本是
|
||||||
|
# "中间过场消息"(如"来看看!"),应该作为独立卡片定型,然后为下一轮
|
||||||
|
# 重新创建一张新卡片。这样最终用户看到的是:
|
||||||
|
# [卡片1: 中间过场1]
|
||||||
|
# [卡片2: 中间过场2]
|
||||||
|
# ...
|
||||||
|
# [卡片N: 最终回复]
|
||||||
|
# 与 wecom_bot 的多消息流式体验对齐。
|
||||||
|
tool_calls = data.get("tool_calls", []) or []
|
||||||
|
if not tool_calls:
|
||||||
|
# 没有工具调用:本轮即最终回复,留给 agent_end 统一处理。
|
||||||
|
return
|
||||||
|
|
||||||
|
with lock:
|
||||||
|
text_to_finalize = current_text[0].rstrip()
|
||||||
|
current_text[0] = ""
|
||||||
|
|
||||||
|
if not text_to_finalize:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 等异步队列里堆积的快照都推完,避免它们晚于 final 文本到达把内容覆盖掉
|
||||||
|
_drain_push_queue()
|
||||||
|
# 用最终文本覆盖当前卡片并关闭流式模式(凝固成普通卡片,
|
||||||
|
# 同时把会话列表的 summary 改成预览,不再显示"正在生成回复...")
|
||||||
|
_stream_update_text(text_to_finalize)
|
||||||
|
_close_streaming_mode(text_to_finalize)
|
||||||
|
|
||||||
|
# 重置卡片状态,下一段 message_update 会触发新卡片的创建
|
||||||
|
with lock:
|
||||||
|
card_id[0] = None
|
||||||
|
message_id[0] = None
|
||||||
|
sequence[0] = 0
|
||||||
|
|
||||||
|
elif event_type == "agent_cancelled":
|
||||||
|
# Lock channel into "no-rewrite" mode: the subsequent
|
||||||
|
# agent_end's final_response is from the last *completed*
|
||||||
|
# turn (the user already saw it), so rewriting the card
|
||||||
|
# would duplicate it visually.
|
||||||
|
with lock:
|
||||||
|
cancelled[0] = True
|
||||||
|
|
||||||
|
elif event_type == "agent_end":
|
||||||
|
# 最终回复:用 final_response 覆盖当前流式卡片,然后关闭流式模式。
|
||||||
|
final_response = data.get("final_response", "")
|
||||||
|
# 标记 streamed 让 chat_channel 跳过 send()
|
||||||
|
context["feishu_streamed"] = True
|
||||||
|
|
||||||
|
with lock:
|
||||||
|
was_cancelled = cancelled[0]
|
||||||
|
has_card = card_id[0] is not None
|
||||||
|
init_busy = init_in_flight[0]
|
||||||
|
pending_text = current_text[0]
|
||||||
|
|
||||||
|
if was_cancelled:
|
||||||
|
# Cancelled path: finalize the in-flight card with
|
||||||
|
# partial output (or a short marker if empty); drop
|
||||||
|
# stale final_response to avoid duplicating last turn.
|
||||||
|
if has_card:
|
||||||
|
_drain_push_queue()
|
||||||
|
partial = (pending_text or "").rstrip()
|
||||||
|
final_text = partial or "_(已中止)_"
|
||||||
|
_stream_update_text(final_text)
|
||||||
|
_close_streaming_mode(final_text)
|
||||||
|
push_queue.put(None)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not final_response:
|
||||||
|
return
|
||||||
|
final_text = str(final_response)
|
||||||
|
|
||||||
|
# 罕见情况:agent_end 触发时还没创建过卡片(极快返回 / 没有
|
||||||
|
# message_update),主动创建一张承载 final_text。
|
||||||
|
if not has_card and not init_busy:
|
||||||
|
with lock:
|
||||||
|
init_in_flight[0] = True
|
||||||
|
_create_and_send_card()
|
||||||
|
with lock:
|
||||||
|
if disabled[0]:
|
||||||
|
return
|
||||||
|
|
||||||
|
_drain_push_queue()
|
||||||
|
_stream_update_text(final_text)
|
||||||
|
_close_streaming_mode(final_text)
|
||||||
|
# 通知 push worker 退出(本次回复彻底结束)
|
||||||
|
push_queue.put(None)
|
||||||
|
|
||||||
|
return on_event
|
||||||
|
|
||||||
def fetch_access_token(self) -> str:
|
def fetch_access_token(self) -> str:
|
||||||
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/"
|
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/"
|
||||||
headers = {
|
headers = {
|
||||||
@@ -622,6 +1340,66 @@ class FeiShuChanel(ChatChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[FeiShu] Failed to remove temp file {temp_file}: {e}")
|
logger.warning(f"[FeiShu] Failed to remove temp file {temp_file}: {e}")
|
||||||
|
|
||||||
|
def _upload_audio(self, audio_path, access_token):
|
||||||
|
"""
|
||||||
|
Upload a local audio file to Feishu and return file_key.
|
||||||
|
audio_path is a plain local file path (no file:// prefix).
|
||||||
|
Feishu audio messages only support opus format; non-opus files are converted first.
|
||||||
|
"""
|
||||||
|
logger.debug(f"[FeiShu] start upload audio, path={audio_path}")
|
||||||
|
|
||||||
|
if not os.path.exists(audio_path):
|
||||||
|
logger.error(f"[FeiShu] audio file not found: {audio_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Feishu only plays audio messages in opus format.
|
||||||
|
# Convert if the TTS engine produced a different format (e.g. mp3 from OpenAI TTS).
|
||||||
|
upload_path = audio_path
|
||||||
|
if not audio_path.lower().endswith('.opus'):
|
||||||
|
opus_path = os.path.splitext(audio_path)[0] + '.opus'
|
||||||
|
try:
|
||||||
|
from pydub import AudioSegment
|
||||||
|
audio = AudioSegment.from_file(audio_path)
|
||||||
|
audio.export(opus_path, format='opus')
|
||||||
|
upload_path = opus_path
|
||||||
|
logger.info(f"[FeiShu] Converted audio to opus: {opus_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[FeiShu] Failed to convert audio to opus, uploading original: {e}")
|
||||||
|
upload_path = audio_path
|
||||||
|
|
||||||
|
file_name = os.path.splitext(os.path.basename(upload_path))[0] + '.opus'
|
||||||
|
upload_url = "https://open.feishu.cn/open-apis/im/v1/files"
|
||||||
|
data = {'file_type': 'opus', 'file_name': file_name}
|
||||||
|
headers = {'Authorization': f'Bearer {access_token}'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(upload_path, "rb") as f:
|
||||||
|
upload_response = requests.post(
|
||||||
|
upload_url,
|
||||||
|
files={"file": f},
|
||||||
|
data=data,
|
||||||
|
headers=headers,
|
||||||
|
timeout=(5, 30)
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"[FeiShu] upload audio response, status={upload_response.status_code}, res={upload_response.content}")
|
||||||
|
response_data = upload_response.json()
|
||||||
|
if response_data.get("code") == 0:
|
||||||
|
return response_data.get("data").get("file_key")
|
||||||
|
else:
|
||||||
|
logger.error(f"[FeiShu] upload audio failed: {response_data}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[FeiShu] upload audio exception: {e}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
# 无论上传成功与否都清理转换产生的临时 opus 文件,避免失败路径下磁盘堆积。
|
||||||
|
if upload_path != audio_path and os.path.exists(upload_path):
|
||||||
|
try:
|
||||||
|
os.remove(upload_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[FeiShu] Failed to remove temp opus file {upload_path}: {e}")
|
||||||
|
|
||||||
def _upload_file_url(self, file_url, access_token):
|
def _upload_file_url(self, file_url, access_token):
|
||||||
"""
|
"""
|
||||||
Upload file to Feishu
|
Upload file to Feishu
|
||||||
@@ -764,10 +1542,16 @@ class FeiShuChanel(ChatChannel):
|
|||||||
else:
|
else:
|
||||||
context.type = ContextType.TEXT
|
context.type = ContextType.TEXT
|
||||||
context.content = content.strip()
|
context.content = content.strip()
|
||||||
|
# Text input opts into voice replies only when the always-on toggle is set.
|
||||||
|
if "desire_rtype" not in context and conf().get("always_reply_voice"):
|
||||||
|
context["desire_rtype"] = ReplyType.VOICE
|
||||||
|
|
||||||
elif context.type == ContextType.VOICE:
|
elif context.type == ContextType.VOICE:
|
||||||
# 2.语音请求
|
# 2.语音请求: voice input replies with voice if either
|
||||||
if "desire_rtype" not in context and conf().get("voice_reply_voice"):
|
# voice_reply_voice (mirror reply) or always_reply_voice is on.
|
||||||
|
if "desire_rtype" not in context and (
|
||||||
|
conf().get("voice_reply_voice") or conf().get("always_reply_voice")
|
||||||
|
):
|
||||||
context["desire_rtype"] = ReplyType.VOICE
|
context["desire_rtype"] = ReplyType.VOICE
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|||||||
@@ -144,7 +144,14 @@ class FeishuMessage(ChatMessage):
|
|||||||
file_key = content.get("file_key")
|
file_key = content.get("file_key")
|
||||||
file_name = content.get("file_name")
|
file_name = content.get("file_name")
|
||||||
|
|
||||||
self.content = TmpDir().path() + file_key + "." + utils.get_path_suffix(file_name)
|
# 落到 agent_workspace/tmp 下(绝对路径),与图片处理一致;
|
||||||
|
# 否则相对路径 ./tmp 在 agent 工作区里 read 时会找不到。
|
||||||
|
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
|
tmp_dir = os.path.join(workspace_root, "tmp")
|
||||||
|
os.makedirs(tmp_dir, exist_ok=True)
|
||||||
|
self.content = os.path.join(
|
||||||
|
tmp_dir, f"{file_key}.{utils.get_path_suffix(file_name)}"
|
||||||
|
)
|
||||||
|
|
||||||
def _download_file():
|
def _download_file():
|
||||||
# 如果响应状态码是200,则将响应内容写入本地文件
|
# 如果响应状态码是200,则将响应内容写入本地文件
|
||||||
@@ -162,6 +169,42 @@ class FeishuMessage(ChatMessage):
|
|||||||
else:
|
else:
|
||||||
logger.info(f"[FeiShu] Failed to download file, key={file_key}, res={response.text}")
|
logger.info(f"[FeiShu] Failed to download file, key={file_key}, res={response.text}")
|
||||||
self._prepare_fn = _download_file
|
self._prepare_fn = _download_file
|
||||||
|
elif msg_type == "audio":
|
||||||
|
# 飞书用户发送的语音消息类型为 "audio",文件为 opus 编码格式。
|
||||||
|
# 映射为 ContextType.VOICE,交由 chat_channel 的语音转文字(STT)流程处理。
|
||||||
|
# 文件通过 _prepare_fn 延迟下载,在 chat_channel 调用 cmsg.prepare() 时才执行。
|
||||||
|
self.ctype = ContextType.VOICE
|
||||||
|
content = json.loads(msg.get("content"))
|
||||||
|
file_key = content.get("file_key")
|
||||||
|
|
||||||
|
# 落到 agent_workspace/tmp 下(绝对路径),保证语音 STT 流程可读到
|
||||||
|
workspace_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
|
tmp_dir = os.path.join(workspace_root, "tmp")
|
||||||
|
os.makedirs(tmp_dir, exist_ok=True)
|
||||||
|
self.content = os.path.join(tmp_dir, f"{file_key}.opus")
|
||||||
|
logger.info(f"[FeiShu] audio message: file_key={file_key}, save_path={self.content}")
|
||||||
|
|
||||||
|
def _download_audio():
|
||||||
|
logger.info(f"[FeiShu] downloading audio: file_key={file_key}, msg_id={self.msg_id}")
|
||||||
|
url = f"https://open.feishu.cn/open-apis/im/v1/messages/{self.msg_id}/resources/{file_key}"
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Bearer " + access_token,
|
||||||
|
}
|
||||||
|
params = {
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = requests.get(url=url, headers=headers, params=params)
|
||||||
|
logger.info(f"[FeiShu] download audio response: status={response.status_code}, size={len(response.content)} bytes")
|
||||||
|
if response.status_code == 200:
|
||||||
|
with open(self.content, "wb") as f:
|
||||||
|
f.write(response.content)
|
||||||
|
logger.info(f"[FeiShu] audio saved to: {self.content}")
|
||||||
|
else:
|
||||||
|
logger.error(f"[FeiShu] Failed to download audio, key={file_key}, status={response.status_code}, res={response.text}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[FeiShu] Exception downloading audio, key={file_key}: {e}", exc_info=True)
|
||||||
|
self._prepare_fn = _download_audio
|
||||||
else:
|
else:
|
||||||
raise NotImplementedError("Unsupported message type: Type:{} ".format(msg_type))
|
raise NotImplementedError("Unsupported message type: Type:{} ".format(msg_type))
|
||||||
|
|
||||||
|
|||||||
0
channel/qq/__init__.py
Normal file
0
channel/qq/__init__.py
Normal file
736
channel/qq/qq_channel.py
Normal file
736
channel/qq/qq_channel.py
Normal file
@@ -0,0 +1,736 @@
|
|||||||
|
"""
|
||||||
|
QQ Bot channel via WebSocket long connection.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Group chat (@bot), single chat (C2C), guild channel, guild DM
|
||||||
|
- Text / image / file message send & receive
|
||||||
|
- Heartbeat keep-alive and auto-reconnect with session resume
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import websocket
|
||||||
|
|
||||||
|
from bridge.context import Context, ContextType
|
||||||
|
from bridge.reply import Reply, ReplyType
|
||||||
|
from channel.chat_channel import ChatChannel, check_prefix
|
||||||
|
from channel.qq.qq_message import QQMessage
|
||||||
|
from common.expired_dict import ExpiredDict
|
||||||
|
from common.log import logger
|
||||||
|
from common.singleton import singleton
|
||||||
|
from common.ws_client_compat import websocket_app_run_forever
|
||||||
|
from config import conf
|
||||||
|
|
||||||
|
# Rich media file_type constants
|
||||||
|
QQ_FILE_TYPE_IMAGE = 1
|
||||||
|
QQ_FILE_TYPE_VIDEO = 2
|
||||||
|
QQ_FILE_TYPE_VOICE = 3
|
||||||
|
QQ_FILE_TYPE_FILE = 4
|
||||||
|
|
||||||
|
QQ_API_BASE = "https://api.sgroup.qq.com"
|
||||||
|
|
||||||
|
# Intents: GROUP_AND_C2C_EVENT(1<<25) | PUBLIC_GUILD_MESSAGES(1<<30)
|
||||||
|
DEFAULT_INTENTS = (1 << 25) | (1 << 30)
|
||||||
|
|
||||||
|
# OpCode constants
|
||||||
|
OP_DISPATCH = 0
|
||||||
|
OP_HEARTBEAT = 1
|
||||||
|
OP_IDENTIFY = 2
|
||||||
|
OP_RESUME = 6
|
||||||
|
OP_RECONNECT = 7
|
||||||
|
OP_INVALID_SESSION = 9
|
||||||
|
OP_HELLO = 10
|
||||||
|
OP_HEARTBEAT_ACK = 11
|
||||||
|
|
||||||
|
# Resumable error codes
|
||||||
|
RESUMABLE_CLOSE_CODES = {4008, 4009}
|
||||||
|
|
||||||
|
|
||||||
|
@singleton
|
||||||
|
class QQChannel(ChatChannel):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.app_id = ""
|
||||||
|
self.app_secret = ""
|
||||||
|
|
||||||
|
self._access_token = ""
|
||||||
|
self._token_expires_at = 0
|
||||||
|
|
||||||
|
self._ws = None
|
||||||
|
self._ws_thread = None
|
||||||
|
self._heartbeat_thread = None
|
||||||
|
self._connected = False
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._token_lock = threading.Lock()
|
||||||
|
|
||||||
|
self._session_id = None
|
||||||
|
self._last_seq = None
|
||||||
|
self._heartbeat_interval = 45000
|
||||||
|
self._can_resume = False
|
||||||
|
|
||||||
|
self.received_msgs = ExpiredDict(60 * 60 * 7.1)
|
||||||
|
self._msg_seq_counter = {}
|
||||||
|
|
||||||
|
conf()["group_name_white_list"] = ["ALL_GROUP"]
|
||||||
|
conf()["single_chat_prefix"] = [""]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def startup(self):
|
||||||
|
self.app_id = conf().get("qq_app_id", "")
|
||||||
|
self.app_secret = conf().get("qq_app_secret", "")
|
||||||
|
|
||||||
|
if not self.app_id or not self.app_secret:
|
||||||
|
err = "[QQ] qq_app_id and qq_app_secret are required"
|
||||||
|
logger.error(err)
|
||||||
|
self.report_startup_error(err)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._refresh_access_token()
|
||||||
|
if not self._access_token:
|
||||||
|
err = "[QQ] Failed to get initial access_token"
|
||||||
|
logger.error(err)
|
||||||
|
self.report_startup_error(err)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._start_ws()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
logger.info("[QQ] stop() called")
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._ws:
|
||||||
|
try:
|
||||||
|
self._ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._ws = None
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Access Token
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _refresh_access_token(self):
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
"https://bots.qq.com/app/getAppAccessToken",
|
||||||
|
json={"appId": self.app_id, "clientSecret": self.app_secret},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
self._access_token = data.get("access_token", "")
|
||||||
|
expires_in = int(data.get("expires_in", 7200))
|
||||||
|
self._token_expires_at = time.time() + expires_in - 60
|
||||||
|
logger.debug(f"[QQ] Access token refreshed, expires_in={expires_in}s")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[QQ] Failed to refresh access_token: {e}")
|
||||||
|
|
||||||
|
def _get_access_token(self) -> str:
|
||||||
|
with self._token_lock:
|
||||||
|
if time.time() >= self._token_expires_at:
|
||||||
|
self._refresh_access_token()
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
def _get_auth_headers(self) -> dict:
|
||||||
|
return {
|
||||||
|
"Authorization": f"QQBot {self._get_access_token()}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# WebSocket connection
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_ws_url(self) -> str:
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
f"{QQ_API_BASE}/gateway",
|
||||||
|
headers=self._get_auth_headers(),
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
url = resp.json().get("url", "")
|
||||||
|
logger.debug(f"[QQ] Gateway URL: {url}")
|
||||||
|
return url
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[QQ] Failed to get gateway URL: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _start_ws(self):
|
||||||
|
ws_url = self._get_ws_url()
|
||||||
|
if not ws_url:
|
||||||
|
logger.error("[QQ] Cannot start WebSocket without gateway URL")
|
||||||
|
self.report_startup_error("Failed to get gateway URL")
|
||||||
|
return
|
||||||
|
|
||||||
|
def _on_open(ws):
|
||||||
|
logger.debug("[QQ] WebSocket connected, waiting for Hello...")
|
||||||
|
|
||||||
|
def _on_message(ws, raw):
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
self._handle_ws_message(data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[QQ] Failed to handle ws message: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def _on_error(ws, error):
|
||||||
|
logger.error(f"[QQ] WebSocket error: {error}")
|
||||||
|
|
||||||
|
def _on_close(ws, close_status_code, close_msg):
|
||||||
|
logger.warning(f"[QQ] WebSocket closed: status={close_status_code}, msg={close_msg}")
|
||||||
|
self._connected = False
|
||||||
|
if not self._stop_event.is_set():
|
||||||
|
if close_status_code in RESUMABLE_CLOSE_CODES and self._session_id:
|
||||||
|
self._can_resume = True
|
||||||
|
logger.info("[QQ] Will attempt resume in 3s...")
|
||||||
|
time.sleep(3)
|
||||||
|
else:
|
||||||
|
self._can_resume = False
|
||||||
|
logger.info("[QQ] Will reconnect in 5s...")
|
||||||
|
time.sleep(5)
|
||||||
|
if not self._stop_event.is_set():
|
||||||
|
self._start_ws()
|
||||||
|
|
||||||
|
self._ws = websocket.WebSocketApp(
|
||||||
|
ws_url,
|
||||||
|
on_open=_on_open,
|
||||||
|
on_message=_on_message,
|
||||||
|
on_error=_on_error,
|
||||||
|
on_close=_on_close,
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_forever():
|
||||||
|
try:
|
||||||
|
websocket_app_run_forever(self._ws, ping_interval=0, reconnect=0)
|
||||||
|
except (SystemExit, KeyboardInterrupt):
|
||||||
|
logger.info("[QQ] WebSocket thread interrupted")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[QQ] WebSocket run_forever error: {e}")
|
||||||
|
|
||||||
|
self._ws_thread = threading.Thread(target=run_forever, daemon=True)
|
||||||
|
self._ws_thread.start()
|
||||||
|
self._ws_thread.join()
|
||||||
|
|
||||||
|
def _ws_send(self, data: dict):
|
||||||
|
if self._ws:
|
||||||
|
self._ws.send(json.dumps(data, ensure_ascii=False))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Identify & Resume & Heartbeat
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _send_identify(self):
|
||||||
|
self._ws_send({
|
||||||
|
"op": OP_IDENTIFY,
|
||||||
|
"d": {
|
||||||
|
"token": f"QQBot {self._get_access_token()}",
|
||||||
|
"intents": DEFAULT_INTENTS,
|
||||||
|
"shard": [0, 1],
|
||||||
|
"properties": {
|
||||||
|
"$os": "linux",
|
||||||
|
"$browser": "chatgpt-on-wechat",
|
||||||
|
"$device": "chatgpt-on-wechat",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
logger.debug(f"[QQ] Identify sent with intents={DEFAULT_INTENTS}")
|
||||||
|
|
||||||
|
def _send_resume(self):
|
||||||
|
self._ws_send({
|
||||||
|
"op": OP_RESUME,
|
||||||
|
"d": {
|
||||||
|
"token": f"QQBot {self._get_access_token()}",
|
||||||
|
"session_id": self._session_id,
|
||||||
|
"seq": self._last_seq,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
logger.debug(f"[QQ] Resume sent: session_id={self._session_id}, seq={self._last_seq}")
|
||||||
|
|
||||||
|
def _start_heartbeat(self, interval_ms: int):
|
||||||
|
if self._heartbeat_thread and self._heartbeat_thread.is_alive():
|
||||||
|
return
|
||||||
|
self._heartbeat_interval = interval_ms
|
||||||
|
interval_sec = interval_ms / 1000.0
|
||||||
|
|
||||||
|
def heartbeat_loop():
|
||||||
|
while not self._stop_event.is_set() and self._connected:
|
||||||
|
try:
|
||||||
|
self._ws_send({
|
||||||
|
"op": OP_HEARTBEAT,
|
||||||
|
"d": self._last_seq,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[QQ] Heartbeat send failed: {e}")
|
||||||
|
break
|
||||||
|
self._stop_event.wait(interval_sec)
|
||||||
|
|
||||||
|
self._heartbeat_thread = threading.Thread(target=heartbeat_loop, daemon=True)
|
||||||
|
self._heartbeat_thread.start()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Incoming message dispatch
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _handle_ws_message(self, data: dict):
|
||||||
|
op = data.get("op")
|
||||||
|
d = data.get("d")
|
||||||
|
t = data.get("t")
|
||||||
|
s = data.get("s")
|
||||||
|
|
||||||
|
if s is not None:
|
||||||
|
self._last_seq = s
|
||||||
|
|
||||||
|
if op == OP_HELLO:
|
||||||
|
heartbeat_interval = d.get("heartbeat_interval", 45000) if d else 45000
|
||||||
|
logger.debug(f"[QQ] Received Hello, heartbeat_interval={heartbeat_interval}ms")
|
||||||
|
self._heartbeat_interval = heartbeat_interval
|
||||||
|
if self._can_resume and self._session_id:
|
||||||
|
self._send_resume()
|
||||||
|
else:
|
||||||
|
self._send_identify()
|
||||||
|
|
||||||
|
elif op == OP_HEARTBEAT_ACK:
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif op == OP_HEARTBEAT:
|
||||||
|
self._ws_send({"op": OP_HEARTBEAT, "d": self._last_seq})
|
||||||
|
|
||||||
|
elif op == OP_RECONNECT:
|
||||||
|
logger.warning("[QQ] Server requested reconnect")
|
||||||
|
self._can_resume = True
|
||||||
|
if self._ws:
|
||||||
|
self._ws.close()
|
||||||
|
|
||||||
|
elif op == OP_INVALID_SESSION:
|
||||||
|
logger.warning("[QQ] Invalid session, re-identifying...")
|
||||||
|
self._session_id = None
|
||||||
|
self._can_resume = False
|
||||||
|
time.sleep(2)
|
||||||
|
self._send_identify()
|
||||||
|
|
||||||
|
elif op == OP_DISPATCH:
|
||||||
|
if t == "READY":
|
||||||
|
self._session_id = d.get("session_id", "")
|
||||||
|
user = d.get("user", {})
|
||||||
|
bot_name = user.get('username', '')
|
||||||
|
logger.info(f"[QQ] ✅ Connected successfully (bot={bot_name})")
|
||||||
|
self._connected = True
|
||||||
|
self._can_resume = False
|
||||||
|
self._start_heartbeat(self._heartbeat_interval)
|
||||||
|
self.report_startup_success()
|
||||||
|
|
||||||
|
elif t == "RESUMED":
|
||||||
|
logger.info("[QQ] Session resumed successfully")
|
||||||
|
self._connected = True
|
||||||
|
self._can_resume = False
|
||||||
|
self._start_heartbeat(self._heartbeat_interval)
|
||||||
|
|
||||||
|
elif t in ("GROUP_AT_MESSAGE_CREATE", "C2C_MESSAGE_CREATE",
|
||||||
|
"AT_MESSAGE_CREATE", "DIRECT_MESSAGE_CREATE"):
|
||||||
|
self._handle_msg_event(d, t)
|
||||||
|
|
||||||
|
elif t in ("GROUP_ADD_ROBOT", "FRIEND_ADD"):
|
||||||
|
logger.info(f"[QQ] Event: {t}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.debug(f"[QQ] Dispatch event: {t}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Message event handling
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _handle_msg_event(self, event_data: dict, event_type: str):
|
||||||
|
msg_id = event_data.get("id", "")
|
||||||
|
if self.received_msgs.get(msg_id):
|
||||||
|
logger.debug(f"[QQ] Duplicate msg filtered: {msg_id}")
|
||||||
|
return
|
||||||
|
self.received_msgs[msg_id] = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
qq_msg = QQMessage(event_data, event_type)
|
||||||
|
except NotImplementedError as e:
|
||||||
|
logger.warning(f"[QQ] {e}")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[QQ] Failed to parse message: {e}", exc_info=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
is_group = qq_msg.is_group
|
||||||
|
|
||||||
|
from channel.file_cache import get_file_cache
|
||||||
|
file_cache = get_file_cache()
|
||||||
|
|
||||||
|
if is_group:
|
||||||
|
session_id = qq_msg.other_user_id
|
||||||
|
else:
|
||||||
|
session_id = qq_msg.from_user_id
|
||||||
|
|
||||||
|
if qq_msg.ctype == ContextType.IMAGE:
|
||||||
|
if hasattr(qq_msg, "image_path") and qq_msg.image_path:
|
||||||
|
file_cache.add(session_id, qq_msg.image_path, file_type="image")
|
||||||
|
logger.info(f"[QQ] Image cached for session {session_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if qq_msg.ctype == ContextType.TEXT:
|
||||||
|
cached_files = file_cache.get(session_id)
|
||||||
|
if cached_files:
|
||||||
|
file_refs = []
|
||||||
|
for fi in cached_files:
|
||||||
|
ftype = fi["type"]
|
||||||
|
fpath = fi["path"]
|
||||||
|
if ftype == "image":
|
||||||
|
file_refs.append(f"[图片: {fpath}]")
|
||||||
|
elif ftype == "video":
|
||||||
|
file_refs.append(f"[视频: {fpath}]")
|
||||||
|
else:
|
||||||
|
file_refs.append(f"[文件: {fpath}]")
|
||||||
|
qq_msg.content = qq_msg.content + "\n" + "\n".join(file_refs)
|
||||||
|
logger.info(f"[QQ] Attached {len(cached_files)} cached file(s)")
|
||||||
|
file_cache.clear(session_id)
|
||||||
|
|
||||||
|
context = self._compose_context(
|
||||||
|
qq_msg.ctype,
|
||||||
|
qq_msg.content,
|
||||||
|
isgroup=is_group,
|
||||||
|
msg=qq_msg,
|
||||||
|
no_need_at=True,
|
||||||
|
)
|
||||||
|
if context:
|
||||||
|
self.produce(context)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# _compose_context
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _compose_context(self, ctype: ContextType, content, **kwargs):
|
||||||
|
context = Context(ctype, content)
|
||||||
|
context.kwargs = kwargs
|
||||||
|
if "channel_type" not in context:
|
||||||
|
context["channel_type"] = self.channel_type
|
||||||
|
if "origin_ctype" not in context:
|
||||||
|
context["origin_ctype"] = ctype
|
||||||
|
|
||||||
|
cmsg = context["msg"]
|
||||||
|
|
||||||
|
if cmsg.is_group:
|
||||||
|
context["session_id"] = cmsg.other_user_id
|
||||||
|
else:
|
||||||
|
context["session_id"] = cmsg.from_user_id
|
||||||
|
|
||||||
|
context["receiver"] = cmsg.other_user_id
|
||||||
|
|
||||||
|
if ctype == ContextType.TEXT:
|
||||||
|
img_match_prefix = check_prefix(content, conf().get("image_create_prefix"))
|
||||||
|
if img_match_prefix:
|
||||||
|
content = content.replace(img_match_prefix, "", 1)
|
||||||
|
context.type = ContextType.IMAGE_CREATE
|
||||||
|
else:
|
||||||
|
context.type = ContextType.TEXT
|
||||||
|
context.content = content.strip()
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Send reply
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def send(self, reply: Reply, context: Context):
|
||||||
|
msg = context.get("msg")
|
||||||
|
is_group = context.get("isgroup", False)
|
||||||
|
receiver = context.get("receiver", "")
|
||||||
|
|
||||||
|
if not msg:
|
||||||
|
# Active send (e.g. scheduled tasks), no original message to reply to
|
||||||
|
self._active_send_text(reply.content if reply.type == ReplyType.TEXT else str(reply.content),
|
||||||
|
receiver, is_group)
|
||||||
|
return
|
||||||
|
|
||||||
|
event_type = getattr(msg, "event_type", "")
|
||||||
|
msg_id = getattr(msg, "msg_id", "")
|
||||||
|
|
||||||
|
if reply.type == ReplyType.TEXT:
|
||||||
|
self._send_text(reply.content, msg, event_type, msg_id)
|
||||||
|
elif reply.type in (ReplyType.IMAGE_URL, ReplyType.IMAGE):
|
||||||
|
self._send_image(reply.content, msg, event_type, msg_id)
|
||||||
|
elif reply.type == ReplyType.FILE:
|
||||||
|
if hasattr(reply, "text_content") and reply.text_content:
|
||||||
|
self._send_text(reply.text_content, msg, event_type, msg_id)
|
||||||
|
time.sleep(0.3)
|
||||||
|
self._send_file(reply.content, msg, event_type, msg_id)
|
||||||
|
elif reply.type in (ReplyType.VIDEO, ReplyType.VIDEO_URL):
|
||||||
|
self._send_media(reply.content, msg, event_type, msg_id, QQ_FILE_TYPE_VIDEO)
|
||||||
|
else:
|
||||||
|
logger.warning(f"[QQ] Unsupported reply type: {reply.type}, falling back to text")
|
||||||
|
self._send_text(str(reply.content), msg, event_type, msg_id)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Send helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_next_msg_seq(self, msg_id: str) -> int:
|
||||||
|
seq = self._msg_seq_counter.get(msg_id, 1)
|
||||||
|
self._msg_seq_counter[msg_id] = seq + 1
|
||||||
|
return seq
|
||||||
|
|
||||||
|
def _build_msg_url_and_base_body(self, msg: QQMessage, event_type: str, msg_id: str):
|
||||||
|
"""Build the API URL and base body dict for sending a message."""
|
||||||
|
if event_type == "GROUP_AT_MESSAGE_CREATE":
|
||||||
|
group_openid = msg._rawmsg.get("group_openid", "")
|
||||||
|
url = f"{QQ_API_BASE}/v2/groups/{group_openid}/messages"
|
||||||
|
body = {
|
||||||
|
"msg_id": msg_id,
|
||||||
|
"msg_seq": self._get_next_msg_seq(msg_id),
|
||||||
|
}
|
||||||
|
return url, body, "group", group_openid
|
||||||
|
|
||||||
|
elif event_type == "C2C_MESSAGE_CREATE":
|
||||||
|
user_openid = msg._rawmsg.get("author", {}).get("user_openid", "") or msg.from_user_id
|
||||||
|
url = f"{QQ_API_BASE}/v2/users/{user_openid}/messages"
|
||||||
|
body = {
|
||||||
|
"msg_id": msg_id,
|
||||||
|
"msg_seq": self._get_next_msg_seq(msg_id),
|
||||||
|
}
|
||||||
|
return url, body, "c2c", user_openid
|
||||||
|
|
||||||
|
elif event_type == "AT_MESSAGE_CREATE":
|
||||||
|
channel_id = msg._rawmsg.get("channel_id", "")
|
||||||
|
url = f"{QQ_API_BASE}/channels/{channel_id}/messages"
|
||||||
|
body = {"msg_id": msg_id}
|
||||||
|
return url, body, "channel", channel_id
|
||||||
|
|
||||||
|
elif event_type == "DIRECT_MESSAGE_CREATE":
|
||||||
|
guild_id = msg._rawmsg.get("guild_id", "")
|
||||||
|
url = f"{QQ_API_BASE}/dms/{guild_id}/messages"
|
||||||
|
body = {"msg_id": msg_id}
|
||||||
|
return url, body, "dm", guild_id
|
||||||
|
|
||||||
|
return None, None, None, None
|
||||||
|
|
||||||
|
def _post_message(self, url: str, body: dict, event_type: str):
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json=body, headers=self._get_auth_headers(), timeout=10)
|
||||||
|
if resp.status_code in (200, 201, 202, 204):
|
||||||
|
logger.info(f"[QQ] Message sent successfully: event_type={event_type}")
|
||||||
|
else:
|
||||||
|
logger.error(f"[QQ] Failed to send message: status={resp.status_code}, "
|
||||||
|
f"body={resp.text}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[QQ] Send message error: {e}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Active send (no original message, e.g. scheduled tasks)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _active_send_text(self, content: str, receiver: str, is_group: bool):
|
||||||
|
"""Send text without an original message (active push). QQ limits active messages to 4/month per user."""
|
||||||
|
if not receiver:
|
||||||
|
logger.warning("[QQ] No receiver for active send")
|
||||||
|
return
|
||||||
|
if is_group:
|
||||||
|
url = f"{QQ_API_BASE}/v2/groups/{receiver}/messages"
|
||||||
|
else:
|
||||||
|
url = f"{QQ_API_BASE}/v2/users/{receiver}/messages"
|
||||||
|
body = {
|
||||||
|
"content": content,
|
||||||
|
"msg_type": 0,
|
||||||
|
}
|
||||||
|
event_label = "GROUP_ACTIVE" if is_group else "C2C_ACTIVE"
|
||||||
|
self._post_message(url, body, event_label)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Send text
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _send_text(self, content: str, msg: QQMessage, event_type: str, msg_id: str):
|
||||||
|
url, body, _, _ = self._build_msg_url_and_base_body(msg, event_type, msg_id)
|
||||||
|
if not url:
|
||||||
|
logger.warning(f"[QQ] Cannot send reply for event_type: {event_type}")
|
||||||
|
return
|
||||||
|
body["content"] = content
|
||||||
|
body["msg_type"] = 0
|
||||||
|
self._post_message(url, body, event_type)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Rich media upload & send (image / video / file)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _upload_rich_media(self, file_url: str, file_type: int, msg: QQMessage,
|
||||||
|
event_type: str) -> str:
|
||||||
|
"""
|
||||||
|
Upload media via QQ rich media API and return file_info.
|
||||||
|
For group: POST /v2/groups/{group_openid}/files
|
||||||
|
For c2c: POST /v2/users/{openid}/files
|
||||||
|
"""
|
||||||
|
if event_type == "GROUP_AT_MESSAGE_CREATE":
|
||||||
|
group_openid = msg._rawmsg.get("group_openid", "")
|
||||||
|
upload_url = f"{QQ_API_BASE}/v2/groups/{group_openid}/files"
|
||||||
|
elif event_type == "C2C_MESSAGE_CREATE":
|
||||||
|
user_openid = (msg._rawmsg.get("author", {}).get("user_openid", "")
|
||||||
|
or msg.from_user_id)
|
||||||
|
upload_url = f"{QQ_API_BASE}/v2/users/{user_openid}/files"
|
||||||
|
else:
|
||||||
|
logger.warning(f"[QQ] Rich media upload not supported for event_type: {event_type}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
upload_body = {
|
||||||
|
"file_type": file_type,
|
||||||
|
"url": file_url,
|
||||||
|
"srv_send_msg": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
upload_url, json=upload_body,
|
||||||
|
headers=self._get_auth_headers(), timeout=30,
|
||||||
|
)
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
data = resp.json()
|
||||||
|
file_info = data.get("file_info", "")
|
||||||
|
logger.info(f"[QQ] Rich media uploaded: file_type={file_type}, "
|
||||||
|
f"file_uuid={data.get('file_uuid', '')}")
|
||||||
|
return file_info
|
||||||
|
else:
|
||||||
|
logger.error(f"[QQ] Rich media upload failed: status={resp.status_code}, "
|
||||||
|
f"body={resp.text}")
|
||||||
|
return ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[QQ] Rich media upload error: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _upload_rich_media_base64(self, file_path: str, file_type: int, msg: QQMessage,
|
||||||
|
event_type: str) -> str:
|
||||||
|
"""Upload local file via base64 file_data field."""
|
||||||
|
if event_type == "GROUP_AT_MESSAGE_CREATE":
|
||||||
|
group_openid = msg._rawmsg.get("group_openid", "")
|
||||||
|
upload_url = f"{QQ_API_BASE}/v2/groups/{group_openid}/files"
|
||||||
|
elif event_type == "C2C_MESSAGE_CREATE":
|
||||||
|
user_openid = (msg._rawmsg.get("author", {}).get("user_openid", "")
|
||||||
|
or msg.from_user_id)
|
||||||
|
upload_url = f"{QQ_API_BASE}/v2/users/{user_openid}/files"
|
||||||
|
else:
|
||||||
|
logger.warning(f"[QQ] Rich media upload not supported for event_type: {event_type}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
file_data = base64.b64encode(f.read()).decode("utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[QQ] Failed to read file for upload: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
upload_body = {
|
||||||
|
"file_type": file_type,
|
||||||
|
"file_data": file_data,
|
||||||
|
"srv_send_msg": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
upload_url, json=upload_body,
|
||||||
|
headers=self._get_auth_headers(), timeout=30,
|
||||||
|
)
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
data = resp.json()
|
||||||
|
file_info = data.get("file_info", "")
|
||||||
|
logger.info(f"[QQ] Rich media uploaded (base64): file_type={file_type}, "
|
||||||
|
f"file_uuid={data.get('file_uuid', '')}")
|
||||||
|
return file_info
|
||||||
|
else:
|
||||||
|
logger.error(f"[QQ] Rich media upload (base64) failed: status={resp.status_code}, "
|
||||||
|
f"body={resp.text}")
|
||||||
|
return ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[QQ] Rich media upload (base64) error: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _send_media_msg(self, file_info: str, msg: QQMessage, event_type: str, msg_id: str):
|
||||||
|
"""Send a message with msg_type=7 (rich media) using file_info."""
|
||||||
|
url, body, _, _ = self._build_msg_url_and_base_body(msg, event_type, msg_id)
|
||||||
|
if not url:
|
||||||
|
return
|
||||||
|
body["msg_type"] = 7
|
||||||
|
body["media"] = {"file_info": file_info}
|
||||||
|
self._post_message(url, body, event_type)
|
||||||
|
|
||||||
|
def _send_image(self, img_path_or_url: str, msg: QQMessage, event_type: str, msg_id: str):
|
||||||
|
"""Send image reply. Supports URL and local file path."""
|
||||||
|
if event_type not in ("GROUP_AT_MESSAGE_CREATE", "C2C_MESSAGE_CREATE"):
|
||||||
|
self._send_text(str(img_path_or_url), msg, event_type, msg_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if img_path_or_url.startswith("file://"):
|
||||||
|
img_path_or_url = img_path_or_url[7:]
|
||||||
|
|
||||||
|
if img_path_or_url.startswith(("http://", "https://")):
|
||||||
|
file_info = self._upload_rich_media(
|
||||||
|
img_path_or_url, QQ_FILE_TYPE_IMAGE, msg, event_type)
|
||||||
|
elif os.path.exists(img_path_or_url):
|
||||||
|
file_info = self._upload_rich_media_base64(
|
||||||
|
img_path_or_url, QQ_FILE_TYPE_IMAGE, msg, event_type)
|
||||||
|
else:
|
||||||
|
logger.error(f"[QQ] Image not found: {img_path_or_url}")
|
||||||
|
self._send_text("[Image send failed]", msg, event_type, msg_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if file_info:
|
||||||
|
self._send_media_msg(file_info, msg, event_type, msg_id)
|
||||||
|
else:
|
||||||
|
self._send_text("[Image upload failed]", msg, event_type, msg_id)
|
||||||
|
|
||||||
|
def _send_file(self, file_path_or_url: str, msg: QQMessage, event_type: str, msg_id: str):
|
||||||
|
"""Send file reply."""
|
||||||
|
if event_type not in ("GROUP_AT_MESSAGE_CREATE", "C2C_MESSAGE_CREATE"):
|
||||||
|
self._send_text(str(file_path_or_url), msg, event_type, msg_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if file_path_or_url.startswith("file://"):
|
||||||
|
file_path_or_url = file_path_or_url[7:]
|
||||||
|
|
||||||
|
if file_path_or_url.startswith(("http://", "https://")):
|
||||||
|
file_info = self._upload_rich_media(
|
||||||
|
file_path_or_url, QQ_FILE_TYPE_FILE, msg, event_type)
|
||||||
|
elif os.path.exists(file_path_or_url):
|
||||||
|
file_info = self._upload_rich_media_base64(
|
||||||
|
file_path_or_url, QQ_FILE_TYPE_FILE, msg, event_type)
|
||||||
|
else:
|
||||||
|
logger.error(f"[QQ] File not found: {file_path_or_url}")
|
||||||
|
self._send_text("[File send failed]", msg, event_type, msg_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if file_info:
|
||||||
|
self._send_media_msg(file_info, msg, event_type, msg_id)
|
||||||
|
else:
|
||||||
|
self._send_text("[File upload failed]", msg, event_type, msg_id)
|
||||||
|
|
||||||
|
def _send_media(self, path_or_url: str, msg: QQMessage, event_type: str,
|
||||||
|
msg_id: str, file_type: int):
|
||||||
|
"""Generic media send for video/voice etc."""
|
||||||
|
if event_type not in ("GROUP_AT_MESSAGE_CREATE", "C2C_MESSAGE_CREATE"):
|
||||||
|
self._send_text(str(path_or_url), msg, event_type, msg_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if path_or_url.startswith("file://"):
|
||||||
|
path_or_url = path_or_url[7:]
|
||||||
|
|
||||||
|
if path_or_url.startswith(("http://", "https://")):
|
||||||
|
file_info = self._upload_rich_media(path_or_url, file_type, msg, event_type)
|
||||||
|
elif os.path.exists(path_or_url):
|
||||||
|
file_info = self._upload_rich_media_base64(path_or_url, file_type, msg, event_type)
|
||||||
|
else:
|
||||||
|
logger.error(f"[QQ] Media not found: {path_or_url}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if file_info:
|
||||||
|
self._send_media_msg(file_info, msg, event_type, msg_id)
|
||||||
|
else:
|
||||||
|
logger.error(f"[QQ] Media upload failed: {path_or_url}")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user