mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 22:11:38 +08:00
Compare commits
835 Commits
codex/capa
...
codex/acti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd842b0574 | ||
|
|
433348672d | ||
|
|
46ab4ffe01 | ||
|
|
b958bccecf | ||
|
|
c13989d08e | ||
|
|
208c0ef783 | ||
|
|
64bc1a8e4c | ||
|
|
dd7999047b | ||
|
|
7ffb2ceaac | ||
|
|
5e23a26098 | ||
|
|
19f3740910 | ||
|
|
b224c12ae1 | ||
|
|
32bcb93527 | ||
|
|
cbb2697215 | ||
|
|
dae966a9b6 | ||
|
|
b8540b46b5 | ||
|
|
8f64587a1e | ||
|
|
8f0698d148 | ||
|
|
8abf8a1bee | ||
|
|
cfe71e2e44 | ||
|
|
ecc9a65f34 | ||
|
|
28fc5d9b5e | ||
|
|
9bcef781e7 | ||
|
|
83bdba2bae | ||
|
|
3e85f9c4ff | ||
|
|
a4bb2698dd | ||
|
|
bfff74fb11 | ||
|
|
dffa88f396 | ||
|
|
ba68537d9d | ||
|
|
5b090561fb | ||
|
|
eb9ce9482c | ||
|
|
f665da8dbc | ||
|
|
abf81ff1ed | ||
|
|
179ccb952c | ||
|
|
182d41d678 | ||
|
|
493e1c246e | ||
|
|
e51a00ffc7 | ||
|
|
ad6bfc44d5 | ||
|
|
0995ee0134 | ||
|
|
b78202d44e | ||
|
|
e418a6d0cc | ||
|
|
6484b41eb9 | ||
|
|
a00b01f5ed | ||
|
|
b5d2bd6f41 | ||
|
|
4e69a9b329 | ||
|
|
cde12e63e7 | ||
|
|
f312d6c106 | ||
|
|
e7538b4499 | ||
|
|
02bd9e8c10 | ||
|
|
35eb70f1f5 | ||
|
|
986536ff6b | ||
|
|
f6544a0a3b | ||
|
|
daeff2fa89 | ||
|
|
76bde3d42b | ||
|
|
816a3eae8a | ||
|
|
5aa4fd3216 | ||
|
|
7d18b145f8 | ||
|
|
cdf18c16b4 | ||
|
|
3182588ad4 | ||
|
|
82535771cd | ||
|
|
f9f38a48e6 | ||
|
|
9a106f7e3c | ||
|
|
e8b446b985 | ||
|
|
f93b217834 | ||
|
|
63e6bb026c | ||
|
|
4f421fa0f1 | ||
|
|
18fb171179 | ||
|
|
bffb83acf8 | ||
|
|
cfbe7ac227 | ||
|
|
e5aae5e056 | ||
|
|
744d176744 | ||
|
|
4a0b8c6248 | ||
|
|
f02ba9a3ed | ||
|
|
6380c872bc | ||
|
|
a383878e97 | ||
|
|
93ab2ac69d | ||
|
|
ceb2311a1b | ||
|
|
86f35a9bc0 | ||
|
|
23ab290a71 | ||
|
|
9edf9804b1 | ||
|
|
d78512b09d | ||
|
|
4108901932 | ||
|
|
d855f5f505 | ||
|
|
12331f0463 | ||
|
|
14ec1ac50f | ||
|
|
adb7b0d5d6 | ||
|
|
e617aa6d1e | ||
|
|
7c478473fe | ||
|
|
16cebe5669 | ||
|
|
049acf23cb | ||
|
|
df881d5c18 | ||
|
|
caecd3c1fe | ||
|
|
c6b5731c5d | ||
|
|
b2dc25cd12 | ||
|
|
037340d287 | ||
|
|
6058eacaec | ||
|
|
1a3f141215 | ||
|
|
cebfa70277 | ||
|
|
d40dc8f025 | ||
|
|
d56fe040b4 | ||
|
|
9e61209780 | ||
|
|
d4eb3e12c9 | ||
|
|
0828db93e9 | ||
|
|
c1fc2ed0e8 | ||
|
|
f0c9978030 | ||
|
|
67a3af7f8d | ||
|
|
e46e32b98c | ||
|
|
dac72889e5 | ||
|
|
23edd9921e | ||
|
|
904017814b | ||
|
|
76bc0ae32f | ||
|
|
2de8b91448 | ||
|
|
e8c0f25598 | ||
|
|
5880ec17b1 | ||
|
|
6a6a279fda | ||
|
|
a563f1f4a0 | ||
|
|
96724e5a4b | ||
|
|
ba6213bc14 | ||
|
|
8cee6f96e6 | ||
|
|
d366b13ec9 | ||
|
|
eb29782416 | ||
|
|
57a3744f16 | ||
|
|
a96790fde7 | ||
|
|
9975e3172d | ||
|
|
2e1979a600 | ||
|
|
e973275fd0 | ||
|
|
9c56c84ce0 | ||
|
|
9d4b0d551d | ||
|
|
ea9efc0e81 | ||
|
|
1d7e87580d | ||
|
|
c3074bd513 | ||
|
|
bbcc95948e | ||
|
|
761e12008d | ||
|
|
ddde144cb6 | ||
|
|
9e007ef759 | ||
|
|
774b6b6438 | ||
|
|
f476f8211c | ||
|
|
4bcbb22678 | ||
|
|
d43cc470c6 | ||
|
|
ac6693986b | ||
|
|
c2995bc470 | ||
|
|
91b3446098 | ||
|
|
6fe93b55cb | ||
|
|
17a8c896a4 | ||
|
|
a3d5630232 | ||
|
|
067f158b74 | ||
|
|
d3b359a1c2 | ||
|
|
d9333ac095 | ||
|
|
8b2b52dc94 | ||
|
|
8894dab3c4 | ||
|
|
cd92c6289c | ||
|
|
bcaa195c52 | ||
|
|
1f48ee8f9c | ||
|
|
9314bb7180 | ||
|
|
948d139399 | ||
|
|
eba04199f8 | ||
|
|
60d9c150b2 | ||
|
|
2cd11565a6 | ||
|
|
a903936750 | ||
|
|
ad605052bf | ||
|
|
f2b13b0a1a | ||
|
|
62a5480808 | ||
|
|
898579d8ba | ||
|
|
da300c12e3 | ||
|
|
dfe1ef9041 | ||
|
|
72559324b3 | ||
|
|
2cd8b2adf4 | ||
|
|
e8bbd19aa2 | ||
|
|
e0ea007536 | ||
|
|
8df1dbb8c7 | ||
|
|
33e93e2a07 | ||
|
|
47563305a2 | ||
|
|
c8e290fe22 | ||
|
|
7316a95327 | ||
|
|
c385a2d45e | ||
|
|
ee6ff1b8c2 | ||
|
|
b7e049b390 | ||
|
|
d0651e688a | ||
|
|
83d08440dc | ||
|
|
1409d5a160 | ||
|
|
f3d105b5e8 | ||
|
|
09c6528bc7 | ||
|
|
92c912ef66 | ||
|
|
3f8d7bb1fe | ||
|
|
df993291b6 | ||
|
|
2e0354e725 | ||
|
|
d607740c4a | ||
|
|
42fa0cb438 | ||
|
|
cc70e663f1 | ||
|
|
67da64f98d | ||
|
|
4c67991f43 | ||
|
|
60199fbee3 | ||
|
|
365c30fbfe | ||
|
|
40bdf60ad6 | ||
|
|
e7bef917c9 | ||
|
|
f8f0c3a017 | ||
|
|
85b518f1ca | ||
|
|
602e45af94 | ||
|
|
62793e6027 | ||
|
|
ab4a6faf86 | ||
|
|
e0c5b6c280 | ||
|
|
572c5b6dd0 | ||
|
|
f09cee84f2 | ||
|
|
a93a94788a | ||
|
|
88b394ba1b | ||
|
|
ae4f8da94f | ||
|
|
cb28d8d6b8 | ||
|
|
a15a5a1edc | ||
|
|
b96155e4e7 | ||
|
|
4b8bca3444 | ||
|
|
46db833772 | ||
|
|
b747e0c34d | ||
|
|
9d26b1056f | ||
|
|
b0e138f7fd | ||
|
|
88e407cd8c | ||
|
|
9743c2538c | ||
|
|
833bd61aa1 | ||
|
|
4ede1e4e3a | ||
|
|
59aea1e74d | ||
|
|
f461033c66 | ||
|
|
dc854ec521 | ||
|
|
6fdea7c755 | ||
|
|
4c97582d4b | ||
|
|
76296a9d14 | ||
|
|
97c031a8db | ||
|
|
dfb6c9c920 | ||
|
|
de3f742221 | ||
|
|
9a6a1508c1 | ||
|
|
3a07d664a8 | ||
|
|
9d6c874d50 | ||
|
|
65aab4bb27 | ||
|
|
18acfe7352 | ||
|
|
a33dd445b2 | ||
|
|
999508ff07 | ||
|
|
9716f970a3 | ||
|
|
8e4eaec394 | ||
|
|
0cbf99ab42 | ||
|
|
da6ca1c094 | ||
|
|
978a0a720e | ||
|
|
50265c8b1f | ||
|
|
f2fa096f14 | ||
|
|
16fad4d7d6 | ||
|
|
443035ba52 | ||
|
|
0161872c41 | ||
|
|
5390eadc4e | ||
|
|
1cec37184c | ||
|
|
ead634812e | ||
|
|
c084630f9e | ||
|
|
9d358d557d | ||
|
|
fdc88a753f | ||
|
|
7834cc14f0 | ||
|
|
7a2a594044 | ||
|
|
af8712fff1 | ||
|
|
e943efc048 | ||
|
|
991d4e2006 | ||
|
|
00e902a60b | ||
|
|
c83db77629 | ||
|
|
98822fdd63 | ||
|
|
f22d708d6f | ||
|
|
bc79bbda0c | ||
|
|
12864e3b21 | ||
|
|
87e0353b06 | ||
|
|
5a652303b5 | ||
|
|
b03522bcd8 | ||
|
|
de6bac331c | ||
|
|
7d2088132d | ||
|
|
c541a9c110 | ||
|
|
e5716394ca | ||
|
|
922459dda0 | ||
|
|
3493db46a4 | ||
|
|
b374a031ec | ||
|
|
768f2fdc47 | ||
|
|
4091fe17b9 | ||
|
|
353678ec05 | ||
|
|
934927fd13 | ||
|
|
bbe5a4b31a | ||
|
|
d6132e10f4 | ||
|
|
e2b5bdd500 | ||
|
|
37a7baf270 | ||
|
|
3a2e347dc7 | ||
|
|
b39c7eece6 | ||
|
|
fbf7859f6d | ||
|
|
43e6c923de | ||
|
|
8183e2d657 | ||
|
|
447ab8102a | ||
|
|
8ebd022377 | ||
|
|
b1255b0e0b | ||
|
|
ee55350450 | ||
|
|
721097f2e9 | ||
|
|
f856e0b72f | ||
|
|
125feadc48 | ||
|
|
d5faa699da | ||
|
|
2f51dfca01 | ||
|
|
74c239c77d | ||
|
|
ac478e2024 | ||
|
|
6071c6f6ea | ||
|
|
48ea1c3492 | ||
|
|
9ea3da08df | ||
|
|
1e5b026e61 | ||
|
|
f13542f211 | ||
|
|
f54188f600 | ||
|
|
aa61b508d1 | ||
|
|
d6b634bc30 | ||
|
|
a20d96ae31 | ||
|
|
8be79a09b8 | ||
|
|
0ca8eb40c1 | ||
|
|
525e78e3d9 | ||
|
|
ce18c3e9e7 | ||
|
|
be3b7cf875 | ||
|
|
5489bff7c3 | ||
|
|
1604b4a304 | ||
|
|
9a4e35a24f | ||
|
|
cd54f20fe2 | ||
|
|
a8e46e7048 | ||
|
|
5613f5a834 | ||
|
|
f6124f3e17 | ||
|
|
4fa7931b1b | ||
|
|
29732c1459 | ||
|
|
1fdb013599 | ||
|
|
36938bccb5 | ||
|
|
5de04bc1d5 | ||
|
|
967ecddfed | ||
|
|
6bd6f4d27c | ||
|
|
4dc16e1567 | ||
|
|
af1cf77b16 | ||
|
|
fbdb20ffd3 | ||
|
|
3139d2007e | ||
|
|
55f07e0381 | ||
|
|
0bbd70ac79 | ||
|
|
9437c24764 | ||
|
|
1baff9c64c | ||
|
|
874ca3d691 | ||
|
|
1395650d95 | ||
|
|
0b04d27beb | ||
|
|
881f41d4a1 | ||
|
|
1b20303c0c | ||
|
|
1e5f5fa319 | ||
|
|
d008e2d015 | ||
|
|
b6a806d67b | ||
|
|
56b0714004 | ||
|
|
3fbb229d04 | ||
|
|
ec708f44df | ||
|
|
dbcb1f06ec | ||
|
|
90e8bef253 | ||
|
|
f7957d3bb7 | ||
|
|
b21dd9c635 | ||
|
|
733063e31c | ||
|
|
d84ac5b1eb | ||
|
|
9dda94c0f7 | ||
|
|
24d4acb274 | ||
|
|
b4d0d6fcc9 | ||
|
|
2b5f663c9c | ||
|
|
67e6f88e42 | ||
|
|
a5efc9a6c9 | ||
|
|
74ea9de6f2 | ||
|
|
434d56a948 | ||
|
|
f54a57b80a | ||
|
|
f1bdfca1ed | ||
|
|
cb29ecc100 | ||
|
|
255abc57b9 | ||
|
|
edfc8eb91a | ||
|
|
dd3e86d35b | ||
|
|
bf040219e4 | ||
|
|
4d4dbe8e15 | ||
|
|
c2f9de3935 | ||
|
|
dbc7710938 | ||
|
|
5ae27dfb5a | ||
|
|
e3cb19d162 | ||
|
|
524951e124 | ||
|
|
16877efba3 | ||
|
|
9db1a7acf0 | ||
|
|
4329d94de3 | ||
|
|
34c78d3ba4 | ||
|
|
991e25b880 | ||
|
|
f510576959 | ||
|
|
ea5faa9b39 | ||
|
|
7f6f6d8023 | ||
|
|
f2494aa33f | ||
|
|
5b5018bac5 | ||
|
|
ba484d263b | ||
|
|
ae12aa49c3 | ||
|
|
43b62c8753 | ||
|
|
a14aef191b | ||
|
|
5a1cf20aee | ||
|
|
124cd5e307 | ||
|
|
45663f2879 | ||
|
|
dc7b21bf36 | ||
|
|
e331694df6 | ||
|
|
54a884865e | ||
|
|
36aeef30c2 | ||
|
|
0312085408 | ||
|
|
85c75f6573 | ||
|
|
649de6d156 | ||
|
|
b697cec223 | ||
|
|
6236db5192 | ||
|
|
4c97f0f0ce | ||
|
|
365d5a410b | ||
|
|
8119915664 | ||
|
|
9d8d1dd4c5 | ||
|
|
f336d8c948 | ||
|
|
7087845f58 | ||
|
|
9c9b0effda | ||
|
|
eac6e2d42d | ||
|
|
fb64ba7bf7 | ||
|
|
81f48384cb | ||
|
|
ad49549c92 | ||
|
|
a799fd7ca7 | ||
|
|
1baf5533aa | ||
|
|
282188a326 | ||
|
|
58e822e712 | ||
|
|
eafe0a6d67 | ||
|
|
389075abc8 | ||
|
|
65f9fc397e | ||
|
|
d56831f81b | ||
|
|
0af808b457 | ||
|
|
a227d1cc65 | ||
|
|
1697bb7d23 | ||
|
|
0e51f2f2ab | ||
|
|
17085ec1a4 | ||
|
|
25fae3d722 | ||
|
|
fd6d3f270d | ||
|
|
01e443755c | ||
|
|
7b53b00009 | ||
|
|
0b159d7250 | ||
|
|
9e9730a55e | ||
|
|
294ee477ac | ||
|
|
2988203a5e | ||
|
|
6a559f0293 | ||
|
|
0d3cd4ac42 | ||
|
|
44fd8b0d6e | ||
|
|
947a43dae3 | ||
|
|
d5ed6d26e9 | ||
|
|
a543c240c9 | ||
|
|
86361f4fca | ||
|
|
ce7ef626b8 | ||
|
|
5eb6921a18 | ||
|
|
02c08b3929 | ||
|
|
2197ce62bd | ||
|
|
b3e6822ef8 | ||
|
|
a5ff85f01c | ||
|
|
763dc614c0 | ||
|
|
dbc67a5626 | ||
|
|
424b65b697 | ||
|
|
90a45a4907 | ||
|
|
fca8ff5748 | ||
|
|
ce19b6bf6a | ||
|
|
c19f322ff9 | ||
|
|
49fbecbf16 | ||
|
|
2ceafbafcc | ||
|
|
e811d04db4 | ||
|
|
91a3af4e24 | ||
|
|
eb4bc200d7 | ||
|
|
494c25b0c4 | ||
|
|
8a6bb1b80e | ||
|
|
880def088e | ||
|
|
b9179ee4b6 | ||
|
|
68bfc6fcf5 | ||
|
|
de2182877a | ||
|
|
5a3293d400 | ||
|
|
28458fa4a8 | ||
|
|
60cd350220 | ||
|
|
e8ea1fe99d | ||
|
|
9a9dc1dbec | ||
|
|
c5a181bf8b | ||
|
|
adededf2f9 | ||
|
|
b3afb2f950 | ||
|
|
38935d18c7 | ||
|
|
242b2e66f2 | ||
|
|
3243c9b5b0 | ||
|
|
2ddeed40c8 | ||
|
|
f13815d9bd | ||
|
|
e1d026f575 | ||
|
|
a305f2f6b0 | ||
|
|
565a228591 | ||
|
|
647aade27a | ||
|
|
1ac4e46cbb | ||
|
|
6c69998291 | ||
|
|
bbfc46fe02 | ||
|
|
b7824ec414 | ||
|
|
1335ce37a8 | ||
|
|
c4dcaf91cd | ||
|
|
858b194095 | ||
|
|
5a89ffe0d8 | ||
|
|
e69b4e4606 | ||
|
|
bc6e5128d2 | ||
|
|
d34d88e0a3 | ||
|
|
55eb9841d9 | ||
|
|
f285087c85 | ||
|
|
f7dc5f930a | ||
|
|
944199d77b | ||
|
|
674b658ff0 | ||
|
|
fb10773a38 | ||
|
|
58744f3d87 | ||
|
|
087eb621ff | ||
|
|
b28cc98c9b | ||
|
|
04681e9770 | ||
|
|
fbb56f0ed2 | ||
|
|
6c7426ed54 | ||
|
|
cf2fc4fdbb | ||
|
|
38a673b688 | ||
|
|
37dccb52ed | ||
|
|
cdbef11809 | ||
|
|
b176bf13af | ||
|
|
6239ab3667 | ||
|
|
59318d9ff8 | ||
|
|
fab7b2a4de | ||
|
|
b081f88952 | ||
|
|
1c3f82dcef | ||
|
|
13a60aa93b | ||
|
|
625fd5b3e3 | ||
|
|
c8b7058058 | ||
|
|
db76f18712 | ||
|
|
7b79579d20 | ||
|
|
e608b7e6f6 | ||
|
|
e318f48ff2 | ||
|
|
371c4147f3 | ||
|
|
768e606f96 | ||
|
|
28d478dc52 | ||
|
|
679a393f6d | ||
|
|
0a6fd459f9 | ||
|
|
dfec7d7f80 | ||
|
|
972fe9286d | ||
|
|
a5991e8017 | ||
|
|
1b2f640c5a | ||
|
|
997a16fa50 | ||
|
|
ad0c4309e6 | ||
|
|
c00cd4b414 | ||
|
|
a3b2fdf7d6 | ||
|
|
0fab2b9b4e | ||
|
|
bcb14cdc40 | ||
|
|
43cc92dc07 | ||
|
|
7155aa9c15 | ||
|
|
9a66b9cd54 | ||
|
|
68e421c487 | ||
|
|
b8451e26a3 | ||
|
|
13df67ebc8 | ||
|
|
d01ec5cee9 | ||
|
|
e8817dde8e | ||
|
|
e16a64ba1a | ||
|
|
7b36fa7672 | ||
|
|
f5c0356b37 | ||
|
|
db0b91417e | ||
|
|
c25ed721f8 | ||
|
|
9fcef82f2d | ||
|
|
41b1d3647c | ||
|
|
820201a343 | ||
|
|
0d5f386f5c | ||
|
|
aad3bbebdd | ||
|
|
e8fb140642 | ||
|
|
50f5831382 | ||
|
|
39ca8d1c53 | ||
|
|
576fb46e28 | ||
|
|
8822f779d9 | ||
|
|
775fa78b1e | ||
|
|
1dea64ab99 | ||
|
|
829fe14188 | ||
|
|
cd313c7f67 | ||
|
|
4504efb7ec | ||
|
|
870cc22cb0 | ||
|
|
575c486ef4 | ||
|
|
b514d61000 | ||
|
|
e42f11ed62 | ||
|
|
ea7297b344 | ||
|
|
059197e496 | ||
|
|
8e8c7344bd | ||
|
|
34c1e53792 | ||
|
|
c12cbaca66 | ||
|
|
02261e931c | ||
|
|
f1b7dd6c0a | ||
|
|
7240830ca4 | ||
|
|
e2f0ea4625 | ||
|
|
2aabe0e8fd | ||
|
|
80826bc000 | ||
|
|
d0562a873f | ||
|
|
42ae213ba6 | ||
|
|
88a63a1816 | ||
|
|
1aca95ae15 | ||
|
|
86679ba84e | ||
|
|
0cb162f05c | ||
|
|
c15919846c | ||
|
|
df58f73a2d | ||
|
|
2091334399 | ||
|
|
687bb21b28 | ||
|
|
bd99671756 | ||
|
|
9c04bdf6de | ||
|
|
d9f1c61361 | ||
|
|
808c34b374 | ||
|
|
8c8c5fa635 | ||
|
|
8d05bdda43 | ||
|
|
9869941c06 | ||
|
|
21802f750f | ||
|
|
1275b9b873 | ||
|
|
7e1f04f36a | ||
|
|
326b36794f | ||
|
|
7a2abb1c50 | ||
|
|
ce1d2c1004 | ||
|
|
c2cd1aed5d | ||
|
|
d0e53a3529 | ||
|
|
ac9464441c | ||
|
|
8cde0167c5 | ||
|
|
60ec27bce0 | ||
|
|
998cc02af4 | ||
|
|
a1e0090fe4 | ||
|
|
dc39e84fdd | ||
|
|
7cf72f7bc8 | ||
|
|
c569e5faba | ||
|
|
fdacaf0853 | ||
|
|
f60c1bb9ad | ||
|
|
67c4733267 | ||
|
|
5b1b7f0f80 | ||
|
|
27d4992eef | ||
|
|
c9c656f2cb | ||
|
|
3107faf571 | ||
|
|
123cc880f3 | ||
|
|
4ff82e9c4a | ||
|
|
e0a0d1f0b3 | ||
|
|
6f900b55fa | ||
|
|
f2602a5d7b | ||
|
|
4dbe8f9f66 | ||
|
|
05e89ff117 | ||
|
|
8b501986aa | ||
|
|
b059328f60 | ||
|
|
8c7dd66a7b | ||
|
|
2f115bc645 | ||
|
|
d9fbfa268f | ||
|
|
d03985415d | ||
|
|
b7be963501 | ||
|
|
59eb291c6e | ||
|
|
80a37ef32a | ||
|
|
7dc085890e | ||
|
|
bbe9b7ba15 | ||
|
|
a03e430248 | ||
|
|
e169fcd263 | ||
|
|
54cd8ed25b | ||
|
|
69f4022950 | ||
|
|
dde1aa8fed | ||
|
|
86f15687b5 | ||
|
|
47e6c57a7a | ||
|
|
b59560c49a | ||
|
|
8c1b954c1b | ||
|
|
44f3539c4f | ||
|
|
ac783d75f0 | ||
|
|
35fd766131 | ||
|
|
f289ba6a05 | ||
|
|
9fd47a5aed | ||
|
|
8d2ccd851c | ||
|
|
d34b3ec701 | ||
|
|
53f6d962c2 | ||
|
|
fc4203d9d9 | ||
|
|
78ff2519e0 | ||
|
|
44cf74717b | ||
|
|
98e27b8a09 | ||
|
|
ca72c2677b | ||
|
|
ba1ffaca57 | ||
|
|
8e62c12ff3 | ||
|
|
4436ca23ca | ||
|
|
416a3148e9 | ||
|
|
1092691d14 | ||
|
|
9cd225ebbe | ||
|
|
ddd0fcdc83 | ||
|
|
4094bf9985 | ||
|
|
96d575de1d | ||
|
|
6f7d0a016c | ||
|
|
338c7b8d66 | ||
|
|
8cea63c61b | ||
|
|
5291a2cfd1 | ||
|
|
6ab359f5a9 | ||
|
|
695176542f | ||
|
|
f0ba7b95da | ||
|
|
76592217ff | ||
|
|
c8ff474c28 | ||
|
|
ed156ee2de | ||
|
|
ccd3fec23e | ||
|
|
add2b8e9f0 | ||
|
|
7a289940cd | ||
|
|
2499accca0 | ||
|
|
3da203556c | ||
|
|
1b243ad562 | ||
|
|
d8dbacb900 | ||
|
|
64c18bc77b | ||
|
|
3342096336 | ||
|
|
d27370702b | ||
|
|
d4360f8068 | ||
|
|
9b7c0bf8e9 | ||
|
|
3d23103081 | ||
|
|
474db91bed | ||
|
|
09b31f9123 | ||
|
|
7f6277b6e5 | ||
|
|
11eed107f4 | ||
|
|
9e2a1e12fd | ||
|
|
77a161c811 | ||
|
|
8a40cd7ed4 | ||
|
|
325ff24bae | ||
|
|
533bd00001 | ||
|
|
999d88d13d | ||
|
|
782247b423 | ||
|
|
fef4e4621a | ||
|
|
a4253deb67 | ||
|
|
3417dbabf4 | ||
|
|
ab6aa28049 | ||
|
|
61f7d53731 | ||
|
|
899f490c9c | ||
|
|
f178a9dc41 | ||
|
|
a88f240311 | ||
|
|
560a7aecd0 | ||
|
|
59ccea334d | ||
|
|
a685a7afc9 | ||
|
|
f96c753ed3 | ||
|
|
6cb11360fa | ||
|
|
d6b2be95b6 | ||
|
|
1111639a11 | ||
|
|
8daf60e2d9 | ||
|
|
eab50fe488 | ||
|
|
061a9b5c58 | ||
|
|
ac3f55504c | ||
|
|
f4fcaa09a3 | ||
|
|
b44c10e91c | ||
|
|
017c25b075 | ||
|
|
b8c8139138 | ||
|
|
98b76d83ea | ||
|
|
f832388e0e | ||
|
|
8c38c662c1 | ||
|
|
fbebf6147c | ||
|
|
191f867ef6 | ||
|
|
1ce9ab36df | ||
|
|
1f6e303e41 | ||
|
|
063ed12bc6 | ||
|
|
fcd9a04e47 | ||
|
|
d6b1cce55c | ||
|
|
0003a3cf3e | ||
|
|
4a7edbf471 | ||
|
|
d5801c03ed | ||
|
|
1566a5b3bc | ||
|
|
d014472ab8 | ||
|
|
a1281d45b2 | ||
|
|
539a8b1619 | ||
|
|
10bc10b853 | ||
|
|
f16e9364d2 | ||
|
|
978513aa6b | ||
|
|
e5be7c2cd4 | ||
|
|
f11005de45 | ||
|
|
13d1fc077b | ||
|
|
ad8341676e | ||
|
|
bd2ac38c1d | ||
|
|
01dc9792fc | ||
|
|
3a1ca98e53 | ||
|
|
d2a03eca1a | ||
|
|
a690eafdf7 | ||
|
|
6e482fc7cf | ||
|
|
d14497301f | ||
|
|
9d37f1e5df | ||
|
|
e87300e2f4 | ||
|
|
7388600b06 | ||
|
|
a6e1fe0296 | ||
|
|
917373b69c | ||
|
|
fe9c4fcf51 | ||
|
|
e336311126 | ||
|
|
d94938ff54 | ||
|
|
3a42641208 | ||
|
|
6164e83b44 | ||
|
|
425592cf9c | ||
|
|
1e7f39abdb | ||
|
|
7071f6e442 | ||
|
|
9697925d4a | ||
|
|
955f38086b | ||
|
|
8f592657ed | ||
|
|
cba1ac3c05 | ||
|
|
453f874d64 | ||
|
|
a27a632e9d | ||
|
|
0db491294b | ||
|
|
d08abd8ce4 | ||
|
|
07020c5627 | ||
|
|
38715ef1b5 | ||
|
|
7928da0f48 | ||
|
|
e2c41df0b9 | ||
|
|
49aef60447 | ||
|
|
0fdb176465 | ||
|
|
4aa31ee6e1 | ||
|
|
e0018999b3 | ||
|
|
bd2798ec5f | ||
|
|
5c9ec970b8 | ||
|
|
1990d2e761 | ||
|
|
ab0c102ed7 | ||
|
|
096e6462c7 | ||
|
|
79f02b6e54 | ||
|
|
ecc13c65f5 | ||
|
|
9005521d63 | ||
|
|
1722bfab93 | ||
|
|
4603f231c3 | ||
|
|
2a6e8dca47 | ||
|
|
3eecbc3c7d | ||
|
|
8f64e1e061 | ||
|
|
a0cf1cc4ad | ||
|
|
637a5b137e | ||
|
|
ca8570be02 | ||
|
|
a463a33eee | ||
|
|
ee04ba0386 | ||
|
|
ee03ad7d9c | ||
|
|
b27a6f8196 | ||
|
|
31d05bb3a4 | ||
|
|
d8e7017326 | ||
|
|
2b5d8ac951 | ||
|
|
15c218c43f | ||
|
|
201697c200 | ||
|
|
421db1a5ec | ||
|
|
7901296153 | ||
|
|
cd09f41fe0 | ||
|
|
7dd23a59db | ||
|
|
c4e9189dd3 | ||
|
|
32eff914c6 | ||
|
|
d88eb0e031 | ||
|
|
e9befcff9e | ||
|
|
08c0018536 | ||
|
|
dc08aea25f | ||
|
|
ea78fb2d32 | ||
|
|
8010f00816 | ||
|
|
f97b1fa0c3 | ||
|
|
7853d42ee9 | ||
|
|
c595ff8e62 | ||
|
|
e894d98cb2 | ||
|
|
41f20e9143 | ||
|
|
2f4b322911 | ||
|
|
e420468ebd | ||
|
|
8e3c597e80 | ||
|
|
29163a8caa | ||
|
|
0b7f6fa9d0 | ||
|
|
a8ac0b7976 | ||
|
|
92e3299793 | ||
|
|
b7e249fc08 | ||
|
|
58c670acc2 | ||
|
|
ca73e598e0 | ||
|
|
08296e9645 | ||
|
|
f6c3474342 | ||
|
|
e44a995e83 | ||
|
|
528868ef76 |
@@ -33,6 +33,7 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
|
||||
- The aggregate npm-update wrapper must resolve the Linux VM with the same Ubuntu fallback policy as `parallels-linux-smoke.sh` before both fresh and update lanes. Treat any Ubuntu guest with major version `>= 24` as acceptable when the exact default VM is missing, preferring the closest version match. On Peter's current host today, missing `Ubuntu 24.04.3 ARM64` should fall back to `Ubuntu 25.10`.
|
||||
- On macOS same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; launchd can otherwise report a loaded service while the old process has exited and the fresh process is not RPC-ready yet.
|
||||
- On Windows same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; in-place global npm updates can otherwise leave stale hashed `dist/*` module imports alive in the running service.
|
||||
- After that Windows restart, do not trust one `gateway status --deep --require-rpc` call after a fixed sleep. Retry the RPC-ready probe for roughly 30 seconds and log each attempt; current guests can keep port `18789` bound while the fresh RPC endpoint is still coming up.
|
||||
- For Windows same-guest update checks, prefer the done-file/log-drain PowerShell runner pattern over one long-lived `prlctl exec ... powershell -EncodedCommand ...` transport. The guest can finish successfully while the outer `prlctl exec` still hangs.
|
||||
- The Windows same-guest update helper should write stage markers to its log before long steps like tgz download and `npm install -g` so the outer progress monitor does not sit on `waiting for first log line` during healthy but quiet installs.
|
||||
- Linux same-guest update verification should also export `HOME=/root`, pass `OPENAI_API_KEY` via `prlctl exec ... /usr/bin/env`, and use `openclaw agent --local`; the fresh Linux baseline does not rely on persisted gateway credentials.
|
||||
@@ -56,6 +57,7 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
|
||||
- On Peter's Tahoe VM, `fresh-latest-march-2026` can hang in `prlctl snapshot-switch`; if restore times out there, rerun with `--snapshot-hint 'macOS 26.3.1 latest'` before blaming auth or the harness.
|
||||
- `parallels-macos-smoke.sh` now retries `snapshot-switch` once after force-stopping a stuck running/suspended guest. If Tahoe still times out after that recovery path, then treat it as a real Parallels/host issue and rerun manually.
|
||||
- The macOS smoke should include a dashboard load phase after gateway health: resolve the tokenized URL with `openclaw dashboard --no-open`, verify the served HTML contains the Control UI title/root shell, then open Safari and require an established localhost TCP connection from Safari to the gateway port.
|
||||
- If a Tahoe lane times out in `fresh.first-agent-turn` and the phase log stops right after `__OPENCLAW_RC__:0` from `models set`, suspect the `prlctl enter` / `expect` wrapper before blaming auth or the model lane. That pattern means the first guest command finished but the transport never released for the next `guest_current_user_cli` call.
|
||||
- If a packaged install regresses with `500` on `/`, `/healthz`, or `__openclaw/control-ui-config.json` after `fresh.install-main` or `upgrade.install-main`, suspect bundled plugin runtime deps resolving from the package root `node_modules` rather than `dist/extensions/*/node_modules`. Repro quickly with a real `npm pack`/global install lane before blaming dashboard auth or Safari.
|
||||
- `prlctl exec` is fine for deterministic repo commands, but use the guest Terminal or `prlctl enter` when installer parity or shell-sensitive behavior matters.
|
||||
- Multi-word `openclaw agent --message ...` checks should go through a guest shell wrapper (`guest_current_user_sh` / `guest_current_user_cli` or `/bin/sh -lc ...`), not raw `prlctl exec ... node openclaw.mjs ...`, or the message can be split into extra argv tokens and Commander reports `too many arguments for 'agent'`.
|
||||
|
||||
@@ -39,7 +39,6 @@ pnpm openclaw qa suite \
|
||||
--provider-mode live-openai \
|
||||
--model openai/gpt-5.4 \
|
||||
--alt-model openai/gpt-5.4 \
|
||||
--fast \
|
||||
--output-dir .artifacts/qa-e2e/run-all-live-openai-<tag>
|
||||
```
|
||||
|
||||
|
||||
BIN
.github/pr-assets/compaction-checkpoints/sessions-checkpoints-inline.png
vendored
Normal file
BIN
.github/pr-assets/compaction-checkpoints/sessions-checkpoints-inline.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
BIN
.github/pr-assets/compaction-checkpoints/sessions-overview-inline.png
vendored
Normal file
BIN
.github/pr-assets/compaction-checkpoints/sessions-overview-inline.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -753,6 +753,11 @@ jobs:
|
||||
continue-on-error: true
|
||||
run: pnpm run lint:extensions:bundled
|
||||
|
||||
- name: Run extension package boundary TypeScript check
|
||||
id: extension_package_boundary_tsc
|
||||
continue-on-error: true
|
||||
run: pnpm run test:extensions:package-boundary
|
||||
|
||||
- name: Enforce safe external URL opening policy
|
||||
id: no_raw_window_open
|
||||
continue-on-error: true
|
||||
@@ -797,6 +802,7 @@ jobs:
|
||||
EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME: ${{ steps.extension_relative_outside_package_boundary.outcome }}
|
||||
EXTENSION_CHANNEL_LINT_OUTCOME: ${{ steps.extension_channel_lint.outcome }}
|
||||
EXTENSION_BUNDLED_LINT_OUTCOME: ${{ steps.extension_bundled_lint.outcome }}
|
||||
EXTENSION_PACKAGE_BOUNDARY_TSC_OUTCOME: ${{ steps.extension_package_boundary_tsc.outcome }}
|
||||
NO_RAW_WINDOW_OPEN_OUTCOME: ${{ steps.no_raw_window_open.outcome }}
|
||||
CONTROL_UI_I18N_OUTCOME: ${{ steps.control_ui_i18n.outcome == 'skipped' && 'success' || steps.control_ui_i18n.outcome }}
|
||||
GATEWAY_WATCH_REGRESSION_OUTCOME: ${{ steps.gateway_watch_regression.outcome }}
|
||||
@@ -820,6 +826,7 @@ jobs:
|
||||
"extension-relative-outside-package-boundary|$EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME" \
|
||||
"lint:extensions:channels|$EXTENSION_CHANNEL_LINT_OUTCOME" \
|
||||
"lint:extensions:bundled|$EXTENSION_BUNDLED_LINT_OUTCOME" \
|
||||
"test:extensions:package-boundary|$EXTENSION_PACKAGE_BOUNDARY_TSC_OUTCOME" \
|
||||
"lint:ui:no-raw-window-open|$NO_RAW_WINDOW_OPEN_OUTCOME" \
|
||||
"ui:i18n:check|$CONTROL_UI_I18N_OUTCOME" \
|
||||
"gateway-watch-regression|$GATEWAY_WATCH_REGRESSION_OUTCOME"; do
|
||||
|
||||
130
CHANGELOG.md
130
CHANGELOG.md
@@ -6,68 +6,97 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- CLI/capabilities: add a first-class `openclaw capability ...` hub for provider-backed inference workflows across model, media, web, and embedding tasks, with capability inspection, provider discovery, and consistent JSON output. Thanks @Takhoffman.
|
||||
- Providers/Anthropic: restore Claude CLI as the preferred local Anthropic path in onboarding, model-auth guidance, and doctor flows again, and keep the Docker Claude CLI live lane aligned with the restored guidance.
|
||||
- Memory/Active Memory: add a new optional Active Memory plugin that gives OpenClaw a dedicated memory subagent right before the main reply, so ongoing chats can automatically pull in relevant preferences, context, and past details without making users remember to manually say "remember this" or "search memory" first. Includes configurable message/recent/full context modes, live `/verbose` inspection, and opt-in transcript persistence for debugging.
|
||||
- CLI/infer: add a first-class `openclaw infer ...` hub for provider-backed inference workflows across model, media, web, and embedding tasks. Thanks @Takhoffman.
|
||||
- Plugins/webhooks: add a bundled webhook ingress plugin so external automation can create and drive bound TaskFlows through per-route shared-secret endpoints. (#61892) Thanks @mbelinky.
|
||||
- Tools/media generation: preserve intent across auth-backed image, music, and video provider fallback, remap size, aspect ratio, resolution, and duration hints to the closest supported option, and surface explicit provider capabilities plus mode-aware video-to-video support.
|
||||
- Memory/wiki: restore the bundled `memory-wiki` stack with plugin, CLI, sync/query/apply tooling, and memory-host integration for wiki-backed memory workflows.
|
||||
- Providers/Arcee AI: add a bundled Arcee AI provider plugin with Trinity catalog entries, OpenRouter support, and updated onboarding/auth guidance. (#62068) Thanks @arthurbr11.
|
||||
- Providers/Google: add Gemma 4 model support and keep Google fallback resolution on the requested provider path so native Google Gemma routes work again. (#61507) Thanks @eyjohn.
|
||||
- Providers/Google: preserve explicit thinking-off semantics for Gemma 4 while still enabling Gemma reasoning support in the Google compatibility wrappers. (#62127) Thanks @romgenie, co-authored with BunsDev.
|
||||
- Providers/Anthropic: restore Claude CLI as the preferred local Anthropic path in onboarding, model-auth guidance, doctor flows, and Docker Claude CLI live lanes again.
|
||||
- ACP/ACPX plugin: bump the bundled `acpx` pin to `0.5.1` so plugin-local installs and strict version checks pick up the latest published runtime release. (#62148) Thanks @onutc.
|
||||
- Tools/media generation: auto-fallback across auth-backed image, music, and video providers by default, and remap fallback size, aspect ratio, resolution, and duration hints to the closest supported option instead of dropping intent on provider switches.
|
||||
- Tools/media generation: report applied fallback geometry and duration settings consistently in tool results, add a shared normalization contract for image/music/video runtimes, and simplify the bundled image-generation-core runtime test to only verify the plugin-sdk re-export seam.
|
||||
- Gateway/sessions: add persisted compaction checkpoints plus Sessions UI branch/restore actions so operators can inspect and recover pre-compaction session state. (#62146) Thanks @scoootscooob.
|
||||
- Providers/Ollama: detect vision capability from the `/api/show` response and set image input on models that support it so Ollama vision models accept image attachments. (#62193) Thanks @BruceMacD.
|
||||
- Memory/dreaming: ingest redacted session transcripts into the dreaming corpus with per-day session-corpus notes, cursor checkpointing, and promotion/doctor support. (#62227) Thanks @vignesh07.
|
||||
- Plugins/memory: add a public memory-artifact export seam to the unified memory capability so companion plugins like `memory-wiki` can bridge the active memory plugin without reaching into `memory-core` internals. Thanks @vincentkoc.
|
||||
- Memory/wiki: add structured claim/evidence fields plus compiled agent digest artifacts so `memory-wiki` behaves more like a persistent knowledge layer and less like markdown-only page storage. Thanks @vincentkoc.
|
||||
- Memory/wiki: add claim-health linting, contradiction clustering, staleness-aware dashboards, and freshness-weighted wiki search so `memory-wiki` can act more like a maintained belief layer than a passive markdown dump. Thanks @vincentkoc.
|
||||
- Memory/wiki: use compiled digest artifacts as the first-pass wiki index for search/get flows, and resolve claim ids back to owning pages so agents can retrieve knowledge by belief identity instead of only by file path. Thanks @vincentkoc.
|
||||
- Memory/wiki: add an opt-in `context.includeCompiledDigestPrompt` flag so memory prompt supplements can append a compact compiled wiki snapshot for legacy prompt assembly and context engines that explicitly consume memory prompt sections. Thanks @vincentkoc.
|
||||
- Plugin SDK/context engines: pass `availableTools` and `citationsMode` into `assemble()`, and expose `buildMemorySystemPromptAddition(...)` so non-legacy context engines can adopt the active memory prompt path without reimplementing it. Thanks @vincentkoc.
|
||||
- Providers/inferrs: add string-content compatibility for stricter OpenAI-compatible chat backends, document `inferrs` setup with a full config example, and add troubleshooting guidance for local backends that pass direct probes but fail on full agent-runtime prompts.
|
||||
- Agents/context engine: expose prompt-cache runtime context to context engines and keep current-turn prompt-cache usage aligned with the active attempt instead of stale prior-turn assistant state. (#62179) Thanks @jalehman.
|
||||
- Tools/media: document per-provider music and video generation capabilities, and add shared live video-to-video sweep coverage for providers that support local reference clips.
|
||||
- Compaction: add pluggable compaction provider registry so plugins can replace the built-in summarization pipeline. Configure via `agents.defaults.compaction.provider`; falls back to LLM summarization on provider failure. (#56224) Thanks @DhruvBhatia0.
|
||||
- Discord/events: allow `event-create` to accept a cover image URL or local file path, load and validate PNG/JPG/GIF event cover media, and pass the encoded image payload through Discord admin action/runtime paths. (#60883) Thanks @bittoby.
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/capabilities: keep provider-backed capability behavior aligned with actual runtime execution by fixing explicit TTS override handling, profile-aware gateway TTS prefs resolution, per-request transcription `prompt`/`language` overrides, image output MIME/extension mismatches, configured web-search fallback behavior, and agent-vs-CLI web-search execution drift.
|
||||
- Channels/secrets: keep bundled channel artifact and secret-contract loading stable under lazy loading so bundled channel secrets continue to appear in `openclaw secret`, status, and security-audit surfaces.
|
||||
- Providers/xAI: recognize `api.grok.x.ai` as an xAI-native endpoint again so native xAI web-search attribution keeps working on Grok-hosted base URLs. (#61377) Thanks @jjjojoj.
|
||||
- Providers/Anthropic/cache: preserve thinking blocks for Claude Opus 4.5+, Sonnet 4.5+, and newer Claude 4-family models so Anthropic prompt-cache prefixes keep matching after thinking turns. (#61793)
|
||||
- Plugins/media: when `plugins.allow` is set, capability fallback now merges bundled capability plugin ids into the allowlist (not only `plugins.entries`), so media understanding providers such as OpenAI-compatible STT load for voice transcription without requiring `openai` in `plugins.allow`. (#62205) Thanks @neeravmakwana.
|
||||
- CLI/infer: keep provider-backed infer behavior aligned with actual runtime execution by fixing explicit TTS override handling, profile-aware gateway TTS prefs resolution, per-request transcription `prompt`/`language` overrides, image output MIME/extension mismatches, configured web-search fallback behavior, and agent-vs-CLI web-search execution drift.
|
||||
- Auth/OpenAI Codex OAuth: reload fresh on-disk credentials inside the locked refresh path and retry once after `refresh_token_reused` rotates only the stored refresh token, so relogin/restart recovery stops getting stuck on stale cached auth state. Thanks @owen-ever.
|
||||
- Agents/history and replies: buffer phaseless OpenAI WS text until a real assistant phase arrives, keep replay and SSE history sequence tracking aligned, hide commentary and leaked tool XML from user-visible history, and keep history-based follow-up replies on `final_answer` text only. (#61729, #61747, #61829, #61855, #61954) Thanks @100yenadmin, @afurm, and @openperf.
|
||||
- Plugins/channels: keep bundled channel artifact and secret-contract loading stable under lazy loading, preserve plugin-schema defaults during install, and fix Windows `file://` plus native-Jiti plugin loader paths so onboarding, doctor, `openclaw secret`, and bundled plugin installs work again. (#61832, #61836, #61853, #61856) Thanks @Zeesejo and @SuperMarioYL.
|
||||
- Plugins/ClawHub: verify downloaded plugin archives against version metadata SHA-256, fail closed when archive integrity metadata is missing or malformed, and tighten fallback ZIP verification so plugin installs cannot proceed on mismatched or incomplete ClawHub package metadata. (#60517) Thanks @mappel-nv.
|
||||
- Auto-reply/media: allow managed generated-media `MEDIA:` paths from normal reply text again while still blocking arbitrary host-local media and document paths, so generated media keep delivering without reopening host-path injection holes.
|
||||
- Runtime event trust: mark background `notifyOnExit` summaries, ACP parent-stream relays, and wake-hook payloads as untrusted system events so lower-trust runtime output no longer re-enters later turns as trusted `System:` text. (#62003)
|
||||
- Providers/Anthropic: preserve thinking blocks for Claude Opus 4.5+, Sonnet 4.5+, and newer Claude 4-family models so prompt-cache prefixes keep matching, and skip `service_tier` injection on OAuth-authenticated stream wrapper requests so Claude OAuth streaming stops failing with HTTP 401. (#60356, #61793)
|
||||
- Control UI: show `/tts` audio replies in webchat, detect mistaken `?token=` auth links with the correct `#token=` hint, and keep Copy, Canvas, and mobile exec-approval UI from covering chat content on narrow screens. (#54842, #61514, #61598) Thanks @neeravmakwana.
|
||||
- TUI: route `/status` through the shared session-status command, keep commentary hidden in history, strip raw envelope metadata from async command notices, preserve fallback streaming before per-attempt failures finalize, and restore Kitty keyboard state on exit or fatal crashes. (#49130, #59985, #60043, #61463) Thanks @biefan, @MoerAI, @jwchmodx, and @100yenadmin.
|
||||
- Sessions/model selection: resolve the explicitly selected session model separately from runtime fallback resolution so session status and live model switching stay aligned with the chosen model.
|
||||
- Discord/ACP bindings: canonicalize DM conversation identity across inbound messages, component interactions, native commands, and current-conversation binding resolution so `--bind here` in Discord DMs keeps routing follow-up replies to the bound agent instead of falling back to the default agent.
|
||||
- iOS/Watch exec approvals: keep Apple Watch review and approval recovery working while the iPhone is locked or backgrounded, including reconnect recovery, pending approval persistence, notification cleanup, and APNs-backed watch refresh recovery. (#61757) Thanks @ngutman.
|
||||
- Nodes/exec approvals: keep `host=node` POSIX transport shell wrappers (`/bin/sh -lc ...`) aligned with inner-command allowlist analysis so allowlisted scripts stop prompting unnecessarily, while Windows `cmd.exe` wrapper runs stay approval-gated. (#62401) Thanks @ngutman.
|
||||
- Nodes/exec approvals: keep Windows `cmd.exe /c` wrapper runs approval-gated even when `env` carriers, including env-assignment carriers, wrap the shell invocation. (#62439) Thanks @ngutman.
|
||||
- Agents/context overflow: combine oversized and aggregate tool-result recovery in one pass and restore a total-context overflow backstop so recoverable sessions retry instead of failing early. (#61651) Thanks @Takhoffman.
|
||||
- Agents/exec: preserve explicit `host=node` routing under elevated defaults when `tools.exec.host=auto`, fail loud on invalid elevated cross-host overrides, and keep `strictInlineEval` commands blocked after approval timeouts instead of falling through to automatic execution. (#61739) Thanks @obviyus.
|
||||
- Host exec/env sanitization: block dangerous `JAVA_OPTS`, `RUSTFLAGS`, and `CARGO_HOME` inputs at the host-exec boundary so attacker-controlled env overrides can no longer inject JVM agents, compiler flags, or Cargo state pivots into host-run processes. (#62291) Thanks @pgondhi987.
|
||||
- Commands/allowlist: require owner authorization for `/allowlist add` and `/allowlist remove` before channel resolution, so non-owner but command-authorized senders can no longer persistently rewrite allowlist policy state. (#62383) Thanks @pgondhi987.
|
||||
- Feishu/docx uploads: honor `tools.fs.workspaceOnly` for local `upload_file` and `upload_image` paths by forwarding workspace-constrained `localRoots` into the media loader, so docx uploads can no longer read host-local files outside the workspace when workspace-only mode is active. (#62369) Thanks @pgondhi987.
|
||||
- Network/fetch guard: drop request bodies and body-describing headers on cross-origin `307` and `308` redirects by default, so attacker-controlled redirect hops cannot receive secret-bearing POST payloads from SSRF-guarded fetch flows unless a caller explicitly opts in. (#62357) Thanks @pgondhi987.
|
||||
- Providers/Ollama: honor the selected provider's `baseUrl` during streaming so multi-Ollama setups stop routing every stream to the first configured Ollama endpoint. (#61678)
|
||||
- Browser/remote CDP: retry the DevTools websocket once after remote browser restarts so healthy remote browser profiles do not fail availability checks during CDP warm-up. (#57397) Thanks @ThanhNguyxn07.
|
||||
- Memory/vector recall: surface explicit warnings when `sqlite-vec` is unavailable or vector writes are degraded so memory indexing no longer reports false-success while semantic recall is impaired.
|
||||
- MS Teams/security: validate file-consent upload URLs against HTTPS, Microsoft/SharePoint host allowlists, and private-IP DNS checks before uploading attachments, blocking SSRF-style consent-upload abuse. (#23596)
|
||||
- Discord/gateway monitor: use `ws://` again for gateway monitor sockets so Discord monitor connections recover reliably after recent gateway socket changes.
|
||||
- Control UI/auth URLs: detect mistaken `?token=` links, show the correct `#token=` fragment hint only on real auth failures, and stop masking the real problem behind a generic device-identity error. (#54842)
|
||||
- Control UI/chat layout: keep Copy and Canvas actions plus mobile exec-approval overlays from covering chat text or command previews on narrow screens. (#61514)
|
||||
- Matrix/formatting: preserve multi-paragraph and loose-list rendering in Element so numbered and bulleted Markdown keeps its content attached to the correct list item. (#60997) Thanks @gucasbrg.
|
||||
- Sessions/model selection: resolve the explicitly selected session model separately from runtime fallback resolution so session status and live model switching stay aligned with the chosen model.
|
||||
- Secrets/x_search: keep legacy `x_search` auth resolution working so older xAI web-search configs continue to load after the plugin-owned auth move.
|
||||
- iOS/Watch exec approvals: keep Apple Watch review and approval recovery working while the iPhone is locked or backgrounded, including background-safe reconnects, persisted pending approvals, notification cleanup, and APNs-backed watch refresh recovery. (#61757) Thanks @ngutman.
|
||||
- Discord/forwarding: recover forwarded referenced message text and attachments when Discord omits snapshot payloads, so forwarded-message relays keep the original content. (#61670) Thanks @artwalker.
|
||||
- TUI/status: route `/status` through the shared session-status command and move the old gateway-wide diagnostic summary to `/gateway-status` (`/gwstatus`). Thanks @vincentkoc.
|
||||
- TUI/history and heartbeat: keep assistant commentary hidden on both streamed and reloaded TUI history views, preserve the phase-sanitized REST history contract, and stop forced heartbeat runs from targeting subagent sessions. (#61463) Thanks @100yenadmin.
|
||||
- TUI/command messages: strip inbound envelope metadata before rendering command/system messages so async completion notices stop leaking raw wrappers into the operator terminal. (#59985) Thanks @MoerAI.
|
||||
- TUI/terminal: restore Kitty keyboard protocol and `modifyOtherKeys` state on TUI exit and fatal CLI crashes so parent shells stop inheriting broken keyboard input after `openclaw tui` exits. (#49130) Thanks @biefan.
|
||||
- Plugins/Windows: load plugin entrypoints through `file://` import specifiers on Windows without breaking plugin SDK alias resolution, fixing `ERR_UNSUPPORTED_ESM_URL_SCHEME` for absolute plugin paths. (#61832) Thanks @Zeesejo.
|
||||
- Plugins/Windows: disable native Jiti loading for setup and doctor contract registries on Windows so onboarding and config-doctor plugin probes stop crashing with `ERR_UNSUPPORTED_ESM_URL_SCHEME`. (#61836, #61853)
|
||||
- Plugins/install: preserve plugin-schema defaults during fresh-install raw config validation so bundled plugin installs stop failing when required fields rely on schema defaults. (#61856) Thanks @SuperMarioYL.
|
||||
- Browser/SSRF: treat main-frame `document` redirect hops as navigations even when Playwright does not flag them as `isNavigationRequest()`, so strict private-network blocking still stops forbidden redirect pivots before the browser reaches the internal target. (#62355) Thanks @pgondhi987.
|
||||
- Gateway/node pairing: require a fresh pairing request when a previously paired node reconnects with additional declared commands, and keep the live session pinned to the earlier approved command set until the upgrade is approved. (#62658) Thanks @eleqtrizit.
|
||||
- Gateway/auth: invalidate existing shared-token and password WebSocket sessions when the configured secret rotates, so stale authenticated sockets cannot stay attached after token or password changes. (#62350) Thanks @pgondhi987.
|
||||
- Gateway/status and containers: auto-bind to `0.0.0.0` inside Docker and Podman environments, and probe local TLS gateways over `wss://` with self-signed fingerprint forwarding so container startup and loopback TLS status checks work again. (#61818, #61935) Thanks @openperf and @ThanhNguyxn07.
|
||||
- macOS/gateway version: strip trailing commit metadata from CLI version output before semver parsing so the Mac app recognizes installed gateway versions like `OpenClaw 2026.4.2 (d74a122)` again. (#61111) Thanks @oliviareid-svg.
|
||||
- Gateway/containers: auto-bind to `0.0.0.0` during container startup for Docker and Podman compatibility, while keeping host-side status and doctor checks on the hardened loopback default when `gateway.bind` is unset. (#61818) Thanks @openperf.
|
||||
- Gateway/status: probe local TLS gateways over `wss://`, forward the local cert fingerprint for self-signed loopback probes, and warn when the local TLS runtime cannot load the configured cert. (#61935) Thanks @ThanhNguyxn07.
|
||||
- Discord: recover forwarded referenced message text and attachments when snapshots are missing, use `ws://` again for gateway monitor sockets, stop forcing a hardcoded temperature for Codex-backed auto-thread titles, and harden voice receive recovery so rapid speaker restarts keep their next utterance. (#41536, #61670) Thanks @artwalker and @wit-oc.
|
||||
- Slack/threading: keep legacy thread stickiness for real replies when older callers omit `isThreadReply`, while still honoring `replyToMode` for Slack's auto-created top-level `thread_ts`. (#61835) Thanks @kaonash.
|
||||
- Discord/voice: re-arm DAVE receive passthrough without suppressing decrypt-failure rejoin recovery, and clear capture state before finalize teardown so rapid speaker restarts keep their next utterance. (#41536) Thanks @wit-oc.
|
||||
- Providers/Google: recognize Gemma model ids in native Google forward-compat resolution, keep the requested provider when cloning fallback templates, and force Gemma reasoning off so Gemma 4 routes stop failing through the Google catalog fallback. (#61507) Thanks @eyjohn.
|
||||
- Providers/Anthropic: skip `service_tier` injection for OAuth-authenticated stream wrapper requests so Claude OAuth requests stop failing with HTTP 401. (#60356) thanks @openperf.
|
||||
- Providers/OpenAI: keep WebSocket text buffered until a real assistant phase arrives, even when text deltas land before a phaseless `output_item.added` announcement. (#61954) Thanks @100yenadmin.
|
||||
- Providers/OpenAI: accept case-insensitive `plugins.entries.openai.config.personality` values, keep unknown overrides on the friendly overlay path, and add `on` as an alias for `friendly`. Thanks @vincentkoc.
|
||||
- Discord/thread titles: stop forcing a hardcoded temperature for generated auto-thread names so Codex-backed thread title generation works on `openai-codex/*` models again. (#59525)
|
||||
- Agents/message tool: add a `read` plus `threadId` discoverability hint when the configured channel actions support threaded message reads.
|
||||
- Agents/context overflow: combine oversized and aggregate tool-result recovery in one repair pass, and restore a total-context overflow backstop during tool loops so recoverable sessions retry instead of failing early. (#61651) Thanks @Takhoffman.
|
||||
- Agents/exec: preserve explicit `host=node` routing under elevated defaults when `tools.exec.host=auto`, and fail loud on invalid elevated cross-host overrides. (#61739) Thanks @obviyus.
|
||||
- Agents/heartbeat: stop truncating live session transcripts after no-op heartbeat acks, move heartbeat cleanup to prompt assembly and compaction, and keep post-filter context-engine ingestion aligned with the real session baseline. (#60998) Thanks @nxmxbbd.
|
||||
- Gateway/TUI: defer terminal chat finalization for per-attempt lifecycle errors so fallback retries keep streaming before the run is marked failed. (#60043) Thanks @jwchmodx.
|
||||
- Gateway/history: seed SSE startup history and raw transcript sequence tracking from one initial transcript snapshot so first history events cannot diverge from subsequent message sequence numbering. (#61855) Thanks @100yenadmin.
|
||||
- Agents/history: keep history-based reply reads and subagent completion summaries on `final_answer` text only so internal commentary stops leaking into user-visible follow-up replies. (#61747) Thanks @afurm.
|
||||
- Agents/history: suppress commentary-only visible-text leaks in streaming and chat history views, and keep sanitized SSE history sequence numbers monotonic after transcript-only refreshes. (#61829) Thanks @100yenadmin.
|
||||
- Agents/history: use one shared assistant-visible sanitizer across embedded delivery and chat-history extraction so leaked `<tool_call>` and `<tool_result>` XML blocks stay hidden from user-facing replies. (#61729) Thanks @openperf.
|
||||
- Agents/history: keep truly legacy unsigned replay text unphased when mixed with phased OpenAI WS assistant blocks, while still inheriting message phase for id-only replay signatures. (#61529) Thanks @100yenadmin.
|
||||
- Memory/dreaming: strip managed Light Sleep and REM blocks before daily-note ingestion so dreaming summaries stop re-ingesting their own staged output into new candidates. (#61720) Thanks @MonkeyLeeT.
|
||||
- Docs/i18n: relocalize final localized-page links after translation so generated locale pages stop keeping stale English-root links when targets appear later in the same run. (#61796) thanks @hxy91819.
|
||||
- Docs/i18n: remove the zh-CN homepage redirect override so Mintlify can resolve the localized Chinese homepage without self-redirecting `/zh-CN/index`.
|
||||
- Tools/web_fetch and web_search: fix `TypeError: fetch failed` caused by undici 8.0 enabling HTTP/2 by default; pinned SSRF-guard dispatchers now explicitly set `allowH2: false` to restore HTTP/1.1 behavior and keep the custom DNS-pinning lookup compatible. (#61738, #61777) Thanks @zozo123.
|
||||
- Agents/session keys: backfill `sessionKey` from `sessionId` in the embedded PI runner when callers omit it, so hooks, LCM, and compaction receive a valid key; also normalize whitespace-only session keys to `undefined` before downstream consumers see them. (#60555) Thanks @100yenadmin.
|
||||
- Plugins/provider hooks: stop recursive provider snapshot loads from overflowing the stack during plugin initialization, while still preserving cached nested provider-hook results. (#61922, #61938, #61946, #61951)
|
||||
- Discord/voice: re-arm DAVE receive passthrough without suppressing decrypt-failure rejoin recovery, and clear capture state before finalize teardown so rapid speaker restarts keep their next utterance. (#41536) Thanks @wit-oc.
|
||||
- Agents/exec: keep `strictInlineEval` commands blocked after approval timeouts on both gateway and node exec hosts, so timeout fallback no longer turns timed-out inline interpreter prompts into automatic execution.
|
||||
- Plugins/loaders: centralize bundled `dist/**` Jiti native-load policy and keep channel, public-surface, facade, and config-metadata loader seams off native Jiti on Windows so onboarding and configure flows stop tripping `ERR_UNSUPPORTED_ESM_URL_SCHEME`. (#62286) Thanks @chen-zhang-cs-code.
|
||||
- Providers/xAI: recognize `api.grok.x.ai` as an xAI-native endpoint again and keep legacy `x_search` auth resolution working so older xAI web-search configs continue to load. (#61377) Thanks @jjjojoj.
|
||||
- Memory/vector recall: surface explicit warnings when `sqlite-vec` is unavailable or vector writes are degraded, and strip managed Light Sleep and REM blocks before daily-note ingestion so memory indexing and dreaming stop reporting false-success or re-ingesting staged output. (#61720) Thanks @MonkeyLeeT.
|
||||
- Matrix/formatting: preserve multi-paragraph and loose-list rendering in Element so numbered and bulleted Markdown keeps their content attached to the correct list item. (#60997) Thanks @gucasbrg.
|
||||
- MS Teams/security: validate file-consent upload URLs against HTTPS, Microsoft/SharePoint host allowlists, and private-IP DNS checks before uploading attachments, blocking SSRF-style consent-upload abuse. (#23596)
|
||||
- QQ Bot/media: route gateway-side attachment and fallback downloads through guarded QQ/Tencent HTTPS fetches so QQ media handling no longer follows arbitrary remote hosts.
|
||||
- Providers/Ollama: stop warning that Ollama could not be reached when discovery only sees empty default local stubs, while still keeping real explicit Ollama overrides loud when the endpoint is unreachable.
|
||||
- Tools/web_fetch and web_search: fix `TypeError: fetch failed` caused by undici 8.0 enabling HTTP/2 by default; pinned SSRF-guard dispatchers now explicitly set `allowH2: false` to restore HTTP/1.1 behavior and keep the custom DNS-pinning lookup compatible. (#61738, #61777) Thanks @zozo123.
|
||||
- Tools/web search/Exa: show Exa Search in onboarding and configure provider pickers again by marking the bundled Exa provider as setup-visible. Thanks @vincentkoc.
|
||||
- Docs/i18n: relocalize final localized-page links after translation and remove the zh-CN homepage redirect override so localized Mintlify pages resolve to the correct language roots again. (#61796) Thanks @hxy91819.
|
||||
- Plugins/provider hooks: stop recursive provider snapshot loads from overflowing the stack during plugin initialization, while still preserving cached nested provider-hook results. (#61922, #61938, #61946, #61951)
|
||||
- Exec/runtime events: mark background `notifyOnExit` summaries and ACP parent-stream relays as untrusted system events so lower-trust runtime output no longer re-enters later turns as trusted `System:` text.
|
||||
- Hooks/wake: queue direct and mapped wake-hook payloads as untrusted system events so external wake content no longer enters the main session as trusted input. (#62003)
|
||||
- Browser/node invoke: block persistent browser profile create, reset, and delete mutations through `browser.proxy` on both gateway-forwarded `node.invoke` and the node-host proxy path, even when no profile allowlist is configured. (#60489)
|
||||
- Slack/thread mentions: add `channels.slack.thread.requireExplicitMention` so Slack channels that already require mentions can also require explicit `@bot` mentions inside bot-participated threads. (#58276) Thanks @praktika-engineer.
|
||||
- UI/light mode: target both root and nested WebKit scrollbar thumbs in the light theme so page-level and container scrollbars stay visible on light backgrounds. (#61753) Thanks @chziyue.
|
||||
- Matrix/onboarding: add an invite auto-join setup step with explicit off warnings and strict stable-target validation so new Matrix accounts stop silently ignoring invited rooms and fresh DM-style invites unless operators opt in. (#62168) Thanks @gumadeiras.
|
||||
- Telegram/doctor: keep top-level access-control fallback in place during multi-account normalization while still promoting legacy default auth into `accounts.default`, so existing named bots keep inherited allowlists without dropping the legacy default bot. (#62263) Thanks @obviyus.
|
||||
- Agents/subagents: honor `sessions_spawn(lightContext: true)` for spawned subagent runs by preserving lightweight bootstrap context through the gateway and embedded runner instead of silently falling back to full workspace bootstrap injection. (#62264) Thanks @theSamPadilla.
|
||||
- Slack/media: keep attachment downloads on the SSRF-guarded dispatcher path so Slack media fetching works on Node 22 without dropping pinned transport enforcement. (#62239) Thanks @openperf.
|
||||
- Docker/plugins: stop forcing bundled plugin discovery to `/app/extensions` in runtime images so packaged installs use compiled `dist/extensions` artifacts again and Node 24 containers do not boot through source-only plugin entry paths. Fixes #62044. (#62316) Thanks @gumadeiras.
|
||||
- Cron: load `jobId` into `id` when the on-disk store omits `id`, matching doctor migration and fixing `unknown cron job id` for hand-edited `jobs.json`. (#62246) Thanks @neeravmakwana.
|
||||
- Agents/model fallback: classify minimal HTTP 404 API errors (for example `404 status code (no body)`) as `model_not_found` so assistant failures throw into the fallback chain instead of stopping at the first fallback candidate. (#62119) Thanks @neeravmakwana.
|
||||
- Providers/Mistral: send `reasoning_effort` for `mistral/mistral-small-latest` (Mistral Small 4) with thinking-level mapping, and mark the catalog entry as reasoning-capable so adjustable reasoning matches Mistral’s Chat Completions API. (#62162) Thanks @neeravmakwana.
|
||||
- OpenAI TTS/Groq: send `wav` to Groq-compatible speech endpoints, honor explicit `responseFormat` overrides on OpenAI-compatible paths, and only mark voice-note output as voice-compatible when the actual format is `opus`. (#62233) Thanks @neeravmakwana.
|
||||
- BlueBubbles/network: respect explicit private-network opt-out for loopback and private `serverUrl` values across account resolution, status probes, monitor startup, and attachment downloads, while keeping public-host attachment hostname pinning intact. (#59373) Thanks @jpreagan.
|
||||
- Agents/heartbeat: keep heartbeat runs pinned to the main session so active subagent transcripts are not overwritten by heartbeat status messages. (#61803) Thanks @100yenadmin.
|
||||
- Agents/compaction: stop compaction-wait aborts from re-entering prompt failover and replaying completed tool turns. (#62600) Thanks @i-dentifier.
|
||||
- Approvals/runtime: move native approval lifecycle assembly into shared core bootstrap/runtime seams driven by channel capabilities and runtime contexts, and remove the legacy bundled approval fallback wiring. (#62135) Thanks @gumadeiras.
|
||||
- Security/fetch-guard: stop rejecting operator-configured proxy hostnames against the target-scoped hostname allowlist in SSRF-guarded fetches, restoring proxy-based media downloads for Telegram and other channels. (#62312) Thanks @ademczuk.
|
||||
- iOS/gateway: replace string-matched connection error UI with structured gateway connection problems, preserve actionable pairing/auth failures over later generic disconnect noise, and surface reusable problem banners and details across onboarding, settings, and root status surfaces. (#62650) Thanks @ngutman.
|
||||
|
||||
## 2026.4.5
|
||||
|
||||
@@ -206,6 +235,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Feishu/reasoning: only expose streamed reasoning previews when the session is explicitly `reasoning:stream`, so hidden reasoning traces do not surface on normal streaming sessions. Thanks @vincentkoc.
|
||||
- Discord: keep REST, webhook, and monitor traffic on the configured proxy, preserve component-only media sends, honor `@everyone` and `@here` mention gates, keep ACK reactions on the active account, and split voice connect/playback timeouts so auto-join is more reliable. (#57465, #60361, #60345) Thanks @geekhuashan.
|
||||
- WhatsApp: restore `channels.whatsapp.blockStreaming` and reset watchdog timeouts after reconnect so quiet chats stop falling into reconnect loops. (#60007, #60069) Thanks @MonkeyLeeT and @mcaxtr.
|
||||
- Browser/security: re-run SSRF safety checks after interaction-driven navigations and before snapshot reads so click, submit, keyboard, and current-page snapshot flows fail closed on disallowed destinations. (#62023) Thanks @eleqtrizit.
|
||||
- Memory: keep `memory-core` builtin embedding registration on the already-registered path so selecting `memory-core` no longer recurses through plugin discovery and crashes during startup. (#61402) Thanks @ngutman.
|
||||
- Agents/tool results: keep large `read` outputs visible longer, preserve the latest `read` output when older tool output can absorb the overflow budget, and fall back to Pi's normal overflow compaction/retry path before replacing a fresh `read` with a compacted stub. Thanks @vincentkoc.
|
||||
- Memory/QMD: prefer modern `qmd collection add --glob`, accept newer single-line JSON hit metadata while keeping legacy line fields, refresh QMD docs/doctor install guidance and model-override guidance, and keep older QMD releases working. Thanks @vincentkoc.
|
||||
@@ -248,6 +278,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Providers/OpenRouter failover: classify `403 “Key limit exceeded”` spending-limit responses as billing so model fallback continues instead of stopping on generic auth. (#59892) Thanks @rockcent.
|
||||
- Providers/Anthropic: keep `claude-cli/*` auth on live Claude CLI credentials at runtime, avoid persisting stale bearer-token profiles, and suppress macOS Keychain prompts during non-interactive Claude CLI setup. (#61234) Thanks @darkamenosa.
|
||||
- Providers/Anthropic: when Claude CLI auth becomes the default, write a real `claude-cli` auth profile so local and gateway agent runs can use Claude CLI immediately without missing-API-key failures. Thanks @vincentkoc.
|
||||
- Memory/dreaming: make Dreams config reads and writes respect the selected memory slot plugin (including `doctor.memory.status` and Control UI fallback state) instead of always targeting `memory-core`. (#62275) Thanks @SnowSky1.
|
||||
- Providers/Anthropic Vertex: honor `cacheRetention: “long”` with the real 1-hour prompt-cache TTL on Vertex AI endpoints, and default `anthropic-vertex` cache retention like direct Anthropic. (#60888) Thanks @affsantos.
|
||||
- Agents/Anthropic: preserve native `toolu_*` replay ids on direct Anthropic and Anthropic Vertex paths so cache-sensitive history stops rewriting known-valid Anthropic tool-use ids. (#52612)
|
||||
- Providers/Google: add model-level `cacheRetention` support for direct Gemini system prompts by creating, reusing, and refreshing `cachedContents` automatically on Google AI Studio runs. (#51372) Thanks @rafaelmariano-glitch.
|
||||
@@ -802,6 +833,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/compaction: surface safeguard-specific cancel reasons and relabel benign manual `/compact` no-op cases as skipped instead of failed. (#51072) Thanks @afurm.
|
||||
- Docs: add `pnpm docs:check-links:anchors` for Mintlify anchor validation while keeping `scripts/docs-link-audit.mjs` as the stable link-audit entrypoint. (#55912) Thanks @velvet-shark.
|
||||
- Tavily: mark outbound API requests with `X-Client-Source: openclaw` so Tavily can attribute OpenClaw-originated traffic. (#55335) Thanks @lakshyaag-tavily.
|
||||
- Plugins/hooks: add async `requireApproval` to `before_tool_call` hooks, letting plugins pause tool execution and prompt the user for approval via the exec approval overlay, Telegram buttons, Discord interactions, or the `/approve` command on any channel. The `/approve` command now handles both exec and plugin approvals with automatic fallback. (#55339) Thanks @vaclavbelak and @joshavant.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
19
Dockerfile
19
Dockerfile
@@ -62,6 +62,7 @@ RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY openclaw.mjs ./
|
||||
COPY ui/package.json ./ui/package.json
|
||||
COPY patches ./patches
|
||||
COPY scripts/postinstall-bundled-plugins.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
|
||||
@@ -102,7 +103,19 @@ RUN pnpm qa:lab:build
|
||||
# Prune dev dependencies and strip build-only metadata before copying
|
||||
# runtime assets into the final image.
|
||||
FROM build AS runtime-assets
|
||||
RUN CI=true pnpm prune --prod && \
|
||||
ARG OPENCLAW_EXTENSIONS
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
# Keep the install layer frozen, but allow prune to run against the full copied
|
||||
# workspace tree subset used during `pnpm install`. The build stage only copied
|
||||
# the root, `ui`, and opted-in plugin manifests into the install layer, so
|
||||
# prune must not rediscover unrelated workspaces from the later full source
|
||||
# copy.
|
||||
RUN printf 'packages:\n - .\n - ui\n' > /tmp/pnpm-workspace.runtime.yaml && \
|
||||
for ext in $OPENCLAW_EXTENSIONS; do \
|
||||
printf ' - %s/%s\n' "$OPENCLAW_BUNDLED_PLUGIN_DIR" "$ext" >> /tmp/pnpm-workspace.runtime.yaml; \
|
||||
done && \
|
||||
cp /tmp/pnpm-workspace.runtime.yaml pnpm-workspace.yaml && \
|
||||
CI=true NPM_CONFIG_FROZEN_LOCKFILE=false pnpm prune --prod && \
|
||||
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete
|
||||
|
||||
# ── Runtime base images ─────────────────────────────────────────
|
||||
@@ -159,10 +172,6 @@ COPY --from=runtime-assets --chown=node:node /app/skills ./skills
|
||||
COPY --from=runtime-assets --chown=node:node /app/docs ./docs
|
||||
COPY --from=runtime-assets --chown=node:node /app/qa ./qa
|
||||
|
||||
# In npm-installed Docker images, prefer the copied source extension tree for
|
||||
# bundled discovery so package metadata that points at source entries stays valid.
|
||||
ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/${OPENCLAW_BUNDLED_PLUGIN_DIR}
|
||||
|
||||
# Keep pnpm available in the runtime image for container-local workflows.
|
||||
# Use a shared Corepack home so the non-root `node` user does not need a
|
||||
# first-run network fetch when invoking pnpm.
|
||||
|
||||
@@ -89,7 +89,7 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
|
||||
|
||||
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
|
||||
|
||||
Model note: while many providers/models are supported, for the best experience and lower prompt-injection risk use the strongest latest-generation model available to you. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
|
||||
Model note: while many providers and models are supported, prefer a current flagship model from the provider you trust and already use. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
|
||||
|
||||
## Models (selection + auth)
|
||||
|
||||
@@ -371,7 +371,7 @@ Minimal `~/.openclaw/openclaw.json` (model + defaults):
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
model: "<provider>/<model-id>",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
enum GatewayConnectionIssue: Equatable {
|
||||
case none
|
||||
@@ -29,6 +30,37 @@ enum GatewayConnectionIssue: Equatable {
|
||||
return false
|
||||
}
|
||||
|
||||
static func detect(problem: GatewayConnectionProblem?) -> Self {
|
||||
guard let problem else { return .none }
|
||||
if problem.needsPairingApproval {
|
||||
return .pairingRequired(requestId: problem.requestId)
|
||||
}
|
||||
if problem.needsCredentialUpdate {
|
||||
return problem.kind == .gatewayAuthTokenMissing ? .tokenMissing : .unauthorized
|
||||
}
|
||||
switch problem.kind {
|
||||
case .deviceIdentityRequired,
|
||||
.deviceSignatureExpired,
|
||||
.deviceNonceRequired,
|
||||
.deviceNonceMismatch,
|
||||
.deviceSignatureInvalid,
|
||||
.devicePublicKeyInvalid,
|
||||
.deviceIdMismatch,
|
||||
.tailscaleIdentityMissing,
|
||||
.tailscaleProxyMissing,
|
||||
.tailscaleWhoisFailed,
|
||||
.tailscaleIdentityMismatch,
|
||||
.authRateLimited:
|
||||
return .unauthorized
|
||||
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
||||
return .network
|
||||
case .unknown:
|
||||
return .unknown(problem.message)
|
||||
default:
|
||||
return .none
|
||||
}
|
||||
}
|
||||
|
||||
static func detect(from statusText: String) -> Self {
|
||||
let trimmed = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return .none }
|
||||
|
||||
232
apps/ios/Sources/Gateway/GatewayProblemView.swift
Normal file
232
apps/ios/Sources/Gateway/GatewayProblemView.swift
Normal file
@@ -0,0 +1,232 @@
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct GatewayProblemBanner: View {
|
||||
let problem: GatewayConnectionProblem
|
||||
var primaryActionTitle: String?
|
||||
var onPrimaryAction: (() -> Void)?
|
||||
var onShowDetails: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: self.iconName)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(self.tint)
|
||||
.frame(width: 20)
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(self.problem.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer(minLength: 0)
|
||||
Text(self.ownerLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(self.problem.message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if let requestId = self.problem.requestId {
|
||||
Text("Request ID: \(requestId)")
|
||||
.font(.system(.caption, design: .monospaced).weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
if let primaryActionTitle, let onPrimaryAction {
|
||||
Button(primaryActionTitle, action: onPrimaryAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
if let onShowDetails {
|
||||
Button("Details", action: onShowDetails)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
.background(
|
||||
.thinMaterial,
|
||||
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
)
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
switch self.problem.kind {
|
||||
case .pairingRequired,
|
||||
.pairingRoleUpgradeRequired,
|
||||
.pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
return "person.crop.circle.badge.clock"
|
||||
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
||||
return "wifi.exclamationmark"
|
||||
case .deviceIdentityRequired,
|
||||
.deviceSignatureExpired,
|
||||
.deviceNonceRequired,
|
||||
.deviceNonceMismatch,
|
||||
.deviceSignatureInvalid,
|
||||
.devicePublicKeyInvalid,
|
||||
.deviceIdMismatch:
|
||||
return "lock.shield"
|
||||
default:
|
||||
return "exclamationmark.triangle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
switch self.problem.kind {
|
||||
case .pairingRequired,
|
||||
.pairingRoleUpgradeRequired,
|
||||
.pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
return .orange
|
||||
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
||||
return .yellow
|
||||
default:
|
||||
return .red
|
||||
}
|
||||
}
|
||||
|
||||
private var ownerLabel: String {
|
||||
switch self.problem.owner {
|
||||
case .gateway:
|
||||
return "Fix on gateway"
|
||||
case .iphone:
|
||||
return "Fix on iPhone"
|
||||
case .both:
|
||||
return "Check both"
|
||||
case .network:
|
||||
return "Check network"
|
||||
case .unknown:
|
||||
return "Needs attention"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewayProblemDetailsSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let problem: GatewayConnectionProblem
|
||||
var primaryActionTitle: String?
|
||||
var onPrimaryAction: (() -> Void)?
|
||||
|
||||
@State private var copyFeedback: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(self.problem.title)
|
||||
.font(.title3.weight(.semibold))
|
||||
Text(self.problem.message)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(self.ownerSummary)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
if let requestId = self.problem.requestId {
|
||||
Section("Request") {
|
||||
Text(verbatim: requestId)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
Button("Copy request ID") {
|
||||
UIPasteboard.general.string = requestId
|
||||
self.copyFeedback = "Copied request ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let actionCommand = self.problem.actionCommand {
|
||||
Section("Gateway command") {
|
||||
Text(verbatim: actionCommand)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
Button("Copy command") {
|
||||
UIPasteboard.general.string = actionCommand
|
||||
self.copyFeedback = "Copied command"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let docsURL = self.problem.docsURL {
|
||||
Section("Help") {
|
||||
Link(destination: docsURL) {
|
||||
Label("Open docs", systemImage: "book")
|
||||
}
|
||||
Text(verbatim: docsURL.absoluteString)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
if let technicalDetails = self.problem.technicalDetails {
|
||||
Section("Technical details") {
|
||||
Text(verbatim: technicalDetails)
|
||||
.font(.system(.footnote, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
if let copyFeedback {
|
||||
Section {
|
||||
Text(copyFeedback)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connection problem")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
if let primaryActionTitle, let onPrimaryAction {
|
||||
Button(primaryActionTitle) {
|
||||
self.dismiss()
|
||||
onPrimaryAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
self.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var ownerSummary: String {
|
||||
switch self.problem.owner {
|
||||
case .gateway:
|
||||
return "Primary fix: gateway"
|
||||
case .iphone:
|
||||
return "Primary fix: this iPhone"
|
||||
case .both:
|
||||
return "Primary fix: check both this iPhone and the gateway"
|
||||
case .network:
|
||||
return "Primary fix: network or remote access"
|
||||
case .unknown:
|
||||
return "Primary fix: review details and retry"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ struct GatewayQuickSetupSheet: View {
|
||||
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
|
||||
@State private var connecting: Bool = false
|
||||
@State private var connectError: String?
|
||||
@State private var showGatewayProblemDetails: Bool = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -15,6 +16,14 @@ struct GatewayQuickSetupSheet: View {
|
||||
Text("Connect to a Gateway?")
|
||||
.font(.title2.bold())
|
||||
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemBanner(
|
||||
problem: gatewayProblem,
|
||||
onShowDetails: {
|
||||
self.showGatewayProblemDetails = true
|
||||
})
|
||||
}
|
||||
|
||||
if let candidate = self.bestCandidate {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(verbatim: candidate.name)
|
||||
@@ -27,7 +36,7 @@ struct GatewayQuickSetupSheet: View {
|
||||
// Use verbatim strings so Bonjour-provided values can't be interpreted as
|
||||
// localized format strings (which can crash with Objective-C exceptions).
|
||||
Text(verbatim: "Discovery: \(self.gatewayController.discoveryStatusText)")
|
||||
Text(verbatim: "Status: \(self.appModel.gatewayStatusText)")
|
||||
Text(verbatim: "Status: \(self.appModel.gatewayDisplayStatusText)")
|
||||
Text(verbatim: "Node: \(self.appModel.nodeStatusText)")
|
||||
Text(verbatim: "Operator: \(self.appModel.operatorStatusText)")
|
||||
}
|
||||
@@ -104,6 +113,11 @@ struct GatewayQuickSetupSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(problem: gatewayProblem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? {
|
||||
|
||||
@@ -120,6 +120,10 @@ final class NodeAppModel {
|
||||
// multiple pending requests and cause the onboarding UI to "flip-flop".
|
||||
var gatewayPairingPaused: Bool = false
|
||||
var gatewayPairingRequestId: String?
|
||||
private(set) var lastGatewayProblem: GatewayConnectionProblem?
|
||||
var gatewayDisplayStatusText: String {
|
||||
self.lastGatewayProblem?.statusText ?? self.gatewayStatusText
|
||||
}
|
||||
var seamColorHex: String?
|
||||
private var mainSessionBaseKey: String = "main"
|
||||
var selectedAgentId: String?
|
||||
@@ -1815,6 +1819,7 @@ extension NodeAppModel {
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.lastGatewayProblem = nil
|
||||
self.nodeGatewayTask?.cancel()
|
||||
self.nodeGatewayTask = nil
|
||||
self.operatorGatewayTask?.cancel()
|
||||
@@ -1848,6 +1853,7 @@ private extension NodeAppModel {
|
||||
self.gatewayAutoReconnectEnabled = true
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.lastGatewayProblem = nil
|
||||
self.nodeGatewayTask?.cancel()
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.gatewayHealthMonitor.stop()
|
||||
@@ -1866,6 +1872,38 @@ private extension NodeAppModel {
|
||||
self.apnsLastRegisteredTokenHex = nil
|
||||
}
|
||||
|
||||
func clearGatewayConnectionProblem() {
|
||||
self.lastGatewayProblem = nil
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
}
|
||||
|
||||
func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
|
||||
self.lastGatewayProblem = problem
|
||||
self.gatewayStatusText = problem.statusText
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.gatewayConnected = false
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
if problem.pauseReconnect {
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
}
|
||||
if problem.needsPairingApproval {
|
||||
self.gatewayPairingPaused = true
|
||||
self.gatewayPairingRequestId = problem.requestId
|
||||
} else {
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
}
|
||||
}
|
||||
|
||||
func shouldKeepGatewayProblemStatus(forDisconnectReason reason: String) -> Bool {
|
||||
guard let lastGatewayProblem else { return false }
|
||||
return GatewayConnectionProblemMapper.shouldPreserve(
|
||||
previousProblem: lastGatewayProblem,
|
||||
overDisconnectReason: reason)
|
||||
}
|
||||
|
||||
func shouldStartOperatorGatewayLoop(
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
@@ -2162,6 +2200,7 @@ private extension NodeAppModel {
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
self.clearGatewayConnectionProblem()
|
||||
self.gatewayStatusText = "Connected"
|
||||
self.gatewayServerName = url.host ?? "gateway"
|
||||
self.gatewayConnected = true
|
||||
@@ -2218,7 +2257,13 @@ private extension NodeAppModel {
|
||||
onDisconnected: { [weak self] reason in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
self.gatewayStatusText = "Disconnected: \(reason)"
|
||||
if self.shouldKeepGatewayProblemStatus(forDisconnectReason: reason),
|
||||
let lastGatewayProblem = self.lastGatewayProblem
|
||||
{
|
||||
self.gatewayStatusText = lastGatewayProblem.statusText
|
||||
} else {
|
||||
self.gatewayStatusText = "Disconnected: \(reason)"
|
||||
}
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.gatewayConnected = false
|
||||
@@ -2257,50 +2302,25 @@ private extension NodeAppModel {
|
||||
}
|
||||
|
||||
attempt += 1
|
||||
await MainActor.run {
|
||||
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.gatewayConnected = false
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
let problem = await MainActor.run {
|
||||
let nextProblem = GatewayConnectionProblemMapper.map(
|
||||
error: error,
|
||||
preserving: self.lastGatewayProblem)
|
||||
if let nextProblem {
|
||||
self.applyGatewayConnectionProblem(nextProblem)
|
||||
} else {
|
||||
self.lastGatewayProblem = nil
|
||||
self.gatewayStatusText = "Gateway error: \(error.localizedDescription)"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.gatewayConnected = false
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
return nextProblem
|
||||
}
|
||||
GatewayDiagnostics.log("gateway connect error: \(error.localizedDescription)")
|
||||
|
||||
// If auth is missing/rejected, pause reconnect churn until the user intervenes.
|
||||
// Reconnect loops only spam the same failing handshake and make onboarding noisy.
|
||||
let lower = error.localizedDescription.lowercased()
|
||||
if lower.contains("unauthorized") || lower.contains("gateway token missing") {
|
||||
await MainActor.run {
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// If pairing is required, stop reconnect churn. The user must approve the request
|
||||
// on the gateway before another connect attempt will succeed, and retry loops can
|
||||
// generate multiple pending requests.
|
||||
if lower.contains("not_paired") || lower.contains("pairing required") {
|
||||
let requestId: String? = {
|
||||
// GatewayResponseError for connect decorates the message with `(requestId: ...)`.
|
||||
// Keep this resilient since other layers may wrap the text.
|
||||
let text = error.localizedDescription
|
||||
guard let start = text.range(of: "(requestId: ")?.upperBound else { return nil }
|
||||
guard let end = text[start...].firstIndex(of: ")") else { return nil }
|
||||
let raw = String(text[start..<end]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return raw.isEmpty ? nil : raw
|
||||
}()
|
||||
await MainActor.run {
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = true
|
||||
self.gatewayPairingRequestId = requestId
|
||||
if let requestId, !requestId.isEmpty {
|
||||
self.gatewayStatusText =
|
||||
"Pairing required (requestId: \(requestId)). "
|
||||
+ "Approve on gateway and return to OpenClaw."
|
||||
} else {
|
||||
self.gatewayStatusText =
|
||||
"Pairing required. Approve on gateway and return to OpenClaw."
|
||||
}
|
||||
}
|
||||
if problem?.needsPairingApproval == true {
|
||||
// Hard stop the underlying WebSocket watchdog reconnects so the UI stays stable and
|
||||
// we don't generate multiple pending requests while waiting for approval.
|
||||
pausedForPairingApproval = true
|
||||
@@ -2311,6 +2331,10 @@ private extension NodeAppModel {
|
||||
break
|
||||
}
|
||||
|
||||
if problem?.pauseReconnect == true {
|
||||
continue
|
||||
}
|
||||
|
||||
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
|
||||
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
|
||||
}
|
||||
@@ -2322,6 +2346,7 @@ private extension NodeAppModel {
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.lastGatewayProblem = nil
|
||||
self.gatewayStatusText = "Offline"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
|
||||
@@ -376,7 +376,7 @@ private struct ConnectionStatusBox: View {
|
||||
gatewayController: GatewayConnectionController
|
||||
) -> [String] {
|
||||
var lines: [String] = [
|
||||
"gateway: \(appModel.gatewayStatusText)",
|
||||
"gateway: \(appModel.gatewayDisplayStatusText)",
|
||||
"discovery: \(gatewayController.discoveryStatusText)",
|
||||
]
|
||||
lines.append("server: \(appModel.gatewayServerName ?? "—")")
|
||||
|
||||
@@ -69,6 +69,7 @@ struct OnboardingWizardView: View {
|
||||
@State private var showQRScanner: Bool = false
|
||||
@State private var scannerError: String?
|
||||
@State private var selectedPhoto: PhotosPickerItem?
|
||||
@State private var showGatewayProblemDetails: Bool = false
|
||||
@State private var lastPairingAutoResumeAttemptAt: Date?
|
||||
private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect()
|
||||
|
||||
@@ -86,6 +87,10 @@ struct OnboardingWizardView: View {
|
||||
self.step == .intro || self.step == .welcome || self.step == .success
|
||||
}
|
||||
|
||||
private var currentProblem: GatewayConnectionProblem? {
|
||||
self.appModel.lastGatewayProblem
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
@@ -216,6 +221,16 @@ struct OnboardingWizardView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let currentProblem = self.currentProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: currentProblem,
|
||||
primaryActionTitle: "Retry",
|
||||
onPrimaryAction: {
|
||||
Task { await self.retryLastAttempt() }
|
||||
})
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.initializeState()
|
||||
}
|
||||
@@ -250,39 +265,11 @@ struct OnboardingWizardView: View {
|
||||
.onChange(of: self.gatewayPassword) { _, newValue in
|
||||
self.saveGatewayCredentials(token: self.gatewayToken, password: newValue)
|
||||
}
|
||||
.onChange(of: self.appModel.lastGatewayProblem) { _, newValue in
|
||||
self.updateConnectionIssue(problem: newValue, statusText: self.appModel.gatewayStatusText)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
|
||||
let next = GatewayConnectionIssue.detect(from: newValue)
|
||||
// Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection
|
||||
// transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns).
|
||||
if self.issue.needsPairing, next.needsPairing {
|
||||
// Keep the requestId sticky even if the status line omits it after we pause.
|
||||
let mergedRequestId = next.requestId ?? self.issue.requestId ?? self.pairingRequestId
|
||||
self.issue = .pairingRequired(requestId: mergedRequestId)
|
||||
} else if self.issue.needsPairing, !next.needsPairing {
|
||||
// Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect.
|
||||
} else if self.issue.needsAuthToken, !next.needsAuthToken, !next.needsPairing {
|
||||
// Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until
|
||||
// the user retries/scans again or we successfully connect.
|
||||
} else {
|
||||
self.issue = next
|
||||
}
|
||||
|
||||
if let requestId = next.requestId, !requestId.isEmpty {
|
||||
self.pairingRequestId = requestId
|
||||
}
|
||||
|
||||
// If the gateway tells us auth is missing/rejected, stop reconnect churn until the user intervenes.
|
||||
if next.needsAuthToken {
|
||||
self.appModel.gatewayAutoReconnectEnabled = false
|
||||
}
|
||||
|
||||
if self.issue.needsAuthToken || self.issue.needsPairing {
|
||||
self.step = .auth
|
||||
}
|
||||
if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
self.connectMessage = newValue
|
||||
self.statusLine = newValue
|
||||
}
|
||||
self.updateConnectionIssue(problem: self.appModel.lastGatewayProblem, statusText: newValue)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
guard newValue != nil else { return }
|
||||
@@ -509,7 +496,7 @@ struct OnboardingWizardView: View {
|
||||
Section {
|
||||
LabeledContent("Mode", value: selectedMode.title)
|
||||
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
|
||||
LabeledContent("Status", value: self.appModel.gatewayStatusText)
|
||||
LabeledContent("Status", value: self.appModel.gatewayDisplayStatusText)
|
||||
LabeledContent("Progress", value: self.statusLine)
|
||||
} header: {
|
||||
Text("Status")
|
||||
@@ -612,7 +599,17 @@ struct OnboardingWizardView: View {
|
||||
.autocorrectionDisabled()
|
||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
|
||||
if self.issue.needsAuthToken {
|
||||
if let problem = self.currentProblem {
|
||||
GatewayProblemBanner(
|
||||
problem: problem,
|
||||
primaryActionTitle: "Retry connection",
|
||||
onPrimaryAction: {
|
||||
Task { await self.retryLastAttempt() }
|
||||
},
|
||||
onShowDetails: {
|
||||
self.showGatewayProblemDetails = true
|
||||
})
|
||||
} else if self.issue.needsAuthToken {
|
||||
Text("Gateway rejected credentials. Scan a fresh QR code or update token/password.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -635,14 +632,15 @@ struct OnboardingWizardView: View {
|
||||
Text("Pairing Approval")
|
||||
} footer: {
|
||||
let requestLine: String = {
|
||||
if let id = self.issue.requestId, !id.isEmpty {
|
||||
if let id = self.currentProblem?.requestId ?? self.issue.requestId, !id.isEmpty {
|
||||
return "Request ID: \(id)"
|
||||
}
|
||||
return "Request ID: check `openclaw devices list`."
|
||||
}()
|
||||
let commandLine = self.currentProblem?.actionCommand ?? "openclaw devices approve <requestId>"
|
||||
Text(
|
||||
"Approve this device on the gateway.\n"
|
||||
+ "1) `openclaw devices approve` (or `openclaw devices approve <requestId>`)\n"
|
||||
+ "1) `\(commandLine)`\n"
|
||||
+ "2) `/pair approve` in your OpenClaw chat\n"
|
||||
+ "\(requestLine)\n"
|
||||
+ "OpenClaw will also retry automatically when you return to this app.")
|
||||
@@ -824,6 +822,45 @@ struct OnboardingWizardView: View {
|
||||
self.resumeAfterPairingApprovalInBackground()
|
||||
}
|
||||
|
||||
private func updateConnectionIssue(problem: GatewayConnectionProblem?, statusText: String) {
|
||||
let next = GatewayConnectionIssue.detect(problem: problem)
|
||||
let fallback = next == .none ? GatewayConnectionIssue.detect(from: statusText) : next
|
||||
|
||||
// Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection
|
||||
// transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns).
|
||||
if self.issue.needsPairing, fallback.needsPairing {
|
||||
let mergedRequestId = fallback.requestId ?? self.issue.requestId ?? self.pairingRequestId
|
||||
self.issue = .pairingRequired(requestId: mergedRequestId)
|
||||
} else if self.issue.needsPairing, !fallback.needsPairing {
|
||||
// Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect.
|
||||
} else if self.issue.needsAuthToken, !fallback.needsAuthToken, !fallback.needsPairing {
|
||||
// Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until
|
||||
// the user retries/scans again or we successfully connect.
|
||||
} else {
|
||||
self.issue = fallback
|
||||
}
|
||||
|
||||
if let requestId = problem?.requestId ?? fallback.requestId, !requestId.isEmpty {
|
||||
self.pairingRequestId = requestId
|
||||
}
|
||||
|
||||
if self.issue.needsAuthToken || self.issue.needsPairing || problem?.pauseReconnect == true {
|
||||
self.step = .auth
|
||||
}
|
||||
|
||||
if let problem {
|
||||
self.connectMessage = problem.message
|
||||
self.statusLine = problem.message
|
||||
return
|
||||
}
|
||||
|
||||
let trimmedStatus = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedStatus.isEmpty {
|
||||
self.connectMessage = trimmedStatus
|
||||
self.statusLine = trimmedStatus
|
||||
}
|
||||
}
|
||||
|
||||
private func detectQRCode(from data: Data) -> String? {
|
||||
guard let ciImage = CIImage(data: data) else { return nil }
|
||||
let detector = CIDetector(
|
||||
|
||||
@@ -98,6 +98,9 @@ struct RootCanvas: View {
|
||||
},
|
||||
openSettings: {
|
||||
self.presentedSheet = .settings
|
||||
},
|
||||
retryGatewayConnection: {
|
||||
Task { await self.gatewayController.connectLastKnown() }
|
||||
})
|
||||
.preferredColorScheme(.dark)
|
||||
|
||||
@@ -229,7 +232,7 @@ struct RootCanvas: View {
|
||||
private func updateCanvasDebugStatus() {
|
||||
self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled)
|
||||
guard self.canvasDebugStatusEnabled else { return }
|
||||
let title = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let title = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress
|
||||
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
|
||||
}
|
||||
@@ -454,6 +457,7 @@ private struct CanvasContent: View {
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
||||
@State private var showGatewayActions: Bool = false
|
||||
@State private var showGatewayProblemDetails: Bool = false
|
||||
var systemColorScheme: ColorScheme
|
||||
var gatewayStatus: StatusPill.GatewayState
|
||||
var voiceWakeEnabled: Bool
|
||||
@@ -462,6 +466,7 @@ private struct CanvasContent: View {
|
||||
var cameraHUDKind: NodeAppModel.CameraHUDKind?
|
||||
var openChat: () -> Void
|
||||
var openSettings: () -> Void
|
||||
var retryGatewayConnection: () -> Void
|
||||
|
||||
private var brightenButtons: Bool { self.systemColorScheme == .light }
|
||||
private var talkActive: Bool { self.appModel.talkMode.isEnabled || self.talkEnabled }
|
||||
@@ -488,6 +493,8 @@ private struct CanvasContent: View {
|
||||
onStatusTap: {
|
||||
if self.gatewayStatus == .connected {
|
||||
self.showGatewayActions = true
|
||||
} else if self.appModel.lastGatewayProblem != nil {
|
||||
self.showGatewayProblemDetails = true
|
||||
} else {
|
||||
self.openSettings()
|
||||
}
|
||||
@@ -504,13 +511,35 @@ private struct CanvasContent: View {
|
||||
self.openSettings()
|
||||
})
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem,
|
||||
self.gatewayStatus != .connected
|
||||
{
|
||||
GatewayProblemBanner(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: gatewayProblem.retryable ? "Retry" : "Open Settings",
|
||||
onPrimaryAction: {
|
||||
if gatewayProblem.retryable {
|
||||
self.retryGatewayConnection()
|
||||
} else {
|
||||
self.openSettings()
|
||||
}
|
||||
},
|
||||
onShowDetails: {
|
||||
self.showGatewayProblemDetails = true
|
||||
})
|
||||
.padding(.horizontal, 12)
|
||||
.safeAreaPadding(.top, 10)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
|
||||
VoiceWakeToast(
|
||||
command: voiceWakeToastText,
|
||||
brighten: self.brightenButtons)
|
||||
.padding(.leading, 10)
|
||||
.safeAreaPadding(.top, 58)
|
||||
.safeAreaPadding(.top, self.appModel.lastGatewayProblem == nil ? 58 : 132)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
@@ -518,6 +547,16 @@ private struct CanvasContent: View {
|
||||
isPresented: self.$showGatewayActions,
|
||||
onDisconnect: { self.appModel.disconnectGateway() },
|
||||
onOpenSettings: { self.openSettings() })
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: "Open Settings",
|
||||
onPrimaryAction: {
|
||||
self.openSettings()
|
||||
})
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Keep the runtime talk state aligned with persisted toggle state on cold launch.
|
||||
if self.talkEnabled != self.appModel.talkMode.isEnabled {
|
||||
|
||||
@@ -9,6 +9,7 @@ struct RootTabs: View {
|
||||
@State private var voiceWakeToastText: String?
|
||||
@State private var toastDismissTask: Task<Void, Never>?
|
||||
@State private var showGatewayActions: Bool = false
|
||||
@State private var showGatewayProblemDetails: Bool = false
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: self.$selectedTab) {
|
||||
@@ -32,6 +33,8 @@ struct RootTabs: View {
|
||||
onTap: {
|
||||
if self.gatewayStatus == .connected {
|
||||
self.showGatewayActions = true
|
||||
} else if self.appModel.lastGatewayProblem != nil {
|
||||
self.showGatewayProblemDetails = true
|
||||
} else {
|
||||
self.selectedTab = 2
|
||||
}
|
||||
@@ -39,11 +42,29 @@ struct RootTabs: View {
|
||||
.padding(.leading, 10)
|
||||
.safeAreaPadding(.top, 10)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem,
|
||||
self.gatewayStatus != .connected
|
||||
{
|
||||
GatewayProblemBanner(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: "Open Settings",
|
||||
onPrimaryAction: {
|
||||
self.selectedTab = 2
|
||||
},
|
||||
onShowDetails: {
|
||||
self.showGatewayProblemDetails = true
|
||||
})
|
||||
.padding(.horizontal, 12)
|
||||
.safeAreaPadding(.top, 10)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
|
||||
VoiceWakeToast(command: voiceWakeToastText)
|
||||
.padding(.leading, 10)
|
||||
.safeAreaPadding(.top, 58)
|
||||
.safeAreaPadding(.top, self.appModel.lastGatewayProblem == nil ? 58 : 132)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
@@ -74,6 +95,16 @@ struct RootTabs: View {
|
||||
isPresented: self.$showGatewayActions,
|
||||
onDisconnect: { self.appModel.disconnectGateway() },
|
||||
onOpenSettings: { self.selectedTab = 2 })
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: "Open Settings",
|
||||
onPrimaryAction: {
|
||||
self.selectedTab = 2
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayStatus: StatusPill.GatewayState {
|
||||
|
||||
@@ -53,6 +53,7 @@ struct SettingsTab: View {
|
||||
@State private var selectedAgentPickerId: String = ""
|
||||
|
||||
@State private var showResetOnboardingAlert: Bool = false
|
||||
@State private var showGatewayProblemDetails: Bool = false
|
||||
@State private var activeFeatureHelp: FeatureHelp?
|
||||
@State private var suppressCredentialPersist: Bool = false
|
||||
|
||||
@@ -63,6 +64,20 @@ struct SettingsTab: View {
|
||||
Form {
|
||||
Section {
|
||||
DisclosureGroup(isExpanded: self.$gatewayExpanded) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem,
|
||||
!self.isGatewayConnected
|
||||
{
|
||||
GatewayProblemBanner(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: "Retry connection",
|
||||
onPrimaryAction: {
|
||||
Task { await self.retryGatewayConnectionFromProblem() }
|
||||
},
|
||||
onShowDetails: {
|
||||
self.showGatewayProblemDetails = true
|
||||
})
|
||||
}
|
||||
|
||||
if !self.isGatewayConnected {
|
||||
Text(
|
||||
"1. Open a chat with your OpenClaw agent and send /pair\n"
|
||||
@@ -123,7 +138,7 @@ struct SettingsTab: View {
|
||||
if self.appModel.gatewayServerName == nil {
|
||||
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
|
||||
}
|
||||
LabeledContent("Status", value: self.appModel.gatewayStatusText)
|
||||
LabeledContent("Status", value: self.appModel.gatewayDisplayStatusText)
|
||||
Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
|
||||
|
||||
if let serverName = self.appModel.gatewayServerName {
|
||||
@@ -402,6 +417,16 @@ struct SettingsTab: View {
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: "Retry",
|
||||
onPrimaryAction: {
|
||||
Task { await self.retryGatewayConnectionFromProblem() }
|
||||
})
|
||||
}
|
||||
}
|
||||
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
|
||||
Button("Reset", role: .destructive) {
|
||||
self.resetOnboarding()
|
||||
@@ -593,6 +618,9 @@ struct SettingsTab: View {
|
||||
if let server = self.appModel.gatewayServerName, self.isGatewayConnected {
|
||||
return server
|
||||
}
|
||||
if let problem = self.appModel.lastGatewayProblem {
|
||||
return problem.statusText
|
||||
}
|
||||
let trimmed = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "Not connected" : trimmed
|
||||
}
|
||||
@@ -642,7 +670,7 @@ struct SettingsTab: View {
|
||||
|
||||
private func gatewayDebugText() -> String {
|
||||
var lines: [String] = [
|
||||
"gateway: \(self.appModel.gatewayStatusText)",
|
||||
"gateway: \(self.appModel.gatewayDisplayStatusText)",
|
||||
"discovery: \(self.gatewayController.discoveryStatusText)",
|
||||
]
|
||||
lines.append("server: \(self.appModel.gatewayServerName ?? "—")")
|
||||
@@ -889,6 +917,9 @@ struct SettingsTab: View {
|
||||
}
|
||||
|
||||
private var setupStatusLine: String? {
|
||||
if let problem = self.appModel.lastGatewayProblem {
|
||||
return problem.message
|
||||
}
|
||||
let trimmedSetup = self.setupStatusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let friendly = self.friendlyGatewayMessage(from: gatewayStatus) { return friendly }
|
||||
@@ -987,6 +1018,14 @@ struct SettingsTab: View {
|
||||
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
|
||||
}
|
||||
|
||||
private func retryGatewayConnectionFromProblem() async {
|
||||
if self.manualGatewayEnabled || self.connectingGatewayID == "manual" {
|
||||
await self.connectManual()
|
||||
return
|
||||
}
|
||||
await self.connectLastKnown()
|
||||
}
|
||||
|
||||
private func resetOnboarding() {
|
||||
// Disconnect first so RootCanvas doesn't instantly mark onboarding complete again.
|
||||
self.appModel.disconnectGateway()
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
enum GatewayStatusBuilder {
|
||||
@MainActor
|
||||
static func build(appModel: NodeAppModel) -> StatusPill.GatewayState {
|
||||
if appModel.gatewayServerName != nil { return .connected }
|
||||
self.build(
|
||||
gatewayServerName: appModel.gatewayServerName,
|
||||
lastGatewayProblem: appModel.lastGatewayProblem,
|
||||
gatewayStatusText: appModel.gatewayStatusText)
|
||||
}
|
||||
|
||||
let text = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
static func build(
|
||||
gatewayServerName: String?,
|
||||
lastGatewayProblem: GatewayConnectionProblem?,
|
||||
gatewayStatusText: String) -> StatusPill.GatewayState
|
||||
{
|
||||
if gatewayServerName != nil { return .connected }
|
||||
if let lastGatewayProblem, lastGatewayProblem.pauseReconnect { return .error }
|
||||
|
||||
let text = gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if text.localizedCaseInsensitiveContains("connecting") ||
|
||||
text.localizedCaseInsensitiveContains("reconnecting")
|
||||
{
|
||||
|
||||
@@ -16,6 +16,31 @@ enum StatusActivityBuilder {
|
||||
tint: .orange)
|
||||
}
|
||||
|
||||
if let gatewayProblem = appModel.lastGatewayProblem {
|
||||
switch gatewayProblem.kind {
|
||||
case .pairingRequired,
|
||||
.pairingRoleUpgradeRequired,
|
||||
.pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
return StatusPill.Activity(
|
||||
title: "Approval pending",
|
||||
systemImage: "person.crop.circle.badge.clock",
|
||||
tint: .orange)
|
||||
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
||||
return StatusPill.Activity(
|
||||
title: "Check network",
|
||||
systemImage: "wifi.exclamationmark",
|
||||
tint: .orange)
|
||||
default:
|
||||
if gatewayProblem.pauseReconnect {
|
||||
return StatusPill.Activity(
|
||||
title: "Action required",
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
tint: .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let gatewayStatus = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayLower = gatewayStatus.lowercased()
|
||||
if gatewayLower.contains("repair") {
|
||||
|
||||
36
apps/ios/Tests/GatewayStatusBuilderTests.swift
Normal file
36
apps/ios/Tests/GatewayStatusBuilderTests.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite struct GatewayStatusBuilderTests {
|
||||
@Test func pausedProblemKeepsErrorStatus() {
|
||||
let state = GatewayStatusBuilder.build(
|
||||
gatewayServerName: nil,
|
||||
lastGatewayProblem: GatewayConnectionProblem(
|
||||
kind: .pairingRequired,
|
||||
owner: .gateway,
|
||||
title: "Pairing required",
|
||||
message: "Approve this device before reconnecting.",
|
||||
requestId: "req-123",
|
||||
retryable: false,
|
||||
pauseReconnect: true),
|
||||
gatewayStatusText: "Reconnecting…")
|
||||
|
||||
#expect(state == .error)
|
||||
}
|
||||
|
||||
@Test func transientProblemAllowsConnectingStatus() {
|
||||
let state = GatewayStatusBuilder.build(
|
||||
gatewayServerName: nil,
|
||||
lastGatewayProblem: GatewayConnectionProblem(
|
||||
kind: .timeout,
|
||||
owner: .network,
|
||||
title: "Connection timed out",
|
||||
message: "The gateway did not respond before the connection timed out.",
|
||||
retryable: true,
|
||||
pauseReconnect: false),
|
||||
gatewayStatusText: "Reconnecting…")
|
||||
|
||||
#expect(state == .connecting)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ enum HostEnvSecurityPolicy {
|
||||
"GCONV_PATH",
|
||||
"IFS",
|
||||
"SSLKEYLOGFILE",
|
||||
"JAVA_OPTS",
|
||||
"JAVA_TOOL_OPTIONS",
|
||||
"_JAVA_OPTIONS",
|
||||
"JDK_JAVA_OPTIONS",
|
||||
@@ -144,6 +145,8 @@ enum HostEnvSecurityPolicy {
|
||||
"HGRCPATH",
|
||||
"PYTHONUSERBASE",
|
||||
"RUSTC_WRAPPER",
|
||||
"RUSTFLAGS",
|
||||
"CARGO_HOME",
|
||||
"VIRTUAL_ENV",
|
||||
"LUA_PATH",
|
||||
"LUA_CPATH",
|
||||
|
||||
@@ -537,6 +537,8 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let besteffortdeliver: Bool?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
public let bootstrapcontextmode: AnyCodable?
|
||||
public let bootstrapcontextrunkind: AnyCodable?
|
||||
public let internalevents: [[String: AnyCodable]]?
|
||||
public let inputprovenance: [String: AnyCodable]?
|
||||
public let idempotencykey: String
|
||||
@@ -566,6 +568,8 @@ public struct AgentParams: Codable, Sendable {
|
||||
besteffortdeliver: Bool?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
bootstrapcontextmode: AnyCodable?,
|
||||
bootstrapcontextrunkind: AnyCodable?,
|
||||
internalevents: [[String: AnyCodable]]?,
|
||||
inputprovenance: [String: AnyCodable]?,
|
||||
idempotencykey: String,
|
||||
@@ -594,6 +598,8 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.besteffortdeliver = besteffortdeliver
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
self.bootstrapcontextmode = bootstrapcontextmode
|
||||
self.bootstrapcontextrunkind = bootstrapcontextrunkind
|
||||
self.internalevents = internalevents
|
||||
self.inputprovenance = inputprovenance
|
||||
self.idempotencykey = idempotencykey
|
||||
@@ -624,6 +630,8 @@ public struct AgentParams: Codable, Sendable {
|
||||
case besteffortdeliver = "bestEffortDeliver"
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
case bootstrapcontextmode = "bootstrapContextMode"
|
||||
case bootstrapcontextrunkind = "bootstrapContextRunKind"
|
||||
case internalevents = "internalEvents"
|
||||
case inputprovenance = "inputProvenance"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
@@ -1327,6 +1335,236 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionCompactionCheckpoint: Codable, Sendable {
|
||||
public let checkpointid: String
|
||||
public let sessionkey: String
|
||||
public let sessionid: String
|
||||
public let createdat: Int
|
||||
public let reason: AnyCodable
|
||||
public let tokensbefore: Int?
|
||||
public let tokensafter: Int?
|
||||
public let summary: String?
|
||||
public let firstkeptentryid: String?
|
||||
public let precompaction: [String: AnyCodable]
|
||||
public let postcompaction: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
checkpointid: String,
|
||||
sessionkey: String,
|
||||
sessionid: String,
|
||||
createdat: Int,
|
||||
reason: AnyCodable,
|
||||
tokensbefore: Int?,
|
||||
tokensafter: Int?,
|
||||
summary: String?,
|
||||
firstkeptentryid: String?,
|
||||
precompaction: [String: AnyCodable],
|
||||
postcompaction: [String: AnyCodable])
|
||||
{
|
||||
self.checkpointid = checkpointid
|
||||
self.sessionkey = sessionkey
|
||||
self.sessionid = sessionid
|
||||
self.createdat = createdat
|
||||
self.reason = reason
|
||||
self.tokensbefore = tokensbefore
|
||||
self.tokensafter = tokensafter
|
||||
self.summary = summary
|
||||
self.firstkeptentryid = firstkeptentryid
|
||||
self.precompaction = precompaction
|
||||
self.postcompaction = postcompaction
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case checkpointid = "checkpointId"
|
||||
case sessionkey = "sessionKey"
|
||||
case sessionid = "sessionId"
|
||||
case createdat = "createdAt"
|
||||
case reason
|
||||
case tokensbefore = "tokensBefore"
|
||||
case tokensafter = "tokensAfter"
|
||||
case summary
|
||||
case firstkeptentryid = "firstKeptEntryId"
|
||||
case precompaction = "preCompaction"
|
||||
case postcompaction = "postCompaction"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionListParams: Codable, Sendable {
|
||||
public let key: String
|
||||
|
||||
public init(
|
||||
key: String)
|
||||
{
|
||||
self.key = key
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionGetParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let checkpointid: String
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
checkpointid: String)
|
||||
{
|
||||
self.key = key
|
||||
self.checkpointid = checkpointid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case checkpointid = "checkpointId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionBranchParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let checkpointid: String
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
checkpointid: String)
|
||||
{
|
||||
self.key = key
|
||||
self.checkpointid = checkpointid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case checkpointid = "checkpointId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionRestoreParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let checkpointid: String
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
checkpointid: String)
|
||||
{
|
||||
self.key = key
|
||||
self.checkpointid = checkpointid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case checkpointid = "checkpointId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionListResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let key: String
|
||||
public let checkpoints: [SessionCompactionCheckpoint]
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
key: String,
|
||||
checkpoints: [SessionCompactionCheckpoint])
|
||||
{
|
||||
self.ok = ok
|
||||
self.key = key
|
||||
self.checkpoints = checkpoints
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case key
|
||||
case checkpoints
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionGetResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let key: String
|
||||
public let checkpoint: SessionCompactionCheckpoint
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
key: String,
|
||||
checkpoint: SessionCompactionCheckpoint)
|
||||
{
|
||||
self.ok = ok
|
||||
self.key = key
|
||||
self.checkpoint = checkpoint
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case key
|
||||
case checkpoint
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionBranchResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let sourcekey: String
|
||||
public let key: String
|
||||
public let sessionid: String
|
||||
public let checkpoint: SessionCompactionCheckpoint
|
||||
public let entry: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
sourcekey: String,
|
||||
key: String,
|
||||
sessionid: String,
|
||||
checkpoint: SessionCompactionCheckpoint,
|
||||
entry: [String: AnyCodable])
|
||||
{
|
||||
self.ok = ok
|
||||
self.sourcekey = sourcekey
|
||||
self.key = key
|
||||
self.sessionid = sessionid
|
||||
self.checkpoint = checkpoint
|
||||
self.entry = entry
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case sourcekey = "sourceKey"
|
||||
case key
|
||||
case sessionid = "sessionId"
|
||||
case checkpoint
|
||||
case entry
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionRestoreResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let key: String
|
||||
public let sessionid: String
|
||||
public let checkpoint: SessionCompactionCheckpoint
|
||||
public let entry: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
key: String,
|
||||
sessionid: String,
|
||||
checkpoint: SessionCompactionCheckpoint,
|
||||
entry: [String: AnyCodable])
|
||||
{
|
||||
self.ok = ok
|
||||
self.key = key
|
||||
self.sessionid = sessionid
|
||||
self.checkpoint = checkpoint
|
||||
self.entry = entry
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case key
|
||||
case sessionid = "sessionId"
|
||||
case checkpoint
|
||||
case entry
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCreateParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let agentid: String?
|
||||
|
||||
@@ -624,11 +624,31 @@ public actor GatewayChannelActor {
|
||||
let detailCode = details?["code"]?.value as? String
|
||||
let canRetryWithDeviceToken = details?["canRetryWithDeviceToken"]?.value as? Bool ?? false
|
||||
let recommendedNextStep = details?["recommendedNextStep"]?.value as? String
|
||||
let requestId = details?["requestId"]?.value as? String
|
||||
let reason = details?["reason"]?.value as? String
|
||||
let owner = details?["owner"]?.value as? String
|
||||
let title = details?["title"]?.value as? String
|
||||
let userMessage = details?["userMessage"]?.value as? String
|
||||
let actionLabel = details?["actionLabel"]?.value as? String
|
||||
let actionCommand = details?["actionCommand"]?.value as? String
|
||||
let docsURLString = details?["docsUrl"]?.value as? String
|
||||
let retryableOverride = details?["retryable"]?.value as? Bool
|
||||
let pauseReconnectOverride = details?["pauseReconnect"]?.value as? Bool
|
||||
throw GatewayConnectAuthError(
|
||||
message: msg,
|
||||
detailCodeRaw: detailCode,
|
||||
canRetryWithDeviceToken: canRetryWithDeviceToken,
|
||||
recommendedNextStepRaw: recommendedNextStep)
|
||||
recommendedNextStepRaw: recommendedNextStep,
|
||||
requestId: requestId,
|
||||
detailsReason: reason,
|
||||
ownerRaw: owner,
|
||||
titleOverride: title,
|
||||
userMessageOverride: userMessage,
|
||||
actionLabel: actionLabel,
|
||||
actionCommand: actionCommand,
|
||||
docsURLString: docsURLString,
|
||||
retryableOverride: retryableOverride,
|
||||
pauseReconnectOverride: pauseReconnectOverride)
|
||||
}
|
||||
guard let payload = res.payload else {
|
||||
throw NSError(
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
import Foundation
|
||||
|
||||
public struct GatewayConnectionProblem: Equatable, Sendable {
|
||||
public enum Kind: String, Equatable, Sendable {
|
||||
case gatewayAuthTokenMissing
|
||||
case gatewayAuthTokenMismatch
|
||||
case gatewayAuthTokenNotConfigured
|
||||
case gatewayAuthPasswordMissing
|
||||
case gatewayAuthPasswordMismatch
|
||||
case gatewayAuthPasswordNotConfigured
|
||||
case bootstrapTokenInvalid
|
||||
case deviceTokenMismatch
|
||||
case pairingRequired
|
||||
case pairingRoleUpgradeRequired
|
||||
case pairingScopeUpgradeRequired
|
||||
case pairingMetadataUpgradeRequired
|
||||
case deviceIdentityRequired
|
||||
case deviceSignatureExpired
|
||||
case deviceNonceRequired
|
||||
case deviceNonceMismatch
|
||||
case deviceSignatureInvalid
|
||||
case devicePublicKeyInvalid
|
||||
case deviceIdMismatch
|
||||
case tailscaleIdentityMissing
|
||||
case tailscaleProxyMissing
|
||||
case tailscaleWhoisFailed
|
||||
case tailscaleIdentityMismatch
|
||||
case authRateLimited
|
||||
case timeout
|
||||
case connectionRefused
|
||||
case reachabilityFailed
|
||||
case websocketCancelled
|
||||
case unknown
|
||||
}
|
||||
|
||||
public enum Owner: String, Equatable, Sendable {
|
||||
case gateway
|
||||
case iphone
|
||||
case both
|
||||
case network
|
||||
case unknown
|
||||
}
|
||||
|
||||
public let kind: Kind
|
||||
public let owner: Owner
|
||||
public let title: String
|
||||
public let message: String
|
||||
public let actionLabel: String?
|
||||
public let actionCommand: String?
|
||||
public let docsURL: URL?
|
||||
public let requestId: String?
|
||||
public let retryable: Bool
|
||||
public let pauseReconnect: Bool
|
||||
public let technicalDetails: String?
|
||||
|
||||
public init(
|
||||
kind: Kind,
|
||||
owner: Owner,
|
||||
title: String,
|
||||
message: String,
|
||||
actionLabel: String? = nil,
|
||||
actionCommand: String? = nil,
|
||||
docsURL: URL? = nil,
|
||||
requestId: String? = nil,
|
||||
retryable: Bool,
|
||||
pauseReconnect: Bool,
|
||||
technicalDetails: String? = nil)
|
||||
{
|
||||
self.kind = kind
|
||||
self.owner = owner
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.actionLabel = Self.trimmedOrNil(actionLabel)
|
||||
self.actionCommand = Self.trimmedOrNil(actionCommand)
|
||||
self.docsURL = docsURL
|
||||
self.requestId = Self.trimmedOrNil(requestId)
|
||||
self.retryable = retryable
|
||||
self.pauseReconnect = pauseReconnect
|
||||
self.technicalDetails = Self.trimmedOrNil(technicalDetails)
|
||||
}
|
||||
|
||||
public var needsPairingApproval: Bool {
|
||||
switch self.kind {
|
||||
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public var needsCredentialUpdate: Bool {
|
||||
switch self.kind {
|
||||
case .gatewayAuthTokenMissing,
|
||||
.gatewayAuthTokenMismatch,
|
||||
.gatewayAuthTokenNotConfigured,
|
||||
.gatewayAuthPasswordMissing,
|
||||
.gatewayAuthPasswordMismatch,
|
||||
.gatewayAuthPasswordNotConfigured,
|
||||
.bootstrapTokenInvalid,
|
||||
.deviceTokenMismatch:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public var statusText: String {
|
||||
switch self.kind {
|
||||
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired:
|
||||
if let requestId {
|
||||
return "\(self.title) (request ID: \(requestId))"
|
||||
}
|
||||
return self.title
|
||||
default:
|
||||
return self.title
|
||||
}
|
||||
}
|
||||
|
||||
private static func trimmedOrNil(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
public enum GatewayConnectionProblemMapper {
|
||||
public static func map(error: Error, preserving previousProblem: GatewayConnectionProblem? = nil) -> GatewayConnectionProblem? {
|
||||
guard let nextProblem = self.rawMap(error) else {
|
||||
return nil
|
||||
}
|
||||
guard let previousProblem else {
|
||||
return nextProblem
|
||||
}
|
||||
if self.shouldPreserve(previousProblem: previousProblem, over: nextProblem) {
|
||||
return previousProblem
|
||||
}
|
||||
return nextProblem
|
||||
}
|
||||
|
||||
public static func shouldPreserve(previousProblem: GatewayConnectionProblem, over nextProblem: GatewayConnectionProblem) -> Bool {
|
||||
if nextProblem.kind == .websocketCancelled {
|
||||
return previousProblem.pauseReconnect || previousProblem.requestId != nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public static func shouldPreserve(previousProblem: GatewayConnectionProblem, overDisconnectReason reason: String) -> Bool {
|
||||
let normalized = reason.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !normalized.isEmpty else { return false }
|
||||
if normalized.contains("cancelled") || normalized.contains("canceled") {
|
||||
return previousProblem.pauseReconnect || previousProblem.requestId != nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func rawMap(_ error: Error) -> GatewayConnectionProblem? {
|
||||
if let authError = error as? GatewayConnectAuthError {
|
||||
return self.map(authError)
|
||||
}
|
||||
if let responseError = error as? GatewayResponseError {
|
||||
return self.map(responseError)
|
||||
}
|
||||
return self.mapTransportError(error)
|
||||
}
|
||||
|
||||
private static func map(_ authError: GatewayConnectAuthError) -> GatewayConnectionProblem {
|
||||
let pairingCommand = self.approvalCommand(requestId: authError.requestId)
|
||||
|
||||
switch authError.detail {
|
||||
case .authTokenMissing:
|
||||
return self.problem(
|
||||
kind: .gatewayAuthTokenMissing,
|
||||
owner: .both,
|
||||
title: authError.titleOverride ?? "Gateway token required",
|
||||
message: authError.userMessageOverride
|
||||
?? "This gateway requires an auth token, but this iPhone did not send one.",
|
||||
actionLabel: authError.actionLabel ?? "Open Settings",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authTokenMismatch:
|
||||
return self.problem(
|
||||
kind: .gatewayAuthTokenMismatch,
|
||||
owner: .both,
|
||||
title: authError.titleOverride ?? "Gateway token is out of date",
|
||||
message: authError.userMessageOverride
|
||||
?? "The token on this iPhone does not match the gateway token.",
|
||||
actionLabel: authError.actionLabel ?? (authError.canRetryWithDeviceToken ? "Retry once" : "Update gateway token"),
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
||||
requestId: authError.requestId,
|
||||
retryable: authError.retryableOverride ?? authError.canRetryWithDeviceToken,
|
||||
pauseReconnect: authError.pauseReconnectOverride ?? !authError.canRetryWithDeviceToken,
|
||||
authError: authError)
|
||||
case .authTokenNotConfigured:
|
||||
return self.problem(
|
||||
kind: .gatewayAuthTokenNotConfigured,
|
||||
owner: .gateway,
|
||||
title: authError.titleOverride ?? "Gateway token is not configured",
|
||||
message: authError.userMessageOverride
|
||||
?? "This gateway is set to token auth, but no gateway token is configured on the gateway.",
|
||||
actionLabel: authError.actionLabel ?? "Fix on gateway",
|
||||
actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.token <new-token>",
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authPasswordMissing:
|
||||
return self.problem(
|
||||
kind: .gatewayAuthPasswordMissing,
|
||||
owner: .both,
|
||||
title: authError.titleOverride ?? "Gateway password required",
|
||||
message: authError.userMessageOverride
|
||||
?? "This gateway requires a password, but this iPhone did not send one.",
|
||||
actionLabel: authError.actionLabel ?? "Open Settings",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authPasswordMismatch:
|
||||
return self.problem(
|
||||
kind: .gatewayAuthPasswordMismatch,
|
||||
owner: .both,
|
||||
title: authError.titleOverride ?? "Gateway password is out of date",
|
||||
message: authError.userMessageOverride
|
||||
?? "The saved password on this iPhone does not match the gateway password.",
|
||||
actionLabel: authError.actionLabel ?? "Update password",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authPasswordNotConfigured:
|
||||
return self.problem(
|
||||
kind: .gatewayAuthPasswordNotConfigured,
|
||||
owner: .gateway,
|
||||
title: authError.titleOverride ?? "Gateway password is not configured",
|
||||
message: authError.userMessageOverride
|
||||
?? "This gateway is set to password auth, but no gateway password is configured on the gateway.",
|
||||
actionLabel: authError.actionLabel ?? "Fix on gateway",
|
||||
actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.password <new-password>",
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authBootstrapTokenInvalid:
|
||||
return self.problem(
|
||||
kind: .bootstrapTokenInvalid,
|
||||
owner: .iphone,
|
||||
title: authError.titleOverride ?? "Setup code expired",
|
||||
message: authError.userMessageOverride
|
||||
?? "The setup QR or bootstrap token is no longer valid.",
|
||||
actionLabel: authError.actionLabel ?? "Scan QR again",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authDeviceTokenMismatch:
|
||||
return self.problem(
|
||||
kind: .deviceTokenMismatch,
|
||||
owner: .both,
|
||||
title: authError.titleOverride ?? "This iPhone's saved device token is no longer valid",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway rejected the stored device token for this role.",
|
||||
actionLabel: authError.actionLabel ?? "Repair pairing",
|
||||
actionCommand: authError.actionCommand ?? pairingCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .pairingRequired:
|
||||
return self.pairingProblem(for: authError)
|
||||
case .controlUiDeviceIdentityRequired, .deviceIdentityRequired:
|
||||
return self.problem(
|
||||
kind: .deviceIdentityRequired,
|
||||
owner: .iphone,
|
||||
title: authError.titleOverride ?? "Secure device identity is required",
|
||||
message: authError.userMessageOverride
|
||||
?? "This connection must include a signed device identity before the gateway can bind permissions to this iPhone.",
|
||||
actionLabel: authError.actionLabel ?? "Retry from the app",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .deviceAuthSignatureExpired:
|
||||
return self.problem(
|
||||
kind: .deviceSignatureExpired,
|
||||
owner: .iphone,
|
||||
title: authError.titleOverride ?? "Secure handshake expired",
|
||||
message: authError.userMessageOverride ?? "The device signature is too old to use.",
|
||||
actionLabel: authError.actionLabel ?? "Check iPhone time",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
requestId: authError.requestId,
|
||||
retryable: true,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .deviceAuthNonceRequired:
|
||||
return self.problem(
|
||||
kind: .deviceNonceRequired,
|
||||
owner: .iphone,
|
||||
title: authError.titleOverride ?? "Secure handshake is incomplete",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway expected a one-time challenge response, but the nonce was missing.",
|
||||
actionLabel: authError.actionLabel ?? "Retry",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
requestId: authError.requestId,
|
||||
retryable: true,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .deviceAuthNonceMismatch:
|
||||
return self.problem(
|
||||
kind: .deviceNonceMismatch,
|
||||
owner: .iphone,
|
||||
title: authError.titleOverride ?? "Secure handshake did not match",
|
||||
message: authError.userMessageOverride ?? "The challenge response was stale or mismatched.",
|
||||
actionLabel: authError.actionLabel ?? "Retry",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
requestId: authError.requestId,
|
||||
retryable: true,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .deviceAuthSignatureInvalid, .deviceAuthInvalid:
|
||||
return self.problem(
|
||||
kind: .deviceSignatureInvalid,
|
||||
owner: .iphone,
|
||||
title: authError.titleOverride ?? "This device identity could not be verified",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway could not verify the identity this iPhone presented.",
|
||||
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .deviceAuthPublicKeyInvalid:
|
||||
return self.problem(
|
||||
kind: .devicePublicKeyInvalid,
|
||||
owner: .iphone,
|
||||
title: authError.titleOverride ?? "This device identity could not be verified",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway could not verify the public key this iPhone presented.",
|
||||
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .deviceAuthDeviceIdMismatch:
|
||||
return self.problem(
|
||||
kind: .deviceIdMismatch,
|
||||
owner: .iphone,
|
||||
title: authError.titleOverride ?? "This device identity could not be verified",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway rejected the device identity because the device ID did not match.",
|
||||
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authTailscaleIdentityMissing:
|
||||
return self.problem(
|
||||
kind: .tailscaleIdentityMissing,
|
||||
owner: .network,
|
||||
title: authError.titleOverride ?? "Tailscale identity check failed",
|
||||
message: authError.userMessageOverride
|
||||
?? "This connection expected Tailscale identity headers, but they were not available.",
|
||||
actionLabel: authError.actionLabel ?? "Turn on Tailscale",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authTailscaleProxyMissing:
|
||||
return self.problem(
|
||||
kind: .tailscaleProxyMissing,
|
||||
owner: .network,
|
||||
title: authError.titleOverride ?? "Tailscale identity check failed",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway expected a Tailscale auth proxy, but it was not configured.",
|
||||
actionLabel: authError.actionLabel ?? "Review Tailscale setup",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authTailscaleWhoisFailed:
|
||||
return self.problem(
|
||||
kind: .tailscaleWhoisFailed,
|
||||
owner: .network,
|
||||
title: authError.titleOverride ?? "Tailscale identity check failed",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway could not verify this Tailscale client identity.",
|
||||
actionLabel: authError.actionLabel ?? "Review Tailscale setup",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authTailscaleIdentityMismatch:
|
||||
return self.problem(
|
||||
kind: .tailscaleIdentityMismatch,
|
||||
owner: .network,
|
||||
title: authError.titleOverride ?? "Tailscale identity check failed",
|
||||
message: authError.userMessageOverride
|
||||
?? "The forwarded Tailscale identity did not match the verified identity.",
|
||||
actionLabel: authError.actionLabel ?? "Review Tailscale setup",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authRateLimited:
|
||||
return self.problem(
|
||||
kind: .authRateLimited,
|
||||
owner: .gateway,
|
||||
title: authError.titleOverride ?? "Too many failed attempts",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway is temporarily refusing new auth attempts after repeated failures.",
|
||||
actionLabel: authError.actionLabel ?? "Wait and retry",
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
requestId: authError.requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case .authRequired, .authUnauthorized, .none:
|
||||
return self.problem(
|
||||
kind: .unknown,
|
||||
owner: authError.ownerRaw.flatMap { self.owner(from: $0) } ?? .unknown,
|
||||
title: authError.titleOverride ?? "Gateway rejected the connection",
|
||||
message: authError.userMessageOverride ?? authError.message,
|
||||
actionLabel: authError.actionLabel,
|
||||
actionCommand: authError.actionCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: nil),
|
||||
requestId: authError.requestId,
|
||||
retryable: authError.retryableOverride ?? false,
|
||||
pauseReconnect: authError.pauseReconnectOverride ?? authError.isNonRecoverable,
|
||||
authError: authError)
|
||||
}
|
||||
}
|
||||
|
||||
private static func map(_ responseError: GatewayResponseError) -> GatewayConnectionProblem? {
|
||||
let code = responseError.code.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
if code == "NOT_PAIRED" || responseError.detailsReason == "not-paired" {
|
||||
let authError = GatewayConnectAuthError(
|
||||
message: responseError.message,
|
||||
detailCodeRaw: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
|
||||
canRetryWithDeviceToken: false,
|
||||
recommendedNextStepRaw: nil,
|
||||
requestId: self.stringValue(responseError.details["requestId"]?.value),
|
||||
detailsReason: responseError.detailsReason,
|
||||
ownerRaw: nil,
|
||||
titleOverride: nil,
|
||||
userMessageOverride: nil,
|
||||
actionLabel: nil,
|
||||
actionCommand: nil,
|
||||
docsURLString: nil,
|
||||
retryableOverride: nil,
|
||||
pauseReconnectOverride: nil)
|
||||
return self.map(authError)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func mapTransportError(_ error: Error) -> GatewayConnectionProblem? {
|
||||
let nsError = error as NSError
|
||||
let rawMessage = nsError.userInfo[NSLocalizedDescriptionKey] as? String ?? nsError.localizedDescription
|
||||
let lower = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if lower.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
let urlErrorCode = URLError.Code(rawValue: nsError.code)
|
||||
if nsError.domain == URLError.errorDomain {
|
||||
switch urlErrorCode {
|
||||
case .timedOut:
|
||||
return GatewayConnectionProblem(
|
||||
kind: .timeout,
|
||||
owner: .network,
|
||||
title: "Connection timed out",
|
||||
message: "The gateway did not respond before the connection timed out.",
|
||||
actionLabel: "Retry",
|
||||
actionCommand: nil,
|
||||
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
retryable: true,
|
||||
pauseReconnect: false,
|
||||
technicalDetails: rawMessage)
|
||||
case .cannotConnectToHost:
|
||||
return GatewayConnectionProblem(
|
||||
kind: .connectionRefused,
|
||||
owner: .network,
|
||||
title: "Gateway refused the connection",
|
||||
message: "The gateway host was reachable, but it refused the connection.",
|
||||
actionLabel: "Retry",
|
||||
actionCommand: nil,
|
||||
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
retryable: true,
|
||||
pauseReconnect: false,
|
||||
technicalDetails: rawMessage)
|
||||
case .cannotFindHost, .dnsLookupFailed, .notConnectedToInternet, .networkConnectionLost, .internationalRoamingOff, .callIsActive, .dataNotAllowed:
|
||||
return GatewayConnectionProblem(
|
||||
kind: .reachabilityFailed,
|
||||
owner: .network,
|
||||
title: "Gateway is not reachable",
|
||||
message: "OpenClaw could not reach the gateway over the current network.",
|
||||
actionLabel: "Check network",
|
||||
actionCommand: nil,
|
||||
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
retryable: true,
|
||||
pauseReconnect: false,
|
||||
technicalDetails: rawMessage)
|
||||
case .cancelled:
|
||||
return GatewayConnectionProblem(
|
||||
kind: .websocketCancelled,
|
||||
owner: .network,
|
||||
title: "Connection interrupted",
|
||||
message: "The connection to the gateway was interrupted before setup completed.",
|
||||
actionLabel: "Retry",
|
||||
actionCommand: nil,
|
||||
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
retryable: true,
|
||||
pauseReconnect: false,
|
||||
technicalDetails: rawMessage)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if lower.contains("timed out") {
|
||||
return GatewayConnectionProblem(
|
||||
kind: .timeout,
|
||||
owner: .network,
|
||||
title: "Connection timed out",
|
||||
message: "The gateway did not respond before the connection timed out.",
|
||||
actionLabel: "Retry",
|
||||
actionCommand: nil,
|
||||
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
retryable: true,
|
||||
pauseReconnect: false,
|
||||
technicalDetails: rawMessage)
|
||||
}
|
||||
if lower.contains("connection refused") || lower.contains("refused") {
|
||||
return GatewayConnectionProblem(
|
||||
kind: .connectionRefused,
|
||||
owner: .network,
|
||||
title: "Gateway refused the connection",
|
||||
message: "The gateway host was reachable, but it refused the connection.",
|
||||
actionLabel: "Retry",
|
||||
actionCommand: nil,
|
||||
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
retryable: true,
|
||||
pauseReconnect: false,
|
||||
technicalDetails: rawMessage)
|
||||
}
|
||||
if lower.contains("cannot find host") || lower.contains("could not connect") || lower.contains("network is unreachable") {
|
||||
return GatewayConnectionProblem(
|
||||
kind: .reachabilityFailed,
|
||||
owner: .network,
|
||||
title: "Gateway is not reachable",
|
||||
message: "OpenClaw could not reach the gateway over the current network.",
|
||||
actionLabel: "Check network",
|
||||
actionCommand: nil,
|
||||
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
retryable: true,
|
||||
pauseReconnect: false,
|
||||
technicalDetails: rawMessage)
|
||||
}
|
||||
if lower.contains("cancelled") || lower.contains("canceled") {
|
||||
return GatewayConnectionProblem(
|
||||
kind: .websocketCancelled,
|
||||
owner: .network,
|
||||
title: "Connection interrupted",
|
||||
message: "The connection to the gateway was interrupted before setup completed.",
|
||||
actionLabel: "Retry",
|
||||
actionCommand: nil,
|
||||
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
|
||||
retryable: true,
|
||||
pauseReconnect: false,
|
||||
technicalDetails: rawMessage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func pairingProblem(for authError: GatewayConnectAuthError) -> GatewayConnectionProblem {
|
||||
let requestId = authError.requestId
|
||||
let pairingCommand = self.approvalCommand(requestId: requestId)
|
||||
|
||||
switch authError.detailsReason {
|
||||
case "role-upgrade":
|
||||
return self.problem(
|
||||
kind: .pairingRoleUpgradeRequired,
|
||||
owner: .gateway,
|
||||
title: authError.titleOverride ?? "Additional approval required",
|
||||
message: authError.userMessageOverride
|
||||
?? "This iPhone is already paired, but it is requesting a new role that was not previously approved.",
|
||||
actionLabel: authError.actionLabel ?? "Approve on gateway",
|
||||
actionCommand: authError.actionCommand ?? pairingCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
||||
requestId: requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case "scope-upgrade":
|
||||
return self.problem(
|
||||
kind: .pairingScopeUpgradeRequired,
|
||||
owner: .gateway,
|
||||
title: authError.titleOverride ?? "Additional permissions required",
|
||||
message: authError.userMessageOverride
|
||||
?? "This iPhone is already paired, but it is requesting new permissions that require approval.",
|
||||
actionLabel: authError.actionLabel ?? "Approve on gateway",
|
||||
actionCommand: authError.actionCommand ?? pairingCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
||||
requestId: requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
case "metadata-upgrade":
|
||||
return self.problem(
|
||||
kind: .pairingMetadataUpgradeRequired,
|
||||
owner: .gateway,
|
||||
title: authError.titleOverride ?? "Device approval needs refresh",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway detected a change in this device's approved identity metadata and requires re-approval.",
|
||||
actionLabel: authError.actionLabel ?? "Approve on gateway",
|
||||
actionCommand: authError.actionCommand ?? pairingCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
||||
requestId: requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
default:
|
||||
return self.problem(
|
||||
kind: .pairingRequired,
|
||||
owner: .gateway,
|
||||
title: authError.titleOverride ?? "This iPhone is not approved yet",
|
||||
message: authError.userMessageOverride
|
||||
?? "The gateway received the connection request, but this device must be approved first.",
|
||||
actionLabel: authError.actionLabel ?? "Approve on gateway",
|
||||
actionCommand: authError.actionCommand ?? pairingCommand,
|
||||
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
|
||||
requestId: requestId,
|
||||
retryable: false,
|
||||
pauseReconnect: true,
|
||||
authError: authError)
|
||||
}
|
||||
}
|
||||
|
||||
private static func problem(
|
||||
kind: GatewayConnectionProblem.Kind,
|
||||
owner: GatewayConnectionProblem.Owner,
|
||||
title: String,
|
||||
message: String,
|
||||
actionLabel: String?,
|
||||
actionCommand: String?,
|
||||
docsURL: URL?,
|
||||
requestId: String?,
|
||||
retryable: Bool,
|
||||
pauseReconnect: Bool,
|
||||
authError: GatewayConnectAuthError)
|
||||
-> GatewayConnectionProblem
|
||||
{
|
||||
GatewayConnectionProblem(
|
||||
kind: kind,
|
||||
owner: authError.ownerRaw.flatMap(self.owner(from:)) ?? owner,
|
||||
title: title,
|
||||
message: message,
|
||||
actionLabel: actionLabel,
|
||||
actionCommand: actionCommand,
|
||||
docsURL: docsURL,
|
||||
requestId: requestId,
|
||||
retryable: authError.retryableOverride ?? retryable,
|
||||
pauseReconnect: authError.pauseReconnectOverride ?? pauseReconnect,
|
||||
technicalDetails: self.technicalDetails(for: authError))
|
||||
}
|
||||
|
||||
private static func approvalCommand(requestId: String?) -> String {
|
||||
if let requestId = self.nonEmpty(requestId) {
|
||||
return "openclaw devices approve \(requestId)"
|
||||
}
|
||||
return "openclaw devices list"
|
||||
}
|
||||
|
||||
private static func technicalDetails(for authError: GatewayConnectAuthError) -> String? {
|
||||
var parts: [String] = []
|
||||
if let detail = self.nonEmpty(authError.detailCodeRaw) {
|
||||
parts.append(detail)
|
||||
}
|
||||
if let reason = self.nonEmpty(authError.detailsReason) {
|
||||
parts.append("reason=\(reason)")
|
||||
}
|
||||
if let requestId = self.nonEmpty(authError.requestId) {
|
||||
parts.append("requestId=\(requestId)")
|
||||
}
|
||||
if let nextStep = self.nonEmpty(authError.recommendedNextStepRaw) {
|
||||
parts.append("next=\(nextStep)")
|
||||
}
|
||||
if authError.canRetryWithDeviceToken {
|
||||
parts.append("deviceTokenRetry=true")
|
||||
}
|
||||
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private static func docsURL(_ preferred: String?, fallback: String?) -> URL? {
|
||||
if let preferred = self.nonEmpty(preferred), let url = URL(string: preferred) {
|
||||
return url
|
||||
}
|
||||
if let fallback = self.nonEmpty(fallback), let url = URL(string: fallback) {
|
||||
return url
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func owner(from raw: String) -> GatewayConnectionProblem.Owner? {
|
||||
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "gateway":
|
||||
return .gateway
|
||||
case "iphone", "ios", "device":
|
||||
return .iphone
|
||||
case "both":
|
||||
return .both
|
||||
case "network":
|
||||
return .network
|
||||
case "unknown", "":
|
||||
return .unknown
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func stringValue(_ value: Any?) -> String? {
|
||||
self.nonEmpty(value as? String)
|
||||
}
|
||||
|
||||
private static func nonEmpty(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@@ -43,12 +43,32 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
|
||||
public let detailCodeRaw: String?
|
||||
public let recommendedNextStepRaw: String?
|
||||
public let canRetryWithDeviceToken: Bool
|
||||
public let requestId: String?
|
||||
public let detailsReason: String?
|
||||
public let ownerRaw: String?
|
||||
public let titleOverride: String?
|
||||
public let userMessageOverride: String?
|
||||
public let actionLabel: String?
|
||||
public let actionCommand: String?
|
||||
public let docsURLString: String?
|
||||
public let retryableOverride: Bool?
|
||||
public let pauseReconnectOverride: Bool?
|
||||
|
||||
public init(
|
||||
message: String,
|
||||
detailCodeRaw: String?,
|
||||
canRetryWithDeviceToken: Bool,
|
||||
recommendedNextStepRaw: String? = nil)
|
||||
recommendedNextStepRaw: String? = nil,
|
||||
requestId: String? = nil,
|
||||
detailsReason: String? = nil,
|
||||
ownerRaw: String? = nil,
|
||||
titleOverride: String? = nil,
|
||||
userMessageOverride: String? = nil,
|
||||
actionLabel: String? = nil,
|
||||
actionCommand: String? = nil,
|
||||
docsURLString: String? = nil,
|
||||
retryableOverride: Bool? = nil,
|
||||
pauseReconnectOverride: Bool? = nil)
|
||||
{
|
||||
let trimmedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedDetailCode = detailCodeRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -59,19 +79,54 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
|
||||
self.canRetryWithDeviceToken = canRetryWithDeviceToken
|
||||
self.recommendedNextStepRaw =
|
||||
trimmedRecommendedNextStep?.isEmpty == false ? trimmedRecommendedNextStep : nil
|
||||
self.requestId = Self.trimmedOrNil(requestId)
|
||||
self.detailsReason = Self.trimmedOrNil(detailsReason)
|
||||
self.ownerRaw = Self.trimmedOrNil(ownerRaw)
|
||||
self.titleOverride = Self.trimmedOrNil(titleOverride)
|
||||
self.userMessageOverride = Self.trimmedOrNil(userMessageOverride)
|
||||
self.actionLabel = Self.trimmedOrNil(actionLabel)
|
||||
self.actionCommand = Self.trimmedOrNil(actionCommand)
|
||||
self.docsURLString = Self.trimmedOrNil(docsURLString)
|
||||
self.retryableOverride = retryableOverride
|
||||
self.pauseReconnectOverride = pauseReconnectOverride
|
||||
}
|
||||
|
||||
public init(
|
||||
message: String,
|
||||
detailCode: String?,
|
||||
canRetryWithDeviceToken: Bool,
|
||||
recommendedNextStep: String? = nil)
|
||||
recommendedNextStep: String? = nil,
|
||||
requestId: String? = nil,
|
||||
detailsReason: String? = nil,
|
||||
ownerRaw: String? = nil,
|
||||
titleOverride: String? = nil,
|
||||
userMessageOverride: String? = nil,
|
||||
actionLabel: String? = nil,
|
||||
actionCommand: String? = nil,
|
||||
docsURLString: String? = nil,
|
||||
retryableOverride: Bool? = nil,
|
||||
pauseReconnectOverride: Bool? = nil)
|
||||
{
|
||||
self.init(
|
||||
message: message,
|
||||
detailCodeRaw: detailCode,
|
||||
canRetryWithDeviceToken: canRetryWithDeviceToken,
|
||||
recommendedNextStepRaw: recommendedNextStep)
|
||||
recommendedNextStepRaw: recommendedNextStep,
|
||||
requestId: requestId,
|
||||
detailsReason: detailsReason,
|
||||
ownerRaw: ownerRaw,
|
||||
titleOverride: titleOverride,
|
||||
userMessageOverride: userMessageOverride,
|
||||
actionLabel: actionLabel,
|
||||
actionCommand: actionCommand,
|
||||
docsURLString: docsURLString,
|
||||
retryableOverride: retryableOverride,
|
||||
pauseReconnectOverride: pauseReconnectOverride)
|
||||
}
|
||||
|
||||
private static func trimmedOrNil(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
public var detailCode: String? { self.detailCodeRaw }
|
||||
|
||||
@@ -361,6 +361,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"update_plan": {
|
||||
"emoji": "🗺️",
|
||||
"title": "Update Plan",
|
||||
"detailKeys": [
|
||||
"explanation",
|
||||
"plan.0.step"
|
||||
]
|
||||
},
|
||||
"gateway": {
|
||||
"emoji": "🔌",
|
||||
"title": "Gateway",
|
||||
|
||||
@@ -537,6 +537,8 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let besteffortdeliver: Bool?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
public let bootstrapcontextmode: AnyCodable?
|
||||
public let bootstrapcontextrunkind: AnyCodable?
|
||||
public let internalevents: [[String: AnyCodable]]?
|
||||
public let inputprovenance: [String: AnyCodable]?
|
||||
public let idempotencykey: String
|
||||
@@ -566,6 +568,8 @@ public struct AgentParams: Codable, Sendable {
|
||||
besteffortdeliver: Bool?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
bootstrapcontextmode: AnyCodable?,
|
||||
bootstrapcontextrunkind: AnyCodable?,
|
||||
internalevents: [[String: AnyCodable]]?,
|
||||
inputprovenance: [String: AnyCodable]?,
|
||||
idempotencykey: String,
|
||||
@@ -594,6 +598,8 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.besteffortdeliver = besteffortdeliver
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
self.bootstrapcontextmode = bootstrapcontextmode
|
||||
self.bootstrapcontextrunkind = bootstrapcontextrunkind
|
||||
self.internalevents = internalevents
|
||||
self.inputprovenance = inputprovenance
|
||||
self.idempotencykey = idempotencykey
|
||||
@@ -624,6 +630,8 @@ public struct AgentParams: Codable, Sendable {
|
||||
case besteffortdeliver = "bestEffortDeliver"
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
case bootstrapcontextmode = "bootstrapContextMode"
|
||||
case bootstrapcontextrunkind = "bootstrapContextRunKind"
|
||||
case internalevents = "internalEvents"
|
||||
case inputprovenance = "inputProvenance"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
@@ -1327,6 +1335,236 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionCompactionCheckpoint: Codable, Sendable {
|
||||
public let checkpointid: String
|
||||
public let sessionkey: String
|
||||
public let sessionid: String
|
||||
public let createdat: Int
|
||||
public let reason: AnyCodable
|
||||
public let tokensbefore: Int?
|
||||
public let tokensafter: Int?
|
||||
public let summary: String?
|
||||
public let firstkeptentryid: String?
|
||||
public let precompaction: [String: AnyCodable]
|
||||
public let postcompaction: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
checkpointid: String,
|
||||
sessionkey: String,
|
||||
sessionid: String,
|
||||
createdat: Int,
|
||||
reason: AnyCodable,
|
||||
tokensbefore: Int?,
|
||||
tokensafter: Int?,
|
||||
summary: String?,
|
||||
firstkeptentryid: String?,
|
||||
precompaction: [String: AnyCodable],
|
||||
postcompaction: [String: AnyCodable])
|
||||
{
|
||||
self.checkpointid = checkpointid
|
||||
self.sessionkey = sessionkey
|
||||
self.sessionid = sessionid
|
||||
self.createdat = createdat
|
||||
self.reason = reason
|
||||
self.tokensbefore = tokensbefore
|
||||
self.tokensafter = tokensafter
|
||||
self.summary = summary
|
||||
self.firstkeptentryid = firstkeptentryid
|
||||
self.precompaction = precompaction
|
||||
self.postcompaction = postcompaction
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case checkpointid = "checkpointId"
|
||||
case sessionkey = "sessionKey"
|
||||
case sessionid = "sessionId"
|
||||
case createdat = "createdAt"
|
||||
case reason
|
||||
case tokensbefore = "tokensBefore"
|
||||
case tokensafter = "tokensAfter"
|
||||
case summary
|
||||
case firstkeptentryid = "firstKeptEntryId"
|
||||
case precompaction = "preCompaction"
|
||||
case postcompaction = "postCompaction"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionListParams: Codable, Sendable {
|
||||
public let key: String
|
||||
|
||||
public init(
|
||||
key: String)
|
||||
{
|
||||
self.key = key
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionGetParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let checkpointid: String
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
checkpointid: String)
|
||||
{
|
||||
self.key = key
|
||||
self.checkpointid = checkpointid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case checkpointid = "checkpointId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionBranchParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let checkpointid: String
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
checkpointid: String)
|
||||
{
|
||||
self.key = key
|
||||
self.checkpointid = checkpointid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case checkpointid = "checkpointId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionRestoreParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let checkpointid: String
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
checkpointid: String)
|
||||
{
|
||||
self.key = key
|
||||
self.checkpointid = checkpointid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case checkpointid = "checkpointId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionListResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let key: String
|
||||
public let checkpoints: [SessionCompactionCheckpoint]
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
key: String,
|
||||
checkpoints: [SessionCompactionCheckpoint])
|
||||
{
|
||||
self.ok = ok
|
||||
self.key = key
|
||||
self.checkpoints = checkpoints
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case key
|
||||
case checkpoints
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionGetResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let key: String
|
||||
public let checkpoint: SessionCompactionCheckpoint
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
key: String,
|
||||
checkpoint: SessionCompactionCheckpoint)
|
||||
{
|
||||
self.ok = ok
|
||||
self.key = key
|
||||
self.checkpoint = checkpoint
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case key
|
||||
case checkpoint
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionBranchResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let sourcekey: String
|
||||
public let key: String
|
||||
public let sessionid: String
|
||||
public let checkpoint: SessionCompactionCheckpoint
|
||||
public let entry: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
sourcekey: String,
|
||||
key: String,
|
||||
sessionid: String,
|
||||
checkpoint: SessionCompactionCheckpoint,
|
||||
entry: [String: AnyCodable])
|
||||
{
|
||||
self.ok = ok
|
||||
self.sourcekey = sourcekey
|
||||
self.key = key
|
||||
self.sessionid = sessionid
|
||||
self.checkpoint = checkpoint
|
||||
self.entry = entry
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case sourcekey = "sourceKey"
|
||||
case key
|
||||
case sessionid = "sessionId"
|
||||
case checkpoint
|
||||
case entry
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactionRestoreResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let key: String
|
||||
public let sessionid: String
|
||||
public let checkpoint: SessionCompactionCheckpoint
|
||||
public let entry: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
key: String,
|
||||
sessionid: String,
|
||||
checkpoint: SessionCompactionCheckpoint,
|
||||
entry: [String: AnyCodable])
|
||||
{
|
||||
self.ok = ok
|
||||
self.key = key
|
||||
self.sessionid = sessionid
|
||||
self.checkpoint = checkpoint
|
||||
self.entry = entry
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case key
|
||||
case sessionid = "sessionId"
|
||||
case checkpoint
|
||||
case entry
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCreateParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let agentid: String?
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
|
||||
@@ -11,4 +12,81 @@ import Testing
|
||||
#expect(error.isNonRecoverable)
|
||||
#expect(error.detail == .authBootstrapTokenInvalid)
|
||||
}
|
||||
|
||||
@Test func connectAuthErrorPreservesStructuredMetadata() {
|
||||
let error = GatewayConnectAuthError(
|
||||
message: "pairing required",
|
||||
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
|
||||
canRetryWithDeviceToken: false,
|
||||
recommendedNextStep: "review_auth_configuration",
|
||||
requestId: "req-123",
|
||||
detailsReason: "scope-upgrade",
|
||||
ownerRaw: "gateway",
|
||||
titleOverride: "Additional permissions required",
|
||||
userMessageOverride: "Approve the requested permissions on the gateway, then reconnect.",
|
||||
actionLabel: "Approve on gateway",
|
||||
actionCommand: "openclaw devices approve req-123",
|
||||
docsURLString: "https://docs.openclaw.ai/gateway/pairing",
|
||||
retryableOverride: false,
|
||||
pauseReconnectOverride: true)
|
||||
|
||||
#expect(error.requestId == "req-123")
|
||||
#expect(error.detailsReason == "scope-upgrade")
|
||||
#expect(error.ownerRaw == "gateway")
|
||||
#expect(error.titleOverride == "Additional permissions required")
|
||||
#expect(error.actionCommand == "openclaw devices approve req-123")
|
||||
#expect(error.docsURLString == "https://docs.openclaw.ai/gateway/pairing")
|
||||
#expect(error.pauseReconnectOverride == true)
|
||||
}
|
||||
|
||||
@Test func pairingProblemUsesStructuredRequestMetadata() {
|
||||
let error = GatewayConnectAuthError(
|
||||
message: "pairing required",
|
||||
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
|
||||
canRetryWithDeviceToken: false,
|
||||
requestId: "req-123",
|
||||
detailsReason: "scope-upgrade")
|
||||
|
||||
let problem = GatewayConnectionProblemMapper.map(error: error)
|
||||
|
||||
#expect(problem?.kind == .pairingScopeUpgradeRequired)
|
||||
#expect(problem?.requestId == "req-123")
|
||||
#expect(problem?.pauseReconnect == true)
|
||||
#expect(problem?.actionCommand == "openclaw devices approve req-123")
|
||||
}
|
||||
|
||||
@Test func cancelledTransportDoesNotReplaceStructuredPairingProblem() {
|
||||
let pairing = GatewayConnectAuthError(
|
||||
message: "pairing required",
|
||||
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
|
||||
canRetryWithDeviceToken: false,
|
||||
requestId: "req-123")
|
||||
let previousProblem = GatewayConnectionProblemMapper.map(error: pairing)
|
||||
let cancelled = NSError(
|
||||
domain: URLError.errorDomain,
|
||||
code: URLError.cancelled.rawValue,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway receive: cancelled"])
|
||||
|
||||
let preserved = GatewayConnectionProblemMapper.map(error: cancelled, preserving: previousProblem)
|
||||
|
||||
#expect(preserved?.kind == .pairingRequired)
|
||||
#expect(preserved?.requestId == "req-123")
|
||||
}
|
||||
|
||||
@Test func unmappedTransportErrorClearsStaleStructuredProblem() {
|
||||
let pairing = GatewayConnectAuthError(
|
||||
message: "pairing required",
|
||||
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
|
||||
canRetryWithDeviceToken: false,
|
||||
requestId: "req-123")
|
||||
let previousProblem = GatewayConnectionProblemMapper.map(error: pairing)
|
||||
let unknownTransport = NSError(
|
||||
domain: NSURLErrorDomain,
|
||||
code: -1202,
|
||||
userInfo: [NSLocalizedDescriptionKey: "certificate chain validation failed"])
|
||||
|
||||
let mapped = GatewayConnectionProblemMapper.map(error: unknownTransport, preserving: previousProblem)
|
||||
|
||||
#expect(mapped == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
1c74540dd152c55dbda3e5dee1e37008ee3e6aabb0608e571292832c7a1c012c config-baseline.json
|
||||
7e30316f2326b7d07b71d7b8a96049a74b81428921299b5c4b5aa3d080e03305 config-baseline.core.json
|
||||
66edc86a9d16db1b9e9e7dd99b7032e2d9bcfb9ff210256a21f4b4f088cb3dc1 config-baseline.channel.json
|
||||
d6ebc4948499b997c4a3727cf31849d4a598de9f1a4c197417dcc0b0ec1b734f config-baseline.plugin.json
|
||||
f7b342080a730da84d1ac84a888e9506d24ee7ce7ec6ec6c0cc4f1897fabcde3 config-baseline.json
|
||||
c3dd9fb8a0059dba411c4d88a6b84ca28af1e0b1925c669058ef9f38c6d2718b config-baseline.core.json
|
||||
d22f4414b79ee03d896e58d875c80523bcc12303cbacb1700261e6ec73945187 config-baseline.channel.json
|
||||
d42cee3dea4668bdb7daf6ff5e6f87f326fdef56a8c3716d73079b92cab6e7b2 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
08615a28ed3deb20a96c9cd8fd7237a4cbb209ceec93dca03b543979304459e4 plugin-sdk-api-baseline.json
|
||||
683c1249dc15529d8e79bc75e9c00484551cb74126befee507fffcf786e01833 plugin-sdk-api-baseline.jsonl
|
||||
2efa99907731355b31a1b95a6baa9cf5bf8d25c67931837857c9bb9dd39fad95 plugin-sdk-api-baseline.json
|
||||
6c99467113b5d6a015cbd424f2eb5c7e21a6c665b3e8d0372e0e09a2218ef13e plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -227,7 +227,10 @@ Quick mental model (evaluation order for group messages):
|
||||
|
||||
Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`.
|
||||
|
||||
Replying to a bot message counts as an implicit mention (when the channel supports reply metadata). This applies to Telegram, WhatsApp, Slack, Discord, and Microsoft Teams.
|
||||
Replying to a bot message counts as an implicit mention when the channel
|
||||
supports reply metadata. Quoting a bot message can also count as an implicit
|
||||
mention on channels that expose quote metadata. Current built-in cases include
|
||||
Telegram, WhatsApp, Slack, Discord, Microsoft Teams, and ZaloUser.
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -61,13 +61,17 @@ What the Matrix wizard actually asks for:
|
||||
- optional device name
|
||||
- whether to enable E2EE
|
||||
- whether to configure Matrix room access now
|
||||
- whether to configure Matrix invite auto-join now
|
||||
- when invite auto-join is enabled, whether it should be `allowlist`, `always`, or `off`
|
||||
|
||||
Wizard behavior that matters:
|
||||
|
||||
- If Matrix auth env vars already exist for the selected account, and that account does not already have auth saved in config, the wizard offers an env shortcut and only writes `enabled: true` for that account.
|
||||
- If Matrix auth env vars already exist for the selected account, and that account does not already have auth saved in config, the wizard offers an env shortcut so setup can keep auth in env vars instead of copying secrets into config.
|
||||
- When you add another Matrix account interactively, the entered account name is normalized into the account ID used in config and env vars. For example, `Ops Bot` becomes `ops-bot`.
|
||||
- DM allowlist prompts accept full `@user:server` values immediately. Display names only work when live directory lookup finds one exact match; otherwise the wizard asks you to retry with a full Matrix ID.
|
||||
- Room allowlist prompts accept room IDs and aliases directly. They can also resolve joined-room names live, but unresolved names are only kept as typed during setup and are ignored later by runtime allowlist resolution. Prefer `!room:server` or `#alias:server`.
|
||||
- The wizard now shows an explicit warning before the invite auto-join step because `channels.matrix.autoJoin` defaults to `off`; agents will not join invited rooms or fresh DM-style invites unless you set it.
|
||||
- In invite auto-join allowlist mode, use only stable invite targets: `!roomId:server`, `#alias:server`, or `*`. Plain room names are rejected.
|
||||
- Runtime room/session identity uses the stable Matrix room ID. Room-declared aliases are only used as lookup inputs, not as the long-term session key or stable group identity.
|
||||
- To resolve room names before saving them, use `openclaw channels resolve --channel matrix "Project Room"`.
|
||||
|
||||
@@ -77,6 +81,8 @@ Wizard behavior that matters:
|
||||
If you leave it unset, the bot will not join invited rooms or fresh DM-style invites, so it will not appear in new groups or invited DMs unless you join manually first.
|
||||
|
||||
Set `autoJoin: "allowlist"` together with `autoJoinAllowlist` to restrict which invites it accepts, or set `autoJoin: "always"` if you want it to join every invite.
|
||||
|
||||
In `allowlist` mode, `autoJoinAllowlist` only accepts `!roomId:server`, `#alias:server`, or `*`.
|
||||
</Warning>
|
||||
|
||||
Allowlist example:
|
||||
@@ -874,7 +880,8 @@ See [Pairing](/channels/pairing) for the shared DM pairing flow and storage layo
|
||||
|
||||
## Exec approvals
|
||||
|
||||
Matrix can act as an exec approval client for a Matrix account.
|
||||
Matrix can act as a native approval client for a Matrix account. The native
|
||||
DM/channel routing knobs still live under exec approval config:
|
||||
|
||||
- `channels.matrix.execApprovals.enabled`
|
||||
- `channels.matrix.execApprovals.approvers` (optional; falls back to `channels.matrix.dm.allowFrom`)
|
||||
@@ -882,13 +889,14 @@ Matrix can act as an exec approval client for a Matrix account.
|
||||
- `channels.matrix.execApprovals.agentFilter`
|
||||
- `channels.matrix.execApprovals.sessionFilter`
|
||||
|
||||
Approvers must be Matrix user IDs such as `@owner:example.org`. Matrix auto-enables native exec approvals when `enabled` is unset or `"auto"` and at least one approver can be resolved, either from `execApprovals.approvers` or from `channels.matrix.dm.allowFrom`. Set `enabled: false` to disable Matrix as a native approval client explicitly. Approval requests otherwise fall back to other configured approval routes or the exec approval fallback policy.
|
||||
Approvers must be Matrix user IDs such as `@owner:example.org`. Matrix auto-enables native approvals when `enabled` is unset or `"auto"` and at least one approver can be resolved. Exec approvals use `execApprovals.approvers` first and can fall back to `channels.matrix.dm.allowFrom`. Plugin approvals authorize through `channels.matrix.dm.allowFrom`. Set `enabled: false` to disable Matrix as a native approval client explicitly. Approval requests otherwise fall back to other configured approval routes or the approval fallback policy.
|
||||
|
||||
Native Matrix routing is exec-only today:
|
||||
Matrix native routing now supports both approval kinds:
|
||||
|
||||
- `channels.matrix.execApprovals.*` controls native DM/channel routing for exec approvals only.
|
||||
- Plugin approvals still use shared same-chat `/approve` plus any configured `approvals.plugin` forwarding.
|
||||
- Matrix can still reuse `channels.matrix.dm.allowFrom` for plugin-approval authorization when it can infer approvers safely, but it does not expose a separate native plugin-approval DM/channel fanout path.
|
||||
- `channels.matrix.execApprovals.*` controls the native DM/channel fanout mode for Matrix approval prompts.
|
||||
- Exec approvals use the exec approver set from `execApprovals.approvers` or `channels.matrix.dm.allowFrom`.
|
||||
- Plugin approvals use the Matrix DM allowlist from `channels.matrix.dm.allowFrom`.
|
||||
- Matrix reaction shortcuts and message updates apply to both exec and plugin approvals.
|
||||
|
||||
Delivery rules:
|
||||
|
||||
@@ -904,9 +912,9 @@ Matrix approval prompts seed reaction shortcuts on the primary approval message:
|
||||
|
||||
Approvers can react on that message or use the fallback slash commands: `/approve <id> allow-once`, `/approve <id> allow-always`, or `/approve <id> deny`.
|
||||
|
||||
Only resolved approvers can approve or deny. Channel delivery includes the command text, so only enable `channel` or `both` in trusted rooms.
|
||||
Only resolved approvers can approve or deny. For exec approvals, channel delivery includes the command text, so only enable `channel` or `both` in trusted rooms.
|
||||
|
||||
Matrix approval prompts reuse the shared core approval planner. The Matrix-specific native surface is transport only for exec approvals: room/DM routing and message send/update/delete behavior.
|
||||
Matrix approval prompts reuse the shared core approval planner. The Matrix-specific native surface handles room/DM routing, reactions, and message send/update/delete behavior for both exec and plugin approvals.
|
||||
|
||||
Per-account override:
|
||||
|
||||
|
||||
@@ -399,7 +399,7 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
|
||||
|
||||
- explicit app mention (`<@botId>`)
|
||||
- mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
|
||||
- implicit reply-to-bot thread behavior
|
||||
- implicit reply-to-bot thread behavior (disabled when `thread.requireExplicitMention` is `true`)
|
||||
|
||||
Per-channel controls (`channels.slack.channels.<id>`; names only via startup resolution or `dangerouslyAllowNameMatching`):
|
||||
|
||||
@@ -423,6 +423,7 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
|
||||
- Thread replies can create thread session suffixes (`:thread:<threadTs>`) when applicable.
|
||||
- `channels.slack.thread.historyScope` default is `thread`; `thread.inheritParent` default is `false`.
|
||||
- `channels.slack.thread.initialHistoryLimit` controls how many existing thread messages are fetched when a new thread session starts (default `20`; set `0` to disable).
|
||||
- `channels.slack.thread.requireExplicitMention` (default `false`): when `true`, suppress implicit thread mentions so the bot only responds to explicit `@bot` mentions inside threads, even when the bot already participated in the thread. Without this, replies in a bot-participated thread bypass `requireMention` gating.
|
||||
|
||||
Reply threading controls:
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ Example:
|
||||
- `channels.zalouser.groups.<group>.requireMention` controls whether group replies require a mention.
|
||||
- Resolution order: exact group id/name -> normalized group slug -> `*` -> default (`true`).
|
||||
- This applies both to allowlisted groups and open group mode.
|
||||
- Quoting a bot message counts as an implicit mention for group activation.
|
||||
- Authorized control commands (for example `/new`) can bypass mention gating.
|
||||
- When a group message is skipped because mention is required, OpenClaw stores it as pending group history and includes it on the next processed group message.
|
||||
- Group history limit defaults to `messages.groupChat.historyLimit` (fallback `50`). You can override per account with `channels.zalouser.historyLimit`.
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
---
|
||||
summary: "Capability-first CLI for provider-backed model, media, web, and embedding workflows"
|
||||
read_when:
|
||||
- Adding or modifying `openclaw capability` commands
|
||||
- Designing stable headless capability automation
|
||||
title: "Capability CLI"
|
||||
---
|
||||
|
||||
# Capability CLI
|
||||
|
||||
`openclaw capability` is the canonical headless surface for provider-backed capabilities.
|
||||
|
||||
It intentionally exposes capability families, not raw gateway RPC names and not raw agent tool ids.
|
||||
|
||||
## Command tree
|
||||
|
||||
```text
|
||||
openclaw capability
|
||||
list
|
||||
inspect
|
||||
|
||||
model
|
||||
run
|
||||
list
|
||||
inspect
|
||||
providers
|
||||
auth login
|
||||
auth logout
|
||||
auth status
|
||||
|
||||
media
|
||||
image
|
||||
generate
|
||||
edit
|
||||
describe
|
||||
describe-many
|
||||
providers
|
||||
audio
|
||||
transcribe
|
||||
providers
|
||||
tts
|
||||
convert
|
||||
voices
|
||||
providers
|
||||
status
|
||||
enable
|
||||
disable
|
||||
set-provider
|
||||
video
|
||||
generate
|
||||
describe
|
||||
providers
|
||||
|
||||
web
|
||||
search
|
||||
fetch
|
||||
providers
|
||||
|
||||
memory
|
||||
embedding
|
||||
create
|
||||
providers
|
||||
```
|
||||
|
||||
## Transport
|
||||
|
||||
Supported transport flags:
|
||||
|
||||
- `--local`
|
||||
- `--gateway`
|
||||
|
||||
Default transport is implicit auto at the command-family level:
|
||||
|
||||
- Stateless execution commands default to local.
|
||||
- Gateway-managed state commands default to gateway.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
openclaw capability model run --prompt "hello" --json
|
||||
openclaw capability media image generate --prompt "friendly lobster" --json
|
||||
openclaw capability media tts status --json
|
||||
openclaw capability embedding create --text "hello world" --json
|
||||
```
|
||||
|
||||
## JSON output
|
||||
|
||||
Capability commands normalize JSON output under a shared envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"capability": "media.image.generate",
|
||||
"transport": "local",
|
||||
"provider": "openai",
|
||||
"model": "gpt-image-1",
|
||||
"attempts": [],
|
||||
"outputs": []
|
||||
}
|
||||
```
|
||||
|
||||
Top-level fields are stable:
|
||||
|
||||
- `ok`
|
||||
- `capability`
|
||||
- `transport`
|
||||
- `provider`
|
||||
- `model`
|
||||
- `attempts`
|
||||
- `outputs`
|
||||
- `error`
|
||||
|
||||
## Notes
|
||||
|
||||
- `model run` reuses the agent runtime so provider/model overrides behave like normal agent execution.
|
||||
- `media tts status` defaults to gateway because it reflects gateway-managed TTS state.
|
||||
@@ -35,7 +35,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`logs`](/cli/logs)
|
||||
- [`system`](/cli/system)
|
||||
- [`models`](/cli/models)
|
||||
- [`capability`](/cli/capability)
|
||||
- [`infer`](/cli/infer)
|
||||
- [`memory`](/cli/memory)
|
||||
- [`directory`](/cli/directory)
|
||||
- [`nodes`](/cli/nodes)
|
||||
@@ -249,14 +249,14 @@ openclaw [--dev] [--profile <name>] <command>
|
||||
fallbacks list|add|remove|clear
|
||||
image-fallbacks list|add|remove|clear
|
||||
scan
|
||||
capability
|
||||
infer (alias: capability)
|
||||
list
|
||||
inspect
|
||||
model run|list|inspect|providers|auth login|logout|status
|
||||
media image generate|edit|describe|describe-many|providers
|
||||
media audio transcribe|providers
|
||||
media tts convert|voices|providers|status|enable|disable|set-provider
|
||||
media video generate|describe|providers
|
||||
image generate|edit|describe|describe-many|providers
|
||||
audio transcribe|providers
|
||||
tts convert|voices|providers|status|enable|disable|set-provider
|
||||
video generate|describe|providers
|
||||
web search|fetch|providers
|
||||
embedding create|providers
|
||||
auth add|login|login-github-copilot|setup-token|paste-token
|
||||
|
||||
280
docs/cli/infer.md
Normal file
280
docs/cli/infer.md
Normal file
@@ -0,0 +1,280 @@
|
||||
---
|
||||
summary: "Infer-first CLI for provider-backed model, image, audio, TTS, video, web, and embedding workflows"
|
||||
read_when:
|
||||
- Adding or modifying `openclaw infer` commands
|
||||
- Designing stable headless capability automation
|
||||
title: "Inference CLI"
|
||||
---
|
||||
|
||||
# Inference CLI
|
||||
|
||||
`openclaw infer` is the canonical headless surface for provider-backed inference workflows.
|
||||
|
||||
It intentionally exposes capability families, not raw gateway RPC names and not raw agent tool ids.
|
||||
|
||||
## Turn infer into a skill
|
||||
|
||||
Copy and paste this to an agent:
|
||||
|
||||
```text
|
||||
Read https://docs.openclaw.ai/cli/infer, then create a skill that routes my common workflows to `openclaw infer`.
|
||||
Focus on model runs, image generation, video generation, audio transcription, TTS, web search, and embeddings.
|
||||
```
|
||||
|
||||
A good infer-based skill should:
|
||||
|
||||
- map common user intents to the correct infer subcommand
|
||||
- include a few canonical infer examples for the workflows it covers
|
||||
- prefer `openclaw infer ...` in examples and suggestions
|
||||
- avoid re-documenting the entire infer surface inside the skill body
|
||||
|
||||
Typical infer-focused skill coverage:
|
||||
|
||||
- `openclaw infer model run`
|
||||
- `openclaw infer image generate`
|
||||
- `openclaw infer audio transcribe`
|
||||
- `openclaw infer tts convert`
|
||||
- `openclaw infer web search`
|
||||
- `openclaw infer embedding create`
|
||||
|
||||
## Why use infer
|
||||
|
||||
`openclaw infer` provides one consistent CLI for provider-backed inference tasks inside OpenClaw.
|
||||
|
||||
Benefits:
|
||||
|
||||
- Use the providers and models already configured in OpenClaw instead of wiring up one-off wrappers for each backend.
|
||||
- Keep model, image, audio transcription, TTS, video, web, and embedding workflows under one command tree.
|
||||
- Use a stable `--json` output shape for scripts, automation, and agent-driven workflows.
|
||||
- Prefer a first-party OpenClaw surface when the task is fundamentally "run inference."
|
||||
- Use the normal local path without requiring the gateway for most infer commands.
|
||||
|
||||
## Command tree
|
||||
|
||||
```text
|
||||
openclaw infer
|
||||
list
|
||||
inspect
|
||||
|
||||
model
|
||||
run
|
||||
list
|
||||
inspect
|
||||
providers
|
||||
auth login
|
||||
auth logout
|
||||
auth status
|
||||
|
||||
image
|
||||
generate
|
||||
edit
|
||||
describe
|
||||
describe-many
|
||||
providers
|
||||
|
||||
audio
|
||||
transcribe
|
||||
providers
|
||||
|
||||
tts
|
||||
convert
|
||||
voices
|
||||
providers
|
||||
status
|
||||
enable
|
||||
disable
|
||||
set-provider
|
||||
|
||||
video
|
||||
generate
|
||||
describe
|
||||
providers
|
||||
|
||||
web
|
||||
search
|
||||
fetch
|
||||
providers
|
||||
|
||||
embedding
|
||||
create
|
||||
providers
|
||||
```
|
||||
|
||||
## Common tasks
|
||||
|
||||
This table maps common inference tasks to the corresponding infer command.
|
||||
|
||||
| Task | Command | Notes |
|
||||
| ----------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| Run a text/model prompt | `openclaw infer model run --prompt "..." --json` | Uses the normal local path by default |
|
||||
| Generate an image | `openclaw infer image generate --prompt "..." --json` | Use `image edit` when starting from an existing file |
|
||||
| Describe an image file | `openclaw infer image describe --file ./image.png --json` | `--model` must be `<provider/model>` |
|
||||
| Transcribe audio | `openclaw infer audio transcribe --file ./memo.m4a --json` | `--model` must be `<provider/model>` |
|
||||
| Synthesize speech | `openclaw infer tts convert --text "..." --output ./speech.mp3 --json` | `tts status` is gateway-oriented |
|
||||
| Generate a video | `openclaw infer video generate --prompt "..." --json` | |
|
||||
| Describe a video file | `openclaw infer video describe --file ./clip.mp4 --json` | `--model` must be `<provider/model>` |
|
||||
| Search the web | `openclaw infer web search --query "..." --json` | |
|
||||
| Fetch a web page | `openclaw infer web fetch --url https://example.com --json` | |
|
||||
| Create embeddings | `openclaw infer embedding create --text "..." --json` | |
|
||||
|
||||
## Behavior
|
||||
|
||||
- `openclaw infer ...` is the primary CLI surface for these workflows.
|
||||
- Use `--json` when the output will be consumed by another command or script.
|
||||
- Use `--provider` or `--model provider/model` when a specific backend is required.
|
||||
- For `image describe`, `audio transcribe`, and `video describe`, `--model` must use the form `<provider/model>`.
|
||||
- Stateless execution commands default to local.
|
||||
- Gateway-managed state commands default to gateway.
|
||||
- The normal local path does not require the gateway to be running.
|
||||
|
||||
## Model
|
||||
|
||||
Use `model` for provider-backed text inference and model/provider inspection.
|
||||
|
||||
```bash
|
||||
openclaw infer model run --prompt "Reply with exactly: smoke-ok" --json
|
||||
openclaw infer model run --prompt "Summarize this changelog entry" --provider openai --json
|
||||
openclaw infer model providers --json
|
||||
openclaw infer model inspect --name gpt-5.4 --json
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `model run` reuses the agent runtime so provider/model overrides behave like normal agent execution.
|
||||
- `model auth login`, `model auth logout`, and `model auth status` manage saved provider auth state.
|
||||
|
||||
## Image
|
||||
|
||||
Use `image` for generation, edit, and description.
|
||||
|
||||
```bash
|
||||
openclaw infer image generate --prompt "friendly lobster illustration" --json
|
||||
openclaw infer image generate --prompt "cinematic product photo of headphones" --json
|
||||
openclaw infer image describe --file ./photo.jpg --json
|
||||
openclaw infer image describe --file ./ui-screenshot.png --model openai/gpt-4.1-mini --json
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Use `image edit` when starting from existing input files.
|
||||
- For `image describe`, `--model` must be `<provider/model>`.
|
||||
|
||||
## Audio
|
||||
|
||||
Use `audio` for file transcription.
|
||||
|
||||
```bash
|
||||
openclaw infer audio transcribe --file ./memo.m4a --json
|
||||
openclaw infer audio transcribe --file ./team-sync.m4a --language en --prompt "Focus on names and action items" --json
|
||||
openclaw infer audio transcribe --file ./memo.m4a --model openai/whisper-1 --json
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `audio transcribe` is for file transcription, not realtime session management.
|
||||
- `--model` must be `<provider/model>`.
|
||||
|
||||
## TTS
|
||||
|
||||
Use `tts` for speech synthesis and TTS provider state.
|
||||
|
||||
```bash
|
||||
openclaw infer tts convert --text "hello from openclaw" --output ./hello.mp3 --json
|
||||
openclaw infer tts convert --text "Your build is complete" --output ./build-complete.mp3 --json
|
||||
openclaw infer tts providers --json
|
||||
openclaw infer tts status --json
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `tts status` defaults to gateway because it reflects gateway-managed TTS state.
|
||||
- Use `tts providers`, `tts voices`, and `tts set-provider` to inspect and configure TTS behavior.
|
||||
|
||||
## Video
|
||||
|
||||
Use `video` for generation and description.
|
||||
|
||||
```bash
|
||||
openclaw infer video generate --prompt "cinematic sunset over the ocean" --json
|
||||
openclaw infer video generate --prompt "slow drone shot over a forest lake" --json
|
||||
openclaw infer video describe --file ./clip.mp4 --json
|
||||
openclaw infer video describe --file ./clip.mp4 --model openai/gpt-4.1-mini --json
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `--model` must be `<provider/model>` for `video describe`.
|
||||
|
||||
## Web
|
||||
|
||||
Use `web` for search and fetch workflows.
|
||||
|
||||
```bash
|
||||
openclaw infer web search --query "OpenClaw docs" --json
|
||||
openclaw infer web search --query "OpenClaw infer web providers" --json
|
||||
openclaw infer web fetch --url https://docs.openclaw.ai/cli/infer --json
|
||||
openclaw infer web providers --json
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Use `web providers` to inspect available, configured, and selected providers.
|
||||
|
||||
## Embedding
|
||||
|
||||
Use `embedding` for vector creation and embedding provider inspection.
|
||||
|
||||
```bash
|
||||
openclaw infer embedding create --text "friendly lobster" --json
|
||||
openclaw infer embedding create --text "customer support ticket: delayed shipment" --model openai/text-embedding-3-large --json
|
||||
openclaw infer embedding providers --json
|
||||
```
|
||||
|
||||
## JSON output
|
||||
|
||||
Infer commands normalize JSON output under a shared envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"capability": "image.generate",
|
||||
"transport": "local",
|
||||
"provider": "openai",
|
||||
"model": "gpt-image-1",
|
||||
"attempts": [],
|
||||
"outputs": []
|
||||
}
|
||||
```
|
||||
|
||||
Top-level fields are stable:
|
||||
|
||||
- `ok`
|
||||
- `capability`
|
||||
- `transport`
|
||||
- `provider`
|
||||
- `model`
|
||||
- `attempts`
|
||||
- `outputs`
|
||||
- `error`
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
```bash
|
||||
# Bad
|
||||
openclaw infer media image generate --prompt "friendly lobster"
|
||||
|
||||
# Good
|
||||
openclaw infer image generate --prompt "friendly lobster"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Bad
|
||||
openclaw infer audio transcribe --file ./memo.m4a --model whisper-1 --json
|
||||
|
||||
# Good
|
||||
openclaw infer audio transcribe --file ./memo.m4a --model openai/whisper-1 --json
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `openclaw capability ...` is an alias for `openclaw infer ...`.
|
||||
504
docs/concepts/active-memory.md
Normal file
504
docs/concepts/active-memory.md
Normal file
@@ -0,0 +1,504 @@
|
||||
---
|
||||
title: "Active Memory"
|
||||
summary: "A plugin-owned blocking memory subagent that injects relevant memory into interactive chat sessions"
|
||||
read_when:
|
||||
- You want to understand what active memory is for
|
||||
- You want to turn active memory on for a conversational agent
|
||||
- You want to tune active memory behavior without enabling it everywhere
|
||||
---
|
||||
|
||||
# Active Memory
|
||||
|
||||
Active memory is an optional plugin-owned blocking memory subagent that runs
|
||||
before the main reply for eligible conversational sessions.
|
||||
|
||||
It exists because most memory systems are capable but reactive. They rely on
|
||||
the main agent to decide when to search memory, or on the user to say things
|
||||
like "remember this" or "search memory." By then, the moment where memory would
|
||||
have made the reply feel natural has already passed.
|
||||
|
||||
Active memory gives the system one bounded chance to surface relevant memory
|
||||
before the main reply is generated.
|
||||
|
||||
## Paste This Into Your Agent
|
||||
|
||||
Paste this into your agent if you want it to enable Active Memory with a
|
||||
self-contained, safe-default setup:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"active-memory": {
|
||||
enabled: true,
|
||||
config: {
|
||||
agents: ["main"],
|
||||
allowedChatTypes: ["direct"],
|
||||
modelFallbackPolicy: "default-remote",
|
||||
queryMode: "recent",
|
||||
timeoutMs: 15000,
|
||||
maxSummaryChars: 220,
|
||||
persistTranscripts: false,
|
||||
logging: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This turns the plugin on for the `main` agent, keeps it limited to direct-message
|
||||
style sessions by default, lets it inherit the current session model first, and
|
||||
still allows the built-in remote fallback if no explicit or inherited model is
|
||||
available.
|
||||
|
||||
After that, restart the gateway:
|
||||
|
||||
```bash
|
||||
node scripts/run-node.mjs gateway --profile dev
|
||||
```
|
||||
|
||||
To inspect it live in a conversation:
|
||||
|
||||
```text
|
||||
/verbose on
|
||||
```
|
||||
|
||||
## Turn active memory on
|
||||
|
||||
The safest setup is:
|
||||
|
||||
1. enable the plugin
|
||||
2. target one conversational agent
|
||||
3. keep logging on only while tuning
|
||||
|
||||
Start with this in `openclaw.json`:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"active-memory": {
|
||||
enabled: true,
|
||||
config: {
|
||||
agents: ["main"],
|
||||
allowedChatTypes: ["direct"],
|
||||
modelFallbackPolicy: "default-remote",
|
||||
queryMode: "recent",
|
||||
timeoutMs: 15000,
|
||||
maxSummaryChars: 220,
|
||||
persistTranscripts: false,
|
||||
logging: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Then restart the gateway:
|
||||
|
||||
```bash
|
||||
node scripts/run-node.mjs gateway --profile dev
|
||||
```
|
||||
|
||||
What this means:
|
||||
|
||||
- `plugins.entries.active-memory.enabled: true` turns the plugin on
|
||||
- `config.agents: ["main"]` opts only the `main` agent into active memory
|
||||
- `config.allowedChatTypes: ["direct"]` keeps active memory on for direct-message style sessions only by default
|
||||
- if `config.model` is unset, active memory inherits the current session model first
|
||||
- `config.modelFallbackPolicy: "default-remote"` keeps the built-in remote fallback as the default when no explicit or inherited model is available
|
||||
- active memory still runs only on eligible interactive persistent chat sessions
|
||||
|
||||
## How to see it
|
||||
|
||||
Active memory injects hidden system context for the model. It does not expose
|
||||
raw `<active_memory_plugin>...</active_memory_plugin>` tags to the client.
|
||||
|
||||
If you want to see what active memory is doing in a live session, turn verbose
|
||||
mode on for that session:
|
||||
|
||||
```text
|
||||
/verbose on
|
||||
```
|
||||
|
||||
With verbose enabled, OpenClaw can show:
|
||||
|
||||
- an active memory status line such as `Active Memory: ok 842ms recent 34 chars`
|
||||
- a readable debug summary such as `Active Memory Debug: Lemon pepper wings with blue cheese.`
|
||||
|
||||
Those lines are derived from the same active memory pass that feeds the hidden
|
||||
system context, but they are formatted for humans instead of exposing raw prompt
|
||||
markup.
|
||||
|
||||
By default, the blocking memory subagent transcript is temporary and deleted
|
||||
after the run completes.
|
||||
|
||||
Example flow:
|
||||
|
||||
```text
|
||||
/verbose on
|
||||
what wings should i order?
|
||||
```
|
||||
|
||||
Expected visible reply shape:
|
||||
|
||||
```text
|
||||
...normal assistant reply...
|
||||
|
||||
🧩 Active Memory: ok 842ms recent 34 chars
|
||||
🔎 Active Memory Debug: Lemon pepper wings with blue cheese.
|
||||
```
|
||||
|
||||
## When it runs
|
||||
|
||||
Active memory uses two gates:
|
||||
|
||||
1. **Config opt-in**
|
||||
The plugin must be enabled, and the current agent id must appear in
|
||||
`plugins.entries.active-memory.config.agents`.
|
||||
2. **Strict runtime eligibility**
|
||||
Even when enabled and targeted, active memory only runs for eligible
|
||||
interactive persistent chat sessions.
|
||||
|
||||
The actual rule is:
|
||||
|
||||
```text
|
||||
plugin enabled
|
||||
+
|
||||
agent id targeted
|
||||
+
|
||||
allowed chat type
|
||||
+
|
||||
eligible interactive persistent chat session
|
||||
=
|
||||
active memory runs
|
||||
```
|
||||
|
||||
If any of those fail, active memory does not run.
|
||||
|
||||
## Session types
|
||||
|
||||
`config.allowedChatTypes` controls which kinds of conversations may run Active
|
||||
Memory at all.
|
||||
|
||||
The default is:
|
||||
|
||||
```json5
|
||||
allowedChatTypes: ["direct"]
|
||||
```
|
||||
|
||||
That means Active Memory runs by default in direct-message style sessions, but
|
||||
not in group or channel sessions unless you opt them in explicitly.
|
||||
|
||||
Examples:
|
||||
|
||||
```json5
|
||||
allowedChatTypes: ["direct"]
|
||||
```
|
||||
|
||||
```json5
|
||||
allowedChatTypes: ["direct", "group"]
|
||||
```
|
||||
|
||||
```json5
|
||||
allowedChatTypes: ["direct", "group", "channel"]
|
||||
```
|
||||
|
||||
## Where it runs
|
||||
|
||||
Active memory is a conversational enrichment feature, not a platform-wide
|
||||
inference feature.
|
||||
|
||||
| Surface | Runs active memory? |
|
||||
| ------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Control UI / web chat persistent sessions | Yes, if the plugin is enabled and the agent is targeted |
|
||||
| Other interactive channel sessions on the same persistent chat path | Yes, if the plugin is enabled and the agent is targeted |
|
||||
| Headless one-shot runs | No |
|
||||
| Heartbeat/background runs | No |
|
||||
| Generic internal `agent-command` paths | No |
|
||||
| Subagent/internal helper execution | No |
|
||||
|
||||
## Why use it
|
||||
|
||||
Use active memory when:
|
||||
|
||||
- the session is persistent and user-facing
|
||||
- the agent has meaningful long-term memory to search
|
||||
- continuity and personalization matter more than raw prompt determinism
|
||||
|
||||
It works especially well for:
|
||||
|
||||
- stable preferences
|
||||
- recurring habits
|
||||
- long-term user context that should surface naturally
|
||||
|
||||
It is a poor fit for:
|
||||
|
||||
- automation
|
||||
- internal workers
|
||||
- one-shot API tasks
|
||||
- places where hidden personalization would be surprising
|
||||
|
||||
## How it works
|
||||
|
||||
The runtime shape is:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["User Message"] --> Q["Build Memory Query"]
|
||||
Q --> R["Active Memory Blocking Memory Subagent"]
|
||||
R -->|NONE or empty| M["Main Reply"]
|
||||
R -->|relevant summary| I["Append Hidden active_memory_plugin System Context"]
|
||||
I --> M["Main Reply"]
|
||||
```
|
||||
|
||||
The blocking memory subagent can use only:
|
||||
|
||||
- `memory_search`
|
||||
- `memory_get`
|
||||
|
||||
If the connection is weak, it should return `NONE`.
|
||||
|
||||
## Query modes
|
||||
|
||||
`config.queryMode` controls how much conversation the blocking memory subagent sees.
|
||||
|
||||
## Model fallback policy
|
||||
|
||||
If `config.model` is unset, Active Memory tries to resolve a model in this order:
|
||||
|
||||
```text
|
||||
explicit plugin model
|
||||
-> current session model
|
||||
-> agent primary model
|
||||
-> optional built-in remote fallback
|
||||
```
|
||||
|
||||
`config.modelFallbackPolicy` controls the last step.
|
||||
|
||||
Default:
|
||||
|
||||
```json5
|
||||
modelFallbackPolicy: "default-remote"
|
||||
```
|
||||
|
||||
Other option:
|
||||
|
||||
```json5
|
||||
modelFallbackPolicy: "resolved-only"
|
||||
```
|
||||
|
||||
Use `resolved-only` if you want Active Memory to skip recall instead of falling
|
||||
back to the built-in remote default when no explicit or inherited model is
|
||||
available.
|
||||
|
||||
### `message`
|
||||
|
||||
Only the latest user message is sent.
|
||||
|
||||
```text
|
||||
Latest user message only
|
||||
```
|
||||
|
||||
Use this when:
|
||||
|
||||
- you want the fastest behavior
|
||||
- you want the strongest bias toward stable preference recall
|
||||
- follow-up turns do not need conversational context
|
||||
|
||||
Recommended timeout:
|
||||
|
||||
- start around `3000` to `5000` ms
|
||||
|
||||
### `recent`
|
||||
|
||||
The latest user message plus a small recent conversational tail is sent.
|
||||
|
||||
```text
|
||||
Recent conversation tail:
|
||||
user: ...
|
||||
assistant: ...
|
||||
user: ...
|
||||
|
||||
Latest user message:
|
||||
...
|
||||
```
|
||||
|
||||
Use this when:
|
||||
|
||||
- you want a better balance of speed and conversational grounding
|
||||
- follow-up questions often depend on the last few turns
|
||||
|
||||
Recommended timeout:
|
||||
|
||||
- start around `15000` ms
|
||||
|
||||
### `full`
|
||||
|
||||
The full conversation is sent to the blocking memory subagent.
|
||||
|
||||
```text
|
||||
Full conversation context:
|
||||
user: ...
|
||||
assistant: ...
|
||||
user: ...
|
||||
...
|
||||
```
|
||||
|
||||
Use this when:
|
||||
|
||||
- the strongest recall quality matters more than latency
|
||||
- the conversation contains important setup far back in the thread
|
||||
|
||||
Recommended timeout:
|
||||
|
||||
- increase it substantially compared with `message` or `recent`
|
||||
- start around `15000` ms or higher depending on thread size
|
||||
|
||||
In general, timeout should increase with context size:
|
||||
|
||||
```text
|
||||
message < recent < full
|
||||
```
|
||||
|
||||
## Transcript persistence
|
||||
|
||||
Active memory blocking memory subagent runs create a real `session.jsonl`
|
||||
transcript during the blocking memory subagent call.
|
||||
|
||||
By default, that transcript is temporary:
|
||||
|
||||
- it is written to a temp directory
|
||||
- it is used only for the blocking memory subagent run
|
||||
- it is deleted immediately after the run finishes
|
||||
|
||||
If you want to keep those blocking memory subagent transcripts on disk for debugging or
|
||||
inspection, turn persistence on explicitly:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"active-memory": {
|
||||
enabled: true,
|
||||
config: {
|
||||
agents: ["main"],
|
||||
persistTranscripts: true,
|
||||
transcriptDir: "active-memory",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, active memory stores transcripts in a separate directory under the
|
||||
target agent's sessions folder, not in the main user conversation transcript
|
||||
path.
|
||||
|
||||
The default layout is conceptually:
|
||||
|
||||
```text
|
||||
agents/<agent>/sessions/active-memory/<blocking-memory-subagent-session-id>.jsonl
|
||||
```
|
||||
|
||||
You can change the relative subdirectory with `config.transcriptDir`.
|
||||
|
||||
Use this carefully:
|
||||
|
||||
- blocking memory subagent transcripts can accumulate quickly on busy sessions
|
||||
- `full` query mode can duplicate a lot of conversation context
|
||||
- these transcripts contain hidden prompt context and recalled memories
|
||||
|
||||
## Configuration
|
||||
|
||||
All active memory configuration lives under:
|
||||
|
||||
```text
|
||||
plugins.entries.active-memory
|
||||
```
|
||||
|
||||
The most important fields are:
|
||||
|
||||
| Key | Type | Meaning |
|
||||
| --------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `enabled` | `boolean` | Enables the plugin itself |
|
||||
| `config.agents` | `string[]` | Agent ids that may use active memory |
|
||||
| `config.model` | `string` | Optional blocking memory subagent model ref; when unset, active memory uses the current session model |
|
||||
| `config.queryMode` | `"message" \| "recent" \| "full"` | Controls how much conversation the blocking memory subagent sees |
|
||||
| `config.timeoutMs` | `number` | Hard timeout for the blocking memory subagent |
|
||||
| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary |
|
||||
| `config.logging` | `boolean` | Emits active memory logs while tuning |
|
||||
| `config.persistTranscripts` | `boolean` | Keeps blocking memory subagent transcripts on disk instead of deleting temp files |
|
||||
| `config.transcriptDir` | `string` | Relative blocking memory subagent transcript directory under the agent sessions folder |
|
||||
|
||||
Useful tuning fields:
|
||||
|
||||
| Key | Type | Meaning |
|
||||
| ----------------------------- | -------- | ------------------------------------------------------------- |
|
||||
| `config.maxSummaryChars` | `number` | Maximum total characters allowed in the active-memory summary |
|
||||
| `config.recentUserTurns` | `number` | Prior user turns to include when `queryMode` is `recent` |
|
||||
| `config.recentAssistantTurns` | `number` | Prior assistant turns to include when `queryMode` is `recent` |
|
||||
| `config.recentUserChars` | `number` | Max chars per recent user turn |
|
||||
| `config.recentAssistantChars` | `number` | Max chars per recent assistant turn |
|
||||
| `config.cacheTtlMs` | `number` | Cache reuse for repeated identical queries |
|
||||
|
||||
## Recommended setup
|
||||
|
||||
Start with `recent`.
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"active-memory": {
|
||||
enabled: true,
|
||||
config: {
|
||||
agents: ["main"],
|
||||
queryMode: "recent",
|
||||
timeoutMs: 15000,
|
||||
maxSummaryChars: 220,
|
||||
logging: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
If you want to inspect live behavior while tuning, use `/verbose on` in the
|
||||
session instead of looking for a separate active-memory debug command.
|
||||
|
||||
Then move to:
|
||||
|
||||
- `message` if you want lower latency
|
||||
- `full` if you decide extra context is worth the slower blocking memory subagent
|
||||
|
||||
## Debugging
|
||||
|
||||
If active memory is not showing up where you expect:
|
||||
|
||||
1. Confirm the plugin is enabled under `plugins.entries.active-memory.enabled`.
|
||||
2. Confirm the current agent id is listed in `config.agents`.
|
||||
3. Confirm you are testing through an interactive persistent chat session.
|
||||
4. Turn on `config.logging: true` and watch the gateway logs.
|
||||
5. Verify memory search itself works with `openclaw memory status --deep`.
|
||||
|
||||
If memory hits are noisy, tighten:
|
||||
|
||||
- `maxSummaryChars`
|
||||
|
||||
If active memory is too slow:
|
||||
|
||||
- lower `queryMode`
|
||||
- lower `timeoutMs`
|
||||
- reduce recent turn counts
|
||||
- reduce per-turn char caps
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Memory Search](/concepts/memory-search)
|
||||
- [Memory configuration reference](/reference/memory-config)
|
||||
- [Plugin SDK setup](/plugins/sdk-setup)
|
||||
@@ -41,6 +41,71 @@ Before compacting, OpenClaw automatically reminds the agent to save important
|
||||
notes to [memory](/concepts/memory) files. This prevents context loss.
|
||||
</Info>
|
||||
|
||||
Use the `agents.defaults.compaction` setting in your `openclaw.json` to configure compaction behavior (mode, target tokens, etc.).
|
||||
Compaction summarization preserves opaque identifiers by default (`identifierPolicy: "strict"`). You can override this with `identifierPolicy: "off"` or provide custom text with `identifierPolicy: "custom"` and `identifierInstructions`.
|
||||
|
||||
You can optionally specify a different model for compaction summarization via `agents.defaults.compaction.model`. This is useful when your primary model is a local or small model and you want compaction summaries produced by a more capable model. The override accepts any `provider/model-id` string:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"compaction": {
|
||||
"model": "openrouter/anthropic/claude-sonnet-4-6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This also works with local models, for example a second Ollama model dedicated to summarization or a fine-tuned compaction specialist:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"compaction": {
|
||||
"model": "ollama/llama3.1:8b"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When unset, compaction uses the agent’s primary model.
|
||||
|
||||
## Pluggable compaction providers
|
||||
|
||||
Plugins can register a custom compaction provider via `registerCompactionProvider()` on the plugin API. When a provider is registered and configured, OpenClaw delegates summarization to it instead of the built-in LLM pipeline.
|
||||
|
||||
To use a registered provider, set the provider id in your config:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"compaction": {
|
||||
"provider": "my-provider"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Setting a `provider` automatically forces `mode: "safeguard"`. Providers receive the same compaction instructions and identifier-preservation policy as the built-in path, and OpenClaw still preserves recent-turn and split-turn suffix context after provider output. If the provider fails or returns an empty result, OpenClaw falls back to built-in LLM summarization.
|
||||
|
||||
## Auto-compaction (default on)
|
||||
|
||||
When a session nears or exceeds the model’s context window, OpenClaw triggers auto-compaction and may retry the original request using the compacted context.
|
||||
|
||||
You’ll see:
|
||||
|
||||
- `🧹 Auto-compaction complete` in verbose mode
|
||||
- `/status` showing `🧹 Compactions: <count>`
|
||||
|
||||
Before compaction, OpenClaw can run a **silent memory flush** turn to store
|
||||
durable notes to disk. See [Memory](/concepts/memory) for details and config.
|
||||
|
||||
## Manual compaction
|
||||
|
||||
Type `/compact` in any chat to force a compaction. Add instructions to guide
|
||||
|
||||
@@ -115,6 +115,8 @@ engine is used automatically.
|
||||
A plugin can register a context engine using the plugin API:
|
||||
|
||||
```ts
|
||||
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
|
||||
|
||||
export default function register(api) {
|
||||
api.registerContextEngine("my-engine", () => ({
|
||||
info: {
|
||||
@@ -128,12 +130,15 @@ export default function register(api) {
|
||||
return { ingested: true };
|
||||
},
|
||||
|
||||
async assemble({ sessionId, messages, tokenBudget }) {
|
||||
async assemble({ sessionId, messages, tokenBudget, availableTools, citationsMode }) {
|
||||
// Return messages that fit the budget
|
||||
return {
|
||||
messages: buildContext(messages, tokenBudget),
|
||||
estimatedTokens: countTokens(messages),
|
||||
systemPromptAddition: "Use lcm_grep to search history...",
|
||||
systemPromptAddition: buildMemorySystemPromptAddition({
|
||||
availableTools: availableTools ?? new Set(),
|
||||
citationsMode,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -248,7 +253,13 @@ OpenClaw resolves when it needs a context engine.
|
||||
- **Memory plugins** (`plugins.slots.memory`) are separate from context engines.
|
||||
Memory plugins provide search/retrieval; context engines control what the
|
||||
model sees. They can work together — a context engine might use memory
|
||||
plugin data during assembly.
|
||||
plugin data during assembly. Plugin engines that want the active memory
|
||||
prompt path should prefer `buildMemorySystemPromptAddition(...)` from
|
||||
`openclaw/plugin-sdk/core`, which converts the active memory prompt sections
|
||||
into a ready-to-prepend `systemPromptAddition`. If an engine needs lower-level
|
||||
control, it can still pull raw lines from
|
||||
`openclaw/plugin-sdk/memory-host-core` via
|
||||
`buildActiveMemoryPromptSection(...)`.
|
||||
- **Session pruning** (trimming old tool results in-memory) still runs
|
||||
regardless of which context engine is active.
|
||||
|
||||
|
||||
@@ -138,5 +138,6 @@ earlier conversations. This is opt-in via
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Active Memory](/concepts/active-memory) -- sidecar memory for interactive chat sessions
|
||||
- [Memory](/concepts/memory) -- file layout, backends, tools
|
||||
- [Memory configuration reference](/reference/memory-config) -- all config knobs
|
||||
|
||||
@@ -360,7 +360,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
- or `npm install -g @google/gemini-cli`
|
||||
- Enable: `openclaw plugins enable google`
|
||||
- Login: `openclaw models auth login --provider google-gemini-cli --set-default`
|
||||
- Default model: `google-gemini-cli/gemini-3.1-pro-preview`
|
||||
- Default model: `google-gemini-cli/gemini-3-flash-preview`
|
||||
- Note: you do **not** paste a client id or secret into `openclaw.json`. The CLI login flow stores
|
||||
tokens in auth profiles on the gateway host.
|
||||
- If requests fail after login, set `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` on the gateway host.
|
||||
|
||||
@@ -37,6 +37,21 @@ QA Lab page where an operator or automation loop can give the agent a QA
|
||||
mission, observe real channel behavior, and record what worked, failed, or
|
||||
stayed blocked.
|
||||
|
||||
For faster QA Lab UI iteration without rebuilding the Docker image each time,
|
||||
start the stack with a bind-mounted QA Lab bundle:
|
||||
|
||||
```bash
|
||||
pnpm openclaw qa docker-build-image
|
||||
pnpm qa:lab:build
|
||||
pnpm qa:lab:up:fast
|
||||
pnpm qa:lab:watch
|
||||
```
|
||||
|
||||
`qa:lab:up:fast` keeps the Docker services on a prebuilt image and bind-mounts
|
||||
`extensions/qa-lab/web/dist` into the `qa-lab` container. `qa:lab:watch`
|
||||
rebuilds that bundle on change, and the browser auto-reloads when the QA Lab
|
||||
asset hash changes.
|
||||
|
||||
## Repo-backed seeds
|
||||
|
||||
Seed assets live in `qa/`:
|
||||
|
||||
@@ -76,6 +76,10 @@
|
||||
"source": "/plugins/agent-tools",
|
||||
"destination": "/plugins/building-plugins#registering-agent-tools"
|
||||
},
|
||||
{
|
||||
"source": "/cli/capability",
|
||||
"destination": "/cli/infer"
|
||||
},
|
||||
{
|
||||
"source": "/tools/capability-cookbook",
|
||||
"destination": "/plugins/architecture"
|
||||
|
||||
@@ -214,8 +214,10 @@ The bundled OpenAI plugin also registers a default for `codex-cli`:
|
||||
The bundled Google plugin also registers a default for `google-gemini-cli`:
|
||||
|
||||
- `command: "gemini"`
|
||||
- `args: ["--prompt", "--output-format", "json"]`
|
||||
- `resumeArgs: ["--resume", "{sessionId}", "--prompt", "--output-format", "json"]`
|
||||
- `args: ["--output-format", "json", "--prompt", "{prompt}"]`
|
||||
- `resumeArgs: ["--resume", "{sessionId}", "--output-format", "json", "--prompt", "{prompt}"]`
|
||||
- `imageArg: "@"`
|
||||
- `imagePathScope: "workspace"`
|
||||
- `modelArg: "--model"`
|
||||
- `sessionMode: "existing"`
|
||||
- `sessionIdFields: ["session_id", "sessionId"]`
|
||||
@@ -251,8 +253,9 @@ opt into a generated MCP config overlay with `bundleMcp: true`.
|
||||
|
||||
Current bundled behavior:
|
||||
|
||||
- `codex-cli`: no bundle MCP overlay
|
||||
- `google-gemini-cli`: no bundle MCP overlay
|
||||
- `claude-cli`: generated strict MCP config file
|
||||
- `codex-cli`: inline config overrides for `mcp_servers`
|
||||
- `google-gemini-cli`: generated Gemini system settings file
|
||||
|
||||
When bundle MCP is enabled, OpenClaw:
|
||||
|
||||
@@ -260,8 +263,8 @@ When bundle MCP is enabled, OpenClaw:
|
||||
- authenticates the bridge with a per-session token (`OPENCLAW_MCP_TOKEN`)
|
||||
- scopes tool access to the current session, account, and channel context
|
||||
- loads enabled bundle-MCP servers for the current workspace
|
||||
- merges them with any existing backend `--mcp-config`
|
||||
- rewrites the CLI args to pass `--strict-mcp-config --mcp-config <generated-file>`
|
||||
- merges them with any existing backend MCP config/settings shape
|
||||
- rewrites the launch config using the backend-owned integration mode from the owning extension
|
||||
|
||||
If no MCP servers are enabled, OpenClaw still injects a strict config when a
|
||||
backend opts into bundle MCP so background runs stay isolated.
|
||||
|
||||
@@ -1160,6 +1160,7 @@ Periodic heartbeat runs.
|
||||
defaults: {
|
||||
compaction: {
|
||||
mode: "safeguard", // default | safeguard
|
||||
provider: "my-provider", // id of a registered compaction provider plugin (optional)
|
||||
timeoutSeconds: 900,
|
||||
reserveTokensFloor: 24000,
|
||||
identifierPolicy: "strict", // strict | off | custom
|
||||
@@ -1180,6 +1181,7 @@ Periodic heartbeat runs.
|
||||
```
|
||||
|
||||
- `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/concepts/compaction).
|
||||
- `provider`: id of a registered compaction provider plugin. When set, the provider's `summarize()` is called instead of built-in LLM summarization. Falls back to built-in on failure. Setting a provider forces `mode: "safeguard"`. See [Compaction](/concepts/compaction).
|
||||
- `timeoutSeconds`: maximum seconds allowed for a single compaction operation before OpenClaw aborts it. Default: `900`.
|
||||
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
|
||||
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
|
||||
@@ -2349,6 +2351,7 @@ OpenClaw uses the built-in model catalog. Add custom providers via `models.provi
|
||||
- `models.providers.*.models.*.contextWindow`: native model context window metadata.
|
||||
- `models.providers.*.models.*.contextTokens`: optional runtime context cap. Use this when you want a smaller effective context budget than the model's native `contextWindow`.
|
||||
- `models.providers.*.models.*.compat.supportsDeveloperRole`: optional compatibility hint. For `api: "openai-completions"` with a non-empty non-native `baseUrl` (host not `api.openai.com`), OpenClaw forces this to `false` at runtime. Empty/omitted `baseUrl` keeps default OpenAI behavior.
|
||||
- `models.providers.*.models.*.compat.requiresStringContent`: optional compatibility hint for string-only OpenAI-compatible chat endpoints. When `true`, OpenClaw flattens pure text `messages[].content` arrays into plain strings before sending the request.
|
||||
- `plugins.entries.amazon-bedrock.config.discovery`: Bedrock auto-discovery settings root.
|
||||
- `plugins.entries.amazon-bedrock.config.discovery.enabled`: turn implicit discovery on/off.
|
||||
- `plugins.entries.amazon-bedrock.config.discovery.region`: AWS region for discovery.
|
||||
|
||||
@@ -155,9 +155,30 @@ Behavior note for local/proxied `/v1` backends:
|
||||
- hidden OpenClaw attribution headers (`originator`, `version`, `User-Agent`)
|
||||
are not injected on these custom proxy URLs
|
||||
|
||||
Compatibility notes for stricter OpenAI-compatible backends:
|
||||
|
||||
- Some servers accept only string `messages[].content` on Chat Completions, not
|
||||
structured content-part arrays. Set
|
||||
`models.providers.<provider>.models[].compat.requiresStringContent: true` for
|
||||
those endpoints.
|
||||
- Some smaller or stricter local backends are unstable with OpenClaw's full
|
||||
agent-runtime prompt shape, especially when tool schemas are included. If the
|
||||
backend works for tiny direct `/v1/chat/completions` calls but fails on normal
|
||||
OpenClaw agent turns, try
|
||||
`models.providers.<provider>.models[].compat.supportsTools: false` first.
|
||||
- If the backend still fails only on larger OpenClaw runs, the remaining issue
|
||||
is usually upstream model/server capacity or a backend bug, not OpenClaw's
|
||||
transport layer.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Gateway can reach the proxy? `curl http://127.0.0.1:1234/v1/models`.
|
||||
- LM Studio model unloaded? Reload; cold start is a common “hanging” cause.
|
||||
- Context errors? Lower `contextWindow` or raise your server limit.
|
||||
- OpenAI-compatible server returns `messages[].content ... expected a string`?
|
||||
Add `compat.requiresStringContent: true` on that model entry.
|
||||
- Direct tiny `/v1/chat/completions` calls work, but `openclaw infer model run`
|
||||
fails on Gemma or another local model? Disable tool schemas first with
|
||||
`compat.supportsTools: false`, then retest. If the server still crashes only
|
||||
on larger OpenClaw prompts, treat it as an upstream server/model limitation.
|
||||
- Safety: local models skip provider-side filters; keep agents narrow and compaction on to limit prompt injection blast radius.
|
||||
|
||||
@@ -381,16 +381,18 @@ implemented in `src/gateway/server-methods/*.ts`.
|
||||
|
||||
#### Approval families
|
||||
|
||||
- `exec.approval.request` and `exec.approval.resolve` cover one-shot exec
|
||||
approval requests.
|
||||
- `exec.approval.request`, `exec.approval.get`, `exec.approval.list`, and
|
||||
`exec.approval.resolve` cover one-shot exec approval requests plus pending
|
||||
approval lookup/replay.
|
||||
- `exec.approval.waitDecision` waits on one pending exec approval and returns
|
||||
the final decision (or `null` on timeout).
|
||||
- `exec.approvals.get` and `exec.approvals.set` manage gateway exec approval
|
||||
policy snapshots.
|
||||
- `exec.approvals.node.get` and `exec.approvals.node.set` manage node-local exec
|
||||
approval policy via node relay commands.
|
||||
- `plugin.approval.request`, `plugin.approval.waitDecision`, and
|
||||
`plugin.approval.resolve` cover plugin-defined approval flows.
|
||||
- `plugin.approval.request`, `plugin.approval.list`,
|
||||
`plugin.approval.waitDecision`, and `plugin.approval.resolve` cover
|
||||
plugin-defined approval flows.
|
||||
|
||||
#### Other major families
|
||||
|
||||
|
||||
@@ -59,6 +59,61 @@ Related:
|
||||
- [/reference/token-use](/reference/token-use)
|
||||
- [/help/faq#why-am-i-seeing-http-429-ratelimiterror-from-anthropic](/help/faq#why-am-i-seeing-http-429-ratelimiterror-from-anthropic)
|
||||
|
||||
## Local OpenAI-compatible backend passes direct probes but agent runs fail
|
||||
|
||||
Use this when:
|
||||
|
||||
- `curl ... /v1/models` works
|
||||
- tiny direct `/v1/chat/completions` calls work
|
||||
- OpenClaw model runs fail only on normal agent turns
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:1234/v1/models
|
||||
curl http://127.0.0.1:1234/v1/chat/completions \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"model":"<id>","messages":[{"role":"user","content":"hi"}],"stream":false}'
|
||||
openclaw infer model run --model <provider/model> --prompt "hi" --json
|
||||
openclaw logs --follow
|
||||
```
|
||||
|
||||
Look for:
|
||||
|
||||
- direct tiny calls succeed, but OpenClaw runs fail only on larger prompts
|
||||
- backend errors about `messages[].content` expecting a string
|
||||
- backend crashes that appear only with larger prompt-token counts or full agent
|
||||
runtime prompts
|
||||
|
||||
Common signatures:
|
||||
|
||||
- `messages[...].content: invalid type: sequence, expected a string` → backend
|
||||
rejects structured Chat Completions content parts. Fix: set
|
||||
`models.providers.<provider>.models[].compat.requiresStringContent: true`.
|
||||
- direct tiny requests succeed, but OpenClaw agent runs fail with backend/model
|
||||
crashes (for example Gemma on some `inferrs` builds) → OpenClaw transport is
|
||||
likely already correct; the backend is failing on the larger agent-runtime
|
||||
prompt shape.
|
||||
- failures shrink after disabling tools but do not disappear → tool schemas were
|
||||
part of the pressure, but the remaining issue is still upstream model/server
|
||||
capacity or a backend bug.
|
||||
|
||||
Fix options:
|
||||
|
||||
1. Set `compat.requiresStringContent: true` for string-only Chat Completions backends.
|
||||
2. Set `compat.supportsTools: false` for models/backends that cannot handle
|
||||
OpenClaw's tool schema surface reliably.
|
||||
3. Lower prompt pressure where possible: smaller workspace bootstrap, shorter
|
||||
session history, lighter local model, or a backend with stronger long-context
|
||||
support.
|
||||
4. If tiny direct requests keep passing while OpenClaw agent turns still crash
|
||||
inside the backend, treat it as an upstream server/model limitation and file
|
||||
a repro there with the accepted payload shape.
|
||||
|
||||
Related:
|
||||
|
||||
- [/gateway/local-models](/gateway/local-models)
|
||||
- [/gateway/configuration#models](/gateway/configuration#models)
|
||||
- [/gateway/configuration-reference#openai-compatible-endpoints](/gateway/configuration-reference#openai-compatible-endpoints)
|
||||
|
||||
## No replies
|
||||
|
||||
If channels are up but nothing answers, check routing and policy before reconnecting anything.
|
||||
|
||||
@@ -701,7 +701,7 @@ for usage/billing and raise limits as needed.
|
||||
- npm: `npm install -g @google/gemini-cli`
|
||||
2. Enable the plugin: `openclaw plugins enable google`
|
||||
3. Login: `openclaw models auth login --provider google-gemini-cli --set-default`
|
||||
4. Default model after login: `google-gemini-cli/gemini-3.1-pro-preview`
|
||||
4. Default model after login: `google-gemini-cli/gemini-3-flash-preview`
|
||||
5. If requests fail, set `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` on the gateway host
|
||||
|
||||
This stores OAuth tokens in auth profiles on the gateway host. Details: [Model providers](/concepts/model-providers).
|
||||
@@ -1816,8 +1816,8 @@ for usage/billing and raise limits as needed.
|
||||
|
||||
- `config.schema.lookup`: inspect one config subtree with its shallow schema node, matched UI hint, and immediate child summaries before writing
|
||||
- `config.get`: fetch the current snapshot + hash
|
||||
- `config.patch`: safe partial update (preferred for most RPC edits)
|
||||
- `config.apply`: validate + replace the full config, then restart
|
||||
- `config.patch`: safe partial update (preferred for most RPC edits); hot-reloads when possible and restarts when required
|
||||
- `config.apply`: validate + replace the full config; hot-reloads when possible and restarts when required
|
||||
- The owner-only `gateway` runtime tool still refuses to rewrite `tools.exec.ask` / `tools.exec.security`; legacy `tools.bash.*` aliases normalize to the same protected exec paths
|
||||
|
||||
</Accordion>
|
||||
@@ -2254,7 +2254,7 @@ for usage/billing and raise limits as needed.
|
||||
Quickest setup:
|
||||
|
||||
1. Install Ollama from `https://ollama.com/download`
|
||||
2. Pull a local model such as `ollama pull glm-4.7-flash`
|
||||
2. Pull a local model such as `ollama pull gemma4`
|
||||
3. If you want cloud models too, run `ollama signin`
|
||||
4. Run `openclaw onboard` and choose `Ollama`
|
||||
5. Pick `Local` or `Cloud + Local`
|
||||
|
||||
@@ -47,7 +47,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
|
||||
### Unit / integration (default)
|
||||
|
||||
- Command: `pnpm test`
|
||||
- Config: five sequential shard runs (`vitest.full-*.config.ts`) over the existing scoped Vitest projects
|
||||
- Config: ten sequential shard runs (`vitest.full-*.config.ts`) over the existing scoped Vitest projects
|
||||
- Files: core/unit inventories under `src/**/*.test.ts`, `packages/**/*.test.ts`, `test/**/*.test.ts`, and the whitelisted `ui` node tests covered by `vitest.unit.config.ts`
|
||||
- Scope:
|
||||
- Pure unit tests
|
||||
@@ -58,7 +58,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
|
||||
- No real keys required
|
||||
- Should be fast and stable
|
||||
- Projects note:
|
||||
- Untargeted `pnpm test` now runs eight smaller shard configs (`core-unit-src`, `core-unit-security`, `core-unit-support`, `core-contracts`, `core-runtime`, `agentic`, `auto-reply`, `extensions`) instead of one giant native root-project process. This cuts peak RSS on loaded machines and avoids auto-reply/extension work starving unrelated suites.
|
||||
- Untargeted `pnpm test` now runs eleven smaller shard configs (`core-unit-src`, `core-unit-security`, `core-unit-ui`, `core-unit-support`, `core-support-boundary`, `core-contracts`, `core-bundled`, `core-runtime`, `agentic`, `auto-reply`, `extensions`) instead of one giant native root-project process. This cuts peak RSS on loaded machines and avoids auto-reply/extension work starving unrelated suites.
|
||||
- `pnpm test --watch` still uses the native root `vitest.config.ts` project graph, because a multi-shard watch loop is not practical.
|
||||
- `pnpm test`, `pnpm test:watch`, and `pnpm test:perf:imports` route explicit file/directory targets through scoped lanes first, so `pnpm test extensions/discord/src/monitor/message-handler.preflight.test.ts` avoids paying the full root project startup tax.
|
||||
- `pnpm test:changed` expands changed git paths into the same scoped lanes when the diff only touches routable source/test files; config/setup edits still fall back to the broad root-project rerun.
|
||||
@@ -253,17 +253,17 @@ openclaw models list
|
||||
openclaw models list --json
|
||||
```
|
||||
|
||||
## Live: CLI backend smoke (Codex CLI or other local CLIs)
|
||||
## Live: CLI backend smoke (Claude, Codex, Gemini, or other local CLIs)
|
||||
|
||||
- Test: `src/gateway/gateway-cli-backend.live.test.ts`
|
||||
- Goal: validate the Gateway + agent pipeline using a local CLI backend, without touching your default config.
|
||||
- Backend-specific smoke defaults live with the owning extension's `cli-backend.ts` definition.
|
||||
- Enable:
|
||||
- `pnpm test:live` (or `OPENCLAW_LIVE_TEST=1` if invoking Vitest directly)
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND=1`
|
||||
- Defaults:
|
||||
- Model: `codex-cli/gpt-5.4`
|
||||
- Command: `codex`
|
||||
- Args: `["exec","--json","--color","never","--sandbox","read-only","--skip-git-repo-check"]`
|
||||
- Default provider/model: `claude-cli/claude-sonnet-4-6`
|
||||
- Command/args/image behavior come from the owning CLI backend plugin metadata.
|
||||
- Overrides (optional):
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND_MODEL="codex-cli/gpt-5.4"`
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND_COMMAND="/full/path/to/codex"`
|
||||
@@ -272,6 +272,7 @@ openclaw models list --json
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND_IMAGE_ARG="--image"` to pass image file paths as CLI args instead of prompt injection.
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE="repeat"` (or `"list"`) to control how image args are passed when `IMAGE_ARG` is set.
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND_RESUME_PROBE=1` to send a second turn and validate resume flow.
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND_MODEL_SWITCH_PROBE=0` to disable the default Claude Sonnet -> Opus same-session continuity probe (set to `1` to force it on when the selected model supports a switch target).
|
||||
|
||||
Example:
|
||||
|
||||
@@ -287,11 +288,21 @@ Docker recipe:
|
||||
pnpm test:docker:live-cli-backend
|
||||
```
|
||||
|
||||
Single-provider Docker recipes:
|
||||
|
||||
```bash
|
||||
pnpm test:docker:live-cli-backend:claude
|
||||
pnpm test:docker:live-cli-backend:codex
|
||||
pnpm test:docker:live-cli-backend:gemini
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The Docker runner lives at `scripts/test-live-cli-backend-docker.sh`.
|
||||
- It runs the live CLI-backend smoke inside the repo Docker image as the non-root `node` user.
|
||||
- For `codex-cli`, it installs the Linux `@openai/codex` package into a cached writable prefix at `OPENCLAW_DOCKER_CLI_TOOLS_DIR` (default: `~/.cache/openclaw/docker-cli-tools`).
|
||||
- It resolves CLI smoke metadata from the owning extension, then installs the matching Linux CLI package (`@anthropic-ai/claude-code`, `@openai/codex`, or `@google/gemini-cli`) into a cached writable prefix at `OPENCLAW_DOCKER_CLI_TOOLS_DIR` (default: `~/.cache/openclaw/docker-cli-tools`).
|
||||
- The live CLI-backend smoke now exercises the same end-to-end flow for Claude, Codex, and Gemini: text turn, image classification turn, then MCP `cron` tool call verified through the gateway CLI.
|
||||
- Claude's default smoke also patches the session from Sonnet to Opus and verifies the resumed session still remembers an earlier note.
|
||||
|
||||
## Live: ACP bind smoke (`/acp spawn ... --bind here`)
|
||||
|
||||
@@ -305,12 +316,15 @@ Notes:
|
||||
- `pnpm test:live src/gateway/gateway-acp-bind.live.test.ts`
|
||||
- `OPENCLAW_LIVE_ACP_BIND=1`
|
||||
- Defaults:
|
||||
- ACP agent: `claude`
|
||||
- ACP agents in Docker: `claude,codex,gemini`
|
||||
- ACP agent for direct `pnpm test:live ...`: `claude`
|
||||
- Synthetic channel: Slack DM-style conversation context
|
||||
- ACP backend: `acpx`
|
||||
- Overrides:
|
||||
- `OPENCLAW_LIVE_ACP_BIND_AGENT=claude`
|
||||
- `OPENCLAW_LIVE_ACP_BIND_AGENT=codex`
|
||||
- `OPENCLAW_LIVE_ACP_BIND_AGENT=gemini`
|
||||
- `OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini`
|
||||
- `OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND='npx -y @agentclientprotocol/claude-agent-acp@<version>'`
|
||||
- Notes:
|
||||
- This lane uses the gateway `chat.send` surface with admin-only synthetic originating-route fields so tests can attach message-channel context without pretending to deliver externally.
|
||||
@@ -330,10 +344,20 @@ Docker recipe:
|
||||
pnpm test:docker:live-acp-bind
|
||||
```
|
||||
|
||||
Single-agent Docker recipes:
|
||||
|
||||
```bash
|
||||
pnpm test:docker:live-acp-bind:claude
|
||||
pnpm test:docker:live-acp-bind:codex
|
||||
pnpm test:docker:live-acp-bind:gemini
|
||||
```
|
||||
|
||||
Docker notes:
|
||||
|
||||
- The Docker runner lives at `scripts/test-live-acp-bind-docker.sh`.
|
||||
- It sources `~/.profile`, stages the matching CLI auth material into the container, installs `acpx` into a writable npm prefix, then installs the requested live CLI (`@anthropic-ai/claude-code` or `@openai/codex`) if missing.
|
||||
- By default, it runs the ACP bind smoke against all supported live CLI agents in sequence: `claude`, `codex`, then `gemini`.
|
||||
- Use `OPENCLAW_LIVE_ACP_BIND_AGENTS=claude`, `OPENCLAW_LIVE_ACP_BIND_AGENTS=codex`, or `OPENCLAW_LIVE_ACP_BIND_AGENTS=gemini` to narrow the matrix.
|
||||
- It sources `~/.profile`, stages the matching CLI auth material into the container, installs `acpx` into a writable npm prefix, then installs the requested live CLI (`@anthropic-ai/claude-code`, `@openai/codex`, or `@google/gemini-cli`) if missing.
|
||||
- Inside Docker, the runner sets `OPENCLAW_LIVE_ACP_BIND_ACPX_COMMAND=$HOME/.npm-global/bin/acpx` so acpx keeps provider env vars from the sourced profile available to the child harness CLI.
|
||||
|
||||
### Recommended live recipes
|
||||
@@ -426,7 +450,7 @@ Live tests discover credentials the same way the CLI does. Practical implication
|
||||
- Per-agent auth profiles: `~/.openclaw/agents/<agentId>/agent/auth-profiles.json` (this is what “profile keys” means in the live tests)
|
||||
- Config: `~/.openclaw/openclaw.json` (or `OPENCLAW_CONFIG_PATH`)
|
||||
- Legacy state dir: `~/.openclaw/credentials/` (copied into the staged live home when present, but not the main profile-key store)
|
||||
- Live local runs copy the active config, per-agent `auth-profiles.json` files, legacy `credentials/`, and supported external CLI auth dirs into a temp test home by default; `agents.*.workspace` / `agentDir` path overrides are stripped in that staged config so probes stay off your real host workspace.
|
||||
- Live local runs copy the active config, per-agent `auth-profiles.json` files, legacy `credentials/`, and supported external CLI auth dirs into a temp test home by default; staged live homes skip `workspace/` and `sandboxes/`, and `agents.*.workspace` / `agentDir` path overrides are stripped so probes stay off your real host workspace.
|
||||
|
||||
If you want to rely on env keys (e.g. exported in your `~/.profile`), run local tests after `source ~/.profile`, or use the Docker runners below (they can mount `~/.profile` into the container).
|
||||
|
||||
|
||||
@@ -42,6 +42,21 @@ If you see:
|
||||
`HTTP 429: rate_limit_error: Extra usage is required for long context requests`,
|
||||
go to [/gateway/troubleshooting#anthropic-429-extra-usage-required-for-long-context](/gateway/troubleshooting#anthropic-429-extra-usage-required-for-long-context).
|
||||
|
||||
## Local OpenAI-compatible backend works directly but fails in OpenClaw
|
||||
|
||||
If your local or self-hosted `/v1` backend answers small direct
|
||||
`/v1/chat/completions` probes but fails on `openclaw infer model run` or normal
|
||||
agent turns:
|
||||
|
||||
1. If the error mentions `messages[].content` expecting a string, set
|
||||
`models.providers.<provider>.models[].compat.requiresStringContent: true`.
|
||||
2. If the backend still fails only on OpenClaw agent turns, set
|
||||
`models.providers.<provider>.models[].compat.supportsTools: false` and retry.
|
||||
3. If tiny direct calls still work but larger OpenClaw prompts crash the
|
||||
backend, treat the remaining issue as an upstream model/server limitation and
|
||||
continue in the deep runbook:
|
||||
[/gateway/troubleshooting#local-openai-compatible-backend-passes-direct-probes-but-agent-runs-fail](/gateway/troubleshooting#local-openai-compatible-backend-passes-direct-probes-but-agent-runs-fail)
|
||||
|
||||
## Plugin install fails with missing openclaw extensions
|
||||
|
||||
If install fails with `package.json missing openclaw.extensions`, the plugin package
|
||||
|
||||
@@ -1120,7 +1120,8 @@ authoring plugins:
|
||||
`openclaw/plugin-sdk/secret-input`, and
|
||||
`openclaw/plugin-sdk/webhook-ingress` for shared setup/auth/reply/webhook
|
||||
wiring. `channel-inbound` is the shared home for debounce, mention matching,
|
||||
envelope formatting, and inbound envelope context helpers.
|
||||
inbound mention-policy helpers, envelope formatting, and inbound envelope
|
||||
context helpers.
|
||||
`channel-setup` is the narrow optional-install setup seam.
|
||||
`setup-runtime` is the runtime-safe setup surface used by `setupEntry` /
|
||||
deferred startup, including the import-safe setup patch adapters.
|
||||
@@ -1133,6 +1134,9 @@ authoring plugins:
|
||||
`openclaw/plugin-sdk/channel-config-schema`,
|
||||
`openclaw/plugin-sdk/telegram-command-config`,
|
||||
`openclaw/plugin-sdk/channel-policy`,
|
||||
`openclaw/plugin-sdk/approval-gateway-runtime`,
|
||||
`openclaw/plugin-sdk/approval-handler-adapter-runtime`,
|
||||
`openclaw/plugin-sdk/approval-handler-runtime`,
|
||||
`openclaw/plugin-sdk/approval-runtime`,
|
||||
`openclaw/plugin-sdk/config-runtime`,
|
||||
`openclaw/plugin-sdk/infra-runtime`,
|
||||
@@ -1151,9 +1155,9 @@ authoring plugins:
|
||||
assistant-visible-text stripping, markdown render/chunking helpers, redaction
|
||||
helpers, directive-tag helpers, and safe-text utilities.
|
||||
- Approval-specific channel seams should prefer one `approvalCapability`
|
||||
contract on the plugin. Core then reads approval auth, delivery, render, and
|
||||
native-routing behavior through that one capability instead of mixing
|
||||
approval behavior into unrelated plugin fields.
|
||||
contract on the plugin. Core then reads approval auth, delivery, render,
|
||||
native-routing, and lazy native-handler behavior through that one capability
|
||||
instead of mixing approval behavior into unrelated plugin fields.
|
||||
- `openclaw/plugin-sdk/channel-runtime` is deprecated and remains only as a
|
||||
compatibility shim for older plugins. New code should import the narrower
|
||||
generic primitives instead, and repo code should not add new imports of the
|
||||
@@ -1493,14 +1497,23 @@ Use this when your plugin needs to replace or extend the default context
|
||||
pipeline rather than just add memory search or hooks.
|
||||
|
||||
```ts
|
||||
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
|
||||
|
||||
export default function (api) {
|
||||
api.registerContextEngine("lossless-claw", () => ({
|
||||
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
|
||||
async ingest() {
|
||||
return { ingested: true };
|
||||
},
|
||||
async assemble({ messages }) {
|
||||
return { messages, estimatedTokens: 0 };
|
||||
async assemble({ messages, availableTools, citationsMode }) {
|
||||
return {
|
||||
messages,
|
||||
estimatedTokens: 0,
|
||||
systemPromptAddition: buildMemorySystemPromptAddition({
|
||||
availableTools: availableTools ?? new Set(),
|
||||
citationsMode,
|
||||
}),
|
||||
};
|
||||
},
|
||||
async compact() {
|
||||
return { ok: true, compacted: false };
|
||||
@@ -1513,7 +1526,10 @@ If your engine does **not** own the compaction algorithm, keep `compact()`
|
||||
implemented and delegate it explicitly:
|
||||
|
||||
```ts
|
||||
import { delegateCompactionToRuntime } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
buildMemorySystemPromptAddition,
|
||||
delegateCompactionToRuntime,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
|
||||
export default function (api) {
|
||||
api.registerContextEngine("my-memory-engine", () => ({
|
||||
@@ -1525,8 +1541,15 @@ export default function (api) {
|
||||
async ingest() {
|
||||
return { ingested: true };
|
||||
},
|
||||
async assemble({ messages }) {
|
||||
return { messages, estimatedTokens: 0 };
|
||||
async assemble({ messages, availableTools, citationsMode }) {
|
||||
return {
|
||||
messages,
|
||||
estimatedTokens: 0,
|
||||
systemPromptAddition: buildMemorySystemPromptAddition({
|
||||
availableTools: availableTools ?? new Set(),
|
||||
citationsMode,
|
||||
}),
|
||||
};
|
||||
},
|
||||
async compact(params) {
|
||||
return await delegateCompactionToRuntime(params);
|
||||
|
||||
@@ -60,22 +60,34 @@ Most channel plugins do not need approval-specific code.
|
||||
|
||||
- Core owns same-chat `/approve`, shared approval button payloads, and generic fallback delivery.
|
||||
- Prefer one `approvalCapability` object on the channel plugin when the channel needs approval-specific behavior.
|
||||
- `ChannelPlugin.approvals` is removed. Put approval delivery/native/render/auth facts on `approvalCapability`.
|
||||
- `plugin.auth` is login/logout only; core no longer reads approval auth hooks from that object.
|
||||
- `approvalCapability.authorizeActorAction` and `approvalCapability.getActionAvailabilityState` are the canonical approval-auth seam.
|
||||
- If your channel exposes native exec approvals, implement `approvalCapability.getActionAvailabilityState` even when the native transport lives entirely under `approvalCapability.native`. Core uses that availability hook to distinguish `enabled` vs `disabled`, decide whether the initiating channel supports native approvals, and include the channel in native-client fallback guidance.
|
||||
- Use `approvalCapability.getActionAvailabilityState` for same-chat approval auth availability.
|
||||
- If your channel exposes native exec approvals, use `approvalCapability.getExecInitiatingSurfaceState` for the initiating-surface/native-client state when it differs from same-chat approval auth. Core uses that exec-specific hook to distinguish `enabled` vs `disabled`, decide whether the initiating channel supports native exec approvals, and include the channel in native-client fallback guidance. `createApproverRestrictedNativeApprovalCapability(...)` fills this in for the common case.
|
||||
- Use `outbound.shouldSuppressLocalPayloadPrompt` or `outbound.beforeDeliverPayload` for channel-specific payload lifecycle behavior such as hiding duplicate local approval prompts or sending typing indicators before delivery.
|
||||
- Use `approvalCapability.delivery` only for native approval routing or fallback suppression.
|
||||
- Use `approvalCapability.nativeRuntime` for channel-owned native approval facts. Keep it lazy on hot channel entrypoints with `createLazyChannelApprovalNativeRuntimeAdapter(...)`, which can import your runtime module on demand while still letting core assemble the approval lifecycle.
|
||||
- Use `approvalCapability.render` only when a channel truly needs custom approval payloads instead of the shared renderer.
|
||||
- Use `approvalCapability.describeExecApprovalSetup` when the channel wants the disabled-path reply to explain the exact config knobs needed to enable native exec approvals. The hook receives `{ channel, channelLabel, accountId }`; named-account channels should render account-scoped paths such as `channels.<channel>.accounts.<id>.execApprovals.*` instead of top-level defaults.
|
||||
- If a channel can infer stable owner-like DM identities from existing config, use `createResolvedApproverActionAuthAdapter` from `openclaw/plugin-sdk/approval-runtime` to restrict same-chat `/approve` without adding approval-specific core logic.
|
||||
- If a channel needs native approval delivery, keep channel code focused on target normalization and transport hooks. Use `createChannelExecApprovalProfile`, `createChannelNativeOriginTargetResolver`, `createChannelApproverDmTargetResolver`, `createApproverRestrictedNativeApprovalCapability`, and `createChannelNativeApprovalRuntime` from `openclaw/plugin-sdk/approval-runtime` so core owns request filtering, routing, dedupe, expiry, and gateway subscription.
|
||||
- If a channel needs native approval delivery, keep channel code focused on target normalization plus transport/presentation facts. Use `createChannelExecApprovalProfile`, `createChannelNativeOriginTargetResolver`, `createChannelApproverDmTargetResolver`, and `createApproverRestrictedNativeApprovalCapability` from `openclaw/plugin-sdk/approval-runtime`. Put the channel-specific facts behind `approvalCapability.nativeRuntime`, ideally via `createChannelApprovalNativeRuntimeAdapter(...)` or `createLazyChannelApprovalNativeRuntimeAdapter(...)`, so core can assemble the handler and own request filtering, routing, dedupe, expiry, gateway subscription, and routed-elsewhere notices. `nativeRuntime` is split into a few smaller seams:
|
||||
- `availability` — whether the account is configured and whether a request should be handled
|
||||
- `presentation` — map the shared approval view model into pending/resolved/expired native payloads or final actions
|
||||
- `transport` — prepare targets plus send/update/delete native approval messages
|
||||
- `interactions` — optional bind/unbind/clear-action hooks for native buttons or reactions
|
||||
- `observe` — optional delivery diagnostics hooks
|
||||
- If the channel needs runtime-owned objects such as a client, token, Bolt app, or webhook receiver, register them through `openclaw/plugin-sdk/channel-runtime-context`. The generic runtime-context registry lets core bootstrap capability-driven handlers from channel startup state without adding approval-specific wrapper glue.
|
||||
- Reach for the lower-level `createChannelApprovalHandler` or `createChannelNativeApprovalRuntime` only when the capability-driven seam is not expressive enough yet.
|
||||
- Native approval channels must route both `accountId` and `approvalKind` through those helpers. `accountId` keeps multi-account approval policy scoped to the right bot account, and `approvalKind` keeps exec vs plugin approval behavior available to the channel without hardcoded branches in core.
|
||||
- Core now owns approval reroute notices too. Channel plugins should not send their own "approval went to DMs / another channel" follow-up messages from `createChannelNativeApprovalRuntime`; instead, expose accurate origin + approver-DM routing through the shared approval capability helpers and let core aggregate actual deliveries before posting any notice back to the initiating chat.
|
||||
- Preserve the delivered approval id kind end-to-end. Native clients should not
|
||||
guess or rewrite exec vs plugin approval routing from channel-local state.
|
||||
- Different approval kinds can intentionally expose different native surfaces.
|
||||
Current bundled examples:
|
||||
- Slack keeps native approval routing available for both exec and plugin ids.
|
||||
- Matrix keeps native DM/channel routing for exec approvals only and leaves
|
||||
plugin approvals on the shared same-chat `/approve` path.
|
||||
- Matrix keeps the same native DM/channel routing and reaction UX for exec
|
||||
and plugin approvals, while still letting auth differ by approval kind.
|
||||
- `createApproverRestrictedNativeApprovalAdapter` still exists as a compatibility wrapper, but new code should prefer the capability builder and expose `approvalCapability` on the plugin.
|
||||
|
||||
For hot channel entrypoints, prefer the narrower runtime subpaths when you only
|
||||
@@ -84,8 +96,12 @@ need one part of that family:
|
||||
- `openclaw/plugin-sdk/approval-auth-runtime`
|
||||
- `openclaw/plugin-sdk/approval-client-runtime`
|
||||
- `openclaw/plugin-sdk/approval-delivery-runtime`
|
||||
- `openclaw/plugin-sdk/approval-gateway-runtime`
|
||||
- `openclaw/plugin-sdk/approval-handler-adapter-runtime`
|
||||
- `openclaw/plugin-sdk/approval-handler-runtime`
|
||||
- `openclaw/plugin-sdk/approval-native-runtime`
|
||||
- `openclaw/plugin-sdk/approval-reply-runtime`
|
||||
- `openclaw/plugin-sdk/channel-runtime-context`
|
||||
|
||||
Likewise, prefer `openclaw/plugin-sdk/setup-runtime`,
|
||||
`openclaw/plugin-sdk/setup-adapter-runtime`,
|
||||
@@ -152,6 +168,87 @@ surfaces:
|
||||
|
||||
Auth-only channels can usually stop at the default path: core handles approvals and the plugin just exposes outbound/auth capabilities. Native approval channels such as Matrix, Slack, Telegram, and custom chat transports should use the shared native helpers instead of rolling their own approval lifecycle.
|
||||
|
||||
## Inbound mention policy
|
||||
|
||||
Keep inbound mention handling split in two layers:
|
||||
|
||||
- plugin-owned evidence gathering
|
||||
- shared policy evaluation
|
||||
|
||||
Use `openclaw/plugin-sdk/channel-inbound` for the shared layer.
|
||||
|
||||
Good fit for plugin-local logic:
|
||||
|
||||
- reply-to-bot detection
|
||||
- quoted-bot detection
|
||||
- thread-participation checks
|
||||
- service/system-message exclusions
|
||||
- platform-native caches needed to prove bot participation
|
||||
|
||||
Good fit for the shared helper:
|
||||
|
||||
- `requireMention`
|
||||
- explicit mention result
|
||||
- implicit mention allowlist
|
||||
- command bypass
|
||||
- final skip decision
|
||||
|
||||
Preferred flow:
|
||||
|
||||
1. Compute local mention facts.
|
||||
2. Pass those facts into `resolveInboundMentionDecision({ facts, policy })`.
|
||||
3. Use `decision.effectiveWasMentioned`, `decision.shouldBypassMention`, and `decision.shouldSkip` in your inbound gate.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
implicitMentionKindWhen,
|
||||
matchesMentionWithExplicit,
|
||||
resolveInboundMentionDecision,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
|
||||
const mentionMatch = matchesMentionWithExplicit(text, {
|
||||
mentionRegexes,
|
||||
mentionPatterns,
|
||||
});
|
||||
|
||||
const facts = {
|
||||
canDetectMention: true,
|
||||
wasMentioned: mentionMatch.matched,
|
||||
hasAnyMention: mentionMatch.hasExplicitMention,
|
||||
implicitMentionKinds: [
|
||||
...implicitMentionKindWhen("reply_to_bot", isReplyToBot),
|
||||
...implicitMentionKindWhen("quoted_bot", isQuoteOfBot),
|
||||
],
|
||||
};
|
||||
|
||||
const decision = resolveInboundMentionDecision({
|
||||
facts,
|
||||
policy: {
|
||||
isGroup,
|
||||
requireMention,
|
||||
allowedImplicitMentionKinds: requireExplicitMention ? [] : ["reply_to_bot", "quoted_bot"],
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
commandAuthorized,
|
||||
},
|
||||
});
|
||||
|
||||
if (decision.shouldSkip) return;
|
||||
```
|
||||
|
||||
`api.runtime.channel.mentions` exposes the same shared mention helpers for
|
||||
bundled channel plugins that already depend on runtime injection:
|
||||
|
||||
- `buildMentionRegexes`
|
||||
- `matchesMentionPatterns`
|
||||
- `matchesMentionWithExplicit`
|
||||
- `implicitMentionKindWhen`
|
||||
- `resolveInboundMentionDecision`
|
||||
|
||||
The older `resolveMentionGating*` helpers remain on
|
||||
`openclaw/plugin-sdk/channel-inbound` as compatibility exports only. New code
|
||||
should use `resolveInboundMentionDecision({ facts, policy })`.
|
||||
|
||||
## Walkthrough
|
||||
|
||||
<Steps>
|
||||
|
||||
@@ -67,6 +67,32 @@ Current bundled provider examples:
|
||||
## How to migrate
|
||||
|
||||
<Steps>
|
||||
<Step title="Migrate approval-native handlers to capability facts">
|
||||
Approval-capable channel plugins now expose native approval behavior through
|
||||
`approvalCapability.nativeRuntime` plus the shared runtime-context registry.
|
||||
|
||||
Key changes:
|
||||
|
||||
- Replace `approvalCapability.handler.loadRuntime(...)` with
|
||||
`approvalCapability.nativeRuntime`
|
||||
- Move approval-specific auth/delivery off legacy `plugin.auth` /
|
||||
`plugin.approvals` wiring and onto `approvalCapability`
|
||||
- `ChannelPlugin.approvals` has been removed from the public channel-plugin
|
||||
contract; move delivery/native/render fields onto `approvalCapability`
|
||||
- `plugin.auth` remains for channel login/logout flows only; approval auth
|
||||
hooks there are no longer read by core
|
||||
- Register channel-owned runtime objects such as clients, tokens, or Bolt
|
||||
apps through `openclaw/plugin-sdk/channel-runtime-context`
|
||||
- Do not send plugin-owned reroute notices from native approval handlers;
|
||||
core now owns routed-elsewhere notices from actual delivery results
|
||||
- When passing `channelRuntime` into `createChannelManager(...)`, provide a
|
||||
real `createPluginRuntime().channel` surface. Partial stubs are rejected.
|
||||
|
||||
See `/plugins/sdk-channel-plugins` for the current approval capability
|
||||
layout.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Audit Windows wrapper fallback behavior">
|
||||
If your plugin uses `openclaw/plugin-sdk/windows-spawn`, unresolved Windows
|
||||
`.cmd`/`.bat` wrappers now fail closed unless you explicitly pass
|
||||
@@ -201,8 +227,12 @@ Current bundled provider examples:
|
||||
| `plugin-sdk/approval-auth-runtime` | Approval auth helpers | Approver resolution, same-chat action auth |
|
||||
| `plugin-sdk/approval-client-runtime` | Approval client helpers | Native exec approval profile/filter helpers |
|
||||
| `plugin-sdk/approval-delivery-runtime` | Approval delivery helpers | Native approval capability/delivery adapters |
|
||||
| `plugin-sdk/approval-gateway-runtime` | Approval gateway helpers | Shared approval gateway-resolution helper |
|
||||
| `plugin-sdk/approval-handler-adapter-runtime` | Approval adapter helpers | Lightweight native approval adapter loading helpers for hot channel entrypoints |
|
||||
| `plugin-sdk/approval-handler-runtime` | Approval handler helpers | Broader approval handler runtime helpers; prefer the narrower adapter/gateway seams when they are enough |
|
||||
| `plugin-sdk/approval-native-runtime` | Approval target helpers | Native approval target/account binding helpers |
|
||||
| `plugin-sdk/approval-reply-runtime` | Approval reply helpers | Exec/plugin approval reply payload helpers |
|
||||
| `plugin-sdk/channel-runtime-context` | Channel runtime-context helpers | Generic channel runtime-context register/get/watch helpers |
|
||||
| `plugin-sdk/security-runtime` | Security helpers | Shared trust, DM gating, external-content, and secret-collection helpers |
|
||||
| `plugin-sdk/ssrf-policy` | SSRF policy helpers | Host allowlist and private-network policy helpers |
|
||||
| `plugin-sdk/ssrf-runtime` | SSRF runtime helpers | Pinned-dispatcher, guarded fetch, SSRF policy helpers |
|
||||
@@ -249,7 +279,8 @@ Current bundled provider examples:
|
||||
| `plugin-sdk/provider-onboard` | Provider onboarding patches | Onboarding config helpers |
|
||||
| `plugin-sdk/provider-http` | Provider HTTP helpers | Generic provider HTTP/endpoint capability helpers |
|
||||
| `plugin-sdk/provider-web-fetch` | Provider web-fetch helpers | Web-fetch provider registration/cache helpers |
|
||||
| `plugin-sdk/provider-web-search` | Provider web-search helpers | Web-search provider registration/cache/config helpers |
|
||||
| `plugin-sdk/provider-web-search-contract` | Provider web-search contract helpers | Narrow web-search config/credential contract helpers such as `enablePluginInConfig`, `resolveProviderWebSearchPluginConfig`, and scoped credential setters/getters |
|
||||
| `plugin-sdk/provider-web-search` | Provider web-search helpers | Web-search provider registration/cache/runtime helpers |
|
||||
| `plugin-sdk/provider-tools` | Provider tool/schema compat helpers | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, Gemini schema cleanup + diagnostics, and xAI compat helpers such as `resolveXaiModelCompatPatch` / `applyXaiModelCompat` |
|
||||
| `plugin-sdk/provider-usage` | Provider usage helpers | `fetchClaudeUsage`, `fetchGeminiUsage`, `fetchGithubCopilotUsage`, and other provider usage helpers |
|
||||
| `plugin-sdk/provider-stream` | Provider stream wrapper helpers | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, and shared Anthropic/Bedrock/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |
|
||||
|
||||
@@ -108,7 +108,7 @@ explicitly promotes one as public.
|
||||
| `plugin-sdk/group-access` | Shared group-access decision helpers |
|
||||
| `plugin-sdk/direct-dm` | Shared direct-DM auth/guard helpers |
|
||||
| `plugin-sdk/interactive-runtime` | Interactive reply payload normalization/reduction helpers |
|
||||
| `plugin-sdk/channel-inbound` | Debounce, mention matching, envelope helpers |
|
||||
| `plugin-sdk/channel-inbound` | Inbound debounce, mention matching, mention-policy helpers, and envelope helpers |
|
||||
| `plugin-sdk/channel-send-result` | Reply result types |
|
||||
| `plugin-sdk/channel-actions` | `createMessageToolButtonsSchema`, `createMessageToolCardSchema` |
|
||||
| `plugin-sdk/channel-targets` | Target parsing/matching helpers |
|
||||
@@ -133,8 +133,10 @@ explicitly promotes one as public.
|
||||
| `plugin-sdk/provider-model-shared` | `ProviderReplayFamily`, `buildProviderReplayFamilyHooks`, `normalizeModelCompat`, shared replay-policy builders, provider-endpoint helpers, and model-id normalization helpers such as `normalizeNativeXaiModelId` |
|
||||
| `plugin-sdk/provider-catalog-shared` | `findCatalogTemplate`, `buildSingleProviderApiKeyCatalog`, `supportsNativeStreamingUsageCompat`, `applyProviderNativeStreamingUsageCompat` |
|
||||
| `plugin-sdk/provider-http` | Generic provider HTTP/endpoint capability helpers |
|
||||
| `plugin-sdk/provider-web-fetch-contract` | Narrow web-fetch config/selection contract helpers such as `enablePluginInConfig` and `WebFetchProviderPlugin` |
|
||||
| `plugin-sdk/provider-web-fetch` | Web-fetch provider registration/cache helpers |
|
||||
| `plugin-sdk/provider-web-search` | Web-search provider registration/cache/config helpers |
|
||||
| `plugin-sdk/provider-web-search-contract` | Narrow web-search config/credential contract helpers such as `enablePluginInConfig`, `resolveProviderWebSearchPluginConfig`, and scoped credential setters/getters |
|
||||
| `plugin-sdk/provider-web-search` | Web-search provider registration/cache/runtime helpers |
|
||||
| `plugin-sdk/provider-tools` | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, Gemini schema cleanup + diagnostics, and xAI compat helpers such as `resolveXaiModelCompatPatch` / `applyXaiModelCompat` |
|
||||
| `plugin-sdk/provider-usage` | `fetchClaudeUsage` and similar |
|
||||
| `plugin-sdk/provider-stream` | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, and shared Anthropic/Bedrock/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |
|
||||
@@ -149,6 +151,9 @@ explicitly promotes one as public.
|
||||
| `plugin-sdk/approval-auth-runtime` | Approver resolution and same-chat action-auth helpers |
|
||||
| `plugin-sdk/approval-client-runtime` | Native exec approval profile/filter helpers |
|
||||
| `plugin-sdk/approval-delivery-runtime` | Native approval capability/delivery adapters |
|
||||
| `plugin-sdk/approval-gateway-runtime` | Shared approval gateway-resolution helper |
|
||||
| `plugin-sdk/approval-handler-adapter-runtime` | Lightweight native approval adapter loading helpers for hot channel entrypoints |
|
||||
| `plugin-sdk/approval-handler-runtime` | Broader approval handler runtime helpers; prefer the narrower adapter/gateway seams when they are enough |
|
||||
| `plugin-sdk/approval-native-runtime` | Native approval target + account-binding helpers |
|
||||
| `plugin-sdk/approval-reply-runtime` | Exec/plugin approval reply payload helpers |
|
||||
| `plugin-sdk/command-auth-native` | Native command auth + native session-target helpers |
|
||||
@@ -156,6 +161,7 @@ explicitly promotes one as public.
|
||||
| `plugin-sdk/command-surface` | Command-body normalization and command-surface helpers |
|
||||
| `plugin-sdk/allow-from` | `formatAllowFromLowercase` |
|
||||
| `plugin-sdk/channel-secret-runtime` | Narrow secret-contract collection helpers for channel/plugin secret surfaces |
|
||||
| `plugin-sdk/secret-ref-runtime` | Narrow `coerceSecretRef` and SecretRef typing helpers for secret-contract/config parsing |
|
||||
| `plugin-sdk/security-runtime` | Shared trust, DM gating, external-content, and secret-collection helpers |
|
||||
| `plugin-sdk/ssrf-policy` | Host allowlist and private-network SSRF policy helpers |
|
||||
| `plugin-sdk/ssrf-runtime` | Pinned-dispatcher, SSRF-guarded fetch, and SSRF policy helpers |
|
||||
@@ -169,6 +175,7 @@ explicitly promotes one as public.
|
||||
| --- | --- |
|
||||
| `plugin-sdk/runtime` | Broad runtime/logging/backup/plugin-install helpers |
|
||||
| `plugin-sdk/runtime-env` | Narrow runtime env, logger, timeout, retry, and backoff helpers |
|
||||
| `plugin-sdk/channel-runtime-context` | Generic channel runtime-context registration and lookup helpers |
|
||||
| `plugin-sdk/runtime-store` | `createPluginRuntimeStore` |
|
||||
| `plugin-sdk/plugin-runtime` | Shared plugin command/hook/http/interactive helpers |
|
||||
| `plugin-sdk/hook-runtime` | Shared webhook/internal hook pipeline helpers |
|
||||
@@ -383,6 +390,7 @@ AI CLI backend such as `codex-cli`.
|
||||
| Method | What it registers |
|
||||
| ------------------------------------------ | ------------------------------------- |
|
||||
| `api.registerContextEngine(id, factory)` | Context engine (one active at a time) |
|
||||
| `api.registerMemoryCapability(capability)` | Unified memory capability |
|
||||
| `api.registerMemoryPromptSection(builder)` | Memory prompt section builder |
|
||||
| `api.registerMemoryFlushPlan(resolver)` | Memory flush plan resolver |
|
||||
| `api.registerMemoryRuntime(runtime)` | Memory runtime adapter |
|
||||
@@ -393,8 +401,13 @@ AI CLI backend such as `codex-cli`.
|
||||
| ---------------------------------------------- | ---------------------------------------------- |
|
||||
| `api.registerMemoryEmbeddingProvider(adapter)` | Memory embedding adapter for the active plugin |
|
||||
|
||||
- `registerMemoryCapability` is the preferred exclusive memory-plugin API.
|
||||
- `registerMemoryCapability` may also expose `publicArtifacts.listArtifacts(...)`
|
||||
so companion plugins can consume exported memory artifacts through
|
||||
`openclaw/plugin-sdk/memory-host-core` instead of reaching into a specific
|
||||
memory plugin's private layout.
|
||||
- `registerMemoryPromptSection`, `registerMemoryFlushPlan`, and
|
||||
`registerMemoryRuntime` are exclusive to memory plugins.
|
||||
`registerMemoryRuntime` are legacy-compatible exclusive memory-plugin APIs.
|
||||
- `registerMemoryEmbeddingProvider` lets the active memory plugin register one
|
||||
or more embedding adapter ids (for example `openai`, `gemini`, or a custom
|
||||
plugin-defined id).
|
||||
|
||||
@@ -330,6 +330,46 @@ api.runtime.tools.registerMemoryCli(/* ... */);
|
||||
|
||||
Channel-specific runtime helpers (available when a channel plugin is loaded).
|
||||
|
||||
`api.runtime.channel.mentions` is the shared inbound mention-policy surface for
|
||||
bundled channel plugins that use runtime injection:
|
||||
|
||||
```typescript
|
||||
const mentionMatch = api.runtime.channel.mentions.matchesMentionWithExplicit(text, {
|
||||
mentionRegexes,
|
||||
mentionPatterns,
|
||||
});
|
||||
|
||||
const decision = api.runtime.channel.mentions.resolveInboundMentionDecision({
|
||||
facts: {
|
||||
canDetectMention: true,
|
||||
wasMentioned: mentionMatch.matched,
|
||||
implicitMentionKinds: api.runtime.channel.mentions.implicitMentionKindWhen(
|
||||
"reply_to_bot",
|
||||
isReplyToBot,
|
||||
),
|
||||
},
|
||||
policy: {
|
||||
isGroup,
|
||||
requireMention,
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
commandAuthorized,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Available mention helpers:
|
||||
|
||||
- `buildMentionRegexes`
|
||||
- `matchesMentionPatterns`
|
||||
- `matchesMentionWithExplicit`
|
||||
- `implicitMentionKindWhen`
|
||||
- `resolveInboundMentionDecision`
|
||||
|
||||
`api.runtime.channel.mentions` intentionally does not expose the older
|
||||
`resolveMentionGating*` compatibility helpers. Prefer the normalized
|
||||
`{ facts, policy }` path.
|
||||
|
||||
## Storing runtime references
|
||||
|
||||
Use `createPluginRuntimeStore` to store the runtime reference for use outside
|
||||
|
||||
@@ -52,7 +52,7 @@ An alternative provider `google-gemini-cli` uses PKCE OAuth instead of an API
|
||||
key. This is an unofficial integration; some users report account
|
||||
restrictions. Use at your own risk.
|
||||
|
||||
- Default model: `google-gemini-cli/gemini-3.1-pro-preview`
|
||||
- Default model: `google-gemini-cli/gemini-3-flash-preview`
|
||||
- Alias: `gemini-cli`
|
||||
- Install prerequisite: local Gemini CLI available as `gemini`
|
||||
- Homebrew: `brew install gemini-cli`
|
||||
|
||||
@@ -42,6 +42,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
|
||||
- [Google (Gemini)](/providers/google)
|
||||
- [Groq (LPU inference)](/providers/groq)
|
||||
- [Hugging Face (Inference)](/providers/huggingface)
|
||||
- [inferrs (local models)](/providers/inferrs)
|
||||
- [Kilocode](/providers/kilocode)
|
||||
- [LiteLLM (unified gateway)](/providers/litellm)
|
||||
- [MiniMax](/providers/minimax)
|
||||
|
||||
173
docs/providers/inferrs.md
Normal file
173
docs/providers/inferrs.md
Normal file
@@ -0,0 +1,173 @@
|
||||
---
|
||||
summary: "Run OpenClaw through inferrs (OpenAI-compatible local server)"
|
||||
read_when:
|
||||
- You want to run OpenClaw against a local inferrs server
|
||||
- You are serving Gemma or another model through inferrs
|
||||
- You need the exact OpenClaw compat flags for inferrs
|
||||
title: "inferrs"
|
||||
---
|
||||
|
||||
# inferrs
|
||||
|
||||
[inferrs](https://github.com/ericcurtin/inferrs) can serve local models behind an
|
||||
OpenAI-compatible `/v1` API. OpenClaw works with `inferrs` through the generic
|
||||
`openai-completions` path.
|
||||
|
||||
`inferrs` is currently best treated as a custom self-hosted OpenAI-compatible
|
||||
backend, not a dedicated OpenClaw provider plugin.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Start `inferrs` with a model.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
inferrs serve gg-hf-gg/gemma-4-E2B-it \
|
||||
--host 127.0.0.1 \
|
||||
--port 8080 \
|
||||
--device metal
|
||||
```
|
||||
|
||||
2. Verify the server is reachable.
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8080/health
|
||||
curl http://127.0.0.1:8080/v1/models
|
||||
```
|
||||
|
||||
3. Add an explicit OpenClaw provider entry and point your default model at it.
|
||||
|
||||
## Full config example
|
||||
|
||||
This example uses Gemma 4 on a local `inferrs` server.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "inferrs/gg-hf-gg/gemma-4-E2B-it" },
|
||||
models: {
|
||||
"inferrs/gg-hf-gg/gemma-4-E2B-it": {
|
||||
alias: "Gemma 4 (inferrs)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
inferrs: {
|
||||
baseUrl: "http://127.0.0.1:8080/v1",
|
||||
apiKey: "inferrs-local",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gg-hf-gg/gemma-4-E2B-it",
|
||||
name: "Gemma 4 E2B (inferrs)",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 131072,
|
||||
maxTokens: 4096,
|
||||
compat: {
|
||||
requiresStringContent: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Why `requiresStringContent` matters
|
||||
|
||||
Some `inferrs` Chat Completions routes accept only string
|
||||
`messages[].content`, not structured content-part arrays.
|
||||
|
||||
If OpenClaw runs fail with an error like:
|
||||
|
||||
```text
|
||||
messages[1].content: invalid type: sequence, expected a string
|
||||
```
|
||||
|
||||
set:
|
||||
|
||||
```json5
|
||||
compat: {
|
||||
requiresStringContent: true
|
||||
}
|
||||
```
|
||||
|
||||
OpenClaw will flatten pure text content parts into plain strings before sending
|
||||
the request.
|
||||
|
||||
## Gemma and tool-schema caveat
|
||||
|
||||
Some current `inferrs` + Gemma combinations accept small direct
|
||||
`/v1/chat/completions` requests but still fail on full OpenClaw agent-runtime
|
||||
turns.
|
||||
|
||||
If that happens, try this first:
|
||||
|
||||
```json5
|
||||
compat: {
|
||||
requiresStringContent: true,
|
||||
supportsTools: false
|
||||
}
|
||||
```
|
||||
|
||||
That disables OpenClaw's tool schema surface for the model and can reduce prompt
|
||||
pressure on stricter local backends.
|
||||
|
||||
If tiny direct requests still work but normal OpenClaw agent turns continue to
|
||||
crash inside `inferrs`, the remaining issue is usually upstream model/server
|
||||
behavior rather than OpenClaw's transport layer.
|
||||
|
||||
## Manual smoke test
|
||||
|
||||
Once configured, test both layers:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8080/v1/chat/completions \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"model":"gg-hf-gg/gemma-4-E2B-it","messages":[{"role":"user","content":"What is 2 + 2?"}],"stream":false}'
|
||||
|
||||
openclaw infer model run \
|
||||
--model inferrs/gg-hf-gg/gemma-4-E2B-it \
|
||||
--prompt "What is 2 + 2? Reply with one short sentence." \
|
||||
--json
|
||||
```
|
||||
|
||||
If the first command works but the second fails, use the troubleshooting notes
|
||||
below.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `curl /v1/models` fails: `inferrs` is not running, not reachable, or not
|
||||
bound to the expected host/port.
|
||||
- `messages[].content ... expected a string`: set
|
||||
`compat.requiresStringContent: true`.
|
||||
- Direct tiny `/v1/chat/completions` calls pass, but `openclaw infer model run`
|
||||
fails: try `compat.supportsTools: false`.
|
||||
- OpenClaw no longer gets schema errors, but `inferrs` still crashes on larger
|
||||
agent turns: treat it as an upstream `inferrs` or model limitation and reduce
|
||||
prompt pressure or switch local backend/model.
|
||||
|
||||
## Proxy-style behavior
|
||||
|
||||
`inferrs` is treated as a proxy-style OpenAI-compatible `/v1` backend, not a
|
||||
native OpenAI endpoint.
|
||||
|
||||
- native OpenAI-only request shaping does not apply here
|
||||
- no `service_tier`, no Responses `store`, no prompt-cache hints, and no
|
||||
OpenAI reasoning-compat payload shaping
|
||||
- hidden OpenClaw attribution headers (`originator`, `version`, `User-Agent`)
|
||||
are not injected on custom `inferrs` base URLs
|
||||
|
||||
## See also
|
||||
|
||||
- [Local models](/gateway/local-models)
|
||||
- [Gateway troubleshooting](/gateway/troubleshooting#local-openai-compatible-backend-passes-direct-probes-but-agent-runs-fail)
|
||||
- [Model providers](/concepts/model-providers)
|
||||
@@ -33,15 +33,15 @@ openclaw onboard --mistral-api-key "$MISTRAL_API_KEY"
|
||||
|
||||
OpenClaw currently ships this bundled Mistral catalog:
|
||||
|
||||
| Model ref | Input | Context | Max output | Notes |
|
||||
| -------------------------------- | ----------- | ------- | ---------- | ------------------------ |
|
||||
| `mistral/mistral-large-latest` | text, image | 262,144 | 16,384 | Default model |
|
||||
| `mistral/mistral-medium-2508` | text, image | 262,144 | 8,192 | Mistral Medium 3.1 |
|
||||
| `mistral/mistral-small-latest` | text, image | 128,000 | 16,384 | Smaller multimodal model |
|
||||
| `mistral/pixtral-large-latest` | text, image | 128,000 | 32,768 | Pixtral |
|
||||
| `mistral/codestral-latest` | text | 256,000 | 4,096 | Coding |
|
||||
| `mistral/devstral-medium-latest` | text | 262,144 | 32,768 | Devstral 2 |
|
||||
| `mistral/magistral-small` | text | 128,000 | 40,000 | Reasoning-enabled |
|
||||
| Model ref | Input | Context | Max output | Notes |
|
||||
| -------------------------------- | ----------- | ------- | ---------- | ---------------------------------------------------------------- |
|
||||
| `mistral/mistral-large-latest` | text, image | 262,144 | 16,384 | Default model |
|
||||
| `mistral/mistral-medium-2508` | text, image | 262,144 | 8,192 | Mistral Medium 3.1 |
|
||||
| `mistral/mistral-small-latest` | text, image | 128,000 | 16,384 | Mistral Small 4; adjustable reasoning via API `reasoning_effort` |
|
||||
| `mistral/pixtral-large-latest` | text, image | 128,000 | 32,768 | Pixtral |
|
||||
| `mistral/codestral-latest` | text | 256,000 | 4,096 | Coding |
|
||||
| `mistral/devstral-medium-latest` | text | 262,144 | 32,768 | Devstral 2 |
|
||||
| `mistral/magistral-small` | text | 128,000 | 40,000 | Reasoning-enabled |
|
||||
|
||||
## Config snippet (audio transcription with Voxtral)
|
||||
|
||||
@@ -58,6 +58,17 @@ OpenClaw currently ships this bundled Mistral catalog:
|
||||
}
|
||||
```
|
||||
|
||||
## Adjustable reasoning (`mistral-small-latest`)
|
||||
|
||||
`mistral/mistral-small-latest` maps to Mistral Small 4 and supports [adjustable reasoning](https://docs.mistral.ai/capabilities/reasoning/adjustable) on the Chat Completions API via `reasoning_effort` (`none` minimizes extra thinking in the output; `high` surfaces full thinking traces before the final answer).
|
||||
|
||||
OpenClaw maps the session **thinking** level to Mistral’s API:
|
||||
|
||||
- **off** / **minimal** → `none`
|
||||
- **low** / **medium** / **high** / **xhigh** / **adaptive** → `high`
|
||||
|
||||
Other bundled Mistral catalog models do not use this parameter; keep using `magistral-*` models when you want Mistral’s native reasoning-first behavior.
|
||||
|
||||
## Notes
|
||||
|
||||
- Mistral auth uses `MISTRAL_API_KEY`.
|
||||
|
||||
@@ -54,7 +54,7 @@ model as `provider/model`.
|
||||
|
||||
- `anthropic-vertex` - implicit Anthropic on Google Vertex support when Vertex credentials are available; no separate onboarding auth choice
|
||||
- `copilot-proxy` - local VS Code Copilot Proxy bridge; use `openclaw onboard --auth-choice copilot-proxy`
|
||||
- `google-gemini-cli` - unofficial Gemini CLI OAuth flow; requires a local `gemini` install (`brew install gemini-cli` or `npm install -g @google/gemini-cli`); default model `google-gemini-cli/gemini-3.1-pro-preview`; use `openclaw onboard --auth-choice google-gemini-cli` or `openclaw models auth login --provider google-gemini-cli --set-default`
|
||||
- `google-gemini-cli` - unofficial Gemini CLI OAuth flow; requires a local `gemini` install (`brew install gemini-cli` or `npm install -g @google/gemini-cli`); default model `google-gemini-cli/gemini-3-flash-preview`; use `openclaw onboard --auth-choice google-gemini-cli` or `openclaw models auth login --provider google-gemini-cli --set-default`
|
||||
|
||||
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
|
||||
see [Model providers](/concepts/model-providers).
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
summary: "Use NVIDIA's OpenAI-compatible API in OpenClaw"
|
||||
read_when:
|
||||
- You want to use NVIDIA models in OpenClaw
|
||||
- You want to use open models in OpenClaw for free
|
||||
- You need NVIDIA_API_KEY setup
|
||||
title: "NVIDIA"
|
||||
---
|
||||
|
||||
# NVIDIA
|
||||
|
||||
NVIDIA provides an OpenAI-compatible API at `https://integrate.api.nvidia.com/v1` for Nemotron and NeMo models. Authenticate with an API key from [NVIDIA NGC](https://catalog.ngc.nvidia.com/).
|
||||
NVIDIA provides an OpenAI-compatible API at `https://integrate.api.nvidia.com/v1` for open models for free. Authenticate with an API key from [build.nvidia.com](https://build.nvidia.com/settings/api-keys).
|
||||
|
||||
## CLI setup
|
||||
|
||||
@@ -17,7 +17,7 @@ Export the key once, then run onboarding and set an NVIDIA model:
|
||||
```bash
|
||||
export NVIDIA_API_KEY="nvapi-..."
|
||||
openclaw onboard --auth-choice skip
|
||||
openclaw models set nvidia/nvidia/llama-3.1-nemotron-70b-instruct
|
||||
openclaw models set nvidia/nvidia/nemotron-3-super-120b-a12b
|
||||
```
|
||||
|
||||
If you still pass `--token`, remember it lands in shell history and `ps` output; prefer the env var when possible.
|
||||
@@ -37,7 +37,7 @@ If you still pass `--token`, remember it lands in shell history and `ps` output;
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "nvidia/nvidia/llama-3.1-nemotron-70b-instruct" },
|
||||
model: { primary: "nvidia/nvidia/nemotron-3-super-120b-a12b" },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -45,14 +45,15 @@ If you still pass `--token`, remember it lands in shell history and `ps` output;
|
||||
|
||||
## Model IDs
|
||||
|
||||
| Model ref | Name | Context | Max output |
|
||||
| ---------------------------------------------------- | ---------------------------------------- | ------- | ---------- |
|
||||
| `nvidia/nvidia/llama-3.1-nemotron-70b-instruct` | NVIDIA Llama 3.1 Nemotron 70B Instruct | 131,072 | 4,096 |
|
||||
| `nvidia/meta/llama-3.3-70b-instruct` | Meta Llama 3.3 70B Instruct | 131,072 | 4,096 |
|
||||
| `nvidia/nvidia/mistral-nemo-minitron-8b-8k-instruct` | NVIDIA Mistral NeMo Minitron 8B Instruct | 8,192 | 2,048 |
|
||||
| Model ref | Name | Context | Max output |
|
||||
| ------------------------------------------ | ---------------------------- | ------- | ---------- |
|
||||
| `nvidia/nvidia/nemotron-3-super-120b-a12b` | NVIDIA Nemotron 3 Super 120B | 262,144 | 8,192 |
|
||||
| `nvidia/moonshotai/kimi-k2.5` | Kimi K2.5 | 262,144 | 8,192 |
|
||||
| `nvidia/minimaxai/minimax-m2.5` | Minimax M2.5 | 196,608 | 8,192 |
|
||||
| `nvidia/z-ai/glm5` | GLM 5 | 202,752 | 8,192 |
|
||||
|
||||
## Notes
|
||||
|
||||
- OpenAI-compatible `/v1` endpoint; use an API key from NVIDIA NGC.
|
||||
- OpenAI-compatible `/v1` endpoint; use an API key from [build.nvidia.com](https://build.nvidia.com/).
|
||||
- Provider auto-enables when `NVIDIA_API_KEY` is set.
|
||||
- The bundled catalog is static; costs default to `0` in source.
|
||||
|
||||
@@ -57,7 +57,7 @@ openclaw onboard --non-interactive \
|
||||
2. Pull a local model if you want local inference:
|
||||
|
||||
```bash
|
||||
ollama pull glm-4.7-flash
|
||||
ollama pull gemma4
|
||||
# or
|
||||
ollama pull gpt-oss:20b
|
||||
# or
|
||||
@@ -78,12 +78,12 @@ openclaw onboard
|
||||
|
||||
- `Local`: local models only
|
||||
- `Cloud + Local`: local models plus cloud models
|
||||
- Cloud models such as `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, and `glm-5:cloud` do **not** require a local `ollama pull`
|
||||
- Cloud models such as `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, and `glm-5.1:cloud` do **not** require a local `ollama pull`
|
||||
|
||||
OpenClaw currently suggests:
|
||||
|
||||
- local default: `glm-4.7-flash`
|
||||
- cloud defaults: `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, `glm-5:cloud`
|
||||
- local default: `gemma4`
|
||||
- cloud defaults: `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, `glm-5.1:cloud`
|
||||
|
||||
5. If you prefer manual setup, enable Ollama for OpenClaw directly (any value works; Ollama doesn't require a real key):
|
||||
|
||||
@@ -99,7 +99,7 @@ openclaw config set models.providers.ollama.apiKey "ollama-local"
|
||||
|
||||
```bash
|
||||
openclaw models list
|
||||
openclaw models set ollama/glm-4.7-flash
|
||||
openclaw models set ollama/gemma4
|
||||
```
|
||||
|
||||
7. Or set the default in config:
|
||||
@@ -108,7 +108,7 @@ openclaw models set ollama/glm-4.7-flash
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "ollama/glm-4.7-flash" },
|
||||
model: { primary: "ollama/gemma4" },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -229,7 +229,7 @@ Once configured, all your Ollama models are available:
|
||||
|
||||
## Cloud models
|
||||
|
||||
Cloud models let you run cloud-hosted models (for example `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, `glm-5:cloud`) alongside your local models.
|
||||
Cloud models let you run cloud-hosted models (for example `kimi-k2.5:cloud`, `minimax-m2.7:cloud`, `glm-5.1:cloud`) alongside your local models.
|
||||
|
||||
To use cloud models, select **Cloud + Local** mode during setup. The wizard checks whether you are signed in and opens a browser sign-in flow when needed. If authentication cannot be verified, the wizard falls back to local model defaults.
|
||||
|
||||
@@ -355,7 +355,7 @@ To add models:
|
||||
|
||||
```bash
|
||||
ollama list # See what's installed
|
||||
ollama pull glm-4.7-flash
|
||||
ollama pull gemma4
|
||||
ollama pull gpt-oss:20b
|
||||
ollama pull llama3.3 # Or another model
|
||||
```
|
||||
|
||||
@@ -17,10 +17,22 @@ conceptual overviews, see:
|
||||
- [Builtin Engine](/concepts/memory-builtin) -- default SQLite backend
|
||||
- [QMD Engine](/concepts/memory-qmd) -- local-first sidecar
|
||||
- [Memory Search](/concepts/memory-search) -- search pipeline and tuning
|
||||
- [Active Memory](/concepts/active-memory) -- enabling the memory sidecar for interactive sessions
|
||||
|
||||
All memory search settings live under `agents.defaults.memorySearch` in
|
||||
`openclaw.json` unless noted otherwise.
|
||||
|
||||
If you are looking for the **active memory** feature toggle and sidecar config,
|
||||
that lives under `plugins.entries.active-memory` instead of `memorySearch`.
|
||||
|
||||
Active memory uses a two-gate model:
|
||||
|
||||
1. the plugin must be enabled and target the current agent id
|
||||
2. the request must be an eligible interactive persistent chat session
|
||||
|
||||
See [Active Memory](/concepts/active-memory) for the activation model,
|
||||
plugin-owned config, transcript persistence, and safe rollout pattern.
|
||||
|
||||
---
|
||||
|
||||
## Provider selection
|
||||
|
||||
@@ -275,6 +275,21 @@ Implementation: `ensurePiCompactionReserveTokens()` in `src/agents/pi-settings.t
|
||||
|
||||
---
|
||||
|
||||
## Pluggable compaction providers
|
||||
|
||||
Plugins can register a compaction provider via `registerCompactionProvider()` on the plugin API. When `agents.defaults.compaction.provider` is set to a registered provider id, the safeguard extension delegates summarization to that provider instead of the built-in `summarizeInStages` pipeline.
|
||||
|
||||
- `provider`: id of a registered compaction provider plugin. Leave unset for default LLM summarization.
|
||||
- Setting a `provider` forces `mode: "safeguard"`.
|
||||
- Providers receive the same compaction instructions and identifier-preservation policy as the built-in path.
|
||||
- The safeguard still preserves recent-turn and split-turn suffix context after provider output.
|
||||
- If the provider fails or returns an empty result, OpenClaw falls back to built-in LLM summarization automatically.
|
||||
- Abort/timeout signals are re-thrown (not swallowed) to respect caller cancellation.
|
||||
|
||||
Source: `src/plugins/compaction-provider.ts`, `src/agents/pi-hooks/compaction-safeguard.ts`.
|
||||
|
||||
---
|
||||
|
||||
## User-visible surfaces
|
||||
|
||||
You can observe compaction and session state via:
|
||||
|
||||
@@ -13,7 +13,7 @@ title: "Tests"
|
||||
- `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
|
||||
- `pnpm test:coverage:changed`: Runs unit coverage only for files changed since `origin/main`.
|
||||
- `pnpm test:changed`: expands changed git paths into scoped Vitest lanes when the diff only touches routable source/test files. Config/setup changes still fall back to the native root projects run so wiring edits rerun broadly when needed.
|
||||
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs now execute eight sequential shard configs (`vitest.full-core-unit-src.config.ts`, `vitest.full-core-unit-security.config.ts`, `vitest.full-core-unit-support.config.ts`, `vitest.full-core-contracts.config.ts`, `vitest.full-core-runtime.config.ts`, `vitest.full-agentic.config.ts`, `vitest.full-auto-reply.config.ts`, `vitest.full-extensions.config.ts`) instead of one giant root-project process.
|
||||
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs now execute eleven sequential shard configs (`vitest.full-core-unit-src.config.ts`, `vitest.full-core-unit-security.config.ts`, `vitest.full-core-unit-ui.config.ts`, `vitest.full-core-unit-support.config.ts`, `vitest.full-core-support-boundary.config.ts`, `vitest.full-core-contracts.config.ts`, `vitest.full-core-bundled.config.ts`, `vitest.full-core-runtime.config.ts`, `vitest.full-agentic.config.ts`, `vitest.full-auto-reply.config.ts`, `vitest.full-extensions.config.ts`) instead of one giant root-project process.
|
||||
- Selected `plugin-sdk` and `commands` test files now route through dedicated light lanes that keep only `test/setup.ts`, leaving runtime-heavy cases on their existing lanes.
|
||||
- Selected `plugin-sdk` and `commands` helper source files also map `pnpm test:changed` to explicit sibling tests in those light lanes, so small helper edits avoid rerunning the heavy runtime-backed suites.
|
||||
- `auto-reply` now also splits into three dedicated configs (`core`, `top-level`, `reply`) so the reply harness does not dominate the lighter top-level status/token/helper tests.
|
||||
|
||||
@@ -557,8 +557,8 @@ Shared behavior:
|
||||
- Slack approvers can be explicit (`execApprovals.approvers`) or inferred from `commands.ownerAllowFrom`
|
||||
- Slack native buttons preserve approval id kind, so `plugin:` ids can resolve plugin approvals
|
||||
without a second Slack-local fallback layer
|
||||
- Matrix native DM/channel routing is exec-only; Matrix plugin approvals stay on the shared
|
||||
same-chat `/approve` and optional `approvals.plugin` forwarding paths
|
||||
- Matrix native DM/channel routing and reaction shortcuts handle both exec and plugin approvals;
|
||||
plugin authorization still comes from `channels.matrix.dm.allowFrom`
|
||||
- the requester does not need to be an approver
|
||||
- the originating chat can approve directly with `/approve` when that chat already supports commands and replies
|
||||
- native Discord approval buttons route by approval id kind: `plugin:` ids go
|
||||
|
||||
@@ -68,7 +68,9 @@ Use `action: "list"` to inspect available providers and models at runtime:
|
||||
| `count` | number | Number of images to generate (1–4) |
|
||||
| `filename` | string | Output filename hint |
|
||||
|
||||
Not all providers support all parameters. The tool passes what each provider supports, ignores the rest, and reports dropped overrides in the tool result.
|
||||
Not all providers support all parameters. When a fallback provider supports a nearby geometry option instead of the exact requested one, OpenClaw remaps to the closest supported size, aspect ratio, or resolution before submission. Truly unsupported overrides are still reported in the tool result.
|
||||
|
||||
Tool results report the applied settings. When OpenClaw remaps geometry during provider fallback, the returned `size`, `aspectRatio`, and `resolution` values reflect what was actually sent, and `details.normalization` captures the requested-to-applied translation.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -104,6 +106,10 @@ Notes:
|
||||
|
||||
- Auto-detection is auth-aware. A provider default only enters the candidate list
|
||||
when OpenClaw can actually authenticate that provider.
|
||||
- Auto-detection is enabled by default. Set
|
||||
`agents.defaults.mediaGenerationAutoProviderFallback: false` if you want image
|
||||
generation to use only the explicit `model`, `primary`, and `fallbacks`
|
||||
entries.
|
||||
- Use `action: "list"` to inspect the currently registered providers, their
|
||||
default models, and auth env-var hints.
|
||||
|
||||
|
||||
@@ -131,8 +131,12 @@ Direct generation example:
|
||||
| `filename` | string | Output filename hint |
|
||||
|
||||
Not all providers support all parameters. OpenClaw still validates hard limits
|
||||
such as input counts before submission, but unsupported optional hints are
|
||||
ignored with a warning when the selected provider or model cannot honor them.
|
||||
such as input counts before submission. When a provider supports duration but
|
||||
uses a shorter maximum than the requested value, OpenClaw automatically clamps
|
||||
to the closest supported duration. Truly unsupported optional hints are ignored
|
||||
with a warning when the selected provider or model cannot honor them.
|
||||
|
||||
Tool results report the applied settings. When OpenClaw clamps duration during provider fallback, the returned `durationSeconds` reflects the submitted value and `details.normalization.durationSeconds` shows the requested-to-applied mapping.
|
||||
|
||||
## Async behavior for the shared provider-backed path
|
||||
|
||||
@@ -194,6 +198,10 @@ When generating music, OpenClaw tries providers in this order:
|
||||
If a provider fails, the next candidate is tried automatically. If all fail, the
|
||||
error includes details from each attempt.
|
||||
|
||||
Set `agents.defaults.mediaGenerationAutoProviderFallback: false` if you want
|
||||
music generation to use only the explicit `model`, `primary`, and `fallbacks`
|
||||
entries.
|
||||
|
||||
## Provider notes
|
||||
|
||||
- Google uses Lyria 3 batch generation. The current bundled flow supports
|
||||
|
||||
@@ -154,7 +154,9 @@ and the shared live sweep.
|
||||
| `model` | string | Provider/model override (e.g. `runway/gen4.5`) |
|
||||
| `filename` | string | Output filename hint |
|
||||
|
||||
Not all providers support all parameters. Unsupported overrides are ignored on a best-effort basis and reported as warnings in the tool result. Hard capability limits (such as too many reference inputs) fail before submission.
|
||||
Not all providers support all parameters. OpenClaw already normalizes duration to the closest provider-supported value, and it also remaps translated geometry hints such as size-to-aspect-ratio when a fallback provider exposes a different control surface. Truly unsupported overrides are ignored on a best-effort basis and reported as warnings in the tool result. Hard capability limits (such as too many reference inputs) fail before submission.
|
||||
|
||||
Tool results report the applied settings. When OpenClaw remaps duration or geometry during provider fallback, the returned `durationSeconds`, `size`, `aspectRatio`, and `resolution` values reflect what was submitted, and `details.normalization` captures the requested-to-applied translation.
|
||||
|
||||
Reference inputs also select the runtime mode:
|
||||
|
||||
@@ -182,6 +184,10 @@ When generating a video, OpenClaw resolves the model in this order:
|
||||
|
||||
If a provider fails, the next candidate is tried automatically. If all candidates fail, the error includes details from each attempt.
|
||||
|
||||
Set `agents.defaults.mediaGenerationAutoProviderFallback: false` if you want
|
||||
video generation to use only the explicit `model`, `primary`, and `fallbacks`
|
||||
entries.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
"description": "OpenClaw ACP runtime backend",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"acpx": "0.5.0"
|
||||
"acpx": "0.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "acpx",
|
||||
@@ -6,8 +7,7 @@ export default definePluginEntry({
|
||||
description: "Lightweight ACPX setup hooks",
|
||||
register(api) {
|
||||
api.registerAutoEnableProbe(({ config }) => {
|
||||
const backendRaw =
|
||||
typeof config.acp?.backend === "string" ? config.acp.backend.trim().toLowerCase() : "";
|
||||
const backendRaw = normalizeLowercaseStringOrEmpty(config.acp?.backend);
|
||||
const configured =
|
||||
config.acp?.enabled === true ||
|
||||
config.acp?.dispatch?.enabled === true ||
|
||||
|
||||
60
extensions/acpx/src/acpx-runtime-compat.d.ts
vendored
Normal file
60
extensions/acpx/src/acpx-runtime-compat.d.ts
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
declare module "acpx/runtime" {
|
||||
export const ACPX_BACKEND_ID: string;
|
||||
|
||||
export type AcpRuntimeDoctorReport = import("../runtime-api.js").AcpRuntimeDoctorReport;
|
||||
export type AcpRuntimeEnsureInput = import("../runtime-api.js").AcpRuntimeEnsureInput;
|
||||
export type AcpRuntimeEvent = import("../runtime-api.js").AcpRuntimeEvent;
|
||||
export type AcpRuntimeHandle = import("../runtime-api.js").AcpRuntimeHandle;
|
||||
export type AcpRuntimeCapabilities = import("../runtime-api.js").AcpRuntimeCapabilities;
|
||||
export type AcpRuntimeStatus = import("../runtime-api.js").AcpRuntimeStatus;
|
||||
export type AcpRuntimeTurnInput = import("../runtime-api.js").AcpRuntimeTurnInput;
|
||||
|
||||
export type AcpAgentRegistry = {
|
||||
resolve(agent: string): string | undefined;
|
||||
list(): string[];
|
||||
};
|
||||
|
||||
export type AcpSessionRecord = Record<string, unknown>;
|
||||
|
||||
export type AcpSessionStore = {
|
||||
load(sessionId: string): Promise<AcpSessionRecord | undefined>;
|
||||
save(record: AcpSessionRecord): Promise<void>;
|
||||
};
|
||||
|
||||
export type AcpRuntimeOptions = {
|
||||
cwd: string;
|
||||
sessionStore: AcpSessionStore;
|
||||
agentRegistry: AcpAgentRegistry;
|
||||
mcpServers?: unknown;
|
||||
permissionMode?: unknown;
|
||||
nonInteractivePermissions?: unknown;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export class AcpxRuntime {
|
||||
constructor(options: AcpRuntimeOptions, testOptions?: unknown);
|
||||
isHealthy(): boolean;
|
||||
probeAvailability(): Promise<void>;
|
||||
doctor(): Promise<AcpRuntimeDoctorReport>;
|
||||
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
|
||||
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
|
||||
getCapabilities(input?: {
|
||||
handle?: AcpRuntimeHandle;
|
||||
}): AcpRuntimeCapabilities | Promise<AcpRuntimeCapabilities>;
|
||||
getStatus(input: { handle: AcpRuntimeHandle; signal?: AbortSignal }): Promise<AcpRuntimeStatus>;
|
||||
setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
|
||||
setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
|
||||
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
|
||||
close(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
reason?: string;
|
||||
discardPersistentState?: boolean;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export function createAcpRuntime(...args: unknown[]): AcpxRuntime;
|
||||
export function createAgentRegistry(params: { overrides?: unknown }): AcpAgentRegistry;
|
||||
export function createFileSessionStore(params: { stateDir: string }): AcpSessionStore;
|
||||
export function decodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
|
||||
export function encodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
|
||||
}
|
||||
55
extensions/acpx/src/acpx-runtime.d.ts
vendored
55
extensions/acpx/src/acpx-runtime.d.ts
vendored
@@ -1,55 +0,0 @@
|
||||
declare module "acpx/runtime" {
|
||||
export const ACPX_BACKEND_ID: string;
|
||||
|
||||
export type AcpRuntimeDoctorReport =
|
||||
import("../../../src/acp/runtime/types.js").AcpRuntimeDoctorReport;
|
||||
export type AcpRuntimeEnsureInput =
|
||||
import("../../../src/acp/runtime/types.js").AcpRuntimeEnsureInput;
|
||||
export type AcpRuntimeEvent = import("../../../src/acp/runtime/types.js").AcpRuntimeEvent;
|
||||
export type AcpRuntimeHandle = import("../../../src/acp/runtime/types.js").AcpRuntimeHandle;
|
||||
export type AcpRuntimeTurnInput = import("../../../src/acp/runtime/types.js").AcpRuntimeTurnInput;
|
||||
export type AcpRuntimeStatus = import("../../../src/acp/runtime/types.js").AcpRuntimeStatus;
|
||||
export type AcpRuntimeCapabilities =
|
||||
import("../../../src/acp/runtime/types.js").AcpRuntimeCapabilities;
|
||||
|
||||
export type AcpSessionStore = {
|
||||
load(sessionId: string): Promise<unknown>;
|
||||
save(record: unknown): Promise<void>;
|
||||
};
|
||||
|
||||
export type AcpAgentRegistry = {
|
||||
resolve(agentId: string): string;
|
||||
list(): string[];
|
||||
};
|
||||
|
||||
export type AcpRuntimeOptions = {
|
||||
cwd: string;
|
||||
sessionStore: AcpSessionStore;
|
||||
agentRegistry: AcpAgentRegistry;
|
||||
permissionMode: string;
|
||||
mcpServers?: unknown[];
|
||||
nonInteractivePermissions?: unknown;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export class AcpxRuntime {
|
||||
constructor(options: AcpRuntimeOptions, testOptions?: unknown);
|
||||
isHealthy(): boolean;
|
||||
probeAvailability(): Promise<void>;
|
||||
doctor(): Promise<AcpRuntimeDoctorReport>;
|
||||
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
|
||||
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
|
||||
getCapabilities(input?: { handle?: AcpRuntimeHandle }): AcpRuntimeCapabilities;
|
||||
getStatus(input: { handle: AcpRuntimeHandle; signal?: AbortSignal }): Promise<AcpRuntimeStatus>;
|
||||
setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
|
||||
setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
|
||||
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
|
||||
close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void>;
|
||||
}
|
||||
|
||||
export function createAcpRuntime(...args: unknown[]): unknown;
|
||||
export function createAgentRegistry(...args: unknown[]): AcpAgentRegistry;
|
||||
export function createFileSessionStore(...args: unknown[]): AcpSessionStore;
|
||||
export function decodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
|
||||
export function encodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { z } from "openclaw/plugin-sdk/zod";
|
||||
import { AcpxPluginConfigSchema } from "./config-schema.js";
|
||||
import type {
|
||||
@@ -223,7 +224,7 @@ export function resolveAcpxPluginConfig(params: {
|
||||
});
|
||||
const agents = Object.fromEntries(
|
||||
Object.entries(normalized.agents ?? {}).map(([name, entry]) => [
|
||||
name.trim().toLowerCase(),
|
||||
normalizeLowercaseStringOrEmpty(name),
|
||||
entry.command.trim(),
|
||||
]),
|
||||
);
|
||||
|
||||
6
extensions/acpx/src/runtime-internals/error-format.mjs
Normal file
6
extensions/acpx/src/runtime-internals/error-format.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
export function formatErrorMessage(error) {
|
||||
if (error instanceof Error) {
|
||||
return error.message || error.name || "Error";
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { formatErrorMessage } from "./error-format.mjs";
|
||||
import { splitCommandLine } from "./mcp-command-line.mjs";
|
||||
|
||||
function decodePayload(argv) {
|
||||
@@ -94,7 +95,7 @@ function main() {
|
||||
child.stdout.pipe(process.stdout);
|
||||
|
||||
child.on("error", (error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.stderr.write(`${formatErrorMessage(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,74 +1,37 @@
|
||||
import type { AcpRuntimeHandle, AcpRuntimeOptions, AcpSessionStore } from "acpx/runtime";
|
||||
import type { AcpSessionStore } from "acpx/runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AcpRuntime } from "../runtime-api.js";
|
||||
import { AcpxRuntime } from "./runtime.js";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state = {
|
||||
capturedStore: undefined as AcpSessionStore | undefined,
|
||||
};
|
||||
|
||||
class MockAcpxRuntime {
|
||||
constructor(options: AcpRuntimeOptions) {
|
||||
state.capturedStore = options.sessionStore;
|
||||
}
|
||||
|
||||
isHealthy() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async probeAvailability() {}
|
||||
|
||||
async doctor() {
|
||||
return { ok: true, message: "ok" };
|
||||
}
|
||||
|
||||
async ensureSession() {
|
||||
return {
|
||||
sessionKey: "agent:codex:acp:binding:test",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "agent:codex:acp:binding:test",
|
||||
} satisfies AcpRuntimeHandle;
|
||||
}
|
||||
|
||||
async *runTurn() {}
|
||||
|
||||
getCapabilities() {
|
||||
return { controls: [] };
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setMode() {}
|
||||
|
||||
async setConfigOption() {}
|
||||
|
||||
async cancel() {}
|
||||
|
||||
async close() {}
|
||||
}
|
||||
function makeRuntime(baseStore: AcpSessionStore): {
|
||||
runtime: AcpxRuntime;
|
||||
wrappedStore: AcpSessionStore & { markFresh: (sessionKey: string) => void };
|
||||
delegate: { close: AcpRuntime["close"] };
|
||||
} {
|
||||
const runtime = new AcpxRuntime({
|
||||
cwd: "/tmp",
|
||||
sessionStore: baseStore,
|
||||
agentRegistry: {
|
||||
resolve: () => "codex",
|
||||
list: () => ["codex"],
|
||||
},
|
||||
permissionMode: "approve-reads",
|
||||
});
|
||||
|
||||
return {
|
||||
state,
|
||||
MockAcpxRuntime,
|
||||
runtime,
|
||||
wrappedStore: (
|
||||
runtime as unknown as {
|
||||
sessionStore: AcpSessionStore & { markFresh: (sessionKey: string) => void };
|
||||
}
|
||||
).sessionStore,
|
||||
delegate: (runtime as unknown as { delegate: { close: AcpRuntime["close"] } }).delegate,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("acpx/runtime", () => ({
|
||||
ACPX_BACKEND_ID: "acpx",
|
||||
AcpxRuntime: mocks.MockAcpxRuntime,
|
||||
createAcpRuntime: vi.fn(),
|
||||
createAgentRegistry: vi.fn(),
|
||||
createFileSessionStore: vi.fn(),
|
||||
decodeAcpxRuntimeHandleState: vi.fn(),
|
||||
encodeAcpxRuntimeHandleState: vi.fn(),
|
||||
}));
|
||||
|
||||
import { AcpxRuntime } from "./runtime.js";
|
||||
}
|
||||
|
||||
describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
beforeEach(() => {
|
||||
mocks.state.capturedStore = undefined;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("keeps stale persistent loads hidden until a fresh record is saved", async () => {
|
||||
@@ -77,20 +40,9 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const runtime = new AcpxRuntime({
|
||||
cwd: "/tmp",
|
||||
sessionStore: baseStore,
|
||||
agentRegistry: {
|
||||
resolve: () => "codex",
|
||||
list: () => ["codex"],
|
||||
},
|
||||
permissionMode: "approve-reads",
|
||||
});
|
||||
const { runtime, wrappedStore } = makeRuntime(baseStore);
|
||||
|
||||
const wrappedStore = mocks.state.capturedStore;
|
||||
expect(wrappedStore).toBeDefined();
|
||||
|
||||
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toEqual({
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toEqual({
|
||||
acpxRecordId: "stale",
|
||||
});
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
||||
@@ -99,17 +51,17 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
sessionKey: "agent:codex:acp:binding:test",
|
||||
});
|
||||
|
||||
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
||||
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
||||
|
||||
await wrappedStore?.save({
|
||||
await wrappedStore.save({
|
||||
acpxRecordId: "fresh-record",
|
||||
name: "agent:codex:acp:binding:test",
|
||||
} as never);
|
||||
|
||||
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toEqual({
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toEqual({
|
||||
acpxRecordId: "stale",
|
||||
});
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(2);
|
||||
@@ -121,18 +73,8 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const runtime = new AcpxRuntime({
|
||||
cwd: "/tmp",
|
||||
sessionStore: baseStore,
|
||||
agentRegistry: {
|
||||
resolve: () => "codex",
|
||||
list: () => ["codex"],
|
||||
},
|
||||
permissionMode: "approve-reads",
|
||||
});
|
||||
|
||||
const wrappedStore = mocks.state.capturedStore;
|
||||
expect(wrappedStore).toBeDefined();
|
||||
const { runtime, wrappedStore, delegate } = makeRuntime(baseStore);
|
||||
const close = vi.spyOn(delegate, "close").mockResolvedValue(undefined);
|
||||
|
||||
await runtime.close({
|
||||
handle: {
|
||||
@@ -144,7 +86,16 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
discardPersistentState: true,
|
||||
});
|
||||
|
||||
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(close).toHaveBeenCalledWith({
|
||||
handle: {
|
||||
sessionKey: "agent:codex:acp:binding:test",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "agent:codex:acp:binding:test",
|
||||
},
|
||||
reason: "new-in-place-reset",
|
||||
discardPersistentState: true,
|
||||
});
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(baseStore.load).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,6 +131,7 @@ export class AcpxRuntime implements AcpxRuntimeLike {
|
||||
.close({
|
||||
handle: input.handle,
|
||||
reason: input.reason,
|
||||
discardPersistentState: input.discardPersistentState,
|
||||
})
|
||||
.then(() => {
|
||||
if (input.discardPersistentState) {
|
||||
|
||||
@@ -37,6 +37,8 @@ async function makeTempDir(): Promise<string> {
|
||||
|
||||
afterEach(async () => {
|
||||
runtimeRegistry.clear();
|
||||
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME;
|
||||
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -140,4 +142,50 @@ describe("createAcpxRuntimeService", () => {
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("can skip the embedded runtime probe via env", async () => {
|
||||
process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE = "1";
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const probeAvailability = vi.fn(async () => {});
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory: () =>
|
||||
({
|
||||
ensureSession: vi.fn(),
|
||||
runTurn: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
close: vi.fn(),
|
||||
probeAvailability,
|
||||
isHealthy: () => false,
|
||||
doctor: async () => ({ ok: false, message: "nope" }),
|
||||
}) as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(probeAvailability).not.toHaveBeenCalled();
|
||||
expect(getAcpRuntimeBackend("acpx")).toBeTruthy();
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("can skip the embedded runtime backend via env", async () => {
|
||||
process.env.OPENCLAW_SKIP_ACPX_RUNTIME = "1";
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtimeFactory = vi.fn(() => {
|
||||
throw new Error("runtime factory should not run when ACPX is skipped");
|
||||
});
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory: runtimeFactory as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(runtimeFactory).not.toHaveBeenCalled();
|
||||
expect(getAcpRuntimeBackend("acpx")).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
"skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type {
|
||||
AcpRuntime,
|
||||
OpenClawPluginService,
|
||||
@@ -90,6 +91,11 @@ export function createAcpxRuntimeService(
|
||||
return {
|
||||
id: "acpx-runtime",
|
||||
async start(ctx: OpenClawPluginServiceContext): Promise<void> {
|
||||
if (process.env.OPENCLAW_SKIP_ACPX_RUNTIME === "1") {
|
||||
ctx.logger.info("skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)");
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: params.pluginConfig,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
@@ -113,6 +119,10 @@ export function createAcpxRuntimeService(
|
||||
});
|
||||
ctx.logger.info(`embedded acpx runtime backend registered (cwd: ${pluginConfig.cwd})`);
|
||||
|
||||
if (process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE === "1") {
|
||||
return;
|
||||
}
|
||||
|
||||
lifecycleRevision += 1;
|
||||
const currentRevision = lifecycleRevision;
|
||||
void (async () => {
|
||||
@@ -136,9 +146,7 @@ export function createAcpxRuntimeService(
|
||||
if (currentRevision !== lifecycleRevision) {
|
||||
return;
|
||||
}
|
||||
ctx.logger.warn(
|
||||
`embedded acpx runtime setup failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
ctx.logger.warn(`embedded acpx runtime setup failed: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
})();
|
||||
},
|
||||
|
||||
16
extensions/acpx/tsconfig.json
Normal file
16
extensions/acpx/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
862
extensions/active-memory/index.test.ts
Normal file
862
extensions/active-memory/index.test.ts
Normal file
@@ -0,0 +1,862 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const sessionStore: Record<string, Record<string, unknown>> = {
|
||||
"agent:main:main": {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
},
|
||||
};
|
||||
return {
|
||||
sessionStore,
|
||||
updateSessionStore: vi.fn(
|
||||
async (_storePath: string, updater: (store: Record<string, unknown>) => void) => {
|
||||
updater(sessionStore);
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
|
||||
"openclaw/plugin-sdk/config-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
updateSessionStore: hoisted.updateSessionStore,
|
||||
};
|
||||
});
|
||||
|
||||
describe("active-memory plugin", () => {
|
||||
const hooks: Record<string, Function> = {};
|
||||
const runEmbeddedPiAgent = vi.fn();
|
||||
const api: any = {
|
||||
pluginConfig: {
|
||||
agents: ["main"],
|
||||
logging: true,
|
||||
},
|
||||
config: {},
|
||||
id: "active-memory",
|
||||
name: "Active Memory",
|
||||
logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() },
|
||||
runtime: {
|
||||
agent: {
|
||||
runEmbeddedPiAgent,
|
||||
session: {
|
||||
resolveStorePath: vi.fn(() => "/tmp/openclaw-session-store.json"),
|
||||
loadSessionStore: vi.fn(() => hoisted.sessionStore),
|
||||
saveSessionStore: vi.fn(async () => {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
on: vi.fn((hookName: string, handler: Function) => {
|
||||
hooks[hookName] = handler;
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
logging: true,
|
||||
};
|
||||
hoisted.sessionStore["agent:main:main"] = {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
};
|
||||
for (const key of Object.keys(hooks)) {
|
||||
delete hooks[key];
|
||||
}
|
||||
runEmbeddedPiAgent.mockResolvedValue({
|
||||
payloads: [{ text: "- lemon pepper wings\n- blue cheese" }],
|
||||
});
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("registers a before_prompt_build hook", () => {
|
||||
expect(api.on).toHaveBeenCalledWith("before_prompt_build", expect.any(Function));
|
||||
});
|
||||
|
||||
it("does not run for agents that are not explicitly targeted", async () => {
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order?", messages: [] },
|
||||
{
|
||||
agentId: "support",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:support:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not rewrite session state for skipped turns with no active-memory entry to clear", async () => {
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order?", messages: [] },
|
||||
{
|
||||
agentId: "support",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:support:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(hoisted.updateSessionStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not run for non-interactive contexts", async () => {
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order?", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "heartbeat",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults to direct-style sessions only", async () => {
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should we order?", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:telegram:group:-100123",
|
||||
messageProvider: "telegram",
|
||||
channelId: "telegram",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs for group sessions when group chat types are explicitly allowed", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
allowedChatTypes: ["direct", "group"],
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should we order?", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:telegram:group:-100123",
|
||||
messageProvider: "telegram",
|
||||
channelId: "telegram",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
|
||||
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
|
||||
});
|
||||
});
|
||||
|
||||
it("injects system context on a successful recall hit", async () => {
|
||||
const result = await hooks.before_prompt_build(
|
||||
{
|
||||
prompt: "what wings should i order?",
|
||||
messages: [
|
||||
{ role: "user", content: "i want something greasy tonight" },
|
||||
{ role: "assistant", content: "let's narrow it down" },
|
||||
],
|
||||
},
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
|
||||
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
|
||||
});
|
||||
expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
|
||||
"lemon pepper wings",
|
||||
);
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
provider: "github-copilot",
|
||||
model: "gpt-5.4-mini",
|
||||
sessionKey: expect.stringMatching(/^agent:main:main:active-memory:[a-f0-9]{12}$/),
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves leading digits in recalled memory bullets", async () => {
|
||||
runEmbeddedPiAgent.mockResolvedValueOnce({
|
||||
payloads: [{ text: "- 2024 trip to tokyo\n- 2% milk" }],
|
||||
});
|
||||
|
||||
const result = await hooks.before_prompt_build(
|
||||
{
|
||||
prompt: "what should i remember from my 2024 trip and should i buy 2% milk?",
|
||||
messages: [],
|
||||
},
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
|
||||
appendSystemContext: expect.stringContaining("<active_memory_plugin>"),
|
||||
});
|
||||
expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
|
||||
"2024 trip to tokyo",
|
||||
);
|
||||
expect((result as { appendSystemContext: string }).appendSystemContext).toContain("2% milk");
|
||||
});
|
||||
|
||||
it("preserves canonical parent session scope in the blocking memory subagent session key", async () => {
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what should i grab on the way?", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:telegram:direct:12345:thread:99",
|
||||
messageProvider: "telegram",
|
||||
channelId: "telegram",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionKey).toMatch(
|
||||
/^agent:main:telegram:direct:12345:thread:99:active-memory:[a-f0-9]{12}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the current session model when no plugin model is configured", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? temp transcript", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
modelProviderId: "qwen",
|
||||
modelId: "glm-5",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
provider: "qwen",
|
||||
model: "glm-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("can disable default remote model fallback", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
modelFallbackPolicy: "resolved-only",
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? no fallback", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:resolved-only",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists a readable debug summary alongside the status line", async () => {
|
||||
const sessionKey = "agent:main:debug";
|
||||
hoisted.sessionStore[sessionKey] = {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
};
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{
|
||||
prompt: "what wings should i order?",
|
||||
messages: [],
|
||||
},
|
||||
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
|
||||
);
|
||||
|
||||
expect(hoisted.updateSessionStore).toHaveBeenCalled();
|
||||
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
|
||||
| ((store: Record<string, Record<string, unknown>>) => void)
|
||||
| undefined;
|
||||
const store = {
|
||||
[sessionKey]: {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
},
|
||||
} as Record<string, Record<string, unknown>>;
|
||||
updater?.(store);
|
||||
expect(store[sessionKey]?.pluginDebugEntries).toEqual([
|
||||
{
|
||||
pluginId: "active-memory",
|
||||
lines: expect.arrayContaining([
|
||||
expect.stringContaining("🧩 Active Memory: ok"),
|
||||
expect.stringContaining("🔎 Active Memory Debug: lemon pepper wings"),
|
||||
]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces stale legacy active-memory lines on a later empty run", async () => {
|
||||
const sessionKey = "agent:main:legacy-active-memory-lines";
|
||||
hoisted.sessionStore[sessionKey] = {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
pluginStatusLines: [
|
||||
"Active Memory: ok 13.4s recent 1 mem",
|
||||
"Active Memory Debug: Favorite desk snack: roasted almonds or cashews.",
|
||||
"Other Plugin: keep me",
|
||||
],
|
||||
};
|
||||
runEmbeddedPiAgent.mockResolvedValueOnce({
|
||||
payloads: [{ text: "NONE" }],
|
||||
});
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what's up with you?", messages: [] },
|
||||
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
|
||||
);
|
||||
|
||||
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
|
||||
| ((store: Record<string, Record<string, unknown>>) => void)
|
||||
| undefined;
|
||||
const store = {
|
||||
[sessionKey]: {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
pluginStatusLines: [
|
||||
"Active Memory: ok 13.4s recent 1 mem",
|
||||
"Active Memory Debug: Favorite desk snack: roasted almonds or cashews.",
|
||||
"Other Plugin: keep me",
|
||||
],
|
||||
},
|
||||
} as Record<string, Record<string, unknown>>;
|
||||
updater?.(store);
|
||||
|
||||
expect(store[sessionKey]?.pluginDebugEntries).toEqual([
|
||||
{
|
||||
pluginId: "active-memory",
|
||||
lines: [expect.stringContaining("🧩 Active Memory: empty")],
|
||||
},
|
||||
]);
|
||||
expect(store[sessionKey]?.pluginStatusLines).toEqual(["Other Plugin: keep me"]);
|
||||
});
|
||||
|
||||
it("returns nothing when the sidecar says none", async () => {
|
||||
runEmbeddedPiAgent.mockResolvedValueOnce({
|
||||
payloads: [{ text: "NONE" }],
|
||||
});
|
||||
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "fair, okay gonna do them by throwing them in the garbage", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not cache timeout results", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
timeoutMs: 250,
|
||||
logging: true,
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
let lastAbortSignal: AbortSignal | undefined;
|
||||
runEmbeddedPiAgent.mockImplementation(async (params: { abortSignal?: AbortSignal }) => {
|
||||
lastAbortSignal = params.abortSignal;
|
||||
return await new Promise((resolve, reject) => {
|
||||
const abortHandler = () => reject(new Error("aborted"));
|
||||
params.abortSignal?.addEventListener("abort", abortHandler, { once: true });
|
||||
setTimeout(() => {
|
||||
params.abortSignal?.removeEventListener("abort", abortHandler);
|
||||
resolve({ payloads: [] });
|
||||
}, 2_000);
|
||||
});
|
||||
});
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? timeout test", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:timeout-test",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? timeout test", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:timeout-test",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(hoisted.updateSessionStore).toHaveBeenCalledTimes(2);
|
||||
expect(lastAbortSignal?.aborted).toBe(true);
|
||||
const infoLines = vi
|
||||
.mocked(api.logger.info)
|
||||
.mock.calls.map((call: unknown[]) => String(call[0]));
|
||||
expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not share cached recall results across session-id-only contexts", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
logging: true,
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? session id cache", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionId: "session-a",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? session id cache", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionId: "session-b",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2);
|
||||
const infoLines = vi
|
||||
.mocked(api.logger.info)
|
||||
.mock.calls.map((call: unknown[]) => String(call[0]));
|
||||
expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false);
|
||||
});
|
||||
|
||||
it("uses a canonical agent session key when only sessionId is available", async () => {
|
||||
hoisted.sessionStore["agent:main:telegram:direct:12345"] = {
|
||||
sessionId: "session-a",
|
||||
updatedAt: 25,
|
||||
};
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? session id only", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionId: "session-a",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionKey).toMatch(
|
||||
/^agent:main:telegram:direct:12345:active-memory:[a-f0-9]{12}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("clears stale status on skipped non-interactive turns even when agentId is missing", async () => {
|
||||
const sessionKey = "agent:main:missing-agent";
|
||||
hoisted.sessionStore[sessionKey] = {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
pluginDebugEntries: [
|
||||
{ pluginId: "active-memory", lines: ["🧩 Active Memory: timeout 15s recent"] },
|
||||
],
|
||||
};
|
||||
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order?", messages: [] },
|
||||
{ trigger: "heartbeat", sessionKey, messageProvider: "webchat" },
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
|
||||
| ((store: Record<string, Record<string, unknown>>) => void)
|
||||
| undefined;
|
||||
const store = {
|
||||
[sessionKey]: {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
pluginDebugEntries: [
|
||||
{ pluginId: "active-memory", lines: ["🧩 Active Memory: timeout 15s recent"] },
|
||||
],
|
||||
},
|
||||
} as Record<string, Record<string, unknown>>;
|
||||
updater?.(store);
|
||||
expect(store[sessionKey]?.pluginDebugEntries).toBeUndefined();
|
||||
});
|
||||
|
||||
it("supports message mode by sending only the latest user message", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
queryMode: "message",
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{
|
||||
prompt: "what should i grab on the way?",
|
||||
messages: [
|
||||
{ role: "user", content: "i have a flight tomorrow" },
|
||||
{ role: "assistant", content: "got it" },
|
||||
],
|
||||
},
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
|
||||
expect(prompt).toContain("Conversation context:\nwhat should i grab on the way?");
|
||||
expect(prompt).not.toContain("Recent conversation tail:");
|
||||
});
|
||||
|
||||
it("supports full mode by sending the whole conversation", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
queryMode: "full",
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{
|
||||
prompt: "what should i grab on the way?",
|
||||
messages: [
|
||||
{ role: "user", content: "i have a flight tomorrow" },
|
||||
{ role: "assistant", content: "got it" },
|
||||
{ role: "user", content: "packing is annoying" },
|
||||
],
|
||||
},
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
|
||||
expect(prompt).toContain("Full conversation context:");
|
||||
expect(prompt).toContain("user: i have a flight tomorrow");
|
||||
expect(prompt).toContain("assistant: got it");
|
||||
expect(prompt).toContain("user: packing is annoying");
|
||||
});
|
||||
|
||||
it("strips prior memory/debug traces from assistant context before retrieval", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
queryMode: "recent",
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{
|
||||
prompt: "what should i grab on the way?",
|
||||
messages: [
|
||||
{ role: "user", content: "i have a flight tomorrow" },
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"🧠 Memory Search: favorite food comfort food tacos sushi ramen\n🧩 Active Memory: ok 842ms recent 2 mem\n🔎 Active Memory Debug: spicy ramen; tacos\nSounds like you want something easy before the airport.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
|
||||
expect(prompt).toContain(
|
||||
"ignore that text and do not search for those same surfaced memories again",
|
||||
);
|
||||
expect(prompt).toContain("assistant: Sounds like you want something easy before the airport.");
|
||||
expect(prompt).not.toContain("Memory Search:");
|
||||
expect(prompt).not.toContain("Active Memory:");
|
||||
expect(prompt).not.toContain("Active Memory Debug:");
|
||||
expect(prompt).not.toContain("spicy ramen; tacos");
|
||||
});
|
||||
|
||||
it("trusts the subagent's relevance decision for explicit preference recall prompts", async () => {
|
||||
runEmbeddedPiAgent.mockResolvedValueOnce({
|
||||
payloads: [{ text: "- aisle seat\n- extra buffer on connections" }],
|
||||
});
|
||||
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "u remember my flight preferences", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
|
||||
appendSystemContext: expect.stringContaining("aisle seat"),
|
||||
});
|
||||
expect((result as { appendSystemContext: string }).appendSystemContext).toContain(
|
||||
"extra buffer on connections",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies total summary truncation after normalizing the sidecar reply", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
maxSummaryChars: 40,
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
runEmbeddedPiAgent.mockResolvedValueOnce({
|
||||
payloads: [
|
||||
{
|
||||
text: "- lemon pepper wings with extra crisp skin\n- blue cheese dressing on the side",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order?", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
prependSystemContext: expect.stringContaining("plugin-provided supplemental context"),
|
||||
appendSystemContext: expect.stringContaining("lemon pepper wings"),
|
||||
});
|
||||
expect((result as { appendSystemContext: string }).appendSystemContext).not.toContain(
|
||||
"dressing on the side",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the configured maxSummaryChars value in the sidecar prompt", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
maxSummaryChars: 90,
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? prompt-count-check", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:prompt-count-check",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt).toContain(
|
||||
"If something is useful, reply with one compact active-memory summary under 90 characters total.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps sidecar transcripts off disk by default by using a temp session file", async () => {
|
||||
const mkdtempSpy = vi
|
||||
.spyOn(fs, "mkdtemp")
|
||||
.mockResolvedValue("/tmp/openclaw-active-memory-temp");
|
||||
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? temp transcript path", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(mkdtempSpy).toHaveBeenCalled();
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toBe(
|
||||
"/tmp/openclaw-active-memory-temp/session.jsonl",
|
||||
);
|
||||
expect(rmSpy).toHaveBeenCalledWith("/tmp/openclaw-active-memory-temp", {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("persists sidecar transcripts in a separate directory when enabled", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
persistTranscripts: true,
|
||||
transcriptDir: "active-memory-sidecars",
|
||||
logging: true,
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
|
||||
const mkdtempSpy = vi.spyOn(fs, "mkdtemp");
|
||||
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
|
||||
|
||||
const sessionKey = "agent:main:persist-transcript";
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? persist transcript", messages: [] },
|
||||
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
|
||||
);
|
||||
|
||||
expect(mkdirSpy).toHaveBeenCalledWith("/tmp/active-memory-sidecars", { recursive: true });
|
||||
expect(mkdtempSpy).not.toHaveBeenCalled();
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toMatch(
|
||||
/^\/tmp\/active-memory-sidecars\/active-memory-[a-z0-9]+-[a-f0-9]{8}\.jsonl$/,
|
||||
);
|
||||
expect(rmSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
vi
|
||||
.mocked(api.logger.info)
|
||||
.mock.calls.some((call: unknown[]) =>
|
||||
String(call[0]).includes("transcript=/tmp/active-memory-sidecars/"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the default transcript directory when transcriptDir is unsafe", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
persistTranscripts: true,
|
||||
transcriptDir: "C:/temp/escape",
|
||||
logging: true,
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? unsafe transcript dir", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:unsafe-transcript",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(mkdirSpy).toHaveBeenCalledWith("/tmp/active-memory", { recursive: true });
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionFile).toMatch(
|
||||
/^\/tmp\/active-memory\/active-memory-[a-z0-9]+-[a-f0-9]{8}\.jsonl$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes control characters out of debug lines", async () => {
|
||||
const sessionKey = "agent:main:debug-sanitize";
|
||||
hoisted.sessionStore[sessionKey] = {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
};
|
||||
runEmbeddedPiAgent.mockResolvedValueOnce({
|
||||
payloads: [{ text: "- spicy ramen\u001b[31m\n- fries\r\n- blue cheese\t" }],
|
||||
});
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what should i order?", messages: [] },
|
||||
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
|
||||
);
|
||||
|
||||
const updater = hoisted.updateSessionStore.mock.calls.at(-1)?.[1] as
|
||||
| ((store: Record<string, Record<string, unknown>>) => void)
|
||||
| undefined;
|
||||
const store = {
|
||||
[sessionKey]: {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
},
|
||||
} as Record<string, Record<string, unknown>>;
|
||||
updater?.(store);
|
||||
const lines =
|
||||
(store[sessionKey]?.pluginDebugEntries as Array<{ lines?: string[] }> | undefined)?.[0]
|
||||
?.lines ?? [];
|
||||
expect(lines.some((line) => line.includes("\u001b"))).toBe(false);
|
||||
expect(lines.some((line) => line.includes("\r"))).toBe(false);
|
||||
});
|
||||
|
||||
it("caps the active-memory cache size and evicts the oldest entries", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
logging: true,
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
for (let index = 0; index <= 1000; index += 1) {
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: `cache pressure prompt ${index}`, messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:cache-cap",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const callsBeforeReplay = runEmbeddedPiAgent.mock.calls.length;
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "cache pressure prompt 0", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:cache-cap",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent.mock.calls.length).toBe(callsBeforeReplay + 1);
|
||||
const infoLines = vi
|
||||
.mocked(api.logger.info)
|
||||
.mock.calls.map((call: unknown[]) => String(call[0]));
|
||||
expect(
|
||||
infoLines.some(
|
||||
(line: string) => line.includes("cached status=ok") && line.includes("prompt 0"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
1048
extensions/active-memory/index.ts
Normal file
1048
extensions/active-memory/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
82
extensions/active-memory/openclaw.plugin.json
Normal file
82
extensions/active-memory/openclaw.plugin.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"id": "active-memory",
|
||||
"name": "Active Memory",
|
||||
"description": "Runs a bounded blocking memory subagent before eligible conversational replies and injects relevant memory into prompt context.",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"agents": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"model": { "type": "string" },
|
||||
"modelFallbackPolicy": {
|
||||
"type": "string",
|
||||
"enum": ["default-remote", "resolved-only"]
|
||||
},
|
||||
"allowedChatTypes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["direct", "group", "channel"]
|
||||
}
|
||||
},
|
||||
"timeoutMs": { "type": "integer", "minimum": 250 },
|
||||
"queryMode": {
|
||||
"type": "string",
|
||||
"enum": ["message", "recent", "full"]
|
||||
},
|
||||
"maxSummaryChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
|
||||
"recentUserTurns": { "type": "integer", "minimum": 0, "maximum": 4 },
|
||||
"recentAssistantTurns": { "type": "integer", "minimum": 0, "maximum": 3 },
|
||||
"recentUserChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
|
||||
"recentAssistantChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
|
||||
"logging": { "type": "boolean" },
|
||||
"persistTranscripts": { "type": "boolean" },
|
||||
"transcriptDir": { "type": "string" },
|
||||
"cacheTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 }
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"agents": {
|
||||
"label": "Target Agents",
|
||||
"help": "Explicit agent ids that may use active memory."
|
||||
},
|
||||
"model": {
|
||||
"label": "Memory Model",
|
||||
"help": "Provider/model used for the blocking memory subagent."
|
||||
},
|
||||
"modelFallbackPolicy": {
|
||||
"label": "Model Fallback Policy",
|
||||
"help": "Choose whether Active Memory falls back to the built-in remote default model when no explicit or inherited model is available."
|
||||
},
|
||||
"allowedChatTypes": {
|
||||
"label": "Allowed Chat Types",
|
||||
"help": "Choose which session types may run Active Memory. Defaults to direct-message style sessions only."
|
||||
},
|
||||
"timeoutMs": {
|
||||
"label": "Timeout (ms)"
|
||||
},
|
||||
"queryMode": {
|
||||
"label": "Query Mode",
|
||||
"help": "Choose whether the blocking memory subagent sees only the latest user message, a small recent tail, or the full conversation."
|
||||
},
|
||||
"maxSummaryChars": {
|
||||
"label": "Max Summary Characters",
|
||||
"help": "Maximum total characters allowed in the active-memory summary."
|
||||
},
|
||||
"logging": {
|
||||
"label": "Enable Logging",
|
||||
"help": "Emit active memory timing and result logs."
|
||||
},
|
||||
"persistTranscripts": {
|
||||
"label": "Persist Transcripts",
|
||||
"help": "Keep blocking memory subagent session transcripts on disk in a separate plugin-owned directory."
|
||||
},
|
||||
"transcriptDir": {
|
||||
"label": "Transcript Directory",
|
||||
"help": "Relative directory under the agent sessions folder used when transcript persistence is enabled."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
"private": true,
|
||||
"description": "OpenClaw Alibaba Model Studio video provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
16
extensions/alibaba/tsconfig.json
Normal file
16
extensions/alibaba/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type {
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
@@ -102,7 +103,7 @@ export async function generateBearerTokenFromIam(params: {
|
||||
} catch (error) {
|
||||
log.debug?.("Mantle IAM token generation unavailable", {
|
||||
region: params.region,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
@@ -233,7 +234,7 @@ export async function discoverMantleModels(params: {
|
||||
return models;
|
||||
} catch (error) {
|
||||
log.debug?.("Mantle model discovery error", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
return cached?.models ?? [];
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"dependencies": {
|
||||
"@aws/bedrock-token-generator": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
|
||||
16
extensions/amazon-bedrock-mantle/tsconfig.json
Normal file
16
extensions/amazon-bedrock-mantle/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { isRecord } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const LEGACY_PATH = "models.bedrockDiscovery";
|
||||
const TARGET_PATH = "plugins.entries.amazon-bedrock.config.discovery";
|
||||
const BLOCKED_OBJECT_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isBlockedObjectKey(key: string): boolean {
|
||||
return BLOCKED_OBJECT_KEYS.has(key);
|
||||
}
|
||||
|
||||
@@ -6,12 +6,17 @@ import {
|
||||
type ListInferenceProfilesCommandOutput,
|
||||
} from "@aws-sdk/client-bedrock";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { resolveAwsSdkEnvVarName } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import type {
|
||||
BedrockDiscoveryConfig,
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
const log = createSubsystemLogger("bedrock-discovery");
|
||||
|
||||
@@ -49,7 +54,9 @@ function normalizeProviderFilter(filter?: string[]): string[] {
|
||||
return [];
|
||||
}
|
||||
const normalized = new Set(
|
||||
filter.map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0),
|
||||
filter
|
||||
.map((entry) => normalizeOptionalLowercaseString(entry))
|
||||
.filter((entry): entry is string => Boolean(entry)),
|
||||
);
|
||||
return Array.from(normalized).toSorted();
|
||||
}
|
||||
@@ -65,7 +72,7 @@ function buildCacheKey(params: {
|
||||
}
|
||||
|
||||
function includesTextModalities(modalities?: Array<string>): boolean {
|
||||
return (modalities ?? []).some((entry) => entry.toLowerCase() === "text");
|
||||
return (modalities ?? []).some((entry) => normalizeOptionalLowercaseString(entry) === "text");
|
||||
}
|
||||
|
||||
function isActive(summary: BedrockModelSummary): boolean {
|
||||
@@ -77,7 +84,7 @@ function mapInputModalities(summary: BedrockModelSummary): Array<"text" | "image
|
||||
const inputs = summary.inputModalities ?? [];
|
||||
const mapped = new Set<"text" | "image">();
|
||||
for (const modality of inputs) {
|
||||
const lower = modality.toLowerCase();
|
||||
const lower = normalizeOptionalLowercaseString(modality);
|
||||
if (lower === "text") {
|
||||
mapped.add("text");
|
||||
}
|
||||
@@ -92,7 +99,9 @@ function mapInputModalities(summary: BedrockModelSummary): Array<"text" | "image
|
||||
}
|
||||
|
||||
function inferReasoningSupport(summary: BedrockModelSummary): boolean {
|
||||
const haystack = `${summary.modelId ?? ""} ${summary.modelName ?? ""}`.toLowerCase();
|
||||
const haystack = normalizeLowercaseStringOrEmpty(
|
||||
`${summary.modelId ?? ""} ${summary.modelName ?? ""}`,
|
||||
);
|
||||
return haystack.includes("reasoning") || haystack.includes("thinking");
|
||||
}
|
||||
|
||||
@@ -117,7 +126,7 @@ function matchesProviderFilter(summary: BedrockModelSummary, filter: string[]):
|
||||
const providerName =
|
||||
summary.providerName ??
|
||||
(typeof summary.modelId === "string" ? summary.modelId.split(".")[0] : undefined);
|
||||
const normalized = providerName?.trim().toLowerCase();
|
||||
const normalized = normalizeOptionalLowercaseString(providerName);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
@@ -214,7 +223,7 @@ async function fetchInferenceProfileSummaries(
|
||||
return profiles;
|
||||
} catch (error) {
|
||||
log.debug?.("Skipping inference profile discovery", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
@@ -252,7 +261,9 @@ function resolveInferenceProfiles(
|
||||
const models = profile.models ?? [];
|
||||
const matchesFilter = models.some((m) => {
|
||||
const provider = m.modelArn?.split("/")?.[1]?.split(".")?.[0];
|
||||
return provider ? providerFilter.includes(provider.toLowerCase()) : false;
|
||||
return provider
|
||||
? providerFilter.includes(normalizeOptionalLowercaseString(provider) ?? "")
|
||||
: false;
|
||||
});
|
||||
if (!matchesFilter) {
|
||||
continue;
|
||||
@@ -261,7 +272,9 @@ function resolveInferenceProfiles(
|
||||
|
||||
// Look up the underlying foundation model to inherit its capabilities.
|
||||
const baseModelId = resolveBaseModelId(profile);
|
||||
const baseModel = baseModelId ? foundationModels.get(baseModelId.toLowerCase()) : undefined;
|
||||
const baseModel = baseModelId
|
||||
? foundationModels.get(normalizeLowercaseStringOrEmpty(baseModelId))
|
||||
: undefined;
|
||||
|
||||
discovered.push({
|
||||
id: profile.inferenceProfileId,
|
||||
@@ -352,8 +365,9 @@ export async function discoverBedrockModels(params: {
|
||||
maxTokens: defaultMaxTokens,
|
||||
});
|
||||
discovered.push(def);
|
||||
seenIds.add(def.id.toLowerCase());
|
||||
foundationModels.set(def.id.toLowerCase(), def);
|
||||
const normalizedId = normalizeLowercaseStringOrEmpty(def.id);
|
||||
seenIds.add(normalizedId);
|
||||
foundationModels.set(normalizedId, def);
|
||||
}
|
||||
|
||||
// Merge inference profiles — inherit capabilities from foundation models.
|
||||
@@ -364,9 +378,10 @@ export async function discoverBedrockModels(params: {
|
||||
foundationModels,
|
||||
);
|
||||
for (const profile of inferenceProfiles) {
|
||||
if (!seenIds.has(profile.id.toLowerCase())) {
|
||||
const normalizedId = normalizeLowercaseStringOrEmpty(profile.id);
|
||||
if (!seenIds.has(normalizedId)) {
|
||||
discovered.push(profile);
|
||||
seenIds.add(profile.id.toLowerCase());
|
||||
seenIds.add(normalizedId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,7 +421,7 @@ export async function discoverBedrockModels(params: {
|
||||
if (!hasLoggedBedrockError) {
|
||||
hasLoggedBedrockError = true;
|
||||
log.warn("Failed to discover Bedrock models", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
}
|
||||
return [];
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"configContracts": {
|
||||
"compatibilityMigrationPaths": ["models.bedrockDiscovery"]
|
||||
},
|
||||
"uiHints": {
|
||||
"discovery": {
|
||||
"label": "Model Discovery",
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock": "3.1024.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
|
||||
16
extensions/amazon-bedrock/tsconfig.json
Normal file
16
extensions/amazon-bedrock/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
export {
|
||||
ANTHROPIC_VERTEX_DEFAULT_MODEL_ID,
|
||||
buildAnthropicVertexProvider,
|
||||
@@ -16,9 +15,9 @@ import { buildAnthropicVertexProvider } from "./provider-catalog.js";
|
||||
import { hasAnthropicVertexAvailableAuth } from "./region.js";
|
||||
|
||||
export function mergeImplicitAnthropicVertexProvider(params: {
|
||||
existing: ModelProviderConfig | undefined;
|
||||
implicit: ModelProviderConfig;
|
||||
}): ModelProviderConfig {
|
||||
existing?: ReturnType<typeof buildAnthropicVertexProvider>;
|
||||
implicit: ReturnType<typeof buildAnthropicVertexProvider>;
|
||||
}) {
|
||||
const { existing, implicit } = params;
|
||||
if (!existing) {
|
||||
return implicit;
|
||||
@@ -33,9 +32,7 @@ export function mergeImplicitAnthropicVertexProvider(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveImplicitAnthropicVertexProvider(params?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): ModelProviderConfig | null {
|
||||
export function resolveImplicitAnthropicVertexProvider(params?: { env?: NodeJS.ProcessEnv }) {
|
||||
const env = params?.env ?? process.env;
|
||||
if (!hasAnthropicVertexAvailableAuth(env)) {
|
||||
return null;
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"private": true,
|
||||
"description": "OpenClaw Anthropic Vertex provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
||||
import { homedir, platform } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { resolveProviderEndpoint } from "openclaw/plugin-sdk/provider-http";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
const ANTHROPIC_VERTEX_DEFAULT_REGION = "global";
|
||||
const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/;
|
||||
@@ -64,7 +65,10 @@ export function resolveAnthropicVertexClientRegion(params?: {
|
||||
|
||||
function hasAnthropicVertexMetadataServerAdc(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const explicitMetadataOptIn = normalizeOptionalSecretInput(env.ANTHROPIC_VERTEX_USE_GCP_METADATA);
|
||||
return explicitMetadataOptIn === "1" || explicitMetadataOptIn?.toLowerCase() === "true";
|
||||
return (
|
||||
explicitMetadataOptIn === "1" ||
|
||||
normalizeLowercaseStringOrEmpty(explicitMetadataOptIn) === "true"
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAnthropicVertexDefaultAdcPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
|
||||
16
extensions/anthropic-vertex/tsconfig.json
Normal file
16
extensions/anthropic-vertex/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user