Compare commits
1107
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b12c3e085 | ||
|
|
623c31615d | ||
|
|
af719e387d | ||
|
|
d12bc2ea0b | ||
|
|
a1df3742b1 | ||
|
|
d8818b776b | ||
|
|
40c36ccb1d | ||
|
|
80c151d084 | ||
|
|
c6a8759e7b | ||
|
|
f17b5212c7 | ||
|
|
b284934e34 | ||
|
|
2d33321fab | ||
|
|
0975471986 | ||
|
|
214d6e21dd | ||
|
|
fca5c23345 | ||
|
|
f9b1500ba2 | ||
|
|
931bf3b27d | ||
|
|
6bb671be21 | ||
|
|
b8f2fa479c | ||
|
|
1639be9841 | ||
|
|
aa0fb2f787 | ||
|
|
1b6d4dbfd8 | ||
|
|
4ef7b03651 | ||
|
|
af615ab177 | ||
|
|
338e39bcdb | ||
|
|
3cfe04be3a | ||
|
|
70d153b365 | ||
|
|
ce93849948 | ||
|
|
3c499719fe | ||
|
|
9e1d3df7ad | ||
|
|
18bac16a23 | ||
|
|
9005856f77 | ||
|
|
e39372a52a | ||
|
|
223a74a9e2 | ||
|
|
f38d9a16ea | ||
|
|
b135fa5e99 | ||
|
|
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 | ||
|
|
17b7bdc769 | ||
|
|
62b0f87477 | ||
|
|
1e59a07ea8 | ||
|
|
014b948990 | ||
|
|
e43410f5e3 | ||
|
|
64ed50f6c0 | ||
|
|
0ec2b3ad86 | ||
|
|
2f75176648 | ||
|
|
7797a65d54 | ||
|
|
f5d8c94713 | ||
|
|
f351996d2c | ||
|
|
1afddc148d | ||
|
|
d066f3963f | ||
|
|
8e0dad98e8 | ||
|
|
f742362b03 | ||
|
|
d101b4bacb | ||
|
|
696e6a51e7 | ||
|
|
b9aaff4052 | ||
|
|
f99da27891 | ||
|
|
d3c3f6f4d5 | ||
|
|
9579f3e245 | ||
|
|
ab8b4ab1ef | ||
|
|
101b021c5f | ||
|
|
ee83fca95b | ||
|
|
5c98dbb208 | ||
|
|
b061ab68de | ||
|
|
63951398f0 | ||
|
|
027a346e9d | ||
|
|
3ee1698957 | ||
|
|
a07d70b107 | ||
|
|
213ed76061 | ||
|
|
eb4c7959a7 | ||
|
|
b5ae7dcd64 | ||
|
|
1bc1f54ea0 | ||
|
|
2ddf547308 | ||
|
|
ca838e4acf | ||
|
|
6b2f40a2a2 | ||
|
|
13f01b2495 | ||
|
|
44d6d740be | ||
|
|
3d87c3860a | ||
|
|
8627d45085 | ||
|
|
cbf1b83599 | ||
|
|
699a3c051c | ||
|
|
5f8ae508fc | ||
|
|
657cae1d4b | ||
|
|
c0fd476d8b | ||
|
|
eb222c8d62 | ||
|
|
5560a88f43 | ||
|
|
58c5c43061 | ||
|
|
c73e20be17 | ||
|
|
709f3c5530 | ||
|
|
4976b6fd00 | ||
|
|
aa144ee6fa | ||
|
|
6d0eaef6bc | ||
|
|
065812bc39 | ||
|
|
6d5d979012 | ||
|
|
22c2e25c8f | ||
|
|
717d28839d | ||
|
|
4e8a7fc118 | ||
|
|
34ed988b75 | ||
|
|
a0944f66c8 | ||
|
|
05d6da45fb | ||
|
|
2006b390b4 | ||
|
|
cafbb73a22 | ||
|
|
39ecd1b239 | ||
|
|
677c7cfed9 | ||
|
|
5f19dcc93f | ||
|
|
39b4a019c9 | ||
|
|
8c78e55b28 | ||
|
|
60137e0aad | ||
|
|
3023a270f4 | ||
|
|
4ffa9e418e | ||
|
|
7a783fcf4e | ||
|
|
e9e0368db4 | ||
|
|
38c2a78b1c | ||
|
|
8ff7df7edf | ||
|
|
d81c9a8154 | ||
|
|
36295edc08 | ||
|
|
c18e3ea0d6 | ||
|
|
4ab2a2b88a | ||
|
|
0440e64975 | ||
|
|
1980da2880 | ||
|
|
dd2bc41ab8 | ||
|
|
dcf14ef565 | ||
|
|
8b951d53e6 | ||
|
|
cc2f8b3422 | ||
|
|
d8067c9789 | ||
|
|
12d1d0e2d9 | ||
|
|
09f8cfb718 | ||
|
|
c979883463 | ||
|
|
21962d7a6a | ||
|
|
08cbf93f7b | ||
|
|
d1303ab638 | ||
|
|
60e0bf1d86 | ||
|
|
6cf7e57309 | ||
|
|
73b1f97e02 | ||
|
|
56822570f6 | ||
|
|
35691071d0 | ||
|
|
e96715dd56 | ||
|
|
544204b24e | ||
|
|
00e0472807 | ||
|
|
59f391e0cd | ||
|
|
24be69918d | ||
|
|
59388f9c35 | ||
|
|
26f1de976a | ||
|
|
ebc98e3de5 | ||
|
|
f33e737d5c | ||
|
|
df3251b2a0 | ||
|
|
54c1174698 | ||
|
|
ad08110c96 | ||
|
|
590abf3142 | ||
|
|
ac7a6027bd | ||
|
|
e1234e5d1c | ||
|
|
ab63a06677 | ||
|
|
2e5a95b842 | ||
|
|
343af9ea3c | ||
|
|
a9c7f79012 | ||
|
|
c8cf91655e | ||
|
|
7df2e20cf3 | ||
|
|
91d1b1fedd | ||
|
|
4d978b8cd8 | ||
|
|
e3587be3bd | ||
|
|
0aa3752bf8 | ||
|
|
1e4fcd170c | ||
|
|
4c653a939f | ||
|
|
0b0669b77b | ||
|
|
1649e1ad89 | ||
|
|
a948c2708d | ||
|
|
54264e6e3b | ||
|
|
92fe0ab9b6 | ||
|
|
0537894768 | ||
|
|
bec3ed9515 | ||
|
|
ecb0989e14 | ||
|
|
d5508a7e35 | ||
|
|
3cd535cc8a | ||
|
|
58d43af3e6 | ||
|
|
e69aa03f5d | ||
|
|
e6608dd42f | ||
|
|
70eb1120a2 | ||
|
|
75bbdfc56e | ||
|
|
3802549e48 | ||
|
|
9b22dcc190 | ||
|
|
593e521e12 | ||
|
|
0baec54ae9 | ||
|
|
4c458cbf74 | ||
|
|
9ccf2fbc3b | ||
|
|
9c107eaf74 | ||
|
|
ed99be3e24 | ||
|
|
237ab0e592 | ||
|
|
97822cf691 | ||
|
|
2766e5b41b | ||
|
|
f25c8d8b6d | ||
|
|
515a19bbc3 | ||
|
|
6f9dc7ab41 | ||
|
|
89024d3b01 | ||
|
|
ad12d77ad4 | ||
|
|
7a975765ac | ||
|
|
6d65d50ec1 | ||
|
|
6c53854f9b | ||
|
|
6cbc3a6988 | ||
|
|
7905ea4054 | ||
|
|
c8766e9fb9 | ||
|
|
91f356698b | ||
|
|
327d2189c9 | ||
|
|
bd3cc65e19 | ||
|
|
9857bf04f7 | ||
|
|
10b3740696 | ||
|
|
f18eef4766 | ||
|
|
617c56bd85 | ||
|
|
a689072756 | ||
|
|
d8444e49bd | ||
|
|
0219cb1d32 | ||
|
|
5fb3cb20cd | ||
|
|
0869ef0cc6 | ||
|
|
043cb7bf22 | ||
|
|
757432dac4 | ||
|
|
b5ee6b5804 | ||
|
|
ce9534f3a2 | ||
|
|
dc135ee1de | ||
|
|
e7ebfdb964 | ||
|
|
aa41250beb | ||
|
|
2cdc35f3e2 | ||
|
|
67b7b2cb80 | ||
|
|
e085814502 | ||
|
|
d7240470d1 | ||
|
|
8e6bd1be5c | ||
|
|
769a3398fa | ||
|
|
36e9ee5060 | ||
|
|
3194729038 | ||
|
|
5525640ef6 | ||
|
|
14d21c4642 | ||
|
|
0df9913286 | ||
|
|
74fbadb1cd | ||
|
|
c102119412 | ||
|
|
4e7f48a389 | ||
|
|
b2ddab6d7e | ||
|
|
2dda4b90b4 | ||
|
|
cd648a9abd | ||
|
|
c492cd9a94 | ||
|
|
904304f1fb | ||
|
|
37df3ec545 | ||
|
|
0c80f84e9c | ||
|
|
b272bc37e8 | ||
|
|
5503b9b977 | ||
|
|
308ae19f5e | ||
|
|
e3034e4465 | ||
|
|
b06e2860ca | ||
|
|
fba12b89bc | ||
|
|
b0ad3689d0 | ||
|
|
e6d3905dc6 | ||
|
|
8ba89e7cee | ||
|
|
f558ca9b1f | ||
|
|
ca305f0ff1 | ||
|
|
9588b1e2c8 | ||
|
|
50ba3ab2d5 | ||
|
|
f0114a0390 | ||
|
|
d7917512ac | ||
|
|
57fa8f6c76 | ||
|
|
8435236862 | ||
|
|
79bafa2a64 | ||
|
|
19ee757880 | ||
|
|
72f0b82ae5 | ||
|
|
852a405182 | ||
|
|
cc06b41322 | ||
|
|
19b04a8d8f | ||
|
|
7a4a988208 | ||
|
|
ceecbbfe6f | ||
|
|
72a61e4952 | ||
|
|
d1161bd1b5 | ||
|
|
bbd949e57f | ||
|
|
092aea62d2 | ||
|
|
d59879cc4b | ||
|
|
103fee0ba4 | ||
|
|
d1d391e511 | ||
|
|
6214e88fac | ||
|
|
986540411e | ||
|
|
82c191d637 | ||
|
|
ff87ccf855 | ||
|
|
91f6589e1f | ||
|
|
8ef7c8e854 | ||
|
|
8dbc1629ff | ||
|
|
e12b555f43 | ||
|
|
1b153ba144 | ||
|
|
b62b7c8f2d | ||
|
|
103ef40f57 | ||
|
|
7551576b08 | ||
|
|
e7c50dc519 | ||
|
|
179bf60e9f | ||
|
|
bd07f3a66f | ||
|
|
fc24cb672e | ||
|
|
c6cb038683 | ||
|
|
95636b3d53 | ||
|
|
34085b121d | ||
|
|
991adc2fa1 | ||
|
|
648421e454 | ||
|
|
9566ad3482 | ||
|
|
f32b354f9b | ||
|
|
9d8f294d75 | ||
|
|
f513cfca10 | ||
|
|
195aeb69be | ||
|
|
0d2b3152b1 | ||
|
|
96e04bbc32 | ||
|
|
9cde75b70f | ||
|
|
74981f8217 | ||
|
|
7a61a26670 | ||
|
|
520c2f70c8 | ||
|
|
ae7f0f0446 | ||
|
|
7aef6f094e | ||
|
|
6baa313684 | ||
|
|
f967c1b392 | ||
|
|
fbdb6ccc22 | ||
|
|
d837684a33 | ||
|
|
5038033c3a | ||
|
|
7a2834c95c | ||
|
|
bd47d064b5 | ||
|
|
20dfc168d5 | ||
|
|
0d86ae66ad | ||
|
|
fd5199e8bd | ||
|
|
ecc0602f53 | ||
|
|
68c59afbe7 | ||
|
|
6d9c9cdb48 | ||
|
|
8ac8b2d50c | ||
|
|
1d7cd9339c | ||
|
|
01f5c560c7 | ||
|
|
01bddf2177 | ||
|
|
019ab57e8f | ||
|
|
633f43530c | ||
|
|
9590cf332d | ||
|
|
5ae1bab4ee | ||
|
|
063748c7ff | ||
|
|
86b2a5fd81 | ||
|
|
e722d21d98 | ||
|
|
f4b34a289e | ||
|
|
a4aeb6e7d3 | ||
|
|
96592b1a24 | ||
|
|
d70dee77a9 | ||
|
|
272cd1bb03 | ||
|
|
1402240ef0 | ||
|
|
decd1311cb | ||
|
|
c4622e9e3a | ||
|
|
360387c986 | ||
|
|
bc0342b03e | ||
|
|
10ebac142e | ||
|
|
d124b03fc8 | ||
|
|
ab6450b9fe | ||
|
|
d6bef5d1c7 | ||
|
|
9d8e0114f5 | ||
|
|
93070a3a93 | ||
|
|
d1b94625b1 | ||
|
|
fbbea1bf91 | ||
|
|
a0f8ee51a4 | ||
|
|
ffc1933f06 | ||
|
|
b6960878a6 | ||
|
|
fef3b6f4a0 | ||
|
|
c0a73c12ec | ||
|
|
f40714efc2 | ||
|
|
07ed908ae6 | ||
|
|
cfe772ea7d | ||
|
|
c0fe20e8a2 | ||
|
|
df22526a0d | ||
|
|
1d06d4b08a | ||
|
|
4e7a314a00 | ||
|
|
c8eb767d63 | ||
|
|
016dbd7cb1 | ||
|
|
7210d3c202 | ||
|
|
572204b49a | ||
|
|
86dd67a24c | ||
|
|
beeba1fdfc | ||
|
|
41b7b289d0 | ||
|
|
24237249d1 | ||
|
|
e448073b98 | ||
|
|
c08152d069 | ||
|
|
3995561220 | ||
|
|
aea51caeb4 | ||
|
|
3eca380bd8 | ||
|
|
8a3e06bc81 | ||
|
|
c4274c42cb | ||
|
|
a61ad15767 | ||
|
|
7f1e374fd7 | ||
|
|
e3f5445a02 | ||
|
|
d2cb50d081 | ||
|
|
6c609808c9 | ||
|
|
2d4c7b383a | ||
|
|
c21ed7dff2 | ||
|
|
448e392a0e | ||
|
|
d97c40d6af | ||
|
|
2d512b6be6 | ||
|
|
f061a95b48 | ||
|
|
eefdef1bef | ||
|
|
7675f81999 | ||
|
|
8fb592071f | ||
|
|
2528312142 | ||
|
|
08d7965ea8 | ||
|
|
e0badad91f | ||
|
|
f0a91ce2d8 | ||
|
|
24cab83f48 | ||
|
|
33a2b5d702 | ||
|
|
7f807004ad | ||
|
|
5564425488 | ||
|
|
4a89c04732 | ||
|
|
ec5a403ec6 | ||
|
|
f6ce1df766 | ||
|
|
9d7ddcc04a | ||
|
|
3df611636b | ||
|
|
d0999326bd | ||
|
|
6fbc65476c | ||
|
|
fcc7d79d80 | ||
|
|
18fd6a1f5e | ||
|
|
a072562034 | ||
|
|
2b4a2bc688 | ||
|
|
3344d9f8b2 | ||
|
|
fae36d220d | ||
|
|
7b6a84a550 | ||
|
|
f29c343879 | ||
|
|
f5e9f49a13 | ||
|
|
73a35599d2 | ||
|
|
5080d7860e | ||
|
|
7fb1d4056c | ||
|
|
243a081874 | ||
|
|
04924cf796 | ||
|
|
8f0917b8bc | ||
|
|
d4ad46127a | ||
|
|
fba5ecf54c | ||
|
|
e035df9e7b | ||
|
|
3baf0b6358 | ||
|
|
4de04e42b5 | ||
|
|
ebec98a14c | ||
|
|
f966470d33 | ||
|
|
c76ede2ab4 | ||
|
|
2fd043b634 | ||
|
|
fb6bbe9145 | ||
|
|
d1585d7483 | ||
|
|
13d853217d | ||
|
|
cc27d57e4a | ||
|
|
f5c5ea5a0b | ||
|
|
f1baea1705 | ||
|
|
31f7d39647 | ||
|
|
c5a834bfd2 | ||
|
|
1eef9b75ee | ||
|
|
ba9c885f52 | ||
|
|
88683a8d8f | ||
|
|
0c48c5dee3 | ||
|
|
101a0acb6b | ||
|
|
2d1956b653 | ||
|
|
b7bba8b53a | ||
|
|
7b25b767f8 | ||
|
|
4631b95144 | ||
|
|
38627c498b | ||
|
|
282a8d31b5 | ||
|
|
ab4fb4c1ee | ||
|
|
e3e9e83bc1 | ||
|
|
4269ebec04 | ||
|
|
e8b9adcde4 | ||
|
|
668a9062b3 | ||
|
|
5fd2ccf084 | ||
|
|
5686bbc9fd | ||
|
|
2cd57a32b2 | ||
|
|
89f4f99622 | ||
|
|
78d571ed14 | ||
|
|
e5332f4a7f | ||
|
|
3ed1545c3c | ||
|
|
9da20d15da | ||
|
|
052d60bd7d | ||
|
|
2456d6fda5 | ||
|
|
e7803d1aba | ||
|
|
ca5fddf89b | ||
|
|
88fad3893e | ||
|
|
82f9b0e48c | ||
|
|
ddb4c1454d | ||
|
|
7b1cf854f2 | ||
|
|
51c6d7f835 | ||
|
|
9d55ce0a87 | ||
|
|
0f8d61188a | ||
|
|
7363dffb9d | ||
|
|
8e4b7deaa4 | ||
|
|
ec845cbc25 | ||
|
|
e7079e223f | ||
|
|
dea5bd581d | ||
|
|
75b85b46d1 | ||
|
|
68f00bc948 | ||
|
|
5e9f7a7dc3 | ||
|
|
b038f022d3 | ||
|
|
cf7515fb35 | ||
|
|
bb4c1dfe4f | ||
|
|
72b56964c3 | ||
|
|
5b0a6691f8 | ||
|
|
1239c638a5 | ||
|
|
d2fa0787d8 | ||
|
|
a7056702e8 | ||
|
|
bb56283063 | ||
|
|
130ef1f0fe | ||
|
|
724205b1df | ||
|
|
69824ea45d | ||
|
|
15bc299987 | ||
|
|
87bdb0c6ed | ||
|
|
aa96bbedbc | ||
|
|
4df277c81f | ||
|
|
12646b6ca0 | ||
|
|
1e674d70c2 | ||
|
|
5ee77698db | ||
|
|
d1f5661881 | ||
|
|
532d078720 | ||
|
|
27e5df106f | ||
|
|
33d98868c3 | ||
|
|
fb13e53cb5 | ||
|
|
60a5495ccd | ||
|
|
f1dc90621c | ||
|
|
eecb116709 | ||
|
|
783d25b1c4 | ||
|
|
af06eecfd0 | ||
|
|
9bd08a3a5b | ||
|
|
3d66247e11 | ||
|
|
4390554477 | ||
|
|
74457db4eb | ||
|
|
5d61da481b | ||
|
|
89856eb7c3 | ||
|
|
64c268582d | ||
|
|
bb6558e7bf | ||
|
|
42d109cae3 | ||
|
|
9b48b1ff5d | ||
|
|
4ca8ea1694 | ||
|
|
c10d6c6914 | ||
|
|
3a94c845cf | ||
|
|
bc810beb3b | ||
|
|
eac4a0c071 | ||
|
|
c1dfb1add5 | ||
|
|
e67f9bee08 | ||
|
|
7c1d81cee9 | ||
|
|
5cec2eef60 | ||
|
|
e62c7cf4f5 | ||
|
|
0245980ea5 | ||
|
|
7abc6aca45 | ||
|
|
f1bcd41ad9 | ||
|
|
8022128993 | ||
|
|
68b1aa64e9 | ||
|
|
f3af8f21dc | ||
|
|
7c056b1db8 | ||
|
|
da14c82f71 | ||
|
|
1f32c693df | ||
|
|
74bfbe941e | ||
|
|
2884c08466 | ||
|
|
85e1ea320a | ||
|
|
7be428d8bf | ||
|
|
56798f9fb4 | ||
|
|
6b1b8a8846 | ||
|
|
9fb1b90856 | ||
|
|
30d4023475 | ||
|
|
70432f3d12 | ||
|
|
7ee6c307fc | ||
|
|
36cfbbe6d2 | ||
|
|
a2e1a3d939 | ||
|
|
9dc8d9a77a | ||
|
|
3f6bb65eb1 | ||
|
|
8a70f3cb26 | ||
|
|
6c5b8315a3 | ||
|
|
690ed0f121 | ||
|
|
fd60c2b8be | ||
|
|
e27b4feb25 | ||
|
|
09a33e7283 | ||
|
|
d87441448e | ||
|
|
f783f10f6e | ||
|
|
70bdb2d723 | ||
|
|
4c1ef04378 | ||
|
|
a595af133c | ||
|
|
f74f3cd133 | ||
|
|
bcd4848458 | ||
|
|
fc05bf9711 | ||
|
|
63ad590262 | ||
|
|
0fd1193b6b | ||
|
|
d996822957 | ||
|
|
96349721cb | ||
|
|
8344921b65 | ||
|
|
c97b3b7b77 | ||
|
|
e00e675ed1 | ||
|
|
538da1f2b2 | ||
|
|
bad37ddc7d | ||
|
|
175eda9f29 | ||
|
|
14c806d38f | ||
|
|
925100fb82 | ||
|
|
b29b003ea3 | ||
|
|
faa727965b | ||
|
|
d2ffbf2c40 | ||
|
|
5418fad7d7 | ||
|
|
510795f1c5 | ||
|
|
9e0d499987 | ||
|
|
e96fde0632 | ||
|
|
eea79dead4 | ||
|
|
c4a3f4ba1e | ||
|
|
4a4a01b730 | ||
|
|
a664e72488 | ||
|
|
21317123a4 | ||
|
|
409245cb52 | ||
|
|
1aeb6fdb35 | ||
|
|
816fa96e07 | ||
|
|
8fbe4218c6 | ||
|
|
00c8df0fc9 | ||
|
|
c52c7ead19 | ||
|
|
d1e8a827c2 | ||
|
|
12d96fb03d | ||
|
|
171a191873 | ||
|
|
47dabd8793 | ||
|
|
2bb661f1cf | ||
|
|
04e296a4ef | ||
|
|
4bba227af5 | ||
|
|
5cc78d63c6 | ||
|
|
323f5dc09c | ||
|
|
fb97edfe95 | ||
|
|
070f62ef12 | ||
|
|
981749aa3d | ||
|
|
e01b46b30a | ||
|
|
a1b659c45d | ||
|
|
37a012ef92 | ||
|
|
4927e8a843 | ||
|
|
025d6ddb47 | ||
|
|
01a4dfd5d3 | ||
|
|
1d7158a0bf | ||
|
|
9de2afbfc6 | ||
|
|
9013754a3a | ||
|
|
21eea0b104 | ||
|
|
15e8d7365c | ||
|
|
88e3bf7065 | ||
|
|
2765138bf3 | ||
|
|
6b20ceac46 | ||
|
|
d748274905 | ||
|
|
e1578217d5 | ||
|
|
3481682cb4 | ||
|
|
996b7f2468 | ||
|
|
879993b9b1 | ||
|
|
6604154e3f | ||
|
|
a9ad42a970 | ||
|
|
8b3d1302c6 | ||
|
|
ac9269d6ce | ||
|
|
8ffb716817 | ||
|
|
23f671fa48 | ||
|
|
d7cdcde443 | ||
|
|
95a81faf63 | ||
|
|
bb8bb6d099 | ||
|
|
310801a29b | ||
|
|
7d09b20445 | ||
|
|
456a06f194 | ||
|
|
62eaefb1fa | ||
|
|
10264b4019 | ||
|
|
ab9765d91d | ||
|
|
e7f4c6864f | ||
|
|
bde1dea2a5 | ||
|
|
13a021c480 | ||
|
|
a7f09fad98 | ||
|
|
10eaf4a5fb | ||
|
|
bcada300e3 | ||
|
|
9756174676 | ||
|
|
6dd8461a46 | ||
|
|
12cc2eb0e9 | ||
|
|
38dd717aa6 | ||
|
|
6640c902de | ||
|
|
583c343d08 | ||
|
|
dfc48f7a05 | ||
|
|
cd9d854595 | ||
|
|
766cbd17e5 | ||
|
|
6df95bf981 | ||
|
|
6398ca0893 | ||
|
|
62372a48cc | ||
|
|
733632509a | ||
|
|
e1c13ec314 | ||
|
|
928ff0eabe | ||
|
|
406559b13d | ||
|
|
20c16aa6fd | ||
|
|
44ba5fd6d4 | ||
|
|
745c6adbf2 | ||
|
|
a9aa09636f | ||
|
|
6945986dd1 | ||
|
|
80c1f48f0e | ||
|
|
31e18205f0 | ||
|
|
e84a9d3f9b | ||
|
|
133feb8c76 | ||
|
|
c5fd9c01e5 | ||
|
|
a7bf5ceac3 | ||
|
|
74139aeb7e | ||
|
|
0cae4fd05c | ||
|
|
4b3b4fda61 | ||
|
|
adb684a6bf | ||
|
|
8493472983 | ||
|
|
862eeb7add | ||
|
|
4d9b211d69 | ||
|
|
ebb272324c | ||
|
|
c0290512b3 | ||
|
|
4ec56fe41e | ||
|
|
f8a7c46cf9 | ||
|
|
2bab8a9bb6 | ||
|
|
89e6a6215a | ||
|
|
88be87e03e | ||
|
|
22867faa9c | ||
|
|
16c0fc704d | ||
|
|
32fdd076bf |
@@ -1,4 +0,0 @@
|
||||
/memory/
|
||||
tickets/.ticket-backend.lock
|
||||
/workspace.db*
|
||||
tickets
|
||||
@@ -1,60 +0,0 @@
|
||||
---
|
||||
title: "E2E テスト戦略"
|
||||
state: "active"
|
||||
created_at: "2026-06-09T07:09:26Z"
|
||||
updated_at: "2026-06-09T07:09:26Z"
|
||||
linked_tickets: ["00001KSKBP9YG"]
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Yoi の実プロセス・実 socket・実 provider 境界をまたぐ振る舞いを、通常の crate 内 unit / integration test だけに頼らず検証できる E2E テスト戦略を確立する。
|
||||
|
||||
最初の到達点は、実 `pod` / product binary を spawn し、protocol 経由で最小シナリオを実行し、graceful shutdown まで確認できる opt-in E2E harness を持つこと。その上で、permission、resume/fork、spawned Pod、provider stream、TUI/Panel などの重要境界を段階的に増やせる状態にする。
|
||||
|
||||
## Motivation / background
|
||||
|
||||
現状のテストは crate 内の in-process coverage が厚い一方で、以下の性質は単体テストだけでは十分に確認しづらい。
|
||||
|
||||
- 実プロセス spawn と runtime dir / socket / env の相互作用。
|
||||
- Pod controller / protocol client / session store / metadata / restore の統合挙動。
|
||||
- provider endpoint、streaming、auth/token、tool call、continuation、retry の実接続に近い振る舞い。
|
||||
- permission deny、scope、manifest/profile 解決、child Pod delegation など、複数 crate と実 runtime state をまたぐ policy。
|
||||
- TUI / Panel が前提にする Pod lifecycle や Ticket orchestration の外形。
|
||||
|
||||
E2E は常時実行の軽いテストではなく、dogfooding 中に「この機能は実 runtime でも壊れていない」と確認するための opt-in 検証基盤として必要。
|
||||
|
||||
## Strategy / design direction
|
||||
|
||||
- E2E は通常の `cargo test --workspace` からは外し、明示 feature / 専用 package / 独立 job で opt-in 実行する。
|
||||
- まずはワークスペース直下の専用 E2E package / harness として設計し、個別 crate の unit test に押し込めない。
|
||||
- protocol を喋る側は TUI の PTY 操作ではなく、typed client / protocol client を使う方向を優先する。
|
||||
- provider 依存は最初から全部を対象にしない。
|
||||
- 最小 harness では canned / fixture / mock HTTP server を使う。
|
||||
- provider 差分は代表 provider から段階的に増やす。
|
||||
- env / runtime dir / socket path は test ごとに隔離し、並列実行方針を明示する。
|
||||
- 必要なら最初は `--test-threads=1` 相当で安全側に倒す。
|
||||
- 将来的には per-test runtime dir と typed launch config で並列性を上げる。
|
||||
- E2E は「全シナリオを大量に持つ」より、重要な runtime seam ごとに少数の高価値 scenario を置く。
|
||||
- 失敗時 diagnostics は、secret を出さずに process phase、socket path、session id、log path、provider fixture id を辿れる形にする。
|
||||
- E2E harness 自体が flaky にならないよう、network / time / external auth への依存は明示 opt-in に分ける。
|
||||
|
||||
## Success criteria / exit conditions
|
||||
|
||||
- `cargo test --workspace` では E2E が走らず、通常開発の feedback loop を重くしない。
|
||||
- 明示コマンドで E2E harness を実行できる。
|
||||
- 例: `cargo test -p e2e --features e2e` または後続で決める同等コマンド。
|
||||
- 最小 scenario が実 `pod` / product binary を spawn し、protocol 経由で 1 turn 実行し、graceful shutdown まで通る。
|
||||
- E2E 実行は専用 runtime/data dir を使い、通常の user/workspace state を汚さない。
|
||||
- fixture / mock provider の設計があり、少なくとも 1 provider 相当の canned response を実 HTTP 経由で返せる。
|
||||
- failure diagnostics から、spawn 失敗・socket 接続失敗・provider fixture 失敗・protocol 失敗・shutdown 失敗を区別できる。
|
||||
- 後続 Ticket が permission / resume / fork / spawned Pod / provider streaming / Panel などを追加できる harness boundary がある。
|
||||
|
||||
## Decision context
|
||||
|
||||
- linked Ticket `00001KSKBP9YG` は、E2E harness の最初の concrete implementation Ticket として扱う。
|
||||
- この Objective は E2E 全体の中長期方針・判断軸を保持する。個別 scenario の実装や harness の細部は concrete Ticket に分ける。
|
||||
- TUI を直接 PTY で叩く方針は初期 harness では避け、protocol/client 経由を優先する。
|
||||
- provider 全対応は初期 scope にしない。fixture / mock HTTP server を基礎にし、代表 provider から段階的に広げる。
|
||||
- E2E は flakiness と実行コストが高いため、既定 CI / 既定 workspace test には入れず、opt-in 検証として始める。
|
||||
- Objective context は判断材料であり、実装 authority は各 Ticket の body/thread/artifacts と明示的な Ticket relation / OrchestrationPlan に置く。
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
title: "ネイティブGUIアプリケーション"
|
||||
state: "active"
|
||||
created_at: "2026-06-10T07:41:18Z"
|
||||
updated_at: "2026-07-15T21:18:00Z"
|
||||
linked_tickets: []
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Yoi の Pod / Ticket / Orchestrator / Skill・prompt resource 操作を、TUI だけでなくネイティブ GUI から扱えるようにする。
|
||||
|
||||
最初の到達点は、既存の runtime / Ticket backend / Pod protocol / Profile / Skill/prompt resource authority を再実装せずに、workspace の状態を視覚的に把握し、選択した Pod・Ticket・role action に対して安全に操作できる desktop GUI client を持つこと。GUI は core authority ではなく client surface とし、既存 CLI/TUI と同じ durable state・同じ protocol・同じ permission/prompt/resource 境界を使う。
|
||||
|
||||
## Motivation / background
|
||||
|
||||
現在の TUI / Panel は dogfooding 可能な状態まで進んでいるが、複数 Pod・複数 Ticket・Orchestrator 状態・session output・role launch・review/merge dossier を同時に扱うには、terminal UI の表示密度・視覚的状態表現・スクロール/選択/比較操作に限界が出ている。
|
||||
|
||||
ネイティブ GUI があると、以下をより自然に扱える。
|
||||
|
||||
- live / stored Pod、Ticket lane、Orchestrator progress、Companion/Intake 状態の同時表示。
|
||||
- Ticket body/thread/artifacts、Pod output、validation evidence、diff/report の並列閲覧。
|
||||
- composer target、role action、queue/routing/attach/restore の明確な affordance。
|
||||
- long-running orchestration の通知、状態変化、失敗診断の視覚化。
|
||||
- 将来的な review / merge-ready dossier / plan board / settings editor の専用 UI。
|
||||
|
||||
一方で、GUI を理由に runtime authority を分散させたり、Ticket/Pod state を独自 DB として二重管理したり、prompt/resource 文字列を GUI code に直書きしたりしてはいけない。
|
||||
|
||||
## Strategy / design direction
|
||||
|
||||
- GUI は Yoi core の上に乗る client として作る。
|
||||
- Pod lifecycle、session/history、Ticket storage、Profile resolution、resource/prompt authority は既存 core を正とする。
|
||||
- GUI 固有 state は selection、layout、local UI preference などに限定する。
|
||||
- 最初に toolkit / architecture の小さな spike を置く。
|
||||
- 評価軸は Rust code reuse、async/runtime 統合、native packaging、Linux dogfooding しやすさ、testability、accessibility、long-running log/output 表示、将来の cross-platform 余地。
|
||||
- toolkit 選定は Objective では固定しない。候補比較と採用理由を Ticket に残す。
|
||||
- 実装は段階的に進める。
|
||||
1. read-only workspace dashboard: Pod / Ticket / Orchestrator 状態を既存 backend から表示する。
|
||||
2. attach / restore / open など、既存 protocol に乗る低リスク操作を追加する。
|
||||
3. composer と role action: Companion / Intake / Orchestrator / coder / reviewer launch を既存 launcher 経由で扱う。
|
||||
4. Ticket body/thread/artifacts、Pod output、validation evidence、review report を閲覧しやすくする。
|
||||
5. 必要に応じて settings/profile/config editor や merge-ready dossier UI を追加する。
|
||||
- TUI は廃止前提にしない。
|
||||
- GUI 導入後も CLI/TUI は fallback / automation / terminal-first operation flow として維持する。
|
||||
- GUI で見つかった state model の改善は、TUI と共有できる pure data model / client API に寄せる。
|
||||
- Prompt / resource / role guidance は GUI code に直書きしない。
|
||||
- LLM-facing prompt は `resources/prompts` または `.yoi/skills` / configured resources を正とする。
|
||||
- GUI は prompt 文言を所有せず、選択・起動・runtime context の入力面を担当する。
|
||||
- Security / privacy / authority boundary を保つ。
|
||||
- secret-like data は UI diagnostics / logs / model context に漏らさない。
|
||||
- permission / scope / profile の authority は既存 resolver/policy に従う。
|
||||
- GUI convenience action は durable Ticket/Pod state transition と対応付け、暗黙の side effect を避ける。
|
||||
|
||||
## Success criteria / exit conditions
|
||||
|
||||
- 明示コマンドまたは binary で native GUI を起動できる。
|
||||
- GUI は既存 workspace config、Profile、Ticket backend、Pod registry/protocol を使い、独自の authority store を持たない。
|
||||
- 最小 dashboard で live/stored Pod、Ticket lane/state、Orchestrator/role session の概況を確認できる。
|
||||
- GUI から少なくとも attach/restore/open 相当の安全な Pod 操作ができる。
|
||||
- GUI から Ticket Intake または既存 role launcher を使った role action を実行でき、既存の prompt/resource 境界を壊さない。
|
||||
- Pod output / Ticket body/thread/artifacts を、TUI より見通しよく閲覧できる最小 UI がある。
|
||||
- GUI 固有 state と core durable state の境界が文書化されている。
|
||||
- toolkit / architecture 選定理由、採用しなかった選択肢、packaging 方針が Ticket artifact または design note として残っている。
|
||||
- GUI で使う state transformation / action eligibility は pure model として test 可能で、主要な selection/action state の unit test がある。
|
||||
- GUI 実装は CLI/TUI の既存 operation flow を破壊せず、必要な targeted validation が定義されている。
|
||||
|
||||
## Decision context
|
||||
|
||||
- この Objective は中長期の方向性・判断軸を保持する。具体的な toolkit 選定、crate 構成、初期 dashboard 実装、role action 実装、packaging は個別 Ticket に分ける。
|
||||
- GUI は TUI の単純な置換ではなく、複数 Pod / Ticket / Orchestrator を扱う workspace cockpit として設計する。
|
||||
- authority は既存 core に残す。GUI は client/view/controller surface であり、Pod/Ticket/resource/prompt の正本を所有しない。
|
||||
- Prompt 直書き禁止方針を守る。GUI 実装中に LLM-facing 文言が必要になった場合は、`resources/prompts` または Skill/resource 側に置く。
|
||||
- 初期 target は dogfooding しやすい desktop GUI とし、public release / cross-platform polish / installer は後続段階で扱う。
|
||||
@@ -1,111 +0,0 @@
|
||||
---
|
||||
title: 'MCP local stdio integration roadmap'
|
||||
state: 'active'
|
||||
created_at: '2026-06-10T07:48:45Z'
|
||||
updated_at: '2026-06-20T05:34:00Z'
|
||||
linked_tickets: ['00001KTR81P9X', '00001KV0SP0TY', '00001KVHR3WRF', '00001KVHR3WRY', '00001KVHR3WS6', '00001KVHR3WSD', '00001KVHR3WSN', '00001KVHR3WSW']
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add MCP local stdio integration to Yoi without weakening Worker history, prompt-context, scoped tool permission, or Plugin/Feature layering invariants.
|
||||
|
||||
MCP is a protocol-backed integration layer on top of `pod::feature`. `pod::feature` supplies contribution/lifecycle/runtime-discovered registration substrate; MCP owns its own enablement, local server trust model, command/env/secret policy, and MCP-specific permission decisions. MCP is not the Plugin model, and Plugin permission policy is not implemented by feature-layer authority grants.
|
||||
|
||||
## Motivation / background
|
||||
|
||||
Yoi needs to integrate with external capability providers without turning them into hidden context sources or bypassing ordinary Tool/Worker safety rules. MCP is useful because it can expose tools, resources, and prompts from local protocol servers, but those server-provided declarations and results are untrusted and must be normalized through Yoi's existing authority boundaries.
|
||||
|
||||
The first MCP slice should focus on local stdio servers because they are concrete enough to implement and debug while keeping remote auth, OAuth, Streamable HTTP, registry distribution, sampling, and elicitation out of the initial trust boundary.
|
||||
|
||||
A configured local MCP server runs as a local executable. Yoi feature authority does not sandbox that executable's OS-level side effects, so command/env/secret handling and explicit local trust policy are MCP-layer responsibilities rather than generic `pod::feature` grants.
|
||||
|
||||
## Strategy / design direction
|
||||
|
||||
- Baseline the initial implementation on MCP specification `2025-11-25`.
|
||||
- Start with local stdio MCP servers only.
|
||||
- Treat MCP server metadata, tools, resources, prompts, and results as untrusted content.
|
||||
- Do not allow MCP resources/prompts to become hidden context injection.
|
||||
- They must be explicit tool operations with history records.
|
||||
- Use the normal Yoi ToolRegistry, PreToolCall permission, history, and bounded result paths.
|
||||
- Do not add private MCP-only bypasses around Worker/tool invariants.
|
||||
- Keep sampling and elicitation fail-closed initially.
|
||||
- Keep Streamable HTTP, remote auth, OAuth, and MCP Registry/distribution out of the first slice.
|
||||
- Treat local stdio server execution as an explicit MCP config/trust decision, not as a `pod::feature` authority grant.
|
||||
- Document clearly that a configured local MCP server runs as a local executable; Yoi feature authority does not sandbox its OS-level side effects.
|
||||
|
||||
### Layering decisions
|
||||
|
||||
- `pod::feature` is an API/contribution substrate.
|
||||
- It owns contribution declarations, provider/service lifecycle hooks, diagnostics, runtime-discovered registration plumbing, and integration with normal Worker/ToolRegistry paths.
|
||||
- It does not own Plugin permission policy or MCP server trust policy.
|
||||
- Plugin is a user-facing package/config/runtime layer over `pod::feature`.
|
||||
- Plugin permissions are Plugin-layer policy.
|
||||
- Plugin package discovery/enablement must not be conflated with MCP local server execution.
|
||||
- MCP is a separate feature-backed integration layer.
|
||||
- MCP enablement, command/env/secret handling, server trust, and MCP-specific permission decisions live in MCP config/implementation.
|
||||
- MCP provider-discovered tools/resources/prompts are exposed through the feature API and ordinary Yoi tool paths.
|
||||
|
||||
### Concrete implementation tickets
|
||||
|
||||
Completed prerequisites:
|
||||
|
||||
- `00001KTR81P9X` — Extend `pod::feature` API for external protocol-backed capability providers.
|
||||
- `00001KV0SP0TY` — Remove feature-layer HostAuthority model.
|
||||
|
||||
Concrete MCP implementation sequence:
|
||||
|
||||
1. `00001KVHR3WRF` — MCP local stdio server config and trust policy.
|
||||
- explicit config, command/env/secret redaction, local executable trust boundary, no auto-start.
|
||||
2. `00001KVHR3WRY` — MCP stdio JSON-RPC lifecycle client.
|
||||
- subprocess lifecycle, initialize/capability negotiation, diagnostics, shutdown.
|
||||
3. `00001KVHR3WS6` — MCP tools/list registration into ToolRegistry.
|
||||
- provider-discovered tools, stable namespacing, schema validation, untrusted metadata normalization, no tools/call yet.
|
||||
4. `00001KVHR3WSD` — MCP tools/call execution through ordinary Tool path.
|
||||
- PreToolCall gate before server call, bounded result serialization, history path.
|
||||
5. `00001KVHR3WSN` — MCP resources/prompts as explicit tool operations.
|
||||
- resources/list/read and prompts/list/get without hidden context injection.
|
||||
6. `00001KVHR3WSW` — MCP list_changed notification handling.
|
||||
- deterministic safe refresh/diagnostic behavior without breaking tool schema or prompt-cache invariants.
|
||||
|
||||
The old broad implementation Ticket `00001KTR82RB7` is superseded by this sequence and should not be used as an implementation work item.
|
||||
|
||||
### Terminology
|
||||
|
||||
Use `runtime-discovered` or `provider-discovered` for MCP tools/resources/prompts discovered from `tools/list`, `resources/list`, or `prompts/list`. Avoid `dynamic tools` / `dynamic registry` in new MCP design prose because those phrases imply that model-visible tool schemas may change during an active LLM run.
|
||||
|
||||
The intended invariant is:
|
||||
|
||||
```text
|
||||
provider-discovered at startup / provider initialization;
|
||||
registered into the ordinary ToolRegistry before model exposure;
|
||||
run-stable for the duration of a model request/run;
|
||||
refreshed only at a safe boundary or reported as a diagnostic.
|
||||
```
|
||||
|
||||
### Later follow-ups
|
||||
|
||||
- Richer MCP task/task-support integration if ordinary tool-call fallback is insufficient.
|
||||
- Streamable HTTP transport.
|
||||
- OAuth / remote auth.
|
||||
- Registry/package distribution.
|
||||
- Explicit MCP/Plugin bridge only if separately approved; do not conflate Plugin packages with MCP local server execution.
|
||||
|
||||
## Success criteria / exit conditions
|
||||
|
||||
- A local mock MCP server can be configured explicitly and initialized.
|
||||
- Discovered MCP tools appear as ordinary Yoi tools with stable namespacing.
|
||||
- Tool calls go through ordinary permission and history paths.
|
||||
- MCP resources/prompts are explicit operations, not hidden context injections.
|
||||
- MCP result forms are bounded and safely serialized.
|
||||
- Secret values, command/env details, and server diagnostics are redacted where required.
|
||||
- Local server trust boundary is documented: Yoi does not sandbox the configured executable through feature authority.
|
||||
- Feature, Plugin, and MCP permission/trust responsibilities are documented as separate layers.
|
||||
|
||||
## Decision context
|
||||
|
||||
- MCP is not the Plugin model; it is a protocol-backed integration layer using `pod::feature` substrate.
|
||||
- `pod::feature` should provide contribution/lifecycle/runtime-discovered registration plumbing, not MCP server trust policy or Plugin package permission policy.
|
||||
- MCP resources and prompts must never be hidden context injection. They are explicit operations recorded through ordinary history/tool paths.
|
||||
- Provider-discovered tools are discovered at startup/provider initialization and registered before model exposure; model-visible schemas remain run-stable during a request/run.
|
||||
- Local stdio server execution is a user/config trust decision. Yoi does not sandbox the local executable merely because it is configured through MCP.
|
||||
@@ -1,101 +0,0 @@
|
||||
---
|
||||
title: "Plugin platform roadmap"
|
||||
state: "active"
|
||||
created_at: "2026-06-19T13:18:58Z"
|
||||
updated_at: "2026-06-24T19:55:00Z"
|
||||
linked_tickets: ["00001KV5R5V2S", "00001KV5W3PHA", "00001KV5W3PHW", "00001KV5W3PJ3", "00001KVFD3YSV", "00001KVFDX9AF", "00001KVFDX9AY", "00001KVG0HR96", "00001KVXHVCR5", "00001KVXK0WD3", "00001KVXK0WDH", "00001KVXK0WDQ", "00001KVXK0WDX", "00001KVXK0WE4", "00001KVXK0WEA"]
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Build Yoi's Plugin platform as a coherent extension system: packages are discovered and inspected safely, enabled explicitly, registered through typed Plugin surfaces, executed in a sandboxed runtime, constrained by Plugin-layer grants, and authored through SDK/templates rather than raw runtime ABI details.
|
||||
|
||||
The long-term platform goal is not merely to run Wasm. It is to make Plugin packages a durable, inspectable, permissioned, and authorable extension layer for Tools first, then host APIs (`https`, `fs`), and later Service / Ingress surfaces when concrete needs justify them.
|
||||
|
||||
## Motivation / background
|
||||
|
||||
The current Plugin foundation is already substantial:
|
||||
|
||||
- package discovery and explicit enablement resolver;
|
||||
- Tool surface registration through the ordinary ToolRegistry/model-visible schema path;
|
||||
- minimal sandboxed WASM Tool execution;
|
||||
- Plugin permission grant enforcement;
|
||||
- follow-up Tickets for read-only inspection CLI, `https`, `fs`, and Component Model migration.
|
||||
|
||||
The remaining work must be kept as one roadmap because the pieces constrain each other:
|
||||
|
||||
- Plugin authoring needs an SDK/PDK and examples, not raw pointer/length Wasm ABI hand-coding.
|
||||
- `https` and `fs` host APIs must be grant-gated and shaped so they can move cleanly to typed Component Model interfaces.
|
||||
- Diagnostics (`yoi plugin list/show`) are needed before the system becomes harder to debug.
|
||||
- Component Model adoption should guide new host API design before a custom raw ABI becomes entrenched.
|
||||
- Service / Ingress are useful for bridge-style integrations, but should come after Tool runtime, diagnostics, and host API policy are stable.
|
||||
|
||||
Research of common Wasm extension systems points to the same pattern: mature systems combine a package manifest, explicit capabilities, a sandbox runtime, host-provided capability APIs, language SDK/PDK bindings, templates/examples, inspection/check tooling, and versioned interfaces.
|
||||
|
||||
## Strategy / design direction
|
||||
|
||||
- Keep Plugin as a user-facing package/config/runtime layer above lower-level `pod::feature` substrate.
|
||||
- `pod::feature` provides contribution/registration substrate.
|
||||
- Plugin owns package discovery, enablement, grant policy, runtime selection, authoring UX, and user-facing diagnostics.
|
||||
- Preserve authority boundaries.
|
||||
- Package discovery is read-only inventory.
|
||||
- Package presence never registers a Tool/Hook, executes Wasm, starts a Service, reads files, opens network, or injects context.
|
||||
- Explicit enablement and Plugin grants are required before registration/execution/host API use.
|
||||
- Tool calls/results continue through ordinary ToolRegistry and Worker history paths.
|
||||
- Treat Component Model as the active Plugin runtime shape before public release.
|
||||
- New typed Plugin host APIs should be designed in WIT-compatible terms.
|
||||
- `runtime.kind = "wasm-component"` is the current Plugin runtime authority for new work.
|
||||
- The earlier `yoi-plugin-wasm-1` raw core-Wasm compatibility bridge is retired from the active roadmap because Plugin has not been publicly released and compatibility would preserve the wrong boundary.
|
||||
- Sequence the platform in usable layers:
|
||||
1. Package discovery / explicit enablement / digest-pinned restore. Completed foundation.
|
||||
2. Tool surface registration. Completed foundation.
|
||||
3. Minimal WASM Tool execution. Completed foundation.
|
||||
4. Permission grants. Completed foundation.
|
||||
5. Read-only Plugin CLI inspection (`yoi plugin list/show`) for debugging discovery/enablement/grants/runtime metadata.
|
||||
6. `https` and `fs` host APIs for Tool Plugins, grant-gated and WIT-compatible.
|
||||
7. Remove the raw core-Wasm compatibility bridge and reject legacy runtime manifests.
|
||||
8. Component Model runtime and authoring model become the only active Plugin runtime path.
|
||||
9. Guest SDK/PDK, examples, `check`/`pack`/`new` authoring tooling target Component Model only.
|
||||
10. Service / Ingress runtime is developed as host-managed lifecycle, event queue, output command, and diagnostics slices.
|
||||
11. WebSocket support for long-lived integrations is host-owned connection driver + ingress event delivery + output command, not Plugin-owned polling with `recv(timeout)`.
|
||||
- Keep Discord-style bridge goals split into two stages.
|
||||
- Outbound Discord/webhook Tool is possible after `https`.
|
||||
- Bidirectional Discord bridge requires Service + Ingress + WebSocket or inbound HTTP and host routing policy.
|
||||
|
||||
## Current implementation split
|
||||
|
||||
The broad Plugin runtime redesign is tracked by `00001KVXHVCR5` as context only; implementation should proceed through concrete Tickets instead of routing that umbrella as a single coding task.
|
||||
|
||||
1. `00001KVXK0WD3` Remove legacy raw WASM Plugin runtime.
|
||||
- Deletes the active `LegacyToolAdapter` / raw-Wasm execution path.
|
||||
2. `00001KVXK0WDH` Reject legacy Plugin runtime in manifest and CLI diagnostics.
|
||||
- Makes `plugin.toml`, `yoi plugin check/list/show`, docs, and fixtures reflect Component Model only runtime authority.
|
||||
3. `00001KVXK0WDQ` Define Plugin Service lifecycle and ingress queue runtime.
|
||||
- Adds host-managed lifecycle, bounded queue, serial dispatch, backpressure, timeout, and diagnostics.
|
||||
4. `00001KVXK0WDX` Add Plugin service output command model.
|
||||
- Lets service handlers request side effects as grant-checked commands rather than ambient authority.
|
||||
5. `00001KVXK0WE4` Add host-owned WebSocket driver for Plugin services.
|
||||
- Converts incoming WS frames into ingress events and sends outbound frames through output commands.
|
||||
6. `00001KVXK0WEA` Update Plugin WIT PDK templates for service event runtime.
|
||||
- Aligns authoring API, WIT, PDK, templates, and docs with the new event/command execution model.
|
||||
|
||||
## Success criteria / exit conditions
|
||||
|
||||
- Users can inspect Plugin discovery/enablement/grant/runtime state through a read-only CLI without executing Plugin code.
|
||||
- Plugin authors can build a Tool Plugin without writing raw memory/pointer ABI plumbing.
|
||||
- Tool Plugins can safely call grant-gated `https` and `fs` host APIs.
|
||||
- Component Model is the only active Plugin runtime path, with WIT-compatible host API types and measured packaging/runtime impact.
|
||||
- Plugin grants remain authoritative over registration, execution, and host API calls.
|
||||
- Plugin diagnostics explain missing package, invalid manifest, digest/version mismatch, missing grant, rejected schema, runtime mismatch, legacy runtime rejection, and unsupported host API cases safely.
|
||||
- Raw core-Wasm Plugin compatibility is removed before public release; tests and docs no longer treat it as a current runtime.
|
||||
- Documentation covers package format, Component Model runtime, host API authority, authoring SDK/templates, Service/Ingress event runtime, and operational debugging.
|
||||
- Service/Ingress work is host-managed: services have lifecycle/status, ingress uses bounded queues, side effects are output commands, and WebSocket integrations use host-owned connection drivers.
|
||||
|
||||
## Decision context
|
||||
|
||||
- This Objective is roadmap context, not Ticket authority. Implementation still requires reading concrete Ticket bodies, threads, artifacts, and relations.
|
||||
- Component Model direction supersedes Yoi's custom raw ABI as both long-term and current active Plugin runtime authority before public release.
|
||||
- `https` / `fs` work should avoid choices that conflict with later WIT typed interfaces.
|
||||
- Guest SDK work targets Component Model directly; raw ABI wrappers are not a supported transitional authoring path.
|
||||
- Plugin and MCP remain separate. Component Model adoption for Plugin does not imply MCP server execution, MCP prompt/resource injection, or MCP trust policy changes.
|
||||
- Plugin surfaces remain Tool / Hook / Service / Ingress; outbound side effects are Tool metadata and host API grants, not a separate surface.
|
||||
@@ -1,287 +0,0 @@
|
||||
---
|
||||
title: "Team workspace control plane and runtime architecture"
|
||||
state: "active"
|
||||
created_at: "2026-06-20T14:26:29Z"
|
||||
updated_at: "2026-07-15T21:18:00Z"
|
||||
linked_tickets: ["00001KVMFFYVX", "00001KWMBAA6V"]
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Yoi を、単一のローカル開発ディレクトリで動くエージェント実行ツールから、チームで作業・判断・実行結果を管理できるワークスペース基盤へ発展させる。
|
||||
|
||||
この Objective の中心は、Web から扱える管理システムを作り、その管理システムにローカル Runtime・リモート Runtime・将来のクラウド Runtime を接続できるようにすることである。管理システムは Ticket、Objective、Memory、Skill catalog、Artifact、Policy、Actor、Repository、Runtime state の正本を持つ。Runtime はその管理システムから Worker launch request / config bundle / repository target / authority を受け取り、作業環境を用意して Worker を実行し、結果・イベント・証跡を返す。
|
||||
|
||||
この Objective は Git ホスティングサービスを作るものではない。Git は重要な Repository provider として扱うが、Yoi の Workspace は Git Repository root と同じものにしない。Yoi が作るべきものは、コード・ドキュメント・データ・成果物などの Repository と Runtime を接続しながら、人間とエージェントの作業、Ticket lifecycle、Memory、Skill catalog、検証証跡、実行環境配置を管理するチームワークスペースである。
|
||||
|
||||
## Glossary
|
||||
|
||||
この Objective では、以下の語をこの意味で使う。
|
||||
|
||||
- Workspace: チームまたはプロジェクトの管理単位。Ticket、Objective、Memory、Skill catalog、Artifact、Policy、Actor、Repository、Runtime state を持つ。Git Repository root ではない。
|
||||
- Control plane: Workspace の正本を持ち、Web UI / API / CLI から操作される管理システム。
|
||||
- Runtime: Worker 群を束ねる実行基盤。Worker lifecycle、sandbox、mount、cache、checkout/worktree/container filesystem などの working directory materialization、event/control plane を管理する。将来的には 1 つの Runtime が複数 Workspace / Repository の Worker を抱えられる。
|
||||
- Worker: Runtime が管理する 1 つの agent/session/process。Runtime が用意した working directory と authority の中で動く。
|
||||
- Repository: Workspace に接続される source/storage。コード、ドキュメント、local directory、object storage、artifact store、dataset などを含む。Git Repository も Repository の一種であり、基本的には filesystem path ではなく URI / URL で識別する。
|
||||
- RepositoryId: Workspace 内でどの Repository を対象にするかを指す安定 identifier。Git hash、branch、path ではない。
|
||||
- Repository provider: Repository の種類ごとの実装。Git、local filesystem、object store、artifact store、将来の non-Git VCS など。
|
||||
- RepositorySelector: Repository provider に渡す未解決の地点指定。branch/tag/PR/revspec/bookmark/revset/path@revision/object version/latest など provider-specific な symbolic / mutable / query-like locator であり、それ自体は再現性の authority ではない。
|
||||
- RepositoryPoint: RepositorySelector をある時点で解決した具体地点。Git commit/tree、Mercurial changeset、SVN revision、object store version/manifest digest、file snapshot など provider ごとの immutable / reproducible point を表し、Artifact/evidence に残す。
|
||||
- working directory: Runtime が Worker のために作る作業環境。1 つ以上の RepositoryPoint から materialize される作業用ディレクトリ、container filesystem、sandbox mount の集合であり、Git worktree、clone、sparse checkout などはこれを作る手段である。Browser-facing UI/API では product `Workspace` と混同しないよう、この呼称に寄せる。`Volume` は storage backing の候補名に留め、作業領域そのものの呼称にはしない。
|
||||
- Ticket: チームで扱う作業単位。目的、要件、判断、議論、完了条件、関係、証跡を持つ。
|
||||
- Objective: 複数の Ticket を束ねる長期目標や設計方針。
|
||||
- Artifact: Ticket や Worker 実行に紐づく成果物や証跡。diff、log、validation result、review result、report など。
|
||||
- Memory: エージェントやユーザーが再利用するための要約された文脈。Ticket や Artifact の正本ではない。
|
||||
- Skill catalog: `.yoi/skills` / builtin skills から Workspace backend が解決する procedural guidance catalog。外部状態 authority は持たず、Ticket / Worker / workdir などの操作は typed feature/tool surface が担う。
|
||||
- Actor: 人間、エージェント、システム、外部サービスなど、Workspace 上で操作や発言を行う主体。
|
||||
|
||||
## Motivation / background
|
||||
|
||||
現在の Yoi は、ローカルの `.yoi` ディレクトリ、ローカルプロセス、Ticket ファイル、ワークツリー運用によって、自分自身の開発に使えるエージェント実行環境になっている。しかし、チーム利用、Web UI、リモート実行、クラウド実行、最終的な SaaS 提供を考えると、次の前提を変える必要がある。
|
||||
|
||||
- Workspace を Git Repository root と同一視しない。
|
||||
- ローカル filesystem 上の `.yoi` を、長期的なチーム用正本 store にしない。
|
||||
- Ticket をローカル作業メモではなく、チームの作業調整 record にする。
|
||||
- 実行証跡は Ticket thread、Artifact、WorkerRef snapshot、Runtime event として扱い、独立した実行単位概念を先に増やさない。
|
||||
- 管理システムと Runtime を分ける。
|
||||
- まず Web から Ticket、Objective、Memory、Skill catalog、Artifact、Runtime / Worker state を見られるようにする。
|
||||
- 最初はローカル Runtime を使い、後でリモート Runtime、クラウド Runtime、runtime pool、resource allocation、quota、billing、sandboxing に拡張する。
|
||||
- Git ホスティング機能を取り込むのではなく、Git Repository / worktree / clone は Repository provider と working directory materialization の手段として扱う。
|
||||
|
||||
OSS として Control plane、Runtime、Web frontend、protocol を公開しつつ、managed service では hosted control plane、runtime fleet、リソース柔軟性、team auth、backup、audit、availability、multi-tenant operations で価値を出す。
|
||||
|
||||
## Strategy / design direction
|
||||
|
||||
### 1. Control plane を先に作る
|
||||
|
||||
Team Workspace の正本は server-side control plane に置く。`.yoi` は local backend、single-user/self-hosted compatibility、offline/export/import、local projection、migration bridge として残せるが、multi-user SaaS の正本とはみなさない。
|
||||
|
||||
Control plane は Ticket、Objective、Memory、Skill catalog、Artifact、Actor、Permission、Audit、Repository、Runtime / Worker state を管理する。Web UI、CLI、TUI、将来の desktop client は、この Control plane を操作する client であり、別の正本 store を持たない。
|
||||
|
||||
### 2. Workspace と Repository を同一視しない
|
||||
|
||||
Workspace はチームまたはプロジェクトの作業管理単位である。Repository は Workspace に接続される source/storage である。Git Repository は Repository の一種にすぎない。
|
||||
|
||||
1 つの Workspace は複数の Repository を持てる。Repository は filesystem path ではなく URI / URL で識別する。例として `git+https://...`、`file://...`、`s3://...`、`artifact://...`、将来の VCS provider URI などを扱えるようにする。
|
||||
|
||||
Ticket と Objective は Repository 配下に置かず、Workspace 配下に平たく持つ。Ticket は必要に応じて対象 RepositoryId、RepositorySelector、path scope、必要 capability を持つ。Objective は複数 Ticket にまたがる target default / scope hint を持てるが、Repository の所有物にはしない。
|
||||
|
||||
RepositorySelector は Git branch/tag の抽象化ではない。Selector は provider-specific な未解決 locator であり、Git provider なら branch/tag/ref/revspec/PR/commit、Mercurial provider なら bookmark/revset/changeset、SVN provider なら path/revision、object store provider なら prefix/version/latest などを解釈する。実行時には Control plane または Repository provider が Selector を RepositoryPoint に解決し、Runtime はその RepositoryPoint を materialize する。
|
||||
|
||||
Worker launch request は Ticket の target selector を concrete RepositoryPoint に解決し、その RepositoryPoint から Runtime が working directory を materialize する。Git worktree 相当の機能は、この working directory を作るための実装戦略として扱う。
|
||||
|
||||
Backend は cwd や `--workspace` を暗黙の Repository として扱わない。`--workspace` は当面 workspace config root / local descriptor root を指すだけであり、Repository registry は明示設定された Workspace config から構築する。短期的には `.yoi/workspace-backend.local.toml` の `[[repositories]]` を local descriptor として使い、`uri = "."` のような local repository も明示 entry として登録する。`./` を暗黙 Repository として自動採用しない。
|
||||
|
||||
`.yoi` は現在の local backend / fs-store / compatibility surface として残るが、long-term Backend store ではない。将来的には `~/.yoi` 側に Backend store と Workspace registry を置き、1 Backend process が複数 Workspace を扱える形へ移行する。`.yoi/workspace-backend.local.toml` はその移行までの workspace-local descriptor / override surface として扱う。
|
||||
|
||||
短期的には Git を主な Repository provider とする。ただし Yoi の authority model を Git object、Git branch、Git Repository root、worktree path に固定しない。Orchestration は Git そのものではなく、`resolve_ref`、`materialize`、`diff`、`patch`、`commit`、`merge` などの Repository capability に依存する。
|
||||
|
||||
### 3. Ticket を team coordination record にする
|
||||
|
||||
Ticket は実行そのものではない。Ticket は「何を、なぜ、どの条件で完了とみなすか」を持つ。Ticket は Workspace に平たく所属し、Repository には所属しない。コードやドキュメントを対象にする Ticket は、対象 Repository / ref selector / path / intent を target として持つ。
|
||||
|
||||
Ticket target は intent/selector であり、実行再現性のための immutable point ではない。Worker launch request が target selector を concrete RepositoryPoint に解決し、Runtime が実際にどの revision/snapshot を materialize したかを Artifact / evidence として記録する。
|
||||
|
||||
```text
|
||||
Ticket
|
||||
-> target selectors: Repository + ref selector + path + intent
|
||||
-> resolved RepositoryPoint
|
||||
-> working directory
|
||||
-> WorkerRef / Artifact / Evidence
|
||||
-> Review / Decision
|
||||
-> Audit / Notification
|
||||
```
|
||||
|
||||
Target 例:
|
||||
|
||||
```text
|
||||
Ticket targets:
|
||||
- repository: main-code
|
||||
role: primary
|
||||
ref: develop
|
||||
paths: ["crates/pod/"]
|
||||
intent: change
|
||||
- repository: docs
|
||||
role: related
|
||||
ref: main
|
||||
paths: ["docs/development/"]
|
||||
intent: read
|
||||
|
||||
Worker launch materialization:
|
||||
- repository: main-code
|
||||
requested_ref: develop
|
||||
resolved_point: git commit abc123
|
||||
mount: /workspace/main-code
|
||||
```
|
||||
|
||||
Ticket には次の概念が必要になる。
|
||||
|
||||
- Actor identity: human / agent / system / service account.
|
||||
- Assignment / owner / reviewer / watcher.
|
||||
- Typed thread events: comment, decision, plan, review, implementation report, state transition.
|
||||
- Linked Objective / Artifact / WorkerRef / Repository / RepositoryPoint / working directory.
|
||||
- Permission / visibility.
|
||||
- Audit trail.
|
||||
- Notification / mention.
|
||||
- Board / queue / planning / review / done / archived views.
|
||||
- Conflict handling and concurrent editing policy.
|
||||
|
||||
### 4. Memory / Skill catalog の本格再設計は後回しにする
|
||||
|
||||
Memory は Ticket / Artifact のコピーではない。再利用可能な文脈、方針、学習された制約を扱うが、Ticket や Artifact の authority を置き換えない。Skill catalog は procedural guidance の catalog であり、外部状態 authority を持たない。Knowledge record kind は削除方針なので、この Objective では separate Knowledge storage を新しい control plane entity として増やさない。
|
||||
|
||||
理由は、Memory と Skill catalog の正しい設計が Workspace control plane の record model、Actor / visibility / permission、Ticket、Artifact / evidence、RepositoryPoint、Runtime に渡す context の監査方法に依存するためである。これらが固まる前に Memory schema や Skill API だけを作ると、local `.yoi` 前提や現行 agent runtime 前提に引っ張られ、後で再設計が必要になる。
|
||||
|
||||
この Objective では、Memory / Skill catalog について以下の platform contract だけを維持する。
|
||||
|
||||
- Memory は Control plane が扱う record だが、Ticket / Artifact の authority を置き換えない。
|
||||
- Skill catalog は Workspace backend が扱う prompt/resource catalog だが、Ticket / Worker / workdir / queue の authority を持たない。
|
||||
- 将来、Memory と Skill catalog の canonical storage / API は Workspace control plane 側に置く。
|
||||
- local `.yoi` memory と `.yoi/skills` は compatibility、offline/export/import、local projection、migration bridge として扱う。
|
||||
- Personal Memory、Workspace Memory、Worker Summary、Skill catalog は分離が必要である。
|
||||
- Generated Memory には provenance、visibility、approval、audit が必要である。
|
||||
- Runtime / Worker に渡した Memory / Skill context は、将来 ContextPack などとして Artifact/evidence に記録できる必要がある。
|
||||
|
||||
本格的な Memory 再設計は、Memory の保存先を Workspace backend / control plane record に移すタイミングで回収する。それまでは低リスクな観察、問題例の収集、既存 local memory の互換維持に留める。
|
||||
|
||||
### 5. Control plane / Runtime を分離する
|
||||
|
||||
Control plane は正本と調整を持つ。Runtime は Worker 群と実行基盤を管理する。初期実装では local backend と runtime process が同じマシン上にあり、役割が近く見えるが、設計上は分ける。
|
||||
|
||||
初期形:
|
||||
|
||||
```text
|
||||
Web UI / Control Plane
|
||||
-> Runtime registry / local backend
|
||||
-> Runtime process
|
||||
-> Workers
|
||||
-> Existing Yoi tools, working copy, build/test commands
|
||||
```
|
||||
|
||||
この段階では、現在ローカル管理画面が行っている Ticket 選択、エージェント起動、レビュー起動、作業用 checkout 作成、検証実行、結果表示を、Web/control plane から local Runtime に対して実行できるようにする。
|
||||
|
||||
長期的には Runtime が作業環境の用意まで請け負う。Runtime は Worker launch request / config bundle / repository target / authority を受け取り、必要な checkout、worktree、container filesystem、sandbox mount、cache、secret boundary を準備して Worker を起動する。Runtime 起動時に特定 Workspace path を必須にする形は暫定であり、Workspace / Repository 情報は Runtime process 起動引数ではなく Worker launch request 側の入力に寄せる。
|
||||
|
||||
Sandbox と authority 分離が成立している前提では、1 つの Runtime が複数 Workspace / Repository の Worker を抱えられる。したがって Runtime identity は Git repository root や single workspace directory と同一視しない。Runtime は execution substrate、Workspace は作業管理 record の正本として扱う。
|
||||
|
||||
Worker の作業環境を用意する経路は次のようにまとめる。
|
||||
|
||||
```text
|
||||
Ticket / user intent
|
||||
-> RepositoryId + RepositorySelector + path scope + required authority
|
||||
-> resolved RepositoryPoint
|
||||
-> WorkerLaunchRequest / ConfigBundle / AuthorityBundle
|
||||
-> Runtime WorkingDirectoryMaterializer
|
||||
-> working directory allocation
|
||||
-> Worker process
|
||||
-> WorkerRef + Artifact/evidence
|
||||
```
|
||||
|
||||
Control plane は Workspace authority、Ticket target、Repository registry、Actor permission、設定 bundle の正本を持つ。Control plane または Repository provider は RepositorySelector を RepositoryPoint に解決し、どの地点を対象にしたかを evidence に残す。Runtime は RepositoryPoint、materialization policy、sandbox policy、mount/cache/secret policy、Worker config を受け取り、Runtime-local な working directory を確保して Worker を起動する。
|
||||
|
||||
この境界では、Worker は host filesystem path や Repository credential を自分で発見しない。Worker は Runtime が materialize した working directory root、mount、環境変数、tool authority、config bundle だけを見る。Runtime は working directory の lifecycle、cleanup、cache reuse、namespace、quota、sandbox boundary、event collection を管理する。Control plane / Browser-facing API は raw host path、secret、socket、internal runtime path を authority-bearing internals として扱い、必要な evidence だけを Artifact として公開する。
|
||||
|
||||
v0 materializer は existing local root を明示的な working directory として返してよい。ただし型と呼び出し順序は、後で Git worktree、clone、sparse checkout、container filesystem、remote object snapshot、multi-repository mount に置き換えられる形にする。Runtime process 起動時の `--workspace` はこの v0 materializer の legacy bootstrap input であり、Runtime identity や long-term workspace binding ではない。
|
||||
|
||||
その後で、remote Runtime、self-hosted Runtime、hosted cloud runtime fleet、runtime pool、resource allocation、quota、billing、sandbox、network policy、secret distribution を追加する。
|
||||
|
||||
```text
|
||||
Phase 1: Web control plane + local Runtime
|
||||
Phase 2: Remote/self-hosted Runtime
|
||||
Phase 3: Hosted cloud runtime fleet
|
||||
Phase 4: Resource allocation / scheduling / quotas / billing / isolation
|
||||
```
|
||||
|
||||
### 6. Web frontend を先に作る
|
||||
|
||||
Desktop app は対応コストが高いので、まず Web frontend を primary UI とする。
|
||||
|
||||
- Web: チームで使う主要 UI。
|
||||
- CLI: automation、scripting、local operations。
|
||||
- TUI/local panel: fallback、dogfooding surface。
|
||||
- Future desktop: Web/control-plane model が安定した後に検討する optional client。
|
||||
|
||||
Web UI は Ticket、Objective、Memory、Skill catalog、Runtime、Worker、Artifact を扱う。UI の都合で正本を二重化しない。
|
||||
|
||||
### 7. 多重起動コストと runtime placement を見直す
|
||||
|
||||
Cloud/remote execution を成立させるには、多数のエージェント実行を安く管理できる必要がある。logical Worker session と Runtime process/resource placement を分ける。Runtime は Git Repository root や Workspace path に固定されず、request ごとに必要な working directory を materialize できる実行基盤として扱う。
|
||||
|
||||
初期 Workspace DB では、Worker を canonical table として永続化しない。Runtime / Worker 一覧は backend-local runtime inspection や将来の Runtime protocol から逐次取得する live view とし、Ticket に関わった Worker は Ticket thread events と WorkerRef snapshot / TicketWorkerLink として記録する。
|
||||
|
||||
Worker の一元管理、データ永続化、アーカイブは将来的には必要になる。これは Runtime protocol、remote/self-hosted/hosted runtime lifecycle、worker identity、retention policy、audit requirements が固まった後に、dedicated Worker registry / archive model として追加する。v0 で Pod metadata の代替として Worker table を作らない。
|
||||
|
||||
検討対象:
|
||||
|
||||
- Worker identity と Runtime process/resource placement の分離。
|
||||
- 1 Runtime が複数 Workspace / Repository の Worker を抱える場合の namespace、quota、cleanup、audit boundary。
|
||||
- Runtime-side working directory materialization、sandbox、mount、checkout/worktree/container filesystem の責務。
|
||||
- Provider client、tool registry、resource cache の共有可能性。
|
||||
- Prompt/resource/profile/config bundle resolution cache。
|
||||
- Model call multiplexing and scheduling。
|
||||
- Tool execution sandbox reuse。
|
||||
- Plugin instance / Service runtime との統合。
|
||||
- Session/event stream と runtime lifecycle の分離。
|
||||
- Runtime-local cache、checkout reuse、build cache、dependency cache。
|
||||
|
||||
## Initial phases / candidate tickets
|
||||
|
||||
1. **Vocabulary / architecture record**
|
||||
- Workspace / RepositoryId / RepositorySelector / RepositoryPoint / working directory / Runtime / Worker / Control Plane / Ticket / Memory の用語と境界を固める。
|
||||
2. **Team-space canonical data model**
|
||||
- Ticket / Objective / Target / Artifact / Actor / Permission / Audit / Memory の entity/event model を設計する。
|
||||
3. **Ticket evidence model**
|
||||
- Ticket lifecycle、WorkerRef、Artifact、validation evidence、review evidence、Ticket thread の責務を明確化する。
|
||||
4. **Memory storage migration boundary**
|
||||
- Memory の本格再設計は後回しにし、まずは Workspace backend に移す時の platform contract、compatibility/cache/export 方針、将来の provenance / visibility / approval 要件だけを固定する。
|
||||
5. **Control plane backend architecture**
|
||||
- local `.yoi` backend と server-side canonical backend の境界、migration/export/import、compatibility mode を設計する。
|
||||
6. **Web control plane MVP design**
|
||||
- read-only Ticket / Objective / Memory / Runtime / Worker state UI/API の範囲を決める。
|
||||
7. **Local Runtime protocol design**
|
||||
- Web/control plane から local Runtime に安全な操作を送り、Runtime が Worker lifecycle と working directory materialization を担う protocol と authority boundary を設計する。
|
||||
8. **Repository and working directory materialization model**
|
||||
- Repository URI、Repository provider capability、RepositorySelector resolution、RepositoryPoint evidence、Git worktree / clone / sparse checkout / future source backend を Runtime-side materialization strategy として抽象化する。
|
||||
9. **Remote/hosted runtime foundation**
|
||||
- runtime registration, heartbeat, capability advertisement, job assignment, logs/events, secrets, sandbox/resource policy を設計する。
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Git hosting service を作ること。
|
||||
- `.yoi` filesystem をそのまま SaaS canonical store にすること。
|
||||
- 最初から full hosted cloud execution を作ること。
|
||||
- local execution / CLI / TUI / local panel を捨てること。
|
||||
- Ticket を単なる issue tracker clone にすること。
|
||||
- Memory を Ticket/Artifact audit log の代替にすること。
|
||||
- Web UI のために core authority を二重化すること。
|
||||
- hidden server state を LLM context に直接注入すること。
|
||||
- multi-tenant auth/billing/secret/security を shortcut して実装すること。
|
||||
|
||||
## Success criteria / exit conditions
|
||||
|
||||
- Workspace / RepositoryId / RepositorySelector / RepositoryPoint / working directory / Runtime / Worker / Control Plane / Ticket / Memory の境界が文書化されている。
|
||||
- Ticket が team coordination record として、target selector / Artifact / Actor / Permission / Audit と分離された model を持つ。
|
||||
- `.yoi` local backend は compatibility/local backend として整理され、server-side canonical backend の設計を阻害しない。
|
||||
- Web UI/API が Ticket / Objective / Runtime / Worker state を中心とした read-only view を提供できる設計または MVP を持つ。Memory は既存 record の表示または将来 placeholder に留め、本格再設計をこの段階の必須条件にしない。
|
||||
- Control plane から local Runtime に対して、現在のローカル管理画面相当の安全な操作を実行できる design/protocol がある。
|
||||
- Runtime は single Workspace / Git repository root 専用 process ではなく、sandbox/authority が成立すれば複数 Workspace / Repository の Worker を抱えられる execution substrate として設計されている。
|
||||
- Git Repository root に依存しない Workspace model があり、Git Repository は Repository provider の一種として扱われている。
|
||||
- Ticket と Objective は Workspace 配下に平たく存在し、Repository への所属ではなく RepositoryId / RepositorySelector / path scope / intent で対象を表現する。
|
||||
- Git worktree 相当は working directory materialization strategy として扱われ、Artifact/evidence が concrete RepositoryPoint を記録する。
|
||||
- Memory は Ticket / Artifact の authority を置き換えない record として platform contract だけを持つ。本格的な意味論・抽出・承認・検索・staleness 処理は、Memory の保存先を Workspace backend / control plane record に移すタイミングで回収する。
|
||||
- Hosted Runtime / resource allocation / SaaS offering に進むための後続 Ticket が切れる状態になっている。
|
||||
- 既存 local dogfooding runtime を壊さず、local use と remote-capable architecture が両立している。
|
||||
|
||||
## Decision context
|
||||
|
||||
- Yoi は hosted Git tool ではなく、team workspace control plane + Runtime execution environment として設計する。
|
||||
- Team-space の長期 canonical authority は server-side control plane に置く。local `.yoi` は互換/local/offline/export/import surface だが、multi-user SaaS の正本ではない。
|
||||
- 実行環境と管理システムは弱結合にする。まず管理システムを独立させ、local Runtime を実行環境として接続する。その後に remote/self-hosted/hosted runtime fleet へ進む。
|
||||
- Runtime は Worker 群を束ねる実行基盤であり、将来的には作業環境の用意、sandbox、mount、checkout/worktree/container filesystem、cache、secret boundary を Worker launch request ごとに準備する。Runtime process は single Workspace / Git repository root 専用に固定しない。RepositorySelector は provider-specific な未解決 locator、RepositoryPoint は解決済み evidence として扱う。
|
||||
- Web frontend を最初の primary team UI とする。Desktop app は web/control-plane model が安定した後に検討する。
|
||||
- Git は重要な Repository provider / materialization backend として使うが、Workspace identity と authority を Git Repository root に固定しない。
|
||||
- Ticket と Objective は Workspace 配下に平たく持つ。対象コードベースや地点指定は RepositoryId / RepositorySelector / path scope / intent として表現し、Worker launch materialization が concrete RepositoryPoint に解決する。
|
||||
- Backend Repository は cwd inspection ではなく、Workspace config の明示 Repository registry から構築する。`--workspace` は Repository ではなく workspace config root / local descriptor root を指す。
|
||||
- `./.yoi` は local descriptor / fs-store / compatibility surface であり、将来の Backend canonical store と Workspace registry は `~/.yoi` 側へ寄せる。
|
||||
- Memory の本格再設計は後回しにする。先に Workspace / Ticket / Repository / Runtime/Worker live view / Control plane の基盤を固め、Memory の保存先を Workspace backend に移すタイミングで、意味論・抽出・承認・検索・staleness 処理をまとめて回収する。
|
||||
- Worker の一元管理・データ永続化・アーカイブも後続設計に回す。初期 DB では Worker を Pod metadata の代替として永続化せず、live view と Ticket-linked WorkerRef 記録に留める。
|
||||
@@ -1,289 +0,0 @@
|
||||
---
|
||||
title: "効果的な Memory システム設計・検証"
|
||||
state: "active"
|
||||
created_at: "2026-06-20T15:16:00Z"
|
||||
updated_at: "2026-07-17T23:10:00Z"
|
||||
linked_tickets: ["00001KSKBPHRG", "00001KT02TCCG", "00001KTGCAFXG", "00001KSKBPTHR", "00001KXMEZNYC", "00001KXMK7YMC", "00001KXNYXNM6", "00001KXRM6G0G", "00001KXS56AS5", "00001KXMK846H"]
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Yoi の Memory / Knowledge / Skills / generated context / resident context / retrieval / usage metrics を、実際の開発・設計・レビュー・オーケストレーションに効く sensemaking substrate として再設計・検証する。Memory は短期・変化前提の context、Knowledge は育てる long-term note、Skill は移植可能な workflow として分け、この Objective ではそれらと authority record / docs / typed tools の境界を再整理する。
|
||||
|
||||
この Objective でいう「効果的な Memory システム」は、単に多く保存する仕組みではなく、作業中の問いに対して relevant material を集め、根拠を検証可能にし、再表現・仮説形成・反証探索・意思決定・成果物への反映を低コストにする仕組みである。
|
||||
|
||||
暫定的な定義:
|
||||
|
||||
- foraging cost を下げる: Ticket / Objective / current question に対して、関連する memory / docs / tickets / session evidence / code references を探しやすい。
|
||||
- evidence を失わない: Memory が authority そのものにならず、Ticket / docs / git history / session logs / user instruction への検証可能な入口になる。
|
||||
- schema 化を支援する: raw summary ではなく、subsystem、invariant、risk、authority boundary、open question、rejected alternative、hypothesis など推論しやすい形へ再表現できる。
|
||||
- hypothesis loop を支援する: 支持証拠だけでなく、代替仮説・棄却理由・反証 evidence・stale assumption を扱える。
|
||||
- product に戻る: Memory に保存して終わりではなく、Ticket、review、docs、implementation、decision、report に影響を戻せる。
|
||||
- stale / contradiction を扱う: 古い前提、矛盾、適用範囲外の memory を検出・降格・更新できる。
|
||||
- usage を成果基準で測る: resident exposure や read count ではなく、判断・レビュー・実装・docs に効いたかを観測できる。
|
||||
|
||||
## Motivation / background
|
||||
|
||||
現在の Memory システムは「墓場化」している。保存された情報はあるが、後続の作業で自然に使われにくく、使われたとしても根拠・適用範囲・鮮度・反証可能性が弱い。結果として Memory は、作業場ではなく古い結論の倉庫になりやすい。
|
||||
|
||||
Pirolli & Card 2005 の sensemaking model では、分析作業は単なる保存ではなく、次の変換として捉えられる。
|
||||
|
||||
```text
|
||||
external data sources
|
||||
-> shoebox
|
||||
-> evidence file
|
||||
-> schema / representation
|
||||
-> hypotheses
|
||||
-> presentation / product
|
||||
```
|
||||
|
||||
Yoi の現行 Memory は、この流れのうち「保存」と「一部の検索」には対応しているが、少なくとも以下が弱い。
|
||||
|
||||
- Ticket / task / question ごとの shoebox がない。
|
||||
- shoebox から evidence snippets を切り出し、source / provenance / applicability / confidence と共に扱う evidence file がない。
|
||||
- `summary`, `decision`, `request` は durable memory storage taxonomy であり、sensemaking 用 schema としては粗い。Knowledge は古い record kind をそのまま残すのではなく、育てる long-term note subsystem として再設計する。再利用可能な手順は Skill、保守された設計資料は Knowledge / docs / Ticket decisions に寄せる。
|
||||
- decision は残るが、hypothesis space、alternative、rejected reason、disconfirming evidence が残りにくい。
|
||||
- reviewer / orchestrator が confirmation bias を避けるための反証探索導線が弱い。関連する手順誘導は旧 Workflow ではなく Skill と role prompt / typed tools へ寄せる。
|
||||
- resident exposure と explicit retrieval は観測できても、Memory が product に効いたかは測りにくい。
|
||||
|
||||
この Objective は、Memory 関連の設計・検証・検討・考察を一元化し、個別 Ticket がばらばらに storage、prompt、retrieval、metrics を改善して再び墓場を増やすことを防ぐための判断背景である。
|
||||
|
||||
## Strategy / design direction
|
||||
|
||||
Memory を「長期保存領域」ではなく、Yoi の multi-agent 開発における sensemaking loop の支援機構として設計する。
|
||||
|
||||
### 1. Pirolli & Card の stage に合わせて責務を分ける
|
||||
|
||||
- external data sources: Tickets、docs、git history、session logs、reports、code、user instructions。
|
||||
- shoebox: 特定 Ticket / Objective / design question に対して関連しそうな材料を集めた task-bound working set。
|
||||
- evidence file: shoebox から抜き出した根拠 snippet。source anchor、支持/反証、適用範囲、confidence、staleness を持つ。
|
||||
- schema / representation: subsystem、invariant、risk、authority boundary、open question、hypothesis、alternative、contradiction など、推論しやすい再表現。
|
||||
- hypotheses: 採用前の設計仮説、代替案、棄却条件、反証 evidence。
|
||||
- product: Ticket、review、docs、implementation、decision、report、orchestration plan などの成果物。
|
||||
|
||||
### 2. 最初の重点は task-bound shoebox と evidence file
|
||||
|
||||
Memory 墓場化の最初の原因は、保存情報が現在の問いに集まらないことである。まずは Orchestrator / Intake / Reviewer が Ticket を扱う時に、関連 memory / docs / tickets / reports / prior decisions を shoebox として束ねる導線を作る。
|
||||
|
||||
この段階では大きな永続 schema 追加に飛びつかず、report / Ticket artifact / bounded generated context として検証してよい。
|
||||
|
||||
### 3. Session Overview を extract の足場にする
|
||||
|
||||
現在の extract は、tool call / tool result summary を含む flat slice から意味を復元しようとして断片化しやすい。改善方針は、main Worker が通常 Assistant Message として Progress message を残し、user messages + Assistant messages を Session Overview として先に読む形にする。
|
||||
|
||||
- Progress message は専用 Tool ではなく通常 Message とし、ユーザーへの進捗報告と extract 用 semantic summary を兼ねる。
|
||||
- extract worker には専用の read-only evidence tools を渡し、Overview で重要そうに見えた箇所だけ session range / tool summaries / source anchors を探索させる。
|
||||
- extract worker は Memory / Knowledge / Skill を直接更新しない。output は必ず staging を挟み、source / provenance を host 側で機械的に保持する。
|
||||
- trigger は初期実装では現行通り Worker run cycle 完了後の threshold 判定にする。LLM call 単位や Run 中の Overview accumulation trigger は含めない。
|
||||
|
||||
### 4. Memory を authority にしない
|
||||
|
||||
Memory は Ticket、docs、git history、session logs、user instruction の代替ではない。Memory は authority record への evidence index / schema / reasoning aid として扱う。
|
||||
|
||||
したがって、改善案は次の性質を持つべきである。
|
||||
|
||||
- source / provenance を辿れる。
|
||||
- stale / superseded / contradicted を扱える。
|
||||
- Memory の断定をそのまま authority として使わない。
|
||||
- Ticket body/thread/artifacts を読まずに Objective や Memory だけで実装判断できる状態を作らない。
|
||||
|
||||
### 5. 反証探索を first-class にする
|
||||
|
||||
より効果的な Memory は、過去方針を思い出すだけでなく、現在案を疑うために使える必要がある。
|
||||
|
||||
Reviewer / Orchestrator / Intake の導線では、次を探せるようにする。
|
||||
|
||||
- supporting evidence
|
||||
- contradicting evidence
|
||||
- stale decisions
|
||||
- rejected alternatives
|
||||
- unresolved questions
|
||||
- authority boundary risks
|
||||
- prior failures / reports
|
||||
|
||||
### 6. Metrics は exposure から product impact へ寄せる
|
||||
|
||||
Memory が prompt に入った、または query されたことは成功ではない。評価は次を区別する。
|
||||
|
||||
- resident exposure
|
||||
- explicit retrieval
|
||||
- cited in response
|
||||
- cited in Ticket / review / report
|
||||
- changed requirement
|
||||
- changed implementation
|
||||
- contradicted / invalidated
|
||||
- led to docs or decision update
|
||||
|
||||
### 7. 後続 Ticket は concrete slice に分割する
|
||||
|
||||
この Objective は中期的な設計・検証の一元化 record であり、umbrella Ticket ではない。実装や調査は、単独で実装・レビュー・close できる concrete Ticket に分割する。
|
||||
|
||||
候補 slice:
|
||||
|
||||
- Memory sensemaking 分析 report を `docs/report/` に作る。
|
||||
- Ticket routing 用 Memory shoebox artifact を試作する。
|
||||
- evidence snippet schema / source resolver を設計する。
|
||||
- hypothesis / rejected alternative / disconfirming evidence の表現を追加する。
|
||||
- Reviewer Skill / review process に反証探索を入れる。
|
||||
- Memory usage metrics を product impact oriented に拡張する。
|
||||
- stale / contradiction / renewal の検出・表示を設計する。
|
||||
- turn 中の Progress message を通常 Assistant Message として残す prompt/guidance を追加する (`00001KXMEZNYC`)。
|
||||
- Session Overview + Evidence index を使う extract input を設計・実装する。
|
||||
- extract worker 専用の read-only evidence search/read/source-anchor tools を設計する。
|
||||
- extract output を staging に限定し、source range と output entry を結びつける schema を設計する。
|
||||
|
||||
## Success criteria / exit conditions
|
||||
|
||||
- Memory システムの目的が「保存」ではなく「sensemaking loop 支援」として project records / docs / prompts / Skills で一貫して説明されている。
|
||||
- Pirolli & Card の `shoebox -> evidence file -> schema -> hypotheses -> product` に対応する Yoi 内の責務と非責務が整理されている。
|
||||
- Ticket / Objective / docs / session logs / Memory / Skills の authority boundary が明確で、Memory が authority を僭称せず、Skill は手順資源として外部状態 authority を持たない。
|
||||
- 少なくとも一つの実作業 routing / review / design analysis で、task-bound shoebox または evidence file が生成・利用され、作業品質にどう効いたかが確認されている。
|
||||
- Memory records または関連 artifacts が source / provenance / applicability / staleness / supports-or-refutes のいずれかを扱えるようになっている。
|
||||
- extract が User / Assistant messages 由来の Session Overview を primary input とし、tool logs を evidence として探索できる設計になっている。
|
||||
- extract worker 専用の read-only evidence tools が設計され、main Worker の tool surface を増やさない方針になっている。
|
||||
- extract output は direct Memory / Knowledge / Skill write ではなく staging を挟む方針になっている。
|
||||
- Reviewer / Orchestrator が supporting evidence だけでなく、contradicting evidence / stale assumptions / rejected alternatives を探す導線を持っている。
|
||||
- Memory usage metrics が resident exposure と product impact を区別している。
|
||||
- 古い Memory が放置されるのではなく、stale / superseded / contradicted / needs-review として扱える方針がある。
|
||||
- 後続の実装 Ticket が concrete slice として分割され、Objective が Ticket dependency や進捗 container として使われていない。
|
||||
|
||||
この Objective は、Memory が少なくとも一つの中規模設計・実装・レビュー作業で「関連情報を見つける」「根拠を確認する」「代替案/反証を検討する」「成果物へ反映する」流れを実証し、その設計方針が docs / Skills / metrics に反映された時点で `done` を検討できる。
|
||||
|
||||
## Decision context
|
||||
|
||||
- ユーザー指摘: 「Memoryシステムが完全に墓場化している」。これは保存量不足ではなく、保存情報が現在の問い・根拠・仮説・成果物に接続されない問題として扱う。
|
||||
- ユーザー指示: Memory システムの設計・検証・検討・考察を Objective にまとめ、より効果的な Memory システムを作成する目標のもとで情報を一元化する。
|
||||
- 「効果的」の定義は未確定だが、当面は Pirolli & Card の sensemaking process に沿って、foraging cost、evidence quality、schema usefulness、hypothesis/disconfirmation support、product impact、staleness handling を評価軸にする。
|
||||
- Memory は durable project authority ではない。Ticket、docs、git history、session logs、明示 user instruction の代替として使わない。
|
||||
- Objective context は判断背景であり、個別実装の authority は各 Ticket body/thread/artifacts と明示的な Ticket relations / OrchestrationPlan records にある。
|
||||
- `history` に残らない context-only injection を改善案にしない。新しい context input は history に commit する原則を守る。
|
||||
- Knowledge は古い unused record kind をそのまま残すのではなく、育てる long-term Markdown note subsystem として再設計する。再利用可能な手順・作法は Agent Skills (`.yoi/skills/<skill>/SKILL.md`) へ、durable policy/rationale は Knowledge / maintained docs / Ticket decisions へ、外部状態 authority は typed feature/tool surface へ分ける。
|
||||
- Generated memory / Ticket / docs / report / Skill の境界を再定義する場合は、authority boundary と migration/staleness を明示する。
|
||||
- 関連する既存 Ticket:
|
||||
- `00001KSKBPHRG` — Prompt / Workflow 評価メトリクスと改善 Offer
|
||||
- `00001KT02TCCG` — Memory prompt: conditional guidance and proactive lookup
|
||||
- `00001KTGCAFXG` — Use .yoi/memory marker for repo-local memory root
|
||||
- `00001KSKBPTHR` — ワークスペースのメモリーをLintするヘッドレスCLI
|
||||
- `00001KXMEZNYC` — ターン中のProgress messageを残す指示を追加する
|
||||
|
||||
## Historical references / prior design sources
|
||||
|
||||
現在の Memory システムの初期設計時には、Codex Memories / Chronicle と HermesAgent を明示的な参考事例として調査していた。関連する調査・設計記録は、現在は主に以下に退避されている。
|
||||
|
||||
- `docs/.local/old-docs/ref/memory-systems.md`
|
||||
- `docs/.local/old-docs/plan/memory.md`
|
||||
- 初期設計 commit: `ca5a3d11` — `2026-04-21 メモリシステムの設計`
|
||||
- 関連 commit:
|
||||
- `0c1276b7` — `Memoryシステムの整理・Promptカタログチケット`
|
||||
- `3d04f793` — `memoryを抽出する仕組みの実装`
|
||||
- `f1b7af62` — `docs: memoryシステムの仕様変更と、動的Tool・VCSの話`
|
||||
- `a2aecbf0` — `update: memoryシステムの"Phase"表記を撤廃`
|
||||
|
||||
### Codex Memories / Chronicle から得た設計要素
|
||||
|
||||
旧設計では、Codex Memories / Chronicle を `extract -> staging -> consolidation -> durable Markdown memory` の非同期パイプラインとして捉えていた。
|
||||
|
||||
主な参照点:
|
||||
|
||||
- extract と consolidation の 2 段構成。
|
||||
- extract は JSON schema / structured output で分類ブレを抑える。
|
||||
- consolidation は reasoning model / agentic rewrite によって、既存 memory と staging entries を統合・整理する。
|
||||
- staging と durable memory を分ける。
|
||||
- `MEMORY.md` は retrieval-oriented handbook として扱う。
|
||||
- `memory_summary.md` は prompt-loaded high-signal context として扱う。
|
||||
- `raw_memories.md` は routing layer / task inventory 的な中間層として扱う。
|
||||
- workspace diff や usage 情報を使い、stale / deleted evidence / noisy entries を整理する。
|
||||
- consolidation は append だけでなく、rewrite / merge / split / trim / drop / cleanup を担う。
|
||||
|
||||
Yoi 初期設計では、これを参考に以下を意図していた。
|
||||
|
||||
- activity token 閾値で extract を発火する。
|
||||
- compact より前に session log range を抽出する。
|
||||
- extract は `decisions`, `discussions`, `attempts`, `requests` などの候補を staging に保存する。
|
||||
- 抽出時点では durable policy / Skill / docs へ早期分類せず、純粋な「起きたこと」に寄せる。
|
||||
- consolidation が summary / decisions / requests と、必要に応じた docs / Skill / Ticket decision 更新候補を整理する。
|
||||
- consolidation 入力に linter warnings / usage metrics / stale cleanup 候補を含める。
|
||||
- stale / superseded / unused / noisy な情報を整理する。
|
||||
|
||||
この Objective での再解釈:
|
||||
|
||||
- Codex の `raw_memories.md` は、Pirolli & Card の sensemaking model では `shoebox` または `evidence file` に近い。
|
||||
- Yoi は extract / consolidation という pipeline だけを継承しても不十分であり、task-bound shoebox / evidence file / hypothesis loop / product feedback がなければ Memory は再び墓場化する。
|
||||
- 特に、staging を consolidation の一時入力としてだけ扱うと、後続 Ticket / Objective / review が使う探索入口にならない。
|
||||
- Yoi では `raw memories` 相当の中間層を、現在の問いに紐づく working set / evidence index として再設計する必要がある。
|
||||
|
||||
### HermesAgent から得た設計要素
|
||||
|
||||
旧設計では、Nous Research HermesAgent を 3 層の memory system として整理していた。
|
||||
|
||||
- Persistent Memory:
|
||||
- `MEMORY.md` / `USER.md`
|
||||
- Markdown + SQLite / FTS5 session search
|
||||
- 起動時 system prompt snapshot
|
||||
- bounded character limits
|
||||
- Skill Library:
|
||||
- procedural memory
|
||||
- `~/.hermes/skills/<name>/SKILL.md`
|
||||
- `skill_manage` tool による agentic CRUD
|
||||
- User Model / Honcho:
|
||||
- dialectic user modeling
|
||||
- 外部 service 連携
|
||||
|
||||
HermesAgent で特に重要だった点:
|
||||
|
||||
- memory / skill review は一定 turn / tool iteration ごとに background agent として起動する。
|
||||
- 保存すべきものがなければ `Nothing to save.` で NOP として終了する。
|
||||
- Yoi extract の「空配列許容」はこの設計からも影響を受けている。
|
||||
- memory は session start 時の frozen snapshot として system prompt に入り、mid-session write で prompt cache を壊さない。
|
||||
- persistent memory は bounded で、limit 超過時は deterministic eviction ではなく、agent に replace / remove を促す。
|
||||
- procedural memory / skills は一般 memory から分離されている。
|
||||
- SQLite FTS5 + LLM summarization による cross-session recall がある。
|
||||
|
||||
この Objective での再解釈:
|
||||
|
||||
- HermesAgent の `MEMORY.md` / `USER.md` / `skills` の分離は、Yoi の Memory / Knowledge / Skills / prompt resources / docs / Ticket decision / generated memory の責務再整理に使える。
|
||||
- Yoi は foreground isolation 自体をすでに持つため、取り入れるべきなのは isolation そのものではなく、Overview を足場にした maintenance / extraction の質改善である。
|
||||
- main Worker が通常 Assistant Message として残す Progress message は、人間向け進捗報告と machine-readable session overview を兼ねられる。
|
||||
- extract worker は専用 read-only evidence tools で必要箇所だけ探索し、direct write ではなく staging に出力する。
|
||||
- reusable procedure, reviewer focus, orchestration tactic, project preference, user preference, design invariant を同じ Memory bucket に入れると墓場化しやすい。
|
||||
- `Nothing to save.` / empty extraction allowed は重要だが、保存抑制だけでは効果的な Memory にはならない。保存されたものが task-bound shoebox / evidence / schema / hypothesis / product に接続される必要がある。
|
||||
- frozen snapshot / prompt cache 配慮は Yoi の history/context 加工原則と整合するが、それだけでは retrieval / resurfacing / disconfirmation は解決しない。
|
||||
|
||||
### Lessons for the next design iteration
|
||||
|
||||
Codex と HermesAgent の調査から、Yoi が継承すべきものと、継承するだけでは足りないものを分ける。
|
||||
|
||||
継承すべきもの:
|
||||
|
||||
- structured extract と agentic consolidation の分離。
|
||||
- staging / raw memories / durable memory の分離。
|
||||
- 保存対象がなければ NOP にする発火設計。
|
||||
- prompt-loaded summary と durable retrieval-oriented memory の分離。
|
||||
- stale / noisy / unused entries の cleanup。
|
||||
- procedural memory と declarative memory の分離。
|
||||
- session search / usage metrics / linter feedback を consolidation に入れる設計。
|
||||
- user / Assistant messages から作る Session Overview を semantic guide にし、tool logs を evidence として探索する設計。
|
||||
|
||||
足りないもの:
|
||||
|
||||
- Pirolli & Card の sensemaking stage における各 record の役割定義。
|
||||
- Ticket / Objective / current question に紐づく task-bound shoebox。
|
||||
- authority record へ戻れる evidence file / provenance / source anchor。
|
||||
- hypothesis, alternative hypothesis, rejected reason, disconfirming evidence の first-class 表現。
|
||||
- reviewer / orchestrator が confirmation bias を避けるための反証探索導線。
|
||||
- resident exposure や read count ではなく product impact を測る metrics。
|
||||
- stale / contradiction / renewal を作業中に resurfacing する導線。
|
||||
|
||||
したがって、次の Memory 設計は Codex / HermesAgent の単純なコピーではなく、以下を満たす必要がある。
|
||||
|
||||
```text
|
||||
external data / sessions / tickets / docs / code
|
||||
-> task-bound shoebox
|
||||
-> evidence file with provenance
|
||||
-> schema / representation
|
||||
-> hypotheses and disconfirmation
|
||||
-> Ticket / review / docs / implementation / decision product
|
||||
-> product impact and stale-feedback metrics
|
||||
```
|
||||
|
||||
この Objective では、以後の Memory 関連 Ticket / report / implementation をこの historical reference と sensemaking model の両方に照らして判断する。
|
||||
@@ -1,916 +0,0 @@
|
||||
---
|
||||
created_at: "2026-07-15T21:33:00Z"
|
||||
updated_at: "2026-07-16T18:20:00Z"
|
||||
objective: "00001KVJSMQXZ"
|
||||
status: "architecture-draft"
|
||||
notes: "Memory / Knowledge / Skills を別々の workspace resource として再設計するための draft architecture。この文書は Objective resource であり、実装 authority ではない。"
|
||||
---
|
||||
|
||||
# Memory / Knowledge / Skills architecture overview
|
||||
|
||||
## 1. Position
|
||||
|
||||
Yoi は **Memory**、**Knowledge**、**Skills** を 1 つの汎用 record store に押し込めず、別々の resource class として扱う。
|
||||
|
||||
最初に固めるべきなのは、成果物が人間に読める形で成長できる workspace resource model である。Pirolli & Card の sensemaking process は有用な背景知識だが、storage taxonomy を shoebox / evidence / hypothesis として先に固定しない。sensemaking は Memory / Knowledge / Skills の上に乗る usage pattern として扱う。
|
||||
|
||||
Target split:
|
||||
|
||||
- **Memory**: 短期 fact、嗜好、現在の focus、進行中の context。変化する前提で書く。
|
||||
- **Knowledge**: 長期的に育てる note。人間と agent が改訂し、相互リンクで mesh を形成し、durable project understanding として読めるもの。
|
||||
- **Skill**: Agent Skills format に従う、移植可能で確立された手順・workflow。
|
||||
|
||||
この文書の中核は次の 2 つである。
|
||||
|
||||
1. resource class の境界を明確にする。
|
||||
2. session から extract / staging / consolidation を経て resource に至る pipeline の責務を明確にする。
|
||||
|
||||
## 2. Design goals
|
||||
|
||||
- 人間が読め、成長できる artifact を作る。
|
||||
- 一時的な model summary だけでは足りない。
|
||||
- 有用な成果は Knowledge note、Skill、Ticket decision、doc、report に育てられるべき。
|
||||
- 揮発的なものと durable なものを分ける。
|
||||
- 短期 context が長期 note を汚染しないようにする。
|
||||
- 長期 note が session extraction のたびに上書きされないようにする。
|
||||
- 手順と note を分ける。
|
||||
- 繰り返し使える作業方法は Knowledge note ではなく Skill にする。
|
||||
- authority boundary を明示する。
|
||||
- Ticket は work authority を定義する。
|
||||
- docs と Objective resources は maintained design context を持つ。
|
||||
- Knowledge notes は育てる project understanding を持つ。
|
||||
- Skills は execution を guide する。
|
||||
- Memory は変化する working context と preferences を追跡する。
|
||||
- typed feature/tool surfaces が external state changes を所有する。
|
||||
- 可能な限り Workspace backend を resource API の共有 authority にする。
|
||||
- `WorkspaceClient::Http` が使えるときに、Worker ごとに local view が分岐してはいけない。
|
||||
|
||||
## 3. Resource model
|
||||
|
||||
### 3.1 Memory
|
||||
|
||||
Memory は、agent が作業を継続する助けになる volatile / short-to-medium-term な情報を扱う。長期的な project truth のふりはしない。
|
||||
|
||||
Memory record に入るもの:
|
||||
|
||||
- 現在の focus。
|
||||
- user preferences。
|
||||
- working assumptions。
|
||||
- authority が別にある recent decisions の要約や pointer。
|
||||
- 進行中の constraints。
|
||||
- あとで Ticket / doc / session を再確認するための reminder。
|
||||
- session から得た observations。
|
||||
- 変化することが前提の personal / workspace context。
|
||||
|
||||
Memory は provisional に書く:
|
||||
|
||||
- いつ / なぜ learned したかを書く。
|
||||
- どの前提で learned したかを必要な範囲で書く。
|
||||
- staleness / supersession を許す。
|
||||
- authoritative records を verbatim にコピーしない。
|
||||
- 可能なら Tickets / docs / Knowledge notes への pointer を優先する。
|
||||
|
||||
Memory は resident context と lightweight lookup には有用だが、permanent note system として最適化しない。
|
||||
|
||||
#### 3.1.1 Memory storage profile: bounded H2 Markdown file
|
||||
|
||||
Memory の初期 storage profile は、bounded な single Markdown file にする。
|
||||
|
||||
Memory は Knowledge のような note bundle ではない。1〜3 行程度の short items を H2 section ごとに並べる resident context surface として扱う。
|
||||
|
||||
初期 filesystem shape:
|
||||
|
||||
```text
|
||||
.yoi/memory/
|
||||
memory.md
|
||||
_staging/
|
||||
_resolutions.jsonl
|
||||
```
|
||||
|
||||
`memory.md` は H2 section を基本単位にする。
|
||||
|
||||
```md
|
||||
# Workspace Memory
|
||||
|
||||
## Current focus
|
||||
|
||||
- Memory extract redesign is focused on Overview-first extraction and staging resolution.
|
||||
Source: Objective 00001KVJSMQXZ. Stale when related tickets close.
|
||||
|
||||
## Preferences
|
||||
|
||||
- User prefers implementation Tickets, not design-only Tickets.
|
||||
Source: 2026-07-16 session.
|
||||
|
||||
## Working assumptions
|
||||
|
||||
- Knowledge should be OKF-compatible, while volatile Memory and staging should not be OKF.
|
||||
Source: architecture objective.
|
||||
|
||||
## Reminders
|
||||
|
||||
- Re-check extract/consolidation prompts after staging resolution is implemented.
|
||||
```
|
||||
|
||||
Recommended H2 sections:
|
||||
|
||||
- `## Current focus`
|
||||
- `## Preferences`
|
||||
- `## Working assumptions`
|
||||
- `## Constraints`
|
||||
- `## Reminders`
|
||||
- `## Stale or superseded`
|
||||
|
||||
Each item should stay short. When useful, include `Source` and `Stale when` inline. Long explanations, evidence-heavy analysis, durable rationale, citations, and cross-linked concepts should be routed to Knowledge rather than expanded inside Memory.
|
||||
|
||||
The single-file layout is an initial storage profile, not an API contract. Workers, Web, Runtime, and CLI should use the Workspace Memory API view rather than depending on the exact file layout, so storage can later split or evolve without changing the model-visible contract.
|
||||
|
||||
#### Memory examples
|
||||
|
||||
```text
|
||||
User preference: prefers direct commits only when explicitly requested.
|
||||
Source: repeated user corrections in sessions around git operations.
|
||||
Staleness: revisit if user changes repo workflow.
|
||||
```
|
||||
|
||||
```text
|
||||
Current focus: web Workspace console and Workspace-backed Ticket/Skill authority.
|
||||
Source: recent Tickets and Objective updates.
|
||||
Expected to change after current milestone.
|
||||
```
|
||||
|
||||
### 3.2 Knowledge
|
||||
|
||||
Knowledge は long-term note system。人間と agent が育て、改訂し、link し、split / merge しながら読むもの。抽出 snippet の山ではなく、project understanding の mesh を形成する。
|
||||
|
||||
Target Knowledge は、古い未使用の Knowledge feature をそのまま残すものではない。legacy implementation は先に削除してよい。置き換えは proper workspace note subsystem として設計する。
|
||||
|
||||
Knowledge notes の要件:
|
||||
|
||||
- Markdown-first で人間が読める。
|
||||
- OKF-compatible な concept document として扱える。
|
||||
- stable path / slug を持ち、OKF concept ID として参照できる。
|
||||
- Yoi 内部で move / rename に強い identity が必要な場合は、extension frontmatter として `yoi_id` を持てる。
|
||||
- bidirectional links / backlinks を support する。
|
||||
- Obsidian-style wiki links (`[[slug]]`, `[[slug|label]]`) を support し、Knowledge mesh の authoring shorthand として使える。
|
||||
- 必要なら tags や typed relations を support する。
|
||||
- 重要な claim には provenance / citations を残す。
|
||||
- Tickets、Objectives、docs、commits、reports、Skills、他 Knowledge notes に link できる。
|
||||
- review / staleness / supersession を support する。
|
||||
- 自動生成だけに頼らず、意図的に maintain される。
|
||||
|
||||
Knowledge は、長期 architecture note、conceptual model、subsystem explanation、decision context、recurring constraints、domain understanding を育てる場所である。
|
||||
|
||||
#### 3.2.1 Knowledge format profile: OKF-compatible bundle
|
||||
|
||||
Yoi Knowledge は、可能な限り Open Knowledge Format (OKF) compatible な bundle として設計する。
|
||||
|
||||
OKF から採用する baseline:
|
||||
|
||||
- Knowledge bundle は Markdown file tree とする。
|
||||
- non-reserved `.md` file は concept document とする。
|
||||
- concept document は YAML frontmatter + Markdown body とする。
|
||||
- path without `.md` を OKF concept ID として扱う。
|
||||
- `type` は required field とする。
|
||||
- `title`, `description`, `resource`, `tags`, `timestamp` は recommended field とする。
|
||||
- normal Markdown links を OKF-compatible graph edges として扱う。
|
||||
- Obsidian-style wiki links (`[[slug]]`, `[[slug|label]]`) も graph edges として扱い、Markdown links へ解決・export できるようにする。
|
||||
- `index.md` は progressive disclosure のための directory listing として使える。
|
||||
- `log.md` は agent-readable update history として使える。
|
||||
- `# Citations` section は external source / authority reference を示す convention として使う。
|
||||
- consumers は unknown frontmatter fields、unknown `type`、broken links を tolerant に扱う。
|
||||
|
||||
Yoi は OKF に extension frontmatter を足してよい。候補:
|
||||
|
||||
```yaml
|
||||
yoi_id: 00001...
|
||||
status: draft | active | stale | superseded
|
||||
source_refs: []
|
||||
authority_refs: []
|
||||
objective_refs: []
|
||||
ticket_refs: []
|
||||
skill_refs: []
|
||||
reviewed_at: 2026-07-16T00:00:00Z
|
||||
staleness: "Revisit when ..."
|
||||
supersedes: []
|
||||
superseded_by: null
|
||||
```
|
||||
|
||||
Filesystem shape の例:
|
||||
|
||||
```text
|
||||
.yoi/knowledge/
|
||||
index.md
|
||||
log.md
|
||||
architecture/
|
||||
index.md
|
||||
memory-architecture.md
|
||||
workspace-authority.md
|
||||
references/
|
||||
pirolli-card-2005-sensemaking.md
|
||||
```
|
||||
|
||||
OKF compatibility は Knowledge の exchange / storage profile であり、Memory / staging / Skill の format ではない。
|
||||
|
||||
- Memory は短期・変化前提の resident context store なので OKF にしない。
|
||||
- staging は extract / consolidation の審査キューなので OKF にしない。
|
||||
- Skill は Agent Skills format を維持する。OKF `type: Playbook` に吸収しない。
|
||||
|
||||
#### Knowledge examples
|
||||
|
||||
- `workspace-authority-model`
|
||||
- Workspace backend が Tickets / Skills / Runtime views の authority である理由を説明する。
|
||||
- Ticket backend API design、Skill support ticket、Workspace control plane Objective に link する。
|
||||
- `memory-knowledge-skill-boundary`
|
||||
- Memory / Knowledge / Skills の boundary を定義する。
|
||||
- この architecture resource と将来の implementation Tickets に link する。
|
||||
- `ticket-lifecycle-authority`
|
||||
- Ticket state authority と transition graph の rationale を説明する。
|
||||
- 関連 decisions と code locations に link する。
|
||||
|
||||
### 3.3 Skill
|
||||
|
||||
Skill は、ある種類の task に対して確立された、portable な workflow / procedure。Agent Skills format に従う。
|
||||
|
||||
```text
|
||||
.yoi/skills/<skill-name>/
|
||||
SKILL.md
|
||||
scripts/
|
||||
references/
|
||||
assets/
|
||||
```
|
||||
|
||||
Skill は state machine ではなく、external authority も所有しない。agent が available tools を使って task をどう実行するかを示す prompt/resource guidance である。
|
||||
|
||||
Skill に含めるもの:
|
||||
|
||||
- いつその Skill を使うか。
|
||||
- step-by-step procedure。
|
||||
- expected inputs。
|
||||
- expected outputs / report shape。
|
||||
- examples。
|
||||
- edge cases。
|
||||
- optional references / scripts / assets。
|
||||
|
||||
Skill は、project-specific assumptions が少なく、別 workspace に移しても使えるとき portable と言える。
|
||||
|
||||
#### Skill examples
|
||||
|
||||
- `coder-review-cycle`
|
||||
- Coder が実装、検証、review request、feedback 対応、dossier 作成をどう行うか。
|
||||
- `ticket-intake`
|
||||
- 曖昧な user request を accepted Ticket requirements に変換する方法。
|
||||
- `architecture-review`
|
||||
- design proposals、alternatives、authority boundaries を評価する方法。
|
||||
|
||||
## 4. Resource boundaries and authority
|
||||
|
||||
### 4.1 Memory vs Knowledge
|
||||
|
||||
Memory は provisional / change-oriented。Knowledge は maintained / growth-oriented。
|
||||
|
||||
Memory を使うべきとき:
|
||||
|
||||
- 情報が短命。
|
||||
- preference や working assumption である。
|
||||
- resident context として有用。
|
||||
- 長期的な置き場所がまだ明確でない。
|
||||
|
||||
Knowledge を使うべきとき:
|
||||
|
||||
- 時間をかけて読み直し、改訂するべき情報。
|
||||
- durable project concept を説明する情報。
|
||||
- 複数の future tasks から link されるべき情報。
|
||||
- backlinks / mesh structure が有用な note。
|
||||
- 人間が project understanding として browse できるべきもの。
|
||||
|
||||
Promotion path:
|
||||
|
||||
```text
|
||||
Memory observation -> candidate note/update -> Knowledge note / docs / Ticket decision
|
||||
```
|
||||
|
||||
Promotion は明示的に行う。すべての Memory item が Knowledge になるわけではない。
|
||||
|
||||
### 4.2 Knowledge vs Docs
|
||||
|
||||
Docs は public / project-facing な maintained exposition。Knowledge は internal で、link され、発展する understanding。
|
||||
|
||||
Knowledge note は後で doc になり得るが、threshold は違う:
|
||||
|
||||
- Knowledge は uncertainty、partial models、evidence links を含められる。
|
||||
- Docs は settled explanations または user/developer guidance を提示するべき。
|
||||
|
||||
### 4.3 Knowledge vs Ticket decisions
|
||||
|
||||
Ticket decisions は work item history と state の authority。Knowledge notes は複数 Ticket をまたいだ synthesis。
|
||||
|
||||
ある decision が Ticket の requirement、state、acceptance criteria を変えるなら、それは Ticket に記録する。Knowledge はそこに link し、より広い pattern を説明できる。
|
||||
|
||||
### 4.4 Skill vs Knowledge
|
||||
|
||||
Knowledge は「何が true か」「project をどう理解するか」を説明する。Skill は「recurring task をどう実行するか」を説明する。
|
||||
|
||||
Skill を使うべきとき:
|
||||
|
||||
- review process。
|
||||
- implementation process。
|
||||
- release checklist。
|
||||
- architecture evaluation method。
|
||||
|
||||
Knowledge を使うべきとき:
|
||||
|
||||
- Workspace authority model。
|
||||
- Memory architecture。
|
||||
- Ticket lifecycle rationale。
|
||||
|
||||
### 4.5 Skill vs Feature/Plugin
|
||||
|
||||
Skill は prompt/resource guidance。Feature/Plugin は executable authority と tool surface。
|
||||
|
||||
Skill は「何をどう進めるか」を書けるが、Ticket、Workspace、Memory、外部状態を変更する権限そのものは持たない。その権限は Feature/Plugin や typed tool/API が持つ。
|
||||
|
||||
例: Skill は「review 前に Ticket shoebox を作る」と指示できる。実際に作成する authority は Workspace / Memory feature の typed tool/API が提供する。
|
||||
|
||||
## 5. Workspace API authority
|
||||
|
||||
Target architecture は Workspace-backed にする。
|
||||
|
||||
### 5.1 Memory API
|
||||
|
||||
Workspace backend が最終的に提供するもの:
|
||||
|
||||
- Memory list / search / read / write / edit / delete。
|
||||
- resident memory summary の生成または取得。
|
||||
- staleness / supersession markers。
|
||||
- sessions や artifacts からの Memory candidate proposal。
|
||||
- provenance と audit events。
|
||||
- preference / current-focus surfaces。
|
||||
|
||||
移行期間中は local `.yoi/memory` を compatibility / offline storage として残してよい。初期 storage profile は H2 section ベースの bounded `.yoi/memory/memory.md` だが、これは API contract ではない。
|
||||
|
||||
### 5.2 Knowledge API
|
||||
|
||||
Workspace backend は、raw filesystem layout を唯一の interface にするのではなく、proper note API を提供する。
|
||||
|
||||
- Knowledge catalog / list / search。
|
||||
- note read / write / edit / delete。
|
||||
- OKF-compatible frontmatter validation / normalization。
|
||||
- link / backlink extraction from Markdown links and wiki links (`[[slug]]`)。
|
||||
- relation / tag metadata。
|
||||
- staleness / supersession markers。
|
||||
- note diagnostics / lint。
|
||||
- source / provenance refs and citations。
|
||||
- Markdown files からの import / export。
|
||||
- OKF bundle import / export profile。
|
||||
|
||||
Filesystem representation は `.yoi/knowledge/` 配下の OKF-compatible bundle を第一候補にする。ただし Worker / Runtime / Web / CLI は、利用可能なら raw filesystem ではなく Workspace API view に収束する。
|
||||
|
||||
### 5.3 Skill API
|
||||
|
||||
Skill support は separate Skill Ticket の方針に従う。
|
||||
|
||||
- Workspace backend が discovery / lint / catalog / activation を所有する。
|
||||
- `.yoi/skills/<skill>/SKILL.md` は workspace storage convention。
|
||||
- `WorkspaceClient::Http` が使えるとき、Workers は Workspace API から Skill metadata / body を使う。
|
||||
- Skill references / assets は backend-resolved authority または Skill resource APIs 経由で access する。
|
||||
|
||||
## 6. Session-to-resource pipeline
|
||||
|
||||
この章が extract 改善の中心である。`extract -> staging -> consolidate` の分割は維持する。3 つは同じ memory maintenance pipeline の一部だが、判断の種類が違う。
|
||||
|
||||
```text
|
||||
Session history
|
||||
-> Overview + Evidence index
|
||||
-> extract
|
||||
-> staging
|
||||
-> consolidation
|
||||
-> Memory / Knowledge candidate / Skill candidate / Ticket-doc candidate / discard
|
||||
```
|
||||
|
||||
役割の要約:
|
||||
|
||||
- **extract**: session から候補を拾う。recall 寄り。Memory 化はしない。
|
||||
- **staging**: provenance 付き候補キュー。まだ Memory ではない。
|
||||
- **consolidation**: staging を審査・剪定・統合する。precision 寄り。Memory 肥大化と陳腐化を防ぐ。
|
||||
|
||||
### 6.1 Overview: transcript as semantic backbone
|
||||
|
||||
Overview は、committed history にある user messages と normal Assistant text outputs から作る。Assistant text outputs には、最終応答だけでなく、長い作業中の Progress message も含める。
|
||||
|
||||
Progress message は専用 Tool ではなく、ordinary user-visible prose response として残す。Tool surface を増やさず、ユーザーへの進捗報告と extract 用 semantic summary を兼ねるためである。
|
||||
|
||||
Overview に含めるもの:
|
||||
|
||||
- user requests / corrections / approvals。
|
||||
- Assistant progress messages。
|
||||
- Assistant final responses。
|
||||
- parent delegation や Ticket context のように、history に commit された task context。
|
||||
- tool evidence への bounded index。
|
||||
|
||||
Overview に含めないもの:
|
||||
|
||||
- raw reasoning / chain-of-thought。
|
||||
- raw tool-result content 全文。
|
||||
- secret-like data。
|
||||
- history に commit されていない hidden context injection。
|
||||
|
||||
Overview の意図は、extract worker に「何のための探索だったか」「どの判断が節目だったか」「どこが未解決か」を伝えることである。Overview は authority ではない。durable output には evidence と source anchors が必要である。
|
||||
|
||||
### 6.2 Extract: candidate generation
|
||||
|
||||
Extract は candidate generation であり、Memory 化ではない。runtime 側の extract worker が Overview と session evidence を読んで、後で記憶化を検討すべき **flat candidate records** を staging に出す。
|
||||
|
||||
Extract の責務:
|
||||
|
||||
- Overview を読んで、記憶化を検討すべき candidate を見つける。
|
||||
- 必要な箇所だけ evidence tools で確認する。
|
||||
- candidate ごとに bounded evidence snippets / source anchors を選ぶ。
|
||||
- candidate ごとに `stage_candidate` を呼び、1 candidate = 1 staging record として保存する。
|
||||
- 最後に `finish_extraction` を呼び、staged count または NOP reason を残す。
|
||||
|
||||
Extract がしてはいけないこと:
|
||||
|
||||
- Memory / Knowledge / Skill / Ticket / docs を直接変更しない。
|
||||
- batch payload として複数 candidate を 1 staging record にまとめない。
|
||||
- tool result 全文や reasoning を無制限に取り込まない。
|
||||
- local slice だけから根拠を推測しない。
|
||||
- tool call chronology、generic progress、current focus update を抽出しない。
|
||||
- 「進捗報告があった」こと自体を Memory 化しない。
|
||||
|
||||
Staging candidate は、Consolidation がそれ単体で discard / merge / promote / defer を判断できる最小単位にする。
|
||||
|
||||
旧 `decisions` / `discussions` / `attempts` / `requests` batch schema は維持しなくてよい。互換性よりも、flat candidate records と clear responsibility を優先する。
|
||||
|
||||
#### 6.2.1 Extract candidate taxonomy
|
||||
|
||||
Extract が抽出する対象は activity log ではない。抽出対象は次の candidate kinds に絞る。
|
||||
|
||||
| kind | extract で見る観点 | consolidation での扱い |
|
||||
| --- | --- | --- |
|
||||
| `preference` | ユーザーまたは workspace の継続的な好み・作法。単発指示ではなく、今後の agent behavior に効くもの。 | Memory に短く merge / replace する候補。既存 preference と重複するなら統合。Ticket/docs の要件そのものなら mirror せず authority link へ。曖昧なら discard / defer。 |
|
||||
| `working_assumption` | 現時点で仮に置いている設計・実装前提。future work に影響し、変更条件や反証条件があり得るもの。 | Memory に入れる場合は short-lived assumption として stale condition 必須。長期設計なら Knowledge candidate。すでに authority record に反映済みなら Memory には pointer だけ、または discard。 |
|
||||
| `constraint` | 今後守るべき境界・禁止・invariant。実装や review でチェック可能なもの。 | active implementation に効くなら Memory。durable policy なら Ticket decision / Knowledge / docs candidate。破られた既存 Memory があれば mark_stale / replace。 |
|
||||
| `decision` | alternatives / chosen / rationale がある判断。単なる事実確認や作業進行ではないもの。 | まず authority routing を判断する。Ticket 要件・state・acceptance に関係するなら Ticket decision/comment candidate。長期設計なら Knowledge/Objective candidate。短期実装判断だけ Memory に短く置く。会話中の一時結論なら discard。 |
|
||||
| `open_question` | 未解決で後続作業に影響する問い。next action が書けるもの。単なる会話中の疑問ではない。 | Memory reminder にするか、Ticket follow-up / planning item に送る。解決済みなら discard。長く残る概念的問いなら Knowledge candidate。TTL / defer reason を持たせる。 |
|
||||
| `lesson` | 検証・失敗・試行から得た再利用価値のある学び。同じ失敗を避ける、作業方法を改善する、Skill 化できる可能性があるもの。 | Memory に短く残すか、recurring / portable なら Skill candidate。単なる tool execution result は discard。validation evidence として Ticket/report に残すべきものは authority link。 |
|
||||
|
||||
抽出しないもの:
|
||||
|
||||
- `current_focus` update。これは resident summary surface であり、extract candidate kind ではない。
|
||||
- tool call chronology。
|
||||
- file read/write history。
|
||||
- generic progress updates。
|
||||
- one-off chit-chat。
|
||||
- resolved local confusion。
|
||||
- assistant self-corrections without durable consequence。
|
||||
- authoritative Ticket/docs/git facts copied verbatim。
|
||||
- validation results unless they imply a reusable lesson, active blocker, or authority evidence。
|
||||
- implementation details that belong only in commit diff。
|
||||
|
||||
### 6.3 Extract worker tools
|
||||
|
||||
extract worker には `session-explore` feature の constrained tools だけを渡す。これは main Worker の tool 数を増やすものではない。
|
||||
|
||||
Tool surface:
|
||||
|
||||
- `search_evidence`: session snapshot / tool summaries / message index から query で候補 range を探す。
|
||||
- `read_evidence`: bounded な session entry range、tool call/result summary、host が許す bounded excerpt を読む。
|
||||
- `stage_candidate`: 1 candidate を staging record として保存する。
|
||||
- `finish_extraction`: extract run を終了し、staged count または NOP reason を記録する。
|
||||
|
||||
`stage_candidate` は direct Memory write ではない。これは staging write だけを行う output tool である。
|
||||
|
||||
`stage_candidate` input の概念形:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "decision",
|
||||
"claim": "Overview is runtime-only extract projection, not staging data.",
|
||||
"why_useful": "Clarifies extract/consolidate responsibility boundary.",
|
||||
"staleness": "Revisit if Workspace can directly explore runtime sessions.",
|
||||
"evidence_ids": ["E001", "E002"]
|
||||
}
|
||||
```
|
||||
|
||||
Host wrapper は `evidence_ids` を runtime reference environment から解決し、bounded evidence snippets と source anchors を staging record に機械的に付与する。LLM に自由な source anchor を書かせない。
|
||||
|
||||
`finish_extraction` の概念形:
|
||||
|
||||
```json
|
||||
{
|
||||
"staged_count": 0,
|
||||
"reason": "Only local progress and tool chronology; no durable candidates."
|
||||
}
|
||||
```
|
||||
|
||||
候補が無い場合は `stage_candidate` を呼ばず、`finish_extraction` で NOP を明示する。tool call なし終了も host 側では NOP fallback として扱ってよいが、基本は `finish_extraction` を要求する。
|
||||
|
||||
制約:
|
||||
|
||||
- read-only evidence access。file write、Ticket mutation、Memory/Knowledge/Skill direct write は持たせない。
|
||||
- bounded output。large tool result は summary / excerpt / pointer に留める。
|
||||
- provenance first。extract worker が根拠を推測せず、読んだ evidence ids を `stage_candidate` に渡す。
|
||||
|
||||
### 6.4 Staging: flat provenance-backed candidate records
|
||||
|
||||
Staging は Memory ではない。Staging は、extract が切り出した candidate を provenance 付きで保管し、consolidation が後で審査できるようにする queue である。
|
||||
|
||||
Staging は flat records にする。
|
||||
|
||||
```text
|
||||
1 extract run = 0..N staging records
|
||||
1 staging record = 1 candidate = 1 consolidation decision unit
|
||||
```
|
||||
|
||||
1 record の概念形:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 2,
|
||||
"id": "stg_...",
|
||||
"extract_run_id": "er_...",
|
||||
"source": {
|
||||
"segment_id": "segment-1",
|
||||
"range": [120, 180]
|
||||
},
|
||||
"kind": "constraint",
|
||||
"claim": "Extract worker must not direct-write Memory/Knowledge/Skill; it only writes staging.",
|
||||
"why_useful": "Preserves runtime/embedded responsibility boundary.",
|
||||
"staleness": "Revisit if extract and consolidation move into the same Workspace execution context.",
|
||||
"evidence": [
|
||||
{
|
||||
"id": "E001",
|
||||
"kind": "message",
|
||||
"entry_range": [132, 133],
|
||||
"excerpt": "extract worker は Memory / Knowledge / Skill を直接更新しない",
|
||||
"summary": "User and architecture discussion fixed staging-only extract boundary."
|
||||
}
|
||||
],
|
||||
"source_refs": [
|
||||
{
|
||||
"evidence_id": "E001",
|
||||
"evidence_kind": "message",
|
||||
"entry_range": [132, 133]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Staging の責務:
|
||||
|
||||
- candidate kind / claim / hints を保持する。
|
||||
- extract が選んだ bounded evidence snippets を保持する。
|
||||
- source anchors を保持する。
|
||||
- extract と consolidation を decouple する。
|
||||
- duplicate / defer / discard / consumed の追跡対象になる。
|
||||
- crash / cancel / long-running work の途中でも、後から審査できる候補を残す。
|
||||
|
||||
Staging がしてはいけないこと:
|
||||
|
||||
- Memory として resident context に直接入らない。
|
||||
- Knowledge note の代替にならない。
|
||||
- raw session log や Overview 全体の保存場所にならない。
|
||||
- extract run 単位の batch file として複数 candidate を抱え込まない。
|
||||
- staging entry をすべて Memory 化する前提にしない。
|
||||
|
||||
Pirolli & Card 的には、staging は shoebox / evidence file に近い。ただし Yoi の storage taxonomy そのものを shoebox にするのではなく、審査前の evidence-backed candidate queue として扱う。
|
||||
|
||||
### 6.5 Staging resolution: the important filter
|
||||
|
||||
Staging から Memory 化する段階が、Memory 肥大化と陳腐化を防ぐ中核 filter である。
|
||||
|
||||
Consolidation は staging entry ごとに resolution / disposition を決めるべきである。
|
||||
|
||||
Disposition action 候補:
|
||||
|
||||
- `discard`: 保存価値なし。discard reason を残す。
|
||||
- `merge_memory`: 既存 Memory に統合する。
|
||||
- `replace_memory`: 古い Memory を新しい内容で置換する。
|
||||
- `mark_memory_stale`: 既存 Memory が古くなったことを記録する。
|
||||
- `delete_memory`: 邪魔または誤った Memory を削除する。
|
||||
- `defer`: 価値判断できないため短期保留する。TTL / defer reason を持つ。
|
||||
- `promote_to_knowledge_candidate`: long-term note に育てる候補へ送る。
|
||||
- `promote_to_skill_candidate`: recurring / portable procedure の候補へ送る。
|
||||
- `link_to_authority`: Ticket / doc / commit / Objective への pointer だけ残す。
|
||||
- `create_ticket_or_doc_candidate`: authority / public guidance にすべき候補として送る。
|
||||
|
||||
Resolution に残すべき情報:
|
||||
|
||||
- staging entry id。
|
||||
- action。
|
||||
- reason。
|
||||
- source anchors。
|
||||
- target record / candidate destination。
|
||||
- consolidation run id / consumed_by。
|
||||
- reviewed_at。
|
||||
- discard / defer / stale reason。
|
||||
|
||||
Consumed staging を削除する場合も、resolution は残す。これにより「何を Memory にしなかったか」が改善材料として残る。
|
||||
|
||||
### 6.6 Consolidation: memoryization, routing, and gardening
|
||||
|
||||
Consolidation は precision-oriented pass である。staging を読んで、Memory にするか、別 resource candidate に送るか、捨てるかを決める。
|
||||
|
||||
Consolidation の責務:
|
||||
|
||||
- staging entry を candidate kind ごとの扱いに沿って評価する。
|
||||
- Memory 化するなら usefulness / staleness / source を要求する。
|
||||
- 既存 Memory と merge / replace / mark stale / delete する。
|
||||
- decision / constraint / lesson などを authority / Knowledge / Skill / Ticket / docs candidate に routing する。
|
||||
- discard reason / defer reason を残す。
|
||||
- linter feedback / usage evidence / tidy hints を使う。
|
||||
- Memory bloat と stale records を防ぐ。
|
||||
|
||||
Consolidation がしてはいけないこと:
|
||||
|
||||
- staging entry を無条件に append しない。
|
||||
- Ticket / docs / git / session log の mirror を Memory に作らない。
|
||||
- Knowledge / Skill / docs を background review だけで無制限に rewrite しない。
|
||||
- source / provenance のない claim を durable Memory にしない。
|
||||
|
||||
Memory 化に必要な minimum fields / prose:
|
||||
|
||||
```text
|
||||
content: 何を覚えるか
|
||||
why_useful: 今後なぜ役立つか
|
||||
source: staging / evidence anchor
|
||||
staleness: 何が起きたら古くなるか
|
||||
destination_reason: なぜ Knowledge / Skill / Ticket / docs ではなく Memory なのか
|
||||
```
|
||||
|
||||
Consolidation は append worker ではなく garden worker である。新規作成より、既存 record の統合・置換・陳腐化・削除を優先する。
|
||||
|
||||
### 6.7 Trigger policy
|
||||
|
||||
発火単位は LLM call 単位にしない。LLM call 単位では文脈が薄く、断片的な extraction になりやすい。
|
||||
|
||||
初期方針:
|
||||
|
||||
- 現行通り、Worker run cycle が完了してから threshold を判定し、超えていれば extract を発火する。
|
||||
- extract 開始時点で immutable snapshot / Overview projection / Evidence index を作る。
|
||||
- LLM call ごとには発火しない。
|
||||
- Run 中の Overview accumulation trigger / mid-run extract は初期実装に含めない。
|
||||
- 将来 long-running 中に mid-run 発火を入れる場合も、direct update ではなく staging/checkpoint extraction に限定する。
|
||||
|
||||
この trigger は、まず既存の post-run memory job model を保ち、extract の入力品質と staging record 粒度の改善に集中する。
|
||||
|
||||
## 7. Sensemaking interpretation
|
||||
|
||||
Sensemaking は storage taxonomy ではなく、pipeline の見方として使う。
|
||||
|
||||
Pirolli & Card の flow を Yoi に対応させるとこうなる:
|
||||
|
||||
```text
|
||||
External data sources
|
||||
= session log, tool results, Tickets, docs, code, web refs
|
||||
|
||||
Shoebox
|
||||
= Overview + Evidence index + selected candidate ranges
|
||||
|
||||
Evidence file
|
||||
= source anchors 付き staging entries
|
||||
|
||||
Schemas / hypotheses
|
||||
= Memory 化すべきか、Knowledge/Skill に送るべきか、stale かの判断
|
||||
|
||||
Product
|
||||
= Memory update, Knowledge candidate, Skill candidate,
|
||||
Ticket/doc update, review evidence, discard resolution
|
||||
```
|
||||
|
||||
重要なのは、Memory 化を evidence から product へ進める審査の一形態として扱うこと。Memory record は「将来の作業に効く」という hypothesis なので、why_useful、staleness、source が必要である。
|
||||
|
||||
初期 sensemaking support は lightweight でよい:
|
||||
|
||||
- task-bound collected references as Ticket/Objective artifacts。
|
||||
- provenance 付き evidence summaries。
|
||||
- review Skills における explicit contradictory evidence sections。
|
||||
- recurring patterns を synthesize する Knowledge notes。
|
||||
- staging resolution に discard / defer / promote reason を残す。
|
||||
|
||||
## 8. Human-readable growth paths
|
||||
|
||||
中核要件は、有用な material が人間に読める形へ成長できること。
|
||||
|
||||
Typical paths:
|
||||
|
||||
```text
|
||||
Session observation
|
||||
-> staging candidate
|
||||
-> Memory record
|
||||
-> Knowledge note candidate
|
||||
-> Knowledge note with links/backlinks
|
||||
-> maintained doc or Ticket decision if it becomes authority
|
||||
```
|
||||
|
||||
```text
|
||||
Repeated successful procedure
|
||||
-> staging candidate / Memory observation / Ticket comment
|
||||
-> Skill candidate
|
||||
-> `.yoi/skills/<skill>/SKILL.md`
|
||||
-> builtin or shared Skill if portable
|
||||
```
|
||||
|
||||
```text
|
||||
Design discussion
|
||||
-> Objective resource
|
||||
-> Knowledge note synthesis
|
||||
-> implementation Tickets
|
||||
-> docs after stabilization
|
||||
```
|
||||
|
||||
Architecture は、これらの promotion を explicit and reviewable にする。
|
||||
|
||||
## 9. External reference lessons
|
||||
|
||||
### 9.1 Current Yoi pipeline
|
||||
|
||||
現在の Yoi extraction は activity-log pipeline:
|
||||
|
||||
- `build_extract_input` が conversation slice を flat Markdown として render する。
|
||||
- user / assistant text は保持する。
|
||||
- tool-call names を含める。
|
||||
- raw tool-result content ではなく tool-result summaries のみを含める。
|
||||
- reasoning は落とす。
|
||||
- extract worker の tool は `write_extracted` 1 つだけ。
|
||||
- `write_extracted` は `decisions`、`discussions`、`attempts`、`requests` を持つ structured `ExtractedPayload` を 1 件受け取る。
|
||||
- LLM は provenance を作らない。Worker が `StagingRecord` を書くときに `source` を機械的に付与する。
|
||||
- empty payload は valid で、no-op として扱える。
|
||||
- consolidation は後で staging entries、full current Memory records、usage evidence、tidy hints を consume する。
|
||||
- consolidation は Memory tools 経由で write し、linter feedback に対応し、record を merge / replace し、outdated / superseded / unused / noisy records を clean up する。
|
||||
|
||||
残す価値がある点:
|
||||
|
||||
- foreground 応答生成から memory maintenance が隔離されている。
|
||||
- provenance が host 側で機械付与される。
|
||||
- extract が direct write せず staging を挟む。
|
||||
- empty / no-op が許される。
|
||||
- consolidation が linter feedback と tidy hints を使う。
|
||||
|
||||
変えるべき点:
|
||||
|
||||
- flat slice ではなく Overview-first / Evidence-index-second にする。
|
||||
- extract worker に read-only evidence tools を持たせる。
|
||||
- staging を一時バッファではなく審査キューとして扱う。
|
||||
- consolidation に entry-level disposition / resolution を要求する。
|
||||
- legacy Knowledge as generated memory 前提を外す。
|
||||
|
||||
### 9.2 HermesAgent
|
||||
|
||||
HermesAgent は background review により、conversation snapshot から Memory / Skill update を直接判断する。
|
||||
|
||||
参考になる点:
|
||||
|
||||
- maintenance を foreground interaction から隔離する。
|
||||
- 保存すべきものがなければ NOP にする。
|
||||
- Memory と Skill を分ける。
|
||||
- prompt snapshot / drift / injection guard を重視する。
|
||||
|
||||
そのまま採用しない点:
|
||||
|
||||
- direct write-only review を主経路にしない。
|
||||
- aggressive Skill update bias を避ける。
|
||||
- `MEMORY.md` / `USER.md` two-file model を Yoi 全体の architecture にしない。
|
||||
|
||||
Yoi では、Hermes 的な direct maintenance は bounded Memory update lane では参考になるが、extract worker 自体は direct write しない。
|
||||
|
||||
### 9.3 Codex
|
||||
|
||||
Codex は session / rollout を durable source として扱い、background pipeline が後から claim / extract / consolidate する。
|
||||
|
||||
参考になる点:
|
||||
|
||||
- durable source。
|
||||
- phase separation。
|
||||
- claim / lease / retry / global lock。
|
||||
- workspace diff / baseline guard。
|
||||
- extraction と consolidation の分離。
|
||||
|
||||
Yoi では、Codex 的な durable job / phase pipeline は staging resolution と consolidation scheduling に取り入れる価値がある。ただし、この architecture phase ではまず Overview-first extract と staging resolution を優先する。
|
||||
|
||||
## 10. Implementation posture
|
||||
|
||||
現在の実装は redesign してよい。既存の Memory / Knowledge shape があるからという理由で残さない。
|
||||
|
||||
ただし big-bang rewrite は避ける。この architecture が accepted されてから分割する。
|
||||
|
||||
Recommended implementation sequence:
|
||||
|
||||
1. **Knowledge removal 後の current Memory を clarify する**
|
||||
- short-term / resident Memory を維持する。
|
||||
- Memory が Knowledge を代替しなければならない、という前提を外す。
|
||||
- legacy `knowledge/*` を generated memory として扱う stale consolidation prompt language を削除する。
|
||||
|
||||
2. **Progress message guidance を追加する**
|
||||
- 長い作業や tool loop の節目で、main Worker が ordinary user-visible prose response として短い Progress message を残す。
|
||||
- 専用 Tool は追加しない。
|
||||
- Progress message は public に見せられる作業状態、確認済み事実、判断、未解決点、次の作業に限定する。
|
||||
|
||||
3. **Overview-first extract input を実装する**
|
||||
- user messages + Assistant text outputs を semantic Overview として優先する。
|
||||
- tool calls / tool results は Evidence index として分離する。
|
||||
- committed history だけから Overview を作り、hidden context injection を避ける。
|
||||
|
||||
4. **Extract worker 専用 evidence tools と staging output tools を実装する**
|
||||
- extract worker に read-only Evidence search / Evidence read を渡す。
|
||||
- main Worker の tool surface は増やさない。
|
||||
- output tools は `stage_candidate` / `finish_extraction` にする。
|
||||
- `stage_candidate` は 1 candidate = 1 flat staging record を書く。
|
||||
- source range と candidate record を結びつけられる schema / staging format を実装する。
|
||||
|
||||
5. **Staging-first extract を維持する**
|
||||
- extract worker は direct Memory / Knowledge / Skill write をしない。
|
||||
- overview accumulation / evidence growth / run or task boundary で extract を予約する。
|
||||
- mid-run 発火を入れる場合も staging/checkpoint extraction に限定する。
|
||||
|
||||
6. **Staging resolution / disposition を実装する**
|
||||
- staging entry ごとに discard / merge / replace / stale / delete / defer / promote / link を記録する。
|
||||
- consumed staging を削除する前に resolution log / archive を残す。
|
||||
- discard reason と defer reason を extract prompt 改善に使えるようにする。
|
||||
|
||||
7. **Target Knowledge note model を設計する**
|
||||
- OKF-compatible Markdown concept document / bundle profile。
|
||||
- Yoi extension frontmatter。
|
||||
- link / backlink model。
|
||||
- provenance / citations / staleness metadata。
|
||||
- Workspace API surface。
|
||||
- reviewable Knowledge updates の candidate / staging format。
|
||||
|
||||
8. **Consolidation / tidy lane を更新する**
|
||||
- staging entries、existing Memory、usage evidence、linter feedback、stale/noisy hints を統合する。
|
||||
- legacy Knowledge as generated memory 前提を外す。
|
||||
- Memory update、Knowledge candidate、Skill candidate を分けて扱う。
|
||||
|
||||
9. **Minimal Knowledge catalog/read/write を実装する**
|
||||
- OKF-compatible Markdown files と Workspace API から始める。
|
||||
- frontmatter validation / lint と backlinks を追加する。
|
||||
- `index.md` progressive disclosure と `# Citations` の扱いを実装する。
|
||||
|
||||
10. **Skill support を別に実装する**
|
||||
- Agent Skills standard と Workspace authority に従う。
|
||||
- Skill と Knowledge note schema を混ぜない。
|
||||
- automatic Skill modification は recurrence と portability で gate する。
|
||||
|
||||
11. **Promotion workflows/tools を追加する**
|
||||
- Memory -> Knowledge candidate。
|
||||
- Knowledge -> docs / Ticket decision candidate。
|
||||
- repeated procedure -> Skill candidate。
|
||||
|
||||
12. **Resource classes が安定してから sensemaking helpers を追加する**
|
||||
- collected refs。
|
||||
- evidence extraction。
|
||||
- contradiction / staleness views。
|
||||
- product-impact metrics。
|
||||
|
||||
## 11. Non-goals
|
||||
|
||||
- Memory を唯一の long-term knowledge store として扱うこと。
|
||||
- Knowledge を名前だけ変えた generated memory として扱うこと。
|
||||
- Skill を Workflow tracker や state machine として扱うこと。
|
||||
- Worker history / tool results の外で context injection を隠すこと。
|
||||
- Knowledge notes を Tickets / docs / git history より authoritative にすること。
|
||||
- review なしに Knowledge / Skills / docs を自動 rewrite すること。
|
||||
- extract worker に direct resource mutation authority を持たせること。
|
||||
- extract run 単位の batch staging record に複数 candidate を抱え込ませること。
|
||||
- staging entry をすべて Memory 化すること。
|
||||
- human-readable artifact model が安定する前に vector database を設計すること。
|
||||
- volatile Memory や staging queue を OKF concept document として扱うこと。
|
||||
- H2 Markdown single-file layout を Workspace Memory API の長期 contract として固定すること。
|
||||
- Agent Skills format を OKF Playbook documents で置き換えること。
|
||||
|
||||
## 12. Open decisions
|
||||
|
||||
- Knowledge bundle の exact directory organization: flat files、nested directories、domain directories のどれを初期推奨にするか。
|
||||
- Yoi extension frontmatter の exact fields。
|
||||
- Yoi stable `yoi_id` を必須にするか optional にするか。
|
||||
- OKF `type` values の初期 convention をどうするか。
|
||||
- Link syntax は OKF-compatible Markdown links と Obsidian-style wiki links (`[[slug]]`, `[[slug|label]]`) を support する。canonical internal representation と export normalization をどうするか。
|
||||
- Knowledge note IDs / slugs と titles の関係。
|
||||
- Memory は local-first のままにするか、Knowledge と同じ phase で Workspace API-first にするか。
|
||||
- Memory / Knowledge APIs は同じ crate にするか、別 domain crates にするか。
|
||||
- Memory -> Knowledge の promotion UI/tool をどうするか。
|
||||
- personal Memory と workspace Memory をどう区別するか。
|
||||
- Knowledge drafts の auto-generation をどこまで許すか。
|
||||
- extract worker 専用 evidence tools の exact API: search/read の引数、上限、evidence id format。
|
||||
- `stage_candidate` / `finish_extraction` の exact tool schema。
|
||||
- flat staging record の exact fields: `claim`, `why_useful`, `staleness`, `evidence`, `source_refs`, `extract_run_id` など。
|
||||
- Overview trigger を overview token count、Assistant message count、evidence growth、run/task boundary のどれで制御するか。
|
||||
- mid-run extract をどこまで許すか。初期は staging/checkpoint extraction に限定する方針。
|
||||
- staging resolution log / archive の保持期間と compact policy。
|
||||
- どの Memory sidecar writes を direct に許し、どれを staged proposals にするか。extract worker 自体は direct write しない。
|
||||
- Skill sidecar output は patches/proposals だけから始めるか、low-risk Skill edits を auto-apply してよいか。
|
||||
- Memory / Knowledge / Skill Workspace APIs をまたぐ sidecar audit events をどう表現するか。
|
||||
- prompt-cache-aware sidecar input で full replay と digest-plus-tail をどう選ぶか。
|
||||
|
||||
## 13. Exit criteria for architecture phase
|
||||
|
||||
この architecture は、次が満たされたら Tickets に分割できる。
|
||||
|
||||
- Memory / Knowledge / Skill boundary が accepted される。
|
||||
- OKF-compatible bundle / Markdown concept documents としての target Knowledge が accepted される。
|
||||
- Memory / Knowledge / Skills の Workspace backend authority が accepted される。
|
||||
- Overview-first extract、extract worker 専用 evidence tools、staging-first output の方針が accepted される。
|
||||
- staging resolution / disposition と、staging -> Memory 化 filter の方針が accepted される。
|
||||
- 最初の implementation slice が選ばれる。
|
||||
- non-goals が accepted され、old Workflow tracking や old unused Knowledge をそのまま再作成しないことが確認される。
|
||||
@@ -1,137 +0,0 @@
|
||||
---
|
||||
source_url: "https://andymatuschak.org/files/papers/Pirolli%2C%20Card%20-%202005%20-%20The%20sensemaking%20process%20and%20leverage%20points%20for%20analyst%20technology%20as.pdf"
|
||||
fetched_at: "2026-07-15T21:24:00Z"
|
||||
content_type: "application/pdf"
|
||||
notes: "Untrusted external reference captured as local Objective resource for Memory sensemaking design. This is a summary/extraction for design discussion, not project authority."
|
||||
---
|
||||
|
||||
# Pirolli & Card (2005): The sensemaking process and leverage points for analyst technology
|
||||
|
||||
## Citation / source
|
||||
|
||||
Peter Pirolli and Stuart Card, PARC. "The Sensemaking Process and Leverage Points for Analyst Technology as Identified Through Cognitive Task Analysis" (2005).
|
||||
|
||||
Source PDF: <https://andymatuschak.org/files/papers/Pirolli%2C%20Card%20-%202005%20-%20The%20sensemaking%20process%20and%20leverage%20points%20for%20analyst%20technology%20as.pdf>
|
||||
|
||||
## Core model
|
||||
|
||||
The paper frames intelligence analysis as a sensemaking task:
|
||||
|
||||
```text
|
||||
Information -> Schema -> Insight -> Product
|
||||
```
|
||||
|
||||
The analyst transforms raw data into progressively more structured representations so expertise can apply and so results can be communicated.
|
||||
|
||||
The paper's notional data flow is especially relevant to Yoi Memory design:
|
||||
|
||||
```text
|
||||
External data sources
|
||||
-> shoebox
|
||||
-> evidence file
|
||||
-> schemas
|
||||
-> hypotheses
|
||||
-> presentation / work product
|
||||
```
|
||||
|
||||
- **External data sources**: raw material, mostly text in the studied setting.
|
||||
- **Shoebox**: the smaller subset collected as relevant for the task.
|
||||
- **Evidence file**: extracted snippets / nuggets from the shoebox, plus low-level inferences.
|
||||
- **Schemas**: re-representations that organize information for analysis.
|
||||
- **Hypotheses**: tentative conclusions with supporting or disconfirming evidence.
|
||||
- **Product**: report / presentation / action suited for communication.
|
||||
|
||||
## Two major loops
|
||||
|
||||
The process has two interacting loops rather than a simple linear pipeline.
|
||||
|
||||
### Foraging loop
|
||||
|
||||
Activities aimed at finding and selecting information:
|
||||
|
||||
- search and filter external data sources;
|
||||
- collect potentially relevant material into a shoebox;
|
||||
- read and extract evidence snippets;
|
||||
- follow up on questions generated by extracted evidence.
|
||||
|
||||
The paper highlights the exploration / enrichment / exploitation tradeoff:
|
||||
|
||||
- **Exploration**: monitor or search more of the information space; increases recall.
|
||||
- **Enrichment**: narrow the collected set into smaller, higher-precision subsets.
|
||||
- **Exploitation**: read/extract/analyze the chosen material more thoroughly.
|
||||
|
||||
Analyst tooling can help by changing the cost structure of search, scanning, assessment, selection, attention shifting, and follow-up searches.
|
||||
|
||||
### Sensemaking loop
|
||||
|
||||
Activities aimed at structuring and reasoning:
|
||||
|
||||
- schematize evidence;
|
||||
- build a case;
|
||||
- generate / manage hypotheses;
|
||||
- marshal evidence for and against hypotheses;
|
||||
- tell a story / produce a report;
|
||||
- re-evaluate based on feedback or new evidence.
|
||||
|
||||
The paper emphasizes opportunistic mixing of bottom-up and top-down processing:
|
||||
|
||||
- **Bottom-up**: data triggers schemas, relations, hypotheses, and products.
|
||||
- **Top-down**: hypotheses / client feedback trigger new searches, re-reading, and re-organization.
|
||||
|
||||
## Leverage points
|
||||
|
||||
### Foraging loop leverage
|
||||
|
||||
- Cost structure of exploration / enrichment / exploitation.
|
||||
- Cost structure of scanning, recognizing, and selecting items for attention.
|
||||
- Cost of shifting attentional control to a new domain or task.
|
||||
- Cost of follow-up searches generated by extracted information.
|
||||
- Broad-band low-fidelity assessment plus narrow-band high-fidelity processing is a useful design pattern.
|
||||
|
||||
### Sensemaking loop leverage
|
||||
|
||||
- Span of attention for evidence, hypotheses, and evidentiary relations.
|
||||
- Generation of alternative hypotheses.
|
||||
- Confirmation bias and failure to seek disconfirming evidence.
|
||||
- External representations can expand working memory for evidence/hypothesis structures.
|
||||
- Tools should help distribute attention toward diagnostic evidence and disconfirming relations.
|
||||
|
||||
## Implications for Yoi Memory Objective
|
||||
|
||||
This paper directly supports the Objective's direction that effective Memory is not just durable storage or retrieval count.
|
||||
|
||||
Useful design implications:
|
||||
|
||||
1. **Task-bound shoebox**
|
||||
- For each Ticket / Objective / question, provide a bounded collection of potentially relevant materials.
|
||||
- Include provenance and why each item was collected.
|
||||
|
||||
2. **Evidence file**
|
||||
- Extract snippets from source material with source, applicability, confidence, and context.
|
||||
- Evidence should be usable for support and disconfirmation, not only recall.
|
||||
|
||||
3. **Schema / representation layer**
|
||||
- The system should help create intermediate structures: timelines, entity/relation maps, alternatives, checklists, hypothesis spaces, decision tables.
|
||||
- These are separate from raw Memory records.
|
||||
|
||||
4. **Hypotheses and alternatives**
|
||||
- Record competing explanations, rejected alternatives, open questions, and evidence gaps.
|
||||
- Avoid only storing final decisions.
|
||||
|
||||
5. **Disconfirming evidence**
|
||||
- Reviewer / Orchestrator support should explicitly search for contradiction and diagnostic evidence.
|
||||
- This belongs in Skill guidance and typed review tooling, not in a separate Knowledge store.
|
||||
|
||||
6. **Metrics**
|
||||
- Measure whether Memory changes product quality, review quality, decision quality, or time-to-evidence.
|
||||
- Avoid treating exposure/retrieval counts alone as success.
|
||||
|
||||
7. **Product connection**
|
||||
- The loop should end in a product: Ticket decision, implementation report, review, design doc, Skill update, or validated artifact.
|
||||
- Memory that never reaches a product is likely a graveyard.
|
||||
|
||||
## Boundary with current Yoi direction
|
||||
|
||||
- Knowledge as a separate record kind is being removed; this paper's reusable representations should map to Memory artifacts, Ticket artifacts, maintained docs, or Skills depending on authority.
|
||||
- Workflow tracking is being removed; procedural guidance such as "generate alternatives" or "seek disconfirming evidence" should live in Skills / role prompts and be enforced by typed tools where authority is needed.
|
||||
- Workspace backend should eventually be the authority for task-bound shoebox / evidence artifacts when Memory moves from local compatibility storage to control-plane records.
|
||||
@@ -1,343 +0,0 @@
|
||||
---
|
||||
title: "Runtime working directory materialization and sandboxed agent environments"
|
||||
state: "active"
|
||||
created_at: "2026-07-06T16:28:12Z"
|
||||
updated_at: "2026-07-10T16:50:00Z"
|
||||
linked_tickets: ["00001KWPC13WQ", "00001KWMBAA6V", "00001KX6BPY7M", "00001KX6CRVBE"]
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Runtime が Worker ごとに安全で安価な作業環境を用意できるようにする。Yoi の Runtime は、単に既存ディレクトリで Worker process を起動する launcher ではなく、RepositoryPoint から working directory を materialize し、sandbox / mount / cache / cleanup / evidence を管理する実行基盤になる。
|
||||
|
||||
この Objective の中心は、Worker 用 working directory の materialization、Repository cache、working directory allocation、sandboxed agent environment の境界を設計し、将来的に 1 つの Runtime が複数 Workspace / Repository の Worker を抱えられるようにすることである。
|
||||
|
||||
初期実装では Git/local repository を主対象にしてよい。ただし設計は Git worktree 固定にしない。Git worktree、bare object cache、sparse checkout、copy-on-write snapshot、reflink copy、APFS clonefile、btrfs snapshot、overlay filesystem、container filesystem、remote object snapshot は、すべて working directory materialization strategy の候補として扱う。
|
||||
|
||||
## Motivation / background
|
||||
|
||||
現在の Runtime は `--workspace` で与えられた単一 root を Worker の workspace scope として使う。この形では次の問題がある。
|
||||
|
||||
- 複数 Worker が同じ repository root を scope として要求すると allocation conflict が起きる。
|
||||
- Runtime が single Workspace / Git repository root 専用 process になってしまう。
|
||||
- Worker が source repository root を直接触るため、sandbox / cleanup / quota / evidence の境界が曖昧になる。
|
||||
- Worker ごとに full clone すると容量と時間のコストが大きすぎる。
|
||||
- `.yoi` / Backend fs-store / Workspace descriptor と、Worker 実行用 checkout / scratch / build output の lifecycle が混ざりやすい。
|
||||
|
||||
Yoi は Workspace / Repository / Runtime を分ける方針になっている。Backend は Repository registry、RepositorySelector、RepositoryPoint、Ticket/Artifact evidence の authority を持つ。一方 Runtime は RepositoryPoint を受け取り、Worker が使う working directory を用意する責務を持つべきである。
|
||||
|
||||
したがって、Repository を持ってくる実体ディレクトリは Backend / Workspace store ではなく Runtime 管理領域に置く。`./.yoi` は local descriptor / compatibility / project record surface であり、Worker ごとの checkout、worktree、snapshot、sandbox root、build cache、dependency cache を置く場所ではない。
|
||||
|
||||
参考になる方向性として、Rift のような copy-on-write workspace snapshot / reflink / btrfs snapshot / APFS clonefile による安価な workspace creation がある。ただし Yoi は Rift を Git worktree 代替 CLI として直接前提にするのではなく、Runtime materializer backend の一候補として扱う。
|
||||
|
||||
## Glossary
|
||||
|
||||
- Runtime root: Runtime が自身の store、cache、Worker metadata、working directory allocation を管理する root。長期的には `~/.yoi/runtimes/<runtime-id>/` 配下など、Workspace backend store とは別に置く。
|
||||
- Repository cache: Runtime-local の共有 source/cache。Git なら bare mirror / object cache / packfile cache など。重く、長寿命で、複数 Worker allocation から共有される。
|
||||
- working directory: Worker ごとの作業環境。短寿命で、Worker が読み書きする root / mounts / scratch / overlay を含む。Browser-facing UI では `workspace` と混同しないよう `workdir` と表示してよい。`Volume` は storage backing の候補名であり、この作業領域そのものの呼称にはしない。
|
||||
- Worker record: 作業単位として保存する record。profile、Runtime、RepositoryPoint / workdir evidence、session/transcript refs、status、diagnostics、summary、pinned flag を束ねる。Session 単体ではなく Worker を保存・削除の単位にする。
|
||||
- Materialization strategy: RepositoryPoint から working directory を作る実装戦略。Git detached worktree、sparse checkout、CoW snapshot、reflink copy、overlay、container filesystem など。
|
||||
- WorkingDirectoryAllocation: Runtime が払い出した作業環境の record。allocation id、worker id、workspace id、repository point、materializer kind、root、mounts、cleanup policy、status を持つ。
|
||||
- Sandbox policy: Worker が見られる filesystem、network、process、secret、tool authority の境界を表す policy。
|
||||
- Dirty state policy: local uncommitted changes を Worker environment に含めるかどうかの方針。clean point only、patch artifact apply、snapshot current worktree など。
|
||||
|
||||
## Strategy / design direction
|
||||
|
||||
### 1. Backend store と Runtime execution storage を分ける
|
||||
|
||||
Workspace/backend store は canonical or local descriptor records を扱う。
|
||||
|
||||
```text
|
||||
Backend / Control plane store
|
||||
- Workspace registry
|
||||
- Repository registry
|
||||
- Ticket / Objective
|
||||
- Artifact metadata / evidence
|
||||
- Actor / Permission / Audit
|
||||
- Runtime registry / observed state
|
||||
```
|
||||
|
||||
Runtime execution storage は Worker 実行のための materialized filesystem と cache を扱う。
|
||||
|
||||
```text
|
||||
Runtime root
|
||||
- runtime catalog / runtime-local DB
|
||||
- config bundles
|
||||
- worker metadata / transcript references
|
||||
- repository-cache
|
||||
- working-directories
|
||||
- sandbox state
|
||||
- build/dependency cache
|
||||
- cleanup ledger
|
||||
```
|
||||
|
||||
この 2 つを混ぜない。特に `.yoi` や workspace config root に Worker ごとの checkout/worktree/sandbox root を置かない。
|
||||
|
||||
推奨配置の方向:
|
||||
|
||||
```text
|
||||
~/.yoi/
|
||||
workspace-server/
|
||||
backend.db
|
||||
workspaces/
|
||||
<workspace-id>/
|
||||
workspace.db
|
||||
artifacts/
|
||||
|
||||
runtimes/
|
||||
<runtime-id>/
|
||||
runtime.db
|
||||
config-bundles/
|
||||
workers/
|
||||
<worker-id>/
|
||||
metadata/
|
||||
repository-cache/
|
||||
git/
|
||||
<repository-cache-key>/
|
||||
bare.git
|
||||
working-directories/
|
||||
<allocation-id>/
|
||||
root/
|
||||
mounts/
|
||||
scratch/
|
||||
materialization.json
|
||||
build-cache/
|
||||
tmp/
|
||||
```
|
||||
|
||||
### 2. clone ではなく materialize と呼ぶ
|
||||
|
||||
Runtime は Repository を毎回 clone するのではない。Runtime は RepositoryPoint を Worker 用の working directory として materialize する。
|
||||
|
||||
```text
|
||||
RepositoryId + RepositorySelector
|
||||
-> resolved RepositoryPoint
|
||||
-> Runtime repository-cache
|
||||
-> WorkingDirectoryMaterializer
|
||||
-> WorkingDirectoryAllocation
|
||||
-> Worker process
|
||||
```
|
||||
|
||||
Git の場合でも materialization strategy は複数あり得る。
|
||||
|
||||
- shared bare cache + detached Git worktree
|
||||
- sparse checkout
|
||||
- partial clone / blob filter
|
||||
- reflink copy
|
||||
- btrfs writable snapshot
|
||||
- APFS clonefile
|
||||
- overlay filesystem
|
||||
- external tool backed snapshot, e.g. Rift-like CoW workspace creation
|
||||
|
||||
### 3. Repository cache と Worker working directory を分離する
|
||||
|
||||
full clone を Worker ごとに作らない。重い source/object data は Runtime-local repository cache に集約し、Worker ごとの working directory は cheap allocation にする。
|
||||
|
||||
Git v0 の方向:
|
||||
|
||||
```text
|
||||
repository-cache/git/<repo-key>/bare.git
|
||||
working-directories/<allocation-id>/root/<repository-id>/
|
||||
```
|
||||
|
||||
初回:
|
||||
|
||||
```text
|
||||
git clone --mirror <uri> repository-cache/git/<repo-key>/bare.git
|
||||
```
|
||||
|
||||
次回以降:
|
||||
|
||||
```text
|
||||
git -C repository-cache/git/<repo-key>/bare.git fetch --prune
|
||||
```
|
||||
|
||||
Worker allocation:
|
||||
|
||||
```text
|
||||
git --git-dir=<bare.git> worktree add --detach <execution-root>/<repository-id> <commit>
|
||||
```
|
||||
|
||||
Git branch 名を直接 checkout しない。Git worktree は同一 branch を複数 worktree に checkout しづらいため、RepositorySelector を RepositoryPoint に解決し、resolved commit を detached worktree として materialize する。Worker が branch を必要とする場合は Worker/Task ごとの synthetic branch を別途作る。
|
||||
|
||||
### 4. Dirty state を明示 policy にする
|
||||
|
||||
local dirty changes を暗黙に Worker に見せない。
|
||||
|
||||
可能な policy:
|
||||
|
||||
- `clean_point_only`: dirty workspace は materialization 拒否。再現性が高く v0 の default 候補。
|
||||
- `patch_artifact`: clean RepositoryPoint を materialize し、Backend が保存した dirty diff artifact を apply する。
|
||||
- `current_worktree_snapshot`: CoW/reflink/Rift-like snapshot で現在の working tree を snapshot として materialize する。便利だが evidence と cleanup の扱いを明示する必要がある。
|
||||
- `direct_legacy_mount`: 既存 root をそのまま渡す。debug/legacy only。通常 Worker creation の default にしない。
|
||||
|
||||
Dirty state を含める場合、Artifact/evidence には source RepositoryPoint、patch/snapshot digest、created_at、materializer kind を残す。
|
||||
|
||||
### 5. Sandbox を materialization と同じ境界で扱う
|
||||
|
||||
working directory は単なる directory path ではなく、Worker が見てよい filesystem view である。Runtime は working directory allocation と同時に sandbox/mount/authority を構築する。
|
||||
|
||||
Worker に渡すもの:
|
||||
|
||||
- workspace root
|
||||
- repository mounts
|
||||
- scratch/cache dirs
|
||||
- tool authority
|
||||
- env vars
|
||||
- config bundle
|
||||
- secret handles, not raw secrets
|
||||
|
||||
Worker が自分で発見してはいけないもの:
|
||||
|
||||
- host repository root
|
||||
- Backend store path
|
||||
- `.yoi` authority-bearing internals
|
||||
- raw credentials
|
||||
- Runtime socket/store/cache internals
|
||||
- sibling Worker working directories
|
||||
|
||||
Sandbox v0 は strong isolation でなくてもよい。ただし型と lifecycle は、後で container sandbox、namespace, mount filtering, network policy, secret boundary に拡張できる形にする。
|
||||
|
||||
### 6. working directory registry を持つ
|
||||
|
||||
Runtime は materialized workspace を filesystem だけでなく registry でも管理する。
|
||||
|
||||
必要な record:
|
||||
|
||||
```text
|
||||
WorkingDirectoryAllocation
|
||||
id
|
||||
runtime_id
|
||||
worker_id
|
||||
workspace_id
|
||||
repository_points[]
|
||||
materializer_kind
|
||||
root
|
||||
mounts[]
|
||||
scratch
|
||||
source_cache_refs[]
|
||||
sandbox_policy
|
||||
dirty_state_policy
|
||||
cleanup_policy
|
||||
status: active | stopped | cleanup_pending | removed | failed
|
||||
created_at
|
||||
stopped_at
|
||||
diagnostics[]
|
||||
```
|
||||
|
||||
この registry は Runtime root 側に置く。Backend は必要な evidence と summary だけを受け取る。Browser-facing API は raw host paths を原則漏らさない。
|
||||
|
||||
### 7. Heavy regenerable artifacts は policy で除外または cache 化する
|
||||
|
||||
Worker working directory creation では、`node_modules`, `target`, `.venv`, framework cache, dist, build, coverage などを無条件に full copy しない。
|
||||
|
||||
Materialization policy は以下を持てるようにする。
|
||||
|
||||
- exclude regenerable artifacts
|
||||
- include manifests / lockfiles
|
||||
- share dependency cache
|
||||
- use build cache
|
||||
- path scope / sparse checkout
|
||||
- per-repository materialization options
|
||||
|
||||
Rift の filtered CoW creation のように、重い artifacts を除外しながら source tree を高速に用意できる strategy を将来取り込めるようにする。
|
||||
|
||||
## Initial implementation phases
|
||||
|
||||
### Phase 1: Materialization boundary and legacy allocation record
|
||||
|
||||
- `CreateWorkerRequest` に working directory request / target placeholder を追加する。
|
||||
- `WorkingDirectoryMaterializer` trait を `worker-runtime` に追加する。
|
||||
- `WorkerRuntimeExecutionBackend` が Worker spawn 前に materializer を呼ぶ順序にする。
|
||||
- v0 materializer は existing local root を explicit allocation として返してよいが、`direct_legacy_mount` として明示し、通常設計の final form と混同しない。
|
||||
- Allocation record / cleanup policy / diagnostics の型を先に作る。
|
||||
- Worker が source repository root を直接 scope として要求する経路を deprecated/legacy に閉じ込める。
|
||||
|
||||
### Phase 2: Runtime root and working directory storage
|
||||
|
||||
- `--runtime-root` を導入し、Runtime state / repository-cache / working-directories / worker metadata / worker runtime dirs を Runtime root 配下へ寄せる。
|
||||
- `--workspace` は legacy bootstrap input としてだけ扱い、Runtime identity / long-term workspace binding から外す。
|
||||
- Runtime root default は user data 配下にする。
|
||||
- working directory allocation registry を Runtime root に保存する。
|
||||
|
||||
### Phase 3: Git cached detached worktree materializer
|
||||
|
||||
- Git repository cache を Runtime root に作る。
|
||||
- RepositorySelector を RepositoryPoint に解決する呼び出し境界を作る。
|
||||
- resolved commit/tree を evidence として残す。
|
||||
- Worker ごとに detached worktree を `working-directories/<allocation-id>/root/<repository-id>` に作る。
|
||||
- Worker stop / cleanup 時に `git worktree remove` と registry cleanup を行う。
|
||||
- dirty state は v0 では `clean_point_only` を default にし、dirty local workspace は明示 diagnostic で拒否する。
|
||||
|
||||
### Phase 4: Path scope / sparse checkout / cache policies
|
||||
|
||||
- Ticket target / Worker launch request の path scope を materialization に渡す。
|
||||
- Git sparse checkout を materializer strategy として追加する。
|
||||
- heavy artifact exclude / dependency cache / build cache policy を導入する。
|
||||
|
||||
### Phase 5: CoW / snapshot materializer
|
||||
|
||||
- btrfs snapshot、Linux reflink、macOS APFS clonefile、Rift-like snapshot backend を materializer strategy として検討・実装する。
|
||||
- `current_worktree_snapshot` policy を evidence と cleanup 付きで扱う。
|
||||
- source root と generated workspace の registry / ancestor / cleanup model を設計する。
|
||||
|
||||
### Phase 6: Strong sandbox and remote Runtime support
|
||||
|
||||
- container filesystem / mount namespace / network policy / secret handle / tool authority を Runtime allocation と統合する。
|
||||
- Runtime が複数 Workspace / Repository の Worker を同時に抱える場合の namespace、quota、cleanup、audit boundary を固める。
|
||||
- remote/self-hosted/hosted Runtime fleet で repository cache と working directory storage をどう扱うかを設計する。
|
||||
|
||||
### 7. Worker / Session / workdir retention and cleanup policy
|
||||
|
||||
- Worker を保存・削除単位にする。Session / transcript は Worker に内包または参照される履歴として扱い、Session 単体を長期保存 authority にしない。
|
||||
- Worker lifecycle は `running -> stopped -> delete` を基本にする。`archived` を lifecycle state として導入しない。
|
||||
- Worker には `pinned` flag を持たせる。Pinned Worker は manual delete / cleanup / future automatic prune から守られる。
|
||||
- Worker delete は Worker record と内包する Session / transcript history の削除を意味する。Workdir files は自動削除しない。
|
||||
- Session / transcript は削除可能にする。圧縮 archive storage や高度な summarized retention は必要になった時点で別の storage policy として設計する。
|
||||
- Workdir 実ファイルは durable history ではなく再現可能 cache として扱う。RepositoryPoint / resolved commit から再現でき、dirty/uncommitted state が無いなら、running Worker に紐づかない workdir files は manual cleanup eligible とする。
|
||||
- Dirty Workdir は活動中または要判断状態として扱う。削除は可能だが、changes ごと消す explicit confirmation を要求する。Dirty orphan は recovery Worker 起動または explicit discard の判断対象であり、通常の clean cleanup と区別する。
|
||||
- Workdir record は materialization evidence として扱う。実ファイルが削除済みなら `removed`、外部要因で欠落しているなら stale/missing materialization として診断可能にする。
|
||||
- Worker と Workdir の関係は Backend registry の link table を authority にする。Worker record は最後に活動した RepositoryPoint / resolved commit / workdir binding summary を保持し、Stopped Worker + Removed Workdir でも必要に応じて再 materialize できるようにする。
|
||||
- Cleanup/delete は当面 manual-first にする。削除前に対象、理由、削除される bytes、blocking conditions(running Worker、pinned Worker、dirty state、unreachable commit など)を plan として提示する。
|
||||
- 将来的には容量/期限ベースの automatic prune を設定可能にする予定。ただし automatic prune は user-configured policy と pinned protection を前提にし、初期の manual cleanup/delete とは別段階で扱う。
|
||||
- UI 表示は `workdir` に寄せる。内部型/API の互換名 `working_directory` は移行中に残ってよいが、Browser-facing navigation では Runtime 管理配下の `Workdirs` として扱う。
|
||||
|
||||
## Non-goals
|
||||
|
||||
- v0 で完全な container sandbox を実装すること。
|
||||
- Worker ごとに full clone すること。
|
||||
- `.yoi` や Backend workspace store に Worker working directory を置くこと。
|
||||
- Git worktree を唯一の materialization strategy として固定すること。
|
||||
- Dirty local changes を暗黙に Worker に渡すこと。
|
||||
- Browser-facing API に raw host path、secret、Runtime internal store path を公開すること。
|
||||
- Repository credential / secret distribution の本格設計をこの Objective だけで完了させること。
|
||||
|
||||
## Success criteria / exit conditions
|
||||
|
||||
- Runtime root、Repository cache、working directory、Backend/Workspace store の境界が文書化されている。
|
||||
- Runtime が Worker spawn 前に working directory materializer を呼ぶ型と順序を持つ。
|
||||
- Worker は source repository root ではなく materialized working directory を scope として起動する。
|
||||
- working directory allocation が Runtime registry に記録され、cleanup policy を持つ。
|
||||
- Git/local repository の v0 materializer が full clone 連発ではなく shared cache / detached worktree / cheap allocation の方向に進んでいる。
|
||||
- dirty state policy が明示され、clean point、patch artifact、snapshot のどれを使ったか evidence に残せる。
|
||||
- Runtime process 起動時の `--workspace` は legacy bootstrap input として隔離され、Runtime identity や single workspace binding とみなされない。
|
||||
- Worker ごとの scope allocation conflict が、同一 source root を直接渡す設計ではなく materialized workspace allocation によって解消される。
|
||||
- Sandbox / mount / cache / secret boundary を後続実装で強化できる model になっている。
|
||||
- Worker record が Session / transcript refs と pinned flag を束ね、`pinned` Worker を cleanup/delete/future automatic prune から守れる。
|
||||
- Workdir 実ファイルは再現可能 cache として manual cleanup/delete でき、削除前に plan-first で linked Worker、session retention、commit reachability、dirty/missing 状態を確認できる。
|
||||
- 将来的に容量/期限ベースの automatic prune を user-configured policy として追加できる設計余地がある。
|
||||
|
||||
## References
|
||||
|
||||
- [Rift: Worktree alternative for fast copy-on-write workspaces](https://github.com/anomalyco/rift) — CoW snapshot / reflink / btrfs snapshot / APFS clonefile による高速 workspace creation、heavy regenerable artifacts の除外、workspace registry などを Runtime materializer backend 設計の参考にする。
|
||||
|
||||
## Decision context
|
||||
|
||||
- Runtime は Worker 群を束ねる実行基盤であり、Worker 用の作業環境を用意する責務を持つ。
|
||||
- Repository は clone されるものではなく、RepositoryPoint から Worker 用 working directory として materialize される。
|
||||
- Runtime execution storage は Backend/Workspace store と分離する。`.yoi` は Worker working directory 置き場ではない。
|
||||
- full clone を Worker ごとに作らない。Runtime-local repository cache と Worker-local cheap workspace allocation を分ける。
|
||||
- Git detached worktree は v0 materialization strategy として有力だが、Git worktree 固定の設計にはしない。
|
||||
- CoW snapshot / reflink / btrfs snapshot / APFS clonefile / Rift-like workspace creation は、将来の materializer backend として検討する。
|
||||
- Dirty local state は暗黙に渡さず、policy と evidence を持って扱う。
|
||||
- Sandbox は後付けの別機能ではなく、working directory allocation と同じ境界で扱う。
|
||||
@@ -1,3 +0,0 @@
|
||||
default = "builtin:companion"
|
||||
|
||||
[profile]
|
||||
@@ -1,329 +0,0 @@
|
||||
---
|
||||
description: worktree と sibling の coder / reviewer Pod を使い、下位 orchestrator が concrete Ticket 群の実装・外部レビュー・修正・完了準備を管理する orchestration フロー
|
||||
model_invokation: true
|
||||
user_invocable: true
|
||||
requires: []
|
||||
---
|
||||
# Multi-agent Worktree Workflow
|
||||
|
||||
yoi を yoi で開発する際の、worktree + coder Pod + 外部 reviewer Pod + orchestrator Pod の標準フロー。これは **最上位 Pod が細かい code review を抱えず、下位 orchestrator が実装と外部レビューの loop を完了状態まで運ぶためのフロー** である。
|
||||
|
||||
worktree の機械的作成手順は `$user/worktree-workflow`、ユーザー依頼の Ticket 化は `$user/ticket-intake-workflow`、Ticket の next action 分類は `$user/ticket-orchestrator-routing`、実装前の planning/requirements sync は compatibility canonical id `$user/ticket-preflight-workflow` に分ける。
|
||||
|
||||
この Workflow は、対象 ticket が implementation-ready であることを前提にする。implementation-ready は full implementation plan ではなく、recorded intent / binding decisions / invariants / implementation latitude / acceptance criteria / escalation conditions に基づいて coder が bounded investigation を進め、reviewer が判断できる状態を指す。設計境界・仕様・authority boundary が未同期の場合は、worktree 作成や coder Pod 起動の前に planning/requirements sync 互換入口として `ticket-preflight-workflow` を通す。
|
||||
|
||||
## 目的
|
||||
|
||||
- 実装差分を ticket ごとの child worktree に隔離する。
|
||||
- coder Pod に narrow write scope を渡して実装させる。
|
||||
- reviewer Pod を coder の子ではなく **同じ orchestrator 配下の sibling** として立て、外部レビューを行わせる。
|
||||
- orchestrator は coder / reviewer のやり取り、修正 loop、orchestration branch への integration、validation、Ticket 記録、child worktree cleanup に責任を持つ。
|
||||
- 最上位 orchestrator は、コードを直接理解し切ることではなく、委譲した intent / 要件 / invariant に沿って下位 orchestrator が完了まで運んだかを acceptance する。
|
||||
|
||||
## Pod coordination model
|
||||
|
||||
基本形は以下。
|
||||
|
||||
```text
|
||||
最上位 orchestrator Pod
|
||||
- 人間との会話相手
|
||||
- intent / 要件 / invariant / escalation 条件を定義
|
||||
- 複数の作業群を並列管理
|
||||
- root/original workspace での read/write/validation/cleanup/git 操作を行う
|
||||
- 原則として line-by-line code review を主業務にしない
|
||||
|
||||
下位 orchestrator Pod(area / concrete-ticket-set coordinator)
|
||||
- 連続した複数 concrete Ticket または大きめの concrete Ticket を完了状態まで運ぶ
|
||||
- worktree / branch / coder / reviewer / validation / 修正 loop を管理する
|
||||
- coder と reviewer を sibling として扱う
|
||||
- orchestration branch 上の integration 結果と残論点だけを返す
|
||||
|
||||
coder Pod
|
||||
- 指定 worktree / branch に実装する
|
||||
- ticket 外判断や設計衝突は orchestrator に戻す
|
||||
- reviewer に直接反論・修正依頼を完結させず、orchestrator に報告する
|
||||
|
||||
reviewer Pod
|
||||
- 原則 read-only
|
||||
- ticket / intent packet / diff / validation 結果を読む
|
||||
- 実際のコード変更が概念的に何を変えたかを説明する
|
||||
- intent / 要件 / invariant に反する blocker を分類して返す
|
||||
```
|
||||
|
||||
一段だけで足りる小さい concrete Ticket では、最上位 orchestrator が直接 coder / reviewer sibling を扱ってよい。複数 concrete Ticket や設計境界をまたぐ作業では、最上位の下に下位 orchestrator を挟む。
|
||||
|
||||
この Pod coordination model は runtime delegation の形であり、Ticket hierarchy を作る根拠ではない。複数 Ticket を扱う場合も各 Ticket は concrete work item として独立に実装・レビュー・検証・close できる必要がある。broad effort の進捗コンテナとして umbrella Ticket、parent/child Ticket、sub-ticket、part-of、contains などを作らない。中期的な目的や戦略は Objective context に置き、typed Ticket relations が利用可能な場合も dependency / related / blocking / replacement などの非階層メタデータに限る。
|
||||
|
||||
明示的な queue review や routing kick の中で、独立した queued Ticket が複数あり coder/reviewer capacity が残っているなら、最初の Ticket の完了を待つことを default にしない。Orchestrator は各 Ticket の relation metadata、OrchestrationPlan records、Ticket thread、workspace/worktree dirty state、visible Pods、既存 branch、conflict notes を確認し、blocking relation や `do_not_parallelize` がなく、write surface が disjoint または conflict risk が小さく機械的に処理できるものを、別 worktree・別 branch・別 narrow write scope で並列開始する。これは background scheduler や queue drain loop ではなく、人間が queued にした Ticket だけを、その場の明示的な acceptance 判断で `queued -> inprogress` に進める運用である。
|
||||
|
||||
## 開始条件
|
||||
|
||||
以下が揃っている時に使う。
|
||||
|
||||
- 対象 concrete Ticket または concrete Ticket 群が決まっている。
|
||||
- Ticket lifecycle を使う場合、対象はすでに `inprogress` であるか、worktree 作成・Pod spawn・coder routing の前に Orchestrator が個別に `queued -> inprogress` acceptance を記録できる `queued` Ticket に限る。unqueued Ticket は capacity 埋めの対象にしない。
|
||||
- ticket の背景・意図・制約・受け入れ条件から、実装調査と局所 tactic 選択を coder に委ねても product / API / UX / authority / design-boundary decision を silently 固定しないと判断できる。
|
||||
- worktree 作成と git 書き込み操作について、人間の許可がある。
|
||||
- 下位 orchestrator に渡す binding decisions / invariants、implementation latitude、escalation conditions を短く書ける。
|
||||
- 設計境界・仕様・authority boundary に不確定要素があり、bounded project-context checks 後も concrete missing decision / information が残る場合、planning/requirements sync 互換入口 `ticket-preflight-workflow` の結果が ticket thread に記録されている。
|
||||
|
||||
product / API / UX / authority / design-boundary 方針が複数自然に導ける場合、ticket 自体の再定義が必要な場合、または protocol / scope / permission / history persistence に触れる作業で bounded project-context checks 後も concrete missing decision / information が残る場合は、実装委譲前に planning/requirements sync 互換入口 `ticket-preflight-workflow` を通し、必要なら人間へ戻す。scope / Pod / permission / persistence のような authority-adjacent domain は reviewer focus と context lookup の signal であり、intent / binding decisions / invariants / implementation latitude / acceptance criteria / escalation conditions が明確なら coder に委ねてよい。実装ファイルの探索、既存コード読解、局所的な構成選択のような bounded implementation uncertainty も同様に coder に委ねてよい。
|
||||
|
||||
## Intent packet
|
||||
|
||||
階層化すると人間の意図が劣化しやすい。下位 orchestrator や coder / reviewer へは、自然文の依頼だけでなく intent packet を渡す。
|
||||
|
||||
標準形:
|
||||
|
||||
```text
|
||||
Intent:
|
||||
- 何を実現するか。
|
||||
|
||||
Binding decisions / invariants:
|
||||
- 人間/Orchestrator/Ticket に記録済みで coder / reviewer が従うべき decision。
|
||||
- 壊してはいけない設計境界、authority boundary、明示制約。
|
||||
- 残してはいけない旧概念や互換層。
|
||||
|
||||
Requirements / acceptance criteria:
|
||||
- 完了時に満たすべき observable な要件と reviewer が判断できる基準。
|
||||
|
||||
Implementation latitude:
|
||||
- Coder が調査しながら選んでよい local tactic / file-local organization / bounded uncertainty。
|
||||
|
||||
Escalate if:
|
||||
- 親へ戻すべき判断条件。特に product / API / UX / authority boundary / explicit design constraint を変える必要が出た場合。
|
||||
|
||||
Validation:
|
||||
- 実行すべき format / build / test / doctor。
|
||||
|
||||
Reviewer focus:
|
||||
- reviewer が確認すべき critical risks。
|
||||
```
|
||||
|
||||
reviewer には coder の実装方針ではなく、この intent packet と diff を中心に読ませる。reviewer は recorded intent / binding decisions / invariants / implementation latitude / acceptance criteria / explicit escalation conditions に照らして判断し、不記録の preferred tactic を基準にしない。
|
||||
|
||||
## orchestrator の責務
|
||||
|
||||
下位 orchestrator を挟まない場合は、以下を最上位 orchestrator が行う。下位 orchestrator を挟む場合は、最上位は intent packet を渡し、以下の実務を下位に委譲する。
|
||||
|
||||
1. 状態確認
|
||||
- `git status --short --branch`
|
||||
- 対象 ticket / ticket 群
|
||||
- relation metadata / OrchestrationPlan records / do_not_parallelize / conflict or dependency notes
|
||||
- visible Pods、既存 worktree/branch、coder/reviewer follow-up capacity
|
||||
- 関連 TODO / docs / 既存 worktree
|
||||
- planning sync が必要な ticket では、互換入口 `ticket-preflight-workflow` の分類・要件同期・critical risks
|
||||
|
||||
2. worktree 作成
|
||||
- 対象 Ticket が `queued` なら、この step の前に typed Ticket backend/tool path で `queued -> inprogress` を記録する。これより前に branch 作成、worktree 作成、Pod spawn、実装調査依頼などの implementation side effect を行わない。
|
||||
- Orchestrator が dedicated orchestration worktree で動く場合、作業対象は Orchestrator workspace/orchestration branch と child implementation worktree に限定する。root/original workspace は read/write/validation/cleanup/git 操作の対象ではない。
|
||||
- implementation worktree は記録済み implementation worktree root の `.worktree` 配下に置く。root/original workspace は配置基準としてだけ扱い、作業対象にしない。
|
||||
- implementation branch は Orchestrator workspace の current HEAD/orchestration branch HEAD から作り、reviewer approve 後は orchestration branch へ自動 integration する。
|
||||
- `.yoi` 自体は除外しない。tracked project records は child worktree に存在してよく、`.yoi/memory` と local/runtime/log/lock/secret-like paths だけを sparse checkout で除外する。
|
||||
|
||||
3. coder Pod spawn
|
||||
- read scope: original workspace root 全体、または実装に必要な bounded read scope。
|
||||
- write scope: child worktree、または必要最小 directory。
|
||||
- task には以下を明示する。
|
||||
- child worktree path / branch
|
||||
- 対象 ticket path
|
||||
- intent packet
|
||||
- SpawnPod の `cwd` は child worktree に設定すること(`cwd` は process/tool default cwd であり、scope/authority ではない)
|
||||
- Orchestrator workspace / recorded Ticket backend の `TODO.md` / Ticket records / `docs/report/` / `.yoi` は編集しないこと
|
||||
- child worktree 内の tracked `.yoi` project records は実装対象に必要な branch-local artifacts/dossiers として編集してよいが、`.yoi/memory` や local/runtime/secret-like files は作らないこと
|
||||
- active orchestration progress、review、integration、Ticket lifecycle update は Orchestrator workspace または recorded Ticket backend の責任として残すこと
|
||||
- 遵守すべき binding decisions / invariants と escalation conditions
|
||||
- 実行すべき build / test / format
|
||||
- 完了報告項目
|
||||
|
||||
4. coder 完了確認
|
||||
- `ReadPodOutput` で報告を読む。
|
||||
- 通知が来ない場合でも、worktree の `git status` / `git diff` / test で完了状態を確認する。
|
||||
- coder が止まった場合、worktree 状態を見て再 spawn / rollback / 親 escalation を判断する。
|
||||
|
||||
5. reviewer Pod spawn
|
||||
- reviewer は coder の子ではなく orchestrator 配下の sibling として立てる。
|
||||
- 原則 read-only scope にする。
|
||||
- reviewer に渡すもの:
|
||||
- ticket / intent packet
|
||||
- branch / commit / diff の読み方
|
||||
- 実行済み validation
|
||||
- blocker / non-blocker / acceptable の分類基準
|
||||
- reviewer の主目的は「コードの詳細を親の代わりに説明し、intent / invariant 違反を見つけること」。
|
||||
|
||||
6. review → 修正 loop
|
||||
- reviewer finding を orchestrator が読む。
|
||||
- blocker は coder に修正依頼する。
|
||||
- reviewer の指摘を却下する場合は、orchestrator が理由を dossier に残す。
|
||||
- 修正後は focused validation を実行し、必要なら reviewer に再確認させる。
|
||||
- reviewer の blocker が未解決のまま親に提出しない。
|
||||
|
||||
7. orchestration branch integration
|
||||
- 親がコードを直接理解しなくても判断できるよう、変更の概念的説明と evidence をまとめる。
|
||||
|
||||
8. integration / lifecycle
|
||||
- reviewer approve と blocker 解消を確認し、implementation branch を Orchestrator workspace の orchestration branch へ integration する。
|
||||
- Orchestrator workspace で必要な validation を実行し、Ticket thread に integration と validation の結果を記録する。
|
||||
- Ticket を完了処理して commit する。TODO cleanup が対象 Ticket の明示要件なら child implementation worktree/branch に限定して行う。root/original workspace は触らない。
|
||||
|
||||
## coder Pod の責務
|
||||
|
||||
- child worktree 内でのみ実装する。
|
||||
- root/original workspace の管理ファイルを読まない・書かない。
|
||||
- child worktree 内の tracked `.yoi` project records は ticket 要件に必要な branch-local artifact/dossier として扱ってよい。
|
||||
- `.yoi/memory`、local/runtime state、logs、locks、secret-like files を child worktree に作らない。
|
||||
- intent / requirements / acceptance criteria / binding decisions / invariants / implementation latitude / escalation conditions を読んでから実装する。
|
||||
- 実装ファイルの探索、既存コード読解、局所的な tactic 選択は、intent packet の implementation latitude 内で行ってよい。
|
||||
- 指定された build / test / format を実行する。
|
||||
- ticket 要件外または binding decisions/invariants 外の設計変更、依存関係追加、scope / permission / history persistence / prompt context 加工原則に触れる変更が必要なら止めて orchestrator に報告する。
|
||||
- 完了時に以下を報告する。
|
||||
- worktree path / branch
|
||||
- commit hash(commit した場合)
|
||||
- 変更ファイル
|
||||
- 実装概要
|
||||
- 実行した build / test / format
|
||||
- 未解決事項
|
||||
- review に回せるか
|
||||
|
||||
## reviewer Pod の責務
|
||||
|
||||
reviewer は coder の subordinate ではない。orchestrator 配下の sibling として、実装 diff を外部から読む。
|
||||
|
||||
- 原則 read-only で作業する。
|
||||
- ticket / intent packet / diff / validation 結果を読む。
|
||||
- 実際のコード変更が概念的に何を変えたかを説明する。
|
||||
- 親や上位 orchestrator が line-by-line diff を読まずに判断できるよう、以下を整理する。
|
||||
- 変更の概念モデル
|
||||
- recorded intent / binding decisions / invariants / implementation latitude / acceptance criteria / explicit escalation conditions との対応
|
||||
- binding decisions / invariants 違反の有無
|
||||
- 旧概念・禁止語彙・不要な互換層の残存
|
||||
- validation の妥当性
|
||||
- blocker / non-blocker / follow-up
|
||||
- reviewer は直接 merge しない。
|
||||
- reviewer は coder に直接作業指示を出さず、orchestrator に finding を返す。
|
||||
|
||||
## commit 方針
|
||||
|
||||
coder Pod には child worktree 内での commit を許可してよい。
|
||||
|
||||
- commit は ticket 内で意味のある粒度にする。
|
||||
- 例: `feat: ...`、`fix: ...`、`test: ...`、`docs: ...`
|
||||
- coder Pod は merge / push / branch deletion / worktree remove をしない。
|
||||
- coder Pod は recorded Ticket backend の Ticket 完了処理 commit、最終 review/approval/close をしない。child worktree 側には branch-local dossier や実装証跡を残してよい。
|
||||
- orchestrator は review 時に commit 粒度も確認する。
|
||||
- 必要な修正は、原則追加 commit として積む。履歴改変や squash は人間の明示指示がある時だけ行う。
|
||||
|
||||
## Review → 修正 → 完了の標準形
|
||||
|
||||
### Approve
|
||||
|
||||
reviewer が approve し blocker が残っていない場合、Orchestrator workspace の orchestration branch への integration、validation、Ticket 記録、child worktree cleanup まで自動的に進める。root/original workspace への read/write/validation/cleanup/git 操作は行わない。
|
||||
|
||||
1. coder Pod / reviewer Pod を停止し、scope を回収する。
|
||||
2. orchestrator が Ticket、child worktree/branch、commits、reviewer verdict、validation evidence を確認する。
|
||||
3. 最上位 orchestrator が必要最小限の spot check を行う。
|
||||
4. Orchestrator workspace の orchestration branch で implementation branch を merge または project-agreed method で integration する。
|
||||
5. Ticket を完了処理して commit する。TODO cleanup が対象 Ticket の明示要件なら child implementation worktree/branch に限定して行う。
|
||||
6. Orchestrator workspace/orchestration branch で検証コマンドを再実行する。root/original workspace では実行しない。
|
||||
7. 変更内容・commit・検証結果・残 dirty changes を報告する。
|
||||
|
||||
### Request changes
|
||||
|
||||
1. reviewer finding または orchestrator finding を blocker / non-blocker に分ける。
|
||||
2. blocker はファイル / 行 / 理由 / 修正方針つきで coder に戻す。
|
||||
3. coder が停止済みなら、同じ worktree / branch / scope で再 spawn する。
|
||||
4. 修正後に focused test と必要な broader test を再実行する。
|
||||
5. 必要なら reviewer に再確認させる。
|
||||
6. blocker が解消したら dossier を更新する。
|
||||
|
||||
### Non-blocking comments
|
||||
|
||||
- ticket 要件外の改善はその場で混ぜない。
|
||||
- 必要なら後続 ticket / docs/report にする。
|
||||
- non-blocking を理由に completion を遅らせない。
|
||||
|
||||
## 並列実装時の注意
|
||||
|
||||
- 1 ticket = 1 worktree = 1 branch を基本にする。
|
||||
- 複数 Pod に同じ write scope を渡さない。parallel Coder Pod は別 worktree と narrow write scope を持つ。
|
||||
- parent / orchestrator は coder の write scope 配下を直接編集しない。
|
||||
- reviewer は read-only を基本にする。明示的に別 scope を与える判断がない限り、review artifact を書かせる場合も ticket artifacts など限定 scope にする。
|
||||
- independent queued Ticket は、blocking relation/dependency がなく、`do_not_parallelize` や relevant `conflicts_with` がなく、source/write surface が disjoint または conflict risk が小さく機械的で、coder/reviewer follow-up capacity があり、workspace records を side effect 前に commit でき、別 worktree/branch/scope を切れる場合に並列開始を優先する。
|
||||
- 依存関係がある ticket、同一 migration boundary や同一 schema/tool/panel surface を大きく触る ticket、または reviewer/coder bottleneck がある ticket は、土台 branch を merge してから次 worktree を切るか、bounded defer reason を記録して idle にする。
|
||||
- capacity が見えるのに queued Ticket を開始しない場合、dependency / conflict / capacity / missing planning decision / dirty workspace / reviewer-coder bottleneck / migration boundary / human gate のいずれかを bounded reason として Ticket thread または `TicketOrchestrationPlanRecord` に残す。
|
||||
- parallel に走らせた Pod の完了通知は取りこぼしうるため、`ReadPodOutput` と worktree 状態で確認する。
|
||||
- この節は自動 scheduler、background runner、resource graph solver、automatic queue drain loop を導入しない。parallel start は明示的な routing/acceptance の結果であり、unqueued Ticket を開始する根拠ではない。
|
||||
|
||||
## orchestration integration dossier の標準形
|
||||
|
||||
```text
|
||||
Status:
|
||||
- completed / blocked / needs parent decision
|
||||
|
||||
Scope:
|
||||
- ticket(s): <path>
|
||||
- branch: <name>
|
||||
- commits:
|
||||
- <hash> <subject>
|
||||
|
||||
Intent check:
|
||||
- invariant 1: ok / violated / not applicable, evidence ...
|
||||
- invariant 2: ok / violated / not applicable, evidence ...
|
||||
|
||||
Implementation summary:
|
||||
- 変更の概念的説明: ...
|
||||
- 主要変更ファイル: ...
|
||||
- compatibility / migration: ...
|
||||
|
||||
Coder loop:
|
||||
- coder pod: <name>
|
||||
- produced commits: ...
|
||||
- unresolved coder notes: ...
|
||||
|
||||
External review:
|
||||
- reviewer pod: <name>
|
||||
- blockers found: ...
|
||||
- blockers fixed: ...
|
||||
- rejected findings and reasons: ...
|
||||
- non-blocking follow-ups: ...
|
||||
|
||||
Validation:
|
||||
- cargo fmt --check
|
||||
- cargo check --workspace
|
||||
- cargo test ...
|
||||
- yoi ticket doctor
|
||||
|
||||
Parent decision needed:
|
||||
- none / specific question
|
||||
|
||||
Residual risk:
|
||||
- ...
|
||||
|
||||
Dirty state:
|
||||
- ...
|
||||
```
|
||||
|
||||
## 最上位 orchestrator の acceptance
|
||||
|
||||
最上位 orchestrator は、下位 orchestrator の成果を code review するのではなく acceptance する。
|
||||
|
||||
確認するもの:
|
||||
|
||||
- intent packet が保持されているか。
|
||||
- reviewer が coder と独立した sibling として機能したか。
|
||||
- blocker が未解決のまま握りつぶされていないか。
|
||||
- 変更の概念的説明が要件と対応しているか。
|
||||
- validation が ticket のリスクに対して十分か。
|
||||
- escalation すべき判断を下位が勝手に決めていないか。
|
||||
|
||||
必要なら spot check するが、常態的な line-by-line review に戻らない。
|
||||
|
||||
## この Workflow で扱わないもの
|
||||
|
||||
以下は `$user/ticket-intake-workflow`、`$user/ticket-orchestrator-routing`、または別の設計相談で扱う。
|
||||
|
||||
- ユーザー依頼を Ticket 化すること。
|
||||
- Ticket の next action を分類すること。
|
||||
- QA feedback / AI feedback を Ticket / report / workflow に落とす判断。
|
||||
- 長期 maintainer loop / scheduler / LeaseStore の設計。
|
||||
- reviewer Pod の品質評価を機械的に採点する仕組み。
|
||||
@@ -1,350 +0,0 @@
|
||||
---
|
||||
description: ユーザーの曖昧な依頼を要件同期し、合意済みの Ticket として作成・更新する Intake workflow
|
||||
model_invokation: true
|
||||
user_invocable: true
|
||||
requires: []
|
||||
---
|
||||
# Ticket Intake Workflow
|
||||
|
||||
Yoi の multi-agent 運用で、ユーザーの依頼をいきなり実装委譲せず、まず **合意済み Ticket** または「まだ Ticket 化しない」判断に変換するための Workflow。
|
||||
|
||||
この workspace workflow は bundled `resources/workflows/ticket-intake-workflow.md` を dogfooding 用に詳述した override である。Objective / split policy / local Ticket 運用の説明を追加するが、bundled workflow の調査ゲート、Ticket 作成前の user agreement、Intake の非 scheduler 境界を弱めてはならない。
|
||||
|
||||
Intake の目的は、ユーザーの意図・要件・制約・受け入れ条件・未決定点を明確にし、Orchestrator が次の routing を判断できる Ticket を作ることである。Intake は scheduler ではなく、coder / reviewer / read-only investigation helper Pod を起動しない。
|
||||
|
||||
## 位置づけ
|
||||
|
||||
```text
|
||||
User request / conversation
|
||||
-> Ticket Intake Workflow
|
||||
-> TicketCreate / TicketComment
|
||||
-> Orchestrator routing
|
||||
-> planning sync / spike / implementation / review / blocked / close
|
||||
```
|
||||
|
||||
- `Ticket` は durable orchestration record。
|
||||
- `Objective` は medium-term goal / motivation / strategy / success criteria / decision context の project record。Objective context は判断背景であり、Ticket body/thread/artifacts を読む代替ではない。
|
||||
- `Task` は session-local progress tracking。
|
||||
- `Assignment` は Orchestrator から coder / reviewer Pod、または task-specific helper Pod への具体的委譲。
|
||||
- `IntentPacket` は Ticket から抽出して Assignment に渡す短い実装・レビュー契約。
|
||||
|
||||
Intake は、要件同期と Ticket 化を担当する。実装の起動・worktree 作成・review 委譲・merge 判断は Orchestrator 側の責務である。`ready` は Orchestrator が routing できる状態を意味し、実装戦術がすべて事前固定されていることを意味しない。
|
||||
|
||||
Ticket に残す内容は、binding decisions / invariants、implementation latitude、escalation conditions を区別する。Coder が調査しながら局所的な実装手段を選べる余地は残してよいが、product / API / UX / authority boundary / explicit design constraint を silently 決める余地を残してはならない。
|
||||
|
||||
## Intake の責務
|
||||
|
||||
Intake は以下を行う。
|
||||
|
||||
- ユーザー依頼の主語と目的を確認する。
|
||||
- 既存 Ticket を確認し、duplicate / related work を探す。
|
||||
- 曖昧な依頼、現在挙動への claim、authority boundary、workflow/source-of-truth 変更では、Ticket 化前の最小調査ゲートとして関連 docs / code / workflow / history を読む。
|
||||
- 不足している要件を質問する。
|
||||
- 作成または refinement する Ticket が、実装・レビュー・検証・完了判断を単独で行える concrete work item であるか確認する。
|
||||
- 広い依頼を分割する場合は、進捗コンテナとしての umbrella Ticket ではなく、concrete Ticket / Objective context / split decision record に責務を分ける。
|
||||
- Objective-to-Ticket links を提案する場合は canonical opaque Ticket ID だけを使い、dependency / blocking / ordering relation として扱わない。
|
||||
- Ticket の title / body/request snapshot / acceptance criteria / priority / readiness / risk flags を、現在の要件として意味がある範囲で提案する。
|
||||
- canonical ID は Ticket 作成/storage が opaque な path-derived value として割り当てるため、Intake はユーザー向け metadata として提案しない。
|
||||
- ユーザー主張、Intake が確認した事実、未確認仮説、未決定点を分けて整理する。
|
||||
- background / requirements / acceptance criteria / escalation conditions を整理する。
|
||||
- binding decisions / invariants と implementation latitude を分けて書く。
|
||||
- 具体的な除外や触れてはいけない境界が binding decision である場合は、generic な除外リストではなく invariant / escalation condition として明記する。
|
||||
- readiness / open questions / risk flags を明示する。
|
||||
- ユーザー合意後にだけ official Ticket を作成する。
|
||||
- 既存 Ticket の refinement を求められた場合は、TicketComment で経緯を残す。
|
||||
|
||||
## Intake がしないこと
|
||||
|
||||
- coder / reviewer Pod や read-only investigation helper Pod を起動しない。
|
||||
- worktree を作らない。
|
||||
- merge / close / branch cleanup をしない。
|
||||
- implementation-ready でない Ticket を実装に投げない。
|
||||
- unattended scheduler として自動実行しない。
|
||||
- ユーザー合意なしに official Ticket を作らない。
|
||||
- broad effort の進捗を保持するためだけの long-lived umbrella / progress-container Ticket を作らない。
|
||||
- parent/child、sub-ticket、umbrella、part-of、contains などの hierarchy/container relation を split の代替として提案しない。
|
||||
- secrets / private context を Ticket body / thread / artifacts / diagnostics に保存しない。
|
||||
- arbitrary filesystem write で `work-items/` を編集しない。Ticket 操作は Ticket tools を使う。
|
||||
|
||||
## 使用する Ticket tools
|
||||
|
||||
利用可能なら、以下の typed Ticket tools を使う。
|
||||
|
||||
- `QueryTicket`: 既存 Ticket の一覧・重複確認。
|
||||
- `ShowTicket`: 関連 Ticket の詳細確認。
|
||||
- `TicketCreate`: 合意済み Ticket の作成。
|
||||
- `TicketComment`: 既存 Ticket refinement / decision / plan の記録。
|
||||
|
||||
Intake は `MergeRequest*`, `TicketWorkflowState`, `TicketClose` を通常使わない。review authority は assigned Coder が起動した read-only direct-child Reviewer の immutable Merge Request attempt に属し、completion / merge / close は各guarded workflowの責務である。
|
||||
|
||||
Ticket tools が利用できない環境では、勝手に file write で代替しない。ユーザーまたは Orchestrator に「Ticket tools がないため materialize できない」と報告し、必要なら `yoi ticket` を使える人間/親 workflow に戻す。
|
||||
|
||||
## 手順
|
||||
|
||||
### 1. 依頼を受け取る
|
||||
|
||||
ユーザー依頼を短く言い換え、以下を分ける。
|
||||
|
||||
- 何を変えたいか。
|
||||
- なぜ必要か。
|
||||
- 影響を受けるユーザー / Pod / workflow / crate / docs。
|
||||
- 既に決まっていること。
|
||||
- まだ未決定のこと。
|
||||
|
||||
この段階では Ticket を作らない。ユーザー発話は request snapshot / claim として扱い、確認済み requirements と混同しない。
|
||||
|
||||
### 2. 既存 Ticket を確認する
|
||||
|
||||
`QueryTicket` / `ShowTicket` で duplicate / related work を探す。
|
||||
|
||||
確認観点:
|
||||
|
||||
- 同じ目的の未完了 Ticket がないか。
|
||||
- closed Ticket の判断・resolution と矛盾しないか。
|
||||
- 既存の umbrella/progress-container Ticket が、superseded/decomposed として退役できる状態か。
|
||||
- 既存 concrete follow-up Ticket や Objective context で足りるか、新規 concrete Ticket が必要か。
|
||||
|
||||
既存 Ticket の更新で足りる場合、新規 Ticket を作らず、ユーザーに更新案を提示する。
|
||||
|
||||
### 2.1. Ticket 化前の最小調査ゲート
|
||||
|
||||
`TicketCreate` または material な `TicketComment` の前に、以下の gate を通す。
|
||||
|
||||
必ず行うこと:
|
||||
|
||||
- duplicate / related / blocking-looking Ticket を確認する。
|
||||
- 既存 Ticket を更新するなら、その Ticket の item/thread/artifacts を読む。
|
||||
- ユーザー claim と、Intake が読んで確認した fact を分ける。
|
||||
|
||||
次のいずれかに当たる場合は、Ticket 作成前に関連 docs / code / workflow / prompt / config / history を読む。
|
||||
|
||||
- 依頼が曖昧、または複数の concrete work item を含む。
|
||||
- 「現在の挙動」「既存仕様」「壊れている」「既にある」など、事実確認を要する claim がある。
|
||||
- scope / permission / history / prompt context / persistence / public API など authority boundary に触れる。
|
||||
- prompt / workflow resource、Ticket schema、source-of-truth 境界の変更に触れる。
|
||||
- 既存実装の map がないと requirements / acceptance criteria を誤って固定しそうである。
|
||||
|
||||
Gate output は draft に以下を分けて残す。
|
||||
|
||||
- User claims / request snapshot: ユーザーが述べたこと。
|
||||
- Confirmed facts / sources: Intake が読んで確認したことと source。
|
||||
- Unverified hypotheses: ありそうだが未確認の推測。
|
||||
- Undecided points / open questions: ユーザーまたは Orchestrator の判断が必要なこと。
|
||||
|
||||
調査が大きい、current-code map がない、または仕様同期が足りない場合は、official Ticket を作らず draft で止める。readiness は `spike_needed` / `requirements_sync_needed` / `blocked` のいずれかを付け、次に必要な調査や質問を報告する。確認できない claim を requirements / acceptance criteria として保存しない。
|
||||
|
||||
### 2.5. Broad request の split policy
|
||||
|
||||
1つの依頼が複数の implementable work item を含む場合、Intake は以下を提案する。
|
||||
|
||||
- broad effort を表す umbrella/progress-container Ticket は新規作成しない。
|
||||
- concrete slice ごとに、単独で実装・レビュー・検証・close できる Ticket を作る。
|
||||
- split した理由と残した範囲は、関連 Ticket の thread、必要なら Objective context に記録する。
|
||||
- Objective は中期的な目的・方針・success criteria を保持するために使う。Objective record の実装や schema 追加はこの workflow の責務ではない。
|
||||
- typed Ticket relation が利用可能になった場合も、dependency / related / blocking / replacement / supersedes などの非階層メタデータに限る。
|
||||
- parent/child、sub-ticket、part-of、contains のような hierarchy/container relation を作らない。
|
||||
|
||||
ユーザーが「まず調査 Ticket」「設計 Ticket」「計画 Ticket」を concrete work item として求める場合は作成してよい。禁止されるのは、他の concrete Ticket の進捗を持つためだけに長期 open となる container Ticket である。
|
||||
|
||||
### 3. 要件を同期する
|
||||
|
||||
最低限、以下を確認する。
|
||||
|
||||
- ユーザー claim のうち、どれが確認済み fact で、どれが未確認仮説か。
|
||||
- observable な完了条件は何か。
|
||||
- 作業の種類・影響範囲は prose として body に書けばよいが、current Ticket core metadata として扱わない。
|
||||
- 受け入れ条件は何か。
|
||||
- binding decision として残す具体的な除外・authority boundary はあるか。
|
||||
- 後方互換が必要か。
|
||||
- authority boundary / scope / permission / history / prompt context に触れるか。
|
||||
- validation は何で確認できるか。
|
||||
- 人間判断が必要な論点は何か。
|
||||
|
||||
不足がある場合は、Ticket 作成前に質問する。質問は多すぎず、Ticket 作成に必要な最小限に絞る。調査が先に必要な場合は `spike_needed`、仕様同期が先に必要な場合は `requirements_sync_needed` として draft に留める。
|
||||
|
||||
### 4. readiness を分類する
|
||||
|
||||
Ticket 作成または更新前に、readiness を明示する。
|
||||
|
||||
```text
|
||||
implementation_ready:
|
||||
- 意図・受け入れ条件・binding decisions / invariants / implementation latitude が明確。
|
||||
- Reviewer が判断できる基準と escalation conditions が明確。
|
||||
- 実装調査や局所的な tactic 選択は残っていてよいが、product / API / UX / authority boundary / explicit design constraint を coder が silently 決める余地はない。
|
||||
- validation が書ける。
|
||||
|
||||
requirements_sync_needed:
|
||||
- 目的は見えているが、仕様・用語・UX・責務境界・受け入れ条件が未同期。
|
||||
- ユーザー claim を requirements として固定するには合意や確認が足りない。
|
||||
|
||||
spike_needed:
|
||||
- 技術調査、依存関係、性能、license、diagnostics、現在コード map が先に必要。
|
||||
- どの files/workflows/Tickets を読むべきかは見えているが、Intake の最小調査では実装可能な要件まで確定できない。
|
||||
|
||||
blocked:
|
||||
- 人間判断、外部イベント、別 Ticket の完了が必要。
|
||||
|
||||
unspecified:
|
||||
- どうしても分類不能な時だけ使う。理由を Ticket に書く。
|
||||
```
|
||||
|
||||
### 5. open questions / risk flags を付ける
|
||||
|
||||
以下に触れる Ticket は risk flags と reviewer/orchestrator focus を Ticket body に短く明記する。これは stop gate ではない。具体的な未決定 decision / information がある場合だけ、blocking open question として記録する。
|
||||
|
||||
- profile / manifest / scope / permission。
|
||||
- session / history / Pod metadata / persistence。
|
||||
- prompt context 加工原則。
|
||||
- public API / plugin / feature boundary。
|
||||
- security / secret / credential handling。
|
||||
- storage migration / backward compatibility。
|
||||
- 複数の自然な設計方針があるもの。
|
||||
- reviewer が diff だけでは見落としやすい設計リスク。
|
||||
|
||||
risk flags は短い語でよい。missing boundary がすでに人間/Orchestrator の Ticket-recorded decision で補われている場合は、その decision を根拠に Orchestrator が routing できる。単に risk があるだけなら Orchestrator は Ticket を戻さず、IntentPacket に escalation / reviewer focus を明記して進める。
|
||||
|
||||
例:
|
||||
|
||||
```text
|
||||
risk_flags: [authority-boundary, persistence, prompt-context, public-api]
|
||||
```
|
||||
|
||||
### 6. Ticket draft を提示する
|
||||
|
||||
ユーザーに作成前 draft を提示する。
|
||||
|
||||
標準 draft:
|
||||
|
||||
```text
|
||||
Title:
|
||||
|
||||
Priority:
|
||||
Readiness:
|
||||
Next Ticket operation: draft_only | create_after_user_agreement | update_existing_after_user_agreement | no_ticket
|
||||
Risk flags:
|
||||
|
||||
User claims / request snapshot:
|
||||
|
||||
Confirmed facts / sources:
|
||||
|
||||
Unverified hypotheses:
|
||||
|
||||
Undecided points / open questions:
|
||||
|
||||
Background:
|
||||
|
||||
Requirements:
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
Binding decisions / invariants:
|
||||
|
||||
Implementation latitude:
|
||||
|
||||
Escalation conditions:
|
||||
|
||||
Validation:
|
||||
|
||||
Related tickets/docs/files:
|
||||
```
|
||||
|
||||
canonical ID は作成時に storage が opaque/path-derived value として割り当てるため、draft では提案しない。
|
||||
|
||||
この時点ではまだ Ticket を作らない。`Next Ticket operation` が `draft_only` / `no_ticket` の場合は、ユーザー合意があっても `TicketCreate` ではなく追加同期または調査へ戻す。
|
||||
|
||||
### 7. ユーザー合意を取る
|
||||
|
||||
以下のどちらかがあるまで official Ticket を作らない。
|
||||
|
||||
- ユーザーが draft を明示的に承認する。
|
||||
- ユーザーが「作って」「切って」「記録して」など、作成を明示する。
|
||||
|
||||
未決定のまま記録する場合は、`requirements_sync_needed` / `spike_needed` / `blocked` として未決定点を明示する。ユーザー合意は「この未決定状態で記録する」ことへの合意であり、未確認仮説を requirements 化する許可ではない。
|
||||
|
||||
### 8. Ticket を作成または更新する
|
||||
|
||||
新規 Ticket の場合:
|
||||
|
||||
- `TicketCreate` を使う。
|
||||
- title / priority / body と、必要な readiness / risk flags を指定する。canonical ID は storage が割り当てる。
|
||||
- body に readiness / open questions / risk flags と、binding decisions / invariants、implementation latitude、escalation conditions を Markdown で明記する。
|
||||
- user claims、confirmed facts、unverified hypotheses、undecided points / open questions を分けて書き、未確認 claim を requirements / acceptance criteria として保存しない。
|
||||
|
||||
既存 Ticket refinement の場合:
|
||||
|
||||
- `TicketComment` を使う。
|
||||
- role は内容に応じて `comment`, `plan`, `decision` を選ぶ。
|
||||
- 既存 `item.md` の大幅変更が必要なら、Orchestrator / maintainer に戻す。
|
||||
|
||||
### 9. 作成後の報告
|
||||
|
||||
ユーザーへ以下を返す。
|
||||
|
||||
- 作成/更新した Ticket の system-assigned id / title。
|
||||
- readiness。
|
||||
- open questions / risk flags。
|
||||
- 次に Orchestrator が取るべき routing 候補。
|
||||
- 未決定点があれば、そのまま明示する。
|
||||
|
||||
Intake はここで止まる。implementation / worktree / coder / reviewer 起動は Orchestrator routing の責務である。
|
||||
|
||||
## Ticket body の推奨形
|
||||
|
||||
```markdown
|
||||
## User claims / request snapshot
|
||||
|
||||
## Confirmed facts / sources
|
||||
|
||||
## Unverified hypotheses
|
||||
|
||||
## Undecided points / open questions
|
||||
|
||||
## Background
|
||||
|
||||
## Requirements
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
## Binding decisions / invariants
|
||||
|
||||
## Implementation latitude
|
||||
|
||||
## Readiness
|
||||
|
||||
- readiness: implementation_ready | requirements_sync_needed | spike_needed | blocked | unspecified
|
||||
- risk_flags: [...]
|
||||
|
||||
## Escalation conditions
|
||||
|
||||
## Validation
|
||||
|
||||
## Related work
|
||||
```
|
||||
|
||||
Ticket の body は Markdown/freeform を維持する。すべてを strict schema に押し込まない。
|
||||
|
||||
## secret / private context policy
|
||||
|
||||
以下は Ticket に保存しない。
|
||||
|
||||
- API keys / tokens / credentials。
|
||||
- secret file contents。
|
||||
- private prompts / model responses that should not become project records。
|
||||
- user private context not needed for project history。
|
||||
- raw logs containing secrets。
|
||||
|
||||
必要なら、保存せずに「secret/config が必要」とだけ書く。
|
||||
|
||||
## 完了条件
|
||||
|
||||
この Workflow の完了条件は次のいずれかである。
|
||||
|
||||
- ユーザー合意済みの新規 Ticket が作成され、Orchestrator が routing できる情報が揃っている。
|
||||
- 既存 Ticket に refinement / decision / plan が記録され、次の routing が明確である。
|
||||
- Ticket 化しない判断がユーザーに説明され、未決定の問いまたは関連 Ticket が明確になっている。
|
||||
|
||||
## 他 Workflow への接続
|
||||
|
||||
- `ticket-preflight-workflow`: legacy compatibility planning/requirements sync 入口。新規 routing は standalone preflight ではなく planning return/requirements sync として扱う。
|
||||
- `multi-agent-workflow`: Orchestrator が implementation_ready と判断した後に接続する。
|
||||
- `ticket-orchestrator-routing`: この Workflow が作った Ticket を routing する後続 Workflow。
|
||||
@@ -1,419 +0,0 @@
|
||||
---
|
||||
description: Ticket を読み、Orchestrator が planning return / spike / implementation / review / blocked / close へ明示的に routing する workflow
|
||||
model_invokation: true
|
||||
user_invocable: true
|
||||
requires: []
|
||||
---
|
||||
# Ticket Orchestrator Routing Workflow
|
||||
|
||||
Yoi の multi-agent 運用で、Intake や人間が作成した Ticket を Orchestrator が読み、次に取るべき action を明示的に分類・記録するための Workflow。
|
||||
|
||||
これは scheduler ではない。目的は、Ticket の fields / body / thread / artifacts / 現在の repository/Pod 状態を明示的に確認し、隠れた会話状態ではなく Ticket に基づいて routing 判断を残すことである。
|
||||
|
||||
Panel Queue / queued notification は、人間が Orchestrator に routing を開始してよいと許可した signal であり、unattended scheduler ではない。implementation side effect に進む場合は、Orchestrator が Ticket と workspace state を再確認し、unblocked と判断してから `queued -> inprogress` を記録する必要がある。明示的な routing / queue review / panel kick の中で、独立した queued Ticket と coder/reviewer capacity が複数あるなら、1件ずつ完了を待つことを default にせず、安全確認を満たす範囲で追加 Ticket の並列開始を優先する。
|
||||
|
||||
`ready` は Orchestrator routing に十分な状態であり、実装戦術が事前にすべて固定されている状態ではない。Orchestrator は、recorded intent / binding decisions / invariants / implementation latitude / acceptance criteria / escalation conditions が揃っていれば、bounded implementation uncertainty を残したまま implementation-ready と判断してよい。
|
||||
|
||||
`ready` / `queued` を `planning` に戻す判断は、Ticket text や risk flags だけで決めない。Orchestrator は、Ticket thread/artifacts、関連 Ticket / orchestration plan、関連 workflow/docs/code、durable project context、現在の repository/Pod/worktree 状態のうち relevant なものを bounded に確認し、実装前に必要な concrete missing decision / information が残っている場合だけ planning return とする。risk flags と risky domain は context lookup と reviewer focus の signal であり、automatic stop gate ではない。
|
||||
|
||||
## 位置づけ
|
||||
|
||||
```text
|
||||
TicketCreate / TicketComment
|
||||
-> Ticket Orchestrator Routing Workflow
|
||||
-> planning return / requirements sync / spike / implementation / review / blocked / close
|
||||
-> 必要に応じて他 Workflow へ接続
|
||||
```
|
||||
|
||||
- Intake は Ticket の materialization と planning/clarification を担当する role であり、state 名ではない。
|
||||
- state は `planning -> ready -> queued -> inprogress -> done` を基本遷移とする。
|
||||
- `ticket-preflight-workflow` は legacy canonical id 互換の planning/requirements sync entry であり、`preflight` を独立 state / lane / long-lived operation として扱わない。
|
||||
- `ready -> queued` は人間が Orchestrator routing を許可した状態であり、worktree 作成や Pod 起動の許可そのものではない。
|
||||
- `multi-agent-workflow` は coder / reviewer Pod と worktree を使う実装・レビュー loop。
|
||||
- この Workflow は自動 scheduler / lease / unattended maintainer ではない。
|
||||
|
||||
## Orchestrator の責務
|
||||
|
||||
Orchestrator は以下を行う。
|
||||
|
||||
- Ticket を `ShowTicket` で読む。
|
||||
- 必要に応じて関連 Ticket を `QueryTicket` / `ShowTicket` で確認する。
|
||||
- Ticket body / thread / artifacts / resolution / review / implementation report を読む。
|
||||
- Ticket が Objective context と結びついている場合は、Objective を medium-term goal / motivation / strategy / success criteria / decision context として読む。ただし Objective context は判断背景であり、Ticket body/thread/artifacts や explicit Ticket relations / OrchestrationPlan records を読む代替ではない。
|
||||
- repository 状態、関連 docs/code、既存 worktree、visible Pods を必要に応じて明示的に確認する。
|
||||
- queued notification を受けた場合も、Ticket と workspace state を再確認してから routing する。
|
||||
- next action を routing classification として決める。
|
||||
- routing decision を `TicketComment` で Ticket thread に記録する。
|
||||
- broad request や split/refinement では、long-lived umbrella/progress-container Ticket ではなく concrete implementable Ticket、Objective context、split decision record を使う。
|
||||
- Objective-to-Ticket links は canonical opaque Ticket ID による non-blocking context link として扱い、dependency / blocking / ordering / ownership / scheduling relation と解釈しない。
|
||||
- 既存 umbrella/progress-container Ticket が concrete follow-up Ticket / Objective context で置き換え済みなら、superseded/decomposed として退役・close する routing を検討する。
|
||||
- implementation-ready の場合は `multi-agent-workflow` に渡す `IntentPacket` を作る。
|
||||
- implementation-ready かつ Ticket が `queued` の場合は、worktree 作成 / implementation Pod `SpawnPod` / coder routing などの side effect の前に、既存の typed Ticket backend/tool path で `queued -> inprogress` を記録する。
|
||||
- 人間による `ready -> queued` は、記録済み Ticket scopeについて、実装、current MRのguarded merge、completion記録、Ticket closeまでをWorkspace Orchestratorへ委任するdurable gateである。Orchestratorは`queued -> inprogress`を受理した後、current-ref approval、repository evidence、blocking relations、merge CASを確認して完了まで進め、別のmerge確認を待たない。Ticketがseparate approval gateを明記する場合、またはqueued scope外の新しい判断が必要な場合だけ停止する。
|
||||
- 明示的な queue review 中に、他にも queued Ticket が見え、capacity が空いている場合は、各 Ticket について relation / orchestration-plan / dirty state / visible Pods / worktree / conflict risk を確認し、独立して受理できるものを同じ routing pass で追加の `queued -> inprogress` 候補にする。
|
||||
- queued Ticket を capacity が見える状態で idle のまま残す場合は、dependency / conflict / capacity / missing planning decision / dirty workspace / reviewer-coder bottleneck / migration boundary / human gate のいずれかに絞った bounded reason を Ticket thread または `TicketOrchestrationPlanRecord` に残す。
|
||||
- `ready` または `queued` に concrete missing decision / information がある場合だけ、typed state-change/routing event 付きで `planning` に戻す。その event/comment には missing item、checked context、implementation latitude では足りない理由、次の planning question/action を含める。
|
||||
|
||||
## Orchestrator がしないこと
|
||||
|
||||
- 自動 scheduler として unattended に実行しない。
|
||||
- Panel Queue / queued notification だけを unattended scheduler trigger として扱わない。
|
||||
- `queued -> inprogress` acceptance なしに worktree 作成、implementation Pod `SpawnPod`、coder/reviewer routing を行わない。
|
||||
- 人間/上位 Orchestrator の許可または明示的な routing acceptance なしに coder / reviewer Pod や read-only investigation helper Pod を起動しない。
|
||||
- unqueued Ticket を capacity 埋めのために開始しない。parallel start の候補は、個別に `queued` であり、人間が routing を許可済みの Ticket に限る。
|
||||
- 設計境界の未決定を勝手に implementation-ready として固定しない。
|
||||
- Ticketが`ready -> queued`されておらず完了権限を委任されていない場合、またはTicketがseparate approval gateを明記する場合に、勝手にmerge / close / cleanupしない。queued delegationとguarded completion evidenceが揃っている場合は、追加のhuman gateを作らず完了まで進める。
|
||||
- Ticket tools があるからといって arbitrary filesystem write を行わない。
|
||||
- broad multi-Ticket effort のために新しい umbrella/progress-container Ticket を作らない。
|
||||
- parent/child、sub-ticket、umbrella、part-of、contains などの hierarchy/container relation を split/refinement の代替として扱わない。
|
||||
- Notification だけを完了証拠にしない。Pod output / diff / validation / Ticket evidence を確認する。
|
||||
- 具体的な不足項目を言語化できない場合に、単に risky という理由だけで `planning` に戻さない。その場合は IntentPacket に escalation / reviewer focus を明記して進める。
|
||||
- risk flags / risky domain / authority-adjacent domain を automatic stop gate として扱わない。これらは bounded context lookup、IntentPacket invariant、reviewer focus、escalation condition に反映する signal である。
|
||||
|
||||
## 使用する Ticket tools
|
||||
|
||||
利用可能なら、以下を使う。
|
||||
|
||||
- `QueryTicket`: routing 候補、関連 Ticket、project-level forward relation (`depends_on` / `blocks` / `related` / `supersedes` / `duplicate_of`) と derived blocker summary を bounded filter/projection で確認する。`depends_on` と incoming unresolved `blocks` は queue/acceptance blocker であり、`related` は blocker ではない。`supersedes` / `duplicate_of` は visible diagnostic として扱い、自動的な lifecycle 変更や scheduler 判断にはしない。
|
||||
- `ShowTicket`: 対象 Ticket の body / thread / artifacts / resolution / typed relation metadata、linked Objective、assignment、implementation/review evidence を確認する。
|
||||
- `TicketComment`: routing decision / intent packet / blocked reason / next question の記録。
|
||||
- `TicketWorkflowState`: `queued -> inprogress` acceptance、`inprogress -> done`、または concrete missing decision/information reason を伴う `ready|queued -> planning` に使う。
|
||||
- `TicketDependencyCheck`: queue/acceptance 直前の typed dependency readiness guard に使う。
|
||||
- `TicketRelationRecord` / `TicketRelationRemove`: ユーザー合意済みの durable project relation を明示的に更新する場合だけ使う。
|
||||
- `TicketOrchestrationPlanQuery`: 対象 Ticket や関連 Ticket の ordering / blocker / conflict / waiting-capacity / accepted-plan 記録を読む。queued acceptance 前に必ず確認する。
|
||||
- `TicketOrchestrationPlanRecord`: Orchestrator が routing 中に project-relevant な ordering / dependency / conflict / capacity/waiting / accepted-plan decision を残す。これは queue reorder、自動起動、state 変更ではない。
|
||||
- `TicketClose`: 完了権限と resolution が揃っている場合だけ使う。
|
||||
|
||||
`TicketCreate` は通常 Intake の責務だが、routing 中に follow-up Ticket が必要だと判断した場合は、ユーザー/上位 Orchestrator の合意後にだけ使う。
|
||||
|
||||
## Queued acceptance contract
|
||||
|
||||
- `queued -> inprogress` acceptance の直前に `ShowTicket` / `QueryTicket` の relation blocker projection を再確認する。unresolved `depends_on` や incoming unresolved `blocks` が残る場合は implementation side effect を始めず、理由を thread に残して `planning` へ戻すか blocked diagnostic として停止する。
|
||||
- Relation metadata は project-level constraint であり、OrchestrationPlan は runtime ordering/capacity decision である。relation を OrchestrationPlan で代替しないし、OrchestrationPlan を durable dependency authority として扱わない。
|
||||
|
||||
`state = queued` は、Ticket が routing 対象として人間により Orchestrator へ渡された状態である。Orchestrator は queued notification を受けたら、Ticket、workspace state、対象 Ticket の `TicketOrchestrationPlanQuery` 記録、risk domain に応じた bounded project context を読んで、次のどちらかを行う。
|
||||
|
||||
- unblocked と判断する場合: `queued -> inprogress` を記録してから worktree 作成、implementation/review Pod spawn、その他の implementation side effect に進む。
|
||||
- `before` / `after` / `blocked_by` / `blocks` / `conflicts_with` / `do_not_parallelize` / waiting-capacity 記録がある場合、それを acceptance 判断の入力にする。記録は自動 scheduler ではないため、実際に進めるかどうかは Orchestrator が読んだうえで明示的に決める。
|
||||
- risk flags / risky domain がある場合は、IntentPacket に invariants / reviewer focus / escalation conditions を入れる。risk flag だけを `queued -> planning` の理由にしない。
|
||||
- concrete missing decision / information がある場合: `TicketWorkflowState` で `queued -> planning` を記録し、reason/body と `TicketComment` に不足項目、checked context、なぜ coder の implementation latitude では解決できないか、次の planning question/action を残す。既存の claimed live/restorable Intake/Planning Pod があり、既存通知経路が使える場合は同じ理由を通知する。
|
||||
- external action 待ちなど planning では解決しない blocker の場合: concise な理由を Ticket thread に記録し、必要に応じて attention / action-required frontmatter や `TicketOrchestrationPlanRecord` の blocker/waiting-capacity 記録で明示する。lifecycle 外の storage bucket を routing target にしない。
|
||||
|
||||
Parallel acceptance pass:
|
||||
|
||||
- 明示的な queue review 中に複数の queued Ticket が見える場合、Orchestrator は最初の1件の完了待ちを default にしない。各 Ticket について Ticket body/thread/artifacts、QueryTicket の relation/blocker projection、TicketOrchestrationPlanQuery、workspace/worktree dirty state、visible Pods、既存 branches、conflict/dependency notes を確認する。
|
||||
- 追加で開始してよいのは、blocking relation/dependency がなく、`do_not_parallelize` または applicable conflict record がなく、source/write surfaces が disjoint または conflict risk が小さく機械的で、coder/reviewer follow-up capacity があり、acceptance basis となる Ticket thread/plan/workspace records を side effect 前に記録・commit でき、別 worktree/branch/scope を切れる Ticket だけである。
|
||||
- capacity が見えるのに queued Ticket を idle にする場合は、dependency / conflict / capacity / missing planning decision / dirty workspace / reviewer-coder bottleneck / migration boundary / human gate のいずれかの bounded reason を記録する。
|
||||
- この pass は scheduler、background runner、resource graph solver、automatic queue drain loop ではない。unqueued Ticket を開始せず、各 Ticket の `queued -> inprogress` acceptance を個別に記録する。
|
||||
|
||||
Invariant:
|
||||
|
||||
- `queued -> inprogress` は Orchestrator acceptance marker であり、coder Pod が既に動いていることの後追い記録ではない。
|
||||
- `queued -> inprogress` に失敗した場合、implementation/review Pod を spawn しない。
|
||||
- `inprogress` acceptance 後に worktree/spawn/first-run が失敗した場合は、queued に黙って戻さず、in-progress Ticket に failure/block/recovery note を記録する。
|
||||
- Queue notification だけを根拠に blind spawn しない。必ず Ticket と workspace state を再確認する。
|
||||
|
||||
## Routing classification
|
||||
|
||||
Orchestrator は対象 Ticket を以下のいずれかに分類する。複数に見える場合は、次に必要な action が最も早いものを選ぶ。
|
||||
|
||||
### `requirements_sync_needed`
|
||||
|
||||
まだ `planning` に留めるべき、または `planning -> ready` に進める前に clarification が必要な状態。
|
||||
|
||||
条件:
|
||||
|
||||
- Ticket の目的は見えるが、observable な完了条件が書けない。
|
||||
- ユーザー判断がないと product/API/UX を固定してしまう。
|
||||
- acceptance criteria、binding decisions / invariants、または escalation conditions が不足している。
|
||||
- existing decisions / docs / closed Tickets と矛盾する可能性がある。
|
||||
|
||||
Action:
|
||||
|
||||
- Intake / human / Planning sync に戻す。
|
||||
- `TicketComment` で不足情報と質問を記録する。
|
||||
- coder Pod は起動しない。
|
||||
|
||||
### `return_to_planning`
|
||||
|
||||
`ready` または `queued` とされているが、bounded project-context checks の後でも、実装 side effect 前に解決すべき concrete missing decision / information が残っている状態。
|
||||
|
||||
条件:
|
||||
|
||||
- product / API / UX / authority boundary / storage migration / security / secrets などについて、実装前に決めなければならない具体項目がある。
|
||||
- 複数の自然な方針があり、human / Orchestrator decision なしでは固定できない。
|
||||
- acceptance criteria、binding decisions、または escalation conditions に、実装可否を左右する具体的欠落がある。
|
||||
- Ticket text / risk flags だけではなく、関連 Ticket/thread/artifacts、orchestration plan、関連 workflow/docs/code、durable project context、現在の workspace evidence の relevant subset を確認しても、既存 recorded decision が見つからない。
|
||||
- その不足は coder の bounded implementation latitude / local tactic selection では安全に解消できない。
|
||||
|
||||
Action:
|
||||
|
||||
- `TicketWorkflowState` で `ready -> planning` または `queued -> planning` を記録する。
|
||||
- reason/body と `TicketComment` に以下を明記する。
|
||||
- concrete missing decision / information。
|
||||
- context checked(Ticket thread/artifacts、関連 Ticket/plan、docs/workflow/code/durable context/workspace state のうち実際に確認したもの)。
|
||||
- why implementation latitude is insufficient(なぜ coder/reviewer の local tactic や escalation condition では足りないか)。
|
||||
- next planning question/action。
|
||||
- risk flag / risky domain だけを return reason にしない。
|
||||
- 既存の claimed live/restorable Intake/Planning Pod があり、既存通知経路が使える場合は同じ理由を通知する。実用的な経路がない場合は follow-up として report する。
|
||||
- planning が再度 `ready` にするまで coder Pod は起動しない。
|
||||
|
||||
### `spike_needed`
|
||||
|
||||
実装前に read-only 調査が必要。
|
||||
|
||||
条件:
|
||||
|
||||
- 技術的実現性が不明。
|
||||
- dependency / license / packaging / portability / performance / diagnostics の確認が必要。
|
||||
- current code map が不足している。
|
||||
- bug の再現条件や観測 evidence が不足している。
|
||||
|
||||
Action:
|
||||
|
||||
- read-only investigation を提案する。
|
||||
- 許可があれば task-specific read-only helper Pod を普通の scoped Pod として起動できる。
|
||||
- `TicketComment` に調査問い・scope・完了条件を記録する。
|
||||
- 実装 worktree はまだ作らない。
|
||||
|
||||
### `implementation_ready`
|
||||
|
||||
既存 multi-agent workflow に渡せる状態。
|
||||
|
||||
条件:
|
||||
|
||||
- intent / binding decisions / invariants / implementation latitude / acceptance criteria が明確。
|
||||
- binding decisions / invariants と implementation latitude が区別されている。
|
||||
- reviewer が判断する basis と escalation conditions が明確。
|
||||
- validation が書ける。
|
||||
- design / authority boundary の未決定がない、または planning return / human decision で補われている。
|
||||
- risk flags / risky domain は context lookup と reviewer focus に反映済みで、bounded context check 後に concrete missing decision / information が残っていない。
|
||||
- 残る不確実性が bounded implementation investigation / local tactic selection に閉じている。
|
||||
- IntentPacket を短く書ける。
|
||||
|
||||
Action:
|
||||
|
||||
- `IntentPacket` を作る。risky-but-specified Ticket では、risk を stop reason にせず、binding invariants / implementation latitude / escalation conditions / Critical risks / reviewer focus として記録する。
|
||||
- project-relevant な ordering / blocker / conflict / capacity decision や accepted work plan がある場合は、`TicketOrchestrationPlanRecord` に bounded typed record として残す。local session/socket/raw model output は入れない。
|
||||
- `TicketComment` に routing decision と IntentPacket を記録する。
|
||||
- 許可があれば `multi-agent-workflow` に接続し、worktree + coder/reviewer sibling loop に進む。
|
||||
|
||||
### `review_needed`
|
||||
|
||||
実装はあるが、review / acceptance が未完了。
|
||||
|
||||
条件:
|
||||
|
||||
- implementation report / branch / commit / diff がある。
|
||||
- external review がない、または reviewer blocker が未解決。
|
||||
- validation evidence が足りない。
|
||||
|
||||
Action:
|
||||
|
||||
- reviewer Pod 起動または追加 validation を提案する。
|
||||
- reviewer は recorded intent / binding decisions / invariants / implementation latitude / acceptance criteria / explicit escalation conditions に照らして判断する。不記録の好みや Orchestrator の未共有 preferred tactic を基準にしない。
|
||||
- `TicketComment` に review target と確認観点を記録する。
|
||||
- blocker 未解決のまま merge-ready としない。
|
||||
|
||||
### `blocked_by_dependency_or_missing_authority`
|
||||
|
||||
人間判断または外部イベント待ち。
|
||||
|
||||
条件:
|
||||
|
||||
- design/product/security 判断が必要だが、planning で同期すれば進められる種類ではない。
|
||||
- credential / secret / environment / external service が必要。
|
||||
- 別 Ticket / branch / upstream change の完了待ち。
|
||||
- scope/permission が不足している。
|
||||
|
||||
Action:
|
||||
|
||||
- 必要な判断・外部 action を短く書く。
|
||||
- `TicketComment` に blocked reason と next question を記録する。
|
||||
- 必要に応じて typed relation metadata や orchestration plan の blocker/waiting-capacity 記録で、待ち理由を current state とは別に表す。lifecycle 外の storage bucket へ移す route は使わない。
|
||||
|
||||
### `close_ready`
|
||||
|
||||
完了処理に進める状態。
|
||||
|
||||
条件:
|
||||
|
||||
- requirements / acceptance criteria が満たされている。
|
||||
- review / validation / merge / cleanup evidence が揃っている。
|
||||
- resolution を書ける。
|
||||
- close 権限がある。
|
||||
- 既存 umbrella/progress-container Ticket については、concrete follow-up Ticket と必要な Objective context が存在し、container role を superseded/decomposed として退役できる。
|
||||
|
||||
Action:
|
||||
|
||||
- `TicketClose` または既存 close workflow で resolution を記録する。
|
||||
- umbrella/progress-container Ticket を退役する close resolution では、関連作業がすべて完了したという意味ではなく container role を retired したことを明記し、完了済み concrete Ticket と残る follow-up Ticket / Objective を列挙する。
|
||||
- close 権限がない場合は merge-ready / close-ready dossier を親/人間に提出する。
|
||||
|
||||
### `closed_or_noop`
|
||||
|
||||
routing 不要。
|
||||
|
||||
条件:
|
||||
|
||||
- closed Ticket。
|
||||
- duplicate として既存 Ticket に統合済み。
|
||||
- Ticket 自体が不要と判断済み。
|
||||
|
||||
Action:
|
||||
|
||||
- 追加 action なし。
|
||||
- 必要なら related Ticket へ comment する。
|
||||
|
||||
## Routing 手順
|
||||
|
||||
### 1. 状態確認
|
||||
|
||||
- `git state --short --branch`
|
||||
- `ShowTicket <target>`
|
||||
- 関連 Ticket の `QueryTicket` / `ShowTicket`
|
||||
- 必要に応じて docs/code/workflow/history
|
||||
- 必要に応じて visible Pods / worktrees / branches
|
||||
|
||||
この段階で unrelated dirty changes がある場合、実装/merge には進まず、routing decision の記録だけに留めるかユーザーに確認する。
|
||||
|
||||
### 2. Ticket evidence を読む
|
||||
|
||||
最低限、以下を確認する。
|
||||
|
||||
- Background
|
||||
- Requirements
|
||||
- Acceptance criteria
|
||||
- Binding decisions / invariants
|
||||
- Implementation latitude
|
||||
- Readiness / open questions / risk flags
|
||||
- Escalation conditions
|
||||
- Validation
|
||||
- Thread の plan / decision / implementation_report / review
|
||||
- Artifacts / branch / commit references
|
||||
- risk flags が示す domain(authority-boundary / scope / permission / Pod / persistence / prompt-context / public-api など)
|
||||
|
||||
### 3. Bounded project-context checks を行う
|
||||
|
||||
Ticket evidence だけで planning return を決めない。risk flags や risky domain がある場合は、必要な最小限の project context を確認する。
|
||||
|
||||
代表的な確認先:
|
||||
|
||||
- Ticket thread/artifacts/resolution と関連 Ticket。
|
||||
- `TicketOrchestrationPlanQuery` の accepted-plan / ordering / blocker / conflict 記録。
|
||||
- 関連 workflow/docs/design/development docs。
|
||||
- 現在の code map や既存 implementation pattern。
|
||||
- durable memory/Knowledge/project decisions(利用可能で、判断に関係する場合)。
|
||||
- repository / branch / worktree / visible Pod state。
|
||||
|
||||
目的は broad repository archaeology ではなく、以下を区別できるだけの bounded check である。
|
||||
|
||||
- genuinely missing binding decision / requirement / invariant / acceptance criterion。
|
||||
- Ticket thread、関連 Ticket、docs/workflows/code、durable context にすでに記録された decision。
|
||||
- coder が implementation latitude の中で選べる local tactic / bounded investigation。
|
||||
|
||||
### 4. Classification を決める
|
||||
|
||||
例:
|
||||
|
||||
- implementation-ready に見えるが authority boundary の explicit decision がなく、bounded context check でも recorded decision がない → concrete missing decision として `return_to_planning`; routing record に missing item / checked context / implementation latitude では足りない理由 / next question を書く。
|
||||
- implementation-ready に見えるが単に risk が高い → `implementation_ready` とし、IntentPacket に escalation / reviewer focus を明記する。
|
||||
- `allow-spawnpod-child-workspace-cwd` のように scope / Pod / authority-adjacent domain に触れるが、intent、authority invariants、implementation latitude、escalation conditions が指定済み → `implementation_ready`。context check で既存 SpawnPod scope/cwd policy と workflow invariants を確認し、reviewer focus に authority leakage / workspace-cwd separation / delegation-scope preservation を入れる。risk domain だけを理由に `planning` へ戻さない。
|
||||
- 実装済みだが review がない → `review_needed`
|
||||
- 要件が曖昧で spike も必要そう → `requirements_sync_needed` を優先し、調査問いを明確化する
|
||||
- 完了しているが close 権限がない → `close_ready` として dossier を返す
|
||||
|
||||
### 5. Routing decision を Ticket に記録する
|
||||
|
||||
`TicketComment` の role は通常 `decision` または `plan` を使う。
|
||||
|
||||
標準形:
|
||||
|
||||
```markdown
|
||||
Routing decision: <classification>
|
||||
|
||||
Reason:
|
||||
- ...
|
||||
|
||||
Evidence checked:
|
||||
- Ticket body / thread / artifacts
|
||||
- related Ticket(s) / orchestration plan records
|
||||
- code/docs/workflow paths
|
||||
- branch/worktree/Pod state if relevant
|
||||
|
||||
If returning to planning:
|
||||
- Missing decision/information: ...
|
||||
- Context checked: summarize the relevant subset from Evidence checked above
|
||||
- Why implementation latitude is insufficient: ...
|
||||
- Next planning question/action: ...
|
||||
|
||||
Next action:
|
||||
- ...
|
||||
|
||||
Escalate if:
|
||||
- ...
|
||||
```
|
||||
|
||||
### 6. IntentPacket を作る(implementation_ready の場合)
|
||||
|
||||
`multi-agent-workflow` に渡す前に、以下を短くまとめる。
|
||||
|
||||
```text
|
||||
Intent:
|
||||
- 何を実現するか。
|
||||
|
||||
Binding decisions / invariants:
|
||||
- 人間/Orchestrator/Ticket に記録済みで coder / reviewer が従うべき decision と、壊してはいけない design / authority boundary。
|
||||
- 具体的な除外・触れてはいけない場所が binding decision である場合はここに書く。
|
||||
|
||||
Requirements / acceptance criteria:
|
||||
- 完了時に満たす observable な要件と reviewer が判断できる基準。
|
||||
|
||||
Implementation latitude:
|
||||
- Coder が調査しながら選んでよい local tactic / file-local organization / bounded uncertainty。
|
||||
|
||||
Escalate if:
|
||||
- 親/人間に戻す判断条件。特に product / API / UX / authority boundary / explicit design constraint を変える必要が出た場合。
|
||||
|
||||
Validation:
|
||||
- 実行すべき format / build / test / doctor。
|
||||
|
||||
Current code map:
|
||||
- 実装対象と触ってはいけない場所。
|
||||
|
||||
Critical risks / reviewer focus:
|
||||
- reviewer にも見てほしい失敗パターン。reviewer は recorded intent / binding decisions / invariants / implementation latitude / acceptance criteria / explicit escalation conditions に照らして判断し、不記録の preferred tactic を基準にしない。
|
||||
```
|
||||
|
||||
IntentPacket が短く書けない場合、`implementation_ready` ではなく `return_to_planning` または `requirements_sync_needed` に戻す。
|
||||
|
||||
### 7. 後続 Workflow へ接続する
|
||||
|
||||
- `requirements_sync_needed` → `ticket-intake-workflow` / human / planning sync
|
||||
- `return_to_planning` → `ticket-preflight-workflow`(legacy compatibility canonical id の planning sync entry)
|
||||
- `spike_needed` → read-only investigation plan / Pod(許可後)
|
||||
- `implementation_ready` → `multi-agent-workflow`
|
||||
- `review_needed` → reviewer Pod / review workflow
|
||||
- concrete blocker / missing decision → human / parent Orchestrator
|
||||
- `close_ready` → close workflow / maintainer decision
|
||||
|
||||
## 完了条件
|
||||
|
||||
この Workflow の完了条件は次のいずれかである。
|
||||
|
||||
- routing decision が Ticket に記録され、次に接続する Workflow / human action が明確である。
|
||||
- `ready` / `queued` を `planning` に戻した場合、typed state-change/routing event に concrete missing decision / information、checked context、implementation latitude では足りない理由、次の planning question/action が残っている。
|
||||
- implementation-ready Ticket について IntentPacket が Ticket に記録され、`multi-agent-workflow` に渡せる。
|
||||
- requirements-sync / planning return / spike / blocked / review / close-ready の理由と次 action が Ticket に記録されている。
|
||||
- routing 不要と判断され、その理由が明確である。
|
||||
|
||||
## この Workflow で固定しないもの
|
||||
|
||||
- unattended scheduler。
|
||||
- LeaseStore / queue persistence。
|
||||
- queue/dashboard UI。
|
||||
- automatic Pod spawning policy。
|
||||
- TicketUpdate tool の導入。
|
||||
- external tracker integration。
|
||||
|
||||
これらは routing decision を人間/Orchestrator が明示的に扱えるようになった後の follow-up とする。
|
||||
@@ -1,89 +0,0 @@
|
||||
---
|
||||
description: 互換 canonical id を残した Ticket planning / requirements sync workflow。preflight を独立 lane や state として扱わない。
|
||||
model_invokation: true
|
||||
user_invocable: true
|
||||
requires: []
|
||||
---
|
||||
|
||||
# Ticket Planning / Requirements Sync Workflow
|
||||
|
||||
このファイル名は既存の workflow discovery / durable references 互換のために残す。新しい概念としての `preflight` state / lane / long-lived operation は作らない。ここで扱う作業は、Ticket を `planning` に戻して不足している決定・情報・受け入れ条件を同期するための planning activity である。
|
||||
|
||||
## 目的
|
||||
|
||||
実装に入る前または Orchestrator routing 中に、bounded project-context checks 後も concrete missing decision / information / acceptance condition が残る Ticket を planning に戻し、Ticket thread に監査可能な形で同期内容を残す。
|
||||
|
||||
この workflow は次をしてはいけない。
|
||||
|
||||
- `preflight` を state として扱う。
|
||||
- `preflight` vocabulary を current Ticket metadata として新規に書く。
|
||||
- 「リスクがある」だけで Ticket を戻す。
|
||||
- broad effort の進捗を保持するためだけの umbrella/progress-container Ticket を作る。
|
||||
- parent/child、sub-ticket、part-of、contains などの hierarchy/container relation を split/refinement の代替として設計する。
|
||||
- Coder / Reviewer / worktree mechanics を再設計する。
|
||||
|
||||
## 適用条件
|
||||
|
||||
次のいずれかを満たす場合に使う。
|
||||
|
||||
- `planning` Ticket の要件・受け入れ条件・制約を明確化する。
|
||||
- ユーザーが concrete work item として求めた initial planning / design / investigation Ticket の scope と acceptance criteria を明確化する。
|
||||
- `ready` または `queued` Ticket について、Orchestrator が Ticket/thread/artifacts、関連 Ticket/plan、関連 workflow/docs/code、durable project context、workspace evidence の relevant subset を bounded に確認したうえで、実装開始前に concrete missing decision / information を特定した。
|
||||
- 既存 Ticket に obsolete state vocabulary が残っており、planning terminology へ整理する必要がある。
|
||||
|
||||
適用しない条件:
|
||||
|
||||
- Orchestrator が具体的な不足項目、checked context、implementation latitude では足りない理由を言語化できない。
|
||||
- 単に変更範囲が広い、リスクが高い、またはレビュー観点が多いだけである。
|
||||
- すでに `queued -> inprogress` が記録され、実装 side effect が始まっている。
|
||||
|
||||
この場合は Ticket を戻さず、IntentPacket に escalation / reviewer focus を明記して進める。
|
||||
|
||||
## 手順
|
||||
|
||||
1. Ticket の current frontmatter と recent thread を読む。
|
||||
2. `ready` / `queued` から planning に戻す場合は、関連 Ticket/plan、docs/workflow/code、durable project context、workspace state の relevant subset を bounded に確認する。
|
||||
3. 不足している decision / information / acceptance condition を箇条書きで特定し、なぜ coder/reviewer の implementation latitude や escalation condition では足りないかを書く。
|
||||
4. `ready` または `queued` から戻す場合は、typed state change で `to = planning` を記録する。reason/body には concrete missing item、checked context、implementation latitude では足りない理由、次の planning question/action を含める。
|
||||
5. 既存の claimed live/restorable Intake/Planning Pod があり、利用可能な通知経路がある場合は、その Pod に同じ不足理由を通知する。実用的な経路が無い場合は follow-up として report する。
|
||||
6. Ticket body または thread に requirements sync 結果を残す。広い依頼を分割する場合は、concrete follow-up Ticket / Objective context / split decision record へ責務を分け、umbrella container や hierarchy relation を作らないことを記録する。
|
||||
7. Ticket が queue 可能になったら `planning -> ready` を typed state change / `TicketIntakeReady` で記録する。
|
||||
|
||||
## 記録テンプレート
|
||||
|
||||
```markdown
|
||||
## Planning sync
|
||||
|
||||
Missing decisions / information:
|
||||
- ...
|
||||
|
||||
Context checked:
|
||||
- Ticket/thread/artifacts: ...
|
||||
- related Ticket/plan: ...
|
||||
- docs/workflow/code/durable/workspace context: ...
|
||||
|
||||
Why implementation latitude is insufficient:
|
||||
- ...
|
||||
|
||||
Next planning question/action:
|
||||
- ...
|
||||
|
||||
Decisions made:
|
||||
- ...
|
||||
|
||||
Acceptance criteria changes:
|
||||
- ...
|
||||
|
||||
Risk / reviewer focus:
|
||||
- ...
|
||||
|
||||
Readiness:
|
||||
- Keep in planning because ...
|
||||
- or mark ready because ...
|
||||
```
|
||||
|
||||
## 完了条件
|
||||
|
||||
- Ticket に具体的な不足項目または解決済み decision が記録されている。
|
||||
- `planning` に戻した場合、state_changed event または thread に concrete missing decision / information、checked context、implementation latitude では足りない理由、次の planning question/action が残っている。
|
||||
- `ready` に進める場合、未解決の blocking attention/action が残っていない。
|
||||
@@ -1,171 +0,0 @@
|
||||
---
|
||||
description: yoi プロジェクトで child git worktree を作成・管理するための機械的手順。coder Pod に作らせず、orchestrator Pod が Orchestrator workspace/orchestration branch から実行する。
|
||||
model_invokation: true
|
||||
user_invocable: true
|
||||
requires: []
|
||||
---
|
||||
# Worktree Workflow
|
||||
|
||||
yoi プロジェクトで実装差分を root/original workspace から分離するため、記録済み implementation worktree root の `.worktree/<task-name>` に child git worktree を作る。これは **worktree の扱い方だけ** を定める Workflow であり、ticket 選定、coder / reviewer sibling の起動、外部レビュー、orchestration branch への integration は `/multi-agent-workflow` 側で扱う。
|
||||
|
||||
yoi では Pod の write scope が排他的に委譲されるため、child Pod の write scope は child worktree に限定する。child worktree は Yoi project records marker として tracked `.yoi` records を含んでよいが、generated/personal memory root `.yoi/memory`、local override、runtime state、logs、locks、secret-like files は出さない。Orchestrator workspace or recorded Ticket backend remains the place for active orchestration progress, review evidence, integration records, and Ticket lifecycle updates.
|
||||
|
||||
## 適用範囲
|
||||
|
||||
この Workflow は親 Pod / 下位 orchestrator が Orchestrator workspace/orchestration branch から実行する。root/original workspace は read/write/validation/cleanup/git 操作の対象にしない。implementation worktree は記録済み implementation worktree root 配下に作る。
|
||||
|
||||
- coder Pod にこの Workflow を渡して worktree を作らせない。
|
||||
- coder Pod は、orchestrator が作成済みの child worktree を受け取り、その中で実装・build・test・報告を行う。
|
||||
- reviewer Pod は、coder Pod の子ではなく orchestrator 配下の sibling として、原則 read-only で child worktree と Orchestrator workspace の Ticket/context を読む。
|
||||
- ticket 作成、active orchestration progress、review evidence、integration、Ticket lifecycle update は Orchestrator workspace または記録済み Ticket backend 側で扱う。
|
||||
- branch-local artifacts / dossiers / docs/report / tracked `.yoi` project records は、実装対象に必要なら child worktree 内で扱ってよい。
|
||||
|
||||
## 原則
|
||||
|
||||
- 1 ticket / 1 実装 task につき 1 worktree を作る。
|
||||
- 複数 ticket を下位 orchestrator に任せる場合も、実装差分は ticket / bounded task ごとに worktree を分ける。
|
||||
- worktree path は `<implementation-worktree-root>/.worktree/<task-name>`。
|
||||
- branch 名は原則 `<task-name>` と同じ kebab-case。
|
||||
- child worktree には `.yoi` project records を出してよい。
|
||||
- child worktree では `.yoi/memory`、local/runtime/log/lock/secret-like paths を sparse checkout で除外する。
|
||||
- active orchestration progress、review evidence、integration、Ticket lifecycle update は Orchestrator workspace または記録済み Ticket backend 側で扱う。branch-local artifacts/dossiers は child worktree 内に置いてよい。
|
||||
- push はしない。
|
||||
|
||||
## 事前確認
|
||||
|
||||
作成前に以下を確認する。
|
||||
|
||||
1. 対象 ticket / task が決まっているか。
|
||||
2. `<task-name>` が branch / path 名に使える kebab-case か。
|
||||
3. `git worktree add` を実行してよい許可があるか。
|
||||
4. root/original workspace で実行しようとしていないか。
|
||||
5. 同名 branch / worktree が既に存在しないか。
|
||||
6. coder / reviewer を sibling として扱う orchestrator が誰か明確か。
|
||||
|
||||
同名 branch がある場合は、既存 branch を使うか、人間に確認する。`git worktree add -b` で上書きしない。
|
||||
|
||||
## 作成手順
|
||||
|
||||
Orchestrator workspace で実行する。root/original workspace では実行しない。
|
||||
|
||||
```bash
|
||||
git -C <orchestrator-workspace-root> worktree add <implementation-worktree-root>/.worktree/<task-name> -b <task-name> HEAD
|
||||
|
||||
git -C <implementation-worktree-root>/.worktree/<task-name> sparse-checkout init --no-cone
|
||||
git -C <implementation-worktree-root>/.worktree/<task-name> sparse-checkout set --no-cone \
|
||||
'/*' \
|
||||
'!/.yoi/memory/' \
|
||||
'!/.yoi/memory/**' \
|
||||
'!/.yoi/logs/' \
|
||||
'!/.yoi/logs/**' \
|
||||
'!/.yoi/_logs/' \
|
||||
'!/.yoi/_logs/**' \
|
||||
'!/.yoi/**/_logs/' \
|
||||
'!/.yoi/**/_logs/**' \
|
||||
'!/.yoi/locks/' \
|
||||
'!/.yoi/locks/**' \
|
||||
'!/.yoi/**/*.log' \
|
||||
'!/.yoi/**/*.lock' \
|
||||
'!/.yoi/**/.lock' \
|
||||
'!/.yoi/override.local.toml' \
|
||||
'!/.yoi/**/*.local' \
|
||||
'!/.yoi/**/*.local.*' \
|
||||
'!/.yoi/local/' \
|
||||
'!/.yoi/local/**' \
|
||||
'!/.yoi/runtime/' \
|
||||
'!/.yoi/runtime/**' \
|
||||
'!/.yoi/pods/' \
|
||||
'!/.yoi/pods/**' \
|
||||
'!/.yoi/sessions/' \
|
||||
'!/.yoi/sessions/**' \
|
||||
'!/.yoi/sockets/' \
|
||||
'!/.yoi/sockets/**' \
|
||||
'!/.yoi/tmp/' \
|
||||
'!/.yoi/tmp/**' \
|
||||
'!/.yoi/cache/' \
|
||||
'!/.yoi/cache/**' \
|
||||
'!/.yoi/secrets/' \
|
||||
'!/.yoi/secrets/**' \
|
||||
'!/.yoi/**/*.secret' \
|
||||
'!/.yoi/**/*.secret.*'
|
||||
```
|
||||
|
||||
この sparse-checkout は `.yoi` 自体を除外しない。`.yoi/memory` は generated/personal memory marker として child worktree から外し、generated log trees は root `_logs` だけでなく recursive `.yoi/**/_logs/**` も外す。memory root detection の実装変更はこの Workflow では扱わない。
|
||||
|
||||
確認する。
|
||||
|
||||
```bash
|
||||
git -C <implementation-worktree-root>/.worktree/<task-name> status --short --branch
|
||||
test ! -e <implementation-worktree-root>/.worktree/<task-name>/.yoi/memory
|
||||
if test -d <implementation-worktree-root>/.worktree/<task-name>/.yoi; then
|
||||
test ! -e <implementation-worktree-root>/.worktree/<task-name>/.yoi/override.local.toml
|
||||
test -z "$(find <implementation-worktree-root>/.worktree/<task-name>/.yoi \
|
||||
\( -path '*/_logs' -o -path '*/logs' -o -path '*/locks' \
|
||||
-o -path '*/local' -o -path '*/runtime' -o -path '*/pods' \
|
||||
-o -path '*/sessions' -o -path '*/sockets' -o -path '*/tmp' \
|
||||
-o -path '*/cache' -o -path '*/secrets' -o -name '*.log' \
|
||||
-o -name '*.lock' -o -name '.lock' -o -name '*.local' \
|
||||
-o -name '*.local.*' -o -name '*.secret' -o -name '*.secret.*' \) \
|
||||
-print -quit)"
|
||||
fi
|
||||
```
|
||||
|
||||
この確認は `.yoi` project records の存在を失敗扱いしない。`.yoi/memory` と local/runtime/log/lock/secret-like paths が出ていないことを確認する。
|
||||
|
||||
失敗した場合は、worktree / branch / lock の状態を確認し、勝手に cleanup せず人間へ報告する。
|
||||
|
||||
## Pod へ渡す scope
|
||||
|
||||
Pod を使う場合、coder Pod の SpawnPod `cwd` は child worktree に設定する。`cwd` は child process/tool default cwd だけを変え、runtime workspace root・project record root・scope/authority は変えないため、下の明示 scope は別途渡す。
|
||||
|
||||
coder Pod 推奨 scope:
|
||||
|
||||
```text
|
||||
read: <implementation-worktree-root>/.worktree/<task-name>
|
||||
write: <implementation-worktree-root>/.worktree/<task-name>
|
||||
```
|
||||
|
||||
reviewer Pod 推奨 scope:
|
||||
|
||||
```text
|
||||
read: <implementation-worktree-root>/.worktree/<task-name>
|
||||
read: <orchestrator-workspace-root> # Ticket/intent/review context が必要な範囲に限定する
|
||||
```
|
||||
|
||||
reviewer は原則 write scope を持たない。review artifact を書かせる必要がある場合だけ、ticket artifacts など限定 directory を write scope として渡す。
|
||||
|
||||
より狭く切れる場合は、coder の write scope を変更対象 crate / directory まで狭めてよい。ただし build / test に必要な生成物を書けることを確認する。
|
||||
|
||||
## child worktree 内の禁止事項
|
||||
|
||||
- `.yoi/memory` を作らない / コピーしない / 復元しない。
|
||||
- local overrides、runtime sockets/state、Pod session mirrors、cache/tmp、logs、locks、secret-like files を作らない / コピーしない / commit しない。
|
||||
- root/original workspace の `TODO.md` / `tickets/` / `docs/report/` / `.yoi` を読まない・編集しない。
|
||||
- active orchestration progress、review evidence、integration、Ticket lifecycle update を child worktree 内だけで完結させない。
|
||||
- 実装対象に必要な tracked `.yoi` project records、branch-local artifacts / dossiers / docs/report は child worktree 内で扱ってよい。
|
||||
- merge / push / branch deletion / worktree remove をしない。
|
||||
- scope / permission / history persistence / prompt context 加工原則に関わる設計変更を無断で行わない。
|
||||
|
||||
## 完了時の扱い
|
||||
|
||||
worktree 作成 Workflow としては、完了時に integration しない。orchestration branch への integration、Ticket 完了、child worktree cleanup は `/multi-agent-workflow` 側で Orchestrator workspace に対して行う。root/original workspace は対象にしない。
|
||||
|
||||
coder Pod へ渡す完了報告項目の標準形:
|
||||
|
||||
- worktree path
|
||||
- branch 名
|
||||
- commit hash(coder Pod に commit を許可した場合)
|
||||
- 変更ファイル
|
||||
- 実装概要
|
||||
- 実行した build / test / format
|
||||
- 未解決事項
|
||||
- review に回せるか
|
||||
|
||||
reviewer Pod へ渡す完了報告項目の標準形:
|
||||
|
||||
- 読んだ ticket / intent packet / diff
|
||||
- 実際のコード変更の概念的説明
|
||||
- intent / requirements / invariant との対応
|
||||
- blocker / non-blocker / follow-up
|
||||
- validation の妥当性
|
||||
- 親または上位 orchestrator が判断すべき残論点
|
||||
@@ -1,22 +0,0 @@
|
||||
workspace_id = "0197a949-4b6b-7f2a-9d9a-1f87e3a4c5b6"
|
||||
created_at = "2026-06-23T00:00:00Z"
|
||||
display_name = "yoi"
|
||||
|
||||
[ticket]
|
||||
language = "Japanese"
|
||||
|
||||
[ticket.backend]
|
||||
provider = "builtin:yoi_local"
|
||||
root = ".yoi/tickets"
|
||||
|
||||
[ticket.roles.intake]
|
||||
profile = "builtin:intake"
|
||||
|
||||
[ticket.roles.orchestrator]
|
||||
profile = "builtin:orchestrator"
|
||||
|
||||
[ticket.roles.coder]
|
||||
profile = "builtin:coder"
|
||||
|
||||
[ticket.roles.reviewer]
|
||||
profile = "builtin:reviewer"
|
||||
@@ -1,68 +1,22 @@
|
||||
すでにシステムのドッグフーディングに成功しているが、一旦安定した旧バージョンで、ブラウザ/TUI Client/backend/runtimeの分離とチームスペースとしてのworkspaceを作るObjectiveを進めている。
|
||||
# 開発時の入口
|
||||
|
||||
## このシステムに置ける設計要旨
|
||||
変更量を最小にするために設計を歪めたり、不必要な後方互換性を作ったりしない。長期的な保守性と型安全性を優先する。
|
||||
|
||||
- プロンプトはすべて resources/promptsに集約している。管理効率の向上と同時に、ユーザーがオーバーライドする形式でもある。
|
||||
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
|
||||
## 作業条件に応じて読む規則
|
||||
|
||||
### LLM コンテキストの加工原則
|
||||
該当する作業の前に、次の文書を読み、その規則に従う。
|
||||
|
||||
LLM に投げる context への割り込みは、大きく2種類に分かれる。**前者は許されるが、後者は禁止**。
|
||||
- 文書・説明コメントを追加、変更、削除する → [文書管理規則](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)
|
||||
|
||||
Workerの状態から純粋に再現可能で、且つ揮発性の無い操作であることが望ましい。(pruning、tool result の content 切り詰め、prompt cache anchor の付与等)。
|
||||
原則として、コンテキストは積み重ねるものであり、一時的にメッセージを差し込むことや、過去のメッセージを改ざんすることはKVキャッシュのヒット率を下げる。
|
||||
## 常に守ること
|
||||
|
||||
**禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
|
||||
|
||||
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / WorkerEvent / typed `SystemItem` reminder はこの原則で扱う。
|
||||
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
|
||||
|
||||
---
|
||||
|
||||
## 検証
|
||||
|
||||
開発中は、変更した契約を証明する最小の 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
+655
-664
File diff suppressed because it is too large
Load Diff
+60
-3
@@ -4,12 +4,18 @@ members = [
|
||||
"crates/client",
|
||||
"crates/agen",
|
||||
"crates/agen-macros",
|
||||
"crates/api-macros",
|
||||
"crates/api-macros-impl",
|
||||
"crates/session-store",
|
||||
"crates/standalone",
|
||||
"crates/job",
|
||||
"crates/secrets",
|
||||
"crates/manifest",
|
||||
"crates/mcp",
|
||||
"crates/worker",
|
||||
"crates/worker-runtime",
|
||||
"crates/runtime-api",
|
||||
"crates/server-api",
|
||||
"crates/plugin-pdk",
|
||||
"crates/yoi",
|
||||
"crates/protocol",
|
||||
@@ -19,15 +25,18 @@ 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",
|
||||
"crates/workspace-api",
|
||||
"crates/workspace-server",
|
||||
"tests/e2e",
|
||||
]
|
||||
@@ -35,12 +44,18 @@ default-members = [
|
||||
"crates/client",
|
||||
"crates/agen",
|
||||
"crates/agen-macros",
|
||||
"crates/api-macros",
|
||||
"crates/api-macros-impl",
|
||||
"crates/session-store",
|
||||
"crates/standalone",
|
||||
"crates/job",
|
||||
"crates/secrets",
|
||||
"crates/manifest",
|
||||
"crates/mcp",
|
||||
"crates/worker",
|
||||
"crates/worker-runtime",
|
||||
"crates/runtime-api",
|
||||
"crates/server-api",
|
||||
"crates/plugin-pdk",
|
||||
"crates/yoi",
|
||||
"crates/protocol",
|
||||
@@ -50,45 +65,73 @@ 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",
|
||||
"crates/workspace-api",
|
||||
"crates/workspace-server",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
rust-version = "1.88"
|
||||
|
||||
[profile.dev]
|
||||
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" }
|
||||
agen = { path = "crates/agen", version = "0.2" }
|
||||
agen-macros = { path = "crates/agen-macros", version = "0.2" }
|
||||
api-macros = { path = "crates/api-macros" }
|
||||
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" }
|
||||
workspace-api = { path = "crates/workspace-api" }
|
||||
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
|
||||
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" }
|
||||
@@ -101,11 +144,13 @@ yoi-workspace-server = { path = "crates/workspace-server" }
|
||||
async-trait = "0.1"
|
||||
axum = "0.8"
|
||||
base64 = "0.22.1"
|
||||
bytes = "1"
|
||||
decodal = "0.4.0"
|
||||
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"
|
||||
@@ -123,7 +168,19 @@ tokio-tungstenite = "0.29"
|
||||
tower = "0.5"
|
||||
toml = "1.1"
|
||||
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.
|
||||
|
||||
Runtime surfaces use `yoi`, `.yoi`, `~/.yoi`, and `YOI_*`.
|
||||
|
||||
## 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 `.yoi/tickets/` and `yoi ticket ...`; git history plus Ticket files are the authoritative project record.
|
||||
|
||||
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.
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use agen::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
|
||||
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||
use agen::{Engine, EngineRunExit, StopReason};
|
||||
use agen::{Engine, EngineRunExit, RunInterruptionReason};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::main]
|
||||
@@ -51,7 +51,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
EngineRunExit::Finished => println!("✅ Task completed normally"),
|
||||
EngineRunExit::Paused => println!("⏸️ Task paused"),
|
||||
EngineRunExit::Yielded => println!("↩️ Task yielded"),
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached) => {
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached) => {
|
||||
println!("🔒 Turn limit reached")
|
||||
}
|
||||
EngineRunExit::Interrupted(reason) => println!("❌ Task interrupted: {reason:?}"),
|
||||
|
||||
@@ -39,8 +39,8 @@ use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use agen::{
|
||||
Engine, EngineRunExit, StopReason,
|
||||
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
|
||||
Engine, EngineRunExit, RunInterruptionReason,
|
||||
interceptor::{Interceptor, InterceptorResult, PostToolAction, ToolResultInfo},
|
||||
llm_client::{
|
||||
LlmClient,
|
||||
capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport},
|
||||
@@ -280,7 +280,10 @@ impl ToolResultPrinterPolicy {
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for ToolResultPrinterPolicy {
|
||||
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
|
||||
async fn post_tool_call(
|
||||
&self,
|
||||
info: &ToolResultInfo<'_, ()>,
|
||||
) -> InterceptorResult<PostToolAction> {
|
||||
let name = self
|
||||
.call_names
|
||||
.lock()
|
||||
@@ -294,7 +297,7 @@ impl Interceptor for ToolResultPrinterPolicy {
|
||||
println!(" Result ({}): ✅ {}", name, info.result.summary);
|
||||
}
|
||||
|
||||
PostToolAction::Continue
|
||||
Ok(PostToolAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,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(
|
||||
@@ -478,7 +481,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// One-shot mode
|
||||
if let Some(prompt) = args.prompt {
|
||||
let output = engine.run(&mut history, &prompt).await;
|
||||
if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) = output.result {
|
||||
if let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(error)) = output.result
|
||||
{
|
||||
eprintln!("\n❌ Error: {error}");
|
||||
}
|
||||
|
||||
@@ -518,7 +522,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
break;
|
||||
}
|
||||
|
||||
if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) =
|
||||
if let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(error)) =
|
||||
locked.run(&mut history, input).await
|
||||
{
|
||||
eprintln!("\n❌ Error: {error}");
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+1921
-230
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
+274
-32
@@ -1,16 +1,218 @@
|
||||
//! 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;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::Item;
|
||||
use crate::engine::EngineRunExit;
|
||||
use crate::history::HistoryEntry;
|
||||
use crate::tool::{Tool, ToolCall, ToolExecutionContext, ToolMeta, ToolResult};
|
||||
|
||||
// =============================================================================
|
||||
// Typed lifecycle metadata and failures
|
||||
// =============================================================================
|
||||
|
||||
/// Maximum UTF-8 byte length retained for interceptor diagnostics.
|
||||
pub const MAX_INTERCEPTOR_DIAGNOSTIC_BYTES: usize = 1024;
|
||||
|
||||
/// Stable category for the source of an interceptor failure.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InterceptorErrorCategory {
|
||||
Policy,
|
||||
Dependency,
|
||||
ContractViolation,
|
||||
Internal,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InterceptorErrorCategory {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::Policy => "policy",
|
||||
Self::Dependency => "dependency",
|
||||
Self::ContractViolation => "contract_violation",
|
||||
Self::Internal => "internal",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A typed, bounded failure returned by an [`Interceptor`] implementation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("{category}: {diagnostic}")]
|
||||
pub struct InterceptorError {
|
||||
category: InterceptorErrorCategory,
|
||||
diagnostic: String,
|
||||
}
|
||||
|
||||
impl InterceptorError {
|
||||
pub fn new(category: InterceptorErrorCategory, diagnostic: impl Into<String>) -> Self {
|
||||
let mut diagnostic = diagnostic.into();
|
||||
if diagnostic.len() > MAX_INTERCEPTOR_DIAGNOSTIC_BYTES {
|
||||
let mut end = MAX_INTERCEPTOR_DIAGNOSTIC_BYTES;
|
||||
while !diagnostic.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
diagnostic.truncate(end);
|
||||
}
|
||||
Self {
|
||||
category,
|
||||
diagnostic,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn category(&self) -> InterceptorErrorCategory {
|
||||
self.category
|
||||
}
|
||||
|
||||
pub fn diagnostic(&self) -> &str {
|
||||
&self.diagnostic
|
||||
}
|
||||
}
|
||||
|
||||
/// The lifecycle phase at which an interceptor callback executes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum InterceptorPhase {
|
||||
#[default]
|
||||
PromptSubmit,
|
||||
PendingHistoryAppends,
|
||||
PreLlmRequest,
|
||||
PreToolCall,
|
||||
PostToolCall,
|
||||
AssistantTurnEnd,
|
||||
RunExit,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InterceptorPhase {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::PromptSubmit => "prompt_submit",
|
||||
Self::PendingHistoryAppends => "pending_history_appends",
|
||||
Self::PreLlmRequest => "pre_llm_request",
|
||||
Self::PreToolCall => "pre_tool_call",
|
||||
Self::PostToolCall => "post_tool_call",
|
||||
Self::AssistantTurnEnd => "assistant_turn_end",
|
||||
Self::RunExit => "run_exit",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub struct InterceptorRunId(pub u64);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct InterceptorTurnId(pub u64);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum InterceptorCallId {
|
||||
Llm(u64),
|
||||
Tool(String),
|
||||
}
|
||||
|
||||
/// Saturating public counter used by interceptor contexts.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
|
||||
pub struct InterceptorCounter(u32);
|
||||
|
||||
impl InterceptorCounter {
|
||||
pub fn from_usize(value: usize) -> Self {
|
||||
Self(u32::try_from(value).unwrap_or(u32::MAX))
|
||||
}
|
||||
|
||||
pub fn get(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct InterceptorCounters {
|
||||
pub invocation: InterceptorCounter,
|
||||
pub engine_turn: InterceptorCounter,
|
||||
pub run_turn: InterceptorCounter,
|
||||
pub llm_call: InterceptorCounter,
|
||||
pub tool_batch: InterceptorCounter,
|
||||
pub tool_call: InterceptorCounter,
|
||||
}
|
||||
|
||||
/// Identity, phase, and bounded counters common to every lifecycle callback.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct InterceptorInvocation {
|
||||
pub run_id: InterceptorRunId,
|
||||
pub turn_id: Option<InterceptorTurnId>,
|
||||
pub call_id: Option<InterceptorCallId>,
|
||||
pub phase: InterceptorPhase,
|
||||
pub counters: InterceptorCounters,
|
||||
}
|
||||
|
||||
/// An interceptor failure bound to the exact Engine lifecycle phase that ran it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("{phase} interceptor failed: {error}")]
|
||||
pub struct InterceptorFailure {
|
||||
phase: InterceptorPhase,
|
||||
#[source]
|
||||
error: InterceptorError,
|
||||
}
|
||||
|
||||
impl InterceptorFailure {
|
||||
pub(crate) fn new(phase: InterceptorPhase, error: InterceptorError) -> Self {
|
||||
Self { phase, error }
|
||||
}
|
||||
|
||||
pub fn phase(&self) -> InterceptorPhase {
|
||||
self.phase
|
||||
}
|
||||
|
||||
pub fn error(&self) -> &InterceptorError {
|
||||
&self.error
|
||||
}
|
||||
}
|
||||
|
||||
pub type InterceptorResult<T> = Result<T, InterceptorError>;
|
||||
|
||||
// =============================================================================
|
||||
// Lifecycle Contexts
|
||||
// =============================================================================
|
||||
|
||||
pub struct PromptSubmitContext<'a, A = ()> {
|
||||
pub invocation: InterceptorInvocation,
|
||||
pub item: &'a mut Item,
|
||||
pub history: &'a [HistoryEntry<A>],
|
||||
}
|
||||
|
||||
pub struct PendingHistoryAppendsContext<'a, A = ()> {
|
||||
pub invocation: InterceptorInvocation,
|
||||
pub history: &'a [HistoryEntry<A>],
|
||||
}
|
||||
|
||||
pub struct PreLlmRequestContext<'a, A = ()> {
|
||||
pub invocation: InterceptorInvocation,
|
||||
pub items: &'a mut Vec<Item>,
|
||||
pub history: &'a [HistoryEntry<A>],
|
||||
}
|
||||
|
||||
pub struct AssistantTurnEndContext<'a, A = ()> {
|
||||
pub invocation: InterceptorInvocation,
|
||||
pub assistant_entries: &'a [HistoryEntry<A>],
|
||||
pub history: &'a [HistoryEntry<A>],
|
||||
pub tool_calls: &'a [ToolCall],
|
||||
}
|
||||
|
||||
pub struct RunExitContext<'a, A = ()> {
|
||||
pub invocation: InterceptorInvocation,
|
||||
pub exit: &'a EngineRunExit,
|
||||
pub history: &'a [HistoryEntry<A>],
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Action Enums
|
||||
// =============================================================================
|
||||
@@ -86,9 +288,9 @@ pub enum PostToolAction {
|
||||
/// Action at the end of a turn (when LLM produces no tool calls).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TurnEndAction {
|
||||
/// Turn is finished, return to caller.
|
||||
/// Accept the Engine's natural next phase: execute tools, or finish when none exist.
|
||||
Finish,
|
||||
/// Continue with additional messages injected into history.
|
||||
/// Commit additional messages, then continue through the natural next phase.
|
||||
ContinueWithMessages(Vec<Item>),
|
||||
/// Pause execution (can be resumed later).
|
||||
Pause,
|
||||
@@ -99,8 +301,9 @@ pub enum TurnEndAction {
|
||||
// =============================================================================
|
||||
|
||||
/// Context for pre-tool-call decisions.
|
||||
pub struct ToolCallInfo {
|
||||
/// Tool call information (modifiable).
|
||||
pub struct ToolCallInfo<'a, A = ()> {
|
||||
pub invocation: InterceptorInvocation,
|
||||
pub history: &'a [HistoryEntry<A>],
|
||||
pub call: ToolCall,
|
||||
/// Tool meta information.
|
||||
pub meta: ToolMeta,
|
||||
@@ -111,10 +314,11 @@ pub struct ToolCallInfo {
|
||||
}
|
||||
|
||||
/// Context for post-tool-call decisions.
|
||||
pub struct ToolResultInfo {
|
||||
/// Original tool call.
|
||||
pub struct ToolResultInfo<'a, A = ()> {
|
||||
pub invocation: InterceptorInvocation,
|
||||
pub history: &'a [HistoryEntry<A>],
|
||||
pub call: ToolCall,
|
||||
/// Tool execution result (modifiable).
|
||||
/// Committed terminal tool execution result.
|
||||
pub result: ToolResult,
|
||||
/// Tool meta information.
|
||||
pub meta: ToolMeta,
|
||||
@@ -130,14 +334,30 @@ pub struct ToolResultInfo {
|
||||
|
||||
/// Intercepts the Engine execution loop at key decision points.
|
||||
///
|
||||
/// All methods have default implementations that let the Engine
|
||||
/// proceed without intervention. Callers provide richer implementations for
|
||||
/// approval flows, permission checks, etc.
|
||||
/// Every lifecycle method is asynchronous and returns [`InterceptorResult`],
|
||||
/// keeping implementation failure separate from the method's control-flow
|
||||
/// action. The Engine reports a failure as a typed run interruption annotated
|
||||
/// with the exact [`InterceptorPhase`] that failed.
|
||||
///
|
||||
/// All methods have default implementations that let the Engine proceed
|
||||
/// without intervention. Callers provide richer implementations for approval
|
||||
/// flows, permission checks, and other trusted host adaptation.
|
||||
#[async_trait]
|
||||
pub trait Interceptor: Send + Sync {
|
||||
/// Called after receiving user input, before adding to history.
|
||||
async fn on_prompt_submit(&self, _item: &mut Item) -> PromptAction {
|
||||
PromptAction::Continue
|
||||
pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
|
||||
/// Whether side effects may start before [`Self::on_assistant_turn_end`].
|
||||
///
|
||||
/// The conservative default preserves the historical whole-response gate.
|
||||
/// Interceptors that do not use turn-end policy may opt in explicitly.
|
||||
fn supports_early_tool_dispatch(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Called after receiving user input, before adding it to Engine history.
|
||||
async fn on_prompt_submit(
|
||||
&self,
|
||||
_context: PromptSubmitContext<'_, A>,
|
||||
) -> InterceptorResult<PromptAction> {
|
||||
Ok(PromptAction::Continue)
|
||||
}
|
||||
|
||||
/// Items that should be **committed to `engine.history`** just
|
||||
@@ -158,7 +378,10 @@ pub trait Interceptor: Send + Sync {
|
||||
/// reproducible per-request transformations (pruning, content
|
||||
/// trimming, cache anchors) that depend only on the existing
|
||||
/// history.
|
||||
async fn pending_history_appends(&self) -> Result<Vec<Item>, String> {
|
||||
async fn pending_history_appends(
|
||||
&self,
|
||||
_context: PendingHistoryAppendsContext<'_, A>,
|
||||
) -> InterceptorResult<Vec<Item>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
@@ -170,27 +393,42 @@ pub trait Interceptor: Send + Sync {
|
||||
/// If an interceptor derives a human/model-visible nudge from the current
|
||||
/// request context, return [`PreRequestAction::ContinueWith`] so the Engine
|
||||
/// commits it to history before the request is sent.
|
||||
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
|
||||
PreRequestAction::Continue
|
||||
async fn pre_llm_request(
|
||||
&self,
|
||||
_context: PreLlmRequestContext<'_, A>,
|
||||
) -> InterceptorResult<PreRequestAction> {
|
||||
Ok(PreRequestAction::Continue)
|
||||
}
|
||||
|
||||
/// Called before each tool is executed.
|
||||
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction {
|
||||
PreToolAction::Continue
|
||||
async fn pre_tool_call(
|
||||
&self,
|
||||
_info: &mut ToolCallInfo<'_, A>,
|
||||
) -> InterceptorResult<PreToolAction> {
|
||||
Ok(PreToolAction::Continue)
|
||||
}
|
||||
|
||||
/// Called after each tool completes.
|
||||
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction {
|
||||
PostToolAction::Continue
|
||||
/// Called after each tool reaches one terminal result and that result is committed.
|
||||
async fn post_tool_call(
|
||||
&self,
|
||||
_info: &ToolResultInfo<'_, A>,
|
||||
) -> InterceptorResult<PostToolAction> {
|
||||
Ok(PostToolAction::Continue)
|
||||
}
|
||||
|
||||
/// Called when a turn ends with no tool calls.
|
||||
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
|
||||
TurnEndAction::Finish
|
||||
/// Called after every terminal assistant response is committed and before
|
||||
/// the Engine decides whether to execute tools, continue, or finish.
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
_context: AssistantTurnEndContext<'_, A>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
Ok(TurnEndAction::Finish)
|
||||
}
|
||||
|
||||
/// Called when execution is interrupted (abort or cancel).
|
||||
async fn on_abort(&self, _reason: &str) {}
|
||||
/// Called once for the terminal outcome of each public run or resume call.
|
||||
async fn on_run_exit(&self, _context: RunExitContext<'_, A>) -> InterceptorResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Default interceptor: no intervention. Engine proceeds through the loop
|
||||
@@ -198,4 +436,8 @@ pub trait Interceptor: Send + Sync {
|
||||
pub(crate) struct DefaultInterceptor;
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for DefaultInterceptor {}
|
||||
impl<A: Send + Sync> Interceptor<A> for DefaultInterceptor {
|
||||
fn supports_early_tool_dispatch(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
+11
-5
@@ -3,6 +3,7 @@
|
||||
mod engine;
|
||||
mod handler;
|
||||
mod history;
|
||||
mod image_recovery;
|
||||
mod message;
|
||||
|
||||
pub(crate) mod callback;
|
||||
@@ -22,11 +23,17 @@ pub use agen_macros::{description, tool, tool_registry};
|
||||
pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
|
||||
pub use engine::{
|
||||
Engine, EngineConfig, EngineError, EngineResult, EngineRunExit, EngineRunOutput,
|
||||
LlmRetryNotice, StopReason, ToolRegistryError,
|
||||
LlmRetryNotice, RunInterruptionReason, ToolCallDispatchMode, ToolRegistryError,
|
||||
};
|
||||
pub use handler::ToolUseBlockStart;
|
||||
pub use history::{History, HistoryEntry};
|
||||
pub use interceptor::Interceptor;
|
||||
pub use interceptor::{
|
||||
AssistantTurnEndContext, Interceptor, InterceptorCallId, InterceptorCounter,
|
||||
InterceptorCounters, InterceptorError, InterceptorErrorCategory, InterceptorFailure,
|
||||
InterceptorInvocation, InterceptorPhase, InterceptorResult, InterceptorRunId,
|
||||
InterceptorTurnId, MAX_INTERCEPTOR_DIAGNOSTIC_BYTES, PendingHistoryAppendsContext,
|
||||
PreLlmRequestContext, PromptSubmitContext, RunExitContext,
|
||||
};
|
||||
pub use message::{ContentPart, Item, Message, Role};
|
||||
pub use tool::{
|
||||
ToolCall, ToolExecutionContext, ToolExecutionHandle, ToolExecutionPolicy,
|
||||
@@ -35,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,
|
||||
@@ -38,19 +38,29 @@ impl std::fmt::Display for ConfigWarning {
|
||||
|
||||
pub type ResponseStream = Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>;
|
||||
|
||||
/// LLMクライアントのtrait
|
||||
/// Whether normalized `BlockStop(ToolUse)` events prove one call is complete.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum ToolCallCompletionSupport {
|
||||
/// Individual tool-call completion events are provider-authoritative.
|
||||
PerBlock,
|
||||
/// Tool calls are complete only once the response stream itself completes.
|
||||
#[default]
|
||||
ResponseComplete,
|
||||
}
|
||||
|
||||
/// 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>`.
|
||||
@@ -59,15 +69,20 @@ pub trait LlmClient: Send + Sync {
|
||||
/// compaction) without access to the original construction parameters.
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient>;
|
||||
|
||||
/// 設定をバリデーションし、未サポートの設定があれば警告を返す
|
||||
/// Describe the strongest safe tool-call completion boundary.
|
||||
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
|
||||
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()
|
||||
}
|
||||
@@ -79,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> {
|
||||
@@ -92,6 +107,10 @@ impl LlmClient for Box<dyn LlmClient> {
|
||||
(**self).clone_boxed()
|
||||
}
|
||||
|
||||
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
|
||||
(**self).tool_call_completion_support()
|
||||
}
|
||||
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
(**self).validate_config(config)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
@@ -263,6 +261,7 @@ impl AnthropicScheme {
|
||||
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
match item {
|
||||
Item::AssistantResponseBoundary { .. } => {}
|
||||
Item::Message { role, content, .. } => {
|
||||
let parts: Vec<AnthropicContentPart> = content
|
||||
.iter()
|
||||
@@ -360,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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -485,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,
|
||||
@@ -496,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,
|
||||
@@ -900,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);
|
||||
@@ -932,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")
|
||||
@@ -975,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?")
|
||||
@@ -999,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 {
|
||||
@@ -1022,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 {
|
||||
@@ -1049,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;
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::llm_client::{
|
||||
ClientError,
|
||||
auth::AuthRequirement,
|
||||
capability::ModelCapability,
|
||||
client::ToolCallCompletionSupport,
|
||||
event::{BlockType, Event, ReasoningBlockData},
|
||||
scheme::Scheme,
|
||||
types::Request,
|
||||
@@ -16,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,
|
||||
@@ -63,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
|
||||
}
|
||||
|
||||
@@ -104,4 +101,8 @@ impl Scheme for AnthropicScheme {
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
super::capability::default_capability()
|
||||
}
|
||||
|
||||
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
|
||||
ToolCallCompletionSupport::PerBlock
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,117 @@ 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)]
|
||||
pub(crate) struct FunctionCall {
|
||||
/// 関数名
|
||||
/// Function name.
|
||||
pub name: String,
|
||||
/// 引数
|
||||
/// Arguments.
|
||||
pub args: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 使用量メタデータ
|
||||
/// 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
|
||||
// ============================================================================
|
||||
|
||||
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)` - パースエラー
|
||||
/// * `Ok(Some(Vec<Event>))` - Conversion succeeded.
|
||||
/// * `Ok(None)` - Event ignored.
|
||||
/// * `Err(ClientError)` - Parse error.
|
||||
pub(crate) fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
|
||||
// データが空または無効な場合はスキップ
|
||||
// Skip empty or invalid data.
|
||||
if data.is_empty() || data == "[DONE]" {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -136,12 +136,12 @@ 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);
|
||||
@@ -149,26 +149,26 @@ impl GeminiScheme {
|
||||
if let Some(content) = candidate.content {
|
||||
if let Some(parts) = content.parts {
|
||||
for (part_index, part) in parts.iter().enumerate() {
|
||||
// テキストデルタ
|
||||
// Text delta.
|
||||
if let Some(text) = &part.text {
|
||||
if !text.is_empty() {
|
||||
// Geminiは明示的なBlockStartを送らないため、
|
||||
// TextDeltaを直接送る(Timelineが暗黙的に開始を処理)
|
||||
// Gemini sends no explicit BlockStart, so emit TextDelta
|
||||
// directly (Timeline handles the implicit start).
|
||||
events.push(Event::text_delta(part_index, text.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// 関数呼び出し
|
||||
// Function call.
|
||||
if let Some(function_call) = &part.function_call {
|
||||
// 関数呼び出しの開始
|
||||
// Geminiでは関数呼び出しは一度に送られることが多い
|
||||
// ストリーミング引数が有効な場合は部分的に送られる可能性がある
|
||||
// Function call start.
|
||||
// Gemini usually sends function calls all at once, but may
|
||||
// send partial calls when streaming arguments are enabled.
|
||||
|
||||
// 関数呼び出しIDはGeminiにはないので、名前をIDとして使用
|
||||
// Gemini has no function call IDs, so use the name as the ID.
|
||||
let function_id = format!("call_{}", function_call.name);
|
||||
|
||||
events.push(Event::BlockStart(BlockStart {
|
||||
index: candidate_index * 10 + part_index, // 複合インデックス
|
||||
index: candidate_index * 10 + part_index, // Composite index.
|
||||
block_type: BlockType::ToolUse,
|
||||
metadata: BlockMetadata::ToolUse {
|
||||
id: function_id,
|
||||
@@ -176,7 +176,7 @@ 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" {
|
||||
@@ -191,7 +191,7 @@ impl GeminiScheme {
|
||||
}
|
||||
}
|
||||
|
||||
// 完了理由
|
||||
// Finish reason.
|
||||
if let Some(finish_reason) = candidate.finish_reason {
|
||||
let stop_reason = match finish_reason.as_str() {
|
||||
"STOP" => Some(StopReason::EndTurn),
|
||||
@@ -200,7 +200,7 @@ impl GeminiScheme {
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// テキストブロックの停止
|
||||
// Text block stop.
|
||||
events.push(Event::BlockStop(BlockStop {
|
||||
index: candidate_index,
|
||||
block_type: BlockType::Text,
|
||||
@@ -262,7 +262,7 @@ mod tests {
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
|
||||
// Usageイベントが含まれるはず
|
||||
// Should contain a Usage event.
|
||||
let usage_event = events.iter().find(|e| matches!(e, Event::Usage(_)));
|
||||
assert!(usage_event.is_some());
|
||||
|
||||
@@ -280,7 +280,7 @@ mod tests {
|
||||
|
||||
let events = scheme.parse_event(data).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 +293,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(_))
|
||||
@@ -311,7 +311,7 @@ mod tests {
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
|
||||
// BlockStopイベントがあるはず
|
||||
// Should contain a BlockStop event.
|
||||
let stop_event = events.iter().find(|e| matches!(e, Event::BlockStop(_)));
|
||||
assert!(stop_event.is_some());
|
||||
|
||||
|
||||
@@ -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),
|
||||
@@ -242,6 +242,7 @@ impl GeminiScheme {
|
||||
|
||||
for item in items {
|
||||
match item {
|
||||
Item::AssistantResponseBoundary { .. } => {}
|
||||
Item::Message { role, content, .. } => {
|
||||
// Flush pending parts
|
||||
self.flush_pending_parts(
|
||||
|
||||
@@ -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;
|
||||
@@ -16,47 +17,45 @@ use serde_json::Value;
|
||||
|
||||
use super::auth::AuthRequirement;
|
||||
use super::capability::ModelCapability;
|
||||
use super::client::ConfigWarning;
|
||||
use super::client::{ConfigWarning, ToolCallCompletionSupport};
|
||||
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,16 +75,46 @@ 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;
|
||||
|
||||
/// scheme 側でサポートしていない `RequestConfig` フィールドを
|
||||
/// 警告として返す(例: OpenAI Chat は `top_k` 非対応)。
|
||||
/// デフォルトは空 Vec。
|
||||
/// Strongest safe completion boundary for streamed tool calls.
|
||||
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
|
||||
ToolCallCompletionSupport::ResponseComplete
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod completion_support_tests {
|
||||
use super::*;
|
||||
use crate::llm_client::ToolCallCompletionSupport;
|
||||
|
||||
#[test]
|
||||
fn only_schemes_with_authoritative_per_call_done_events_enable_early_dispatch() {
|
||||
assert_eq!(
|
||||
anthropic::AnthropicScheme::new().tool_call_completion_support(),
|
||||
ToolCallCompletionSupport::PerBlock
|
||||
);
|
||||
assert_eq!(
|
||||
openai_responses::OpenAIResponsesScheme::new().tool_call_completion_support(),
|
||||
ToolCallCompletionSupport::PerBlock
|
||||
);
|
||||
assert_eq!(
|
||||
openai_chat::OpenAIScheme::new().tool_call_completion_support(),
|
||||
ToolCallCompletionSupport::ResponseComplete
|
||||
);
|
||||
assert_eq!(
|
||||
gemini::GeminiScheme::new().tool_call_completion_support(),
|
||||
ToolCallCompletionSupport::ResponseComplete
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
@@ -221,6 +221,7 @@ impl OpenAIScheme {
|
||||
);
|
||||
}
|
||||
match item {
|
||||
Item::AssistantResponseBoundary { .. } => {}
|
||||
Item::Message { role, content, .. } => {
|
||||
// Flush pending tool calls
|
||||
self.flush_pending_assistant(
|
||||
|
||||
@@ -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,31 +18,69 @@ 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>,
|
||||
}
|
||||
|
||||
/// 1 つの reasoning output_item の蓄積バッファ。
|
||||
/// A tool input stream accumulated until an authoritative done payload arrives.
|
||||
#[derive(Debug)]
|
||||
struct PendingToolInput {
|
||||
kind: ToolInputKind,
|
||||
streamed: String,
|
||||
saw_delta: bool,
|
||||
final_value: Option<String>,
|
||||
}
|
||||
|
||||
impl PendingToolInput {
|
||||
fn new(kind: ToolInputKind) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
streamed: String::new(),
|
||||
saw_delta: false,
|
||||
final_value: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ToolInputKind {
|
||||
FunctionCall,
|
||||
CustomToolCall,
|
||||
}
|
||||
|
||||
impl ToolInputKind {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::FunctionCall => "function-call arguments",
|
||||
Self::CustomToolCall => "custom-tool input",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
@@ -69,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)
|
||||
@@ -120,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 },
|
||||
}
|
||||
|
||||
@@ -135,7 +173,7 @@ struct SlotInfo {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSE イベントの JSON 構造
|
||||
// SSE event JSON structures
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -151,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 {
|
||||
@@ -162,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>,
|
||||
},
|
||||
@@ -173,6 +211,8 @@ enum OutputItem {
|
||||
id: Option<String>,
|
||||
call_id: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
arguments: Option<String>,
|
||||
},
|
||||
CustomToolCall {
|
||||
#[allow(dead_code)]
|
||||
@@ -180,6 +220,8 @@ enum OutputItem {
|
||||
id: Option<String>,
|
||||
call_id: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
input: Option<String>,
|
||||
},
|
||||
#[serde(other)]
|
||||
Other,
|
||||
@@ -200,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 {
|
||||
@@ -260,12 +302,24 @@ struct FunctionCallArgumentsDelta {
|
||||
delta: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FunctionCallArgumentsDone {
|
||||
output_index: usize,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CustomToolCallInputDelta {
|
||||
output_index: usize,
|
||||
delta: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CustomToolCallInputDone {
|
||||
output_index: usize,
|
||||
input: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponseCompleted {
|
||||
response: CompletedResponse,
|
||||
@@ -285,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>,
|
||||
}
|
||||
@@ -346,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,
|
||||
@@ -374,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,
|
||||
}));
|
||||
}
|
||||
@@ -398,19 +452,23 @@ pub(crate) fn parse_sse(
|
||||
"response.output_item.added" => {
|
||||
let ev: OutputItemAdded = from_json(data)?;
|
||||
match ev.item {
|
||||
OutputItem::FunctionCall { call_id, name, .. }
|
||||
| OutputItem::CustomToolCall { call_id, name, .. } => {
|
||||
let info =
|
||||
state.allocate(SlotKey::OutputItem(ev.output_index), BlockType::ToolUse);
|
||||
Ok(vec![Event::BlockStart(BlockStart {
|
||||
index: info.flat_index,
|
||||
block_type: BlockType::ToolUse,
|
||||
metadata: BlockMetadata::ToolUse { id: call_id, name },
|
||||
})])
|
||||
}
|
||||
OutputItem::FunctionCall { call_id, name, .. } => start_tool_output_item(
|
||||
state,
|
||||
ev.output_index,
|
||||
ToolInputKind::FunctionCall,
|
||||
call_id,
|
||||
name,
|
||||
),
|
||||
OutputItem::CustomToolCall { call_id, name, .. } => start_tool_output_item(
|
||||
state,
|
||||
ev.output_index,
|
||||
ToolInputKind::CustomToolCall,
|
||||
call_id,
|
||||
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;
|
||||
@@ -423,27 +481,27 @@ 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,
|
||||
..
|
||||
} = ev.item
|
||||
} = &ev.item
|
||||
{
|
||||
let mut pending = state
|
||||
.pending_reasoning
|
||||
.remove(&ev.output_index)
|
||||
.unwrap_or_default();
|
||||
if pending.id.is_none() {
|
||||
pending.id = id;
|
||||
pending.id = id.clone();
|
||||
}
|
||||
|
||||
let mut stop_blocks = std::mem::take(&mut pending.deferred_thinking_stops);
|
||||
stop_blocks.extend(state.take_active_reasoning_slots(ev.output_index));
|
||||
let reasoning = pending.into_reasoning_data(encrypted_content);
|
||||
let reasoning = pending.into_reasoning_data(encrypted_content.clone());
|
||||
|
||||
if stop_blocks.is_empty() {
|
||||
let info =
|
||||
@@ -478,15 +536,45 @@ pub(crate) fn parse_sse(
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
if let Some(info) = state.slots.remove(&SlotKey::OutputItem(ev.output_index)) {
|
||||
Ok(vec![Event::BlockStop(BlockStop {
|
||||
index: info.flat_index,
|
||||
block_type: info.block_type,
|
||||
stop_reason: None,
|
||||
reasoning: None,
|
||||
})])
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
match ev.item {
|
||||
OutputItem::FunctionCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
..
|
||||
} => finish_tool_output_item(
|
||||
state,
|
||||
ev.output_index,
|
||||
ToolInputKind::FunctionCall,
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
),
|
||||
OutputItem::CustomToolCall {
|
||||
call_id,
|
||||
name,
|
||||
input,
|
||||
..
|
||||
} => finish_tool_output_item(
|
||||
state,
|
||||
ev.output_index,
|
||||
ToolInputKind::CustomToolCall,
|
||||
call_id,
|
||||
name,
|
||||
input,
|
||||
),
|
||||
_ => {
|
||||
if let Some(info) = state.slots.remove(&SlotKey::OutputItem(ev.output_index)) {
|
||||
Ok(vec![Event::BlockStop(BlockStop {
|
||||
index: info.flat_index,
|
||||
block_type: info.block_type,
|
||||
stop_reason: None,
|
||||
reasoning: None,
|
||||
})])
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,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
|
||||
@@ -586,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,
|
||||
@@ -619,53 +707,262 @@ pub(crate) fn parse_sse(
|
||||
|
||||
"response.function_call_arguments.delta" => {
|
||||
let ev: FunctionCallArgumentsDelta = from_json(data)?;
|
||||
Ok(ensure_and_delta(
|
||||
record_tool_input_delta(
|
||||
state,
|
||||
SlotKey::OutputItem(ev.output_index),
|
||||
BlockType::ToolUse,
|
||||
ev.output_index,
|
||||
ToolInputKind::FunctionCall,
|
||||
ev.delta,
|
||||
)
|
||||
}
|
||||
|
||||
"response.function_call_arguments.done" => {
|
||||
let ev: FunctionCallArgumentsDone = from_json(data)?;
|
||||
reconcile_tool_input(
|
||||
state,
|
||||
ev.output_index,
|
||||
ToolInputKind::FunctionCall,
|
||||
ev.arguments,
|
||||
BlockMetadata::ToolUse {
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
},
|
||||
DeltaContent::InputJson(ev.delta),
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
"response.custom_tool_call_input.delta" => {
|
||||
let ev: CustomToolCallInputDelta = from_json(data)?;
|
||||
Ok(ensure_and_delta(
|
||||
record_tool_input_delta(
|
||||
state,
|
||||
SlotKey::OutputItem(ev.output_index),
|
||||
BlockType::ToolUse,
|
||||
ev.output_index,
|
||||
ToolInputKind::CustomToolCall,
|
||||
ev.delta,
|
||||
)
|
||||
}
|
||||
|
||||
"response.custom_tool_call_input.done" => {
|
||||
let ev: CustomToolCallInputDone = from_json(data)?;
|
||||
reconcile_tool_input(
|
||||
state,
|
||||
ev.output_index,
|
||||
ToolInputKind::CustomToolCall,
|
||||
ev.input,
|
||||
BlockMetadata::ToolUse {
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
},
|
||||
DeltaContent::InputJson(ev.delta),
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
"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)]),
|
||||
}
|
||||
}
|
||||
|
||||
fn start_tool_output_item(
|
||||
state: &mut OpenAIResponsesState,
|
||||
output_index: usize,
|
||||
kind: ToolInputKind,
|
||||
call_id: String,
|
||||
name: String,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
ensure_tool_input_kind(state, output_index, kind)?;
|
||||
let (info, just_created) =
|
||||
state.get_or_allocate(SlotKey::OutputItem(output_index), BlockType::ToolUse);
|
||||
if just_created {
|
||||
Ok(vec![Event::BlockStart(BlockStart {
|
||||
index: info.flat_index,
|
||||
block_type: BlockType::ToolUse,
|
||||
metadata: BlockMetadata::ToolUse { id: call_id, name },
|
||||
})])
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn record_tool_input_delta(
|
||||
state: &mut OpenAIResponsesState,
|
||||
output_index: usize,
|
||||
kind: ToolInputKind,
|
||||
delta: String,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
let pending = ensure_tool_input_kind(state, output_index, kind)?;
|
||||
if pending.final_value.is_some() {
|
||||
return Err(tool_input_error(
|
||||
"tool_input_delta_after_done",
|
||||
format!(
|
||||
"OpenAI Responses {} delta arrived after its final value at output index {output_index}",
|
||||
kind.label()
|
||||
),
|
||||
));
|
||||
}
|
||||
pending.saw_delta = true;
|
||||
pending.streamed.push_str(&delta);
|
||||
|
||||
Ok(ensure_and_delta(
|
||||
state,
|
||||
SlotKey::OutputItem(output_index),
|
||||
BlockType::ToolUse,
|
||||
BlockMetadata::ToolUse {
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
},
|
||||
DeltaContent::InputJson(delta),
|
||||
))
|
||||
}
|
||||
|
||||
fn reconcile_tool_input(
|
||||
state: &mut OpenAIResponsesState,
|
||||
output_index: usize,
|
||||
kind: ToolInputKind,
|
||||
final_value: String,
|
||||
metadata: BlockMetadata,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
let pending = ensure_tool_input_kind(state, output_index, kind)?;
|
||||
if pending
|
||||
.final_value
|
||||
.as_ref()
|
||||
.is_some_and(|existing| existing != &final_value)
|
||||
{
|
||||
return Err(tool_input_error(
|
||||
"tool_input_final_mismatch",
|
||||
format!(
|
||||
"OpenAI Responses authoritative {} values disagree at output index {output_index}",
|
||||
kind.label()
|
||||
),
|
||||
));
|
||||
}
|
||||
if pending.saw_delta && pending.streamed != final_value {
|
||||
return Err(tool_input_error(
|
||||
"tool_input_delta_mismatch",
|
||||
format!(
|
||||
"OpenAI Responses streamed {} does not match its authoritative final value at output index {output_index}",
|
||||
kind.label()
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let should_emit = !pending.saw_delta;
|
||||
if should_emit {
|
||||
pending.saw_delta = true;
|
||||
pending.streamed.clone_from(&final_value);
|
||||
}
|
||||
pending.final_value = Some(final_value.clone());
|
||||
|
||||
if should_emit {
|
||||
Ok(ensure_and_delta(
|
||||
state,
|
||||
SlotKey::OutputItem(output_index),
|
||||
BlockType::ToolUse,
|
||||
metadata,
|
||||
DeltaContent::InputJson(final_value),
|
||||
))
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_tool_output_item(
|
||||
state: &mut OpenAIResponsesState,
|
||||
output_index: usize,
|
||||
kind: ToolInputKind,
|
||||
call_id: String,
|
||||
name: String,
|
||||
final_value: Option<String>,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
let metadata = BlockMetadata::ToolUse { id: call_id, name };
|
||||
let mut out = if let Some(final_value) = final_value {
|
||||
reconcile_tool_input(state, output_index, kind, final_value, metadata.clone())?
|
||||
} else {
|
||||
let pending = ensure_tool_input_kind(state, output_index, kind)?;
|
||||
if pending.final_value.is_none() {
|
||||
return Err(tool_input_error(
|
||||
"tool_input_final_missing",
|
||||
format!(
|
||||
"OpenAI Responses {} ended without an authoritative final value at output index {output_index}",
|
||||
kind.label()
|
||||
),
|
||||
));
|
||||
}
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let (info, just_created) =
|
||||
state.get_or_allocate(SlotKey::OutputItem(output_index), BlockType::ToolUse);
|
||||
if just_created {
|
||||
out.push(Event::BlockStart(BlockStart {
|
||||
index: info.flat_index,
|
||||
block_type: BlockType::ToolUse,
|
||||
metadata,
|
||||
}));
|
||||
}
|
||||
state.slots.remove(&SlotKey::OutputItem(output_index));
|
||||
state.pending_tool_inputs.remove(&output_index);
|
||||
out.push(Event::BlockStop(BlockStop {
|
||||
index: info.flat_index,
|
||||
block_type: info.block_type,
|
||||
stop_reason: None,
|
||||
reasoning: None,
|
||||
}));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn ensure_tool_input_kind(
|
||||
state: &mut OpenAIResponsesState,
|
||||
output_index: usize,
|
||||
kind: ToolInputKind,
|
||||
) -> Result<&mut PendingToolInput, ClientError> {
|
||||
let pending = state
|
||||
.pending_tool_inputs
|
||||
.entry(output_index)
|
||||
.or_insert_with(|| PendingToolInput::new(kind));
|
||||
if pending.kind != kind {
|
||||
return Err(tool_input_error(
|
||||
"tool_input_kind_mismatch",
|
||||
format!("OpenAI Responses tool input kind changed at output index {output_index}"),
|
||||
));
|
||||
}
|
||||
Ok(pending)
|
||||
}
|
||||
|
||||
fn tool_input_error(code: &str, message: String) -> ClientError {
|
||||
ClientError::Api {
|
||||
status: None,
|
||||
code: Some(code.to_string()),
|
||||
message,
|
||||
retry_after: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn response_failure_diagnostic(event_type: &str, ev: ResponseFailed) -> (Option<String>, String) {
|
||||
let mut diagnostic = Map::new();
|
||||
diagnostic.insert("event".to_string(), Value::String(event_type.to_string()));
|
||||
@@ -807,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,
|
||||
@@ -855,6 +1152,26 @@ mod tests {
|
||||
parse_sse(event_type, data, state).unwrap()
|
||||
}
|
||||
|
||||
fn input_json_deltas(events: &[Event]) -> Vec<&str> {
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
Event::BlockDelta(BlockDelta {
|
||||
delta: DeltaContent::InputJson(delta),
|
||||
..
|
||||
}) => Some(delta.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn api_error_code(error: ClientError) -> Option<String> {
|
||||
match error {
|
||||
ClientError::Api { code, .. } => code,
|
||||
other => panic!("expected ClientError::Api, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_emits_status_started() {
|
||||
let (events, _) = run("response.created", r#"{"response":{}}"#);
|
||||
@@ -903,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",
|
||||
@@ -981,6 +1298,19 @@ mod tests {
|
||||
if let Event::BlockDelta(d) = &ev[0] {
|
||||
assert!(matches!(&d.delta, DeltaContent::InputJson(j) if j == "{\"x\":"));
|
||||
}
|
||||
let ev = with(
|
||||
&mut state,
|
||||
"response.function_call_arguments.delta",
|
||||
r#"{"output_index":1,"item_id":"fc1","delta":"1}"}"#,
|
||||
);
|
||||
assert_eq!(ev.len(), 1);
|
||||
assert!(matches!(
|
||||
&ev[0],
|
||||
Event::BlockDelta(BlockDelta {
|
||||
delta: DeltaContent::InputJson(j),
|
||||
..
|
||||
}) if j == "1}"
|
||||
));
|
||||
// output_item.done → BlockStop
|
||||
let ev = with(
|
||||
&mut state,
|
||||
@@ -1012,6 +1342,155 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_call_done_payload_emits_missing_final_input_before_stop() {
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
with(
|
||||
&mut state,
|
||||
"response.output_item.added",
|
||||
r#"{"output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"lookup"}}"#,
|
||||
);
|
||||
|
||||
let events = with(
|
||||
&mut state,
|
||||
"response.output_item.done",
|
||||
r#"{"output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"city\":\"Tokyo\"}"}}"#,
|
||||
);
|
||||
|
||||
assert_eq!(input_json_deltas(&events), vec![r#"{"city":"Tokyo"}"#]);
|
||||
assert!(matches!(events.last(), Some(Event::BlockStop(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_call_arguments_done_accepts_exact_stream_without_duplication() {
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
with(
|
||||
&mut state,
|
||||
"response.output_item.added",
|
||||
r#"{"output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"lookup"}}"#,
|
||||
);
|
||||
let delta = with(
|
||||
&mut state,
|
||||
"response.function_call_arguments.delta",
|
||||
r#"{"output_index":0,"delta":"{\"city\":\"Tokyo\"}"}"#,
|
||||
);
|
||||
assert_eq!(input_json_deltas(&delta), vec![r#"{"city":"Tokyo"}"#]);
|
||||
|
||||
let done = with(
|
||||
&mut state,
|
||||
"response.function_call_arguments.done",
|
||||
r#"{"output_index":0,"arguments":"{\"city\":\"Tokyo\"}"}"#,
|
||||
);
|
||||
assert!(done.is_empty());
|
||||
let output_done = with(
|
||||
&mut state,
|
||||
"response.output_item.done",
|
||||
r#"{"output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"city\":\"Tokyo\"}"}}"#,
|
||||
);
|
||||
assert!(input_json_deltas(&output_done).is_empty());
|
||||
assert!(matches!(output_done.as_slice(), [Event::BlockStop(_)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_call_arguments_done_rejects_stream_mismatch() {
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
with(
|
||||
&mut state,
|
||||
"response.output_item.added",
|
||||
r#"{"output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"lookup"}}"#,
|
||||
);
|
||||
with(
|
||||
&mut state,
|
||||
"response.function_call_arguments.delta",
|
||||
r#"{"output_index":0,"delta":"{\"city\":\"Kyoto\"}"}"#,
|
||||
);
|
||||
|
||||
let error = parse_sse(
|
||||
"response.function_call_arguments.done",
|
||||
r#"{"output_index":0,"arguments":"{\"city\":\"Tokyo\"}"}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
api_error_code(error).as_deref(),
|
||||
Some("tool_input_delta_mismatch")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_tool_input_done_event_emits_missing_final_input_once() {
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
with(
|
||||
&mut state,
|
||||
"response.output_item.added",
|
||||
r#"{"output_index":0,"item":{"type":"custom_tool_call","call_id":"call_1","name":"shell"}}"#,
|
||||
);
|
||||
|
||||
let done = with(
|
||||
&mut state,
|
||||
"response.custom_tool_call_input.done",
|
||||
r#"{"output_index":0,"input":"echo ready"}"#,
|
||||
);
|
||||
assert_eq!(input_json_deltas(&done), vec!["echo ready"]);
|
||||
let output_done = with(
|
||||
&mut state,
|
||||
"response.output_item.done",
|
||||
r#"{"output_index":0,"item":{"type":"custom_tool_call","call_id":"call_1","name":"shell","input":"echo ready"}}"#,
|
||||
);
|
||||
assert!(input_json_deltas(&output_done).is_empty());
|
||||
assert!(matches!(output_done.as_slice(), [Event::BlockStop(_)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_tool_done_payload_accepts_exact_stream_without_duplication() {
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
with(
|
||||
&mut state,
|
||||
"response.output_item.added",
|
||||
r#"{"output_index":0,"item":{"type":"custom_tool_call","call_id":"call_1","name":"shell"}}"#,
|
||||
);
|
||||
let delta = with(
|
||||
&mut state,
|
||||
"response.custom_tool_call_input.delta",
|
||||
r#"{"output_index":0,"delta":"echo ready"}"#,
|
||||
);
|
||||
assert_eq!(input_json_deltas(&delta), vec!["echo ready"]);
|
||||
|
||||
let output_done = with(
|
||||
&mut state,
|
||||
"response.output_item.done",
|
||||
r#"{"output_index":0,"item":{"type":"custom_tool_call","call_id":"call_1","name":"shell","input":"echo ready"}}"#,
|
||||
);
|
||||
assert!(input_json_deltas(&output_done).is_empty());
|
||||
assert!(matches!(output_done.as_slice(), [Event::BlockStop(_)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_tool_done_payload_rejects_stream_mismatch() {
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
with(
|
||||
&mut state,
|
||||
"response.output_item.added",
|
||||
r#"{"output_index":0,"item":{"type":"custom_tool_call","call_id":"call_1","name":"shell"}}"#,
|
||||
);
|
||||
with(
|
||||
&mut state,
|
||||
"response.custom_tool_call_input.delta",
|
||||
r#"{"output_index":0,"delta":"echo stale"}"#,
|
||||
);
|
||||
|
||||
let error = parse_sse(
|
||||
"response.output_item.done",
|
||||
r#"{"output_index":0,"item":{"type":"custom_tool_call","call_id":"call_1","name":"shell","input":"echo ready"}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
api_error_code(error).as_deref(),
|
||||
Some("tool_input_delta_mismatch")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_text_delta_emits_thinking() {
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
@@ -1059,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,
|
||||
@@ -1073,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,
|
||||
@@ -1197,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",
|
||||
@@ -1233,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",
|
||||
@@ -1250,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",
|
||||
@@ -1267,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());
|
||||
}
|
||||
|
||||
@@ -1357,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,11 +246,12 @@ 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 {
|
||||
match item {
|
||||
Item::AssistantResponseBoundary { .. } => {}
|
||||
Item::Message { role, content, .. } => {
|
||||
let (role_str, text_variant): (&'static str, fn(String) -> InputContent) =
|
||||
match role {
|
||||
@@ -279,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(),
|
||||
@@ -553,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);
|
||||
@@ -666,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(
|
||||
@@ -706,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")
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::llm_client::{
|
||||
ClientError,
|
||||
auth::AuthRequirement,
|
||||
capability::ModelCapability,
|
||||
client::ConfigWarning,
|
||||
client::{ConfigWarning, ToolCallCompletionSupport},
|
||||
event::Event,
|
||||
scheme::Scheme,
|
||||
types::{Request, RequestConfig},
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ impl Scheme for OpenAIResponsesScheme {
|
||||
super::capability::default_capability()
|
||||
}
|
||||
|
||||
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
|
||||
ToolCallCompletionSupport::PerBlock
|
||||
}
|
||||
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
let mut warnings = Vec::new();
|
||||
// Some compatible backends reject `max_output_tokens` with HTTP 400.
|
||||
|
||||
@@ -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
|
||||
@@ -510,6 +510,10 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
|
||||
self.scheme.validate_config(config)
|
||||
}
|
||||
|
||||
fn tool_call_completion_support(&self) -> super::ToolCallCompletionSupport {
|
||||
self.scheme.tool_call_completion_support()
|
||||
}
|
||||
|
||||
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
|
||||
let total_started = Instant::now();
|
||||
let path = self.scheme.path(&self.model_id);
|
||||
@@ -709,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
|
||||
|
||||
@@ -93,6 +93,12 @@ pub enum Item {
|
||||
status: Option<ItemStatus>,
|
||||
},
|
||||
|
||||
/// Internal durable boundary used to reconstruct one assistant response when
|
||||
/// early ToolResults are committed before the response stream has ended.
|
||||
/// Provider request projections remove this item before serialization.
|
||||
#[doc(hidden)]
|
||||
AssistantResponseBoundary { response_id: String },
|
||||
|
||||
/// Tool call from the assistant
|
||||
ToolCall {
|
||||
/// Optional item ID
|
||||
@@ -104,7 +110,15 @@ pub enum Item {
|
||||
name: String,
|
||||
/// Tool arguments as JSON string
|
||||
arguments: String,
|
||||
/// Item status
|
||||
/// Provider/model order among tool calls in this assistant response.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
call_index: Option<usize>,
|
||||
/// Durable identity of an execution or synthetic terminalization that
|
||||
/// owns this call. Once present, restore must never infer the call pending.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
execution_id: Option<String>,
|
||||
/// Item status. `InProgress` on a persisted ToolCall means execution or
|
||||
/// terminalization ownership was transferred and must never be inferred pending.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
status: Option<ItemStatus>,
|
||||
},
|
||||
@@ -137,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
|
||||
@@ -219,6 +232,14 @@ impl Item {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an internal durable assistant-response boundary.
|
||||
#[doc(hidden)]
|
||||
pub fn assistant_response_boundary(response_id: impl Into<String>) -> Self {
|
||||
Self::AssistantResponseBoundary {
|
||||
response_id: response_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Tool call constructors
|
||||
// ========================================================================
|
||||
@@ -234,6 +255,8 @@ impl Item {
|
||||
call_id: call_id.into(),
|
||||
name: name.into(),
|
||||
arguments: arguments.into(),
|
||||
call_index: None,
|
||||
execution_id: None,
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
@@ -247,6 +270,25 @@ impl Item {
|
||||
Self::tool_call(call_id, name, arguments.to_string())
|
||||
}
|
||||
|
||||
/// Attach response-local ordering and, when execution has started, its
|
||||
/// durable attempt identity to a tool call.
|
||||
pub fn with_tool_execution_metadata(
|
||||
mut self,
|
||||
call_index: usize,
|
||||
execution_id: Option<String>,
|
||||
) -> Self {
|
||||
if let Self::ToolCall {
|
||||
call_index: item_call_index,
|
||||
execution_id: item_execution_id,
|
||||
..
|
||||
} = &mut self
|
||||
{
|
||||
*item_call_index = Some(call_index);
|
||||
*item_execution_id = execution_id;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Create a tool result item with summary only (no content).
|
||||
pub fn tool_result(call_id: impl Into<String>, summary: impl Into<String>) -> Self {
|
||||
Self::tool_result_item(call_id, summary, None, false)
|
||||
@@ -377,6 +419,7 @@ impl Item {
|
||||
pub fn with_id(mut self, id: impl Into<String>) -> Self {
|
||||
match &mut self {
|
||||
Self::Message { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
Self::AssistantResponseBoundary { .. } => {}
|
||||
Self::ToolCall { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
Self::ToolResult { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
Self::Reasoning { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
@@ -388,6 +431,7 @@ impl Item {
|
||||
pub fn with_status(mut self, new_status: ItemStatus) -> Self {
|
||||
match &mut self {
|
||||
Self::Message { status, .. } => *status = Some(new_status),
|
||||
Self::AssistantResponseBoundary { .. } => {}
|
||||
Self::ToolCall { status, .. } => *status = Some(new_status),
|
||||
Self::ToolResult { .. } => {} // Result items don't have status
|
||||
Self::Reasoning { status, .. } => *status = Some(new_status),
|
||||
@@ -403,6 +447,7 @@ impl Item {
|
||||
pub fn id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Message { id, .. } => id.as_deref(),
|
||||
Self::AssistantResponseBoundary { .. } => None,
|
||||
Self::ToolCall { id, .. } => id.as_deref(),
|
||||
Self::ToolResult { id, .. } => id.as_deref(),
|
||||
Self::Reasoning { id, .. } => id.as_deref(),
|
||||
@@ -413,6 +458,7 @@ impl Item {
|
||||
pub fn item_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Message { .. } => "message",
|
||||
Self::AssistantResponseBoundary { .. } => "assistant_response_boundary",
|
||||
Self::ToolCall { .. } => "tool_call",
|
||||
Self::ToolResult { .. } => "tool_result",
|
||||
Self::Reasoning { .. } => "reasoning",
|
||||
@@ -577,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)]
|
||||
@@ -678,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
|
||||
@@ -743,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>,
|
||||
}
|
||||
@@ -804,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());
|
||||
}
|
||||
}
|
||||
+99
-30
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -133,6 +130,32 @@ pub fn project(items: &mut [Item], indices: &[usize]) -> usize {
|
||||
count
|
||||
}
|
||||
|
||||
/// 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. 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 = 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);
|
||||
if is_fresh {
|
||||
continue;
|
||||
}
|
||||
if let Item::ToolResult { attachments, .. } = item
|
||||
&& !attachments.is_empty()
|
||||
{
|
||||
attachments.clear();
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Indices of detailed `Item::ToolResult` values that lie before
|
||||
/// the suffix protected by `protected_tokens`. Pure: does not mutate `items`.
|
||||
///
|
||||
@@ -412,6 +435,52 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
fn image_result(call_id: &str, bytes: &[u8]) -> Item {
|
||||
Item::tool_result_item_with_attachments(
|
||||
call_id,
|
||||
"Attached image.png",
|
||||
None,
|
||||
false,
|
||||
vec![crate::tool::Attachment::Image(
|
||||
crate::tool::ImageAttachment::new("image/png", bytes.to_vec()),
|
||||
)],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumed_attachment_projection_keeps_only_fresh_tool_outputs() {
|
||||
let mut immediate = vec![
|
||||
Item::tool_call("image-1", "ViewImage", r#"{"path":"first.png"}"#),
|
||||
image_result("image-1", b"first"),
|
||||
];
|
||||
assert_eq!(project_consumed_attachments(&mut immediate), 0);
|
||||
assert!(matches!(
|
||||
&immediate[1],
|
||||
Item::ToolResult { attachments, .. } if attachments.len() == 1
|
||||
));
|
||||
|
||||
let mut consumed = immediate.clone();
|
||||
consumed.push(Item::assistant_message("I inspected the image."));
|
||||
consumed.push(Item::user_message("continue"));
|
||||
assert_eq!(project_consumed_attachments(&mut consumed), 1);
|
||||
assert!(matches!(
|
||||
&consumed[1],
|
||||
Item::ToolResult { attachments, .. } if attachments.is_empty()
|
||||
));
|
||||
|
||||
consumed.push(Item::tool_call(
|
||||
"image-2",
|
||||
"ViewImage",
|
||||
r#"{"path":"second.png"}"#,
|
||||
));
|
||||
consumed.push(image_result("image-2", b"second"));
|
||||
assert_eq!(project_consumed_attachments(&mut consumed), 0);
|
||||
assert!(matches!(
|
||||
consumed.last(),
|
||||
Some(Item::ToolResult { attachments, .. }) if attachments.len() == 1
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_skips_already_pruned_items() {
|
||||
// indices points at an item whose content is already None.
|
||||
@@ -441,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;
|
||||
|
||||
|
||||
@@ -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,14 +11,15 @@ 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;
|
||||
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,14 +250,14 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// ToolUseBlockKind用のラッパー
|
||||
/// Wrapper for ToolUseBlockKind.
|
||||
struct ToolUseBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<ToolUseBlockKind>,
|
||||
{
|
||||
handler: H,
|
||||
scope: Option<H::Scope>,
|
||||
current_tool: Option<(String, String)>, // (id, name)
|
||||
scopes: HashMap<usize, H::Scope>,
|
||||
tools: HashMap<usize, (String, String)>, // index -> (id, name)
|
||||
}
|
||||
|
||||
impl<H> ToolUseBlockHandlerWrapper<H>
|
||||
@@ -267,8 +267,8 @@ where
|
||||
fn new(handler: H) -> Self {
|
||||
Self {
|
||||
handler,
|
||||
scope: None,
|
||||
current_tool: None,
|
||||
scopes: HashMap::new(),
|
||||
tools: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,60 +279,55 @@ where
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
fn dispatch_start(&mut self, start: &BlockStart) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
if let BlockMetadata::ToolUse { id, name } = &start.metadata {
|
||||
self.current_tool = Some((id.clone(), name.clone()));
|
||||
self.handler.on_event(
|
||||
scope,
|
||||
&ToolUseBlockEvent::Start(ToolUseBlockStart {
|
||||
index: start.index,
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if let BlockMetadata::ToolUse { id, name } = &start.metadata {
|
||||
let scope = self.scopes.entry(start.index).or_default();
|
||||
self.tools.insert(start.index, (id.clone(), name.clone()));
|
||||
self.handler.on_event(
|
||||
scope,
|
||||
&ToolUseBlockEvent::Start(ToolUseBlockStart {
|
||||
index: start.index,
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_delta(&mut self, delta: &BlockDelta) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
if let DeltaContent::InputJson(json) = &delta.delta {
|
||||
self.handler
|
||||
.on_event(scope, &ToolUseBlockEvent::InputJsonDelta(json.clone()));
|
||||
}
|
||||
if let DeltaContent::InputJson(json) = &delta.delta {
|
||||
let scope = self.scopes.entry(delta.index).or_default();
|
||||
self.handler
|
||||
.on_event(scope, &ToolUseBlockEvent::InputJsonDelta(json.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_stop(&mut self, stop: &BlockStop) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
if let Some((id, name)) = self.current_tool.take() {
|
||||
self.handler.on_event(
|
||||
scope,
|
||||
&ToolUseBlockEvent::Stop(ToolUseBlockStop {
|
||||
index: stop.index,
|
||||
id,
|
||||
name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if let (Some(mut scope), Some((id, name))) = (
|
||||
self.scopes.remove(&stop.index),
|
||||
self.tools.remove(&stop.index),
|
||||
) {
|
||||
self.handler.on_event(
|
||||
&mut scope,
|
||||
&ToolUseBlockEvent::Stop(ToolUseBlockStop {
|
||||
index: stop.index,
|
||||
id,
|
||||
name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_abort(&mut self, _abort: &BlockAbort) {
|
||||
self.current_tool = None;
|
||||
fn dispatch_abort(&mut self, abort: &BlockAbort) {
|
||||
self.scopes.remove(&abort.index);
|
||||
self.tools.remove(&abort.index);
|
||||
}
|
||||
|
||||
fn start_scope(&mut self) {
|
||||
self.scope = Some(H::Scope::default());
|
||||
}
|
||||
fn start_scope(&mut self) {}
|
||||
|
||||
fn end_scope(&mut self) {
|
||||
self.scope = None;
|
||||
self.current_tool = None;
|
||||
}
|
||||
fn end_scope(&mut self) {}
|
||||
|
||||
fn has_scope(&self) -> bool {
|
||||
self.scope.is_some()
|
||||
!self.scopes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,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
|
||||
///
|
||||
@@ -364,28 +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>>,
|
||||
|
||||
// 現在アクティブなブロック
|
||||
// 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>,
|
||||
}
|
||||
|
||||
@@ -405,6 +402,7 @@ impl Timeline {
|
||||
text_block_handlers: Vec::new(),
|
||||
thinking_block_handlers: Vec::new(),
|
||||
tool_use_block_handlers: Vec::new(),
|
||||
active_blocks: HashMap::new(),
|
||||
current_block: None,
|
||||
pending_usage: None,
|
||||
}
|
||||
@@ -414,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,
|
||||
@@ -439,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,
|
||||
@@ -451,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,
|
||||
@@ -463,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,
|
||||
@@ -474,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,
|
||||
@@ -485,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,
|
||||
@@ -500,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),
|
||||
@@ -511,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),
|
||||
@@ -519,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),
|
||||
@@ -529,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 {
|
||||
@@ -559,6 +557,8 @@ impl Timeline {
|
||||
}
|
||||
|
||||
fn handle_block_start(&mut self, start: &BlockStart) {
|
||||
self.active_blocks
|
||||
.insert((start.block_type, start.index), ());
|
||||
self.current_block = Some(start.block_type);
|
||||
|
||||
let handlers = self.get_block_handlers_mut(start.block_type);
|
||||
@@ -571,15 +571,16 @@ impl Timeline {
|
||||
fn handle_block_delta(&mut self, delta: &BlockDelta) {
|
||||
let block_type = delta.delta.block_type();
|
||||
|
||||
// OpenAIなどのプロバイダはBlockStartを送らない場合があるため、
|
||||
// Deltaが来たときにスコープがなければ暗黙的に開始する
|
||||
if self.current_block.is_none() {
|
||||
self.current_block = Some(block_type);
|
||||
}
|
||||
// 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(());
|
||||
self.current_block = Some(block_type);
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -593,7 +594,12 @@ impl Timeline {
|
||||
handler.dispatch_stop(stop);
|
||||
handler.end_scope();
|
||||
}
|
||||
self.current_block = None;
|
||||
self.active_blocks.remove(&(stop.block_type, stop.index));
|
||||
self.current_block = self
|
||||
.active_blocks
|
||||
.keys()
|
||||
.next()
|
||||
.map(|(block_type, _)| *block_type);
|
||||
}
|
||||
|
||||
fn handle_block_abort(&mut self, abort: &BlockAbort) {
|
||||
@@ -602,7 +608,12 @@ impl Timeline {
|
||||
handler.dispatch_abort(abort);
|
||||
handler.end_scope();
|
||||
}
|
||||
self.current_block = None;
|
||||
self.active_blocks.remove(&(abort.block_type, abort.index));
|
||||
self.current_block = self
|
||||
.active_blocks
|
||||
.keys()
|
||||
.next()
|
||||
.map(|(block_type, _)| *block_type);
|
||||
}
|
||||
|
||||
fn get_block_handlers_mut(
|
||||
@@ -613,28 +624,31 @@ 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.
|
||||
///
|
||||
/// キャンセルやエラー時に呼び出し、進行中のブロックに対して
|
||||
/// 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) {
|
||||
if let Some(block_type) = self.current_block {
|
||||
let active = self.active_blocks.keys().copied().collect::<Vec<_>>();
|
||||
for (block_type, index) in active {
|
||||
let abort = crate::timeline::event::BlockAbort {
|
||||
index: 0, // インデックスは不明なので0
|
||||
index,
|
||||
block_type,
|
||||
reason: "Cancelled".to_string(),
|
||||
};
|
||||
self.handle_block_abort(&abort);
|
||||
}
|
||||
self.active_blocks.clear();
|
||||
self.current_block = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -679,9 +693,53 @@ mod tests {
|
||||
assert!(calls.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abort_clears_every_indexed_scope_before_indices_are_reused() {
|
||||
#[derive(Default)]
|
||||
struct Scope {
|
||||
started: bool,
|
||||
}
|
||||
struct IndexedToolHandler {
|
||||
reused_cleanly: Arc<Mutex<Vec<bool>>>,
|
||||
}
|
||||
impl Handler<ToolUseBlockKind> for IndexedToolHandler {
|
||||
type Scope = Scope;
|
||||
|
||||
fn on_event(&mut self, scope: &mut Scope, event: &ToolUseBlockEvent) {
|
||||
if matches!(event, ToolUseBlockEvent::Start(_)) {
|
||||
self.reused_cleanly.lock().unwrap().push(!scope.started);
|
||||
scope.started = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let reused_cleanly = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_tool_use_block(IndexedToolHandler {
|
||||
reused_cleanly: reused_cleanly.clone(),
|
||||
});
|
||||
|
||||
timeline.dispatch(&Event::tool_use_start(0, "first", "tool"));
|
||||
timeline.dispatch(&Event::tool_use_start(1, "second", "tool"));
|
||||
// Stopping one interleaved block must not hide its still-active sibling.
|
||||
timeline.dispatch(&Event::tool_use_stop(0));
|
||||
assert_eq!(timeline.current_block(), Some(BlockType::ToolUse));
|
||||
timeline.abort_current_block();
|
||||
assert!(timeline.current_block().is_none());
|
||||
|
||||
// A later provider response may reuse the same content-block index.
|
||||
timeline.dispatch(&Event::tool_use_start(1, "replacement", "tool"));
|
||||
timeline.dispatch(&Event::tool_use_stop(1));
|
||||
|
||||
assert_eq!(
|
||||
reused_cleanly.lock().unwrap().as_slice(),
|
||||
[true, true, true]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_meta_event_dispatch() {
|
||||
// シンプルなテスト用構造体
|
||||
// Simple test struct
|
||||
struct TestUsageHandler {
|
||||
calls: Arc<Mutex<Vec<UsageEvent>>>,
|
||||
}
|
||||
@@ -702,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);
|
||||
@@ -730,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),
|
||||
@@ -738,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),
|
||||
@@ -747,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();
|
||||
@@ -756,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,62 +1,89 @@
|
||||
//! 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},
|
||||
llm_client::types::parse_tool_arguments,
|
||||
tool::ToolCall,
|
||||
};
|
||||
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>,
|
||||
/// 蓄積中のJSON入力
|
||||
/// Zero-based order among tool calls in this assistant response.
|
||||
call_index: Option<usize>,
|
||||
/// JSON input being accumulated.
|
||||
input_json_buffer: String,
|
||||
}
|
||||
|
||||
/// ToolCallCollector - ToolUseブロックハンドラ
|
||||
/// One provider-completed call together with its stable model order.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CollectedToolCall {
|
||||
pub call_index: usize,
|
||||
pub call: ToolCall,
|
||||
}
|
||||
|
||||
/// 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: Arc<Mutex<Vec<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())),
|
||||
next_call_index: Arc::new(Mutex::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 収集されたToolCallを取得してクリア
|
||||
/// Take the collected ToolCalls and clear the collection.
|
||||
pub fn take_collected(&self) -> Vec<ToolCall> {
|
||||
self.take_collected_with_index()
|
||||
.into_iter()
|
||||
.map(|collected| collected.call)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drain completed calls while preserving their model-returned order.
|
||||
pub(crate) fn take_collected_with_index(&self) -> Vec<CollectedToolCall> {
|
||||
let mut guard = self.collected.lock().unwrap();
|
||||
std::mem::take(&mut *guard)
|
||||
}
|
||||
|
||||
/// 収集されたToolCallの参照を取得
|
||||
/// Access the collected ToolCalls.
|
||||
pub fn collected(&self) -> Vec<ToolCall> {
|
||||
self.collected.lock().unwrap().clone()
|
||||
self.collected
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|collected| collected.call.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 収集されたToolCallがあるかどうか
|
||||
/// Start one response-local tool-call ordering scope.
|
||||
pub(crate) fn begin_response(&self) {
|
||||
self.collected.lock().unwrap().clear();
|
||||
*self.next_call_index.lock().unwrap() = 0;
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
@@ -76,20 +103,43 @@ impl Handler<ToolUseBlockKind> for ToolCallCollector {
|
||||
ToolUseBlockEvent::Start(start) => {
|
||||
scope.current_id = Some(start.id.clone());
|
||||
scope.current_name = Some(start.name.clone());
|
||||
if scope.call_index.is_none() {
|
||||
let mut next = self.next_call_index.lock().unwrap();
|
||||
scope.call_index = Some(*next);
|
||||
*next += 1;
|
||||
}
|
||||
scope.input_json_buffer.clear();
|
||||
}
|
||||
ToolUseBlockEvent::InputJsonDelta(delta) => {
|
||||
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())
|
||||
{
|
||||
let input = parse_tool_arguments(&scope.input_json_buffer);
|
||||
// A Stop event proves the provider finished the block, but
|
||||
// malformed/non-object arguments are still not executable.
|
||||
// Preserve an explicit null sentinel so the engine can commit
|
||||
// a terminal validation error without guessing `{}`.
|
||||
let input = if scope.input_json_buffer.trim().is_empty() {
|
||||
serde_json::Value::Object(serde_json::Map::new())
|
||||
} else {
|
||||
serde_json::from_str::<serde_json::Value>(&scope.input_json_buffer)
|
||||
.ok()
|
||||
.filter(serde_json::Value::is_object)
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
};
|
||||
|
||||
let tool_call = ToolCall { id, name, input };
|
||||
let call_index = scope
|
||||
.call_index
|
||||
.take()
|
||||
.expect("tool-use stop follows a started collector scope");
|
||||
|
||||
self.collected.lock().unwrap().push(tool_call);
|
||||
self.collected.lock().unwrap().push(CollectedToolCall {
|
||||
call_index,
|
||||
call: tool_call,
|
||||
});
|
||||
}
|
||||
scope.input_json_buffer.clear();
|
||||
}
|
||||
@@ -109,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");
|
||||
@@ -125,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());
|
||||
@@ -144,18 +194,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_interleaved_tool_call_indexes_and_ignore_duplicate_stop() {
|
||||
let collector = ToolCallCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_tool_use_block(collector.clone());
|
||||
|
||||
timeline.dispatch(&Event::tool_use_start(0, "call_0", "tool_0"));
|
||||
timeline.dispatch(&Event::tool_input_delta(0, r#"{"a":"#));
|
||||
timeline.dispatch(&Event::tool_use_start(1, "call_1", "tool_1"));
|
||||
timeline.dispatch(&Event::tool_input_delta(1, r#"{"b":2}"#));
|
||||
timeline.dispatch(&Event::tool_input_delta(0, r#"1}"#));
|
||||
timeline.dispatch(&Event::tool_use_stop(1));
|
||||
timeline.dispatch(&Event::tool_use_stop(1));
|
||||
timeline.dispatch(&Event::tool_use_stop(0));
|
||||
|
||||
let calls = collector.take_collected();
|
||||
assert_eq!(calls.len(), 2);
|
||||
assert_eq!(calls[0].id, "call_1");
|
||||
assert_eq!(calls[0].input, serde_json::json!({"b": 2}));
|
||||
assert_eq!(calls[1].id, "call_0");
|
||||
assert_eq!(calls[1].input, serde_json::json!({"a": 1}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_completed_arguments_are_not_normalized_to_executable_object() {
|
||||
let collector = ToolCallCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_tool_use_block(collector.clone());
|
||||
timeline.dispatch(&Event::tool_use_start(0, "bad", "mutate"));
|
||||
timeline.dispatch(&Event::tool_input_delta(0, r#"{"unterminated":"#));
|
||||
timeline.dispatch(&Event::tool_use_stop(0));
|
||||
|
||||
let calls = collector.take_collected();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert!(calls[0].input.is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_multiple_tool_calls() {
|
||||
let collector = ToolCallCollector::new();
|
||||
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,20 +46,27 @@ pub fn prefix_bytes(items: &[Item]) -> Vec<u64> {
|
||||
prefix
|
||||
}
|
||||
|
||||
/// 1 Item の大きさ。JSON シリアライズ長を使う粗い近似。
|
||||
/// トークン数との絶対変換ではなく区間の按分にしか使わないので、
|
||||
/// プロバイダごとの overhead は比率でキャンセルされる。
|
||||
/// 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
|
||||
/// rate produces meaningless estimates. Exact provider usage measurements remain authoritative
|
||||
/// when an attachment was actually sent.
|
||||
pub fn item_bytes(item: &Item) -> u64 {
|
||||
serde_json::to_string(item)
|
||||
let mut projected = item.clone();
|
||||
if let Item::ToolResult { attachments, .. } = &mut projected {
|
||||
attachments.clear();
|
||||
}
|
||||
serde_json::to_string(&projected)
|
||||
.map(|s| s.len() as 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],
|
||||
@@ -84,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,
|
||||
@@ -146,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],
|
||||
@@ -265,6 +271,31 @@ mod tests {
|
||||
assert_eq!(est.tokens, 30_000 + delta_bytes / 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_does_not_count_durable_image_bytes_as_text_tokens() {
|
||||
let small = Item::tool_result_item_with_attachments(
|
||||
"call",
|
||||
"Attached image.png",
|
||||
None,
|
||||
false,
|
||||
vec![crate::tool::Attachment::Image(
|
||||
crate::tool::ImageAttachment::new("image/png", vec![1_u8; 8]),
|
||||
)],
|
||||
);
|
||||
let large = Item::tool_result_item_with_attachments(
|
||||
"call",
|
||||
"Attached image.png",
|
||||
None,
|
||||
false,
|
||||
vec![crate::tool::Attachment::Image(
|
||||
crate::tool::ImageAttachment::new("image/png", vec![1_u8; 1_000_000]),
|
||||
)],
|
||||
);
|
||||
|
||||
assert_eq!(item_bytes(&small), item_bytes(&large));
|
||||
assert_eq!(total_tokens(&[small], &[]), total_tokens(&[large], &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_zero_history_is_zero() {
|
||||
let est = total_tokens(&[], &[]);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
mod common;
|
||||
|
||||
use agen::interceptor::{
|
||||
AssistantTurnEndContext, Interceptor, InterceptorCallId, InterceptorInvocation,
|
||||
InterceptorPhase, InterceptorResult, PendingHistoryAppendsContext, PreLlmRequestContext,
|
||||
PreRequestAction, PromptAction, PromptSubmitContext, RunExitContext, TurnEndAction,
|
||||
};
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::{Engine, EngineError, History, HistoryEntry, Item, Role};
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn completed_text_events(text: &str) -> Vec<Event> {
|
||||
vec![
|
||||
@@ -29,6 +36,7 @@ async fn run_preserves_item_annotations_without_projecting_them() {
|
||||
Role::Assistant => "assistant",
|
||||
Role::System => "system",
|
||||
},
|
||||
Item::AssistantResponseBoundary { .. } => "assistant_response_boundary",
|
||||
Item::ToolCall { .. } => "tool_call",
|
||||
Item::ToolResult { .. } => "tool_result",
|
||||
Item::Reasoning { .. } => "reasoning",
|
||||
@@ -47,6 +55,125 @@ async fn run_preserves_item_annotations_without_projecting_them() {
|
||||
assert_eq!(history.items_cloned().len(), 2);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AnnotationObservingInterceptor {
|
||||
observed: Arc<Mutex<Vec<(InterceptorInvocation, Vec<String>)>>>,
|
||||
}
|
||||
|
||||
impl AnnotationObservingInterceptor {
|
||||
fn record(&self, invocation: &InterceptorInvocation, history: &[HistoryEntry<String>]) {
|
||||
self.observed.lock().unwrap().push((
|
||||
invocation.clone(),
|
||||
history
|
||||
.iter()
|
||||
.map(|entry| entry.annotation.clone())
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor<String> for AnnotationObservingInterceptor {
|
||||
async fn on_prompt_submit(
|
||||
&self,
|
||||
context: PromptSubmitContext<'_, String>,
|
||||
) -> InterceptorResult<PromptAction> {
|
||||
self.record(&context.invocation, context.history);
|
||||
Ok(PromptAction::Continue)
|
||||
}
|
||||
|
||||
async fn pending_history_appends(
|
||||
&self,
|
||||
context: PendingHistoryAppendsContext<'_, String>,
|
||||
) -> InterceptorResult<Vec<Item>> {
|
||||
self.record(&context.invocation, context.history);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn pre_llm_request(
|
||||
&self,
|
||||
context: PreLlmRequestContext<'_, String>,
|
||||
) -> InterceptorResult<PreRequestAction> {
|
||||
self.record(&context.invocation, context.history);
|
||||
Ok(PreRequestAction::Continue)
|
||||
}
|
||||
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
context: AssistantTurnEndContext<'_, String>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
assert_eq!(context.assistant_entries.len(), 1);
|
||||
assert_eq!(context.assistant_entries[0].annotation, "2:assistant");
|
||||
self.record(&context.invocation, context.history);
|
||||
Ok(TurnEndAction::Finish)
|
||||
}
|
||||
|
||||
async fn on_run_exit(&self, context: RunExitContext<'_, String>) -> InterceptorResult<()> {
|
||||
self.record(&context.invocation, context.history);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interceptor_contexts_preserve_annotations_and_typed_lifecycle_identity() {
|
||||
let client = MockLlmClient::new(completed_text_events("assistant reply"));
|
||||
let mut engine = Engine::<_, agen::state::Mutable, String>::new_annotated(client);
|
||||
let observed = Arc::new(Mutex::new(Vec::new()));
|
||||
engine.set_interceptor(AnnotationObservingInterceptor {
|
||||
observed: observed.clone(),
|
||||
});
|
||||
let mut history = History::<String>::new();
|
||||
let mut next = 0usize;
|
||||
let mut annotate = |item: &Item| {
|
||||
next += 1;
|
||||
let kind = if item.is_assistant_message() {
|
||||
"assistant"
|
||||
} else {
|
||||
"user"
|
||||
};
|
||||
Ok(format!("{next}:{kind}"))
|
||||
};
|
||||
|
||||
let output = engine
|
||||
.run_with_annotation(&mut history, "hello", &mut annotate)
|
||||
.await;
|
||||
assert!(matches!(output.result, agen::EngineRunExit::Finished));
|
||||
|
||||
let observed = observed.lock().unwrap();
|
||||
let phases: Vec<_> = observed
|
||||
.iter()
|
||||
.map(|(invocation, _)| invocation.phase)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
phases,
|
||||
[
|
||||
InterceptorPhase::PromptSubmit,
|
||||
InterceptorPhase::PendingHistoryAppends,
|
||||
InterceptorPhase::PreLlmRequest,
|
||||
InterceptorPhase::AssistantTurnEnd,
|
||||
InterceptorPhase::RunExit,
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
observed
|
||||
.iter()
|
||||
.all(|(invocation, _)| invocation.run_id == observed[0].0.run_id)
|
||||
);
|
||||
assert_eq!(
|
||||
observed
|
||||
.iter()
|
||||
.map(|(invocation, _)| invocation.counters.invocation.get())
|
||||
.collect::<Vec<_>>(),
|
||||
[0, 1, 2, 3, 4]
|
||||
);
|
||||
assert_eq!(observed[2].0.call_id, Some(InterceptorCallId::Llm(0)));
|
||||
assert_eq!(observed[3].0.call_id, Some(InterceptorCallId::Llm(0)));
|
||||
assert_eq!(observed[1].1, ["1:user"]);
|
||||
assert_eq!(observed[2].1, ["1:user"]);
|
||||
assert_eq!(observed[3].1, ["1:user", "2:assistant"]);
|
||||
assert_eq!(observed[4].1, ["1:user", "2:assistant"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_failure_does_not_make_item_live() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::llm_client::event::{BlockType, DeltaContent, Event};
|
||||
use agen::llm_client::{ClientError, LlmClient, Request};
|
||||
use agen::llm_client::{ClientError, LlmClient, Request, ToolCallCompletionSupport};
|
||||
use agen::timeline::{Handler, TextBlockEvent, TextBlockKind, Timeline};
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
@@ -20,6 +20,7 @@ pub struct MockLlmClient {
|
||||
responses: Arc<Vec<Vec<Event>>>,
|
||||
call_count: Arc<AtomicUsize>,
|
||||
requests: Arc<Mutex<Vec<Request>>>,
|
||||
completion_support: ToolCallCompletionSupport,
|
||||
}
|
||||
|
||||
impl MockLlmClient {
|
||||
@@ -32,6 +33,7 @@ impl MockLlmClient {
|
||||
responses: Arc::new(responses),
|
||||
call_count: Arc::new(AtomicUsize::new(0)),
|
||||
requests: Arc::new(Mutex::new(Vec::new())),
|
||||
completion_support: ToolCallCompletionSupport::ResponseComplete,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +49,11 @@ impl MockLlmClient {
|
||||
pub fn requests(&self) -> Vec<Request> {
|
||||
self.requests.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn with_completion_support(mut self, support: ToolCallCompletionSupport) -> Self {
|
||||
self.completion_support = support;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -55,6 +62,10 @@ impl LlmClient for MockLlmClient {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
|
||||
self.completion_support
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
request: Request,
|
||||
|
||||
@@ -7,20 +7,79 @@ mod common;
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use agen::Item;
|
||||
use agen::interceptor::{
|
||||
Interceptor, PreRequestAction, PreToolAction, ToolCallInfo, TurnEndAction,
|
||||
AssistantTurnEndContext, Interceptor, InterceptorError, InterceptorErrorCategory,
|
||||
InterceptorPhase as InterceptorPoint, InterceptorResult, MAX_INTERCEPTOR_DIAGNOSTIC_BYTES,
|
||||
PendingHistoryAppendsContext, PostToolAction, PreLlmRequestContext, PreRequestAction,
|
||||
PreToolAction, PromptAction, PromptSubmitContext, RunExitContext, ToolCallInfo, ToolResultInfo,
|
||||
TurnEndAction,
|
||||
};
|
||||
use agen::llm_client::{
|
||||
ClientError, LlmClient, Request, ResponseStream,
|
||||
event::{Event, ResponseStatus, StatusEvent, UsageEvent},
|
||||
};
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use agen::{Engine, EngineError, EngineRunExit, History, StopReason};
|
||||
use agen::{Engine, EngineError, EngineRunExit, History, RunInterruptionReason};
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
use futures::StreamExt;
|
||||
|
||||
// =============================================================================
|
||||
// Mutable State Tests
|
||||
// =============================================================================
|
||||
#[derive(Clone)]
|
||||
struct UsageThenPendingClient;
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for UsageThenPendingClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn stream(&self, _request: Request) -> Result<ResponseStream, ClientError> {
|
||||
let usage = Event::Usage(UsageEvent {
|
||||
input_tokens: Some(90),
|
||||
output_tokens: Some(2),
|
||||
total_tokens: Some(92),
|
||||
cache_read_input_tokens: Some(0),
|
||||
cache_creation_input_tokens: Some(0),
|
||||
});
|
||||
Ok(Box::pin(
|
||||
futures::stream::once(async move { Ok(usage) })
|
||||
.chain(futures::stream::pending::<Result<Event, ClientError>>()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pause_during_response_stream_flushes_observed_usage() {
|
||||
let observed = Arc::new(Mutex::new(Vec::new()));
|
||||
let observed_for_callback = Arc::clone(&observed);
|
||||
let mut engine = Engine::new(UsageThenPendingClient);
|
||||
engine.on_usage(move |usage| {
|
||||
observed_for_callback.lock().unwrap().push(usage.clone());
|
||||
});
|
||||
let pause = engine.pause_sender();
|
||||
let mut history = History::new();
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
pause.send(()).await.unwrap();
|
||||
});
|
||||
|
||||
let exit = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
engine.run(&mut history, "pause after usage"),
|
||||
)
|
||||
.await
|
||||
.expect("run should observe pause");
|
||||
assert!(matches!(exit, EngineRunExit::Paused));
|
||||
let observed = observed.lock().unwrap();
|
||||
assert_eq!(observed.len(), 1);
|
||||
assert_eq!(observed[0].input_tokens, Some(90));
|
||||
assert_eq!(observed[0].output_tokens, Some(2));
|
||||
}
|
||||
|
||||
/// Verify that system prompt can be set in Mutable state
|
||||
#[test]
|
||||
@@ -205,7 +264,7 @@ async fn history_append_failure_stops_before_tool_execution() {
|
||||
let exit = engine.run(&mut history, "use the tool").await;
|
||||
|
||||
assert!(
|
||||
matches!(exit, EngineRunExit::Interrupted(StopReason::Unexpected(EngineError::HistoryAppend(ref message))) if message == "simulated ENOSPC")
|
||||
matches!(exit, EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(EngineError::HistoryAppend(ref message))) if message == "simulated ENOSPC")
|
||||
);
|
||||
assert_eq!(tool.call_count(), 0);
|
||||
assert_eq!(history.len(), 1);
|
||||
@@ -613,12 +672,15 @@ struct YieldOnce {
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for YieldOnce {
|
||||
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
|
||||
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
async fn pre_llm_request(
|
||||
&self,
|
||||
_context: PreLlmRequestContext<'_, ()>,
|
||||
) -> InterceptorResult<PreRequestAction> {
|
||||
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
PreRequestAction::Yield
|
||||
} else {
|
||||
PreRequestAction::Continue
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,12 +690,15 @@ struct PauseToolOnce {
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for PauseToolOnce {
|
||||
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction {
|
||||
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
async fn pre_tool_call(
|
||||
&self,
|
||||
_info: &mut ToolCallInfo<'_, ()>,
|
||||
) -> InterceptorResult<PreToolAction> {
|
||||
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
PreToolAction::Pause
|
||||
} else {
|
||||
PreToolAction::Continue
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,13 +708,509 @@ struct ContinueTurnOnce {
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for ContinueTurnOnce {
|
||||
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
|
||||
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
_context: AssistantTurnEndContext<'_, ()>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
Ok(if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
TurnEndAction::ContinueWithMessages(vec![Item::system_message("continue")])
|
||||
} else {
|
||||
TurnEndAction::Finish
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FailingLifecycleInterceptor {
|
||||
failure: InterceptorPoint,
|
||||
calls: Arc<Mutex<Vec<InterceptorPoint>>>,
|
||||
}
|
||||
|
||||
impl FailingLifecycleInterceptor {
|
||||
fn new(failure: InterceptorPoint) -> Self {
|
||||
Self {
|
||||
failure,
|
||||
calls: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn record<T>(&self, point: InterceptorPoint, action: T) -> InterceptorResult<T> {
|
||||
self.calls.lock().unwrap().push(point);
|
||||
if self.failure == point {
|
||||
Err(InterceptorError::new(
|
||||
InterceptorErrorCategory::Policy,
|
||||
format!("{point} rejected"),
|
||||
))
|
||||
} else {
|
||||
Ok(action)
|
||||
}
|
||||
}
|
||||
|
||||
fn calls(&self) -> Vec<InterceptorPoint> {
|
||||
self.calls.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for FailingLifecycleInterceptor {
|
||||
async fn on_prompt_submit(
|
||||
&self,
|
||||
_context: PromptSubmitContext<'_, ()>,
|
||||
) -> InterceptorResult<PromptAction> {
|
||||
tokio::task::yield_now().await;
|
||||
self.record(InterceptorPoint::PromptSubmit, PromptAction::Continue)
|
||||
}
|
||||
|
||||
async fn pending_history_appends(
|
||||
&self,
|
||||
_context: PendingHistoryAppendsContext<'_, ()>,
|
||||
) -> InterceptorResult<Vec<Item>> {
|
||||
tokio::task::yield_now().await;
|
||||
self.record(InterceptorPoint::PendingHistoryAppends, Vec::new())
|
||||
}
|
||||
|
||||
async fn pre_llm_request(
|
||||
&self,
|
||||
_context: PreLlmRequestContext<'_, ()>,
|
||||
) -> InterceptorResult<PreRequestAction> {
|
||||
tokio::task::yield_now().await;
|
||||
self.record(InterceptorPoint::PreLlmRequest, PreRequestAction::Continue)
|
||||
}
|
||||
|
||||
async fn pre_tool_call(
|
||||
&self,
|
||||
_info: &mut ToolCallInfo<'_, ()>,
|
||||
) -> InterceptorResult<PreToolAction> {
|
||||
tokio::task::yield_now().await;
|
||||
self.record(InterceptorPoint::PreToolCall, PreToolAction::Continue)
|
||||
}
|
||||
|
||||
async fn post_tool_call(
|
||||
&self,
|
||||
_info: &ToolResultInfo<'_, ()>,
|
||||
) -> InterceptorResult<PostToolAction> {
|
||||
tokio::task::yield_now().await;
|
||||
self.record(InterceptorPoint::PostToolCall, PostToolAction::Continue)
|
||||
}
|
||||
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
context: AssistantTurnEndContext<'_, ()>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
tokio::task::yield_now().await;
|
||||
assert!(context.history.ends_with(context.assistant_entries));
|
||||
if !context.tool_calls.is_empty() {
|
||||
assert_eq!(
|
||||
context
|
||||
.assistant_entries
|
||||
.iter()
|
||||
.filter(|entry| matches!(&entry.item, Item::ToolCall { .. }))
|
||||
.count(),
|
||||
context.tool_calls.len()
|
||||
);
|
||||
}
|
||||
self.record(InterceptorPoint::AssistantTurnEnd, TurnEndAction::Finish)
|
||||
}
|
||||
|
||||
async fn on_run_exit(&self, _context: RunExitContext<'_, ()>) -> InterceptorResult<()> {
|
||||
tokio::task::yield_now().await;
|
||||
self.record(InterceptorPoint::RunExit, ())
|
||||
}
|
||||
}
|
||||
|
||||
fn expected_interceptor_calls(failure: InterceptorPoint) -> Vec<InterceptorPoint> {
|
||||
use InterceptorPoint as Point;
|
||||
|
||||
let mut calls = match failure {
|
||||
Point::PromptSubmit => vec![Point::PromptSubmit],
|
||||
Point::PendingHistoryAppends => {
|
||||
vec![Point::PromptSubmit, Point::PendingHistoryAppends]
|
||||
}
|
||||
Point::PreLlmRequest => vec![
|
||||
Point::PromptSubmit,
|
||||
Point::PendingHistoryAppends,
|
||||
Point::PreLlmRequest,
|
||||
],
|
||||
Point::PreToolCall => vec![
|
||||
Point::PromptSubmit,
|
||||
Point::PendingHistoryAppends,
|
||||
Point::PreLlmRequest,
|
||||
Point::AssistantTurnEnd,
|
||||
Point::PreToolCall,
|
||||
],
|
||||
Point::PostToolCall => vec![
|
||||
Point::PromptSubmit,
|
||||
Point::PendingHistoryAppends,
|
||||
Point::PreLlmRequest,
|
||||
Point::AssistantTurnEnd,
|
||||
Point::PreToolCall,
|
||||
Point::PostToolCall,
|
||||
],
|
||||
Point::AssistantTurnEnd => vec![
|
||||
Point::PromptSubmit,
|
||||
Point::PendingHistoryAppends,
|
||||
Point::PreLlmRequest,
|
||||
Point::AssistantTurnEnd,
|
||||
],
|
||||
Point::RunExit => vec![
|
||||
Point::PromptSubmit,
|
||||
Point::PendingHistoryAppends,
|
||||
Point::PreLlmRequest,
|
||||
Point::AssistantTurnEnd,
|
||||
],
|
||||
};
|
||||
calls.push(Point::RunExit);
|
||||
calls
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interceptor_failures_are_typed_and_terminal_observer_preserves_original_exit() {
|
||||
use InterceptorPoint as Point;
|
||||
|
||||
for failure_point in [
|
||||
Point::PromptSubmit,
|
||||
Point::PendingHistoryAppends,
|
||||
Point::PreLlmRequest,
|
||||
Point::PreToolCall,
|
||||
Point::PostToolCall,
|
||||
Point::AssistantTurnEnd,
|
||||
Point::RunExit,
|
||||
] {
|
||||
let interceptor = FailingLifecycleInterceptor::new(failure_point);
|
||||
let needs_tool = matches!(failure_point, Point::PreToolCall | Point::PostToolCall);
|
||||
let events = if needs_tool {
|
||||
vec![
|
||||
Event::tool_use_start(0, "call-1", "count_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]
|
||||
} else {
|
||||
completed_text_events()
|
||||
};
|
||||
let mut engine = Engine::new(MockLlmClient::new(events));
|
||||
engine.register_tool(CountingTool::new("count_tool").definition());
|
||||
engine.set_interceptor(interceptor.clone());
|
||||
let mut history = History::new();
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
let exit = engine.run(&mut history, "test").await;
|
||||
let failure = if failure_point == Point::RunExit {
|
||||
assert!(matches!(exit, EngineRunExit::Finished));
|
||||
engine
|
||||
.last_run_exit_observer_failure()
|
||||
.expect("terminal observer diagnostic should be retained")
|
||||
} else {
|
||||
let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(
|
||||
EngineError::Interceptor(failure),
|
||||
)) = &exit
|
||||
else {
|
||||
panic!("expected typed interceptor interruption at {failure_point}, got {exit:?}");
|
||||
};
|
||||
failure
|
||||
};
|
||||
assert_eq!(failure.phase(), failure_point);
|
||||
assert_eq!(
|
||||
failure.error().diagnostic(),
|
||||
format!("{failure_point} rejected")
|
||||
);
|
||||
assert_eq!(
|
||||
interceptor.calls(),
|
||||
expected_interceptor_calls(failure_point)
|
||||
);
|
||||
if failure_point == Point::PostToolCall {
|
||||
assert!(
|
||||
history
|
||||
.items()
|
||||
.any(|item| matches!(item, Item::ToolResult { .. })),
|
||||
"post-tool failure must not precede terminal output commit"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interceptor_error_keeps_typed_category_and_bounded_utf8_diagnostic() {
|
||||
let error = InterceptorError::new(
|
||||
InterceptorErrorCategory::Dependency,
|
||||
"界".repeat(MAX_INTERCEPTOR_DIAGNOSTIC_BYTES),
|
||||
);
|
||||
assert_eq!(error.category(), InterceptorErrorCategory::Dependency);
|
||||
assert!(error.diagnostic().len() <= MAX_INTERCEPTOR_DIAGNOSTIC_BYTES);
|
||||
assert!(
|
||||
error
|
||||
.diagnostic()
|
||||
.is_char_boundary(error.diagnostic().len())
|
||||
);
|
||||
}
|
||||
|
||||
struct FailingRunExitObserver {
|
||||
pause: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for FailingRunExitObserver {
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
_context: AssistantTurnEndContext<'_, ()>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
Ok(if self.pause {
|
||||
TurnEndAction::Pause
|
||||
} else {
|
||||
TurnEndAction::Finish
|
||||
})
|
||||
}
|
||||
|
||||
async fn on_run_exit(&self, _context: RunExitContext<'_, ()>) -> InterceptorResult<()> {
|
||||
Err(InterceptorError::new(
|
||||
InterceptorErrorCategory::Dependency,
|
||||
"terminal audit unavailable",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_observer_failure_preserves_paused_and_interrupted_exits() {
|
||||
let mut paused_engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
paused_engine.set_interceptor(FailingRunExitObserver { pause: true });
|
||||
let mut paused_history = History::new();
|
||||
let mut paused_engine = paused_engine.lock(&paused_history);
|
||||
assert!(matches!(
|
||||
paused_engine.run(&mut paused_history, "pause").await,
|
||||
EngineRunExit::Paused
|
||||
));
|
||||
assert_eq!(
|
||||
paused_engine
|
||||
.last_run_exit_observer_failure()
|
||||
.expect("paused observer diagnostic")
|
||||
.error()
|
||||
.category(),
|
||||
InterceptorErrorCategory::Dependency
|
||||
);
|
||||
|
||||
let mut interrupted_engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
interrupted_engine.set_max_turns(Some(0));
|
||||
interrupted_engine.set_interceptor(FailingRunExitObserver { pause: false });
|
||||
let mut interrupted_history = History::new();
|
||||
let mut interrupted_engine = interrupted_engine.lock(&interrupted_history);
|
||||
assert!(matches!(
|
||||
interrupted_engine
|
||||
.run(&mut interrupted_history, "limit")
|
||||
.await,
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
|
||||
));
|
||||
assert_eq!(
|
||||
interrupted_engine
|
||||
.last_run_exit_observer_failure()
|
||||
.expect("interrupted observer diagnostic")
|
||||
.phase(),
|
||||
InterceptorPoint::RunExit
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TerminalMode {
|
||||
Finish,
|
||||
PauseOnce,
|
||||
Yield,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RecordingTerminalInterceptor {
|
||||
mode: TerminalMode,
|
||||
assistant_turns: Arc<AtomicUsize>,
|
||||
exits: Arc<Mutex<Vec<&'static str>>>,
|
||||
}
|
||||
|
||||
impl RecordingTerminalInterceptor {
|
||||
fn new(mode: TerminalMode) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
assistant_turns: Arc::new(AtomicUsize::new(0)),
|
||||
exits: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn exits(&self) -> Vec<&'static str> {
|
||||
self.exits.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for RecordingTerminalInterceptor {
|
||||
async fn pre_llm_request(
|
||||
&self,
|
||||
_context: PreLlmRequestContext<'_, ()>,
|
||||
) -> InterceptorResult<PreRequestAction> {
|
||||
Ok(if self.mode == TerminalMode::Yield {
|
||||
PreRequestAction::Yield
|
||||
} else {
|
||||
PreRequestAction::Continue
|
||||
})
|
||||
}
|
||||
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
context: AssistantTurnEndContext<'_, ()>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
assert!(!context.assistant_entries.is_empty());
|
||||
assert!(
|
||||
context.history.ends_with(context.assistant_entries),
|
||||
"assistant-turn callback must observe committed terminal items"
|
||||
);
|
||||
let turn = self.assistant_turns.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(if self.mode == TerminalMode::PauseOnce && turn == 0 {
|
||||
TurnEndAction::Pause
|
||||
} else {
|
||||
TurnEndAction::Finish
|
||||
})
|
||||
}
|
||||
|
||||
async fn on_run_exit(&self, context: RunExitContext<'_, ()>) -> InterceptorResult<()> {
|
||||
let kind = match context.exit {
|
||||
EngineRunExit::Finished => "finished",
|
||||
EngineRunExit::Paused => "paused",
|
||||
EngineRunExit::Yielded => "yielded",
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached) => "limit",
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::ContextWindowExceeded) => "context",
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled) => "cancelled",
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(_)) => "unexpected",
|
||||
};
|
||||
self.exits.lock().unwrap().push(kind);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ContextWindowClient;
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for ContextWindowClient {
|
||||
async fn stream(&self, _request: Request) -> Result<ResponseStream, ClientError> {
|
||||
Err(ClientError::ContextWindowExceeded)
|
||||
}
|
||||
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_observer_runs_once_for_every_exit_and_interruption_kind() {
|
||||
let finished = RecordingTerminalInterceptor::new(TerminalMode::Finish);
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_interceptor(finished.clone());
|
||||
let mut history = History::new();
|
||||
assert!(matches!(
|
||||
engine.lock(&history).run(&mut history, "finish").await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(finished.exits(), ["finished"]);
|
||||
|
||||
let yielded = RecordingTerminalInterceptor::new(TerminalMode::Yield);
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_interceptor(yielded.clone());
|
||||
let mut history = History::new();
|
||||
assert!(matches!(
|
||||
engine.lock(&history).run(&mut history, "yield").await,
|
||||
EngineRunExit::Yielded
|
||||
));
|
||||
assert_eq!(yielded.exits(), ["yielded"]);
|
||||
|
||||
let limited = RecordingTerminalInterceptor::new(TerminalMode::Finish);
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_max_turns(Some(0));
|
||||
engine.set_interceptor(limited.clone());
|
||||
let mut history = History::new();
|
||||
assert!(matches!(
|
||||
engine.lock(&history).run(&mut history, "limit").await,
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
|
||||
));
|
||||
assert_eq!(limited.exits(), ["limit"]);
|
||||
|
||||
let cancelled = RecordingTerminalInterceptor::new(TerminalMode::Finish);
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_interceptor(cancelled.clone());
|
||||
engine.cancel();
|
||||
let mut history = History::new();
|
||||
assert!(matches!(
|
||||
engine.lock(&history).run(&mut history, "cancel").await,
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled)
|
||||
));
|
||||
assert_eq!(cancelled.exits(), ["cancelled"]);
|
||||
|
||||
let context = RecordingTerminalInterceptor::new(TerminalMode::Finish);
|
||||
let mut engine = Engine::new(ContextWindowClient);
|
||||
engine.set_interceptor(context.clone());
|
||||
let mut history = History::new();
|
||||
assert!(matches!(
|
||||
engine.lock(&history).run(&mut history, "context").await,
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::ContextWindowExceeded)
|
||||
));
|
||||
assert_eq!(context.exits(), ["context"]);
|
||||
|
||||
let unexpected = FailingLifecycleInterceptor::new(InterceptorPoint::PromptSubmit);
|
||||
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
|
||||
engine.set_interceptor(unexpected.clone());
|
||||
let mut history = History::new();
|
||||
assert!(matches!(
|
||||
engine.lock(&history).run(&mut history, "fail").await,
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(EngineError::Interceptor(
|
||||
_
|
||||
)))
|
||||
));
|
||||
assert_eq!(
|
||||
unexpected
|
||||
.calls()
|
||||
.iter()
|
||||
.filter(|point| **point == InterceptorPoint::RunExit)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_observer_does_not_duplicate_on_resume() {
|
||||
let interceptor = RecordingTerminalInterceptor::new(TerminalMode::PauseOnce);
|
||||
let first_response = vec![
|
||||
Event::tool_use_start(0, "call-1", "count_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
let client = MockLlmClient::with_responses(vec![first_response, completed_text_events()]);
|
||||
let tool = CountingTool::new("count_tool");
|
||||
let mut engine = Engine::new(client);
|
||||
engine.register_tool(tool.definition());
|
||||
engine.set_interceptor(interceptor.clone());
|
||||
let mut history = History::new();
|
||||
let mut engine = engine.lock(&history);
|
||||
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "pause").await,
|
||||
EngineRunExit::Paused
|
||||
));
|
||||
assert_eq!(interceptor.exits(), ["paused"]);
|
||||
assert_eq!(
|
||||
tool.call_count(),
|
||||
0,
|
||||
"pause must retain the pending tool phase"
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
engine.resume(&mut history).await,
|
||||
EngineRunExit::Finished
|
||||
));
|
||||
assert_eq!(interceptor.exits(), ["paused", "finished"]);
|
||||
assert_eq!(
|
||||
tool.call_count(),
|
||||
1,
|
||||
"resume must execute the retained tool once"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -730,7 +1291,7 @@ async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() {
|
||||
|
||||
assert!(matches!(
|
||||
engine.resume(&mut history).await,
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached)
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.active_run_turn_count(), None);
|
||||
@@ -785,7 +1346,7 @@ async fn interceptor_continuation_consumes_the_logical_run_budget() {
|
||||
|
||||
assert!(matches!(
|
||||
engine.run(&mut history, "start").await,
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached)
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 1);
|
||||
assert_eq!(engine.llm_call_count(), 1);
|
||||
@@ -803,7 +1364,7 @@ async fn restored_active_run_budget_is_enforced_before_another_llm_call() {
|
||||
|
||||
assert!(matches!(
|
||||
engine.resume(&mut history).await,
|
||||
EngineRunExit::Interrupted(StopReason::LimitReached)
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached)
|
||||
));
|
||||
assert_eq!(engine.turn_count(), 7);
|
||||
assert_eq!(engine.llm_call_count(), 0);
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
use agen::llm_client::{
|
||||
ClientError, LlmClient, Request, ResponseStream, ToolCallCompletionSupport,
|
||||
scheme::{Scheme, gemini::GeminiScheme, openai_chat::OpenAIScheme},
|
||||
};
|
||||
use agen::{Engine, EngineRunExit, History, ToolCallDispatchMode};
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SseFrame {
|
||||
event_type: &'static str,
|
||||
data: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SchemeReplayClient<S> {
|
||||
scheme: S,
|
||||
responses: Arc<Vec<Vec<SseFrame>>>,
|
||||
stream_calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl<S> SchemeReplayClient<S> {
|
||||
fn new(scheme: S, responses: Vec<Vec<SseFrame>>) -> Self {
|
||||
Self {
|
||||
scheme,
|
||||
responses: Arc::new(responses),
|
||||
stream_calls: Arc::new(AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_count(&self) -> usize {
|
||||
self.stream_calls.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S: Scheme> LlmClient for SchemeReplayClient<S> {
|
||||
async fn stream(&self, _request: Request) -> Result<ResponseStream, ClientError> {
|
||||
let response_index = self.stream_calls.fetch_add(1, Ordering::SeqCst);
|
||||
let frames = self
|
||||
.responses
|
||||
.get(response_index)
|
||||
.ok_or_else(|| ClientError::Config("scheme replay responses exhausted".to_string()))?;
|
||||
let mut state = S::State::default();
|
||||
let mut output = Vec::new();
|
||||
for frame in frames {
|
||||
match self
|
||||
.scheme
|
||||
.parse_sse(frame.event_type, frame.data, &mut state)
|
||||
{
|
||||
Ok(events) => output.extend(events.into_iter().map(Ok)),
|
||||
Err(error) => {
|
||||
output.push(Err(error));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Box::pin(futures::stream::iter(output)))
|
||||
}
|
||||
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
|
||||
self.scheme.tool_call_completion_support()
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_response_complete_scheme_accepts_clean_eof<S: Scheme>(
|
||||
scheme: S,
|
||||
frames: Vec<SseFrame>,
|
||||
) {
|
||||
assert_eq!(
|
||||
scheme.tool_call_completion_support(),
|
||||
ToolCallCompletionSupport::ResponseComplete
|
||||
);
|
||||
|
||||
for (label, mode) in [
|
||||
("default on_tool_call_complete fallback", None),
|
||||
(
|
||||
"explicit after_response",
|
||||
Some(ToolCallDispatchMode::AfterResponse),
|
||||
),
|
||||
] {
|
||||
let client = SchemeReplayClient::new(scheme.clone(), vec![frames.clone()]);
|
||||
let probe = client.clone();
|
||||
let mut engine = Engine::new(client);
|
||||
if let Some(mode) = mode {
|
||||
engine.set_tool_call_dispatch_mode(mode);
|
||||
}
|
||||
let mut history = History::new();
|
||||
|
||||
let output = engine.run(&mut history, "hello").await;
|
||||
|
||||
assert!(
|
||||
matches!(output.result, EngineRunExit::Finished),
|
||||
"{label} should accept the response-complete scheme's clean EOF, got {:?}",
|
||||
output.result
|
||||
);
|
||||
assert_eq!(
|
||||
probe.stream_count(),
|
||||
1,
|
||||
"{label} must not open a continuation request after the adapter's normal EOF"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_chat_clean_eof_completes_in_default_and_explicit_after_response_modes() {
|
||||
assert_response_complete_scheme_accepts_clean_eof(
|
||||
OpenAIScheme::new(),
|
||||
vec![
|
||||
SseFrame {
|
||||
event_type: "message",
|
||||
data: r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":"stop"}]}"#,
|
||||
},
|
||||
SseFrame {
|
||||
event_type: "message",
|
||||
data: "[DONE]",
|
||||
},
|
||||
],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gemini_clean_eof_completes_in_default_and_explicit_after_response_modes() {
|
||||
assert_response_complete_scheme_accepts_clean_eof(
|
||||
GeminiScheme::new(),
|
||||
vec![
|
||||
SseFrame {
|
||||
event_type: "message",
|
||||
data: r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},"finishReason":"STOP","index":0}]}"#,
|
||||
},
|
||||
SseFrame {
|
||||
event_type: "message",
|
||||
data: "[DONE]",
|
||||
},
|
||||
],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -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,19 @@
|
||||
[package]
|
||||
name = "api-macros-impl"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
openapi = []
|
||||
|
||||
[dependencies]
|
||||
proc-macro-crate = "3"
|
||||
proc-macro2 = "1"
|
||||
quote = "1"
|
||||
syn = { version = "2", features = ["full", "extra-traits"] }
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "api-macros"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
axum = ["dep:axum", "dep:serde"]
|
||||
openapi = ["dep:schemars", "dep:serde_json", "api-macros-impl/openapi"]
|
||||
reqwest = [
|
||||
"dep:futures",
|
||||
"dep:reqwest",
|
||||
"dep:serde",
|
||||
"dep:serde_json",
|
||||
"dep:serde_urlencoded",
|
||||
"dep:url",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
api-macros-impl = { path = "../api-macros-impl" }
|
||||
bytes.workspace = true
|
||||
axum = { workspace = true, optional = true }
|
||||
futures = { workspace = true, optional = true }
|
||||
reqwest = { version = "0.13", default-features = false, optional = true, features = ["stream", "rustls"] }
|
||||
schemars = { version = "1.2", optional = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
serde_urlencoded = { version = "0.7", optional = true }
|
||||
url = { version = "2", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
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
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Transport boundary for generated routers.
|
||||
//!
|
||||
//! 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};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
handler::Handler,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
/// Parse one contract header into its declared Rust type.
|
||||
pub fn parse_header<T>(headers: &HeaderMap, name: &'static str) -> Result<T, StatusCode>
|
||||
where
|
||||
T: FromStr,
|
||||
{
|
||||
headers
|
||||
.get(name)
|
||||
.ok_or(StatusCode::BAD_REQUEST)?
|
||||
.to_str()
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?
|
||||
.parse()
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)
|
||||
}
|
||||
|
||||
pub fn parse_optional_header<T>(
|
||||
headers: &HeaderMap,
|
||||
name: &'static str,
|
||||
) -> Result<Option<T>, StatusCode>
|
||||
where
|
||||
T: FromStr,
|
||||
{
|
||||
headers
|
||||
.get(name)
|
||||
.map(|value| {
|
||||
value
|
||||
.to_str()
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?
|
||||
.parse()
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Opaque failure to encode one declared response header.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct ResponseHeaderError;
|
||||
|
||||
impl fmt::Display for ResponseHeaderError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("declared response header encoding failed")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ResponseHeaderError {}
|
||||
|
||||
/// Insert one typed declared response header without retaining its value in an error.
|
||||
pub fn insert_response_header<T: fmt::Display>(
|
||||
headers: &mut HeaderMap,
|
||||
name: &'static str,
|
||||
value: &T,
|
||||
) -> Result<(), ResponseHeaderError> {
|
||||
let value = value.to_string().parse().map_err(|_| ResponseHeaderError)?;
|
||||
headers.insert(name, value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize a typed JSON response with the contract status.
|
||||
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()
|
||||
}
|
||||
|
||||
/// Emit a transport-level rejection without a response body.
|
||||
pub fn rejection(status: StatusCode) -> Response {
|
||||
status.into_response()
|
||||
}
|
||||
|
||||
/// Convert a status constant from generated metadata.
|
||||
pub fn status(code: u16) -> StatusCode {
|
||||
StatusCode::from_u16(code).expect("#[api] validates HTTP status constants")
|
||||
}
|
||||
|
||||
/// Mount a manual Axum upgrade handler at a contract-declared WebSocket route.
|
||||
///
|
||||
/// The operation marker supplies the only path and method authority. Requiring
|
||||
/// [`crate::WebSocketOperation`] prevents HTTP operation markers from being registered through this
|
||||
/// helper while Axum retains its normal route composition and collision behavior.
|
||||
pub fn websocket_route<O, H, T, S>(router: Router<S>, handler: H) -> Router<S>
|
||||
where
|
||||
O: crate::WebSocketOperation,
|
||||
H: Handler<T, S>,
|
||||
T: 'static,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
debug_assert_eq!(O::METADATA.method, crate::HttpMethod::Get);
|
||||
debug_assert!(matches!(
|
||||
O::METADATA.transport,
|
||||
crate::TransportMetadata::WebSocket { .. }
|
||||
));
|
||||
router.route(O::METADATA.path, get(handler))
|
||||
}
|
||||
|
||||
/// Reexports used by generated router code.
|
||||
pub mod framework {
|
||||
pub use axum::{
|
||||
Json, Router,
|
||||
body::{Body, Bytes},
|
||||
extract::{
|
||||
DefaultBodyLimit, Extension, Path, Query, State,
|
||||
rejection::{BytesRejection, JsonRejection},
|
||||
},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Response,
|
||||
routing::{delete, get, head, options, patch, post, put},
|
||||
serve,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn response_header_encoding_failure_is_bounded_and_value_safe() {
|
||||
let mut headers = HeaderMap::new();
|
||||
let error = insert_response_header(&mut headers, "set-cookie", &"secret\ninvalid")
|
||||
.expect_err("invalid header bytes must fail");
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"declared response header encoding failed"
|
||||
);
|
||||
assert!(!format!("{error:?}").contains("secret"));
|
||||
assert!(headers.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
#![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")]
|
||||
pub mod axum;
|
||||
#[cfg(feature = "openapi")]
|
||||
pub mod openapi;
|
||||
#[cfg(feature = "reqwest")]
|
||||
pub mod reqwest;
|
||||
|
||||
/// 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
|
||||
/// without reserialization and lets generated Reqwest clients authorize and send the same storage.
|
||||
#[derive(Clone, Default, Eq, Hash, PartialEq)]
|
||||
pub struct BinaryBody(bytes::Bytes);
|
||||
|
||||
impl BinaryBody {
|
||||
pub const fn new(bytes: bytes::Bytes) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
pub fn into_bytes(self) -> bytes::Bytes {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<[u8]> for BinaryBody {
|
||||
fn as_ref(&self) -> &[u8] {
|
||||
self.0.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for BinaryBody {
|
||||
type Target = [u8];
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bytes::Bytes> for BinaryBody {
|
||||
fn from(value: bytes::Bytes) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<u8>> for BinaryBody {
|
||||
fn from(value: Vec<u8>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&'static [u8]> for BinaryBody {
|
||||
fn from(value: &'static [u8]) -> Self {
|
||||
Self(bytes::Bytes::from_static(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for BinaryBody {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str("BinaryBody(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
/// A transport-neutral HTTP method.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub enum HttpMethod {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Patch,
|
||||
Delete,
|
||||
Head,
|
||||
Options,
|
||||
}
|
||||
|
||||
/// Where an operation argument is encoded on the wire.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub enum ParameterLocation {
|
||||
Path,
|
||||
Query,
|
||||
Header,
|
||||
Body,
|
||||
}
|
||||
|
||||
/// Body encodings understood or explicitly reserved by the API contract.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
#[non_exhaustive]
|
||||
pub enum WireKind {
|
||||
Empty,
|
||||
Json,
|
||||
Streaming,
|
||||
Multipart,
|
||||
Binary,
|
||||
}
|
||||
|
||||
/// Stable metadata for one operation argument.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct ParameterMetadata {
|
||||
pub rust_name: &'static str,
|
||||
pub rust_type: &'static str,
|
||||
pub wire_name: &'static str,
|
||||
pub location: ParameterLocation,
|
||||
}
|
||||
|
||||
/// Transport semantics declared by one operation.
|
||||
///
|
||||
/// WebSocket frame directions remain distinct even when both directions use the same Rust type.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum TransportMetadata {
|
||||
Http,
|
||||
WebSocket {
|
||||
client_to_server_frame: &'static str,
|
||||
server_to_client_frame: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
/// Stable metadata for a request or response body.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct BodyMetadata {
|
||||
pub wire_kind: WireKind,
|
||||
}
|
||||
|
||||
/// Stable metadata for one explicitly declared response header.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct ResponseHeaderMetadata {
|
||||
/// Canonical wire name declared in the API contract.
|
||||
pub wire_name: &'static str,
|
||||
/// Rust type spelling declared alongside the wire name.
|
||||
pub rust_type: &'static str,
|
||||
}
|
||||
|
||||
/// Stable metadata for one HTTP response.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct ResponseMetadata {
|
||||
pub status: u16,
|
||||
pub body: BodyMetadata,
|
||||
pub headers: &'static [ResponseHeaderMetadata],
|
||||
}
|
||||
|
||||
/// Complete transport metadata for one operation.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct OperationMetadata {
|
||||
pub operation_id: &'static str,
|
||||
pub method: HttpMethod,
|
||||
pub path: &'static str,
|
||||
pub transport: TransportMetadata,
|
||||
pub parameters: &'static [ParameterMetadata],
|
||||
pub request_body: BodyMetadata,
|
||||
/// Every declared successful response, including status-specific body and header shape.
|
||||
pub success_responses: &'static [ResponseMetadata],
|
||||
/// Every declared public-error status. Error responses currently have no declared headers.
|
||||
pub error_responses: &'static [ResponseMetadata],
|
||||
}
|
||||
|
||||
/// Deterministic operation inventory emitted for an `#[api]` trait.
|
||||
pub trait ApiContract {
|
||||
const OPERATIONS: &'static [OperationMetadata];
|
||||
}
|
||||
|
||||
/// Compile-time connection between operation metadata and its concrete body types.
|
||||
pub trait Operation {
|
||||
/// Tuple of all non-receiver parameter types in declaration order.
|
||||
type Parameters;
|
||||
/// Typed JSON or binary request body, or [`NoBody`].
|
||||
type RequestBody;
|
||||
/// JSON or binary success body type, generated declared-response result, or [`NoBody`].
|
||||
type ResponseBody;
|
||||
/// JSON public error body type, or [`NoBody`].
|
||||
type ErrorBody;
|
||||
|
||||
const METADATA: OperationMetadata;
|
||||
}
|
||||
|
||||
/// Compile-time connection between a WebSocket route and its directional frame authorities.
|
||||
///
|
||||
/// This is intentionally separate from [`Operation`]: WebSocket routes do not have unary request,
|
||||
/// response, or error bodies and therefore cannot be passed to HTTP adapter surfaces.
|
||||
pub trait WebSocketOperation {
|
||||
/// Tuple of path parameter types in path-template order.
|
||||
type PathParameters;
|
||||
/// Frame received by the server from the connected client.
|
||||
type ClientToServerFrame;
|
||||
/// Frame sent by the server to the connected client.
|
||||
type ServerToClientFrame;
|
||||
|
||||
const METADATA: OperationMetadata;
|
||||
}
|
||||
|
||||
/// Public JSON error that can select its concrete HTTP status at the server boundary.
|
||||
///
|
||||
/// `error_status` remains the contract's default/documented status. Implementations may return a
|
||||
/// different 4xx/5xx status for one named error shape, allowing a generated client to preserve a
|
||||
/// service's typed 401/403/404/409 responses without introducing duplicate route handlers.
|
||||
pub trait HttpError {
|
||||
fn status_code(&self) -> u16;
|
||||
}
|
||||
|
||||
/// Successful JSON response that selects one of an operation's declared success statuses.
|
||||
///
|
||||
/// Operations opt in with `alternate_status`; ordinary one-status operations remain fully static.
|
||||
pub trait HttpSuccess {
|
||||
fn status_code(&self) -> u16;
|
||||
}
|
||||
|
||||
/// Public error type capable of normalizing Axum request-body rejections.
|
||||
pub trait HttpRequestError: HttpError {
|
||||
fn from_request_rejection(status: u16, message: String) -> Self;
|
||||
}
|
||||
|
||||
/// Marker used when an operation has no request, response, or public error body.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub enum NoBody {}
|
||||
@@ -0,0 +1,678 @@
|
||||
//! Generate deterministic OpenAPI documents from API contracts.
|
||||
//!
|
||||
//! 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,
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fmt, fs,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
pub use schemars::{self, JsonSchema};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
|
||||
|
||||
/// An explicit assertion that `JsonSchema` describes the type's serialized
|
||||
/// wire representation.
|
||||
///
|
||||
/// OpenAPI-enabled APIs require this trait instead of accepting every
|
||||
/// `JsonSchema` implementation implicitly. This makes unusual Serde behavior
|
||||
/// (for example `flatten`, `untagged`, or a custom serializer) fail closed
|
||||
/// until the type owner supplies and reviews a matching schema implementation.
|
||||
/// The default component name delegates to [`JsonSchema::schema_name`]; types
|
||||
/// may override it to provide a stable public wire name.
|
||||
pub trait OpenApiSchema: JsonSchema {
|
||||
/// Whether a parameter of this type must be present on the wire.
|
||||
///
|
||||
/// This semantic hook intentionally lives on the resolved type, so aliases
|
||||
/// such as `type Header = Option<String>` retain optionality.
|
||||
const PARAMETER_REQUIRED: bool = true;
|
||||
/// Whether an `Authorization` header of this type is a bearer credential.
|
||||
const BEARER_CREDENTIAL: bool = false;
|
||||
|
||||
fn openapi_schema_name() -> Cow<'static, str> {
|
||||
Self::schema_name()
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! impl_openapi_schema {
|
||||
($($ty:ty),* $(,)?) => {
|
||||
$(impl OpenApiSchema for $ty {})*
|
||||
};
|
||||
}
|
||||
|
||||
impl_openapi_schema!((), bool, char, i8, i16, i32, u8, u16, u32, f32, f64);
|
||||
|
||||
impl OpenApiSchema for String {
|
||||
const BEARER_CREDENTIAL: bool = true;
|
||||
}
|
||||
|
||||
impl<T: OpenApiSchema> OpenApiSchema for Option<T> {
|
||||
const PARAMETER_REQUIRED: bool = false;
|
||||
const BEARER_CREDENTIAL: bool = T::BEARER_CREDENTIAL;
|
||||
}
|
||||
impl<T: OpenApiSchema> OpenApiSchema for Vec<T> {}
|
||||
impl<T: OpenApiSchema> OpenApiSchema for Box<T> {}
|
||||
impl<T: OpenApiSchema> OpenApiSchema for std::sync::Arc<T> {}
|
||||
impl<T: OpenApiSchema> OpenApiSchema for BTreeMap<String, T> {}
|
||||
|
||||
/// Stable, deployment-independent document identity supplied by an exporter.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct OpenApiInfo<'a> {
|
||||
pub title: &'a str,
|
||||
pub version: &'a str,
|
||||
/// Source-content digest used to identify the exported contract's build inputs.
|
||||
pub source_digest: &'a str,
|
||||
}
|
||||
|
||||
/// An immutable OpenAPI 3.1 document with canonical JSON rendering.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OpenApiDocument(Value);
|
||||
|
||||
impl OpenApiDocument {
|
||||
pub fn as_value(&self) -> &Value {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Render pretty JSON with recursively sorted object keys and a final LF.
|
||||
pub fn to_json(&self) -> Result<String, OpenApiError> {
|
||||
let mut rendered = serde_json::to_string_pretty(&self.0)?;
|
||||
rendered.push('\n');
|
||||
Ok(rendered)
|
||||
}
|
||||
|
||||
/// Export the same canonical bytes returned by [`Self::to_json`].
|
||||
pub fn write_json(&self, path: impl AsRef<Path>) -> Result<(), OpenApiError> {
|
||||
fs::write(path, self.to_json()?)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum OpenApiError {
|
||||
InvalidContract(String),
|
||||
Io(std::io::Error),
|
||||
Json(serde_json::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for OpenApiError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidContract(message) => formatter.write_str(message),
|
||||
Self::Io(error) => write!(formatter, "failed to export OpenAPI document: {error}"),
|
||||
Self::Json(error) => write!(formatter, "failed to serialize OpenAPI document: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for OpenApiError {}
|
||||
|
||||
impl From<std::io::Error> for OpenApiError {
|
||||
fn from(error: std::io::Error) -> Self {
|
||||
Self::Io(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for OpenApiError {
|
||||
fn from(error: serde_json::Error) -> Self {
|
||||
Self::Json(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder used by code generated for an `#[api(openapi)]` trait.
|
||||
#[doc(hidden)]
|
||||
pub struct OpenApiBuilder {
|
||||
info: Value,
|
||||
paths: BTreeMap<String, BTreeMap<String, Value>>,
|
||||
schemas: BTreeMap<String, Value>,
|
||||
operation_ids: BTreeSet<String>,
|
||||
uses_bearer_auth: bool,
|
||||
uses_browser_auth: bool,
|
||||
}
|
||||
|
||||
impl OpenApiBuilder {
|
||||
pub fn new(info: OpenApiInfo<'_>) -> Result<Self, OpenApiError> {
|
||||
for (field, value) in [
|
||||
("title", info.title),
|
||||
("version", info.version),
|
||||
("source digest", info.source_digest),
|
||||
] {
|
||||
if value.trim().is_empty() {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"OpenAPI {field} must not be empty"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
info: json!({
|
||||
"title": info.title,
|
||||
"version": info.version,
|
||||
"x-yoi-source-digest": info.source_digest,
|
||||
}),
|
||||
paths: BTreeMap::new(),
|
||||
schemas: BTreeMap::new(),
|
||||
operation_ids: BTreeSet::new(),
|
||||
uses_bearer_auth: false,
|
||||
uses_browser_auth: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn operation<'a>(
|
||||
&'a mut self,
|
||||
method: &'static str,
|
||||
path: &'static str,
|
||||
operation_id: &'static str,
|
||||
) -> Result<OpenApiOperation<'a>, OpenApiError> {
|
||||
let method = method.to_ascii_lowercase();
|
||||
if !matches!(
|
||||
method.as_str(),
|
||||
"get" | "post" | "put" | "patch" | "delete" | "head" | "options" | "trace"
|
||||
) {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"unsupported OpenAPI method `{method}` for `{operation_id}`"
|
||||
)));
|
||||
}
|
||||
if !self.operation_ids.insert(operation_id.to_owned()) {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"duplicate operationId `{operation_id}`"
|
||||
)));
|
||||
}
|
||||
if self
|
||||
.paths
|
||||
.get(path)
|
||||
.is_some_and(|operations| operations.contains_key(&method))
|
||||
{
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"duplicate operation for {method} {path}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(OpenApiOperation {
|
||||
parent: self,
|
||||
method,
|
||||
path: path.to_owned(),
|
||||
operation_id,
|
||||
parameters: Vec::new(),
|
||||
request_body: None,
|
||||
responses: BTreeMap::new(),
|
||||
bearer_security_required: None,
|
||||
browser_security_required: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Result<OpenApiDocument, OpenApiError> {
|
||||
let paths = self
|
||||
.paths
|
||||
.into_iter()
|
||||
.map(|(path, operations)| {
|
||||
let operations = operations.into_iter().collect::<Map<_, _>>();
|
||||
(path, Value::Object(operations))
|
||||
})
|
||||
.collect::<Map<_, _>>();
|
||||
let schemas = self.schemas.into_iter().collect::<Map<_, _>>();
|
||||
let mut components = Map::new();
|
||||
components.insert("schemas".to_owned(), Value::Object(schemas));
|
||||
let mut security_schemes = Map::new();
|
||||
if self.uses_bearer_auth {
|
||||
security_schemes.insert(
|
||||
"bearerAuth".to_owned(),
|
||||
json!({
|
||||
"type": "http",
|
||||
"scheme": "bearer"
|
||||
}),
|
||||
);
|
||||
}
|
||||
if self.uses_browser_auth {
|
||||
security_schemes.insert(
|
||||
"browserSession".to_owned(),
|
||||
json!({
|
||||
"type": "apiKey",
|
||||
"in": "cookie",
|
||||
"name": "yoi_workspace_session"
|
||||
}),
|
||||
);
|
||||
}
|
||||
if !security_schemes.is_empty() {
|
||||
components.insert(
|
||||
"securitySchemes".to_owned(),
|
||||
Value::Object(security_schemes),
|
||||
);
|
||||
}
|
||||
|
||||
let document = canonicalize(json!({
|
||||
"openapi": "3.1.0",
|
||||
"info": self.info,
|
||||
"paths": paths,
|
||||
"components": components,
|
||||
}));
|
||||
validate_component_references(&document)?;
|
||||
Ok(OpenApiDocument(document))
|
||||
}
|
||||
|
||||
fn schema_ref<T: OpenApiSchema>(&mut self) -> Result<Value, OpenApiError> {
|
||||
let name = T::openapi_schema_name().into_owned();
|
||||
validate_component_name(&name)?;
|
||||
|
||||
let mut schema = serde_json::to_value(schemars::schema_for!(T))?;
|
||||
let definitions = schema
|
||||
.as_object_mut()
|
||||
.and_then(|object| object.remove("$defs"))
|
||||
.unwrap_or_else(|| json!({}));
|
||||
if let Some(object) = schema.as_object_mut() {
|
||||
object.remove("$schema");
|
||||
object.remove("title");
|
||||
}
|
||||
rewrite_definition_refs(&mut schema);
|
||||
validate_schema(&schema, &name)?;
|
||||
self.insert_schema(name.clone(), canonicalize(schema))?;
|
||||
|
||||
let definitions = definitions.as_object().ok_or_else(|| {
|
||||
OpenApiError::InvalidContract(format!(
|
||||
"schema `{name}` emitted non-object JSON Schema definitions"
|
||||
))
|
||||
})?;
|
||||
for (definition_name, definition) in definitions {
|
||||
validate_component_name(definition_name)?;
|
||||
let mut definition = definition.clone();
|
||||
rewrite_definition_refs(&mut definition);
|
||||
validate_schema(&definition, definition_name)?;
|
||||
self.insert_schema(definition_name.clone(), canonicalize(definition))?;
|
||||
}
|
||||
|
||||
Ok(json!({ "$ref": format!("#/components/schemas/{name}") }))
|
||||
}
|
||||
|
||||
fn insert_schema(&mut self, name: String, schema: Value) -> Result<(), OpenApiError> {
|
||||
match self.schemas.get(&name) {
|
||||
Some(existing) if existing != &schema => Err(OpenApiError::InvalidContract(format!(
|
||||
"OpenAPI component name collision for `{name}`"
|
||||
))),
|
||||
Some(_) => Ok(()),
|
||||
None => {
|
||||
self.schemas.insert(name, schema);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One operation under construction. Generated code adds its complete wire
|
||||
/// metadata before atomically inserting it into the parent document.
|
||||
#[doc(hidden)]
|
||||
pub struct OpenApiOperation<'a> {
|
||||
parent: &'a mut OpenApiBuilder,
|
||||
method: String,
|
||||
path: String,
|
||||
operation_id: &'static str,
|
||||
parameters: Vec<Value>,
|
||||
request_body: Option<Value>,
|
||||
responses: BTreeMap<String, Value>,
|
||||
bearer_security_required: Option<bool>,
|
||||
browser_security_required: bool,
|
||||
}
|
||||
|
||||
impl OpenApiOperation<'_> {
|
||||
pub fn parameter<T: OpenApiSchema>(
|
||||
&mut self,
|
||||
name: &'static str,
|
||||
location: &'static str,
|
||||
) -> Result<(), OpenApiError> {
|
||||
if !matches!(location, "path" | "query" | "header") {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"unsupported parameter location `{location}` for `{}`",
|
||||
self.operation_id
|
||||
)));
|
||||
}
|
||||
let required = location == "path" || T::PARAMETER_REQUIRED;
|
||||
if location == "header" && name.eq_ignore_ascii_case("authorization") {
|
||||
if !T::BEARER_CREDENTIAL {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"Authorization header for `{}` must use a bearer credential schema",
|
||||
self.operation_id
|
||||
)));
|
||||
}
|
||||
if self.bearer_security_required.replace(required).is_some() {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"multiple Authorization headers for `{}`",
|
||||
self.operation_id
|
||||
)));
|
||||
}
|
||||
self.parent.uses_bearer_auth = true;
|
||||
return Ok(());
|
||||
}
|
||||
let schema = self.parent.schema_ref::<T>()?;
|
||||
self.parameters.push(json!({
|
||||
"name": name,
|
||||
"in": location,
|
||||
"required": required,
|
||||
"schema": schema,
|
||||
"style": if location == "query" { "form" } else { "simple" },
|
||||
"explode": location == "query",
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn bearer_authentication(&mut self) {
|
||||
self.parent.uses_bearer_auth = true;
|
||||
self.bearer_security_required = Some(true);
|
||||
}
|
||||
|
||||
pub fn browser_authentication(&mut self) {
|
||||
self.parent.uses_browser_auth = true;
|
||||
self.browser_security_required = true;
|
||||
}
|
||||
|
||||
pub fn request_body<T: OpenApiSchema>(
|
||||
&mut self,
|
||||
content_type: &'static str,
|
||||
) -> Result<(), OpenApiError> {
|
||||
if self.request_body.is_some() {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"multiple request bodies for `{}`",
|
||||
self.operation_id
|
||||
)));
|
||||
}
|
||||
let schema = self.parent.schema_ref::<T>()?;
|
||||
self.request_body = Some(json!({
|
||||
"required": true,
|
||||
"content": { content_type: { "schema": schema } },
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn binary_request_body(&mut self) -> Result<(), OpenApiError> {
|
||||
if self.request_body.is_some() {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"multiple request bodies for `{}`",
|
||||
self.operation_id
|
||||
)));
|
||||
}
|
||||
self.request_body = Some(json!({
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": { "type": "string", "format": "binary" }
|
||||
}
|
||||
},
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn response<T: OpenApiSchema>(
|
||||
&mut self,
|
||||
status: u16,
|
||||
content_type: &'static str,
|
||||
description: &'static str,
|
||||
) -> Result<(), OpenApiError> {
|
||||
let schema = self.parent.schema_ref::<T>()?;
|
||||
self.insert_response(
|
||||
status,
|
||||
json!({
|
||||
"description": description,
|
||||
"content": { content_type: { "schema": schema } },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
description: &'static str,
|
||||
) -> Result<(), OpenApiError> {
|
||||
self.insert_response(status, json!({ "description": description }))
|
||||
}
|
||||
|
||||
pub fn response_header<T: OpenApiSchema>(
|
||||
&mut self,
|
||||
status: u16,
|
||||
name: &'static str,
|
||||
) -> Result<(), OpenApiError> {
|
||||
let schema = self.parent.schema_ref::<T>()?;
|
||||
let response = self
|
||||
.responses
|
||||
.get_mut(&status.to_string())
|
||||
.and_then(Value::as_object_mut)
|
||||
.ok_or_else(|| {
|
||||
OpenApiError::InvalidContract(format!(
|
||||
"response header `{name}` precedes status `{status}` for `{}`",
|
||||
self.operation_id
|
||||
))
|
||||
})?;
|
||||
let headers = response
|
||||
.entry("headers")
|
||||
.or_insert_with(|| Value::Object(Map::new()))
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| {
|
||||
OpenApiError::InvalidContract(format!(
|
||||
"response headers for status `{status}` are not an object for `{}`",
|
||||
self.operation_id
|
||||
))
|
||||
})?;
|
||||
if headers.contains_key(name) {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"duplicate response header `{name}` for status `{status}` on `{}`",
|
||||
self.operation_id
|
||||
)));
|
||||
}
|
||||
headers.insert(name.to_owned(), json!({ "schema": schema }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_response(&mut self, status: u16, response: Value) -> Result<(), OpenApiError> {
|
||||
if !(100..=599).contains(&status) {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"invalid HTTP response status `{status}` for `{}`",
|
||||
self.operation_id
|
||||
)));
|
||||
}
|
||||
let status = status.to_string();
|
||||
match self.responses.get(&status) {
|
||||
Some(existing) if existing != &response => Err(OpenApiError::InvalidContract(format!(
|
||||
"conflicting response status `{status}` for `{}`",
|
||||
self.operation_id
|
||||
))),
|
||||
Some(_) => Ok(()),
|
||||
None => {
|
||||
self.responses.insert(status, response);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Result<(), OpenApiError> {
|
||||
if self.responses.is_empty() {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"operation `{}` has no responses",
|
||||
self.operation_id
|
||||
)));
|
||||
}
|
||||
let responses = self.responses.into_iter().collect::<Map<_, _>>();
|
||||
let mut operation = Map::new();
|
||||
operation.insert("operationId".to_owned(), json!(self.operation_id));
|
||||
operation.insert("responses".to_owned(), Value::Object(responses));
|
||||
if !self.parameters.is_empty() {
|
||||
operation.insert("parameters".to_owned(), Value::Array(self.parameters));
|
||||
}
|
||||
if let Some(request_body) = self.request_body {
|
||||
operation.insert("requestBody".to_owned(), request_body);
|
||||
}
|
||||
let mut security = Vec::new();
|
||||
if self.bearer_security_required == Some(false) {
|
||||
security.push(json!({}));
|
||||
}
|
||||
if self.bearer_security_required.is_some() {
|
||||
security.push(json!({ "bearerAuth": [] }));
|
||||
}
|
||||
if self.browser_security_required {
|
||||
security.push(json!({ "browserSession": [] }));
|
||||
}
|
||||
if !security.is_empty() {
|
||||
operation.insert("security".to_owned(), Value::Array(security));
|
||||
}
|
||||
self.parent
|
||||
.paths
|
||||
.entry(self.path)
|
||||
.or_default()
|
||||
.insert(self.method, Value::Object(operation));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_component_name(name: &str) -> Result<(), OpenApiError> {
|
||||
if name.is_empty()
|
||||
|| !name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"OpenAPI component name `{name}` must match [A-Za-z0-9._-]+"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_component_references(document: &Value) -> Result<(), OpenApiError> {
|
||||
let schemas = document["components"]["schemas"]
|
||||
.as_object()
|
||||
.ok_or_else(|| OpenApiError::InvalidContract("missing component schema map".to_owned()))?;
|
||||
let mut references = Vec::new();
|
||||
collect_references(document, &mut references);
|
||||
for reference in references {
|
||||
let Some(name) = reference.strip_prefix("#/components/schemas/") else {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"unsupported non-component schema reference `{reference}`"
|
||||
)));
|
||||
};
|
||||
if !schemas.contains_key(name) {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"unresolved OpenAPI component reference `{reference}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_references<'a>(value: &'a Value, references: &mut Vec<&'a str>) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
|
||||
references.push(reference);
|
||||
}
|
||||
for value in object.values() {
|
||||
collect_references(value, references);
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for value in values {
|
||||
collect_references(value, references);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn rewrite_definition_refs(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if let Some(Value::String(reference)) = object.get_mut("$ref") {
|
||||
if let Some(name) = reference.strip_prefix("#/$defs/") {
|
||||
*reference = format!("#/components/schemas/{name}");
|
||||
}
|
||||
}
|
||||
for value in object.values_mut() {
|
||||
rewrite_definition_refs(value);
|
||||
}
|
||||
}
|
||||
Value::Array(values) => values.iter_mut().for_each(rewrite_definition_refs),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_schema(value: &Value, component: &str) -> Result<(), OpenApiError> {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if object.get("type").and_then(Value::as_str) == Some("integer") {
|
||||
let safe_format = matches!(
|
||||
object.get("format").and_then(Value::as_str),
|
||||
Some("int8" | "uint8" | "int16" | "uint16" | "int32" | "uint32")
|
||||
);
|
||||
let minimum = object.get("minimum").and_then(Value::as_f64);
|
||||
let maximum = object.get("maximum").and_then(Value::as_f64);
|
||||
if !safe_format
|
||||
&& (minimum.is_none_or(|value| value < -(MAX_SAFE_INTEGER as f64))
|
||||
|| maximum.is_none_or(|value| value > MAX_SAFE_INTEGER as f64))
|
||||
{
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"integer schema in `{component}` exceeds the JSON safe-integer range; expose a bounded wire integer"
|
||||
)));
|
||||
}
|
||||
}
|
||||
// A non-null anyOf is commonly emitted for Serde's untagged enum
|
||||
// representation. Its branch-selection semantics are not lossless
|
||||
// enough for this projection. Nullable schemas remain supported.
|
||||
if let Some(Value::Array(branches)) = object.get("anyOf") {
|
||||
let null_branches = branches
|
||||
.iter()
|
||||
.filter(|branch| branch.get("type").and_then(Value::as_str) == Some("null"))
|
||||
.count();
|
||||
if null_branches != 1 || branches.len() != 2 {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"schema `{component}` uses unsupported ambiguous anyOf/untagged semantics"
|
||||
)));
|
||||
}
|
||||
}
|
||||
for value in object.values() {
|
||||
validate_schema(value, component)?;
|
||||
}
|
||||
}
|
||||
Value::Array(values) => {
|
||||
for value in values {
|
||||
validate_schema(value, component)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonicalize(value: Value) -> Value {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
let sorted = object
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key, canonicalize(value)))
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
Value::Object(sorted)
|
||||
}
|
||||
Value::Array(values) => Value::Array(values.into_iter().map(canonicalize).collect()),
|
||||
scalar => scalar,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
//! Transport boundary for generated clients.
|
||||
//!
|
||||
//! 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};
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::StreamExt as _;
|
||||
use reqwest::{
|
||||
Client, Method, StatusCode, Url,
|
||||
header::{HeaderMap, HeaderName, HeaderValue},
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
/// Default timeout applied to the complete request, including response-body reads.
|
||||
pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Default maximum response body size (1 MiB).
|
||||
pub const DEFAULT_RESPONSE_BODY_LIMIT: usize = 1024 * 1024;
|
||||
|
||||
/// A parsed HTTP(S) base URL without credentials, query, or fragment.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct BaseUrl(Url);
|
||||
|
||||
impl BaseUrl {
|
||||
/// Access the validated URL.
|
||||
pub fn as_url(&self) -> &Url {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for BaseUrl {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.debug_tuple("BaseUrl").field(&self.0).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for BaseUrl {
|
||||
type Error = ClientBuildError;
|
||||
|
||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||
Url::parse(value)
|
||||
.map_err(|_| ClientBuildError::InvalidBaseUrl)
|
||||
.and_then(Self::try_from)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for BaseUrl {
|
||||
type Error = ClientBuildError;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::try_from(value.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Url> for BaseUrl {
|
||||
type Error = ClientBuildError;
|
||||
|
||||
fn try_from(mut value: Url) -> Result<Self, Self::Error> {
|
||||
if !matches!(value.scheme(), "http" | "https")
|
||||
|| value.cannot_be_a_base()
|
||||
|| value.host_str().is_none()
|
||||
|| !value.username().is_empty()
|
||||
|| value.password().is_some()
|
||||
|| value.query().is_some()
|
||||
|| value.fragment().is_some()
|
||||
{
|
||||
return Err(ClientBuildError::InvalidBaseUrl);
|
||||
}
|
||||
if !value.path().ends_with('/') {
|
||||
let mut path = value.path().to_owned();
|
||||
path.push('/');
|
||||
value.set_path(&path);
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure while constructing a generated client.
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
pub enum ClientBuildError {
|
||||
/// The base URL was not an absolute credential-free HTTP(S) URL without query or fragment.
|
||||
InvalidBaseUrl,
|
||||
/// Reqwest could not construct its default client.
|
||||
ClientConstruction,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ClientBuildError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(self, formatter)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ClientBuildError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::InvalidBaseUrl => "invalid HTTP client base URL",
|
||||
Self::ClientConstruction => "failed to construct HTTP client",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ClientBuildError {}
|
||||
|
||||
/// Exact canonical request material supplied to a [`RequestAuthorizer`].
|
||||
///
|
||||
/// `body` is the same byte slice subsequently passed to Reqwest. `path_and_query` is taken from
|
||||
/// the final URL after path-segment and query encoding.
|
||||
pub struct AuthorizerRequest<'a> {
|
||||
pub method: &'a Method,
|
||||
pub path_and_query: &'a str,
|
||||
pub body: &'a [u8],
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthorizerRequest<'_> {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AuthorizerRequest")
|
||||
.field("method", self.method)
|
||||
.field("path_and_query", &"<redacted>")
|
||||
.field("body", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque authorizer failure. Secret-bearing authorizer errors are intentionally not retained.
|
||||
#[derive(Clone, Copy, Default, Eq, PartialEq)]
|
||||
pub struct AuthorizationError(());
|
||||
|
||||
impl AuthorizationError {
|
||||
pub const fn new() -> Self {
|
||||
Self(())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthorizationError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("AuthorizationError")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AuthorizationError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("request authorization failed")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AuthorizationError {}
|
||||
|
||||
/// Supplies credential headers for an exact encoded request.
|
||||
pub trait RequestAuthorizer: Send + Sync {
|
||||
fn authorize(&self, request: AuthorizerRequest<'_>) -> Result<HeaderMap, AuthorizationError>;
|
||||
}
|
||||
|
||||
/// Authorizer used by default; it adds no headers.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct NoAuthorizer;
|
||||
|
||||
impl RequestAuthorizer for NoAuthorizer {
|
||||
fn authorize(&self, _request: AuthorizerRequest<'_>) -> Result<HeaderMap, AuthorizationError> {
|
||||
Ok(HeaderMap::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for NoAuthorizer {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("NoAuthorizer")
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder shared by generated API-specific client builders.
|
||||
pub struct ClientBuilder<A = NoAuthorizer> {
|
||||
base_url: BaseUrl,
|
||||
client: Option<Client>,
|
||||
authorizer: A,
|
||||
request_timeout: Duration,
|
||||
response_body_limit: usize,
|
||||
}
|
||||
|
||||
impl ClientBuilder<NoAuthorizer> {
|
||||
pub fn try_new(base_url: impl AsRef<str>) -> Result<Self, ClientBuildError> {
|
||||
Ok(Self::from_base_url(BaseUrl::try_from(base_url.as_ref())?))
|
||||
}
|
||||
|
||||
pub fn from_base_url(base_url: BaseUrl) -> Self {
|
||||
Self {
|
||||
base_url,
|
||||
client: None,
|
||||
authorizer: NoAuthorizer,
|
||||
request_timeout: DEFAULT_REQUEST_TIMEOUT,
|
||||
response_body_limit: DEFAULT_RESPONSE_BODY_LIMIT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A> ClientBuilder<A> {
|
||||
pub fn client(mut self, client: Client) -> Self {
|
||||
self.client = Some(client);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn authorizer<B>(self, authorizer: B) -> ClientBuilder<B> {
|
||||
ClientBuilder {
|
||||
base_url: self.base_url,
|
||||
client: self.client,
|
||||
authorizer,
|
||||
request_timeout: self.request_timeout,
|
||||
response_body_limit: self.response_body_limit,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.request_timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn response_body_limit(mut self, limit: usize) -> Self {
|
||||
self.response_body_limit = limit;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<ClientCore<A>, ClientBuildError> {
|
||||
let client = match self.client {
|
||||
Some(client) => client,
|
||||
None => Client::builder()
|
||||
.build()
|
||||
.map_err(|_| ClientBuildError::ClientConstruction)?,
|
||||
};
|
||||
Ok(ClientCore {
|
||||
base_url: self.base_url,
|
||||
client,
|
||||
authorizer: self.authorizer,
|
||||
request_timeout: self.request_timeout,
|
||||
response_body_limit: self.response_body_limit,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<A> fmt::Debug for ClientBuilder<A> {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ClientBuilder")
|
||||
.field("base_url", &self.base_url)
|
||||
.field("client", &"<opaque>")
|
||||
.field("authorizer", &"<redacted>")
|
||||
.field("request_timeout", &self.request_timeout)
|
||||
.field("response_body_limit", &self.response_body_limit)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Framework support held by each generated API client.
|
||||
pub struct ClientCore<A = NoAuthorizer> {
|
||||
base_url: BaseUrl,
|
||||
client: Client,
|
||||
authorizer: A,
|
||||
request_timeout: Duration,
|
||||
response_body_limit: usize,
|
||||
}
|
||||
|
||||
impl<A: Clone> Clone for ClientCore<A> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
base_url: self.base_url.clone(),
|
||||
client: self.client.clone(),
|
||||
authorizer: self.authorizer.clone(),
|
||||
request_timeout: self.request_timeout,
|
||||
response_body_limit: self.response_body_limit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A> fmt::Debug for ClientCore<A> {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ClientCore")
|
||||
.field("base_url", &self.base_url)
|
||||
.field("client", &"<opaque>")
|
||||
.field("authorizer", &"<redacted>")
|
||||
.field("request_timeout", &self.request_timeout)
|
||||
.field("response_body_limit", &self.response_body_limit)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<A> ClientCore<A> {
|
||||
pub fn from_parts(
|
||||
base_url: BaseUrl,
|
||||
client: Client,
|
||||
authorizer: A,
|
||||
request_timeout: Duration,
|
||||
response_body_limit: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
base_url,
|
||||
client,
|
||||
authorizer,
|
||||
request_timeout,
|
||||
response_body_limit,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an endpoint by appending already-separated, unencoded path segments.
|
||||
pub fn endpoint(&self, segments: &[String]) -> Result<Url, ClientFailure> {
|
||||
let mut url = self.base_url.0.clone();
|
||||
let mut path = url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| ClientFailure::RequestEncoding)?;
|
||||
path.pop_if_empty();
|
||||
for segment in segments {
|
||||
path.push(segment);
|
||||
}
|
||||
drop(path);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
pub fn response_body_limit(&self) -> usize {
|
||||
self.response_body_limit
|
||||
}
|
||||
}
|
||||
|
||||
/// A prepared request body whose bytes are authorized and transmitted without further encoding.
|
||||
pub struct EncodedBody {
|
||||
bytes: Bytes,
|
||||
content_type: &'static str,
|
||||
}
|
||||
|
||||
impl EncodedBody {
|
||||
fn json(bytes: Vec<u8>) -> Self {
|
||||
Self {
|
||||
bytes: bytes.into(),
|
||||
content_type: "application/json",
|
||||
}
|
||||
}
|
||||
|
||||
fn binary(body: crate::BinaryBody) -> Self {
|
||||
Self {
|
||||
bytes: body.into_bytes(),
|
||||
content_type: "application/octet-stream",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for EncodedBody {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("EncodedBody")
|
||||
.field("bytes", &"<redacted>")
|
||||
.field("content_type", &self.content_type)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: RequestAuthorizer> ClientCore<A> {
|
||||
pub async fn send(
|
||||
&self,
|
||||
method: Method,
|
||||
url: Url,
|
||||
mut headers: HeaderMap,
|
||||
body: Option<EncodedBody>,
|
||||
) -> Result<ReceivedResponse, ClientFailure> {
|
||||
let body_bytes = body
|
||||
.as_ref()
|
||||
.map(|body| body.bytes.as_ref())
|
||||
.unwrap_or_default();
|
||||
let path_and_query = &url[url::Position::BeforePath..url::Position::AfterQuery];
|
||||
let authorization = self
|
||||
.authorizer
|
||||
.authorize(AuthorizerRequest {
|
||||
method: &method,
|
||||
path_and_query,
|
||||
body: body_bytes,
|
||||
})
|
||||
.map_err(|_| ClientFailure::Authorization)?;
|
||||
headers.extend(authorization);
|
||||
if let Some(body) = body.as_ref() {
|
||||
headers.insert(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
HeaderValue::from_static(body.content_type),
|
||||
);
|
||||
}
|
||||
|
||||
let mut request = self
|
||||
.client
|
||||
.request(method, url)
|
||||
.headers(headers)
|
||||
.timeout(self.request_timeout);
|
||||
if let Some(body) = body {
|
||||
request = request.body(body.bytes);
|
||||
}
|
||||
let response = request.send().await.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
ClientFailure::Timeout
|
||||
} else if error.is_connect() {
|
||||
ClientFailure::Connect
|
||||
} else {
|
||||
ClientFailure::Transport
|
||||
}
|
||||
})?;
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
ClientFailure::Timeout
|
||||
} else {
|
||||
ClientFailure::Transport
|
||||
}
|
||||
})?;
|
||||
let Some(size) = bytes.len().checked_add(chunk.len()) else {
|
||||
return Err(ClientFailure::ResponseTooLarge {
|
||||
limit: self.response_body_limit,
|
||||
});
|
||||
};
|
||||
if size > self.response_body_limit {
|
||||
return Err(ClientFailure::ResponseTooLarge {
|
||||
limit: self.response_body_limit,
|
||||
});
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(ReceivedResponse {
|
||||
status,
|
||||
headers,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a serializable query DTO and append its pairs without changing existing pairs.
|
||||
pub fn append_query<T: Serialize + ?Sized>(url: &mut Url, value: &T) -> Result<(), ClientFailure> {
|
||||
let encoded = serde_urlencoded::to_string(value).map_err(|_| ClientFailure::RequestEncoding)?;
|
||||
if encoded.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let combined = match url.query() {
|
||||
Some(existing) if !existing.is_empty() => format!("{existing}&{encoded}"),
|
||||
_ => encoded,
|
||||
};
|
||||
url.set_query(Some(&combined));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert a string-form header value.
|
||||
pub fn insert_header<T: fmt::Display>(
|
||||
headers: &mut HeaderMap,
|
||||
name: &str,
|
||||
value: &T,
|
||||
) -> Result<(), ClientFailure> {
|
||||
let name =
|
||||
HeaderName::from_bytes(name.as_bytes()).map_err(|_| ClientFailure::RequestEncoding)?;
|
||||
let value =
|
||||
HeaderValue::from_str(&value.to_string()).map_err(|_| ClientFailure::RequestEncoding)?;
|
||||
headers.insert(name, value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert an optional string-form header value when present.
|
||||
pub fn insert_optional_header<T: fmt::Display>(
|
||||
headers: &mut HeaderMap,
|
||||
name: &str,
|
||||
value: &Option<T>,
|
||||
) -> Result<(), ClientFailure> {
|
||||
match value {
|
||||
Some(value) => insert_header(headers, name, value),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a JSON request body once. These exact bytes are authorized and transmitted.
|
||||
pub fn encode_json<T: Serialize + ?Sized>(value: &T) -> Result<EncodedBody, ClientFailure> {
|
||||
serde_json::to_vec(value)
|
||||
.map(EncodedBody::json)
|
||||
.map_err(|_| ClientFailure::RequestEncoding)
|
||||
}
|
||||
|
||||
/// Move an explicit binary request body into the exact authorized/transmitted representation.
|
||||
pub fn encode_binary(value: crate::BinaryBody) -> EncodedBody {
|
||||
EncodedBody::binary(value)
|
||||
}
|
||||
|
||||
/// Response received under the configured byte limit. Its content is always redacted in Debug.
|
||||
pub struct ReceivedResponse {
|
||||
status: StatusCode,
|
||||
headers: HeaderMap,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ReceivedResponse {
|
||||
pub fn status(&self) -> StatusCode {
|
||||
self.status
|
||||
}
|
||||
|
||||
pub fn parse_header<T>(&self, name: &'static str) -> Result<T, ClientFailure>
|
||||
where
|
||||
T: std::str::FromStr,
|
||||
{
|
||||
let mut values = self.headers.get_all(name).iter();
|
||||
let value = values
|
||||
.next()
|
||||
.ok_or(ClientFailure::MissingResponseHeader { name })?;
|
||||
if values.next().is_some() {
|
||||
return Err(ClientFailure::DuplicateResponseHeader { name });
|
||||
}
|
||||
value
|
||||
.to_str()
|
||||
.map_err(|_| ClientFailure::InvalidResponseHeader { name })?
|
||||
.parse()
|
||||
.map_err(|_| ClientFailure::InvalidResponseHeader { name })
|
||||
}
|
||||
|
||||
pub fn decode_json<T: DeserializeOwned>(&self, kind: DecodeKind) -> Result<T, ClientFailure> {
|
||||
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(())
|
||||
} else {
|
||||
Err(ClientFailure::UnexpectedBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ReceivedResponse {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ReceivedResponse")
|
||||
.field("status", &self.status)
|
||||
.field("headers", &"<redacted>")
|
||||
.field("bytes", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum DecodeKind {
|
||||
Success,
|
||||
PublicError,
|
||||
}
|
||||
|
||||
/// Non-public-error failure from a generated client.
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
pub enum ClientFailure {
|
||||
Authorization,
|
||||
RequestEncoding,
|
||||
Connect,
|
||||
Transport,
|
||||
Timeout,
|
||||
ResponseTooLarge { limit: usize },
|
||||
UnexpectedStatus { expected: u16, actual: u16 },
|
||||
ErrorResponseDecode { status: u16 },
|
||||
Decode { kind: DecodeKind },
|
||||
MissingResponseHeader { name: &'static str },
|
||||
DuplicateResponseHeader { name: &'static str },
|
||||
InvalidResponseHeader { name: &'static str },
|
||||
UnexpectedBody,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ClientFailure {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(self, formatter)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ClientFailure {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Authorization => formatter.write_str("request authorization failed"),
|
||||
Self::RequestEncoding => formatter.write_str("request encoding failed"),
|
||||
Self::Connect => formatter.write_str("HTTP connection failed"),
|
||||
Self::Transport => formatter.write_str("HTTP transport failed"),
|
||||
Self::Timeout => formatter.write_str("HTTP request timed out"),
|
||||
Self::ResponseTooLarge { limit } => {
|
||||
write!(formatter, "response body exceeded {limit} bytes")
|
||||
}
|
||||
Self::UnexpectedStatus { expected, actual } => {
|
||||
write!(
|
||||
formatter,
|
||||
"expected HTTP status {expected}, received {actual}"
|
||||
)
|
||||
}
|
||||
Self::ErrorResponseDecode { status } => {
|
||||
write!(formatter, "HTTP {status} error response JSON was malformed")
|
||||
}
|
||||
Self::Decode {
|
||||
kind: DecodeKind::Success,
|
||||
} => formatter.write_str("success response JSON was malformed"),
|
||||
Self::Decode {
|
||||
kind: DecodeKind::PublicError,
|
||||
} => formatter.write_str("public error response JSON was malformed"),
|
||||
Self::MissingResponseHeader { name } => {
|
||||
write!(formatter, "response header `{name}` was missing")
|
||||
}
|
||||
Self::DuplicateResponseHeader { name } => {
|
||||
write!(
|
||||
formatter,
|
||||
"response header `{name}` occurred more than once"
|
||||
)
|
||||
}
|
||||
Self::InvalidResponseHeader { name } => {
|
||||
write!(formatter, "response header `{name}` was invalid")
|
||||
}
|
||||
Self::UnexpectedBody => formatter.write_str("empty response contained a body"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ClientFailure {}
|
||||
|
||||
/// Typed error returned by generated client methods.
|
||||
pub enum ClientError<E> {
|
||||
Failure(ClientFailure),
|
||||
Public { status: StatusCode, error: E },
|
||||
}
|
||||
|
||||
impl<E> ClientError<E> {
|
||||
pub fn failure(failure: ClientFailure) -> Self {
|
||||
Self::Failure(failure)
|
||||
}
|
||||
|
||||
pub fn public(status: StatusCode, error: E) -> Self {
|
||||
Self::Public { status, error }
|
||||
}
|
||||
|
||||
pub fn into_public_error(self) -> Option<E> {
|
||||
match self {
|
||||
Self::Public { error, .. } => Some(error),
|
||||
Self::Failure(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> From<ClientFailure> for ClientError<E> {
|
||||
fn from(value: ClientFailure) -> Self {
|
||||
Self::Failure(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> fmt::Debug for ClientError<E> {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Failure(failure) => formatter.debug_tuple("Failure").field(failure).finish(),
|
||||
Self::Public { status, .. } => formatter
|
||||
.debug_struct("Public")
|
||||
.field("status", status)
|
||||
.field("error", &"<redacted>")
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> fmt::Display for ClientError<E> {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Failure(failure) => fmt::Display::fmt(failure, formatter),
|
||||
Self::Public { status, .. } => write!(formatter, "public HTTP error {status}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> std::error::Error for ClientError<E> {}
|
||||
|
||||
/// Reexports used by generated code and advanced client construction.
|
||||
pub mod framework {
|
||||
pub use reqwest::{Client, Method, StatusCode, Url, header};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bodyless_response_validation_rejects_retained_bytes_without_exposing_them() {
|
||||
let response = ReceivedResponse {
|
||||
status: StatusCode::NOT_MODIFIED,
|
||||
headers: HeaderMap::new(),
|
||||
bytes: b"sensitive-body".to_vec(),
|
||||
};
|
||||
assert_eq!(response.require_empty(), Err(ClientFailure::UnexpectedBody));
|
||||
assert!(!format!("{response:?}").contains("sensitive-body"));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user