Compare commits
821
Commits
acc7281414
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
402ae0d466 | ||
|
|
acb3c6d68b | ||
|
|
40ac83e632 | ||
|
|
58da395941 | ||
|
|
0e3ef94c9e | ||
|
|
1f68dfc2b5 | ||
|
|
84977a464c | ||
|
|
62ada5eaa4 | ||
|
|
f5ff0b7c13 | ||
|
|
3337cafcdf | ||
|
|
e87784118b | ||
|
|
40fada28ea | ||
|
|
58cc94d4b7 | ||
|
|
ccabea59c9 | ||
|
|
8cc1dc042d | ||
|
|
183c37446e | ||
|
|
7aa06afc45 | ||
|
|
1515a2fb86 | ||
|
|
ec798c58d7 | ||
|
|
e365189276 | ||
|
|
116d610ad0 | ||
|
|
75c570962d | ||
|
|
cae8ac1799 | ||
|
|
917cc222a3 | ||
|
|
7edc588202 | ||
|
|
c83461508b | ||
|
|
651d64f34d | ||
|
|
b98d4b59f5 | ||
|
|
374449e663 | ||
|
|
4c876a201b | ||
|
|
21b3dd1da1 | ||
|
|
5ca0ea9228 | ||
|
|
d5c3a68a37 | ||
|
|
2b33b9158d | ||
|
|
b31642e284 | ||
|
|
3a7a3307ef | ||
|
|
0496cd907b | ||
|
|
975b4fa700 | ||
|
|
060f280fdf | ||
|
|
7aaf189247 | ||
|
|
df6d99c07d | ||
|
|
63306cf017 | ||
|
|
08be5e85e4 | ||
|
|
9843510e1f | ||
|
|
83bda3dfb2 | ||
|
|
0ab15aa227 | ||
|
|
29c2fb8e06 | ||
|
|
4ebc465e8d | ||
|
|
4b132a21e9 | ||
|
|
b644971d45 | ||
|
|
df34533765 | ||
|
|
6f4efb36bb | ||
|
|
108d5b14d7 | ||
|
|
f633b86b35 | ||
|
|
1873e18f8e | ||
|
|
3cdcbb47bf | ||
|
|
aaa9c7987c | ||
|
|
048007a042 | ||
|
|
cb35b40b9d | ||
|
|
3b71fe03b4 | ||
|
|
471db64bcc | ||
|
|
5e2234763c | ||
|
|
1d140be715 | ||
|
|
ffb2a34ae5 | ||
|
|
ccf7de1a55 | ||
|
|
ccf3c80d29 | ||
|
|
3a3c89e0b4 | ||
|
|
17c629136a | ||
|
|
52a5c4141f | ||
|
|
c9ba27c333 | ||
|
|
46f6e2c58b | ||
|
|
d1f47e5a22 | ||
|
|
65de94bad3 | ||
|
|
4e935c6203 | ||
|
|
eac07cf5a8 | ||
|
|
260259d461 | ||
|
|
a9fb092834 | ||
|
|
3a21a68792 | ||
|
|
9d572d18bc | ||
|
|
f7852e8034 | ||
|
|
8c075de147 | ||
|
|
864367f4f5 | ||
|
|
33db2ea7f4 | ||
|
|
8396d09891 | ||
|
|
bf7171924d | ||
|
|
097c363fbc | ||
|
|
7dd8809e38 | ||
|
|
1749757036 | ||
|
|
5857e6121c | ||
|
|
a41147916b | ||
|
|
cabe38db1d | ||
|
|
f079479160 | ||
|
|
87e160a01a | ||
|
|
f94d829bf8 | ||
|
|
9a05bfa0c3 | ||
|
|
c1d46859a3 | ||
|
|
87ecbcb113 | ||
|
|
1fb2949561 | ||
|
|
11be777fc0 | ||
|
|
ff94161fc0 | ||
|
|
c2ab9a950f | ||
|
|
8e26a0f5a8 | ||
|
|
d1c15ee295 | ||
|
|
00a96234c6 | ||
|
|
fc3b663510 | ||
|
|
e4e045d059 | ||
|
|
436feaf33d | ||
|
|
29f450b962 | ||
|
|
db343893c8 | ||
|
|
554906ec02 | ||
|
|
18c37f4842 | ||
|
|
83382b824a | ||
|
|
6203316aa1 | ||
|
|
d57b4d1d5e | ||
|
|
379ae214fc | ||
|
|
163a403636 | ||
|
|
53edaadc3a | ||
|
|
3c2664c3ce | ||
|
|
d9048954a5 | ||
|
|
4f84dfd73f | ||
|
|
d69c367285 | ||
|
|
aa8dd4f89e | ||
|
|
b12e0785e2 | ||
|
|
24291a4545 | ||
|
|
92d073ff36 | ||
|
|
bc57ef38c1 | ||
|
|
ae8a0316d8 | ||
|
|
2cfbb1caea | ||
|
|
703398bd2c | ||
|
|
10c80ae514 | ||
|
|
285763f4ae | ||
|
|
8030045602 | ||
|
|
8c85b93e7d | ||
|
|
4017992c7d | ||
|
|
9dc6d8f144 | ||
|
|
779ced82b1 | ||
|
|
30e4985f9a | ||
|
|
1a1e3c286f | ||
|
|
71c906e04d | ||
|
|
e6bfb27fa9 | ||
|
|
2a3ece0364 | ||
|
|
61d174b174 | ||
|
|
c4814115de | ||
|
|
f84377b2fe | ||
|
|
19f506f8bc | ||
|
|
0a9d09b958 | ||
|
|
83c6d290b4 | ||
|
|
831db34acf | ||
|
|
4a276b0af0 | ||
|
|
c2b82a2591 | ||
|
|
99daaf31b6 | ||
|
|
94e51ea6d1 | ||
|
|
f2d2ab0102 | ||
|
|
6f42f23d2b | ||
|
|
8f54fa2a00 | ||
|
|
c110965911 | ||
|
|
2a48dfc41a | ||
|
|
0451142d41 | ||
|
|
65f18b0cdb | ||
|
|
ea31c7ca81 | ||
|
|
dd47dba5b0 | ||
|
|
cbedc76d06 | ||
|
|
a4aa1a1848 | ||
|
|
ee8ee360ef | ||
|
|
72cae33ea6 | ||
|
|
0cd5ca11cc | ||
|
|
ccb9d03865 | ||
|
|
41cd2d044a | ||
|
|
85d1815dcf | ||
|
|
9989aed916 | ||
|
|
3f2ba9df47 | ||
|
|
cd9f0f009e | ||
|
|
b49abce798 | ||
|
|
51b381a701 | ||
|
|
bea121ade0 | ||
|
|
18112d29a6 | ||
|
|
8bedfcda84 | ||
|
|
402543d617 | ||
|
|
a21ef31ee7 | ||
|
|
e8c159247a | ||
|
|
5dd9392575 | ||
|
|
da2296bc9c | ||
|
|
8e4f35eb3f | ||
|
|
9917c09b19 | ||
|
|
4445501f6c | ||
|
|
6fe36f3e46 | ||
|
|
cfb173c570 | ||
|
|
ad729af592 | ||
|
|
17abe1c40c | ||
|
|
4b8dc302ee | ||
|
|
cf2e74404d | ||
|
|
a0e161c653 | ||
|
|
614157424f | ||
|
|
f5f80fcd48 | ||
|
|
7dd4539f50 | ||
|
|
e66876249e | ||
|
|
d39eb43419 | ||
|
|
674b897321 | ||
|
|
d7e54ed181 | ||
|
|
3e833b5295 | ||
|
|
9194f0a1ba | ||
|
|
6945b7b3c3 | ||
|
|
560226dea2 | ||
|
|
4583b512b3 | ||
|
|
223a6ed011 | ||
|
|
a82234a75e | ||
|
|
92594488da | ||
|
|
2315c69f0a | ||
|
|
9d003a5c98 | ||
|
|
53ec914a52 | ||
|
|
d052cedc7d | ||
|
|
de72afd9a1 | ||
|
|
80ffff642f | ||
|
|
97960d4e3f | ||
|
|
a96038d79f | ||
|
|
1ca36d6b66 | ||
|
|
bb8eda379f | ||
|
|
2858e8ceba | ||
|
|
e35b5797a3 | ||
|
|
25baeedc03 | ||
|
|
17d81e29cc | ||
|
|
08bce5b630 | ||
|
|
84f1b229ba | ||
|
|
856ea7119a | ||
|
|
5f2798458e | ||
|
|
af3decce51 | ||
|
|
1cb6cd4e98 | ||
|
|
21bd089a23 | ||
|
|
88f463e633 | ||
|
|
ce62e09919 | ||
|
|
fe74d7c4b8 | ||
|
|
73902b03b6 | ||
|
|
9aeaa52bdb | ||
|
|
89ee5e48a5 | ||
|
|
5b5396599d | ||
|
|
1e33b2945c | ||
|
|
50b05051bb | ||
|
|
4208b6228e | ||
|
|
bb558bad2b | ||
|
|
fcc7c49ff1 | ||
|
|
fb5f49d2a2 | ||
|
|
82eaa986d8 | ||
|
|
17d6789b41 | ||
|
|
d24d50cac9 | ||
|
|
44b3c78761 | ||
|
|
08daf782b9 | ||
|
|
d7e35ea9ee | ||
|
|
39aa465a51 | ||
|
|
382b5e57f2 | ||
|
|
0d011ea0cd | ||
|
|
b740b2d1e2 | ||
|
|
c97bde9ee0 | ||
|
|
bb742e253d | ||
|
|
a10507c54f | ||
|
|
6a607ccbed | ||
|
|
4ac79b3665 | ||
|
|
a4fdf9cc45 | ||
|
|
981c422122 | ||
|
|
ab0f57c00a | ||
|
|
b723c64fa1 | ||
|
|
f86ae6d52f | ||
|
|
9a548d2b5e | ||
|
|
4e738ac5eb | ||
|
|
a6f3e30652 | ||
|
|
6ca5dfbe11 | ||
|
|
bc835b8503 | ||
|
|
46767daf49 | ||
|
|
99170d47ab | ||
|
|
796fa2ee85 | ||
|
|
200c24bc00 | ||
|
|
0a2b24bf5e | ||
|
|
eec2be87ad | ||
|
|
71cd58f868 | ||
|
|
58ed04ac59 | ||
|
|
c6a476d65d | ||
|
|
4c31ea2228 | ||
|
|
f9e5fca67d | ||
|
|
a2cd860199 | ||
|
|
f5fdce2d07 | ||
|
|
8c60921c99 | ||
|
|
956b453e6a | ||
|
|
063f203efe | ||
|
|
5f44b10ff9 | ||
|
|
e3dc8ee327 | ||
|
|
645458498a | ||
|
|
6c4119e2c6 | ||
|
|
7e9cae5f39 | ||
|
|
1fe1b7463f | ||
|
|
9e48cae759 | ||
|
|
aeb2727bea | ||
|
|
298c20012a | ||
|
|
7507412f1c | ||
|
|
45b7d0764d | ||
|
|
7508d428b0 | ||
|
|
977c8e7b21 | ||
|
|
4964583868 | ||
|
|
14aa1aabea | ||
|
|
76c427c887 | ||
|
|
ff7d874138 | ||
|
|
e881c8fad8 | ||
|
|
06cc6056e5 | ||
|
|
c15999b7a6 | ||
|
|
0b954c1ab6 | ||
|
|
eb0dd67d16 | ||
|
|
97828f8bd5 | ||
|
|
8be2cfd2a3 | ||
|
|
f41ab0e277 | ||
|
|
46a44b232b | ||
|
|
2cf4c57813 | ||
|
|
41534b215a | ||
|
|
5ea2792df7 | ||
|
|
1479148f84 | ||
|
|
7256d80514 | ||
|
|
8e73d755d3 | ||
|
|
027f60d262 | ||
|
|
4f042cae84 | ||
|
|
0b8924eda7 | ||
|
|
eaaf2f6dcc | ||
|
|
f5a0e14991 | ||
|
|
11e4d536c5 | ||
|
|
0fd2486baf | ||
|
|
8294a476d2 | ||
|
|
9922b654d7 | ||
|
|
b910efd945 | ||
|
|
20854edca2 | ||
|
|
2a23a5d574 | ||
|
|
4cf34375a8 | ||
|
|
404809ab6e | ||
|
|
c5c89795e1 | ||
|
|
99b08e0f0c | ||
|
|
83a99541b2 | ||
|
|
1ed835fb79 | ||
|
|
ace134e2f0 | ||
|
|
ecbd003579 | ||
|
|
93e784a3ed | ||
|
|
a1f6a6bad5 | ||
|
|
a6f92104fa | ||
|
|
0ad7d6d210 | ||
|
|
48ff977d06 | ||
|
|
93eea24420 | ||
|
|
e582babae3 | ||
|
|
46ffde19a6 | ||
|
|
6446eb1302 | ||
|
|
ad0f6c68d5 | ||
|
|
6bcd59fcc6 | ||
|
|
1fad5fc8ed | ||
|
|
5c57e10de9 | ||
|
|
c0f4e80320 | ||
|
|
4857910410 | ||
|
|
c2510495ef | ||
|
|
f8baa1edb7 | ||
|
|
7a8bd7717e | ||
|
|
c98048b97b | ||
|
|
38dad4e865 | ||
|
|
d039359386 | ||
|
|
8c68129d69 | ||
|
|
348ac51011 | ||
|
|
544abbbf56 | ||
|
|
fa9bd8207f | ||
|
|
0a7e67e373 | ||
|
|
9cd1c1c448 | ||
|
|
dfea679f0b | ||
|
|
0689116e4d | ||
|
|
02a09ae2d7 | ||
|
|
f9d328f2db | ||
|
|
8c6dcb9483 | ||
|
|
c8b57a6a5d | ||
|
|
7aa4d3067e | ||
|
|
e47eca53a2 | ||
|
|
8d061a8734 | ||
|
|
9c8ab6441c | ||
|
|
dfede080d2 | ||
|
|
cf32d871de | ||
|
|
fe373b5656 | ||
|
|
a44a4bc4f8 | ||
|
|
cb683beef8 | ||
|
|
1d4ffa875a | ||
|
|
297a7ddd9d | ||
|
|
be698c2239 | ||
|
|
e80581d139 | ||
|
|
4e7eaac7d5 | ||
|
|
92f7f3fca3 | ||
|
|
1e10cdecc8 | ||
|
|
ce6b8f65cc | ||
|
|
f60c2d5834 | ||
|
|
795f26fb51 | ||
|
|
8ae930c5fc | ||
|
|
ebe0f93744 | ||
|
|
8cc0aaf8d2 | ||
|
|
86be3a6865 | ||
|
|
5e5ce73fd0 | ||
|
|
01971807cb | ||
|
|
da8313fa1a | ||
|
|
09fd17e38b | ||
|
|
34f8949e85 | ||
|
|
33d3d1a43f | ||
|
|
1814c35701 | ||
|
|
6df5fe5b2a | ||
|
|
c292c01da3 | ||
|
|
133fbbe038 | ||
|
|
7f0d025312 | ||
|
|
9dccf99c50 | ||
|
|
1d498d13c3 | ||
|
|
e88b0cda18 | ||
|
|
8d2b8b690f | ||
|
|
38b8f26a50 | ||
|
|
64ced7dbad | ||
|
|
b9dadb6a08 | ||
|
|
068ba9afa5 | ||
|
|
c0532fda4e | ||
|
|
ff50baec99 | ||
|
|
a9bb806387 | ||
|
|
8fe0525295 | ||
|
|
e6703ed18a | ||
|
|
ee75272917 | ||
|
|
620ecbafcb | ||
|
|
cf394403a6 | ||
|
|
8f0d7fa3c0 | ||
|
|
cc7266c801 | ||
|
|
e0ac732769 | ||
|
|
c79db24016 | ||
|
|
485918ebe3 | ||
|
|
d9f399b97b | ||
|
|
b8a5c60ff9 | ||
|
|
2d4b93dde7 | ||
|
|
bd893f271a | ||
|
|
2c8e617b2a | ||
|
|
8207e560e9 | ||
|
|
22b6f4e71d | ||
|
|
5c5921fcd2 | ||
|
|
a2781f57e9 | ||
|
|
fd391ef705 | ||
|
|
36df79e561 | ||
|
|
636dc14616 | ||
|
|
fb115fbb7e | ||
|
|
ba009c0a20 | ||
|
|
50726e4cf3 | ||
|
|
f98a123e40 | ||
|
|
dd2ca54874 | ||
|
|
da90ac74b4 | ||
|
|
e6a2da548f | ||
|
|
e5f0c4168f | ||
|
|
05e8b00bf0 | ||
|
|
0ffaa6c741 | ||
|
|
ddadc830ac | ||
|
|
0fa36395e7 | ||
|
|
606cd5fa31 | ||
|
|
e5f3c20f64 | ||
|
|
e530150e43 | ||
|
|
0f9f06048a | ||
|
|
24f7267d55 | ||
|
|
11f26a1090 | ||
|
|
72cef5ed9e | ||
|
|
b977c4cbad | ||
|
|
5d4deb258a | ||
|
|
f46c82d171 | ||
|
|
42c69f9f6c | ||
|
|
9308f93de7 | ||
|
|
ecd5751a67 | ||
|
|
ec262f0238 | ||
|
|
21cd672f64 | ||
|
|
9dfcddf40b | ||
|
|
81e631e640 | ||
|
|
3412f1c0ed | ||
|
|
48556e1a2c | ||
|
|
06a98fe30c | ||
|
|
4cf110b28e | ||
|
|
df34d43a23 | ||
|
|
e9a6269d9f | ||
|
|
005f6cb498 | ||
|
|
32d3f8f75e | ||
|
|
cb4b588598 | ||
|
|
20a0f7a454 | ||
|
|
e720af38a6 | ||
|
|
1c2d284578 | ||
|
|
3e217adc14 | ||
|
|
d94ef81b43 | ||
|
|
ac6c8b275d | ||
|
|
3dda06cbe3 | ||
|
|
0ff8a95da3 | ||
|
|
be7a96a825 | ||
|
|
7265041e55 | ||
|
|
0b64eab148 | ||
|
|
fb285b17e4 | ||
|
|
699290ccb1 | ||
|
|
406104babd | ||
|
|
297c56c72d | ||
|
|
8ec940f624 | ||
|
|
cbbc860366 | ||
|
|
bffbe6b551 | ||
|
|
b6f83d81bb |
@@ -70,13 +70,12 @@ Intake は以下を行う。
|
|||||||
|
|
||||||
利用可能なら、以下の typed Ticket tools を使う。
|
利用可能なら、以下の typed Ticket tools を使う。
|
||||||
|
|
||||||
- `TicketList`: 既存 Ticket の一覧・重複確認。
|
- `QueryTicket`: 既存 Ticket の一覧・重複確認。
|
||||||
- `TicketShow`: 関連 Ticket の詳細確認。
|
- `ShowTicket`: 関連 Ticket の詳細確認。
|
||||||
- `TicketCreate`: 合意済み Ticket の作成。
|
- `TicketCreate`: 合意済み Ticket の作成。
|
||||||
- `TicketComment`: 既存 Ticket refinement / decision / plan の記録。
|
- `TicketComment`: 既存 Ticket refinement / decision / plan の記録。
|
||||||
- `TicketDoctor`: 必要に応じた整合性確認。
|
|
||||||
|
|
||||||
Intake は `TicketReview`, `TicketWorkflowState`, `TicketClose` を通常使わない。review / state transition / close は Orchestrator または reviewer / maintainer workflow の責務である。
|
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 に戻す。
|
Ticket tools が利用できない環境では、勝手に file write で代替しない。ユーザーまたは Orchestrator に「Ticket tools がないため materialize できない」と報告し、必要なら `yoi ticket` を使える人間/親 workflow に戻す。
|
||||||
|
|
||||||
@@ -96,7 +95,7 @@ Ticket tools が利用できない環境では、勝手に file write で代替
|
|||||||
|
|
||||||
### 2. 既存 Ticket を確認する
|
### 2. 既存 Ticket を確認する
|
||||||
|
|
||||||
`TicketList` / `TicketShow` で duplicate / related work を探す。
|
`QueryTicket` / `ShowTicket` で duplicate / related work を探す。
|
||||||
|
|
||||||
確認観点:
|
確認観点:
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ TicketCreate / TicketComment
|
|||||||
|
|
||||||
Orchestrator は以下を行う。
|
Orchestrator は以下を行う。
|
||||||
|
|
||||||
- Ticket を `TicketShow` で読む。
|
- Ticket を `ShowTicket` で読む。
|
||||||
- 必要に応じて関連 Ticket を `TicketList` / `TicketShow` で確認する。
|
- 必要に応じて関連 Ticket を `QueryTicket` / `ShowTicket` で確認する。
|
||||||
- Ticket body / thread / artifacts / resolution / review / implementation report を読む。
|
- 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 を読む代替ではない。
|
- 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 を必要に応じて明示的に確認する。
|
- repository 状態、関連 docs/code、既存 worktree、visible Pods を必要に応じて明示的に確認する。
|
||||||
@@ -49,6 +49,7 @@ Orchestrator は以下を行う。
|
|||||||
- 既存 umbrella/progress-container Ticket が concrete follow-up Ticket / Objective context で置き換え済みなら、superseded/decomposed として退役・close する routing を検討する。
|
- 既存 umbrella/progress-container Ticket が concrete follow-up Ticket / Objective context で置き換え済みなら、superseded/decomposed として退役・close する routing を検討する。
|
||||||
- implementation-ready の場合は `multi-agent-workflow` に渡す `IntentPacket` を作る。
|
- 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` を記録する。
|
- 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` 候補にする。
|
- 明示的な 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` に残す。
|
- 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 を含める。
|
- `ready` または `queued` に concrete missing decision / information がある場合だけ、typed state-change/routing event 付きで `planning` に戻す。その event/comment には missing item、checked context、implementation latitude では足りない理由、次の planning question/action を含める。
|
||||||
@@ -61,7 +62,7 @@ Orchestrator は以下を行う。
|
|||||||
- 人間/上位 Orchestrator の許可または明示的な routing acceptance なしに coder / reviewer Pod や read-only investigation helper Pod を起動しない。
|
- 人間/上位 Orchestrator の許可または明示的な routing acceptance なしに coder / reviewer Pod や read-only investigation helper Pod を起動しない。
|
||||||
- unqueued Ticket を capacity 埋めのために開始しない。parallel start の候補は、個別に `queued` であり、人間が routing を許可済みの Ticket に限る。
|
- unqueued Ticket を capacity 埋めのために開始しない。parallel start の候補は、個別に `queued` であり、人間が routing を許可済みの Ticket に限る。
|
||||||
- 設計境界の未決定を勝手に implementation-ready として固定しない。
|
- 設計境界の未決定を勝手に implementation-ready として固定しない。
|
||||||
- merge / close / cleanup 権限を持たない場面で勝手に完了処理しない。
|
- Ticketが`ready -> queued`されておらず完了権限を委任されていない場合、またはTicketがseparate approval gateを明記する場合に、勝手にmerge / close / cleanupしない。queued delegationとguarded completion evidenceが揃っている場合は、追加のhuman gateを作らず完了まで進める。
|
||||||
- Ticket tools があるからといって arbitrary filesystem write を行わない。
|
- Ticket tools があるからといって arbitrary filesystem write を行わない。
|
||||||
- broad multi-Ticket effort のために新しい umbrella/progress-container Ticket を作らない。
|
- broad multi-Ticket effort のために新しい umbrella/progress-container Ticket を作らない。
|
||||||
- parent/child、sub-ticket、umbrella、part-of、contains などの hierarchy/container relation を split/refinement の代替として扱わない。
|
- parent/child、sub-ticket、umbrella、part-of、contains などの hierarchy/container relation を split/refinement の代替として扱わない。
|
||||||
@@ -73,21 +74,21 @@ Orchestrator は以下を行う。
|
|||||||
|
|
||||||
利用可能なら、以下を使う。
|
利用可能なら、以下を使う。
|
||||||
|
|
||||||
- `TicketList`: routing 候補や関連 Ticket の確認。
|
- `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 判断にはしない。
|
||||||
- `TicketShow`: 対象 Ticket の body / thread / artifacts / resolution / typed relation metadata と derived inverse/blocker view を確認。
|
- `ShowTicket`: 対象 Ticket の body / thread / artifacts / resolution / typed relation metadata、linked Objective、assignment、implementation/review evidence を確認する。
|
||||||
- `TicketComment`: routing decision / intent packet / blocked reason / next question の記録。
|
- `TicketComment`: routing decision / intent packet / blocked reason / next question の記録。
|
||||||
- `TicketWorkflowState`: `queued -> inprogress` acceptance、`inprogress -> done`、または concrete missing decision/information reason を伴う `ready|queued -> planning` に使う。
|
- `TicketWorkflowState`: `queued -> inprogress` acceptance、`inprogress -> done`、または concrete missing decision/information reason を伴う `ready|queued -> planning` に使う。
|
||||||
- `TicketRelationQuery`: project-level の forward relation (`depends_on` / `blocks` / `related` / `supersedes` / `duplicate_of`) を読む。`depends_on` と incoming unresolved `blocks` は queue/acceptance blocker であり、`related` は blocker ではない。`supersedes` / `duplicate_of` は visible diagnostic として扱い、自動的な lifecycle 変更や scheduler 判断にはしない。
|
- `TicketDependencyCheck`: queue/acceptance 直前の typed dependency readiness guard に使う。
|
||||||
|
- `TicketRelationRecord` / `TicketRelationRemove`: ユーザー合意済みの durable project relation を明示的に更新する場合だけ使う。
|
||||||
- `TicketOrchestrationPlanQuery`: 対象 Ticket や関連 Ticket の ordering / blocker / conflict / waiting-capacity / accepted-plan 記録を読む。queued acceptance 前に必ず確認する。
|
- `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 変更ではない。
|
- `TicketOrchestrationPlanRecord`: Orchestrator が routing 中に project-relevant な ordering / dependency / conflict / capacity/waiting / accepted-plan decision を残す。これは queue reorder、自動起動、state 変更ではない。
|
||||||
- `TicketClose`: 完了権限と resolution が揃っている場合だけ使う。
|
- `TicketClose`: 完了権限と resolution が揃っている場合だけ使う。
|
||||||
- `TicketDoctor`: routing 前後の整合性確認。
|
|
||||||
|
|
||||||
`TicketCreate` は通常 Intake の責務だが、routing 中に follow-up Ticket が必要だと判断した場合は、ユーザー/上位 Orchestrator の合意後にだけ使う。
|
`TicketCreate` は通常 Intake の責務だが、routing 中に follow-up Ticket が必要だと判断した場合は、ユーザー/上位 Orchestrator の合意後にだけ使う。
|
||||||
|
|
||||||
## Queued acceptance contract
|
## Queued acceptance contract
|
||||||
|
|
||||||
- `queued -> inprogress` acceptance の直前に `TicketShow` / `TicketRelationQuery` の relation blockers を再確認する。unresolved `depends_on` や incoming unresolved `blocks` が残る場合は implementation side effect を始めず、理由を thread に残して `planning` へ戻すか blocked diagnostic として停止する。
|
- `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 として扱わない。
|
- 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 を読んで、次のどちらかを行う。
|
`state = queued` は、Ticket が routing 対象として人間により Orchestrator へ渡された状態である。Orchestrator は queued notification を受けたら、Ticket、workspace state、対象 Ticket の `TicketOrchestrationPlanQuery` 記録、risk domain に応じた bounded project context を読んで、次のどちらかを行う。
|
||||||
@@ -100,7 +101,7 @@ Orchestrator は以下を行う。
|
|||||||
|
|
||||||
Parallel acceptance pass:
|
Parallel acceptance pass:
|
||||||
|
|
||||||
- 明示的な queue review 中に複数の queued Ticket が見える場合、Orchestrator は最初の1件の完了待ちを default にしない。各 Ticket について Ticket body/thread/artifacts、TicketRelationQuery、TicketOrchestrationPlanQuery、workspace/worktree dirty state、visible Pods、既存 branches、conflict/dependency notes を確認する。
|
- 明示的な 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 だけである。
|
- 追加で開始してよいのは、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 を記録する。
|
- 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 を個別に記録する。
|
- この pass は scheduler、background runner、resource graph solver、automatic queue drain loop ではない。unqueued Ticket を開始せず、各 Ticket の `queued -> inprogress` acceptance を個別に記録する。
|
||||||
@@ -269,8 +270,8 @@ Action:
|
|||||||
### 1. 状態確認
|
### 1. 状態確認
|
||||||
|
|
||||||
- `git state --short --branch`
|
- `git state --short --branch`
|
||||||
- `TicketShow <target>`
|
- `ShowTicket <target>`
|
||||||
- 関連 Ticket の `TicketList` / `TicketShow`
|
- 関連 Ticket の `QueryTicket` / `ShowTicket`
|
||||||
- 必要に応じて docs/code/workflow/history
|
- 必要に応じて docs/code/workflow/history
|
||||||
- 必要に応じて visible Pods / worktrees / branches
|
- 必要に応じて visible Pods / worktrees / branches
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +1,49 @@
|
|||||||
すでにシステムのドッグフーディングに成功しているが、一旦安定した旧バージョンで、ブラウザ/TUI Client/backend/runtimeの分離とチームスペースとしてのworkspaceを作るObjectiveを進めている。
|
すでにシステムのドッグフーディングに成功しており、ブラウザ/TUI Client/backend/runtimeの分離とチームスペースとしてのworkspaceの実装を進めている。
|
||||||
|
|
||||||
## このシステムに置ける設計要旨
|
## このシステムに置ける設計要旨
|
||||||
|
|
||||||
- プロンプトはすべて resources/promptsに集約している。管理効率の向上と同時に、ユーザーがオーバーライドする形式でもある。
|
- プロンプトはすべて`resources/prompts`に集約している。管理効率の向上のためであると同時に、ユーザーがオーバーライドする形式でもある。
|
||||||
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
|
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
|
||||||
|
|
||||||
### LLM コンテキストの加工原則
|
### LLM コンテキストの加工原則
|
||||||
|
|
||||||
LLM に投げる context への割り込みは、大きく2種類に分かれる。**前者は許されるが、後者は禁止**。
|
LLM に投げる context はappend-onlyが基本であり、またその永続化形式からAPIコールの形式を純粋に再現可能である必要が有る。
|
||||||
|
|
||||||
Workerの状態から純粋に再現可能で、且つ揮発性の無い操作であることが望ましい。(pruning、tool result の content 切り詰め、prompt cache anchor の付与等)。
|
一時的にメッセージを差し込む等の、揮発性の有るコンテキストの改変や、過去のメッセージを改ざんすることは基本的に禁止されている。
|
||||||
原則として、コンテキストは積み重ねるものであり、一時的にメッセージを差し込むことや、過去のメッセージを改ざんすることはKVキャッシュのヒット率を下げる。
|
これを行うと、 LLM はそのコンテキストに基づいて生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
|
||||||
|
|
||||||
**禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
|
過去のコンテキストの圧縮は、キャッシュ破壊とトークン消費のトレードオフであり、必要であれば行っている。
|
||||||
|
しかし、キャッシュを破壊するタイミングと頻度は正確にコントロールされる必要があり、実際のセッションデータの解析に基づいて慎重に設計されるべきである。
|
||||||
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / WorkerEvent / `<system-reminder>` 系はこの原則で扱う。
|
|
||||||
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 実際のセッションを読んでデバッグする
|
|
||||||
|
|
||||||
`~/.yoi/sessions`にすべてのセッションがある。jsonlなので、いい感じにBashで読むこと。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Git操作
|
|
||||||
|
|
||||||
明示的に指示されない限り、読み取り以外の操作は控えること。
|
|
||||||
基本はworktree上の一時的なブランチでコミットを重ね、メインブランチに取り込む運用をしている。
|
|
||||||
Orchestrator の cwd が orchestration 用ブランチ/worktree の場合、通常作業では親ブランチの dirty state を気にしない。
|
|
||||||
コミットメッセージは適当に`<prefix>: *簡潔な1行*`で書いている。
|
|
||||||
|
|
||||||
外部の参考プロジェクトは必要に応じてローカルの外部 checkout からReadすること。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 検証
|
## 検証
|
||||||
|
|
||||||
検証は変更内容に応じて `cargo test` / `cargo check` / `git diff --check` など、妥当な範囲で行う。重い検証は必要性が高い場合に選ぶ。
|
開発中は、変更した契約を証明する最小の 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と狭い検証では証明できない場合や、明示的に要求された場合に選ぶ。実行した検証が
|
||||||
|
何を証明するのかを意識し、広い検証を形式的に回すだけにしない。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -46,7 +51,6 @@ Orchestrator の cwd が orchestration 用ブランチ/worktree の場合、通
|
|||||||
|
|
||||||
Yoi Worker で作業する場合、Ticket の authority・ライフサイクル・操作方法は Yoi
|
Yoi Worker で作業する場合、Ticket の authority・ライフサイクル・操作方法は Yoi
|
||||||
system instructions と、その Worker に提供された typed Ticket tools に従うこと。
|
system instructions と、その Worker に提供された typed Ticket tools に従うこと。
|
||||||
backend や CLI の具体的な手順はこのリポジトリの `AGENTS.md` では重複して定義しない。
|
|
||||||
|
|
||||||
Codex など typed Ticket tools が提供されていないクライアントでは、`yoi ticket`
|
Codex など typed Ticket tools が提供されていないクライアントでは、`yoi ticket`
|
||||||
CLI や保存先の直接操作で Ticket tools を代替しないこと。Ticket の作成・更新は
|
CLI や保存先の直接操作で Ticket tools を代替しないこと。Ticket の作成・更新は
|
||||||
@@ -55,3 +59,8 @@ Yoi Worker に委ねる。
|
|||||||
---
|
---
|
||||||
|
|
||||||
YoiでYoiを開発している際、AI自身のフィードバックを元に改善を回すために `docs/report/`ディレクトリに感じた障壁や改善案等を書き残す形にした。 明確に力不足な点/ツールの問題があった場合や、ユーザーからの指示があった際に作ること。
|
YoiでYoiを開発している際、AI自身のフィードバックを元に改善を回すために `docs/report/`ディレクトリに感じた障壁や改善案等を書き残す形にした。 明確に力不足な点/ツールの問題があった場合や、ユーザーからの指示があった際に作ること。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
絶対に自身が動作しているプロセスを止めないこと。
|
||||||
|
マージ後のドッグフーディング環境の更新は必ずユーザーの操作で行う。
|
||||||
|
|||||||
Generated
+825
-695
File diff suppressed because it is too large
Load Diff
+41
-8
@@ -2,9 +2,10 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/client",
|
"crates/client",
|
||||||
"crates/llm-engine",
|
"crates/agen",
|
||||||
"crates/llm-engine-macros",
|
"crates/agen-macros",
|
||||||
"crates/session-store",
|
"crates/session-store",
|
||||||
|
"crates/standalone",
|
||||||
"crates/secrets",
|
"crates/secrets",
|
||||||
"crates/manifest",
|
"crates/manifest",
|
||||||
"crates/mcp",
|
"crates/mcp",
|
||||||
@@ -17,18 +18,26 @@ members = [
|
|||||||
"crates/session-analytics",
|
"crates/session-analytics",
|
||||||
"crates/lint-common",
|
"crates/lint-common",
|
||||||
"crates/tools",
|
"crates/tools",
|
||||||
|
"crates/fs-operation",
|
||||||
|
"crates/flow",
|
||||||
|
"crates/config-source",
|
||||||
|
"crates/config-source-wasm",
|
||||||
|
"crates/workdir",
|
||||||
"crates/tui",
|
"crates/tui",
|
||||||
"crates/memory",
|
"crates/memory",
|
||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
|
"crates/merge-request",
|
||||||
"crates/project-record",
|
"crates/project-record",
|
||||||
|
"crates/workspace-api",
|
||||||
"crates/workspace-server",
|
"crates/workspace-server",
|
||||||
"tests/e2e",
|
"tests/e2e",
|
||||||
]
|
]
|
||||||
default-members = [
|
default-members = [
|
||||||
"crates/client",
|
"crates/client",
|
||||||
"crates/llm-engine",
|
"crates/agen",
|
||||||
"crates/llm-engine-macros",
|
"crates/agen-macros",
|
||||||
"crates/session-store",
|
"crates/session-store",
|
||||||
|
"crates/standalone",
|
||||||
"crates/secrets",
|
"crates/secrets",
|
||||||
"crates/manifest",
|
"crates/manifest",
|
||||||
"crates/mcp",
|
"crates/mcp",
|
||||||
@@ -41,10 +50,17 @@ default-members = [
|
|||||||
"crates/session-analytics",
|
"crates/session-analytics",
|
||||||
"crates/lint-common",
|
"crates/lint-common",
|
||||||
"crates/tools",
|
"crates/tools",
|
||||||
|
"crates/fs-operation",
|
||||||
|
"crates/flow",
|
||||||
|
"crates/config-source",
|
||||||
|
"crates/config-source-wasm",
|
||||||
|
"crates/workdir",
|
||||||
"crates/tui",
|
"crates/tui",
|
||||||
"crates/memory",
|
"crates/memory",
|
||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
|
"crates/merge-request",
|
||||||
"crates/project-record",
|
"crates/project-record",
|
||||||
|
"crates/workspace-api",
|
||||||
"crates/workspace-server",
|
"crates/workspace-server",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -52,27 +68,39 @@ default-members = [
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
|
[profile.dev.package."*"]
|
||||||
|
debug = false
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# Internal crates
|
# Internal crates
|
||||||
client = { path = "crates/client" }
|
client = { path = "crates/client" }
|
||||||
llm-engine = { path = "crates/llm-engine", version = "0.2" }
|
agen = { path = "crates/agen", version = "0.2" }
|
||||||
llm-engine-macros = { path = "crates/llm-engine-macros", version = "0.2" }
|
agen-macros = { path = "crates/agen-macros", version = "0.2" }
|
||||||
manifest = { path = "crates/manifest" }
|
manifest = { path = "crates/manifest" }
|
||||||
mcp = { path = "crates/mcp" }
|
mcp = { path = "crates/mcp" }
|
||||||
lint-common = { path = "crates/lint-common" }
|
lint-common = { path = "crates/lint-common" }
|
||||||
memory = { path = "crates/memory" }
|
memory = { path = "crates/memory" }
|
||||||
|
merge-request = { path = "crates/merge-request" }
|
||||||
ticket = { path = "crates/ticket" }
|
ticket = { path = "crates/ticket" }
|
||||||
project-record = { path = "crates/project-record" }
|
project-record = { path = "crates/project-record" }
|
||||||
worker = { path = "crates/worker" }
|
worker = { path = "crates/worker" }
|
||||||
worker-runtime = { path = "crates/worker-runtime" }
|
worker-runtime = { path = "crates/worker-runtime" }
|
||||||
|
workspace-api = { path = "crates/workspace-api" }
|
||||||
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
|
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
|
||||||
yoi = { path = "crates/yoi" }
|
yoi = { path = "crates/yoi" }
|
||||||
protocol = { path = "crates/protocol" }
|
protocol = { path = "crates/protocol" }
|
||||||
session-metrics = { path = "crates/session-metrics" }
|
session-metrics = { path = "crates/session-metrics" }
|
||||||
session-analytics = { path = "crates/session-analytics" }
|
session-analytics = { path = "crates/session-analytics" }
|
||||||
session-store = { path = "crates/session-store" }
|
session-store = { path = "crates/session-store" }
|
||||||
|
standalone = { path = "crates/standalone" }
|
||||||
secrets = { path = "crates/secrets" }
|
secrets = { path = "crates/secrets" }
|
||||||
tools = { path = "crates/tools" }
|
tools = { path = "crates/tools" }
|
||||||
|
config-source = { path = "crates/config-source" }
|
||||||
|
fs-operation = { path = "crates/fs-operation" }
|
||||||
|
workdir = { path = "crates/workdir" }
|
||||||
tui = { path = "crates/tui" }
|
tui = { path = "crates/tui" }
|
||||||
yoi-workspace-server = { path = "crates/workspace-server" }
|
yoi-workspace-server = { path = "crates/workspace-server" }
|
||||||
|
|
||||||
@@ -82,7 +110,9 @@ yoi-workspace-server = { path = "crates/workspace-server" }
|
|||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
decodal = "0.1.1"
|
decodal = "0.4.0"
|
||||||
|
decodal-language-service = "0.4.0"
|
||||||
|
decodal-language-tools = "0.4.0"
|
||||||
fs4 = "0.13"
|
fs4 = "0.13"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
@@ -91,9 +121,10 @@ serde = "1.0"
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde_yaml = "0.9.34"
|
serde_yaml = "0.9.34"
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
rusqlite = { version = "0.37", features = ["backup", "bundled"] }
|
||||||
ring = "0.17.14"
|
ring = "0.17.14"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
|
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption"] }
|
||||||
tempfile = "3.27"
|
tempfile = "3.27"
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
tokio = "1.52"
|
tokio = "1.52"
|
||||||
@@ -101,6 +132,8 @@ tokio-tungstenite = "0.29"
|
|||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
toml = "1.1"
|
toml = "1.1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
uuid = "1.23"
|
uuid = "1.23"
|
||||||
|
zeroize = "1"
|
||||||
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
||||||
|
|||||||
@@ -80,6 +80,6 @@ cargo check --workspace --all-targets
|
|||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
```
|
```
|
||||||
|
|
||||||
E2E testing with real spawned processes is not yet designed. Keep changes scoped, preserve durable authority boundaries, and prefer clear type-safe structure over short-term compatibility layers.
|
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).
|
License: MIT. See [`LICENSE`](LICENSE).
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ services:
|
|||||||
- "8787"
|
- "8787"
|
||||||
volumes:
|
volumes:
|
||||||
- server-data:/server-data
|
- server-data:/server-data
|
||||||
- ./docker/workspace:/workspace:ro
|
- /etc/yoi/server.toml:/server-config/server.toml:ro
|
||||||
|
|
||||||
webui:
|
webui:
|
||||||
image: yoi-webui:latest
|
image: yoi-webui:latest
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "agen-macros"
|
||||||
|
description = "Procedural macros for declaring agen tools"
|
||||||
|
version = "0.2.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version = "1.85"
|
||||||
|
license.workspace = true
|
||||||
|
readme = "README.md"
|
||||||
|
repository = "https://gitea.hareworks.net/Hare/yoi"
|
||||||
|
homepage = "https://gitea.hareworks.net/Hare/yoi"
|
||||||
|
documentation = "https://docs.rs/agen-macros"
|
||||||
|
keywords = ["llm", "agent", "tools", "macros"]
|
||||||
|
categories = ["development-tools::procedural-macro-helpers"]
|
||||||
|
include = ["src/**", "README.md", "LICENSE"]
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
proc-macro = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
proc-macro2 = "1"
|
||||||
|
quote = "1"
|
||||||
|
syn = { version = "2", features = ["full"] }
|
||||||
|
|
||||||
|
[package.metadata.docs.rs]
|
||||||
|
all-features = true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Copyright 2026 Hare
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# agen-macros
|
||||||
|
|
||||||
|
Procedural macros used by [`agen`](https://crates.io/crates/agen) to declare LLM tools from Rust methods.
|
||||||
|
|
||||||
|
Applications should normally depend only on `agen` and import its re-exports:
|
||||||
|
|
||||||
|
```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).
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
//! 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.
|
||||||
|
|
||||||
|
use proc_macro::TokenStream;
|
||||||
|
use quote::{format_ident, quote};
|
||||||
|
use syn::{
|
||||||
|
Attribute, FnArg, ImplItem, ItemImpl, Lit, Meta, Pat, ReturnType, Type, parse_macro_input,
|
||||||
|
spanned::Spanned,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Generates tools for methods marked with `#[tool]` in an `impl` block.
|
||||||
|
///
|
||||||
|
/// Method doc comments become the tool description. An argument can use
|
||||||
|
/// `#[description = "..."]` to supply its JSON Schema description.
|
||||||
|
///
|
||||||
|
/// ```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()`.
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn tool_registry(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
let attr = proc_macro2::TokenStream::from(attr);
|
||||||
|
let impl_block = parse_macro_input!(item as ItemImpl);
|
||||||
|
|
||||||
|
expand_tool_registry(attr, impl_block)
|
||||||
|
.unwrap_or_else(syn::Error::into_compile_error)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_tool_registry(
|
||||||
|
attr: proc_macro2::TokenStream,
|
||||||
|
mut impl_block: ItemImpl,
|
||||||
|
) -> syn::Result<proc_macro2::TokenStream> {
|
||||||
|
if !attr.is_empty() {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
attr.span(),
|
||||||
|
"tool_registry does not accept arguments",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let self_ty = impl_block.self_ty.as_ref().clone();
|
||||||
|
let mut generated_items = Vec::new();
|
||||||
|
|
||||||
|
for item in &mut impl_block.items {
|
||||||
|
let ImplItem::Fn(method) = item else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let tool_attrs: Vec<_> = method
|
||||||
|
.attrs
|
||||||
|
.iter()
|
||||||
|
.filter(|attr| attr.path().is_ident("tool"))
|
||||||
|
.collect();
|
||||||
|
if tool_attrs.len() > 1 {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
tool_attrs[1],
|
||||||
|
"duplicate #[tool] attribute",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Some(tool_attr) = tool_attrs.first() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !matches!(tool_attr.meta, Meta::Path(_)) {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
tool_attr,
|
||||||
|
"#[tool] does not accept arguments",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
method.attrs.retain(|attr| !attr.path().is_ident("tool"));
|
||||||
|
generated_items.push(generate_tool_impl(&self_ty, method)?);
|
||||||
|
|
||||||
|
for input in &mut method.sig.inputs {
|
||||||
|
if let FnArg::Typed(pat_type) = input {
|
||||||
|
pat_type
|
||||||
|
.attrs
|
||||||
|
.retain(|attr| !attr.path().is_ident("description"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(quote! {
|
||||||
|
#impl_block
|
||||||
|
|
||||||
|
#(#generated_items)*
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_doc_comment(attrs: &[Attribute]) -> String {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
for attr in attrs {
|
||||||
|
if attr.path().is_ident("doc")
|
||||||
|
&& let Meta::NameValue(meta) = &attr.meta
|
||||||
|
&& let syn::Expr::Lit(expr_lit) = &meta.value
|
||||||
|
&& let Lit::Str(lit_str) = &expr_lit.lit
|
||||||
|
{
|
||||||
|
let line = lit_str.value();
|
||||||
|
let trimmed = line.strip_prefix(' ').unwrap_or(&line);
|
||||||
|
lines.push(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_description_attr(attrs: &[Attribute]) -> syn::Result<Option<String>> {
|
||||||
|
let mut description = None;
|
||||||
|
|
||||||
|
for attr in attrs
|
||||||
|
.iter()
|
||||||
|
.filter(|attr| attr.path().is_ident("description"))
|
||||||
|
{
|
||||||
|
let value = match &attr.meta {
|
||||||
|
Meta::NameValue(meta) => match &meta.value {
|
||||||
|
syn::Expr::Lit(expr_lit) => match &expr_lit.lit {
|
||||||
|
Lit::Str(value) => value.value(),
|
||||||
|
_ => {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
attr,
|
||||||
|
"description must be a string literal",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
attr,
|
||||||
|
"description must be a string literal",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
attr,
|
||||||
|
"expected #[description = \"...\"]",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if description.replace(value).is_some() {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
attr,
|
||||||
|
"duplicate #[description] attribute",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(description)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn argument_ident(pat: &Pat) -> syn::Result<&syn::Ident> {
|
||||||
|
match pat {
|
||||||
|
Pat::Ident(pat_ident) => Ok(&pat_ident.ident),
|
||||||
|
_ => Err(syn::Error::new_spanned(
|
||||||
|
pat,
|
||||||
|
"tool arguments must use simple identifier patterns",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_tool_execution_context_type(ty: &Type) -> bool {
|
||||||
|
let Type::Path(path) = ty else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
path.path
|
||||||
|
.segments
|
||||||
|
.last()
|
||||||
|
.is_some_and(|segment| segment.ident == "ToolExecutionContext")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_tool_impl(
|
||||||
|
self_ty: &Type,
|
||||||
|
method: &syn::ImplItemFn,
|
||||||
|
) -> syn::Result<proc_macro2::TokenStream> {
|
||||||
|
let sig = &method.sig;
|
||||||
|
let method_name = &sig.ident;
|
||||||
|
let tool_name = method_name.to_string();
|
||||||
|
|
||||||
|
let pascal_name = to_pascal_case(&method_name.to_string());
|
||||||
|
let tool_struct_name = format_ident!("Tool{}", pascal_name);
|
||||||
|
let args_struct_name = format_ident!("{}Args", pascal_name);
|
||||||
|
let definition_name = format_ident!("{}_definition", method_name);
|
||||||
|
|
||||||
|
let description = extract_doc_comment(&method.attrs);
|
||||||
|
let description = if description.is_empty() {
|
||||||
|
format!("Tool: {}", tool_name)
|
||||||
|
} else {
|
||||||
|
description
|
||||||
|
};
|
||||||
|
|
||||||
|
let method_args: Vec<_> = sig
|
||||||
|
.inputs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|arg| match arg {
|
||||||
|
FnArg::Typed(pat_type) => Some(pat_type),
|
||||||
|
FnArg::Receiver(_) => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let json_args: Vec<_> = method_args
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|pat_type| !is_tool_execution_context_type(pat_type.ty.as_ref()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let arg_fields: Vec<_> = json_args
|
||||||
|
.iter()
|
||||||
|
.map(|pat_type| {
|
||||||
|
let field_name = argument_ident(pat_type.pat.as_ref())?;
|
||||||
|
let ty = &pat_type.ty;
|
||||||
|
let description = extract_description_attr(&pat_type.attrs)?;
|
||||||
|
|
||||||
|
Ok(if let Some(description) = description {
|
||||||
|
quote! {
|
||||||
|
#[schemars(description = #description)]
|
||||||
|
pub #field_name: #ty
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
quote! {
|
||||||
|
pub #field_name: #ty
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<syn::Result<_>>()?;
|
||||||
|
|
||||||
|
let call_args: Vec<_> = method_args
|
||||||
|
.iter()
|
||||||
|
.map(|pat_type| {
|
||||||
|
if is_tool_execution_context_type(pat_type.ty.as_ref()) {
|
||||||
|
Ok(quote! { ctx.clone() })
|
||||||
|
} else {
|
||||||
|
let ident = argument_ident(pat_type.pat.as_ref())?;
|
||||||
|
Ok(quote! { args.#ident })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<syn::Result<_>>()?;
|
||||||
|
let method_call = if call_args.is_empty() {
|
||||||
|
quote! { self.ctx.#method_name() }
|
||||||
|
} else {
|
||||||
|
quote! { self.ctx.#method_name(#(#call_args),*) }
|
||||||
|
};
|
||||||
|
|
||||||
|
let awaiter = if sig.asyncness.is_some() {
|
||||||
|
quote! { .await }
|
||||||
|
} else {
|
||||||
|
quote! {}
|
||||||
|
};
|
||||||
|
|
||||||
|
let result_handling = if is_result_type(&sig.output) {
|
||||||
|
quote! {
|
||||||
|
match result {
|
||||||
|
Ok(val) => Ok(format!("{:?}", val).into()),
|
||||||
|
Err(error) => Err(::agen::tool::ToolError::ExecutionFailed(format!("{}", error))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
quote! {
|
||||||
|
Ok(format!("{:?}", result).into())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let args_struct_def = quote! {
|
||||||
|
#[derive(
|
||||||
|
::agen::__private::serde::Deserialize,
|
||||||
|
::agen::__private::schemars::JsonSchema,
|
||||||
|
)]
|
||||||
|
#[serde(crate = "::agen::__private::serde")]
|
||||||
|
#[schemars(crate = "::agen::__private::schemars")]
|
||||||
|
struct #args_struct_name {
|
||||||
|
#(#arg_fields),*
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let execute_body = if json_args.is_empty() {
|
||||||
|
quote! {
|
||||||
|
let _: #args_struct_name = ::agen::__private::serde_json::from_str(input_json)
|
||||||
|
.unwrap_or(#args_struct_name {});
|
||||||
|
|
||||||
|
let result = #method_call #awaiter;
|
||||||
|
#result_handling
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
quote! {
|
||||||
|
let args: #args_struct_name = ::agen::__private::serde_json::from_str(input_json)
|
||||||
|
.map_err(|error| ::agen::tool::ToolError::InvalidArgument(error.to_string()))?;
|
||||||
|
|
||||||
|
let result = #method_call #awaiter;
|
||||||
|
#result_handling
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(quote! {
|
||||||
|
#args_struct_def
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct #tool_struct_name {
|
||||||
|
ctx: #self_ty,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[::agen::__private::async_trait::async_trait]
|
||||||
|
impl ::agen::tool::Tool for #tool_struct_name {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
input_json: &str,
|
||||||
|
ctx: ::agen::tool::ToolExecutionContext,
|
||||||
|
) -> Result<::agen::tool::ToolOutput, ::agen::tool::ToolError> {
|
||||||
|
let _ = &ctx;
|
||||||
|
#execute_body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl #self_ty {
|
||||||
|
/// Returns a tool definition for registration with an `agen::Engine`.
|
||||||
|
pub fn #definition_name(&self) -> ::agen::tool::ToolDefinition {
|
||||||
|
let ctx = self.clone();
|
||||||
|
::std::sync::Arc::new(move || {
|
||||||
|
let schema = ::agen::__private::schemars::schema_for!(#args_struct_name);
|
||||||
|
let meta = ::agen::tool::ToolMeta::new(#tool_name)
|
||||||
|
.description(#description)
|
||||||
|
.input_schema(
|
||||||
|
::agen::__private::serde_json::to_value(schema)
|
||||||
|
.unwrap_or_else(|_| ::agen::__private::serde_json::json!({})),
|
||||||
|
);
|
||||||
|
let tool: ::std::sync::Arc<dyn ::agen::tool::Tool> =
|
||||||
|
::std::sync::Arc::new(#tool_struct_name { ctx: ctx.clone() });
|
||||||
|
(meta, tool)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_result_type(return_type: &ReturnType) -> bool {
|
||||||
|
match return_type {
|
||||||
|
ReturnType::Default => false,
|
||||||
|
ReturnType::Type(_, ty) => {
|
||||||
|
if let Type::Path(type_path) = ty.as_ref()
|
||||||
|
&& let Some(segment) = type_path.path.segments.last()
|
||||||
|
{
|
||||||
|
return segment.ident == "Result";
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_pascal_case(s: &str) -> String {
|
||||||
|
s.split('_')
|
||||||
|
.map(|part| {
|
||||||
|
let mut chars = part.chars();
|
||||||
|
match chars.next() {
|
||||||
|
None => String::new(),
|
||||||
|
Some(first) => first.to_uppercase().chain(chars).collect(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker attribute interpreted by [`tool_registry`].
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn tool(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
marker_attribute("tool", attr, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Argument description marker interpreted by [`tool_registry`].
|
||||||
|
///
|
||||||
|
/// Use it as `#[description = "The argument description"]` on a tool method argument.
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn description(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
marker_attribute("description", attr, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn marker_attribute(name: &str, attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
if attr.is_empty() {
|
||||||
|
item
|
||||||
|
} else {
|
||||||
|
syn::Error::new(
|
||||||
|
proc_macro2::Span::call_site(),
|
||||||
|
format!("{name} is a marker interpreted by #[tool_registry]"),
|
||||||
|
)
|
||||||
|
.into_compile_error()
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use quote::quote;
|
||||||
|
use syn::parse_quote;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_tool_registry_arguments() {
|
||||||
|
let implementation: ItemImpl = parse_quote!(impl Registry {});
|
||||||
|
let error = expand_tool_registry(quote!(unexpected), implementation).unwrap_err();
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("does not accept arguments"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_duplicate_tool_markers() {
|
||||||
|
let implementation: ItemImpl = parse_quote! {
|
||||||
|
impl Registry {
|
||||||
|
#[tool]
|
||||||
|
#[tool]
|
||||||
|
fn inspect(&self) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let error = expand_tool_registry(quote!(), implementation).unwrap_err();
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("duplicate #[tool]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_description_attributes() {
|
||||||
|
let implementation: ItemImpl = parse_quote! {
|
||||||
|
impl Registry {
|
||||||
|
#[tool]
|
||||||
|
fn inspect(&self, #[description] input: String) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let error = expand_tool_registry(quote!(), implementation).unwrap_err();
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("expected #[description"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_duplicate_description_attributes() {
|
||||||
|
let implementation: ItemImpl = parse_quote! {
|
||||||
|
impl Registry {
|
||||||
|
#[tool]
|
||||||
|
fn inspect(
|
||||||
|
&self,
|
||||||
|
#[description = "first"]
|
||||||
|
#[description = "second"]
|
||||||
|
input: String,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let error = expand_tool_registry(quote!(), implementation).unwrap_err();
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("duplicate #[description]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_code_uses_only_agen_runtime_paths() {
|
||||||
|
let implementation: ItemImpl = parse_quote! {
|
||||||
|
impl Registry {
|
||||||
|
#[tool]
|
||||||
|
fn inspect(&self, input: String) -> Result<String, Error> {
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let expanded = expand_tool_registry(quote!(), implementation)
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
assert!(expanded.contains(":: agen :: tool :: Tool"));
|
||||||
|
assert!(expanded.contains(":: agen :: __private :: serde_json"));
|
||||||
|
assert!(expanded.contains(":: agen :: __private :: serde"));
|
||||||
|
assert!(expanded.contains(":: agen :: __private :: schemars"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,27 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "llm-engine"
|
name = "agen"
|
||||||
description = "A library for building autonomous LLM-powered systems"
|
description = "Provider-neutral orchestration for tool-using LLM applications"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
rust-version = "1.86"
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
readme = "README.md"
|
||||||
|
repository = "https://gitea.hareworks.net/Hare/yoi"
|
||||||
|
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"]
|
||||||
|
autoexamples = false
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
codex = ["dep:base64", "dep:chrono"]
|
codex = ["dep:chrono"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
schemars = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
@@ -21,15 +31,25 @@ tokio-util = "0.7"
|
|||||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "native-tls", "http2"] }
|
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "native-tls", "http2"] }
|
||||||
eventsource-stream = "0.2"
|
eventsource-stream = "0.2"
|
||||||
zstd = "0.13"
|
zstd = "0.13"
|
||||||
base64 = { version = "0.22.1", optional = true }
|
base64 = "0.22.1"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true }
|
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true }
|
||||||
llm-engine-macros = { workspace = true }
|
agen-macros = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
schemars = { workspace = true }
|
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
dotenv = "0.15"
|
dotenv = "0.15"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
trybuild = "1.0.116"
|
trybuild = "1.0.116"
|
||||||
wiremock = "0.6.5"
|
wiremock = "0.6.5"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "engine_cancel_demo"
|
||||||
|
path = "examples/engine_cancel_demo.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "engine_cli"
|
||||||
|
path = "examples/engine_cli.rs"
|
||||||
|
|
||||||
|
[package.metadata.docs.rs]
|
||||||
|
all-features = true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Copyright 2026 Hare
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# 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` is pre-1.0. Public APIs may change between minor releases.
|
||||||
|
|
||||||
|
## 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).
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 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.
|
||||||
+12
-12
@@ -2,9 +2,9 @@
|
|||||||
//!
|
//!
|
||||||
//! Example of cancelling from another thread during streaming
|
//! Example of cancelling from another thread during streaming
|
||||||
|
|
||||||
use llm_engine::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
|
use agen::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
|
||||||
use llm_engine::llm_client::transport::{HttpTransport, ResolvedAuth};
|
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||||
use llm_engine::{Engine, EngineResult};
|
use agen::{Engine, EngineRunExit, RunInterruptionReason};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -29,6 +29,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let base_url = scheme.default_base_url().to_string();
|
let base_url = scheme.default_base_url().to_string();
|
||||||
let client = HttpTransport::new(scheme, model, base_url, ResolvedAuth::ApiKey(api_key), cap);
|
let client = HttpTransport::new(scheme, model, base_url, ResolvedAuth::ApiKey(api_key), cap);
|
||||||
let engine = Engine::new(client);
|
let engine = Engine::new(client);
|
||||||
|
let mut history = agen::History::new();
|
||||||
|
|
||||||
println!("🚀 Starting Engine...");
|
println!("🚀 Starting Engine...");
|
||||||
println!("💡 Will cancel after 2 seconds\n");
|
println!("💡 Will cancel after 2 seconds\n");
|
||||||
@@ -45,16 +46,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
println!("📡 Sending request to LLM...");
|
println!("📡 Sending request to LLM...");
|
||||||
|
|
||||||
match engine.run("Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await {
|
let output = engine.run(&mut history, "Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await;
|
||||||
Ok(out) => match out.result {
|
match output.result {
|
||||||
EngineResult::Finished => println!("✅ Task completed normally"),
|
EngineRunExit::Finished => println!("✅ Task completed normally"),
|
||||||
EngineResult::Paused => println!("⏸️ Task paused"),
|
EngineRunExit::Paused => println!("⏸️ Task paused"),
|
||||||
EngineResult::LimitReached => println!("🔒 Turn limit reached"),
|
EngineRunExit::Yielded => println!("↩️ Task yielded"),
|
||||||
EngineResult::Yielded => println!("↩️ Task yielded"),
|
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached) => {
|
||||||
},
|
println!("🔒 Turn limit reached")
|
||||||
Err(e) => {
|
|
||||||
println!("❌ Task error: {}", e);
|
|
||||||
}
|
}
|
||||||
|
EngineRunExit::Interrupted(reason) => println!("❌ Task interrupted: {reason:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("\n✨ Demo complete!");
|
println!("\n✨ Demo complete!");
|
||||||
@@ -38,10 +38,9 @@ use async_trait::async_trait;
|
|||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use clap::{Parser, ValueEnum};
|
use agen::{
|
||||||
use llm_engine::{
|
Engine, EngineRunExit, RunInterruptionReason,
|
||||||
Engine,
|
interceptor::{Interceptor, InterceptorResult, PostToolAction, ToolResultInfo},
|
||||||
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
|
|
||||||
llm_client::{
|
llm_client::{
|
||||||
LlmClient,
|
LlmClient,
|
||||||
capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport},
|
capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport},
|
||||||
@@ -51,12 +50,9 @@ use llm_engine::{
|
|||||||
transport::{HttpTransport, ResolvedAuth},
|
transport::{HttpTransport, ResolvedAuth},
|
||||||
},
|
},
|
||||||
timeline::{Handler, TextBlockEvent, TextBlockKind, ToolUseBlockEvent, ToolUseBlockKind},
|
timeline::{Handler, TextBlockEvent, TextBlockKind, ToolUseBlockEvent, ToolUseBlockKind},
|
||||||
|
tool_registry,
|
||||||
};
|
};
|
||||||
use llm_engine_macros::tool_registry;
|
use clap::{Parser, ValueEnum};
|
||||||
|
|
||||||
// Required imports for macro expansion
|
|
||||||
use schemars;
|
|
||||||
use serde;
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Provider Definition
|
// Provider Definition
|
||||||
@@ -284,7 +280,10 @@ impl ToolResultPrinterPolicy {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Interceptor for ToolResultPrinterPolicy {
|
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
|
let name = self
|
||||||
.call_names
|
.call_names
|
||||||
.lock()
|
.lock()
|
||||||
@@ -298,7 +297,7 @@ impl Interceptor for ToolResultPrinterPolicy {
|
|||||||
println!(" Result ({}): ✅ {}", name, info.result.summary);
|
println!(" Result ({}): ✅ {}", name, info.result.summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
PostToolAction::Continue
|
Ok(PostToolAction::Continue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,6 +454,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
// Create Engine
|
// Create Engine
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history = agen::History::new();
|
||||||
|
|
||||||
let tool_call_names = Arc::new(Mutex::new(HashMap::new()));
|
let tool_call_names = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
@@ -480,12 +480,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
// One-shot mode
|
// One-shot mode
|
||||||
if let Some(prompt) = args.prompt {
|
if let Some(prompt) = args.prompt {
|
||||||
match engine.run(&prompt).await {
|
let output = engine.run(&mut history, &prompt).await;
|
||||||
Ok(_) => {}
|
if let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(error)) = output.result
|
||||||
Err(e) => {
|
{
|
||||||
eprintln!("\n❌ Error: {}", e);
|
eprintln!("\n❌ Error: {error}");
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -504,13 +502,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut locked = match engine.run(first_input).await {
|
let output = engine.run(&mut history, first_input).await;
|
||||||
Ok(out) => out.engine,
|
let mut locked = output.engine;
|
||||||
Err(e) => {
|
|
||||||
eprintln!("\n❌ Error: {}", e);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
print!("\n👤 You: ");
|
print!("\n👤 You: ");
|
||||||
@@ -529,11 +522,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
match locked.run(input).await {
|
if let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(error)) =
|
||||||
Ok(_) => {}
|
locked.run(&mut history, input).await
|
||||||
Err(e) => {
|
{
|
||||||
eprintln!("\n❌ Error: {}", e);
|
eprintln!("\n❌ Error: {error}");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+3
-3
@@ -19,11 +19,11 @@
|
|||||||
mod recorder;
|
mod recorder;
|
||||||
mod scenarios;
|
mod scenarios;
|
||||||
|
|
||||||
use clap::{Parser, ValueEnum};
|
use agen::llm_client::scheme::{
|
||||||
use llm_engine::llm_client::scheme::{
|
|
||||||
Scheme, anthropic::AnthropicScheme, gemini::GeminiScheme, openai_chat::OpenAIScheme,
|
Scheme, anthropic::AnthropicScheme, gemini::GeminiScheme, openai_chat::OpenAIScheme,
|
||||||
};
|
};
|
||||||
use llm_engine::llm_client::transport::{HttpTransport, ResolvedAuth};
|
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||||
|
use clap::{Parser, ValueEnum};
|
||||||
|
|
||||||
fn make_transport<S: Scheme>(scheme: S, model: &str, auth: ResolvedAuth) -> HttpTransport<S> {
|
fn make_transport<S: Scheme>(scheme: S, model: &str, auth: ResolvedAuth) -> HttpTransport<S> {
|
||||||
let cap = scheme.default_capability();
|
let cap = scheme.default_capability();
|
||||||
+1
-1
@@ -7,8 +7,8 @@ use std::io::{BufWriter, Write};
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use agen::llm_client::{LlmClient, Request};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use llm_engine::llm_client::{LlmClient, Request};
|
|
||||||
|
|
||||||
/// Recorded event
|
/// Recorded event
|
||||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Defines requests and output file names for each scenario
|
//! Defines requests and output file names for each scenario
|
||||||
|
|
||||||
use llm_engine::llm_client::{Request, ToolDefinition};
|
use agen::llm_client::{Request, ToolDefinition};
|
||||||
|
|
||||||
/// Test scenario
|
/// Test scenario
|
||||||
pub struct TestScenario {
|
pub struct TestScenario {
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@ pub trait Kind {
|
|||||||
/// # Examples
|
/// # Examples
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// use llm_engine::timeline::{Handler, TextBlockEvent, TextBlockKind};
|
/// use agen::timeline::{Handler, TextBlockEvent, TextBlockKind};
|
||||||
///
|
///
|
||||||
/// struct TextCollector {
|
/// struct TextCollector {
|
||||||
/// texts: Vec<String>,
|
/// texts: Vec<String>,
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
//! Typed conversation history containers.
|
||||||
|
//!
|
||||||
|
//! 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.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::Item;
|
||||||
|
|
||||||
|
/// One conversation-history entry with host-owned annotation.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct HistoryEntry<A = ()> {
|
||||||
|
/// Provider/model-visible conversation item.
|
||||||
|
pub item: Item,
|
||||||
|
/// Host-domain metadata kept with the item and never projected to providers.
|
||||||
|
pub annotation: A,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A> HistoryEntry<A> {
|
||||||
|
/// Build an entry from an item and its annotation.
|
||||||
|
pub fn new(item: Item, annotation: A) -> Self {
|
||||||
|
Self { item, annotation }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split the entry into its item and annotation.
|
||||||
|
pub fn into_parts(self) -> (Item, A) {
|
||||||
|
(self.item, self.annotation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HistoryEntry<()> {
|
||||||
|
/// Build a unit-annotated entry.
|
||||||
|
pub fn from_item(item: Item) -> Self {
|
||||||
|
Self {
|
||||||
|
item,
|
||||||
|
annotation: (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Conversation history with one annotation per item.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
pub struct History<A = ()> {
|
||||||
|
entries: Vec<HistoryEntry<A>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A> History<A> {
|
||||||
|
/// Create an empty history.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
entries: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build history from already annotated entries, preserving order.
|
||||||
|
pub fn from_entries(entries: Vec<HistoryEntry<A>>) -> Self {
|
||||||
|
Self { entries }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace all entries as one restore/rebuild operation and return the old entries.
|
||||||
|
pub fn replace_entries(&mut self, entries: Vec<HistoryEntry<A>>) -> Vec<HistoryEntry<A>> {
|
||||||
|
std::mem::replace(&mut self.entries, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrow annotated entries.
|
||||||
|
pub fn entries(&self) -> &[HistoryEntry<A>] {
|
||||||
|
&self.entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutably borrow annotated entries for host-owned rebuild operations.
|
||||||
|
pub fn entries_mut(&mut self) -> &mut [HistoryEntry<A>] {
|
||||||
|
&mut self.entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume the history into annotated entries.
|
||||||
|
pub fn into_entries(self) -> Vec<HistoryEntry<A>> {
|
||||||
|
self.entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of entries.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.entries.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the history is empty.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.entries.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate over annotated entries.
|
||||||
|
pub fn iter(&self) -> impl ExactSizeIterator<Item = &HistoryEntry<A>> {
|
||||||
|
self.entries.iter()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate over provider-visible items only.
|
||||||
|
pub fn items(&self) -> impl ExactSizeIterator<Item = &Item> {
|
||||||
|
self.entries.iter().map(|entry| &entry.item)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clone provider-visible items into a request-local projection.
|
||||||
|
pub fn items_cloned(&self) -> Vec<Item> {
|
||||||
|
self.items().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append an already annotated entry.
|
||||||
|
pub fn push_entry(&mut self, entry: HistoryEntry<A>) {
|
||||||
|
self.entries.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append many already annotated entries.
|
||||||
|
pub fn extend_entries(&mut self, entries: impl IntoIterator<Item = HistoryEntry<A>>) {
|
||||||
|
self.entries.extend(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit one item through a trusted annotation callback before it becomes live.
|
||||||
|
///
|
||||||
|
/// The callback may durably persist the item and returns the annotation that
|
||||||
|
/// must be stored with it. If the callback fails, the history is left unchanged.
|
||||||
|
pub fn append_with(
|
||||||
|
&mut self,
|
||||||
|
item: Item,
|
||||||
|
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let annotation = annotate(&item)?;
|
||||||
|
self.entries.push(HistoryEntry { item, annotation });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit items through a trusted annotation callback before they become live.
|
||||||
|
///
|
||||||
|
/// Items before a failure remain appended; the failing item and later items do
|
||||||
|
/// not enter history. This mirrors append-only durable logs where each accepted
|
||||||
|
/// item is already committed before the next item is attempted.
|
||||||
|
pub fn extend_with(
|
||||||
|
&mut self,
|
||||||
|
items: impl IntoIterator<Item = Item>,
|
||||||
|
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
for item in items {
|
||||||
|
self.append_with(item, annotate)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Truncate entries, preserving item+annotation pairing for retained entries.
|
||||||
|
pub fn truncate(&mut self, len: usize) {
|
||||||
|
self.entries.truncate(len);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all entries.
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.entries.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl History<()> {
|
||||||
|
/// Build unit-annotated history from provider-visible items.
|
||||||
|
pub fn from_items(items: Vec<Item>) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: items.into_iter().map(HistoryEntry::from_item).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace history from provider-visible items using unit annotations.
|
||||||
|
pub fn replace_items(&mut self, items: Vec<Item>) -> Vec<HistoryEntry<()>> {
|
||||||
|
self.replace_entries(items.into_iter().map(HistoryEntry::from_item).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append one item with unit annotation.
|
||||||
|
pub fn push(&mut self, item: Item) {
|
||||||
|
self.entries.push(HistoryEntry::from_item(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append items with unit annotations.
|
||||||
|
pub fn extend_items(&mut self, items: impl IntoIterator<Item = Item>) {
|
||||||
|
self.entries
|
||||||
|
.extend(items.into_iter().map(HistoryEntry::from_item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<A> IntoIterator for History<A> {
|
||||||
|
type Item = HistoryEntry<A>;
|
||||||
|
type IntoIter = std::vec::IntoIter<HistoryEntry<A>>;
|
||||||
|
|
||||||
|
fn into_iter(self) -> Self::IntoIter {
|
||||||
|
self.entries.into_iter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, A> IntoIterator for &'a History<A> {
|
||||||
|
type Item = &'a HistoryEntry<A>;
|
||||||
|
type IntoIter = std::slice::Iter<'a, HistoryEntry<A>>;
|
||||||
|
|
||||||
|
fn into_iter(self) -> Self::IntoIter {
|
||||||
|
self.entries.iter()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
//! Interceptor - control flow delegation for the Engine execution loop
|
||||||
|
//!
|
||||||
|
//! 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Action after prompt submission.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum PromptAction {
|
||||||
|
/// Proceed normally.
|
||||||
|
Continue,
|
||||||
|
/// Cancel with a reason.
|
||||||
|
Cancel(String),
|
||||||
|
/// Proceed, and append these items to history right after the user
|
||||||
|
/// message. Mirrors [`TurnEndAction::ContinueWithMessages`] for the
|
||||||
|
/// submit edge: lets the upper layer attach resolver-produced
|
||||||
|
/// system messages (e.g. `@<path>` file content) so they sit
|
||||||
|
/// adjacent to the user message that referenced them.
|
||||||
|
ContinueWith(Vec<Item>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Action before an LLM request.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum PreRequestAction {
|
||||||
|
/// Proceed normally.
|
||||||
|
Continue,
|
||||||
|
/// Proceed after appending these items to durable engine history.
|
||||||
|
///
|
||||||
|
/// This is for upper-layer budget/status nudges that the model may react
|
||||||
|
/// to: the items are committed before the request so later turns can see
|
||||||
|
/// why the engine changed course.
|
||||||
|
ContinueWith(Vec<Item>),
|
||||||
|
/// Yield after appending these items to durable engine history.
|
||||||
|
///
|
||||||
|
/// This is for host-mediated pre-request appends that must be visible to
|
||||||
|
/// usage accounting and compaction checks before the current LLM request is
|
||||||
|
/// allowed to proceed.
|
||||||
|
YieldWith(Vec<Item>),
|
||||||
|
/// Cancel with a reason (treated as an error).
|
||||||
|
Cancel(String),
|
||||||
|
/// Yield control to the caller for external processing.
|
||||||
|
///
|
||||||
|
/// The Engine exits the turn loop cleanly with `EngineResult::Yielded`.
|
||||||
|
/// The caller is expected to resume execution later.
|
||||||
|
Yield,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Action before a tool call.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum PreToolAction {
|
||||||
|
/// Proceed with execution.
|
||||||
|
Continue,
|
||||||
|
/// Skip this tool call (do not execute).
|
||||||
|
Skip,
|
||||||
|
/// Do not execute the tool call; commit this synthetic result instead.
|
||||||
|
///
|
||||||
|
/// This preserves provider-visible `tool_use` / `tool_result` pairing
|
||||||
|
/// without aborting the whole turn.
|
||||||
|
SyntheticResult(ToolResult),
|
||||||
|
/// Abort the entire run.
|
||||||
|
Abort(String),
|
||||||
|
/// Pause execution (can be resumed later).
|
||||||
|
Pause,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Action after a tool call.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum PostToolAction {
|
||||||
|
/// Proceed normally.
|
||||||
|
Continue,
|
||||||
|
/// Abort the entire run.
|
||||||
|
Abort(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Action at the end of a turn (when LLM produces no tool calls).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum TurnEndAction {
|
||||||
|
/// Accept the Engine's natural next phase: execute tools, or finish when none exist.
|
||||||
|
Finish,
|
||||||
|
/// Commit additional messages, then continue through the natural next phase.
|
||||||
|
ContinueWithMessages(Vec<Item>),
|
||||||
|
/// Pause execution (can be resumed later).
|
||||||
|
Pause,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Context Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Context for pre-tool-call decisions.
|
||||||
|
pub struct ToolCallInfo<'a, A = ()> {
|
||||||
|
pub invocation: InterceptorInvocation,
|
||||||
|
pub history: &'a [HistoryEntry<A>],
|
||||||
|
pub call: ToolCall,
|
||||||
|
/// Tool meta information.
|
||||||
|
pub meta: ToolMeta,
|
||||||
|
/// Tool instance (for state access).
|
||||||
|
pub tool: Arc<dyn Tool>,
|
||||||
|
/// Response-local execution context for this call.
|
||||||
|
pub context: ToolExecutionContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Context for post-tool-call decisions.
|
||||||
|
pub struct ToolResultInfo<'a, A = ()> {
|
||||||
|
pub invocation: InterceptorInvocation,
|
||||||
|
pub history: &'a [HistoryEntry<A>],
|
||||||
|
pub call: ToolCall,
|
||||||
|
/// Committed terminal tool execution result.
|
||||||
|
pub result: ToolResult,
|
||||||
|
/// Tool meta information.
|
||||||
|
pub meta: ToolMeta,
|
||||||
|
/// Tool instance (for state access).
|
||||||
|
pub tool: Arc<dyn Tool>,
|
||||||
|
/// Response-local execution context for this call.
|
||||||
|
pub context: ToolExecutionContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Interceptor Trait
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Intercepts the Engine execution loop at key decision points.
|
||||||
|
///
|
||||||
|
/// 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<A: Send + Sync = ()>: Send + Sync {
|
||||||
|
/// 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
|
||||||
|
/// before the next LLM request. Returned items are `extend`ed into
|
||||||
|
/// the persistent history (and therefore picked up by the per-turn
|
||||||
|
/// clone that backs the LLM request, plus the usual
|
||||||
|
/// history-persistence path).
|
||||||
|
///
|
||||||
|
/// Use this for inputs that arrive from outside the LLM and need
|
||||||
|
/// to be reflected in the on-disk history — notifications,
|
||||||
|
/// external events, system reminders. Do **not** use
|
||||||
|
/// [`Self::pre_llm_request`] for that purpose: it mutates a
|
||||||
|
/// per-request clone, so any committed assistant response that
|
||||||
|
/// reacts to the injection would have no visible trigger on the
|
||||||
|
/// next turn (or after resume / compaction).
|
||||||
|
///
|
||||||
|
/// `pre_llm_request` remains the right place for purely
|
||||||
|
/// reproducible per-request transformations (pruning, content
|
||||||
|
/// trimming, cache anchors) that depend only on the existing
|
||||||
|
/// history.
|
||||||
|
async fn pending_history_appends(
|
||||||
|
&self,
|
||||||
|
_context: PendingHistoryAppendsContext<'_, A>,
|
||||||
|
) -> InterceptorResult<Vec<Item>> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called before each LLM request. The context starts as a clone
|
||||||
|
/// of `engine.history` (after `pending_history_appends` and the
|
||||||
|
/// Engine's own prune projection have been applied).
|
||||||
|
///
|
||||||
|
/// Direct mutations to `context` remain request-local and are not persisted.
|
||||||
|
/// 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: PreLlmRequestContext<'_, A>,
|
||||||
|
) -> InterceptorResult<PreRequestAction> {
|
||||||
|
Ok(PreRequestAction::Continue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called before each tool is executed.
|
||||||
|
async fn pre_tool_call(
|
||||||
|
&self,
|
||||||
|
_info: &mut ToolCallInfo<'_, A>,
|
||||||
|
) -> InterceptorResult<PreToolAction> {
|
||||||
|
Ok(PreToolAction::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 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 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
|
||||||
|
/// without any external control flow decisions.
|
||||||
|
pub(crate) struct DefaultInterceptor;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<A: Send + Sync> Interceptor<A> for DefaultInterceptor {}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#![doc = include_str!("../README.md")]
|
||||||
|
|
||||||
|
mod engine;
|
||||||
|
mod handler;
|
||||||
|
mod history;
|
||||||
|
mod message;
|
||||||
|
|
||||||
|
pub(crate) mod callback;
|
||||||
|
pub mod event;
|
||||||
|
pub mod interceptor;
|
||||||
|
pub mod llm_client;
|
||||||
|
pub mod providers;
|
||||||
|
pub mod prune;
|
||||||
|
pub mod state;
|
||||||
|
pub mod timeline;
|
||||||
|
pub mod token_counter;
|
||||||
|
pub mod tool;
|
||||||
|
pub mod tool_server;
|
||||||
|
pub mod usage_record;
|
||||||
|
|
||||||
|
pub use agen_macros::{description, tool, tool_registry};
|
||||||
|
pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
|
||||||
|
pub use engine::{
|
||||||
|
Engine, EngineConfig, EngineError, EngineResult, EngineRunExit, EngineRunOutput,
|
||||||
|
LlmRetryNotice, RunInterruptionReason, ToolRegistryError,
|
||||||
|
};
|
||||||
|
pub use handler::ToolUseBlockStart;
|
||||||
|
pub use history::{History, HistoryEntry};
|
||||||
|
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,
|
||||||
|
ToolExecutionTerminal, ToolExecutionTerminalFuture, ToolOutputLimits, ToolResult,
|
||||||
|
ToolResultDisposition,
|
||||||
|
};
|
||||||
|
pub use usage_record::UsageRecord;
|
||||||
|
|
||||||
|
/// Implementation dependencies used by code generated from `agen` macros.
|
||||||
|
///
|
||||||
|
/// This module is not a stable user-facing API. It is public only because macro expansion
|
||||||
|
/// happens in the downstream crate.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub mod __private {
|
||||||
|
pub use async_trait;
|
||||||
|
pub use schemars;
|
||||||
|
pub use serde;
|
||||||
|
pub use serde_json;
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ pub enum AuthRequirement {
|
|||||||
/// リクエスト毎に認証ヘッダを動的に組み立てるプロバイダ。
|
/// リクエスト毎に認証ヘッダを動的に組み立てるプロバイダ。
|
||||||
///
|
///
|
||||||
/// access token が refresh で更新されたり、複数ヘッダを同時に注入する
|
/// access token が refresh で更新されたり、複数ヘッダを同時に注入する
|
||||||
/// 必要があるケースで使う。実体は呼び出し側に置き、llm-engine は
|
/// 必要があるケースで使う。実体は呼び出し側に置き、agen は
|
||||||
/// trait を知るだけ。
|
/// trait を知るだけ。
|
||||||
///
|
///
|
||||||
/// 返したヘッダはそのまま `HeaderMap` に挿入される。`Authorization`
|
/// 返したヘッダはそのまま `HeaderMap` に挿入される。`Authorization`
|
||||||
@@ -18,6 +18,9 @@ pub enum ClientError {
|
|||||||
message: String,
|
message: String,
|
||||||
retry_after: Option<Duration>,
|
retry_after: Option<Duration>,
|
||||||
},
|
},
|
||||||
|
/// The provider rejected the request because it exceeded the model context window.
|
||||||
|
/// Classified only from a structured provider error code, never message text.
|
||||||
|
ContextWindowExceeded,
|
||||||
/// A request lifecycle phase exceeded its hard timeout.
|
/// A request lifecycle phase exceeded its hard timeout.
|
||||||
Timeout {
|
Timeout {
|
||||||
phase: &'static str,
|
phase: &'static str,
|
||||||
@@ -48,6 +51,7 @@ impl fmt::Display for ClientError {
|
|||||||
}
|
}
|
||||||
write!(f, ": {}", message)
|
write!(f, ": {}", message)
|
||||||
}
|
}
|
||||||
|
ClientError::ContextWindowExceeded => write!(f, "Model context window reached"),
|
||||||
ClientError::Timeout { phase, timeout } => {
|
ClientError::Timeout { phase, timeout } => {
|
||||||
write!(f, "{phase} timed out after {}s", timeout.as_secs())
|
write!(f, "{phase} timed out after {}s", timeout.as_secs())
|
||||||
}
|
}
|
||||||
@@ -112,7 +116,10 @@ pub fn is_retryable(error: &ClientError) -> bool {
|
|||||||
ClientError::Api { status: None, .. } => false,
|
ClientError::Api { status: None, .. } => false,
|
||||||
ClientError::Timeout { .. } => true,
|
ClientError::Timeout { .. } => true,
|
||||||
ClientError::Http(e) => e.is_connect() || e.is_timeout(),
|
ClientError::Http(e) => e.is_connect() || e.is_timeout(),
|
||||||
ClientError::Json(_) | ClientError::Sse(_) | ClientError::Config(_) => false,
|
ClientError::ContextWindowExceeded
|
||||||
|
| ClientError::Json(_)
|
||||||
|
| ClientError::Sse(_)
|
||||||
|
| ClientError::Config(_) => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
/// 指数バックオフ + ジッター + 累積タイムアウトを表すポリシー。
|
/// 指数バックオフ + ジッター + 累積タイムアウトを表すポリシー。
|
||||||
///
|
///
|
||||||
/// `Default` は llm-engine 全体の固定値を返す。呼び出し側からの上書きが
|
/// `Default` は agen 全体の固定値を返す。呼び出し側からの上書きが
|
||||||
/// 必要になったら拡張する。
|
/// 必要になったら拡張する。
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RetryPolicy {
|
pub struct RetryPolicy {
|
||||||
+155
-11
@@ -5,10 +5,13 @@
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::llm_client::{
|
use crate::{
|
||||||
|
llm_client::{
|
||||||
Request,
|
Request,
|
||||||
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
|
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
|
||||||
types::{Item, Role, ToolDefinition, parse_tool_arguments},
|
types::{ContentPart, Item, Role, ToolDefinition, image_data_url, parse_tool_arguments},
|
||||||
|
},
|
||||||
|
tool::Attachment,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::OpenAIScheme;
|
use super::OpenAIScheme;
|
||||||
@@ -134,7 +137,7 @@ impl OpenAIScheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert items to messages
|
// Convert items to messages
|
||||||
messages.extend(self.convert_items_to_messages(&request.items));
|
messages.extend(self.convert_items_to_messages(&request.items, capability.vision));
|
||||||
|
|
||||||
let tools = request.tools.iter().map(|t| self.convert_tool(t)).collect();
|
let tools = request.tools.iter().map(|t| self.convert_tool(t)).collect();
|
||||||
|
|
||||||
@@ -185,12 +188,38 @@ impl OpenAIScheme {
|
|||||||
/// - Assistant messages have role "assistant"
|
/// - Assistant messages have role "assistant"
|
||||||
/// - Tool calls are within assistant messages as tool_calls array
|
/// - Tool calls are within assistant messages as tool_calls array
|
||||||
/// - Tool results have role "tool" with tool_call_id
|
/// - Tool results have role "tool" with tool_call_id
|
||||||
fn convert_items_to_messages(&self, items: &[Item]) -> Vec<OpenAIMessage> {
|
fn flush_pending_tool_result_images(
|
||||||
|
messages: &mut Vec<OpenAIMessage>,
|
||||||
|
pending_images: &mut Vec<OpenAIContentPart>,
|
||||||
|
) {
|
||||||
|
if !pending_images.is_empty() {
|
||||||
|
messages.push(OpenAIMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: Some(OpenAIContent::Parts(std::mem::take(pending_images))),
|
||||||
|
tool_calls: vec![],
|
||||||
|
tool_call_id: None,
|
||||||
|
name: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_items_to_messages(
|
||||||
|
&self,
|
||||||
|
items: &[Item],
|
||||||
|
supports_images: bool,
|
||||||
|
) -> Vec<OpenAIMessage> {
|
||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
let mut pending_tool_calls: Vec<OpenAIToolCall> = Vec::new();
|
let mut pending_tool_calls: Vec<OpenAIToolCall> = Vec::new();
|
||||||
let mut pending_assistant_text: Option<String> = None;
|
let mut pending_assistant_text: Option<String> = None;
|
||||||
|
let mut pending_tool_result_images: Vec<OpenAIContentPart> = Vec::new();
|
||||||
|
|
||||||
for item in items {
|
for item in items {
|
||||||
|
if !matches!(item, Item::ToolResult { .. }) {
|
||||||
|
Self::flush_pending_tool_result_images(
|
||||||
|
&mut messages,
|
||||||
|
&mut pending_tool_result_images,
|
||||||
|
);
|
||||||
|
}
|
||||||
match item {
|
match item {
|
||||||
Item::Message { role, content, .. } => {
|
Item::Message { role, content, .. } => {
|
||||||
// Flush pending tool calls
|
// Flush pending tool calls
|
||||||
@@ -205,16 +234,17 @@ impl OpenAIScheme {
|
|||||||
Role::Assistant => "assistant",
|
Role::Assistant => "assistant",
|
||||||
Role::System => "system",
|
Role::System => "system",
|
||||||
};
|
};
|
||||||
|
let message_content = OpenAIContent::Text(
|
||||||
let text_content: String = content
|
content
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| p.as_text())
|
.map(ContentPart::as_text)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("");
|
.join(""),
|
||||||
|
);
|
||||||
|
|
||||||
messages.push(OpenAIMessage {
|
messages.push(OpenAIMessage {
|
||||||
role: openai_role.to_string(),
|
role: openai_role.to_string(),
|
||||||
content: Some(OpenAIContent::Text(text_content)),
|
content: Some(message_content),
|
||||||
tool_calls: vec![],
|
tool_calls: vec![],
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
name: None,
|
name: None,
|
||||||
@@ -244,19 +274,35 @@ impl OpenAIScheme {
|
|||||||
call_id,
|
call_id,
|
||||||
summary,
|
summary,
|
||||||
content,
|
content,
|
||||||
|
attachments,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
// Flush pending tool calls before tool result
|
// OpenAI requires every parallel tool result before a new user message.
|
||||||
self.flush_pending_assistant(
|
self.flush_pending_assistant(
|
||||||
&mut messages,
|
&mut messages,
|
||||||
&mut pending_tool_calls,
|
&mut pending_tool_calls,
|
||||||
&mut pending_assistant_text,
|
&mut pending_assistant_text,
|
||||||
);
|
);
|
||||||
|
|
||||||
let text = match content {
|
let mut text = match content {
|
||||||
Some(c) => format!("{summary}\n{c}"),
|
Some(c) => format!("{summary}\n{c}"),
|
||||||
None => summary.clone(),
|
None => summary.clone(),
|
||||||
};
|
};
|
||||||
|
if supports_images {
|
||||||
|
pending_tool_result_images.extend(attachments.iter().map(|attachment| {
|
||||||
|
let Attachment::Image(image) = attachment;
|
||||||
|
OpenAIContentPart::ImageUrl {
|
||||||
|
image_url: ImageUrl {
|
||||||
|
url: image_data_url(image.mime_type(), image.data()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} else if !attachments.is_empty() {
|
||||||
|
text.push_str(&format!(
|
||||||
|
"\n[{} image attachment(s) omitted: model does not support images]",
|
||||||
|
attachments.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
messages.push(OpenAIMessage {
|
messages.push(OpenAIMessage {
|
||||||
role: "tool".to_string(),
|
role: "tool".to_string(),
|
||||||
content: Some(OpenAIContent::Text(text)),
|
content: Some(OpenAIContent::Text(text)),
|
||||||
@@ -284,6 +330,7 @@ impl OpenAIScheme {
|
|||||||
&mut pending_tool_calls,
|
&mut pending_tool_calls,
|
||||||
&mut pending_assistant_text,
|
&mut pending_assistant_text,
|
||||||
);
|
);
|
||||||
|
Self::flush_pending_tool_result_images(&mut messages, &mut pending_tool_result_images);
|
||||||
|
|
||||||
messages
|
messages
|
||||||
}
|
}
|
||||||
@@ -334,6 +381,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn vision_cap() -> ModelCapability {
|
||||||
|
ModelCapability {
|
||||||
|
vision: true,
|
||||||
|
..cap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_simple_request() {
|
fn test_build_simple_request() {
|
||||||
let scheme = OpenAIScheme::new();
|
let scheme = OpenAIScheme::new();
|
||||||
@@ -439,4 +493,94 @@ mod tests {
|
|||||||
assert_eq!(body.messages[1].tool_calls.len(), 1);
|
assert_eq!(body.messages[1].tool_calls.len(), 1);
|
||||||
assert_eq!(body.messages[2].role, "tool");
|
assert_eq!(body.messages[2].role, "tool");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parallel_tool_results_precede_durable_image_projection() {
|
||||||
|
let scheme = OpenAIScheme::new();
|
||||||
|
let image = std::sync::Arc::<[u8]>::from(&b"\x89PNG\r\n\x1a\nbody"[..]);
|
||||||
|
let request = Request::new()
|
||||||
|
.item(Item::tool_call("call_image", "ViewImage", "{}"))
|
||||||
|
.item(Item::tool_call("call_text", "Read", "{}"))
|
||||||
|
.item(Item::tool_result_item_with_attachments(
|
||||||
|
"call_image",
|
||||||
|
"Attached image",
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
vec![crate::tool::Attachment::Image(
|
||||||
|
crate::tool::ImageAttachment::new("image/png", image),
|
||||||
|
)],
|
||||||
|
))
|
||||||
|
.item(Item::tool_result_item(
|
||||||
|
"call_text",
|
||||||
|
"Read text",
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
let json = serde_json::to_value(
|
||||||
|
&scheme
|
||||||
|
.build_request("gpt-4o", &request, &vision_cap())
|
||||||
|
.messages,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(json[0]["role"], "assistant");
|
||||||
|
assert_eq!(json[1]["role"], "tool");
|
||||||
|
assert_eq!(json[2]["role"], "tool");
|
||||||
|
assert_eq!(json[3]["role"], "user");
|
||||||
|
assert_eq!(json[3]["content"][0]["type"], "image_url");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn durable_tool_image_is_deterministically_lowered_to_following_user_content() {
|
||||||
|
let scheme = OpenAIScheme::new();
|
||||||
|
let image = std::sync::Arc::<[u8]>::from(&b"\x89PNG\r\n\x1a\nbody"[..]);
|
||||||
|
let attachment = crate::tool::Attachment::Image(crate::tool::ImageAttachment::new(
|
||||||
|
"image/png",
|
||||||
|
image.clone(),
|
||||||
|
));
|
||||||
|
let item = Item::tool_result_item_with_attachments(
|
||||||
|
"call_image",
|
||||||
|
"Attached image",
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
vec![attachment],
|
||||||
|
);
|
||||||
|
let persisted = serde_json::to_string(&item).unwrap();
|
||||||
|
assert!(persisted.contains("attachments"));
|
||||||
|
let restored: Item = serde_json::from_str(&persisted).unwrap();
|
||||||
|
|
||||||
|
let request = Request::new()
|
||||||
|
.item(Item::tool_call(
|
||||||
|
"call_image",
|
||||||
|
"ViewImage",
|
||||||
|
r#"{"path":"a.png"}"#,
|
||||||
|
))
|
||||||
|
.item(restored);
|
||||||
|
let body = scheme.build_request("gpt-4o", &request, &vision_cap());
|
||||||
|
let json = serde_json::to_value(&body.messages).unwrap();
|
||||||
|
let rebuilt = serde_json::to_value(
|
||||||
|
&scheme
|
||||||
|
.build_request("gpt-4o", &request, &vision_cap())
|
||||||
|
.messages,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rebuilt, json);
|
||||||
|
|
||||||
|
assert_eq!(json[0]["role"], "assistant");
|
||||||
|
assert_eq!(json[1]["role"], "tool");
|
||||||
|
assert_eq!(json[2]["role"], "user");
|
||||||
|
assert_eq!(json[2]["content"][0]["type"], "image_url");
|
||||||
|
assert!(
|
||||||
|
json[2]["content"][0]["image_url"]["url"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("data:image/png;base64,")
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut no_vision = cap();
|
||||||
|
no_vision.vision = false;
|
||||||
|
let disabled =
|
||||||
|
serde_json::to_string(&scheme.build_request("gpt-4o", &request, &no_vision)).unwrap();
|
||||||
|
assert!(!disabled.contains("data:image"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
//! function_call item が first-class で、SSE イベントも `response.*` 名前空間で
|
//! function_call item が first-class で、SSE イベントも `response.*` 名前空間で
|
||||||
//! 流れる。
|
//! 流れる。
|
||||||
//!
|
//!
|
||||||
//! - リクエスト JSON 生成: [`request`]
|
//! - リクエスト JSON 生成: `request`
|
||||||
//! - SSE イベントパース → [`Event`](crate::llm_client::event::Event) 変換: [`events`]
|
//! - SSE イベントパース → [`Event`](crate::llm_client::event::Event) 変換: `events`
|
||||||
|
|
||||||
mod capability;
|
mod capability;
|
||||||
mod events;
|
mod events;
|
||||||
+90
-9
@@ -7,14 +7,31 @@
|
|||||||
use serde::{Serialize, Serializer};
|
use serde::{Serialize, Serializer};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::llm_client::{
|
use crate::{
|
||||||
|
llm_client::{
|
||||||
Request,
|
Request,
|
||||||
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
|
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
|
||||||
types::{ContentPart, Item, Role, ToolDefinition, parse_tool_arguments},
|
types::{ContentPart, Item, Role, ToolDefinition, image_data_url, parse_tool_arguments},
|
||||||
|
},
|
||||||
|
tool::Attachment,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::OpenAIResponsesScheme;
|
use super::OpenAIResponsesScheme;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub(crate) enum FunctionCallOutputBody {
|
||||||
|
Text(String),
|
||||||
|
ContentItems(Vec<FunctionCallOutputContentItem>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub(crate) enum FunctionCallOutputContentItem {
|
||||||
|
InputText { text: String },
|
||||||
|
InputImage { image_url: String },
|
||||||
|
}
|
||||||
|
|
||||||
/// `/v1/responses` のリクエスト body。
|
/// `/v1/responses` のリクエスト body。
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub(crate) struct ResponsesRequest {
|
pub(crate) struct ResponsesRequest {
|
||||||
@@ -91,9 +108,7 @@ pub(crate) enum InputItem {
|
|||||||
/// function tool の結果(user 側)。
|
/// function tool の結果(user 側)。
|
||||||
FunctionCallOutput {
|
FunctionCallOutput {
|
||||||
call_id: String,
|
call_id: String,
|
||||||
/// Responses は文字列 or 構造化 output を許すが、ここでは
|
output: FunctionCallOutputBody,
|
||||||
/// `summary` + `content` を改行連結した文字列で送る。
|
|
||||||
output: String,
|
|
||||||
},
|
},
|
||||||
/// reasoning item。`encrypted_content` があれば必ず添える。
|
/// reasoning item。`encrypted_content` があれば必ず添える。
|
||||||
Reasoning {
|
Reasoning {
|
||||||
@@ -118,6 +133,7 @@ pub(crate) enum InputItem {
|
|||||||
pub(crate) enum InputContent {
|
pub(crate) enum InputContent {
|
||||||
/// user / developer 側のテキスト
|
/// user / developer 側のテキスト
|
||||||
InputText { text: String },
|
InputText { text: String },
|
||||||
|
/// user 側の画像
|
||||||
/// assistant 側のテキスト
|
/// assistant 側のテキスト
|
||||||
OutputText { text: String },
|
OutputText { text: String },
|
||||||
}
|
}
|
||||||
@@ -173,7 +189,7 @@ impl OpenAIResponsesScheme {
|
|||||||
request: &Request,
|
request: &Request,
|
||||||
capability: &ModelCapability,
|
capability: &ModelCapability,
|
||||||
) -> ResponsesRequest {
|
) -> ResponsesRequest {
|
||||||
let input = convert_items_to_input(&request.items);
|
let input = convert_items_to_input(&request.items, capability.vision);
|
||||||
let tools = request.tools.iter().map(convert_tool).collect();
|
let tools = request.tools.iter().map(convert_tool).collect();
|
||||||
|
|
||||||
// Reasoning 投影: capability が Effort / Both をサポートし、かつ
|
// Reasoning 投影: capability が Effort / Both をサポートし、かつ
|
||||||
@@ -234,7 +250,7 @@ impl OpenAIResponsesScheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `Item` 列を `input[]` に変換する。
|
/// `Item` 列を `input[]` に変換する。
|
||||||
fn convert_items_to_input(items: &[Item]) -> Vec<InputItem> {
|
fn convert_items_to_input(items: &[Item], supports_images: bool) -> Vec<InputItem> {
|
||||||
let mut out = Vec::with_capacity(items.len());
|
let mut out = Vec::with_capacity(items.len());
|
||||||
for item in items {
|
for item in items {
|
||||||
match item {
|
match item {
|
||||||
@@ -247,7 +263,7 @@ fn convert_items_to_input(items: &[Item]) -> Vec<InputItem> {
|
|||||||
};
|
};
|
||||||
let parts: Vec<InputContent> = content
|
let parts: Vec<InputContent> = content
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| match p {
|
.map(|part| match part {
|
||||||
ContentPart::Text { text } => text_variant(text.clone()),
|
ContentPart::Text { text } => text_variant(text.clone()),
|
||||||
ContentPart::Refusal { refusal } => text_variant(refusal.clone()),
|
ContentPart::Refusal { refusal } => text_variant(refusal.clone()),
|
||||||
})
|
})
|
||||||
@@ -275,15 +291,33 @@ fn convert_items_to_input(items: &[Item]) -> Vec<InputItem> {
|
|||||||
call_id,
|
call_id,
|
||||||
summary,
|
summary,
|
||||||
content,
|
content,
|
||||||
|
attachments,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let text = match content {
|
let text = match content {
|
||||||
Some(c) => format!("{summary}\n{c}"),
|
Some(c) => format!("{summary}\n{c}"),
|
||||||
None => summary.clone(),
|
None => summary.clone(),
|
||||||
};
|
};
|
||||||
|
let output = if attachments.is_empty() {
|
||||||
|
FunctionCallOutputBody::Text(text)
|
||||||
|
} else if supports_images {
|
||||||
|
let mut parts = vec![FunctionCallOutputContentItem::InputText { text }];
|
||||||
|
parts.extend(attachments.iter().map(|attachment| {
|
||||||
|
let Attachment::Image(image) = attachment;
|
||||||
|
FunctionCallOutputContentItem::InputImage {
|
||||||
|
image_url: image_data_url(image.mime_type(), image.data()),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
FunctionCallOutputBody::ContentItems(parts)
|
||||||
|
} else {
|
||||||
|
FunctionCallOutputBody::Text(format!(
|
||||||
|
"{text}\n[{} image attachment(s) omitted: model does not support images]",
|
||||||
|
attachments.len()
|
||||||
|
))
|
||||||
|
};
|
||||||
out.push(InputItem::FunctionCallOutput {
|
out.push(InputItem::FunctionCallOutput {
|
||||||
call_id: call_id.clone(),
|
call_id: call_id.clone(),
|
||||||
output: text,
|
output,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Item::Reasoning {
|
Item::Reasoning {
|
||||||
@@ -690,4 +724,51 @@ mod tests {
|
|||||||
assert_eq!(json["tools"][0]["type"], "function");
|
assert_eq!(json["tools"][0]["type"], "function");
|
||||||
assert_eq!(json["tools"][0]["name"], "t");
|
assert_eq!(json["tools"][0]["name"], "t");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn durable_tool_image_uses_function_call_output_content_items() {
|
||||||
|
let scheme = OpenAIResponsesScheme::new();
|
||||||
|
let image = std::sync::Arc::<[u8]>::from(&b"\x89PNG\r\n\x1a\nbody"[..]);
|
||||||
|
let item = Item::tool_result_item_with_attachments(
|
||||||
|
"call_image",
|
||||||
|
"Attached image",
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
vec![crate::tool::Attachment::Image(
|
||||||
|
crate::tool::ImageAttachment::new("image/png", image),
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
let persisted = serde_json::to_string(&item).unwrap();
|
||||||
|
let restored: Item = serde_json::from_str(&persisted).unwrap();
|
||||||
|
let req = Request::new()
|
||||||
|
.item(Item::tool_call(
|
||||||
|
"call_image",
|
||||||
|
"ViewImage",
|
||||||
|
r#"{"path":"a.png"}"#,
|
||||||
|
))
|
||||||
|
.item(restored);
|
||||||
|
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||||
|
let json = serde_json::to_value(&body).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(json["input"][1]["type"], "function_call_output");
|
||||||
|
assert_eq!(json["input"].as_array().unwrap().len(), 2);
|
||||||
|
assert_eq!(json["input"][1]["output"][0]["type"], "input_text");
|
||||||
|
assert_eq!(json["input"][1]["output"][1]["type"], "input_image");
|
||||||
|
assert!(
|
||||||
|
json["input"][1]["output"][1]["image_url"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("data:image/png;base64,")
|
||||||
|
);
|
||||||
|
let rebuilt =
|
||||||
|
serde_json::to_value(scheme.build_request("gpt-5", &req, &cap_with_reasoning()))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rebuilt["input"], json["input"]);
|
||||||
|
|
||||||
|
let mut no_vision = cap_with_reasoning();
|
||||||
|
no_vision.vision = false;
|
||||||
|
let disabled =
|
||||||
|
serde_json::to_string(&scheme.build_request("gpt-5", &req, &no_vision)).unwrap();
|
||||||
|
assert!(!disabled.contains("data:image"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+4
-7
@@ -431,13 +431,7 @@ fn api_error_code(error: &ClientError) -> Option<&str> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_context_length_exceeded(error: &ClientError) -> bool {
|
fn is_context_length_exceeded(error: &ClientError) -> bool {
|
||||||
match error {
|
matches!(error, ClientError::ContextWindowExceeded)
|
||||||
ClientError::Api { code, message, .. } => {
|
|
||||||
code.as_deref() == Some("context_length_exceeded")
|
|
||||||
|| message.contains("context_length_exceeded")
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn response_with_timeout(
|
async fn response_with_timeout(
|
||||||
@@ -487,6 +481,9 @@ async fn classify_error_response(resp: reqwest::Response) -> ClientError {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or(&text)
|
.unwrap_or(&text)
|
||||||
.to_string();
|
.to_string();
|
||||||
|
if code.as_deref() == Some("context_length_exceeded") {
|
||||||
|
return ClientError::ContextWindowExceeded;
|
||||||
|
}
|
||||||
ClientError::Api {
|
ClientError::Api {
|
||||||
status: Some(status),
|
status: Some(status),
|
||||||
code,
|
code,
|
||||||
@@ -9,12 +9,19 @@
|
|||||||
|
|
||||||
use std::{fmt, sync::Arc};
|
use std::{fmt, sync::Arc};
|
||||||
|
|
||||||
|
use crate::tool::{Attachment, ToolResultDisposition};
|
||||||
|
use base64::Engine as _;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
fn is_false(value: &bool) -> bool {
|
fn is_false(value: &bool) -> bool {
|
||||||
!*value
|
!*value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn image_data_url(media_type: &str, data: &[u8]) -> String {
|
||||||
|
let encoded = base64::engine::general_purpose::STANDARD.encode(data);
|
||||||
|
format!("data:{media_type};base64,{encoded}")
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Item - The core unit of conversation
|
// Item - The core unit of conversation
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -62,7 +69,7 @@ impl fmt::Debug for RequestTrace {
|
|||||||
/// # Examples
|
/// # Examples
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// use llm_engine::Item;
|
/// use agen::Item;
|
||||||
///
|
///
|
||||||
/// let user = Item::user_message("Hello!");
|
/// let user = Item::user_message("Hello!");
|
||||||
/// let assistant = Item::assistant_message("Hi there!");
|
/// let assistant = Item::assistant_message("Hi there!");
|
||||||
@@ -114,9 +121,15 @@ pub enum Item {
|
|||||||
/// Detailed output (removed by pruning when old enough)
|
/// Detailed output (removed by pruning when old enough)
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
|
/// Typed terminal state used for replay and recovery.
|
||||||
|
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
|
||||||
|
disposition: ToolResultDisposition,
|
||||||
/// Whether the tool result represents an execution error.
|
/// Whether the tool result represents an execution error.
|
||||||
#[serde(default, skip_serializing_if = "is_false")]
|
#[serde(default, skip_serializing_if = "is_false")]
|
||||||
is_error: bool,
|
is_error: bool,
|
||||||
|
/// Durable binary details (removed with `content` by normal pruning).
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
attachments: Vec<Attachment>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Reasoning/thinking item
|
/// Reasoning/thinking item
|
||||||
@@ -251,12 +264,56 @@ impl Item {
|
|||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
is_error: bool,
|
is_error: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
Self::tool_result_item_with_disposition_and_attachments(
|
||||||
|
call_id,
|
||||||
|
summary,
|
||||||
|
content,
|
||||||
|
if is_error {
|
||||||
|
ToolResultDisposition::Error
|
||||||
|
} else {
|
||||||
|
ToolResultDisposition::Success
|
||||||
|
},
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a tool result item with durable, prunable structured attachments.
|
||||||
|
pub fn tool_result_item_with_attachments(
|
||||||
|
call_id: impl Into<String>,
|
||||||
|
summary: impl Into<String>,
|
||||||
|
content: Option<String>,
|
||||||
|
is_error: bool,
|
||||||
|
attachments: Vec<Attachment>,
|
||||||
|
) -> Self {
|
||||||
|
Self::tool_result_item_with_disposition_and_attachments(
|
||||||
|
call_id,
|
||||||
|
summary,
|
||||||
|
content,
|
||||||
|
if is_error {
|
||||||
|
ToolResultDisposition::Error
|
||||||
|
} else {
|
||||||
|
ToolResultDisposition::Success
|
||||||
|
},
|
||||||
|
attachments,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tool_result_item_with_disposition_and_attachments(
|
||||||
|
call_id: impl Into<String>,
|
||||||
|
summary: impl Into<String>,
|
||||||
|
content: Option<String>,
|
||||||
|
disposition: ToolResultDisposition,
|
||||||
|
attachments: Vec<Attachment>,
|
||||||
|
) -> Self {
|
||||||
|
let is_error = !disposition.is_success();
|
||||||
Self::ToolResult {
|
Self::ToolResult {
|
||||||
id: None,
|
id: None,
|
||||||
call_id: call_id.into(),
|
call_id: call_id.into(),
|
||||||
summary: summary.into(),
|
summary: summary.into(),
|
||||||
content,
|
content,
|
||||||
|
disposition,
|
||||||
is_error,
|
is_error,
|
||||||
|
attachments,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,7 +518,7 @@ impl ContentPart {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the text content regardless of type
|
/// Get a textual projection of the content part.
|
||||||
pub fn as_text(&self) -> &str {
|
pub fn as_text(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
Self::Text { text } => text,
|
Self::Text { text } => text,
|
||||||
@@ -110,25 +110,30 @@ impl Default for PruneConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set `content = None` on each `Item::ToolResult` at the given indices.
|
/// Remove detailed text and attachments from each `Item::ToolResult` at the given indices.
|
||||||
///
|
///
|
||||||
/// Returns the number of items that were actually modified — items that
|
/// The mandatory summary remains. Returns the number of items that were actually
|
||||||
/// are already content-less are counted as 0. Intended for use on a
|
/// modified — results that already contain no detail are counted as 0. Intended
|
||||||
/// request-context clone (never on a persistent history).
|
/// for use on a request-context clone (never on a persistent history).
|
||||||
pub fn project(items: &mut [Item], indices: &[usize]) -> usize {
|
pub fn project(items: &mut [Item], indices: &[usize]) -> usize {
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
for &i in indices {
|
for &i in indices {
|
||||||
if let Item::ToolResult { content, .. } = &mut items[i]
|
if let Item::ToolResult {
|
||||||
&& content.is_some()
|
content,
|
||||||
|
attachments,
|
||||||
|
..
|
||||||
|
} = &mut items[i]
|
||||||
|
&& (content.is_some() || !attachments.is_empty())
|
||||||
{
|
{
|
||||||
*content = None;
|
*content = None;
|
||||||
|
attachments.clear();
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
count
|
count
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Indices of `Item::ToolResult { content: Some(_), .. }` that lie before
|
/// Indices of detailed `Item::ToolResult` values that lie before
|
||||||
/// the suffix protected by `protected_tokens`. Pure: does not mutate `items`.
|
/// the suffix protected by `protected_tokens`. Pure: does not mutate `items`.
|
||||||
///
|
///
|
||||||
/// Returns an empty vector when token estimates are unavailable (`NoData`) or
|
/// Returns an empty vector when token estimates are unavailable (`NoData`) or
|
||||||
@@ -159,8 +164,10 @@ pub fn evaluate_candidates(
|
|||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(i, item)| match item {
|
.filter_map(|(i, item)| match item {
|
||||||
Item::ToolResult {
|
Item::ToolResult {
|
||||||
content: Some(_), ..
|
content,
|
||||||
} => Some(i),
|
attachments,
|
||||||
|
..
|
||||||
|
} if content.is_some() || !attachments.is_empty() => Some(i),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -373,6 +380,38 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_drops_image_detail_but_keeps_summary_and_persistent_source() {
|
||||||
|
let original = vec![Item::tool_result_item_with_attachments(
|
||||||
|
"call_image",
|
||||||
|
"Attached image/png image (12 bytes)",
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
vec![crate::tool::Attachment::Image(
|
||||||
|
crate::tool::ImageAttachment::new("image/png", b"image-body".to_vec()),
|
||||||
|
)],
|
||||||
|
)];
|
||||||
|
let mut request_context = original.clone();
|
||||||
|
|
||||||
|
let estimates = uniform_estimates(&original, 100);
|
||||||
|
assert_eq!(prunable_indices(&original, 0, &estimates), vec![0]);
|
||||||
|
|
||||||
|
assert_eq!(project(&mut request_context, &[0]), 1);
|
||||||
|
assert!(matches!(
|
||||||
|
&request_context[0],
|
||||||
|
Item::ToolResult {
|
||||||
|
summary,
|
||||||
|
content: None,
|
||||||
|
attachments,
|
||||||
|
..
|
||||||
|
} if summary == "Attached image/png image (12 bytes)" && attachments.is_empty()
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
&original[0],
|
||||||
|
Item::ToolResult { attachments, .. } if attachments.len() == 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_skips_already_pruned_items() {
|
fn project_skips_already_pruned_items() {
|
||||||
// indices points at an item whose content is already None.
|
// indices points at an item whose content is already None.
|
||||||
@@ -19,12 +19,12 @@ mod private {
|
|||||||
/// - Editing message history (add, delete, clear)
|
/// - Editing message history (add, delete, clear)
|
||||||
/// - Registering tools and hooks
|
/// - Registering tools and hooks
|
||||||
///
|
///
|
||||||
/// Can transition to [`Locked`] state via `Engine::lock()`.
|
/// Can transition to [`Locked`] state via `Engine::lock(&history)`.
|
||||||
///
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// use llm_engine::Engine;
|
/// use agen::Engine;
|
||||||
///
|
///
|
||||||
/// let mut engine = Engine::new(client)
|
/// let mut engine = Engine::new(client)
|
||||||
/// .system_prompt("You are helpful.");
|
/// .system_prompt("You are helpful.");
|
||||||
@@ -348,7 +348,7 @@ where
|
|||||||
/// # Examples
|
/// # Examples
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// use llm_engine::{Timeline, Handler, TextBlockKind, TextBlockEvent};
|
/// use agen::{Timeline, Handler, TextBlockKind, TextBlockEvent};
|
||||||
///
|
///
|
||||||
/// struct MyHandler;
|
/// struct MyHandler;
|
||||||
/// impl Handler<TextBlockKind> for MyHandler {
|
/// impl Handler<TextBlockKind> for MyHandler {
|
||||||
@@ -3,11 +3,18 @@
|
|||||||
//! Traits for defining tools callable by LLM.
|
//! Traits for defining tools callable by LLM.
|
||||||
//! Usually auto-implemented using the `#[tool]` macro.
|
//! Usually auto-implemented using the `#[tool]` macro.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::{
|
||||||
use std::sync::Arc;
|
collections::HashMap,
|
||||||
|
fmt,
|
||||||
|
future::Future,
|
||||||
|
pin::Pin,
|
||||||
|
sync::Arc,
|
||||||
|
task::{Context, Poll},
|
||||||
|
};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::{Deserialize, Serialize};
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
@@ -23,6 +30,12 @@ pub enum ToolError {
|
|||||||
/// Internal error
|
/// Internal error
|
||||||
#[error("Internal error: {0}")]
|
#[error("Internal error: {0}")]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
|
/// Cooperative cancellation completed with bounded terminal output.
|
||||||
|
#[error("Tool execution cancelled")]
|
||||||
|
Cancelled(ToolOutput),
|
||||||
|
/// Execution was interrupted with a confirmed bounded terminal output.
|
||||||
|
#[error("Tool execution interrupted")]
|
||||||
|
Interrupted(ToolOutput),
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -89,19 +102,112 @@ pub(crate) fn truncate_content(content: &mut String, limit: usize) {
|
|||||||
content.push_str(&suffix_template.replace("%BYTES%", &dropped.to_string()));
|
content.push_str(&suffix_template.replace("%BYTES%", &dropped.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub struct ImageAttachment {
|
||||||
|
mime_type: String,
|
||||||
|
data: Arc<[u8]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageAttachment {
|
||||||
|
pub fn new(mime_type: impl Into<String>, data: impl Into<Arc<[u8]>>) -> Self {
|
||||||
|
Self {
|
||||||
|
mime_type: mime_type.into(),
|
||||||
|
data: data.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mime_type(&self) -> &str {
|
||||||
|
&self.mime_type
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn data(&self) -> &[u8] {
|
||||||
|
&self.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for ImageAttachment {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("ImageAttachment")
|
||||||
|
.field("mime_type", &self.mime_type)
|
||||||
|
.field("bytes", &self.data.len())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct ImageAttachmentWire {
|
||||||
|
mime_type: String,
|
||||||
|
data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for ImageAttachment {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
ImageAttachmentWire {
|
||||||
|
mime_type: self.mime_type.clone(),
|
||||||
|
data: STANDARD.encode(self.data.as_ref()),
|
||||||
|
}
|
||||||
|
.serialize(serializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for ImageAttachment {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let wire = ImageAttachmentWire::deserialize(deserializer)?;
|
||||||
|
let data = STANDARD.decode(wire.data).map_err(D::Error::custom)?;
|
||||||
|
Ok(Self::new(wire.mime_type, data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Durable binary detail emitted by a tool and handled by normal ToolResult pruning.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
|
||||||
|
pub enum Attachment {
|
||||||
|
Image(ImageAttachment),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminal disposition of one started tool call.
|
||||||
|
///
|
||||||
|
/// `Cancelled` means the tool confirmed cancellation. `OutcomeUnknown` means
|
||||||
|
/// execution stopped without confirmation, so neither completion nor side
|
||||||
|
/// effects may be inferred.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ToolResultDisposition {
|
||||||
|
#[default]
|
||||||
|
Success,
|
||||||
|
Error,
|
||||||
|
Interrupted,
|
||||||
|
Cancelled,
|
||||||
|
OutcomeUnknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolResultDisposition {
|
||||||
|
pub const fn is_success(&self) -> bool {
|
||||||
|
matches!(self, Self::Success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Tool execution result.
|
/// Tool execution result.
|
||||||
///
|
///
|
||||||
/// Every output has a mandatory `summary` (1-2 lines) that persists in
|
/// Every output has a mandatory `summary` (1-2 lines) that persists in
|
||||||
/// conversation history even after pruning. The optional `content` carries
|
/// conversation history even after pruning. Optional text and binary details are
|
||||||
/// full details and is removed by the Prune mechanism when the context
|
/// committed to history and may later be omitted only by normal ToolResult pruning.
|
||||||
/// grows too large.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ToolOutput {
|
pub struct ToolOutput {
|
||||||
/// Short summary (1-2 lines). Always remains in history.
|
/// Short summary (1-2 lines). Always remains in history.
|
||||||
pub summary: String,
|
pub summary: String,
|
||||||
/// Detailed output. Removed by Prune when old enough.
|
/// Detailed text output. Removed by Prune when old enough.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub content: Option<String>,
|
pub content: Option<String>,
|
||||||
|
/// Durable binary details handled by the same pruning lifecycle as `content`.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub attachments: Vec<Attachment>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<String> for ToolOutput {
|
impl From<String> for ToolOutput {
|
||||||
@@ -110,6 +216,7 @@ impl From<String> for ToolOutput {
|
|||||||
ToolOutput {
|
ToolOutput {
|
||||||
summary: s,
|
summary: s,
|
||||||
content: None,
|
content: None,
|
||||||
|
attachments: Vec::new(),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let lines = s.lines().count();
|
let lines = s.lines().count();
|
||||||
@@ -118,6 +225,7 @@ impl From<String> for ToolOutput {
|
|||||||
ToolOutput {
|
ToolOutput {
|
||||||
summary,
|
summary,
|
||||||
content: Some(s),
|
content: Some(s),
|
||||||
|
attachments: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,6 +357,12 @@ impl ToolExecutionContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Identifies one live execution attempt without making the batch id a durable
|
||||||
|
/// replay or idempotency authority.
|
||||||
|
pub fn execution_id(&self) -> String {
|
||||||
|
format!("{}:{}", self.batch_id, self.call_id)
|
||||||
|
}
|
||||||
|
|
||||||
/// Context for direct, non-engine calls in unit tests and low-level callers.
|
/// Context for direct, non-engine calls in unit tests and low-level callers.
|
||||||
pub fn direct() -> Self {
|
pub fn direct() -> Self {
|
||||||
Self::new("direct", "direct", 0)
|
Self::new("direct", "direct", 0)
|
||||||
@@ -261,6 +375,142 @@ impl Default for ToolExecutionContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The provider-confirmed terminal result of one started tool execution.
|
||||||
|
///
|
||||||
|
/// `OutcomeUnknown` is reserved for an execution task that had to be force-closed
|
||||||
|
/// or failed before the provider could confirm its terminal result.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ToolExecutionTerminal {
|
||||||
|
Confirmed(Result<ToolOutput, ToolError>),
|
||||||
|
OutcomeUnknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The completion future paired with a [`ToolExecutionHandle`]. Dropping this
|
||||||
|
/// future does not drop the provider execution: the spawned execution remains
|
||||||
|
/// owned by its handle until it completes or is explicitly force-closed.
|
||||||
|
pub struct ToolExecutionTerminalFuture {
|
||||||
|
task: tokio::task::JoinHandle<Result<ToolOutput, ToolError>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Future for ToolExecutionTerminalFuture {
|
||||||
|
type Output = ToolExecutionTerminal;
|
||||||
|
|
||||||
|
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||||
|
match Pin::new(&mut self.task).poll(cx) {
|
||||||
|
Poll::Ready(Ok(result)) => Poll::Ready(ToolExecutionTerminal::Confirmed(result)),
|
||||||
|
Poll::Ready(Err(_)) => Poll::Ready(ToolExecutionTerminal::OutcomeUnknown),
|
||||||
|
Poll::Pending => Poll::Pending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live ownership and control for one started tool execution.
|
||||||
|
///
|
||||||
|
/// Execution, cancellation, and terminal confirmation remain provider-owned:
|
||||||
|
/// this handle starts `Tool::execute`, delegates cooperative cancellation to
|
||||||
|
/// `Tool::cancel_execution`, and treats execution-future completion as the
|
||||||
|
/// provider's terminal confirmation. Agen may force-close only after its caller's
|
||||||
|
/// deadline expires, at which point the outcome is necessarily unknown.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ToolExecutionHandle {
|
||||||
|
inner: Arc<ToolExecutionHandleInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ToolExecutionHandleInner {
|
||||||
|
tool: Arc<dyn Tool>,
|
||||||
|
context: ToolExecutionContext,
|
||||||
|
abort: tokio::task::AbortHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ToolExecutionHandleInner {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Losing the final live owner is an explicit forced close, never a
|
||||||
|
// best-effort detached provider future.
|
||||||
|
self.abort.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for ToolExecutionHandle {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("ToolExecutionHandle")
|
||||||
|
.field("call_id", &self.inner.context.call_id)
|
||||||
|
.field("batch_id", &self.inner.context.batch_id)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolExecutionHandle {
|
||||||
|
pub fn start(
|
||||||
|
tool: Arc<dyn Tool>,
|
||||||
|
input_json: String,
|
||||||
|
context: ToolExecutionContext,
|
||||||
|
) -> (Self, ToolExecutionTerminalFuture) {
|
||||||
|
let execution_tool = Arc::clone(&tool);
|
||||||
|
let execution_context = context.clone();
|
||||||
|
let task =
|
||||||
|
tokio::spawn(
|
||||||
|
async move { execution_tool.execute(&input_json, execution_context).await },
|
||||||
|
);
|
||||||
|
let abort = task.abort_handle();
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(ToolExecutionHandleInner {
|
||||||
|
tool,
|
||||||
|
context,
|
||||||
|
abort,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ToolExecutionTerminalFuture { task },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn context(&self) -> &ToolExecutionContext {
|
||||||
|
&self.inner.context
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cancel_before(&self, deadline: tokio::time::Instant) -> Result<(), ToolError> {
|
||||||
|
match tokio::time::timeout_at(
|
||||||
|
deadline,
|
||||||
|
self.inner.tool.cancel_execution(&self.inner.context),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => Err(ToolError::Internal(format!(
|
||||||
|
"tool cancellation request exceeded its deadline for call {}",
|
||||||
|
self.inner.context.call_id
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn force_close(&self) {
|
||||||
|
self.inner.abort.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ToolExecutionPolicy {
|
||||||
|
/// Time a pause waits for already-started providers to reach a natural safe
|
||||||
|
/// boundary before escalating to explicit cooperative cancellation.
|
||||||
|
pub pause_safe_boundary_timeout: std::time::Duration,
|
||||||
|
/// Maximum time allowed for a provider to accept one cooperative
|
||||||
|
/// cancellation request.
|
||||||
|
pub cancellation_request_timeout: std::time::Duration,
|
||||||
|
/// Maximum time allowed for all providers to confirm terminal results after
|
||||||
|
/// cancellation has been requested.
|
||||||
|
pub terminal_confirmation_timeout: std::time::Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ToolExecutionPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
pause_safe_boundary_timeout: std::time::Duration::from_millis(100),
|
||||||
|
cancellation_request_timeout: std::time::Duration::from_millis(100),
|
||||||
|
terminal_confirmation_timeout: std::time::Duration::from_millis(500),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Tool trait
|
// Tool trait
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -291,7 +541,7 @@ impl Default for ToolExecutionContext {
|
|||||||
/// # Manual Implementation
|
/// # Manual Implementation
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// use llm_engine::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolDefinition, ToolOutput};
|
/// use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolDefinition, ToolOutput};
|
||||||
/// use std::sync::Arc;
|
/// use std::sync::Arc;
|
||||||
///
|
///
|
||||||
/// struct MyTool { counter: std::sync::atomic::AtomicUsize }
|
/// struct MyTool { counter: std::sync::atomic::AtomicUsize }
|
||||||
@@ -329,6 +579,26 @@ pub trait Tool: Send + Sync {
|
|||||||
input_json: &str,
|
input_json: &str,
|
||||||
ctx: ToolExecutionContext,
|
ctx: ToolExecutionContext,
|
||||||
) -> Result<ToolOutput, ToolError>;
|
) -> Result<ToolOutput, ToolError>;
|
||||||
|
|
||||||
|
/// Request cooperative cancellation for one started call.
|
||||||
|
///
|
||||||
|
/// Implementations that own cancellable provider operations should signal
|
||||||
|
/// every live execution identified by `call_id`, then let `execute` return
|
||||||
|
/// the confirmed bounded terminal output. Direct callers may use this
|
||||||
|
/// compatibility surface; Agen uses [`Tool::cancel_execution`] so providers
|
||||||
|
/// can bind cancellation to one exact live attempt.
|
||||||
|
async fn cancel(&self, _call_id: &str) -> Result<(), ToolError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request cooperative cancellation for one exact started execution.
|
||||||
|
///
|
||||||
|
/// The default preserves existing tools by delegating to `cancel(call_id)`.
|
||||||
|
/// Providers with their own execution registry should override this method
|
||||||
|
/// and key cancellation by [`ToolExecutionContext::execution_id`].
|
||||||
|
async fn cancel_execution(&self, ctx: &ToolExecutionContext) -> Result<(), ToolError> {
|
||||||
|
self.cancel(&ctx.call_id).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -356,6 +626,9 @@ pub struct ToolCall {
|
|||||||
pub struct ToolResult {
|
pub struct ToolResult {
|
||||||
/// Corresponding tool call ID
|
/// Corresponding tool call ID
|
||||||
pub tool_use_id: String,
|
pub tool_use_id: String,
|
||||||
|
/// Typed terminal state.
|
||||||
|
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
|
||||||
|
pub disposition: ToolResultDisposition,
|
||||||
/// Short summary (always kept in history)
|
/// Short summary (always kept in history)
|
||||||
pub summary: String,
|
pub summary: String,
|
||||||
/// Detailed output (prunable)
|
/// Detailed output (prunable)
|
||||||
@@ -364,16 +637,29 @@ pub struct ToolResult {
|
|||||||
/// Whether this is an error
|
/// Whether this is an error
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub is_error: bool,
|
pub is_error: bool,
|
||||||
|
/// Durable binary details (prunable with `content`).
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub attachments: Vec<Attachment>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolResult {
|
impl ToolResult {
|
||||||
/// Create a success result from a [`ToolOutput`].
|
/// Create a success result from a [`ToolOutput`].
|
||||||
pub fn from_output(tool_use_id: impl Into<String>, output: ToolOutput) -> Self {
|
pub fn from_output(tool_use_id: impl Into<String>, output: ToolOutput) -> Self {
|
||||||
|
Self::from_output_with_disposition(tool_use_id, output, ToolResultDisposition::Success)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_output_with_disposition(
|
||||||
|
tool_use_id: impl Into<String>,
|
||||||
|
output: ToolOutput,
|
||||||
|
disposition: ToolResultDisposition,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
tool_use_id: tool_use_id.into(),
|
tool_use_id: tool_use_id.into(),
|
||||||
|
disposition,
|
||||||
summary: output.summary,
|
summary: output.summary,
|
||||||
content: output.content,
|
content: output.content,
|
||||||
is_error: false,
|
is_error: !disposition.is_success(),
|
||||||
|
attachments: output.attachments,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,9 +667,26 @@ impl ToolResult {
|
|||||||
pub fn error(tool_use_id: impl Into<String>, message: impl Into<String>) -> Self {
|
pub fn error(tool_use_id: impl Into<String>, message: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
tool_use_id: tool_use_id.into(),
|
tool_use_id: tool_use_id.into(),
|
||||||
|
disposition: ToolResultDisposition::Error,
|
||||||
summary: message.into(),
|
summary: message.into(),
|
||||||
content: None,
|
content: None,
|
||||||
is_error: true,
|
is_error: true,
|
||||||
|
attachments: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close an execution whose completion and side effects cannot be confirmed.
|
||||||
|
pub fn outcome_unknown(tool_use_id: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
tool_use_id: tool_use_id.into(),
|
||||||
|
disposition: ToolResultDisposition::OutcomeUnknown,
|
||||||
|
summary: "Tool execution outcome unknown".to_string(),
|
||||||
|
content: Some(
|
||||||
|
"Execution was interrupted before completion could be confirmed. Completion and side effects are unknown."
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
is_error: true,
|
||||||
|
attachments: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
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![
|
||||||
|
Event::text_block_start(0),
|
||||||
|
Event::text_delta(0, text),
|
||||||
|
Event::text_block_stop(0, None),
|
||||||
|
Event::Status(StatusEvent {
|
||||||
|
status: ResponseStatus::Completed,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn run_preserves_item_annotations_without_projecting_them() {
|
||||||
|
let client = MockLlmClient::new(completed_text_events("assistant reply"));
|
||||||
|
let engine = Engine::<_, agen::state::Mutable, String>::new_annotated(client);
|
||||||
|
let mut history = History::<String>::new();
|
||||||
|
let mut next = 0usize;
|
||||||
|
let mut annotate = |item: &Item| {
|
||||||
|
next += 1;
|
||||||
|
let kind = match item {
|
||||||
|
Item::Message { role, .. } => match role {
|
||||||
|
Role::User => "user",
|
||||||
|
Role::Assistant => "assistant",
|
||||||
|
Role::System => "system",
|
||||||
|
},
|
||||||
|
Item::ToolCall { .. } => "tool_call",
|
||||||
|
Item::ToolResult { .. } => "tool_result",
|
||||||
|
Item::Reasoning { .. } => "reasoning",
|
||||||
|
};
|
||||||
|
Ok(format!("{next}:{kind}"))
|
||||||
|
};
|
||||||
|
|
||||||
|
let output = engine
|
||||||
|
.run_with_annotation(&mut history, "hello", &mut annotate)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(matches!(output.result, agen::EngineRunExit::Finished));
|
||||||
|
assert_eq!(history.len(), 2);
|
||||||
|
assert_eq!(history.entries()[0].annotation, "1:user");
|
||||||
|
assert_eq!(history.entries()[1].annotation, "2:assistant");
|
||||||
|
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![]);
|
||||||
|
let mut engine = Engine::<_, agen::state::Mutable, usize>::new_annotated(client);
|
||||||
|
let mut history = History::<usize>::new();
|
||||||
|
let mut fail = |_item: &Item| Err("commit failed".to_string());
|
||||||
|
|
||||||
|
let err = engine
|
||||||
|
.append_history_with(&mut history, [Item::user_message("uncommitted")], &mut fail)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(err, EngineError::HistoryAppend(message) if message == "commit failed"));
|
||||||
|
assert!(history.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replacement_keeps_items_and_annotations_together() {
|
||||||
|
let mut history = History::from_entries(vec![
|
||||||
|
HistoryEntry::new(Item::user_message("old"), "old-ann".to_string()),
|
||||||
|
HistoryEntry::new(Item::user_message("second"), "second-ann".to_string()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
history.truncate(1);
|
||||||
|
assert_eq!(history.entries()[0].item.as_text(), Some("old"));
|
||||||
|
assert_eq!(history.entries()[0].annotation, "old-ann");
|
||||||
|
|
||||||
|
let previous = history.replace_entries(vec![HistoryEntry::new(
|
||||||
|
Item::user_message("restored"),
|
||||||
|
"restored-ann".to_string(),
|
||||||
|
)]);
|
||||||
|
|
||||||
|
assert_eq!(previous.len(), 1);
|
||||||
|
assert_eq!(history.entries()[0].item.as_text(), Some("restored"));
|
||||||
|
assert_eq!(history.entries()[0].annotation, "restored-ann");
|
||||||
|
}
|
||||||
@@ -8,13 +8,13 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use agen::Engine;
|
||||||
|
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent as ClientStatusEvent};
|
||||||
|
use agen::llm_client::retry::RetryPolicy;
|
||||||
|
use agen::llm_client::{ClientError, LlmClient, Request, ResponseStream};
|
||||||
|
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use common::MockLlmClient;
|
use common::MockLlmClient;
|
||||||
use llm_engine::Engine;
|
|
||||||
use llm_engine::llm_client::event::{Event, ResponseStatus, StatusEvent as ClientStatusEvent};
|
|
||||||
use llm_engine::llm_client::retry::RetryPolicy;
|
|
||||||
use llm_engine::llm_client::{ClientError, LlmClient, Request, ResponseStream};
|
|
||||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct FailOnceClient {
|
struct FailOnceClient {
|
||||||
@@ -58,6 +58,7 @@ async fn test_callback_llm_retry_event() {
|
|||||||
max_attempts: 2,
|
max_attempts: 2,
|
||||||
total_timeout: Duration::from_secs(1),
|
total_timeout: Duration::from_secs(1),
|
||||||
});
|
});
|
||||||
|
let mut history = agen::History::new();
|
||||||
|
|
||||||
let notices = Arc::new(Mutex::new(Vec::new()));
|
let notices = Arc::new(Mutex::new(Vec::new()));
|
||||||
let sink = notices.clone();
|
let sink = notices.clone();
|
||||||
@@ -65,8 +66,11 @@ async fn test_callback_llm_retry_event() {
|
|||||||
sink.lock().unwrap().push((llm_call, notice.clone()));
|
sink.lock().unwrap().push((llm_call, notice.clone()));
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = engine.run("retry once").await;
|
let result = engine.run(&mut history, "retry once").await;
|
||||||
assert!(result.is_ok(), "engine should succeed after one retry");
|
assert!(
|
||||||
|
matches!(result.result, agen::EngineRunExit::Finished),
|
||||||
|
"engine should succeed after one retry"
|
||||||
|
);
|
||||||
|
|
||||||
let notices = notices.lock().unwrap();
|
let notices = notices.lock().unwrap();
|
||||||
assert_eq!(notices.len(), 1);
|
assert_eq!(notices.len(), 1);
|
||||||
@@ -91,6 +95,7 @@ async fn test_callback_text_block_events() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history = agen::History::new();
|
||||||
|
|
||||||
let text_deltas = Arc::new(Mutex::new(Vec::new()));
|
let text_deltas = Arc::new(Mutex::new(Vec::new()));
|
||||||
let text_completes = Arc::new(Mutex::new(Vec::new()));
|
let text_completes = Arc::new(Mutex::new(Vec::new()));
|
||||||
@@ -108,9 +113,12 @@ async fn test_callback_text_block_events() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
||||||
let result = engine.run("Greet me").await;
|
let result = engine.run(&mut history, "Greet me").await;
|
||||||
assert!(result.is_ok(), "Engine should complete");
|
assert!(
|
||||||
|
matches!(result.result, agen::EngineRunExit::Finished),
|
||||||
|
"Engine should complete"
|
||||||
|
);
|
||||||
|
|
||||||
let deltas = text_deltas.lock().unwrap();
|
let deltas = text_deltas.lock().unwrap();
|
||||||
assert_eq!(deltas.len(), 2);
|
assert_eq!(deltas.len(), 2);
|
||||||
@@ -137,6 +145,7 @@ async fn test_callback_tool_call_complete() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history = agen::History::new();
|
||||||
|
|
||||||
let tool_starts = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
|
let tool_starts = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
|
||||||
let tool_completes = Arc::new(Mutex::new(Vec::new()));
|
let tool_completes = Arc::new(Mutex::new(Vec::new()));
|
||||||
@@ -154,8 +163,8 @@ async fn test_callback_tool_call_complete() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
||||||
let _ = engine.run("Weather please").await;
|
let _ = engine.run(&mut history, "Weather please").await;
|
||||||
|
|
||||||
let starts = tool_starts.lock().unwrap();
|
let starts = tool_starts.lock().unwrap();
|
||||||
assert_eq!(starts.len(), 1);
|
assert_eq!(starts.len(), 1);
|
||||||
@@ -183,6 +192,7 @@ async fn test_callback_turn_events() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history = agen::History::new();
|
||||||
|
|
||||||
let turn_starts = Arc::new(Mutex::new(Vec::new()));
|
let turn_starts = Arc::new(Mutex::new(Vec::new()));
|
||||||
let turn_ends = Arc::new(Mutex::new(Vec::new()));
|
let turn_ends = Arc::new(Mutex::new(Vec::new()));
|
||||||
@@ -197,9 +207,9 @@ async fn test_callback_turn_events() {
|
|||||||
ends.lock().unwrap().push(turn);
|
ends.lock().unwrap().push(turn);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
||||||
let result = engine.run("Do something").await;
|
let result = engine.run(&mut history, "Do something").await;
|
||||||
assert!(result.is_ok());
|
assert!(matches!(result.result, agen::EngineRunExit::Finished));
|
||||||
|
|
||||||
let starts = turn_starts.lock().unwrap();
|
let starts = turn_starts.lock().unwrap();
|
||||||
let ends = turn_ends.lock().unwrap();
|
let ends = turn_ends.lock().unwrap();
|
||||||
@@ -221,7 +231,7 @@ impl Tool for FixedOutputTool {
|
|||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
_input_json: &str,
|
_input_json: &str,
|
||||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
_ctx: agen::tool::ToolExecutionContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
Ok(self.output.clone())
|
Ok(self.output.clone())
|
||||||
}
|
}
|
||||||
@@ -254,12 +264,14 @@ async fn test_callback_tool_result_events() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history = agen::History::new();
|
||||||
|
|
||||||
engine.register_tool(fixed_tool(
|
engine.register_tool(fixed_tool(
|
||||||
"fixed",
|
"fixed",
|
||||||
ToolOutput {
|
ToolOutput {
|
||||||
summary: "did the thing".into(),
|
summary: "did the thing".into(),
|
||||||
content: Some("full detail body".into()),
|
content: Some("full detail body".into()),
|
||||||
|
attachments: Vec::new(),
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -275,7 +287,7 @@ async fn test_callback_tool_result_events() {
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
let _ = engine.run("call it").await;
|
let _ = engine.run(&mut history, "call it").await;
|
||||||
|
|
||||||
let observed = captured.lock().unwrap();
|
let observed = captured.lock().unwrap();
|
||||||
assert_eq!(observed.len(), 1);
|
assert_eq!(observed.len(), 1);
|
||||||
@@ -296,7 +308,7 @@ impl Tool for ErroringTool {
|
|||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
_input_json: &str,
|
_input_json: &str,
|
||||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
_ctx: agen::tool::ToolExecutionContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
Err(ToolError::ExecutionFailed(self.message.clone()))
|
Err(ToolError::ExecutionFailed(self.message.clone()))
|
||||||
}
|
}
|
||||||
@@ -329,6 +341,7 @@ async fn test_callback_tool_result_error_path() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history = agen::History::new();
|
||||||
|
|
||||||
engine.register_tool(erroring_tool("erroring", "boom"));
|
engine.register_tool(erroring_tool("erroring", "boom"));
|
||||||
|
|
||||||
@@ -344,7 +357,7 @@ async fn test_callback_tool_result_error_path() {
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
let _ = engine.run("fail it").await;
|
let _ = engine.run(&mut history, "fail it").await;
|
||||||
|
|
||||||
let observed = captured.lock().unwrap();
|
let observed = captured.lock().unwrap();
|
||||||
assert_eq!(observed.len(), 1);
|
assert_eq!(observed.len(), 1);
|
||||||
@@ -373,6 +386,7 @@ async fn test_callback_usage_events() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history = agen::History::new();
|
||||||
|
|
||||||
let usage_events = Arc::new(Mutex::new(Vec::new()));
|
let usage_events = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
|
||||||
@@ -381,8 +395,8 @@ async fn test_callback_usage_events() {
|
|||||||
usages.lock().unwrap().push(event.clone());
|
usages.lock().unwrap().push(event.clone());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutable::run consumes self, returns (Locked, EngineResult)
|
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
||||||
let _ = engine.run("Hello").await;
|
let _ = engine.run(&mut history, "Hello").await;
|
||||||
|
|
||||||
let usages = usage_events.lock().unwrap();
|
let usages = usage_events.lock().unwrap();
|
||||||
assert_eq!(usages.len(), 1);
|
assert_eq!(usages.len(), 1);
|
||||||
@@ -6,11 +6,11 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use agen::llm_client::event::{BlockType, DeltaContent, Event};
|
||||||
|
use agen::llm_client::{ClientError, LlmClient, Request};
|
||||||
|
use agen::timeline::{Handler, TextBlockEvent, TextBlockKind, Timeline};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures::Stream;
|
use futures::Stream;
|
||||||
use llm_engine::llm_client::event::{BlockType, DeltaContent, Event};
|
|
||||||
use llm_engine::llm_client::{ClientError, LlmClient, Request};
|
|
||||||
use llm_engine::timeline::{Handler, TextBlockEvent, TextBlockKind, Timeline};
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
|||||||
pub struct MockLlmClient {
|
pub struct MockLlmClient {
|
||||||
responses: Arc<Vec<Vec<Event>>>,
|
responses: Arc<Vec<Vec<Event>>>,
|
||||||
call_count: Arc<AtomicUsize>,
|
call_count: Arc<AtomicUsize>,
|
||||||
|
requests: Arc<Mutex<Vec<Request>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MockLlmClient {
|
impl MockLlmClient {
|
||||||
@@ -30,6 +31,7 @@ impl MockLlmClient {
|
|||||||
Self {
|
Self {
|
||||||
responses: Arc::new(responses),
|
responses: Arc::new(responses),
|
||||||
call_count: Arc::new(AtomicUsize::new(0)),
|
call_count: Arc::new(AtomicUsize::new(0)),
|
||||||
|
requests: Arc::new(Mutex::new(Vec::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +43,10 @@ impl MockLlmClient {
|
|||||||
pub fn event_count(&self) -> usize {
|
pub fn event_count(&self) -> usize {
|
||||||
self.responses.iter().map(|v| v.len()).sum()
|
self.responses.iter().map(|v| v.len()).sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn requests(&self) -> Vec<Request> {
|
||||||
|
self.requests.lock().unwrap().clone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -51,8 +57,9 @@ impl LlmClient for MockLlmClient {
|
|||||||
|
|
||||||
async fn stream(
|
async fn stream(
|
||||||
&self,
|
&self,
|
||||||
_request: Request,
|
request: Request,
|
||||||
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
|
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
|
||||||
|
self.requests.lock().unwrap().push(request);
|
||||||
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
|
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
|
||||||
if count >= self.responses.len() {
|
if count >= self.responses.len() {
|
||||||
return Err(ClientError::Api {
|
return Err(ClientError::Api {
|
||||||
@@ -272,7 +279,7 @@ pub fn assert_timeline_integration(subdir: &str) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for event in &events {
|
for event in &events {
|
||||||
let timeline_event: llm_engine::timeline::event::Event = event.clone().into();
|
let timeline_event: agen::timeline::event::Event = event.clone().into();
|
||||||
timeline.dispatch(&timeline_event);
|
timeline.dispatch(&timeline_event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9,10 +9,10 @@ use std::path::Path;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
use agen::Engine;
|
||||||
|
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use common::MockLlmClient;
|
use common::MockLlmClient;
|
||||||
use llm_engine::Engine;
|
|
||||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
|
||||||
|
|
||||||
/// Fixture directory path
|
/// Fixture directory path
|
||||||
fn fixtures_dir() -> std::path::PathBuf {
|
fn fixtures_dir() -> std::path::PathBuf {
|
||||||
@@ -61,7 +61,7 @@ impl Tool for MockWeatherTool {
|
|||||||
async fn execute(
|
async fn execute(
|
||||||
&self,
|
&self,
|
||||||
input_json: &str,
|
input_json: &str,
|
||||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
_ctx: agen::tool::ToolExecutionContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> Result<ToolOutput, ToolError> {
|
||||||
self.call_count.fetch_add(1, Ordering::SeqCst);
|
self.call_count.fetch_add(1, Ordering::SeqCst);
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ fn test_mock_client_from_fixture() {
|
|||||||
/// Creates a client with programmatically constructed events instead of using fixture files.
|
/// Creates a client with programmatically constructed events instead of using fixture files.
|
||||||
#[test]
|
#[test]
|
||||||
fn test_mock_client_from_events() {
|
fn test_mock_client_from_events() {
|
||||||
use llm_engine::llm_client::event::Event;
|
use agen::llm_client::event::Event;
|
||||||
|
|
||||||
// Specify events directly
|
// Specify events directly
|
||||||
let events = vec![
|
let events = vec![
|
||||||
@@ -134,11 +134,15 @@ async fn test_engine_simple_text_response() {
|
|||||||
|
|
||||||
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
|
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
|
||||||
let engine = Engine::new(client);
|
let engine = Engine::new(client);
|
||||||
|
let mut history = agen::History::new();
|
||||||
|
|
||||||
// Send a simple message (Mutable::run consumes self, returns tuple)
|
// Send a simple message (Mutable::run consumes self, returns tuple)
|
||||||
let result = engine.run("Hello").await;
|
let result = engine.run(&mut history, "Hello").await;
|
||||||
|
|
||||||
assert!(result.is_ok(), "Engine should complete successfully");
|
assert!(
|
||||||
|
matches!(result.result, agen::EngineRunExit::Finished),
|
||||||
|
"Engine should complete successfully"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify that Engine can correctly process responses containing tool calls
|
/// Verify that Engine can correctly process responses containing tool calls
|
||||||
@@ -156,6 +160,7 @@ async fn test_engine_tool_call() {
|
|||||||
|
|
||||||
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
|
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
|
||||||
let mut engine = Engine::new(client);
|
let mut engine = Engine::new(client);
|
||||||
|
let mut history = agen::History::new();
|
||||||
|
|
||||||
// Register tool
|
// Register tool
|
||||||
let weather_tool = MockWeatherTool::new();
|
let weather_tool = MockWeatherTool::new();
|
||||||
@@ -163,7 +168,9 @@ async fn test_engine_tool_call() {
|
|||||||
engine.register_tool(weather_tool.definition());
|
engine.register_tool(weather_tool.definition());
|
||||||
|
|
||||||
// Send message (Mutable::run consumes self, returns tuple)
|
// Send message (Mutable::run consumes self, returns tuple)
|
||||||
let _result = engine.run("What's the weather in Tokyo?").await;
|
let _result = engine
|
||||||
|
.run(&mut history, "What's the weather in Tokyo?")
|
||||||
|
.await;
|
||||||
|
|
||||||
// Verify tool was called
|
// Verify tool was called
|
||||||
// Note: max_turns=1 so no request is sent after tool result
|
// Note: max_turns=1 so no request is sent after tool result
|
||||||
@@ -180,7 +187,7 @@ async fn test_engine_tool_call() {
|
|||||||
/// Useful when test independence is needed and external file dependency should be eliminated.
|
/// Useful when test independence is needed and external file dependency should be eliminated.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_engine_with_programmatic_events() {
|
async fn test_engine_with_programmatic_events() {
|
||||||
use llm_engine::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||||
|
|
||||||
// Construct event sequence programmatically
|
// Construct event sequence programmatically
|
||||||
let events = vec![
|
let events = vec![
|
||||||
@@ -195,11 +202,15 @@ async fn test_engine_with_programmatic_events() {
|
|||||||
|
|
||||||
let client = MockLlmClient::new(events);
|
let client = MockLlmClient::new(events);
|
||||||
let engine = Engine::new(client);
|
let engine = Engine::new(client);
|
||||||
|
let mut history = agen::History::new();
|
||||||
|
|
||||||
// Mutable::run consumes self, returns tuple
|
// Mutable::run consumes self, returns tuple
|
||||||
let result = engine.run("Greet me").await;
|
let result = engine.run(&mut history, "Greet me").await;
|
||||||
|
|
||||||
assert!(result.is_ok(), "Engine should complete successfully");
|
assert!(
|
||||||
|
matches!(result.result, agen::EngineRunExit::Finished),
|
||||||
|
"Engine should complete successfully"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify that ToolCallCollector correctly collects ToolCall from ToolUse block events
|
/// Verify that ToolCallCollector correctly collects ToolCall from ToolUse block events
|
||||||
@@ -208,8 +219,8 @@ async fn test_engine_with_programmatic_events() {
|
|||||||
/// correctly extracts id, name, and input (JSON).
|
/// correctly extracts id, name, and input (JSON).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_tool_call_collector_integration() {
|
async fn test_tool_call_collector_integration() {
|
||||||
use llm_engine::llm_client::event::Event;
|
use agen::llm_client::event::Event;
|
||||||
use llm_engine::timeline::{Timeline, ToolCallCollector};
|
use agen::timeline::{Timeline, ToolCallCollector};
|
||||||
|
|
||||||
// Event sequence containing ToolUse block
|
// Event sequence containing ToolUse block
|
||||||
let events = vec![
|
let events = vec![
|
||||||
@@ -225,7 +236,7 @@ async fn test_tool_call_collector_integration() {
|
|||||||
|
|
||||||
// Dispatch events
|
// Dispatch events
|
||||||
for event in &events {
|
for event in &events {
|
||||||
let timeline_event: llm_engine::timeline::event::Event = event.clone().into();
|
let timeline_event: agen::timeline::event::Event = event.clone().into();
|
||||||
timeline.dispatch(&timeline_event);
|
timeline.dispatch(&timeline_event);
|
||||||
}
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user