Compare commits
603 Commits
5f7a53ed21
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a7fb0a4369 | |||
| 539599dbb8 | |||
| 294ce75892 | |||
| f1c5e7dc1d | |||
| 812bca79a3 | |||
| 07a918d4cd | |||
| 8ad811c977 | |||
| 131546b2fe | |||
| 9ed9ed91dd | |||
| 0d8ea01b43 | |||
|
|
d93b92baca | ||
|
|
b4f31408b6 | ||
|
|
615bf2f874 | ||
|
|
1aa79b9b29 | ||
|
|
350ac3f344 | ||
|
|
500b568b04 | ||
|
|
5505316ea2 | ||
|
|
a1da88aed4 | ||
|
|
8597ebafd9 | ||
|
|
416c673dfe | ||
|
|
3095977d13 | ||
|
|
8b97e75c6b | ||
|
|
128330e36f | ||
|
|
1287b69fe0 | ||
|
|
6e6ce70caf | ||
|
|
226a316824 | ||
|
|
373cd6ae52 | ||
|
|
47d1d90a8e | ||
|
|
783571a615 | ||
|
|
56b22768db | ||
|
|
8eb9704b3e | ||
|
|
74677bbfbf | ||
|
|
a2f70e5f28 | ||
|
|
ea65a51a15 | ||
|
|
0d89a33373 | ||
|
|
1a418ad223 | ||
|
|
9179734cc2 | ||
|
|
651d10d90b | ||
|
|
8025fdd01f | ||
|
|
fd1ba2c7fe | ||
|
|
cab89d1435 | ||
|
|
c60f6a8ab3 | ||
|
|
b09fbde00e | ||
|
|
7d08c81a09 | ||
|
|
6b9f3f492f | ||
|
|
788a044483 | ||
|
|
c4ab61dcbf | ||
|
|
717494223d | ||
|
|
aae62dfa87 | ||
|
|
2b46f38863 | ||
|
|
d0b46764b1 | ||
|
|
908be616f2 | ||
|
|
bc93655e1c | ||
|
|
20da9bc140 | ||
|
|
7a14325b24 | ||
|
|
51f7523587 | ||
|
|
008c76f955 | ||
|
|
58dd2f5996 | ||
|
|
6d5ede31c8 | ||
|
|
ae89286d07 | ||
|
|
068ab5d19b | ||
|
|
29c1504cc1 | ||
|
|
12bb8dd2c9 | ||
|
|
31bfb2f16f | ||
|
|
34b6aea1e6 | ||
|
|
7da475db66 | ||
|
|
bd9f8773ad | ||
|
|
a93f066607 | ||
|
|
a78cd7ccb1 | ||
|
|
0b0b9eae62 | ||
|
|
6561cb293b | ||
|
|
b6b5bed9ae | ||
|
|
2431491c92 | ||
|
|
2d597f0212 | ||
|
|
910508595a | ||
|
|
0680726ac4 | ||
|
|
8f0e9251b9 | ||
|
|
7f29e7a369 | ||
|
|
3039f3e17d | ||
|
|
06d8c54de2 | ||
|
|
75b0d723c0 | ||
|
|
f8a77f47f2 | ||
|
|
b0c8f65ffa | ||
|
|
7cfd1af395 | ||
|
|
bee8e9c805 | ||
|
|
431d88f4fc | ||
|
|
0369bdb8f4 | ||
|
|
a2a5539e78 | ||
|
|
072398bddc | ||
|
|
1aa3c02d56 | ||
|
|
bba6af2c71 | ||
|
|
b1ad469be4 | ||
|
|
8a7ad60f03 | ||
|
|
3fa4095629 | ||
|
|
408ac103ec | ||
|
|
28b3af5d0f | ||
|
|
0be5bb6c96 | ||
|
|
a851968170 | ||
| 731ba23515 | |||
|
|
675dd18182 | ||
|
|
bb959aae01 | ||
|
|
24053eab66 | ||
|
|
5b6058c3a7 | ||
|
|
ba0ec61563 | ||
|
|
b5e13bcd8a | ||
|
|
93b3b7c1fe | ||
|
|
d683a581b7 | ||
|
|
6b40c99a59 | ||
| ad62015fe3 | |||
|
|
11b5403fad | ||
|
|
f0989800cb | ||
|
|
3fcfb7abf7 | ||
|
|
c48f656f88 | ||
|
|
2fbdff9dab | ||
|
|
5be8c31fa7 | ||
|
|
21a904f4b7 | ||
|
|
9e9fc79478 | ||
|
|
f315d81491 | ||
|
|
62e2866228 | ||
|
|
6f29450e13 | ||
|
|
b15148fe37 | ||
|
|
f2585c4c82 | ||
|
|
57b6bdce71 | ||
|
|
aa3a5233ad | ||
|
|
032c01c1e1 | ||
|
|
f21f3c4bbd | ||
|
|
e320f09618 | ||
|
|
121f0edbf4 | ||
|
|
7a3cb6312e | ||
|
|
b4bff7f0d0 | ||
|
|
a7cdc679e7 | ||
|
|
17721d5e01 | ||
|
|
daab056ac1 | ||
|
|
adf567c1c6 | ||
|
|
1b2c32c653 | ||
|
|
0caca6cf3f | ||
|
|
6102dd2084 | ||
|
|
1fc80f4f6c | ||
|
|
be7d5cf585 | ||
|
|
a455f62f72 | ||
|
|
1c24336560 | ||
|
|
3f44d3e2eb | ||
|
|
1da903983a | ||
|
|
406a2214aa | ||
| d51a055d78 | |||
|
|
0ab2aa86af | ||
|
|
a3cd03e7ff | ||
|
|
2f5066d7a0 | ||
|
|
e537dba3de | ||
|
|
9fd75b8acb | ||
|
|
a7f9fe681d | ||
|
|
cf5edd6394 | ||
|
|
0b6c95dda0 | ||
|
|
eb01f59dc9 | ||
|
|
9ccfcd7cfc | ||
|
|
dacb367e9e | ||
|
|
82f2b1e908 | ||
|
|
28df940f0d | ||
|
|
4d9f9f455d | ||
|
|
66f432cae4 | ||
|
|
6b5923f107 | ||
|
|
5a9d72ea02 | ||
|
|
a0c2465d47 | ||
|
|
dfd4571cb0 | ||
|
|
aa03982169 | ||
|
|
c5582102f5 | ||
|
|
143b251e77 | ||
|
|
ae7a885da2 | ||
|
|
b7e721cb38 | ||
|
|
69ea1a6310 | ||
|
|
d81ac20920 | ||
|
|
d041b5cc35 | ||
|
|
c20ea06d4a | ||
|
|
3d02d1da11 | ||
|
|
9632bddd38 | ||
|
|
64b51efb6e | ||
|
|
359a0769f1 | ||
|
|
5cb4f597f7 | ||
|
|
66335d3a49 | ||
|
|
28c83e8385 | ||
|
|
1906074369 | ||
|
|
e4907b3b09 | ||
|
|
db3f9953ee | ||
|
|
081a0a2bef | ||
|
|
84cdd02400 | ||
|
|
8da077bcca | ||
|
|
def99d395e | ||
|
|
22e4549275 | ||
|
|
2326d5cb4a | ||
|
|
c6bdfa1971 | ||
|
|
4c4863654e | ||
|
|
44e33798fd | ||
|
|
8cef3c8d7f | ||
|
|
20b78eafb4 | ||
|
|
2edd6b432a | ||
|
|
c10fb95fd9 | ||
|
|
35120d7e48 | ||
|
|
ce3a72444e | ||
|
|
2527441b40 | ||
|
|
d8aef0feff | ||
|
|
085a08582f | ||
|
|
21917fed37 | ||
|
|
718215bd95 | ||
|
|
dce3e28516 | ||
|
|
f4f72d4ed5 | ||
|
|
9d007fe642 | ||
|
|
38f18be447 | ||
|
|
130ae577ac | ||
|
|
72fd911356 | ||
|
|
eb43bd4493 | ||
|
|
8c6c8a4ef3 | ||
|
|
b8f6f660e7 | ||
|
|
59d7025668 | ||
|
|
22ac2713a3 | ||
|
|
e34b3b3803 | ||
|
|
98697d2d7a | ||
|
|
ff3e9df5f5 | ||
|
|
89a92207f1 | ||
|
|
db594d3a59 | ||
|
|
3e73204242 | ||
|
|
b9bfa7ed46 | ||
|
|
2808fc03f4 | ||
|
|
c52c22b39e | ||
|
|
c45787411e | ||
|
|
ca66adfe60 | ||
|
|
592c34c056 | ||
|
|
1d33a8eb7a | ||
|
|
4f7d756df8 | ||
|
|
e0c2813a2a | ||
|
|
f9ce0bf0e7 | ||
|
|
e4d6f45efe | ||
|
|
e9a9322199 | ||
|
|
dc7b547f62 | ||
|
|
86314bf38d | ||
|
|
564ad70fb8 | ||
|
|
4ac20511be | ||
|
|
ea3465a8e3 | ||
|
|
135fb545f9 | ||
|
|
83afcdc24f | ||
|
|
ba6e5298df | ||
|
|
a98e087e5d | ||
|
|
51df39b9b9 | ||
|
|
0462d44f56 | ||
|
|
e30b1b18d0 | ||
|
|
25a4a8ed1e | ||
|
|
6cb23f9b5d | ||
|
|
7e72ca47c8 | ||
|
|
276cb1bb89 | ||
|
|
2125221bfc | ||
|
|
0d38e17b68 | ||
|
|
6e269edfd0 | ||
|
|
6014801229 | ||
|
|
13898f048f | ||
|
|
4c6c4482b4 | ||
|
|
7c08227019 | ||
|
|
7c531d0518 | ||
|
|
f58c1702cf | ||
|
|
afcf9ff0dc | ||
|
|
8216cca5a7 | ||
|
|
e56521e323 | ||
|
|
335e09ba0d | ||
|
|
34719d3f4f | ||
|
|
893ba99466 | ||
|
|
ce065efa7e | ||
|
|
91a2f86600 | ||
|
|
654e4529d5 | ||
|
|
f429ddbffd | ||
|
|
d4fa41ae3c | ||
|
|
b177333026 | ||
|
|
ad9081b7fa | ||
|
|
40832d1957 | ||
|
|
82f29ea442 | ||
|
|
735ccbd00f | ||
|
|
5d9b28ee43 | ||
|
|
3911d6f5cd | ||
|
|
74d81bd363 | ||
|
|
b55fe1bbd4 | ||
|
|
dbb9911a54 | ||
|
|
0897f175e4 | ||
|
|
36515a3793 | ||
|
|
886fa6cd11 | ||
|
|
c1633e609d | ||
|
|
cfefc30cf1 | ||
|
|
0ffa590a33 | ||
|
|
9c4a3f3518 | ||
|
|
31b961f2ac | ||
|
|
ed1952c5ca | ||
|
|
5992a62baa | ||
|
|
a213abb97e | ||
|
|
778f519b23 | ||
|
|
ba2d313858 | ||
|
|
a36a132c70 | ||
|
|
7921ae499a | ||
|
|
01a8c2d62c | ||
|
|
dbcfc16741 | ||
|
|
edd1212200 | ||
|
|
9d2bceba5d | ||
|
|
42ce989a62 | ||
|
|
4faac05419 | ||
|
|
ce554ad3de | ||
|
|
0c448e12e8 | ||
|
|
3d1d18fa96 | ||
|
|
c122bcde44 | ||
|
|
7619aaefa9 | ||
|
|
17e9e87576 | ||
|
|
7a5dc0d0db | ||
|
|
c8df99a795 | ||
|
|
7be8a10d23 | ||
|
|
b6b0f6923f | ||
|
|
20bcab26e0 | ||
|
|
93600d8980 | ||
|
|
f3b4e1285c | ||
|
|
abf07d0c4d | ||
|
|
8322745e28 | ||
|
|
f29472c387 | ||
|
|
0127caea7b | ||
|
|
f9fa077bbb | ||
|
|
9380d5f2e4 | ||
|
|
1ab2899800 | ||
|
|
4b4641c6b0 | ||
|
|
b64f3f5eae | ||
|
|
d1fb34bc8d | ||
|
|
3f1ce9b6e9 | ||
|
|
f9832ccb82 | ||
|
|
97ef317389 | ||
|
|
7e70886652 | ||
|
|
bfa3d1fa60 | ||
|
|
ae50dec121 | ||
|
|
53ca936adb | ||
|
|
b63d26332f | ||
|
|
458a7bc27c | ||
|
|
3e9f717445 | ||
|
|
5b31ffd744 | ||
|
|
9d5fb70b7e | ||
|
|
edd26444af | ||
|
|
17d39ccfd4 | ||
|
|
8e77f8797b | ||
|
|
e43f2c3d0a | ||
|
|
a29a7902e9 | ||
|
|
5f00f3f50f | ||
|
|
cbe8625528 | ||
|
|
1fc57a5d5b | ||
|
|
bcea4b2e27 | ||
|
|
b85bf65678 | ||
|
|
99aec8e02b | ||
|
|
1e168a89c5 | ||
|
|
6ab62a06d7 | ||
|
|
39a26c84f8 | ||
|
|
706f8720f8 | ||
|
|
16dc525e7b | ||
|
|
41d28a9232 | ||
|
|
52dc08c1f7 | ||
|
|
7c02a55632 | ||
|
|
91b46c2ba9 | ||
|
|
701801dec2 | ||
|
|
61babc24cf | ||
|
|
4bbe2959bd | ||
|
|
b62776e4a2 | ||
|
|
493efd422d | ||
|
|
26f1e00f71 | ||
|
|
2531fc130d | ||
|
|
59ec8003db | ||
|
|
4402100830 | ||
|
|
8fb1e877cd | ||
|
|
96f0edd02b | ||
|
|
7c2775f6f6 | ||
|
|
71446c6c2b | ||
|
|
fa1180b6b7 | ||
|
|
fc8a155901 | ||
|
|
3eb002766f | ||
|
|
e007fcd0d2 | ||
|
|
ce0e801d8e | ||
|
|
9600ded922 | ||
|
|
d0d1d8edca | ||
|
|
fc51a40d02 | ||
|
|
30b3ab3532 | ||
|
|
c5181a266e | ||
|
|
4a98f748bb | ||
|
|
15f1dea8df | ||
|
|
30f48feae7 | ||
|
|
89ba72c35d | ||
|
|
83a227aed3 | ||
|
|
ea2b70ddd9 | ||
|
|
98477f2ee7 | ||
|
|
b9566fc111 | ||
|
|
373bd1282e | ||
|
|
8100046cb8 | ||
|
|
c4f86e3992 | ||
|
|
3f89350cef | ||
|
|
ba09f1c9e0 | ||
|
|
2e1f07bac2 | ||
|
|
e9146a5ff7 | ||
|
|
c85dbb1620 | ||
|
|
d80bcc367e | ||
|
|
42379a37bd | ||
|
|
9b62f1f87c | ||
|
|
d801d88a11 | ||
|
|
7002c68f8b | ||
|
|
c841a6c78f | ||
|
|
b3ed545938 | ||
|
|
60e2e4332d | ||
|
|
11e868b790 | ||
|
|
11c3a3a399 | ||
|
|
092279126b | ||
|
|
0a91cc99d0 | ||
|
|
a8daa1d8ff | ||
|
|
baf4028fb9 | ||
|
|
7426ce977d | ||
|
|
c100620bf4 | ||
|
|
bf56a86e1e | ||
|
|
1a2a536dba | ||
|
|
c554364c2a | ||
|
|
ced73b396a | ||
|
|
11c3da4f74 | ||
|
|
b0c5554902 | ||
|
|
1367164204 | ||
|
|
2171cefbaa | ||
|
|
f953067814 | ||
|
|
6a38d7ff6e | ||
|
|
a93c6a5266 | ||
|
|
60a55a2399 | ||
|
|
7dad27e5f2 | ||
|
|
4868222e34 | ||
|
|
385a11bf53 | ||
|
|
088987b200 | ||
|
|
3b37c9e0a1 | ||
|
|
b8326ca935 | ||
|
|
3533843dd7 | ||
|
|
144b93861f | ||
|
|
cd2ae1b544 | ||
|
|
beefa6a4fe | ||
|
|
17cc86a479 | ||
|
|
aa4adac766 | ||
|
|
8011e20932 | ||
|
|
29851d01e4 | ||
|
|
5b7c5d2157 | ||
|
|
a3ebcd2322 | ||
|
|
715c82ce04 | ||
|
|
dce24ac9ff | ||
|
|
fec3871432 | ||
|
|
ea4eab15cf | ||
|
|
2e02c74dcb | ||
|
|
849f9d9c5a | ||
|
|
f1f7cd50c3 | ||
|
|
ac58c6f449 | ||
|
|
9fdc54b6f0 | ||
|
|
04e93af5c4 | ||
|
|
b141e1fa24 | ||
|
|
559a71683d | ||
|
|
9e64c72386 | ||
|
|
23b361cfd0 | ||
|
|
e78f1a3dac | ||
|
|
8a6775bbe6 | ||
|
|
ae9450dc51 | ||
|
|
06c6c324d7 | ||
|
|
48b6510c18 | ||
|
|
b944827623 | ||
|
|
f4f0ac7ada | ||
|
|
2787b601d7 | ||
|
|
5e8f0cf45e | ||
|
|
7be75baded | ||
|
|
725e2b7b2a | ||
|
|
c685b27369 | ||
|
|
32bcdc9739 | ||
|
|
6a21a52d91 | ||
|
|
29b3417a7a | ||
|
|
b1893b3d77 | ||
|
|
93ecdbea35 | ||
|
|
7670563374 | ||
|
|
aad8cf6600 | ||
|
|
dafcf61a34 | ||
|
|
f10cf57e96 | ||
|
|
479024603e | ||
|
|
7577d3b8d5 | ||
|
|
137547a470 | ||
|
|
4943c1d62c | ||
|
|
a7d8dd3d5d | ||
|
|
967fb4d8b9 | ||
|
|
0c4d704a7b | ||
|
|
ea713ba174 | ||
|
|
92e7f13dc0 | ||
|
|
64150668b8 | ||
|
|
b8f51957a0 | ||
|
|
dbadffdfae | ||
|
|
0f066367bf | ||
|
|
936f55ba9b | ||
|
|
ddb0f69a2f | ||
|
|
a294cdafd9 | ||
|
|
c65177370b | ||
|
|
96cad24a07 | ||
|
|
89b64d9dd5 | ||
|
|
64248840f8 | ||
|
|
363214bd32 | ||
|
|
93b2e7fae7 | ||
|
|
73a61654af | ||
|
|
f1218fa8aa | ||
|
|
085230373f | ||
|
|
9e2bfc7c40 | ||
|
|
730f2f1046 | ||
|
|
52e13870a1 | ||
|
|
21d80deda2 | ||
|
|
ceacacaa45 | ||
|
|
6b872be2b9 | ||
|
|
ed3904ddd3 | ||
|
|
266234047a | ||
|
|
b256ac7d77 | ||
|
|
7f3c340dc6 | ||
|
|
12f5c00cc1 | ||
|
|
d5ebc973d0 | ||
|
|
2ad5890bb5 | ||
|
|
238d7530fe | ||
|
|
8e6913711a | ||
|
|
a01cf7afae | ||
|
|
6730c04a65 | ||
|
|
c9e7049212 | ||
|
|
6f931797ca | ||
|
|
6d474f8c1a | ||
|
|
6236ad5077 | ||
|
|
485afc9c3f | ||
|
|
e2fd651eb9 | ||
|
|
1af823be9d | ||
|
|
734e08edf8 | ||
|
|
35f807cfaf | ||
|
|
2c708492e3 | ||
|
|
72104d88f9 | ||
|
|
e26fbb3d46 | ||
|
|
bfd6535f7b | ||
|
|
7bdc1d7944 | ||
|
|
d803768f6a | ||
|
|
27a193070d | ||
|
|
43d3cdd48a | ||
|
|
c5831df689 | ||
|
|
7afd80d788 | ||
|
|
5d31e70b8a | ||
|
|
d0fe8570e9 | ||
|
|
22a9c484e7 | ||
|
|
7c5c3d6fd6 | ||
|
|
758004d1bb | ||
|
|
163fd35fd8 | ||
|
|
0412f62f9e | ||
|
|
943236c9e6 | ||
|
|
9b65c24c44 | ||
|
|
f8d2c8aa6e | ||
|
|
87881ca686 | ||
|
|
b9ae2590f5 | ||
|
|
b4ee3aaeea | ||
|
|
4f40f62b7b | ||
|
|
656bd2cadf | ||
|
|
98ffad0437 | ||
|
|
a8af0b5e99 | ||
|
|
7ddc7dd1ec | ||
|
|
3ca474b15b | ||
|
|
80c918c247 | ||
|
|
0b54c87e24 | ||
|
|
93708de65d | ||
|
|
ac75abee43 | ||
|
|
a31ce0f47f | ||
|
|
b7ea82105a | ||
|
|
846906e4d1 | ||
|
|
9fcf924ac9 | ||
|
|
0ae909316a | ||
|
|
40c05f5539 | ||
|
|
7f30fb6136 | ||
|
|
a5cca409d8 | ||
|
|
e3faf41801 | ||
|
|
c00f1cf130 | ||
|
|
8bf2bfab58 | ||
|
|
e2b69a0bb1 | ||
|
|
c08c462461 | ||
|
|
e0b5d27af2 | ||
|
|
2829146dde | ||
|
|
8da1f3d131 | ||
|
|
c786efda9d | ||
|
|
a5cbd8e1aa | ||
|
|
f2b105dd56 | ||
|
|
6ff6dc6278 | ||
|
|
2b895e20cd | ||
|
|
5998b57958 | ||
|
|
64882c6a51 | ||
|
|
24e370fde5 | ||
|
|
d62c416cfd | ||
|
|
5ac874c849 | ||
|
|
2726e57cda | ||
|
|
9d84e28692 | ||
|
|
3d9e14d748 | ||
|
|
d68011dab1 | ||
|
|
68deecfb4f | ||
|
|
74739567b9 | ||
|
|
df72d5eefe | ||
|
|
4eadc8fd3e | ||
|
|
c3ce1d33d4 | ||
|
|
863341fbf5 | ||
|
|
f348a06294 | ||
|
|
c0f416aabc | ||
|
|
f8d0fa67dc | ||
|
|
f6b6b1f052 | ||
|
|
cb3c42ecf5 | ||
|
|
f2231fa5f7 | ||
|
|
533d37305c | ||
|
|
cf7f2e3dbb | ||
|
|
99dc6fcec8 | ||
|
|
8c2e3eddec | ||
|
|
fcd96df6f2 |
38
.github/APPROVED_CONTRIBUTORS
vendored
38
.github/APPROVED_CONTRIBUTORS
vendored
@@ -203,3 +203,41 @@ pandada8 pr
|
||||
npupko issue
|
||||
|
||||
chrisvariety pr
|
||||
|
||||
maximilianzuern pr
|
||||
|
||||
brianmichel pr
|
||||
|
||||
abhinavmathur-atlan pr
|
||||
|
||||
mattiacerutti pr
|
||||
|
||||
josephyoung pr
|
||||
|
||||
mbazso pr
|
||||
|
||||
AJM10565 pr
|
||||
|
||||
DanielThomas pr
|
||||
|
||||
MichaelYochpaz pr
|
||||
|
||||
stephanmck pr
|
||||
|
||||
rolfvreijdenberger pr
|
||||
|
||||
psoukie pr
|
||||
|
||||
vastxie pr
|
||||
|
||||
ItsumoSeito pr
|
||||
|
||||
davidlifschitz pr
|
||||
|
||||
vdxz pr
|
||||
|
||||
dangooddd pr
|
||||
|
||||
Mearman pr
|
||||
|
||||
dodiego pr
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -11,6 +11,8 @@ body:
|
||||
|
||||
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
|
||||
|
||||
**Important:** before reporting an issue in core, please validate first with `pi -ne` that this is not caused by an extension you loaded.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
|
||||
49
.github/ISSUE_TEMPLATE/package-report.yml
vendored
Normal file
49
.github/ISSUE_TEMPLATE/package-report.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Package Report
|
||||
description: Report a problematic Pi package listed on pi.dev
|
||||
labels: ["package-report"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Use this form to report a package listed on pi.dev. For Pi core bugs, use the bug report template instead.
|
||||
|
||||
New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi-mono/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply.
|
||||
|
||||
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
|
||||
|
||||
- type: input
|
||||
id: package-name
|
||||
attributes:
|
||||
label: Package name
|
||||
description: The npm package name from pi.dev.
|
||||
placeholder: "@scope/package"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: package-version
|
||||
attributes:
|
||||
label: Version
|
||||
description: The package version shown on pi.dev.
|
||||
placeholder: "0.1.0"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: report-type
|
||||
attributes:
|
||||
label: What are you reporting?
|
||||
options:
|
||||
- Malicious or unsafe behavior
|
||||
- Impersonation
|
||||
- Trademark / TOS Violations
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: details
|
||||
attributes:
|
||||
label: Details
|
||||
description: Describe the concern and include links, logs, or screenshots if helpful.
|
||||
validations:
|
||||
required: true
|
||||
121
.github/workflows/build-binaries.yml
vendored
121
.github/workflows/build-binaries.yml
vendored
@@ -10,25 +10,32 @@ on:
|
||||
description: 'Tag to build (e.g., v0.12.0)'
|
||||
required: true
|
||||
type: string
|
||||
source_ref:
|
||||
description: 'Source ref to build/publish (defaults to tag; use only for release recovery)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ env.RELEASE_TAG }}
|
||||
ref: ${{ env.SOURCE_REF }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: 1.2.20
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
@@ -44,36 +51,86 @@ jobs:
|
||||
run: |
|
||||
VERSION="${RELEASE_TAG}"
|
||||
VERSION="${VERSION#v}" # Remove 'v' prefix
|
||||
|
||||
# Extract changelog section for this version
|
||||
cd packages/coding-agent
|
||||
awk "/^## \[${VERSION}\]/{flag=1; next} /^## \[/{flag=0} flag" CHANGELOG.md > /tmp/release-notes.md
|
||||
|
||||
# If empty, use a default message
|
||||
if [ ! -s /tmp/release-notes.md ]; then
|
||||
echo "Release ${VERSION}" > /tmp/release-notes.md
|
||||
fi
|
||||
node scripts/release-notes.mjs extract --version "${VERSION}" --tag "${RELEASE_TAG}" --out /tmp/release-notes.md
|
||||
|
||||
- name: Create GitHub Release and upload binaries
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
cd packages/coding-agent/binaries
|
||||
|
||||
# Create release with changelog notes (or update if exists)
|
||||
gh release create "${RELEASE_TAG}" \
|
||||
--title "${RELEASE_TAG}" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
pi-darwin-arm64.tar.gz \
|
||||
pi-darwin-x64.tar.gz \
|
||||
pi-linux-x64.tar.gz \
|
||||
pi-linux-arm64.tar.gz \
|
||||
pi-windows-x64.zip \
|
||||
2>/dev/null || \
|
||||
gh release upload "${RELEASE_TAG}" \
|
||||
pi-darwin-arm64.tar.gz \
|
||||
pi-darwin-x64.tar.gz \
|
||||
pi-linux-x64.tar.gz \
|
||||
pi-linux-arm64.tar.gz \
|
||||
pi-windows-x64.zip \
|
||||
--clobber
|
||||
|
||||
release_assets=(
|
||||
pi-darwin-arm64.tar.gz
|
||||
pi-darwin-x64.tar.gz
|
||||
pi-linux-x64.tar.gz
|
||||
pi-linux-arm64.tar.gz
|
||||
pi-windows-x64.zip
|
||||
pi-windows-arm64.zip
|
||||
)
|
||||
sha256sum "${release_assets[@]}" > SHA256SUMS
|
||||
release_assets+=(SHA256SUMS)
|
||||
|
||||
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
|
||||
gh release edit "${RELEASE_TAG}" \
|
||||
--title "${RELEASE_TAG}" \
|
||||
--notes-file /tmp/release-notes.md
|
||||
gh release upload "${RELEASE_TAG}" "${release_assets[@]}" --clobber
|
||||
else
|
||||
gh release create "${RELEASE_TAG}" \
|
||||
--title "${RELEASE_TAG}" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
"${release_assets[@]}"
|
||||
fi
|
||||
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
environment: npm-publish
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ env.SOURCE_REF }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: npm
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep
|
||||
sudo ln -s $(which fdfind) /usr/local/bin/fd
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Check
|
||||
run: npm run check
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Verify release artifacts are committed
|
||||
run: git diff --exit-code
|
||||
|
||||
- name: Upgrade npm for trusted publishing
|
||||
run: |
|
||||
npm install -g npm@11.16.0 --ignore-scripts
|
||||
npm --version
|
||||
|
||||
- name: Publish npm packages
|
||||
run: node scripts/publish.mjs
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
sudo ln -s $(which fdfind) /usr/local/bin/fd
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
75
.github/workflows/issue-gate.yml
vendored
75
.github/workflows/issue-gate.yml
vendored
@@ -17,11 +17,8 @@ jobs:
|
||||
script: |
|
||||
const APPROVED_FILE = '.github/APPROVED_CONTRIBUTORS';
|
||||
const VALID_CAPABILITIES = new Set(['issue', 'pr']);
|
||||
const ISSUE_GATE_MESSAGE_MODE = 'refactor'; // Switch to 'normal' to restore the standard auto-close message.
|
||||
const issueAuthor = context.payload.issue.user.login;
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
const issueCreatedDay = new Date(context.payload.issue.created_at).getUTCDay();
|
||||
const isFridayThroughSunday = issueCreatedDay === 5 || issueCreatedDay === 6 || issueCreatedDay === 0;
|
||||
|
||||
if (issueAuthor.endsWith('[bot]') || issueAuthor === 'dependabot[bot]') {
|
||||
console.log(`Skipping bot: ${issueAuthor}`);
|
||||
@@ -97,36 +94,15 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
function buildNormalGateMessage() {
|
||||
return [
|
||||
'This issue was auto-closed. All issues from new contributors are auto-closed by default.',
|
||||
...(isFridayThroughSunday
|
||||
? [
|
||||
'Issues submitted Friday through Sunday are not reviewed. If this is urgent, ask on Discord: https://discord.com/invite/3cU7Bz4UPx',
|
||||
]
|
||||
: []),
|
||||
'',
|
||||
`Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
|
||||
'',
|
||||
'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open.',
|
||||
'',
|
||||
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildRefactorGateMessage() {
|
||||
return [
|
||||
'This issue was auto-closed. All issues will be closed until 2026-05-17 while the project refactor is being completed.',
|
||||
'',
|
||||
`The refactor is happening on \`${defaultBranch}\`: https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${defaultBranch}`,
|
||||
'',
|
||||
'Issues closed during this period will not be reviewed. The reason is that the refactor will not get done otherwise, because issue triage has been taking about 8 hours per day.',
|
||||
'',
|
||||
'In case of emergency, ask on Discord: https://discord.com/invite/3cU7Bz4UPx',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const message = ISSUE_GATE_MESSAGE_MODE === 'refactor' ? buildRefactorGateMessage() : buildNormalGateMessage();
|
||||
const message = [
|
||||
'This issue was auto-closed. All issues from new contributors are auto-closed by default.',
|
||||
'',
|
||||
`Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md) will not be reopened or receive a reply.`,
|
||||
'',
|
||||
'If a maintainer replies `lgtmi` on one of your issues, your future issues will stay open. If a maintainer replies `lgtm`, your future issues and PRs will stay open.',
|
||||
'',
|
||||
`See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md).`,
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
@@ -135,36 +111,17 @@ jobs:
|
||||
body: message,
|
||||
});
|
||||
|
||||
const labelsToAdd = [];
|
||||
if (isFridayThroughSunday) labelsToAdd.push('closed-because-weekend');
|
||||
if (ISSUE_GATE_MESSAGE_MODE === 'refactor') labelsToAdd.push('closed-because-refactor');
|
||||
|
||||
if (labelsToAdd.includes('closed-because-refactor')) {
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: 'closed-because-refactor',
|
||||
color: '5319e7',
|
||||
description: 'Closed while the project refactor is in progress',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status !== 422) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (labelsToAdd.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: labelsToAdd,
|
||||
});
|
||||
}
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['untriaged'],
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
|
||||
137
.github/workflows/issue-triage-labels.yml
vendored
Normal file
137
.github/workflows/issue-triage-labels.yml
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
name: Issue Triage Labels
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [reopened, labeled]
|
||||
|
||||
jobs:
|
||||
update-labels:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Update triage labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const UNTRIAGED_LABEL = 'untriaged';
|
||||
const NO_ACTION_LABEL = 'no-action';
|
||||
const LAST_READ_LABEL = 'last-read';
|
||||
const TO_DISCUSS_LABEL = 'to-discuss';
|
||||
const INPROGRESS_LABEL = 'inprogress';
|
||||
|
||||
function issueHasLabel(issue, labelName) {
|
||||
return (issue.labels ?? []).some((label) => label.name === labelName);
|
||||
}
|
||||
|
||||
async function removeLabelIfPresent(issueNumber, issue, labelName) {
|
||||
if (!issueHasLabel(issue, labelName)) {
|
||||
console.log(`Issue #${issueNumber} does not have ${labelName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
name: labelName,
|
||||
});
|
||||
console.log(`Removed ${labelName} from #${issueNumber}`);
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
console.log(`Label ${labelName} was already absent from #${issueNumber}`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (context.payload.action === 'reopened') {
|
||||
await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL);
|
||||
await removeLabelIfPresent(context.issue.number, context.payload.issue, NO_ACTION_LABEL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.payload.action !== 'labeled' || context.payload.label?.name !== LAST_READ_LABEL) {
|
||||
console.log('Not a last-read label event');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIssueNumber = context.issue.number;
|
||||
const lastReadIssues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'all',
|
||||
labels: LAST_READ_LABEL,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const previousIssueNumbers = lastReadIssues
|
||||
.filter((issue) => !issue.pull_request)
|
||||
.map((issue) => issue.number)
|
||||
.filter((issueNumber) => issueNumber !== currentIssueNumber);
|
||||
|
||||
if (previousIssueNumbers.length === 0) {
|
||||
console.log('No previous last-read issue found');
|
||||
return;
|
||||
}
|
||||
|
||||
const previousIssueNumber = Math.max(...previousIssueNumbers);
|
||||
if (currentIssueNumber <= previousIssueNumber) {
|
||||
console.log(
|
||||
`Last-read was added to old issue #${currentIssueNumber}; latest last-read is #${previousIssueNumber}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const untriagedIssues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'all',
|
||||
labels: UNTRIAGED_LABEL,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const issuesToMark = untriagedIssues
|
||||
.filter((issue) => !issue.pull_request)
|
||||
.filter((issue) => issue.number >= previousIssueNumber && issue.number <= currentIssueNumber)
|
||||
.sort((a, b) => a.number - b.number);
|
||||
|
||||
if (issuesToMark.length === 0) {
|
||||
console.log(`No untriaged issues found from #${previousIssueNumber} to #${currentIssueNumber}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const issue of issuesToMark) {
|
||||
if (issueHasLabel(issue, TO_DISCUSS_LABEL)) {
|
||||
console.log(`Skipped ${NO_ACTION_LABEL} for #${issue.number} because it has ${TO_DISCUSS_LABEL}`);
|
||||
} else {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [NO_ACTION_LABEL],
|
||||
});
|
||||
console.log(`Added ${NO_ACTION_LABEL} to #${issue.number}`);
|
||||
}
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
console.log(`Closed #${issue.number} as not planned`);
|
||||
|
||||
await removeLabelIfPresent(issue.number, issue, INPROGRESS_LABEL);
|
||||
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
name: UNTRIAGED_LABEL,
|
||||
});
|
||||
console.log(`Removed ${UNTRIAGED_LABEL} from #${issue.number}`);
|
||||
}
|
||||
29
.github/workflows/npm-audit.yml
vendored
Normal file
29
.github/workflows/npm-audit.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: npm audit
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies without lifecycle scripts
|
||||
run: npm ci --ignore-scripts --no-audit --no-fund
|
||||
|
||||
- name: Audit production vulnerabilities
|
||||
run: npm audit --omit=dev --audit-level=moderate
|
||||
|
||||
- name: Verify registry signatures
|
||||
run: npm audit signatures --omit=dev
|
||||
31
.github/workflows/remove-inprogress-on-close.yml
vendored
Normal file
31
.github/workflows/remove-inprogress-on-close.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Remove In Progress Label On Close
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
remove-label:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove inprogress label
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const labelName = 'inprogress';
|
||||
const labels = context.payload.issue.labels ?? [];
|
||||
const hasLabel = labels.some((label) => label.name === labelName);
|
||||
|
||||
if (!hasLabel) {
|
||||
console.log(`Issue does not have ${labelName} label`);
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: labelName,
|
||||
});
|
||||
46
.gitignore
vendored
46
.gitignore
vendored
@@ -1,5 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# SproutClaw persistent config — never commit
|
||||
.sproutclaw/
|
||||
*.log
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
@@ -25,7 +28,7 @@ packages/*/dist-firefox/
|
||||
.npm/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
.pi_config/
|
||||
.sproutclaw_config/
|
||||
tui-debug.log
|
||||
compaction-results/
|
||||
.opencode/
|
||||
@@ -36,12 +39,41 @@ out.html
|
||||
packages/coding-agent/binaries/
|
||||
todo.md
|
||||
plans/
|
||||
.pi/hf-sessions/
|
||||
.pi/hf-sessions-backup/
|
||||
.pi/agent/
|
||||
.pi/sessions/
|
||||
.pi/extensions/webui/.webui.pid
|
||||
.pi/extensions/webui/.webui.log
|
||||
.sproutclaw/hf-sessions/
|
||||
.sproutclaw/hf-sessions-backup/
|
||||
.sproutclaw/sessions/
|
||||
.sproutclaw/extensions/webui/.webui.pid
|
||||
.sproutclaw/extensions/webui/.webui.log
|
||||
# Local runtime / secrets (do not commit)
|
||||
.local/
|
||||
progress.md
|
||||
|
||||
# SproutClaw agent: track extensions, ignore local runtime secrets and sessions
|
||||
.sproutclaw/agent/sessions/
|
||||
.sproutclaw/agent/git/
|
||||
.sproutclaw/agent/npm/
|
||||
.sproutclaw/agent/bin/
|
||||
.sproutclaw/agent/data/
|
||||
.sproutclaw/agent/auth.json
|
||||
.sproutclaw/agent/models.json
|
||||
.sproutclaw/agent/settings.json
|
||||
.sproutclaw/agent/mcp.json
|
||||
.sproutclaw/agent/mcp-cache.json
|
||||
.sproutclaw/agent/mcp-npx-cache.json
|
||||
.sproutclaw/agent/run-history.jsonl
|
||||
.sproutclaw/agent/taskplane/
|
||||
.sproutclaw/agent/skills/
|
||||
.sproutclaw/agent/skills-disabled/
|
||||
.sproutclaw/agent/extensions/webui/frontend/node_modules/
|
||||
.sproutclaw/agent/extensions/webui/.webui.pid
|
||||
.sproutclaw/agent/extensions/webui/data/
|
||||
tmp/
|
||||
bun.lock
|
||||
collect.sh
|
||||
|
||||
# Root-level binary/build artifacts (do not commit)
|
||||
/*.wasm
|
||||
/llvm-*.js
|
||||
/rustc-*.js
|
||||
/util_cmd-*.js
|
||||
/wasm32-*.tar.br
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
# Get list of staged files before running check
|
||||
STAGED_FILES=$(git diff --cached --name-only)
|
||||
|
||||
node scripts/check-lockfile-commit.mjs
|
||||
if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the check script (formatting, linting, and type checking)
|
||||
echo "Running formatting, linting, and type checking..."
|
||||
npm run check
|
||||
@@ -33,7 +38,7 @@ fi
|
||||
# Restage files that were previously staged and may have been modified by formatting
|
||||
for file in $STAGED_FILES; do
|
||||
if [ -f "$file" ]; then
|
||||
git add "$file"
|
||||
git add "$file" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
303
AGENTS.md
303
AGENTS.md
@@ -4,244 +4,159 @@
|
||||
|
||||
- Keep answers short and concise
|
||||
- No emojis in commits, issues, PR comments, or code
|
||||
- No fluff or cheerful filler text
|
||||
- Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!")
|
||||
- No fluff or cheerful filler text (e.g., "Thanks @user" not "Thanks so much @user!")
|
||||
- Technical prose only, be direct
|
||||
- When the user asks a question, answer it first before making edits or running implementation commands.
|
||||
- When responding to user feedback or an analysis, explicitly say whether you agree or disagree before saying what you changed.
|
||||
|
||||
## Code Quality
|
||||
|
||||
- Read files in full before making wide-ranging changes, before editing files you have not already fully inspected, and when the user asks you to investigate or audit something. Do not rely only on search snippets for broad changes.
|
||||
- No `any` types unless absolutely necessary
|
||||
- Check node_modules for external API type definitions instead of guessing
|
||||
- **NEVER use inline imports** - no `await import("./foo.js")`, no `import("pkg").Type` in type positions, no dynamic imports for types. Always use standard top-level imports.
|
||||
- NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead
|
||||
- Always ask before removing functionality or code that appears to be intentional
|
||||
- Do not preserve backward compatibility unless the user explicitly asks for it
|
||||
- Never hardcode key checks with, eg. `matchesKey(keyData, "ctrl+x")`. All keybindings must be configurable. Add default to matching object (`DEFAULT_EDITOR_KEYBINDINGS` or `DEFAULT_APP_KEYBINDINGS`)
|
||||
- NEVER modify `packages/ai/src/models.generated.ts` directly. Update `packages/ai/scripts/generate-models.ts` instead.
|
||||
- Read files in full before wide-ranging changes, before editing files you have not fully inspected, and when asked to investigate or audit. Do not rely on search snippets for broad changes.
|
||||
- No `any` unless absolutely necessary.
|
||||
- Inline single-line helpers that have only one call site.
|
||||
- Check node_modules for external API types; don't guess.
|
||||
- **No inline imports** (`await import()`, `import("pkg").Type`, dynamic type imports). Top-level imports only.
|
||||
- Never remove or downgrade code to fix type errors from outdated deps; upgrade the dep instead.
|
||||
- Use only erasable TypeScript syntax (Node strip-only mode) in code checked by the root config (`packages/*/src`, `packages/*/test`, `packages/coding-agent/examples`): no parameter properties, `enum`, `namespace`/`module`, `import =`, `export =`, or other constructs needing JS emit. Use explicit fields with constructor assignments.
|
||||
- Always ask before removing functionality or code that appears intentional.
|
||||
- Do not preserve backward compatibility unless the user asks for it.
|
||||
- Never hardcode key checks (e.g. `matchesKey(keyData, "ctrl+x")`). Add defaults to `DEFAULT_EDITOR_KEYBINDINGS` or `DEFAULT_APP_KEYBINDINGS` so they stay configurable.
|
||||
- Never modify `packages/ai/src/models.generated.ts` directly; update `packages/ai/scripts/generate-models.ts` instead, then regenerate. Including the resulting `models.generated.ts` diff is always OK, even if regeneration includes unrelated upstream model metadata changes.
|
||||
|
||||
## Commands
|
||||
|
||||
- After code changes (not documentation changes): `npm run check` (get full output, no tail). Fix all errors, warnings, and infos before committing.
|
||||
- Note: `npm run check` does not run tests.
|
||||
- NEVER run: `npm run dev`, `npm run build`, `npm test`
|
||||
- Only run specific tests if user instructs: `npx tsx ../../node_modules/vitest/dist/cli.js --run test/specific.test.ts`
|
||||
- Run tests from the package root, not the repo root.
|
||||
- If you create or modify a test file, you MUST run that test file and iterate until it passes.
|
||||
- When writing tests, run them, identify issues in either the test or implementation, and iterate until fixed.
|
||||
- For `packages/coding-agent/test/suite/`, use `test/suite/harness.ts` plus the faux provider. Do not use real provider APIs, real API keys, or paid tokens.
|
||||
- Put issue-specific regressions under `packages/coding-agent/test/suite/regressions/` and name them `<issue-number>-<short-slug>.test.ts`.
|
||||
- NEVER commit unless user asks
|
||||
- After code changes (not docs): `npm run check` (full output, no tail). Fix all errors, warnings, and infos before committing. Does not run tests.
|
||||
- Never run `npm run build` or `npm test` unless requested by the user.
|
||||
- Never run the full vitest suite directly: it includes e2e tests that activate when endpoint/auth env vars are present. For all non-e2e tests, run `./test.sh` from the repo root. Otherwise run specific tests from the package root: `node ../../node_modules/vitest/dist/cli.js --run test/specific.test.ts`.
|
||||
- If you create or modify a test file, run it and iterate on test or implementation until it passes.
|
||||
- For `packages/coding-agent/test/suite/`, use `test/suite/harness.ts` + the faux provider. No real provider APIs, keys, or paid tokens.
|
||||
- Put issue-specific regressions under `packages/coding-agent/test/suite/regressions/` named `<issue-number>-<short-slug>.test.ts`.
|
||||
- For ad-hoc scripts, `write` them to a temp file (e.g. `/tmp`), run, edit if needed, remove when done. Don't embed multi-line scripts in `bash` commands.
|
||||
- Never commit unless the user asks.
|
||||
|
||||
## Contribution Gate
|
||||
## Dependency and Install Security
|
||||
|
||||
- New issues from new contributors are auto-closed by `.github/workflows/issue-gate.yml`
|
||||
- New PRs from new contributors without PR rights are auto-closed by `.github/workflows/pr-gate.yml`
|
||||
- Maintainer approval comments are handled by `.github/workflows/approve-contributor.yml`
|
||||
- Maintainers review auto-closed issues daily
|
||||
- Issues that do not meet the quality bar in `CONTRIBUTING.md` are not reopened and do not receive a reply
|
||||
- `lgtmi` approves future issues
|
||||
- `lgtm` approves future issues and rights to submit PRs
|
||||
- Treat npm dep and lockfile changes as reviewed code. Direct external deps stay pinned to exact versions.
|
||||
- Hydrate/update locally with `npm install --ignore-scripts`; clean/CI-style with `npm ci --ignore-scripts`. Don't run lifecycle scripts unless the user asks.
|
||||
- If dep metadata changes, refresh `package-lock.json` with `npm install --package-lock-only --ignore-scripts`.
|
||||
- If `packages/coding-agent/npm-shrinkwrap.json` needs regen, run `node scripts/generate-coding-agent-shrinkwrap.mjs` (verify with `--check` or `npm run check`). New deps with lifecycle scripts require review and an explicit allowlist entry in that script; never add one silently.
|
||||
- Pre-commit blocks lockfile commits unless `PI_ALLOW_LOCKFILE_CHANGE=1`. Don't bypass unless the user wants the lockfile change committed.
|
||||
|
||||
## Git
|
||||
|
||||
Multiple pi sessions may be running in this cwd at the same time, each modifying different files. Git operations that touch unstaged, staged, or untracked files outside your own changes will stomp on other sessions' work. Follow these rules:
|
||||
|
||||
Committing:
|
||||
|
||||
- Only commit files YOU changed in THIS session.
|
||||
- Stage explicit paths (`git add <path1> <path2>`); never `git add -A` / `git add .`.
|
||||
- Before committing, run `git status` and verify you are only staging your files.
|
||||
- `packages/ai/src/models.generated.ts` may always be included alongside your files.
|
||||
- Message format: `{feat,fix,docs}[(ai,tui,agent,coding-agent)]: <commit message> (optionally multiple lines)`. Message is informative and concise.
|
||||
|
||||
Never run (destroys other agents' work or bypasses checks):
|
||||
|
||||
- `git reset --hard`, `git checkout .`, `git clean -fd`, `git stash`, `git add -A`, `git add .`, `git commit --no-verify`.
|
||||
|
||||
If rebase conflicts occur:
|
||||
|
||||
- Resolve conflicts only in files you modified.
|
||||
- If a conflict is in a file you did not modify, abort and ask the user.
|
||||
- Never force push.
|
||||
|
||||
## Issues and PRs
|
||||
|
||||
See `CONTRIBUTING.md` for the contributor gate (auto-close workflows, `lgtm`/`lgtmi`, quality bar).
|
||||
|
||||
When reviewing PRs:
|
||||
|
||||
- Do not run `gh pr checkout`, `git switch`, or otherwise move the worktree to the PR branch unless the user explicitly asks.
|
||||
- Use `gh pr view`, `gh pr diff`, `gh api`, and local `git show`/`git diff` against fetched refs to inspect PR metadata, commits, and patches without changing branches.
|
||||
- If you need PR file contents, fetch/read them into temporary files or use `git show <ref>:<path>` without switching branches.
|
||||
|
||||
When creating issues:
|
||||
|
||||
- Add `pkg:*` labels to indicate which package(s) the issue affects
|
||||
- Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:tui`, `pkg:web-ui`
|
||||
- If an issue spans multiple packages, add all relevant labels
|
||||
- Add `pkg:*` labels for affected packages (`pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:tui`); use all that apply.
|
||||
|
||||
When posting issue/PR comments:
|
||||
|
||||
- Write the full comment to a temp file and use `gh issue comment --body-file` or `gh pr comment --body-file`
|
||||
- Never pass multi-line markdown directly via `--body` in shell commands
|
||||
- Preview the exact comment text before posting
|
||||
- Post exactly one final comment unless the user explicitly asks for multiple comments
|
||||
- If a comment is malformed, delete it immediately, then post one corrected comment
|
||||
- Keep comments concise, technical, and in the user's tone
|
||||
- Write the comment to a temp file and post with `gh issue/pr comment --body-file` (never multi-line markdown via `--body`).
|
||||
- Keep comments concise, technical, in the user's tone.
|
||||
- End every AI-posted comment with the AI-generated disclaimer line specified by the originating prompt (e.g. `This comment is AI-generated by `/wr``).
|
||||
|
||||
When closing issues via commit:
|
||||
|
||||
- Include `fixes #<number>` or `closes #<number>` in the commit message
|
||||
- This automatically closes the issue when the commit is merged
|
||||
|
||||
## PR Workflow
|
||||
|
||||
- Analyze PRs without pulling locally first
|
||||
- If the user approves: create a feature branch, pull PR, rebase on main, apply adjustments, commit, merge into main, push, close PR, and leave a comment in the user's tone
|
||||
- You never open PRs yourself. We work in feature branches until everything is according to the user's requirements, then merge into main, and push.
|
||||
- Include `fixes #<number>` or `closes #<number>` in the message so merging auto-closes the issue. For multiple issues, repeat the keyword per issue (`closes #1, closes #2`); a shared keyword (`closes #1, #2`) only closes the first.
|
||||
|
||||
## Testing pi Interactive Mode with tmux
|
||||
|
||||
To test pi's TUI in a controlled terminal environment:
|
||||
Run the TUI in a controlled terminal (from the repo root):
|
||||
|
||||
```bash
|
||||
# Create tmux session with specific dimensions
|
||||
tmux new-session -d -s pi-test -x 80 -y 24
|
||||
|
||||
# Start pi from source
|
||||
tmux send-keys -t pi-test "cd /Users/badlogic/workspaces/pi-mono && ./pi-test.sh" Enter
|
||||
|
||||
# Wait for startup, then capture output
|
||||
sleep 3 && tmux capture-pane -t pi-test -p
|
||||
|
||||
# Send input
|
||||
tmux send-keys -t pi-test "./test.sh" Enter
|
||||
sleep 3 && tmux capture-pane -t pi-test -p # capture after startup
|
||||
tmux send-keys -t pi-test "your prompt here" Enter
|
||||
|
||||
# Send special keys
|
||||
tmux send-keys -t pi-test Escape
|
||||
tmux send-keys -t pi-test C-o # ctrl+o
|
||||
|
||||
# Cleanup
|
||||
tmux send-keys -t pi-test Escape # special keys (also C-o for ctrl+o, etc.)
|
||||
tmux kill-session -t pi-test
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
Location: `packages/*/CHANGELOG.md` (each package has its own)
|
||||
Location: `packages/*/CHANGELOG.md` (one per package).
|
||||
|
||||
### Format
|
||||
Sections under `## [Unreleased]`: `### Breaking Changes` (API changes requiring migration), `### Added`, `### Changed`, `### Fixed`, `### Removed`.
|
||||
|
||||
Use these sections under `## [Unreleased]`:
|
||||
Rules:
|
||||
|
||||
- `### Breaking Changes` - API changes requiring migration
|
||||
- `### Added` - New features
|
||||
- `### Changed` - Changes to existing functionality
|
||||
- `### Fixed` - Bug fixes
|
||||
- `### Removed` - Removed features
|
||||
- All new entries go under `## [Unreleased]`. Read the full section first and append to existing subsections; never duplicate them.
|
||||
- Released version sections (e.g. `## [0.12.2]`) are immutable; never modify them.
|
||||
|
||||
### Rules
|
||||
Attribution:
|
||||
|
||||
- Before adding entries, read the full `[Unreleased]` section to see which subsections already exist
|
||||
- New entries ALWAYS go under `## [Unreleased]` section
|
||||
- Append to existing subsections (e.g., `### Fixed`), do not create duplicates
|
||||
- NEVER modify already-released version sections (e.g., `## [0.12.2]`)
|
||||
- Each version section is immutable once released
|
||||
|
||||
### Attribution
|
||||
|
||||
- **Internal changes (from issues)**: `Fixed foo bar ([#123](https://github.com/earendil-works/pi-mono/issues/123))`
|
||||
- **External contributions**: `Added feature X ([#456](https://github.com/earendil-works/pi-mono/pull/456) by [@username](https://github.com/username))`
|
||||
|
||||
## Adding a New LLM Provider (packages/ai)
|
||||
|
||||
Adding a new provider requires changes across multiple files:
|
||||
|
||||
### 1. Core Types (`packages/ai/src/types.ts`)
|
||||
|
||||
- Add API identifier to `Api` type union (e.g., `"bedrock-converse-stream"`)
|
||||
- Create options interface extending `StreamOptions`
|
||||
- Add mapping to `ApiOptionsMap`
|
||||
- Add provider name to `KnownProvider` type union
|
||||
|
||||
### 2. Provider Implementation (`packages/ai/src/providers/`)
|
||||
|
||||
Create provider file exporting:
|
||||
|
||||
- `stream<Provider>()` function returning `AssistantMessageEventStream`
|
||||
- `streamSimple<Provider>()` for `SimpleStreamOptions` mapping
|
||||
- Provider-specific options interface
|
||||
- Message/tool conversion functions
|
||||
- Response parsing emitting standardized events (`text`, `tool_call`, `thinking`, `usage`, `stop`)
|
||||
|
||||
### 3. Provider Exports and Lazy Registration
|
||||
|
||||
- Add a package subpath export in `packages/ai/package.json` pointing at `./dist/providers/<provider>.js`
|
||||
- Add `export type` re-exports in `packages/ai/src/index.ts` for provider option types that should remain available from the root entry
|
||||
- Register the provider in `packages/ai/src/providers/register-builtins.ts` via lazy loader wrappers, do not statically import provider implementation modules there
|
||||
- Add credential detection in `packages/ai/src/env-api-keys.ts`
|
||||
|
||||
### 4. Model Generation (`packages/ai/scripts/generate-models.ts`)
|
||||
|
||||
- Add logic to fetch/parse models from provider source
|
||||
- Map to standardized `Model` interface
|
||||
|
||||
### 5. Tests (`packages/ai/test/`)
|
||||
|
||||
- Always add the provider to `stream.test.ts` with at least one representative model, even if it reuses an existing API implementation such as `openai-completions`.
|
||||
- Add the provider to the broader provider matrix where applicable: `tokens.test.ts`, `abort.test.ts`, `empty.test.ts`, `context-overflow.test.ts`, `unicode-surrogate.test.ts`, `tool-call-without-result.test.ts`, `image-tool-result.test.ts`, `total-tokens.test.ts`, `cross-provider-handoff.test.ts`.
|
||||
- For `cross-provider-handoff.test.ts`, add at least one provider/model pair. If the provider exposes multiple model families (for example GPT and Claude), add at least one pair per family.
|
||||
- For non-standard auth, create utility (e.g., `bedrock-utils.ts`) with credential detection.
|
||||
|
||||
### 6. Coding Agent (`packages/coding-agent/`)
|
||||
|
||||
- `src/core/model-resolver.ts`: Add default model ID to `defaultModelPerProvider`
|
||||
- `src/core/provider-display-names.ts`: Add API-key login display name so `/login` and related UI show the provider for built-in API-key auth.
|
||||
- `src/cli/args.ts`: Add env var documentation
|
||||
- `README.md`: Add provider setup instructions
|
||||
- `docs/providers.md`: Add setup instructions, env var, and `auth.json` key
|
||||
|
||||
### 7. Documentation
|
||||
|
||||
- `packages/ai/README.md`: Add to providers table, document options/auth, add env vars
|
||||
- `packages/ai/CHANGELOG.md`: Add entry under `## [Unreleased]`
|
||||
- Internal (from issues): `Fixed foo bar ([#123](https://github.com/earendil-works/pi-mono/issues/123))`
|
||||
- External contributions: `Added feature X ([#456](https://github.com/earendil-works/pi-mono/pull/456) by [@username](https://github.com/username))`
|
||||
|
||||
## Releasing
|
||||
|
||||
**Lockstep versioning**: All packages always share the same version number. Every release updates all packages together.
|
||||
**Lockstep versioning**: all packages share one version; every release updates all together. `patch` = fixes + additions, `minor` = breaking changes. No major releases.
|
||||
|
||||
**Version semantics** (no major releases):
|
||||
1. **Update CHANGELOGs**: ask the user whether they ran the `/cl` prompt on the latest commit on `main`. If not, they must run `/cl` first to audit and update each package's `[Unreleased]` section before releasing.
|
||||
|
||||
- `patch`: Bug fixes and new features
|
||||
- `minor`: API breaking changes
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Update CHANGELOGs**: Ensure all changes since last release are documented in the `[Unreleased]` section of each affected package's CHANGELOG.md
|
||||
|
||||
2. **Run release script**:
|
||||
2. **Local smoke test**: build an unpublished release and smoke test from outside the repo (so it can't resolve workspace files):
|
||||
```bash
|
||||
npm run release:patch # Fixes and additions
|
||||
npm run release:minor # API breaking changes
|
||||
npm run release:local -- --out /tmp/pi-local-release --force
|
||||
cd /tmp
|
||||
|
||||
# Node package install smoke tests
|
||||
/tmp/pi-local-release/node/pi --help
|
||||
/tmp/pi-local-release/node/pi --version
|
||||
/tmp/pi-local-release/node/pi --list-models
|
||||
/tmp/pi-local-release/node/pi -p "Say exactly: ok"
|
||||
/tmp/pi-local-release/node/pi
|
||||
|
||||
# Bun binary smoke tests
|
||||
/tmp/pi-local-release/bun/pi --help
|
||||
/tmp/pi-local-release/bun/pi --version
|
||||
/tmp/pi-local-release/bun/pi --list-models
|
||||
/tmp/pi-local-release/bun/pi -p "Say exactly: ok"
|
||||
/tmp/pi-local-release/bun/pi
|
||||
```
|
||||
Verify both Node and Bun startup, model/account listing, interactive startup, and at least one real prompt with the intended default provider. The bare commands `/tmp/pi-local-release/node/pi` and `/tmp/pi-local-release/bun/pi` start interactive mode; run each in tmux, submit a prompt, and wait for the model reply before considering the interactive smoke test passed. Failures are release blockers unless the user explicitly accepts the risk.
|
||||
|
||||
The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections.
|
||||
3. **Run the release script**:
|
||||
```bash
|
||||
PI_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:patch # fixes + additions
|
||||
PI_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:minor # breaking changes
|
||||
```
|
||||
Use `npm_config_min_release_age=0` only for the release command. The repo's normal npm age gate can otherwise block the release lockfile refresh when the current workspace package version was published recently. Review any lockfile or shrinkwrap diffs the release creates before push.
|
||||
|
||||
## **CRITICAL** Git Rules for Parallel Agents **CRITICAL**
|
||||
The release script bumps all package versions, updates changelogs, regenerates release artifacts, runs `npm run check`, commits `Release vX.Y.Z`, tags `vX.Y.Z`, adds fresh `## [Unreleased]` changelog sections, commits `Add [Unreleased] section for next cycle`, then pushes `main` and the tag. Do not rerun the release script after a tag was pushed.
|
||||
|
||||
Multiple agents may work on different files in the same worktree simultaneously. You MUST follow these rules:
|
||||
4. **CI publishes npm packages**: pushing the `vX.Y.Z` tag triggers `.github/workflows/build-binaries.yml`. The `publish-npm` job uses npm trusted publishing through GitHub Actions OIDC with environment `npm-publish`; no local `npm publish`, `npm whoami`, OTP, or WebAuthn flow is required.
|
||||
|
||||
### Committing
|
||||
5. **If CI publish fails**: inspect the failed `publish-npm` job. The publish helper is idempotent and skips package versions already present on npm, so rerun the tag workflow after fixing CI or transient npm issues. Do not rerun `npm run release:patch` or `npm run release:minor` for the same version.
|
||||
|
||||
- **ONLY commit files YOU changed in THIS session**
|
||||
- ALWAYS include `fixes #<number>` or `closes #<number>` in the commit message when there is a related issue or PR
|
||||
- NEVER use `git add -A` or `git add .` - these sweep up changes from other agents
|
||||
- ALWAYS use `git add <specific-file-paths>` listing only files you modified
|
||||
- Before committing, run `git status` and verify you are only staging YOUR files
|
||||
- Track which files you created/modified/deleted during the session
|
||||
- It is always fine to include `packages/ai/src/models.generated.ts` in a commit alongside the actual files you want to commit
|
||||
## User Override
|
||||
|
||||
### Forbidden Git Operations
|
||||
|
||||
These commands can destroy other agents' work:
|
||||
|
||||
- `git reset --hard` - destroys uncommitted changes
|
||||
- `git checkout .` - destroys uncommitted changes
|
||||
- `git clean -fd` - deletes untracked files
|
||||
- `git stash` - stashes ALL changes including other agents' work
|
||||
- `git add -A` / `git add .` - stages other agents' uncommitted work
|
||||
- `git commit --no-verify` - bypasses required checks and is never allowed
|
||||
|
||||
### Safe Workflow
|
||||
|
||||
```bash
|
||||
# 1. Check status first
|
||||
git status
|
||||
|
||||
# 2. Add ONLY your specific files
|
||||
git add packages/ai/src/providers/transform-messages.ts
|
||||
git add packages/ai/CHANGELOG.md
|
||||
|
||||
# 3. Commit
|
||||
git commit -m "fix(ai): description"
|
||||
|
||||
# 4. Push (pull --rebase if needed, but NEVER reset/checkout)
|
||||
git pull --rebase && git push
|
||||
```
|
||||
|
||||
### If Rebase Conflicts Occur
|
||||
|
||||
- Resolve conflicts in YOUR files only
|
||||
- If conflict is in a file you didn't modify, abort and ask the user
|
||||
- NEVER force push
|
||||
|
||||
### User override
|
||||
|
||||
If the user instructions conflict with rules set out here, ask for confirmation that they want to override the rules. Only then execute their instructions.
|
||||
If the user's instructions conflict with any rule in this document, ask for explicit confirmation before overriding. Only then execute their instructions.
|
||||
|
||||
102
CONTRIBUTING.md
102
CONTRIBUTING.md
@@ -1,93 +1,97 @@
|
||||
# Contributing to pi
|
||||
# 贡献指南
|
||||
|
||||
This guide exists to save both sides time.
|
||||
本指南旨在为双方节省时间。
|
||||
|
||||
## The One Rule
|
||||
## 设计理念
|
||||
|
||||
**You must understand your code.** If you cannot explain what your changes do and how they interact with the rest of the system, your PR will be closed.
|
||||
首先明确一点:**SproutClaw 的核心保持精简**。
|
||||
|
||||
Using AI to write code is fine. Submitting AI-generated slop without understanding it is not.
|
||||
如果你的功能不属于核心,它应该以扩展的形式实现。会让核心臃肿的 PR 大概率会被拒绝。
|
||||
|
||||
If you use an agent, run it from the `pi-mono` root directory so it picks up `AGENTS.md` automatically. Your agent must follow the rules and guidelines in that file.
|
||||
SproutClaw 的核心存在的意义在于精简且可扩展,以便扩展能够影响和定制其行为。即便是扩展的 hook 点也需要经过充分考量和讨论,避免引入难以维护的冗余代码和复杂交互。
|
||||
|
||||
## Contribution Gate
|
||||
## 唯一准则
|
||||
|
||||
All issues and PRs from new contributors are auto-closed by default.
|
||||
**你必须理解自己提交的代码。** 如果你无法解释你的改动做了什么、以及它与系统其他部分如何交互,你的 PR 将被关闭。
|
||||
|
||||
Issues submitted Friday through Sunday are not reviewed. If something is urgent, ask on Discord: https://discord.com/invite/3cU7Bz4UPx
|
||||
使用 AI 辅助编写代码没有问题,但在不理解代码的情况下直接提交 AI 生成的内容是不被接受的。
|
||||
|
||||
Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar below will not be reopened or receive a reply.
|
||||
如果你使用 Agent,请在 `sproutclaw` 根目录运行,这样它会自动读取 `AGENTS.md`。你的 Agent 必须遵循该文件中的规则和指南。
|
||||
|
||||
Approval happens through maintainer replies on issues:
|
||||
## 贡献门槛
|
||||
|
||||
- `lgtmi`: your future issues will not be auto-closed
|
||||
- `lgtm`: your future issues and PRs will not be auto-closed
|
||||
来自新贡献者的 issue 和 PR 默认会被自动关闭。
|
||||
|
||||
`lgtmi` does not grant rights to submit PRs. Only `lgtm` grants rights to submit PRs.
|
||||
周五至周日提交的 issue 不保证会被审查。如有紧急问题,请在内部联系维护者。
|
||||
|
||||
## Quality Bar For Issues
|
||||
维护者会每日审查被自动关闭的 issue,并重新开放值得处理的内容。不符合以下质量标准的 issue 将不会被重新开放或收到回复。
|
||||
|
||||
If you open an issue, you must use one of the two GitHub issue templates.
|
||||
审批通过由维护者在 issue 中回复:
|
||||
|
||||
If you open an issue, keep it short, concrete, and worth reading.
|
||||
- `lgtmi`:你未来提交的 issue 将不再被自动关闭
|
||||
- `lgtm`:你未来提交的 issue 和 PR 将不再被自动关闭
|
||||
|
||||
- Keep it concise. If it does not fit on one screen, it is too long.
|
||||
- Write in your own voice.
|
||||
- State the bug or request clearly.
|
||||
- Explain why it matters.
|
||||
- If you want to implement the change yourself, say so.
|
||||
`lgtmi` 不授予提交 PR 的权限,只有 `lgtm` 才授予该权限。
|
||||
|
||||
If the issue is real and written well, a maintainer may reopen it, reply `lgtmi`, or reply `lgtm`.
|
||||
## Issue 质量标准
|
||||
|
||||
## Blocking
|
||||
提交 issue 时,必须使用两个 GitHub issue 模板之一。
|
||||
|
||||
If you ignore this document twice, or if you spam the tracker with agent-generated issues, your GitHub account will be permanently blocked.
|
||||
提交的 issue 应简洁、具体、有阅读价值:
|
||||
|
||||
If you send a large volume of issues through automation, your GitHub account will be permanently blocked. No taksies backsies.
|
||||
- 保持简短。如果超过一屏,就太长了。
|
||||
- 用自己的语言表达(不要用 LLM 生成文本;如果必须,请在后续评论中明确标注为 AI 生成)。
|
||||
- 清晰说明 bug 或需求。
|
||||
- 解释其重要性。
|
||||
- 如果你希望自己来实现,请说明。
|
||||
|
||||
## Before Submitting a PR
|
||||
如果 issue 真实且表述良好,维护者可能会重新开放、回复 `lgtmi` 或回复 `lgtm`。
|
||||
|
||||
Do not open a PR unless you have already been approved with `lgtm`.
|
||||
## 封禁规则
|
||||
|
||||
Before submitting a PR:
|
||||
如果你两次无视本文档,或通过 Agent 批量刷 issue,你的账号将被永久封禁。
|
||||
|
||||
如果你通过自动化手段大量提交 issue,你的账号将被永久封禁,且不可申诉。
|
||||
|
||||
## 提交 PR 前
|
||||
|
||||
未获得 `lgtm` 批准前,请勿提交 PR。
|
||||
|
||||
提交 PR 前,请先执行:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
./test.sh
|
||||
```
|
||||
|
||||
Both must pass.
|
||||
两项均须通过。
|
||||
|
||||
Do not edit `CHANGELOG.md`. Changelog entries are added by maintainers.
|
||||
不要修改 `CHANGELOG.md`,更新日志条目由维护者添加。
|
||||
|
||||
If you are adding a new provider to `packages/ai`, see `AGENTS.md` for required tests.
|
||||
如果你要在 `packages/ai` 中添加新的 provider,请参阅 `AGENTS.md` 中关于必要测试的说明。
|
||||
|
||||
## Philosophy
|
||||
## 有疑问?
|
||||
|
||||
pi's core is minimal. If your feature does not belong in the core, it should be an extension. PRs that bloat the core will likely be rejected.
|
||||
请联系项目维护者。
|
||||
|
||||
## Questions?
|
||||
## 常见问题
|
||||
|
||||
Ask on [Discord](https://discord.com/invite/nKXTsAcmbT).
|
||||
### 为什么新 issue 和 PR 会被自动关闭?
|
||||
|
||||
## FAQ
|
||||
SproutClaw 收到的 issue 数量超过维护者能够实时处理的上限。许多报告不符合本指南的质量标准,或者是通过 Agent 随意提交而未经本人审查和整理的。自动关闭机制让维护者可以按自己的节奏审查,并重新开放符合质量要求的 issue。
|
||||
|
||||
### Why are new issues and PRs auto-closed?
|
||||
### 为什么周末的 issue 优先级较低?
|
||||
|
||||
pi receives more issues than the maintainers can responsibly review in real time. Many reports do not meet the quality bar in this guide or do not follow CONTRIBUTING.md. Some are slung at the repository mindlessly via an agent instead of being reviewed and shaped by the person submitting them. Auto-closing creates a buffer so maintainers can review the tracker on their own schedule and reopen the issues that meet the quality bar.
|
||||
维护者在工作时间处理 issue。周末可能积累更多未处理内容,周五至周日提交的 issue 可能在周一审查时被遗漏或优先级降低。如有紧急问题,请直接联系维护者并附上简要描述、复现步骤和相关日志。
|
||||
|
||||
### Why are weekend issues not reviewed?
|
||||
### 为什么有些 issue 没有收到回复?
|
||||
|
||||
Maintainers need uninterrupted time away from the issue tracker. Issues submitted Friday through Sunday are auto-closed and are not part of the Monday review queue. If a problem is urgent, ask on Discord and include the short version, a repro, and the relevant logs.
|
||||
回复本身也是一种维护成本。低质量 issue、表述不清的报告、重复 issue 以及不遵循本指南的 issue 可能被直接关闭而不作说明。这样可以将时间留给可复现的 bug、深思熟虑的需求,以及真正用心整理报告的贡献者。
|
||||
|
||||
### Why do some issues get no reply?
|
||||
### 为什么不用 AI 来做所有分类工作?
|
||||
|
||||
A reply is maintenance work too. Low-signal issues, unclear reports, duplicates, and issues that do not follow this guide may be closed without discussion. This keeps time available for reproducible bugs, thoughtful requests, and contributors who have done the work to make their report actionable.
|
||||
AI 可以帮助归纳重复内容、汇总报告、发现缺失信息,但不被信任来做最终的维护决策。经过润色的 AI 生成 issue 仍然可能是错误的、有误导性的,或需要大量时间排查。人工审查始终是最终把关环节。
|
||||
|
||||
### Why not let AI triage everything?
|
||||
### 这对贡献者是否过于苛刻?
|
||||
|
||||
AI can help group duplicates, summarize reports, and spot missing information. It is not trusted to make final maintainer decisions. Polished AI-generated issues can still be wrong, misleading, or expensive to investigate. Human review remains the final gate.
|
||||
|
||||
### Is this hostile to contributors?
|
||||
|
||||
No. It is a guardrail against burnout and tracker spam. Short, concrete, reproducible issues are welcome. Thoughtful contributions are welcome. Automated slop, entitlement, and large volumes of low-effort reports are not.
|
||||
不是。这是防止维护者精力耗尽和 issue 刷屏的保护机制。简洁、具体、可复现的 issue 是受欢迎的,有深度的贡献同样受欢迎。自动化生成的低质量内容、无理取闹以及大量低价值报告则不被接受。
|
||||
|
||||
183
README.md
183
README.md
@@ -1,6 +1,6 @@
|
||||
# sproutclaw
|
||||
|
||||
`sproutclaw` 是一个面向服务器运维与开发工作的 Agent 助手,基于 pi-mono 改造,重点服务于日常服务器管理、Docker 部署、项目开发、故障排查和自动化工作流。
|
||||
`sproutclaw` 是一个面向服务器运维与开发工作的 Agent 助手,基于 [pi-mono](https://github.com/badlogic/pi-mono) 改造,重点服务于日常服务器管理、Docker 部署、项目开发、故障排查和自动化工作流。
|
||||
|
||||
## 定位
|
||||
|
||||
@@ -8,51 +8,192 @@
|
||||
- 开发协作助手:理解本地项目结构,执行代码修改、测试验证和 Git 工作流。
|
||||
- Docker 部署助手:按项目目录组织 `docker compose` 服务,关注数据持久化、端口规划和资源限制。
|
||||
- 内网服务助手:适配 smallmengya、bigmengya、alycd 等内网服务器使用习惯。
|
||||
- WebUI/TUI 双入口:保留控制台 TUI,同时扩展网页前端,方便在浏览器里管理会话和模型。
|
||||
- WebUI/TUI 双入口:保留控制台 TUI,同时提供独立的网页前端(`sproutclaw-web`)。
|
||||
|
||||
## 项目结构
|
||||
|
||||
| 路径 | 说明 |
|
||||
| --- | --- |
|
||||
| `packages/coding-agent` | 交互式 Agent CLI 与 TUI 主体 |
|
||||
| `packages/coding-agent` | 交互式 Agent CLI 与 TUI 主体(含 sproutclaw 定制补丁) |
|
||||
| `packages/agent` | Agent 运行时、工具调用和状态管理 |
|
||||
| `packages/ai` | 多模型、多 provider 的 LLM 接入层 |
|
||||
| `packages/tui` | 终端 UI 渲染库 |
|
||||
| `packages/web-ui` | Web UI 组件 |
|
||||
| `.pi/extensions/webui` | 本地扩展的网页对话入口 |
|
||||
| `sproutclaw` / `sproutclaw.bat` | 启动入口(从已构建 `dist/cli.js` 启动) |
|
||||
| `build.sh` / `build.bat` | 同上,使用已构建版启动 |
|
||||
| `test.sh` / `test.bat` | 从源码(tsx)启动,用于开发调试 |
|
||||
| `scripts/sync-upstream.sh` | 合并上游 pi-mono 更新 |
|
||||
| `scripts/push-sproutclaw.sh` | 推送到 GitHub / Gitea |
|
||||
|
||||
> `.sproutclaw/agent/` 为本地持久化配置目录(settings、auth、sessions、extensions、skills 等),已整体加入 `.gitignore`,不进入版本控制。与原版 pi 的 `~/.pi/agent/` 完全隔离。
|
||||
|
||||
## 常用命令
|
||||
|
||||
安装依赖并构建:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm install --ignore-scripts
|
||||
npm run build
|
||||
npm run check
|
||||
./test.sh
|
||||
./pi-test.sh
|
||||
```
|
||||
|
||||
运行本地源码版 Agent:
|
||||
从源码运行(开发调试,无需 build):
|
||||
|
||||
```bash
|
||||
./pi-test.sh
|
||||
./test.sh
|
||||
```
|
||||
|
||||
启动 WebUI 时,在 TUI 中执行:
|
||||
从已构建版运行(日常更快):
|
||||
|
||||
```bash
|
||||
./build.sh
|
||||
# 或
|
||||
./sproutclaw
|
||||
```
|
||||
|
||||
检查代码:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
```
|
||||
|
||||
## WebUI(sproutclaw-web)
|
||||
|
||||
WebUI 是独立项目,位于 `../sproutclaw-web/`,通过 systemd 以服务形式运行。
|
||||
|
||||
构建后端:
|
||||
|
||||
```bash
|
||||
cd ../sproutclaw-web
|
||||
bash build.sh
|
||||
```
|
||||
|
||||
构建前端:
|
||||
|
||||
```bash
|
||||
cd ../sproutclaw-web/frontend
|
||||
npm install && npm run build
|
||||
```
|
||||
|
||||
管理服务:
|
||||
|
||||
```bash
|
||||
systemctl start sproutclaw-web
|
||||
systemctl stop sproutclaw-web
|
||||
systemctl restart sproutclaw-web
|
||||
systemctl status sproutclaw-web
|
||||
```
|
||||
|
||||
## Git 分支策略
|
||||
|
||||
本仓库是 pi-mono 的 fork,同时维护**上游同步**和 **sproutclaw 定制**。
|
||||
|
||||
| 分支 | 用途 |
|
||||
| --- | --- |
|
||||
| `main` | 稳定可跑版本:已合并的上游 + sproutclaw 定制,推送到 GitHub / Gitea |
|
||||
| `feature/*` | 日常开发:WebUI、扩展、补丁等,完成后 merge 进 `main` |
|
||||
|
||||
### Remote 配置
|
||||
|
||||
```text
|
||||
/webui on 19133
|
||||
origin → https://github.com/shumengya/sproutclaw.git
|
||||
upstream → https://github.com/badlogic/pi-mono.git
|
||||
gitea → ssh://git@git.shumengya.top:8022/shumengya/sproutclaw.git
|
||||
```
|
||||
|
||||
关闭 WebUI:
|
||||
首次配置 upstream:
|
||||
|
||||
```text
|
||||
/webui off
|
||||
```bash
|
||||
git remote add upstream https://github.com/badlogic/pi-mono.git
|
||||
git remote add gitea ssh://git@git.shumengya.top:8022/shumengya/sproutclaw.git
|
||||
```
|
||||
|
||||
### 日常开发
|
||||
|
||||
```bash
|
||||
git checkout -b feature/my-fix
|
||||
# ... 开发、测试 ...
|
||||
git add <改动的具体文件>
|
||||
git commit -m "fix: ..."
|
||||
git checkout main
|
||||
git merge feature/my-fix
|
||||
./scripts/push-sproutclaw.sh
|
||||
git branch -d feature/my-fix
|
||||
```
|
||||
|
||||
### 推送到 GitHub / Gitea
|
||||
|
||||
```bash
|
||||
./scripts/push-sproutclaw.sh
|
||||
# 或手动
|
||||
git push origin main && git push gitea main
|
||||
```
|
||||
|
||||
### 合并上游 pi-mono 更新
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git merge upstream/main --no-commit --no-ff
|
||||
# 解决冲突(见下方惯例),然后:
|
||||
git add <resolved-files>
|
||||
PI_ALLOW_LOCKFILE_CHANGE=1 git commit
|
||||
./scripts/push-sproutclaw.sh
|
||||
```
|
||||
|
||||
**合并冲突处理惯例:**
|
||||
|
||||
| 文件类型 | 处理方式 |
|
||||
| --- | --- |
|
||||
| `README.md`、sproutclaw 定制代码 | 保留本仓库版本 |
|
||||
| `*.generated.ts` | 采用 upstream |
|
||||
| `package-lock.json` | 采用 upstream,再 `npm install --ignore-scripts` |
|
||||
| 测试/文档/新增上游功能 | 采用 upstream |
|
||||
|
||||
合并后必做:
|
||||
|
||||
```bash
|
||||
npm install --ignore-scripts
|
||||
npm run build
|
||||
./test.sh
|
||||
```
|
||||
|
||||
### sproutclaw 相对 upstream 的定制改动
|
||||
|
||||
合并上游时,以下内容**不要误删**:
|
||||
|
||||
- `packages/coding-agent/package.json` 中 `piConfig.name = sproutclaw`、`configDir = .sproutclaw`(命名空间隔离)
|
||||
- `showChangelogOnStartup` 设置项(关闭启动 What's New)
|
||||
- RPC 扩展:`reload` 命令、bash 流式输出(`bash_update`)、`get_extensions` 接口
|
||||
- `sproutclaw` / `build.sh` / `test.sh` 等启动脚本
|
||||
- `scripts/sync-upstream.sh`、`scripts/push-sproutclaw.sh` 等工具脚本
|
||||
- 本 README 及中文版 CONTRIBUTING.md / SECURITY.md
|
||||
|
||||
## 本地配置
|
||||
|
||||
`.sproutclaw/agent/` 中以下内容不进 Git:
|
||||
|
||||
- `settings.json` → 参考 `settings.example.json` 本地复制
|
||||
- `models.json`、`auth.json`、`mcp.json`
|
||||
- `sessions/`、`skills/`、`extensions/`、`prompts/`
|
||||
|
||||
环境变量(可选):
|
||||
|
||||
- `SPROUTCLAW_CODING_AGENT_DIR` — 覆盖 agent 配置目录,默认 `<repo>/.sproutclaw/agent`
|
||||
|
||||
首次部署:
|
||||
|
||||
```bash
|
||||
cp .sproutclaw/agent/settings.example.json .sproutclaw/agent/settings.json
|
||||
# 编辑 settings.json 填入 provider / model / auth 信息
|
||||
```
|
||||
|
||||
### 与原版 pi 并存
|
||||
|
||||
| | 原版 pi | SproutClaw |
|
||||
|---|---|---|
|
||||
| 命令 | `pi` | `sproutclaw` / `mengya` |
|
||||
| 全局配置 | `~/.pi/agent/` | `~/.sproutclaw/agent/` |
|
||||
| 项目配置 | `.pi/` | `.sproutclaw/` |
|
||||
| 环境变量 | `PI_CODING_AGENT_DIR` | `SPROUTCLAW_CODING_AGENT_DIR` |
|
||||
|
||||
## 说明
|
||||
|
||||
这个仓库是自用分支,默认会围绕树萌芽的服务器环境、部署规范和日常开发习惯进行调整。上游能力来自 pi-mono,后续会继续精简、增强并沉淀适合服务器运维开发场景的 Agent 工作流。
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
这个仓库是自用 fork,默认围绕树萌芽的服务器环境、部署规范和日常开发习惯调整。上游能力来自 pi-mono,通过 `git fetch upstream && git merge upstream/main` 定期同步,sproutclaw 定制在 `feature/*` 开发后合入 `main`。
|
||||
|
||||
58
SECURITY.md
Normal file
58
SECURITY.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 安全政策
|
||||
|
||||
本文档旨在帮助你了解 SproutClaw 的安全设计理念及其边界范围。
|
||||
|
||||
## 安全模型概述
|
||||
|
||||
SproutClaw 是一个在本地运行的 Coding Agent,运行于使用者账号的安全边界之内。监控其操作或将其限制在容器、虚拟机或其他沙箱环境中,是使用者自身的责任。
|
||||
|
||||
SproutClaw 将本地用户账号及该账号可写的文件视为与 SproutClaw 进程处于同一信任边界内。如果攻击者能够修改用户主目录、工作区、Shell 启动文件、环境变量或 SproutClaw 配置文件,通常即可影响 SproutClaw 或其他本地开发工具的行为。依赖此类本地写入权限的报告不属于安全漏洞,除非其能证明 SproutClaw 本身授予了该写入权限,或越过了操作系统的权限边界。
|
||||
|
||||
SproutClaw 依赖使用者仅安装可信扩展、加载可信技能,并仅在可信仓库中使用。这是因为 `AGENTS.md` 等文件中的内容或代码注释可被用于对 Coding Agent 进行提示词注入,且此类攻击无法从根本上防御。
|
||||
|
||||
## 报告漏洞
|
||||
|
||||
如果你认为在 SproutClaw 中发现了安全漏洞,请通过以下方式私下报告:
|
||||
|
||||
- 直接联系项目维护者
|
||||
|
||||
报告时请包含:
|
||||
|
||||
- 问题描述及其影响
|
||||
- 复现步骤、概念验证或相关日志
|
||||
- 受影响的包、版本、提交哈希或配置信息
|
||||
- 已知的缓解措施(如有)
|
||||
|
||||
请勿为安全敏感问题创建公开 issue。
|
||||
|
||||
## 范围内(In Scope)
|
||||
|
||||
以下属于安全问题的受理范围:
|
||||
|
||||
- 分发包、命令行工具、API 及仓库代码中的安全问题
|
||||
|
||||
## 范围外(Out of Scope)
|
||||
|
||||
以下内容不在安全问题受理范围之内:
|
||||
|
||||
- 本地代码执行或沙箱行为(SproutClaw Coding Agent 有意不提供沙箱)
|
||||
- 用户自行安装的扩展或技能的行为
|
||||
- 在不可信仓库中工作所带来的风险
|
||||
- 安装不可信扩展、技能、包或工具所带来的风险
|
||||
- 由不可信中间人代理(MITM Proxy)导致的问题
|
||||
- 将 SproutClaw 暴露在公网上的风险
|
||||
- 提示词注入攻击
|
||||
- 第三方或用户自行控制的凭据泄露
|
||||
- 需要在目标机器上具备创建、修改、删除或替换文件、目录、符号链接、环境变量、Shell 配置或其他用户可控本地状态的能力才能复现的报告(包括 `~/.pi`、`~/.pi/agent/models.json`、工作区文件、`AGENTS.md`、技能、扩展、扩展配置、dotfiles 及通过 NFS/漫游配置/dotfile 管理器同步的文件),除非报告能证明 SproutClaw 本身授予了该访问权限
|
||||
- 由用户有意弱化配置导致的问题
|
||||
- 针对 SproutClaw Coding Agent 使用可信本地输入/配置的资源耗尽/DoS 声明
|
||||
- 关于恶意模型输出的报告
|
||||
- 用户批准或用户主动发起的本地操作被当作漏洞提交的报告
|
||||
|
||||
## 报告者须知
|
||||
|
||||
最有价值的报告应展示当前可复现的安全边界绕过,并附有已证明的实际影响。仅展示预期本地 Agent 行为、提示词注入或恶意可信扩展/技能的报告,在本安全模型下不构成安全漏洞。
|
||||
|
||||
例如,若报告显示向可信 SproutClaw 配置文件写入恶意内容会导致 SproutClaw 执行命令、加载攻击者控制的工具、向攻击者控制的端点发送凭据或改变其他行为,此类报告属于范围之外。
|
||||
|
||||
提交报告时,请尽量包含以下信息:受影响的确切路径、包版本或提交 SHA、配置信息,以及针对最新版本或最新 `main` 分支的概念验证。对于依赖项报告,请提供证据说明已分发的依赖项确实受影响,且该问题可通过 SproutClaw 触达。
|
||||
@@ -28,12 +28,9 @@
|
||||
"packages/*/src/**/*.ts",
|
||||
"packages/*/test/**/*.ts",
|
||||
"packages/coding-agent/examples/**/*.ts",
|
||||
"packages/web-ui/src/**/*.ts",
|
||||
"packages/web-ui/example/**/*.ts",
|
||||
"!**/node_modules/**/*",
|
||||
"!**/test-sessions.ts",
|
||||
"!**/models.generated.ts",
|
||||
"!packages/web-ui/src/app.css",
|
||||
"!packages/mom/data/**/*",
|
||||
"!!**/node_modules"
|
||||
]
|
||||
|
||||
77
build.bat
Normal file
77
build.bat
Normal file
@@ -0,0 +1,77 @@
|
||||
|
||||
@echo off
|
||||
setlocal EnableDelayedExpansion
|
||||
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
|
||||
set "PI_CLI_DIST=%SCRIPT_DIR%\packages\coding-agent\dist\cli.js"
|
||||
|
||||
if not defined SPROUTCLAW_CODING_AGENT_DIR (
|
||||
set "SPROUTCLAW_CODING_AGENT_DIR=%SCRIPT_DIR%\.sproutclaw\agent"
|
||||
)
|
||||
|
||||
set "NO_ENV=false"
|
||||
set "ARGS="
|
||||
|
||||
:loop
|
||||
if "%~1"=="" goto endloop
|
||||
if "%~1"=="--no-env" (
|
||||
set "NO_ENV=true"
|
||||
) else (
|
||||
if "%ARGS%"=="" (
|
||||
set "ARGS=%1"
|
||||
) else (
|
||||
set "ARGS=%ARGS% %1"
|
||||
)
|
||||
)
|
||||
shift
|
||||
goto loop
|
||||
:endloop
|
||||
|
||||
if "%NO_ENV%"=="true" (
|
||||
set "ANTHROPIC_API_KEY="
|
||||
set "ANTHROPIC_OAUTH_TOKEN="
|
||||
set "OPENAI_API_KEY="
|
||||
set "GEMINI_API_KEY="
|
||||
set "GROQ_API_KEY="
|
||||
set "CEREBRAS_API_KEY="
|
||||
set "XAI_API_KEY="
|
||||
set "OPENROUTER_API_KEY="
|
||||
set "ZAI_API_KEY="
|
||||
set "MISTRAL_API_KEY="
|
||||
set "MINIMAX_API_KEY="
|
||||
set "MINIMAX_CN_API_KEY="
|
||||
set "AI_GATEWAY_API_KEY="
|
||||
set "OPENCODE_API_KEY="
|
||||
set "COPILOT_GITHUB_TOKEN="
|
||||
set "GH_TOKEN="
|
||||
set "GITHUB_TOKEN="
|
||||
set "HF_TOKEN="
|
||||
set "GOOGLE_APPLICATION_CREDENTIALS="
|
||||
set "GOOGLE_CLOUD_PROJECT="
|
||||
set "GCLOUD_PROJECT="
|
||||
set "GOOGLE_CLOUD_LOCATION="
|
||||
set "AWS_PROFILE="
|
||||
set "AWS_ACCESS_KEY_ID="
|
||||
set "AWS_SECRET_ACCESS_KEY="
|
||||
set "AWS_SESSION_TOKEN="
|
||||
set "AWS_REGION="
|
||||
set "AWS_DEFAULT_REGION="
|
||||
set "AWS_BEARER_TOKEN_BEDROCK="
|
||||
set "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI="
|
||||
set "AWS_CONTAINER_CREDENTIALS_FULL_URI="
|
||||
set "AWS_WEB_IDENTITY_TOKEN_FILE="
|
||||
set "AZURE_OPENAI_API_KEY="
|
||||
set "AZURE_OPENAI_BASE_URL="
|
||||
set "AZURE_OPENAI_RESOURCE_NAME="
|
||||
echo Running without API keys...
|
||||
)
|
||||
|
||||
if not exist "%PI_CLI_DIST%" (
|
||||
echo Build output not found: %PI_CLI_DIST% >&2
|
||||
echo Run from repo root: npm run build >&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
node "%PI_CLI_DIST%" %ARGS%
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PI_CLI_DIST="$SCRIPT_DIR/packages/coding-agent/dist/cli.js"
|
||||
|
||||
# Default to the repository-local pi config so this source checkout uses the
|
||||
# copied learning config instead of ~/.pi.
|
||||
export PI_CODING_AGENT_DIR="${PI_CODING_AGENT_DIR:-$SCRIPT_DIR/.pi/agent}"
|
||||
export SPROUTCLAW_CODING_AGENT_DIR="${SPROUTCLAW_CODING_AGENT_DIR:-$SCRIPT_DIR/.sproutclaw/agent}"
|
||||
# Align pi-mcp-adapter (and other third-party packages that only read PI_CODING_AGENT_DIR)
|
||||
# with SproutClaw's agent dir, so MCP configs in .sproutclaw/agent/mcp.json are loaded.
|
||||
export PI_CODING_AGENT_DIR="$SPROUTCLAW_CODING_AGENT_DIR"
|
||||
|
||||
# Check for --no-env flag
|
||||
NO_ENV=false
|
||||
ARGS=()
|
||||
for arg in "$@"; do
|
||||
@@ -19,7 +20,6 @@ for arg in "$@"; do
|
||||
done
|
||||
|
||||
if [[ "$NO_ENV" == "true" ]]; then
|
||||
# Unset API keys (see packages/ai/src/env-api-keys.ts)
|
||||
unset ANTHROPIC_API_KEY
|
||||
unset ANTHROPIC_OAUTH_TOKEN
|
||||
unset OPENAI_API_KEY
|
||||
@@ -58,10 +58,10 @@ if [[ "$NO_ENV" == "true" ]]; then
|
||||
echo "Running without API keys..."
|
||||
fi
|
||||
|
||||
TSX_BIN="$SCRIPT_DIR/node_modules/.bin/tsx"
|
||||
if [[ ! -x "$TSX_BIN" ]]; then
|
||||
echo "tsx not found at $TSX_BIN. Run npm install from the repo root first." >&2
|
||||
if [[ ! -f "$PI_CLI_DIST" ]]; then
|
||||
echo "Build output not found: $PI_CLI_DIST" >&2
|
||||
echo "Run from repo root: npm run build" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$TSX_BIN" "$SCRIPT_DIR/packages/coding-agent/src/cli.ts" ${ARGS[@]+"${ARGS[@]}"}
|
||||
exec node "$PI_CLI_DIST" ${ARGS[@]+"${ARGS[@]}"}
|
||||
142
context.md
142
context.md
@@ -1,142 +0,0 @@
|
||||
# Code Context
|
||||
|
||||
## Top-Level Structure
|
||||
|
||||
```
|
||||
.sproutclaw/
|
||||
├── .claude/ # Claude configuration
|
||||
├── .github/ # Issue templates, workflows (issue-gate, pr-gate, approve-contributor)
|
||||
├── .husky/ # Git hooks
|
||||
├── .pi/ # pi/pi-mono agent configuration (AGENTS.md)
|
||||
├── node_modules/
|
||||
├── packages/ # 5 monorepo workspaces
|
||||
│ ├── ai/ # @earendil-works/pi-ai v0.74.0
|
||||
│ ├── agent/ # @earendil-works/pi-agent-core v0.74.0
|
||||
│ ├── coding-agent/ # @earendil-works/pi-coding-agent v0.74.0
|
||||
│ ├── tui/ # @earendil-works/pi-tui v0.74.0
|
||||
│ └── web-ui/ # @earendil-works/pi-web-ui v0.74.0
|
||||
├── scripts/ # CI/release/utility scripts
|
||||
├── tmp/ # Temp files
|
||||
├── pi-test.sh # Interactive TUI test script
|
||||
├── README.md
|
||||
├── CONTRIBUTING.md
|
||||
├── AGENTS.md
|
||||
├── biome.json # Linting/formatting config
|
||||
├── tsconfig.base.json # Shared TS config
|
||||
├── tsconfig.json # Root TS config
|
||||
├── package.json # Workspace root (npm workspaces)
|
||||
└── bun.lock / package-lock.json
|
||||
```
|
||||
|
||||
## Packages
|
||||
|
||||
### 1. `packages/ai` — `@earendil-works/pi-ai`
|
||||
**Unified LLM API** — automatic model discovery, provider configuration, streaming.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/types.ts` — core types (`Api`, `StreamOptions`, `Model`, `KnownProvider`)
|
||||
- `src/stream.ts` — main streaming implementation
|
||||
- `src/models.ts` / `src/models.generated.ts` — model registry + auto-generated
|
||||
- `src/providers/` — individual provider implementations (Anthropic, Google, OpenAI, Mistral, etc.)
|
||||
- `src/cli.ts` — CLI for model listing etc.
|
||||
- `src/env-api-keys.ts` — credential detection from env vars
|
||||
- `src/oauth.ts` — OAuth support
|
||||
- `src/api-registry.ts` — API/stream provider registration
|
||||
- `scripts/generate-models.ts` — model list generation script
|
||||
- Subpath exports per provider (e.g. `./anthropic`, `./google`, etc.)
|
||||
|
||||
### 2. `packages/agent` — `@earendil-works/pi-agent-core`
|
||||
**General-purpose agent** — transport abstraction, state management, attachment support.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/types.ts` — core types (agent state, attachments, transports)
|
||||
- `src/agent.ts` — agent implementation
|
||||
- `src/agent-loop.ts` — agent main loop
|
||||
- `src/proxy.ts` — proxy support
|
||||
- `src/harness/` — test harness utilities
|
||||
|
||||
### 3. `packages/coding-agent` — `@earendil-works/pi-coding-agent`
|
||||
**Coding agent CLI** — the `pi` CLI binary with read, bash, edit, write tools and session management.
|
||||
|
||||
- `src/cli.ts` / `src/cli/` — CLI argument parsing / entry point
|
||||
- `src/main.ts` — main agent runner
|
||||
- `src/index.ts` — package entry
|
||||
- `src/config.ts` — configuration management
|
||||
- `src/core/` — core logic (model resolution, hooks, tool execution, session management)
|
||||
- `src/modes/` — operational modes
|
||||
- `src/bun/` — Bun-specific support
|
||||
- `src/migrations.ts` — data migration utilities
|
||||
- `src/package-manager-cli.ts` — package manager interaction
|
||||
- `docs/` — provider setup docs
|
||||
- `examples/` — extension examples (custom provider, sandbox)
|
||||
- Binary name: `pi`
|
||||
|
||||
### 4. `packages/tui` — `@earendil-works/pi-tui`
|
||||
**Terminal UI library** — differential rendering for text-based terminal applications.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/tui.ts` — core TUI rendering engine
|
||||
- `src/terminal.ts` — low-level terminal I/O
|
||||
- `src/keys.ts` / `src/keybindings.ts` — key input handling
|
||||
- `src/components/` — reusable UI components
|
||||
- `src/editor-component.ts` — text editor component
|
||||
- `src/autocomplete.ts` — autocomplete support
|
||||
- `src/fuzzy.ts` — fuzzy matching
|
||||
- `src/terminal-image.ts` — image rendering in terminal
|
||||
- `src/kill-ring.ts` / `src/undo-stack.ts` — editing helpers
|
||||
- `src/stdin-buffer.ts` / `src/utils.ts` — utilities
|
||||
|
||||
### 5. `packages/web-ui` — `@earendil-works/pi-web-ui`
|
||||
**Reusable web UI components** — AI chat interfaces powered by pi-ai.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/app.css` — Tailwind CSS stylesheet
|
||||
- `src/ChatPanel.ts` — main chat panel component
|
||||
- `src/components/` — reusable React components
|
||||
- `src/dialogs/` — dialog components
|
||||
- `src/prompts/` — prompt templates/management
|
||||
- `src/storage/` — client-side storage abstraction
|
||||
- `src/tools/` — tool integration
|
||||
- `src/utils/` — utilities
|
||||
- Build: TypeScript + Tailwind CSS
|
||||
|
||||
## Root Scripts (`scripts/`)
|
||||
|
||||
- `release.mjs` — automated release workflow (version + changelog + publish)
|
||||
- `sync-versions.js` — lockstep version sync across all packages
|
||||
- `cost.ts` / `stats.ts` — analytics/telemetry stats
|
||||
- `tool-stats.ts` / `edit-tool-stats.mjs` / `read-tool-stats.mjs` — tool usage statistics
|
||||
- `profile-coding-agent-node.mjs` — Node.js profiling for coding agent
|
||||
- `build-binaries.sh` — binary build script
|
||||
- `browser-smoke-entry.ts` / `check-browser-smoke.mjs` — browser smoke tests
|
||||
- `session-transcripts.ts` / `session-context-stats.mjs` — session logging
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
```
|
||||
pi-tui (terminal UI) ─── pi-coding-agent (CLI + tools) ─── pi-agent-core (agent loop)
|
||||
│
|
||||
pi-web-ui (web UI) ────────────┤
|
||||
│
|
||||
pi-ai (LLM provider abstraction)
|
||||
```
|
||||
|
||||
- **pi-ai** is the foundation layer providing unified LLM API access (Anthropic, Google, OpenAI, Azure, Mistral, Bedrock, etc.)
|
||||
- **pi-agent-core** builds on pi-ai with agent loop, transport abstraction, and state management
|
||||
- **pi-coding-agent** is the main product: a coding agent CLI (`pi` command) with read/bash/edit/write tools and session management
|
||||
- **pi-tui** is a standalone TUI rendering library used by pi-coding-agent
|
||||
- **pi-web-ui** provides reusable chat UI components (React) powered by pi-ai
|
||||
|
||||
## Key Facts
|
||||
|
||||
- **Lockstep versioning**: all packages share the same version (currently 0.74.0)
|
||||
- **Release automation**: `npm run release:patch` / `release:minor` via `scripts/release.mjs`
|
||||
- **TypeScript with tsgo**: uses `tsgo` (TypeScript native preview) for fast builds; web-ui uses `tsc`
|
||||
- **Linting**: Biome (v2.3.5) for formatting and linting (`npm run check`)
|
||||
- **Testing**: vitest for ai/agent/coding-agent; node --test for tui
|
||||
- **No `any` types allowed**, standard top-level imports only (no inline `import()`)
|
||||
- **GitHub CI**: issue-gate, pr-gate, approve-contributor workflows for contributor management
|
||||
|
||||
## Start Here
|
||||
|
||||
Open `package.json` (root) for workspace structure and available scripts. For a specific package, start with its `package.json` then `src/index.ts` for the public API surface.
|
||||
6516
package-lock.json
generated
6516
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
51
package.json
51
package.json
@@ -4,58 +4,61 @@
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"packages/web-ui/example",
|
||||
"packages/coding-agent/examples/extensions/with-deps",
|
||||
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
|
||||
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
|
||||
"packages/coding-agent/examples/extensions/sandbox"
|
||||
"packages/coding-agent/examples/extensions/sandbox",
|
||||
"packages/coding-agent/examples/extensions/gondolin"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "npm run clean --workspaces",
|
||||
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build && cd ../web-ui && npm run build",
|
||||
"dev": "concurrently --names \"ai,agent,coding-agent,web-ui,tui\" --prefix-colors \"cyan,yellow,red,green,magenta\" \"cd packages/ai && npm run dev\" \"cd packages/agent && npm run dev\" \"cd packages/coding-agent && npm run dev\" \"cd packages/web-ui && npm run dev\" \"cd packages/tui && npm run dev\"",
|
||||
"dev:tsc": "concurrently --names \"ai,web-ui\" --prefix-colors \"cyan,green\" \"cd packages/ai && npm run dev:tsc\" \"cd packages/web-ui && npm run dev:tsc\"",
|
||||
"check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:browser-smoke && cd packages/web-ui && npm run check",
|
||||
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
|
||||
"check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && tsgo --noEmit && npm run check:browser-smoke",
|
||||
"check:browser-smoke": "node scripts/check-browser-smoke.mjs",
|
||||
"check:pinned-deps": "node scripts/check-pinned-deps.mjs",
|
||||
"check:shrinkwrap": "node scripts/generate-coding-agent-shrinkwrap.mjs --check",
|
||||
"check:ts-imports": "node scripts/check-ts-relative-imports.mjs",
|
||||
"profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui",
|
||||
"profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install",
|
||||
"version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install",
|
||||
"version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install",
|
||||
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
|
||||
"version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
|
||||
"version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
|
||||
"version:set": "npm version -ws",
|
||||
"prepublishOnly": "npm run clean && npm run build && npm run check",
|
||||
"publish": "npm run prepublishOnly && npm publish -ws --access public",
|
||||
"publish:dry": "npm run prepublishOnly && npm publish -ws --access public --dry-run",
|
||||
"publish": "npm run prepublishOnly && node scripts/publish.mjs",
|
||||
"publish:dry": "npm run prepublishOnly && node scripts/publish.mjs --dry-run",
|
||||
"release:local": "node scripts/local-release.mjs",
|
||||
"shrinkwrap:coding-agent": "node scripts/generate-coding-agent-shrinkwrap.mjs",
|
||||
"release:patch": "node scripts/release.mjs patch",
|
||||
"release:minor": "node scripts/release.mjs minor",
|
||||
"release:major": "node scripts/release.mjs major",
|
||||
"release:fix-links": "node scripts/release-notes.mjs fix-github-releases",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.26",
|
||||
"@anthropic-ai/sandbox-runtime": "0.0.26",
|
||||
"@biomejs/biome": "2.3.5",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/node": "22.19.19",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260120.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"husky": "^9.1.7",
|
||||
"jiti": "^2.7.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.9.2",
|
||||
"shx": "^0.4.0"
|
||||
"esbuild": "0.28.1",
|
||||
"husky": "9.1.7",
|
||||
"jiti": "2.7.0",
|
||||
"shx": "0.4.0",
|
||||
"tsx": "4.22.1",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"version": "0.0.3",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "^0.30.2",
|
||||
"get-east-asian-width": "^1.4.0"
|
||||
},
|
||||
"overrides": {
|
||||
"rimraf": "6.1.2",
|
||||
"gaxios": {
|
||||
"rimraf": "6.1.2"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@mistralai/mistralai": "2.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,91 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.79.9] - 2026-06-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Node execution environment commands through legacy WSL `bash.exe` to pass scripts over stdin so shell variables expand in the target bash ([#5893](https://github.com/earendil-works/pi/issues/5893)).
|
||||
|
||||
## [0.79.8] - 2026-06-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added `@earendil-works/pi-agent-core/base` for bundlers that want to pair the agent core with selective `@earendil-works/pi-ai/base` provider registration ([#5348](https://github.com/earendil-works/pi/pull/5348) by [@FredKSchott](https://github.com/FredKSchott)).
|
||||
|
||||
## [0.79.7] - 2026-06-18
|
||||
|
||||
## [0.79.6] - 2026-06-16
|
||||
|
||||
## [0.79.5] - 2026-06-16
|
||||
|
||||
## [0.79.4] - 2026-06-15
|
||||
|
||||
## [0.79.3] - 2026-06-13
|
||||
|
||||
## [0.79.2] - 2026-06-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed late tool progress callbacks after tool settlement to be ignored instead of emitting stale `tool_execution_update` events ([#5573](https://github.com/earendil-works/pi/issues/5573)).
|
||||
|
||||
## [0.79.1] - 2026-06-09
|
||||
|
||||
## [0.79.0] - 2026-06-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the compaction summarization system prompt to use neutral AI assistant wording for non-coding agents ([#5401](https://github.com/earendil-works/pi/issues/5401)).
|
||||
|
||||
## [0.78.1] - 2026-06-04
|
||||
|
||||
## [0.78.0] - 2026-05-29
|
||||
|
||||
## [0.77.0] - 2026-05-28
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed agent harness `model_select` and `thinking_level_select` events to `model_update` and `thinking_level_update`.
|
||||
|
||||
### Added
|
||||
|
||||
- Added agent harness tool registry APIs, `tools_update` events, branch-scoped active-tool persistence, and duplicate tool validation.
|
||||
|
||||
## [0.76.0] - 2026-05-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed context token estimates to count user image attachments consistently with tool result images ([#4983](https://github.com/earendil-works/pi/issues/4983)).
|
||||
|
||||
## [0.75.5] - 2026-05-23
|
||||
|
||||
## [0.75.4] - 2026-05-20
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed source syntax to avoid TypeScript constructs that require JavaScript emit, keeping the package compatible with Node.js strip-only TypeScript checks.
|
||||
- Removed the package-level development watch script now that the root TypeScript check validates strip-only-compatible sources.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed tool-call preflight to stop preparing sibling tool calls after the run is aborted ([#4276](https://github.com/earendil-works/pi/issues/4276)).
|
||||
- Fixed tail truncation for oversized single-line output that ends with a trailing newline ([#4715](https://github.com/earendil-works/pi/issues/4715)).
|
||||
- Fixed Windows Node execution environment command spawns to hide helper console windows from background processes ([#4699](https://github.com/earendil-works/pi/issues/4699)).
|
||||
|
||||
## [0.75.3] - 2026-05-18
|
||||
|
||||
## [0.75.2] - 2026-05-18
|
||||
|
||||
## [0.75.1] - 2026-05-18
|
||||
|
||||
## [0.75.0] - 2026-05-17
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Raised the minimum supported Node.js version to 22.19.0.
|
||||
|
||||
## [0.74.1] - 2026-05-16
|
||||
|
||||
## [0.74.0] - 2026-05-07
|
||||
|
||||
## [0.73.1] - 2026-05-07
|
||||
|
||||
@@ -31,6 +31,24 @@ agent.subscribe((event) => {
|
||||
await agent.prompt("Hello!");
|
||||
```
|
||||
|
||||
## Base Entry Point
|
||||
|
||||
Use `@earendil-works/pi-agent-core/base` with `@earendil-works/pi-ai/base` when bundling applications that should include only selected provider transports:
|
||||
|
||||
```typescript
|
||||
import { Agent } from "@earendil-works/pi-agent-core/base";
|
||||
import { getModel } from "@earendil-works/pi-ai/base";
|
||||
import { register } from "@earendil-works/pi-ai/anthropic";
|
||||
|
||||
register();
|
||||
|
||||
const agent = new Agent({
|
||||
initialState: { model: getModel("anthropic", "claude-sonnet-4-6") },
|
||||
});
|
||||
```
|
||||
|
||||
The default `@earendil-works/pi-agent-core` entry point remains batteries-included and registers pi-ai's lazy built-in transports for backward compatibility.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### AgentMessage vs LLM Message
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AgentHarness lifecycle
|
||||
|
||||
`AgentHarness` is the orchestration layer above the low-level `Agent`. It owns session persistence, runtime configuration, resource resolution, operation locking, and extension-facing mutation semantics.
|
||||
`AgentHarness` is the orchestration layer above the low-level agent loop. It owns session persistence, runtime configuration, resource resolution, operation locking, and extension-facing mutation semantics.
|
||||
|
||||
This document describes the current direction and implemented behavior. Some extension/session-facade details are planned and called out explicitly.
|
||||
|
||||
@@ -15,9 +15,22 @@ The intended rule is:
|
||||
- runtime config setters update future snapshots without mutating the current provider request
|
||||
- session writes made while busy are durably queued and flushed in deterministic order
|
||||
- getters return latest harness config, not in-flight snapshots
|
||||
- listeners/hooks currently receive no facade; if they close over the raw harness and call settlement APIs such as `waitForIdle()` during the active run, they can deadlock. A future facade should expose `runWhenIdle()` instead.
|
||||
|
||||
`AssistantMessageStream` already decouples provider transport streaming, such as SSE or websocket reads, from downstream event consumption. The harness can therefore await listeners, extension hooks, persistence, and save-point work without blocking the provider transport reader or reintroducing ad hoc event queues. Lifecycle code should prefer explicit awaited sequencing at harness boundaries over fire-and-forget hook/event settlement.
|
||||
|
||||
A final lifecycle hardening pass should prove these guarantees with a broad listener/hook reentrancy test suite.
|
||||
|
||||
## Error handling
|
||||
|
||||
The current split is:
|
||||
|
||||
- low-level capabilities and helpers use `Result<TValue, TError>` where expected failures are contained and must not throw, such as `ExecutionEnv`, filesystem/shell operations, shell-output capture, resource loading, and compaction helpers
|
||||
- high-level mutation/orchestration APIs such as `Session` and `AgentHarness` reject/throw instead of returning bare results that can be ignored
|
||||
- public `AgentHarness` failures are normalized to `AgentHarnessError` where practical; subsystem errors are preserved as `cause`
|
||||
|
||||
Harness events observe committed state. Public mutators validate required input and persistence before committing when practical, then await notifications. If a hook or subscriber fails after commit, the state change is not rolled back and the public method rejects with `AgentHarnessError` code `"hook"`.
|
||||
|
||||
## State model
|
||||
|
||||
The harness separates state into four categories.
|
||||
@@ -31,6 +44,7 @@ Harness config is the latest runtime configuration set by the application or ext
|
||||
- tools
|
||||
- active tool names
|
||||
- resources
|
||||
- stream options
|
||||
- system prompt or system prompt provider
|
||||
|
||||
Getters return harness config. They do not return the snapshot used by an in-flight provider request.
|
||||
@@ -52,15 +66,21 @@ A turn snapshot is the concrete state used for one LLM turn. It is created by `c
|
||||
- thinking level
|
||||
- all tools
|
||||
- active tools
|
||||
- stream options
|
||||
- derived session id
|
||||
|
||||
Static option values are used directly. System-prompt provider callbacks are invoked once per `createTurnState()` call. All logic for that turn uses the same snapshot.
|
||||
|
||||
Resource arrays are shallow-copied when a snapshot is created. Individual skill and prompt-template objects are not deep-copied.
|
||||
|
||||
Stream options are shallow-copied when a snapshot is created. `headers` and `metadata` maps are shallow-copied; their values are not deep-copied. Credentials from `getApiKeyAndHeaders()` are resolved per provider request so expiring tokens can refresh, but the configured stream options and derived session id come from the current turn snapshot.
|
||||
|
||||
### Session
|
||||
|
||||
The session contains persisted entries only. Session reads return persisted state and do not include queued writes.
|
||||
|
||||
Session storage implementations must persist leaf changes as `leaf` entries. `setLeafId()` is not an in-memory-only cursor update; it appends a durable entry whose `targetId` is the active tree leaf or `null` for root. Reopening storage must reconstruct the current leaf from the latest persisted leaf-affecting entry.
|
||||
|
||||
### Pending session writes
|
||||
|
||||
Session writes requested while an operation is active are queued as pending session writes. Pending writes are based on session-entry shapes without generated fields (`id`, `parentId`, `timestamp`).
|
||||
@@ -85,7 +105,7 @@ Structural operations require `phase === "idle"` and synchronously set the phase
|
||||
- `compact`
|
||||
- `navigateTree`
|
||||
|
||||
Starting another structural operation while the harness is not idle throws.
|
||||
Starting another structural operation while the harness is not idle rejects with `AgentHarnessError` code `"busy"`.
|
||||
|
||||
The following operations are allowed during a turn where appropriate:
|
||||
|
||||
@@ -112,8 +132,8 @@ Phase/settlement semantics are still provisional and need a full lifecycle pass.
|
||||
|
||||
Queue modes are live, not turn-snapshotted:
|
||||
|
||||
- `steeringMode`
|
||||
- `followUpMode`
|
||||
- `getSteeringMode()` / `setSteeringMode()`
|
||||
- `getFollowUpMode()` / `setFollowUpMode()`
|
||||
|
||||
Changing a queue mode during a run affects the next queue drain. Queue drains happen at safe points.
|
||||
|
||||
@@ -125,9 +145,9 @@ At a save point the harness:
|
||||
|
||||
1. flushes pending session writes after the agent-emitted messages for that turn
|
||||
2. creates a fresh turn snapshot if the low-level loop may continue
|
||||
3. applies the fresh context/model/thinking-level state before the next provider request
|
||||
3. applies the fresh context/model/thinking-level/stream-options/session-id state before the next provider request
|
||||
|
||||
This lets model, thinking level, tool, resource, and system prompt changes made during a turn affect the next turn in the same run, while never mutating an in-flight provider request. The loop callbacks are not recreated at save points.
|
||||
This lets model, thinking level, tool, resource, stream option, and system prompt changes made during a turn affect the next turn in the same run, while never mutating an in-flight provider request. Because provider transport reading is already decoupled by `AssistantMessageStream`, save-point work and hook settlement can be awaited directly to keep transcript/session ordering deterministic. The loop callbacks are not recreated at save points.
|
||||
|
||||
The low-level loop converts harness `ThinkingLevel` to provider `reasoning` at the provider boundary:
|
||||
|
||||
@@ -136,21 +156,26 @@ The low-level loop converts harness `ThinkingLevel` to provider `reasoning` at t
|
||||
|
||||
No state refresh is needed on `agent_end` except flushing leftover pending session writes and clearing the operation phase. The exact `settled` event timing is still under review.
|
||||
|
||||
If the system-prompt callback throws while starting `prompt`, `skill`, or `promptFromTemplate`, the operation throws and the harness returns to idle. If it throws from the save-point snapshot created by `prepareNextTurn`, the low-level agent run records an assistant error message.
|
||||
If the system-prompt callback throws while starting `prompt`, `skill`, or `promptFromTemplate`, the operation rejects with `AgentHarnessError` and the harness returns to idle. If it throws from the save-point snapshot created by `prepareNextTurn`, the low-level agent run records an assistant error message.
|
||||
|
||||
## Hooks and events
|
||||
|
||||
Current hooks receive only the event payload. There is no extension context object yet.
|
||||
The target hook system is described in [hooks.md](./hooks.md).
|
||||
|
||||
Event payloads describe what is happening. Harness getters describe latest config for future snapshots.
|
||||
Summary:
|
||||
|
||||
The split between harness-specific events (`AgentHarnessOwnEvent`) and the union of low-level plus harness events (`AgentHarnessEvent`) is provisional but useful for distinguishing hookable harness events from public subscription events.
|
||||
- `AgentHarness` emits typed hook events and consumes typed results.
|
||||
- A single hooks implementation owns registration, cleanup, provenance, and result reducers.
|
||||
- Observational and mutation hooks use one event-specific `on()` API; the event result type determines whether a handler may return a result.
|
||||
- Result-producing events are reduced by typed reducer tables; app-specific hooks add reducers only for app-specific result-producing events.
|
||||
- Hook registration provenance is sidecar metadata on the registration. Resource and tool provenance belongs on app-specific concrete value types.
|
||||
- Hook context should be a plain object of facades, not raw internals or late-bound getter mazes.
|
||||
|
||||
A future extension context may expose the harness and a queued-write session facade.
|
||||
Event payloads describe what is happening. Harness getters describe latest config for future snapshots. Hook and listener settlement should be awaited in lifecycle order where possible; transport backpressure is handled below the harness by `AssistantMessageStream`, so the harness does not need a separate async event queue merely to keep SSE or websocket reads flowing.
|
||||
|
||||
## Planned session facade
|
||||
|
||||
Extensions should eventually interact with a harness-scoped session facade rather than the raw session.
|
||||
Extensions should eventually interact with a harness-scoped `HarnessSession` facade rather than the raw session. The facade should wrap the internal session and enforce harness pending-write ordering semantics. Once this exists, hooks and event listeners can receive a context that exposes the full `AgentHarness` plus the session facade without giving direct access to unordered raw session writes.
|
||||
|
||||
Planned read semantics:
|
||||
|
||||
@@ -172,7 +197,9 @@ Agent-emitted messages are persisted on `message_end` to preserve transcript ord
|
||||
|
||||
## Abort
|
||||
|
||||
Abort is allowed during a turn. It aborts the low-level run and clears low-level steering/follow-up queues.
|
||||
Abort is allowed during a turn. It aborts the low-level run and clears steering/follow-up queues.
|
||||
|
||||
Abort does not clear `nextTurn` messages. Messages queued with `nextTurn()` survive abort and are inserted before the user message on the next user-initiated turn.
|
||||
|
||||
Abort does not discard pending session writes. Pending writes flush at the next save point if reached, at `agent_end`, or in operation failure cleanup.
|
||||
|
||||
@@ -188,17 +215,272 @@ Branch summary generation is part of the tree navigation operation.
|
||||
|
||||
Auto-compaction and retry decision points are not implemented in `AgentHarness` yet.
|
||||
|
||||
## Final lifecycle hardening todo
|
||||
## Test organization
|
||||
|
||||
Before treating `AgentHarness` as migration-ready, add a broad test suite that exercises listeners and hooks closing over the harness and calling public APIs during every relevant event:
|
||||
Harness tests should stay focused by area instead of growing one large catch-all file.
|
||||
|
||||
- runtime config setters from low-level lifecycle events and harness events
|
||||
- resource/tool/model/thinking updates during active turns and save points
|
||||
- session writes from listeners and hooks, including writes from `settled`
|
||||
- queue operations from turn events, tool events, and provider hooks
|
||||
- rejected structural operations while busy
|
||||
- abort from listeners/hooks
|
||||
- getter behavior during active operations
|
||||
- deterministic ordering of agent-emitted messages and pending listener writes
|
||||
- no deadlocks when async listeners call harness APIs and await them
|
||||
- phase cleanup through success, provider error, hook error, abort, compaction, and tree navigation
|
||||
Current structure:
|
||||
|
||||
- `packages/agent/test/harness/agent-harness.test.ts`: core lifecycle and public API behavior.
|
||||
- `packages/agent/test/harness/agent-harness-stream.test.ts`: stream options and provider hook semantics.
|
||||
|
||||
Preferred future structure:
|
||||
|
||||
- `agent-harness-resources.test.ts`: resource snapshot/loading semantics.
|
||||
- `agent-harness-tools.test.ts`: tool registry getters, active-tool semantics, and update events.
|
||||
- `agent-harness-lifecycle.test.ts`: phase/save-point/settled/reentrancy behavior.
|
||||
|
||||
Use the `pi-ai` faux provider (`registerFauxProvider`, `fauxAssistantMessage`) for deterministic harness/provider tests. Faux response factories can inspect `StreamOptions`, invoke `options.onPayload`, and return scripted assistant messages without real provider APIs or network access.
|
||||
|
||||
Harness coverage is configured separately from the default package test run:
|
||||
|
||||
```bash
|
||||
npm run test:harness
|
||||
npm run coverage:harness
|
||||
```
|
||||
|
||||
`coverage:harness` runs `test/harness/**/*.test.ts` and reports coverage for `src/harness/**/*.ts` plus the non-harness runtime files it directly exercises (`src/agent.ts` and `src/agent-loop.ts`) into `coverage/harness`. Type-only dependencies such as `src/types.ts` are not included because they have no meaningful runtime coverage.
|
||||
|
||||
## Implementation todo
|
||||
|
||||
This list tracks the remaining work before treating `AgentHarness` as migration-ready. Active/planned items are ordered from easiest to hardest. Completed items are archived at the bottom.
|
||||
|
||||
### 1. Add explicit tool registry read/update semantics
|
||||
|
||||
Status: In progress
|
||||
|
||||
Done:
|
||||
|
||||
- Added `setTools(tools, activeToolNames?)`.
|
||||
- Added `setActiveTools(toolNames)`.
|
||||
- Invalid active tool names reject with `AgentHarnessError`.
|
||||
- Added generic app tool shape via `AgentHarness<TSkill, TPromptTemplate, TTool>`.
|
||||
- Exported `QueueMode` from core types.
|
||||
- Added `AgentHarnessOptions.steeringMode` and `followUpMode`.
|
||||
- Added live `getSteeringMode()` / `setSteeringMode()` and `getFollowUpMode()` / `setFollowUpMode()`.
|
||||
- Added `getTools()` and `getActiveTools()`.
|
||||
- Added `tools_update` observability events, including active-tool-only updates.
|
||||
- Active tool changes are persisted as branch-scoped `active_tools_change` entries.
|
||||
- Duplicate tool names and duplicate active tool names reject.
|
||||
|
||||
Remaining:
|
||||
|
||||
- None.
|
||||
|
||||
Notes:
|
||||
|
||||
- Observability design: [observability.md](./observability.md)
|
||||
|
||||
### 2. Design per-`AgentHarness` model registry
|
||||
|
||||
Status: Planned
|
||||
|
||||
Done:
|
||||
|
||||
- Current `setModel()` behavior is preserved.
|
||||
|
||||
Remaining:
|
||||
|
||||
- Decide how applications supply the model registry.
|
||||
- Decide whether the harness stores concrete `Model` objects, model references, or both.
|
||||
- Validate model selection against the registry.
|
||||
- Define model change semantics during active turns and save points.
|
||||
|
||||
### 3. Full `AgentHarness` lifecycle/state pass
|
||||
|
||||
Status: In progress
|
||||
|
||||
Done:
|
||||
|
||||
- Removed constructor `void syncFromTree()`, `syncFromTree()`, `liveOperationId`, and `shell()`.
|
||||
- Added `createTurnState()`, `applyTurnState()`, and `executeTurn()`.
|
||||
- Added explicit `phase` in place of boolean idle state.
|
||||
- Save points refresh context, model, thinking level, stream options, and session snapshot state.
|
||||
- Pending session writes use session-entry shapes without generated fields.
|
||||
- Pending session writes flush at save points, settlement, and failure cleanup.
|
||||
- `steer`, `followUp`, and `nextTurn` create user messages from text plus optional images.
|
||||
- `nextTurn` messages are inserted before the new user prompt.
|
||||
- Structural compaction/tree operations restore phase with `finally`.
|
||||
- Public harness failures normalize subsystem causes to `AgentHarnessError`.
|
||||
- Pending session writes flush one-by-one and are not dropped on failure.
|
||||
- Queue drains roll back if queue-update notification fails.
|
||||
- `message_end` persistence happens before subscriber notification.
|
||||
- `abort()` signals cancellation before notifications and still waits for idle through notification errors.
|
||||
- Idle model/thinking/tool updates validate and persist before committing in-memory state.
|
||||
- `setLeafId()` persists durable `leaf` entries so tree navigation survives storage reopen.
|
||||
|
||||
Remaining:
|
||||
|
||||
- Finalize phase/idle semantics.
|
||||
- Audit whether `settled` can fire too early.
|
||||
- Make session writes inside `settled` callbacks deterministic.
|
||||
- Audit follow-up behavior around `agent_end`.
|
||||
- Implement auto-compaction decision point.
|
||||
- Implement retry handling.
|
||||
- Verify `before_agent_start` hook semantics against coding-agent.
|
||||
- Decide whether `before_agent_start` needs more turn info such as tools/tool snippets.
|
||||
- Document or change runtime config event timing while busy.
|
||||
- Audit `abort()` barrier semantics.
|
||||
|
||||
### 4. Implement generic hook/event extension mechanism
|
||||
|
||||
Status: Designed in [hooks.md](./hooks.md), not implemented
|
||||
|
||||
Done:
|
||||
|
||||
- Removed `AgentHarnessContext`.
|
||||
- Hooks receive only event payloads.
|
||||
- `emitHook(event)` derives the hook type from `event.type`.
|
||||
- Provider request/payload hooks have ordered transform semantics.
|
||||
|
||||
Remaining:
|
||||
|
||||
- Add `HookEvent`, `ResultOf`, registration options with generic source metadata, and the single `AgentHarnessHooks` implementation.
|
||||
- Move result chaining out of `AgentHarness` into reducer functions.
|
||||
- Type-check base harness reducers so every result-producing `AgentHarnessEvent` has reducer semantics.
|
||||
- Make `AgentHarness` accept and expose the concrete hooks instance with constructor inference for app-specific hooks.
|
||||
- Define the initial harness/context facades exposed through hook context.
|
||||
- Preserve current provider hook behavior, including stream option patch deletion semantics.
|
||||
- Add parity tests for reducer semantics: transform chaining, patch chaining, early block/cancel, cleanup, source metadata, and typed app-specific reducer coverage.
|
||||
|
||||
Notes:
|
||||
|
||||
- Hook design: [hooks.md](./hooks.md)
|
||||
|
||||
### 5. Spike semi-durable harness/session recovery
|
||||
|
||||
Status: Planned
|
||||
|
||||
Done:
|
||||
|
||||
- Wrote durability design: [durable-harness.md](./durable-harness.md)
|
||||
|
||||
Remaining:
|
||||
|
||||
- Decide whether session owns all durable harness state or whether any sidecars are needed for large blobs.
|
||||
- Define durable entries for queues, pending writes, operations, turns, provider requests, and tool calls.
|
||||
- Define resume requirements for app-provided tools, models, extensions, resources, hooks, and auth providers.
|
||||
- Define conservative recovery policy for unfinished agent turns, provider requests, tool calls, compaction, and tree navigation.
|
||||
- Prototype reducer-based recovery from session entries.
|
||||
- Decide whether interrupted operations append user-visible messages or only internal operation entries.
|
||||
|
||||
Notes:
|
||||
|
||||
- Provider streams are not resumable; recovery should restart from durable boundaries or mark operations interrupted.
|
||||
- Unfinished tool calls are unsafe to retry unless tools declare idempotent/retry-safe behavior.
|
||||
|
||||
### 6. Final lifecycle hardening suite
|
||||
|
||||
Status: Planned
|
||||
|
||||
Done:
|
||||
|
||||
- None.
|
||||
|
||||
Remaining:
|
||||
|
||||
- Add broad listener/hook reentrancy tests across relevant events.
|
||||
- Test runtime config setters from low-level lifecycle events and harness events.
|
||||
- Test runtime config observability for model, thinking, resources, tools, active tools, and stream options.
|
||||
- Test resource/tool/model/thinking/stream-option updates during active turns and save points.
|
||||
- Test session writes from listeners and hooks, including `settled` writes.
|
||||
- Test queue operations from turn events, tool events, and provider hooks.
|
||||
- Test rejected structural operations while busy.
|
||||
- Test abort from listeners/hooks.
|
||||
- Test getter behavior during active operations.
|
||||
- Test deterministic ordering of agent-emitted messages and pending listener writes.
|
||||
- Test no deadlocks when async listeners call harness APIs and await them.
|
||||
- Test phase cleanup through success, provider error, hook error, abort, compaction, and tree navigation.
|
||||
|
||||
### 7. Later coding-agent migration plan
|
||||
|
||||
Status: Planned
|
||||
|
||||
Done:
|
||||
|
||||
- None.
|
||||
|
||||
Remaining:
|
||||
|
||||
- Map coding-agent resources to sourced loaders.
|
||||
- Keep app-level resource dedupe/provenance outside the harness.
|
||||
- Adapt extension loading to the future hook/session facade.
|
||||
- Preserve UI/session behavior outside core.
|
||||
- Move coding-agent stream/auth/retry/header behavior onto harness stream configuration and provider hooks.
|
||||
|
||||
---
|
||||
|
||||
## Completed implementation todo
|
||||
|
||||
### 8. Remove `Agent` dependency from `AgentHarness`
|
||||
|
||||
Status: Done
|
||||
|
||||
Done:
|
||||
|
||||
- `AgentHarness` calls `runAgentLoop()` directly.
|
||||
- Harness owns run lifecycle, abort controller, queue draining, provider stream config, event reduction, session persistence, pending write flushing, and save-point snapshots.
|
||||
- Harness tests cover prompt construction, queue draining, abort behavior, save-point refresh, pending write ordering, awaited listener settlement, tool hooks, and provider stream wrapping.
|
||||
|
||||
Remaining:
|
||||
|
||||
- None.
|
||||
|
||||
Notes:
|
||||
|
||||
- Broader listener/hook reentrancy coverage is tracked in item 6.
|
||||
|
||||
### 9. Finish curated provider/stream configuration
|
||||
|
||||
Status: Done
|
||||
|
||||
Done:
|
||||
|
||||
- Added curated `AgentHarnessOptions.streamOptions`, `getStreamOptions()`, and `setStreamOptions()`.
|
||||
- Stream options, headers, metadata, and derived session id are snapshotted per turn.
|
||||
- Harness-owned stream wrapper calls `streamSimple()` and keeps lifecycle-owned `signal` and `reasoning` from the low-level loop.
|
||||
- `getApiKeyAndHeaders()` resolves credentials per provider request.
|
||||
- `before_provider_request`, `before_provider_payload`, and `after_provider_response` hooks are implemented.
|
||||
- Stream option patching supports explicit field deletion and ordered hook chaining.
|
||||
- `agent-harness-stream.test.ts` covers forwarding, auth merge, hook patching/deletion/chaining, payload hooks, and busy/save-point snapshot behavior.
|
||||
|
||||
Remaining:
|
||||
|
||||
- None.
|
||||
|
||||
### 10. Complete low-level `Result` cleanup
|
||||
|
||||
Status: Done
|
||||
|
||||
Done:
|
||||
|
||||
- Added generic `Result<TValue, TError>` plus helpers.
|
||||
- Updated `ExecutionEnv` and `NodeExecutionEnv` to return typed results for filesystem/process operations.
|
||||
- Split filesystem and shell capabilities.
|
||||
- Moved JSONL session storage/repo onto filesystem picks instead of direct Node imports.
|
||||
- Added `ExecutionEnv.appendFile()` for streaming append use cases.
|
||||
- Updated skill and prompt-template loaders to consume `ExecutionEnv` results.
|
||||
- Updated shell output capture to return a result and use `ExecutionEnv`, including full-output spill via `appendFile()`.
|
||||
- Removed `NodeExecutionEnv` from browser-safe root exports.
|
||||
- Replaced `Buffer` usage in generic truncation utilities with runtime-neutral UTF-8 handling.
|
||||
- Converted compaction and branch-summary helpers to typed result returns.
|
||||
- Added `readTextLines()` so JSONL metadata loading reads only the header line.
|
||||
- Removed no-op abort handling from Node filesystem methods where cancellation is not meaningful.
|
||||
- Mapped filesystem errors crossing the session boundary to typed `SessionError`.
|
||||
- Added typed branch-summary errors and cause-aware public harness error normalization.
|
||||
- Resource loaders report structured diagnostics for non-`not_found` filesystem failures.
|
||||
- Expanded `NodeExecutionEnv` tests for file operations, exec errors, aborts, callbacks, timeouts, and shell-output spill.
|
||||
|
||||
Remaining:
|
||||
|
||||
- None.
|
||||
|
||||
Notes:
|
||||
|
||||
- Keep low-level capability/helper APIs non-throwing where they return `Result`.
|
||||
- Keep session storage/repo/session APIs throwing typed `SessionError`.
|
||||
- Keep public structural harness failures normalized to `AgentHarnessError`.
|
||||
- Keep Node-specific APIs isolated under `src/harness/env/nodejs.ts`, Node-backed storage/session implementations, or explicit Node-only entry points.
|
||||
- Audit generic harness utilities for Node globals as APIs are added.
|
||||
- Audit package exports so browser/generic imports do not pull Node-only modules.
|
||||
- Keep expanding `ExecutionEnv` and shell-output contract tests as APIs evolve.
|
||||
|
||||
212
packages/agent/docs/durable-harness.md
Normal file
212
packages/agent/docs/durable-harness.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# Durable AgentHarness and session design
|
||||
|
||||
<!-- Synced from jot zmnps2zu. Edit this file in-repo going forward. -->
|
||||
|
||||
Durable AgentHarness / session design notes.
|
||||
|
||||
## Framing
|
||||
|
||||
A fully durable `AgentHarness` is not realistic by itself because important dependencies are runtime JS supplied by the host app:
|
||||
|
||||
- tool implementations
|
||||
- model/auth providers
|
||||
- extensions and hook handlers
|
||||
- resource loaders
|
||||
- system-prompt callbacks/modifiers
|
||||
|
||||
Tool registries are runtime dependencies. The harness should persist serializable tool configuration, such as active tool names, but not concrete tool implementations.
|
||||
|
||||
The practical target is a semi-durable harness:
|
||||
|
||||
- session is the durable append-only state tree
|
||||
- harness persists the state it owns into session entries
|
||||
- the host app is responsible for recreating compatible non-persistable dependencies on resume
|
||||
- recovery restarts from durable boundaries, not from an in-flight provider stream
|
||||
|
||||
## Session owns durable state
|
||||
|
||||
Treat session as all durable agent state, not just transcript history.
|
||||
|
||||
Existing session state already includes harness state:
|
||||
|
||||
- model changes
|
||||
- thinking-level changes
|
||||
- active-tool changes
|
||||
- leaf entries
|
||||
- labels
|
||||
- compactions and branch summaries
|
||||
- custom messages and custom entries
|
||||
|
||||
That suggests continuing with one durable session log rather than adding harness sidecars. Sidecars may still be useful for large blobs, but the session entry should remain the source-of-truth reference.
|
||||
|
||||
## What the app must provide on resume
|
||||
|
||||
The app must recreate compatible runtime dependencies:
|
||||
|
||||
- model registry / model objects
|
||||
- tool registry
|
||||
- extension set, versions, and ordering
|
||||
- resource loaders
|
||||
- system prompt providers/hooks
|
||||
- auth providers
|
||||
- app-specific hooks
|
||||
|
||||
Harness can validate stable IDs/versions/hashes when available, but it cannot serialize these dependencies itself.
|
||||
|
||||
## Runtime configuration and restore
|
||||
|
||||
Constructor options remain explicit runtime configuration and do not read session state. Hidden async restore in a constructor would make failure handling ambiguous.
|
||||
|
||||
A future async builder/factory should own durable restore:
|
||||
|
||||
```ts
|
||||
const harness = await AgentHarness.builder()
|
||||
.env(env)
|
||||
.session(session)
|
||||
.model(defaultModel)
|
||||
.tools(runtimeTools)
|
||||
.defaultActiveTools(["read", "edit"])
|
||||
.restore({ missingActiveTools: "fail" });
|
||||
```
|
||||
|
||||
`restore()` should read the active branch, reduce durable harness configuration, apply defaults for missing entries, validate against app-supplied runtime dependencies, construct the harness, and optionally emit `source: "restore"` update events after construction.
|
||||
|
||||
For active tools:
|
||||
|
||||
- `active_tools_change` entries are branch-scoped durable config.
|
||||
- If no `active_tools_change` exists on the branch, restore uses builder defaults, or all registered tools if no default active names were supplied.
|
||||
- Active tool names must be unique.
|
||||
- Tool registry names must be unique.
|
||||
- Missing restored active tool names should fail restore by default; permissive drop/disable policies can be added explicitly later.
|
||||
- Concrete tools are never restored from session; the host app must provide compatible tools.
|
||||
|
||||
## What harness should persist
|
||||
|
||||
Minimum useful durability entries:
|
||||
|
||||
- branch-scoped active tool names
|
||||
- queued steer/followUp/nextTurn messages
|
||||
- queue consumption tied to a turn
|
||||
- pending session writes accepted during active operations
|
||||
- pending write application status
|
||||
- operation start/finish/interruption
|
||||
- turn start/finish
|
||||
- provider request start/finish, if needed for recovery diagnostics
|
||||
- tool call start/finish, if we want safe tool recovery
|
||||
|
||||
Potential entries:
|
||||
|
||||
```ts
|
||||
type DurableHarnessEntry =
|
||||
| QueueEnqueuedEntry
|
||||
| QueueConsumedEntry
|
||||
| PendingWriteEnqueuedEntry
|
||||
| PendingWriteAppliedEntry
|
||||
| OperationStartedEntry
|
||||
| OperationFinishedEntry
|
||||
| OperationInterruptedEntry
|
||||
| TurnStartedEntry
|
||||
| TurnFinishedEntry
|
||||
| ProviderRequestStartedEntry
|
||||
| ProviderRequestFinishedEntry
|
||||
| ToolCallStartedEntry
|
||||
| ToolCallFinishedEntry;
|
||||
```
|
||||
|
||||
Every accepted mutation must be durable before the public API resolves.
|
||||
|
||||
## Recovery model
|
||||
|
||||
On startup:
|
||||
|
||||
1. Host app registers tools/models/extensions/resources/auth/hooks.
|
||||
2. Harness opens session.
|
||||
3. Harness reduces session entries into:
|
||||
- current leaf
|
||||
- conversation branch
|
||||
- harness config, including active tool names
|
||||
- queues
|
||||
- pending writes
|
||||
- active operation/turn/tool state
|
||||
4. Harness validates required runtime dependencies, including restored active tool names against the app-provided tool registry.
|
||||
5. Harness reconciles unfinished operation state.
|
||||
|
||||
Provider streams are not resumable. Recovery can only retry from a durable boundary or mark the operation interrupted.
|
||||
|
||||
## Recovery policies
|
||||
|
||||
Default conservative policy:
|
||||
|
||||
- unfinished agent turn: mark interrupted, preserve durable queues/pending writes, return idle
|
||||
- unfinished provider request: mark interrupted; do not retry automatically
|
||||
- unfinished tool call: append interrupted/error tool result; retry only if the tool declares retry-safe/idempotent
|
||||
- unfinished compaction: rerun if no compaction entry exists
|
||||
- unfinished branch summary/tree navigation: rerun/apply missing summary or leaf entries if safe
|
||||
|
||||
Optional policy:
|
||||
|
||||
```ts
|
||||
recovery: "mark_interrupted" | "retry_unfinished"
|
||||
```
|
||||
|
||||
`retry_unfinished` must be guarded around non-idempotent tool calls.
|
||||
|
||||
## Critical scenarios
|
||||
|
||||
### Queues
|
||||
|
||||
- Crash before `queue_enqueued`: message was not accepted.
|
||||
- Crash after `queue_enqueued`: message is restored.
|
||||
- Crash after queue drain but before durable turn record: risk of loss/duplication.
|
||||
- Required invariant: consumed queue IDs must be recorded in `turn_started` or equivalent before they are considered consumed.
|
||||
|
||||
### Pending writes
|
||||
|
||||
- Crash before `pending_write_enqueued`: write was not accepted.
|
||||
- Crash after enqueue before apply: recovery applies it.
|
||||
- Crash after apply before applied marker: deterministic target entry IDs let recovery detect the entry already exists and mark it applied.
|
||||
|
||||
### Agent loop turn
|
||||
|
||||
- Crash before provider request: retry or mark interrupted.
|
||||
- Crash during provider request: mark interrupted by default.
|
||||
- Crash after provider response before assistant message persisted: response is lost unless provider result was journaled.
|
||||
- Crash after assistant message persisted: recover from durable message.
|
||||
|
||||
### Tool calls
|
||||
|
||||
- Crash after tool call starts but before result: external side effects may already have happened.
|
||||
- Default recovery should not rerun non-idempotent tools.
|
||||
- Tool calls need stable IDs and retry-safety metadata for automatic recovery.
|
||||
|
||||
### Compaction
|
||||
|
||||
- Crash before summary generation: rerun preparation/summary.
|
||||
- Crash after generated summary but before compaction entry: rerun unless summary was journaled.
|
||||
- Crash after compaction entry: operation is complete; append finish marker if missing.
|
||||
|
||||
### Branch summary / tree navigation
|
||||
|
||||
- Crash before summary: rerun or mark interrupted.
|
||||
- Crash after summary entry before leaf entry: append missing leaf entry.
|
||||
- Crash after leaf entry: operation is complete; append finish marker if missing.
|
||||
|
||||
## Minimum viable spike
|
||||
|
||||
1. Add durable queue entries.
|
||||
2. Add durable pending write entries with deterministic target IDs.
|
||||
3. Add operation start/finish/interrupted entries.
|
||||
4. Add turn start with consumed queue IDs.
|
||||
5. Recover by reducing the session log.
|
||||
6. Mark unfinished agent turns interrupted by default.
|
||||
7. Rerun unfinished compaction/tree operations only when no final entry exists.
|
||||
8. Do not retry unfinished tool calls unless tool metadata says retry-safe.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Which remaining harness config entries should move into session first: resources, stream options, system prompt refs?
|
||||
- Should resolved system prompt text be snapshotted per turn for audit/debug?
|
||||
- Do we require strict dependency ID/version matching on resume?
|
||||
- How much provider request data should be journaled?
|
||||
- Should recovery append user-visible assistant interruption messages or only internal operation entries?
|
||||
- Should storage support truncating a final partial JSONL line during recovery?
|
||||
445
packages/agent/docs/hooks.md
Normal file
445
packages/agent/docs/hooks.md
Normal file
@@ -0,0 +1,445 @@
|
||||
# AgentHarness hooks design
|
||||
|
||||
<!-- Synced from jot 3utlzkxy. Edit this file in-repo going forward. -->
|
||||
|
||||
Final design.
|
||||
|
||||
## Core model
|
||||
|
||||
Events carry their result type as a type-only phantom:
|
||||
|
||||
```ts
|
||||
declare const HookResult: unique symbol;
|
||||
|
||||
interface HookEvent<TType extends string, TResult = void> {
|
||||
type: TType;
|
||||
readonly [HookResult]?: TResult;
|
||||
}
|
||||
|
||||
type ResultOf<E> = E extends { readonly [HookResult]?: infer R } ? R : void;
|
||||
|
||||
type HookHandler<E, Ctx> = (
|
||||
event: E,
|
||||
ctx: Ctx,
|
||||
signal?: AbortSignal,
|
||||
) => ResultOf<E> | void | Promise<ResultOf<E> | void>;
|
||||
|
||||
type HookObserver<E, Ctx> = (
|
||||
event: E,
|
||||
ctx: Ctx,
|
||||
signal?: AbortSignal,
|
||||
) => void | Promise<void>;
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
interface ContextEvent extends HookEvent<"context", { messages?: AgentMessage[] }> {
|
||||
type: "context";
|
||||
messages: AgentMessage[];
|
||||
}
|
||||
|
||||
interface ToolCallEvent extends HookEvent<"tool_call", { block?: boolean; reason?: string }> {
|
||||
type: "tool_call";
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface MessageEndEvent extends HookEvent<"message_end"> {
|
||||
type: "message_end";
|
||||
message: AgentMessage;
|
||||
}
|
||||
```
|
||||
|
||||
No result map. No spec table. The event type defines its own result.
|
||||
|
||||
## Hooks interface
|
||||
|
||||
```ts
|
||||
interface AgentHarnessHooks<E extends HookEvent<string, unknown>, Ctx> {
|
||||
context: Ctx;
|
||||
|
||||
setContext(ctx: Ctx): void;
|
||||
|
||||
observe(handler: HookObserver<E, Ctx>): () => void;
|
||||
|
||||
on<TType extends E["type"]>(
|
||||
type: TType,
|
||||
handler: HookHandler<Extract<E, { type: TType }>, Ctx>,
|
||||
): () => void;
|
||||
|
||||
emit<TEvent extends E>(
|
||||
event: TEvent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ResultOf<TEvent> | undefined>;
|
||||
|
||||
addCleanup(cleanup: () => void | Promise<void>): () => void;
|
||||
|
||||
clear(): Promise<void>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
Important split:
|
||||
|
||||
- `observe()` sees all events, read-only, return ignored.
|
||||
- `on(type, handler)` participates in that event’s semantics.
|
||||
- `emit(event)` is the only thing `AgentHarness` calls.
|
||||
- `clear()` removes observers/handlers and runs cleanups.
|
||||
|
||||
## Default implementation internals
|
||||
|
||||
```ts
|
||||
class DefaultAgentHarnessHooks<E extends HookEvent<string, unknown>, Ctx>
|
||||
implements AgentHarnessHooks<E, Ctx> {
|
||||
context: Ctx;
|
||||
|
||||
private observers = new Set<HookObserver<E, Ctx>>();
|
||||
private handlers = new Map<string, Set<HookHandler<any, Ctx>>>();
|
||||
private cleanups = new Set<() => void | Promise<void>>();
|
||||
|
||||
constructor(ctx: Ctx) {
|
||||
this.context = ctx;
|
||||
}
|
||||
|
||||
setContext(ctx: Ctx): void {
|
||||
this.context = ctx;
|
||||
}
|
||||
|
||||
observe(handler: HookObserver<E, Ctx>): () => void {
|
||||
this.observers.add(handler);
|
||||
return () => this.observers.delete(handler);
|
||||
}
|
||||
|
||||
on(type, handler): () => void {
|
||||
let handlers = this.handlers.get(type);
|
||||
if (!handlers) {
|
||||
handlers = new Set();
|
||||
this.handlers.set(type, handlers);
|
||||
}
|
||||
handlers.add(handler);
|
||||
return () => handlers.delete(handler);
|
||||
}
|
||||
|
||||
async emit(event, signal?) {
|
||||
for (const observer of this.observers) {
|
||||
await observer(event, this.context, signal);
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case "context":
|
||||
return this.emitContext(event, signal);
|
||||
case "before_provider_request":
|
||||
return this.emitBeforeProviderRequest(event, signal);
|
||||
case "before_provider_payload":
|
||||
return this.emitBeforeProviderPayload(event, signal);
|
||||
case "before_agent_start":
|
||||
return this.emitBeforeAgentStart(event, signal);
|
||||
case "tool_call":
|
||||
return this.emitToolCall(event, signal);
|
||||
case "tool_result":
|
||||
return this.emitToolResult(event, signal);
|
||||
case "session_before_compact":
|
||||
case "session_before_tree":
|
||||
return this.emitFirstCancelOrLast(event, signal);
|
||||
default:
|
||||
await this.emitObservationHandlers(event, signal);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Internal casts are acceptable inside the implementation because `Map<string, ...>` loses specificity. Public API remains typed.
|
||||
|
||||
## Mutation semantics
|
||||
|
||||
### Observation
|
||||
|
||||
```ts
|
||||
await hooks.emit({ type: "message_end", message }, signal);
|
||||
```
|
||||
|
||||
Observers run. `message_end` handlers run. Return ignored unless that event later gets a result type.
|
||||
|
||||
### Context transform
|
||||
|
||||
Handlers run in order. Each sees current messages.
|
||||
|
||||
```ts
|
||||
let current = event;
|
||||
|
||||
for (const handler of handlers("context")) {
|
||||
const result = await handler(current, ctx, signal);
|
||||
if (result?.messages) {
|
||||
current = { ...current, messages: result.messages };
|
||||
}
|
||||
}
|
||||
|
||||
return current.messages === event.messages ? undefined : { messages: current.messages };
|
||||
```
|
||||
|
||||
### Provider request / payload
|
||||
|
||||
Sequential transform. Each handler sees previous output.
|
||||
|
||||
```ts
|
||||
let current = event;
|
||||
|
||||
for (const handler of handlers("before_provider_payload")) {
|
||||
const result = await handler(current, ctx, signal);
|
||||
if (result !== undefined) {
|
||||
current = { ...current, payload: result.payload };
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? { payload: current.payload } : undefined;
|
||||
```
|
||||
|
||||
### Before agent start
|
||||
|
||||
Collect injected messages, chain system prompt.
|
||||
|
||||
```ts
|
||||
let systemPrompt = event.systemPrompt;
|
||||
const messages = [];
|
||||
|
||||
for (const handler of handlers("before_agent_start")) {
|
||||
const result = await handler({ ...event, systemPrompt }, ctx, signal);
|
||||
if (result?.messages) messages.push(...result.messages);
|
||||
if (result?.systemPrompt !== undefined) systemPrompt = result.systemPrompt;
|
||||
}
|
||||
|
||||
return messages.length || systemPrompt !== event.systemPrompt
|
||||
? { messages, systemPrompt }
|
||||
: undefined;
|
||||
```
|
||||
|
||||
### Tool call
|
||||
|
||||
Sequential, early exit on block.
|
||||
|
||||
```ts
|
||||
for (const handler of handlers("tool_call")) {
|
||||
const result = await handler(event, ctx, signal);
|
||||
if (result?.block) return result;
|
||||
}
|
||||
```
|
||||
|
||||
### Tool result
|
||||
|
||||
Sequential patch accumulation. Each handler sees current patched result.
|
||||
|
||||
```ts
|
||||
let current = event;
|
||||
let modified = false;
|
||||
|
||||
for (const handler of handlers("tool_result")) {
|
||||
const result = await handler(current, ctx, signal);
|
||||
if (!result) continue;
|
||||
|
||||
current = {
|
||||
...current,
|
||||
content: result.content ?? current.content,
|
||||
details: result.details ?? current.details,
|
||||
isError: result.isError ?? current.isError,
|
||||
};
|
||||
|
||||
modified = true;
|
||||
}
|
||||
|
||||
return modified
|
||||
? { content: current.content, details: current.details, isError: current.isError }
|
||||
: undefined;
|
||||
```
|
||||
|
||||
### Session-before events
|
||||
|
||||
Sequential, early exit on cancel.
|
||||
|
||||
```ts
|
||||
let last;
|
||||
|
||||
for (const handler of handlers(event.type)) {
|
||||
const result = await handler(event, ctx, signal);
|
||||
if (!result) continue;
|
||||
last = result;
|
||||
if (result.cancel) return result;
|
||||
}
|
||||
|
||||
return last;
|
||||
```
|
||||
|
||||
## Harness usage
|
||||
|
||||
Harness only does this:
|
||||
|
||||
```ts
|
||||
await this.hooks.emit(event, signal);
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```ts
|
||||
const result = await this.hooks.emit({ type: "context", messages }, signal);
|
||||
return result?.messages ?? messages;
|
||||
```
|
||||
|
||||
Harness does not store handlers, chain listeners, or know extension policy.
|
||||
|
||||
## Context
|
||||
|
||||
Context is a normal object, not rebuilt per emit.
|
||||
|
||||
```ts
|
||||
const hooks = new CodingAgentHooks({
|
||||
harness: harnessFacade,
|
||||
session: sessionFacade,
|
||||
ui: noUiFacade,
|
||||
});
|
||||
```
|
||||
|
||||
Later:
|
||||
|
||||
```ts
|
||||
hooks.setContext({
|
||||
...hooks.context,
|
||||
ui: tuiFacade,
|
||||
});
|
||||
```
|
||||
|
||||
For dynamic state, prefer stable facades/methods over getter maze:
|
||||
|
||||
```ts
|
||||
interface CodingAgentHookContext {
|
||||
harness: HarnessFacade;
|
||||
session: SessionFacade;
|
||||
ui: UiFacade;
|
||||
models: ModelFacade;
|
||||
}
|
||||
```
|
||||
|
||||
Per-run `signal` is passed as the third handler arg.
|
||||
|
||||
## Extension loading later
|
||||
|
||||
Extension loading can live next to harness and construct hooks:
|
||||
|
||||
```ts
|
||||
const hooks = await loadExtensions({
|
||||
paths,
|
||||
context,
|
||||
hooks: new CodingAgentHooks(context),
|
||||
});
|
||||
const harness = new AgentHarness({ ..., hooks });
|
||||
```
|
||||
|
||||
The loader registers into hooks:
|
||||
|
||||
```ts
|
||||
hooks.on("context", handler);
|
||||
hooks.on("tool_call", handler);
|
||||
hooks.addCleanup(cleanup);
|
||||
```
|
||||
|
||||
For reload:
|
||||
|
||||
```ts
|
||||
await hooks.clear();
|
||||
const nextHooks = await loadExtensions(...);
|
||||
harness.setHooks(nextHooks); // idle-only if supported
|
||||
```
|
||||
|
||||
## Poking holes
|
||||
|
||||
### 1. Error policy must be explicit
|
||||
|
||||
Existing coding-agent catches extension errors, reports them, and continues. New hooks need the same policy, likely:
|
||||
|
||||
```ts
|
||||
errorMode: "continue" | "throw"
|
||||
onError(error)
|
||||
```
|
||||
|
||||
For coding-agent, default should be `"continue"`.
|
||||
|
||||
### 2. Source metadata matters
|
||||
|
||||
Existing runner knows which extension produced an error/resource/tool. Plain `on()` loses that unless we add registration metadata or scopes.
|
||||
|
||||
Probably needed:
|
||||
|
||||
```ts
|
||||
const scope = hooks.createScope({ sourceInfo });
|
||||
scope.on("context", handler);
|
||||
scope.addCleanup(...);
|
||||
```
|
||||
|
||||
Or `on(type, handler, { sourceInfo })`.
|
||||
|
||||
### 3. Some extension capabilities are registries, not hooks
|
||||
|
||||
These are not covered by `emit()` and should stay as registries on `CodingAgentHooks` or an extension host:
|
||||
|
||||
- tools
|
||||
- commands
|
||||
- shortcuts
|
||||
- flags
|
||||
- message renderers
|
||||
- provider registrations
|
||||
- OAuth providers
|
||||
- custom model providers
|
||||
|
||||
That is fine. They do not belong in `AgentHarness`.
|
||||
|
||||
### 4. Existing coding-agent events can be represented
|
||||
|
||||
No blocker for:
|
||||
|
||||
- `context`
|
||||
- `before_provider_request`
|
||||
- `after_provider_response`
|
||||
- `before_agent_start`
|
||||
- `message_end`
|
||||
- `tool_call`
|
||||
- `tool_result`
|
||||
- `input`
|
||||
- `user_bash`
|
||||
- `resources_discover`
|
||||
- `session_before_*`
|
||||
- `session_*`
|
||||
- model/thinking selection events
|
||||
- agent/turn/message/tool lifecycle events
|
||||
|
||||
They become additional event types handled by `CodingAgentHooks`.
|
||||
|
||||
### 5. Need to preserve exact old semantics
|
||||
|
||||
When porting coding-agent, special cases must be copied:
|
||||
|
||||
- `input`: transform chain, `handled` short-circuits.
|
||||
- `user_bash`: first meaningful result wins.
|
||||
- `message_end`: replacement must keep same role.
|
||||
- `before_agent_start`: `ctx.getSystemPrompt()` must reflect current chained prompt.
|
||||
- `resources_discover`: aggregate paths and keep extension source.
|
||||
- `tool_call`: argument mutation remains visible to later handlers.
|
||||
- `tool_result`: later handlers see prior patches.
|
||||
|
||||
The design allows all of that, but the default/coding hooks implementation must encode it.
|
||||
|
||||
### 6. `emit()` switch can miss custom mutation events
|
||||
|
||||
If a subclass adds a result-producing event but forgets to override `emit()`, it will behave observationally. Tests should catch this. Could add a protected strategy registry later if this becomes error-prone, but not initially.
|
||||
|
||||
### 7. Observer semantics are intentionally limited
|
||||
|
||||
Observers see the original emitted event once. They do not see every intermediate mutation. If something needs final transformed state, emit a separate final event or use an event-specific handler.
|
||||
|
||||
## Verdict
|
||||
|
||||
This design can implement a new coding-agent. It is simpler than the current runner, keeps harness clean, and preserves the important extension capabilities as long as `CodingAgentHooks` adds source-aware scopes, registries, cleanup, and the exact old event semantics.
|
||||
|
||||
--- Comments ---
|
||||
|
||||
Thread hn2xk0tzhj on "addCleanup(cleanup"
|
||||
[tmluyaub9v] Owner (2026-05-14T12:55:45.500Z): cleanup should be passed along optionally to on/observe
|
||||
376
packages/agent/docs/observability.md
Normal file
376
packages/agent/docs/observability.md
Normal file
@@ -0,0 +1,376 @@
|
||||
<!-- Synced from jot qe0ikdqs. Edit this file in-repo going forward. -->
|
||||
|
||||
# Pi Observability Design Notes
|
||||
|
||||
## Goal
|
||||
|
||||
Make `packages/ai` and `packages/agent`/harness observable without depending on OpenTelemetry, Sentry, or any APM vendor.
|
||||
|
||||
Pi should emit stable, structured lifecycle events. External listeners can convert those events into OTel spans, Sentry spans, logs, metrics, or custom telemetry.
|
||||
|
||||
## Mental model
|
||||
|
||||
A trace is one causal tree of work, e.g. one user turn.
|
||||
|
||||
A span is one timed operation in that tree. It is normally represented by IDs, not object pointers:
|
||||
|
||||
```ts
|
||||
interface SpanRecord {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
parentSpanId?: string;
|
||||
name: string;
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
attributes: Record<string, unknown>;
|
||||
status: "ok" | "error";
|
||||
}
|
||||
```
|
||||
|
||||
Example tree:
|
||||
|
||||
```text
|
||||
traceId=t1 spanId=s1 parent=- name=pi.agent.prompt
|
||||
traceId=t1 spanId=s2 parent=s1 name=pi.agent.turn
|
||||
traceId=t1 spanId=s3 parent=s2 name=pi.ai.provider.request
|
||||
traceId=t1 spanId=s4 parent=s2 name=pi.agent.tool_call
|
||||
traceId=t1 spanId=s5 parent=s4 name=pi.session.append_entry
|
||||
```
|
||||
|
||||
## Async context
|
||||
|
||||
JavaScript has one event loop but multiple async chains can interleave. A single global `currentContext` breaks under concurrency.
|
||||
|
||||
`AsyncLocalStorage` is the Node equivalent of `ThreadLocal` for async continuations. It lets concurrent operations keep distinct current contexts:
|
||||
|
||||
```ts
|
||||
await Promise.all([
|
||||
runWithPiContext({ userId: "alice" }, () => harness.prompt("A")),
|
||||
runWithPiContext({ userId: "bob" }, () => harness.prompt("B")),
|
||||
]);
|
||||
```
|
||||
|
||||
Deep code can then read the correct current context for the active async chain.
|
||||
|
||||
Pi must run in Node, Bun, browser, workers, and other JS runtimes, so ALS cannot be the core abstraction. It should be a runtime adapter.
|
||||
|
||||
## Core design
|
||||
|
||||
Pi owns a small runtime-agnostic observability abstraction:
|
||||
|
||||
```ts
|
||||
export interface PiObservabilityContext {
|
||||
traceId?: string;
|
||||
currentSpanId?: string;
|
||||
userContext?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PiObservabilityEvent {
|
||||
type: "start" | "end" | "error" | "event";
|
||||
name: string;
|
||||
traceId: string;
|
||||
spanId?: string;
|
||||
parentSpanId?: string;
|
||||
timestamp: number;
|
||||
durationMs?: number;
|
||||
context?: Record<string, unknown>;
|
||||
payload?: Record<string, unknown>;
|
||||
error?: { name: string; message: string };
|
||||
}
|
||||
|
||||
export interface PiObservability {
|
||||
getContext(): PiObservabilityContext | undefined;
|
||||
runWithContext<T>(context: PiObservabilityContext, fn: () => T): T;
|
||||
emit(event: PiObservabilityEvent): void;
|
||||
hasSubscribers(): boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Public API:
|
||||
|
||||
```ts
|
||||
export function configurePiObservability(observability: PiObservability): void;
|
||||
export function subscribePiObservability(listener: (event: PiObservabilityEvent) => void): () => void;
|
||||
export function runWithPiContext<T>(userContext: Record<string, unknown>, fn: () => T): T;
|
||||
export function traceOperation<T>(name: string, payload: Record<string, unknown>, fn: () => T): T;
|
||||
```
|
||||
|
||||
`traceOperation()`:
|
||||
|
||||
1. reads the current context
|
||||
2. creates `traceId` if missing
|
||||
3. creates a new `spanId`
|
||||
4. uses current span as `parentSpanId`
|
||||
5. emits `start`
|
||||
6. runs callback under child context
|
||||
7. emits `end` or `error`
|
||||
8. rethrows on error
|
||||
|
||||
Pseudo-code:
|
||||
|
||||
```ts
|
||||
function traceOperation<T>(name: string, payload: Record<string, unknown>, fn: () => T): T {
|
||||
const parent = getContext();
|
||||
const traceId = parent?.traceId ?? createId();
|
||||
const spanId = createId();
|
||||
const parentSpanId = parent?.currentSpanId;
|
||||
|
||||
const child = { ...parent, traceId, currentSpanId: spanId };
|
||||
|
||||
emit({ type: "start", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: parent?.userContext, payload });
|
||||
|
||||
return runWithContext(child, () => {
|
||||
try {
|
||||
const result = fn();
|
||||
// Promise-aware implementation emits end/error after settlement.
|
||||
emit({ type: "end", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: child.userContext, payload });
|
||||
return result;
|
||||
} catch (error) {
|
||||
emit({ type: "error", name, traceId, spanId, parentSpanId, timestamp: Date.now(), context: child.userContext, payload, error: serializeError(error) });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Runtime adapters
|
||||
|
||||
Core packages should not import Node-only APIs.
|
||||
|
||||
Possible implementations:
|
||||
|
||||
- Node adapter: `AsyncLocalStorage` for context, optional `diagnostics_channel` publishing.
|
||||
- Browser/workers fallback: local subscriber set and limited/manual context propagation.
|
||||
- Bun/Deno adapters: use runtime-specific async context if available.
|
||||
|
||||
For Node, diagnostics channels can be used as a passive event bus:
|
||||
|
||||
```ts
|
||||
import { channel } from "diagnostics_channel";
|
||||
channel("pi.observability").publish(event);
|
||||
```
|
||||
|
||||
Subscribers can create OTel/Sentry spans without monkey-patching pi.
|
||||
|
||||
## What pi emits
|
||||
|
||||
Pi emits what happened. It does not create OTel/Sentry spans directly.
|
||||
|
||||
Initial minimal event names:
|
||||
|
||||
```text
|
||||
pi.agent.prompt
|
||||
pi.agent.skill
|
||||
pi.agent.prompt_template
|
||||
pi.agent.compaction
|
||||
pi.agent.branch_navigation
|
||||
pi.agent.session.append_entry
|
||||
pi.ai.provider.request
|
||||
```
|
||||
|
||||
Each operation emits:
|
||||
|
||||
```text
|
||||
start
|
||||
end
|
||||
error
|
||||
```
|
||||
|
||||
Later additions:
|
||||
|
||||
```text
|
||||
pi.agent.turn
|
||||
pi.agent.tool_call
|
||||
pi.agent.queue_update
|
||||
pi.ai.provider.retry
|
||||
pi.ai.provider.first_token
|
||||
pi.ai.provider.usage
|
||||
pi.session.read
|
||||
pi.session.write
|
||||
```
|
||||
|
||||
## Minimal instrumentation points
|
||||
|
||||
### packages/agent
|
||||
|
||||
Wrap:
|
||||
|
||||
- `AgentHarness.prompt()`
|
||||
- `AgentHarness.skill()`
|
||||
- `AgentHarness.promptFromTemplate()`
|
||||
- `AgentHarness.compact()`
|
||||
- `AgentHarness.navigateTree()`
|
||||
- `Session.appendTypedEntry()` or storage append facade
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
return traceOperation(
|
||||
"pi.agent.prompt",
|
||||
{
|
||||
sessionId: turnState.sessionId,
|
||||
provider: turnState.model.provider,
|
||||
model: turnState.model.id,
|
||||
promptLength: text.length,
|
||||
imageCount: options?.images?.length ?? 0,
|
||||
},
|
||||
() => this.executeTurn(turnState, text, options),
|
||||
);
|
||||
```
|
||||
|
||||
Session write:
|
||||
|
||||
```ts
|
||||
return traceOperation(
|
||||
"pi.agent.session.append_entry",
|
||||
{ entryType: entry.type },
|
||||
async () => {
|
||||
await this.unwrap(this.storage.appendEntry(entry));
|
||||
return entry.id;
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### packages/ai
|
||||
|
||||
Wrap common provider boundaries:
|
||||
|
||||
- `streamSimple()`
|
||||
- `completeSimple()`
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
return traceOperation(
|
||||
"pi.ai.provider.request",
|
||||
{
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
sessionId: options.sessionId,
|
||||
reasoning: options.reasoning,
|
||||
},
|
||||
() => actualStreamSimple(model, context, options),
|
||||
);
|
||||
```
|
||||
|
||||
End/error payloads can include safe metadata:
|
||||
|
||||
- stop reason
|
||||
- status code
|
||||
- retry count
|
||||
- input/output/total tokens
|
||||
- cost total
|
||||
- aborted/timeout flag
|
||||
|
||||
## Safety and redaction
|
||||
|
||||
Default payloads must be safe.
|
||||
|
||||
Safe by default:
|
||||
|
||||
- provider
|
||||
- model
|
||||
- API identifier
|
||||
- session id
|
||||
- entry type
|
||||
- tool name
|
||||
- status code
|
||||
- stop reason
|
||||
- token counts
|
||||
- costs
|
||||
- durations
|
||||
|
||||
Unsafe by default:
|
||||
|
||||
- prompts
|
||||
- completions
|
||||
- tool args
|
||||
- tool results
|
||||
- shell output
|
||||
- file contents
|
||||
- provider request payloads
|
||||
- provider response bodies
|
||||
- API keys
|
||||
- headers
|
||||
|
||||
Content capture can be opt-in later with explicit redaction hooks.
|
||||
|
||||
## Listener behavior
|
||||
|
||||
Observability must never affect pi execution.
|
||||
|
||||
Subscriber errors should be swallowed or isolated. Harness hooks are control-plane and may affect execution; observability subscribers are passive and must not.
|
||||
|
||||
## User context
|
||||
|
||||
Users can associate arbitrary context with a turn:
|
||||
|
||||
```ts
|
||||
await runWithPiContext(
|
||||
{
|
||||
userId: "u123",
|
||||
orgId: "acme",
|
||||
region: "eu",
|
||||
},
|
||||
() => harness.prompt("fix this"),
|
||||
);
|
||||
```
|
||||
|
||||
Every emitted event inside that async chain includes the context:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: "start",
|
||||
name: "pi.ai.provider.request",
|
||||
traceId: "t1",
|
||||
spanId: "s3",
|
||||
parentSpanId: "s1",
|
||||
context: {
|
||||
userId: "u123",
|
||||
orgId: "acme",
|
||||
region: "eu",
|
||||
},
|
||||
payload: {
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
An OTel adapter can map this to span attributes. A Sentry adapter can map it to Sentry context/spans. A custom user can log JSON.
|
||||
|
||||
## Package story
|
||||
|
||||
Minimal initial package:
|
||||
|
||||
```text
|
||||
packages/observability
|
||||
runtime-agnostic context + traceOperation + subscribe
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```text
|
||||
packages/ai
|
||||
emits pi.ai.* events
|
||||
|
||||
packages/agent
|
||||
emits pi.agent.* / pi.session.* events
|
||||
```
|
||||
|
||||
Optional later:
|
||||
|
||||
```text
|
||||
packages/observability-node
|
||||
AsyncLocalStorage + diagnostics_channel bridge
|
||||
|
||||
packages/otel
|
||||
subscribes to pi events and creates OpenTelemetry spans
|
||||
```
|
||||
|
||||
## Thesis
|
||||
|
||||
Pi defines a stable, safe event contract. Adapters define where events go.
|
||||
|
||||
This makes ai/harness observable without binding core packages to OTel, Sentry, Node-only APIs, or monkey-patching.
|
||||
@@ -1,10 +1,25 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-agent-core",
|
||||
"version": "0.74.0",
|
||||
"version": "0.79.9",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./base": {
|
||||
"types": "./dist/base.d.ts",
|
||||
"import": "./dist/base.js"
|
||||
},
|
||||
"./node": {
|
||||
"types": "./dist/node.d.ts",
|
||||
"import": "./dist/node.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
@@ -12,15 +27,16 @@
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"build": "tsgo -p tsconfig.build.json",
|
||||
"dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
|
||||
"test": "vitest --run",
|
||||
"test:harness": "vitest --run --config vitest.harness.config.ts",
|
||||
"coverage:harness": "vitest --run --config vitest.harness.config.ts --coverage",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.74.0",
|
||||
"ignore": "^7.0.5",
|
||||
"typebox": "^1.1.24",
|
||||
"yaml": "^2.8.2"
|
||||
"@earendil-works/pi-ai": "^0.79.9",
|
||||
"ignore": "7.0.5",
|
||||
"typebox": "1.1.38",
|
||||
"yaml": "2.9.0"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
@@ -33,15 +49,16 @@
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/earendil-works/pi-mono.git",
|
||||
"url": "git+https://github.com/earendil-works/pi.git",
|
||||
"directory": "packages/agent"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.2.4"
|
||||
"@types/node": "24.12.4",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
streamSimple,
|
||||
type ToolResultMessage,
|
||||
validateToolArguments,
|
||||
} from "@earendil-works/pi-ai";
|
||||
} from "@earendil-works/pi-ai/base";
|
||||
import type {
|
||||
AgentContext,
|
||||
AgentEvent,
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
AgentToolCall,
|
||||
AgentToolResult,
|
||||
StreamFn,
|
||||
} from "./types.js";
|
||||
} from "./types.ts";
|
||||
|
||||
export type AgentEventSink = (event: AgentEvent) => Promise<void> | void;
|
||||
|
||||
@@ -436,6 +436,10 @@ async function executeToolCallsSequential(
|
||||
await emitToolResultMessage(toolResultMessage, emit);
|
||||
finalizedCalls.push(finalized);
|
||||
messages.push(toolResultMessage);
|
||||
|
||||
if (signal?.aborted) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -471,6 +475,9 @@ async function executeToolCallsParallel(
|
||||
} satisfies FinalizedToolCallOutcome;
|
||||
await emitToolExecutionEnd(finalized, emit);
|
||||
finalizedCalls.push(finalized);
|
||||
if (signal?.aborted) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -487,6 +494,9 @@ async function executeToolCallsParallel(
|
||||
await emitToolExecutionEnd(finalized, emit);
|
||||
return finalized;
|
||||
});
|
||||
if (signal?.aborted) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const orderedFinalizedCalls = await Promise.all(
|
||||
@@ -578,6 +588,13 @@ async function prepareToolCall(
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (signal?.aborted) {
|
||||
return {
|
||||
kind: "immediate",
|
||||
result: createErrorToolResult("Operation aborted"),
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
if (beforeResult?.block) {
|
||||
return {
|
||||
kind: "immediate",
|
||||
@@ -586,6 +603,13 @@ async function prepareToolCall(
|
||||
};
|
||||
}
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return {
|
||||
kind: "immediate",
|
||||
result: createErrorToolResult("Operation aborted"),
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "prepared",
|
||||
toolCall,
|
||||
@@ -607,6 +631,7 @@ async function executePreparedToolCall(
|
||||
emit: AgentEventSink,
|
||||
): Promise<ExecutedToolCallOutcome> {
|
||||
const updateEvents: Promise<void>[] = [];
|
||||
let acceptingUpdates = true;
|
||||
|
||||
try {
|
||||
const result = await prepared.tool.execute(
|
||||
@@ -614,6 +639,7 @@ async function executePreparedToolCall(
|
||||
prepared.args as never,
|
||||
signal,
|
||||
(partialResult) => {
|
||||
if (!acceptingUpdates) return;
|
||||
updateEvents.push(
|
||||
Promise.resolve(
|
||||
emit({
|
||||
@@ -627,14 +653,18 @@ async function executePreparedToolCall(
|
||||
);
|
||||
},
|
||||
);
|
||||
acceptingUpdates = false;
|
||||
await Promise.all(updateEvents);
|
||||
return { result, isError: false };
|
||||
} catch (error) {
|
||||
acceptingUpdates = false;
|
||||
await Promise.all(updateEvents);
|
||||
return {
|
||||
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
|
||||
isError: true,
|
||||
};
|
||||
} finally {
|
||||
acceptingUpdates = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
type TextContent,
|
||||
type ThinkingBudgets,
|
||||
type Transport,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.js";
|
||||
} from "@earendil-works/pi-ai/base";
|
||||
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts";
|
||||
import type {
|
||||
AfterToolCallContext,
|
||||
AfterToolCallResult,
|
||||
@@ -21,9 +21,12 @@ import type {
|
||||
AgentTool,
|
||||
BeforeToolCallContext,
|
||||
BeforeToolCallResult,
|
||||
QueueMode,
|
||||
StreamFn,
|
||||
ToolExecutionMode,
|
||||
} from "./types.js";
|
||||
} from "./types.ts";
|
||||
|
||||
export type { QueueMode } from "./types.ts";
|
||||
|
||||
function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
|
||||
return messages.filter(
|
||||
@@ -53,8 +56,6 @@ const DEFAULT_MODEL = {
|
||||
maxTokens: 0,
|
||||
} satisfies Model<any>;
|
||||
|
||||
export type QueueMode = "all" | "one-at-a-time";
|
||||
|
||||
type MutableAgentState = Omit<AgentState, "isStreaming" | "streamingMessage" | "pendingToolCalls" | "errorMessage"> & {
|
||||
isStreaming: boolean;
|
||||
streamingMessage?: AgentMessage;
|
||||
@@ -116,8 +117,11 @@ export interface AgentOptions {
|
||||
|
||||
class PendingMessageQueue {
|
||||
private messages: AgentMessage[] = [];
|
||||
public mode: QueueMode;
|
||||
|
||||
constructor(public mode: QueueMode) {}
|
||||
constructor(mode: QueueMode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
enqueue(message: AgentMessage): void {
|
||||
this.messages.push(message);
|
||||
|
||||
39
packages/agent/src/base.ts
Normal file
39
packages/agent/src/base.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export * from "./agent.ts";
|
||||
export * from "./agent-loop.ts";
|
||||
export * from "./harness/agent-harness.ts";
|
||||
export {
|
||||
type BranchPreparation,
|
||||
type BranchSummaryDetails,
|
||||
type CollectEntriesResult,
|
||||
collectEntriesForBranchSummary,
|
||||
generateBranchSummary,
|
||||
prepareBranchEntries,
|
||||
} from "./harness/compaction/branch-summarization.ts";
|
||||
export {
|
||||
calculateContextTokens,
|
||||
compact,
|
||||
DEFAULT_COMPACTION_SETTINGS,
|
||||
estimateContextTokens,
|
||||
estimateTokens,
|
||||
findCutPoint,
|
||||
findTurnStartIndex,
|
||||
generateSummary,
|
||||
getLastAssistantUsage,
|
||||
prepareCompaction,
|
||||
serializeConversation,
|
||||
shouldCompact,
|
||||
} from "./harness/compaction/compaction.ts";
|
||||
export * from "./harness/messages.ts";
|
||||
export * from "./harness/prompt-templates.ts";
|
||||
export * from "./harness/session/jsonl-repo.ts";
|
||||
export * from "./harness/session/memory-repo.ts";
|
||||
export * from "./harness/session/repo-utils.ts";
|
||||
export * from "./harness/session/session.ts";
|
||||
export { uuidv7 } from "./harness/session/uuid.ts";
|
||||
export * from "./harness/skills.ts";
|
||||
export * from "./harness/system-prompt.ts";
|
||||
export * from "./harness/types.ts";
|
||||
export * from "./harness/utils/shell-output.ts";
|
||||
export * from "./harness/utils/truncate.ts";
|
||||
export * from "./proxy.ts";
|
||||
export * from "./types.ts";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,115 +1,80 @@
|
||||
/**
|
||||
* Branch summarization for tree navigation.
|
||||
*
|
||||
* When navigating to a different point in the session tree, this generates
|
||||
* a summary of the branch being left so context isn't lost.
|
||||
*/
|
||||
|
||||
import type { ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
|
||||
import { completeSimple } from "@earendil-works/pi-ai";
|
||||
import type { AgentMessage } from "../../types.js";
|
||||
import { completeSimple, type Model } from "@earendil-works/pi-ai/base";
|
||||
import type { AgentMessage } from "../../types.ts";
|
||||
import {
|
||||
convertToLlm,
|
||||
createBranchSummaryMessage,
|
||||
createCompactionSummaryMessage,
|
||||
createCustomMessage,
|
||||
} from "../messages.js";
|
||||
import type { Session, SessionTreeEntry } from "../types.js";
|
||||
import { estimateTokens } from "./compaction.js";
|
||||
} from "../messages.ts";
|
||||
import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types.ts";
|
||||
import { BranchSummaryError, err, ok, type Result, SessionError } from "../types.ts";
|
||||
import { estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.ts";
|
||||
import {
|
||||
computeFileLists,
|
||||
createFileOps,
|
||||
extractFileOpsFromMessage,
|
||||
type FileOperations,
|
||||
formatFileOperations,
|
||||
SUMMARIZATION_SYSTEM_PROMPT,
|
||||
serializeConversation,
|
||||
} from "./utils.js";
|
||||
} from "./utils.ts";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface BranchSummaryResult {
|
||||
summary?: string;
|
||||
readFiles?: string[];
|
||||
modifiedFiles?: string[];
|
||||
aborted?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Details stored in BranchSummaryEntry.details for file tracking */
|
||||
/** File-operation details stored on generated branch summary entries. */
|
||||
export interface BranchSummaryDetails {
|
||||
/** Files read while exploring the summarized branch. */
|
||||
readFiles: string[];
|
||||
/** Files modified while exploring the summarized branch. */
|
||||
modifiedFiles: string[];
|
||||
}
|
||||
|
||||
export type { FileOperations } from "./utils.js";
|
||||
export type { FileOperations } from "./utils.ts";
|
||||
|
||||
/** Prepared branch content for summarization. */
|
||||
export interface BranchPreparation {
|
||||
/** Messages extracted for summarization, in chronological order */
|
||||
/** Messages selected for the branch summary. */
|
||||
messages: AgentMessage[];
|
||||
/** File operations extracted from tool calls */
|
||||
/** File operations extracted from the branch. */
|
||||
fileOps: FileOperations;
|
||||
/** Total estimated tokens in messages */
|
||||
/** Estimated token count for selected messages. */
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
/** Entries selected for branch summarization. */
|
||||
export interface CollectEntriesResult {
|
||||
/** Entries to summarize, in chronological order */
|
||||
/** Entries to summarize in chronological order. */
|
||||
entries: SessionTreeEntry[];
|
||||
/** Common ancestor between old and new position, if any */
|
||||
/** Deepest common ancestor between the previous leaf and target entry. */
|
||||
commonAncestorId: string | null;
|
||||
}
|
||||
|
||||
/** Options for generating a branch summary. */
|
||||
export interface GenerateBranchSummaryOptions {
|
||||
/** Model to use for summarization */
|
||||
/** Model used for summarization. */
|
||||
model: Model<any>;
|
||||
/** API key for the model */
|
||||
/** API key forwarded to the provider. */
|
||||
apiKey: string;
|
||||
/** Request headers for the model */
|
||||
/** Optional request headers forwarded to the provider. */
|
||||
headers?: Record<string, string>;
|
||||
/** Abort signal for cancellation */
|
||||
/** Abort signal for the summarization request. */
|
||||
signal: AbortSignal;
|
||||
/** Optional custom instructions for summarization */
|
||||
/** Optional instructions appended to or replacing the default prompt. */
|
||||
customInstructions?: string;
|
||||
/** If true, customInstructions replaces the default prompt instead of being appended */
|
||||
/** Replace the default prompt with custom instructions instead of appending them. */
|
||||
replaceInstructions?: boolean;
|
||||
/** Tokens reserved for prompt + LLM response (default 16384) */
|
||||
/** Tokens reserved for prompt and model output. Defaults to 16384. */
|
||||
reserveTokens?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Entry Collection
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Collect entries that should be summarized when navigating from one position to another.
|
||||
*
|
||||
* Walks from oldLeafId back to the common ancestor with targetId, collecting entries
|
||||
* along the way. Does NOT stop at compaction boundaries - those are included and their
|
||||
* summaries become context.
|
||||
*
|
||||
* @param session - Session manager (read-only access)
|
||||
* @param oldLeafId - Current position (where we're navigating from)
|
||||
* @param targetId - Target position (where we're navigating to)
|
||||
* @returns Entries to summarize and the common ancestor
|
||||
*/
|
||||
/** Collect entries that should be summarized before navigating to a different session tree entry. */
|
||||
export async function collectEntriesForBranchSummary(
|
||||
session: Session,
|
||||
oldLeafId: string | null,
|
||||
targetId: string,
|
||||
): Promise<CollectEntriesResult> {
|
||||
// If no old position, nothing to summarize
|
||||
if (!oldLeafId) {
|
||||
return { entries: [], commonAncestorId: null };
|
||||
}
|
||||
|
||||
// Find common ancestor (deepest node that's on both paths)
|
||||
const oldPath = new Set((await session.getBranch(oldLeafId)).map((e) => e.id));
|
||||
const targetPath = await session.getBranch(targetId);
|
||||
|
||||
// targetPath is root-first, so iterate backwards to find deepest common ancestor
|
||||
let commonAncestorId: string | null = null;
|
||||
for (let i = targetPath.length - 1; i >= 0; i--) {
|
||||
if (oldPath.has(targetPath[i].id)) {
|
||||
@@ -117,85 +82,49 @@ export async function collectEntriesForBranchSummary(
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect entries from old leaf back to common ancestor
|
||||
const entries: SessionTreeEntry[] = [];
|
||||
let current: string | null = oldLeafId;
|
||||
|
||||
while (current && current !== commonAncestorId) {
|
||||
const entry = await session.getEntry(current);
|
||||
if (!entry) break;
|
||||
if (!entry) throw new SessionError("invalid_session", `Entry ${current} not found`);
|
||||
entries.push(entry as SessionTreeEntry);
|
||||
current = entry.parentId;
|
||||
}
|
||||
|
||||
// Reverse to get chronological order
|
||||
entries.reverse();
|
||||
|
||||
return { entries, commonAncestorId };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Entry to Message Conversion
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract AgentMessage from a session entry.
|
||||
* Similar to getMessageFromEntry in compaction.ts but also handles compaction entries.
|
||||
*/
|
||||
function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined {
|
||||
switch (entry.type) {
|
||||
case "message":
|
||||
// Skip tool results - context is in assistant's tool call
|
||||
if (entry.message.role === "toolResult") return undefined;
|
||||
return entry.message as AgentMessage;
|
||||
return entry.message;
|
||||
|
||||
case "custom_message":
|
||||
return createCustomMessage(
|
||||
entry.customType,
|
||||
entry.content as string | (TextContent | ImageContent)[],
|
||||
entry.display,
|
||||
entry.details,
|
||||
entry.timestamp,
|
||||
);
|
||||
return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
|
||||
|
||||
case "branch_summary":
|
||||
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
|
||||
|
||||
case "compaction":
|
||||
return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);
|
||||
|
||||
// These don't contribute to conversation content
|
||||
case "thinking_level_change":
|
||||
case "model_change":
|
||||
case "active_tools_change":
|
||||
case "custom":
|
||||
case "label":
|
||||
case "session_info":
|
||||
case "leaf":
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare entries for summarization with token budget.
|
||||
*
|
||||
* Walks entries from NEWEST to OLDEST, adding messages until we hit the token budget.
|
||||
* This ensures we keep the most recent context when the branch is too long.
|
||||
*
|
||||
* Also collects file operations from:
|
||||
* - Tool calls in assistant messages
|
||||
* - Existing branch_summary entries' details (for cumulative tracking)
|
||||
*
|
||||
* @param entries - Entries in chronological order
|
||||
* @param tokenBudget - Maximum tokens to include (0 = no limit)
|
||||
*/
|
||||
/** Prepare branch entries for summarization within an optional token budget. */
|
||||
export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: number = 0): BranchPreparation {
|
||||
const messages: AgentMessage[] = [];
|
||||
const fileOps = createFileOps();
|
||||
let totalTokens = 0;
|
||||
|
||||
// First pass: collect file ops from ALL entries (even if they don't fit in token budget)
|
||||
// This ensures we capture cumulative file tracking from nested branch summaries
|
||||
// Only extract from pi-generated summaries (fromHook !== true), not extension-generated ones
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "branch_summary" && !entry.fromHook && entry.details) {
|
||||
const details = entry.details as BranchSummaryDetails;
|
||||
@@ -203,35 +132,26 @@ export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: n
|
||||
for (const f of details.readFiles) fileOps.read.add(f);
|
||||
}
|
||||
if (Array.isArray(details.modifiedFiles)) {
|
||||
// Modified files go into both edited and written for proper deduplication
|
||||
for (const f of details.modifiedFiles) {
|
||||
fileOps.edited.add(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: walk from newest to oldest, adding messages until token budget
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
const message = getMessageFromEntry(entry);
|
||||
if (!message) continue;
|
||||
|
||||
// Extract file ops from assistant messages (tool calls)
|
||||
extractFileOpsFromMessage(message, fileOps);
|
||||
|
||||
const tokens = estimateTokens(message);
|
||||
|
||||
// Check budget before adding
|
||||
if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {
|
||||
// If this is a summary entry, try to fit it anyway as it's important context
|
||||
if (entry.type === "compaction" || entry.type === "branch_summary") {
|
||||
if (totalTokens < tokenBudget * 0.9) {
|
||||
messages.unshift(message);
|
||||
totalTokens += tokens;
|
||||
}
|
||||
}
|
||||
// Stop - we've hit the budget
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -242,10 +162,6 @@ export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: n
|
||||
return { messages, fileOps, totalTokens };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Summary Generation
|
||||
// ============================================================================
|
||||
|
||||
const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.
|
||||
Summary of that exploration:
|
||||
|
||||
@@ -280,34 +196,22 @@ Use this EXACT format:
|
||||
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
|
||||
|
||||
/**
|
||||
* Generate a summary of abandoned branch entries.
|
||||
*
|
||||
* @param entries - Session entries to summarize (chronological order)
|
||||
* @param options - Generation options
|
||||
*/
|
||||
/** Generate a summary for abandoned branch entries. */
|
||||
export async function generateBranchSummary(
|
||||
entries: SessionTreeEntry[],
|
||||
options: GenerateBranchSummaryOptions,
|
||||
): Promise<BranchSummaryResult> {
|
||||
): Promise<Result<BranchSummaryResult, BranchSummaryError>> {
|
||||
const { model, apiKey, headers, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options;
|
||||
|
||||
// Token budget = context window minus reserved space for prompt + response
|
||||
const contextWindow = model.contextWindow || 128000;
|
||||
const tokenBudget = contextWindow - reserveTokens;
|
||||
|
||||
const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return { summary: "No content to summarize" };
|
||||
return ok({ summary: "No content to summarize", readFiles: [], modifiedFiles: [] });
|
||||
}
|
||||
|
||||
// Transform to LLM-compatible messages, then serialize to text
|
||||
// Serialization prevents the model from treating it as a conversation to continue
|
||||
const llmMessages = convertToLlm(messages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
|
||||
// Build prompt
|
||||
let instructions: string;
|
||||
if (replaceInstructions && customInstructions) {
|
||||
instructions = customInstructions;
|
||||
@@ -325,37 +229,34 @@ export async function generateBranchSummary(
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
// Call LLM for summarization
|
||||
const response = await completeSimple(
|
||||
model,
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
{ apiKey, headers, signal, maxTokens: 2048 },
|
||||
);
|
||||
|
||||
// Check if aborted or errored
|
||||
if (response.stopReason === "aborted") {
|
||||
return { aborted: true };
|
||||
return err(new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted"));
|
||||
}
|
||||
if (response.stopReason === "error") {
|
||||
return { error: response.errorMessage || "Summarization failed" };
|
||||
return err(
|
||||
new BranchSummaryError(
|
||||
"summarization_failed",
|
||||
`Branch summary failed: ${response.errorMessage || "Unknown error"}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let summary = response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
|
||||
// Prepend preamble to provide context about the branch summary
|
||||
summary = BRANCH_SUMMARY_PREAMBLE + summary;
|
||||
|
||||
// Compute file lists and append to summary
|
||||
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
||||
summary += formatFileOperations(readFiles, modifiedFiles);
|
||||
|
||||
return {
|
||||
return ok({
|
||||
summary: summary || "No summary generated",
|
||||
readFiles,
|
||||
modifiedFiles,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,56 +1,53 @@
|
||||
/**
|
||||
* Context compaction for long sessions.
|
||||
*
|
||||
* Pure functions for compaction logic. The session manager handles I/O,
|
||||
* and after compaction the session is reloaded.
|
||||
*/
|
||||
|
||||
import type { AssistantMessage, ImageContent, Model, TextContent, Usage } from "@earendil-works/pi-ai";
|
||||
import { completeSimple } from "@earendil-works/pi-ai";
|
||||
import type { AgentMessage, ThinkingLevel } from "../../types.js";
|
||||
import {
|
||||
type AssistantMessage,
|
||||
completeSimple,
|
||||
type ImageContent,
|
||||
type Model,
|
||||
type TextContent,
|
||||
type Usage,
|
||||
} from "@earendil-works/pi-ai/base";
|
||||
import type { AgentMessage, ThinkingLevel } from "../../types.ts";
|
||||
import {
|
||||
convertToLlm,
|
||||
createBranchSummaryMessage,
|
||||
createCompactionSummaryMessage,
|
||||
createCustomMessage,
|
||||
} from "../messages.js";
|
||||
import { buildSessionContext } from "../session/session.js";
|
||||
import type { CompactionEntry, SessionTreeEntry } from "../types.js";
|
||||
} from "../messages.ts";
|
||||
import { buildSessionContext } from "../session/session.ts";
|
||||
import { type CompactionEntry, CompactionError, err, ok, type Result, type SessionTreeEntry } from "../types.ts";
|
||||
import {
|
||||
computeFileLists,
|
||||
createFileOps,
|
||||
extractFileOpsFromMessage,
|
||||
type FileOperations,
|
||||
formatFileOperations,
|
||||
SUMMARIZATION_SYSTEM_PROMPT,
|
||||
serializeConversation,
|
||||
} from "./utils.js";
|
||||
} from "./utils.ts";
|
||||
|
||||
// ============================================================================
|
||||
// File Operation Tracking
|
||||
// ============================================================================
|
||||
|
||||
/** Details stored in CompactionEntry.details for file tracking */
|
||||
/** File-operation details stored on generated compaction entries. */
|
||||
export interface CompactionDetails {
|
||||
/** Files read in the compacted history. */
|
||||
readFiles: string[];
|
||||
/** Files modified in the compacted history. */
|
||||
modifiedFiles: string[];
|
||||
}
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value) ?? "undefined";
|
||||
} catch {
|
||||
return "[unserializable]";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file operations from messages and previous compaction entries.
|
||||
*/
|
||||
function extractFileOperations(
|
||||
messages: AgentMessage[],
|
||||
entries: SessionTreeEntry[],
|
||||
prevCompactionIndex: number,
|
||||
): FileOperations {
|
||||
const fileOps = createFileOps();
|
||||
|
||||
// Collect from previous compaction's details (if pi-generated)
|
||||
if (prevCompactionIndex >= 0) {
|
||||
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
|
||||
if (!prevCompaction.fromHook && prevCompaction.details) {
|
||||
// fromHook field kept for session file compatibility
|
||||
const details = prevCompaction.details as CompactionDetails;
|
||||
if (Array.isArray(details.readFiles)) {
|
||||
for (const f of details.readFiles) fileOps.read.add(f);
|
||||
@@ -60,23 +57,12 @@ function extractFileOperations(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract from tool calls in messages
|
||||
for (const msg of messages) {
|
||||
extractFileOpsFromMessage(msg, fileOps);
|
||||
}
|
||||
|
||||
return fileOps;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message Extraction
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract AgentMessage from an entry if it produces one.
|
||||
* Returns undefined for entries that don't contribute to LLM context.
|
||||
*/
|
||||
function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined {
|
||||
if (entry.type === "message") {
|
||||
return entry.message as AgentMessage;
|
||||
@@ -106,47 +92,39 @@ function getMessageFromEntryForCompaction(entry: SessionTreeEntry): AgentMessage
|
||||
return getMessageFromEntry(entry);
|
||||
}
|
||||
|
||||
/** Result from compact() - SessionManager adds uuid/parentUuid when saving */
|
||||
/** Generated compaction data ready to be persisted as a compaction entry. */
|
||||
export interface CompactionResult<T = unknown> {
|
||||
/** Summary text that replaces compacted history in future context. */
|
||||
summary: string;
|
||||
/** Entry id where retained history starts. */
|
||||
firstKeptEntryId: string;
|
||||
/** Estimated context tokens before compaction. */
|
||||
tokensBefore: number;
|
||||
/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
|
||||
/** Optional implementation-specific details stored with the compaction entry. */
|
||||
details?: T;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/** Compaction thresholds and retention settings. */
|
||||
export interface CompactionSettings {
|
||||
/** Enable automatic compaction decisions. */
|
||||
enabled: boolean;
|
||||
/** Tokens reserved for summary prompt and output. */
|
||||
reserveTokens: number;
|
||||
/** Approximate recent-context tokens to keep after compaction. */
|
||||
keepRecentTokens: number;
|
||||
}
|
||||
|
||||
/** Default compaction settings used by the harness. */
|
||||
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
|
||||
enabled: true,
|
||||
reserveTokens: 16384,
|
||||
keepRecentTokens: 20000,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Token calculation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Calculate total context tokens from usage.
|
||||
* Uses the native totalTokens field when available, falls back to computing from components.
|
||||
*/
|
||||
/** Calculate total context tokens from provider usage. */
|
||||
export function calculateContextTokens(usage: Usage): number {
|
||||
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage from an assistant message if available.
|
||||
* Skips aborted and error messages as they don't have valid usage data.
|
||||
*/
|
||||
function getAssistantUsage(msg: AgentMessage): Usage | undefined {
|
||||
if (msg.role === "assistant" && "usage" in msg) {
|
||||
const assistantMsg = msg as AssistantMessage;
|
||||
@@ -157,9 +135,7 @@ function getAssistantUsage(msg: AgentMessage): Usage | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the last non-aborted assistant message usage from session entries.
|
||||
*/
|
||||
/** Return usage from the last successful assistant message in session entries. */
|
||||
export function getLastAssistantUsage(entries: SessionTreeEntry[]): Usage | undefined {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
@@ -171,10 +147,15 @@ export function getLastAssistantUsage(entries: SessionTreeEntry[]): Usage | unde
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Estimated context-token usage for a message list. */
|
||||
export interface ContextUsageEstimate {
|
||||
/** Estimated total context tokens. */
|
||||
tokens: number;
|
||||
/** Tokens reported by the most recent assistant usage block. */
|
||||
usageTokens: number;
|
||||
/** Estimated tokens after the most recent assistant usage block. */
|
||||
trailingTokens: number;
|
||||
/** Index of the message that provided usage, or null when none exists. */
|
||||
lastUsageIndex: number | null;
|
||||
}
|
||||
|
||||
@@ -186,10 +167,7 @@ function getLastAssistantUsageInfo(messages: AgentMessage[]): { usage: Usage; in
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate context tokens from messages, using the last assistant usage when available.
|
||||
* If there are messages after the last usage, estimate their tokens with estimateTokens.
|
||||
*/
|
||||
/** Estimate context tokens for messages using provider usage when available. */
|
||||
export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate {
|
||||
const usageInfo = getLastAssistantUsageInfo(messages);
|
||||
|
||||
@@ -220,37 +198,39 @@ export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEst
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if compaction should trigger based on context usage.
|
||||
*/
|
||||
/** Return whether context usage exceeds the configured compaction threshold. */
|
||||
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
|
||||
if (!settings.enabled) return false;
|
||||
return contextTokens > contextWindow - settings.reserveTokens;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cut point detection
|
||||
// ============================================================================
|
||||
const ESTIMATED_IMAGE_CHARS = 4800;
|
||||
|
||||
/**
|
||||
* Estimate token count for a message using chars/4 heuristic.
|
||||
* This is conservative (overestimates tokens).
|
||||
*/
|
||||
function estimateTextAndImageContentChars(content: string | Array<{ type: string; text?: string }>): number {
|
||||
if (typeof content === "string") {
|
||||
return content.length;
|
||||
}
|
||||
|
||||
let chars = 0;
|
||||
for (const block of content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
chars += block.text.length;
|
||||
} else if (block.type === "image") {
|
||||
chars += ESTIMATED_IMAGE_CHARS;
|
||||
}
|
||||
}
|
||||
return chars;
|
||||
}
|
||||
|
||||
/** Estimate token count for one message using a conservative character heuristic. */
|
||||
export function estimateTokens(message: AgentMessage): number {
|
||||
let chars = 0;
|
||||
|
||||
switch (message.role) {
|
||||
case "user": {
|
||||
const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
|
||||
if (typeof content === "string") {
|
||||
chars = content.length;
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
chars += block.text.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
chars = estimateTextAndImageContentChars(
|
||||
(message as { content: string | Array<{ type: string; text?: string }> }).content,
|
||||
);
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "assistant": {
|
||||
@@ -261,25 +241,14 @@ export function estimateTokens(message: AgentMessage): number {
|
||||
} else if (block.type === "thinking") {
|
||||
chars += block.thinking.length;
|
||||
} else if (block.type === "toolCall") {
|
||||
chars += block.name.length + JSON.stringify(block.arguments).length;
|
||||
chars += block.name.length + safeJsonStringify(block.arguments).length;
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "custom":
|
||||
case "toolResult": {
|
||||
if (typeof message.content === "string") {
|
||||
chars = message.content.length;
|
||||
} else {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
chars += block.text.length;
|
||||
}
|
||||
if (block.type === "image") {
|
||||
chars += 4800; // Estimate images as 4000 chars, or 1200 tokens
|
||||
}
|
||||
}
|
||||
}
|
||||
chars = estimateTextAndImageContentChars(message.content);
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "bashExecution": {
|
||||
@@ -295,14 +264,6 @@ export function estimateTokens(message: AgentMessage): number {
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find valid cut points: indices of user, assistant, custom, or bashExecution messages.
|
||||
* Never cut at tool results (they must follow their tool call).
|
||||
* When we cut at an assistant message with tool calls, its tool results follow it
|
||||
* and will be kept.
|
||||
* BashExecutionMessage is treated like a user message (user-initiated context).
|
||||
*/
|
||||
function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, endIndex: number): number[] {
|
||||
const cutPoints: number[] = [];
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
@@ -326,16 +287,16 @@ function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, end
|
||||
}
|
||||
case "thinking_level_change":
|
||||
case "model_change":
|
||||
case "active_tools_change":
|
||||
case "compaction":
|
||||
case "branch_summary":
|
||||
case "custom":
|
||||
case "custom_message":
|
||||
case "label":
|
||||
case "session_info":
|
||||
case "leaf":
|
||||
break;
|
||||
}
|
||||
|
||||
// branch_summary and custom_message are user-role messages, valid cut points
|
||||
if (entry.type === "branch_summary" || entry.type === "custom_message") {
|
||||
cutPoints.push(i);
|
||||
}
|
||||
@@ -343,15 +304,10 @@ function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, end
|
||||
return cutPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the user message (or bashExecution) that starts the turn containing the given entry index.
|
||||
* Returns -1 if no turn start found before the index.
|
||||
* BashExecutionMessage is treated like a user message for turn boundaries.
|
||||
*/
|
||||
/** Find the user-visible message that starts the turn containing an entry. */
|
||||
export function findTurnStartIndex(entries: SessionTreeEntry[], entryIndex: number, startIndex: number): number {
|
||||
for (let i = entryIndex; i >= startIndex; i--) {
|
||||
const entry = entries[i];
|
||||
// branch_summary and custom_message are user-role messages, can start a turn
|
||||
if (entry.type === "branch_summary" || entry.type === "custom_message") {
|
||||
return i;
|
||||
}
|
||||
@@ -365,31 +321,17 @@ export function findTurnStartIndex(entries: SessionTreeEntry[], entryIndex: numb
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Cut point selected for compaction. */
|
||||
export interface CutPointResult {
|
||||
/** Index of first entry to keep */
|
||||
/** Index of the first entry retained after compaction. */
|
||||
firstKeptEntryIndex: number;
|
||||
/** Index of user message that starts the turn being split, or -1 if not splitting */
|
||||
/** Index of the turn-start entry when the cut splits a turn, otherwise -1. */
|
||||
turnStartIndex: number;
|
||||
/** Whether this cut splits a turn (cut point is not a user message) */
|
||||
/** Whether the selected cut point splits an in-progress turn. */
|
||||
isSplitTurn: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the cut point in session entries that keeps approximately `keepRecentTokens`.
|
||||
*
|
||||
* Algorithm: Walk backwards from newest, accumulating estimated message sizes.
|
||||
* Stop when we've accumulated >= keepRecentTokens. Cut at that point.
|
||||
*
|
||||
* Can cut at user OR assistant messages (never tool results). When cutting at an
|
||||
* assistant message with tool calls, its tool results come after and will be kept.
|
||||
*
|
||||
* Returns CutPointResult with:
|
||||
* - firstKeptEntryIndex: the entry index to start keeping from
|
||||
* - turnStartIndex: if cutting mid-turn, the user message that started that turn
|
||||
* - isSplitTurn: whether we're cutting in the middle of a turn
|
||||
*
|
||||
* Only considers entries between `startIndex` and `endIndex` (exclusive).
|
||||
*/
|
||||
/** Find the compaction cut point that keeps approximately the requested recent-token budget. */
|
||||
export function findCutPoint(
|
||||
entries: SessionTreeEntry[],
|
||||
startIndex: number,
|
||||
@@ -401,22 +343,15 @@ export function findCutPoint(
|
||||
if (cutPoints.length === 0) {
|
||||
return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
|
||||
}
|
||||
|
||||
// Walk backwards from newest, accumulating estimated message sizes
|
||||
let accumulatedTokens = 0;
|
||||
let cutIndex = cutPoints[0]; // Default: keep from first message (not header)
|
||||
let cutIndex = cutPoints[0];
|
||||
|
||||
for (let i = endIndex - 1; i >= startIndex; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type !== "message") continue;
|
||||
|
||||
// Estimate this message's size
|
||||
const messageTokens = estimateTokens(entry.message as AgentMessage);
|
||||
accumulatedTokens += messageTokens;
|
||||
|
||||
// Check if we've exceeded the budget
|
||||
if (accumulatedTokens >= keepRecentTokens) {
|
||||
// Find the closest valid cut point at or after this entry
|
||||
for (let c = 0; c < cutPoints.length; c++) {
|
||||
if (cutPoints[c] >= i) {
|
||||
cutIndex = cutPoints[c];
|
||||
@@ -426,23 +361,16 @@ export function findCutPoint(
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Scan backwards from cutIndex to include any non-message entries (bash, settings, etc.)
|
||||
while (cutIndex > startIndex) {
|
||||
const prevEntry = entries[cutIndex - 1];
|
||||
// Stop at session header or compaction boundaries
|
||||
if (prevEntry.type === "compaction") {
|
||||
break;
|
||||
}
|
||||
if (prevEntry.type === "message") {
|
||||
// Stop if we hit any message
|
||||
break;
|
||||
}
|
||||
// Include this non-message entry (bash, settings change, etc.)
|
||||
cutIndex--;
|
||||
}
|
||||
|
||||
// Determine if this is a split turn
|
||||
const cutEntry = entries[cutIndex];
|
||||
const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
|
||||
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
|
||||
@@ -454,9 +382,9 @@ export function findCutPoint(
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Summarization
|
||||
// ============================================================================
|
||||
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.
|
||||
|
||||
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;
|
||||
|
||||
const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
|
||||
|
||||
@@ -530,10 +458,7 @@ Use this EXACT format:
|
||||
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
|
||||
|
||||
/**
|
||||
* Generate a summary of the conversation using the LLM.
|
||||
* If previousSummary is provided, uses the update prompt to merge.
|
||||
*/
|
||||
/** Generate or update a conversation summary for compaction. */
|
||||
export async function generateSummary(
|
||||
currentMessages: AgentMessage[],
|
||||
model: Model<any>,
|
||||
@@ -544,21 +469,17 @@ export async function generateSummary(
|
||||
customInstructions?: string,
|
||||
previousSummary?: string,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
): Promise<string> {
|
||||
const maxTokens = Math.floor(0.8 * reserveTokens);
|
||||
|
||||
// Use update prompt if we have a previous summary, otherwise initial prompt
|
||||
): Promise<Result<string, CompactionError>> {
|
||||
const maxTokens = Math.min(
|
||||
Math.floor(0.8 * reserveTokens),
|
||||
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
|
||||
);
|
||||
let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
|
||||
if (customInstructions) {
|
||||
basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
|
||||
}
|
||||
|
||||
// Serialize conversation to text so model doesn't try to continue it
|
||||
// Convert to LLM messages first (handles custom types like bashExecution, custom, etc.)
|
||||
const llmMessages = convertToLlm(currentMessages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
|
||||
// Build the prompt with conversation wrapped in tags
|
||||
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
|
||||
if (previousSummary) {
|
||||
promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
|
||||
@@ -583,9 +504,16 @@ export async function generateSummary(
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
completionOptions,
|
||||
);
|
||||
|
||||
if (response.stopReason === "aborted") {
|
||||
return err(new CompactionError("aborted", response.errorMessage || "Summarization aborted"));
|
||||
}
|
||||
if (response.stopReason === "error") {
|
||||
throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`);
|
||||
return err(
|
||||
new CompactionError(
|
||||
"summarization_failed",
|
||||
`Summarization failed: ${response.errorMessage || "Unknown error"}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const textContent = response.content
|
||||
@@ -593,37 +521,36 @@ export async function generateSummary(
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
|
||||
return textContent;
|
||||
return ok(textContent);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Compaction Preparation (for extensions)
|
||||
// ============================================================================
|
||||
|
||||
/** Prepared inputs for a compaction run. */
|
||||
export interface CompactionPreparation {
|
||||
/** UUID of first entry to keep */
|
||||
/** Entry id where retained history starts. */
|
||||
firstKeptEntryId: string;
|
||||
/** Messages that will be summarized and discarded */
|
||||
/** Messages summarized into the history summary. */
|
||||
messagesToSummarize: AgentMessage[];
|
||||
/** Messages that will be turned into turn prefix summary (if splitting) */
|
||||
/** Prefix messages summarized separately when compaction splits a turn. */
|
||||
turnPrefixMessages: AgentMessage[];
|
||||
/** Whether this is a split turn (cut point in middle of turn) */
|
||||
/** Whether compaction splits a turn. */
|
||||
isSplitTurn: boolean;
|
||||
/** Estimated context tokens before compaction. */
|
||||
tokensBefore: number;
|
||||
/** Summary from previous compaction, for iterative update */
|
||||
/** Previous compaction summary used for iterative updates. */
|
||||
previousSummary?: string;
|
||||
/** File operations extracted from messagesToSummarize */
|
||||
/** File operations extracted from summarized history. */
|
||||
fileOps: FileOperations;
|
||||
/** Compaction settions from settings.jsonl */
|
||||
/** Settings used to prepare compaction. */
|
||||
settings: CompactionSettings;
|
||||
}
|
||||
|
||||
/** Prepare session entries for compaction, or return undefined when compaction is not applicable. */
|
||||
export function prepareCompaction(
|
||||
pathEntries: SessionTreeEntry[],
|
||||
settings: CompactionSettings,
|
||||
): CompactionPreparation | undefined {
|
||||
if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
|
||||
return undefined;
|
||||
): Result<CompactionPreparation | undefined, CompactionError> {
|
||||
if (pathEntries.length === 0 || pathEntries[pathEntries.length - 1].type === "compaction") {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
let prevCompactionIndex = -1;
|
||||
@@ -647,24 +574,18 @@ export function prepareCompaction(
|
||||
const tokensBefore = estimateContextTokens(buildSessionContext(pathEntries).messages).tokens;
|
||||
|
||||
const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
|
||||
|
||||
// Get UUID of first kept entry
|
||||
const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex];
|
||||
if (!firstKeptEntry?.id) {
|
||||
return undefined; // Session needs migration
|
||||
return err(new CompactionError("invalid_session", "First kept entry has no UUID - session may need migration"));
|
||||
}
|
||||
const firstKeptEntryId = firstKeptEntry.id;
|
||||
|
||||
const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
|
||||
|
||||
// Messages to summarize (will be discarded after summary)
|
||||
const messagesToSummarize: AgentMessage[] = [];
|
||||
for (let i = boundaryStart; i < historyEnd; i++) {
|
||||
const msg = getMessageFromEntryForCompaction(pathEntries[i]);
|
||||
if (msg) messagesToSummarize.push(msg);
|
||||
}
|
||||
|
||||
// Messages for turn prefix summary (if splitting a turn)
|
||||
const turnPrefixMessages: AgentMessage[] = [];
|
||||
if (cutPoint.isSplitTurn) {
|
||||
for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) {
|
||||
@@ -672,18 +593,14 @@ export function prepareCompaction(
|
||||
if (msg) turnPrefixMessages.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract file operations from messages and previous compaction
|
||||
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
|
||||
|
||||
// Also extract file ops from turn prefix if splitting
|
||||
if (cutPoint.isSplitTurn) {
|
||||
for (const msg of turnPrefixMessages) {
|
||||
extractFileOpsFromMessage(msg, fileOps);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
return ok({
|
||||
firstKeptEntryId,
|
||||
messagesToSummarize,
|
||||
turnPrefixMessages,
|
||||
@@ -692,13 +609,9 @@ export function prepareCompaction(
|
||||
previousSummary,
|
||||
fileOps,
|
||||
settings,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main compaction function
|
||||
// ============================================================================
|
||||
|
||||
const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
|
||||
|
||||
Summarize the prefix to provide context for the retained suffix:
|
||||
@@ -714,15 +627,9 @@ Summarize the prefix to provide context for the retained suffix:
|
||||
|
||||
Be concise. Focus on what's needed to understand the kept suffix.`;
|
||||
|
||||
/**
|
||||
* Generate summaries for compaction using prepared data.
|
||||
* Returns CompactionResult - SessionManager adds uuid/parentUuid when saving.
|
||||
*
|
||||
* @param preparation - Pre-calculated preparation from prepareCompaction()
|
||||
* @param customInstructions - Optional custom focus for the summary
|
||||
*/
|
||||
export { serializeConversation } from "./utils.js";
|
||||
export { serializeConversation } from "./utils.ts";
|
||||
|
||||
/** Generate compaction summary data from prepared session history. */
|
||||
export async function compact(
|
||||
preparation: CompactionPreparation,
|
||||
model: Model<any>,
|
||||
@@ -731,7 +638,7 @@ export async function compact(
|
||||
customInstructions?: string,
|
||||
signal?: AbortSignal,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
): Promise<CompactionResult> {
|
||||
): Promise<Result<CompactionResult, CompactionError>> {
|
||||
const {
|
||||
firstKeptEntryId,
|
||||
messagesToSummarize,
|
||||
@@ -743,11 +650,13 @@ export async function compact(
|
||||
settings,
|
||||
} = preparation;
|
||||
|
||||
// Generate summaries (can be parallel if both needed) and merge into one
|
||||
if (!firstKeptEntryId) {
|
||||
return err(new CompactionError("invalid_session", "First kept entry has no UUID - session may need migration"));
|
||||
}
|
||||
|
||||
let summary: string;
|
||||
|
||||
if (isSplitTurn && turnPrefixMessages.length > 0) {
|
||||
// Generate both summaries in parallel
|
||||
const [historyResult, turnPrefixResult] = await Promise.all([
|
||||
messagesToSummarize.length > 0
|
||||
? generateSummary(
|
||||
@@ -761,7 +670,7 @@ export async function compact(
|
||||
previousSummary,
|
||||
thinkingLevel,
|
||||
)
|
||||
: Promise.resolve("No prior history."),
|
||||
: Promise.resolve(ok<string, CompactionError>("No prior history.")),
|
||||
generateTurnPrefixSummary(
|
||||
turnPrefixMessages,
|
||||
model,
|
||||
@@ -772,11 +681,11 @@ export async function compact(
|
||||
thinkingLevel,
|
||||
),
|
||||
]);
|
||||
// Merge into single summary
|
||||
summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`;
|
||||
if (!historyResult.ok) return err(historyResult.error);
|
||||
if (!turnPrefixResult.ok) return err(turnPrefixResult.error);
|
||||
summary = `${historyResult.value}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value}`;
|
||||
} else {
|
||||
// Just generate history summary
|
||||
summary = await generateSummary(
|
||||
const summaryResult = await generateSummary(
|
||||
messagesToSummarize,
|
||||
model,
|
||||
settings.reserveTokens,
|
||||
@@ -787,27 +696,20 @@ export async function compact(
|
||||
previousSummary,
|
||||
thinkingLevel,
|
||||
);
|
||||
if (!summaryResult.ok) return err(summaryResult.error);
|
||||
summary = summaryResult.value;
|
||||
}
|
||||
|
||||
// Compute file lists and append to summary
|
||||
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
||||
summary += formatFileOperations(readFiles, modifiedFiles);
|
||||
|
||||
if (!firstKeptEntryId) {
|
||||
throw new Error("First kept entry has no UUID - session may need migration");
|
||||
}
|
||||
|
||||
return {
|
||||
return ok({
|
||||
summary,
|
||||
firstKeptEntryId,
|
||||
tokensBefore,
|
||||
details: { readFiles, modifiedFiles } as CompactionDetails,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a summary for a turn prefix (when splitting a turn).
|
||||
*/
|
||||
async function generateTurnPrefixSummary(
|
||||
messages: AgentMessage[],
|
||||
model: Model<any>,
|
||||
@@ -816,8 +718,11 @@ async function generateTurnPrefixSummary(
|
||||
headers?: Record<string, string>,
|
||||
signal?: AbortSignal,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
): Promise<string> {
|
||||
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
|
||||
): Promise<Result<string, CompactionError>> {
|
||||
const maxTokens = Math.min(
|
||||
Math.floor(0.5 * reserveTokens),
|
||||
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
|
||||
);
|
||||
const llmMessages = convertToLlm(messages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
|
||||
@@ -836,13 +741,22 @@ async function generateTurnPrefixSummary(
|
||||
? { maxTokens, signal, apiKey, headers, reasoning: thinkingLevel }
|
||||
: { maxTokens, signal, apiKey, headers },
|
||||
);
|
||||
|
||||
if (response.stopReason === "aborted") {
|
||||
return err(new CompactionError("aborted", response.errorMessage || "Turn prefix summarization aborted"));
|
||||
}
|
||||
if (response.stopReason === "error") {
|
||||
throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`);
|
||||
return err(
|
||||
new CompactionError(
|
||||
"summarization_failed",
|
||||
`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
return ok(
|
||||
response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
/**
|
||||
* Shared utilities for compaction and branch summarization.
|
||||
*/
|
||||
|
||||
import type { Message } from "@earendil-works/pi-ai";
|
||||
import type { AgentMessage } from "../../types.js";
|
||||
|
||||
// ============================================================================
|
||||
// File Operation Tracking
|
||||
// ============================================================================
|
||||
import type { Message } from "@earendil-works/pi-ai/base";
|
||||
import type { AgentMessage } from "../../types.ts";
|
||||
|
||||
/** File paths touched by a session branch or compaction range. */
|
||||
export interface FileOperations {
|
||||
/** Files read but not necessarily modified. */
|
||||
read: Set<string>;
|
||||
/** Files written by full-file write operations. */
|
||||
written: Set<string>;
|
||||
/** Files modified by edit operations. */
|
||||
edited: Set<string>;
|
||||
}
|
||||
|
||||
/** Create an empty file-operation accumulator. */
|
||||
export function createFileOps(): FileOperations {
|
||||
return {
|
||||
read: new Set(),
|
||||
@@ -23,9 +20,7 @@ export function createFileOps(): FileOperations {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file operations from tool calls in an assistant message.
|
||||
*/
|
||||
/** Add file operations from assistant tool calls to an accumulator. */
|
||||
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
|
||||
if (message.role !== "assistant") return;
|
||||
if (!("content" in message) || !Array.isArray(message.content)) return;
|
||||
@@ -55,10 +50,7 @@ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOp
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute final file lists from file operations.
|
||||
* Returns readFiles (files only read, not modified) and modifiedFiles.
|
||||
*/
|
||||
/** Compute sorted read-only and modified file lists from accumulated operations. */
|
||||
export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } {
|
||||
const modified = new Set([...fileOps.edited, ...fileOps.written]);
|
||||
const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort();
|
||||
@@ -66,9 +58,7 @@ export function computeFileLists(fileOps: FileOperations): { readFiles: string[]
|
||||
return { readFiles: readOnly, modifiedFiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file operations as XML tags for summary.
|
||||
*/
|
||||
/** Format file lists as summary metadata tags. */
|
||||
export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string {
|
||||
const sections: string[] = [];
|
||||
if (readFiles.length > 0) {
|
||||
@@ -81,31 +71,23 @@ export function formatFileOperations(readFiles: string[], modifiedFiles: string[
|
||||
return `\n\n${sections.join("\n\n")}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message Serialization
|
||||
// ============================================================================
|
||||
|
||||
/** Maximum characters for a tool result in serialized summaries. */
|
||||
const TOOL_RESULT_MAX_CHARS = 2000;
|
||||
|
||||
/**
|
||||
* Truncate text to a maximum character length for summarization.
|
||||
* Keeps the beginning and appends a truncation marker.
|
||||
*/
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value) ?? "undefined";
|
||||
} catch {
|
||||
return "[unserializable]";
|
||||
}
|
||||
}
|
||||
|
||||
function truncateForSummary(text: string, maxChars: number): string {
|
||||
if (text.length <= maxChars) return text;
|
||||
const truncatedChars = text.length - maxChars;
|
||||
return `${text.slice(0, maxChars)}\n\n[... ${truncatedChars} more characters truncated]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize LLM messages to text for summarization.
|
||||
* This prevents the model from treating it as a conversation to continue.
|
||||
* Call convertToLlm() first to handle custom message types.
|
||||
*
|
||||
* Tool results are truncated to keep the summarization request within
|
||||
* reasonable token budgets. Full content is not needed for summarization.
|
||||
*/
|
||||
/** Serialize LLM messages to plain text for summarization prompts. */
|
||||
export function serializeConversation(messages: Message[]): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
@@ -132,7 +114,7 @@ export function serializeConversation(messages: Message[]): string {
|
||||
} else if (block.type === "toolCall") {
|
||||
const args = block.arguments as Record<string, unknown>;
|
||||
const argsStr = Object.entries(args)
|
||||
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
||||
.map(([k, v]) => `${k}=${safeJsonStringify(v)}`)
|
||||
.join(", ");
|
||||
toolCalls.push(`${block.name}(${argsStr})`);
|
||||
}
|
||||
@@ -160,11 +142,3 @@ export function serializeConversation(messages: Message[]): string {
|
||||
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Summarization System Prompt
|
||||
// ============================================================================
|
||||
|
||||
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
|
||||
|
||||
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;
|
||||
|
||||
376
packages/agent/src/harness/env/nodejs.ts
vendored
376
packages/agent/src/harness/env/nodejs.ts
vendored
@@ -1,33 +1,61 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { access, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { constants, createReadStream } from "node:fs";
|
||||
import {
|
||||
access,
|
||||
appendFile,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
realpath,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join, resolve } from "node:path";
|
||||
import { type ExecutionEnv, FileError, type FileInfo, type FileKind } from "../types.js";
|
||||
import { createInterface } from "node:readline";
|
||||
import {
|
||||
type ExecutionEnv,
|
||||
ExecutionError,
|
||||
err,
|
||||
FileError,
|
||||
type FileInfo,
|
||||
type FileKind,
|
||||
ok,
|
||||
type Result,
|
||||
toError,
|
||||
} from "../types.ts";
|
||||
|
||||
function resolvePath(cwd: string, path: string): string {
|
||||
return isAbsolute(path) ? path : resolve(cwd, path);
|
||||
}
|
||||
|
||||
function fileKindFromStats(stats: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }): FileKind {
|
||||
function fileKindFromStats(stats: {
|
||||
isFile(): boolean;
|
||||
isDirectory(): boolean;
|
||||
isSymbolicLink(): boolean;
|
||||
}): FileKind | undefined {
|
||||
if (stats.isFile()) return "file";
|
||||
if (stats.isDirectory()) return "directory";
|
||||
if (stats.isSymbolicLink()) return "symlink";
|
||||
throw new FileError("invalid", "Unsupported file type");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function fileInfoFromStats(
|
||||
path: string,
|
||||
stats: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean; size: number; mtimeMs: number },
|
||||
): FileInfo {
|
||||
return {
|
||||
): Result<FileInfo, FileError> {
|
||||
const kind = fileKindFromStats(stats);
|
||||
if (!kind) return err(new FileError("invalid", "Unsupported file type", path));
|
||||
return ok({
|
||||
name: path.replace(/\/+$/, "").split("/").pop() ?? path,
|
||||
path,
|
||||
kind: fileKindFromStats(stats),
|
||||
kind,
|
||||
size: stats.size,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
||||
@@ -36,23 +64,30 @@ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
||||
|
||||
function toFileError(error: unknown, path?: string): FileError {
|
||||
if (error instanceof FileError) return error;
|
||||
const cause = toError(error);
|
||||
if (isNodeError(error)) {
|
||||
const message = error.message;
|
||||
switch (error.code) {
|
||||
case "ABORT_ERR":
|
||||
return new FileError("aborted", message, path, cause);
|
||||
case "ENOENT":
|
||||
return new FileError("not_found", message, path, { cause: error });
|
||||
return new FileError("not_found", message, path, cause);
|
||||
case "EACCES":
|
||||
case "EPERM":
|
||||
return new FileError("permission_denied", message, path, { cause: error });
|
||||
return new FileError("permission_denied", message, path, cause);
|
||||
case "ENOTDIR":
|
||||
return new FileError("not_directory", message, path, { cause: error });
|
||||
return new FileError("not_directory", message, path, cause);
|
||||
case "EISDIR":
|
||||
return new FileError("is_directory", message, path, { cause: error });
|
||||
return new FileError("is_directory", message, path, cause);
|
||||
case "EINVAL":
|
||||
return new FileError("invalid", message, path, { cause: error });
|
||||
return new FileError("invalid", message, path, cause);
|
||||
}
|
||||
}
|
||||
return new FileError("unknown", error instanceof Error ? error.message : String(error), path, { cause: error });
|
||||
return new FileError("unknown", cause.message, path, cause);
|
||||
}
|
||||
|
||||
function abortResult<TValue>(signal: AbortSignal | undefined, path?: string): Result<TValue, FileError> | undefined {
|
||||
return signal?.aborted ? err(new FileError("aborted", "aborted", path)) : undefined;
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
@@ -71,7 +106,16 @@ async function runCommand(
|
||||
): Promise<{ stdout: string; status: number | null }> {
|
||||
return await new Promise((resolve) => {
|
||||
let stdout = "";
|
||||
const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"] });
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
resolve({ stdout: "", status: null });
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
if (child.pid) killProcessTree(child.pid);
|
||||
}, timeoutMs);
|
||||
@@ -100,12 +144,27 @@ async function findBashOnPath(): Promise<string | null> {
|
||||
return firstMatch && (await pathExists(firstMatch)) ? firstMatch : null;
|
||||
}
|
||||
|
||||
async function getShellConfig(customShellPath?: string): Promise<{ shell: string; args: string[] }> {
|
||||
interface ShellConfig {
|
||||
shell: string;
|
||||
args: string[];
|
||||
commandTransport?: "argv" | "stdin";
|
||||
}
|
||||
|
||||
function isLegacyWslBashPath(path: string): boolean {
|
||||
const normalized = path.replace(/\//g, "\\").toLowerCase();
|
||||
return /^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(normalized);
|
||||
}
|
||||
|
||||
function getBashShellConfig(shell: string): ShellConfig {
|
||||
return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] };
|
||||
}
|
||||
|
||||
async function getShellConfig(customShellPath?: string): Promise<Result<ShellConfig, ExecutionError>> {
|
||||
if (customShellPath) {
|
||||
if (await pathExists(customShellPath)) {
|
||||
return { shell: customShellPath, args: ["-c"] };
|
||||
return ok(getBashShellConfig(customShellPath));
|
||||
}
|
||||
throw new Error(`Custom shell path not found: ${customShellPath}`);
|
||||
return err(new ExecutionError("shell_unavailable", `Custom shell path not found: ${customShellPath}`));
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
const candidates: string[] = [];
|
||||
@@ -115,24 +174,24 @@ async function getShellConfig(customShellPath?: string): Promise<{ shell: string
|
||||
if (programFilesX86) candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
|
||||
for (const candidate of candidates) {
|
||||
if (await pathExists(candidate)) {
|
||||
return { shell: candidate, args: ["-c"] };
|
||||
return ok(getBashShellConfig(candidate));
|
||||
}
|
||||
}
|
||||
const bashOnPath = await findBashOnPath();
|
||||
if (bashOnPath) {
|
||||
return { shell: bashOnPath, args: ["-c"] };
|
||||
return ok(getBashShellConfig(bashOnPath));
|
||||
}
|
||||
throw new Error("No bash shell found");
|
||||
return err(new ExecutionError("shell_unavailable", "No bash shell found"));
|
||||
}
|
||||
|
||||
if (await pathExists("/bin/bash")) {
|
||||
return { shell: "/bin/bash", args: ["-c"] };
|
||||
return ok(getBashShellConfig("/bin/bash"));
|
||||
}
|
||||
const bashOnPath = await findBashOnPath();
|
||||
if (bashOnPath) {
|
||||
return { shell: bashOnPath, args: ["-c"] };
|
||||
return ok(getBashShellConfig(bashOnPath));
|
||||
}
|
||||
return { shell: "sh", args: ["-c"] };
|
||||
return ok({ shell: "sh", args: ["-c"] });
|
||||
}
|
||||
|
||||
function getShellEnv(baseEnv?: NodeJS.ProcessEnv, extraEnv?: Record<string, string>): NodeJS.ProcessEnv {
|
||||
@@ -149,6 +208,7 @@ function killProcessTree(pid: number): void {
|
||||
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
// Ignore errors.
|
||||
@@ -178,52 +238,92 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
||||
this.shellEnv = options.shellEnv;
|
||||
}
|
||||
|
||||
async absolutePath(path: string): Promise<Result<string, FileError>> {
|
||||
return ok(resolvePath(this.cwd, path));
|
||||
}
|
||||
|
||||
async joinPath(parts: string[]): Promise<Result<string, FileError>> {
|
||||
return ok(join(...parts));
|
||||
}
|
||||
|
||||
async exec(
|
||||
command: string,
|
||||
options?: {
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
timeout?: number;
|
||||
signal?: AbortSignal;
|
||||
abortSignal?: AbortSignal;
|
||||
onStdout?: (chunk: string) => void;
|
||||
onStderr?: (chunk: string) => void;
|
||||
},
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd;
|
||||
const { shell, args } = await getShellConfig(this.shellPath);
|
||||
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
|
||||
if (options?.abortSignal?.aborted) return err(new ExecutionError("aborted", "aborted"));
|
||||
|
||||
return await new Promise((resolvePromise, reject) => {
|
||||
const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd;
|
||||
const shellConfig = await getShellConfig(this.shellPath);
|
||||
if (!shellConfig.ok) return shellConfig;
|
||||
|
||||
return await new Promise((resolvePromise) => {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
const child = spawn(shell, [...args, command], {
|
||||
cwd,
|
||||
detached: process.platform !== "win32",
|
||||
env: getShellEnv(this.shellEnv, options?.env),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let callbackError: ExecutionError | undefined;
|
||||
let child: ReturnType<typeof spawn> | undefined;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const timeoutId =
|
||||
const onAbort = () => {
|
||||
if (child?.pid) {
|
||||
killProcessTree(child.pid);
|
||||
}
|
||||
};
|
||||
|
||||
const settle = (result: Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (options?.abortSignal) options.abortSignal.removeEventListener("abort", onAbort);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolvePromise(result);
|
||||
};
|
||||
|
||||
try {
|
||||
const commandFromStdin = shellConfig.value.commandTransport === "stdin";
|
||||
child = spawn(
|
||||
shellConfig.value.shell,
|
||||
commandFromStdin ? shellConfig.value.args : [...shellConfig.value.args, command],
|
||||
{
|
||||
cwd,
|
||||
detached: process.platform !== "win32",
|
||||
env: getShellEnv(this.shellEnv, options?.env),
|
||||
stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
if (commandFromStdin) {
|
||||
child.stdin?.on("error", () => {});
|
||||
child.stdin?.end(command);
|
||||
}
|
||||
} catch (error) {
|
||||
const cause = toError(error);
|
||||
settle(err(new ExecutionError("spawn_error", cause.message, cause)));
|
||||
return;
|
||||
}
|
||||
|
||||
timeoutId =
|
||||
typeof options?.timeout === "number"
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
if (child.pid) {
|
||||
if (child?.pid) {
|
||||
killProcessTree(child.pid);
|
||||
}
|
||||
}, options.timeout * 1000)
|
||||
: undefined;
|
||||
|
||||
const onAbort = () => {
|
||||
if (child.pid) {
|
||||
killProcessTree(child.pid);
|
||||
}
|
||||
};
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) {
|
||||
if (options?.abortSignal) {
|
||||
if (options.abortSignal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
options.signal.addEventListener("abort", onAbort, { once: true });
|
||||
options.abortSignal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,137 +331,217 @@ export class NodeExecutionEnv implements ExecutionEnv {
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (chunk: string) => {
|
||||
stdout += chunk;
|
||||
options?.onStdout?.(chunk);
|
||||
try {
|
||||
options?.onStdout?.(chunk);
|
||||
} catch (error) {
|
||||
const cause = toError(error);
|
||||
callbackError = new ExecutionError("callback_error", cause.message, cause);
|
||||
onAbort();
|
||||
}
|
||||
});
|
||||
child.stderr?.on("data", (chunk: string) => {
|
||||
stderr += chunk;
|
||||
options?.onStderr?.(chunk);
|
||||
try {
|
||||
options?.onStderr?.(chunk);
|
||||
} catch (error) {
|
||||
const cause = toError(error);
|
||||
callbackError = new ExecutionError("callback_error", cause.message, cause);
|
||||
onAbort();
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (options?.signal) options.signal.removeEventListener("abort", onAbort);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
settle(err(new ExecutionError("spawn_error", error.message, error)));
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (options?.signal) options.signal.removeEventListener("abort", onAbort);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (options?.signal?.aborted) {
|
||||
reject(new Error("aborted"));
|
||||
if (callbackError) {
|
||||
settle(err(callbackError));
|
||||
return;
|
||||
}
|
||||
if (timedOut) {
|
||||
reject(new Error(`timeout:${options?.timeout}`));
|
||||
settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`)));
|
||||
return;
|
||||
}
|
||||
resolvePromise({ stdout, stderr, exitCode: code ?? 0 });
|
||||
if (options?.abortSignal?.aborted) {
|
||||
settle(err(new ExecutionError("aborted", "aborted")));
|
||||
return;
|
||||
}
|
||||
settle(ok({ stdout, stderr, exitCode: code ?? 0 }));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async readTextFile(path: string): Promise<string> {
|
||||
async readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const aborted = abortResult<string>(abortSignal, resolved);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
return await readFile(resolved, "utf8");
|
||||
return ok(await readFile(resolved, { encoding: "utf8", signal: abortSignal }));
|
||||
} catch (error) {
|
||||
throw toFileError(error, resolved);
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async readBinaryFile(path: string): Promise<Uint8Array> {
|
||||
async readTextLines(
|
||||
path: string,
|
||||
options?: { maxLines?: number; abortSignal?: AbortSignal },
|
||||
): Promise<Result<string[], FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const aborted = abortResult<string[]>(options?.abortSignal, resolved);
|
||||
if (aborted) return aborted;
|
||||
if (options?.maxLines !== undefined && options.maxLines <= 0) return ok([]);
|
||||
let stream: ReturnType<typeof createReadStream> | undefined;
|
||||
let lineReader: ReturnType<typeof createInterface> | undefined;
|
||||
try {
|
||||
return await readFile(resolved);
|
||||
stream = createReadStream(resolved, { encoding: "utf8", signal: options?.abortSignal });
|
||||
lineReader = createInterface({ input: stream, crlfDelay: Infinity });
|
||||
const lines: string[] = [];
|
||||
for await (const line of lineReader) {
|
||||
const loopAbort = abortResult<string[]>(options?.abortSignal, resolved);
|
||||
if (loopAbort) return loopAbort;
|
||||
lines.push(line);
|
||||
if (options?.maxLines !== undefined && lines.length >= options.maxLines) break;
|
||||
}
|
||||
const afterReadAbort = abortResult<string[]>(options?.abortSignal, resolved);
|
||||
if (afterReadAbort) return afterReadAbort;
|
||||
return ok(lines);
|
||||
} catch (error) {
|
||||
throw toFileError(error, resolved);
|
||||
return err(toFileError(error, resolved));
|
||||
} finally {
|
||||
lineReader?.close();
|
||||
stream?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: string | Uint8Array): Promise<void> {
|
||||
async readBinaryFile(path: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const aborted = abortResult<Uint8Array>(abortSignal, resolved);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
return ok(await readFile(resolved, { signal: abortSignal }));
|
||||
} catch (error) {
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async writeFile(
|
||||
path: string,
|
||||
content: string | Uint8Array,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<Result<void, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const aborted = abortResult<void>(abortSignal, resolved);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
await mkdir(resolve(resolved, ".."), { recursive: true });
|
||||
const afterMkdirAbort = abortResult<void>(abortSignal, resolved);
|
||||
if (afterMkdirAbort) return afterMkdirAbort;
|
||||
await writeFile(resolved, content, { signal: abortSignal });
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async appendFile(path: string, content: string | Uint8Array): Promise<Result<void, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
try {
|
||||
await mkdir(resolve(resolved, ".."), { recursive: true });
|
||||
await writeFile(resolved, content);
|
||||
await appendFile(resolved, content);
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
throw toFileError(error, resolved);
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async fileInfo(path: string): Promise<FileInfo> {
|
||||
async fileInfo(path: string): Promise<Result<FileInfo, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
try {
|
||||
return fileInfoFromStats(resolved, await lstat(resolved));
|
||||
} catch (error) {
|
||||
throw toFileError(error, resolved);
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async listDir(path: string): Promise<FileInfo[]> {
|
||||
async listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const aborted = abortResult<FileInfo[]>(abortSignal, resolved);
|
||||
if (aborted) return aborted;
|
||||
try {
|
||||
const entries = await readdir(resolved, { withFileTypes: true });
|
||||
const infos: FileInfo[] = [];
|
||||
for (const entry of entries) {
|
||||
const loopAbort = abortResult<FileInfo[]>(abortSignal, resolved);
|
||||
if (loopAbort) return loopAbort;
|
||||
const entryPath = resolve(resolved, entry.name);
|
||||
try {
|
||||
infos.push(fileInfoFromStats(entryPath, await lstat(entryPath)));
|
||||
const info = fileInfoFromStats(entryPath, await lstat(entryPath));
|
||||
if (info.ok) infos.push(info.value);
|
||||
} catch (error) {
|
||||
if (error instanceof FileError && error.code === "invalid") continue;
|
||||
throw error;
|
||||
return err(toFileError(error, entryPath));
|
||||
}
|
||||
}
|
||||
return infos;
|
||||
return ok(infos);
|
||||
} catch (error) {
|
||||
throw toFileError(error, resolved);
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async realPath(path: string): Promise<string> {
|
||||
async canonicalPath(path: string): Promise<Result<string, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
try {
|
||||
return await realpath(resolved);
|
||||
return ok(await realpath(resolved));
|
||||
} catch (error) {
|
||||
throw toFileError(error, resolved);
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
async exists(path: string): Promise<Result<boolean, FileError>> {
|
||||
const result = await this.fileInfo(path);
|
||||
if (result.ok) return ok(true);
|
||||
if (result.error.code === "not_found") return ok(false);
|
||||
return err(result.error);
|
||||
}
|
||||
|
||||
async createDir(path: string, options?: { recursive?: boolean }): Promise<Result<void, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
try {
|
||||
await this.fileInfo(path);
|
||||
return true;
|
||||
await mkdir(resolved, { recursive: options?.recursive ?? true });
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
if (error instanceof FileError && error.code === "not_found") return false;
|
||||
throw error;
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async createDir(path: string, options?: { recursive?: boolean }): Promise<void> {
|
||||
await mkdir(resolvePath(this.cwd, path), { recursive: options?.recursive });
|
||||
}
|
||||
|
||||
async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void> {
|
||||
async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<Result<void, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
try {
|
||||
await rm(resolved, { recursive: options?.recursive ?? false, force: options?.force ?? false });
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
throw toFileError(error, resolved);
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async createTempDir(prefix: string = "tmp-"): Promise<string> {
|
||||
return await mkdtemp(join(tmpdir(), prefix));
|
||||
async createTempDir(prefix: string = "tmp-"): Promise<Result<string, FileError>> {
|
||||
try {
|
||||
return ok(await mkdtemp(join(tmpdir(), prefix)));
|
||||
} catch (error) {
|
||||
return err(toFileError(error));
|
||||
}
|
||||
}
|
||||
|
||||
async createTempFile(options?: { prefix?: string; suffix?: string }): Promise<string> {
|
||||
async createTempFile(options?: { prefix?: string; suffix?: string }): Promise<Result<string, FileError>> {
|
||||
const dir = await this.createTempDir("tmp-");
|
||||
const filePath = join(dir, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`);
|
||||
await writeFile(filePath, "");
|
||||
return filePath;
|
||||
if (!dir.ok) return dir;
|
||||
const filePath = join(dir.value, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`);
|
||||
try {
|
||||
await writeFile(filePath, "");
|
||||
return ok(filePath);
|
||||
} catch (error) {
|
||||
return err(toFileError(error, filePath));
|
||||
}
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export { NodeExecutionEnv } from "./env/nodejs.js";
|
||||
export type { ExecutionEnv, ExecutionEnvExecOptions, FileErrorCode, FileInfo, FileKind } from "./types.js";
|
||||
export { FileError } from "./types.js";
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai";
|
||||
import type { AgentMessage } from "../types.js";
|
||||
import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai/base";
|
||||
import type { AgentMessage } from "../types.ts";
|
||||
|
||||
export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
|
||||
|
||||
@@ -51,7 +51,7 @@ export interface CompactionSummaryMessage {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
declare module "../types.js" {
|
||||
declare module "../types.ts" {
|
||||
interface CustomAgentMessages {
|
||||
bashExecution: BashExecutionMessage;
|
||||
custom: CustomMessage;
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { parse } from "yaml";
|
||||
import type { ExecutionEnv, FileInfo, PromptTemplate } from "./types.js";
|
||||
import { type ExecutionEnv, type FileInfo, type PromptTemplate, type Result, toError } from "./types.ts";
|
||||
|
||||
export type PromptTemplateDiagnosticCode = "file_info_failed" | "list_failed" | "read_failed" | "parse_failed";
|
||||
|
||||
/** Warning produced while loading prompt templates. */
|
||||
export interface PromptTemplateDiagnostic {
|
||||
/** Diagnostic severity. Currently only warnings are emitted. */
|
||||
type: "warning";
|
||||
/** Stable diagnostic code. */
|
||||
code: PromptTemplateDiagnosticCode;
|
||||
/** Human-readable diagnostic message. */
|
||||
message: string;
|
||||
/** Path associated with the diagnostic. */
|
||||
@@ -30,9 +34,20 @@ export async function loadPromptTemplates(
|
||||
const promptTemplates: PromptTemplate[] = [];
|
||||
const diagnostics: PromptTemplateDiagnostic[] = [];
|
||||
for (const path of Array.isArray(paths) ? paths : [paths]) {
|
||||
const info = await safeFileInfo(env, path);
|
||||
if (!info) continue;
|
||||
const kind = await resolveKind(env, info);
|
||||
const infoResult = await env.fileInfo(path);
|
||||
if (!infoResult.ok) {
|
||||
if (infoResult.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
code: "file_info_failed",
|
||||
message: infoResult.error.message,
|
||||
path,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const info = infoResult.value;
|
||||
const kind = await resolveKind(env, info, diagnostics);
|
||||
if (kind === "directory") {
|
||||
const result = await loadTemplatesFromDir(env, info.path);
|
||||
promptTemplates.push(...result.promptTemplates);
|
||||
@@ -83,20 +98,20 @@ async function loadTemplatesFromDir(
|
||||
): Promise<{ promptTemplates: PromptTemplate[]; diagnostics: PromptTemplateDiagnostic[] }> {
|
||||
const promptTemplates: PromptTemplate[] = [];
|
||||
const diagnostics: PromptTemplateDiagnostic[] = [];
|
||||
let entries: FileInfo[];
|
||||
try {
|
||||
entries = await env.listDir(dir);
|
||||
} catch (error) {
|
||||
const entriesResult = await env.listDir(dir);
|
||||
if (!entriesResult.ok) {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
message: errorMessage(error, "failed to list prompt template directory"),
|
||||
code: "list_failed",
|
||||
message: entriesResult.error.message,
|
||||
path: dir,
|
||||
});
|
||||
return { promptTemplates, diagnostics };
|
||||
}
|
||||
const entries = entriesResult.value;
|
||||
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const kind = await resolveKind(env, entry);
|
||||
const kind = await resolveKind(env, entry, diagnostics);
|
||||
if (kind !== "file" || !entry.name.endsWith(".md")) continue;
|
||||
const result = await loadTemplateFromFile(env, entry.path);
|
||||
if (result.promptTemplate) promptTemplates.push(result.promptTemplate);
|
||||
@@ -110,60 +125,92 @@ async function loadTemplateFromFile(
|
||||
filePath: string,
|
||||
): Promise<{ promptTemplate: PromptTemplate | null; diagnostics: PromptTemplateDiagnostic[] }> {
|
||||
const diagnostics: PromptTemplateDiagnostic[] = [];
|
||||
try {
|
||||
const rawContent = await env.readTextFile(filePath);
|
||||
const { frontmatter, body } = parseFrontmatter<PromptTemplateFrontmatter>(rawContent);
|
||||
const firstLine = body.split("\n").find((line) => line.trim());
|
||||
let description = typeof frontmatter.description === "string" ? frontmatter.description : "";
|
||||
if (!description && firstLine) {
|
||||
description = firstLine.slice(0, 60);
|
||||
if (firstLine.length > 60) description += "...";
|
||||
}
|
||||
return {
|
||||
promptTemplate: {
|
||||
name: basenameEnvPath(filePath).replace(/\.md$/i, ""),
|
||||
description,
|
||||
content: body,
|
||||
},
|
||||
diagnostics,
|
||||
};
|
||||
} catch (error) {
|
||||
const rawContent = await env.readTextFile(filePath);
|
||||
if (!rawContent.ok) {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
message: errorMessage(error, "failed to load prompt template"),
|
||||
code: "read_failed",
|
||||
message: rawContent.error.message,
|
||||
path: filePath,
|
||||
});
|
||||
return { promptTemplate: null, diagnostics };
|
||||
}
|
||||
}
|
||||
|
||||
async function safeFileInfo(env: ExecutionEnv, path: string): Promise<FileInfo | undefined> {
|
||||
try {
|
||||
return await env.fileInfo(path);
|
||||
} catch {
|
||||
return undefined;
|
||||
const parsed = parseFrontmatter<PromptTemplateFrontmatter>(rawContent.value);
|
||||
if (!parsed.ok) {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
code: "parse_failed",
|
||||
message: parsed.error.message,
|
||||
path: filePath,
|
||||
});
|
||||
return { promptTemplate: null, diagnostics };
|
||||
}
|
||||
|
||||
const { frontmatter, body } = parsed.value;
|
||||
const firstLine = body.split("\n").find((line) => line.trim());
|
||||
let description = typeof frontmatter.description === "string" ? frontmatter.description : "";
|
||||
if (!description && firstLine) {
|
||||
description = firstLine.slice(0, 60);
|
||||
if (firstLine.length > 60) description += "...";
|
||||
}
|
||||
return {
|
||||
promptTemplate: {
|
||||
name: basenameEnvPath(filePath).replace(/\.md$/i, ""),
|
||||
description,
|
||||
content: body,
|
||||
},
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveKind(env: ExecutionEnv, info: FileInfo): Promise<"file" | "directory" | undefined> {
|
||||
async function resolveKind(
|
||||
env: ExecutionEnv,
|
||||
info: FileInfo,
|
||||
diagnostics: PromptTemplateDiagnostic[],
|
||||
): Promise<"file" | "directory" | undefined> {
|
||||
if (info.kind === "file" || info.kind === "directory") return info.kind;
|
||||
try {
|
||||
const realPath = await env.realPath(info.path);
|
||||
const target = await env.fileInfo(realPath);
|
||||
return target.kind === "file" || target.kind === "directory" ? target.kind : undefined;
|
||||
} catch {
|
||||
const canonicalPath = await env.canonicalPath(info.path);
|
||||
if (!canonicalPath.ok) {
|
||||
if (canonicalPath.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
code: "file_info_failed",
|
||||
message: canonicalPath.error.message,
|
||||
path: info.path,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const target = await env.fileInfo(canonicalPath.value);
|
||||
if (!target.ok) {
|
||||
if (target.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
code: "file_info_failed",
|
||||
message: target.error.message,
|
||||
path: info.path,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return target.value.kind === "file" || target.value.kind === "directory" ? target.value.kind : undefined;
|
||||
}
|
||||
|
||||
function parseFrontmatter<T extends Record<string, unknown>>(content: string): { frontmatter: T; body: string } {
|
||||
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
if (!normalized.startsWith("---")) return { frontmatter: {} as T, body: normalized };
|
||||
const endIndex = normalized.indexOf("\n---", 3);
|
||||
if (endIndex === -1) return { frontmatter: {} as T, body: normalized };
|
||||
const yamlString = normalized.slice(4, endIndex);
|
||||
const body = normalized.slice(endIndex + 4).trim();
|
||||
return { frontmatter: (parse(yamlString) ?? {}) as T, body };
|
||||
function parseFrontmatter<T extends Record<string, unknown>>(
|
||||
content: string,
|
||||
): Result<{ frontmatter: T; body: string }, Error> {
|
||||
try {
|
||||
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
if (!normalized.startsWith("---")) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
|
||||
const endIndex = normalized.indexOf("\n---", 3);
|
||||
if (endIndex === -1) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
|
||||
const yamlString = normalized.slice(4, endIndex);
|
||||
const body = normalized.slice(endIndex + 4).trim();
|
||||
return { ok: true, value: { frontmatter: (parse(yamlString) ?? {}) as T, body } };
|
||||
} catch (error) {
|
||||
return { ok: false, error: toError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function basenameEnvPath(path: string): string {
|
||||
@@ -172,10 +219,6 @@ function basenameEnvPath(path: string): string {
|
||||
return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
/** Parse an argument string using simple shell-style single and double quotes. */
|
||||
export function parseCommandArgs(argsString: string): string[] {
|
||||
const args: string[] = [];
|
||||
|
||||
177
packages/agent/src/harness/session/jsonl-repo.ts
Normal file
177
packages/agent/src/harness/session/jsonl-repo.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import type {
|
||||
FileSystem,
|
||||
JsonlSessionCreateOptions,
|
||||
JsonlSessionListOptions,
|
||||
JsonlSessionMetadata,
|
||||
JsonlSessionRepoApi,
|
||||
Session,
|
||||
} from "../types.ts";
|
||||
import { SessionError, toError } from "../types.ts";
|
||||
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage.ts";
|
||||
import {
|
||||
createSessionId,
|
||||
createTimestamp,
|
||||
getEntriesToFork,
|
||||
getFileSystemResultOrThrow,
|
||||
toSession,
|
||||
} from "./repo-utils.ts";
|
||||
|
||||
type JsonlSessionRepoFileSystem = Pick<
|
||||
FileSystem,
|
||||
| "cwd"
|
||||
| "absolutePath"
|
||||
| "joinPath"
|
||||
| "readTextFile"
|
||||
| "readTextLines"
|
||||
| "writeFile"
|
||||
| "appendFile"
|
||||
| "listDir"
|
||||
| "exists"
|
||||
| "createDir"
|
||||
| "remove"
|
||||
>;
|
||||
|
||||
function encodeCwd(cwd: string): string {
|
||||
return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
||||
}
|
||||
|
||||
export class JsonlSessionRepo implements JsonlSessionRepoApi {
|
||||
private readonly fs: JsonlSessionRepoFileSystem;
|
||||
private readonly sessionsRootInput: string;
|
||||
private sessionsRoot: string | undefined;
|
||||
|
||||
constructor(options: { fs: JsonlSessionRepoFileSystem; sessionsRoot: string }) {
|
||||
this.fs = options.fs;
|
||||
this.sessionsRootInput = options.sessionsRoot;
|
||||
}
|
||||
|
||||
private async getSessionsRoot(): Promise<string> {
|
||||
if (!this.sessionsRoot) {
|
||||
this.sessionsRoot = getFileSystemResultOrThrow(
|
||||
await this.fs.absolutePath(this.sessionsRootInput),
|
||||
`Failed to resolve sessions root ${this.sessionsRootInput}`,
|
||||
);
|
||||
}
|
||||
return this.sessionsRoot;
|
||||
}
|
||||
|
||||
private async getSessionDir(cwd: string): Promise<string> {
|
||||
return getFileSystemResultOrThrow(
|
||||
await this.fs.joinPath([await this.getSessionsRoot(), encodeCwd(cwd)]),
|
||||
`Failed to resolve session directory for ${cwd}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async createSessionFilePath(cwd: string, sessionId: string, timestamp: string): Promise<string> {
|
||||
return getFileSystemResultOrThrow(
|
||||
await this.fs.joinPath([
|
||||
await this.getSessionDir(cwd),
|
||||
`${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`,
|
||||
]),
|
||||
`Failed to resolve session file path for ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
async create(options: JsonlSessionCreateOptions): Promise<Session<JsonlSessionMetadata>> {
|
||||
const id = options.id ?? createSessionId();
|
||||
const createdAt = createTimestamp();
|
||||
const sessionDir = await this.getSessionDir(options.cwd);
|
||||
getFileSystemResultOrThrow(
|
||||
await this.fs.createDir(sessionDir, { recursive: true }),
|
||||
`Failed to create session directory ${sessionDir}`,
|
||||
);
|
||||
const filePath = await this.createSessionFilePath(options.cwd, id, createdAt);
|
||||
const storage = await JsonlSessionStorage.create(this.fs, filePath, {
|
||||
cwd: options.cwd,
|
||||
sessionId: id,
|
||||
parentSessionPath: options.parentSessionPath,
|
||||
});
|
||||
return toSession(storage);
|
||||
}
|
||||
|
||||
async open(metadata: JsonlSessionMetadata): Promise<Session<JsonlSessionMetadata>> {
|
||||
if (
|
||||
!getFileSystemResultOrThrow(await this.fs.exists(metadata.path), `Failed to check session ${metadata.path}`)
|
||||
) {
|
||||
throw new SessionError("not_found", `Session not found: ${metadata.path}`);
|
||||
}
|
||||
const storage = await JsonlSessionStorage.open(this.fs, metadata.path);
|
||||
return toSession(storage);
|
||||
}
|
||||
|
||||
async list(options: JsonlSessionListOptions = {}): Promise<JsonlSessionMetadata[]> {
|
||||
const dirs = options.cwd ? [await this.getSessionDir(options.cwd)] : await this.listSessionDirs();
|
||||
const sessions: JsonlSessionMetadata[] = [];
|
||||
for (const dir of dirs) {
|
||||
if (!getFileSystemResultOrThrow(await this.fs.exists(dir), `Failed to check session directory ${dir}`)) {
|
||||
continue;
|
||||
}
|
||||
const files = getFileSystemResultOrThrow(
|
||||
await this.fs.listDir(dir),
|
||||
`Failed to list sessions in ${dir}`,
|
||||
).filter((file) => file.kind !== "directory" && file.name.endsWith(".jsonl"));
|
||||
for (const file of files) {
|
||||
try {
|
||||
sessions.push(await loadJsonlSessionMetadata(this.fs, file.path));
|
||||
} catch (error) {
|
||||
const cause = toError(error);
|
||||
if (!(cause instanceof SessionError) || cause.code !== "invalid_session") throw cause;
|
||||
}
|
||||
}
|
||||
}
|
||||
sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
return sessions;
|
||||
}
|
||||
|
||||
async delete(metadata: JsonlSessionMetadata): Promise<void> {
|
||||
getFileSystemResultOrThrow(
|
||||
await this.fs.remove(metadata.path, { force: true }),
|
||||
`Failed to delete session ${metadata.path}`,
|
||||
);
|
||||
}
|
||||
|
||||
async fork(
|
||||
sourceMetadata: JsonlSessionMetadata,
|
||||
options: JsonlSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string },
|
||||
): Promise<Session<JsonlSessionMetadata>> {
|
||||
const source = await this.open(sourceMetadata);
|
||||
const forkedEntries = await getEntriesToFork(source.getStorage(), options);
|
||||
const id = options.id ?? createSessionId();
|
||||
const createdAt = createTimestamp();
|
||||
const sessionDir = await this.getSessionDir(options.cwd);
|
||||
getFileSystemResultOrThrow(
|
||||
await this.fs.createDir(sessionDir, { recursive: true }),
|
||||
`Failed to create session directory ${sessionDir}`,
|
||||
);
|
||||
const storage = await JsonlSessionStorage.create(
|
||||
this.fs,
|
||||
await this.createSessionFilePath(options.cwd, id, createdAt),
|
||||
{
|
||||
cwd: options.cwd,
|
||||
sessionId: id,
|
||||
parentSessionPath: options.parentSessionPath ?? sourceMetadata.path,
|
||||
},
|
||||
);
|
||||
for (const entry of forkedEntries) {
|
||||
await storage.appendEntry(entry);
|
||||
}
|
||||
return toSession(storage);
|
||||
}
|
||||
|
||||
private async listSessionDirs(): Promise<string[]> {
|
||||
const sessionsRoot = await this.getSessionsRoot();
|
||||
if (
|
||||
!getFileSystemResultOrThrow(
|
||||
await this.fs.exists(sessionsRoot),
|
||||
`Failed to check sessions root ${sessionsRoot}`,
|
||||
)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const entries = getFileSystemResultOrThrow(
|
||||
await this.fs.listDir(sessionsRoot),
|
||||
`Failed to list sessions root ${sessionsRoot}`,
|
||||
);
|
||||
return entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path);
|
||||
}
|
||||
}
|
||||
293
packages/agent/src/harness/session/jsonl-storage.ts
Normal file
293
packages/agent/src/harness/session/jsonl-storage.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import type { FileSystem, JsonlSessionMetadata, LeafEntry, SessionStorage, SessionTreeEntry } from "../types.ts";
|
||||
import { SessionError, toError } from "../types.ts";
|
||||
import { getFileSystemResultOrThrow } from "./repo-utils.ts";
|
||||
import { uuidv7 } from "./uuid.ts";
|
||||
|
||||
type JsonlSessionStorageFileSystem = Pick<FileSystem, "readTextFile" | "readTextLines" | "writeFile" | "appendFile">;
|
||||
|
||||
interface SessionHeader {
|
||||
type: "session";
|
||||
version: 3;
|
||||
id: string;
|
||||
timestamp: string;
|
||||
cwd: string;
|
||||
parentSession?: string;
|
||||
}
|
||||
|
||||
function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void {
|
||||
if (entry.type !== "label") return;
|
||||
const label = entry.label?.trim();
|
||||
if (label) {
|
||||
labelsById.set(entry.targetId, label);
|
||||
} else {
|
||||
labelsById.delete(entry.targetId);
|
||||
}
|
||||
}
|
||||
|
||||
function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
|
||||
const labelsById = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
updateLabelCache(labelsById, entry);
|
||||
}
|
||||
return labelsById;
|
||||
}
|
||||
|
||||
function generateEntryId(byId: { has(id: string): boolean }): string {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const id = uuidv7().slice(0, 8);
|
||||
if (!byId.has(id)) return id;
|
||||
}
|
||||
return uuidv7();
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function invalidSession(filePath: string, message: string, cause?: Error): SessionError {
|
||||
return new SessionError("invalid_session", `Invalid JSONL session file ${filePath}: ${message}`, cause);
|
||||
}
|
||||
|
||||
function invalidEntry(filePath: string, lineNumber: number, message: string, cause?: Error): SessionError {
|
||||
return new SessionError(
|
||||
"invalid_entry",
|
||||
`Invalid JSONL session file ${filePath}: line ${lineNumber} ${message}`,
|
||||
cause,
|
||||
);
|
||||
}
|
||||
|
||||
function parseHeaderLine(line: string, filePath: string): SessionHeader {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch (error) {
|
||||
throw invalidSession(filePath, "first line is not a valid session header", toError(error));
|
||||
}
|
||||
if (!isRecord(parsed)) throw invalidSession(filePath, "first line is not a valid session header");
|
||||
if (parsed.type !== "session") throw invalidSession(filePath, "first line is not a valid session header");
|
||||
if (parsed.version !== 3) throw invalidSession(filePath, "unsupported session version");
|
||||
if (typeof parsed.id !== "string" || !parsed.id) throw invalidSession(filePath, "session header is missing id");
|
||||
if (typeof parsed.timestamp !== "string" || !parsed.timestamp) {
|
||||
throw invalidSession(filePath, "session header is missing timestamp");
|
||||
}
|
||||
if (typeof parsed.cwd !== "string" || !parsed.cwd) throw invalidSession(filePath, "session header is missing cwd");
|
||||
if (parsed.parentSession !== undefined && typeof parsed.parentSession !== "string") {
|
||||
throw invalidSession(filePath, "session header parentSession must be a string");
|
||||
}
|
||||
return {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: parsed.id,
|
||||
timestamp: parsed.timestamp,
|
||||
cwd: parsed.cwd,
|
||||
parentSession: parsed.parentSession,
|
||||
};
|
||||
}
|
||||
|
||||
function parseEntryLine(line: string, filePath: string, lineNumber: number): SessionTreeEntry {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch (error) {
|
||||
throw invalidEntry(filePath, lineNumber, "is not valid JSON", toError(error));
|
||||
}
|
||||
if (!isRecord(parsed)) throw invalidEntry(filePath, lineNumber, "is not a valid session entry");
|
||||
if (typeof parsed.type !== "string") throw invalidEntry(filePath, lineNumber, "is missing entry type");
|
||||
if (typeof parsed.id !== "string" || !parsed.id) throw invalidEntry(filePath, lineNumber, "is missing entry id");
|
||||
if (parsed.parentId !== null && typeof parsed.parentId !== "string") {
|
||||
throw invalidEntry(filePath, lineNumber, "has invalid parentId");
|
||||
}
|
||||
if (typeof parsed.timestamp !== "string" || !parsed.timestamp) {
|
||||
throw invalidEntry(filePath, lineNumber, "is missing timestamp");
|
||||
}
|
||||
if (parsed.type === "leaf" && parsed.targetId !== null && typeof parsed.targetId !== "string") {
|
||||
throw invalidEntry(filePath, lineNumber, "has invalid targetId");
|
||||
}
|
||||
return parsed as unknown as SessionTreeEntry;
|
||||
}
|
||||
|
||||
function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
|
||||
return entry.type === "leaf" ? entry.targetId : entry.id;
|
||||
}
|
||||
|
||||
function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata {
|
||||
return {
|
||||
id: header.id,
|
||||
createdAt: header.timestamp,
|
||||
cwd: header.cwd,
|
||||
path,
|
||||
parentSessionPath: header.parentSession,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadJsonlSessionMetadata(
|
||||
fs: JsonlSessionStorageFileSystem,
|
||||
filePath: string,
|
||||
): Promise<JsonlSessionMetadata> {
|
||||
const lines = getFileSystemResultOrThrow(
|
||||
await fs.readTextLines(filePath, { maxLines: 1 }),
|
||||
`Failed to read session header ${filePath}`,
|
||||
);
|
||||
const line = lines[0];
|
||||
if (line?.trim()) return headerToSessionMetadata(parseHeaderLine(line, filePath), filePath);
|
||||
throw invalidSession(filePath, "missing session header");
|
||||
}
|
||||
|
||||
async function loadJsonlStorage(
|
||||
fs: JsonlSessionStorageFileSystem,
|
||||
filePath: string,
|
||||
): Promise<{
|
||||
header: SessionHeader;
|
||||
entries: SessionTreeEntry[];
|
||||
leafId: string | null;
|
||||
}> {
|
||||
const content = getFileSystemResultOrThrow(await fs.readTextFile(filePath), `Failed to read session ${filePath}`);
|
||||
const lines = content.split("\n").filter((line) => line.trim());
|
||||
if (lines.length === 0) {
|
||||
throw invalidSession(filePath, "missing session header");
|
||||
}
|
||||
|
||||
const header = parseHeaderLine(lines[0]!, filePath);
|
||||
const entries: SessionTreeEntry[] = [];
|
||||
let leafId: string | null = null;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const entry = parseEntryLine(lines[i]!, filePath, i + 1);
|
||||
entries.push(entry);
|
||||
leafId = leafIdAfterEntry(entry);
|
||||
}
|
||||
return { header, entries, leafId };
|
||||
}
|
||||
|
||||
export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata> {
|
||||
private readonly fs: JsonlSessionStorageFileSystem;
|
||||
private readonly filePath: string;
|
||||
private readonly metadata: JsonlSessionMetadata;
|
||||
private entries: SessionTreeEntry[];
|
||||
private byId: Map<string, SessionTreeEntry>;
|
||||
private labelsById: Map<string, string>;
|
||||
private currentLeafId: string | null;
|
||||
|
||||
private constructor(
|
||||
fs: JsonlSessionStorageFileSystem,
|
||||
filePath: string,
|
||||
header: SessionHeader,
|
||||
entries: SessionTreeEntry[],
|
||||
leafId: string | null,
|
||||
) {
|
||||
this.fs = fs;
|
||||
this.filePath = filePath;
|
||||
this.metadata = headerToSessionMetadata(header, this.filePath);
|
||||
this.entries = entries;
|
||||
this.byId = new Map(entries.map((entry) => [entry.id, entry]));
|
||||
this.labelsById = buildLabelsById(entries);
|
||||
this.currentLeafId = leafId;
|
||||
}
|
||||
|
||||
static async open(fs: JsonlSessionStorageFileSystem, filePath: string): Promise<JsonlSessionStorage> {
|
||||
const loaded = await loadJsonlStorage(fs, filePath);
|
||||
return new JsonlSessionStorage(fs, filePath, loaded.header, loaded.entries, loaded.leafId);
|
||||
}
|
||||
|
||||
static async create(
|
||||
fs: JsonlSessionStorageFileSystem,
|
||||
filePath: string,
|
||||
options: {
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
parentSessionPath?: string;
|
||||
},
|
||||
): Promise<JsonlSessionStorage> {
|
||||
const header: SessionHeader = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: options.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: options.cwd,
|
||||
parentSession: options.parentSessionPath,
|
||||
};
|
||||
getFileSystemResultOrThrow(
|
||||
await fs.writeFile(filePath, `${JSON.stringify(header)}\n`),
|
||||
`Failed to create session ${filePath}`,
|
||||
);
|
||||
return new JsonlSessionStorage(fs, filePath, header, [], null);
|
||||
}
|
||||
|
||||
async getMetadata(): Promise<JsonlSessionMetadata> {
|
||||
return this.metadata;
|
||||
}
|
||||
|
||||
async getLeafId(): Promise<string | null> {
|
||||
if (this.currentLeafId !== null && !this.byId.has(this.currentLeafId)) {
|
||||
throw new SessionError("invalid_session", `Entry ${this.currentLeafId} not found`);
|
||||
}
|
||||
return this.currentLeafId;
|
||||
}
|
||||
|
||||
async setLeafId(leafId: string | null): Promise<void> {
|
||||
if (leafId !== null && !this.byId.has(leafId)) {
|
||||
throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||
}
|
||||
const entry: LeafEntry = {
|
||||
type: "leaf",
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.currentLeafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
targetId: leafId,
|
||||
};
|
||||
getFileSystemResultOrThrow(
|
||||
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
|
||||
`Failed to append session leaf ${entry.id}`,
|
||||
);
|
||||
this.entries.push(entry);
|
||||
this.byId.set(entry.id, entry);
|
||||
this.currentLeafId = leafId;
|
||||
}
|
||||
|
||||
async createEntryId(): Promise<string> {
|
||||
return generateEntryId(this.byId);
|
||||
}
|
||||
|
||||
async appendEntry(entry: SessionTreeEntry): Promise<void> {
|
||||
getFileSystemResultOrThrow(
|
||||
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
|
||||
`Failed to append session entry ${entry.id}`,
|
||||
);
|
||||
this.entries.push(entry);
|
||||
this.byId.set(entry.id, entry);
|
||||
updateLabelCache(this.labelsById, entry);
|
||||
this.currentLeafId = leafIdAfterEntry(entry);
|
||||
}
|
||||
|
||||
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
|
||||
return this.byId.get(id);
|
||||
}
|
||||
|
||||
async findEntries<TType extends SessionTreeEntry["type"]>(
|
||||
type: TType,
|
||||
): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>> {
|
||||
return this.entries.filter((entry): entry is Extract<SessionTreeEntry, { type: TType }> => entry.type === type);
|
||||
}
|
||||
|
||||
async getLabel(id: string): Promise<string | undefined> {
|
||||
return this.labelsById.get(id);
|
||||
}
|
||||
|
||||
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
|
||||
if (leafId === null) return [];
|
||||
const path: SessionTreeEntry[] = [];
|
||||
let current = this.byId.get(leafId);
|
||||
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||
while (current) {
|
||||
path.unshift(current);
|
||||
if (!current.parentId) break;
|
||||
const parent = this.byId.get(current.parentId);
|
||||
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
|
||||
current = parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
async getEntries(): Promise<SessionTreeEntry[]> {
|
||||
return [...this.entries];
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Session, SessionMetadata, SessionRepo } from "../../types.js";
|
||||
import { InMemorySessionStorage } from "../storage/memory.js";
|
||||
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./shared.js";
|
||||
import { type Session, SessionError, type SessionMetadata, type SessionRepo } from "../types.ts";
|
||||
import { InMemorySessionStorage } from "./memory-storage.ts";
|
||||
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./repo-utils.ts";
|
||||
|
||||
export class InMemorySessionRepo implements SessionRepo<SessionMetadata, { id?: string }, void> {
|
||||
private sessions = new Map<string, Session<SessionMetadata>>();
|
||||
@@ -19,7 +19,7 @@ export class InMemorySessionRepo implements SessionRepo<SessionMetadata, { id?:
|
||||
async open(metadata: SessionMetadata): Promise<Session<SessionMetadata>> {
|
||||
const session = this.sessions.get(metadata.id);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${metadata.id}`);
|
||||
throw new SessionError("not_found", `Session not found: ${metadata.id}`);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
@@ -42,8 +42,7 @@ export class InMemorySessionRepo implements SessionRepo<SessionMetadata, { id?:
|
||||
id: options.id ?? createSessionId(),
|
||||
createdAt: createTimestamp(),
|
||||
};
|
||||
const leafId = forkedEntries[forkedEntries.length - 1]?.id ?? null;
|
||||
const storage = new InMemorySessionStorage({ metadata, entries: forkedEntries, leafId });
|
||||
const storage = new InMemorySessionStorage({ metadata, entries: forkedEntries });
|
||||
const session = toSession(storage);
|
||||
this.sessions.set(metadata.id, session);
|
||||
return session;
|
||||
@@ -1,6 +1,11 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js";
|
||||
import {
|
||||
type LeafEntry,
|
||||
SessionError,
|
||||
type SessionMetadata,
|
||||
type SessionStorage,
|
||||
type SessionTreeEntry,
|
||||
} from "../types.ts";
|
||||
import { uuidv7 } from "./uuid.ts";
|
||||
|
||||
function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void {
|
||||
if (entry.type !== "label") return;
|
||||
@@ -22,42 +27,61 @@ function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
|
||||
|
||||
function generateEntryId(byId: { has(id: string): boolean }): string {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const id = randomUUID().slice(0, 8);
|
||||
const id = uuidv7().slice(0, 8);
|
||||
if (!byId.has(id)) return id;
|
||||
}
|
||||
return randomUUID();
|
||||
return uuidv7();
|
||||
}
|
||||
|
||||
export class InMemorySessionStorage implements SessionStorage {
|
||||
private readonly metadata: SessionMetadata;
|
||||
function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
|
||||
return entry.type === "leaf" ? entry.targetId : entry.id;
|
||||
}
|
||||
|
||||
export class InMemorySessionStorage<TMetadata extends SessionMetadata = SessionMetadata>
|
||||
implements SessionStorage<TMetadata>
|
||||
{
|
||||
private readonly metadata: TMetadata;
|
||||
private entries: SessionTreeEntry[];
|
||||
private byId: Map<string, SessionTreeEntry>;
|
||||
private labelsById: Map<string, string>;
|
||||
private leafId: string | null;
|
||||
|
||||
constructor(options?: { entries?: SessionTreeEntry[]; leafId?: string | null; metadata?: SessionMetadata }) {
|
||||
constructor(options?: { entries?: SessionTreeEntry[]; metadata?: TMetadata }) {
|
||||
this.entries = options?.entries ? [...options.entries] : [];
|
||||
this.byId = new Map(this.entries.map((entry) => [entry.id, entry]));
|
||||
this.labelsById = buildLabelsById(this.entries);
|
||||
this.leafId = options?.leafId ?? this.entries[this.entries.length - 1]?.id ?? null;
|
||||
this.leafId = null;
|
||||
for (const entry of this.entries) this.leafId = leafIdAfterEntry(entry);
|
||||
if (this.leafId !== null && !this.byId.has(this.leafId)) {
|
||||
throw new Error(`Entry ${this.leafId} not found`);
|
||||
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
|
||||
}
|
||||
this.metadata = options?.metadata ?? { id: uuidv7(), createdAt: new Date().toISOString() };
|
||||
this.metadata = options?.metadata ?? ({ id: uuidv7(), createdAt: new Date().toISOString() } as TMetadata);
|
||||
}
|
||||
|
||||
async getMetadata(): Promise<SessionMetadata> {
|
||||
async getMetadata(): Promise<TMetadata> {
|
||||
return this.metadata;
|
||||
}
|
||||
|
||||
async getLeafId(): Promise<string | null> {
|
||||
if (this.leafId !== null && !this.byId.has(this.leafId)) {
|
||||
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
|
||||
}
|
||||
return this.leafId;
|
||||
}
|
||||
|
||||
async setLeafId(leafId: string | null): Promise<void> {
|
||||
if (leafId !== null && !this.byId.has(leafId)) {
|
||||
throw new Error(`Entry ${leafId} not found`);
|
||||
throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||
}
|
||||
const entry: LeafEntry = {
|
||||
type: "leaf",
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
targetId: leafId,
|
||||
};
|
||||
this.entries.push(entry);
|
||||
this.byId.set(entry.id, entry);
|
||||
this.leafId = leafId;
|
||||
}
|
||||
|
||||
@@ -69,7 +93,7 @@ export class InMemorySessionStorage implements SessionStorage {
|
||||
this.entries.push(entry);
|
||||
this.byId.set(entry.id, entry);
|
||||
updateLabelCache(this.labelsById, entry);
|
||||
this.leafId = entry.id;
|
||||
this.leafId = leafIdAfterEntry(entry);
|
||||
}
|
||||
|
||||
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
|
||||
@@ -90,9 +114,13 @@ export class InMemorySessionStorage implements SessionStorage {
|
||||
if (leafId === null) return [];
|
||||
const path: SessionTreeEntry[] = [];
|
||||
let current = this.byId.get(leafId);
|
||||
if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||
while (current) {
|
||||
path.unshift(current);
|
||||
current = current.parentId ? this.byId.get(current.parentId) : undefined;
|
||||
if (!current.parentId) break;
|
||||
const parent = this.byId.get(current.parentId);
|
||||
if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
|
||||
current = parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js";
|
||||
import { Session } from "../session.js";
|
||||
import {
|
||||
type FileError,
|
||||
type Result,
|
||||
SessionError,
|
||||
type SessionMetadata,
|
||||
type SessionStorage,
|
||||
type SessionTreeEntry,
|
||||
} from "../types.ts";
|
||||
import { Session } from "./session.ts";
|
||||
import { uuidv7 } from "./uuid.ts";
|
||||
|
||||
export function createSessionId(): string {
|
||||
return uuidv7();
|
||||
@@ -14,6 +21,14 @@ export function toSession<TMetadata extends SessionMetadata>(storage: SessionSto
|
||||
return new Session(storage);
|
||||
}
|
||||
|
||||
export function getFileSystemResultOrThrow<TValue>(result: Result<TValue, FileError>, message: string): TValue {
|
||||
if (!result.ok) {
|
||||
const code = result.error.code === "not_found" ? "not_found" : "storage";
|
||||
throw new SessionError(code, `${message}: ${result.error.message}`, result.error);
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function getEntriesToFork(
|
||||
storage: SessionStorage,
|
||||
options: { entryId?: string; position?: "before" | "at" },
|
||||
@@ -21,14 +36,14 @@ export async function getEntriesToFork(
|
||||
if (!options.entryId) return storage.getEntries();
|
||||
const target = await storage.getEntry(options.entryId);
|
||||
if (!target) {
|
||||
throw new Error(`Entry ${options.entryId} not found`);
|
||||
throw new SessionError("invalid_fork_target", `Entry ${options.entryId} not found`);
|
||||
}
|
||||
let effectiveLeafId: string | null;
|
||||
if ((options.position ?? "before") === "at") {
|
||||
effectiveLeafId = target.id;
|
||||
} else {
|
||||
if (target.type !== "message" || target.message.role !== "user") {
|
||||
throw new Error(`Entry ${options.entryId} is not a user message`);
|
||||
throw new SessionError("invalid_fork_target", `Entry ${options.entryId} is not a user message`);
|
||||
}
|
||||
effectiveLeafId = target.parentId;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access, mkdir, readdir, rm } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import type {
|
||||
JsonlSessionCreateOptions,
|
||||
JsonlSessionListOptions,
|
||||
JsonlSessionMetadata,
|
||||
JsonlSessionRepoApi,
|
||||
Session,
|
||||
} from "../../types.js";
|
||||
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../storage/jsonl.js";
|
||||
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./shared.js";
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCwd(cwd: string): string {
|
||||
return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
||||
}
|
||||
|
||||
export class JsonlSessionRepo implements JsonlSessionRepoApi {
|
||||
private sessionsRoot: string;
|
||||
|
||||
constructor(options: { sessionsRoot: string }) {
|
||||
this.sessionsRoot = resolve(options.sessionsRoot);
|
||||
}
|
||||
|
||||
private getSessionDir(cwd: string): string {
|
||||
return join(this.sessionsRoot, encodeCwd(cwd));
|
||||
}
|
||||
|
||||
private createSessionFilePath(cwd: string, sessionId: string, timestamp: string): string {
|
||||
return join(this.getSessionDir(cwd), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
async create(options: JsonlSessionCreateOptions): Promise<Session<JsonlSessionMetadata>> {
|
||||
await mkdir(this.sessionsRoot, { recursive: true });
|
||||
const id = options.id ?? createSessionId();
|
||||
const createdAt = createTimestamp();
|
||||
const filePath = this.createSessionFilePath(options.cwd, id, createdAt);
|
||||
const storage = await JsonlSessionStorage.create(filePath, {
|
||||
cwd: options.cwd,
|
||||
sessionId: id,
|
||||
parentSessionPath: options.parentSessionPath,
|
||||
});
|
||||
return toSession(storage);
|
||||
}
|
||||
|
||||
async open(metadata: JsonlSessionMetadata): Promise<Session<JsonlSessionMetadata>> {
|
||||
if (!(await exists(metadata.path))) {
|
||||
throw new Error(`Session not found: ${metadata.path}`);
|
||||
}
|
||||
const storage = await JsonlSessionStorage.open(metadata.path);
|
||||
return toSession(storage);
|
||||
}
|
||||
|
||||
async list(options: JsonlSessionListOptions = {}): Promise<JsonlSessionMetadata[]> {
|
||||
const dirs = options.cwd ? [this.getSessionDir(options.cwd)] : await this.listSessionDirs();
|
||||
const sessions: JsonlSessionMetadata[] = [];
|
||||
for (const dir of dirs) {
|
||||
if (!(await exists(dir))) continue;
|
||||
const files = (await readdir(dir)).filter((file) => file.endsWith(".jsonl")).map((file) => join(dir, file));
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
sessions.push(await loadJsonlSessionMetadata(filePath));
|
||||
} catch {
|
||||
// Ignore invalid session files when listing a directory.
|
||||
}
|
||||
}
|
||||
}
|
||||
sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
return sessions;
|
||||
}
|
||||
|
||||
async delete(metadata: JsonlSessionMetadata): Promise<void> {
|
||||
await rm(metadata.path, { force: true });
|
||||
}
|
||||
|
||||
async fork(
|
||||
sourceMetadata: JsonlSessionMetadata,
|
||||
options: JsonlSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string },
|
||||
): Promise<Session<JsonlSessionMetadata>> {
|
||||
const source = await this.open(sourceMetadata);
|
||||
const forkedEntries = await getEntriesToFork(source.getStorage(), options);
|
||||
const id = options.id ?? createSessionId();
|
||||
const createdAt = createTimestamp();
|
||||
const storage = await JsonlSessionStorage.create(this.createSessionFilePath(options.cwd, id, createdAt), {
|
||||
cwd: options.cwd,
|
||||
sessionId: id,
|
||||
parentSessionPath: options.parentSessionPath ?? sourceMetadata.path,
|
||||
});
|
||||
for (const entry of forkedEntries) {
|
||||
await storage.appendEntry(entry);
|
||||
}
|
||||
return toSession(storage);
|
||||
}
|
||||
|
||||
private async listSessionDirs(): Promise<string[]> {
|
||||
if (!(await exists(this.sessionsRoot))) return [];
|
||||
const entries = await readdir(this.sessionsRoot, { withFileTypes: true });
|
||||
return entries.filter((entry) => entry.isDirectory()).map((entry) => join(this.sessionsRoot, entry.name));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
||||
import type { AgentMessage } from "../../types.js";
|
||||
import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.js";
|
||||
import type { ImageContent, TextContent } from "@earendil-works/pi-ai/base";
|
||||
import type { AgentMessage } from "../../types.ts";
|
||||
import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.ts";
|
||||
import type {
|
||||
ActiveToolsChangeEntry,
|
||||
BranchSummaryEntry,
|
||||
CompactionEntry,
|
||||
CustomEntry,
|
||||
@@ -15,11 +16,13 @@ import type {
|
||||
SessionStorage,
|
||||
SessionTreeEntry,
|
||||
ThinkingLevelChangeEntry,
|
||||
} from "../types.js";
|
||||
} from "../types.ts";
|
||||
import { SessionError } from "../types.ts";
|
||||
|
||||
export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext {
|
||||
let thinkingLevel = "off";
|
||||
let model: { provider: string; modelId: string } | null = null;
|
||||
let activeToolNames: string[] | null = null;
|
||||
let compaction: CompactionEntry | null = null;
|
||||
|
||||
for (const entry of pathEntries) {
|
||||
@@ -29,6 +32,8 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon
|
||||
model = { provider: entry.provider, modelId: entry.modelId };
|
||||
} else if (entry.type === "message" && entry.message.role === "assistant") {
|
||||
model = { provider: entry.message.provider, modelId: entry.message.model };
|
||||
} else if (entry.type === "active_tools_change") {
|
||||
activeToolNames = [...entry.activeToolNames];
|
||||
} else if (entry.type === "compaction") {
|
||||
compaction = entry;
|
||||
}
|
||||
@@ -71,7 +76,7 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon
|
||||
}
|
||||
}
|
||||
|
||||
return { messages, thinkingLevel, model };
|
||||
return { messages, thinkingLevel, model, activeToolNames };
|
||||
}
|
||||
|
||||
export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
@@ -155,6 +160,16 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
} satisfies ModelChangeEntry);
|
||||
}
|
||||
|
||||
async appendActiveToolsChange(activeToolNames: string[]): Promise<string> {
|
||||
return this.appendTypedEntry({
|
||||
type: "active_tools_change",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.storage.getLeafId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
activeToolNames: [...activeToolNames],
|
||||
} satisfies ActiveToolsChangeEntry);
|
||||
}
|
||||
|
||||
async appendCompaction<T = unknown>(
|
||||
summary: string,
|
||||
firstKeptEntryId: string,
|
||||
@@ -206,7 +221,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
|
||||
async appendLabel(targetId: string, label: string | undefined): Promise<string> {
|
||||
if (!(await this.storage.getEntry(targetId))) {
|
||||
throw new Error(`Entry ${targetId} not found`);
|
||||
throw new SessionError("not_found", `Entry ${targetId} not found`);
|
||||
}
|
||||
return this.appendTypedEntry({
|
||||
type: "label",
|
||||
@@ -233,7 +248,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
summary?: { summary: string; details?: unknown; fromHook?: boolean },
|
||||
): Promise<string | undefined> {
|
||||
if (entryId !== null && !(await this.storage.getEntry(entryId))) {
|
||||
throw new Error(`Entry ${entryId} not found`);
|
||||
throw new SessionError("not_found", `Entry ${entryId} not found`);
|
||||
}
|
||||
await this.storage.setLeafId(entryId);
|
||||
if (!summary) return undefined;
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { JsonlSessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js";
|
||||
|
||||
interface SessionHeader {
|
||||
type: "session";
|
||||
version: 3;
|
||||
id: string;
|
||||
timestamp: string;
|
||||
cwd: string;
|
||||
parentSession?: string;
|
||||
}
|
||||
|
||||
function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void {
|
||||
if (entry.type !== "label") return;
|
||||
const label = entry.label?.trim();
|
||||
if (label) {
|
||||
labelsById.set(entry.targetId, label);
|
||||
} else {
|
||||
labelsById.delete(entry.targetId);
|
||||
}
|
||||
}
|
||||
|
||||
function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
|
||||
const labelsById = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
updateLabelCache(labelsById, entry);
|
||||
}
|
||||
return labelsById;
|
||||
}
|
||||
|
||||
function generateEntryId(byId: { has(id: string): boolean }): string {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const id = randomUUID().slice(0, 8);
|
||||
if (!byId.has(id)) return id;
|
||||
}
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata {
|
||||
return {
|
||||
id: header.id,
|
||||
createdAt: header.timestamp,
|
||||
cwd: header.cwd,
|
||||
path,
|
||||
parentSessionPath: header.parentSession,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadJsonlSessionMetadata(filePath: string): Promise<JsonlSessionMetadata> {
|
||||
const stream = createReadStream(filePath, { encoding: "utf8" });
|
||||
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
||||
try {
|
||||
for await (const line of lines) {
|
||||
if (!line.trim()) break;
|
||||
try {
|
||||
const header = JSON.parse(line) as SessionHeader;
|
||||
return headerToSessionMetadata(header, resolve(filePath));
|
||||
} catch {
|
||||
throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`);
|
||||
}
|
||||
}
|
||||
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
|
||||
} finally {
|
||||
lines.close();
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadJsonlStorage(filePath: string): Promise<{
|
||||
header: SessionHeader;
|
||||
entries: SessionTreeEntry[];
|
||||
leafId: string | null;
|
||||
}> {
|
||||
const content = await readFile(filePath, "utf8");
|
||||
const lines = content.split("\n").filter((line) => line.trim());
|
||||
if (lines.length === 0) {
|
||||
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
|
||||
}
|
||||
|
||||
let header: SessionHeader;
|
||||
try {
|
||||
header = JSON.parse(lines[0]!) as SessionHeader;
|
||||
} catch {
|
||||
throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`);
|
||||
}
|
||||
|
||||
const entries: SessionTreeEntry[] = [];
|
||||
let leafId: string | null = null;
|
||||
for (const line of lines.slice(1)) {
|
||||
try {
|
||||
const entry = JSON.parse(line) as SessionTreeEntry;
|
||||
entries.push(entry);
|
||||
leafId = entry.id;
|
||||
} catch {
|
||||
// ignore malformed entry lines
|
||||
}
|
||||
}
|
||||
return { header, entries, leafId };
|
||||
}
|
||||
|
||||
export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata> {
|
||||
private readonly filePath: string;
|
||||
private readonly metadata: JsonlSessionMetadata;
|
||||
private entries: SessionTreeEntry[];
|
||||
private byId: Map<string, SessionTreeEntry>;
|
||||
private labelsById: Map<string, string>;
|
||||
private currentLeafId: string | null;
|
||||
|
||||
private constructor(filePath: string, header: SessionHeader, entries: SessionTreeEntry[], leafId: string | null) {
|
||||
this.filePath = resolve(filePath);
|
||||
this.metadata = headerToSessionMetadata(header, this.filePath);
|
||||
this.entries = entries;
|
||||
this.byId = new Map(entries.map((entry) => [entry.id, entry]));
|
||||
this.labelsById = buildLabelsById(entries);
|
||||
this.currentLeafId = leafId;
|
||||
}
|
||||
|
||||
static async open(filePath: string): Promise<JsonlSessionStorage> {
|
||||
const resolvedPath = resolve(filePath);
|
||||
const loaded = await loadJsonlStorage(resolvedPath);
|
||||
return new JsonlSessionStorage(resolvedPath, loaded.header, loaded.entries, loaded.leafId);
|
||||
}
|
||||
|
||||
static async create(
|
||||
filePath: string,
|
||||
options: {
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
parentSessionPath?: string;
|
||||
},
|
||||
): Promise<JsonlSessionStorage> {
|
||||
const resolvedPath = resolve(filePath);
|
||||
const header: SessionHeader = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: options.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: options.cwd,
|
||||
parentSession: options.parentSessionPath,
|
||||
};
|
||||
await mkdir(dirname(resolvedPath), { recursive: true });
|
||||
await writeFile(resolvedPath, `${JSON.stringify(header)}\n`);
|
||||
return new JsonlSessionStorage(resolvedPath, header, [], null);
|
||||
}
|
||||
|
||||
async getMetadata(): Promise<JsonlSessionMetadata> {
|
||||
return this.metadata;
|
||||
}
|
||||
|
||||
async getLeafId(): Promise<string | null> {
|
||||
return this.currentLeafId;
|
||||
}
|
||||
|
||||
async setLeafId(leafId: string | null): Promise<void> {
|
||||
if (leafId !== null && !this.byId.has(leafId)) {
|
||||
throw new Error(`Entry ${leafId} not found`);
|
||||
}
|
||||
this.currentLeafId = leafId;
|
||||
}
|
||||
|
||||
async createEntryId(): Promise<string> {
|
||||
return generateEntryId(this.byId);
|
||||
}
|
||||
|
||||
async appendEntry(entry: SessionTreeEntry): Promise<void> {
|
||||
await appendFile(this.filePath, `${JSON.stringify(entry)}\n`);
|
||||
this.entries.push(entry);
|
||||
this.byId.set(entry.id, entry);
|
||||
updateLabelCache(this.labelsById, entry);
|
||||
this.currentLeafId = entry.id;
|
||||
}
|
||||
|
||||
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
|
||||
return this.byId.get(id);
|
||||
}
|
||||
|
||||
async findEntries<TType extends SessionTreeEntry["type"]>(
|
||||
type: TType,
|
||||
): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>> {
|
||||
return this.entries.filter((entry): entry is Extract<SessionTreeEntry, { type: TType }> => entry.type === type);
|
||||
}
|
||||
|
||||
async getLabel(id: string): Promise<string | undefined> {
|
||||
return this.labelsById.get(id);
|
||||
}
|
||||
|
||||
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
|
||||
if (leafId === null) return [];
|
||||
const path: SessionTreeEntry[] = [];
|
||||
let current = this.byId.get(leafId);
|
||||
while (current) {
|
||||
path.unshift(current);
|
||||
current = current.parentId ? this.byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
async getEntries(): Promise<SessionTreeEntry[]> {
|
||||
return [...this.entries];
|
||||
}
|
||||
}
|
||||
54
packages/agent/src/harness/session/uuid.ts
Normal file
54
packages/agent/src/harness/session/uuid.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
let lastTimestamp = -Infinity;
|
||||
let sequence = 0;
|
||||
|
||||
function fillRandomBytes(bytes: Uint8Array): void {
|
||||
const crypto = globalThis.crypto;
|
||||
if (crypto?.getRandomValues) {
|
||||
crypto.getRandomValues(bytes);
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
|
||||
export function uuidv7(): string {
|
||||
const random = new Uint8Array(16);
|
||||
fillRandomBytes(random);
|
||||
const timestamp = Date.now();
|
||||
|
||||
if (timestamp > lastTimestamp) {
|
||||
sequence = random[6] * 0x1000000 + random[7] * 0x10000 + random[8] * 0x100 + random[9];
|
||||
lastTimestamp = timestamp;
|
||||
} else {
|
||||
sequence = (sequence + 1) >>> 0;
|
||||
if (sequence === 0) {
|
||||
lastTimestamp++;
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(16);
|
||||
bytes[0] = (lastTimestamp / 0x10000000000) & 0xff;
|
||||
bytes[1] = (lastTimestamp / 0x100000000) & 0xff;
|
||||
bytes[2] = (lastTimestamp / 0x1000000) & 0xff;
|
||||
bytes[3] = (lastTimestamp / 0x10000) & 0xff;
|
||||
bytes[4] = (lastTimestamp / 0x100) & 0xff;
|
||||
bytes[5] = lastTimestamp & 0xff;
|
||||
bytes[6] = 0x70 | ((sequence >>> 28) & 0x0f);
|
||||
bytes[7] = (sequence >>> 20) & 0xff;
|
||||
bytes[8] = 0x80 | ((sequence >>> 14) & 0x3f);
|
||||
bytes[9] = (sequence >>> 6) & 0xff;
|
||||
bytes[10] = ((sequence & 0x3f) << 2) | (random[10] & 0x03);
|
||||
bytes[11] = random[11];
|
||||
bytes[12] = random[12];
|
||||
bytes[13] = random[13];
|
||||
bytes[14] = random[14];
|
||||
bytes[15] = random[15];
|
||||
|
||||
return formatUuid(bytes);
|
||||
}
|
||||
|
||||
function formatUuid(bytes: Uint8Array): string {
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
||||
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
|
||||
}
|
||||
@@ -1,17 +1,31 @@
|
||||
import ignore from "ignore";
|
||||
import { parse } from "yaml";
|
||||
import type { ExecutionEnv, Skill } from "./types.js";
|
||||
import { type ExecutionEnv, type FileInfo, type Result, type Skill, toError } from "./types.ts";
|
||||
|
||||
const MAX_NAME_LENGTH = 64;
|
||||
const MAX_DESCRIPTION_LENGTH = 1024;
|
||||
const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];
|
||||
const IGNORED_ROOT_SKILL_MARKDOWN = new Set(["README.md"]);
|
||||
|
||||
type IgnoreMatcher = ReturnType<typeof ignore>;
|
||||
|
||||
function isIgnoredRootSkillMarkdown(fileName: string): boolean {
|
||||
return IGNORED_ROOT_SKILL_MARKDOWN.has(fileName) || fileName.toLowerCase() === "readme.md";
|
||||
}
|
||||
|
||||
export type SkillDiagnosticCode =
|
||||
| "file_info_failed"
|
||||
| "list_failed"
|
||||
| "read_failed"
|
||||
| "parse_failed"
|
||||
| "invalid_metadata";
|
||||
|
||||
/** Warning produced while loading skills. */
|
||||
export interface SkillDiagnostic {
|
||||
/** Diagnostic severity. Currently only warnings are emitted. */
|
||||
type: "warning";
|
||||
/** Stable diagnostic code. */
|
||||
code: SkillDiagnosticCode;
|
||||
/** Human-readable diagnostic message. */
|
||||
message: string;
|
||||
/** Path associated with the diagnostic. */
|
||||
@@ -44,8 +58,20 @@ export async function loadSkills(
|
||||
const skills: Skill[] = [];
|
||||
const diagnostics: SkillDiagnostic[] = [];
|
||||
for (const dir of Array.isArray(dirs) ? dirs : [dirs]) {
|
||||
const rootInfo = await safeFileInfo(env, dir);
|
||||
if (!rootInfo || (await resolveKind(env, rootInfo)) !== "directory") continue;
|
||||
const rootInfoResult = await env.fileInfo(dir);
|
||||
if (!rootInfoResult.ok) {
|
||||
if (rootInfoResult.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
code: "file_info_failed",
|
||||
message: rootInfoResult.error.message,
|
||||
path: dir,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const rootInfo = rootInfoResult.value;
|
||||
if ((await resolveKind(env, rootInfo, diagnostics)) !== "directory") continue;
|
||||
const result = await loadSkillsFromDirInternal(env, rootInfo.path, true, ignore(), rootInfo.path);
|
||||
skills.push(...result.skills);
|
||||
diagnostics.push(...result.diagnostics);
|
||||
@@ -89,23 +115,34 @@ async function loadSkillsFromDirInternal(
|
||||
const skills: Skill[] = [];
|
||||
const diagnostics: SkillDiagnostic[] = [];
|
||||
|
||||
if (!(await env.exists(dir))) return { skills, diagnostics };
|
||||
const dirInfo = await safeFileInfo(env, dir);
|
||||
if (!dirInfo || (await resolveKind(env, dirInfo)) !== "directory") return { skills, diagnostics };
|
||||
|
||||
await addIgnoreRules(env, ignoreMatcher, dir, rootDir);
|
||||
|
||||
let entries: Awaited<ReturnType<ExecutionEnv["listDir"]>>;
|
||||
try {
|
||||
entries = await env.listDir(dir);
|
||||
} catch {
|
||||
const dirInfoResult = await env.fileInfo(dir);
|
||||
if (!dirInfoResult.ok) {
|
||||
if (dirInfoResult.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
code: "file_info_failed",
|
||||
message: dirInfoResult.error.message,
|
||||
path: dir,
|
||||
});
|
||||
}
|
||||
return { skills, diagnostics };
|
||||
}
|
||||
const dirInfo = dirInfoResult.value;
|
||||
if ((await resolveKind(env, dirInfo, diagnostics)) !== "directory") return { skills, diagnostics };
|
||||
|
||||
await addIgnoreRules(env, ignoreMatcher, dir, rootDir, diagnostics);
|
||||
|
||||
const entriesResult = await env.listDir(dir);
|
||||
if (!entriesResult.ok) {
|
||||
diagnostics.push({ type: "warning", code: "list_failed", message: entriesResult.error.message, path: dir });
|
||||
return { skills, diagnostics };
|
||||
}
|
||||
const entries = entriesResult.value;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name !== "SKILL.md") continue;
|
||||
const fullPath = entry.path;
|
||||
const kind = await resolveKind(env, entry);
|
||||
const kind = await resolveKind(env, entry, diagnostics);
|
||||
if (kind !== "file") continue;
|
||||
const relPath = relativeEnvPath(rootDir, fullPath);
|
||||
if (ignoreMatcher.ignores(relPath)) continue;
|
||||
@@ -119,7 +156,7 @@ async function loadSkillsFromDirInternal(
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
||||
const fullPath = entry.path;
|
||||
const kind = await resolveKind(env, entry);
|
||||
const kind = await resolveKind(env, entry, diagnostics);
|
||||
if (!kind) continue;
|
||||
|
||||
const relPath = relativeEnvPath(rootDir, fullPath);
|
||||
@@ -133,7 +170,8 @@ async function loadSkillsFromDirInternal(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kind !== "file" || !includeRootFiles || !entry.name.endsWith(".md")) continue;
|
||||
if (kind !== "file" || !includeRootFiles || !entry.name.endsWith(".md") || isIgnoredRootSkillMarkdown(entry.name))
|
||||
continue;
|
||||
const result = await loadSkillFromFile(env, fullPath);
|
||||
if (result.skill) skills.push(result.skill);
|
||||
diagnostics.push(...result.diagnostics);
|
||||
@@ -142,22 +180,41 @@ async function loadSkillsFromDirInternal(
|
||||
return { skills, diagnostics };
|
||||
}
|
||||
|
||||
async function addIgnoreRules(env: ExecutionEnv, ig: IgnoreMatcher, dir: string, rootDir: string): Promise<void> {
|
||||
async function addIgnoreRules(
|
||||
env: ExecutionEnv,
|
||||
ig: IgnoreMatcher,
|
||||
dir: string,
|
||||
rootDir: string,
|
||||
diagnostics: SkillDiagnostic[],
|
||||
): Promise<void> {
|
||||
const relativeDir = relativeEnvPath(rootDir, dir);
|
||||
const prefix = relativeDir ? `${relativeDir}/` : "";
|
||||
|
||||
for (const filename of IGNORE_FILE_NAMES) {
|
||||
const ignorePath = joinEnvPath(dir, filename);
|
||||
const info = await safeFileInfo(env, ignorePath);
|
||||
if (info?.kind !== "file") continue;
|
||||
try {
|
||||
const content = await env.readTextFile(ignorePath);
|
||||
const patterns = content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => prefixIgnorePattern(line, prefix))
|
||||
.filter((line): line is string => Boolean(line));
|
||||
if (patterns.length > 0) ig.add(patterns);
|
||||
} catch {}
|
||||
const info = await env.fileInfo(ignorePath);
|
||||
if (!info.ok) {
|
||||
if (info.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
code: "file_info_failed",
|
||||
message: info.error.message,
|
||||
path: ignorePath,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (info.value.kind !== "file") continue;
|
||||
const content = await env.readTextFile(ignorePath);
|
||||
if (!content.ok) {
|
||||
diagnostics.push({ type: "warning", code: "read_failed", message: content.error.message, path: ignorePath });
|
||||
continue;
|
||||
}
|
||||
const patterns = content.value
|
||||
.split(/\r?\n/)
|
||||
.map((line) => prefixIgnorePattern(line, prefix))
|
||||
.filter((line): line is string => Boolean(line));
|
||||
if (patterns.length > 0) ig.add(patterns);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,41 +240,52 @@ async function loadSkillFromFile(
|
||||
env: ExecutionEnv,
|
||||
filePath: string,
|
||||
): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> {
|
||||
if (isIgnoredRootSkillMarkdown(basenameEnvPath(filePath))) {
|
||||
return { skill: null, diagnostics: [] };
|
||||
}
|
||||
|
||||
const diagnostics: SkillDiagnostic[] = [];
|
||||
try {
|
||||
const rawContent = await env.readTextFile(filePath);
|
||||
const { frontmatter, body } = parseFrontmatter<SkillFrontmatter>(rawContent);
|
||||
const skillDir = dirnameEnvPath(filePath);
|
||||
const parentDirName = basenameEnvPath(skillDir);
|
||||
|
||||
for (const error of validateDescription(frontmatter.description)) {
|
||||
diagnostics.push({ type: "warning", message: error, path: filePath });
|
||||
}
|
||||
|
||||
const name = frontmatter.name || parentDirName;
|
||||
for (const error of validateName(name, parentDirName)) {
|
||||
diagnostics.push({ type: "warning", message: error, path: filePath });
|
||||
}
|
||||
|
||||
if (!frontmatter.description || frontmatter.description.trim() === "") {
|
||||
return { skill: null, diagnostics };
|
||||
}
|
||||
|
||||
return {
|
||||
skill: {
|
||||
name,
|
||||
description: frontmatter.description,
|
||||
content: body,
|
||||
filePath,
|
||||
disableModelInvocation: frontmatter["disable-model-invocation"] === true,
|
||||
},
|
||||
diagnostics,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "failed to parse skill file";
|
||||
diagnostics.push({ type: "warning", message, path: filePath });
|
||||
const rawContent = await env.readTextFile(filePath);
|
||||
if (!rawContent.ok) {
|
||||
diagnostics.push({ type: "warning", code: "read_failed", message: rawContent.error.message, path: filePath });
|
||||
return { skill: null, diagnostics };
|
||||
}
|
||||
|
||||
const parsed = parseFrontmatter<SkillFrontmatter>(rawContent.value);
|
||||
if (!parsed.ok) {
|
||||
diagnostics.push({ type: "warning", code: "parse_failed", message: parsed.error.message, path: filePath });
|
||||
return { skill: null, diagnostics };
|
||||
}
|
||||
|
||||
const { frontmatter, body } = parsed.value;
|
||||
const skillDir = dirnameEnvPath(filePath);
|
||||
const parentDirName = basenameEnvPath(skillDir);
|
||||
const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined;
|
||||
|
||||
for (const error of validateDescription(description)) {
|
||||
diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath });
|
||||
}
|
||||
|
||||
const frontmatterName = typeof frontmatter.name === "string" ? frontmatter.name : undefined;
|
||||
const name = frontmatterName || parentDirName;
|
||||
for (const error of validateName(name, parentDirName)) {
|
||||
diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath });
|
||||
}
|
||||
|
||||
if (!description || description.trim() === "") {
|
||||
return { skill: null, diagnostics };
|
||||
}
|
||||
|
||||
return {
|
||||
skill: {
|
||||
name,
|
||||
description,
|
||||
content: body,
|
||||
filePath,
|
||||
disableModelInvocation: frontmatter["disable-model-invocation"] === true,
|
||||
},
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
function validateName(name: string, parentDirName: string): string[] {
|
||||
@@ -242,39 +310,53 @@ function validateDescription(description: string | undefined): string[] {
|
||||
return errors;
|
||||
}
|
||||
|
||||
function parseFrontmatter<T extends Record<string, unknown>>(content: string): { frontmatter: T; body: string } {
|
||||
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
if (!normalized.startsWith("---")) return { frontmatter: {} as T, body: normalized };
|
||||
const endIndex = normalized.indexOf("\n---", 3);
|
||||
if (endIndex === -1) return { frontmatter: {} as T, body: normalized };
|
||||
const yamlString = normalized.slice(4, endIndex);
|
||||
const body = normalized.slice(endIndex + 4).trim();
|
||||
return { frontmatter: (parse(yamlString) ?? {}) as T, body };
|
||||
}
|
||||
|
||||
async function safeFileInfo(
|
||||
env: ExecutionEnv,
|
||||
path: string,
|
||||
): Promise<Awaited<ReturnType<ExecutionEnv["fileInfo"]>> | undefined> {
|
||||
function parseFrontmatter<T extends Record<string, unknown>>(
|
||||
content: string,
|
||||
): Result<{ frontmatter: T; body: string }, Error> {
|
||||
try {
|
||||
return await env.fileInfo(path);
|
||||
} catch {
|
||||
return undefined;
|
||||
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
if (!normalized.startsWith("---")) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
|
||||
const endIndex = normalized.indexOf("\n---", 3);
|
||||
if (endIndex === -1) return { ok: true, value: { frontmatter: {} as T, body: normalized } };
|
||||
const yamlString = normalized.slice(4, endIndex);
|
||||
const body = normalized.slice(endIndex + 4).trim();
|
||||
return { ok: true, value: { frontmatter: (parse(yamlString) ?? {}) as T, body } };
|
||||
} catch (error) {
|
||||
return { ok: false, error: toError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveKind(
|
||||
env: ExecutionEnv,
|
||||
info: Awaited<ReturnType<ExecutionEnv["fileInfo"]>>,
|
||||
info: FileInfo,
|
||||
diagnostics: SkillDiagnostic[],
|
||||
): Promise<"file" | "directory" | undefined> {
|
||||
if (info.kind === "file" || info.kind === "directory") return info.kind;
|
||||
try {
|
||||
const realPath = await env.realPath(info.path);
|
||||
const target = await env.fileInfo(realPath);
|
||||
return target.kind === "file" || target.kind === "directory" ? target.kind : undefined;
|
||||
} catch {
|
||||
const canonicalPath = await env.canonicalPath(info.path);
|
||||
if (!canonicalPath.ok) {
|
||||
if (canonicalPath.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
code: "file_info_failed",
|
||||
message: canonicalPath.error.message,
|
||||
path: info.path,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const target = await env.fileInfo(canonicalPath.value);
|
||||
if (!target.ok) {
|
||||
if (target.error.code !== "not_found") {
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
code: "file_info_failed",
|
||||
message: target.error.message,
|
||||
path: info.path,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return target.value.kind === "file" || target.value.kind === "directory" ? target.value.kind : undefined;
|
||||
}
|
||||
|
||||
function joinEnvPath(base: string, child: string): string {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Skill } from "./types.js";
|
||||
import type { Skill } from "./types.ts";
|
||||
|
||||
export function formatSkillsForSystemPrompt(skills: Skill[]): string {
|
||||
const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation);
|
||||
|
||||
@@ -1,7 +1,41 @@
|
||||
import type { ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
|
||||
import type { QueueMode } from "../agent.js";
|
||||
import type { AgentEvent, AgentMessage, AgentTool, ThinkingLevel } from "../index.js";
|
||||
import type { Session } from "./session/session.js";
|
||||
import type { ImageContent, Model, SimpleStreamOptions, TextContent, Transport } from "@earendil-works/pi-ai/base";
|
||||
import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../types.ts";
|
||||
import type { Session } from "./session/session.ts";
|
||||
|
||||
/** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */
|
||||
export type Result<TValue, TError> = { ok: true; value: TValue } | { ok: false; error: TError };
|
||||
|
||||
/** Create a successful {@link Result}. */
|
||||
export function ok<TValue, TError>(value: TValue): Result<TValue, TError> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
/** Create a failed {@link Result}. */
|
||||
export function err<TValue, TError>(error: TError): Result<TValue, TError> {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
/** Return the success value or throw the failure error. Intended for tests and explicit adapter boundaries. */
|
||||
export function getOrThrow<TValue, TError>(result: Result<TValue, TError>): TValue {
|
||||
if (!result.ok) throw result.error;
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/** Return the success value or `undefined`. Only object values are allowed to avoid truthiness bugs with primitives. */
|
||||
export function getOrUndefined<TValue extends object, TError>(result: Result<TValue, TError>): TValue | undefined {
|
||||
return result.ok ? result.value : undefined;
|
||||
}
|
||||
|
||||
/** Normalize unknown thrown values into Error instances before using them as typed error causes. */
|
||||
export function toError(error: unknown): Error {
|
||||
if (error instanceof Error) return error;
|
||||
if (typeof error === "string") return new Error(error);
|
||||
try {
|
||||
return new Error(JSON.stringify(error));
|
||||
} catch {
|
||||
return new Error(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill loaded from a `SKILL.md` file or provided by an application.
|
||||
@@ -43,11 +77,39 @@ export interface AgentHarnessResources<
|
||||
skills?: TSkill[];
|
||||
}
|
||||
|
||||
/** Kind of filesystem object as addressed by an {@link ExecutionEnv}. Symlinks are not followed automatically. */
|
||||
/** Curated provider request options owned by the harness and snapshotted per turn. */
|
||||
export interface AgentHarnessStreamOptions {
|
||||
/** Preferred transport forwarded to the stream function. */
|
||||
transport?: Transport;
|
||||
/** Provider request timeout in milliseconds. */
|
||||
timeoutMs?: number;
|
||||
/** Maximum provider retry attempts. */
|
||||
maxRetries?: number;
|
||||
/** Optional cap for provider-requested retry delays. */
|
||||
maxRetryDelayMs?: number;
|
||||
/** Additional request headers merged with auth and lifecycle headers. */
|
||||
headers?: Record<string, string>;
|
||||
/** Provider metadata forwarded with requests. */
|
||||
metadata?: SimpleStreamOptions["metadata"];
|
||||
/** Provider cache retention hint. */
|
||||
cacheRetention?: SimpleStreamOptions["cacheRetention"];
|
||||
}
|
||||
|
||||
/** Per-request stream option patch returned by provider hooks. */
|
||||
export interface AgentHarnessStreamOptionsPatch
|
||||
extends Omit<Partial<AgentHarnessStreamOptions>, "headers" | "metadata"> {
|
||||
/** Header patch. `undefined` values delete keys; explicit `headers: undefined` clears all headers. */
|
||||
headers?: Record<string, string | undefined>;
|
||||
/** Metadata patch. `undefined` values delete keys; explicit `metadata: undefined` clears all metadata. */
|
||||
metadata?: Record<string, unknown | undefined>;
|
||||
}
|
||||
|
||||
/** Kind of filesystem object as addressed by a {@link FileSystem}. Symlinks are not followed automatically. */
|
||||
export type FileKind = "file" | "directory" | "symlink";
|
||||
|
||||
/** Stable, backend-independent file error codes thrown by {@link ExecutionEnv} file operations. */
|
||||
/** Stable, backend-independent file error codes returned by {@link FileSystem} file operations. */
|
||||
export type FileErrorCode =
|
||||
| "aborted"
|
||||
| "not_found"
|
||||
| "permission_denied"
|
||||
| "not_directory"
|
||||
@@ -56,28 +118,121 @@ export type FileErrorCode =
|
||||
| "not_supported"
|
||||
| "unknown";
|
||||
|
||||
/** Error thrown by {@link ExecutionEnv} file operations. */
|
||||
/** Error returned by {@link FileSystem} file operations. */
|
||||
export class FileError extends Error {
|
||||
constructor(
|
||||
/** Backend-independent error code. */
|
||||
public code: FileErrorCode,
|
||||
message: string,
|
||||
/** Absolute addressed path associated with the failure, when available. */
|
||||
public path?: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options);
|
||||
/** Backend-independent error code. */
|
||||
public code: FileErrorCode;
|
||||
/** Absolute addressed path associated with the failure, when available. */
|
||||
public path?: string;
|
||||
|
||||
constructor(code: FileErrorCode, message: string, path?: string, cause?: Error) {
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
this.name = "FileError";
|
||||
this.code = code;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
/** Metadata for one filesystem object in an {@link ExecutionEnv}. */
|
||||
/** Stable, backend-independent execution error codes returned by {@link ExecutionEnv.exec}. */
|
||||
export type ExecutionErrorCode =
|
||||
| "aborted"
|
||||
| "timeout"
|
||||
| "shell_unavailable"
|
||||
| "spawn_error"
|
||||
| "callback_error"
|
||||
| "unknown";
|
||||
|
||||
/** Error returned by {@link ExecutionEnv.exec}. */
|
||||
export class ExecutionError extends Error {
|
||||
/** Backend-independent error code. */
|
||||
public code: ExecutionErrorCode;
|
||||
|
||||
constructor(code: ExecutionErrorCode, message: string, cause?: Error) {
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
this.name = "ExecutionError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable compaction error codes returned by compaction helpers. */
|
||||
export type CompactionErrorCode = "aborted" | "summarization_failed" | "invalid_session" | "unknown";
|
||||
|
||||
/** Error returned by compaction helpers. */
|
||||
export class CompactionError extends Error {
|
||||
/** Backend-independent error code. */
|
||||
public code: CompactionErrorCode;
|
||||
|
||||
constructor(code: CompactionErrorCode, message: string, cause?: Error) {
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
this.name = "CompactionError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable branch-summary error codes returned by branch summarization helpers. */
|
||||
export type BranchSummaryErrorCode = "aborted" | "summarization_failed" | "invalid_session";
|
||||
|
||||
/** Error returned by branch summarization helpers. */
|
||||
export class BranchSummaryError extends Error {
|
||||
/** Backend-independent error code. */
|
||||
public code: BranchSummaryErrorCode;
|
||||
|
||||
constructor(code: BranchSummaryErrorCode, message: string, cause?: Error) {
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
this.name = "BranchSummaryError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionErrorCode =
|
||||
| "not_found"
|
||||
| "invalid_session"
|
||||
| "invalid_entry"
|
||||
| "invalid_fork_target"
|
||||
| "storage"
|
||||
| "unknown";
|
||||
|
||||
/** Error thrown by session storage, repositories, and session tree operations. */
|
||||
export class SessionError extends Error {
|
||||
/** Session subsystem error code. */
|
||||
public code: SessionErrorCode;
|
||||
|
||||
constructor(code: SessionErrorCode, message: string, cause?: Error) {
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
this.name = "SessionError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentHarnessErrorCode =
|
||||
| "busy"
|
||||
| "invalid_state"
|
||||
| "invalid_argument"
|
||||
| "session"
|
||||
| "hook"
|
||||
| "auth"
|
||||
| "compaction"
|
||||
| "branch_summary"
|
||||
| "unknown";
|
||||
|
||||
/** Public AgentHarness failure with a stable top-level classification. */
|
||||
export class AgentHarnessError extends Error {
|
||||
public code: AgentHarnessErrorCode;
|
||||
|
||||
constructor(code: AgentHarnessErrorCode, message: string, cause?: Error) {
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
this.name = "AgentHarnessError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Metadata for one filesystem object in a {@link FileSystem}. */
|
||||
export interface FileInfo {
|
||||
/** Basename of {@link path}. */
|
||||
name: string;
|
||||
/** Absolute, syntactically normalized addressed path in the execution environment. Symlinks are not followed. */
|
||||
path: string;
|
||||
/** Object kind. Symlink targets are not followed; use {@link ExecutionEnv.resolvePath} explicitly. */
|
||||
/** Object kind. Symlink targets are not followed; use {@link FileSystem.canonicalPath} explicitly. */
|
||||
kind: FileKind;
|
||||
/** Size in bytes for the addressed filesystem object. */
|
||||
size: number;
|
||||
@@ -85,16 +240,16 @@ export interface FileInfo {
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
/** Options for {@link ExecutionEnv.exec}. */
|
||||
/** Options for {@link Shell.exec}. */
|
||||
export interface ExecutionEnvExecOptions {
|
||||
/** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. */
|
||||
/** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */
|
||||
cwd?: string;
|
||||
/** Additional environment variables for the command. Values override the environment defaults. */
|
||||
/** Additional environment variables for the command. Values override the environment defaults. Defaults to no overrides. */
|
||||
env?: Record<string, string>;
|
||||
/** Timeout in seconds. Implementations should reject when the command exceeds this duration. */
|
||||
/** Timeout in seconds. Implementations should return a timeout error when the command exceeds this duration. Defaults to no timeout. */
|
||||
timeout?: number;
|
||||
/** Abort signal used to terminate the command. */
|
||||
signal?: AbortSignal;
|
||||
/** Abort signal used to terminate the command. Defaults to no abort signal. */
|
||||
abortSignal?: AbortSignal;
|
||||
/** Called with stdout chunks as they are produced. */
|
||||
onStdout?: (chunk: string) => void;
|
||||
/** Called with stderr chunks as they are produced. */
|
||||
@@ -102,50 +257,80 @@ export interface ExecutionEnvExecOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Filesystem and process execution environment used by the harness.
|
||||
* Filesystem capability used by the harness.
|
||||
*
|
||||
* Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by this interface are absolute
|
||||
* addressed paths in the environment, but are not canonicalized through symlinks unless returned by {@link resolvePath}.
|
||||
* Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by file operations are addressed paths
|
||||
* in the filesystem namespace, but are not canonicalized through symlinks unless returned by {@link canonicalPath}.
|
||||
*
|
||||
* File operations throw {@link FileError} for expected filesystem failures such as missing paths or permission errors.
|
||||
* Operation methods must never throw or reject. All filesystem failures, including unexpected backend failures, must be
|
||||
* encoded in the returned {@link Result}. Implementations must preserve this invariant.
|
||||
*/
|
||||
export interface ExecutionEnv {
|
||||
/** Current working directory for relative paths and command execution. */
|
||||
export interface FileSystem {
|
||||
/** Current working directory for relative paths. */
|
||||
cwd: string;
|
||||
|
||||
/** Execute a shell command in {@link cwd} unless `options.cwd` is provided. */
|
||||
/** Return an absolute addressed path without requiring it to exist and without resolving symlinks. */
|
||||
absolutePath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
||||
/** Join path segments in the filesystem namespace without requiring the result to exist. */
|
||||
joinPath(parts: string[], abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
||||
/** Read a UTF-8 text file. */
|
||||
readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
||||
/** Read UTF-8 text lines. Implementations should stop once `maxLines` lines have been read. */
|
||||
readTextLines(
|
||||
path: string,
|
||||
options?: { maxLines?: number; abortSignal?: AbortSignal },
|
||||
): Promise<Result<string[], FileError>>;
|
||||
/** Read a binary file. */
|
||||
readBinaryFile(path: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>>;
|
||||
/** Create or overwrite a file, creating parent directories when supported. */
|
||||
writeFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
|
||||
/** Create or append to a file, creating parent directories when supported. */
|
||||
appendFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
|
||||
/** Return metadata for the addressed path without following symlinks. */
|
||||
fileInfo(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>>;
|
||||
/** List direct children of a directory without following symlinks. */
|
||||
listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
|
||||
/** Return the canonical path for an existing path, resolving symlinks where supported. */
|
||||
canonicalPath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
||||
/** Return false for missing paths. Other errors, such as permission failures, return a {@link FileError}. */
|
||||
exists(path: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
|
||||
/** Create a directory. Defaults: `recursive: true`, no abort signal. */
|
||||
createDir(
|
||||
path: string,
|
||||
options?: { recursive?: boolean; abortSignal?: AbortSignal },
|
||||
): Promise<Result<void, FileError>>;
|
||||
/** Remove a file or directory. Defaults: `recursive: false`, `force: false`, no abort signal. */
|
||||
remove(
|
||||
path: string,
|
||||
options?: { recursive?: boolean; force?: boolean; abortSignal?: AbortSignal },
|
||||
): Promise<Result<void, FileError>>;
|
||||
/** Create a temporary directory and return its absolute path. Defaults: `prefix: "tmp-"`, no abort signal. */
|
||||
createTempDir(prefix?: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
||||
/** Create a temporary file and return its absolute path. Defaults: `prefix: ""`, `suffix: ""`, no abort signal. */
|
||||
createTempFile(options?: {
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<Result<string, FileError>>;
|
||||
|
||||
/** Release filesystem resources. Must be best-effort and must not throw or reject. */
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Shell execution capability used by the harness. */
|
||||
export interface Shell {
|
||||
/** Execute a shell command in {@link FileSystem.cwd} unless `options.cwd` is provided. */
|
||||
exec(
|
||||
command: string,
|
||||
options?: ExecutionEnvExecOptions,
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }>;
|
||||
|
||||
/** Read a UTF-8 text file. Throws {@link FileError}. */
|
||||
readTextFile(path: string): Promise<string>;
|
||||
/** Read a binary file. Throws {@link FileError}. */
|
||||
readBinaryFile(path: string): Promise<Uint8Array>;
|
||||
/** Create or overwrite a file, creating parent directories when supported. Throws {@link FileError}. */
|
||||
writeFile(path: string, content: string | Uint8Array): Promise<void>;
|
||||
/** Return metadata for the addressed path without following symlinks. Throws {@link FileError}. */
|
||||
fileInfo(path: string): Promise<FileInfo>;
|
||||
/** List direct children of a directory without following symlinks. Throws {@link FileError}. */
|
||||
listDir(path: string): Promise<FileInfo[]>;
|
||||
/** Return the canonical path for a path, following symlinks. Throws {@link FileError}. */
|
||||
realPath(path: string): Promise<string>;
|
||||
/** Return false for missing paths. Other errors, such as permission failures, may throw {@link FileError}. */
|
||||
exists(path: string): Promise<boolean>;
|
||||
/** Create a directory. */
|
||||
createDir(path: string, options?: { recursive?: boolean }): Promise<void>;
|
||||
/** Remove a file or directory. */
|
||||
remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
|
||||
/** Create a temporary directory and return its absolute path. */
|
||||
createTempDir(prefix?: string): Promise<string>;
|
||||
/** Create a temporary file and return its absolute path. */
|
||||
createTempFile(options?: { prefix?: string; suffix?: string }): Promise<string>;
|
||||
|
||||
/** Release resources owned by the environment. */
|
||||
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>>;
|
||||
/** Release shell resources. Must be best-effort and must not throw or reject. */
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Filesystem and process execution environment used by the harness. */
|
||||
export interface ExecutionEnv extends FileSystem, Shell {}
|
||||
|
||||
export interface SessionTreeEntryBase {
|
||||
type: string;
|
||||
id: string;
|
||||
@@ -169,6 +354,11 @@ export interface ModelChangeEntry extends SessionTreeEntryBase {
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
export interface ActiveToolsChangeEntry extends SessionTreeEntryBase {
|
||||
type: "active_tools_change";
|
||||
activeToolNames: string[];
|
||||
}
|
||||
|
||||
export interface CompactionEntry<T = unknown> extends SessionTreeEntryBase {
|
||||
type: "compaction";
|
||||
summary: string;
|
||||
@@ -211,21 +401,29 @@ export interface SessionInfoEntry extends SessionTreeEntryBase {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface LeafEntry extends SessionTreeEntryBase {
|
||||
type: "leaf";
|
||||
targetId: string | null;
|
||||
}
|
||||
|
||||
export type SessionTreeEntry =
|
||||
| MessageEntry
|
||||
| ThinkingLevelChangeEntry
|
||||
| ModelChangeEntry
|
||||
| ActiveToolsChangeEntry
|
||||
| CompactionEntry
|
||||
| BranchSummaryEntry
|
||||
| CustomEntry
|
||||
| CustomMessageEntry
|
||||
| LabelEntry
|
||||
| SessionInfoEntry;
|
||||
| SessionInfoEntry
|
||||
| LeafEntry;
|
||||
|
||||
export interface SessionContext {
|
||||
messages: AgentMessage[];
|
||||
thinkingLevel: string;
|
||||
model: { provider: string; modelId: string } | null;
|
||||
activeToolNames: string[] | null;
|
||||
}
|
||||
|
||||
export interface SessionMetadata {
|
||||
@@ -242,6 +440,7 @@ export interface JsonlSessionMetadata extends SessionMetadata {
|
||||
export interface SessionStorage<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
getMetadata(): Promise<TMetadata>;
|
||||
getLeafId(): Promise<string | null>;
|
||||
/** Persist a leaf entry that records the active session-tree leaf. */
|
||||
setLeafId(leafId: string | null): Promise<void>;
|
||||
createEntryId(): Promise<string>;
|
||||
appendEntry(entry: SessionTreeEntry): Promise<void>;
|
||||
@@ -254,7 +453,7 @@ export interface SessionStorage<TMetadata extends SessionMetadata = SessionMetad
|
||||
getEntries(): Promise<SessionTreeEntry[]>;
|
||||
}
|
||||
|
||||
export type { Session } from "./session/session.js";
|
||||
export type { Session } from "./session/session.ts";
|
||||
|
||||
export interface SessionCreateOptions {
|
||||
id?: string;
|
||||
@@ -298,20 +497,6 @@ export type PendingSessionWrite = SessionTreeEntry extends infer TEntry
|
||||
: never
|
||||
: never;
|
||||
|
||||
export interface AgentHarnessTurnState<
|
||||
TSkill extends Skill = Skill,
|
||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||
TTool extends AgentTool = AgentTool,
|
||||
> {
|
||||
messages: AgentMessage[];
|
||||
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
systemPrompt: string;
|
||||
model: Model<any>;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
tools: TTool[];
|
||||
activeTools: TTool[];
|
||||
}
|
||||
|
||||
export interface QueueUpdateEvent {
|
||||
type: "queue_update";
|
||||
steer: AgentMessage[];
|
||||
@@ -353,6 +538,14 @@ export interface ContextEvent {
|
||||
|
||||
export interface BeforeProviderRequestEvent {
|
||||
type: "before_provider_request";
|
||||
model: Model<any>;
|
||||
sessionId: string;
|
||||
streamOptions: AgentHarnessStreamOptions;
|
||||
}
|
||||
|
||||
export interface BeforeProviderPayloadEvent {
|
||||
type: "before_provider_payload";
|
||||
model: Model<any>;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
@@ -407,19 +600,28 @@ export interface SessionTreeEvent {
|
||||
fromHook?: boolean;
|
||||
}
|
||||
|
||||
export interface ModelSelectEvent {
|
||||
type: "model_select";
|
||||
export interface ModelUpdateEvent {
|
||||
type: "model_update";
|
||||
model: Model<any>;
|
||||
previousModel: Model<any> | undefined;
|
||||
source: "set" | "restore";
|
||||
}
|
||||
|
||||
export interface ThinkingLevelSelectEvent {
|
||||
type: "thinking_level_select";
|
||||
export interface ThinkingLevelUpdateEvent {
|
||||
type: "thinking_level_update";
|
||||
level: ThinkingLevel;
|
||||
previousLevel: ThinkingLevel;
|
||||
}
|
||||
|
||||
export interface ToolsUpdateEvent {
|
||||
type: "tools_update";
|
||||
toolNames: string[];
|
||||
previousToolNames: string[];
|
||||
activeToolNames: string[];
|
||||
previousActiveToolNames: string[];
|
||||
source: "set" | "restore";
|
||||
}
|
||||
|
||||
export interface ResourcesUpdateEvent<
|
||||
TSkill extends Skill = Skill,
|
||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||
@@ -440,6 +642,7 @@ export type AgentHarnessOwnEvent<
|
||||
| BeforeAgentStartEvent<TSkill, TPromptTemplate>
|
||||
| ContextEvent
|
||||
| BeforeProviderRequestEvent
|
||||
| BeforeProviderPayloadEvent
|
||||
| AfterProviderResponseEvent
|
||||
| ToolCallEvent
|
||||
| ToolResultEvent
|
||||
@@ -447,9 +650,10 @@ export type AgentHarnessOwnEvent<
|
||||
| SessionCompactEvent
|
||||
| SessionBeforeTreeEvent
|
||||
| SessionTreeEvent
|
||||
| ModelSelectEvent
|
||||
| ThinkingLevelSelectEvent
|
||||
| ResourcesUpdateEvent<TSkill, TPromptTemplate>;
|
||||
| ModelUpdateEvent
|
||||
| ThinkingLevelUpdateEvent
|
||||
| ResourcesUpdateEvent<TSkill, TPromptTemplate>
|
||||
| ToolsUpdateEvent;
|
||||
|
||||
export type AgentHarnessEvent<TSkill extends Skill = Skill, TPromptTemplate extends PromptTemplate = PromptTemplate> =
|
||||
| AgentEvent
|
||||
@@ -465,6 +669,10 @@ export interface ContextResult {
|
||||
}
|
||||
|
||||
export interface BeforeProviderRequestResult {
|
||||
streamOptions?: AgentHarnessStreamOptionsPatch;
|
||||
}
|
||||
|
||||
export interface BeforeProviderPayloadResult {
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
@@ -497,6 +705,7 @@ export type AgentHarnessEventResultMap = {
|
||||
before_agent_start: BeforeAgentStartResult | undefined;
|
||||
context: ContextResult | undefined;
|
||||
before_provider_request: BeforeProviderRequestResult | undefined;
|
||||
before_provider_payload: BeforeProviderPayloadResult | undefined;
|
||||
after_provider_response: undefined;
|
||||
tool_call: ToolCallResult | undefined;
|
||||
tool_result: ToolResultPatch | undefined;
|
||||
@@ -504,9 +713,10 @@ export type AgentHarnessEventResultMap = {
|
||||
session_compact: undefined;
|
||||
session_before_tree: SessionBeforeTreeResult | undefined;
|
||||
session_tree: undefined;
|
||||
model_select: undefined;
|
||||
thinking_level_select: undefined;
|
||||
model_update: undefined;
|
||||
thinking_level_update: undefined;
|
||||
resources_update: undefined;
|
||||
tools_update: undefined;
|
||||
queue_update: undefined;
|
||||
save_point: undefined;
|
||||
abort: undefined;
|
||||
@@ -580,11 +790,9 @@ export interface GenerateBranchSummaryOptions {
|
||||
}
|
||||
|
||||
export interface BranchSummaryResult {
|
||||
summary?: string;
|
||||
readFiles?: string[];
|
||||
modifiedFiles?: string[];
|
||||
aborted?: boolean;
|
||||
error?: string;
|
||||
summary: string;
|
||||
readFiles: string[];
|
||||
modifiedFiles: string[];
|
||||
}
|
||||
|
||||
export interface AgentHarnessOptions<
|
||||
@@ -613,6 +821,8 @@ export interface AgentHarnessOptions<
|
||||
getApiKeyAndHeaders?: (
|
||||
model: Model<any>,
|
||||
) => Promise<{ apiKey: string; headers?: Record<string, string> } | undefined>;
|
||||
/** Curated stream/provider request options. Snapshotted at turn start. */
|
||||
streamOptions?: AgentHarnessStreamOptions;
|
||||
model: Model<any>;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
activeToolNames?: string[];
|
||||
@@ -620,4 +830,4 @@ export interface AgentHarnessOptions<
|
||||
followUpMode?: QueueMode;
|
||||
}
|
||||
|
||||
export type { AgentHarness } from "./agent-harness.js";
|
||||
export type { AgentHarness } from "./agent-harness.ts";
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createWriteStream, type WriteStream } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ExecutionEnv, ExecutionEnvExecOptions } from "../types.js";
|
||||
import { DEFAULT_MAX_BYTES, truncateTail } from "./truncate.js";
|
||||
import {
|
||||
type ExecutionEnv,
|
||||
type ExecutionEnvExecOptions,
|
||||
ExecutionError,
|
||||
err,
|
||||
ok,
|
||||
type Result,
|
||||
toError,
|
||||
} from "../types.ts";
|
||||
import { DEFAULT_MAX_BYTES, truncateTail } from "./truncate.ts";
|
||||
|
||||
export interface ShellCaptureOptions extends Omit<ExecutionEnvExecOptions, "onStdout" | "onStderr"> {
|
||||
onChunk?: (chunk: string) => void;
|
||||
@@ -17,6 +21,12 @@ export interface ShellCaptureResult {
|
||||
fullOutputPath?: string;
|
||||
}
|
||||
|
||||
function toExecutionError(error: unknown): ExecutionError {
|
||||
if (error instanceof ExecutionError) return error;
|
||||
const cause = toError(error);
|
||||
return new ExecutionError("unknown", cause.message, cause);
|
||||
}
|
||||
|
||||
export function sanitizeBinaryOutput(str: string): string {
|
||||
return Array.from(str)
|
||||
.filter((char) => {
|
||||
@@ -34,41 +44,62 @@ export async function executeShellWithCapture(
|
||||
env: ExecutionEnv,
|
||||
command: string,
|
||||
options?: ShellCaptureOptions,
|
||||
): Promise<ShellCaptureResult> {
|
||||
): Promise<Result<ShellCaptureResult, ExecutionError>> {
|
||||
const outputChunks: string[] = [];
|
||||
let outputBytes = 0;
|
||||
const maxOutputBytes = DEFAULT_MAX_BYTES * 2;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
let tempFilePath: string | undefined;
|
||||
let tempFileStream: WriteStream | undefined;
|
||||
let totalBytes = 0;
|
||||
let fullOutputPath: string | undefined;
|
||||
let writeChain: Promise<Result<void, ExecutionError>> = Promise.resolve(ok(undefined));
|
||||
let captureError: ExecutionError | undefined;
|
||||
|
||||
const ensureTempFile = () => {
|
||||
if (tempFilePath) return;
|
||||
const id = randomBytes(8).toString("hex");
|
||||
tempFilePath = join(tmpdir(), `bash-${id}.log`);
|
||||
tempFileStream = createWriteStream(tempFilePath);
|
||||
for (const chunk of outputChunks) {
|
||||
tempFileStream.write(chunk);
|
||||
}
|
||||
const appendFullOutput = (text: string): void => {
|
||||
if (!fullOutputPath || captureError) return;
|
||||
const path = fullOutputPath;
|
||||
writeChain = writeChain.then(async (previous) => {
|
||||
if (!previous.ok) return previous;
|
||||
const appendResult = await env.appendFile(path, text, options?.abortSignal);
|
||||
return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error));
|
||||
});
|
||||
};
|
||||
|
||||
const ensureFullOutputFile = (initialContent: string): void => {
|
||||
if (fullOutputPath || captureError) return;
|
||||
writeChain = writeChain.then(async (previous) => {
|
||||
if (!previous.ok) return previous;
|
||||
const tempFile = await env.createTempFile({
|
||||
prefix: "bash-",
|
||||
suffix: ".log",
|
||||
abortSignal: options?.abortSignal,
|
||||
});
|
||||
if (!tempFile.ok) return err(toExecutionError(tempFile.error));
|
||||
fullOutputPath = tempFile.value;
|
||||
const appendResult = await env.appendFile(tempFile.value, initialContent, options?.abortSignal);
|
||||
return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error));
|
||||
});
|
||||
};
|
||||
|
||||
const onChunk = (chunk: string) => {
|
||||
totalBytes += Buffer.byteLength(chunk, "utf-8");
|
||||
const text = sanitizeBinaryOutput(chunk).replace(/\r/g, "");
|
||||
if (totalBytes > DEFAULT_MAX_BYTES) {
|
||||
ensureTempFile();
|
||||
try {
|
||||
totalBytes += encoder.encode(chunk).byteLength;
|
||||
const text = sanitizeBinaryOutput(chunk).replace(/\r/g, "");
|
||||
if (totalBytes > DEFAULT_MAX_BYTES && !fullOutputPath) {
|
||||
ensureFullOutputFile(outputChunks.join("") + text);
|
||||
} else {
|
||||
appendFullOutput(text);
|
||||
}
|
||||
outputChunks.push(text);
|
||||
outputBytes += text.length;
|
||||
while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
|
||||
const removed = outputChunks.shift()!;
|
||||
outputBytes -= removed.length;
|
||||
}
|
||||
options?.onChunk?.(text);
|
||||
} catch (error) {
|
||||
captureError = toExecutionError(error);
|
||||
}
|
||||
if (tempFileStream) {
|
||||
tempFileStream.write(text);
|
||||
}
|
||||
outputChunks.push(text);
|
||||
outputBytes += text.length;
|
||||
while (outputBytes > maxOutputBytes && outputChunks.length > 1) {
|
||||
const removed = outputChunks.shift()!;
|
||||
outputBytes -= removed.length;
|
||||
}
|
||||
options?.onChunk?.(text);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -77,37 +108,36 @@ export async function executeShellWithCapture(
|
||||
onStdout: onChunk,
|
||||
onStderr: onChunk,
|
||||
});
|
||||
const fullOutput = outputChunks.join("");
|
||||
const truncationResult = truncateTail(fullOutput);
|
||||
if (truncationResult.truncated) {
|
||||
ensureTempFile();
|
||||
const tailOutput = outputChunks.join("");
|
||||
const truncationResult = truncateTail(tailOutput);
|
||||
if (truncationResult.truncated && !fullOutputPath) {
|
||||
ensureFullOutputFile(tailOutput);
|
||||
}
|
||||
tempFileStream?.end();
|
||||
const cancelled = options?.signal?.aborted ?? false;
|
||||
return {
|
||||
output: truncationResult.truncated ? truncationResult.content : fullOutput,
|
||||
exitCode: cancelled ? undefined : result.exitCode,
|
||||
const writeResult = await writeChain;
|
||||
if (!writeResult.ok) return err(writeResult.error);
|
||||
if (captureError) return err(captureError);
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.error.code === "aborted" || options?.abortSignal?.aborted) {
|
||||
return ok({
|
||||
output: truncationResult.truncated ? truncationResult.content : tailOutput,
|
||||
exitCode: undefined,
|
||||
cancelled: true,
|
||||
truncated: truncationResult.truncated,
|
||||
fullOutputPath,
|
||||
});
|
||||
}
|
||||
return err(result.error);
|
||||
}
|
||||
const cancelled = options?.abortSignal?.aborted ?? false;
|
||||
return ok({
|
||||
output: truncationResult.truncated ? truncationResult.content : tailOutput,
|
||||
exitCode: cancelled ? undefined : result.value.exitCode,
|
||||
cancelled,
|
||||
truncated: truncationResult.truncated,
|
||||
fullOutputPath: tempFilePath,
|
||||
};
|
||||
} catch (err) {
|
||||
if (options?.signal?.aborted) {
|
||||
const fullOutput = outputChunks.join("");
|
||||
const truncationResult = truncateTail(fullOutput);
|
||||
if (truncationResult.truncated) {
|
||||
ensureTempFile();
|
||||
}
|
||||
tempFileStream?.end();
|
||||
return {
|
||||
output: truncationResult.truncated ? truncationResult.content : fullOutput,
|
||||
exitCode: undefined,
|
||||
cancelled: true,
|
||||
truncated: truncationResult.truncated,
|
||||
fullOutputPath: tempFilePath,
|
||||
};
|
||||
}
|
||||
tempFileStream?.end();
|
||||
throw err;
|
||||
fullOutputPath,
|
||||
});
|
||||
} catch (error) {
|
||||
return err(toExecutionError(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,64 @@ export interface TruncationOptions {
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
interface RuntimeBuffer {
|
||||
byteLength(content: string, encoding: "utf8"): number;
|
||||
}
|
||||
|
||||
const runtimeBuffer = (globalThis as { Buffer?: RuntimeBuffer }).Buffer;
|
||||
const nonAsciiPattern = /[^\x00-\x7f]/;
|
||||
|
||||
function utf8ByteLength(content: string): number {
|
||||
if (runtimeBuffer) return runtimeBuffer.byteLength(content, "utf8");
|
||||
|
||||
const firstNonAscii = content.search(nonAsciiPattern);
|
||||
if (firstNonAscii === -1) return content.length;
|
||||
|
||||
let bytes = firstNonAscii;
|
||||
for (let i = firstNonAscii; i < content.length; i++) {
|
||||
const code = content.charCodeAt(i);
|
||||
if (code <= 0x7f) {
|
||||
bytes += 1;
|
||||
} else if (code <= 0x7ff) {
|
||||
bytes += 2;
|
||||
} else if (code >= 0xd800 && code <= 0xdbff && i + 1 < content.length) {
|
||||
const next = content.charCodeAt(i + 1);
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
bytes += 4;
|
||||
i++;
|
||||
} else {
|
||||
bytes += 3;
|
||||
}
|
||||
} else {
|
||||
bytes += 3;
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function replaceUnpairedSurrogates(content: string): string {
|
||||
let output = "";
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const code = content.charCodeAt(i);
|
||||
if (code >= 0xd800 && code <= 0xdbff) {
|
||||
if (i + 1 < content.length) {
|
||||
const next = content.charCodeAt(i + 1);
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
output += content[i] + content[i + 1];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
output += "<22>";
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
output += "<22>";
|
||||
} else {
|
||||
output += content[i];
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes as human-readable size.
|
||||
*/
|
||||
@@ -68,7 +126,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
|
||||
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
||||
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
|
||||
const totalBytes = Buffer.byteLength(content, "utf-8");
|
||||
const totalBytes = utf8ByteLength(content);
|
||||
const lines = content.split("\n");
|
||||
const totalLines = lines.length;
|
||||
|
||||
@@ -90,7 +148,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
|
||||
}
|
||||
|
||||
// Check if first line alone exceeds byte limit
|
||||
const firstLineBytes = Buffer.byteLength(lines[0], "utf-8");
|
||||
const firstLineBytes = utf8ByteLength(lines[0]);
|
||||
if (firstLineBytes > maxBytes) {
|
||||
return {
|
||||
content: "",
|
||||
@@ -114,7 +172,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
|
||||
|
||||
for (let i = 0; i < lines.length && i < maxLines; i++) {
|
||||
const line = lines[i];
|
||||
const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0); // +1 for newline
|
||||
const lineBytes = utf8ByteLength(line) + (i > 0 ? 1 : 0); // +1 for newline
|
||||
|
||||
if (outputBytesCount + lineBytes > maxBytes) {
|
||||
truncatedBy = "bytes";
|
||||
@@ -131,7 +189,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
|
||||
}
|
||||
|
||||
const outputContent = outputLinesArr.join("\n");
|
||||
const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
|
||||
const finalOutputBytes = utf8ByteLength(outputContent);
|
||||
|
||||
return {
|
||||
content: outputContent,
|
||||
@@ -158,8 +216,9 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
|
||||
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
||||
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
|
||||
const totalBytes = Buffer.byteLength(content, "utf-8");
|
||||
const totalBytes = utf8ByteLength(content);
|
||||
const lines = content.split("\n");
|
||||
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
||||
const totalLines = lines.length;
|
||||
|
||||
// Check if no truncation needed
|
||||
@@ -187,7 +246,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
|
||||
|
||||
for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {
|
||||
const line = lines[i];
|
||||
const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline
|
||||
const lineBytes = utf8ByteLength(line) + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline
|
||||
|
||||
if (outputBytesCount + lineBytes > maxBytes) {
|
||||
truncatedBy = "bytes";
|
||||
@@ -196,7 +255,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
|
||||
if (outputLinesArr.length === 0) {
|
||||
const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes);
|
||||
outputLinesArr.unshift(truncatedLine);
|
||||
outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8");
|
||||
outputBytesCount = utf8ByteLength(truncatedLine);
|
||||
lastLinePartial = true;
|
||||
}
|
||||
break;
|
||||
@@ -212,7 +271,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
|
||||
}
|
||||
|
||||
const outputContent = outputLinesArr.join("\n");
|
||||
const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
|
||||
const finalOutputBytes = utf8ByteLength(outputContent);
|
||||
|
||||
return {
|
||||
content: outputContent,
|
||||
@@ -234,20 +293,40 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
|
||||
* Handles multi-byte UTF-8 characters correctly.
|
||||
*/
|
||||
function truncateStringToBytesFromEnd(str: string, maxBytes: number): string {
|
||||
const buf = Buffer.from(str, "utf-8");
|
||||
if (buf.length <= maxBytes) {
|
||||
return str;
|
||||
if (maxBytes <= 0) return "";
|
||||
|
||||
let outputBytes = 0;
|
||||
let start = str.length;
|
||||
let needsReplacement = false;
|
||||
for (let i = str.length; i > 0; ) {
|
||||
let characterStart = i - 1;
|
||||
const code = str.charCodeAt(characterStart);
|
||||
let characterBytes: number;
|
||||
let unpairedSurrogate = false;
|
||||
if (code >= 0xdc00 && code <= 0xdfff && characterStart > 0) {
|
||||
const previous = str.charCodeAt(characterStart - 1);
|
||||
if (previous >= 0xd800 && previous <= 0xdbff) {
|
||||
characterStart--;
|
||||
characterBytes = 4;
|
||||
} else {
|
||||
characterBytes = 3;
|
||||
unpairedSurrogate = true;
|
||||
}
|
||||
} else if (code >= 0xd800 && code <= 0xdfff) {
|
||||
characterBytes = 3;
|
||||
unpairedSurrogate = true;
|
||||
} else {
|
||||
characterBytes = code <= 0x7f ? 1 : code <= 0x7ff ? 2 : 3;
|
||||
}
|
||||
if (outputBytes + characterBytes > maxBytes) break;
|
||||
outputBytes += characterBytes;
|
||||
start = characterStart;
|
||||
needsReplacement ||= unpairedSurrogate;
|
||||
i = characterStart;
|
||||
}
|
||||
|
||||
// Start from the end, skip maxBytes back
|
||||
let start = buf.length - maxBytes;
|
||||
|
||||
// Find a valid UTF-8 boundary (start of a character)
|
||||
while (start < buf.length && (buf[start] & 0xc0) === 0x80) {
|
||||
start++;
|
||||
}
|
||||
|
||||
return buf.slice(start).toString("utf-8");
|
||||
const output = str.slice(start);
|
||||
return needsReplacement ? replaceUnpairedSurrogates(output) : output;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,41 +1,5 @@
|
||||
// Core Agent
|
||||
export * from "./agent.js";
|
||||
// Loop functions
|
||||
export * from "./agent-loop.js";
|
||||
export * from "./harness/agent-harness.js";
|
||||
export {
|
||||
collectEntriesForBranchSummary,
|
||||
generateBranchSummary,
|
||||
prepareBranchEntries,
|
||||
} from "./harness/compaction/branch-summarization.js";
|
||||
export {
|
||||
calculateContextTokens,
|
||||
compact,
|
||||
DEFAULT_COMPACTION_SETTINGS,
|
||||
estimateContextTokens,
|
||||
estimateTokens,
|
||||
findCutPoint,
|
||||
findTurnStartIndex,
|
||||
generateSummary,
|
||||
getLastAssistantUsage,
|
||||
prepareCompaction,
|
||||
serializeConversation,
|
||||
shouldCompact,
|
||||
} from "./harness/compaction/compaction.js";
|
||||
export * from "./harness/execution-env.js";
|
||||
export * from "./harness/messages.js";
|
||||
export * from "./harness/prompt-templates.js";
|
||||
export * from "./harness/session/repo/jsonl.js";
|
||||
export * from "./harness/session/repo/memory.js";
|
||||
export * from "./harness/session/repo/shared.js";
|
||||
export * from "./harness/session/session.js";
|
||||
export * from "./harness/skills.js";
|
||||
export * from "./harness/system-prompt.js";
|
||||
// Harness
|
||||
export * from "./harness/types.js";
|
||||
export * from "./harness/utils/shell-output.js";
|
||||
export * from "./harness/utils/truncate.js";
|
||||
// Proxy utilities
|
||||
export * from "./proxy.js";
|
||||
// Types
|
||||
export * from "./types.js";
|
||||
// Import the default pi-ai entrypoint so that all built-in providers register
|
||||
// automatically. Unlike "@earendil-works/pi-ai/base" which does not.
|
||||
import "@earendil-works/pi-ai";
|
||||
|
||||
export * from "./base.ts";
|
||||
|
||||
2
packages/agent/src/node.ts
Normal file
2
packages/agent/src/node.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { NodeExecutionEnv } from "./harness/env/nodejs.ts";
|
||||
export * from "./index.ts";
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type SimpleStreamOptions,
|
||||
type StopReason,
|
||||
type ToolCall,
|
||||
} from "@earendil-works/pi-ai";
|
||||
} from "@earendil-works/pi-ai/base";
|
||||
|
||||
// Create stream class matching ProxyMessageEventStream
|
||||
class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
TextContent,
|
||||
Tool,
|
||||
ToolResultMessage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
} from "@earendil-works/pi-ai/base";
|
||||
import type { Static, TSchema } from "typebox";
|
||||
|
||||
/**
|
||||
@@ -35,6 +35,14 @@ export type StreamFn = (
|
||||
*/
|
||||
export type ToolExecutionMode = "sequential" | "parallel";
|
||||
|
||||
/**
|
||||
* Controls how many queued user messages are injected when the agent loop reaches a queue drain point.
|
||||
*
|
||||
* - "all": drain and inject every queued message at that point.
|
||||
* - "one-at-a-time": drain and inject only the oldest queued message, leaving the rest queued for later drain points.
|
||||
*/
|
||||
export type QueueMode = "all" | "one-at-a-time";
|
||||
|
||||
/** A single tool call content block emitted by an assistant message. */
|
||||
export type AgentToolCall = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
|
||||
|
||||
@@ -346,7 +354,12 @@ export interface AgentToolResult<T> {
|
||||
terminate?: boolean;
|
||||
}
|
||||
|
||||
/** Callback used by tools to stream partial execution updates. */
|
||||
/**
|
||||
* Callback used by tools to stream partial execution updates.
|
||||
*
|
||||
* The callback is scoped to the current `execute()` invocation. Calls made after
|
||||
* the tool promise settles are ignored.
|
||||
*/
|
||||
export type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T>) => void;
|
||||
|
||||
/** Tool definition used by the agent runtime. */
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { Type } from "typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { agentLoop, agentLoopContinue } from "../src/agent-loop.js";
|
||||
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from "../src/types.js";
|
||||
import { agentLoop, agentLoopContinue } from "../src/agent-loop.ts";
|
||||
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from "../src/types.ts";
|
||||
|
||||
// Mock stream for testing - mimics MockAssistantStream
|
||||
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai";
|
||||
import { Type } from "typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Agent } from "../src/index.js";
|
||||
import { Agent, type AgentEvent, type AgentTool, type AgentToolUpdateCallback } from "../src/index.ts";
|
||||
|
||||
// Mock stream that mimics AssistantMessageEventStream
|
||||
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
@@ -36,6 +37,28 @@ function createAssistantMessage(text: string): AssistantMessage {
|
||||
};
|
||||
}
|
||||
|
||||
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
|
||||
|
||||
function createAssistantToolUseMessage(content: ToolCallContent[]): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "mock",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred(): {
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
@@ -242,6 +265,147 @@ describe("Agent", () => {
|
||||
expect(receivedSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("should ignore tool updates after the tool execution settles", async () => {
|
||||
const toolSchema = Type.Object({});
|
||||
let delayedUpdate: AgentToolUpdateCallback<{ status: string }> | undefined;
|
||||
const events: AgentEvent[] = [];
|
||||
const unhandledRejections: unknown[] = [];
|
||||
const onUnhandledRejection = (error: unknown) => {
|
||||
unhandledRejections.push(error);
|
||||
};
|
||||
const tool: AgentTool<typeof toolSchema, { status: string }> = {
|
||||
name: "delayed_tool",
|
||||
label: "Delayed Tool",
|
||||
description: "Captures progress callbacks",
|
||||
parameters: toolSchema,
|
||||
async execute(_toolCallId, _params, _signal, onUpdate) {
|
||||
delayedUpdate = onUpdate;
|
||||
onUpdate?.({
|
||||
content: [{ type: "text", text: "running" }],
|
||||
details: { status: "running" },
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
details: { status: "done" },
|
||||
terminate: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
const agent = new Agent({
|
||||
initialState: { tools: [tool] },
|
||||
streamFn: () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "toolUse",
|
||||
message: createAssistantToolUseMessage([
|
||||
{ type: "toolCall", id: "call-1", name: "delayed_tool", arguments: {} },
|
||||
]),
|
||||
});
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
agent.subscribe((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", onUnhandledRejection);
|
||||
try {
|
||||
await agent.prompt("run tool");
|
||||
const eventCountAfterPrompt = events.length;
|
||||
|
||||
delayedUpdate?.({
|
||||
content: [{ type: "text", text: "late" }],
|
||||
details: { status: "late" },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(events.filter((event) => event.type === "tool_execution_update")).toHaveLength(1);
|
||||
expect(events).toHaveLength(eventCountAfterPrompt);
|
||||
expect(unhandledRejections).toEqual([]);
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandledRejection);
|
||||
}
|
||||
});
|
||||
|
||||
it("should ignore a settled parallel tool update while another tool is still running", async () => {
|
||||
const toolSchema = Type.Object({});
|
||||
const slowStarted = createDeferred();
|
||||
const settledToolEnded = createDeferred();
|
||||
const releaseSlow = createDeferred();
|
||||
let settledToolUpdate: AgentToolUpdateCallback<{ status: string }> | undefined;
|
||||
const events: AgentEvent[] = [];
|
||||
const settledTool: AgentTool<typeof toolSchema, { status: string }> = {
|
||||
name: "settled_tool",
|
||||
label: "Settled Tool",
|
||||
description: "Captures progress callbacks",
|
||||
parameters: toolSchema,
|
||||
async execute(_toolCallId, _params, _signal, onUpdate) {
|
||||
settledToolUpdate = onUpdate;
|
||||
return {
|
||||
content: [{ type: "text", text: "done" }],
|
||||
details: { status: "done" },
|
||||
terminate: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
const slowTool: AgentTool<typeof toolSchema, { status: string }> = {
|
||||
name: "slow_tool",
|
||||
label: "Slow Tool",
|
||||
description: "Keeps the agent run active",
|
||||
parameters: toolSchema,
|
||||
async execute() {
|
||||
slowStarted.resolve();
|
||||
await releaseSlow.promise;
|
||||
return {
|
||||
content: [{ type: "text", text: "done" }],
|
||||
details: { status: "done" },
|
||||
terminate: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
const agent = new Agent({
|
||||
initialState: { tools: [settledTool, slowTool] },
|
||||
streamFn: () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "toolUse",
|
||||
message: createAssistantToolUseMessage([
|
||||
{ type: "toolCall", id: "call-1", name: "settled_tool", arguments: {} },
|
||||
{ type: "toolCall", id: "call-2", name: "slow_tool", arguments: {} },
|
||||
]),
|
||||
});
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
});
|
||||
agent.subscribe((event) => {
|
||||
events.push(event);
|
||||
if (event.type === "tool_execution_end" && event.toolCallId === "call-1") {
|
||||
settledToolEnded.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
const promptPromise = agent.prompt("run tools");
|
||||
await Promise.all([slowStarted.promise, settledToolEnded.promise]);
|
||||
const eventCountBeforeLateUpdate = events.length;
|
||||
|
||||
settledToolUpdate?.({
|
||||
content: [{ type: "text", text: "late" }],
|
||||
details: { status: "late" },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(events).toHaveLength(eventCountBeforeLateUpdate);
|
||||
|
||||
releaseSlow.resolve();
|
||||
await promptPromise;
|
||||
expect(events.filter((event) => event.type === "tool_execution_update")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should update state with mutators", () => {
|
||||
const agent = new Agent();
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
type UserMessage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Agent, type AgentEvent } from "../src/index.js";
|
||||
import { calculateTool } from "./utils/calculate.js";
|
||||
import { Agent, type AgentEvent } from "../src/index.ts";
|
||||
import { calculateTool } from "./utils/calculate.ts";
|
||||
|
||||
const registrations: FauxProviderRegistration[] = [];
|
||||
|
||||
|
||||
206
packages/agent/test/harness/agent-harness-stream.test.ts
Normal file
206
packages/agent/test/harness/agent-harness-stream.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { fauxAssistantMessage, fauxToolCall, registerFauxProvider, type StreamOptions } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AgentHarness } from "../../src/harness/agent-harness.ts";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
|
||||
import { Session } from "../../src/harness/session/session.ts";
|
||||
import { calculateTool } from "../utils/calculate.ts";
|
||||
|
||||
const registrations: Array<{ unregister(): void }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const registration of registrations.splice(0)) {
|
||||
registration.unregister();
|
||||
}
|
||||
});
|
||||
|
||||
function createHarness(options: ConstructorParameters<typeof AgentHarness>[0]): AgentHarness {
|
||||
return new AgentHarness(options);
|
||||
}
|
||||
|
||||
function captureOptions(options: StreamOptions | undefined): StreamOptions {
|
||||
return {
|
||||
...options,
|
||||
headers: options?.headers ? { ...options.headers } : undefined,
|
||||
metadata: options?.metadata ? { ...options.metadata } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentHarness stream configuration", () => {
|
||||
it("snapshots stream options and merges auth headers before provider request hooks", async () => {
|
||||
let capturedOptions: StreamOptions | undefined;
|
||||
const registration = registerFauxProvider();
|
||||
registrations.push(registration);
|
||||
registration.setResponses([
|
||||
(_context, options) => {
|
||||
capturedOptions = options;
|
||||
return fauxAssistantMessage("ok");
|
||||
},
|
||||
]);
|
||||
|
||||
const session = new Session(new InMemorySessionStorage({ metadata: { id: "session-1", createdAt: "now" } }));
|
||||
const harness = createHarness({
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
streamOptions: {
|
||||
timeoutMs: 1000,
|
||||
maxRetries: 2,
|
||||
maxRetryDelayMs: 3000,
|
||||
headers: { "x-base": "base" },
|
||||
metadata: { base: true },
|
||||
cacheRetention: "none",
|
||||
},
|
||||
getApiKeyAndHeaders: async () => ({ apiKey: "secret", headers: { "x-auth": "auth" } }),
|
||||
});
|
||||
|
||||
harness.on("before_provider_request", (event) => {
|
||||
expect(event.sessionId).toBe("session-1");
|
||||
expect(event.streamOptions.headers).toEqual({ "x-base": "base", "x-auth": "auth" });
|
||||
return {
|
||||
streamOptions: {
|
||||
headers: { "x-hook": "hook" },
|
||||
metadata: { hook: true },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await harness.prompt("hello");
|
||||
|
||||
expect(capturedOptions).toMatchObject({
|
||||
apiKey: "secret",
|
||||
timeoutMs: 1000,
|
||||
maxRetries: 2,
|
||||
maxRetryDelayMs: 3000,
|
||||
sessionId: "session-1",
|
||||
cacheRetention: "none",
|
||||
});
|
||||
expect(capturedOptions?.headers).toEqual({ "x-base": "base", "x-auth": "auth", "x-hook": "hook" });
|
||||
expect(capturedOptions?.metadata).toEqual({ base: true, hook: true });
|
||||
});
|
||||
|
||||
it("chains provider request patches and supports deletion semantics", async () => {
|
||||
let capturedOptions: StreamOptions | undefined;
|
||||
const registration = registerFauxProvider();
|
||||
registrations.push(registration);
|
||||
registration.setResponses([
|
||||
(_context, options) => {
|
||||
capturedOptions = options;
|
||||
return fauxAssistantMessage("ok");
|
||||
},
|
||||
]);
|
||||
|
||||
const harness = createHarness({
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
model: registration.getModel(),
|
||||
streamOptions: {
|
||||
timeoutMs: 1000,
|
||||
maxRetries: 2,
|
||||
headers: { keep: "base", remove: "base" },
|
||||
metadata: { keep: "base", remove: "base" },
|
||||
},
|
||||
});
|
||||
|
||||
harness.on("before_provider_request", (event) => {
|
||||
expect(event.streamOptions.headers).toEqual({ keep: "base", remove: "base" });
|
||||
return {
|
||||
streamOptions: {
|
||||
headers: { first: "1", remove: undefined },
|
||||
metadata: { first: 1, remove: undefined },
|
||||
},
|
||||
};
|
||||
});
|
||||
harness.on("before_provider_request", (event) => {
|
||||
expect(event.streamOptions.headers).toEqual({ keep: "base", first: "1" });
|
||||
expect(event.streamOptions.metadata).toEqual({ keep: "base", first: 1 });
|
||||
return {
|
||||
streamOptions: {
|
||||
timeoutMs: undefined,
|
||||
headers: { second: "2" },
|
||||
metadata: undefined,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await harness.prompt("hello");
|
||||
|
||||
expect(capturedOptions?.timeoutMs).toBeUndefined();
|
||||
expect(capturedOptions?.maxRetries).toBe(2);
|
||||
expect(capturedOptions?.headers).toEqual({ keep: "base", first: "1", second: "2" });
|
||||
expect(capturedOptions?.metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses updated stream options for save-point snapshots without mutating the active request", async () => {
|
||||
const capturedOptions: StreamOptions[] = [];
|
||||
const registration = registerFauxProvider();
|
||||
registrations.push(registration);
|
||||
registration.setResponses([
|
||||
(_context, options) => {
|
||||
capturedOptions.push(captureOptions(options));
|
||||
return fauxAssistantMessage(fauxToolCall("calculate", { expression: "1 + 1" }, { id: "call-1" }), {
|
||||
stopReason: "toolUse",
|
||||
});
|
||||
},
|
||||
(_context, options) => {
|
||||
capturedOptions.push(captureOptions(options));
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
|
||||
const harness = createHarness({
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
model: registration.getModel(),
|
||||
tools: [calculateTool],
|
||||
streamOptions: { timeoutMs: 1000, headers: { turn: "first" } },
|
||||
});
|
||||
|
||||
harness.subscribe((event) => {
|
||||
if (event.type === "tool_execution_start") {
|
||||
harness.setStreamOptions({ timeoutMs: 2000, headers: { turn: "second" } });
|
||||
}
|
||||
});
|
||||
|
||||
await harness.prompt("hello");
|
||||
|
||||
expect(capturedOptions).toHaveLength(2);
|
||||
expect(capturedOptions[0].timeoutMs).toBe(1000);
|
||||
expect(capturedOptions[0].headers).toEqual({ turn: "first" });
|
||||
expect(capturedOptions[1].timeoutMs).toBe(2000);
|
||||
expect(capturedOptions[1].headers).toEqual({ turn: "second" });
|
||||
});
|
||||
|
||||
it("chains provider payload hooks", async () => {
|
||||
const seenPayloads: unknown[] = [];
|
||||
let finalPayload: unknown;
|
||||
const registration = registerFauxProvider();
|
||||
registrations.push(registration);
|
||||
registration.setResponses([
|
||||
async (_context, options, _state, model) => {
|
||||
finalPayload = await options?.onPayload?.({ steps: ["provider"] }, model);
|
||||
return fauxAssistantMessage("ok");
|
||||
},
|
||||
]);
|
||||
|
||||
const harness = createHarness({
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
model: registration.getModel(),
|
||||
});
|
||||
|
||||
harness.on("before_provider_payload", (event) => {
|
||||
seenPayloads.push(event.payload);
|
||||
return { payload: { steps: ["provider", "first"] } };
|
||||
});
|
||||
harness.on("before_provider_payload", (event) => {
|
||||
seenPayloads.push(event.payload);
|
||||
return { payload: { steps: ["provider", "first", "second"] } };
|
||||
});
|
||||
|
||||
await harness.prompt("hello");
|
||||
|
||||
expect(seenPayloads).toEqual([{ steps: ["provider"] }, { steps: ["provider", "first"] }]);
|
||||
expect(finalPayload).toEqual({ steps: ["provider", "first", "second"] });
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
import { getModel } from "@earendil-works/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AgentHarness } from "../../src/harness/agent-harness.js";
|
||||
import { NodeExecutionEnv } from "../../src/harness/execution-env.js";
|
||||
import { Session } from "../../src/harness/session/session.js";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
|
||||
import type { PromptTemplate, Skill } from "../../src/harness/types.js";
|
||||
import type { AgentTool } from "../../src/types.js";
|
||||
import { fauxAssistantMessage, fauxToolCall, getModel, registerFauxProvider } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AgentHarness } from "../../src/harness/agent-harness.ts";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
|
||||
import { Session } from "../../src/harness/session/session.ts";
|
||||
import type { PromptTemplate, Skill } from "../../src/harness/types.ts";
|
||||
import type { AgentMessage, AgentTool } from "../../src/types.ts";
|
||||
import { calculateTool } from "../utils/calculate.ts";
|
||||
import { getCurrentTimeTool } from "../utils/get-current-time.ts";
|
||||
|
||||
interface AppSkill extends Skill {
|
||||
source: "project" | "user";
|
||||
@@ -15,10 +17,39 @@ interface AppPromptTemplate extends PromptTemplate {
|
||||
source: "project" | "user";
|
||||
}
|
||||
|
||||
interface AppTool extends AgentTool {
|
||||
source: "builtin" | "extension";
|
||||
const registrations: Array<{ unregister(): void }> = [];
|
||||
|
||||
function textFromUserMessages(messages: Array<{ role: string; content: unknown }>): string[] {
|
||||
return messages.flatMap((message) => {
|
||||
if (message.role !== "user") return [];
|
||||
if (typeof message.content === "string") return [message.content];
|
||||
if (!Array.isArray(message.content)) return [];
|
||||
return message.content.flatMap((part) => {
|
||||
if (!part || typeof part !== "object" || !("type" in part) || part.type !== "text") return [];
|
||||
return "text" in part && typeof part.text === "string" ? [part.text] : [];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve = () => {};
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function getReasoning(options: unknown): unknown {
|
||||
if (!options || typeof options !== "object" || !("reasoning" in options)) return undefined;
|
||||
return options.reasoning;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const registration of registrations.splice(0)) {
|
||||
registration.unregister();
|
||||
}
|
||||
});
|
||||
|
||||
describe("AgentHarness", () => {
|
||||
it("constructs directly and exposes queue modes", () => {
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
@@ -28,25 +59,506 @@ describe("AgentHarness", () => {
|
||||
env,
|
||||
session,
|
||||
model: initialModel,
|
||||
thinkingLevel: "high",
|
||||
systemPrompt: "You are helpful.",
|
||||
steeringMode: "all",
|
||||
followUpMode: "all",
|
||||
});
|
||||
expect(harness.env).toBe(env);
|
||||
expect(harness.agent.state.model).toBe(initialModel);
|
||||
expect(harness.steeringMode).toBe("all");
|
||||
expect(harness.followUpMode).toBe("all");
|
||||
harness.steeringMode = "one-at-a-time";
|
||||
harness.followUpMode = "one-at-a-time";
|
||||
expect(harness.agent.steeringMode).toBe("one-at-a-time");
|
||||
expect(harness.agent.followUpMode).toBe("one-at-a-time");
|
||||
expect(harness.getModel()).toBe(initialModel);
|
||||
expect(harness.getThinkingLevel()).toBe("high");
|
||||
expect(harness.getSteeringMode()).toBe("all");
|
||||
expect(harness.getFollowUpMode()).toBe("all");
|
||||
harness.setSteeringMode("one-at-a-time");
|
||||
harness.setFollowUpMode("one-at-a-time");
|
||||
expect(harness.getSteeringMode()).toBe("one-at-a-time");
|
||||
expect(harness.getFollowUpMode()).toBe("one-at-a-time");
|
||||
});
|
||||
|
||||
it("drains one queued steering message at a time and emits queue updates", async () => {
|
||||
const registration = registerFauxProvider();
|
||||
registrations.push(registration);
|
||||
const userCounts: number[] = [];
|
||||
registration.setResponses([
|
||||
(context) => {
|
||||
userCounts.push(context.messages.filter((message) => message.role === "user").length);
|
||||
return fauxAssistantMessage("first");
|
||||
},
|
||||
(context) => {
|
||||
userCounts.push(context.messages.filter((message) => message.role === "user").length);
|
||||
return fauxAssistantMessage("second");
|
||||
},
|
||||
(context) => {
|
||||
userCounts.push(context.messages.filter((message) => message.role === "user").length);
|
||||
return fauxAssistantMessage("third");
|
||||
},
|
||||
]);
|
||||
const harness = new AgentHarness({
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
model: registration.getModel(),
|
||||
steeringMode: "one-at-a-time",
|
||||
});
|
||||
const steerQueueLengths: number[] = [];
|
||||
let queued = false;
|
||||
harness.subscribe((event) => {
|
||||
if (event.type === "queue_update") {
|
||||
steerQueueLengths.push(event.steer.length);
|
||||
}
|
||||
if (event.type === "message_start" && event.message.role === "assistant" && !queued) {
|
||||
queued = true;
|
||||
harness.steer("one");
|
||||
harness.steer("two");
|
||||
}
|
||||
});
|
||||
|
||||
await harness.prompt("hello");
|
||||
|
||||
expect(userCounts).toEqual([1, 2, 3]);
|
||||
expect(steerQueueLengths).toEqual([1, 2, 1, 0]);
|
||||
});
|
||||
|
||||
it("appends before_agent_start messages and persists them", async () => {
|
||||
const registration = registerFauxProvider();
|
||||
registrations.push(registration);
|
||||
let requestText: string[] = [];
|
||||
registration.setResponses([
|
||||
(context) => {
|
||||
requestText = textFromUserMessages(context.messages);
|
||||
return fauxAssistantMessage("ok");
|
||||
},
|
||||
]);
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const harness = new AgentHarness({
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
});
|
||||
harness.on("before_agent_start", () => ({
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hook" }], timestamp: Date.now() }],
|
||||
}));
|
||||
|
||||
await harness.prompt("hello");
|
||||
|
||||
const persistedText = (await session.getEntries()).flatMap((entry) => {
|
||||
if (entry.type !== "message" || entry.message.role !== "user") return [];
|
||||
const content = entry.message.content;
|
||||
if (typeof content === "string") return [content];
|
||||
return content.flatMap((part) => (part.type === "text" ? [part.text] : []));
|
||||
});
|
||||
expect(requestText).toEqual(["hello", "hook"]);
|
||||
expect(persistedText).toEqual(["hello", "hook"]);
|
||||
});
|
||||
|
||||
it("abort clears steer and follow-up queues but preserves next-turn messages", async () => {
|
||||
const registration = registerFauxProvider();
|
||||
registrations.push(registration);
|
||||
let releaseFirstResponse: (() => void) | undefined;
|
||||
let abortedSignal: AbortSignal | undefined;
|
||||
const firstResponseReleased = new Promise<void>((resolve) => {
|
||||
releaseFirstResponse = resolve;
|
||||
});
|
||||
const secondRequestText: string[] = [];
|
||||
registration.setResponses([
|
||||
async (_context, options) => {
|
||||
abortedSignal = options?.signal;
|
||||
await firstResponseReleased;
|
||||
return fauxAssistantMessage("aborted-ish");
|
||||
},
|
||||
(context) => {
|
||||
secondRequestText.push(...textFromUserMessages(context.messages));
|
||||
return fauxAssistantMessage("second");
|
||||
},
|
||||
]);
|
||||
const harness = new AgentHarness({
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
model: registration.getModel(),
|
||||
});
|
||||
const queueUpdates: Array<{ steer: number; followUp: number; nextTurn: number }> = [];
|
||||
harness.subscribe((event) => {
|
||||
if (event.type === "queue_update") {
|
||||
queueUpdates.push({
|
||||
steer: event.steer.length,
|
||||
followUp: event.followUp.length,
|
||||
nextTurn: event.nextTurn.length,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const firstPrompt = harness.prompt("first");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
harness.steer("steer");
|
||||
harness.followUp("follow");
|
||||
harness.nextTurn("next");
|
||||
const abortResultPromise = harness.abort();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(abortedSignal?.aborted).toBe(true);
|
||||
releaseFirstResponse?.();
|
||||
const abortResult = await abortResultPromise;
|
||||
await firstPrompt;
|
||||
await harness.prompt("second");
|
||||
|
||||
expect(abortResult.clearedSteer).toHaveLength(1);
|
||||
expect(abortResult.clearedFollowUp).toHaveLength(1);
|
||||
expect(queueUpdates).toContainEqual({ steer: 0, followUp: 0, nextTurn: 1 });
|
||||
expect(secondRequestText).toEqual(["first", "next", "second"]);
|
||||
});
|
||||
|
||||
it("drains follow-up messages one at a time after the agent would otherwise stop", async () => {
|
||||
const registration = registerFauxProvider();
|
||||
registrations.push(registration);
|
||||
const userCounts: number[] = [];
|
||||
registration.setResponses([
|
||||
(context) => {
|
||||
userCounts.push(context.messages.filter((message) => message.role === "user").length);
|
||||
return fauxAssistantMessage("first");
|
||||
},
|
||||
(context) => {
|
||||
userCounts.push(context.messages.filter((message) => message.role === "user").length);
|
||||
return fauxAssistantMessage("second");
|
||||
},
|
||||
(context) => {
|
||||
userCounts.push(context.messages.filter((message) => message.role === "user").length);
|
||||
return fauxAssistantMessage("third");
|
||||
},
|
||||
]);
|
||||
const harness = new AgentHarness({
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
model: registration.getModel(),
|
||||
followUpMode: "one-at-a-time",
|
||||
});
|
||||
const followUpQueueLengths: number[] = [];
|
||||
let queued = false;
|
||||
harness.subscribe((event) => {
|
||||
if (event.type === "queue_update") {
|
||||
followUpQueueLengths.push(event.followUp.length);
|
||||
}
|
||||
if (event.type === "message_start" && event.message.role === "assistant" && !queued) {
|
||||
queued = true;
|
||||
harness.followUp("one");
|
||||
harness.followUp("two");
|
||||
}
|
||||
});
|
||||
|
||||
await harness.prompt("hello");
|
||||
|
||||
expect(userCounts).toEqual([1, 2, 3]);
|
||||
expect(followUpQueueLengths).toEqual([1, 2, 1, 0]);
|
||||
});
|
||||
|
||||
it("settles thrown hook failures with persisted assistant error messages", async () => {
|
||||
const registration = registerFauxProvider();
|
||||
registrations.push(registration);
|
||||
registration.setResponses([() => fauxAssistantMessage("should not be used")]);
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const harness = new AgentHarness({
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
});
|
||||
const events: string[] = [];
|
||||
harness.subscribe((event) => {
|
||||
events.push(event.type);
|
||||
});
|
||||
harness.on("context", () => {
|
||||
throw new Error("context exploded");
|
||||
});
|
||||
|
||||
const response = await harness.prompt("hello");
|
||||
await expect(harness.prompt("after failure")).resolves.toMatchObject({ role: "assistant" });
|
||||
|
||||
const entries = await session.getEntries();
|
||||
const messages = entries.flatMap((entry) => (entry.type === "message" ? [entry.message] : []));
|
||||
expect(response.stopReason).toBe("error");
|
||||
expect(response.errorMessage).toBe("context exploded");
|
||||
expect(messages[0]?.role).toBe("user");
|
||||
expect(messages[1]).toMatchObject({ role: "assistant", stopReason: "error", errorMessage: "context exploded" });
|
||||
expect(events).toContain("agent_end");
|
||||
expect(events).toContain("settled");
|
||||
});
|
||||
|
||||
it("refreshes model, thinking level, resources, system prompt, and active tools at save points", async () => {
|
||||
const registration = registerFauxProvider({
|
||||
models: [
|
||||
{ id: "first", reasoning: true },
|
||||
{ id: "second", reasoning: true },
|
||||
],
|
||||
});
|
||||
registrations.push(registration);
|
||||
const secondModel = registration.getModel("second");
|
||||
if (!secondModel) throw new Error("missing second faux model");
|
||||
const captured: Array<{ modelId: string; reasoning: unknown; systemPrompt: string; tools: string[] }> = [];
|
||||
registration.setResponses([
|
||||
(context, options, _state, model) => {
|
||||
captured.push({
|
||||
modelId: model.id,
|
||||
reasoning: getReasoning(options),
|
||||
systemPrompt: context.systemPrompt ?? "",
|
||||
tools: context.tools?.map((tool) => tool.name) ?? [],
|
||||
});
|
||||
return fauxAssistantMessage(fauxToolCall("calculate", { expression: "1 + 1" }, { id: "call-1" }), {
|
||||
stopReason: "toolUse",
|
||||
});
|
||||
},
|
||||
(context, options, _state, model) => {
|
||||
captured.push({
|
||||
modelId: model.id,
|
||||
reasoning: getReasoning(options),
|
||||
systemPrompt: context.systemPrompt ?? "",
|
||||
tools: context.tools?.map((tool) => tool.name) ?? [],
|
||||
});
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
const harness = new AgentHarness<Skill, PromptTemplate, AgentTool>({
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
model: registration.getModel(),
|
||||
thinkingLevel: "off",
|
||||
resources: {
|
||||
skills: [{ name: "prompt", description: "prompt", content: "first prompt", filePath: "/skills/prompt" }],
|
||||
},
|
||||
systemPrompt: ({ resources }) => resources.skills?.[0]?.content ?? "missing prompt",
|
||||
tools: [calculateTool],
|
||||
});
|
||||
harness.subscribe((event) => {
|
||||
if (event.type === "tool_execution_start") {
|
||||
void harness.setModel(secondModel);
|
||||
void harness.setThinkingLevel("high");
|
||||
void harness.setResources({
|
||||
skills: [
|
||||
{ name: "prompt", description: "prompt", content: "second prompt", filePath: "/skills/prompt" },
|
||||
],
|
||||
});
|
||||
void harness.setTools([calculateTool, getCurrentTimeTool], [getCurrentTimeTool.name]);
|
||||
}
|
||||
});
|
||||
|
||||
await harness.prompt("hello");
|
||||
|
||||
expect(captured).toEqual([
|
||||
{ modelId: "first", reasoning: undefined, systemPrompt: "first prompt", tools: ["calculate"] },
|
||||
{ modelId: "second", reasoning: "high", systemPrompt: "second prompt", tools: ["get_current_time"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("orders pending listener session writes after agent-emitted messages", async () => {
|
||||
const registration = registerFauxProvider();
|
||||
registrations.push(registration);
|
||||
registration.setResponses([() => fauxAssistantMessage("ok")]);
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const harness = new AgentHarness({
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
});
|
||||
let wrotePendingMessage = false;
|
||||
harness.subscribe(async (event) => {
|
||||
if (event.type === "message_end" && event.message.role === "assistant" && !wrotePendingMessage) {
|
||||
wrotePendingMessage = true;
|
||||
await harness.appendMessage({
|
||||
role: "custom",
|
||||
customType: "listener",
|
||||
content: "listener write",
|
||||
display: true,
|
||||
timestamp: Date.now(),
|
||||
} as AgentMessage);
|
||||
}
|
||||
});
|
||||
|
||||
await harness.prompt("hello");
|
||||
|
||||
const entries = await session.getEntries();
|
||||
const roles = entries.flatMap((entry) => (entry.type === "message" ? [entry.message.role] : []));
|
||||
expect(roles).toEqual(["user", "assistant", "custom"]);
|
||||
});
|
||||
|
||||
it("waitForIdle waits for external run settlement and awaited listeners", async () => {
|
||||
const registration = registerFauxProvider();
|
||||
registrations.push(registration);
|
||||
registration.setResponses([() => fauxAssistantMessage("ok")]);
|
||||
const barrier = deferred();
|
||||
const harness = new AgentHarness({
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session: new Session(new InMemorySessionStorage()),
|
||||
model: registration.getModel(),
|
||||
});
|
||||
let listenerFinished = false;
|
||||
harness.subscribe(async (event) => {
|
||||
if (event.type === "agent_end") {
|
||||
await barrier.promise;
|
||||
listenerFinished = true;
|
||||
}
|
||||
});
|
||||
|
||||
const promptPromise = harness.prompt("hello");
|
||||
let idleResolved = false;
|
||||
const idlePromise = harness.waitForIdle().then(() => {
|
||||
idleResolved = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(idleResolved).toBe(false);
|
||||
expect(listenerFinished).toBe(false);
|
||||
barrier.resolve();
|
||||
await Promise.all([promptPromise, idlePromise]);
|
||||
expect(idleResolved).toBe(true);
|
||||
expect(listenerFinished).toBe(true);
|
||||
});
|
||||
|
||||
it("runs tool_call and tool_result hooks through the direct loop", async () => {
|
||||
const registration = registerFauxProvider();
|
||||
registrations.push(registration);
|
||||
registration.setResponses([
|
||||
() =>
|
||||
fauxAssistantMessage(fauxToolCall("calculate", { expression: "2 + 2" }, { id: "call-1" }), {
|
||||
stopReason: "toolUse",
|
||||
}),
|
||||
]);
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const harness = new AgentHarness({
|
||||
env: new NodeExecutionEnv({ cwd: process.cwd() }),
|
||||
session,
|
||||
model: registration.getModel(),
|
||||
tools: [calculateTool],
|
||||
});
|
||||
const seenToolCalls: Array<{ id: string; name: string; expression: unknown }> = [];
|
||||
harness.on("tool_call", (event) => {
|
||||
seenToolCalls.push({ id: event.toolCallId, name: event.toolName, expression: event.input.expression });
|
||||
return undefined;
|
||||
});
|
||||
harness.on("tool_result", (event) => {
|
||||
expect(event.toolCallId).toBe("call-1");
|
||||
expect(event.toolName).toBe("calculate");
|
||||
return {
|
||||
content: [{ type: "text", text: "patched result" }],
|
||||
details: { patched: true },
|
||||
terminate: true,
|
||||
};
|
||||
});
|
||||
|
||||
await harness.prompt("hello");
|
||||
|
||||
const toolResult = (await session.getEntries()).find(
|
||||
(entry) => entry.type === "message" && entry.message.role === "toolResult",
|
||||
);
|
||||
expect(seenToolCalls).toEqual([{ id: "call-1", name: "calculate", expression: "2 + 2" }]);
|
||||
expect(toolResult).toMatchObject({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "toolResult",
|
||||
content: [{ type: "text", text: "patched result" }],
|
||||
details: { patched: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves app tool types for getters and update events", async () => {
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5");
|
||||
type AppTool = AgentTool<typeof calculateTool.parameters, undefined> & { source: "builtin" | "extension" };
|
||||
const inspectTool: AppTool = { ...calculateTool, name: "inspect", source: "builtin" };
|
||||
const searchTool: AppTool = { ...calculateTool, name: "search", source: "extension" };
|
||||
const harness = new AgentHarness<AppSkill, AppPromptTemplate, AppTool>({
|
||||
env,
|
||||
session,
|
||||
model,
|
||||
tools: [inspectTool, searchTool],
|
||||
activeToolNames: ["inspect"],
|
||||
});
|
||||
const updates: Array<{
|
||||
toolNames: string[];
|
||||
previousToolNames: string[];
|
||||
activeToolNames: string[];
|
||||
previousActiveToolNames: string[];
|
||||
source: "set" | "restore";
|
||||
}> = [];
|
||||
harness.subscribe((event) => {
|
||||
if (event.type === "tools_update") {
|
||||
updates.push({
|
||||
toolNames: event.toolNames,
|
||||
previousToolNames: event.previousToolNames,
|
||||
activeToolNames: event.activeToolNames,
|
||||
previousActiveToolNames: event.previousActiveToolNames,
|
||||
source: event.source,
|
||||
});
|
||||
expect(harness.getActiveTools().map((tool) => tool.name)).toEqual(event.activeToolNames);
|
||||
}
|
||||
});
|
||||
|
||||
const tools = harness.getTools();
|
||||
const activeTools = harness.getActiveTools();
|
||||
tools.pop();
|
||||
activeTools.pop();
|
||||
expect(harness.getTools().map((tool) => tool.name)).toEqual(["inspect", "search"]);
|
||||
expect(harness.getActiveTools().map((tool) => tool.source)).toEqual(["builtin"]);
|
||||
|
||||
await harness.setActiveTools(["search"]);
|
||||
await harness.setTools([searchTool], ["search"]);
|
||||
await expect(harness.setActiveTools(["missing"])).rejects.toMatchObject({ code: "invalid_argument" });
|
||||
await expect(harness.setActiveTools(["search", "search"])).rejects.toMatchObject({ code: "invalid_argument" });
|
||||
await expect(harness.setTools([inspectTool])).rejects.toMatchObject({ code: "invalid_argument" });
|
||||
await expect(harness.setTools([inspectTool, inspectTool], ["inspect"])).rejects.toMatchObject({
|
||||
code: "invalid_argument",
|
||||
});
|
||||
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
toolNames: ["inspect", "search"],
|
||||
previousToolNames: ["inspect", "search"],
|
||||
activeToolNames: ["search"],
|
||||
previousActiveToolNames: ["inspect"],
|
||||
source: "set",
|
||||
},
|
||||
{
|
||||
toolNames: ["search"],
|
||||
previousToolNames: ["inspect", "search"],
|
||||
activeToolNames: ["search"],
|
||||
previousActiveToolNames: ["search"],
|
||||
source: "set",
|
||||
},
|
||||
]);
|
||||
expect(harness.getTools().map((tool) => tool.source)).toEqual(["extension"]);
|
||||
expect(harness.getActiveTools().map((tool) => tool.name)).toEqual(["search"]);
|
||||
expect((await session.buildContext()).activeToolNames).toEqual(["search"]);
|
||||
});
|
||||
|
||||
it("validates constructor tool names", () => {
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5");
|
||||
expect(
|
||||
() => new AgentHarness({ env, session, model, tools: [calculateTool], activeToolNames: ["missing"] }),
|
||||
).toThrow(/Unknown tool/);
|
||||
expect(
|
||||
() =>
|
||||
new AgentHarness({
|
||||
env,
|
||||
session,
|
||||
model,
|
||||
tools: [calculateTool, calculateTool],
|
||||
activeToolNames: [calculateTool.name],
|
||||
}),
|
||||
).toThrow(/Duplicate tool/);
|
||||
expect(
|
||||
() =>
|
||||
new AgentHarness({
|
||||
env,
|
||||
session,
|
||||
model,
|
||||
tools: [calculateTool],
|
||||
activeToolNames: [calculateTool.name, calculateTool.name],
|
||||
}),
|
||||
).toThrow(/Duplicate active tool/);
|
||||
});
|
||||
|
||||
it("preserves app resource types for getters and update events", async () => {
|
||||
const session = new Session(new InMemorySessionStorage());
|
||||
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5");
|
||||
const harness = new AgentHarness<AppSkill, AppPromptTemplate, AppTool>({ env, session, model });
|
||||
const harness = new AgentHarness<AppSkill, AppPromptTemplate, AgentTool>({ env, session, model });
|
||||
const skill: AppSkill = {
|
||||
name: "inspect",
|
||||
description: "Inspect things",
|
||||
|
||||
@@ -2,32 +2,40 @@ import {
|
||||
type AssistantMessage,
|
||||
type FauxProviderRegistration,
|
||||
fauxAssistantMessage,
|
||||
type Message,
|
||||
type Model,
|
||||
registerFauxProvider,
|
||||
type Usage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type CompactionPreparation,
|
||||
calculateContextTokens,
|
||||
compact,
|
||||
DEFAULT_COMPACTION_SETTINGS,
|
||||
estimateContextTokens,
|
||||
estimateTokens,
|
||||
findCutPoint,
|
||||
findTurnStartIndex,
|
||||
generateSummary,
|
||||
getLastAssistantUsage,
|
||||
prepareCompaction,
|
||||
serializeConversation,
|
||||
shouldCompact,
|
||||
} from "../../src/harness/compaction/compaction.js";
|
||||
import { buildSessionContext } from "../../src/harness/session/session.js";
|
||||
} from "../../src/harness/compaction/compaction.ts";
|
||||
import { buildSessionContext } from "../../src/harness/session/session.ts";
|
||||
import type {
|
||||
BranchSummaryEntry,
|
||||
CompactionEntry,
|
||||
CompactionSettings,
|
||||
CustomMessageEntry,
|
||||
MessageEntry,
|
||||
ModelChangeEntry,
|
||||
SessionTreeEntry,
|
||||
ThinkingLevelChangeEntry,
|
||||
} from "../../src/harness/types.js";
|
||||
import type { AgentMessage } from "../../src/types.js";
|
||||
} from "../../src/harness/types.ts";
|
||||
import { getOrThrow } from "../../src/harness/types.ts";
|
||||
import type { AgentMessage } from "../../src/types.ts";
|
||||
|
||||
let nextId = 0;
|
||||
function createId(): string {
|
||||
@@ -113,14 +121,17 @@ function createModelChangeEntry(provider: string, modelId: string, parentId: str
|
||||
};
|
||||
}
|
||||
|
||||
function createFauxModel(reasoning: boolean): { faux: FauxProviderRegistration; model: Model<string> } {
|
||||
function createFauxModel(
|
||||
reasoning: boolean,
|
||||
maxTokens = 8192,
|
||||
): { faux: FauxProviderRegistration; model: Model<string> } {
|
||||
const faux = registerFauxProvider({
|
||||
models: [
|
||||
{
|
||||
id: reasoning ? "reasoning-model" : "non-reasoning-model",
|
||||
reasoning,
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
maxTokens,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -175,6 +186,133 @@ describe("harness compaction", () => {
|
||||
expect(entries[result.firstKeptEntryIndex]?.type).toBe("message");
|
||||
});
|
||||
|
||||
it("covers cut-point and turn-start edge cases", () => {
|
||||
const thinking = createThinkingLevelEntry("high");
|
||||
const modelChange = createModelChangeEntry("openai", "gpt-4", thinking.id);
|
||||
expect(findCutPoint([thinking, modelChange], 0, 2, 1)).toEqual({
|
||||
firstKeptEntryIndex: 0,
|
||||
turnStartIndex: -1,
|
||||
isSplitTurn: false,
|
||||
});
|
||||
|
||||
const branchSummary: BranchSummaryEntry = {
|
||||
type: "branch_summary",
|
||||
id: createId(),
|
||||
parentId: modelChange.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
fromId: "branch",
|
||||
summary: "branch summary",
|
||||
};
|
||||
const customMessage: CustomMessageEntry = {
|
||||
type: "custom_message",
|
||||
id: createId(),
|
||||
parentId: branchSummary.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
customType: "note",
|
||||
content: "custom content",
|
||||
display: true,
|
||||
};
|
||||
expect(findTurnStartIndex([thinking, branchSummary], 1, 0)).toBe(1);
|
||||
expect(findTurnStartIndex([thinking, customMessage], 1, 0)).toBe(1);
|
||||
expect(findTurnStartIndex([thinking, modelChange], 1, 0)).toBe(-1);
|
||||
|
||||
const result = findCutPoint([thinking, branchSummary, customMessage], 0, 3, 1);
|
||||
expect(result.firstKeptEntryIndex).toBe(0);
|
||||
|
||||
const toolResult = createMessageEntry({
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "tool output" }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
expect(findCutPoint([toolResult], 0, 1, 1)).toEqual({
|
||||
firstKeptEntryIndex: 0,
|
||||
turnStartIndex: -1,
|
||||
isSplitTurn: false,
|
||||
});
|
||||
|
||||
const user = createMessageEntry(createUserMessage("user"));
|
||||
const compaction = createCompactionEntry("summary", user.id, user.id);
|
||||
const assistant = createMessageEntry(createAssistantMessage("assistant"), compaction.id);
|
||||
expect(findCutPoint([user, compaction, assistant], 0, 3, 1).firstKeptEntryIndex).toBe(2);
|
||||
});
|
||||
|
||||
it("estimates tokens and context usage across supported message roles", () => {
|
||||
const usage = createMockUsage(10, 5, 3, 2);
|
||||
const assistant = createAssistantMessage("assistant", usage);
|
||||
const assistantWithThinkingAndTool: AssistantMessage = {
|
||||
...assistant,
|
||||
content: [
|
||||
{ type: "thinking", thinking: "thinking" },
|
||||
{ type: "toolCall", id: "call-1", name: "read", arguments: { path: "file.ts" } },
|
||||
],
|
||||
};
|
||||
const customString: AgentMessage = {
|
||||
role: "custom",
|
||||
customType: "note",
|
||||
content: "custom text",
|
||||
display: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const toolResultWithImage: AgentMessage = {
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "read",
|
||||
content: [
|
||||
{ type: "text", text: "tool text" },
|
||||
{ type: "image", mimeType: "image/png", data: "abc" },
|
||||
],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const bashExecution: AgentMessage = {
|
||||
role: "bashExecution",
|
||||
command: "npm run check",
|
||||
output: "ok",
|
||||
exitCode: 0,
|
||||
cancelled: false,
|
||||
truncated: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const branchSummaryMessage: AgentMessage = {
|
||||
role: "branchSummary",
|
||||
summary: "branch",
|
||||
fromId: "x",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const compactionSummaryMessage: AgentMessage = {
|
||||
role: "compactionSummary",
|
||||
summary: "compact",
|
||||
tokensBefore: 123,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
expect(estimateTokens({ role: "user", content: "plain user", timestamp: Date.now() })).toBeGreaterThan(0);
|
||||
expect(estimateTokens(assistantWithThinkingAndTool)).toBeGreaterThan(0);
|
||||
expect(estimateTokens(customString)).toBeGreaterThan(0);
|
||||
expect(estimateTokens(toolResultWithImage)).toBeGreaterThan(1000);
|
||||
expect(estimateTokens(bashExecution)).toBeGreaterThan(0);
|
||||
expect(estimateTokens(branchSummaryMessage)).toBeGreaterThan(0);
|
||||
expect(estimateTokens(compactionSummaryMessage)).toBeGreaterThan(0);
|
||||
expect(estimateTokens({ role: "unknown", timestamp: Date.now() } as unknown as AgentMessage)).toBe(0);
|
||||
expect(
|
||||
getLastAssistantUsage([createMessageEntry(createUserMessage("user")), createMessageEntry(assistant)]),
|
||||
).toBe(usage);
|
||||
expect(
|
||||
getLastAssistantUsage([
|
||||
createMessageEntry({ ...assistant, stopReason: "aborted" }),
|
||||
createMessageEntry({ ...assistant, stopReason: "error" }),
|
||||
]),
|
||||
).toBeUndefined();
|
||||
expect(estimateContextTokens([createUserMessage("no usage")]).lastUsageIndex).toBeNull();
|
||||
expect(estimateContextTokens([assistant, createUserMessage("tail")])).toMatchObject({
|
||||
usageTokens: 20,
|
||||
lastUsageIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("builds session context with a compaction entry", () => {
|
||||
const u1 = createMessageEntry(createUserMessage("1"));
|
||||
const a1 = createMessageEntry(createAssistantMessage("a"), u1.id);
|
||||
@@ -207,13 +345,78 @@ describe("harness compaction", () => {
|
||||
const u3 = createMessageEntry(createUserMessage("user msg 3"), compaction1.id);
|
||||
const a3 = createMessageEntry(createAssistantMessage("assistant msg 3", createMockUsage(8000, 2000)), u3.id);
|
||||
const pathEntries = [u1, a1, u2, a2, compaction1, u3, a3];
|
||||
const preparation = prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS);
|
||||
const preparation = getOrThrow(prepareCompaction(pathEntries, DEFAULT_COMPACTION_SETTINGS));
|
||||
expect(preparation).toBeDefined();
|
||||
expect(preparation?.previousSummary).toBe("First summary");
|
||||
expect(preparation?.firstKeptEntryId).toBeTruthy();
|
||||
expect(preparation?.tokensBefore).toBe(estimateContextTokens(buildSessionContext(pathEntries).messages).tokens);
|
||||
});
|
||||
|
||||
it("prepares split-turn compaction with prior file-operation details", () => {
|
||||
const u1 = createMessageEntry(createUserMessage("user msg 1"));
|
||||
const assistantMessage: AssistantMessage = {
|
||||
...createAssistantMessage("assistant msg 1"),
|
||||
content: [{ type: "toolCall", id: "tool-1", name: "write", arguments: { path: "written.ts" } }],
|
||||
};
|
||||
const a1 = createMessageEntry(assistantMessage, u1.id);
|
||||
const compaction1: CompactionEntry = {
|
||||
...createCompactionEntry("First summary", u1.id, a1.id),
|
||||
details: { readFiles: ["old-read.ts"], modifiedFiles: ["old-edit.ts"] },
|
||||
};
|
||||
const u2 = createMessageEntry(createUserMessage("large turn"), compaction1.id);
|
||||
const a2 = createMessageEntry(createAssistantMessage("large assistant message"), u2.id);
|
||||
const preparation = getOrThrow(
|
||||
prepareCompaction([u1, a1, compaction1, u2, a2], {
|
||||
enabled: true,
|
||||
reserveTokens: 100,
|
||||
keepRecentTokens: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(preparation).toMatchObject({ previousSummary: "First summary", isSplitTurn: true });
|
||||
expect(preparation?.turnPrefixMessages.map((message) => message.role)).toEqual(["user"]);
|
||||
expect([...preparation!.fileOps.read]).toContain("old-read.ts");
|
||||
expect([...preparation!.fileOps.edited]).toContain("old-edit.ts");
|
||||
expect([...preparation!.fileOps.written]).toContain("written.ts");
|
||||
});
|
||||
|
||||
it("prepares custom and branch summary entries for summarization", () => {
|
||||
const branchSummary: BranchSummaryEntry = {
|
||||
type: "branch_summary",
|
||||
id: createId(),
|
||||
parentId: null,
|
||||
timestamp: new Date().toISOString(),
|
||||
fromId: "branch",
|
||||
summary: "branch summary",
|
||||
};
|
||||
const customMessage: CustomMessageEntry = {
|
||||
type: "custom_message",
|
||||
id: createId(),
|
||||
parentId: branchSummary.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
customType: "note",
|
||||
content: "custom content",
|
||||
display: true,
|
||||
};
|
||||
const user = createMessageEntry(createUserMessage("keep"), customMessage.id);
|
||||
const assistant = createMessageEntry(createAssistantMessage("assistant"), user.id);
|
||||
const preparation = getOrThrow(
|
||||
prepareCompaction([branchSummary, customMessage, user, assistant], {
|
||||
enabled: true,
|
||||
reserveTokens: 100,
|
||||
keepRecentTokens: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(preparation?.messagesToSummarize.map((message) => message.role)).toEqual(["branchSummary", "custom"]);
|
||||
});
|
||||
|
||||
it("does not prepare compaction when there is nothing valid to compact", () => {
|
||||
const compaction = createCompactionEntry("already compacted", "entry-keep");
|
||||
expect(getOrThrow(prepareCompaction([compaction], DEFAULT_COMPACTION_SETTINGS))).toBeUndefined();
|
||||
expect(getOrThrow(prepareCompaction([], DEFAULT_COMPACTION_SETTINGS))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("serializes conversation with truncated tool results", () => {
|
||||
const longContent = "x".repeat(5000);
|
||||
const messages = convertMessages([
|
||||
@@ -241,16 +444,18 @@ describe("harness compaction", () => {
|
||||
return fauxAssistantMessage("## Goal\nTest summary");
|
||||
},
|
||||
]);
|
||||
await generateSummary(
|
||||
messages,
|
||||
reasoningModel,
|
||||
2000,
|
||||
"test-key",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"medium",
|
||||
getOrThrow(
|
||||
await generateSummary(
|
||||
messages,
|
||||
reasoningModel,
|
||||
2000,
|
||||
"test-key",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"medium",
|
||||
),
|
||||
);
|
||||
expect(seenOptions[0]).toMatchObject({ reasoning: "medium", apiKey: "test-key" });
|
||||
|
||||
@@ -261,7 +466,9 @@ describe("harness compaction", () => {
|
||||
return fauxAssistantMessage("## Goal\nTest summary");
|
||||
},
|
||||
]);
|
||||
await generateSummary(messages, offModel, 2000, "test-key", undefined, undefined, undefined, undefined, "off");
|
||||
getOrThrow(
|
||||
await generateSummary(messages, offModel, 2000, "test-key", undefined, undefined, undefined, undefined, "off"),
|
||||
);
|
||||
expect(seenOptions[1]).not.toHaveProperty("reasoning");
|
||||
|
||||
const { faux: fauxNonReasoning, model: nonReasoningModel } = createFauxModel(false);
|
||||
@@ -271,20 +478,177 @@ describe("harness compaction", () => {
|
||||
return fauxAssistantMessage("## Goal\nTest summary");
|
||||
},
|
||||
]);
|
||||
await generateSummary(
|
||||
messages,
|
||||
nonReasoningModel,
|
||||
2000,
|
||||
"test-key",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"medium",
|
||||
getOrThrow(
|
||||
await generateSummary(
|
||||
messages,
|
||||
nonReasoningModel,
|
||||
2000,
|
||||
"test-key",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"medium",
|
||||
),
|
||||
);
|
||||
expect(seenOptions[2]).not.toHaveProperty("reasoning");
|
||||
});
|
||||
|
||||
it("includes previous summaries and custom instructions in generateSummary prompts", async () => {
|
||||
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
|
||||
let promptText = "";
|
||||
const { faux, model } = createFauxModel(false);
|
||||
faux.setResponses([
|
||||
(context) => {
|
||||
const message = context.messages[0];
|
||||
const content = message?.role === "user" ? message.content : [];
|
||||
promptText = Array.isArray(content) && content[0]?.type === "text" ? content[0].text : "";
|
||||
return fauxAssistantMessage("## Goal\nTest summary");
|
||||
},
|
||||
]);
|
||||
|
||||
const summary = getOrThrow(
|
||||
await generateSummary(
|
||||
messages,
|
||||
model,
|
||||
2000,
|
||||
"test-key",
|
||||
{ "x-test": "yes" },
|
||||
undefined,
|
||||
"focus",
|
||||
"old summary",
|
||||
),
|
||||
);
|
||||
|
||||
expect(summary).toContain("Test summary");
|
||||
expect(promptText).toContain("<previous-summary>\nold summary\n</previous-summary>");
|
||||
expect(promptText).toContain("Additional focus: focus");
|
||||
});
|
||||
|
||||
it("returns error results for failed or aborted summary generations", async () => {
|
||||
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
|
||||
const { faux: errorFaux, model: errorModel } = createFauxModel(false);
|
||||
errorFaux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "boom" })]);
|
||||
const errorResult = await generateSummary(messages, errorModel, 2000, "test-key");
|
||||
expect(errorResult).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "summarization_failed", message: "Summarization failed: boom" },
|
||||
});
|
||||
|
||||
const { faux: abortedFaux, model: abortedModel } = createFauxModel(false);
|
||||
abortedFaux.setResponses([fauxAssistantMessage("", { stopReason: "aborted", errorMessage: "stopped" })]);
|
||||
const abortedResult = await generateSummary(messages, abortedModel, 2000, "test-key");
|
||||
expect(abortedResult).toMatchObject({ ok: false, error: { code: "aborted", message: "stopped" } });
|
||||
});
|
||||
|
||||
it("clamps compaction summary maxTokens to the model output cap", async () => {
|
||||
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
|
||||
const seenOptions: Array<Record<string, unknown> | undefined> = [];
|
||||
const { faux, model } = createFauxModel(false, 128000);
|
||||
faux.setResponses([
|
||||
(_context, options) => {
|
||||
seenOptions.push(options as Record<string, unknown> | undefined);
|
||||
return fauxAssistantMessage("## Goal\nTest summary");
|
||||
},
|
||||
(_context, options) => {
|
||||
seenOptions.push(options as Record<string, unknown> | undefined);
|
||||
return fauxAssistantMessage("## Goal\nTest summary");
|
||||
},
|
||||
]);
|
||||
const preparation: CompactionPreparation = {
|
||||
firstKeptEntryId: "entry-keep",
|
||||
messagesToSummarize: messages,
|
||||
turnPrefixMessages: messages,
|
||||
isSplitTurn: true,
|
||||
tokensBefore: 600000,
|
||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||
settings: { enabled: true, reserveTokens: 500000, keepRecentTokens: 20000 },
|
||||
};
|
||||
|
||||
getOrThrow(await compact(preparation, model, "test-key"));
|
||||
|
||||
expect(seenOptions.map((options) => options?.maxTokens)).toEqual([128000, 128000]);
|
||||
});
|
||||
|
||||
it("returns compaction error results without throwing", async () => {
|
||||
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
|
||||
const preparation: CompactionPreparation = {
|
||||
firstKeptEntryId: "entry-keep",
|
||||
messagesToSummarize: messages,
|
||||
turnPrefixMessages: [],
|
||||
isSplitTurn: false,
|
||||
tokensBefore: 100,
|
||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
|
||||
};
|
||||
const { faux: historyFaux, model: historyModel } = createFauxModel(false);
|
||||
historyFaux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "history failed" })]);
|
||||
expect(await compact(preparation, historyModel, "test-key")).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "summarization_failed", message: "Summarization failed: history failed" },
|
||||
});
|
||||
|
||||
const { model: invalidModel } = createFauxModel(false);
|
||||
const invalidResult = await compact(
|
||||
{ ...preparation, messagesToSummarize: [], firstKeptEntryId: "" },
|
||||
invalidModel,
|
||||
"test-key",
|
||||
);
|
||||
expect(invalidResult).toMatchObject({ ok: false, error: { code: "invalid_session" } });
|
||||
});
|
||||
|
||||
it("passes reasoning through turn-prefix summaries when enabled", async () => {
|
||||
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
|
||||
const seenOptions: Array<Record<string, unknown> | undefined> = [];
|
||||
const { faux, model } = createFauxModel(true);
|
||||
faux.setResponses([
|
||||
(_context, options) => {
|
||||
seenOptions.push(options as Record<string, unknown> | undefined);
|
||||
return fauxAssistantMessage("## Original Request\nTest summary");
|
||||
},
|
||||
]);
|
||||
const preparation: CompactionPreparation = {
|
||||
firstKeptEntryId: "entry-keep",
|
||||
messagesToSummarize: [],
|
||||
turnPrefixMessages: messages,
|
||||
isSplitTurn: true,
|
||||
tokensBefore: 100,
|
||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
|
||||
};
|
||||
|
||||
getOrThrow(await compact(preparation, model, "test-key", undefined, undefined, undefined, "high"));
|
||||
|
||||
expect(seenOptions[0]).toMatchObject({ reasoning: "high" });
|
||||
});
|
||||
|
||||
it("returns turn-prefix compaction errors without throwing", async () => {
|
||||
const messages: AgentMessage[] = [createUserMessage("Summarize this.")];
|
||||
const preparation: CompactionPreparation = {
|
||||
firstKeptEntryId: "entry-keep",
|
||||
messagesToSummarize: [],
|
||||
turnPrefixMessages: messages,
|
||||
isSplitTurn: true,
|
||||
tokensBefore: 100,
|
||||
fileOps: { read: new Set(), written: new Set(), edited: new Set() },
|
||||
settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 },
|
||||
};
|
||||
const { faux, model } = createFauxModel(false);
|
||||
faux.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "prefix failed" })]);
|
||||
|
||||
expect(await compact(preparation, model, "test-key")).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "summarization_failed", message: "Turn prefix summarization failed: prefix failed" },
|
||||
});
|
||||
|
||||
const { faux: abortedFaux, model: abortedModel } = createFauxModel(false);
|
||||
abortedFaux.setResponses([fauxAssistantMessage("", { stopReason: "aborted", errorMessage: "prefix stopped" })]);
|
||||
expect(await compact(preparation, abortedModel, "test-key")).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "aborted", message: "prefix stopped" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a compaction result with file details", async () => {
|
||||
const u1 = createMessageEntry(createUserMessage("read a file"));
|
||||
const assistantMessage: AssistantMessage = {
|
||||
@@ -294,17 +658,17 @@ describe("harness compaction", () => {
|
||||
const a1 = createMessageEntry(assistantMessage, u1.id);
|
||||
const u2 = createMessageEntry(createUserMessage("continue"), a1.id);
|
||||
const a2 = createMessageEntry(createAssistantMessage("done", createMockUsage(4000, 500)), u2.id);
|
||||
const preparation = prepareCompaction([u1, a1, u2, a2], DEFAULT_COMPACTION_SETTINGS);
|
||||
const preparation = getOrThrow(prepareCompaction([u1, a1, u2, a2], DEFAULT_COMPACTION_SETTINGS));
|
||||
expect(preparation).toBeDefined();
|
||||
const { faux, model } = createFauxModel(false);
|
||||
faux.setResponses([fauxAssistantMessage("## Goal\nTest summary")]);
|
||||
const result = await compact(preparation!, model, "test-key");
|
||||
const result = getOrThrow(await compact(preparation!, model, "test-key"));
|
||||
expect(result.summary.length).toBeGreaterThan(0);
|
||||
expect(result.firstKeptEntryId).toBeTruthy();
|
||||
expect(result.details).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
function convertMessages(messages: any[]): any[] {
|
||||
function convertMessages(messages: Message[]): Message[] {
|
||||
return messages;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { access, chmod, realpath, symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { FileError, NodeExecutionEnv } from "../../src/harness/execution-env.js";
|
||||
import { createTempDir } from "./session-test-utils.js";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { FileError, getOrThrow } from "../../src/harness/types.ts";
|
||||
import { executeShellWithCapture } from "../../src/harness/utils/shell-output.ts";
|
||||
import { createTempDir } from "./session-test-utils.ts";
|
||||
|
||||
const chmodRestorePaths: string[] = [];
|
||||
|
||||
@@ -19,61 +21,69 @@ describe("NodeExecutionEnv", () => {
|
||||
it("reads, writes, lists, and removes files and directories", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.createDir("nested", { recursive: true });
|
||||
await env.writeFile("nested/file.txt", "hello");
|
||||
expect(await env.readTextFile("nested/file.txt")).toBe("hello");
|
||||
expect(Buffer.from(await env.readBinaryFile("nested/file.txt")).toString("utf8")).toBe("hello");
|
||||
expect(getOrThrow(await env.absolutePath("nested/child"))).toBe(join(root, "nested/child"));
|
||||
expect(getOrThrow(await env.joinPath([root, "nested", "child"]))).toBe(join(root, "nested", "child"));
|
||||
getOrThrow(await env.createDir("nested/child"));
|
||||
getOrThrow(await env.writeFile("nested/child/file.txt", "hel"));
|
||||
getOrThrow(await env.appendFile("nested/child/file.txt", "lo"));
|
||||
expect(getOrThrow(await env.readTextFile("nested/child/file.txt"))).toBe("hello");
|
||||
expect(getOrThrow(await env.readTextLines("nested/child/file.txt", { maxLines: 1 }))).toEqual(["hello"]);
|
||||
expect(Buffer.from(getOrThrow(await env.readBinaryFile("nested/child/file.txt"))).toString("utf8")).toBe("hello");
|
||||
|
||||
const entries = await env.listDir("nested");
|
||||
const entries = getOrThrow(await env.listDir("nested/child"));
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toMatchObject({
|
||||
name: "file.txt",
|
||||
path: join(root, "nested/file.txt"),
|
||||
path: join(root, "nested/child/file.txt"),
|
||||
kind: "file",
|
||||
size: 5,
|
||||
});
|
||||
expect(typeof entries[0]!.mtimeMs).toBe("number");
|
||||
|
||||
expect(await env.exists("nested/file.txt")).toBe(true);
|
||||
await env.remove("nested/file.txt");
|
||||
expect(await env.exists("nested/file.txt")).toBe(false);
|
||||
expect(getOrThrow(await env.exists("nested/child/file.txt"))).toBe(true);
|
||||
getOrThrow(await env.remove("nested/child/file.txt"));
|
||||
expect(getOrThrow(await env.exists("nested/child/file.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns fileInfo for files, directories, and symlinks without following symlinks", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.createDir("dir", { recursive: true });
|
||||
await env.writeFile("dir/file.txt", "hello");
|
||||
getOrThrow(await env.createDir("dir", { recursive: true }));
|
||||
getOrThrow(await env.writeFile("dir/file.txt", "hello"));
|
||||
await symlink(join(root, "dir/file.txt"), join(root, "file-link"));
|
||||
await symlink(join(root, "dir"), join(root, "dir-link"));
|
||||
|
||||
expect(await env.fileInfo("dir")).toMatchObject({ name: "dir", path: join(root, "dir"), kind: "directory" });
|
||||
expect(await env.fileInfo("dir/file.txt")).toMatchObject({
|
||||
expect(getOrThrow(await env.fileInfo("dir"))).toMatchObject({
|
||||
name: "dir",
|
||||
path: join(root, "dir"),
|
||||
kind: "directory",
|
||||
});
|
||||
expect(getOrThrow(await env.fileInfo("dir/file.txt"))).toMatchObject({
|
||||
name: "file.txt",
|
||||
path: join(root, "dir/file.txt"),
|
||||
kind: "file",
|
||||
size: 5,
|
||||
});
|
||||
expect(await env.fileInfo("file-link")).toMatchObject({
|
||||
expect(getOrThrow(await env.fileInfo("file-link"))).toMatchObject({
|
||||
name: "file-link",
|
||||
path: join(root, "file-link"),
|
||||
kind: "symlink",
|
||||
});
|
||||
expect(await env.fileInfo("dir-link")).toMatchObject({
|
||||
expect(getOrThrow(await env.fileInfo("dir-link"))).toMatchObject({
|
||||
name: "dir-link",
|
||||
path: join(root, "dir-link"),
|
||||
kind: "symlink",
|
||||
});
|
||||
expect(await env.realPath("file-link")).toBe(await realpath(join(root, "dir/file.txt")));
|
||||
expect(getOrThrow(await env.canonicalPath("file-link"))).toBe(await realpath(join(root, "dir/file.txt")));
|
||||
});
|
||||
|
||||
it("lists symlinks as symlinks", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.writeFile("target.txt", "hello");
|
||||
getOrThrow(await env.writeFile("target.txt", "hello"));
|
||||
await symlink(join(root, "target.txt"), join(root, "link.txt"));
|
||||
|
||||
const entries = await env.listDir(".");
|
||||
const entries = getOrThrow(await env.listDir("."));
|
||||
expect(
|
||||
entries.map((entry) => ({ name: entry.name, kind: entry.kind })).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
).toEqual([
|
||||
@@ -82,68 +92,230 @@ describe("NodeExecutionEnv", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws FileError for missing paths and keeps exists false for missing paths", async () => {
|
||||
it("stops reading text lines at the requested limit", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await expect(env.fileInfo("missing.txt")).rejects.toMatchObject({
|
||||
name: "FileError",
|
||||
code: "not_found",
|
||||
path: join(root, "missing.txt"),
|
||||
});
|
||||
expect(await env.exists("missing.txt")).toBe(false);
|
||||
getOrThrow(await env.writeFile("file.txt", "one\ntwo\nthree"));
|
||||
expect(getOrThrow(await env.readTextLines("file.txt", { maxLines: 1 }))).toEqual(["one"]);
|
||||
});
|
||||
|
||||
it("throws FileError for listing non-directories", async () => {
|
||||
it("returns FileError for missing paths and keeps exists false for missing paths", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.writeFile("file.txt", "hello");
|
||||
await expect(env.listDir("file.txt")).rejects.toBeInstanceOf(FileError);
|
||||
await expect(env.listDir("file.txt")).rejects.toMatchObject({ code: "not_directory" });
|
||||
const info = await env.fileInfo("missing.txt");
|
||||
expect(info.ok).toBe(false);
|
||||
if (!info.ok) {
|
||||
expect(info.error).toBeInstanceOf(FileError);
|
||||
expect(info.error).toMatchObject({
|
||||
name: "FileError",
|
||||
code: "not_found",
|
||||
path: join(root, "missing.txt"),
|
||||
});
|
||||
}
|
||||
expect(getOrThrow(await env.exists("missing.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns FileError for listing non-directories", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
getOrThrow(await env.writeFile("file.txt", "hello"));
|
||||
const result = await env.listDir("file.txt");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toBeInstanceOf(FileError);
|
||||
expect(result.error).toMatchObject({ code: "not_directory" });
|
||||
}
|
||||
});
|
||||
|
||||
it("appends to new files and creates parent directories", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
getOrThrow(await env.appendFile("new/nested/file.txt", "a"));
|
||||
getOrThrow(await env.appendFile("new/nested/file.txt", "b"));
|
||||
expect(getOrThrow(await env.readTextFile("new/nested/file.txt"))).toBe("ab");
|
||||
});
|
||||
|
||||
it("creates temporary directories and files", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const tempDir = await env.createTempDir("node-env-test-");
|
||||
const tempDir = getOrThrow(await env.createTempDir("node-env-test-"));
|
||||
await expect(access(tempDir)).resolves.toBeUndefined();
|
||||
const tempFile = await env.createTempFile({ prefix: "prefix-", suffix: ".txt" });
|
||||
const tempFile = getOrThrow(await env.createTempFile({ prefix: "prefix-", suffix: ".txt" }));
|
||||
await expect(access(tempFile)).resolves.toBeUndefined();
|
||||
expect(tempFile.endsWith(".txt")).toBe(true);
|
||||
});
|
||||
|
||||
it("honors createDir recursive false and remove recursive/force options", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const createResult = await env.createDir("missing/child", { recursive: false });
|
||||
expect(createResult.ok).toBe(false);
|
||||
if (!createResult.ok) expect(createResult.error).toMatchObject({ code: "not_found" });
|
||||
|
||||
getOrThrow(await env.writeFile("dir/child/file.txt", "hello"));
|
||||
const removeDirectory = await env.remove("dir", { recursive: false });
|
||||
expect(removeDirectory.ok).toBe(false);
|
||||
getOrThrow(await env.remove("dir", { recursive: true }));
|
||||
expect(getOrThrow(await env.exists("dir"))).toBe(false);
|
||||
|
||||
const removeMissing = await env.remove("missing", { force: false });
|
||||
expect(removeMissing.ok).toBe(false);
|
||||
getOrThrow(await env.remove("missing", { force: true }));
|
||||
});
|
||||
|
||||
it("returns aborted results for pre-aborted cancellable file operations", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
getOrThrow(await env.writeFile("file.txt", "hello"));
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const signal = controller.signal;
|
||||
|
||||
const results = await Promise.all([
|
||||
env.readTextFile("file.txt", signal),
|
||||
env.readTextLines("file.txt", { abortSignal: signal }),
|
||||
env.readBinaryFile("file.txt", signal),
|
||||
env.writeFile("other.txt", "hello", signal),
|
||||
env.listDir(".", signal),
|
||||
]);
|
||||
for (const result of results) {
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toMatchObject({ code: "aborted" });
|
||||
}
|
||||
});
|
||||
|
||||
it("cleanup is best-effort", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await expect(env.cleanup()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("executes commands in cwd with env overrides", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const result = await env.exec('printf \'%s:%s\' "$PWD" "$NODE_ENV_TEST"', {
|
||||
env: { NODE_ENV_TEST: "ok" },
|
||||
});
|
||||
const result = getOrThrow(
|
||||
await env.exec('printf \'%s:%s\' "$PWD" "$NODE_ENV_TEST"', {
|
||||
env: { NODE_ENV_TEST: "ok" },
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 });
|
||||
});
|
||||
|
||||
it("uses stdin command transport for legacy WSL bash paths", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const root = createTempDir();
|
||||
const shellPath = "C:\\Windows\\System32\\bash.exe";
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
getOrThrow(await env.writeFile(shellPath, '#!/bin/sh\nprintf \'args:%s\\n\' "$*" >&2\nexec /bin/bash "$@"\n'));
|
||||
await chmod(join(root, shellPath), 0o755);
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
const originalPath = process.env.PATH;
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
try {
|
||||
process.chdir(root);
|
||||
process.env.PATH = `${root}${delimiter}${originalPath ?? ""}`;
|
||||
Object.defineProperty(process, "platform", {
|
||||
configurable: true,
|
||||
value: "win32",
|
||||
});
|
||||
|
||||
const wslEnv = new NodeExecutionEnv({ cwd: root, shellPath });
|
||||
const nameExpansion = "$" + "{name}";
|
||||
const result = getOrThrow(await wslEnv.exec(`name='World'; echo "Hello, ${nameExpansion}!"`));
|
||||
|
||||
expect(result).toEqual({ stdout: "Hello, World!\n", stderr: "args:-s\n", exitCode: 0 });
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
process.env.PATH = originalPath;
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, "platform", platformDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("streams stdout and stderr chunks", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const result = await env.exec("printf out; printf err >&2", {
|
||||
onStdout: (chunk) => {
|
||||
stdout += chunk;
|
||||
},
|
||||
onStderr: (chunk) => {
|
||||
stderr += chunk;
|
||||
},
|
||||
});
|
||||
const result = getOrThrow(
|
||||
await env.exec("printf out; printf err >&2", {
|
||||
onStdout: (chunk) => {
|
||||
stdout += chunk;
|
||||
},
|
||||
onStderr: (chunk) => {
|
||||
stderr += chunk;
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ stdout: "out", stderr: "err", exitCode: 0 });
|
||||
expect(stdout).toBe("out");
|
||||
expect(stderr).toBe("err");
|
||||
});
|
||||
|
||||
it("rejects aborted commands", async () => {
|
||||
it("returns non-zero command exit codes as successful execution results", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const result = getOrThrow(await env.exec("exit 7"));
|
||||
expect(result).toEqual({ stdout: "", stderr: "", exitCode: 7 });
|
||||
});
|
||||
|
||||
it("returns timeout errors for commands exceeding the timeout", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const result = await env.exec("sleep 5", { timeout: 0.01 });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toMatchObject({ code: "timeout" });
|
||||
});
|
||||
|
||||
it("returns callback errors from exec stream handlers", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const result = await env.exec("printf out", {
|
||||
onStdout: () => {
|
||||
throw new Error("callback failed");
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toMatchObject({ code: "callback_error", message: "callback failed" });
|
||||
});
|
||||
|
||||
it("returns shell unavailable and spawn errors", async () => {
|
||||
const root = createTempDir();
|
||||
const missingShellEnv = new NodeExecutionEnv({ cwd: root, shellPath: join(root, "missing-shell") });
|
||||
const missingShell = await missingShellEnv.exec("printf ok");
|
||||
expect(missingShell.ok).toBe(false);
|
||||
if (!missingShell.ok) expect(missingShell.error).toMatchObject({ code: "shell_unavailable" });
|
||||
|
||||
const shellPath = join(root, "not-executable-shell");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
getOrThrow(await env.writeFile(shellPath, "not executable"));
|
||||
const spawnErrorEnv = new NodeExecutionEnv({ cwd: root, shellPath });
|
||||
const spawnError = await spawnErrorEnv.exec("printf ok");
|
||||
expect(spawnError.ok).toBe(false);
|
||||
if (!spawnError.ok) expect(spawnError.error).toMatchObject({ code: "spawn_error" });
|
||||
});
|
||||
|
||||
it("returns an aborted result for aborted commands", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const controller = new AbortController();
|
||||
const promise = env.exec("sleep 5", { signal: controller.signal });
|
||||
const promise = env.exec("sleep 5", { abortSignal: controller.signal });
|
||||
controller.abort();
|
||||
await expect(promise).rejects.toThrow("aborted");
|
||||
const result = await promise;
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toMatchObject({ code: "aborted" });
|
||||
});
|
||||
|
||||
it("captures large shell output to a full output file through the execution env", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const result = getOrThrow(await executeShellWithCapture(env, "yes line | head -n 15000"));
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.fullOutputPath).toBeDefined();
|
||||
const fullOutput = getOrThrow(await env.readTextFile(result.fullOutputPath!));
|
||||
expect(fullOutput.split("\n").length).toBeGreaterThan(10000);
|
||||
expect(result.output.length).toBeLessThan(fullOutput.length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NodeExecutionEnv } from "../../src/harness/execution-env.js";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import {
|
||||
formatPromptTemplateInvocation,
|
||||
loadPromptTemplates,
|
||||
loadSourcedPromptTemplates,
|
||||
} from "../../src/harness/prompt-templates.js";
|
||||
import { createTempDir } from "./session-test-utils.js";
|
||||
} from "../../src/harness/prompt-templates.ts";
|
||||
import { createTempDir } from "./session-test-utils.ts";
|
||||
|
||||
describe("loadPromptTemplates", () => {
|
||||
it("loads markdown templates non-recursively from one or more dirs", async () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { JsonlSessionRepo } from "../../src/harness/session/repo/jsonl.js";
|
||||
import { InMemorySessionRepo } from "../../src/harness/session/repo/memory.js";
|
||||
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.js";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { JsonlSessionRepo } from "../../src/harness/session/jsonl-repo.ts";
|
||||
import { InMemorySessionRepo } from "../../src/harness/session/memory-repo.ts";
|
||||
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts";
|
||||
|
||||
describe("InMemorySessionRepo", () => {
|
||||
it("opens, deletes, and forks by metadata", async () => {
|
||||
@@ -26,9 +27,10 @@ describe("InMemorySessionRepo", () => {
|
||||
describe("JsonlSessionRepo", () => {
|
||||
it("stores sessions below encoded cwd directories and lists by cwd", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const cwd = "/tmp/my-project";
|
||||
const otherCwd = "/tmp/other-project";
|
||||
const repo = new JsonlSessionRepo({ sessionsRoot: root });
|
||||
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root });
|
||||
const session = await repo.create({ cwd, id: "019de8c2-de29-73e9-ae0c-e134db34c447" });
|
||||
const otherSession = await repo.create({ cwd: otherCwd, id: "other-session" });
|
||||
const metadata = await session.getMetadata();
|
||||
@@ -44,7 +46,8 @@ describe("JsonlSessionRepo", () => {
|
||||
|
||||
it("opens, deletes, and forks by metadata", async () => {
|
||||
const root = createTempDir();
|
||||
const repo = new JsonlSessionRepo({ sessionsRoot: root });
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root });
|
||||
const source = await repo.create({ cwd: "/tmp/source", id: "source-session" });
|
||||
const sourceMetadata = await source.getMetadata();
|
||||
const user1 = await source.appendMessage(createUserMessage("one"));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatPromptTemplateInvocation } from "../../src/harness/prompt-templates.js";
|
||||
import { formatSkillInvocation } from "../../src/harness/skills.js";
|
||||
import { formatPromptTemplateInvocation } from "../../src/harness/prompt-templates.ts";
|
||||
import { formatSkillInvocation } from "../../src/harness/skills.ts";
|
||||
|
||||
describe("resource formatting helpers", () => {
|
||||
it("formats skill invocations with additional instructions", () => {
|
||||
|
||||
50
packages/agent/test/harness/session-uuid.test.ts
Normal file
50
packages/agent/test/harness/session-uuid.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { uuidv7 } from "../../src/harness/session/uuid.ts";
|
||||
|
||||
const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
const TIMESTAMP = 0x0123456789ab;
|
||||
|
||||
function parseTimestamp(uuid: string): number {
|
||||
return Number.parseInt(uuid.replaceAll("-", "").slice(0, 12), 16);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("uuidv7", () => {
|
||||
it("uses the RFC 9562 layout and preserves monotonic order", () => {
|
||||
const randomValues = [
|
||||
new Uint8Array([0, 0, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xfe, 0x01, 0x11, 0x22, 0x33, 0x44, 0x55]),
|
||||
new Uint8Array(16),
|
||||
new Uint8Array(16),
|
||||
];
|
||||
const getRandomValues = vi.fn((bytes: Uint8Array) => {
|
||||
bytes.set(randomValues.shift() ?? new Uint8Array(bytes.length));
|
||||
return bytes;
|
||||
});
|
||||
vi.stubGlobal("crypto", { getRandomValues });
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(TIMESTAMP);
|
||||
|
||||
try {
|
||||
const first = uuidv7();
|
||||
const second = uuidv7();
|
||||
const third = uuidv7();
|
||||
|
||||
expect(first).toBe("01234567-89ab-7fff-bfff-f91122334455");
|
||||
expect(second).toBe("01234567-89ab-7fff-bfff-fc0000000000");
|
||||
expect(third).toBe("01234567-89ac-7000-8000-000000000000");
|
||||
expect(first).toMatch(UUID_V7_RE);
|
||||
expect(second).toMatch(UUID_V7_RE);
|
||||
expect(third).toMatch(UUID_V7_RE);
|
||||
expect(parseTimestamp(first)).toBe(TIMESTAMP);
|
||||
expect(parseTimestamp(second)).toBe(TIMESTAMP);
|
||||
expect(parseTimestamp(third)).toBe(TIMESTAMP + 1);
|
||||
expect(first < second).toBe(true);
|
||||
expect(second < third).toBe(true);
|
||||
expect(getRandomValues).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Session } from "../../src/harness/session/session.js";
|
||||
import { JsonlSessionStorage } from "../../src/harness/session/storage/jsonl.js";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
|
||||
import type { SessionStorage } from "../../src/harness/types.js";
|
||||
import { createAssistantMessage, createTempDir, createUserMessage, getLatestTempDir } from "./session-test-utils.js";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { JsonlSessionStorage } from "../../src/harness/session/jsonl-storage.ts";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
|
||||
import { Session } from "../../src/harness/session/session.ts";
|
||||
import type { SessionStorage } from "../../src/harness/types.ts";
|
||||
import { createAssistantMessage, createTempDir, createUserMessage, getLatestTempDir } from "./session-test-utils.ts";
|
||||
|
||||
async function runSessionSuite(
|
||||
name: string,
|
||||
@@ -128,7 +129,8 @@ runSessionSuite(
|
||||
"Session with JSONL storage",
|
||||
async () => {
|
||||
const dir = createTempDir();
|
||||
return await JsonlSessionStorage.create(join(dir, "session.jsonl"), { cwd: dir, sessionId: "session-1" });
|
||||
const env = new NodeExecutionEnv({ cwd: dir });
|
||||
return await JsonlSessionStorage.create(env, join(dir, "session.jsonl"), { cwd: dir, sessionId: "session-1" });
|
||||
},
|
||||
() => {
|
||||
const dir = getLatestTempDir();
|
||||
@@ -138,10 +140,10 @@ runSessionSuite(
|
||||
const header = JSON.parse(lines[0]!);
|
||||
expect(header.type).toBe("session");
|
||||
expect(header.version).toBe(3);
|
||||
for (const line of lines.slice(1)) {
|
||||
const entry = JSON.parse(line);
|
||||
const entries = lines.slice(1).map((line) => JSON.parse(line));
|
||||
expect(entries.some((entry) => entry.type === "leaf")).toBe(true);
|
||||
for (const entry of entries) {
|
||||
expect(entry.type).not.toBe("entry");
|
||||
expect(entry.type).not.toBe("leaf");
|
||||
expect(typeof entry.id).toBe("string");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NodeExecutionEnv } from "../../src/harness/execution-env.js";
|
||||
import { loadSkills, loadSourcedSkills } from "../../src/harness/skills.js";
|
||||
import { createTempDir } from "./session-test-utils.js";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { loadSkills, loadSourcedSkills } from "../../src/harness/skills.ts";
|
||||
import { createTempDir } from "./session-test-utils.ts";
|
||||
|
||||
describe("loadSkills", () => {
|
||||
it("loads SKILL.md files through the execution environment", async () => {
|
||||
@@ -93,6 +93,7 @@ Use this skill.
|
||||
expect(diagnostics).toEqual([
|
||||
{
|
||||
type: "warning",
|
||||
code: "invalid_metadata",
|
||||
message: "description is required",
|
||||
path: join(root, "user/broken/SKILL.md"),
|
||||
source: { type: "user" },
|
||||
@@ -112,4 +113,20 @@ Use this skill.
|
||||
expect(skills.map((skill) => skill.name)).toEqual(["skills"]);
|
||||
expect(skills[0]?.content).toBe("Root content");
|
||||
});
|
||||
|
||||
it("ignores README.md at the skills directory root", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
await env.createDir("skills/example", { recursive: true });
|
||||
await env.writeFile("skills/README.md", "# Skills\n\nDirectory documentation.");
|
||||
await env.writeFile(
|
||||
"skills/example/SKILL.md",
|
||||
"---\nname: example\ndescription: Example skill\n---\nUse this skill.",
|
||||
);
|
||||
|
||||
const { skills, diagnostics } = await loadSkills(env, "skills");
|
||||
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(skills.map((skill) => skill.name)).toEqual(["example"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../../src/harness/session/storage/jsonl.js";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
|
||||
import type { MessageEntry, SessionMetadata } from "../../src/harness/types.js";
|
||||
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.js";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../../src/harness/session/jsonl-storage.ts";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
|
||||
import { type MessageEntry, ok, type SessionMetadata } from "../../src/harness/types.ts";
|
||||
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts";
|
||||
|
||||
describe("InMemorySessionStorage", () => {
|
||||
it("returns configured session metadata", async () => {
|
||||
@@ -13,7 +14,7 @@ describe("InMemorySessionStorage", () => {
|
||||
expect(await storage.getMetadata()).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("copies initial entries and tracks leaf independently", async () => {
|
||||
it("copies initial entries and persists leaf changes", async () => {
|
||||
const entry: MessageEntry = {
|
||||
type: "message",
|
||||
id: "entry-1",
|
||||
@@ -28,12 +29,12 @@ describe("InMemorySessionStorage", () => {
|
||||
expect(await storage.getLeafId()).toBe("entry-1");
|
||||
await storage.setLeafId(null);
|
||||
expect(await storage.getLeafId()).toBeNull();
|
||||
expect((await storage.getEntries()).at(-1)).toMatchObject({ type: "leaf", targetId: null });
|
||||
});
|
||||
|
||||
it("rejects invalid leaf ids", async () => {
|
||||
const storage = new InMemorySessionStorage();
|
||||
await expect(storage.setLeafId("missing")).rejects.toThrow("Entry missing not found");
|
||||
expect(() => new InMemorySessionStorage({ leafId: "missing" })).toThrow("Entry missing not found");
|
||||
});
|
||||
|
||||
it("finds entries by type", async () => {
|
||||
@@ -102,14 +103,16 @@ describe("InMemorySessionStorage", () => {
|
||||
describe("JsonlSessionStorage", () => {
|
||||
it("throws for missing files when opening", async () => {
|
||||
const dir = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: dir });
|
||||
const filePath = join(dir, "session.jsonl");
|
||||
await expect(JsonlSessionStorage.open(filePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(JsonlSessionStorage.open(env, filePath)).rejects.toMatchObject({ code: "not_found" });
|
||||
});
|
||||
|
||||
it("writes the header on create", async () => {
|
||||
const dir = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: dir });
|
||||
const filePath = join(dir, "session.jsonl");
|
||||
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
|
||||
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(readFileSync(filePath, "utf8").trim().split("\n")).toHaveLength(1);
|
||||
expect(await storage.getLeafId()).toBeNull();
|
||||
@@ -129,13 +132,15 @@ describe("JsonlSessionStorage", () => {
|
||||
|
||||
it("throws for malformed session headers", async () => {
|
||||
const dir = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: dir });
|
||||
const filePath = join(dir, "session.jsonl");
|
||||
writeFileSync(filePath, "not json\n");
|
||||
await expect(JsonlSessionStorage.open(filePath)).rejects.toThrow("first line is not a valid session header");
|
||||
await expect(JsonlSessionStorage.open(env, filePath)).rejects.toThrow("first line is not a valid session header");
|
||||
});
|
||||
|
||||
it("ignores malformed entry lines", async () => {
|
||||
it("throws for malformed entry lines", async () => {
|
||||
const dir = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: dir });
|
||||
const filePath = join(dir, "session.jsonl");
|
||||
const header = {
|
||||
type: "session",
|
||||
@@ -152,15 +157,14 @@ describe("JsonlSessionStorage", () => {
|
||||
message: createUserMessage("one"),
|
||||
};
|
||||
writeFileSync(filePath, `${JSON.stringify(header)}\nnot json\n${JSON.stringify(entry)}\n`);
|
||||
const storage = await JsonlSessionStorage.open(filePath);
|
||||
expect((await storage.getEntries()).map((loadedEntry) => loadedEntry.id)).toEqual(["entry-1"]);
|
||||
expect(await storage.getLeafId()).toBe("entry-1");
|
||||
await expect(JsonlSessionStorage.open(env, filePath)).rejects.toMatchObject({ code: "invalid_entry" });
|
||||
});
|
||||
|
||||
it("creates and reads session metadata from the header", async () => {
|
||||
const dir = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: dir });
|
||||
const filePath = join(dir, "session.jsonl");
|
||||
const storage = await JsonlSessionStorage.create(filePath, {
|
||||
const storage = await JsonlSessionStorage.create(env, filePath, {
|
||||
cwd: dir,
|
||||
sessionId: "session-1",
|
||||
parentSessionPath: "/tmp/parent.jsonl",
|
||||
@@ -179,13 +183,14 @@ describe("JsonlSessionStorage", () => {
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
message: createUserMessage("one"),
|
||||
});
|
||||
expect(await loadJsonlSessionMetadata(filePath)).toEqual(metadata);
|
||||
expect(await loadJsonlSessionMetadata(env, filePath)).toEqual(metadata);
|
||||
});
|
||||
|
||||
it("loads existing entries and reconstructs leaf", async () => {
|
||||
const dir = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: dir });
|
||||
const filePath = join(dir, "session.jsonl");
|
||||
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
|
||||
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
|
||||
const root: MessageEntry = {
|
||||
type: "message",
|
||||
id: "root",
|
||||
@@ -201,16 +206,21 @@ describe("JsonlSessionStorage", () => {
|
||||
};
|
||||
await storage.appendEntry(root);
|
||||
await storage.appendEntry(child);
|
||||
const loaded = await JsonlSessionStorage.open(filePath);
|
||||
const loaded = await JsonlSessionStorage.open(env, filePath);
|
||||
expect(await loaded.getLeafId()).toBe("child");
|
||||
expect((await loaded.getEntries()).map((entry) => entry.id)).toEqual(["root", "child"]);
|
||||
await loaded.setLeafId("root");
|
||||
const reloaded = await JsonlSessionStorage.open(env, filePath);
|
||||
expect(await reloaded.getLeafId()).toBe("root");
|
||||
expect((await reloaded.getEntries()).at(-1)).toMatchObject({ type: "leaf", targetId: "root" });
|
||||
expect((await loaded.getPathToRoot("child")).map((entry) => entry.id)).toEqual(["root", "child"]);
|
||||
});
|
||||
|
||||
it("finds entries by type", async () => {
|
||||
const dir = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: dir });
|
||||
const filePath = join(dir, "session.jsonl");
|
||||
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
|
||||
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
|
||||
await storage.appendEntry({
|
||||
type: "message",
|
||||
id: "entry-1",
|
||||
@@ -224,8 +234,9 @@ describe("JsonlSessionStorage", () => {
|
||||
|
||||
it("maintains label lookup", async () => {
|
||||
const dir = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: dir });
|
||||
const filePath = join(dir, "session.jsonl");
|
||||
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
|
||||
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
|
||||
await storage.appendEntry({
|
||||
type: "message",
|
||||
id: "entry-1",
|
||||
@@ -252,11 +263,11 @@ describe("JsonlSessionStorage", () => {
|
||||
label: undefined,
|
||||
});
|
||||
expect(await storage.getLabel("entry-1")).toBeUndefined();
|
||||
const loaded = await JsonlSessionStorage.open(filePath);
|
||||
const loaded = await JsonlSessionStorage.open(env, filePath);
|
||||
expect(await loaded.getLabel("entry-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads session metadata from only the first JSONL line", async () => {
|
||||
it("reads session metadata through the line-reading filesystem operation", async () => {
|
||||
const dir = createTempDir();
|
||||
const filePath = join(dir, "session.jsonl");
|
||||
const header = {
|
||||
@@ -266,9 +277,18 @@ describe("JsonlSessionStorage", () => {
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd: dir,
|
||||
};
|
||||
const malformedSecondLine = "{".repeat(10000);
|
||||
writeFileSync(filePath, `${JSON.stringify(header)}\n${malformedSecondLine}\n`);
|
||||
expect(await loadJsonlSessionMetadata(filePath)).toEqual({
|
||||
const metadata = await loadJsonlSessionMetadata(
|
||||
{
|
||||
readTextLines: async () => ok([JSON.stringify(header)]),
|
||||
readTextFile: async () => {
|
||||
throw new Error("readTextFile should not be called for metadata");
|
||||
},
|
||||
writeFile: async () => ok(undefined),
|
||||
appendFile: async () => ok(undefined),
|
||||
},
|
||||
filePath,
|
||||
);
|
||||
expect(metadata).toEqual({
|
||||
id: "session-1",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
cwd: dir,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatSkillsForSystemPrompt } from "../../src/harness/system-prompt.js";
|
||||
import { formatSkillsForSystemPrompt } from "../../src/harness/system-prompt.ts";
|
||||
|
||||
const visibleSkill = {
|
||||
name: "visible",
|
||||
|
||||
169
packages/agent/test/harness/truncate.test.ts
Normal file
169
packages/agent/test/harness/truncate.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { truncateHead, truncateTail } from "../../src/harness/utils/truncate.ts";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function byteLength(content: string): number {
|
||||
return encoder.encode(content).length;
|
||||
}
|
||||
|
||||
function bufferTail(content: string, maxBytes: number): string {
|
||||
const bytes = Buffer.from(content, "utf8");
|
||||
if (bytes.length <= maxBytes) return content;
|
||||
let start = bytes.length - maxBytes;
|
||||
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
|
||||
return bytes.subarray(start).toString("utf8");
|
||||
}
|
||||
|
||||
function assertMatchesBufferTail(input: string, maxByteValues?: readonly number[]): void {
|
||||
const totalBytes = Buffer.byteLength(input, "utf8");
|
||||
const values = maxByteValues ?? Array.from({ length: totalBytes + 5 }, (_, maxBytes) => maxBytes);
|
||||
for (const maxBytes of values) {
|
||||
const result = truncateTail(input, { maxBytes, maxLines: 10 });
|
||||
const expected = bufferTail(input, maxBytes);
|
||||
if (result.content !== expected) {
|
||||
throw new Error(
|
||||
`tail mismatch input=${JSON.stringify(input)} maxBytes=${maxBytes} expected=${JSON.stringify(expected)} actual=${JSON.stringify(result.content)}`,
|
||||
);
|
||||
}
|
||||
const outputBytes = Buffer.byteLength(result.content, "utf8");
|
||||
if (outputBytes > maxBytes) {
|
||||
throw new Error(
|
||||
`tail output exceeded byte limit input=${JSON.stringify(input)} maxBytes=${maxBytes} outputBytes=${outputBytes}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sampledByteLimits(input: string): number[] {
|
||||
const totalBytes = Buffer.byteLength(input, "utf8");
|
||||
const candidates = [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
8,
|
||||
Math.floor(totalBytes / 2) - 1,
|
||||
Math.floor(totalBytes / 2),
|
||||
Math.floor(totalBytes / 2) + 1,
|
||||
totalBytes - 8,
|
||||
totalBytes - 5,
|
||||
totalBytes - 4,
|
||||
totalBytes - 3,
|
||||
totalBytes - 2,
|
||||
totalBytes - 1,
|
||||
totalBytes,
|
||||
totalBytes + 1,
|
||||
totalBytes + 4,
|
||||
];
|
||||
return [...new Set(candidates.filter((value) => value >= 0))].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
describe("truncate utilities", () => {
|
||||
it("counts UTF-8 bytes without Node Buffer", () => {
|
||||
const content = "aé🙂\nb";
|
||||
const result = truncateHead(content, { maxBytes: 100, maxLines: 10 });
|
||||
|
||||
expect(result.truncated).toBe(false);
|
||||
expect(result.totalBytes).toBe(byteLength(content));
|
||||
expect(result.outputBytes).toBe(byteLength(content));
|
||||
expect(result.totalBytes).toBe(9);
|
||||
});
|
||||
|
||||
it("truncates head on UTF-8 byte limits without partial lines", () => {
|
||||
const content = "éé\nabc";
|
||||
const result = truncateHead(content, { maxBytes: 4, maxLines: 10 });
|
||||
|
||||
expect(result.content).toBe("éé");
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.truncatedBy).toBe("bytes");
|
||||
expect(result.outputBytes).toBe(4);
|
||||
expect(result.firstLineExceedsLimit).toBe(false);
|
||||
});
|
||||
|
||||
it("reports head truncation when the first line exceeds the byte limit", () => {
|
||||
const result = truncateHead("éé\nabc", { maxBytes: 3, maxLines: 10 });
|
||||
|
||||
expect(result.content).toBe("");
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.truncatedBy).toBe("bytes");
|
||||
expect(result.firstLineExceedsLimit).toBe(true);
|
||||
});
|
||||
|
||||
it("truncates tail on UTF-8 boundaries when only a partial last line fits", () => {
|
||||
const result = truncateTail("aé🙂b", { maxBytes: 5, maxLines: 10 });
|
||||
|
||||
expect(result.content).toBe("🙂b");
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.truncatedBy).toBe("bytes");
|
||||
expect(result.lastLinePartial).toBe(true);
|
||||
expect(result.outputBytes).toBe(5);
|
||||
});
|
||||
|
||||
it("truncates an oversized single line with a trailing newline", () => {
|
||||
const input = `${"X".repeat(300_000)}\n`;
|
||||
const result = truncateTail(input, { maxBytes: 1024, maxLines: 100 });
|
||||
|
||||
expect(result.content).toBe("X".repeat(1024));
|
||||
expect(result.outputBytes).toBe(1024);
|
||||
expect(result.outputLines).toBe(1);
|
||||
expect(result.lastLinePartial).toBe(true);
|
||||
expect(result.truncatedBy).toBe("bytes");
|
||||
});
|
||||
|
||||
it("drops an oversized trailing character when it cannot fit in tail byte limit", () => {
|
||||
const result = truncateTail("abc🙂", { maxBytes: 3, maxLines: 10 });
|
||||
|
||||
expect(result.content).toBe("");
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.truncatedBy).toBe("bytes");
|
||||
expect(result.lastLinePartial).toBe(true);
|
||||
expect(result.outputBytes).toBe(0);
|
||||
});
|
||||
|
||||
it("matches Buffer tail truncation semantics for surrogate edge cases", () => {
|
||||
const inputs = ["a\ud83d", "\ude42b", "a\ude42b", "\ud83d\ud83d\ude42", "\ud83d\ude42\ude42", "👩💻"];
|
||||
for (const input of inputs) assertMatchesBufferTail(input);
|
||||
});
|
||||
|
||||
it("matches Buffer tail truncation semantics across deterministic fuzz cases", () => {
|
||||
const alphabet = [
|
||||
"a",
|
||||
"\u007f",
|
||||
"\u0080",
|
||||
"é",
|
||||
"\u07ff",
|
||||
"\u0800",
|
||||
"中",
|
||||
"\ud7ff",
|
||||
"\ud800",
|
||||
"\ud83d",
|
||||
"\udc00",
|
||||
"\ude42",
|
||||
"🙂",
|
||||
"\ue000",
|
||||
"\uffff",
|
||||
];
|
||||
|
||||
function checkExhaustive(prefix: string, depth: number): void {
|
||||
assertMatchesBufferTail(prefix, sampledByteLimits(prefix));
|
||||
if (depth === 0) return;
|
||||
for (const character of alphabet) checkExhaustive(prefix + character, depth - 1);
|
||||
}
|
||||
checkExhaustive("", 3);
|
||||
|
||||
let seed = 0x12345678;
|
||||
function random(): number {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
return seed / 0x100000000;
|
||||
}
|
||||
for (let i = 0; i < 1_000; i++) {
|
||||
let input = "";
|
||||
const length = Math.floor(random() * 80);
|
||||
for (let j = 0; j < length; j++) input += alphabet[Math.floor(random() * alphabet.length)];
|
||||
assertMatchesBufferTail(input, sampledByteLimits(input));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,24 @@
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { getModel } from "@earendil-works/pi-ai";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
|
||||
import {
|
||||
AgentHarness,
|
||||
formatSkillsForSystemPrompt,
|
||||
loadSourcedPromptTemplates,
|
||||
loadSourcedSkills,
|
||||
NodeExecutionEnv,
|
||||
type PromptTemplate,
|
||||
Session,
|
||||
type Skill,
|
||||
} from "../../src/index.js";
|
||||
} from "../../src/index.ts";
|
||||
|
||||
type Source = { type: "project" | "user" | "path"; dir: string };
|
||||
type SourcedSkill = Skill & { source: Source };
|
||||
type SourcedPromptTemplate = PromptTemplate & { source: Source };
|
||||
|
||||
const env = new NodeExecutionEnv({ cwd: process.cwd() });
|
||||
|
||||
const source = (type: Source["type"], dir: string) => ({ path: dir, source: { type, dir } });
|
||||
const { skills: sourcedSkills } = await loadSourcedSkills<Source, SourcedSkill>(
|
||||
env,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type Static, Type } from "typebox";
|
||||
import type { AgentTool, AgentToolResult } from "../../src/types.js";
|
||||
import type { AgentTool, AgentToolResult } from "../../src/types.ts";
|
||||
|
||||
export interface CalculateResult extends AgentToolResult<undefined> {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type Static, Type } from "typebox";
|
||||
import type { AgentTool, AgentToolResult } from "../../src/types.js";
|
||||
import type { AgentTool, AgentToolResult } from "../../src/types.ts";
|
||||
|
||||
export interface GetCurrentTimeResult extends AgentToolResult<{ utcTimestamp: number }> {}
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"paths": {
|
||||
"@earendil-works/pi-ai": ["../ai/dist/index.d.ts"],
|
||||
"@earendil-works/pi-ai/*": ["../ai/dist/*.d.ts", "../ai/dist/providers/*.d.ts"]
|
||||
},
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@earendil-works/pi-ai/base": new URL("../ai/src/base.ts", import.meta.url).pathname,
|
||||
"@earendil-works/pi-ai": new URL("../ai/src/index.ts", import.meta.url).pathname,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
|
||||
24
packages/agent/vitest.harness.config.ts
Normal file
24
packages/agent/vitest.harness.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@earendil-works/pi-ai/base": new URL("../ai/src/base.ts", import.meta.url).pathname,
|
||||
"@earendil-works/pi-ai": new URL("../ai/src/index.ts", import.meta.url).pathname,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
testTimeout: 30000,
|
||||
include: ["test/harness/**/*.test.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
all: true,
|
||||
include: ["src/harness/**/*.ts", "src/agent.ts", "src/agent-loop.ts"],
|
||||
exclude: ["src/**/*.d.ts"],
|
||||
reporter: ["text", "html", "lcov"],
|
||||
reportsDirectory: "coverage/harness",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -2,13 +2,248 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.79.9] - 2026-06-20
|
||||
|
||||
### Added
|
||||
|
||||
- Added configurable `chat-template` thinking support for OpenAI-compatible providers that use `chat_template_kwargs`, such as DeepSeek models behind vLLM ([#5673](https://github.com/earendil-works/pi/issues/5673)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Fireworks GLM-5.2 metadata to use the OpenAI-compatible Chat Completions endpoint with `reasoning_effort` support ([#5923](https://github.com/earendil-works/pi/issues/5923)).
|
||||
- Fixed OpenRouter GLM-5.2 metadata to expose `xhigh` reasoning and send OpenRouter's native `xhigh` effort ([#5770](https://github.com/earendil-works/pi/issues/5770)).
|
||||
- Fixed GitHub Copilot OAuth model availability to use the authenticated account's model picker catalog ([#5897](https://github.com/earendil-works/pi/issues/5897)).
|
||||
|
||||
## [0.79.8] - 2026-06-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added `@earendil-works/pi-ai/base` and direct provider registration exports for bundlers that want selective provider transports without root built-in registration ([#5348](https://github.com/earendil-works/pi/pull/5348) by [@FredKSchott](https://github.com/FredKSchott)).
|
||||
- Added prompt caching for Mistral requests using the pi session ID as `prompt_cache_key`, including cached-token usage and cost accounting ([#5854](https://github.com/earendil-works/pi/issues/5854)).
|
||||
- Added the OpenRouter Fusion alias as `openrouter/fusion` ([#5866](https://github.com/earendil-works/pi/pull/5866) by [@dannote](https://github.com/dannote)).
|
||||
|
||||
## [0.79.7] - 2026-06-18
|
||||
|
||||
### Added
|
||||
|
||||
- Added GLM-5.2 model to the OpenCode Go subscription model catalog ([#5860](https://github.com/earendil-works/pi/issues/5860)).
|
||||
|
||||
## [0.79.6] - 2026-06-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenCode Go DeepSeek V4 thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter.
|
||||
|
||||
## [0.79.5] - 2026-06-16
|
||||
|
||||
### Added
|
||||
|
||||
- Added provider-scoped `StreamOptions.env` overrides for provider configuration, including Cloudflare endpoint placeholders, Azure OpenAI, Google Vertex, Amazon Bedrock, cache retention, and proxy environment lookups ([#5728](https://github.com/earendil-works/pi/issues/5728)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenAI Responses streaming to tolerate null message content from OpenAI-compatible servers before tool calls ([#5819](https://github.com/earendil-works/pi/issues/5819)).
|
||||
- Fixed OpenCode DeepSeek V4 thinking requests to avoid sending both `thinking` and `reasoning_effort` ([#5818](https://github.com/earendil-works/pi/issues/5818)).
|
||||
- Fixed Z.AI GLM-5.2 thinking requests to send `reasoning_effort` with the provider's `high`/`max` effort mapping ([#5770](https://github.com/earendil-works/pi/issues/5770)).
|
||||
- Fixed Google and `google-vertex` Gemini model metadata to map `latest` aliases to the current models, add Gemini 3.5 Flash for Vertex, correct Gemini 2.5 Flash Vertex cache pricing, and remove shut-down Vertex preview models ([#5761](https://github.com/earendil-works/pi/issues/5761)).
|
||||
- Fixed Moonshot AI China model metadata to include Kimi K2.7 Code, and omitted unsupported thinking-off payloads for Kimi K2.7 Code models ([#5760](https://github.com/earendil-works/pi/issues/5760)).
|
||||
|
||||
## [0.79.4] - 2026-06-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Anthropic 1-hour prompt-cache write cost accounting to price 1-hour cache writes at 2x input instead of the 5-minute cache-write rate ([#5738](https://github.com/earendil-works/pi/pull/5738) by [@theBucky](https://github.com/theBucky)).
|
||||
- Fixed GitHub Copilot Claude adaptive-thinking effort metadata to match manually checked Copilot model capabilities ([#4637](https://github.com/earendil-works/pi/issues/4637)).
|
||||
- Fixed OpenCode/OpenCode Go completion models that reject `prompt_cache_retention` to omit long-retention cache fields when `cacheRetention` is `long` ([#5702](https://github.com/earendil-works/pi/issues/5702)).
|
||||
|
||||
## [0.79.3] - 2026-06-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restored OpenAI GPT-5.4/GPT-5.5 and OpenAI Codex GPT-5.4/GPT-5.4 mini/GPT-5.5 context window metadata to the observed 272k-token Codex backend limit, avoiding a billing hazard from sending prompts above Codex's accepted limit (reported by [@trethore](https://github.com/trethore)).
|
||||
|
||||
## [0.79.2] - 2026-06-12
|
||||
|
||||
### Added
|
||||
|
||||
- Added AWS data retention documentation links to Amazon Bedrock unsupported data retention mode validation errors ([#5561](https://github.com/earendil-works/pi/pull/5561) by [@unexge](https://github.com/unexge)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenAI-compatible context overflow detection for parenthesized `maximum context length (N)` errors ([#5677](https://github.com/earendil-works/pi/issues/5677)).
|
||||
- Fixed OpenAI GPT-5.4/GPT-5.5 and OpenAI Codex GPT-5.4/GPT-5.4 mini/GPT-5.5 context window metadata to match current OpenAI limits ([#5644](https://github.com/earendil-works/pi/issues/5644)).
|
||||
- Increased the OpenAI Codex Responses SSE response-header timeout to 20 seconds to reduce false-positive stalls while retaining the bounded wait introduced for zero-event hangs ([#4945](https://github.com/earendil-works/pi/issues/4945)).
|
||||
- Fixed Anthropic refusal stops to preserve provider `stop_details` explanations in error messages ([#5666](https://github.com/earendil-works/pi/pull/5666) by [@rwachtler](https://github.com/rwachtler)).
|
||||
- Fixed Claude Fable 5 thinking-off requests to omit Anthropic's unsupported `thinking.type: "disabled"` payload ([#5567](https://github.com/earendil-works/pi/pull/5567) by [@tmustier](https://github.com/tmustier)).
|
||||
|
||||
## [0.79.1] - 2026-06-09
|
||||
|
||||
### Added
|
||||
|
||||
- Added Claude Fable 5 to Anthropic and Amazon Bedrock model metadata, with adaptive thinking and `xhigh` effort support.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Amazon Bedrock inference profile ARN region resolution to prefer the ARN's embedded region over `AWS_REGION` ([#5527](https://github.com/earendil-works/pi/pull/5527) by [@AJM10565](https://github.com/AJM10565)).
|
||||
- Fixed z.ai thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5330](https://github.com/earendil-works/pi/issues/5330)).
|
||||
- Fixed OpenCode completions model metadata to send explicit `maxTokens` as `max_tokens` ([#5331](https://github.com/earendil-works/pi/issues/5331)).
|
||||
- Fixed Moonshot Kimi thinking-off requests to send the provider's `thinking: { type: "disabled" }` compatibility parameter ([#5531](https://github.com/earendil-works/pi/issues/5531)).
|
||||
- Fixed Azure OpenAI Responses requests to disable server-side response storage ([#5530](https://github.com/earendil-works/pi/issues/5530)).
|
||||
- Fixed Azure GPT-5.4 and GPT-5.5 context window metadata to 1,050,000 tokens, matching Azure Foundry deployments instead of OpenAI's 272k limit ([#5559](https://github.com/earendil-works/pi/issues/5559)).
|
||||
- Fixed OpenAI and Azure GPT-5 Pro `maxTokens` metadata to 128,000, correcting an upstream value that duplicated the 272,000 input sub-limit as the output limit ([#5559](https://github.com/earendil-works/pi/issues/5559)).
|
||||
|
||||
## [0.79.0] - 2026-06-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenAI Responses custom providers to honor `compat.supportsDeveloperRole: false` for reasoning models ([#5456](https://github.com/earendil-works/pi/issues/5456)).
|
||||
- Fixed OpenRouter routing preferences on OpenAI-compatible custom providers to send `compat.openRouterRouting` even when `baseUrl` does not point directly at OpenRouter ([#5347](https://github.com/earendil-works/pi/issues/5347)).
|
||||
|
||||
## [0.78.1] - 2026-06-04
|
||||
|
||||
### Added
|
||||
|
||||
- Added Ant Ling as a built-in OpenAI-compatible provider with Ling 2.6 and Ring 2.6 models.
|
||||
- Added MiniMax-M3 model to the `minimax` and `minimax-cn` direct providers, and removed the hardcoded context-window override that was masking models.dev values ([#5313](https://github.com/earendil-works/pi/issues/5313)).
|
||||
- Added NVIDIA NIM as a built-in OpenAI-compatible provider, exposing public NIM models that support tool use.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Amazon Bedrock requests to replace blank required user/tool-result text with a placeholder and skip blank replay text blocks ([#4975](https://github.com/earendil-works/pi/issues/4975)).
|
||||
- Fixed Anthropic Claude Opus 4.7+ requests to suppress deprecated temperature parameters ([#5251](https://github.com/earendil-works/pi/pull/5251) by [@yzhg1983](https://github.com/yzhg1983)).
|
||||
- Fixed OpenAI GPT-5.5 generated metadata to omit unsupported minimal thinking ([#5243](https://github.com/earendil-works/pi/issues/5243)).
|
||||
- Fixed OpenRouter Kimi K2.6 thinking replay and preserved developer-role instructions for OpenRouter OpenAI and Anthropic models ([#5309](https://github.com/earendil-works/pi/issues/5309)).
|
||||
- Fixed OpenRouter reasoning instruction requests to preserve the system role when required ([#5221](https://github.com/earendil-works/pi/pull/5221) by [@PriNova](https://github.com/PriNova)).
|
||||
- Restored the NVIDIA Qwen 3.5 122B NIM model.
|
||||
|
||||
## [0.78.0] - 2026-05-29
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Changed direct provider stream functions to require explicit `options.apiKey`; top-level `stream*`/`complete*` helpers still resolve built-in environment auth.
|
||||
|
||||
### Added
|
||||
|
||||
- Added custom Amazon Bedrock request header support via `StreamOptions.headers`, excluding reserved AWS signing headers ([#5178](https://github.com/earendil-works/pi-mono/pull/5178) by [@stephanmck](https://github.com/stephanmck)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenRouter Moonshot Kimi K2.6 requests to use `system` instead of unsupported `developer` messages ([#5159](https://github.com/earendil-works/pi-mono/issues/5159)).
|
||||
- Fixed OpenCode Go Kimi K2.6 thinking requests to send `thinking` objects instead of invalid string values, and fixed OpenCode Zen Grok Build thinking requests to omit unsupported `reasoning_effort` ([#5169](https://github.com/earendil-works/pi-mono/issues/5169)).
|
||||
- Fixed OpenAI Codex Responses SSE streams to abort response body reads after terminal events.
|
||||
- Fixed OpenCode Kimi K2.6 generated metadata to use Anthropic-style thinking metadata instead of invalid reasoning-effort parameters.
|
||||
|
||||
## [0.77.0] - 2026-05-28
|
||||
|
||||
### Added
|
||||
|
||||
- Added OpenAI Codex subscription device-code login as a selectable headless alternative while keeping browser login as the default ([#4911](https://github.com/earendil-works/pi/pull/4911) by [@vegarsti](https://github.com/vegarsti)).
|
||||
- Added Claude Opus 4.8 model metadata for Anthropic and updated Opus adaptive-thinking coverage to use it.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenRouter DeepSeek V4 `xhigh` reasoning metadata to preserve OpenRouter's native effort instead of sending DeepSeek's `max` effort ([#4801](https://github.com/earendil-works/pi/issues/4801)).
|
||||
- Fixed OpenAI Codex Responses replay after switching from Anthropic extended-thinking sessions by generating unique fallback message item IDs for converted thinking/text blocks ([#5148](https://github.com/earendil-works/pi/issues/5148)).
|
||||
- Fixed Anthropic-compatible replay for providers that return empty thinking signatures by adding an opt-in `allowEmptySignature` compatibility flag ([#4464](https://github.com/earendil-works/pi/issues/4464)).
|
||||
- Fixed OpenAI and OpenRouter GPT-5.5 Pro thinking level metadata to expose only supported medium, high, and xhigh efforts.
|
||||
- Fixed OpenCode Go Kimi K2.6 thinking-off requests to send `thinking: "none"` ([#5078](https://github.com/earendil-works/pi/issues/5078)).
|
||||
- Fixed Xiaomi Token Plan model metadata to omit unsupported `mimo-v2-flash` variants ([#5075](https://github.com/earendil-works/pi/issues/5075)).
|
||||
|
||||
## [0.76.0] - 2026-05-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenAI Codex Responses cache-affinity headers to send `session-id` instead of proxy-incompatible `session_id` ([#4967](https://github.com/earendil-works/pi/issues/4967)).
|
||||
- Fixed `openai-codex/gpt-5.3-codex-spark` generated metadata to use its 128k context window ([#4969](https://github.com/earendil-works/pi/issues/4969)).
|
||||
- Fixed OpenRouter/Poolside context overflow detection for `maximum allowed input length` errors ([#4943](https://github.com/earendil-works/pi/issues/4943)).
|
||||
- Fixed OpenAI Codex Responses WebSocket streams and SSE response-header waits to apply bounded timeouts instead of waiting indefinitely when no events arrive ([#4945](https://github.com/earendil-works/pi/issues/4945)).
|
||||
- Fixed provider retry controls so OpenAI Codex Responses honors `maxRetries`, SDK retries default to `0`, and quota/billing 429s are not retried behind Pi's retry handling ([#4991](https://github.com/earendil-works/pi-mono/pull/4991) by [@mitsuhiko](https://github.com/mitsuhiko)).
|
||||
|
||||
## [0.75.5] - 2026-05-23
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Changed `OAuthLoginCallbacks` to require `onDeviceCode` and `onSelect`, so OAuth providers can rely on pi supplying device-code and selection UI callbacks ([#4788](https://github.com/earendil-works/pi-mono/pull/4788) by [@vegarsti](https://github.com/vegarsti)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed custom Anthropic-compatible model aliases for adaptive-thinking Claude models by adding `compat.forceAdaptiveThinking` model metadata and moving built-in adaptive-thinking selection out of provider id substring checks ([#4797](https://github.com/earendil-works/pi-mono/pull/4797) by [@mbazso](https://github.com/mbazso)).
|
||||
- Fixed GitHub Copilot OAuth login to rely on the required device-code callback without a runtime callback availability guard ([#4788](https://github.com/earendil-works/pi-mono/pull/4788) by [@vegarsti](https://github.com/vegarsti)).
|
||||
- Fixed Amazon Bedrock provider loading under strict package managers by declaring its direct `@smithy/node-http-handler` dependency ([#4842](https://github.com/earendil-works/pi/issues/4842)).
|
||||
- Fixed Amazon Bedrock Claude requests to send the model output token cap by default, matching Anthropic requests and avoiding Bedrock's 4096-token default truncation ([#4848](https://github.com/earendil-works/pi/issues/4848)).
|
||||
|
||||
## [0.75.4] - 2026-05-20
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed source syntax to avoid TypeScript constructs that require JavaScript emit, keeping the package compatible with Node.js strip-only TypeScript checks.
|
||||
- Removed the package-level development watch scripts now that the root TypeScript check validates strip-only-compatible sources.
|
||||
|
||||
### Added
|
||||
|
||||
- Added first-class OAuth device-code callback metadata, shared polling support, and GitHub Copilot OAuth integration.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenAI-compatible `streamSimple()` requests to stop sending model-derived default output token caps, avoiding context-window reservation failures on servers such as vLLM while preserving explicit `maxTokens` and required Anthropic `max_tokens` handling ([#4675](https://github.com/earendil-works/pi/issues/4675)).
|
||||
- Fixed OpenAI prompt cache keys to clamp session-derived values to the 64-character API limit across OpenAI Responses, Chat Completions, Codex Responses, and Azure OpenAI Responses ([#4720](https://github.com/earendil-works/pi/issues/4720)).
|
||||
|
||||
## [0.75.3] - 2026-05-18
|
||||
|
||||
## [0.75.2] - 2026-05-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Xiaomi MiMo generated model metadata to replay assistant tool-call messages with `reasoning_content` for thinking-mode multi-turn requests ([#4678](https://github.com/earendil-works/pi/issues/4678)).
|
||||
|
||||
## [0.75.1] - 2026-05-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Anthropic-compatible API-key requests to ignore unrelated `ANTHROPIC_AUTH_TOKEN` environment values, avoiding invalid bearer credentials for providers such as Xiaomi MiMo ([#4342](https://github.com/earendil-works/pi/issues/4342)).
|
||||
- Fixed Amazon Bedrock message conversion to skip unknown content blocks instead of failing the stream ([#4223](https://github.com/earendil-works/pi/issues/4223)).
|
||||
- Fixed Azure OpenAI Responses and OpenAI Responses error formatting to prefix HTTP status codes onto `errorMessage`, so transient 5xx and 429 errors are correctly matched by the agent-level auto-retry classifier ([#4232](https://github.com/earendil-works/pi/issues/4232)).
|
||||
- Fixed Xiaomi MiMo model metadata to use the OpenAI-compatible endpoints and `openai-completions` API, restoring multi-turn thinking/tool-call sessions ([#4505](https://github.com/earendil-works/pi/issues/4505)).
|
||||
- Fixed OpenCode Go Kimi reasoning replay by normalizing streamed `reasoning` fields back to `reasoning_content` for OpenCode Go only ([#4251](https://github.com/earendil-works/pi/issues/4251)).
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed non-working OpenAI Codex fast model variants.
|
||||
|
||||
## [0.75.0] - 2026-05-17
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Raised the minimum supported Node.js version to 22.19.0.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenAI Codex generated model metadata to use the current upstream model list ([#4603](https://github.com/earendil-works/pi-mono/pull/4603) by [@mattiacerutti](https://github.com/mattiacerutti)).
|
||||
- Fixed GitHub Copilot GPT model thinking metadata to map unsupported minimal thinking to low ([#4622](https://github.com/earendil-works/pi-mono/pull/4622) by [@mattiacerutti](https://github.com/mattiacerutti)).
|
||||
- Fixed `streamSimple()` defaults for models whose advertised output limit is effectively their full context window to avoid impossible default requests ([#4614](https://github.com/earendil-works/pi/issues/4614)).
|
||||
|
||||
## [0.74.1] - 2026-05-16
|
||||
|
||||
### Added
|
||||
|
||||
- Added image generation APIs, image model metadata, and built-in OpenRouter image generation support ([#3887](https://github.com/earendil-works/pi-mono/pull/3887) by [@cristinaponcela](https://github.com/cristinaponcela)).
|
||||
- Added Together AI as a built-in OpenAI-compatible provider with generated model metadata and `TOGETHER_API_KEY` authentication ([#3624](https://github.com/earendil-works/pi-mono/pull/3624) by [@Nutlope](https://github.com/Nutlope)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed GitHub Copilot model availability to ignore generic `GH_TOKEN` and `GITHUB_TOKEN` environment variables, requiring OAuth login or `COPILOT_GITHUB_TOKEN` instead ([#4485](https://github.com/earendil-works/pi/issues/4485)).
|
||||
- Fixed `openai-completions` streams to surface an error when the stream ends before any terminal `finish_reason`, so truncated responses can retry instead of being accepted as success ([#4345](https://github.com/earendil-works/pi/issues/4345)).
|
||||
- Fixed Fireworks provider caching compatibility by adding session affinity headers and model metadata compat settings ([#4358](https://github.com/earendil-works/pi-mono/pull/4358) by [@yanirz](https://github.com/yanirz)).
|
||||
- Fixed OpenAI Codex WebSocket transport to respect proxy environment variables under Bun ([#4354](https://github.com/earendil-works/pi-mono/pull/4354) by [@haoqixu](https://github.com/haoqixu)).
|
||||
- Fixed OpenRouter cache usage normalization to preserve cached-token semantics without treating cached tokens as cache writes.
|
||||
- Fixed Bedrock proxy handling to preserve `NO_PROXY` exclusions while using HTTP(S)-only proxy agents.
|
||||
- Fixed compiled Bun binaries failing to start outside the repo when Bedrock proxy support tried to resolve `proxy-from-env` from external `node_modules` ([#4513](https://github.com/earendil-works/pi/issues/4513)).
|
||||
- Fixed GitHub Copilot Claude test coverage to use the current Claude Sonnet 4.6 model ID.
|
||||
- Fixed OpenAI Responses requests for models that support disabling reasoning to send `reasoning.effort: "none"` when thinking is off.
|
||||
- Fixed Inception Mercury 2 tool calling on OpenRouter by marking `off` as unsupported in `thinkingLevelMap`, so the openai-completions provider omits the reasoning param instead of defaulting to `{reasoning:{effort:"none"}}` (which puts Mercury 2 in instant mode, disabling tool calls).
|
||||
- Fixed OpenAI Codex SSE retries to honor `retry-after-ms` and `retry-after` headers before falling back to exponential backoff.
|
||||
- Fixed context overflow detection for LiteLLM-wrapped OpenAI-compatible errors using `exceeds the model's maximum context length of ... tokens` wording ([#4563](https://github.com/earendil-works/pi/issues/4563)).
|
||||
- Fixed `streamSimple()` defaults to respect model output limits above 32000 tokens instead of clamping provider requests to 32000 ([#4539](https://github.com/earendil-works/pi/issues/4539)).
|
||||
|
||||
## [0.74.0] - 2026-05-07
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an
|
||||
|
||||
- [Supported Providers](#supported-providers)
|
||||
- [Installation](#installation)
|
||||
- [Base Entry Point](#base-entry-point)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Tools](#tools)
|
||||
- [Defining Tools](#defining-tools)
|
||||
@@ -38,6 +39,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an
|
||||
- [Browser Usage](#browser-usage)
|
||||
- [Browser Compatibility Notes](#browser-compatibility-notes)
|
||||
- [Environment Variables](#environment-variables-nodejs-only)
|
||||
- [Provider-Scoped Environment Overrides](#provider-scoped-environment-overrides)
|
||||
- [Checking Environment Variables](#checking-environment-variables)
|
||||
- [OAuth Providers](#oauth-providers)
|
||||
- [Vertex AI](#vertex-ai)
|
||||
@@ -51,9 +53,11 @@ Unified LLM API with automatic model discovery, provider configuration, token an
|
||||
## Supported Providers
|
||||
|
||||
- **OpenAI**
|
||||
- **Ant Ling**
|
||||
- **Azure OpenAI (Responses)**
|
||||
- **OpenAI Codex** (ChatGPT Plus/Pro subscription, requires OAuth, see below)
|
||||
- **DeepSeek**
|
||||
- **NVIDIA NIM**
|
||||
- **Anthropic**
|
||||
- **Google**
|
||||
- **Vertex AI** (Gemini via Vertex AI)
|
||||
@@ -65,6 +69,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an
|
||||
- **xAI**
|
||||
- **OpenRouter**
|
||||
- **Vercel AI Gateway**
|
||||
- **ZAI** (with separate Coding Plan China provider)
|
||||
- **MiniMax**
|
||||
- **Together AI**
|
||||
- **GitHub Copilot** (requires OAuth, see below)
|
||||
@@ -84,6 +89,30 @@ npm install @earendil-works/pi-ai
|
||||
|
||||
TypeBox exports are re-exported from `@earendil-works/pi-ai`: `Type`, `Static`, and `TSchema`.
|
||||
|
||||
## Base Entry Point
|
||||
|
||||
Use `@earendil-works/pi-ai/base` when a bundler should include only explicitly selected transport implementations. The base entry point exposes model discovery, registries, generic dispatch, environment-key helpers, and provider-neutral utilities without registering built-in transports during module initialization. Generic dispatch still resolves configured environment keys when called.
|
||||
|
||||
Import each selected transport directly and call its `register()` function:
|
||||
|
||||
```typescript
|
||||
import { getModel, streamSimple } from '@earendil-works/pi-ai/base';
|
||||
import { register as registerAnthropic } from '@earendil-works/pi-ai/anthropic';
|
||||
|
||||
registerAnthropic();
|
||||
|
||||
const model = getModel('anthropic', 'claude-sonnet-4-6');
|
||||
const response = await streamSimple(model, {
|
||||
messages: [{ role: 'user', content: 'Hello!' }]
|
||||
}, {
|
||||
apiKey: process.env.ANTHROPIC_API_KEY
|
||||
}).result();
|
||||
```
|
||||
|
||||
Direct transport imports bundle their implementation and SDK code. Register only the transports used by the application. For example, OpenRouter, Groq, xAI, and DeepSeek chat models share the `@earendil-works/pi-ai/openai-completions` transport.
|
||||
|
||||
Use `@earendil-works/pi-ai` for the batteries-included behavior. The root entry point remains backward-compatible: it registers lazy wrappers for all built-in transports and resolves environment API keys automatically during dispatch.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
@@ -434,7 +463,7 @@ Do not use `stream()` or `complete()` for image generation. Image generation is
|
||||
### Basic Image Generation
|
||||
|
||||
```typescript
|
||||
import { getImageModel, generateImages } from '@mariozechner/pi-ai';
|
||||
import { getImageModel, generateImages } from '@earendil-works/pi-ai';
|
||||
|
||||
const model = getImageModel('openrouter', 'google/gemini-2.5-flash-image');
|
||||
|
||||
@@ -801,7 +830,7 @@ A **provider** offers models through a specific API. For example:
|
||||
- **Google** models use the `google-generative-ai` API
|
||||
- **OpenAI** models use the `openai-responses` API
|
||||
- **Mistral** models use the `mistral-conversations` API
|
||||
- **xAI, Cerebras, Groq, Together AI, etc.** models use the `openai-completions` API (OpenAI-compatible)
|
||||
- **xAI, Cerebras, Groq, NVIDIA NIM, Together AI, etc.** models use the `openai-completions` API (OpenAI-compatible)
|
||||
|
||||
### Querying Providers and Models
|
||||
|
||||
@@ -923,7 +952,7 @@ const ollamaReasoningModel: Model<'openai-completions'> = {
|
||||
|
||||
### OpenAI Compatibility Settings
|
||||
|
||||
The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, Together AI, zAi, OpenCode, Cloudflare Workers AI, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field only supports Responses-specific flags.
|
||||
The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, NVIDIA NIM, Together AI, zAi, OpenCode, Cloudflare Workers AI, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field supports Responses-specific flags.
|
||||
|
||||
```typescript
|
||||
interface OpenAICompletionsCompat {
|
||||
@@ -938,14 +967,17 @@ interface OpenAICompletionsCompat {
|
||||
requiresAssistantAfterToolResult?: boolean; // Whether tool results must be followed by an assistant message (default: false)
|
||||
requiresThinkingAsText?: boolean; // Whether thinking blocks must be converted to text (default: false)
|
||||
requiresReasoningContentOnAssistantMessages?: boolean; // Whether all replayed assistant messages must include empty reasoning_content when reasoning is enabled (default: auto-detected for DeepSeek)
|
||||
thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'qwen-chat-template'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses enable_thinking, 'qwen' uses enable_thinking, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking (default: openai)
|
||||
thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'chat-template' | 'qwen-chat-template' | 'string-thinking' | 'ant-ling'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort when supported, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses thinking: { type }, 'qwen' uses enable_thinking, 'chat-template' uses configurable chat_template_kwargs, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking and preserve_thinking, 'string-thinking' uses top-level thinking, 'ant-ling' uses reasoning: { effort } only for mapped efforts (default: openai)
|
||||
chatTemplateKwargs?: Record<string, string | number | boolean | null | { '$var': 'thinking.enabled' | 'thinking.effort'; omitWhenOff?: boolean }>; // chat_template_kwargs values; use $var for pi-controlled thinking values
|
||||
cacheControlFormat?: 'anthropic'; // Anthropic-style cache_control on system prompt, last tool, and last user/assistant text content
|
||||
openRouterRouting?: OpenRouterRouting; // OpenRouter routing preferences (default: {})
|
||||
vercelGatewayRouting?: VercelGatewayRouting; // Vercel AI Gateway routing preferences (default: {})
|
||||
}
|
||||
|
||||
interface OpenAIResponsesCompat {
|
||||
// Reserved for future use
|
||||
supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true)
|
||||
sendSessionIdHeader?: boolean; // Whether to send `session_id` from `sessionId` when caching is enabled (default: true)
|
||||
supportsLongCacheRetention?: boolean; // Whether provider supports `prompt_cache_retention: "24h"` (default: true)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1091,6 +1123,7 @@ const response = await complete(model, {
|
||||
- OAuth login flows are not supported in browser environments. Use the `@earendil-works/pi-ai/oauth` entry point in Node.js.
|
||||
- In browser builds, Bedrock can still appear in model lists. Calls to Bedrock models fail at runtime.
|
||||
- Use a server-side proxy or backend service if you need Bedrock or OAuth-based auth from a web app.
|
||||
- Use `@earendil-works/pi-ai/base` plus explicit direct transport registration when a browser bundle should exclude unused provider SDK implementations.
|
||||
|
||||
### Environment Variables (Node.js only)
|
||||
|
||||
@@ -1099,9 +1132,11 @@ In Node.js environments, you can set environment variables to avoid passing API
|
||||
| Provider | Environment Variable(s) |
|
||||
|----------|------------------------|
|
||||
| OpenAI | `OPENAI_API_KEY` |
|
||||
| Ant Ling | `ANT_LING_API_KEY` |
|
||||
| Azure OpenAI | `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_BASE_URL` (e.g. `https://{resource}.openai.azure.com`) or `AZURE_OPENAI_RESOURCE_NAME`. Supports `*.openai.azure.com` and `*.cognitiveservices.azure.com`; root endpoints auto-normalize to `/openai/v1`. Optional: `AZURE_OPENAI_API_VERSION` (default `v1`), `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`. |
|
||||
| Anthropic | `ANTHROPIC_API_KEY` or `ANTHROPIC_OAUTH_TOKEN` |
|
||||
| DeepSeek | `DEEPSEEK_API_KEY` |
|
||||
| NVIDIA NIM | `NVIDIA_API_KEY` |
|
||||
| Google | `GEMINI_API_KEY` |
|
||||
| Vertex AI | `GOOGLE_CLOUD_API_KEY` or `GOOGLE_CLOUD_PROJECT` (or `GCLOUD_PROJECT`) + `GOOGLE_CLOUD_LOCATION` + ADC |
|
||||
| Mistral | `MISTRAL_API_KEY` |
|
||||
@@ -1115,6 +1150,7 @@ In Node.js environments, you can set environment variables to avoid passing API
|
||||
| OpenRouter | `OPENROUTER_API_KEY` |
|
||||
| Vercel AI Gateway | `AI_GATEWAY_API_KEY` |
|
||||
| zAI | `ZAI_API_KEY` |
|
||||
| ZAI Coding Plan (China) | `ZAI_CODING_CN_API_KEY` |
|
||||
| MiniMax | `MINIMAX_API_KEY` |
|
||||
| OpenCode Zen / OpenCode Go | `OPENCODE_API_KEY` |
|
||||
| Kimi For Coding | `KIMI_API_KEY` |
|
||||
@@ -1122,7 +1158,7 @@ In Node.js environments, you can set environment variables to avoid passing API
|
||||
| Xiaomi MiMo Token Plan (China) | `XIAOMI_TOKEN_PLAN_CN_API_KEY` |
|
||||
| Xiaomi MiMo Token Plan (Amsterdam) | `XIAOMI_TOKEN_PLAN_AMS_API_KEY` |
|
||||
| Xiaomi MiMo Token Plan (Singapore) | `XIAOMI_TOKEN_PLAN_SGP_API_KEY` |
|
||||
| GitHub Copilot | `COPILOT_GITHUB_TOKEN` or `GH_TOKEN` or `GITHUB_TOKEN` |
|
||||
| GitHub Copilot | `COPILOT_GITHUB_TOKEN` |
|
||||
|
||||
When set, the library automatically uses these keys:
|
||||
|
||||
@@ -1137,6 +1173,24 @@ const response = await complete(model, context, {
|
||||
});
|
||||
```
|
||||
|
||||
### Provider-Scoped Environment Overrides
|
||||
|
||||
Pass `env` in stream options to scope provider configuration to a request. Values in `env` are used before process environment variables for API key discovery and provider configuration such as Cloudflare account IDs, Azure OpenAI settings, Vertex project/location, Bedrock settings, `PI_CACHE_RETENTION`, and `HTTP_PROXY`/`HTTPS_PROXY`.
|
||||
|
||||
```typescript
|
||||
const model = getModel('cloudflare-ai-gateway', 'workers-ai/@cf/moonshotai/kimi-k2.6');
|
||||
|
||||
const response = await complete(model, context, {
|
||||
env: {
|
||||
CLOUDFLARE_API_KEY: '...',
|
||||
CLOUDFLARE_ACCOUNT_ID: 'account-id',
|
||||
CLOUDFLARE_GATEWAY_ID: 'gateway-id'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Use this when one process needs different provider settings per request, or when ambient environment variables should not leak into a provider call.
|
||||
|
||||
### Checking Environment Variables
|
||||
|
||||
```typescript
|
||||
@@ -1306,6 +1360,7 @@ Create a new provider file (for example `amazon-bedrock.ts`) that exports:
|
||||
|
||||
- `stream<Provider>()` function returning `AssistantMessageEventStream`
|
||||
- `streamSimple<Provider>()` for `SimpleStreamOptions` mapping
|
||||
- `register()` for explicit direct transport registration from `@earendil-works/pi-ai/base`
|
||||
- Provider-specific options interface
|
||||
- Message conversion functions to transform `Context` to provider format
|
||||
- Tool conversion if the provider supports tools
|
||||
@@ -1316,7 +1371,8 @@ Create a new provider file (for example `amazon-bedrock.ts`) that exports:
|
||||
- Register the API with `registerApiProvider()`
|
||||
- Add a package subpath export in `package.json` for the provider module (`./dist/providers/<provider>.js`)
|
||||
- Add lazy loader wrappers in `src/providers/register-builtins.ts`, do not statically import provider implementation modules there
|
||||
- Add any root-level `export type` re-exports in `src/index.ts` that should remain available from `@earendil-works/pi-ai`
|
||||
- Add any root-level `export type` re-exports in `src/index.ts` and `src/base.ts` that should remain available from `@earendil-works/pi-ai` and `@earendil-works/pi-ai/base`
|
||||
- Keep `src/base.ts` free of built-in registration imports
|
||||
- Add credential detection in `env-api-keys.ts` for the new provider
|
||||
- Ensure `streamSimple` handles auth lookup via `getEnvApiKey()` or provider-specific auth
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-ai",
|
||||
"version": "0.74.0",
|
||||
"version": "0.79.9",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -10,6 +10,14 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./base": {
|
||||
"types": "./dist/base.d.ts",
|
||||
"import": "./dist/base.js"
|
||||
},
|
||||
"./amazon-bedrock": {
|
||||
"types": "./dist/providers/amazon-bedrock.d.ts",
|
||||
"import": "./dist/providers/amazon-bedrock.js"
|
||||
},
|
||||
"./anthropic": {
|
||||
"types": "./dist/providers/anthropic.d.ts",
|
||||
"import": "./dist/providers/anthropic.js"
|
||||
@@ -42,6 +50,10 @@
|
||||
"types": "./dist/providers/openai-responses.d.ts",
|
||||
"import": "./dist/providers/openai-responses.js"
|
||||
},
|
||||
"./openrouter-images": {
|
||||
"types": "./dist/providers/images/openrouter.d.ts",
|
||||
"import": "./dist/providers/images/openrouter.js"
|
||||
},
|
||||
"./oauth": {
|
||||
"types": "./dist/oauth.d.ts",
|
||||
"import": "./dist/oauth.js"
|
||||
@@ -60,26 +72,24 @@
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"generate-models": "npx tsx scripts/generate-models.ts",
|
||||
"generate-image-models": "npx tsx scripts/generate-image-models.ts",
|
||||
"generate-models": "node scripts/generate-models.ts",
|
||||
"generate-image-models": "node scripts/generate-image-models.ts",
|
||||
"build": "npm run generate-models && npm run generate-image-models && tsgo -p tsconfig.build.json",
|
||||
"dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
|
||||
"dev:tsc": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
|
||||
"test": "vitest --run",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.91.1",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
|
||||
"@google/genai": "^1.40.0",
|
||||
"@mistralai/mistralai": "^2.2.0",
|
||||
"typebox": "^1.1.24",
|
||||
"chalk": "^5.6.2",
|
||||
"@anthropic-ai/sdk": "0.91.1",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1048.0",
|
||||
"@google/genai": "1.52.0",
|
||||
"@mistralai/mistralai": "2.2.6",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@smithy/node-http-handler": "4.7.3",
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"openai": "6.26.0",
|
||||
"partial-json": "^0.1.7",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"undici": "^7.19.1",
|
||||
"zod-to-json-schema": "^3.24.6"
|
||||
"partial-json": "0.1.7",
|
||||
"typebox": "1.1.38"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
@@ -95,15 +105,15 @@
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/earendil-works/pi-mono.git",
|
||||
"url": "git+https://github.com/earendil-works/pi.git",
|
||||
"directory": "packages/ai"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"canvas": "^3.2.0",
|
||||
"vitest": "^3.2.4"
|
||||
"@types/node": "24.12.4",
|
||||
"canvas": "3.2.3",
|
||||
"vitest": "4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env tsx
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { writeFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import type { ImagesModel } from "../src/types.js";
|
||||
import type { ImagesModel } from "../src/types.ts";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -109,7 +109,7 @@ function generateImageModelsFile(models: ImagesModel<"openrouter-images">[]): st
|
||||
return `// This file is auto-generated by scripts/generate-image-models.ts
|
||||
// Do not edit manually - run 'npm run generate-image-models' to update
|
||||
|
||||
import type { ImagesApi, ImagesModel } from "./types.js";
|
||||
import type { ImagesApi, ImagesModel } from "./types.ts";
|
||||
|
||||
export const IMAGE_MODELS = {
|
||||
${providerEntries}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env tsx
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createCanvas } from "canvas";
|
||||
import { writeFileSync } from "fs";
|
||||
import { mkdirSync, writeFileSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
@@ -27,7 +27,6 @@ const buffer = canvas.toBuffer("image/png");
|
||||
const outputPath = join(__dirname, "..", "test", "data", "red-circle.png");
|
||||
|
||||
// Ensure the directory exists
|
||||
import { mkdirSync } from "fs";
|
||||
mkdirSync(join(__dirname, "..", "test", "data"), { recursive: true });
|
||||
|
||||
writeFileSync(outputPath, buffer);
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
SimpleStreamOptions,
|
||||
StreamFunction,
|
||||
StreamOptions,
|
||||
} from "./types.js";
|
||||
} from "./types.ts";
|
||||
|
||||
export type ApiStreamFunction = (
|
||||
model: Model<Api>,
|
||||
|
||||
45
packages/ai/src/base.ts
Normal file
45
packages/ai/src/base.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export type { Static, TSchema } from "typebox";
|
||||
export { Type } from "typebox";
|
||||
|
||||
export * from "./api-registry.ts";
|
||||
export * from "./env-api-keys.ts";
|
||||
export * from "./image-models.ts";
|
||||
export * from "./images.ts";
|
||||
export * from "./images-api-registry.ts";
|
||||
export * from "./models.ts";
|
||||
export type { BedrockOptions, BedrockThinkingDisplay } from "./providers/amazon-bedrock.ts";
|
||||
export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from "./providers/anthropic.ts";
|
||||
export type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses.ts";
|
||||
export * from "./providers/faux.ts";
|
||||
export type { GoogleOptions } from "./providers/google.ts";
|
||||
export type { GoogleThinkingLevel } from "./providers/google-shared.ts";
|
||||
export type { GoogleVertexOptions } from "./providers/google-vertex.ts";
|
||||
export type { MistralOptions } from "./providers/mistral.ts";
|
||||
export type {
|
||||
OpenAICodexResponsesOptions,
|
||||
OpenAICodexWebSocketDebugStats,
|
||||
} from "./providers/openai-codex-responses.ts";
|
||||
export type { OpenAICompletionsOptions } from "./providers/openai-completions.ts";
|
||||
export type { OpenAIResponsesOptions } from "./providers/openai-responses.ts";
|
||||
export * from "./session-resources.ts";
|
||||
export * from "./stream.ts";
|
||||
export * from "./types.ts";
|
||||
export * from "./utils/diagnostics.ts";
|
||||
export * from "./utils/event-stream.ts";
|
||||
export * from "./utils/json-parse.ts";
|
||||
export type {
|
||||
OAuthAuthInfo,
|
||||
OAuthCredentials,
|
||||
OAuthDeviceCodeInfo,
|
||||
OAuthLoginCallbacks,
|
||||
OAuthPrompt,
|
||||
OAuthProvider,
|
||||
OAuthProviderId,
|
||||
OAuthProviderInfo,
|
||||
OAuthProviderInterface,
|
||||
OAuthSelectOption,
|
||||
OAuthSelectPrompt,
|
||||
} from "./utils/oauth/types.ts";
|
||||
export * from "./utils/overflow.ts";
|
||||
export * from "./utils/typebox-helpers.ts";
|
||||
export * from "./utils/validation.ts";
|
||||
@@ -1,4 +1,6 @@
|
||||
import { streamBedrock, streamSimpleBedrock } from "./providers/amazon-bedrock.js";
|
||||
import { register, streamBedrock, streamSimpleBedrock } from "./providers/amazon-bedrock.ts";
|
||||
|
||||
export { register };
|
||||
|
||||
export const bedrockProviderModule = {
|
||||
streamBedrock,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import { getOAuthProvider, getOAuthProviders } from "./utils/oauth/index.js";
|
||||
import type { OAuthCredentials, OAuthProviderId } from "./utils/oauth/types.js";
|
||||
import { getOAuthProvider, getOAuthProviders } from "./utils/oauth/index.ts";
|
||||
import type { OAuthCredentials, OAuthProviderId } from "./utils/oauth/types.ts";
|
||||
|
||||
const AUTH_FILE = "auth.json";
|
||||
const PROVIDERS = getOAuthProviders();
|
||||
@@ -42,9 +42,23 @@ async function login(providerId: OAuthProviderId): Promise<void> {
|
||||
if (info.instructions) console.log(info.instructions);
|
||||
console.log();
|
||||
},
|
||||
onDeviceCode: (info) => {
|
||||
console.log(`\nOpen this URL in your browser:\n${info.verificationUri}`);
|
||||
console.log(`Enter code: ${info.userCode}`);
|
||||
console.log();
|
||||
},
|
||||
onPrompt: async (p) => {
|
||||
return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
|
||||
},
|
||||
onSelect: async (p) => {
|
||||
console.log(`\n${p.message}`);
|
||||
for (let i = 0; i < p.options.length; i++) {
|
||||
console.log(` ${i + 1}. ${p.options[i].label}`);
|
||||
}
|
||||
const choice = await promptFn(`Enter number (1-${p.options.length}):`);
|
||||
const index = parseInt(choice, 10) - 1;
|
||||
return p.options[index]?.id;
|
||||
},
|
||||
onProgress: (msg) => console.log(msg),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// NEVER convert to top-level imports - breaks browser/Vite builds (web-ui)
|
||||
// NEVER convert to top-level imports - breaks browser/Vite builds
|
||||
let _existsSync: typeof import("node:fs").existsSync | null = null;
|
||||
let _homedir: typeof import("node:os").homedir | null = null;
|
||||
let _join: typeof import("node:path").join | null = null;
|
||||
@@ -23,44 +23,17 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version
|
||||
});
|
||||
}
|
||||
|
||||
import type { KnownProvider } from "./types.js";
|
||||
|
||||
let _procEnvCache: Map<string, string> | null = null;
|
||||
|
||||
/**
|
||||
* Fallback for https://github.com/oven-sh/bun/issues/27802
|
||||
* Bun compiled binaries have an empty `process.env` inside sandbox
|
||||
* environments on Linux. We can recover the env from `/proc/self/environ`.
|
||||
*/
|
||||
function getProcEnv(key: string): string | undefined {
|
||||
if (!process.versions?.bun) return undefined;
|
||||
if (typeof process === "undefined") return undefined;
|
||||
|
||||
// If process.env already has entries, the bug is not triggered.
|
||||
if (Object.keys(process.env).length > 0) return undefined;
|
||||
|
||||
if (_procEnvCache === null) {
|
||||
_procEnvCache = new Map();
|
||||
try {
|
||||
const { readFileSync } = require("node:fs") as typeof import("node:fs");
|
||||
const data = readFileSync("/proc/self/environ", "utf-8");
|
||||
for (const entry of data.split("\0")) {
|
||||
const idx = entry.indexOf("=");
|
||||
if (idx > 0) {
|
||||
_procEnvCache.set(entry.slice(0, idx), entry.slice(idx + 1));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// /proc/self/environ may not be readable.
|
||||
}
|
||||
}
|
||||
|
||||
return _procEnvCache.get(key);
|
||||
}
|
||||
import type { KnownProvider, ProviderEnv } from "./types.ts";
|
||||
import { getProviderEnvValue } from "./utils/provider-env.ts";
|
||||
|
||||
let cachedVertexAdcCredentialsExists: boolean | null = null;
|
||||
|
||||
function hasVertexAdcCredentials(): boolean {
|
||||
function hasVertexAdcCredentials(env?: ProviderEnv): boolean {
|
||||
const explicitCredentialsPath = env?.GOOGLE_APPLICATION_CREDENTIALS;
|
||||
if (explicitCredentialsPath) {
|
||||
return _existsSync ? _existsSync(explicitCredentialsPath) : false;
|
||||
}
|
||||
|
||||
if (cachedVertexAdcCredentialsExists === null) {
|
||||
// If node modules haven't loaded yet (async import race at startup),
|
||||
// return false WITHOUT caching so the next call retries once they're ready.
|
||||
@@ -75,7 +48,7 @@ function hasVertexAdcCredentials(): boolean {
|
||||
}
|
||||
|
||||
// Check GOOGLE_APPLICATION_CREDENTIALS env var first (standard way)
|
||||
const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS || getProcEnv("GOOGLE_APPLICATION_CREDENTIALS");
|
||||
const gacPath = getProviderEnvValue("GOOGLE_APPLICATION_CREDENTIALS", env);
|
||||
if (gacPath) {
|
||||
cachedVertexAdcCredentialsExists = _existsSync(gacPath);
|
||||
} else {
|
||||
@@ -99,8 +72,10 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
|
||||
}
|
||||
|
||||
const envMap: Record<string, string> = {
|
||||
"ant-ling": "ANT_LING_API_KEY",
|
||||
openai: "OPENAI_API_KEY",
|
||||
"azure-openai-responses": "AZURE_OPENAI_API_KEY",
|
||||
nvidia: "NVIDIA_API_KEY",
|
||||
deepseek: "DEEPSEEK_API_KEY",
|
||||
google: "GEMINI_API_KEY",
|
||||
"google-vertex": "GOOGLE_CLOUD_API_KEY",
|
||||
@@ -110,6 +85,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
|
||||
zai: "ZAI_API_KEY",
|
||||
"zai-coding-cn": "ZAI_CODING_CN_API_KEY",
|
||||
mistral: "MISTRAL_API_KEY",
|
||||
minimax: "MINIMAX_API_KEY",
|
||||
"minimax-cn": "MINIMAX_CN_API_KEY",
|
||||
@@ -140,13 +116,13 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
|
||||
* credential sources such as AWS profiles, AWS IAM credentials, and Google
|
||||
* Application Default Credentials.
|
||||
*/
|
||||
export function findEnvKeys(provider: KnownProvider): string[] | undefined;
|
||||
export function findEnvKeys(provider: string): string[] | undefined;
|
||||
export function findEnvKeys(provider: string): string[] | undefined {
|
||||
export function findEnvKeys(provider: KnownProvider, env?: ProviderEnv): string[] | undefined;
|
||||
export function findEnvKeys(provider: string, env?: ProviderEnv): string[] | undefined;
|
||||
export function findEnvKeys(provider: string, env?: ProviderEnv): string[] | undefined {
|
||||
const envVars = getApiKeyEnvVars(provider);
|
||||
if (!envVars) return undefined;
|
||||
|
||||
const found = envVars.filter((envVar) => !!process.env[envVar] || !!getProcEnv(envVar));
|
||||
const found = envVars.filter((envVar) => !!getProviderEnvValue(envVar, env));
|
||||
return found.length > 0 ? found : undefined;
|
||||
}
|
||||
|
||||
@@ -155,25 +131,22 @@ export function findEnvKeys(provider: string): string[] | undefined {
|
||||
*
|
||||
* Will not return API keys for providers that require OAuth tokens.
|
||||
*/
|
||||
export function getEnvApiKey(provider: KnownProvider): string | undefined;
|
||||
export function getEnvApiKey(provider: string): string | undefined;
|
||||
export function getEnvApiKey(provider: string): string | undefined {
|
||||
const envKeys = findEnvKeys(provider);
|
||||
export function getEnvApiKey(provider: KnownProvider, env?: ProviderEnv): string | undefined;
|
||||
export function getEnvApiKey(provider: string, env?: ProviderEnv): string | undefined;
|
||||
export function getEnvApiKey(provider: string, env?: ProviderEnv): string | undefined {
|
||||
const envKeys = findEnvKeys(provider, env);
|
||||
if (envKeys?.[0]) {
|
||||
return process.env[envKeys[0]] || getProcEnv(envKeys[0]);
|
||||
return getProviderEnvValue(envKeys[0], env);
|
||||
}
|
||||
|
||||
// Vertex AI supports either an explicit API key or Application Default Credentials.
|
||||
// Auth is configured via `gcloud auth application-default login`.
|
||||
if (provider === "google-vertex") {
|
||||
const hasCredentials = hasVertexAdcCredentials();
|
||||
const hasCredentials = hasVertexAdcCredentials(env);
|
||||
const hasProject = !!(
|
||||
process.env.GOOGLE_CLOUD_PROJECT ||
|
||||
process.env.GCLOUD_PROJECT ||
|
||||
getProcEnv("GOOGLE_CLOUD_PROJECT") ||
|
||||
getProcEnv("GCLOUD_PROJECT")
|
||||
getProviderEnvValue("GOOGLE_CLOUD_PROJECT", env) || getProviderEnvValue("GCLOUD_PROJECT", env)
|
||||
);
|
||||
const hasLocation = !!(process.env.GOOGLE_CLOUD_LOCATION || getProcEnv("GOOGLE_CLOUD_LOCATION"));
|
||||
const hasLocation = !!getProviderEnvValue("GOOGLE_CLOUD_LOCATION", env);
|
||||
|
||||
if (hasCredentials && hasProject && hasLocation) {
|
||||
return "<authenticated>";
|
||||
@@ -189,18 +162,12 @@ export function getEnvApiKey(provider: string): string | undefined {
|
||||
// 5. AWS_CONTAINER_CREDENTIALS_FULL_URI - ECS task roles (full URI)
|
||||
// 6. AWS_WEB_IDENTITY_TOKEN_FILE - IRSA (IAM Roles for Service Accounts)
|
||||
if (
|
||||
process.env.AWS_PROFILE ||
|
||||
(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) ||
|
||||
process.env.AWS_BEARER_TOKEN_BEDROCK ||
|
||||
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI ||
|
||||
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI ||
|
||||
process.env.AWS_WEB_IDENTITY_TOKEN_FILE ||
|
||||
getProcEnv("AWS_PROFILE") ||
|
||||
(getProcEnv("AWS_ACCESS_KEY_ID") && getProcEnv("AWS_SECRET_ACCESS_KEY")) ||
|
||||
getProcEnv("AWS_BEARER_TOKEN_BEDROCK") ||
|
||||
getProcEnv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") ||
|
||||
getProcEnv("AWS_CONTAINER_CREDENTIALS_FULL_URI") ||
|
||||
getProcEnv("AWS_WEB_IDENTITY_TOKEN_FILE")
|
||||
getProviderEnvValue("AWS_PROFILE", env) ||
|
||||
(getProviderEnvValue("AWS_ACCESS_KEY_ID", env) && getProviderEnvValue("AWS_SECRET_ACCESS_KEY", env)) ||
|
||||
getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", env) ||
|
||||
getProviderEnvValue("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", env) ||
|
||||
getProviderEnvValue("AWS_CONTAINER_CREDENTIALS_FULL_URI", env) ||
|
||||
getProviderEnvValue("AWS_WEB_IDENTITY_TOKEN_FILE", env)
|
||||
) {
|
||||
return "<authenticated>";
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
// This file is auto-generated by scripts/generate-image-models.ts
|
||||
// Do not edit manually - run 'npm run generate-image-models' to update
|
||||
|
||||
import type { ImagesApi, ImagesModel } from "./types.js";
|
||||
import type { ImagesApi, ImagesModel } from "./types.ts";
|
||||
|
||||
export const IMAGE_MODELS = {
|
||||
"openrouter": {
|
||||
openrouter: {
|
||||
"black-forest-labs/flux.2-flex": {
|
||||
id: "black-forest-labs/flux.2-flex",
|
||||
name: "Black Forest Labs: FLUX.2 Flex",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"black-forest-labs/flux.2-klein-4b": {
|
||||
id: "black-forest-labs/flux.2-klein-4b",
|
||||
@@ -26,14 +26,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"black-forest-labs/flux.2-max": {
|
||||
id: "black-forest-labs/flux.2-max",
|
||||
@@ -41,14 +41,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"black-forest-labs/flux.2-pro": {
|
||||
id: "black-forest-labs/flux.2-pro",
|
||||
@@ -56,14 +56,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"bytedance-seed/seedream-4.5": {
|
||||
id: "bytedance-seed/seedream-4.5",
|
||||
@@ -71,14 +71,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image","text"],
|
||||
input: ["image", "text"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"google/gemini-2.5-flash-image": {
|
||||
id: "google/gemini-2.5-flash-image",
|
||||
@@ -86,14 +86,29 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 0.3,
|
||||
"output": 2.5,
|
||||
"cacheRead": 0.03,
|
||||
"cacheWrite": 0.08333333333333334
|
||||
}
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 0.3,
|
||||
output: 2.5,
|
||||
cacheRead: 0.03,
|
||||
cacheWrite: 0.08333333333333334,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"google/gemini-3-pro-image": {
|
||||
id: "google/gemini-3-pro-image",
|
||||
name: "Google: Nano Banana Pro (Gemini 3 Pro Image)",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 2,
|
||||
output: 12,
|
||||
cacheRead: 0.19999999999999998,
|
||||
cacheWrite: 0.375,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"google/gemini-3-pro-image-preview": {
|
||||
id: "google/gemini-3-pro-image-preview",
|
||||
@@ -101,14 +116,29 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 2,
|
||||
"output": 12,
|
||||
"cacheRead": 0.19999999999999998,
|
||||
"cacheWrite": 0.375
|
||||
}
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 2,
|
||||
output: 12,
|
||||
cacheRead: 0.19999999999999998,
|
||||
cacheWrite: 0.375,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"google/gemini-3.1-flash-image": {
|
||||
id: "google/gemini-3.1-flash-image",
|
||||
name: "Google: Nano Banana 2 (Gemini 3.1 Flash Image)",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 0.5,
|
||||
output: 3,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"google/gemini-3.1-flash-image-preview": {
|
||||
id: "google/gemini-3.1-flash-image-preview",
|
||||
@@ -116,14 +146,29 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 0.5,
|
||||
"output": 3,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 0.5,
|
||||
output: 3,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"microsoft/mai-image-2.5": {
|
||||
id: "microsoft/mai-image-2.5",
|
||||
name: "Microsoft: MAI-Image-2.5",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openai/gpt-5-image": {
|
||||
id: "openai/gpt-5-image",
|
||||
@@ -131,14 +176,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 10,
|
||||
"output": 10,
|
||||
"cacheRead": 1.25,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 10,
|
||||
output: 10,
|
||||
cacheRead: 1.25,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openai/gpt-5-image-mini": {
|
||||
id: "openai/gpt-5-image-mini",
|
||||
@@ -146,14 +191,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 2.5,
|
||||
"output": 2,
|
||||
"cacheRead": 0.25,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 2.5,
|
||||
output: 2,
|
||||
cacheRead: 0.25,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openai/gpt-5.4-image-2": {
|
||||
id: "openai/gpt-5.4-image-2",
|
||||
@@ -161,14 +206,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image","text"],
|
||||
output: ["image","text"],
|
||||
cost: {
|
||||
"input": 8,
|
||||
"output": 15,
|
||||
"cacheRead": 2,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 8,
|
||||
output: 15,
|
||||
cacheRead: 2,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openrouter/auto": {
|
||||
id: "openrouter/auto",
|
||||
@@ -176,14 +221,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
output: ["text","image"],
|
||||
cost: {
|
||||
"input": -1000000,
|
||||
"output": -1000000,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
input: ["text", "image"],
|
||||
output: ["text", "image"],
|
||||
cost: {
|
||||
input: -1000000,
|
||||
output: -1000000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v3": {
|
||||
id: "recraft/recraft-v3",
|
||||
@@ -191,14 +236,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4": {
|
||||
id: "recraft/recraft-v4",
|
||||
@@ -206,14 +251,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4-pro": {
|
||||
id: "recraft/recraft-v4-pro",
|
||||
@@ -221,14 +266,134 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4-pro-vector": {
|
||||
id: "recraft/recraft-v4-pro-vector",
|
||||
name: "Recraft: Recraft V4 Pro Vector",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4-vector": {
|
||||
id: "recraft/recraft-v4-vector",
|
||||
name: "Recraft: Recraft V4 Vector",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4.1": {
|
||||
id: "recraft/recraft-v4.1",
|
||||
name: "Recraft: Recraft V4.1",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4.1-pro": {
|
||||
id: "recraft/recraft-v4.1-pro",
|
||||
name: "Recraft: Recraft V4.1 Pro",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4.1-pro-vector": {
|
||||
id: "recraft/recraft-v4.1-pro-vector",
|
||||
name: "Recraft: Recraft V4.1 Pro Vector",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4.1-utility": {
|
||||
id: "recraft/recraft-v4.1-utility",
|
||||
name: "Recraft: Recraft V4.1 Utility",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4.1-utility-pro": {
|
||||
id: "recraft/recraft-v4.1-utility-pro",
|
||||
name: "Recraft: Recraft V4.1 Utility Pro",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4.1-vector": {
|
||||
id: "recraft/recraft-v4.1-vector",
|
||||
name: "Recraft: Recraft V4.1 Vector",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-fast": {
|
||||
id: "sourceful/riverflow-v2-fast",
|
||||
@@ -236,14 +401,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-fast-preview": {
|
||||
id: "sourceful/riverflow-v2-fast-preview",
|
||||
@@ -251,14 +416,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-max-preview": {
|
||||
id: "sourceful/riverflow-v2-max-preview",
|
||||
@@ -266,14 +431,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-pro": {
|
||||
id: "sourceful/riverflow-v2-pro",
|
||||
@@ -281,14 +446,14 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-standard-preview": {
|
||||
id: "sourceful/riverflow-v2-standard-preview",
|
||||
@@ -296,14 +461,59 @@ export const IMAGE_MODELS = {
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text","image"],
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2.5-fast": {
|
||||
id: "sourceful/riverflow-v2.5-fast",
|
||||
name: "Sourceful: Riverflow V2.5 Fast",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2.5-pro": {
|
||||
id: "sourceful/riverflow-v2.5-pro",
|
||||
name: "Sourceful: Riverflow V2.5 Pro",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"x-ai/grok-imagine-image-quality": {
|
||||
id: "x-ai/grok-imagine-image-quality",
|
||||
name: "xAI: Grok Imagine Image Quality",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
},
|
||||
} as const satisfies Record<string, Record<string, ImagesModel<ImagesApi>>>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IMAGE_MODELS } from "./image-models.generated.js";
|
||||
import type { ImagesApi, ImagesModel, KnownImagesProvider } from "./types.js";
|
||||
import { IMAGE_MODELS } from "./image-models.generated.ts";
|
||||
import type { ImagesApi, ImagesModel, KnownImagesProvider } from "./types.ts";
|
||||
|
||||
const imageModelRegistry: Map<string, Map<string, ImagesModel<ImagesApi>>> = new Map();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AssistantImages, ImagesApi, ImagesContext, ImagesFunction, ImagesModel, ImagesOptions } from "./types.js";
|
||||
import type { AssistantImages, ImagesApi, ImagesContext, ImagesFunction, ImagesModel, ImagesOptions } from "./types.ts";
|
||||
|
||||
export type ImagesApiFunction = (
|
||||
model: ImagesModel<ImagesApi>,
|
||||
@@ -51,3 +51,7 @@ export function registerImagesApiProvider<TApi extends ImagesApi, TOptions exten
|
||||
export function getImagesApiProvider(api: ImagesApi): ImagesApiProviderInternal | undefined {
|
||||
return imagesApiProviderRegistry.get(api)?.provider;
|
||||
}
|
||||
|
||||
export function clearImagesApiProviders(): void {
|
||||
imagesApiProviderRegistry.clear();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import "./providers/images/register-builtins.js";
|
||||
|
||||
import { getImagesApiProvider } from "./images-api-registry.js";
|
||||
import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ProviderImagesOptions } from "./types.js";
|
||||
import { getImagesApiProvider } from "./images-api-registry.ts";
|
||||
import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ProviderImagesOptions } from "./types.ts";
|
||||
|
||||
function resolveImagesApiProvider(api: ImagesApi) {
|
||||
const provider = getImagesApiProvider(api);
|
||||
|
||||
@@ -1,46 +1,6 @@
|
||||
export type { Static, TSchema } from "typebox";
|
||||
export { Type } from "typebox";
|
||||
import { registerBuiltInImagesApiProviders } from "./providers/register-builtins.ts";
|
||||
|
||||
export * from "./api-registry.js";
|
||||
export * from "./env-api-keys.js";
|
||||
export * from "./image-models.js";
|
||||
export * from "./images.js";
|
||||
export * from "./images-api-registry.js";
|
||||
export * from "./models.js";
|
||||
export type { BedrockOptions, BedrockThinkingDisplay } from "./providers/amazon-bedrock.js";
|
||||
export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from "./providers/anthropic.js";
|
||||
export type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses.js";
|
||||
export * from "./providers/faux.js";
|
||||
export type { GoogleOptions } from "./providers/google.js";
|
||||
export type { GoogleThinkingLevel } from "./providers/google-shared.js";
|
||||
export type { GoogleVertexOptions } from "./providers/google-vertex.js";
|
||||
export * from "./providers/images/register-builtins.js";
|
||||
export type { MistralOptions } from "./providers/mistral.js";
|
||||
export type {
|
||||
OpenAICodexResponsesOptions,
|
||||
OpenAICodexWebSocketDebugStats,
|
||||
} from "./providers/openai-codex-responses.js";
|
||||
export type { OpenAICompletionsOptions } from "./providers/openai-completions.js";
|
||||
export type { OpenAIResponsesOptions } from "./providers/openai-responses.js";
|
||||
export * from "./providers/register-builtins.js";
|
||||
export * from "./session-resources.js";
|
||||
export * from "./stream.js";
|
||||
export * from "./types.js";
|
||||
export * from "./utils/diagnostics.js";
|
||||
export * from "./utils/event-stream.js";
|
||||
export * from "./utils/json-parse.js";
|
||||
export type {
|
||||
OAuthAuthInfo,
|
||||
OAuthCredentials,
|
||||
OAuthLoginCallbacks,
|
||||
OAuthPrompt,
|
||||
OAuthProvider,
|
||||
OAuthProviderId,
|
||||
OAuthProviderInfo,
|
||||
OAuthProviderInterface,
|
||||
OAuthSelectOption,
|
||||
OAuthSelectPrompt,
|
||||
} from "./utils/oauth/types.js";
|
||||
export * from "./utils/overflow.js";
|
||||
export * from "./utils/typebox-helpers.js";
|
||||
export * from "./utils/validation.js";
|
||||
export * from "./base.ts";
|
||||
export * from "./providers/register-builtins.ts";
|
||||
|
||||
registerBuiltInImagesApiProviders();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { MODELS } from "./models.generated.js";
|
||||
import type { Api, KnownProvider, Model, ModelThinkingLevel, Usage } from "./types.js";
|
||||
import { MODELS } from "./models.generated.ts";
|
||||
import type { Api, KnownProvider, Model, ModelThinkingLevel, Usage } from "./types.ts";
|
||||
|
||||
const modelRegistry: Map<string, Map<string, Model<Api>>> = new Map();
|
||||
|
||||
@@ -37,10 +37,13 @@ export function getModels<TProvider extends KnownProvider>(
|
||||
}
|
||||
|
||||
export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage): Usage["cost"] {
|
||||
// Anthropic charges 2x base input for 1h cache writes.
|
||||
const longWrite = usage.cacheWrite1h ?? 0;
|
||||
const shortWrite = usage.cacheWrite - longWrite;
|
||||
usage.cost.input = (model.cost.input / 1000000) * usage.input;
|
||||
usage.cost.output = (model.cost.output / 1000000) * usage.output;
|
||||
usage.cost.cacheRead = (model.cost.cacheRead / 1000000) * usage.cacheRead;
|
||||
usage.cost.cacheWrite = (model.cost.cacheWrite / 1000000) * usage.cacheWrite;
|
||||
usage.cost.cacheWrite = (model.cost.cacheWrite * shortWrite + model.cost.input * 2 * longWrite) / 1000000;
|
||||
usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
|
||||
return usage.cost;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./utils/oauth/index.js";
|
||||
export * from "./utils/oauth/index.ts";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Agent as HttpsAgent } from "node:https";
|
||||
import {
|
||||
BedrockRuntimeClient,
|
||||
type BedrockRuntimeClientConfig,
|
||||
@@ -18,16 +19,23 @@ import {
|
||||
type SystemContentBlock,
|
||||
type ToolChoice,
|
||||
type ToolConfiguration,
|
||||
type ToolResultContentBlock,
|
||||
ToolResultStatus,
|
||||
} from "@aws-sdk/client-bedrock-runtime";
|
||||
import type { DocumentType } from "@smithy/types";
|
||||
import { calculateCost } from "../models.js";
|
||||
import { NodeHttpHandler } from "@smithy/node-http-handler";
|
||||
import type { BuildMiddleware, DocumentType, MetadataBearer } from "@smithy/types";
|
||||
import { HttpProxyAgent } from "http-proxy-agent";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import { registerApiProvider } from "../api-registry.ts";
|
||||
import { calculateCost } from "../models.ts";
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
CacheRetention,
|
||||
Context,
|
||||
ImageContent,
|
||||
Model,
|
||||
ProviderEnv,
|
||||
SimpleStreamOptions,
|
||||
StopReason,
|
||||
StreamFunction,
|
||||
@@ -39,12 +47,14 @@ import type {
|
||||
Tool,
|
||||
ToolCall,
|
||||
ToolResultMessage,
|
||||
} from "../types.js";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { parseStreamingJson } from "../utils/json-parse.js";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||
import { adjustMaxTokensForThinking, buildBaseOptions, clampReasoning } from "./simple-options.js";
|
||||
import { transformMessages } from "./transform-messages.js";
|
||||
} from "../types.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { parseStreamingJson } from "../utils/json-parse.ts";
|
||||
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { adjustMaxTokensForThinking, buildBaseOptions, clampReasoning } from "./simple-options.ts";
|
||||
import { transformMessages } from "./transform-messages.ts";
|
||||
|
||||
export type BedrockThinkingDisplay = "summarized" | "omitted";
|
||||
|
||||
@@ -64,7 +74,7 @@ export interface BedrockOptions extends StreamOptions {
|
||||
* - "omitted": Thinking content is redacted but the signature still travels back
|
||||
* for multi-turn continuity, reducing time-to-first-text-token.
|
||||
*
|
||||
* Note: Anthropic's API default for Claude Opus 4.7 and Mythos Preview is
|
||||
* Note: Anthropic's API default for Claude Opus 4.8 and Mythos Preview is
|
||||
* "omitted". We default to "summarized" here to keep behavior consistent with
|
||||
* older Claude 4 models. Only applies to Claude models on Bedrock.
|
||||
*/
|
||||
@@ -84,6 +94,8 @@ export interface BedrockOptions extends StreamOptions {
|
||||
|
||||
type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string };
|
||||
|
||||
const EMPTY_TEXT_PLACEHOLDER = "<empty>";
|
||||
|
||||
export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOptions> = (
|
||||
model: Model<"bedrock-converse-stream">,
|
||||
context: Context,
|
||||
@@ -113,18 +125,18 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
|
||||
const blocks = output.content as Block[];
|
||||
|
||||
const config: BedrockRuntimeClientConfig = {
|
||||
profile: options.profile,
|
||||
profile: options.profile || getProviderEnvValue("AWS_PROFILE", options.env),
|
||||
};
|
||||
const configuredRegion = getConfiguredBedrockRegion(options);
|
||||
const hasConfiguredProfile = hasConfiguredBedrockProfile();
|
||||
const hasAmbientConfiguredProfile = Boolean(getProviderEnvValue("AWS_PROFILE"));
|
||||
const endpointRegion = getStandardBedrockEndpointRegion(model.baseUrl);
|
||||
const useExplicitEndpoint = shouldUseExplicitBedrockEndpoint(
|
||||
model.baseUrl,
|
||||
configuredRegion,
|
||||
hasConfiguredProfile,
|
||||
hasAmbientConfiguredProfile,
|
||||
);
|
||||
|
||||
// Only pin standard AWS Bedrock runtime endpoints when no region/profile is configured.
|
||||
// Only pin standard AWS Bedrock runtime endpoints when no region or ambient AWS_PROFILE is configured.
|
||||
// This preserves custom endpoints (VPC/proxy) from #3402 without forcing built-in
|
||||
// catalog defaults such as us-east-1 to override AWS_REGION/AWS_PROFILE.
|
||||
if (useExplicitEndpoint) {
|
||||
@@ -132,54 +144,52 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
|
||||
}
|
||||
|
||||
// Resolve bearer token for Bedrock API key auth.
|
||||
const bearerToken = options.bearerToken || process.env.AWS_BEARER_TOKEN_BEDROCK || undefined;
|
||||
const useBearerToken = bearerToken !== undefined && process.env.AWS_BEDROCK_SKIP_AUTH !== "1";
|
||||
const skipAuth = getProviderEnvValue("AWS_BEDROCK_SKIP_AUTH", options.env) === "1";
|
||||
const bearerToken =
|
||||
options.bearerToken || getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", options.env) || undefined;
|
||||
const useBearerToken = bearerToken !== undefined && !skipAuth;
|
||||
|
||||
// in Node.js/Bun environment only
|
||||
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
|
||||
// Region resolution: explicit option > env vars > SDK default chain.
|
||||
// When AWS_PROFILE is set, we leave region undefined so the SDK can
|
||||
// resovle it from aws profile configs. Otherwise fall back to us-east-1.
|
||||
if (configuredRegion) {
|
||||
// Region resolution: ARN-embedded > explicit option > env vars > SDK default chain.
|
||||
// When the model ID is an inference profile ARN, extract the region from it.
|
||||
// This avoids conflicts with AWS_REGION set for other services.
|
||||
const arnRegionMatch = model.id.match(/^arn:aws(?:-[a-z0-9-]+)?:bedrock:([a-z0-9-]+):/);
|
||||
if (arnRegionMatch) {
|
||||
config.region = arnRegionMatch[1];
|
||||
} else if (configuredRegion) {
|
||||
config.region = configuredRegion;
|
||||
} else if (endpointRegion && useExplicitEndpoint) {
|
||||
config.region = endpointRegion;
|
||||
} else if (!hasConfiguredProfile) {
|
||||
} else if (!hasAmbientConfiguredProfile) {
|
||||
config.region = "us-east-1";
|
||||
}
|
||||
|
||||
// Support proxies that don't need authentication
|
||||
if (process.env.AWS_BEDROCK_SKIP_AUTH === "1") {
|
||||
if (skipAuth) {
|
||||
config.credentials = {
|
||||
accessKeyId: "dummy-access-key",
|
||||
secretAccessKey: "dummy-secret-key",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.HTTP_PROXY ||
|
||||
process.env.HTTPS_PROXY ||
|
||||
process.env.NO_PROXY ||
|
||||
process.env.http_proxy ||
|
||||
process.env.https_proxy ||
|
||||
process.env.no_proxy
|
||||
) {
|
||||
const nodeHttpHandler = await import("@smithy/node-http-handler");
|
||||
const proxyAgent = await import("proxy-agent");
|
||||
|
||||
const agent = new proxyAgent.ProxyAgent();
|
||||
const credentials = getConfiguredBedrockCredentials(options.env);
|
||||
if (!skipAuth && credentials) {
|
||||
config.credentials = credentials;
|
||||
}
|
||||
|
||||
const proxyUrl = resolveHttpProxyUrlForTarget(model.baseUrl, options.env);
|
||||
if (proxyUrl) {
|
||||
// Bedrock runtime uses NodeHttp2Handler by default since v3.798.0, which is based
|
||||
// on `http2` module and has no support for http agent.
|
||||
// Use NodeHttpHandler to support http agent.
|
||||
config.requestHandler = new nodeHttpHandler.NodeHttpHandler({
|
||||
httpAgent: agent,
|
||||
httpsAgent: agent,
|
||||
// Use NodeHttpHandler to support HTTP(S) proxy agents.
|
||||
config.requestHandler = new NodeHttpHandler({
|
||||
httpAgent: new HttpProxyAgent(proxyUrl),
|
||||
httpsAgent: new HttpsProxyAgent(proxyUrl) as unknown as HttpsAgent,
|
||||
});
|
||||
} else if (process.env.AWS_BEDROCK_FORCE_HTTP1 === "1") {
|
||||
} else if (getProviderEnvValue("AWS_BEDROCK_FORCE_HTTP1", options.env) === "1") {
|
||||
// Some custom endpoints require HTTP/1.1 instead of HTTP/2
|
||||
const nodeHttpHandler = await import("@smithy/node-http-handler");
|
||||
config.requestHandler = new nodeHttpHandler.NodeHttpHandler();
|
||||
config.requestHandler = new NodeHttpHandler();
|
||||
}
|
||||
} else {
|
||||
// Non-Node environment (browser): fall back to us-east-1 since
|
||||
@@ -195,13 +205,17 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
|
||||
|
||||
try {
|
||||
const client = new BedrockRuntimeClient(config);
|
||||
const cacheRetention = resolveCacheRetention(options.cacheRetention);
|
||||
if (options.headers && Object.keys(options.headers).length > 0) {
|
||||
addCustomHeadersMiddleware(client, options.headers);
|
||||
}
|
||||
const cacheRetention = resolveCacheRetention(options.cacheRetention, options.env);
|
||||
const inferenceMaxTokens = options.maxTokens ?? (isAnthropicClaudeModel(model) ? model.maxTokens : undefined);
|
||||
let commandInput = {
|
||||
modelId: model.id,
|
||||
messages: convertMessages(context, model, cacheRetention),
|
||||
system: buildSystemPrompt(context.systemPrompt, model, cacheRetention),
|
||||
messages: convertMessages(context, model, cacheRetention, options.env),
|
||||
system: buildSystemPrompt(context.systemPrompt, model, cacheRetention, options.env),
|
||||
inferenceConfig: {
|
||||
...(options.maxTokens !== undefined && { maxTokens: options.maxTokens }),
|
||||
...(inferenceMaxTokens !== undefined && { maxTokens: inferenceMaxTokens }),
|
||||
...(options.temperature !== undefined && { temperature: options.temperature }),
|
||||
},
|
||||
toolConfig: convertToolConfig(context.tools, options.toolChoice),
|
||||
@@ -292,6 +306,13 @@ const BEDROCK_ERROR_PREFIXES: Record<string, string> = {
|
||||
ServiceUnavailableException: "Service unavailable",
|
||||
};
|
||||
|
||||
/**
|
||||
* Some models reject the account/profile's configured Bedrock data retention mode
|
||||
* (e.g. "data retention mode 'default' is not available for this model"). Point
|
||||
* users at the AWS docs explaining how to configure a supported mode.
|
||||
*/
|
||||
const BEDROCK_DATA_RETENTION_DOCS_URL = "https://docs.aws.amazon.com/bedrock/latest/userguide/data-retention.html";
|
||||
|
||||
/**
|
||||
* Format a Bedrock error with a human-readable prefix.
|
||||
* AWS SDK exceptions (both from `client.send()` and from stream event items)
|
||||
@@ -301,11 +322,50 @@ const BEDROCK_ERROR_PREFIXES: Record<string, string> = {
|
||||
*/
|
||||
function formatBedrockError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
const dataRetentionHint = /data retention mode/i.test(message)
|
||||
? ` See ${BEDROCK_DATA_RETENTION_DOCS_URL} for supported data retention modes.`
|
||||
: "";
|
||||
if (error instanceof BedrockRuntimeServiceException) {
|
||||
const prefix = BEDROCK_ERROR_PREFIXES[error.name] ?? error.name;
|
||||
return `${prefix}: ${message}`;
|
||||
return `${prefix}: ${message}${dataRetentionHint}`;
|
||||
}
|
||||
return message;
|
||||
return `${message}${dataRetentionHint}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header keys that must never be overwritten by caller-supplied headers.
|
||||
* `host` and `x-amz-*` participate in the SigV4 canonical request; `authorization`
|
||||
* is owned by SigV4 or the bearer-token path (config.token + authSchemePreference).
|
||||
* Compared case-insensitively (caller key is lower-cased before lookup).
|
||||
*/
|
||||
const RESERVED_HEADER_EXACT = new Set(["authorization", "host"]);
|
||||
|
||||
function isReservedHeader(key: string): boolean {
|
||||
const lower = key.toLowerCase();
|
||||
return lower.startsWith("x-amz-") || RESERVED_HEADER_EXACT.has(lower);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach caller-supplied headers to the outgoing Bedrock request via a Smithy
|
||||
* `build`-step middleware. The `build` step runs after request serialisation but
|
||||
* before SigV4 signing, so injected headers are covered by the signature. Reserved
|
||||
* SigV4 / auth headers (`x-amz-*`, `authorization`, `host`) are silently skipped;
|
||||
* all other caller headers override any existing same-named header on the request.
|
||||
*/
|
||||
function addCustomHeadersMiddleware(client: BedrockRuntimeClient, headers: Record<string, string>): void {
|
||||
const middleware: BuildMiddleware<object, MetadataBearer> = (next) => async (args) => {
|
||||
const request = args.request;
|
||||
if (request && typeof request === "object" && "headers" in request) {
|
||||
const requestHeaders = (request as { headers: Record<string, string> }).headers;
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (!isReservedHeader(key)) {
|
||||
requestHeaders[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return next(args);
|
||||
};
|
||||
client.middlewareStack.add(middleware, { step: "build", name: "pi-ai-custom-headers", priority: "low" });
|
||||
}
|
||||
|
||||
export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", SimpleStreamOptions> = (
|
||||
@@ -327,8 +387,10 @@ export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", Simp
|
||||
} satisfies BedrockOptions);
|
||||
}
|
||||
|
||||
// Undefined means the caller did not request an output cap; let the helper use the model cap.
|
||||
// Do not coerce to 0 here, or the thinking budget would become the entire maxTokens value.
|
||||
const adjusted = adjustMaxTokensForThinking(
|
||||
base.maxTokens || 0,
|
||||
base.maxTokens,
|
||||
model.maxTokens,
|
||||
options.reasoning,
|
||||
options.thinkingBudgets,
|
||||
@@ -352,6 +414,14 @@ export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", Simp
|
||||
} satisfies BedrockOptions);
|
||||
};
|
||||
|
||||
export function register(): void {
|
||||
registerApiProvider({
|
||||
api: "bedrock-converse-stream",
|
||||
stream: streamBedrock,
|
||||
streamSimple: streamSimpleBedrock,
|
||||
});
|
||||
}
|
||||
|
||||
function handleContentBlockStart(
|
||||
event: ContentBlockStartEvent,
|
||||
blocks: Block[],
|
||||
@@ -491,12 +561,19 @@ function getModelMatchCandidates(modelId: string, modelName?: string): string[]
|
||||
|
||||
function supportsAdaptiveThinking(modelId: string, modelName?: string): boolean {
|
||||
const candidates = getModelMatchCandidates(modelId, modelName);
|
||||
return candidates.some((s) => s.includes("opus-4-6") || s.includes("opus-4-7") || s.includes("sonnet-4-6"));
|
||||
return candidates.some(
|
||||
(s) =>
|
||||
s.includes("opus-4-6") ||
|
||||
s.includes("opus-4-7") ||
|
||||
s.includes("opus-4-8") ||
|
||||
s.includes("sonnet-4-6") ||
|
||||
s.includes("fable-5"),
|
||||
);
|
||||
}
|
||||
|
||||
function supportsNativeXhighEffort(model: Model<"bedrock-converse-stream">): boolean {
|
||||
const candidates = getModelMatchCandidates(model.id, model.name);
|
||||
return candidates.some((s) => s.includes("opus-4-7"));
|
||||
return candidates.some((s) => s.includes("opus-4-7") || s.includes("opus-4-8") || s.includes("fable-5"));
|
||||
}
|
||||
|
||||
function mapThinkingLevelToEffort(
|
||||
@@ -525,11 +602,11 @@ function mapThinkingLevelToEffort(
|
||||
* Resolve cache retention preference.
|
||||
* Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility.
|
||||
*/
|
||||
function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention {
|
||||
function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention {
|
||||
if (cacheRetention) {
|
||||
return cacheRetention;
|
||||
}
|
||||
if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") {
|
||||
if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") {
|
||||
return "long";
|
||||
}
|
||||
return "short";
|
||||
@@ -564,14 +641,14 @@ function isAnthropicClaudeModel(model: Model<"bedrock-converse-stream">): boolea
|
||||
* As a last resort, set AWS_BEDROCK_FORCE_CACHE=1 to enable cache points.
|
||||
* Amazon Nova models have automatic caching and don't need explicit cache points.
|
||||
*/
|
||||
function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean {
|
||||
function supportsPromptCaching(model: Model<"bedrock-converse-stream">, env?: ProviderEnv): boolean {
|
||||
const candidates = getModelMatchCandidates(model.id, model.name);
|
||||
|
||||
const hasClaudeRef = candidates.some((s) => s.includes("claude"));
|
||||
if (!hasClaudeRef) {
|
||||
// Application inference profiles don't contain the model name in the ARN.
|
||||
// Allow users to force cache points via environment variable.
|
||||
if (typeof process !== "undefined" && process.env.AWS_BEDROCK_FORCE_CACHE === "1") return true;
|
||||
if (getProviderEnvValue("AWS_BEDROCK_FORCE_CACHE", env) === "1") return true;
|
||||
return false;
|
||||
}
|
||||
// Claude 4.x models (opus-4, sonnet-4, haiku-4)
|
||||
@@ -599,13 +676,14 @@ function buildSystemPrompt(
|
||||
systemPrompt: string | undefined,
|
||||
model: Model<"bedrock-converse-stream">,
|
||||
cacheRetention: CacheRetention,
|
||||
env?: ProviderEnv,
|
||||
): SystemContentBlock[] | undefined {
|
||||
if (!systemPrompt) return undefined;
|
||||
|
||||
const blocks: SystemContentBlock[] = [{ text: sanitizeSurrogates(systemPrompt) }];
|
||||
|
||||
// Add cache point for supported Claude models when caching is enabled
|
||||
if (cacheRetention !== "none" && supportsPromptCaching(model)) {
|
||||
if (cacheRetention !== "none" && supportsPromptCaching(model, env)) {
|
||||
blocks.push({
|
||||
cachePoint: { type: CachePointType.DEFAULT, ...(cacheRetention === "long" ? { ttl: CacheTTL.ONE_HOUR } : {}) },
|
||||
});
|
||||
@@ -619,10 +697,34 @@ function normalizeToolCallId(id: string): string {
|
||||
return sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized;
|
||||
}
|
||||
|
||||
function createNonBlankTextBlock(text: string): ContentBlock.TextMember | undefined {
|
||||
const sanitized = sanitizeSurrogates(text);
|
||||
return sanitized.trim().length === 0 ? undefined : { text: sanitized };
|
||||
}
|
||||
|
||||
function createRequiredTextBlock(text: string): ContentBlock.TextMember {
|
||||
return createNonBlankTextBlock(text) ?? { text: EMPTY_TEXT_PLACEHOLDER };
|
||||
}
|
||||
|
||||
function convertToolResultContent(content: (TextContent | ImageContent)[]): ToolResultContentBlock[] {
|
||||
const result: ToolResultContentBlock[] = [];
|
||||
for (const c of content) {
|
||||
if (c.type === "image") {
|
||||
result.push({ image: createImageBlock(c.mimeType, c.data) });
|
||||
} else {
|
||||
const textBlock = createNonBlankTextBlock(c.text);
|
||||
if (textBlock) result.push(textBlock);
|
||||
}
|
||||
}
|
||||
if (result.length === 0) result.push({ text: EMPTY_TEXT_PLACEHOLDER });
|
||||
return result;
|
||||
}
|
||||
|
||||
function convertMessages(
|
||||
context: Context,
|
||||
model: Model<"bedrock-converse-stream">,
|
||||
cacheRetention: CacheRetention,
|
||||
env?: ProviderEnv,
|
||||
): Message[] {
|
||||
const result: Message[] = [];
|
||||
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
|
||||
@@ -631,24 +733,33 @@ function convertMessages(
|
||||
const m = transformedMessages[i];
|
||||
|
||||
switch (m.role) {
|
||||
case "user":
|
||||
case "user": {
|
||||
const content: ContentBlock[] = [];
|
||||
if (typeof m.content === "string") {
|
||||
content.push(createRequiredTextBlock(m.content));
|
||||
} else {
|
||||
for (const c of m.content) {
|
||||
switch (c.type) {
|
||||
case "text": {
|
||||
const textBlock = createNonBlankTextBlock(c.text);
|
||||
if (textBlock) content.push(textBlock);
|
||||
break;
|
||||
}
|
||||
case "image":
|
||||
content.push({ image: createImageBlock(c.mimeType, c.data) });
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (content.length === 0) content.push({ text: EMPTY_TEXT_PLACEHOLDER });
|
||||
}
|
||||
result.push({
|
||||
role: ConversationRole.USER,
|
||||
content:
|
||||
typeof m.content === "string"
|
||||
? [{ text: sanitizeSurrogates(m.content) }]
|
||||
: m.content.map((c) => {
|
||||
switch (c.type) {
|
||||
case "text":
|
||||
return { text: sanitizeSurrogates(c.text) };
|
||||
case "image":
|
||||
return { image: createImageBlock(c.mimeType, c.data) };
|
||||
default:
|
||||
throw new Error("Unknown user content type");
|
||||
}
|
||||
}),
|
||||
content,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "assistant": {
|
||||
// Skip assistant messages with empty content (e.g., from aborted requests)
|
||||
// Bedrock rejects messages with empty content arrays
|
||||
@@ -658,19 +769,22 @@ function convertMessages(
|
||||
const contentBlocks: ContentBlock[] = [];
|
||||
for (const c of m.content) {
|
||||
switch (c.type) {
|
||||
case "text":
|
||||
case "text": {
|
||||
// Skip empty text blocks
|
||||
if (c.text.trim().length === 0) continue;
|
||||
contentBlocks.push({ text: sanitizeSurrogates(c.text) });
|
||||
const textBlock = createNonBlankTextBlock(c.text);
|
||||
if (!textBlock) continue;
|
||||
contentBlocks.push(textBlock);
|
||||
break;
|
||||
}
|
||||
case "toolCall":
|
||||
contentBlocks.push({
|
||||
toolUse: { toolUseId: c.id, name: c.name, input: c.arguments },
|
||||
});
|
||||
break;
|
||||
case "thinking":
|
||||
case "thinking": {
|
||||
// Skip empty thinking blocks
|
||||
if (c.thinking.trim().length === 0) continue;
|
||||
const thinking = sanitizeSurrogates(c.thinking);
|
||||
if (thinking.trim().length === 0) continue;
|
||||
// Only Anthropic models support the signature field in reasoningText.
|
||||
// For other models, we omit the signature to avoid errors like:
|
||||
// "This model doesn't support the reasoningContent.reasoningText.signature field"
|
||||
@@ -679,12 +793,12 @@ function convertMessages(
|
||||
// persisted message lacks a signature, Bedrock rejects the replayed
|
||||
// reasoning block. Fall back to plain text, matching Anthropic.
|
||||
if (!c.thinkingSignature || c.thinkingSignature.trim().length === 0) {
|
||||
contentBlocks.push({ text: sanitizeSurrogates(c.thinking) });
|
||||
contentBlocks.push({ text: thinking });
|
||||
} else {
|
||||
contentBlocks.push({
|
||||
reasoningContent: {
|
||||
reasoningText: {
|
||||
text: sanitizeSurrogates(c.thinking),
|
||||
text: thinking,
|
||||
signature: c.thinkingSignature,
|
||||
},
|
||||
},
|
||||
@@ -693,13 +807,14 @@ function convertMessages(
|
||||
} else {
|
||||
contentBlocks.push({
|
||||
reasoningContent: {
|
||||
reasoningText: { text: sanitizeSurrogates(c.thinking) },
|
||||
reasoningText: { text: thinking },
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error("Unknown assistant content type");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Skip if all content blocks were filtered out
|
||||
@@ -721,11 +836,7 @@ function convertMessages(
|
||||
toolResults.push({
|
||||
toolResult: {
|
||||
toolUseId: m.toolCallId,
|
||||
content: m.content.map((c) =>
|
||||
c.type === "image"
|
||||
? { image: createImageBlock(c.mimeType, c.data) }
|
||||
: { text: sanitizeSurrogates(c.text) },
|
||||
),
|
||||
content: convertToolResultContent(m.content),
|
||||
status: m.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS,
|
||||
},
|
||||
});
|
||||
@@ -737,11 +848,7 @@ function convertMessages(
|
||||
toolResults.push({
|
||||
toolResult: {
|
||||
toolUseId: nextMsg.toolCallId,
|
||||
content: nextMsg.content.map((c) =>
|
||||
c.type === "image"
|
||||
? { image: createImageBlock(c.mimeType, c.data) }
|
||||
: { text: sanitizeSurrogates(c.text) },
|
||||
),
|
||||
content: convertToolResultContent(nextMsg.content),
|
||||
status: nextMsg.isError ? ToolResultStatus.ERROR : ToolResultStatus.SUCCESS,
|
||||
},
|
||||
});
|
||||
@@ -758,12 +865,12 @@ function convertMessages(
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error("Unknown message role");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Add cache point to the last user message for supported Claude models when caching is enabled
|
||||
if (cacheRetention !== "none" && supportsPromptCaching(model) && result.length > 0) {
|
||||
if (cacheRetention !== "none" && supportsPromptCaching(model, env) && result.length > 0) {
|
||||
const lastMessage = result[result.length - 1];
|
||||
if (lastMessage.role === ConversationRole.USER && lastMessage.content) {
|
||||
(lastMessage.content as ContentBlock[]).push({
|
||||
@@ -825,19 +932,26 @@ function mapStopReason(reason: string | undefined): StopReason {
|
||||
}
|
||||
|
||||
function getConfiguredBedrockRegion(options: BedrockOptions): string | undefined {
|
||||
if (typeof process === "undefined") {
|
||||
return options.region;
|
||||
}
|
||||
|
||||
return options.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || undefined;
|
||||
return (
|
||||
options.region ||
|
||||
getProviderEnvValue("AWS_REGION", options.env) ||
|
||||
getProviderEnvValue("AWS_DEFAULT_REGION", options.env) ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
function hasConfiguredBedrockProfile(): boolean {
|
||||
if (typeof process === "undefined") {
|
||||
return false;
|
||||
function getConfiguredBedrockCredentials(env?: ProviderEnv): BedrockRuntimeClientConfig["credentials"] | undefined {
|
||||
const accessKeyId = getProviderEnvValue("AWS_ACCESS_KEY_ID", env);
|
||||
const secretAccessKey = getProviderEnvValue("AWS_SECRET_ACCESS_KEY", env);
|
||||
if (!accessKeyId || !secretAccessKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Boolean(process.env.AWS_PROFILE);
|
||||
const sessionToken = getProviderEnvValue("AWS_SESSION_TOKEN", env);
|
||||
return {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
...(sessionToken ? { sessionToken } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getStandardBedrockEndpointRegion(baseUrl: string | undefined): string | undefined {
|
||||
@@ -857,14 +971,14 @@ function getStandardBedrockEndpointRegion(baseUrl: string | undefined): string |
|
||||
function shouldUseExplicitBedrockEndpoint(
|
||||
baseUrl: string,
|
||||
configuredRegion: string | undefined,
|
||||
hasConfiguredProfile: boolean,
|
||||
hasAmbientConfiguredProfile: boolean,
|
||||
): boolean {
|
||||
const endpointRegion = getStandardBedrockEndpointRegion(baseUrl);
|
||||
if (!endpointRegion) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !configuredRegion && !hasConfiguredProfile;
|
||||
return !configuredRegion && !hasAmbientConfiguredProfile;
|
||||
}
|
||||
|
||||
function isGovCloudBedrockTarget(model: Model<"bedrock-converse-stream">, options: BedrockOptions): boolean {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user