Compare commits
596
Commits
17b7bdc769
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fffe4517b | ||
|
|
ab138d72fc | ||
|
|
5627f47e2b | ||
|
|
2c1f5dfafe | ||
|
|
b508fef698 | ||
|
|
27f3b36ce5 | ||
|
|
5d0e03dc53 | ||
|
|
4f7dcfda61 | ||
|
|
1c03758fc1 | ||
|
|
0f82d6fa8f | ||
|
|
5a317e6d2a | ||
|
|
5329a93e3e | ||
|
|
94d458883e | ||
|
|
b824195b36 | ||
|
|
318c1a9ce9 | ||
|
|
9f13446afd | ||
|
|
479bf0872a | ||
|
|
36932321e0 | ||
|
|
70ce8479b2 | ||
|
|
57b57aed67 | ||
|
|
0cd4811739 | ||
|
|
165153c63c | ||
|
|
3a13301f75 | ||
|
|
732fe5c4c1 | ||
|
|
7c238edc1a | ||
|
|
55ad8c4265 | ||
|
|
1eecccc21e | ||
|
|
c65f145376 | ||
|
|
8661505b5a | ||
|
|
4f096f26b5 | ||
|
|
e7506c5eb8 | ||
|
|
967274bfbd | ||
|
|
6d15569b9b | ||
|
|
8810d3245e | ||
|
|
bd70eda4e0 | ||
|
|
771120778b | ||
|
|
d077302b92 | ||
|
|
c82113f7c4 | ||
|
|
51f0c7e019 | ||
|
|
26cc5e9306 | ||
|
|
6733aeef01 | ||
|
|
c215c72873 | ||
|
|
87a1b6f1eb | ||
|
|
882316fb3b | ||
|
|
74ddd568eb | ||
|
|
4c2bd3ea78 | ||
|
|
dd312b71ee | ||
|
|
2141672310 | ||
|
|
3600843683 | ||
|
|
38a953cd91 | ||
|
|
fc1115b167 | ||
|
|
9b7a58d5c4 | ||
|
|
7ad6030498 | ||
|
|
5087abc5ab | ||
|
|
90398391dd | ||
|
|
6486caf992 | ||
|
|
0ce4fc04ba | ||
|
|
3812126897 | ||
|
|
ec275da601 | ||
|
|
5f0c960048 | ||
|
|
e35cb50f3a | ||
|
|
b8f54eeb6d | ||
|
|
959ed7a554 | ||
|
|
0666434ec8 | ||
|
|
14101d45dc | ||
|
|
4e2f6fcb9c | ||
|
|
043465dc6b | ||
|
|
9b5fad3970 | ||
|
|
45e24375d5 | ||
|
|
d9e9c0f13d | ||
|
|
de4159b20f | ||
|
|
6826db7e0c | ||
|
|
a1fbe1e754 | ||
|
|
8b261cdae1 | ||
|
|
881e241e67 | ||
|
|
00903264f3 | ||
|
|
f598a36ac0 | ||
|
|
2d195f4058 | ||
|
|
06883c51b7 | ||
|
|
a35d9a3d45 | ||
|
|
0710738173 | ||
|
|
960a1b4364 | ||
|
|
a06f6bfb3d | ||
|
|
618880d38d | ||
|
|
79b76c8789 | ||
|
|
897c025b85 | ||
|
|
5b12c3e085 | ||
|
|
623c31615d | ||
|
|
af719e387d | ||
|
|
dddb21bd70 | ||
|
|
e8d4e964cb | ||
|
|
eb24cb04dc | ||
|
|
e84057f402 | ||
|
|
15c3006d09 | ||
|
|
b3209c2012 | ||
|
|
6f5a424661 | ||
|
|
8d47d2670b | ||
|
|
b19de45a82 | ||
|
|
1e88893ef9 | ||
|
|
1fd2e09971 | ||
|
|
f85d831104 | ||
|
|
2b24db5a15 | ||
|
|
e1945d977c | ||
|
|
a10875d68e | ||
|
|
f35dcef4d0 | ||
|
|
f5ebae1134 | ||
|
|
6be4e8388b | ||
|
|
9e3f87ac52 | ||
|
|
ceb01f6eaa | ||
|
|
1b13563e19 | ||
|
|
848a1e9a5b | ||
|
|
a77453338c | ||
|
|
d12bc2ea0b | ||
|
|
a1df3742b1 | ||
|
|
d8818b776b | ||
|
|
40c36ccb1d | ||
|
|
80c151d084 | ||
|
|
c6a8759e7b | ||
|
|
f17b5212c7 | ||
|
|
85f70d4bc1 | ||
|
|
b284934e34 | ||
|
|
2d33321fab | ||
|
|
0975471986 | ||
|
|
214d6e21dd | ||
|
|
fca5c23345 | ||
|
|
f9b1500ba2 | ||
|
|
931bf3b27d | ||
|
|
6bb671be21 | ||
|
|
b8f2fa479c | ||
|
|
1639be9841 | ||
|
|
907392cce8 | ||
|
|
6776973e4c | ||
|
|
73a3e674c9 | ||
|
|
994e75233b | ||
|
|
8b6e3070dd | ||
|
|
b860c67ea2 | ||
|
|
533477c2e7 | ||
|
|
5aa2dc579a | ||
|
|
280b3d2a25 | ||
|
|
ec74f4cdc4 | ||
|
|
5af9dc8e4a | ||
|
|
ca6d4b4b72 | ||
|
|
cccecbe7ca | ||
|
|
7a64c38493 | ||
|
|
b1361d86e9 | ||
|
|
6181426abd | ||
|
|
cd331b93c6 | ||
|
|
60eabea19a | ||
|
|
7394ec6784 | ||
|
|
28b7cc414b | ||
|
|
86d5cf9403 | ||
|
|
6f0a23e5cc | ||
|
|
2f4a46bbdb | ||
|
|
4872cf2aac | ||
|
|
734d4b44ae | ||
|
|
6810f51f46 | ||
|
|
a1748b552c | ||
|
|
62b2d27b4a | ||
|
|
62e2b44305 | ||
|
|
b5c105545c | ||
|
|
aa0fb2f787 | ||
|
|
1b6d4dbfd8 | ||
|
|
61c20b0995 | ||
|
|
4ef7b03651 | ||
|
|
af615ab177 | ||
|
|
338e39bcdb | ||
|
|
3cfe04be3a | ||
|
|
70d153b365 | ||
|
|
ce93849948 | ||
|
|
3c499719fe | ||
|
|
9e1d3df7ad | ||
|
|
18bac16a23 | ||
|
|
9005856f77 | ||
|
|
e39372a52a | ||
|
|
223a74a9e2 | ||
|
|
f38d9a16ea | ||
|
|
b135fa5e99 | ||
|
|
b7a22146a0 | ||
|
|
07b8b6d41b | ||
|
|
8017c55d82 | ||
|
|
b8e548b92d | ||
|
|
90e38551cf | ||
|
|
3e8164e757 | ||
|
|
3b3381900f | ||
|
|
00ad02d047 | ||
|
|
a774925dec | ||
|
|
8f759911ab | ||
|
|
daf1f6ed50 | ||
|
|
86602240fa | ||
|
|
bfeee487fd | ||
|
|
f9830fe7dd | ||
|
|
de96c5b577 | ||
|
|
6e729ed06c | ||
|
|
f38b2a52b1 | ||
|
|
dd5384df3b | ||
|
|
6576850b7a | ||
|
|
4555aace28 | ||
|
|
47b32ef6ab | ||
|
|
bb5c50f9ab | ||
|
|
32a409c15d | ||
|
|
19514a5d6a | ||
|
|
887184796e | ||
|
|
cd0eb5eb00 | ||
|
|
e8dfb3e68d | ||
|
|
434ce34da7 | ||
|
|
bfb51b7ca9 | ||
|
|
38d9069632 | ||
|
|
e5749b9be0 | ||
|
|
387b5ff7df | ||
|
|
ec6c5beda0 | ||
|
|
8e40fae6f4 | ||
|
|
c961f244ab | ||
|
|
cea0225a6e | ||
|
|
b3189d8e85 | ||
|
|
24bc45a31e | ||
|
|
b02360fd75 | ||
|
|
41c9d2d5c7 | ||
|
|
cc66bd5052 | ||
|
|
d1408d3a2f | ||
|
|
19122fb5ea | ||
|
|
1fb216e6ae | ||
|
|
4b14f7e6b3 | ||
|
|
67767eeee7 | ||
|
|
1bfb4ce119 | ||
|
|
651820dc6d | ||
|
|
75b1557d63 | ||
|
|
23ff518209 | ||
|
|
fab9e0f4b5 | ||
|
|
5ee42b8db8 | ||
|
|
daf438701f | ||
|
|
23ee401287 | ||
|
|
8f598da931 | ||
|
|
956aeed6b2 | ||
|
|
adfa422973 | ||
|
|
10d200d9ba | ||
|
|
7a5228b8e6 | ||
|
|
0eabdf3a1f | ||
|
|
b1808b7c4d | ||
|
|
6645ea0a47 | ||
|
|
b544e53bd7 | ||
|
|
04a4e59100 | ||
|
|
52e914eebc | ||
|
|
9c12fb4372 | ||
|
|
3c18916118 | ||
|
|
be1c030350 | ||
|
|
a7faf2cea6 | ||
|
|
5cc5ece997 | ||
|
|
ffaaf2ecb0 | ||
|
|
6667985883 | ||
|
|
22dcd8a29f | ||
|
|
a2816a053d | ||
|
|
d74660d1d1 | ||
|
|
24c6f772f7 | ||
|
|
a060ffada8 | ||
|
|
b8b12442a0 | ||
|
|
d22899d39e | ||
|
|
af008f802d | ||
|
|
7042ec7d50 | ||
|
|
a2cec5afd1 | ||
|
|
b5a307811b | ||
|
|
5bd49760e5 | ||
|
|
9debf43645 | ||
|
|
81af1f085c | ||
|
|
b67aa2de7d | ||
|
|
9f1cbbd385 | ||
|
|
8a3d9d67d2 | ||
|
|
6218c27fb6 | ||
|
|
b744efd104 | ||
|
|
18384482f6 | ||
|
|
885e8045fe | ||
|
|
7b52bea23b | ||
|
|
a31f002a0a | ||
|
|
8ab7ee05db | ||
|
|
62d71ed17e | ||
|
|
cafa2fa6ea | ||
|
|
95ed9e8719 | ||
|
|
84ea23c2e6 | ||
|
|
ac49740a50 | ||
|
|
bdc7c73143 | ||
|
|
011dbb9a98 | ||
|
|
b9e7d5675b | ||
|
|
c155259c8e | ||
|
|
e33729647c | ||
|
|
e71177900e | ||
|
|
f50fa47a48 | ||
|
|
b71fc9ee54 | ||
|
|
e70cba4618 | ||
|
|
d115819f51 | ||
|
|
822a084b31 | ||
|
|
c2df3041f9 | ||
|
|
aabc502f30 | ||
|
|
65ead5fffd | ||
|
|
63d581914c | ||
|
|
da378fc902 | ||
|
|
8611078058 | ||
|
|
26ab6130e0 | ||
|
|
a82e70047a | ||
|
|
12d1588c05 | ||
|
|
523bade3a6 | ||
|
|
e436b2ae58 | ||
|
|
05ba6a43ad | ||
|
|
88811466f2 | ||
|
|
4d98119440 | ||
|
|
c6d73e8286 | ||
|
|
4efb0ace1f | ||
|
|
5873f25073 | ||
|
|
ebce7ab7b1 | ||
|
|
1a065db0fc | ||
|
|
6d8c49b72d | ||
|
|
925bce2a40 | ||
|
|
b63bdb4402 | ||
|
|
dbcea6c34b | ||
|
|
27b6c688e4 | ||
|
|
a2487b575d | ||
|
|
b10b731531 | ||
|
|
5c5727e3f8 | ||
|
|
c0223a0dea | ||
|
|
d3fcfb2977 | ||
|
|
66bb9d0b82 | ||
|
|
49bfbfb235 | ||
|
|
f597f1b57a | ||
|
|
df9d056d42 | ||
|
|
f3d4aba54e | ||
|
|
9b0d42fdbc | ||
|
|
61533fadb6 | ||
|
|
b1076f1d65 | ||
|
|
a633a53274 | ||
|
|
3aaef94bfe | ||
|
|
ce2ee11ce6 | ||
|
|
56795fb92c | ||
|
|
26434bb867 | ||
|
|
a6a97bca74 | ||
|
|
27bf74a051 | ||
|
|
96e5ceb4fb | ||
|
|
cc9177728d | ||
|
|
ae26cdfbef | ||
|
|
45d8f4d69f | ||
|
|
21df210a7f | ||
|
|
2a55bef036 | ||
|
|
e19742d509 | ||
|
|
2b1f68a3ea | ||
|
|
74d0af93e3 | ||
|
|
520a898bd8 | ||
|
|
9e1de93a04 | ||
|
|
2bf68a5bc8 | ||
|
|
6f86d11000 | ||
|
|
98023b9872 | ||
|
|
5ac3b1e482 | ||
|
|
7f03db9d79 | ||
|
|
4dd5207d1e | ||
|
|
04bdf1da23 | ||
|
|
a558c7656a | ||
|
|
b1fe562e15 | ||
|
|
ba3ebaa68c | ||
|
|
71ab4c06b9 | ||
|
|
e9397fa0dd | ||
|
|
a9a67fd6ad | ||
|
|
536c593539 | ||
|
|
4099359560 | ||
|
|
298f8c1330 | ||
|
|
6a12b6cc34 | ||
|
|
3263d50125 | ||
|
|
c78f7f7d80 | ||
|
|
72ce817de4 | ||
|
|
ac01f64f4d | ||
|
|
8af492ff84 | ||
|
|
defaf8f369 | ||
|
|
fc57716344 | ||
|
|
dc8e586a0b | ||
|
|
7321d6c7c3 | ||
|
|
fd759545c1 | ||
|
|
6ac87c885e | ||
|
|
10c55874e3 | ||
|
|
f9d70e2ae2 | ||
|
|
2060e8f2c1 | ||
|
|
471113fbcf | ||
|
|
e8828cd1b5 | ||
|
|
73ac13e802 | ||
|
|
e7b1aa2a6a | ||
|
|
c550abb11b | ||
|
|
caac1c18e0 | ||
|
|
5831190012 | ||
|
|
b1dc8f9f1c | ||
|
|
4d266420fc | ||
|
|
b02eb7bee3 | ||
|
|
ed50847afe | ||
|
|
ef642b34be | ||
|
|
e4daa4aaa7 | ||
|
|
c889c9a68d | ||
|
|
a91eec295f | ||
|
|
4337f415f9 | ||
|
|
34f9e70ce8 | ||
|
|
e6aa56ff6f | ||
|
|
3a2994cdd7 | ||
|
|
e94af40dab | ||
|
|
b8faa83c47 | ||
|
|
c79506b60f | ||
|
|
e90e2ab391 | ||
|
|
61afdb7773 | ||
|
|
8affa46bce | ||
|
|
cb8784c108 | ||
|
|
e67d75615d | ||
|
|
7bb4096373 | ||
|
|
5244308c0d | ||
|
|
717ff2555f | ||
|
|
d4ed6192ea | ||
|
|
ea1badc0be | ||
|
|
f1287f27e7 | ||
|
|
af655ae632 | ||
|
|
6445999120 | ||
|
|
37a13e3566 | ||
|
|
32c2227e8e | ||
|
|
edf5368e09 | ||
|
|
f9d4c97f1e | ||
|
|
5480421fcc | ||
|
|
c1b3b95c5d | ||
|
|
23cf6c4f88 | ||
|
|
85c9b218dd | ||
|
|
b5ea162f83 | ||
|
|
9da79ab9f0 | ||
|
|
60ec815237 | ||
|
|
3637824c8c | ||
|
|
bb1b05ed20 | ||
|
|
633522278b | ||
|
|
761de69e18 | ||
|
|
e5ea7544d4 | ||
|
|
1a095315ee | ||
|
|
148da0b3d9 | ||
|
|
df390f02e9 | ||
|
|
f8a9555626 | ||
|
|
7919616db5 | ||
|
|
e939cd4e4b | ||
|
|
b715b1ee37 | ||
|
|
14fea520df | ||
|
|
5424280348 | ||
|
|
4d0f2afc1e | ||
|
|
b6de908e9f | ||
|
|
e723afe5f5 | ||
|
|
56ab8c22ba | ||
|
|
b0d8c7b786 | ||
|
|
cfacf04260 | ||
|
|
f086cf3375 | ||
|
|
4343be86c1 | ||
|
|
a64707ee69 | ||
|
|
e24fbabe54 | ||
|
|
61f0469986 | ||
|
|
728ea3d915 | ||
|
|
24ae6288a5 | ||
|
|
3558e4aae1 | ||
|
|
ed3bcf35bd | ||
|
|
58462d5844 | ||
|
|
13055d7ee1 | ||
|
|
5c95859d39 | ||
|
|
3c424fd17d | ||
|
|
d12cdc9106 | ||
|
|
6f9621ca2e | ||
|
|
fdce12a47b | ||
|
|
1a6d391f83 | ||
|
|
849a6b4c1c | ||
|
|
7d8e595270 | ||
|
|
4892a82a28 | ||
|
|
4f38f7d5b0 | ||
|
|
4511d4f8ac | ||
|
|
4ceabef40e | ||
|
|
3029e77c7a | ||
|
|
b96bd110c6 | ||
|
|
0e2a6f2860 | ||
|
|
2c023ac2be | ||
|
|
0a471242cb | ||
|
|
7a96c42130 | ||
|
|
b952e56bc2 | ||
|
|
ae19426298 | ||
|
|
603a814b80 | ||
|
|
149853bb0e | ||
|
|
5670c20a6a | ||
|
|
c847c2ffad | ||
|
|
9812e68b4d | ||
|
|
9146331450 | ||
|
|
1a45542842 | ||
|
|
3deef49897 | ||
|
|
c57e236768 | ||
|
|
f5c6b0aeb1 | ||
|
|
31f7847d1f | ||
|
|
fa1169c556 | ||
|
|
6feed0f22f | ||
|
|
6972928612 | ||
|
|
a3c3bda3fa | ||
|
|
f7783b3d2a | ||
|
|
0fe61df25d | ||
|
|
01156f4c78 | ||
|
|
42e4ce5550 | ||
|
|
bbfbf92264 | ||
|
|
0a8f1f3b1b | ||
|
|
3174142bd1 | ||
|
|
5e4902db2b | ||
|
|
a4af26e8fd | ||
|
|
5a4c823c40 | ||
|
|
d3a1788445 | ||
|
|
b0bce50bb4 | ||
|
|
06480b9c7c | ||
|
|
b981e60231 | ||
|
|
e17e81a9aa | ||
|
|
8891236db3 | ||
|
|
0c644c13e5 | ||
|
|
bc7b2f894c | ||
|
|
a00a0575f6 | ||
|
|
90c74df879 | ||
|
|
b489a59057 | ||
|
|
4cf6c130cd | ||
|
|
fbb09e6597 | ||
|
|
cc97987482 | ||
|
|
d2a0b75045 | ||
|
|
fec85dd38d | ||
|
|
685b8aa65f | ||
|
|
bd79b93c1d | ||
|
|
e741643717 | ||
|
|
74a00f9edc | ||
|
|
c6d2ad1f4c | ||
|
|
c8caa5518a | ||
|
|
3a9def7b80 | ||
|
|
1c5966c535 | ||
|
|
08881fa27a | ||
|
|
8a2a82d13c | ||
|
|
b0d4973717 | ||
|
|
728f03952e | ||
|
|
c7ae825a02 | ||
|
|
8ad52e36c1 | ||
|
|
d8f3e46126 | ||
|
|
20109b88df | ||
|
|
773907462b | ||
|
|
1fbf93c8c1 | ||
|
|
2eb9418a4e | ||
|
|
d3eb7bc66e | ||
|
|
f7b722246d | ||
|
|
1bbc48dad0 | ||
|
|
983c20c3f7 | ||
|
|
31ee6b3c6a | ||
|
|
28c9d752e3 | ||
|
|
e5f255b6fd | ||
|
|
e2181e9052 | ||
|
|
e44637519e | ||
|
|
4a05aad508 | ||
|
|
85a8944a6c | ||
|
|
8399d4f79c | ||
|
|
eea873e7dc | ||
|
|
bf918d1a11 | ||
|
|
29ed19fc8c | ||
|
|
75fc7c921b | ||
|
|
3875c8e675 | ||
|
|
00c7e223b1 | ||
|
|
f637667384 | ||
|
|
13bce155aa | ||
|
|
f4966b78ea | ||
|
|
07fcc249fe | ||
|
|
ec7a6f59c8 | ||
|
|
c491cf8a48 | ||
|
|
ce841cfee0 | ||
|
|
ee6aa9de41 | ||
|
|
3ae9b1c6d1 | ||
|
|
07cbff9a5f | ||
|
|
3d37f056ad | ||
|
|
cf3cf4b766 | ||
|
|
43e3b42055 | ||
|
|
dfe837655a | ||
|
|
aad200258c | ||
|
|
7b13303724 | ||
|
|
e662e2f465 | ||
|
|
138a8f4cec | ||
|
|
013f909b9b | ||
|
|
504ea00ae3 | ||
|
|
860252c22d | ||
|
|
539163acf3 | ||
|
|
2d28c05447 | ||
|
|
b74f1b495d | ||
|
|
16fa3f14c9 | ||
|
|
c631310192 | ||
|
|
ba98a6afe6 | ||
|
|
046e606a38 | ||
|
|
2c329072a2 | ||
|
|
8f8c2676b4 | ||
|
|
860c34b8da | ||
|
|
f50a0caea9 | ||
|
|
07f2b0e8a3 | ||
|
|
b391392246 | ||
|
|
a0460f107d | ||
|
|
6d7eac940c | ||
|
|
1f26a7f2bb | ||
|
|
fede0fb51b | ||
|
|
6e293d57cd | ||
|
|
9a7bfba507 | ||
|
|
6091fafcac | ||
|
|
d10783c0d6 | ||
|
|
88338229a1 | ||
|
|
d0ea9f598d | ||
|
|
d5f49cbf00 | ||
|
|
4ac448d66c |
@@ -1,66 +1,22 @@
|
||||
すでにシステムのドッグフーディングに成功しており、ブラウザ/TUI Client/backend/runtimeの分離とチームスペースとしてのworkspaceの実装を進めている。
|
||||
# 開発時の入口
|
||||
|
||||
## このシステムに置ける設計要旨
|
||||
変更量を最小にするために設計を歪めたり、不必要な後方互換性を作ったりしない。長期的な保守性と型安全性を優先する。
|
||||
|
||||
- プロンプトはすべて`resources/prompts`に集約している。管理効率の向上のためであると同時に、ユーザーがオーバーライドする形式でもある。
|
||||
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
|
||||
## 作業条件に応じて読む規則
|
||||
|
||||
### LLM コンテキストの加工原則
|
||||
該当する作業の前に、次の文書を読み、その規則に従う。
|
||||
|
||||
LLM に投げる context はappend-onlyが基本であり、またその永続化形式からAPIコールの形式を純粋に再現可能である必要が有る。
|
||||
- 文書・説明コメントを追加、変更、削除する → [文書管理規則](docs/README.md)
|
||||
- 正本、権限、状態の競合制御、LLM入力・履歴を扱う → [正本・権限・実行の原則](docs/principle/authority-and-history.md)
|
||||
- 永続操作、外部副作用、再試行・復旧を設計する → [永続操作の原則](docs/principle/durable-operations.md)
|
||||
- テストを追加・変更する → [テスト設計規則](docs/development/rust-testing-strategy.md)
|
||||
- 変更を検証する・作業を完了する → [検証規則](docs/development/validation.md)
|
||||
- 保存schemaやmigrationを変更する → [保存schema変更の規則](docs/development/workspace-schema-migrations.md)
|
||||
- Webの画面・操作を設計する → [UX設計](docs/principle/ux-design.md)・[Design Language](docs/principle/design-language.md)。描画を変更したら[目視確認規則](docs/development/ui-ux/visual-review.md)にも従う。
|
||||
- Ticket・Objective・作業記録を扱う → [作業記録と判断の規則](docs/development/work-items.md)
|
||||
- 稼働環境に関わる作業や、開発中の障壁を報告する → [ドッグフーディングの安全規則](docs/development/dogfooding.md)
|
||||
|
||||
一時的にメッセージを差し込む等の、揮発性の有るコンテキストの改変や、過去のメッセージを改ざんすることは基本的に禁止されている。
|
||||
これを行うと、 LLM はそのコンテキストに基づいて生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
|
||||
## 常に守ること
|
||||
|
||||
過去のコンテキストの圧縮は、キャッシュ破壊とトークン消費のトレードオフであり、必要であれば行っている。
|
||||
しかし、キャッシュを破壊するタイミングと頻度は正確にコントロールされる必要があり、実際のセッションデータの解析に基づいて慎重に設計されるべきである。
|
||||
|
||||
---
|
||||
|
||||
## 検証
|
||||
|
||||
開発中は、変更した契約を証明する最小の target / filter から実行する。
|
||||
|
||||
```sh
|
||||
cargo test -p <crate> --lib <test-or-module-filter>
|
||||
cargo test -p <crate> --test <test-target> <test-filter>
|
||||
```
|
||||
|
||||
完了前には、workspace rootで必ず`cargo check`を実行する。rootの`cargo check`は
|
||||
`default-members`に含まれるTUIやServerを含む通常のcompile closureを確認するため、公開型の
|
||||
変更ごとにLLMがreverse dependencyを推測して`-p`を列挙する運用にはしない。
|
||||
|
||||
```sh
|
||||
cargo check
|
||||
cargo test -p <changed-crate>
|
||||
cargo fmt --all -- --check
|
||||
git diff --check HEAD
|
||||
```
|
||||
|
||||
変更したcrate全体のtestに加え、影響するfeature構成やtest-only targetがある場合は、その検証を
|
||||
追加する。`cargo check`はtestを実行せず、通常有効でないfeatureまでは確認しないため、semanticな
|
||||
証明とfeature境界の検証はtargeted test/checkで補う。
|
||||
|
||||
workspace全体のtest、`--all-targets`、E2E、Nix/Docker buildなどの重い検証は、変更した境界を
|
||||
通常のroot checkと狭い検証では証明できない場合や、明示的に要求された場合に選ぶ。実行した検証が
|
||||
何を証明するのかを意識し、広い検証を形式的に回すだけにしない。
|
||||
|
||||
---
|
||||
|
||||
## ドッグフーディング時の Ticket 境界
|
||||
|
||||
Yoi Worker で作業する場合、Ticket の authority・ライフサイクル・操作方法は Yoi
|
||||
system instructions と、その Worker に提供された typed Ticket tools に従うこと。
|
||||
|
||||
Codex など typed Ticket tools が提供されていないクライアントでは、`yoi ticket`
|
||||
CLI や保存先の直接操作で Ticket tools を代替しないこと。Ticket の作成・更新は
|
||||
Yoi Worker に委ねる。
|
||||
|
||||
---
|
||||
|
||||
YoiでYoiを開発している際、AI自身のフィードバックを元に改善を回すために `docs/report/`ディレクトリに感じた障壁や改善案等を書き残す形にした。 明確に力不足な点/ツールの問題があった場合や、ユーザーからの指示があった際に作ること。
|
||||
|
||||
---
|
||||
|
||||
絶対に自身が動作しているプロセスを止めないこと。
|
||||
マージ後のドッグフーディング環境の更新は必ずユーザーの操作で行う。
|
||||
- 自身を実行しているプロセスを停止しない。マージ後の稼働環境の更新は利用者が行う。
|
||||
- Ticket操作は提供されたtyped toolsとsystem instructionsに従い、CLIや保存先の直接編集で代替しない。
|
||||
|
||||
Generated
+1157
-15
File diff suppressed because it is too large
Load Diff
+43
@@ -8,6 +8,7 @@ members = [
|
||||
"crates/api-macros-impl",
|
||||
"crates/session-store",
|
||||
"crates/standalone",
|
||||
"crates/job",
|
||||
"crates/secrets",
|
||||
"crates/manifest",
|
||||
"crates/mcp",
|
||||
@@ -16,6 +17,8 @@ members = [
|
||||
"crates/runtime-api",
|
||||
"crates/server-api",
|
||||
"crates/plugin-pdk",
|
||||
"crates/plugin-plan",
|
||||
"crates/plugin-runtime",
|
||||
"crates/yoi",
|
||||
"crates/protocol",
|
||||
"crates/session-metrics",
|
||||
@@ -24,11 +27,15 @@ members = [
|
||||
"crates/tools",
|
||||
"crates/fs-operation",
|
||||
"crates/flow",
|
||||
"crates/builtin-source",
|
||||
"crates/config-source",
|
||||
"crates/config-source-wasm",
|
||||
"crates/workdir",
|
||||
"crates/tui",
|
||||
"crates/memory",
|
||||
"crates/feature-storage",
|
||||
"crates/workspace-drive",
|
||||
"crates/subjektiv",
|
||||
"crates/ticket",
|
||||
"crates/merge-request",
|
||||
"crates/project-record",
|
||||
@@ -43,6 +50,7 @@ default-members = [
|
||||
"crates/api-macros-impl",
|
||||
"crates/session-store",
|
||||
"crates/standalone",
|
||||
"crates/job",
|
||||
"crates/secrets",
|
||||
"crates/manifest",
|
||||
"crates/mcp",
|
||||
@@ -51,6 +59,8 @@ default-members = [
|
||||
"crates/runtime-api",
|
||||
"crates/server-api",
|
||||
"crates/plugin-pdk",
|
||||
"crates/plugin-plan",
|
||||
"crates/plugin-runtime",
|
||||
"crates/yoi",
|
||||
"crates/protocol",
|
||||
"crates/session-metrics",
|
||||
@@ -59,11 +69,15 @@ default-members = [
|
||||
"crates/tools",
|
||||
"crates/fs-operation",
|
||||
"crates/flow",
|
||||
"crates/builtin-source",
|
||||
"crates/config-source",
|
||||
"crates/config-source-wasm",
|
||||
"crates/workdir",
|
||||
"crates/tui",
|
||||
"crates/memory",
|
||||
"crates/feature-storage",
|
||||
"crates/workspace-drive",
|
||||
"crates/subjektiv",
|
||||
"crates/ticket",
|
||||
"crates/merge-request",
|
||||
"crates/project-record",
|
||||
@@ -73,6 +87,7 @@ default-members = [
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
rust-version = "1.88"
|
||||
|
||||
[profile.dev]
|
||||
debug = "line-tables-only"
|
||||
@@ -80,6 +95,16 @@ debug = "line-tables-only"
|
||||
[profile.dev.package."*"]
|
||||
debug = false
|
||||
|
||||
# Keep the real SQLite engine in schema/transaction tests without paying for
|
||||
# an unoptimized C interpreter on every statement. Rust test assertions stay enabled.
|
||||
[profile.test.package.libsqlite3-sys]
|
||||
opt-level = 2
|
||||
|
||||
# Schema/authority integration tests execute substantial parsing and validation.
|
||||
# Optimize that code without disabling test/debug assertions or overflow checks.
|
||||
[profile.test.package.yoi-workspace-server]
|
||||
opt-level = 1
|
||||
|
||||
[workspace.dependencies]
|
||||
# Internal crates
|
||||
client = { path = "crates/client" }
|
||||
@@ -90,22 +115,28 @@ manifest = { path = "crates/manifest" }
|
||||
mcp = { path = "crates/mcp" }
|
||||
lint-common = { path = "crates/lint-common" }
|
||||
memory = { path = "crates/memory" }
|
||||
feature-storage = { path = "crates/feature-storage" }
|
||||
workspace-drive = { path = "crates/workspace-drive" }
|
||||
subjektiv = { path = "crates/subjektiv" }
|
||||
merge-request = { path = "crates/merge-request" }
|
||||
ticket = { path = "crates/ticket" }
|
||||
project-record = { path = "crates/project-record" }
|
||||
worker = { path = "crates/worker" }
|
||||
worker-runtime = { path = "crates/worker-runtime" }
|
||||
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
|
||||
plugin-plan = { path = "crates/plugin-plan" }
|
||||
yoi = { path = "crates/yoi" }
|
||||
protocol = { path = "crates/protocol" }
|
||||
session-metrics = { path = "crates/session-metrics" }
|
||||
session-analytics = { path = "crates/session-analytics" }
|
||||
session-store = { path = "crates/session-store" }
|
||||
standalone = { path = "crates/standalone" }
|
||||
job = { path = "crates/job" }
|
||||
runtime-api = { path = "crates/runtime-api" }
|
||||
server-api = { path = "crates/server-api" }
|
||||
secrets = { path = "crates/secrets" }
|
||||
tools = { path = "crates/tools" }
|
||||
builtin-source = { path = "crates/builtin-source" }
|
||||
config-source = { path = "crates/config-source" }
|
||||
fs-operation = { path = "crates/fs-operation" }
|
||||
workdir = { path = "crates/workdir" }
|
||||
@@ -124,6 +155,7 @@ decodal-language-service = "0.4.0"
|
||||
decodal-language-tools = "0.4.0"
|
||||
fs4 = "0.13"
|
||||
futures = "0.3"
|
||||
http = "1"
|
||||
libc = "0.2"
|
||||
schemars = "1.2"
|
||||
serde = "1.0"
|
||||
@@ -144,5 +176,16 @@ tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
url = "2.5"
|
||||
uuid = "1.23"
|
||||
# Released structured scope/lifetime contract and canonical Text View, one registry source.
|
||||
wip-client = "=0.2.0"
|
||||
wip-http = "=0.2.0"
|
||||
wip-protocol = "=0.2.0"
|
||||
wip-text-view = "=0.2.0"
|
||||
zeroize = "1"
|
||||
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
||||
|
||||
[workspace.metadata.wip-upstream]
|
||||
repository = "https://gitea.hareworks.net/Hare/wip-rs"
|
||||
version = "0.2.0"
|
||||
reference-commit = "6086f3c5ef10aa1464ce9c824750d7f667217bfd"
|
||||
source = "crates.io"
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# Known Issues
|
||||
|
||||
Ticket を切るほどではないが、次に近所を触るときに合わせて拾いたい小粒な所見の置き場。
|
||||
|
||||
- `crates/worker/src/controller.rs:1453-1461` — `worker_error_code` で `WorkerError::WorkflowResolve(_) => InvalidRequest` が post-commit な resolve エラー (`KnowledgeNotFound` 等) にも適用される。意味論的には妥当方向だが、resolve 系のエラー粒度を分けたくなったタイミングで再評価。
|
||||
- `crates/session-store/src/fs_store.rs:200-210` — `FsStore::read_entry_count` が `fs::read_to_string` で全文ロードしてから行数カウントするため O(n)。`ensure_head_or_fork` は run-start でしか呼ばれず現状は許容範囲だが、長期セッションが普通になった時点で `\n` バイト数の cheap count か末尾 seek に置き換える。
|
||||
- `crates/session-store/src/segment.rs:143-172` `ensure_head_or_fork` (free fn, test 専用・本番 caller ゼロ) と `crates/worker/src/worker.rs:2032` `Worker::ensure_segment_head` (本番 inline) に live auto-fork の検知 + forked_from 記録が二重実装されている。entry-hash-abolish 以前からの重複で、両方独立にテスト済みだが drift 必至。session-store 側を本番から呼ぶ形に寄せるか free fn を畳むかは要設計判断。Worker state / fork 周辺を次に触るときに統合を検討。
|
||||
- `crates/worker/src/worker.rs` / `crates/worker/src/spawn/registry.rs:84-174` — restore 時の spawned child prune/reclaim が Worker restore path と spawned registry load path の両方に残っている。現状は安全側の重複チェックだが、Worker state / spawned registry 周辺を次に触るときに責務境界を再整理。
|
||||
@@ -1,85 +1,16 @@
|
||||
# 夜居 / Yoi agent
|
||||
# 夜居 / Yoi
|
||||
|
||||
Yoi is an agent runtime for building, running, and orchestrating LLM Workers while preserving explicit history, scoped capabilities, and developer-controlled workflows.
|
||||
Yoiは、LLM Workerの実行履歴と明示的な権限を管理するエージェント実行基盤である。Workspaceの記録と操作権限をBackendが管理し、RuntimeがWorkerと作業ディレクトリの実行資源を管理する。単一プロセスで実行するStandaloneは、Workspaceの権限やRuntime Workerの登録とは分離している。
|
||||
|
||||
## 1. Yoi agent
|
||||
## 実装の入口
|
||||
|
||||
Yoi focuses on long-running agent operation rather than one-off prompt execution. A named Worker can keep durable session history, run with explicit tool and filesystem authority, delegate bounded work to child Workers, and be inspected or restored through CLI/TUI surfaces.
|
||||
- [agen](crates/agen/README.md):LLMターン、履歴、ツール呼出し。
|
||||
- [worker](crates/worker/README.md):セッション、機能、ツールと実行権限の接続。
|
||||
- [workspace-server](crates/workspace-server/README.md):Workspaceの正本とBackend操作。
|
||||
- [worker-runtime](crates/worker-runtime/README.md):Workerの実行とRuntime資源。
|
||||
- [standalone](crates/standalone/README.md):プロセス内の独立した実行ホスト。
|
||||
- [yoi](crates/yoi/README.md)・[tui](crates/tui/README.md)・[Web](web/workspace/README.md):利用者との接点。
|
||||
|
||||
Main highlights:
|
||||
各crateのREADMEをcrateドキュメントとして使い、具体的な設計理由はmodule・itemのrustdocに置く。文書の追加・維持・削除は[文書管理規則](docs/README.md)、変更の検証は[検証規則](docs/development/validation.md)に従う。
|
||||
|
||||
- Named long-running **Workers** with durable session and metadata records.
|
||||
- Explicit tool permissions and filesystem scopes.
|
||||
- Multi-agent orchestration with scoped coder/reviewer Workers.
|
||||
- Profile, Manifest, and prompt-based runtime configuration.
|
||||
- Local Tickets and workflow files for auditable project coordination.
|
||||
- TUI and CLI entry points, including the `yoi panel` workspace Dashboard and single-Worker Console.
|
||||
|
||||
Yoi is actively dogfooded in this repository. Public APIs, configuration formats, and workflows may still change.
|
||||
|
||||
## 2. Quick Install
|
||||
|
||||
From source:
|
||||
|
||||
```sh
|
||||
cargo build --release -p yoi
|
||||
./target/release/yoi --help
|
||||
```
|
||||
|
||||
With Nix:
|
||||
|
||||
```sh
|
||||
nix build .#yoi
|
||||
./result/bin/yoi --help
|
||||
```
|
||||
|
||||
## 3. Getting Started
|
||||
|
||||
```sh
|
||||
yoi --help
|
||||
yoi
|
||||
yoi panel
|
||||
yoi --worker <name>
|
||||
yoi worker --help
|
||||
```
|
||||
|
||||
Typical flow:
|
||||
|
||||
1. Configure providers, models, profiles, prompts, and scopes.
|
||||
2. Start or attach to a named Worker in the Console, or inspect workspace activity in the Dashboard.
|
||||
3. Use explicit tools and scoped delegation for multi-agent work.
|
||||
4. Record project work through Tickets, workflow files, and git history.
|
||||
|
||||
Client and execution surfaces use the `yoi` binaries, XDG client/server configuration and data directories, Runtime-owned Workdirs/session artifacts, Server data directories, `YOI_*`, and the preserved legacy home secret-store boundary under `~/.yoi`. Repository-local `.yoi` trees are ignored legacy input, not runtime authority.
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
Start with [`docs/README.md`](docs/README.md).
|
||||
|
||||
Key docs:
|
||||
|
||||
- [`docs/design/overview.md`](docs/design/overview.md) — architecture and crate ownership map.
|
||||
- [`docs/design/context-history.md`](docs/design/context-history.md) — history/context invariants.
|
||||
- [`docs/design/worker-session-state.md`](docs/design/worker-session-state.md) — Worker identity, metadata, and session logs.
|
||||
- [`docs/design/profiles-manifests-prompts.md`](docs/design/profiles-manifests-prompts.md) — Profiles, Manifests, and prompt resources.
|
||||
- [`docs/design/tool-permissions-scope.md`](docs/design/tool-permissions-scope.md) — tool policy and filesystem scope.
|
||||
- [`docs/development/work-items.md`](docs/development/work-items.md) — Ticket workflow and project records.
|
||||
- [`docs/development/validation.md`](docs/development/validation.md) — validation expectations.
|
||||
|
||||
## 5. Development
|
||||
|
||||
This repository dogfoods Yoi to develop Yoi. Work is tracked through Workspace Server-backed Tickets and `yoi ticket ...`; Git history plus append-only Ticket and Merge Request evidence are the authoritative project record. Repository-local `.yoi/tickets` trees are ignored legacy input.
|
||||
|
||||
Common checks:
|
||||
|
||||
```sh
|
||||
yoi ticket doctor
|
||||
git diff --check
|
||||
cargo fmt --check
|
||||
cargo check --workspace --all-targets
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Real-process E2E testing is opt-in. Do not design or implement E2E coverage unless the work explicitly requires E2E; otherwise protect the narrower parser, API, protocol, authority, or runtime boundary.
|
||||
|
||||
License: MIT. See [`LICENSE`](LICENSE).
|
||||
ライセンスは[MIT](LICENSE)。
|
||||
|
||||
@@ -1,32 +1,9 @@
|
||||
# agen-macros
|
||||
|
||||
Procedural macros used by [`agen`](https://crates.io/crates/agen) to declare LLM tools from Rust methods.
|
||||
`agen-macros` generates `agen` tool definitions and implementations from Rust
|
||||
method declarations. It isolates procedural macro expansion from `agen`, which
|
||||
owns tool registration and execution.
|
||||
|
||||
Applications should normally depend only on `agen` and import its re-exports:
|
||||
## Design entry points
|
||||
|
||||
```rust
|
||||
use agen::tool_registry;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Tools;
|
||||
|
||||
#[tool_registry]
|
||||
impl Tools {
|
||||
/// Returns the supplied text.
|
||||
#[tool]
|
||||
async fn echo(
|
||||
&self,
|
||||
#[description = "Text to return"] text: String,
|
||||
) -> Result<String, std::io::Error> {
|
||||
Ok(text)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`#[tool_registry]` generates the argument schema, a `Tool` implementation, and an `<method>_definition` constructor. It rejects arguments of its own, duplicate `#[tool]` markers, malformed or duplicate `#[description = "..."]` attributes, and non-identifier argument patterns.
|
||||
|
||||
Generated code targets the canonical `::agen` path and uses implementation dependencies re-exported by `agen`; consumers do not need direct `serde`, `schemars`, `serde_json`, or `async-trait` dependencies. Renaming the `agen` dependency in `Cargo.toml` is not currently supported.
|
||||
|
||||
This companion package is published before the matching `agen` release. Its public contract is the generated API consumed by `agen`, and its minor version compatibility follows the `agen` 0.2 series.
|
||||
|
||||
Licensed under the [MIT License](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/LICENSE).
|
||||
- [`tool_registry`]: declaration validation, argument schemas, and execution wrappers
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
//! Procedural macros for declaring [`agen`](https://docs.rs/agen) tools.
|
||||
//!
|
||||
//! [`tool_registry`] expands methods marked with `#[tool]` into `agen::tool::Tool`
|
||||
//! implementations and tool definitions. Applications normally use the re-exports from
|
||||
//! `agen`; this companion crate exists so those macros can be published and versioned
|
||||
//! independently.
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
use proc_macro::TokenStream;
|
||||
use quote::{format_ident, quote};
|
||||
@@ -12,30 +7,16 @@ use syn::{
|
||||
spanned::Spanned,
|
||||
};
|
||||
|
||||
/// Generates tools for methods marked with `#[tool]` in an `impl` block.
|
||||
/// Validates tool declarations and generates JSON Schema and `agen` execution wrappers.
|
||||
///
|
||||
/// Method doc comments become the tool description. An argument can use
|
||||
/// `#[description = "..."]` to supply its JSON Schema description.
|
||||
/// Method doc comments and argument `description` attributes provide schema descriptions.
|
||||
/// Invalid attributes, duplicate markers, and non-identifier argument patterns produce
|
||||
/// `compile_error!` diagnostics rather than panicking during macro expansion.
|
||||
///
|
||||
/// ```ignore
|
||||
/// #[derive(Clone)]
|
||||
/// struct MyApp;
|
||||
///
|
||||
/// #[agen::tool_registry]
|
||||
/// impl MyApp {
|
||||
/// /// Retrieves a user by ID.
|
||||
/// #[tool]
|
||||
/// async fn get_user(
|
||||
/// &self,
|
||||
/// #[description = "The user ID"] user_id: String,
|
||||
/// ) -> Result<String, std::io::Error> {
|
||||
/// todo!()
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// This generates a `ToolGetUser` wrapper, a `GetUserArgs` schema type, and
|
||||
/// `MyApp::get_user_definition()`.
|
||||
/// Generated code references helper dependencies through `::agen::__private` so
|
||||
/// downstream crates do not need to depend on implementation details directly.
|
||||
/// The expansion uses the canonical `::agen` path and does not resolve renamed dependencies.
|
||||
/// `ToolExecutionContext` is supplied by the executor and excluded from model input schemas.
|
||||
#[proc_macro_attribute]
|
||||
pub fn tool_registry(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let attr = proc_macro2::TokenStream::from(attr);
|
||||
|
||||
@@ -11,7 +11,7 @@ homepage = "https://gitea.hareworks.net/Hare/yoi"
|
||||
documentation = "https://docs.rs/agen"
|
||||
keywords = ["llm", "agent", "tools", "streaming", "orchestration"]
|
||||
categories = ["api-bindings", "asynchronous"]
|
||||
include = ["src/**", "tests/**", "examples/*.rs", "docs/**", "README.md", "LICENSE"]
|
||||
include = ["src/**", "tests/**", "examples/*.rs", "README.md", "LICENSE"]
|
||||
autoexamples = false
|
||||
|
||||
[features]
|
||||
@@ -32,10 +32,12 @@ reqwest = { version = "0.13", default-features = false, features = ["stream", "j
|
||||
eventsource-stream = "0.2"
|
||||
zstd = "0.13"
|
||||
base64 = "0.22.1"
|
||||
imagesize = "0.14"
|
||||
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true }
|
||||
agen-macros = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
tempfile = { workspace = true }
|
||||
dotenv = "0.15"
|
||||
|
||||
+10
-87
@@ -1,91 +1,14 @@
|
||||
# agen
|
||||
|
||||
`agen` is a provider-neutral Rust engine for streaming LLM applications that use tools. It owns the turn loop, typed conversation history, provider wire-format adapters, tool execution, interceptors, usage accounting, and cache-aware state transitions.
|
||||
`agen` integrates streaming LLM responses and tool execution in an agent engine.
|
||||
It manages turns, history appends, pause/resume, and retries, while the LLM client
|
||||
layer handles wire formats and authentication. The caller owns history storage,
|
||||
worker lifecycle control, and compaction.
|
||||
|
||||
> `agen` is pre-1.0. Public APIs may change between minor releases.
|
||||
## Design entry points
|
||||
|
||||
## Installation
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
agen = "0.2.1"
|
||||
```
|
||||
|
||||
The default feature set is intentionally empty. Enable the experimental Codex/ChatGPT authentication adapter when needed:
|
||||
|
||||
```toml
|
||||
agen = { version = "0.2.1", features = ["codex"] }
|
||||
```
|
||||
|
||||
`agen` requires Rust 1.86 or newer. The companion `agen-macros` package requires Rust 1.85 or newer.
|
||||
|
||||
## Quick start
|
||||
|
||||
Supply an implementation of [`LlmClient`](https://docs.rs/agen/latest/agen/llm_client/trait.LlmClient.html), keep conversation history in your application, then run a turn. The first call consumes the mutable engine and returns a cache-locked engine for later turns.
|
||||
|
||||
```no_run
|
||||
use agen::{Engine, EngineError, History};
|
||||
use agen::llm_client::LlmClient;
|
||||
|
||||
async fn conversation<C: LlmClient>(client: C) -> Result<(), EngineError> {
|
||||
let mut history = History::new();
|
||||
let output = Engine::new(client)
|
||||
.system_prompt("You are a concise assistant.")
|
||||
.run(&mut history, "Explain typed state in one sentence.")
|
||||
.await;
|
||||
|
||||
let mut engine = output.engine;
|
||||
let _result = engine.run(&mut history, "Give a Rust example.").await;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Declaring tools
|
||||
|
||||
The tool macros are re-exported by `agen`; applications do not need direct dependencies on `serde`, `schemars`, `serde_json`, or `async-trait` for generated code.
|
||||
|
||||
```rust
|
||||
use agen::tool_registry;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Tools;
|
||||
|
||||
#[tool_registry]
|
||||
impl Tools {
|
||||
/// Returns the supplied text.
|
||||
#[tool]
|
||||
async fn echo(
|
||||
&self,
|
||||
#[description = "Text to return"] text: String,
|
||||
) -> Result<String, std::io::Error> {
|
||||
Ok(text)
|
||||
}
|
||||
}
|
||||
|
||||
let definition = Tools.echo_definition();
|
||||
assert_eq!(definition().0.name, "echo");
|
||||
```
|
||||
|
||||
The generated API uses the canonical crate name `agen`. Renaming the `agen` dependency in `Cargo.toml` is not currently supported by these macros.
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Default | Adds |
|
||||
|---|---:|---|
|
||||
| `codex` | No | Experimental Codex/ChatGPT auth-file loading and token refresh support |
|
||||
|
||||
The base crate includes provider-neutral transport and Anthropic, OpenAI-compatible, Gemini, and Ollama wire-format schemes. See [`llm_client`](https://docs.rs/agen/latest/agen/llm_client/) for the client boundary.
|
||||
|
||||
## Architecture and API scope
|
||||
|
||||
The current public modules cover the engine, typed history, client transport/schemes, timeline events, tools, interceptors, pruning, token estimation, and usage records. Their relationships are described in [Architecture](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/crates/agen/docs/architecture.md); behavioral requirements are summarized in [Requirements](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/crates/agen/docs/requirements.md).
|
||||
|
||||
Low-level modules remain public in the 0.2 series because downstream Yoi components implement custom clients, event handlers, pruning policies, and tool registries against them. This surface is versioned as pre-1.0 API rather than declared stable.
|
||||
|
||||
## Packaging and security
|
||||
|
||||
The published package contains source, public documentation, curated examples, and deterministic tests/fixtures. Credentialed fixture-recording utilities are intentionally excluded. Examples that contact a provider read credentials from environment variables and never embed production credentials.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the [MIT License](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/LICENSE).
|
||||
- [`Engine`]: turn execution, interruption, and resumption
|
||||
- [`History`]: model input paired with caller-owned annotations
|
||||
- [`state`] and [`prune`]: prefix protection and request-only history projections
|
||||
- [`llm_client`] and [`timeline`]: wire-format conversion and event processing
|
||||
- [`interceptor`]: host-owned control decisions
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# agen architecture
|
||||
|
||||
`agen` separates orchestration, event projection, and provider transport so applications can replace an LLM client without changing the turn loop or tool model.
|
||||
|
||||
```text
|
||||
┌────────────────────────────────────────────┐
|
||||
│ Engine │
|
||||
│ turn loop · interceptors · tool execution │
|
||||
│ typed state: Mutable → Locked → Mutable │
|
||||
└─────────────────────┬──────────────────────┘
|
||||
│
|
||||
┌─────────────────────▼──────────────────────┐
|
||||
│ Timeline │
|
||||
│ event dispatch · block collectors │
|
||||
└─────────────────────┬──────────────────────┘
|
||||
│
|
||||
┌─────────────────────▼──────────────────────┐
|
||||
│ LlmClient │
|
||||
│ transport · provider wire-format schemes │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Main modules
|
||||
|
||||
| Module | Responsibility |
|
||||
|---|---|
|
||||
| `engine` | Turn execution, pause/resume, retries, tool integration, and callbacks |
|
||||
| `state` | Sealed `Mutable` and `Locked` type-state markers |
|
||||
| `interceptor` | Application-owned control decisions at orchestration boundaries |
|
||||
| `tool` / `tool_server` | Tool metadata, registration, execution, and bounded output |
|
||||
| `timeline` | Streaming event dispatch, handlers, and block assembly |
|
||||
| `llm_client` | Provider-neutral request, response, auth, transport, and scheme contracts |
|
||||
| `providers` | Optional higher-level provider adapters such as the `codex` feature |
|
||||
| `prune` / `token_counter` | Cache-aware history reduction and token estimation |
|
||||
| `usage_record` | Request and token usage accounting |
|
||||
|
||||
## Request flow
|
||||
|
||||
```text
|
||||
Engine history
|
||||
→ provider-neutral Request
|
||||
→ Scheme::build_request
|
||||
→ Provider transport
|
||||
```
|
||||
|
||||
## Response flow
|
||||
|
||||
```text
|
||||
streaming response bytes
|
||||
→ Scheme event parsing
|
||||
→ unified Event values
|
||||
→ Timeline handlers and collectors
|
||||
→ Engine history/tool decisions
|
||||
```
|
||||
|
||||
## Type state and cache protection
|
||||
|
||||
`Engine<C, Mutable>` permits configuration and history editing. `Engine::run` or `Engine::lock` commits the current prefix and produces `Engine<C, Locked>`. The locked engine may append turns without mutating the committed prefix. `Engine::unlock` explicitly returns to mutable state when an application accepts losing that cache guarantee.
|
||||
|
||||
## Public surface
|
||||
|
||||
The 0.2 series exposes the low-level client, timeline, tool, pruning, and usage modules because custom clients and orchestration hosts build directly on them. These APIs are intentionally provider-neutral but remain pre-1.0 and may change in later minor releases.
|
||||
@@ -1,39 +0,0 @@
|
||||
# agen requirements
|
||||
|
||||
## R1: Turn execution and continuation
|
||||
|
||||
- `Engine::run` starts a turn and loops through provider output and tool calls.
|
||||
- An `Interceptor` may continue, cancel, or pause work at defined orchestration boundaries.
|
||||
- `Engine::resume` continues paused generation without fabricating another user message.
|
||||
- Cancellation and provider errors are represented as typed `EngineError` values.
|
||||
|
||||
## R2: Explicit cache-preserving state
|
||||
|
||||
- `Engine<C, Mutable>` permits configuration and history edits.
|
||||
- `Engine::run` or `Engine::lock` transitions to `Engine<C, Locked>` and records the committed prefix.
|
||||
- A locked engine appends turns but cannot mutate that prefix through mutable-only APIs.
|
||||
- `Engine::unlock` explicitly abandons the lock before configuration or history changes.
|
||||
|
||||
## R3: Tool declarations and execution
|
||||
|
||||
- `#[tool_registry]` generates a schema and `Tool` implementation for methods marked `#[tool]`.
|
||||
- `#[description = "..."]` supplies argument descriptions in generated JSON Schema.
|
||||
- Generated code resolves its runtime and helper dependencies through `::agen`.
|
||||
- Invalid and duplicate marker attributes produce compile errors rather than panics.
|
||||
- Tools execute through `ToolServer` with typed context, errors, and output limits.
|
||||
|
||||
## R4: Provider-neutral orchestration
|
||||
|
||||
- `LlmClient` is the boundary between the engine and provider-specific transport.
|
||||
- Request/response schemes translate provider wire formats into shared request and event types.
|
||||
- Interceptors, tool execution, timeline collection, and pruning stay above the provider transport.
|
||||
- Provider-specific capabilities are optional features when they require additional policy or dependencies.
|
||||
|
||||
## R5: Publication quality
|
||||
|
||||
- crates.io metadata includes license, repository, documentation, README, categories, keywords, and MSRV.
|
||||
- The default feature set and each optional feature compile and test independently.
|
||||
- Macro expansion compiles in a downstream-style integration test without direct helper dependencies.
|
||||
- rustdoc builds without dependency documentation.
|
||||
- Package contents are explicitly bounded and exclude credentialed fixture-recording utilities.
|
||||
- `cargo package` and `cargo publish --dry-run` are run for `agen-macros` before `agen` because the main package depends on its companion package.
|
||||
@@ -368,7 +368,7 @@ fn create_client(args: &Args) -> Result<Box<dyn LlmClient>, String> {
|
||||
ResolvedAuth::ApiKey(api_key),
|
||||
)),
|
||||
Provider::Ollama => {
|
||||
// Ollama = Anthropic scheme + base_url 差し替え + 認証なし
|
||||
// Ollama uses the Anthropic scheme with a custom base_url and no authentication.
|
||||
let scheme = AnthropicScheme::new();
|
||||
let cap = default_capability();
|
||||
Ok(Box::new(HttpTransport::new(
|
||||
|
||||
@@ -108,7 +108,7 @@ async fn run_scenario_with_ollama(
|
||||
subdir: &str,
|
||||
model: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Ollama = Anthropic scheme + base_url 差し替え + 認証なし
|
||||
// Ollama uses the Anthropic scheme with a custom base_url and no authentication.
|
||||
let model = model.as_deref().unwrap_or("llama3");
|
||||
let client = HttpTransport::new(
|
||||
AnthropicScheme::new(),
|
||||
|
||||
+394
-212
@@ -1,3 +1,20 @@
|
||||
//! Provider-independent orchestration of turns and tool execution.
|
||||
//!
|
||||
//! The Engine uses common Request and Event types to keep wire formats and
|
||||
//! authentication out of the turn loop. It delegates caller control decisions to
|
||||
//! Interceptor and does not own notification intake, storage, or segment switches
|
||||
//! during compaction.
|
||||
//!
|
||||
//! New history items enter in-memory history only after their annotation callback
|
||||
//! succeeds. Hosts that require persistence record items in that callback.
|
||||
//! Request-time reordering and pruning operate on a clone, preserving stored order
|
||||
//! and caller-owned annotations.
|
||||
//!
|
||||
//! Retries before a stream opens differ from continuation requests after partial
|
||||
//! output has been recorded. A stream failure after early tool dispatch disables
|
||||
//! automatic continuation: regenerating the same side effect under a new call ID
|
||||
//! is unsafe, so the Engine finalizes the executions it owns and returns an error.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::{future::Future, marker::PhantomData, pin::Pin, sync::Arc, time::Instant};
|
||||
|
||||
@@ -71,6 +88,63 @@ pub enum EngineError {
|
||||
ToolAttemptFence(String),
|
||||
}
|
||||
|
||||
/// Policy completion is not permission to start work when an interruption was
|
||||
/// queued during its final poll. Cancel wins when both requests are observed.
|
||||
fn check_policy_interruption(
|
||||
cancel_rx: &mut mpsc::Receiver<()>,
|
||||
pause_rx: &mut mpsc::Receiver<()>,
|
||||
) -> Result<(), EngineError> {
|
||||
if cancel_rx.try_recv().is_ok() {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
if pause_rx.try_recv().is_ok() {
|
||||
return Err(EngineError::PauseRequested);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll one policy invocation, never a second copy of its side effects. Dropping
|
||||
/// a pending policy stops admission, not any already-owned tool execution; the
|
||||
/// caller must drain that execution through its original attempt fence.
|
||||
async fn await_tool_policy<T>(
|
||||
policy: impl Future<Output = Result<T, InterceptorError>>,
|
||||
phase: InterceptorPhase,
|
||||
cancel_rx: &mut mpsc::Receiver<()>,
|
||||
pause_rx: &mut mpsc::Receiver<()>,
|
||||
) -> Result<T, EngineError> {
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
Some(()) = cancel_rx.recv() => return Err(EngineError::Cancelled),
|
||||
Some(()) = pause_rx.recv() => return Err(EngineError::PauseRequested),
|
||||
result = policy => result,
|
||||
};
|
||||
check_policy_interruption(cancel_rx, pause_rx)?;
|
||||
result.map_err(|error| InterceptorFailure::new(phase, error).into())
|
||||
}
|
||||
|
||||
/// A consumed Pause still bounds post-policy processing while owned tools drain.
|
||||
/// Terminal history and publication happen before this wait, so expiring it
|
||||
/// cannot discard the result or reopen the execution attempt.
|
||||
#[derive(Clone, Copy)]
|
||||
enum PostToolPolicyWait {
|
||||
Skip,
|
||||
Interruptible,
|
||||
UntilPauseSafeBoundary(TokioInstant),
|
||||
}
|
||||
|
||||
impl PostToolPolicyWait {
|
||||
fn for_pause_deadline(deadline: Option<TokioInstant>) -> Self {
|
||||
deadline.map_or(Self::Interruptible, Self::UntilPauseSafeBoundary)
|
||||
}
|
||||
|
||||
fn pause_deadline(self) -> Option<TokioInstant> {
|
||||
match self {
|
||||
Self::UntilPauseSafeBoundary(deadline) => Some(deadline),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool registration error
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ToolRegistryError {
|
||||
@@ -464,7 +538,7 @@ const MAX_STREAM_CONTINUATIONS: u32 = 3;
|
||||
///
|
||||
/// Calling `run()` on a `Mutable` Engine consumes it and returns a
|
||||
/// `Locked` Engine together with the result. The engine borrows the caller's
|
||||
/// [`History`](crate::History) only while running, so host annotations stay with
|
||||
/// [`History`] only while running, so host annotations stay with
|
||||
/// the host-owned history and are never projected to providers.
|
||||
///
|
||||
/// ```ignore
|
||||
@@ -488,7 +562,7 @@ const MAX_STREAM_CONTINUATIONS: u32 = 3;
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LlmRetryNotice {
|
||||
/// 直近で失敗した attempt 番号。1 origin。
|
||||
/// Number of the most recently failed attempt, starting at 1.
|
||||
pub failed_attempt: u32,
|
||||
pub max_attempts: u32,
|
||||
pub wait: std::time::Duration,
|
||||
@@ -539,6 +613,14 @@ impl Drop for ResponseStreamPump {
|
||||
}
|
||||
}
|
||||
|
||||
/// Coordinates LLM responses, tool results, and control callbacks in one turn loop.
|
||||
///
|
||||
/// History belongs to the caller. `run` appends new input and starts a logical run;
|
||||
/// `resume` continues a paused or yielded run without creating a new user message.
|
||||
/// The turn budget survives pauses and yields, so resuming does not reset the limit.
|
||||
///
|
||||
/// Type states restrict Engine configuration changes; they do not guarantee that
|
||||
/// external History remains unchanged or that the provider reports a cache hit.
|
||||
pub struct Engine<C: LlmClient, S: EngineState = Mutable, A: Send + Sync = ()> {
|
||||
/// LLM client
|
||||
client: C,
|
||||
@@ -550,8 +632,8 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A: Send + Sync = ()> {
|
||||
text_block_collector: TextBlockCollector,
|
||||
/// Tool call collector (Timeline handler)
|
||||
tool_call_collector: ToolCallCollector,
|
||||
/// Thinking block collector (Timeline handler)。metadata 付きで完了した
|
||||
/// Thinking block を 1 ターン分バッファし、history に append する。
|
||||
/// Thinking block collector (Timeline handler). Buffers completed blocks and
|
||||
/// their metadata for one turn before appending them to history.
|
||||
thinking_block_collector: ThinkingBlockCollector,
|
||||
/// Tool server handle
|
||||
tool_server: ToolServerHandle,
|
||||
@@ -561,12 +643,9 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A: Send + Sync = ()> {
|
||||
system_prompt: Option<String>,
|
||||
/// History length at lock time (only meaningful in Locked state)
|
||||
locked_prefix_len: usize,
|
||||
/// AgentTurn count across the lifetime of this Engine.
|
||||
/// Cumulative AgentTurns started during this Engine's lifetime.
|
||||
///
|
||||
/// Once retry (`agen-stream-continuation`) is implemented, an
|
||||
/// AgentTurn collapses N retried `LlmCall`s with identical input;
|
||||
/// today retry is not implemented so AgentTurn and LlmCall fire 1:1
|
||||
/// and the increment site (the LLM-call loop) is shared.
|
||||
/// The logical run's turn budget is tracked separately in `active_run_turn_count`.
|
||||
turn_count: usize,
|
||||
/// AgentTurns consumed by the currently active logical run.
|
||||
///
|
||||
@@ -626,6 +705,10 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A: Send + Sync = ()> {
|
||||
/// engine history. An error rejects the item and aborts the turn, allowing
|
||||
/// upper layers to make durable storage the commit gate.
|
||||
history_append_cbs: Vec<Box<dyn Fn(&Item) -> Result<(), String> + Send + Sync>>,
|
||||
/// Durable host commit gate for image-result corrections. Runs before the
|
||||
/// correction becomes live or a replacement request can be sent.
|
||||
image_rejection_handler:
|
||||
Option<Box<dyn Fn(&HistoryEntry<A>, &Item) -> Result<(), String> + Send + Sync>>,
|
||||
/// Request configuration (max_tokens, temperature, etc.)
|
||||
request_config: RequestConfig,
|
||||
/// Cancel notification channel (for interrupting execution)
|
||||
@@ -744,14 +827,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
}
|
||||
}
|
||||
|
||||
fn try_paused(&mut self) -> bool {
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
match self.pause_rx.try_recv() {
|
||||
Ok(()) => true,
|
||||
Err(TryRecvError::Empty | TryRecvError::Disconnected) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a text block observer with scoped callbacks.
|
||||
///
|
||||
/// The setup closure is called once per text block. Inside it, register
|
||||
@@ -933,7 +1008,8 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
///
|
||||
/// The callback is invoked with a short human-readable message
|
||||
/// whenever the Engine encounters a condition that should be
|
||||
/// surfaced to a human (e.g. tool output byte-cap truncation).
|
||||
/// surfaced to a human (e.g. a provider-rejected image). Tool output
|
||||
/// byte-cap truncation is reported in the tool result, not this channel.
|
||||
/// This channel is separate from `tracing::warn!`, which remains
|
||||
/// in place for developer logs.
|
||||
pub fn on_warning(&mut self, callback: impl Fn(&str) + Send + Sync + 'static) {
|
||||
@@ -948,10 +1024,10 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
|
||||
/// Register a callback invoked once per completed tool execution.
|
||||
///
|
||||
/// Fired after `post_tool_call` interceptors and any `content`
|
||||
/// truncation from `tool_output_limits`, so the callback observes
|
||||
/// exactly what is persisted to history. Intended for callers that need
|
||||
/// to forward tool results to clients.
|
||||
/// Fired after `content` truncation from `tool_output_limits` and terminal
|
||||
/// history commit, before `post_tool_call` policy. The callback observes
|
||||
/// exactly what is persisted even if that policy is interrupted. Intended
|
||||
/// for callers that need to forward tool results to clients.
|
||||
pub fn on_tool_result(&mut self, callback: impl Fn(&ToolResult) + Send + Sync + 'static) {
|
||||
self.tool_result_cbs.push(Box::new(callback));
|
||||
}
|
||||
@@ -971,6 +1047,16 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
self.history_append_cbs.push(Box::new(callback));
|
||||
}
|
||||
|
||||
/// Set the host's durable commit gate for correcting a rejected ViewImage
|
||||
/// result. The original annotation/identity must be preserved. A failed
|
||||
/// commit leaves both history and the outgoing request untouched.
|
||||
pub fn set_image_rejection_handler(
|
||||
&mut self,
|
||||
handler: impl Fn(&HistoryEntry<A>, &Item) -> Result<(), String> + Send + Sync + 'static,
|
||||
) {
|
||||
self.image_rejection_handler = Some(Box::new(handler));
|
||||
}
|
||||
|
||||
fn emit_history_append(&self, item: &Item) -> Result<(), EngineError> {
|
||||
for cb in &self.history_append_cbs {
|
||||
cb(item).map_err(EngineError::HistoryAppend)?;
|
||||
@@ -1284,10 +1370,9 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
|
||||
/// Build assistant response items from reasoning items, text blocks, and tool calls.
|
||||
///
|
||||
/// Reasoning items come first (Anthropic / OpenAI Responses 双方ともに
|
||||
/// アシスタント応答内で reasoning は先頭に並ぶ仕様)。これは Anthropic
|
||||
/// が新世代モデルで thinking ブロックを assistant メッセージの先頭に
|
||||
/// 置くことを要求するためでもある。
|
||||
/// Reasoning items precede other assistant output for both Anthropic and
|
||||
/// OpenAI Responses. This also satisfies newer Anthropic models' requirement
|
||||
/// that thinking blocks appear at the start of the assistant message.
|
||||
fn build_assistant_items(
|
||||
&self,
|
||||
reasoning_items: &[crate::llm_client::event::ReasoningBlockData],
|
||||
@@ -1395,15 +1480,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
fn get_unanswered_tool_calls(&self, history: &History<A>) -> (Vec<ToolCall>, Vec<String>) {
|
||||
let mut pending_calls = Vec::new();
|
||||
let mut started_call_ids = Vec::new();
|
||||
let mut answered_call_ids = std::collections::HashSet::new();
|
||||
|
||||
for item in history.items() {
|
||||
if let Item::ToolResult { call_id, .. } = item {
|
||||
answered_call_ids.insert(call_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for item in history.items() {
|
||||
for entry in unanswered_tool_call_entries(history) {
|
||||
if let Item::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
@@ -1411,8 +1488,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
execution_id,
|
||||
status,
|
||||
..
|
||||
} = item
|
||||
&& !answered_call_ids.contains(call_id)
|
||||
} = &entry.item
|
||||
{
|
||||
if execution_id.is_some() || *status == Some(ItemStatus::InProgress) {
|
||||
started_call_ids.push(call_id.clone());
|
||||
@@ -1516,43 +1592,24 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
tool,
|
||||
context,
|
||||
};
|
||||
let action = tokio::select! {
|
||||
result = self.interceptor.pre_tool_call(&mut info) => {
|
||||
result.map_err(|error| {
|
||||
EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::PreToolCall,
|
||||
error,
|
||||
))
|
||||
})?
|
||||
}
|
||||
pause = self.pause_rx.recv() => {
|
||||
if pause.is_some() {
|
||||
info!(call_id = %expected_tool_use_id, "Paused while awaiting pre-tool policy");
|
||||
}
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
None,
|
||||
)?;
|
||||
let action = await_tool_policy(
|
||||
self.interceptor.pre_tool_call(&mut info),
|
||||
InterceptorPhase::PreToolCall,
|
||||
&mut self.cancel_rx,
|
||||
&mut self.pause_rx,
|
||||
)
|
||||
.await;
|
||||
let action = match action {
|
||||
Ok(action) => action,
|
||||
Err(error @ (EngineError::PauseRequested | EngineError::Cancelled)) => {
|
||||
self.append_early_tool_call(history, annotate, &tool_call, call_index, None)?;
|
||||
batch.dispatched_any = true;
|
||||
return Ok(EarlyToolAdmission::Pause);
|
||||
}
|
||||
cancel = self.cancel_rx.recv() => {
|
||||
if cancel.is_some() {
|
||||
info!(call_id = %expected_tool_use_id, "Cancelled while awaiting pre-tool policy");
|
||||
}
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
None,
|
||||
)?;
|
||||
batch.dispatched_any = true;
|
||||
return Err(EngineError::Cancelled);
|
||||
return match error {
|
||||
EngineError::PauseRequested => Ok(EarlyToolAdmission::Pause),
|
||||
_ => Err(error),
|
||||
};
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if info.call.id != expected_tool_use_id {
|
||||
return Err(InterceptorFailure::new(
|
||||
@@ -1573,6 +1630,30 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
} = info;
|
||||
tool_call = call;
|
||||
context.call_id = tool_call.id.clone();
|
||||
if let PreToolAction::SyntheticResult(result) = &action
|
||||
&& result.tool_use_id != expected_tool_use_id
|
||||
{
|
||||
return Err(InterceptorFailure::new(
|
||||
InterceptorPhase::PreToolCall,
|
||||
InterceptorError::new(
|
||||
InterceptorErrorCategory::ContractViolation,
|
||||
"synthetic tool result changed immutable tool call identity",
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let execution_id = if matches!(action, PreToolAction::Pause) {
|
||||
None
|
||||
} else {
|
||||
Some(context.execution_id())
|
||||
};
|
||||
self.append_early_tool_call(history, annotate, &tool_call, call_index, execution_id)?;
|
||||
// Policy runs before this call exists in history. Only the successful
|
||||
// append's annotation may bind the identity, overriding hook changes.
|
||||
context.logical_call_id = history
|
||||
.entries()
|
||||
.last()
|
||||
.and_then(|entry| self.interceptor.logical_tool_call_id(&entry.annotation));
|
||||
batch.call_info_map.insert(
|
||||
tool_call.id.clone(),
|
||||
(tool_call.clone(), meta, tool.clone(), context.clone()),
|
||||
@@ -1581,13 +1662,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
match action {
|
||||
PreToolAction::Continue => {
|
||||
let attempt_id = context.execution_id();
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
Some(attempt_id.clone()),
|
||||
)?;
|
||||
batch
|
||||
.attempt_fence
|
||||
.register(tool_call.id.clone(), attempt_id.clone());
|
||||
@@ -1629,34 +1703,10 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
batch.dispatched_any = true;
|
||||
}
|
||||
PreToolAction::SyntheticResult(result) => {
|
||||
if result.tool_use_id != expected_tool_use_id {
|
||||
return Err(InterceptorFailure::new(
|
||||
InterceptorPhase::PreToolCall,
|
||||
InterceptorError::new(
|
||||
InterceptorErrorCategory::ContractViolation,
|
||||
"synthetic tool result changed immutable tool call identity",
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
Some(context.execution_id()),
|
||||
)?;
|
||||
batch.synthetic_results.push_back(result);
|
||||
batch.dispatched_any = true;
|
||||
}
|
||||
PreToolAction::Skip => {
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
Some(context.execution_id()),
|
||||
)?;
|
||||
batch.synthetic_results.push_back(ToolResult::error(
|
||||
&tool_call.id,
|
||||
"Tool execution skipped by interceptor",
|
||||
@@ -1664,13 +1714,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
batch.dispatched_any = true;
|
||||
}
|
||||
PreToolAction::Abort(reason) => {
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
Some(context.execution_id()),
|
||||
)?;
|
||||
batch.synthetic_results.push_back(ToolResult::error(
|
||||
&tool_call.id,
|
||||
format!("Tool execution aborted: {reason}"),
|
||||
@@ -1679,7 +1722,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
return Err(EngineError::Aborted(reason));
|
||||
}
|
||||
PreToolAction::Pause => {
|
||||
self.append_early_tool_call(history, annotate, &tool_call, call_index, None)?;
|
||||
batch.dispatched_any = true;
|
||||
return Ok(EarlyToolAdmission::Pause);
|
||||
}
|
||||
@@ -1701,7 +1743,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
None,
|
||||
batch.turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::Interruptible,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1728,7 +1770,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
None,
|
||||
batch.turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1776,7 +1818,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
batch.turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1799,7 +1841,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
ToolResult::outcome_unknown(call_id),
|
||||
Some(attempt_id),
|
||||
batch.turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1822,28 +1864,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
force_cancel: bool,
|
||||
initial_pause_requested: bool,
|
||||
) -> Result<ToolExecutionResult, EngineError> {
|
||||
while let Some(result) = batch.synthetic_results.pop_front() {
|
||||
if let Err(error) = self
|
||||
.finalize_and_commit_tool_result(
|
||||
history,
|
||||
annotate,
|
||||
result,
|
||||
None,
|
||||
batch.turn_id,
|
||||
true,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let cleanup_error = self
|
||||
.cancel_and_terminalize_early_batch(history, annotate, &mut batch)
|
||||
.await;
|
||||
return Err(cleanup_error.unwrap_or(error));
|
||||
}
|
||||
}
|
||||
|
||||
if force_cancel {
|
||||
if let Some(error) = self
|
||||
.cancel_and_terminalize_early_batch(history, annotate, &mut batch)
|
||||
@@ -1857,6 +1877,28 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
let mut pause_requested = initial_pause_requested;
|
||||
let mut pause_deadline = initial_pause_requested
|
||||
.then(|| TokioInstant::now() + self.tool_execution_policy.pause_safe_boundary_timeout);
|
||||
while let Some(result) = batch.synthetic_results.pop_front() {
|
||||
if let Err(error) = self
|
||||
.finalize_and_commit_tool_result(
|
||||
history,
|
||||
annotate,
|
||||
result,
|
||||
None,
|
||||
batch.turn_id,
|
||||
PostToolPolicyWait::for_pause_deadline(pause_deadline),
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let cleanup_error = self
|
||||
.cancel_and_terminalize_early_batch(history, annotate, &mut batch)
|
||||
.await;
|
||||
return Err(cleanup_error.unwrap_or(error));
|
||||
}
|
||||
}
|
||||
|
||||
while !batch.futures.is_empty() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
@@ -1868,7 +1910,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
batch.turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::for_pause_deadline(pause_deadline),
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1930,8 +1972,10 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
|
||||
// A pause observed before provider ownership starts leaves every call
|
||||
// NotStarted and therefore eligible for an explicit later retry.
|
||||
if self.try_paused() {
|
||||
return Ok(ToolExecutionResult::Paused);
|
||||
match check_policy_interruption(&mut self.cancel_rx, &mut self.pause_rx) {
|
||||
Ok(()) => {}
|
||||
Err(EngineError::PauseRequested) => return Ok(ToolExecutionResult::Paused),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
|
||||
// Map from tool call ID to (ToolCall, Meta, Tool, Context)
|
||||
@@ -1942,10 +1986,24 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
let batch_id = format!("tool-batch-{}", self.tool_execution_batch_count);
|
||||
self.tool_execution_batch_count += 1;
|
||||
|
||||
let mut logical_call_ids: HashMap<String, VecDeque<Option<String>>> = HashMap::new();
|
||||
for entry in unanswered_tool_call_entries(history) {
|
||||
if let Item::ToolCall { call_id, .. } = &entry.item {
|
||||
logical_call_ids
|
||||
.entry(call_id.clone())
|
||||
.or_default()
|
||||
.push_back(self.interceptor.logical_tool_call_id(&entry.annotation));
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1: Apply pre_tool_call interceptor (determine skip/abort/synthetic result)
|
||||
let mut approved_calls = Vec::new();
|
||||
for (call_index, mut tool_call) in tool_calls.into_iter().enumerate() {
|
||||
let expected_tool_use_id = tool_call.id.clone();
|
||||
let logical_call_id = logical_call_ids
|
||||
.get_mut(&tool_call.id)
|
||||
.and_then(VecDeque::pop_front)
|
||||
.flatten();
|
||||
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
|
||||
if !tool_call.input.is_object() {
|
||||
synthetic_results.push(ToolResult::error(
|
||||
@@ -1970,16 +2028,18 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
context,
|
||||
};
|
||||
|
||||
let pre_tool_action =
|
||||
self.interceptor
|
||||
.pre_tool_call(&mut info)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::PreToolCall,
|
||||
error,
|
||||
))
|
||||
})?;
|
||||
let pre_tool_action = match await_tool_policy(
|
||||
self.interceptor.pre_tool_call(&mut info),
|
||||
InterceptorPhase::PreToolCall,
|
||||
&mut self.cancel_rx,
|
||||
&mut self.pause_rx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(action) => action,
|
||||
Err(EngineError::PauseRequested) => return Ok(ToolExecutionResult::Paused),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if info.call.id != expected_tool_use_id {
|
||||
return Err(InterceptorFailure::new(
|
||||
InterceptorPhase::PreToolCall,
|
||||
@@ -2009,6 +2069,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
let tool_call = info.call;
|
||||
let mut context = info.context;
|
||||
context.call_id = tool_call.id.clone();
|
||||
context.logical_call_id = logical_call_id;
|
||||
call_info_map.insert(
|
||||
tool_call.id.clone(),
|
||||
(tool_call, info.meta.clone(), info.tool.clone(), context),
|
||||
@@ -2028,6 +2089,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
tool_call = info.call;
|
||||
let mut context = info.context;
|
||||
context.call_id = tool_call.id.clone();
|
||||
context.logical_call_id = logical_call_id;
|
||||
|
||||
call_info_map.insert(
|
||||
tool_call.id.clone(),
|
||||
@@ -2041,11 +2103,20 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
approved_calls.push((tool_call, context, Some(info.tool)));
|
||||
} else {
|
||||
// Unknown tools go into approved list as-is (will error at execution)
|
||||
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
|
||||
let mut context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
|
||||
context.logical_call_id = logical_call_id;
|
||||
approved_calls.push((tool_call, context, None));
|
||||
}
|
||||
}
|
||||
|
||||
// Recheck after all policies and immediately before provider ownership.
|
||||
// No tool task exists yet: these calls remain proven NotStarted.
|
||||
match check_policy_interruption(&mut self.cancel_rx, &mut self.pause_rx) {
|
||||
Ok(()) => {}
|
||||
Err(EngineError::PauseRequested) => return Ok(ToolExecutionResult::Paused),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
|
||||
// Phase 2: Execute approved tools in parallel. FuturesUnordered yields
|
||||
// each terminal result as soon as that call completes instead of
|
||||
// holding fast siblings behind the slowest call in the batch.
|
||||
@@ -2122,7 +2193,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
None,
|
||||
turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::Interruptible,
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2154,7 +2225,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::for_pause_deadline(pause_deadline),
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2235,7 +2306,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2263,7 +2334,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
ToolResult::outcome_unknown(call_id),
|
||||
Some(attempt_id),
|
||||
turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2304,8 +2375,8 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply post-execution policy, bound the model-visible payload, durably
|
||||
/// append one terminal ToolResult, and only then publish it to observers.
|
||||
/// Bound the model-visible payload, durably append one terminal ToolResult,
|
||||
/// publish it, then await interruptible post-execution policy.
|
||||
async fn finalize_and_commit_tool_result(
|
||||
&mut self,
|
||||
history: &mut History<A>,
|
||||
@@ -2313,7 +2384,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
mut tool_result: ToolResult,
|
||||
execution_attempt_id: Option<&str>,
|
||||
turn_id: usize,
|
||||
invoke_post_hook: bool,
|
||||
post_policy_wait: PostToolPolicyWait,
|
||||
call_info_map: &HashMap<
|
||||
String,
|
||||
(
|
||||
@@ -2365,13 +2436,9 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
limit_bytes = limit,
|
||||
"Tool output exceeded byte limit and was truncated"
|
||||
);
|
||||
self.emit_warning(&format!(
|
||||
"tool `{}` output truncated from {} to {} bytes (limit {})",
|
||||
tool_call.name,
|
||||
before,
|
||||
content.len(),
|
||||
limit
|
||||
));
|
||||
// Truncation is a tool-result constraint, not an actionable
|
||||
// engine warning. The content marker tells the model to refine
|
||||
// its query; keep diagnostics in tracing, not user alerts.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2402,7 +2469,9 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
);
|
||||
self.emit_tool_result(&tool_result);
|
||||
|
||||
if invoke_post_hook && let Some((tool_call, meta, tool, context)) = call_info {
|
||||
if !matches!(post_policy_wait, PostToolPolicyWait::Skip)
|
||||
&& let Some((tool_call, meta, tool, context)) = call_info
|
||||
{
|
||||
let invocation = self.interceptor_invocation(
|
||||
InterceptorPhase::PostToolCall,
|
||||
Some(turn_id),
|
||||
@@ -2418,27 +2487,20 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
tool: tool.clone(),
|
||||
context: context.clone(),
|
||||
};
|
||||
let pause_deadline = post_policy_wait.pause_deadline();
|
||||
let post_tool_action = tokio::select! {
|
||||
result = self.interceptor.post_tool_call(&info) => {
|
||||
result.map_err(|error| {
|
||||
EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::PostToolCall,
|
||||
error,
|
||||
))
|
||||
})?
|
||||
}
|
||||
pause = self.pause_rx.recv() => {
|
||||
if pause.is_some() {
|
||||
info!(call_id = %tool_call.id, "Paused while awaiting post-tool policy");
|
||||
}
|
||||
biased;
|
||||
// Poll the existing safe-boundary deadline even when the Pause
|
||||
// that established it was consumed by an outer policy gate.
|
||||
_ = tokio::time::sleep_until(pause_deadline.unwrap_or_else(TokioInstant::now)), if pause_deadline.is_some() => {
|
||||
return Err(EngineError::PauseRequested);
|
||||
}
|
||||
cancel = self.cancel_rx.recv() => {
|
||||
if cancel.is_some() {
|
||||
info!(call_id = %tool_call.id, "Cancelled while awaiting post-tool policy");
|
||||
}
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
result = await_tool_policy(
|
||||
self.interceptor.post_tool_call(&info),
|
||||
InterceptorPhase::PostToolCall,
|
||||
&mut self.cancel_rx,
|
||||
&mut self.pause_rx,
|
||||
) => result?,
|
||||
};
|
||||
if let PostToolAction::Abort(reason) = post_tool_action {
|
||||
return Err(EngineError::Aborted(reason));
|
||||
@@ -2476,7 +2538,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
ToolResult::outcome_unknown(&call_id),
|
||||
None,
|
||||
self.turn_count,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2938,22 +3000,29 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
)),
|
||||
0,
|
||||
);
|
||||
let assistant_turn_action = self
|
||||
.interceptor
|
||||
.on_assistant_turn_end(AssistantTurnEndContext {
|
||||
invocation: assistant_invocation,
|
||||
assistant_entries: &history.entries()[assistant_start..],
|
||||
history: history.entries(),
|
||||
tool_calls: &tool_calls,
|
||||
})
|
||||
.await;
|
||||
let assistant_turn_action = await_tool_policy(
|
||||
self.interceptor
|
||||
.on_assistant_turn_end(AssistantTurnEndContext {
|
||||
invocation: assistant_invocation,
|
||||
assistant_entries: &history.entries()[assistant_start..],
|
||||
history: history.entries(),
|
||||
tool_calls: &tool_calls,
|
||||
}),
|
||||
InterceptorPhase::AssistantTurnEnd,
|
||||
&mut self.cancel_rx,
|
||||
&mut self.pause_rx,
|
||||
)
|
||||
.await;
|
||||
let assistant_turn_action = match assistant_turn_action {
|
||||
Ok(action) => action,
|
||||
Err(EngineError::PauseRequested) => {
|
||||
if let Some(batch) = early_tools.take() {
|
||||
self.drain_early_tool_batch(history, annotate, batch, false, true)
|
||||
.await?;
|
||||
}
|
||||
return Ok(EngineResult::Paused);
|
||||
}
|
||||
Err(error) => {
|
||||
let error = EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::AssistantTurnEnd,
|
||||
error,
|
||||
));
|
||||
if let Some(batch) = early_tools.take()
|
||||
&& let Err(cleanup_error) = self
|
||||
.drain_early_tool_batch(history, annotate, batch, true, false)
|
||||
@@ -3188,6 +3257,55 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Correct one candidate actually sent in the rejected request. Commit the
|
||||
/// durable correction before changing either in-memory projection or retrying.
|
||||
fn recover_rejected_image(
|
||||
&self,
|
||||
history: &mut History<A>,
|
||||
request: &mut Request,
|
||||
) -> Result<bool, EngineError> {
|
||||
let Some((history_index, request_index)) =
|
||||
crate::image_recovery::largest_candidate(history, request)
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let entry = &history.entries()[history_index];
|
||||
let replacement = crate::image_recovery::rejected_result(&entry.item);
|
||||
if let Some(commit) = &self.image_rejection_handler {
|
||||
commit(entry, &replacement).map_err(EngineError::HistoryAppend)?;
|
||||
}
|
||||
history.entries_mut()[history_index].item = replacement.clone();
|
||||
// Retain the original request's prune/interceptor decisions.
|
||||
request.items[request_index] = replacement;
|
||||
self.emit_warning(crate::image_recovery::REJECTION_MESSAGE);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Opening a corrected request can itself yield an HTTP image rejection.
|
||||
/// Each successful correction removes one candidate, bounding this loop.
|
||||
async fn open_stream_with_image_recovery(
|
||||
&mut self,
|
||||
request: &mut Request,
|
||||
turn: usize,
|
||||
llm_call: usize,
|
||||
history: &mut History<A>,
|
||||
) -> Result<ResponseStream, EngineError> {
|
||||
loop {
|
||||
match self
|
||||
.open_stream_with_retry(request.clone(), turn, llm_call)
|
||||
.await
|
||||
{
|
||||
Ok(stream) => return Ok(stream),
|
||||
Err(EngineError::Client(error)) if error.is_image_size_rejection() => {
|
||||
if !self.recover_rejected_image(history, request)? {
|
||||
return Err(EngineError::Client(error));
|
||||
}
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a stream, dispatch all events to the timeline, handle cancellation.
|
||||
async fn stream_response(
|
||||
&mut self,
|
||||
@@ -3205,11 +3323,15 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
"Sending request to LLM"
|
||||
);
|
||||
|
||||
let stream = self.open_stream_with_retry(request, turn, llm_call).await?;
|
||||
let mut request = request;
|
||||
let stream = self
|
||||
.open_stream_with_image_recovery(&mut request, turn, llm_call, history)
|
||||
.await?;
|
||||
self.tool_call_collector.begin_response();
|
||||
let mut stream = ResponseStreamPump::start(stream);
|
||||
let mut early_tools: Option<EarlyToolExecutionBatch> = None;
|
||||
let mut response_completed = false;
|
||||
let mut response_progress = false;
|
||||
|
||||
let mut event_count: usize = 0;
|
||||
loop {
|
||||
@@ -3229,7 +3351,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
let event = match result {
|
||||
Ok(event) => event,
|
||||
Err(err) => {
|
||||
// 部分情報でも発火しておく(料金会計用)
|
||||
// Emit even partial usage for billing accounting.
|
||||
self.timeline.flush_usage();
|
||||
return Ok(StreamResponseOutput {
|
||||
completion: StreamCompletion::Interrupted {
|
||||
@@ -3247,6 +3369,35 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
json!({}),
|
||||
);
|
||||
}
|
||||
self.emit_stream_event(turn, llm_call, &event);
|
||||
// Only pre-output provider rejection can safely replay the
|
||||
// original request. Even an empty block start closes this
|
||||
// window, before collectors or early tool admission run.
|
||||
// Unknown events are conservatively not replayable either.
|
||||
match &event {
|
||||
Event::Ping(_) | Event::Usage(_) | Event::Error(_) => {}
|
||||
Event::Status(StatusEvent {
|
||||
status: crate::llm_client::event::ResponseStatus::Started,
|
||||
}) => {}
|
||||
_ => response_progress = true,
|
||||
}
|
||||
if let Event::Error(error) = &event {
|
||||
if !response_progress && error.is_image_size_rejection() {
|
||||
self.timeline.flush_usage();
|
||||
if self.recover_rejected_image(history, &mut request)? {
|
||||
// Drop the old pump (including queued failed status)
|
||||
// before opening the corrected request. No collector or
|
||||
// tool state exists for this pre-output attempt.
|
||||
drop(stream);
|
||||
stream = ResponseStreamPump::start(self.open_stream_with_image_recovery(
|
||||
&mut request, turn, llm_call, history,
|
||||
).await?);
|
||||
self.tool_call_collector.begin_response();
|
||||
event_count = 0;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
&event,
|
||||
Event::Status(StatusEvent {
|
||||
@@ -3255,7 +3406,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
) {
|
||||
response_completed = true;
|
||||
}
|
||||
self.emit_stream_event(turn, llm_call, &event);
|
||||
self.timeline.dispatch(&event);
|
||||
if early_dispatch {
|
||||
let collected = self.tool_call_collector.take_collected_with_index();
|
||||
@@ -3438,7 +3588,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
batch.turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::Interruptible,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -3493,7 +3643,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// ストリーム完了時に集約済み Usage を 1 度だけ発火
|
||||
// Emit aggregated usage once when the stream completes.
|
||||
self.timeline.flush_usage();
|
||||
debug!(event_count = event_count, "Stream completed");
|
||||
Ok(StreamResponseOutput {
|
||||
@@ -3567,6 +3717,7 @@ impl<C: LlmClient, A: Send + Sync> Engine<C, Mutable, A> {
|
||||
warning_cbs: Vec::new(),
|
||||
tool_result_cbs: Vec::new(),
|
||||
history_append_cbs: Vec::new(),
|
||||
image_rejection_handler: None,
|
||||
request_config: RequestConfig::default(),
|
||||
cancel_tx,
|
||||
cancel_rx,
|
||||
@@ -3807,19 +3958,16 @@ impl<C: LlmClient, A: Send + Sync> Engine<C, Mutable, A> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock and transition to Locked state
|
||||
/// Finalizes configuration and transitions to Locked.
|
||||
///
|
||||
/// Flushes pending tool factories, then fixes the current system prompt
|
||||
/// and history as a "committed prefix". After this, only `run()` / `resume()`
|
||||
/// may append to history, ensuring cache hits.
|
||||
///
|
||||
/// Most callers should use [`run()`](Self::run) instead, which calls
|
||||
/// this internally. Use `lock()` directly only when you need the
|
||||
/// `Locked` engine back on error (e.g. in a persistence layer).
|
||||
/// Flushes pending tool factories and records the current history length as the
|
||||
/// prefix boundary. External History mutations remain possible, so the caller
|
||||
/// is also responsible for preserving the prefix. This transition changes the
|
||||
/// type state; it does not persist history.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if a pending tool factory produces a duplicate name.
|
||||
/// Panics if pending tool factories produce duplicate names.
|
||||
pub fn lock(self, history: &History<A>) -> Engine<C, Locked, A> {
|
||||
self.tool_server.flush_pending();
|
||||
let locked_prefix_len = history.len();
|
||||
@@ -3856,6 +4004,7 @@ impl<C: LlmClient, A: Send + Sync> Engine<C, Mutable, A> {
|
||||
warning_cbs: self.warning_cbs,
|
||||
tool_result_cbs: self.tool_result_cbs,
|
||||
history_append_cbs: self.history_append_cbs,
|
||||
image_rejection_handler: self.image_rejection_handler,
|
||||
request_config: self.request_config,
|
||||
|
||||
cancel_tx: self.cancel_tx,
|
||||
@@ -4040,6 +4189,7 @@ impl<C: LlmClient, A: Send + Sync> Engine<C, Locked, A> {
|
||||
warning_cbs: self.warning_cbs,
|
||||
tool_result_cbs: self.tool_result_cbs,
|
||||
history_append_cbs: self.history_append_cbs,
|
||||
image_rejection_handler: self.image_rejection_handler,
|
||||
request_config: self.request_config,
|
||||
|
||||
cancel_tx: self.cancel_tx,
|
||||
@@ -4176,6 +4326,38 @@ fn item_kind(item: &Item) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pair each result with one preceding unanswered occurrence, never with a later
|
||||
/// call that happens to reuse the provider ID. Keep the exact annotated entries
|
||||
/// so dispatch and restoration resolve the same host-owned identity.
|
||||
fn unanswered_tool_call_entries<A>(history: &History<A>) -> Vec<&HistoryEntry<A>> {
|
||||
let mut calls = Vec::new();
|
||||
let mut by_provider_id: HashMap<&str, VecDeque<usize>> = HashMap::new();
|
||||
for entry in history.entries() {
|
||||
match &entry.item {
|
||||
Item::ToolCall { call_id, .. } => {
|
||||
by_provider_id
|
||||
.entry(call_id.as_str())
|
||||
.or_default()
|
||||
.push_back(calls.len());
|
||||
calls.push(Some(entry));
|
||||
}
|
||||
Item::ToolResult { call_id, .. } => {
|
||||
if let Some(queue) = by_provider_id.get_mut(call_id.as_str())
|
||||
&& let Some(index) = queue.pop_front()
|
||||
{
|
||||
calls[index] = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
calls.into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/tool_call_identity.rs"]
|
||||
mod tool_call_identity_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
//! Typed conversation history containers.
|
||||
//! History that pairs model input with host-owned annotations.
|
||||
//!
|
||||
//! Agen keeps provider-visible [`Item`](crate::Item) values separate from any
|
||||
//! host-domain provenance. The host chooses the annotation type `A`, while Agen
|
||||
//! preserves each item and annotation as one entry for clone/truncate/restore
|
||||
//! style history operations.
|
||||
//! Only [`Item`] is projected to providers; annotation `A` holds storage identifiers
|
||||
//! and input provenance. Cloning, truncation, and restoration keep each pair together.
|
||||
//! The caller's append callback owns persistence; this container does not.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -40,7 +39,13 @@ impl HistoryEntry<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversation history with one annotation per item.
|
||||
/// Ordered history of model input and host-owned annotations.
|
||||
///
|
||||
/// [`History::append_with`] runs the callback before adding an item. A host that
|
||||
/// persists in that callback keeps failed writes out of subsequent model requests.
|
||||
/// Batch appends commit one item at a time; later failures do not roll back earlier
|
||||
/// items. Direct append and replacement APIs do not persist, so the host must
|
||||
/// distinguish restoration from normal appends.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct History<A = ()> {
|
||||
entries: Vec<HistoryEntry<A>>,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Recovery is driven by provider rejection, never a locally chosen image limit.
|
||||
use crate::{History, Item, Role, ToolResultDisposition, llm_client::Request, tool::Attachment};
|
||||
|
||||
pub(crate) const REJECTION_MESSAGE: &str = "ViewImage image omitted from model input after the provider rejected image size. This is a recovery candidate, not proof that this particular image caused the rejection. Resize or crop the image and retry ViewImage; do not read the unchanged image again.";
|
||||
|
||||
/// Keep the most recent response's tool results eligible, including early tool
|
||||
/// results interleaved with that response's later text/calls. Empty boundaries
|
||||
/// from older versions' failed requests are not evidence of accepted input.
|
||||
pub(crate) fn accepted_input_end<'a>(items: impl Iterator<Item = &'a Item>) -> Option<usize> {
|
||||
let mut response_start = None;
|
||||
let mut accepted_prefix = None;
|
||||
for (index, item) in items.enumerate() {
|
||||
match item {
|
||||
Item::AssistantResponseBoundary { .. } => response_start = Some(index),
|
||||
Item::Message {
|
||||
role: Role::Assistant,
|
||||
..
|
||||
}
|
||||
| Item::ToolCall { .. }
|
||||
| Item::Reasoning { .. } => {
|
||||
accepted_prefix = Some(response_start.unwrap_or(index));
|
||||
}
|
||||
Item::Message {
|
||||
role: Role::User, ..
|
||||
} => response_start = None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
accepted_prefix
|
||||
}
|
||||
|
||||
/// Select only a ViewImage result actually sent in the rejected request. The
|
||||
/// tool contract returns exactly one image, so each correction removes one
|
||||
/// image and preserves the tool call/result pair. Other tools and user uploads
|
||||
/// are deliberately not rewritten by this recovery policy.
|
||||
pub(crate) fn largest_candidate<A>(
|
||||
history: &History<A>,
|
||||
request: &Request,
|
||||
) -> Option<(usize, usize)> {
|
||||
let start = accepted_input_end(history.items()).unwrap_or(0);
|
||||
let mut largest = None;
|
||||
for (index, entry) in history.entries().iter().enumerate().skip(start) {
|
||||
let Item::ToolResult {
|
||||
call_id,
|
||||
attachments,
|
||||
..
|
||||
} = &entry.item
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let [Attachment::Image(image)] = attachments.as_slice() else {
|
||||
continue;
|
||||
};
|
||||
let from_view_image = history.items().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
Item::ToolCall { call_id: id, name, .. } if id == call_id && name == "ViewImage"
|
||||
)
|
||||
});
|
||||
if !from_view_image {
|
||||
continue;
|
||||
}
|
||||
let sent = request.items.iter().position(|item| {
|
||||
matches!(
|
||||
item,
|
||||
Item::ToolResult { call_id: id, attachments: sent, .. }
|
||||
if id == call_id && sent == attachments
|
||||
)
|
||||
});
|
||||
let Some(request_index) = sent else { continue };
|
||||
// Header inspection only; no raster allocation or size threshold.
|
||||
// Unknown dimensions sort by bytes as a deterministic fallback.
|
||||
let pixels = imagesize::blob_size(image.data())
|
||||
.ok()
|
||||
.map(|size| (size.width as u64).saturating_mul(size.height as u64))
|
||||
.unwrap_or(0);
|
||||
let rank = (pixels, image.data().len(), index);
|
||||
if largest
|
||||
.as_ref()
|
||||
.is_none_or(|(previous, _)| rank > *previous)
|
||||
{
|
||||
largest = Some((rank, (index, request_index)));
|
||||
}
|
||||
}
|
||||
largest.map(|(_, candidate)| candidate)
|
||||
}
|
||||
|
||||
pub(crate) fn rejected_result(item: &Item) -> Item {
|
||||
let mut item = item.clone();
|
||||
if let Item::ToolResult {
|
||||
summary,
|
||||
content,
|
||||
attachments,
|
||||
disposition,
|
||||
is_error,
|
||||
..
|
||||
} = &mut item
|
||||
{
|
||||
*summary = REJECTION_MESSAGE.to_owned();
|
||||
*content = None;
|
||||
attachments.clear();
|
||||
*disposition = ToolResultDisposition::Error;
|
||||
*is_error = true;
|
||||
}
|
||||
item
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
//! Interceptor - control flow delegation for the Engine execution loop
|
||||
//! Host-owned control decisions at Engine execution boundaries.
|
||||
//!
|
||||
//! Defines the [`Interceptor`] trait that callers implement to inject
|
||||
//! orchestration decisions (approval, skip, pause, abort) into the Engine's
|
||||
//! turn loop without the Engine knowing about host-application concepts.
|
||||
//! [`Interceptor`] delegates approval, skip, pause, and abort decisions to the host,
|
||||
//! keeping host-specific policy out of the Engine. Additional model input must go
|
||||
//! through history appends: inserting new facts only into an in-flight request
|
||||
//! would make its input impossible to reconstruct from stored history.
|
||||
//!
|
||||
//! Tool pre/post policy and assistant-turn-end waits are interruptible by Cancel or Pause;
|
||||
//! Cancel wins when both are observed. Pending policy futures may be dropped, not replayed, so
|
||||
//! hosts must not depend on their completion for cleanup of already-owned effects. The Engine
|
||||
//! drains owned tool attempts through their original fences. Terminal tool history and result
|
||||
//! publication precede post-tool policy; interruption cannot erase that result. Pause draining
|
||||
//! bounds post-tool waits by the existing safe-boundary deadline.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -301,7 +309,7 @@ pub struct ToolCallInfo<'a, A = ()> {
|
||||
pub meta: ToolMeta,
|
||||
/// Tool instance (for state access).
|
||||
pub tool: Arc<dyn Tool>,
|
||||
/// Response-local execution context for this call.
|
||||
/// Execution context. The Engine binds durable identity after pre-tool policy.
|
||||
pub context: ToolExecutionContext,
|
||||
}
|
||||
|
||||
@@ -316,7 +324,7 @@ pub struct ToolResultInfo<'a, A = ()> {
|
||||
pub meta: ToolMeta,
|
||||
/// Tool instance (for state access).
|
||||
pub tool: Arc<dyn Tool>,
|
||||
/// Response-local execution context for this call.
|
||||
/// Execution context. The Engine binds durable identity after pre-tool policy.
|
||||
pub context: ToolExecutionContext,
|
||||
}
|
||||
|
||||
@@ -336,6 +344,14 @@ pub struct ToolResultInfo<'a, A = ()> {
|
||||
/// flows, permission checks, and other trusted host adaptation.
|
||||
#[async_trait]
|
||||
pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
|
||||
/// Pure projection of a committed ToolCall's host annotation into an opaque,
|
||||
/// durable call identity. Agen neither interprets storage metadata nor derives
|
||||
/// this identity from provider IDs. Early pre-tool policy runs before commit;
|
||||
/// the Engine binds this value after policy, before execution.
|
||||
fn logical_tool_call_id(&self, _annotation: &A) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether side effects may start before [`Self::on_assistant_turn_end`].
|
||||
///
|
||||
/// The conservative default preserves the historical whole-response gate.
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
mod engine;
|
||||
mod handler;
|
||||
mod history;
|
||||
mod image_recovery;
|
||||
mod message;
|
||||
|
||||
pub(crate) mod callback;
|
||||
@@ -41,10 +42,9 @@ pub use tool::{
|
||||
};
|
||||
pub use usage_record::UsageRecord;
|
||||
|
||||
/// Implementation dependencies used by code generated from `agen` macros.
|
||||
/// Implementation dependencies referenced by macro-generated code.
|
||||
///
|
||||
/// This module is not a stable user-facing API. It is public only because macro expansion
|
||||
/// happens in the downstream crate.
|
||||
/// Public for access from downstream macro expansions, not a stable user-facing API.
|
||||
#[doc(hidden)]
|
||||
pub mod __private {
|
||||
pub use async_trait;
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
//! `Scheme` 実装と通信層が要求する認証要件、および動的認証プロバイダ。
|
||||
//! Authentication requirements for `Scheme` implementations and the transport layer,
|
||||
//! plus dynamic authentication providers.
|
||||
//!
|
||||
//! `AuthRequirement` は scheme が宣言する「この scheme はどんな認証を
|
||||
//! 期待するか」のランタイム記述で、設定ファイルや環境変数などから
|
||||
//! [`super::transport::ResolvedAuth`] を組み立てる責務は呼び出し側にある。
|
||||
//! `AuthRequirement` describes the authentication a scheme expects at runtime.
|
||||
//! The caller is responsible for building [`super::transport::ResolvedAuth`]
|
||||
//! from configuration files, environment variables, or other sources.
|
||||
//!
|
||||
//! リクエスト毎にトークンが変わり得る認証は [`AuthProvider`] trait を
|
||||
//! 実装し、[`super::transport::ResolvedAuth::Custom`] 経由で transport に渡す。
|
||||
//! Authentication whose tokens may change per request implements [`AuthProvider`]
|
||||
//! and is passed to the transport via [`super::transport::ResolvedAuth::Custom`].
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::header::{HeaderName, HeaderValue};
|
||||
|
||||
use super::error::ClientError;
|
||||
|
||||
/// `Scheme::required_auth()` が返す認証要件。
|
||||
/// Authentication requirements returned by `Scheme::required_auth()`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthRequirement {
|
||||
/// 認証を行わない(Ollama など)
|
||||
/// No authentication (e.g. Ollama).
|
||||
None,
|
||||
/// `Authorization: Bearer <token>` ヘッダ(token は API key 相当)
|
||||
/// `Authorization: Bearer <token>` header (the token acts as an API key).
|
||||
Bearer,
|
||||
/// `x-api-key: <token>` ヘッダ(Anthropic 形式)
|
||||
/// `x-api-key: <token>` header (Anthropic format).
|
||||
XApiKey,
|
||||
/// クエリパラメータ `?<name>=<token>`(Gemini 形式)
|
||||
/// Query parameter `?<name>=<token>` (Gemini format).
|
||||
QueryParam { name: &'static str },
|
||||
/// 複合ヘッダ(呼び出し側が [`AuthProvider`] で解決)
|
||||
/// Combined headers (resolved by the caller through [`AuthProvider`]).
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// リクエスト毎に認証ヘッダを動的に組み立てるプロバイダ。
|
||||
/// Provider that builds authentication headers dynamically for each request.
|
||||
///
|
||||
/// access token が refresh で更新されたり、複数ヘッダを同時に注入する
|
||||
/// 必要があるケースで使う。実体は呼び出し側に置き、agen は
|
||||
/// trait を知るだけ。
|
||||
/// Use this when access tokens are refreshed or multiple headers must be injected
|
||||
/// together. The implementation belongs to the caller; agen only knows the trait.
|
||||
///
|
||||
/// 返したヘッダはそのまま `HeaderMap` に挿入される。`Authorization`
|
||||
/// 含む scheme 既定の認証ヘッダは送出されないので、必要なら
|
||||
/// 実装側でセットすること。
|
||||
/// Returned headers are inserted directly into `HeaderMap`. The scheme's default
|
||||
/// authentication headers, including `Authorization`, are not sent; the
|
||||
/// implementation must set them if needed.
|
||||
#[async_trait]
|
||||
pub trait AuthProvider: Send + Sync + std::fmt::Debug {
|
||||
/// 1 リクエスト分の認証ヘッダを返す。refresh が必要なら内部で行う。
|
||||
/// Return authentication headers for one request, refreshing internally if needed.
|
||||
async fn headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>, ClientError>;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
//! モデル能力メタデータ
|
||||
//! Model capability metadata.
|
||||
//!
|
||||
//! `ModelCapability` はモデルが持つ機能差を表現する。scheme は同じでも
|
||||
//! モデルごとに reasoning 可否や prompt caching 方式が違うため、scheme
|
||||
//! から分離して保持する。
|
||||
//! `ModelCapability` represents differences in model features. Reasoning support
|
||||
//! and prompt caching strategies may vary even within one scheme, so this
|
||||
//! metadata is kept separate from the scheme.
|
||||
//!
|
||||
//! 値の供給経路は 2 通り:
|
||||
//! 1. scheme 実装側の `model_id → ModelCapability` 静的テーブル(既知モデル)
|
||||
//! 2. `ModelConfig::capability` での明示 override(未知モデル、または上書き)
|
||||
//! The client construction layer supplies model-specific capabilities and overrides.
|
||||
//! Schemes provide only wire-level defaults through `Scheme::default_capability`;
|
||||
//! this module does not own a model registry or application configuration format.
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
/// モデル能力メタデータ
|
||||
/// Model capability metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ModelCapability {
|
||||
pub tool_calling: ToolCallingSupport,
|
||||
@@ -23,7 +23,7 @@ pub struct ModelCapability {
|
||||
}
|
||||
|
||||
impl ModelCapability {
|
||||
/// 何もサポートしない安全側デフォルト。未知モデルのフォールバック用。
|
||||
/// Conservative default with no supported features, used for unknown models.
|
||||
pub const fn minimal() -> Self {
|
||||
Self {
|
||||
tool_calling: ToolCallingSupport::None,
|
||||
@@ -35,55 +35,55 @@ impl ModelCapability {
|
||||
}
|
||||
}
|
||||
|
||||
/// ツール呼び出しサポート
|
||||
/// Tool calling support.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolCallingSupport {
|
||||
/// 非サポート
|
||||
/// Unsupported.
|
||||
None,
|
||||
/// 1 回のレスポンスで 1 ツールのみ
|
||||
/// Only one tool per response.
|
||||
Sequential,
|
||||
/// 1 回のレスポンスで複数ツール並行
|
||||
/// Multiple parallel tools per response.
|
||||
Parallel,
|
||||
}
|
||||
|
||||
/// Structured output サポート
|
||||
/// Structured output support.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StructuredOutput {
|
||||
None,
|
||||
/// `json_object` モード(スキーマなし JSON 強制)
|
||||
/// `json_object` mode (enforces JSON without a schema).
|
||||
JsonObject,
|
||||
/// JSON Schema 指定で構造化出力
|
||||
/// Structured output with a JSON Schema.
|
||||
JsonSchema,
|
||||
}
|
||||
|
||||
/// Reasoning(extended thinking)サポート
|
||||
/// Reasoning (extended thinking) support.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReasoningSupport {
|
||||
/// OpenAI 形式: `reasoning.effort` (low/medium/high)
|
||||
/// OpenAI format: `reasoning.effort` (low/medium/high).
|
||||
Effort,
|
||||
/// Anthropic 形式: `thinking.budget_tokens`
|
||||
/// Anthropic format: `thinking.budget_tokens`.
|
||||
BudgetTokens,
|
||||
/// 両対応(内部では共通 `ReasoningControl` として扱い、各 scheme で投影)
|
||||
/// Both (handled internally as shared `ReasoningControl`, projected by each scheme).
|
||||
Both,
|
||||
}
|
||||
|
||||
/// Prompt caching 戦略
|
||||
/// Prompt caching strategy.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum CacheStrategy {
|
||||
/// Anthropic: `cache_control` マーカーを明示挿入
|
||||
/// Anthropic: insert explicit `cache_control` markers.
|
||||
Explicit { max_breakpoints: u8 },
|
||||
/// それ以外: サーバ側自動 prefix、または未サポート
|
||||
/// Other providers: automatic server-side prefix caching, or unsupported.
|
||||
Auto,
|
||||
}
|
||||
|
||||
/// Reasoning 制御(共通型、scheme 側で各社形式に投影)。
|
||||
/// Shared reasoning control, projected into each provider's format by the scheme.
|
||||
///
|
||||
/// 文字列は provider-native な effort label、数値は provider-native な
|
||||
/// thinking budget token として扱う。どちらか一方だけを型で表現する。
|
||||
/// Strings are provider-native effort labels; numbers are provider-native
|
||||
/// thinking token budgets. The type represents only one at a time.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum ReasoningControl {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! LLMクライアント共通trait定義
|
||||
//! Shared LLM client trait definitions.
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
@@ -6,19 +6,19 @@ use crate::llm_client::{ClientError, Request, RequestConfig, event::Event};
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
|
||||
/// 設定に関する警告
|
||||
/// Configuration warning.
|
||||
///
|
||||
/// プロバイダがサポートしていない設定を使用した場合に返される。
|
||||
/// Returned when using a configuration option unsupported by the provider.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfigWarning {
|
||||
/// 設定オプション名
|
||||
/// Configuration option name.
|
||||
pub option_name: &'static str,
|
||||
/// 警告メッセージ
|
||||
/// Warning message.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ConfigWarning {
|
||||
/// 新しい警告を作成
|
||||
/// Create a new warning.
|
||||
pub fn unsupported(option_name: &'static str, provider_name: &str) -> Self {
|
||||
Self {
|
||||
option_name,
|
||||
@@ -48,19 +48,19 @@ pub enum ToolCallCompletionSupport {
|
||||
ResponseComplete,
|
||||
}
|
||||
|
||||
/// LLMクライアントのtrait
|
||||
/// LLM client trait.
|
||||
///
|
||||
/// 各プロバイダはこのtraitを実装し、統一されたインターフェースを提供する。
|
||||
/// Each provider implements this trait to expose a unified interface.
|
||||
#[async_trait]
|
||||
pub trait LlmClient: Send + Sync {
|
||||
/// ストリーミングリクエストを送信し、Eventストリームを返す
|
||||
/// Send a streaming request and return an Event stream.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request` - リクエスト情報
|
||||
/// * `request` - Request information.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Stream)` - イベントストリーム
|
||||
/// * `Err(ClientError)` - エラー
|
||||
/// * `Ok(Stream)` - Event stream.
|
||||
/// * `Err(ClientError)` - Error.
|
||||
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError>;
|
||||
|
||||
/// Clone this client into a new `Box<dyn LlmClient>`.
|
||||
@@ -74,15 +74,15 @@ pub trait LlmClient: Send + Sync {
|
||||
ToolCallCompletionSupport::ResponseComplete
|
||||
}
|
||||
|
||||
/// 設定をバリデーションし、未サポートの設定があれば警告を返す
|
||||
/// Validate configuration and return warnings for unsupported options.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `config` - バリデーション対象の設定
|
||||
/// * `config` - Configuration to validate.
|
||||
///
|
||||
/// # Returns
|
||||
/// サポートされていない設定に対する警告のリスト
|
||||
/// List of warnings for unsupported options.
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
// デフォルト実装: 全ての設定をサポート
|
||||
// Default implementation: all options are supported.
|
||||
let _ = config;
|
||||
Vec::new()
|
||||
}
|
||||
@@ -94,9 +94,9 @@ impl Clone for Box<dyn LlmClient> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Box<dyn LlmClient>` に対する `LlmClient` の実装
|
||||
/// `LlmClient` implementation for `Box<dyn LlmClient>`.
|
||||
///
|
||||
/// これにより、動的ディスパッチを使用するクライアントも `Engine` で利用可能になる。
|
||||
/// Allows clients using dynamic dispatch to be used with `Engine`.
|
||||
#[async_trait]
|
||||
impl LlmClient for Box<dyn LlmClient> {
|
||||
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
//! LLMクライアントエラー型
|
||||
//! Report provider communication failures to turn control.
|
||||
//!
|
||||
//! Context limit rejections are classified from structured provider codes;
|
||||
//! fuzzy body matches must not imply success or retryability. The Engine maps
|
||||
//! client errors to typed execution results, which the host records as the Run's
|
||||
//! terminal outcome. Live diagnostic display and persistence are separate concerns.
|
||||
|
||||
use std::{fmt, time::Duration};
|
||||
|
||||
/// LLMクライアントのエラー
|
||||
/// LLM client error.
|
||||
#[derive(Debug)]
|
||||
pub enum ClientError {
|
||||
/// HTTPリクエストエラー
|
||||
/// HTTP request error.
|
||||
Http(reqwest::Error),
|
||||
/// JSONパースエラー
|
||||
/// JSON parse error.
|
||||
Json(serde_json::Error),
|
||||
/// SSEパースエラー
|
||||
/// SSE parse error.
|
||||
Sse(String),
|
||||
/// APIエラー (プロバイダからのエラーレスポンス)
|
||||
/// API error (an error response from the provider).
|
||||
Api {
|
||||
status: Option<u16>,
|
||||
code: Option<String>,
|
||||
@@ -26,7 +31,7 @@ pub enum ClientError {
|
||||
phase: &'static str,
|
||||
timeout: Duration,
|
||||
},
|
||||
/// 設定エラー
|
||||
/// Configuration error.
|
||||
Config(String),
|
||||
}
|
||||
|
||||
@@ -90,6 +95,22 @@ impl ClientError {
|
||||
}
|
||||
}
|
||||
|
||||
/// A provider-declared image size rejection, not a generic invalid input.
|
||||
/// Some providers (including OpenAI) expose only `invalid_value` here, so
|
||||
/// narrowly recognize their image-size diagnostic without hardcoded limits.
|
||||
pub fn is_image_size_rejection(&self) -> bool {
|
||||
let Self::Api {
|
||||
status: Some(400 | 413 | 422),
|
||||
code,
|
||||
message,
|
||||
..
|
||||
} = self
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
is_image_size_diagnostic(code.as_deref(), message)
|
||||
}
|
||||
|
||||
pub fn retry_after(&self) -> Option<Duration> {
|
||||
match self {
|
||||
ClientError::Api { retry_after, .. } => *retry_after,
|
||||
@@ -98,16 +119,39 @@ impl ClientError {
|
||||
}
|
||||
}
|
||||
|
||||
/// transient な失敗としてリトライ対象になるかを判定する。
|
||||
/// Shared diagnostic policy for HTTP rejections and provider Error events.
|
||||
/// The caller must establish the rejection path and whether replay is safe;
|
||||
/// this predicate alone never authorizes a retry.
|
||||
pub(crate) fn is_image_size_diagnostic(code: Option<&str>, message: &str) -> bool {
|
||||
if matches!(code, Some("image_too_large" | "image_size_exceeded")) {
|
||||
return true;
|
||||
}
|
||||
// OpenAI Responses appends structured diagnostic details for observation.
|
||||
// Extra fields (including echoed input) are not the error message itself.
|
||||
let message = message
|
||||
.split(" | diagnostic=")
|
||||
.next()
|
||||
.unwrap_or(message)
|
||||
.to_ascii_lowercase();
|
||||
message.contains("image")
|
||||
&& ((message.contains("patches")
|
||||
&& message.contains("exceed")
|
||||
&& message.contains("limit"))
|
||||
|| message.contains("image is too large")
|
||||
|| message.contains("image too large")
|
||||
|| (message.contains("image dimensions") && message.contains("exceed")))
|
||||
}
|
||||
|
||||
/// Determine whether a failure is transient and eligible for retry.
|
||||
///
|
||||
/// 対象:
|
||||
/// - `Api { status }` のうち 408 / 425 / 429 / 500 / 502 / 503 / 504 / 529
|
||||
/// - `Http(reqwest::Error)` のうち `is_connect()` または `is_timeout()`
|
||||
/// - `Timeout { .. }` の lifecycle hard timeout
|
||||
/// Eligible failures:
|
||||
/// - `Api { status }` with 408 / 425 / 429 / 500 / 502 / 503 / 504 / 529.
|
||||
/// - `Http(reqwest::Error)` with `is_connect()` or `is_timeout()`.
|
||||
/// - Lifecycle hard timeouts from `Timeout { .. }`.
|
||||
///
|
||||
/// それ以外(Json、Sse、Config、上記以外の Api ステータス)は false。
|
||||
/// SSE 読み出し開始後の失敗は呼び出し側で `Sse` として上に流すため、
|
||||
/// ここで対象外にしておけば自動的に弾かれる。
|
||||
/// Other failures (Json, Sse, Config, and other Api statuses) return false.
|
||||
/// Callers propagate failures after SSE reading begins as `Sse`, so excluding
|
||||
/// them here automatically prevents retries.
|
||||
pub fn is_retryable(error: &ClientError) -> bool {
|
||||
match error {
|
||||
ClientError::Api {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! LLMクライアント層のイベント型
|
||||
//! Event types for the LLM client layer.
|
||||
//!
|
||||
//! 各LLMプロバイダからのストリーミングレスポンスを表現するイベント型。
|
||||
//! Events representing streaming responses from LLM providers.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -8,43 +8,42 @@ use serde::{Deserialize, Serialize};
|
||||
// Core Event Types (from llm_client layer)
|
||||
// =============================================================================
|
||||
|
||||
/// LLMからのストリーミングイベント
|
||||
/// Streaming event from an LLM.
|
||||
///
|
||||
/// 各LLMプロバイダからのレスポンスは、この`Event`のストリームとして
|
||||
/// 統一的に処理されます。
|
||||
/// Responses from all LLM providers are handled uniformly as streams of `Event`.
|
||||
///
|
||||
/// # イベントの種類
|
||||
/// # Event types
|
||||
///
|
||||
/// - **メタイベント**: `Ping`, `Usage`, `Status`, `Error`, `UnhandledSse`
|
||||
/// - **ブロックイベント**: `BlockStart`, `BlockDelta`, `BlockStop`, `BlockAbort`
|
||||
/// - **Meta events**: `Ping`, `Usage`, `Status`, `Error`, `UnhandledSse`
|
||||
/// - **Block events**: `BlockStart`, `BlockDelta`, `BlockStop`, `BlockAbort`
|
||||
///
|
||||
/// # ブロックのライフサイクル
|
||||
/// # Block lifecycle
|
||||
///
|
||||
/// テキスト、thinking、ツール呼び出しは、`BlockStart` → `BlockDelta`(複数) → `BlockStop`
|
||||
/// の順序でイベントが発生します。thinking の round-trip metadata は
|
||||
/// `BlockStop.reasoning` に載ります。
|
||||
/// Text, thinking, and tool calls emit `BlockStart` → `BlockDelta` (multiple) →
|
||||
/// `BlockStop` in order. Thinking round-trip metadata is carried in
|
||||
/// `BlockStop.reasoning`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Event {
|
||||
/// ハートビート
|
||||
/// Heartbeat.
|
||||
Ping(PingEvent),
|
||||
/// トークン使用量
|
||||
/// Token usage.
|
||||
Usage(UsageEvent),
|
||||
/// ストリームのステータス変化
|
||||
/// Stream status change.
|
||||
Status(StatusEvent),
|
||||
/// エラー発生
|
||||
/// Error occurred.
|
||||
Error(ErrorEvent),
|
||||
/// Scheme が生成内容として解釈しない未対応 SSE イベント。
|
||||
/// Unsupported SSE event that the Scheme does not interpret as generated content.
|
||||
///
|
||||
/// stream trace 用の観測イベントであり、timeline / history には反映しない。
|
||||
/// An observation event for stream tracing, not reflected in the timeline or history.
|
||||
UnhandledSse(UnhandledSseEvent),
|
||||
|
||||
/// ブロック開始(テキスト、ツール使用等)
|
||||
/// Block start (text, tool use, etc.).
|
||||
BlockStart(BlockStart),
|
||||
/// ブロックの差分データ
|
||||
/// Block delta data.
|
||||
BlockDelta(BlockDelta),
|
||||
/// ブロック正常終了
|
||||
/// Normal block completion.
|
||||
BlockStop(BlockStop),
|
||||
/// ブロック中断
|
||||
/// Block abort.
|
||||
BlockAbort(BlockAbort),
|
||||
}
|
||||
|
||||
@@ -52,67 +51,76 @@ pub enum Event {
|
||||
// Meta Events
|
||||
// =============================================================================
|
||||
|
||||
/// Pingイベント(ハートビート)
|
||||
/// Ping event (heartbeat).
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct PingEvent {
|
||||
pub timestamp: Option<u64>,
|
||||
}
|
||||
|
||||
/// 使用量イベント
|
||||
/// Usage event.
|
||||
///
|
||||
/// プロバイダから受信した 1 LLM リクエスト分のトークン会計。
|
||||
/// 各 scheme で正規化され、フィールドの意味は全プロバイダ共通:
|
||||
/// Token accounting received from the provider for one LLM request.
|
||||
/// Each scheme normalizes it; field meanings are shared across providers:
|
||||
///
|
||||
/// - `input_tokens` は **送信した prompt prefix 全体の占有量**(プロンプト全長)。
|
||||
/// キャッシュヒット分も含まれる。Anthropic は raw API では非キャッシュ分のみを
|
||||
/// `input_tokens` として返すため、`AnthropicScheme::convert_usage` で
|
||||
/// `cache_read + cache_creation` を加算してこの規約に揃えている。
|
||||
/// - `cache_read_input_tokens` / `cache_creation_input_tokens` は上記の内訳で、
|
||||
/// 料金会計用。占有量からは差し引かない。
|
||||
/// - `input_tokens` is the **occupancy of the entire submitted prompt prefix**
|
||||
/// (full prompt length), including cache hits. Anthropic's raw API returns only
|
||||
/// uncached tokens as `input_tokens`, so `AnthropicScheme::convert_usage` adds
|
||||
/// `cache_read + cache_creation` to follow this convention.
|
||||
/// - `cache_read_input_tokens` / `cache_creation_input_tokens` are breakdowns
|
||||
/// for billing. They are not subtracted from occupancy.
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct UsageEvent {
|
||||
/// 送信した prompt prefix の総トークン数(占有量、キャッシュ込み)
|
||||
/// Total tokens in the submitted prompt prefix (occupancy, including cached tokens).
|
||||
pub input_tokens: Option<u64>,
|
||||
/// このリクエストで生成された出力トークン数
|
||||
/// Output tokens generated by this request.
|
||||
pub output_tokens: Option<u64>,
|
||||
/// `input_tokens + output_tokens`
|
||||
pub total_tokens: Option<u64>,
|
||||
/// `input_tokens` のうちキャッシュから読まれた分(割引料金)
|
||||
/// Portion of `input_tokens` read from cache (discounted rate).
|
||||
pub cache_read_input_tokens: Option<u64>,
|
||||
/// `input_tokens` のうちこのリクエストでキャッシュに書かれた分(割増料金、Anthropic)
|
||||
/// Portion of `input_tokens` written to cache by this request (premium rate, Anthropic).
|
||||
pub cache_creation_input_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
/// ステータスイベント
|
||||
/// Status event.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StatusEvent {
|
||||
pub status: ResponseStatus,
|
||||
}
|
||||
|
||||
/// レスポンスステータス
|
||||
/// Response status.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ResponseStatus {
|
||||
/// ストリーム開始
|
||||
/// Stream started.
|
||||
Started,
|
||||
/// 正常完了
|
||||
/// Completed normally.
|
||||
Completed,
|
||||
/// キャンセルされた
|
||||
/// Cancelled.
|
||||
Cancelled,
|
||||
/// エラー発生
|
||||
/// Error occurred.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// エラーイベント
|
||||
/// Error event.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ErrorEvent {
|
||||
pub code: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 未対応 SSE イベントの観測用メタイベント。
|
||||
impl ErrorEvent {
|
||||
/// A provider Error event has no HTTP status. Classify its diagnostic
|
||||
/// explicitly, rather than making every status-less ClientError eligible.
|
||||
/// The engine must separately establish that no response output was seen.
|
||||
pub(crate) fn is_image_size_rejection(&self) -> bool {
|
||||
super::error::is_image_size_diagnostic(self.code.as_deref(), &self.message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Observation-only meta event for unsupported SSE events.
|
||||
///
|
||||
/// `data_preview` は provider から受け取った raw SSE data の bounded preview、
|
||||
/// `data_len` は preview 前の raw data byte length。
|
||||
/// `data_preview` is a bounded preview of raw SSE data received from the provider;
|
||||
/// `data_len` is the raw data's byte length before preview truncation.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UnhandledSseEvent {
|
||||
pub provider: String,
|
||||
@@ -125,27 +133,27 @@ pub struct UnhandledSseEvent {
|
||||
// Block Types
|
||||
// =============================================================================
|
||||
|
||||
/// ブロックの種別
|
||||
/// Block type.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum BlockType {
|
||||
/// テキスト生成
|
||||
/// Text generation.
|
||||
Text,
|
||||
/// 思考 (Claude Extended Thinking等)
|
||||
/// Thinking (e.g. Claude Extended Thinking).
|
||||
Thinking,
|
||||
/// ツール呼び出し
|
||||
/// Tool call.
|
||||
ToolUse,
|
||||
/// ツール結果
|
||||
/// Tool result.
|
||||
ToolResult,
|
||||
}
|
||||
|
||||
/// ブロック開始イベント
|
||||
/// Block start event.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockStart {
|
||||
/// ブロックのインデックス
|
||||
/// Block index.
|
||||
pub index: usize,
|
||||
/// ブロックの種別
|
||||
/// Block type.
|
||||
pub block_type: BlockType,
|
||||
/// ブロック固有のメタデータ
|
||||
/// Block-specific metadata.
|
||||
pub metadata: BlockMetadata,
|
||||
}
|
||||
|
||||
@@ -155,7 +163,7 @@ impl BlockStart {
|
||||
}
|
||||
}
|
||||
|
||||
/// ブロックのメタデータ
|
||||
/// Block metadata.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum BlockMetadata {
|
||||
Text,
|
||||
@@ -164,28 +172,28 @@ pub enum BlockMetadata {
|
||||
ToolResult { tool_use_id: String },
|
||||
}
|
||||
|
||||
/// ブロックデルタイベント
|
||||
/// Block delta event.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockDelta {
|
||||
/// ブロックのインデックス
|
||||
/// Block index.
|
||||
pub index: usize,
|
||||
/// デルタの内容
|
||||
/// Delta content.
|
||||
pub delta: DeltaContent,
|
||||
}
|
||||
|
||||
/// デルタの内容
|
||||
/// Delta content.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DeltaContent {
|
||||
/// テキストデルタ
|
||||
/// Text delta.
|
||||
Text(String),
|
||||
/// 思考デルタ
|
||||
/// Thinking delta.
|
||||
Thinking(String),
|
||||
/// ツール引数のJSON部分文字列
|
||||
/// Partial JSON string for tool arguments.
|
||||
InputJson(String),
|
||||
}
|
||||
|
||||
impl DeltaContent {
|
||||
/// デルタのブロック種別を取得
|
||||
/// Get the delta's block type.
|
||||
pub fn block_type(&self) -> BlockType {
|
||||
match self {
|
||||
DeltaContent::Text(_) => BlockType::Text,
|
||||
@@ -195,20 +203,20 @@ impl DeltaContent {
|
||||
}
|
||||
}
|
||||
|
||||
/// ブロック停止イベント
|
||||
/// Block stop event.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockStop {
|
||||
/// ブロックのインデックス
|
||||
/// Block index.
|
||||
pub index: usize,
|
||||
/// ブロックの種別
|
||||
/// Block type.
|
||||
pub block_type: BlockType,
|
||||
/// 停止理由
|
||||
/// Stop reason.
|
||||
pub stop_reason: Option<StopReason>,
|
||||
/// Thinking block の停止時に確定した reasoning round-trip metadata。
|
||||
/// Reasoning round-trip metadata finalized when a Thinking block stops.
|
||||
///
|
||||
/// `None` の Thinking block は live streaming / trace 用で、history に
|
||||
/// `Item::Reasoning` として永続化しない。`Some` の場合は block lifecycle
|
||||
/// が永続化の authoritative source になる。
|
||||
/// Thinking blocks with `None` are for live streaming / tracing and are not
|
||||
/// persisted in history as `Item::Reasoning`. With `Some`, the block lifecycle
|
||||
/// is the authoritative source for persistence.
|
||||
pub reasoning: Option<ReasoningBlockData>,
|
||||
}
|
||||
|
||||
@@ -218,14 +226,14 @@ impl BlockStop {
|
||||
}
|
||||
}
|
||||
|
||||
/// ブロック中断イベント
|
||||
/// Block abort event.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockAbort {
|
||||
/// ブロックのインデックス
|
||||
/// Block index.
|
||||
pub index: usize,
|
||||
/// ブロックの種別
|
||||
/// Block type.
|
||||
pub block_type: BlockType,
|
||||
/// 中断理由
|
||||
/// Abort reason.
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
@@ -235,36 +243,36 @@ impl BlockAbort {
|
||||
}
|
||||
}
|
||||
|
||||
/// Thinking block stop で確定した reasoning material。
|
||||
/// Reasoning material finalized at Thinking block stop.
|
||||
///
|
||||
/// `Item::Reasoning` の round-trip に必要な provider material を保持する。
|
||||
/// `text` は deltas から収集した本文を上書きするために使う(metadata-only
|
||||
/// reasoning block や provider completion event で全文が届くケース)。
|
||||
/// Holds provider material needed to round-trip `Item::Reasoning`.
|
||||
/// `text` overrides the body collected from deltas (for metadata-only reasoning
|
||||
/// blocks or provider completion events that deliver the full text).
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct ReasoningBlockData {
|
||||
/// scheme 側で観測した item id(OpenAI Responses の `id`)。
|
||||
/// Item ID observed by the scheme (OpenAI Responses `id`).
|
||||
pub id: Option<String>,
|
||||
/// reasoning 本体テキスト。`None` の場合は block delta 収集結果を使う。
|
||||
/// Reasoning body text. `None` uses the collected block deltas.
|
||||
pub text: Option<String>,
|
||||
/// summary (OpenAI Responses の `summary_text[]`)。他 scheme は空。
|
||||
/// Summary (OpenAI Responses `summary_text[]`); empty for other schemes.
|
||||
pub summary: Vec<String>,
|
||||
/// 暗号化された opaque blob(Anthropic `redacted_thinking.data` /
|
||||
/// OpenAI Responses `encrypted_content`)。
|
||||
/// Encrypted opaque blob (Anthropic `redacted_thinking.data` /
|
||||
/// OpenAI Responses `encrypted_content`).
|
||||
pub encrypted_content: Option<String>,
|
||||
/// Anthropic extended thinking signature。round-trip 必須。
|
||||
/// Anthropic extended thinking signature, required for round-tripping.
|
||||
pub signature: Option<String>,
|
||||
}
|
||||
|
||||
/// 停止理由
|
||||
/// Stop reason.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum StopReason {
|
||||
/// 自然終了
|
||||
/// Natural end of turn.
|
||||
EndTurn,
|
||||
/// 最大トークン数到達
|
||||
/// Maximum token count reached.
|
||||
MaxTokens,
|
||||
/// ストップシーケンス到達
|
||||
/// Stop sequence reached.
|
||||
StopSequence,
|
||||
/// ツール使用
|
||||
/// Tool use.
|
||||
ToolUse,
|
||||
}
|
||||
|
||||
@@ -273,7 +281,7 @@ pub enum StopReason {
|
||||
// =============================================================================
|
||||
|
||||
impl Event {
|
||||
/// テキストブロック開始イベントを作成
|
||||
/// Create a text block start event.
|
||||
pub fn text_block_start(index: usize) -> Self {
|
||||
Event::BlockStart(BlockStart {
|
||||
index,
|
||||
@@ -282,7 +290,7 @@ impl Event {
|
||||
})
|
||||
}
|
||||
|
||||
/// テキストデルタイベントを作成
|
||||
/// Create a text delta event.
|
||||
pub fn text_delta(index: usize, text: impl Into<String>) -> Self {
|
||||
Event::BlockDelta(BlockDelta {
|
||||
index,
|
||||
@@ -290,7 +298,7 @@ impl Event {
|
||||
})
|
||||
}
|
||||
|
||||
/// テキストブロック停止イベントを作成
|
||||
/// Create a text block stop event.
|
||||
pub fn text_block_stop(index: usize, stop_reason: Option<StopReason>) -> Self {
|
||||
Event::BlockStop(BlockStop {
|
||||
index,
|
||||
@@ -300,7 +308,7 @@ impl Event {
|
||||
})
|
||||
}
|
||||
|
||||
/// ツール使用ブロック開始イベントを作成
|
||||
/// Create a tool use block start event.
|
||||
pub fn tool_use_start(index: usize, id: impl Into<String>, name: impl Into<String>) -> Self {
|
||||
Event::BlockStart(BlockStart {
|
||||
index,
|
||||
@@ -312,7 +320,7 @@ impl Event {
|
||||
})
|
||||
}
|
||||
|
||||
/// ツール引数デルタイベントを作成
|
||||
/// Create a tool argument delta event.
|
||||
pub fn tool_input_delta(index: usize, json: impl Into<String>) -> Self {
|
||||
Event::BlockDelta(BlockDelta {
|
||||
index,
|
||||
@@ -320,7 +328,7 @@ impl Event {
|
||||
})
|
||||
}
|
||||
|
||||
/// ツール使用ブロック停止イベントを作成
|
||||
/// Create a tool use block stop event.
|
||||
pub fn tool_use_stop(index: usize) -> Self {
|
||||
Event::BlockStop(BlockStop {
|
||||
index,
|
||||
@@ -330,7 +338,7 @@ impl Event {
|
||||
})
|
||||
}
|
||||
|
||||
/// 使用量イベントを作成
|
||||
/// Create a usage event.
|
||||
pub fn usage(input_tokens: u64, output_tokens: u64) -> Self {
|
||||
Event::Usage(UsageEvent {
|
||||
input_tokens: Some(input_tokens),
|
||||
@@ -341,7 +349,7 @@ impl Event {
|
||||
})
|
||||
}
|
||||
|
||||
/// Pingイベントを作成
|
||||
/// Create a Ping event.
|
||||
pub fn ping() -> Self {
|
||||
Event::Ping(PingEvent { timestamp: None })
|
||||
}
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
//! LLMクライアント層
|
||||
//! Boundary between the Engine and provider communication.
|
||||
//!
|
||||
//! 各LLMプロバイダと通信し、統一された[`Event`]
|
||||
//! ストリームを出力します。
|
||||
//! [`LlmClient`] accepts a shared [`Request`] and returns normalized [`Event`]s.
|
||||
//! Provider-specific bodies, authentication requirements, and stream parsing are
|
||||
//! separated into [`scheme`] and [`transport`] to limit how API changes affect
|
||||
//! turn progression and tool execution semantics.
|
||||
//!
|
||||
//! # サポートするプロバイダ
|
||||
//!
|
||||
//! - Anthropic (Claude)
|
||||
//! - OpenAI (GPT-4, etc.)
|
||||
//! - Google (Gemini)
|
||||
//! - Ollama (ローカルLLM)
|
||||
//!
|
||||
//! # アーキテクチャ
|
||||
//!
|
||||
//! - [`LlmClient`] - プロバイダ共通のtrait
|
||||
//! - `providers`: プロバイダ固有のクライアント実装
|
||||
//! - `scheme`: APIスキーマ(リクエスト/レスポンス変換)
|
||||
//! Scheme capabilities describe what the wire format can safely handle.
|
||||
//! The client construction layer owns per-model capability tables and alias
|
||||
//! resolution; the Engine does not depend on that selection process.
|
||||
|
||||
pub mod auth;
|
||||
pub mod capability;
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
//! LLM response stream を開く前の transient error 向けリトライポリシー。
|
||||
//! Retry policy for transient errors before opening an LLM response stream.
|
||||
//!
|
||||
//! `LlmClient::stream` の open error に対して `is_retryable` を見て
|
||||
//! retry / backoff / cancellation をまとめて管理する。
|
||||
//! SSE 読み出し開始後の失敗は対象外。
|
||||
//! Uses `is_retryable` on `LlmClient::stream` open errors to manage retries,
|
||||
//! backoff, and cancellation together. Failures after SSE reading begins
|
||||
//! are excluded.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// 指数バックオフ + ジッター + 累積タイムアウトを表すポリシー。
|
||||
/// Policy combining exponential backoff, jitter, and a cumulative timeout.
|
||||
///
|
||||
/// `Default` は agen 全体の固定値を返す。呼び出し側からの上書きが
|
||||
/// 必要になったら拡張する。
|
||||
/// `Default` returns fixed values shared across agen. Extend this if caller
|
||||
/// overrides become necessary.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetryPolicy {
|
||||
/// 指数の基準値。`base * 2^attempt` を `cap` で頭打ちにした上限から
|
||||
/// フルジッターで実際の wait を抽選する。
|
||||
/// Exponential base. Full jitter samples the actual wait from an upper bound
|
||||
/// of `base * 2^attempt`, capped at `cap`.
|
||||
pub base: Duration,
|
||||
/// 1 回あたりの wait の上限。
|
||||
/// Maximum wait per attempt.
|
||||
pub cap: Duration,
|
||||
/// 試行の合計回数(初回 + リトライ)。`1` ならリトライしない。
|
||||
/// Total attempts (initial attempt + retries). `1` disables retries.
|
||||
pub max_attempts: u32,
|
||||
/// 初回送信開始からの累積タイムアウト。これを超える wait は打ち切る。
|
||||
/// Cumulative timeout from the first send. Waits exceeding it are cut short.
|
||||
pub total_timeout: Duration,
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ impl Default for RetryPolicy {
|
||||
}
|
||||
|
||||
impl RetryPolicy {
|
||||
/// `attempt` 回目の失敗(0-indexed)後に待つ時間を返す。
|
||||
/// `Retry-After` で上書きしたい場合は呼び出さず、その値をそのまま使う。
|
||||
/// Return the wait after failure number `attempt` (zero-indexed).
|
||||
/// For a `Retry-After` override, use its value directly instead of calling this.
|
||||
pub fn backoff(&self, attempt: u32) -> Duration {
|
||||
let shift = attempt.min(20);
|
||||
let base_nanos = self.base.as_nanos() as u64;
|
||||
@@ -47,9 +47,9 @@ impl RetryPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
/// `[0, max_nanos]` から擬似乱数的に 1 つ取り出す。`SystemTime` の
|
||||
/// 下位ビットを splitmix64 で攪拌するだけの軽量実装で、暗号的乱数性は
|
||||
/// 持たないがフルジッターのぶつかり回避には十分。
|
||||
/// Sample a pseudorandom value from `[0, max_nanos]`. This lightweight
|
||||
/// implementation mixes the low bits of `SystemTime` with splitmix64.
|
||||
/// It is not cryptographically random, but suffices to avoid full-jitter collisions.
|
||||
fn jitter_nanos(max_nanos: u64) -> u64 {
|
||||
if max_nanos == 0 {
|
||||
return 0;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
//! Anthropic scheme の wire-level 既定 capability。
|
||||
//! Wire-level default capability for the Anthropic scheme.
|
||||
//!
|
||||
//! モデル ID 固有のテーブル(`claude-*` など)は client construction layer
|
||||
//! の責務。ここでは未知モデルでも「この wire で
|
||||
//! 安全に送れる最小共通項」を返すだけに留める。
|
||||
//! Model ID tables (e.g. `claude-*`) belong to the client construction layer.
|
||||
//! This module returns only the minimum common features safe to send over
|
||||
//! this wire, even for unknown models.
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
/// Scheme 既定の capability。
|
||||
/// Scheme default capability.
|
||||
///
|
||||
/// Ollama の `/v1/messages` 流用を想定して `cache_control` を送らない
|
||||
/// `CacheStrategy::Auto` にする。
|
||||
/// Uses `CacheStrategy::Auto` to avoid sending `cache_control`, allowing reuse
|
||||
/// with Ollama's `/v1/messages` endpoint.
|
||||
pub(crate) fn default_capability() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Anthropic SSEイベントパース
|
||||
//! Anthropic SSE event parsing.
|
||||
//!
|
||||
//! Anthropic Messages APIのSSEイベントをパースし、統一Event型に変換
|
||||
//! Parse Anthropic Messages API SSE events and convert them to the unified Event type.
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
@@ -14,7 +14,7 @@ use serde::Deserialize;
|
||||
use super::AnthropicScheme;
|
||||
use super::scheme_impl::{AnthropicState, PendingThinking};
|
||||
|
||||
/// Anthropic SSEイベントタイプ
|
||||
/// Anthropic SSE event type.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum AnthropicEventType {
|
||||
MessageStart,
|
||||
@@ -28,7 +28,7 @@ pub(crate) enum AnthropicEventType {
|
||||
}
|
||||
|
||||
impl AnthropicEventType {
|
||||
/// イベントタイプ文字列からパース
|
||||
/// Parse an event type string.
|
||||
pub(crate) fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"message_start" => Some(Self::MessageStart),
|
||||
@@ -45,10 +45,10 @@ impl AnthropicEventType {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSEイベントのJSON構造
|
||||
// SSE event JSON structures
|
||||
// ============================================================================
|
||||
|
||||
/// message_start イベント
|
||||
/// message_start event.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct MessageStartEvent {
|
||||
pub message: MessageStartMessage,
|
||||
@@ -62,7 +62,7 @@ pub(crate) struct MessageStartMessage {
|
||||
pub usage: Option<UsageData>,
|
||||
}
|
||||
|
||||
/// content_block_start イベント
|
||||
/// content_block_start event.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ContentBlockStartEvent {
|
||||
pub index: usize,
|
||||
@@ -79,15 +79,15 @@ pub(crate) enum ContentBlock {
|
||||
Thinking {
|
||||
#[serde(default)]
|
||||
thinking: String,
|
||||
/// 非ストリーミングレスポンス由来の初期 signature(通常はストリームでは
|
||||
/// 空 → `signature_delta` で埋まる)。
|
||||
/// Initial signature from a non-streaming response (usually empty in
|
||||
/// streams, then filled by `signature_delta`).
|
||||
#[serde(default)]
|
||||
signature: Option<String>,
|
||||
},
|
||||
#[serde(rename = "redacted_thinking")]
|
||||
RedactedThinking {
|
||||
/// 暗号化された opaque blob。signature ではなく、まるごと
|
||||
/// `redacted_thinking.data` として送り返す必要がある。
|
||||
/// Encrypted opaque blob, not a signature. Must be sent back in full
|
||||
/// as `redacted_thinking.data`.
|
||||
#[serde(default)]
|
||||
data: String,
|
||||
},
|
||||
@@ -99,7 +99,7 @@ pub(crate) enum ContentBlock {
|
||||
},
|
||||
}
|
||||
|
||||
/// content_block_delta イベント
|
||||
/// content_block_delta event.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ContentBlockDeltaEvent {
|
||||
pub index: usize,
|
||||
@@ -120,13 +120,13 @@ pub(crate) enum DeltaBlock {
|
||||
SignatureDelta { signature: String },
|
||||
}
|
||||
|
||||
/// content_block_stop イベント
|
||||
/// content_block_stop event.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ContentBlockStopEvent {
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
/// message_delta イベント
|
||||
/// message_delta event.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct MessageDeltaEvent {
|
||||
@@ -141,7 +141,7 @@ pub(crate) struct MessageDeltaData {
|
||||
pub stop_sequence: Option<String>,
|
||||
}
|
||||
|
||||
/// 使用量データ
|
||||
/// Usage data.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct UsageData {
|
||||
pub input_tokens: Option<u64>,
|
||||
@@ -150,7 +150,7 @@ pub(crate) struct UsageData {
|
||||
pub cache_creation_input_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
/// エラーイベント
|
||||
/// Error event.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ErrorEventData {
|
||||
pub error: ErrorDetail,
|
||||
@@ -164,20 +164,20 @@ pub(crate) struct ErrorDetail {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// イベント変換
|
||||
// Event conversion
|
||||
// ============================================================================
|
||||
|
||||
impl AnthropicScheme {
|
||||
/// SSEイベントをEvent型に変換
|
||||
/// Convert an SSE event to Event.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `event_type` - SSEイベントタイプ
|
||||
/// * `data` - イベントデータJSON文字列
|
||||
/// * `event_type` - SSE event type.
|
||||
/// * `data` - Event data as a JSON string.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Some(Event))` - 変換成功
|
||||
/// * `Ok(None)` - イベントを無視(unknown event等)
|
||||
/// * `Err(ClientError)` - パースエラー
|
||||
/// * `Ok(Some(Event))` - Conversion succeeded.
|
||||
/// * `Ok(None)` - Event ignored (e.g. an unknown event).
|
||||
/// * `Err(ClientError)` - Parse error.
|
||||
pub(crate) fn parse_event(
|
||||
&self,
|
||||
event_type: &str,
|
||||
@@ -191,11 +191,11 @@ impl AnthropicScheme {
|
||||
match event_type {
|
||||
AnthropicEventType::MessageStart => {
|
||||
let event: MessageStartEvent = serde_json::from_str(data)?;
|
||||
// message_start時にUsageイベントがあれば出力
|
||||
// Emit a Usage event at message_start if available.
|
||||
if let Some(usage) = event.message.usage {
|
||||
return Ok(Some(Event::Usage(self.convert_usage(&usage))));
|
||||
}
|
||||
// Statusイベントとして開始を通知
|
||||
// Notify start via a Status event.
|
||||
Ok(Some(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Started,
|
||||
})))
|
||||
@@ -210,18 +210,18 @@ impl AnthropicScheme {
|
||||
}
|
||||
AnthropicEventType::ContentBlockStop => {
|
||||
let event: ContentBlockStopEvent = serde_json::from_str(data)?;
|
||||
// Note: BlockStopにはblock_typeが必要だが、AnthropicはStopイベントに含めない
|
||||
// Timeline層がBlockStartを追跡して正しいblock_typeを知る
|
||||
// Note: BlockStop requires block_type, but Anthropic omits it from Stop events.
|
||||
// The stateful parser tracks BlockStart to restore the correct block_type.
|
||||
Ok(Some(Event::BlockStop(BlockStop {
|
||||
index: event.index,
|
||||
block_type: BlockType::Text, // Timeline層で上書きされる
|
||||
block_type: BlockType::Text, // Placeholder for the stateful parser.
|
||||
stop_reason: None,
|
||||
reasoning: None,
|
||||
})))
|
||||
}
|
||||
AnthropicEventType::MessageDelta => {
|
||||
let event: MessageDeltaEvent = serde_json::from_str(data)?;
|
||||
// Usage情報があれば出力
|
||||
// Emit Usage information if available.
|
||||
if let Some(usage) = event.usage {
|
||||
return Ok(Some(Event::Usage(self.convert_usage(&usage))));
|
||||
}
|
||||
@@ -271,7 +271,7 @@ impl AnthropicScheme {
|
||||
DeltaContent::InputJson(partial_json.clone())
|
||||
}
|
||||
DeltaBlock::SignatureDelta { .. } => {
|
||||
// signature_delta は無視
|
||||
// Ignore signature_delta.
|
||||
return None;
|
||||
}
|
||||
};
|
||||
@@ -282,15 +282,14 @@ impl AnthropicScheme {
|
||||
}))
|
||||
}
|
||||
|
||||
/// state を持ち回す上位パース。
|
||||
/// Higher-level parser carrying state across events.
|
||||
///
|
||||
/// `parse_event` の単発 Event に加えて、以下を行う:
|
||||
/// - `content_block_stop` の `block_type` を直前の Start 値で書き戻す
|
||||
/// - `thinking` / `redacted_thinking` ブロックの本体・signature・data を
|
||||
/// `state.pending_thinking` に蓄積し、`content_block_stop` の Thinking
|
||||
/// BlockStop metadata に載せる
|
||||
/// - `signature_delta` を蓄積(Stream channel には流さず、reasoning metadata
|
||||
/// にだけ反映する)
|
||||
/// In addition to the single Event from `parse_event`, it:
|
||||
/// - Writes the preceding Start's `block_type` into `content_block_stop`.
|
||||
/// - Accumulates the body, signature, and data of `thinking` /
|
||||
/// `redacted_thinking` blocks in `state.pending_thinking` and attaches them
|
||||
/// to Thinking BlockStop metadata at `content_block_stop`.
|
||||
/// - Accumulates `signature_delta` only in reasoning metadata, not the Stream channel.
|
||||
pub(crate) fn parse_with_state(
|
||||
&self,
|
||||
event_type: &str,
|
||||
@@ -301,8 +300,8 @@ impl AnthropicScheme {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
// signature_delta はストリーム表示には流さず、state にだけ蓄積。
|
||||
// それ以外は parse_event で標準 Event 化する。
|
||||
// Accumulate signature_delta in state only, not in the stream display.
|
||||
// Convert everything else to standard Events via parse_event.
|
||||
let mut emitted: Vec<Event> = Vec::new();
|
||||
|
||||
match parsed_event_type {
|
||||
@@ -351,7 +350,7 @@ impl AnthropicScheme {
|
||||
}
|
||||
DeltaBlock::SignatureDelta { signature } => {
|
||||
if let Some(pending) = state.pending_thinking.as_mut() {
|
||||
// 通常 1 回しか来ないが、複数 fragment 来ても連結しておく
|
||||
// Usually received once, but concatenate multiple fragments if present.
|
||||
match &mut pending.signature {
|
||||
Some(acc) => acc.push_str(signature),
|
||||
None => pending.signature = Some(signature.clone()),
|
||||
@@ -391,7 +390,7 @@ impl AnthropicScheme {
|
||||
reasoning,
|
||||
}));
|
||||
}
|
||||
// 残りは state を必要としない。既存 parse_event に委譲。
|
||||
// The rest needs no state; delegate to the existing parse_event.
|
||||
_ => {
|
||||
if let Some(event) = self.parse_event(event_type, data)? {
|
||||
emitted.push(event);
|
||||
@@ -403,13 +402,13 @@ impl AnthropicScheme {
|
||||
}
|
||||
|
||||
fn convert_usage(&self, usage: &UsageData) -> UsageEvent {
|
||||
// Anthropic の `input_tokens` は **キャッシュ外** の入力トークンのみで、
|
||||
// プロンプト全長は input_tokens + cache_read + cache_creation。
|
||||
// UsageEvent の `input_tokens` には「占有量(プロンプト全長)」を載せる
|
||||
// 規約に合わせて、ここでキャッシュ分を足し込む。
|
||||
// cache_read_input_tokens / cache_creation_input_tokens は内訳として
|
||||
// 別フィールドに残るので、料金計算側で `input - cache_read - cache_creation`
|
||||
// により非キャッシュ入力分は逆算可能。
|
||||
// Anthropic's `input_tokens` counts only **uncached** input tokens.
|
||||
// Full prompt length is input_tokens + cache_read + cache_creation.
|
||||
// Add cached tokens here to follow UsageEvent's convention of reporting
|
||||
// occupancy (full prompt length) in `input_tokens`.
|
||||
// cache_read_input_tokens / cache_creation_input_tokens remain separate
|
||||
// breakdown fields, so billing can recover uncached input with
|
||||
// `input - cache_read - cache_creation`.
|
||||
let raw_input = usage.input_tokens.unwrap_or(0);
|
||||
let cache_read = usage.cache_read_input_tokens.unwrap_or(0);
|
||||
let cache_creation = usage.cache_creation_input_tokens.unwrap_or(0);
|
||||
@@ -438,7 +437,7 @@ mod tests {
|
||||
let event = scheme.parse_event("message_start", data).unwrap().unwrap();
|
||||
match event {
|
||||
Event::Usage(u) => {
|
||||
// キャッシュなしなので input_total = raw_input = 10
|
||||
// No cache, so input_total = raw_input = 10.
|
||||
assert_eq!(u.input_tokens, Some(10));
|
||||
}
|
||||
_ => panic!("Expected Usage event"),
|
||||
@@ -447,9 +446,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_convert_usage_includes_cache_in_input_total() {
|
||||
// Anthropic の input_tokens はキャッシュ外のみで、占有量は
|
||||
// input + cache_read + cache_creation。
|
||||
// UsageEvent.input_tokens は占有量に正規化される。
|
||||
// Anthropic's input_tokens counts only uncached tokens; occupancy is
|
||||
// input + cache_read + cache_creation.
|
||||
// UsageEvent.input_tokens is normalized to occupancy.
|
||||
let scheme = AnthropicScheme::new();
|
||||
let usage = UsageData {
|
||||
input_tokens: Some(100),
|
||||
@@ -531,7 +530,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn thinking_block_stop_carries_reasoning_with_signature() {
|
||||
// thinking ブロックが完了したら reasoning metadata に text+signature が乗ること
|
||||
// Completed thinking blocks carry text+signature in reasoning metadata.
|
||||
let scheme = AnthropicScheme::new();
|
||||
let mut state = AnthropicState::default();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Anthropic Messages API スキーマ
|
||||
//! Anthropic Messages API scheme.
|
||||
//!
|
||||
//! - リクエストJSON生成
|
||||
//! - SSEイベントパース → Event変換
|
||||
//! - Request JSON generation.
|
||||
//! - SSE event parsing → Event conversion.
|
||||
|
||||
mod capability;
|
||||
mod events;
|
||||
@@ -10,14 +10,14 @@ mod scheme_impl;
|
||||
|
||||
pub use scheme_impl::AnthropicState;
|
||||
|
||||
/// Anthropicスキーマ
|
||||
/// Anthropic scheme.
|
||||
///
|
||||
/// Anthropic Messages APIのリクエスト/レスポンス変換を担当
|
||||
/// Handles request/response conversion for the Anthropic Messages API.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnthropicScheme {
|
||||
/// APIバージョン
|
||||
/// API version.
|
||||
pub api_version: String,
|
||||
/// 細粒度ツールストリーミングを有効にするか
|
||||
/// Whether to enable fine-grained tool streaming.
|
||||
pub fine_grained_tool_streaming: bool,
|
||||
}
|
||||
|
||||
@@ -31,12 +31,12 @@ impl Default for AnthropicScheme {
|
||||
}
|
||||
|
||||
impl AnthropicScheme {
|
||||
/// 新しいスキーマを作成
|
||||
/// Create a new scheme.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 細粒度ツールストリーミングを有効/無効にする
|
||||
/// Enable or disable fine-grained tool streaming.
|
||||
pub fn with_fine_grained_tool_streaming(mut self, enabled: bool) -> Self {
|
||||
self.fine_grained_tool_streaming = enabled;
|
||||
self
|
||||
|
||||
@@ -41,7 +41,7 @@ pub(crate) struct AnthropicRequest {
|
||||
pub thinking: Option<AnthropicThinking>,
|
||||
}
|
||||
|
||||
/// Anthropic extended thinking 指示。
|
||||
/// Anthropic extended thinking instructions.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum AnthropicThinking {
|
||||
@@ -90,8 +90,7 @@ pub(crate) enum AnthropicContentPart {
|
||||
},
|
||||
#[serde(rename = "redacted_thinking")]
|
||||
RedactedThinking {
|
||||
/// 暗号化済み reasoning blob。`Item::Reasoning::encrypted_content`
|
||||
/// から渡る。
|
||||
/// Encrypted reasoning blob from `Item::Reasoning::encrypted_content`.
|
||||
data: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cache_control: Option<CacheControl>,
|
||||
@@ -181,9 +180,9 @@ pub(crate) struct AnthropicTool {
|
||||
impl AnthropicScheme {
|
||||
/// Build Anthropic request from Request.
|
||||
///
|
||||
/// `capability.prompt_caching` が [`CacheStrategy::Auto`] のときは
|
||||
/// `cache_control` マーカーを一切挿入しない(Ollama の `/v1/messages`
|
||||
/// 流用時など、サーバ側が `cache_control` を受け付けないケース)。
|
||||
/// No `cache_control` markers are inserted when `capability.prompt_caching`
|
||||
/// is [`CacheStrategy::Auto`] (e.g. servers that reject `cache_control`,
|
||||
/// such as Ollama when reusing `/v1/messages`).
|
||||
pub(crate) fn build_request(
|
||||
&self,
|
||||
model: &str,
|
||||
@@ -198,9 +197,8 @@ impl AnthropicScheme {
|
||||
let messages = self.convert_items_to_messages(&request.items, &breakpoints);
|
||||
let tools = request.tools.iter().map(|t| self.convert_tool(t)).collect();
|
||||
|
||||
// Reasoning の投影: capability が BudgetTokens / Both をサポート
|
||||
// していて、request 側で budget_tokens が指定されているときだけ
|
||||
// thinking フィールドを付ける。
|
||||
// Project reasoning: add the thinking field only when the capability
|
||||
// supports BudgetTokens / Both and the request specifies budget_tokens.
|
||||
let supports_budget_tokens = matches!(
|
||||
capability.reasoning,
|
||||
Some(ReasoningSupport::BudgetTokens | ReasoningSupport::Both),
|
||||
@@ -361,23 +359,23 @@ impl AnthropicScheme {
|
||||
..
|
||||
} => {
|
||||
flush_pending(&mut messages, &mut pending_user, "user", &mut locations);
|
||||
// Anthropic はアシスタントターン中の `thinking` /
|
||||
// `redacted_thinking` ブロックを必ず assistant role の
|
||||
// content_part として送り返す必要がある。
|
||||
// Anthropic requires `thinking` / `redacted_thinking` blocks
|
||||
// from an assistant turn to be sent back as assistant-role
|
||||
// content_parts.
|
||||
//
|
||||
// - signature あり: `thinking` content_part を投影
|
||||
// - signature 無し + encrypted_content あり:
|
||||
// `redacted_thinking` content_part を投影
|
||||
// - どちらも無い: 他 scheme(OpenAI 等)から流入した
|
||||
// 素の reasoning text。Anthropic に投げる意味も
|
||||
// round-trip の根拠も無いので drop。
|
||||
// - With signature: project a `thinking` content_part.
|
||||
// - No signature, but encrypted_content present:
|
||||
// project a `redacted_thinking` content_part.
|
||||
// - Neither: plain reasoning text from another scheme
|
||||
// (e.g. OpenAI). Drop it: there is no reason to send it to
|
||||
// Anthropic and no basis for round-tripping it.
|
||||
if let Some(sig) = signature.clone() {
|
||||
pending_assistant
|
||||
.push((i, AnthropicContentPart::thinking(text.clone(), sig)));
|
||||
} else if let Some(data) = encrypted_content.clone() {
|
||||
pending_assistant.push((i, AnthropicContentPart::redacted_thinking(data)));
|
||||
}
|
||||
// どちらも None なら何も pend せず、本 item は無視。
|
||||
// If both are None, queue nothing and ignore this item.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -486,7 +484,7 @@ mod tests {
|
||||
CacheStrategy, ReasoningEffort, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
/// cache_control が有効になる既定の capability。
|
||||
/// Default capability with cache_control enabled.
|
||||
fn cap_explicit() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
@@ -497,7 +495,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// cache_control を送らない capability(Ollama 等)。
|
||||
/// Capability that does not send cache_control (e.g. Ollama).
|
||||
fn cap_auto() -> ModelCapability {
|
||||
ModelCapability {
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
@@ -901,8 +899,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cache_auto_does_not_add_cache_control() {
|
||||
// Ollama のように `CacheStrategy::Auto` のときは cache_control
|
||||
// マーカーを一切付けない。breakpoint 計算も走らないこと。
|
||||
// With `CacheStrategy::Auto` (e.g. Ollama), add no cache_control markers.
|
||||
// Breakpoint calculation must not run either.
|
||||
let scheme = AnthropicScheme::new();
|
||||
let mut request = Request::new().user("hello");
|
||||
request.cache_anchor = Some(0);
|
||||
@@ -933,8 +931,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn reasoning_with_signature_projects_thinking_part() {
|
||||
// Item::Reasoning に signature があれば assistant role の
|
||||
// `thinking` content_part として送る。
|
||||
// Send Item::Reasoning with a signature as an assistant-role
|
||||
// `thinking` content_part.
|
||||
let scheme = AnthropicScheme::new();
|
||||
let request = Request::new()
|
||||
.user("hi")
|
||||
@@ -976,21 +974,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn reasoning_without_signature_or_encrypted_is_dropped() {
|
||||
// 他 scheme から流入した素の reasoning は Anthropic に投げない。
|
||||
// Do not send plain reasoning from other schemes to Anthropic.
|
||||
let scheme = AnthropicScheme::new();
|
||||
let request = Request::new()
|
||||
.user("hi")
|
||||
.item(Item::reasoning("plain text"))
|
||||
.item(Item::assistant_message("done"));
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
|
||||
// thinking part は 1 つも乗らない
|
||||
// No thinking parts are included.
|
||||
assert!(collect_assistant_thinking_parts(&req).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_part_lands_in_assistant_role_message() {
|
||||
// wire 構造の position 検証: thinking part は assistant role の
|
||||
// message 配列に並ぶ(user role には絶対に入らない)。
|
||||
// Verify wire positions: thinking parts belong in the assistant-role
|
||||
// message array (never in the user role).
|
||||
let scheme = AnthropicScheme::new();
|
||||
let request = Request::new()
|
||||
.user("question?")
|
||||
@@ -1000,7 +998,7 @@ mod tests {
|
||||
.user("follow up");
|
||||
let req = scheme.build_request("claude-sonnet-4-20250514", &request, &cap_explicit());
|
||||
|
||||
// 全 thinking part が assistant role の message に存在すること
|
||||
// All thinking parts must be in assistant-role messages.
|
||||
let mut thinking_msg_indices = Vec::new();
|
||||
for (i, msg) in req.messages.iter().enumerate() {
|
||||
if let AnthropicContent::Parts(parts) = &msg.content {
|
||||
@@ -1023,9 +1021,9 @@ mod tests {
|
||||
req.messages,
|
||||
);
|
||||
|
||||
// thinking part を含む assistant message は、それに続く tool_use を含む
|
||||
// assistant message より前 (= 先頭側) に位置すること
|
||||
// (Anthropic 仕様: 同一論理ターン内で thinking → tool_use の順)
|
||||
// Assistant messages containing thinking parts must precede the
|
||||
// subsequent assistant messages containing tool_use.
|
||||
// (Anthropic requires thinking → tool_use within one logical turn.)
|
||||
let mut tool_use_msg_indices = Vec::new();
|
||||
for (i, msg) in req.messages.iter().enumerate() {
|
||||
if let AnthropicContent::Parts(parts) = &msg.content {
|
||||
@@ -1050,7 +1048,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn redacted_thinking_part_lands_in_assistant_role_message() {
|
||||
// RedactedThinking も同様に assistant role に置かれること。
|
||||
// RedactedThinking must also be placed in the assistant role.
|
||||
let scheme = AnthropicScheme::new();
|
||||
let request = Request::new()
|
||||
.user("ask")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! `impl Scheme for AnthropicScheme`
|
||||
//!
|
||||
//! Anthropic Messages API の wire 表現に必要な URL・ヘッダ・SSE パース・
|
||||
//! リクエスト body 生成を共通 `Scheme` trait にぶら下げる。
|
||||
//! Implements the URLs, headers, SSE parsing, and request body generation needed
|
||||
//! for the Anthropic Messages API wire format through the shared `Scheme` trait.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -17,22 +17,20 @@ use crate::llm_client::{
|
||||
|
||||
use super::AnthropicScheme;
|
||||
|
||||
/// Anthropic の SSE パースで必要な状態。
|
||||
/// State required for Anthropic SSE parsing.
|
||||
///
|
||||
/// 1. `content_block_stop` イベントは `block_type` を持たない仕様なので、
|
||||
/// 直前の `content_block_start` で観測した `block_type` を保持して
|
||||
/// `BlockStop` に書き戻す。
|
||||
/// 2. `thinking` ブロック中の `thinking_delta` テキストと `signature_delta`
|
||||
/// 署名、および `redacted_thinking` ブロックの `data` を蓄積し、
|
||||
/// `content_block_stop` の Thinking block metadata として返す
|
||||
/// (round-trip 永続化のため)。
|
||||
/// 1. `content_block_stop` events lack `block_type`, so retain the type observed
|
||||
/// in the preceding `content_block_start` and write it back into `BlockStop`.
|
||||
/// 2. Accumulate `thinking_delta` text and `signature_delta` signatures from
|
||||
/// `thinking` blocks, plus `data` from `redacted_thinking` blocks. Return them
|
||||
/// as Thinking block metadata at `content_block_stop` for round-trip persistence.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AnthropicState {
|
||||
pub(crate) current_block_type: Option<BlockType>,
|
||||
pub(crate) pending_thinking: Option<PendingThinking>,
|
||||
}
|
||||
|
||||
/// 1 つの `thinking` または `redacted_thinking` content_block の蓄積バッファ。
|
||||
/// Accumulation buffer for one `thinking` or `redacted_thinking` content_block.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct PendingThinking {
|
||||
pub(crate) text: String,
|
||||
@@ -64,11 +62,9 @@ impl Scheme for AnthropicScheme {
|
||||
}
|
||||
|
||||
fn required_auth(&self) -> AuthRequirement {
|
||||
// Ollama の `/v1/messages` 互換では認証が要らないが、それは
|
||||
// `AuthRef::None` + `build_headers` 側の「ResolvedAuth::None
|
||||
// なら何もしない」分岐で吸収する(`accepts` 判定で弾かれない
|
||||
// よう、現状は XApiKey を要求しつつ、None 側でもパスするよう
|
||||
// にする戦略)。
|
||||
// Anthropic uses XApiKey. Compatible endpoints such as Ollama can opt out
|
||||
// through ResolvedAuth::None, which the transport accepts for any auth
|
||||
// requirement and handles without sending authentication headers.
|
||||
AuthRequirement::XApiKey
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
//! Gemini scheme の wire-level 既定 capability。
|
||||
//! Wire-level default capability for the Gemini scheme.
|
||||
//!
|
||||
//! モデル ID 固有のテーブル(`gemini-*` バージョン別の reasoning 有無)は
|
||||
//! client construction layer の責務。ここでは wire の
|
||||
//! 保守的 default のみ。
|
||||
//! Model ID tables (reasoning support by `gemini-*` version) belong to the
|
||||
//! client construction layer. This module provides only conservative wire
|
||||
//! defaults.
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
/// Scheme 既定の capability(未知モデル / 未明示モデル用)。
|
||||
/// Scheme default capability (for unknown or unspecified models).
|
||||
pub(crate) fn default_capability() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Gemini SSEイベントパース
|
||||
//! Gemini SSE event parsing.
|
||||
//!
|
||||
//! Google Gemini APIのSSEイベントをパースし、統一Event型に変換
|
||||
//! Parse Google Gemini API SSE events and convert them to the unified Event type.
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
@@ -11,117 +11,133 @@ use serde::Deserialize;
|
||||
use super::GeminiScheme;
|
||||
|
||||
// ============================================================================
|
||||
// SSEイベントのJSON構造
|
||||
// SSE event JSON structures
|
||||
// ============================================================================
|
||||
|
||||
/// Gemini GenerateContentResponse (ストリーミングチャンク)
|
||||
/// Gemini GenerateContentResponse (streaming chunk).
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GenerateContentResponse {
|
||||
/// 候補
|
||||
/// Candidates.
|
||||
pub candidates: Option<Vec<Candidate>>,
|
||||
/// 使用量メタデータ
|
||||
/// Usage metadata.
|
||||
pub usage_metadata: Option<UsageMetadata>,
|
||||
/// プロンプトフィードバック
|
||||
/// Prompt feedback.
|
||||
pub prompt_feedback: Option<PromptFeedback>,
|
||||
/// モデルバージョン
|
||||
/// Model version.
|
||||
pub model_version: Option<String>,
|
||||
}
|
||||
|
||||
/// 候補
|
||||
/// Candidate.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Candidate {
|
||||
/// コンテンツ
|
||||
/// Content.
|
||||
pub content: Option<CandidateContent>,
|
||||
/// 完了理由
|
||||
/// Finish reason.
|
||||
pub finish_reason: Option<String>,
|
||||
/// インデックス
|
||||
/// Index.
|
||||
pub index: Option<usize>,
|
||||
/// 安全性評価
|
||||
/// Safety rating.
|
||||
pub safety_ratings: Option<Vec<SafetyRating>>,
|
||||
}
|
||||
|
||||
/// 候補コンテンツ
|
||||
/// Candidate content.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct CandidateContent {
|
||||
/// パーツ
|
||||
/// Parts.
|
||||
pub parts: Option<Vec<CandidatePart>>,
|
||||
/// ロール
|
||||
/// Role.
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
/// 候補パーツ
|
||||
/// Candidate part.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CandidatePart {
|
||||
/// テキスト
|
||||
/// Text.
|
||||
pub text: Option<String>,
|
||||
/// 関数呼び出し
|
||||
/// Function call.
|
||||
pub function_call: Option<FunctionCall>,
|
||||
}
|
||||
|
||||
/// 関数呼び出し
|
||||
/// Function call.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct FunctionCall {
|
||||
/// 関数名
|
||||
/// Function name.
|
||||
pub name: String,
|
||||
/// 引数
|
||||
/// Arguments.
|
||||
pub args: Option<serde_json::Value>,
|
||||
/// Partial-argument protocol is not a complete args object.
|
||||
pub partial_args: Option<serde_json::Value>,
|
||||
pub will_continue: Option<bool>,
|
||||
}
|
||||
|
||||
/// 使用量メタデータ
|
||||
/// Usage metadata.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UsageMetadata {
|
||||
/// プロンプトトークン数
|
||||
/// Prompt token count.
|
||||
pub prompt_token_count: Option<u64>,
|
||||
/// 候補トークン数
|
||||
/// Candidate token count.
|
||||
pub candidates_token_count: Option<u64>,
|
||||
/// 合計トークン数
|
||||
/// Total token count.
|
||||
pub total_token_count: Option<u64>,
|
||||
}
|
||||
|
||||
/// プロンプトフィードバック
|
||||
/// Prompt feedback.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PromptFeedback {
|
||||
/// ブロック理由
|
||||
/// Block reason.
|
||||
pub block_reason: Option<String>,
|
||||
/// 安全性評価
|
||||
/// Safety rating.
|
||||
pub safety_ratings: Option<Vec<SafetyRating>>,
|
||||
}
|
||||
|
||||
/// 安全性評価
|
||||
/// Safety rating.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct SafetyRating {
|
||||
/// カテゴリ
|
||||
/// Category.
|
||||
pub category: Option<String>,
|
||||
/// 確率
|
||||
/// Probability.
|
||||
pub probability: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// イベント変換
|
||||
// Event conversion
|
||||
// ============================================================================
|
||||
|
||||
/// Response-local Gemini block allocation. Parts in separate SSE frames may
|
||||
/// restart their array index, and function names are not call identities.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct GeminiStreamState {
|
||||
next_index: usize,
|
||||
last_text_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl GeminiScheme {
|
||||
/// SSEデータをEvent型に変換
|
||||
/// Convert SSE data to Event.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - SSEイベントデータJSON文字列
|
||||
/// * `data` - SSE event data as a JSON string.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Some(Vec<Event>))` - 変換成功
|
||||
/// * `Ok(None)` - イベントを無視
|
||||
/// * `Err(ClientError)` - パースエラー
|
||||
pub(crate) fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
|
||||
// データが空または無効な場合はスキップ
|
||||
/// * `Ok(Some(Vec<Event>))` - Conversion succeeded.
|
||||
/// * `Ok(None)` - Event ignored.
|
||||
/// * `Err(ClientError)` - Parse error.
|
||||
pub(crate) fn parse_event(
|
||||
&self,
|
||||
data: &str,
|
||||
state: &mut GeminiStreamState,
|
||||
) -> Result<Option<Vec<Event>>, ClientError> {
|
||||
// Skip empty or invalid data.
|
||||
if data.is_empty() || data == "[DONE]" {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -136,39 +152,47 @@ impl GeminiScheme {
|
||||
|
||||
let mut events = Vec::new();
|
||||
|
||||
// 使用量メタデータ
|
||||
// Usage metadata.
|
||||
if let Some(usage) = response.usage_metadata {
|
||||
events.push(self.convert_usage(&usage));
|
||||
}
|
||||
|
||||
// 候補を処理
|
||||
// Process candidates.
|
||||
if let Some(candidates) = response.candidates {
|
||||
for candidate in candidates {
|
||||
let candidate_index = candidate.index.unwrap_or(0);
|
||||
|
||||
if let Some(content) = candidate.content {
|
||||
if let Some(parts) = content.parts {
|
||||
for (part_index, part) in parts.iter().enumerate() {
|
||||
// テキストデルタ
|
||||
for part in &parts {
|
||||
let index = state.next_index;
|
||||
state.next_index += 1;
|
||||
// Text delta.
|
||||
if let Some(text) = &part.text {
|
||||
if !text.is_empty() {
|
||||
// Geminiは明示的なBlockStartを送らないため、
|
||||
// TextDeltaを直接送る(Timelineが暗黙的に開始を処理)
|
||||
events.push(Event::text_delta(part_index, text.clone()));
|
||||
// Gemini sends no explicit BlockStart, so emit TextDelta
|
||||
// directly (Timeline handles the implicit start).
|
||||
state.last_text_index = Some(index);
|
||||
events.push(Event::text_delta(index, text.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// 関数呼び出し
|
||||
// Function call.
|
||||
if let Some(function_call) = &part.function_call {
|
||||
// 関数呼び出しの開始
|
||||
// Geminiでは関数呼び出しは一度に送られることが多い
|
||||
// ストリーミング引数が有効な場合は部分的に送られる可能性がある
|
||||
|
||||
// 関数呼び出しIDはGeminiにはないので、名前をIDとして使用
|
||||
let function_id = format!("call_{}", function_call.name);
|
||||
if function_call.partial_args.is_some()
|
||||
|| function_call.will_continue.is_some()
|
||||
{
|
||||
return Err(ClientError::Api {
|
||||
status: None,
|
||||
code: Some("unsupported_partial_function_call".into()),
|
||||
message: "Gemini partial function arguments require an unsupported streaming protocol".into(),
|
||||
retry_after: None,
|
||||
});
|
||||
}
|
||||
// These synthetic IDs correlate calls within a response;
|
||||
// durable identity still comes from Host history entries.
|
||||
let function_id = format!("call_{index}");
|
||||
|
||||
events.push(Event::BlockStart(BlockStart {
|
||||
index: candidate_index * 10 + part_index, // 複合インデックス
|
||||
index,
|
||||
block_type: BlockType::ToolUse,
|
||||
metadata: BlockMetadata::ToolUse {
|
||||
id: function_id,
|
||||
@@ -176,22 +200,23 @@ impl GeminiScheme {
|
||||
},
|
||||
}));
|
||||
|
||||
// 引数がある場合はデルタとして送る
|
||||
// Send arguments as a delta if present.
|
||||
if let Some(args) = &function_call.args {
|
||||
let args_str = serde_json::to_string(args).unwrap_or_default();
|
||||
if !args_str.is_empty() && args_str != "null" {
|
||||
events.push(Event::tool_input_delta(
|
||||
candidate_index * 10 + part_index,
|
||||
args_str,
|
||||
));
|
||||
events.push(Event::tool_input_delta(index, args_str));
|
||||
}
|
||||
}
|
||||
// Each args object is complete in this wire part. Close
|
||||
// its collector before the next function call starts;
|
||||
// dispatch still waits for the response-complete gate.
|
||||
events.push(Event::tool_use_stop(index));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 完了理由
|
||||
// Finish reason.
|
||||
if let Some(finish_reason) = candidate.finish_reason {
|
||||
let stop_reason = match finish_reason.as_str() {
|
||||
"STOP" => Some(StopReason::EndTurn),
|
||||
@@ -200,13 +225,14 @@ impl GeminiScheme {
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// テキストブロックの停止
|
||||
events.push(Event::BlockStop(BlockStop {
|
||||
index: candidate_index,
|
||||
block_type: BlockType::Text,
|
||||
stop_reason,
|
||||
reasoning: None,
|
||||
}));
|
||||
if let Some(index) = state.last_text_index.take() {
|
||||
events.push(Event::BlockStop(BlockStop {
|
||||
index,
|
||||
block_type: BlockType::Text,
|
||||
stop_reason,
|
||||
reasoning: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,7 +266,10 @@ mod tests {
|
||||
let data =
|
||||
r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},"index":0}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
let events = scheme
|
||||
.parse_event(data, &mut GeminiStreamState::default())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
if let Event::BlockDelta(delta) = &events[0] {
|
||||
@@ -260,9 +289,12 @@ mod tests {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
let events = scheme
|
||||
.parse_event(data, &mut GeminiStreamState::default())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// Usageイベントが含まれるはず
|
||||
// Should contain a Usage event.
|
||||
let usage_event = events.iter().find(|e| matches!(e, Event::Usage(_)));
|
||||
assert!(usage_event.is_some());
|
||||
|
||||
@@ -278,9 +310,12 @@ mod tests {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data = r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"location":"Tokyo"}}}],"role":"model"},"index":0}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
let events = scheme
|
||||
.parse_event(data, &mut GeminiStreamState::default())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// BlockStartイベントがあるはず
|
||||
// Should contain a BlockStart event.
|
||||
let start_event = events.iter().find(|e| matches!(e, Event::BlockStart(_)));
|
||||
assert!(start_event.is_some());
|
||||
|
||||
@@ -293,7 +328,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// 引数デルタもあるはず
|
||||
// Should also contain an argument delta.
|
||||
let delta_event = events.iter().find(|e| {
|
||||
if let Event::BlockDelta(d) = e {
|
||||
matches!(d.delta, DeltaContent::InputJson(_))
|
||||
@@ -309,9 +344,12 @@ mod tests {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Done"}],"role":"model"},"finishReason":"STOP","index":0}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
let events = scheme
|
||||
.parse_event(data, &mut GeminiStreamState::default())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// BlockStopイベントがあるはず
|
||||
// Should contain a BlockStop event.
|
||||
let stop_event = events.iter().find(|e| matches!(e, Event::BlockStop(_)));
|
||||
assert!(stop_event.is_some());
|
||||
|
||||
@@ -320,10 +358,85 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_name_calls_have_distinct_ids_and_block_indexes_across_parts_and_frames() {
|
||||
use crate::llm_client::scheme::Scheme;
|
||||
let scheme = GeminiScheme::new();
|
||||
for frames in [
|
||||
vec![
|
||||
r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"echo","args":{"text":"first"}}},{"functionCall":{"name":"echo","args":{"text":"second"}}}]}}]}"#,
|
||||
],
|
||||
vec![
|
||||
r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"echo","args":{"text":"first"}}}]}}]}"#,
|
||||
r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"echo","args":{"text":"second"}}}]}}]}"#,
|
||||
],
|
||||
] {
|
||||
let mut state = Default::default();
|
||||
let mut events = Vec::new();
|
||||
for frame in frames {
|
||||
events.extend(scheme.parse_sse("", frame, &mut state).unwrap());
|
||||
}
|
||||
let calls: Vec<_> = events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
Event::BlockStart(BlockStart {
|
||||
index,
|
||||
metadata: BlockMetadata::ToolUse { id, name },
|
||||
..
|
||||
}) => Some((*index, id.as_str(), name.as_str())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(calls, [(0, "call_0", "echo"), (1, "call_1", "echo")]);
|
||||
let inputs: Vec<_> = events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
Event::BlockDelta(delta) => match &delta.delta {
|
||||
DeltaContent::InputJson(json) => Some((delta.index, json.as_str())),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
inputs,
|
||||
[(0, r#"{"text":"first"}"#), (1, r#"{"text":"second"}"#)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_argument_protocol_is_not_misread_as_an_empty_complete_call() {
|
||||
use crate::llm_client::scheme::Scheme;
|
||||
let scheme = GeminiScheme::new();
|
||||
for call in [
|
||||
r#"{"name":"echo","partialArgs":[{"jsonPath":"$.text","stringValue":"part"}]}"#,
|
||||
r#"{"name":"echo","willContinue":true,"args":{"text":"part"}}"#,
|
||||
r#"{"name":"echo","willContinue":false}"#,
|
||||
] {
|
||||
let data = format!(
|
||||
r#"{{"candidates":[{{"content":{{"parts":[{{"functionCall":{call}}}]}}}}]}}"#
|
||||
);
|
||||
assert!(
|
||||
matches!(scheme.parse_sse("", &data, &mut Default::default()), Err(ClientError::Api { code: Some(code), .. }) if code == "unsupported_partial_function_call")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_data() {
|
||||
let scheme = GeminiScheme::new();
|
||||
assert!(scheme.parse_event("").unwrap().is_none());
|
||||
assert!(scheme.parse_event("[DONE]").unwrap().is_none());
|
||||
assert!(
|
||||
scheme
|
||||
.parse_event("", &mut GeminiStreamState::default())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
scheme
|
||||
.parse_event("[DONE]", &mut GeminiStreamState::default())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
//! Google Gemini API スキーマ
|
||||
//! Google Gemini API scheme.
|
||||
//!
|
||||
//! - リクエストJSON生成
|
||||
//! - SSEイベントパース → Event変換
|
||||
//! - Request JSON generation.
|
||||
//! - SSE event parsing → Event conversion.
|
||||
|
||||
mod capability;
|
||||
mod events;
|
||||
mod request;
|
||||
mod scheme_impl;
|
||||
|
||||
/// Geminiスキーマ
|
||||
/// Gemini scheme.
|
||||
///
|
||||
/// Google Gemini APIのリクエスト/レスポンス変換を担当
|
||||
/// Handles request/response conversion for the Google Gemini API.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GeminiScheme {
|
||||
/// ストリーミング関数呼び出し引数を有効にするか
|
||||
/// Whether to enable streaming function call arguments.
|
||||
pub stream_function_call_arguments: bool,
|
||||
}
|
||||
|
||||
impl GeminiScheme {
|
||||
/// 新しいスキーマを作成
|
||||
/// Create a new scheme.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// ストリーミング関数呼び出し引数を有効/無効にする
|
||||
/// Enable or disable streaming function call arguments.
|
||||
pub fn with_stream_function_call_arguments(mut self, enabled: bool) -> Self {
|
||||
self.stream_function_call_arguments = enabled;
|
||||
self
|
||||
|
||||
@@ -140,12 +140,12 @@ pub(crate) struct GeminiGenerationConfig {
|
||||
/// Stop sequences
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub stop_sequences: Vec<String>,
|
||||
/// Thinking / reasoning 設定(Gemini 2.5 以降)。
|
||||
/// Thinking / reasoning settings (Gemini 2.5 and later).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thinking_config: Option<GeminiThinkingConfig>,
|
||||
}
|
||||
|
||||
/// Gemini thinking config (gemini-2.5 以降)
|
||||
/// Gemini thinking config (gemini-2.5 and later).
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GeminiThinkingConfig {
|
||||
@@ -193,8 +193,8 @@ impl GeminiScheme {
|
||||
None
|
||||
};
|
||||
|
||||
// Reasoning の投影: capability が BudgetTokens / Both をサポートし、
|
||||
// request 側で budget_tokens が指定されているときだけ thinking_config を付ける。
|
||||
// Project reasoning: add thinking_config only when the capability
|
||||
// supports BudgetTokens / Both and the request specifies budget_tokens.
|
||||
let supports_budget = matches!(
|
||||
capability.reasoning,
|
||||
Some(ReasoningSupport::BudgetTokens | ReasoningSupport::Both),
|
||||
@@ -239,6 +239,8 @@ impl GeminiScheme {
|
||||
let mut contents = Vec::new();
|
||||
let mut pending_model_parts: Vec<GeminiPart> = Vec::new();
|
||||
let mut pending_user_parts: Vec<GeminiPart> = Vec::new();
|
||||
let mut call_names: std::collections::HashMap<&str, std::collections::VecDeque<&str>> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for item in items {
|
||||
match item {
|
||||
@@ -270,8 +272,12 @@ impl GeminiScheme {
|
||||
}
|
||||
|
||||
Item::ToolCall {
|
||||
name, arguments, ..
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
..
|
||||
} => {
|
||||
call_names.entry(call_id).or_default().push_back(name);
|
||||
// Flush pending user parts first
|
||||
if !pending_user_parts.is_empty() {
|
||||
contents.push(GeminiContent {
|
||||
@@ -309,11 +315,17 @@ impl GeminiScheme {
|
||||
Some(c) => format!("{summary}\n{c}"),
|
||||
None => summary.clone(),
|
||||
};
|
||||
// Gemini responses identify the function by its declaration
|
||||
// name, never by the adapter's response-local correlation ID.
|
||||
let name = call_names
|
||||
.get_mut(call_id.as_str())
|
||||
.and_then(std::collections::VecDeque::pop_front)
|
||||
.unwrap_or(call_id.as_str());
|
||||
pending_user_parts.push(GeminiPart::FunctionResponse {
|
||||
function_response: GeminiFunctionResponse {
|
||||
name: call_id.clone(),
|
||||
name: name.to_string(),
|
||||
response: GeminiFunctionResponseContent {
|
||||
name: call_id.clone(),
|
||||
name: name.to_string(),
|
||||
content: Value::String(text),
|
||||
},
|
||||
},
|
||||
@@ -468,6 +480,36 @@ mod tests {
|
||||
assert_eq!(gemini_req.contents[0].role, "user");
|
||||
assert_eq!(gemini_req.contents[1].role, "model");
|
||||
assert_eq!(gemini_req.contents[2].role, "user");
|
||||
let body = serde_json::to_value(gemini_req).unwrap();
|
||||
assert_eq!(
|
||||
body["contents"][2]["parts"][0]["functionResponse"]["name"],
|
||||
"get_weather"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_results_resolve_function_names_not_synthetic_call_ids() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let request = Request::new()
|
||||
.item(Item::tool_call("call_0", "echo", r#"{"text":"first"}"#))
|
||||
.item(Item::tool_call("call_1", "echo", r#"{"text":"second"}"#))
|
||||
.item(Item::tool_result("call_1", "second"))
|
||||
.item(Item::tool_result("call_0", "first"))
|
||||
.item(Item::tool_call("call_0", "other", "{}"))
|
||||
.item(Item::tool_result("call_0", "later"));
|
||||
let body = serde_json::to_value(scheme.build_request(&request, &cap())).unwrap();
|
||||
let responses: Vec<_> = body["contents"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.flat_map(|content| content["parts"].as_array().unwrap())
|
||||
.filter_map(|part| part.get("functionResponse"))
|
||||
.collect();
|
||||
assert_eq!(responses.len(), 3);
|
||||
for (response, name) in responses.iter().zip(["echo", "echo", "other"]) {
|
||||
assert_eq!(response["name"], name);
|
||||
assert_eq!(response["response"]["name"], name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::llm_client::{
|
||||
use super::GeminiScheme;
|
||||
|
||||
impl Scheme for GeminiScheme {
|
||||
type State = ();
|
||||
type State = super::events::GeminiStreamState;
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
"https://generativelanguage.googleapis.com"
|
||||
@@ -38,9 +38,9 @@ impl Scheme for GeminiScheme {
|
||||
&self,
|
||||
_event_type: &str,
|
||||
data: &str,
|
||||
_state: &mut Self::State,
|
||||
state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
Ok(self.parse_event(data)?.unwrap_or_default())
|
||||
Ok(self.parse_event(data, state)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! APIスキーマ定義
|
||||
//! API scheme definitions.
|
||||
//!
|
||||
//! 各APIスキーマごとの変換ロジック
|
||||
//! - リクエスト変換: Request → プロバイダ固有JSON
|
||||
//! - レスポンス変換: SSEイベント → Event
|
||||
//! Conversion logic for each API scheme:
|
||||
//! - Request conversion: Request → provider-specific JSON.
|
||||
//! - Response conversion: SSE events → Event.
|
||||
//!
|
||||
//! [`Scheme`] trait により `HttpTransport<S>` から scheme 固有の差分
|
||||
//! (パス、ヘッダ、認証要件、body 生成、SSE パース)をすべて委譲する。
|
||||
//! The [`Scheme`] trait delegates all scheme-specific differences from
|
||||
//! `HttpTransport<S>` (paths, headers, authentication requirements, body
|
||||
//! generation, and SSE parsing).
|
||||
|
||||
pub mod anthropic;
|
||||
pub mod gemini;
|
||||
@@ -21,42 +22,40 @@ use super::error::ClientError;
|
||||
use super::event::Event;
|
||||
use super::types::{Request, RequestConfig};
|
||||
|
||||
/// wire scheme の抽象。各プロバイダの API 仕様ごとに 1 つ実装する。
|
||||
/// Wire scheme abstraction, implemented once per provider API specification.
|
||||
///
|
||||
/// `HttpTransport<S: Scheme>` が URL 組立・認証ヘッダ挿入・SSE パース
|
||||
/// のループを担い、`Scheme` 実装は各仕様固有の差分のみ提供する。
|
||||
/// `HttpTransport<S: Scheme>` handles URL assembly, authentication header
|
||||
/// insertion, and the SSE parsing loop; `Scheme` implementations supply only
|
||||
/// specification-specific differences.
|
||||
///
|
||||
/// # 状態
|
||||
/// # State
|
||||
///
|
||||
/// SSE パースでフレーム間に状態を保つ必要がある scheme(Anthropic の
|
||||
/// `BlockStop` に `block_type` が載らない仕様の補完など)は
|
||||
/// [`Scheme::State`] に中間状態を表す型を置く。
|
||||
/// 状態を持たない scheme は `type State = ()` とする。
|
||||
/// Schemes requiring state across SSE frames (e.g. to compensate for Anthropic
|
||||
/// omitting `block_type` from `BlockStop`) define an intermediate state type in
|
||||
/// [`Scheme::State`]. Stateless schemes use `type State = ()`.
|
||||
pub trait Scheme: Clone + Send + Sync + 'static {
|
||||
/// SSE パースのフレーム間で共有する状態。`HttpTransport` が
|
||||
/// ストリーム開始時に `Default::default()` を一度だけ作り、
|
||||
/// フレームごとに `&mut` で渡す。
|
||||
/// State shared across SSE frames. `HttpTransport` creates it once via
|
||||
/// `Default::default()` at stream start and passes it by `&mut` for each frame.
|
||||
type State: Default + Send + 'static;
|
||||
|
||||
/// scheme のベース URL(`ModelConfig::base_url` 未指定時のデフォルト)
|
||||
/// Default base URL for callers that do not supply an override.
|
||||
fn default_base_url(&self) -> &'static str;
|
||||
|
||||
/// リクエスト先の相対パス。Gemini のようにモデル名をパスに埋め込む
|
||||
/// プロバイダもあるため、モデル ID を受け取る。
|
||||
/// Relative request path. Accepts a model ID because some providers, such as
|
||||
/// Gemini, embed the model name in the path.
|
||||
fn path(&self, model_id: &str) -> String;
|
||||
|
||||
/// この scheme が要求する認証形式。呼び出し側は client 構築時に
|
||||
/// 設定された認証情報と照合する。
|
||||
/// Authentication format required by this scheme. The caller checks it
|
||||
/// against configured credentials when constructing the client.
|
||||
fn required_auth(&self) -> AuthRequirement;
|
||||
|
||||
/// `Content-Type` 以外の追加ヘッダ。`anthropic-version` / `anthropic-beta` 等。
|
||||
/// Additional headers beyond `Content-Type`, e.g. `anthropic-version` / `anthropic-beta`.
|
||||
fn additional_headers(&self) -> Vec<(&'static str, String)> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// リクエスト body を生成する。`capability` は `CacheStrategy` や
|
||||
/// `ReasoningSupport` を参照して scheme 側の挙動を分岐させるため
|
||||
/// に渡される。
|
||||
/// Build the request body. `capability` lets the scheme select behavior
|
||||
/// based on `CacheStrategy` and `ReasoningSupport`.
|
||||
fn build_request_body(
|
||||
&self,
|
||||
model_id: &str,
|
||||
@@ -64,11 +63,11 @@ pub trait Scheme: Clone + Send + Sync + 'static {
|
||||
capability: &ModelCapability,
|
||||
) -> Value;
|
||||
|
||||
/// SSE イベント 1 件を 0 個以上の [`Event`] に変換する。
|
||||
/// Convert one SSE event to zero or more [`Event`]s.
|
||||
///
|
||||
/// `event_type` は SSE フレームの `event:` フィールド、`data` は
|
||||
/// `data:` フィールド。`[DONE]` 等の終端マーカーは実装側で判定する。
|
||||
/// `state` はストリーム単位で共有される可変状態。
|
||||
/// `event_type` is the SSE frame's `event:` field; `data` is its `data:` field.
|
||||
/// Implementations detect terminal markers such as `[DONE]`.
|
||||
/// `state` is mutable state shared for the stream.
|
||||
fn parse_sse(
|
||||
&self,
|
||||
event_type: &str,
|
||||
@@ -76,9 +75,9 @@ pub trait Scheme: Clone + Send + Sync + 'static {
|
||||
state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError>;
|
||||
|
||||
/// scheme 既定の capability。モデル ID に関係なく、この wire で
|
||||
/// 安全に送れる最小共通項を返す。既知モデル ID の能力テーブルは
|
||||
/// 高レベルの client 構築層の責務で、scheme はここには関与しない。
|
||||
/// Scheme default capability: the minimum common features safe to send over
|
||||
/// this wire, regardless of model ID. Known-model capability tables belong
|
||||
/// to the higher-level client construction layer, not the scheme.
|
||||
fn default_capability(&self) -> ModelCapability;
|
||||
|
||||
/// Strongest safe completion boundary for streamed tool calls.
|
||||
@@ -86,9 +85,8 @@ pub trait Scheme: Clone + Send + Sync + 'static {
|
||||
ToolCallCompletionSupport::ResponseComplete
|
||||
}
|
||||
|
||||
/// scheme 側でサポートしていない `RequestConfig` フィールドを
|
||||
/// 警告として返す(例: OpenAI Chat は `top_k` 非対応)。
|
||||
/// デフォルトは空 Vec。
|
||||
/// Return warnings for `RequestConfig` fields unsupported by the scheme
|
||||
/// (e.g. OpenAI Chat does not support `top_k`). Defaults to an empty Vec.
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
let _ = config;
|
||||
Vec::new()
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
//! OpenAI Chat Completions scheme の wire-level 既定 capability。
|
||||
//! Wire-level default capability for the OpenAI Chat Completions scheme.
|
||||
//!
|
||||
//! モデル ID 固有のテーブル(`gpt-5` 系など)は client construction layer
|
||||
//! の責務。ここでは wire の保守的 default のみ。
|
||||
//! Model ID tables (e.g. the `gpt-5` family) belong to the client construction
|
||||
//! layer. This module provides only conservative wire defaults.
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
/// Scheme 既定の capability。OpenAI 互換ルーター系(xAI / Groq / OpenRouter 等)
|
||||
/// で未知モデル ID を受けたときのフォールバックに使う。
|
||||
/// Scheme default capability, used as a fallback for unknown model IDs on
|
||||
/// OpenAI-compatible routers (xAI / Groq / OpenRouter, etc.).
|
||||
pub(crate) fn default_capability() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! OpenAI SSEイベントパース
|
||||
//! OpenAI SSE event parsing.
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
@@ -61,10 +61,10 @@ pub(crate) struct ChunkUsage {
|
||||
}
|
||||
|
||||
impl OpenAIScheme {
|
||||
/// SSEデータのパースとEventへの変換
|
||||
/// Parse SSE data and convert it to Event.
|
||||
///
|
||||
/// OpenAI APIはBlockStartイベントを明示的に送信しない。
|
||||
/// Timeline層が暗黙的なBlockStartを処理する。
|
||||
/// The OpenAI API does not explicitly send BlockStart events.
|
||||
/// The Timeline layer handles implicit BlockStart.
|
||||
pub fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
|
||||
if data == "[DONE]" {
|
||||
return Ok(None);
|
||||
@@ -94,8 +94,8 @@ impl OpenAIScheme {
|
||||
for choice in chunk.choices {
|
||||
// Text Content Delta
|
||||
if let Some(content) = choice.delta.content {
|
||||
// OpenAI APIはBlockStartを送らないため、デルタのみを発行
|
||||
// Timeline層が暗黙的なBlockStartを処理する
|
||||
// Emit only deltas because the OpenAI API sends no BlockStart.
|
||||
// The Timeline layer handles implicit BlockStart.
|
||||
events.push(Event::text_delta(choice.index, content));
|
||||
}
|
||||
|
||||
@@ -136,11 +136,11 @@ impl OpenAIScheme {
|
||||
finish_reason == "tool_calls" || finish_reason == "function_call";
|
||||
|
||||
if is_tool_finish {
|
||||
// ツール呼び出し終了
|
||||
// Note: OpenAIはどのツールが終了したか明示しないため、
|
||||
// Timeline層で適切に処理する必要がある
|
||||
// Tool call ended.
|
||||
// Note: OpenAI does not specify which tool ended, so the
|
||||
// Timeline layer must handle this appropriately.
|
||||
} else {
|
||||
// テキスト終了
|
||||
// Text ended.
|
||||
events.push(Event::text_block_stop(choice.index, stop_reason));
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,7 @@ mod tests {
|
||||
let data = r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
// OpenAIはBlockStartを発行しないため、デルタのみ
|
||||
// Deltas only, since OpenAI does not emit BlockStart.
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
if let Event::BlockDelta(delta) = &events[0] {
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
//! OpenAI Chat Completions API スキーマ
|
||||
//! OpenAI Chat Completions API scheme.
|
||||
//!
|
||||
//! - リクエストJSON生成
|
||||
//! - SSEイベントパース → Event変換
|
||||
//! - Request JSON generation.
|
||||
//! - SSE event parsing → Event conversion.
|
||||
|
||||
pub(crate) mod capability;
|
||||
mod events;
|
||||
mod request;
|
||||
mod scheme_impl;
|
||||
|
||||
/// OpenAIスキーマ
|
||||
/// OpenAI scheme.
|
||||
///
|
||||
/// OpenAI Chat Completions API (および互換API) のリクエスト/レスポンス変換を担当
|
||||
/// Handles request/response conversion for the OpenAI Chat Completions API
|
||||
/// (and compatible APIs).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct OpenAIScheme {
|
||||
/// モデル名 (リクエスト時に指定されるが、デフォルト値として保持も可能)
|
||||
/// Model name (supplied per request, but may also be stored as a default).
|
||||
pub model: Option<String>,
|
||||
/// レガシーなmax_tokensを使用するか (Ollama互換用)
|
||||
/// Whether to use legacy max_tokens (for Ollama compatibility).
|
||||
pub use_legacy_max_tokens: bool,
|
||||
}
|
||||
|
||||
impl OpenAIScheme {
|
||||
/// 新しいスキーマを作成
|
||||
/// Create a new scheme.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// レガシーなmax_tokensを使用するか設定
|
||||
/// Set whether to use legacy max_tokens.
|
||||
pub fn with_legacy_max_tokens(mut self, use_legacy: bool) -> Self {
|
||||
self.use_legacy_max_tokens = use_legacy;
|
||||
self
|
||||
|
||||
@@ -38,7 +38,7 @@ pub(crate) struct OpenAIRequest {
|
||||
pub tools: Vec<OpenAITool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<String>,
|
||||
/// Reasoning effort(o1 / o3 / o4 / gpt-5 系で有効)。
|
||||
/// Reasoning effort (supported by the o1 / o3 / o4 / gpt-5 families).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_effort: Option<String>,
|
||||
}
|
||||
@@ -147,8 +147,8 @@ impl OpenAIScheme {
|
||||
(None, request.config.max_tokens)
|
||||
};
|
||||
|
||||
// Reasoning の投影: capability が Effort / Both をサポートし、
|
||||
// request 側で effort が指定されているときだけ reasoning_effort を付ける。
|
||||
// Project reasoning: add reasoning_effort only when the capability
|
||||
// supports Effort / Both and the request specifies an effort.
|
||||
let supports_effort = matches!(
|
||||
capability.reasoning,
|
||||
Some(ReasoningSupport::Effort | ReasoningSupport::Both),
|
||||
|
||||
@@ -45,7 +45,7 @@ impl Scheme for OpenAIScheme {
|
||||
data: &str,
|
||||
_state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
// `data: [DONE]` は終端マーカー
|
||||
// `data: [DONE]` is a terminal marker.
|
||||
if data.trim() == "[DONE]" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -58,7 +58,7 @@ impl Scheme for OpenAIScheme {
|
||||
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
let mut warnings = Vec::new();
|
||||
// OpenAI Chat Completions API は top_k を受け付けない
|
||||
// The OpenAI Chat Completions API does not accept top_k.
|
||||
if config.top_k.is_some() {
|
||||
warnings.push(ConfigWarning::unsupported("top_k", "OpenAI Chat"));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! OpenAI Responses scheme の wire-level 既定 capability。
|
||||
//! Wire-level default capability for the OpenAI Responses scheme.
|
||||
//!
|
||||
//! モデル ID 固有の能力テーブルは高レベルの client 構築層の責務。
|
||||
//! ここでは wire の保守的 default のみ。
|
||||
//! Model ID capability tables belong to the higher-level client construction
|
||||
//! layer. This module provides only conservative wire defaults.
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! OpenAI Responses API の SSE イベントパース
|
||||
//! OpenAI Responses API SSE event parsing.
|
||||
//!
|
||||
//! `response.*` 名前空間の SSE を共通の [`Event`](crate::llm_client::event::Event)
|
||||
//! に変換する。Responses の (output_index, content_index) 2 次元座標と
|
||||
//! この crate の 1 次元 `BlockStart/Delta/Stop::index` のマッピングは
|
||||
//! [`OpenAIResponsesState`] が保持する。
|
||||
//! Converts SSE events in the `response.*` namespace to shared [`Event`]s.
|
||||
//! [`OpenAIResponsesState`] holds the mapping from Responses' two-dimensional
|
||||
//! (output_index, content_index) coordinates to this crate's one-dimensional
|
||||
//! `BlockStart/Delta/Stop::index`.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
@@ -18,15 +18,16 @@ use crate::llm_client::{
|
||||
},
|
||||
};
|
||||
|
||||
/// SSE パース中の座標 → flat block index マップ。
|
||||
/// Coordinate → flat block index map used during SSE parsing.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OpenAIResponsesState {
|
||||
slots: HashMap<SlotKey, SlotInfo>,
|
||||
next_index: usize,
|
||||
/// 蓄積中の reasoning output_item。`output_item.added`(Reasoning) で
|
||||
/// 確保し、`reasoning_text.delta` / `reasoning_summary_text.delta` で
|
||||
/// 蓄積、`output_item.done`(Reasoning) で既存 reasoning_text block または
|
||||
/// metadata-only Thinking block に reasoning persistence material を載せる。
|
||||
/// Reasoning output_items being accumulated. Allocated at
|
||||
/// `output_item.added` (Reasoning), filled by `reasoning_text.delta` /
|
||||
/// `reasoning_summary_text.delta`, then attached as reasoning persistence
|
||||
/// material to an existing reasoning_text block or metadata-only Thinking
|
||||
/// block at `output_item.done` (Reasoning).
|
||||
pending_reasoning: HashMap<usize, PendingReasoning>,
|
||||
/// Tool input deltas and authoritative final values, keyed by output index.
|
||||
pending_tool_inputs: HashMap<usize, PendingToolInput>,
|
||||
@@ -67,19 +68,19 @@ impl ToolInputKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// 1 つの reasoning output_item の蓄積バッファ。
|
||||
/// Accumulation buffer for one reasoning output_item.
|
||||
#[derive(Debug, Default)]
|
||||
struct PendingReasoning {
|
||||
id: Option<String>,
|
||||
/// `reasoning_text.delta` の累積。複数 content_part あれば順に concat。
|
||||
/// Accumulated `reasoning_text.delta`, concatenating multiple content_parts in order.
|
||||
text: String,
|
||||
/// `reasoning_summary_text.delta` を summary_index 順に蓄積。
|
||||
/// Accumulates `reasoning_summary_text.delta` in summary_index order.
|
||||
summary: Vec<String>,
|
||||
/// `response.content_part.done` が先に到着した reasoning/thinking block。
|
||||
/// Reasoning/thinking blocks whose `response.content_part.done` arrived first.
|
||||
///
|
||||
/// `response.output_item.done` まで待たないと encrypted_content や最終
|
||||
/// summary が揃わないため、live-visible な余分な synthetic Thinking block
|
||||
/// を作らず、既存 block の stop に persistence metadata を載せる。
|
||||
/// encrypted_content and final summaries are not complete until
|
||||
/// `response.output_item.done`. Attach persistence metadata to the existing
|
||||
/// block's stop rather than creating an extra live-visible synthetic Thinking block.
|
||||
deferred_thinking_stops: Vec<SlotInfo>,
|
||||
}
|
||||
|
||||
@@ -106,9 +107,9 @@ impl OpenAIResponsesState {
|
||||
info
|
||||
}
|
||||
|
||||
/// 既存 slot を取得。無ければ `block_type` で暗黙に確保し、
|
||||
/// 新規確保したかを併せて返す。delta 先行 / content_part.added が
|
||||
/// 抜けたときの防御。
|
||||
/// Get an existing slot or implicitly allocate one with `block_type`, also
|
||||
/// returning whether it was newly allocated. Defends against deltas arriving
|
||||
/// first or missing content_part.added events.
|
||||
fn get_or_allocate(&mut self, key: SlotKey, block_type: BlockType) -> (SlotInfo, bool) {
|
||||
if let Some(info) = self.slots.get(&key).copied() {
|
||||
(info, false)
|
||||
@@ -157,11 +158,11 @@ impl OpenAIResponsesState {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum SlotKey {
|
||||
/// tool_use (function_call / custom_tool_call) — output_item 全体で 1 block
|
||||
/// tool_use (function_call / custom_tool_call) — one block for the entire output_item.
|
||||
OutputItem(usize),
|
||||
/// message の output_text / reasoning item の reasoning_text
|
||||
/// Message output_text / reasoning item reasoning_text.
|
||||
ContentPart { output: usize, content: usize },
|
||||
/// reasoning item の summary_text (summary_index)
|
||||
/// Reasoning item summary_text (summary_index).
|
||||
Summary { output: usize, summary: usize },
|
||||
}
|
||||
|
||||
@@ -172,7 +173,7 @@ struct SlotInfo {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSE イベントの JSON 構造
|
||||
// SSE event JSON structures
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -188,7 +189,7 @@ struct OutputItemDone {
|
||||
item: OutputItem,
|
||||
}
|
||||
|
||||
/// `response.output_item.added/done` の `item`。
|
||||
/// `item` in `response.output_item.added/done`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum OutputItem {
|
||||
@@ -199,8 +200,8 @@ enum OutputItem {
|
||||
Reasoning {
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
/// `output_item.done` で初めて埋まる。`include=["reasoning.encrypted_content"]`
|
||||
/// 指定時に opaque blob が乗る。
|
||||
/// First populated at `output_item.done`. Carries an opaque blob when
|
||||
/// `include=["reasoning.encrypted_content"]` is specified.
|
||||
#[serde(default)]
|
||||
encrypted_content: Option<String>,
|
||||
},
|
||||
@@ -241,7 +242,7 @@ struct ContentPartDone {
|
||||
part: ContentPart,
|
||||
}
|
||||
|
||||
/// `response.content_part.added/done` の `part`。
|
||||
/// `part` in `response.content_part.added/done`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ContentPart {
|
||||
@@ -338,7 +339,7 @@ struct ResponsesUsage {
|
||||
output_tokens: Option<u64>,
|
||||
#[serde(default)]
|
||||
total_tokens: Option<u64>,
|
||||
/// `input_tokens` の内訳。`cached_tokens` がプロンプトキャッシュヒット分。
|
||||
/// Breakdown of `input_tokens`; `cached_tokens` counts prompt cache hits.
|
||||
#[serde(default)]
|
||||
input_tokens_details: Option<InputTokensDetails>,
|
||||
}
|
||||
@@ -399,11 +400,11 @@ struct TopLevelError {
|
||||
// parse entry point
|
||||
// ============================================================================
|
||||
|
||||
/// SSE フレーム 1 件をパースし、0 個以上の [`Event`] に変換する。
|
||||
/// Parse one SSE frame and convert it to zero or more [`Event`]s.
|
||||
///
|
||||
/// `event_type` は SSE の `event:` フィールド。未対応の event type は
|
||||
/// [`Event::UnhandledSse`] として観測可能にする。`data` が JSON でない /
|
||||
/// 必要なフィールドが抜けている等は [`ClientError::Api`] で返す。
|
||||
/// `event_type` is the SSE `event:` field. Unsupported event types are observable
|
||||
/// as [`Event::UnhandledSse`]. Non-JSON `data`, missing required fields, and similar
|
||||
/// failures return [`ClientError::Api`].
|
||||
pub(crate) fn parse_sse(
|
||||
event_type: &str,
|
||||
data: &str,
|
||||
@@ -427,7 +428,7 @@ pub(crate) fn parse_sse(
|
||||
cache_read_input_tokens: usage
|
||||
.input_tokens_details
|
||||
.and_then(|d| d.cached_tokens),
|
||||
// Responses API は cache 書き込みを別計上しない(input_tokens に含まれる)
|
||||
// The Responses API does not account for cache writes separately (included in input_tokens).
|
||||
cache_creation_input_tokens: None,
|
||||
}));
|
||||
}
|
||||
@@ -466,8 +467,8 @@ pub(crate) fn parse_sse(
|
||||
name,
|
||||
),
|
||||
OutputItem::Reasoning { id, .. } => {
|
||||
// wrapper を確保。中身の content_part / summary_part は
|
||||
// 別 SlotKey で扱われ続ける(Streaming 表示は維持)。
|
||||
// Allocate the wrapper. Its content_parts / summary_parts continue
|
||||
// to use separate SlotKeys (preserving the streaming display).
|
||||
let entry = state.ensure_reasoning(ev.output_index);
|
||||
if id.is_some() {
|
||||
entry.id = id;
|
||||
@@ -480,10 +481,10 @@ pub(crate) fn parse_sse(
|
||||
|
||||
"response.output_item.done" => {
|
||||
let ev: OutputItemDone = from_json(data)?;
|
||||
// Reasoning wrapper の done で蓄積分を既存 reasoning_text block の
|
||||
// stop に載せる。content_part.done が先に来た場合は stop を defer
|
||||
// しておき、ここで encrypted_content / summary と一緒に完了させる。
|
||||
// reasoning_text が無い metadata-only item だけ synthetic block を作る。
|
||||
// On Reasoning wrapper done, attach accumulated material to the existing
|
||||
// reasoning_text block's stop. If content_part.done arrived first, defer
|
||||
// its stop and complete it here with encrypted_content / summary.
|
||||
// Create synthetic blocks only for metadata-only items without reasoning_text.
|
||||
if let OutputItem::Reasoning {
|
||||
id,
|
||||
encrypted_content,
|
||||
@@ -638,7 +639,7 @@ pub(crate) fn parse_sse(
|
||||
|
||||
"response.reasoning_text.delta" => {
|
||||
let ev: ReasoningTextDelta = from_json(data)?;
|
||||
// round-trip 用に蓄積
|
||||
// Accumulate for round-tripping.
|
||||
state
|
||||
.ensure_reasoning(ev.output_index)
|
||||
.text
|
||||
@@ -673,7 +674,7 @@ pub(crate) fn parse_sse(
|
||||
|
||||
"response.reasoning_summary_text.delta" => {
|
||||
let ev: ReasoningSummaryTextDelta = from_json(data)?;
|
||||
// round-trip 用に蓄積
|
||||
// Accumulate for round-tripping.
|
||||
state.extend_reasoning_summary(ev.output_index, ev.summary_index, &ev.delta);
|
||||
Ok(ensure_and_delta(
|
||||
state,
|
||||
@@ -753,22 +754,36 @@ pub(crate) fn parse_sse(
|
||||
}
|
||||
|
||||
"error" => {
|
||||
let ev = from_json::<TopLevelErrorEnvelope>(data).unwrap_or_else(|_| {
|
||||
TopLevelErrorEnvelope {
|
||||
// Responses documents flat error frames (code/message/param),
|
||||
// while providers also send nested error envelopes. Normalize both
|
||||
// structurally so classification never scans arbitrary raw JSON.
|
||||
let ev = from_json::<TopLevelErrorEnvelope>(data)
|
||||
.or_else(|_| {
|
||||
from_json::<TopLevelError>(data).map(|error| TopLevelErrorEnvelope {
|
||||
error,
|
||||
extra: BTreeMap::new(),
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|_| TopLevelErrorEnvelope {
|
||||
error: TopLevelError {
|
||||
message: Some(data.to_string()),
|
||||
// Raw data is observation, not a provider diagnostic that
|
||||
// can authorize request recovery. Keep it out of the base
|
||||
// message even when it contains image-size wording.
|
||||
message: Some("OpenAI Responses error with unparseable data".to_string()),
|
||||
error_type: None,
|
||||
code: None,
|
||||
extra: BTreeMap::new(),
|
||||
extra: BTreeMap::from([(
|
||||
"raw_data".to_string(),
|
||||
Value::String(data.to_string()),
|
||||
)]),
|
||||
},
|
||||
extra: BTreeMap::new(),
|
||||
}
|
||||
});
|
||||
});
|
||||
let (code, message) = top_level_error_diagnostic(ev);
|
||||
Ok(vec![Event::Error(ErrorEvent { code, message })])
|
||||
}
|
||||
|
||||
// 未対応 / 情報系 event type は生成 semantics からは無視しつつ trace に残す。
|
||||
// Ignore unsupported / informational event types for generation semantics, but retain them in traces.
|
||||
_ => Ok(vec![unhandled_sse_event(event_type, data)]),
|
||||
}
|
||||
}
|
||||
@@ -1089,8 +1104,8 @@ fn append_diagnostic(message: String, diagnostic: Map<String, Value>) -> String
|
||||
format!("{} | diagnostic={}", message, Value::Object(diagnostic))
|
||||
}
|
||||
|
||||
/// 対応する BlockStart がまだ発行されていなければ発行しつつ、delta を流す。
|
||||
/// content_part.added を取りこぼしても delta 単独で復旧できるようにする。
|
||||
/// Emit a delta, first emitting its BlockStart if not already sent.
|
||||
/// Allows recovery from a delta alone when content_part.added was missed.
|
||||
fn ensure_and_delta(
|
||||
state: &mut OpenAIResponsesState,
|
||||
key: SlotKey,
|
||||
@@ -1205,14 +1220,14 @@ mod tests {
|
||||
assert_eq!(u.output_tokens, Some(50));
|
||||
assert_eq!(u.total_tokens, Some(12395));
|
||||
assert_eq!(u.cache_read_input_tokens, Some(11000));
|
||||
// OpenAI Responses は cache 書き込みを別計上しない
|
||||
// OpenAI Responses does not account for cache writes separately.
|
||||
assert_eq!(u.cache_creation_input_tokens, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_stream_start_delta_stop() {
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
// output_item.added (message) → 無視
|
||||
// output_item.added (message) → ignored.
|
||||
with(
|
||||
&mut state,
|
||||
"response.output_item.added",
|
||||
@@ -1523,7 +1538,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn delta_without_prior_start_recovers() {
|
||||
// 防御: content_part.added が落ちても delta 単独で BlockStart+Delta を発行
|
||||
// Defensive: emit BlockStart+Delta from a delta alone if content_part.added was lost.
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
let ev = with(
|
||||
&mut state,
|
||||
@@ -1537,8 +1552,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parallel_output_items_get_distinct_indices() {
|
||||
// 2 つの function_call が並列で output_item.added される場合、
|
||||
// flat index が別々になる(Parallel tool calling の基本)。
|
||||
// Two parallel function_call output_item.added events get distinct
|
||||
// flat indices (the basis of parallel tool calling).
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
let ev1 = with(
|
||||
&mut state,
|
||||
@@ -1661,17 +1676,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn reasoning_output_item_completes_metadata_thinking_block_with_text_summary_encrypted() {
|
||||
// 完成済み reasoning wrapper が text + summary[] + encrypted_content を持って
|
||||
// Thinking BlockStop metadata として届くこと。
|
||||
// A completed reasoning wrapper must deliver text + summary[] +
|
||||
// encrypted_content as Thinking BlockStop metadata.
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
|
||||
// wrapper added (id だけ持つ)
|
||||
// Wrapper added (with only an id).
|
||||
with(
|
||||
&mut state,
|
||||
"response.output_item.added",
|
||||
r#"{"output_index":0,"item":{"type":"reasoning","id":"r1"}}"#,
|
||||
);
|
||||
// 内側の reasoning_text 用 content_part
|
||||
// Inner content_part for reasoning_text.
|
||||
let start = with(
|
||||
&mut state,
|
||||
"response.content_part.added",
|
||||
@@ -1697,7 +1712,7 @@ mod tests {
|
||||
r#"{"output_index":0,"content_index":0,"item_id":"r1","part":{"type":"reasoning_text","text":"hello world"}}"#,
|
||||
);
|
||||
assert!(part_done.is_empty());
|
||||
// summary 1 件
|
||||
// One summary.
|
||||
with(
|
||||
&mut state,
|
||||
"response.reasoning_summary_part.added",
|
||||
@@ -1714,7 +1729,7 @@ mod tests {
|
||||
r#"{"output_index":0,"summary_index":0,"item_id":"r1"}"#,
|
||||
);
|
||||
|
||||
// wrapper done (encrypted_content が乗る)
|
||||
// Wrapper done (includes encrypted_content).
|
||||
let evs = with(
|
||||
&mut state,
|
||||
"response.output_item.done",
|
||||
@@ -1731,7 +1746,7 @@ mod tests {
|
||||
assert_eq!(reasoning.summary, vec!["sum-A".to_string()]);
|
||||
assert_eq!(reasoning.encrypted_content.as_deref(), Some("ENC-XYZ"));
|
||||
assert!(reasoning.signature.is_none());
|
||||
// pending_reasoning は drain されていること
|
||||
// pending_reasoning must be drained.
|
||||
assert!(state.pending_reasoning.is_empty());
|
||||
}
|
||||
|
||||
@@ -1821,8 +1836,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn reasoning_wrapper_without_inner_content_emits_empty_text() {
|
||||
// encrypted_content だけ届く(reasoning_text 無し)ケースでも
|
||||
// reasoning metadata は届く。
|
||||
// Reasoning metadata must arrive even when only encrypted_content
|
||||
// is received (no reasoning_text).
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
with(
|
||||
&mut state,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! OpenAI Responses API スキーマ (`/v1/responses`)
|
||||
//! OpenAI Responses API scheme (`/v1/responses`).
|
||||
//!
|
||||
//! Chat Completions とは別物の item-based wire format。reasoning item と
|
||||
//! function_call item が first-class で、SSE イベントも `response.*` 名前空間で
|
||||
//! 流れる。
|
||||
//! An item-based wire format distinct from Chat Completions. Reasoning and
|
||||
//! function_call items are first-class, and SSE events use the `response.*`
|
||||
//! namespace.
|
||||
//!
|
||||
//! - リクエスト JSON 生成: `request`
|
||||
//! - SSE イベントパース → [`Event`](crate::llm_client::event::Event) 変換: `events`
|
||||
//! - Request JSON generation: `request`.
|
||||
//! - SSE event parsing → [`Event`](crate::llm_client::event::Event) conversion: `events`.
|
||||
|
||||
mod capability;
|
||||
mod events;
|
||||
@@ -14,29 +14,29 @@ mod scheme_impl;
|
||||
|
||||
pub use scheme_impl::OpenAIResponsesState;
|
||||
|
||||
/// OpenAI Responses scheme 本体。
|
||||
/// OpenAI Responses scheme.
|
||||
///
|
||||
/// `store` / `include_encrypted_content` / `send_max_output_tokens` /
|
||||
/// `send_sampling_params` は scheme 固定の wire 設定で、デフォルトは
|
||||
/// 公式 OpenAI Responses API 向け (stateless + ZDR + `max_output_tokens`
|
||||
/// / `temperature` / `top_p` 送出可)。受理パラメータが subset の
|
||||
/// 互換 backend では client 構築層で `send_max_output_tokens=false` /
|
||||
/// `send_sampling_params=false` に上書きする。`ModelCapability` には
|
||||
/// 入れない(モデル能力ではなく wire policy)。
|
||||
/// `send_sampling_params` are scheme-level wire settings. Defaults target the
|
||||
/// official OpenAI Responses API (stateless + ZDR, with `max_output_tokens` /
|
||||
/// `temperature` / `top_p` allowed). For compatible backends accepting only a
|
||||
/// subset of parameters, the client construction layer overrides
|
||||
/// `send_max_output_tokens=false` / `send_sampling_params=false`.
|
||||
/// These do not belong in `ModelCapability`: they are wire policy, not model capabilities.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenAIResponsesScheme {
|
||||
/// サーバ側に response を保存するか。ZDR/stateless 運用では `false`。
|
||||
/// Whether to store responses on the server. `false` for ZDR/stateless operation.
|
||||
pub store: bool,
|
||||
/// `include: ["reasoning.encrypted_content"]` を付けるか。
|
||||
/// `store=false` で reasoning を使うなら必須。
|
||||
/// Whether to add `include: ["reasoning.encrypted_content"]`.
|
||||
/// Required when using reasoning with `store=false`.
|
||||
pub include_encrypted_content: bool,
|
||||
/// `max_output_tokens` を body に載せるか。公式 OpenAI Responses API は
|
||||
/// 受理するが、互換 backend によっては `Unsupported parameter` で
|
||||
/// 400 を返すため、その経路では `false` にする。
|
||||
/// Whether to include `max_output_tokens` in the body. The official OpenAI
|
||||
/// Responses API accepts it, but some compatible backends return 400 with
|
||||
/// `Unsupported parameter`; set this to `false` for those routes.
|
||||
pub send_max_output_tokens: bool,
|
||||
/// `temperature` / `top_p` を body に載せるか。公式 OpenAI Responses API
|
||||
/// は受理するが、互換 backend によっては `Unsupported parameter` で
|
||||
/// 400 を返すため、その経路では `false` にする。
|
||||
/// Whether to include `temperature` / `top_p` in the body. The official OpenAI
|
||||
/// Responses API accepts them, but some compatible backends return 400 with
|
||||
/// `Unsupported parameter`; set this to `false` for those routes.
|
||||
pub send_sampling_params: bool,
|
||||
}
|
||||
|
||||
@@ -52,31 +52,31 @@ impl Default for OpenAIResponsesScheme {
|
||||
}
|
||||
|
||||
impl OpenAIResponsesScheme {
|
||||
/// デフォルト設定 (`store=false`, `include=["reasoning.encrypted_content"]`,
|
||||
/// `send_max_output_tokens=true`, `send_sampling_params=true`)。
|
||||
/// Default settings (`store=false`, `include=["reasoning.encrypted_content"]`,
|
||||
/// `send_max_output_tokens=true`, `send_sampling_params=true`).
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// `store` を上書き。
|
||||
/// Override `store`.
|
||||
pub fn with_store(mut self, store: bool) -> Self {
|
||||
self.store = store;
|
||||
self
|
||||
}
|
||||
|
||||
/// `include: ["reasoning.encrypted_content"]` の有無を上書き。
|
||||
/// Override whether to include `include: ["reasoning.encrypted_content"]`.
|
||||
pub fn with_include_encrypted_content(mut self, include: bool) -> Self {
|
||||
self.include_encrypted_content = include;
|
||||
self
|
||||
}
|
||||
|
||||
/// `max_output_tokens` を body に載せるかを上書き。
|
||||
/// Override whether to include `max_output_tokens` in the body.
|
||||
pub fn with_send_max_output_tokens(mut self, send: bool) -> Self {
|
||||
self.send_max_output_tokens = send;
|
||||
self
|
||||
}
|
||||
|
||||
/// `temperature` / `top_p` を body に載せるかを上書き。
|
||||
/// Override whether to include `temperature` / `top_p` in the body.
|
||||
pub fn with_send_sampling_params(mut self, send: bool) -> Self {
|
||||
self.send_sampling_params = send;
|
||||
self
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! OpenAI Responses API リクエスト body 生成
|
||||
//! OpenAI Responses API request body generation.
|
||||
//!
|
||||
//! Chat Completions の `messages` と違い、Responses は `input[]` の
|
||||
//! item 配列で reasoning / function_call / function_call_output が
|
||||
//! first-class。`Item` を素に近い形で `input[]` に投影できる。
|
||||
//! Unlike Chat Completions `messages`, Responses uses an `input[]` item array
|
||||
//! with first-class reasoning / function_call / function_call_output items.
|
||||
//! `Item` can be projected into `input[]` with little transformation.
|
||||
|
||||
use serde::{Serialize, Serializer};
|
||||
use serde_json::Value;
|
||||
@@ -32,93 +32,92 @@ pub(crate) enum FunctionCallOutputContentItem {
|
||||
InputImage { image_url: String },
|
||||
}
|
||||
|
||||
/// `/v1/responses` のリクエスト body。
|
||||
/// Request body for `/v1/responses`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ResponsesRequest {
|
||||
pub model: String,
|
||||
/// システムプロンプト相当。`input[]` とは別フィールド。
|
||||
/// System prompt equivalent, separate from `input[]`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instructions: Option<String>,
|
||||
pub input: Vec<InputItem>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tools: Vec<ResponseTool>,
|
||||
/// 常時 `"auto"` を送る。scheme 固定値。
|
||||
/// Always sends `"auto"`, a scheme-level constant.
|
||||
pub tool_choice: &'static str,
|
||||
/// 常時 `true` を送る。scheme 固定値。
|
||||
/// Always sends `true`, a scheme-level constant.
|
||||
pub parallel_tool_calls: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning: Option<ReasoningConfig>,
|
||||
/// ZDR / stateless 運用では `false`。
|
||||
/// `false` for ZDR / stateless operation.
|
||||
pub store: bool,
|
||||
/// 常時 `true`。
|
||||
/// Always `true`.
|
||||
pub stream: bool,
|
||||
/// `["reasoning.encrypted_content"]` 等。
|
||||
/// E.g. `["reasoning.encrypted_content"]`.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub include: Vec<&'static str>,
|
||||
/// 公式 OpenAI Responses API では受理されるが、互換 backend によっては
|
||||
/// 400 で弾く。scheme の `send_max_output_tokens` が `false` のときは
|
||||
/// `None` のまま送る (skip_serializing_if で除外)。
|
||||
/// Accepted by the official OpenAI Responses API, but some compatible backends
|
||||
/// reject it with 400. When the scheme's `send_max_output_tokens` is `false`,
|
||||
/// leave it as `None` (excluded by skip_serializing_if).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_output_tokens: Option<u32>,
|
||||
/// 公式 OpenAI Responses API では受理されるが、互換 backend によっては
|
||||
/// `temperature` / `top_p` を 400 で弾く。scheme の
|
||||
/// `send_sampling_params` が `false` のときは `None` のまま送る。
|
||||
/// Accepted by the official OpenAI Responses API, but some compatible backends
|
||||
/// reject `temperature` / `top_p` with 400. When the scheme's
|
||||
/// `send_sampling_params` is `false`, leave them as `None`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
/// 会話単位の安定キー。明示キーを必要とする backend では、
|
||||
/// 呼び出し側が安定した conversation identifier を渡す。
|
||||
/// `Request::cache_key` が `None` のときはキー自体を送らない。
|
||||
/// Stable conversation key. For backends requiring an explicit key, the caller
|
||||
/// supplies a stable conversation identifier. The key is omitted entirely
|
||||
/// when `Request::cache_key` is `None`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
}
|
||||
|
||||
/// reasoning 制御。
|
||||
/// Reasoning control.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ReasoningConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub effort: Option<String>,
|
||||
/// summary の出力制御。`"auto"` 固定で summary_text を受け取る。
|
||||
/// Summary output control, fixed to `"auto"` to receive summary_text.
|
||||
pub summary: &'static str,
|
||||
}
|
||||
|
||||
/// `input[]` の 1 要素。
|
||||
/// One element of `input[]`.
|
||||
///
|
||||
/// Responses API の item 型を素に近い形で投影する。未対応 type は
|
||||
/// 無視(reasoning 送信時に `content: []` の場合は `None` として弾く)。
|
||||
/// Projects Responses API item types with little transformation. Unsupported types
|
||||
/// are ignored (reasoning with `content: []` is rejected as `None` when sending).
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum InputItem {
|
||||
/// 会話メッセージ。user / assistant / developer のいずれか。
|
||||
/// `Role::System` items は `developer` として投影する。OpenAI
|
||||
/// Responses 互換 backend の一部は `role: "system"` を拒否するため、
|
||||
/// system 相当の挿入には `role: "developer"` を使う。
|
||||
/// Conversation message: user / assistant / developer.
|
||||
/// `Role::System` items are projected as `developer`. Some OpenAI Responses
|
||||
/// compatible backends reject `role: "system"`, so system-equivalent
|
||||
/// insertions use `role: "developer"`.
|
||||
Message {
|
||||
role: &'static str,
|
||||
content: Vec<InputContent>,
|
||||
},
|
||||
/// 過去の function tool 呼び出し(assistant 側)。
|
||||
/// Previous function tool call (assistant side).
|
||||
FunctionCall {
|
||||
call_id: String,
|
||||
name: String,
|
||||
/// JSON 文字列(object でなくても正規化済み)。
|
||||
/// JSON string (normalized even if the original was not an object).
|
||||
arguments: String,
|
||||
},
|
||||
/// function tool の結果(user 側)。
|
||||
/// Function tool result (user side).
|
||||
FunctionCallOutput {
|
||||
call_id: String,
|
||||
output: FunctionCallOutputBody,
|
||||
},
|
||||
/// reasoning item。`encrypted_content` があれば必ず添える。
|
||||
/// Reasoning item, always including `encrypted_content` when present.
|
||||
Reasoning {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
/// Responses API は reasoning item に `summary` フィールドを必須で
|
||||
/// 要求する(中身が空でも `[]` として送る必要がある)。GPT-5 など
|
||||
/// summary を返さないモデル + reasoning effort 指定なしのターンでは
|
||||
/// summary text が一切付かないので、ここを skip すると 400
|
||||
/// "Missing required parameter: 'input[N].summary'" で弾かれる。
|
||||
/// The Responses API requires `summary` on reasoning items, even if sent
|
||||
/// as an empty `[]`. Models such as GPT-5 may return no summary text on
|
||||
/// turns without a specified reasoning effort. Omitting this field then
|
||||
/// causes a 400 `"Missing required parameter: 'input[N].summary'"` rejection.
|
||||
summary: Vec<ReasoningSummaryPart>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
content: Vec<ReasoningContentPart>,
|
||||
@@ -127,14 +126,13 @@ pub(crate) enum InputItem {
|
||||
},
|
||||
}
|
||||
|
||||
/// メッセージ content_part。role で input/output を使い分ける。
|
||||
/// Message content_part, using input/output according to role.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum InputContent {
|
||||
/// user / developer 側のテキスト
|
||||
/// User / developer text.
|
||||
InputText { text: String },
|
||||
/// user 側の画像
|
||||
/// assistant 側のテキスト
|
||||
/// Assistant text.
|
||||
OutputText { text: String },
|
||||
}
|
||||
|
||||
@@ -150,8 +148,8 @@ pub(crate) enum ReasoningContentPart {
|
||||
ReasoningText { text: String },
|
||||
}
|
||||
|
||||
/// Responses 用 tool 定義。Chat と違い function キーでネストせず
|
||||
/// トップレベルに `name` / `parameters` が載る。
|
||||
/// Tool definition for Responses. Unlike Chat, `name` / `parameters` are
|
||||
/// top-level fields, not nested under a function key.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ResponseTool {
|
||||
#[serde(rename = "type")]
|
||||
@@ -159,13 +157,12 @@ pub(crate) struct ResponseTool {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// OpenAI Responses API は `type:"object"` のパラメータスキーマに
|
||||
/// `properties` が存在することを要求する。schemars は引数なし struct
|
||||
/// から `properties` を含まない最小スキーマを出すので、serialize
|
||||
/// 時に空オブジェクトを補う。
|
||||
/// The OpenAI Responses API requires `properties` in `type:"object"` parameter
|
||||
/// schemas. schemars emits minimal schemas without `properties` for structs
|
||||
/// with no arguments, so serialization supplies an empty object.
|
||||
#[serde(serialize_with = "serialize_parameters")]
|
||||
pub parameters: Value,
|
||||
/// Structured output モード制御。デフォルト false。
|
||||
/// Structured output mode control. Defaults to false.
|
||||
pub strict: bool,
|
||||
}
|
||||
|
||||
@@ -182,7 +179,7 @@ fn serialize_parameters<S: Serializer>(value: &Value, s: S) -> Result<S::Ok, S::
|
||||
}
|
||||
|
||||
impl OpenAIResponsesScheme {
|
||||
/// `Request` から wire 形式の body を組み立てる。
|
||||
/// Build a wire-format body from `Request`.
|
||||
pub(crate) fn build_request(
|
||||
&self,
|
||||
model: &str,
|
||||
@@ -192,8 +189,8 @@ impl OpenAIResponsesScheme {
|
||||
let input = convert_items_to_input(&request.items, capability.vision);
|
||||
let tools = request.tools.iter().map(convert_tool).collect();
|
||||
|
||||
// Reasoning 投影: capability が Effort / Both をサポートし、かつ
|
||||
// request 側で effort が指定されているときだけ reasoning を付ける。
|
||||
// Project reasoning: add reasoning only when the capability supports
|
||||
// Effort / Both and the request specifies an effort.
|
||||
let supports_effort = matches!(
|
||||
capability.reasoning,
|
||||
Some(ReasoningSupport::Effort | ReasoningSupport::Both),
|
||||
@@ -249,7 +246,7 @@ impl OpenAIResponsesScheme {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Item` 列を `input[]` に変換する。
|
||||
/// Convert an `Item` sequence to `input[]`.
|
||||
fn convert_items_to_input(items: &[Item], supports_images: bool) -> Vec<InputItem> {
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
@@ -280,7 +277,7 @@ fn convert_items_to_input(items: &[Item], supports_images: bool) -> Vec<InputIte
|
||||
arguments,
|
||||
..
|
||||
} => {
|
||||
// 非 object / 旧形式の "null" を "{}" に正規化。
|
||||
// Normalize non-objects / legacy "null" to "{}".
|
||||
let normalized = parse_tool_arguments(arguments).to_string();
|
||||
out.push(InputItem::FunctionCall {
|
||||
call_id: call_id.clone(),
|
||||
@@ -554,11 +551,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn reasoning_summary_field_is_always_serialized() {
|
||||
// Responses API は reasoning item に `summary` を必須で要求する。
|
||||
// summary が空でも wire 上に `summary: []` として残らないと、
|
||||
// backend によっては missing required parameter として拒否される。
|
||||
// reasoning effort 未指定のターンでは summary text が付かないことが
|
||||
// あるため、空のままでも skip しないこと。
|
||||
// The Responses API requires `summary` on reasoning items. Even an empty
|
||||
// summary must remain as `summary: []` on the wire, or some backends
|
||||
// reject it as a missing required parameter. Turns without a specified
|
||||
// reasoning effort may have no summary text; do not skip empty summaries.
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let item = Item::reasoning("").with_encrypted_content("ENC");
|
||||
let req = Request::new().user("hi").item(item);
|
||||
@@ -667,9 +663,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tool_schema_without_properties_is_normalized() {
|
||||
// schemars は引数なし struct から `type:"object"` だけのスキーマを
|
||||
// 吐く。OpenAI Responses は `properties` 欠落を 400 で拒否するので
|
||||
// 送る直前に空オブジェクトを補うのを確認。
|
||||
// schemars emits only `type:"object"` for structs with no arguments.
|
||||
// OpenAI Responses rejects missing `properties` with 400, so verify that
|
||||
// an empty object is supplied just before sending.
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let raw_schema = serde_json::json!({ "type": "object" });
|
||||
let req = Request::new().tool(
|
||||
@@ -707,7 +703,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn serialized_body_has_expected_shape() {
|
||||
// wire 形式が崩れていないかのスモークテスト
|
||||
// Smoke test for an intact wire format.
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new()
|
||||
.system("sys")
|
||||
|
||||
@@ -20,8 +20,8 @@ impl Scheme for OpenAIResponsesScheme {
|
||||
type State = OpenAIResponsesState;
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
// `/v1` は base_url 側に寄せる。互換 backend を使う場合も、
|
||||
// base URL を差し替えるだけで同じ `/responses` path を使える。
|
||||
// Keep `/v1` in base_url. Compatible backends can reuse the same
|
||||
// `/responses` path by replacing only the base URL.
|
||||
"https://api.openai.com/v1"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! `HttpTransport<S: Scheme>`: すべての LLM wire scheme を共通の 1 本の
|
||||
//! HTTP クライアントで扱う。
|
||||
//! `HttpTransport<S: Scheme>` handles all LLM wire schemes with one shared
|
||||
//! HTTP client.
|
||||
//!
|
||||
//! scheme 固有の差分は [`Scheme`] trait 実装に委譲し、backend 固有の
|
||||
//! HTTP policy は [`TransportPolicy`] で明示的に差し込む。
|
||||
//! Scheme-specific differences are delegated to [`Scheme`] implementations;
|
||||
//! backend-specific HTTP policy is explicitly injected via [`TransportPolicy`].
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
@@ -28,11 +28,11 @@ use super::types::{Request, RequestConfig};
|
||||
pub const DEFAULT_STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
pub const DEFAULT_FIRST_STREAM_EVENT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// 認証設定をリクエスト時に使える形へ解決したランタイム表現。
|
||||
/// Runtime representation of authentication resolved for use in requests.
|
||||
///
|
||||
/// - `None`: 認証ヘッダを送らない(Ollama 等の opt-out)
|
||||
/// - `ApiKey`: 静的な API key 文字列
|
||||
/// - `Custom`: リクエスト毎に動的にヘッダを組み立てる
|
||||
/// - `None`: send no authentication headers (opt-out for Ollama, etc.).
|
||||
/// - `ApiKey`: static API key string.
|
||||
/// - `Custom`: build headers dynamically for each request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ResolvedAuth {
|
||||
None,
|
||||
@@ -41,13 +41,13 @@ pub enum ResolvedAuth {
|
||||
}
|
||||
|
||||
impl ResolvedAuth {
|
||||
/// 認証要件と実際の解決値が噛み合うか検査する。構築時検証用。
|
||||
/// Check whether resolved authentication matches the requirement at construction.
|
||||
///
|
||||
/// - `ResolvedAuth::None` は認証を付けない宣言なので、どの
|
||||
/// `AuthRequirement` でも受け入れる(Ollama の Anthropic scheme
|
||||
/// 流用は `required_auth = XApiKey` だが認証ヘッダなしで動く)
|
||||
/// - `ResolvedAuth::Custom` は「ヘッダ組立を全部こちらで行う」
|
||||
/// 宣言なので、scheme が要求する形式によらず受け入れる
|
||||
/// - `ResolvedAuth::None` declares no authentication, so any `AuthRequirement`
|
||||
/// is accepted (Ollama reuses the Anthropic scheme with
|
||||
/// `required_auth = XApiKey`, but works without authentication headers).
|
||||
/// - `ResolvedAuth::Custom` declares that all header construction is handled
|
||||
/// externally, so it is accepted regardless of the scheme's required format.
|
||||
pub fn matches(&self, req: AuthRequirement) -> bool {
|
||||
match (self, req) {
|
||||
(Self::None, _) => true,
|
||||
@@ -177,7 +177,7 @@ fn sse_error_context(status: u16, headers: &serde_json::Value, source: &str) ->
|
||||
)
|
||||
}
|
||||
|
||||
/// scheme 共通の HTTP 通信層。
|
||||
/// Shared HTTP transport layer for schemes.
|
||||
pub struct HttpTransport<S: Scheme> {
|
||||
http_client: reqwest::Client,
|
||||
scheme: S,
|
||||
@@ -189,8 +189,8 @@ pub struct HttpTransport<S: Scheme> {
|
||||
}
|
||||
|
||||
impl<S: Scheme> HttpTransport<S> {
|
||||
/// 新しい transport を作る。`base_url` は末尾スラッシュの有無を
|
||||
/// どちらでも受け付ける(内部で正規化)。
|
||||
/// Create a transport. `base_url` accepts an optional trailing slash
|
||||
/// (normalized internally).
|
||||
pub fn new(
|
||||
scheme: S,
|
||||
model_id: impl Into<String>,
|
||||
@@ -217,7 +217,7 @@ impl<S: Scheme> HttpTransport<S> {
|
||||
self
|
||||
}
|
||||
|
||||
/// カスタム HTTP クライアントを差し込む(テスト等)。
|
||||
/// Inject a custom HTTP client (e.g. for tests).
|
||||
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
|
||||
self.http_client = client;
|
||||
self
|
||||
@@ -226,7 +226,7 @@ impl<S: Scheme> HttpTransport<S> {
|
||||
fn build_url(&self) -> String {
|
||||
let path = self.scheme.path(&self.model_id);
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
// Gemini のようにクエリパラメータで認証する場合は URL にキーを追記する
|
||||
// Append the key to the URL for query-parameter authentication, as in Gemini.
|
||||
if let (AuthRequirement::QueryParam { name }, ResolvedAuth::ApiKey(key)) =
|
||||
(self.scheme.required_auth(), &self.auth)
|
||||
{
|
||||
@@ -262,11 +262,11 @@ impl<S: Scheme> HttpTransport<S> {
|
||||
headers.insert("x-api-key", val);
|
||||
}
|
||||
(_, AuthRequirement::QueryParam { .. }) => {
|
||||
// クエリパラメータは `build_url` で付与済み
|
||||
// Query parameters were already added by `build_url`.
|
||||
}
|
||||
(ResolvedAuth::ApiKey(_), AuthRequirement::Custom) => {
|
||||
// scheme が Custom を要求する組合せに ApiKey は流れてこない想定
|
||||
// (`matches()` で弾かれる)。安全側で何もしない
|
||||
// ApiKey should never reach a scheme requiring Custom (`matches()`
|
||||
// rejects it). Do nothing as a conservative fallback.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +459,7 @@ impl<S: Scheme + Clone> Clone for HttpTransport<S> {
|
||||
}
|
||||
}
|
||||
|
||||
/// エラーレスポンスを `ClientError::Api` に変換する。
|
||||
/// Convert an error response to `ClientError::Api`.
|
||||
async fn classify_error_response(resp: reqwest::Response) -> ClientError {
|
||||
let status = resp.status().as_u16();
|
||||
let retry_after = resp
|
||||
@@ -713,7 +713,7 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
|
||||
let byte_stream = response.bytes_stream().map_err(std::io::Error::other);
|
||||
let event_stream = byte_stream.eventsource();
|
||||
|
||||
// scheme 固有のパース状態をストリーム単位で保持する
|
||||
// Keep scheme-specific parsing state for each stream.
|
||||
let mut state = <S::State as Default>::default();
|
||||
|
||||
let stream = event_stream
|
||||
|
||||
@@ -151,21 +151,20 @@ pub enum Item {
|
||||
/// Optional item ID
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<ItemId>,
|
||||
/// Reasoning text(reasoning body, `reasoning_text.delta` の累積)
|
||||
/// Reasoning text (reasoning body, accumulated `reasoning_text.delta`).
|
||||
text: String,
|
||||
/// Reasoning summary(OpenAI Responses の `summary_text[]` を格納。
|
||||
/// 他 scheme は空)
|
||||
/// Reasoning summary (OpenAI Responses `summary_text[]`; empty for other schemes).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
summary: Vec<String>,
|
||||
/// サーバから返された暗号化済み reasoning blob。ZDR / `store=false`
|
||||
/// 運用で stateless に再送するときそのまま添える必要がある。
|
||||
/// Anthropic の `redacted_thinking.data` もここに格納する。
|
||||
/// Encrypted reasoning blob returned by the server. Must be included unchanged
|
||||
/// when resending statelessly under ZDR / `store=false`.
|
||||
/// Also holds Anthropic `redacted_thinking.data`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
encrypted_content: Option<String>,
|
||||
/// Anthropic extended thinking の `signature`。新世代 Claude
|
||||
/// (Opus 4.5+/Sonnet 4.6+) では同一論理ターン内の `thinking`
|
||||
/// ブロックを送り返す際に必須。改ざん検知に使われる。他 scheme
|
||||
/// では `None`。
|
||||
/// Anthropic extended thinking `signature`, used for tamper detection.
|
||||
/// Newer Claude models (Opus 4.5+/Sonnet 4.6+) require it when sending
|
||||
/// `thinking` blocks back within the same logical turn.
|
||||
/// `None` for other schemes.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
signature: Option<String>,
|
||||
/// Item status
|
||||
@@ -624,12 +623,11 @@ pub struct Request {
|
||||
/// (Anthropic today) can place a long-lived cache breakpoint there.
|
||||
/// Providers without prompt caching ignore the field.
|
||||
pub cache_anchor: Option<usize>,
|
||||
/// 会話単位の安定キー。`prompt_cache_key` として送られる
|
||||
/// (OpenAI Responses)。明示キーを必要とする backend では、
|
||||
/// 呼び出し側が安定した conversation identifier を渡す。
|
||||
/// `cache_anchor` と違い名前空間キーであり、`prefix anchor` とは
|
||||
/// 別の概念。`cache_anchor` を読まない provider と同じく、
|
||||
/// `prompt_cache_key` を持たない provider は無視する。
|
||||
/// Stable conversation key, sent as `prompt_cache_key` (OpenAI Responses).
|
||||
/// For backends requiring an explicit key, the caller supplies a stable
|
||||
/// conversation identifier. Unlike `cache_anchor`, this is a namespace key,
|
||||
/// separate from a `prefix anchor`. Just as some providers ignore
|
||||
/// `cache_anchor`, providers without `prompt_cache_key` ignore this field.
|
||||
pub cache_key: Option<String>,
|
||||
/// Request-local diagnostics sink for transport lifecycle tracing.
|
||||
#[doc(hidden)]
|
||||
@@ -725,7 +723,7 @@ impl Request {
|
||||
|
||||
/// Set the conversation cache key.
|
||||
///
|
||||
/// 詳細は [`Request::cache_key`] のフィールドコメント参照。
|
||||
/// See the [`Request::cache_key`] field documentation for details.
|
||||
pub fn cache_key(mut self, key: impl Into<String>) -> Self {
|
||||
self.cache_key = Some(key.into());
|
||||
self
|
||||
@@ -790,10 +788,11 @@ pub struct RequestConfig {
|
||||
pub top_k: Option<u32>,
|
||||
/// Stop sequences
|
||||
pub stop_sequences: Vec<String>,
|
||||
/// Reasoning / extended-thinking 制御(共通型、scheme 側で各社形式に投影)。
|
||||
/// Shared reasoning / extended-thinking control, projected by each scheme
|
||||
/// into the provider's format.
|
||||
///
|
||||
/// `None` のときは何も送らない。`Some` でも scheme の
|
||||
/// `ModelCapability::reasoning` が `None` なら無視される。
|
||||
/// `None` sends nothing. Even `Some` is ignored if the scheme's
|
||||
/// `ModelCapability::reasoning` is `None`.
|
||||
#[serde(default)]
|
||||
pub reasoning: Option<crate::llm_client::capability::ReasoningControl>,
|
||||
}
|
||||
@@ -851,7 +850,7 @@ mod parse_tool_arguments_tests {
|
||||
|
||||
#[test]
|
||||
fn literal_null_normalizes_to_object() {
|
||||
// 既存セッションに残っている "null" が resume 時に復旧できること
|
||||
// Ensure "null" retained in existing sessions can be recovered on resume.
|
||||
assert_eq!(parse_tool_arguments("null"), empty_object());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! `~/.codex/auth.json` の読み書き。
|
||||
//! Reading and writing `~/.codex/auth.json`.
|
||||
//!
|
||||
//! Codex CLI と schema を共有するが、知らないフィールドを失わないよう
|
||||
//! ファイル全体を `serde_json::Value` で保持し、必要箇所
|
||||
//! のみアクセスする。書込は `mode 0o600` を再設定(Codex CLI 同様)、
|
||||
//! ファイルロックは取らない(manager 側で guarded reload)。
|
||||
//! Shares the schema with Codex CLI, but retains the entire file as
|
||||
//! `serde_json::Value` and accesses only the required fields to preserve
|
||||
//! unknown fields. Writes reapply `mode 0o600` (as in Codex CLI),
|
||||
//! without file locking (the manager performs a guarded reload).
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
@@ -16,7 +16,7 @@ use serde_json::Value;
|
||||
|
||||
use super::error::CodexAuthError;
|
||||
|
||||
/// auth.json から取り出した使い回す情報のスナップショット。
|
||||
/// Snapshot of reusable information extracted from auth.json.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthSnapshot {
|
||||
pub access_token: String,
|
||||
@@ -24,12 +24,12 @@ pub struct AuthSnapshot {
|
||||
pub account_id: String,
|
||||
pub id_token: String,
|
||||
pub last_refresh: Option<DateTime<Utc>>,
|
||||
/// 書き戻し時に他のフィールドを失わないため、ファイル全体を保持する。
|
||||
/// Retain the entire file to preserve other fields when writing back.
|
||||
pub raw: Value,
|
||||
}
|
||||
|
||||
impl AuthSnapshot {
|
||||
/// `Value` から必要フィールドを抽出。欠落・型不一致は `MalformedAuthJson`。
|
||||
/// Extract required fields from `Value`. Missing fields or type mismatches yield `MalformedAuthJson`.
|
||||
pub fn from_value(raw: Value) -> Result<Self, CodexAuthError> {
|
||||
let tokens = raw
|
||||
.get("tokens")
|
||||
@@ -55,7 +55,7 @@ impl AuthSnapshot {
|
||||
.ok_or_else(|| CodexAuthError::MalformedAuthJson("missing tokens.id_token".into()))?
|
||||
.to_string();
|
||||
|
||||
// account_id は tokens.account_id を優先、無ければ id_token JWT 由来
|
||||
// Prefer tokens.account_id; fall back to the id_token JWT if absent
|
||||
let account_id = tokens
|
||||
.get("account_id")
|
||||
.and_then(Value::as_str)
|
||||
@@ -84,7 +84,7 @@ impl AuthSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/// auth.json を読む。存在しなければ `NotLoggedIn`。
|
||||
/// Read auth.json. Return `NotLoggedIn` if it does not exist.
|
||||
pub async fn load(path: &Path) -> Result<AuthSnapshot, CodexAuthError> {
|
||||
let bytes = match tokio::fs::read(path).await {
|
||||
Ok(b) => b,
|
||||
@@ -103,18 +103,26 @@ pub async fn load(path: &Path) -> Result<AuthSnapshot, CodexAuthError> {
|
||||
AuthSnapshot::from_value(raw)
|
||||
}
|
||||
|
||||
/// 既存ファイルを再読込し、`tokens.{id_token,access_token,refresh_token}` と
|
||||
/// `last_refresh` を更新して書き戻す。Codex CLI の `persist_tokens` 相当。
|
||||
/// Reload the existing file, update `tokens.{id_token,access_token,refresh_token}`
|
||||
/// and `last_refresh`, and write it back. Equivalent to Codex CLI's `persist_tokens`.
|
||||
///
|
||||
/// 並行する Codex CLI / 別プロセスが先に refresh していた場合の
|
||||
/// fields を保護するため、書込前に再 load して merge する。
|
||||
/// Reload and merge before writing to preserve fields updated by a concurrent
|
||||
/// Codex CLI / another process. Account and refresh token must still match the
|
||||
/// request's input: merge unrelated fields, but return the current snapshot
|
||||
/// without writing if another login or token rotation is observed.
|
||||
pub async fn persist_refreshed(
|
||||
path: &Path,
|
||||
refresh_input: &AuthSnapshot,
|
||||
new_id_token: Option<String>,
|
||||
new_access_token: Option<String>,
|
||||
new_refresh_token: Option<String>,
|
||||
) -> Result<AuthSnapshot, CodexAuthError> {
|
||||
let mut current = load(path).await?;
|
||||
if current.account_id != refresh_input.account_id
|
||||
|| current.refresh_token != refresh_input.refresh_token
|
||||
{
|
||||
return Ok(current);
|
||||
}
|
||||
let raw = &mut current.raw;
|
||||
let tokens = raw
|
||||
.get_mut("tokens")
|
||||
@@ -161,8 +169,8 @@ fn write_atomic(path: &Path, value: &Value) -> Result<(), CodexAuthError> {
|
||||
file.flush()
|
||||
.map_err(|e| CodexAuthError::Io(format!("flush {}: {e}", path.display())))?;
|
||||
|
||||
// 既存ファイルが緩いパーミッションだった場合に備えて 0o600 を強制し直す。
|
||||
// OpenOptions の `mode()` は新規作成時しか効かないため。
|
||||
// Reapply 0o600 in case the existing file had permissive permissions.
|
||||
// OpenOptions `mode()` only takes effect when creating a new file.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -204,7 +212,7 @@ mod tests {
|
||||
assert_eq!(snap.refresh_token, "ref");
|
||||
assert_eq!(snap.account_id, "acc-1");
|
||||
assert!(snap.last_refresh.is_some());
|
||||
// 未知フィールドが raw に保持されている
|
||||
// Unknown fields are retained in raw
|
||||
assert_eq!(
|
||||
snap.raw.get("OPENAI_API_KEY").and_then(Value::as_str),
|
||||
Some("sk-extra")
|
||||
@@ -214,7 +222,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn account_id_falls_back_to_jwt_claim() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// tokens.account_id を欠落させ、id_token JWT 内 claim から拾う
|
||||
// Omit tokens.account_id and obtain it from a claim in the id_token JWT
|
||||
let id_token = "h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiZnJvbS1qd3QifX0.s";
|
||||
let path = write_auth_json(
|
||||
dir.path(),
|
||||
@@ -243,13 +251,19 @@ mod tests {
|
||||
"agent_identity":{"workspace_id":"w","agent_runtime_id":"r","agent_private_key":"k","registered_at":"x"}
|
||||
}"#,
|
||||
);
|
||||
let updated =
|
||||
persist_refreshed(&path, None, Some("new-acc".into()), Some("new-ref".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
let input = load(&path).await.unwrap();
|
||||
let updated = persist_refreshed(
|
||||
&path,
|
||||
&input,
|
||||
None,
|
||||
Some("new-acc".into()),
|
||||
Some("new-ref".into()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated.access_token, "new-acc");
|
||||
assert_eq!(updated.refresh_token, "new-ref");
|
||||
// 未知フィールド agent_identity が保たれる
|
||||
// The unknown field agent_identity is preserved
|
||||
let on_disk: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert!(on_disk.get("agent_identity").is_some());
|
||||
assert!(on_disk.get("last_refresh").is_some());
|
||||
@@ -264,9 +278,10 @@ mod tests {
|
||||
dir.path(),
|
||||
r#"{"tokens":{"id_token":"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYSJ9fQ.s","access_token":"a","refresh_token":"r","account_id":"a"}}"#,
|
||||
);
|
||||
// 既存ファイルを 644 に変えてから persist → 600 に直るか
|
||||
// Change the existing file to 644, then persist → check that it returns to 600
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
persist_refreshed(&path, None, Some("a2".into()), None)
|
||||
let input = load(&path).await.unwrap();
|
||||
persist_refreshed(&path, &input, None, Some("a2".into()), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Codex OAuth サブモジュール内部のエラー型。
|
||||
//! Internal error types for the Codex OAuth submodule.
|
||||
//!
|
||||
//! `LlmClient` 境界に渡す際は `to_client_error` で `ClientError` に
|
||||
//! 変換する。`Permanent` 系(refresh_token 失効)は `codex login` の
|
||||
//! 再実行案内付きメッセージにする。
|
||||
//! Convert to `ClientError` using `to_client_error` at the `LlmClient`
|
||||
//! boundary. `Permanent` errors (invalidated refresh_token) include
|
||||
//! a message instructing the user to run `codex login` again.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -25,7 +25,7 @@ pub enum CodexAuthError {
|
||||
#[error("token refresh failed (transient): {0}")]
|
||||
RefreshTransient(String),
|
||||
|
||||
/// refresh_token が永続的に失効。再ログインが必要。
|
||||
/// refresh_token is permanently invalidated. Logging in again is required.
|
||||
#[error("token refresh failed permanently ({reason:?}): {message}")]
|
||||
RefreshPermanent {
|
||||
reason: PermanentReason,
|
||||
@@ -45,7 +45,7 @@ pub enum PermanentReason {
|
||||
}
|
||||
|
||||
impl CodexAuthError {
|
||||
/// `LlmClient` トランスポート境界向けに変換する。
|
||||
/// Convert for the `LlmClient` transport boundary.
|
||||
pub fn to_client_error(self) -> ClientError {
|
||||
match self {
|
||||
CodexAuthError::NotLoggedIn(_)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
//! JWT payload の最小限のパース(署名検証なし)。
|
||||
//! Minimal JWT payload parsing (without signature verification).
|
||||
//!
|
||||
//! Codex CLI と同じく、access_token / id_token の payload を base64url
|
||||
//! デコードして `exp` や ChatGPT 固有 claims を取り出すためだけに使う。
|
||||
//! As in Codex CLI, used only to base64url-decode access_token / id_token
|
||||
//! payloads and extract `exp` and ChatGPT-specific claims.
|
||||
|
||||
use base64::Engine;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// `Authorization: Bearer` で送る access_token JWT の `exp` を読む。
|
||||
/// Read `exp` from the access_token JWT sent in `Authorization: Bearer`.
|
||||
pub fn parse_exp(jwt: &str) -> Option<DateTime<Utc>> {
|
||||
#[derive(Deserialize)]
|
||||
struct Claims {
|
||||
@@ -17,7 +17,7 @@ pub fn parse_exp(jwt: &str) -> Option<DateTime<Utc>> {
|
||||
DateTime::<Utc>::from_timestamp(claims.exp?, 0)
|
||||
}
|
||||
|
||||
/// id_token JWT から ChatGPT 固有 claims を取り出す。
|
||||
/// Extract ChatGPT-specific claims from the id_token JWT.
|
||||
pub fn parse_chatgpt_claims(jwt: &str) -> Option<ChatGptClaims> {
|
||||
#[derive(Deserialize)]
|
||||
struct IdClaims {
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
//! ChatGPT OAuth (`~/.codex/auth.json`) を読んで `Authorization` /
|
||||
//! `ChatGPT-Account-Id` / `X-OpenAI-Fedramp` ヘッダを組み立てる
|
||||
//! [`AuthProvider`] 実装。
|
||||
//! [`AuthProvider`] implementation that reads ChatGPT OAuth credentials
|
||||
//! (`~/.codex/auth.json`) and builds `Authorization` /
|
||||
//! `ChatGPT-Account-Id` / `X-OpenAI-Fedramp` headers.
|
||||
//!
|
||||
//! 設計:
|
||||
//! Design:
|
||||
//!
|
||||
//! - HTTP transport は [`AuthProvider`] trait だけを見て、実体である
|
||||
//! [`CodexAuthProvider`] はこの optional module に置く
|
||||
//! - access_token JWT の `exp` を読み、`now` 以下で proactive refresh
|
||||
//! (Codex CLI と同じバッファなし)
|
||||
//! - 並行する Codex CLI / 別プロセスの refresh と取り違えないよう、
|
||||
//! refresh 直前に再 load して account_id 一致を確認(guarded reload)
|
||||
//! - ファイルロックは取らず、書込前に再 load + diff merge で吸収
|
||||
//! - Codex の Keyring storage は対象外。auth.json 不在ならエラーで案内
|
||||
//! - The HTTP transport sees only the [`AuthProvider`] trait; the concrete
|
||||
//! [`CodexAuthProvider`] lives in this optional module
|
||||
//! - Read the access_token JWT's `exp` and proactively refresh when `exp` <= `now`
|
||||
//! (no buffer, as in Codex CLI)
|
||||
//! - Reload immediately before refreshing and check that account_id matches
|
||||
//! to avoid confusing concurrent Codex CLI / other-process refreshes (guarded reload)
|
||||
//! - No file locking; reload and merge differences before writing
|
||||
//! - Codex Keyring storage is not supported. If auth.json is missing, return an error with guidance
|
||||
|
||||
mod auth_json;
|
||||
mod error;
|
||||
mod jwt;
|
||||
mod refresh;
|
||||
#[cfg(test)]
|
||||
mod refresh_tests;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -32,14 +34,17 @@ use error::CodexAuthError;
|
||||
|
||||
pub use error::PermanentReason;
|
||||
|
||||
/// Codex CLI の `last_refresh` ベース fallback 期限(Codex CLI 準拠で 8 日)。
|
||||
/// Codex CLI's `last_refresh`-based fallback interval (8 days, matching Codex CLI).
|
||||
const TOKEN_REFRESH_INTERVAL_DAYS: i64 = 8;
|
||||
|
||||
/// `~/.codex/auth.json` を読んで Codex 互換のヘッダを返す provider。
|
||||
/// Provider that reads `~/.codex/auth.json` and returns Codex-compatible headers.
|
||||
pub struct CodexAuthProvider {
|
||||
auth_path: PathBuf,
|
||||
refresh_endpoint: String,
|
||||
http: reqwest::Client,
|
||||
refresh_limits: refresh::RefreshLimits,
|
||||
#[cfg(test)]
|
||||
before_guarded_reload: Option<Arc<tokio::sync::Barrier>>,
|
||||
state: Arc<Mutex<State>>,
|
||||
}
|
||||
|
||||
@@ -58,7 +63,7 @@ struct State {
|
||||
}
|
||||
|
||||
impl CodexAuthProvider {
|
||||
/// `CODEX_HOME` → `$HOME/.codex` の順で auth.json の場所を決める。
|
||||
/// Locate auth.json using `CODEX_HOME`, then `$HOME/.codex` as a fallback.
|
||||
pub fn from_default_home() -> Result<Self, ClientError> {
|
||||
let codex_home = if let Ok(p) = std::env::var("CODEX_HOME") {
|
||||
PathBuf::from(p)
|
||||
@@ -70,24 +75,27 @@ impl CodexAuthProvider {
|
||||
Ok(Self::new(codex_home))
|
||||
}
|
||||
|
||||
/// 任意の codex_home から構築する。
|
||||
/// Construct from a custom codex_home.
|
||||
pub fn new(codex_home: PathBuf) -> Self {
|
||||
Self {
|
||||
auth_path: codex_home.join("auth.json"),
|
||||
refresh_endpoint: refresh::REFRESH_URL.to_string(),
|
||||
http: reqwest::Client::new(),
|
||||
refresh_limits: refresh::RefreshLimits::default(),
|
||||
#[cfg(test)]
|
||||
before_guarded_reload: None,
|
||||
state: Arc::new(Mutex::new(State::default())),
|
||||
}
|
||||
}
|
||||
|
||||
/// テスト用に refresh エンドポイントを差し替える。
|
||||
/// Override the refresh endpoint for tests.
|
||||
#[cfg(test)]
|
||||
fn with_refresh_endpoint(mut self, endpoint: impl Into<String>) -> Self {
|
||||
self.refresh_endpoint = endpoint.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// テスト用に HTTP クライアントを差し替える。
|
||||
/// Override the HTTP client for tests.
|
||||
#[cfg(test)]
|
||||
fn with_http_client(mut self, client: reqwest::Client) -> Self {
|
||||
self.http = client;
|
||||
@@ -97,39 +105,46 @@ impl CodexAuthProvider {
|
||||
async fn ensure_fresh(&self) -> Result<AuthSnapshot, CodexAuthError> {
|
||||
let mut state = self.state.lock().await;
|
||||
|
||||
// 1. 常にディスクから最新を読む(他プロセスの更新を反映)
|
||||
// 1. Always read the latest from disk (reflect updates by other processes)
|
||||
let snap = auth_json::load(&self.auth_path).await?;
|
||||
|
||||
// 2. stale でなければそのまま返す
|
||||
// 2. Return as-is if not stale
|
||||
if !is_stale(&snap) {
|
||||
state.cached = Some(snap.clone());
|
||||
return Ok(snap);
|
||||
}
|
||||
|
||||
// 3. Refresh 直前に再 load し account_id 一致を確認(guarded reload)。
|
||||
// 一致しなければ他プロセスが先に更新済 → 自分は refresh しない
|
||||
// 3. Reload immediately before refreshing (guarded reload).
|
||||
// Skip refresh if another process supplied fresh tokens or changed account_id.
|
||||
#[cfg(test)]
|
||||
if let Some(barrier) = &self.before_guarded_reload {
|
||||
barrier.wait().await;
|
||||
barrier.wait().await;
|
||||
}
|
||||
let pre_refresh = auth_json::load(&self.auth_path).await?;
|
||||
if !is_stale(&pre_refresh) {
|
||||
state.cached = Some(pre_refresh.clone());
|
||||
return Ok(pre_refresh);
|
||||
}
|
||||
if pre_refresh.account_id != snap.account_id {
|
||||
// account が切り替わった → 新しい auth を採用、refresh はしない
|
||||
// The account has changed → use the new auth without refreshing
|
||||
state.cached = Some(pre_refresh.clone());
|
||||
return Ok(pre_refresh);
|
||||
}
|
||||
|
||||
// 4. Refresh 実行
|
||||
// 4. Perform the refresh
|
||||
let refreshed = refresh::request_refresh(
|
||||
&self.http,
|
||||
&self.refresh_endpoint,
|
||||
&pre_refresh.refresh_token,
|
||||
&self.refresh_limits,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 5. 書き戻し(書込前に再 load + merge は persist_refreshed 内で実施)
|
||||
// 5. Write back (persist_refreshed reloads and merges before writing)
|
||||
let new_snap = auth_json::persist_refreshed(
|
||||
&self.auth_path,
|
||||
&pre_refresh,
|
||||
refreshed.id_token,
|
||||
refreshed.access_token,
|
||||
refreshed.refresh_token,
|
||||
@@ -152,9 +167,9 @@ impl CodexAuthProvider {
|
||||
.map_err(|e| CodexAuthError::InvalidHeader(format!("ChatGPT-Account-Id: {e}")))?;
|
||||
out.push((HeaderName::from_static("chatgpt-account-id"), acc_val));
|
||||
|
||||
// Cloudflare WAF は互換 backend アクセス元を `originator` /
|
||||
// `User-Agent` で識別する。Codex CLI が送る固定値を流用しないと
|
||||
// HTML challenge (403) を返されて SSE に到達できない。
|
||||
// Cloudflare WAF identifies clients accessing the compatible backend by `originator` /
|
||||
// `User-Agent`. Without the fixed values sent by Codex CLI,
|
||||
// it returns an HTML challenge (403), preventing access to SSE.
|
||||
out.push((
|
||||
HeaderName::from_static("originator"),
|
||||
HeaderValue::from_static("codex_cli_rs"),
|
||||
@@ -164,7 +179,7 @@ impl CodexAuthProvider {
|
||||
HeaderValue::from_static("codex_cli_rs/0.60.0"),
|
||||
));
|
||||
|
||||
// FedRAMP 組織は id_token JWT 内の claim で判定
|
||||
// Identify FedRAMP organizations using a claim in the id_token JWT
|
||||
if jwt::parse_chatgpt_claims(&snap.id_token)
|
||||
.map(|c| c.is_fedramp)
|
||||
.unwrap_or(false)
|
||||
@@ -190,8 +205,8 @@ impl AuthProvider for CodexAuthProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// `access_token` の JWT `exp` を見て、期限切れなら true。
|
||||
/// `exp` が読めない場合は `last_refresh + 8 日` で判定(Codex CLI と同じ)。
|
||||
/// Return true if the `access_token` JWT's `exp` indicates expiration.
|
||||
/// If `exp` cannot be read, use `last_refresh + 8 days` (as in Codex CLI).
|
||||
fn is_stale(snap: &AuthSnapshot) -> bool {
|
||||
if let Some(exp) = jwt::parse_exp(&snap.access_token) {
|
||||
return exp <= Utc::now();
|
||||
@@ -210,13 +225,18 @@ mod tests {
|
||||
use wiremock::matchers::{method, path as url_path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn make_jwt(payload: serde_json::Value) -> String {
|
||||
pub(super) fn make_jwt(payload: serde_json::Value) -> String {
|
||||
let p = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(serde_json::to_vec(&payload).unwrap());
|
||||
format!("h.{p}.s")
|
||||
}
|
||||
|
||||
fn write_auth(dir: &std::path::Path, exp: i64, fedramp: bool, refresh: &str) -> PathBuf {
|
||||
pub(super) fn write_auth(
|
||||
dir: &std::path::Path,
|
||||
exp: i64,
|
||||
fedramp: bool,
|
||||
refresh: &str,
|
||||
) -> PathBuf {
|
||||
let id_token = make_jwt(json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acc-xyz",
|
||||
@@ -241,7 +261,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn returns_headers_for_fresh_token() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// 期限を遠い未来に
|
||||
// Set expiration far in the future
|
||||
write_auth(dir.path(), Utc::now().timestamp() + 3600, false, "rt");
|
||||
let provider = CodexAuthProvider::new(dir.path().to_path_buf());
|
||||
|
||||
@@ -285,7 +305,7 @@ mod tests {
|
||||
"old-refresh",
|
||||
);
|
||||
|
||||
// refresh エンドポイントを mock。新しい JWT (将来 exp) を返す
|
||||
// Mock the refresh endpoint to return a new JWT (future exp)
|
||||
let server = MockServer::start().await;
|
||||
let new_access = make_jwt(json!({ "exp": Utc::now().timestamp() + 3600 }));
|
||||
let new_refresh = "new-refresh-token";
|
||||
@@ -310,7 +330,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(auth.1.to_str().unwrap(), format!("Bearer {new_access}"));
|
||||
|
||||
// ファイルに新しい refresh_token が書き戻されている
|
||||
// The new refresh_token has been written back to the file
|
||||
let on_disk: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! ChatGPT OAuth トークンの refresh HTTP 呼出。
|
||||
//! HTTP calls to refresh ChatGPT OAuth tokens.
|
||||
//!
|
||||
//! Codex CLI と同じ `POST https://auth.openai.com/oauth/token` 形式。
|
||||
//! 401 + `error.code` で永続失敗を分類する。
|
||||
//! Uses the same `POST https://auth.openai.com/oauth/token` format as Codex CLI.
|
||||
//! Classifies permanent failures using 401 + `error.code`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
@@ -10,7 +10,26 @@ use super::error::{CodexAuthError, PermanentReason};
|
||||
|
||||
pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
pub const REFRESH_URL: &str = "https://auth.openai.com/oauth/token";
|
||||
pub const DEFAULT_REFRESH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Limits cover send, both response bodies, and decode, not auth file I/O.
|
||||
/// OAuth responses contain only a few tokens; 64 KiB leaves ample JWT headroom.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct RefreshLimits {
|
||||
pub timeout: Duration,
|
||||
pub max_body_bytes: usize,
|
||||
#[cfg(test)]
|
||||
pub body_started: Option<std::sync::Arc<tokio::sync::Notify>>,
|
||||
}
|
||||
|
||||
impl Default for RefreshLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
timeout: Duration::from_secs(30),
|
||||
max_body_bytes: 64 * 1024,
|
||||
#[cfg(test)]
|
||||
body_started: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshRequest<'a> {
|
||||
@@ -29,62 +48,102 @@ pub struct RefreshResponse {
|
||||
pub refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
/// refresh_token を使って新しいトークン群を取得する。
|
||||
/// Obtain new tokens using refresh_token.
|
||||
///
|
||||
/// 永続失敗(401 + `refresh_token_(expired|reused|invalidated)`)は
|
||||
/// `RefreshPermanent`、それ以外は `RefreshTransient`。
|
||||
/// Permanent failures (401 + `refresh_token_(expired|reused|invalidated)`) return
|
||||
/// `RefreshPermanent`; all others return `RefreshTransient`.
|
||||
pub async fn request_refresh(
|
||||
client: &reqwest::Client,
|
||||
endpoint: &str,
|
||||
refresh_token: &str,
|
||||
limits: &RefreshLimits,
|
||||
) -> Result<RefreshResponse, CodexAuthError> {
|
||||
let deadline = tokio::time::Instant::now() + limits.timeout;
|
||||
let result = tokio::time::timeout_at(
|
||||
deadline,
|
||||
request_refresh_bounded(client, endpoint, refresh_token, limits),
|
||||
)
|
||||
.await;
|
||||
// JSON decode is synchronous. The byte bound limits its work, and this
|
||||
// post-check prevents a decode that crosses the deadline from being persisted.
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(CodexAuthError::RefreshTransient(
|
||||
"oauth_token_refresh timed out".into(),
|
||||
));
|
||||
}
|
||||
result.map_err(|_| CodexAuthError::RefreshTransient("oauth_token_refresh timed out".into()))?
|
||||
}
|
||||
|
||||
async fn request_refresh_bounded(
|
||||
client: &reqwest::Client,
|
||||
endpoint: &str,
|
||||
refresh_token: &str,
|
||||
limits: &RefreshLimits,
|
||||
) -> Result<RefreshResponse, CodexAuthError> {
|
||||
let body = RefreshRequest {
|
||||
client_id: CLIENT_ID,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token,
|
||||
};
|
||||
let response = response_with_timeout(
|
||||
client
|
||||
.post(endpoint)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send(),
|
||||
DEFAULT_REFRESH_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
let mut response = client
|
||||
.post(endpoint)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| CodexAuthError::RefreshTransient("send failed".into()))?;
|
||||
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
response
|
||||
.json::<RefreshResponse>()
|
||||
.await
|
||||
.map_err(|e| CodexAuthError::RefreshTransient(format!("parse response: {e}")))
|
||||
} else {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let (reason, message) = classify_permanent(&body);
|
||||
Err(CodexAuthError::RefreshPermanent { reason, message })
|
||||
} else {
|
||||
Err(CodexAuthError::RefreshTransient(format!(
|
||||
"{status}: {body}"
|
||||
)))
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|len| len > limits.max_body_bytes as u64)
|
||||
{
|
||||
return Err(body_too_large());
|
||||
}
|
||||
#[cfg(test)]
|
||||
if let Some(started) = &limits.body_started {
|
||||
started.notify_one();
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|_| CodexAuthError::RefreshTransient("read response body failed".into()))?
|
||||
{
|
||||
// Content-Length can be absent or untrustworthy. Bound actual bytes too,
|
||||
// before copying a chunk into the accumulated body.
|
||||
if chunk.len() > limits.max_body_bytes.saturating_sub(bytes.len()) {
|
||||
return Err(body_too_large());
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
if status.is_success() {
|
||||
let tokens: RefreshResponse = serde_json::from_slice(&bytes).map_err(|_| {
|
||||
// Do not expose untrusted body values (which can contain secrets).
|
||||
CodexAuthError::RefreshTransient("decode response failed".into())
|
||||
})?;
|
||||
if tokens.access_token.as_deref().is_none_or(str::is_empty)
|
||||
|| tokens.id_token.as_deref().is_some_and(str::is_empty)
|
||||
|| tokens.refresh_token.as_deref().is_some_and(str::is_empty)
|
||||
{
|
||||
return Err(CodexAuthError::RefreshTransient(
|
||||
"incomplete token response".into(),
|
||||
));
|
||||
}
|
||||
Ok(tokens)
|
||||
} else if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let (reason, message) = classify_permanent(&String::from_utf8_lossy(&bytes));
|
||||
Err(CodexAuthError::RefreshPermanent { reason, message })
|
||||
} else {
|
||||
// Error bodies are consumed within the same limits but never echoed.
|
||||
Err(CodexAuthError::RefreshTransient(format!(
|
||||
"refresh endpoint returned {status}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn response_with_timeout(
|
||||
future: impl std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
|
||||
timeout: Duration,
|
||||
) -> Result<reqwest::Response, CodexAuthError> {
|
||||
tokio::time::timeout(timeout, future)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
CodexAuthError::RefreshTransient(format!(
|
||||
"oauth_token_refresh timed out after {}s",
|
||||
timeout.as_secs()
|
||||
))
|
||||
})?
|
||||
.map_err(|e| CodexAuthError::RefreshTransient(format!("send: {e}")))
|
||||
fn body_too_large() -> CodexAuthError {
|
||||
CodexAuthError::RefreshTransient("response body exceeds size limit".into())
|
||||
}
|
||||
|
||||
fn classify_permanent(body: &str) -> (PermanentReason, String) {
|
||||
@@ -99,7 +158,7 @@ fn classify_permanent(body: &str) -> (PermanentReason, String) {
|
||||
PermanentReason::Expired => "Your refresh token has expired".to_string(),
|
||||
PermanentReason::Reused => "Your refresh token was already used".to_string(),
|
||||
PermanentReason::Revoked => "Your refresh token was revoked".to_string(),
|
||||
PermanentReason::Other => format!("Unknown 401 from refresh endpoint: {body}"),
|
||||
PermanentReason::Other => "Unknown 401 from refresh endpoint".to_string(),
|
||||
};
|
||||
(reason, message)
|
||||
}
|
||||
@@ -126,23 +185,6 @@ fn extract_error_code(body: &str) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_response_timeout_is_transient() {
|
||||
let err = match response_with_timeout(
|
||||
std::future::pending::<Result<reqwest::Response, reqwest::Error>>(),
|
||||
Duration::from_millis(5),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("expected refresh timeout"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
assert!(
|
||||
matches!(err, CodexAuthError::RefreshTransient(message) if message.contains("timed out"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_expired() {
|
||||
let body = r#"{"error":{"code":"refresh_token_expired"}}"#;
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
//! Real AuthProvider calls against owned auth files and a loopback HTTP peer.
|
||||
//! Gates place callers at response-body / reload boundaries; paused Tokio time
|
||||
//! advances the injected HTTP deadline only after headers have been received.
|
||||
use super::tests::{make_jwt, write_auth};
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Duration as StdDuration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{Notify, mpsc};
|
||||
use tokio::task::{JoinHandle, JoinSet};
|
||||
|
||||
struct Reply {
|
||||
status: u16,
|
||||
body: Vec<u8>,
|
||||
chunked: bool,
|
||||
gate: Option<Arc<Notify>>,
|
||||
truncate: bool,
|
||||
}
|
||||
|
||||
impl Reply {
|
||||
fn body(status: u16, body: impl Into<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
status,
|
||||
body: body.into(),
|
||||
chunked: false,
|
||||
gate: None,
|
||||
truncate: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn gated(status: u16, body: impl Into<Vec<u8>>, gate: &Arc<Notify>) -> Self {
|
||||
Self {
|
||||
gate: Some(gate.clone()),
|
||||
..Self::body(status, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Stub {
|
||||
endpoint: String,
|
||||
requests: mpsc::UnboundedReceiver<Value>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Drop for Stub {
|
||||
fn drop(&mut self) {
|
||||
// Dropping the server's JoinSet also aborts stalled connection handlers.
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
impl Stub {
|
||||
async fn start(replies: Vec<Reply>) -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let endpoint = format!("http://{}/oauth/token", listener.local_addr().unwrap());
|
||||
let (tx, requests) = mpsc::unbounded_channel();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut handlers = JoinSet::new();
|
||||
for reply in replies {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let tx = tx.clone();
|
||||
handlers.spawn(async move {
|
||||
let mut request = Vec::new();
|
||||
let header_end = loop {
|
||||
let byte = stream.read_u8().await.unwrap();
|
||||
request.push(byte);
|
||||
if request.ends_with(b"\r\n\r\n") { break request.len(); }
|
||||
assert!(request.len() < 16 * 1024, "fixture request headers too large");
|
||||
};
|
||||
let headers = std::str::from_utf8(&request).unwrap();
|
||||
assert!(headers.starts_with("POST /oauth/token HTTP/1.1\r\n"));
|
||||
let len: usize = headers.lines().find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse().unwrap())
|
||||
}).unwrap();
|
||||
request.resize(header_end + len, 0);
|
||||
stream.read_exact(&mut request[header_end..]).await.unwrap();
|
||||
tx.send(serde_json::from_slice(&request[header_end..]).unwrap()).unwrap();
|
||||
let framing = if reply.chunked {
|
||||
"Transfer-Encoding: chunked".to_string()
|
||||
} else {
|
||||
format!("Content-Length: {}", reply.body.len())
|
||||
};
|
||||
stream.write_all(format!(
|
||||
"HTTP/1.1 {} Fixture\r\nContent-Type: application/json\r\n{framing}\r\nConnection: close\r\n\r\n", reply.status
|
||||
).as_bytes()).await.unwrap();
|
||||
if let Some(gate) = reply.gate { gate.notified().await; }
|
||||
let body = if reply.truncate { &reply.body[..reply.body.len() / 2] } else { &reply.body[..] };
|
||||
if reply.chunked {
|
||||
for chunk in body.chunks(512) {
|
||||
let _ = stream.write_all(format!("{:x}\r\n", chunk.len()).as_bytes()).await;
|
||||
let _ = stream.write_all(chunk).await;
|
||||
let _ = stream.write_all(b"\r\n").await;
|
||||
}
|
||||
let _ = stream.write_all(b"0\r\n\r\n").await;
|
||||
} else {
|
||||
let _ = stream.write_all(body).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
while let Some(result) = handlers.join_next().await {
|
||||
result.unwrap();
|
||||
}
|
||||
});
|
||||
Self {
|
||||
endpoint,
|
||||
requests,
|
||||
task,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rotated_body() -> Vec<u8> {
|
||||
serde_json::to_vec(&json!({
|
||||
"access_token": make_jwt(json!({"exp": Utc::now().timestamp() + 3600})),
|
||||
"refresh_token": "fixture-rotated-secret",
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn provider(dir: &std::path::Path, stub: &Stub, started: &Arc<Notify>) -> Arc<CodexAuthProvider> {
|
||||
let mut provider = CodexAuthProvider::new(dir.to_path_buf())
|
||||
.with_refresh_endpoint(&stub.endpoint)
|
||||
.with_http_client(reqwest::Client::builder().no_proxy().build().unwrap());
|
||||
provider.refresh_limits = refresh::RefreshLimits {
|
||||
timeout: StdDuration::from_secs(10),
|
||||
max_body_bytes: 1024,
|
||||
body_started: Some(started.clone()),
|
||||
};
|
||||
Arc::new(provider)
|
||||
}
|
||||
|
||||
async fn bounded<F: std::future::Future>(future: F) -> F::Output {
|
||||
tokio::time::timeout(StdDuration::from_secs(5), future)
|
||||
.await
|
||||
.expect("fixture/caller did not progress")
|
||||
}
|
||||
|
||||
fn assert_rotated(path: &std::path::Path, headers: &[(HeaderName, HeaderValue)]) {
|
||||
let value: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||
assert_eq!(value["tokens"]["refresh_token"], "fixture-rotated-secret");
|
||||
assert_eq!(value["fixture_extra"], "preserve me");
|
||||
let auth = headers
|
||||
.iter()
|
||||
.find(|(name, _)| name == "authorization")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
auth.1.to_str().unwrap(),
|
||||
format!(
|
||||
"Bearer {}",
|
||||
value["tokens"]["access_token"].as_str().unwrap()
|
||||
)
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn stale_auth(dir: &std::path::Path) -> PathBuf {
|
||||
let path = write_auth(
|
||||
dir,
|
||||
Utc::now().timestamp() - 60,
|
||||
false,
|
||||
"fixture-old-secret",
|
||||
);
|
||||
let mut value: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
value["fixture_extra"] = json!("preserve me");
|
||||
std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stalled_success_and_error_bodies_timeout_without_persist_and_release_waiting_caller() {
|
||||
for status in [200, 401, 503] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let gate = Arc::new(Notify::new());
|
||||
let started = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![
|
||||
Reply::gated(status, b"unfinished".to_vec(), &gate),
|
||||
Reply::body(200, rotated_body()),
|
||||
])
|
||||
.await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let first = tokio::spawn({
|
||||
let provider = provider.clone();
|
||||
async move { provider.headers().await }
|
||||
});
|
||||
bounded(started.notified()).await; // provider has received headers and is consuming the body
|
||||
let request = bounded(stub.requests.recv()).await.unwrap();
|
||||
assert_eq!(request["refresh_token"], "fixture-old-secret");
|
||||
let second = provider.headers();
|
||||
tokio::pin!(second);
|
||||
assert!(futures::poll!(&mut second).is_pending());
|
||||
assert!(provider.state.try_lock().is_err());
|
||||
assert!(
|
||||
stub.requests.try_recv().is_err(),
|
||||
"waiter must not refresh concurrently"
|
||||
);
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(StdDuration::from_secs(11)).await;
|
||||
let error = first.await.unwrap().unwrap_err();
|
||||
tokio::time::resume();
|
||||
assert!(error.to_string().contains("timed out"), "{error}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
let headers = bounded(second).await.unwrap();
|
||||
assert_rotated(&path, &headers);
|
||||
let retry = bounded(stub.requests.recv()).await.unwrap();
|
||||
assert_eq!(retry["refresh_token"], "fixture-old-secret");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_malformed_and_incomplete_bodies_do_not_persist_and_allow_later_refresh() {
|
||||
let mut cases = vec![
|
||||
(Reply::body(200, vec![b'x'; 1025]), "size limit"),
|
||||
(
|
||||
Reply {
|
||||
chunked: true,
|
||||
..Reply::body(200, vec![b'x'; 1025])
|
||||
},
|
||||
"size limit",
|
||||
),
|
||||
(
|
||||
Reply {
|
||||
chunked: true,
|
||||
..Reply::body(401, vec![b'x'; 1025])
|
||||
},
|
||||
"size limit",
|
||||
),
|
||||
(Reply::body(503, vec![b'x'; 1025]), "size limit"),
|
||||
(
|
||||
Reply::body(
|
||||
200,
|
||||
br#"{"access_token": {"secret":"fixture-body-secret"}}"#.to_vec(),
|
||||
),
|
||||
"decode",
|
||||
),
|
||||
(Reply::body(200, b"{".to_vec()), "decode"),
|
||||
(Reply::body(200, b"{}".to_vec()), "incomplete"),
|
||||
(
|
||||
Reply::body(
|
||||
200,
|
||||
br#"{"access_token":"","refresh_token":"fixture-body-secret"}"#.to_vec(),
|
||||
),
|
||||
"incomplete",
|
||||
),
|
||||
(
|
||||
Reply {
|
||||
truncate: true,
|
||||
..Reply::body(200, rotated_body())
|
||||
},
|
||||
"read response body",
|
||||
),
|
||||
(
|
||||
Reply {
|
||||
truncate: true,
|
||||
..Reply::body(401, b"fixture-body-secret".to_vec())
|
||||
},
|
||||
"read response body",
|
||||
),
|
||||
(
|
||||
Reply::body(401, b"fixture-body-secret".to_vec()),
|
||||
"codex login",
|
||||
),
|
||||
(Reply::body(503, b"fixture-body-secret".to_vec()), "503"),
|
||||
];
|
||||
for (reply, expected) in cases.drain(..) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let started = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![reply, Reply::body(200, rotated_body())]).await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let error = bounded(provider.headers()).await.unwrap_err();
|
||||
let message = error.to_string();
|
||||
assert!(message.contains(expected), "expected {expected}: {message}");
|
||||
assert!(!message.contains("fixture-body-secret"));
|
||||
assert!(!message.contains("fixture-old-secret"));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
let headers = bounded(provider.headers()).await.unwrap();
|
||||
assert_rotated(&path, &headers);
|
||||
for _ in 0..2 {
|
||||
assert_eq!(
|
||||
bounded(stub.requests.recv()).await.unwrap()["refresh_token"],
|
||||
"fixture-old-secret"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_body_wait_leaves_auth_unchanged_and_releases_waiting_caller() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let started = Arc::new(Notify::new());
|
||||
let gate = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![
|
||||
Reply::gated(200, rotated_body(), &gate),
|
||||
Reply::body(200, rotated_body()),
|
||||
])
|
||||
.await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let first = tokio::spawn({
|
||||
let provider = provider.clone();
|
||||
async move { provider.headers().await }
|
||||
});
|
||||
bounded(started.notified()).await;
|
||||
bounded(stub.requests.recv()).await.unwrap();
|
||||
let second = provider.headers();
|
||||
tokio::pin!(second);
|
||||
assert!(futures::poll!(&mut second).is_pending());
|
||||
assert!(stub.requests.try_recv().is_err());
|
||||
first.abort();
|
||||
assert!(first.await.unwrap_err().is_cancelled());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
assert_rotated(&path, &bounded(second).await.unwrap());
|
||||
assert_eq!(
|
||||
bounded(stub.requests.recv()).await.unwrap()["refresh_token"],
|
||||
"fixture-old-secret"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_callers_refresh_once_and_use_persisted_rotated_tokens() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let started = Arc::new(Notify::new());
|
||||
let gate = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![Reply::gated(200, rotated_body(), &gate)]).await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let first = tokio::spawn({
|
||||
let provider = provider.clone();
|
||||
async move { provider.headers().await }
|
||||
});
|
||||
bounded(started.notified()).await;
|
||||
let second = provider.headers();
|
||||
tokio::pin!(second);
|
||||
assert!(futures::poll!(&mut second).is_pending());
|
||||
assert_eq!(
|
||||
bounded(stub.requests.recv()).await.unwrap()["refresh_token"],
|
||||
"fixture-old-secret"
|
||||
);
|
||||
let mut external: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
external["concurrent_extra"] = json!("merge me");
|
||||
std::fs::write(&path, serde_json::to_vec(&external).unwrap()).unwrap();
|
||||
gate.notify_one();
|
||||
let first_headers = bounded(first).await.unwrap().unwrap();
|
||||
let second_headers = bounded(second).await.unwrap();
|
||||
assert_eq!(first_headers, second_headers);
|
||||
assert_rotated(&path, &second_headers);
|
||||
let persisted: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(persisted["concurrent_extra"], "merge me");
|
||||
assert!(
|
||||
stub.requests.try_recv().is_err(),
|
||||
"single-flight must make only one request"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_switch_before_guarded_reload_skips_old_account_refresh() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let barrier = Arc::new(tokio::sync::Barrier::new(2));
|
||||
let mut provider = CodexAuthProvider::new(dir.path().to_path_buf());
|
||||
let mut stub = Stub::start(vec![]).await;
|
||||
provider.refresh_endpoint = stub.endpoint.clone();
|
||||
provider.before_guarded_reload = Some(barrier.clone());
|
||||
let task = tokio::spawn(async move { provider.headers().await });
|
||||
bounded(barrier.wait()).await;
|
||||
let mut switched: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
switched["tokens"]["account_id"] = json!("switched-account");
|
||||
switched["tokens"]["refresh_token"] = json!("fixture-switched-secret");
|
||||
std::fs::write(&path, serde_json::to_vec(&switched).unwrap()).unwrap();
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
barrier.wait().await;
|
||||
let headers = bounded(task).await.unwrap().unwrap();
|
||||
assert!(
|
||||
headers
|
||||
.iter()
|
||||
.any(|(name, value)| name == "chatgpt-account-id" && value == "switched-account")
|
||||
);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
assert!(stub.requests.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_or_token_switch_during_refresh_is_not_overwritten_by_old_response() {
|
||||
for account_changed in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let started = Arc::new(Notify::new());
|
||||
let gate = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![Reply::gated(200, rotated_body(), &gate)]).await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let task = tokio::spawn({
|
||||
let provider = provider.clone();
|
||||
async move { provider.headers().await }
|
||||
});
|
||||
bounded(started.notified()).await;
|
||||
bounded(stub.requests.recv()).await.unwrap();
|
||||
let mut switched: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
if account_changed {
|
||||
switched["tokens"]["account_id"] = json!("switched-account");
|
||||
} else {
|
||||
switched["tokens"]["refresh_token"] = json!("fixture-switched-secret");
|
||||
}
|
||||
switched["tokens"]["access_token"] =
|
||||
json!(make_jwt(json!({"exp": Utc::now().timestamp() + 7200})));
|
||||
std::fs::write(&path, serde_json::to_vec(&switched).unwrap()).unwrap();
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
gate.notify_one();
|
||||
let headers = bounded(task).await.unwrap().unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
let expected = format!(
|
||||
"Bearer {}",
|
||||
switched["tokens"]["access_token"].as_str().unwrap()
|
||||
);
|
||||
assert!(headers.iter().any(|(name, value)| name == "authorization" && value.to_str().unwrap() == expected));
|
||||
assert_eq!(bounded(provider.headers()).await.unwrap(), headers);
|
||||
assert!(stub.requests.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
+33
-37
@@ -1,19 +1,17 @@
|
||||
//! Prune — context projection for old tool-result content.
|
||||
//! Request-only omission of details from old tool results.
|
||||
//!
|
||||
//! LLM 送信時のコンテキストから古い [`Item::ToolResult`] の `content` を
|
||||
//! 省略して、コンテキスト窓のトークンを回収する。`summary` は残すので
|
||||
//! 「何が起きたか」の痕跡は保たれる。
|
||||
//! [`prunable_indices`] selects candidates while protecting a token budget at the
|
||||
//! history tail; [`project`] removes detailed text and attachments. Summaries remain
|
||||
//! visible to the model. Projection operates on a request-history clone, leaving
|
||||
//! stored history and annotations unchanged.
|
||||
//!
|
||||
//! # 設計方針
|
||||
//! This module does not own token measurements. The host supplies prefix and savings
|
||||
//! estimates; unavailable estimates or invalid boundaries produce no candidates.
|
||||
//! The Engine also checks savings to avoid invalidating the cache for little benefit.
|
||||
//!
|
||||
//! Prune は **コンテキスト射影** であり、history の変換ではない。
|
||||
//! この crate が提供するのは pure な候補抽出 [`prunable_indices`] のみで、
|
||||
//! 射影の適用は LLM に送る一時コンテキストに対してだけ行う。
|
||||
//! Engine の永続履歴は決して変更されない。
|
||||
//!
|
||||
//! 保護境界は末尾 token budget で決めるが、この crate は usage 履歴を
|
||||
//! 所有しない。prefix ごとの token 推定値と savings 推定は上位層から
|
||||
//! callback で注入される。
|
||||
//! Compaction, which stores a summary as a new segment, is a separate responsibility.
|
||||
//! Omitting already-consumed images likewise affects only the request projection,
|
||||
//! never the stored results.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -36,27 +34,26 @@ pub type TokenEstimator = Box<dyn Fn(&[Item]) -> Vec<TokenEstimate> + Send + Syn
|
||||
/// Engine can make `min_savings` decisions without knowing about usage
|
||||
/// measurement sources. Return `0` to signal "no data / refuse to prune".
|
||||
///
|
||||
/// 推定対象は「drop する範囲全体」ではなく「content を None にする差分」
|
||||
/// であることに注意。item 自体(summary 等)は残るので、この callback は
|
||||
/// 実際の projection と一致する savings を返す必要がある。
|
||||
/// Estimate the savings from removing details, not from dropping entire items.
|
||||
/// Items and their summaries remain, so this callback must match the actual projection.
|
||||
pub type SavingsEstimator = Box<dyn Fn(&[Item], &[usize]) -> u64 + Send + Sync>;
|
||||
|
||||
/// Result of one prune evaluation pass, surfaced to the optional
|
||||
/// [`PruneObserver`] for instrumentation.
|
||||
///
|
||||
/// Engine は LLM リクエストごとに 1 回 prune の評価をし、その結果を
|
||||
/// (observer が登録されていれば)この値で通知する。fire/skip の判定
|
||||
/// 結果と、判定材料になった候補数 / 推定 savings / 保護領域の先頭 index を持つ。
|
||||
/// The Engine evaluates pruning once per LLM request and reports this value if
|
||||
/// an observer is registered. It includes the decision and its inputs: candidate
|
||||
/// count, estimated savings, and the start index of the protected suffix.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PruneEvaluation {
|
||||
/// `prunable_indices` の長さ。`Skipped::NoCandidates` の時は 0。
|
||||
/// Length of `prunable_indices`; zero for `SkippedNoCandidates`.
|
||||
pub candidate_count: usize,
|
||||
/// 推定された savings (tokens)。`NoCandidates` の時は 0。
|
||||
/// Estimated token savings; zero when there are no candidates.
|
||||
pub estimated_savings: u64,
|
||||
/// Token budget で保護される suffix の先頭 item index。
|
||||
/// usage 推定が `NoData` で境界が決まらない場合は `None`。
|
||||
/// First item in the suffix protected by the token budget.
|
||||
/// `None` when `NoData` estimates prevent determining the boundary.
|
||||
pub protected_start_index: Option<usize>,
|
||||
/// 判定結果。
|
||||
/// Evaluation outcome.
|
||||
pub decision: PruneDecision,
|
||||
}
|
||||
|
||||
@@ -64,13 +61,13 @@ pub struct PruneEvaluation {
|
||||
/// "fire vs skip" decision tree the Engine walks before each LLM request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PruneDecision {
|
||||
/// `prunable_indices` が空 → 何もしない。
|
||||
/// No projection: `prunable_indices` is empty.
|
||||
SkippedNoCandidates,
|
||||
/// 候補はあったが推定 savings が `min_savings` 未満 → 何もしない。
|
||||
/// No projection: candidates exist, but estimated savings are below `min_savings`.
|
||||
SkippedBelowMinSavings,
|
||||
/// 候補があり savings >= min_savings → projection を適用した。
|
||||
/// `pruned_count` は `project()` が実際に書き換えた item 数
|
||||
/// (既に content=None だった候補は 0 計上)。
|
||||
/// Applied projection because candidates exist and savings meet `min_savings`.
|
||||
/// `pruned_count` counts items actually changed by `project()`;
|
||||
/// candidates that already lack details contribute zero.
|
||||
Fired { pruned_count: usize },
|
||||
}
|
||||
|
||||
@@ -136,14 +133,13 @@ pub fn project(items: &mut [Item], indices: &[usize]) -> usize {
|
||||
/// Remove binary attachments after the first model request that could consume them.
|
||||
///
|
||||
/// Attachments on tool results following the latest model output are fresh outputs for the
|
||||
/// immediate follow-up request and remain visible. Once a later assistant/reasoning/tool-call
|
||||
/// item exists, the model has already had an opportunity to inspect the attachment. Subsequent
|
||||
/// requests keep the durable tool summary and can call the originating tool again when the image
|
||||
/// is still needed. Only the request-context clone is modified.
|
||||
/// immediate follow-up request and remain visible. With early dispatch, later
|
||||
/// text/calls in the same response have not consumed its tool results: use the
|
||||
/// confirmed response boundary, not that response's last individual item.
|
||||
/// Subsequent requests keep the durable tool summary and can call the originating
|
||||
/// tool again when the image is still needed. Only the request-context clone is modified.
|
||||
pub fn project_consumed_attachments(items: &mut [Item]) -> usize {
|
||||
let latest_model_output = items.iter().rposition(|item| {
|
||||
item.is_assistant_message() || item.is_reasoning() || item.is_tool_call()
|
||||
});
|
||||
let latest_model_output = crate::image_recovery::accepted_input_end(items.iter());
|
||||
let mut count = 0;
|
||||
for (index, item) in items.iter_mut().enumerate() {
|
||||
let is_fresh = latest_model_output.is_some_and(|boundary| index > boundary);
|
||||
@@ -514,7 +510,7 @@ mod tests {
|
||||
let estimates = uniform_estimates(&items, 10);
|
||||
let candidates = prunable_indices(&items, 20, &estimates);
|
||||
assert_eq!(project(&mut items, &candidates), 1);
|
||||
// 2 周目: 候補は一度の prunable_indices 結果を使い回しても 0 件。
|
||||
// Second pass: reusing the same prunable_indices result changes zero items.
|
||||
assert_eq!(project(&mut items, &candidates), 0);
|
||||
}
|
||||
|
||||
|
||||
+14
-38
@@ -1,58 +1,34 @@
|
||||
//! Engine State
|
||||
//! Type states that restrict changes to Engine configuration.
|
||||
//!
|
||||
//! State marker types for cache protection using the Type-state pattern.
|
||||
//! Engine has state transitions from `Mutable` → `Locked`.
|
||||
//! Transitioning from [`Mutable`] to [`Locked`] fixes the configuration and history
|
||||
//! prefix length, restricting changes to system prompts and tool definitions during
|
||||
//! execution. Changes require an explicit unlock because altering the prefix may
|
||||
//! prevent prompt-cache reuse. History itself belongs to the caller: these states
|
||||
//! neither prevent external history mutations nor guarantee cache hits.
|
||||
|
||||
/// Marker trait representing Engine state
|
||||
/// Sealed trait for Engine type states.
|
||||
///
|
||||
/// This trait is sealed and cannot be implemented externally.
|
||||
/// External implementations are prohibited so the crate controls the separation
|
||||
/// between configuration and execution APIs.
|
||||
pub trait EngineState: private::Sealed + Send + Sync + 'static {}
|
||||
|
||||
mod private {
|
||||
pub trait Sealed {}
|
||||
}
|
||||
|
||||
/// Mutable state (editable)
|
||||
/// State in which configuration can be edited.
|
||||
///
|
||||
/// In this state, the following operations are available:
|
||||
/// - Setting/changing system prompt
|
||||
/// - Editing message history (add, delete, clear)
|
||||
/// - Registering tools and hooks
|
||||
///
|
||||
/// Can transition to [`Locked`] state via `Engine::lock(&history)`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use agen::Engine;
|
||||
///
|
||||
/// let mut engine = Engine::new(client)
|
||||
/// .system_prompt("You are helpful.");
|
||||
///
|
||||
/// // History can be edited
|
||||
/// engine.push_message(Message::user("Hello"));
|
||||
/// engine.clear_history();
|
||||
///
|
||||
/// // Lock to protected state
|
||||
/// let locked = engine.lock();
|
||||
/// ```
|
||||
/// Transitions to [`Locked`] through `Engine::lock` or the first `run`.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Mutable;
|
||||
|
||||
impl private::Sealed for Mutable {}
|
||||
impl EngineState for Mutable {}
|
||||
|
||||
/// Cache locked state (cache protected)
|
||||
/// State with fixed configuration and history prefix length.
|
||||
///
|
||||
/// In this state, the following restrictions apply:
|
||||
/// - System prompt cannot be changed
|
||||
/// - Existing message history cannot be modified (only appending to the end)
|
||||
///
|
||||
/// To ensure LLM API KV cache hits,
|
||||
/// using this state during execution is recommended.
|
||||
///
|
||||
/// Can return to [`Mutable`] state via `Engine::unlock()`,
|
||||
/// but note that cache protection will be released.
|
||||
/// `Engine::unlock` returns to [`Mutable`] and permits configuration changes,
|
||||
/// but requests made after those changes may no longer reuse the previous cache.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Locked;
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Engine component tests with a recording tool and scripted host annotations.
|
||||
//! Persistence is represented by the append callback; Server/IPC storage is not
|
||||
//! exercised here. Run with `cargo test -p agen --lib tool_call_identity_tests`.
|
||||
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct NoLlm;
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for NoLlm {
|
||||
async fn stream(&self, _: Request) -> Result<ResponseStream, ClientError> {
|
||||
panic!("identity tests must not request a model response")
|
||||
}
|
||||
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct HostIdentity {
|
||||
early: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor<String> for HostIdentity {
|
||||
fn logical_tool_call_id(&self, annotation: &String) -> Option<String> {
|
||||
Some(annotation.clone())
|
||||
}
|
||||
|
||||
async fn pre_tool_call(
|
||||
&self,
|
||||
info: &mut ToolCallInfo<'_, String>,
|
||||
) -> Result<PreToolAction, InterceptorError> {
|
||||
if self.early {
|
||||
assert!(info.history.is_empty());
|
||||
}
|
||||
// Pre-tool hooks may replace the context, but cannot replace persisted identity.
|
||||
info.context.logical_call_id = Some("hook-forged-identity".into());
|
||||
Ok(PreToolAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
struct RecordingTool(Arc<Mutex<Vec<ToolExecutionContext>>>);
|
||||
|
||||
#[async_trait]
|
||||
impl crate::tool::Tool for RecordingTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_: &str,
|
||||
context: ToolExecutionContext,
|
||||
) -> Result<crate::tool::ToolOutput, ToolError> {
|
||||
self.0.lock().unwrap().push(context);
|
||||
Ok("recorded".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
fn engine(
|
||||
early: bool,
|
||||
) -> (
|
||||
Engine<NoLlm, Mutable, String>,
|
||||
Arc<Mutex<Vec<ToolExecutionContext>>>,
|
||||
) {
|
||||
let mut engine = Engine::new_annotated(NoLlm);
|
||||
engine.set_interceptor(HostIdentity { early });
|
||||
engine.max_turns = Some(0);
|
||||
engine.start_logical_run();
|
||||
let observed = Arc::new(Mutex::new(Vec::new()));
|
||||
let tool = Arc::new(RecordingTool(observed.clone()));
|
||||
engine.register_tool(Arc::new(move || {
|
||||
(crate::tool::ToolMeta::new("record"), tool.clone())
|
||||
}));
|
||||
engine.tool_server_handle().flush_pending();
|
||||
(engine, observed)
|
||||
}
|
||||
|
||||
fn entry(item: Item, id: &str) -> HistoryEntry<String> {
|
||||
HistoryEntry::new(item, id.to_string())
|
||||
}
|
||||
|
||||
fn call(id: &str) -> Item {
|
||||
Item::tool_call_json(id, "record", json!({}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_answer_only_preceding_occurrences_of_reused_provider_ids() {
|
||||
let history = History::from_entries(vec![
|
||||
entry(
|
||||
Item::tool_result("orphan", "no preceding call"),
|
||||
"orphan-result",
|
||||
),
|
||||
entry(call("reused"), "host-first"),
|
||||
entry(call("sibling"), "host-sibling"),
|
||||
entry(
|
||||
Item::tool_result("sibling", "out of order"),
|
||||
"sibling-result",
|
||||
),
|
||||
entry(Item::tool_result("reused", "answered"), "first-result"),
|
||||
entry(call("reused"), "host-pending"),
|
||||
entry(call("orphan"), "host-after-orphan"),
|
||||
]);
|
||||
let pending = unanswered_tool_call_entries(&history);
|
||||
assert_eq!(
|
||||
pending
|
||||
.iter()
|
||||
.map(|entry| entry.annotation.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["host-pending", "host-after-orphan"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn early_dispatch_binds_identity_only_after_successful_append() {
|
||||
for reject_append in [false, true] {
|
||||
let (mut engine, observed) = engine(true);
|
||||
let mut history = History::new();
|
||||
let mut batch = EarlyToolExecutionBatch::new("live-batch".into(), 0);
|
||||
let result = engine
|
||||
.admit_early_tool_calls(
|
||||
&mut history,
|
||||
&mut |_| {
|
||||
if reject_append {
|
||||
Err("storage unavailable".into())
|
||||
} else {
|
||||
Ok("host-committed-entry".into())
|
||||
}
|
||||
},
|
||||
&mut batch,
|
||||
vec![crate::timeline::CollectedToolCall {
|
||||
call_index: 0,
|
||||
call: ToolCall {
|
||||
id: "provider-call".into(),
|
||||
name: "record".into(),
|
||||
input: json!({}),
|
||||
},
|
||||
}],
|
||||
)
|
||||
.await;
|
||||
if reject_append {
|
||||
assert!(matches!(result, Err(EngineError::HistoryAppend(_))));
|
||||
assert!(history.is_empty());
|
||||
assert!(batch.execution_handles.is_empty());
|
||||
assert!(observed.lock().unwrap().is_empty());
|
||||
} else {
|
||||
assert!(matches!(result, Ok(EarlyToolAdmission::Continue)));
|
||||
let (_, terminal) = batch.futures.next().await.unwrap();
|
||||
assert_eq!(terminal.tool_use_id, "provider-call");
|
||||
let observed = observed.lock().unwrap();
|
||||
assert_eq!(observed.len(), 1);
|
||||
assert_eq!(
|
||||
observed[0].logical_call_id.as_deref(),
|
||||
Some(history.entries()[0].annotation.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
observed[0].logical_call_id.as_deref(),
|
||||
Some("host-committed-entry")
|
||||
);
|
||||
assert_eq!(batch.call_info_map["provider-call"].3, observed[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn normal_dispatch_and_unstarted_restore_use_the_exact_unanswered_annotation() {
|
||||
for restore in [false, true] {
|
||||
let (mut engine, observed) = engine(false);
|
||||
let mut history = History::from_entries(vec![
|
||||
entry(call("reused"), "host-answered"),
|
||||
entry(
|
||||
Item::tool_result("reused", "already completed"),
|
||||
"answered-result",
|
||||
),
|
||||
]);
|
||||
engine
|
||||
.append_history_items(&mut history, [call("reused")], &mut |_| {
|
||||
Ok("host-pending".into())
|
||||
})
|
||||
.unwrap();
|
||||
if restore {
|
||||
let persisted = serde_json::to_string(&history).unwrap();
|
||||
history = serde_json::from_str(&persisted).unwrap();
|
||||
}
|
||||
let (pending, started) = engine.get_unanswered_tool_calls(&history);
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert!(started.is_empty());
|
||||
if restore {
|
||||
engine
|
||||
.run_turn_loop(&mut history, &mut |_| Ok("terminal-result".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
} else {
|
||||
let result = engine
|
||||
.execute_tools(&mut history, &mut |_| Ok("terminal-result".into()), pending)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ToolExecutionResult::Completed));
|
||||
}
|
||||
assert_eq!(observed.lock().unwrap().len(), 1);
|
||||
let context = observed.lock().unwrap()[0].clone();
|
||||
assert_eq!(context.call_id, "reused");
|
||||
assert_eq!(context.logical_call_id.as_deref(), Some("host-pending"));
|
||||
// A subsequent resume sees the terminal result, not another live attempt.
|
||||
engine
|
||||
.run_turn_loop(&mut history, &mut |_| Ok("unexpected-append".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed.lock().unwrap().len(), 1);
|
||||
assert!(unanswered_tool_call_entries(&history).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn started_restore_with_reused_provider_id_closes_unknown_without_reexecution() {
|
||||
let (mut engine, observed) = engine(false);
|
||||
let mut history = History::from_entries(vec![
|
||||
entry(call("reused"), "host-answered"),
|
||||
entry(
|
||||
Item::tool_result("reused", "already completed"),
|
||||
"answered-result",
|
||||
),
|
||||
entry(
|
||||
call("reused")
|
||||
.with_tool_execution_metadata(0, Some("old-attempt".into()))
|
||||
.with_status(ItemStatus::InProgress),
|
||||
"host-started",
|
||||
),
|
||||
]);
|
||||
let (pending, started) = engine.get_unanswered_tool_calls(&history);
|
||||
assert!(pending.is_empty());
|
||||
assert_eq!(started, ["reused"]);
|
||||
engine
|
||||
.run_turn_loop(&mut history, &mut |_| Ok("unknown-result".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(observed.lock().unwrap().is_empty());
|
||||
assert!(matches!(
|
||||
&history.entries().last().unwrap().item,
|
||||
Item::ToolResult { call_id, disposition: ToolResultDisposition::OutcomeUnknown, .. }
|
||||
if call_id == "reused"
|
||||
));
|
||||
let len = history.len();
|
||||
engine
|
||||
.run_turn_loop(&mut history, &mut |_| Ok("unexpected-append".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(history.len(), len);
|
||||
assert!(observed.lock().unwrap().is_empty());
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Timeline層のイベント型
|
||||
//! Event types for the Timeline layer.
|
||||
//!
|
||||
//! llm_client層のイベント型をそのまま使用する。
|
||||
//! Uses the event types from the llm_client layer directly.
|
||||
|
||||
pub use crate::llm_client::event::*;
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
//! Timeline層
|
||||
//! Dispatch normalized LLM events and collect blocks.
|
||||
//!
|
||||
//! LLMからのイベントストリームを受信し、登録されたHandlerにディスパッチします。
|
||||
//!
|
||||
//! # 主要コンポーネント
|
||||
//!
|
||||
//! - [`Timeline`] - イベントストリームの管理とディスパッチ
|
||||
//! - [`Handler`] - イベントを処理するトレイト
|
||||
//! - [`TextBlockCollector`] - テキストブロックを収集するHandler
|
||||
//! - [`ThinkingBlockCollector`] - reasoning material を Thinking block から収集するHandler
|
||||
//! - [`ToolCallCollector`] - ツール呼び出しを収集するHandler
|
||||
//! [`Timeline`] dispatches common events to handlers without parsing provider protocols.
|
||||
//! Block assembly and display callbacks are separate from turn decisions. The Engine
|
||||
//! uses collected responses to update history and execute tools. Provisional streaming
|
||||
//! output is distinct from committed history.
|
||||
|
||||
pub mod event;
|
||||
mod text_block_collector;
|
||||
@@ -16,7 +11,7 @@ mod thinking_block_collector;
|
||||
mod timeline;
|
||||
mod tool_call_collector;
|
||||
|
||||
// 公開API
|
||||
// Public API
|
||||
pub use event::*;
|
||||
pub use text_block_collector::TextBlockCollector;
|
||||
pub use thinking_block_collector::ThinkingBlockCollector;
|
||||
@@ -24,7 +19,7 @@ pub use timeline::Timeline;
|
||||
pub(crate) use tool_call_collector::CollectedToolCall;
|
||||
pub use tool_call_collector::ToolCallCollector;
|
||||
|
||||
// 型定義からのre-export
|
||||
// Re-exports from type definitions
|
||||
pub use crate::handler::{
|
||||
// Meta Kinds
|
||||
ErrorKind,
|
||||
|
||||
@@ -1,53 +1,51 @@
|
||||
//! TextBlockCollector - テキストブロック収集用ハンドラ
|
||||
//! TextBlockCollector - a handler for collecting text blocks.
|
||||
//!
|
||||
//! TimelineのTextBlockHandler として登録され、
|
||||
//! ストリーム中のテキストブロックを収集する。
|
||||
//! Registered as a TextBlockHandler on Timeline to collect streaming text blocks.
|
||||
|
||||
use crate::handler::{Handler, TextBlockEvent, TextBlockKind};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// TextBlockから収集したテキスト情報を保持
|
||||
/// Holds text collected from TextBlock events.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TextCollectorState {
|
||||
/// 蓄積中のテキスト
|
||||
/// Text being accumulated.
|
||||
buffer: String,
|
||||
}
|
||||
|
||||
/// TextBlockCollector - テキストブロックハンドラ
|
||||
/// TextBlockCollector - a text block handler.
|
||||
///
|
||||
/// Timelineに登録してTextBlockイベントを受信し、
|
||||
/// 完了したテキストブロックを収集する。
|
||||
/// Register with Timeline to receive TextBlock events and collect completed text blocks.
|
||||
#[derive(Clone)]
|
||||
pub struct TextBlockCollector {
|
||||
/// 収集されたテキストブロック
|
||||
/// Collected text blocks.
|
||||
collected: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl TextBlockCollector {
|
||||
/// 新しいTextBlockCollectorを作成
|
||||
/// Create a new TextBlockCollector.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
collected: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 収集されたテキストを取得してクリア
|
||||
/// Take the collected text and clear the collection.
|
||||
pub fn take_collected(&self) -> Vec<String> {
|
||||
let mut guard = self.collected.lock().unwrap();
|
||||
std::mem::take(&mut *guard)
|
||||
}
|
||||
|
||||
/// 収集されたテキストの参照を取得
|
||||
/// Access the collected text.
|
||||
pub fn collected(&self) -> Vec<String> {
|
||||
self.collected.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// 収集されたテキストがあるかどうか
|
||||
/// Whether any text has been collected.
|
||||
pub fn has_content(&self) -> bool {
|
||||
!self.collected.lock().unwrap().is_empty()
|
||||
}
|
||||
|
||||
/// 収集をクリア
|
||||
/// Clear the collection.
|
||||
pub fn clear(&self) {
|
||||
self.collected.lock().unwrap().clear();
|
||||
}
|
||||
@@ -71,7 +69,7 @@ impl Handler<TextBlockKind> for TextBlockCollector {
|
||||
scope.buffer.push_str(text);
|
||||
}
|
||||
TextBlockEvent::Stop(_) => {
|
||||
// ブロック完了時にテキストを確定
|
||||
// Finalize the text when the block completes.
|
||||
if !scope.buffer.is_empty() {
|
||||
let text = std::mem::take(&mut scope.buffer);
|
||||
self.collected.lock().unwrap().push(text);
|
||||
@@ -87,38 +85,38 @@ mod tests {
|
||||
use crate::timeline::Timeline;
|
||||
use crate::timeline::event::Event;
|
||||
|
||||
/// TextBlockCollectorが単一のテキストブロックを正しく収集することを確認
|
||||
/// Verify that TextBlockCollector correctly collects a single text block.
|
||||
#[test]
|
||||
fn test_collect_single_text_block() {
|
||||
let collector = TextBlockCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_text_block(collector.clone());
|
||||
|
||||
// テキストブロックのイベントシーケンスをディスパッチ
|
||||
// Dispatch a sequence of text block events.
|
||||
timeline.dispatch(&Event::text_block_start(0));
|
||||
timeline.dispatch(&Event::text_delta(0, "Hello, "));
|
||||
timeline.dispatch(&Event::text_delta(0, "World!"));
|
||||
timeline.dispatch(&Event::text_block_stop(0, None));
|
||||
|
||||
// 収集されたテキストを確認
|
||||
// Check the collected text.
|
||||
let texts = collector.take_collected();
|
||||
assert_eq!(texts.len(), 1);
|
||||
assert_eq!(texts[0], "Hello, World!");
|
||||
}
|
||||
|
||||
/// TextBlockCollectorが複数のテキストブロックを正しく収集することを確認
|
||||
/// Verify that TextBlockCollector correctly collects multiple text blocks.
|
||||
#[test]
|
||||
fn test_collect_multiple_text_blocks() {
|
||||
let collector = TextBlockCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_text_block(collector.clone());
|
||||
|
||||
// 1つ目のテキストブロック
|
||||
// First text block
|
||||
timeline.dispatch(&Event::text_block_start(0));
|
||||
timeline.dispatch(&Event::text_delta(0, "First"));
|
||||
timeline.dispatch(&Event::text_block_stop(0, None));
|
||||
|
||||
// 2つ目のテキストブロック
|
||||
// Second text block
|
||||
timeline.dispatch(&Event::text_block_start(1));
|
||||
timeline.dispatch(&Event::text_delta(1, "Second"));
|
||||
timeline.dispatch(&Event::text_block_stop(1, None));
|
||||
|
||||
@@ -20,13 +20,13 @@ impl ThinkingBlockCollector {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 収集済み item を取り出してクリア
|
||||
/// Take the collected items and clear the collection.
|
||||
pub fn take_collected(&self) -> Vec<ReasoningBlockData> {
|
||||
let mut guard = self.collected.lock().unwrap();
|
||||
std::mem::take(&mut *guard)
|
||||
}
|
||||
|
||||
/// 収集をクリア
|
||||
/// Clear the collection.
|
||||
pub fn clear(&self) {
|
||||
self.collected.lock().unwrap().clear();
|
||||
}
|
||||
@@ -107,7 +107,7 @@ mod tests {
|
||||
assert_eq!(items[0].signature.as_deref(), Some("sig1"));
|
||||
assert_eq!(items[1].text.as_deref(), Some("second"));
|
||||
|
||||
// take は drain なので 2 度目は空
|
||||
// Taking drains the collection, so the second call is empty.
|
||||
assert!(collector.take_collected().is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Timeline層
|
||||
//! Timeline layer.
|
||||
//!
|
||||
//! LLMからのイベントストリームを受信し、登録されたHandlerにディスパッチします。
|
||||
//! 通常はEngine経由で使用しますが、直接使用することも可能です。
|
||||
//! Receives an LLM event stream and dispatches it to registered handlers.
|
||||
//! Usually used through the Engine, but can also be used directly.
|
||||
|
||||
use std::{collections::HashMap, marker::PhantomData};
|
||||
|
||||
@@ -12,11 +12,11 @@ use crate::handler::*;
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
/// 1リクエスト内で受信した複数 UsageEvent をマージする。
|
||||
/// 各フィールドについて新しい値が `Some` ならそれで上書き。
|
||||
/// プロバイダによっては input/cache 系を最初の event だけに載せ、
|
||||
/// output_tokens を後続 event で更新するため、最後の値だけを取るのではなく
|
||||
/// フィールド単位で latest-non-None を取る。
|
||||
/// Merge multiple UsageEvents received within one request.
|
||||
/// Overwrite each field when the new value is `Some`.
|
||||
/// Some providers send input/cache fields only in the first event and update
|
||||
/// output_tokens in later events, so use the latest non-None value per field
|
||||
/// rather than taking only the last event.
|
||||
fn merge_usage(acc: &mut UsageEvent, new: &UsageEvent) {
|
||||
if new.input_tokens.is_some() {
|
||||
acc.input_tokens = new.input_tokens;
|
||||
@@ -39,19 +39,19 @@ fn merge_usage(acc: &mut UsageEvent, new: &UsageEvent) {
|
||||
// Type-erased Handler
|
||||
// =============================================================================
|
||||
|
||||
/// 型消去された`Handler` trait
|
||||
/// Type-erased `Handler` trait.
|
||||
///
|
||||
/// 各Handlerは独自のScope型を持つため、Timelineで保持するには型消去が必要です。
|
||||
/// 通常は直接使用せず、`Timeline::on_text_block()`などのメソッド経由で
|
||||
/// 自動的にラップされます。
|
||||
/// Each handler has its own Scope type, so Timeline needs type erasure to store them.
|
||||
/// Usually wrapped automatically by methods such as `Timeline::on_text_block()`
|
||||
/// rather than used directly.
|
||||
pub trait ErasedHandler<K: Kind>: Send + Sync {
|
||||
/// イベントをディスパッチ
|
||||
/// Dispatch an event.
|
||||
fn dispatch(&mut self, event: &K::Event);
|
||||
/// スコープを開始
|
||||
/// Start a scope.
|
||||
fn start_scope(&mut self);
|
||||
}
|
||||
|
||||
/// `Handler<K>`を`ErasedHandler<K>`として扱うためのラッパー
|
||||
/// Wrapper that exposes `Handler<K>` as `ErasedHandler<K>`.
|
||||
pub struct HandlerWrapper<H, K>
|
||||
where
|
||||
H: Handler<K>,
|
||||
@@ -59,7 +59,7 @@ where
|
||||
{
|
||||
handler: H,
|
||||
scope: Option<H::Scope>,
|
||||
// fn() -> K は常にSend+Syncなので、Kの制約に関係なくSendを満たせる
|
||||
// fn() -> K is always Send+Sync, so Send does not depend on K's bounds.
|
||||
_kind: PhantomData<fn() -> K>,
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ where
|
||||
// Block Handler Registry
|
||||
// =============================================================================
|
||||
|
||||
/// ブロックハンドラーの型消去trait
|
||||
/// Type-erased trait for block handlers.
|
||||
trait ErasedBlockHandler: Send + Sync {
|
||||
fn dispatch_start(&mut self, start: &BlockStart);
|
||||
fn dispatch_delta(&mut self, delta: &BlockDelta);
|
||||
@@ -106,11 +106,11 @@ trait ErasedBlockHandler: Send + Sync {
|
||||
fn dispatch_abort(&mut self, abort: &BlockAbort);
|
||||
fn start_scope(&mut self);
|
||||
fn end_scope(&mut self);
|
||||
/// スコープがアクティブかどうか
|
||||
/// Whether a scope is active.
|
||||
fn has_scope(&self) -> bool;
|
||||
}
|
||||
|
||||
/// TextBlockKind用のラッパー
|
||||
/// Wrapper for TextBlockKind.
|
||||
struct TextBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<TextBlockKind>,
|
||||
@@ -167,7 +167,7 @@ where
|
||||
}
|
||||
|
||||
fn dispatch_abort(&mut self, _abort: &BlockAbort) {
|
||||
// TextBlockはabortを特別扱いしない(スコープ終了のみ)
|
||||
// TextBlock needs no special abort handling (only scope cleanup).
|
||||
}
|
||||
|
||||
fn start_scope(&mut self) {
|
||||
@@ -183,7 +183,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// ThinkingBlockKind用のラッパー
|
||||
/// Wrapper for ThinkingBlockKind.
|
||||
struct ThinkingBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<ThinkingBlockKind>,
|
||||
@@ -250,7 +250,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// ToolUseBlockKind用のラッパー
|
||||
/// Wrapper for ToolUseBlockKind.
|
||||
struct ToolUseBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<ToolUseBlockKind>,
|
||||
@@ -335,10 +335,10 @@ where
|
||||
// Timeline
|
||||
// =============================================================================
|
||||
|
||||
/// イベントストリームの管理とハンドラへのディスパッチ
|
||||
/// Manages an event stream and dispatches events to handlers.
|
||||
///
|
||||
/// LLMからのイベントを受信し、登録されたハンドラに振り分けます。
|
||||
/// ブロック系イベントはスコープ管理付きで処理されます。
|
||||
/// Receives events from the LLM and routes them to registered handlers.
|
||||
/// Block events are handled with scope management.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
@@ -359,30 +359,30 @@ where
|
||||
/// timeline.on_text_block(MyHandler);
|
||||
/// ```
|
||||
///
|
||||
/// # サポートするイベント種別
|
||||
/// # Supported event kinds
|
||||
///
|
||||
/// - **メタ系**: Usage, Ping, Status, Error
|
||||
/// - **ブロック系**: TextBlock, ThinkingBlock, ToolUseBlock
|
||||
/// - **Meta**: Usage, Ping, Status, Error
|
||||
/// - **Block**: TextBlock, ThinkingBlock, ToolUseBlock
|
||||
pub struct Timeline {
|
||||
// Meta系ハンドラー
|
||||
// Meta handlers
|
||||
usage_handlers: Vec<Box<dyn ErasedHandler<UsageKind>>>,
|
||||
ping_handlers: Vec<Box<dyn ErasedHandler<PingKind>>>,
|
||||
status_handlers: Vec<Box<dyn ErasedHandler<StatusKind>>>,
|
||||
error_handlers: Vec<Box<dyn ErasedHandler<ErrorKind>>>,
|
||||
// Block系ハンドラー(BlockTypeごとにグループ化)
|
||||
// Block handlers (grouped by BlockType)
|
||||
text_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
|
||||
thinking_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
|
||||
tool_use_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
|
||||
|
||||
// 現在アクティブなブロック。provider は index ごとに複数 scope を
|
||||
// interleave できるため、単一の current block ではなく全 scope を追跡する。
|
||||
// Currently active blocks. Providers can interleave multiple scopes by index,
|
||||
// so track all scopes rather than a single current block.
|
||||
active_blocks: HashMap<(BlockType, usize), ()>,
|
||||
current_block: Option<BlockType>,
|
||||
|
||||
// 1リクエスト内で受信した Usage event の集約バッファ。
|
||||
// Anthropic は message_start と message_delta、Gemini は各チャンクと、
|
||||
// 多くのプロバイダが複数 Usage を発行するため、リクエスト境界で
|
||||
// 1度だけ発火するためにここでマージする。flush_usage() で発火する。
|
||||
// Aggregation buffer for Usage events received within one request.
|
||||
// Many providers emit multiple Usage events: Anthropic in message_start and
|
||||
// message_delta, Gemini in each chunk. Merge them here to dispatch only once
|
||||
// at the request boundary via flush_usage().
|
||||
pending_usage: Option<UsageEvent>,
|
||||
}
|
||||
|
||||
@@ -412,20 +412,20 @@ impl Timeline {
|
||||
// Handler Registration
|
||||
// =========================================================================
|
||||
|
||||
/// UsageKind用のHandlerを登録
|
||||
/// Register a Handler for UsageKind.
|
||||
pub fn on_usage<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<UsageKind> + Send + Sync + 'static,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
// Meta系はデフォルトでスコープを開始しておく
|
||||
// Meta handlers start with a scope by default.
|
||||
let mut wrapper = HandlerWrapper::new(handler);
|
||||
wrapper.start_scope();
|
||||
self.usage_handlers.push(Box::new(wrapper));
|
||||
self
|
||||
}
|
||||
|
||||
/// PingKind用のHandlerを登録
|
||||
/// Register a Handler for PingKind.
|
||||
pub fn on_ping<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<PingKind> + Send + Sync + 'static,
|
||||
@@ -437,7 +437,7 @@ impl Timeline {
|
||||
self
|
||||
}
|
||||
|
||||
/// StatusKind用のHandlerを登録
|
||||
/// Register a Handler for StatusKind.
|
||||
pub fn on_status<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<StatusKind> + Send + Sync + 'static,
|
||||
@@ -449,7 +449,7 @@ impl Timeline {
|
||||
self
|
||||
}
|
||||
|
||||
/// ErrorKind用のHandlerを登録
|
||||
/// Register a Handler for ErrorKind.
|
||||
pub fn on_error<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<ErrorKind> + Send + Sync + 'static,
|
||||
@@ -461,7 +461,7 @@ impl Timeline {
|
||||
self
|
||||
}
|
||||
|
||||
/// TextBlockKind用のHandlerを登録
|
||||
/// Register a Handler for TextBlockKind.
|
||||
pub fn on_text_block<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<TextBlockKind> + Send + Sync + 'static,
|
||||
@@ -472,7 +472,7 @@ impl Timeline {
|
||||
self
|
||||
}
|
||||
|
||||
/// ThinkingBlockKind用のHandlerを登録
|
||||
/// Register a Handler for ThinkingBlockKind.
|
||||
pub fn on_thinking_block<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<ThinkingBlockKind> + Send + Sync + 'static,
|
||||
@@ -483,7 +483,7 @@ impl Timeline {
|
||||
self
|
||||
}
|
||||
|
||||
/// ToolUseBlockKind用のHandlerを登録
|
||||
/// Register a Handler for ToolUseBlockKind.
|
||||
pub fn on_tool_use_block<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<ToolUseBlockKind> + Send + Sync + 'static,
|
||||
@@ -498,10 +498,10 @@ impl Timeline {
|
||||
// Event Dispatch
|
||||
// =========================================================================
|
||||
|
||||
/// メインのディスパッチエントリポイント
|
||||
/// Main dispatch entry point.
|
||||
pub fn dispatch(&mut self, event: &Event) {
|
||||
match event {
|
||||
// Meta系: 即時ディスパッチ(登録順)
|
||||
// Buffer usage until flush; dispatch other handled meta events immediately.
|
||||
Event::Usage(u) => self.dispatch_usage(u),
|
||||
Event::Ping(p) => self.dispatch_ping(p),
|
||||
Event::Status(s) => self.dispatch_status(s),
|
||||
@@ -509,7 +509,7 @@ impl Timeline {
|
||||
// Observability-only event: stream trace records it before timeline dispatch.
|
||||
Event::UnhandledSse(_) => {}
|
||||
|
||||
// Block系: スコープ管理しながらディスパッチ
|
||||
// Block events: dispatch with scope management.
|
||||
Event::BlockStart(s) => self.handle_block_start(s),
|
||||
Event::BlockDelta(d) => self.handle_block_delta(d),
|
||||
Event::BlockStop(s) => self.handle_block_stop(s),
|
||||
@@ -517,9 +517,9 @@ impl Timeline {
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage event を即時には dispatch せず、pending_usage にマージする。
|
||||
/// 1リクエスト内で複数の Usage event が来ても、ハンドラには 1 度だけ
|
||||
/// 最終値を渡したいため。flush_usage() で発火する。
|
||||
/// Merge Usage events into pending_usage instead of dispatching immediately.
|
||||
/// Even if a request emits multiple Usage events, handlers receive the final
|
||||
/// values only once, when flush_usage() dispatches them.
|
||||
fn dispatch_usage(&mut self, event: &UsageEvent) {
|
||||
match &mut self.pending_usage {
|
||||
Some(acc) => merge_usage(acc, event),
|
||||
@@ -527,9 +527,9 @@ impl Timeline {
|
||||
}
|
||||
}
|
||||
|
||||
/// pending_usage を usage_handlers に発火し、バッファをクリアする。
|
||||
/// 1リクエスト分のストリーム終了時に1回だけ呼ぶ想定。
|
||||
/// pending_usage が空ならば何もしない。
|
||||
/// Dispatch pending_usage to usage_handlers and clear the buffer.
|
||||
/// Intended to be called once at the end of a request's stream.
|
||||
/// Does nothing if pending_usage is empty.
|
||||
pub fn flush_usage(&mut self) {
|
||||
if let Some(event) = self.pending_usage.take() {
|
||||
for handler in &mut self.usage_handlers {
|
||||
@@ -571,8 +571,8 @@ impl Timeline {
|
||||
fn handle_block_delta(&mut self, delta: &BlockDelta) {
|
||||
let block_type = delta.delta.block_type();
|
||||
|
||||
// OpenAIなどのプロバイダはBlockStartを送らない場合があるため、
|
||||
// Deltaが来たときにその index のスコープがなければ暗黙的に開始する
|
||||
// Providers such as OpenAI may omit BlockStart, so implicitly start a scope
|
||||
// for this index when a Delta arrives without one.
|
||||
self.active_blocks
|
||||
.entry((block_type, delta.index))
|
||||
.or_insert(());
|
||||
@@ -580,7 +580,7 @@ impl Timeline {
|
||||
|
||||
let handlers = self.get_block_handlers_mut(block_type);
|
||||
for handler in handlers {
|
||||
// スコープがなければ暗黙的に開始
|
||||
// Implicitly start a scope if none exists.
|
||||
if !handler.has_scope() {
|
||||
handler.start_scope();
|
||||
}
|
||||
@@ -624,19 +624,19 @@ impl Timeline {
|
||||
BlockType::Text => &mut self.text_block_handlers,
|
||||
BlockType::Thinking => &mut self.thinking_block_handlers,
|
||||
BlockType::ToolUse => &mut self.tool_use_block_handlers,
|
||||
BlockType::ToolResult => &mut self.text_block_handlers, // ToolResultはTextとして扱う
|
||||
BlockType::ToolResult => &mut self.text_block_handlers, // Treat ToolResult as Text.
|
||||
}
|
||||
}
|
||||
|
||||
/// 現在アクティブなブロックタイプを取得
|
||||
/// Get the currently active block type.
|
||||
pub fn current_block(&self) -> Option<BlockType> {
|
||||
self.current_block
|
||||
}
|
||||
|
||||
/// 現在アクティブな全ブロックを中断する
|
||||
/// Abort all currently active blocks.
|
||||
///
|
||||
/// キャンセルやエラー時に呼び出し、index ごとに進行中の全ブロックへ
|
||||
/// BlockAbortイベントを発火してスコープをクリーンアップする。
|
||||
/// Call on cancellation or error to emit BlockAbort events for all in-progress
|
||||
/// blocks by index and clean up their scopes.
|
||||
pub fn abort_current_block(&mut self) {
|
||||
let active = self.active_blocks.keys().copied().collect::<Vec<_>>();
|
||||
for (block_type, index) in active {
|
||||
@@ -739,7 +739,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_meta_event_dispatch() {
|
||||
// シンプルなテスト用構造体
|
||||
// Simple test struct
|
||||
struct TestUsageHandler {
|
||||
calls: Arc<Mutex<Vec<UsageEvent>>>,
|
||||
}
|
||||
@@ -760,10 +760,10 @@ mod tests {
|
||||
timeline.on_usage(handler);
|
||||
|
||||
timeline.dispatch(&Event::usage(100, 50));
|
||||
// pending_usage に積まれているだけなのでまだ未発火
|
||||
// Only buffered in pending_usage; not yet dispatched.
|
||||
assert_eq!(calls.lock().unwrap().len(), 0);
|
||||
|
||||
// flush で 1 度だけ発火
|
||||
// Flush dispatches once.
|
||||
timeline.flush_usage();
|
||||
let recorded = calls.lock().unwrap();
|
||||
assert_eq!(recorded.len(), 1);
|
||||
@@ -788,7 +788,7 @@ mod tests {
|
||||
calls: calls.clone(),
|
||||
});
|
||||
|
||||
// Anthropic 風: message_start で input + 暫定 output
|
||||
// Anthropic-style: input + provisional output in message_start.
|
||||
timeline.dispatch(&Event::Usage(UsageEvent {
|
||||
input_tokens: Some(409),
|
||||
output_tokens: Some(1),
|
||||
@@ -796,7 +796,7 @@ mod tests {
|
||||
cache_read_input_tokens: Some(0),
|
||||
cache_creation_input_tokens: Some(0),
|
||||
}));
|
||||
// message_delta で最終 output
|
||||
// Final output in message_delta.
|
||||
timeline.dispatch(&Event::Usage(UsageEvent {
|
||||
input_tokens: Some(409),
|
||||
output_tokens: Some(71),
|
||||
@@ -805,7 +805,7 @@ mod tests {
|
||||
cache_creation_input_tokens: Some(0),
|
||||
}));
|
||||
|
||||
// 未 flush の段階では発火しない
|
||||
// No dispatch before flushing.
|
||||
assert_eq!(calls.lock().unwrap().len(), 0);
|
||||
|
||||
timeline.flush_usage();
|
||||
@@ -814,7 +814,7 @@ mod tests {
|
||||
assert_eq!(recorded[0].input_tokens, Some(409));
|
||||
assert_eq!(recorded[0].output_tokens, Some(71));
|
||||
|
||||
// flush 後にもう一度 flush しても何も起きない
|
||||
// Flushing again after a flush does nothing.
|
||||
drop(recorded);
|
||||
timeline.flush_usage();
|
||||
assert_eq!(calls.lock().unwrap().len(), 1);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! ToolCallCollector - ツール呼び出し収集用ハンドラ
|
||||
//! ToolCallCollector - a handler for collecting tool calls.
|
||||
//!
|
||||
//! TimelineのToolUseBlockHandler として登録され、
|
||||
//! ストリーム中のToolUseブロックを収集する。
|
||||
//! Registered as a ToolUseBlockHandler on Timeline to collect streaming ToolUse blocks.
|
||||
|
||||
use crate::{
|
||||
handler::{Handler, ToolUseBlockEvent, ToolUseBlockKind},
|
||||
@@ -9,17 +8,17 @@ use crate::{
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// ToolUseブロックから収集したツール呼び出し情報を保持
|
||||
/// Holds tool call information collected from ToolUse blocks.
|
||||
///
|
||||
/// ToolCallCollectorのHandler実装で使用するスコープ型
|
||||
/// Scope type for ToolCallCollector's Handler implementation.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CollectorState {
|
||||
/// 現在のツール呼び出し情報 (ブロック進行中)
|
||||
/// Current tool call information (while the block is in progress).
|
||||
current_id: Option<String>,
|
||||
current_name: Option<String>,
|
||||
/// Zero-based order among tool calls in this assistant response.
|
||||
call_index: Option<usize>,
|
||||
/// 蓄積中のJSON入力
|
||||
/// JSON input being accumulated.
|
||||
input_json_buffer: String,
|
||||
}
|
||||
|
||||
@@ -30,19 +29,18 @@ pub(crate) struct CollectedToolCall {
|
||||
pub call: ToolCall,
|
||||
}
|
||||
|
||||
/// ToolCallCollector - ToolUseブロックハンドラ
|
||||
/// ToolCallCollector - a ToolUse block handler.
|
||||
///
|
||||
/// Timelineに登録してToolUseブロックイベントを受信し、
|
||||
/// 完了したToolCallを収集する。
|
||||
/// Register with Timeline to receive ToolUse block events and collect completed ToolCalls.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolCallCollector {
|
||||
/// 収集されたToolCall
|
||||
/// Collected ToolCalls.
|
||||
collected: Arc<Mutex<Vec<CollectedToolCall>>>,
|
||||
next_call_index: Arc<Mutex<usize>>,
|
||||
}
|
||||
|
||||
impl ToolCallCollector {
|
||||
/// 新しいToolCallCollectorを作成
|
||||
/// Create a new ToolCallCollector.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
collected: Arc::new(Mutex::new(Vec::new())),
|
||||
@@ -50,7 +48,7 @@ impl ToolCallCollector {
|
||||
}
|
||||
}
|
||||
|
||||
/// 収集されたToolCallを取得してクリア
|
||||
/// Take the collected ToolCalls and clear the collection.
|
||||
pub fn take_collected(&self) -> Vec<ToolCall> {
|
||||
self.take_collected_with_index()
|
||||
.into_iter()
|
||||
@@ -64,7 +62,7 @@ impl ToolCallCollector {
|
||||
std::mem::take(&mut *guard)
|
||||
}
|
||||
|
||||
/// 収集されたToolCallの参照を取得
|
||||
/// Access the collected ToolCalls.
|
||||
pub fn collected(&self) -> Vec<ToolCall> {
|
||||
self.collected
|
||||
.lock()
|
||||
@@ -80,12 +78,12 @@ impl ToolCallCollector {
|
||||
*self.next_call_index.lock().unwrap() = 0;
|
||||
}
|
||||
|
||||
/// 収集されたToolCallがあるかどうか
|
||||
/// Whether any ToolCalls have been collected.
|
||||
pub fn has_pending_calls(&self) -> bool {
|
||||
!self.collected.lock().unwrap().is_empty()
|
||||
}
|
||||
|
||||
/// 収集をクリア
|
||||
/// Clear the collection.
|
||||
pub fn clear(&self) {
|
||||
self.collected.lock().unwrap().clear();
|
||||
}
|
||||
@@ -116,7 +114,7 @@ impl Handler<ToolUseBlockKind> for ToolCallCollector {
|
||||
scope.input_json_buffer.push_str(delta);
|
||||
}
|
||||
ToolUseBlockEvent::Stop(_stop) => {
|
||||
// ブロック完了時にToolCallを確定
|
||||
// Finalize the ToolCall when the block completes.
|
||||
if let (Some(id), Some(name)) = (scope.current_id.take(), scope.current_name.take())
|
||||
{
|
||||
// A Stop event proves the provider finished the block, but
|
||||
@@ -161,13 +159,13 @@ mod tests {
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_tool_use_block(collector.clone());
|
||||
|
||||
// ToolUseブロックのイベントシーケンスをディスパッチ
|
||||
// Dispatch a sequence of ToolUse block events.
|
||||
timeline.dispatch(&Event::tool_use_start(0, "tool_123", "get_weather"));
|
||||
timeline.dispatch(&Event::tool_input_delta(0, r#"{"city":"#));
|
||||
timeline.dispatch(&Event::tool_input_delta(0, r#""Tokyo"}"#));
|
||||
timeline.dispatch(&Event::tool_use_stop(0));
|
||||
|
||||
// 収集されたToolCallを確認
|
||||
// Check the collected ToolCalls.
|
||||
let calls = collector.take_collected();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].id, "tool_123");
|
||||
@@ -177,7 +175,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_collect_empty_buffer_returns_object() {
|
||||
// 引数なしツール呼び出し: input_json_delta が一度も来ないケース
|
||||
// A tool call without arguments: no input_json_delta arrives.
|
||||
let collector = ToolCallCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_tool_use_block(collector.clone());
|
||||
@@ -239,12 +237,12 @@ mod tests {
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_tool_use_block(collector.clone());
|
||||
|
||||
// 1つ目のToolCall
|
||||
// First ToolCall
|
||||
timeline.dispatch(&Event::tool_use_start(0, "call_1", "tool_a"));
|
||||
timeline.dispatch(&Event::tool_input_delta(0, r#"{"a":1}"#));
|
||||
timeline.dispatch(&Event::tool_use_stop(0));
|
||||
|
||||
// 2つ目のToolCall
|
||||
// Second ToolCall
|
||||
timeline.dispatch(&Event::tool_use_start(1, "call_2", "tool_b"));
|
||||
timeline.dispatch(&Event::tool_input_delta(1, r#"{"b":2}"#));
|
||||
timeline.dispatch(&Event::tool_use_stop(1));
|
||||
|
||||
@@ -1,41 +1,40 @@
|
||||
//! Usage 履歴ベースのトークン会計(汎用部分)。
|
||||
//! Token estimates based on provider measurements and history growth.
|
||||
//!
|
||||
//! `UsageRecord` の列(プロバイダ実測値)と現在の history から、
|
||||
//! 任意の history index 時点のプロンプト全長トークン数を pure に計算する。
|
||||
//! Uses the full input token count from [`UsageRecord`], interpolating between
|
||||
//! measured boundaries by byte count and extrapolating beyond the latest boundary
|
||||
//! from text bytes. Input totals include cache reads rather than inventing a
|
||||
//! system/history breakdown.
|
||||
//!
|
||||
//! # 方針
|
||||
//!
|
||||
//! - ローカルトークナイザは持たない。実測値があればそれを採用し、
|
||||
//! measurement 間はバイト数で按分、最新 measurement より先は byte/4
|
||||
//! fallback で外挿する
|
||||
//! - 推定の出どころは [`EstimateSource`] で呼び出し側に明示する。
|
||||
//! 課金判断には使えないが、compact / prune / memory extract trigger 等の
|
||||
//! 閾値判定には十分な精度
|
||||
//! - `records` は `history_len` 昇順を仮定する(呼び出し側がそのように積む)
|
||||
//! [`EstimateSource`] distinguishes measurements from estimates. Estimates inform
|
||||
//! threshold decisions; they neither determine billing nor guarantee that a request
|
||||
//! fits the provider's context limit. Attachment base64 is a storage representation,
|
||||
//! not text input, and is excluded from byte counts. Provider measurements for
|
||||
//! requests that actually sent images remain valid. The caller supplies records in
|
||||
//! ascending `history_len` order.
|
||||
|
||||
use crate::{Item, UsageRecord};
|
||||
|
||||
/// 推定の出どころ。
|
||||
/// Source of a token estimate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EstimateSource {
|
||||
/// measurement の境界にちょうど一致(実測値そのもの)
|
||||
/// Exact match with a measurement boundary; uses the measured value.
|
||||
Measured,
|
||||
/// 連続する 2 つの measurement の間をバイト按分で計算
|
||||
/// Interpolated between consecutive measurements in proportion to byte count.
|
||||
Interpolated,
|
||||
/// 最後の measurement より新しい区間を byte/4 fallback で外挿
|
||||
/// Extrapolated beyond the latest measurement using the bytes/4 fallback.
|
||||
Extrapolated,
|
||||
/// measurement が 1 件も無く、バイト数のみのフォールバック
|
||||
/// Byte-count fallback with no measurements available.
|
||||
NoData,
|
||||
}
|
||||
|
||||
/// トークン数の推定値。
|
||||
/// Estimated token count.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TokenEstimate {
|
||||
pub tokens: u64,
|
||||
pub source: EstimateSource,
|
||||
}
|
||||
|
||||
/// `items[..i]` までの累積バイト数(`prefix[i]`)を返す。長さは `items.len()+1`。
|
||||
/// Returns cumulative bytes for `items[..i]` in `prefix[i]`, with length `items.len() + 1`.
|
||||
pub fn prefix_bytes(items: &[Item]) -> Vec<u64> {
|
||||
let mut prefix = Vec::with_capacity(items.len() + 1);
|
||||
let mut acc: u64 = 0;
|
||||
@@ -47,7 +46,7 @@ pub fn prefix_bytes(items: &[Item]) -> Vec<u64> {
|
||||
prefix
|
||||
}
|
||||
|
||||
/// 1 Item の text-shaped size。JSON シリアライズ長を使う粗い近似。
|
||||
/// Approximate text size of one Item, using its JSON serialization length.
|
||||
///
|
||||
/// Binary attachments are deliberately projected out. Their durable base64 encoding is a
|
||||
/// storage representation, not provider text, so charging it at the text `bytes / 4` fallback
|
||||
@@ -63,11 +62,11 @@ pub fn item_bytes(item: &Item) -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `history[..index]` までのトークン数を推定する。
|
||||
/// Estimates the token count of `history[..index]`.
|
||||
///
|
||||
/// `prefix` は [`prefix_bytes`] で得た `history.len() + 1` 長の累積バイト列。
|
||||
/// 呼び出し側が 1 度だけ計算して使い回すことで、線形探索や複数回の推定が
|
||||
/// O(n) シリアライズで済む(内部で毎回再計算すると O(n²) になる)。
|
||||
/// `prefix` contains `history.len() + 1` cumulative byte counts from [`prefix_bytes`].
|
||||
/// Computing it once and reusing it keeps serialization for a scan or repeated
|
||||
/// estimates O(n), rather than O(n²) from recomputing it for every estimate.
|
||||
pub fn tokens_at(
|
||||
history: &[Item],
|
||||
records: &[UsageRecord],
|
||||
@@ -91,7 +90,7 @@ pub fn tokens_at(
|
||||
};
|
||||
}
|
||||
|
||||
// exact match(rev 走査で一番新しい record を採用)
|
||||
// Exact match: reverse iteration selects the most recent record.
|
||||
if let Some(r) = records.iter().rev().find(|r| r.history_len == index) {
|
||||
return TokenEstimate {
|
||||
tokens: r.input_total_tokens,
|
||||
@@ -153,15 +152,15 @@ pub fn tokens_at(
|
||||
}
|
||||
}
|
||||
|
||||
/// 現在の history 全体の推定トークン数。
|
||||
/// Estimated token count for the entire current history.
|
||||
pub fn total_tokens(history: &[Item], records: &[UsageRecord]) -> TokenEstimate {
|
||||
let prefix = prefix_bytes(history);
|
||||
tokens_at(history, records, history.len(), &prefix)
|
||||
}
|
||||
|
||||
/// 任意の history index 時点でのプロンプト全長推定。
|
||||
/// `history_len == 0` で 0 を返す。delta 計算 (extract trigger 等) で
|
||||
/// `total_tokens_at(now) - total_tokens_at(pointer)` の形で使う。
|
||||
/// Estimates the full prompt token count at an arbitrary history boundary.
|
||||
/// Returns zero for `history_len == 0`. Differences such as
|
||||
/// `total_tokens_at(now) - total_tokens_at(pointer)` can drive extraction triggers.
|
||||
pub fn total_tokens_at(
|
||||
history: &[Item],
|
||||
records: &[UsageRecord],
|
||||
|
||||
+17
-6
@@ -30,6 +30,9 @@ pub enum ToolError {
|
||||
/// Internal error
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
/// Machine-readable domain conflict that a tool adapter may project as structured output.
|
||||
#[error("{code}: {message}")]
|
||||
StructuredConflict { code: String, message: String },
|
||||
/// Cooperative cancellation completed with bounded terminal output.
|
||||
#[error("Tool execution cancelled")]
|
||||
Cancelled(ToolOutput),
|
||||
@@ -333,15 +336,16 @@ pub type ToolDefinition = Arc<dyn Fn() -> (ToolMeta, Arc<dyn Tool>) + Send + Syn
|
||||
|
||||
/// Per-call context supplied by the engine when executing a tool call.
|
||||
///
|
||||
/// The context identifies a tool call within one assistant response's tool-call
|
||||
/// batch without imposing any scheduling policy on the engine. Tool
|
||||
/// implementations may use it for response-local ordering, diagnostics, or
|
||||
/// correlation, but it is intentionally not a handle to engine state, history,
|
||||
/// or session mutation.
|
||||
/// Live batch identity and ordering are response-local. An optional opaque host
|
||||
/// identity comes from the committed ToolCall annotation and survives restore.
|
||||
/// Neither is a handle to engine state, history, or session mutation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ToolExecutionContext {
|
||||
/// Provider/tool-call id for the call being executed.
|
||||
pub call_id: String,
|
||||
/// Opaque host identity from the committed ToolCall annotation, stable across
|
||||
/// restoration. Provider call IDs and live batch IDs are not replay authority.
|
||||
pub logical_call_id: Option<String>,
|
||||
/// Engine-local identity shared by all tool calls from one execution batch.
|
||||
pub batch_id: String,
|
||||
/// Zero-based order of this call in the model-returned tool-call list.
|
||||
@@ -352,11 +356,18 @@ impl ToolExecutionContext {
|
||||
pub fn new(call_id: impl Into<String>, batch_id: impl Into<String>, call_index: usize) -> Self {
|
||||
Self {
|
||||
call_id: call_id.into(),
|
||||
logical_call_id: None,
|
||||
batch_id: batch_id.into(),
|
||||
call_index,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind an identity supplied by the host's persisted-call annotation.
|
||||
pub fn with_logical_call_id(mut self, logical_call_id: impl Into<String>) -> Self {
|
||||
self.logical_call_id = Some(logical_call_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Identifies one live execution attempt without making the batch id a durable
|
||||
/// replay or idempotency authority.
|
||||
pub fn execution_id(&self) -> String {
|
||||
@@ -569,7 +580,7 @@ pub trait Tool: Send + Sync {
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `input_json` - JSON-formatted arguments generated by LLM
|
||||
/// * `ctx` - response-local call identity and ordering context
|
||||
/// * `ctx` - per-call identity and live execution ordering context
|
||||
///
|
||||
/// # Returns
|
||||
/// A [`ToolOutput`] with summary and optional detailed content.
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
//! Per-LLM-request Usage measurement snapshot.
|
||||
//! Per-LLM-request usage measurement snapshot.
|
||||
//!
|
||||
//! 1 リクエストの送信時点での「ある history prefix 長で計測した占有量」を
|
||||
//! 1 件分にまとめたもの。`UsageEvent` (provider stream イベント) を
|
||||
//! 受けて呼び出し側が組み立て、永続化層に流したり、token accounting
|
||||
//! (`token_counter`) で履歴として参照したりする。
|
||||
//! Records measured token usage for the history prefix included in one request.
|
||||
//! The caller builds it from provider `UsageEvent`s and passes it to persistence
|
||||
//! or to token accounting (`token_counter`).
|
||||
|
||||
/// LLM リクエスト送信時点での占有量スナップショット。
|
||||
/// Usage snapshot for the history prefix sent with an LLM request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageRecord {
|
||||
/// 送信時の history.len()
|
||||
/// `history.len()` when the request was sent.
|
||||
pub history_len: usize,
|
||||
/// history[..history_len] の占有量(プロンプト全長、実測)
|
||||
/// Measured total prompt tokens for `history[..history_len]`.
|
||||
pub input_total_tokens: u64,
|
||||
/// 上記のうちキャッシュから読み出された分
|
||||
/// Portion of the input tokens read from cache.
|
||||
pub cache_read_tokens: u64,
|
||||
/// 上記のうちこのリクエストでキャッシュに書かれた分
|
||||
/// Portion of the input tokens written to cache by this request.
|
||||
pub cache_write_tokens: u64,
|
||||
/// このリクエストで生成された出力トークン数
|
||||
/// Output tokens generated by this request.
|
||||
pub output_tokens: u64,
|
||||
}
|
||||
|
||||
@@ -16,6 +16,182 @@ use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
|
||||
/// A per-run tracing sink: no global subscriber or output-body logging.
|
||||
#[derive(Clone)]
|
||||
struct DiagnosticLog(Arc<Mutex<Vec<u8>>>);
|
||||
|
||||
impl std::io::Write for DiagnosticLog {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn output_limits_keep_partial_results_without_warning_callbacks() {
|
||||
use agen::tool::{Attachment, ImageAttachment, ToolOutputLimits, ToolResult};
|
||||
use agen::{History, Item, ToolResultDisposition};
|
||||
use tracing::instrument::WithSubscriber;
|
||||
|
||||
let limit = 256;
|
||||
let cases = [
|
||||
("below", "small result".to_string(), limit),
|
||||
("exact", "x".repeat(limit), limit),
|
||||
("Grep", "matched line\n".repeat(200), limit),
|
||||
("Read", "あいうえお".repeat(200), 120),
|
||||
];
|
||||
let mut events = Vec::new();
|
||||
for (index, name) in cases.iter().map(|c| c.0).chain(["erroring"]).enumerate() {
|
||||
events.extend([
|
||||
Event::tool_use_start(index, name, name),
|
||||
Event::tool_input_delta(index, "{}"),
|
||||
Event::tool_use_stop(index),
|
||||
]);
|
||||
}
|
||||
events.push(Event::Status(ClientStatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}));
|
||||
let client = MockLlmClient::with_responses(vec![
|
||||
events,
|
||||
vec![Event::Status(ClientStatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})],
|
||||
]);
|
||||
let probe = client.clone();
|
||||
let mut engine = Engine::new(client);
|
||||
engine.set_tool_call_dispatch_mode(agen::ToolCallDispatchMode::AfterResponse);
|
||||
engine.set_tool_output_limits(Some(ToolOutputLimits {
|
||||
default_max_bytes: limit,
|
||||
per_tool: [("Read".to_string(), 120)].into(),
|
||||
}));
|
||||
let attachments = vec![Attachment::Image(ImageAttachment::new(
|
||||
"image/png",
|
||||
b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x01\0\0\0\x01".to_vec(),
|
||||
))];
|
||||
for (name, content, _) in &cases {
|
||||
engine.register_tool(fixed_tool(
|
||||
name,
|
||||
ToolOutput {
|
||||
summary: format!("result from {name}"),
|
||||
content: Some(content.clone()),
|
||||
attachments: attachments.clone(),
|
||||
},
|
||||
));
|
||||
}
|
||||
engine.register_tool(erroring_tool("erroring", "execution failed"));
|
||||
|
||||
let warnings = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = warnings.clone();
|
||||
engine.on_warning(move |warning| sink.lock().unwrap().push(warning.to_owned()));
|
||||
let published = Arc::new(Mutex::new(Vec::<ToolResult>::new()));
|
||||
let sink = published.clone();
|
||||
engine.on_tool_result(move |result| sink.lock().unwrap().push(result.clone()));
|
||||
let committed = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = committed.clone();
|
||||
engine.on_history_append(move |item| {
|
||||
if matches!(item, Item::ToolResult { .. }) {
|
||||
sink.lock().unwrap().push(item.clone());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let log = DiagnosticLog(Arc::new(Mutex::new(Vec::new())));
|
||||
let writer = log.clone();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.without_time()
|
||||
.with_ansi(false)
|
||||
.with_max_level(tracing::Level::WARN)
|
||||
.with_writer(move || writer.clone())
|
||||
.finish();
|
||||
let mut history = History::new();
|
||||
let run = engine
|
||||
.run(&mut history, "run tools")
|
||||
.with_subscriber(subscriber)
|
||||
.await;
|
||||
assert!(matches!(run.result, agen::EngineRunExit::Finished));
|
||||
assert!(warnings.lock().unwrap().is_empty());
|
||||
|
||||
let requests = probe.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let saved: Vec<_> = history
|
||||
.iter()
|
||||
.filter_map(|entry| matches!(entry.item, Item::ToolResult { .. }).then_some(&entry.item))
|
||||
.collect();
|
||||
assert_eq!(saved.len(), cases.len() + 1);
|
||||
let committed = committed.lock().unwrap();
|
||||
let published = published.lock().unwrap();
|
||||
let logs = String::from_utf8(log.0.lock().unwrap().clone()).unwrap();
|
||||
for item in saved {
|
||||
let Item::ToolResult {
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
disposition,
|
||||
is_error,
|
||||
attachments: saved_attachments,
|
||||
..
|
||||
} = item
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(committed.iter().any(|candidate| candidate == item));
|
||||
assert!(requests[1].items.iter().any(|candidate| candidate == item));
|
||||
let result = published
|
||||
.iter()
|
||||
.find(|result| result.tool_use_id == *call_id)
|
||||
.unwrap();
|
||||
assert_eq!(&result.summary, summary);
|
||||
assert_eq!(&result.content, content);
|
||||
assert_eq!(&result.disposition, disposition);
|
||||
assert_eq!(&result.is_error, is_error);
|
||||
assert_eq!(&result.attachments, saved_attachments);
|
||||
if call_id == "erroring" {
|
||||
assert_eq!(*disposition, ToolResultDisposition::Error);
|
||||
assert!(*is_error);
|
||||
assert!(content.is_none());
|
||||
continue;
|
||||
}
|
||||
assert_eq!(*disposition, ToolResultDisposition::Success);
|
||||
assert!(!*is_error);
|
||||
assert_eq!(*summary, format!("result from {call_id}"));
|
||||
assert_eq!(saved_attachments, &attachments);
|
||||
let (_, original, cap) = cases.iter().find(|c| c.0 == call_id).unwrap();
|
||||
let content = content.as_ref().unwrap();
|
||||
assert!(content.len() <= *cap);
|
||||
if original.len() <= *cap {
|
||||
assert_eq!(content, original);
|
||||
} else {
|
||||
let (body, marker) = content.split_once("\n\n[truncated: ").unwrap();
|
||||
assert!(original.starts_with(body));
|
||||
assert_eq!(
|
||||
marker,
|
||||
format!(
|
||||
"{} bytes dropped, refine your query]",
|
||||
original.len() - body.len()
|
||||
)
|
||||
);
|
||||
let diagnostic = logs
|
||||
.lines()
|
||||
.find(|line| line.contains(&format!("tool={call_id}")))
|
||||
.unwrap();
|
||||
assert!(diagnostic.contains(&format!("before_bytes={}", original.len())));
|
||||
assert!(diagnostic.contains(&format!("after_bytes={}", content.len())));
|
||||
assert!(diagnostic.contains(&format!("limit_bytes={cap}")));
|
||||
assert!(!diagnostic.contains(body));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
logs.lines()
|
||||
.filter(|line| line.contains("Tool output exceeded byte limit"))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FailOnceClient {
|
||||
calls: Arc<AtomicUsize>,
|
||||
|
||||
@@ -0,0 +1,738 @@
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::llm_client::scheme::{Scheme, openai_responses::OpenAIResponsesScheme};
|
||||
use agen::llm_client::{ClientError, LlmClient, Request, ResponseStream};
|
||||
use agen::tool::{Attachment, ImageAttachment};
|
||||
use agen::{Engine, EngineRunExit, History, Item, ToolResultDisposition};
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn rejection() -> ClientError {
|
||||
ClientError::Api { status: Some(400), code: Some("invalid_value".into()), message: "The image you provided requires 52150 patches after processing, exceeding the limit of 30000. Please resize the image and try again.".into(), retry_after: None }
|
||||
}
|
||||
|
||||
fn image_result(id: &str, width: u32, height: u32, padding: usize) -> Item {
|
||||
// PNG header: dimension inspection never decodes/allocates the pixel plane.
|
||||
let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
|
||||
bytes.extend(width.to_be_bytes());
|
||||
bytes.extend(height.to_be_bytes());
|
||||
bytes.resize(40 + padding, 0);
|
||||
Item::ToolResult {
|
||||
id: None,
|
||||
call_id: id.into(),
|
||||
summary: "Attached image".into(),
|
||||
content: None,
|
||||
disposition: ToolResultDisposition::Success,
|
||||
is_error: false,
|
||||
attachments: vec![Attachment::Image(ImageAttachment::new("image/png", bytes))],
|
||||
}
|
||||
}
|
||||
fn history() -> History {
|
||||
History::from_items(vec![
|
||||
Item::user_message("inspect"),
|
||||
Item::tool_call("small", "ViewImage", "{}"),
|
||||
Item::tool_call("large", "ViewImage", "{}"),
|
||||
image_result("small", 10, 10, 2000),
|
||||
image_result("large", 1600, 33349, 0),
|
||||
])
|
||||
}
|
||||
fn images(request: &Request) -> Vec<String> {
|
||||
request
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
attachments,
|
||||
..
|
||||
} if !attachments.is_empty() => Some(call_id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum RejectionPath {
|
||||
Http,
|
||||
SseError,
|
||||
SseNestedError,
|
||||
ResponseFailed,
|
||||
}
|
||||
const STREAM_PATHS: [RejectionPath; 3] = [
|
||||
RejectionPath::SseError,
|
||||
RejectionPath::SseNestedError,
|
||||
RejectionPath::ResponseFailed,
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Client {
|
||||
requests: Arc<Mutex<Vec<Request>>>,
|
||||
failures: usize,
|
||||
other_error: bool,
|
||||
path: RejectionPath,
|
||||
prefix: Vec<(&'static str, Value)>,
|
||||
wait_before_error: Option<Arc<tokio::sync::Notify>>,
|
||||
rejection_data: Option<&'static str>,
|
||||
}
|
||||
impl Client {
|
||||
fn new(failures: usize) -> Self {
|
||||
Self {
|
||||
requests: Arc::default(),
|
||||
failures,
|
||||
other_error: false,
|
||||
path: RejectionPath::Http,
|
||||
prefix: Vec::new(),
|
||||
wait_before_error: None,
|
||||
rejection_data: None,
|
||||
}
|
||||
}
|
||||
fn streamed(failures: usize, path: RejectionPath) -> Self {
|
||||
Self {
|
||||
path,
|
||||
..Self::new(failures)
|
||||
}
|
||||
}
|
||||
fn reject(&self, error: ClientError) -> Result<ResponseStream, ClientError> {
|
||||
if matches!(self.path, RejectionPath::Http) {
|
||||
return Err(error);
|
||||
}
|
||||
let ClientError::Api { code, message, .. } = error else {
|
||||
unreachable!()
|
||||
};
|
||||
let error = json!({"type":"invalid_request_error", "code":code, "message":message, "param":"input"});
|
||||
let (kind, data) = match self.path {
|
||||
RejectionPath::SseError => (
|
||||
"error",
|
||||
json!({"type":"error", "code":code, "message":message, "param":"input", "error_type":"invalid_request_error"}),
|
||||
),
|
||||
RejectionPath::SseNestedError => ("error", json!({"type":"error", "error":error})),
|
||||
RejectionPath::ResponseFailed => (
|
||||
"response.failed",
|
||||
json!({"type":"response.failed", "response":{"id":"rejected", "error":error}}),
|
||||
),
|
||||
RejectionPath::Http => unreachable!(),
|
||||
};
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let mut state = Default::default();
|
||||
let mut events = scheme.parse_sse("response.created", r#"{"response":{}}"#, &mut state)?;
|
||||
for (kind, data) in &self.prefix {
|
||||
events.extend(scheme.parse_sse(kind, &data.to_string(), &mut state)?);
|
||||
}
|
||||
let encoded = data.to_string();
|
||||
events.extend(scheme.parse_sse(
|
||||
kind,
|
||||
self.rejection_data.unwrap_or(&encoded),
|
||||
&mut state,
|
||||
)?);
|
||||
let wait = self.wait_before_error.clone();
|
||||
Ok(Box::pin(futures::stream::iter(events).then(move |event| {
|
||||
let wait = wait.clone();
|
||||
async move {
|
||||
if matches!(event, Event::Error(_))
|
||||
&& let Some(wait) = wait
|
||||
{
|
||||
wait.notified().await;
|
||||
}
|
||||
Ok(event)
|
||||
}
|
||||
})))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl LlmClient for Client {
|
||||
fn tool_call_completion_support(&self) -> agen::llm_client::ToolCallCompletionSupport {
|
||||
agen::llm_client::ToolCallCompletionSupport::PerBlock
|
||||
}
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
|
||||
let mut requests = self.requests.lock().unwrap();
|
||||
requests.push(request);
|
||||
if requests.len() <= self.failures {
|
||||
return self.reject(rejection());
|
||||
}
|
||||
if self.other_error {
|
||||
return self.reject(ClientError::Api {
|
||||
status: Some(400),
|
||||
code: Some("invalid_value".into()),
|
||||
message: "invalid tool arguments".into(),
|
||||
retry_after: None,
|
||||
});
|
||||
}
|
||||
Ok(Box::pin(futures::stream::iter(vec![
|
||||
Ok(Event::text_block_start(0)),
|
||||
Ok(Event::text_delta(0, "resize and retry")),
|
||||
Ok(Event::text_block_stop(0, None)),
|
||||
Ok(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})),
|
||||
])))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removes_largest_pixels_not_largest_file_and_keeps_pairing() {
|
||||
let client = Client::new(1);
|
||||
let observer = client.clone();
|
||||
let mut history = history();
|
||||
let mut engine = Engine::new(client);
|
||||
let committed = Arc::new(Mutex::new(Vec::new()));
|
||||
let commits = committed.clone();
|
||||
engine.set_image_rejection_handler(move |original, replacement| {
|
||||
commits
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((original.item.clone(), replacement.clone()));
|
||||
Ok(())
|
||||
});
|
||||
let output = engine.run(&mut history, "continue").await;
|
||||
assert!(
|
||||
matches!(output.result, EngineRunExit::Finished),
|
||||
"{:?}",
|
||||
output.result
|
||||
);
|
||||
let requests = observer.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(images(&requests[0]), ["small", "large"]);
|
||||
assert_eq!(images(&requests[1]), ["small"]);
|
||||
assert!(requests[1].items.iter().any(|item| matches!(item, Item::ToolResult { call_id, is_error: true, disposition: ToolResultDisposition::Error, summary, .. } if call_id == "large" && summary.contains("retry ViewImage"))));
|
||||
assert_eq!(committed.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
history
|
||||
.items()
|
||||
.filter(|item| matches!(item, Item::ToolResult {call_id, ..} if call_id == "large"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_rejections_remove_one_image_at_a_time_and_stop_at_exhaustion() {
|
||||
let client = Client::new(99);
|
||||
let observer = client.clone();
|
||||
let mut history = history();
|
||||
let out = Engine::new(client).run(&mut history, "continue").await;
|
||||
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
|
||||
let requests = observer.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 3);
|
||||
assert_eq!(images(&requests[1]), ["small"]);
|
||||
assert!(images(&requests[2]).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrelated_error_stops_recovery_without_removing_another_image() {
|
||||
let mut client = Client::new(1);
|
||||
client.other_error = true;
|
||||
let observer = client.clone();
|
||||
let mut history = history();
|
||||
let out = Engine::new(client).run(&mut history, "continue").await;
|
||||
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
|
||||
assert_eq!(observer.requests.lock().unwrap().len(), 2);
|
||||
assert!(history.items().any(|item| matches!(item, Item::ToolResult {call_id, attachments, ..} if call_id == "small" && !attachments.is_empty())));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persistence_failure_does_not_change_history_or_retry() {
|
||||
let client = Client::new(99);
|
||||
let observer = client.clone();
|
||||
let mut history = history();
|
||||
let original = history.items_cloned();
|
||||
let mut engine = Engine::new(client);
|
||||
engine.set_image_rejection_handler(|_, _| Err("disk full".into()));
|
||||
let out = engine.run(&mut history, "continue").await;
|
||||
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
|
||||
assert_eq!(
|
||||
&history.items_cloned()[..original.len()],
|
||||
original.as_slice()
|
||||
);
|
||||
assert_eq!(observer.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn previously_accepted_images_are_not_recovery_candidates() {
|
||||
let client = Client::new(99);
|
||||
let observer = client.clone();
|
||||
let mut history = history();
|
||||
history.push(Item::assistant_message("I saw those images"));
|
||||
history.push(Item::tool_call("new", "ViewImage", "{}"));
|
||||
history.push(image_result("new", 1, 1, 0));
|
||||
let out = Engine::new(client).run(&mut history, "continue").await;
|
||||
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
|
||||
let requests = observer.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
// Already-consumed images are projected out, but their durable records
|
||||
// must not be rewritten as failures.
|
||||
assert!(images(&requests[1]).is_empty());
|
||||
assert_eq!(history.items().filter(|item| matches!(item, Item::ToolResult {call_id, is_error: false, attachments, ..} if (call_id == "small" || call_id == "large") && !attachments.is_empty())).count(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_empty_failed_request_boundaries_do_not_hide_candidates() {
|
||||
let client = Client::new(1);
|
||||
let observer = client.clone();
|
||||
let mut history = history();
|
||||
history.push(Item::assistant_response_boundary("failed-1"));
|
||||
history.push(Item::assistant_response_boundary("failed-2"));
|
||||
let out = Engine::new(client).run(&mut history, "continue").await;
|
||||
assert!(matches!(out.result, EngineRunExit::Finished));
|
||||
assert_eq!(images(&observer.requests.lock().unwrap()[1]), ["small"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn early_results_remain_candidates_despite_later_text_in_same_response() {
|
||||
let client = Client::new(1);
|
||||
let observer = client.clone();
|
||||
let mut history = History::from_items(vec![
|
||||
Item::user_message("inspect"),
|
||||
Item::assistant_response_boundary("response-1"),
|
||||
Item::tool_call("image", "ViewImage", "{}"),
|
||||
image_result("image", 99, 99, 0),
|
||||
Item::assistant_message("waiting for image"),
|
||||
]);
|
||||
let out = Engine::new(client).run(&mut history, "continue").await;
|
||||
assert!(
|
||||
matches!(out.result, EngineRunExit::Finished),
|
||||
"{:?}; requests: {:?}",
|
||||
out.result,
|
||||
observer.requests.lock().unwrap()
|
||||
);
|
||||
assert!(images(&observer.requests.lock().unwrap()[1]).is_empty());
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CountingTool {
|
||||
image: bool,
|
||||
count: Arc<std::sync::atomic::AtomicUsize>,
|
||||
executed: Option<Arc<tokio::sync::Notify>>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl agen::tool::Tool for CountingTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_: &str,
|
||||
_: agen::tool::ToolExecutionContext,
|
||||
) -> Result<agen::tool::ToolOutput, agen::tool::ToolError> {
|
||||
self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
if let Some(executed) = &self.executed {
|
||||
executed.notify_one();
|
||||
}
|
||||
let attachments = if self.image {
|
||||
let Item::ToolResult { attachments, .. } = image_result("image", 1600, 33349, 0) else {
|
||||
unreachable!()
|
||||
};
|
||||
attachments
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok(agen::tool::ToolOutput {
|
||||
summary: "executed once".into(),
|
||||
content: None,
|
||||
attachments,
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct ToolClient(Client);
|
||||
#[async_trait]
|
||||
impl LlmClient for ToolClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
fn tool_call_completion_support(&self) -> agen::llm_client::ToolCallCompletionSupport {
|
||||
agen::llm_client::ToolCallCompletionSupport::PerBlock
|
||||
}
|
||||
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
|
||||
if self.0.requests.lock().unwrap().is_empty() {
|
||||
self.0.requests.lock().unwrap().push(request);
|
||||
return Ok(Box::pin(futures::stream::iter(
|
||||
vec![
|
||||
Event::tool_use_start(0, "image", "ViewImage"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::tool_use_start(1, "side-effect", "Bash"),
|
||||
Event::tool_input_delta(1, "{}"),
|
||||
Event::tool_use_stop(1),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]
|
||||
.into_iter()
|
||||
.map(Ok),
|
||||
)));
|
||||
}
|
||||
self.0.stream(request).await
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_image_recovery_does_not_repeat_tools_in_either_dispatch_mode() {
|
||||
for mode in [
|
||||
agen::ToolCallDispatchMode::AfterResponse,
|
||||
agen::ToolCallDispatchMode::OnToolCallComplete,
|
||||
] {
|
||||
let client = Client::new(2);
|
||||
let mut engine = Engine::new(ToolClient(client.clone()));
|
||||
engine.set_tool_call_dispatch_mode(mode);
|
||||
let mut counts = Vec::new();
|
||||
for (name, image) in [("ViewImage", true), ("Bash", false)] {
|
||||
let tool = CountingTool {
|
||||
image,
|
||||
count: Arc::default(),
|
||||
executed: None,
|
||||
};
|
||||
counts.push(tool.count.clone());
|
||||
engine.register_tool(Arc::new(move || {
|
||||
(
|
||||
agen::tool::ToolMeta::new(name)
|
||||
.description("test")
|
||||
.input_schema(serde_json::json!({"type":"object"})),
|
||||
Arc::new(tool.clone()),
|
||||
)
|
||||
}));
|
||||
}
|
||||
let mut history = History::new();
|
||||
let out = engine.run(&mut history, "inspect").await;
|
||||
assert!(
|
||||
matches!(out.result, EngineRunExit::Finished),
|
||||
"{:?}",
|
||||
out.result
|
||||
);
|
||||
assert!(
|
||||
counts
|
||||
.iter()
|
||||
.all(|count| count.load(std::sync::atomic::Ordering::SeqCst) == 1)
|
||||
);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 3);
|
||||
assert_eq!(images(&requests[1]), ["image"]);
|
||||
assert!(images(&requests[2]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_error_classifier_is_not_a_generic_400_retry() {
|
||||
assert!(rejection().is_image_size_rejection());
|
||||
for (status, message) in [
|
||||
(400, "invalid image format"),
|
||||
(400, "image URL not found"),
|
||||
(400, "context window exceeds token limit"),
|
||||
(401, "image is too large"),
|
||||
(500, "image is too large"),
|
||||
] {
|
||||
assert!(
|
||||
!ClientError::Api {
|
||||
status: Some(status),
|
||||
code: Some("invalid_value".into()),
|
||||
message: message.into(),
|
||||
retry_after: None
|
||||
}
|
||||
.is_image_size_rejection()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streamed_image_rejections_correct_largest_candidate_before_next_request() {
|
||||
for path in STREAM_PATHS {
|
||||
let client = Client::streamed(1, path);
|
||||
let mut history = history();
|
||||
let mut engine = Engine::new(client.clone());
|
||||
let committed = Arc::new(Mutex::new(Vec::new()));
|
||||
let commits = committed.clone();
|
||||
engine.set_image_rejection_handler(move |original, replacement| {
|
||||
commits
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((original.item.clone(), replacement.clone()));
|
||||
Ok(())
|
||||
});
|
||||
let out = engine.run(&mut history, "continue").await;
|
||||
assert!(
|
||||
matches!(out.result, EngineRunExit::Finished),
|
||||
"{:?}",
|
||||
out.result
|
||||
);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(images(&requests[0]), ["small", "large"]);
|
||||
assert_eq!(images(&requests[1]), ["small"]);
|
||||
assert!(requests[1].items.iter().any(|item| matches!(item, Item::ToolResult { call_id, is_error: true, disposition: ToolResultDisposition::Error, summary, .. } if call_id == "large" && summary.contains("Resize or crop") && summary.contains("retry ViewImage"))));
|
||||
assert!(requests[1].items.iter().any(|item| matches!(item, Item::ToolCall { call_id, name, .. } if call_id == "large" && name == "ViewImage")));
|
||||
assert_eq!(committed.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
history
|
||||
.items()
|
||||
.filter(
|
||||
|item| matches!(item, Item::ToolResult { call_id, .. } if call_id == "large")
|
||||
)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streamed_rejections_stop_when_images_are_absent_or_candidates_exhausted() {
|
||||
for path in STREAM_PATHS {
|
||||
for (mut history, expected_requests) in [(History::new(), 1), (history(), 3)] {
|
||||
let client = Client::streamed(99, path);
|
||||
let out = Engine::new(client.clone())
|
||||
.run(&mut history, "continue")
|
||||
.await;
|
||||
assert!(
|
||||
matches!(out.result, EngineRunExit::Interrupted(_)),
|
||||
"{:?}",
|
||||
out.result
|
||||
);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), expected_requests);
|
||||
assert!(images(requests.last().unwrap()).is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrelated_streamed_invalid_value_preserves_images_and_diagnostic() {
|
||||
for path in STREAM_PATHS {
|
||||
let mut client = Client::streamed(0, path);
|
||||
client.other_error = true;
|
||||
let mut history = history();
|
||||
let original = history.items_cloned();
|
||||
let out = Engine::new(client.clone())
|
||||
.run(&mut history, "continue")
|
||||
.await;
|
||||
let EngineRunExit::Interrupted(reason) = out.result else {
|
||||
panic!("{:?}", out.result)
|
||||
};
|
||||
let reason = format!("{reason:?}");
|
||||
assert!(
|
||||
reason.contains("invalid_value")
|
||||
&& reason.contains("invalid tool arguments")
|
||||
&& reason.contains("diagnostic=")
|
||||
&& reason.contains("input"),
|
||||
"{reason}"
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
&history.items_cloned()[..original.len()],
|
||||
original.as_slice()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streamed_correction_commit_failure_does_not_mutate_or_resend() {
|
||||
for path in STREAM_PATHS {
|
||||
let client = Client::streamed(99, path);
|
||||
let mut history = history();
|
||||
let original = history.items_cloned();
|
||||
let mut engine = Engine::new(client.clone());
|
||||
engine.set_image_rejection_handler(|_, _| Err("disk full".into()));
|
||||
let out = engine.run(&mut history, "continue").await;
|
||||
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
|
||||
assert_eq!(
|
||||
&history.items_cloned()[..original.len()],
|
||||
original.as_slice()
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streamed_rejection_after_output_never_replays_or_corrects_input() {
|
||||
for path in STREAM_PATHS {
|
||||
// Start alone closes the replay window, as do partial/completed text,
|
||||
// reasoning and unrecognized events. None may change accepted input.
|
||||
for prefix in [
|
||||
vec![(
|
||||
"response.content_part.added",
|
||||
json!({"output_index":0,"content_index":0,"part":{"type":"output_text"}}),
|
||||
)],
|
||||
vec![(
|
||||
"response.output_text.delta",
|
||||
json!({"output_index":0,"content_index":0,"delta":"partial"}),
|
||||
)],
|
||||
vec![
|
||||
(
|
||||
"response.output_text.delta",
|
||||
json!({"output_index":0,"content_index":0,"delta":"completed"}),
|
||||
),
|
||||
(
|
||||
"response.content_part.done",
|
||||
json!({"output_index":0,"content_index":0,"part":{"type":"output_text"}}),
|
||||
),
|
||||
],
|
||||
vec![(
|
||||
"response.reasoning_text.delta",
|
||||
json!({"output_index":0,"content_index":0,"delta":"thinking"}),
|
||||
)],
|
||||
vec![("future.unknown", json!({"value":"unknown semantics"}))],
|
||||
] {
|
||||
let mut client = Client::streamed(1, path);
|
||||
client.prefix = prefix;
|
||||
let mut history = history();
|
||||
let original = history.items_cloned();
|
||||
let out = Engine::new(client.clone())
|
||||
.run(&mut history, "continue")
|
||||
.await;
|
||||
assert!(
|
||||
matches!(out.result, EngineRunExit::Interrupted(_)),
|
||||
"{:?}",
|
||||
out.result
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
&history.items_cloned()[..original.len()],
|
||||
original.as_slice()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn statusless_api_errors_are_not_image_retry_authority() {
|
||||
let ClientError::Api { code, message, .. } = rejection() else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(
|
||||
!ClientError::Api {
|
||||
status: None,
|
||||
code,
|
||||
message,
|
||||
retry_after: None
|
||||
}
|
||||
.is_image_size_rejection()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streamed_rejection_after_tool_completion_never_reexecutes_side_effects() {
|
||||
for path in STREAM_PATHS {
|
||||
for mode in [
|
||||
agen::ToolCallDispatchMode::AfterResponse,
|
||||
agen::ToolCallDispatchMode::OnToolCallComplete,
|
||||
] {
|
||||
let executed = Arc::new(tokio::sync::Notify::new());
|
||||
let mut client = Client::streamed(1, path);
|
||||
client.prefix = vec![
|
||||
(
|
||||
"response.output_item.added",
|
||||
json!({"output_index":0,"item":{"type":"function_call","call_id":"side-effect","name":"Bash","arguments":"{}"}}),
|
||||
),
|
||||
(
|
||||
"response.output_item.done",
|
||||
json!({"output_index":0,"item":{"type":"function_call","call_id":"side-effect","name":"Bash","arguments":"{}"}}),
|
||||
),
|
||||
];
|
||||
if mode == agen::ToolCallDispatchMode::OnToolCallComplete {
|
||||
client.wait_before_error = Some(executed.clone());
|
||||
}
|
||||
let tool = CountingTool {
|
||||
image: false,
|
||||
count: Arc::default(),
|
||||
executed: Some(executed),
|
||||
};
|
||||
let count = tool.count.clone();
|
||||
let mut engine = Engine::new(client.clone());
|
||||
engine.set_tool_call_dispatch_mode(mode);
|
||||
engine.register_tool(Arc::new(move || {
|
||||
(
|
||||
agen::tool::ToolMeta::new("Bash")
|
||||
.description("side effect")
|
||||
.input_schema(json!({"type":"object"})),
|
||||
Arc::new(tool.clone()),
|
||||
)
|
||||
}));
|
||||
let mut history = history();
|
||||
let original = history.items_cloned();
|
||||
let out = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
engine.run(&mut history, "continue"),
|
||||
)
|
||||
.await
|
||||
.expect("tool admission/error cleanup must terminate");
|
||||
assert!(
|
||||
matches!(out.result, EngineRunExit::Interrupted(_)),
|
||||
"{:?}",
|
||||
out.result
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
&history.items_cloned()[..original.len()],
|
||||
original.as_slice()
|
||||
);
|
||||
if mode == agen::ToolCallDispatchMode::OnToolCallComplete {
|
||||
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
assert_eq!(history.items().filter(|item| matches!(item, Item::ToolCall { call_id, .. } if call_id == "side-effect")).count(), 1);
|
||||
assert_eq!(history.items().filter(|item| matches!(item, Item::ToolResult { call_id, .. } if call_id == "side-effect")).count(), 1);
|
||||
} else {
|
||||
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
assert!(!history.items().any(|item| matches!(item, Item::ToolCall { call_id, .. } | Item::ToolResult { call_id, .. } if call_id == "side-effect")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_error_extra_fields_do_not_turn_unrelated_message_into_image_rejection() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let mut state = Default::default();
|
||||
let events = scheme.parse_sse("error", r#"{"type":"error","code":"invalid_value","message":"invalid tool arguments","param":"input","input":"image too large"}"#, &mut state).unwrap();
|
||||
let Event::Error(error) = &events[0] else {
|
||||
panic!("{events:?}")
|
||||
};
|
||||
assert_eq!(error.code.as_deref(), Some("invalid_value"));
|
||||
assert!(
|
||||
error
|
||||
.message
|
||||
.starts_with("invalid tool arguments | diagnostic=")
|
||||
);
|
||||
// HTTP uses the same diagnostic policy, but still requires status authority.
|
||||
assert!(
|
||||
!ClientError::Api {
|
||||
status: Some(400),
|
||||
code: error.code.clone(),
|
||||
message: error.message.clone(),
|
||||
retry_after: None
|
||||
}
|
||||
.is_image_size_rejection()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unparseable_stream_error_data_preserves_images_and_terminates_with_diagnostic() {
|
||||
for data in [
|
||||
r#"{"type":"error","code":"invalid_value","message":123,"input":"image too large"}"#,
|
||||
"unparseable provider error with echoed input: image too large",
|
||||
] {
|
||||
let mut client = Client::streamed(1, RejectionPath::SseError);
|
||||
client.rejection_data = Some(data);
|
||||
let mut history = history();
|
||||
let original = history.items_cloned();
|
||||
let out = Engine::new(client.clone())
|
||||
.run(&mut history, "continue")
|
||||
.await;
|
||||
let EngineRunExit::Interrupted(agen::RunInterruptionReason::Unexpected(
|
||||
agen::EngineError::Client(ClientError::Api { code, message, .. }),
|
||||
)) = out.result
|
||||
else {
|
||||
panic!(
|
||||
"malformed provider error must terminate, got {:?}",
|
||||
out.result
|
||||
);
|
||||
};
|
||||
assert_eq!(code, None);
|
||||
let (base, diagnostic) = message
|
||||
.split_once(" | diagnostic=")
|
||||
.expect("raw data remains diagnostic-only");
|
||||
assert!(!base.contains("image"));
|
||||
let diagnostic: Value = serde_json::from_str(diagnostic).unwrap();
|
||||
assert_eq!(diagnostic["error_extra"]["raw_data"], data);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
&history.items_cloned()[..original.len()],
|
||||
original.as_slice()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2873,3 +2873,737 @@ async fn post_tool_stop_terminalizes_started_parallel_siblings_before_returning(
|
||||
assert!(matches!(next, EngineRunExit::Finished));
|
||||
}
|
||||
}
|
||||
|
||||
// The same policy barrier is exercised through the real response-complete,
|
||||
// provider fallback, interceptor fallback, and early-dispatch engine paths.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum PolicyRoute {
|
||||
AfterResponse,
|
||||
ProviderFallback,
|
||||
InterceptorFallback,
|
||||
Early,
|
||||
}
|
||||
|
||||
impl PolicyRoute {
|
||||
fn completion_support(self) -> ToolCallCompletionSupport {
|
||||
match self {
|
||||
Self::ProviderFallback => ToolCallCompletionSupport::ResponseComplete,
|
||||
_ => ToolCallCompletionSupport::PerBlock,
|
||||
}
|
||||
}
|
||||
|
||||
fn configure(self, engine: &mut Engine<ControlledStreamClient>) {
|
||||
engine.set_max_turns(Some(1));
|
||||
if matches!(self, Self::AfterResponse) {
|
||||
engine.set_tool_call_dispatch_mode(ToolCallDispatchMode::AfterResponse);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotifyOnDrop(Arc<tokio::sync::Notify>);
|
||||
|
||||
impl Drop for NotifyOnDrop {
|
||||
fn drop(&mut self) {
|
||||
self.0.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PolicyBarrier {
|
||||
phase: InterceptorPhase,
|
||||
early_compatible: bool,
|
||||
entered: Arc<tokio::sync::Notify>,
|
||||
release: Arc<tokio::sync::Notify>,
|
||||
invocations: Arc<AtomicUsize>,
|
||||
continues: Arc<AtomicUsize>,
|
||||
retired: Arc<tokio::sync::Notify>,
|
||||
// Enqueue from the policy's final poll, after select has polled signals.
|
||||
// This deterministically exercises the policy-completion/ownership race.
|
||||
signal_on_continue: Option<tokio::sync::mpsc::Sender<()>>,
|
||||
}
|
||||
|
||||
impl PolicyBarrier {
|
||||
fn new(phase: InterceptorPhase, route: PolicyRoute) -> Self {
|
||||
Self {
|
||||
phase,
|
||||
early_compatible: !matches!(route, PolicyRoute::InterceptorFallback),
|
||||
entered: Arc::new(tokio::sync::Notify::new()),
|
||||
release: Arc::new(tokio::sync::Notify::new()),
|
||||
invocations: Arc::new(AtomicUsize::new(0)),
|
||||
continues: Arc::new(AtomicUsize::new(0)),
|
||||
retired: Arc::new(tokio::sync::Notify::new()),
|
||||
signal_on_continue: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait(&self) {
|
||||
let _retired = NotifyOnDrop(self.retired.clone());
|
||||
self.invocations.fetch_add(1, Ordering::SeqCst);
|
||||
self.entered.notify_one();
|
||||
self.release.notified().await;
|
||||
if let Some(signal) = &self.signal_on_continue {
|
||||
signal.try_send(()).unwrap();
|
||||
}
|
||||
self.continues.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for PolicyBarrier {
|
||||
fn supports_early_tool_dispatch(&self) -> bool {
|
||||
self.early_compatible
|
||||
}
|
||||
|
||||
async fn pre_tool_call(
|
||||
&self,
|
||||
_info: &mut ToolCallInfo<'_>,
|
||||
) -> InterceptorResult<PreToolAction> {
|
||||
if self.phase == InterceptorPhase::PreToolCall {
|
||||
self.wait().await;
|
||||
}
|
||||
Ok(PreToolAction::Continue)
|
||||
}
|
||||
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
_context: AssistantTurnEndContext<'_>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
if self.phase == InterceptorPhase::AssistantTurnEnd {
|
||||
self.wait().await;
|
||||
}
|
||||
Ok(TurnEndAction::Finish)
|
||||
}
|
||||
}
|
||||
|
||||
fn send_policy_response(tx: &tokio::sync::mpsc::UnboundedSender<Result<Event, ClientError>>) {
|
||||
for event in [
|
||||
Event::tool_use_start(0, "policy_call", "policy_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::usage(10, 3),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
] {
|
||||
tx.send(Ok(event)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_policy_interruption(exit: &EngineRunExit, pause: bool) {
|
||||
if pause {
|
||||
assert!(matches!(exit, EngineRunExit::Paused), "{exit:?}");
|
||||
} else {
|
||||
assert!(
|
||||
matches!(
|
||||
exit,
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled)
|
||||
),
|
||||
"{exit:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn call_and_result_count(history: &History) -> (usize, usize) {
|
||||
(
|
||||
history
|
||||
.items()
|
||||
.filter(
|
||||
|item| matches!(item, Item::ToolCall { call_id, .. } if call_id == "policy_call"),
|
||||
)
|
||||
.count(),
|
||||
history
|
||||
.items()
|
||||
.filter(
|
||||
|item| matches!(item, Item::ToolResult { call_id, .. } if call_id == "policy_call"),
|
||||
)
|
||||
.count(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_complete_pending_policies_interrupt_before_release_and_preserve_not_started() {
|
||||
for route in [
|
||||
PolicyRoute::AfterResponse,
|
||||
PolicyRoute::ProviderFallback,
|
||||
PolicyRoute::InterceptorFallback,
|
||||
] {
|
||||
for phase in [
|
||||
InterceptorPhase::PreToolCall,
|
||||
InterceptorPhase::AssistantTurnEnd,
|
||||
] {
|
||||
for pause in [false, true] {
|
||||
let (client, tx) = ControlledStreamClient::new(route.completion_support());
|
||||
let mut engine = Engine::new(client);
|
||||
route.configure(&mut engine);
|
||||
let policy = PolicyBarrier::new(phase, route);
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let tool = SlowTool::new("policy_tool", 0);
|
||||
engine.register_tool(tool.definition());
|
||||
let usage = Arc::new(AtomicUsize::new(0));
|
||||
let usage_probe = usage.clone();
|
||||
engine.on_usage(move |_| {
|
||||
usage_probe.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
let signal = if pause {
|
||||
engine.pause_sender()
|
||||
} else {
|
||||
engine.cancel_sender()
|
||||
};
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
send_policy_response(&tx);
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("policy entered");
|
||||
signal.send(()).await.unwrap();
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run).await
|
||||
.unwrap_or_else(|_| panic!("{route:?}/{phase:?}/pause={pause}: interruption waited for policy release"))
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, pause);
|
||||
assert_eq!(tool.call_count(), 0);
|
||||
assert_eq!(call_and_result_count(&history), (1, 0));
|
||||
assert!(history.items().any(|item| matches!(
|
||||
item,
|
||||
Item::ToolCall {
|
||||
execution_id: None,
|
||||
status: None | Some(agen::llm_client::ItemStatus::Completed),
|
||||
..
|
||||
}
|
||||
)));
|
||||
assert_eq!(usage.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(probe.invocations.load(Ordering::SeqCst), 1);
|
||||
probe.release.notify_one(); // late permission cannot create a task
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(tool.call_count(), 0);
|
||||
|
||||
// Recreate both history and engine as a restore boundary. Only
|
||||
// proven NotStarted is allowed to run, with normal pre-tool policy.
|
||||
let mut restored_history: History =
|
||||
serde_json::from_str(&serde_json::to_string(&history).unwrap()).unwrap();
|
||||
let mut restored =
|
||||
Engine::new(MockLlmClient::new(vec![Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})]));
|
||||
restored.set_max_turns(Some(1));
|
||||
restored.register_tool(tool.definition());
|
||||
let _ = restored.resume(&mut restored_history).await;
|
||||
assert_eq!(tool.call_count(), 1);
|
||||
assert_eq!(call_and_result_count(&restored_history), (1, 1));
|
||||
assert_eq!(usage.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn policy_final_poll_signal_prevents_provider_ownership_even_with_continue() {
|
||||
for route in [
|
||||
PolicyRoute::AfterResponse,
|
||||
PolicyRoute::ProviderFallback,
|
||||
PolicyRoute::InterceptorFallback,
|
||||
PolicyRoute::Early,
|
||||
] {
|
||||
for pause in [false, true] {
|
||||
let (client, tx) = ControlledStreamClient::new(route.completion_support());
|
||||
let mut engine = Engine::new(client);
|
||||
route.configure(&mut engine);
|
||||
let mut policy = PolicyBarrier::new(InterceptorPhase::PreToolCall, route);
|
||||
policy.signal_on_continue = Some(if pause {
|
||||
engine.pause_sender()
|
||||
} else {
|
||||
engine.cancel_sender()
|
||||
});
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let tool = SlowTool::new("policy_tool", 0);
|
||||
engine.register_tool(tool.definition());
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
send_policy_response(&tx);
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("policy entered");
|
||||
probe.release.notify_one();
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("final-poll signal processed")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, pause);
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(probe.invocations.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
tool.call_count(),
|
||||
0,
|
||||
"{route:?}/pause={pause}: Continue bypassed queued interruption"
|
||||
);
|
||||
assert_eq!(call_and_result_count(&history), (1, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Provider owns the terminal channel. Cancel acknowledges receipt but does not
|
||||
// manufacture a result, so tests can release a real terminal output or deliver
|
||||
// it after the engine's confirmation deadline (without a live side effect).
|
||||
#[derive(Clone)]
|
||||
struct PolicyOwnedTool {
|
||||
starts: Arc<AtomicUsize>,
|
||||
started: Arc<tokio::sync::Notify>,
|
||||
cancel_requested: Arc<tokio::sync::Notify>,
|
||||
terminal: Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Receiver<ToolOutput>>>>,
|
||||
retired: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for PolicyOwnedTool {
|
||||
async fn execute(&self, _: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
|
||||
let _retired = NotifyOnDrop(self.retired.clone());
|
||||
self.starts.fetch_add(1, Ordering::SeqCst);
|
||||
self.started.notify_one();
|
||||
Ok(self.terminal.lock().await.take().unwrap().await.unwrap())
|
||||
}
|
||||
async fn cancel(&self, _: &str) -> Result<(), ToolError> {
|
||||
self.cancel_requested.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_turn_end_interrupts_drain_owned_early_attempt_and_fence_late_result_on_restore() {
|
||||
for pause in [false, true] {
|
||||
for late in [false, true] {
|
||||
let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock);
|
||||
let mut engine = Engine::new(client);
|
||||
PolicyRoute::Early.configure(&mut engine);
|
||||
engine.set_tool_execution_policy(ToolExecutionPolicy {
|
||||
pause_safe_boundary_timeout: Duration::ZERO,
|
||||
cancellation_request_timeout: Duration::from_secs(1),
|
||||
terminal_confirmation_timeout: if late {
|
||||
Duration::ZERO
|
||||
} else {
|
||||
Duration::from_secs(2)
|
||||
},
|
||||
});
|
||||
let policy = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early);
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
|
||||
let tool = PolicyOwnedTool {
|
||||
starts: Arc::new(AtomicUsize::new(0)),
|
||||
started: Arc::new(tokio::sync::Notify::new()),
|
||||
cancel_requested: Arc::new(tokio::sync::Notify::new()),
|
||||
terminal: Arc::new(tokio::sync::Mutex::new(Some(terminal_rx))),
|
||||
retired: Arc::new(tokio::sync::Notify::new()),
|
||||
};
|
||||
let definition: ToolDefinition = {
|
||||
let tool = tool.clone();
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new("policy_tool")
|
||||
.input_schema(serde_json::json!({"type":"object"})),
|
||||
Arc::new(tool.clone()) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
};
|
||||
engine.register_tool(definition.clone());
|
||||
let committed = Arc::new(AtomicUsize::new(0));
|
||||
let commit_probe = committed.clone();
|
||||
engine.on_history_append(move |item| {
|
||||
if matches!(item, Item::ToolResult { .. }) {
|
||||
commit_probe.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
let published = Arc::new(AtomicUsize::new(0));
|
||||
let publish_probe = published.clone();
|
||||
let commit_probe = committed.clone();
|
||||
engine.on_tool_result(move |_| {
|
||||
assert_eq!(
|
||||
commit_probe.load(Ordering::SeqCst),
|
||||
1,
|
||||
"commit before publication"
|
||||
);
|
||||
publish_probe.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
let usage = Arc::new(AtomicUsize::new(0));
|
||||
let usage_probe = usage.clone();
|
||||
engine.on_usage(move |_| {
|
||||
usage_probe.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
let signal = if pause {
|
||||
engine.pause_sender()
|
||||
} else {
|
||||
engine.cancel_sender()
|
||||
};
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
// Wait for ownership before completing the assistant response.
|
||||
for event in [
|
||||
Event::tool_use_start(0, "policy_call", "policy_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
] {
|
||||
tx.send(Ok(event)).unwrap();
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(2), tool.started.notified())
|
||||
.await
|
||||
.expect("early provider started");
|
||||
tx.send(Ok(Event::usage(10, 3))).unwrap();
|
||||
tx.send(Ok(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})))
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("turn-end policy pending");
|
||||
signal.send(()).await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), tool.cancel_requested.notified())
|
||||
.await
|
||||
.expect("owned provider cancellation progresses before policy release");
|
||||
let terminal_tx = if late {
|
||||
Some(terminal_tx)
|
||||
} else {
|
||||
terminal_tx
|
||||
.send(ToolOutput::from("confirmed after interruption".to_string()))
|
||||
.unwrap();
|
||||
None
|
||||
};
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("terminalization does not await turn-end policy")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, pause);
|
||||
assert_eq!(call_and_result_count(&history), (1, 1));
|
||||
assert_eq!(committed.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(published.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(usage.load(Ordering::SeqCst), 1);
|
||||
assert!(history.items().any(|item| matches!(
|
||||
item,
|
||||
Item::ToolCall {
|
||||
execution_id: Some(_),
|
||||
status: Some(agen::llm_client::ItemStatus::InProgress),
|
||||
..
|
||||
}
|
||||
)));
|
||||
assert!(history.items().any(|item| matches!(item, Item::ToolResult { disposition, .. } if *disposition == if late { ToolResultDisposition::OutcomeUnknown } else { ToolResultDisposition::Success })));
|
||||
if let Some(terminal_tx) = terminal_tx {
|
||||
tokio::time::timeout(Duration::from_secs(2), tool.retired.notified())
|
||||
.await
|
||||
.expect("forced-close retired the owned receiver");
|
||||
// Force-close retires the receiver; a provider's late completion
|
||||
// cannot reenter history or replace OutcomeUnknown.
|
||||
assert!(
|
||||
terminal_tx
|
||||
.send(ToolOutput::from("late completion".to_string()))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
probe.release.notify_one();
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 0);
|
||||
let original_items = history.items().cloned().collect::<Vec<_>>();
|
||||
let mut resumed_history = history.clone();
|
||||
let mut resumed_engine = output.engine;
|
||||
let _ = resumed_engine.resume(&mut resumed_history).await;
|
||||
assert_eq!(
|
||||
tool.starts.load(Ordering::SeqCst),
|
||||
1,
|
||||
"same-engine resume cannot rerun a terminal attempt"
|
||||
);
|
||||
assert_eq!(call_and_result_count(&resumed_history), (1, 1));
|
||||
let mut restored_history: History =
|
||||
serde_json::from_str(&serde_json::to_string(&history).unwrap()).unwrap();
|
||||
let restored_client = MockLlmClient::new(vec![Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})]);
|
||||
let request_probe = restored_client.clone();
|
||||
let mut restored = Engine::new(restored_client);
|
||||
restored.register_tool(definition);
|
||||
restored.set_max_turns(Some(1));
|
||||
let _ = restored.resume(&mut restored_history).await;
|
||||
let requests = request_probe.requests();
|
||||
assert_eq!(requests.len(), 1);
|
||||
let replay = requests[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Item::ToolCall { call_id, .. } => Some(format!("call:{call_id}")),
|
||||
Item::ToolResult { call_id, .. } => Some(format!("result:{call_id}")),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(replay, ["call:policy_call", "result:policy_call"]);
|
||||
assert_eq!(tool.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(call_and_result_count(&restored_history), (1, 1));
|
||||
assert_eq!(
|
||||
restored_history
|
||||
.items()
|
||||
.take(original_items.len())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>(),
|
||||
original_items,
|
||||
"restore must not rewrite original history"
|
||||
);
|
||||
assert_eq!(published.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_turn_end_pause_preserves_natural_safe_boundary_without_cancelling_started_tool() {
|
||||
let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock);
|
||||
let mut engine = Engine::new(client);
|
||||
PolicyRoute::Early.configure(&mut engine);
|
||||
engine.set_tool_execution_policy(ToolExecutionPolicy {
|
||||
pause_safe_boundary_timeout: Duration::from_secs(2),
|
||||
..ToolExecutionPolicy::default()
|
||||
});
|
||||
let policy = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early);
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let tool = SafePauseTool::new();
|
||||
// Use this tool's actual public name in the stream fixture.
|
||||
engine.register_tool(tool.definition());
|
||||
let pause = engine.pause_sender();
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
for event in [
|
||||
Event::tool_use_start(0, "policy_call", "safe_pause"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
] {
|
||||
tx.send(Ok(event)).unwrap();
|
||||
}
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("turn-end entered");
|
||||
pause.send(()).await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.retired.notified())
|
||||
.await
|
||||
.expect("pending policy interrupted before tool release");
|
||||
tool.release.notify_one();
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("safe boundary collected")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, true);
|
||||
assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(tool.cancellations.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(call_and_result_count(&history), (1, 1));
|
||||
probe.release.notify_one();
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_end_final_poll_signal_wins_over_finish_without_tools() {
|
||||
for pause in [false, true] {
|
||||
let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut policy = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early);
|
||||
policy.signal_on_continue = Some(if pause {
|
||||
engine.pause_sender()
|
||||
} else {
|
||||
engine.cancel_sender()
|
||||
});
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
engine.run(&mut history, "run").await
|
||||
});
|
||||
tx.send(Ok(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})))
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("turn-end entered");
|
||||
probe.release.notify_one();
|
||||
let output = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("final-poll signal processed")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, pause);
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_end_pause_deadline_reclaims_started_sibling_while_post_policy_is_pending() {
|
||||
struct TurnAndPostBarrier {
|
||||
turn: PolicyBarrier,
|
||||
post: PolicyBarrier,
|
||||
}
|
||||
#[async_trait]
|
||||
impl Interceptor for TurnAndPostBarrier {
|
||||
fn supports_early_tool_dispatch(&self) -> bool {
|
||||
true
|
||||
}
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
_: AssistantTurnEndContext<'_>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
self.turn.wait().await;
|
||||
Ok(TurnEndAction::Finish)
|
||||
}
|
||||
async fn post_tool_call(
|
||||
&self,
|
||||
_: &ToolResultInfo<'_>,
|
||||
) -> InterceptorResult<PostToolAction> {
|
||||
self.post.wait().await;
|
||||
Ok(PostToolAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock);
|
||||
let mut engine = Engine::new(client);
|
||||
engine.set_tool_execution_policy(ToolExecutionPolicy {
|
||||
pause_safe_boundary_timeout: Duration::from_millis(500),
|
||||
cancellation_request_timeout: Duration::from_secs(1),
|
||||
terminal_confirmation_timeout: Duration::from_secs(2),
|
||||
});
|
||||
let turn = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early);
|
||||
let post = PolicyBarrier::new(InterceptorPhase::PostToolCall, PolicyRoute::Early);
|
||||
engine.set_interceptor(TurnAndPostBarrier {
|
||||
turn: turn.clone(),
|
||||
post: post.clone(),
|
||||
});
|
||||
let completed = BarrierTool::new("completed_before_pause");
|
||||
engine.register_tool(completed.definition());
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
|
||||
let sibling = PolicyOwnedTool {
|
||||
starts: Arc::new(AtomicUsize::new(0)),
|
||||
started: Arc::new(tokio::sync::Notify::new()),
|
||||
cancel_requested: Arc::new(tokio::sync::Notify::new()),
|
||||
terminal: Arc::new(tokio::sync::Mutex::new(Some(terminal_rx))),
|
||||
retired: Arc::new(tokio::sync::Notify::new()),
|
||||
};
|
||||
let definition: ToolDefinition = {
|
||||
let sibling = sibling.clone();
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new("pending_sibling").input_schema(serde_json::json!({"type":"object"})),
|
||||
Arc::new(sibling.clone()) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
};
|
||||
engine.register_tool(definition.clone());
|
||||
let committed = Arc::new(AtomicUsize::new(0));
|
||||
let commit_probe = committed.clone();
|
||||
engine.on_history_append(move |item| {
|
||||
if matches!(item, Item::ToolResult { .. }) {
|
||||
commit_probe.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
let published = Arc::new(AtomicUsize::new(0));
|
||||
let publish_probe = published.clone();
|
||||
let commit_probe = committed.clone();
|
||||
engine.on_tool_result(move |_| {
|
||||
let n = publish_probe.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
assert_eq!(commit_probe.load(Ordering::SeqCst), n);
|
||||
});
|
||||
let pause = engine.pause_sender();
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
for (index, id, name) in [
|
||||
(0, "policy_call", "completed_before_pause"),
|
||||
(1, "sibling_call", "pending_sibling"),
|
||||
] {
|
||||
for event in [
|
||||
Event::tool_use_start(index, id, name),
|
||||
Event::tool_input_delta(index, "{}"),
|
||||
Event::tool_use_stop(index),
|
||||
] {
|
||||
tx.send(Ok(event)).unwrap();
|
||||
}
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(2), completed.started.notified())
|
||||
.await
|
||||
.expect("first provider owned");
|
||||
tokio::time::timeout(Duration::from_secs(2), sibling.started.notified())
|
||||
.await
|
||||
.expect("sibling provider owned");
|
||||
tx.send(Ok(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})))
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), turn.entered.notified())
|
||||
.await
|
||||
.expect("turn-end pending");
|
||||
pause.send(()).await.unwrap(); // only one interruption signal
|
||||
tokio::time::timeout(Duration::from_secs(2), turn.retired.notified())
|
||||
.await
|
||||
.expect("turn-end interrupted");
|
||||
completed.release.notify_one();
|
||||
tokio::time::timeout(Duration::from_secs(2), post.entered.notified())
|
||||
.await
|
||||
.expect("completed result's post policy pending");
|
||||
assert_eq!(committed.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(published.load(Ordering::SeqCst), 1);
|
||||
tokio::time::timeout(Duration::from_secs(2), sibling.cancel_requested.notified()).await.expect("original Pause deadline must reach sibling cancellation without releasing either policy");
|
||||
terminal_tx
|
||||
.send(ToolOutput::from("confirmed sibling terminal".to_string()))
|
||||
.unwrap();
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("all owned tools reclaimed")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, true);
|
||||
assert_eq!(committed.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(published.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(
|
||||
history
|
||||
.items()
|
||||
.filter(|item| matches!(item, Item::ToolResult { .. }))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(completed.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(sibling.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(post.invocations.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(post.continues.load(Ordering::SeqCst), 0);
|
||||
turn.release.notify_one();
|
||||
post.release.notify_one();
|
||||
let mut restored_history: History =
|
||||
serde_json::from_str(&serde_json::to_string(&history).unwrap()).unwrap();
|
||||
let mut restored = Engine::new(MockLlmClient::new(vec![Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})]));
|
||||
restored.register_tool(completed.definition());
|
||||
restored.register_tool(definition);
|
||||
restored.set_max_turns(Some(1));
|
||||
let _ = restored.resume(&mut restored_history).await;
|
||||
assert_eq!(completed.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(sibling.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
restored_history
|
||||
.items()
|
||||
.filter(|item| matches!(item, Item::ToolResult { .. }))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
//! Reasoning history round-trip 統合テスト
|
||||
//! Integration tests for reasoning history round trips.
|
||||
//!
|
||||
//! Engine のストリーム → history append → 次リクエスト送出までの
|
||||
//! ライフサイクルで `Item::Reasoning` が脱落せず保持されることを確認する。
|
||||
//! Verifies that `Item::Reasoning` survives the lifecycle from Engine streaming
|
||||
//! through history append to the next outgoing request.
|
||||
//!
|
||||
//! 検証点:
|
||||
//! - Anthropic 由来の thinking + signature が `Item::Reasoning::signature` として
|
||||
//! history に残る
|
||||
//! - OpenAI Responses 由来の reasoning text + summary + encrypted_content が
|
||||
//! `Item::Reasoning` の各フィールドに展開される
|
||||
//! - 直前の reasoning は次の outgoing request の `request.items` の先頭付近に
|
||||
//! 含まれる(assistant メッセージの先頭、Anthropic 仕様)
|
||||
//! Covers Anthropic thinking and signatures, OpenAI Responses reasoning text,
|
||||
//! summaries and encrypted content, and ordering reasoning before other assistant
|
||||
//! output as required by Anthropic.
|
||||
|
||||
mod common;
|
||||
|
||||
@@ -41,8 +37,8 @@ fn reasoning_block(text: impl Into<String>, data: ReasoningBlockData) -> Vec<Eve
|
||||
]
|
||||
}
|
||||
|
||||
/// Anthropic 風: thinking ブロック → text → 終了 のシーケンス。
|
||||
/// Engine history に Reasoning(signature 付き) → assistant_message が並ぶ。
|
||||
/// Anthropic-style sequence: thinking block, text, completion.
|
||||
/// History contains Reasoning with a signature followed by assistant_message.
|
||||
#[tokio::test]
|
||||
async fn anthropic_thinking_round_trips_signature_into_history() {
|
||||
let mut events = reasoning_block(
|
||||
@@ -85,8 +81,8 @@ async fn anthropic_thinking_round_trips_signature_into_history() {
|
||||
assert_eq!(entries[2].item.as_text(), Some("Here's the answer"));
|
||||
}
|
||||
|
||||
/// OpenAI Responses 風: encrypted_content + summary を持った reasoning が
|
||||
/// `Item::Reasoning` のフィールドに展開されること。
|
||||
/// OpenAI Responses reasoning with encrypted_content and summary is projected
|
||||
/// into the corresponding `Item::Reasoning` fields.
|
||||
#[tokio::test]
|
||||
async fn openai_reasoning_round_trips_encrypted_and_summary() {
|
||||
let mut events = reasoning_block(
|
||||
@@ -132,13 +128,13 @@ async fn openai_reasoning_round_trips_encrypted_and_summary() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reasoning は assistant ターン内で text/tool_call より先に並ぶこと(Anthropic
|
||||
/// が thinking を assistant メッセージの先頭に要求するため)。
|
||||
/// Reasoning precedes text/tool_call in the assistant turn because Anthropic
|
||||
/// requires thinking at the start of the assistant message.
|
||||
#[tokio::test]
|
||||
async fn reasoning_precedes_text_in_assistant_burst() {
|
||||
let mut events = vec![
|
||||
// text/tool_call とは独立に、reasoning block が中盤で完了しても、
|
||||
// history append 時には assistant items の先頭に置かれる。
|
||||
// Even if the reasoning block finishes after text/tool_call output,
|
||||
// it is placed first among assistant items when appended to history.
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "intermediate"),
|
||||
Event::text_block_stop(0, None),
|
||||
@@ -159,14 +155,14 @@ async fn reasoning_precedes_text_in_assistant_burst() {
|
||||
let _out = engine.run(&mut history, "q").await;
|
||||
|
||||
let entries = history.entries();
|
||||
// user / reasoning(先頭) / assistant_message
|
||||
// user / reasoning (first assistant item) / assistant_message
|
||||
assert!(matches!(entries[1].item, Item::Reasoning { .. }));
|
||||
assert_eq!(entries[2].item.as_text(), Some("intermediate"));
|
||||
}
|
||||
|
||||
/// resume シナリオ: history.json 由来の Item::Reasoning(signature) を Engine に
|
||||
/// 注入して run しても、次の outgoing request の `Request::items` にそのまま
|
||||
/// 載って LLM へ渡る(engine は items を改変しない契約)。
|
||||
/// Restored history: inject an Item::Reasoning with a signature as if restored
|
||||
/// from history.json. Running the Engine preserves it in the next `Request::items`
|
||||
/// sent to the LLM without modifying the reasoning item.
|
||||
#[tokio::test]
|
||||
async fn injected_reasoning_survives_into_outgoing_request() {
|
||||
use async_trait::async_trait;
|
||||
@@ -176,7 +172,7 @@ async fn injected_reasoning_survives_into_outgoing_request() {
|
||||
|
||||
use agen::llm_client::{ClientError, LlmClient, Request};
|
||||
|
||||
/// Request を 1 度だけキャプチャして空ストリームを返す client。
|
||||
/// Captures a Request and returns a stream containing only completion status.
|
||||
#[derive(Clone)]
|
||||
struct CapturingClient {
|
||||
captured: Arc<Mutex<Option<Request>>>,
|
||||
@@ -208,7 +204,7 @@ async fn injected_reasoning_survives_into_outgoing_request() {
|
||||
|
||||
let mut engine = Engine::new(client);
|
||||
let mut history: History = History::new();
|
||||
// resume: 既存 history を流し込む
|
||||
// Restore existing history before continuing.
|
||||
engine.set_history(
|
||||
&mut history,
|
||||
vec![
|
||||
@@ -225,7 +221,7 @@ async fn injected_reasoning_survives_into_outgoing_request() {
|
||||
.unwrap()
|
||||
.take()
|
||||
.expect("client should have received a request");
|
||||
// Reasoning item が outgoing items に保持されていること
|
||||
// The reasoning item must remain in the outgoing items.
|
||||
let mut found = false;
|
||||
for item in &req.items {
|
||||
if let Item::Reasoning {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! HTTP transport の単発 request / error classification テスト。
|
||||
//! Tests for single-attempt HTTP requests and error classification.
|
||||
//!
|
||||
//! Retry/backoff は Engine の lifecycle 管理に属するため、transport は 1 回だけ
|
||||
//! request を送り、HTTP status / Retry-After を `ClientError` に載せて返す。
|
||||
//! Retry and backoff belong to the Engine lifecycle. The transport sends one
|
||||
//! request and returns the HTTP status and Retry-After information in `ClientError`.
|
||||
|
||||
use agen::llm_client::LlmClient;
|
||||
use agen::llm_client::auth::AuthRequirement;
|
||||
@@ -17,8 +17,8 @@ use std::time::Duration;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// SSE 本体は触らないテスト用 scheme。`parse_fail` を立てると
|
||||
/// stream 消費中で `ClientError::Sse` を返す。
|
||||
/// Test scheme that does not inspect SSE payloads. With `parse_fail` set,
|
||||
/// it returns `ClientError::Sse` while consuming the stream.
|
||||
#[derive(Clone)]
|
||||
struct DummyScheme {
|
||||
parse_fail: bool,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# api-macros-impl
|
||||
|
||||
Procedural macro implementation for `api-macros`. It validates and normalizes trait declarations before generating metadata and adapters. It does not own runtime HTTP handling or application authorization decisions.
|
||||
|
||||
## Main entry points
|
||||
|
||||
- [`api`] — declaration normalization and code generation
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Implementation of `api-macros`.
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
@@ -15,6 +15,13 @@ use syn::{
|
||||
spanned::Spanned,
|
||||
};
|
||||
|
||||
/// Macro boundary that normalizes a trait before generating each output.
|
||||
///
|
||||
/// Validate routes, operation IDs, and body/response constraints before generation.
|
||||
/// Generate HTTP, WebSocket, and OpenAPI from one normalized model rather than interpreting
|
||||
/// them independently, reducing differences between adapters for the same declaration.
|
||||
/// Validation failures are compile errors; unsupported wire shapes are not implicitly
|
||||
/// accepted as JSON.
|
||||
#[proc_macro_attribute]
|
||||
pub fn api(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let attr = proc_macro2::TokenStream::from(attr);
|
||||
@@ -1534,6 +1541,13 @@ fn validate_named_body_type(ty: &Type, kind: &str) -> syn::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_binary_body_type(ty: &Type) -> bool {
|
||||
matches!(ty, Type::Path(path)
|
||||
if path.qself.is_none()
|
||||
&& path.path.segments.last().is_some_and(|last|
|
||||
last.ident == "BinaryBody" && matches!(last.arguments, PathArguments::None)))
|
||||
}
|
||||
|
||||
fn validate_binary_body_type(ty: &Type) -> syn::Result<()> {
|
||||
let Type::Path(path) = ty else {
|
||||
return Err(syn::Error::new_spanned(
|
||||
@@ -1738,6 +1752,9 @@ fn reqwest_adapter_tokens(
|
||||
}
|
||||
});
|
||||
let body_parser = match response.body.as_ref() {
|
||||
Some(ty) if is_binary_body_type(ty) => quote! {
|
||||
let body = __response.decode_binary();
|
||||
},
|
||||
Some(ty) => quote! {
|
||||
let body = __response
|
||||
.decode_json::<#ty>(#api_crate::reqwest::DecodeKind::Success)
|
||||
@@ -1775,7 +1792,11 @@ fn reqwest_adapter_tokens(
|
||||
.alternate_status
|
||||
.map(|alternate| quote!(__actual == #response_status || __actual == #alternate))
|
||||
.unwrap_or_else(|| quote!(__actual == #response_status));
|
||||
let success = if let Some(response) = operation.response_body.as_ref() {
|
||||
let success = if operation.response_body.as_ref().is_some_and(is_binary_body_type) {
|
||||
quote! {
|
||||
return ::core::result::Result::Ok(__response.decode_binary());
|
||||
}
|
||||
} else if let Some(response) = operation.response_body.as_ref() {
|
||||
quote! {
|
||||
return __response
|
||||
.decode_json::<#response>(#api_crate::reqwest::DecodeKind::Success)
|
||||
@@ -2077,7 +2098,7 @@ fn axum_adapter_tokens(
|
||||
status,
|
||||
rejection.body_text(),
|
||||
);
|
||||
return #api_crate::axum::json_response(
|
||||
return #api_crate::axum::error_response(
|
||||
#api_crate::axum::status(status),
|
||||
error,
|
||||
);
|
||||
@@ -2111,7 +2132,7 @@ fn axum_adapter_tokens(
|
||||
status,
|
||||
rejection.body_text(),
|
||||
);
|
||||
return #api_crate::axum::json_response(
|
||||
return #api_crate::axum::error_response(
|
||||
#api_crate::axum::status(status),
|
||||
error,
|
||||
);
|
||||
@@ -2133,7 +2154,9 @@ fn axum_adapter_tokens(
|
||||
let variant_ident = format_ident!("Status{}", status);
|
||||
let body_pattern = response.body.as_ref().map(|_| quote!(body,));
|
||||
let header_fields = response.headers.iter().map(|header| &header.field_ident);
|
||||
let base_response = if response.body.is_some() {
|
||||
let base_response = if response.body.as_ref().is_some_and(is_binary_body_type) {
|
||||
quote!(#api_crate::axum::binary_response(#api_crate::axum::status(#status), body))
|
||||
} else if response.body.is_some() {
|
||||
quote!(#api_crate::axum::json_response(#api_crate::axum::status(#status), body))
|
||||
} else {
|
||||
quote!(#api_crate::axum::empty_response(#api_crate::axum::status(#status)))
|
||||
@@ -2170,14 +2193,21 @@ fn axum_adapter_tokens(
|
||||
}
|
||||
}
|
||||
} else if let Some(alternate_status) = operation.alternate_status {
|
||||
let response_encoder = if operation.response_body.as_ref().is_some_and(is_binary_body_type) {
|
||||
quote!(#api_crate::axum::binary_response)
|
||||
} else {
|
||||
quote!(#api_crate::axum::json_response)
|
||||
};
|
||||
quote!({
|
||||
let status = #api_crate::HttpSuccess::status_code(&value);
|
||||
if status == #response_status || status == #alternate_status {
|
||||
#api_crate::axum::json_response(#api_crate::axum::status(status), value)
|
||||
#response_encoder(#api_crate::axum::status(status), value)
|
||||
} else {
|
||||
#api_crate::axum::empty_response(#api_crate::axum::status(500))
|
||||
}
|
||||
})
|
||||
} else if operation.response_body.as_ref().is_some_and(is_binary_body_type) {
|
||||
quote!(#api_crate::axum::binary_response(#api_crate::axum::status(#response_status), value))
|
||||
} else if operation.response_body.is_some() {
|
||||
quote!(#api_crate::axum::json_response(#api_crate::axum::status(#response_status), value))
|
||||
} else {
|
||||
@@ -2188,7 +2218,7 @@ fn axum_adapter_tokens(
|
||||
let error = match operation.error_status {
|
||||
Some(_) => quote!({
|
||||
let status = #api_crate::HttpError::status_code(&error);
|
||||
#api_crate::axum::json_response(#api_crate::axum::status(status), error)
|
||||
#api_crate::axum::error_response(#api_crate::axum::status(status), error)
|
||||
}),
|
||||
None => quote!({ let _ = error; #api_crate::axum::empty_response(#api_crate::axum::framework::StatusCode::INTERNAL_SERVER_ERROR) }),
|
||||
};
|
||||
@@ -2323,6 +2353,9 @@ fn openapi_adapter_tokens(
|
||||
"Alternate successful response"
|
||||
};
|
||||
let body = match response.body.as_ref() {
|
||||
Some(ty) if is_binary_body_type(ty) => quote! {
|
||||
operation.binary_response(#status, #description)?;
|
||||
},
|
||||
Some(ty) => quote! {
|
||||
operation.response::<#ty>(#status, "application/json", #description)?;
|
||||
},
|
||||
@@ -2620,7 +2653,9 @@ fn metadata_tokens(
|
||||
};
|
||||
let success_responses = operation.success_responses.iter().map(|response| {
|
||||
let status = response.status;
|
||||
let wire_kind = if response.body.is_some() {
|
||||
let wire_kind = if response.body.as_ref().is_some_and(is_binary_body_type) {
|
||||
quote!(#api_crate::WireKind::Binary)
|
||||
} else if response.body.is_some() {
|
||||
quote!(#api_crate::WireKind::Json)
|
||||
} else {
|
||||
quote!(#api_crate::WireKind::Empty)
|
||||
|
||||
@@ -35,4 +35,5 @@ oas3 = { version = "0.22", default-features = false }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
tower = { workspace = true, features = ["util"] }
|
||||
trybuild = "1"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# api-macros
|
||||
|
||||
Defines transport-independent API metadata from public traits. The same declarations drive optional HTTP adapters and OpenAPI generation, while callers retain authentication, authorization, and application state.
|
||||
|
||||
## Main entry points
|
||||
|
||||
- [`api`] — API declaration and validation
|
||||
- [`ApiContract`], [`Operation`], and [`WebSocketOperation`] — typed operation contracts
|
||||
- `axum`, `reqwest`, and `openapi` — adapters and document generation enabled by their respective features
|
||||
@@ -1,9 +1,8 @@
|
||||
//! Axum support used by routers generated with `#[api(axum)]`.
|
||||
//! Transport boundary for generated routers.
|
||||
//!
|
||||
//! Generated operation routers contain only transport extraction, typed service invocation, and
|
||||
//! response mapping. Authentication/authorization context, body-limit policy, timeout policy, and
|
||||
//! other application state remain explicit outer Axum layers. Each operation is also exposed as a
|
||||
//! separate router so route-specific layers can be applied before routers are merged.
|
||||
//! Routers only extract inputs, call typed services, and convert responses.
|
||||
//! Outer layers retain authentication, authorization, body limits, timeouts, and application state.
|
||||
//! Per-operation routers allow route-specific constraints to be applied before merging.
|
||||
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
@@ -77,6 +76,36 @@ pub fn json_response<T: Serialize>(status: StatusCode, value: T) -> Response {
|
||||
(status, Json(value)).into_response()
|
||||
}
|
||||
|
||||
/// Serialize a public error and retain its typed value for outer application middleware.
|
||||
///
|
||||
/// The extension is server-local (`Arc<T>`); it is not another wire field. This lets observers
|
||||
/// inspect the declared error without consuming the response body or requiring `T: Clone`.
|
||||
/// Applications still choose which public fields are safe to log; the adapter does not log them.
|
||||
pub fn error_response<T: Serialize + Send + Sync + 'static>(
|
||||
status: StatusCode,
|
||||
error: T,
|
||||
) -> Response {
|
||||
let mut response = json_response(status, &error);
|
||||
// JSON encoding failures are plain-text responses, including when the requested status is 500.
|
||||
if response.status() == status
|
||||
&& response.headers().get(axum::http::header::CONTENT_TYPE)
|
||||
== Some(&axum::http::HeaderValue::from_static("application/json"))
|
||||
{
|
||||
response.extensions_mut().insert(std::sync::Arc::new(error));
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
/// Emit an exact binary response with the contract status and octet-stream content type.
|
||||
pub fn binary_response(status: StatusCode, value: crate::BinaryBody) -> Response {
|
||||
(
|
||||
status,
|
||||
[(axum::http::header::CONTENT_TYPE, "application/octet-stream")],
|
||||
value.into_bytes(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Emit an empty response with the contract status.
|
||||
pub fn empty_response(status: StatusCode) -> Response {
|
||||
status.into_response()
|
||||
|
||||
+68
-140
@@ -1,144 +1,72 @@
|
||||
//! Shared API trait declarations for Yoi services.
|
||||
//!
|
||||
//! `#[api]` turns a public trait into a single, transport-neutral API definition. The
|
||||
//! original trait remains usable by Rust code; generated metadata and zero-sized operation
|
||||
//! markers give adapters and generators a stable view of the same declaration.
|
||||
//!
|
||||
//! # Declaration syntax
|
||||
//!
|
||||
//! ```
|
||||
//! # mod example {
|
||||
//! use api_macros::api;
|
||||
//!
|
||||
//! pub struct CreateWidget;
|
||||
//! pub struct Widget;
|
||||
//! pub struct PublicError;
|
||||
//!
|
||||
//! #[api]
|
||||
//! pub trait WidgetApi {
|
||||
//! #[post(
|
||||
//! "/widgets",
|
||||
//! operation_id = "widgets.create",
|
||||
//! status = 201,
|
||||
//! error_status = 422
|
||||
//! )]
|
||||
//! async fn create(
|
||||
//! &self,
|
||||
//! #[body] request: CreateWidget,
|
||||
//! #[header("x-request-id")] request_id: String,
|
||||
//! ) -> Result<Widget, PublicError>;
|
||||
//!
|
||||
//! #[get("/widgets/{widget_id}", operation_id = "widgets.get")]
|
||||
//! async fn get(&self, widget_id: u64) -> Result<Widget, PublicError>;
|
||||
//! }
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! Supported route attributes are `get`, `post`, `put`, `patch`, `delete`, `head`, and
|
||||
//! `options`. The first argument is a path template. `operation_id` defaults to the Rust
|
||||
//! method name but should be set explicitly for contracts which must remain stable while Rust
|
||||
//! names evolve. Success status defaults to `200`, except an empty (`()`) response defaults to
|
||||
//! `204`. `alternate_status` opts a JSON response into [`HttpSuccess`] when two successful HTTP
|
||||
//! outcomes share one schema. Operations whose successful statuses have different body or header
|
||||
//! shapes use `responses = [(status = 200, body = Widget, headers = [("etag", String)]),
|
||||
//! (status = 304, headers = [("etag", String)])]`; the macro emits a typed result enum in the
|
||||
//! `<trait_name>_responses` module. Only explicit body-less `304` declarations extend success
|
||||
//! beyond 2xx. A public error inferred from `Result<T, E>` defaults to status `400`;
|
||||
//! `additional_error_statuses` publishes the same typed error schema for other declared outcomes.
|
||||
//! `bearer_auth = true` and `browser_auth = true` attach standard bearer and browser-session cookie
|
||||
//! security schemes. Body operations may opt into typed Axum rejection normalization with
|
||||
//! typed Axum rejection normalization with `normalize_body_errors = true` and [`HttpRequestError`].
|
||||
//!
|
||||
//! Arguments are classified with `#[body]`, `#[binary]`, `#[query]`, `#[header]`, `#[path]`, or
|
||||
//! `#[extension]`. Extension values are trusted server-local Axum context: generated clients and
|
||||
//! OpenAPI omit them. An unannotated argument whose Rust name occurs in the route template is
|
||||
//! inferred as a path argument. Header attributes may carry a wire name, as in
|
||||
//! `#[header("x-request-id")]`. Exactly one JSON or binary body is allowed. JSON request, response,
|
||||
//! and error bodies must be named Rust types; tuples, references, arrays, and other anonymous
|
||||
//! structural types are rejected. Binary request bodies use [`BinaryBody`] and remain byte-exact.
|
||||
//! `openapi = false` explicitly excludes an operation whose wire body cannot yet satisfy the
|
||||
//! strict OpenAPI schema boundary; Reqwest and Axum adapters are still generated.
|
||||
//!
|
||||
//! WebSocket routes use a declaration-only associated type rather than a dummy unary method:
|
||||
//!
|
||||
//! ```
|
||||
//! # mod websocket_example {
|
||||
//! # use api_macros::api;
|
||||
//! # pub struct ClientFrame;
|
||||
//! # pub struct ServerFrame;
|
||||
//! #[api]
|
||||
//! pub trait EventApi {
|
||||
//! #[websocket(
|
||||
//! "/events/{stream_id}",
|
||||
//! operation_id = "events.stream",
|
||||
//! method = GET,
|
||||
//! client_to_server = ClientFrame,
|
||||
//! server_to_client = ServerFrame,
|
||||
//! path_parameters = [stream_id: u64]
|
||||
//! )]
|
||||
//! type EventStream;
|
||||
//! }
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! The declaration is removed from the emitted service trait and becomes a typed marker implementing
|
||||
//! [`WebSocketOperation`]. It participates in the same operation/route inventory and path validation,
|
||||
//! but never generates a Reqwest method, unary Axum adapter, or OpenAPI path. Under the `axum`
|
||||
//! feature, [`axum::websocket_route`] mounts an existing manual upgrade handler at its declared GET
|
||||
//! path without exposing framework WebSocket types to the contract.
|
||||
//!
|
||||
//! # Generated names
|
||||
//!
|
||||
//! For `WidgetApi`, the macro emits `WidgetApiMetadata`, which implements [`ApiContract`],
|
||||
//! plus a `widget_api_operations` module. That module contains one marker per Rust method
|
||||
//! (`Create`, `Get`, ...), each implementing [`Operation`] and connecting metadata to concrete
|
||||
//! body types at compile time. Unusual method names which have no valid PascalCase form use the
|
||||
//! deterministic `Operation<utf8-hex>` fallback. The `ApiContract::OPERATIONS` inventory is
|
||||
//! sorted by operation ID, so its ordering is independent of source method order.
|
||||
//!
|
||||
//! Empty, JSON, and bounded binary request bodies are supported. [`WireKind`] reserves explicit
|
||||
//! variants for other future transport work; accepting one requires a deliberate macro and adapter
|
||||
//! change rather than silently treating it as JSON.
|
||||
//!
|
||||
//! # OpenAPI 3.1 export
|
||||
//!
|
||||
//! With the `openapi` feature, `#[api(openapi)]` emits a `<trait_name>_openapi` function from the
|
||||
//! same normalized operation model used by the HTTP adapters. The caller supplies title, version,
|
||||
//! and an opaque source digest through [`openapi::OpenApiInfo`], then either serves
|
||||
//! [`openapi::OpenApiDocument::to_json`] or writes exactly those canonical bytes with
|
||||
//! [`openapi::OpenApiDocument::write_json`] from a CLI. Documents contain no server URLs,
|
||||
//! environment names, timestamps, or other deployment topology.
|
||||
//!
|
||||
//! Every exposed wire type must implement [`openapi::OpenApiSchema`] as an explicit assertion that
|
||||
//! its `schemars::JsonSchema` contract matches Serde serialization. The trait's naming hook is the
|
||||
//! public component-name contract. Different schemas claiming one name, unsafe-width integer
|
||||
//! schemas, and ambiguous non-null `anyOf`/untagged representations fail closed. Use bounded
|
||||
//! integers (for example `u32`) for JSON numeric fields, and model optionality separately from
|
||||
//! nullability through object `required` membership and nullable schemas. A normalized
|
||||
//! `#[header("authorization")]` string (or optional string) is projected as an HTTP bearer
|
||||
//! security scheme and operation requirement rather than as a raw header parameter.
|
||||
//!
|
||||
//! # Optional HTTP adapters
|
||||
//!
|
||||
//! `#[api(reqwest)]` generates `TraitNameClient` and enables a typed Reqwest client when this
|
||||
//! crate's `reqwest` feature is enabled. `#[api(axum)]` generates `TraitNameAxum` plus one public
|
||||
//! router per operation when the `axum` feature is enabled. `#[api(reqwest, axum)]` emits both
|
||||
//! from the same normalized definition. A plain `#[api]` remains framework-independent and pulls
|
||||
//! neither framework into DTO/contract-only builds.
|
||||
//!
|
||||
//! Generated clients use a fallible `try_new` constructor and builder. The builder validates an
|
||||
//! absolute credential-free HTTP(S) base URL and configures request timeout and response byte
|
||||
//! limit. A prevalidated [`reqwest::BaseUrl`] and policy-configured `reqwest::framework::Client`
|
||||
//! may be injected instead. Request authorizers receive the final method, encoded path/query, and
|
||||
//! exact serialized body bytes; credential headers and all body content are redacted from normal
|
||||
//! diagnostics.
|
||||
//!
|
||||
//! Generated Axum adapters keep authentication, authorization, application state, body limits,
|
||||
//! and timeout layers outside the adapter. Callers may layer the complete router or an individual
|
||||
//! generated operation router before merging it into their application.
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
extern crate self as api_macros;
|
||||
|
||||
/// Normalize a public trait into a transport-independent, typed API contract.
|
||||
///
|
||||
/// Preserve the original Rust trait and generate operation markers and metadata from the
|
||||
/// same declaration. HTTP adapters and OpenAPI use the same normalized result, avoiding
|
||||
/// manual synchronization of separate route lists. Sort the inventory by operation ID so
|
||||
/// declaration reordering does not change the external contract order.
|
||||
///
|
||||
/// Validate request/response wire shapes at declaration time; unsupported types and duplicate
|
||||
/// routes are compile errors. Exclude server-local extensions from clients and OpenAPI;
|
||||
/// leave authentication, authorization, body limits, and timeouts to the enclosing application.
|
||||
/// Declarations without adapters do not depend on an HTTP framework.
|
||||
///
|
||||
/// This declaration checks that a Rust trait and typed operation markers can be defined together.
|
||||
///
|
||||
/// ```
|
||||
/// # mod example {
|
||||
/// use api_macros::api;
|
||||
///
|
||||
/// pub struct CreateWidget;
|
||||
/// pub struct Widget;
|
||||
/// pub struct PublicError;
|
||||
///
|
||||
/// #[api]
|
||||
/// pub trait WidgetApi {
|
||||
/// #[post(
|
||||
/// "/widgets",
|
||||
/// operation_id = "widgets.create",
|
||||
/// status = 201,
|
||||
/// error_status = 422
|
||||
/// )]
|
||||
/// async fn create(
|
||||
/// &self,
|
||||
/// #[body] request: CreateWidget,
|
||||
/// #[header("x-request-id")] request_id: String,
|
||||
/// ) -> Result<Widget, PublicError>;
|
||||
///
|
||||
/// #[get("/widgets/{widget_id}", operation_id = "widgets.get")]
|
||||
/// async fn get(&self, widget_id: u64) -> Result<Widget, PublicError>;
|
||||
/// }
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// WebSocket associated types are declarations only: remove them from the output trait
|
||||
/// and turn them into [`WebSocketOperation`] markers. They are not unary HTTP operations
|
||||
/// and generate neither client methods nor OpenAPI paths.
|
||||
///
|
||||
/// ```
|
||||
/// # mod websocket_example {
|
||||
/// # use api_macros::api;
|
||||
/// # pub struct ClientFrame;
|
||||
/// # pub struct ServerFrame;
|
||||
/// #[api]
|
||||
/// pub trait EventApi {
|
||||
/// #[websocket(
|
||||
/// "/events/{stream_id}",
|
||||
/// operation_id = "events.stream",
|
||||
/// method = GET,
|
||||
/// client_to_server = ClientFrame,
|
||||
/// server_to_client = ServerFrame,
|
||||
/// path_parameters = [stream_id: u64]
|
||||
/// )]
|
||||
/// type EventStream;
|
||||
/// }
|
||||
/// # }
|
||||
/// ```
|
||||
pub use api_macros_impl::api;
|
||||
|
||||
#[cfg(feature = "axum")]
|
||||
@@ -148,7 +76,7 @@ pub mod openapi;
|
||||
#[cfg(feature = "reqwest")]
|
||||
pub mod reqwest;
|
||||
|
||||
/// Framework-neutral owned bytes used by explicit `#[binary]` request bodies.
|
||||
/// Framework-neutral owned bytes used by explicit `#[binary]` requests and binary success responses.
|
||||
///
|
||||
/// The wrapper preserves the exact byte sequence while keeping payload content out of `Debug`
|
||||
/// output. Its `bytes::Bytes` storage lets generated Axum adapters transfer an extracted body
|
||||
@@ -314,7 +242,7 @@ pub trait Operation {
|
||||
type Parameters;
|
||||
/// Typed JSON or binary request body, or [`NoBody`].
|
||||
type RequestBody;
|
||||
/// JSON success response body type, generated declared-response result, or [`NoBody`].
|
||||
/// JSON or binary success body type, generated declared-response result, or [`NoBody`].
|
||||
type ResponseBody;
|
||||
/// JSON public error body type, or [`NoBody`].
|
||||
type ErrorBody;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! Deterministic OpenAPI 3.1 projection for `#[api(openapi)]` contracts.
|
||||
//! Generate deterministic OpenAPI documents from API contracts.
|
||||
//!
|
||||
//! The projection deliberately has no deployment topology (`servers`) and no
|
||||
//! generated timestamps. Callers supply an opaque source digest, which is
|
||||
//! retained as provenance in `info.x-yoi-source-digest`.
|
||||
//! Omit deployment URLs and generation timestamps; retain the caller's source digest as
|
||||
//! provenance. This separates contract changes from environment changes and produces the
|
||||
//! same bytes from the same inputs. Owners explicitly assert wire-schema/Serde agreement
|
||||
//! through [`OpenApiSchema`]; name collisions, unsafe integers, and ambiguous schemas are
|
||||
//! not silently published.
|
||||
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
@@ -65,7 +67,7 @@ impl<T: OpenApiSchema> OpenApiSchema for BTreeMap<String, T> {}
|
||||
pub struct OpenApiInfo<'a> {
|
||||
pub title: &'a str,
|
||||
pub version: &'a str,
|
||||
/// Opaque digest of the source revision used to build the contract.
|
||||
/// Source-content digest used to identify the exported contract's build inputs.
|
||||
pub source_digest: &'a str,
|
||||
}
|
||||
|
||||
@@ -419,6 +421,25 @@ impl OpenApiOperation<'_> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Describe an octet-stream response inline, without registering a JSON schema component.
|
||||
pub fn binary_response(
|
||||
&mut self,
|
||||
status: u16,
|
||||
description: &'static str,
|
||||
) -> Result<(), OpenApiError> {
|
||||
self.insert_response(
|
||||
status,
|
||||
json!({
|
||||
"description": description,
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": { "type": "string", "format": "binary" }
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn empty_response(
|
||||
&mut self,
|
||||
status: u16,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Reqwest transport support used by clients generated with `#[api(reqwest)]`.
|
||||
//! Transport boundary for generated clients.
|
||||
//!
|
||||
//! The generated builder validates the base URL before it can be stored. Callers that enforce
|
||||
//! stricter egress policy (custom DNS resolution, proxy policy, TLS roots, or connection pinning)
|
||||
//! can construct their own [`reqwest::Client`] and inject it with the validated [`BaseUrl`].
|
||||
//! Validate the base URL before storing it; apply timeouts and byte limits to requests
|
||||
//! and response reads. Stricter outbound constraints such as DNS, proxy, and TLS policy
|
||||
//! remain at the boundary where callers inject their own [`reqwest::Client`] with [`BaseUrl`].
|
||||
|
||||
use std::{fmt, time::Duration};
|
||||
|
||||
@@ -516,6 +516,11 @@ impl ReceivedResponse {
|
||||
serde_json::from_slice(&self.bytes).map_err(|_| ClientFailure::Decode { kind })
|
||||
}
|
||||
|
||||
/// Move the exact response bytes, already collected under the configured limit, into a body.
|
||||
pub fn decode_binary(self) -> crate::BinaryBody {
|
||||
self.bytes.into()
|
||||
}
|
||||
|
||||
pub fn require_empty(&self) -> Result<(), ClientFailure> {
|
||||
if self.bytes.is_empty() {
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
#![cfg(all(feature = "reqwest", feature = "axum"))]
|
||||
|
||||
use api_macros::{
|
||||
BinaryBody, Operation, api,
|
||||
axum::framework::{Body, Response, Router, get},
|
||||
reqwest::{ClientError, ClientFailure},
|
||||
};
|
||||
#[cfg(feature = "openapi")]
|
||||
use api_macros::{
|
||||
WireKind,
|
||||
openapi::{OpenApiInfo, OpenApiSchema},
|
||||
};
|
||||
#[cfg(feature = "openapi")]
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "openapi")]
|
||||
use serde_json::json;
|
||||
|
||||
const BYTES: &[u8] = b"\0\xff\x80\r\n{\"not\":\"a JSON response\"}";
|
||||
const ETAG: &str = "\"binary-v1\"";
|
||||
const DISPOSITION: &str = "attachment; filename=fixture.bin";
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(JsonSchema))]
|
||||
pub struct PublicError {
|
||||
message: String,
|
||||
}
|
||||
#[cfg(feature = "openapi")]
|
||||
impl OpenApiSchema for PublicError {}
|
||||
impl api_macros::HttpError for PublicError {
|
||||
fn status_code(&self) -> u16 {
|
||||
404
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "openapi", api(reqwest, axum, openapi))]
|
||||
#[cfg_attr(not(feature = "openapi"), api(reqwest, axum))]
|
||||
pub trait DownloadApi {
|
||||
#[get("/plain/{mode}", error_status = 404)]
|
||||
async fn plain(&self, mode: String) -> Result<api_macros::BinaryBody, PublicError>;
|
||||
|
||||
#[get(
|
||||
"/download/{mode}",
|
||||
error_status = 404,
|
||||
responses = [
|
||||
(status = 200, body = BinaryBody, headers = [
|
||||
("x-byte-length", u32), ("content-disposition", String), ("etag", String)
|
||||
]),
|
||||
(status = 304, headers = [("etag", String)])
|
||||
]
|
||||
)]
|
||||
async fn download(&self, mode: String)
|
||||
-> Result<download_api_responses::Download, PublicError>;
|
||||
}
|
||||
|
||||
struct DownloadService;
|
||||
impl DownloadApi for DownloadService {
|
||||
async fn plain(&self, mode: String) -> Result<BinaryBody, PublicError> {
|
||||
if mode == "missing" {
|
||||
return Err(PublicError {
|
||||
message: "not found".to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(if mode == "empty" {
|
||||
b"".as_slice()
|
||||
} else {
|
||||
BYTES
|
||||
}
|
||||
.into())
|
||||
}
|
||||
|
||||
async fn download(
|
||||
&self,
|
||||
mode: String,
|
||||
) -> Result<download_api_responses::Download, PublicError> {
|
||||
if mode == "cached" {
|
||||
return Ok(download_api_responses::Download::Status304 {
|
||||
header_etag: ETAG.to_owned(),
|
||||
});
|
||||
}
|
||||
let body = self.plain(mode).await?;
|
||||
Ok(download_api_responses::Download::Status200 {
|
||||
header_x_byte_length: body.len() as u32,
|
||||
header_content_disposition: DISPOSITION.to_owned(),
|
||||
header_etag: ETAG.to_owned(),
|
||||
body,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Server {
|
||||
base_url: String,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
impl Server {
|
||||
async fn start(router: Router) -> Self {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base_url = format!("http://{}/", listener.local_addr().unwrap());
|
||||
let task = tokio::spawn(async move {
|
||||
api_macros::axum::framework::serve(listener, router)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
Self { base_url, task }
|
||||
}
|
||||
|
||||
fn client(&self, limit: usize) -> DownloadApiClient {
|
||||
DownloadApiClient::builder(&self.base_url)
|
||||
.unwrap()
|
||||
.response_body_limit(limit)
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binary_success_preserves_exact_bytes_content_type_and_declared_headers() {
|
||||
let server = Server::start(DownloadApiAxum::router(DownloadService)).await;
|
||||
let client = server.client(BYTES.len());
|
||||
for (mode, expected) in [("full", BYTES), ("empty", b"".as_slice())] {
|
||||
let response = reqwest::get(format!("{}plain/{mode}", server.base_url))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status().as_u16(), 200);
|
||||
assert_eq!(
|
||||
response.headers()["content-type"],
|
||||
"application/octet-stream"
|
||||
);
|
||||
assert_eq!(response.bytes().await.unwrap().as_ref(), expected);
|
||||
assert_eq!(
|
||||
client.plain(mode.to_owned()).await.unwrap().as_ref(),
|
||||
expected
|
||||
);
|
||||
|
||||
let response = client.download(mode.to_owned()).await.unwrap();
|
||||
match response {
|
||||
download_api_responses::Download::Status200 {
|
||||
body,
|
||||
header_x_byte_length,
|
||||
header_content_disposition,
|
||||
header_etag,
|
||||
} => {
|
||||
assert_eq!(body.as_ref(), expected);
|
||||
assert_eq!(header_x_byte_length as usize, expected.len());
|
||||
assert_eq!(header_content_disposition, DISPOSITION);
|
||||
assert_eq!(header_etag, ETAG);
|
||||
}
|
||||
other => panic!("expected binary success, received {other:?}"),
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
matches!(client.download("cached".to_owned()).await.unwrap(),
|
||||
download_api_responses::Download::Status304 { header_etag } if header_etag == ETAG)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "openapi")]
|
||||
#[test]
|
||||
fn binary_success_metadata_and_openapi_use_inline_binary_schema_with_headers() {
|
||||
let document = download_api_openapi(OpenApiInfo {
|
||||
title: "Download fixture",
|
||||
version: "1",
|
||||
source_digest: "fixture",
|
||||
})
|
||||
.unwrap();
|
||||
let value = document.as_value();
|
||||
for (path, metadata) in [
|
||||
("/plain/{mode}", download_api_operations::Plain::METADATA),
|
||||
(
|
||||
"/download/{mode}",
|
||||
download_api_operations::Download::METADATA,
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
metadata.success_responses[0].body.wire_kind,
|
||||
WireKind::Binary
|
||||
);
|
||||
assert_eq!(metadata.error_responses[0].body.wire_kind, WireKind::Json);
|
||||
let responses = &value["paths"][path]["get"]["responses"];
|
||||
assert_eq!(
|
||||
responses["200"]["content"],
|
||||
json!({
|
||||
"application/octet-stream": { "schema": { "type": "string", "format": "binary" } }
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
responses["404"]["content"]["application/json"]["schema"]["$ref"],
|
||||
"#/components/schemas/PublicError"
|
||||
);
|
||||
}
|
||||
let metadata = download_api_operations::Download::METADATA;
|
||||
assert_eq!(
|
||||
metadata.success_responses[1].body.wire_kind,
|
||||
WireKind::Empty
|
||||
);
|
||||
let responses = &value["paths"]["/download/{mode}"]["get"]["responses"];
|
||||
for header in metadata.success_responses[0].headers {
|
||||
assert!(responses["200"]["headers"][header.wire_name]["schema"].is_object());
|
||||
}
|
||||
assert!(responses["304"].get("content").is_none());
|
||||
assert!(responses["304"]["headers"]["etag"]["schema"].is_object());
|
||||
assert!(
|
||||
value["components"]["schemas"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.all(|name| !name.contains("BinaryBody"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binary_success_keeps_json_public_errors_and_rejects_unexpected_statuses() {
|
||||
let server = Server::start(DownloadApiAxum::router(DownloadService)).await;
|
||||
let client = server.client(1024);
|
||||
for result in [
|
||||
client.plain("missing".to_owned()).await.map(|_| ()),
|
||||
client.download("missing".to_owned()).await.map(|_| ()),
|
||||
] {
|
||||
match result.unwrap_err() {
|
||||
ClientError::Public { status, error } => {
|
||||
assert_eq!(status.as_u16(), 404);
|
||||
assert_eq!(error.message, "not found");
|
||||
}
|
||||
other => panic!("expected JSON public error, received {other:?}"),
|
||||
}
|
||||
}
|
||||
for (status, expected) in [
|
||||
(
|
||||
201,
|
||||
ClientFailure::UnexpectedStatus {
|
||||
expected: 200,
|
||||
actual: 201,
|
||||
},
|
||||
),
|
||||
(404, ClientFailure::ErrorResponseDecode { status: 404 }),
|
||||
] {
|
||||
let server = Server::start(Router::new().route(
|
||||
download_api_operations::Plain::METADATA.path,
|
||||
get(move || async move {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.body(Body::from(BYTES))
|
||||
.unwrap()
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
assert!(matches!(server.client(1024).plain("full".to_owned()).await,
|
||||
Err(ClientError::Failure(actual)) if actual == expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binary_declared_headers_reject_missing_invalid_and_duplicate_values() {
|
||||
for (values, expected) in [
|
||||
(
|
||||
vec![],
|
||||
ClientFailure::MissingResponseHeader { name: "etag" },
|
||||
),
|
||||
(
|
||||
vec![b"\xff".as_slice()],
|
||||
ClientFailure::InvalidResponseHeader { name: "etag" },
|
||||
),
|
||||
(
|
||||
vec![b"one".as_slice(), b"two".as_slice()],
|
||||
ClientFailure::DuplicateResponseHeader { name: "etag" },
|
||||
),
|
||||
] {
|
||||
let server = Server::start(Router::new().route(
|
||||
download_api_operations::Download::METADATA.path,
|
||||
get(move || {
|
||||
let values = values.clone();
|
||||
async move {
|
||||
let mut response = Response::builder()
|
||||
.status(200)
|
||||
.header("content-type", "application/octet-stream")
|
||||
.header("x-byte-length", BYTES.len())
|
||||
.header("content-disposition", DISPOSITION);
|
||||
for value in values {
|
||||
response = response.header("etag", value);
|
||||
}
|
||||
response.body(Body::from(BYTES)).unwrap()
|
||||
}
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
matches!(server.client(1024).download("full".to_owned()).await,
|
||||
Err(ClientError::Failure(actual)) if actual == expected)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binary_responses_enforce_the_same_limit_with_and_without_content_length() {
|
||||
for chunked in [false, true] {
|
||||
let server = Server::start(Router::new().route(
|
||||
download_api_operations::Plain::METADATA.path,
|
||||
get(move || async move {
|
||||
if chunked {
|
||||
let chunks = [BYTES[..3].to_vec(), BYTES[3..].to_vec()];
|
||||
let stream = futures::stream::iter(chunks.map(Ok::<_, std::io::Error>));
|
||||
Response::builder()
|
||||
.status(200)
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap()
|
||||
} else {
|
||||
api_macros::axum::binary_response(api_macros::axum::status(200), BYTES.into())
|
||||
}
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
assert_eq!(
|
||||
server
|
||||
.client(BYTES.len())
|
||||
.plain("full".to_owned())
|
||||
.await
|
||||
.unwrap()
|
||||
.as_ref(),
|
||||
BYTES
|
||||
);
|
||||
assert!(
|
||||
matches!(server.client(BYTES.len() - 1).plain("full".to_owned()).await,
|
||||
Err(ClientError::Failure(ClientFailure::ResponseTooLarge { limit })) if limit == BYTES.len() - 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
#![cfg(feature = "axum")]
|
||||
|
||||
use api_macros::api;
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
// No Clone requirement: retaining an error must not require duplicating its typed value.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PublicError {
|
||||
message: String,
|
||||
#[serde(skip)]
|
||||
status: u16,
|
||||
}
|
||||
|
||||
impl api_macros::HttpError for PublicError {
|
||||
fn status_code(&self) -> u16 {
|
||||
self.status
|
||||
}
|
||||
}
|
||||
|
||||
impl api_macros::HttpRequestError for PublicError {
|
||||
fn from_request_rejection(status: u16, message: String) -> Self {
|
||||
Self { status, message }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Input {
|
||||
fail: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Output {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[api(axum)]
|
||||
pub trait ErrorMetadataApi {
|
||||
#[post(
|
||||
"/json",
|
||||
status = 200,
|
||||
error_status = 400,
|
||||
normalize_body_errors = true
|
||||
)]
|
||||
async fn json(&self, #[body] input: Input) -> Result<Output, PublicError>;
|
||||
|
||||
#[post(
|
||||
"/binary",
|
||||
status = 200,
|
||||
error_status = 400,
|
||||
normalize_body_errors = true
|
||||
)]
|
||||
async fn binary(&self, #[binary] input: api_macros::BinaryBody) -> Result<Output, PublicError>;
|
||||
}
|
||||
|
||||
pub struct Service;
|
||||
impl ErrorMetadataApi for Service {
|
||||
async fn json(&self, input: Input) -> Result<Output, PublicError> {
|
||||
if input.fail {
|
||||
Err(PublicError {
|
||||
status: 502,
|
||||
message: "upstream rejected operation".to_string(),
|
||||
})
|
||||
} else {
|
||||
Ok(Output {
|
||||
message: "ok".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
async fn binary(&self, _input: api_macros::BinaryBody) -> Result<Output, PublicError> {
|
||||
Ok(Output {
|
||||
message: "ok".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generated_service_and_body_errors_retain_typed_metadata_without_changing_wire_body() {
|
||||
let app = ErrorMetadataApiAxum::router(Service).layer(axum::extract::DefaultBodyLimit::max(32));
|
||||
for (path, content_type, input, status) in [
|
||||
(
|
||||
"/json",
|
||||
"application/json",
|
||||
"{\"fail\":true}".to_string(),
|
||||
StatusCode::BAD_GATEWAY,
|
||||
),
|
||||
(
|
||||
"/json",
|
||||
"application/json",
|
||||
"{".to_string(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(
|
||||
"/binary",
|
||||
"application/octet-stream",
|
||||
"x".repeat(33),
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(path)
|
||||
.header("content-type", content_type)
|
||||
.body(Body::from(input))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), status);
|
||||
assert_eq!(response.headers()["content-type"], "application/json");
|
||||
let error = response
|
||||
.extensions()
|
||||
.get::<Arc<PublicError>>()
|
||||
.expect("typed error retained")
|
||||
.clone();
|
||||
assert_eq!(error.status, status.as_u16());
|
||||
assert!(!error.message.is_empty());
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
|
||||
serde_json::json!({"message": error.message})
|
||||
);
|
||||
}
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/json")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{\"fail\":false}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(response.extensions().get::<Arc<PublicError>>().is_none());
|
||||
assert_eq!(
|
||||
to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.as_ref(),
|
||||
b"{\"message\":\"ok\"}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_encoding_failure_does_not_retain_misleading_original_metadata() {
|
||||
struct UnencodableError;
|
||||
impl Serialize for UnencodableError {
|
||||
fn serialize<S: serde::Serializer>(&self, _serializer: S) -> Result<S::Ok, S::Error> {
|
||||
Err(serde::ser::Error::custom("cannot encode fixture error"))
|
||||
}
|
||||
}
|
||||
for status in [StatusCode::BAD_GATEWAY, StatusCode::INTERNAL_SERVER_ERROR] {
|
||||
let response = api_macros::axum::error_response(status, UnencodableError);
|
||||
assert_eq!(response.status(), status);
|
||||
assert_eq!(
|
||||
response.headers()["content-type"],
|
||||
"text/plain; charset=utf-8"
|
||||
);
|
||||
assert!(
|
||||
response
|
||||
.extensions()
|
||||
.get::<Arc<UnencodableError>>()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,38 @@
|
||||
//! Compile the real public macro through a renamed dependency in an isolated consumer.
|
||||
//! Only Cargo artifacts are shared; Cargo checks the fresh consumer on every invocation.
|
||||
|
||||
use std::{fs, path::PathBuf, process::Command};
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
for attempt in 0..1000 {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"api-macros-renamed-dependency-{}-{attempt}",
|
||||
std::process::id()
|
||||
));
|
||||
match fs::create_dir(&path) {
|
||||
Ok(()) => return Self(path),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => panic!("create renamed dependency fixture: {error}"),
|
||||
}
|
||||
}
|
||||
panic!("could not allocate a renamed dependency fixture directory");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macro_expansion_resolves_a_renamed_support_dependency() {
|
||||
let fixture = std::env::temp_dir().join(format!(
|
||||
"api-macros-renamed-dependency-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&fixture);
|
||||
fs::create_dir_all(fixture.join("src")).expect("create fixture source directory");
|
||||
let fixture = Fixture::new();
|
||||
let fixture = &fixture.0;
|
||||
fs::create_dir(fixture.join("src")).expect("create fixture source directory");
|
||||
|
||||
let package_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let package_path = package_path
|
||||
@@ -49,13 +74,21 @@ pub fn operation_count() -> usize {
|
||||
)
|
||||
.expect("write fixture source");
|
||||
|
||||
// Keep nested Cargo checks off the outer build's target lock. Cargo owns cache
|
||||
// invalidation and locking between concurrent consumers, not a saved
|
||||
// test-success marker.
|
||||
let executable = std::env::current_exe().expect("current test executable");
|
||||
let target_dir = executable
|
||||
.parent()
|
||||
.and_then(|deps| deps.parent())
|
||||
.expect("test executable has a profile directory")
|
||||
.join("compile-tests/api-macros-renamed");
|
||||
let output = Command::new(env!("CARGO"))
|
||||
.args(["check", "--quiet", "--offline"])
|
||||
.current_dir(&fixture)
|
||||
.env("CARGO_TARGET_DIR", fixture.join("target"))
|
||||
.current_dir(fixture)
|
||||
.env("CARGO_TARGET_DIR", target_dir)
|
||||
.output()
|
||||
.expect("run cargo check for renamed dependency fixture");
|
||||
let _ = fs::remove_dir_all(&fixture);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "builtin-source"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
sha2.workspace = true
|
||||
@@ -0,0 +1,8 @@
|
||||
# builtin-source
|
||||
|
||||
Catalog of public Profile sources embedded at compile time and shared across native/WASM. It exposes only explicit assets and does not discover sources from host files or the network.
|
||||
|
||||
## Main entry points
|
||||
|
||||
- [`BUILTIN_PROFILE_RESOURCES`] and [`BuiltinProfileResource`] — sources and import relationships
|
||||
- [`builtin_profile_catalog_snapshot`] and [`BuiltinProfileCatalogSnapshot`] — fixed catalog contents and content identity
|
||||
@@ -0,0 +1,296 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const BUILTIN_PROFILE_CATALOG_ID: &str = "builtin-profiles-v2";
|
||||
pub const BUILTIN_DEFAULT_PROFILE: &str = "builtin:default";
|
||||
pub const BUILTIN_STANDALONE_PROFILE: &str = "builtin:standalone";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BuiltinProfileImport {
|
||||
pub specifier: &'static str,
|
||||
pub resolved_path: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BuiltinProfileResource {
|
||||
pub selector: Option<&'static str>,
|
||||
pub path: &'static str,
|
||||
pub source: &'static str,
|
||||
pub description: &'static str,
|
||||
pub imports: &'static [BuiltinProfileImport],
|
||||
}
|
||||
|
||||
const BASE_PATH: &str = "profiles/base.dcdl";
|
||||
pub const DEFAULT_PATH: &str = "profiles/default.dcdl";
|
||||
const BASE_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport {
|
||||
specifier: "./base.dcdl",
|
||||
resolved_path: BASE_PATH,
|
||||
}];
|
||||
const DEFAULT_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport {
|
||||
specifier: "./default.dcdl",
|
||||
resolved_path: DEFAULT_PATH,
|
||||
}];
|
||||
const NO_IMPORTS: &[BuiltinProfileImport] = &[];
|
||||
|
||||
/// Fixed catalog of public embedded Profile sources and import relationships.
|
||||
///
|
||||
/// Only the sources listed here are shared across native/WASM, not arbitrary resources.
|
||||
/// Launch selectors identify entry points; paths identify assets for imports.
|
||||
/// Publishing sources alone neither launches Workers nor grants permissions.
|
||||
pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
|
||||
BuiltinProfileResource {
|
||||
selector: None,
|
||||
path: BASE_PATH,
|
||||
source: include_str!("../../../resources/profiles/base.dcdl"),
|
||||
description: "Shared built-in Profile defaults.",
|
||||
imports: NO_IMPORTS,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some(BUILTIN_DEFAULT_PROFILE),
|
||||
path: DEFAULT_PATH,
|
||||
source: include_str!("../../../resources/profiles/default.dcdl"),
|
||||
description: "Default Yoi coding profile.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some(BUILTIN_STANDALONE_PROFILE),
|
||||
path: "profiles/standalone.dcdl",
|
||||
source: include_str!("../../../resources/profiles/standalone.dcdl"),
|
||||
description: "Standalone Yoi coding profile.",
|
||||
imports: DEFAULT_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:standalone-subjektiv"),
|
||||
path: "profiles/standalone-subjektiv.dcdl",
|
||||
source: include_str!("../../../resources/profiles/standalone-subjektiv.dcdl"),
|
||||
description: "Standalone with an explicitly connected local Subject.",
|
||||
imports: &[BuiltinProfileImport {
|
||||
specifier: "./standalone.dcdl",
|
||||
resolved_path: "profiles/standalone.dcdl",
|
||||
}],
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:standalone-subjektiv-consolidation"),
|
||||
path: "profiles/standalone-subjektiv-consolidation.dcdl",
|
||||
source: include_str!("../../../resources/profiles/standalone-subjektiv-consolidation.dcdl"),
|
||||
description: "Delegated local Subject Job policy.",
|
||||
imports: &[BuiltinProfileImport {
|
||||
specifier: "./job.dcdl",
|
||||
resolved_path: "profiles/job.dcdl",
|
||||
}],
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:subjektiv-journal-recording"),
|
||||
path: "profiles/subjektiv-journal-recording.dcdl",
|
||||
source: include_str!("../../../resources/profiles/subjektiv-journal-recording.dcdl"),
|
||||
description: "Journal recording policy with Host-bound sequential Job tools.",
|
||||
imports: &[BuiltinProfileImport {
|
||||
specifier: "./job.dcdl",
|
||||
resolved_path: "profiles/job.dcdl",
|
||||
}],
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:ticket-worker"),
|
||||
path: "profiles/ticket-worker.dcdl",
|
||||
source: include_str!("../../../resources/profiles/ticket-worker.dcdl"),
|
||||
description: "General intent-led Ticket work with optional result recording and authorized conclusion decisions.",
|
||||
imports: &[BuiltinProfileImport {
|
||||
specifier: "./default.dcdl",
|
||||
resolved_path: DEFAULT_PATH,
|
||||
}],
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:coder"),
|
||||
path: "profiles/coder.dcdl",
|
||||
source: include_str!("../../../resources/profiles/coder.dcdl"),
|
||||
description: "Code implementation with optional Git/MR review recipe; no universal Ticket conclusion gate.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:async-chat"),
|
||||
path: "profiles/async-chat.dcdl",
|
||||
source: include_str!("../../../resources/profiles/async-chat.dcdl"),
|
||||
description: "Explicitly connected Subject chat with bounded references and asynchronous Worker delegation.",
|
||||
imports: NO_IMPORTS,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:companion"),
|
||||
path: "profiles/companion.dcdl",
|
||||
source: include_str!("../../../resources/profiles/companion.dcdl"),
|
||||
description: "General assistance with Workspace tools.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:intake"),
|
||||
path: "profiles/intake.dcdl",
|
||||
source: include_str!("../../../resources/profiles/intake.dcdl"),
|
||||
description: "Read-only intake and planning.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:reviewer"),
|
||||
path: "profiles/reviewer.dcdl",
|
||||
source: include_str!("../../../resources/profiles/reviewer.dcdl"),
|
||||
description: "Independent review of a published Merge Request source.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:orchestrator"),
|
||||
path: "profiles/orchestrator.dcdl",
|
||||
source: include_str!("../../../resources/profiles/orchestrator.dcdl"),
|
||||
description: "Outcome-led orchestration, selection of candidate changes, and risk-proportionate validation.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:job"),
|
||||
path: "profiles/job.dcdl",
|
||||
source: include_str!("../../../resources/profiles/job.dcdl"),
|
||||
description: "Result-only caller-selected Job policy.",
|
||||
imports: NO_IMPORTS,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:backend-job"),
|
||||
path: "profiles/backend-job.dcdl",
|
||||
source: include_str!("../../../resources/profiles/backend-job.dcdl"),
|
||||
description: "Backend-owned bounded Job Worker.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:memory-consolidation"),
|
||||
path: "profiles/memory-consolidation.dcdl",
|
||||
source: include_str!("../../../resources/profiles/memory-consolidation.dcdl"),
|
||||
description: "Internal Memory consolidation service.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:subjektiv-memory-consolidation"),
|
||||
path: "profiles/subjektiv-memory-consolidation.dcdl",
|
||||
source: include_str!("../../../resources/profiles/subjektiv-memory-consolidation.dcdl"),
|
||||
description: "Delegated subject Memory consolidation service.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
];
|
||||
|
||||
/// Catalog snapshot fixing source bytes, selector entry points, and import relationships together.
|
||||
///
|
||||
/// [`Self::digest`] identifies all ordered contents and the catalog chosen by the consumer.
|
||||
/// It is not information for replacing values already evaluated in a Workspace with the current catalog.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BuiltinProfileCatalogSnapshot {
|
||||
pub id: &'static str,
|
||||
pub sources: BTreeMap<String, String>,
|
||||
pub entrypoints: BTreeMap<String, String>,
|
||||
pub imports: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl BuiltinProfileCatalogSnapshot {
|
||||
pub fn digest(&self) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(self.id.as_bytes());
|
||||
for (path, source) in &self.sources {
|
||||
hasher.update((path.len() as u64).to_le_bytes());
|
||||
hasher.update(path.as_bytes());
|
||||
hasher.update((source.len() as u64).to_le_bytes());
|
||||
hasher.update(source.as_bytes());
|
||||
}
|
||||
for (selector, path) in &self.entrypoints {
|
||||
hasher.update((selector.len() as u64).to_le_bytes());
|
||||
hasher.update(selector.as_bytes());
|
||||
hasher.update((path.len() as u64).to_le_bytes());
|
||||
hasher.update(path.as_bytes());
|
||||
}
|
||||
for (request, resolved_path) in &self.imports {
|
||||
hasher.update((request.len() as u64).to_le_bytes());
|
||||
hasher.update(request.as_bytes());
|
||||
hasher.update((resolved_path.len() as u64).to_le_bytes());
|
||||
hasher.update(resolved_path.as_bytes());
|
||||
}
|
||||
format!(
|
||||
"sha256:{}",
|
||||
hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn builtin_profile_catalog_snapshot() -> BuiltinProfileCatalogSnapshot {
|
||||
let mut sources = BTreeMap::new();
|
||||
let mut entrypoints = BTreeMap::new();
|
||||
let mut imports = BTreeMap::new();
|
||||
|
||||
for resource in BUILTIN_PROFILE_RESOURCES {
|
||||
sources.insert(resource.path.to_owned(), resource.source.to_owned());
|
||||
for import in resource.imports {
|
||||
imports.insert(
|
||||
format!("{}\0{}", resource.path, import.specifier),
|
||||
import.resolved_path.to_owned(),
|
||||
);
|
||||
}
|
||||
if let Some(selector) = resource.selector {
|
||||
entrypoints.insert(selector.to_owned(), resource.path.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
BuiltinProfileCatalogSnapshot {
|
||||
id: BUILTIN_PROFILE_CATALOG_ID,
|
||||
sources,
|
||||
entrypoints,
|
||||
imports,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn builtin_profile_entrypoints() -> impl Iterator<Item = &'static BuiltinProfileResource> {
|
||||
BUILTIN_PROFILE_RESOURCES
|
||||
.iter()
|
||||
.filter(|resource| resource.selector.is_some())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[test]
|
||||
fn ticket_worker_catalog_import_graph_includes_default_and_base() {
|
||||
// This snapshot supplies the Runtime profile source archive. Follow its
|
||||
// declared edges rather than resolving against every catalog source.
|
||||
let catalog = builtin_profile_catalog_snapshot();
|
||||
let entrypoint = catalog.entrypoints["builtin:ticket-worker"].clone();
|
||||
assert_eq!(
|
||||
catalog
|
||||
.imports
|
||||
.get(&format!("{entrypoint}\0./default.dcdl")),
|
||||
Some(&DEFAULT_PATH.to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.imports.get(&format!("{DEFAULT_PATH}\0./base.dcdl")),
|
||||
Some(&BASE_PATH.to_owned())
|
||||
);
|
||||
|
||||
let mut pending = vec![entrypoint.clone()];
|
||||
let mut reachable = BTreeSet::new();
|
||||
while let Some(path) = pending.pop() {
|
||||
if !reachable.insert(path.clone()) {
|
||||
continue;
|
||||
}
|
||||
assert!(catalog.sources.contains_key(&path), "missing source {path}");
|
||||
let prefix = format!("{path}\0");
|
||||
pending.extend(
|
||||
catalog
|
||||
.imports
|
||||
.iter()
|
||||
.filter(|(request, _)| request.starts_with(&prefix))
|
||||
.map(|(_, resolved)| resolved.clone()),
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
reachable,
|
||||
BTreeSet::from([entrypoint, DEFAULT_PATH.to_owned(), BASE_PATH.to_owned()])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use builtin_source::{BUILTIN_PROFILE_RESOURCES, builtin_profile_catalog_snapshot};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[test]
|
||||
fn public_catalog_is_closed_and_explicit() {
|
||||
let catalog = builtin_profile_catalog_snapshot();
|
||||
assert_eq!(catalog.sources.len(), BUILTIN_PROFILE_RESOURCES.len());
|
||||
assert!(catalog.digest().starts_with("sha256:"));
|
||||
for resource in BUILTIN_PROFILE_RESOURCES {
|
||||
assert!(resource.path.starts_with("profiles/"));
|
||||
assert!(resource.path.ends_with(".dcdl"));
|
||||
assert_eq!(catalog.sources[resource.path], resource.source);
|
||||
for import in resource.imports {
|
||||
assert!(catalog.sources.contains_key(import.resolved_path));
|
||||
assert!(
|
||||
resource
|
||||
.source
|
||||
.contains(&format!("import {:?}", import.specifier))
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(!catalog.sources.contains_key("prompts/default.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_import_metadata_has_no_cycles() {
|
||||
fn visit(path: &str, visiting: &mut BTreeSet<String>) {
|
||||
assert!(visiting.insert(path.to_string()), "catalog cycle at {path}");
|
||||
let resource = BUILTIN_PROFILE_RESOURCES
|
||||
.iter()
|
||||
.find(|resource| resource.path == path)
|
||||
.unwrap();
|
||||
for import in resource.imports {
|
||||
visit(import.resolved_path, visiting);
|
||||
}
|
||||
visiting.remove(path);
|
||||
}
|
||||
for resource in BUILTIN_PROFILE_RESOURCES {
|
||||
visit(resource.path, &mut BTreeSet::new());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_chat_is_public_and_does_not_inherit_broader_profile_defaults() {
|
||||
let catalog = builtin_profile_catalog_snapshot();
|
||||
let path = &catalog.entrypoints["builtin:async-chat"];
|
||||
let resource = BUILTIN_PROFILE_RESOURCES
|
||||
.iter()
|
||||
.find(|resource| resource.path == path)
|
||||
.unwrap();
|
||||
assert_eq!(resource.selector, Some("builtin:async-chat"));
|
||||
assert!(!resource.description.is_empty());
|
||||
assert!(resource.imports.is_empty());
|
||||
assert!(
|
||||
!catalog
|
||||
.imports
|
||||
.keys()
|
||||
.any(|request| request.starts_with(&format!("{path}\0")))
|
||||
);
|
||||
}
|
||||
+6
-26
@@ -1,30 +1,10 @@
|
||||
# client
|
||||
|
||||
## Role
|
||||
Shared client for Backend Workspace/Runtime APIs and the Worker protocol. Provides authenticated communication and typed connection targets for CLI/TUI. The standalone crate owns Standalone execution, Runtime and the controller own Worker state and input admission, and higher-level crates own presentation and product commands.
|
||||
|
||||
`client` contains reusable socket-client and runtime-command mechanics for talking to Workers from CLI/TUI code.
|
||||
## Implementation entry points
|
||||
|
||||
## Boundaries
|
||||
|
||||
Owns:
|
||||
|
||||
- one-shot Worker socket client behavior
|
||||
- request/reply delivery mechanics
|
||||
- runtime command construction below the product façade
|
||||
- shared attach/status probing helpers used by higher layers
|
||||
|
||||
Does not own:
|
||||
|
||||
- product command names (`yoi`)
|
||||
- Worker state authority (`worker`, `session-store` worker metadata)
|
||||
- UI rendering (`tui`)
|
||||
- Engine turn semantics (`agen`)
|
||||
|
||||
## Design notes
|
||||
|
||||
The client boundary lets `tui` and `yoi` share Worker communication without making library crates depend on the product binary. Socket clients should drain connect-time snapshot/alert traffic before sending a method or deciding status.
|
||||
|
||||
## See also
|
||||
|
||||
- [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
|
||||
- [`../../docs/design/overview.md`](../../docs/design/overview.md)
|
||||
- [`BackendApiClient`] — Authenticated HTTP/WS requests bound to an origin
|
||||
- [`backend_runtime`] / [`backend_workspace`] — Backend resource retrieval and operations
|
||||
- [`Client`] / [`transport`] — Method/Event encoding and message transport
|
||||
- [`target`] — Distinct Backend and Standalone targets and operation intents
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
//! Credentials and HTTP/WS requests bound to a Backend origin.
|
||||
//!
|
||||
//! BackendOrigin normalizes scheme/host/port and rejects inputs with a path, query, fragment,
|
||||
//! or userinfo. Stored tokens are selected by origin; cross-origin redirects are rejected
|
||||
//! to prevent credential leaks. WS requests use the same origin and bearer token.
|
||||
//!
|
||||
//! This client uses human Backend API tokens, not Workspace signing capabilities or Runtime
|
||||
//! source proofs. It returns 401/403 as authentication/authorization failures without falling
|
||||
//! back to a local Runtime or another token. Callers interpret typed results; transport success
|
||||
//! does not imply completion of a domain operation.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::{Method, StatusCode, Url, redirect};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
//! Runtime Worker operations and observation through the Backend.
|
||||
//!
|
||||
//! Targets are Workspace/Runtime/Worker tuples sent to scoped Backend APIs. Runtime endpoints
|
||||
//! and display names do not confer execution authority on the client. HTTP management reuses
|
||||
//! server-api DTOs; protocol connections carry Method/Event messages over Backend WS.
|
||||
//!
|
||||
//! Restore sends the caller's fixed observation token/request ID unchanged. Response loss does
|
||||
//! not imply rollback; there is no token replacement, automatic Restore, or reconnection to
|
||||
//! the next execution. Initial snapshots and notices are observations, not input admission.
|
||||
|
||||
use crate::transport::websocket::{Socket as WebSocket, SocketError as WebSocketError};
|
||||
use crate::{BackendApiClient, BackendApiClientError, Client};
|
||||
pub use server_api::{
|
||||
@@ -10,6 +20,7 @@ pub use server_api::{
|
||||
WorkerLaunchProfileCandidate as BackendWorkerLaunchProfileCandidate,
|
||||
WorkerLaunchRuntimeOption as BackendWorkerLaunchRuntimeOption,
|
||||
WorkerOperationState as BackendWorkerOperationState,
|
||||
WorkerRestoreRequest as BackendWorkerRestoreRequest,
|
||||
WorkerRestoreResponse as BackendWorkerRestoreResponse,
|
||||
WorkerRestoreResult as BackendWorkerRestoreResult,
|
||||
WorkerRestoreState as BackendWorkerRestoreState, WorkerSummary as BackendWorkerSummary,
|
||||
@@ -259,6 +270,15 @@ impl fmt::Display for BackendRuntimeClientError {
|
||||
}
|
||||
}
|
||||
|
||||
impl BackendRuntimeClientError {
|
||||
/// A stale intent is not a rejected Restore operation and must not be retried
|
||||
/// with a silently refreshed token.
|
||||
pub fn is_restore_observation_conflict(&self) -> bool {
|
||||
matches!(self, Self::ContractApi(server_api::client_support::ClientError::Public { status, error })
|
||||
if *status == reqwest::StatusCode::CONFLICT && error.error == "restore_observation_conflict")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for BackendRuntimeClientError {}
|
||||
|
||||
impl From<server_api::ServerApiClientError> for BackendRuntimeClientError {
|
||||
@@ -502,6 +522,7 @@ pub async fn list_backend_stopped_workers(
|
||||
|
||||
pub async fn restore_backend_worker(
|
||||
target: &BackendRuntimeTarget,
|
||||
request: BackendWorkerRestoreRequest,
|
||||
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
|
||||
validate_target(target)?;
|
||||
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
@@ -514,6 +535,7 @@ pub async fn restore_backend_worker(
|
||||
ticket_id: None,
|
||||
assignment_operation_id: None,
|
||||
},
|
||||
request,
|
||||
)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
@@ -811,7 +833,7 @@ mod tests {
|
||||
"display_name": "Embedded",
|
||||
"built_in": true,
|
||||
"worker_creation_available": true,
|
||||
"working_directory_required": false,
|
||||
"supports_workdir_attachments": false,
|
||||
"status": "online",
|
||||
"diagnostics": []
|
||||
}],
|
||||
@@ -819,7 +841,8 @@ mod tests {
|
||||
"profiles": [{
|
||||
"id": "builtin:default",
|
||||
"label": "Default",
|
||||
"description": ""
|
||||
"description": "",
|
||||
"feature_connections": { "subjektiv": false }
|
||||
}],
|
||||
"repositories": [],
|
||||
"working_directories": [],
|
||||
@@ -883,7 +906,9 @@ mod tests {
|
||||
ticket_assignment: None,
|
||||
initial_submit: Vec::new(),
|
||||
workdir_attachments: Vec::new(),
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
};
|
||||
|
||||
let response = create_backend_worker_with_client(&target, &create, &api)
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
//! Generated client adapter for the Backend Workspace catalog.
|
||||
//!
|
||||
//! Workspace creation passes an operation key and optional initial Repository to the Server,
|
||||
//! without local Git discovery or repository initialization. Empty Repository lists are valid.
|
||||
//! The Server determines creation idempotency, ownership, and current-state replay; this client
|
||||
//! keeps no separate creation ledger. It classifies 401/403 as shared Backend authentication
|
||||
//! errors, distinct from other domain errors.
|
||||
|
||||
use crate::{BackendApiClient, BackendApiClientError};
|
||||
use server_api::{
|
||||
InitialRepositoryIntent, RepositorySummary, WorkspaceCreateRequest, WorkspaceCreateResponse,
|
||||
@@ -434,11 +442,11 @@ mod tests {
|
||||
let request = CreateBackendWorkspaceRequest {
|
||||
operation_key: "workspace-create-1".to_string(),
|
||||
display_name: "Alpha".to_string(),
|
||||
repository: CreateBackendWorkspaceRepository {
|
||||
repository: Some(CreateBackendWorkspaceRepository {
|
||||
repository_key: "main".to_string(),
|
||||
uri: "/srv/repos/alpha".to_string(),
|
||||
default_ref: Some("develop".to_string()),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
let retry = request.clone();
|
||||
|
||||
@@ -6,7 +6,12 @@ use protocol::{Event, Method};
|
||||
|
||||
use crate::transport::Socket;
|
||||
|
||||
/// Typed Worker protocol client over an injected message transport.
|
||||
/// Typed Worker protocol client over a message transport.
|
||||
///
|
||||
/// Encodes and sends Methods and decodes received messages into Events. Successful sends
|
||||
/// indicate transport delivery, not controller command acknowledgments or durable Submit/Notify
|
||||
/// receipts. Callers interpret events; connection snapshots are not consumed automatically
|
||||
/// and state is not inferred.
|
||||
pub struct Client<T> {
|
||||
socket: T,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
//! Backend Workspace/Runtime と既存 Worker protocol へ接続するクライアント。
|
||||
//!
|
||||
//! Standalone execution is owned by the `standalone` crate and does not spawn
|
||||
//! a Worker subprocess through this crate.
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
pub mod backend_api;
|
||||
mod backend_auth;
|
||||
@@ -26,12 +23,12 @@ pub use backend_runtime::{
|
||||
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
|
||||
BackendWorkerImplementationSummary, BackendWorkerLaunchOptions,
|
||||
BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption,
|
||||
BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
|
||||
BackendWorkerRestoreResult, BackendWorkerRestoreState, BackendWorkerSummary,
|
||||
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySource, BackendWorkingDirectorySummary,
|
||||
connect_backend_runtime, create_backend_worker, get_backend_worker_launch_options,
|
||||
list_backend_stopped_workers, list_backend_workers, observe_backend_worker_session,
|
||||
restore_backend_worker,
|
||||
BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreRequest,
|
||||
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerRestoreState,
|
||||
BackendWorkerSummary, BackendWorkerWorkspaceSummary, BackendWorkingDirectorySource,
|
||||
BackendWorkingDirectorySummary, connect_backend_runtime, create_backend_worker,
|
||||
get_backend_worker_launch_options, list_backend_stopped_workers, list_backend_workers,
|
||||
observe_backend_worker_session, restore_backend_worker,
|
||||
};
|
||||
pub use backend_workspace::{
|
||||
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
//! Distinct Backend and Standalone targets and product operation intents.
|
||||
//!
|
||||
//! Backend targets resolve an origin and Workspace and identify Workers by Runtime/Worker pairs.
|
||||
//! Standalone spawn/list/resume are intents passed to higher-level crates, not execution;
|
||||
//! this module starts no subprocesses. Target identity is separate from product commands
|
||||
//! and Worker state authority.
|
||||
|
||||
use std::{fmt, path::PathBuf};
|
||||
|
||||
use crate::{
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
//! Message transport for one Worker connection.
|
||||
//!
|
||||
//! Separates in-process, Unix socket, and WS framing from protocol Method/Event encoding.
|
||||
//! Changing transports does not change ownership of Worker execution, input admission, or receipts.
|
||||
|
||||
use std::error::Error;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -6,10 +11,9 @@ pub mod in_process;
|
||||
pub mod unix_socket;
|
||||
pub mod websocket;
|
||||
|
||||
/// Message-oriented transport for one Worker protocol connection.
|
||||
/// UTF-8 message transport for a Worker protocol connection.
|
||||
///
|
||||
/// Implementations own physical framing. `client::Client` owns the typed
|
||||
/// Method/Event protocol encoding layered on top of these UTF-8 messages.
|
||||
/// Implementations own physical framing; the higher-level Client encodes and decodes Method/Event.
|
||||
#[async_trait]
|
||||
pub trait Socket {
|
||||
type Error: Error + Send + Sync + 'static;
|
||||
|
||||
@@ -3,8 +3,11 @@ use server_api::{
|
||||
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
||||
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
|
||||
RuntimeTrustKeyRevealResponse, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
|
||||
WorkspaceRuntimeResource,
|
||||
RuntimeTrustKeyRevealResponse, SubjektivMemoryChangesQuery, SubjektivMemoryDetailQuery,
|
||||
SubjektivMemoryListChangesResponse, SubjektivMemoryListQuery, SubjektivMemoryQueryResponse,
|
||||
SubjektivMemoryReadResponse, SubjektivResidentSurfaceResponse, SubjektivSubjectListQuery,
|
||||
SubjektivSubjectListResponse, SubjektivSubjectResponse, WorkerLaunchOptionsResponse,
|
||||
WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
|
||||
};
|
||||
use ticket::{
|
||||
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
|
||||
@@ -17,6 +20,7 @@ use ticket::{
|
||||
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
||||
|
||||
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
||||
const UNSUPPORTED_TICKET_STATE_CHANGE: &str = "unsupported legacy Ticket state/close operation; use TicketBackend::update_case with caller-supplied content digest, case state, closure operation key, and replay key (CAS); case updates do not start or stop work";
|
||||
|
||||
/// Workspace-scoped Backend client for Ticket and Objective product state.
|
||||
///
|
||||
@@ -88,6 +92,56 @@ impl BackendWorkspaceProductClient {
|
||||
.map_err(|error| crate::backend_workspace::server_client_error(&self.api, error))
|
||||
}
|
||||
|
||||
pub fn list_workspace_tags(
|
||||
&self,
|
||||
request: &ticket::TagListRequest,
|
||||
) -> Result<Vec<ticket::WorkspaceTag>, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let request = request.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.workspace_ticket_tag_list(
|
||||
workspace_id,
|
||||
server_api::WorkspaceTicketTagListRequest(request),
|
||||
)
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mutate_workspace_tag(
|
||||
&self,
|
||||
request: &ticket::WorkspaceTagMutation,
|
||||
) -> Result<ticket::WorkspaceTag, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let request = request.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.workspace_ticket_tag_mutate(
|
||||
workspace_id,
|
||||
server_api::WorkspaceTicketTagMutationRequest(request),
|
||||
)
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_ticket_tags(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
request: &ticket::TicketTagSetRequest,
|
||||
) -> Result<Vec<ticket::WorkspaceTag>, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(id);
|
||||
let request = request.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_tags_set(workspace_id, id, server_api::TicketTagsSetRequest(request))
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_tickets(
|
||||
&self,
|
||||
query: &TicketListQuery,
|
||||
@@ -154,40 +208,22 @@ impl BackendWorkspaceProductClient {
|
||||
|
||||
pub fn set_ticket_workflow_state(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
change: &TicketStateChange,
|
||||
_id: &TicketIdOrSlug,
|
||||
_change: &TicketStateChange,
|
||||
) -> Result<(), BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(id);
|
||||
let change = change.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_workflow_state_set(
|
||||
workspace_id,
|
||||
id,
|
||||
server_api::TicketStateChangeRequest(change),
|
||||
)
|
||||
.await
|
||||
})
|
||||
Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
UNSUPPORTED_TICKET_STATE_CHANGE.into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn close_ticket(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
resolution: &MarkdownText,
|
||||
_id: &TicketIdOrSlug,
|
||||
_resolution: &MarkdownText,
|
||||
) -> Result<(), BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(id);
|
||||
let resolution = resolution.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_close_record(
|
||||
workspace_id,
|
||||
id,
|
||||
server_api::TicketCloseRecordRequest(resolution),
|
||||
)
|
||||
.await
|
||||
})
|
||||
Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
UNSUPPORTED_TICKET_STATE_CHANGE.into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn add_ticket_relation(
|
||||
@@ -410,6 +446,97 @@ impl BackendWorkspaceProductClient {
|
||||
crate::backend_workspace::memory_staging_list_blocking(&self.api, &self.workspace_id, limit)
|
||||
}
|
||||
|
||||
pub fn list_subjektiv_subjects(
|
||||
&self,
|
||||
limit: usize,
|
||||
) -> Result<SubjektivSubjectListResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.subjektiv_subject_list(
|
||||
workspace_id,
|
||||
SubjektivSubjectListQuery {
|
||||
limit: Some(limit),
|
||||
cursor: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subjektiv_subject(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
) -> Result<SubjektivSubjectResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let subject_id = subject_id.to_string();
|
||||
self.generated(move |client| async move {
|
||||
client.subjektiv_subject_get(workspace_id, subject_id).await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subjektiv_resident_surface(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
) -> Result<SubjektivResidentSurfaceResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let subject_id = subject_id.to_string();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.subjektiv_resident_surface(workspace_id, subject_id)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_subjektiv_memories(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
query: &SubjektivMemoryListQuery,
|
||||
) -> Result<SubjektivMemoryQueryResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let subject_id = subject_id.to_string();
|
||||
let query = query.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.subjektiv_memory_list(workspace_id, subject_id, query)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subjektiv_memory(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
memory_id: &str,
|
||||
query: &SubjektivMemoryDetailQuery,
|
||||
) -> Result<SubjektivMemoryReadResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let subject_id = subject_id.to_string();
|
||||
let memory_id = memory_id.to_string();
|
||||
let query = query.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.subjektiv_memory_detail(workspace_id, subject_id, memory_id, query)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_subjektiv_memory_changes(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
memory_id: &str,
|
||||
query: &SubjektivMemoryChangesQuery,
|
||||
) -> Result<SubjektivMemoryListChangesResponse, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let subject_id = subject_id.to_string();
|
||||
let memory_id = memory_id.to_string();
|
||||
let query = query.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.subjektiv_memory_changes(workspace_id, subject_id, memory_id, query)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn launch_ticket_intake(
|
||||
&self,
|
||||
ticket_id: &str,
|
||||
@@ -421,7 +548,7 @@ impl BackendWorkspaceProductClient {
|
||||
let runtime = options
|
||||
.runtimes
|
||||
.iter()
|
||||
.find(|runtime| runtime.worker_creation_available && !runtime.working_directory_required)
|
||||
.find(|runtime| runtime.worker_creation_available)
|
||||
.ok_or_else(|| {
|
||||
BackendWorkspaceClientError::InvalidTarget(
|
||||
"Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker"
|
||||
@@ -438,7 +565,9 @@ impl BackendWorkspaceProductClient {
|
||||
content: format!("Please handle intake for Ticket {ticket_id}."),
|
||||
}],
|
||||
workdir_attachments: Vec::new(),
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
};
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let response: BrowserCreateWorkerResponse = self.generated(move |client| async move {
|
||||
@@ -473,6 +602,47 @@ impl BackendWorkspaceProductClient {
|
||||
}
|
||||
|
||||
impl TicketBackend for BackendWorkspaceProductClient {
|
||||
fn edit_post_with_snapshots(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
edit: ticket::TicketPostEdit,
|
||||
) -> ticket::Result<(Ticket, Ticket)> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_record_post_edit(workspace_id, id, server_api::EditTicketPostRequest(edit))
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn list_workspace_tags(
|
||||
&self,
|
||||
request: ticket::TagListRequest,
|
||||
) -> ticket::Result<Vec<ticket::WorkspaceTag>> {
|
||||
BackendWorkspaceProductClient::list_workspace_tags(self, &request)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn mutate_workspace_tag(
|
||||
&self,
|
||||
request: ticket::WorkspaceTagMutation,
|
||||
) -> ticket::Result<ticket::WorkspaceTag> {
|
||||
BackendWorkspaceProductClient::mutate_workspace_tag(self, &request)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn set_ticket_tags(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
request: ticket::TicketTagSetRequest,
|
||||
) -> ticket::Result<Vec<ticket::WorkspaceTag>> {
|
||||
BackendWorkspaceProductClient::set_ticket_tags(self, &id, &request)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn default_intake_ready_state_change_body(&self, from: &str) -> String {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let from = from.to_string();
|
||||
@@ -535,21 +705,12 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
|
||||
fn add_state_changed(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
change: TicketStateChange,
|
||||
_id: TicketIdOrSlug,
|
||||
_change: TicketStateChange,
|
||||
) -> ticket::Result<()> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_state_change_add(
|
||||
workspace_id,
|
||||
id,
|
||||
server_api::TicketStateChangeRequest(change),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.map_err(ticket_client_error)
|
||||
Err(TicketError::Conflict(
|
||||
UNSUPPORTED_TICKET_STATE_CHANGE.into(),
|
||||
))
|
||||
}
|
||||
|
||||
fn add_intake_summary(
|
||||
@@ -571,37 +732,37 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn set_state_field(
|
||||
fn update_case(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
field: &str,
|
||||
change: TicketStateChange,
|
||||
) -> ticket::Result<()> {
|
||||
ticket: &str,
|
||||
request: ticket::TicketCaseUpdate,
|
||||
) -> ticket::Result<Ticket> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
let field = field.to_string();
|
||||
let id = ticket.to_owned();
|
||||
// Actor identity belongs to authenticated Backend authority, not the caller.
|
||||
let request = server_api::TicketCaseUpdateRequest {
|
||||
expected_content_digest: request.expected_content_digest,
|
||||
expected_state: request.expected_state,
|
||||
expected_closure_operation_key: request.expected_closure_operation_key,
|
||||
operation_key: request.operation_key,
|
||||
action: request.action,
|
||||
explanation: request.explanation,
|
||||
references: request.references,
|
||||
};
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_state_field_set(
|
||||
workspace_id,
|
||||
id,
|
||||
field,
|
||||
server_api::TicketStateChangeRequest(change),
|
||||
)
|
||||
.ticket_case_update(workspace_id.clone(), id.clone(), request)
|
||||
.await?;
|
||||
// The case endpoint returns only a summary. Fetch the authoritative
|
||||
// full record rather than fabricating body, events, or case history.
|
||||
client
|
||||
.ticket_record_get(workspace_id, id)
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn set_workflow_state(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
change: TicketStateChange,
|
||||
) -> ticket::Result<()> {
|
||||
self.set_ticket_workflow_state(&id, &change)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> ticket::Result<Ticket> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
@@ -635,11 +796,6 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> ticket::Result<()> {
|
||||
self.close_ticket(&id, &resolution)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn add_ticket_relation(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
@@ -781,6 +937,33 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
fn read_http_request(stream: &mut std::net::TcpStream) -> String {
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 4_096];
|
||||
loop {
|
||||
let bytes = stream.read(&mut buffer).unwrap();
|
||||
assert_ne!(bytes, 0, "incomplete HTTP request");
|
||||
request.extend_from_slice(&buffer[..bytes]);
|
||||
if let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") {
|
||||
let headers = std::str::from_utf8(&request[..header_end]).unwrap();
|
||||
let content_length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length")
|
||||
.then(|| value.trim().parse::<usize>().unwrap())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if request.len() >= header_end + 4 + content_length {
|
||||
return String::from_utf8(request).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn one_response_server(
|
||||
status: &str,
|
||||
body: &str,
|
||||
@@ -792,11 +975,7 @@ mod tests {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let handle = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = vec![0_u8; 8_192];
|
||||
let bytes = stream.read(&mut request).unwrap();
|
||||
sender
|
||||
.send(String::from_utf8_lossy(&request[..bytes]).to_string())
|
||||
.unwrap();
|
||||
sender.send(read_http_request(&mut stream)).unwrap();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
@@ -808,19 +987,19 @@ mod tests {
|
||||
}
|
||||
|
||||
fn response_sequence_server(
|
||||
responses: Vec<(&'static str, &'static str)>,
|
||||
responses: Vec<(&str, &str)>,
|
||||
) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
|
||||
let responses = responses
|
||||
.into_iter()
|
||||
.map(|(status, body)| (status.to_owned(), body.to_owned()))
|
||||
.collect::<Vec<_>>();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let handle = thread::spawn(move || {
|
||||
for (status, body) in responses {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = vec![0_u8; 16_384];
|
||||
let bytes = stream.read(&mut request).unwrap();
|
||||
sender
|
||||
.send(String::from_utf8_lossy(&request[..bytes]).to_string())
|
||||
.unwrap();
|
||||
sender.send(read_http_request(&mut stream)).unwrap();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
@@ -832,6 +1011,399 @@ mod tests {
|
||||
(format!("http://{address}"), receiver, handle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_edit_uses_native_patch_and_returns_server_snapshots_without_reread() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
|
||||
.unwrap();
|
||||
let mut input = NewTicket::new("Post snapshots");
|
||||
input.body = MarkdownText::new("Original requirements");
|
||||
let reference = backend.create(input).unwrap();
|
||||
let original = backend.show(reference.id.clone().into()).unwrap();
|
||||
let edit = ticket::TicketPostEdit {
|
||||
event_ref: original.events[0].event_ref.clone(),
|
||||
expected_body: original.document.body.clone(),
|
||||
body: MarkdownText::new("Changed requirements"),
|
||||
author: "authenticated-editor".into(),
|
||||
};
|
||||
let expected = backend
|
||||
.edit_post_with_snapshots(reference.id.into(), edit.clone())
|
||||
.unwrap();
|
||||
let body = serde_json::to_string(&expected).unwrap();
|
||||
let (url, requests, server) = one_response_server("200 OK", &body);
|
||||
let client =
|
||||
BackendWorkspaceProductClient::new_with_access_token(url, "workspace-a", "token")
|
||||
.unwrap();
|
||||
let actual = TicketBackend::edit_post_with_snapshots(
|
||||
&client,
|
||||
TicketIdOrSlug::Query("T-123".into()),
|
||||
edit.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(actual, expected);
|
||||
let request = requests.recv().unwrap();
|
||||
assert!(request.starts_with("PATCH /api/w/workspace-a/tickets/T-123/posts HTTP/1.1\r\n"));
|
||||
let (_, body) = request.split_once("\r\n\r\n").unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ticket::TicketPostEdit>(body).unwrap(),
|
||||
edit
|
||||
);
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_state_and_close_calls_require_explicit_case_cas_without_http() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
format!("http://{}", listener.local_addr().unwrap()),
|
||||
"workspace-a",
|
||||
"token",
|
||||
)
|
||||
.unwrap();
|
||||
let id = TicketIdOrSlug::Query("T-739".into());
|
||||
let change = TicketStateChange::new("open", "closed", "Judgment", "Explanation");
|
||||
let errors = [
|
||||
client
|
||||
.set_ticket_workflow_state(&id, &change)
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
client
|
||||
.close_ticket(&id, &MarkdownText::new("Withdrawn"))
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
TicketBackend::add_state_changed(&client, id, change)
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
];
|
||||
for error in errors {
|
||||
assert!(
|
||||
error.contains("unsupported legacy Ticket state/close operation"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(error.contains("TicketBackend::update_case"), "{error}");
|
||||
assert!(error.contains("caller-supplied"), "{error}");
|
||||
assert!(error.contains("CAS"), "{error}");
|
||||
}
|
||||
assert_eq!(
|
||||
listener.accept().unwrap_err().kind(),
|
||||
std::io::ErrorKind::WouldBlock
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_update_case_forwards_tagged_actions_and_returns_full_backend_record() {
|
||||
for action in [
|
||||
serde_json::json!({"action": "close", "reason": "completed"}),
|
||||
serde_json::json!({"action": "close", "reason": {"other": "Custom judgment"}}),
|
||||
serde_json::json!({"action": "correct_closure", "reason": "duplicate"}),
|
||||
serde_json::json!({"action": "reopen"}),
|
||||
] {
|
||||
// SQLite only supplies hermetic response fixtures, never a client fallback.
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
|
||||
.unwrap();
|
||||
let mut input = NewTicket::new("Full case record");
|
||||
input.body = MarkdownText::new("Full body absent from the endpoint summary");
|
||||
let reference = backend.create(input).unwrap();
|
||||
let tag = backend
|
||||
.mutate_workspace_tag(ticket::WorkspaceTagMutation {
|
||||
operation_key: "classify-create".into(),
|
||||
action: ticket::WorkspaceTagAction::Create {
|
||||
name: "closed".into(),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
backend
|
||||
.set_ticket_tags(
|
||||
reference.id.clone().into(),
|
||||
ticket::TicketTagSetRequest {
|
||||
operation_key: "classify-set".into(),
|
||||
expected_tag_ids: Vec::new(),
|
||||
tag_ids: vec![tag.id.clone()],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut snapshot = backend.show(reference.id.clone().into()).unwrap();
|
||||
if action["action"] != "close" {
|
||||
snapshot = backend
|
||||
.update_case(
|
||||
&reference.id,
|
||||
ticket::TicketCaseUpdate {
|
||||
expected_content_digest: ticket::ticket_content_digest(&snapshot),
|
||||
expected_state: ticket::TicketState::Open,
|
||||
expected_closure_operation_key: None,
|
||||
operation_key: "initial-close".into(),
|
||||
action: ticket::TicketCaseAction::Close {
|
||||
reason: ticket::TicketClosureReason::Unknown,
|
||||
},
|
||||
explanation: "Initial judgment".into(),
|
||||
references: Vec::new(),
|
||||
author: Some("authenticated-actor".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let expected_request = serde_json::json!({
|
||||
"expected_content_digest": ticket::ticket_content_digest(&snapshot),
|
||||
"expected_state": snapshot.meta.workflow_state.as_str(),
|
||||
"expected_closure_operation_key": snapshot.closure_operation_key,
|
||||
"operation_key": "caller-case-key",
|
||||
"action": action,
|
||||
"explanation": "Caller judgment, not work progress",
|
||||
"references": [{"kind": "ticket", "target": "T-738"}],
|
||||
});
|
||||
let mut domain_request = expected_request.clone();
|
||||
domain_request["author"] = serde_json::json!("caller-cannot-impersonate");
|
||||
let operation =
|
||||
serde_json::from_value::<ticket::TicketBackendOperation>(serde_json::json!({
|
||||
"operation": "update_case",
|
||||
"ticket": reference.id,
|
||||
"request": domain_request,
|
||||
}))
|
||||
.unwrap();
|
||||
let mut fixture_request: ticket::TicketCaseUpdate =
|
||||
serde_json::from_value(expected_request.clone()).unwrap();
|
||||
fixture_request.author = Some("authenticated-actor".into());
|
||||
let expected = backend.update_case(&reference.id, fixture_request).unwrap();
|
||||
let summary =
|
||||
serde_json::to_string(&backend.list(TicketListQuery::all()).unwrap()[0]).unwrap();
|
||||
let full_record = serde_json::to_string(&expected).unwrap();
|
||||
let (base_url, requests, handle) =
|
||||
response_sequence_server(vec![("200 OK", &summary), ("200 OK", &full_record)]);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-token",
|
||||
)
|
||||
.unwrap();
|
||||
let result = ticket::execute_ticket_backend_operation(&client, operation).unwrap();
|
||||
let ticket::TicketBackendOperationResult::Ticket(actual) = result else {
|
||||
panic!("case operation must return a full Ticket");
|
||||
};
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(actual.tags, vec![tag]);
|
||||
assert!(!actual.case_history.is_empty());
|
||||
assert_eq!(actual.closure_operation_key, expected.closure_operation_key);
|
||||
let post = requests.recv().unwrap();
|
||||
assert!(
|
||||
post.starts_with(&format!(
|
||||
"POST /workspaces/workspace-a/tickets/{}/case ",
|
||||
reference.id,
|
||||
)),
|
||||
"{post}"
|
||||
);
|
||||
assert!(
|
||||
post.contains("authorization: Bearer test-token\r\n"),
|
||||
"{post}"
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(post.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(body, expected_request);
|
||||
assert!(body.get("author").is_none());
|
||||
assert!(requests.recv().unwrap().starts_with(&format!(
|
||||
"GET /api/w/workspace-a/tickets/{}/record ",
|
||||
reference.id,
|
||||
)));
|
||||
handle.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classification_client_uses_scoped_routes_and_preserves_cas_and_replay_input() {
|
||||
let tag = serde_json::json!({"workspace_id":"workspace-a", "id":"tag-a", "name":"Ready", "last_operation_key":"create-1", "deleted":false});
|
||||
let single = tag.to_string();
|
||||
let list = serde_json::json!([tag]).to_string();
|
||||
let (base_url, requests, handle) = response_sequence_server(vec![
|
||||
("200 OK", &list),
|
||||
("200 OK", &single),
|
||||
("200 OK", &list),
|
||||
]);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-token",
|
||||
)
|
||||
.unwrap();
|
||||
let listed = client
|
||||
.list_workspace_tags(&ticket::TagListRequest {
|
||||
after: Some("tag-0".into()),
|
||||
limit: Some(7),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(listed[0].id, "tag-a");
|
||||
let request = requests.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("GET /api/w/workspace-a/ticket-tags?"),
|
||||
"{request}"
|
||||
);
|
||||
assert!(
|
||||
request.contains("after=tag-0") && request.contains("limit=7"),
|
||||
"{request}"
|
||||
);
|
||||
assert!(
|
||||
request
|
||||
.to_lowercase()
|
||||
.contains("authorization: bearer test-token")
|
||||
);
|
||||
let mutation = ticket::WorkspaceTagMutation {
|
||||
operation_key: "rename-1".into(),
|
||||
action: ticket::WorkspaceTagAction::Rename {
|
||||
tag_id: "tag-a".into(),
|
||||
expected_last_operation_key: "create-1".into(),
|
||||
name: "New".into(),
|
||||
},
|
||||
};
|
||||
TicketBackend::mutate_workspace_tag(&client, mutation.clone()).unwrap();
|
||||
let request = requests.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("POST /api/w/workspace-a/ticket-tags "),
|
||||
"{request}"
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(body, serde_json::json!(mutation));
|
||||
let set = ticket::TicketTagSetRequest {
|
||||
operation_key: "set-1".into(),
|
||||
expected_tag_ids: vec!["tag-old".into()],
|
||||
tag_ids: vec!["tag-a".into(), "tag-b".into()],
|
||||
};
|
||||
let receipt =
|
||||
TicketBackend::set_ticket_tags(&client, TicketIdOrSlug::from("T-740"), set.clone())
|
||||
.unwrap();
|
||||
assert_eq!(receipt[0].name, "Ready");
|
||||
let request = requests.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("PUT /api/w/workspace-a/tickets/T-740/tags "),
|
||||
"{request}"
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(body, serde_json::json!(set));
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classification_client_returns_backend_denials_without_local_fallback() {
|
||||
for status in ["403 Forbidden", "409 Conflict"] {
|
||||
let (base_url, requests, handle) = one_response_server(
|
||||
status,
|
||||
r#"{"error":"ticket_conflict","message":"stale or unauthorized","diagnostics":[]}"#,
|
||||
);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"token",
|
||||
)
|
||||
.unwrap();
|
||||
let result = client.set_ticket_tags(
|
||||
&TicketIdOrSlug::from("T-740"),
|
||||
&ticket::TicketTagSetRequest {
|
||||
operation_key: "retry-key".into(),
|
||||
expected_tag_ids: vec![],
|
||||
tag_ids: vec!["tag-a".into()],
|
||||
},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
requests
|
||||
.recv()
|
||||
.unwrap()
|
||||
.starts_with("PUT /api/w/workspace-a/tickets/T-740/tags ")
|
||||
);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn case_request() -> ticket::TicketCaseUpdate {
|
||||
ticket::TicketCaseUpdate {
|
||||
operation_key: "caller-case-key".into(),
|
||||
expected_content_digest: "caller-content-digest".into(),
|
||||
expected_state: ticket::TicketState::Closed,
|
||||
expected_closure_operation_key: Some("last-close-key".into()),
|
||||
action: ticket::TicketCaseAction::Reopen,
|
||||
explanation: "Caller reopening judgment".into(),
|
||||
references: Vec::new(),
|
||||
author: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_update_rejection_preserves_caller_cas_without_local_fallback() {
|
||||
for (status, body, expected_error) in [
|
||||
(
|
||||
"409 Conflict",
|
||||
r#"{"error":"ticket_conflict","message":"stale content","diagnostics":[]}"#,
|
||||
"409",
|
||||
),
|
||||
(
|
||||
"403 Forbidden",
|
||||
r#"{"error":"forbidden","message":"not authorized","diagnostics":[]}"#,
|
||||
"403",
|
||||
),
|
||||
] {
|
||||
let (base_url, requests, handle) = one_response_server(status, body);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-token",
|
||||
)
|
||||
.unwrap();
|
||||
let error = TicketBackend::update_case(&client, "T-739", case_request()).unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains(expected_error),
|
||||
"{error}; response {status}: {body}"
|
||||
);
|
||||
let request = requests.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("POST /workspaces/workspace-a/tickets/T-739/case "),
|
||||
"{request}"
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(body["expected_content_digest"], "caller-content-digest");
|
||||
assert_eq!(body["expected_state"], "closed");
|
||||
assert_eq!(body["expected_closure_operation_key"], "last-close-key");
|
||||
assert_eq!(body["operation_key"], "caller-case-key");
|
||||
assert_eq!(body["action"], serde_json::json!({"action": "reopen"}));
|
||||
assert!(!error.to_string().contains("test-token"));
|
||||
handle.join().unwrap();
|
||||
assert!(requests.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_case_update_with_failed_show_does_not_fabricate_full_ticket() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
|
||||
.unwrap();
|
||||
let reference = backend.create(NewTicket::new("Summary only")).unwrap();
|
||||
let summary =
|
||||
serde_json::to_string(&backend.list(TicketListQuery::all()).unwrap()[0]).unwrap();
|
||||
let (base_url, requests, handle) = response_sequence_server(vec![
|
||||
("200 OK", &summary),
|
||||
(
|
||||
"404 Not Found",
|
||||
r#"{"error":"ticket_not_found","message":"full record unavailable","diagnostics":[]}"#,
|
||||
),
|
||||
]);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-token",
|
||||
)
|
||||
.unwrap();
|
||||
let error = TicketBackend::update_case(&client, &reference.id, case_request()).unwrap_err();
|
||||
assert!(error.to_string().contains("404"), "{error}");
|
||||
assert!(requests.recv().unwrap().starts_with("POST /workspaces/"));
|
||||
assert!(requests.recv().unwrap().starts_with("GET /api/w/"));
|
||||
handle.join().unwrap();
|
||||
assert!(requests.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_document_uses_shared_workspace_scoped_response() {
|
||||
let body = r##"{"body_md":"# Memory\\n","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-02T00:00:00Z","bytes":10,"record_source":"workspace-sqlite"}"##;
|
||||
@@ -917,6 +1489,45 @@ mod tests {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subjektiv_memory_list_encodes_typed_filters_as_bounded_query_scalars() {
|
||||
let (base_url, request, handle) =
|
||||
one_response_server("200 OK", r#"{"items":[],"has_more":false}"#);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-backend-token",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let response = client
|
||||
.list_subjektiv_memories(
|
||||
"subject/a",
|
||||
&SubjektivMemoryListQuery {
|
||||
query: Some("stable cursor".to_string()),
|
||||
kinds: None,
|
||||
states: Some(vec![
|
||||
server_api::SubjektivMemoryState::Active,
|
||||
server_api::SubjektivMemoryState::Resolved,
|
||||
]),
|
||||
limit: Some(25),
|
||||
cursor: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(response.items.is_empty());
|
||||
let request = request.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("GET /api/w/workspace-a/subjektiv/subjects/subject%2Fa/memories?")
|
||||
);
|
||||
assert!(request.contains("query=stable+cursor"));
|
||||
assert!(request.contains("states=active%2Cresolved"));
|
||||
assert!(request.contains("limit=25"));
|
||||
assert!(request.contains("authorization: Bearer test-backend-token\r\n"));
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objective_list_uses_workspace_scoped_backend_route() {
|
||||
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"invalid_records":[{"label":"broken.md","reason":"invalid frontmatter"}],"record_authority":"sqlite"}"#;
|
||||
@@ -1026,7 +1637,7 @@ mod tests {
|
||||
let (base_url, requests, handle) = response_sequence_server(vec![
|
||||
(
|
||||
"200 OK",
|
||||
r#"{"workspace_id":"workspace-a","runtimes":[{"runtime_id":"embedded","display_name":"Embedded","built_in":true,"worker_creation_available":true,"working_directory_required":false,"status":"connected","diagnostics":[]}],"default_profile":null,"profiles":[],"repositories":[],"working_directories":[],"diagnostics":[]}"#,
|
||||
r#"{"workspace_id":"workspace-a","runtimes":[{"runtime_id":"embedded","display_name":"Embedded","built_in":true,"worker_creation_available":true,"supports_workdir_attachments":false,"status":"connected","diagnostics":[]}],"default_profile":null,"profiles":[],"repositories":[],"working_directories":[],"diagnostics":[]}"#,
|
||||
),
|
||||
(
|
||||
"200 OK",
|
||||
@@ -1100,12 +1711,12 @@ mod tests {
|
||||
assert_eq!(
|
||||
ticket_list_state_query(&TicketListQuery {
|
||||
state: TicketStateSelector::States(
|
||||
[TicketListState::Ready, TicketListState::InProgress]
|
||||
[TicketListState::Open, TicketListState::Closed]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
}),
|
||||
"ready,inprogress"
|
||||
"open,closed"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ fn workspace_creation_request_preserves_operation_key_for_retry() {
|
||||
let request = CreateBackendWorkspaceRequest {
|
||||
operation_key: "workspace-create-1".to_string(),
|
||||
display_name: "Alpha".to_string(),
|
||||
repository: CreateBackendWorkspaceRepository {
|
||||
repository: Some(CreateBackendWorkspaceRepository {
|
||||
repository_key: "main".to_string(),
|
||||
uri: "/srv/repos/alpha".to_string(),
|
||||
default_ref: Some("develop".to_string()),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
assert_eq!(request.clone(), request);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# config-source-wasm
|
||||
|
||||
WASM adapter exposing `config-source` snapshot evaluation and editing support to JavaScript. It handles browser-local edit candidates without changing Backend save permissions or persistent state.
|
||||
|
||||
## Main entry points
|
||||
|
||||
- [`set_snapshot`] and [`apply_changes`] — update local edit candidates
|
||||
- [`evaluate_snapshot`] and [`evaluate_current`] — evaluation through the shared implementation
|
||||
- [`analyze_snapshot`] and [`complete_current`] — diagnostics and completion
|
||||
@@ -1,3 +1,5 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
use config_source::{
|
||||
ConfigSchemaContribution, ConfigTreeChange, ConfigTreeSnapshot, EvaluationResult,
|
||||
SnapshotEnvironment, ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle,
|
||||
@@ -17,6 +19,10 @@ pub fn compose_schema_bundle(contributions: JsValue) -> Result<JsValue, JsValue>
|
||||
encode(WorkspaceConfigSchemaBundle::compose(contributions).map_err(js_error)?)
|
||||
}
|
||||
|
||||
/// Retain the browser's edit candidate as a snapshot.
|
||||
///
|
||||
/// Deserialization uses shared digest validation but neither saves to nor authorizes the Backend.
|
||||
/// Keep the schema bundle in separate state to distinguish sources from evaluation conditions.
|
||||
#[wasm_bindgen]
|
||||
pub fn set_snapshot(snapshot: JsValue) -> Result<(), JsValue> {
|
||||
let snapshot: ConfigTreeSnapshot = decode(snapshot)?;
|
||||
@@ -85,6 +91,10 @@ struct WasmCompletionItem {
|
||||
priority: i32,
|
||||
}
|
||||
|
||||
/// Complete after converting the editor's UTF-16 offset to the shared analyzer's UTF-8 byte offset.
|
||||
///
|
||||
/// Convert the returned start position back to UTF-16 so non-ASCII characters do not
|
||||
/// misalign editor and analyzer positions. Reject offsets that cannot map to a character boundary.
|
||||
#[wasm_bindgen]
|
||||
pub fn complete_current(
|
||||
entrypoint: String,
|
||||
@@ -98,31 +108,47 @@ pub fn complete_current(
|
||||
let snapshot = session
|
||||
.as_ref()
|
||||
.ok_or_else(|| JsValue::from_str("config source snapshot is not initialized"))?;
|
||||
let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?;
|
||||
let result = session_environment(snapshot.clone())
|
||||
.complete_config(&entrypoint, &source, utf8_byte_offset, explicit)
|
||||
.map_err(|error| JsValue::from_str(&format!("{error:?}")))?;
|
||||
let result = result
|
||||
.map(|result| {
|
||||
Ok::<WasmCompletionResult, JsValue>(WasmCompletionResult {
|
||||
from: utf8_to_utf16_offset(&source, result.from)?,
|
||||
items: result
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| WasmCompletionItem {
|
||||
label: item.label,
|
||||
kind: format!("{:?}", item.kind).to_lowercase(),
|
||||
detail: item.detail,
|
||||
priority: item.priority,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
encode(result)
|
||||
encode(complete_snapshot(
|
||||
snapshot,
|
||||
&entrypoint,
|
||||
&source,
|
||||
utf16_offset,
|
||||
explicit,
|
||||
)?)
|
||||
})
|
||||
}
|
||||
|
||||
fn complete_snapshot(
|
||||
snapshot: &ConfigTreeSnapshot,
|
||||
entrypoint: &VirtualPath,
|
||||
source: &str,
|
||||
utf16_offset: usize,
|
||||
explicit: bool,
|
||||
) -> Result<Option<WasmCompletionResult>, JsValue> {
|
||||
let utf8_byte_offset = utf16_to_utf8_offset(source, utf16_offset)?;
|
||||
let result = session_environment(snapshot.clone())
|
||||
.complete_config(entrypoint, source, utf8_byte_offset, explicit)
|
||||
.map_err(|error| JsValue::from_str(&format!("{error:?}")))?;
|
||||
let result = result
|
||||
.map(|result| {
|
||||
Ok::<WasmCompletionResult, JsValue>(WasmCompletionResult {
|
||||
from: utf8_to_utf16_offset(source, result.from)?,
|
||||
items: result
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| WasmCompletionItem {
|
||||
label: item.label,
|
||||
kind: format!("{:?}", item.kind).to_lowercase(),
|
||||
detail: item.detail,
|
||||
priority: item.priority,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn evaluate_snapshot(snapshot: JsValue, contract: JsValue) -> Result<JsValue, JsValue> {
|
||||
let snapshot: ConfigTreeSnapshot = decode(snapshot)?;
|
||||
@@ -207,3 +233,121 @@ fn js_error(error: impl std::fmt::Display) -> JsValue {
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _assert_serializable(_: EvaluationResult) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use config_source::{ConfigContentType, ConfigEntry};
|
||||
|
||||
fn snapshot() -> ConfigTreeSnapshot {
|
||||
ConfigTreeSnapshot::from_entries([
|
||||
ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
"{}",
|
||||
)
|
||||
.unwrap(),
|
||||
// A same-suffix workspace file must not be an unknown builtin fallback.
|
||||
ConfigEntry::new(
|
||||
VirtualPath::parse("profiles/missing.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
"{}",
|
||||
)
|
||||
.unwrap(),
|
||||
])
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wasm_analysis_uses_applied_tree_content_identity() {
|
||||
let base = snapshot();
|
||||
let path = VirtualPath::parse("main.dcdl").unwrap();
|
||||
let change = ConfigTreeChange::Update {
|
||||
path: path.clone(),
|
||||
expected_digest: base.entries[&path].content_digest.clone(),
|
||||
content: "{ broken = ; }".into(),
|
||||
};
|
||||
let changed = base.apply(&[change]).unwrap();
|
||||
let diagnostics = session_environment(changed.clone()).analyze(&path, None);
|
||||
assert!(!diagnostics.is_empty());
|
||||
assert!(
|
||||
diagnostics
|
||||
.iter()
|
||||
.all(|diagnostic| diagnostic.tree_digest == changed.digest)
|
||||
);
|
||||
assert_ne!(changed.digest, base.digest);
|
||||
let restored = changed.apply(&changed.changes_to(&base)).unwrap();
|
||||
assert_eq!(restored.digest, base.digest);
|
||||
assert!(
|
||||
session_environment(restored)
|
||||
.analyze(&path, None)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wasm_completion_boundary_preserves_read_only_builtin_detail() {
|
||||
let snapshot = snapshot();
|
||||
let source = "let 名 = 1; import \"$builtin/profiles/comp\"";
|
||||
let cursor = source.encode_utf16().count() - 1;
|
||||
let result = complete_snapshot(
|
||||
&snapshot,
|
||||
&VirtualPath::parse("main.dcdl").unwrap(),
|
||||
source,
|
||||
cursor,
|
||||
true,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(result.from, "let 名 = 1; import \"".encode_utf16().count());
|
||||
let item = result
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.label == "$builtin/profiles/companion.dcdl")
|
||||
.unwrap();
|
||||
assert_eq!(item.kind, "file");
|
||||
assert_eq!(
|
||||
item.detail.as_deref(),
|
||||
Some("read-only builtin Decodal source")
|
||||
);
|
||||
assert_eq!(snapshot.entries.len(), 2);
|
||||
assert!(
|
||||
snapshot
|
||||
.entries
|
||||
.keys()
|
||||
.all(|path| !path.as_str().starts_with("$builtin/"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wasm_analysis_boundary_reports_unknown_builtin_without_workspace_fallback() {
|
||||
let snapshot = snapshot();
|
||||
let path = VirtualPath::parse("main.dcdl").unwrap();
|
||||
let diagnostics = session_environment(snapshot.clone())
|
||||
.analyze(&path, Some("import \"$builtin/profiles/missing.dcdl\""));
|
||||
assert!(!diagnostics.is_empty());
|
||||
let diagnostic = diagnostics
|
||||
.iter()
|
||||
.find(|diagnostic| {
|
||||
diagnostic
|
||||
.message
|
||||
.contains("unknown or non-public read-only builtin source")
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(diagnostic.path, path);
|
||||
assert_eq!(diagnostic.tree_digest, snapshot.digest);
|
||||
assert!(
|
||||
diagnostic
|
||||
.message
|
||||
.contains("$builtin/profiles/missing.dcdl")
|
||||
);
|
||||
assert!(
|
||||
session_environment(snapshot)
|
||||
.analyze(
|
||||
&VirtualPath::parse("main.dcdl").unwrap(),
|
||||
Some("import \"./profiles/missing.dcdl\"")
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
builtin-source.workspace = true
|
||||
decodal.workspace = true
|
||||
decodal-language-service.workspace = true
|
||||
decodal-language-tools.workspace = true
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# config-source
|
||||
|
||||
Shares the virtual Workspace configuration source tree, Decodal evaluation, diagnostics, and completion across native/WASM. Inputs are content-addressed snapshots, not a mechanism for reading host configuration directories. Callers own persistence, save-time authorization, and whole-tree CAS.
|
||||
|
||||
## Main entry points
|
||||
|
||||
- [`VirtualPath`] and [`ConfigTreeSnapshot`] — virtual paths and source-change validation
|
||||
- [`WorkspaceConfigSchemaBundle`] and [`ToolchainContract`] — evaluation-condition identity
|
||||
- [`SnapshotEnvironment`] and [`SnapshotImportLoader`] — evaluation and closed import resolution
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user