Compare commits
584
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24320f22a6 | ||
|
|
98cf218cc3 | ||
|
|
7abe73e7bd | ||
|
|
a05ddacff3 | ||
|
|
20b18de60a | ||
|
|
d5c98dd62f | ||
|
|
61d08fa05a | ||
|
|
1ea2e713d8 | ||
|
|
38dce57666 | ||
|
|
8f255b8bde | ||
|
|
6b97d6668b | ||
|
|
3fa1d70379 | ||
|
|
2dd05a7aa6 | ||
|
|
02227edc23 | ||
|
|
a2297a84ad | ||
|
|
05cbd8adc7 | ||
|
|
984731b111 | ||
|
|
0da03c18d1 | ||
|
|
302b8fc8cd | ||
|
|
59239635cd | ||
|
|
238105a7da | ||
|
|
d7ca5f7e68 | ||
|
|
827135a02d | ||
|
|
a3ddf53207 | ||
|
|
35ac9015ad | ||
|
|
1c196ef675 | ||
|
|
2ce3b5a9b3 | ||
|
|
7421fd058d | ||
|
|
368e9df551 | ||
|
|
e9629046cb | ||
|
|
b5ccc419f6 | ||
|
|
615723da2a | ||
|
|
d1bcd5beff | ||
|
|
519dc583a0 | ||
|
|
0bf883cf56 | ||
|
|
c8502c735c | ||
|
|
48af97fbdd | ||
|
|
13bcf87ea5 | ||
|
|
e271fbb1e1 | ||
|
|
5678686c57 | ||
|
|
9fc3fd1aa5 | ||
|
|
e3a9c18cb3 | ||
|
|
9534b041db | ||
|
|
9426fa40a9 | ||
|
|
51e2b23082 | ||
|
|
aece914fb8 | ||
|
|
762e312730 | ||
|
|
e9eb529d6e | ||
|
|
e39a2b7dda | ||
|
|
cdc6baa4c0 | ||
|
|
255ab8dbf3 | ||
|
|
a76dc42bd8 | ||
|
|
f0cbd71129 | ||
|
|
aa10c3e093 | ||
|
|
0d836121d0 | ||
|
|
6e8ecbf5e5 | ||
|
|
8088ff197e | ||
|
|
55ee967f2f | ||
|
|
a56bd614bd | ||
|
|
992e8470b2 | ||
|
|
fe2bd8493f | ||
|
|
e43820c5ce | ||
|
|
662e30cb93 | ||
|
|
a51675e58d | ||
|
|
820b1dc643 | ||
|
|
94f2ae81c3 | ||
|
|
9020100313 | ||
|
|
d097940692 | ||
|
|
28dfcc8aec | ||
|
|
7dd0c8e7a4 | ||
|
|
bed21ad5c0 | ||
|
|
2622140529 | ||
|
|
3f9dc7a270 | ||
|
|
b5633c7fab | ||
|
|
5d053fedf4 | ||
|
|
d924694602 | ||
|
|
cf8a48ae0d | ||
|
|
24e865aa07 | ||
|
|
5d0dbbbe92 | ||
|
|
f2277e35b4 | ||
|
|
e8a642c5e2 | ||
|
|
399bdea220 | ||
|
|
9a0205758b | ||
|
|
0507e1af9c | ||
|
|
f893744ed1 | ||
|
|
e455e89777 | ||
|
|
9b60da96b0 | ||
|
|
34c0e7a0f4 | ||
|
|
550ce99bcd | ||
|
|
e81411c4a4 | ||
|
|
688a7e49db | ||
|
|
2ce997ca73 | ||
|
|
075967c395 | ||
|
|
1efd8bd25d | ||
|
|
e2bfd83481 | ||
|
|
2d5f0c69d0 | ||
|
|
339159a194 | ||
|
|
ad905e0bc5 | ||
|
|
721817515a | ||
|
|
73de357c9f | ||
|
|
b1e635d00b | ||
|
|
e489b5511a | ||
|
|
79409fb5c8 | ||
|
|
e9bfe12293 | ||
|
|
0833a7a0ca | ||
|
|
dfecab7c34 | ||
|
|
2eb57675c8 | ||
|
|
98f1454b3c | ||
|
|
d274768bcc | ||
|
|
ba31326302 | ||
|
|
f4e674fa9b | ||
|
|
1c7447b7e3 | ||
|
|
2d24c011aa | ||
|
|
b9b082b802 | ||
|
|
74dd9922a5 | ||
|
|
4298b63d91 | ||
|
|
c48fba7d8d | ||
|
|
7b71216a05 | ||
|
|
35204177e9 | ||
|
|
fc39278c91 | ||
|
|
eefd54137c | ||
|
|
880cd055dd | ||
|
|
ce07163541 | ||
|
|
6bd9acac6e | ||
|
|
1be14da122 | ||
|
|
96a61a46d0 | ||
|
|
4f4e43c953 | ||
|
|
306a35e294 | ||
|
|
53a0bdb2e3 | ||
|
|
bf73dcd1ea | ||
|
|
6409a9d442 | ||
|
|
96f96bd130 | ||
|
|
a8ce97c807 | ||
|
|
0804a69b4f | ||
|
|
e816b70f46 | ||
|
|
8adcd86cad | ||
|
|
9d2db45dd5 | ||
|
|
111276fb22 | ||
|
|
cad4623232 | ||
|
|
ececb58025 | ||
|
|
4a9ba3b7d4 | ||
|
|
b029adfb43 | ||
|
|
d0651474ab | ||
|
|
ca9db836de | ||
|
|
44ea97b938 | ||
|
|
84f5ab6c47 | ||
|
|
153372c2de | ||
|
|
7384b8922b | ||
|
|
295523b470 | ||
|
|
0991a24739 | ||
|
|
420c52b10e | ||
|
|
1e3ed210d7 | ||
|
|
947a75b771 | ||
|
|
c84ddad051 | ||
|
|
71b63ac6f9 | ||
|
|
659eb4ecfc | ||
|
|
2ff2c7db09 | ||
|
|
475e216ee0 | ||
|
|
226056fb46 | ||
|
|
580d18a41c | ||
|
|
0abd6c2a11 | ||
|
|
def43ecfc3 | ||
|
|
c4bfcf032a | ||
|
|
4a3a7d4812 | ||
|
|
dbe3e023ec | ||
|
|
c63110ae7d | ||
|
|
101f8beadb | ||
|
|
b16925b386 | ||
|
|
be5a146cca | ||
|
|
094fdcc51e | ||
|
|
307ad4a587 | ||
|
|
e867ba3c93 | ||
|
|
c3027c5901 | ||
|
|
30bf2d6e67 | ||
|
|
7fcab3bf31 | ||
|
|
eca8475e29 | ||
|
|
b340d46cdd | ||
|
|
ac721216ea | ||
|
|
e314d02212 | ||
|
|
6394e45bf2 | ||
|
|
ba5cc60c15 | ||
|
|
a0fed9956c | ||
|
|
58e9c97d76 | ||
|
|
ba154e4ec2 | ||
|
|
c4d7795280 | ||
|
|
25323d4ac1 | ||
|
|
15133cd0fc | ||
|
|
813554dc80 | ||
|
|
bd8980bc3c | ||
|
|
62a3713e1e | ||
|
|
1fb03de7be | ||
|
|
fa92a10eb7 | ||
|
|
bf20f247eb | ||
|
|
560b51e2f6 | ||
|
|
d19fcceff3 | ||
|
|
8c6f23dc49 | ||
|
|
19c58b6d25 | ||
|
|
e9d5b1ca8a | ||
|
|
444d9d825a | ||
|
|
a2ff01050e | ||
|
|
443e0ceb2e | ||
|
|
3e91ae503c | ||
|
|
bf6f2365d4 | ||
|
|
9b7091bbe9 | ||
|
|
12725511c0 | ||
|
|
3c46a5f07f | ||
|
|
b0a8b3e5a7 | ||
|
|
f6f68ffb04 | ||
|
|
5c98bc3568 | ||
|
|
de1d2f5107 | ||
|
|
bcbd41f217 | ||
|
|
f5616fe3ea | ||
|
|
94baf98060 | ||
|
|
c499ae090e | ||
|
|
e132ec6ba9 | ||
|
|
344f8ad106 | ||
|
|
080bd44021 | ||
|
|
318a68c3dd | ||
|
|
73d7ffd012 | ||
|
|
51db6377a4 | ||
|
|
c3b4283101 | ||
|
|
ffd7ddcce5 | ||
|
|
dd83442ff9 | ||
|
|
f1c3abb3b7 | ||
|
|
a56957138d | ||
|
|
5058d5ac0e | ||
|
|
00d7f70ffb | ||
|
|
c09be4c9b5 | ||
|
|
976edccfd8 | ||
|
|
1eb3e39286 | ||
|
|
15088d1d7d | ||
|
|
4cdd1a5276 | ||
|
|
e93f810946 | ||
|
|
459d836468 | ||
|
|
52058459f6 | ||
|
|
9b91be0764 | ||
|
|
e4f8d6fa70 | ||
|
|
d7151f1d78 | ||
|
|
d357e7714a | ||
|
|
9160654a07 | ||
|
|
29dad403ec | ||
|
|
041c1ce849 | ||
|
|
ba162f6433 | ||
|
|
3ca7ffab16 | ||
|
|
615a3e4765 | ||
|
|
c459cd563c | ||
|
|
b2753a64c4 | ||
|
|
649dd602dd | ||
|
|
7a004e524a | ||
|
|
cb78c35f24 | ||
|
|
b1a79ede91 | ||
|
|
e012694108 | ||
|
|
c4f3f02933 | ||
|
|
102a57aaff | ||
|
|
381e4d52e8 | ||
|
|
c8dc9edbdc | ||
|
|
a0b0b32f0a | ||
|
|
1f2068512e | ||
|
|
b97c910434 | ||
|
|
b789878d01 | ||
|
|
705e4a8ed6 | ||
|
|
531dfa85ad | ||
|
|
6e215b5b57 | ||
|
|
d46c0cf719 | ||
|
|
3eecc710ed | ||
|
|
1b4284592b | ||
|
|
d2dd38b9ae | ||
|
|
fb6b5e9677 | ||
|
|
40735ce9c1 | ||
|
|
9f40c6a793 | ||
|
|
a9dbc3b624 | ||
|
|
d4b5416126 | ||
|
|
7c4981ea09 | ||
|
|
5e8286695f | ||
|
|
2b11baa170 | ||
|
|
8868258afd | ||
|
|
90cb2b1590 | ||
|
|
3d97b348f7 | ||
|
|
3eea9e58cd | ||
|
|
12ab124411 | ||
|
|
0886342031 | ||
|
|
3bfe76278b | ||
|
|
067facdfa4 | ||
|
|
f2e520e31e | ||
|
|
cf91807fab | ||
|
|
c925089fd2 | ||
|
|
a2d0a3bf19 | ||
|
|
c1cde67bcf | ||
|
|
aeafc6e0ac | ||
|
|
1f5569db15 | ||
|
|
2b8cea5aa8 | ||
|
|
7b09b3b2ec | ||
|
|
9cc5101a30 | ||
|
|
f90ed7af35 | ||
|
|
6f61601d77 | ||
|
|
259e7343e3 | ||
|
|
cdc72e17be | ||
|
|
931a0b0f0c | ||
|
|
e70be0103a | ||
|
|
2353f1f3bb | ||
|
|
355ba7db56 | ||
|
|
4839f139e8 | ||
|
|
ea90bf0fab | ||
|
|
7978b495d1 | ||
|
|
d73d4fc118 | ||
|
|
c81e2269e1 | ||
|
|
47327cc6d8 | ||
|
|
d0ede46352 | ||
|
|
e4ba1cb5fa | ||
|
|
90104d2553 | ||
|
|
ca0d002073 | ||
|
|
20db36f52e | ||
|
|
383d4c6e4f | ||
|
|
f3ddb45fe5 | ||
|
|
6061eb5c13 | ||
|
|
0e46a64702 | ||
|
|
0609561dfe | ||
|
|
1b88a35b82 | ||
|
|
be01b6b883 | ||
|
|
deaddbca12 | ||
|
|
b20611590a | ||
|
|
d4972afd11 | ||
|
|
60e8292c1d | ||
|
|
da251e1aa8 | ||
|
|
eac39a3fd4 | ||
|
|
1f8af98f31 | ||
|
|
ee16103e03 | ||
|
|
09d53adfd1 | ||
|
|
89694047b1 | ||
|
|
23e0525c08 | ||
|
|
45874f2186 | ||
|
|
ae5848d799 | ||
|
|
ec79f19a79 | ||
|
|
f63c4bb40c | ||
|
|
22f839df99 | ||
|
|
c41634a065 | ||
|
|
2609f31e59 | ||
|
|
7aa41b3be2 | ||
|
|
1f6ca363ee | ||
|
|
02471990ef | ||
|
|
40b56798e2 | ||
|
|
c53d7ded06 | ||
|
|
e080c34a77 | ||
|
|
306f53d484 | ||
|
|
c4411afaaf | ||
|
|
c508941a8a | ||
|
|
ee68e2207e | ||
|
|
3d9a5bccba | ||
|
|
d736500d98 | ||
|
|
33b51a4445 | ||
|
|
0aab10863c | ||
|
|
b17caa3ea9 | ||
|
|
66ed523248 | ||
|
|
c5b5055ff1 | ||
|
|
7476f43ffa | ||
|
|
be29ed5776 | ||
|
|
ab840878fe | ||
|
|
aebc932b97 | ||
|
|
05a246ea3f | ||
|
|
c847988630 | ||
|
|
62f0c529b3 | ||
|
|
54958d0902 | ||
|
|
8d3e7f7cac | ||
|
|
c9eb398857 | ||
|
|
284ea88553 | ||
|
|
f9b9765100 | ||
|
|
863a379048 | ||
|
|
a86d1994c3 | ||
|
|
699eac00eb | ||
|
|
dff6c91c8e | ||
|
|
7afe3ff48f | ||
|
|
6dec47c791 | ||
|
|
def0e4aff0 | ||
|
|
9a6ab6a9ca | ||
|
|
8f20326c07 | ||
|
|
a82592a130 | ||
|
|
feea909d69 | ||
|
|
b78a026ee1 | ||
|
|
cf35c0ae25 | ||
|
|
9f96393fb9 | ||
|
|
beee8deb34 | ||
|
|
faab913227 | ||
|
|
346a8c8467 | ||
|
|
288d18a4fb | ||
|
|
97ed2e90ee | ||
|
|
b4d166480e | ||
|
|
46b35161e9 | ||
|
|
bee238b61e | ||
|
|
ca9c4bede5 | ||
|
|
48d73c3a4b | ||
|
|
76c558ee1a | ||
|
|
e530058659 | ||
|
|
79feab8e41 | ||
|
|
03943f152d | ||
|
|
c8aa731eeb | ||
|
|
45435814ee | ||
|
|
c7170ee751 | ||
|
|
e9f338484a | ||
|
|
bb131147f8 | ||
|
|
22b84ead9c | ||
|
|
42c7df964b | ||
|
|
c542642bf0 | ||
|
|
fde14cf0b8 | ||
|
|
b52590c458 | ||
|
|
6854458e21 | ||
|
|
5658bed520 | ||
|
|
7252296a74 | ||
|
|
348856a9c2 | ||
|
|
7779dc37d1 | ||
|
|
c1db571a39 | ||
|
|
5f65f3d570 | ||
|
|
bb18cb4f1c | ||
|
|
3f7b1f6e9c | ||
|
|
413a432e8c | ||
|
|
ca5f96115b | ||
|
|
9fe2f37012 | ||
|
|
b7247c9abd | ||
|
|
be8561c7d8 | ||
|
|
b78083f0d9 | ||
|
|
5d9124d0df | ||
|
|
b7a4c0f83e | ||
|
|
50d4bdf174 | ||
|
|
5b92cd6a58 | ||
|
|
18d0911810 | ||
|
|
dcf8cbc855 | ||
|
|
3d30681279 | ||
|
|
41bc4b567b | ||
|
|
38f8f816d5 | ||
|
|
4d9d1b8023 | ||
|
|
c60a575fda | ||
|
|
a09a7e811f | ||
|
|
ecbb34627a | ||
|
|
cdbccb6238 | ||
|
|
6e28e02f98 | ||
|
|
5812974a80 | ||
|
|
0b6a249178 | ||
|
|
7ab4b02afa | ||
|
|
d3a50c7afd | ||
|
|
16a0e8c492 | ||
|
|
0d6326b45b | ||
|
|
6b3a6b2dfb | ||
|
|
30443027a6 | ||
|
|
416d7c3a82 | ||
|
|
4709ef4607 | ||
|
|
6f0e099632 | ||
|
|
775fa03ed4 | ||
|
|
2ed2302526 | ||
|
|
46538ba1e9 | ||
|
|
5960844fcb | ||
|
|
b60fc38333 | ||
|
|
dc1ee7ff3c | ||
|
|
a51bf57d7a | ||
|
|
d60fb8697a | ||
|
|
bf4b81e320 | ||
|
|
389dd0c23d | ||
|
|
064a650763 | ||
|
|
6ecb431ccd | ||
|
|
b29f8775e1 | ||
|
|
85ee05e291 | ||
|
|
35aa64546b | ||
|
|
6e0d1a2279 | ||
|
|
64642f4bae | ||
|
|
2f9e9bdc9f | ||
|
|
2c7a85779b | ||
|
|
b4c3027e6d | ||
|
|
503fe84074 | ||
|
|
3b551b2918 | ||
|
|
51427a246e | ||
|
|
9b5ce2a850 | ||
|
|
8f3920519b | ||
|
|
2277f8a699 | ||
|
|
205d8f6206 | ||
|
|
ae06d33ccc | ||
|
|
c7b57daaf8 | ||
|
|
8aa0123047 | ||
|
|
4a3d348e39 | ||
|
|
4086b75224 | ||
|
|
4f89c46545 | ||
|
|
5d5afc8888 | ||
|
|
79ce5a0b82 | ||
|
|
65a60d5f06 | ||
|
|
4739a22f7b | ||
|
|
c25a6346d0 | ||
|
|
5f71fa8dc4 | ||
|
|
bda71caece | ||
|
|
0656f88fd0 | ||
|
|
3cf44ada8a | ||
|
|
ca00377bec | ||
|
|
ada2da0c6c | ||
|
|
82df14bde9 | ||
|
|
e1186757bf | ||
|
|
1f002bd1a3 | ||
|
|
3a52c18bef | ||
|
|
fe0351edae | ||
|
|
077f16e50c | ||
|
|
3a9d3f0503 | ||
|
|
03c3484ee1 | ||
|
|
e47d363145 | ||
|
|
5703ab3ed5 | ||
|
|
d03e9d3307 | ||
|
|
283f5c044c | ||
|
|
ea5327799a | ||
|
|
dbbc54171d | ||
|
|
2b98501a68 | ||
|
|
d082005b37 | ||
|
|
624c41d4f6 | ||
|
|
6d1d68e406 | ||
|
|
6871e36657 | ||
|
|
b1ccc3200b | ||
|
|
ee678db42c | ||
|
|
b16c12068e | ||
|
|
eb920bb9f2 | ||
|
|
dd7fd2cd68 | ||
|
|
7030a51b69 | ||
|
|
6f71b73ac2 | ||
|
|
641a8cd8fb | ||
|
|
70007c0da0 | ||
|
|
0e62ab7684 | ||
|
|
6f7421167b | ||
|
|
122dab2a9d | ||
|
|
7896404b6d | ||
|
|
a232d88bcf | ||
|
|
e023491e8c | ||
|
|
798b124fd4 | ||
|
|
c7fc8d6a63 | ||
|
|
91e867f468 | ||
|
|
785441f2a0 | ||
|
|
16d714238e | ||
|
|
7e703fe4b4 | ||
|
|
e935aadc94 | ||
|
|
7b06cd64a2 | ||
|
|
b3afe5236f | ||
|
|
cef7bd0226 | ||
|
|
23b04aafd0 | ||
|
|
6ee00af54b | ||
|
|
6f37194724 | ||
|
|
82a0052470 | ||
|
|
d563db9e57 | ||
|
|
c1bc9c35d1 | ||
|
|
5a8a368153 | ||
|
|
7a14973e9a | ||
|
|
f9e21737a5 | ||
|
|
53f86e4176 | ||
|
|
0b321fe115 | ||
|
|
94ae8589ce | ||
|
|
8d8f7cb097 | ||
|
|
6e9111dd0c | ||
|
|
8c5386ec34 | ||
|
|
38cff16f45 | ||
|
|
2d6baef7a9 | ||
|
|
89924a417a | ||
|
|
6735bca317 | ||
|
|
3c83e2ba5c | ||
|
|
f2cc059e4b | ||
|
|
91c60b8c0c | ||
|
|
1687dad063 | ||
|
|
37560816aa | ||
|
|
f92406da68 | ||
|
|
0131a96de8 | ||
|
|
0d4e9ee1b4 | ||
|
|
9282fc5bf6 | ||
|
|
4c4a85aec6 | ||
|
|
ebcae4cc9c | ||
|
|
9b262609b7 | ||
|
|
79c99289e7 | ||
|
|
07a8de9cb5 | ||
|
|
fdf38196f0 | ||
|
|
b753761e1a | ||
|
|
5faa5321dd | ||
|
|
7271d7950d | ||
|
|
533243da25 | ||
|
|
b1fba3ccef | ||
|
|
b0afa2417f | ||
|
|
6f5f7e1ee3 | ||
|
|
94c5bcb017 | ||
|
|
3f7c4705fe | ||
|
|
1ccd43c552 | ||
|
|
d38406fbc5 | ||
|
|
f73c69960c | ||
|
|
32e33e3050 | ||
|
|
e9bb42cd07 | ||
|
|
4a800df1e1 | ||
|
|
b2f922cbbc | ||
|
|
a4450348a1 |
@@ -0,0 +1,61 @@
|
||||
package agentbridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
var ErrUnavailable = errors.New("agent execution bridge unavailable")
|
||||
|
||||
type HTTPExecutor struct {
|
||||
URL string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func NewHTTPExecutor(url string, client *http.Client) (*HTTPExecutor, error) {
|
||||
url = strings.TrimSpace(url)
|
||||
if url == "" || !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return &HTTPExecutor{URL: strings.TrimRight(url, "/"), Client: client}, nil
|
||||
}
|
||||
|
||||
func (e *HTTPExecutor) Execute(ctx context.Context, request work.AgentExecutionRequest) (work.AgentExecutionResult, error) {
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, e.URL+"/v1/execute", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
response, err := e.Client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return work.AgentExecutionResult{}, fmt.Errorf("%w: status=%s", ErrUnavailable, response.Status)
|
||||
}
|
||||
decoder := json.NewDecoder(io.LimitReader(response.Body, 1<<20))
|
||||
var result work.AgentExecutionResult
|
||||
if err := decoder.Decode(&result); err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
if result.State == "" || result.Summary == "" || result.ObservedAtUnixMillis < 1 {
|
||||
return work.AgentExecutionResult{}, errors.New("agent execution bridge returned invalid result")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package agentbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
func TestHTTPExecutorCarriesTypedDispatchAndDecodesResult(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/execute" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
var request work.AgentExecutionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request.WorkerID != "worker-1" || request.FencingToken != 7 || request.Intent.DispatchID != "dispatch-1" {
|
||||
t.Fatalf("request=%+v", request)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(work.AgentExecutionResult{State: work.AgentExecutionCompleted, Summary: "completed", ObservedAtUnixMillis: 2})
|
||||
}))
|
||||
defer server.Close()
|
||||
executor, err := NewHTTPExecutor(server.URL, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := executor.Execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{DispatchID: "dispatch-1"}, WorkerID: "worker-1", FencingToken: 7})
|
||||
if err != nil || result.State != work.AgentExecutionCompleted || result.Summary != "completed" {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPExecutorRejectsUnavailableEndpoint(t *testing.T) {
|
||||
if _, err := NewHTTPExecutor("", nil); err != ErrUnavailable {
|
||||
t.Fatalf("expected unavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// Package developmentexecutor contains the local, read-only verification
|
||||
// adapter for governed development candidates. It is deliberately not a
|
||||
// source mutator, promotion authority, secret resolver, or deployment tool.
|
||||
package developmentexecutor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CheckKind string
|
||||
|
||||
const (
|
||||
CheckGoTest CheckKind = "GO_TEST"
|
||||
CheckGoVet CheckKind = "GO_VET"
|
||||
CheckGitDiffCheck CheckKind = "GIT_DIFF_CHECK"
|
||||
)
|
||||
|
||||
type Check struct {
|
||||
Kind CheckKind
|
||||
Packages []string
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
Worktree string
|
||||
WorktreeID string
|
||||
BaseRepoRevision string
|
||||
ProtectedMainAccess string
|
||||
ProductionAccess string
|
||||
SecretAccess string
|
||||
Checks []Check
|
||||
}
|
||||
|
||||
type CheckResult struct {
|
||||
Kind CheckKind `json:"kind"`
|
||||
Passed bool `json:"passed"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
DurationMillis int64 `json:"duration_millis"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
WorktreeID string `json:"worktree_id"`
|
||||
BaseRepoRevision string `json:"base_repo_revision"`
|
||||
Passed bool `json:"passed"`
|
||||
Checks []CheckResult `json:"checks"`
|
||||
EvidenceRef string `json:"evidence_ref"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidPlan = errors.New("invalid governed verification plan")
|
||||
ErrUnsupportedCheck = errors.New("unsupported governed verification check")
|
||||
ErrProtectedWorktree = errors.New("protected worktree is not executable")
|
||||
)
|
||||
|
||||
type CommandRunner interface {
|
||||
Run(context.Context, string, string, []string) int
|
||||
}
|
||||
|
||||
type OSCommandRunner struct{}
|
||||
|
||||
func (OSCommandRunner) Run(ctx context.Context, dir, name string, args []string) int {
|
||||
command := exec.CommandContext(ctx, name, args...)
|
||||
command.Dir = dir
|
||||
command.Stdin = nil
|
||||
command.Stdout = io.Discard
|
||||
command.Stderr = io.Discard
|
||||
command.Env = safeEnvironment()
|
||||
if err := command.Run(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return exitErr.ExitCode()
|
||||
}
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func ValidatePlan(plan Plan) error {
|
||||
if strings.TrimSpace(plan.Worktree) == "" || strings.TrimSpace(plan.WorktreeID) == "" || strings.TrimSpace(plan.BaseRepoRevision) == "" || len(plan.Checks) == 0 {
|
||||
return ErrInvalidPlan
|
||||
}
|
||||
if !filepath.IsAbs(plan.Worktree) {
|
||||
return ErrInvalidPlan
|
||||
}
|
||||
if plan.ProtectedMainAccess != "DENIED" || plan.ProductionAccess != "DENIED" || plan.SecretAccess != "DENIED" {
|
||||
return ErrProtectedWorktree
|
||||
}
|
||||
switch strings.ToLower(filepath.Base(filepath.Clean(plan.WorktreeID))) {
|
||||
case "main", "protected-main", "production":
|
||||
return ErrProtectedWorktree
|
||||
}
|
||||
for _, check := range plan.Checks {
|
||||
if err := validateCheck(check); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCheck(check Check) error {
|
||||
switch check.Kind {
|
||||
case CheckGoTest, CheckGoVet:
|
||||
if len(check.Packages) == 0 {
|
||||
return ErrInvalidPlan
|
||||
}
|
||||
for _, packagePath := range check.Packages {
|
||||
if strings.TrimSpace(packagePath) == "" || strings.ContainsAny(packagePath, ";&|`$()<>\r\n") || unsafePackagePath(packagePath) {
|
||||
return ErrInvalidPlan
|
||||
}
|
||||
}
|
||||
case CheckGitDiffCheck:
|
||||
if len(check.Packages) != 0 {
|
||||
return ErrInvalidPlan
|
||||
}
|
||||
default:
|
||||
return ErrUnsupportedCheck
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unsafePackagePath(value string) bool {
|
||||
value = strings.ReplaceAll(value, "\\", "/")
|
||||
for _, part := range strings.Split(value, "/") {
|
||||
if part == ".." {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.HasPrefix(value, "../") || strings.HasPrefix(value, "/") || (len(value) > 1 && value[1] == ':')
|
||||
}
|
||||
|
||||
func Execute(ctx context.Context, plan Plan, runner CommandRunner) (Result, error) {
|
||||
if err := ValidatePlan(plan); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if runner == nil {
|
||||
runner = OSCommandRunner{}
|
||||
}
|
||||
if _, err := os.Stat(plan.Worktree); err != nil {
|
||||
return Result{}, fmt.Errorf("worktree unavailable: %w", err)
|
||||
}
|
||||
result := Result{WorktreeID: plan.WorktreeID, BaseRepoRevision: plan.BaseRepoRevision, Passed: true, Checks: make([]CheckResult, 0, len(plan.Checks))}
|
||||
for _, check := range plan.Checks {
|
||||
started := time.Now()
|
||||
name, args := commandFor(check)
|
||||
exitCode := runner.Run(ctx, plan.Worktree, name, args)
|
||||
passed := exitCode == 0
|
||||
result.Checks = append(result.Checks, CheckResult{Kind: check.Kind, Passed: passed, ExitCode: exitCode, DurationMillis: time.Since(started).Milliseconds()})
|
||||
if !passed {
|
||||
result.Passed = false
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
result.Passed = false
|
||||
break
|
||||
}
|
||||
}
|
||||
result.EvidenceRef = evidenceRef(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func commandFor(check Check) (string, []string) {
|
||||
switch check.Kind {
|
||||
case CheckGoTest:
|
||||
return "go", append([]string{"test"}, check.Packages...)
|
||||
case CheckGoVet:
|
||||
return "go", append([]string{"vet"}, check.Packages...)
|
||||
default:
|
||||
return "git", []string{"diff", "--check"}
|
||||
}
|
||||
}
|
||||
|
||||
func evidenceRef(result Result) string {
|
||||
hash := sha256.New()
|
||||
_, _ = fmt.Fprintf(hash, "%s|%s|%t|", result.WorktreeID, result.BaseRepoRevision, result.Passed)
|
||||
for _, check := range result.Checks {
|
||||
_, _ = fmt.Fprintf(hash, "%s|%t|%d|", check.Kind, check.Passed, check.ExitCode)
|
||||
}
|
||||
return "verification:" + hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
|
||||
func safeEnvironment() []string {
|
||||
allowed := []string{"PATH", "SystemRoot", "TEMP", "TMP", "GOROOT", "GOPATH", "GOMODCACHE"}
|
||||
result := make([]string, 0, len(allowed))
|
||||
for _, name := range allowed {
|
||||
if value, ok := os.LookupEnv(name); ok && value != "" {
|
||||
result = append(result, name+"="+value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package developmentexecutor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeRunner struct {
|
||||
calls []string
|
||||
exits []int
|
||||
}
|
||||
|
||||
func (r *fakeRunner) Run(_ context.Context, _ string, name string, args []string) int {
|
||||
call := name
|
||||
for _, arg := range args {
|
||||
call += " " + arg
|
||||
}
|
||||
r.calls = append(r.calls, call)
|
||||
if len(r.exits) == 0 {
|
||||
return 0
|
||||
}
|
||||
exit := r.exits[0]
|
||||
r.exits = r.exits[1:]
|
||||
return exit
|
||||
}
|
||||
|
||||
func validPlan(t *testing.T) Plan {
|
||||
t.Helper()
|
||||
worktree, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Plan{Worktree: worktree, WorktreeID: "sandbox-1", BaseRepoRevision: "revision-1", ProtectedMainAccess: "DENIED", ProductionAccess: "DENIED", SecretAccess: "DENIED", Checks: []Check{{Kind: CheckGoTest, Packages: []string{"./modules/work/..."}}, {Kind: CheckGoVet, Packages: []string{"./modules/work/..."}}, {Kind: CheckGitDiffCheck}}}
|
||||
}
|
||||
|
||||
func TestExecuteRunsOnlyTypedChecksAndReturnsEvidence(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
result, err := Execute(context.Background(), validPlan(t), runner)
|
||||
if err != nil || !result.Passed || len(result.Checks) != 3 || result.EvidenceRef == "" {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
if len(runner.calls) != 3 || runner.calls[0] != "go test ./modules/work/..." || runner.calls[2] != "git diff --check" {
|
||||
t.Fatalf("calls=%v", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteFailureCannotBecomeVerificationSuccess(t *testing.T) {
|
||||
runner := &fakeRunner{exits: []int{1, 0}}
|
||||
plan := validPlan(t)
|
||||
plan.Checks = []Check{{Kind: CheckGoTest, Packages: []string{"./..."}}, {Kind: CheckGitDiffCheck}}
|
||||
result, err := Execute(context.Background(), plan, runner)
|
||||
if err != nil || result.Passed || result.Checks[0].Passed || result.EvidenceRef == "" {
|
||||
t.Fatalf("failed result was overclaimed: %+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePlanRejectsProtectedAndShellLikeInputs(t *testing.T) {
|
||||
protected := validPlan(t)
|
||||
protected.WorktreeID = "production"
|
||||
if !errors.Is(ValidatePlan(protected), ErrProtectedWorktree) {
|
||||
t.Fatal("protected worktree accepted")
|
||||
}
|
||||
injection := validPlan(t)
|
||||
injection.Checks = []Check{{Kind: CheckGoTest, Packages: []string{"./...; echo leaked"}}}
|
||||
if !errors.Is(ValidatePlan(injection), ErrInvalidPlan) {
|
||||
t.Fatal("shell-like package accepted")
|
||||
}
|
||||
}
|
||||
@@ -22,17 +22,35 @@ import (
|
||||
type OpenAICompatibleLocalAdapter struct {
|
||||
base *url.URL
|
||||
client *http.Client
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func NewOpenAICompatibleLocalAdapter(raw string, transport http.RoundTripper) (*OpenAICompatibleLocalAdapter, error) {
|
||||
return NewOpenAICompatibleLocalAdapterWithAPIKey(raw, "", transport)
|
||||
}
|
||||
|
||||
// NewOpenAICompatibleLocalAdapterWithAPIKey creates an adapter for a gateway
|
||||
// whose bearer credential was resolved by the trusted runtime boundary.
|
||||
func NewOpenAICompatibleLocalAdapterWithAPIKey(raw, apiKey string, transport http.RoundTripper) (*OpenAICompatibleLocalAdapter, error) {
|
||||
return NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(raw, apiKey, transport, 10*time.Second)
|
||||
}
|
||||
|
||||
// NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout creates a local gateway
|
||||
// adapter with an explicit invocation deadline. The request context remains
|
||||
// authoritative for per-call cancellation; this client deadline prevents a
|
||||
// provider from holding a worker indefinitely.
|
||||
func NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(raw, apiKey string, transport http.RoundTripper, timeout time.Duration) (*OpenAICompatibleLocalAdapter, error) {
|
||||
base, err := url.Parse(raw)
|
||||
if err != nil || base.Scheme != "http" || base.Host == "" || strings.TrimSuffix(base.Path, "/") != "/v1" || base.RawQuery != "" || base.Fragment != "" || base.User != nil {
|
||||
return nil, errors.New("invalid local compatible endpoint")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return nil, errors.New("local compatible timeout must be positive")
|
||||
}
|
||||
if transport == nil {
|
||||
transport = http.DefaultTransport
|
||||
}
|
||||
return &OpenAICompatibleLocalAdapter{base: base, client: &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil
|
||||
return &OpenAICompatibleLocalAdapter{base: base, client: &http.Client{Transport: transport, Timeout: timeout, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, apiKey: strings.TrimSpace(apiKey)}, nil
|
||||
}
|
||||
|
||||
type compatibleModelsResponse struct {
|
||||
@@ -78,11 +96,15 @@ func (a *OpenAICompatibleLocalAdapter) Discover(ctx context.Context) ([]Discover
|
||||
}
|
||||
|
||||
type compatibleChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []compatibleMessage `json:"messages"`
|
||||
Temperature int `json:"temperature"`
|
||||
Stream bool `json:"stream"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Model string `json:"model"`
|
||||
Messages []compatibleMessage `json:"messages"`
|
||||
Temperature int `json:"temperature"`
|
||||
Stream bool `json:"stream"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
ChatTemplateKwargs compatibleTemplateKwargs `json:"chat_template_kwargs"`
|
||||
}
|
||||
type compatibleTemplateKwargs struct {
|
||||
EnableThinking bool `json:"enable_thinking"`
|
||||
}
|
||||
type compatibleMessage struct {
|
||||
Role string `json:"role"`
|
||||
@@ -100,10 +122,10 @@ type compatibleChatResponse struct {
|
||||
}
|
||||
|
||||
func (a *OpenAICompatibleLocalAdapter) Advise(ctx context.Context, request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
|
||||
if err := ValidateAdvisoryRequest(request); err != nil || request.ProviderModelID == "" || len(request.Context) != 0 {
|
||||
if err := ValidateAdvisoryRequest(request); err != nil || request.ProviderModelID == "" {
|
||||
return cognitive.AdvisoryResponse{}, ErrInvalidAdvisory
|
||||
}
|
||||
payload := compatibleChatRequest{Model: string(request.ProviderModelID), Messages: []compatibleMessage{{Role: "system", Content: "Provide non-canonical advisory text only. Never claim that an action occurred."}, {Role: "user", Content: request.Message}}, Temperature: 0, Stream: false, MaxTokens: request.MaxOutputBytes / 4}
|
||||
payload := compatibleChatRequest{Model: string(request.ProviderModelID), Messages: []compatibleMessage{{Role: "system", Content: "Ты отвечаешь от имени Домового — локального помощника пользователя. Не называй себя именем модели, поставщиком или внутренним runtime. Если спрашивают имя, ответь: «Меня зовут Домовой». Понимай смысл сообщения и помогай довести цель до полезного результата. По умолчанию отвечай кратко, примерно одним-тремя предложениями; подробности давай только по просьбе. Отвечай только некатегоричным текстом совета; не утверждай, что внешнее действие уже выполнено."}, {Role: "user", Content: localAdvisoryMessage(request)}}, Temperature: 0, Stream: false, MaxTokens: request.MaxOutputBytes / 4, ChatTemplateKwargs: compatibleTemplateKwargs{EnableThinking: false}}
|
||||
var result compatibleChatResponse
|
||||
status, err := a.requestJSON(ctx, http.MethodPost, "/chat/completions", payload, &result)
|
||||
if err != nil {
|
||||
@@ -129,6 +151,19 @@ func (a *OpenAICompatibleLocalAdapter) Advise(ctx context.Context, request cogni
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func localAdvisoryMessage(request cognitive.AdvisoryRequest) string {
|
||||
if len(request.Context) == 0 {
|
||||
return request.Message
|
||||
}
|
||||
var builder strings.Builder
|
||||
builder.WriteString(request.Message)
|
||||
builder.WriteString("\n\nRelevant local context:\n")
|
||||
for _, excerpt := range request.Context {
|
||||
fmt.Fprintf(&builder, "- [%s/%s] %s\n", excerpt.SourceKind, excerpt.SourceID, excerpt.Text)
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func (a *OpenAICompatibleLocalAdapter) requestJSON(ctx context.Context, method, path string, input, output any) (int, error) {
|
||||
var body io.Reader
|
||||
if input != nil {
|
||||
@@ -145,6 +180,9 @@ func (a *OpenAICompatibleLocalAdapter) requestJSON(ctx context.Context, method,
|
||||
return 0, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if a.apiKey != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+a.apiKey)
|
||||
}
|
||||
if input != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
)
|
||||
|
||||
type captureRoundTripper struct {
|
||||
authorization string
|
||||
body []byte
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleLocalAdapterUsesConfiguredTimeout(t *testing.T) {
|
||||
adapter, err := NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout("http://gateway/v1", "", &captureRoundTripper{}, 47*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if adapter.client.Timeout != 47*time.Second {
|
||||
t.Fatalf("unexpected client timeout: %s", adapter.client.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleLocalAdapterRejectsNonPositiveTimeout(t *testing.T) {
|
||||
if _, err := NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout("http://gateway/v1", "", nil, 0); err == nil {
|
||||
t.Fatal("expected non-positive timeout to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func (t *captureRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
t.authorization = request.Header.Get("Authorization")
|
||||
if request.Body != nil {
|
||||
t.body, _ = io.ReadAll(request.Body)
|
||||
}
|
||||
body := `{"data":[]}`
|
||||
if strings.HasSuffix(request.URL.Path, "/chat/completions") {
|
||||
body = `{"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"ok"}}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header), Request: request}, nil
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleLocalAdapterUsesResolvedAPIKey(t *testing.T) {
|
||||
transport := &captureRoundTripper{}
|
||||
adapter, err := NewOpenAICompatibleLocalAdapterWithAPIKey("http://gateway/v1", "resolved-key", transport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = adapter.Discover(context.Background())
|
||||
if transport.authorization != "Bearer resolved-key" {
|
||||
t.Fatalf("unexpected authorization header: %q", transport.authorization)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleLocalAdapterDoesNotSendAuthorizationWithoutKey(t *testing.T) {
|
||||
transport := &captureRoundTripper{}
|
||||
adapter, err := NewOpenAICompatibleLocalAdapter("http://gateway/v1", transport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = adapter.Discover(context.Background())
|
||||
if transport.authorization != "" {
|
||||
t.Fatalf("unexpected authorization header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleLocalAdapterDisablesTemplateThinkingForBoundedAdvisory(t *testing.T) {
|
||||
transport := &captureRoundTripper{}
|
||||
adapter, err := NewOpenAICompatibleLocalAdapter("http://gateway/v1", transport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = adapter.Advise(context.Background(), cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", ActorID: "actor", PrincipalID: "principal", ScopeID: "scope", Message: "help", Locale: "ru-RU", MaxOutputBytes: 256, ProviderBindingID: "binding-1", ProviderBindingRevision: 1, ProviderInstanceID: "provider-1", ModelDescriptorID: "model-1", ProviderModelID: capability.ProviderModelID("medium-model")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload struct {
|
||||
ChatTemplateKwargs struct {
|
||||
EnableThinking bool `json:"enable_thinking"`
|
||||
} `json:"chat_template_kwargs"`
|
||||
}
|
||||
if err := json.Unmarshal(transport.body, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.ChatTemplateKwargs.EnableThinking {
|
||||
t.Fatal("expected bounded advisory to disable template thinking")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleLocalAdapterPassesLocalContextToAdvisory(t *testing.T) {
|
||||
transport := &captureRoundTripper{}
|
||||
adapter, err := NewOpenAICompatibleLocalAdapter("http://gateway/v1", transport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = adapter.Advise(context.Background(), cognitive.AdvisoryRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1, RequestID: "request-context", ActorID: "actor", PrincipalID: "principal", ScopeID: "scope",
|
||||
Message: "Продолжи разговор", Locale: "ru-RU", MaxOutputBytes: 256, ProviderBindingID: "binding-1", ProviderBindingRevision: 1,
|
||||
ProviderInstanceID: "provider-1", ModelDescriptorID: "model-1", ProviderModelID: capability.ProviderModelID("local-model"),
|
||||
Context: []cognitive.ContextExcerpt{{ID: "focus", SourceKind: "situation", SourceID: "focus-1", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: "Активный проект: Домовой"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(transport.body, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(payload.Messages) != 2 || !strings.Contains(payload.Messages[1].Content, "Активный проект: Домовой") {
|
||||
t.Fatalf("local context was not passed to advisory: %+v", payload.Messages)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,89 @@ func OpenCapability(ctx context.Context, config ContinuityConfig) (*CapabilityRe
|
||||
|
||||
func (r *CapabilityRepository) Close() { r.pool.Close() }
|
||||
|
||||
func (r *CapabilityRepository) AdmitCognitive(ctx context.Context, leaseID string, policy capability.AdmissionPolicy, request capability.AdmissionRequest, nowUnixMillis int64) (capability.AdmissionDecision, capability.AdmissionLease, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if leaseID == "" || nowUnixMillis < 1 {
|
||||
return capability.AdmissionDecision{State: capability.AdmissionRejected, Reason: capability.AdmissionReasonInputBudget, PolicyID: policy.ID}, capability.AdmissionLease{}, nil
|
||||
}
|
||||
var existingState string
|
||||
var existingPolicy string
|
||||
err = tx.QueryRow(ctx, `SELECT state, policy_id FROM capability.cognitive_admission_leases WHERE lease_id=$1`, leaseID).Scan(&existingState, &existingPolicy)
|
||||
if err == nil {
|
||||
if existingState == "ACTIVE" {
|
||||
return capability.AdmissionDecision{State: capability.AdmissionAdmitted, Reason: capability.AdmissionReasonNone, PolicyID: existingPolicy}, capability.AdmissionLease{ID: leaseID, PolicyID: existingPolicy}, tx.Commit(ctx)
|
||||
}
|
||||
return capability.AdmissionDecision{State: capability.AdmissionRejected, Reason: capability.AdmissionReasonConcurrency, PolicyID: existingPolicy}, capability.AdmissionLease{}, tx.Commit(ctx)
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
windowStart := nowUnixMillis - 60*60*1000
|
||||
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_admission_counters(policy_id,window_start_unix_millis,accepted_count,concurrent_count,updated_at_unix_millis) VALUES($1,$2,0,0,$3) ON CONFLICT(policy_id) DO NOTHING`, policy.ID, windowStart, nowUnixMillis)
|
||||
if err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
var storedWindow int64
|
||||
var accepted, concurrent int
|
||||
if err = tx.QueryRow(ctx, `SELECT window_start_unix_millis,accepted_count,concurrent_count FROM capability.cognitive_admission_counters WHERE policy_id=$1 FOR UPDATE`, policy.ID).Scan(&storedWindow, &accepted, &concurrent); err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
if nowUnixMillis-storedWindow >= 60*60*1000 {
|
||||
storedWindow, accepted = windowStart, 0
|
||||
}
|
||||
var expired int64
|
||||
if err = tx.QueryRow(ctx, `WITH expired AS (UPDATE capability.cognitive_admission_leases SET state='EXPIRED' WHERE policy_id=$1 AND state='ACTIVE' AND expires_at_unix_millis <= $2 RETURNING lease_id) SELECT count(*) FROM expired`, policy.ID, nowUnixMillis).Scan(&expired); err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
concurrent -= int(expired)
|
||||
if concurrent < 0 {
|
||||
concurrent = 0
|
||||
}
|
||||
decision := capability.Admit(request, policy, capability.AdmissionCounters{Concurrent: concurrent, AcceptedLastHour: accepted}, nowUnixMillis)
|
||||
if decision.State != capability.AdmissionAdmitted {
|
||||
if _, err = tx.Exec(ctx, `UPDATE capability.cognitive_admission_counters SET window_start_unix_millis=$2,accepted_count=$3,concurrent_count=$4,updated_at_unix_millis=$5 WHERE policy_id=$1`, policy.ID, storedWindow, accepted, concurrent, nowUnixMillis); err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
return decision, capability.AdmissionLease{}, tx.Commit(ctx)
|
||||
}
|
||||
expires := nowUnixMillis + 60*1000
|
||||
if request.DeadlineMillis > nowUnixMillis && request.DeadlineMillis < expires {
|
||||
expires = request.DeadlineMillis
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `UPDATE capability.cognitive_admission_counters SET window_start_unix_millis=$2,accepted_count=$3,concurrent_count=$4,updated_at_unix_millis=$5 WHERE policy_id=$1`, policy.ID, storedWindow, accepted+1, concurrent+1, nowUnixMillis); err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_admission_leases(lease_id,policy_id,state,expires_at_unix_millis,created_at_unix_millis) VALUES($1,$2,'ACTIVE',$3,$4)`, leaseID, policy.ID, expires, nowUnixMillis); err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
return decision, capability.AdmissionLease{ID: leaseID, PolicyID: policy.ID}, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) ReleaseCognitiveAdmission(ctx context.Context, lease capability.AdmissionLease, nowUnixMillis int64) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var policyID string
|
||||
err = tx.QueryRow(ctx, `UPDATE capability.cognitive_admission_leases SET state='RELEASED' WHERE lease_id=$1 AND state='ACTIVE' RETURNING policy_id`, lease.ID).Scan(&policyID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE capability.cognitive_admission_counters SET concurrent_count=GREATEST(0,concurrent_count-1),updated_at_unix_millis=$2 WHERE policy_id=$1`, policyID, nowUnixMillis)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) SaveHealth(ctx context.Context, health capability.Health) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO capability.health (capability_id, state, reason, revision, semantic_version, observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (capability_id) DO UPDATE SET state=EXCLUDED.state, reason=EXCLUDED.reason, revision=EXCLUDED.revision, semantic_version=EXCLUDED.semantic_version, observed_at_unix_millis=EXCLUDED.observed_at_unix_millis WHERE capability.health.revision < EXCLUDED.revision`, health.CapabilityID, health.State, health.Reason, health.Revision, health.SemanticVersion, health.ObservedAtUnixMillis)
|
||||
return err
|
||||
@@ -347,6 +430,32 @@ func (r *CapabilityRepository) SaveCognitiveBinding(ctx context.Context, value c
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) LoadCognitiveCandidates(ctx context.Context, capabilityID capability.CapabilityID, owner identity.PrincipalID) ([]capability.ResolvedCognitiveBinding, error) {
|
||||
return r.loadCognitiveCandidates(ctx, capabilityID, owner, "")
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) LoadCognitiveCandidatesForProfile(ctx context.Context, capabilityID capability.CapabilityID, owner identity.PrincipalID, profileID capability.TaskEvaluationProfileID) ([]capability.ResolvedCognitiveBinding, error) {
|
||||
return r.loadCognitiveCandidates(ctx, capabilityID, owner, profileID)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) LoadCognitiveCandidate(ctx context.Context, bindingID capability.CapabilityBindingID, revision int64) (capability.ResolvedCognitiveBinding, error) {
|
||||
var capabilityID capability.CapabilityID
|
||||
var owner identity.PrincipalID
|
||||
if err := r.pool.QueryRow(ctx, `SELECT capability_id,owner_principal_id FROM capability.cognitive_bindings WHERE binding_id=$1 AND revision=$2`, bindingID, revision).Scan(&capabilityID, &owner); err != nil {
|
||||
return capability.ResolvedCognitiveBinding{}, err
|
||||
}
|
||||
candidates, err := r.loadCognitiveCandidates(ctx, capabilityID, owner, "")
|
||||
if err != nil {
|
||||
return capability.ResolvedCognitiveBinding{}, err
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Binding.ID == bindingID && candidate.Binding.Revision == revision {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return capability.ResolvedCognitiveBinding{}, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) loadCognitiveCandidates(ctx context.Context, capabilityID capability.CapabilityID, owner identity.PrincipalID, profileID capability.TaskEvaluationProfileID) ([]capability.ResolvedCognitiveBinding, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT
|
||||
b.binding_id,b.revision,b.capability_id,b.owner_principal_id,b.provider_instance_id,b.provider_revision,b.model_descriptor_id,b.model_revision,b.data_policy,b.qualification,b.activation,b.health,b.limit_policy_ref,
|
||||
p.provider_instance_id,p.revision,p.provider_kind,p.endpoint_ref,COALESCE(p.credential_ref,''),p.enabled,p.qualification,COALESCE(ph.health,p.health),GREATEST(p.observed_at_unix_millis,COALESCE(ph.observed_at_unix_millis,0)),p.billing_mode,p.auth_mode,p.input_cost_micros,p.output_cost_micros,p.request_cost_micros,
|
||||
@@ -371,7 +480,7 @@ func (r *CapabilityRepository) LoadCognitiveCandidates(ctx context.Context, capa
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = r.loadCognitiveEvidence(ctx, &value); err != nil {
|
||||
if err = r.loadCognitiveEvidence(ctx, &value, profileID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, value)
|
||||
@@ -379,7 +488,7 @@ func (r *CapabilityRepository) LoadCognitiveCandidates(ctx context.Context, capa
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) loadCognitiveEvidence(ctx context.Context, value *capability.ResolvedCognitiveBinding) error {
|
||||
func (r *CapabilityRepository) loadCognitiveEvidence(ctx context.Context, value *capability.ResolvedCognitiveBinding, profileID capability.TaskEvaluationProfileID) error {
|
||||
var evaluation capability.EvaluatedCapability
|
||||
err := r.pool.QueryRow(ctx, `SELECT evaluation_id,binding_id,binding_revision,capability_id,result,quality_score_millis,critical_failure_millis,benchmark_version,artifact_id,observed_at_unix_millis FROM capability.cognitive_evaluated_capabilities WHERE binding_id=$1 AND binding_revision=$2 ORDER BY observed_at_unix_millis DESC LIMIT 1`, value.Binding.ID, value.Binding.Revision).Scan(&evaluation.ID, &evaluation.BindingID, &evaluation.BindingRevision, &evaluation.CapabilityID, &evaluation.Result, &evaluation.QualityScoreMillis, &evaluation.CriticalFailureMillis, &evaluation.BenchmarkVersion, &evaluation.ArtifactID, &evaluation.ObservedAtUnixMillis)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -396,6 +505,24 @@ func (r *CapabilityRepository) loadCognitiveEvidence(ctx context.Context, value
|
||||
if err == nil {
|
||||
value.Performance = &performance
|
||||
}
|
||||
var qualification capability.ModelQualificationObservation
|
||||
err = r.pool.QueryRow(ctx, `SELECT observation_id,provider_instance_id,provider_model_id,status,finish_reason,latency_millis,output_observed,quality_assessed,category,observed_at_unix_millis FROM capability.cognitive_model_qualification_observations WHERE provider_instance_id=$1 AND provider_model_id=$2 ORDER BY observed_at_unix_millis DESC LIMIT 1`, value.Provider.ID, value.Model.ProviderModelID).Scan(&qualification.ID, &qualification.ProviderInstanceID, &qualification.ProviderModelID, &qualification.Status, &qualification.FinishReason, &qualification.LatencyMillis, &qualification.OutputObserved, &qualification.QualityAssessed, &qualification.Category, &qualification.ObservedAtUnixMillis)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
value.Qualification = &qualification
|
||||
}
|
||||
if profileID != "" {
|
||||
var taskEvaluation capability.TaskEvaluation
|
||||
err = r.pool.QueryRow(ctx, `SELECT evaluation_id,task_profile_id,binding_id,binding_revision,capability_id,result,quality_score_millis,reliability_score_millis,latency_score_millis,overall_score_millis,critical_failure_millis,benchmark_version,artifact_id,observed_at_unix_millis FROM capability.cognitive_task_evaluations WHERE task_profile_id=$1 AND binding_id=$2 AND binding_revision=$3 ORDER BY observed_at_unix_millis DESC LIMIT 1`, profileID, value.Binding.ID, value.Binding.Revision).Scan(&taskEvaluation.ID, &taskEvaluation.ProfileID, &taskEvaluation.BindingID, &taskEvaluation.BindingRevision, &taskEvaluation.CapabilityID, &taskEvaluation.Result, &taskEvaluation.QualityScoreMillis, &taskEvaluation.ReliabilityScoreMillis, &taskEvaluation.LatencyScoreMillis, &taskEvaluation.OverallScoreMillis, &taskEvaluation.CriticalFailureMillis, &taskEvaluation.BenchmarkVersion, &taskEvaluation.ArtifactID, &taskEvaluation.ObservedAtUnixMillis)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
value.TaskEvaluation = &taskEvaluation
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -509,3 +636,13 @@ func (r *CapabilityRepository) SavePerformanceEvidence(ctx context.Context, valu
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_performance_evidence (evidence_id,binding_id,binding_revision,model_descriptor_id,model_revision,hardware_profile,runtime,quantization,context_units,thinking_enabled,generation_tps_millis,prompt_tps_millis,ttft_micros,latency_micros,memory_bytes,reliability_millis,benchmark_version,artifact_id,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) ON CONFLICT DO NOTHING`, value.ID, value.BindingID, value.BindingRevision, value.ModelDescriptorID, value.ModelRevision, value.HardwareProfile, value.Runtime, value.Quantization, value.ContextUnits, value.ThinkingEnabled, value.GenerationTPSMillis, value.PromptTPSMillis, value.TTFTMicros, value.LatencyMicros, value.MemoryBytes, value.ReliabilityMillis, value.BenchmarkVersion, value.ArtifactID, value.ObservedAtUnixMillis)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) SaveTaskEvaluation(ctx context.Context, value capability.TaskEvaluation) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_task_evaluations (evaluation_id,task_profile_id,binding_id,binding_revision,capability_id,result,quality_score_millis,reliability_score_millis,latency_score_millis,overall_score_millis,critical_failure_millis,benchmark_version,artifact_id,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT DO NOTHING`, value.ID, value.ProfileID, value.BindingID, value.BindingRevision, value.CapabilityID, value.Result, value.QualityScoreMillis, value.ReliabilityScoreMillis, value.LatencyScoreMillis, value.OverallScoreMillis, value.CriticalFailureMillis, value.BenchmarkVersion, value.ArtifactID, value.ObservedAtUnixMillis)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) SaveModelQualificationObservation(ctx context.Context, value capability.ModelQualificationObservation) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_model_qualification_observations (observation_id,provider_instance_id,provider_model_id,status,finish_reason,latency_millis,output_observed,quality_assessed,category,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT DO NOTHING`, value.ID, value.ProviderInstanceID, value.ProviderModelID, value.Status, value.FinishReason, value.LatencyMillis, value.OutputObserved, value.QualityAssessed, value.Category, value.ObservedAtUnixMillis)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package postgres
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -10,6 +11,14 @@ import (
|
||||
|
||||
type EpistemicsRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
type TruthRuntimeStatus struct {
|
||||
BundleCount int64 `json:"bundle_count"`
|
||||
ClaimsByVerification map[string]int64 `json:"claims_by_verification"`
|
||||
BeliefsByState map[string]int64 `json:"beliefs_by_state"`
|
||||
SourcesByFreshness map[string]int64 `json:"sources_by_freshness"`
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
}
|
||||
|
||||
func OpenEpistemics(ctx context.Context, config ContinuityConfig) (*EpistemicsRepository, error) {
|
||||
pool, err := openPool(ctx, config)
|
||||
if err != nil {
|
||||
@@ -20,6 +29,53 @@ func OpenEpistemics(ctx context.Context, config ContinuityConfig) (*EpistemicsRe
|
||||
|
||||
func (r *EpistemicsRepository) Close() { r.pool.Close() }
|
||||
|
||||
func (r *EpistemicsRepository) Ping(ctx context.Context) error {
|
||||
return r.pool.Ping(ctx)
|
||||
}
|
||||
|
||||
// TruthRuntimeStatus is a read-only projection of durable truth bundles.
|
||||
// It does not promote claims, revise beliefs, or mutate epistemic state.
|
||||
func (r *EpistemicsRepository) TruthRuntimeStatus(ctx context.Context) (TruthRuntimeStatus, error) {
|
||||
status := TruthRuntimeStatus{
|
||||
ClaimsByVerification: map[string]int64{},
|
||||
BeliefsByState: map[string]int64{},
|
||||
SourcesByFreshness: map[string]int64{},
|
||||
ObservedAtUnixMillis: time.Now().UnixMilli(),
|
||||
}
|
||||
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM epistemics.truth_bundles`).Scan(&status.BundleCount); err != nil {
|
||||
return TruthRuntimeStatus{}, err
|
||||
}
|
||||
queries := []struct {
|
||||
query string
|
||||
destination map[string]int64
|
||||
}{
|
||||
{`SELECT claim_verification, count(*) FROM epistemics.truth_bundles GROUP BY claim_verification`, status.ClaimsByVerification},
|
||||
{`SELECT belief_state, count(*) FROM epistemics.truth_bundles GROUP BY belief_state`, status.BeliefsByState},
|
||||
{`SELECT snapshot_freshness, count(*) FROM epistemics.truth_bundles GROUP BY snapshot_freshness`, status.SourcesByFreshness},
|
||||
}
|
||||
for _, item := range queries {
|
||||
rows, err := r.pool.Query(ctx, item.query)
|
||||
if err != nil {
|
||||
return TruthRuntimeStatus{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var state string
|
||||
var count int64
|
||||
if err := rows.Scan(&state, &count); err != nil {
|
||||
rows.Close()
|
||||
return TruthRuntimeStatus{}, err
|
||||
}
|
||||
item.destination[state] = count
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return TruthRuntimeStatus{}, err
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) SaveSystemExpectation(ctx context.Context, value epistemics.SystemExpectation) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO epistemics.system_expectations (
|
||||
expectation_id, original_intent_id, source_utterance_id, expected_outcome, expected_effect,
|
||||
@@ -64,7 +120,7 @@ func (r *EpistemicsRepository) RecentReflectionCandidates(ctx context.Context, l
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `SELECT
|
||||
reflection_candidate_id,trigger,state,source_experience_id,original_intent_id,source_utterance_id,
|
||||
summary,evidence_refs,requires_cognition,recorded_at_unix_millis,revision
|
||||
summary,evidence_refs,requires_cognition,recorded_at_unix_millis,revision,claimed_by,claim_expires_at_unix_millis
|
||||
FROM epistemics.reflection_candidates ORDER BY recorded_at_unix_millis DESC, reflection_candidate_id DESC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -74,7 +130,7 @@ func (r *EpistemicsRepository) RecentReflectionCandidates(ctx context.Context, l
|
||||
for rows.Next() {
|
||||
var value epistemics.ReflectionCandidate
|
||||
if err := rows.Scan(&value.ID, &value.Trigger, &value.State, &value.SourceExperienceID, &value.OriginalIntentID, &value.SourceUtteranceID,
|
||||
&value.Summary, &value.EvidenceRefs, &value.RequiresCognition, &value.RecordedAtUnixMillis, &value.Revision); err != nil {
|
||||
&value.Summary, &value.EvidenceRefs, &value.RequiresCognition, &value.RecordedAtUnixMillis, &value.Revision, &value.ClaimedBy, &value.ClaimExpiresAtUnixMillis); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, value)
|
||||
@@ -82,6 +138,61 @@ func (r *EpistemicsRepository) RecentReflectionCandidates(ctx context.Context, l
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) ClaimReflectionCandidate(ctx context.Context, workerID string, nowUnixMillis, leaseMillis int64) (epistemics.ReflectionCandidate, error) {
|
||||
if workerID == "" || leaseMillis < 1 {
|
||||
return epistemics.ReflectionCandidate{}, fmt.Errorf("invalid reflection claim")
|
||||
}
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return epistemics.ReflectionCandidate{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var value epistemics.ReflectionCandidate
|
||||
err = tx.QueryRow(ctx, `SELECT reflection_candidate_id,trigger,state,source_experience_id,original_intent_id,source_utterance_id,summary,evidence_refs,requires_cognition,recorded_at_unix_millis,revision,claimed_by,claim_expires_at_unix_millis
|
||||
FROM epistemics.reflection_candidates
|
||||
WHERE state='OPEN' AND requires_cognition=false AND (claim_expires_at_unix_millis=0 OR claim_expires_at_unix_millis < $1)
|
||||
ORDER BY recorded_at_unix_millis,reflection_candidate_id
|
||||
FOR UPDATE SKIP LOCKED LIMIT 1`, nowUnixMillis).Scan(&value.ID, &value.Trigger, &value.State, &value.SourceExperienceID, &value.OriginalIntentID, &value.SourceUtteranceID, &value.Summary, &value.EvidenceRefs, &value.RequiresCognition, &value.RecordedAtUnixMillis, &value.Revision, &value.ClaimedBy, &value.ClaimExpiresAtUnixMillis)
|
||||
if err != nil {
|
||||
return epistemics.ReflectionCandidate{}, err
|
||||
}
|
||||
value.State = epistemics.ReflectionCandidateEvaluating
|
||||
value.ClaimedBy = workerID
|
||||
value.ClaimExpiresAtUnixMillis = nowUnixMillis + leaseMillis
|
||||
_, err = tx.Exec(ctx, `UPDATE epistemics.reflection_candidates SET state=$1,claimed_by=$2,claim_expires_at_unix_millis=$3 WHERE reflection_candidate_id=$4 AND state='OPEN'`, value.State, value.ClaimedBy, value.ClaimExpiresAtUnixMillis, value.ID)
|
||||
if err != nil {
|
||||
return epistemics.ReflectionCandidate{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return epistemics.ReflectionCandidate{}, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) RecoverReflectionClaims(ctx context.Context, nowUnixMillis int64) (int64, error) {
|
||||
result, err := r.pool.Exec(ctx, `UPDATE epistemics.reflection_candidates SET state='OPEN',claimed_by='',claim_expires_at_unix_millis=0 WHERE state='EVALUATING' AND claim_expires_at_unix_millis > 0 AND claim_expires_at_unix_millis < $1`, nowUnixMillis)
|
||||
return result.RowsAffected(), err
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) ResolveReflectionCandidate(ctx context.Context, id epistemics.ReflectionCandidateID, workerID string, nowUnixMillis int64) error {
|
||||
if id == "" || workerID == "" {
|
||||
return fmt.Errorf("invalid reflection resolution")
|
||||
}
|
||||
result, err := r.pool.Exec(ctx, `UPDATE epistemics.reflection_candidates SET state='RESOLVED',claimed_by='',claim_expires_at_unix_millis=0,revision=revision+1 WHERE reflection_candidate_id=$1 AND state='EVALUATING' AND claimed_by=$2 AND claim_expires_at_unix_millis >= $3`, id, workerID, nowUnixMillis)
|
||||
if err == nil && result.RowsAffected() != 1 {
|
||||
return fmt.Errorf("reflection candidate claim lost")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) WaitForReflectionCandidate(ctx context.Context, interval time.Duration) error {
|
||||
if interval <= 0 {
|
||||
return fmt.Errorf("invalid reflection poll interval")
|
||||
}
|
||||
_, err := r.pool.Exec(ctx, `SELECT pg_sleep($1)`, interval.Seconds())
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) SaveExperienceRecord(ctx context.Context, value epistemics.ExperienceRecord) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO epistemics.experience_records (
|
||||
experience_id, source_kind, original_intent_id, source_utterance_id, context_refs, help_method, help_method_ref,
|
||||
@@ -105,3 +216,21 @@ func (r *EpistemicsRepository) SaveReflectionCandidate(ctx context.Context, valu
|
||||
value.Summary, value.EvidenceRefs, value.RequiresCognition, value.RecordedAtUnixMillis, value.Revision)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) SaveTruthBundle(ctx context.Context, value epistemics.TruthBundle, recordedAtUnixMillis int64) error {
|
||||
if value.Source.ID == "" || value.Snapshot.ID == "" || value.Excerpt.ID == "" || value.Claim.ID == "" || value.Belief.ID == "" || value.Citation.ID == "" || recordedAtUnixMillis < 1 {
|
||||
return fmt.Errorf("invalid truth bundle")
|
||||
}
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO epistemics.truth_bundles (
|
||||
bundle_id,source_id,source_kind,source_locator,source_title,source_publisher,snapshot_id,snapshot_source_id,snapshot_content_digest,snapshot_captured_at_unix_millis,snapshot_freshness,
|
||||
excerpt_id,excerpt_snapshot_id,excerpt_text,excerpt_start,excerpt_end,observation_id,observation_observed_at_unix_millis,claim_id,claim_statement,claim_observation_id,claim_citation_id,claim_verification,
|
||||
belief_id,belief_claim_id,belief_state,citation_id,citation_excerpt_id,citation_evidence,citation_coverage,citation_conflict,citation_resolved_at_unix_millis,recorded_at_unix_millis)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33)
|
||||
ON CONFLICT (bundle_id) DO UPDATE SET recorded_at_unix_millis=EXCLUDED.recorded_at_unix_millis`,
|
||||
"bundle:"+string(value.Citation.ID), value.Source.ID, value.Source.Kind, value.Source.Locator, value.Source.Title, value.Source.Publisher,
|
||||
value.Snapshot.ID, value.Snapshot.SourceID, value.Snapshot.ContentDigest, value.Snapshot.CapturedAtUnixMillis, value.Snapshot.Freshness,
|
||||
value.Excerpt.ID, value.Excerpt.SnapshotID, value.Excerpt.Text, value.Excerpt.Start, value.Excerpt.End,
|
||||
value.Observation.ID, value.Observation.ObservedAt, value.Claim.ID, value.Claim.Statement, value.Claim.ObservationID, value.Claim.CitationID, value.Claim.Verification,
|
||||
value.Belief.ID, value.Belief.ClaimID, value.Belief.State, value.Citation.ID, value.Citation.ExcerptID, value.Citation.Evidence, value.Citation.Coverage, value.Citation.Conflict, value.Citation.ResolvedAtUnixMillis, recordedAtUnixMillis)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package postgres
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
@@ -98,3 +99,22 @@ func (r *InteractionRepository) Utterances(ctx context.Context, id interaction.C
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RecentUtterancesForPrincipal provides durable conversational context when a
|
||||
// client starts a fresh conversation instead of carrying the previous ID.
|
||||
func (r *InteractionRepository) RecentUtterancesForPrincipal(ctx context.Context, principalID identity.PrincipalID, limit int) ([]interaction.HumanUtterance, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT utterance_id,conversation_id,actor_id,principal_id,channel,received_at_unix_millis,correlation_id,content,content_ref,speech_act,interpretation_state,corrects_utterance_id FROM interaction.utterances WHERE principal_id=$1 ORDER BY received_at_unix_millis DESC, utterance_id DESC LIMIT $2`, principalID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []interaction.HumanUtterance
|
||||
for rows.Next() {
|
||||
var u interaction.HumanUtterance
|
||||
if err := rows.Scan(&u.ID, &u.ConversationID, &u.ActorID, &u.PrincipalID, &u.Channel, &u.ReceivedAtUnixMillis, &u.CorrelationID, &u.Content, &u.ContentRef, &u.SpeechAct, &u.InterpretationState, &u.CorrectsUtteranceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
@@ -2,20 +2,27 @@ package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type WorkRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
var (
|
||||
ErrDispatchIntentConflict = errors.New("dispatch intent idempotency conflict")
|
||||
ErrAgentLeaseUnavailable = errors.New("agent lease unavailable")
|
||||
ErrAgentCallbackRejected = errors.New("agent callback rejected")
|
||||
ErrDispatchIntentConflict = errors.New("dispatch intent idempotency conflict")
|
||||
ErrAgentLeaseUnavailable = errors.New("agent lease unavailable")
|
||||
ErrAgentCallbackRejected = errors.New("agent callback rejected")
|
||||
ErrDevelopmentGateConflict = errors.New("development gate revision conflict")
|
||||
ErrDevelopmentCandidateConflict = errors.New("development candidate conflict")
|
||||
)
|
||||
|
||||
func OpenWork(ctx context.Context, config ContinuityConfig) (*WorkRepository, error) {
|
||||
@@ -25,7 +32,56 @@ func OpenWork(ctx context.Context, config ContinuityConfig) (*WorkRepository, er
|
||||
}
|
||||
return &WorkRepository{pool}, nil
|
||||
}
|
||||
func (r *WorkRepository) Close() { r.pool.Close() }
|
||||
|
||||
// WorkGraphRuntimeStatus is a read-only operational projection owned by Work.
|
||||
// It is not canonical graph state and cannot advance any lifecycle.
|
||||
type WorkGraphRuntimeStatus struct {
|
||||
GraphsByState map[string]int64 `json:"graphs_by_state"`
|
||||
NodesByState map[string]int64 `json:"nodes_by_state"`
|
||||
QueueByState map[string]int64 `json:"queue_by_state"`
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
}
|
||||
|
||||
func (r *WorkRepository) RuntimeStatus(ctx context.Context) (WorkGraphRuntimeStatus, error) {
|
||||
status := WorkGraphRuntimeStatus{
|
||||
GraphsByState: map[string]int64{},
|
||||
NodesByState: map[string]int64{},
|
||||
QueueByState: map[string]int64{},
|
||||
ObservedAtUnixMillis: time.Now().UnixMilli(),
|
||||
}
|
||||
queries := []struct {
|
||||
query string
|
||||
destination map[string]int64
|
||||
}{
|
||||
{`SELECT state, count(*) FROM work.graphs GROUP BY state`, status.GraphsByState},
|
||||
{`SELECT state, count(*) FROM work.graph_nodes GROUP BY state`, status.NodesByState},
|
||||
{`SELECT state, count(*) FROM work.agent_dispatch_queue GROUP BY state`, status.QueueByState},
|
||||
}
|
||||
for _, item := range queries {
|
||||
rows, err := r.pool.Query(ctx, item.query)
|
||||
if err != nil {
|
||||
return WorkGraphRuntimeStatus{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var state string
|
||||
var count int64
|
||||
if err := rows.Scan(&state, &count); err != nil {
|
||||
rows.Close()
|
||||
return WorkGraphRuntimeStatus{}, err
|
||||
}
|
||||
item.destination[state] = count
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return WorkGraphRuntimeStatus{}, err
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) Close() { r.pool.Close() }
|
||||
func (r *WorkRepository) Ping(ctx context.Context) error { return r.pool.Ping(ctx) }
|
||||
func (r *WorkRepository) Create(ctx context.Context, target work.TaskTarget) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO work.tasks (task_id,goal_id,scope_id,revision) VALUES ($1,$2,$3,$4) ON CONFLICT (task_id) DO NOTHING`, target.TaskID, target.GoalID, target.ScopeID, target.Revision)
|
||||
return err
|
||||
@@ -47,27 +103,138 @@ func (r *WorkRepository) Load(ctx context.Context, id work.TaskID) (work.TaskTar
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) LoadDevelopmentGate(ctx context.Context, id string) (work.DevelopmentGate, error) {
|
||||
var gate work.DevelopmentGate
|
||||
var evidence, producer, verifier []string
|
||||
err := r.pool.QueryRow(ctx, `SELECT gate_id,revision,updated_at_unix_millis,stage,evidence_refs,proposal_ref,task_packet_ref,worktree_ref,producer_run_refs,verifier_run_refs,verification_evidence,canary_evidence,promotion_ref,rollback_ref FROM work.development_gates WHERE gate_id=$1`, id).Scan(
|
||||
&gate.ID, &gate.Revision, &gate.UpdatedAtUnixMillis, &gate.Stage, &evidence, &gate.ProposalRef, &gate.TaskPacketRef, &gate.WorktreeRef, &producer, &verifier, &gate.VerificationEvidence, &gate.CanaryEvidence, &gate.PromotionRef, &gate.RollbackRef)
|
||||
if err != nil {
|
||||
return work.DevelopmentGate{}, fmt.Errorf("load development gate: %w", err)
|
||||
}
|
||||
gate.EvidenceRefs = append([]string(nil), evidence...)
|
||||
for _, run := range producer {
|
||||
gate.ProducerRunRefs = append(gate.ProducerRunRefs, work.AgentRunID(run))
|
||||
}
|
||||
for _, run := range verifier {
|
||||
gate.VerifierRunRefs = append(gate.VerifierRunRefs, work.AgentRunID(run))
|
||||
}
|
||||
return gate, nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveDevelopmentGate(ctx context.Context, gate work.DevelopmentGate, expectedRevision int64) error {
|
||||
if gate.ID == "" || expectedRevision < 0 || gate.Revision < 1 || gate.UpdatedAtUnixMillis < 1 {
|
||||
return ErrDevelopmentGateConflict
|
||||
}
|
||||
producer := make([]string, 0, len(gate.ProducerRunRefs))
|
||||
for _, run := range gate.ProducerRunRefs {
|
||||
producer = append(producer, string(run))
|
||||
}
|
||||
verifier := make([]string, 0, len(gate.VerifierRunRefs))
|
||||
for _, run := range gate.VerifierRunRefs {
|
||||
verifier = append(verifier, string(run))
|
||||
}
|
||||
if expectedRevision == 0 {
|
||||
command, err := r.pool.Exec(ctx, `INSERT INTO work.development_gates(gate_id,revision,updated_at_unix_millis,stage,evidence_refs,proposal_ref,task_packet_ref,worktree_ref,producer_run_refs,verifier_run_refs,verification_evidence,canary_evidence,promotion_ref,rollback_ref) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT(gate_id) DO NOTHING`, gate.ID, gate.Revision, gate.UpdatedAtUnixMillis, gate.Stage, gate.EvidenceRefs, gate.ProposalRef, gate.TaskPacketRef, gate.WorktreeRef, producer, verifier, gate.VerificationEvidence, gate.CanaryEvidence, gate.PromotionRef, gate.RollbackRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if command.RowsAffected() != 1 {
|
||||
return ErrDevelopmentGateConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
command, err := r.pool.Exec(ctx, `UPDATE work.development_gates SET revision=$1,updated_at_unix_millis=$2,stage=$3,evidence_refs=$4,proposal_ref=$5,task_packet_ref=$6,worktree_ref=$7,producer_run_refs=$8,verifier_run_refs=$9,verification_evidence=$10,canary_evidence=$11,promotion_ref=$12,rollback_ref=$13 WHERE gate_id=$14 AND revision=$15`, gate.Revision, gate.UpdatedAtUnixMillis, gate.Stage, gate.EvidenceRefs, gate.ProposalRef, gate.TaskPacketRef, gate.WorktreeRef, producer, verifier, gate.VerificationEvidence, gate.CanaryEvidence, gate.PromotionRef, gate.RollbackRef, gate.ID, expectedRevision)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if command.RowsAffected() != 1 {
|
||||
return ErrDevelopmentGateConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveExtensionProposal(ctx context.Context, value work.ExtensionProposal) error {
|
||||
result, err := r.pool.Exec(ctx, `INSERT INTO work.extension_proposals(proposal_id,requested_by_principal_id,stage,evidence_refs,deduplicated_signal_keys,desired_outcome,smallest_change_layer,affected_owners,affected_contracts,risk_tier,evaluation_plan,alternatives,rollback_plan,assumptions,created_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT(proposal_id) DO NOTHING`, value.ID, value.RequestedByPrincipalID, value.Stage, value.EvidenceRefs, value.DeduplicatedSignalKeys, value.DesiredOutcome, value.SmallestChangeLayer, value.AffectedOwners, value.AffectedContracts, value.RiskTier, value.EvaluationPlan, value.Alternatives, value.RollbackPlan, value.Assumptions, value.CreatedAtUnixMillis)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return ErrDevelopmentCandidateConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveDevelopmentScaffold(ctx context.Context, value work.DevelopmentScaffold) error {
|
||||
result, err := r.pool.Exec(ctx, `INSERT INTO work.development_scaffolds(scaffold_id,requested_by_principal_id,stage,proposal_ref,base_repo_revision,worktree_ref,task_intent,modules,allowed_paths,forbidden_paths,required_tests,artifact_lineage_refs,protected_main_access,production_access,secret_access,created_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) ON CONFLICT(scaffold_id) DO NOTHING`, value.ID, value.RequestedByPrincipalID, value.Stage, value.ProposalRef, value.BaseRepoRevision, value.WorktreeRef, value.TaskIntent, value.Modules, value.AllowedPaths, value.ForbiddenPaths, value.RequiredTests, value.ArtifactLineageRefs, value.ProtectedMainAccess, value.ProductionAccess, value.SecretAccess, value.CreatedAtUnixMillis)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return ErrDevelopmentCandidateConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveDispatchIntent(ctx context.Context, intent work.AgentDispatchIntent) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
_, err = tx.Exec(ctx, `INSERT INTO work.agent_dispatch_intents(dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT(idempotency_key) DO NOTHING`, intent.DispatchID, intent.IdempotencyKey, intent.DelegationID, intent.RunID, intent.ImplementationID, intent.ImplementationRevision, intent.SourceRevision, intent.WorktreeID, intent.VerificationPlanRef, intent.CreatedAtUnixMillis)
|
||||
profileJSON, casesJSON, err := marshalTaskPayload(intent.TaskEvaluationProfile, intent.TaskEvaluationCases)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
executionJSON, err := json.Marshal(intent.TaskEvaluationExecution)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO work.agent_dispatch_intents(dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis,specialist_purpose,task_evaluation_profile_id,task_evaluation_binding_id,task_evaluation_case_ids,task_evaluation_profile,task_evaluation_cases,task_evaluation_execution) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) ON CONFLICT(idempotency_key) DO NOTHING`, intent.DispatchID, intent.IdempotencyKey, intent.DelegationID, intent.RunID, intent.ImplementationID, intent.ImplementationRevision, intent.SourceRevision, intent.WorktreeID, intent.VerificationPlanRef, intent.CreatedAtUnixMillis, intent.SpecialistPurpose, intent.TaskEvaluationProfileID, intent.TaskEvaluationBindingID, intent.TaskEvaluationCaseIDs, profileJSON, casesJSON, executionJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save dispatch intent: %w", err)
|
||||
}
|
||||
var existing work.AgentDispatchIntent
|
||||
err = tx.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis FROM work.agent_dispatch_intents WHERE idempotency_key=$1`, intent.IdempotencyKey).Scan(&existing.DispatchID, &existing.IdempotencyKey, &existing.DelegationID, &existing.RunID, &existing.ImplementationID, &existing.ImplementationRevision, &existing.SourceRevision, &existing.WorktreeID, &existing.VerificationPlanRef, &existing.CreatedAtUnixMillis)
|
||||
var profileJSONExisting, casesJSONExisting, executionJSONExisting []byte
|
||||
err = tx.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis,specialist_purpose,task_evaluation_profile_id,task_evaluation_binding_id,task_evaluation_case_ids,task_evaluation_profile,task_evaluation_cases,task_evaluation_execution FROM work.agent_dispatch_intents WHERE idempotency_key=$1`, intent.IdempotencyKey).Scan(&existing.DispatchID, &existing.IdempotencyKey, &existing.DelegationID, &existing.RunID, &existing.ImplementationID, &existing.ImplementationRevision, &existing.SourceRevision, &existing.WorktreeID, &existing.VerificationPlanRef, &existing.CreatedAtUnixMillis, &existing.SpecialistPurpose, &existing.TaskEvaluationProfileID, &existing.TaskEvaluationBindingID, &existing.TaskEvaluationCaseIDs, &profileJSONExisting, &casesJSONExisting, &executionJSONExisting)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read dispatch intent: %w", err)
|
||||
}
|
||||
if existing != intent {
|
||||
if err := unmarshalTaskPayload(profileJSONExisting, casesJSONExisting, &existing.TaskEvaluationProfile, &existing.TaskEvaluationCases); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(executionJSONExisting) > 0 && string(executionJSONExisting) != "null" {
|
||||
if err := json.Unmarshal(executionJSONExisting, &existing.TaskEvaluationExecution); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !sameDispatchIntent(existing, intent) {
|
||||
return ErrDispatchIntentConflict
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func sameDispatchIntent(left, right work.AgentDispatchIntent) bool {
|
||||
return left.DispatchID == right.DispatchID && left.IdempotencyKey == right.IdempotencyKey && left.DelegationID == right.DelegationID && left.RunID == right.RunID && left.ImplementationID == right.ImplementationID && left.ImplementationRevision == right.ImplementationRevision && left.SourceRevision == right.SourceRevision && left.WorktreeID == right.WorktreeID && left.VerificationPlanRef == right.VerificationPlanRef && left.CreatedAtUnixMillis == right.CreatedAtUnixMillis && left.SpecialistPurpose == right.SpecialistPurpose && left.TaskEvaluationProfileID == right.TaskEvaluationProfileID && left.TaskEvaluationBindingID == right.TaskEvaluationBindingID && slices.Equal(left.TaskEvaluationCaseIDs, right.TaskEvaluationCaseIDs) && reflect.DeepEqual(left.TaskEvaluationProfile, right.TaskEvaluationProfile) && slices.Equal(left.TaskEvaluationCases, right.TaskEvaluationCases) && reflect.DeepEqual(left.TaskEvaluationExecution, right.TaskEvaluationExecution)
|
||||
}
|
||||
|
||||
func (r *WorkRepository) LoadDispatchIntent(ctx context.Context, dispatchID string) (work.AgentDispatchIntent, error) {
|
||||
var intent work.AgentDispatchIntent
|
||||
var profileJSON, casesJSON, executionJSON []byte
|
||||
err := r.pool.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis,specialist_purpose,task_evaluation_profile_id,task_evaluation_binding_id,task_evaluation_case_ids,task_evaluation_profile,task_evaluation_cases,task_evaluation_execution FROM work.agent_dispatch_intents WHERE dispatch_id=$1`, dispatchID).Scan(&intent.DispatchID, &intent.IdempotencyKey, &intent.DelegationID, &intent.RunID, &intent.ImplementationID, &intent.ImplementationRevision, &intent.SourceRevision, &intent.WorktreeID, &intent.VerificationPlanRef, &intent.CreatedAtUnixMillis, &intent.SpecialistPurpose, &intent.TaskEvaluationProfileID, &intent.TaskEvaluationBindingID, &intent.TaskEvaluationCaseIDs, &profileJSON, &casesJSON, &executionJSON)
|
||||
if err != nil {
|
||||
return work.AgentDispatchIntent{}, fmt.Errorf("load dispatch intent: %w", err)
|
||||
}
|
||||
if err := unmarshalTaskPayload(profileJSON, casesJSON, &intent.TaskEvaluationProfile, &intent.TaskEvaluationCases); err != nil {
|
||||
return work.AgentDispatchIntent{}, err
|
||||
}
|
||||
if len(executionJSON) > 0 && string(executionJSON) != "null" {
|
||||
if err := json.Unmarshal(executionJSON, &intent.TaskEvaluationExecution); err != nil {
|
||||
return work.AgentDispatchIntent{}, err
|
||||
}
|
||||
}
|
||||
return intent, nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) EnqueueDispatch(ctx context.Context, item work.AgentDispatchQueueItem) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO work.agent_dispatch_queue(dispatch_id,state,available_at_unix_millis,attempts,claimed_by,updated_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(dispatch_id) DO NOTHING`, item.DispatchID, item.State, item.AvailableAtUnixMillis, item.Attempts, item.ClaimedBy, item.UpdatedAtUnixMillis)
|
||||
return err
|
||||
@@ -80,8 +247,11 @@ func (r *WorkRepository) ClaimNextDispatch(ctx context.Context, workerID string,
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var item work.AgentDispatchQueueItem
|
||||
err = tx.QueryRow(ctx, `SELECT dispatch_id,state,available_at_unix_millis,attempts,claimed_by,updated_at_unix_millis FROM work.agent_dispatch_queue WHERE state='QUEUED' AND available_at_unix_millis <= $1 ORDER BY available_at_unix_millis,dispatch_id FOR UPDATE SKIP LOCKED LIMIT 1`, nowUnixMillis).Scan(&item.DispatchID, &item.State, &item.AvailableAtUnixMillis, &item.Attempts, &item.ClaimedBy, &item.UpdatedAtUnixMillis)
|
||||
err = tx.QueryRow(ctx, `SELECT dispatch_id,state,available_at_unix_millis,attempts,claimed_by,updated_at_unix_millis FROM work.agent_dispatch_queue WHERE state='QUEUED' AND available_at_unix_millis <= $1 AND attempts < retry_limit ORDER BY available_at_unix_millis,dispatch_id FOR UPDATE SKIP LOCKED LIMIT 1`, nowUnixMillis).Scan(&item.DispatchID, &item.State, &item.AvailableAtUnixMillis, &item.Attempts, &item.ClaimedBy, &item.UpdatedAtUnixMillis)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return work.AgentDispatchQueueItem{}, nil
|
||||
}
|
||||
return work.AgentDispatchQueueItem{}, err
|
||||
}
|
||||
item.State = work.AgentDispatchClaimed
|
||||
@@ -98,6 +268,14 @@ func (r *WorkRepository) ClaimNextDispatch(ctx context.Context, workerID string,
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) WaitForDispatch(ctx context.Context, interval time.Duration) error {
|
||||
if interval <= 0 {
|
||||
return fmt.Errorf("invalid worker poll interval")
|
||||
}
|
||||
_, err := r.pool.Exec(ctx, `SELECT pg_sleep($1)`, interval.Seconds())
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *WorkRepository) CompleteDispatch(ctx context.Context, dispatchID, workerID string, state work.AgentDispatchQueueState, nowUnixMillis int64) error {
|
||||
if state != work.AgentDispatchCompleted && state != work.AgentDispatchFailed {
|
||||
return fmt.Errorf("invalid terminal dispatch state: %s", state)
|
||||
@@ -112,6 +290,142 @@ func (r *WorkRepository) CompleteDispatch(ctx context.Context, dispatchID, worke
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) RetryDispatch(ctx context.Context, dispatchID, workerID string, nowUnixMillis int64, delay time.Duration) error {
|
||||
if delay < 0 {
|
||||
return fmt.Errorf("invalid retry delay")
|
||||
}
|
||||
availableAt := nowUnixMillis + delay.Milliseconds()
|
||||
command, err := r.pool.Exec(ctx, `UPDATE work.agent_dispatch_queue SET state=CASE WHEN attempts >= retry_limit THEN 'FAILED' ELSE 'QUEUED' END,available_at_unix_millis=$1,claimed_by='',updated_at_unix_millis=$2 WHERE dispatch_id=$3 AND state='CLAIMED' AND claimed_by=$4`, availableAt, nowUnixMillis, dispatchID, workerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if command.RowsAffected() != 1 {
|
||||
return ErrAgentCallbackRejected
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveWorkGraph(ctx context.Context, graph work.WorkGraph) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO work.graphs(graph_id,revision,state,max_parallel,max_attempts,parent_graph_id,parent_graph_revision,trigger_evidence_ref,orchestration_bound,orchestration_goal_id,orchestration_goal_state,orchestration_search_round,orchestration_max_round,orchestration_cancel_requested,orchestration_verification_ready,orchestration_unresolved) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) ON CONFLICT(graph_id,revision) DO NOTHING`, graph.ID, graph.Revision, graph.State, graph.Budget.MaxParallel, graph.Budget.MaxAttempts, graph.ParentGraphID, graph.ParentGraphRevision, graph.TriggerEvidenceRef, graph.SearchInputBound, graph.SearchInput.GoalID, graph.SearchInput.GoalState, graph.SearchInput.SearchRound, graph.SearchInput.MaxRound, graph.SearchInput.CancelRequested, graph.SearchInput.VerificationReady, graph.SearchInput.Unresolved); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, node := range graph.Nodes {
|
||||
profileJSON, casesJSON, err := marshalTaskPayload(node.TaskProfile, node.TaskCases)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO work.graph_nodes(graph_id,revision,node_id,dispatch_id,idempotency_key,state,attempts,purpose,task_profile_id,binding_id,case_ids,task_evaluation_profile,task_evaluation_cases) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT(graph_id,revision,node_id) DO NOTHING`, graph.ID, graph.Revision, node.ID, node.DispatchID, node.IdempotencyKey, node.State, node.Attempts, node.Purpose, node.TaskProfileID, node.BindingID, node.CaseIDs, profileJSON, casesJSON); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, edge := range graph.Edges {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO work.graph_edges(graph_id,revision,from_node_id,to_node_id) VALUES($1,$2,$3,$4) ON CONFLICT(graph_id,revision,from_node_id,to_node_id) DO NOTHING`, graph.ID, graph.Revision, edge.From, edge.To); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *WorkRepository) LoadWorkGraph(ctx context.Context, graphID string, revision int64) (work.WorkGraph, error) {
|
||||
var graph work.WorkGraph
|
||||
err := r.pool.QueryRow(ctx, `SELECT graph_id,revision,state,max_parallel,max_attempts,parent_graph_id,parent_graph_revision,trigger_evidence_ref,orchestration_bound,orchestration_goal_id,orchestration_goal_state,orchestration_search_round,orchestration_max_round,orchestration_cancel_requested,orchestration_verification_ready,orchestration_unresolved FROM work.graphs WHERE graph_id=$1 AND revision=$2`, graphID, revision).Scan(&graph.ID, &graph.Revision, &graph.State, &graph.Budget.MaxParallel, &graph.Budget.MaxAttempts, &graph.ParentGraphID, &graph.ParentGraphRevision, &graph.TriggerEvidenceRef, &graph.SearchInputBound, &graph.SearchInput.GoalID, &graph.SearchInput.GoalState, &graph.SearchInput.SearchRound, &graph.SearchInput.MaxRound, &graph.SearchInput.CancelRequested, &graph.SearchInput.VerificationReady, &graph.SearchInput.Unresolved)
|
||||
if err != nil {
|
||||
return work.WorkGraph{}, fmt.Errorf("load graph: %w", err)
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `SELECT node_id,dispatch_id,idempotency_key,state,attempts,purpose,task_profile_id,binding_id,case_ids,task_evaluation_profile,task_evaluation_cases FROM work.graph_nodes WHERE graph_id=$1 AND revision=$2 ORDER BY node_id`, graphID, revision)
|
||||
if err != nil {
|
||||
return work.WorkGraph{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var node work.GraphNode
|
||||
var profileJSON, casesJSON []byte
|
||||
if err := rows.Scan(&node.ID, &node.DispatchID, &node.IdempotencyKey, &node.State, &node.Attempts, &node.Purpose, &node.TaskProfileID, &node.BindingID, &node.CaseIDs, &profileJSON, &casesJSON); err != nil {
|
||||
rows.Close()
|
||||
return work.WorkGraph{}, err
|
||||
}
|
||||
if err := unmarshalTaskPayload(profileJSON, casesJSON, &node.TaskProfile, &node.TaskCases); err != nil {
|
||||
rows.Close()
|
||||
return work.WorkGraph{}, err
|
||||
}
|
||||
graph.Nodes = append(graph.Nodes, node)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return work.WorkGraph{}, err
|
||||
}
|
||||
rows.Close()
|
||||
edges, err := r.pool.Query(ctx, `SELECT from_node_id,to_node_id FROM work.graph_edges WHERE graph_id=$1 AND revision=$2 ORDER BY from_node_id,to_node_id`, graphID, revision)
|
||||
if err != nil {
|
||||
return work.WorkGraph{}, err
|
||||
}
|
||||
for edges.Next() {
|
||||
var edge work.GraphEdge
|
||||
if err := edges.Scan(&edge.From, &edge.To); err != nil {
|
||||
edges.Close()
|
||||
return work.WorkGraph{}, err
|
||||
}
|
||||
graph.Edges = append(graph.Edges, edge)
|
||||
}
|
||||
if err := edges.Err(); err != nil {
|
||||
edges.Close()
|
||||
return work.WorkGraph{}, err
|
||||
}
|
||||
edges.Close()
|
||||
return graph, nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveGraphSynthesis(ctx context.Context, conflict work.ConflictSet, candidates []work.ResultCandidate, synthesis work.GraphSynthesisCandidate) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
for _, candidate := range candidates {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO work.graph_result_candidates(result_candidate_id,graph_id,graph_revision,node_id,author_run_id,artifact_refs,evidence_summary,summary) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(result_candidate_id) DO NOTHING`, candidate.ID, candidate.GraphID, candidate.GraphRevision, candidate.NodeID, candidate.AuthorRunID, candidate.ArtifactRefs, candidate.EvidenceSummary, candidate.Summary); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO work.graph_conflicts(conflict_id,graph_id,graph_revision,node_id,candidate_ids,resolved) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(conflict_id) DO NOTHING`, conflict.ID, conflict.GraphID, conflict.GraphRevision, conflict.NodeID, conflict.CandidateIDs, conflict.Resolved); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO work.graph_synthesis_candidates(synthesis_id,graph_id,graph_revision,node_id,candidate_ids,selected_candidate_id,dissent_candidate_ids,verifier_run_id,verifier_identity,evidence_refs,summary,state) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT(synthesis_id) DO NOTHING`, synthesis.ID, synthesis.GraphID, synthesis.GraphRevision, synthesis.NodeID, synthesis.CandidateIDs, synthesis.SelectedCandidateID, synthesis.DissentCandidateIDs, synthesis.VerifierRunID, synthesis.VerifierIdentity, synthesis.EvidenceRefs, synthesis.Summary, synthesis.State); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveGraphResultCandidate(ctx context.Context, candidate work.ResultCandidate) error {
|
||||
if candidate.ID == "" || candidate.GraphID == "" || candidate.GraphRevision < 1 || candidate.NodeID == "" || candidate.AuthorRunID == "" || candidate.EvidenceSummary == "" || candidate.Summary == "" {
|
||||
return errors.New("invalid graph result candidate")
|
||||
}
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO work.graph_result_candidates(result_candidate_id,graph_id,graph_revision,node_id,author_run_id,artifact_refs,evidence_summary,summary) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(result_candidate_id) DO NOTHING`, candidate.ID, candidate.GraphID, candidate.GraphRevision, candidate.NodeID, candidate.AuthorRunID, candidate.ArtifactRefs, candidate.EvidenceSummary, candidate.Summary)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *WorkRepository) RecoverStaleClaims(ctx context.Context, nowUnixMillis, staleAfterMillis int64) (int64, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, `UPDATE work.agent_leases AS l SET state='EXPIRED' FROM work.agent_dispatch_queue AS q JOIN work.agent_dispatch_intents AS i ON i.dispatch_id=q.dispatch_id WHERE q.state='CLAIMED' AND q.updated_at_unix_millis <= $1 AND l.run_id=i.run_id AND l.state='ACTIVE'`, nowUnixMillis-staleAfterMillis); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
command, err := tx.Exec(ctx, `UPDATE work.agent_dispatch_queue SET state=CASE WHEN attempts >= retry_limit THEN 'FAILED' ELSE 'QUEUED' END,available_at_unix_millis=$1,claimed_by='',updated_at_unix_millis=$2 WHERE state='CLAIMED' AND updated_at_unix_millis <= $3`, nowUnixMillis, nowUnixMillis, nowUnixMillis-staleAfterMillis)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return command.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveAgentLease(ctx context.Context, lease work.AgentLease) error {
|
||||
err := r.pool.QueryRow(ctx, `INSERT INTO work.agent_leases(lease_id,run_id,worker_id,fencing_token,state,acquired_at_unix_millis,expires_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(run_id) DO UPDATE SET lease_id=EXCLUDED.lease_id,worker_id=EXCLUDED.worker_id,fencing_token=EXCLUDED.fencing_token,state=EXCLUDED.state,acquired_at_unix_millis=EXCLUDED.acquired_at_unix_millis,expires_at_unix_millis=EXCLUDED.expires_at_unix_millis WHERE work.agent_leases.state='EXPIRED' OR work.agent_leases.expires_at_unix_millis <= EXCLUDED.acquired_at_unix_millis RETURNING run_id`, lease.ID, lease.RunID, lease.WorkerID, lease.FencingToken, lease.State, lease.AcquiredAt.UnixMilli(), lease.ExpiresAt.UnixMilli()).Scan(new(string))
|
||||
if err != nil {
|
||||
@@ -399,3 +713,32 @@ func latestRowErr(err error) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func marshalTaskPayload(profile *capability.TaskEvaluationProfile, cases []capability.TaskEvaluationCase) ([]byte, []byte, error) {
|
||||
var profileJSON []byte
|
||||
var err error
|
||||
if profile != nil {
|
||||
profileJSON, err = json.Marshal(profile)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
casesJSON, err := json.Marshal(cases)
|
||||
return profileJSON, casesJSON, err
|
||||
}
|
||||
|
||||
func unmarshalTaskPayload(profileJSON, casesJSON []byte, profile **capability.TaskEvaluationProfile, cases *[]capability.TaskEvaluationCase) error {
|
||||
if len(profileJSON) > 0 && string(profileJSON) != "null" {
|
||||
var value capability.TaskEvaluationProfile
|
||||
if err := json.Unmarshal(profileJSON, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
*profile = &value
|
||||
}
|
||||
if len(casesJSON) > 0 && string(casesJSON) != "null" {
|
||||
if err := json.Unmarshal(casesJSON, cases); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package research
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
)
|
||||
|
||||
var ErrHTTPSourceRejected = errors.New("http research source rejected")
|
||||
|
||||
type HTTPConnector struct {
|
||||
Client *http.Client
|
||||
AllowedHosts map[string]struct{}
|
||||
MaxBytes int64
|
||||
}
|
||||
|
||||
func (c HTTPConnector) Collect(ctx context.Context, request epistemics.WebResearchRequest, nowUnixMillis int64) (epistemics.ResearchCandidate, error) {
|
||||
if request.Plan.Validate() != nil || request.SourceID == "" || strings.TrimSpace(request.Statement) == "" || nowUnixMillis < 1 {
|
||||
return epistemics.ResearchCandidate{}, ErrHTTPSourceRejected
|
||||
}
|
||||
u, err := url.Parse(request.URL)
|
||||
if err != nil || u.Scheme != "https" || u.User != nil || u.Host == "" || !c.allowedHost(strings.ToLower(u.Hostname())) {
|
||||
return epistemics.ResearchCandidate{}, ErrHTTPSourceRejected
|
||||
}
|
||||
client := c.Client
|
||||
if client == nil {
|
||||
client = &http.Client{}
|
||||
}
|
||||
maxBytes := c.MaxBytes
|
||||
if maxBytes <= 0 || maxBytes > 4<<20 {
|
||||
maxBytes = 256 << 10
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return epistemics.ResearchCandidate{}, ErrHTTPSourceRejected
|
||||
}
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
return epistemics.ResearchCandidate{}, fmt.Errorf("read web source: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return epistemics.ResearchCandidate{}, fmt.Errorf("web source status: %s", response.Status)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, maxBytes+1))
|
||||
if err != nil || int64(len(body)) > maxBytes || len(body) == 0 {
|
||||
return epistemics.ResearchCandidate{}, ErrHTTPSourceRejected
|
||||
}
|
||||
digest := sha256.Sum256(body)
|
||||
digestText := "sha256:" + hex.EncodeToString(digest[:])
|
||||
key := sha256.Sum256([]byte(u.String() + "\x00" + digestText))
|
||||
keyText := hex.EncodeToString(key[:])
|
||||
snapshotID := epistemics.SourceSnapshotID("snapshot:" + keyText)
|
||||
return epistemics.ResearchCandidate{
|
||||
PropositionKey: request.PropositionKey, Statement: request.Statement,
|
||||
Source: epistemics.Source{ID: request.SourceID, Kind: "web", Locator: u.String(), Title: request.Title, Publisher: u.Hostname()},
|
||||
Snapshot: epistemics.SourceSnapshot{ID: snapshotID, SourceID: request.SourceID, ContentDigest: digestText, CapturedAtUnixMillis: nowUnixMillis, Freshness: epistemics.FreshnessCurrent},
|
||||
Excerpt: epistemics.SourceExcerpt{ID: epistemics.SourceExcerptID("excerpt:" + keyText), SnapshotID: snapshotID, Text: string(body), Start: 0, End: int64(len(body))},
|
||||
Evidence: epistemics.EvidenceObserved, Coverage: epistemics.CoverageComplete, Conflict: epistemics.ConflictNone,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c HTTPConnector) allowedHost(host string) bool {
|
||||
_, ok := c.AllowedHosts[host]
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package research
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
)
|
||||
|
||||
var ErrRepositoryPathRejected = errors.New("repository source path rejected")
|
||||
|
||||
type RepositoryConnector struct{ Root string }
|
||||
|
||||
func (c RepositoryConnector) Collect(ctx context.Context, request epistemics.RepositoryResearchRequest, nowUnixMillis int64) (epistemics.ResearchCandidate, error) {
|
||||
if err := request.Plan.Validate(); err != nil {
|
||||
return epistemics.ResearchCandidate{}, err
|
||||
}
|
||||
if request.SourceID == "" || strings.TrimSpace(request.Statement) == "" || nowUnixMillis < 1 {
|
||||
return epistemics.ResearchCandidate{}, ErrRepositoryPathRejected
|
||||
}
|
||||
root, err := filepath.Abs(c.Root)
|
||||
if err != nil || root == "" {
|
||||
return epistemics.ResearchCandidate{}, ErrRepositoryPathRejected
|
||||
}
|
||||
path, err := filepath.Abs(filepath.Join(root, filepath.FromSlash(request.Path)))
|
||||
if err != nil || !withinRoot(root, path) {
|
||||
return epistemics.ResearchCandidate{}, ErrRepositoryPathRejected
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return epistemics.ResearchCandidate{}, ctx.Err()
|
||||
default:
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil || len(content) == 0 {
|
||||
return epistemics.ResearchCandidate{}, fmt.Errorf("read repository source: %w", err)
|
||||
}
|
||||
digest := sha256.Sum256(content)
|
||||
digestText := "sha256:" + hex.EncodeToString(digest[:])
|
||||
key := sha256.Sum256([]byte(filepath.ToSlash(path) + "\x00" + digestText))
|
||||
keyText := hex.EncodeToString(key[:])
|
||||
return epistemics.ResearchCandidate{
|
||||
PropositionKey: request.PropositionKey,
|
||||
Statement: request.Statement,
|
||||
Source: epistemics.Source{ID: request.SourceID, Kind: "repository", Locator: "file://" + filepath.ToSlash(path), Title: filepath.Base(path), Publisher: "local-repository"},
|
||||
Snapshot: epistemics.SourceSnapshot{ID: epistemics.SourceSnapshotID("snapshot:" + keyText), SourceID: request.SourceID, ContentDigest: digestText, CapturedAtUnixMillis: nowUnixMillis, Freshness: epistemics.FreshnessCurrent},
|
||||
Excerpt: epistemics.SourceExcerpt{ID: epistemics.SourceExcerptID("excerpt:" + keyText), SnapshotID: epistemics.SourceSnapshotID("snapshot:" + keyText), Text: string(content), Start: 0, End: int64(len(content))},
|
||||
Evidence: epistemics.EvidenceObserved,
|
||||
Coverage: epistemics.CoverageComplete,
|
||||
Conflict: epistemics.ConflictNone,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func withinRoot(root, path string) bool {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package research
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
)
|
||||
|
||||
var ErrSearchRejected = errors.New("web search request rejected")
|
||||
|
||||
var (
|
||||
searchLinkPattern = regexp.MustCompile(`(?is)(<a[^>]+class=["'][^"']*result__a[^"']*["'][^>]*>)(.*?)</a>`)
|
||||
searchSnippetPattern = regexp.MustCompile(`(?is)<a[^>]+class=["'][^"']*result__snippet[^"']*["'][^>]*>(.*?)</a>`)
|
||||
bingLinkPattern = regexp.MustCompile(`(?is)<li[^>]+class=["'][^"']*b_algo[^"']*["'][^>]*>.*?<h2[^>]*>(<a[^>]*>)(.*?)</a>`)
|
||||
bingSnippetPattern = regexp.MustCompile(`(?is)<li[^>]+class=["'][^"']*b_algo[^"']*["'][^>]*>.*?<p[^>]*>(.*?)</p>`)
|
||||
tagPattern = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||
termPattern = regexp.MustCompile(`(?i)[\p{L}\p{N}]{2,}`)
|
||||
)
|
||||
|
||||
var searchStopTerms = map[string]struct{}{
|
||||
"какая": {}, "какое": {}, "какие": {}, "какой": {}, "что": {}, "как": {}, "где": {}, "когда": {}, "почему": {}, "мне": {}, "пожалуйста": {},
|
||||
"what": {}, "which": {}, "where": {}, "when": {}, "why": {}, "how": {}, "please": {}, "the": {}, "this": {},
|
||||
}
|
||||
|
||||
// SearchConnector uses a configured HTTPS search endpoint. It does not know
|
||||
// cities, weather terms, entities or model names: the complete semantic query
|
||||
// comes from the interpretation layer.
|
||||
type SearchConnector struct {
|
||||
Client *http.Client
|
||||
EndpointTemplate string
|
||||
AllowedHosts map[string]struct{}
|
||||
FallbackEndpoints []SearchEndpoint
|
||||
MaxBytes int64
|
||||
MaxResults int
|
||||
}
|
||||
|
||||
// SearchEndpoint is an independently allowlisted web-search route. The
|
||||
// connector tries routes in order and stops at the first relevant evidence.
|
||||
// This is a source-recovery policy, not a question-type classifier.
|
||||
type SearchEndpoint struct {
|
||||
Template string
|
||||
AllowedHosts map[string]struct{}
|
||||
}
|
||||
|
||||
func (c SearchConnector) Collect(ctx context.Context, request epistemics.WebSearchRequest, nowUnixMillis int64) ([]epistemics.ResearchCandidate, error) {
|
||||
if request.Plan.Validate() != nil || strings.TrimSpace(request.Query) == "" || nowUnixMillis < 1 || !strings.Contains(c.EndpointTemplate, "%s") {
|
||||
return nil, ErrSearchRejected
|
||||
}
|
||||
endpoints := make([]SearchEndpoint, 0, 1+len(c.FallbackEndpoints))
|
||||
endpoints = append(endpoints, SearchEndpoint{Template: c.EndpointTemplate, AllowedHosts: c.AllowedHosts})
|
||||
endpoints = append(endpoints, c.FallbackEndpoints...)
|
||||
var lastErr error
|
||||
for _, candidateEndpoint := range endpoints {
|
||||
results, err := c.collectEndpoint(ctx, candidateEndpoint, request, nowUnixMillis)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if len(results) > 0 {
|
||||
return results, nil
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c SearchConnector) collectEndpoint(ctx context.Context, configured SearchEndpoint, request epistemics.WebSearchRequest, nowUnixMillis int64) ([]epistemics.ResearchCandidate, error) {
|
||||
if !strings.Contains(configured.Template, "%s") {
|
||||
return nil, ErrSearchRejected
|
||||
}
|
||||
searchQuery := enrichedSearchQuery(request.Query)
|
||||
endpoint, err := url.Parse(strings.Replace(configured.Template, "%s", url.QueryEscape(searchQuery), 1))
|
||||
if err != nil || endpoint.Scheme != "https" || endpoint.Host == "" || !allowedHost(configured.AllowedHosts, strings.ToLower(endpoint.Hostname())) {
|
||||
return nil, ErrSearchRejected
|
||||
}
|
||||
client := c.Client
|
||||
if client == nil {
|
||||
client = &http.Client{}
|
||||
}
|
||||
maxBytes := c.MaxBytes
|
||||
if maxBytes <= 0 || maxBytes > 4<<20 {
|
||||
maxBytes = 512 << 10
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, ErrSearchRejected
|
||||
}
|
||||
// Search providers commonly return a consent/challenge shell to the
|
||||
// default Go client. A neutral browser identification improves the same
|
||||
// generic evidence path for every query without selecting by topic.
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; DomovoyResearch/1.0)")
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, ErrSearchRejected
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, maxBytes+1))
|
||||
if err != nil || len(body) == 0 || int64(len(body)) > maxBytes {
|
||||
return nil, ErrSearchRejected
|
||||
}
|
||||
links := searchLinkPattern.FindAllSubmatch(body, c.limit())
|
||||
snippets := searchSnippetPattern.FindAllSubmatch(body, c.limit())
|
||||
if len(links) == 0 {
|
||||
links = bingLinkPattern.FindAllSubmatch(body, c.limit())
|
||||
snippets = bingSnippetPattern.FindAllSubmatch(body, c.limit())
|
||||
}
|
||||
if len(links) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
digest := sha256.Sum256(body)
|
||||
digestText := "sha256:" + hex.EncodeToString(digest[:])
|
||||
base := hex.EncodeToString(digest[:])
|
||||
sourceID := epistemics.SourceID("source:search:" + base)
|
||||
snapshotID := epistemics.SourceSnapshotID("snapshot:search:" + base)
|
||||
results := make([]epistemics.ResearchCandidate, 0, len(links))
|
||||
for index, link := range links {
|
||||
linkURL := resolveSearchResultLocator(extractHref(string(link[1])), endpoint)
|
||||
title := cleanHTML(string(link[2]))
|
||||
snippet := ""
|
||||
if index < len(snippets) {
|
||||
snippet = cleanHTML(string(snippets[index][1]))
|
||||
}
|
||||
statement := strings.TrimSpace(strings.Trim(title+" — "+snippet, " —"))
|
||||
if statement == "" || !searchStatementRelatesToQuery(request.Query, statement) {
|
||||
continue
|
||||
}
|
||||
ordinal := strconv.Itoa(index)
|
||||
excerptID := epistemics.SourceExcerptID("excerpt:search:" + base + ":" + ordinal)
|
||||
results = append(results, epistemics.ResearchCandidate{
|
||||
PropositionKey: request.PropositionKey + ":result:" + ordinal,
|
||||
Statement: statement,
|
||||
Source: epistemics.Source{ID: sourceID, Kind: "web-search", Locator: linkURL, Title: title, Publisher: sourcePublisher(linkURL, endpoint.Hostname())},
|
||||
Snapshot: epistemics.SourceSnapshot{ID: snapshotID, SourceID: sourceID, ContentDigest: digestText, CapturedAtUnixMillis: nowUnixMillis, Freshness: epistemics.FreshnessCurrent},
|
||||
Excerpt: epistemics.SourceExcerpt{ID: excerptID, SnapshotID: snapshotID, Text: statement, Start: 0, End: int64(len(statement))},
|
||||
Evidence: epistemics.EvidenceObserved, Coverage: epistemics.CoverageComplete, Conflict: epistemics.ConflictNone,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// searchStatementRelatesToQuery is a bounded relevance gate, not a truth
|
||||
// check. It prevents generic search pages from becoming apparent evidence
|
||||
// when their content has no substantive relation to the requested query.
|
||||
func searchStatementRelatesToQuery(query, statement string) bool {
|
||||
terms := termPattern.FindAllString(strings.ToLower(query), -1)
|
||||
content := strings.ToLower(statement)
|
||||
productCue := containsSearchTerm(terms, "видеокарт", "gpu", "процессор", "телефон", "ноутбук", "монитор")
|
||||
if productCue && hasNumericSearchTerm(terms) {
|
||||
contentTerms := termPattern.FindAllString(content, -1)
|
||||
return hasNumericSearchTerm(contentTerms) && containsSearchTerm(contentTerms, "видеокарт", "gpu", "процессор", "телефон", "ноутбук", "монитор")
|
||||
}
|
||||
usable := 0
|
||||
for _, term := range terms {
|
||||
if _, stop := searchStopTerms[term]; stop || len([]rune(term)) < 4 {
|
||||
continue
|
||||
}
|
||||
usable++
|
||||
runes := []rune(term)
|
||||
prefixLen := 5
|
||||
if len(runes) < prefixLen {
|
||||
prefixLen = len(runes)
|
||||
}
|
||||
if strings.Contains(content, string(runes[:prefixLen])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return usable == 0
|
||||
}
|
||||
|
||||
func containsSearchTerm(values []string, wanted ...string) bool {
|
||||
for _, value := range values {
|
||||
for _, candidate := range wanted {
|
||||
if strings.Contains(value, candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasNumericSearchTerm(values []string) bool {
|
||||
for _, value := range values {
|
||||
for _, runeValue := range value {
|
||||
if runeValue >= '0' && runeValue <= '9' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func enrichedSearchQuery(query string) string {
|
||||
trimmed := strings.TrimSpace(query)
|
||||
lower := strings.ToLower(trimmed)
|
||||
if (strings.Contains(lower, "цен") || strings.Contains(lower, "стоим")) && !strings.Contains(lower, "куп") {
|
||||
return trimmed + " цена купить сегодня"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func allowedHost(hosts map[string]struct{}, host string) bool { _, ok := hosts[host]; return ok }
|
||||
func (c SearchConnector) limit() int {
|
||||
if c.MaxResults <= 0 || c.MaxResults > 8 {
|
||||
return 3
|
||||
}
|
||||
return c.MaxResults
|
||||
}
|
||||
func cleanHTML(value string) string {
|
||||
return strings.Join(strings.Fields(html.UnescapeString(tagPattern.ReplaceAllString(value, ""))), " ")
|
||||
}
|
||||
|
||||
func extractHref(tag string) string {
|
||||
match := regexp.MustCompile(`(?is)\bhref=["']([^"']+)["']`).FindStringSubmatch(tag)
|
||||
if len(match) == 2 {
|
||||
return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveSearchResultLocator(raw string, searchEndpoint *url.URL) string {
|
||||
value := strings.TrimSpace(html.UnescapeString(raw))
|
||||
if value == "" {
|
||||
return searchEndpoint.String()
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return searchEndpoint.String()
|
||||
}
|
||||
if target := parsed.Query().Get("uddg"); target != "" {
|
||||
value = target
|
||||
parsed, err = url.Parse(target)
|
||||
if err != nil {
|
||||
return searchEndpoint.String()
|
||||
}
|
||||
}
|
||||
if target := parsed.Query().Get("u"); strings.HasPrefix(target, "a1") {
|
||||
if decoded, decodeErr := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(target, "a1")); decodeErr == nil {
|
||||
if targetURL, parseErr := url.Parse(string(decoded)); parseErr == nil {
|
||||
parsed = targetURL
|
||||
}
|
||||
}
|
||||
}
|
||||
if parsed.Scheme == "" {
|
||||
parsed.Scheme = "https"
|
||||
}
|
||||
if parsed.Host == "" && strings.HasPrefix(value, "//") {
|
||||
parsed, err = url.Parse("https:" + value)
|
||||
}
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
|
||||
return searchEndpoint.String()
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func sourcePublisher(locator, fallback string) string {
|
||||
if parsed, err := url.Parse(locator); err == nil && parsed.Hostname() != "" {
|
||||
return parsed.Hostname()
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package research
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
)
|
||||
|
||||
func TestSearchConnectorFallsBackToAnotherAllowedSource(t *testing.T) {
|
||||
primary := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`<html><body>no usable results</body></html>`))
|
||||
}))
|
||||
defer primary.Close()
|
||||
fallback := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`<a class="result__a" href="https://example.test">Казань: текущая температура 18 °C</a><a class="result__snippet">Наблюдается переменная облачность.</a>`))
|
||||
}))
|
||||
defer fallback.Close()
|
||||
client := fallback.Client()
|
||||
client.Transport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // test servers only
|
||||
request := epistemics.WebSearchRequest{Plan: epistemics.ResearchPlan{ID: "plan:test", Question: "какая погода в Казани", SourceClasses: []string{"web-search"}, FreshnessPolicy: string(epistemics.FreshnessCurrent)}, PropositionKey: "question:test", Query: "какая погода в Казани"}
|
||||
connector := SearchConnector{
|
||||
Client: client,
|
||||
EndpointTemplate: primary.URL + "?q=%s",
|
||||
AllowedHosts: map[string]struct{}{strings.TrimPrefix(primary.URL, "https://"): {}},
|
||||
FallbackEndpoints: []SearchEndpoint{{
|
||||
Template: fallback.URL + "?q=%s",
|
||||
AllowedHosts: map[string]struct{}{strings.TrimPrefix(fallback.URL, "https://"): {}},
|
||||
}},
|
||||
}
|
||||
// The allowlist is hostname-only; test servers include an ephemeral port.
|
||||
connector.AllowedHosts = map[string]struct{}{strings.Split(strings.TrimPrefix(primary.URL, "https://"), ":")[0]: {}}
|
||||
connector.FallbackEndpoints[0].AllowedHosts = map[string]struct{}{strings.Split(strings.TrimPrefix(fallback.URL, "https://"), ":")[0]: {}}
|
||||
results, err := connector.Collect(context.Background(), request, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 1 || !strings.Contains(results[0].Statement, "18") {
|
||||
t.Fatalf("fallback evidence = %#v", results)
|
||||
}
|
||||
if results[0].Source.Locator != "https://example.test" || results[0].Source.Publisher != "example.test" {
|
||||
t.Fatalf("search result source = %#v", results[0].Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichedSearchQueryAddsPriceSignalsOnlyToPriceRequests(t *testing.T) {
|
||||
if got := enrichedSearchQuery("проверь цену на видеокарту 4090"); got != "проверь цену на видеокарту 4090 цена купить сегодня" {
|
||||
t.Fatalf("price query = %q", got)
|
||||
}
|
||||
if got := enrichedSearchQuery("какая погода в Казани"); got != "какая погода в Казани" {
|
||||
t.Fatalf("non-price query = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,16 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
"git.cin.su/m/Assistant/modules/work/application"
|
||||
"git.cin.su/m/Assistant/modules/work/ports"
|
||||
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||
)
|
||||
|
||||
type memoryStore struct{ writes int }
|
||||
@@ -20,7 +24,8 @@ func (s *memoryStore) SaveDispatchIntent(context.Context, work.AgentDispatchInte
|
||||
s.writes++
|
||||
return nil
|
||||
}
|
||||
func (s *memoryStore) SaveAgentLease(context.Context, work.AgentLease) error { s.writes++; return nil }
|
||||
func (s *memoryStore) SaveAgentLease(context.Context, work.AgentLease) error { s.writes++; return nil }
|
||||
func (s *memoryStore) RenewAgentLease(context.Context, work.AgentLease, time.Time) error { return nil }
|
||||
func (s *memoryStore) AcceptAgentCallback(context.Context, work.AgentCallback) error {
|
||||
s.writes++
|
||||
return nil
|
||||
@@ -30,14 +35,24 @@ func main() {
|
||||
enabled := flag.Bool("enable-canary", false, "explicitly enable the manual canary")
|
||||
implementation := flag.String("implementation", "fake-worker", "allowlisted deterministic implementation")
|
||||
postgresMode := flag.Bool("postgres", false, "use the Work PostgreSQL repository instead of in-memory storage")
|
||||
runID := flag.String("run-id", "", "optional unique canary run suffix; generated when omitted")
|
||||
flag.Parse()
|
||||
|
||||
now := time.UnixMilli(1_700_000_000_000)
|
||||
intent := work.AgentDispatchIntent{DispatchID: "manual-dispatch-1", IdempotencyKey: "manual-idempotency-1", DelegationID: "manual-delegation-1", RunID: "manual-run-1", ImplementationID: *implementation, ImplementationRevision: 1, SourceRevision: "manual-source-1", WorktreeID: "manual-worktree-1", VerificationPlanRef: "manual-verification-1", CreatedAtUnixMillis: now.UnixMilli()}
|
||||
suffix := *runID
|
||||
if suffix == "" {
|
||||
suffix = fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
intent := work.AgentDispatchIntent{DispatchID: "manual-dispatch-" + suffix, IdempotencyKey: "manual-idempotency-" + suffix, DelegationID: work.DelegationID("manual-delegation-" + suffix), RunID: work.AgentRunID("manual-run-" + suffix), ImplementationID: *implementation, ImplementationRevision: 1, SourceRevision: "manual-source-" + suffix, WorktreeID: "manual-worktree-" + suffix, VerificationPlanRef: "manual-verification-" + suffix, CreatedAtUnixMillis: now.UnixMilli()}
|
||||
var store ports.ExecutionStore = &memoryStore{}
|
||||
var closeStore func()
|
||||
if *postgresMode {
|
||||
repository, err := postgres.OpenWork(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("WORK_POSTGRES_USER"), Password: os.Getenv("WORK_POSTGRES_PASSWORD")})
|
||||
password, err := resolveDatabasePassword()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
repository, err := postgres.OpenWork(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("WORK_POSTGRES_USER"), Password: password})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
@@ -67,3 +82,27 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveDatabasePassword() (string, error) {
|
||||
if _, ok := os.LookupEnv("WORK_POSTGRES_PASSWORD"); ok {
|
||||
return "", fmt.Errorf("WORK_POSTGRES_PASSWORD direct environment secret is forbidden")
|
||||
}
|
||||
path, ref := os.Getenv("WORK_POSTGRES_PASSWORD_FILE"), os.Getenv("WORK_POSTGRES_CREDENTIAL_REF")
|
||||
if path == "" || ref == "" {
|
||||
return "", fmt.Errorf("WORK_POSTGRES_PASSWORD_FILE and WORK_POSTGRES_CREDENTIAL_REF are required")
|
||||
}
|
||||
root := os.Getenv("SECRET_BACKEND_ROOT")
|
||||
if root == "" {
|
||||
root = "/run/secrets"
|
||||
}
|
||||
cleanRoot := filepath.Clean(root)
|
||||
cleanPath := filepath.Clean(path)
|
||||
if cleanPath != cleanRoot && !strings.HasPrefix(cleanPath, cleanRoot+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("database password file escapes protected secret root")
|
||||
}
|
||||
resolver, err := platformsecrets.NewFileResolver(cleanRoot)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerWorkWorker, Path: cleanPath})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveDatabasePasswordRejectsDirectEnvironmentSecret(t *testing.T) {
|
||||
t.Setenv("WORK_POSTGRES_PASSWORD", "literal-secret")
|
||||
if _, err := resolveDatabasePassword(); err == nil {
|
||||
t.Fatal("direct database password environment variable was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDatabasePasswordRejectsPathOutsideProtectedRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("SECRET_BACKEND_ROOT", root)
|
||||
t.Setenv("WORK_POSTGRES_PASSWORD_FILE", filepath.Join(root, "..", "outside"))
|
||||
t.Setenv("WORK_POSTGRES_CREDENTIAL_REF", "credential://domovoy/work/db-password/v1")
|
||||
if _, err := resolveDatabasePassword(); err == nil {
|
||||
t.Fatal("database password path outside protected root was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/artifact"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain"
|
||||
)
|
||||
|
||||
type bridge struct {
|
||||
cognitiveURL string
|
||||
callbackURL string
|
||||
verifierURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func main() {
|
||||
b := bridge{
|
||||
cognitiveURL: strings.TrimRight(envOr("COGNITIVE_WORKER_URL", "http://cognitive-worker:8083"), "/"),
|
||||
callbackURL: strings.TrimRight(envOr("WORK_GRAPH_RUNTIME_URL", "http://work-graph-runtime:8088"), "/"),
|
||||
verifierURL: strings.TrimRight(envOr("TASK_VERIFIER_URL", "http://task-verifier:8091"), "/"),
|
||||
client: &http.Client{Timeout: 10 * time.Minute},
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", healthHandler)
|
||||
mux.HandleFunc("/readyz", healthHandler)
|
||||
mux.HandleFunc("/v1/execute", b.executeHandler)
|
||||
addr := envOr("LISTEN_ADDR", ":8092")
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (b bridge) executeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var request work.AgentExecutionRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid execution request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := b.execute(r.Context(), request)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
func (b bridge) execute(ctx context.Context, request work.AgentExecutionRequest) (work.AgentExecutionResult, error) {
|
||||
now := time.Now().UnixMilli()
|
||||
intent := request.Intent
|
||||
if intent.TaskEvaluationExecution != nil && intent.TaskEvaluationExecution.GenericMessage != "" {
|
||||
return b.executeGenericCognitiveWork(ctx, request, *intent.TaskEvaluationExecution)
|
||||
}
|
||||
if intent.TaskEvaluationExecution == nil || intent.TaskEvaluationProfile == nil || len(intent.TaskEvaluationCases) == 0 {
|
||||
return work.AgentExecutionResult{State: work.AgentExecutionWaiting, Summary: "локальный bridge не поддерживает это намерение; работа сохранена и ожидает подходящего исполнителя", ObservedAtUnixMillis: now}, nil
|
||||
}
|
||||
execution := intent.TaskEvaluationExecution
|
||||
if execution.GraphID == "" || execution.NodeID == "" || execution.BindingRevision < 1 || execution.ActorID == "" || execution.PrincipalID == "" || execution.ScopeID == "" {
|
||||
return work.AgentExecutionResult{State: work.AgentExecutionWaiting, Summary: "контекст task-evaluation неполон; работа сохранена для повторной маршрутизации", ObservedAtUnixMillis: now}, nil
|
||||
}
|
||||
payload := capability.TaskEvaluationExecutionRequest{Profile: *intent.TaskEvaluationProfile, CapabilityID: intent.TaskEvaluationProfile.CapabilityID, BindingID: intent.TaskEvaluationBindingID, BindingRevision: execution.BindingRevision, ActorID: execution.ActorID, PrincipalID: execution.PrincipalID, ScopeID: execution.ScopeID, Cases: intent.TaskEvaluationCases, MaxOutputBytes: 4096}
|
||||
response, err := postJSON[capability.TaskEvaluationExecutionResponse](ctx, b.client, b.cognitiveURL+"/v1/task-evaluations/execute", payload)
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
if len(response.Responses) == 0 || response.ObservedAtUnixMillis < 1 {
|
||||
return work.AgentExecutionResult{}, fmt.Errorf("task evaluation returned no observations")
|
||||
}
|
||||
caseByID := make(map[string]capability.TaskEvaluationCase, len(intent.TaskEvaluationCases))
|
||||
for _, item := range intent.TaskEvaluationCases {
|
||||
caseByID[item.CaseID] = item
|
||||
}
|
||||
observations := response.Observations
|
||||
if !hasCriticalFailure(observations) {
|
||||
observations = make([]capability.TaskCaseObservation, 0, len(response.Responses))
|
||||
for _, item := range response.Responses {
|
||||
caseValue, ok := caseByID[item.CaseID]
|
||||
if !ok || caseValue.Verification.Mode == "" {
|
||||
return work.AgentExecutionResult{State: work.AgentExecutionWaiting, Summary: "task-evaluation case has no bounded verifier; work remains waiting", ObservedAtUnixMillis: time.Now().UnixMilli()}, nil
|
||||
}
|
||||
verified, verifyErr := postJSON[capability.TaskCaseVerificationResponse](ctx, b.client, b.verifierURL+"/v1/task-evaluations/verify", capability.TaskCaseVerificationRequest{CaseID: item.CaseID, ResponseText: item.ResponseText, LatencyMicros: item.LatencyMicros, EvidenceArtifactRef: item.EvidenceArtifactRef, Verification: caseValue.Verification})
|
||||
if verifyErr != nil {
|
||||
return work.AgentExecutionResult{}, verifyErr
|
||||
}
|
||||
observations = append(observations, capability.TaskCaseObservation{CaseID: verified.CaseID, Result: verified.Result, QualityScoreMillis: verified.QualityScoreMillis, LatencyMicros: verified.LatencyMicros, CriticalFailure: verified.CriticalFailure, EvidenceArtifactRef: verified.EvidenceArtifactRef, VerificationEvidenceRef: verified.VerificationEvidenceRef, VerifierIdentity: verified.VerifierIdentity})
|
||||
}
|
||||
}
|
||||
evaluation, err := capabilitydomain.BuildTaskEvaluation(*intent.TaskEvaluationProfile, capability.TaskEvaluationID("evaluation:"+intent.DispatchID), response.BindingID, response.BindingRevision, intent.TaskEvaluationProfile.CapabilityID, observations, artifact.ArtifactID(intent.TaskEvaluationCases[0].EvidenceArtifactRef), response.ObservedAtUnixMillis)
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
callback := work.TaskEvaluationEvidenceCallback{GraphID: execution.GraphID, Revision: execution.GraphRevision, NodeID: execution.NodeID, Evaluation: evaluation, Succeeded: true}
|
||||
if _, err := postJSON[json.RawMessage](ctx, b.client, b.callbackURL+"/v1/work-graphs/task-evaluations/callback", callback); err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
return work.AgentExecutionResult{State: work.AgentExecutionCompleted, Summary: "local bridge executed task evaluation and persisted verifier callback", OutcomeRef: string(evaluation.ID), ArtifactRefs: []string{string(evaluation.ArtifactID)}, ObservedAtUnixMillis: time.Now().UnixMilli()}, nil
|
||||
}
|
||||
|
||||
func (b bridge) executeGenericCognitiveWork(ctx context.Context, request work.AgentExecutionRequest, execution work.TaskEvaluationDispatchContext) (work.AgentExecutionResult, error) {
|
||||
if execution.GraphID == "" || execution.NodeID == "" || execution.ActorID == "" || execution.PrincipalID == "" || execution.ScopeID == "" {
|
||||
return work.AgentExecutionResult{State: work.AgentExecutionWaiting, Summary: "generic cognitive work context is incomplete; durable work remains waiting for rerouting", ObservedAtUnixMillis: time.Now().UnixMilli()}, nil
|
||||
}
|
||||
maxOutputBytes := execution.GenericMaxOutputBytes
|
||||
if maxOutputBytes < 1 {
|
||||
maxOutputBytes = 4096
|
||||
}
|
||||
dataPolicy := capability.DataPolicy(execution.GenericDataPolicy)
|
||||
if dataPolicy == "" {
|
||||
dataPolicy = capability.DataPolicyLocalOnly
|
||||
}
|
||||
response, err := postJSON[cognitive.GatewayAdvisoryResponse](ctx, b.client, b.cognitiveURL+"/v1/advisories", cognitive.GatewayAdvisoryRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID("agent-work:" + request.Intent.DispatchID), ActorID: execution.ActorID,
|
||||
PrincipalID: execution.PrincipalID, ScopeID: execution.ScopeID, Message: execution.GenericMessage, Locale: execution.GenericLocale,
|
||||
MaxOutputBytes: maxOutputBytes, DataPolicy: dataPolicy, Meaning: cognitive.TaskMeaning{SpeechAct: "DIRECTIVE", RequestedOutcome: execution.GenericPurpose},
|
||||
})
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
if response.FinishReason != cognitive.FinishCompleted || strings.TrimSpace(response.AdvisoryText) == "" {
|
||||
return work.AgentExecutionResult{}, fmt.Errorf("generic cognitive work did not complete: %s", response.FinishReason)
|
||||
}
|
||||
candidateID := "candidate:" + request.Intent.DispatchID
|
||||
artifactRef := work.EvidenceRef("agent-output:" + request.Intent.DispatchID)
|
||||
verification, err := postJSON[work.GenericCandidateVerificationResponse](ctx, b.client, b.verifierURL+"/v1/work-graphs/candidates/verify", work.GenericCandidateVerificationRequest{
|
||||
GraphID: execution.GraphID, GraphRevision: execution.GraphRevision, NodeID: execution.NodeID, AuthorRunID: request.Intent.RunID, CandidateID: candidateID, Summary: response.AdvisoryText, EvidenceRefs: []work.EvidenceRef{artifactRef},
|
||||
})
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
if !verification.Verified || verification.VerificationEvidenceRef == "" || verification.VerifierIdentity == "" {
|
||||
return work.AgentExecutionResult{State: work.AgentExecutionWaiting, Summary: "generic candidate envelope was not independently verified; work remains waiting", ObservedAtUnixMillis: time.Now().UnixMilli()}, nil
|
||||
}
|
||||
candidate := work.GraphResultCallback{GraphID: execution.GraphID, Revision: execution.GraphRevision, Candidate: work.ResultCandidate{
|
||||
ID: candidateID, GraphID: execution.GraphID, GraphRevision: execution.GraphRevision, NodeID: execution.NodeID,
|
||||
AuthorRunID: request.Intent.RunID, ArtifactRefs: []work.EvidenceRef{artifactRef, work.EvidenceRef(verification.VerificationEvidenceRef)}, EvidenceSummary: "candidate attribution and evidence envelope independently verified; semantic content remains unverified",
|
||||
Summary: response.AdvisoryText,
|
||||
}}
|
||||
if _, err := postJSON[json.RawMessage](ctx, b.client, b.callbackURL+"/v1/work-graphs/result-callback", candidate); err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
return work.AgentExecutionResult{State: work.AgentExecutionCompleted, Summary: "local cognitive specialist produced a candidate for independent verification", OutcomeRef: "candidate:" + request.Intent.DispatchID, ArtifactRefs: []string{"agent-output:" + request.Intent.DispatchID}, ObservedAtUnixMillis: time.Now().UnixMilli()}, nil
|
||||
}
|
||||
|
||||
func hasCriticalFailure(observations []capability.TaskCaseObservation) bool {
|
||||
for _, observation := range observations {
|
||||
if observation.CriticalFailure {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func postJSON[T any](ctx context.Context, client *http.Client, url string, payload any) (T, error) {
|
||||
var result T
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return result, fmt.Errorf("POST %s: status %s", url, resp.Status)
|
||||
}
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
return result, nil
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"runtime":"agent-execution-bridge","status":"ok","capabilities":["task-evaluation","generic-cognitive-work"]}`))
|
||||
}
|
||||
|
||||
func envOr(name, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
func TestBridgeWaitsForUnsupportedIntent(t *testing.T) {
|
||||
b := bridge{client: http.DefaultClient}
|
||||
result, err := b.execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{DispatchID: "dispatch-unsupported"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.State != work.AgentExecutionWaiting || result.ObservedAtUnixMillis < 1 {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthHandlerExposesBoundedCapability(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
healthHandler(recorder, request)
|
||||
if recorder.Code != http.StatusOK || recorder.Body.String() == "" {
|
||||
t.Fatalf("status=%d body=%q", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeRunsTaskEvaluationThroughVerifierAndCallback(t *testing.T) {
|
||||
profile := capability.TaskEvaluationProfile{ID: "profile-1", Revision: 1, CapabilityID: "capability-1", Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "general-v1", RequiredCaseIDs: []string{"case-1"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
|
||||
verification := capability.TaskCaseVerification{Mode: capability.TaskCaseVerificationExactText, ExpectedText: "answer", MinimumQualityMillis: 900}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/task-evaluations/execute":
|
||||
_ = json.NewEncoder(w).Encode(capability.TaskEvaluationExecutionResponse{ProfileID: profile.ID, BindingID: "binding-1", BindingRevision: 3, Responses: []capability.TaskCaseResponse{{CaseID: "case-1", ResponseText: "answer", LatencyMicros: 2, EvidenceArtifactRef: "artifact-1"}}, ObservedAtUnixMillis: 10})
|
||||
case "/v1/task-evaluations/verify":
|
||||
_ = json.NewEncoder(w).Encode(capability.TaskCaseVerificationResponse{CaseID: "case-1", Result: capability.EvaluationSupported, QualityScoreMillis: 900, LatencyMicros: 2, EvidenceArtifactRef: "artifact-1", VerificationEvidenceRef: "verify-1", VerifierIdentity: "verifier-1"})
|
||||
case "/v1/work-graphs/task-evaluations/callback":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
b := bridge{cognitiveURL: server.URL, callbackURL: server.URL, verifierURL: server.URL, client: server.Client()}
|
||||
result, err := b.execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{DispatchID: "dispatch-1", TaskEvaluationBindingID: "binding-1", TaskEvaluationProfile: &profile, TaskEvaluationCases: []capability.TaskEvaluationCase{{CaseID: "case-1", Prompt: "answer", EvidenceArtifactRef: "artifact-1", Verification: verification}}, TaskEvaluationExecution: &work.TaskEvaluationDispatchContext{GraphID: "graph-1", GraphRevision: 1, NodeID: "node-1", BindingRevision: 3, ActorID: "actor-1", PrincipalID: "principal-1", ScopeID: "scope-1"}}})
|
||||
if err != nil || result.State != work.AgentExecutionCompleted || result.OutcomeRef == "" {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeRunsGenericCognitiveWorkAsUnverifiedGraphCandidate(t *testing.T) {
|
||||
var callback work.GraphResultCallback
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/advisories":
|
||||
var request cognitive.GatewayAdvisoryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.Message != "find the bounded solution" {
|
||||
t.Fatalf("unexpected advisory request: %+v err=%v", request, err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "candidate answer", FinishReason: cognitive.FinishCompleted})
|
||||
case "/v1/work-graphs/result-callback":
|
||||
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case "/v1/work-graphs/candidates/verify":
|
||||
_ = json.NewEncoder(w).Encode(work.GenericCandidateVerificationResponse{Verified: true, VerificationEvidenceRef: "verification:generic-1", VerifierIdentity: "deterministic-generic-envelope-verifier-v1", Summary: "envelope verified"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
b := bridge{cognitiveURL: server.URL, callbackURL: server.URL, verifierURL: server.URL, client: server.Client()}
|
||||
result, err := b.execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{
|
||||
DispatchID: "dispatch-generic", RunID: "run-generic",
|
||||
TaskEvaluationExecution: &work.TaskEvaluationDispatchContext{GraphID: "graph-1", GraphRevision: 2, NodeID: "node-1", ActorID: "actor-1", PrincipalID: "principal-1", ScopeID: "scope-1", GenericMessage: "find the bounded solution", GenericPurpose: "investigate", GenericDataPolicy: string(capability.DataPolicyLocalOnly)},
|
||||
}})
|
||||
if err != nil || result.State != work.AgentExecutionCompleted || callback.Candidate.Summary != "candidate answer" || callback.Candidate.AuthorRunID != "run-generic" || len(callback.Candidate.ArtifactRefs) != 2 {
|
||||
t.Fatalf("result=%+v callback=%+v err=%v", result, callback, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
// localExecutor provides the bounded local execution capability. It never
|
||||
// claims that an unsupported or external action happened. The work owner
|
||||
// records the WAITING callback and can retry after a suitable executor is
|
||||
// configured or a human chooses the next step.
|
||||
type localExecutor struct {
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func newLocalExecutor() localExecutor {
|
||||
return localExecutor{now: time.Now}
|
||||
}
|
||||
|
||||
func (e localExecutor) Execute(_ context.Context, request work.AgentExecutionRequest) (work.AgentExecutionResult, error) {
|
||||
now := time.Now()
|
||||
if e.now != nil {
|
||||
now = e.now()
|
||||
}
|
||||
return work.AgentExecutionResult{
|
||||
State: work.AgentExecutionWaiting,
|
||||
Summary: "Локальный исполнитель не поддерживает эту операцию; durable работа сохранена и ожидает подходящего исполнителя.",
|
||||
ObservedAtUnixMillis: now.UnixMilli(),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
func TestLocalExecutorNeverClaimsUnsupportedExecution(t *testing.T) {
|
||||
when := time.UnixMilli(1_700_000_000_000)
|
||||
executor := localExecutor{now: func() time.Time { return when }}
|
||||
result, err := executor.Execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{DispatchID: "dispatch-1"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.State != work.AgentExecutionWaiting || result.ObservedAtUnixMillis != when.UnixMilli() {
|
||||
t.Fatalf("unexpected fallback result: %+v", result)
|
||||
}
|
||||
if result.OutcomeRef != "" || result.Summary == "" {
|
||||
t.Fatalf("local executor must not claim an outcome: %+v", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/agentbridge"
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
"git.cin.su/m/Assistant/modules/work/application"
|
||||
"git.cin.su/m/Assistant/modules/work/ports"
|
||||
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||
)
|
||||
|
||||
func main() {
|
||||
workerID := flag.String("worker-id", "agent-worker-1", "durable worker identity")
|
||||
statusAddr := flag.String("status-addr", ":8090", "internal status HTTP address")
|
||||
staleAfter := flag.Duration("stale-after", 10*time.Minute, "age after which a claimed item is recovered")
|
||||
leaseTTL := flag.Duration("lease-ttl", 15*time.Minute, "executor lease duration")
|
||||
pollInterval := flag.Duration("poll-interval", 5*time.Second, "database-owned idle wake-up interval")
|
||||
once := flag.Bool("once", false, "run one cycle and exit")
|
||||
flag.Parse()
|
||||
|
||||
password, err := resolveDatabasePassword("WORK_POSTGRES_PASSWORD", "WORK_POSTGRES_CREDENTIAL_REF", secretcontracts.RuntimeConsumerWorkWorker)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
repository, err := postgres.OpenWork(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("WORK_POSTGRES_USER"), Password: password})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer repository.Close()
|
||||
var executor ports.AgentExecutor
|
||||
localFallback := false
|
||||
if bridgeURL, bridgeConfigured := loadConfiguredBridgeURL(); bridgeConfigured {
|
||||
executor, err = agentbridge.NewHTTPExecutor(bridgeURL, &http.Client{Timeout: *leaseTTL})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
// The local executor keeps supported task-qualification work alive, but
|
||||
// it is not an external Agent Execution Bridge and must never be reported
|
||||
// as one. Unsupported work remains durably WAITING.
|
||||
localFallback = true
|
||||
executor = newTaskEvaluationExecutor(envOr("COGNITIVE_WORKER_URL", "http://cognitive-worker:8083"), envOr("WORK_GRAPH_RUNTIME_URL", "http://work-graph-runtime:8088"), envOr("TASK_VERIFIER_URL", "http://task-verifier:8091"), &http.Client{Timeout: *leaseTTL})
|
||||
fmt.Fprintln(os.Stderr, "protected agent execution bridge URL is not configured; task evaluations use the local durable evaluator, other work remains in fallback")
|
||||
}
|
||||
runtimeStatus := newAgentWorkerRuntimeStatus()
|
||||
go func() {
|
||||
if serveErr := http.ListenAndServe(*statusAddr, workerStatusHandler(executor != nil && !localFallback, localFallback, runtimeStatus)); serveErr != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent worker status server:", serveErr)
|
||||
}
|
||||
}()
|
||||
ctx := context.Background()
|
||||
for {
|
||||
runtimeStatus.cycleStarted(time.Now())
|
||||
var result application.WorkerCycleResult
|
||||
var cycleErr error
|
||||
if executor == nil {
|
||||
result, cycleErr = application.RecoverOnlyWorkerCycle(ctx, repository, time.Now().UTC(), *staleAfter)
|
||||
} else {
|
||||
result, cycleErr = application.ExecuteWorkerCycleWithExecutor(ctx, repository, executor, *workerID, work.AgentFencingToken(1), time.Now().UTC(), *staleAfter, *leaseTTL)
|
||||
}
|
||||
if cycleErr != nil {
|
||||
runtimeStatus.cycleFailed(time.Now(), cycleErr)
|
||||
fmt.Fprintln(os.Stderr, cycleErr)
|
||||
if *once {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
cycleResult := "idle"
|
||||
if result.Completed {
|
||||
cycleResult = "completed"
|
||||
} else if result.RecoveredClaims > 0 {
|
||||
cycleResult = "recovered"
|
||||
}
|
||||
runtimeStatus.cycleFinished(time.Now(), result.RecoveredClaims, result.DispatchID, cycleResult)
|
||||
if cycleErr == nil && (result.Completed || result.RecoveredClaims > 0) {
|
||||
_ = json.NewEncoder(os.Stdout).Encode(result)
|
||||
}
|
||||
if *once {
|
||||
return
|
||||
}
|
||||
if err := repository.WaitForDispatch(ctx, *pollInterval); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfiguredBridgeURL() (string, bool) {
|
||||
if bridgeURL, configured := loadProtectedBridgeURL(); configured {
|
||||
return bridgeURL, true
|
||||
}
|
||||
// The URL is routing configuration, not a credential. A protected file
|
||||
// remains preferred when an independently operated bridge is supplied.
|
||||
value := strings.TrimSpace(os.Getenv("AGENT_EXECUTION_BRIDGE_URL"))
|
||||
if value == "" || !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func loadProtectedBridgeURL() (string, bool) {
|
||||
return loadProtectedBridgeURLFrom("/run/secrets", "/run/secrets/agent_execution_bridge_url")
|
||||
}
|
||||
|
||||
func loadProtectedBridgeURLFrom(root, path string) (string, bool) {
|
||||
clean := filepath.Clean(path)
|
||||
cleanRoot := filepath.Clean(root)
|
||||
if clean != cleanRoot && !strings.HasPrefix(clean, cleanRoot+string(os.PathSeparator)) {
|
||||
return "", false
|
||||
}
|
||||
payload, err := os.ReadFile(clean)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
value := strings.TrimSpace(string(payload))
|
||||
if value == "" || strings.ContainsAny(value, "\r\n") {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func envOr(name, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func resolveDatabasePassword(name, refName string, consumer secretcontracts.RuntimeConsumer) (string, error) {
|
||||
if _, ok := os.LookupEnv(name); ok {
|
||||
return "", fmt.Errorf("%s direct environment secret is forbidden", name)
|
||||
}
|
||||
path, ref := os.Getenv(name+"_FILE"), os.Getenv(refName)
|
||||
if path == "" || ref == "" {
|
||||
return "", fmt.Errorf("%s_FILE and %s are required", name, refName)
|
||||
}
|
||||
root := os.Getenv("SECRET_BACKEND_ROOT")
|
||||
if root == "" {
|
||||
root = "/run/secrets"
|
||||
}
|
||||
resolver, err := platformsecrets.NewFileResolver(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: consumer, Path: path})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type workerStatusResponse struct {
|
||||
Runtime string `json:"runtime"`
|
||||
ExecutorConfigured bool `json:"executor_configured"`
|
||||
Mode string `json:"mode"`
|
||||
Ready bool `json:"ready"`
|
||||
Reason string `json:"reason"`
|
||||
LocalFallback bool `json:"local_fallback"`
|
||||
LocalExecutor bool `json:"local_executor"`
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
StartedAt time.Time `json:"started_at,omitempty"`
|
||||
LastCycleAt time.Time `json:"last_cycle_at,omitempty"`
|
||||
LastCompletedAt time.Time `json:"last_completed_at,omitempty"`
|
||||
LastRecoveredClaims int64 `json:"last_recovered_claims"`
|
||||
TotalRecoveredClaims int64 `json:"total_recovered_claims"`
|
||||
LastDispatchID string `json:"last_dispatch_id,omitempty"`
|
||||
LastResult string `json:"last_result,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
type agentWorkerRuntimeStatus struct {
|
||||
mu sync.RWMutex
|
||||
startedAt time.Time
|
||||
lastCycleAt time.Time
|
||||
lastCompletedAt time.Time
|
||||
lastRecoveredClaims int64
|
||||
totalRecoveredClaims int64
|
||||
lastDispatchID string
|
||||
lastResult string
|
||||
lastError string
|
||||
}
|
||||
|
||||
func newAgentWorkerRuntimeStatus() *agentWorkerRuntimeStatus {
|
||||
return &agentWorkerRuntimeStatus{startedAt: time.Now().UTC()}
|
||||
}
|
||||
|
||||
func (s *agentWorkerRuntimeStatus) cycleStarted(at time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lastCycleAt = at.UTC()
|
||||
s.lastResult = "running"
|
||||
s.lastError = ""
|
||||
}
|
||||
|
||||
func (s *agentWorkerRuntimeStatus) cycleFinished(at time.Time, recoveredClaims int64, dispatchID string, result string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lastCompletedAt = at.UTC()
|
||||
s.lastRecoveredClaims = recoveredClaims
|
||||
s.totalRecoveredClaims += recoveredClaims
|
||||
s.lastDispatchID = dispatchID
|
||||
s.lastResult = result
|
||||
s.lastError = ""
|
||||
}
|
||||
|
||||
func (s *agentWorkerRuntimeStatus) cycleFailed(at time.Time, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lastCompletedAt = at.UTC()
|
||||
s.lastResult = "failed"
|
||||
s.lastError = err.Error()
|
||||
}
|
||||
|
||||
func workerStatusHandler(executorConfigured bool, localFallback ...interface{}) http.Handler {
|
||||
local := false
|
||||
var runtime *agentWorkerRuntimeStatus
|
||||
for _, argument := range localFallback {
|
||||
switch value := argument.(type) {
|
||||
case bool:
|
||||
local = value
|
||||
case *agentWorkerRuntimeStatus:
|
||||
runtime = value
|
||||
}
|
||||
}
|
||||
if runtime == nil {
|
||||
runtime = newAgentWorkerRuntimeStatus()
|
||||
}
|
||||
baseStatus := workerStatusResponse{
|
||||
Runtime: "agent-worker",
|
||||
ExecutorConfigured: executorConfigured,
|
||||
Mode: "recovery-only",
|
||||
Ready: false,
|
||||
Reason: "protected agent execution bridge URL is not configured",
|
||||
LocalFallback: local,
|
||||
LocalExecutor: local,
|
||||
}
|
||||
if local {
|
||||
baseStatus.Mode = "local-executor"
|
||||
baseStatus.Ready = true
|
||||
baseStatus.Reason = "bounded local task-evaluation capability is ready; unsupported work remains durably waiting"
|
||||
baseStatus.Capabilities = []string{"task-evaluation"}
|
||||
}
|
||||
if executorConfigured {
|
||||
baseStatus.Mode = "execution"
|
||||
baseStatus.Ready = true
|
||||
baseStatus.Reason = "typed agent execution bridge configured"
|
||||
baseStatus.Capabilities = []string{"task-evaluation", "generic-cognitive-work"}
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || (r.URL.Path != "/healthz" && r.URL.Path != "/readyz" && r.URL.Path != "/status") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path == "/readyz" && !baseStatus.Ready {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
response := baseStatus
|
||||
runtime.mu.RLock()
|
||||
response.StartedAt = runtime.startedAt
|
||||
response.LastCycleAt = runtime.lastCycleAt
|
||||
response.LastCompletedAt = runtime.lastCompletedAt
|
||||
response.LastRecoveredClaims = runtime.lastRecoveredClaims
|
||||
response.TotalRecoveredClaims = runtime.totalRecoveredClaims
|
||||
response.LastDispatchID = runtime.lastDispatchID
|
||||
response.LastResult = runtime.lastResult
|
||||
response.LastError = runtime.lastError
|
||||
runtime.mu.RUnlock()
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadProtectedBridgeURL(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "agent_execution_bridge_url")
|
||||
if err := os.WriteFile(path, []byte("http://bridge.local\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value, configured := loadProtectedBridgeURLFrom(root, path); !configured || value != "http://bridge.local" {
|
||||
t.Fatalf("protected bridge was not loaded: %q %v", value, configured)
|
||||
}
|
||||
if value, configured := loadProtectedBridgeURLFrom(root, filepath.Join(root, "missing")); configured || value != "" {
|
||||
t.Fatalf("missing bridge was accepted: %q %v", value, configured)
|
||||
}
|
||||
if value, configured := loadProtectedBridgeURLFrom(root, "/tmp/not-protected"); configured || value != "" {
|
||||
t.Fatalf("path outside protected root was accepted: %q %v", value, configured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerStatusReportsRecoveryOnlyWithoutExecutor(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
workerStatusHandler(false).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/status", nil))
|
||||
var value workerStatusResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Code != http.StatusOK || value.Ready || value.Mode != "recovery-only" || value.ExecutorConfigured {
|
||||
t.Fatalf("unexpected status: code=%d value=%+v", response.Code, value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerStatusReportsLocalExecutorCapabilityReady(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
workerStatusHandler(false, true).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/status", nil))
|
||||
var value workerStatusResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Code != http.StatusOK || !value.Ready || value.Mode != "local-executor" || !value.LocalFallback || !value.LocalExecutor {
|
||||
t.Fatalf("unexpected status: code=%d value=%+v", response.Code, value)
|
||||
}
|
||||
if len(value.Capabilities) != 1 || value.Capabilities[0] != "task-evaluation" {
|
||||
t.Fatalf("unexpected local capabilities: %+v", value.Capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerStatusDoesNotCallLocalFallbackAnExternalExecutor(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
workerStatusHandler(false, true).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/status", nil))
|
||||
var value workerStatusResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value.ExecutorConfigured || !value.Ready || !value.LocalFallback || !value.LocalExecutor || value.Mode != "local-executor" {
|
||||
t.Fatalf("local fallback was presented as external execution: %+v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerReadyzRequiresAnAdvertisedCapability(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
workerStatusHandler(false).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("readyz status=%d", response.Code)
|
||||
}
|
||||
response = httptest.NewRecorder()
|
||||
workerStatusHandler(false, true).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("local capability readyz status=%d", response.Code)
|
||||
}
|
||||
response = httptest.NewRecorder()
|
||||
workerStatusHandler(true).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("configured readyz status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerStatusReportsRecoveryCycle(t *testing.T) {
|
||||
runtime := newAgentWorkerRuntimeStatus()
|
||||
runtime.cycleStarted(time.Date(2026, 8, 23, 10, 0, 0, 0, time.UTC))
|
||||
runtime.cycleFinished(time.Date(2026, 8, 23, 10, 0, 1, 0, time.UTC), 3, "dispatch-1", "recovered")
|
||||
response := httptest.NewRecorder()
|
||||
workerStatusHandler(false, true, runtime).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/status", nil))
|
||||
var value workerStatusResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value.LastRecoveredClaims != 3 || value.TotalRecoveredClaims != 3 || value.LastDispatchID != "dispatch-1" || value.LastResult != "recovered" {
|
||||
t.Fatalf("recovery evidence missing: %+v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerStatusReportsCycleError(t *testing.T) {
|
||||
runtime := newAgentWorkerRuntimeStatus()
|
||||
runtime.cycleFailed(time.Date(2026, 8, 23, 10, 0, 1, 0, time.UTC), os.ErrClosed)
|
||||
response := httptest.NewRecorder()
|
||||
workerStatusHandler(false, runtime).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/status", nil))
|
||||
var value workerStatusResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value.LastResult != "failed" || value.LastError == "" {
|
||||
t.Fatalf("cycle error missing: %+v", value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/artifact"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain"
|
||||
)
|
||||
|
||||
type taskEvaluationExecutor struct {
|
||||
cognitiveURL string
|
||||
callbackURL string
|
||||
verifierURL string
|
||||
client *http.Client
|
||||
fallback localExecutor
|
||||
}
|
||||
|
||||
func newTaskEvaluationExecutor(cognitiveURL, callbackURL, verifierURL string, client *http.Client) taskEvaluationExecutor {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return taskEvaluationExecutor{cognitiveURL: strings.TrimRight(cognitiveURL, "/"), callbackURL: strings.TrimRight(callbackURL, "/"), verifierURL: strings.TrimRight(verifierURL, "/"), client: client, fallback: newLocalExecutor()}
|
||||
}
|
||||
|
||||
func (e taskEvaluationExecutor) Execute(ctx context.Context, request work.AgentExecutionRequest) (work.AgentExecutionResult, error) {
|
||||
intent := request.Intent
|
||||
if intent.TaskEvaluationExecution == nil || intent.TaskEvaluationProfile == nil || len(intent.TaskEvaluationCases) == 0 {
|
||||
return e.fallback.Execute(ctx, request)
|
||||
}
|
||||
execution := intent.TaskEvaluationExecution
|
||||
if execution.GraphID == "" || execution.NodeID == "" || execution.BindingRevision < 1 || execution.ActorID == "" || execution.PrincipalID == "" || execution.ScopeID == "" {
|
||||
return e.fallback.Execute(ctx, request)
|
||||
}
|
||||
if e.cognitiveURL == "" || e.callbackURL == "" || e.verifierURL == "" {
|
||||
return e.fallback.Execute(ctx, request)
|
||||
}
|
||||
payload := capability.TaskEvaluationExecutionRequest{Profile: *intent.TaskEvaluationProfile, CapabilityID: intent.TaskEvaluationProfile.CapabilityID, BindingID: intent.TaskEvaluationBindingID, BindingRevision: execution.BindingRevision, ActorID: execution.ActorID, PrincipalID: execution.PrincipalID, ScopeID: execution.ScopeID, Cases: intent.TaskEvaluationCases, MaxOutputBytes: 4096}
|
||||
response, err := postJSON[capability.TaskEvaluationExecutionResponse](ctx, e.client, e.cognitiveURL+"/v1/task-evaluations/execute", payload)
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
if len(response.Responses) == 0 || response.ObservedAtUnixMillis < 1 {
|
||||
return work.AgentExecutionResult{}, fmt.Errorf("task evaluation returned no observations")
|
||||
}
|
||||
if len(response.Observations) == len(intent.TaskEvaluationCases) && taskEvaluationHasCriticalFailure(response.Observations) {
|
||||
evaluation, err := capabilitydomain.BuildTaskEvaluation(*intent.TaskEvaluationProfile, capability.TaskEvaluationID("evaluation:"+intent.DispatchID), response.BindingID, response.BindingRevision, intent.TaskEvaluationProfile.CapabilityID, response.Observations, artifact.ArtifactID(intent.TaskEvaluationCases[0].EvidenceArtifactRef), response.ObservedAtUnixMillis)
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
callback := work.TaskEvaluationEvidenceCallback{GraphID: execution.GraphID, Revision: execution.GraphRevision, NodeID: execution.NodeID, Evaluation: evaluation, Succeeded: true}
|
||||
if _, err := postJSON[json.RawMessage](ctx, e.client, e.callbackURL+"/v1/work-graphs/task-evaluations/callback", callback); err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
return work.AgentExecutionResult{State: work.AgentExecutionCompleted, Summary: "task evaluation recorded as UNKNOWN after bounded provider recovery", OutcomeRef: string(evaluation.ID), ArtifactRefs: []string{string(evaluation.ArtifactID)}, ObservedAtUnixMillis: time.Now().UnixMilli()}, nil
|
||||
}
|
||||
if len(intent.TaskEvaluationCases) == 0 {
|
||||
return work.AgentExecutionResult{}, fmt.Errorf("task evaluation cases are missing")
|
||||
}
|
||||
caseByID := make(map[string]capability.TaskEvaluationCase, len(intent.TaskEvaluationCases))
|
||||
for _, item := range intent.TaskEvaluationCases {
|
||||
caseByID[item.CaseID] = item
|
||||
}
|
||||
observations := make([]capability.TaskCaseObservation, 0, len(response.Responses))
|
||||
for _, item := range response.Responses {
|
||||
caseValue, ok := caseByID[item.CaseID]
|
||||
if !ok || caseValue.Verification.Mode == "" {
|
||||
return e.fallback.Execute(ctx, request)
|
||||
}
|
||||
verified, verifyErr := postJSON[capability.TaskCaseVerificationResponse](ctx, e.client, e.verifierURL+"/v1/task-evaluations/verify", capability.TaskCaseVerificationRequest{CaseID: item.CaseID, ResponseText: item.ResponseText, LatencyMicros: item.LatencyMicros, EvidenceArtifactRef: item.EvidenceArtifactRef, Verification: caseValue.Verification})
|
||||
if verifyErr != nil {
|
||||
return e.fallback.Execute(ctx, request)
|
||||
}
|
||||
observations = append(observations, capability.TaskCaseObservation{CaseID: verified.CaseID, Result: verified.Result, QualityScoreMillis: verified.QualityScoreMillis, LatencyMicros: verified.LatencyMicros, CriticalFailure: verified.CriticalFailure, EvidenceArtifactRef: verified.EvidenceArtifactRef, VerificationEvidenceRef: verified.VerificationEvidenceRef, VerifierIdentity: verified.VerifierIdentity})
|
||||
}
|
||||
evaluation, err := capabilitydomain.BuildTaskEvaluation(*intent.TaskEvaluationProfile, capability.TaskEvaluationID("evaluation:"+intent.DispatchID), response.BindingID, response.BindingRevision, intent.TaskEvaluationProfile.CapabilityID, observations, artifact.ArtifactID(intent.TaskEvaluationCases[0].EvidenceArtifactRef), response.ObservedAtUnixMillis)
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
callback := work.TaskEvaluationEvidenceCallback{GraphID: execution.GraphID, Revision: execution.GraphRevision, NodeID: execution.NodeID, Evaluation: evaluation, Succeeded: true}
|
||||
if _, err := postJSON[json.RawMessage](ctx, e.client, e.callbackURL+"/v1/work-graphs/task-evaluations/callback", callback); err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
return work.AgentExecutionResult{State: work.AgentExecutionCompleted, Summary: "task evaluation executed, aggregated and accepted by Work Graph callback", OutcomeRef: string(evaluation.ID), ArtifactRefs: []string{string(evaluation.ArtifactID)}, ObservedAtUnixMillis: time.Now().UnixMilli()}, nil
|
||||
}
|
||||
|
||||
func taskEvaluationHasCriticalFailure(observations []capability.TaskCaseObservation) bool {
|
||||
for _, observation := range observations {
|
||||
if observation.CriticalFailure {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func postJSON[T any](ctx context.Context, client *http.Client, url string, payload any) (T, error) {
|
||||
var result T
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return result, fmt.Errorf("POST %s: status %s", url, resp.Status)
|
||||
}
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
return result, nil
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
func TestTaskEvaluationExecutorInvokesAggregatesAndCallbacks(t *testing.T) {
|
||||
profile := capability.TaskEvaluationProfile{ID: "profile-1", Revision: 1, CapabilityID: "capability-1", Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "general-v1", RequiredCaseIDs: []string{"case-1"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
|
||||
verification := capability.TaskCaseVerification{Mode: capability.TaskCaseVerificationExactText, ExpectedText: "answer", MinimumQualityMillis: 900}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/task-evaluations/execute" {
|
||||
_ = json.NewEncoder(w).Encode(capability.TaskEvaluationExecutionResponse{ProfileID: profile.ID, BindingID: "binding-1", BindingRevision: 3, Responses: []capability.TaskCaseResponse{{CaseID: "case-1", ResponseText: "answer", LatencyMicros: 2, EvidenceArtifactRef: "artifact-1"}}, ObservedAtUnixMillis: 10})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/v1/task-evaluations/verify" {
|
||||
_ = json.NewEncoder(w).Encode(capability.TaskCaseVerificationResponse{CaseID: "case-1", Result: capability.EvaluationSupported, QualityScoreMillis: 900, LatencyMicros: 2, EvidenceArtifactRef: "artifact-1", VerificationEvidenceRef: "verify-1", VerifierIdentity: "verifier-1"})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/v1/work-graphs/task-evaluations/callback" {
|
||||
var callback work.TaskEvaluationEvidenceCallback
|
||||
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil || callback.GraphID != "graph-1" || callback.NodeID != "node-1" || callback.Evaluation.Result != capability.EvaluationSupported {
|
||||
http.Error(w, "invalid callback", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
executor := newTaskEvaluationExecutor(server.URL, server.URL, server.URL, server.Client())
|
||||
result, err := executor.Execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{DispatchID: "dispatch-1", TaskEvaluationBindingID: "binding-1", TaskEvaluationProfile: &profile, TaskEvaluationCases: []capability.TaskEvaluationCase{{CaseID: "case-1", Prompt: "answer", EvidenceArtifactRef: "artifact-1", Verification: verification}}, TaskEvaluationExecution: &work.TaskEvaluationDispatchContext{GraphID: "graph-1", GraphRevision: 1, NodeID: "node-1", BindingRevision: 3, ActorID: "actor-1", PrincipalID: "principal-1", ScopeID: "scope-1"}}})
|
||||
if err != nil || result.State != work.AgentExecutionCompleted || result.OutcomeRef == "" {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskEvaluationExecutorPersistsUnknownAfterProviderFailure(t *testing.T) {
|
||||
profile := capability.TaskEvaluationProfile{ID: "profile-unknown", Revision: 1, CapabilityID: "capability-1", Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "unknown-v1", RequiredCaseIDs: []string{"case-1"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/task-evaluations/execute" {
|
||||
_ = json.NewEncoder(w).Encode(capability.TaskEvaluationExecutionResponse{ProfileID: profile.ID, BindingID: "binding-1", BindingRevision: 3, Observations: []capability.TaskCaseObservation{{CaseID: "case-1", Result: capability.EvaluationUnknown, CriticalFailure: true, EvidenceArtifactRef: "artifact-1"}}, Responses: []capability.TaskCaseResponse{{CaseID: "case-1", EvidenceArtifactRef: "artifact-1"}}, ObservedAtUnixMillis: 10})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/v1/work-graphs/task-evaluations/callback" {
|
||||
var callback work.TaskEvaluationEvidenceCallback
|
||||
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil || callback.Evaluation.Result != capability.EvaluationUnknown || !callback.Succeeded {
|
||||
http.Error(w, "invalid unknown callback", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/v1/task-evaluations/verify" {
|
||||
t.Errorf("verifier must not be called for a provider failure")
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
executor := newTaskEvaluationExecutor(server.URL, server.URL, server.URL, server.Client())
|
||||
result, err := executor.Execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{DispatchID: "dispatch-unknown", TaskEvaluationBindingID: "binding-1", TaskEvaluationProfile: &profile, TaskEvaluationCases: []capability.TaskEvaluationCase{{CaseID: "case-1", Prompt: "answer", EvidenceArtifactRef: "artifact-1"}}, TaskEvaluationExecution: &work.TaskEvaluationDispatchContext{GraphID: "graph-1", GraphRevision: 1, NodeID: "node-1", BindingRevision: 3, ActorID: "actor-1", PrincipalID: "principal-1", ScopeID: "scope-1"}}})
|
||||
if err != nil || result.State != work.AgentExecutionCompleted || result.OutcomeRef == "" {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
@@ -98,8 +98,9 @@ func main() {
|
||||
manifestPath := flag.String("manifest", "", "benchmark manifest JSON")
|
||||
outputPath := flag.String("output", "", "output artifact JSON")
|
||||
timeout := flag.Duration("case-timeout", 30*time.Second, "deadline for each case")
|
||||
maxOutputBytes := flag.Int64("max-output-bytes", 4096, "maximum advisory output bytes per case")
|
||||
flag.Parse()
|
||||
if *manifestPath == "" || *outputPath == "" || *timeout <= 0 {
|
||||
if *manifestPath == "" || *outputPath == "" || *timeout <= 0 || *maxOutputBytes < 1 {
|
||||
fatal(errors.New("manifest, output and positive case-timeout are required"))
|
||||
}
|
||||
input, err := os.ReadFile(*manifestPath)
|
||||
@@ -110,7 +111,7 @@ func main() {
|
||||
if err := json.Unmarshal(input, &plan); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
artifact, err := run(context.Background(), plan, *timeout, time.Now().UTC())
|
||||
artifact, err := run(context.Background(), plan, *timeout, *maxOutputBytes, time.Now().UTC())
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
@@ -125,7 +126,7 @@ func main() {
|
||||
fmt.Printf("artifact=%s models=%d output=%s\n", artifact.ArtifactID, len(artifact.Results), *outputPath)
|
||||
}
|
||||
|
||||
func run(ctx context.Context, plan manifest, timeout time.Duration, observed time.Time) (benchmarkArtifact, error) {
|
||||
func run(ctx context.Context, plan manifest, timeout time.Duration, maxOutputBytes int64, observed time.Time) (benchmarkArtifact, error) {
|
||||
if plan.ArtifactID == "" || len(plan.Providers) == 0 {
|
||||
return benchmarkArtifact{}, errors.New("invalid benchmark manifest")
|
||||
}
|
||||
@@ -134,7 +135,7 @@ func run(ctx context.Context, plan manifest, timeout time.Duration, observed tim
|
||||
artifact.Corpus = append(artifact.Corpus, corpusRecord{ID: item.id, Prompt: item.prompt, PromptSHA256: digest(item.prompt), Critical: item.critical})
|
||||
}
|
||||
for _, provider := range plan.Providers {
|
||||
adapter, err := newProvider(provider)
|
||||
adapter, err := newProvider(provider, timeout)
|
||||
if err != nil {
|
||||
return benchmarkArtifact{}, err
|
||||
}
|
||||
@@ -142,7 +143,7 @@ func run(ctx context.Context, plan manifest, timeout time.Duration, observed tim
|
||||
return provider.Candidates[i].ProviderModelID < provider.Candidates[j].ProviderModelID
|
||||
})
|
||||
for _, candidate := range provider.Candidates {
|
||||
result, err := runModel(ctx, adapter, provider.ProviderInstanceID, candidate, timeout)
|
||||
result, err := runModel(ctx, adapter, provider.ProviderInstanceID, candidate, timeout, maxOutputBytes)
|
||||
if err != nil {
|
||||
return benchmarkArtifact{}, err
|
||||
}
|
||||
@@ -158,10 +159,10 @@ func run(ctx context.Context, plan manifest, timeout time.Duration, observed tim
|
||||
return artifact, nil
|
||||
}
|
||||
|
||||
func newProvider(candidate providerCandidate) (models.LocalProvider, error) {
|
||||
func newProvider(candidate providerCandidate, timeout time.Duration) (models.LocalProvider, error) {
|
||||
switch candidate.ProviderKind {
|
||||
case "openai-compatible-chat-v1":
|
||||
return models.NewOpenAICompatibleLocalAdapter(candidate.Endpoint, nil)
|
||||
return models.NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(candidate.Endpoint, "", nil, timeout)
|
||||
case "ollama-v1":
|
||||
return models.NewOllamaAdapter(candidate.Endpoint, nil)
|
||||
default:
|
||||
@@ -169,7 +170,7 @@ func newProvider(candidate providerCandidate) (models.LocalProvider, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func runModel(ctx context.Context, adapter models.LocalProvider, providerID string, candidate modelCandidate, timeout time.Duration) (modelResult, error) {
|
||||
func runModel(ctx context.Context, adapter models.LocalProvider, providerID string, candidate modelCandidate, timeout time.Duration, maxOutputBytes int64) (modelResult, error) {
|
||||
if providerID == "" || candidate.ProviderModelID == "" || candidate.ModelDescriptorID == "" || candidate.ModelRevision < 1 || candidate.BindingID == "" || candidate.BindingRevision < 1 {
|
||||
return modelResult{}, errors.New("invalid benchmark candidate")
|
||||
}
|
||||
@@ -178,7 +179,7 @@ func runModel(ctx context.Context, adapter models.LocalProvider, providerID stri
|
||||
for index, item := range corpus {
|
||||
caseCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
started := time.Now()
|
||||
response, err := adapter.Advise(caseCtx, cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(fmt.Sprintf("bench-%s-%d", digest(candidate.BindingID)[:12], index)), ActorID: "benchmark-actor", PrincipalID: "domovoy-creator-principal", ScopeID: "benchmark-public-synthetic", Message: item.prompt, Locale: "ru-RU", MaxOutputBytes: 4096, ProviderBindingID: capability.CapabilityBindingID(candidate.BindingID), ProviderBindingRevision: candidate.BindingRevision, ProviderInstanceID: capability.ProviderInstanceID(providerID), ModelDescriptorID: capability.ModelDescriptorID(candidate.ModelDescriptorID), ProviderModelID: capability.ProviderModelID(candidate.ProviderModelID)})
|
||||
response, err := adapter.Advise(caseCtx, cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(fmt.Sprintf("bench-%s-%d", digest(candidate.BindingID)[:12], index)), ActorID: "benchmark-actor", PrincipalID: "domovoy-creator-principal", ScopeID: "benchmark-public-synthetic", Message: item.prompt, Locale: "ru-RU", MaxOutputBytes: int(maxOutputBytes), ProviderBindingID: capability.CapabilityBindingID(candidate.BindingID), ProviderBindingRevision: candidate.BindingRevision, ProviderInstanceID: capability.ProviderInstanceID(providerID), ModelDescriptorID: capability.ModelDescriptorID(candidate.ModelDescriptorID), ProviderModelID: capability.ProviderModelID(candidate.ProviderModelID)})
|
||||
cancel()
|
||||
caseResult := caseResult{CaseID: item.id, LatencyMillis: time.Since(started).Milliseconds()}
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
)
|
||||
|
||||
type httpAdmissionClient struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newHTTPAdmissionClient(baseURL string, client *http.Client) *httpAdmissionClient {
|
||||
return &httpAdmissionClient{baseURL: strings.TrimRight(baseURL, "/"), client: client}
|
||||
}
|
||||
|
||||
func (c *httpAdmissionClient) Admit(ctx context.Context, leaseID string, request capability.AdmissionRequest) (capability.AdmissionDecision, capability.AdmissionLease, error) {
|
||||
payload, err := json.Marshal(struct {
|
||||
LeaseID string `json:"lease_id"`
|
||||
Request capability.AdmissionRequest `json:"request"`
|
||||
}{leaseID, request})
|
||||
if err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/internal/cognitive/admission", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, fmt.Errorf("admission status %d", resp.StatusCode)
|
||||
}
|
||||
var result struct {
|
||||
Decision capability.AdmissionDecision `json:"decision"`
|
||||
Lease capability.AdmissionLease `json:"lease"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
|
||||
}
|
||||
return result.Decision, result.Lease, nil
|
||||
}
|
||||
|
||||
func (c *httpAdmissionClient) Release(ctx context.Context, lease capability.AdmissionLease) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/internal/cognitive/admission/"+lease.ID+"/release", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("admission release status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
)
|
||||
|
||||
type admissionTestProvider struct{ calls int }
|
||||
|
||||
func (p *admissionTestProvider) Advise(context.Context, cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
|
||||
p.calls++
|
||||
return cognitive.AdvisoryResponse{FinishReason: cognitive.FinishCompleted}, nil
|
||||
}
|
||||
|
||||
type admissionTestClient struct {
|
||||
decision capability.AdmissionDecision
|
||||
releases int
|
||||
}
|
||||
|
||||
func (c *admissionTestClient) Admit(context.Context, string, capability.AdmissionRequest) (capability.AdmissionDecision, capability.AdmissionLease, error) {
|
||||
return c.decision, capability.AdmissionLease{ID: "lease-1", PolicyID: "policy-1"}, nil
|
||||
}
|
||||
func (c *admissionTestClient) Release(context.Context, capability.AdmissionLease) error {
|
||||
c.releases++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInvokeAdmittedDoesNotCallProviderWhenRejected(t *testing.T) {
|
||||
provider := &admissionTestProvider{}
|
||||
client := &admissionTestClient{decision: capability.AdmissionDecision{State: capability.AdmissionRejected, Reason: capability.AdmissionReasonConcurrency, PolicyID: "policy-1"}}
|
||||
gateway := cognitiveGateway{admission: client}
|
||||
response, err := gateway.invokeAdmitted(context.Background(), "request-1", provider, cognitive.AdvisoryRequest{Message: "hello"}, 100)
|
||||
if err != nil || response.FinishReason != cognitive.FinishRateLimited || provider.calls != 0 || client.releases != 0 {
|
||||
t.Fatalf("rejected admission leaked provider call: response=%#v err=%v calls=%d releases=%d", response, err, provider.calls, client.releases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeAdmittedReleasesDurableLeaseAfterProvider(t *testing.T) {
|
||||
provider := &admissionTestProvider{}
|
||||
client := &admissionTestClient{decision: capability.AdmissionDecision{State: capability.AdmissionAdmitted, Reason: capability.AdmissionReasonNone, PolicyID: "policy-1"}}
|
||||
gateway := cognitiveGateway{admission: client}
|
||||
response, err := gateway.invokeAdmitted(context.Background(), "request-1", provider, cognitive.AdvisoryRequest{Message: "hello"}, 100)
|
||||
if err != nil || response.FinishReason != cognitive.FinishCompleted || provider.calls != 1 || client.releases != 1 {
|
||||
t.Fatalf("admitted lease was not released: response=%#v err=%v calls=%d releases=%d", response, err, provider.calls, client.releases)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
@@ -17,6 +18,10 @@ type discoveryService struct {
|
||||
}
|
||||
|
||||
func (s discoveryService) handler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/v1/provider-discovery/status" {
|
||||
s.statusHandler(w, r)
|
||||
return
|
||||
}
|
||||
registration, ok := s.providers[r.PathValue("provider")]
|
||||
if !ok || registration.Discovery == nil {
|
||||
http.Error(w, "provider discovery not found", http.StatusNotFound)
|
||||
@@ -35,3 +40,42 @@ func (s discoveryService) handler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
type discoveryStatus struct {
|
||||
Provider string `json:"provider"`
|
||||
ProviderKind capability.ProviderKind `json:"provider_kind"`
|
||||
Health capability.HealthState `json:"health"`
|
||||
FailureClass capability.CognitiveFailureType `json:"failure_class,omitempty"`
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
Models []discoveredModelStatus `json:"models"`
|
||||
}
|
||||
|
||||
type discoveredModelStatus struct {
|
||||
ProviderModelID capability.ProviderModelID `json:"provider_model_id"`
|
||||
Fingerprint capability.ModelFingerprint `json:"fingerprint,omitempty"`
|
||||
Capabilities []capability.ModelCapability `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
func (s discoveryService) statusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
providers := make([]string, 0, len(s.providers))
|
||||
for name := range s.providers {
|
||||
providers = append(providers, name)
|
||||
}
|
||||
sort.Strings(providers)
|
||||
result := make([]discoveryStatus, 0, len(providers))
|
||||
for _, name := range providers {
|
||||
registration := s.providers[name]
|
||||
status := discoveryStatus{Provider: name, ProviderKind: registration.Kind, Health: capability.HealthUnknown, ObservedAtUnixMillis: s.now().UnixMilli(), Models: []discoveredModelStatus{}}
|
||||
if registration.Discovery != nil {
|
||||
models, probe := registration.Discovery.Discover(r.Context())
|
||||
status.Health = probe.Health
|
||||
status.FailureClass = capability.CognitiveFailureType(probe.Failure)
|
||||
for _, model := range models {
|
||||
status.Models = append(status.Models, discoveredModelStatus{ProviderModelID: model.ProviderModelID, Fingerprint: model.Fingerprint, Capabilities: model.Capabilities})
|
||||
}
|
||||
}
|
||||
result = append(result, status)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/models"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
type discoveryTestProvider struct{}
|
||||
|
||||
func (discoveryTestProvider) Probe(context.Context) models.ProviderProbe {
|
||||
return models.ProviderProbe{Health: capability.HealthAvailable}
|
||||
}
|
||||
func (discoveryTestProvider) Discover(context.Context) ([]models.DiscoveredModel, models.ProviderProbe) {
|
||||
return []models.DiscoveredModel{{ProviderModelID: "local-model", Fingerprint: "fingerprint-1", Capabilities: []capability.ModelCapability{capability.CapabilityAdvisory}}}, models.ProviderProbe{Health: capability.HealthAvailable}
|
||||
}
|
||||
func (discoveryTestProvider) Advise(context.Context, cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
|
||||
return cognitive.AdvisoryResponse{}, nil
|
||||
}
|
||||
|
||||
func TestDiscoveryStatusIsReadOnlyAndDoesNotExposeCredentialMetadata(t *testing.T) {
|
||||
service := discoveryService{
|
||||
providers: map[string]providerRegistration{"cpu": {ProviderInstanceID: "provider-1", Kind: "openai-compatible-chat-v1", CredentialRef: "credential://must-not-be-returned", Discovery: discoveryTestProvider{}}},
|
||||
owner: identity.PrincipalID("owner-1"),
|
||||
now: func() time.Time { return time.UnixMilli(1234) },
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
service.handler(response, httptest.NewRequest(http.MethodGet, "/v1/provider-discovery/status", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
var result []discoveryStatus
|
||||
if err := json.NewDecoder(strings.NewReader(body)).Decode(&result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result) != 1 || result[0].Provider != "cpu" || result[0].Health != capability.HealthAvailable || len(result[0].Models) != 1 || result[0].ObservedAtUnixMillis != 1234 {
|
||||
t.Fatalf("unexpected discovery status: %+v", result)
|
||||
}
|
||||
if strings.Contains(body, "must-not-be-returned") || strings.Contains(body, "credential://") {
|
||||
t.Fatalf("credential metadata leaked in discovery status: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
fleetdomain "git.cin.su/m/Assistant/modules/capability/domain"
|
||||
)
|
||||
|
||||
type fleetEndpoint struct {
|
||||
ProviderID string
|
||||
ModelID string
|
||||
Tier capability.FleetTier
|
||||
URL string
|
||||
Headers map[string]string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
type fleetStatusResponse struct {
|
||||
Readiness capability.FleetReadiness `json:"readiness"`
|
||||
Preferred capability.FleetTier `json:"preferred_tier"`
|
||||
Selected capability.FleetTier `json:"selected_tier,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func fleetReadinessHandler(client *http.Client, preferred capability.FleetTier, endpoints []fleetEndpoint) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
members := make([]capability.FleetMember, 0, len(endpoints))
|
||||
members = make([]capability.FleetMember, len(endpoints))
|
||||
var waitGroup sync.WaitGroup
|
||||
for index, endpoint := range endpoints {
|
||||
waitGroup.Add(1)
|
||||
go func(index int, endpoint fleetEndpoint) {
|
||||
defer waitGroup.Done()
|
||||
members[index] = probeFleetEndpoint(r.Context(), client, endpoint)
|
||||
}(index, endpoint)
|
||||
}
|
||||
waitGroup.Wait()
|
||||
readiness, err := fleetdomain.BuildFleetReadiness(time.Now().UnixMilli(), members)
|
||||
response := fleetStatusResponse{Readiness: readiness, Preferred: preferred}
|
||||
if err == nil {
|
||||
response.Selected, err = fleetdomain.SelectFleetTier(readiness, preferred)
|
||||
}
|
||||
if err != nil {
|
||||
response.Error = err.Error()
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if response.Selected == "" {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
})
|
||||
}
|
||||
|
||||
func probeFleetEndpoint(parent context.Context, fallbackClient *http.Client, endpoint fleetEndpoint) capability.FleetMember {
|
||||
member := capability.FleetMember{ProviderInstanceID: capability.ProviderInstanceID(endpoint.ProviderID), ModelDescriptorID: capability.ModelDescriptorID(endpoint.ModelID), Tier: endpoint.Tier, Reason: "NOT_CONFIGURED"}
|
||||
if strings.TrimSpace(endpoint.URL) == "" {
|
||||
return member
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.URL, nil)
|
||||
if err != nil {
|
||||
member.Reason = err.Error()
|
||||
return member
|
||||
}
|
||||
for name, value := range endpoint.Headers {
|
||||
request.Header.Set(name, value)
|
||||
}
|
||||
probeClient := endpoint.Client
|
||||
if probeClient == nil {
|
||||
probeClient = fallbackClient
|
||||
}
|
||||
response, requestErr := probeClient.Do(request)
|
||||
if requestErr != nil {
|
||||
member.Reason = requestErr.Error()
|
||||
return member
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||
member.Ready, member.Reason = true, "AVAILABLE"
|
||||
} else {
|
||||
member.Reason = fmt.Sprintf("status=%s", response.Status)
|
||||
}
|
||||
return member
|
||||
}
|
||||
|
||||
func fleetContext(ctx context.Context) context.Context { return ctx }
|
||||
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
)
|
||||
|
||||
func TestFleetReadinessSelectsBestAvailablePrimaryTierBeforeLifeboat(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||
defer server.Close()
|
||||
handler := fleetReadinessHandler(server.Client(), capability.FleetTierHeavy, []fleetEndpoint{
|
||||
{ProviderID: "cpu", ModelID: "cpu-model", Tier: capability.FleetTierFast, URL: server.URL},
|
||||
{ProviderID: "medium", ModelID: "medium-model", Tier: capability.FleetTierMedium},
|
||||
{ProviderID: "heavy", ModelID: "heavy-model", Tier: capability.FleetTierHeavy},
|
||||
{ProviderID: "lifeboat", ModelID: "lifeboat-model", Tier: capability.FleetTierLifeboat, URL: server.URL},
|
||||
})
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/v1/fleet/readiness", nil)
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"selected_tier":"FAST"`) || !strings.Contains(recorder.Body.String(), `"tier":"MEDIUM"`) {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetReadinessDoesNotWaitForSlowMembersSerially(t *testing.T) {
|
||||
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer slow.Close()
|
||||
fast := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||
defer fast.Close()
|
||||
handler := fleetReadinessHandler(fast.Client(), capability.FleetTierFast, []fleetEndpoint{
|
||||
{ProviderID: "slow", ModelID: "slow-model", Tier: capability.FleetTierMedium, URL: slow.URL},
|
||||
{ProviderID: "lifeboat", ModelID: "lifeboat-model", Tier: capability.FleetTierLifeboat, URL: fast.URL},
|
||||
})
|
||||
started := time.Now()
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/fleet/readiness", nil))
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"selected_tier":"LIFEBOAT"`) {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed >= 300*time.Millisecond {
|
||||
t.Fatalf("fleet readiness was serialized: elapsed=%s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetReadinessSendsConfiguredProviderHeaders(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer fleet-key" {
|
||||
t.Fatalf("authorization=%q", r.Header.Get("Authorization"))
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
handler := fleetReadinessHandler(server.Client(), capability.FleetTierFast, []fleetEndpoint{{
|
||||
ProviderID: "cpu", ModelID: "cpu-model", Tier: capability.FleetTierFast, URL: server.URL,
|
||||
Headers: map[string]string{"Authorization": "Bearer fleet-key"},
|
||||
}})
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/fleet/readiness", nil))
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"ready":true`) {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
+101
-12
@@ -26,6 +26,15 @@ type bindingResolver interface {
|
||||
RegisterDiscovery(context.Context, capability.CognitiveDiscoverySnapshot) (capability.CognitiveDiscoveryRegistration, error)
|
||||
}
|
||||
|
||||
type candidateBindingResolver interface {
|
||||
ResolveCandidate(context.Context, capability.CapabilityBindingID, int64) (capability.ResolvedCognitiveBinding, error)
|
||||
}
|
||||
|
||||
type admissionClient interface {
|
||||
Admit(context.Context, string, capability.AdmissionRequest) (capability.AdmissionDecision, capability.AdmissionLease, error)
|
||||
Release(context.Context, capability.AdmissionLease) error
|
||||
}
|
||||
|
||||
type providerRegistration struct {
|
||||
ProviderInstanceID capability.ProviderInstanceID
|
||||
Kind capability.ProviderKind
|
||||
@@ -46,6 +55,7 @@ type cognitiveGateway struct {
|
||||
now func() time.Time
|
||||
allowCanary bool
|
||||
canaryMaxOutputBytes int
|
||||
admission admissionClient
|
||||
}
|
||||
|
||||
func (g cognitiveGateway) policyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -95,9 +105,9 @@ func (g cognitiveGateway) advisoryHandler(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
invocation := cognitive.AdvisoryRequest{Version: request.Version, RequestID: request.RequestID, ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: request.Message, Context: request.Context, Locale: request.Locale, MaxOutputBytes: maxOutputBytes, ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, ProviderInstanceID: selected.Provider.ID, ModelDescriptorID: selected.Model.ID, ProviderModelID: selected.Model.ProviderModelID}
|
||||
started := g.now()
|
||||
response, invokeErr := provider.Advise(r.Context(), invocation)
|
||||
response, invokeErr := g.invokeAdmitted(r.Context(), string(request.RequestID), provider, invocation, maxOutputBytes)
|
||||
if invokeErr != nil {
|
||||
response = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, FinishReason: cognitive.FinishInvalidProviderResponse}
|
||||
response = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, FinishReason: cognitive.FinishProviderUnavailable}
|
||||
}
|
||||
g.observe(r.Context(), selected, response, started)
|
||||
if fallbackEligible(response.FinishReason) {
|
||||
@@ -109,9 +119,9 @@ func (g cognitiveGateway) advisoryHandler(w http.ResponseWriter, r *http.Request
|
||||
fallbackRequest.ProviderBindingID, fallbackRequest.ProviderBindingRevision = fallback.Binding.ID, fallback.Binding.Revision
|
||||
fallbackRequest.ProviderInstanceID, fallbackRequest.ModelDescriptorID, fallbackRequest.ProviderModelID = fallback.Provider.ID, fallback.Model.ID, fallback.Model.ProviderModelID
|
||||
fallbackStarted := g.now()
|
||||
fallbackResponse, fallbackErr := fallbackProvider.Advise(r.Context(), fallbackRequest)
|
||||
fallbackResponse, fallbackErr := g.invokeAdmitted(r.Context(), string(request.RequestID)+":fallback", fallbackProvider, fallbackRequest, maxOutputBytes)
|
||||
if fallbackErr != nil {
|
||||
fallbackResponse = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: fallback.Binding.ID, ProviderBindingRevision: fallback.Binding.Revision, FinishReason: cognitive.FinishInvalidProviderResponse}
|
||||
fallbackResponse = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: fallback.Binding.ID, ProviderBindingRevision: fallback.Binding.Revision, FinishReason: cognitive.FinishProviderUnavailable}
|
||||
}
|
||||
g.observe(r.Context(), fallback, fallbackResponse, fallbackStarted)
|
||||
response = fallbackResponse
|
||||
@@ -124,6 +134,22 @@ func (g cognitiveGateway) advisoryHandler(w http.ResponseWriter, r *http.Request
|
||||
writeGatewayResponse(w, cognitive.GatewayAdvisoryResponse{Version: response.Version, RequestID: response.RequestID, AdvisoryText: response.AdvisoryText, ReferencedExcerptIDs: response.ReferencedExcerptIDs, FinishReason: response.FinishReason, Usage: response.Usage})
|
||||
}
|
||||
|
||||
func (g cognitiveGateway) invokeAdmitted(ctx context.Context, leaseID string, provider advisoryProvider, invocation cognitive.AdvisoryRequest, maxOutputBytes int) (cognitive.AdvisoryResponse, error) {
|
||||
var lease capability.AdmissionLease
|
||||
if g.admission != nil {
|
||||
decision, admittedLease, err := g.admission.Admit(ctx, leaseID, capability.AdmissionRequest{InputBytes: len(invocation.Message), MaxOutputBytes: maxOutputBytes, DeadlineMillis: 0})
|
||||
if err != nil {
|
||||
return cognitive.AdvisoryResponse{}, err
|
||||
}
|
||||
if decision.State != capability.AdmissionAdmitted {
|
||||
return cognitive.AdvisoryResponse{Version: invocation.Version, RequestID: invocation.RequestID, ProviderBindingID: invocation.ProviderBindingID, ProviderBindingRevision: invocation.ProviderBindingRevision, FinishReason: cognitive.FinishRateLimited}, nil
|
||||
}
|
||||
lease = admittedLease
|
||||
defer func() { _ = g.admission.Release(ctx, lease) }()
|
||||
}
|
||||
return provider.Advise(ctx, invocation)
|
||||
}
|
||||
|
||||
func selectionObjectiveForMessage(message string) capability.SelectionObjective {
|
||||
lower := strings.ToLower(message)
|
||||
if strings.Contains(lower, "дешев") || strings.Contains(lower, "бесплат") || strings.Contains(lower, "эконом") || strings.Contains(lower, "cost") || strings.Contains(lower, "cheap") {
|
||||
@@ -160,27 +186,61 @@ func selectionObjectiveForMeaning(meaning cognitive.TaskMeaning, message string)
|
||||
}
|
||||
|
||||
func builtinAdvisory(request cognitive.GatewayAdvisoryRequest) cognitive.GatewayAdvisoryResponse {
|
||||
text := "Я рядом и продолжаю работу во встроенном режиме Домового. Внешний когнитивный маршрут сейчас недоступен, поэтому опираюсь на уже установленный смысл сообщения и локальные возможности."
|
||||
if request.Meaning.SpeechAct == "RESPONSE_REVIEW" {
|
||||
const marker = "Черновик ответа:"
|
||||
if index := strings.LastIndex(request.Message, marker); index >= 0 {
|
||||
if draft := strings.TrimSpace(request.Message[index+len(marker):]); draft != "" {
|
||||
return cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, AdvisoryText: draft, FinishReason: cognitive.FinishCompleted}
|
||||
}
|
||||
}
|
||||
}
|
||||
text := "Понял."
|
||||
identityText := request.Meaning.RequestedOutcome
|
||||
if identityText == "" {
|
||||
identityText = request.Message
|
||||
}
|
||||
if request.Meaning.SpeechAct == "QUESTION" && isIdentityInquiry(identityText) {
|
||||
text = "Я — Домовой, ваш помощник."
|
||||
}
|
||||
switch request.Meaning.InformationRequestKind {
|
||||
case interaction.InformationRequestCognitiveModelInventory:
|
||||
text = "Запрос понят как проверка моделей. Встроенный режим Домового доступен; я могу показать подключённые модели по назначению, качеству, скорости и стоимости. Ключи и пароли в разговор не нужны."
|
||||
text = "Сейчас не смог проверить подключённые модели."
|
||||
case interaction.InformationRequestCapabilityInventory:
|
||||
text = "Запрос понят как проверка возможностей. Я могу объяснить доступные capabilities и отличить то, что уже подключено, от того, что пока является только планом или capability gap."
|
||||
text = "Сейчас не смог проверить доступные возможности."
|
||||
case interaction.InformationRequestCognitiveChannelStatus:
|
||||
text = "Запрос понят как проверка когнитивного канала. Локальный режим остаётся доступен; я объясню наблюдаемое состояние и что можно сделать без ожидания внешней модели."
|
||||
text = "Локальный канал Домового доступен; подробности состояния дам по запросу."
|
||||
case interaction.InformationRequestCognitiveRecovery:
|
||||
text = "Запрос понят как восстановление ИИ. Начинаю с локального маршрута: фиксирую, что недоступно, проверяю безопасные варианты восстановления и сформулирую следующий обратимый шаг."
|
||||
text = "Встроенный канал сейчас недоступен."
|
||||
default:
|
||||
switch request.Meaning.SpeechAct {
|
||||
case "REQUEST_TO_INVESTIGATE":
|
||||
text = "Я принял это как исследование. Даже без внешней модели могу сформулировать гипотезы, отделить известное от неизвестного и предложить следующий проверяемый шаг."
|
||||
text = "Понял задачу, но пока не смог получить проверяемый результат."
|
||||
case "QUESTION":
|
||||
text = "Я отвечаю во встроенном режиме Домового. Если для полного ответа не хватает внешней модели, объясню это прямо и продолжу с тем, что можно проверить локально."
|
||||
if !isIdentityInquiry(identityText) {
|
||||
text = "Пока не смог получить проверяемый ответ."
|
||||
}
|
||||
case "CONVERSATIONAL":
|
||||
text = "Я — Домовой, ваш помощник."
|
||||
}
|
||||
}
|
||||
return cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, AdvisoryText: text, FinishReason: cognitive.FinishCompleted}
|
||||
}
|
||||
|
||||
func isIdentityInquiry(message string) bool {
|
||||
tokens := strings.Fields(strings.ToLower(strings.NewReplacer("?", " ", "!", " ", ",", " ").Replace(message)))
|
||||
hasIdentitySubject := false
|
||||
hasAssistantReference := false
|
||||
for _, token := range tokens {
|
||||
switch token {
|
||||
case "кто", "зовут", "имя":
|
||||
hasIdentitySubject = true
|
||||
case "ты", "тебя", "тебе", "вы", "вас":
|
||||
hasAssistantReference = true
|
||||
}
|
||||
}
|
||||
return hasIdentitySubject && hasAssistantReference
|
||||
}
|
||||
|
||||
func (g cognitiveGateway) invokeBuiltinAdvisory(ctx context.Context, request cognitive.GatewayAdvisoryRequest, maxOutputBytes int) cognitive.GatewayAdvisoryResponse {
|
||||
if g.builtinLocal != nil && g.builtinLocal.Provider != nil {
|
||||
invocation := cognitive.AdvisoryRequest{Version: request.Version, RequestID: request.RequestID, ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: request.Message, Context: request.Context, Locale: request.Locale, MaxOutputBytes: maxOutputBytes, ProviderBindingID: "builtin-local-binding-v1", ProviderBindingRevision: 1, ProviderInstanceID: "builtin-local-lifeboat-v1", ModelDescriptorID: "builtin-local-model-v1", ProviderModelID: "domovoy-lifeboat"}
|
||||
@@ -272,12 +332,21 @@ func fallbackEligible(reason cognitive.FinishReason) bool {
|
||||
}
|
||||
|
||||
func (g cognitiveGateway) provider(selected capability.ResolvedCognitiveBinding) advisoryProvider {
|
||||
var byStableID advisoryProvider
|
||||
for _, registration := range g.providers[selected.Provider.Kind] {
|
||||
if registration.EndpointRef == selected.Provider.EndpointRef && registration.CredentialRef == selected.Provider.CredentialRef {
|
||||
return registration.Provider
|
||||
}
|
||||
// Capability revisions can legitimately retain an older credential
|
||||
// reference while the protected runtime has rotated or reloaded the
|
||||
// current credential. ProviderInstanceID is the stable owner identity;
|
||||
// use it after exact metadata matching so stale non-secret metadata does
|
||||
// not force an unnecessary builtin fallback.
|
||||
if byStableID == nil && registration.ProviderInstanceID == selected.Provider.ID && registration.Kind == selected.Provider.Kind {
|
||||
byStableID = registration.Provider
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return byStableID
|
||||
}
|
||||
|
||||
func writeGatewayResponse(w http.ResponseWriter, response cognitive.GatewayAdvisoryResponse) {
|
||||
@@ -319,6 +388,26 @@ func (r *httpBindingResolver) Resolve(ctx context.Context, value capability.Cogn
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (r *httpBindingResolver) ResolveCandidate(ctx context.Context, bindingID capability.CapabilityBindingID, revision int64) (capability.ResolvedCognitiveBinding, error) {
|
||||
var result capability.ResolvedCognitiveBinding
|
||||
target := *r.base
|
||||
target.Path = "/v1/cognitive-bindings/" + url.PathEscape(string(bindingID)) + "/revisions/" + fmt.Sprint(revision) + "/snapshot"
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
response, err := r.client.Do(request)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return result, fmt.Errorf("candidate status %d", response.StatusCode)
|
||||
}
|
||||
err = json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (r *httpBindingResolver) Observe(ctx context.Context, value capability.CognitiveUsageObservation) error {
|
||||
return r.post(ctx, "/v1/cognitive-usage-observations", value, nil)
|
||||
}
|
||||
|
||||
@@ -88,6 +88,19 @@ func TestActiveRegistryBindingReachesProviderAdapter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderUsesStableInstanceWhenCredentialMetadataIsStale(t *testing.T) {
|
||||
provider := &stubAdvisoryProvider{}
|
||||
gateway := cognitiveGateway{providers: map[capability.ProviderKind][]providerRegistration{
|
||||
"provider-kind-1": {{ProviderInstanceID: "provider-1", Kind: "provider-kind-1", EndpointRef: "current-endpoint", CredentialRef: "current-credential", Provider: provider}},
|
||||
}}
|
||||
selected := gatewaySelection()
|
||||
selected.Provider.EndpointRef = "old-endpoint"
|
||||
selected.Provider.CredentialRef = ""
|
||||
if got := gateway.provider(selected); got != provider {
|
||||
t.Fatalf("stable provider identity was not used for stale metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanaryFlagIsForwardedToCapabilityResolver(t *testing.T) {
|
||||
registry := ®istryStub{err: errors.New("stop after resolve")}
|
||||
gateway := cognitiveGateway{resolver: registry, providers: map[capability.ProviderKind][]providerRegistration{}, now: time.Now, allowCanary: true}
|
||||
@@ -172,11 +185,34 @@ func TestNoActiveBindingUsesBuiltInProviderWithoutInvocation(t *testing.T) {
|
||||
func TestBuiltInAdvisoryUsesTrustedInformationIntentInsteadOfMessageKeywords(t *testing.T) {
|
||||
request := cognitive.GatewayAdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", Message: "arbitrary text", Meaning: cognitive.TaskMeaning{InformationRequestKind: interaction.InformationRequestCognitiveRecovery}}
|
||||
response := builtinAdvisory(request)
|
||||
if !strings.Contains(response.AdvisoryText, "восстановление ИИ") {
|
||||
if response.AdvisoryText != "Встроенный канал сейчас недоступен." {
|
||||
t.Fatalf("unexpected recovery response: %q", response.AdvisoryText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInAdvisoryIdentityIsNaturalAndDoesNotMentionResponseLength(t *testing.T) {
|
||||
response := builtinAdvisory(cognitive.GatewayAdvisoryRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1,
|
||||
RequestID: "request-identity",
|
||||
Meaning: cognitive.TaskMeaning{SpeechAct: "QUESTION", RequestedOutcome: "ты кто?"},
|
||||
})
|
||||
if response.AdvisoryText != "Я — Домовой, ваш помощник." {
|
||||
t.Fatalf("unexpected identity response: %q", response.AdvisoryText)
|
||||
}
|
||||
if strings.Contains(response.AdvisoryText, "коротко") || strings.Contains(response.AdvisoryText, "подробност") {
|
||||
t.Fatalf("identity response contains style meta-text: %q", response.AdvisoryText)
|
||||
}
|
||||
messageResponse := builtinAdvisory(cognitive.GatewayAdvisoryRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1,
|
||||
RequestID: "request-identity-message",
|
||||
Message: "как тебя зовут?",
|
||||
Meaning: cognitive.TaskMeaning{SpeechAct: "QUESTION"},
|
||||
})
|
||||
if messageResponse.AdvisoryText != "Я — Домовой, ваш помощник." {
|
||||
t.Fatalf("unexpected message identity response: %q", messageResponse.AdvisoryText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoActiveBindingInvokesBuiltInLocalProvider(t *testing.T) {
|
||||
registry := ®istryStub{err: errors.New("no active binding")}
|
||||
provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", AdvisoryText: "локальный ответ", FinishReason: cognitive.FinishCompleted}}
|
||||
|
||||
@@ -163,8 +163,8 @@ func interpretationPrompt(request cognitive.GatewayInterpretationRequest) string
|
||||
Allowed speech acts: DIRECTIVE, ASSERTION, DECISION, PREFERENCE, QUESTION, HYPOTHESIS, CORRECTION, REQUEST_TO_INVESTIGATE, CONFIRMATION, REJECTION, CANCELLATION, CONVERSATIONAL, UNKNOWN.
|
||||
Allowed applicability: APPLICABLE, LOW_CONFIDENCE, MIXED_INTENT, NEEDS_BROADER_INTERPRETATION, CLARIFICATION_REQUIRED.
|
||||
Allowed clarification: NOT_REQUIRED or REQUIRED.
|
||||
Capability hints may only contain known capability kinds like COGNITIVE_REASONING, MODIFY_CODE, RUN_TESTS, SEND_EMAIL, SCHEDULE_REMINDER, VERIFY_FACT, INSPECT_PROJECT, READ_DATABASE, QUERY_LOGS.
|
||||
Interpret the human utterance semantically. Do not claim action execution. Do not treat punctuation alone as decisive. If uncertain, set applicability accordingly.
|
||||
Capability hints may only contain known capability kinds like COGNITIVE_REASONING, WEB_RESEARCH, MODIFY_CODE, RUN_TESTS, SEND_EMAIL, SCHEDULE_REMINDER, VERIFY_FACT, INSPECT_PROJECT, READ_DATABASE, QUERY_LOGS.
|
||||
Interpret the human utterance semantically. Do not claim action execution. A question asking for a current, external or otherwise source-dependent fact should receive WEB_RESEARCH; do not answer that fact from model memory. A request to discuss hypotheses or options is different and may receive COGNITIVE_REASONING. Do not treat punctuation alone as decisive. If uncertain, set applicability accordingly.
|
||||
Message: %q
|
||||
Deterministic primary: %s
|
||||
Deterministic secondary: %s
|
||||
|
||||
+182
-15
@@ -10,11 +10,14 @@ import (
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -43,13 +46,45 @@ func main() {
|
||||
discoveries := map[string]providerRegistration{}
|
||||
var builtinLocal *providerRegistration
|
||||
externalRecovery := []providerRegistration{}
|
||||
cpuAdapter, err := models.NewOpenAICompatibleLocalAdapter(envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1"), nil)
|
||||
cpuAPIKey, err := loadProviderAPIKey("LOCAL_CPU_PROVIDER_API_KEY", "LOCAL_CPU_PROVIDER_API_KEY_FILE", "LOCAL_CPU_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-cpu/provider-api-key/v1")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cpu := providerRegistration{ProviderInstanceID: "local-cpu-primary", Kind: "openai-compatible-chat-v1", EndpointRef: "local-cpu-primary-v1", AuthMode: capability.AuthNone, BillingMode: capability.BillingFree, DataPolicy: capability.DataPolicyLocalOnly, Provider: cpuAdapter, Discovery: cpuAdapter}
|
||||
cpuAdapter, err := models.NewOpenAICompatibleLocalAdapterWithAPIKey(envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1"), cpuAPIKey, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cpuAuthMode := capability.AuthNone
|
||||
cpuCredentialRef := capability.CredentialRef("")
|
||||
if cpuAPIKey != "" {
|
||||
cpuAuthMode = capability.AuthKey
|
||||
cpuCredentialRef = capability.CredentialRef(envOr("LOCAL_CPU_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-cpu/provider-api-key/v1"))
|
||||
}
|
||||
cpu := providerRegistration{ProviderInstanceID: "local-cpu-primary", Kind: "openai-compatible-chat-v1", EndpointRef: "local-cpu-primary-v1", CredentialRef: cpuCredentialRef, AuthMode: cpuAuthMode, BillingMode: capability.BillingFree, DataPolicy: capability.DataPolicyLocalOnly, Provider: cpuAdapter, Discovery: cpuAdapter}
|
||||
providers[cpu.Kind] = append(providers[cpu.Kind], cpu)
|
||||
discoveries["cpu"] = cpu
|
||||
mediumURL := strings.TrimSpace(os.Getenv("LOCAL_MEDIUM_PROVIDER_URL"))
|
||||
mediumAPIKey := ""
|
||||
if mediumURL != "" {
|
||||
mediumAPIKey, err = loadProviderAPIKey("LOCAL_MEDIUM_PROVIDER_API_KEY", "LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", "LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
mediumTimeout := durationEnv("LOCAL_MEDIUM_PROVIDER_TIMEOUT", 60*time.Second)
|
||||
mediumAdapter, adapterErr := models.NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(strings.TrimRight(mediumURL, "/"), mediumAPIKey, nil, mediumTimeout)
|
||||
if adapterErr != nil {
|
||||
log.Fatal(adapterErr)
|
||||
}
|
||||
mediumAuthMode := capability.AuthNone
|
||||
mediumCredentialRef := capability.CredentialRef("")
|
||||
if mediumAPIKey != "" {
|
||||
mediumAuthMode = capability.AuthKey
|
||||
mediumCredentialRef = capability.CredentialRef(envOr("LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1"))
|
||||
}
|
||||
medium := providerRegistration{ProviderInstanceID: "local-medium-primary", Kind: "openai-compatible-chat-v1", EndpointRef: "local-medium-primary-v1", CredentialRef: mediumCredentialRef, AuthMode: mediumAuthMode, BillingMode: capability.BillingFree, DataPolicy: capability.DataPolicyLocalOnly, Provider: mediumAdapter, Discovery: mediumAdapter}
|
||||
providers[medium.Kind] = append(providers[medium.Kind], medium)
|
||||
discoveries["medium"] = medium
|
||||
}
|
||||
if lifeboat, enabled, lifeboatErr := newLifeboatProvider(os.Getenv("LOCAL_LIFEBOAT_PROVIDER_URL")); lifeboatErr != nil {
|
||||
log.Fatal(lifeboatErr)
|
||||
} else if enabled {
|
||||
@@ -58,34 +93,87 @@ func main() {
|
||||
discoveries["lifeboat"] = lifeboat
|
||||
builtinLocal = &lifeboat
|
||||
}
|
||||
gpuAdapter, err := models.NewOllamaAdapter(envOr("LOCAL_GPU_PROVIDER_URL", "http://192.168.220.91:11434"), nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
gpuURL, gpuKey, gpuConfigured, gpuErr := loadProtectedGPUConfiguration()
|
||||
if gpuErr != nil {
|
||||
log.Printf("optional GPU provider disabled: %v", gpuErr)
|
||||
} else if gpuConfigured {
|
||||
gpuTimeout := durationEnv("LOCAL_GPU_PROVIDER_TIMEOUT", 60*time.Second)
|
||||
gpuAdapter, adapterErr := models.NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(gpuURL, gpuKey, nil, gpuTimeout)
|
||||
if adapterErr != nil {
|
||||
log.Printf("optional GPU provider disabled: %v", adapterErr)
|
||||
} else {
|
||||
gpu := providerRegistration{ProviderInstanceID: "local-gpu-opportunistic", Kind: "openai-compatible-chat-v1", EndpointRef: "local-gpu-opportunistic-v1", CredentialRef: "credential://domovoy/gpu/provider-api-key/v1", AuthMode: capability.AuthKey, BillingMode: capability.BillingFree, DataPolicy: capability.DataPolicyExternalMessageOnly, Provider: gpuAdapter, Discovery: gpuAdapter}
|
||||
providers[gpu.Kind] = append(providers[gpu.Kind], gpu)
|
||||
discoveries["gpu"] = gpu
|
||||
externalRecovery = append(externalRecovery, gpu)
|
||||
}
|
||||
}
|
||||
gpu := providerRegistration{ProviderInstanceID: "local-gpu-opportunistic", Kind: "ollama-v1", EndpointRef: "local-gpu-opportunistic-v1", AuthMode: capability.AuthNone, BillingMode: capability.BillingFree, DataPolicy: capability.DataPolicyExternalMessageOnly, Provider: gpuAdapter, Discovery: gpuAdapter}
|
||||
providers[gpu.Kind] = append(providers[gpu.Kind], gpu)
|
||||
discoveries["gpu"] = gpu
|
||||
externalRecovery = append(externalRecovery, gpu)
|
||||
if openAIProvider != nil {
|
||||
kind := capability.ProviderKind(envOr("OPENAI_PROVIDER_KIND", "openai-responses-v1"))
|
||||
providers[kind] = append(providers[kind], providerRegistration{ProviderInstanceID: "openai-bootstrap", Kind: kind, EndpointRef: capability.EndpointBindingRef(envOr("OPENAI_ENDPOINT_REF", "openai-responses-default")), CredentialRef: capability.CredentialRef(envOr("OPENAI_CREDENTIAL_REF", "openai-advisory-v1")), AuthMode: capability.AuthKey, BillingMode: capability.BillingPerInput, DataPolicy: capability.DataPolicyExternalMessageOnly, Provider: openAIProvider})
|
||||
}
|
||||
gateway := cognitiveGateway{resolver: resolver, providers: providers, builtinLocal: builtinLocal, externalRecovery: externalRecovery, now: time.Now, allowCanary: os.Getenv("COGNITIVE_ALLOW_CANARY") == "true", canaryMaxOutputBytes: 4096}
|
||||
gateway := cognitiveGateway{resolver: resolver, providers: providers, builtinLocal: builtinLocal, externalRecovery: externalRecovery, now: time.Now, allowCanary: os.Getenv("COGNITIVE_ALLOW_CANARY") == "true", canaryMaxOutputBytes: 4096, admission: newHTTPAdmissionClient(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), &http.Client{Timeout: 5 * time.Second})}
|
||||
mux.Handle("/healthz", health.Handler{Runtime: "cognitive-worker", SemanticGeneration: 1, ArchitectureGeneration: 1})
|
||||
readinessClient := &http.Client{Timeout: 2 * time.Second}
|
||||
cpuReadinessClient := &http.Client{Timeout: 10 * time.Second}
|
||||
mediumReadinessClient := &http.Client{Timeout: 10 * time.Second}
|
||||
lifeboatURL := strings.TrimSuffix(strings.TrimRight(envOr("LOCAL_LIFEBOAT_PROVIDER_URL", "http://cognitive-lifeboat:8086/v1"), "/"), "/v1") + "/health"
|
||||
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "cognitive-worker", Probes: []health.Probe{
|
||||
fleetClient := &http.Client{Timeout: 2 * time.Second}
|
||||
preferredFleet := capability.FleetTier(envOr("COGNITIVE_PREFERRED_FLEET_TIER", string(capability.FleetTierFast)))
|
||||
cpuFleetHeaders := map[string]string{}
|
||||
if cpuAPIKey != "" {
|
||||
cpuFleetHeaders["Authorization"] = "Bearer " + cpuAPIKey
|
||||
}
|
||||
cpuEndpoint := strings.TrimRight(envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1"), "/") + "/models"
|
||||
cpuHealthProbe := health.OptionalHTTPProbeWithHeaders(cpuReadinessClient, cpuEndpoint, cpuFleetHeaders)
|
||||
// Opportunistic providers must not hide a ready local lifeboat. Keep their
|
||||
// readiness budget short; their detailed fleet state is reported separately.
|
||||
cpuHealthProbe.Timeout = 2 * time.Second
|
||||
mediumEndpoint := ""
|
||||
mediumHeaders := map[string]string{}
|
||||
mediumHealthProbe := health.Probe{}
|
||||
if mediumURL != "" {
|
||||
mediumEndpoint = strings.TrimRight(mediumURL, "/") + "/models"
|
||||
if mediumAPIKey != "" {
|
||||
mediumHeaders["Authorization"] = "Bearer " + mediumAPIKey
|
||||
}
|
||||
mediumHealthProbe = health.OptionalHTTPProbeWithHeaders(mediumReadinessClient, mediumEndpoint, mediumHeaders)
|
||||
mediumHealthProbe.Timeout = 2 * time.Second
|
||||
}
|
||||
readinessProbes := []health.Probe{
|
||||
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), "/")+"/healthz"),
|
||||
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1"), "/")+"/models"),
|
||||
health.HTTPProbe(readinessClient, lifeboatURL),
|
||||
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("LOCAL_GPU_PROVIDER_URL", "http://192.168.220.91:11434"), "/")+"/api/tags"),
|
||||
}})
|
||||
cpuHealthProbe,
|
||||
}
|
||||
if mediumURL != "" {
|
||||
readinessProbes = append(readinessProbes, mediumHealthProbe)
|
||||
}
|
||||
readinessProbes = append(readinessProbes, health.HTTPProbe(readinessClient, lifeboatURL))
|
||||
if gpuConfigured {
|
||||
gpuHeaders := map[string]string{"Authorization": "Bearer " + gpuKey}
|
||||
gpuProbe := health.OptionalHTTPProbeWithHeaders(readinessClient, strings.TrimRight(gpuURL, "/")+"/models", gpuHeaders)
|
||||
gpuProbe.Timeout = 2 * time.Second
|
||||
readinessProbes = append(readinessProbes, gpuProbe)
|
||||
}
|
||||
|
||||
fleetEndpoints := []fleetEndpoint{
|
||||
{ProviderID: "local-cpu-primary", ModelID: "local-cpu-model", Tier: capability.FleetTierFast, URL: cpuEndpoint, Headers: cpuFleetHeaders, Client: &http.Client{Timeout: 10 * time.Second}},
|
||||
{ProviderID: "local-medium-primary", ModelID: "local-medium-model", Tier: capability.FleetTierMedium, URL: mediumEndpoint, Headers: mediumHeaders, Client: mediumReadinessClient},
|
||||
{ProviderID: "cognitive-lifeboat", ModelID: "qwen3-8b-lifeboat", Tier: capability.FleetTierLifeboat, URL: lifeboatURL},
|
||||
}
|
||||
if gpuConfigured {
|
||||
fleetEndpoints = append(fleetEndpoints, fleetEndpoint{ProviderID: "local-gpu-opportunistic", ModelID: "gpu-opportunistic", Tier: capability.FleetTierHeavy, URL: strings.TrimRight(gpuURL, "/") + "/models", Headers: map[string]string{"Authorization": "Bearer " + gpuKey}})
|
||||
}
|
||||
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "cognitive-worker", Probes: readinessProbes})
|
||||
mux.Handle("/v1/fleet/readiness", fleetReadinessHandler(fleetClient, preferredFleet, fleetEndpoints))
|
||||
mux.HandleFunc("POST /v1/proposals/deterministic", deterministicProposal)
|
||||
mux.HandleFunc("POST /v1/advisory-policy", gateway.policyHandler)
|
||||
mux.HandleFunc("POST /v1/advisories", gateway.advisoryHandler)
|
||||
mux.HandleFunc("POST /v1/interpretations", gateway.interpretationHandler)
|
||||
mux.HandleFunc("POST /v1/task-evaluations/execute", gateway.taskEvaluationHandler)
|
||||
discovery := discoveryService{resolver: resolver, providers: discoveries, owner: identity.PrincipalID(envOr("COGNITIVE_OWNER_PRINCIPAL_ID", "domovoy-creator-principal")), now: time.Now}
|
||||
mux.HandleFunc("POST /v1/provider-discovery/{provider}", discovery.handler)
|
||||
mux.Handle("POST /v1/provider-discovery/{provider}/qualify", modelQualificationHandler(discoveries, string(envOr("COGNITIVE_OWNER_PRINCIPAL_ID", "domovoy-creator-principal")), httpQualificationObserver{resolver: resolver}))
|
||||
mux.HandleFunc("GET /v1/provider-discovery/status", discovery.statusHandler)
|
||||
refreshProviderDiscoveries(resolver, discovery)
|
||||
log.Printf("cognitive-worker listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
@@ -93,6 +181,19 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func durationEnv(name string, fallback time.Duration) time.Duration {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := time.ParseDuration(value)
|
||||
if err != nil || parsed <= 0 {
|
||||
log.Printf("invalid %s=%q; using %s", name, value, fallback)
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func refreshProviderDiscoveries(resolver bindingResolver, service discoveryService) {
|
||||
for name := range service.providers {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
@@ -137,6 +238,72 @@ func refreshProviderDiscoveries(resolver bindingResolver, service discoveryServi
|
||||
}
|
||||
}
|
||||
|
||||
func loadProviderAPIKey(directEnvName, fileEnvName, refEnvName, defaultRef string) (string, error) {
|
||||
if _, set := os.LookupEnv(directEnvName); set {
|
||||
return "", fmt.Errorf("%s direct environment secret is forbidden; use %s", directEnvName, fileEnvName)
|
||||
}
|
||||
fileName := strings.TrimSpace(os.Getenv(fileEnvName))
|
||||
if fileName == "" {
|
||||
return "", nil
|
||||
}
|
||||
ref := strings.TrimSpace(os.Getenv(refEnvName))
|
||||
if ref == "" {
|
||||
ref = defaultRef
|
||||
}
|
||||
if ref == "" {
|
||||
return "", fmt.Errorf("%s is required when %s is configured", refEnvName, fileEnvName)
|
||||
}
|
||||
resolver, err := platformsecrets.NewFileResolver(envOr("SECRET_BACKEND_ROOT", "/run/secrets"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeProviderAPIKey, Consumer: secretcontracts.RuntimeConsumerCognitive, Path: fileName})
|
||||
}
|
||||
|
||||
func loadLocalCPUProviderAPIKey() (string, error) {
|
||||
return loadProviderAPIKey("LOCAL_CPU_PROVIDER_API_KEY", "LOCAL_CPU_PROVIDER_API_KEY_FILE", "LOCAL_CPU_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-cpu/provider-api-key/v1")
|
||||
}
|
||||
|
||||
const (
|
||||
gpuProviderURLFile = "/run/secrets/llm_gpu_gateway_url"
|
||||
gpuProviderKeyFile = "/run/secrets/llm_gpu_gateway_api_key"
|
||||
)
|
||||
|
||||
func loadProtectedGPUConfiguration() (string, string, bool, error) {
|
||||
urlValue, err := readProtectedRuntimeValue(gpuProviderURLFile)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
key, err := readProtectedRuntimeValue(gpuProviderKeyFile)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", "", false, fmt.Errorf("GPU provider key is not configured")
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
return urlValue, key, true, nil
|
||||
}
|
||||
|
||||
func readProtectedRuntimeValue(path string) (string, error) {
|
||||
const root = "/run/secrets"
|
||||
clean := filepath.Clean(path)
|
||||
if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("protected runtime path outside secret root")
|
||||
}
|
||||
payload, err := os.ReadFile(clean)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
value := strings.TrimSpace(string(payload))
|
||||
if value == "" || strings.ContainsAny(value, "\r\n") {
|
||||
return "", fmt.Errorf("protected runtime value is empty or multiline")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func deterministicAdvisory(w http.ResponseWriter, r *http.Request) {
|
||||
request, ok := decodeAdvisoryRequest(w, r)
|
||||
if !ok {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLifeboatProviderIsOptionalAndLocalAdapterBacked(t *testing.T) {
|
||||
if _, enabled, err := newLifeboatProvider(""); err != nil || enabled {
|
||||
@@ -14,3 +19,74 @@ func TestLifeboatProviderIsOptionalAndLocalAdapterBacked(t *testing.T) {
|
||||
t.Fatalf("unexpected lifeboat registration: %+v", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPUProviderTimeoutUsesBoundedConfigurableDefault(t *testing.T) {
|
||||
t.Setenv("LOCAL_GPU_PROVIDER_TIMEOUT", "")
|
||||
if got := durationEnv("LOCAL_GPU_PROVIDER_TIMEOUT", 60*time.Second); got != 60*time.Second {
|
||||
t.Fatalf("default GPU timeout=%s", got)
|
||||
}
|
||||
t.Setenv("LOCAL_GPU_PROVIDER_TIMEOUT", "31s")
|
||||
if got := durationEnv("LOCAL_GPU_PROVIDER_TIMEOUT", 60*time.Second); got != 31*time.Second {
|
||||
t.Fatalf("configured GPU timeout=%s", got)
|
||||
}
|
||||
t.Setenv("LOCAL_GPU_PROVIDER_TIMEOUT", "invalid")
|
||||
if got := durationEnv("LOCAL_GPU_PROVIDER_TIMEOUT", 60*time.Second); got != 60*time.Second {
|
||||
t.Fatalf("invalid GPU timeout=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLocalCPUProviderAPIKeyUsesProtectedFileReference(t *testing.T) {
|
||||
t.Setenv("LOCAL_CPU_PROVIDER_API_KEY", "")
|
||||
_ = os.Unsetenv("LOCAL_CPU_PROVIDER_API_KEY")
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "local_cpu_provider_api_key")
|
||||
if err := os.WriteFile(path, []byte("resolved-key\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("LOCAL_CPU_PROVIDER_API_KEY_FILE", path)
|
||||
t.Setenv("LOCAL_CPU_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-cpu/provider-api-key/v1")
|
||||
t.Setenv("SECRET_BACKEND_ROOT", root)
|
||||
key, err := loadLocalCPUProviderAPIKey()
|
||||
if err != nil || key != "resolved-key" {
|
||||
t.Fatalf("unexpected key resolution result: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLocalCPUProviderAPIKeyRejectsDirectEnvironmentSecret(t *testing.T) {
|
||||
t.Setenv("LOCAL_CPU_PROVIDER_API_KEY", "must-not-be-used")
|
||||
t.Setenv("LOCAL_CPU_PROVIDER_API_KEY_FILE", "")
|
||||
if _, err := loadLocalCPUProviderAPIKey(); err == nil {
|
||||
t.Fatal("expected direct environment secret to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProviderAPIKeySupportsMediumCredentialRef(t *testing.T) {
|
||||
t.Setenv("LOCAL_MEDIUM_PROVIDER_API_KEY", "")
|
||||
_ = os.Unsetenv("LOCAL_MEDIUM_PROVIDER_API_KEY")
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "medium_provider_api_key")
|
||||
if err := os.WriteFile(path, []byte("medium-key\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", path)
|
||||
t.Setenv("LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1")
|
||||
t.Setenv("SECRET_BACKEND_ROOT", root)
|
||||
key, err := loadProviderAPIKey("LOCAL_MEDIUM_PROVIDER_API_KEY", "LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", "LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1")
|
||||
if err != nil || key != "medium-key" {
|
||||
t.Fatalf("unexpected medium key resolution result: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProviderAPIKeyRejectsMediumDirectEnvironmentSecret(t *testing.T) {
|
||||
t.Setenv("LOCAL_MEDIUM_PROVIDER_API_KEY", "must-not-be-used")
|
||||
t.Setenv("LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", "")
|
||||
if _, err := loadProviderAPIKey("LOCAL_MEDIUM_PROVIDER_API_KEY", "LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", "LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1"); err == nil {
|
||||
t.Fatal("expected direct medium environment secret to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadProtectedRuntimeValueRejectsPathOutsideSecretRoot(t *testing.T) {
|
||||
if _, err := readProtectedRuntimeValue("/tmp/not-a-domovoy-secret"); err == nil {
|
||||
t.Fatal("expected protected path rejection")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/models"
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
type qualificationObserver interface {
|
||||
ObserveQualification(context.Context, capability.ModelQualificationObservation) error
|
||||
}
|
||||
|
||||
type httpQualificationObserver struct{ resolver *httpBindingResolver }
|
||||
|
||||
func (o httpQualificationObserver) ObserveQualification(ctx context.Context, value capability.ModelQualificationObservation) error {
|
||||
if o.resolver == nil {
|
||||
return fmt.Errorf("qualification observer resolver is nil")
|
||||
}
|
||||
return o.resolver.post(ctx, "/v1/cognitive-registry/model-qualification-observations", value, nil)
|
||||
}
|
||||
|
||||
type modelQualificationRequest struct {
|
||||
Prompt string `json:"prompt"`
|
||||
MaxOutputBytes int `json:"max_output_bytes"`
|
||||
ModelIDs []capability.ProviderModelID `json:"model_ids,omitempty"`
|
||||
}
|
||||
|
||||
type modelQualificationResult struct {
|
||||
ProviderModelID capability.ProviderModelID `json:"provider_model_id"`
|
||||
Status string `json:"status"`
|
||||
FinishReason cognitive.FinishReason `json:"finish_reason,omitempty"`
|
||||
LatencyMillis int64 `json:"latency_millis"`
|
||||
OutputObserved bool `json:"output_observed"`
|
||||
QualityAssessed bool `json:"quality_assessed"`
|
||||
Category capability.ModelRatingCategory `json:"category"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type modelQualificationResponse struct {
|
||||
Provider capability.ProviderInstanceID `json:"provider"`
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
Results []modelQualificationResult `json:"results"`
|
||||
}
|
||||
|
||||
func modelQualificationHandler(providers map[string]providerRegistration, owner string, observers ...qualificationObserver) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("provider")
|
||||
registration, ok := providers[name]
|
||||
if !ok || registration.Discovery == nil || registration.Provider == nil {
|
||||
http.Error(w, "provider qualification not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var request modelQualificationRequest
|
||||
if !decodeJSON(w, r, 16<<10, &request) || request.Prompt == "" {
|
||||
return
|
||||
}
|
||||
if request.MaxOutputBytes < 1 {
|
||||
request.MaxOutputBytes = 512
|
||||
}
|
||||
if request.MaxOutputBytes > 4096 {
|
||||
http.Error(w, "max_output_bytes exceeds qualification bound", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
modelsFound, probe := registration.Discovery.Discover(r.Context())
|
||||
if probe.Health != capability.HealthAvailable || len(modelsFound) == 0 {
|
||||
http.Error(w, "provider unavailable for qualification", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if len(request.ModelIDs) > 0 {
|
||||
modelsFound = slices.DeleteFunc(modelsFound, func(model models.DiscoveredModel) bool {
|
||||
return !slices.Contains(request.ModelIDs, model.ProviderModelID)
|
||||
})
|
||||
}
|
||||
if len(modelsFound) == 0 {
|
||||
http.Error(w, "requested model not discovered", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if len(modelsFound) > 16 {
|
||||
modelsFound = modelsFound[:16]
|
||||
}
|
||||
results := qualifyModels(r.Context(), registration, owner, request.Prompt, request.MaxOutputBytes, modelsFound)
|
||||
if len(observers) > 0 && observers[0] != nil {
|
||||
if err := persistQualificationResults(r.Context(), observers[0], registration, results); err != nil {
|
||||
http.Error(w, "persist model qualification observation", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = jsonEncode(w, modelQualificationResponse{Provider: registration.ProviderInstanceID, ObservedAtUnixMillis: time.Now().UnixMilli(), Results: results})
|
||||
})
|
||||
}
|
||||
|
||||
func qualifyModels(ctx context.Context, registration providerRegistration, owner, prompt string, maxOutputBytes int, discovered []models.DiscoveredModel) []modelQualificationResult {
|
||||
results := make([]modelQualificationResult, len(discovered))
|
||||
var wg sync.WaitGroup
|
||||
for index, model := range discovered {
|
||||
wg.Add(1)
|
||||
go func(index int, model models.DiscoveredModel) {
|
||||
defer wg.Done()
|
||||
started := time.Now()
|
||||
request := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(fmt.Sprintf("qualification:%s:%d", model.ProviderModelID, started.UnixNano())), ActorID: identity.ActorID("qualification-runtime"), PrincipalID: identity.PrincipalID(owner), ScopeID: authority.ScopeID("qualification-scope"), Message: prompt, Locale: "en-US", MaxOutputBytes: maxOutputBytes, ProviderBindingID: "qualification-binding-v1", ProviderBindingRevision: 1, ProviderInstanceID: registration.ProviderInstanceID, ModelDescriptorID: capability.ModelDescriptorID("qualification-" + string(model.ProviderModelID)), ProviderModelID: model.ProviderModelID}
|
||||
response, err := registration.Provider.Advise(ctx, request)
|
||||
result := modelQualificationResult{ProviderModelID: model.ProviderModelID, LatencyMillis: time.Since(started).Milliseconds(), QualityAssessed: false, Category: capability.ModelRatingUnrated}
|
||||
if err != nil {
|
||||
result.Status = "UNAVAILABLE"
|
||||
result.FinishReason = cognitive.FinishProviderUnavailable
|
||||
result.Error = err.Error()
|
||||
results[index] = result
|
||||
return
|
||||
}
|
||||
result.FinishReason = response.FinishReason
|
||||
result.OutputObserved = response.AdvisoryText != ""
|
||||
if response.FinishReason == cognitive.FinishCompleted && result.OutputObserved {
|
||||
result.Status = "REACHABLE"
|
||||
} else {
|
||||
result.Status = "RESPONDED_WITHOUT_QUALIFICATION"
|
||||
}
|
||||
results[index] = result
|
||||
}(index, model)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
func persistQualificationResults(ctx context.Context, observer qualificationObserver, registration providerRegistration, results []modelQualificationResult) error {
|
||||
for _, result := range results {
|
||||
observationID, err := ids.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status := capability.ModelQualificationRespondedWithoutRating
|
||||
switch result.Status {
|
||||
case "REACHABLE":
|
||||
status = capability.ModelQualificationReachable
|
||||
case "UNAVAILABLE":
|
||||
status = capability.ModelQualificationUnavailable
|
||||
}
|
||||
observation := capability.ModelQualificationObservation{
|
||||
ID: capability.ModelQualificationObservationID(observationID), ProviderInstanceID: registration.ProviderInstanceID, ProviderModelID: result.ProviderModelID,
|
||||
Status: status, FinishReason: string(result.FinishReason), LatencyMillis: result.LatencyMillis,
|
||||
OutputObserved: result.OutputObserved, QualityAssessed: false, Category: capability.ModelRatingUnrated,
|
||||
ObservedAtUnixMillis: time.Now().UnixMilli(),
|
||||
}
|
||||
if err := observation.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := observer.ObserveQualification(ctx, observation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func jsonEncode(w http.ResponseWriter, value any) error {
|
||||
return json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/models"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
)
|
||||
|
||||
type qualificationProvider struct{}
|
||||
|
||||
func (qualificationProvider) Probe(context.Context) models.ProviderProbe {
|
||||
return models.ProviderProbe{Health: capability.HealthAvailable}
|
||||
}
|
||||
func (qualificationProvider) Discover(context.Context) ([]models.DiscoveredModel, models.ProviderProbe) {
|
||||
return []models.DiscoveredModel{{ProviderModelID: "model-a"}, {ProviderModelID: "model-b"}}, models.ProviderProbe{Health: capability.HealthAvailable}
|
||||
}
|
||||
func (qualificationProvider) Advise(_ context.Context, request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
|
||||
if err := models.ValidateAdvisoryRequest(request); err != nil {
|
||||
return cognitive.AdvisoryResponse{}, err
|
||||
}
|
||||
return cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, AdvisoryText: "reachable", FinishReason: cognitive.FinishCompleted}, nil
|
||||
}
|
||||
|
||||
func TestModelQualificationUsesDiscoveredModelsAndLeavesQualityUnrated(t *testing.T) {
|
||||
provider := qualificationProvider{}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("POST /v1/provider-discovery/{provider}/qualify", modelQualificationHandler(map[string]providerRegistration{"gpu": {ProviderInstanceID: "gpu", Provider: provider, Discovery: provider}}, "owner"))
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
request := httptest.NewRequest(http.MethodPost, server.URL+"/v1/provider-discovery/gpu/qualify", strings.NewReader(`{"prompt":"ready","max_output_bytes":128}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("qualification status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), `"status":"REACHABLE"`) || !strings.Contains(response.Body.String(), `"category":"UNRATED"`) || strings.Contains(response.Body.String(), `"quality_assessed":true`) {
|
||||
t.Fatalf("qualification overclaimed quality: %s", response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelQualificationRejectsUnboundedOutput(t *testing.T) {
|
||||
provider := qualificationProvider{}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("POST /v1/provider-discovery/{provider}/qualify", modelQualificationHandler(map[string]providerRegistration{"cpu": {ProviderInstanceID: "cpu", Provider: provider, Discovery: provider}}, "owner"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/provider-discovery/cpu/qualify", strings.NewReader(`{"prompt":"ready","max_output_bytes":8192}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unbounded qualification status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
type qualificationObserverStub struct {
|
||||
observations []capability.ModelQualificationObservation
|
||||
}
|
||||
|
||||
func (s *qualificationObserverStub) ObserveQualification(_ context.Context, value capability.ModelQualificationObservation) error {
|
||||
s.observations = append(s.observations, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestModelQualificationPersistsReachabilityWithoutQuality(t *testing.T) {
|
||||
provider := qualificationProvider{}
|
||||
observer := &qualificationObserverStub{}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("POST /v1/provider-discovery/{provider}/qualify", modelQualificationHandler(map[string]providerRegistration{"cpu": {ProviderInstanceID: "cpu", Provider: provider, Discovery: provider}}, "owner", observer))
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/provider-discovery/cpu/qualify", strings.NewReader(`{"prompt":"ready","max_output_bytes":128,"model_ids":["model-a"]}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || len(observer.observations) != 1 {
|
||||
t.Fatalf("qualification persistence status=%d observations=%d body=%s", response.Code, len(observer.observations), response.Body.String())
|
||||
}
|
||||
if err := observer.observations[0].Validate(); err != nil || observer.observations[0].Status != capability.ModelQualificationReachable {
|
||||
t.Fatalf("invalid qualification observation: %+v err=%v", observer.observations[0], err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
)
|
||||
|
||||
func (g cognitiveGateway) taskEvaluationHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var request capability.TaskEvaluationExecutionRequest
|
||||
if !decodeJSON(w, r, 256<<10, &request) {
|
||||
return
|
||||
}
|
||||
if err := request.Profile.Validate(); err != nil || request.CapabilityID == "" || request.CapabilityID != request.Profile.CapabilityID || request.BindingID == "" || request.BindingRevision < 1 || request.ActorID == "" || request.PrincipalID == "" || request.ScopeID == "" || request.MaxOutputBytes < 1 || len(request.Cases) != len(request.Profile.RequiredCaseIDs) {
|
||||
http.Error(w, "invalid task evaluation execution request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resolver, ok := g.resolver.(candidateBindingResolver)
|
||||
if !ok {
|
||||
http.Error(w, "exact candidate resolver unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
selected, err := resolver.ResolveCandidate(r.Context(), request.BindingID, request.BindingRevision)
|
||||
if err != nil || selected.Binding.ID != request.BindingID || selected.Binding.Revision != request.BindingRevision || selected.Binding.CapabilityID != request.CapabilityID || selected.Binding.OwnerPrincipalID != request.PrincipalID {
|
||||
http.Error(w, "cognitive binding snapshot unavailable", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if selected.Binding.DataPolicy != capability.DataPolicyLocalOnly && !request.EgressAcknowledged {
|
||||
http.Error(w, "external egress acknowledgement required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
caseSet := make(map[string]struct{}, len(request.Profile.RequiredCaseIDs))
|
||||
seenCases := make(map[string]struct{}, len(request.Cases))
|
||||
for _, id := range request.Profile.RequiredCaseIDs {
|
||||
caseSet[id] = struct{}{}
|
||||
}
|
||||
for _, item := range request.Cases {
|
||||
if item.CaseID == "" || item.Prompt == "" || item.EvidenceArtifactRef == "" {
|
||||
http.Error(w, "invalid task evaluation case", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, exists := caseSet[item.CaseID]; !exists {
|
||||
http.Error(w, "task evaluation case is not in profile", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, exists := seenCases[item.CaseID]; exists {
|
||||
http.Error(w, "duplicate task evaluation case", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
seenCases[item.CaseID] = struct{}{}
|
||||
}
|
||||
if len(seenCases) != len(caseSet) {
|
||||
http.Error(w, "task evaluation case set is incomplete", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// One bounded fan-out wave uses one binding snapshot. If that candidate is
|
||||
// unavailable, only this binding is excluded and the existing resolver
|
||||
// chooses the next eligible candidate. Provider output remains untrusted
|
||||
// until the independent verifier accepts it downstream.
|
||||
requirement := capability.CognitiveRequirement{CapabilityID: request.CapabilityID, OwnerPrincipalID: request.PrincipalID, DataPolicy: selected.Binding.DataPolicy, RequiredModalities: []capability.ModelModality{capability.ModalityText}, RequiredCapabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, AllowCanary: g.allowCanary, MaxOutputBytes: request.MaxOutputBytes}
|
||||
var observations []capability.TaskCaseObservation
|
||||
var responses []capability.TaskCaseResponse
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
provider := g.provider(selected)
|
||||
if provider == nil {
|
||||
observations, responses = taskEvaluationUnavailable(request.Cases)
|
||||
} else {
|
||||
observations, responses = g.executeTaskEvaluationCases(r.Context(), request, selected, provider)
|
||||
}
|
||||
if taskEvaluationResponsesUsable(responses) {
|
||||
break
|
||||
}
|
||||
requirement.ExcludedBindingIDs = append(requirement.ExcludedBindingIDs, selected.Binding.ID)
|
||||
fallback, resolveErr := g.resolver.Resolve(r.Context(), requirement)
|
||||
fallbackProvider := g.provider(fallback)
|
||||
if resolveErr != nil || fallbackProvider == nil || fallback.Binding.ID == selected.Binding.ID || (fallback.Binding.DataPolicy != capability.DataPolicyLocalOnly && !request.EgressAcknowledged) {
|
||||
break
|
||||
}
|
||||
selected = fallback
|
||||
}
|
||||
|
||||
writeTaskEvaluationJSON(w, capability.TaskEvaluationExecutionResponse{ProfileID: request.Profile.ID, BindingID: selected.Binding.ID, BindingRevision: selected.Binding.Revision, Observations: observations, Responses: responses, ObservedAtUnixMillis: time.Now().UnixMilli()})
|
||||
}
|
||||
|
||||
func (g cognitiveGateway) executeTaskEvaluationCases(ctx context.Context, request capability.TaskEvaluationExecutionRequest, selected capability.ResolvedCognitiveBinding, provider advisoryProvider) ([]capability.TaskCaseObservation, []capability.TaskCaseResponse) {
|
||||
observations := make([]capability.TaskCaseObservation, len(request.Cases))
|
||||
responses := make([]capability.TaskCaseResponse, len(request.Cases))
|
||||
var wg sync.WaitGroup
|
||||
for i, item := range request.Cases {
|
||||
wg.Add(1)
|
||||
go func(index int, item capability.TaskEvaluationCase) {
|
||||
defer wg.Done()
|
||||
started := time.Now()
|
||||
invocation := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(fmt.Sprintf("task-evaluation:%s:%s", request.Profile.ID, item.CaseID)), ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: item.Prompt, MaxOutputBytes: int(request.MaxOutputBytes), ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, ProviderInstanceID: selected.Provider.ID, ModelDescriptorID: selected.Model.ID, ProviderModelID: selected.Model.ProviderModelID}
|
||||
response, invokeErr := g.invokeAdmitted(ctx, string(invocation.RequestID), provider, invocation, int(request.MaxOutputBytes))
|
||||
latency := time.Since(started).Microseconds()
|
||||
observations[index] = capability.TaskCaseObservation{CaseID: item.CaseID, Result: capability.EvaluationUnknown, CriticalFailure: invokeErr != nil || response.FinishReason != cognitive.FinishCompleted || strings.TrimSpace(response.AdvisoryText) == "", EvidenceArtifactRef: item.EvidenceArtifactRef, LatencyMicros: latency}
|
||||
responses[index] = capability.TaskCaseResponse{CaseID: item.CaseID, ResponseText: response.AdvisoryText, LatencyMicros: latency, EvidenceArtifactRef: item.EvidenceArtifactRef}
|
||||
}(i, item)
|
||||
}
|
||||
wg.Wait()
|
||||
return observations, responses
|
||||
}
|
||||
|
||||
func taskEvaluationUnavailable(cases []capability.TaskEvaluationCase) ([]capability.TaskCaseObservation, []capability.TaskCaseResponse) {
|
||||
observations := make([]capability.TaskCaseObservation, len(cases))
|
||||
responses := make([]capability.TaskCaseResponse, len(cases))
|
||||
for i, item := range cases {
|
||||
observations[i] = capability.TaskCaseObservation{CaseID: item.CaseID, Result: capability.EvaluationUnknown, CriticalFailure: true, EvidenceArtifactRef: item.EvidenceArtifactRef}
|
||||
responses[i] = capability.TaskCaseResponse{CaseID: item.CaseID, EvidenceArtifactRef: item.EvidenceArtifactRef}
|
||||
}
|
||||
return observations, responses
|
||||
}
|
||||
|
||||
func taskEvaluationResponsesUsable(responses []capability.TaskCaseResponse) bool {
|
||||
if len(responses) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, response := range responses {
|
||||
if strings.TrimSpace(response.ResponseText) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeTaskEvaluationJSON(w http.ResponseWriter, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
type candidateRegistryStub struct {
|
||||
registryStub
|
||||
selected capability.ResolvedCognitiveBinding
|
||||
}
|
||||
|
||||
func (s *candidateRegistryStub) ResolveCandidate(context.Context, capability.CapabilityBindingID, int64) (capability.ResolvedCognitiveBinding, error) {
|
||||
return s.selected, nil
|
||||
}
|
||||
|
||||
func TestTaskEvaluationHandlerInvokesEveryBoundedCase(t *testing.T) {
|
||||
selected := gatewaySelection()
|
||||
registry := &candidateRegistryStub{selected: selected}
|
||||
provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, FinishReason: cognitive.FinishCompleted, AdvisoryText: "observed response"}}
|
||||
gateway := cognitiveGateway{
|
||||
resolver: registry,
|
||||
providers: map[capability.ProviderKind][]providerRegistration{
|
||||
selected.Provider.Kind: {{ProviderInstanceID: selected.Provider.ID, Kind: selected.Provider.Kind, EndpointRef: selected.Provider.EndpointRef, CredentialRef: selected.Provider.CredentialRef, Provider: provider}},
|
||||
},
|
||||
now: func() time.Time { return time.UnixMilli(1000) },
|
||||
}
|
||||
profile := capability.TaskEvaluationProfile{ID: "profile-general", Revision: 1, CapabilityID: selected.Binding.CapabilityID, Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "general-v1", RequiredCaseIDs: []string{"case-a", "case-b"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
|
||||
request := capability.TaskEvaluationExecutionRequest{Profile: profile, CapabilityID: selected.Binding.CapabilityID, BindingID: selected.Binding.ID, BindingRevision: selected.Binding.Revision, ActorID: identity.ActorID("actor-1"), PrincipalID: selected.Binding.OwnerPrincipalID, ScopeID: authority.ScopeID("scope-1"), MaxOutputBytes: 512, EgressAcknowledged: true, Cases: []capability.TaskEvaluationCase{{CaseID: "case-a", Prompt: "one", EvidenceArtifactRef: "artifact:a"}, {CaseID: "case-b", Prompt: "two", EvidenceArtifactRef: "artifact:b"}}}
|
||||
payload, _ := json.Marshal(request)
|
||||
recorder := httptest.NewRecorder()
|
||||
gateway.taskEvaluationHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/task-evaluations/execute", bytes.NewReader(payload)))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response capability.TaskEvaluationExecutionResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Observations) != 2 || response.Observations[0].Result != capability.EvaluationUnknown || response.Observations[1].Result != capability.EvaluationUnknown {
|
||||
t.Fatalf("unexpected observations: %+v", response.Observations)
|
||||
}
|
||||
for _, observation := range response.Observations {
|
||||
if observation.QualityScoreMillis != 0 || observation.CriticalFailure {
|
||||
t.Fatalf("reachability-only evidence must not carry quality or failure claims: %+v", observation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type taskEvaluationFailoverRegistry struct {
|
||||
primary, fallback capability.ResolvedCognitiveBinding
|
||||
resolveCalls int
|
||||
}
|
||||
|
||||
func (s *taskEvaluationFailoverRegistry) ResolveCandidate(context.Context, capability.CapabilityBindingID, int64) (capability.ResolvedCognitiveBinding, error) {
|
||||
return s.primary, nil
|
||||
}
|
||||
|
||||
func (s *taskEvaluationFailoverRegistry) Resolve(_ context.Context, requirement capability.CognitiveRequirement) (capability.ResolvedCognitiveBinding, error) {
|
||||
s.resolveCalls++
|
||||
if len(requirement.ExcludedBindingIDs) == 0 {
|
||||
return s.primary, nil
|
||||
}
|
||||
return s.fallback, nil
|
||||
}
|
||||
|
||||
func (s *taskEvaluationFailoverRegistry) Observe(context.Context, capability.CognitiveUsageObservation) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *taskEvaluationFailoverRegistry) RegisterDiscovery(context.Context, capability.CognitiveDiscoverySnapshot) (capability.CognitiveDiscoveryRegistration, error) {
|
||||
return capability.CognitiveDiscoveryRegistration{}, nil
|
||||
}
|
||||
|
||||
func TestTaskEvaluationExcludesUnavailableCandidateAndUsesFallback(t *testing.T) {
|
||||
primary := gatewaySelection()
|
||||
fallback := primary
|
||||
fallback.Binding.ID = "binding-fallback"
|
||||
fallback.Binding.Revision = 7
|
||||
fallback.Binding.ProviderInstanceID = "provider-fallback"
|
||||
fallback.Provider.ID = "provider-fallback"
|
||||
fallback.Provider.Kind = "provider-fallback-kind"
|
||||
fallback.Provider.EndpointRef = "fallback-endpoint"
|
||||
fallback.Provider.CredentialRef = "fallback-credential"
|
||||
fallback.Model.ProviderInstanceID = "provider-fallback"
|
||||
registry := &taskEvaluationFailoverRegistry{primary: primary, fallback: fallback}
|
||||
primaryProvider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, FinishReason: cognitive.FinishProviderUnavailable}}
|
||||
fallbackProvider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, FinishReason: cognitive.FinishCompleted, AdvisoryText: "fallback response"}}
|
||||
gateway := cognitiveGateway{resolver: registry, providers: map[capability.ProviderKind][]providerRegistration{
|
||||
primary.Provider.Kind: {{ProviderInstanceID: primary.Provider.ID, Kind: primary.Provider.Kind, EndpointRef: primary.Provider.EndpointRef, CredentialRef: primary.Provider.CredentialRef, Provider: primaryProvider}},
|
||||
fallback.Provider.Kind: {{ProviderInstanceID: fallback.Provider.ID, Kind: fallback.Provider.Kind, EndpointRef: fallback.Provider.EndpointRef, CredentialRef: fallback.Provider.CredentialRef, Provider: fallbackProvider}},
|
||||
}, now: func() time.Time { return time.UnixMilli(1000) }}
|
||||
profile := capability.TaskEvaluationProfile{ID: "profile-failover", Revision: 1, CapabilityID: primary.Binding.CapabilityID, Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "failover-v1", RequiredCaseIDs: []string{"case-a"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
|
||||
request := capability.TaskEvaluationExecutionRequest{Profile: profile, CapabilityID: profile.CapabilityID, BindingID: primary.Binding.ID, BindingRevision: primary.Binding.Revision, ActorID: identity.ActorID("actor-1"), PrincipalID: primary.Binding.OwnerPrincipalID, ScopeID: authority.ScopeID("scope-1"), MaxOutputBytes: 512, EgressAcknowledged: true, Cases: []capability.TaskEvaluationCase{{CaseID: "case-a", Prompt: "one", EvidenceArtifactRef: "artifact:a"}}}
|
||||
payload, _ := json.Marshal(request)
|
||||
recorder := httptest.NewRecorder()
|
||||
gateway.taskEvaluationHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/task-evaluations/execute", bytes.NewReader(payload)))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response capability.TaskEvaluationExecutionResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.BindingID != fallback.Binding.ID || !primaryProvider.called || !fallbackProvider.called || registry.resolveCalls != 1 {
|
||||
t.Fatalf("failover binding=%s primary_called=%t fallback_called=%t resolve_calls=%d", response.BindingID, primaryProvider.called, fallbackProvider.called, registry.resolveCalls)
|
||||
}
|
||||
if response.Observations[0].CriticalFailure || response.Responses[0].ResponseText != "fallback response" {
|
||||
t.Fatalf("fallback response was not usable: %+v %+v", response.Observations, response.Responses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskEvaluationUnavailableResponseIsMarkedCriticalUnknown(t *testing.T) {
|
||||
selected := gatewaySelection()
|
||||
registry := &taskEvaluationFailoverRegistry{primary: selected, fallback: selected}
|
||||
provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, FinishReason: cognitive.FinishTimeout}}
|
||||
gateway := cognitiveGateway{resolver: registry, providers: map[capability.ProviderKind][]providerRegistration{
|
||||
selected.Provider.Kind: {{ProviderInstanceID: selected.Provider.ID, Kind: selected.Provider.Kind, EndpointRef: selected.Provider.EndpointRef, CredentialRef: selected.Provider.CredentialRef, Provider: provider}},
|
||||
}, now: func() time.Time { return time.UnixMilli(1000) }}
|
||||
profile := capability.TaskEvaluationProfile{ID: "profile-unknown", Revision: 1, CapabilityID: selected.Binding.CapabilityID, Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "unknown-v1", RequiredCaseIDs: []string{"case-a"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
|
||||
request := capability.TaskEvaluationExecutionRequest{Profile: profile, CapabilityID: profile.CapabilityID, BindingID: selected.Binding.ID, BindingRevision: selected.Binding.Revision, ActorID: identity.ActorID("actor-1"), PrincipalID: selected.Binding.OwnerPrincipalID, ScopeID: authority.ScopeID("scope-1"), MaxOutputBytes: 512, EgressAcknowledged: true, Cases: []capability.TaskEvaluationCase{{CaseID: "case-a", Prompt: "one", EvidenceArtifactRef: "artifact:a"}}}
|
||||
payload, _ := json.Marshal(request)
|
||||
recorder := httptest.NewRecorder()
|
||||
gateway.taskEvaluationHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/task-evaluations/execute", bytes.NewReader(payload)))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response capability.TaskEvaluationExecutionResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Observations) != 1 || response.Observations[0].Result != capability.EvaluationUnknown || !response.Observations[0].CriticalFailure {
|
||||
t.Fatalf("unavailable response was overclaimed: %+v", response.Observations)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
)
|
||||
|
||||
type chatRouteKind string
|
||||
|
||||
const (
|
||||
chatRouteResearch chatRouteKind = "RESEARCH"
|
||||
chatRouteConversation chatRouteKind = "CONVERSATION"
|
||||
chatRouteAction chatRouteKind = "ACTION"
|
||||
chatRouteAgent chatRouteKind = "AGENT"
|
||||
)
|
||||
|
||||
type chatRoute struct {
|
||||
Kind chatRouteKind
|
||||
Capability capability.CapabilityKind
|
||||
Profile string
|
||||
Order int
|
||||
}
|
||||
|
||||
// buildChatRoutePlan creates one bounded route per meaning-bearing capability.
|
||||
// A failed research route must not suppress the local conversational route or
|
||||
// an unrelated action route.
|
||||
func buildChatRoutePlan(result interactiondomain.SemanticResult) []chatRoute {
|
||||
seen := make(map[capability.CapabilityKind]struct{})
|
||||
routes := make([]chatRoute, 0, len(result.CapabilityHints)+1)
|
||||
add := func(kind chatRouteKind, capabilityKind capability.CapabilityKind, profile string, order int) {
|
||||
if capabilityKind != "" {
|
||||
if _, exists := seen[capabilityKind]; exists {
|
||||
return
|
||||
}
|
||||
seen[capabilityKind] = struct{}{}
|
||||
}
|
||||
routes = append(routes, chatRoute{Kind: kind, Capability: capabilityKind, Profile: profile, Order: order})
|
||||
}
|
||||
for _, hint := range result.CapabilityHints {
|
||||
switch capability.CapabilityKind(hint) {
|
||||
case capability.CapabilityKindWebResearch:
|
||||
add(chatRouteResearch, capability.CapabilityKindWebResearch, "truth-research-v1", 10)
|
||||
case capability.CapabilityKindCognitiveReasoning, capability.CapabilityKindVerifyFact:
|
||||
add(chatRouteConversation, capability.CapabilityKind(hint), "local-reasoning-v1", 20)
|
||||
case capability.CapabilityKindScheduleReminder:
|
||||
add(chatRouteAction, capability.CapabilityKindScheduleReminder, "continuity-reminder-v1", 30)
|
||||
case capability.CapabilityKindModifyCode, capability.CapabilityKindRunTests, capability.CapabilityKindInspectProject:
|
||||
add(chatRouteAgent, capability.CapabilityKind(hint), "software-agent-v1", 40)
|
||||
case capability.CapabilityKindSendEmail, capability.CapabilityKindReadEmail:
|
||||
add(chatRouteAgent, capability.CapabilityKind(hint), "communication-agent-v1", 50)
|
||||
case capability.CapabilityKindReadDatabase, capability.CapabilityKindQueryLogs:
|
||||
add(chatRouteAgent, capability.CapabilityKind(hint), "diagnostics-agent-v1", 60)
|
||||
}
|
||||
}
|
||||
if result.NeedsCognition || len(routes) == 0 {
|
||||
add(chatRouteConversation, capability.CapabilityKindCognitiveReasoning, "local-reasoning-v1", 20)
|
||||
}
|
||||
sort.SliceStable(routes, func(i, j int) bool { return routes[i].Order < routes[j].Order })
|
||||
return routes
|
||||
}
|
||||
|
||||
func hasChatRoute(routes []chatRoute, kind chatRouteKind) bool {
|
||||
for _, route := range routes {
|
||||
if route.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
)
|
||||
|
||||
func TestBuildChatRoutePlanPreservesMultipleMeaningfulRoutes(t *testing.T) {
|
||||
planned := buildChatRoutePlan(interactiondomain.SemanticResult{
|
||||
NeedsCognition: true,
|
||||
CapabilityHints: []string{"WEB_RESEARCH", "MODIFY_CODE", "RUN_TESTS", "SCHEDULE_REMINDER"},
|
||||
})
|
||||
if len(planned) != 5 {
|
||||
t.Fatalf("expected five deduplicated routes, got %+v", planned)
|
||||
}
|
||||
want := []struct {
|
||||
kind chatRouteKind
|
||||
profile string
|
||||
cap capability.CapabilityKind
|
||||
}{
|
||||
{chatRouteResearch, "truth-research-v1", capability.CapabilityKindWebResearch},
|
||||
{chatRouteConversation, "local-reasoning-v1", capability.CapabilityKindCognitiveReasoning},
|
||||
{chatRouteAction, "continuity-reminder-v1", capability.CapabilityKindScheduleReminder},
|
||||
{chatRouteAgent, "software-agent-v1", capability.CapabilityKindModifyCode},
|
||||
{chatRouteAgent, "software-agent-v1", capability.CapabilityKindRunTests},
|
||||
}
|
||||
for index, expected := range want {
|
||||
if planned[index].Kind != expected.kind || planned[index].Profile != expected.profile || planned[index].Capability != expected.cap {
|
||||
t.Fatalf("route %d = %+v, want %+v", index, planned[index], expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChatRoutePlanAlwaysKeepsLocalConversationRoute(t *testing.T) {
|
||||
planned := buildChatRoutePlan(interactiondomain.SemanticResult{NeedsCognition: true})
|
||||
if len(planned) != 1 || planned[0].Kind != chatRouteConversation || planned[0].Profile != "local-reasoning-v1" {
|
||||
t.Fatalf("unexpected local route: %+v", planned)
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
@@ -22,6 +25,56 @@ type cognitiveInterpretationClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// ReviewResponse asks the cognitive gateway to turn a locally assembled draft
|
||||
// into a natural answer. The draft remains the safe fallback when cognition is
|
||||
// unavailable; the model does not mutate canonical state or execute actions.
|
||||
func (c cognitiveInterpretationClient) ReviewResponse(ctx context.Context, session identity.CreatorSession, userMessage, draft string, meaning cognitive.TaskMeaning) (string, error) {
|
||||
requestID, err := ids.New()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
prompt := "Ты редактор ответа Домового. Проверь черновик по смыслу и подготовь естественный ответ человеку. Учитывай весь запрос, включая недостающие параметры для конкретного результата. Если данных не хватает, задай один короткий полезный уточняющий вопрос, например откуда и куда ехать. Не выдавай поисковые сниппеты как выполненное действие и не показывай служебные URL поисковой выдачи. Сохрани проверяемые факты, названия источников, прямые ссылки и ограничения. Не выдумывай факты и не утверждай невыполненные действия. Верни только готовый ответ без разбора проверки, служебных слов и упоминания моделей. Если черновик уже адекватен, верни его без лишних добавлений.\n\nСообщение пользователя:\n" + userMessage + "\n\nЧерновик ответа:\n" + draft
|
||||
target := *c.baseURL
|
||||
target.Path = "/v1/advisories"
|
||||
payload, err := json.Marshal(cognitive.GatewayAdvisoryRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID),
|
||||
ActorID: session.ActorID, PrincipalID: session.PrincipalID,
|
||||
ScopeID: authority.ScopeID("domovoy-global-scope"), Message: prompt,
|
||||
Locale: "ru", MaxOutputBytes: 2048, DataPolicy: capability.DataPolicyLocalOnly,
|
||||
Meaning: cognitive.TaskMeaning{
|
||||
SpeechAct: "RESPONSE_REVIEW",
|
||||
CapabilityHints: meaning.CapabilityHints,
|
||||
RequestedOutcome: meaning.RequestedOutcome,
|
||||
InformationRequestKind: meaning.InformationRequestKind,
|
||||
ConfidenceMillis: meaning.ConfidenceMillis,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := c.client.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("cognitive response review status %d", response.StatusCode)
|
||||
}
|
||||
var result cognitive.GatewayAdvisoryResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.FinishReason != cognitive.FinishCompleted || strings.TrimSpace(result.AdvisoryText) == "" {
|
||||
return "", fmt.Errorf("cognitive response review incomplete: %s", result.FinishReason)
|
||||
}
|
||||
return strings.TrimSpace(result.AdvisoryText), nil
|
||||
}
|
||||
|
||||
func newCognitiveInterpretationClient(rawURL string, client *http.Client) (cognitiveInterpretationClient, error) {
|
||||
base, err := url.Parse(rawURL)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
@@ -61,19 +114,63 @@ func (c cognitiveInterpretationClient) Interpret(ctx context.Context, request co
|
||||
}
|
||||
|
||||
func shouldRequestBroaderInterpretation(result interactiondomain.SemanticResult) bool {
|
||||
// The built-in advisory channel is the default conversational path. A
|
||||
// simple question must not first wait for the slower, strict JSON
|
||||
// interpretation route: that route exists for ambiguity and work routing,
|
||||
// not as a prerequisite for talking to Domovoy.
|
||||
if isSimpleLocalQuestion(result) {
|
||||
return shouldRequestSemanticInterpretation(result)
|
||||
}
|
||||
|
||||
// shouldRequestSemanticInterpretation keeps the deterministic layer as a
|
||||
// fast reflex, not as the meaning authority. Questions, requests, directives,
|
||||
// mixed signals and capability-bearing messages go through cognitive
|
||||
// interpretation; only stable low-risk conversational/assertive reflexes may
|
||||
// answer without it.
|
||||
func shouldRequestSemanticInterpretation(result interactiondomain.SemanticResult) bool {
|
||||
if result.Interpretation.Clarification == interaction.ClarificationRequired {
|
||||
return false
|
||||
}
|
||||
switch result.Interpretation.Applicability {
|
||||
case interaction.SemanticNeedsBroaderInterpret, interaction.SemanticLowConfidence, interaction.SemanticMixedIntent:
|
||||
// Structured, read-only information intents already have a semantic owner
|
||||
// in core-runtime. Sending them through the slower advisory interpretation
|
||||
// path only adds latency and can make a healthy runtime look unavailable.
|
||||
if result.InformationRequestKind != "" {
|
||||
return false
|
||||
}
|
||||
if result.NeedsCognition || len(result.CapabilityHints) > 0 {
|
||||
return true
|
||||
}
|
||||
if result.Interpretation.Applicability != interaction.SemanticApplicable {
|
||||
return true
|
||||
}
|
||||
switch result.Interpretation.SpeechAct {
|
||||
case interaction.SpeechActConversational, interaction.SpeechActConfirmation, interaction.SpeechActRejection:
|
||||
return false
|
||||
case interaction.SpeechActPreference:
|
||||
// Explicit user preferences are durable interaction context. A sampled
|
||||
// advisory audit must not reinterpret or erase them.
|
||||
return false
|
||||
case interaction.SpeechActAssertion:
|
||||
return result.Interpretation.ConfidenceMillis < 850
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
deterministicReflexAuditInterval = 10 * time.Minute
|
||||
deterministicReflexAuditSample = uint32(16)
|
||||
)
|
||||
|
||||
// shouldAuditDeterministicReflex samples stable reflexes in a time bucket.
|
||||
// This is observability, not a correctness gate: a failed audit leaves the
|
||||
// reflex usable, while a successful disagreement can replace it with the
|
||||
// cognitive candidate and becomes durable evidence through the normal
|
||||
// candidate store.
|
||||
func shouldAuditDeterministicReflex(result interactiondomain.SemanticResult, utteranceID string, now time.Time) bool {
|
||||
if shouldRequestSemanticInterpretation(result) || utteranceID == "" {
|
||||
return false
|
||||
}
|
||||
bucket := now.Unix() / int64(deterministicReflexAuditInterval/time.Second)
|
||||
hash := fnv.New32a()
|
||||
_, _ = hash.Write([]byte(utteranceID))
|
||||
_, _ = hash.Write([]byte(fmt.Sprintf(":%d", bucket)))
|
||||
return hash.Sum32()%deterministicReflexAuditSample == 0
|
||||
}
|
||||
|
||||
func isSimpleLocalQuestion(result interactiondomain.SemanticResult) bool {
|
||||
|
||||
@@ -1,13 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
)
|
||||
|
||||
func TestSimpleQuestionUsesBuiltInAdvisoryWithoutStrictInterpretation(t *testing.T) {
|
||||
func TestReviewResponseUsesCognitiveGatewayAndReturnsOnlyDraftAnswer(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request cognitive.GatewayAdvisoryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatalf("decode review request: %v", err)
|
||||
}
|
||||
if request.Message == "" || request.Meaning.SpeechAct == "" {
|
||||
t.Fatalf("review request did not carry semantic context: %+v", request)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "Меня зовут Дед. Я Домовой.", FinishReason: cognitive.FinishCompleted})
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := newCognitiveInterpretationClient(server.URL, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reviewed, err := client.ReviewResponse(context.Background(), identity.CreatorSession{ActorID: "actor", PrincipalID: "principal"}, "кто ты?", "Я — Домовой.", cognitive.TaskMeaning{SpeechAct: "QUESTION"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reviewed != "Меня зовут Дед. Я Домовой." {
|
||||
t.Fatalf("unexpected reviewed response %q", reviewed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewPreservesResearchEvidence(t *testing.T) {
|
||||
draft := "Нашёл вариант. Источник: Aviasales (https://www.aviasales.ru/search)"
|
||||
if reviewPreservesResearchEvidence("Нашёл вариант на Авиасейлс.", draft) {
|
||||
t.Fatal("review without the direct source must be rejected")
|
||||
}
|
||||
if !reviewPreservesResearchEvidence("Нашёл вариант. Источник: Aviasales (https://www.aviasales.ru/search)", draft) {
|
||||
t.Fatal("review with the direct source must be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleQuestionUsesStrictInterpretationForSemanticRouting(t *testing.T) {
|
||||
result := interactiondomain.SemanticResult{
|
||||
Interpretation: interaction.UtteranceInterpretation{
|
||||
SpeechAct: interaction.SpeechActQuestion,
|
||||
@@ -16,8 +59,8 @@ func TestSimpleQuestionUsesBuiltInAdvisoryWithoutStrictInterpretation(t *testing
|
||||
},
|
||||
CapabilityHints: []string{"COGNITIVE_REASONING", "VERIFY_FACT"},
|
||||
}
|
||||
if shouldRequestBroaderInterpretation(result) {
|
||||
t.Fatal("simple question must go directly to the built-in advisory path")
|
||||
if !shouldRequestBroaderInterpretation(result) {
|
||||
t.Fatal("simple question must reach semantic interpretation before any answer")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,3 +77,51 @@ func TestOperationalQuestionStillUsesStrictInterpretation(t *testing.T) {
|
||||
t.Fatal("operational question must retain strict interpretation routing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredInventoryUsesDirectReadOnlySemanticRoute(t *testing.T) {
|
||||
result := interactiondomain.SemanticResult{
|
||||
Interpretation: interaction.UtteranceInterpretation{
|
||||
SpeechAct: interaction.SpeechActDirective,
|
||||
Applicability: interaction.SemanticApplicable,
|
||||
ConfidenceMillis: 980,
|
||||
},
|
||||
InformationRequestKind: interaction.InformationRequestCognitiveModelInventory,
|
||||
}
|
||||
if shouldRequestSemanticInterpretation(result) {
|
||||
t.Fatal("structured model inventory must use the direct read-only semantic route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableReflexIsPeriodicallySampledForDrift(t *testing.T) {
|
||||
result := interactiondomain.SemanticResult{Interpretation: interaction.UtteranceInterpretation{
|
||||
SpeechAct: interaction.SpeechActConversational, Applicability: interaction.SemanticApplicable,
|
||||
}}
|
||||
found := false
|
||||
for minute := 0; minute < 160; minute++ {
|
||||
if shouldAuditDeterministicReflex(result, "utterance-drift-probe", time.Unix(int64(minute)*60, 0)) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("stable reflex was never sampled during the audit window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeaningfulSignalsAreNotReflexOnly(t *testing.T) {
|
||||
for _, act := range []interaction.SpeechAct{interaction.SpeechActQuestion, interaction.SpeechActRequestToInvestigate, interaction.SpeechActDirective} {
|
||||
result := interactiondomain.SemanticResult{Interpretation: interaction.UtteranceInterpretation{SpeechAct: act, Applicability: interaction.SemanticApplicable}}
|
||||
if !shouldRequestSemanticInterpretation(result) {
|
||||
t.Fatalf("%s must prefer cognitive interpretation", act)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitPreferenceIsNotReinterpretedByCognitiveAudit(t *testing.T) {
|
||||
result := interactiondomain.SemanticResult{Interpretation: interaction.UtteranceInterpretation{
|
||||
SpeechAct: interaction.SpeechActPreference, Applicability: interaction.SemanticApplicable,
|
||||
}}
|
||||
if shouldRequestSemanticInterpretation(result) || shouldAuditDeterministicReflex(result, "preference", time.Unix(0, 0)) {
|
||||
t.Fatal("explicit preference must remain on the deterministic durable-context path")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/developmentexecutor"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
workdomain "git.cin.su/m/Assistant/modules/work/domain"
|
||||
"git.cin.su/m/Assistant/modules/work/ports"
|
||||
)
|
||||
|
||||
var errDevelopmentGateCreate = errors.New("development gate must start at evidence collected with revision one")
|
||||
var errDevelopmentGateNotIsolated = errors.New("development gate is not isolated")
|
||||
var errDevelopmentVerifierNotIndependent = errors.New("development verifier is not independent")
|
||||
var errDevelopmentWorktreeMismatch = errors.New("verification worktree does not match gate")
|
||||
var errDevelopmentChecksFailed = errors.New("development verification checks failed")
|
||||
var errDevelopmentEvidenceRequired = errors.New("development transition evidence is required")
|
||||
var errDevelopmentEvidenceStage = errors.New("development transition evidence does not match stage")
|
||||
|
||||
type developmentGateVerificationRequest struct {
|
||||
GateID string
|
||||
ExpectedRevision int64
|
||||
VerifierRunID work.AgentRunID
|
||||
Plan developmentexecutor.Plan
|
||||
}
|
||||
|
||||
type developmentGateEvidenceRequest struct {
|
||||
GateID string
|
||||
ExpectedRevision int64
|
||||
NextStage work.DevelopmentStage
|
||||
EvidenceRef string
|
||||
}
|
||||
|
||||
func recordDevelopmentGateEvidence(ctx context.Context, store ports.DevelopmentGateStore, request developmentGateEvidenceRequest, now time.Time) (work.DevelopmentGate, error) {
|
||||
gate, err := store.LoadDevelopmentGate(ctx, request.GateID)
|
||||
if err != nil {
|
||||
return work.DevelopmentGate{}, err
|
||||
}
|
||||
if request.ExpectedRevision < 1 || gate.Revision != request.ExpectedRevision {
|
||||
return gate, errDevelopmentGateNotIsolated
|
||||
}
|
||||
if strings.TrimSpace(request.EvidenceRef) == "" {
|
||||
return gate, errDevelopmentEvidenceRequired
|
||||
}
|
||||
switch request.NextStage {
|
||||
case work.DevelopmentCanary:
|
||||
gate.CanaryEvidence = strings.TrimSpace(request.EvidenceRef)
|
||||
case work.DevelopmentPromoted:
|
||||
gate.PromotionRef = strings.TrimSpace(request.EvidenceRef)
|
||||
case work.DevelopmentRolledBack:
|
||||
gate.RollbackRef = strings.TrimSpace(request.EvidenceRef)
|
||||
default:
|
||||
return gate, errDevelopmentEvidenceStage
|
||||
}
|
||||
advanced, err := advanceDevelopmentGate(gate, request.NextStage, now)
|
||||
if err != nil {
|
||||
return gate, err
|
||||
}
|
||||
if err := store.SaveDevelopmentGate(ctx, advanced, request.ExpectedRevision); err != nil {
|
||||
return gate, err
|
||||
}
|
||||
return advanced, nil
|
||||
}
|
||||
|
||||
func verifyDevelopmentGate(ctx context.Context, store ports.DevelopmentGateStore, request developmentGateVerificationRequest, now time.Time, runner developmentexecutor.CommandRunner) (work.DevelopmentGate, developmentexecutor.Result, error) {
|
||||
gate, err := store.LoadDevelopmentGate(ctx, request.GateID)
|
||||
if err != nil {
|
||||
return work.DevelopmentGate{}, developmentexecutor.Result{}, err
|
||||
}
|
||||
if request.ExpectedRevision < 1 || gate.Revision != request.ExpectedRevision || gate.Stage != work.DevelopmentIsolated {
|
||||
return gate, developmentexecutor.Result{}, errDevelopmentGateNotIsolated
|
||||
}
|
||||
if request.VerifierRunID == "" || sameRun(request.VerifierRunID, gate.ProducerRunRefs) {
|
||||
return gate, developmentexecutor.Result{}, errDevelopmentVerifierNotIndependent
|
||||
}
|
||||
if request.Plan.WorktreeID != gate.WorktreeRef {
|
||||
return gate, developmentexecutor.Result{}, errDevelopmentWorktreeMismatch
|
||||
}
|
||||
result, err := developmentexecutor.Execute(ctx, request.Plan, runner)
|
||||
if err != nil {
|
||||
return gate, result, err
|
||||
}
|
||||
if !result.Passed {
|
||||
return gate, result, errDevelopmentChecksFailed
|
||||
}
|
||||
gate.VerifierRunRefs = append(append([]work.AgentRunID(nil), gate.VerifierRunRefs...), request.VerifierRunID)
|
||||
gate.VerificationEvidence = result.EvidenceRef
|
||||
advanced, err := advanceDevelopmentGate(gate, work.DevelopmentVerified, now)
|
||||
if err != nil {
|
||||
return gate, result, err
|
||||
}
|
||||
if err := store.SaveDevelopmentGate(ctx, advanced, request.ExpectedRevision); err != nil {
|
||||
return gate, result, err
|
||||
}
|
||||
return advanced, result, nil
|
||||
}
|
||||
|
||||
func sameRun(value work.AgentRunID, runs []work.AgentRunID) bool {
|
||||
for _, run := range runs {
|
||||
if run == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func prepareDevelopmentGate(value work.DevelopmentGate, now time.Time) (work.DevelopmentGate, error) {
|
||||
if value.Stage != work.DevelopmentEvidenceCollected || value.Revision != 1 {
|
||||
return work.DevelopmentGate{}, errDevelopmentGateCreate
|
||||
}
|
||||
value.UpdatedAtUnixMillis = now.UnixMilli()
|
||||
if err := workdomain.ValidateDevelopmentGate(value); err != nil {
|
||||
return work.DevelopmentGate{}, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func advanceDevelopmentGate(value work.DevelopmentGate, next work.DevelopmentStage, now time.Time) (work.DevelopmentGate, error) {
|
||||
advanced, err := workdomain.AdvanceDevelopmentGate(value, next)
|
||||
if err != nil {
|
||||
return work.DevelopmentGate{}, err
|
||||
}
|
||||
advanced.Revision = value.Revision + 1
|
||||
advanced.UpdatedAtUnixMillis = now.UnixMilli()
|
||||
return advanced, nil
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/developmentexecutor"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
type developmentGateStoreStub struct {
|
||||
gate work.DevelopmentGate
|
||||
saved bool
|
||||
expected int64
|
||||
}
|
||||
|
||||
func (s *developmentGateStoreStub) LoadDevelopmentGate(context.Context, string) (work.DevelopmentGate, error) {
|
||||
return s.gate, nil
|
||||
}
|
||||
|
||||
func (s *developmentGateStoreStub) SaveDevelopmentGate(_ context.Context, gate work.DevelopmentGate, expected int64) error {
|
||||
s.gate = gate
|
||||
s.saved = true
|
||||
s.expected = expected
|
||||
return nil
|
||||
}
|
||||
|
||||
type developmentCommandRunnerStub struct {
|
||||
exitCodes []int
|
||||
commands int
|
||||
}
|
||||
|
||||
func (r *developmentCommandRunnerStub) Run(context.Context, string, string, []string) int {
|
||||
code := 0
|
||||
if r.commands < len(r.exitCodes) {
|
||||
code = r.exitCodes[r.commands]
|
||||
}
|
||||
r.commands++
|
||||
return code
|
||||
}
|
||||
|
||||
func validDevelopmentVerificationPlan(t *testing.T, worktreeID string) developmentexecutor.Plan {
|
||||
t.Helper()
|
||||
worktree, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return developmentexecutor.Plan{
|
||||
Worktree: worktree,
|
||||
WorktreeID: worktreeID,
|
||||
BaseRepoRevision: "base-revision",
|
||||
ProtectedMainAccess: "DENIED",
|
||||
ProductionAccess: "DENIED",
|
||||
SecretAccess: "DENIED",
|
||||
Checks: []developmentexecutor.Check{{Kind: developmentexecutor.CheckGitDiffCheck}},
|
||||
}
|
||||
}
|
||||
|
||||
func isolatedDevelopmentGate() work.DevelopmentGate {
|
||||
return work.DevelopmentGate{
|
||||
ID: "gate-1",
|
||||
Revision: 4,
|
||||
Stage: work.DevelopmentIsolated,
|
||||
EvidenceRefs: []string{"evidence-1"},
|
||||
ProposalRef: "proposal-1",
|
||||
TaskPacketRef: "task-1",
|
||||
WorktreeRef: "sandbox-1",
|
||||
ProducerRunRefs: []work.AgentRunID{"producer-1"},
|
||||
VerifierRunRefs: nil,
|
||||
VerificationEvidence: "",
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDevelopmentGatePersistsVerifiedWithIndependentEvidence(t *testing.T) {
|
||||
store := &developmentGateStoreStub{gate: isolatedDevelopmentGate()}
|
||||
runner := &developmentCommandRunnerStub{exitCodes: []int{0}}
|
||||
gate, result, err := verifyDevelopmentGate(context.Background(), store, developmentGateVerificationRequest{
|
||||
GateID: "gate-1",
|
||||
ExpectedRevision: 4,
|
||||
VerifierRunID: "verifier-1",
|
||||
Plan: validDevelopmentVerificationPlan(t, "sandbox-1"),
|
||||
}, time.UnixMilli(20), runner)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Passed || gate.Stage != work.DevelopmentVerified || gate.Revision != 5 {
|
||||
t.Fatalf("verification did not advance gate: gate=%#v result=%#v", gate, result)
|
||||
}
|
||||
if !store.saved || store.expected != 4 || gate.VerificationEvidence == "" {
|
||||
t.Fatalf("verification was not durably persisted: store=%#v gate=%#v", store, gate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDevelopmentGateLeavesIsolatedOnFailedChecks(t *testing.T) {
|
||||
store := &developmentGateStoreStub{gate: isolatedDevelopmentGate()}
|
||||
gate, result, err := verifyDevelopmentGate(context.Background(), store, developmentGateVerificationRequest{
|
||||
GateID: "gate-1",
|
||||
ExpectedRevision: 4,
|
||||
VerifierRunID: "verifier-1",
|
||||
Plan: validDevelopmentVerificationPlan(t, "sandbox-1"),
|
||||
}, time.UnixMilli(20), &developmentCommandRunnerStub{exitCodes: []int{1}})
|
||||
if err != errDevelopmentChecksFailed || result.Passed || gate.Stage != work.DevelopmentIsolated || store.saved {
|
||||
t.Fatalf("failed checks changed gate unexpectedly: gate=%#v result=%#v err=%v store=%#v", gate, result, err, store)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDevelopmentGateRejectsProducerAsVerifier(t *testing.T) {
|
||||
store := &developmentGateStoreStub{gate: isolatedDevelopmentGate()}
|
||||
_, _, err := verifyDevelopmentGate(context.Background(), store, developmentGateVerificationRequest{
|
||||
GateID: "gate-1",
|
||||
ExpectedRevision: 4,
|
||||
VerifierRunID: "producer-1",
|
||||
Plan: validDevelopmentVerificationPlan(t, "sandbox-1"),
|
||||
}, time.UnixMilli(20), &developmentCommandRunnerStub{exitCodes: []int{0}})
|
||||
if err != errDevelopmentVerifierNotIndependent {
|
||||
t.Fatalf("producer was accepted as verifier: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordDevelopmentGateEvidenceAdvancesCanaryAndPromotion(t *testing.T) {
|
||||
store := &developmentGateStoreStub{gate: work.DevelopmentGate{
|
||||
ID: "gate-1", Revision: 6, Stage: work.DevelopmentVerified,
|
||||
EvidenceRefs: []string{"evidence-1"}, ProposalRef: "proposal-1", TaskPacketRef: "task-1", WorktreeRef: "sandbox-1",
|
||||
ProducerRunRefs: []work.AgentRunID{"producer-1"}, VerifierRunRefs: []work.AgentRunID{"verifier-1"}, VerificationEvidence: "verification-1",
|
||||
}}
|
||||
gate, err := recordDevelopmentGateEvidence(context.Background(), store, developmentGateEvidenceRequest{GateID: "gate-1", ExpectedRevision: 6, NextStage: work.DevelopmentCanary, EvidenceRef: "canary-1"}, time.UnixMilli(30))
|
||||
if err != nil || gate.Stage != work.DevelopmentCanary || gate.Revision != 7 || gate.CanaryEvidence != "canary-1" {
|
||||
t.Fatalf("canary transition failed: gate=%#v err=%v", gate, err)
|
||||
}
|
||||
gate, err = recordDevelopmentGateEvidence(context.Background(), store, developmentGateEvidenceRequest{GateID: "gate-1", ExpectedRevision: 7, NextStage: work.DevelopmentPromoted, EvidenceRef: "promotion-1"}, time.UnixMilli(31))
|
||||
if err != nil || gate.Stage != work.DevelopmentPromoted || gate.Revision != 8 || gate.PromotionRef != "promotion-1" {
|
||||
t.Fatalf("promotion transition failed: gate=%#v err=%v", gate, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordDevelopmentGateEvidenceSupportsCanaryRollbackAndRejectsMissingEvidence(t *testing.T) {
|
||||
store := &developmentGateStoreStub{gate: work.DevelopmentGate{ID: "gate-1", Revision: 2, Stage: work.DevelopmentCanary, EvidenceRefs: []string{"evidence-1"}, CanaryEvidence: "canary-1"}}
|
||||
gate, err := recordDevelopmentGateEvidence(context.Background(), store, developmentGateEvidenceRequest{GateID: "gate-1", ExpectedRevision: 2, NextStage: work.DevelopmentRolledBack, EvidenceRef: "rollback-1"}, time.UnixMilli(40))
|
||||
if err != nil || gate.Stage != work.DevelopmentRolledBack || gate.RollbackRef != "rollback-1" {
|
||||
t.Fatalf("rollback transition failed: gate=%#v err=%v", gate, err)
|
||||
}
|
||||
store = &developmentGateStoreStub{gate: work.DevelopmentGate{ID: "gate-2", Revision: 2, Stage: work.DevelopmentCanary, EvidenceRefs: []string{"evidence-1"}, CanaryEvidence: "canary-1"}}
|
||||
gate, err = recordDevelopmentGateEvidence(context.Background(), store, developmentGateEvidenceRequest{GateID: "gate-2", ExpectedRevision: 2, NextStage: work.DevelopmentRolledBack}, time.UnixMilli(40))
|
||||
if err != errDevelopmentEvidenceRequired || gate.Stage != work.DevelopmentCanary || store.saved {
|
||||
t.Fatalf("missing rollback evidence changed gate: gate=%#v err=%v store=%#v", gate, err, store)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevelopmentGateControllerRequiresSequentialVerifiedEvidence(t *testing.T) {
|
||||
now := time.UnixMilli(10)
|
||||
gate, err := prepareDevelopmentGate(work.DevelopmentGate{ID: "gate-1", Revision: 1, Stage: work.DevelopmentEvidenceCollected, EvidenceRefs: []string{"evidence-1"}}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate.ProposalRef = "proposal-1"
|
||||
gate, err = advanceDevelopmentGate(gate, work.DevelopmentProposalCreated, now)
|
||||
if err != nil || gate.Revision != 2 {
|
||||
t.Fatalf("proposal transition failed: %#v %v", gate, err)
|
||||
}
|
||||
gate.TaskPacketRef = "task-1"
|
||||
gate, err = advanceDevelopmentGate(gate, work.DevelopmentTaskAdmitted, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate.WorktreeRef = "isolated-worktree"
|
||||
gate, err = advanceDevelopmentGate(gate, work.DevelopmentIsolated, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate.ProducerRunRefs = []work.AgentRunID{"producer"}
|
||||
gate.VerifierRunRefs = []work.AgentRunID{"verifier"}
|
||||
gate.VerificationEvidence = "verification-1"
|
||||
gate, err = advanceDevelopmentGate(gate, work.DevelopmentVerified, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := advanceDevelopmentGate(gate, work.DevelopmentPromoted, now); err == nil {
|
||||
t.Fatal("promotion bypassed canary")
|
||||
}
|
||||
}
|
||||
+667
-47
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.cin.su/m/Assistant/adapters/developmentexecutor"
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
situationdomain "git.cin.su/m/Assistant/modules/situation/domain"
|
||||
workdomain "git.cin.su/m/Assistant/modules/work/domain"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -28,6 +30,8 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -112,7 +116,11 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cognitiveClient, err := newCognitiveInterpretationClient(envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"), &http.Client{Timeout: 20 * time.Second})
|
||||
cognitiveClient, err := newCognitiveInterpretationClient(envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"), &http.Client{Timeout: 90 * time.Second})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
researchClient, err := newResearchRuntimeClient(envOr("RESEARCH_RUNTIME_URL", "http://research-runtime:8087"), &http.Client{Timeout: 12 * time.Second})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -141,6 +149,34 @@ func main() {
|
||||
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), "/")+"/healthz"),
|
||||
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"), "/")+"/healthz"),
|
||||
}})
|
||||
admissionPolicy := capability.AdmissionPolicy{ID: envOr("COGNITIVE_ADMISSION_POLICY_ID", "creator-advisory-local-v1"), MaxConcurrent: envInt("COGNITIVE_ADMISSION_MAX_CONCURRENT", 1), MaxAcceptedPerHour: envInt("COGNITIVE_ADMISSION_MAX_ACCEPTED_PER_HOUR", 60), MaxInputBytes: envInt("COGNITIVE_ADMISSION_MAX_INPUT_BYTES", 80<<10), MaxOutputBytes: envInt("COGNITIVE_ADMISSION_MAX_OUTPUT_BYTES", 16<<10)}
|
||||
mux.HandleFunc("POST /v1/internal/cognitive/admission", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
LeaseID string `json:"lease_id"`
|
||||
Request capability.AdmissionRequest `json:"request"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&request); err != nil || request.LeaseID == "" {
|
||||
http.Error(w, "invalid admission request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
decision, lease, err := capabilityRepository.AdmitCognitive(r.Context(), request.LeaseID, admissionPolicy, request.Request, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, "admission unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Decision capability.AdmissionDecision `json:"decision"`
|
||||
Lease capability.AdmissionLease `json:"lease"`
|
||||
}{decision, lease})
|
||||
})
|
||||
mux.HandleFunc("POST /v1/internal/cognitive/admission/{lease}/release", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := capabilityRepository.ReleaseCognitiveAdmission(r.Context(), capability.AdmissionLease{ID: r.PathValue("lease")}, time.Now().UnixMilli()); err != nil {
|
||||
http.Error(w, "admission release unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/sessions", creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword, sessionKey, time.Now))
|
||||
mux.HandleFunc("GET /v1/creator/sessions/current", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateCreator(w, r)
|
||||
@@ -150,6 +186,185 @@ func main() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(session)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/research/web", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := authenticateCreator(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var request epistemics.WebResearchRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, "invalid web research request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := researchClient.EvaluateWeb(r.Context(), request)
|
||||
if err != nil {
|
||||
http.Error(w, "web research unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/development/proposals", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateCreator(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
Evidence []work.ExtensionEvidence `json:"evidence"`
|
||||
Proposal work.ExtensionProposalRequest `json:"proposal"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, "invalid development proposal", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
proposal, err := workdomain.BuildExtensionProposal(request.Evidence, request.Proposal)
|
||||
if err != nil {
|
||||
http.Error(w, "development proposal rejected", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
proposal.RequestedByPrincipalID = string(session.PrincipalID)
|
||||
if err := workRepository.SaveExtensionProposal(r.Context(), proposal); err != nil {
|
||||
http.Error(w, "development proposal conflict", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(proposal)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/development/scaffolds", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateCreator(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request work.DevelopmentScaffoldRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, "invalid development scaffold", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
scaffold, err := workdomain.BuildDevelopmentScaffold(request)
|
||||
if err != nil {
|
||||
http.Error(w, "development scaffold rejected", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
scaffold.RequestedByPrincipalID = string(session.PrincipalID)
|
||||
if err := workRepository.SaveDevelopmentScaffold(r.Context(), scaffold); err != nil {
|
||||
http.Error(w, "development scaffold conflict", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(scaffold)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/development/gates", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := authenticateCreator(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var gate work.DevelopmentGate
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&gate); err != nil {
|
||||
http.Error(w, "invalid development gate", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
gate, err := prepareDevelopmentGate(gate, time.Now())
|
||||
if err != nil {
|
||||
http.Error(w, "development gate rejected", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if err := workRepository.SaveDevelopmentGate(r.Context(), gate, 0); err != nil {
|
||||
http.Error(w, "development gate conflict", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(gate)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/development/gates/{id}/advance", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := authenticateCreator(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
ExpectedRevision int64 `json:"expected_revision"`
|
||||
NextStage work.DevelopmentStage `json:"next_stage"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || request.ExpectedRevision < 1 || request.NextStage == "" {
|
||||
http.Error(w, "invalid development gate transition", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
gate, err := workRepository.LoadDevelopmentGate(r.Context(), r.PathValue("id"))
|
||||
if err != nil || gate.Revision != request.ExpectedRevision {
|
||||
http.Error(w, "development gate revision conflict", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
gate, err = advanceDevelopmentGate(gate, request.NextStage, time.Now())
|
||||
if err != nil {
|
||||
http.Error(w, "development gate transition rejected", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if err := workRepository.SaveDevelopmentGate(r.Context(), gate, request.ExpectedRevision); err != nil {
|
||||
http.Error(w, "development gate conflict", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(gate)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/development/gates/{id}/verify", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := authenticateCreator(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
ExpectedRevision int64 `json:"expected_revision"`
|
||||
VerifierRunID work.AgentRunID `json:"verifier_run_id"`
|
||||
Plan developmentexecutor.Plan `json:"plan"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || request.ExpectedRevision < 1 || request.VerifierRunID == "" {
|
||||
http.Error(w, "invalid development verification request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
gate, result, err := verifyDevelopmentGate(r.Context(), workRepository, developmentGateVerificationRequest{GateID: r.PathValue("id"), ExpectedRevision: request.ExpectedRevision, VerifierRunID: request.VerifierRunID, Plan: request.Plan}, time.Now(), developmentexecutor.OSCommandRunner{})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"gate": gate, "verification": result})
|
||||
})
|
||||
registerDevelopmentGateEvidenceRoute := func(path string, next work.DevelopmentStage, fieldName string) {
|
||||
mux.HandleFunc("POST /v1/creator/development/gates/{id}/"+path, func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := authenticateCreator(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
ExpectedRevision int64 `json:"expected_revision"`
|
||||
EvidenceRef string `json:"evidence_ref"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || request.ExpectedRevision < 1 || request.EvidenceRef == "" {
|
||||
http.Error(w, "invalid development transition evidence", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
gate, err := recordDevelopmentGateEvidence(r.Context(), workRepository, developmentGateEvidenceRequest{GateID: r.PathValue("id"), ExpectedRevision: request.ExpectedRevision, NextStage: next, EvidenceRef: request.EvidenceRef}, time.Now())
|
||||
if err != nil {
|
||||
http.Error(w, "development "+fieldName+" transition rejected", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(gate)
|
||||
})
|
||||
}
|
||||
registerDevelopmentGateEvidenceRoute("canary", work.DevelopmentCanary, "canary")
|
||||
registerDevelopmentGateEvidenceRoute("promote", work.DevelopmentPromoted, "promotion")
|
||||
registerDevelopmentGateEvidenceRoute("rollback", work.DevelopmentRolledBack, "rollback")
|
||||
mux.HandleFunc("GET /v1/creator/focus", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateCreator(w, r)
|
||||
if !ok {
|
||||
@@ -294,14 +509,106 @@ func main() {
|
||||
http.Error(w, "project focus unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var effectiveTaskMessage = interactiondomain.StripAssistantVocative(utterance.Content)
|
||||
continuationMessage := ""
|
||||
travelContinuation := false
|
||||
var previousTask *interaction.HumanUtterance
|
||||
var conversationHistory []interaction.HumanUtterance
|
||||
if request.ConversationID != "" {
|
||||
var historyErr error
|
||||
conversationHistory, historyErr = interactionRepository.Utterances(r.Context(), conversation.ID)
|
||||
if historyErr != nil {
|
||||
log.Printf("interaction conversation history unavailable: %v", historyErr)
|
||||
}
|
||||
}
|
||||
// Keep durable user context available even when the web client carries a
|
||||
// conversation ID; a new or rotated conversation must not erase learned
|
||||
// preferences.
|
||||
recentUserHistory, historyErr := interactionRepository.RecentUtterancesForPrincipal(r.Context(), session.PrincipalID, 64)
|
||||
if historyErr != nil {
|
||||
log.Printf("interaction principal history unavailable: %v", historyErr)
|
||||
} else {
|
||||
log.Printf("interaction principal history loaded: %d utterances", len(recentUserHistory))
|
||||
}
|
||||
conversationHistory = append(conversationHistory, recentUserHistory...)
|
||||
if continuation := interactiondomain.ContinueLocalSearch(effectiveTaskMessage, conversationHistory); continuation != "" {
|
||||
continuationMessage = continuation
|
||||
effectiveTaskMessage = continuation
|
||||
} else if continuation := interactiondomain.ContinueTravelOrigin(effectiveTaskMessage, conversationHistory); continuation != "" {
|
||||
continuationMessage = continuation
|
||||
travelContinuation = true
|
||||
effectiveTaskMessage = continuation
|
||||
}
|
||||
if request.ConversationID != "" && interactiondomain.IsRetryRequest(utterance.Content) {
|
||||
if len(conversationHistory) > 0 {
|
||||
for index := len(conversationHistory) - 1; index >= 0; index-- {
|
||||
if interactiondomain.IsRetryRequest(conversationHistory[index].Content) {
|
||||
continue
|
||||
}
|
||||
candidate := conversationHistory[index]
|
||||
previousTask = &candidate
|
||||
effectiveTaskMessage = interactiondomain.StripAssistantVocative(candidate.Content)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
semantic, err := interactiondomain.InterpretUtterance(utterance, projects, previousFocus, now)
|
||||
if err != nil {
|
||||
http.Error(w, "interpret chat message", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if continuationMessage != "" {
|
||||
continuedUtterance := utterance
|
||||
continuedUtterance.Content = continuationMessage
|
||||
continuedSemantic, continuedErr := interactiondomain.InterpretUtterance(continuedUtterance, projects, previousFocus, now)
|
||||
if continuedErr == nil {
|
||||
continuedSemantic.Interpretation.Source = "CONVERSATION_CONTINUATION"
|
||||
continuedSemantic.Interpretation.SelectionReason = "location follow-up completed the previous local-search task"
|
||||
semantic = continuedSemantic
|
||||
}
|
||||
}
|
||||
if travelContinuation && interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory) == "" {
|
||||
semantic.CapabilityHints = append(semantic.CapabilityHints, string(capability.CapabilityKindWebResearch))
|
||||
semantic.NeedsCognition = false
|
||||
semantic.Interpretation.NeedsCognition = false
|
||||
}
|
||||
locationReply := interactiondomain.LocationContextReply(utterance.Content)
|
||||
if locationReply != "" {
|
||||
semantic.Reply = locationReply
|
||||
semantic.NeedsCognition = false
|
||||
semantic.Interpretation.NeedsCognition = false
|
||||
}
|
||||
if previousTask != nil {
|
||||
previousSemantic, previousErr := interactiondomain.InterpretUtterance(*previousTask, projects, previousFocus, now)
|
||||
if previousErr == nil {
|
||||
semantic = interactiondomain.CarryForwardPreviousTask(semantic, previousSemantic)
|
||||
}
|
||||
}
|
||||
assistantName := interactiondomain.PreferredAssistantName(utterance.Content)
|
||||
if assistantName == "" {
|
||||
assistantName = latestAssistantName(conversationHistory)
|
||||
}
|
||||
identityReply := ""
|
||||
log.Printf("interaction identity context: name=%q history=%d", assistantName, len(conversationHistory))
|
||||
if interactiondomain.IsIdentityInquiry(utterance.Content) && assistantName != "" {
|
||||
identityReply = "Меня зовут " + assistantName + ". Я Домовой, ваш помощник."
|
||||
semantic.Reply = identityReply
|
||||
semantic.NeedsCognition = false
|
||||
semantic.CapabilityHints = nil
|
||||
semantic.Interpretation.Applicability = interaction.SemanticApplicable
|
||||
semantic.Interpretation.Clarification = interaction.ClarificationNotRequired
|
||||
}
|
||||
deterministicSpeechAct := semantic.Interpretation.SpeechAct
|
||||
deterministicReply := semantic.Reply
|
||||
routePlan := buildChatRoutePlan(semantic)
|
||||
cognitiveAttempted := false
|
||||
cognitiveAvailable := true
|
||||
if shouldRequestBroaderInterpretation(semantic) {
|
||||
semanticInterpretationRequested := shouldRequestSemanticInterpretation(semantic)
|
||||
semanticReflexAudit := shouldAuditDeterministicReflex(semantic, string(utterance.ID), now)
|
||||
if identityReply != "" {
|
||||
semanticReflexAudit = false
|
||||
}
|
||||
if semanticInterpretationRequested || semanticReflexAudit {
|
||||
cognitiveAttempted = true
|
||||
projectNames := make([]string, 0, len(semantic.Projects))
|
||||
for _, ref := range semantic.Projects {
|
||||
@@ -309,9 +616,11 @@ func main() {
|
||||
projectNames = append(projectNames, ref.ProjectDisplayName)
|
||||
}
|
||||
}
|
||||
if response, interpretationErr := cognitiveClient.Interpret(r.Context(), buildGatewayInterpretationRequest(session, utterance, semantic, projectNames)); interpretationErr == nil && response.FinishReason == cognitive.FinishCompleted {
|
||||
cognitiveUtterance := utterance
|
||||
cognitiveUtterance.Content = effectiveTaskMessage
|
||||
if response, interpretationErr := cognitiveClient.Interpret(r.Context(), buildGatewayInterpretationRequest(session, cognitiveUtterance, semantic, projectNames)); interpretationErr == nil && response.FinishReason == cognitive.FinishCompleted {
|
||||
if candidate, candidateErr := interactionCandidateFromGateway(utterance.ID, response.Candidate, now); candidateErr == nil {
|
||||
semantic = interactiondomain.ApplyCognitiveCandidate(semantic, utterance.Content, candidate, previousFocus)
|
||||
semantic = interactiondomain.ApplyCognitiveCandidate(semantic, effectiveTaskMessage, candidate, previousFocus)
|
||||
} else {
|
||||
cognitiveAvailable = false
|
||||
}
|
||||
@@ -319,6 +628,14 @@ func main() {
|
||||
cognitiveAvailable = false
|
||||
}
|
||||
}
|
||||
if travelReply := interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory); travelReply != "" {
|
||||
semantic.Interpretation.Clarification = interaction.ClarificationRequired
|
||||
semantic.Interpretation.Applicability = interaction.SemanticCandidateClarify
|
||||
semantic.Reply = travelReply
|
||||
deterministicReply = travelReply
|
||||
semantic.NeedsCognition = false
|
||||
semantic.Interpretation.NeedsCognition = false
|
||||
}
|
||||
if err := interactionRepository.AppendUtterance(r.Context(), conversation, utterance, intent, relations); err != nil {
|
||||
http.Error(w, "save chat message", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -330,7 +647,7 @@ func main() {
|
||||
responseResult, responseErr := responseClient.Respond(r.Context(), r.Header.Get("Authorization"), continuity.HumanResponseRequest{
|
||||
SourceUtteranceID: string(utterance.ID),
|
||||
SourceIntentID: string(intent.ID),
|
||||
Message: utterance.Content,
|
||||
Message: effectiveTaskMessage,
|
||||
RequestedAtUnixMillis: now.UnixMilli(),
|
||||
})
|
||||
if semantic.Interpretation.SpeechAct == interaction.SpeechActCorrection {
|
||||
@@ -349,7 +666,7 @@ func main() {
|
||||
activeFocusName = previousFocus.ProjectDisplayName
|
||||
activeFocusProvenance = previousFocus.Provenance
|
||||
}
|
||||
var planningReply string
|
||||
planningReply := deterministicReply
|
||||
workPlanned := false
|
||||
needsCognition := semantic.NeedsCognition
|
||||
projectID := ""
|
||||
@@ -378,7 +695,7 @@ func main() {
|
||||
ProjectID: work.ProjectRef(projectID),
|
||||
ProjectDisplayName: activeFocusName,
|
||||
ProjectProvenance: work.ProjectProvenanceRef(activeFocusProvenance),
|
||||
Message: utterance.Content,
|
||||
Message: effectiveTaskMessage,
|
||||
SpeechAct: work.SpeechActRef(semantic.Interpretation.SpeechAct),
|
||||
SecondarySpeechAct: work.SpeechActRef(semantic.Interpretation.SecondarySpeechAct),
|
||||
AssertionVerification: epistemics.VerificationState(semantic.Interpretation.AssertionVerification),
|
||||
@@ -392,12 +709,18 @@ func main() {
|
||||
helpPath = work.HelpPath{}
|
||||
}
|
||||
switch semantic.InformationRequestKind {
|
||||
case interaction.InformationRequestMemoryInventory:
|
||||
planningReply = humanMemoryInventory(conversationHistory, assistantName)
|
||||
needsCognition = false
|
||||
case interaction.InformationRequestTimeDifference:
|
||||
planningReply = humanTimeDifferenceReply(effectiveTaskMessage)
|
||||
needsCognition = false
|
||||
case interaction.InformationRequestCapabilityInventory:
|
||||
kinds, err := capabilityRepository.AvailableCapabilityKinds(r.Context(), session.PrincipalID)
|
||||
if err == nil && len(kinds) > 0 {
|
||||
planningReply = "Сейчас у меня подключены реальные capabilities: " + humanCapabilityList(kinds) + ". Для рассуждений я использую отдельный Cognitive Gateway/Router, если локальный CANARY доступен. Возможности вроде отправки почты пока будут фиксироваться как capability gap, а не как выполненное действие."
|
||||
planningReply = "Я умею общаться, искать проверяемую информацию, работать с подключёнными моделями и " + humanCapabilityList(kinds) + "."
|
||||
} else {
|
||||
planningReply = "Я умею записывать диалог и намерения, работать с текущим контекстом, строить планы и обращаться к Cognitive Gateway при доступности локального CANARY. Реальные action-capabilities подключаются через registry; если capability нет, я фиксирую gap."
|
||||
planningReply = "Я умею общаться, сохранять контекст, искать проверяемую информацию и работать с подключёнными моделями. Дополнительные действия подключаются отдельно."
|
||||
}
|
||||
needsCognition = false
|
||||
case interaction.InformationRequestCognitiveModelInventory:
|
||||
@@ -409,10 +732,48 @@ func main() {
|
||||
planningReply = humanCognitiveChannelReply(semantic.InformationRequestKind, semantic.Interpretation.SpeechAct, health, healthErr)
|
||||
needsCognition = false
|
||||
}
|
||||
researchBacked := false
|
||||
if hasChatRoute(routePlan, chatRouteResearch) && semantic.Interpretation.Clarification != interaction.ClarificationRequired {
|
||||
if planID, idErr := ids.New(); idErr == nil {
|
||||
parts := interactiondomain.IndependentRequestParts(effectiveTaskMessage)
|
||||
replies := make([]string, 0, len(parts))
|
||||
for index, part := range parts {
|
||||
if clarification := interactiondomain.LocalSearchClarification(part); clarification != "" {
|
||||
replies = append(replies, "По запросу «"+part+"»: "+clarification)
|
||||
continue
|
||||
}
|
||||
researchCtx, cancelResearch := context.WithTimeout(r.Context(), 8*time.Second)
|
||||
result, researchErr := researchClient.SearchWeb(researchCtx, epistemics.WebSearchRequest{
|
||||
Plan: epistemics.ResearchPlan{ID: epistemics.ResearchPlanID(fmt.Sprintf("plan:%s:%d", planID, index)), Question: part, SourceClasses: []string{"web-search"}, FreshnessPolicy: "current"},
|
||||
PropositionKey: fmt.Sprintf("question:%s:%d", utterance.ID, index), Query: part,
|
||||
})
|
||||
cancelResearch()
|
||||
reply, backed := renderResearchReply(part, result, researchErr)
|
||||
if backed {
|
||||
researchBacked = true
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
reply = "По запросу «" + part + "»: " + reply
|
||||
}
|
||||
replies = append(replies, reply)
|
||||
}
|
||||
planningReply = strings.Join(replies, "\n\n")
|
||||
if researchBacked {
|
||||
semantic.Reply = planningReply
|
||||
}
|
||||
// A failed source must not turn the complete conversation into a
|
||||
// runtime failure or invite an unsupported local guess for a fact.
|
||||
needsCognition = false
|
||||
}
|
||||
}
|
||||
if helpPath.Reply != "" {
|
||||
planningReply = helpPath.Reply
|
||||
needsCognition = false
|
||||
}
|
||||
if travelReply := interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory); travelReply != "" {
|
||||
planningReply = travelReply
|
||||
needsCognition = false
|
||||
}
|
||||
if planningReply == "" && needsCognition && cognitiveAttempted && !cognitiveAvailable {
|
||||
planningReply = humanCognitiveDegradedReply(utterance.Content)
|
||||
needsCognition = false
|
||||
@@ -493,6 +854,46 @@ func main() {
|
||||
planningReply = planningReply + " Для coding delegation подходящий agent implementation сейчас недоступен, поэтому я сохранил план без ложного запуска."
|
||||
}
|
||||
}
|
||||
if identityReply != "" {
|
||||
planningReply = identityReply
|
||||
needsCognition = false
|
||||
}
|
||||
if strings.TrimSpace(planningReply) != "" {
|
||||
reviewContext, cancelReview := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
reviewedReply, reviewErr := cognitiveClient.ReviewResponse(reviewContext, session, utterance.Content, planningReply, cognitive.TaskMeaning{
|
||||
SpeechAct: string(semantic.Interpretation.SpeechAct),
|
||||
CapabilityHints: toCapabilityKinds(semantic.CapabilityHints),
|
||||
RequestedOutcome: semantic.Interpretation.RequestedOutcome,
|
||||
InformationRequestKind: semantic.InformationRequestKind,
|
||||
ConfidenceMillis: semantic.Interpretation.ConfidenceMillis,
|
||||
})
|
||||
cancelReview()
|
||||
stableSemanticReply := identityReply != "" || semantic.InformationRequestKind != "" || locationReply != "" || deterministicSpeechAct == interaction.SpeechActConversational || deterministicSpeechAct == interaction.SpeechActConfirmation || deterministicSpeechAct == interaction.SpeechActRejection
|
||||
if reviewErr != nil {
|
||||
log.Printf("cognitive response review unavailable: %v", reviewErr)
|
||||
} else if !stableSemanticReply && (!researchBacked || reviewPreservesResearchEvidence(reviewedReply, planningReply)) {
|
||||
planningReply = reviewedReply
|
||||
} else if reviewedReply != planningReply {
|
||||
log.Printf("cognitive response review proposed stable semantic rewrite; preserving semantic reply")
|
||||
}
|
||||
}
|
||||
if identityReply == "" {
|
||||
stableAct := interactiondomain.ClassifySpeechAct(utterance.Content, utterance.CorrectsUtteranceID)
|
||||
travelClarification := interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory)
|
||||
if stableReply, stable := interactiondomain.StableConversationalReply(utterance.Content, stableAct); stable && travelClarification == "" {
|
||||
planningReply = stableReply
|
||||
needsCognition = false
|
||||
}
|
||||
}
|
||||
if identityReply != "" {
|
||||
semantic.Reply = identityReply
|
||||
needsCognition = false
|
||||
planningReply = identityReply
|
||||
}
|
||||
if strings.TrimSpace(planningReply) != "" {
|
||||
semantic.Reply = planningReply
|
||||
}
|
||||
log.Printf("chat response final message=%q act=%s deterministic=%s handled=%t fallback=%q planning=%q", utterance.Content, semantic.Interpretation.SpeechAct, deterministicSpeechAct, responseResult.Handled, semantic.Reply, planningReply)
|
||||
response := interaction.ChatMessageResponse{
|
||||
ConversationID: conversation.ID,
|
||||
UtteranceID: utterance.ID,
|
||||
@@ -506,6 +907,7 @@ func main() {
|
||||
Clarification: semantic.Interpretation.Clarification,
|
||||
WorkPlanned: workPlanned,
|
||||
PlanningReply: planningReply,
|
||||
ResearchBacked: researchBacked,
|
||||
CapabilityStatus: capabilityStatus,
|
||||
CapabilityKind: capabilityKind,
|
||||
CapabilityHints: toCapabilityKinds(semantic.CapabilityHints),
|
||||
@@ -806,6 +1208,20 @@ func main() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(binding)
|
||||
})
|
||||
mux.HandleFunc("GET /v1/cognitive-bindings/{id}/revisions/{revision}/snapshot", func(w http.ResponseWriter, r *http.Request) {
|
||||
revision, err := strconv.ParseInt(r.PathValue("revision"), 10, 64)
|
||||
if err != nil || revision < 1 {
|
||||
http.Error(w, "invalid cognitive binding revision", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
snapshot, err := capabilityRepository.LoadCognitiveCandidate(r.Context(), capability.CapabilityBindingID(r.PathValue("id")), revision)
|
||||
if err != nil {
|
||||
http.Error(w, "cognitive binding snapshot not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(snapshot)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/cognitive-bindings/resolve", func(w http.ResponseWriter, r *http.Request) {
|
||||
var requirement capability.CognitiveRequirement
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 16*1024+1))
|
||||
@@ -814,7 +1230,13 @@ func main() {
|
||||
http.Error(w, "invalid cognitive requirement", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
candidates, err := capabilityRepository.LoadCognitiveCandidates(r.Context(), requirement.CapabilityID, requirement.OwnerPrincipalID)
|
||||
var candidates []capability.ResolvedCognitiveBinding
|
||||
var err error
|
||||
if requirement.TaskProfileID != "" {
|
||||
candidates, err = capabilityRepository.LoadCognitiveCandidatesForProfile(r.Context(), requirement.CapabilityID, requirement.OwnerPrincipalID, requirement.TaskProfileID)
|
||||
} else {
|
||||
candidates, err = capabilityRepository.LoadCognitiveCandidates(r.Context(), requirement.CapabilityID, requirement.OwnerPrincipalID)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "cognitive registry unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
@@ -863,6 +1285,34 @@ func main() {
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/cognitive-registry/task-evaluations", func(w http.ResponseWriter, r *http.Request) {
|
||||
var value capability.TaskEvaluation
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 64<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&value); err != nil || capabilitydomain.ValidateTaskEvaluation(value) != nil {
|
||||
http.Error(w, "invalid task evaluation", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := capabilityRepository.SaveTaskEvaluation(r.Context(), value); err != nil {
|
||||
http.Error(w, "save task evaluation", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/cognitive-registry/model-qualification-observations", func(w http.ResponseWriter, r *http.Request) {
|
||||
var value capability.ModelQualificationObservation
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&value); err != nil || value.Validate() != nil {
|
||||
http.Error(w, "invalid model qualification observation", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := capabilityRepository.SaveModelQualificationObservation(r.Context(), value); err != nil {
|
||||
http.Error(w, "save model qualification observation", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/cognitive-canary", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateCreator(w, r)
|
||||
if !ok {
|
||||
@@ -1159,6 +1609,140 @@ func envOr(name, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envInt(name string, fallback int) int {
|
||||
value, err := strconv.Atoi(os.Getenv(name))
|
||||
if err != nil || value < 1 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func hasCapabilityHint(hints []string, wanted string) bool {
|
||||
for _, hint := range hints {
|
||||
if hint == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func renderResearchReply(query string, result epistemics.ResearchResult, researchErr error) (string, bool) {
|
||||
if researchErr != nil {
|
||||
return "Сейчас не удалось получить данные из подключённого источника. Попробую снова через доступный маршрут.", false
|
||||
}
|
||||
lines := make([]string, 0, 2)
|
||||
for index, finding := range result.Findings {
|
||||
if finding.State != epistemics.FindingSupported || strings.TrimSpace(finding.Excerpt.Text) == "" || strings.TrimSpace(finding.Source.Locator) == "" {
|
||||
continue
|
||||
}
|
||||
excerpt := strings.TrimSpace(finding.Excerpt.Text)
|
||||
limit := 180
|
||||
if index > 0 {
|
||||
limit = 100
|
||||
}
|
||||
if len([]rune(excerpt)) > limit {
|
||||
excerpt = string([]rune(excerpt)[:limit]) + "…"
|
||||
}
|
||||
lines = append(lines, excerpt+"\nИсточник: "+researchSourceLabel(finding))
|
||||
if len(lines) == 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return "Проверяемых данных пока не найдено. Могу повторить поиск или уточнить, что именно проверить.", false
|
||||
}
|
||||
reply := strings.Join(lines, "\n")
|
||||
lowerQuery := strings.ToLower(query)
|
||||
if (strings.Contains(lowerQuery, "цен") || strings.Contains(lowerQuery, "стоим")) && !containsPriceEvidence(reply) {
|
||||
reply = "Актуальная цена в найденных фрагментах не указана, поэтому не буду придумывать сумму. Вот страницы, где её можно проверить:\n" + reply
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
func containsPriceEvidence(value string) bool {
|
||||
pricePattern := regexp.MustCompile(`(?i)(\d[\d\s.,]{2,})(?:\s*(?:₽|руб\.?|р\.?|usd|\$|€))`)
|
||||
return pricePattern.MatchString(value)
|
||||
}
|
||||
|
||||
func reviewPreservesResearchEvidence(reviewed, draft string) bool {
|
||||
if strings.Contains(draft, "http://") || strings.Contains(draft, "https://") {
|
||||
return strings.Contains(reviewed, "http://") || strings.Contains(reviewed, "https://")
|
||||
}
|
||||
if strings.Contains(draft, "Источник:") {
|
||||
return strings.Contains(reviewed, "Источник:")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func humanMemoryInventory(history []interaction.HumanUtterance, assistantName string) string {
|
||||
rows := make([]string, 0, 4)
|
||||
if assistantName != "" {
|
||||
rows = append(rows, "| Имя помощника | "+assistantName+" |")
|
||||
}
|
||||
latestPreferenceAt := int64(-1)
|
||||
latestPreference := ""
|
||||
for _, utterance := range history {
|
||||
message := strings.TrimSpace(utterance.Content)
|
||||
lower := strings.ToLower(message)
|
||||
if (strings.Contains(lower, "нравится") || strings.Contains(lower, "люблю")) && utterance.ReceivedAtUnixMillis >= latestPreferenceAt {
|
||||
latestPreferenceAt = utterance.ReceivedAtUnixMillis
|
||||
latestPreference = message
|
||||
}
|
||||
}
|
||||
if latestPreference != "" {
|
||||
rows = append(rows, "| Предпочтение пользователя | "+latestPreference+" |")
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return "| Ячейка памяти | Значение |\n|---|---|\n| — | Сохранённых пользовательских предпочтений пока не найдено. |"
|
||||
}
|
||||
return "| Ячейка памяти | Значение |\n|---|---|\n" + strings.Join(rows, "\n")
|
||||
}
|
||||
|
||||
func latestAssistantName(history []interaction.HumanUtterance) string {
|
||||
latestAt := int64(-1)
|
||||
latestName := ""
|
||||
for _, utterance := range history {
|
||||
if name := interactiondomain.PreferredAssistantName(utterance.Content); name != "" && utterance.ReceivedAtUnixMillis >= latestAt {
|
||||
latestAt = utterance.ReceivedAtUnixMillis
|
||||
latestName = name
|
||||
}
|
||||
}
|
||||
return latestName
|
||||
}
|
||||
|
||||
func humanTimeDifferenceReply(message string) string {
|
||||
lower := strings.ToLower(message)
|
||||
if strings.Contains(lower, "моск") && strings.Contains(lower, "шанх") {
|
||||
return "Шанхай опережает Москву на 5 часов. Если в Москве 12:00, в Шанхае 17:00."
|
||||
}
|
||||
return "Уточните два города — я сравню их часовые пояса."
|
||||
}
|
||||
|
||||
func researchSourceLabel(finding epistemics.ResearchFinding) string {
|
||||
title := strings.TrimSpace(finding.Source.Title)
|
||||
locator := strings.TrimSpace(finding.Source.Locator)
|
||||
publisher := strings.TrimSpace(finding.Source.Publisher)
|
||||
if strings.Contains(locator, "duckduckgo.com/html") || strings.Contains(locator, "bing.com/search") {
|
||||
locator = ""
|
||||
}
|
||||
if title != "" && publisher != "" {
|
||||
if locator != "" {
|
||||
return publisher + " (" + locator + ")"
|
||||
}
|
||||
return publisher
|
||||
}
|
||||
if title != "" {
|
||||
return title
|
||||
}
|
||||
if publisher != "" {
|
||||
return publisher
|
||||
}
|
||||
if locator != "" {
|
||||
return locator
|
||||
}
|
||||
return "подключённый источник"
|
||||
}
|
||||
|
||||
func humanCognitiveModelInventory(candidates []capability.ResolvedCognitiveBinding, inventoryErr error) string {
|
||||
if inventoryErr != nil {
|
||||
return "Я не смогла прочитать реестр подключённых моделей. Встроенный канал Домового остаётся доступен; настройки можно проверить повторно после восстановления реестра. Ключи и пароли не выводятся в чат."
|
||||
@@ -1166,28 +1750,82 @@ func humanCognitiveModelInventory(candidates []capability.ResolvedCognitiveBindi
|
||||
if len(candidates) == 0 {
|
||||
return "В реестре пока нет подтверждённых подключённых моделей. Встроенный локальный канал Домового доступен всегда; внешние модели добавляются через настройки подключения. Ключи и пароли в чат не выводятся."
|
||||
}
|
||||
lines := make([]string, 0, len(candidates)+1)
|
||||
lines = append(lines, "Подключённые модели из реестра:")
|
||||
type inventoryEntry struct {
|
||||
name string
|
||||
status string
|
||||
rank int
|
||||
seenAt int
|
||||
}
|
||||
entries := make([]inventoryEntry, 0, len(candidates))
|
||||
byName := make(map[string]int, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
modelName := string(candidate.Model.ProviderModelID)
|
||||
if modelName == "" {
|
||||
modelName = "имя модели не подтверждено"
|
||||
continue
|
||||
}
|
||||
status := cognitiveInventoryStatus(candidate)
|
||||
pricing := cognitiveInventoryPricing(candidate.Provider)
|
||||
details := []string{status, "тип " + string(candidate.Provider.Kind), "политика " + string(candidate.Binding.DataPolicy)}
|
||||
if pricing != "" {
|
||||
details = append(details, pricing)
|
||||
status := conciseCognitiveInventoryStatus(candidate)
|
||||
rank := cognitiveInventoryRank(candidate)
|
||||
if index, exists := byName[modelName]; exists {
|
||||
if rank < entries[index].rank {
|
||||
entries[index].status = status
|
||||
entries[index].rank = rank
|
||||
}
|
||||
continue
|
||||
}
|
||||
if candidate.Provider.AuthMode != "" && candidate.Provider.AuthMode != capability.AuthNone {
|
||||
details = append(details, "доступ "+humanAuthMode(candidate.Provider.AuthMode))
|
||||
byName[modelName] = len(entries)
|
||||
entries = append(entries, inventoryEntry{name: modelName, status: status, rank: rank, seenAt: len(entries)})
|
||||
}
|
||||
sort.SliceStable(entries, func(left, right int) bool {
|
||||
if entries[left].rank != entries[right].rank {
|
||||
return entries[left].rank < entries[right].rank
|
||||
}
|
||||
lines = append(lines, "- "+modelName+" ("+strings.Join(details, "; ")+")")
|
||||
return entries[left].seenAt < entries[right].seenAt
|
||||
})
|
||||
lines := make([]string, 0, len(entries)+2)
|
||||
lines = append(lines, "Доступные модели:")
|
||||
unconfirmed := 0
|
||||
for _, entry := range entries {
|
||||
if entry.rank >= 3 {
|
||||
unconfirmed++
|
||||
continue
|
||||
}
|
||||
lines = append(lines, "- "+entry.name+" — "+entry.status)
|
||||
}
|
||||
if len(lines) == 1 {
|
||||
return "Подтверждённых доступных моделей сейчас нет."
|
||||
}
|
||||
if unconfirmed > 0 {
|
||||
lines = append(lines, fmt.Sprintf("Ещё %d моделей требуют проверки.", unconfirmed))
|
||||
}
|
||||
lines = append(lines, "Секреты, ключи и пароли намеренно не показываются.")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func conciseCognitiveInventoryStatus(candidate capability.ResolvedCognitiveBinding) string {
|
||||
if candidate.Provider.Health == capability.HealthAvailable && candidate.Model.Health == capability.HealthAvailable {
|
||||
if candidate.Binding.Activation == capability.ActivationActive {
|
||||
return "активна"
|
||||
}
|
||||
return "доступна"
|
||||
}
|
||||
if candidate.Provider.Health == capability.HealthDegraded || candidate.Model.Health == capability.HealthDegraded {
|
||||
return "работает с ограничениями"
|
||||
}
|
||||
return "не подтверждена"
|
||||
}
|
||||
|
||||
func cognitiveInventoryRank(candidate capability.ResolvedCognitiveBinding) int {
|
||||
if candidate.Provider.Health == capability.HealthAvailable && candidate.Model.Health == capability.HealthAvailable {
|
||||
if candidate.Binding.Activation == capability.ActivationActive {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if candidate.Provider.Health == capability.HealthDegraded || candidate.Model.Health == capability.HealthDegraded {
|
||||
return 2
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
func cognitiveInventoryStatus(candidate capability.ResolvedCognitiveBinding) string {
|
||||
if candidate.Provider.Health == capability.HealthAvailable && candidate.Model.Health == capability.HealthAvailable {
|
||||
if candidate.Binding.Activation == capability.ActivationActive {
|
||||
@@ -1234,44 +1872,26 @@ func humanAuthMode(mode capability.AuthMode) string {
|
||||
}
|
||||
|
||||
func humanCognitiveChannelReply(kind interaction.InformationRequestKind, act interaction.SpeechAct, health capability.Health, healthErr error) string {
|
||||
recoveryRequested := kind == interaction.InformationRequestCognitiveRecovery || act == interaction.SpeechActRequestToInvestigate || act == interaction.SpeechActDirective
|
||||
if healthErr != nil {
|
||||
if recoveryRequested {
|
||||
return "Я не вижу подтверждённого состояния локального канала. Домовой всё равно рядом: опишите, что изменилось, и я помогу подготовить аварийную настройку."
|
||||
}
|
||||
return "Я не вижу подтверждённого состояния локального канала, но Домовой работает. Можно продолжать разговор и проверить канал ещё раз."
|
||||
return "Не удалось проверить канал. Домовой остаётся на связи."
|
||||
}
|
||||
var observation string
|
||||
switch health.State {
|
||||
case capability.HealthAvailable:
|
||||
observation = "Основной локальный канал сейчас доступен."
|
||||
return "Канал мышления доступен."
|
||||
case capability.HealthDegraded:
|
||||
observation = "Основной локальный канал сейчас работает с ограничениями."
|
||||
return "Канал мышления работает с ограничениями."
|
||||
case capability.HealthUnavailable:
|
||||
observation = "Основной локальный канал сейчас недоступен."
|
||||
return "Канал мышления недоступен. Домовой остаётся на связи."
|
||||
default:
|
||||
observation = "Состояние основного локального канала пока не подтверждено."
|
||||
return "Состояние канала пока не подтверждено. Домовой остаётся на связи."
|
||||
}
|
||||
if recoveryRequested {
|
||||
if health.State == capability.HealthUnavailable {
|
||||
return observation + " Домовой жив: начну с проверки встроенного маршрута, затем зафиксирую варианты аварийного восстановления без передачи секретов."
|
||||
}
|
||||
return observation + " Я не меняю модель прямо из разговора: сначала покажу наблюдаемое состояние и подготовлю следующий проверяемый шаг восстановления."
|
||||
}
|
||||
if health.State == capability.HealthUnavailable {
|
||||
if recoveryRequested {
|
||||
return observation + " Домовой не остановился: начну с проверки встроенного маршрута, затем зафиксирую доступные варианты восстановления без передачи секретов."
|
||||
}
|
||||
return observation + " Но Домовой не остановился: продолжаю принимать сообщения и помогу восстановить локальный канал."
|
||||
}
|
||||
return observation + " Это наблюдение о готовности capability, а не прямой доступ к модели."
|
||||
}
|
||||
|
||||
func humanCognitiveDegradedReply(message string) string {
|
||||
if strings.TrimSpace(message) == "" {
|
||||
return "Основной канал мышления сейчас недоступен. Я рядом и продолжаю работать в локальном аварийном режиме."
|
||||
return "Сейчас когнитивный канал недоступен; Домовой остаётся на связи."
|
||||
}
|
||||
return "Основной канал мышления сейчас недоступен. Домовой жив: сообщение принято, а я могу помочь разобраться с локальным каналом и настройкой восстановления."
|
||||
return "Сейчас когнитивный канал недоступен. Повторю запрос через доступный маршрут, когда он восстановится."
|
||||
}
|
||||
|
||||
func humanCapabilityList(kinds []capability.CapabilityKind) string {
|
||||
|
||||
@@ -27,6 +27,36 @@ func TestBearerTokenRequiresBearerScheme(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestAssistantNameUsesNewestDurablePreference(t *testing.T) {
|
||||
history := []interaction.HumanUtterance{
|
||||
{Content: "я назову тебя Дед", ReceivedAtUnixMillis: 20},
|
||||
{Content: "теперь тебя зовут Фея", ReceivedAtUnixMillis: 30},
|
||||
}
|
||||
if got := latestAssistantName(history); got != "Фея" {
|
||||
t.Fatalf("latest assistant name = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanMemoryInventoryUsesNewestPreference(t *testing.T) {
|
||||
history := []interaction.HumanUtterance{
|
||||
{Content: "мне нравится синий цвет", ReceivedAtUnixMillis: 20},
|
||||
{Content: "мне нравится красный цвет", ReceivedAtUnixMillis: 30},
|
||||
}
|
||||
got := humanMemoryInventory(history, "Фея")
|
||||
if !strings.Contains(got, "красный") || strings.Contains(got, "синий") {
|
||||
t.Fatalf("memory inventory did not select newest preference: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsPriceEvidenceDistinguishesLinksFromPrices(t *testing.T) {
|
||||
if containsPriceEvidence("RTX 4090 — market.yandex.ru") {
|
||||
t.Fatal("a product link is not price evidence")
|
||||
}
|
||||
if !containsPriceEvidence("Цена: 189 990 ₽") {
|
||||
t.Fatal("expected a currency amount to count as price evidence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCognitiveChannelQuestionUsesHumanSafeHealthReply(t *testing.T) {
|
||||
reply := humanCognitiveChannelReply(interaction.InformationRequestCognitiveChannelStatus, interaction.SpeechActQuestion, capability.Health{State: capability.HealthAvailable, CapabilityID: "provider-secret-model"}, nil)
|
||||
if !strings.Contains(reply, "доступен") || strings.Contains(reply, "provider-secret-model") {
|
||||
@@ -36,14 +66,14 @@ func TestCognitiveChannelQuestionUsesHumanSafeHealthReply(t *testing.T) {
|
||||
|
||||
func TestCognitiveConfigurationRequestDoesNotPretendToMutate(t *testing.T) {
|
||||
reply := humanCognitiveChannelReply(interaction.InformationRequestCognitiveRecovery, interaction.SpeechActDirective, capability.Health{State: capability.HealthUnavailable}, nil)
|
||||
if !strings.Contains(reply, "Домовой жив") || !strings.Contains(reply, "аварийн") {
|
||||
if reply != "Канал мышления недоступен. Домовой остаётся на связи." {
|
||||
t.Fatalf("unexpected configuration reply: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCognitiveDegradedReplyKeepsDomovoyAlive(t *testing.T) {
|
||||
reply := humanCognitiveDegradedReply("Помоги настроить локальную модель")
|
||||
if !strings.Contains(reply, "Домовой жив") || !strings.Contains(reply, "принято") {
|
||||
if !strings.Contains(reply, "когнитивный канал") || !strings.Contains(reply, "Повторю") {
|
||||
t.Fatalf("unexpected degraded reply: %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -74,11 +104,21 @@ func TestHumanCognitiveModelInventoryDoesNotExposeSecrets(t *testing.T) {
|
||||
Provider: capability.ProviderInstance{Kind: "openai-compatible-chat-v1", CredentialRef: "secret-ref", AuthMode: capability.AuthKey, BillingMode: capability.BillingPerRequest, RequestCostMicros: &cost, Health: capability.HealthAvailable},
|
||||
Model: capability.ModelDescriptor{ProviderModelID: "example-model", Health: capability.HealthAvailable},
|
||||
}}, nil)
|
||||
if strings.Contains(text, "secret-ref") || strings.Contains(text, "25") || !strings.Contains(text, "example-model") || !strings.Contains(text, "API-ключ") {
|
||||
if strings.Contains(text, "secret-ref") || strings.Contains(text, "25") || !strings.Contains(text, "example-model") || !strings.Contains(text, "активна") {
|
||||
t.Fatalf("unsafe or incomplete inventory: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanCognitiveModelInventoryDeduplicatesAndPrioritizesAvailable(t *testing.T) {
|
||||
text := humanCognitiveModelInventory([]capability.ResolvedCognitiveBinding{
|
||||
{Binding: capability.CognitiveBinding{Activation: capability.ActivationInactive}, Provider: capability.ProviderInstance{Health: capability.HealthUnavailable}, Model: capability.ModelDescriptor{ProviderModelID: "same-model", Health: capability.HealthUnavailable}},
|
||||
{Binding: capability.CognitiveBinding{Activation: capability.ActivationActive}, Provider: capability.ProviderInstance{Health: capability.HealthAvailable}, Model: capability.ModelDescriptor{ProviderModelID: "same-model", Health: capability.HealthAvailable}},
|
||||
}, nil)
|
||||
if strings.Count(text, "same-model") != 1 || !strings.Contains(text, "same-model — активна") {
|
||||
t.Fatalf("expected one active model entry, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorSessionHandlerAcceptsConfiguredCredentials(t *testing.T) {
|
||||
handler := creatorSessionHandler("recovery", "m", "password", []byte("01234567890123456789012345678901"), func() time.Time { return time.Unix(1000, 0) })
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/creator/sessions", strings.NewReader(`{"username":"m","password":"password"}`))
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
)
|
||||
|
||||
type researchRuntimeClient struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newResearchRuntimeClient(rawURL string, client *http.Client) (researchRuntimeClient, error) {
|
||||
base := strings.TrimRight(strings.TrimSpace(rawURL), "/")
|
||||
if base == "" {
|
||||
return researchRuntimeClient{}, fmt.Errorf("research runtime URL is empty")
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return researchRuntimeClient{baseURL: base, client: client}, nil
|
||||
}
|
||||
|
||||
func (c researchRuntimeClient) EvaluateWeb(ctx context.Context, request epistemics.WebResearchRequest) (epistemics.ResearchResult, error) {
|
||||
payload, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return epistemics.ResearchResult{}, err
|
||||
}
|
||||
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/research/web/evaluate", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return epistemics.ResearchResult{}, err
|
||||
}
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
response, err := c.client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return epistemics.ResearchResult{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return epistemics.ResearchResult{}, fmt.Errorf("research runtime status %d", response.StatusCode)
|
||||
}
|
||||
var result epistemics.ResearchResult
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return epistemics.ResearchResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c researchRuntimeClient) SearchWeb(ctx context.Context, request epistemics.WebSearchRequest) (epistemics.ResearchResult, error) {
|
||||
payload, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return epistemics.ResearchResult{}, err
|
||||
}
|
||||
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/research/web/search", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return epistemics.ResearchResult{}, err
|
||||
}
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
response, err := c.client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return epistemics.ResearchResult{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return epistemics.ResearchResult{}, fmt.Errorf("research search status %d", response.StatusCode)
|
||||
}
|
||||
var result epistemics.ResearchResult
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return epistemics.ResearchResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
)
|
||||
|
||||
func TestResearchRuntimeClientForwardsBoundedWebRequest(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/research/web/evaluate" || r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
var request epistemics.WebResearchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.URL != "https://assistant.cin.su/healthz" {
|
||||
t.Fatalf("unexpected request body: %#v %v", request, err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(epistemics.ResearchResult{PlanID: request.Plan.ID, Findings: []epistemics.ResearchFinding{{PropositionKey: request.PropositionKey, State: epistemics.FindingSupported}}})
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := newResearchRuntimeClient(server.URL, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.EvaluateWeb(context.Background(), epistemics.WebResearchRequest{Plan: epistemics.ResearchPlan{ID: "plan-1"}, PropositionKey: "health", URL: "https://assistant.cin.su/healthz"})
|
||||
if err != nil || result.PlanID != "plan-1" || len(result.Findings) != 1 || result.Findings[0].State != epistemics.FindingSupported {
|
||||
t.Fatalf("unexpected result: %#v %v", result, err)
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,96 @@ func TestChatMessageUsesCoreAndLocalOnlyCognitiveGateway(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageDoesNotExposeUnverifiedQuestionAsFact(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/creator/sessions/current":
|
||||
_ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/creator/chat/messages":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(interaction.ChatMessageResponse{ConversationID: "conversation-1", UtteranceID: "utterance-1", SpeechAct: interaction.SpeechActQuestion, NeedsCognition: true, CapabilityHints: []capability.CapabilityKind{capability.CapabilityKindWebResearch}, FallbackText: "небезопасная модельная догадка"})
|
||||
default:
|
||||
t.Fatalf("unexpected core route %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer core.Close()
|
||||
cognitiveRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/advisories" {
|
||||
t.Fatalf("unexpected cognitive route: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
var request cognitive.GatewayAdvisoryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "Я продолжаю искать проверяемый ответ через доступный источник.", FinishReason: cognitive.FinishCompleted})
|
||||
}))
|
||||
defer cognitiveRuntime.Close()
|
||||
handler, err := newHandler(core.URL, "http://continuity", "http://commit", core.Client(), cognitiveRuntime.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/chat/messages", strings.NewReader(`{"message":"Какая погода?"}`))
|
||||
request.Header.Set("Authorization", "Bearer session-token")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response browserChatResponse
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Cognition != string(cognitive.FinishCompleted) || strings.Contains(response.ReplyText, "небезопасная") || !strings.Contains(response.ReplyText, "продолжаю искать") {
|
||||
t.Fatalf("unexpected research recovery response: %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageUsesLocalAdvisoryForOrdinaryConversationalQuestion(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/creator/sessions/current":
|
||||
_ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/creator/chat/messages":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(interaction.ChatMessageResponse{ConversationID: "conversation-1", UtteranceID: "utterance-1", SpeechAct: interaction.SpeechActQuestion, NeedsCognition: true, FallbackText: "fallback"})
|
||||
default:
|
||||
t.Fatalf("unexpected core route %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer core.Close()
|
||||
cognitiveRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/advisories" {
|
||||
t.Fatalf("unexpected cognitive path %s", r.URL.Path)
|
||||
}
|
||||
var request cognitive.GatewayAdvisoryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "Я рядом. Давай поговорим.", FinishReason: cognitive.FinishCompleted})
|
||||
}))
|
||||
defer cognitiveRuntime.Close()
|
||||
handler, err := newHandler(core.URL, "http://continuity", "http://commit", core.Client(), cognitiveRuntime.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/chat/messages", strings.NewReader(`{"message":"Как ты?"}`))
|
||||
request.Header.Set("Authorization", "Bearer session-token")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response browserChatResponse
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.ReplyText != "Я рядом. Давай поговорим." || response.Cognition != string(cognitive.FinishCompleted) {
|
||||
t.Fatalf("unexpected conversational response: %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageReturnsHumanCapabilityMetadata(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
|
||||
@@ -13,8 +13,16 @@ func main() {
|
||||
envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"),
|
||||
envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"),
|
||||
envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"),
|
||||
&http.Client{Timeout: 10 * time.Second},
|
||||
// Core chat may spend several seconds on semantic interpretation and
|
||||
// local cognition. Keep the browser gateway alive long enough to return
|
||||
// the completed response instead of converting a successful core 201
|
||||
// into a misleading 502.
|
||||
// A cold local model may need more than one short HTTP round-trip to
|
||||
// load on CPU. Keep the browser gateway aligned with the cognition
|
||||
// deadline instead of turning that wait into a false unavailable reply.
|
||||
&http.Client{Timeout: 90 * time.Second},
|
||||
envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"),
|
||||
envOr("AGENT_WORKER_URL", "http://agent-worker:8090"),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreatorConsoleResponsiveGuardrails(t *testing.T) {
|
||||
html, err := staticFiles.ReadFile("static/index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(html), `<meta name="viewport" content="width=device-width, initial-scale=1">`) {
|
||||
t.Fatal("creator console is missing responsive viewport metadata")
|
||||
}
|
||||
css, err := staticFiles.ReadFile("static/assets/console.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stylesheet := string(css)
|
||||
for _, guard := range []string{
|
||||
"@media (max-width: 680px)",
|
||||
".console { display: block; }",
|
||||
".diagnostic-list { grid-template-columns: 1fr; }",
|
||||
".agent-details { grid-template-columns: 1fr; gap: 14px; }",
|
||||
".fleet-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }",
|
||||
".section-nav { display: flex;",
|
||||
"overflow-x: auto",
|
||||
} {
|
||||
if !strings.Contains(stylesheet, guard) {
|
||||
t.Fatalf("responsive guardrail missing: %s", guard)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResponsiveGuardrailsConstrainMobileGridItem(t *testing.T) {
|
||||
css, err := staticFiles.ReadFile("static/assets/chat.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stylesheet := string(css)
|
||||
for _, guard := range []string{
|
||||
".chat-view { width: 100%; min-width: 0;",
|
||||
"@media (max-width: 640px)",
|
||||
".chat-layout { height: auto; display: block; }",
|
||||
".chat-header > .operator-link, .build-meta { display: none; }",
|
||||
".chat-header .cognitive-console-link { flex: 0 1 110px;",
|
||||
} {
|
||||
if !strings.Contains(stylesheet, guard) {
|
||||
t.Fatalf("chat responsive guardrail missing: %s", guard)
|
||||
}
|
||||
}
|
||||
}
|
||||
+133
-18
@@ -33,13 +33,14 @@ const (
|
||||
var staticFiles embed.FS
|
||||
|
||||
type gateway struct {
|
||||
core *url.URL
|
||||
continuity *url.URL
|
||||
commit *url.URL
|
||||
cognitive *url.URL
|
||||
client *http.Client
|
||||
static http.Handler
|
||||
build buildMetadata
|
||||
core *url.URL
|
||||
continuity *url.URL
|
||||
commit *url.URL
|
||||
cognitive *url.URL
|
||||
agentWorker *url.URL
|
||||
client *http.Client
|
||||
static http.Handler
|
||||
build buildMetadata
|
||||
}
|
||||
|
||||
type buildMetadata struct {
|
||||
@@ -76,6 +77,14 @@ func newHandler(coreURL, continuityURL, commitURL string, client *http.Client, c
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agentWorkerURL := "http://agent-worker:8090"
|
||||
if len(cognitiveURLs) > 1 {
|
||||
agentWorkerURL = cognitiveURLs[1]
|
||||
}
|
||||
agentWorker, err := parseRuntimeURL(agentWorkerURL, "Agent worker")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assets, err := fs.Sub(staticFiles, "static")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load embedded console: %w", err)
|
||||
@@ -96,7 +105,7 @@ func newHandler(coreURL, continuityURL, commitURL string, client *http.Client, c
|
||||
if len(revision) > 12 {
|
||||
revision = revision[:12]
|
||||
}
|
||||
return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, cognitive: cognitiveRuntime, client: client, static: http.FileServer(http.FS(assets)), build: buildMetadata{revision: revision, updated: updated}}), nil
|
||||
return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, cognitive: cognitiveRuntime, agentWorker: agentWorker, client: client, static: http.FileServer(http.FS(assets)), build: buildMetadata{revision: revision, updated: updated}}), nil
|
||||
}
|
||||
|
||||
func parseRuntimeURL(rawURL, name string) (*url.URL, error) {
|
||||
@@ -126,6 +135,14 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
g.handleChatMessage(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/api/fleet/readiness" {
|
||||
g.handleFleetReadiness(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/api/agent-worker/status" {
|
||||
g.handleAgentWorkerStatus(w, r)
|
||||
return
|
||||
}
|
||||
g.forwardAPI(w, r)
|
||||
return
|
||||
}
|
||||
@@ -133,7 +150,7 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/" || r.URL.Path == "/chat" || r.URL.Path == "/chat.html" {
|
||||
if r.URL.Path == "/" || r.URL.Path == "/login" || r.URL.Path == "/chat" || r.URL.Path == "/chat.html" {
|
||||
g.serveEmbeddedHTML(w, "chat.html")
|
||||
return
|
||||
}
|
||||
@@ -192,6 +209,7 @@ type browserChatResponse struct {
|
||||
CapabilityKind string `json:"capability_kind"`
|
||||
CapabilityHints []capability.CapabilityKind `json:"capability_hints,omitempty"`
|
||||
RequestedOutcome string `json:"requested_outcome,omitempty"`
|
||||
ResearchBacked bool `json:"research_backed"`
|
||||
InterpretationConfidenceMillis int64 `json:"interpretation_confidence_millis,omitempty"`
|
||||
DelegationID string `json:"delegation_id"`
|
||||
DelegationState string `json:"delegation_state"`
|
||||
@@ -248,6 +266,82 @@ func (g *gateway) handleAdvisoryPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(policy)
|
||||
}
|
||||
|
||||
func (g *gateway) handleFleetReadiness(w http.ResponseWriter, r *http.Request) {
|
||||
authorization := r.Header.Get("Authorization")
|
||||
if authorization == "" {
|
||||
http.Error(w, "creator session denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var session identity.CreatorSession
|
||||
status, err := g.getTrustedJSON(r, g.core, "/v1/creator/sessions/current", "", authorization, &session)
|
||||
if err != nil {
|
||||
http.Error(w, "creator session unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK || session.ActorID == "" || session.PrincipalID == "" {
|
||||
if status == 0 {
|
||||
status = http.StatusUnauthorized
|
||||
}
|
||||
http.Error(w, "creator session denied", status)
|
||||
return
|
||||
}
|
||||
target := *g.cognitive
|
||||
target.Path = "/v1/fleet/readiness"
|
||||
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, "fleet readiness unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
response, err := g.client.Do(request)
|
||||
if err != nil {
|
||||
http.Error(w, "fleet readiness unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
_, _ = io.Copy(w, io.LimitReader(response.Body, maxResponseBytes))
|
||||
}
|
||||
|
||||
func (g *gateway) handleAgentWorkerStatus(w http.ResponseWriter, r *http.Request) {
|
||||
authorization := r.Header.Get("Authorization")
|
||||
if authorization == "" {
|
||||
http.Error(w, "creator session denied", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var session identity.CreatorSession
|
||||
status, err := g.getTrustedJSON(r, g.core, "/v1/creator/sessions/current", "", authorization, &session)
|
||||
if err != nil {
|
||||
http.Error(w, "creator session unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK || session.ActorID == "" || session.PrincipalID == "" {
|
||||
if status == 0 {
|
||||
status = http.StatusUnauthorized
|
||||
}
|
||||
http.Error(w, "creator session denied", status)
|
||||
return
|
||||
}
|
||||
target := *g.agentWorker
|
||||
target.Path = "/status"
|
||||
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
http.Error(w, "agent worker status unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
response, err := g.client.Do(request)
|
||||
if err != nil {
|
||||
http.Error(w, "agent worker status unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
_, _ = io.Copy(w, io.LimitReader(response.Body, maxResponseBytes))
|
||||
}
|
||||
|
||||
func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) {
|
||||
var browserRequest browserAdvisoryRequest
|
||||
if err := decodeStrictJSON(r.Body, maxAdvisoryRequestBytes, &browserRequest); err != nil || browserRequest.Message == "" || len(browserRequest.Message) > 16*1024 || !validID(browserRequest.SessionID) || !validID(browserRequest.WorkspaceID) {
|
||||
@@ -408,6 +502,7 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) {
|
||||
CapabilityKind: string(saved.CapabilityKind),
|
||||
CapabilityHints: saved.CapabilityHints,
|
||||
RequestedOutcome: saved.RequestedOutcome,
|
||||
ResearchBacked: saved.ResearchBacked,
|
||||
InterpretationConfidenceMillis: saved.InterpretationConfidenceMillis,
|
||||
DelegationID: saved.DelegationID,
|
||||
DelegationState: saved.DelegationState,
|
||||
@@ -416,13 +511,18 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func localChatContext(saved interaction.ChatMessageResponse) []cognitive.ContextExcerpt {
|
||||
if strings.TrimSpace(saved.ActiveProjectName) == "" {
|
||||
return []cognitive.ContextExcerpt{}
|
||||
}
|
||||
bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{
|
||||
DataPolicy: capability.DataPolicyLocalOnly,
|
||||
MaxBytes: 2048,
|
||||
Candidates: []cognitive.ContextCandidate{{
|
||||
candidates := []cognitive.ContextCandidate{{
|
||||
Excerpt: cognitive.ContextExcerpt{
|
||||
ID: "assistant-identity",
|
||||
SourceKind: "assistant-identity-policy",
|
||||
SourceID: "domovoy",
|
||||
Sensitivity: cognitive.SensitivityPublic,
|
||||
Text: "Ты — Домовой, локальный помощник пользователя. Отвечай от имени Домового. Не называй себя Qwen, Alibaba Cloud, поставщиком модели или внутренним runtime. Если спрашивают имя, скажи: «Меня зовут Домовой». Твоя задача — понять смысл запроса, найти путь к цели и довести помощь до полезного конечного результата; не ограничивайся пересказом отказа модели.",
|
||||
},
|
||||
Relevance: 100,
|
||||
}}
|
||||
if strings.TrimSpace(saved.ActiveProjectName) != "" {
|
||||
candidates = append(candidates, cognitive.ContextCandidate{
|
||||
Excerpt: cognitive.ContextExcerpt{
|
||||
ID: cognitive.ContextExcerptID("active-project-focus"),
|
||||
SourceKind: "situation-active-project",
|
||||
@@ -430,8 +530,13 @@ func localChatContext(saved interaction.ChatMessageResponse) []cognitive.Context
|
||||
Sensitivity: cognitive.SensitivityPrivateAllowed,
|
||||
Text: "Текущий проект: " + strings.TrimSpace(saved.ActiveProjectName),
|
||||
},
|
||||
Relevance: 100,
|
||||
}},
|
||||
Relevance: 90,
|
||||
})
|
||||
}
|
||||
bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{
|
||||
DataPolicy: capability.DataPolicyLocalOnly,
|
||||
MaxBytes: 2048,
|
||||
Candidates: candidates,
|
||||
})
|
||||
if err != nil {
|
||||
return []cognitive.ContextExcerpt{}
|
||||
@@ -621,6 +726,16 @@ func creatorRoute(method, path string) (routeTarget, string, bool) {
|
||||
}
|
||||
case method == http.MethodGet && path == "/api/learning/recent":
|
||||
return targetCore, "/v1/creator/learning/recent", true
|
||||
case method == http.MethodGet && strings.HasPrefix(path, "/api/delegations/") && strings.HasSuffix(path, "/latest-checkpoint"):
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(path, "/api/delegations/"), "/latest-checkpoint")
|
||||
if validID(id) {
|
||||
return targetCore, "/v1/creator/delegations/" + id + "/latest-checkpoint", true
|
||||
}
|
||||
case method == http.MethodGet && strings.HasPrefix(path, "/api/delegations/"):
|
||||
id := strings.TrimPrefix(path, "/api/delegations/")
|
||||
if validID(id) {
|
||||
return targetCore, "/v1/creator/delegations/" + id, true
|
||||
}
|
||||
case method == http.MethodGet && strings.HasPrefix(path, "/api/prospective-items/"):
|
||||
id := strings.TrimPrefix(path, "/api/prospective-items/")
|
||||
if validID(id) {
|
||||
|
||||
@@ -13,13 +13,99 @@ import (
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
)
|
||||
|
||||
func TestLocalChatContextUsesOnlyActiveProject(t *testing.T) {
|
||||
func TestLocalChatContextIncludesDomovoyIdentityAndOptionalActiveProject(t *testing.T) {
|
||||
context := localChatContext(interaction.ChatMessageResponse{ActiveProjectName: "Домовой"})
|
||||
if len(context) != 1 || context[0].SourceKind != "situation-active-project" || context[0].Text != "Текущий проект: Домовой" {
|
||||
if len(context) != 2 || context[0].SourceID != "domovoy" || !strings.Contains(context[0].Text, "Ты — Домовой") || context[1].Text != "Текущий проект: Домовой" {
|
||||
t.Fatalf("unexpected local context: %#v", context)
|
||||
}
|
||||
if empty := localChatContext(interaction.ChatMessageResponse{}); len(empty) != 0 {
|
||||
t.Fatalf("expected empty context without active project: %#v", empty)
|
||||
if empty := localChatContext(interaction.ChatMessageResponse{}); len(empty) != 1 || empty[0].SourceID != "domovoy" {
|
||||
t.Fatalf("identity context must remain available without active project: %#v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorRouteAllowsOnlyBoundedDelegationReads(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"/api/delegations/delegation-1", "/v1/creator/delegations/delegation-1"},
|
||||
{"/api/delegations/delegation-1/latest-checkpoint", "/v1/creator/delegations/delegation-1/latest-checkpoint"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
_, got, ok := creatorRoute(http.MethodGet, test.path)
|
||||
if !ok || got != test.want {
|
||||
t.Fatalf("creatorRoute(%q) = %q, %t; want %q, true", test.path, got, ok, test.want)
|
||||
}
|
||||
}
|
||||
if _, _, ok := creatorRoute(http.MethodGet, "/api/delegations/../secrets"); ok {
|
||||
t.Fatal("path traversal-like delegation route must be denied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentWorkerStatusProxyRequiresCreatorSession(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/creator/sessions/current" || r.Header.Get("Authorization") != "Bearer bounded-session" {
|
||||
t.Fatalf("unexpected session request path=%s auth=%q", r.URL.Path, r.Header.Get("Authorization"))
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"ActorID":"creator-actor","PrincipalID":"creator-principal","ExpiresAtUnix":2000000000}`)
|
||||
}))
|
||||
defer core.Close()
|
||||
worker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/status" {
|
||||
t.Fatalf("unexpected worker path %s", r.URL.Path)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"runtime":"agent-worker","executor_configured":false,"mode":"recovery-only","ready":false,"reason":"bridge missing"}`)
|
||||
}))
|
||||
defer worker.Close()
|
||||
handler, err := newHandler(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), "http://cognitive.invalid", worker.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
denied := httptest.NewRecorder()
|
||||
handler.ServeHTTP(denied, httptest.NewRequest(http.MethodGet, "/api/agent-worker/status", nil))
|
||||
if denied.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated status=%d", denied.Code)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/agent-worker/status", nil)
|
||||
request.Header.Set("Authorization", "Bearer bounded-session")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"mode":"recovery-only"`) || response.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("response=%d cache=%q body=%s", response.Code, response.Header().Get("Cache-Control"), response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetReadinessRequiresCreatorSessionAndProxiesCognitiveState(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/creator/sessions/current" || r.Header.Get("Authorization") != "Bearer bounded-session" {
|
||||
t.Fatalf("unexpected session request path=%s auth=%q", r.URL.Path, r.Header.Get("Authorization"))
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"ActorID":"creator-actor","PrincipalID":"creator-principal","ExpiresAtUnix":2000000000}`)
|
||||
}))
|
||||
defer core.Close()
|
||||
cognitiveRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/fleet/readiness" {
|
||||
t.Fatalf("unexpected cognitive path %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"readiness":{"members":[{"provider_instance_id":"lifeboat","model_descriptor_id":"qwen","tier":"LIFEBOAT","ready":true,"reason":"AVAILABLE"}],"ready_tiers":["LIFEBOAT"],"fallback_tier":"LIFEBOAT"},"preferred_tier":"HEAVY","selected_tier":"LIFEBOAT"}`)
|
||||
}))
|
||||
defer cognitiveRuntime.Close()
|
||||
handler, err := newHandler(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), cognitiveRuntime.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
denied := httptest.NewRecorder()
|
||||
handler.ServeHTTP(denied, httptest.NewRequest(http.MethodGet, "/api/fleet/readiness", nil))
|
||||
if denied.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated status=%d", denied.Code)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/fleet/readiness", nil)
|
||||
request.Header.Set("Authorization", "Bearer bounded-session")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"selected_tier":"LIFEBOAT"`) || response.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("response=%d cache=%q body=%s", response.Code, response.Header().Get("Cache-Control"), response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +354,18 @@ func TestConsoleAndSecurityHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRouteServesChatShell(t *testing.T) {
|
||||
handler, err := newHandler("http://core-runtime:8080", "http://continuity-runtime:8081", "http://commit-runtime:8082", http.DefaultClient)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/login", nil))
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "id=\"username\"") || !strings.Contains(response.Body.String(), "id=\"password\"") {
|
||||
t.Fatalf("login response = %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvisoryModeRejectsInvalidConfiguration(t *testing.T) {
|
||||
_, err := newHandlerWithAdvisoryMode("http://core.invalid", "http://continuity.invalid", "http://commit.invalid", http.DefaultClient, "browser-selected")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid advisory binding mode") {
|
||||
|
||||
@@ -29,7 +29,7 @@ textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px;
|
||||
.login-form button, .composer button { border: 0; color: #11150f; background: var(--signal); font-weight: 700; }
|
||||
.login-form button { width: 100%; height: 50px; margin-top: 18px; }
|
||||
#login-error { min-height: 20px; color: var(--bad); font-size: 13px; }
|
||||
.chat-view { height: 100vh; min-height: 0; display: grid; grid-template-rows: 64px minmax(0, 1fr); overflow: hidden; }
|
||||
.chat-view { width: 100%; min-width: 0; height: 100vh; min-height: 0; display: grid; grid-template-rows: 64px minmax(0, 1fr); overflow: hidden; }
|
||||
.chat-header { min-width: 0; display: flex; align-items: center; gap: 14px; padding: 0 22px; border-bottom: 1px solid var(--line); background: rgba(16, 23, 20, .98); }
|
||||
.chat-header a { color: var(--ink); text-decoration: none; font: 400 24px/1 var(--serif); }
|
||||
#active-focus { color: var(--good); }
|
||||
@@ -107,9 +107,10 @@ textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px;
|
||||
.conversation-starters { align-items: stretch; }
|
||||
.conversation-starters span { width: 100%; }
|
||||
.chat-header { gap: 10px; padding: 0 12px; }
|
||||
.chat-header .cognitive-status { display: inline-flex; max-width: 150px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.build-meta { font-size: 8px; letter-spacing: .04em; }
|
||||
.build-meta time { font-size: 7px; }
|
||||
.chat-header .cognitive-status { display: inline-flex; flex: 1 1 110px; min-width: 0; max-width: 110px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-header .cognitive-console-link { flex: 0 1 110px; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-header > .operator-link, .build-meta { display: none; }
|
||||
.chat-header > #logout { flex: 0 0 38px; }
|
||||
.chat-layout { height: auto; display: block; }
|
||||
.chat-side, .chat-main { min-height: auto; max-height: none; overflow: visible; }
|
||||
.chat-left, .chat-right { border: 0; border-bottom: 1px solid var(--line); }
|
||||
|
||||
@@ -253,6 +253,40 @@ html[data-reduced-motion="true"] *, html[data-reduced-motion="true"] *::before,
|
||||
.activity-reconcile:disabled { opacity: .55; cursor: wait; }
|
||||
.activity-error { color: var(--bad); }
|
||||
|
||||
.fleet-panel { margin-bottom: 18px; border: 1px solid rgba(201,168,95,.32); background: linear-gradient(135deg, rgba(31,27,18,.92), rgba(13,20,17,.96)); box-shadow: inset 3px 0 0 rgba(201,168,95,.45); }
|
||||
.fleet-panel > header { min-height: 66px; padding: 13px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); }
|
||||
.fleet-panel header p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; }
|
||||
.fleet-panel h2 { margin: 0; font: 400 22px/1 var(--serif); }
|
||||
.fleet-overview { padding: 16px 18px; display: flex; align-items: end; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line-soft); }
|
||||
.fleet-overview span { display: block; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
|
||||
.fleet-overview strong { display: block; margin-top: 6px; color: var(--signal); font: 600 16px/1 var(--mono); }
|
||||
.fleet-overview p { max-width: 620px; margin: 0; color: var(--muted); font: 10px/1.45 var(--mono); }
|
||||
.fleet-list { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.fleet-list[aria-busy="true"] { opacity: .72; }
|
||||
.fleet-row { min-width: 0; padding: 14px 18px; display: grid; gap: 7px; border-right: 1px solid var(--line-soft); }
|
||||
.fleet-row:last-child { border-right: 0; }
|
||||
.fleet-row strong, .fleet-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fleet-row strong { color: var(--ink); font: 600 11px/1.3 var(--mono); }
|
||||
.fleet-row span { color: var(--muted); font: 9px/1.4 var(--mono); }
|
||||
.fleet-row .learning-state[data-state="READY"] { color: var(--good); }
|
||||
.fleet-row .learning-state[data-state="DEGRADED"] { color: var(--warn); }
|
||||
|
||||
.agent-panel { margin-bottom: 18px; border: 1px solid rgba(120,198,163,.3); background: rgba(13,20,17,.92); box-shadow: inset 3px 0 0 rgba(120,198,163,.38); }
|
||||
.agent-panel > header { min-height: 66px; padding: 13px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); }
|
||||
.agent-panel header p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; }
|
||||
.agent-panel h2 { margin: 0; font: 400 22px/1 var(--serif); }
|
||||
.agent-empty { padding: 20px 18px; color: var(--muted); font: 10px/1.5 var(--mono); }
|
||||
.agent-details { padding: 16px 18px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }
|
||||
.agent-details div { min-width: 0; }
|
||||
.agent-details span { display: block; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
|
||||
.agent-details strong { display: block; margin-top: 7px; overflow: hidden; color: var(--ink); font: 600 10px/1.4 var(--mono); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.agent-details p { grid-column: 1 / -1; margin: 0; padding-top: 12px; border-top: 1px solid var(--line-soft); color: var(--muted); font: 10px/1.5 var(--mono); }
|
||||
.agent-worker-status { margin: 0 18px 16px; padding-top: 13px; display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; border-top: 1px solid var(--line-soft); }
|
||||
.agent-worker-status span { color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
|
||||
.agent-worker-status strong { color: var(--warn); font: 600 10px/1.3 var(--mono); }
|
||||
.agent-worker-status strong[data-state="ready"] { color: var(--good); }
|
||||
.agent-worker-status p { grid-column: 1 / -1; margin: 0; color: var(--muted); font: 10px/1.45 var(--mono); }
|
||||
|
||||
.learning-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.learning-grid article { min-width: 0; border-right: 1px solid var(--line-soft); }
|
||||
.learning-grid article:last-child { border-right: 0; }
|
||||
@@ -404,6 +438,12 @@ html[data-reduced-motion="true"] *, html[data-reduced-motion="true"] *::before,
|
||||
.learning-grid { grid-template-columns: 1fr; }
|
||||
.learning-grid article { border-right: 0; border-bottom: 1px solid var(--line-soft); }
|
||||
.learning-grid article:last-child { border-bottom: 0; }
|
||||
.fleet-overview { align-items: flex-start; flex-direction: column; }
|
||||
.fleet-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.fleet-row:nth-child(2n) { border-right: 0; }
|
||||
.fleet-row { border-bottom: 1px solid var(--line-soft); }
|
||||
.agent-details { grid-template-columns: 1fr; gap: 14px; }
|
||||
.agent-details p { grid-column: auto; }
|
||||
.activity-row { padding: 14px 16px; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 12px; }
|
||||
.activity-state { grid-column: 2; grid-row: 1; }
|
||||
.activity-operation { grid-column: 1; grid-row: 1; }
|
||||
|
||||
@@ -6,7 +6,7 @@ const contextForm = byId("context-form");
|
||||
const advisoryForm = byId("advisory-form");
|
||||
const loginError = byId("login-error");
|
||||
const toast = byId("toast");
|
||||
const contextFields = ["session-id", "workspace-id", "task-id", "prospective-id", "capability-id"];
|
||||
const contextFields = ["session-id", "workspace-id", "task-id", "prospective-id", "capability-id", "delegation-id"];
|
||||
const settingsStorageKey = "domovoy.creator.settings";
|
||||
const contextPresetsStorageKey = "domovoy.creator.context.presets";
|
||||
let sessionToken = sessionStorage.getItem("domovoy.creator.session") || "";
|
||||
@@ -172,6 +172,9 @@ async function showConsole() {
|
||||
else {
|
||||
loadActionHistory();
|
||||
loadLearning();
|
||||
loadFleetReadiness();
|
||||
loadAgentDelegation();
|
||||
loadAgentWorkerStatus();
|
||||
}
|
||||
runDiagnostics();
|
||||
}
|
||||
@@ -353,6 +356,8 @@ byId("reset-settings").addEventListener("click", () => {
|
||||
byId("refresh-all").addEventListener("click", loadDashboard);
|
||||
byId("refresh-actions").addEventListener("click", loadActionHistory);
|
||||
byId("refresh-learning").addEventListener("click", loadLearning);
|
||||
byId("refresh-fleet").addEventListener("click", loadFleetReadiness);
|
||||
byId("refresh-agent").addEventListener("click", loadAgentDelegation);
|
||||
byId("logout").addEventListener("click", () => endSession());
|
||||
byId("run-diagnostics").addEventListener("click", runDiagnostics);
|
||||
|
||||
@@ -442,6 +447,9 @@ async function loadDashboard() {
|
||||
work.push(loadProspectiveRegister(context["prospective-id"]));
|
||||
work.push(loadActionHistory());
|
||||
work.push(loadLearning());
|
||||
work.push(loadFleetReadiness());
|
||||
work.push(loadAgentDelegation());
|
||||
work.push(loadAgentWorkerStatus());
|
||||
if (context["capability-id"]) work.push(loadCapability(context["capability-id"]));
|
||||
else setSignal("capability", "не выбрана", "idle");
|
||||
|
||||
@@ -500,6 +508,93 @@ function createDiagnosticItem(label, key, state, detail = "Проверяю…")
|
||||
return item;
|
||||
}
|
||||
|
||||
async function loadFleetReadiness() {
|
||||
const list = byId("fleet-list");
|
||||
list.setAttribute("aria-busy", "true");
|
||||
list.replaceChildren();
|
||||
const loading = document.createElement("p");
|
||||
loading.className = "register-empty";
|
||||
loading.textContent = "Проверяю доступность model fleet…";
|
||||
list.append(loading);
|
||||
try {
|
||||
const result = await request("/api/fleet/readiness");
|
||||
const readiness = result.readiness || {};
|
||||
const members = Array.isArray(readiness.members) ? readiness.members : [];
|
||||
setText("fleet-selected", result.selected_tier || "не выбран");
|
||||
setText("fleet-summary", `${members.filter((member) => member.ready).length}/${members.length} ГОТОВЫ`);
|
||||
setText("fleet-explanation", result.selected_tier ? `Активный маршрут: ${result.selected_tier}. Остальные tier остаются видимыми с их фактической причиной.` : "Ни один tier не выбран: проверьте конфигурацию и health провайдеров.");
|
||||
list.replaceChildren(...members.map(createFleetRow));
|
||||
} catch (error) {
|
||||
setText("fleet-selected", "недоступен");
|
||||
setText("fleet-summary", "ОШИБКА");
|
||||
setText("fleet-explanation", safeClientError(error));
|
||||
const failed = document.createElement("p");
|
||||
failed.className = "register-empty activity-error";
|
||||
failed.textContent = safeClientError(error);
|
||||
list.replaceChildren(failed);
|
||||
} finally {
|
||||
list.setAttribute("aria-busy", "false");
|
||||
}
|
||||
}
|
||||
|
||||
function createFleetRow(member) {
|
||||
const row = document.createElement("article");
|
||||
row.className = "fleet-row";
|
||||
const state = document.createElement("em");
|
||||
state.className = "learning-state";
|
||||
state.dataset.state = member.ready ? "READY" : "DEGRADED";
|
||||
state.textContent = member.ready ? "READY" : "DEGRADED";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = member.tier || "UNKNOWN TIER";
|
||||
const detail = document.createElement("span");
|
||||
detail.textContent = `${member.model_descriptor_id || "model"} · ${member.reason || "причина не указана"}`;
|
||||
row.append(state, title, detail);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function loadAgentDelegation() {
|
||||
const delegationID = byId("delegation-id").value.trim();
|
||||
const empty = byId("agent-empty");
|
||||
const details = byId("agent-details");
|
||||
if (!delegationID) {
|
||||
empty.hidden = false;
|
||||
details.hidden = true;
|
||||
setText("agent-summary", "DELEGATION НЕ ВЫБРАНА");
|
||||
return;
|
||||
}
|
||||
empty.hidden = false;
|
||||
empty.textContent = "Читаю durable delegation…";
|
||||
details.hidden = true;
|
||||
try {
|
||||
const delegation = await request(`/api/delegations/${encodeURIComponent(delegationID)}`);
|
||||
const checkpoint = await request(`/api/delegations/${encodeURIComponent(delegationID)}/latest-checkpoint`).catch(() => ({}));
|
||||
setText("agent-summary", delegation.State || "СОСТОЯНИЕ НЕИЗВЕСТНО");
|
||||
setText("agent-delegation-state", delegation.State);
|
||||
setText("agent-run-state", `${delegation.LatestRunID || "run не выбран"} · ${delegation.SelectedImplementationID || "implementation не указана"}`);
|
||||
setText("agent-blocker", checkpoint.BlockerClass || "нет checkpoint");
|
||||
setText("agent-next-step", checkpoint.NextStep || checkpoint.RemainingSummary || delegation.DesiredOutcome || "Следующий шаг не записан.");
|
||||
empty.hidden = true;
|
||||
details.hidden = false;
|
||||
} catch (error) {
|
||||
setText("agent-summary", "ОШИБКА");
|
||||
empty.textContent = safeClientError(error);
|
||||
empty.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAgentWorkerStatus() {
|
||||
try {
|
||||
const status = await request("/api/agent-worker/status");
|
||||
setText("agent-worker-state", status.mode || "UNKNOWN");
|
||||
setText("agent-worker-message", status.reason || "Причина не указана.");
|
||||
byId("agent-worker-state").dataset.state = status.ready === true ? "ready" : "degraded";
|
||||
} catch (error) {
|
||||
setText("agent-worker-state", "НЕДОСТУПЕН");
|
||||
setText("agent-worker-message", safeClientError(error));
|
||||
byId("agent-worker-state").dataset.state = "degraded";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLearning() {
|
||||
const experienceList = byId("experience-list");
|
||||
const reflectionList = byId("reflection-list");
|
||||
|
||||
@@ -94,6 +94,8 @@
|
||||
<input id="prospective-id" autocomplete="off" placeholder="prospective-item-id">
|
||||
<label for="capability-id">Возможность</label>
|
||||
<input id="capability-id" autocomplete="off" value="cognitive-proposal">
|
||||
<label for="delegation-id">Delegation / агентская работа</label>
|
||||
<input id="delegation-id" autocomplete="off" placeholder="delegation-id из чата">
|
||||
</div>
|
||||
</details>
|
||||
<div class="context-actions"><button class="secondary-button" type="submit">Загрузить контекст</button><button id="reset-context" class="text-button" type="button">Сбросить</button></div>
|
||||
@@ -181,6 +183,30 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fleet-panel" aria-labelledby="fleet-title" data-page="operations">
|
||||
<header>
|
||||
<div><p>Cognitive / capability health</p><h2 id="fleet-title">Модели и fallback</h2></div>
|
||||
<div class="activity-tools"><span id="fleet-summary">СОСТОЯНИЕ НЕ ЗАГРУЖЕНО</span><button id="refresh-fleet" class="icon-button" type="button" aria-label="Обновить состояние моделей" title="Обновить состояние моделей">↻</button></div>
|
||||
</header>
|
||||
<div class="fleet-overview"><div><span>Выбранный tier</span><strong id="fleet-selected">—</strong></div><p id="fleet-explanation">Проверка доступности провайдеров ещё не выполнялась.</p></div>
|
||||
<div id="fleet-list" class="fleet-list" aria-live="polite" aria-busy="false"><p class="register-empty">Состояние fleet ещё не загружено.</p></div>
|
||||
</section>
|
||||
|
||||
<section class="agent-panel" aria-labelledby="agent-title" data-page="operations">
|
||||
<header>
|
||||
<div><p>Work / durable execution</p><h2 id="agent-title">Агентская работа</h2></div>
|
||||
<div class="activity-tools"><span id="agent-summary">DELEGATION НЕ ВЫБРАНА</span><button id="refresh-agent" class="icon-button" type="button" aria-label="Обновить агентскую работу" title="Обновить агентскую работу">↻</button></div>
|
||||
</header>
|
||||
<div id="agent-empty" class="agent-empty">Укажите delegation ID в расширенных идентификаторах, чтобы прочитать durable state.</div>
|
||||
<div id="agent-details" class="agent-details" hidden>
|
||||
<div><span>Состояние delegation</span><strong id="agent-delegation-state">—</strong></div>
|
||||
<div><span>Run / implementation</span><strong id="agent-run-state">—</strong></div>
|
||||
<div><span>Последний blocker</span><strong id="agent-blocker">—</strong></div>
|
||||
<p id="agent-next-step">—</p>
|
||||
</div>
|
||||
<div class="agent-worker-status"><span>Executor capability</span><strong id="agent-worker-state">ПРОВЕРКА НЕ ВЫПОЛНЯЛАСЬ</strong><p id="agent-worker-message">Статус worker виден только в авторизованной диагностике.</p></div>
|
||||
</section>
|
||||
|
||||
<section class="activity-panel" aria-labelledby="activity-title" data-page="operations">
|
||||
<header>
|
||||
<div><p>Commit / владелец</p><h2 id="activity-title">Журнал действий</h2></div>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||
)
|
||||
|
||||
func main() {
|
||||
workerID := flag.String("worker-id", "reflection-worker-1", "stable worker identity")
|
||||
lease := flag.Duration("lease-ttl", 5*time.Minute, "candidate claim lease")
|
||||
pollInterval := flag.Duration("poll-interval", 15*time.Second, "database-owned idle wake-up interval")
|
||||
once := flag.Bool("once", false, "run one cycle and exit")
|
||||
flag.Parse()
|
||||
ctx := context.Background()
|
||||
password, err := resolveDatabasePassword("EPISTEMICS_POSTGRES_PASSWORD", "EPISTEMICS_POSTGRES_CREDENTIAL_REF")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
config := postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("EPISTEMICS_POSTGRES_USER"), Password: password}
|
||||
repository, err := postgres.OpenEpistemics(ctx, config)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer repository.Close()
|
||||
status := newWorkerStatus(*workerID, *once)
|
||||
listenAddr := os.Getenv("REFLECTION_LISTEN_ADDR")
|
||||
if listenAddr == "" {
|
||||
listenAddr = ":8092"
|
||||
}
|
||||
go func() {
|
||||
if err := http.ListenAndServe(listenAddr, status.handler()); err != nil {
|
||||
log.Printf("reflection status server stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
for {
|
||||
now := time.Now().UnixMilli()
|
||||
status.cycleStarted(time.Now())
|
||||
if _, err := repository.RecoverReflectionClaims(ctx, now); err != nil {
|
||||
status.cycleFailed(time.Now(), err)
|
||||
panic(err)
|
||||
}
|
||||
candidate, claimErr := repository.ClaimReflectionCandidate(ctx, *workerID, now, lease.Milliseconds())
|
||||
if claimErr == nil {
|
||||
// This worker only closes the durable evaluation loop. Any cognitive
|
||||
// proposal remains a candidate and must be admitted by a separate governed flow.
|
||||
if err := repository.ResolveReflectionCandidate(ctx, candidate.ID, *workerID, time.Now().UnixMilli()); err != nil {
|
||||
status.cycleFailed(time.Now(), err)
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("resolved reflection candidate %s\n", candidate.ID)
|
||||
} else if *once {
|
||||
fmt.Println("no reflection candidate")
|
||||
}
|
||||
status.cycleFinished(time.Now(), map[bool]string{true: "candidate-resolved", false: "idle"}[claimErr == nil])
|
||||
if *once {
|
||||
return
|
||||
}
|
||||
if err := repository.WaitForReflectionCandidate(ctx, *pollInterval); err != nil {
|
||||
status.cycleFailed(time.Now(), err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resolveDatabasePassword(name, refName string) (string, error) {
|
||||
if _, ok := os.LookupEnv(name); ok {
|
||||
return "", fmt.Errorf("%s direct environment secret is forbidden", name)
|
||||
}
|
||||
path, ref := os.Getenv(name+"_FILE"), os.Getenv(refName)
|
||||
if path == "" || ref == "" {
|
||||
return "", fmt.Errorf("%s_FILE and %s are required", name, refName)
|
||||
}
|
||||
root := os.Getenv("SECRET_BACKEND_ROOT")
|
||||
if root == "" {
|
||||
root = "/run/secrets"
|
||||
}
|
||||
resolver, err := platformsecrets.NewFileResolver(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: path})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
)
|
||||
|
||||
type workerStatus struct {
|
||||
mu sync.RWMutex
|
||||
workerID string
|
||||
once bool
|
||||
startedAt time.Time
|
||||
lastCycleAt time.Time
|
||||
lastCompleted time.Time
|
||||
lastResult string
|
||||
lastError string
|
||||
}
|
||||
|
||||
func newWorkerStatus(workerID string, once bool) *workerStatus {
|
||||
return &workerStatus{workerID: workerID, once: once, startedAt: time.Now().UTC()}
|
||||
}
|
||||
|
||||
func (s *workerStatus) cycleStarted(at time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lastCycleAt = at.UTC()
|
||||
s.lastResult = "running"
|
||||
s.lastError = ""
|
||||
}
|
||||
|
||||
func (s *workerStatus) cycleFinished(at time.Time, result string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lastCompleted = at.UTC()
|
||||
s.lastResult = result
|
||||
s.lastError = ""
|
||||
}
|
||||
|
||||
func (s *workerStatus) cycleFailed(at time.Time, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lastCompleted = at.UTC()
|
||||
s.lastResult = "failed"
|
||||
s.lastError = err.Error()
|
||||
}
|
||||
|
||||
func (s *workerStatus) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/healthz", health.Handler{Runtime: "reflection-worker", SemanticGeneration: 1, ArchitectureGeneration: 1})
|
||||
mux.HandleFunc("/status", func(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.RLock()
|
||||
response := struct {
|
||||
Runtime string `json:"runtime"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
Mode string `json:"mode"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
LastCycleAt time.Time `json:"last_cycle_at,omitempty"`
|
||||
LastCompleted time.Time `json:"last_completed_at,omitempty"`
|
||||
LastResult string `json:"last_result"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}{Runtime: "reflection-worker", WorkerID: s.workerID, Mode: "durable-reflection-loop", StartedAt: s.startedAt, LastCycleAt: s.lastCycleAt, LastCompleted: s.lastCompleted, LastResult: s.lastResult, LastError: s.lastError}
|
||||
if s.once {
|
||||
response.Mode = "durable-reflection-once"
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
})
|
||||
return mux
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWorkerStatusReportsCycle(t *testing.T) {
|
||||
status := newWorkerStatus("test-worker", false)
|
||||
status.cycleStarted(time.Date(2026, 8, 23, 10, 0, 0, 0, time.UTC))
|
||||
status.cycleFinished(time.Date(2026, 8, 23, 10, 0, 1, 0, time.UTC), "idle")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
status.handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/status", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status code = %d, want 200", recorder.Code)
|
||||
}
|
||||
body := recorder.Body.String()
|
||||
for _, expected := range []string{"\"runtime\":\"reflection-worker\"", "\"worker_id\":\"test-worker\"", "\"last_result\":\"idle\""} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("status body missing %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerHealthIsAvailable(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
newWorkerStatus("test-worker", true).handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("health status code = %d, want 200", recorder.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
researchadapter "git.cin.su/m/Assistant/adapters/research"
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
"git.cin.su/m/Assistant/modules/epistemics/application"
|
||||
"git.cin.su/m/Assistant/platform/secrets"
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
password, err := resolveDatabasePassword()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
repository, err := postgres.OpenEpistemics(ctx, postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("EPISTEMICS_POSTGRES_USER"), Password: password})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer repository.Close()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/healthz", health.Handler{Runtime: "research-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
|
||||
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "research-runtime", Probes: []health.Probe{{Name: "epistemics-postgres", Check: func(ctx context.Context) error { return repository.Ping(ctx) }}}})
|
||||
mux.Handle("/v1/research/status", truthRuntimeStatusHandler(repository))
|
||||
mux.HandleFunc("POST /v1/research/evaluate", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request epistemics.ResearchEvaluationRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid research evidence", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := application.EvaluateAndPersist(r.Context(), request, repository, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
connector := researchadapter.RepositoryConnector{Root: os.Getenv("RESEARCH_SOURCE_ROOT")}
|
||||
mux.HandleFunc("POST /v1/research/repository/evaluate", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request epistemics.RepositoryResearchRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128<<10)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid repository research request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
candidate, err := connector.Collect(r.Context(), request, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
result, err := application.EvaluateAndPersist(r.Context(), epistemics.ResearchEvaluationRequest{Plan: request.Plan, Candidates: []epistemics.ResearchCandidate{candidate}}, repository, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
webConnector := researchadapter.HTTPConnector{AllowedHosts: allowedHosts(os.Getenv("RESEARCH_HTTP_ALLOWED_HOSTS")), Client: &http.Client{Timeout: 5 * time.Second}}
|
||||
mux.HandleFunc("POST /v1/research/web/evaluate", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request epistemics.WebResearchRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128<<10)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid web research request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
candidate, err := webConnector.Collect(r.Context(), request, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
result, err := application.EvaluateAndPersist(r.Context(), epistemics.ResearchEvaluationRequest{Plan: request.Plan, Candidates: []epistemics.ResearchCandidate{candidate}}, repository, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
searchConnector := researchadapter.SearchConnector{
|
||||
EndpointTemplate: os.Getenv("RESEARCH_SEARCH_URL_TEMPLATE"),
|
||||
AllowedHosts: allowedHosts(os.Getenv("RESEARCH_SEARCH_ALLOWED_HOSTS")),
|
||||
FallbackEndpoints: []researchadapter.SearchEndpoint{{
|
||||
Template: "https://html.duckduckgo.com/html/?q=%s",
|
||||
AllowedHosts: map[string]struct{}{"html.duckduckgo.com": {}},
|
||||
}},
|
||||
Client: &http.Client{Timeout: 8 * time.Second},
|
||||
MaxResults: 3,
|
||||
}
|
||||
mux.HandleFunc("POST /v1/research/web/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request epistemics.WebSearchRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid web search request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
candidates, err := searchConnector.Collect(r.Context(), request, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
result, err := application.EvaluateAndPersist(r.Context(), epistemics.ResearchEvaluationRequest{Plan: request.Plan, Candidates: candidates}, repository, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
addr := os.Getenv("LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8087"
|
||||
}
|
||||
log.Printf("research-runtime listening on %s", addr)
|
||||
log.Fatal(http.ListenAndServe(addr, mux))
|
||||
}
|
||||
|
||||
func allowedHosts(value string) map[string]struct{} {
|
||||
result := map[string]struct{}{}
|
||||
for _, host := range strings.Split(value, ",") {
|
||||
if host = strings.ToLower(strings.TrimSpace(host)); host != "" {
|
||||
result[host] = struct{}{}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func resolveDatabasePassword() (string, error) {
|
||||
if _, ok := os.LookupEnv("EPISTEMICS_POSTGRES_PASSWORD"); ok {
|
||||
return "", fmt.Errorf("EPISTEMICS_POSTGRES_PASSWORD direct environment secret is forbidden")
|
||||
}
|
||||
path, ref := os.Getenv("EPISTEMICS_POSTGRES_PASSWORD_FILE"), os.Getenv("EPISTEMICS_POSTGRES_CREDENTIAL_REF")
|
||||
if path == "" || ref == "" {
|
||||
return "", fmt.Errorf("EPISTEMICS_POSTGRES_PASSWORD_FILE and EPISTEMICS_POSTGRES_CREDENTIAL_REF are required")
|
||||
}
|
||||
root := os.Getenv("SECRET_BACKEND_ROOT")
|
||||
if root == "" {
|
||||
root = "/run/secrets"
|
||||
}
|
||||
resolver, err := secrets.NewFileResolver(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: path})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
)
|
||||
|
||||
type truthRuntimeStatusReader interface {
|
||||
TruthRuntimeStatus(context.Context) (postgres.TruthRuntimeStatus, error)
|
||||
}
|
||||
|
||||
type truthRuntimeStatusResponse struct {
|
||||
Runtime string `json:"runtime"`
|
||||
Status string `json:"status"`
|
||||
BundleCount int64 `json:"bundle_count"`
|
||||
ClaimsByVerification map[string]int64 `json:"claims_by_verification"`
|
||||
BeliefsByState map[string]int64 `json:"beliefs_by_state"`
|
||||
SourcesByFreshness map[string]int64 `json:"sources_by_freshness"`
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
}
|
||||
|
||||
func truthRuntimeStatusHandler(reader truthRuntimeStatusReader) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/v1/research/status" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
status, err := reader.TruthRuntimeStatus(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "research status unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(truthRuntimeStatusResponse{
|
||||
Runtime: "research-runtime",
|
||||
Status: "ready",
|
||||
BundleCount: status.BundleCount,
|
||||
ClaimsByVerification: status.ClaimsByVerification,
|
||||
BeliefsByState: status.BeliefsByState,
|
||||
SourcesByFreshness: status.SourcesByFreshness,
|
||||
ObservedAtUnixMillis: status.ObservedAtUnixMillis,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
)
|
||||
|
||||
type fakeTruthRuntimeStatusReader struct {
|
||||
status postgres.TruthRuntimeStatus
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeTruthRuntimeStatusReader) TruthRuntimeStatus(context.Context) (postgres.TruthRuntimeStatus, error) {
|
||||
return f.status, f.err
|
||||
}
|
||||
|
||||
func TestTruthRuntimeStatusReportsEvidenceCounts(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
truthRuntimeStatusHandler(fakeTruthRuntimeStatusReader{status: postgres.TruthRuntimeStatus{
|
||||
BundleCount: 4,
|
||||
ClaimsByVerification: map[string]int64{"UNVERIFIED": 3, "VERIFIED": 1},
|
||||
BeliefsByState: map[string]int64{"PROVISIONAL": 4},
|
||||
SourcesByFreshness: map[string]int64{"FRESH": 4},
|
||||
ObservedAtUnixMillis: 1700000000000,
|
||||
}}).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/v1/research/status", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status code = %d", response.Code)
|
||||
}
|
||||
for _, expected := range []string{"\"status\":\"ready\"", "\"bundle_count\":4", "\"VERIFIED\":1", "\"PROVISIONAL\":4", "1700000000000"} {
|
||||
if !strings.Contains(response.Body.String(), expected) {
|
||||
t.Fatalf("response missing %q: %s", expected, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruthRuntimeStatusFailsClosedOnDatabaseError(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
truthRuntimeStatusHandler(fakeTruthRuntimeStatusReader{err: errors.New("database unavailable")}).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/v1/research/status", nil))
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status code = %d", response.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
const verifierIdentity = "deterministic-task-verifier-v1"
|
||||
const genericVerifierIdentity = "deterministic-generic-envelope-verifier-v1"
|
||||
|
||||
func main() {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"runtime":"task-verifier","status":"ok"}`))
|
||||
})
|
||||
mux.HandleFunc("POST /v1/task-evaluations/verify", verifyHandler)
|
||||
mux.HandleFunc("POST /v1/work-graphs/candidates/verify", genericCandidateVerifyHandler)
|
||||
addr := ":8091"
|
||||
if value := strings.TrimSpace(os.Getenv("LISTEN_ADDR")); value != "" {
|
||||
addr = value
|
||||
}
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func genericCandidateVerifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var request work.GenericCandidateVerificationRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || request.GraphID == "" || request.GraphRevision < 1 || request.NodeID == "" || request.AuthorRunID == "" || request.CandidateID == "" || strings.TrimSpace(request.Summary) == "" || len(request.EvidenceRefs) == 0 {
|
||||
http.Error(w, "invalid generic candidate envelope", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
payload := fmt.Sprintf("%s|%s|%d|%s|%s|%s|%s", genericVerifierIdentity, request.GraphID, request.GraphRevision, request.NodeID, request.AuthorRunID, request.CandidateID, strings.Join(evidenceRefStrings(request.EvidenceRefs), "|"))
|
||||
digest := sha256.Sum256([]byte(payload))
|
||||
writeJSON(w, work.GenericCandidateVerificationResponse{Verified: true, VerificationEvidenceRef: "verification:" + hex.EncodeToString(digest[:]), VerifierIdentity: genericVerifierIdentity, Summary: "candidate attribution and evidence envelope verified; semantic content remains unverified"})
|
||||
}
|
||||
|
||||
func evidenceRefStrings(values []work.EvidenceRef) []string {
|
||||
result := make([]string, len(values))
|
||||
for i, value := range values {
|
||||
result[i] = string(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func verifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var request capability.TaskCaseVerificationRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || request.CaseID == "" || request.ResponseText == "" || request.EvidenceArtifactRef == "" || request.LatencyMicros < 0 || (request.Verification.Mode != capability.TaskCaseVerificationExactText && request.Verification.Mode != capability.TaskCaseVerificationJSONFieldEquals) || (request.Verification.Mode == capability.TaskCaseVerificationJSONFieldEquals && request.Verification.FieldPath == "") || request.Verification.ExpectedText == "" || request.Verification.MinimumQualityMillis < 0 || request.Verification.MinimumQualityMillis > 1000 {
|
||||
http.Error(w, "invalid task verification request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
matched := matches(request)
|
||||
quality := int64(0)
|
||||
result := capability.EvaluationUnsupported
|
||||
if matched {
|
||||
quality = request.Verification.MinimumQualityMillis
|
||||
if quality == 0 {
|
||||
quality = 1000
|
||||
}
|
||||
result = capability.EvaluationSupported
|
||||
}
|
||||
evidence := verificationEvidence(request, matched)
|
||||
writeJSON(w, capability.TaskCaseVerificationResponse{CaseID: request.CaseID, Result: result, QualityScoreMillis: quality, LatencyMicros: request.LatencyMicros, EvidenceArtifactRef: request.EvidenceArtifactRef, VerificationEvidenceRef: evidence, VerifierIdentity: verifierIdentity})
|
||||
}
|
||||
|
||||
func verificationEvidence(request capability.TaskCaseVerificationRequest, matched bool) string {
|
||||
payload := fmt.Sprintf("%s|%s|%s|%s|%s|%t", verifierIdentity, request.CaseID, request.EvidenceArtifactRef, request.Verification.Mode, request.Verification.FieldPath, matched)
|
||||
digest := sha256.Sum256([]byte(payload))
|
||||
return "verification:" + hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func matches(request capability.TaskCaseVerificationRequest) bool {
|
||||
if request.Verification.Mode == capability.TaskCaseVerificationExactText {
|
||||
return strings.TrimSpace(request.ResponseText) == strings.TrimSpace(request.Verification.ExpectedText)
|
||||
}
|
||||
var document map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(request.ResponseText), &document) != nil {
|
||||
return false
|
||||
}
|
||||
value, ok := document[request.Verification.FieldPath]
|
||||
return ok && strings.TrimSpace(string(value)) == strings.TrimSpace(request.Verification.ExpectedText)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
func TestVerifyExactTextProducesIndependentEvidence(t *testing.T) {
|
||||
payload, _ := json.Marshal(capability.TaskCaseVerificationRequest{CaseID: "case-1", ResponseText: " answer ", EvidenceArtifactRef: "case-artifact", Verification: capability.TaskCaseVerification{Mode: capability.TaskCaseVerificationExactText, ExpectedText: "answer", MinimumQualityMillis: 900}})
|
||||
recorder := httptest.NewRecorder()
|
||||
verifyHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/task-evaluations/verify", bytes.NewReader(payload)))
|
||||
var response capability.TaskCaseVerificationResponse
|
||||
if recorder.Code != http.StatusOK || json.NewDecoder(recorder.Body).Decode(&response) != nil || response.Result != capability.EvaluationSupported || response.QualityScoreMillis != 900 || response.VerifierIdentity == "" || response.VerificationEvidenceRef == "" {
|
||||
t.Fatalf("unexpected verification response: code=%d body=%s value=%+v", recorder.Code, recorder.Body.String(), response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyMismatchIsUnsupportedWithEvidence(t *testing.T) {
|
||||
payload, _ := json.Marshal(capability.TaskCaseVerificationRequest{CaseID: "case-1", ResponseText: "wrong", EvidenceArtifactRef: "case-artifact", Verification: capability.TaskCaseVerification{Mode: capability.TaskCaseVerificationExactText, ExpectedText: "answer", MinimumQualityMillis: 900}})
|
||||
recorder := httptest.NewRecorder()
|
||||
verifyHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/task-evaluations/verify", bytes.NewReader(payload)))
|
||||
var response capability.TaskCaseVerificationResponse
|
||||
if recorder.Code != http.StatusOK || json.NewDecoder(recorder.Body).Decode(&response) != nil || response.Result != capability.EvaluationUnsupported || response.QualityScoreMillis != 0 || response.VerificationEvidenceRef == "" {
|
||||
t.Fatalf("unexpected mismatch response: code=%d body=%s value=%+v", recorder.Code, recorder.Body.String(), response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyJSONFieldEquals(t *testing.T) {
|
||||
payload, _ := json.Marshal(capability.TaskCaseVerificationRequest{CaseID: "json-1", ResponseText: `{"answer":42}`, EvidenceArtifactRef: "case-artifact", Verification: capability.TaskCaseVerification{Mode: capability.TaskCaseVerificationJSONFieldEquals, FieldPath: "answer", ExpectedText: "42", MinimumQualityMillis: 850}})
|
||||
recorder := httptest.NewRecorder()
|
||||
verifyHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/task-evaluations/verify", bytes.NewReader(payload)))
|
||||
var response capability.TaskCaseVerificationResponse
|
||||
if recorder.Code != http.StatusOK || json.NewDecoder(recorder.Body).Decode(&response) != nil || response.Result != capability.EvaluationSupported || response.QualityScoreMillis != 850 {
|
||||
t.Fatalf("unexpected JSON verification response: code=%d body=%s value=%+v", recorder.Code, recorder.Body.String(), response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyGenericCandidateEnvelopeDoesNotClaimTruth(t *testing.T) {
|
||||
payload, _ := json.Marshal(work.GenericCandidateVerificationRequest{GraphID: "graph-1", GraphRevision: 2, NodeID: "node-1", AuthorRunID: "run-1", CandidateID: "candidate-1", Summary: "model proposal", EvidenceRefs: []work.EvidenceRef{"agent-output:1"}})
|
||||
recorder := httptest.NewRecorder()
|
||||
genericCandidateVerifyHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/work-graphs/candidates/verify", bytes.NewReader(payload)))
|
||||
var response work.GenericCandidateVerificationResponse
|
||||
if recorder.Code != http.StatusOK || json.NewDecoder(recorder.Body).Decode(&response) != nil || !response.Verified || response.VerifierIdentity == "" || response.VerificationEvidenceRef == "" || !strings.Contains(response.Summary, "semantic content remains unverified") {
|
||||
t.Fatalf("unexpected generic verification response: code=%d body=%s value=%+v", recorder.Code, recorder.Body.String(), response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
"git.cin.su/m/Assistant/modules/work/application"
|
||||
"git.cin.su/m/Assistant/platform/secrets"
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
password, err := resolveDatabasePassword()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
repository, err := postgres.OpenWork(ctx, postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("WORK_POSTGRES_USER"), Password: password})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer repository.Close()
|
||||
evidenceSink, err := newCoreTaskEvaluationSink(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), &http.Client{Timeout: 12 * time.Second})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/healthz", health.Handler{Runtime: "work-graph-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
|
||||
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "work-graph-runtime", Probes: []health.Probe{{Name: "work-postgres", Check: func(ctx context.Context) error { return repository.Ping(ctx) }}}})
|
||||
mux.Handle("/v1/work-graphs/status", workGraphStatusHandler(repository))
|
||||
mux.HandleFunc("POST /v1/work-graphs/schedule", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request application.GraphScheduleRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid graph schedule request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
graph, ready, err := application.AdmitAndSchedule(r.Context(), repository, request)
|
||||
if err != nil {
|
||||
log.Printf("work graph schedule rejected: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"graph": graph, "scheduled_nodes": ready})
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/task-evaluations/schedule", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request application.TaskEvaluationScheduleRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid task evaluation graph request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
graph, ready, err := application.AdmitAndScheduleTaskEvaluation(r.Context(), repository, request)
|
||||
if err != nil {
|
||||
log.Printf("task evaluation graph schedule rejected: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"graph": graph, "scheduled_nodes": ready})
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/task-evaluations/additional/schedule", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request application.TaskEvaluationExpansionScheduleRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid additional task evaluation request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
graph, ready, err := application.ScheduleAdditionalTaskEvaluation(r.Context(), repository, request)
|
||||
if err != nil {
|
||||
log.Printf("additional task evaluation schedule rejected: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"graph": graph, "scheduled_nodes": ready})
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/orchestration/next", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request work.SolutionSearchInput
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid orchestration input", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
decision, err := application.DecideSolutionSearch(request)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid orchestration input", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, decision)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/orchestration/wave", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request work.SolutionSearchWaveRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid orchestration wave request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := application.OrchestrateSolutionSearch(r.Context(), repository, request)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid orchestration wave request", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, result)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/result-callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request work.GraphResultCallback
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid graph result callback", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
graph, err := application.RecordGraphResultCandidate(r.Context(), repository, repository, request)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid graph result callback", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, graph)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/complete", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request application.GraphCompleteRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid graph completion request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := application.CompleteGraphNodeWithReplan(r.Context(), repository, request)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, result)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/task-evaluations/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request work.TaskEvaluationEvidenceCallback
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid task evaluation callback", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
graph, err := application.CompleteTaskEvaluationNode(r.Context(), repository, evidenceSink, request)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, graph)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/synthesis", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request application.GraphSynthesisRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid graph synthesis request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
conflict, synthesis, graph, err := application.SynthesizeAndCompleteGraph(r.Context(), repository, repository, request)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"conflict": conflict, "synthesis": synthesis, "graph": graph})
|
||||
})
|
||||
addr := os.Getenv("LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8088"
|
||||
}
|
||||
log.Printf("work-graph-runtime listening on %s", addr)
|
||||
log.Fatal(http.ListenAndServe(addr, mux))
|
||||
}
|
||||
|
||||
func envOr(name, fallback string) string {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func resolveDatabasePassword() (string, error) {
|
||||
if _, ok := os.LookupEnv("WORK_POSTGRES_PASSWORD"); ok {
|
||||
return "", fmt.Errorf("WORK_POSTGRES_PASSWORD direct environment secret is forbidden")
|
||||
}
|
||||
path, ref := os.Getenv("WORK_POSTGRES_PASSWORD_FILE"), os.Getenv("WORK_POSTGRES_CREDENTIAL_REF")
|
||||
if path == "" || ref == "" {
|
||||
return "", fmt.Errorf("WORK_POSTGRES_PASSWORD_FILE and WORK_POSTGRES_CREDENTIAL_REF are required")
|
||||
}
|
||||
root := os.Getenv("SECRET_BACKEND_ROOT")
|
||||
if root == "" {
|
||||
root = "/run/secrets"
|
||||
}
|
||||
resolver, err := secrets.NewFileResolver(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerWorkWorker, Path: path})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
)
|
||||
|
||||
type workGraphStatusReader interface {
|
||||
RuntimeStatus(context.Context) (postgres.WorkGraphRuntimeStatus, error)
|
||||
}
|
||||
|
||||
type workGraphStatusResponse struct {
|
||||
Runtime string `json:"runtime"`
|
||||
Status string `json:"status"`
|
||||
GraphsByState map[string]int64 `json:"graphs_by_state"`
|
||||
NodesByState map[string]int64 `json:"nodes_by_state"`
|
||||
QueueByState map[string]int64 `json:"queue_by_state"`
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
}
|
||||
|
||||
func workGraphStatusHandler(reader workGraphStatusReader) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/v1/work-graphs/status" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
status, err := reader.RuntimeStatus(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "work graph status unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(workGraphStatusResponse{
|
||||
Runtime: "work-graph-runtime",
|
||||
Status: "ready",
|
||||
GraphsByState: status.GraphsByState,
|
||||
NodesByState: status.NodesByState,
|
||||
QueueByState: status.QueueByState,
|
||||
ObservedAtUnixMillis: status.ObservedAtUnixMillis,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
)
|
||||
|
||||
type fakeWorkGraphStatusReader struct {
|
||||
status postgres.WorkGraphRuntimeStatus
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeWorkGraphStatusReader) RuntimeStatus(context.Context) (postgres.WorkGraphRuntimeStatus, error) {
|
||||
return f.status, f.err
|
||||
}
|
||||
|
||||
func TestWorkGraphStatusReportsDurableCounts(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
workGraphStatusHandler(fakeWorkGraphStatusReader{status: postgres.WorkGraphRuntimeStatus{
|
||||
GraphsByState: map[string]int64{"RUNNING": 2},
|
||||
NodesByState: map[string]int64{"LEASED": 3, "SUCCEEDED": 1},
|
||||
QueueByState: map[string]int64{"QUEUED": 3},
|
||||
ObservedAtUnixMillis: 1700000000000,
|
||||
}}).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/v1/work-graphs/status", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status code = %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, expected := range []string{"\"status\":\"ready\"", "\"RUNNING\":2", "\"LEASED\":3", "\"QUEUED\":3", "1700000000000"} {
|
||||
if !contains(body, expected) {
|
||||
t.Fatalf("response missing %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkGraphStatusFailsClosedOnDatabaseError(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
workGraphStatusHandler(fakeWorkGraphStatusReader{err: errors.New("database unavailable")}).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/v1/work-graphs/status", nil))
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status code = %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(value, expected string) bool {
|
||||
for i := 0; i+len(expected) <= len(value); i++ {
|
||||
if value[i:i+len(expected)] == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
)
|
||||
|
||||
type coreTaskEvaluationSink struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newCoreTaskEvaluationSink(baseURL string, client *http.Client) (*coreTaskEvaluationSink, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("core runtime URL is required")
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return &coreTaskEvaluationSink{baseURL: baseURL, client: client}, nil
|
||||
}
|
||||
|
||||
func (s *coreTaskEvaluationSink) SaveTaskEvaluation(ctx context.Context, value capability.TaskEvaluation) error {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL+"/v1/cognitive-registry/task-evaluations", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := s.client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("save task evaluation: status %s", response.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -27,10 +27,10 @@ type AdmissionPolicy struct {
|
||||
}
|
||||
|
||||
type AdmissionRequest struct {
|
||||
Complexity AdmissionComplexity
|
||||
InputBytes int
|
||||
MaxOutputBytes int
|
||||
DeadlineMillis int64
|
||||
Complexity AdmissionComplexity `json:"complexity"`
|
||||
InputBytes int `json:"input_bytes"`
|
||||
MaxOutputBytes int `json:"max_output_bytes"`
|
||||
DeadlineMillis int64 `json:"deadline_millis"`
|
||||
}
|
||||
|
||||
type AdmissionCounters struct {
|
||||
@@ -39,9 +39,14 @@ type AdmissionCounters struct {
|
||||
}
|
||||
|
||||
type AdmissionDecision struct {
|
||||
State AdmissionDecisionState
|
||||
Reason AdmissionReason
|
||||
PolicyID string
|
||||
State AdmissionDecisionState `json:"state"`
|
||||
Reason AdmissionReason `json:"reason"`
|
||||
PolicyID string `json:"policy_id"`
|
||||
}
|
||||
|
||||
type AdmissionLease struct {
|
||||
ID string `json:"id"`
|
||||
PolicyID string `json:"policy_id"`
|
||||
}
|
||||
|
||||
func Admit(request AdmissionRequest, policy AdmissionPolicy, counters AdmissionCounters, nowMillis int64) AdmissionDecision {
|
||||
|
||||
@@ -26,6 +26,7 @@ type PerformanceEvidenceID ids.ID
|
||||
type BillingMode string
|
||||
type AuthMode string
|
||||
type SelectionObjective string
|
||||
type ModelRatingCategory string
|
||||
|
||||
const (
|
||||
QualificationDiscovered QualificationState = "DISCOVERED"
|
||||
@@ -73,6 +74,11 @@ const (
|
||||
SelectionQuality SelectionObjective = "QUALITY"
|
||||
SelectionSpeed SelectionObjective = "SPEED"
|
||||
SelectionEconomy SelectionObjective = "ECONOMY"
|
||||
|
||||
ModelRatingUnrated ModelRatingCategory = "UNRATED"
|
||||
ModelRatingFast ModelRatingCategory = "FAST"
|
||||
ModelRatingBalanced ModelRatingCategory = "BALANCED"
|
||||
ModelRatingQuality ModelRatingCategory = "QUALITY"
|
||||
)
|
||||
|
||||
// ProviderInstance contains only opaque references to endpoint and credentials.
|
||||
@@ -154,16 +160,33 @@ type CognitiveRequirement struct {
|
||||
ExcludedBindingIDs []CapabilityBindingID
|
||||
Objective SelectionObjective
|
||||
MaxCostMicros *int64
|
||||
TaskProfileID TaskEvaluationProfileID
|
||||
}
|
||||
|
||||
// ResolvedCognitiveBinding is a provider-neutral invocation snapshot selected
|
||||
// by Capability. It contains references and declared metadata, never secrets.
|
||||
type ResolvedCognitiveBinding struct {
|
||||
Binding CognitiveBinding
|
||||
Provider ProviderInstance
|
||||
Model ModelDescriptor
|
||||
Evaluation *EvaluatedCapability
|
||||
Performance *PerformanceEvidence
|
||||
Binding CognitiveBinding
|
||||
Provider ProviderInstance
|
||||
Model ModelDescriptor
|
||||
Evaluation *EvaluatedCapability
|
||||
Performance *PerformanceEvidence
|
||||
TaskEvaluation *TaskEvaluation
|
||||
Qualification *ModelQualificationObservation
|
||||
Rating *ModelRating
|
||||
}
|
||||
|
||||
// ModelRating is a derived, provider-neutral explanation of why an eligible
|
||||
// model is routed. It never grants qualification or activation authority.
|
||||
type ModelRating struct {
|
||||
TaskProfileID TaskEvaluationProfileID `json:"task_profile_id,omitempty"`
|
||||
TaskScoreMillis int64 `json:"task_score_millis,omitempty"`
|
||||
Category ModelRatingCategory `json:"category"`
|
||||
OverallScoreMillis int64 `json:"overall_score_millis"`
|
||||
QualityScoreMillis int64 `json:"quality_score_millis"`
|
||||
ReliabilityScoreMillis int64 `json:"reliability_score_millis"`
|
||||
LatencyScoreMillis int64 `json:"latency_score_millis"`
|
||||
EvidenceScoreMillis int64 `json:"evidence_score_millis"`
|
||||
}
|
||||
|
||||
type CognitiveResultClass string
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package capability
|
||||
|
||||
type FleetTier string
|
||||
|
||||
const (
|
||||
FleetTierFast FleetTier = "FAST"
|
||||
FleetTierMedium FleetTier = "MEDIUM"
|
||||
FleetTierHeavy FleetTier = "HEAVY"
|
||||
FleetTierLifeboat FleetTier = "LIFEBOAT"
|
||||
)
|
||||
|
||||
type FleetMember struct {
|
||||
ProviderInstanceID ProviderInstanceID `json:"provider_instance_id"`
|
||||
ModelDescriptorID ModelDescriptorID `json:"model_descriptor_id"`
|
||||
Tier FleetTier `json:"tier"`
|
||||
Ready bool `json:"ready"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type FleetReadiness struct {
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
Members []FleetMember `json:"members"`
|
||||
ReadyTiers []FleetTier `json:"ready_tiers"`
|
||||
FallbackTier FleetTier `json:"fallback_tier"`
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package capability
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
type ModelQualificationObservationID ids.ID
|
||||
type ModelQualificationStatus string
|
||||
|
||||
const (
|
||||
ModelQualificationReachable ModelQualificationStatus = "REACHABLE"
|
||||
ModelQualificationRespondedWithoutRating ModelQualificationStatus = "RESPONDED_WITHOUT_QUALIFICATION"
|
||||
ModelQualificationUnavailable ModelQualificationStatus = "UNAVAILABLE"
|
||||
)
|
||||
|
||||
// ModelQualificationObservation is operational evidence only. It records
|
||||
// reachability and latency, never quality, activation, routing priority or
|
||||
// provider credentials.
|
||||
type ModelQualificationObservation struct {
|
||||
ID ModelQualificationObservationID
|
||||
ProviderInstanceID ProviderInstanceID
|
||||
ProviderModelID ProviderModelID
|
||||
Status ModelQualificationStatus
|
||||
FinishReason string
|
||||
LatencyMillis int64
|
||||
OutputObserved bool
|
||||
QualityAssessed bool
|
||||
Category ModelRatingCategory
|
||||
ObservedAtUnixMillis int64
|
||||
}
|
||||
|
||||
func (o ModelQualificationObservation) Validate() error {
|
||||
if o.ID == "" || o.ProviderInstanceID == "" || o.ProviderModelID == "" || o.FinishReason == "" || o.LatencyMillis < 0 || o.ObservedAtUnixMillis < 1 {
|
||||
return errors.New("model qualification observation identity and timing are incomplete")
|
||||
}
|
||||
switch o.Status {
|
||||
case ModelQualificationReachable, ModelQualificationRespondedWithoutRating, ModelQualificationUnavailable:
|
||||
default:
|
||||
return errors.New("model qualification observation status is unsupported")
|
||||
}
|
||||
if o.Category != ModelRatingUnrated || o.QualityAssessed {
|
||||
return errors.New("model qualification observation cannot assert quality")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package capability
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/artifact"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
type TaskEvaluationProfileID ids.ID
|
||||
type TaskEvaluationID ids.ID
|
||||
type TaskEvaluationProfileStage string
|
||||
|
||||
const (
|
||||
TaskEvaluationGeneral TaskEvaluationProfileStage = "GENERAL"
|
||||
TaskEvaluationAdditional TaskEvaluationProfileStage = "ADDITIONAL"
|
||||
)
|
||||
|
||||
// TaskEvaluationProfile is the extensible, provider-neutral description of a
|
||||
// task-specific qualification run. Case payloads live in Artifact; the
|
||||
// profile stores only stable case references and scoring policy.
|
||||
type TaskEvaluationProfile struct {
|
||||
ID TaskEvaluationProfileID
|
||||
Revision int64
|
||||
CapabilityID CapabilityID
|
||||
Stage TaskEvaluationProfileStage
|
||||
ParentProfileID TaskEvaluationProfileID
|
||||
TriggerEvidenceRef string
|
||||
BenchmarkVersion string
|
||||
RequiredCaseIDs []string
|
||||
QualityWeightMillis int64
|
||||
ReliabilityWeightMillis int64
|
||||
LatencyWeightMillis int64
|
||||
}
|
||||
|
||||
func (p TaskEvaluationProfile) Validate() error {
|
||||
if p.ID == "" || p.Revision < 1 || p.CapabilityID == "" || (p.Stage != TaskEvaluationGeneral && p.Stage != TaskEvaluationAdditional) || p.BenchmarkVersion == "" || len(p.RequiredCaseIDs) == 0 {
|
||||
return errors.New("task evaluation profile identity and cases are incomplete")
|
||||
}
|
||||
if p.Stage == TaskEvaluationGeneral && (p.ParentProfileID != "" || p.TriggerEvidenceRef != "") {
|
||||
return errors.New("general task evaluation profile cannot have an expansion parent")
|
||||
}
|
||||
if p.Stage == TaskEvaluationAdditional && (p.ParentProfileID == "" || p.TriggerEvidenceRef == "") {
|
||||
return errors.New("additional task evaluation profile requires parent and trigger evidence")
|
||||
}
|
||||
if p.QualityWeightMillis < 0 || p.ReliabilityWeightMillis < 0 || p.LatencyWeightMillis < 0 || p.QualityWeightMillis+p.ReliabilityWeightMillis+p.LatencyWeightMillis != 1000 {
|
||||
return errors.New("task evaluation profile weights must be non-negative and sum to 1000")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(p.RequiredCaseIDs))
|
||||
for _, caseID := range p.RequiredCaseIDs {
|
||||
if caseID == "" {
|
||||
return errors.New("task evaluation profile contains an empty case")
|
||||
}
|
||||
if _, ok := seen[caseID]; ok {
|
||||
return errors.New("task evaluation profile contains duplicate cases")
|
||||
}
|
||||
seen[caseID] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TaskEvaluationExpansionRequest is emitted after a task remains unresolved
|
||||
// with the current best eligible candidate. It creates only a bounded new
|
||||
// profile; it never silently rewrites the general benchmark.
|
||||
type TaskEvaluationExpansionRequest struct {
|
||||
ParentProfileID TaskEvaluationProfileID
|
||||
CapabilityID CapabilityID
|
||||
TriggerEvidenceRef string
|
||||
NewCaseIDs []string
|
||||
BenchmarkVersion string
|
||||
}
|
||||
|
||||
type TaskCaseVerificationMode string
|
||||
|
||||
const (
|
||||
TaskCaseVerificationExactText TaskCaseVerificationMode = "EXACT_TEXT"
|
||||
TaskCaseVerificationJSONFieldEquals TaskCaseVerificationMode = "JSON_FIELD_EQUALS"
|
||||
)
|
||||
|
||||
// TaskCaseVerification is a bounded, task-owned rubric. The expected text is
|
||||
// test data, not model output, and is never sent to the cognitive provider.
|
||||
type TaskCaseVerification struct {
|
||||
Mode TaskCaseVerificationMode
|
||||
FieldPath string
|
||||
ExpectedText string
|
||||
MinimumQualityMillis int64
|
||||
}
|
||||
|
||||
// TaskCaseObservation is produced by the bounded evaluator and verifier. A
|
||||
// score without a case result and evidence reference is not accepted as
|
||||
// qualification evidence.
|
||||
type TaskCaseObservation struct {
|
||||
CaseID string
|
||||
Result EvaluationResult
|
||||
QualityScoreMillis int64
|
||||
LatencyMicros int64
|
||||
CriticalFailure bool
|
||||
EvidenceArtifactRef string
|
||||
// VerificationEvidenceRef and VerifierIdentity are required whenever an
|
||||
// observation claims semantic support or a non-zero quality score.
|
||||
VerificationEvidenceRef string
|
||||
VerifierIdentity string
|
||||
}
|
||||
|
||||
// TaskEvaluation is durable evidence for one model/binding revision under one
|
||||
// task profile. It is not activation authority.
|
||||
type TaskEvaluation struct {
|
||||
ID TaskEvaluationID
|
||||
ProfileID TaskEvaluationProfileID
|
||||
BindingID CapabilityBindingID
|
||||
BindingRevision int64
|
||||
CapabilityID CapabilityID
|
||||
Result EvaluationResult
|
||||
QualityScoreMillis int64
|
||||
ReliabilityScoreMillis int64
|
||||
LatencyScoreMillis int64
|
||||
OverallScoreMillis int64
|
||||
CriticalFailureMillis int64
|
||||
BenchmarkVersion string
|
||||
ArtifactID artifact.ArtifactID
|
||||
ObservedAtUnixMillis int64
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package capability
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
// TaskEvaluationCase carries one bounded prompt selected by the evaluator.
|
||||
// The prompt is an input artifact, not a model-produced fact.
|
||||
type TaskEvaluationCase struct {
|
||||
CaseID string
|
||||
Prompt string
|
||||
EvidenceArtifactRef string
|
||||
Verification TaskCaseVerification
|
||||
}
|
||||
|
||||
type TaskCaseResponse struct {
|
||||
CaseID string
|
||||
ResponseText string
|
||||
LatencyMicros int64
|
||||
EvidenceArtifactRef string
|
||||
}
|
||||
|
||||
type TaskCaseVerificationRequest struct {
|
||||
CaseID string
|
||||
ResponseText string
|
||||
LatencyMicros int64
|
||||
EvidenceArtifactRef string
|
||||
Verification TaskCaseVerification
|
||||
}
|
||||
|
||||
type TaskCaseVerificationResponse struct {
|
||||
CaseID string
|
||||
Result EvaluationResult
|
||||
QualityScoreMillis int64
|
||||
LatencyMicros int64
|
||||
CriticalFailure bool
|
||||
EvidenceArtifactRef string
|
||||
VerificationEvidenceRef string
|
||||
VerifierIdentity string
|
||||
}
|
||||
|
||||
type TaskEvaluationExecutionRequest struct {
|
||||
Profile TaskEvaluationProfile
|
||||
CapabilityID CapabilityID
|
||||
BindingID CapabilityBindingID
|
||||
BindingRevision int64
|
||||
ActorID identity.ActorID
|
||||
PrincipalID identity.PrincipalID
|
||||
ScopeID authority.ScopeID
|
||||
Cases []TaskEvaluationCase
|
||||
MaxOutputBytes int64
|
||||
EgressAcknowledged bool
|
||||
}
|
||||
|
||||
type TaskEvaluationExecutionResponse struct {
|
||||
ProfileID TaskEvaluationProfileID
|
||||
BindingID CapabilityBindingID
|
||||
BindingRevision int64
|
||||
Observations []TaskCaseObservation
|
||||
Responses []TaskCaseResponse
|
||||
ObservedAtUnixMillis int64
|
||||
}
|
||||
@@ -16,19 +16,24 @@ type FreshnessState string
|
||||
type CoverageState string
|
||||
type ConflictState string
|
||||
type EvidenceKind string
|
||||
type ResearchFindingState string
|
||||
|
||||
const (
|
||||
FreshnessCurrent FreshnessState = "CURRENT"
|
||||
FreshnessStale FreshnessState = "STALE"
|
||||
FreshnessUnknown FreshnessState = "UNKNOWN"
|
||||
CoverageComplete CoverageState = "COMPLETE"
|
||||
CoveragePartial CoverageState = "PARTIAL"
|
||||
CoverageUnknown CoverageState = "UNKNOWN"
|
||||
ConflictNone ConflictState = "NONE"
|
||||
ConflictPresent ConflictState = "PRESENT"
|
||||
ConflictUnknown ConflictState = "UNKNOWN"
|
||||
EvidenceObserved EvidenceKind = "OBSERVED"
|
||||
EvidenceAssumed EvidenceKind = "ASSUMED"
|
||||
FreshnessCurrent FreshnessState = "CURRENT"
|
||||
FreshnessStale FreshnessState = "STALE"
|
||||
FreshnessUnknown FreshnessState = "UNKNOWN"
|
||||
CoverageComplete CoverageState = "COMPLETE"
|
||||
CoveragePartial CoverageState = "PARTIAL"
|
||||
CoverageUnknown CoverageState = "UNKNOWN"
|
||||
ConflictNone ConflictState = "NONE"
|
||||
ConflictPresent ConflictState = "PRESENT"
|
||||
ConflictUnknown ConflictState = "UNKNOWN"
|
||||
EvidenceObserved EvidenceKind = "OBSERVED"
|
||||
EvidenceAssumed EvidenceKind = "ASSUMED"
|
||||
FindingSupported ResearchFindingState = "SUPPORTED"
|
||||
FindingStale ResearchFindingState = "STALE"
|
||||
FindingConflicted ResearchFindingState = "CONFLICTED"
|
||||
FindingUnknown ResearchFindingState = "UNKNOWN"
|
||||
)
|
||||
|
||||
type ResearchPlan struct {
|
||||
@@ -72,6 +77,71 @@ type Citation struct {
|
||||
ResolvedAtUnixMillis int64
|
||||
}
|
||||
|
||||
// ResearchCandidate is read-only evidence returned by a connector. The
|
||||
// connector supplies the immutable snapshot and exact excerpt; the evaluator
|
||||
// never fetches, rewrites or invents either one.
|
||||
type ResearchCandidate struct {
|
||||
PropositionKey string
|
||||
Statement string
|
||||
Source Source
|
||||
Snapshot SourceSnapshot
|
||||
Excerpt SourceExcerpt
|
||||
Evidence EvidenceKind
|
||||
Coverage CoverageState
|
||||
Conflict ConflictState
|
||||
}
|
||||
|
||||
type ResearchFinding struct {
|
||||
PropositionKey string
|
||||
Statement string
|
||||
Claim Claim
|
||||
Citations []Citation
|
||||
Source Source
|
||||
Excerpt SourceExcerpt
|
||||
State ResearchFindingState
|
||||
Reason string
|
||||
}
|
||||
|
||||
type ResearchResult struct {
|
||||
PlanID ResearchPlanID
|
||||
Findings []ResearchFinding
|
||||
EvaluatedAtUnixMillis int64
|
||||
}
|
||||
|
||||
// ResearchEvaluationRequest carries immutable evidence from a read-only
|
||||
// connector to the evaluator. The runtime never fetches or mutates a source
|
||||
// from this contract.
|
||||
type ResearchEvaluationRequest struct {
|
||||
Plan ResearchPlan
|
||||
Candidates []ResearchCandidate
|
||||
}
|
||||
|
||||
type RepositoryResearchRequest struct {
|
||||
Plan ResearchPlan
|
||||
PropositionKey string
|
||||
SourceID SourceID
|
||||
Path string
|
||||
Statement string
|
||||
}
|
||||
|
||||
type WebResearchRequest struct {
|
||||
Plan ResearchPlan
|
||||
PropositionKey string
|
||||
SourceID SourceID
|
||||
URL string
|
||||
Title string
|
||||
Statement string
|
||||
}
|
||||
|
||||
// WebSearchRequest asks the research runtime to discover bounded source
|
||||
// evidence for a human question. The provider is configured by the runtime;
|
||||
// the caller supplies meaning, not a provider URL or model choice.
|
||||
type WebSearchRequest struct {
|
||||
Plan ResearchPlan
|
||||
PropositionKey string
|
||||
Query string
|
||||
}
|
||||
|
||||
// ResolveCitation creates evidence only when all identifiers form one exact
|
||||
// source chain. It deliberately does not fetch, infer or rewrite source text.
|
||||
func ResolveCitation(source Source, snapshot SourceSnapshot, excerpt SourceExcerpt, evidence EvidenceKind, coverage CoverageState, conflict ConflictState, resolvedAtUnixMillis int64) (Citation, error) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package epistemics
|
||||
|
||||
type BeliefState string
|
||||
|
||||
const (
|
||||
BeliefProvisional BeliefState = "PROVISIONAL"
|
||||
BeliefConflicted BeliefState = "CONFLICTED"
|
||||
BeliefVerified BeliefState = "VERIFIED"
|
||||
)
|
||||
|
||||
type Observation struct {
|
||||
ID ObservationID
|
||||
SourceSnapshot SourceSnapshotID
|
||||
Excerpt SourceExcerptID
|
||||
Text string
|
||||
ObservedAt int64
|
||||
}
|
||||
|
||||
type Claim struct {
|
||||
ID ClaimID
|
||||
Statement string
|
||||
ObservationID ObservationID
|
||||
CitationID CitationID
|
||||
Verification VerificationState
|
||||
}
|
||||
|
||||
type Belief struct {
|
||||
ID BeliefID
|
||||
ClaimID ClaimID
|
||||
State BeliefState
|
||||
}
|
||||
|
||||
type TruthBundle struct {
|
||||
Source Source
|
||||
Snapshot SourceSnapshot
|
||||
Excerpt SourceExcerpt
|
||||
Observation Observation
|
||||
Claim Claim
|
||||
Belief Belief
|
||||
Citation Citation
|
||||
}
|
||||
@@ -120,15 +120,17 @@ type ExperienceRecord struct {
|
||||
// Cognitive reflection, if used, must go through Cognitive Gateway/Router and
|
||||
// produce candidates only; it cannot directly rewrite canonical state.
|
||||
type ReflectionCandidate struct {
|
||||
ID ReflectionCandidateID
|
||||
Trigger ReflectionTriggerKind
|
||||
State ReflectionCandidateState
|
||||
SourceExperienceID ExperienceRecordID
|
||||
OriginalIntentID OriginalIntentRef
|
||||
SourceUtteranceID SourceUtteranceRef
|
||||
Summary string
|
||||
EvidenceRefs []EvidenceRef
|
||||
RequiresCognition bool
|
||||
RecordedAtUnixMillis int64
|
||||
Revision int64
|
||||
ID ReflectionCandidateID
|
||||
Trigger ReflectionTriggerKind
|
||||
State ReflectionCandidateState
|
||||
SourceExperienceID ExperienceRecordID
|
||||
OriginalIntentID OriginalIntentRef
|
||||
SourceUtteranceID SourceUtteranceRef
|
||||
Summary string
|
||||
EvidenceRefs []EvidenceRef
|
||||
RequiresCognition bool
|
||||
RecordedAtUnixMillis int64
|
||||
Revision int64
|
||||
ClaimedBy string
|
||||
ClaimExpiresAtUnixMillis int64
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ const (
|
||||
InformationRequestCognitiveModelInventory InformationRequestKind = "COGNITIVE_MODEL_INVENTORY"
|
||||
InformationRequestCognitiveChannelStatus InformationRequestKind = "COGNITIVE_CHANNEL_STATUS"
|
||||
InformationRequestCognitiveRecovery InformationRequestKind = "COGNITIVE_RECOVERY"
|
||||
InformationRequestMemoryInventory InformationRequestKind = "MEMORY_INVENTORY"
|
||||
InformationRequestTimeDifference InformationRequestKind = "TIME_DIFFERENCE"
|
||||
)
|
||||
|
||||
type Conversation struct {
|
||||
@@ -173,6 +175,7 @@ type ChatMessageResponse struct {
|
||||
Clarification ClarificationState
|
||||
WorkPlanned bool
|
||||
PlanningReply string
|
||||
ResearchBacked bool
|
||||
CapabilityStatus capability.CapabilityResolutionStatus
|
||||
CapabilityKind capability.CapabilityKind
|
||||
CapabilityHints []capability.CapabilityKind
|
||||
|
||||
@@ -9,13 +9,17 @@ type SecretPurpose string
|
||||
|
||||
const (
|
||||
SecretPurposeDatabasePassword SecretPurpose = "DATABASE_PASSWORD"
|
||||
SecretPurposeProviderAPIKey SecretPurpose = "PROVIDER_API_KEY"
|
||||
)
|
||||
|
||||
// RuntimeConsumer identifies a trusted runtime allowed to resolve a credential.
|
||||
type RuntimeConsumer string
|
||||
|
||||
const (
|
||||
RuntimeConsumerCore RuntimeConsumer = "core-runtime"
|
||||
RuntimeConsumerCore RuntimeConsumer = "core-runtime"
|
||||
RuntimeConsumerWorkWorker RuntimeConsumer = "agent-worker"
|
||||
RuntimeConsumerEpistemicsWorker RuntimeConsumer = "reflection-worker"
|
||||
RuntimeConsumerCognitive RuntimeConsumer = "cognitive-worker"
|
||||
)
|
||||
|
||||
// SecretFileRef is bootstrap delivery metadata for the V1 protected local
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package work
|
||||
|
||||
import "time"
|
||||
|
||||
// AgentExecutionRequest is the serializable hand-off from the durable worker
|
||||
// to an executor. The bridge receives the already-authorized dispatch context;
|
||||
// it does not receive database credentials or ambient authority.
|
||||
type AgentExecutionRequest struct {
|
||||
Intent AgentDispatchIntent `json:"intent"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
FencingToken AgentFencingToken `json:"fencing_token"`
|
||||
LeaseExpiresAt time.Time `json:"lease_expires_at"`
|
||||
}
|
||||
|
||||
type AgentExecutionState string
|
||||
|
||||
const (
|
||||
AgentExecutionCompleted AgentExecutionState = "COMPLETED"
|
||||
AgentExecutionWaiting AgentExecutionState = "WAITING"
|
||||
AgentExecutionFailed AgentExecutionState = "FAILED"
|
||||
)
|
||||
|
||||
// AgentExecutionResult is an acknowledgement of the external execution
|
||||
// attempt. The worker persists it through the work owner before completing a
|
||||
// dispatch, so a bridge timeout never becomes an unrecorded success.
|
||||
type AgentExecutionResult struct {
|
||||
State AgentExecutionState `json:"state"`
|
||||
Summary string `json:"summary"`
|
||||
OutcomeRef string `json:"outcome_ref"`
|
||||
ArtifactRefs []string `json:"artifact_refs"`
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
}
|
||||
@@ -10,10 +10,10 @@ const (
|
||||
)
|
||||
|
||||
type AgentDispatchQueueItem struct {
|
||||
DispatchID string
|
||||
State AgentDispatchQueueState
|
||||
AvailableAtUnixMillis int64
|
||||
Attempts int
|
||||
ClaimedBy string
|
||||
UpdatedAtUnixMillis int64
|
||||
DispatchID string `json:"dispatch_id"`
|
||||
State AgentDispatchQueueState `json:"state"`
|
||||
AvailableAtUnixMillis int64 `json:"available_at_unix_millis"`
|
||||
Attempts int `json:"attempts"`
|
||||
ClaimedBy string `json:"claimed_by"`
|
||||
UpdatedAtUnixMillis int64 `json:"updated_at_unix_millis"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package work
|
||||
|
||||
import "git.cin.su/m/Assistant/contracts/capability"
|
||||
|
||||
type GraphNodeID string
|
||||
|
||||
type GraphState string
|
||||
type GraphNodeState string
|
||||
type GraphNodePurpose string
|
||||
|
||||
const (
|
||||
GraphNodePurposeTaskEvaluation GraphNodePurpose = "TASK_EVALUATION_SPECIALIST"
|
||||
GraphNodePurposeGeneric GraphNodePurpose = "GENERIC"
|
||||
|
||||
GraphDraft GraphState = "DRAFT"
|
||||
GraphAdmitted GraphState = "ADMITTED"
|
||||
GraphRunning GraphState = "RUNNING"
|
||||
GraphPartial GraphState = "PARTIAL"
|
||||
GraphVerifying GraphState = "VERIFYING"
|
||||
GraphCompleted GraphState = "COMPLETED"
|
||||
GraphFailed GraphState = "FAILED"
|
||||
GraphUnknown GraphState = "UNKNOWN"
|
||||
|
||||
GraphNodeBlocked GraphNodeState = "BLOCKED_PREREQUISITE"
|
||||
GraphNodeReady GraphNodeState = "READY"
|
||||
GraphNodeLeased GraphNodeState = "LEASED"
|
||||
GraphNodeRunning GraphNodeState = "RUNNING"
|
||||
GraphNodeVerifying GraphNodeState = "VERIFYING"
|
||||
GraphNodeSuccess GraphNodeState = "SUCCEEDED"
|
||||
GraphNodeFailed GraphNodeState = "FAILED"
|
||||
GraphNodeCancel GraphNodeState = "CANCELLED"
|
||||
GraphNodeUnknown GraphNodeState = "UNKNOWN"
|
||||
)
|
||||
|
||||
type GraphBudget struct {
|
||||
MaxParallel int `json:"max_parallel"`
|
||||
MaxAttempts int `json:"max_attempts"`
|
||||
}
|
||||
|
||||
type GraphNode struct {
|
||||
ID GraphNodeID `json:"id"`
|
||||
DispatchID string `json:"dispatch_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
State GraphNodeState `json:"state"`
|
||||
Attempts int `json:"attempts"`
|
||||
Purpose GraphNodePurpose `json:"purpose,omitempty"`
|
||||
TaskProfileID capability.TaskEvaluationProfileID `json:"task_profile_id,omitempty"`
|
||||
BindingID capability.CapabilityBindingID `json:"binding_id,omitempty"`
|
||||
CaseIDs []string `json:"case_ids,omitempty"`
|
||||
TaskProfile *capability.TaskEvaluationProfile `json:"task_profile,omitempty"`
|
||||
TaskCases []capability.TaskEvaluationCase `json:"task_cases,omitempty"`
|
||||
}
|
||||
|
||||
type GraphEdge struct {
|
||||
From GraphNodeID `json:"from"`
|
||||
To GraphNodeID `json:"to"`
|
||||
}
|
||||
|
||||
type WorkGraph struct {
|
||||
ID string `json:"id"`
|
||||
Revision int64 `json:"revision"`
|
||||
State GraphState `json:"state"`
|
||||
Nodes []GraphNode `json:"nodes"`
|
||||
Edges []GraphEdge `json:"edges"`
|
||||
Budget GraphBudget `json:"budget"`
|
||||
ParentGraphID string `json:"parent_graph_id,omitempty"`
|
||||
ParentGraphRevision int64 `json:"parent_graph_revision,omitempty"`
|
||||
TriggerEvidenceRef string `json:"trigger_evidence_ref,omitempty"`
|
||||
SearchInputBound bool `json:"search_input_bound,omitempty"`
|
||||
SearchInput SolutionSearchInput `json:"search_input,omitempty"`
|
||||
}
|
||||
|
||||
// TaskEvaluationGraphRequest describes a durable fan-out. The graph contains
|
||||
// one specialist node per binding; each node receives the profile's bounded
|
||||
// cases and reports evidence back to Capability for fan-in aggregation.
|
||||
type TaskEvaluationGraphRequest struct {
|
||||
GraphID string `json:"graph_id"`
|
||||
Profile capability.TaskEvaluationProfile `json:"profile"`
|
||||
BindingIDs []capability.CapabilityBindingID `json:"binding_ids"`
|
||||
DispatchIDs []string `json:"dispatch_ids"`
|
||||
IdempotencyPrefix string `json:"idempotency_prefix"`
|
||||
Budget GraphBudget `json:"budget"`
|
||||
ParentGraphID string `json:"parent_graph_id,omitempty"`
|
||||
ParentGraphRevision int64 `json:"parent_graph_revision,omitempty"`
|
||||
ParentGraphState GraphState `json:"parent_graph_state,omitempty"`
|
||||
TriggerEvidenceRef string `json:"trigger_evidence_ref,omitempty"`
|
||||
Cases []capability.TaskEvaluationCase `json:"cases,omitempty"`
|
||||
BindingRevision int64 `json:"binding_revision,omitempty"`
|
||||
ActorID string `json:"actor_id,omitempty"`
|
||||
PrincipalID string `json:"principal_id,omitempty"`
|
||||
ScopeID string `json:"scope_id,omitempty"`
|
||||
}
|
||||
|
||||
// TaskEvaluationEvidenceCallback is emitted by a specialist after it has
|
||||
// completed the bounded case set. Capability remains the evidence owner;
|
||||
// Work only advances its matching graph node after the evidence is accepted.
|
||||
type TaskEvaluationEvidenceCallback struct {
|
||||
GraphID string `json:"graph_id"`
|
||||
Revision int64 `json:"revision"`
|
||||
NodeID GraphNodeID `json:"node_id"`
|
||||
Evaluation capability.TaskEvaluation `json:"evaluation"`
|
||||
Succeeded bool `json:"succeeded"`
|
||||
}
|
||||
|
||||
type ResultCandidate struct {
|
||||
ID string `json:"id"`
|
||||
GraphID string `json:"graph_id"`
|
||||
GraphRevision int64 `json:"graph_revision"`
|
||||
NodeID GraphNodeID `json:"node_id"`
|
||||
AuthorRunID AgentRunID `json:"author_run_id"`
|
||||
ArtifactRefs []EvidenceRef `json:"artifact_refs"`
|
||||
EvidenceSummary string `json:"evidence_summary"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// GenericCandidateVerificationRequest asks the independent deterministic
|
||||
// verifier to validate attribution and evidence-envelope integrity. It does
|
||||
// not assert that the candidate's semantic content is true.
|
||||
type GenericCandidateVerificationRequest struct {
|
||||
GraphID string `json:"graph_id"`
|
||||
GraphRevision int64 `json:"graph_revision"`
|
||||
NodeID GraphNodeID `json:"node_id"`
|
||||
AuthorRunID AgentRunID `json:"author_run_id"`
|
||||
CandidateID string `json:"candidate_id"`
|
||||
Summary string `json:"summary"`
|
||||
EvidenceRefs []EvidenceRef `json:"evidence_refs"`
|
||||
}
|
||||
|
||||
type GenericCandidateVerificationResponse struct {
|
||||
Verified bool `json:"verified"`
|
||||
VerificationEvidenceRef string `json:"verification_evidence_ref"`
|
||||
VerifierIdentity string `json:"verifier_identity"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// GraphResultCallback carries one agent's attributable candidate to Work.
|
||||
// Recording a candidate never makes the node successful; the node enters
|
||||
// VERIFYING until an independent synthesis/verifier accepts it.
|
||||
type GraphResultCallback struct {
|
||||
GraphID string `json:"graph_id"`
|
||||
Revision int64 `json:"revision"`
|
||||
Candidate ResultCandidate `json:"candidate"`
|
||||
}
|
||||
|
||||
type ConflictSet struct {
|
||||
ID string `json:"id"`
|
||||
GraphID string `json:"graph_id"`
|
||||
GraphRevision int64 `json:"graph_revision"`
|
||||
NodeID GraphNodeID `json:"node_id"`
|
||||
CandidateIDs []string `json:"candidate_ids"`
|
||||
Resolved bool `json:"resolved"`
|
||||
}
|
||||
|
||||
type GraphSynthesisState string
|
||||
|
||||
const GraphSynthesisVerified GraphSynthesisState = "VERIFIED"
|
||||
|
||||
// GraphSynthesisCandidate retains every competing result while recording the
|
||||
// independently verified selection. It is a candidate, not an external action
|
||||
// or authority grant.
|
||||
type GraphSynthesisCandidate struct {
|
||||
ID string `json:"id"`
|
||||
GraphID string `json:"graph_id"`
|
||||
GraphRevision int64 `json:"graph_revision"`
|
||||
NodeID GraphNodeID `json:"node_id"`
|
||||
CandidateIDs []string `json:"candidate_ids"`
|
||||
SelectedCandidateID string `json:"selected_candidate_id"`
|
||||
DissentCandidateIDs []string `json:"dissent_candidate_ids"`
|
||||
VerifierRunID AgentRunID `json:"verifier_run_id"`
|
||||
VerifierIdentity string `json:"verifier_identity"`
|
||||
EvidenceRefs []EvidenceRef `json:"evidence_refs"`
|
||||
Summary string `json:"summary"`
|
||||
State GraphSynthesisState `json:"state"`
|
||||
}
|
||||
+42
-11
@@ -1,6 +1,12 @@
|
||||
package work
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
type AgentLeaseID string
|
||||
type AgentFencingToken uint64
|
||||
@@ -50,14 +56,39 @@ type IndependentVerification struct {
|
||||
}
|
||||
|
||||
type AgentDispatchIntent struct {
|
||||
DispatchID string
|
||||
IdempotencyKey string
|
||||
DelegationID DelegationID
|
||||
RunID AgentRunID
|
||||
ImplementationID string
|
||||
ImplementationRevision int64
|
||||
SourceRevision string
|
||||
WorktreeID string
|
||||
VerificationPlanRef string
|
||||
CreatedAtUnixMillis int64
|
||||
DispatchID string `json:"dispatch_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
DelegationID DelegationID `json:"delegation_id"`
|
||||
RunID AgentRunID `json:"run_id"`
|
||||
ImplementationID string `json:"implementation_id"`
|
||||
ImplementationRevision int64 `json:"implementation_revision"`
|
||||
SourceRevision string `json:"source_revision"`
|
||||
WorktreeID string `json:"worktree_id"`
|
||||
VerificationPlanRef string `json:"verification_plan_ref"`
|
||||
CreatedAtUnixMillis int64 `json:"created_at_unix_millis"`
|
||||
SpecialistPurpose string `json:"specialist_purpose,omitempty"`
|
||||
TaskEvaluationProfileID capability.TaskEvaluationProfileID `json:"task_evaluation_profile_id,omitempty"`
|
||||
TaskEvaluationBindingID capability.CapabilityBindingID `json:"task_evaluation_binding_id,omitempty"`
|
||||
TaskEvaluationCaseIDs []string `json:"task_evaluation_case_ids,omitempty"`
|
||||
TaskEvaluationProfile *capability.TaskEvaluationProfile `json:"task_evaluation_profile,omitempty"`
|
||||
TaskEvaluationCases []capability.TaskEvaluationCase `json:"task_evaluation_cases,omitempty"`
|
||||
TaskEvaluationExecution *TaskEvaluationDispatchContext `json:"task_evaluation_execution,omitempty"`
|
||||
}
|
||||
|
||||
type TaskEvaluationDispatchContext struct {
|
||||
GraphID string `json:"graph_id"`
|
||||
GraphRevision int64 `json:"graph_revision"`
|
||||
NodeID GraphNodeID `json:"node_id"`
|
||||
BindingRevision int64 `json:"binding_revision"`
|
||||
ActorID identity.ActorID `json:"actor_id"`
|
||||
PrincipalID identity.PrincipalID `json:"principal_id"`
|
||||
ScopeID authority.ScopeID `json:"scope_id"`
|
||||
// GenericMessage turns the existing durable dispatch context into a
|
||||
// discriminated local cognitive-work payload without adding a second
|
||||
// persistence column. It is ignored by task-evaluation executors.
|
||||
GenericMessage string `json:"generic_message,omitempty"`
|
||||
GenericPurpose string `json:"generic_purpose,omitempty"`
|
||||
GenericLocale string `json:"generic_locale,omitempty"`
|
||||
GenericDataPolicy string `json:"generic_data_policy,omitempty"`
|
||||
GenericMaxOutputBytes int `json:"generic_max_output_bytes,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package work
|
||||
|
||||
type OrchestrationState string
|
||||
type SearchOptionKind string
|
||||
|
||||
const (
|
||||
OrchestrationSearching OrchestrationState = "SEARCHING"
|
||||
OrchestrationExecuting OrchestrationState = "EXECUTING"
|
||||
OrchestrationVerifying OrchestrationState = "VERIFYING"
|
||||
OrchestrationWaiting OrchestrationState = "WAITING"
|
||||
OrchestrationCompleted OrchestrationState = "COMPLETED"
|
||||
OrchestrationCancelled OrchestrationState = "CANCELLED"
|
||||
|
||||
SearchOptionAgents SearchOptionKind = "AGENT_TEAM"
|
||||
SearchOptionCapability SearchOptionKind = "CAPABILITY_DISCOVERY"
|
||||
SearchOptionResearch SearchOptionKind = "TRUTH_RESEARCH"
|
||||
SearchOptionReflection SearchOptionKind = "REFLECTION_MEMORY"
|
||||
SearchOptionHuman SearchOptionKind = "HUMAN_ATTENTION"
|
||||
)
|
||||
|
||||
// SolutionSearchInput is the durable orchestration snapshot. The scheduler
|
||||
// stores the graph/goal separately; this value only decides the next bounded
|
||||
// search wave and never grants authority by itself.
|
||||
type SolutionSearchInput struct {
|
||||
GoalID GoalID
|
||||
GoalState GoalState
|
||||
SearchRound int
|
||||
MaxRound int
|
||||
CancelRequested bool
|
||||
ActiveOptionCount int
|
||||
VerificationReady bool
|
||||
Unresolved bool
|
||||
}
|
||||
|
||||
type SolutionSearchDecision struct {
|
||||
GoalID GoalID
|
||||
State OrchestrationState
|
||||
ContinueSearching bool
|
||||
NextSearchRound int
|
||||
Options []SearchOptionKind
|
||||
Reason string
|
||||
}
|
||||
|
||||
type SolutionSearchWaveRequest struct {
|
||||
Input SolutionSearchInput `json:"input"`
|
||||
GraphID string `json:"graph_id"`
|
||||
IdempotencyPrefix string `json:"idempotency_prefix"`
|
||||
Dispatches []SolutionSearchDispatch `json:"dispatches"`
|
||||
Budget GraphBudget `json:"budget"`
|
||||
ParentGraphID string `json:"parent_graph_id,omitempty"`
|
||||
ParentGraphRevision int64 `json:"parent_graph_revision,omitempty"`
|
||||
TriggerEvidenceRef string `json:"trigger_evidence_ref,omitempty"`
|
||||
NowUnixMillis int64 `json:"now_unix_millis"`
|
||||
}
|
||||
|
||||
type SolutionSearchDispatch struct {
|
||||
Option SearchOptionKind `json:"option"`
|
||||
Intent AgentDispatchIntent `json:"intent"`
|
||||
}
|
||||
|
||||
type SolutionSearchWaveResult struct {
|
||||
Decision SolutionSearchDecision `json:"decision"`
|
||||
Graph WorkGraph `json:"graph"`
|
||||
ScheduledNodeIDs []GraphNodeID `json:"scheduled_node_ids,omitempty"`
|
||||
GraphMaterialized bool `json:"graph_materialized"`
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package work
|
||||
|
||||
import "time"
|
||||
|
||||
type DevelopmentStage string
|
||||
|
||||
const (
|
||||
DevelopmentEvidenceCollected DevelopmentStage = "EVIDENCE_COLLECTED"
|
||||
DevelopmentProposalCreated DevelopmentStage = "PROPOSAL_CREATED"
|
||||
DevelopmentTaskAdmitted DevelopmentStage = "TASK_ADMITTED"
|
||||
DevelopmentIsolated DevelopmentStage = "ISOLATED"
|
||||
DevelopmentVerified DevelopmentStage = "VERIFIED"
|
||||
DevelopmentCanary DevelopmentStage = "CANARY"
|
||||
DevelopmentPromoted DevelopmentStage = "PROMOTED"
|
||||
DevelopmentRolledBack DevelopmentStage = "ROLLED_BACK"
|
||||
)
|
||||
|
||||
type DevelopmentGate struct {
|
||||
ID string
|
||||
Revision int64
|
||||
UpdatedAtUnixMillis int64
|
||||
Stage DevelopmentStage
|
||||
EvidenceRefs []string
|
||||
ProposalRef string
|
||||
TaskPacketRef string
|
||||
WorktreeRef string
|
||||
ProducerRunRefs []AgentRunID
|
||||
VerifierRunRefs []AgentRunID
|
||||
VerificationEvidence string
|
||||
CanaryEvidence string
|
||||
PromotionRef string
|
||||
RollbackRef string
|
||||
}
|
||||
|
||||
// ExtensionProposalStage is deliberately proposal-only in this contract. A
|
||||
// proposal does not authorize source changes, provider calls, deployment or
|
||||
// promotion; those stages require the existing governed development gate.
|
||||
type ExtensionProposalStage string
|
||||
|
||||
const ExtensionProposalProposed ExtensionProposalStage = "PROPOSED"
|
||||
|
||||
// ExtensionEvidence is an immutable reference to an observation that points
|
||||
// at a repeated or corroborated improvement signal. The payload is owned by
|
||||
// its source module; Work stores only the typed reference and stable signal
|
||||
// key.
|
||||
type ExtensionEvidence struct {
|
||||
Ref string
|
||||
SignalKey string
|
||||
}
|
||||
|
||||
// ExtensionProposalRequest contains the operator/governance information
|
||||
// needed to decide whether an evidence pattern is worth reviewing. Empty
|
||||
// fields are rejected by the proposal generator rather than inferred.
|
||||
type ExtensionProposalRequest struct {
|
||||
ID string
|
||||
DesiredOutcome string
|
||||
SmallestChangeLayer string
|
||||
AffectedOwners []string
|
||||
AffectedContracts []string
|
||||
RiskTier string
|
||||
EvaluationPlan []string
|
||||
Alternatives []string
|
||||
RollbackPlan string
|
||||
Assumptions []string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ExtensionProposal is a bounded, evidence-bearing candidate for governed
|
||||
// development. It intentionally contains no executable command, credential,
|
||||
// authority grant, implementation body or promotion decision.
|
||||
type ExtensionProposal struct {
|
||||
ID string
|
||||
RequestedByPrincipalID string
|
||||
Stage ExtensionProposalStage
|
||||
EvidenceRefs []string
|
||||
DeduplicatedSignalKeys []string
|
||||
DesiredOutcome string
|
||||
SmallestChangeLayer string
|
||||
AffectedOwners []string
|
||||
AffectedContracts []string
|
||||
RiskTier string
|
||||
EvaluationPlan []string
|
||||
Alternatives []string
|
||||
RollbackPlan string
|
||||
Assumptions []string
|
||||
CreatedAtUnixMillis int64
|
||||
}
|
||||
|
||||
type DevelopmentScaffoldStage string
|
||||
|
||||
const DevelopmentScaffoldSandboxed DevelopmentScaffoldStage = "SANDBOXED"
|
||||
|
||||
// DevelopmentScaffoldRequest describes the boundary a later control plane
|
||||
// would use to create an isolated worktree. This value object itself performs
|
||||
// no checkout or filesystem operation.
|
||||
type DevelopmentScaffoldRequest struct {
|
||||
ID string
|
||||
ProposalRef string
|
||||
BaseRepoRevision string
|
||||
WorktreeRef string
|
||||
TaskIntent string
|
||||
Modules []string
|
||||
AllowedPaths []string
|
||||
ForbiddenPaths []string
|
||||
ProtectedPaths []string
|
||||
RequiredTests []string
|
||||
ArtifactLineageRefs []string
|
||||
ProtectedMainAccess string
|
||||
ProductionAccess string
|
||||
SecretAccess string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// DevelopmentScaffold is a provenance-linked task-packet candidate. It has
|
||||
// no executable instruction, credential, checkout handle or authority grant.
|
||||
type DevelopmentScaffold struct {
|
||||
ID string
|
||||
RequestedByPrincipalID string
|
||||
Stage DevelopmentScaffoldStage
|
||||
ProposalRef string
|
||||
BaseRepoRevision string
|
||||
WorktreeRef string
|
||||
TaskIntent string
|
||||
Modules []string
|
||||
AllowedPaths []string
|
||||
ForbiddenPaths []string
|
||||
RequiredTests []string
|
||||
ArtifactLineageRefs []string
|
||||
ProtectedMainAccess string
|
||||
ProductionAccess string
|
||||
SecretAccess string
|
||||
CreatedAtUnixMillis int64
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE work_role;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS work.graphs (
|
||||
graph_id TEXT NOT NULL,
|
||||
revision BIGINT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
max_parallel INTEGER NOT NULL,
|
||||
max_attempts INTEGER NOT NULL,
|
||||
PRIMARY KEY (graph_id, revision),
|
||||
CHECK (graph_id <> ''), CHECK (revision > 0),
|
||||
CHECK (state IN ('DRAFT','ADMITTED','RUNNING','PAUSED','CANCELLING','PARTIAL','VERIFYING','COMPLETED','FAILED','UNKNOWN','RECOVERING')),
|
||||
CHECK (max_parallel > 0), CHECK (max_attempts > 0)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS work.graph_nodes (
|
||||
graph_id TEXT NOT NULL,
|
||||
revision BIGINT NOT NULL,
|
||||
node_id TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL,
|
||||
PRIMARY KEY (graph_id, revision, node_id),
|
||||
FOREIGN KEY (graph_id, revision) REFERENCES work.graphs(graph_id, revision),
|
||||
CHECK (node_id <> ''), CHECK (idempotency_key <> ''), CHECK (attempts >= 0),
|
||||
CHECK (state IN ('BLOCKED_PREREQUISITE','READY','LEASED','RUNNING','SUCCEEDED','FAILED','CANCELLED','UNKNOWN','RECOVERING'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS work.graph_edges (
|
||||
graph_id TEXT NOT NULL,
|
||||
revision BIGINT NOT NULL,
|
||||
from_node_id TEXT NOT NULL,
|
||||
to_node_id TEXT NOT NULL,
|
||||
PRIMARY KEY (graph_id, revision, from_node_id, to_node_id),
|
||||
FOREIGN KEY (graph_id, revision) REFERENCES work.graphs(graph_id, revision),
|
||||
CHECK (from_node_id <> ''), CHECK (to_node_id <> ''), CHECK (from_node_id <> to_node_id)
|
||||
);
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,19 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE epistemics_role;
|
||||
|
||||
ALTER TABLE epistemics.reflection_candidates
|
||||
ADD COLUMN IF NOT EXISTS claimed_by TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS claim_expires_at_unix_millis BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE epistemics.reflection_candidates
|
||||
DROP CONSTRAINT IF EXISTS reflection_candidates_claim_expiry_check;
|
||||
ALTER TABLE epistemics.reflection_candidates
|
||||
ADD CONSTRAINT reflection_candidates_claim_expiry_check
|
||||
CHECK (claim_expires_at_unix_millis >= 0);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS reflection_candidates_claimable_idx
|
||||
ON epistemics.reflection_candidates (state, claim_expires_at_unix_millis, recorded_at_unix_millis, reflection_candidate_id);
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,30 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE capability_role;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS capability.cognitive_admission_counters (
|
||||
policy_id TEXT PRIMARY KEY,
|
||||
window_start_unix_millis BIGINT NOT NULL,
|
||||
accepted_count INTEGER NOT NULL DEFAULT 0,
|
||||
concurrent_count INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (policy_id <> ''), CHECK (window_start_unix_millis > 0),
|
||||
CHECK (accepted_count >= 0), CHECK (concurrent_count >= 0),
|
||||
CHECK (updated_at_unix_millis > 0)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS capability.cognitive_admission_leases (
|
||||
lease_id TEXT PRIMARY KEY,
|
||||
policy_id TEXT NOT NULL REFERENCES capability.cognitive_admission_counters(policy_id),
|
||||
state TEXT NOT NULL,
|
||||
expires_at_unix_millis BIGINT NOT NULL,
|
||||
created_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (lease_id <> ''), CHECK (state IN ('ACTIVE','RELEASED','EXPIRED')),
|
||||
CHECK (expires_at_unix_millis > 0), CHECK (created_at_unix_millis > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS cognitive_admission_lease_expiry_idx
|
||||
ON capability.cognitive_admission_leases(policy_id, state, expires_at_unix_millis);
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,49 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE epistemics_role;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS epistemics.truth_bundles (
|
||||
bundle_id TEXT PRIMARY KEY,
|
||||
source_id TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL,
|
||||
source_locator TEXT NOT NULL,
|
||||
source_title TEXT NOT NULL,
|
||||
source_publisher TEXT NOT NULL,
|
||||
snapshot_id TEXT NOT NULL,
|
||||
snapshot_source_id TEXT NOT NULL,
|
||||
snapshot_content_digest TEXT NOT NULL,
|
||||
snapshot_captured_at_unix_millis BIGINT NOT NULL,
|
||||
snapshot_freshness TEXT NOT NULL,
|
||||
excerpt_id TEXT NOT NULL,
|
||||
excerpt_snapshot_id TEXT NOT NULL,
|
||||
excerpt_text TEXT NOT NULL,
|
||||
excerpt_start BIGINT NOT NULL,
|
||||
excerpt_end BIGINT NOT NULL,
|
||||
observation_id TEXT NOT NULL,
|
||||
observation_observed_at_unix_millis BIGINT NOT NULL,
|
||||
claim_id TEXT NOT NULL,
|
||||
claim_statement TEXT NOT NULL,
|
||||
claim_observation_id TEXT NOT NULL,
|
||||
claim_citation_id TEXT NOT NULL,
|
||||
claim_verification TEXT NOT NULL,
|
||||
belief_id TEXT NOT NULL,
|
||||
belief_claim_id TEXT NOT NULL,
|
||||
belief_state TEXT NOT NULL,
|
||||
citation_id TEXT NOT NULL,
|
||||
citation_excerpt_id TEXT NOT NULL,
|
||||
citation_evidence TEXT NOT NULL,
|
||||
citation_coverage TEXT NOT NULL,
|
||||
citation_conflict TEXT NOT NULL,
|
||||
citation_resolved_at_unix_millis BIGINT NOT NULL,
|
||||
recorded_at_unix_millis BIGINT NOT NULL,
|
||||
UNIQUE (snapshot_id, excerpt_id, claim_id),
|
||||
CHECK (bundle_id <> ''), CHECK (source_id <> ''), CHECK (source_locator <> ''),
|
||||
CHECK (excerpt_start >= 0), CHECK (excerpt_end >= excerpt_start),
|
||||
CHECK (recorded_at_unix_millis > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS truth_bundles_source_time_idx
|
||||
ON epistemics.truth_bundles (source_id, recorded_at_unix_millis DESC);
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,8 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE work_role;
|
||||
|
||||
ALTER TABLE work.graph_nodes ADD COLUMN IF NOT EXISTS dispatch_id TEXT NOT NULL DEFAULT '';
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,58 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE work_role;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS work.graph_result_candidates (
|
||||
result_candidate_id TEXT PRIMARY KEY,
|
||||
graph_id TEXT NOT NULL,
|
||||
graph_revision BIGINT NOT NULL,
|
||||
node_id TEXT NOT NULL,
|
||||
author_run_id TEXT NOT NULL,
|
||||
artifact_refs TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
evidence_summary TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (result_candidate_id <> ''), CHECK (graph_id <> ''), CHECK (graph_revision > 0),
|
||||
CHECK (node_id <> ''), CHECK (author_run_id <> ''), CHECK (evidence_summary <> ''), CHECK (summary <> ''),
|
||||
FOREIGN KEY (graph_id, graph_revision) REFERENCES work.graphs(graph_id, revision)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS work.graph_conflicts (
|
||||
conflict_id TEXT PRIMARY KEY,
|
||||
graph_id TEXT NOT NULL,
|
||||
graph_revision BIGINT NOT NULL,
|
||||
node_id TEXT NOT NULL,
|
||||
candidate_ids TEXT[] NOT NULL,
|
||||
resolved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (conflict_id <> ''), CHECK (graph_id <> ''), CHECK (graph_revision > 0), CHECK (node_id <> ''),
|
||||
CHECK (cardinality(candidate_ids) >= 2),
|
||||
FOREIGN KEY (graph_id, graph_revision) REFERENCES work.graphs(graph_id, revision)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS work.graph_synthesis_candidates (
|
||||
synthesis_id TEXT PRIMARY KEY,
|
||||
graph_id TEXT NOT NULL,
|
||||
graph_revision BIGINT NOT NULL,
|
||||
node_id TEXT NOT NULL,
|
||||
candidate_ids TEXT[] NOT NULL,
|
||||
selected_candidate_id TEXT NOT NULL,
|
||||
dissent_candidate_ids TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
verifier_run_id TEXT NOT NULL,
|
||||
verifier_identity TEXT NOT NULL,
|
||||
evidence_refs TEXT[] NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (synthesis_id <> ''), CHECK (graph_id <> ''), CHECK (graph_revision > 0), CHECK (node_id <> ''),
|
||||
CHECK (cardinality(candidate_ids) >= 2), CHECK (selected_candidate_id <> ''), CHECK (verifier_run_id <> ''),
|
||||
CHECK (verifier_identity <> ''), CHECK (cardinality(evidence_refs) >= 1), CHECK (summary <> ''), CHECK (state = 'VERIFIED'),
|
||||
FOREIGN KEY (graph_id, graph_revision) REFERENCES work.graphs(graph_id, revision)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS graph_result_candidates_node_idx ON work.graph_result_candidates(graph_id, graph_revision, node_id);
|
||||
CREATE INDEX IF NOT EXISTS graph_conflicts_node_idx ON work.graph_conflicts(graph_id, graph_revision, node_id);
|
||||
CREATE INDEX IF NOT EXISTS graph_synthesis_node_idx ON work.graph_synthesis_candidates(graph_id, graph_revision, node_id);
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE IF NOT EXISTS work.development_gates (
|
||||
gate_id TEXT PRIMARY KEY,
|
||||
revision BIGINT NOT NULL CHECK (revision > 0),
|
||||
updated_at_unix_millis BIGINT NOT NULL CHECK (updated_at_unix_millis > 0),
|
||||
stage TEXT NOT NULL,
|
||||
evidence_refs TEXT[] NOT NULL,
|
||||
proposal_ref TEXT NOT NULL,
|
||||
task_packet_ref TEXT NOT NULL,
|
||||
worktree_ref TEXT NOT NULL,
|
||||
producer_run_refs TEXT[] NOT NULL,
|
||||
verifier_run_refs TEXT[] NOT NULL,
|
||||
verification_evidence TEXT NOT NULL,
|
||||
canary_evidence TEXT NOT NULL,
|
||||
promotion_ref TEXT NOT NULL,
|
||||
rollback_ref TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE work.development_gates OWNER TO work_role;
|
||||
GRANT SELECT, INSERT, UPDATE ON work.development_gates TO work_role;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,35 @@
|
||||
BEGIN;
|
||||
SET ROLE capability_role;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS capability.cognitive_task_evaluations (
|
||||
evaluation_id TEXT PRIMARY KEY,
|
||||
task_profile_id TEXT NOT NULL,
|
||||
binding_id TEXT NOT NULL,
|
||||
binding_revision BIGINT NOT NULL,
|
||||
capability_id TEXT NOT NULL,
|
||||
result TEXT NOT NULL,
|
||||
quality_score_millis BIGINT NOT NULL,
|
||||
reliability_score_millis BIGINT NOT NULL,
|
||||
latency_score_millis BIGINT NOT NULL,
|
||||
overall_score_millis BIGINT NOT NULL,
|
||||
critical_failure_millis BIGINT NOT NULL,
|
||||
benchmark_version TEXT NOT NULL,
|
||||
artifact_id TEXT NOT NULL,
|
||||
observed_at_unix_millis BIGINT NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (binding_revision > 0),
|
||||
CHECK (result IN ('UNKNOWN','SUPPORTED','UNSUPPORTED')),
|
||||
CHECK (quality_score_millis BETWEEN 0 AND 1000),
|
||||
CHECK (reliability_score_millis BETWEEN 0 AND 1000),
|
||||
CHECK (latency_score_millis BETWEEN 0 AND 1000),
|
||||
CHECK (overall_score_millis BETWEEN 0 AND 1000),
|
||||
CHECK (critical_failure_millis BETWEEN 0 AND 1000),
|
||||
CHECK (observed_at_unix_millis > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS cognitive_task_evaluations_lookup
|
||||
ON capability.cognitive_task_evaluations(task_profile_id, binding_id, binding_revision, observed_at_unix_millis DESC);
|
||||
|
||||
GRANT SELECT, INSERT ON capability.cognitive_task_evaluations TO capability_role;
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,23 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE work_role;
|
||||
|
||||
ALTER TABLE work.agent_dispatch_intents
|
||||
ADD COLUMN IF NOT EXISTS specialist_purpose TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS task_evaluation_profile_id TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS task_evaluation_binding_id TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS task_evaluation_case_ids TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
ALTER TABLE work.graphs
|
||||
ADD COLUMN IF NOT EXISTS parent_graph_id TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS parent_graph_revision BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS trigger_evidence_ref TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE work.graph_nodes
|
||||
ADD COLUMN IF NOT EXISTS purpose TEXT NOT NULL DEFAULT 'GENERIC',
|
||||
ADD COLUMN IF NOT EXISTS task_profile_id TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS binding_id TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS case_ids TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,16 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE work_role;
|
||||
|
||||
ALTER TABLE work.agent_dispatch_intents
|
||||
ADD COLUMN IF NOT EXISTS task_evaluation_profile JSONB,
|
||||
ADD COLUMN IF NOT EXISTS task_evaluation_cases JSONB NOT NULL DEFAULT '[]',
|
||||
ADD COLUMN IF NOT EXISTS task_evaluation_execution JSONB;
|
||||
|
||||
ALTER TABLE work.graph_nodes
|
||||
ADD COLUMN IF NOT EXISTS task_evaluation_profile JSONB,
|
||||
ADD COLUMN IF NOT EXISTS task_evaluation_cases JSONB NOT NULL DEFAULT '[]',
|
||||
ADD COLUMN IF NOT EXISTS task_evaluation_execution JSONB;
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user