Compare commits
669 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c610d95165 | |||
| 2b0a6779cc | |||
| c7f0ab0444 | |||
| 2d87042a70 | |||
| 8a28abb7b8 | |||
| c7a47c71f0 | |||
| 93e270dafb | |||
| be675dbb17 | |||
| 1c24848db3 | |||
| 4b5ec796bc | |||
| 24df4729ca | |||
| 1e6538efac | |||
| f9e53f58af | |||
| 41388efc31 | |||
| fab5ce6fd0 | |||
| 207d6baee5 | |||
| fec72bb2b6 | |||
| c4c4c24c59 | |||
| 2005ba2dca | |||
| 557d5fd6e5 | |||
| 79d2a15f95 | |||
| ab32e44128 | |||
| 047059f85f | |||
| e8364f616d | |||
| 9098c9b6c6 | |||
| 84fd9ebac8 | |||
| 23d5d76d56 | |||
| b0c86588b6 | |||
| 5aff1f9489 | |||
| 199cb3d8cc | |||
| a98a4ca0b6 | |||
| c4f49aadfa | |||
| ca5ac389cf | |||
| 7a658f7953 | |||
| e05fc99da7 | |||
| 787090667e | |||
| 80b36b4052 | |||
| 0b8ed521c0 | |||
| 1c58ccb0c1 | |||
| 79b80fe817 | |||
| c0f3841af7 | |||
| 2b7d9bc471 | |||
| 98dc493a39 | |||
| cfaa57b28d | |||
| 219e603de6 | |||
| 7663a5bce8 | |||
| f2841b945d | |||
| faff64c413 | |||
| 6fbcdc1d87 | |||
| 69a11af949 | |||
| 9ef272020e | |||
| 258cfe7de5 | |||
| 0d53b21133 | |||
| 704a0fd63a | |||
| 0ccb28ffab | |||
| bf4101ac38 | |||
| b30b571b44 | |||
| bc44c3a401 | |||
| 7fbf57cbb7 | |||
| bc349e8fde | |||
| 67d094f51a | |||
| 873af04c6e | |||
| 2f0439dca8 | |||
| 8470c6a980 | |||
| 1920192656 | |||
| f56feaf821 | |||
| 4cbd5a4c6c | |||
| 65aa5629e8 | |||
| 7193d09bed | |||
| 49f8fae0b4 | |||
| e1a490756e | |||
| c313ea7ee2 | |||
| 91bfaf36e3 | |||
| 99d41d8cc6 | |||
| 465adf5b1f | |||
| 132d00d166 | |||
| a604fee3aa | |||
| 8018325923 | |||
| 3f86bd4009 | |||
| b4cf10214b | |||
| c7818c2c33 | |||
| e421bcc326 | |||
| 09e5a4dcc0 | |||
| ce08c44235 | |||
| e743234324 | |||
| 9b76ac48b7 | |||
| 6ae16345a8 | |||
| 8daaf000b1 | |||
| 273f411eee | |||
| 6929cecf8a | |||
| 9221a7ff03 | |||
| a6089c5b3b | |||
| a7ee972b32 | |||
| c817989b99 | |||
| 2272a6854c | |||
| 040fc1ee8d | |||
| f00b8d7b8c | |||
| 6c8c6d7048 | |||
| f27ef52c7a | |||
| 0a2ff1db97 | |||
| 6da48eac6f | |||
| 638ff04e24 | |||
| d7075b459b | |||
| d0e7aa14b6 | |||
| 59fee56c54 | |||
| 2207306169 | |||
| 8ff2e91f2d | |||
| 61afaa4c8b | |||
| 0de47dbc3f | |||
| 676ef56134 | |||
| f0899bb35d | |||
| f490038e36 | |||
| cbf220eb00 | |||
| bf0d80ea20 | |||
| 3ae889a6f8 | |||
| 03ca1067ac | |||
| 3cda30a40a | |||
| 26934527b9 | |||
| 2619acde22 | |||
| b983d3cfd2 | |||
| 87a9dd15fe | |||
| 4066962ade | |||
| 0f26e34f09 | |||
| d76e436e3d | |||
| 4ff531dec7 | |||
| 4f8b3d7aff | |||
| 210fa9c474 | |||
| 25361cac8c | |||
| 28defebd6d | |||
| c74381619e | |||
| d58f3103dd | |||
| 5d1ed35660 | |||
| 1f3e305534 | |||
| 7d8fdd279c | |||
| cacae9f290 | |||
| bb061b770f | |||
| a8768b9ed6 | |||
| b437aa5f6c | |||
| 9248182570 | |||
| 511c1a6ed5 | |||
| 7c77c7170f | |||
| 85fcb6516c | |||
| e8e76d85f7 | |||
| 5aaa5ae4d5 | |||
| c3a8ee9c7b | |||
| 5d07a8aba5 | |||
| d18e0594b8 | |||
| 26dcc86a24 | |||
| e928ad19e5 | |||
| 6768aaa575 | |||
| f561aacbfc | |||
| af1ece40c2 | |||
| d9edd7adf7 | |||
| 3541fab363 | |||
| 1160dceeff | |||
| bbe8efeba2 | |||
| b4a5323009 | |||
| ade8b5b9a7 | |||
| e4ace3d484 | |||
| f3dd25adc5 | |||
| ec251f8168 | |||
| 1bb9579dc5 | |||
| 7ebf4146ce | |||
| a8db4cb2f5 | |||
| 24433396dd | |||
| 02bdf17641 | |||
| e0e05f3488 | |||
| c92f2510c8 | |||
| ea1fbe9ee1 | |||
| 84a0be0179 | |||
| 54f5c0dc91 | |||
| adf1a10318 | |||
| e2a679a265 | |||
| a3916a6932 | |||
| 1b5780461e | |||
| c8d35b63a4 | |||
| feb1ebae04 | |||
| efe49d0a5b | |||
| e50a5ea22a | |||
| 6382c94d0a | |||
| 58ce84c9cc | |||
| 08fd6ff765 | |||
| a9cb79909c | |||
| 852f8ccd94 | |||
| 9388ef3e99 | |||
| 04afb0c4bb | |||
| a07fd44de3 | |||
| f6c1b13846 | |||
| 654fa3dd1f | |||
| 8183449d27 | |||
| a9acfb86ad | |||
| d7d070ac5f | |||
| ead51f1eb6 | |||
| 8c01b573ce | |||
| 7744f21b9d | |||
| 9ed23a235f | |||
| e88328321f | |||
| a4c516bea1 | |||
| 1c932a04ef | |||
| 76d34be4c2 | |||
| cb0e9ff9ec | |||
| d6e8afe316 | |||
| a04f2bcf99 | |||
| c138e7c638 | |||
| fc08c7007f | |||
| d559bb3446 | |||
| 55a8c39e4b | |||
| 02d6f10e5f | |||
| 77428a91cc | |||
| 51403dc276 | |||
| 914a07a35d | |||
| 3c70d7b424 | |||
| ce1ee4ff17 | |||
| fca41d9bda | |||
| ff889e02f7 | |||
| cbd2c86bbf | |||
| 43ab460462 | |||
| caa06e266b | |||
| 3622ca78ee | |||
| 019e3f9659 | |||
| 208cb579a2 | |||
| 17de7e4485 | |||
| 810616eee1 | |||
| 191f583669 | |||
| 1d638cc18e | |||
| 3efa1f3b88 | |||
| 4daa33db09 | |||
| fab2fb0056 | |||
| ce885c120e | |||
| 75b53c47ff | |||
| 2936f73707 | |||
| e26426b138 | |||
| 62cacb8e28 | |||
| f3e37190ce | |||
| 0863bbbd2f | |||
| b23fa1daad | |||
| 05cc1ce599 | |||
| a1c045fd91 | |||
| e6939f8d51 | |||
| 801fef12e1 | |||
| 5845629175 | |||
| 11b916301a | |||
| aa5d80b1d2 | |||
| aa5f990acd | |||
| 9764c82c2a | |||
| f921846879 | |||
| a370403b16 | |||
| 543a71eb6c | |||
| 8285593c13 | |||
| 6fbfe773fb | |||
| a8c54b1e5f | |||
| a5323abfca | |||
| ba4df2d2c4 | |||
| 6510633a8c | |||
| 9172e5f46b | |||
| ed3e3848c0 | |||
| ee90185d5c | |||
| 6eb2633677 | |||
| c1f215dcf2 | |||
| 97cc9a1045 | |||
| 5f7b02a4b7 | |||
| ad6d504ea4 | |||
| e696b41a0e | |||
| 1f9acc6135 | |||
| 7e8699cb4b | |||
| fd4fc657d6 | |||
| 34403648b9 | |||
| 3795d50eb9 | |||
| 80515dde5a | |||
| b59094d35f | |||
| efcd296d83 | |||
| 802cb292b0 | |||
| 8e55f74d73 | |||
| 3d810485a0 | |||
| 94cfd48661 | |||
| 87c8e741f3 | |||
| d0e92ed18d | |||
| 88640f9222 | |||
| 1927045519 | |||
| 68cffb86c9 | |||
| 5bec989647 | |||
| 66f5d2f36c | |||
| 941f815254 | |||
| 42afd10518 | |||
| 3efa285a59 | |||
| 4f2b4172b4 | |||
| 0d7de71b94 | |||
| f0f5b4bede | |||
| bfd27e97d3 | |||
| f2def27390 | |||
| b3f7bd6cc0 | |||
| 0e8e78dc5b | |||
| b259d85776 | |||
| 175d9c3b7c | |||
| a2a810aabf | |||
| 175c7cfd51 | |||
| 5ada973d38 | |||
| 0103276136 | |||
| 1d9e8ec138 | |||
| 83ac2e71bb | |||
| 0b35a729a7 | |||
| 56723a519a | |||
| ebff394c76 | |||
| ceecc97bc8 | |||
| 313154f880 | |||
| 3eb6417cdc | |||
| 1b35d6ca0a | |||
| 1d89f0ba9d | |||
| 864df0e21a | |||
| 3f626decc4 | |||
| bf1760b1a9 | |||
| 8a58ea6344 | |||
| 662ff4c35f | |||
| af02352b49 | |||
| db9f987d46 | |||
| 8490ce1389 | |||
| 55ea9a56a4 | |||
| bd2381b10d | |||
| 443de755bd | |||
| 55ec5f14ee | |||
| 2e019302c9 | |||
| b1e829644b | |||
| 18f773e91b | |||
| 987cfee930 | |||
| 57f6b8498a | |||
| 9f0d35977c | |||
| e5910bbf2f | |||
| 0015bf7b38 | |||
| a6b9234abb | |||
| 086f3942b8 | |||
| 924f4abede | |||
| 02be91cb08 | |||
| c2298393ab | |||
| 4b8c63bf6e | |||
| e089c3b72c | |||
| a93983b5db | |||
| 20f6329004 | |||
| 3c2cf71c47 | |||
| 56288c3137 | |||
| 79188921a5 | |||
| 65962ddf58 | |||
| 5ab66008ae | |||
| f38c9ee049 | |||
| 86f5e71ec2 | |||
| 1e15cc8495 | |||
| bba44430c4 | |||
| 077d82ad82 | |||
| e4cf7f3da2 | |||
| e3bdc9e8d7 | |||
| f1c1c9aab3 | |||
| 97cbcf7658 | |||
| 69c71d77fb | |||
| 4860739a2f | |||
| 791ee40cd6 | |||
| e0191ac52b | |||
| e0724df196 | |||
| 2a56294638 | |||
| d5cd557013 | |||
| 2a43f23a3d | |||
| 69af8f569a | |||
| dcc11c9ea3 | |||
| 4b4abb47b0 | |||
| 0e86dbcc9b | |||
| 92c75aa6f5 | |||
| be41d848e5 | |||
| f7c299f6f0 | |||
| b6a0f65a09 | |||
| 1e7b0068ed | |||
| 207d2fb911 | |||
| de5105f313 | |||
| c65a99c87d | |||
| b4d7e57250 | |||
| 63845a07aa | |||
| 68ac73aa55 | |||
| 6d32f1bb36 | |||
| 9c316cee28 | |||
| 6af4f2d6e6 | |||
| bc9a43d5a9 | |||
| 57651900f1 | |||
| 46b0617018 | |||
| 7b98a6613a | |||
| 26481e27a6 | |||
| 87a26db779 | |||
| bb227b3d73 | |||
| 8a0cf5e0ae | |||
| 69218d5699 | |||
| 7d1433af21 | |||
| 0bfbf1e9c5 | |||
| 1ca4f5b22b | |||
| 0984e4c1e8 | |||
| 7d9bd2e86b | |||
| 4cbf5a7434 | |||
| b33178c5be | |||
| dc6a336c60 | |||
| 20ef5cb14f | |||
| 2c3ec7e74c | |||
| b855336448 | |||
| de021977fd | |||
| cd2b3fcd16 | |||
| b64024ede5 | |||
| a280d23113 | |||
| 41785abdba | |||
| de494c7e55 | |||
| 5fa0903ea8 | |||
| 7bd99fe074 | |||
| c838e1ca6d | |||
| f475923353 | |||
| 43f43c92e3 | |||
| 5463134322 | |||
| 3fbb392103 | |||
| a162da17e1 | |||
| b565134d57 | |||
| 3aafc89912 | |||
| 93449f92fe | |||
| d766e68d42 | |||
| 1d8b1f9774 | |||
| 5ea9abae83 | |||
| 15957499c5 | |||
| 0b50d9e874 | |||
| cce073dbdb | |||
| a1e54922bd | |||
| 63c0ca34ea | |||
| 135477e516 | |||
| 8cac49cd91 | |||
| 28dce63682 | |||
| 313ac952e0 | |||
| 0633d5130b | |||
| 995e487b49 | |||
| 64b58b57e0 | |||
| c6465908df | |||
| ca96bcc09f | |||
| 65ee628fae | |||
| 02043614e5 | |||
| 212b9bf9d4 | |||
| 6070c30a88 | |||
| 8a653e51bc | |||
| 6a92588264 | |||
| 276aad6f0d | |||
| 10620bda4f | |||
| c214401a00 | |||
| 260ac33324 | |||
| d4cd643860 | |||
| dc16cfda21 | |||
| d562670425 | |||
| 677bee6fe5 | |||
| de27bfe76f | |||
| 1c1dcb9c33 | |||
| 4ba950f155 | |||
| 9c3a11d7bb | |||
| b7d357aea2 | |||
| b2fed68346 | |||
| 0e996928be | |||
| 6ff4ec3643 | |||
| a0eda3e492 | |||
| 099f9514ef | |||
| b2096e4a55 | |||
| 1bf2164745 | |||
| 48205bbde7 | |||
| 296aab6ecb | |||
| 14182c45fc | |||
| 2fa8f4283c | |||
| ad3cec2361 | |||
| eddb628298 | |||
| f63b226d8d | |||
| cc5bd61d86 | |||
| 8bd14fb16f | |||
| 30b5472e33 | |||
| bc836db0f9 | |||
| bd3b0fb8eb | |||
| 7f28474967 | |||
| 09460b28bc | |||
| 5d8ba1e49c | |||
| ccb394675b | |||
| 931487a7d4 | |||
| 3654c57f66 | |||
| fb28280ced | |||
| 6215441b58 | |||
| 52f16d5bb6 | |||
| e5b6c8581a | |||
| e1db3a4af9 | |||
| 5dcca99913 | |||
| 890b906f15 | |||
| 6a8286d4cf | |||
| 680024f790 | |||
| 6f7bfb92a8 | |||
| 335a9603e8 | |||
| 5e8a6202e7 | |||
| 55a4cdefd7 | |||
| 2b63135afb | |||
| 49d8c3572d | |||
| 4b40962186 | |||
| 779b376c6e | |||
| 4e2a9a247a | |||
| b1f3d6b155 | |||
| ea28a9d3c3 | |||
| 69a03e463f | |||
| e7da62e61c | |||
| 7176745e1c | |||
| cce0e26f5c | |||
| 641af16dfc | |||
| a335c427ef | |||
| 9ea6c959ae | |||
| 20efd523c9 | |||
| 8fc7fff496 | |||
| edf51e6996 | |||
| 6b867883ce | |||
| 35a05f4120 | |||
| e0e78a97ce | |||
| ddd30a950d | |||
| e4e476f463 | |||
| 3ca0e63d54 | |||
| c4c8917ecb | |||
| 1524d2ef00 | |||
| 5032834034 | |||
| 0b83f6ea99 | |||
| 415201f467 | |||
| 73005a8498 | |||
| 4edb960fbd | |||
| 42d11ead01 | |||
| 5e18f85b10 | |||
| 85b25bf006 | |||
| c1ba108489 | |||
| 214098aaae | |||
| 241a0b7adc | |||
| 9a7b41a4be | |||
| fe918adb16 | |||
| 746f026654 | |||
| 8294cd3dd9 | |||
| 3bbc63b1db | |||
| 337fb6d922 | |||
| bda6b18e8a | |||
| d256ff929f | |||
| f71b20cf07 | |||
| db26b0afd6 | |||
| 145860f42e | |||
| d9f84648d0 | |||
| 9fb7e0bae7 | |||
| b00203702e | |||
| ead85dd41f | |||
| cf5bf6f174 | |||
| 46237e7309 | |||
| afa686b47b | |||
| 21e02c9e50 | |||
| 30a188d7c8 | |||
| 355f51b25e | |||
| 8e1cde86e8 | |||
| 9e72801c28 | |||
| 3a3d538b73 | |||
| b11bca0c67 | |||
| faf8975b42 | |||
| 863168880e | |||
| 8c3866a014 | |||
| 61283d9bd6 | |||
| 585a7186d4 | |||
| 10d9e54857 | |||
| 11379fc0ef | |||
| 754e33a1ae | |||
| 51154a3070 | |||
| b11b43bbe1 | |||
| 86f4645d1c | |||
| 2d05e96cd5 | |||
| ebeac68707 | |||
| de5fcab933 | |||
| a7a2100472 | |||
| 9c44d3b793 | |||
| 9b89ac694e | |||
| 630d8208cf | |||
| 9b342dc593 | |||
| ad879de6ff | |||
| 795266aab4 | |||
| 4e4ef121f9 | |||
| ddb9126955 | |||
| bac6d6dd68 | |||
| 3451570541 | |||
| e5e939f344 | |||
| 0d51d25482 | |||
| a0a5b10df0 | |||
| 04bac93c14 | |||
| 4961d3ba8c | |||
| 047f4a1a0c | |||
| 7994b90dfa | |||
| 04b6a80370 | |||
| 0f8627f17a | |||
| fc0c3e169f | |||
| 4760f95bda | |||
| cd0cf69099 | |||
| a04a8a866d | |||
| 8c9baa62b0 | |||
| 262eaa6d84 | |||
| fc1a48f3bc | |||
| 060f320cd1 | |||
| bff32bcaa3 | |||
| 9744363342 | |||
| 6fe8439e94 | |||
| 8e61ffe377 | |||
| 723476f7a7 | |||
| 0f253027ae | |||
| 6053895a82 | |||
| ceffa38717 | |||
| ae205fa3f2 | |||
| 669a05892b | |||
| 4898a9759a | |||
| 2c2fa25580 | |||
| 56496d7dbd | |||
| dd0696e44d | |||
| dcda273e0b | |||
| f3b159c650 | |||
| 06df037e28 | |||
| e814e516d1 | |||
| 0375e068ed | |||
| 34ffc533d3 | |||
| ea2ea1a4ae | |||
| 9e11947687 | |||
| 47117281e1 | |||
| 032dd13f5a | |||
| 13d8ebbeff | |||
| 2efa0e01df | |||
| 6044369fdf | |||
| 40e74e408b | |||
| 97440f9e8a | |||
| 765f7cae58 | |||
| b455c8a2ad | |||
| da25e0ffa5 | |||
| e07703c01f | |||
| a4abf3eb2b | |||
| 269d72d073 | |||
| c8f5dccbd2 | |||
| 8b797ee73f | |||
| de38adb1e4 | |||
| c169bcc5d8 | |||
| 80ea286beb | |||
| 3499be782e | |||
| 16603ae49c | |||
| bf6bd9ce7f | |||
| a54c0f6f46 | |||
| beeed11d48 | |||
| 25331590a7 | |||
| bff9f8976e | |||
| b71628e211 | |||
| 8c1cb1f55b | |||
| 66214384a9 | |||
| 6d6646887c | |||
| 6f8db0ed08 | |||
| 6aaf6836ea | |||
| 4f2348f50e | |||
| deb7f2f72a | |||
| d989d9c65a | |||
| 4173c606ab | |||
| a01430d20f | |||
| 2a8f775732 | |||
| 4a0d9b2855 | |||
| 92c65d69ea | |||
| 910a8968c4 | |||
| cdb4679c5a | |||
| 1a9dce89b4 | |||
| cf1e4d7f88 | |||
| f2f0b4fc61 | |||
| b21dd25181 | |||
| 04a18bcbe5 | |||
| 7f66dd67eb | |||
| cfa03b89c8 | |||
| 9866d7a22b | |||
| 331a6e442f | |||
| 1c2295b2b5 | |||
| fa43ca3785 | |||
| b4a2c3bd14 | |||
| 2d4ec4f462 | |||
| 1e8b933da0 | |||
| 48b1e0e038 |
@@ -195,7 +195,7 @@ class DeepResearchAgent:
|
||||
max_tokens=self.config.max_tokens,
|
||||
loop_config={
|
||||
"max_iterations": 100,
|
||||
"max_tool_calls_per_turn": 20,
|
||||
"max_tool_calls_per_turn": 30,
|
||||
"max_history_tokens": 32000,
|
||||
},
|
||||
conversation_mode="continuous",
|
||||
|
||||
@@ -71,6 +71,12 @@ Important:
|
||||
- Track which URL each finding comes from (you'll need citations later)
|
||||
- Call set_output for each key in a SEPARATE turn (not in the same turn as other tool calls)
|
||||
|
||||
Context management:
|
||||
- Your tool results are automatically saved to files. After compaction, the file \
|
||||
references remain in the conversation — use load_data() to recover any content you need.
|
||||
- Use append_data('research_notes.md', ...) to maintain a running log of key findings \
|
||||
as you go. This survives compaction and helps the report node produce a detailed report.
|
||||
|
||||
When done, use set_output (one key at a time, separate turns):
|
||||
- set_output("findings", "Structured summary: key findings with source URLs for each claim. \
|
||||
Include themes, contradictions, and confidence levels.")
|
||||
@@ -161,6 +167,9 @@ Requirements:
|
||||
- Every factual claim must cite its source with [n] notation
|
||||
- Be objective — present multiple viewpoints where sources disagree
|
||||
- Answer the original research questions from the brief
|
||||
- If findings appear incomplete or summarized, call list_data_files() and load_data() \
|
||||
to access the detailed source material from the research phase. The research node's \
|
||||
tool results and research_notes.md contain the full data.
|
||||
|
||||
Save the HTML:
|
||||
save_data(filename="report.html", data="<html>...</html>")
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
name: Integration Bounty
|
||||
description: A bounty task for the integration contribution program
|
||||
title: "[Bounty]: "
|
||||
labels: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Integration Bounty
|
||||
|
||||
This issue is part of the [Integration Bounty Program](../../docs/bounty-program/README.md).
|
||||
**Claim this bounty** by commenting below — a maintainer will assign you within 24 hours.
|
||||
|
||||
- type: dropdown
|
||||
id: bounty-type
|
||||
attributes:
|
||||
label: Bounty Type
|
||||
options:
|
||||
- "Test a Tool (20 pts)"
|
||||
- "Write Docs (20 pts)"
|
||||
- "Code Contribution (30 pts)"
|
||||
- "New Integration (75 pts)"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: difficulty
|
||||
attributes:
|
||||
label: Difficulty
|
||||
options:
|
||||
- Easy
|
||||
- Medium
|
||||
- Hard
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: tool-name
|
||||
attributes:
|
||||
label: Tool Name
|
||||
description: The integration this bounty targets (e.g., `airtable`, `salesforce`)
|
||||
placeholder: e.g., airtable
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What needs to be done to complete this bounty.
|
||||
placeholder: |
|
||||
Describe the specific task, including:
|
||||
- What the contributor needs to do
|
||||
- Links to relevant files in the repo
|
||||
- Any setup requirements (API keys, accounts, etc.)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: acceptance-criteria
|
||||
attributes:
|
||||
label: Acceptance Criteria
|
||||
description: What "done" looks like. The PR or report must meet all criteria.
|
||||
placeholder: |
|
||||
- [ ] Criterion 1
|
||||
- [ ] Criterion 2
|
||||
- [ ] CI passes
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: relevant-files
|
||||
attributes:
|
||||
label: Relevant Files
|
||||
description: Links to tool directory, credential spec, health check file, etc.
|
||||
placeholder: |
|
||||
- Tool: `tools/src/aden_tools/tools/{tool_name}/`
|
||||
- Credential spec: `tools/src/aden_tools/credentials/{category}.py`
|
||||
- Health checks: `tools/src/aden_tools/credentials/health_check.py`
|
||||
|
||||
- type: textarea
|
||||
id: resources
|
||||
attributes:
|
||||
label: Resources
|
||||
description: Links to API docs, examples, or guides that will help the contributor.
|
||||
placeholder: |
|
||||
- [Building Tools Guide](../../tools/BUILDING_TOOLS.md)
|
||||
- [Tool README Template](../../docs/bounty-program/templates/tool-readme-template.md)
|
||||
- API docs: https://...
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Bounty completed
|
||||
description: Awards points and notifies Discord when a bounty PR is merged
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
bounty-notify:
|
||||
if: >
|
||||
github.event.pull_request.merged == true &&
|
||||
contains(join(github.event.pull_request.labels.*.name, ','), 'bounty:')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Award XP and notify Discord
|
||||
run: bun run scripts/bounty-tracker.ts notify
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||
GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }}
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_BOUNTY_WEBHOOK_URL }}
|
||||
LURKR_API_KEY: ${{ secrets.LURKR_API_KEY }}
|
||||
LURKR_GUILD_ID: ${{ secrets.LURKR_GUILD_ID }}
|
||||
MONGODB_URI: ${{ secrets.MONGODB_URI }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
@@ -62,8 +62,11 @@ jobs:
|
||||
uv run pytest tests/ -v
|
||||
|
||||
test-tools:
|
||||
name: Test Tools
|
||||
runs-on: ubuntu-latest
|
||||
name: Test Tools (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Weekly bounty leaderboard
|
||||
description: Posts the integration bounty leaderboard to Discord every Monday
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every Monday at 9:00 UTC
|
||||
- cron: "0 9 * * 1"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
since_date:
|
||||
description: "Only count PRs merged after this date (YYYY-MM-DD). Leave empty for all-time."
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
leaderboard:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Post leaderboard to Discord
|
||||
run: bun run scripts/bounty-tracker.ts leaderboard
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||
GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }}
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_BOUNTY_WEBHOOK_URL }}
|
||||
LURKR_API_KEY: ${{ secrets.LURKR_API_KEY }}
|
||||
LURKR_GUILD_ID: ${{ secrets.LURKR_GUILD_ID }}
|
||||
MONGODB_URI: ${{ secrets.MONGODB_URI }}
|
||||
SINCE_DATE: ${{ github.event.inputs.since_date || '' }}
|
||||
@@ -70,6 +70,7 @@ exports/*
|
||||
.agent-builder-sessions/*
|
||||
|
||||
.claude/settings.local.json
|
||||
.claude/skills/ship-it/
|
||||
|
||||
.venv
|
||||
|
||||
@@ -78,3 +79,4 @@ core/tests/*dumps/*
|
||||
|
||||
screenshots/*
|
||||
|
||||
.gemini/*
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Repository Guidelines
|
||||
|
||||
Shared agent instructions for this workspace.
|
||||
|
||||
## Deprecations
|
||||
|
||||
- **TUI is deprecated.** The terminal UI (`hive tui`) is no longer maintained. Use the browser-based interface (`hive open`) instead.
|
||||
|
||||
## Coding Agent Notes
|
||||
|
||||
-
|
||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
- Do not update dependencies casually. Version bumps, patched dependencies, overrides, or vendored dependency changes require explicit approval.
|
||||
- Add brief comments for tricky logic. Keep files reasonably small when practical; split or refactor large files instead of growing them indefinitely.
|
||||
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
||||
- Use `uv` for Python execution and package management. Do not use `python` or `python3` directly unless the user explicitly asks for it.
|
||||
- Prefer `uv run` for scripts and tests, and `uv pip` for package operations.
|
||||
|
||||
|
||||
## Multi-Agent Safety
|
||||
|
||||
- Do not create, apply, or drop `git stash` entries unless explicitly requested.
|
||||
- Do not create, remove, or modify `git worktree` checkouts unless explicitly requested.
|
||||
- Do not switch branches or check out a different branch unless explicitly requested.
|
||||
- When the user says `push`, you may `git pull --rebase` to integrate latest changes, but never discard other in-progress work.
|
||||
- When the user says `commit`, commit only your changes. When the user says `commit all`, commit everything in grouped chunks.
|
||||
- When you see unrecognized files or unrelated changes, keep going and focus on your scoped changes.
|
||||
|
||||
## Change Hygiene
|
||||
|
||||
- If staged and unstaged diffs are formatting-only, resolve them without asking.
|
||||
- If a commit or push was already requested, include formatting-only follow-up changes in that same commit when practical.
|
||||
- Only stop to ask for confirmation when changes are semantic and may alter behavior.
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: lint format check test install-hooks help frontend-dev frontend-build
|
||||
.PHONY: lint format check test install-hooks help frontend-install frontend-dev frontend-build
|
||||
|
||||
help: ## Show this help
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
|
||||
@@ -20,13 +20,28 @@ check: ## Run all checks without modifying files (CI-safe)
|
||||
cd core && ruff format --check .
|
||||
cd tools && ruff format --check .
|
||||
|
||||
test: ## Run all tests
|
||||
test: ## Run all tests (core + tools, excludes live)
|
||||
cd core && uv run python -m pytest tests/ -v
|
||||
cd tools && uv run python -m pytest -v
|
||||
|
||||
test-tools: ## Run tool tests only (mocked, no credentials needed)
|
||||
cd tools && uv run python -m pytest -v
|
||||
|
||||
test-live: ## Run live integration tests (requires real API credentials)
|
||||
cd tools && uv run python -m pytest -m live -s -o "addopts=" --log-cli-level=INFO
|
||||
|
||||
test-all: ## Run everything including live tests
|
||||
cd core && uv run python -m pytest tests/ -v
|
||||
cd tools && uv run python -m pytest -v
|
||||
cd tools && uv run python -m pytest -m live -s -o "addopts=" --log-cli-level=INFO
|
||||
|
||||
install-hooks: ## Install pre-commit hooks
|
||||
uv pip install pre-commit
|
||||
pre-commit install
|
||||
|
||||
frontend-install: ## Install frontend npm packages
|
||||
cd core/frontend && npm install
|
||||
|
||||
frontend-dev: ## Start frontend dev server
|
||||
cd core/frontend && npm run dev
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/adenhq/hive/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="Apache 2.0 License" /></a>
|
||||
<a href="https://github.com/aden-hive/hive/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="Apache 2.0 License" /></a>
|
||||
<a href="https://www.ycombinator.com/companies/aden"><img src="https://img.shields.io/badge/Y%20Combinator-Aden-orange" alt="Y Combinator" /></a>
|
||||
<a href="https://discord.com/invite/MXE49hrKDk"><img src="https://img.shields.io/discord/1172610340073242735?logo=discord&labelColor=%235462eb&logoColor=%23f5f5f5&color=%235462eb" alt="Discord" /></a>
|
||||
<a href="https://x.com/aden_hq"><img src="https://img.shields.io/twitter/follow/teamaden?logo=X&color=%23f5f5f5" alt="Twitter Follow" /></a>
|
||||
@@ -37,11 +37,11 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Build autonomous, reliable, self-improving AI agents without hardcoding workflows. Define your goal through conversation with a coding agent, and the framework generates a node graph with dynamically created connection code. When things break, the framework captures failure data, evolves the agent through the coding agent, and redeploys. Built-in human-in-the-loop nodes, credential management, and real-time monitoring give you control without sacrificing adaptability.
|
||||
Build autonomous, reliable, self-improving AI agents without hardcoding workflows. Define your goal through conversation with hive coding agent(queen), and the framework generates a node graph with dynamically created connection code. When things break, the framework captures failure data, evolves the agent through the coding agent, and redeploys. Built-in human-in-the-loop nodes, credential management, and real-time monitoring give you control without sacrificing adaptability.
|
||||
|
||||
Visit [adenhq.com](https://adenhq.com) for complete documentation, examples, and guides.
|
||||
|
||||
https://github.com/user-attachments/assets/846c0cc7-ffd6-47fa-b4b7-495494857a55
|
||||
[](https://www.youtube.com/watch?v=XDOG9fOaLjU)
|
||||
|
||||
## Who Is Hive For?
|
||||
|
||||
@@ -50,7 +50,7 @@ Hive is designed for developers and teams who want to build **production-grade A
|
||||
Hive is a good fit if you:
|
||||
|
||||
- Want AI agents that **execute real business processes**, not demos
|
||||
- Prefer **goal-driven development** over hardcoded workflows
|
||||
- Need **fast or high volume agent execution** over open workflow
|
||||
- Need **self-healing and adaptive agents** that improve over time
|
||||
- Require **human-in-the-loop control**, observability, and cost limits
|
||||
- Plan to run agents in **production environments**
|
||||
@@ -71,7 +71,7 @@ Use Hive when you need:
|
||||
|
||||
- **[Documentation](https://docs.adenhq.com/)** - Complete guides and API reference
|
||||
- **[Self-Hosting Guide](https://docs.adenhq.com/getting-started/quickstart)** - Deploy Hive on your infrastructure
|
||||
- **[Changelog](https://github.com/adenhq/hive/releases)** - Latest updates and releases
|
||||
- **[Changelog](https://github.com/aden-hive/hive/releases)** - Latest updates and releases
|
||||
- **[Roadmap](docs/roadmap.md)** - Upcoming features and plans
|
||||
- **[Report Issues](https://github.com/adenhq/hive/issues)** - Bug reports and feature requests
|
||||
- **[Contributing](CONTRIBUTING.md)** - How to contribute and submit PRs
|
||||
@@ -81,7 +81,8 @@ Use Hive when you need:
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.11+ for agent development
|
||||
- Claude Code, Codex CLI, or Cursor for utilizing agent skills
|
||||
- An LLM provider that powers the agents
|
||||
- **ripgrep (optional, recommended on Windows):** The `search_files` tool uses ripgrep for faster file search. If not installed, a Python fallback is used. On Windows: `winget install BurntSushi.ripgrep` or `scoop install ripgrep`
|
||||
|
||||
> **Note for Windows Users:** It is strongly recommended to use **WSL (Windows Subsystem for Linux)** or **Git Bash** to run this framework. Some core automation scripts may not execute correctly in standard Command Prompt or PowerShell.
|
||||
|
||||
@@ -94,9 +95,10 @@ Use Hive when you need:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/adenhq/hive.git
|
||||
git clone https://github.com/aden-hive/hive.git
|
||||
cd hive
|
||||
|
||||
|
||||
# Run quickstart setup
|
||||
./quickstart.sh
|
||||
```
|
||||
@@ -109,77 +111,43 @@ This sets up:
|
||||
- **LLM provider** - Interactive default model configuration
|
||||
- All required Python dependencies with `uv`
|
||||
|
||||
- At last, it will initiate the open hive interface in your browser
|
||||
|
||||
> **Tip:** To reopen the dashboard later, run `hive open` from the project directory.
|
||||
|
||||
<img width="2500" height="1214" alt="home-screen" src="https://github.com/user-attachments/assets/134d897f-5e75-4874-b00b-e0505f6b45c4" />
|
||||
|
||||
### Build Your First Agent
|
||||
|
||||
```bash
|
||||
# Build an agent using Claude Code
|
||||
claude> /hive
|
||||
Type the agent you want to build in the home input box
|
||||
|
||||
# Test your agent
|
||||
claude> /hive-debugger
|
||||
<img width="2500" height="1214" alt="Image" src="https://github.com/user-attachments/assets/1ce19141-a78b-46f5-8d64-dbf987e048f4" />
|
||||
|
||||
# (at separate terminal) Launch the interactive dashboard
|
||||
hive tui
|
||||
### Use Template Agents
|
||||
|
||||
# Or run directly
|
||||
hive run exports/your_agent_name --input '{"key": "value"}'
|
||||
```
|
||||
Click "Try a sample agent" and check the templates. You can run a templates directly or choose to build your version on top of the existing template.
|
||||
|
||||
## Coding Agent Support
|
||||
### Run Agents
|
||||
|
||||
### Codex CLI
|
||||
Now you can run an agent by selectiing the agent (either an existing agent or example agent). You can click the Run button on the top left, or talk to the queen agent and it can run the agent for you.
|
||||
|
||||
Hive includes native support for [OpenAI Codex CLI](https://github.com/openai/codex) (v0.101.0+).
|
||||
|
||||
1. **Config:** `.codex/config.toml` with `agent-builder` MCP server (tracked in git)
|
||||
2. **Skills:** `.agents/skills/` symlinks to Hive skills (tracked in git)
|
||||
3. **Launch:** Run `codex` in the repo root, then type `use hive`
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
codex> use hive
|
||||
```
|
||||
|
||||
### Opencode
|
||||
|
||||
Hive includes native support for [Opencode](https://github.com/opencode-ai/opencode).
|
||||
|
||||
1. **Setup:** Run the quickstart script
|
||||
2. **Launch:** Open Opencode in the project root.
|
||||
3. **Activate:** Type `/hive` in the chat to switch to the Hive Agent.
|
||||
4. **Verify:** Ask the agent _"List your tools"_ to confirm the connection.
|
||||
|
||||
The agent has access to all Hive skills and can scaffold agents, add tools, and debug workflows directly from the chat.
|
||||
|
||||
**[📖 Complete Setup Guide](docs/environment-setup.md)** - Detailed instructions for agent development
|
||||
|
||||
### Antigravity IDE Support
|
||||
|
||||
Skills and MCP servers are also available in [Antigravity IDE](https://antigravity.google/) (Google's AI-powered IDE). **Easiest:** open a terminal in the hive repo folder and run (use `./` — the script is inside the repo):
|
||||
|
||||
```bash
|
||||
./scripts/setup-antigravity-mcp.sh
|
||||
```
|
||||
|
||||
**Important:** Always restart/refresh Antigravity IDE after running the setup script—MCP servers only load on startup. After restart, **agent-builder** and **tools** MCP servers should connect. Skills are under `.agent/skills/` (symlinks to `.claude/skills/`). See [docs/antigravity-setup.md](docs/antigravity-setup.md) for manual setup and troubleshooting.
|
||||
<img width="2500" height="1214" alt="Image" src="https://github.com/user-attachments/assets/71c38206-2ad5-49aa-bde8-6698d0bc55f5" />
|
||||
|
||||
## Features
|
||||
|
||||
- **[Goal-Driven Development](docs/key_concepts/goals_outcome.md)** - Define objectives in natural language; the coding agent generates the agent graph and connection code to achieve them
|
||||
- **Browser-Use** - Control the browser on your computer to achieve hard tasks
|
||||
- **Parallel Execution** - Execute the generated graph in parallel. This way you can have multiple agent compelteing the jobs for you
|
||||
- **[Goal-Driven Generation](docs/key_concepts/goals_outcome.md)** - Define objectives in natural language; the coding agent generates the agent graph and connection code to achieve them
|
||||
- **[Adaptiveness](docs/key_concepts/evolution.md)** - Framework captures failures, calibrates according to the objectives, and evolves the agent graph
|
||||
- **[Dynamic Node Connections](docs/key_concepts/graph.md)** - No predefined edges; connection code is generated by any capable LLM based on your goals
|
||||
- **SDK-Wrapped Nodes** - Every node gets shared memory, local RLM memory, monitoring, tools, and LLM access out of the box
|
||||
- **[Human-in-the-Loop](docs/key_concepts/graph.md#human-in-the-loop)** - Intervention nodes that pause execution for human input with configurable timeouts and escalation
|
||||
- **Real-time Observability** - WebSocket streaming for live monitoring of agent execution, decisions, and node-to-node communication
|
||||
- **Interactive TUI Dashboard** - Terminal-based dashboard with live graph view, event log, and chat interface for agent interaction
|
||||
- **Cost & Budget Control** - Set spending limits, throttles, and automatic model degradation policies
|
||||
- **Production-Ready** - Self-hostable, built for scale and reliability
|
||||
|
||||
## Integration
|
||||
|
||||
<a href="https://github.com/adenhq/hive/tree/main/tools/src/aden_tools/tools"><img width="100%" alt="Integration" src="https://github.com/user-attachments/assets/a1573f93-cf02-4bb8-b3d5-b305b05b1e51" /></a>
|
||||
|
||||
<a href="https://github.com/aden-hive/hive/tree/main/tools/src/aden_tools/tools"><img width="100%" alt="Integration" src="https://github.com/user-attachments/assets/a1573f93-cf02-4bb8-b3d5-b305b05b1e51" /></a>
|
||||
Hive is built to be model-agnostic and system-agnostic.
|
||||
|
||||
- **LLM flexibility** - Hive Framework is designed to support various types of LLMs, including hosted and local models through LiteLLM-compatible providers.
|
||||
@@ -240,35 +208,10 @@ flowchart LR
|
||||
4. **Control Plane Monitors** → Real-time metrics, budget enforcement, policy management
|
||||
5. **[Adaptiveness](docs/key_concepts/evolution.md)** → On failure, the system evolves the graph and redeploys automatically
|
||||
|
||||
## Run Agents
|
||||
|
||||
The `hive` CLI is the primary interface for running agents.
|
||||
|
||||
```bash
|
||||
# Browse and run agents interactively (Recommended)
|
||||
hive tui
|
||||
|
||||
# Run a specific agent directly
|
||||
hive run exports/my_agent --input '{"task": "Your input here"}'
|
||||
|
||||
# Run a specific agent with the TUI dashboard
|
||||
hive run exports/my_agent --tui
|
||||
|
||||
# Interactive REPL
|
||||
hive shell
|
||||
```
|
||||
|
||||
The TUI scans both `exports/` and `examples/templates/` for available agents.
|
||||
|
||||
> **Using Python directly (alternative):** You can also run agents with `PYTHONPATH=exports uv run python -m agent_name run --input '{...}'`
|
||||
|
||||
See [environment-setup.md](docs/environment-setup.md) for complete setup instructions.
|
||||
|
||||
## Documentation
|
||||
|
||||
- **[Developer Guide](docs/developer-guide.md)** - Comprehensive guide for developers
|
||||
- [Getting Started](docs/getting-started.md) - Quick setup instructions
|
||||
- [TUI Guide](docs/tui-selection-guide.md) - Interactive dashboard usage
|
||||
- [Configuration Guide](docs/configuration.md) - All configuration options
|
||||
- [Architecture Overview](docs/architecture/README.md) - System design and structure
|
||||
|
||||
@@ -398,8 +341,7 @@ flowchart TB
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions from the community! We’re especially looking for help building tools, integrations, and example agents for the framework ([check #2805](https://github.com/adenhq/hive/issues/2805)). If you’re interested in extending its functionality, this is the perfect place to start. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
We welcome contributions from the community! We’re especially looking for help building tools, integrations, and example agents for the framework ([check #2805](https://github.com/aden-hive/hive/issues/2805)). If you’re interested in extending its functionality, this is the perfect place to start. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
**Important:** Please get assigned to an issue before submitting a PR. Comment on an issue to claim it, and a maintainer will assign you. Issues with reproducible steps and proposals are prioritized. This helps prevent duplicate work.
|
||||
|
||||
@@ -436,7 +378,7 @@ This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENS
|
||||
|
||||
**Q: What LLM providers does Hive support?**
|
||||
|
||||
Hive supports 100+ LLM providers through LiteLLM integration, including OpenAI (GPT-4, GPT-4o), Anthropic (Claude models), Google Gemini, DeepSeek, Mistral, Groq, and many more. Simply set the appropriate API key environment variable and specify the model name.
|
||||
Hive supports 100+ LLM providers through LiteLLM integration, including OpenAI (GPT-4, GPT-4o), Anthropic (Claude models), Google Gemini, DeepSeek, Mistral, Groq, and many more. Simply set the appropriate API key environment variable and specify the model name. We recommend using Claude, GLM and Gemini as they have the best performance.
|
||||
|
||||
**Q: Can I use Hive with local AI models like Ollama?**
|
||||
|
||||
@@ -478,14 +420,6 @@ Visit [docs.adenhq.com](https://docs.adenhq.com/) for complete guides, API refer
|
||||
|
||||
Contributions are welcome! Fork the repository, create your feature branch, implement your changes, and submit a pull request. See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
|
||||
|
||||
**Q: When will my team start seeing results from Aden's adaptive agents?**
|
||||
|
||||
Aden's adaptation loop begins working from the first execution. When an agent fails, the framework captures the failure data, helping developers evolve the agent graph through the coding agent. How quickly this translates to measurable results depends on the complexity of your use case, the quality of your goal definitions, and the volume of executions generating feedback.
|
||||
|
||||
**Q: How does Hive compare to other agent frameworks?**
|
||||
|
||||
Hive focuses on generating agents that run real business processes, rather than generic agents. This vision emphasizes outcome-driven design, adaptability, and an easy-to-use set of tools and integrations.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
perf: reduce subprocess spawning in quickstart scripts (#4427)
|
||||
|
||||
## Problem
|
||||
Windows process creation (CreateProcess) is 10-100x slower than Linux fork/exec.
|
||||
The quickstart scripts were spawning 4+ separate `uv run python -c "import X"`
|
||||
processes to verify imports, adding ~600ms overhead on Windows.
|
||||
|
||||
## Solution
|
||||
Consolidated all import checks into a single batch script that checks multiple
|
||||
modules in one subprocess call, reducing spawn overhead by ~75%.
|
||||
|
||||
## Changes
|
||||
- **New**: `scripts/check_requirements.py` - Batched import checker
|
||||
- **New**: `scripts/test_check_requirements.py` - Test suite
|
||||
- **New**: `scripts/benchmark_quickstart.ps1` - Performance benchmark tool
|
||||
- **Modified**: `quickstart.ps1` - Updated import verification (2 sections)
|
||||
- **Modified**: `quickstart.sh` - Updated import verification
|
||||
|
||||
## Performance Impact
|
||||
**Benchmark results on Windows:**
|
||||
- Before: ~19.8 seconds for import checks
|
||||
- After: ~4.9 seconds for import checks
|
||||
- **Improvement: 14.9 seconds saved (75.2% faster)**
|
||||
|
||||
## Testing
|
||||
- ✅ All functional tests pass (`scripts/test_check_requirements.py`)
|
||||
- ✅ Quickstart scripts work correctly on Windows
|
||||
- ✅ Error handling verified (invalid imports reported correctly)
|
||||
- ✅ Performance benchmark confirms 75%+ improvement
|
||||
|
||||
Fixes #4427
|
||||
@@ -0,0 +1,12 @@
|
||||
# Identity mapping: GitHub username -> Discord ID
|
||||
#
|
||||
# DEPRECATED: Contributor identities are now stored in MongoDB.
|
||||
# Use /link-github <your-github-username> in Discord to link your account.
|
||||
#
|
||||
# This file is kept as a fallback for local development when MONGODB_URI
|
||||
# is not set. The bounty-tracker.ts script will prefer MongoDB when available.
|
||||
|
||||
contributors:
|
||||
# - github: example-user
|
||||
# discord: "123456789012345678"
|
||||
# name: Example User
|
||||
+9
-9
@@ -64,7 +64,7 @@ To use the agent builder with Claude Desktop or other MCP clients, add this to y
|
||||
"agent-builder": {
|
||||
"command": "python",
|
||||
"args": ["-m", "framework.mcp.agent_builder_server"],
|
||||
"cwd": "/path/to/goal-agent"
|
||||
"cwd": "/path/to/hive/core"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,14 +85,14 @@ The MCP server provides tools for:
|
||||
Run an LLM-powered calculator:
|
||||
|
||||
```bash
|
||||
# Single calculation
|
||||
uv run python -m framework calculate "2 + 3 * 4"
|
||||
# Run an exported agent
|
||||
uv run python -m framework run exports/calculator --input '{"expression": "2 + 3 * 4"}'
|
||||
|
||||
# Interactive mode
|
||||
uv run python -m framework interactive
|
||||
# Interactive shell session
|
||||
uv run python -m framework shell exports/calculator
|
||||
|
||||
# Analyze runs with Builder
|
||||
uv run python -m framework analyze calculator
|
||||
# Show agent info
|
||||
uv run python -m framework info exports/calculator
|
||||
```
|
||||
|
||||
### Using the Runtime
|
||||
@@ -141,8 +141,8 @@ uv run python -m framework test-run <agent_path> --goal <goal_id> --parallel 4
|
||||
# Debug failed tests
|
||||
uv run python -m framework test-debug <agent_path> <test_name>
|
||||
|
||||
# List tests for a goal
|
||||
uv run python -m framework test-list <goal_id>
|
||||
# List tests for an agent
|
||||
uv run python -m framework test-list <agent_path>
|
||||
```
|
||||
|
||||
For detailed testing workflows, see the [hive-test skill](../.claude/skills/hive-test/SKILL.md).
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""OpenAI Codex OAuth PKCE login flow.
|
||||
|
||||
Runs the full browser-based OAuth flow so users can authenticate with their
|
||||
ChatGPT Plus/Pro subscription without needing the Codex CLI installed.
|
||||
|
||||
Usage (from quickstart.sh):
|
||||
uv run python codex_oauth.py
|
||||
|
||||
Exit codes:
|
||||
0 - success (credentials saved to ~/.codex/auth.json)
|
||||
1 - failure (user cancelled, timeout, or token exchange error)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
# OAuth constants (from the Codex CLI binary)
|
||||
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
|
||||
TOKEN_URL = "https://auth.openai.com/oauth/token"
|
||||
REDIRECT_URI = "http://localhost:1455/auth/callback"
|
||||
SCOPE = "openid profile email offline_access"
|
||||
CALLBACK_PORT = 1455
|
||||
|
||||
# Where to save credentials (same location the Codex CLI uses)
|
||||
CODEX_AUTH_FILE = Path.home() / ".codex" / "auth.json"
|
||||
|
||||
# JWT claim path for account_id
|
||||
JWT_CLAIM_PATH = "https://api.openai.com/auth"
|
||||
|
||||
|
||||
def _base64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def generate_pkce() -> tuple[str, str]:
|
||||
"""Generate PKCE code_verifier and code_challenge (S256)."""
|
||||
verifier_bytes = secrets.token_bytes(32)
|
||||
verifier = _base64url(verifier_bytes)
|
||||
challenge = _base64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
def build_authorize_url(state: str, challenge: str) -> str:
|
||||
"""Build the OpenAI OAuth authorize URL with PKCE."""
|
||||
params = urllib.parse.urlencode(
|
||||
{
|
||||
"response_type": "code",
|
||||
"client_id": CLIENT_ID,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"scope": SCOPE,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": state,
|
||||
"id_token_add_organizations": "true",
|
||||
"codex_cli_simplified_flow": "true",
|
||||
"originator": "hive",
|
||||
}
|
||||
)
|
||||
return f"{AUTHORIZE_URL}?{params}"
|
||||
|
||||
|
||||
def exchange_code_for_tokens(code: str, verifier: str) -> dict | None:
|
||||
"""Exchange the authorization code for tokens."""
|
||||
data = urllib.parse.urlencode(
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": CLIENT_ID,
|
||||
"code": code,
|
||||
"code_verifier": verifier,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
TOKEN_URL,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
token_data = json.loads(resp.read())
|
||||
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError, OSError) as exc:
|
||||
print(f"\033[0;31mToken exchange failed: {exc}\033[0m", file=sys.stderr)
|
||||
return None
|
||||
|
||||
if not token_data.get("access_token") or not token_data.get("refresh_token"):
|
||||
print("\033[0;31mToken response missing required fields\033[0m", file=sys.stderr)
|
||||
return None
|
||||
|
||||
return token_data
|
||||
|
||||
|
||||
def decode_jwt_payload(token: str) -> dict | None:
|
||||
"""Decode the payload of a JWT (no signature verification)."""
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
payload = parts[1]
|
||||
# Add padding
|
||||
padding = 4 - len(payload) % 4
|
||||
if padding != 4:
|
||||
payload += "=" * padding
|
||||
decoded = base64.urlsafe_b64decode(payload)
|
||||
return json.loads(decoded)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_account_id(access_token: str) -> str | None:
|
||||
"""Extract the ChatGPT account_id from the access token JWT."""
|
||||
payload = decode_jwt_payload(access_token)
|
||||
if not payload:
|
||||
return None
|
||||
auth = payload.get(JWT_CLAIM_PATH)
|
||||
if isinstance(auth, dict):
|
||||
account_id = auth.get("chatgpt_account_id")
|
||||
if isinstance(account_id, str) and account_id:
|
||||
return account_id
|
||||
return None
|
||||
|
||||
|
||||
def save_credentials(token_data: dict, account_id: str) -> None:
|
||||
"""Save credentials to ~/.codex/auth.json in the same format the Codex CLI uses."""
|
||||
auth_data = {
|
||||
"tokens": {
|
||||
"access_token": token_data["access_token"],
|
||||
"refresh_token": token_data["refresh_token"],
|
||||
"account_id": account_id,
|
||||
},
|
||||
"auth_mode": "chatgpt",
|
||||
"last_refresh": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
if "id_token" in token_data:
|
||||
auth_data["tokens"]["id_token"] = token_data["id_token"]
|
||||
|
||||
CODEX_AUTH_FILE.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
fd = os.open(CODEX_AUTH_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(auth_data, f, indent=2)
|
||||
|
||||
|
||||
def open_browser(url: str) -> bool:
|
||||
"""Open the URL in the user's default browser."""
|
||||
system = platform.system()
|
||||
try:
|
||||
devnull = subprocess.DEVNULL
|
||||
if system == "Darwin":
|
||||
subprocess.Popen(["open", url], stdout=devnull, stderr=devnull)
|
||||
elif system == "Windows":
|
||||
subprocess.Popen(["cmd", "/c", "start", url], stdout=devnull, stderr=devnull)
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", url], stdout=devnull, stderr=devnull)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
|
||||
"""HTTP handler that captures the OAuth callback."""
|
||||
|
||||
auth_code: str | None = None
|
||||
received_state: str | None = None
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
if parsed.path != "/auth/callback":
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"Not found")
|
||||
return
|
||||
|
||||
params = urllib.parse.parse_qs(parsed.query)
|
||||
code = params.get("code", [None])[0]
|
||||
state = params.get("state", [None])[0]
|
||||
|
||||
if not code:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"Missing authorization code")
|
||||
return
|
||||
|
||||
OAuthCallbackHandler.auth_code = code
|
||||
OAuthCallbackHandler.received_state = state
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(
|
||||
b"<!doctype html><html><head><meta charset='utf-8'/></head>"
|
||||
b"<body><h2>Authentication successful</h2>"
|
||||
b"<p>Return to your terminal to continue.</p></body></html>"
|
||||
)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
# Suppress request logging
|
||||
pass
|
||||
|
||||
|
||||
def wait_for_callback(state: str, timeout_secs: int = 120) -> str | None:
|
||||
"""Start a local HTTP server and wait for the OAuth callback.
|
||||
|
||||
Returns the authorization code on success, None on timeout.
|
||||
"""
|
||||
OAuthCallbackHandler.auth_code = None
|
||||
OAuthCallbackHandler.received_state = None
|
||||
|
||||
server = http.server.HTTPServer(("127.0.0.1", CALLBACK_PORT), OAuthCallbackHandler)
|
||||
server.timeout = 1
|
||||
|
||||
deadline = time.time() + timeout_secs
|
||||
server_thread = threading.Thread(target=_serve_until_done, args=(server, deadline, state))
|
||||
server_thread.daemon = True
|
||||
server_thread.start()
|
||||
server_thread.join(timeout=timeout_secs + 2)
|
||||
|
||||
server.server_close()
|
||||
|
||||
if OAuthCallbackHandler.auth_code and OAuthCallbackHandler.received_state == state:
|
||||
return OAuthCallbackHandler.auth_code
|
||||
return None
|
||||
|
||||
|
||||
def _serve_until_done(server: http.server.HTTPServer, deadline: float, state: str) -> None:
|
||||
while time.time() < deadline:
|
||||
server.handle_request()
|
||||
if OAuthCallbackHandler.auth_code and OAuthCallbackHandler.received_state == state:
|
||||
return
|
||||
|
||||
|
||||
def parse_manual_input(value: str, expected_state: str) -> str | None:
|
||||
"""Parse user-pasted redirect URL or auth code."""
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(value)
|
||||
params = urllib.parse.parse_qs(parsed.query)
|
||||
code = params.get("code", [None])[0]
|
||||
state = params.get("state", [None])[0]
|
||||
if state and state != expected_state:
|
||||
return None
|
||||
return code
|
||||
except Exception:
|
||||
pass
|
||||
# Maybe it's just the raw code
|
||||
if len(value) > 10 and " " not in value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# Generate PKCE and state
|
||||
verifier, challenge = generate_pkce()
|
||||
state = secrets.token_hex(16)
|
||||
|
||||
# Build URL
|
||||
auth_url = build_authorize_url(state, challenge)
|
||||
|
||||
print()
|
||||
print("\033[1mOpenAI Codex OAuth Login\033[0m")
|
||||
print()
|
||||
|
||||
# Try to start the local callback server first
|
||||
try:
|
||||
server_available = True
|
||||
# Quick test that port is free
|
||||
import socket
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(1)
|
||||
result = sock.connect_ex(("127.0.0.1", CALLBACK_PORT))
|
||||
sock.close()
|
||||
if result == 0:
|
||||
print(f"\033[1;33mPort {CALLBACK_PORT} is in use. Using manual paste mode.\033[0m")
|
||||
server_available = False
|
||||
except Exception:
|
||||
server_available = True
|
||||
|
||||
# Open browser
|
||||
browser_opened = open_browser(auth_url)
|
||||
if browser_opened:
|
||||
print(" Browser opened for OpenAI sign-in...")
|
||||
else:
|
||||
print(" Could not open browser automatically.")
|
||||
|
||||
print()
|
||||
print(" If the browser didn't open, visit this URL:")
|
||||
print(f" \033[0;36m{auth_url}\033[0m")
|
||||
print()
|
||||
|
||||
code = None
|
||||
|
||||
if server_available:
|
||||
print(" Waiting for authentication (up to 2 minutes)...")
|
||||
print(" \033[2mOr paste the redirect URL below if the callback didn't work:\033[0m")
|
||||
print()
|
||||
|
||||
# Start callback server in background
|
||||
callback_result: list[str | None] = [None]
|
||||
|
||||
def run_server() -> None:
|
||||
callback_result[0] = wait_for_callback(state, timeout_secs=120)
|
||||
|
||||
server_thread = threading.Thread(target=run_server)
|
||||
server_thread.daemon = True
|
||||
server_thread.start()
|
||||
|
||||
# Also accept manual input in parallel
|
||||
# We poll for both the server result and stdin
|
||||
try:
|
||||
import select
|
||||
|
||||
while server_thread.is_alive():
|
||||
# Check if stdin has data (non-blocking on unix)
|
||||
if hasattr(select, "select"):
|
||||
ready, _, _ = select.select([sys.stdin], [], [], 0.5)
|
||||
if ready:
|
||||
manual = sys.stdin.readline()
|
||||
if manual.strip():
|
||||
code = parse_manual_input(manual, state)
|
||||
if code:
|
||||
break
|
||||
else:
|
||||
time.sleep(0.5)
|
||||
|
||||
if callback_result[0]:
|
||||
code = callback_result[0]
|
||||
break
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\n\033[0;31mCancelled.\033[0m")
|
||||
return 1
|
||||
|
||||
if not code:
|
||||
code = callback_result[0]
|
||||
else:
|
||||
# Manual paste mode
|
||||
try:
|
||||
manual = input(" Paste the redirect URL: ").strip()
|
||||
code = parse_manual_input(manual, state)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\n\033[0;31mCancelled.\033[0m")
|
||||
return 1
|
||||
|
||||
if not code:
|
||||
print("\n\033[0;31mAuthentication timed out or failed.\033[0m")
|
||||
return 1
|
||||
|
||||
# Exchange code for tokens
|
||||
print()
|
||||
print(" Exchanging authorization code for tokens...")
|
||||
token_data = exchange_code_for_tokens(code, verifier)
|
||||
if not token_data:
|
||||
return 1
|
||||
|
||||
# Extract account_id from JWT
|
||||
account_id = get_account_id(token_data["access_token"])
|
||||
if not account_id:
|
||||
print("\033[0;31mFailed to extract account ID from token.\033[0m", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Save credentials
|
||||
save_credentials(token_data, account_id)
|
||||
print(" \033[0;32mAuthentication successful!\033[0m")
|
||||
print(f" Credentials saved to {CODEX_AUTH_FILE}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1768,7 +1768,7 @@ async def _run_pipeline(websocket, initial_message: str):
|
||||
judge=judge,
|
||||
config=LoopConfig(
|
||||
max_iterations=30,
|
||||
max_tool_calls_per_turn=15,
|
||||
max_tool_calls_per_turn=30,
|
||||
max_history_tokens=64000,
|
||||
max_tool_result_chars=8_000,
|
||||
spillover_dir=str(_DATA_DIR),
|
||||
|
||||
@@ -751,7 +751,7 @@ async def _run_pipeline(websocket, topic: str):
|
||||
judge=None, # implicit judge: accept when output_keys filled
|
||||
config=LoopConfig(
|
||||
max_iterations=20,
|
||||
max_tool_calls_per_turn=10,
|
||||
max_tool_calls_per_turn=30,
|
||||
max_history_tokens=32_000,
|
||||
),
|
||||
conversation_store=store_a,
|
||||
@@ -849,7 +849,7 @@ async def _run_pipeline(websocket, topic: str):
|
||||
judge=None, # implicit judge
|
||||
config=LoopConfig(
|
||||
max_iterations=10,
|
||||
max_tool_calls_per_turn=5,
|
||||
max_tool_calls_per_turn=30,
|
||||
max_history_tokens=32_000,
|
||||
),
|
||||
conversation_store=store_b,
|
||||
|
||||
@@ -1257,7 +1257,7 @@ async def _run_org_pipeline(websocket, topic: str):
|
||||
judge=judge,
|
||||
config=LoopConfig(
|
||||
max_iterations=30,
|
||||
max_tool_calls_per_turn=25,
|
||||
max_tool_calls_per_turn=30,
|
||||
max_history_tokens=32_000,
|
||||
),
|
||||
conversation_store=store,
|
||||
|
||||
@@ -453,7 +453,7 @@ identity_prompt = (
|
||||
)
|
||||
loop_config = {
|
||||
"max_iterations": 50,
|
||||
"max_tool_calls_per_turn": 10,
|
||||
"max_tool_calls_per_turn": 30,
|
||||
"max_history_tokens": 32000,
|
||||
}
|
||||
|
||||
@@ -539,7 +539,7 @@ class CredentialTesterAgent:
|
||||
max_tokens=self.config.max_tokens,
|
||||
loop_config={
|
||||
"max_iterations": 50,
|
||||
"max_tool_calls_per_turn": 10,
|
||||
"max_tool_calls_per_turn": 30,
|
||||
"max_history_tokens": 32000,
|
||||
},
|
||||
conversation_mode="continuous",
|
||||
|
||||
@@ -69,7 +69,7 @@ goal = Goal(
|
||||
id="dynamic-tool-discovery",
|
||||
description=(
|
||||
"Always discover available tools dynamically via "
|
||||
"discover_mcp_tools before referencing tools in agent designs"
|
||||
"list_agent_tools before referencing tools in agent designs"
|
||||
),
|
||||
constraint_type="hard",
|
||||
category="correctness",
|
||||
@@ -127,7 +127,7 @@ identity_prompt = (
|
||||
)
|
||||
loop_config = {
|
||||
"max_iterations": 100,
|
||||
"max_tool_calls_per_turn": 20,
|
||||
"max_tool_calls_per_turn": 30,
|
||||
"max_history_tokens": 32000,
|
||||
}
|
||||
|
||||
@@ -160,8 +160,8 @@ queen_graph = GraphSpec(
|
||||
edges=[],
|
||||
conversation_mode="continuous",
|
||||
loop_config={
|
||||
"max_iterations": 200,
|
||||
"max_tool_calls_per_turn": 10,
|
||||
"max_iterations": 999_999,
|
||||
"max_tool_calls_per_turn": 30,
|
||||
"max_history_tokens": 32000,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ def _load_preferred_model() -> str:
|
||||
config_path = Path.home() / ".hive" / "configuration.json"
|
||||
if config_path.exists():
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
llm = config.get("llm", {})
|
||||
if llm.get("provider") and llm.get("model"):
|
||||
@@ -24,7 +24,7 @@ def _load_preferred_model() -> str:
|
||||
class RuntimeConfig:
|
||||
model: str = field(default_factory=_load_preferred_model)
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 40000
|
||||
max_tokens: int = 8000
|
||||
api_key: str | None = None
|
||||
api_base: str | None = None
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,11 +48,11 @@ profile_setup → daily_intake → update_tracker → analyze_progress → gener
|
||||
```
|
||||
`analyze_progress` has no tools. `schedule_reminders` just sets one boolean. `report` just presents analysis. `update_tracker` and `generate_plan` are sequential autonomous work.
|
||||
|
||||
**Good example** (3 nodes):
|
||||
**Good example** (2 nodes):
|
||||
```
|
||||
intake (client-facing) → process (autonomous: track + analyze + plan) → intake (loop back)
|
||||
process (autonomous: track + analyze + plan) → review (client-facing) → process (loop back)
|
||||
```
|
||||
One client-facing node handles ALL user interaction (setup, logging, reports). One autonomous node handles ALL backend work (CSV update, analysis, plan generation) with tools and context preserved.
|
||||
The queen handles intake (gathering requirements from the user) and passes the task via `run_agent_with_input(task)`. One autonomous node handles ALL backend work (CSV update, analysis, plan generation) with tools and context preserved. One client-facing node handles review/approval when needed.
|
||||
|
||||
12. **Adding framework gating for LLM behavior** — Don't add output rollback, premature rejection, or interaction protocol injection. Fix with better prompts or custom judges.
|
||||
|
||||
@@ -80,7 +80,7 @@ One client-facing node handles ALL user interaction (setup, logging, reports). O
|
||||
- Validate graph structure (nodes, edges, entry points)
|
||||
- Verify node specs (tools, prompts, client-facing flag)
|
||||
- Check goal/constraints/success criteria definitions
|
||||
- Test that `AgentRunner.load()` + `_setup()` succeeds (skip if no API key)
|
||||
- Test that `AgentRunner.load()` succeeds (structural, no API key needed)
|
||||
|
||||
**What NOT to do:**
|
||||
```python
|
||||
@@ -105,3 +105,9 @@ def test_research_routes_back_to_interact(self):
|
||||
23. **Forgetting sys.path setup in conftest.py** — Tests need `exports/` and `core/` on sys.path.
|
||||
|
||||
24. **Not using auto_responder for client-facing nodes** — Tests with client-facing nodes hang without an auto-responder that injects input. But note: even WITH auto_responder, forever-alive agents still hang because the graph never terminates. Auto-responder only helps for agents with terminal nodes.
|
||||
|
||||
25. **Manually wiring browser tools on event_loop nodes** — If the agent needs browser automation, use `node_type="gcu"` which auto-includes all browser tools and prepends best-practices guidance. Do NOT manually list browser tool names on event_loop nodes — they may not exist in the MCP server or may be incomplete. See the GCU Guide appendix.
|
||||
|
||||
26. **Using GCU nodes as regular graph nodes** — GCU nodes (`node_type="gcu"`) are exclusively subagents. They must ONLY appear in a parent node's `sub_agents=["gcu-node-id"]` list and be invoked via `delegate_to_sub_agent()`. They must NEVER be connected via edges, used as entry nodes, or used as terminal nodes. If a GCU node appears as an edge source or target, the graph will fail pre-load validation.
|
||||
|
||||
27. **Adding a client-facing intake node to worker agents** — The queen owns intake. She defines the entry node's `input_keys` at build time and fills them via `run_agent_with_input(task)` at run time. Worker agents should start with an autonomous processing node, NOT a client-facing intake node that asks the user for requirements. Client-facing nodes in workers are for mid-execution review/approval only.
|
||||
|
||||
@@ -57,51 +57,28 @@ metadata = AgentMetadata()
|
||||
|
||||
from framework.graph import NodeSpec
|
||||
|
||||
# Node 1: Intake (client-facing)
|
||||
intake_node = NodeSpec(
|
||||
id="intake",
|
||||
name="Intake",
|
||||
description="Gather requirements from the user",
|
||||
# Node 1: Process (autonomous entry node)
|
||||
# The queen handles intake and passes structured input via
|
||||
# run_agent_with_input(task). NO client-facing intake node.
|
||||
# The queen defines input_keys at build time and fills them at run time.
|
||||
process_node = NodeSpec(
|
||||
id="process",
|
||||
name="Process",
|
||||
description="Execute the task using available tools",
|
||||
node_type="event_loop",
|
||||
client_facing=True,
|
||||
max_node_visits=0, # Unlimited for forever-alive
|
||||
input_keys=["topic"],
|
||||
output_keys=["brief"],
|
||||
success_criteria="The brief is specific and actionable.",
|
||||
system_prompt="""\
|
||||
You are an intake specialist.
|
||||
|
||||
**STEP 1 — Read and respond (text only, NO tool calls):**
|
||||
1. Read the topic provided
|
||||
2. If vague, ask 1-2 clarifying questions
|
||||
3. If clear, confirm your understanding
|
||||
|
||||
**STEP 2 — After the user confirms, call set_output:**
|
||||
- set_output("brief", "Clear description of what to do")
|
||||
""",
|
||||
tools=[],
|
||||
)
|
||||
|
||||
# Node 2: Worker (autonomous)
|
||||
worker_node = NodeSpec(
|
||||
id="worker",
|
||||
name="Worker",
|
||||
description="Do the main work",
|
||||
node_type="event_loop",
|
||||
max_node_visits=0,
|
||||
input_keys=["brief", "feedback"],
|
||||
input_keys=["user_request", "feedback"],
|
||||
output_keys=["results"],
|
||||
nullable_output_keys=["feedback"], # Only on feedback edge
|
||||
success_criteria="Results are complete and accurate.",
|
||||
system_prompt="""\
|
||||
You are a worker agent. Given a brief, do the work.
|
||||
|
||||
If feedback is provided, this is a follow-up — address the feedback.
|
||||
You are a processing agent. Your task is in memory under "user_request". \
|
||||
If "feedback" is present, this is a revision — address the feedback.
|
||||
|
||||
Work in phases:
|
||||
1. Use tools to gather/process data
|
||||
2. Analyze results
|
||||
3. Call set_output for each key in a SEPARATE turn:
|
||||
3. Call set_output in a SEPARATE turn:
|
||||
- set_output("results", "structured results")
|
||||
""",
|
||||
tools=["web_search", "web_scrape", "save_data", "load_data", "list_data_files"],
|
||||
@@ -115,7 +92,7 @@ review_node = NodeSpec(
|
||||
node_type="event_loop",
|
||||
client_facing=True,
|
||||
max_node_visits=0,
|
||||
input_keys=["results", "brief"],
|
||||
input_keys=["results", "user_request"],
|
||||
output_keys=["next_action", "feedback"],
|
||||
nullable_output_keys=["feedback"],
|
||||
success_criteria="User has reviewed and decided next steps.",
|
||||
@@ -128,14 +105,14 @@ Present the results to the user.
|
||||
3. Ask: satisfied, or want changes?
|
||||
|
||||
**STEP 2 — After user responds, call set_output:**
|
||||
- set_output("next_action", "new_topic") — if starting fresh
|
||||
- set_output("next_action", "done") — if satisfied
|
||||
- set_output("next_action", "revise") — if changes needed
|
||||
- set_output("feedback", "what to change") — only if revising
|
||||
""",
|
||||
tools=[],
|
||||
)
|
||||
|
||||
__all__ = ["intake_node", "worker_node", "review_node"]
|
||||
__all__ = ["process_node", "review_node"]
|
||||
```
|
||||
|
||||
## agent.py
|
||||
@@ -155,7 +132,7 @@ from framework.runtime.agent_runtime import AgentRuntime, create_agent_runtime
|
||||
from framework.runtime.execution_stream import EntryPointSpec
|
||||
|
||||
from .config import default_config, metadata
|
||||
from .nodes import intake_node, worker_node, review_node
|
||||
from .nodes import process_node, review_node
|
||||
|
||||
# Goal definition
|
||||
goal = Goal(
|
||||
@@ -172,27 +149,26 @@ goal = Goal(
|
||||
)
|
||||
|
||||
# Node list
|
||||
nodes = [intake_node, worker_node, review_node]
|
||||
nodes = [process_node, review_node]
|
||||
|
||||
# Edge definitions
|
||||
edges = [
|
||||
EdgeSpec(id="intake-to-worker", source="intake", target="worker",
|
||||
EdgeSpec(id="process-to-review", source="process", target="review",
|
||||
condition=EdgeCondition.ON_SUCCESS, priority=1),
|
||||
EdgeSpec(id="worker-to-review", source="worker", target="review",
|
||||
condition=EdgeCondition.ON_SUCCESS, priority=1),
|
||||
# Feedback loop
|
||||
EdgeSpec(id="review-to-worker", source="review", target="worker",
|
||||
# Feedback loop — revise results
|
||||
EdgeSpec(id="review-to-process", source="review", target="process",
|
||||
condition=EdgeCondition.CONDITIONAL,
|
||||
condition_expr="str(next_action).lower() == 'revise'", priority=2),
|
||||
# Loop back for new topic
|
||||
EdgeSpec(id="review-to-intake", source="review", target="intake",
|
||||
# Loop back for next task (queen sends new input)
|
||||
EdgeSpec(id="review-done", source="review", target="process",
|
||||
condition=EdgeCondition.CONDITIONAL,
|
||||
condition_expr="str(next_action).lower() == 'new_topic'", priority=1),
|
||||
condition_expr="str(next_action).lower() == 'done'", priority=1),
|
||||
]
|
||||
|
||||
# Graph configuration
|
||||
entry_node = "intake"
|
||||
entry_points = {"start": "intake"}
|
||||
# Graph configuration — entry is the autonomous process node
|
||||
# The queen handles intake and passes the task via run_agent_with_input(task)
|
||||
entry_node = "process"
|
||||
entry_points = {"start": "process"}
|
||||
pause_nodes = []
|
||||
terminal_nodes = [] # Forever-alive
|
||||
|
||||
@@ -208,7 +184,7 @@ class MyAgent:
|
||||
self.goal = goal
|
||||
self.nodes = nodes
|
||||
self.edges = edges
|
||||
self.entry_node = entry_node
|
||||
self.entry_node = entry_node # "process" — autonomous entry
|
||||
self.entry_points = entry_points
|
||||
self.pause_nodes = pause_nodes
|
||||
self.terminal_nodes = terminal_nodes
|
||||
@@ -235,16 +211,14 @@ class MyAgent:
|
||||
identity_prompt=identity_prompt,
|
||||
)
|
||||
|
||||
def _setup(self, mock_mode=False):
|
||||
def _setup(self):
|
||||
self._storage_path = Path.home() / ".hive" / "agents" / "my_agent"
|
||||
self._storage_path.mkdir(parents=True, exist_ok=True)
|
||||
self._tool_registry = ToolRegistry()
|
||||
mcp_config = Path(__file__).parent / "mcp_servers.json"
|
||||
if mcp_config.exists():
|
||||
self._tool_registry.load_mcp_config(mcp_config)
|
||||
llm = None
|
||||
if not mock_mode:
|
||||
llm = LiteLLMProvider(model=self.config.model, api_key=self.config.api_key, api_base=self.config.api_base)
|
||||
llm = LiteLLMProvider(model=self.config.model, api_key=self.config.api_key, api_base=self.config.api_base)
|
||||
tools = list(self._tool_registry.get_tools().values())
|
||||
tool_executor = self._tool_registry.get_executor()
|
||||
self._graph = self._build_graph()
|
||||
@@ -257,9 +231,9 @@ class MyAgent:
|
||||
checkpoint_max_age_days=7, async_checkpoint=True),
|
||||
)
|
||||
|
||||
async def start(self, mock_mode=False):
|
||||
async def start(self):
|
||||
if self._agent_runtime is None:
|
||||
self._setup(mock_mode=mock_mode)
|
||||
self._setup()
|
||||
if not self._agent_runtime.is_running:
|
||||
await self._agent_runtime.start()
|
||||
|
||||
@@ -274,8 +248,8 @@ class MyAgent:
|
||||
return await self._agent_runtime.trigger_and_wait(
|
||||
entry_point_id=entry_point, input_data=input_data or {}, session_state=session_state)
|
||||
|
||||
async def run(self, context, mock_mode=False, session_state=None):
|
||||
await self.start(mock_mode=mock_mode)
|
||||
async def run(self, context, session_state=None):
|
||||
await self.start()
|
||||
try:
|
||||
result = await self.trigger_and_wait("default", context, session_state=session_state)
|
||||
return result or ExecutionResult(success=False, error="Execution timeout")
|
||||
@@ -471,19 +445,17 @@ def cli():
|
||||
|
||||
@cli.command()
|
||||
@click.option("--topic", "-t", required=True)
|
||||
@click.option("--mock", is_flag=True)
|
||||
@click.option("--verbose", "-v", is_flag=True)
|
||||
def run(topic, mock, verbose):
|
||||
def run(topic, verbose):
|
||||
"""Execute the agent."""
|
||||
setup_logging(verbose=verbose)
|
||||
result = asyncio.run(default_agent.run({"topic": topic}, mock_mode=mock))
|
||||
result = asyncio.run(default_agent.run({"topic": topic}))
|
||||
click.echo(json.dumps({"success": result.success, "output": result.output}, indent=2, default=str))
|
||||
sys.exit(0 if result.success else 1)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--mock", is_flag=True)
|
||||
def tui(mock):
|
||||
def tui():
|
||||
"""Launch TUI dashboard."""
|
||||
from pathlib import Path
|
||||
from framework.tui.app import AdenTUI
|
||||
@@ -499,10 +471,10 @@ def tui(mock):
|
||||
storage.mkdir(parents=True, exist_ok=True)
|
||||
mcp_cfg = Path(__file__).parent / "mcp_servers.json"
|
||||
if mcp_cfg.exists(): agent._tool_registry.load_mcp_config(mcp_cfg)
|
||||
llm = None if mock else LiteLLMProvider(model=agent.config.model, api_key=agent.config.api_key, api_base=agent.config.api_base)
|
||||
llm = LiteLLMProvider(model=agent.config.model, api_key=agent.config.api_key, api_base=agent.config.api_base)
|
||||
runtime = create_agent_runtime(
|
||||
graph=agent._build_graph(), goal=agent.goal, storage_path=storage,
|
||||
entry_points=[EntryPointSpec(id="start", name="Start", entry_node="intake", trigger_type="manual", isolation_level="isolated")],
|
||||
entry_points=[EntryPointSpec(id="start", name="Start", entry_node="process", trigger_type="manual", isolation_level="isolated")],
|
||||
llm=llm, tools=list(agent._tool_registry.get_tools().values()), tool_executor=agent._tool_registry.get_executor())
|
||||
await runtime.start()
|
||||
try:
|
||||
@@ -564,7 +536,6 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
_repo_root = Path(__file__).resolve().parents[3]
|
||||
for _p in ["exports", "core"]:
|
||||
@@ -576,18 +547,17 @@ AGENT_PATH = str(Path(__file__).resolve().parents[1])
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def mock_mode():
|
||||
return True
|
||||
def agent_module():
|
||||
"""Import the agent package for structural validation."""
|
||||
import importlib
|
||||
return importlib.import_module(Path(AGENT_PATH).name)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
async def runner(tmp_path_factory, mock_mode):
|
||||
@pytest.fixture(scope="session")
|
||||
def runner_loaded():
|
||||
"""Load the agent through AgentRunner (structural only, no LLM needed)."""
|
||||
from framework.runner.runner import AgentRunner
|
||||
storage = tmp_path_factory.mktemp("agent_storage")
|
||||
r = AgentRunner.load(AGENT_PATH, mock_mode=mock_mode, storage_path=storage)
|
||||
r._setup()
|
||||
yield r
|
||||
await r.cleanup_async()
|
||||
return AgentRunner.load(AGENT_PATH)
|
||||
```
|
||||
|
||||
## entry_points Format
|
||||
|
||||
@@ -72,7 +72,7 @@ goal = Goal(
|
||||
| id | str | required | kebab-case identifier |
|
||||
| name | str | required | Display name |
|
||||
| description | str | required | What the node does |
|
||||
| node_type | str | required | Always `"event_loop"` |
|
||||
| node_type | str | required | `"event_loop"` or `"gcu"` (browser automation — see GCU Guide appendix) |
|
||||
| input_keys | list[str] | required | Memory keys this node reads |
|
||||
| output_keys | list[str] | required | Memory keys this node writes via set_output |
|
||||
| system_prompt | str | "" | LLM instructions |
|
||||
@@ -131,13 +131,19 @@ downstream node only sees the serialized summary string.
|
||||
- A "report" node that presents analysis → merge into the client-facing node
|
||||
- A "confirm" or "schedule" node that doesn't call any external service → remove
|
||||
|
||||
**Typical agent structure (3 nodes):**
|
||||
**Typical agent structure (2 nodes):**
|
||||
```
|
||||
intake (client-facing) ←→ process (autonomous) ←→ review (client-facing)
|
||||
process (autonomous) ←→ review (client-facing)
|
||||
```
|
||||
Or for simpler agents, just 2 nodes:
|
||||
The queen owns intake — she gathers requirements from the user, then
|
||||
passes structured input via `run_agent_with_input(task)`. When building
|
||||
the agent, design the entry node's `input_keys` to match what the queen
|
||||
will provide at run time. Worker agents should NOT have a client-facing
|
||||
intake node. Client-facing nodes are for mid-execution review/approval only.
|
||||
|
||||
For simpler agents, just 1 autonomous node:
|
||||
```
|
||||
interact (client-facing) → process (autonomous) → interact (loop)
|
||||
process (autonomous) — loops back to itself
|
||||
```
|
||||
|
||||
### nullable_output_keys
|
||||
@@ -397,7 +403,7 @@ from .agent import (
|
||||
### Reference Agent
|
||||
|
||||
See `exports/gmail_inbox_guardian/agent.py` for a complete example with:
|
||||
- Primary client-facing intake node (user configures rules)
|
||||
- Primary client-facing node (user configures rules)
|
||||
- Timer-based scheduled inbox checks (every 20 min)
|
||||
- Webhook-triggered email event handling
|
||||
- Shared isolation for memory access across streams
|
||||
@@ -413,13 +419,13 @@ See `exports/gmail_inbox_guardian/agent.py` for a complete example with:
|
||||
## Tool Discovery
|
||||
|
||||
Do NOT rely on a static tool list — it will be outdated. Always use
|
||||
`list_agent_tools()` to get available tool names grouped by category.
|
||||
For full schemas with parameter details, use `discover_mcp_tools()`.
|
||||
`list_agent_tools()` to discover available tools, grouped by category.
|
||||
|
||||
```
|
||||
list_agent_tools() # all available tools
|
||||
list_agent_tools("exports/my_agent/mcp_servers.json") # specific agent
|
||||
discover_mcp_tools() # full schemas with params
|
||||
list_agent_tools() # names + descriptions, all groups
|
||||
list_agent_tools(output_schema="full") # include input_schema
|
||||
list_agent_tools(group="gmail") # only gmail_* tools
|
||||
list_agent_tools("exports/my_agent/mcp_servers.json") # specific agent's tools
|
||||
```
|
||||
|
||||
After building, validate tools exist: `validate_agent_tools("exports/{name}")`
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# GCU Browser Automation Guide
|
||||
|
||||
## When to Use GCU Nodes
|
||||
|
||||
Use `node_type="gcu"` when:
|
||||
- The user's workflow requires **navigating real websites** (scraping, form-filling, social media interaction, testing web UIs)
|
||||
- The task involves **dynamic/JS-rendered pages** that `web_scrape` cannot handle (SPAs, infinite scroll, login-gated content)
|
||||
- The agent needs to **interact with a website** — clicking, typing, scrolling, selecting, uploading files
|
||||
|
||||
Do NOT use GCU for:
|
||||
- Static content that `web_scrape` handles fine
|
||||
- API-accessible data (use the API directly)
|
||||
- PDF/file processing
|
||||
- Anything that doesn't require a browser UI
|
||||
|
||||
## What GCU Nodes Are
|
||||
|
||||
- `node_type="gcu"` — a declarative enhancement over `event_loop`
|
||||
- Framework auto-prepends browser best-practices system prompt
|
||||
- Framework auto-includes all 31 browser tools from `gcu-tools` MCP server
|
||||
- Same underlying `EventLoopNode` class — no new imports needed
|
||||
- `tools=[]` is correct — tools are auto-populated at runtime
|
||||
|
||||
## GCU Architecture Pattern
|
||||
|
||||
GCU nodes are **subagents** — invoked via `delegate_to_sub_agent()`, not connected via edges.
|
||||
|
||||
- Primary nodes (`event_loop`, client-facing) orchestrate; GCU nodes do browser work
|
||||
- Parent node declares `sub_agents=["gcu-node-id"]` and calls `delegate_to_sub_agent(agent_id="gcu-node-id", task="...")`
|
||||
- GCU nodes set `max_node_visits=1` (single execution per delegation), `client_facing=False`
|
||||
- GCU nodes use `output_keys=["result"]` and return structured JSON via `set_output("result", ...)`
|
||||
|
||||
## GCU Node Definition Template
|
||||
|
||||
```python
|
||||
gcu_browser_node = NodeSpec(
|
||||
id="gcu-browser-worker",
|
||||
name="Browser Worker",
|
||||
description="Browser subagent that does X.",
|
||||
node_type="gcu",
|
||||
client_facing=False,
|
||||
max_node_visits=1,
|
||||
input_keys=[],
|
||||
output_keys=["result"],
|
||||
tools=[], # Auto-populated with all browser tools
|
||||
system_prompt="""\
|
||||
You are a browser agent. Your job: [specific task].
|
||||
|
||||
## Workflow
|
||||
1. browser_start (only if no browser is running yet)
|
||||
2. browser_open(url=TARGET_URL) — note the returned targetId
|
||||
3. browser_snapshot to read the page
|
||||
4. [task-specific steps]
|
||||
5. set_output("result", JSON)
|
||||
|
||||
## Output format
|
||||
set_output("result", JSON) with:
|
||||
- [field]: [type and description]
|
||||
""",
|
||||
)
|
||||
```
|
||||
|
||||
## Parent Node Template (orchestrating GCU subagents)
|
||||
|
||||
```python
|
||||
orchestrator_node = NodeSpec(
|
||||
id="orchestrator",
|
||||
...
|
||||
node_type="event_loop",
|
||||
sub_agents=["gcu-browser-worker"],
|
||||
system_prompt="""\
|
||||
...
|
||||
delegate_to_sub_agent(
|
||||
agent_id="gcu-browser-worker",
|
||||
task="Navigate to [URL]. Do [specific task]. Return JSON with [fields]."
|
||||
)
|
||||
...
|
||||
""",
|
||||
tools=[], # Orchestrator doesn't need browser tools
|
||||
)
|
||||
```
|
||||
|
||||
## mcp_servers.json with GCU
|
||||
|
||||
```json
|
||||
{
|
||||
"hive-tools": { ... },
|
||||
"gcu-tools": {
|
||||
"transport": "stdio",
|
||||
"command": "uv",
|
||||
"args": ["run", "python", "-m", "gcu.server", "--stdio"],
|
||||
"cwd": "../../tools",
|
||||
"description": "GCU tools for browser automation"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `gcu-tools` is auto-added if any node uses `node_type="gcu"`, but including it explicitly is fine.
|
||||
|
||||
## GCU System Prompt Best Practices
|
||||
|
||||
Key rules to bake into GCU node prompts:
|
||||
|
||||
- Prefer `browser_snapshot` over `browser_get_text("body")` — compact accessibility tree vs 100KB+ raw HTML
|
||||
- Always `browser_wait` after navigation
|
||||
- Use large scroll amounts (~2000-5000) for lazy-loaded content
|
||||
- For spillover files, use `run_command` with grep, not `read_file`
|
||||
- If auth wall detected, report immediately — don't attempt login
|
||||
- Keep tool calls per turn ≤10
|
||||
- Tab isolation: when browser is already running, use `browser_open(background=true)` and pass `target_id` to every call
|
||||
|
||||
## GCU Anti-Patterns
|
||||
|
||||
- Using `browser_screenshot` to read text (use `browser_snapshot`)
|
||||
- Re-navigating after scrolling (resets scroll position)
|
||||
- Attempting login on auth walls
|
||||
- Forgetting `target_id` in multi-tab scenarios
|
||||
- Putting browser tools directly on `event_loop` nodes instead of using GCU subagent pattern
|
||||
- Making GCU nodes `client_facing=True` (they should be autonomous subagents)
|
||||
@@ -660,7 +660,7 @@ class GraphBuilder:
|
||||
# Generate Python code
|
||||
code = self._generate_code(graph)
|
||||
|
||||
Path(path).write_text(code)
|
||||
Path(path).write_text(code, encoding="utf-8")
|
||||
self.session.phase = BuildPhase.EXPORTED
|
||||
self._save_session()
|
||||
|
||||
@@ -754,14 +754,14 @@ class GraphBuilder:
|
||||
"""Save session to disk."""
|
||||
self.session.updated_at = datetime.now()
|
||||
path = self.storage_path / f"{self.session.id}.json"
|
||||
path.write_text(self.session.model_dump_json(indent=2))
|
||||
path.write_text(self.session.model_dump_json(indent=2), encoding="utf-8")
|
||||
|
||||
def _load_session(self, session_id: str) -> BuildSession:
|
||||
"""Load session from disk."""
|
||||
path = self.storage_path / f"{session_id}.json"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Session not found: {session_id}")
|
||||
return BuildSession.model_validate_json(path.read_text())
|
||||
return BuildSession.model_validate_json(path.read_text(encoding="utf-8"))
|
||||
|
||||
@classmethod
|
||||
def list_sessions(cls, storage_path: Path | str | None = None) -> list[str]:
|
||||
|
||||
@@ -50,12 +50,14 @@ def get_max_tokens() -> int:
|
||||
|
||||
|
||||
def get_api_key() -> str | None:
|
||||
"""Return the API key, supporting env var, Claude Code subscription, and ZAI Code.
|
||||
"""Return the API key, supporting env var, Claude Code subscription, Codex, and ZAI Code.
|
||||
|
||||
Priority:
|
||||
1. Claude Code subscription (``use_claude_code_subscription: true``)
|
||||
reads the OAuth token from ``~/.claude/.credentials.json``.
|
||||
2. Environment variable named in ``api_key_env_var``.
|
||||
2. Codex subscription (``use_codex_subscription: true``)
|
||||
reads the OAuth token from macOS Keychain or ``~/.codex/auth.json``.
|
||||
3. Environment variable named in ``api_key_env_var``.
|
||||
"""
|
||||
llm = get_hive_config().get("llm", {})
|
||||
|
||||
@@ -70,6 +72,17 @@ def get_api_key() -> str | None:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Codex subscription: read OAuth token from Keychain / auth.json
|
||||
if llm.get("use_codex_subscription"):
|
||||
try:
|
||||
from framework.runner.runner import get_codex_token
|
||||
|
||||
token = get_codex_token()
|
||||
if token:
|
||||
return token
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Standard env-var path (covers ZAI Code and all API-key providers)
|
||||
api_key_env_var = llm.get("api_key_env_var")
|
||||
if api_key_env_var:
|
||||
@@ -77,9 +90,18 @@ def get_api_key() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_gcu_enabled() -> bool:
|
||||
"""Return whether GCU (browser automation) is enabled in user config."""
|
||||
return get_hive_config().get("gcu_enabled", True)
|
||||
|
||||
|
||||
def get_api_base() -> str | None:
|
||||
"""Return the api_base URL for OpenAI-compatible endpoints, if configured."""
|
||||
return get_hive_config().get("llm", {}).get("api_base")
|
||||
llm = get_hive_config().get("llm", {})
|
||||
if llm.get("use_codex_subscription"):
|
||||
# Codex subscription routes through the ChatGPT backend, not api.openai.com.
|
||||
return "https://chatgpt.com/backend-api/codex"
|
||||
return llm.get("api_base")
|
||||
|
||||
|
||||
def get_llm_extra_kwargs() -> dict[str, Any]:
|
||||
@@ -88,6 +110,10 @@ def get_llm_extra_kwargs() -> dict[str, Any]:
|
||||
When ``use_claude_code_subscription`` is enabled, returns
|
||||
``extra_headers`` with the OAuth Bearer token so that litellm's
|
||||
built-in Anthropic OAuth handler adds the required beta headers.
|
||||
|
||||
When ``use_codex_subscription`` is enabled, returns
|
||||
``extra_headers`` with the Bearer token, ``ChatGPT-Account-Id``,
|
||||
and ``store=False`` (required by the ChatGPT backend).
|
||||
"""
|
||||
llm = get_hive_config().get("llm", {})
|
||||
if llm.get("use_claude_code_subscription"):
|
||||
@@ -96,6 +122,26 @@ def get_llm_extra_kwargs() -> dict[str, Any]:
|
||||
return {
|
||||
"extra_headers": {"authorization": f"Bearer {api_key}"},
|
||||
}
|
||||
if llm.get("use_codex_subscription"):
|
||||
api_key = get_api_key()
|
||||
if api_key:
|
||||
headers: dict[str, str] = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": "CodexBar",
|
||||
}
|
||||
try:
|
||||
from framework.runner.runner import get_codex_account_id
|
||||
|
||||
account_id = get_codex_account_id()
|
||||
if account_id:
|
||||
headers["ChatGPT-Account-Id"] = account_id
|
||||
except ImportError:
|
||||
pass
|
||||
return {
|
||||
"extra_headers": headers,
|
||||
"store": False,
|
||||
"allowed_openai_params": ["store"],
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
@@ -42,6 +42,14 @@ For Vault integration:
|
||||
from core.framework.credentials.vault import HashiCorpVaultStorage
|
||||
"""
|
||||
|
||||
from .key_storage import (
|
||||
delete_aden_api_key,
|
||||
generate_and_save_credential_key,
|
||||
load_aden_api_key,
|
||||
load_credential_key,
|
||||
save_aden_api_key,
|
||||
save_credential_key,
|
||||
)
|
||||
from .models import (
|
||||
CredentialDecryptionError,
|
||||
CredentialError,
|
||||
@@ -63,7 +71,7 @@ from .setup import (
|
||||
CredentialSetupSession,
|
||||
MissingCredential,
|
||||
SetupResult,
|
||||
detect_missing_credentials_from_nodes,
|
||||
load_agent_nodes,
|
||||
run_credential_setup_cli,
|
||||
)
|
||||
from .storage import (
|
||||
@@ -75,7 +83,12 @@ from .storage import (
|
||||
)
|
||||
from .store import CredentialStore
|
||||
from .template import TemplateResolver
|
||||
from .validation import ensure_credential_key_env, validate_agent_credentials
|
||||
from .validation import (
|
||||
CredentialStatus,
|
||||
CredentialValidationResult,
|
||||
ensure_credential_key_env,
|
||||
validate_agent_credentials,
|
||||
)
|
||||
|
||||
# Aden sync components (lazy import to avoid httpx dependency when not needed)
|
||||
# Usage: from core.framework.credentials.aden import AdenSyncProvider
|
||||
@@ -127,14 +140,23 @@ __all__ = [
|
||||
"CredentialRefreshError",
|
||||
"CredentialValidationError",
|
||||
"CredentialDecryptionError",
|
||||
# Key storage (bootstrap credentials)
|
||||
"load_credential_key",
|
||||
"save_credential_key",
|
||||
"generate_and_save_credential_key",
|
||||
"load_aden_api_key",
|
||||
"save_aden_api_key",
|
||||
"delete_aden_api_key",
|
||||
# Validation
|
||||
"ensure_credential_key_env",
|
||||
"validate_agent_credentials",
|
||||
"CredentialStatus",
|
||||
"CredentialValidationResult",
|
||||
# Interactive setup
|
||||
"CredentialSetupSession",
|
||||
"MissingCredential",
|
||||
"SetupResult",
|
||||
"detect_missing_credentials_from_nodes",
|
||||
"load_agent_nodes",
|
||||
"run_credential_setup_cli",
|
||||
# Aden sync (optional - requires httpx)
|
||||
"AdenSyncProvider",
|
||||
|
||||
@@ -26,7 +26,7 @@ Usage:
|
||||
storage = AdenCachedStorage(
|
||||
local_storage=EncryptedFileStorage(),
|
||||
aden_provider=provider,
|
||||
cache_ttl_seconds=300, # Re-check Aden every 5 minutes
|
||||
cache_ttl_seconds=600, # Re-check Aden every 5 minutes
|
||||
)
|
||||
|
||||
# Create store
|
||||
@@ -77,7 +77,7 @@ class AdenCachedStorage(CredentialStorage):
|
||||
storage = AdenCachedStorage(
|
||||
local_storage=EncryptedFileStorage(),
|
||||
aden_provider=provider,
|
||||
cache_ttl_seconds=300, # 5 minutes
|
||||
cache_ttl_seconds=00, # 5 minutes
|
||||
)
|
||||
|
||||
store = CredentialStore(
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
Dedicated file-based storage for bootstrap credentials.
|
||||
|
||||
HIVE_CREDENTIAL_KEY -> ~/.hive/secrets/credential_key (plain text, chmod 600)
|
||||
ADEN_API_KEY -> ~/.hive/credentials/ (encrypted via EncryptedFileStorage)
|
||||
|
||||
Boot order:
|
||||
1. load_credential_key() -- reads/generates the Fernet key, sets os.environ
|
||||
2. load_aden_api_key() -- uses the encrypted store (which needs the key from step 1)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CREDENTIAL_KEY_PATH = Path.home() / ".hive" / "secrets" / "credential_key"
|
||||
CREDENTIAL_KEY_ENV_VAR = "HIVE_CREDENTIAL_KEY"
|
||||
ADEN_CREDENTIAL_ID = "aden_api_key"
|
||||
ADEN_ENV_VAR = "ADEN_API_KEY"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HIVE_CREDENTIAL_KEY
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_credential_key() -> str | None:
|
||||
"""Load HIVE_CREDENTIAL_KEY with priority: env > file > shell config.
|
||||
|
||||
Sets ``os.environ["HIVE_CREDENTIAL_KEY"]`` as a side-effect when found.
|
||||
Returns the key string, or ``None`` if unavailable everywhere.
|
||||
"""
|
||||
# 1. Already in environment (set by parent process, CI, Windows Registry, etc.)
|
||||
key = os.environ.get(CREDENTIAL_KEY_ENV_VAR)
|
||||
if key:
|
||||
return key
|
||||
|
||||
# 2. Dedicated secrets file
|
||||
key = _read_credential_key_file()
|
||||
if key:
|
||||
os.environ[CREDENTIAL_KEY_ENV_VAR] = key
|
||||
return key
|
||||
|
||||
# 3. Shell config fallback (backward compat for old installs)
|
||||
key = _read_from_shell_config(CREDENTIAL_KEY_ENV_VAR)
|
||||
if key:
|
||||
os.environ[CREDENTIAL_KEY_ENV_VAR] = key
|
||||
return key
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def save_credential_key(key: str) -> Path:
|
||||
"""Save HIVE_CREDENTIAL_KEY to ``~/.hive/secrets/credential_key``.
|
||||
|
||||
Creates parent dirs with mode 700, writes the file with mode 600.
|
||||
Also sets ``os.environ["HIVE_CREDENTIAL_KEY"]``.
|
||||
|
||||
Returns:
|
||||
The path that was written.
|
||||
"""
|
||||
path = CREDENTIAL_KEY_PATH
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Restrict the secrets directory itself
|
||||
path.parent.chmod(stat.S_IRWXU) # 0o700
|
||||
|
||||
path.write_text(key, encoding="utf-8")
|
||||
path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600
|
||||
|
||||
os.environ[CREDENTIAL_KEY_ENV_VAR] = key
|
||||
return path
|
||||
|
||||
|
||||
def generate_and_save_credential_key() -> str:
|
||||
"""Generate a new Fernet key and persist it to ``~/.hive/secrets/credential_key``.
|
||||
|
||||
Returns:
|
||||
The generated key string.
|
||||
"""
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
key = Fernet.generate_key().decode()
|
||||
save_credential_key(key)
|
||||
return key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ADEN_API_KEY
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_aden_api_key() -> str | None:
|
||||
"""Load ADEN_API_KEY with priority: env > encrypted store > shell config.
|
||||
|
||||
**Must** be called after ``load_credential_key()`` because the encrypted
|
||||
store depends on HIVE_CREDENTIAL_KEY.
|
||||
|
||||
Sets ``os.environ["ADEN_API_KEY"]`` as a side-effect when found.
|
||||
Returns the key string, or ``None`` if unavailable everywhere.
|
||||
"""
|
||||
# 1. Already in environment
|
||||
key = os.environ.get(ADEN_ENV_VAR)
|
||||
if key:
|
||||
return key
|
||||
|
||||
# 2. Encrypted credential store
|
||||
key = _read_aden_from_encrypted_store()
|
||||
if key:
|
||||
os.environ[ADEN_ENV_VAR] = key
|
||||
return key
|
||||
|
||||
# 3. Shell config fallback (backward compat)
|
||||
key = _read_from_shell_config(ADEN_ENV_VAR)
|
||||
if key:
|
||||
os.environ[ADEN_ENV_VAR] = key
|
||||
return key
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def save_aden_api_key(key: str) -> None:
|
||||
"""Save ADEN_API_KEY to the encrypted credential store.
|
||||
|
||||
Also sets ``os.environ["ADEN_API_KEY"]``.
|
||||
"""
|
||||
from pydantic import SecretStr
|
||||
|
||||
from .models import CredentialKey, CredentialObject
|
||||
from .storage import EncryptedFileStorage
|
||||
|
||||
storage = EncryptedFileStorage()
|
||||
cred = CredentialObject(
|
||||
id=ADEN_CREDENTIAL_ID,
|
||||
keys={"api_key": CredentialKey(name="api_key", value=SecretStr(key))},
|
||||
)
|
||||
storage.save(cred)
|
||||
os.environ[ADEN_ENV_VAR] = key
|
||||
|
||||
|
||||
def delete_aden_api_key() -> None:
|
||||
"""Remove ADEN_API_KEY from the encrypted store and ``os.environ``."""
|
||||
try:
|
||||
from .storage import EncryptedFileStorage
|
||||
|
||||
storage = EncryptedFileStorage()
|
||||
storage.delete(ADEN_CREDENTIAL_ID)
|
||||
except Exception:
|
||||
logger.debug("Could not delete %s from encrypted store", ADEN_CREDENTIAL_ID)
|
||||
|
||||
os.environ.pop(ADEN_ENV_VAR, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _read_credential_key_file() -> str | None:
|
||||
"""Read the credential key from ``~/.hive/secrets/credential_key``."""
|
||||
try:
|
||||
if CREDENTIAL_KEY_PATH.is_file():
|
||||
value = CREDENTIAL_KEY_PATH.read_text(encoding="utf-8").strip()
|
||||
if value:
|
||||
return value
|
||||
except Exception:
|
||||
logger.debug("Could not read %s", CREDENTIAL_KEY_PATH)
|
||||
return None
|
||||
|
||||
|
||||
def _read_from_shell_config(env_var: str) -> str | None:
|
||||
"""Fallback: read an env var from ~/.zshrc or ~/.bashrc."""
|
||||
try:
|
||||
from aden_tools.credentials.shell_config import check_env_var_in_shell_config
|
||||
|
||||
found, value = check_env_var_in_shell_config(env_var)
|
||||
if found and value:
|
||||
return value
|
||||
except ImportError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _read_aden_from_encrypted_store() -> str | None:
|
||||
"""Try to load ADEN_API_KEY from the encrypted credential store."""
|
||||
if not os.environ.get(CREDENTIAL_KEY_ENV_VAR):
|
||||
return None
|
||||
try:
|
||||
from .storage import EncryptedFileStorage
|
||||
|
||||
storage = EncryptedFileStorage()
|
||||
cred = storage.load(ADEN_CREDENTIAL_ID)
|
||||
if cred:
|
||||
return cred.get_key("api_key")
|
||||
except Exception:
|
||||
logger.debug("Could not load %s from encrypted store", ADEN_CREDENTIAL_ID)
|
||||
return None
|
||||
@@ -73,6 +73,7 @@ from .provider import (
|
||||
TokenExpiredError,
|
||||
TokenPlacement,
|
||||
)
|
||||
from .zoho_provider import ZohoOAuth2Provider
|
||||
|
||||
__all__ = [
|
||||
# Types
|
||||
@@ -82,6 +83,7 @@ __all__ = [
|
||||
# Providers
|
||||
"BaseOAuth2Provider",
|
||||
"HubSpotOAuth2Provider",
|
||||
"ZohoOAuth2Provider",
|
||||
# Lifecycle
|
||||
"TokenLifecycleManager",
|
||||
"TokenRefreshResult",
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
Zoho CRM-specific OAuth2 provider.
|
||||
|
||||
Pre-configured for Zoho's OAuth2 endpoints and CRM scopes.
|
||||
Extends BaseOAuth2Provider for Zoho-specific behavior.
|
||||
|
||||
Usage:
|
||||
provider = ZohoOAuth2Provider(
|
||||
client_id="your-client-id",
|
||||
client_secret="your-client-secret",
|
||||
accounts_domain="https://accounts.zoho.com", # or .in, .eu, etc.
|
||||
)
|
||||
|
||||
# Use with credential store
|
||||
store = CredentialStore(
|
||||
storage=EncryptedFileStorage(),
|
||||
providers=[provider],
|
||||
)
|
||||
|
||||
See: https://www.zoho.com/crm/developer/docs/api/v2/access-refresh.html
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from ..models import CredentialObject, CredentialRefreshError, CredentialType
|
||||
from .base_provider import BaseOAuth2Provider
|
||||
from .provider import OAuth2Config, OAuth2Token, TokenPlacement
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default CRM scopes for Phase 1 (Leads, Contacts, Accounts, Deals, Notes)
|
||||
ZOHO_DEFAULT_SCOPES = [
|
||||
"ZohoCRM.modules.leads.ALL",
|
||||
"ZohoCRM.modules.contacts.ALL",
|
||||
"ZohoCRM.modules.accounts.ALL",
|
||||
"ZohoCRM.modules.deals.ALL",
|
||||
"ZohoCRM.modules.notes.CREATE",
|
||||
]
|
||||
|
||||
|
||||
class ZohoOAuth2Provider(BaseOAuth2Provider):
|
||||
"""
|
||||
Zoho CRM OAuth2 provider with pre-configured endpoints.
|
||||
|
||||
Handles Zoho-specific OAuth2 behavior:
|
||||
- Pre-configured token and authorization URLs (region-aware)
|
||||
- Default CRM scopes for Leads, Contacts, Accounts, Deals, Notes
|
||||
- Token validation via Zoho CRM API
|
||||
- Authorization header format: "Authorization: Zoho-oauthtoken {token}"
|
||||
|
||||
Example:
|
||||
provider = ZohoOAuth2Provider(
|
||||
client_id="your-zoho-client-id",
|
||||
client_secret="your-zoho-client-secret",
|
||||
accounts_domain="https://accounts.zoho.com", # US
|
||||
# or "https://accounts.zoho.in" for India
|
||||
# or "https://accounts.zoho.eu" for EU
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
accounts_domain: str = "https://accounts.zoho.com",
|
||||
api_domain: str | None = None,
|
||||
scopes: list[str] | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize Zoho OAuth2 provider.
|
||||
|
||||
Args:
|
||||
client_id: Zoho OAuth2 client ID
|
||||
client_secret: Zoho OAuth2 client secret
|
||||
accounts_domain: Zoho accounts domain (region-specific)
|
||||
- US: https://accounts.zoho.com
|
||||
- India: https://accounts.zoho.in
|
||||
- EU: https://accounts.zoho.eu
|
||||
- etc.
|
||||
api_domain: Zoho API domain for CRM calls (used in validate).
|
||||
Defaults to ZOHO_API_DOMAIN env or https://www.zohoapis.com
|
||||
scopes: Override default scopes if needed
|
||||
"""
|
||||
base = accounts_domain.rstrip("/")
|
||||
token_url = f"{base}/oauth/v2/token"
|
||||
auth_url = f"{base}/oauth/v2/auth"
|
||||
|
||||
config = OAuth2Config(
|
||||
token_url=token_url,
|
||||
authorization_url=auth_url,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
default_scopes=scopes or ZOHO_DEFAULT_SCOPES,
|
||||
token_placement=TokenPlacement.HEADER_CUSTOM,
|
||||
custom_header_name="Authorization",
|
||||
)
|
||||
super().__init__(config, provider_id="zoho_crm_oauth2")
|
||||
self._accounts_domain = base
|
||||
self._api_domain = (
|
||||
api_domain or os.getenv("ZOHO_API_DOMAIN", "https://www.zohoapis.com")
|
||||
).rstrip("/")
|
||||
|
||||
@property
|
||||
def supported_types(self) -> list[CredentialType]:
|
||||
return [CredentialType.OAUTH2]
|
||||
|
||||
def format_for_request(self, token: OAuth2Token) -> dict[str, Any]:
|
||||
"""
|
||||
Format token for Zoho CRM API requests.
|
||||
|
||||
Zoho uses Authorization header: "Zoho-oauthtoken {access_token}"
|
||||
(not Bearer).
|
||||
"""
|
||||
return {
|
||||
"headers": {
|
||||
"Authorization": f"Zoho-oauthtoken {token.access_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
}
|
||||
|
||||
def validate(self, credential: CredentialObject) -> bool:
|
||||
"""
|
||||
Validate Zoho credential by making a lightweight API call.
|
||||
|
||||
Uses GET /crm/v2/users?type=CurrentUser (doesn't require module access).
|
||||
Treats 429 as valid-but-rate-limited.
|
||||
"""
|
||||
access_token = credential.get_key("access_token")
|
||||
if not access_token:
|
||||
return False
|
||||
|
||||
try:
|
||||
client = self._get_client()
|
||||
response = client.get(
|
||||
f"{self._api_domain}/crm/v2/users?type=CurrentUser",
|
||||
headers={
|
||||
"Authorization": f"Zoho-oauthtoken {access_token}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
timeout=self.config.request_timeout,
|
||||
)
|
||||
return response.status_code in (200, 429)
|
||||
except Exception as e:
|
||||
logger.debug("Zoho credential validation failed: %s", e)
|
||||
return False
|
||||
|
||||
def _parse_token_response(self, response_data: dict[str, Any]) -> OAuth2Token:
|
||||
"""
|
||||
Parse Zoho token response.
|
||||
|
||||
Zoho returns:
|
||||
{
|
||||
"access_token": "...",
|
||||
"refresh_token": "...",
|
||||
"expires_in": 3600,
|
||||
"api_domain": "https://www.zohoapis.com",
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
"""
|
||||
token = OAuth2Token.from_token_response(response_data)
|
||||
if "api_domain" in response_data:
|
||||
token.raw_response["api_domain"] = response_data["api_domain"]
|
||||
return token
|
||||
|
||||
def refresh(self, credential: CredentialObject) -> CredentialObject:
|
||||
"""Refresh Zoho OAuth2 credential and persist DC metadata."""
|
||||
refresh_tok = credential.get_key("refresh_token")
|
||||
if not refresh_tok:
|
||||
raise CredentialRefreshError(f"Credential '{credential.id}' has no refresh_token")
|
||||
|
||||
try:
|
||||
new_token = self.refresh_access_token(refresh_tok)
|
||||
except Exception as e:
|
||||
raise CredentialRefreshError(f"Failed to refresh '{credential.id}': {e}") from e
|
||||
|
||||
credential.set_key("access_token", new_token.access_token, expires_at=new_token.expires_at)
|
||||
|
||||
if new_token.refresh_token and new_token.refresh_token != refresh_tok:
|
||||
credential.set_key("refresh_token", new_token.refresh_token)
|
||||
|
||||
api_domain = new_token.raw_response.get("api_domain")
|
||||
if isinstance(api_domain, str) and api_domain:
|
||||
credential.set_key("api_domain", api_domain.rstrip("/"))
|
||||
|
||||
accounts_server = new_token.raw_response.get("accounts-server")
|
||||
if isinstance(accounts_server, str) and accounts_server:
|
||||
credential.set_key("accounts_domain", accounts_server.rstrip("/"))
|
||||
|
||||
location = new_token.raw_response.get("location")
|
||||
if isinstance(location, str) and location:
|
||||
credential.set_key("location", location.strip().lower())
|
||||
|
||||
return credential
|
||||
@@ -160,7 +160,10 @@ class CredentialSetupSession:
|
||||
@classmethod
|
||||
def from_nodes(cls, nodes: list[NodeSpec]) -> CredentialSetupSession:
|
||||
"""Create a setup session by detecting missing credentials from nodes."""
|
||||
missing = detect_missing_credentials_from_nodes(nodes)
|
||||
from framework.credentials.validation import _status_to_missing, validate_agent_credentials
|
||||
|
||||
result = validate_agent_credentials(nodes, verify=False, raise_on_error=False)
|
||||
missing = [_status_to_missing(c) for c in result.credentials if not c.available]
|
||||
return cls(missing)
|
||||
|
||||
@classmethod
|
||||
@@ -178,22 +181,15 @@ class CredentialSetupSession:
|
||||
are NOT yet available. If False, include all required
|
||||
credentials regardless of availability.
|
||||
"""
|
||||
agent_path = Path(agent_path)
|
||||
from framework.credentials.validation import _status_to_missing, validate_agent_credentials
|
||||
|
||||
# Load agent to get nodes
|
||||
agent_json = agent_path / "agent.json"
|
||||
agent_py = agent_path / "agent.py"
|
||||
|
||||
nodes = []
|
||||
if agent_py.exists():
|
||||
# Python-based agent
|
||||
nodes = _load_nodes_from_python_agent(agent_path)
|
||||
elif agent_json.exists():
|
||||
# JSON-based agent
|
||||
nodes = _load_nodes_from_json_agent(agent_json)
|
||||
|
||||
creds = detect_missing_credentials_from_nodes(nodes, missing_only=missing_only)
|
||||
return cls(creds)
|
||||
nodes = load_agent_nodes(agent_path)
|
||||
result = validate_agent_credentials(nodes, verify=False, raise_on_error=False)
|
||||
if missing_only:
|
||||
missing = [_status_to_missing(c) for c in result.credentials if not c.available]
|
||||
else:
|
||||
missing = [_status_to_missing(c) for c in result.credentials]
|
||||
return cls(missing)
|
||||
|
||||
def run_interactive(self) -> SetupResult:
|
||||
"""Run the interactive setup flow."""
|
||||
@@ -260,57 +256,23 @@ class CredentialSetupSession:
|
||||
|
||||
def _ensure_credential_key(self) -> bool:
|
||||
"""Ensure HIVE_CREDENTIAL_KEY is available for encrypted storage."""
|
||||
if os.environ.get("HIVE_CREDENTIAL_KEY"):
|
||||
from .key_storage import generate_and_save_credential_key, load_credential_key
|
||||
|
||||
if load_credential_key():
|
||||
return True
|
||||
|
||||
# Try to load from shell config
|
||||
try:
|
||||
from aden_tools.credentials.shell_config import check_env_var_in_shell_config
|
||||
|
||||
found, value = check_env_var_in_shell_config("HIVE_CREDENTIAL_KEY")
|
||||
if found and value:
|
||||
os.environ["HIVE_CREDENTIAL_KEY"] = value
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Generate a new key
|
||||
self._print(f"{Colors.YELLOW}Initializing credential store...{Colors.NC}")
|
||||
try:
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
generated_key = Fernet.generate_key().decode()
|
||||
os.environ["HIVE_CREDENTIAL_KEY"] = generated_key
|
||||
|
||||
# Save to shell config
|
||||
self._save_key_to_shell_config(generated_key)
|
||||
generate_and_save_credential_key()
|
||||
self._print(
|
||||
f"{Colors.GREEN}✓ Encryption key saved to ~/.hive/secrets/credential_key{Colors.NC}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
self._print(f"{Colors.RED}Failed to initialize credential store: {e}{Colors.NC}")
|
||||
return False
|
||||
|
||||
def _save_key_to_shell_config(self, key: str) -> None:
|
||||
"""Save HIVE_CREDENTIAL_KEY to shell config."""
|
||||
try:
|
||||
from aden_tools.credentials.shell_config import (
|
||||
add_env_var_to_shell_config,
|
||||
)
|
||||
|
||||
success, config_path = add_env_var_to_shell_config(
|
||||
"HIVE_CREDENTIAL_KEY",
|
||||
key,
|
||||
comment="Encryption key for Hive credential store",
|
||||
)
|
||||
if success:
|
||||
self._print(f"{Colors.GREEN}✓ Encryption key saved to {config_path}{Colors.NC}")
|
||||
except Exception:
|
||||
# Fallback: just tell the user
|
||||
self._print("\n")
|
||||
self._print(
|
||||
f"{Colors.YELLOW}Add this to your shell config (~/.zshrc or ~/.bashrc):{Colors.NC}"
|
||||
)
|
||||
self._print(f' export HIVE_CREDENTIAL_KEY="{key}"')
|
||||
|
||||
def _setup_single_credential(self, cred: MissingCredential) -> bool:
|
||||
"""Set up a single credential. Returns True if configured."""
|
||||
self._print(f"\n{Colors.CYAN}{'─' * 60}{Colors.NC}")
|
||||
@@ -448,19 +410,10 @@ class CredentialSetupSession:
|
||||
self._print(f"{Colors.YELLOW}No key entered. Skipping.{Colors.NC}")
|
||||
return False
|
||||
|
||||
os.environ["ADEN_API_KEY"] = aden_key
|
||||
# Persist to encrypted store and set os.environ
|
||||
from .key_storage import save_aden_api_key
|
||||
|
||||
# Save to shell config
|
||||
try:
|
||||
from aden_tools.credentials.shell_config import add_env_var_to_shell_config
|
||||
|
||||
add_env_var_to_shell_config(
|
||||
"ADEN_API_KEY",
|
||||
aden_key,
|
||||
comment="Aden Platform API key",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
save_aden_api_key(aden_key)
|
||||
|
||||
# Sync from Aden
|
||||
try:
|
||||
@@ -564,123 +517,24 @@ class CredentialSetupSession:
|
||||
self._print("")
|
||||
|
||||
|
||||
def detect_missing_credentials_from_nodes(
|
||||
nodes: list,
|
||||
*,
|
||||
missing_only: bool = True,
|
||||
) -> list[MissingCredential]:
|
||||
"""
|
||||
Detect credentials required by a list of nodes.
|
||||
def load_agent_nodes(agent_path: str | Path) -> list:
|
||||
"""Load NodeSpec list from an agent's agent.py or agent.json.
|
||||
|
||||
Args:
|
||||
nodes: List of NodeSpec objects
|
||||
missing_only: If True (default), only return credentials that are
|
||||
NOT yet available. If False, return ALL required credentials
|
||||
regardless of availability.
|
||||
agent_path: Path to agent directory.
|
||||
|
||||
Returns:
|
||||
List of MissingCredential objects for credentials that need setup
|
||||
(or all required credentials when missing_only=False).
|
||||
List of NodeSpec objects (empty list if agent can't be loaded).
|
||||
"""
|
||||
try:
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
agent_path = Path(agent_path)
|
||||
agent_py = agent_path / "agent.py"
|
||||
agent_json = agent_path / "agent.json"
|
||||
|
||||
from framework.credentials import CredentialStore
|
||||
from framework.credentials.storage import (
|
||||
CompositeStorage,
|
||||
EncryptedFileStorage,
|
||||
EnvVarStorage,
|
||||
)
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
# Collect required tools and node types
|
||||
required_tools: set[str] = set()
|
||||
node_types: set[str] = set()
|
||||
|
||||
for node in nodes:
|
||||
if hasattr(node, "tools") and node.tools:
|
||||
required_tools.update(node.tools)
|
||||
if hasattr(node, "node_type"):
|
||||
node_types.add(node.node_type)
|
||||
|
||||
# Build credential store to check availability.
|
||||
# Env vars take priority over encrypted store (fresh key wins over stale).
|
||||
env_mapping = {
|
||||
(spec.credential_id or name): spec.env_var for name, spec in CREDENTIAL_SPECS.items()
|
||||
}
|
||||
env_storage = EnvVarStorage(env_mapping=env_mapping)
|
||||
if os.environ.get("HIVE_CREDENTIAL_KEY"):
|
||||
storage = CompositeStorage(primary=env_storage, fallbacks=[EncryptedFileStorage()])
|
||||
else:
|
||||
storage = env_storage
|
||||
store = CredentialStore(storage=storage)
|
||||
|
||||
# Build reverse mappings
|
||||
tool_to_cred: dict[str, str] = {}
|
||||
node_type_to_cred: dict[str, str] = {}
|
||||
for cred_name, spec in CREDENTIAL_SPECS.items():
|
||||
for tool_name in spec.tools:
|
||||
tool_to_cred[tool_name] = cred_name
|
||||
for nt in spec.node_types:
|
||||
node_type_to_cred[nt] = cred_name
|
||||
|
||||
missing: list[MissingCredential] = []
|
||||
checked: set[str] = set()
|
||||
|
||||
# Check tool credentials
|
||||
for tool_name in sorted(required_tools):
|
||||
cred_name = tool_to_cred.get(tool_name)
|
||||
if cred_name is None or cred_name in checked:
|
||||
continue
|
||||
checked.add(cred_name)
|
||||
|
||||
spec = CREDENTIAL_SPECS[cred_name]
|
||||
cred_id = spec.credential_id or cred_name
|
||||
if spec.required and (not missing_only or not store.is_available(cred_id)):
|
||||
affected_tools = sorted(t for t in required_tools if t in spec.tools)
|
||||
missing.append(
|
||||
MissingCredential(
|
||||
credential_name=cred_name,
|
||||
env_var=spec.env_var,
|
||||
description=spec.description,
|
||||
help_url=spec.help_url,
|
||||
api_key_instructions=spec.api_key_instructions,
|
||||
tools=affected_tools,
|
||||
aden_supported=spec.aden_supported,
|
||||
direct_api_key_supported=spec.direct_api_key_supported,
|
||||
credential_id=spec.credential_id,
|
||||
credential_key=spec.credential_key,
|
||||
)
|
||||
)
|
||||
|
||||
# Check node type credentials
|
||||
for nt in sorted(node_types):
|
||||
cred_name = node_type_to_cred.get(nt)
|
||||
if cred_name is None or cred_name in checked:
|
||||
continue
|
||||
checked.add(cred_name)
|
||||
|
||||
spec = CREDENTIAL_SPECS[cred_name]
|
||||
cred_id = spec.credential_id or cred_name
|
||||
if spec.required and (not missing_only or not store.is_available(cred_id)):
|
||||
affected_types = sorted(t for t in node_types if t in spec.node_types)
|
||||
missing.append(
|
||||
MissingCredential(
|
||||
credential_name=cred_name,
|
||||
env_var=spec.env_var,
|
||||
description=spec.description,
|
||||
help_url=spec.help_url,
|
||||
api_key_instructions=spec.api_key_instructions,
|
||||
node_types=affected_types,
|
||||
aden_supported=spec.aden_supported,
|
||||
direct_api_key_supported=spec.direct_api_key_supported,
|
||||
credential_id=spec.credential_id,
|
||||
credential_key=spec.credential_key,
|
||||
)
|
||||
)
|
||||
|
||||
return missing
|
||||
if agent_py.exists():
|
||||
return _load_nodes_from_python_agent(agent_path)
|
||||
elif agent_json.exists():
|
||||
return _load_nodes_from_json_agent(agent_json)
|
||||
return []
|
||||
|
||||
|
||||
def _load_nodes_from_python_agent(agent_path: Path) -> list:
|
||||
@@ -714,7 +568,7 @@ def _load_nodes_from_python_agent(agent_path: Path) -> list:
|
||||
def _load_nodes_from_json_agent(agent_json: Path) -> list:
|
||||
"""Load nodes from a JSON-based agent."""
|
||||
try:
|
||||
with open(agent_json) as f:
|
||||
with open(agent_json, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
from framework.graph import NodeSpec
|
||||
|
||||
@@ -227,7 +227,7 @@ class EncryptedFileStorage(CredentialStorage):
|
||||
index_path = self.base_path / "metadata" / "index.json"
|
||||
if not index_path.exists():
|
||||
return []
|
||||
with open(index_path) as f:
|
||||
with open(index_path, encoding="utf-8") as f:
|
||||
index = json.load(f)
|
||||
return list(index.get("credentials", {}).keys())
|
||||
|
||||
@@ -268,7 +268,7 @@ class EncryptedFileStorage(CredentialStorage):
|
||||
index_path = self.base_path / "metadata" / "index.json"
|
||||
|
||||
if index_path.exists():
|
||||
with open(index_path) as f:
|
||||
with open(index_path, encoding="utf-8") as f:
|
||||
index = json.load(f)
|
||||
else:
|
||||
index = {"credentials": {}, "version": "1.0"}
|
||||
@@ -283,7 +283,7 @@ class EncryptedFileStorage(CredentialStorage):
|
||||
|
||||
index["last_modified"] = datetime.now(UTC).isoformat()
|
||||
|
||||
with open(index_path, "w") as f:
|
||||
with open(index_path, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, indent=2)
|
||||
|
||||
|
||||
|
||||
@@ -14,56 +14,165 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ensure_credential_key_env() -> None:
|
||||
"""Load credentials from shell config if not in environment.
|
||||
"""Load bootstrap credentials into ``os.environ``.
|
||||
|
||||
The quickstart.sh and setup-credentials skill write API keys to ~/.zshrc
|
||||
or ~/.bashrc. If the user hasn't sourced their config in the current shell,
|
||||
this reads them directly so the runner (and any MCP subprocesses) can use them.
|
||||
Priority chain for each credential:
|
||||
1. ``os.environ`` (already set — nothing to do)
|
||||
2. Dedicated file storage (``~/.hive/secrets/`` or encrypted store)
|
||||
3. Shell config fallback (``~/.zshrc`` / ``~/.bashrc``) for backward compat
|
||||
|
||||
Loads:
|
||||
- HIVE_CREDENTIAL_KEY (encrypted credential store)
|
||||
- ADEN_API_KEY (Aden OAuth sync)
|
||||
- All LLM API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, ZAI_API_KEY, etc.)
|
||||
Boot order matters: HIVE_CREDENTIAL_KEY must load BEFORE ADEN_API_KEY
|
||||
because the encrypted store depends on it.
|
||||
|
||||
Remaining LLM/tool API keys still load from shell config.
|
||||
"""
|
||||
from .key_storage import load_aden_api_key, load_credential_key
|
||||
|
||||
# Step 1: HIVE_CREDENTIAL_KEY (must come first — encrypted store depends on it)
|
||||
load_credential_key()
|
||||
|
||||
# Step 2: ADEN_API_KEY (uses encrypted store, then shell config fallback)
|
||||
load_aden_api_key()
|
||||
|
||||
# Step 3: Load remaining LLM/tool API keys from shell config
|
||||
try:
|
||||
from aden_tools.credentials.shell_config import check_env_var_in_shell_config
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
# Core credentials that are always checked
|
||||
env_vars_to_load = ["HIVE_CREDENTIAL_KEY", "ADEN_API_KEY"]
|
||||
|
||||
# Add all LLM/tool API keys from CREDENTIAL_SPECS
|
||||
try:
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
|
||||
for spec in CREDENTIAL_SPECS.values():
|
||||
if spec.env_var and spec.env_var not in env_vars_to_load:
|
||||
env_vars_to_load.append(spec.env_var)
|
||||
var_name = spec.env_var
|
||||
if var_name and var_name not in ("HIVE_CREDENTIAL_KEY", "ADEN_API_KEY"):
|
||||
if not os.environ.get(var_name):
|
||||
found, value = check_env_var_in_shell_config(var_name)
|
||||
if found and value:
|
||||
os.environ[var_name] = value
|
||||
logger.debug("Loaded %s from shell config", var_name)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
for var_name in env_vars_to_load:
|
||||
if os.environ.get(var_name):
|
||||
continue
|
||||
found, value = check_env_var_in_shell_config(var_name)
|
||||
if found and value:
|
||||
os.environ[var_name] = value
|
||||
logger.debug("Loaded %s from shell config", var_name)
|
||||
|
||||
@dataclass
|
||||
class CredentialStatus:
|
||||
"""Status of a single required credential after validation."""
|
||||
|
||||
credential_name: str
|
||||
credential_id: str
|
||||
env_var: str
|
||||
description: str
|
||||
help_url: str
|
||||
api_key_instructions: str
|
||||
tools: list[str]
|
||||
node_types: list[str]
|
||||
available: bool
|
||||
valid: bool | None # None = not checked
|
||||
validation_message: str | None
|
||||
aden_supported: bool
|
||||
direct_api_key_supported: bool
|
||||
credential_key: str
|
||||
aden_not_connected: bool # Aden-only cred, ADEN_API_KEY set, but integration missing
|
||||
alternative_group: str | None = None # non-None when multiple providers can satisfy a tool
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CredentialCheck:
|
||||
"""Result of checking a single credential."""
|
||||
class CredentialValidationResult:
|
||||
"""Result of validating all credentials required by an agent."""
|
||||
|
||||
env_var: str
|
||||
source: str
|
||||
used_by: str
|
||||
available: bool
|
||||
help_url: str = ""
|
||||
credentials: list[CredentialStatus]
|
||||
has_aden_key: bool
|
||||
|
||||
@property
|
||||
def failed(self) -> list[CredentialStatus]:
|
||||
"""Credentials that are missing, invalid, or Aden-not-connected.
|
||||
|
||||
For alternative groups (multi-provider tools like send_email), the group
|
||||
is satisfied if ANY member is available and valid — only report failures
|
||||
when the entire group is unsatisfied.
|
||||
"""
|
||||
# Check which alternative groups are satisfied
|
||||
alt_satisfied: dict[str, bool] = {}
|
||||
for c in self.credentials:
|
||||
if not c.alternative_group:
|
||||
continue
|
||||
if c.alternative_group not in alt_satisfied:
|
||||
alt_satisfied[c.alternative_group] = False
|
||||
if c.available and c.valid is not False:
|
||||
alt_satisfied[c.alternative_group] = True
|
||||
|
||||
result = []
|
||||
for c in self.credentials:
|
||||
if c.alternative_group:
|
||||
# Skip if any alternative in the group is satisfied
|
||||
if alt_satisfied.get(c.alternative_group, False):
|
||||
continue
|
||||
if not c.available or c.valid is False:
|
||||
result.append(c)
|
||||
else:
|
||||
if not c.available or c.valid is False:
|
||||
result.append(c)
|
||||
return result
|
||||
|
||||
@property
|
||||
def has_errors(self) -> bool:
|
||||
return bool(self.failed)
|
||||
|
||||
@property
|
||||
def failed_cred_names(self) -> list[str]:
|
||||
"""Credential names that need (re-)collection, excluding Aden-not-connected."""
|
||||
return [c.credential_name for c in self.failed if not c.aden_not_connected]
|
||||
|
||||
def format_error_message(self) -> str:
|
||||
"""Format a human-readable error message for CLI/runner output."""
|
||||
missing = [c for c in self.credentials if not c.available and not c.aden_not_connected]
|
||||
invalid = [c for c in self.credentials if c.available and c.valid is False]
|
||||
aden_nc = [c for c in self.credentials if c.aden_not_connected]
|
||||
|
||||
lines: list[str] = []
|
||||
if missing:
|
||||
lines.append("Missing credentials:\n")
|
||||
for c in missing:
|
||||
entry = f" {c.env_var} for {_label(c)}"
|
||||
if c.help_url:
|
||||
entry += f"\n Get it at: {c.help_url}"
|
||||
lines.append(entry)
|
||||
if invalid:
|
||||
if missing:
|
||||
lines.append("")
|
||||
lines.append("Invalid or expired credentials:\n")
|
||||
for c in invalid:
|
||||
entry = f" {c.env_var} for {_label(c)} — {c.validation_message}"
|
||||
if c.help_url:
|
||||
entry += f"\n Get a new key at: {c.help_url}"
|
||||
lines.append(entry)
|
||||
if aden_nc:
|
||||
if missing or invalid:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Aden integrations not connected "
|
||||
"(ADEN_API_KEY is set but OAuth tokens unavailable):\n"
|
||||
)
|
||||
for c in aden_nc:
|
||||
lines.append(
|
||||
f" {c.env_var} for {_label(c)}"
|
||||
f"\n Connect this integration at hive.adenhq.com first."
|
||||
)
|
||||
lines.append("\nIf you've already set up credentials, restart your terminal to load them.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _presync_aden_tokens(credential_specs: dict) -> None:
|
||||
def _label(c: CredentialStatus) -> str:
|
||||
"""Build a human-readable label from tools/node_types."""
|
||||
if c.tools:
|
||||
return ", ".join(c.tools)
|
||||
if c.node_types:
|
||||
return ", ".join(c.node_types) + " nodes"
|
||||
return c.credential_name
|
||||
|
||||
|
||||
def _presync_aden_tokens(credential_specs: dict, *, force: bool = False) -> None:
|
||||
"""Sync Aden-backed OAuth tokens into env vars for validation.
|
||||
|
||||
When ADEN_API_KEY is available, fetches fresh OAuth tokens from the Aden
|
||||
@@ -71,6 +180,11 @@ def _presync_aden_tokens(credential_specs: dict) -> None:
|
||||
tokens instead of stale or mis-stored values in the encrypted store.
|
||||
Only touches credentials that are ``aden_supported`` AND whose env var
|
||||
is not already set (so explicit user exports always win).
|
||||
|
||||
Args:
|
||||
force: When True, overwrite env vars that are already set. Used by
|
||||
the credentials modal to pick up freshly reauthorized tokens
|
||||
from Aden instead of reusing stale values from a prior sync.
|
||||
"""
|
||||
from framework.credentials.store import CredentialStore
|
||||
|
||||
@@ -83,7 +197,7 @@ def _presync_aden_tokens(credential_specs: dict) -> None:
|
||||
for name, spec in credential_specs.items():
|
||||
if not spec.aden_supported:
|
||||
continue
|
||||
if os.environ.get(spec.env_var):
|
||||
if not force and os.environ.get(spec.env_var):
|
||||
continue # Already set — don't overwrite
|
||||
cred_id = spec.credential_id or name
|
||||
# sync_all() already fetched everything available from Aden.
|
||||
@@ -112,7 +226,13 @@ def _presync_aden_tokens(credential_specs: dict) -> None:
|
||||
)
|
||||
|
||||
|
||||
def validate_agent_credentials(nodes: list, quiet: bool = False, verify: bool = True) -> None:
|
||||
def validate_agent_credentials(
|
||||
nodes: list,
|
||||
quiet: bool = False,
|
||||
verify: bool = True,
|
||||
raise_on_error: bool = True,
|
||||
force_refresh: bool = False,
|
||||
) -> CredentialValidationResult:
|
||||
"""Check that required credentials are available and valid before running an agent.
|
||||
|
||||
Two-phase validation:
|
||||
@@ -124,15 +244,30 @@ def validate_agent_credentials(nodes: list, quiet: bool = False, verify: bool =
|
||||
nodes: List of NodeSpec objects from the agent graph.
|
||||
quiet: If True, suppress the credential summary output.
|
||||
verify: If True (default), run health checks on present credentials.
|
||||
raise_on_error: If True (default), raise CredentialError when validation
|
||||
fails. Set to False to get the result without raising.
|
||||
force_refresh: If True, force re-sync of Aden OAuth tokens even when
|
||||
env vars are already set. Used by the credentials modal after
|
||||
reauthorization.
|
||||
|
||||
Returns:
|
||||
CredentialValidationResult with status of ALL required credentials.
|
||||
"""
|
||||
empty_result = CredentialValidationResult(credentials=[], has_aden_key=False)
|
||||
|
||||
# Collect required tools and node types
|
||||
required_tools = {tool for node in nodes if node.tools for tool in node.tools}
|
||||
node_types = {node.node_type for node in nodes}
|
||||
required_tools: set[str] = set()
|
||||
node_types: set[str] = set()
|
||||
for node in nodes:
|
||||
if hasattr(node, "tools") and node.tools:
|
||||
required_tools.update(node.tools)
|
||||
if hasattr(node, "node_type"):
|
||||
node_types.add(node.node_type)
|
||||
|
||||
try:
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
except ImportError:
|
||||
return # aden_tools not installed, skip check
|
||||
return empty_result # aden_tools not installed, skip check
|
||||
|
||||
from framework.credentials.storage import CompositeStorage, EncryptedFileStorage, EnvVarStorage
|
||||
from framework.credentials.store import CredentialStore
|
||||
@@ -145,7 +280,7 @@ def validate_agent_credentials(nodes: list, quiet: bool = False, verify: bool =
|
||||
# into env vars so validation sees fresh tokens instead of stale values
|
||||
# in the encrypted store (e.g., a previously mis-stored google.enc).
|
||||
if os.environ.get("ADEN_API_KEY"):
|
||||
_presync_aden_tokens(CREDENTIAL_SPECS)
|
||||
_presync_aden_tokens(CREDENTIAL_SPECS, force=force_refresh)
|
||||
|
||||
env_mapping = {
|
||||
(spec.credential_id or name): spec.env_var for name, spec in CREDENTIAL_SPECS.items()
|
||||
@@ -157,57 +292,114 @@ def validate_agent_credentials(nodes: list, quiet: bool = False, verify: bool =
|
||||
storage = env_storage
|
||||
store = CredentialStore(storage=storage)
|
||||
|
||||
# Build reverse mappings
|
||||
tool_to_cred: dict[str, str] = {}
|
||||
# Build reverse mappings — 1:many for multi-provider tools (e.g. send_email → resend OR google)
|
||||
tool_to_creds: dict[str, list[str]] = {}
|
||||
node_type_to_cred: dict[str, str] = {}
|
||||
for cred_name, spec in CREDENTIAL_SPECS.items():
|
||||
for tool_name in spec.tools:
|
||||
tool_to_cred[tool_name] = cred_name
|
||||
tool_to_creds.setdefault(tool_name, []).append(cred_name)
|
||||
for nt in spec.node_types:
|
||||
node_type_to_cred[nt] = cred_name
|
||||
|
||||
missing: list[str] = []
|
||||
invalid: list[str] = []
|
||||
# Aden-backed creds where ADEN_API_KEY is set but integration not connected
|
||||
aden_not_connected: list[str] = []
|
||||
failed_cred_names: list[str] = [] # all cred names that need (re-)collection
|
||||
has_aden_key = bool(os.environ.get("ADEN_API_KEY"))
|
||||
checked: set[str] = set()
|
||||
all_credentials: list[CredentialStatus] = []
|
||||
# Credentials that are present and should be health-checked
|
||||
to_verify: list[tuple[str, str]] = [] # (cred_name, used_by_label)
|
||||
to_verify: list[int] = [] # indices into all_credentials
|
||||
|
||||
def _check_credential(spec, cred_name: str, label: str) -> None:
|
||||
def _check_credential(
|
||||
spec,
|
||||
cred_name: str,
|
||||
affected_tools: list[str],
|
||||
affected_node_types: list[str],
|
||||
alternative_group: str | None = None,
|
||||
) -> None:
|
||||
cred_id = spec.credential_id or cred_name
|
||||
if not store.is_available(cred_id):
|
||||
# If ADEN_API_KEY is set and this is an Aden-only credential,
|
||||
# the issue is that the integration isn't connected on hive.adenhq.com,
|
||||
# NOT that the user needs to re-enter ADEN_API_KEY.
|
||||
if has_aden_key and spec.aden_supported and not spec.direct_api_key_supported:
|
||||
aden_not_connected.append(
|
||||
f" {spec.env_var} for {label}"
|
||||
f"\n Connect this integration at hive.adenhq.com first."
|
||||
)
|
||||
else:
|
||||
entry = f" {spec.env_var} for {label}"
|
||||
if spec.help_url:
|
||||
entry += f"\n Get it at: {spec.help_url}"
|
||||
missing.append(entry)
|
||||
failed_cred_names.append(cred_name)
|
||||
elif verify and spec.health_check_endpoint:
|
||||
to_verify.append((cred_name, label))
|
||||
available = store.is_available(cred_id)
|
||||
|
||||
# Aden-not-connected: ADEN_API_KEY set, Aden-only cred, but integration missing
|
||||
is_aden_nc = (
|
||||
not available
|
||||
and has_aden_key
|
||||
and spec.aden_supported
|
||||
and not spec.direct_api_key_supported
|
||||
)
|
||||
|
||||
status = CredentialStatus(
|
||||
credential_name=cred_name,
|
||||
credential_id=cred_id,
|
||||
env_var=spec.env_var,
|
||||
description=spec.description,
|
||||
help_url=spec.help_url,
|
||||
api_key_instructions=getattr(spec, "api_key_instructions", ""),
|
||||
tools=affected_tools,
|
||||
node_types=affected_node_types,
|
||||
available=available,
|
||||
valid=None,
|
||||
validation_message=None,
|
||||
aden_supported=spec.aden_supported,
|
||||
direct_api_key_supported=spec.direct_api_key_supported,
|
||||
credential_key=spec.credential_key,
|
||||
aden_not_connected=is_aden_nc,
|
||||
alternative_group=alternative_group,
|
||||
)
|
||||
all_credentials.append(status)
|
||||
|
||||
if available and verify and spec.health_check_endpoint:
|
||||
to_verify.append(len(all_credentials) - 1)
|
||||
|
||||
# Check tool credentials
|
||||
for tool_name in sorted(required_tools):
|
||||
cred_name = tool_to_cred.get(tool_name)
|
||||
if cred_name is None or cred_name in checked:
|
||||
cred_names = tool_to_creds.get(tool_name)
|
||||
if cred_names is None:
|
||||
continue
|
||||
checked.add(cred_name)
|
||||
spec = CREDENTIAL_SPECS[cred_name]
|
||||
if not spec.required:
|
||||
|
||||
# Filter to credentials we haven't already checked
|
||||
unchecked = [cn for cn in cred_names if cn not in checked]
|
||||
if not unchecked:
|
||||
continue
|
||||
affected = sorted(t for t in required_tools if t in spec.tools)
|
||||
label = ", ".join(affected)
|
||||
_check_credential(spec, cred_name, label)
|
||||
|
||||
# Single provider — existing behavior
|
||||
if len(unchecked) == 1:
|
||||
cred_name = unchecked[0]
|
||||
checked.add(cred_name)
|
||||
spec = CREDENTIAL_SPECS[cred_name]
|
||||
if not spec.required:
|
||||
continue
|
||||
affected = sorted(t for t in required_tools if t in spec.tools)
|
||||
_check_credential(spec, cred_name, affected_tools=affected, affected_node_types=[])
|
||||
continue
|
||||
|
||||
# Multi-provider (e.g. send_email → resend OR google):
|
||||
# satisfied if ANY provider credential is available.
|
||||
available_cn = None
|
||||
for cn in unchecked:
|
||||
spec = CREDENTIAL_SPECS[cn]
|
||||
cred_id = spec.credential_id or cn
|
||||
if store.is_available(cred_id):
|
||||
available_cn = cn
|
||||
break
|
||||
|
||||
if available_cn is not None:
|
||||
# Found an available provider — check (and health-check) it
|
||||
checked.add(available_cn)
|
||||
spec = CREDENTIAL_SPECS[available_cn]
|
||||
affected = sorted(t for t in required_tools if t in spec.tools)
|
||||
_check_credential(spec, available_cn, affected_tools=affected, affected_node_types=[])
|
||||
else:
|
||||
# None available — report ALL alternatives so the modal can show them
|
||||
group_key = tool_name # e.g. "send_email"
|
||||
for cn in unchecked:
|
||||
checked.add(cn)
|
||||
spec = CREDENTIAL_SPECS[cn]
|
||||
affected = sorted(t for t in required_tools if t in spec.tools)
|
||||
_check_credential(
|
||||
spec,
|
||||
cn,
|
||||
affected_tools=affected,
|
||||
affected_node_types=[],
|
||||
alternative_group=group_key,
|
||||
)
|
||||
|
||||
# Check node type credentials (e.g., ANTHROPIC_API_KEY for LLM nodes)
|
||||
for nt in sorted(node_types):
|
||||
@@ -219,8 +411,7 @@ def validate_agent_credentials(nodes: list, quiet: bool = False, verify: bool =
|
||||
if not spec.required:
|
||||
continue
|
||||
affected_types = sorted(t for t in node_types if t in spec.node_types)
|
||||
label = ", ".join(affected_types) + " nodes"
|
||||
_check_credential(spec, cred_name, label)
|
||||
_check_credential(spec, cred_name, affected_tools=[], affected_node_types=affected_types)
|
||||
|
||||
# Phase 2: health-check present credentials
|
||||
if to_verify:
|
||||
@@ -230,69 +421,52 @@ def validate_agent_credentials(nodes: list, quiet: bool = False, verify: bool =
|
||||
check_credential_health = None # type: ignore[assignment]
|
||||
|
||||
if check_credential_health is not None:
|
||||
for cred_name, label in to_verify:
|
||||
spec = CREDENTIAL_SPECS[cred_name]
|
||||
cred_id = spec.credential_id or cred_name
|
||||
value = store.get(cred_id)
|
||||
for idx in to_verify:
|
||||
status = all_credentials[idx]
|
||||
spec = CREDENTIAL_SPECS[status.credential_name]
|
||||
value = store.get(status.credential_id)
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
result = check_credential_health(
|
||||
cred_name,
|
||||
status.credential_name,
|
||||
value,
|
||||
health_check_endpoint=spec.health_check_endpoint,
|
||||
health_check_method=spec.health_check_method,
|
||||
)
|
||||
if not result.valid:
|
||||
entry = f" {spec.env_var} for {label} — {result.message}"
|
||||
if spec.help_url:
|
||||
entry += f"\n Get a new key at: {spec.help_url}"
|
||||
invalid.append(entry)
|
||||
failed_cred_names.append(cred_name)
|
||||
elif result.valid:
|
||||
status.valid = result.valid
|
||||
status.validation_message = result.message
|
||||
if result.valid:
|
||||
# Persist identity from health check (best-effort)
|
||||
identity_data = result.details.get("identity")
|
||||
if identity_data and isinstance(identity_data, dict):
|
||||
try:
|
||||
cred_obj = store.get_credential(cred_id, refresh_if_needed=False)
|
||||
cred_obj = store.get_credential(
|
||||
status.credential_id, refresh_if_needed=False
|
||||
)
|
||||
if cred_obj:
|
||||
cred_obj.set_identity(**identity_data)
|
||||
store.save_credential(cred_obj)
|
||||
except Exception:
|
||||
pass # Identity persistence is best-effort
|
||||
except Exception as exc:
|
||||
logger.debug("Health check for %s failed: %s", cred_name, exc)
|
||||
logger.debug("Health check for %s failed: %s", status.credential_name, exc)
|
||||
|
||||
errors = missing + invalid + aden_not_connected
|
||||
if errors:
|
||||
validation_result = CredentialValidationResult(
|
||||
credentials=all_credentials,
|
||||
has_aden_key=has_aden_key,
|
||||
)
|
||||
|
||||
if raise_on_error and validation_result.has_errors:
|
||||
from framework.credentials.models import CredentialError
|
||||
|
||||
lines: list[str] = []
|
||||
if missing:
|
||||
lines.append("Missing credentials:\n")
|
||||
lines.extend(missing)
|
||||
if invalid:
|
||||
if missing:
|
||||
lines.append("")
|
||||
lines.append("Invalid or expired credentials:\n")
|
||||
lines.extend(invalid)
|
||||
if aden_not_connected:
|
||||
if missing or invalid:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Aden integrations not connected "
|
||||
"(ADEN_API_KEY is set but OAuth tokens unavailable):\n"
|
||||
)
|
||||
lines.extend(aden_not_connected)
|
||||
lines.append(
|
||||
"\nTo fix: run /hive-credentials in Claude Code."
|
||||
"\nIf you've already set up credentials, "
|
||||
"restart your terminal to load them."
|
||||
)
|
||||
exc = CredentialError("\n".join(lines))
|
||||
exc.failed_cred_names = failed_cred_names # type: ignore[attr-defined]
|
||||
exc = CredentialError(validation_result.format_error_message())
|
||||
exc.validation_result = validation_result # type: ignore[attr-defined]
|
||||
exc.failed_cred_names = validation_result.failed_cred_names # type: ignore[attr-defined]
|
||||
raise exc
|
||||
|
||||
return validation_result
|
||||
|
||||
|
||||
def build_setup_session_from_error(
|
||||
credential_error: Exception,
|
||||
@@ -301,56 +475,44 @@ def build_setup_session_from_error(
|
||||
):
|
||||
"""Build a ``CredentialSetupSession`` that covers all failed credentials.
|
||||
|
||||
``validate_agent_credentials`` attaches ``failed_cred_names`` (both missing
|
||||
and invalid) to the ``CredentialError``. This helper converts those names
|
||||
into ``MissingCredential`` entries so the setup screen can re-collect them.
|
||||
|
||||
Falls back to the normal ``from_nodes`` / ``from_agent_path`` detection
|
||||
when the attribute is absent.
|
||||
Uses the ``CredentialValidationResult`` attached to the ``CredentialError``
|
||||
when available. Falls back to re-detecting from nodes / agent_path.
|
||||
|
||||
Args:
|
||||
credential_error: The ``CredentialError`` raised by validation.
|
||||
nodes: Graph nodes (preferred — avoids re-loading from disk).
|
||||
agent_path: Agent directory path (used when nodes aren't available).
|
||||
"""
|
||||
from framework.credentials.setup import CredentialSetupSession, MissingCredential
|
||||
from framework.credentials.setup import CredentialSetupSession
|
||||
|
||||
# Start with normal detection (picks up truly missing creds)
|
||||
# Prefer the validation result attached to the exception
|
||||
result: CredentialValidationResult | None = getattr(credential_error, "validation_result", None)
|
||||
if result is not None:
|
||||
missing = [_status_to_missing(c) for c in result.failed]
|
||||
return CredentialSetupSession(missing)
|
||||
|
||||
# Fallback: re-detect from nodes or agent_path
|
||||
if nodes is not None:
|
||||
session = CredentialSetupSession.from_nodes(nodes)
|
||||
return CredentialSetupSession.from_nodes(nodes)
|
||||
elif agent_path is not None:
|
||||
session = CredentialSetupSession.from_agent_path(agent_path)
|
||||
else:
|
||||
session = CredentialSetupSession(missing=[])
|
||||
return CredentialSetupSession.from_agent_path(agent_path)
|
||||
return CredentialSetupSession(missing=[])
|
||||
|
||||
# Add credentials that are present but failed health checks
|
||||
already = {m.credential_name for m in session.missing}
|
||||
failed_names: list[str] = getattr(credential_error, "failed_cred_names", [])
|
||||
if failed_names:
|
||||
try:
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
|
||||
for name in failed_names:
|
||||
if name in already:
|
||||
continue
|
||||
spec = CREDENTIAL_SPECS.get(name)
|
||||
if spec is None:
|
||||
continue
|
||||
session.missing.append(
|
||||
MissingCredential(
|
||||
credential_name=name,
|
||||
env_var=spec.env_var,
|
||||
description=spec.description,
|
||||
help_url=spec.help_url,
|
||||
api_key_instructions=spec.api_key_instructions,
|
||||
tools=list(spec.tools),
|
||||
aden_supported=spec.aden_supported,
|
||||
direct_api_key_supported=spec.direct_api_key_supported,
|
||||
credential_id=spec.credential_id,
|
||||
credential_key=spec.credential_key,
|
||||
)
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
def _status_to_missing(c: CredentialStatus):
|
||||
"""Convert a CredentialStatus to a MissingCredential for the setup flow."""
|
||||
from framework.credentials.setup import MissingCredential
|
||||
|
||||
return session
|
||||
return MissingCredential(
|
||||
credential_name=c.credential_name,
|
||||
env_var=c.env_var,
|
||||
description=c.description,
|
||||
help_url=c.help_url,
|
||||
api_key_instructions=c.api_key_instructions,
|
||||
tools=c.tools,
|
||||
node_types=c.node_types,
|
||||
aden_supported=c.aden_supported,
|
||||
direct_api_key_supported=c.direct_api_key_supported,
|
||||
credential_id=c.credential_id,
|
||||
credential_key=c.credential_key,
|
||||
)
|
||||
|
||||
@@ -46,9 +46,11 @@ class ActiveNodeClientIO(NodeClientIO):
|
||||
self,
|
||||
node_id: str,
|
||||
event_bus: EventBus | None = None,
|
||||
execution_id: str = "",
|
||||
) -> None:
|
||||
self.node_id = node_id
|
||||
self._event_bus = event_bus
|
||||
self._execution_id = execution_id
|
||||
|
||||
self._output_queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
self._output_snapshot = ""
|
||||
@@ -66,6 +68,7 @@ class ActiveNodeClientIO(NodeClientIO):
|
||||
node_id=self.node_id,
|
||||
content=content,
|
||||
snapshot=self._output_snapshot,
|
||||
execution_id=self._execution_id or None,
|
||||
)
|
||||
|
||||
if is_final:
|
||||
@@ -83,6 +86,7 @@ class ActiveNodeClientIO(NodeClientIO):
|
||||
stream_id=self.node_id,
|
||||
node_id=self.node_id,
|
||||
prompt=prompt,
|
||||
execution_id=self._execution_id or None,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -158,11 +162,12 @@ class ClientIOGateway:
|
||||
def __init__(self, event_bus: EventBus | None = None) -> None:
|
||||
self._event_bus = event_bus
|
||||
|
||||
def create_io(self, node_id: str, client_facing: bool) -> NodeClientIO:
|
||||
def create_io(self, node_id: str, client_facing: bool, execution_id: str = "") -> NodeClientIO:
|
||||
if client_facing:
|
||||
return ActiveNodeClientIO(
|
||||
node_id=node_id,
|
||||
event_bus=self._event_bus,
|
||||
execution_id=execution_id,
|
||||
)
|
||||
return InertNodeClientIO(
|
||||
node_id=node_id,
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@@ -90,15 +91,133 @@ class Message:
|
||||
|
||||
|
||||
def _extract_spillover_filename(content: str) -> str | None:
|
||||
"""Extract spillover filename from a truncated tool result.
|
||||
"""Extract spillover filename from a tool result annotation.
|
||||
|
||||
Matches the pattern produced by EventLoopNode._truncate_tool_result():
|
||||
"saved to 'tool_github_list_stargazers_abc123.txt'"
|
||||
Matches patterns produced by EventLoopNode._truncate_tool_result():
|
||||
- Large result: "saved to 'web_search_1.txt'"
|
||||
- Small result: "[Saved to 'web_search_1.txt']"
|
||||
"""
|
||||
match = re.search(r"saved to '([^']+)'", content)
|
||||
match = re.search(r"[Ss]aved to '([^']+)'", content)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
_TC_ARG_LIMIT = 200 # max chars per tool_call argument after compaction
|
||||
|
||||
|
||||
def _compact_tool_calls(tool_calls: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Truncate tool_call arguments to save context tokens during compaction.
|
||||
|
||||
Preserves ``id``, ``type``, and ``function.name`` exactly. When arguments
|
||||
exceed ``_TC_ARG_LIMIT``, replaces the full JSON string with a compact
|
||||
**valid** JSON summary. The Anthropic API parses tool_call arguments and
|
||||
rejects requests with malformed JSON (e.g. unterminated strings), so we
|
||||
must never produce broken JSON here.
|
||||
"""
|
||||
compact = []
|
||||
for tc in tool_calls:
|
||||
func = tc.get("function", {})
|
||||
args = func.get("arguments", "")
|
||||
if len(args) > _TC_ARG_LIMIT:
|
||||
# Build a valid JSON summary instead of slicing mid-string.
|
||||
# Try to extract top-level keys for a meaningful preview.
|
||||
try:
|
||||
parsed = json.loads(args)
|
||||
if isinstance(parsed, dict):
|
||||
# Preserve key names, truncate values
|
||||
summary_parts = []
|
||||
for k, v in parsed.items():
|
||||
v_str = str(v)
|
||||
if len(v_str) > 60:
|
||||
v_str = v_str[:60] + "..."
|
||||
summary_parts.append(f"{k}={v_str}")
|
||||
summary = ", ".join(summary_parts)
|
||||
if len(summary) > _TC_ARG_LIMIT:
|
||||
summary = summary[:_TC_ARG_LIMIT] + "..."
|
||||
args = json.dumps({"_compacted": summary})
|
||||
else:
|
||||
args = json.dumps({"_compacted": str(parsed)[:_TC_ARG_LIMIT]})
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# Args were already invalid JSON — wrap the preview safely
|
||||
args = json.dumps({"_compacted": args[:_TC_ARG_LIMIT]})
|
||||
compact.append(
|
||||
{
|
||||
"id": tc.get("id", ""),
|
||||
"type": tc.get("type", "function"),
|
||||
"function": {
|
||||
"name": func.get("name", ""),
|
||||
"arguments": args,
|
||||
},
|
||||
}
|
||||
)
|
||||
return compact
|
||||
|
||||
|
||||
def extract_tool_call_history(messages: list[Message], max_entries: int = 30) -> str:
|
||||
"""Build a compact tool call history from a list of messages.
|
||||
|
||||
Used in compaction summaries to prevent the LLM from re-calling
|
||||
tools it already called. Extracts tool call details, files saved,
|
||||
outputs set, and errors encountered.
|
||||
"""
|
||||
tool_calls_detail: dict[str, list[str]] = {}
|
||||
files_saved: list[str] = []
|
||||
outputs_set: list[str] = []
|
||||
errors: list[str] = []
|
||||
|
||||
def _summarize_input(name: str, args: dict) -> str:
|
||||
if name == "web_search":
|
||||
return args.get("query", "")
|
||||
if name == "web_scrape":
|
||||
return args.get("url", "")
|
||||
if name in ("load_data", "save_data"):
|
||||
return args.get("filename", "")
|
||||
return ""
|
||||
|
||||
for msg in messages:
|
||||
if msg.role == "assistant" and msg.tool_calls:
|
||||
for tc in msg.tool_calls:
|
||||
func = tc.get("function", {})
|
||||
name = func.get("name", "unknown")
|
||||
try:
|
||||
args = json.loads(func.get("arguments", "{}"))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {}
|
||||
|
||||
summary = _summarize_input(name, args)
|
||||
tool_calls_detail.setdefault(name, []).append(summary)
|
||||
|
||||
if name == "save_data" and args.get("filename"):
|
||||
files_saved.append(args["filename"])
|
||||
if name == "set_output" and args.get("key"):
|
||||
outputs_set.append(args["key"])
|
||||
|
||||
if msg.role == "tool" and msg.is_error:
|
||||
preview = msg.content[:120].replace("\n", " ")
|
||||
errors.append(preview)
|
||||
|
||||
parts: list[str] = []
|
||||
if tool_calls_detail:
|
||||
lines: list[str] = []
|
||||
for name, inputs in list(tool_calls_detail.items())[:max_entries]:
|
||||
count = len(inputs)
|
||||
non_empty = [s for s in inputs if s]
|
||||
if non_empty:
|
||||
detail_lines = [f" - {s[:120]}" for s in non_empty[:8]]
|
||||
lines.append(f" {name} ({count}x):\n" + "\n".join(detail_lines))
|
||||
else:
|
||||
lines.append(f" {name} ({count}x)")
|
||||
parts.append("TOOLS ALREADY CALLED:\n" + "\n".join(lines))
|
||||
if files_saved:
|
||||
unique = list(dict.fromkeys(files_saved))
|
||||
parts.append("FILES SAVED: " + ", ".join(unique))
|
||||
if outputs_set:
|
||||
unique = list(dict.fromkeys(outputs_set))
|
||||
parts.append("OUTPUTS SET: " + ", ".join(unique))
|
||||
if errors:
|
||||
parts.append("ERRORS (do NOT retry these):\n" + "\n".join(f" - {e}" for e in errors[:10]))
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ConversationStore protocol (Phase 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -320,9 +439,36 @@ class NodeConversation:
|
||||
def _repair_orphaned_tool_calls(
|
||||
msgs: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Ensure every tool_call has a matching tool-result message."""
|
||||
"""Ensure tool_call / tool_result pairs are consistent.
|
||||
|
||||
1. **Orphaned tool results** (tool_result with no preceding tool_use)
|
||||
are dropped. This happens when compaction removes an assistant
|
||||
message but leaves its tool-result messages behind.
|
||||
2. **Orphaned tool calls** (tool_use with no following tool_result)
|
||||
get a synthetic error result appended. This happens when a loop
|
||||
is cancelled mid-tool-execution.
|
||||
"""
|
||||
# Pass 1: collect all tool_call IDs from assistant messages so we
|
||||
# can identify orphaned tool-result messages.
|
||||
all_tool_call_ids: set[str] = set()
|
||||
for m in msgs:
|
||||
if m.get("role") == "assistant":
|
||||
for tc in m.get("tool_calls") or []:
|
||||
tc_id = tc.get("id")
|
||||
if tc_id:
|
||||
all_tool_call_ids.add(tc_id)
|
||||
|
||||
# Pass 2: build repaired list — drop orphaned tool results, patch
|
||||
# missing tool results.
|
||||
repaired: list[dict[str, Any]] = []
|
||||
for i, m in enumerate(msgs):
|
||||
# Drop tool-result messages whose tool_call_id has no matching
|
||||
# tool_use in any assistant message (orphaned by compaction).
|
||||
if m.get("role") == "tool":
|
||||
tid = m.get("tool_call_id")
|
||||
if tid and tid not in all_tool_call_ids:
|
||||
continue # skip orphaned result
|
||||
|
||||
repaired.append(m)
|
||||
tool_calls = m.get("tool_calls")
|
||||
if m.get("role") != "assistant" or not tool_calls:
|
||||
@@ -353,12 +499,20 @@ class NodeConversation:
|
||||
"""Best available token estimate.
|
||||
|
||||
Uses actual API input token count when available (set via
|
||||
:meth:`update_token_count`), otherwise falls back to the rough
|
||||
``total_chars / 4`` heuristic.
|
||||
:meth:`update_token_count`), otherwise falls back to a
|
||||
``total_chars / 4`` heuristic that includes both message content
|
||||
AND tool_call argument sizes.
|
||||
"""
|
||||
if self._last_api_input_tokens is not None:
|
||||
return self._last_api_input_tokens
|
||||
total_chars = sum(len(m.content) for m in self._messages)
|
||||
total_chars = 0
|
||||
for m in self._messages:
|
||||
total_chars += len(m.content)
|
||||
if m.tool_calls:
|
||||
for tc in m.tool_calls:
|
||||
func = tc.get("function", {})
|
||||
total_chars += len(func.get("arguments", ""))
|
||||
total_chars += len(func.get("name", ""))
|
||||
return total_chars // 4
|
||||
|
||||
def update_token_count(self, actual_input_tokens: int) -> None:
|
||||
@@ -587,6 +741,210 @@ class NodeConversation:
|
||||
self._messages = [summary_msg] + recent_messages
|
||||
self._last_api_input_tokens = None # reset; next LLM call will recalibrate
|
||||
|
||||
async def compact_preserving_structure(
|
||||
self,
|
||||
spillover_dir: str,
|
||||
keep_recent: int = 4,
|
||||
phase_graduated: bool = False,
|
||||
aggressive: bool = False,
|
||||
) -> None:
|
||||
"""Structure-preserving compaction: save freeform text to file, keep tool messages.
|
||||
|
||||
Unlike ``compact()`` which replaces ALL old messages with a single LLM
|
||||
summary, this method preserves the tool call structure (assistant
|
||||
messages with tool_calls + tool result messages) that are already tiny
|
||||
after pruning. Only freeform text exchanges (user messages,
|
||||
text-only assistant messages) are saved to a file and removed.
|
||||
|
||||
When *aggressive* is True, non-essential tool call pairs are also
|
||||
collapsed into a compact summary instead of being kept individually.
|
||||
Only ``set_output`` calls and error results are preserved; all other
|
||||
old tool pairs are replaced by a tool-call history summary.
|
||||
|
||||
The result: the agent retains exact knowledge of what tools it called,
|
||||
where each result is stored, and can load the conversation text if
|
||||
needed. No LLM summary call. No heuristics. Nothing lost.
|
||||
"""
|
||||
if not self._messages:
|
||||
return
|
||||
|
||||
total = len(self._messages)
|
||||
|
||||
# Determine split point (same logic as compact)
|
||||
if phase_graduated and self._current_phase:
|
||||
split = self._find_phase_graduated_split()
|
||||
else:
|
||||
split = None
|
||||
|
||||
if split is None:
|
||||
keep_recent = max(0, min(keep_recent, total - 1))
|
||||
split = total - keep_recent if keep_recent > 0 else total
|
||||
|
||||
# Advance split past orphaned tool results at the boundary
|
||||
while split < total and self._messages[split].role == "tool":
|
||||
split += 1
|
||||
|
||||
if split == 0:
|
||||
return
|
||||
|
||||
old_messages = self._messages[:split]
|
||||
|
||||
# Classify old messages: structural (keep) vs freeform (save to file)
|
||||
kept_structural: list[Message] = []
|
||||
freeform_lines: list[str] = []
|
||||
collapsed_msgs: list[Message] = []
|
||||
|
||||
if aggressive:
|
||||
# Aggressive: only keep set_output tool pairs and error results.
|
||||
# Everything else is collapsed into a tool-call history summary.
|
||||
# We need to track tool_call IDs to pair assistant messages with
|
||||
# their tool results.
|
||||
protected_tc_ids: set[str] = set()
|
||||
collapsible_tc_ids: set[str] = set()
|
||||
|
||||
# First pass: classify assistant messages
|
||||
for msg in old_messages:
|
||||
if msg.role != "assistant" or not msg.tool_calls:
|
||||
continue
|
||||
has_protected = any(
|
||||
tc.get("function", {}).get("name") == "set_output" for tc in msg.tool_calls
|
||||
)
|
||||
tc_ids = {tc.get("id", "") for tc in msg.tool_calls}
|
||||
if has_protected:
|
||||
protected_tc_ids |= tc_ids
|
||||
else:
|
||||
collapsible_tc_ids |= tc_ids
|
||||
|
||||
# Second pass: classify all messages
|
||||
for msg in old_messages:
|
||||
if msg.role == "tool":
|
||||
tc_id = msg.tool_use_id or ""
|
||||
if tc_id in protected_tc_ids:
|
||||
kept_structural.append(msg)
|
||||
elif msg.is_error:
|
||||
# Error results are always protected
|
||||
kept_structural.append(msg)
|
||||
# Protect the parent assistant message too
|
||||
protected_tc_ids.add(tc_id)
|
||||
else:
|
||||
collapsed_msgs.append(msg)
|
||||
elif msg.role == "assistant" and msg.tool_calls:
|
||||
tc_ids = {tc.get("id", "") for tc in msg.tool_calls}
|
||||
if tc_ids & protected_tc_ids:
|
||||
# Has at least one protected tool call — keep entire msg
|
||||
compact_tcs = _compact_tool_calls(msg.tool_calls)
|
||||
kept_structural.append(
|
||||
Message(
|
||||
seq=msg.seq,
|
||||
role=msg.role,
|
||||
content="",
|
||||
tool_calls=compact_tcs,
|
||||
is_error=msg.is_error,
|
||||
phase_id=msg.phase_id,
|
||||
is_transition_marker=msg.is_transition_marker,
|
||||
)
|
||||
)
|
||||
else:
|
||||
collapsed_msgs.append(msg)
|
||||
else:
|
||||
# Freeform text — save to file
|
||||
role_label = msg.role
|
||||
text = msg.content
|
||||
if len(text) > 2000:
|
||||
text = text[:2000] + "…"
|
||||
freeform_lines.append(f"[{role_label}] (seq={msg.seq}): {text}")
|
||||
else:
|
||||
# Standard mode: keep all tool call pairs as structural
|
||||
for msg in old_messages:
|
||||
if msg.role == "tool":
|
||||
kept_structural.append(msg)
|
||||
elif msg.role == "assistant" and msg.tool_calls:
|
||||
compact_tcs = _compact_tool_calls(msg.tool_calls)
|
||||
kept_structural.append(
|
||||
Message(
|
||||
seq=msg.seq,
|
||||
role=msg.role,
|
||||
content="",
|
||||
tool_calls=compact_tcs,
|
||||
is_error=msg.is_error,
|
||||
phase_id=msg.phase_id,
|
||||
is_transition_marker=msg.is_transition_marker,
|
||||
)
|
||||
)
|
||||
else:
|
||||
role_label = msg.role
|
||||
text = msg.content
|
||||
if len(text) > 2000:
|
||||
text = text[:2000] + "…"
|
||||
freeform_lines.append(f"[{role_label}] (seq={msg.seq}): {text}")
|
||||
|
||||
# Write freeform text to a numbered conversation file
|
||||
spill_path = Path(spillover_dir)
|
||||
spill_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Find next conversation file number
|
||||
existing = sorted(spill_path.glob("conversation_*.md"))
|
||||
next_n = len(existing) + 1
|
||||
conv_filename = f"conversation_{next_n}.md"
|
||||
|
||||
if freeform_lines:
|
||||
header = f"## Compacted conversation (messages 1-{split})\n\n"
|
||||
conv_text = header + "\n\n".join(freeform_lines)
|
||||
(spill_path / conv_filename).write_text(conv_text, encoding="utf-8")
|
||||
else:
|
||||
# Nothing to save — skip file creation
|
||||
conv_filename = ""
|
||||
|
||||
# Build reference message
|
||||
ref_parts: list[str] = []
|
||||
if conv_filename:
|
||||
ref_parts.append(
|
||||
f"[Previous conversation saved to '{conv_filename}'. "
|
||||
f"Use load_data('{conv_filename}') to review if needed.]"
|
||||
)
|
||||
elif not collapsed_msgs:
|
||||
ref_parts.append("[Previous freeform messages compacted.]")
|
||||
|
||||
# Aggressive: add collapsed tool-call history to the reference
|
||||
if collapsed_msgs:
|
||||
tool_history = extract_tool_call_history(collapsed_msgs)
|
||||
if tool_history:
|
||||
ref_parts.append(tool_history)
|
||||
elif not ref_parts:
|
||||
ref_parts.append("[Previous tool calls compacted.]")
|
||||
|
||||
ref_content = "\n\n".join(ref_parts)
|
||||
|
||||
# Use a seq just before the first kept message
|
||||
recent_messages = list(self._messages[split:])
|
||||
if kept_structural:
|
||||
ref_seq = kept_structural[0].seq - 1
|
||||
elif recent_messages:
|
||||
ref_seq = recent_messages[0].seq - 1
|
||||
else:
|
||||
ref_seq = self._next_seq
|
||||
self._next_seq += 1
|
||||
|
||||
ref_msg = Message(seq=ref_seq, role="user", content=ref_content)
|
||||
|
||||
# Persist: delete old messages from store, write reference + kept structural.
|
||||
# In aggressive mode, collapsed messages may be interspersed with kept
|
||||
# messages, so we delete everything before the recent boundary and
|
||||
# rewrite only what we want to keep.
|
||||
if self._store:
|
||||
recent_boundary = recent_messages[0].seq if recent_messages else self._next_seq
|
||||
await self._store.delete_parts_before(recent_boundary)
|
||||
# Write the reference message
|
||||
await self._store.write_part(ref_msg.seq, ref_msg.to_storage_dict())
|
||||
# Write kept structural messages (they may have been modified)
|
||||
for msg in kept_structural:
|
||||
await self._store.write_part(msg.seq, msg.to_storage_dict())
|
||||
await self._store.write_cursor({"next_seq": self._next_seq})
|
||||
|
||||
# Reassemble: reference + kept structural (in original order) + recent
|
||||
self._messages = [ref_msg] + kept_structural + recent_messages
|
||||
self._last_api_input_tokens = None
|
||||
|
||||
def _find_phase_graduated_split(self) -> int | None:
|
||||
"""Find split point that preserves current + previous phase.
|
||||
|
||||
|
||||
@@ -103,7 +103,12 @@ FEEDBACK: (reason if RETRY, empty if ACCEPT)"""
|
||||
|
||||
|
||||
def _extract_recent_context(conversation: NodeConversation, max_messages: int = 10) -> str:
|
||||
"""Extract recent conversation messages for evaluation."""
|
||||
"""Extract recent conversation messages for evaluation.
|
||||
|
||||
Includes tool-call summaries from assistant messages so the judge
|
||||
can see what tools were invoked (especially set_output values) even
|
||||
when the assistant message body is empty.
|
||||
"""
|
||||
messages = conversation.messages
|
||||
recent = messages[-max_messages:] if len(messages) > max_messages else messages
|
||||
|
||||
@@ -112,8 +117,24 @@ def _extract_recent_context(conversation: NodeConversation, max_messages: int =
|
||||
role = msg.role.upper()
|
||||
content = msg.content or ""
|
||||
# Truncate long tool results
|
||||
if msg.role == "tool" and len(content) > 200:
|
||||
content = content[:200] + "..."
|
||||
if msg.role == "tool" and len(content) > 500:
|
||||
content = content[:500] + "..."
|
||||
# For assistant messages with empty content but tool_calls,
|
||||
# summarise the tool calls so the judge knows what happened.
|
||||
if msg.role == "assistant" and not content.strip():
|
||||
tool_calls = getattr(msg, "tool_calls", None)
|
||||
if tool_calls:
|
||||
tc_parts = []
|
||||
for tc in tool_calls:
|
||||
fn = tc.get("function", {}) if isinstance(tc, dict) else {}
|
||||
name = fn.get("name", "")
|
||||
args = fn.get("arguments", "")
|
||||
if name == "set_output":
|
||||
# Show the value so the judge can evaluate content quality
|
||||
tc_parts.append(f" called {name}({args[:1000]})")
|
||||
else:
|
||||
tc_parts.append(f" called {name}(...)")
|
||||
content = "Tool calls:\n" + "\n".join(tc_parts)
|
||||
if content.strip():
|
||||
parts.append(f"[{role}]: {content.strip()}")
|
||||
|
||||
@@ -125,6 +146,10 @@ def _format_outputs(accumulator_state: dict[str, Any]) -> str:
|
||||
|
||||
Lists and dicts get structural formatting so the judge can assess
|
||||
quantity and structure, not just a truncated stringification.
|
||||
|
||||
String values are given a generous limit (2000 chars) so the judge
|
||||
can verify substantive content (e.g. a research brief with key
|
||||
questions, scope boundaries, and deliverables).
|
||||
"""
|
||||
if not accumulator_state:
|
||||
return "(none)"
|
||||
@@ -144,12 +169,12 @@ def _format_outputs(accumulator_state: dict[str, Any]) -> str:
|
||||
val_str += f"\n ... and {len(value) - 8} more"
|
||||
elif isinstance(value, dict):
|
||||
val_str = str(value)
|
||||
if len(val_str) > 400:
|
||||
val_str = val_str[:400] + "..."
|
||||
if len(val_str) > 2000:
|
||||
val_str = val_str[:2000] + "..."
|
||||
else:
|
||||
val_str = str(value)
|
||||
if len(val_str) > 300:
|
||||
val_str = val_str[:300] + "..."
|
||||
if len(val_str) > 2000:
|
||||
val_str = val_str[:2000] + "..."
|
||||
parts.append(f" {key}: {val_str}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@@ -338,6 +338,10 @@ class AsyncEntryPointSpec(BaseModel):
|
||||
max_concurrent: int = Field(
|
||||
default=10, description="Maximum concurrent executions for this entry point"
|
||||
)
|
||||
max_resurrections: int = Field(
|
||||
default=3,
|
||||
description="Auto-restart on non-fatal failure (0 to disable)",
|
||||
)
|
||||
|
||||
model_config = {"extra": "allow"}
|
||||
|
||||
@@ -427,8 +431,7 @@ class GraphSpec(BaseModel):
|
||||
max_tokens: int = Field(default=None) # resolved by _resolve_max_tokens validator
|
||||
|
||||
# Cleanup LLM for JSON extraction fallback (fast/cheap model preferred)
|
||||
# If not set, uses CEREBRAS_API_KEY -> cerebras/llama-3.3-70b or
|
||||
# ANTHROPIC_API_KEY -> claude-haiku-4-5 as fallback
|
||||
# If not set, uses CEREBRAS_API_KEY -> cerebras/llama-3.3-70b
|
||||
cleanup_llm_model: str | None = None
|
||||
|
||||
# Execution limits
|
||||
@@ -644,6 +647,13 @@ class GraphSpec(BaseModel):
|
||||
for edge in self.get_outgoing_edges(current):
|
||||
to_visit.append(edge.target)
|
||||
|
||||
# Also mark sub-agents as reachable (they're invoked via delegate_to_sub_agent, not edges)
|
||||
for node in self.nodes:
|
||||
if node.id in reachable:
|
||||
sub_agents = getattr(node, "sub_agents", []) or []
|
||||
for sub_agent_id in sub_agents:
|
||||
reachable.add(sub_agent_id)
|
||||
|
||||
# Build set of async entry point nodes for quick lookup
|
||||
async_entry_nodes = {ep.entry_node for ep in self.async_entry_points}
|
||||
|
||||
@@ -695,4 +705,48 @@ class GraphSpec(BaseModel):
|
||||
else:
|
||||
seen_keys[key] = node_id
|
||||
|
||||
# GCU nodes must only be used as subagents
|
||||
gcu_node_ids = {n.id for n in self.nodes if n.node_type == "gcu"}
|
||||
if gcu_node_ids:
|
||||
# GCU nodes must not be entry nodes
|
||||
if self.entry_node in gcu_node_ids:
|
||||
errors.append(
|
||||
f"GCU node '{self.entry_node}' is used as entry node. "
|
||||
"GCU nodes must only be used as subagents via delegate_to_sub_agent()."
|
||||
)
|
||||
|
||||
# GCU nodes must not be terminal nodes
|
||||
for term in self.terminal_nodes:
|
||||
if term in gcu_node_ids:
|
||||
errors.append(
|
||||
f"GCU node '{term}' is used as terminal node. "
|
||||
"GCU nodes must only be used as subagents."
|
||||
)
|
||||
|
||||
# GCU nodes must not be connected via edges
|
||||
for edge in self.edges:
|
||||
if edge.source in gcu_node_ids:
|
||||
errors.append(
|
||||
f"GCU node '{edge.source}' is used as edge source (edge '{edge.id}'). "
|
||||
"GCU nodes must only be used as subagents, not connected via edges."
|
||||
)
|
||||
if edge.target in gcu_node_ids:
|
||||
errors.append(
|
||||
f"GCU node '{edge.target}' is used as edge target (edge '{edge.id}'). "
|
||||
"GCU nodes must only be used as subagents, not connected via edges."
|
||||
)
|
||||
|
||||
# GCU nodes must be referenced in at least one parent's sub_agents
|
||||
referenced_subagents = set()
|
||||
for node in self.nodes:
|
||||
for sa_id in node.sub_agents or []:
|
||||
referenced_subagents.add(sa_id)
|
||||
|
||||
orphaned = gcu_node_ids - referenced_subagents
|
||||
for nid in orphaned:
|
||||
errors.append(
|
||||
f"GCU node '{nid}' is not referenced in any node's sub_agents list. "
|
||||
"GCU nodes must be declared as subagents of a parent node."
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
+1726
-413
File diff suppressed because it is too large
Load Diff
@@ -138,6 +138,7 @@ class GraphExecutor:
|
||||
accounts_prompt: str = "",
|
||||
accounts_data: list[dict] | None = None,
|
||||
tool_provider_map: dict[str, str] | None = None,
|
||||
dynamic_tools_provider: Callable | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the executor.
|
||||
@@ -160,6 +161,8 @@ class GraphExecutor:
|
||||
accounts_prompt: Connected accounts block for system prompt injection
|
||||
accounts_data: Raw account data for per-node prompt generation
|
||||
tool_provider_map: Tool name to provider name mapping for account routing
|
||||
dynamic_tools_provider: Optional callback returning current
|
||||
tool list (for mode switching)
|
||||
"""
|
||||
self.runtime = runtime
|
||||
self.llm = llm
|
||||
@@ -178,12 +181,14 @@ class GraphExecutor:
|
||||
self.accounts_prompt = accounts_prompt
|
||||
self.accounts_data = accounts_data
|
||||
self.tool_provider_map = tool_provider_map
|
||||
self.dynamic_tools_provider = dynamic_tools_provider
|
||||
|
||||
# Initialize output cleaner
|
||||
# Initialize output cleaner — uses its own dedicated fast model (CEREBRAS_API_KEY),
|
||||
# never the main agent LLM. Passing the main LLM here would cause expensive
|
||||
# Anthropic calls for output cleaning whenever ANTHROPIC_API_KEY is set.
|
||||
self.cleansing_config = cleansing_config or CleansingConfig()
|
||||
self.output_cleaner = OutputCleaner(
|
||||
config=self.cleansing_config,
|
||||
llm_provider=llm,
|
||||
)
|
||||
|
||||
# Parallel execution settings
|
||||
@@ -193,6 +198,9 @@ class GraphExecutor:
|
||||
# Pause/resume control
|
||||
self._pause_requested = asyncio.Event()
|
||||
|
||||
# Track the currently executing node for external injection routing
|
||||
self.current_node_id: str | None = None
|
||||
|
||||
def _write_progress(
|
||||
self,
|
||||
current_node: str,
|
||||
@@ -283,6 +291,125 @@ class GraphExecutor:
|
||||
|
||||
return errors
|
||||
|
||||
# Max chars of formatted messages before proactively splitting for LLM.
|
||||
_PHASE_LLM_CHAR_LIMIT = 240_000
|
||||
_PHASE_LLM_MAX_DEPTH = 10
|
||||
|
||||
async def _phase_llm_compact(
|
||||
self,
|
||||
conversation: Any,
|
||||
next_spec: NodeSpec,
|
||||
messages: list,
|
||||
_depth: int = 0,
|
||||
) -> str:
|
||||
"""Summarise messages for phase-boundary compaction.
|
||||
|
||||
Uses the same recursive binary-search splitting as EventLoopNode.
|
||||
"""
|
||||
from framework.graph.conversation import extract_tool_call_history
|
||||
from framework.graph.event_loop_node import _is_context_too_large_error
|
||||
|
||||
if _depth > self._PHASE_LLM_MAX_DEPTH:
|
||||
raise RuntimeError("Phase LLM compaction recursion limit")
|
||||
|
||||
# Format messages
|
||||
lines: list[str] = []
|
||||
for m in messages:
|
||||
if m.role == "tool":
|
||||
c = m.content[:500] + ("..." if len(m.content) > 500 else "")
|
||||
lines.append(f"[tool result]: {c}")
|
||||
elif m.role == "assistant" and m.tool_calls:
|
||||
names = [tc.get("function", {}).get("name", "?") for tc in m.tool_calls]
|
||||
lines.append(
|
||||
f"[assistant (calls: {', '.join(names)})]: "
|
||||
f"{m.content[:200] if m.content else ''}"
|
||||
)
|
||||
else:
|
||||
lines.append(f"[{m.role}]: {m.content}")
|
||||
formatted = "\n\n".join(lines)
|
||||
|
||||
# Proactive split
|
||||
if len(formatted) > self._PHASE_LLM_CHAR_LIMIT and len(messages) > 1:
|
||||
summary = await self._phase_llm_compact_split(
|
||||
conversation,
|
||||
next_spec,
|
||||
messages,
|
||||
_depth,
|
||||
)
|
||||
else:
|
||||
max_tokens = getattr(conversation, "_max_history_tokens", 32000)
|
||||
target_tokens = max_tokens // 2
|
||||
target_chars = target_tokens * 4
|
||||
|
||||
prompt = (
|
||||
"You are compacting an AI agent's conversation history "
|
||||
"at a phase boundary.\n\n"
|
||||
f"NEXT PHASE: {next_spec.name}\n"
|
||||
)
|
||||
if next_spec.description:
|
||||
prompt += f"NEXT PHASE PURPOSE: {next_spec.description}\n"
|
||||
prompt += (
|
||||
f"\nCONVERSATION MESSAGES:\n{formatted}\n\n"
|
||||
"INSTRUCTIONS:\n"
|
||||
f"Write a summary of approximately {target_chars} characters "
|
||||
f"(~{target_tokens} tokens).\n"
|
||||
"Preserve user-stated rules, constraints, and preferences "
|
||||
"verbatim. Preserve key decisions and results from earlier "
|
||||
"phases. Preserve context needed for the next phase.\n"
|
||||
)
|
||||
summary_budget = max(1024, max_tokens // 2)
|
||||
try:
|
||||
response = await self._llm.acomplete(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
system=(
|
||||
"You are a conversation compactor. Write a detailed "
|
||||
"summary preserving context for the next phase."
|
||||
),
|
||||
max_tokens=summary_budget,
|
||||
)
|
||||
summary = response.content
|
||||
except Exception as e:
|
||||
if _is_context_too_large_error(e) and len(messages) > 1:
|
||||
summary = await self._phase_llm_compact_split(
|
||||
conversation,
|
||||
next_spec,
|
||||
messages,
|
||||
_depth,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
# Append tool history at top level only
|
||||
if _depth == 0:
|
||||
tool_history = extract_tool_call_history(messages)
|
||||
if tool_history and "TOOLS ALREADY CALLED" not in summary:
|
||||
summary += "\n\n" + tool_history
|
||||
|
||||
return summary
|
||||
|
||||
async def _phase_llm_compact_split(
|
||||
self,
|
||||
conversation: Any,
|
||||
next_spec: NodeSpec,
|
||||
messages: list,
|
||||
_depth: int,
|
||||
) -> str:
|
||||
"""Split messages in half and summarise each half."""
|
||||
mid = max(1, len(messages) // 2)
|
||||
s1 = await self._phase_llm_compact(
|
||||
conversation,
|
||||
next_spec,
|
||||
messages[:mid],
|
||||
_depth + 1,
|
||||
)
|
||||
s2 = await self._phase_llm_compact(
|
||||
conversation,
|
||||
next_spec,
|
||||
messages[mid:],
|
||||
_depth + 1,
|
||||
)
|
||||
return s1 + "\n\n" + s2
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
graph: GraphSpec,
|
||||
@@ -338,6 +465,9 @@ class GraphExecutor:
|
||||
cumulative_tool_names: set[str] = set()
|
||||
cumulative_output_keys: list[str] = [] # Output keys from all visited nodes
|
||||
|
||||
# Build node registry for subagent lookup
|
||||
node_registry: dict[str, NodeSpec] = {node.id: node for node in graph.nodes}
|
||||
|
||||
# Initialize checkpoint store if checkpointing is enabled
|
||||
checkpoint_store: CheckpointStore | None = None
|
||||
if checkpoint_config and checkpoint_config.enabled and self._storage_path:
|
||||
@@ -694,6 +824,9 @@ class GraphExecutor:
|
||||
# Execute this node, then pause
|
||||
# (We'll check again after execution and save state)
|
||||
|
||||
# Expose current node for external injection routing
|
||||
self.current_node_id = current_node_id
|
||||
|
||||
self.logger.info(f"\n▶ Step {steps}: {node_spec.name} ({node_spec.node_type})")
|
||||
self.logger.info(f" Inputs: {node_spec.input_keys}")
|
||||
self.logger.info(f" Outputs: {node_spec.output_keys}")
|
||||
@@ -709,6 +842,14 @@ class GraphExecutor:
|
||||
if k not in cumulative_output_keys:
|
||||
cumulative_output_keys.append(k)
|
||||
|
||||
# Build resume narrative (Layer 2) when restoring a session
|
||||
# so the EventLoopNode can rebuild the full 3-layer system prompt.
|
||||
_resume_narrative = ""
|
||||
if _is_resuming and path:
|
||||
from framework.graph.prompt_composer import build_narrative
|
||||
|
||||
_resume_narrative = build_narrative(memory, path, graph)
|
||||
|
||||
# Build context for node
|
||||
ctx = self._build_context(
|
||||
node_spec=node_spec,
|
||||
@@ -721,6 +862,10 @@ class GraphExecutor:
|
||||
override_tools=cumulative_tools if is_continuous else None,
|
||||
cumulative_output_keys=cumulative_output_keys if is_continuous else None,
|
||||
event_triggered=_event_triggered,
|
||||
node_registry=node_registry,
|
||||
identity_prompt=getattr(graph, "identity_prompt", ""),
|
||||
narrative=_resume_narrative,
|
||||
graph=graph,
|
||||
)
|
||||
|
||||
# Log actual input data being read
|
||||
@@ -1120,6 +1265,7 @@ class GraphExecutor:
|
||||
source_result=result,
|
||||
source_node_spec=node_spec,
|
||||
path=path,
|
||||
node_registry=node_registry,
|
||||
)
|
||||
|
||||
total_tokens += branch_tokens
|
||||
@@ -1269,27 +1415,78 @@ class GraphExecutor:
|
||||
# Set current phase for phase-aware compaction
|
||||
continuous_conversation.set_current_phase(next_spec.id)
|
||||
|
||||
# Opportunistic compaction at transition:
|
||||
# 1. Prune old tool results (free, no LLM call)
|
||||
# 2. If still over 80%, do a phase-graduated compact
|
||||
# Phase-boundary compaction (same flow as EventLoopNode._compact)
|
||||
if continuous_conversation.usage_ratio() > 0.5:
|
||||
await continuous_conversation.prune_old_tool_results(
|
||||
protect_tokens=2000,
|
||||
)
|
||||
if continuous_conversation.needs_compaction():
|
||||
_phase_ratio = continuous_conversation.usage_ratio()
|
||||
self.logger.info(
|
||||
" Phase-boundary compaction (%.0f%% usage)",
|
||||
continuous_conversation.usage_ratio() * 100,
|
||||
_phase_ratio * 100,
|
||||
)
|
||||
summary = (
|
||||
f"Summary of earlier phases (before {next_spec.name}). "
|
||||
"See transition markers for phase details."
|
||||
)
|
||||
await continuous_conversation.compact(
|
||||
summary,
|
||||
keep_recent=4,
|
||||
phase_graduated=True,
|
||||
_data_dir = (
|
||||
str(self._storage_path / "data") if self._storage_path else None
|
||||
)
|
||||
# Step 1: Structural compaction (>=80%)
|
||||
if _data_dir:
|
||||
_pre = continuous_conversation.usage_ratio()
|
||||
await continuous_conversation.compact_preserving_structure(
|
||||
spillover_dir=_data_dir,
|
||||
keep_recent=4,
|
||||
phase_graduated=True,
|
||||
)
|
||||
if continuous_conversation.usage_ratio() >= 0.9 * _pre:
|
||||
await continuous_conversation.compact_preserving_structure(
|
||||
spillover_dir=_data_dir,
|
||||
keep_recent=4,
|
||||
phase_graduated=True,
|
||||
aggressive=True,
|
||||
)
|
||||
|
||||
# Step 2: LLM compaction (>95%)
|
||||
if (
|
||||
continuous_conversation.usage_ratio() > 0.95
|
||||
and self._llm is not None
|
||||
):
|
||||
self.logger.info(
|
||||
" LLM phase-boundary compaction (%.0f%% usage)",
|
||||
continuous_conversation.usage_ratio() * 100,
|
||||
)
|
||||
try:
|
||||
_llm_summary = await self._phase_llm_compact(
|
||||
continuous_conversation,
|
||||
next_spec,
|
||||
list(continuous_conversation.messages),
|
||||
)
|
||||
await continuous_conversation.compact(
|
||||
_llm_summary,
|
||||
keep_recent=2,
|
||||
phase_graduated=True,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
" Phase LLM compaction failed: %s",
|
||||
e,
|
||||
)
|
||||
|
||||
# Step 3: Emergency (only if still over budget)
|
||||
if continuous_conversation.needs_compaction():
|
||||
self.logger.warning(
|
||||
" Emergency phase compaction (%.0f%%)",
|
||||
continuous_conversation.usage_ratio() * 100,
|
||||
)
|
||||
summary = (
|
||||
f"Summary of earlier phases "
|
||||
f"(before {next_spec.name}). "
|
||||
"See transition markers for phase details."
|
||||
)
|
||||
await continuous_conversation.compact(
|
||||
summary,
|
||||
keep_recent=1,
|
||||
phase_graduated=True,
|
||||
)
|
||||
|
||||
# Update input_data for next node
|
||||
input_data = result.output
|
||||
@@ -1541,6 +1738,10 @@ class GraphExecutor:
|
||||
override_tools: list | None = None,
|
||||
cumulative_output_keys: list[str] | None = None,
|
||||
event_triggered: bool = False,
|
||||
identity_prompt: str = "",
|
||||
narrative: str = "",
|
||||
node_registry: dict[str, NodeSpec] | None = None,
|
||||
graph: "GraphSpec | None" = None,
|
||||
) -> NodeContext:
|
||||
"""Build execution context for a node."""
|
||||
# Filter tools to those available to this node
|
||||
@@ -1569,6 +1770,8 @@ class GraphExecutor:
|
||||
node_tool_names=node_spec.tools,
|
||||
)
|
||||
|
||||
goal_context = goal.to_prompt_context()
|
||||
|
||||
return NodeContext(
|
||||
runtime=self.runtime,
|
||||
node_id=node_spec.id,
|
||||
@@ -1577,7 +1780,7 @@ class GraphExecutor:
|
||||
input_data=input_data,
|
||||
llm=self.llm,
|
||||
available_tools=available_tools,
|
||||
goal_context=goal.to_prompt_context(),
|
||||
goal_context=goal_context,
|
||||
goal=goal, # Pass Goal object for LLM-powered routers
|
||||
max_tokens=max_tokens,
|
||||
runtime_logger=self.runtime_logger,
|
||||
@@ -1587,12 +1790,19 @@ class GraphExecutor:
|
||||
cumulative_output_keys=cumulative_output_keys or [],
|
||||
event_triggered=event_triggered,
|
||||
accounts_prompt=node_accounts_prompt,
|
||||
identity_prompt=identity_prompt,
|
||||
narrative=narrative,
|
||||
execution_id=self._execution_id,
|
||||
stream_id=self._stream_id,
|
||||
node_registry=node_registry or {},
|
||||
all_tools=list(self.tools), # Full catalog for subagent tool resolution
|
||||
shared_node_registry=self.node_registry, # For subagent escalation routing
|
||||
dynamic_tools_provider=self.dynamic_tools_provider,
|
||||
)
|
||||
|
||||
VALID_NODE_TYPES = {
|
||||
"event_loop",
|
||||
"gcu",
|
||||
}
|
||||
# Node types removed in v0.5 — provide migration guidance
|
||||
REMOVED_NODE_TYPES = {
|
||||
@@ -1627,8 +1837,8 @@ class GraphExecutor:
|
||||
f"Must be one of: {sorted(self.VALID_NODE_TYPES)}."
|
||||
)
|
||||
|
||||
# Create based on type (only event_loop is valid)
|
||||
if node_spec.node_type == "event_loop":
|
||||
# Create based on type
|
||||
if node_spec.node_type in ("event_loop", "gcu"):
|
||||
# Auto-create EventLoopNode with sensible defaults.
|
||||
# Custom configs can still be pre-registered via node_registry.
|
||||
from framework.graph.event_loop_node import EventLoopNode, LoopConfig
|
||||
@@ -1658,11 +1868,11 @@ class GraphExecutor:
|
||||
judge=None, # implicit judge: accept when output_keys are filled
|
||||
config=LoopConfig(
|
||||
max_iterations=lc.get("max_iterations", default_max_iter),
|
||||
max_tool_calls_per_turn=lc.get("max_tool_calls_per_turn", 10),
|
||||
max_tool_calls_per_turn=lc.get("max_tool_calls_per_turn", 30),
|
||||
tool_call_overflow_margin=lc.get("tool_call_overflow_margin", 0.5),
|
||||
stall_detection_threshold=lc.get("stall_detection_threshold", 3),
|
||||
max_history_tokens=lc.get("max_history_tokens", 32000),
|
||||
max_tool_result_chars=lc.get("max_tool_result_chars", 3_000),
|
||||
max_tool_result_chars=lc.get("max_tool_result_chars", 30_000),
|
||||
spillover_dir=spillover,
|
||||
),
|
||||
tool_executor=self.tool_executor,
|
||||
@@ -1845,6 +2055,7 @@ class GraphExecutor:
|
||||
source_result: NodeResult,
|
||||
source_node_spec: Any,
|
||||
path: list[str],
|
||||
node_registry: dict[str, NodeSpec] | None = None,
|
||||
) -> tuple[dict[str, NodeResult], int, int]:
|
||||
"""
|
||||
Execute multiple branches in parallel using asyncio.gather.
|
||||
@@ -1942,7 +2153,15 @@ class GraphExecutor:
|
||||
branch.retry_count = attempt
|
||||
|
||||
# Build context for this branch
|
||||
ctx = self._build_context(node_spec, memory, goal, mapped, graph.max_tokens)
|
||||
ctx = self._build_context(
|
||||
node_spec,
|
||||
memory,
|
||||
goal,
|
||||
mapped,
|
||||
graph.max_tokens,
|
||||
node_registry=node_registry,
|
||||
graph=graph,
|
||||
)
|
||||
node_impl = self._get_node_implementation(node_spec, graph.cleanup_llm_model)
|
||||
|
||||
# Emit node-started event (skip event_loop nodes)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""File tools MCP server constants.
|
||||
|
||||
Analogous to ``gcu.py`` — defines the server name and default stdio config
|
||||
so the runner can auto-register the files MCP server for any agent that has
|
||||
``event_loop`` or ``gcu`` nodes.
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP server identity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FILES_MCP_SERVER_NAME = "files-tools"
|
||||
"""Name used to identify the file tools MCP server in ``mcp_servers.json``."""
|
||||
|
||||
FILES_MCP_SERVER_CONFIG: dict = {
|
||||
"name": FILES_MCP_SERVER_NAME,
|
||||
"transport": "stdio",
|
||||
"command": "uv",
|
||||
"args": ["run", "python", "files_server.py", "--stdio"],
|
||||
"cwd": "../../tools",
|
||||
"description": "File tools for reading, writing, editing, and searching files",
|
||||
}
|
||||
"""Default stdio config for the file tools MCP server (relative to exports/<agent>/)."""
|
||||
@@ -0,0 +1,86 @@
|
||||
"""GCU (browser automation) node type constants.
|
||||
|
||||
A ``gcu`` node is an ``event_loop`` node with two automatic enhancements:
|
||||
1. A canonical browser best-practices system prompt is prepended.
|
||||
2. All tools from the GCU MCP server are auto-included.
|
||||
|
||||
No new ``NodeProtocol`` subclass — the ``gcu`` type is purely a declarative
|
||||
signal processed by the runner and executor at setup time.
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP server identity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GCU_SERVER_NAME = "gcu-tools"
|
||||
"""Name used to identify the GCU MCP server in ``mcp_servers.json``."""
|
||||
|
||||
GCU_MCP_SERVER_CONFIG: dict = {
|
||||
"name": GCU_SERVER_NAME,
|
||||
"transport": "stdio",
|
||||
"command": "uv",
|
||||
"args": ["run", "python", "-m", "gcu.server", "--stdio"],
|
||||
"cwd": "../../tools",
|
||||
"description": "GCU tools for browser automation",
|
||||
}
|
||||
"""Default stdio config for the GCU MCP server (relative to exports/<agent>/)."""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Browser best-practices system prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GCU_BROWSER_SYSTEM_PROMPT = """\
|
||||
# Browser Automation Best Practices
|
||||
|
||||
Follow these rules for reliable, efficient browser interaction.
|
||||
|
||||
## Reading Pages
|
||||
- ALWAYS prefer `browser_snapshot` over `browser_get_text("body")`
|
||||
— it returns a compact ~1-5 KB accessibility tree vs 100+ KB of raw HTML.
|
||||
- Use `browser_snapshot_aria` when you need full ARIA properties
|
||||
for detailed element inspection.
|
||||
- Do NOT use `browser_screenshot` for reading text content
|
||||
— it produces huge base64 images with no searchable text.
|
||||
- Only fall back to `browser_get_text` for extracting specific
|
||||
small elements by CSS selector.
|
||||
|
||||
## Navigation & Waiting
|
||||
- Always call `browser_wait` after navigation actions
|
||||
(`browser_open`, `browser_navigate`, `browser_click` on links)
|
||||
to let the page load.
|
||||
- NEVER re-navigate to the same URL after scrolling
|
||||
— this resets your scroll position and loses loaded content.
|
||||
|
||||
## Scrolling
|
||||
- Use large scroll amounts ~2000 when loading more content
|
||||
— sites like twitter and linkedin have lazy loading for paging.
|
||||
- After scrolling, take a new `browser_snapshot` to see updated content.
|
||||
|
||||
## Error Recovery
|
||||
- If a tool fails, retry once with the same approach.
|
||||
- If it fails a second time, STOP retrying and switch approach.
|
||||
- If `browser_snapshot` fails → try `browser_get_text` with a
|
||||
specific small selector as fallback.
|
||||
- If `browser_open` fails or page seems stale → `browser_stop`,
|
||||
then `browser_start`, then retry.
|
||||
|
||||
## Tab Management
|
||||
- Use `browser_tabs` to list open tabs when managing multiple pages.
|
||||
- Pass `target_id` to tools when operating on a specific tab.
|
||||
- Open background tabs with `browser_open(url=..., background=true)`
|
||||
to avoid losing your current context.
|
||||
- Close tabs you no longer need with `browser_close` to free resources.
|
||||
|
||||
## Login & Auth Walls
|
||||
- If you see a "Log in" or "Sign up" prompt instead of expected
|
||||
content, report the auth wall immediately — do NOT attempt to log in.
|
||||
- Check for cookie consent banners and dismiss them if they block content.
|
||||
|
||||
## Efficiency
|
||||
- Minimize tool calls — combine actions where possible.
|
||||
- When a snapshot result is saved to a spillover file, use
|
||||
`run_command` with grep to extract specific data rather than
|
||||
re-reading the full file.
|
||||
- Call `set_output` in the same turn as your last browser action
|
||||
when possible — don't waste a turn.
|
||||
"""
|
||||
@@ -176,7 +176,17 @@ class Goal(BaseModel):
|
||||
return True
|
||||
|
||||
def to_prompt_context(self) -> str:
|
||||
"""Generate context string for LLM prompts."""
|
||||
"""Generate context string for LLM prompts.
|
||||
|
||||
Returns empty string when the goal is a stub (no success criteria,
|
||||
no constraints, no context). Stub goals are metadata-only — used for
|
||||
graph identification but not communicated to the LLM as actionable
|
||||
intent. This prevents runtime agents (e.g. the queen) from
|
||||
misinterpreting their own goal as a user request.
|
||||
"""
|
||||
if not self.success_criteria and not self.constraints and not self.context:
|
||||
return ""
|
||||
|
||||
lines = [
|
||||
f"# Goal: {self.name}",
|
||||
f"{self.description}",
|
||||
|
||||
@@ -154,69 +154,17 @@ class HITLProtocol:
|
||||
"""
|
||||
Parse human's raw input into structured response.
|
||||
|
||||
Uses Haiku to intelligently extract answers for each question.
|
||||
Maps the raw input to the first question. For multi-question HITL,
|
||||
the caller should present one question at a time.
|
||||
"""
|
||||
import os
|
||||
|
||||
response = HITLResponse(request_id=request.request_id, raw_input=raw_input)
|
||||
|
||||
# If no questions, just return raw input
|
||||
if not request.questions:
|
||||
return response
|
||||
|
||||
# Try to use Haiku for intelligent parsing
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
if not use_haiku or not api_key:
|
||||
# Simple fallback: treat as answer to first question
|
||||
if request.questions:
|
||||
response.answers[request.questions[0].id] = raw_input
|
||||
return response
|
||||
|
||||
# Use Haiku to extract answers
|
||||
try:
|
||||
import json
|
||||
|
||||
import anthropic
|
||||
|
||||
questions_str = "\n".join(
|
||||
[f"{i + 1}. {q.question} (id: {q.id})" for i, q in enumerate(request.questions)]
|
||||
)
|
||||
|
||||
prompt = f"""Parse the user's response and extract answers for each question.
|
||||
|
||||
Questions asked:
|
||||
{questions_str}
|
||||
|
||||
User's response:
|
||||
{raw_input}
|
||||
|
||||
Extract the answer for each question. Output JSON with question IDs as keys.
|
||||
|
||||
Example format:
|
||||
{{"question-1": "answer here", "question-2": "answer here"}}"""
|
||||
|
||||
client = anthropic.Anthropic(api_key=api_key)
|
||||
message = client.messages.create(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
max_tokens=500,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
|
||||
# Parse Haiku's response
|
||||
import re
|
||||
|
||||
response_text = message.content[0].text.strip()
|
||||
json_match = re.search(r"\{[^{}]*\}", response_text, re.DOTALL)
|
||||
|
||||
if json_match:
|
||||
parsed = json.loads(json_match.group())
|
||||
response.answers = parsed
|
||||
|
||||
except Exception:
|
||||
# Fallback: use raw input for first question
|
||||
if request.questions:
|
||||
response.answers[request.questions[0].id] = raw_input
|
||||
|
||||
# Map raw input to first question
|
||||
response.answers[request.questions[0].id] = raw_input
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -166,7 +166,7 @@ class NodeSpec(BaseModel):
|
||||
# Node behavior type
|
||||
node_type: str = Field(
|
||||
default="event_loop",
|
||||
description="Type: 'event_loop' (recommended), 'router', 'human_input'.",
|
||||
description="Type: 'event_loop' (recommended), 'gcu' (browser automation).",
|
||||
)
|
||||
|
||||
# Data flow
|
||||
@@ -204,6 +204,16 @@ class NodeSpec(BaseModel):
|
||||
default=None, description="Specific model to use (defaults to graph default)"
|
||||
)
|
||||
|
||||
# For subagent delegation
|
||||
sub_agents: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Node IDs that can be invoked as subagents from this node",
|
||||
)
|
||||
# For function nodes
|
||||
function: str | None = Field(
|
||||
default=None, description="Function name or path for function nodes"
|
||||
)
|
||||
|
||||
# For router nodes
|
||||
routes: dict[str, str] = Field(
|
||||
default_factory=dict, description="Condition -> target_node_id mapping for routers"
|
||||
@@ -505,6 +515,11 @@ class NodeContext:
|
||||
# Connected accounts prompt (injected from runner)
|
||||
accounts_prompt: str = ""
|
||||
|
||||
# Resume context — Layer 1 (identity) and Layer 2 (narrative) for
|
||||
# rebuilding the full system prompt when restoring from conversation store.
|
||||
identity_prompt: str = ""
|
||||
narrative: str = ""
|
||||
|
||||
# Event-triggered execution (no interactive user attached)
|
||||
event_triggered: bool = False
|
||||
|
||||
@@ -515,6 +530,25 @@ class NodeContext:
|
||||
# Falls back to node_id when not set (legacy / standalone executor).
|
||||
stream_id: str = ""
|
||||
|
||||
# Subagent mode
|
||||
is_subagent_mode: bool = False # True when running as a subagent (prevents nested delegation)
|
||||
report_callback: Any = None # async (message: str, data: dict | None) -> None
|
||||
node_registry: dict[str, "NodeSpec"] = field(default_factory=dict) # For subagent lookup
|
||||
|
||||
# Full tool catalog (unfiltered) — used by _execute_subagent to resolve
|
||||
# subagent tools that aren't in the parent node's filtered available_tools.
|
||||
all_tools: list[Tool] = field(default_factory=list)
|
||||
|
||||
# Shared reference to the executor's node_registry — used by subagent
|
||||
# escalation (_EscalationReceiver) to register temporary receivers that
|
||||
# the inject_input() routing chain can find.
|
||||
shared_node_registry: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Dynamic tool provider — when set, EventLoopNode rebuilds the tool
|
||||
# list from this callback at the start of each iteration. Used by
|
||||
# the queen to switch between building-mode and running-mode tools.
|
||||
dynamic_tools_provider: Any = None # Callable[[], list[Tool]] | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeResult:
|
||||
@@ -551,7 +585,6 @@ class NodeResult:
|
||||
Generate a human-readable summary of this node's execution and output.
|
||||
|
||||
This is like toString() - it describes what the node produced in its current state.
|
||||
Uses Haiku to intelligently summarize complex outputs.
|
||||
"""
|
||||
if not self.success:
|
||||
return f"❌ Failed: {self.error}"
|
||||
@@ -559,59 +592,13 @@ class NodeResult:
|
||||
if not self.output:
|
||||
return "✓ Completed (no output)"
|
||||
|
||||
# Use Haiku to generate intelligent summary
|
||||
import os
|
||||
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
|
||||
if not api_key:
|
||||
# Fallback: simple key-value listing
|
||||
parts = [f"✓ Completed with {len(self.output)} outputs:"]
|
||||
for key, value in list(self.output.items())[:5]: # Limit to 5 keys
|
||||
value_str = str(value)[:100]
|
||||
if len(str(value)) > 100:
|
||||
value_str += "..."
|
||||
parts.append(f" • {key}: {value_str}")
|
||||
return "\n".join(parts)
|
||||
|
||||
# Use Haiku to generate intelligent summary
|
||||
try:
|
||||
import json
|
||||
|
||||
import anthropic
|
||||
|
||||
node_context = ""
|
||||
if node_spec:
|
||||
node_context = f"\nNode: {node_spec.name}\nPurpose: {node_spec.description}"
|
||||
|
||||
output_json = json.dumps(self.output, indent=2, default=str)[:2000]
|
||||
prompt = (
|
||||
f"Generate a 1-2 sentence human-readable summary of "
|
||||
f"what this node produced.{node_context}\n\n"
|
||||
f"Node output:\n{output_json}\n\n"
|
||||
"Provide a concise, clear summary that a human can quickly "
|
||||
"understand. Focus on the key information produced."
|
||||
)
|
||||
|
||||
client = anthropic.Anthropic(api_key=api_key)
|
||||
message = client.messages.create(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
max_tokens=200,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
|
||||
summary = message.content[0].text.strip()
|
||||
return f"✓ {summary}"
|
||||
|
||||
except Exception:
|
||||
# Fallback on error
|
||||
parts = [f"✓ Completed with {len(self.output)} outputs:"]
|
||||
for key, value in list(self.output.items())[:3]:
|
||||
value_str = str(value)[:80]
|
||||
if len(str(value)) > 80:
|
||||
value_str += "..."
|
||||
parts.append(f" • {key}: {value_str}")
|
||||
return "\n".join(parts)
|
||||
parts = [f"✓ Completed with {len(self.output)} outputs:"]
|
||||
for key, value in list(self.output.items())[:5]: # Limit to 5 keys
|
||||
value_str = str(value)[:100]
|
||||
if len(str(value)) > 100:
|
||||
value_str += "..."
|
||||
parts.append(f" • {key}: {value_str}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
class NodeProtocol(ABC):
|
||||
|
||||
@@ -280,7 +280,7 @@ def build_transition_marker(
|
||||
]
|
||||
if file_lines:
|
||||
sections.append(
|
||||
"\nData files (use load_data to access):\n" + "\n".join(file_lines)
|
||||
"\nData files (use read_file to access):\n" + "\n".join(file_lines)
|
||||
)
|
||||
|
||||
# Agent working memory
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Anthropic Claude LLM provider - backward compatible wrapper around LiteLLM."""
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from framework.llm.litellm import LiteLLMProvider
|
||||
from framework.llm.provider import LLMProvider, LLMResponse, Tool, ToolResult, ToolUse
|
||||
from framework.llm.provider import LLMProvider, LLMResponse, Tool
|
||||
|
||||
|
||||
def _get_api_key_from_credential_store() -> str | None:
|
||||
@@ -83,23 +82,6 @@ class AnthropicProvider(LLMProvider):
|
||||
max_retries=max_retries,
|
||||
)
|
||||
|
||||
def complete_with_tools(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
system: str,
|
||||
tools: list[Tool],
|
||||
tool_executor: Callable[[ToolUse], ToolResult],
|
||||
max_iterations: int = 10,
|
||||
) -> LLMResponse:
|
||||
"""Run a tool-use loop until Claude produces a final response (via LiteLLM)."""
|
||||
return self._provider.complete_with_tools(
|
||||
messages=messages,
|
||||
system=system,
|
||||
tools=tools,
|
||||
tool_executor=tool_executor,
|
||||
max_iterations=max_iterations,
|
||||
)
|
||||
|
||||
async def acomplete(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
@@ -120,20 +102,3 @@ class AnthropicProvider(LLMProvider):
|
||||
json_mode=json_mode,
|
||||
max_retries=max_retries,
|
||||
)
|
||||
|
||||
async def acomplete_with_tools(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
system: str,
|
||||
tools: list[Tool],
|
||||
tool_executor: Callable[[ToolUse], ToolResult],
|
||||
max_iterations: int = 10,
|
||||
) -> LLMResponse:
|
||||
"""Async tool-use loop via LiteLLM."""
|
||||
return await self._provider.acomplete_with_tools(
|
||||
messages=messages,
|
||||
system=system,
|
||||
tools=tools,
|
||||
tool_executor=tool_executor,
|
||||
max_iterations=max_iterations,
|
||||
)
|
||||
|
||||
+247
-274
@@ -11,7 +11,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -23,7 +23,7 @@ except ImportError:
|
||||
litellm = None # type: ignore[assignment]
|
||||
RateLimitError = Exception # type: ignore[assignment, misc]
|
||||
|
||||
from framework.llm.provider import LLMProvider, LLMResponse, Tool, ToolResult, ToolUse
|
||||
from framework.llm.provider import LLMProvider, LLMResponse, Tool
|
||||
from framework.llm.stream_events import StreamEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -70,13 +70,59 @@ def _patch_litellm_anthropic_oauth() -> None:
|
||||
AnthropicModelInfo.validate_environment = _patched_validate_environment
|
||||
|
||||
|
||||
def _patch_litellm_metadata_nonetype() -> None:
|
||||
"""Patch litellm entry points to prevent metadata=None TypeError.
|
||||
|
||||
litellm bug: the @client decorator in utils.py has four places that do
|
||||
"model_group" in kwargs.get("metadata", {})
|
||||
but kwargs["metadata"] can be explicitly None (set internally by
|
||||
litellm_params), causing:
|
||||
TypeError: argument of type 'NoneType' is not iterable
|
||||
This masks the real API error with a confusing APIConnectionError.
|
||||
|
||||
Fix: wrap the four litellm entry points (completion, acompletion,
|
||||
responses, aresponses) to pop metadata=None before the @client
|
||||
decorator's error handler can crash on it.
|
||||
"""
|
||||
import functools
|
||||
|
||||
for fn_name in ("completion", "acompletion", "responses", "aresponses"):
|
||||
original = getattr(litellm, fn_name, None)
|
||||
if original is None:
|
||||
continue
|
||||
if asyncio.iscoroutinefunction(original):
|
||||
|
||||
@functools.wraps(original)
|
||||
async def _async_wrapper(*args, _orig=original, **kwargs):
|
||||
if kwargs.get("metadata") is None:
|
||||
kwargs.pop("metadata", None)
|
||||
return await _orig(*args, **kwargs)
|
||||
|
||||
setattr(litellm, fn_name, _async_wrapper)
|
||||
else:
|
||||
|
||||
@functools.wraps(original)
|
||||
def _sync_wrapper(*args, _orig=original, **kwargs):
|
||||
if kwargs.get("metadata") is None:
|
||||
kwargs.pop("metadata", None)
|
||||
return _orig(*args, **kwargs)
|
||||
|
||||
setattr(litellm, fn_name, _sync_wrapper)
|
||||
|
||||
|
||||
if litellm is not None:
|
||||
_patch_litellm_anthropic_oauth()
|
||||
_patch_litellm_metadata_nonetype()
|
||||
|
||||
RATE_LIMIT_MAX_RETRIES = 10
|
||||
RATE_LIMIT_BACKOFF_BASE = 2 # seconds
|
||||
RATE_LIMIT_MAX_DELAY = 120 # seconds - cap to prevent absurd waits
|
||||
|
||||
# Empty-stream retries use a short fixed delay, not the rate-limit backoff.
|
||||
# Conversation-structure issues are deterministic — long waits don't help.
|
||||
EMPTY_STREAM_MAX_RETRIES = 3
|
||||
EMPTY_STREAM_RETRY_DELAY = 1.0 # seconds
|
||||
|
||||
# Directory for dumping failed requests
|
||||
FAILED_REQUESTS_DIR = Path.home() / ".hive" / "failed_requests"
|
||||
|
||||
@@ -124,7 +170,7 @@ def _dump_failed_request(
|
||||
"temperature": kwargs.get("temperature"),
|
||||
}
|
||||
|
||||
with open(filepath, "w") as f:
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(dump_data, f, indent=2, default=str)
|
||||
|
||||
return str(filepath)
|
||||
@@ -191,6 +237,11 @@ def _is_stream_transient_error(exc: BaseException) -> bool:
|
||||
|
||||
Transient errors (recoverable=True): network issues, server errors, timeouts.
|
||||
Permanent errors (recoverable=False): auth, bad request, context window, etc.
|
||||
|
||||
NOTE: "Failed to parse tool call arguments" (malformed LLM output) is NOT
|
||||
transient at the stream level — retrying with the same messages produces the
|
||||
same malformed output. This error is handled at the EventLoopNode level
|
||||
where the conversation can be modified before retrying.
|
||||
"""
|
||||
try:
|
||||
from litellm.exceptions import (
|
||||
@@ -275,12 +326,21 @@ class LiteLLMProvider(LLMProvider):
|
||||
self.api_key = api_key
|
||||
self.api_base = api_base
|
||||
self.extra_kwargs = kwargs
|
||||
# The Codex ChatGPT backend (chatgpt.com/backend-api/codex) rejects
|
||||
# several standard OpenAI params: max_output_tokens, stream_options.
|
||||
self._codex_backend = bool(api_base and "chatgpt.com/backend-api/codex" in api_base)
|
||||
|
||||
if litellm is None:
|
||||
raise ImportError(
|
||||
"LiteLLM is not installed. Please install it with: uv pip install litellm"
|
||||
)
|
||||
|
||||
# Note: The Codex ChatGPT backend is a Responses API endpoint at
|
||||
# chatgpt.com/backend-api/codex/responses. LiteLLM's model registry
|
||||
# correctly marks codex models with mode="responses", so we do NOT
|
||||
# override the mode. The responses_api_bridge in litellm handles
|
||||
# converting Chat Completions requests to Responses API format.
|
||||
|
||||
def _completion_with_rate_limit_retry(
|
||||
self, max_retries: int | None = None, **kwargs: Any
|
||||
) -> Any:
|
||||
@@ -404,6 +464,21 @@ class LiteLLMProvider(LLMProvider):
|
||||
max_retries: int | None = None,
|
||||
) -> LLMResponse:
|
||||
"""Generate a completion using LiteLLM."""
|
||||
# Codex ChatGPT backend requires streaming — delegate to the unified
|
||||
# async streaming path which properly handles tool calls.
|
||||
if self._codex_backend:
|
||||
return asyncio.run(
|
||||
self.acomplete(
|
||||
messages=messages,
|
||||
system=system,
|
||||
tools=tools,
|
||||
max_tokens=max_tokens,
|
||||
response_format=response_format,
|
||||
json_mode=json_mode,
|
||||
max_retries=max_retries,
|
||||
)
|
||||
)
|
||||
|
||||
# Prepare messages with system prompt
|
||||
full_messages = []
|
||||
if system:
|
||||
@@ -466,127 +541,6 @@ class LiteLLMProvider(LLMProvider):
|
||||
raw_response=response,
|
||||
)
|
||||
|
||||
def complete_with_tools(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
system: str,
|
||||
tools: list[Tool],
|
||||
tool_executor: Callable[[ToolUse], ToolResult],
|
||||
max_iterations: int = 10,
|
||||
max_tokens: int = 4096,
|
||||
) -> LLMResponse:
|
||||
"""Run a tool-use loop until the LLM produces a final response."""
|
||||
# Prepare messages with system prompt
|
||||
current_messages = []
|
||||
if system:
|
||||
current_messages.append({"role": "system", "content": system})
|
||||
current_messages.extend(messages)
|
||||
|
||||
total_input_tokens = 0
|
||||
total_output_tokens = 0
|
||||
|
||||
# Convert tools to OpenAI format
|
||||
openai_tools = [self._tool_to_openai_format(t) for t in tools]
|
||||
|
||||
for _ in range(max_iterations):
|
||||
# Build kwargs
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"messages": current_messages,
|
||||
"max_tokens": max_tokens,
|
||||
"tools": openai_tools,
|
||||
**self.extra_kwargs,
|
||||
}
|
||||
|
||||
if self.api_key:
|
||||
kwargs["api_key"] = self.api_key
|
||||
if self.api_base:
|
||||
kwargs["api_base"] = self.api_base
|
||||
|
||||
response = self._completion_with_rate_limit_retry(**kwargs)
|
||||
|
||||
# Track tokens
|
||||
usage = response.usage
|
||||
if usage:
|
||||
total_input_tokens += usage.prompt_tokens
|
||||
total_output_tokens += usage.completion_tokens
|
||||
|
||||
choice = response.choices[0]
|
||||
message = choice.message
|
||||
|
||||
# Check if we're done (no tool calls)
|
||||
if choice.finish_reason == "stop" or not message.tool_calls:
|
||||
return LLMResponse(
|
||||
content=message.content or "",
|
||||
model=response.model or self.model,
|
||||
input_tokens=total_input_tokens,
|
||||
output_tokens=total_output_tokens,
|
||||
stop_reason=choice.finish_reason or "stop",
|
||||
raw_response=response,
|
||||
)
|
||||
|
||||
# Process tool calls.
|
||||
# Add assistant message with tool calls.
|
||||
current_messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": message.content,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
},
|
||||
}
|
||||
for tc in message.tool_calls
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Execute tools and add results.
|
||||
for tool_call in message.tool_calls:
|
||||
try:
|
||||
args = json.loads(tool_call.function.arguments)
|
||||
except json.JSONDecodeError:
|
||||
# Surface error to LLM and skip tool execution
|
||||
current_messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call.id,
|
||||
"content": "Invalid JSON arguments provided to tool.",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
tool_use = ToolUse(
|
||||
id=tool_call.id,
|
||||
name=tool_call.function.name,
|
||||
input=args,
|
||||
)
|
||||
|
||||
result = tool_executor(tool_use)
|
||||
|
||||
# Add tool result message
|
||||
current_messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": result.tool_use_id,
|
||||
"content": result.content,
|
||||
}
|
||||
)
|
||||
|
||||
# Max iterations reached
|
||||
return LLMResponse(
|
||||
content="Max tool iterations reached",
|
||||
model=self.model,
|
||||
input_tokens=total_input_tokens,
|
||||
output_tokens=total_output_tokens,
|
||||
stop_reason="max_iterations",
|
||||
raw_response=None,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Async variants — non-blocking on the event loop
|
||||
# ------------------------------------------------------------------
|
||||
@@ -709,6 +663,19 @@ class LiteLLMProvider(LLMProvider):
|
||||
max_retries: int | None = None,
|
||||
) -> LLMResponse:
|
||||
"""Async version of complete(). Uses litellm.acompletion — non-blocking."""
|
||||
# Codex ChatGPT backend requires streaming — route through stream() which
|
||||
# already handles Codex quirks and has proper tool call accumulation.
|
||||
if self._codex_backend:
|
||||
stream_iter = self.stream(
|
||||
messages=messages,
|
||||
system=system,
|
||||
tools=tools,
|
||||
max_tokens=max_tokens,
|
||||
response_format=response_format,
|
||||
json_mode=json_mode,
|
||||
)
|
||||
return await self._collect_stream_to_response(stream_iter)
|
||||
|
||||
full_messages: list[dict[str, Any]] = []
|
||||
if system:
|
||||
full_messages.append({"role": "system", "content": system})
|
||||
@@ -753,115 +720,6 @@ class LiteLLMProvider(LLMProvider):
|
||||
raw_response=response,
|
||||
)
|
||||
|
||||
async def acomplete_with_tools(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
system: str,
|
||||
tools: list[Tool],
|
||||
tool_executor: Callable[[ToolUse], ToolResult],
|
||||
max_iterations: int = 10,
|
||||
max_tokens: int = 4096,
|
||||
) -> LLMResponse:
|
||||
"""Async version of complete_with_tools(). Uses litellm.acompletion — non-blocking."""
|
||||
current_messages: list[dict[str, Any]] = []
|
||||
if system:
|
||||
current_messages.append({"role": "system", "content": system})
|
||||
current_messages.extend(messages)
|
||||
|
||||
total_input_tokens = 0
|
||||
total_output_tokens = 0
|
||||
openai_tools = [self._tool_to_openai_format(t) for t in tools]
|
||||
|
||||
for _ in range(max_iterations):
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"messages": current_messages,
|
||||
"max_tokens": max_tokens,
|
||||
"tools": openai_tools,
|
||||
**self.extra_kwargs,
|
||||
}
|
||||
|
||||
if self.api_key:
|
||||
kwargs["api_key"] = self.api_key
|
||||
if self.api_base:
|
||||
kwargs["api_base"] = self.api_base
|
||||
|
||||
response = await self._acompletion_with_rate_limit_retry(**kwargs)
|
||||
|
||||
usage = response.usage
|
||||
if usage:
|
||||
total_input_tokens += usage.prompt_tokens
|
||||
total_output_tokens += usage.completion_tokens
|
||||
|
||||
choice = response.choices[0]
|
||||
message = choice.message
|
||||
|
||||
if choice.finish_reason == "stop" or not message.tool_calls:
|
||||
return LLMResponse(
|
||||
content=message.content or "",
|
||||
model=response.model or self.model,
|
||||
input_tokens=total_input_tokens,
|
||||
output_tokens=total_output_tokens,
|
||||
stop_reason=choice.finish_reason or "stop",
|
||||
raw_response=response,
|
||||
)
|
||||
|
||||
current_messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": message.content,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
},
|
||||
}
|
||||
for tc in message.tool_calls
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
for tool_call in message.tool_calls:
|
||||
try:
|
||||
args = json.loads(tool_call.function.arguments)
|
||||
except json.JSONDecodeError:
|
||||
current_messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call.id,
|
||||
"content": "Invalid JSON arguments provided to tool.",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
tool_use = ToolUse(
|
||||
id=tool_call.id,
|
||||
name=tool_call.function.name,
|
||||
input=args,
|
||||
)
|
||||
|
||||
result = tool_executor(tool_use)
|
||||
|
||||
current_messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": result.tool_use_id,
|
||||
"content": result.content,
|
||||
}
|
||||
)
|
||||
|
||||
return LLMResponse(
|
||||
content="Max tool iterations reached",
|
||||
model=self.model,
|
||||
input_tokens=total_input_tokens,
|
||||
output_tokens=total_output_tokens,
|
||||
stop_reason="max_iterations",
|
||||
raw_response=None,
|
||||
)
|
||||
|
||||
def _tool_to_openai_format(self, tool: Tool) -> dict[str, Any]:
|
||||
"""Convert Tool to OpenAI function calling format."""
|
||||
return {
|
||||
@@ -883,6 +741,8 @@ class LiteLLMProvider(LLMProvider):
|
||||
system: str = "",
|
||||
tools: list[Tool] | None = None,
|
||||
max_tokens: int = 4096,
|
||||
response_format: dict[str, Any] | None = None,
|
||||
json_mode: bool = False,
|
||||
) -> AsyncIterator[StreamEvent]:
|
||||
"""Stream a completion via litellm.acompletion(stream=True).
|
||||
|
||||
@@ -907,6 +767,31 @@ class LiteLLMProvider(LLMProvider):
|
||||
full_messages.append({"role": "system", "content": system})
|
||||
full_messages.extend(messages)
|
||||
|
||||
# Codex Responses API requires an `instructions` field (system prompt).
|
||||
# Inject a minimal one when callers don't provide a system message.
|
||||
if self._codex_backend and not any(m["role"] == "system" for m in full_messages):
|
||||
full_messages.insert(0, {"role": "system", "content": "You are a helpful assistant."})
|
||||
|
||||
# Add JSON mode via prompt engineering (works across all providers)
|
||||
if json_mode:
|
||||
json_instruction = "\n\nPlease respond with a valid JSON object."
|
||||
if full_messages and full_messages[0]["role"] == "system":
|
||||
full_messages[0]["content"] += json_instruction
|
||||
else:
|
||||
full_messages.insert(0, {"role": "system", "content": json_instruction.strip()})
|
||||
|
||||
# Remove ghost empty assistant messages (content="" and no tool_calls).
|
||||
# These arise when a model returns an empty stream after a tool result
|
||||
# (an "expected" no-op turn). Keeping them in history confuses some
|
||||
# models (notably Codex/gpt-5.3) and causes cascading empty streams.
|
||||
full_messages = [
|
||||
m
|
||||
for m in full_messages
|
||||
if not (
|
||||
m.get("role") == "assistant" and not m.get("content") and not m.get("tool_calls")
|
||||
)
|
||||
]
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"messages": full_messages,
|
||||
@@ -921,6 +806,12 @@ class LiteLLMProvider(LLMProvider):
|
||||
kwargs["api_base"] = self.api_base
|
||||
if tools:
|
||||
kwargs["tools"] = [self._tool_to_openai_format(t) for t in tools]
|
||||
if response_format:
|
||||
kwargs["response_format"] = response_format
|
||||
# The Codex ChatGPT backend (Responses API) rejects several params.
|
||||
if self._codex_backend:
|
||||
kwargs.pop("max_tokens", None)
|
||||
kwargs.pop("stream_options", None)
|
||||
|
||||
for attempt in range(RATE_LIMIT_MAX_RETRIES + 1):
|
||||
# Post-stream events (ToolCall, TextEnd, Finish) are buffered
|
||||
@@ -929,6 +820,7 @@ class LiteLLMProvider(LLMProvider):
|
||||
tail_events: list[StreamEvent] = []
|
||||
accumulated_text = ""
|
||||
tool_calls_acc: dict[int, dict[str, str]] = {}
|
||||
_last_tool_idx = 0 # tracks most recently opened tool call slot
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
stream_finish_reason: str | None = None
|
||||
@@ -952,9 +844,36 @@ class LiteLLMProvider(LLMProvider):
|
||||
)
|
||||
|
||||
# --- Tool calls (accumulate across chunks) ---
|
||||
# The Codex/Responses API bridge (litellm bug) hardcodes
|
||||
# index=0 on every ChatCompletionToolCallChunk, even for
|
||||
# parallel tool calls. We work around this by using tc.id
|
||||
# (set on output_item.added events) as a "new tool call"
|
||||
# signal and tracking the most recently opened slot for
|
||||
# argument deltas that arrive with id=None.
|
||||
if delta and delta.tool_calls:
|
||||
for tc in delta.tool_calls:
|
||||
idx = tc.index if hasattr(tc, "index") and tc.index is not None else 0
|
||||
|
||||
if tc.id:
|
||||
# New tool call announced (or done event re-sent).
|
||||
# Check if this id already has a slot.
|
||||
existing_idx = next(
|
||||
(k for k, v in tool_calls_acc.items() if v["id"] == tc.id),
|
||||
None,
|
||||
)
|
||||
if existing_idx is not None:
|
||||
idx = existing_idx
|
||||
elif idx in tool_calls_acc and tool_calls_acc[idx]["id"] not in (
|
||||
"",
|
||||
tc.id,
|
||||
):
|
||||
# Slot taken by a different call — assign new index
|
||||
idx = max(tool_calls_acc.keys()) + 1
|
||||
_last_tool_idx = idx
|
||||
else:
|
||||
# Argument delta with no id — route to last opened slot
|
||||
idx = _last_tool_idx
|
||||
|
||||
if idx not in tool_calls_acc:
|
||||
tool_calls_acc[idx] = {"id": "", "name": "", "arguments": ""}
|
||||
if tc.id:
|
||||
@@ -1002,27 +921,7 @@ class LiteLLMProvider(LLMProvider):
|
||||
# (If text deltas were yielded above, has_content is True
|
||||
# and we skip the retry path — nothing was yielded in vain.)
|
||||
has_content = accumulated_text or tool_calls_acc
|
||||
if not has_content and attempt < RATE_LIMIT_MAX_RETRIES:
|
||||
# If the conversation ends with an assistant or tool
|
||||
# message, an empty stream is expected — the LLM has
|
||||
# nothing new to say. Don't burn retries on this;
|
||||
# let the caller (EventLoopNode) decide what to do.
|
||||
# Typical case: client_facing node where the LLM set
|
||||
# all outputs via set_output tool calls, and the tool
|
||||
# results are the last messages.
|
||||
last_role = next(
|
||||
(m["role"] for m in reversed(full_messages) if m.get("role") != "system"),
|
||||
None,
|
||||
)
|
||||
if last_role in ("assistant", "tool"):
|
||||
logger.debug(
|
||||
"[stream] Empty response after %s message — expected, not retrying.",
|
||||
last_role,
|
||||
)
|
||||
for event in tail_events:
|
||||
yield event
|
||||
return
|
||||
|
||||
if not has_content:
|
||||
# finish_reason=length means the model exhausted
|
||||
# max_tokens before producing content. Retrying with
|
||||
# the same max_tokens will never help.
|
||||
@@ -1040,28 +939,49 @@ class LiteLLMProvider(LLMProvider):
|
||||
yield event
|
||||
return
|
||||
|
||||
wait = _compute_retry_delay(attempt)
|
||||
token_count, token_method = _estimate_tokens(
|
||||
self.model,
|
||||
full_messages,
|
||||
# Empty stream — always retry regardless of last message
|
||||
# role. Ghost empty streams after tool results are NOT
|
||||
# expected no-ops; they create infinite loops when the
|
||||
# conversation doesn't change between iterations.
|
||||
# After retries, return the empty result and let the
|
||||
# caller (EventLoopNode) decide how to handle it.
|
||||
last_role = next(
|
||||
(m["role"] for m in reversed(full_messages) if m.get("role") != "system"),
|
||||
None,
|
||||
)
|
||||
dump_path = _dump_failed_request(
|
||||
model=self.model,
|
||||
kwargs=kwargs,
|
||||
error_type="empty_stream",
|
||||
attempt=attempt,
|
||||
)
|
||||
logger.warning(
|
||||
f"[stream-retry] {self.model} returned empty stream — "
|
||||
f"~{token_count} tokens ({token_method}). "
|
||||
f"Request dumped to: {dump_path}. "
|
||||
f"Retrying in {wait}s "
|
||||
f"(attempt {attempt + 1}/{RATE_LIMIT_MAX_RETRIES})"
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
continue
|
||||
if attempt < EMPTY_STREAM_MAX_RETRIES:
|
||||
token_count, token_method = _estimate_tokens(
|
||||
self.model,
|
||||
full_messages,
|
||||
)
|
||||
dump_path = _dump_failed_request(
|
||||
model=self.model,
|
||||
kwargs=kwargs,
|
||||
error_type="empty_stream",
|
||||
attempt=attempt,
|
||||
)
|
||||
logger.warning(
|
||||
f"[stream-retry] {self.model} returned empty stream "
|
||||
f"after {last_role} message — "
|
||||
f"~{token_count} tokens ({token_method}). "
|
||||
f"Request dumped to: {dump_path}. "
|
||||
f"Retrying in {EMPTY_STREAM_RETRY_DELAY}s "
|
||||
f"(attempt {attempt + 1}/{EMPTY_STREAM_MAX_RETRIES})"
|
||||
)
|
||||
await asyncio.sleep(EMPTY_STREAM_RETRY_DELAY)
|
||||
continue
|
||||
|
||||
# Success (or final attempt) — flush remaining events.
|
||||
# All retries exhausted — log and return the empty
|
||||
# result. EventLoopNode's empty response guard will
|
||||
# accept if all outputs are set, or handle the ghost
|
||||
# stream case if outputs are still missing.
|
||||
logger.error(
|
||||
f"[stream] {self.model} returned empty stream after "
|
||||
f"{EMPTY_STREAM_MAX_RETRIES} retries "
|
||||
f"(last_role={last_role}). Returning empty result."
|
||||
)
|
||||
|
||||
# Success (or empty after exhausted retries) — flush events.
|
||||
for event in tail_events:
|
||||
yield event
|
||||
return
|
||||
@@ -1093,3 +1013,56 @@ class LiteLLMProvider(LLMProvider):
|
||||
recoverable = _is_stream_transient_error(e)
|
||||
yield StreamErrorEvent(error=str(e), recoverable=recoverable)
|
||||
return
|
||||
|
||||
async def _collect_stream_to_response(
|
||||
self,
|
||||
stream: AsyncIterator[StreamEvent],
|
||||
) -> LLMResponse:
|
||||
"""Consume a stream() iterator and collect it into a single LLMResponse.
|
||||
|
||||
Used by acomplete() to route through the unified streaming path so that
|
||||
all backends (including Codex) get proper tool call handling.
|
||||
"""
|
||||
from framework.llm.stream_events import (
|
||||
FinishEvent,
|
||||
StreamErrorEvent,
|
||||
TextDeltaEvent,
|
||||
ToolCallEvent,
|
||||
)
|
||||
|
||||
content = ""
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
stop_reason = ""
|
||||
model = self.model
|
||||
|
||||
async for event in stream:
|
||||
if isinstance(event, TextDeltaEvent):
|
||||
content = event.snapshot # snapshot is the accumulated text
|
||||
elif isinstance(event, ToolCallEvent):
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": event.tool_use_id,
|
||||
"name": event.tool_name,
|
||||
"input": event.tool_input,
|
||||
}
|
||||
)
|
||||
elif isinstance(event, FinishEvent):
|
||||
input_tokens = event.input_tokens
|
||||
output_tokens = event.output_tokens
|
||||
stop_reason = event.stop_reason
|
||||
if event.model:
|
||||
model = event.model
|
||||
elif isinstance(event, StreamErrorEvent):
|
||||
if not event.recoverable:
|
||||
raise RuntimeError(f"Stream error: {event.error}")
|
||||
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
model=model,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
stop_reason=stop_reason,
|
||||
raw_response={"tool_calls": tool_calls} if tool_calls else None,
|
||||
)
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from framework.llm.provider import LLMProvider, LLMResponse, Tool, ToolResult, ToolUse
|
||||
from framework.llm.provider import LLMProvider, LLMResponse, Tool
|
||||
from framework.llm.stream_events import (
|
||||
FinishEvent,
|
||||
StreamEvent,
|
||||
@@ -146,43 +146,6 @@ class MockLLMProvider(LLMProvider):
|
||||
stop_reason="mock_complete",
|
||||
)
|
||||
|
||||
def complete_with_tools(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
system: str,
|
||||
tools: list[Tool],
|
||||
tool_executor: Callable[[ToolUse], ToolResult],
|
||||
max_iterations: int = 10,
|
||||
) -> LLMResponse:
|
||||
"""
|
||||
Generate a mock completion without tool use.
|
||||
|
||||
In mock mode, we skip tool execution and return a final response immediately.
|
||||
|
||||
Args:
|
||||
messages: Initial conversation (ignored in mock mode)
|
||||
system: System prompt (used to extract expected output keys)
|
||||
tools: Available tools (ignored in mock mode)
|
||||
tool_executor: Tool executor function (ignored in mock mode)
|
||||
max_iterations: Max iterations (ignored in mock mode)
|
||||
|
||||
Returns:
|
||||
LLMResponse with mock content
|
||||
"""
|
||||
# In mock mode, we don't execute tools - just return a final response
|
||||
# Try to generate JSON if the system prompt suggests structured output
|
||||
json_mode = "json" in system.lower() or "output_keys" in system.lower()
|
||||
|
||||
content = self._generate_mock_response(system=system, json_mode=json_mode)
|
||||
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
model=self.model,
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
stop_reason="mock_complete",
|
||||
)
|
||||
|
||||
async def acomplete(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
@@ -204,23 +167,6 @@ class MockLLMProvider(LLMProvider):
|
||||
max_retries=max_retries,
|
||||
)
|
||||
|
||||
async def acomplete_with_tools(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
system: str,
|
||||
tools: list[Tool],
|
||||
tool_executor: Callable[[ToolUse], ToolResult],
|
||||
max_iterations: int = 10,
|
||||
) -> LLMResponse:
|
||||
"""Async mock tool-use completion (no I/O, returns immediately)."""
|
||||
return self.complete_with_tools(
|
||||
messages=messages,
|
||||
system=system,
|
||||
tools=tools,
|
||||
tool_executor=tool_executor,
|
||||
max_iterations=max_iterations,
|
||||
)
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
@@ -90,30 +90,6 @@ class LLMProvider(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def complete_with_tools(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
system: str,
|
||||
tools: list[Tool],
|
||||
tool_executor: Callable[["ToolUse"], "ToolResult"],
|
||||
max_iterations: int = 10,
|
||||
) -> LLMResponse:
|
||||
"""
|
||||
Run a tool-use loop until the LLM produces a final response.
|
||||
|
||||
Args:
|
||||
messages: Initial conversation
|
||||
system: System prompt
|
||||
tools: Available tools
|
||||
tool_executor: Function to execute tools: (ToolUse) -> ToolResult
|
||||
max_iterations: Max tool calls before stopping
|
||||
|
||||
Returns:
|
||||
Final LLMResponse after tool use completes
|
||||
"""
|
||||
pass
|
||||
|
||||
async def acomplete(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
@@ -144,32 +120,6 @@ class LLMProvider(ABC):
|
||||
),
|
||||
)
|
||||
|
||||
async def acomplete_with_tools(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
system: str,
|
||||
tools: list["Tool"],
|
||||
tool_executor: Callable[["ToolUse"], "ToolResult"],
|
||||
max_iterations: int = 10,
|
||||
) -> "LLMResponse":
|
||||
"""Async version of complete_with_tools(). Non-blocking on the event loop.
|
||||
|
||||
Default implementation offloads the sync complete_with_tools() to a thread pool.
|
||||
Subclasses SHOULD override for native async I/O.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
partial(
|
||||
self.complete_with_tools,
|
||||
messages=messages,
|
||||
system=system,
|
||||
tools=tools,
|
||||
tool_executor=tool_executor,
|
||||
max_iterations=max_iterations,
|
||||
),
|
||||
)
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
|
||||
@@ -10,6 +10,7 @@ Usage:
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -161,7 +162,7 @@ def _load_session(session_id: str) -> BuildSession:
|
||||
if not session_file.exists():
|
||||
raise ValueError(f"Session '{session_id}' not found")
|
||||
|
||||
with open(session_file) as f:
|
||||
with open(session_file, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
return BuildSession.from_dict(data)
|
||||
@@ -173,7 +174,7 @@ def _load_active_session() -> BuildSession | None:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(ACTIVE_SESSION_FILE) as f:
|
||||
with open(ACTIVE_SESSION_FILE, encoding="utf-8") as f:
|
||||
session_id = f.read().strip()
|
||||
|
||||
if session_id:
|
||||
@@ -227,7 +228,7 @@ def list_sessions() -> str:
|
||||
if SESSIONS_DIR.exists():
|
||||
for session_file in SESSIONS_DIR.glob("*.json"):
|
||||
try:
|
||||
with open(session_file) as f:
|
||||
with open(session_file, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
sessions.append(
|
||||
{
|
||||
@@ -247,7 +248,7 @@ def list_sessions() -> str:
|
||||
active_id = None
|
||||
if ACTIVE_SESSION_FILE.exists():
|
||||
try:
|
||||
with open(ACTIVE_SESSION_FILE) as f:
|
||||
with open(ACTIVE_SESSION_FILE, encoding="utf-8") as f:
|
||||
active_id = f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -309,7 +310,7 @@ def delete_session(session_id: Annotated[str, "ID of the session to delete"]) ->
|
||||
_session = None
|
||||
|
||||
if ACTIVE_SESSION_FILE.exists():
|
||||
with open(ACTIVE_SESSION_FILE) as f:
|
||||
with open(ACTIVE_SESSION_FILE, encoding="utf-8") as f:
|
||||
active_id = f.read().strip()
|
||||
if active_id == session_id:
|
||||
ACTIVE_SESSION_FILE.unlink()
|
||||
@@ -562,16 +563,29 @@ def _validate_agent_path(agent_path: str) -> tuple[Path | None, str | None]:
|
||||
path = Path(agent_path)
|
||||
|
||||
# Resolve relative paths against project root (not MCP server's cwd)
|
||||
if not path.is_absolute() and not path.exists():
|
||||
resolved = _PROJECT_ROOT / path
|
||||
if resolved.exists():
|
||||
path = resolved
|
||||
if not path.is_absolute():
|
||||
path = _PROJECT_ROOT / path
|
||||
|
||||
# Restrict to allowed directories BEFORE checking existence to prevent
|
||||
# leaking whether arbitrary filesystem paths exist on disk.
|
||||
from framework.server.app import validate_agent_path
|
||||
|
||||
try:
|
||||
path = validate_agent_path(path)
|
||||
except ValueError:
|
||||
return None, json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": "agent_path must be inside an allowed directory "
|
||||
"(exports/, examples/, or ~/.hive/agents/)",
|
||||
}
|
||||
)
|
||||
|
||||
if not path.exists():
|
||||
return None, json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Agent path not found: {path}",
|
||||
"error": f"Agent path not found: {agent_path}",
|
||||
"hint": "Run export_graph to create an agent in exports/ first",
|
||||
}
|
||||
)
|
||||
@@ -586,7 +600,7 @@ def add_node(
|
||||
description: Annotated[str, "What this node does"],
|
||||
node_type: Annotated[
|
||||
str,
|
||||
"Type: event_loop (recommended), router.",
|
||||
"Type: event_loop (recommended), gcu (browser automation), router.",
|
||||
],
|
||||
input_keys: Annotated[str, "JSON array of keys this node reads from shared memory"],
|
||||
output_keys: Annotated[str, "JSON array of keys this node writes to shared memory"],
|
||||
@@ -675,8 +689,23 @@ def add_node(
|
||||
if node_type == "event_loop" and not system_prompt:
|
||||
warnings.append(f"Event loop node '{node_id}' should have a system_prompt")
|
||||
|
||||
# GCU node validation
|
||||
if node_type == "gcu":
|
||||
if tools_list:
|
||||
warnings.append(
|
||||
f"GCU node '{node_id}' auto-includes all browser tools from the "
|
||||
f"gcu-tools MCP server. Manually listed tools {tools_list} will be "
|
||||
f"merged with the auto-included set."
|
||||
)
|
||||
if not system_prompt:
|
||||
warnings.append(
|
||||
f"GCU node '{node_id}' has a default browser best-practices prompt. "
|
||||
f"Consider adding a task-specific system_prompt — it will be appended "
|
||||
f"after the browser instructions."
|
||||
)
|
||||
|
||||
# Warn about client_facing on nodes with tools (likely autonomous work)
|
||||
if node_type == "event_loop" and client_facing and tools_list:
|
||||
if node_type in ("event_loop", "gcu") and client_facing and tools_list:
|
||||
warnings.append(
|
||||
f"Node '{node_id}' is client_facing=True but has tools {tools_list}. "
|
||||
"Nodes with tools typically do autonomous work and should be "
|
||||
@@ -1774,6 +1803,14 @@ def export_graph() -> str:
|
||||
enriched_criteria.append(crit_dict)
|
||||
export_data["goal"]["success_criteria"] = enriched_criteria
|
||||
|
||||
# Auto-add GCU MCP server if any node uses the gcu type
|
||||
has_gcu_nodes = any(n.node_type == "gcu" for n in session.nodes)
|
||||
if has_gcu_nodes:
|
||||
from framework.graph.gcu import GCU_MCP_SERVER_CONFIG, GCU_SERVER_NAME
|
||||
|
||||
if not any(s.get("name") == GCU_SERVER_NAME for s in session.mcp_servers):
|
||||
session.mcp_servers.append(dict(GCU_MCP_SERVER_CONFIG))
|
||||
|
||||
# === WRITE FILES TO DISK ===
|
||||
# Create exports directory
|
||||
exports_dir = Path("exports") / session.name
|
||||
@@ -1864,7 +1901,7 @@ def import_from_export(
|
||||
return json.dumps({"success": False, "error": f"File not found: {agent_json_path}"})
|
||||
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as e:
|
||||
return json.dumps({"success": False, "error": f"Invalid JSON: {e}"})
|
||||
|
||||
@@ -1946,7 +1983,7 @@ def get_session_status() -> str:
|
||||
@mcp.tool()
|
||||
def configure_loop(
|
||||
max_iterations: Annotated[int, "Maximum loop iterations per node execution (default 50)"] = 50,
|
||||
max_tool_calls_per_turn: Annotated[int, "Maximum tool calls per LLM turn (default 10)"] = 10,
|
||||
max_tool_calls_per_turn: Annotated[int, "Maximum tool calls per LLM turn (default 30)"] = 30,
|
||||
stall_detection_threshold: Annotated[
|
||||
int, "Consecutive identical responses before stall detection triggers (default 3)"
|
||||
] = 3,
|
||||
@@ -2772,6 +2809,21 @@ def run_tests(
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
# Guard: pytest must be available as a subprocess command.
|
||||
# Install with: pip install 'framework[testing]'
|
||||
if shutil.which("pytest") is None:
|
||||
return json.dumps(
|
||||
{
|
||||
"goal_id": goal_id,
|
||||
"error": (
|
||||
"pytest is not installed or not on PATH. "
|
||||
"Hive's test runner requires pytest at runtime. "
|
||||
"Install it with: pip install 'framework[testing]' "
|
||||
"or: uv pip install 'framework[testing]'"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
path, err = _validate_agent_path(agent_path)
|
||||
if err:
|
||||
return err
|
||||
@@ -2842,10 +2894,12 @@ def run_tests(
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
encoding="utf-8",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600, # 10 minute timeout
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return json.dumps(
|
||||
@@ -2965,6 +3019,22 @@ def debug_test(
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
# Guard: pytest must be available as a subprocess command.
|
||||
# Install with: pip install 'framework[testing]'
|
||||
if shutil.which("pytest") is None:
|
||||
return json.dumps(
|
||||
{
|
||||
"goal_id": goal_id,
|
||||
"test_name": test_name,
|
||||
"error": (
|
||||
"pytest is not installed or not on PATH. "
|
||||
"Hive's test runner requires pytest at runtime. "
|
||||
"Install it with: pip install 'framework[testing]' "
|
||||
"or: uv pip install 'framework[testing]'"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# Derive agent_path from session if not provided
|
||||
if not agent_path and _session:
|
||||
agent_path = f"exports/{_session.name}"
|
||||
@@ -2986,7 +3056,7 @@ def debug_test(
|
||||
# Find which file contains the test
|
||||
test_file = None
|
||||
for py_file in tests_dir.glob("test_*.py"):
|
||||
content = py_file.read_text()
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
if f"def {test_name}" in content or f"async def {test_name}" in content:
|
||||
test_file = py_file
|
||||
break
|
||||
@@ -3017,10 +3087,12 @@ def debug_test(
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
encoding="utf-8",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120, # 2 minute timeout for single test
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return json.dumps(
|
||||
@@ -3138,7 +3210,7 @@ def list_tests(
|
||||
tests = []
|
||||
for test_file in sorted(tests_dir.glob("test_*.py")):
|
||||
try:
|
||||
content = test_file.read_text()
|
||||
content = test_file.read_text(encoding="utf-8")
|
||||
tree = ast.parse(content)
|
||||
|
||||
# Find all async function definitions that start with "test_"
|
||||
@@ -3644,82 +3716,6 @@ def list_agent_sessions(
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_agent_session_state(
|
||||
agent_work_dir: Annotated[str, "Path to the agent's working directory"],
|
||||
session_id: Annotated[str, "The session ID (e.g., 'session_20260208_143022_abc12345')"],
|
||||
) -> str:
|
||||
"""
|
||||
Load full session state for a specific session.
|
||||
|
||||
Returns complete session data including status, progress, result,
|
||||
metrics, and checkpoint info. Memory values are excluded to prevent
|
||||
context bloat -- use get_agent_session_memory to retrieve memory contents.
|
||||
"""
|
||||
state_path = Path(agent_work_dir) / "sessions" / session_id / "state.json"
|
||||
data = _read_session_json(state_path)
|
||||
if data is None:
|
||||
return json.dumps({"error": f"Session not found: {session_id}"})
|
||||
|
||||
memory = data.get("memory", {})
|
||||
data["memory_keys"] = list(memory.keys()) if isinstance(memory, dict) else []
|
||||
data["memory_size"] = len(memory) if isinstance(memory, dict) else 0
|
||||
data.pop("memory", None)
|
||||
|
||||
return json.dumps(data, indent=2, default=str)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_agent_session_memory(
|
||||
agent_work_dir: Annotated[str, "Path to the agent's working directory"],
|
||||
session_id: Annotated[str, "The session ID"],
|
||||
key: Annotated[str, "Specific memory key to retrieve. Empty for all."] = "",
|
||||
) -> str:
|
||||
"""
|
||||
Get memory contents from a session.
|
||||
|
||||
Memory stores intermediate results passed between nodes. Use this
|
||||
to inspect what data was produced during execution.
|
||||
|
||||
If key is provided, returns only that memory key's value.
|
||||
If key is empty, returns all memory keys and their values.
|
||||
"""
|
||||
state_path = Path(agent_work_dir) / "sessions" / session_id / "state.json"
|
||||
data = _read_session_json(state_path)
|
||||
if data is None:
|
||||
return json.dumps({"error": f"Session not found: {session_id}"})
|
||||
|
||||
memory = data.get("memory", {})
|
||||
if not isinstance(memory, dict):
|
||||
memory = {}
|
||||
|
||||
if key:
|
||||
if key not in memory:
|
||||
return json.dumps(
|
||||
{
|
||||
"error": f"Memory key not found: '{key}'",
|
||||
"available_keys": list(memory.keys()),
|
||||
}
|
||||
)
|
||||
value = memory[key]
|
||||
return json.dumps(
|
||||
{
|
||||
"session_id": session_id,
|
||||
"key": key,
|
||||
"value": value,
|
||||
"value_type": type(value).__name__,
|
||||
},
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
{"session_id": session_id, "memory": memory, "total_keys": len(memory)},
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_agent_checkpoints(
|
||||
agent_work_dir: Annotated[str, "Path to the agent's working directory"],
|
||||
|
||||
+175
-72
@@ -394,8 +394,50 @@ def register_commands(subparsers: argparse._SubParsersAction) -> None:
|
||||
default=None,
|
||||
help="LLM model for preloaded agents",
|
||||
)
|
||||
serve_parser.add_argument(
|
||||
"--open",
|
||||
action="store_true",
|
||||
help="Open dashboard in browser after server starts",
|
||||
)
|
||||
serve_parser.set_defaults(func=cmd_serve)
|
||||
|
||||
# open command (serve + auto-open browser)
|
||||
open_parser = subparsers.add_parser(
|
||||
"open",
|
||||
help="Start HTTP server and open dashboard in browser",
|
||||
description="Shortcut for 'hive serve --open'. "
|
||||
"Starts the HTTP server and opens the dashboard.",
|
||||
)
|
||||
open_parser.add_argument(
|
||||
"--host",
|
||||
type=str,
|
||||
default="127.0.0.1",
|
||||
help="Host to bind (default: 127.0.0.1)",
|
||||
)
|
||||
open_parser.add_argument(
|
||||
"--port",
|
||||
"-p",
|
||||
type=int,
|
||||
default=8787,
|
||||
help="Port to listen on (default: 8787)",
|
||||
)
|
||||
open_parser.add_argument(
|
||||
"--agent",
|
||||
"-a",
|
||||
type=str,
|
||||
action="append",
|
||||
default=[],
|
||||
help="Agent path to preload (repeatable)",
|
||||
)
|
||||
open_parser.add_argument(
|
||||
"--model",
|
||||
"-m",
|
||||
type=str,
|
||||
default=None,
|
||||
help="LLM model for preloaded agents",
|
||||
)
|
||||
open_parser.set_defaults(func=cmd_open)
|
||||
|
||||
|
||||
def _load_resume_state(
|
||||
agent_path: str, session_id: str, checkpoint_id: str | None = None
|
||||
@@ -423,7 +465,7 @@ def _load_resume_state(
|
||||
if not cp_path.exists():
|
||||
return None
|
||||
try:
|
||||
cp_data = json.loads(cp_path.read_text())
|
||||
cp_data = json.loads(cp_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
return {
|
||||
@@ -439,7 +481,7 @@ def _load_resume_state(
|
||||
if not state_path.exists():
|
||||
return None
|
||||
try:
|
||||
state_data = json.loads(state_path.read_text())
|
||||
state_data = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
progress = state_data.get("progress", {})
|
||||
@@ -512,7 +554,7 @@ def cmd_run(args: argparse.Namespace) -> int:
|
||||
return 1
|
||||
elif args.input_file:
|
||||
try:
|
||||
with open(args.input_file) as f:
|
||||
with open(args.input_file, encoding="utf-8") as f:
|
||||
context = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
print(f"Error reading input file: {e}", file=sys.stderr)
|
||||
@@ -654,7 +696,7 @@ def cmd_run(args: argparse.Namespace) -> int:
|
||||
|
||||
# Output results
|
||||
if args.output:
|
||||
with open(args.output, "w") as f:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(output, f, indent=2, default=str)
|
||||
if not args.quiet:
|
||||
print(f"Results written to {args.output}")
|
||||
@@ -834,7 +876,7 @@ def cmd_list(args: argparse.Namespace) -> int:
|
||||
|
||||
agents = []
|
||||
for path in directory.iterdir():
|
||||
if path.is_dir() and (path / "agent.json").exists():
|
||||
if _is_valid_agent_dir(path):
|
||||
try:
|
||||
runner = AgentRunner.load(path)
|
||||
info = runner.info()
|
||||
@@ -901,14 +943,14 @@ def cmd_dispatch(args: argparse.Namespace) -> int:
|
||||
# Use specific agents
|
||||
for agent_name in args.agents:
|
||||
agent_path = agents_dir / agent_name
|
||||
if not (agent_path / "agent.json").exists():
|
||||
if not _is_valid_agent_dir(agent_path):
|
||||
print(f"Agent not found: {agent_path}", file=sys.stderr)
|
||||
return 1
|
||||
agent_paths.append((agent_name, agent_path))
|
||||
else:
|
||||
# Discover all agents
|
||||
for path in agents_dir.iterdir():
|
||||
if path.is_dir() and (path / "agent.json").exists():
|
||||
if _is_valid_agent_dir(path):
|
||||
agent_paths.append((path.name, path))
|
||||
|
||||
if not agent_paths:
|
||||
@@ -1048,62 +1090,19 @@ def _interactive_approval(request):
|
||||
def _format_natural_language_to_json(
|
||||
user_input: str, input_keys: list[str], agent_description: str, session_context: dict = None
|
||||
) -> dict:
|
||||
"""Use Haiku to convert natural language input to JSON based on agent's input schema."""
|
||||
import os
|
||||
"""Convert natural language input to JSON based on agent's input schema.
|
||||
|
||||
import anthropic
|
||||
Maps user input to the primary input field. For follow-up inputs,
|
||||
appends to the existing value.
|
||||
"""
|
||||
main_field = input_keys[0] if input_keys else "objective"
|
||||
|
||||
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
|
||||
|
||||
# Build prompt for Haiku
|
||||
session_info = ""
|
||||
if session_context:
|
||||
# Extract the main field (usually 'objective') that we'll append to
|
||||
main_field = input_keys[0] if input_keys else "objective"
|
||||
existing_value = session_context.get(main_field, "")
|
||||
if existing_value:
|
||||
return {main_field: f"{existing_value}\n\n{user_input}"}
|
||||
|
||||
session_info = (
|
||||
f'\n\nExisting {main_field}: "{existing_value}"\n\n'
|
||||
f"The user is providing ADDITIONAL information. Append this new "
|
||||
f"information to the existing {main_field} to create an enriched, "
|
||||
"more detailed version."
|
||||
)
|
||||
|
||||
prompt = f"""You are formatting user input for an agent that requires specific input fields.
|
||||
|
||||
Agent: {agent_description}
|
||||
|
||||
Required input fields: {", ".join(input_keys)}{session_info}
|
||||
|
||||
User input: {user_input}
|
||||
|
||||
{"If this is a follow-up, APPEND new info to the existing field value." if session_context else ""}
|
||||
|
||||
Output ONLY valid JSON, no explanation:"""
|
||||
|
||||
try:
|
||||
message = client.messages.create(
|
||||
model="claude-haiku-4-5-20251001", # Fast and cheap
|
||||
max_tokens=500,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
|
||||
json_str = message.content[0].text.strip()
|
||||
# Remove markdown code blocks if present
|
||||
if json_str.startswith("```"):
|
||||
json_str = json_str.split("```")[1]
|
||||
if json_str.startswith("json"):
|
||||
json_str = json_str[4:]
|
||||
json_str = json_str.strip()
|
||||
|
||||
return json.loads(json_str)
|
||||
except Exception:
|
||||
# Fallback: try to infer the main field
|
||||
if len(input_keys) == 1:
|
||||
return {input_keys[0]: user_input}
|
||||
else:
|
||||
# Put it in the first field as fallback
|
||||
return {input_keys[0]: user_input}
|
||||
return {main_field: user_input}
|
||||
|
||||
|
||||
def cmd_shell(args: argparse.Namespace) -> int:
|
||||
@@ -1512,7 +1511,7 @@ def _extract_python_agent_metadata(agent_path: Path) -> tuple[str, str]:
|
||||
return fallback_name, fallback_desc
|
||||
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
tree = ast.parse(f.read())
|
||||
|
||||
# Find AgentMetadata class definition
|
||||
@@ -1659,16 +1658,7 @@ def _select_agent(agents_dir: Path) -> str | None:
|
||||
# Display agents for current page (with global numbering)
|
||||
for i, agent_path in enumerate(page_agents, start_idx + 1):
|
||||
try:
|
||||
agent_json = agent_path / "agent.json"
|
||||
if agent_json.exists():
|
||||
with open(agent_json) as f:
|
||||
data = json.load(f)
|
||||
agent_meta = data.get("agent", {})
|
||||
name = agent_meta.get("name", agent_path.name)
|
||||
desc = agent_meta.get("description", "")
|
||||
else:
|
||||
# Python-based agent - extract from config.py
|
||||
name, desc = _extract_python_agent_metadata(agent_path)
|
||||
name, desc = _extract_python_agent_metadata(agent_path)
|
||||
desc = desc[:50] + "..." if len(desc) > 50 else desc
|
||||
print(f" {i}. {name}")
|
||||
print(f" {desc}")
|
||||
@@ -1929,12 +1919,105 @@ def cmd_setup_credentials(args: argparse.Namespace) -> int:
|
||||
return 0 if result.success else 1
|
||||
|
||||
|
||||
def _open_browser(url: str) -> None:
|
||||
"""Open URL in the default browser (best-effort, non-blocking)."""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
if sys.platform == "darwin":
|
||||
subprocess.Popen(
|
||||
["open", url],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
encoding="utf-8",
|
||||
)
|
||||
elif sys.platform == "win32":
|
||||
subprocess.Popen(
|
||||
["cmd", "/c", "start", "", url],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
elif sys.platform == "linux":
|
||||
subprocess.Popen(
|
||||
["xdg-open", url],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception:
|
||||
pass # Best-effort — don't crash if browser can't open
|
||||
|
||||
|
||||
def _build_frontend() -> bool:
|
||||
"""Build the frontend if source is newer than dist. Returns True if dist exists."""
|
||||
import subprocess
|
||||
|
||||
# Find the frontend directory relative to this file or cwd
|
||||
candidates = [
|
||||
Path("core/frontend"),
|
||||
Path(__file__).resolve().parent.parent.parent / "frontend",
|
||||
]
|
||||
frontend_dir: Path | None = None
|
||||
for c in candidates:
|
||||
if (c / "package.json").is_file():
|
||||
frontend_dir = c.resolve()
|
||||
break
|
||||
|
||||
if frontend_dir is None:
|
||||
return False
|
||||
|
||||
dist_dir = frontend_dir / "dist"
|
||||
src_dir = frontend_dir / "src"
|
||||
|
||||
# Skip build if dist is up-to-date (newest src file older than dist index.html)
|
||||
index_html = dist_dir / "index.html"
|
||||
if index_html.exists() and src_dir.is_dir():
|
||||
dist_mtime = index_html.stat().st_mtime
|
||||
needs_build = False
|
||||
for f in src_dir.rglob("*"):
|
||||
if f.is_file() and f.stat().st_mtime > dist_mtime:
|
||||
needs_build = True
|
||||
break
|
||||
if not needs_build:
|
||||
return True
|
||||
|
||||
# Need to build
|
||||
print("Building frontend...")
|
||||
try:
|
||||
# Ensure deps are installed
|
||||
subprocess.run(
|
||||
["npm", "install", "--no-fund", "--no-audit"],
|
||||
encoding="utf-8",
|
||||
cwd=frontend_dir,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["npm", "run", "build"],
|
||||
encoding="utf-8",
|
||||
cwd=frontend_dir,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
print("Frontend built.")
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
print("Node.js not found — skipping frontend build.")
|
||||
return dist_dir.is_dir()
|
||||
except subprocess.CalledProcessError as exc:
|
||||
stderr = exc.stderr.decode(errors="replace") if exc.stderr else ""
|
||||
print(f"Frontend build failed: {stderr[:500]}")
|
||||
return dist_dir.is_dir()
|
||||
|
||||
|
||||
def cmd_serve(args: argparse.Namespace) -> int:
|
||||
"""Start the HTTP API server."""
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
_build_frontend()
|
||||
|
||||
from framework.server.app import create_app
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -1959,18 +2042,32 @@ def cmd_serve(args: argparse.Namespace) -> int:
|
||||
print(f"Error loading {agent_path}: {e}")
|
||||
|
||||
# Start server using AppRunner/TCPSite (same pattern as webhook_server.py)
|
||||
runner = web.AppRunner(app)
|
||||
runner = web.AppRunner(app, access_log=None)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, args.host, args.port)
|
||||
await site.start()
|
||||
|
||||
# Check if frontend is being served
|
||||
dist_candidates = [
|
||||
Path("frontend/dist"),
|
||||
Path("core/frontend/dist"),
|
||||
]
|
||||
has_frontend = any((c / "index.html").exists() for c in dist_candidates if c.is_dir())
|
||||
dashboard_url = f"http://{args.host}:{args.port}"
|
||||
|
||||
print()
|
||||
print(f"Hive API server running on http://{args.host}:{args.port}")
|
||||
print(f"Health: http://{args.host}:{args.port}/api/health")
|
||||
print(f"Hive API server running on {dashboard_url}")
|
||||
if has_frontend:
|
||||
print(f"Dashboard: {dashboard_url}")
|
||||
print(f"Health: {dashboard_url}/api/health")
|
||||
print(f"Agents loaded: {sum(1 for s in manager.list_sessions() if s.worker_runtime)}")
|
||||
print()
|
||||
print("Press Ctrl+C to stop")
|
||||
|
||||
# Auto-open browser if --open flag is set and frontend exists
|
||||
if getattr(args, "open", False) and has_frontend:
|
||||
_open_browser(dashboard_url)
|
||||
|
||||
# Run forever until interrupted
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
@@ -1986,3 +2083,9 @@ def cmd_serve(args: argparse.Namespace) -> int:
|
||||
print("\nServer stopped.")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_open(args: argparse.Namespace) -> int:
|
||||
"""Start the HTTP API server and open the dashboard in the browser."""
|
||||
args.open = True
|
||||
return cmd_serve(args)
|
||||
|
||||
@@ -7,6 +7,8 @@ Supports both STDIO and HTTP transports using the official MCP Python SDK.
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
@@ -73,6 +75,8 @@ class MCPClient:
|
||||
# Background event loop for persistent STDIO connection
|
||||
self._loop = None
|
||||
self._loop_thread = None
|
||||
# Serialize STDIO tool calls (avoids races, helps on Windows)
|
||||
self._stdio_call_lock = threading.Lock()
|
||||
|
||||
def _run_async(self, coro):
|
||||
"""
|
||||
@@ -156,11 +160,19 @@ class MCPClient:
|
||||
# Create server parameters
|
||||
# Always inherit parent environment and merge with any custom env vars
|
||||
merged_env = {**os.environ, **(self.config.env or {})}
|
||||
# On Windows, passing cwd can cause WinError 267 ("invalid directory name").
|
||||
# tool_registry passes cwd=None and uses absolute script paths when applicable.
|
||||
cwd = self.config.cwd
|
||||
if os.name == "nt" and cwd is not None:
|
||||
# Avoid passing cwd on Windows; tool_registry should have set cwd=None
|
||||
# and absolute script paths for tools-dir servers. If cwd is still set,
|
||||
# pass None to prevent WinError 267 (caller should use absolute paths).
|
||||
cwd = None
|
||||
server_params = StdioServerParameters(
|
||||
command=self.config.command,
|
||||
args=self.config.args,
|
||||
env=merged_env,
|
||||
cwd=self.config.cwd,
|
||||
cwd=cwd,
|
||||
)
|
||||
|
||||
# Store for later use
|
||||
@@ -184,10 +196,12 @@ class MCPClient:
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
# Create persistent stdio client context.
|
||||
# Redirect server stderr to devnull to prevent raw
|
||||
# output from leaking behind the TUI.
|
||||
devnull = open(os.devnull, "w") # noqa: SIM115
|
||||
self._stdio_context = stdio_client(server_params, errlog=devnull)
|
||||
# On Windows, use stderr so subprocess startup errors are visible.
|
||||
if os.name == "nt":
|
||||
errlog = sys.stderr
|
||||
else:
|
||||
errlog = open(os.devnull, "w") # noqa: SIM115
|
||||
self._stdio_context = stdio_client(server_params, errlog=errlog)
|
||||
(
|
||||
self._read_stream,
|
||||
self._write_stream,
|
||||
@@ -353,7 +367,8 @@ class MCPClient:
|
||||
raise ValueError(f"Unknown tool: {tool_name}")
|
||||
|
||||
if self.config.transport == "stdio":
|
||||
return self._run_async(self._call_tool_stdio_async(tool_name, arguments))
|
||||
with self._stdio_call_lock:
|
||||
return self._run_async(self._call_tool_stdio_async(tool_name, arguments))
|
||||
else:
|
||||
return self._call_tool_http(tool_name, arguments)
|
||||
|
||||
@@ -448,11 +463,15 @@ class MCPClient:
|
||||
if self._stdio_context:
|
||||
await self._stdio_context.__aexit__(None, None, None)
|
||||
except asyncio.CancelledError:
|
||||
logger.warning(
|
||||
logger.debug(
|
||||
"STDIO context cleanup was cancelled; proceeding with best-effort shutdown"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing STDIO context: {e}")
|
||||
msg = str(e).lower()
|
||||
if "cancel scope" in msg or "different task" in msg:
|
||||
logger.debug("STDIO context teardown (known anyio quirk): %s", e)
|
||||
else:
|
||||
logger.warning(f"Error closing STDIO context: {e}")
|
||||
finally:
|
||||
self._stdio_context = None
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Pre-load validation for agent graphs.
|
||||
|
||||
Runs structural and credential checks before MCP servers are spawned.
|
||||
Fails fast with actionable error messages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from framework.graph.edge import GraphSpec
|
||||
from framework.graph.node import NodeSpec
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PreloadValidationError(Exception):
|
||||
"""Raised when pre-load validation fails."""
|
||||
|
||||
def __init__(self, errors: list[str]):
|
||||
self.errors = errors
|
||||
msg = "Pre-load validation failed:\n" + "\n".join(f" - {e}" for e in errors)
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreloadResult:
|
||||
"""Result of pre-load validation."""
|
||||
|
||||
valid: bool
|
||||
errors: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def validate_graph_structure(graph: GraphSpec) -> list[str]:
|
||||
"""Run graph structural validation (includes GCU subagent-only checks).
|
||||
|
||||
Delegates to GraphSpec.validate() which checks entry/terminal nodes,
|
||||
edge references, reachability, fan-out rules, and GCU constraints.
|
||||
"""
|
||||
return graph.validate()
|
||||
|
||||
|
||||
def validate_credentials(
|
||||
nodes: list[NodeSpec],
|
||||
*,
|
||||
interactive: bool = True,
|
||||
skip: bool = False,
|
||||
) -> None:
|
||||
"""Validate agent credentials.
|
||||
|
||||
Calls ``validate_agent_credentials`` which performs two-phase validation:
|
||||
1. Presence check (env var, encrypted store, Aden sync)
|
||||
2. Health check (lightweight HTTP call to verify the key works)
|
||||
|
||||
On failure raises ``CredentialError`` with ``validation_result`` and
|
||||
``failed_cred_names`` attributes preserved from the upstream check.
|
||||
|
||||
In interactive mode (CLI with TTY), attempts recovery via the
|
||||
credential setup flow before re-raising.
|
||||
"""
|
||||
if skip:
|
||||
return
|
||||
|
||||
from framework.credentials.validation import validate_agent_credentials
|
||||
|
||||
if not interactive:
|
||||
# Non-interactive: let CredentialError propagate with full context.
|
||||
# validate_agent_credentials attaches .validation_result and
|
||||
# .failed_cred_names to the exception automatically.
|
||||
validate_agent_credentials(nodes)
|
||||
return
|
||||
|
||||
import sys
|
||||
|
||||
from framework.credentials.models import CredentialError
|
||||
|
||||
try:
|
||||
validate_agent_credentials(nodes)
|
||||
except CredentialError as e:
|
||||
if not sys.stdin.isatty():
|
||||
raise
|
||||
|
||||
print(f"\n{e}", file=sys.stderr)
|
||||
|
||||
from framework.credentials.validation import build_setup_session_from_error
|
||||
|
||||
session = build_setup_session_from_error(e, nodes=nodes)
|
||||
if not session.missing:
|
||||
raise
|
||||
|
||||
result = session.run_interactive()
|
||||
if not result.success:
|
||||
# Preserve the original validation_result so callers can
|
||||
# inspect which credentials are still missing.
|
||||
exc = CredentialError(
|
||||
"Credential setup incomplete. Run again after configuring the required credentials."
|
||||
)
|
||||
if hasattr(e, "validation_result"):
|
||||
exc.validation_result = e.validation_result # type: ignore[attr-defined]
|
||||
if hasattr(e, "failed_cred_names"):
|
||||
exc.failed_cred_names = e.failed_cred_names # type: ignore[attr-defined]
|
||||
raise exc from None
|
||||
|
||||
# Re-validate after successful setup — this will raise if still broken,
|
||||
# with fresh validation_result attached to the new exception.
|
||||
validate_agent_credentials(nodes)
|
||||
|
||||
|
||||
def credential_errors_to_json(exc: Exception) -> dict:
|
||||
"""Extract structured credential failure details from a CredentialError.
|
||||
|
||||
Returns a dict suitable for JSON serialization with enough detail for
|
||||
the queen to report actionable guidance to the user. Falls back to
|
||||
``str(exc)`` when rich metadata is not available.
|
||||
"""
|
||||
result = getattr(exc, "validation_result", None)
|
||||
if result is None:
|
||||
return {
|
||||
"error": "credentials_required",
|
||||
"message": str(exc),
|
||||
}
|
||||
|
||||
failed = result.failed
|
||||
missing = []
|
||||
for c in failed:
|
||||
if c.available:
|
||||
status = "invalid"
|
||||
elif c.aden_not_connected:
|
||||
status = "aden_not_connected"
|
||||
else:
|
||||
status = "missing"
|
||||
entry: dict = {
|
||||
"credential": c.credential_name,
|
||||
"env_var": c.env_var,
|
||||
"status": status,
|
||||
}
|
||||
if c.tools:
|
||||
entry["tools"] = c.tools
|
||||
if c.node_types:
|
||||
entry["node_types"] = c.node_types
|
||||
if c.help_url:
|
||||
entry["help_url"] = c.help_url
|
||||
if c.validation_message:
|
||||
entry["validation_message"] = c.validation_message
|
||||
missing.append(entry)
|
||||
|
||||
return {
|
||||
"error": "credentials_required",
|
||||
"message": str(exc),
|
||||
"missing_credentials": missing,
|
||||
}
|
||||
|
||||
|
||||
def run_preload_validation(
|
||||
graph: GraphSpec,
|
||||
*,
|
||||
interactive: bool = True,
|
||||
skip_credential_validation: bool = False,
|
||||
) -> PreloadResult:
|
||||
"""Run all pre-load validations.
|
||||
|
||||
Order:
|
||||
1. Graph structure (includes GCU subagent-only checks) — non-recoverable
|
||||
2. Credentials — potentially recoverable via interactive setup
|
||||
|
||||
Raises PreloadValidationError for structural issues.
|
||||
Raises CredentialError for credential issues.
|
||||
"""
|
||||
# 1. Structural validation (calls graph.validate() which includes GCU checks)
|
||||
graph_errors = validate_graph_structure(graph)
|
||||
if graph_errors:
|
||||
raise PreloadValidationError(graph_errors)
|
||||
|
||||
# 2. Credential validation
|
||||
validate_credentials(
|
||||
graph.nodes,
|
||||
interactive=interactive,
|
||||
skip=skip_credential_validation,
|
||||
)
|
||||
|
||||
return PreloadResult(valid=True)
|
||||
+515
-87
@@ -5,13 +5,13 @@ import logging
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from framework.config import get_hive_config, get_preferred_model
|
||||
from framework.credentials.validation import (
|
||||
ensure_credential_key_env as _ensure_credential_key_env,
|
||||
validate_agent_credentials,
|
||||
)
|
||||
from framework.graph import Goal
|
||||
from framework.graph.edge import (
|
||||
@@ -24,6 +24,7 @@ from framework.graph.edge import (
|
||||
from framework.graph.executor import ExecutionResult
|
||||
from framework.graph.node import NodeSpec
|
||||
from framework.llm.provider import LLMProvider, Tool
|
||||
from framework.runner.preload_validation import run_preload_validation
|
||||
from framework.runner.tool_registry import ToolRegistry
|
||||
from framework.runtime.agent_runtime import AgentRuntime, AgentRuntimeConfig, create_agent_runtime
|
||||
from framework.runtime.execution_stream import EntryPointSpec
|
||||
@@ -38,10 +39,108 @@ logger = logging.getLogger(__name__)
|
||||
CLAUDE_CREDENTIALS_FILE = Path.home() / ".claude" / ".credentials.json"
|
||||
CLAUDE_OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
||||
CLAUDE_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials"
|
||||
|
||||
# Buffer in seconds before token expiry to trigger a proactive refresh
|
||||
_TOKEN_REFRESH_BUFFER_SECS = 300 # 5 minutes
|
||||
|
||||
# Codex (OpenAI) subscription auth
|
||||
CODEX_AUTH_FILE = Path.home() / ".codex" / "auth.json"
|
||||
CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
|
||||
CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
CODEX_KEYCHAIN_SERVICE = "Codex Auth"
|
||||
_CODEX_TOKEN_LIFETIME_SECS = 3600 # 1 hour (no explicit expiry field)
|
||||
|
||||
|
||||
def _read_claude_keychain() -> dict | None:
|
||||
"""Read Claude Code credentials from macOS Keychain.
|
||||
|
||||
Returns the parsed JSON dict, or None if not on macOS or entry missing.
|
||||
"""
|
||||
import getpass
|
||||
import platform
|
||||
import subprocess
|
||||
|
||||
if platform.system() != "Darwin":
|
||||
return None
|
||||
|
||||
try:
|
||||
account = getpass.getuser()
|
||||
result = subprocess.run(
|
||||
[
|
||||
"security",
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
CLAUDE_KEYCHAIN_SERVICE,
|
||||
"-a",
|
||||
account,
|
||||
"-w",
|
||||
],
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
raw = result.stdout.strip()
|
||||
if not raw:
|
||||
return None
|
||||
return json.loads(raw)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError) as exc:
|
||||
logger.debug("Claude keychain read failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _save_claude_keychain(creds: dict) -> bool:
|
||||
"""Write Claude Code credentials to macOS Keychain. Returns True on success."""
|
||||
import getpass
|
||||
import platform
|
||||
import subprocess
|
||||
|
||||
if platform.system() != "Darwin":
|
||||
return False
|
||||
|
||||
try:
|
||||
account = getpass.getuser()
|
||||
data = json.dumps(creds)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"security",
|
||||
"add-generic-password",
|
||||
"-U",
|
||||
"-s",
|
||||
CLAUDE_KEYCHAIN_SERVICE,
|
||||
"-a",
|
||||
account,
|
||||
"-w",
|
||||
data,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.debug("Claude keychain write failed: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def _read_claude_credentials() -> dict | None:
|
||||
"""Read Claude Code credentials from Keychain (macOS) or file (Linux/Windows)."""
|
||||
# Try macOS Keychain first
|
||||
creds = _read_claude_keychain()
|
||||
if creds:
|
||||
return creds
|
||||
|
||||
# Fall back to file
|
||||
if not CLAUDE_CREDENTIALS_FILE.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(CLAUDE_CREDENTIALS_FILE, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _refresh_claude_code_token(refresh_token: str) -> dict | None:
|
||||
"""Refresh the Claude Code OAuth token using the refresh token.
|
||||
@@ -81,16 +180,14 @@ def _refresh_claude_code_token(refresh_token: str) -> dict | None:
|
||||
|
||||
|
||||
def _save_refreshed_credentials(token_data: dict) -> None:
|
||||
"""Write refreshed token data back to ~/.claude/.credentials.json."""
|
||||
"""Write refreshed token data back to Keychain (macOS) or credentials file."""
|
||||
import time
|
||||
|
||||
if not CLAUDE_CREDENTIALS_FILE.exists():
|
||||
creds = _read_claude_credentials()
|
||||
if not creds:
|
||||
return
|
||||
|
||||
try:
|
||||
with open(CLAUDE_CREDENTIALS_FILE) as f:
|
||||
creds = json.load(f)
|
||||
|
||||
oauth = creds.get("claudeAiOauth", {})
|
||||
oauth["accessToken"] = token_data["access_token"]
|
||||
if "refresh_token" in token_data:
|
||||
@@ -99,9 +196,15 @@ def _save_refreshed_credentials(token_data: dict) -> None:
|
||||
oauth["expiresAt"] = int((time.time() + token_data["expires_in"]) * 1000)
|
||||
creds["claudeAiOauth"] = oauth
|
||||
|
||||
with open(CLAUDE_CREDENTIALS_FILE, "w") as f:
|
||||
json.dump(creds, f, indent=2)
|
||||
logger.debug("Claude Code credentials refreshed successfully")
|
||||
# Try Keychain first (macOS), fall back to file
|
||||
if _save_claude_keychain(creds):
|
||||
logger.debug("Claude Code credentials refreshed in Keychain")
|
||||
return
|
||||
|
||||
if CLAUDE_CREDENTIALS_FILE.exists():
|
||||
with open(CLAUDE_CREDENTIALS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(creds, f, indent=2)
|
||||
logger.debug("Claude Code credentials refreshed in file")
|
||||
except (json.JSONDecodeError, OSError, KeyError) as exc:
|
||||
logger.debug("Failed to save refreshed credentials: %s", exc)
|
||||
|
||||
@@ -109,8 +212,8 @@ def _save_refreshed_credentials(token_data: dict) -> None:
|
||||
def get_claude_code_token() -> str | None:
|
||||
"""Get the OAuth token from Claude Code subscription with auto-refresh.
|
||||
|
||||
Reads from ~/.claude/.credentials.json which is created by the
|
||||
Claude Code CLI when users authenticate with their subscription.
|
||||
Reads from macOS Keychain (on Darwin) or ~/.claude/.credentials.json
|
||||
(on Linux/Windows), as created by the Claude Code CLI.
|
||||
|
||||
If the token is expired or close to expiry, attempts an automatic
|
||||
refresh using the stored refresh token.
|
||||
@@ -120,13 +223,8 @@ def get_claude_code_token() -> str | None:
|
||||
"""
|
||||
import time
|
||||
|
||||
if not CLAUDE_CREDENTIALS_FILE.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(CLAUDE_CREDENTIALS_FILE) as f:
|
||||
creds = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
creds = _read_claude_credentials()
|
||||
if not creds:
|
||||
return None
|
||||
|
||||
oauth = creds.get("claudeAiOauth", {})
|
||||
@@ -161,6 +259,264 @@ def get_claude_code_token() -> str | None:
|
||||
return access_token
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Codex (OpenAI) subscription token helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_codex_keychain_account() -> str:
|
||||
"""Compute the macOS Keychain account name used by the Codex CLI.
|
||||
|
||||
The Codex CLI stores credentials under the account
|
||||
``cli|<sha256(~/.codex)[:16]>`` in the ``Codex Auth`` service.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
codex_dir = str(Path.home() / ".codex")
|
||||
digest = hashlib.sha256(codex_dir.encode()).hexdigest()[:16]
|
||||
return f"cli|{digest}"
|
||||
|
||||
|
||||
def _read_codex_keychain() -> dict | None:
|
||||
"""Read Codex auth data from macOS Keychain (macOS only).
|
||||
|
||||
Returns the parsed JSON from the Keychain entry, or None if not
|
||||
available (wrong platform, entry missing, etc.).
|
||||
"""
|
||||
import platform
|
||||
import subprocess
|
||||
|
||||
if platform.system() != "Darwin":
|
||||
return None
|
||||
|
||||
try:
|
||||
account = _get_codex_keychain_account()
|
||||
result = subprocess.run(
|
||||
[
|
||||
"security",
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
CODEX_KEYCHAIN_SERVICE,
|
||||
"-a",
|
||||
account,
|
||||
"-w",
|
||||
],
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
raw = result.stdout.strip()
|
||||
if not raw:
|
||||
return None
|
||||
return json.loads(raw)
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError) as exc:
|
||||
logger.debug("Codex keychain read failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _read_codex_auth_file() -> dict | None:
|
||||
"""Read Codex auth data from ~/.codex/auth.json (fallback)."""
|
||||
if not CODEX_AUTH_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
with open(CODEX_AUTH_FILE, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _is_codex_token_expired(auth_data: dict) -> bool:
|
||||
"""Check whether the Codex token is expired or close to expiry.
|
||||
|
||||
The Codex auth.json has no explicit ``expiresAt`` field, so we infer
|
||||
expiry as ``last_refresh + _CODEX_TOKEN_LIFETIME_SECS``. Falls back
|
||||
to the file mtime when ``last_refresh`` is absent.
|
||||
"""
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
now = time.time()
|
||||
last_refresh = auth_data.get("last_refresh")
|
||||
|
||||
if last_refresh is None:
|
||||
# Fall back to file modification time
|
||||
try:
|
||||
last_refresh = CODEX_AUTH_FILE.stat().st_mtime
|
||||
except OSError:
|
||||
# Cannot determine age — assume expired
|
||||
return True
|
||||
elif isinstance(last_refresh, str):
|
||||
# Codex stores last_refresh as an ISO 8601 timestamp string —
|
||||
# convert to Unix epoch float for arithmetic.
|
||||
try:
|
||||
last_refresh = datetime.fromisoformat(last_refresh.replace("Z", "+00:00")).timestamp()
|
||||
except (ValueError, TypeError):
|
||||
return True
|
||||
|
||||
expires_at = last_refresh + _CODEX_TOKEN_LIFETIME_SECS
|
||||
return now >= (expires_at - _TOKEN_REFRESH_BUFFER_SECS)
|
||||
|
||||
|
||||
def _refresh_codex_token(refresh_token: str) -> dict | None:
|
||||
"""Refresh the Codex OAuth token using the refresh token.
|
||||
|
||||
POSTs to the OpenAI auth endpoint with form-urlencoded data.
|
||||
|
||||
Returns:
|
||||
Dict with new token data on success, None on failure.
|
||||
"""
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
data = urllib.parse.urlencode(
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": CODEX_OAUTH_CLIENT_ID,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
CODEX_OAUTH_TOKEN_URL,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read())
|
||||
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError, OSError) as exc:
|
||||
logger.debug("Codex token refresh failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _save_refreshed_codex_credentials(auth_data: dict, token_data: dict) -> None:
|
||||
"""Write refreshed tokens back to ~/.codex/auth.json only (not Keychain).
|
||||
|
||||
The Codex CLI manages its own Keychain entries, so we only update the
|
||||
file-based credentials.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
tokens = auth_data.get("tokens", {})
|
||||
tokens["access_token"] = token_data["access_token"]
|
||||
if "refresh_token" in token_data:
|
||||
tokens["refresh_token"] = token_data["refresh_token"]
|
||||
if "id_token" in token_data:
|
||||
tokens["id_token"] = token_data["id_token"]
|
||||
auth_data["tokens"] = tokens
|
||||
auth_data["last_refresh"] = datetime.now(UTC).isoformat()
|
||||
|
||||
CODEX_AUTH_FILE.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
fd = os.open(CODEX_AUTH_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(auth_data, f, indent=2)
|
||||
logger.debug("Codex credentials refreshed successfully")
|
||||
except (OSError, KeyError) as exc:
|
||||
logger.debug("Failed to save refreshed Codex credentials: %s", exc)
|
||||
|
||||
|
||||
def get_codex_token() -> str | None:
|
||||
"""Get the OAuth token from Codex subscription with auto-refresh.
|
||||
|
||||
Reads from macOS Keychain first, then falls back to
|
||||
``~/.codex/auth.json``. If the token is expired or close to
|
||||
expiry, attempts an automatic refresh.
|
||||
|
||||
Returns:
|
||||
The access token if available, None otherwise.
|
||||
"""
|
||||
# Try Keychain first, then file
|
||||
auth_data = _read_codex_keychain() or _read_codex_auth_file()
|
||||
if not auth_data:
|
||||
return None
|
||||
|
||||
tokens = auth_data.get("tokens", {})
|
||||
access_token = tokens.get("access_token")
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
# Check if token is still valid
|
||||
if not _is_codex_token_expired(auth_data):
|
||||
return access_token
|
||||
|
||||
# Token is expired or near expiry — attempt refresh
|
||||
refresh_token = tokens.get("refresh_token")
|
||||
if not refresh_token:
|
||||
logger.warning("Codex token expired and no refresh token available")
|
||||
return access_token # Return expired token; it may still work briefly
|
||||
|
||||
logger.info("Codex token expired or near expiry, refreshing...")
|
||||
token_data = _refresh_codex_token(refresh_token)
|
||||
|
||||
if token_data and "access_token" in token_data:
|
||||
_save_refreshed_codex_credentials(auth_data, token_data)
|
||||
return token_data["access_token"]
|
||||
|
||||
# Refresh failed — return the existing token and warn
|
||||
logger.warning("Codex token refresh failed. Run 'codex' to re-authenticate.")
|
||||
return access_token
|
||||
|
||||
|
||||
def _get_account_id_from_jwt(access_token: str) -> str | None:
|
||||
"""Extract the ChatGPT account_id from the access token JWT.
|
||||
|
||||
The OpenAI access token JWT contains a claim at
|
||||
``https://api.openai.com/auth`` with a ``chatgpt_account_id`` field.
|
||||
This is used as a fallback when the auth.json doesn't store the
|
||||
account_id explicitly.
|
||||
"""
|
||||
import base64
|
||||
|
||||
try:
|
||||
parts = access_token.split(".")
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
payload = parts[1]
|
||||
# Add base64 padding
|
||||
padding = 4 - len(payload) % 4
|
||||
if padding != 4:
|
||||
payload += "=" * padding
|
||||
decoded = base64.urlsafe_b64decode(payload)
|
||||
claims = json.loads(decoded)
|
||||
auth = claims.get("https://api.openai.com/auth")
|
||||
if isinstance(auth, dict):
|
||||
account_id = auth.get("chatgpt_account_id")
|
||||
if isinstance(account_id, str) and account_id:
|
||||
return account_id
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_codex_account_id() -> str | None:
|
||||
"""Extract the account ID from Codex auth data for the ChatGPT-Account-Id header.
|
||||
|
||||
Checks the ``tokens.account_id`` field first, then falls back to
|
||||
decoding the account ID from the access token JWT.
|
||||
|
||||
Returns:
|
||||
The account_id string if available, None otherwise.
|
||||
"""
|
||||
auth_data = _read_codex_keychain() or _read_codex_auth_file()
|
||||
if not auth_data:
|
||||
return None
|
||||
tokens = auth_data.get("tokens", {})
|
||||
account_id = tokens.get("account_id")
|
||||
if account_id:
|
||||
return account_id
|
||||
# Fallback: extract from JWT
|
||||
access_token = tokens.get("access_token")
|
||||
if access_token:
|
||||
return _get_account_id_from_jwt(access_token)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentInfo:
|
||||
"""Information about an exported agent."""
|
||||
@@ -355,6 +711,7 @@ class AgentRunner:
|
||||
requires_account_selection: bool = False,
|
||||
configure_for_account: Callable | None = None,
|
||||
list_accounts: Callable | None = None,
|
||||
credential_store: Any | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the runner (use AgentRunner.load() instead).
|
||||
@@ -374,6 +731,7 @@ class AgentRunner:
|
||||
requires_account_selection: If True, TUI shows account picker before starting.
|
||||
configure_for_account: Callback(runner, account_dict) to scope tools after selection.
|
||||
list_accounts: Callback() -> list[dict] to fetch available accounts.
|
||||
credential_store: Optional shared CredentialStore (avoids creating redundant stores).
|
||||
"""
|
||||
self.agent_path = agent_path
|
||||
self.graph = graph
|
||||
@@ -387,6 +745,7 @@ class AgentRunner:
|
||||
self.requires_account_selection = requires_account_selection
|
||||
self._configure_for_account = configure_for_account
|
||||
self._list_accounts = list_accounts
|
||||
self._credential_store = credential_store
|
||||
|
||||
# Set up storage
|
||||
if storage_path:
|
||||
@@ -413,74 +772,39 @@ class AgentRunner:
|
||||
self._agent_runtime: AgentRuntime | None = None
|
||||
self._uses_async_entry_points = self.graph.has_async_entry_points()
|
||||
|
||||
# Validate credentials before spawning MCP servers.
|
||||
# Pre-load validation: structural checks + credentials.
|
||||
# Fails fast with actionable guidance — no MCP noise on screen.
|
||||
self._validate_credentials()
|
||||
run_preload_validation(
|
||||
self.graph,
|
||||
interactive=self._interactive,
|
||||
skip_credential_validation=self.skip_credential_validation,
|
||||
)
|
||||
|
||||
# Auto-discover tools from tools.py
|
||||
tools_path = agent_path / "tools.py"
|
||||
if tools_path.exists():
|
||||
self._tool_registry.discover_from_module(tools_path)
|
||||
|
||||
# Set environment variables for MCP subprocesses
|
||||
# These are inherited by MCP servers (e.g., GCU browser tools)
|
||||
os.environ["HIVE_AGENT_NAME"] = agent_path.name
|
||||
os.environ["HIVE_STORAGE_PATH"] = str(self._storage_path)
|
||||
|
||||
# Auto-discover MCP servers from mcp_servers.json
|
||||
mcp_config_path = agent_path / "mcp_servers.json"
|
||||
if mcp_config_path.exists():
|
||||
self._load_mcp_servers_from_config(mcp_config_path)
|
||||
|
||||
def _validate_credentials(self) -> None:
|
||||
"""Check that required credentials are available before spawning MCP servers.
|
||||
|
||||
If ``interactive`` is True and stdin is a TTY, automatically launches
|
||||
the interactive credential setup flow so the user can fix the issue
|
||||
in-place. Re-validates after setup succeeds.
|
||||
|
||||
When ``interactive`` is False (e.g. TUI callers), the CredentialError
|
||||
propagates immediately so the caller can handle it with its own UI.
|
||||
"""
|
||||
if self.skip_credential_validation:
|
||||
return
|
||||
|
||||
if not self._interactive:
|
||||
# Let the CredentialError propagate — caller handles UI.
|
||||
validate_agent_credentials(self.graph.nodes)
|
||||
return
|
||||
|
||||
import sys
|
||||
|
||||
from framework.credentials.models import CredentialError
|
||||
|
||||
try:
|
||||
validate_agent_credentials(self.graph.nodes)
|
||||
return # All good
|
||||
except CredentialError as e:
|
||||
if not sys.stdin.isatty():
|
||||
raise
|
||||
|
||||
# Interactive: show the error then enter credential setup
|
||||
print(f"\n{e}", file=sys.stderr)
|
||||
|
||||
from framework.credentials.validation import build_setup_session_from_error
|
||||
|
||||
session = build_setup_session_from_error(e, nodes=self.graph.nodes)
|
||||
if not session.missing:
|
||||
raise
|
||||
|
||||
result = session.run_interactive()
|
||||
if not result.success:
|
||||
raise CredentialError(
|
||||
"Credential setup incomplete. "
|
||||
"Run again after configuring the required credentials."
|
||||
) from None
|
||||
|
||||
# Re-validate after setup
|
||||
validate_agent_credentials(self.graph.nodes)
|
||||
|
||||
@staticmethod
|
||||
def _import_agent_module(agent_path: Path):
|
||||
"""Import an agent package from its directory path.
|
||||
|
||||
Ensures the agent's parent directory is on sys.path so the package
|
||||
can be imported normally (supports relative imports within the agent).
|
||||
|
||||
Always reloads the package and its submodules so that code changes
|
||||
made since the last import (or since a previous session load in the
|
||||
same server process) are picked up.
|
||||
"""
|
||||
import importlib
|
||||
import sys
|
||||
@@ -488,10 +812,25 @@ class AgentRunner:
|
||||
package_name = agent_path.name
|
||||
parent_dir = str(agent_path.resolve().parent)
|
||||
|
||||
# Ensure the parent directory is on sys.path so the agent package
|
||||
# is importable (e.g., exports/ or examples/templates/)
|
||||
if parent_dir not in sys.path:
|
||||
sys.path.insert(0, parent_dir)
|
||||
# Always place the correct parent directory first on sys.path.
|
||||
# Multiple agent dirs can contain packages with the same name
|
||||
# (e.g. exports/deep_research_agent and examples/deep_research_agent).
|
||||
# Without this, a previously-added parent dir could shadow the
|
||||
# agent we actually want to load.
|
||||
if parent_dir in sys.path:
|
||||
sys.path.remove(parent_dir)
|
||||
sys.path.insert(0, parent_dir)
|
||||
|
||||
# Evict cached submodules first (e.g. deep_research_agent.nodes,
|
||||
# deep_research_agent.agent) so the top-level reload picks up
|
||||
# changes in the entire package — not just __init__.py.
|
||||
stale = [
|
||||
name
|
||||
for name in sys.modules
|
||||
if name == package_name or name.startswith(f"{package_name}.")
|
||||
]
|
||||
for name in stale:
|
||||
del sys.modules[name]
|
||||
|
||||
return importlib.import_module(package_name)
|
||||
|
||||
@@ -503,6 +842,8 @@ class AgentRunner:
|
||||
storage_path: Path | None = None,
|
||||
model: str | None = None,
|
||||
interactive: bool = True,
|
||||
skip_credential_validation: bool | None = None,
|
||||
credential_store: Any | None = None,
|
||||
) -> "AgentRunner":
|
||||
"""
|
||||
Load an agent from an export folder.
|
||||
@@ -518,6 +859,9 @@ class AgentRunner:
|
||||
model: LLM model to use (reads from agent's default_config if None)
|
||||
interactive: If True (default), offer interactive credential setup.
|
||||
Set to False from TUI callers that handle setup via their own UI.
|
||||
skip_credential_validation: If True, skip credential checks at load time.
|
||||
When None (default), uses the agent module's setting.
|
||||
credential_store: Optional shared CredentialStore (avoids creating redundant stores).
|
||||
|
||||
Returns:
|
||||
AgentRunner instance ready to run
|
||||
@@ -587,6 +931,8 @@ class AgentRunner:
|
||||
|
||||
# Read pre-run hooks (e.g., credential_tester needs account selection)
|
||||
skip_cred = getattr(agent_module, "skip_credential_validation", False)
|
||||
if skip_credential_validation is not None:
|
||||
skip_cred = skip_credential_validation
|
||||
needs_acct = getattr(agent_module, "requires_account_selection", False)
|
||||
configure_fn = getattr(agent_module, "configure_for_account", None)
|
||||
list_accts_fn = getattr(agent_module, "list_connected_accounts", None)
|
||||
@@ -605,6 +951,7 @@ class AgentRunner:
|
||||
requires_account_selection=needs_acct,
|
||||
configure_for_account=configure_fn,
|
||||
list_accounts=list_accts_fn,
|
||||
credential_store=credential_store,
|
||||
)
|
||||
|
||||
# Fallback: load from agent.json (legacy JSON-based agents)
|
||||
@@ -612,7 +959,7 @@ class AgentRunner:
|
||||
if not agent_json_path.exists():
|
||||
raise FileNotFoundError(f"No agent.py or agent.json found in {agent_path}")
|
||||
|
||||
with open(agent_json_path) as f:
|
||||
with open(agent_json_path, encoding="utf-8") as f:
|
||||
graph, goal = load_agent_export(f.read())
|
||||
|
||||
return cls(
|
||||
@@ -623,6 +970,8 @@ class AgentRunner:
|
||||
storage_path=storage_path,
|
||||
model=model,
|
||||
interactive=interactive,
|
||||
skip_credential_validation=skip_credential_validation or False,
|
||||
credential_store=credential_store,
|
||||
)
|
||||
|
||||
def register_tool(
|
||||
@@ -742,10 +1091,11 @@ class AgentRunner:
|
||||
else:
|
||||
from framework.llm.litellm import LiteLLMProvider
|
||||
|
||||
# Check if Claude Code subscription is configured
|
||||
# Check if a subscription mode is configured
|
||||
config = get_hive_config()
|
||||
llm_config = config.get("llm", {})
|
||||
use_claude_code = llm_config.get("use_claude_code_subscription", False)
|
||||
use_codex = llm_config.get("use_codex_subscription", False)
|
||||
api_base = llm_config.get("api_base")
|
||||
|
||||
api_key = None
|
||||
@@ -755,6 +1105,12 @@ class AgentRunner:
|
||||
if not api_key:
|
||||
print("Warning: Claude Code subscription configured but no token found.")
|
||||
print("Run 'claude' to authenticate, then try again.")
|
||||
elif use_codex:
|
||||
# Get OAuth token from Codex subscription
|
||||
api_key = get_codex_token()
|
||||
if not api_key:
|
||||
print("Warning: Codex subscription configured but no token found.")
|
||||
print("Run 'codex' to authenticate, then try again.")
|
||||
|
||||
if api_key and use_claude_code:
|
||||
# Use litellm's built-in Anthropic OAuth support.
|
||||
@@ -766,6 +1122,25 @@ class AgentRunner:
|
||||
api_base=api_base,
|
||||
extra_headers={"authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
elif api_key and use_codex:
|
||||
# OpenAI Codex subscription routes through the ChatGPT backend
|
||||
# (chatgpt.com/backend-api/codex/responses), NOT the standard
|
||||
# OpenAI API. The consumer OAuth token lacks platform API scopes.
|
||||
extra_headers: dict[str, str] = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": "CodexBar",
|
||||
}
|
||||
account_id = get_codex_account_id()
|
||||
if account_id:
|
||||
extra_headers["ChatGPT-Account-Id"] = account_id
|
||||
self._llm = LiteLLMProvider(
|
||||
model=self.model,
|
||||
api_key=api_key,
|
||||
api_base="https://chatgpt.com/backend-api/codex",
|
||||
extra_headers=extra_headers,
|
||||
store=False,
|
||||
allowed_openai_params=["store"],
|
||||
)
|
||||
else:
|
||||
# Local models (e.g. Ollama) don't need an API key
|
||||
if self._is_local_model(self.model):
|
||||
@@ -802,7 +1177,9 @@ class AgentRunner:
|
||||
|
||||
# Fail fast if the agent needs an LLM but none was configured
|
||||
if self._llm is None:
|
||||
has_llm_nodes = any(node.node_type == "event_loop" for node in self.graph.nodes)
|
||||
has_llm_nodes = any(
|
||||
node.node_type in ("event_loop", "gcu") for node in self.graph.nodes
|
||||
)
|
||||
if has_llm_nodes:
|
||||
from framework.credentials.models import CredentialError
|
||||
|
||||
@@ -820,6 +1197,52 @@ class AgentRunner:
|
||||
)
|
||||
raise CredentialError(f"LLM API key not found for model '{self.model}'. {hint}")
|
||||
|
||||
# For GCU nodes: auto-register GCU MCP server if needed, then expand tool lists
|
||||
has_gcu_nodes = any(node.node_type == "gcu" for node in self.graph.nodes)
|
||||
if has_gcu_nodes:
|
||||
from framework.graph.gcu import GCU_MCP_SERVER_CONFIG, GCU_SERVER_NAME
|
||||
|
||||
# Auto-register GCU MCP server if tools aren't loaded yet
|
||||
gcu_tool_names = self._tool_registry.get_server_tool_names(GCU_SERVER_NAME)
|
||||
if not gcu_tool_names:
|
||||
# Resolve cwd to repo-level tools/ (not relative to agent_path)
|
||||
gcu_config = dict(GCU_MCP_SERVER_CONFIG)
|
||||
_repo_root = Path(__file__).resolve().parent.parent.parent.parent
|
||||
gcu_config["cwd"] = str(_repo_root / "tools")
|
||||
self._tool_registry.register_mcp_server(gcu_config)
|
||||
gcu_tool_names = self._tool_registry.get_server_tool_names(GCU_SERVER_NAME)
|
||||
|
||||
# Expand each GCU node's tools list to include all GCU server tools
|
||||
if gcu_tool_names:
|
||||
for node in self.graph.nodes:
|
||||
if node.node_type == "gcu":
|
||||
existing = set(node.tools)
|
||||
for tool_name in sorted(gcu_tool_names):
|
||||
if tool_name not in existing:
|
||||
node.tools.append(tool_name)
|
||||
|
||||
# For event_loop/gcu nodes: auto-register file tools MCP server, then expand tool lists
|
||||
has_loop_nodes = any(node.node_type in ("event_loop", "gcu") for node in self.graph.nodes)
|
||||
if has_loop_nodes:
|
||||
from framework.graph.files import FILES_MCP_SERVER_CONFIG, FILES_MCP_SERVER_NAME
|
||||
|
||||
files_tool_names = self._tool_registry.get_server_tool_names(FILES_MCP_SERVER_NAME)
|
||||
if not files_tool_names:
|
||||
# Resolve cwd to repo-level tools/ (not relative to agent_path)
|
||||
files_config = dict(FILES_MCP_SERVER_CONFIG)
|
||||
_repo_root = Path(__file__).resolve().parent.parent.parent.parent
|
||||
files_config["cwd"] = str(_repo_root / "tools")
|
||||
self._tool_registry.register_mcp_server(files_config)
|
||||
files_tool_names = self._tool_registry.get_server_tool_names(FILES_MCP_SERVER_NAME)
|
||||
|
||||
if files_tool_names:
|
||||
for node in self.graph.nodes:
|
||||
if node.node_type in ("event_loop", "gcu"):
|
||||
existing = set(node.tools)
|
||||
for tool_name in sorted(files_tool_names):
|
||||
if tool_name not in existing:
|
||||
node.tools.append(tool_name)
|
||||
|
||||
# Get tools for runtime
|
||||
tools = list(self._tool_registry.get_tools().values())
|
||||
tool_executor = self._tool_registry.get_executor()
|
||||
@@ -831,7 +1254,10 @@ class AgentRunner:
|
||||
try:
|
||||
from aden_tools.credentials.store_adapter import CredentialStoreAdapter
|
||||
|
||||
adapter = CredentialStoreAdapter.default()
|
||||
if self._credential_store is not None:
|
||||
adapter = CredentialStoreAdapter(store=self._credential_store)
|
||||
else:
|
||||
adapter = CredentialStoreAdapter.default()
|
||||
accounts_data = adapter.get_all_account_info()
|
||||
tool_provider_map = adapter.get_tool_provider_map()
|
||||
if accounts_data:
|
||||
@@ -902,9 +1328,11 @@ class AgentRunner:
|
||||
return None
|
||||
|
||||
try:
|
||||
from framework.credentials import CredentialStore
|
||||
store = self._credential_store
|
||||
if store is None:
|
||||
from framework.credentials import CredentialStore
|
||||
|
||||
store = CredentialStore.with_encrypted_storage()
|
||||
store = CredentialStore.with_encrypted_storage()
|
||||
return store.get(cred_id)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -947,6 +1375,7 @@ class AgentRunner:
|
||||
isolation_level=async_ep.isolation_level,
|
||||
priority=async_ep.priority,
|
||||
max_concurrent=async_ep.max_concurrent,
|
||||
max_resurrections=async_ep.max_resurrections,
|
||||
)
|
||||
entry_points.append(ep)
|
||||
|
||||
@@ -979,17 +1408,14 @@ class AgentRunner:
|
||||
async_checkpoint=True, # Non-blocking
|
||||
)
|
||||
|
||||
# Handle runtime_config - ensure it's AgentRuntimeConfig, not RuntimeConfig
|
||||
# RuntimeConfig is for LLM settings; AgentRuntimeConfig is for AgentRuntime settings
|
||||
# Handle runtime_config - only pass through if it's actually an AgentRuntimeConfig.
|
||||
# Agents may export a RuntimeConfig (LLM settings) or queen-generated custom classes
|
||||
# that would crash AgentRuntime if passed through.
|
||||
runtime_config = None
|
||||
if self.runtime_config is not None:
|
||||
from framework.config import RuntimeConfig
|
||||
from framework.runtime.agent_runtime import AgentRuntimeConfig
|
||||
|
||||
# If it's a RuntimeConfig (LLM config), don't pass it
|
||||
if isinstance(self.runtime_config, RuntimeConfig):
|
||||
runtime_config = None
|
||||
else:
|
||||
# It's already an AgentRuntimeConfig or compatible type
|
||||
if isinstance(self.runtime_config, AgentRuntimeConfig):
|
||||
runtime_config = self.runtime_config
|
||||
|
||||
self._agent_runtime = create_agent_runtime(
|
||||
@@ -1359,7 +1785,9 @@ class AgentRunner:
|
||||
warnings.append(warning_msg)
|
||||
except ImportError:
|
||||
# aden_tools not installed - fall back to direct check
|
||||
has_llm_nodes = any(node.node_type == "event_loop" for node in self.graph.nodes)
|
||||
has_llm_nodes = any(
|
||||
node.node_type in ("event_loop", "gcu") for node in self.graph.nodes
|
||||
)
|
||||
if has_llm_nodes:
|
||||
api_key_env = self._get_api_key_env_var(self.model)
|
||||
if api_key_env and not os.environ.get(api_key_env):
|
||||
|
||||
@@ -6,6 +6,7 @@ import importlib.util
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -47,11 +48,20 @@ class ToolRegistry:
|
||||
# and auto-injected at call time for tools that accept them.
|
||||
CONTEXT_PARAMS = frozenset({"workspace_id", "agent_id", "session_id", "data_dir"})
|
||||
|
||||
# Credential directory used for change detection
|
||||
_CREDENTIAL_DIR = Path("~/.hive/credentials/credentials").expanduser()
|
||||
|
||||
def __init__(self):
|
||||
self._tools: dict[str, RegisteredTool] = {}
|
||||
self._mcp_clients: list[Any] = [] # List of MCPClient instances
|
||||
self._session_context: dict[str, Any] = {} # Auto-injected context for tools
|
||||
self._provider_index: dict[str, set[str]] = {} # provider -> tool names
|
||||
# MCP resync tracking
|
||||
self._mcp_config_path: Path | None = None # Path used for initial load
|
||||
self._mcp_tool_names: set[str] = set() # Tool names registered from MCP
|
||||
self._mcp_cred_snapshot: set[str] = set() # Credential filenames at MCP load time
|
||||
self._mcp_aden_key_snapshot: str | None = None # ADEN_API_KEY value at MCP load time
|
||||
self._mcp_server_tools: dict[str, set[str]] = {} # server name -> tool names
|
||||
|
||||
def register(
|
||||
self,
|
||||
@@ -285,6 +295,10 @@ class ToolRegistry:
|
||||
"""Check if a tool is registered."""
|
||||
return name in self._tools
|
||||
|
||||
def get_server_tool_names(self, server_name: str) -> set[str]:
|
||||
"""Return tool names registered from a specific MCP server."""
|
||||
return set(self._mcp_server_tools.get(server_name, set()))
|
||||
|
||||
def set_session_context(self, **context) -> None:
|
||||
"""
|
||||
Set session context to auto-inject into tool calls.
|
||||
@@ -312,6 +326,103 @@ class ToolRegistry:
|
||||
"""Restore execution context to its previous state."""
|
||||
_execution_context.reset(token)
|
||||
|
||||
@staticmethod
|
||||
def resolve_mcp_stdio_config(server_config: dict[str, Any], base_dir: Path) -> dict[str, Any]:
|
||||
"""Resolve cwd and script paths for MCP stdio config (Windows compatibility).
|
||||
|
||||
Use this when building MCPServerConfig from a config file (e.g. in
|
||||
list_agent_tools, discover_mcp_tools) so hive-tools and other servers
|
||||
work on Windows. Call with base_dir = directory containing the config.
|
||||
"""
|
||||
registry = ToolRegistry()
|
||||
return registry._resolve_mcp_server_config(server_config, base_dir)
|
||||
|
||||
def _resolve_mcp_server_config(
|
||||
self, server_config: dict[str, Any], base_dir: Path
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve cwd and script paths for MCP stdio servers (Windows compatibility).
|
||||
|
||||
On Windows, passing cwd to subprocess can cause WinError 267. We use cwd=None
|
||||
and absolute script paths when the server runs a .py script from the tools dir.
|
||||
If the resolved cwd doesn't exist (e.g. config from ~/.hive/agents/), fall back
|
||||
to Path.cwd() / "tools".
|
||||
"""
|
||||
config = dict(server_config)
|
||||
if config.get("transport") != "stdio":
|
||||
return config
|
||||
|
||||
cwd = config.get("cwd")
|
||||
args = list(config.get("args", []))
|
||||
if not cwd and not args:
|
||||
return config
|
||||
|
||||
# Resolve cwd relative to base_dir
|
||||
resolved_cwd: Path | None = None
|
||||
if cwd:
|
||||
if Path(cwd).is_absolute():
|
||||
resolved_cwd = Path(cwd)
|
||||
else:
|
||||
resolved_cwd = (base_dir / cwd).resolve()
|
||||
|
||||
# Find .py script in args (e.g. coder_tools_server.py, files_server.py)
|
||||
script_name = None
|
||||
for i, arg in enumerate(args):
|
||||
if isinstance(arg, str) and arg.endswith(".py"):
|
||||
script_name = arg
|
||||
script_idx = i
|
||||
break
|
||||
|
||||
if resolved_cwd is None:
|
||||
return config
|
||||
|
||||
# If resolved cwd doesn't exist or (when we have a script) doesn't contain it,
|
||||
# try fallback
|
||||
tools_fallback = Path.cwd() / "tools"
|
||||
need_fallback = not resolved_cwd.is_dir()
|
||||
if script_name and not need_fallback:
|
||||
need_fallback = not (resolved_cwd / script_name).exists()
|
||||
if need_fallback:
|
||||
fallback_ok = tools_fallback.is_dir()
|
||||
if script_name:
|
||||
fallback_ok = fallback_ok and (tools_fallback / script_name).exists()
|
||||
else:
|
||||
# No script (e.g. GCU); just need tools dir to exist
|
||||
pass
|
||||
if fallback_ok:
|
||||
resolved_cwd = tools_fallback
|
||||
logger.debug(
|
||||
"MCP server '%s': using fallback tools dir %s",
|
||||
config.get("name", "?"),
|
||||
resolved_cwd,
|
||||
)
|
||||
else:
|
||||
config["cwd"] = str(resolved_cwd)
|
||||
return config
|
||||
|
||||
if not script_name:
|
||||
# No .py script (e.g. GCU uses -m gcu.server); just set cwd
|
||||
config["cwd"] = str(resolved_cwd)
|
||||
return config
|
||||
|
||||
# For coder_tools_server, inject --project-root so writes go to the expected workspace
|
||||
if script_name and "coder_tools" in script_name:
|
||||
project_root = str(resolved_cwd.parent.resolve())
|
||||
args = list(args)
|
||||
if "--project-root" not in args:
|
||||
args.extend(["--project-root", project_root])
|
||||
config["args"] = args
|
||||
|
||||
if os.name == "nt":
|
||||
# Windows: cwd=None avoids WinError 267; use absolute script path
|
||||
config["cwd"] = None
|
||||
abs_script = str((resolved_cwd / script_name).resolve())
|
||||
args = list(config["args"])
|
||||
args[script_idx] = abs_script
|
||||
config["args"] = args
|
||||
else:
|
||||
config["cwd"] = str(resolved_cwd)
|
||||
return config
|
||||
|
||||
def load_mcp_config(self, config_path: Path) -> None:
|
||||
"""
|
||||
Load and register MCP servers from a config file.
|
||||
@@ -322,8 +433,11 @@ class ToolRegistry:
|
||||
Args:
|
||||
config_path: Path to an ``mcp_servers.json`` file.
|
||||
"""
|
||||
# Remember config path for potential resync later
|
||||
self._mcp_config_path = Path(config_path)
|
||||
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load MCP config from {config_path}: {e}")
|
||||
@@ -340,15 +454,17 @@ class ToolRegistry:
|
||||
server_list = [{"name": name, **cfg} for name, cfg in config.items()]
|
||||
|
||||
for server_config in server_list:
|
||||
cwd = server_config.get("cwd")
|
||||
if cwd and not Path(cwd).is_absolute():
|
||||
server_config["cwd"] = str((base_dir / cwd).resolve())
|
||||
server_config = self._resolve_mcp_server_config(server_config, base_dir)
|
||||
try:
|
||||
self.register_mcp_server(server_config)
|
||||
except Exception as e:
|
||||
name = server_config.get("name", "unknown")
|
||||
logger.warning(f"Failed to register MCP server '{name}': {e}")
|
||||
|
||||
# Snapshot credential files and ADEN_API_KEY so we can detect mid-session changes
|
||||
self._mcp_cred_snapshot = self._snapshot_credentials()
|
||||
self._mcp_aden_key_snapshot = os.environ.get("ADEN_API_KEY")
|
||||
|
||||
def register_mcp_server(
|
||||
self,
|
||||
server_config: dict[str, Any],
|
||||
@@ -395,6 +511,9 @@ class ToolRegistry:
|
||||
self._mcp_clients.append(client)
|
||||
|
||||
# Register each tool
|
||||
server_name = server_config["name"]
|
||||
if server_name not in self._mcp_server_tools:
|
||||
self._mcp_server_tools[server_name] = set()
|
||||
count = 0
|
||||
for mcp_tool in client.list_tools():
|
||||
# Convert MCP tool to framework Tool (strips context params from LLM schema)
|
||||
@@ -419,7 +538,15 @@ class ToolRegistry:
|
||||
filtered_context = {
|
||||
k: v for k, v in base_context.items() if k in tool_params
|
||||
}
|
||||
merged_inputs = {**filtered_context, **inputs}
|
||||
# Strip context params from LLM inputs — the framework
|
||||
# values are authoritative (prevents the LLM from passing
|
||||
# e.g. data_dir="/data" and overriding the real path).
|
||||
clean_inputs = {
|
||||
k: v
|
||||
for k, v in inputs.items()
|
||||
if k not in registry_ref.CONTEXT_PARAMS
|
||||
}
|
||||
merged_inputs = {**clean_inputs, **filtered_context}
|
||||
result = client_ref.call_tool(tool_name, merged_inputs)
|
||||
# MCP tools return content array, extract the result
|
||||
if isinstance(result, list) and len(result) > 0:
|
||||
@@ -439,6 +566,8 @@ class ToolRegistry:
|
||||
tool,
|
||||
make_mcp_executor(client, mcp_tool.name, self, tool_params),
|
||||
)
|
||||
self._mcp_tool_names.add(mcp_tool.name)
|
||||
self._mcp_server_tools[server_name].add(mcp_tool.name)
|
||||
count += 1
|
||||
|
||||
logger.info(f"Registered {count} tools from MCP server '{config.name}'")
|
||||
@@ -446,6 +575,11 @@ class ToolRegistry:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register MCP server: {e}")
|
||||
if "Connection closed" in str(e) and os.name == "nt":
|
||||
logger.debug(
|
||||
"On Windows, check that the MCP subprocess starts (e.g. uv in PATH, "
|
||||
"script path correct). Worker config uses base_dir = mcp_servers.json parent."
|
||||
)
|
||||
return 0
|
||||
|
||||
def _convert_mcp_tool_to_framework_tool(self, mcp_tool: Any) -> Tool:
|
||||
@@ -531,6 +665,67 @@ class ToolRegistry:
|
||||
all_names.update(names)
|
||||
return sorted(name for name in self._tools if name in all_names)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# MCP credential resync
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _snapshot_credentials(self) -> set[str]:
|
||||
"""Return the set of credential filenames currently on disk."""
|
||||
try:
|
||||
return set(self._CREDENTIAL_DIR.iterdir()) if self._CREDENTIAL_DIR.is_dir() else set()
|
||||
except OSError:
|
||||
return set()
|
||||
|
||||
def resync_mcp_servers_if_needed(self) -> bool:
|
||||
"""Restart MCP servers if credential files changed since last load.
|
||||
|
||||
Compares the current credential directory listing against the snapshot
|
||||
taken when MCP servers were first loaded. If new files appeared (e.g.
|
||||
user connected an OAuth account mid-session), disconnects all MCP
|
||||
clients and re-loads them so the new subprocess picks up the fresh
|
||||
credentials.
|
||||
|
||||
Returns True if a resync was performed, False otherwise.
|
||||
"""
|
||||
if not self._mcp_clients or self._mcp_config_path is None:
|
||||
return False
|
||||
|
||||
current = self._snapshot_credentials()
|
||||
current_aden_key = os.environ.get("ADEN_API_KEY")
|
||||
files_changed = current != self._mcp_cred_snapshot
|
||||
aden_key_changed = current_aden_key != self._mcp_aden_key_snapshot
|
||||
|
||||
if not files_changed and not aden_key_changed:
|
||||
return False
|
||||
|
||||
reason = (
|
||||
"Credential files and ADEN_API_KEY changed"
|
||||
if files_changed and aden_key_changed
|
||||
else "ADEN_API_KEY changed"
|
||||
if aden_key_changed
|
||||
else "Credential files changed"
|
||||
)
|
||||
logger.info("%s — resyncing MCP servers", reason)
|
||||
|
||||
# 1. Disconnect existing MCP clients
|
||||
for client in self._mcp_clients:
|
||||
try:
|
||||
client.disconnect()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error disconnecting MCP client during resync: {e}")
|
||||
self._mcp_clients.clear()
|
||||
|
||||
# 2. Remove MCP-registered tools
|
||||
for name in self._mcp_tool_names:
|
||||
self._tools.pop(name, None)
|
||||
self._mcp_tool_names.clear()
|
||||
|
||||
# 3. Re-load MCP servers (spawns fresh subprocesses with new credentials)
|
||||
self.load_mcp_config(self._mcp_config_path)
|
||||
|
||||
logger.info("MCP server resync complete")
|
||||
return True
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up all MCP client connections."""
|
||||
for client in self._mcp_clients:
|
||||
|
||||
@@ -411,7 +411,12 @@ class AgentRuntime:
|
||||
)
|
||||
continue
|
||||
|
||||
def _make_cron_timer(entry_point_id: str, expr: str, immediate: bool):
|
||||
def _make_cron_timer(
|
||||
entry_point_id: str,
|
||||
expr: str,
|
||||
immediate: bool,
|
||||
idle_timeout: float = 300,
|
||||
):
|
||||
async def _cron_loop():
|
||||
from croniter import croniter
|
||||
|
||||
@@ -442,11 +447,28 @@ class AgentRuntime:
|
||||
await asyncio.sleep(max(0, sleep_secs))
|
||||
continue
|
||||
|
||||
# Gate: skip tick if previous execution still running
|
||||
_stream = self._streams.get(entry_point_id)
|
||||
if _stream and _stream.active_execution_ids:
|
||||
logger.debug(
|
||||
"Cron '%s': execution already in progress, skipping tick",
|
||||
# Gate: skip tick if ANY stream is actively working.
|
||||
# If the execution is idle (no LLM/tool activity
|
||||
# beyond idle_timeout) let the timer proceed —
|
||||
# execute() will cancel the stale execution.
|
||||
_any_active = False
|
||||
_min_idle = float("inf")
|
||||
for _s in self._streams.values():
|
||||
if _s.active_execution_ids:
|
||||
_any_active = True
|
||||
_idle = _s.agent_idle_seconds
|
||||
if _idle < _min_idle:
|
||||
_min_idle = _idle
|
||||
logger.info(
|
||||
"Cron '%s': gate — active=%s, idle=%.1fs, timeout=%ds",
|
||||
entry_point_id,
|
||||
_any_active,
|
||||
_min_idle,
|
||||
idle_timeout,
|
||||
)
|
||||
if _any_active and _min_idle < idle_timeout:
|
||||
logger.info(
|
||||
"Cron '%s': agent actively working, skipping tick",
|
||||
entry_point_id,
|
||||
)
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
@@ -473,7 +495,7 @@ class AgentRuntime:
|
||||
# Gate: skip tick if no active session
|
||||
if session_state is None:
|
||||
logger.debug(
|
||||
"Cron '%s': no active primary session, skipping tick",
|
||||
"Cron '%s': no active session, skipping",
|
||||
entry_point_id,
|
||||
)
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
@@ -517,7 +539,12 @@ class AgentRuntime:
|
||||
return _cron_loop
|
||||
|
||||
task = asyncio.create_task(
|
||||
_make_cron_timer(ep_id, cron_expr, run_immediately)()
|
||||
_make_cron_timer(
|
||||
ep_id,
|
||||
cron_expr,
|
||||
run_immediately,
|
||||
idle_timeout=tc.get("idle_timeout_seconds", 300),
|
||||
)()
|
||||
)
|
||||
self._timer_tasks.append(task)
|
||||
logger.info(
|
||||
@@ -529,7 +556,12 @@ class AgentRuntime:
|
||||
|
||||
elif interval and interval > 0:
|
||||
# Fixed interval mode (original behavior)
|
||||
def _make_timer(entry_point_id: str, mins: float, immediate: bool):
|
||||
def _make_timer(
|
||||
entry_point_id: str,
|
||||
mins: float,
|
||||
immediate: bool,
|
||||
idle_timeout: float = 300,
|
||||
):
|
||||
async def _timer_loop():
|
||||
interval_secs = mins * 60
|
||||
_persistent_session_id: str | None = None
|
||||
@@ -551,11 +583,26 @@ class AgentRuntime:
|
||||
await asyncio.sleep(interval_secs)
|
||||
continue
|
||||
|
||||
# Gate: skip tick if previous execution still running
|
||||
_stream = self._streams.get(entry_point_id)
|
||||
if _stream and _stream.active_execution_ids:
|
||||
logger.debug(
|
||||
"Timer '%s': execution already in progress, skipping tick",
|
||||
# Gate: skip tick if agent is actively working.
|
||||
# Gate: skip tick if ANY stream is actively working.
|
||||
_any_active = False
|
||||
_min_idle = float("inf")
|
||||
for _s in self._streams.values():
|
||||
if _s.active_execution_ids:
|
||||
_any_active = True
|
||||
_idle = _s.agent_idle_seconds
|
||||
if _idle < _min_idle:
|
||||
_min_idle = _idle
|
||||
logger.info(
|
||||
"Timer '%s': gate — active=%s, idle=%.1fs, timeout=%ds",
|
||||
entry_point_id,
|
||||
_any_active,
|
||||
_min_idle,
|
||||
idle_timeout,
|
||||
)
|
||||
if _any_active and _min_idle < idle_timeout:
|
||||
logger.info(
|
||||
"Timer '%s': agent actively working, skipping tick",
|
||||
entry_point_id,
|
||||
)
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
@@ -582,7 +629,7 @@ class AgentRuntime:
|
||||
# Gate: skip tick if no active session
|
||||
if session_state is None:
|
||||
logger.debug(
|
||||
"Timer '%s': no active primary session, skipping tick",
|
||||
"Timer '%s': no active session, skipping",
|
||||
entry_point_id,
|
||||
)
|
||||
self._timer_next_fire[entry_point_id] = (
|
||||
@@ -621,7 +668,14 @@ class AgentRuntime:
|
||||
|
||||
return _timer_loop
|
||||
|
||||
task = asyncio.create_task(_make_timer(ep_id, interval, run_immediately)())
|
||||
task = asyncio.create_task(
|
||||
_make_timer(
|
||||
ep_id,
|
||||
interval,
|
||||
run_immediately,
|
||||
idle_timeout=tc.get("idle_timeout_seconds", 300),
|
||||
)()
|
||||
)
|
||||
self._timer_tasks.append(task)
|
||||
logger.info(
|
||||
"Started timer for entry point '%s' every %s min%s",
|
||||
@@ -961,6 +1015,7 @@ class AgentRuntime:
|
||||
local_ep: str,
|
||||
mins: float,
|
||||
immediate: bool,
|
||||
idle_timeout: float = 300,
|
||||
):
|
||||
async def _timer_loop():
|
||||
interval_secs = mins * 60
|
||||
@@ -990,12 +1045,28 @@ class AgentRuntime:
|
||||
await asyncio.sleep(interval_secs)
|
||||
continue
|
||||
|
||||
# Gate: skip tick if previous execution still running
|
||||
# Gate: skip tick if ANY stream in this graph is actively working.
|
||||
_reg = self._graphs.get(gid)
|
||||
_stream = _reg.streams.get(local_ep) if _reg else None
|
||||
if _stream and _stream.active_execution_ids:
|
||||
logger.debug(
|
||||
"Timer '%s::%s': execution already in progress, skipping tick",
|
||||
_any_active = False
|
||||
_min_idle = float("inf")
|
||||
if _reg:
|
||||
for _sid, _s in _reg.streams.items():
|
||||
if _s.active_execution_ids:
|
||||
_any_active = True
|
||||
_idle = _s.agent_idle_seconds
|
||||
if _idle < _min_idle:
|
||||
_min_idle = _idle
|
||||
logger.info(
|
||||
"Timer '%s::%s': gate — active=%s, idle=%.1fs, timeout=%ds",
|
||||
gid,
|
||||
local_ep,
|
||||
_any_active,
|
||||
_min_idle,
|
||||
idle_timeout,
|
||||
)
|
||||
if _any_active and _min_idle < idle_timeout:
|
||||
logger.info(
|
||||
"Timer '%s::%s': agent actively working, skipping tick",
|
||||
gid,
|
||||
local_ep,
|
||||
)
|
||||
@@ -1033,7 +1104,7 @@ class AgentRuntime:
|
||||
# Gate: skip tick if no active session
|
||||
if session_state is None:
|
||||
logger.debug(
|
||||
"Timer '%s::%s': no active primary session, skipping tick",
|
||||
"Timer '%s::%s': no active session, skipping",
|
||||
gid,
|
||||
local_ep,
|
||||
)
|
||||
@@ -1066,7 +1137,13 @@ class AgentRuntime:
|
||||
return _timer_loop
|
||||
|
||||
task = asyncio.create_task(
|
||||
_make_timer(graph_id, ep_id, interval, run_immediately)()
|
||||
_make_timer(
|
||||
graph_id,
|
||||
ep_id,
|
||||
interval,
|
||||
run_immediately,
|
||||
idle_timeout=tc.get("idle_timeout_seconds", 300),
|
||||
)()
|
||||
)
|
||||
timer_tasks.append(task)
|
||||
logger.info("Timer task created for '%s::%s': %s", graph_id, ep_id, task)
|
||||
@@ -1174,10 +1251,61 @@ class AgentRuntime:
|
||||
return float("inf")
|
||||
return time.monotonic() - self._last_user_input_time
|
||||
|
||||
@property
|
||||
def agent_idle_seconds(self) -> float:
|
||||
"""Seconds since any stream last had activity (LLM call, tool call, etc.).
|
||||
|
||||
Returns the *minimum* idle time across all streams with active
|
||||
executions. Returns ``float('inf')`` if nothing is running.
|
||||
"""
|
||||
min_idle = float("inf")
|
||||
for reg in self._graphs.values():
|
||||
for stream in reg.streams.values():
|
||||
idle = stream.agent_idle_seconds
|
||||
if idle < min_idle:
|
||||
min_idle = idle
|
||||
return min_idle
|
||||
|
||||
def get_graph_registration(self, graph_id: str) -> _GraphRegistration | None:
|
||||
"""Get the registration for a specific graph (or None)."""
|
||||
return self._graphs.get(graph_id)
|
||||
|
||||
def cancel_all_tasks(self, loop: asyncio.AbstractEventLoop) -> bool:
|
||||
"""Cancel all running execution tasks across all graphs.
|
||||
|
||||
Schedules the cancellation on *loop* (the agent event loop) so
|
||||
that ``_execution_tasks`` is only read from the thread that owns
|
||||
it, avoiding cross-thread dict access. Safe to call from any
|
||||
thread (e.g. the Textual UI thread).
|
||||
|
||||
Blocks the caller for up to 5 seconds waiting for the result.
|
||||
For async callers, use :meth:`cancel_all_tasks_async` instead.
|
||||
"""
|
||||
future = asyncio.run_coroutine_threadsafe(self.cancel_all_tasks_async(), loop)
|
||||
try:
|
||||
return future.result(timeout=5)
|
||||
except Exception:
|
||||
logger.warning("cancel_all_tasks: timed out or failed")
|
||||
return False
|
||||
|
||||
async def cancel_all_tasks_async(self) -> bool:
|
||||
"""Cancel all running execution tasks (runs on the agent loop).
|
||||
|
||||
Iterates ``_execution_tasks`` and calls ``task.cancel()`` directly.
|
||||
Must be awaited on the agent event loop so dict access is
|
||||
thread-safe. Returns True if at least one task was cancelled.
|
||||
"""
|
||||
cancelled = False
|
||||
for gid in self.list_graphs():
|
||||
reg = self.get_graph_registration(gid)
|
||||
if reg:
|
||||
for stream in reg.streams.values():
|
||||
for task in list(stream._execution_tasks.values()):
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
cancelled = True
|
||||
return cancelled
|
||||
|
||||
def _get_primary_session_state(
|
||||
self,
|
||||
exclude_entry_point: str,
|
||||
@@ -1368,6 +1496,23 @@ class AgentRuntime:
|
||||
# Fallback: primary graph
|
||||
return list(self._entry_points.values())
|
||||
|
||||
def get_timer_next_fire_in(self, entry_point_id: str) -> float | None:
|
||||
"""Return seconds until the next timer fire for *entry_point_id*.
|
||||
|
||||
Checks the primary graph's ``_timer_next_fire`` dict as well as
|
||||
all registered secondary graphs. Returns ``None`` when no fire
|
||||
time is recorded (e.g. the timer is currently executing or the
|
||||
entry point is not a timer).
|
||||
"""
|
||||
mono = self._timer_next_fire.get(entry_point_id)
|
||||
if mono is not None:
|
||||
return max(0.0, mono - time.monotonic())
|
||||
for reg in self._graphs.values():
|
||||
mono = reg.timer_next_fire.get(entry_point_id)
|
||||
if mono is not None:
|
||||
return max(0.0, mono - time.monotonic())
|
||||
return None
|
||||
|
||||
def get_stream(self, entry_point_id: str) -> ExecutionStream | None:
|
||||
"""Get a specific execution stream."""
|
||||
return self._streams.get(entry_point_id)
|
||||
|
||||
@@ -88,6 +88,7 @@ class EventType(StrEnum):
|
||||
# LLM streaming observability
|
||||
LLM_TEXT_DELTA = "llm_text_delta"
|
||||
LLM_REASONING_DELTA = "llm_reasoning_delta"
|
||||
LLM_TURN_COMPLETE = "llm_turn_complete"
|
||||
|
||||
# Tool lifecycle
|
||||
TOOL_CALL_STARTED = "tool_call_started"
|
||||
@@ -129,8 +130,18 @@ class EventType(StrEnum):
|
||||
WORKER_ESCALATION_TICKET = "worker_escalation_ticket"
|
||||
QUEEN_INTERVENTION_REQUESTED = "queen_intervention_requested"
|
||||
|
||||
# Execution resurrection (auto-restart on non-fatal failure)
|
||||
EXECUTION_RESURRECTED = "execution_resurrected"
|
||||
|
||||
# Worker lifecycle (session manager → frontend)
|
||||
WORKER_LOADED = "worker_loaded"
|
||||
CREDENTIALS_REQUIRED = "credentials_required"
|
||||
|
||||
# Queen mode changes (building ↔ running)
|
||||
QUEEN_MODE_CHANGED = "queen_mode_changed"
|
||||
|
||||
# Subagent reports (one-way progress updates from sub-agents)
|
||||
SUBAGENT_REPORT = "subagent_report"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -594,6 +605,36 @@ class EventBus:
|
||||
)
|
||||
)
|
||||
|
||||
async def emit_llm_turn_complete(
|
||||
self,
|
||||
stream_id: str,
|
||||
node_id: str,
|
||||
stop_reason: str,
|
||||
model: str,
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
execution_id: str | None = None,
|
||||
iteration: int | None = None,
|
||||
) -> None:
|
||||
"""Emit LLM turn completion with stop reason and model metadata."""
|
||||
data: dict = {
|
||||
"stop_reason": stop_reason,
|
||||
"model": model,
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
}
|
||||
if iteration is not None:
|
||||
data["iteration"] = iteration
|
||||
await self.publish(
|
||||
AgentEvent(
|
||||
type=EventType.LLM_TURN_COMPLETE,
|
||||
stream_id=stream_id,
|
||||
node_id=node_id,
|
||||
execution_id=execution_id,
|
||||
data=data,
|
||||
)
|
||||
)
|
||||
|
||||
# === TOOL LIFECYCLE PUBLISHERS ===
|
||||
|
||||
async def emit_tool_call_started(
|
||||
@@ -655,15 +696,19 @@ class EventBus:
|
||||
content: str,
|
||||
snapshot: str,
|
||||
execution_id: str | None = None,
|
||||
iteration: int | None = None,
|
||||
) -> None:
|
||||
"""Emit client output delta event (client_facing=True nodes)."""
|
||||
data: dict = {"content": content, "snapshot": snapshot}
|
||||
if iteration is not None:
|
||||
data["iteration"] = iteration
|
||||
await self.publish(
|
||||
AgentEvent(
|
||||
type=EventType.CLIENT_OUTPUT_DELTA,
|
||||
stream_id=stream_id,
|
||||
node_id=node_id,
|
||||
execution_id=execution_id,
|
||||
data={"content": content, "snapshot": snapshot},
|
||||
data=data,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -673,15 +718,24 @@ class EventBus:
|
||||
node_id: str,
|
||||
prompt: str = "",
|
||||
execution_id: str | None = None,
|
||||
options: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Emit client input requested event (client_facing=True nodes)."""
|
||||
"""Emit client input requested event (client_facing=True nodes).
|
||||
|
||||
Args:
|
||||
options: Optional predefined choices for the user (1-3 items).
|
||||
The frontend appends an "Other" free-text option automatically.
|
||||
"""
|
||||
data: dict[str, Any] = {"prompt": prompt}
|
||||
if options:
|
||||
data["options"] = options
|
||||
await self.publish(
|
||||
AgentEvent(
|
||||
type=EventType.CLIENT_INPUT_REQUESTED,
|
||||
stream_id=stream_id,
|
||||
node_id=node_id,
|
||||
execution_id=execution_id,
|
||||
data={"prompt": prompt},
|
||||
data=data,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -976,6 +1030,30 @@ class EventBus:
|
||||
)
|
||||
)
|
||||
|
||||
async def emit_subagent_report(
|
||||
self,
|
||||
stream_id: str,
|
||||
node_id: str,
|
||||
subagent_id: str,
|
||||
message: str,
|
||||
data: dict[str, Any] | None = None,
|
||||
execution_id: str | None = None,
|
||||
) -> None:
|
||||
"""Emit a one-way progress report from a sub-agent."""
|
||||
await self.publish(
|
||||
AgentEvent(
|
||||
type=EventType.SUBAGENT_REPORT,
|
||||
stream_id=stream_id,
|
||||
node_id=node_id,
|
||||
execution_id=execution_id,
|
||||
data={
|
||||
"subagent_id": subagent_id,
|
||||
"message": message,
|
||||
"data": data,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# === QUERY OPERATIONS ===
|
||||
|
||||
def get_history(
|
||||
|
||||
@@ -32,6 +32,19 @@ if TYPE_CHECKING:
|
||||
from framework.storage.concurrent import ConcurrentStorage
|
||||
from framework.storage.session_store import SessionStore
|
||||
|
||||
|
||||
class ExecutionAlreadyRunningError(RuntimeError):
|
||||
"""Raised when attempting to start an execution on a stream that already has one running."""
|
||||
|
||||
def __init__(self, stream_id: str, active_ids: list[str]):
|
||||
self.stream_id = stream_id
|
||||
self.active_ids = active_ids
|
||||
super().__init__(
|
||||
f"Stream '{stream_id}' already has an active execution: {active_ids}. "
|
||||
"Concurrent executions on the same stream are not allowed."
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -56,9 +69,11 @@ class GraphScopedEventBus(EventBus):
|
||||
# (subscriptions, history, semaphore, etc.) to the real bus.
|
||||
self._real_bus = bus
|
||||
self._scope_graph_id = graph_id
|
||||
self.last_activity_time: float = time.monotonic()
|
||||
|
||||
async def publish(self, event: "AgentEvent") -> None: # type: ignore[override]
|
||||
event.graph_id = self._scope_graph_id
|
||||
self.last_activity_time = time.monotonic()
|
||||
await self._real_bus.publish(event)
|
||||
|
||||
# --- Delegate state-reading methods to the real bus ---
|
||||
@@ -93,6 +108,7 @@ class EntryPointSpec:
|
||||
isolation_level: str = "shared" # "isolated" | "shared" | "synchronized"
|
||||
priority: int = 0
|
||||
max_concurrent: int = 10 # Max concurrent executions for this entry point
|
||||
max_resurrections: int = 3 # Auto-restart on non-fatal failure (0 to disable)
|
||||
|
||||
def get_isolation_level(self) -> IsolationLevel:
|
||||
"""Convert string isolation level to enum."""
|
||||
@@ -233,9 +249,11 @@ class ExecutionStream:
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# Graph-scoped event bus (stamps graph_id on published events)
|
||||
self._scoped_event_bus = self._event_bus
|
||||
if self._event_bus and self.graph_id:
|
||||
self._scoped_event_bus = GraphScopedEventBus(self._event_bus, self.graph_id)
|
||||
# Always wrap in GraphScopedEventBus so we can track last_activity_time.
|
||||
if self._event_bus:
|
||||
self._scoped_event_bus = GraphScopedEventBus(self._event_bus, self.graph_id or "")
|
||||
else:
|
||||
self._scoped_event_bus = None
|
||||
|
||||
# State
|
||||
self._running = False
|
||||
@@ -265,6 +283,21 @@ class ExecutionStream:
|
||||
"""Return IDs of all currently active executions."""
|
||||
return list(self._active_executions.keys())
|
||||
|
||||
@property
|
||||
def agent_idle_seconds(self) -> float:
|
||||
"""Seconds since the last agent activity (LLM call, tool call, node transition).
|
||||
|
||||
Returns ``float('inf')`` if no event bus is attached or no events have
|
||||
been published yet. When there are no active executions, also returns
|
||||
``float('inf')`` (nothing to be idle *about*).
|
||||
"""
|
||||
if not self._active_executions:
|
||||
return float("inf")
|
||||
bus = self._scoped_event_bus
|
||||
if isinstance(bus, GraphScopedEventBus):
|
||||
return time.monotonic() - bus.last_activity_time
|
||||
return float("inf")
|
||||
|
||||
@property
|
||||
def is_awaiting_input(self) -> bool:
|
||||
"""True when an active execution is blocked waiting for client input."""
|
||||
@@ -292,13 +325,21 @@ class ExecutionStream:
|
||||
"""Return nodes that support message injection (have ``inject_event``).
|
||||
|
||||
Each entry is ``{"node_id": ..., "execution_id": ...}``.
|
||||
The currently executing node is placed first so that
|
||||
``inject_worker_message`` targets the active node, not a stale one.
|
||||
"""
|
||||
injectable: list[dict[str, str]] = []
|
||||
current_first: list[dict[str, str]] = []
|
||||
for exec_id, executor in self._active_executors.items():
|
||||
current = getattr(executor, "current_node_id", None)
|
||||
for node_id, node in executor.node_registry.items():
|
||||
if hasattr(node, "inject_event"):
|
||||
injectable.append({"node_id": node_id, "execution_id": exec_id})
|
||||
return injectable
|
||||
entry = {"node_id": node_id, "execution_id": exec_id}
|
||||
if node_id == current:
|
||||
current_first.append(entry)
|
||||
else:
|
||||
injectable.append(entry)
|
||||
return current_first + injectable
|
||||
|
||||
def _record_execution_result(self, execution_id: str, result: ExecutionResult) -> None:
|
||||
"""Record a completed execution result with retention pruning."""
|
||||
@@ -329,20 +370,21 @@ class ExecutionStream:
|
||||
self._running = False
|
||||
|
||||
# Cancel all active executions
|
||||
tasks_to_wait = []
|
||||
for _, task in self._execution_tasks.items():
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except RuntimeError as e:
|
||||
# Task may be attached to a different event loop (e.g., when TUI
|
||||
# uses a separate loop). Log and continue cleanup.
|
||||
if "attached to a different loop" in str(e):
|
||||
logger.warning(f"Task cleanup skipped (different event loop): {e}")
|
||||
else:
|
||||
raise
|
||||
tasks_to_wait.append(task)
|
||||
|
||||
if tasks_to_wait:
|
||||
# Wait briefly — don't block indefinitely if tasks are stuck
|
||||
# in long-running operations (LLM calls, tool executions).
|
||||
_, pending = await asyncio.wait(tasks_to_wait, timeout=5.0)
|
||||
if pending:
|
||||
logger.warning(
|
||||
"%d execution task(s) did not finish within 5s after cancellation",
|
||||
len(pending),
|
||||
)
|
||||
|
||||
self._execution_tasks.clear()
|
||||
self._active_executions.clear()
|
||||
@@ -403,6 +445,27 @@ class ExecutionStream:
|
||||
if not self._running:
|
||||
raise RuntimeError(f"ExecutionStream '{self.stream_id}' is not running")
|
||||
|
||||
# Only one execution may run on a stream at a time — concurrent
|
||||
# executions corrupt shared session state. Cancel any running
|
||||
# execution before starting the new one. The cancelled execution
|
||||
# writes its state to disk before cleanup, and the new execution
|
||||
# runs in the same session directory (via resume_session_id).
|
||||
active = self.active_execution_ids
|
||||
for eid in active:
|
||||
logger.info(
|
||||
"Cancelling running execution %s on stream '%s' before starting new one",
|
||||
eid,
|
||||
self.stream_id,
|
||||
)
|
||||
executor = self._active_executors.get(eid)
|
||||
if executor:
|
||||
for node in executor.node_registry.values():
|
||||
if hasattr(node, "signal_shutdown"):
|
||||
node.signal_shutdown()
|
||||
if hasattr(node, "cancel_current_turn"):
|
||||
node.cancel_current_turn()
|
||||
await self.cancel_execution(eid)
|
||||
|
||||
# When resuming, reuse the original session ID so the execution
|
||||
# continues in the same session directory instead of creating a new one.
|
||||
resume_session_id = session_state.get("resume_session_id") if session_state else None
|
||||
@@ -448,8 +511,44 @@ class ExecutionStream:
|
||||
logger.debug(f"Queued execution {execution_id} for stream {self.stream_id}")
|
||||
return execution_id
|
||||
|
||||
# Errors that indicate resurrection won't help — the same error will recur.
|
||||
# Includes both configuration/environment errors and deterministic node
|
||||
# failures where the conversation/state hasn't changed.
|
||||
_FATAL_ERROR_PATTERNS: tuple[str, ...] = (
|
||||
# Configuration / environment
|
||||
"credential",
|
||||
"authentication",
|
||||
"unauthorized",
|
||||
"forbidden",
|
||||
"api key",
|
||||
"import error",
|
||||
"module not found",
|
||||
"no module named",
|
||||
"permission denied",
|
||||
"invalid api",
|
||||
"configuration error",
|
||||
# Deterministic node failures — resurrecting at the same node with
|
||||
# the same conversation produces the same result.
|
||||
"node stalled",
|
||||
"ghost empty stream",
|
||||
"max iterations",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _is_fatal_error(cls, error: str | None) -> bool:
|
||||
"""Return True if the error is life-threatening (no point resurrecting)."""
|
||||
if not error:
|
||||
return False
|
||||
error_lower = error.lower()
|
||||
return any(pat in error_lower for pat in cls._FATAL_ERROR_PATTERNS)
|
||||
|
||||
async def _run_execution(self, ctx: ExecutionContext) -> None:
|
||||
"""Run a single execution within the stream."""
|
||||
"""Run a single execution within the stream.
|
||||
|
||||
Supports automatic resurrection: when the execution fails with a
|
||||
non-fatal error, it restarts from the failed node up to
|
||||
``entry_spec.max_resurrections`` times (default 3).
|
||||
"""
|
||||
execution_id = ctx.id
|
||||
|
||||
# When sharing a session with another entry point (resume_session_id),
|
||||
@@ -457,6 +556,11 @@ class ExecutionStream:
|
||||
# owns the state.json and _write_progress() keeps memory up-to-date.
|
||||
_is_shared_session = bool(ctx.session_state and ctx.session_state.get("resume_session_id"))
|
||||
|
||||
max_resurrections = self.entry_spec.max_resurrections
|
||||
_resurrection_count = 0
|
||||
_current_session_state = ctx.session_state
|
||||
_current_input_data = ctx.input_data
|
||||
|
||||
# Acquire semaphore to limit concurrency
|
||||
async with self._semaphore:
|
||||
ctx.status = "running"
|
||||
@@ -497,12 +601,6 @@ class ExecutionStream:
|
||||
store=self._runtime_log_store, agent_id=self.graph.id
|
||||
)
|
||||
|
||||
# Create executor for this execution.
|
||||
# Each execution gets its own storage under sessions/{exec_id}/
|
||||
# so conversations, spillover, and data files are all scoped
|
||||
# to this execution. The executor sets data_dir via execution
|
||||
# context (contextvars) so data tools and spillover share the
|
||||
# same session-scoped directory.
|
||||
# Derive storage from session_store (graph-specific for secondary
|
||||
# graphs) so that all files — conversations, state, checkpoints,
|
||||
# data — land under the graph's own sessions/ directory, not the
|
||||
@@ -511,43 +609,106 @@ class ExecutionStream:
|
||||
exec_storage = self._session_store.sessions_dir / execution_id
|
||||
else:
|
||||
exec_storage = self._storage.base_path / "sessions" / execution_id
|
||||
executor = GraphExecutor(
|
||||
runtime=runtime_adapter,
|
||||
llm=self._llm,
|
||||
tools=self._tools,
|
||||
tool_executor=self._tool_executor,
|
||||
event_bus=self._scoped_event_bus,
|
||||
stream_id=self.stream_id,
|
||||
execution_id=execution_id,
|
||||
storage_path=exec_storage,
|
||||
runtime_logger=runtime_logger,
|
||||
loop_config=self.graph.loop_config,
|
||||
accounts_prompt=self._accounts_prompt,
|
||||
accounts_data=self._accounts_data,
|
||||
tool_provider_map=self._tool_provider_map,
|
||||
)
|
||||
# Track executor so inject_input() can reach EventLoopNode instances
|
||||
self._active_executors[execution_id] = executor
|
||||
|
||||
# Write initial session state
|
||||
if not _is_shared_session:
|
||||
await self._write_session_state(execution_id, ctx)
|
||||
|
||||
# Create modified graph with entry point
|
||||
# We need to override the entry_node to use our entry point
|
||||
modified_graph = self._create_modified_graph()
|
||||
|
||||
# Execute
|
||||
result = await executor.execute(
|
||||
graph=modified_graph,
|
||||
goal=self.goal,
|
||||
input_data=ctx.input_data,
|
||||
session_state=ctx.session_state,
|
||||
checkpoint_config=self._checkpoint_config,
|
||||
)
|
||||
# Write initial session state
|
||||
if not _is_shared_session:
|
||||
await self._write_session_state(execution_id, ctx)
|
||||
|
||||
# Clean up executor reference
|
||||
self._active_executors.pop(execution_id, None)
|
||||
# --- Resurrection loop ---
|
||||
# Each iteration creates a fresh executor. On non-fatal failure,
|
||||
# the executor's session_state (memory + resume_from) carries
|
||||
# forward so the next attempt resumes at the failed node.
|
||||
while True:
|
||||
# Create executor for this execution.
|
||||
# Each execution gets its own storage under sessions/{exec_id}/
|
||||
# so conversations, spillover, and data files are all scoped
|
||||
# to this execution. The executor sets data_dir via execution
|
||||
# context (contextvars) so data tools and spillover share the
|
||||
# same session-scoped directory.
|
||||
executor = GraphExecutor(
|
||||
runtime=runtime_adapter,
|
||||
llm=self._llm,
|
||||
tools=self._tools,
|
||||
tool_executor=self._tool_executor,
|
||||
event_bus=self._scoped_event_bus,
|
||||
stream_id=self.stream_id,
|
||||
execution_id=execution_id,
|
||||
storage_path=exec_storage,
|
||||
runtime_logger=runtime_logger,
|
||||
loop_config=self.graph.loop_config,
|
||||
accounts_prompt=self._accounts_prompt,
|
||||
accounts_data=self._accounts_data,
|
||||
tool_provider_map=self._tool_provider_map,
|
||||
)
|
||||
# Track executor so inject_input() can reach EventLoopNode instances
|
||||
self._active_executors[execution_id] = executor
|
||||
|
||||
# Execute
|
||||
result = await executor.execute(
|
||||
graph=modified_graph,
|
||||
goal=self.goal,
|
||||
input_data=_current_input_data,
|
||||
session_state=_current_session_state,
|
||||
checkpoint_config=self._checkpoint_config,
|
||||
)
|
||||
|
||||
# Clean up executor reference
|
||||
self._active_executors.pop(execution_id, None)
|
||||
|
||||
# Check if resurrection is appropriate
|
||||
if (
|
||||
not result.success
|
||||
and not result.paused_at
|
||||
and _resurrection_count < max_resurrections
|
||||
and result.session_state
|
||||
and not self._is_fatal_error(result.error)
|
||||
):
|
||||
_resurrection_count += 1
|
||||
logger.warning(
|
||||
"Execution %s failed (%s) — resurrecting (%d/%d) from node '%s'",
|
||||
execution_id,
|
||||
(result.error or "unknown")[:200],
|
||||
_resurrection_count,
|
||||
max_resurrections,
|
||||
result.session_state.get("resume_from", "?"),
|
||||
)
|
||||
|
||||
# Emit resurrection event
|
||||
if self._scoped_event_bus:
|
||||
from framework.runtime.event_bus import AgentEvent, EventType
|
||||
|
||||
await self._scoped_event_bus.publish(
|
||||
AgentEvent(
|
||||
type=EventType.EXECUTION_RESURRECTED,
|
||||
stream_id=self.stream_id,
|
||||
execution_id=execution_id,
|
||||
data={
|
||||
"attempt": _resurrection_count,
|
||||
"max_resurrections": max_resurrections,
|
||||
"error": (result.error or "")[:500],
|
||||
"resume_from": result.session_state.get("resume_from"),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Resume from the failed node with preserved memory
|
||||
_current_session_state = {
|
||||
**result.session_state,
|
||||
"resume_session_id": execution_id,
|
||||
}
|
||||
# On resurrection, input_data is already in memory —
|
||||
# pass empty so we don't overwrite intermediate results.
|
||||
_current_input_data = {}
|
||||
|
||||
# Brief cooldown before resurrection
|
||||
await asyncio.sleep(2.0)
|
||||
continue
|
||||
|
||||
break # success, fatal failure, or resurrections exhausted
|
||||
|
||||
# Store result with retention
|
||||
self._record_execution_result(execution_id, result)
|
||||
@@ -569,8 +730,7 @@ class ExecutionStream:
|
||||
if not _is_shared_session:
|
||||
await self._write_session_state(execution_id, ctx, result=result)
|
||||
|
||||
# Emit completion/failure event
|
||||
# (skip for pauses — executor already emitted execution_paused)
|
||||
# Emit completion/failure/pause event
|
||||
if self._scoped_event_bus:
|
||||
if result.success:
|
||||
await self._scoped_event_bus.emit_execution_completed(
|
||||
@@ -579,7 +739,17 @@ class ExecutionStream:
|
||||
output=result.output,
|
||||
correlation_id=ctx.correlation_id,
|
||||
)
|
||||
elif not result.paused_at:
|
||||
elif result.paused_at:
|
||||
# The executor returns paused_at on CancelledError but
|
||||
# does NOT emit execution_paused itself — we must emit
|
||||
# it here so the frontend can transition out of "running".
|
||||
await self._scoped_event_bus.emit_execution_paused(
|
||||
stream_id=self.stream_id,
|
||||
node_id=result.paused_at,
|
||||
reason=result.error or "Execution paused",
|
||||
execution_id=execution_id,
|
||||
)
|
||||
else:
|
||||
await self._scoped_event_bus.emit_execution_failed(
|
||||
stream_id=self.stream_id,
|
||||
execution_id=execution_id,
|
||||
@@ -628,6 +798,25 @@ class ExecutionStream:
|
||||
execution_id, ctx, error="Execution cancelled"
|
||||
)
|
||||
|
||||
# Emit SSE event so the frontend knows the execution stopped.
|
||||
# The executor does NOT emit on CancelledError, so there is no
|
||||
# risk of double-emitting.
|
||||
if self._scoped_event_bus:
|
||||
if has_result and result.paused_at:
|
||||
await self._scoped_event_bus.emit_execution_paused(
|
||||
stream_id=self.stream_id,
|
||||
node_id=result.paused_at,
|
||||
reason="Execution cancelled",
|
||||
execution_id=execution_id,
|
||||
)
|
||||
else:
|
||||
await self._scoped_event_bus.emit_execution_failed(
|
||||
stream_id=self.stream_id,
|
||||
execution_id=execution_id,
|
||||
error="Execution cancelled",
|
||||
correlation_id=ctx.correlation_id,
|
||||
)
|
||||
|
||||
# Don't re-raise - we've handled it and saved state
|
||||
|
||||
except Exception as e:
|
||||
@@ -878,10 +1067,11 @@ class ExecutionStream:
|
||||
task = self._execution_tasks.get(execution_id)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
# Wait briefly for the task to finish. Don't block indefinitely —
|
||||
# the task may be stuck in a long LLM API call that doesn't
|
||||
# respond to cancellation quickly. The cancellation is already
|
||||
# requested; the task will clean up in the background.
|
||||
done, _ = await asyncio.wait({task}, timeout=5.0)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""HIVE_LLM_DEBUG — write every LLM turn to a JSONL file for replay/debugging.
|
||||
|
||||
Set the env var to enable:
|
||||
HIVE_LLM_DEBUG=1 → writes to ~/.hive/llm_logs/<ts>.jsonl
|
||||
HIVE_LLM_DEBUG=/some/path → writes to that directory
|
||||
|
||||
Each line is a JSON object with the full LLM turn: assistant text, tool calls,
|
||||
tool results, and token counts. The file is opened lazily on first call and
|
||||
flushed after every write. Errors are silently swallowed — this must never
|
||||
break the agent.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import IO, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LLM_DEBUG_RAW = os.environ.get("HIVE_LLM_DEBUG", "").strip()
|
||||
_LLM_DEBUG_ENABLED = _LLM_DEBUG_RAW.lower() in ("1", "true") or (
|
||||
bool(_LLM_DEBUG_RAW) and _LLM_DEBUG_RAW.lower() not in ("0", "false", "")
|
||||
)
|
||||
|
||||
_log_file: IO[str] | None = None
|
||||
_log_ready = False # lazy init guard
|
||||
|
||||
|
||||
def _open_log() -> IO[str] | None:
|
||||
"""Open a JSONL log file. Returns None if disabled."""
|
||||
if not _LLM_DEBUG_ENABLED:
|
||||
return None
|
||||
raw = _LLM_DEBUG_RAW
|
||||
if raw.lower() in ("1", "true"):
|
||||
log_dir = Path.home() / ".hive" / "llm_logs"
|
||||
else:
|
||||
log_dir = Path(raw)
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
path = log_dir / f"{ts}.jsonl"
|
||||
logger.info("LLM debug log → %s", path)
|
||||
return open(path, "a", encoding="utf-8") # noqa: SIM115
|
||||
|
||||
|
||||
def log_llm_turn(
|
||||
*,
|
||||
node_id: str,
|
||||
stream_id: str,
|
||||
execution_id: str,
|
||||
iteration: int,
|
||||
assistant_text: str,
|
||||
tool_calls: list[dict[str, Any]],
|
||||
tool_results: list[dict[str, Any]],
|
||||
token_counts: dict[str, Any],
|
||||
) -> None:
|
||||
"""Write one JSONL line capturing a complete LLM turn.
|
||||
|
||||
No-op when HIVE_LLM_DEBUG is not set. Never raises.
|
||||
"""
|
||||
if not _LLM_DEBUG_ENABLED:
|
||||
return
|
||||
try:
|
||||
global _log_file, _log_ready # noqa: PLW0603
|
||||
if not _log_ready:
|
||||
_log_file = _open_log()
|
||||
_log_ready = True
|
||||
if _log_file is None:
|
||||
return
|
||||
record = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"node_id": node_id,
|
||||
"stream_id": stream_id,
|
||||
"execution_id": execution_id,
|
||||
"iteration": iteration,
|
||||
"assistant_text": assistant_text,
|
||||
"tool_calls": tool_calls,
|
||||
"tool_results": tool_results,
|
||||
"token_counts": token_counts,
|
||||
}
|
||||
_log_file.write(json.dumps(record, default=str) + "\n")
|
||||
_log_file.flush()
|
||||
except Exception:
|
||||
pass # never break the agent
|
||||
@@ -24,6 +24,8 @@ class ToolCallLog(BaseModel):
|
||||
tool_input: dict[str, Any] = Field(default_factory=dict)
|
||||
result: str = ""
|
||||
is_error: bool = False
|
||||
start_timestamp: str = "" # ISO 8601 timestamp when tool execution started
|
||||
duration_s: float = 0.0 # Wall-clock execution time in seconds
|
||||
|
||||
|
||||
class NodeStepLog(BaseModel):
|
||||
|
||||
@@ -114,6 +114,8 @@ class RuntimeLogger:
|
||||
tool_input=tc.get("tool_input", {}),
|
||||
result=tc.get("content", ""),
|
||||
is_error=tc.get("is_error", False),
|
||||
start_timestamp=tc.get("start_timestamp", ""),
|
||||
duration_s=tc.get("duration_s", 0.0),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -821,5 +821,148 @@ class TestTimerEntryPoints:
|
||||
await runtime.stop()
|
||||
|
||||
|
||||
# === Cancel All Tasks Tests ===
|
||||
|
||||
|
||||
class TestCancelAllTasks:
|
||||
"""Tests for cancel_all_tasks and cancel_all_tasks_async."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_all_tasks_async_returns_false_when_no_tasks(
|
||||
self, sample_graph, sample_goal, temp_storage
|
||||
):
|
||||
"""Test that cancel_all_tasks_async returns False with no running tasks."""
|
||||
runtime = AgentRuntime(
|
||||
graph=sample_graph,
|
||||
goal=sample_goal,
|
||||
storage_path=temp_storage,
|
||||
)
|
||||
|
||||
entry_spec = EntryPointSpec(
|
||||
id="webhook",
|
||||
name="Webhook",
|
||||
entry_node="process-webhook",
|
||||
trigger_type="webhook",
|
||||
)
|
||||
runtime.register_entry_point(entry_spec)
|
||||
await runtime.start()
|
||||
|
||||
try:
|
||||
result = await runtime.cancel_all_tasks_async()
|
||||
assert result is False
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_all_tasks_async_cancels_running_task(
|
||||
self, sample_graph, sample_goal, temp_storage
|
||||
):
|
||||
"""Test that cancel_all_tasks_async cancels a running task and returns True."""
|
||||
runtime = AgentRuntime(
|
||||
graph=sample_graph,
|
||||
goal=sample_goal,
|
||||
storage_path=temp_storage,
|
||||
)
|
||||
|
||||
entry_spec = EntryPointSpec(
|
||||
id="webhook",
|
||||
name="Webhook",
|
||||
entry_node="process-webhook",
|
||||
trigger_type="webhook",
|
||||
)
|
||||
runtime.register_entry_point(entry_spec)
|
||||
await runtime.start()
|
||||
|
||||
try:
|
||||
# Inject a fake running task into the stream
|
||||
stream = runtime._streams["webhook"]
|
||||
|
||||
async def hang_forever():
|
||||
await asyncio.get_event_loop().create_future()
|
||||
|
||||
fake_task = asyncio.ensure_future(hang_forever())
|
||||
stream._execution_tasks["fake-exec"] = fake_task
|
||||
|
||||
result = await runtime.cancel_all_tasks_async()
|
||||
assert result is True
|
||||
|
||||
# Let the CancelledError propagate
|
||||
try:
|
||||
await fake_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
assert fake_task.cancelled()
|
||||
|
||||
# Clean up
|
||||
del stream._execution_tasks["fake-exec"]
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_all_tasks_async_cancels_multiple_tasks_across_streams(
|
||||
self, sample_graph, sample_goal, temp_storage
|
||||
):
|
||||
"""Test that cancel_all_tasks_async cancels tasks across multiple streams."""
|
||||
runtime = AgentRuntime(
|
||||
graph=sample_graph,
|
||||
goal=sample_goal,
|
||||
storage_path=temp_storage,
|
||||
)
|
||||
|
||||
# Register two entry points so we get two streams
|
||||
runtime.register_entry_point(
|
||||
EntryPointSpec(
|
||||
id="stream-a",
|
||||
name="Stream A",
|
||||
entry_node="process-webhook",
|
||||
trigger_type="webhook",
|
||||
)
|
||||
)
|
||||
runtime.register_entry_point(
|
||||
EntryPointSpec(
|
||||
id="stream-b",
|
||||
name="Stream B",
|
||||
entry_node="process-webhook",
|
||||
trigger_type="webhook",
|
||||
)
|
||||
)
|
||||
await runtime.start()
|
||||
|
||||
try:
|
||||
|
||||
async def hang_forever():
|
||||
await asyncio.get_event_loop().create_future()
|
||||
|
||||
stream_a = runtime._streams["stream-a"]
|
||||
stream_b = runtime._streams["stream-b"]
|
||||
|
||||
# Two tasks in stream A, one task in stream B
|
||||
task_a1 = asyncio.ensure_future(hang_forever())
|
||||
task_a2 = asyncio.ensure_future(hang_forever())
|
||||
task_b1 = asyncio.ensure_future(hang_forever())
|
||||
|
||||
stream_a._execution_tasks["exec-a1"] = task_a1
|
||||
stream_a._execution_tasks["exec-a2"] = task_a2
|
||||
stream_b._execution_tasks["exec-b1"] = task_b1
|
||||
|
||||
result = await runtime.cancel_all_tasks_async()
|
||||
assert result is True
|
||||
|
||||
# Let CancelledErrors propagate
|
||||
for task in [task_a1, task_a2, task_b1]:
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
assert task.cancelled()
|
||||
|
||||
# Clean up
|
||||
del stream_a._execution_tasks["exec-a1"]
|
||||
del stream_a._execution_tasks["exec-a2"]
|
||||
del stream_b._execution_tasks["exec-b1"]
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""aiohttp Application factory for the Hive HTTP API server."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
@@ -10,6 +11,52 @@ from framework.server.session_manager import Session, SessionManager
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Anchor to the repository root so allowed roots are independent of CWD.
|
||||
# app.py lives at core/framework/server/app.py, so four .parent calls
|
||||
# reach the repo root where exports/ and examples/ live.
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
|
||||
_ALLOWED_AGENT_ROOTS: tuple[Path, ...] | None = None
|
||||
|
||||
|
||||
def _get_allowed_agent_roots() -> tuple[Path, ...]:
|
||||
"""Return resolved allowed root directories for agent loading.
|
||||
|
||||
Roots are anchored to the repository root (derived from ``__file__``)
|
||||
so the allowlist is correct regardless of the process's working
|
||||
directory.
|
||||
"""
|
||||
global _ALLOWED_AGENT_ROOTS
|
||||
if _ALLOWED_AGENT_ROOTS is None:
|
||||
_ALLOWED_AGENT_ROOTS = (
|
||||
(_REPO_ROOT / "exports").resolve(),
|
||||
(_REPO_ROOT / "examples").resolve(),
|
||||
(Path.home() / ".hive" / "agents").resolve(),
|
||||
)
|
||||
return _ALLOWED_AGENT_ROOTS
|
||||
|
||||
|
||||
def validate_agent_path(agent_path: str | Path) -> Path:
|
||||
"""Validate that an agent path resolves inside an allowed directory.
|
||||
|
||||
Prevents arbitrary code execution via ``importlib.import_module`` by
|
||||
restricting agent loading to known safe directories: ``exports/``,
|
||||
``examples/``, and ``~/.hive/agents/``.
|
||||
|
||||
Returns the resolved ``Path`` on success.
|
||||
|
||||
Raises:
|
||||
ValueError: If the path is outside all allowed roots.
|
||||
"""
|
||||
resolved = Path(agent_path).expanduser().resolve()
|
||||
for root in _get_allowed_agent_roots():
|
||||
if resolved.is_relative_to(root) and resolved != root:
|
||||
return resolved
|
||||
raise ValueError(
|
||||
"agent_path must be inside an allowed directory (exports/, examples/, or ~/.hive/agents/)"
|
||||
)
|
||||
|
||||
|
||||
def safe_path_segment(value: str) -> str:
|
||||
"""Validate a URL path parameter is a safe filesystem name.
|
||||
|
||||
@@ -17,7 +64,7 @@ def safe_path_segment(value: str) -> str:
|
||||
traversal sequences. aiohttp decodes ``%2F`` inside route params,
|
||||
so a raw ``{session_id}`` can contain ``/`` or ``..`` after decoding.
|
||||
"""
|
||||
if "/" in value or "\\" in value or ".." in value:
|
||||
if not value or value == "." or "/" in value or "\\" in value or ".." in value:
|
||||
raise web.HTTPBadRequest(reason="Invalid path parameter")
|
||||
return value
|
||||
|
||||
@@ -129,20 +176,34 @@ def create_app(model: str | None = None) -> web.Application:
|
||||
"""
|
||||
app = web.Application(middlewares=[cors_middleware, error_middleware])
|
||||
|
||||
# Store manager on app for handlers
|
||||
app["manager"] = SessionManager(model=model)
|
||||
|
||||
# Initialize credential store
|
||||
# Initialize credential store (before SessionManager so it can be shared)
|
||||
from framework.credentials.store import CredentialStore
|
||||
|
||||
try:
|
||||
from framework.credentials.validation import ensure_credential_key_env
|
||||
|
||||
# Load ALL credentials: HIVE_CREDENTIAL_KEY, ADEN_API_KEY, and LLM keys
|
||||
ensure_credential_key_env()
|
||||
app["credential_store"] = CredentialStore.with_aden_sync()
|
||||
|
||||
# Auto-generate credential key for web-only users who never ran the TUI
|
||||
if not os.environ.get("HIVE_CREDENTIAL_KEY"):
|
||||
try:
|
||||
from framework.credentials.key_storage import generate_and_save_credential_key
|
||||
|
||||
generate_and_save_credential_key()
|
||||
logger.info(
|
||||
"Generated and persisted HIVE_CREDENTIAL_KEY to ~/.hive/secrets/credential_key"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not auto-persist HIVE_CREDENTIAL_KEY: %s", exc)
|
||||
|
||||
credential_store = CredentialStore.with_aden_sync()
|
||||
except Exception:
|
||||
logger.debug("Encrypted credential store unavailable, using in-memory fallback")
|
||||
app["credential_store"] = CredentialStore.for_testing({})
|
||||
credential_store = CredentialStore.for_testing({})
|
||||
|
||||
app["credential_store"] = credential_store
|
||||
app["manager"] = SessionManager(model=model, credential_store=credential_store)
|
||||
|
||||
# Register shutdown hook
|
||||
app.on_shutdown.append(_on_shutdown)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Credential CRUD routes."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
@@ -7,6 +8,7 @@ from pydantic import SecretStr
|
||||
|
||||
from framework.credentials.models import CredentialKey, CredentialObject
|
||||
from framework.credentials.store import CredentialStore
|
||||
from framework.server.app import validate_agent_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,7 +55,6 @@ async def handle_save_credential(request: web.Request) -> web.Response:
|
||||
|
||||
Body: {"credential_id": "...", "keys": {"key_name": "value", ...}}
|
||||
"""
|
||||
store = _get_store(request)
|
||||
body = await request.json()
|
||||
|
||||
credential_id = body.get("credential_id")
|
||||
@@ -62,6 +63,31 @@ async def handle_save_credential(request: web.Request) -> web.Response:
|
||||
if not credential_id or not keys or not isinstance(keys, dict):
|
||||
return web.json_response({"error": "credential_id and keys are required"}, status=400)
|
||||
|
||||
# ADEN_API_KEY is stored in the encrypted store via key_storage module
|
||||
if credential_id == "aden_api_key":
|
||||
key = keys.get("api_key", "").strip()
|
||||
if not key:
|
||||
return web.json_response({"error": "api_key is required"}, status=400)
|
||||
|
||||
from framework.credentials.key_storage import save_aden_api_key
|
||||
|
||||
save_aden_api_key(key)
|
||||
|
||||
# Immediately sync OAuth tokens from Aden (runs in executor because
|
||||
# _presync_aden_tokens makes blocking HTTP calls to the Aden server).
|
||||
try:
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
|
||||
from framework.credentials.validation import _presync_aden_tokens
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, _presync_aden_tokens, CREDENTIAL_SPECS)
|
||||
except Exception as exc:
|
||||
logger.warning("Aden token sync after key save failed: %s", exc)
|
||||
|
||||
return web.json_response({"saved": "aden_api_key"}, status=201)
|
||||
|
||||
store = _get_store(request)
|
||||
cred = CredentialObject(
|
||||
id=credential_id,
|
||||
keys={k: CredentialKey(name=k, value=SecretStr(v)) for k, v in keys.items()},
|
||||
@@ -73,6 +99,13 @@ async def handle_save_credential(request: web.Request) -> web.Response:
|
||||
async def handle_delete_credential(request: web.Request) -> web.Response:
|
||||
"""DELETE /api/credentials/{credential_id} — delete a credential."""
|
||||
credential_id = request.match_info["credential_id"]
|
||||
|
||||
if credential_id == "aden_api_key":
|
||||
from framework.credentials.key_storage import delete_aden_api_key
|
||||
|
||||
delete_aden_api_key()
|
||||
return web.json_response({"deleted": True})
|
||||
|
||||
store = _get_store(request)
|
||||
deleted = store.delete_credential(credential_id)
|
||||
if not deleted:
|
||||
@@ -83,7 +116,7 @@ async def handle_delete_credential(request: web.Request) -> web.Response:
|
||||
async def handle_check_agent(request: web.Request) -> web.Response:
|
||||
"""POST /api/credentials/check-agent — check and validate agent credentials.
|
||||
|
||||
Uses the same two-phase validation as agent startup:
|
||||
Uses the same ``validate_agent_credentials`` as agent startup:
|
||||
1. Presence — is the credential available (env, encrypted store, Aden)?
|
||||
2. Health check — does the credential actually work (lightweight HTTP call)?
|
||||
|
||||
@@ -97,150 +130,81 @@ async def handle_check_agent(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": "agent_path is required"}, status=400)
|
||||
|
||||
try:
|
||||
import os
|
||||
agent_path = str(validate_agent_path(agent_path))
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
|
||||
from framework.credentials.setup import CredentialSetupSession
|
||||
from framework.credentials.storage import (
|
||||
CompositeStorage,
|
||||
EncryptedFileStorage,
|
||||
EnvVarStorage,
|
||||
try:
|
||||
from framework.credentials.setup import load_agent_nodes
|
||||
from framework.credentials.validation import (
|
||||
ensure_credential_key_env,
|
||||
validate_agent_credentials,
|
||||
)
|
||||
from framework.credentials.validation import _presync_aden_tokens, ensure_credential_key_env
|
||||
|
||||
# Load env vars from shell config (same as runtime startup)
|
||||
ensure_credential_key_env()
|
||||
|
||||
# Build a proper store with env + encrypted storage (same as validate_agent_credentials)
|
||||
try:
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
except ImportError:
|
||||
CREDENTIAL_SPECS = {}
|
||||
nodes = load_agent_nodes(agent_path)
|
||||
result = validate_agent_credentials(
|
||||
nodes, verify=verify, raise_on_error=False, force_refresh=True
|
||||
)
|
||||
|
||||
if os.environ.get("ADEN_API_KEY") and CREDENTIAL_SPECS:
|
||||
_presync_aden_tokens(CREDENTIAL_SPECS)
|
||||
|
||||
env_mapping = {
|
||||
(spec.credential_id or name): spec.env_var for name, spec in CREDENTIAL_SPECS.items()
|
||||
}
|
||||
env_storage = EnvVarStorage(env_mapping=env_mapping)
|
||||
if os.environ.get("HIVE_CREDENTIAL_KEY"):
|
||||
storage = CompositeStorage(primary=env_storage, fallbacks=[EncryptedFileStorage()])
|
||||
else:
|
||||
storage = env_storage
|
||||
store = CredentialStore(storage=storage)
|
||||
|
||||
# Detect required credentials from agent graph
|
||||
session = CredentialSetupSession.from_agent_path(agent_path, missing_only=False)
|
||||
|
||||
# Health check function (may not be available)
|
||||
check_health = None
|
||||
if verify:
|
||||
try:
|
||||
from aden_tools.credentials import check_credential_health
|
||||
|
||||
check_health = check_credential_health
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
required = []
|
||||
for mc in session.missing:
|
||||
cred_id = mc.credential_id or mc.credential_name
|
||||
available = store.is_available(cred_id)
|
||||
|
||||
entry = {
|
||||
"credential_name": mc.credential_name,
|
||||
"credential_id": cred_id,
|
||||
"env_var": mc.env_var,
|
||||
"description": mc.description,
|
||||
"help_url": mc.help_url,
|
||||
"tools": mc.tools,
|
||||
"node_types": mc.node_types,
|
||||
"available": available,
|
||||
"direct_api_key_supported": mc.direct_api_key_supported,
|
||||
"aden_supported": mc.aden_supported,
|
||||
"credential_key": mc.credential_key,
|
||||
"valid": None, # null = not checked
|
||||
# If any credential needs Aden, include ADEN_API_KEY as a first-class row
|
||||
if any(c.aden_supported for c in result.credentials):
|
||||
aden_key_status = {
|
||||
"credential_name": "Aden Platform",
|
||||
"credential_id": "aden_api_key",
|
||||
"env_var": "ADEN_API_KEY",
|
||||
"description": "API key from the Developers tab in Settings",
|
||||
"help_url": "https://hive.adenhq.com/",
|
||||
"tools": [],
|
||||
"node_types": [],
|
||||
"available": result.has_aden_key,
|
||||
"valid": None,
|
||||
"validation_message": None,
|
||||
"direct_api_key_supported": True,
|
||||
"aden_supported": True, # renders with "Authorize" button to open Aden
|
||||
"credential_key": "api_key",
|
||||
}
|
||||
required = [aden_key_status] + [_status_to_dict(c) for c in result.credentials]
|
||||
else:
|
||||
required = [_status_to_dict(c) for c in result.credentials]
|
||||
|
||||
# Phase 2: health check for available credentials
|
||||
if available and verify and check_health:
|
||||
spec = CREDENTIAL_SPECS.get(mc.credential_name)
|
||||
if spec and spec.health_check_endpoint:
|
||||
value = store.get(cred_id)
|
||||
if value:
|
||||
try:
|
||||
result = check_health(
|
||||
mc.credential_name,
|
||||
value,
|
||||
health_check_endpoint=spec.health_check_endpoint,
|
||||
health_check_method=spec.health_check_method,
|
||||
)
|
||||
entry["valid"] = result.valid
|
||||
entry["validation_message"] = result.message
|
||||
except Exception as exc:
|
||||
entry["valid"] = False
|
||||
entry["validation_message"] = f"Health check error: {exc}"
|
||||
|
||||
required.append(entry)
|
||||
|
||||
has_aden_key = bool(os.environ.get("ADEN_API_KEY"))
|
||||
return web.json_response({
|
||||
"required": required,
|
||||
"has_aden_key": has_aden_key,
|
||||
})
|
||||
return web.json_response(
|
||||
{
|
||||
"required": required,
|
||||
"has_aden_key": result.has_aden_key,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error checking agent credentials: {e}")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_save_aden_key(request: web.Request) -> web.Response:
|
||||
"""POST /api/credentials/aden-key — save the user's ADEN_API_KEY.
|
||||
|
||||
Sets the key in the current process environment and persists it to shell
|
||||
config so future terminals pick it up. Then triggers an Aden token sync
|
||||
so OAuth credentials resolve immediately.
|
||||
|
||||
Body: {"key": "..."}
|
||||
"""
|
||||
import os
|
||||
|
||||
body = await request.json()
|
||||
key = body.get("key", "").strip()
|
||||
if not key:
|
||||
return web.json_response({"error": "key is required"}, status=400)
|
||||
|
||||
os.environ["ADEN_API_KEY"] = key
|
||||
|
||||
# Persist to shell config (best-effort, same pattern as TUI setup)
|
||||
try:
|
||||
from aden_tools.credentials.shell_config import add_env_var_to_shell_config
|
||||
|
||||
add_env_var_to_shell_config(
|
||||
"ADEN_API_KEY",
|
||||
key,
|
||||
comment="Aden Platform API key",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not persist ADEN_API_KEY to shell config: %s", exc)
|
||||
|
||||
# Immediately sync OAuth tokens from Aden
|
||||
try:
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
from framework.credentials.validation import _presync_aden_tokens
|
||||
|
||||
_presync_aden_tokens(CREDENTIAL_SPECS)
|
||||
except Exception as exc:
|
||||
logger.warning("Aden token sync after key save failed: %s", exc)
|
||||
|
||||
return web.json_response({"saved": True}, status=201)
|
||||
def _status_to_dict(c) -> dict:
|
||||
"""Convert a CredentialStatus to the JSON dict expected by the frontend."""
|
||||
return {
|
||||
"credential_name": c.credential_name,
|
||||
"credential_id": c.credential_id,
|
||||
"env_var": c.env_var,
|
||||
"description": c.description,
|
||||
"help_url": c.help_url,
|
||||
"tools": c.tools,
|
||||
"node_types": c.node_types,
|
||||
"available": c.available,
|
||||
"direct_api_key_supported": c.direct_api_key_supported,
|
||||
"aden_supported": c.aden_supported,
|
||||
"credential_key": c.credential_key,
|
||||
"valid": c.valid,
|
||||
"validation_message": c.validation_message,
|
||||
"alternative_group": c.alternative_group,
|
||||
}
|
||||
|
||||
|
||||
def register_routes(app: web.Application) -> None:
|
||||
"""Register credential routes on the application."""
|
||||
# check-agent and aden-key must be registered BEFORE the {credential_id} wildcard
|
||||
# check-agent must be registered BEFORE the {credential_id} wildcard
|
||||
app.router.add_post("/api/credentials/check-agent", handle_check_agent)
|
||||
app.router.add_post("/api/credentials/aden-key", handle_save_aden_key)
|
||||
app.router.add_get("/api/credentials", handle_list_credentials)
|
||||
app.router.add_post("/api/credentials", handle_save_credential)
|
||||
app.router.add_get("/api/credentials/{credential_id}", handle_get_credential)
|
||||
|
||||
@@ -24,6 +24,7 @@ DEFAULT_EVENT_TYPES = [
|
||||
EventType.NODE_LOOP_STARTED,
|
||||
EventType.NODE_LOOP_ITERATION,
|
||||
EventType.NODE_LOOP_COMPLETED,
|
||||
EventType.LLM_TURN_COMPLETE,
|
||||
EventType.NODE_ACTION_PLAN,
|
||||
EventType.EDGE_TRAVERSED,
|
||||
EventType.GOAL_PROGRESS,
|
||||
@@ -35,6 +36,9 @@ DEFAULT_EVENT_TYPES = [
|
||||
EventType.NODE_TOOL_DOOM_LOOP,
|
||||
EventType.CONTEXT_COMPACTED,
|
||||
EventType.WORKER_LOADED,
|
||||
EventType.CREDENTIALS_REQUIRED,
|
||||
EventType.SUBAGENT_REPORT,
|
||||
EventType.QUEEN_MODE_CHANGED,
|
||||
]
|
||||
|
||||
# Keepalive interval in seconds
|
||||
@@ -77,12 +81,42 @@ async def handle_events(request: web.Request) -> web.StreamResponse:
|
||||
# Per-client buffer queue
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
|
||||
|
||||
# Lifecycle events drive frontend state transitions and must never be lost.
|
||||
_CRITICAL_EVENTS = {
|
||||
"execution_started",
|
||||
"execution_completed",
|
||||
"execution_failed",
|
||||
"execution_paused",
|
||||
"client_input_requested",
|
||||
"node_loop_iteration",
|
||||
"node_loop_started",
|
||||
"credentials_required",
|
||||
"worker_loaded",
|
||||
"queen_mode_changed",
|
||||
}
|
||||
|
||||
client_disconnected = asyncio.Event()
|
||||
|
||||
async def on_event(event) -> None:
|
||||
"""Push event dict into queue; drop if full."""
|
||||
try:
|
||||
queue.put_nowait(event.to_dict())
|
||||
except asyncio.QueueFull:
|
||||
pass # Drop oldest-undelivered; client will catch up
|
||||
"""Push event dict into queue; drop non-critical events if full."""
|
||||
if client_disconnected.is_set():
|
||||
return
|
||||
|
||||
evt_dict = event.to_dict()
|
||||
if evt_dict.get("type") in _CRITICAL_EVENTS:
|
||||
try:
|
||||
queue.put_nowait(evt_dict)
|
||||
except asyncio.QueueFull:
|
||||
logger.warning(
|
||||
"SSE client queue full on critical event; disconnecting session='%s'",
|
||||
session.id,
|
||||
)
|
||||
client_disconnected.set()
|
||||
else:
|
||||
try:
|
||||
queue.put_nowait(evt_dict)
|
||||
except asyncio.QueueFull:
|
||||
pass # high-frequency events can be dropped; client will catch up
|
||||
|
||||
# Subscribe to EventBus
|
||||
from framework.server.sse import SSEResponse
|
||||
@@ -94,25 +128,69 @@ async def handle_events(request: web.Request) -> web.StreamResponse:
|
||||
|
||||
sse = SSEResponse()
|
||||
await sse.prepare(request)
|
||||
logger.info(
|
||||
"SSE connected: session='%s', sub_id='%s', types=%d", session.id, sub_id, len(event_types)
|
||||
)
|
||||
|
||||
# Replay buffered events that were published before this SSE connected.
|
||||
# The EventBus keeps a history ring-buffer; we replay the subset that
|
||||
# produces visible chat messages so the frontend never misses early
|
||||
# queen output. Lifecycle events are NOT replayed to avoid duplicate
|
||||
# state transitions (turn counter increments, etc.).
|
||||
_REPLAY_TYPES = {
|
||||
EventType.CLIENT_OUTPUT_DELTA.value,
|
||||
EventType.EXECUTION_STARTED.value,
|
||||
EventType.CLIENT_INPUT_REQUESTED.value,
|
||||
}
|
||||
event_type_values = {et.value for et in event_types}
|
||||
replay_types = _REPLAY_TYPES & event_type_values
|
||||
replayed = 0
|
||||
for past_event in event_bus._event_history:
|
||||
if past_event.type.value in replay_types:
|
||||
try:
|
||||
queue.put_nowait(past_event.to_dict())
|
||||
replayed += 1
|
||||
except asyncio.QueueFull:
|
||||
break
|
||||
if replayed:
|
||||
logger.info("SSE replayed %d buffered events for session='%s'", replayed, session.id)
|
||||
|
||||
event_count = 0
|
||||
close_reason = "unknown"
|
||||
try:
|
||||
while True:
|
||||
while not client_disconnected.is_set():
|
||||
try:
|
||||
data = await asyncio.wait_for(queue.get(), timeout=KEEPALIVE_INTERVAL)
|
||||
await sse.send_event(data)
|
||||
event_count += 1
|
||||
if event_count == 1:
|
||||
logger.info(
|
||||
"SSE first event: session='%s', type='%s'", session.id, data.get("type")
|
||||
)
|
||||
except TimeoutError:
|
||||
await sse.send_keepalive()
|
||||
except (ConnectionResetError, ConnectionError):
|
||||
close_reason = "client_disconnected"
|
||||
break
|
||||
except RuntimeError as exc:
|
||||
if "closing transport" in str(exc).lower():
|
||||
break
|
||||
raise
|
||||
except Exception as exc:
|
||||
close_reason = f"error: {exc}"
|
||||
break
|
||||
|
||||
if client_disconnected.is_set() and close_reason == "unknown":
|
||||
close_reason = "slow_client"
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
close_reason = "cancelled"
|
||||
finally:
|
||||
event_bus.unsubscribe(sub_id)
|
||||
logger.debug("SSE client disconnected from session '%s'", session.id)
|
||||
try:
|
||||
event_bus.unsubscribe(sub_id)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(
|
||||
"SSE disconnected: session='%s', events_sent=%d, reason='%s'",
|
||||
session.id,
|
||||
event_count,
|
||||
close_reason,
|
||||
)
|
||||
|
||||
return sse.response
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Execution control routes — trigger, inject, chat, resume, stop, replay."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from framework.credentials.validation import validate_agent_credentials
|
||||
from framework.server.app import resolve_session, safe_path_segment, sessions_dir
|
||||
from framework.server.routes_sessions import _credential_error_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,6 +25,30 @@ async def handle_trigger(request: web.Request) -> web.Response:
|
||||
if not session.worker_runtime:
|
||||
return web.json_response({"error": "No worker loaded in this session"}, status=503)
|
||||
|
||||
# Validate credentials before running — deferred from load time to avoid
|
||||
# showing the modal before the user clicks Run. Runs in executor because
|
||||
# validate_agent_credentials makes blocking HTTP health-check calls.
|
||||
if session.runner:
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None, lambda: validate_agent_credentials(session.runner.graph.nodes)
|
||||
)
|
||||
except Exception as e:
|
||||
agent_path = str(session.worker_path) if session.worker_path else ""
|
||||
resp = _credential_error_response(e, agent_path)
|
||||
if resp is not None:
|
||||
return resp
|
||||
|
||||
# Resync MCP servers if credentials were added since the worker loaded
|
||||
# (e.g. user connected an OAuth account mid-session via Aden UI).
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None, lambda: session.runner._tool_registry.resync_mcp_servers_if_needed()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("MCP resync failed: %s", e)
|
||||
|
||||
body = await request.json()
|
||||
entry_point_id = body.get("entry_point_id", "default")
|
||||
input_data = body.get("input_data", {})
|
||||
@@ -37,6 +64,16 @@ async def handle_trigger(request: web.Request) -> web.Response:
|
||||
session_state=session_state,
|
||||
)
|
||||
|
||||
# Cancel queen's in-progress LLM turn so it picks up the mode change cleanly
|
||||
if session.queen_executor:
|
||||
node = session.queen_executor.node_registry.get("queen")
|
||||
if node and hasattr(node, "cancel_current_turn"):
|
||||
node.cancel_current_turn()
|
||||
|
||||
# Switch queen to running mode (mirrors run_agent_with_input tool behavior)
|
||||
if session.mode_state is not None:
|
||||
await session.mode_state.switch_to_running(source="frontend")
|
||||
|
||||
return web.json_response({"execution_id": execution_id})
|
||||
|
||||
|
||||
@@ -65,12 +102,10 @@ async def handle_inject(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
async def handle_chat(request: web.Request) -> web.Response:
|
||||
"""POST /api/sessions/{session_id}/chat — convenience endpoint.
|
||||
"""POST /api/sessions/{session_id}/chat — send a message to the queen.
|
||||
|
||||
Routing priority:
|
||||
1. Worker awaiting input → inject into worker node
|
||||
2. Queen active → inject into queen conversation
|
||||
3. Error — no handler available
|
||||
The input box is permanently connected to the queen agent.
|
||||
Worker input is handled separately via /worker-input.
|
||||
|
||||
Body: {"message": "hello"}
|
||||
"""
|
||||
@@ -84,26 +119,6 @@ async def handle_chat(request: web.Request) -> web.Response:
|
||||
if not message:
|
||||
return web.json_response({"error": "message is required"}, status=400)
|
||||
|
||||
# 1. Check if worker is awaiting input → inject to worker
|
||||
if session.worker_runtime:
|
||||
node_id, graph_id = session.worker_runtime.find_awaiting_node()
|
||||
|
||||
if node_id:
|
||||
delivered = await session.worker_runtime.inject_input(
|
||||
node_id,
|
||||
message,
|
||||
graph_id=graph_id,
|
||||
is_client_input=True,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "injected",
|
||||
"node_id": node_id,
|
||||
"delivered": delivered,
|
||||
}
|
||||
)
|
||||
|
||||
# 2. Queen active → inject into queen conversation
|
||||
queen_executor = session.queen_executor
|
||||
if queen_executor is not None:
|
||||
node = queen_executor.node_registry.get("queen")
|
||||
@@ -116,8 +131,76 @@ async def handle_chat(request: web.Request) -> web.Response:
|
||||
}
|
||||
)
|
||||
|
||||
# 3. No queen or worker available
|
||||
return web.json_response({"error": "No worker or queen available"}, status=503)
|
||||
return web.json_response({"error": "Queen not available"}, status=503)
|
||||
|
||||
|
||||
async def handle_queen_context(request: web.Request) -> web.Response:
|
||||
"""POST /api/sessions/{session_id}/queen-context — queue context for the queen.
|
||||
|
||||
Unlike /chat, this does NOT trigger an LLM response. The message is
|
||||
queued in the queen's injection queue and will be drained on her next
|
||||
natural iteration (prefixed with [External event]:).
|
||||
|
||||
Body: {"message": "..."}
|
||||
"""
|
||||
session, err = resolve_session(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
body = await request.json()
|
||||
message = body.get("message", "")
|
||||
|
||||
if not message:
|
||||
return web.json_response({"error": "message is required"}, status=400)
|
||||
|
||||
queen_executor = session.queen_executor
|
||||
if queen_executor is not None:
|
||||
node = queen_executor.node_registry.get("queen")
|
||||
if node is not None and hasattr(node, "inject_event"):
|
||||
await node.inject_event(message, is_client_input=False)
|
||||
return web.json_response({"status": "queued", "delivered": True})
|
||||
|
||||
return web.json_response({"error": "Queen not available"}, status=503)
|
||||
|
||||
|
||||
async def handle_worker_input(request: web.Request) -> web.Response:
|
||||
"""POST /api/sessions/{session_id}/worker-input — send input to waiting worker node.
|
||||
|
||||
Auto-discovers the worker node currently awaiting input and injects the message.
|
||||
Returns 404 if no worker node is awaiting input.
|
||||
|
||||
Body: {"message": "..."}
|
||||
"""
|
||||
session, err = resolve_session(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
body = await request.json()
|
||||
message = body.get("message", "")
|
||||
|
||||
if not message:
|
||||
return web.json_response({"error": "message is required"}, status=400)
|
||||
|
||||
if not session.worker_runtime:
|
||||
return web.json_response({"error": "No worker loaded"}, status=503)
|
||||
|
||||
node_id, graph_id = session.worker_runtime.find_awaiting_node()
|
||||
if not node_id:
|
||||
return web.json_response({"error": "No worker node awaiting input"}, status=404)
|
||||
|
||||
delivered = await session.worker_runtime.inject_input(
|
||||
node_id,
|
||||
message,
|
||||
graph_id=graph_id,
|
||||
is_client_input=True,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "injected",
|
||||
"node_id": node_id,
|
||||
"delivered": delivered,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def handle_goal_progress(request: web.Request) -> web.Response:
|
||||
@@ -163,7 +246,7 @@ async def handle_resume(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": "Session not found"}, status=404)
|
||||
|
||||
try:
|
||||
state = json.loads(state_path.read_text())
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
return web.json_response({"error": f"Failed to read session: {e}"}, status=500)
|
||||
|
||||
@@ -205,6 +288,60 @@ async def handle_resume(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
async def handle_pause(request: web.Request) -> web.Response:
|
||||
"""POST /api/sessions/{session_id}/pause — pause the worker (queen stays alive).
|
||||
|
||||
Mirrors the queen's stop_worker() tool: cancels all active worker
|
||||
executions, pauses timers so nothing auto-restarts, but does NOT
|
||||
touch the queen so she can observe and react to the pause.
|
||||
"""
|
||||
session, err = resolve_session(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
if not session.worker_runtime:
|
||||
return web.json_response({"error": "No worker loaded in this session"}, status=503)
|
||||
|
||||
runtime = session.worker_runtime
|
||||
cancelled = []
|
||||
|
||||
for graph_id in runtime.list_graphs():
|
||||
reg = runtime.get_graph_registration(graph_id)
|
||||
if reg is None:
|
||||
continue
|
||||
for _ep_id, stream in reg.streams.items():
|
||||
# Signal shutdown on active nodes to abort in-flight LLM streams
|
||||
for executor in stream._active_executors.values():
|
||||
for node in executor.node_registry.values():
|
||||
if hasattr(node, "signal_shutdown"):
|
||||
node.signal_shutdown()
|
||||
if hasattr(node, "cancel_current_turn"):
|
||||
node.cancel_current_turn()
|
||||
|
||||
for exec_id in list(stream.active_execution_ids):
|
||||
try:
|
||||
ok = await stream.cancel_execution(exec_id)
|
||||
if ok:
|
||||
cancelled.append(exec_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Pause timers so the next tick doesn't restart execution
|
||||
runtime.pause_timers()
|
||||
|
||||
# Switch to staging (agent still loaded, ready to re-run)
|
||||
if session.mode_state is not None:
|
||||
await session.mode_state.switch_to_staging(source="frontend")
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"stopped": bool(cancelled),
|
||||
"cancelled": cancelled,
|
||||
"timers_paused": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def handle_stop(request: web.Request) -> web.Response:
|
||||
"""POST /api/sessions/{session_id}/stop — cancel a running execution.
|
||||
|
||||
@@ -228,8 +365,26 @@ async def handle_stop(request: web.Request) -> web.Response:
|
||||
if reg is None:
|
||||
continue
|
||||
for _ep_id, stream in reg.streams.items():
|
||||
# Signal shutdown on active nodes to abort in-flight LLM streams
|
||||
for executor in stream._active_executors.values():
|
||||
for node in executor.node_registry.values():
|
||||
if hasattr(node, "signal_shutdown"):
|
||||
node.signal_shutdown()
|
||||
if hasattr(node, "cancel_current_turn"):
|
||||
node.cancel_current_turn()
|
||||
|
||||
cancelled = await stream.cancel_execution(execution_id)
|
||||
if cancelled:
|
||||
# Cancel queen's in-progress LLM turn
|
||||
if session.queen_executor:
|
||||
node = session.queen_executor.node_registry.get("queen")
|
||||
if node and hasattr(node, "cancel_current_turn"):
|
||||
node.cancel_current_turn()
|
||||
|
||||
# Switch to staging (agent still loaded, ready to re-run)
|
||||
if session.mode_state is not None:
|
||||
await session.mode_state.switch_to_staging(source="frontend")
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"stopped": True,
|
||||
@@ -292,14 +447,32 @@ async def handle_replay(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
async def handle_cancel_queen(request: web.Request) -> web.Response:
|
||||
"""POST /api/sessions/{session_id}/cancel-queen — cancel the queen's current LLM turn."""
|
||||
session, err = resolve_session(request)
|
||||
if err:
|
||||
return err
|
||||
queen_executor = session.queen_executor
|
||||
if queen_executor is None:
|
||||
return web.json_response({"cancelled": False, "error": "Queen not active"}, status=404)
|
||||
node = queen_executor.node_registry.get("queen")
|
||||
if node is None or not hasattr(node, "cancel_current_turn"):
|
||||
return web.json_response({"cancelled": False, "error": "Queen node not found"}, status=404)
|
||||
node.cancel_current_turn()
|
||||
return web.json_response({"cancelled": True})
|
||||
|
||||
|
||||
def register_routes(app: web.Application) -> None:
|
||||
"""Register execution control routes."""
|
||||
# Session-primary routes
|
||||
app.router.add_post("/api/sessions/{session_id}/trigger", handle_trigger)
|
||||
app.router.add_post("/api/sessions/{session_id}/inject", handle_inject)
|
||||
app.router.add_post("/api/sessions/{session_id}/chat", handle_chat)
|
||||
app.router.add_post("/api/sessions/{session_id}/pause", handle_stop)
|
||||
app.router.add_post("/api/sessions/{session_id}/queen-context", handle_queen_context)
|
||||
app.router.add_post("/api/sessions/{session_id}/worker-input", handle_worker_input)
|
||||
app.router.add_post("/api/sessions/{session_id}/pause", handle_pause)
|
||||
app.router.add_post("/api/sessions/{session_id}/resume", handle_resume)
|
||||
app.router.add_post("/api/sessions/{session_id}/stop", handle_stop)
|
||||
app.router.add_post("/api/sessions/{session_id}/cancel-queen", handle_cancel_queen)
|
||||
app.router.add_post("/api/sessions/{session_id}/replay", handle_replay)
|
||||
app.router.add_get("/api/sessions/{session_id}/goal-progress", handle_goal_progress)
|
||||
|
||||
@@ -10,13 +10,21 @@ from framework.server.app import resolve_session, safe_path_segment
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_graph_spec(session, graph_id: str):
|
||||
"""Get GraphSpec for a graph_id. Returns (graph_spec, None) or (None, error_response)."""
|
||||
def _get_graph_registration(session, graph_id: str):
|
||||
"""Get _GraphRegistration for a graph_id. Returns (reg, None) or (None, error_response)."""
|
||||
if not session.worker_runtime:
|
||||
return None, web.json_response({"error": "No worker loaded in this session"}, status=503)
|
||||
reg = session.worker_runtime.get_graph_registration(graph_id)
|
||||
if reg is None:
|
||||
return None, web.json_response({"error": f"Graph '{graph_id}' not found"}, status=404)
|
||||
return reg, None
|
||||
|
||||
|
||||
def _get_graph_spec(session, graph_id: str):
|
||||
"""Get GraphSpec for a graph_id. Returns (graph_spec, None) or (None, error_response)."""
|
||||
reg, err = _get_graph_registration(session, graph_id)
|
||||
if err:
|
||||
return None, err
|
||||
return reg.graph, None
|
||||
|
||||
|
||||
@@ -37,6 +45,7 @@ def _node_to_dict(node) -> dict:
|
||||
"client_facing": node.client_facing,
|
||||
"success_criteria": node.success_criteria,
|
||||
"system_prompt": node.system_prompt or "",
|
||||
"sub_agents": node.sub_agents,
|
||||
}
|
||||
|
||||
|
||||
@@ -47,10 +56,11 @@ async def handle_list_nodes(request: web.Request) -> web.Response:
|
||||
return err
|
||||
|
||||
graph_id = request.match_info["graph_id"]
|
||||
graph, err = _get_graph_spec(session, graph_id)
|
||||
reg, err = _get_graph_registration(session, graph_id)
|
||||
if err:
|
||||
return err
|
||||
|
||||
graph = reg.graph
|
||||
nodes = [_node_to_dict(n) for n in graph.nodes]
|
||||
|
||||
# Optionally enrich with session progress
|
||||
@@ -70,7 +80,7 @@ async def handle_list_nodes(request: web.Request) -> web.Response:
|
||||
)
|
||||
if state_path.exists():
|
||||
try:
|
||||
state = json.loads(state_path.read_text())
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
progress = state.get("progress", {})
|
||||
visit_counts = progress.get("node_visit_counts", {})
|
||||
failures = progress.get("nodes_with_failures", [])
|
||||
@@ -90,11 +100,28 @@ async def handle_list_nodes(request: web.Request) -> web.Response:
|
||||
{"source": e.source, "target": e.target, "condition": e.condition, "priority": e.priority}
|
||||
for e in graph.edges
|
||||
]
|
||||
rt = session.worker_runtime
|
||||
entry_points = [
|
||||
{
|
||||
"id": ep.id,
|
||||
"name": ep.name,
|
||||
"entry_node": ep.entry_node,
|
||||
"trigger_type": ep.trigger_type,
|
||||
"trigger_config": ep.trigger_config,
|
||||
**(
|
||||
{"next_fire_in": nf}
|
||||
if rt and (nf := rt.get_timer_next_fire_in(ep.id)) is not None
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for ep in reg.entry_points.values()
|
||||
]
|
||||
return web.json_response(
|
||||
{
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"entry_node": graph.entry_node,
|
||||
"entry_points": entry_points,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ Session-primary routes:
|
||||
- GET /api/sessions/{session_id}/stats — runtime statistics
|
||||
- GET /api/sessions/{session_id}/entry-points — list entry points
|
||||
- GET /api/sessions/{session_id}/graphs — list graph IDs
|
||||
- GET /api/sessions/{session_id}/queen-messages — queen conversation history
|
||||
|
||||
Worker session browsing (persisted execution runs on disk):
|
||||
- GET /api/sessions/{session_id}/worker-sessions — list
|
||||
@@ -29,7 +30,12 @@ from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from framework.server.app import resolve_session, safe_path_segment, sessions_dir
|
||||
from framework.server.app import (
|
||||
resolve_session,
|
||||
safe_path_segment,
|
||||
sessions_dir,
|
||||
validate_agent_path,
|
||||
)
|
||||
from framework.server.session_manager import SessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -42,6 +48,7 @@ def _get_manager(request: web.Request) -> SessionManager:
|
||||
def _session_to_live_dict(session) -> dict:
|
||||
"""Serialize a live Session to the session-primary JSON shape."""
|
||||
info = session.worker_info
|
||||
mode_state = getattr(session, "mode_state", None)
|
||||
return {
|
||||
"session_id": session.id,
|
||||
"worker_id": session.worker_id,
|
||||
@@ -54,6 +61,7 @@ def _session_to_live_dict(session) -> dict:
|
||||
"loaded_at": session.loaded_at,
|
||||
"uptime_seconds": round(time.time() - session.loaded_at, 1),
|
||||
"intro_message": getattr(session.runner, "intro_message", "") or "",
|
||||
"queen_mode": mode_state.mode if mode_state else "building",
|
||||
}
|
||||
|
||||
|
||||
@@ -61,37 +69,22 @@ def _credential_error_response(exc: Exception, agent_path: str | None) -> web.Re
|
||||
"""If *exc* is a CredentialError, return a 424 with structured credential info.
|
||||
|
||||
Returns None if *exc* is not a credential error (caller should handle it).
|
||||
Uses the CredentialValidationResult attached by validate_agent_credentials.
|
||||
"""
|
||||
from framework.credentials.models import CredentialError
|
||||
|
||||
if not isinstance(exc, CredentialError):
|
||||
return None
|
||||
|
||||
failed_names: list[str] = getattr(exc, "failed_cred_names", [])
|
||||
from framework.server.routes_credentials import _status_to_dict
|
||||
|
||||
required: list[dict] = []
|
||||
try:
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
|
||||
for name in failed_names:
|
||||
spec = CREDENTIAL_SPECS.get(name)
|
||||
if spec is None:
|
||||
continue
|
||||
required.append({
|
||||
"credential_name": name,
|
||||
"credential_id": spec.credential_id or name,
|
||||
"env_var": spec.env_var,
|
||||
"description": spec.description,
|
||||
"help_url": spec.help_url,
|
||||
"tools": list(spec.tools),
|
||||
"node_types": list(spec.node_types),
|
||||
"available": False,
|
||||
"direct_api_key_supported": spec.direct_api_key_supported,
|
||||
"aden_supported": spec.aden_supported,
|
||||
"credential_key": spec.credential_key,
|
||||
})
|
||||
except ImportError:
|
||||
pass
|
||||
# Prefer the structured validation result attached to the exception
|
||||
validation_result = getattr(exc, "validation_result", None)
|
||||
if validation_result is not None:
|
||||
required = [_status_to_dict(c) for c in validation_result.failed]
|
||||
else:
|
||||
# Fallback for exceptions without a validation result
|
||||
required = []
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -117,6 +110,7 @@ async def handle_create_session(request: web.Request) -> web.Response:
|
||||
"agent_id": "..." (optional — worker ID override),
|
||||
"session_id": "..." (optional — custom session ID),
|
||||
"model": "..." (optional),
|
||||
"initial_prompt": "..." (optional — first user message for the queen),
|
||||
}
|
||||
|
||||
When agent_path is provided, creates a session with a worker in one step
|
||||
@@ -129,6 +123,13 @@ async def handle_create_session(request: web.Request) -> web.Response:
|
||||
agent_id = body.get("agent_id")
|
||||
session_id = body.get("session_id")
|
||||
model = body.get("model")
|
||||
initial_prompt = body.get("initial_prompt")
|
||||
|
||||
if agent_path:
|
||||
try:
|
||||
agent_path = str(validate_agent_path(agent_path))
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
|
||||
try:
|
||||
if agent_path:
|
||||
@@ -137,12 +138,14 @@ async def handle_create_session(request: web.Request) -> web.Response:
|
||||
agent_path,
|
||||
agent_id=agent_id,
|
||||
model=model,
|
||||
initial_prompt=initial_prompt,
|
||||
)
|
||||
else:
|
||||
# Queen-only session
|
||||
session = await manager.create_session(
|
||||
session_id=session_id,
|
||||
model=model,
|
||||
initial_prompt=initial_prompt,
|
||||
)
|
||||
except ValueError as e:
|
||||
msg = str(e)
|
||||
@@ -153,14 +156,17 @@ async def handle_create_session(request: web.Request) -> web.Response:
|
||||
status=409,
|
||||
)
|
||||
return web.json_response({"error": msg}, status=409)
|
||||
except FileNotFoundError as e:
|
||||
return web.json_response({"error": str(e)}, status=404)
|
||||
except FileNotFoundError:
|
||||
return web.json_response(
|
||||
{"error": f"Agent not found: {agent_path or 'no path'}"},
|
||||
status=404,
|
||||
)
|
||||
except Exception as e:
|
||||
resp = _credential_error_response(e, agent_path)
|
||||
if resp is not None:
|
||||
return resp
|
||||
logger.exception("Error creating session: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
return web.json_response(_session_to_live_dict(session), status=201)
|
||||
|
||||
@@ -192,14 +198,21 @@ async def handle_get_live_session(request: web.Request) -> web.Response:
|
||||
data = _session_to_live_dict(session)
|
||||
|
||||
if session.worker_runtime:
|
||||
rt = session.worker_runtime
|
||||
data["entry_points"] = [
|
||||
{
|
||||
"id": ep.id,
|
||||
"name": ep.name,
|
||||
"entry_node": ep.entry_node,
|
||||
"trigger_type": ep.trigger_type,
|
||||
"trigger_config": ep.trigger_config,
|
||||
**(
|
||||
{"next_fire_in": nf}
|
||||
if (nf := rt.get_timer_next_fire_in(ep.id)) is not None
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for ep in session.worker_runtime.get_entry_points()
|
||||
for ep in rt.get_entry_points()
|
||||
]
|
||||
data["graphs"] = session.worker_runtime.list_graphs()
|
||||
|
||||
@@ -239,6 +252,11 @@ async def handle_load_worker(request: web.Request) -> web.Response:
|
||||
if not agent_path:
|
||||
return web.json_response({"error": "agent_path is required"}, status=400)
|
||||
|
||||
try:
|
||||
agent_path = str(validate_agent_path(agent_path))
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
|
||||
worker_id = body.get("worker_id")
|
||||
model = body.get("model")
|
||||
|
||||
@@ -251,14 +269,14 @@ async def handle_load_worker(request: web.Request) -> web.Response:
|
||||
)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=409)
|
||||
except FileNotFoundError as e:
|
||||
return web.json_response({"error": str(e)}, status=404)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": f"Agent not found: {agent_path}"}, status=404)
|
||||
except Exception as e:
|
||||
resp = _credential_error_response(e, agent_path)
|
||||
if resp is not None:
|
||||
return resp
|
||||
logger.exception("Error loading worker: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
return web.json_response(_session_to_live_dict(session))
|
||||
|
||||
@@ -317,7 +335,8 @@ async def handle_session_entry_points(request: web.Request) -> web.Response:
|
||||
status=404,
|
||||
)
|
||||
|
||||
eps = session.worker_runtime.get_entry_points() if session.worker_runtime else []
|
||||
rt = session.worker_runtime
|
||||
eps = rt.get_entry_points() if rt else []
|
||||
return web.json_response(
|
||||
{
|
||||
"entry_points": [
|
||||
@@ -326,6 +345,12 @@ async def handle_session_entry_points(request: web.Request) -> web.Response:
|
||||
"name": ep.name,
|
||||
"entry_node": ep.entry_node,
|
||||
"trigger_type": ep.trigger_type,
|
||||
"trigger_config": ep.trigger_config,
|
||||
**(
|
||||
{"next_fire_in": nf}
|
||||
if rt and (nf := rt.get_timer_next_fire_in(ep.id)) is not None
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for ep in eps
|
||||
]
|
||||
@@ -377,7 +402,7 @@ async def handle_list_worker_sessions(request: web.Request) -> web.Response:
|
||||
state_path = d / "state.json"
|
||||
if state_path.exists():
|
||||
try:
|
||||
state = json.loads(state_path.read_text())
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
entry["status"] = state.get("status", "unknown")
|
||||
entry["started_at"] = state.get("started_at")
|
||||
entry["completed_at"] = state.get("completed_at")
|
||||
@@ -416,7 +441,7 @@ async def handle_get_worker_session(request: web.Request) -> web.Response:
|
||||
return web.json_response({"error": "Session not found"}, status=404)
|
||||
|
||||
try:
|
||||
state = json.loads(state_path.read_text())
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
return web.json_response({"error": f"Failed to read session: {e}"}, status=500)
|
||||
|
||||
@@ -444,7 +469,7 @@ async def handle_list_checkpoints(request: web.Request) -> web.Response:
|
||||
if f.suffix != ".json":
|
||||
continue
|
||||
try:
|
||||
data = json.loads(f.read_text())
|
||||
data = json.loads(f.read_text(encoding="utf-8"))
|
||||
checkpoints.append(
|
||||
{
|
||||
"checkpoint_id": f.stem,
|
||||
@@ -554,13 +579,14 @@ async def handle_messages(request: web.Request) -> web.Response:
|
||||
if part_file.suffix != ".json":
|
||||
continue
|
||||
try:
|
||||
part = json.loads(part_file.read_text())
|
||||
part = json.loads(part_file.read_text(encoding="utf-8"))
|
||||
part["_node_id"] = node_dir.name
|
||||
part.setdefault("created_at", part_file.stat().st_mtime)
|
||||
all_messages.append(part)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
|
||||
all_messages.sort(key=lambda m: m.get("seq", 0))
|
||||
all_messages.sort(key=lambda m: m.get("created_at", m.get("seq", 0)))
|
||||
|
||||
client_only = request.query.get("client_only", "").lower() in ("true", "1")
|
||||
if client_only:
|
||||
@@ -586,6 +612,51 @@ async def handle_messages(request: web.Request) -> web.Response:
|
||||
return web.json_response({"messages": all_messages})
|
||||
|
||||
|
||||
async def handle_queen_messages(request: web.Request) -> web.Response:
|
||||
"""GET /api/sessions/{session_id}/queen-messages — get queen conversation."""
|
||||
session, err = resolve_session(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
queen_dir = Path.home() / ".hive" / "queen" / "session" / session.id
|
||||
convs_dir = queen_dir / "conversations"
|
||||
if not convs_dir.exists():
|
||||
return web.json_response({"messages": []})
|
||||
|
||||
all_messages: list[dict] = []
|
||||
for node_dir in convs_dir.iterdir():
|
||||
if not node_dir.is_dir():
|
||||
continue
|
||||
parts_dir = node_dir / "parts"
|
||||
if not parts_dir.exists():
|
||||
continue
|
||||
for part_file in sorted(parts_dir.iterdir()):
|
||||
if part_file.suffix != ".json":
|
||||
continue
|
||||
try:
|
||||
part = json.loads(part_file.read_text(encoding="utf-8"))
|
||||
part["_node_id"] = node_dir.name
|
||||
# Use file mtime as created_at so frontend can order
|
||||
# queen and worker messages chronologically.
|
||||
part.setdefault("created_at", part_file.stat().st_mtime)
|
||||
all_messages.append(part)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
|
||||
all_messages.sort(key=lambda m: m.get("created_at", m.get("seq", 0)))
|
||||
|
||||
# Filter to client-facing messages only
|
||||
all_messages = [
|
||||
m
|
||||
for m in all_messages
|
||||
if not m.get("is_transition_marker")
|
||||
and m["role"] != "tool"
|
||||
and not (m["role"] == "assistant" and m.get("tool_calls"))
|
||||
]
|
||||
|
||||
return web.json_response({"messages": all_messages})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Agent discovery (not session-specific)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -643,6 +714,7 @@ def register_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/sessions/{session_id}/stats", handle_session_stats)
|
||||
app.router.add_get("/api/sessions/{session_id}/entry-points", handle_session_entry_points)
|
||||
app.router.add_get("/api/sessions/{session_id}/graphs", handle_session_graphs)
|
||||
app.router.add_get("/api/sessions/{session_id}/queen-messages", handle_queen_messages)
|
||||
|
||||
# Worker session browsing (session-primary)
|
||||
app.router.add_get("/api/sessions/{session_id}/worker-sessions", handle_list_worker_sessions)
|
||||
|
||||
@@ -40,6 +40,8 @@ class Session:
|
||||
runner: Any | None = None # AgentRunner
|
||||
worker_runtime: Any | None = None # AgentRuntime
|
||||
worker_info: Any | None = None # AgentInfo
|
||||
# Queen mode state (building/staging/running)
|
||||
mode_state: Any = None # QueenModeState
|
||||
# Judge (active when worker is loaded)
|
||||
judge_task: asyncio.Task | None = None
|
||||
escalation_sub: str | None = None
|
||||
@@ -52,10 +54,11 @@ class SessionManager:
|
||||
(blocking I/O) then started on the event loop.
|
||||
"""
|
||||
|
||||
def __init__(self, model: str | None = None) -> None:
|
||||
def __init__(self, model: str | None = None, credential_store=None) -> None:
|
||||
self._sessions: dict[str, Session] = {}
|
||||
self._loading: set[str] = set()
|
||||
self._model = model
|
||||
self._credential_store = credential_store
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -110,6 +113,7 @@ class SessionManager:
|
||||
self,
|
||||
session_id: str | None = None,
|
||||
model: str | None = None,
|
||||
initial_prompt: str | None = None,
|
||||
) -> Session:
|
||||
"""Create a new session with a queen but no worker.
|
||||
|
||||
@@ -119,7 +123,7 @@ class SessionManager:
|
||||
session = await self._create_session_core(session_id=session_id, model=model)
|
||||
|
||||
# Start queen immediately (queen-only, no worker tools yet)
|
||||
await self._start_queen(session, worker_identity=None)
|
||||
await self._start_queen(session, worker_identity=None, initial_prompt=initial_prompt)
|
||||
|
||||
logger.info("Session '%s' created (queen-only)", session.id)
|
||||
return session
|
||||
@@ -129,6 +133,7 @@ class SessionManager:
|
||||
agent_path: str | Path,
|
||||
agent_id: str | None = None,
|
||||
model: str | None = None,
|
||||
initial_prompt: str | None = None,
|
||||
) -> Session:
|
||||
"""Create a session and load a worker in one step.
|
||||
|
||||
@@ -157,13 +162,17 @@ class SessionManager:
|
||||
|
||||
# Start queen with worker profile + lifecycle + monitoring tools
|
||||
worker_identity = (
|
||||
build_worker_profile(session.worker_runtime) if session.worker_runtime else None
|
||||
build_worker_profile(session.worker_runtime, agent_path=agent_path)
|
||||
if session.worker_runtime
|
||||
else None
|
||||
)
|
||||
await self._start_queen(
|
||||
session, worker_identity=worker_identity, initial_prompt=initial_prompt
|
||||
)
|
||||
await self._start_queen(session, worker_identity=worker_identity)
|
||||
|
||||
# Start health judge
|
||||
if agent_path.name != "hive_coder" and session.worker_runtime:
|
||||
await self._start_judge(session, session.runner._storage_path)
|
||||
# Health judge disabled for simplicity.
|
||||
# if agent_path.name != "hive_coder" and session.worker_runtime:
|
||||
# await self._start_judge(session, session.runner._storage_path)
|
||||
|
||||
except Exception:
|
||||
# If anything fails, tear down the session
|
||||
@@ -210,6 +219,8 @@ class SessionManager:
|
||||
agent_path,
|
||||
model=resolved_model,
|
||||
interactive=False,
|
||||
skip_credential_validation=True,
|
||||
credential_store=self._credential_store,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -226,6 +237,9 @@ class SessionManager:
|
||||
if runtime and not runtime.is_running:
|
||||
await runtime.start()
|
||||
|
||||
# Clean up stale "active" sessions from previous (dead) processes
|
||||
self._cleanup_stale_active_sessions(agent_path)
|
||||
|
||||
info = runner.info()
|
||||
|
||||
# Update session
|
||||
@@ -249,6 +263,37 @@ class SessionManager:
|
||||
self._loading.discard(session.id)
|
||||
raise
|
||||
|
||||
def _cleanup_stale_active_sessions(self, agent_path: Path) -> None:
|
||||
"""Mark stale 'active' sessions on disk as 'cancelled'.
|
||||
|
||||
When a new runtime starts, any on-disk session still marked 'active'
|
||||
is from a process that no longer exists. 'Paused' sessions are left
|
||||
intact so they remain resumable.
|
||||
"""
|
||||
sessions_path = Path.home() / ".hive" / "agents" / agent_path.name / "sessions"
|
||||
if not sessions_path.exists():
|
||||
return
|
||||
|
||||
for d in sessions_path.iterdir():
|
||||
if not d.is_dir() or not d.name.startswith("session_"):
|
||||
continue
|
||||
state_path = d / "state.json"
|
||||
if not state_path.exists():
|
||||
continue
|
||||
try:
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
if state.get("status") != "active":
|
||||
continue
|
||||
state["status"] = "cancelled"
|
||||
state.setdefault("result", {})["error"] = "Stale session: runtime restarted"
|
||||
state.setdefault("timestamps", {})["updated_at"] = datetime.now().isoformat()
|
||||
state_path.write_text(json.dumps(state, indent=2), encoding="utf-8")
|
||||
logger.info(
|
||||
"Marked stale session '%s' as cancelled for agent '%s'", d.name, agent_path.name
|
||||
)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to clean up stale session %s: %s", d.name, e)
|
||||
|
||||
async def load_worker(
|
||||
self,
|
||||
session_id: str,
|
||||
@@ -273,9 +318,10 @@ class SessionManager:
|
||||
model=model,
|
||||
)
|
||||
|
||||
# Start judge + notify queen (skip for hive_coder itself)
|
||||
# Notify queen about the loaded worker (skip for hive_coder itself).
|
||||
# Health judge disabled for simplicity.
|
||||
if agent_path.name != "hive_coder" and session.worker_runtime:
|
||||
await self._start_judge(session, session.runner._storage_path)
|
||||
# await self._start_judge(session, session.runner._storage_path)
|
||||
await self._notify_queen_worker_loaded(session)
|
||||
|
||||
# Emit SSE event so the frontend can update UI
|
||||
@@ -353,6 +399,7 @@ class SessionManager:
|
||||
self,
|
||||
session: Session,
|
||||
worker_identity: str | None,
|
||||
initial_prompt: str | None = None,
|
||||
) -> None:
|
||||
"""Start the queen executor for a session."""
|
||||
from framework.agents.hive_coder.agent import (
|
||||
@@ -380,14 +427,26 @@ class SessionManager:
|
||||
except Exception:
|
||||
logger.warning("Queen: MCP config failed to load", exc_info=True)
|
||||
|
||||
# Mode state for building/running mode switching
|
||||
from framework.tools.queen_lifecycle_tools import (
|
||||
QueenModeState,
|
||||
register_queen_lifecycle_tools,
|
||||
)
|
||||
|
||||
# Start in staging when the caller provided an agent, building otherwise.
|
||||
initial_mode = "staging" if worker_identity else "building"
|
||||
mode_state = QueenModeState(mode=initial_mode, event_bus=session.event_bus)
|
||||
session.mode_state = mode_state
|
||||
|
||||
# Always register lifecycle tools — they check session.worker_runtime
|
||||
# at call time, so they work even if no worker is loaded yet.
|
||||
from framework.tools.queen_lifecycle_tools import register_queen_lifecycle_tools
|
||||
|
||||
register_queen_lifecycle_tools(
|
||||
queen_registry,
|
||||
session=session,
|
||||
session_id=session.id,
|
||||
session_manager=self,
|
||||
manager_session_id=session.id,
|
||||
mode_state=mode_state,
|
||||
)
|
||||
|
||||
# Monitoring tools need concrete worker paths — only register when present
|
||||
@@ -405,6 +464,32 @@ class SessionManager:
|
||||
queen_tools = list(queen_registry.get_tools().values())
|
||||
queen_tool_executor = queen_registry.get_executor()
|
||||
|
||||
# Partition tools into mode-specific sets
|
||||
from framework.agents.hive_coder.nodes import (
|
||||
_QUEEN_BUILDING_TOOLS,
|
||||
_QUEEN_RUNNING_TOOLS,
|
||||
_QUEEN_STAGING_TOOLS,
|
||||
)
|
||||
|
||||
building_names = set(_QUEEN_BUILDING_TOOLS)
|
||||
staging_names = set(_QUEEN_STAGING_TOOLS)
|
||||
running_names = set(_QUEEN_RUNNING_TOOLS)
|
||||
|
||||
registered_names = {t.name for t in queen_tools}
|
||||
missing_building = building_names - registered_names
|
||||
if missing_building:
|
||||
logger.warning(
|
||||
"Queen: %d/%d building tools NOT registered: %s",
|
||||
len(missing_building),
|
||||
len(building_names),
|
||||
sorted(missing_building),
|
||||
)
|
||||
logger.info("Queen: registered tools: %s", sorted(registered_names))
|
||||
|
||||
mode_state.building_tools = [t for t in queen_tools if t.name in building_names]
|
||||
mode_state.staging_tools = [t for t in queen_tools if t.name in staging_names]
|
||||
mode_state.running_tools = [t for t in queen_tools if t.name in running_names]
|
||||
|
||||
# Build queen graph with adjusted prompt + tools
|
||||
_orig_node = _queen_graph.nodes[0]
|
||||
base_prompt = _orig_node.system_prompt or ""
|
||||
@@ -445,20 +530,52 @@ class SessionManager:
|
||||
stream_id="queen",
|
||||
storage_path=queen_dir,
|
||||
loop_config=queen_graph.loop_config,
|
||||
execution_id=session.id,
|
||||
dynamic_tools_provider=mode_state.get_current_tools,
|
||||
)
|
||||
session.queen_executor = executor
|
||||
logger.info(
|
||||
"Queen starting with %d tools: %s",
|
||||
len(queen_tools),
|
||||
[t.name for t in queen_tools],
|
||||
|
||||
# Wire inject_notification so mode switches notify the queen LLM
|
||||
async def _inject_mode_notification(content: str) -> None:
|
||||
node = executor.node_registry.get("queen")
|
||||
if node is not None and hasattr(node, "inject_event"):
|
||||
await node.inject_event(content)
|
||||
|
||||
mode_state.inject_notification = _inject_mode_notification
|
||||
|
||||
# Auto-switch to staging when worker execution finishes naturally
|
||||
from framework.runtime.event_bus import EventType as _ET
|
||||
|
||||
async def _on_worker_done(event):
|
||||
if event.stream_id == "queen":
|
||||
return
|
||||
if mode_state.mode == "running":
|
||||
await mode_state.switch_to_staging(source="auto")
|
||||
|
||||
session.event_bus.subscribe(
|
||||
event_types=[_ET.EXECUTION_COMPLETED, _ET.EXECUTION_FAILED],
|
||||
handler=_on_worker_done,
|
||||
)
|
||||
await executor.execute(
|
||||
|
||||
logger.info(
|
||||
"Queen starting in %s mode with %d tools: %s",
|
||||
mode_state.mode,
|
||||
len(mode_state.get_current_tools()),
|
||||
[t.name for t in mode_state.get_current_tools()],
|
||||
)
|
||||
result = await executor.execute(
|
||||
graph=queen_graph,
|
||||
goal=queen_goal,
|
||||
input_data={"greeting": "Session started."},
|
||||
input_data={"greeting": initial_prompt or "Session started."},
|
||||
session_state={"resume_session_id": session.id},
|
||||
)
|
||||
logger.warning("Queen executor returned (should be forever-alive)")
|
||||
if result.success:
|
||||
logger.warning("Queen executor returned (should be forever-alive)")
|
||||
else:
|
||||
logger.error(
|
||||
"Queen executor failed: %s",
|
||||
result.error or "(no error message)",
|
||||
)
|
||||
except Exception:
|
||||
logger.error("Queen conversation crashed", exc_info=True)
|
||||
finally:
|
||||
@@ -591,7 +708,7 @@ class SessionManager:
|
||||
if node is None or not hasattr(node, "inject_event"):
|
||||
return
|
||||
|
||||
profile = build_worker_profile(session.worker_runtime)
|
||||
profile = build_worker_profile(session.worker_runtime, agent_path=session.worker_path)
|
||||
await node.inject_event(f"[SYSTEM] Worker loaded.{profile}")
|
||||
|
||||
async def _emit_worker_loaded(self, session: Session) -> None:
|
||||
|
||||
@@ -13,8 +13,8 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from framework.server.session_manager import Session
|
||||
from framework.server.app import create_app
|
||||
from framework.server.session_manager import Session
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock helpers
|
||||
@@ -74,6 +74,7 @@ class MockStream:
|
||||
is_awaiting_input: bool = False
|
||||
_execution_tasks: dict = field(default_factory=dict)
|
||||
_active_executors: dict = field(default_factory=dict)
|
||||
active_execution_ids: set = field(default_factory=set)
|
||||
|
||||
async def cancel_execution(self, execution_id: str) -> bool:
|
||||
return execution_id in self._execution_tasks
|
||||
@@ -117,6 +118,9 @@ class MockRuntime:
|
||||
async def inject_input(self, node_id, content, graph_id=None, *, is_client_input=False):
|
||||
return True
|
||||
|
||||
def pause_timers(self):
|
||||
pass
|
||||
|
||||
async def get_goal_progress(self):
|
||||
return {"progress": 0.5, "criteria": []}
|
||||
|
||||
@@ -537,18 +541,8 @@ class TestExecution:
|
||||
assert resp.status == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_not_found(self):
|
||||
session = _make_session()
|
||||
app = _make_app_with_session(session)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
resp = await client.post(
|
||||
"/api/sessions/test_agent/pause",
|
||||
json={"execution_id": "nonexistent"},
|
||||
)
|
||||
assert resp.status == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_missing_execution_id(self):
|
||||
async def test_pause_no_active_executions(self):
|
||||
"""Pause with no active executions returns stopped=False."""
|
||||
session = _make_session()
|
||||
app = _make_app_with_session(session)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
@@ -556,7 +550,26 @@ class TestExecution:
|
||||
"/api/sessions/test_agent/pause",
|
||||
json={},
|
||||
)
|
||||
assert resp.status == 400
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["stopped"] is False
|
||||
assert data["cancelled"] == []
|
||||
assert data["timers_paused"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_does_not_cancel_queen(self):
|
||||
"""Pause should stop the worker but leave the queen running."""
|
||||
session = _make_session()
|
||||
app = _make_app_with_session(session)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
resp = await client.post(
|
||||
"/api/sessions/test_agent/pause",
|
||||
json={},
|
||||
)
|
||||
assert resp.status == 200
|
||||
# Queen's cancel_current_turn should NOT have been called
|
||||
queen_node = session.queen_executor.node_registry["queen"]
|
||||
queen_node.cancel_current_turn.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_progress(self):
|
||||
|
||||
@@ -95,7 +95,7 @@ class CheckpointStore:
|
||||
return None
|
||||
|
||||
try:
|
||||
return Checkpoint.model_validate_json(checkpoint_path.read_text())
|
||||
return Checkpoint.model_validate_json(checkpoint_path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load checkpoint {checkpoint_id}: {e}")
|
||||
return None
|
||||
@@ -123,7 +123,9 @@ class CheckpointStore:
|
||||
return None
|
||||
|
||||
try:
|
||||
return CheckpointIndex.model_validate_json(self.index_path.read_text())
|
||||
return CheckpointIndex.model_validate_json(
|
||||
self.index_path.read_text(encoding="utf-8")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load checkpoint index: {e}")
|
||||
return None
|
||||
|
||||
@@ -114,7 +114,7 @@ class SessionStore:
|
||||
if not state_path.exists():
|
||||
return None
|
||||
|
||||
return SessionState.model_validate_json(state_path.read_text())
|
||||
return SessionState.model_validate_json(state_path.read_text(encoding="utf-8"))
|
||||
|
||||
return await asyncio.to_thread(_read)
|
||||
|
||||
@@ -151,7 +151,7 @@ class SessionStore:
|
||||
continue
|
||||
|
||||
try:
|
||||
state = SessionState.model_validate_json(state_path.read_text())
|
||||
state = SessionState.model_validate_json(state_path.read_text(encoding="utf-8"))
|
||||
|
||||
# Apply filters
|
||||
if status and state.status != status:
|
||||
|
||||
@@ -270,10 +270,10 @@ def _edit_test_code(code: str) -> str:
|
||||
|
||||
try:
|
||||
# Open editor
|
||||
subprocess.run([editor, temp_path], check=True)
|
||||
subprocess.run([editor, temp_path], check=True, encoding="utf-8")
|
||||
|
||||
# Read edited code
|
||||
with open(temp_path) as f:
|
||||
with open(temp_path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except subprocess.CalledProcessError:
|
||||
print("Editor failed, keeping original code")
|
||||
|
||||
@@ -11,10 +11,35 @@ Provides commands:
|
||||
import argparse
|
||||
import ast
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _check_pytest_available() -> bool:
|
||||
"""Check if pytest is available as a runnable command.
|
||||
|
||||
Returns True if pytest is found, otherwise prints an error message
|
||||
with install instructions and returns False.
|
||||
"""
|
||||
if shutil.which("pytest") is None:
|
||||
print(
|
||||
"Error: pytest is not installed or not on PATH.\n"
|
||||
"Hive's testing commands require pytest at runtime.\n"
|
||||
"Install it with:\n"
|
||||
"\n"
|
||||
" pip install 'framework[testing]'\n"
|
||||
"\n"
|
||||
"or if using uv:\n"
|
||||
"\n"
|
||||
" uv pip install 'framework[testing]'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def register_testing_commands(subparsers: argparse._SubParsersAction) -> None:
|
||||
"""Register testing CLI commands."""
|
||||
|
||||
@@ -105,6 +130,9 @@ def register_testing_commands(subparsers: argparse._SubParsersAction) -> None:
|
||||
|
||||
def cmd_test_run(args: argparse.Namespace) -> int:
|
||||
"""Run tests for an agent using pytest subprocess."""
|
||||
if not _check_pytest_available():
|
||||
return 1
|
||||
|
||||
agent_path = Path(args.agent_path)
|
||||
tests_dir = agent_path / "tests"
|
||||
|
||||
@@ -162,6 +190,7 @@ def cmd_test_run(args: argparse.Namespace) -> int:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
encoding="utf-8",
|
||||
env=env,
|
||||
timeout=600, # 10 minute timeout
|
||||
)
|
||||
@@ -177,7 +206,8 @@ def cmd_test_run(args: argparse.Namespace) -> int:
|
||||
|
||||
def cmd_test_debug(args: argparse.Namespace) -> int:
|
||||
"""Debug a failed test by re-running with verbose output."""
|
||||
import subprocess
|
||||
if not _check_pytest_available():
|
||||
return 1
|
||||
|
||||
agent_path = Path(args.agent_path)
|
||||
test_name = args.test_name
|
||||
@@ -190,7 +220,7 @@ def cmd_test_debug(args: argparse.Namespace) -> int:
|
||||
# Find which file contains the test
|
||||
test_file = None
|
||||
for py_file in tests_dir.glob("test_*.py"):
|
||||
content = py_file.read_text()
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
if f"def {test_name}" in content or f"async def {test_name}" in content:
|
||||
test_file = py_file
|
||||
break
|
||||
@@ -219,6 +249,7 @@ def cmd_test_debug(args: argparse.Namespace) -> int:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
encoding="utf-8",
|
||||
env=env,
|
||||
timeout=120, # 2 minute timeout for single test
|
||||
)
|
||||
@@ -238,7 +269,7 @@ def _scan_test_files(tests_dir: Path) -> list[dict]:
|
||||
|
||||
for test_file in sorted(tests_dir.glob("test_*.py")):
|
||||
try:
|
||||
content = test_file.read_text()
|
||||
content = test_file.read_text(encoding="utf-8")
|
||||
tree = ast.parse(content)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
|
||||
@@ -33,12 +33,18 @@ Usage::
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from framework.credentials.models import CredentialError
|
||||
from framework.runner.preload_validation import credential_errors_to_json, validate_credentials
|
||||
from framework.runtime.event_bus import AgentEvent, EventType
|
||||
from framework.server.app import validate_agent_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from framework.runner.tool_registry import ToolRegistry
|
||||
from framework.runtime.agent_runtime import AgentRuntime
|
||||
@@ -60,7 +66,126 @@ class WorkerSessionAdapter:
|
||||
worker_path: Path | None = None
|
||||
|
||||
|
||||
def build_worker_profile(runtime: AgentRuntime) -> str:
|
||||
@dataclass
|
||||
class QueenModeState:
|
||||
"""Mutable state container for queen operating mode.
|
||||
|
||||
Three modes: building → staging → running.
|
||||
Shared between the dynamic_tools_provider callback and tool handlers
|
||||
that trigger mode transitions.
|
||||
"""
|
||||
|
||||
mode: str = "building" # "building", "staging", or "running"
|
||||
building_tools: list = field(default_factory=list) # list[Tool]
|
||||
staging_tools: list = field(default_factory=list) # list[Tool]
|
||||
running_tools: list = field(default_factory=list) # list[Tool]
|
||||
inject_notification: Any = None # async (str) -> None
|
||||
event_bus: Any = None # EventBus — for emitting QUEEN_MODE_CHANGED events
|
||||
|
||||
def get_current_tools(self) -> list:
|
||||
"""Return tools for the current mode."""
|
||||
if self.mode == "running":
|
||||
return list(self.running_tools)
|
||||
if self.mode == "staging":
|
||||
return list(self.staging_tools)
|
||||
return list(self.building_tools)
|
||||
|
||||
async def _emit_mode_event(self) -> None:
|
||||
"""Publish a QUEEN_MODE_CHANGED event so the frontend updates the tag."""
|
||||
if self.event_bus is not None:
|
||||
await self.event_bus.publish(
|
||||
AgentEvent(
|
||||
type=EventType.QUEEN_MODE_CHANGED,
|
||||
stream_id="queen",
|
||||
data={"mode": self.mode},
|
||||
)
|
||||
)
|
||||
|
||||
async def switch_to_running(self, source: str = "tool") -> None:
|
||||
"""Switch to running mode and notify the queen.
|
||||
|
||||
Args:
|
||||
source: Who triggered the switch — "tool" (queen LLM),
|
||||
"frontend" (user clicked Run), or "auto" (system).
|
||||
"""
|
||||
if self.mode == "running":
|
||||
return
|
||||
self.mode = "running"
|
||||
tool_names = [t.name for t in self.running_tools]
|
||||
logger.info("Queen mode → running (source=%s, tools: %s)", source, tool_names)
|
||||
await self._emit_mode_event()
|
||||
if self.inject_notification:
|
||||
if source == "frontend":
|
||||
msg = (
|
||||
"[MODE CHANGE] The user clicked Run in the UI. Switched to RUNNING mode. "
|
||||
"Worker is now executing. You have monitoring/lifecycle tools: "
|
||||
+ ", ".join(tool_names)
|
||||
+ "."
|
||||
)
|
||||
else:
|
||||
msg = (
|
||||
"[MODE CHANGE] Switched to RUNNING mode. "
|
||||
"Worker is executing. You now have monitoring/lifecycle tools: "
|
||||
+ ", ".join(tool_names)
|
||||
+ "."
|
||||
)
|
||||
await self.inject_notification(msg)
|
||||
|
||||
async def switch_to_staging(self, source: str = "tool") -> None:
|
||||
"""Switch to staging mode and notify the queen.
|
||||
|
||||
Args:
|
||||
source: Who triggered the switch — "tool", "frontend", or "auto".
|
||||
"""
|
||||
if self.mode == "staging":
|
||||
return
|
||||
self.mode = "staging"
|
||||
tool_names = [t.name for t in self.staging_tools]
|
||||
logger.info("Queen mode → staging (source=%s, tools: %s)", source, tool_names)
|
||||
await self._emit_mode_event()
|
||||
if self.inject_notification:
|
||||
if source == "frontend":
|
||||
msg = (
|
||||
"[MODE CHANGE] The user stopped the worker from the UI. "
|
||||
"Switched to STAGING mode. Agent is still loaded. "
|
||||
"Available tools: " + ", ".join(tool_names) + "."
|
||||
)
|
||||
elif source == "auto":
|
||||
msg = (
|
||||
"[MODE CHANGE] Worker execution completed. Switched to STAGING mode. "
|
||||
"Agent is still loaded. Call run_agent_with_input(task) to run again. "
|
||||
"Available tools: " + ", ".join(tool_names) + "."
|
||||
)
|
||||
else:
|
||||
msg = (
|
||||
"[MODE CHANGE] Switched to STAGING mode. "
|
||||
"Agent loaded and ready. Call run_agent_with_input(task) to start, "
|
||||
"or stop_worker_and_edit() to go back to building. "
|
||||
"Available tools: " + ", ".join(tool_names) + "."
|
||||
)
|
||||
await self.inject_notification(msg)
|
||||
|
||||
async def switch_to_building(self, source: str = "tool") -> None:
|
||||
"""Switch to building mode and notify the queen.
|
||||
|
||||
Args:
|
||||
source: Who triggered the switch — "tool", "frontend", or "auto".
|
||||
"""
|
||||
if self.mode == "building":
|
||||
return
|
||||
self.mode = "building"
|
||||
tool_names = [t.name for t in self.building_tools]
|
||||
logger.info("Queen mode → building (source=%s, tools: %s)", source, tool_names)
|
||||
await self._emit_mode_event()
|
||||
if self.inject_notification:
|
||||
await self.inject_notification(
|
||||
"[MODE CHANGE] Switched to BUILDING mode. "
|
||||
"Lifecycle tools removed. Full coding tools restored. "
|
||||
"Call load_built_agent(path) when ready to stage."
|
||||
)
|
||||
|
||||
|
||||
def build_worker_profile(runtime: AgentRuntime, agent_path: Path | str | None = None) -> str:
|
||||
"""Build a worker capability profile from its graph/goal definition.
|
||||
|
||||
Injected into the queen's system prompt so it knows what the worker
|
||||
@@ -71,6 +196,8 @@ def build_worker_profile(runtime: AgentRuntime) -> str:
|
||||
|
||||
lines = ["\n\n# Worker Profile"]
|
||||
lines.append(f"Agent: {runtime.graph_id}")
|
||||
if agent_path:
|
||||
lines.append(f"Path: {agent_path}")
|
||||
lines.append(f"Goal: {goal.name}")
|
||||
if goal.description:
|
||||
lines.append(f"Description: {goal.description}")
|
||||
@@ -112,6 +239,8 @@ def register_queen_lifecycle_tools(
|
||||
# Server context — enables load_built_agent tool
|
||||
session_manager: Any = None,
|
||||
manager_session_id: str | None = None,
|
||||
# Mode switching
|
||||
mode_state: QueenModeState | None = None,
|
||||
) -> int:
|
||||
"""Register queen lifecycle tools.
|
||||
|
||||
@@ -128,6 +257,9 @@ def register_queen_lifecycle_tools(
|
||||
for ``load_built_agent`` to hot-load a worker.
|
||||
manager_session_id: (Server only) The session's ID in the manager,
|
||||
used with ``session_manager.load_worker()``.
|
||||
mode_state: (Optional) Mutable mode state for building/running
|
||||
mode switching. When provided, load_built_agent switches to
|
||||
running mode and stop_worker_and_edit switches to building mode.
|
||||
|
||||
Returns the number of tools registered.
|
||||
"""
|
||||
@@ -151,6 +283,11 @@ def register_queen_lifecycle_tools(
|
||||
|
||||
# --- start_worker ---------------------------------------------------------
|
||||
|
||||
# How long to wait for credential validation + MCP resync before
|
||||
# proceeding with trigger anyway. These are pre-flight checks that
|
||||
# should not block the queen indefinitely.
|
||||
_START_PREFLIGHT_TIMEOUT = 15 # seconds
|
||||
|
||||
async def start_worker(task: str) -> str:
|
||||
"""Start the worker agent with a task description.
|
||||
|
||||
@@ -162,8 +299,53 @@ def register_queen_lifecycle_tools(
|
||||
return json.dumps({"error": "No worker loaded in this session."})
|
||||
|
||||
try:
|
||||
# Pre-flight: validate credentials and resync MCP servers.
|
||||
# Both are blocking I/O (HTTP health-checks, subprocess spawns)
|
||||
# so they run in a thread-pool executor. We cap the total
|
||||
# preflight time so the queen never hangs waiting.
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
async def _preflight():
|
||||
cred_error: CredentialError | None = None
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: validate_credentials(
|
||||
runtime.graph.nodes,
|
||||
interactive=False,
|
||||
skip=False,
|
||||
),
|
||||
)
|
||||
except CredentialError as e:
|
||||
cred_error = e
|
||||
|
||||
runner = getattr(session, "runner", None)
|
||||
if runner:
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: runner._tool_registry.resync_mcp_servers_if_needed(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("MCP resync failed: %s", e)
|
||||
|
||||
# Re-raise CredentialError after MCP resync so both steps
|
||||
# get a chance to run before we bail.
|
||||
if cred_error is not None:
|
||||
raise cred_error
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(_preflight(), timeout=_START_PREFLIGHT_TIMEOUT)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"start_worker preflight timed out after %ds — proceeding with trigger",
|
||||
_START_PREFLIGHT_TIMEOUT,
|
||||
)
|
||||
except CredentialError:
|
||||
raise # handled below
|
||||
|
||||
# Resume timers in case they were paused by a previous stop_worker
|
||||
worker_runtime.resume_timers()
|
||||
runtime.resume_timers()
|
||||
|
||||
# Get session state from any prior execution for memory continuity
|
||||
session_state = runtime._get_primary_session_state("default") or {}
|
||||
@@ -185,6 +367,23 @@ def register_queen_lifecycle_tools(
|
||||
"task": task,
|
||||
}
|
||||
)
|
||||
except CredentialError as e:
|
||||
# Build structured error with per-credential details so the
|
||||
# queen can report exactly what's missing and how to fix it.
|
||||
error_payload = credential_errors_to_json(e)
|
||||
error_payload["agent_path"] = str(getattr(session, "worker_path", "") or "")
|
||||
|
||||
# Emit SSE event so the frontend opens the credentials modal
|
||||
bus = getattr(session, "event_bus", None)
|
||||
if bus is not None:
|
||||
await bus.publish(
|
||||
AgentEvent(
|
||||
type=EventType.CREDENTIALS_REQUIRED,
|
||||
stream_id="queen",
|
||||
data=error_payload,
|
||||
)
|
||||
)
|
||||
return json.dumps(error_payload)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": f"Failed to start worker: {e}"})
|
||||
|
||||
@@ -211,33 +410,43 @@ def register_queen_lifecycle_tools(
|
||||
# --- stop_worker ----------------------------------------------------------
|
||||
|
||||
async def stop_worker() -> str:
|
||||
"""Cancel all active worker executions.
|
||||
"""Cancel all active worker executions across all graphs.
|
||||
|
||||
Stops the worker gracefully. Returns the IDs of cancelled executions.
|
||||
Stops the worker immediately. Returns the IDs of cancelled executions.
|
||||
"""
|
||||
runtime = _get_runtime()
|
||||
if runtime is None:
|
||||
return json.dumps({"error": "No worker loaded in this session."})
|
||||
|
||||
cancelled = []
|
||||
graph_id = runtime.graph_id
|
||||
|
||||
# Get the primary graph's streams
|
||||
reg = runtime.get_graph_registration(graph_id)
|
||||
if reg is None:
|
||||
return json.dumps({"error": "Worker graph not found"})
|
||||
# Iterate ALL registered graphs — multiple entrypoint requests
|
||||
# can spawn executions in different graphs within the same session.
|
||||
for graph_id in runtime.list_graphs():
|
||||
reg = runtime.get_graph_registration(graph_id)
|
||||
if reg is None:
|
||||
continue
|
||||
|
||||
for _ep_id, stream in reg.streams.items():
|
||||
for exec_id in list(stream.active_execution_ids):
|
||||
try:
|
||||
ok = await stream.cancel_execution(exec_id)
|
||||
if ok:
|
||||
cancelled.append(exec_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to cancel %s: %s", exec_id, e)
|
||||
for _ep_id, stream in reg.streams.items():
|
||||
# Signal shutdown on all active EventLoopNodes first so they
|
||||
# exit cleanly and cancel their in-flight LLM streams.
|
||||
for executor in stream._active_executors.values():
|
||||
for node in executor.node_registry.values():
|
||||
if hasattr(node, "signal_shutdown"):
|
||||
node.signal_shutdown()
|
||||
if hasattr(node, "cancel_current_turn"):
|
||||
node.cancel_current_turn()
|
||||
|
||||
for exec_id in list(stream.active_execution_ids):
|
||||
try:
|
||||
ok = await stream.cancel_execution(exec_id)
|
||||
if ok:
|
||||
cancelled.append(exec_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to cancel %s: %s", exec_id, e)
|
||||
|
||||
# Pause timers so the next tick doesn't restart execution
|
||||
worker_runtime.pause_timers()
|
||||
runtime.pause_timers()
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
@@ -258,13 +467,117 @@ def register_queen_lifecycle_tools(
|
||||
registry.register("stop_worker", _stop_tool, lambda inputs: stop_worker())
|
||||
tools_registered += 1
|
||||
|
||||
# --- stop_worker_and_edit -------------------------------------------------
|
||||
|
||||
async def stop_worker_and_edit() -> str:
|
||||
"""Stop the worker and switch to building mode for editing the agent."""
|
||||
stop_result = await stop_worker()
|
||||
|
||||
# Switch to building mode
|
||||
if mode_state is not None:
|
||||
await mode_state.switch_to_building()
|
||||
|
||||
result = json.loads(stop_result)
|
||||
result["mode"] = "building"
|
||||
result["message"] = (
|
||||
"Worker stopped. You are now in building mode. "
|
||||
"Use your coding tools to modify the agent, then call "
|
||||
"load_built_agent(path) to stage it again."
|
||||
)
|
||||
return json.dumps(result)
|
||||
|
||||
_stop_edit_tool = Tool(
|
||||
name="stop_worker_and_edit",
|
||||
description=(
|
||||
"Stop the running worker and switch to building mode. "
|
||||
"Use this when you need to modify the agent's code, nodes, or configuration. "
|
||||
"After editing, call load_built_agent(path) to reload and run."
|
||||
),
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
registry.register(
|
||||
"stop_worker_and_edit", _stop_edit_tool, lambda inputs: stop_worker_and_edit()
|
||||
)
|
||||
tools_registered += 1
|
||||
|
||||
# --- stop_worker (Running → Staging) -------------------------------------
|
||||
|
||||
async def stop_worker_to_staging() -> str:
|
||||
"""Stop the running worker and switch to staging mode.
|
||||
|
||||
After stopping, ask the user whether they want to:
|
||||
1. Re-run the agent with new input → call run_agent_with_input(task)
|
||||
2. Edit the agent code → call stop_worker_and_edit() to go to building mode
|
||||
"""
|
||||
stop_result = await stop_worker()
|
||||
|
||||
# Switch to staging mode
|
||||
if mode_state is not None:
|
||||
await mode_state.switch_to_staging()
|
||||
|
||||
result = json.loads(stop_result)
|
||||
result["mode"] = "staging"
|
||||
result["message"] = (
|
||||
"Worker stopped. You are now in staging mode. "
|
||||
"Ask the user: would they like to re-run with new input, "
|
||||
"or edit the agent code?"
|
||||
)
|
||||
return json.dumps(result)
|
||||
|
||||
_stop_worker_tool = Tool(
|
||||
name="stop_worker",
|
||||
description=(
|
||||
"Stop the running worker and switch to staging mode. "
|
||||
"After stopping, ask the user whether they want to re-run "
|
||||
"with new input or edit the agent code."
|
||||
),
|
||||
parameters={"type": "object", "properties": {}},
|
||||
)
|
||||
registry.register("stop_worker", _stop_worker_tool, lambda inputs: stop_worker_to_staging())
|
||||
tools_registered += 1
|
||||
|
||||
# --- get_worker_status ----------------------------------------------------
|
||||
|
||||
async def get_worker_status() -> str:
|
||||
"""Check if the worker is idle, running, or waiting for user input.
|
||||
def _get_event_bus():
|
||||
"""Get the session's event bus for querying history."""
|
||||
return getattr(session, "event_bus", None)
|
||||
|
||||
Returns worker identity, execution state, active node, and iteration count.
|
||||
_status_last_called: dict[str, float] = {} # {"ts": monotonic time}
|
||||
_STATUS_COOLDOWN = 30.0 # seconds between full status checks
|
||||
|
||||
async def get_worker_status(last_n: int = 20) -> str:
|
||||
"""Comprehensive worker status: state, execution details, and recent activity.
|
||||
|
||||
Returns everything the queen needs in a single call:
|
||||
- Identity and high-level state (idle / running / waiting_for_input)
|
||||
- Active execution details (elapsed time, current node, iteration)
|
||||
- Running tool calls (started but not yet completed)
|
||||
- Recent completed tool calls (name, success/error)
|
||||
- Node transitions (execution path)
|
||||
- Retries, stalls, and constraint violations
|
||||
- Goal progress and token consumption
|
||||
|
||||
Args:
|
||||
last_n: Number of recent events to include per category (default 20).
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
now = _time.monotonic()
|
||||
last = _status_last_called.get("ts", 0.0)
|
||||
if now - last < _STATUS_COOLDOWN:
|
||||
remaining = int(_STATUS_COOLDOWN - (now - last))
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "cooldown",
|
||||
"message": (
|
||||
f"Status was checked {int(now - last)}s ago. "
|
||||
f"Wait {remaining}s before checking again. "
|
||||
"Do NOT call this tool in a loop — wait for user input instead."
|
||||
),
|
||||
}
|
||||
)
|
||||
_status_last_called["ts"] = now
|
||||
|
||||
runtime = _get_runtime()
|
||||
if runtime is None:
|
||||
return json.dumps({"status": "not_loaded", "message": "No worker loaded."})
|
||||
@@ -275,55 +588,235 @@ def register_queen_lifecycle_tools(
|
||||
if reg is None:
|
||||
return json.dumps({"status": "not_loaded"})
|
||||
|
||||
base = {
|
||||
result: dict[str, Any] = {
|
||||
"worker_graph_id": graph_id,
|
||||
"worker_goal": getattr(goal, "name", graph_id),
|
||||
}
|
||||
|
||||
# --- Execution state ---
|
||||
active_execs = []
|
||||
for ep_id, stream in reg.streams.items():
|
||||
for exec_id in stream.active_execution_ids:
|
||||
active_execs.append(
|
||||
{
|
||||
"execution_id": exec_id,
|
||||
"entry_point": ep_id,
|
||||
}
|
||||
)
|
||||
exec_info: dict[str, Any] = {
|
||||
"execution_id": exec_id,
|
||||
"entry_point": ep_id,
|
||||
}
|
||||
ctx = stream.get_context(exec_id)
|
||||
if ctx:
|
||||
from datetime import datetime
|
||||
|
||||
elapsed = (datetime.now() - ctx.started_at).total_seconds()
|
||||
exec_info["elapsed_seconds"] = round(elapsed, 1)
|
||||
exec_info["exec_status"] = ctx.status
|
||||
active_execs.append(exec_info)
|
||||
|
||||
if not active_execs:
|
||||
return json.dumps(
|
||||
{
|
||||
**base,
|
||||
"status": "idle",
|
||||
"message": "Worker has no active executions.",
|
||||
}
|
||||
result["status"] = "idle"
|
||||
result["message"] = "Worker has no active executions."
|
||||
else:
|
||||
waiting_nodes = []
|
||||
for _ep_id, stream in reg.streams.items():
|
||||
waiting_nodes.extend(stream.get_waiting_nodes())
|
||||
|
||||
result["status"] = "waiting_for_input" if waiting_nodes else "running"
|
||||
result["active_executions"] = active_execs
|
||||
if waiting_nodes:
|
||||
result["waiting_node_id"] = waiting_nodes[0]["node_id"]
|
||||
|
||||
result["agent_idle_seconds"] = round(runtime.agent_idle_seconds, 1)
|
||||
|
||||
# --- EventBus enrichment ---
|
||||
bus = _get_event_bus()
|
||||
if not bus:
|
||||
return json.dumps(result)
|
||||
|
||||
try:
|
||||
# Pending user question (from ask_user tool)
|
||||
if result.get("status") == "waiting_for_input":
|
||||
input_events = bus.get_history(event_type=EventType.CLIENT_INPUT_REQUESTED, limit=1)
|
||||
if input_events:
|
||||
prompt = input_events[0].data.get("prompt", "")
|
||||
if prompt:
|
||||
result["pending_question"] = prompt
|
||||
# Current node
|
||||
edge_events = bus.get_history(event_type=EventType.EDGE_TRAVERSED, limit=1)
|
||||
if edge_events:
|
||||
target = edge_events[0].data.get("target_node")
|
||||
if target:
|
||||
result["current_node"] = target
|
||||
|
||||
# Current iteration
|
||||
iter_events = bus.get_history(event_type=EventType.NODE_LOOP_ITERATION, limit=1)
|
||||
if iter_events:
|
||||
result["current_iteration"] = iter_events[0].data.get("iteration")
|
||||
|
||||
# Running tool calls (started but not yet completed)
|
||||
tool_started = bus.get_history(event_type=EventType.TOOL_CALL_STARTED, limit=last_n * 2)
|
||||
tool_completed = bus.get_history(
|
||||
event_type=EventType.TOOL_CALL_COMPLETED, limit=last_n * 2
|
||||
)
|
||||
completed_ids = {
|
||||
evt.data.get("tool_use_id") for evt in tool_completed if evt.data.get("tool_use_id")
|
||||
}
|
||||
running = [
|
||||
evt
|
||||
for evt in tool_started
|
||||
if evt.data.get("tool_use_id") and evt.data.get("tool_use_id") not in completed_ids
|
||||
]
|
||||
if running:
|
||||
result["running_tools"] = [
|
||||
{
|
||||
"tool": evt.data.get("tool_name"),
|
||||
"node": evt.node_id,
|
||||
"started_at": evt.timestamp.isoformat(),
|
||||
"input_preview": str(evt.data.get("tool_input", ""))[:200],
|
||||
}
|
||||
for evt in running
|
||||
]
|
||||
|
||||
# Check if the worker is waiting for user input
|
||||
waiting_nodes = []
|
||||
for _ep_id, stream in reg.streams.items():
|
||||
waiting_nodes.extend(stream.get_waiting_nodes())
|
||||
# Recent completed tool calls
|
||||
if tool_completed:
|
||||
result["recent_tool_calls"] = [
|
||||
{
|
||||
"tool": evt.data.get("tool_name"),
|
||||
"error": bool(evt.data.get("is_error")),
|
||||
"node": evt.node_id,
|
||||
"time": evt.timestamp.isoformat(),
|
||||
}
|
||||
for evt in tool_completed[:last_n]
|
||||
]
|
||||
|
||||
status = "waiting_for_input" if waiting_nodes else "running"
|
||||
result = {
|
||||
**base,
|
||||
"status": status,
|
||||
"active_executions": active_execs,
|
||||
}
|
||||
if waiting_nodes:
|
||||
result["waiting_node_id"] = waiting_nodes[0]["node_id"]
|
||||
return json.dumps(result)
|
||||
# Node transitions
|
||||
edges = bus.get_history(event_type=EventType.EDGE_TRAVERSED, limit=last_n)
|
||||
if edges:
|
||||
result["node_transitions"] = [
|
||||
{
|
||||
"from": evt.data.get("source_node"),
|
||||
"to": evt.data.get("target_node"),
|
||||
"condition": evt.data.get("edge_condition"),
|
||||
"time": evt.timestamp.isoformat(),
|
||||
}
|
||||
for evt in edges
|
||||
]
|
||||
|
||||
# Retries
|
||||
retries = bus.get_history(event_type=EventType.NODE_RETRY, limit=last_n)
|
||||
if retries:
|
||||
result["retries"] = [
|
||||
{
|
||||
"node": evt.node_id,
|
||||
"retry_count": evt.data.get("retry_count"),
|
||||
"error": evt.data.get("error", "")[:200],
|
||||
"time": evt.timestamp.isoformat(),
|
||||
}
|
||||
for evt in retries
|
||||
]
|
||||
|
||||
# Stalls and doom loops
|
||||
stalls = bus.get_history(event_type=EventType.NODE_STALLED, limit=5)
|
||||
doom_loops = bus.get_history(event_type=EventType.NODE_TOOL_DOOM_LOOP, limit=5)
|
||||
issues = []
|
||||
for evt in stalls:
|
||||
issues.append(
|
||||
{
|
||||
"type": "stall",
|
||||
"node": evt.node_id,
|
||||
"reason": evt.data.get("reason", "")[:200],
|
||||
"time": evt.timestamp.isoformat(),
|
||||
}
|
||||
)
|
||||
for evt in doom_loops:
|
||||
issues.append(
|
||||
{
|
||||
"type": "tool_doom_loop",
|
||||
"node": evt.node_id,
|
||||
"description": evt.data.get("description", "")[:200],
|
||||
"time": evt.timestamp.isoformat(),
|
||||
}
|
||||
)
|
||||
if issues:
|
||||
result["issues"] = issues
|
||||
|
||||
# Constraint violations
|
||||
violations = bus.get_history(event_type=EventType.CONSTRAINT_VIOLATION, limit=5)
|
||||
if violations:
|
||||
result["constraint_violations"] = [
|
||||
{
|
||||
"constraint": evt.data.get("constraint_id"),
|
||||
"description": evt.data.get("description", "")[:200],
|
||||
"time": evt.timestamp.isoformat(),
|
||||
}
|
||||
for evt in violations
|
||||
]
|
||||
|
||||
# Goal progress
|
||||
try:
|
||||
progress = await runtime.get_goal_progress()
|
||||
if progress:
|
||||
result["goal_progress"] = progress
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Token summary
|
||||
llm_events = bus.get_history(event_type=EventType.LLM_TURN_COMPLETE, limit=200)
|
||||
if llm_events:
|
||||
total_in = sum(evt.data.get("input_tokens", 0) or 0 for evt in llm_events)
|
||||
total_out = sum(evt.data.get("output_tokens", 0) or 0 for evt in llm_events)
|
||||
result["token_summary"] = {
|
||||
"llm_turns": len(llm_events),
|
||||
"input_tokens": total_in,
|
||||
"output_tokens": total_out,
|
||||
"total_tokens": total_in + total_out,
|
||||
}
|
||||
|
||||
# Execution completions/failures
|
||||
exec_completed = bus.get_history(event_type=EventType.EXECUTION_COMPLETED, limit=5)
|
||||
exec_failed = bus.get_history(event_type=EventType.EXECUTION_FAILED, limit=5)
|
||||
if exec_completed or exec_failed:
|
||||
result["execution_outcomes"] = []
|
||||
for evt in exec_completed:
|
||||
result["execution_outcomes"].append(
|
||||
{
|
||||
"outcome": "completed",
|
||||
"execution_id": evt.execution_id,
|
||||
"time": evt.timestamp.isoformat(),
|
||||
}
|
||||
)
|
||||
for evt in exec_failed:
|
||||
result["execution_outcomes"].append(
|
||||
{
|
||||
"outcome": "failed",
|
||||
"execution_id": evt.execution_id,
|
||||
"error": evt.data.get("error", "")[:200],
|
||||
"time": evt.timestamp.isoformat(),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass # Non-critical enrichment
|
||||
|
||||
return json.dumps(result, default=str, ensure_ascii=False)
|
||||
|
||||
_status_tool = Tool(
|
||||
name="get_worker_status",
|
||||
description=(
|
||||
"Check the worker agent's current state: idle (no execution), "
|
||||
"running (actively processing), or waiting_for_input (blocked on "
|
||||
"user response). Returns execution details."
|
||||
"Get comprehensive worker status: state (idle/running/waiting_for_input), "
|
||||
"execution details (elapsed time, current node, iteration), "
|
||||
"recent tool calls, running tools, node transitions, retries, "
|
||||
"stalls, constraint violations, goal progress, and token consumption. "
|
||||
"One call gives the queen a complete picture."
|
||||
),
|
||||
parameters={"type": "object", "properties": {}},
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"last_n": {
|
||||
"type": "integer",
|
||||
"description": "Number of recent events per category (default 20)",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
)
|
||||
registry.register("get_worker_status", _status_tool, lambda inputs: get_worker_status())
|
||||
registry.register("get_worker_status", _status_tool, lambda inputs: get_worker_status(**inputs))
|
||||
tools_registered += 1
|
||||
|
||||
# --- inject_worker_message ------------------------------------------------
|
||||
@@ -348,7 +841,7 @@ def register_queen_lifecycle_tools(
|
||||
injectable = stream.get_injectable_nodes()
|
||||
if injectable:
|
||||
target_node_id = injectable[0]["node_id"]
|
||||
ok = await stream.inject_input(target_node_id, content)
|
||||
ok = await stream.inject_input(target_node_id, content, is_client_input=True)
|
||||
if ok:
|
||||
return json.dumps(
|
||||
{
|
||||
@@ -387,6 +880,105 @@ def register_queen_lifecycle_tools(
|
||||
)
|
||||
tools_registered += 1
|
||||
|
||||
# --- list_credentials -----------------------------------------------------
|
||||
|
||||
async def list_credentials(credential_id: str = "") -> str:
|
||||
"""List all authorized credentials (Aden OAuth + local encrypted store).
|
||||
|
||||
Returns credential IDs, aliases, status, and identity metadata.
|
||||
Never returns secret values. Optionally filter by credential_id.
|
||||
"""
|
||||
try:
|
||||
# Primary: CredentialStoreAdapter sees both Aden OAuth and local accounts
|
||||
from aden_tools.credentials import CredentialStoreAdapter
|
||||
|
||||
store = CredentialStoreAdapter.default()
|
||||
all_accounts = store.get_all_account_info()
|
||||
|
||||
# Filter by credential_id / provider if requested
|
||||
if credential_id:
|
||||
all_accounts = [
|
||||
a
|
||||
for a in all_accounts
|
||||
if a.get("credential_id", "").startswith(credential_id)
|
||||
or a.get("provider", "") == credential_id
|
||||
]
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"count": len(all_accounts),
|
||||
"credentials": all_accounts,
|
||||
},
|
||||
default=str,
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
return json.dumps({"error": f"Failed to list credentials: {e}"})
|
||||
|
||||
# Fallback: local encrypted store only
|
||||
try:
|
||||
from framework.credentials.local.registry import LocalCredentialRegistry
|
||||
|
||||
registry = LocalCredentialRegistry.default()
|
||||
accounts = registry.list_accounts(
|
||||
credential_id=credential_id or None,
|
||||
)
|
||||
|
||||
credentials = []
|
||||
for info in accounts:
|
||||
entry: dict[str, Any] = {
|
||||
"credential_id": info.credential_id,
|
||||
"alias": info.alias,
|
||||
"storage_id": info.storage_id,
|
||||
"status": info.status,
|
||||
"created_at": info.created_at.isoformat() if info.created_at else None,
|
||||
"last_validated": (
|
||||
info.last_validated.isoformat() if info.last_validated else None
|
||||
),
|
||||
}
|
||||
identity = info.identity.to_dict()
|
||||
if identity:
|
||||
entry["identity"] = identity
|
||||
credentials.append(entry)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"count": len(credentials),
|
||||
"credentials": credentials,
|
||||
"location": "~/.hive/credentials",
|
||||
},
|
||||
default=str,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": f"Failed to list credentials: {e}"})
|
||||
|
||||
_list_creds_tool = Tool(
|
||||
name="list_credentials",
|
||||
description=(
|
||||
"List all authorized credentials in the local store. Returns credential IDs, "
|
||||
"aliases, status (active/failed/unknown), and identity metadata — never secret "
|
||||
"values. Optionally filter by credential_id (e.g. 'brave_search')."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"credential_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Filter to a specific credential type (e.g. 'brave_search'). "
|
||||
"Omit to list all credentials."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
)
|
||||
registry.register(
|
||||
"list_credentials", _list_creds_tool, lambda inputs: list_credentials(**inputs)
|
||||
)
|
||||
tools_registered += 1
|
||||
|
||||
# --- load_built_agent (server context only) --------------------------------
|
||||
|
||||
if session_manager is not None and manager_session_id is not None:
|
||||
@@ -400,16 +992,18 @@ def register_queen_lifecycle_tools(
|
||||
"""
|
||||
runtime = _get_runtime()
|
||||
if runtime is not None:
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "A worker is already loaded in this session. "
|
||||
"Unload it first or open a new tab."
|
||||
}
|
||||
)
|
||||
try:
|
||||
await session_manager.unload_worker(manager_session_id)
|
||||
except Exception as e:
|
||||
logger.error("Failed to unload existing worker: %s", e, exc_info=True)
|
||||
return json.dumps({"error": f"Failed to unload existing worker: {e}"})
|
||||
|
||||
resolved_path = Path(agent_path).resolve()
|
||||
try:
|
||||
resolved_path = validate_agent_path(agent_path)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
if not resolved_path.exists():
|
||||
return json.dumps({"error": f"Agent path does not exist: {resolved_path}"})
|
||||
return json.dumps({"error": f"Agent path does not exist: {agent_path}"})
|
||||
|
||||
try:
|
||||
updated_session = await session_manager.load_worker(
|
||||
@@ -417,16 +1011,30 @@ def register_queen_lifecycle_tools(
|
||||
str(resolved_path),
|
||||
)
|
||||
info = updated_session.worker_info
|
||||
|
||||
# Switch to staging mode after successful load
|
||||
if mode_state is not None:
|
||||
await mode_state.switch_to_staging()
|
||||
|
||||
worker_name = info.name if info else updated_session.worker_id
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "loaded",
|
||||
"mode": "staging",
|
||||
"message": (
|
||||
f"Successfully loaded '{worker_name}'. "
|
||||
"You are now in STAGING mode. "
|
||||
"Call run_agent_with_input(task) to start the worker, "
|
||||
"or stop_worker_and_edit() to go back to building."
|
||||
),
|
||||
"worker_id": updated_session.worker_id,
|
||||
"worker_name": info.name if info else updated_session.worker_id,
|
||||
"worker_name": worker_name,
|
||||
"goal": info.goal_name if info else "",
|
||||
"node_count": info.node_count if info else 0,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("load_built_agent failed for '%s'", agent_path, exc_info=True)
|
||||
return json.dumps({"error": f"Failed to load agent: {e}"})
|
||||
|
||||
_load_built_tool = Tool(
|
||||
@@ -455,5 +1063,125 @@ def register_queen_lifecycle_tools(
|
||||
)
|
||||
tools_registered += 1
|
||||
|
||||
# --- run_agent_with_input ------------------------------------------------
|
||||
|
||||
async def run_agent_with_input(task: str) -> str:
|
||||
"""Run the loaded worker agent with the given task input.
|
||||
|
||||
Performs preflight checks (credentials, MCP resync), triggers the
|
||||
worker's default entry point, and switches to running mode.
|
||||
"""
|
||||
runtime = _get_runtime()
|
||||
if runtime is None:
|
||||
return json.dumps({"error": "No worker loaded in this session."})
|
||||
|
||||
try:
|
||||
# Pre-flight: validate credentials and resync MCP servers.
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
async def _preflight():
|
||||
cred_error: CredentialError | None = None
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: validate_credentials(
|
||||
runtime.graph.nodes,
|
||||
interactive=False,
|
||||
skip=False,
|
||||
),
|
||||
)
|
||||
except CredentialError as e:
|
||||
cred_error = e
|
||||
|
||||
runner = getattr(session, "runner", None)
|
||||
if runner:
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: runner._tool_registry.resync_mcp_servers_if_needed(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("MCP resync failed: %s", e)
|
||||
|
||||
if cred_error is not None:
|
||||
raise cred_error
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(_preflight(), timeout=_START_PREFLIGHT_TIMEOUT)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"run_agent_with_input preflight timed out after %ds — proceeding",
|
||||
_START_PREFLIGHT_TIMEOUT,
|
||||
)
|
||||
except CredentialError:
|
||||
raise # handled below
|
||||
|
||||
# Resume timers in case they were paused by a previous stop
|
||||
runtime.resume_timers()
|
||||
|
||||
# Get session state from any prior execution for memory continuity
|
||||
session_state = runtime._get_primary_session_state("default") or {}
|
||||
|
||||
if session_id:
|
||||
session_state["resume_session_id"] = session_id
|
||||
|
||||
exec_id = await runtime.trigger(
|
||||
entry_point_id="default",
|
||||
input_data={"user_request": task},
|
||||
session_state=session_state,
|
||||
)
|
||||
|
||||
# Switch to running mode
|
||||
if mode_state is not None:
|
||||
await mode_state.switch_to_running()
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "started",
|
||||
"mode": "running",
|
||||
"execution_id": exec_id,
|
||||
"task": task,
|
||||
}
|
||||
)
|
||||
except CredentialError as e:
|
||||
error_payload = credential_errors_to_json(e)
|
||||
error_payload["agent_path"] = str(getattr(session, "worker_path", "") or "")
|
||||
|
||||
bus = getattr(session, "event_bus", None)
|
||||
if bus is not None:
|
||||
await bus.publish(
|
||||
AgentEvent(
|
||||
type=EventType.CREDENTIALS_REQUIRED,
|
||||
stream_id="queen",
|
||||
data=error_payload,
|
||||
)
|
||||
)
|
||||
return json.dumps(error_payload)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": f"Failed to start worker: {e}"})
|
||||
|
||||
_run_input_tool = Tool(
|
||||
name="run_agent_with_input",
|
||||
description=(
|
||||
"Run the loaded worker agent with the given task. Validates credentials, "
|
||||
"triggers the worker's default entry point, and switches to running mode. "
|
||||
"Use this after loading an agent (staging mode) to start execution."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": "The task or input for the worker agent to execute",
|
||||
},
|
||||
},
|
||||
"required": ["task"],
|
||||
},
|
||||
)
|
||||
registry.register(
|
||||
"run_agent_with_input", _run_input_tool, lambda inputs: run_agent_with_input(**inputs)
|
||||
)
|
||||
tools_registered += 1
|
||||
|
||||
logger.info("Registered %d queen lifecycle tools", tools_registered)
|
||||
return tools_registered
|
||||
|
||||
@@ -18,7 +18,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -48,10 +47,14 @@ def register_graph_tools(registry: ToolRegistry, runtime: AgentRuntime) -> int:
|
||||
"""
|
||||
from framework.runner.runner import AgentRunner
|
||||
from framework.runtime.execution_stream import EntryPointSpec
|
||||
from framework.server.app import validate_agent_path
|
||||
|
||||
path = Path(agent_path).resolve()
|
||||
try:
|
||||
path = validate_agent_path(agent_path)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
if not path.exists():
|
||||
return json.dumps({"error": f"Agent path does not exist: {path}"})
|
||||
return json.dumps({"error": f"Agent path does not exist: {agent_path}"})
|
||||
|
||||
try:
|
||||
runner = AgentRunner.load(path)
|
||||
|
||||
+51
-115
@@ -256,7 +256,7 @@ class AdenTUI(App):
|
||||
"""Override to use native `open` for file:// URLs on macOS."""
|
||||
if url.startswith("file://") and platform.system() == "Darwin":
|
||||
path = url.removeprefix("file://")
|
||||
subprocess.Popen(["open", path])
|
||||
subprocess.Popen(["open", path], encoding="utf-8")
|
||||
else:
|
||||
super().open_url(url, new_tab=new_tab)
|
||||
|
||||
@@ -473,14 +473,15 @@ class AdenTUI(App):
|
||||
from pathlib import Path
|
||||
|
||||
from framework.graph.executor import GraphExecutor
|
||||
from framework.monitoring import judge_goal, judge_graph
|
||||
from framework.runner.tool_registry import ToolRegistry
|
||||
from framework.runtime.core import Runtime
|
||||
from framework.runtime.event_bus import EventType as _ET
|
||||
from framework.tools.queen_lifecycle_tools import register_queen_lifecycle_tools
|
||||
from framework.tools.queen_lifecycle_tools import (
|
||||
QueenModeState,
|
||||
register_queen_lifecycle_tools,
|
||||
)
|
||||
from framework.tools.worker_monitoring_tools import register_worker_monitoring_tools
|
||||
|
||||
log = logging.getLogger("tui.judge")
|
||||
log = logging.getLogger("tui.queen")
|
||||
|
||||
try:
|
||||
storage_path = Path(storage_path)
|
||||
@@ -502,64 +503,16 @@ class AdenTUI(App):
|
||||
worker_graph_id=self.runtime._graph_id,
|
||||
)
|
||||
|
||||
# 2. Storage dirs — global, not per-agent. Queen and judge are
|
||||
# supervisory components that outlive any single worker.
|
||||
# 2. Storage dirs — global, not per-agent.
|
||||
hive_home = Path.home() / ".hive"
|
||||
judge_dir = hive_home / "judge" / "session" / session_id
|
||||
judge_dir.mkdir(parents=True, exist_ok=True)
|
||||
queen_dir = hive_home / "queen" / "session" / session_id
|
||||
queen_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 3. Health judge — background task, fires every 2 minutes.
|
||||
# ---------------------------------------------------------------
|
||||
judge_runtime = Runtime(hive_home / "judge")
|
||||
monitoring_tools = list(monitoring_registry.get_tools().values())
|
||||
monitoring_executor = monitoring_registry.get_executor()
|
||||
|
||||
# Scoped event buses — stamp graph_id on every event so
|
||||
# downstream routing (queen-primary mode) can distinguish
|
||||
# queen/judge/worker events.
|
||||
# Health judge disabled for simplicity.
|
||||
from framework.runtime.execution_stream import GraphScopedEventBus
|
||||
|
||||
judge_event_bus = GraphScopedEventBus(event_bus, "judge")
|
||||
queen_event_bus = GraphScopedEventBus(event_bus, "queen")
|
||||
|
||||
async def _judge_loop():
|
||||
interval = 120 # seconds
|
||||
first = True
|
||||
while True:
|
||||
if not first:
|
||||
await asyncio.sleep(interval)
|
||||
first = False
|
||||
try:
|
||||
executor = GraphExecutor(
|
||||
runtime=judge_runtime,
|
||||
llm=llm,
|
||||
tools=monitoring_tools,
|
||||
tool_executor=monitoring_executor,
|
||||
event_bus=judge_event_bus,
|
||||
stream_id="judge",
|
||||
storage_path=judge_dir,
|
||||
loop_config=judge_graph.loop_config,
|
||||
)
|
||||
await executor.execute(
|
||||
graph=judge_graph,
|
||||
goal=judge_goal,
|
||||
input_data={
|
||||
"event": {"source": "timer", "reason": "scheduled"},
|
||||
},
|
||||
session_state={"resume_session_id": session_id},
|
||||
)
|
||||
except Exception:
|
||||
log.error("Health judge tick failed", exc_info=True)
|
||||
|
||||
self._judge_task = asyncio.run_coroutine_threadsafe(
|
||||
_judge_loop(),
|
||||
agent_loop,
|
||||
)
|
||||
self._judge_graph_id = "judge"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 4. Queen — persistent interactive conversation.
|
||||
# Runs a continuous event_loop node that is the user's
|
||||
@@ -586,12 +539,16 @@ class AdenTUI(App):
|
||||
except Exception:
|
||||
log.warning("Queen: MCP config failed to load", exc_info=True)
|
||||
|
||||
# Worker is already loaded in TUI path → start in staging mode.
|
||||
mode_state = QueenModeState(mode="staging", event_bus=event_bus)
|
||||
|
||||
register_queen_lifecycle_tools(
|
||||
queen_registry,
|
||||
worker_runtime=self.runtime,
|
||||
event_bus=event_bus,
|
||||
storage_path=storage_path,
|
||||
session_id=session_id,
|
||||
mode_state=mode_state,
|
||||
)
|
||||
register_worker_monitoring_tools(
|
||||
queen_registry,
|
||||
@@ -603,10 +560,27 @@ class AdenTUI(App):
|
||||
queen_tools = list(queen_registry.get_tools().values())
|
||||
queen_tool_executor = queen_registry.get_executor()
|
||||
|
||||
# Partition tools into mode-specific sets
|
||||
from framework.agents.hive_coder.nodes import (
|
||||
_QUEEN_BUILDING_TOOLS,
|
||||
_QUEEN_RUNNING_TOOLS,
|
||||
_QUEEN_STAGING_TOOLS,
|
||||
)
|
||||
|
||||
building_names = set(_QUEEN_BUILDING_TOOLS)
|
||||
staging_names = set(_QUEEN_STAGING_TOOLS)
|
||||
running_names = set(_QUEEN_RUNNING_TOOLS)
|
||||
mode_state.building_tools = [t for t in queen_tools if t.name in building_names]
|
||||
mode_state.staging_tools = [t for t in queen_tools if t.name in staging_names]
|
||||
mode_state.running_tools = [t for t in queen_tools if t.name in running_names]
|
||||
|
||||
# Build worker profile for queen's system prompt.
|
||||
from framework.tools.queen_lifecycle_tools import build_worker_profile
|
||||
|
||||
worker_identity = build_worker_profile(self.runtime)
|
||||
worker_identity = build_worker_profile(
|
||||
self.runtime,
|
||||
agent_path=self._runner.agent_path if self._runner else None,
|
||||
)
|
||||
|
||||
# Adjust queen graph: filter tools to what's registered and
|
||||
# append worker identity to the system prompt.
|
||||
@@ -640,12 +614,23 @@ class AdenTUI(App):
|
||||
stream_id="queen",
|
||||
storage_path=queen_dir,
|
||||
loop_config=queen_graph.loop_config,
|
||||
dynamic_tools_provider=mode_state.get_current_tools,
|
||||
)
|
||||
self._queen_executor = executor
|
||||
|
||||
# Wire inject_notification so mode switches notify the queen LLM
|
||||
async def _inject_mode_notification(content: str) -> None:
|
||||
node = executor.node_registry.get("queen")
|
||||
if node is not None and hasattr(node, "inject_event"):
|
||||
await node.inject_event(content)
|
||||
|
||||
mode_state.inject_notification = _inject_mode_notification
|
||||
|
||||
log.info(
|
||||
"Queen starting with %d tools: %s",
|
||||
len(queen_tools),
|
||||
[t.name for t in queen_tools],
|
||||
"Queen starting in %s mode with %d tools: %s",
|
||||
mode_state.mode,
|
||||
len(mode_state.get_current_tools()),
|
||||
[t.name for t in mode_state.get_current_tools()],
|
||||
)
|
||||
# The queen's event_loop node runs forever (continuous mode).
|
||||
# It blocks on _await_user_input() after each LLM turn,
|
||||
@@ -687,31 +672,8 @@ class AdenTUI(App):
|
||||
|
||||
self.chat_repl._queen_inject_callback = _inject_queen
|
||||
|
||||
# Judge escalation → inject into queen conversation as a message.
|
||||
async def _on_escalation(event):
|
||||
ticket = event.data.get("ticket", {})
|
||||
executor = self._queen_executor
|
||||
if executor is None:
|
||||
log.warning("Escalation received but queen executor is None")
|
||||
return
|
||||
node = executor.node_registry.get("queen")
|
||||
if node is not None and hasattr(node, "inject_event"):
|
||||
import json as _json
|
||||
|
||||
msg = "[ESCALATION TICKET from Health Judge]\n" + _json.dumps(
|
||||
ticket, indent=2, ensure_ascii=False
|
||||
)
|
||||
await node.inject_event(msg)
|
||||
else:
|
||||
log.warning("Escalation received but queen node not ready for injection")
|
||||
|
||||
self._queen_escalation_sub = event_bus.subscribe(
|
||||
event_types=[_ET.WORKER_ESCALATION_TICKET],
|
||||
handler=_on_escalation,
|
||||
)
|
||||
|
||||
self.notify(
|
||||
"Queen + health judge active",
|
||||
"Queen active",
|
||||
severity="information",
|
||||
timeout=3,
|
||||
)
|
||||
@@ -1681,46 +1643,20 @@ class AdenTUI(App):
|
||||
self.notify(f"Logs {mode}", severity="information", timeout=2)
|
||||
|
||||
def action_pause_execution(self) -> None:
|
||||
"""Immediately pause execution by cancelling task (bound to Ctrl+Z)."""
|
||||
"""Immediately pause execution by cancelling all running tasks (bound to Ctrl+Z)."""
|
||||
if self.chat_repl is None or self.runtime is None:
|
||||
return
|
||||
try:
|
||||
if not self.chat_repl._current_exec_id:
|
||||
if self.runtime.cancel_all_tasks(self.chat_repl._agent_loop):
|
||||
self.chat_repl._current_exec_id = None
|
||||
self.notify(
|
||||
"No active execution to pause",
|
||||
"All executions stopped",
|
||||
severity="information",
|
||||
timeout=3,
|
||||
)
|
||||
return
|
||||
|
||||
task_cancelled = False
|
||||
all_streams = []
|
||||
active_reg = self.runtime.get_graph_registration(self.runtime.active_graph_id)
|
||||
if active_reg:
|
||||
all_streams.extend(active_reg.streams.values())
|
||||
for gid in self.runtime.list_graphs():
|
||||
if gid == self.runtime.active_graph_id:
|
||||
continue
|
||||
reg = self.runtime.get_graph_registration(gid)
|
||||
if reg:
|
||||
all_streams.extend(reg.streams.values())
|
||||
|
||||
for stream in all_streams:
|
||||
exec_id = self.chat_repl._current_exec_id
|
||||
task = stream._execution_tasks.get(exec_id)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
task_cancelled = True
|
||||
self.notify(
|
||||
"Execution paused - state saved",
|
||||
severity="information",
|
||||
timeout=3,
|
||||
)
|
||||
break
|
||||
|
||||
if not task_cancelled:
|
||||
else:
|
||||
self.notify(
|
||||
"Execution already completed",
|
||||
"No active executions",
|
||||
severity="information",
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
@@ -53,7 +53,7 @@ def _get_last_active(agent_name: str) -> str | None:
|
||||
if not state_file.exists():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(state_file.read_text())
|
||||
data = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
ts = data.get("timestamps", {}).get("updated_at")
|
||||
if ts and (latest is None or ts > latest):
|
||||
latest = ts
|
||||
@@ -70,19 +70,50 @@ def _count_sessions(agent_name: str) -> int:
|
||||
return sum(1 for d in sessions_dir.iterdir() if d.is_dir() and d.name.startswith("session_"))
|
||||
|
||||
|
||||
def _extract_agent_stats(agent_json_path: Path) -> tuple[int, int, list[str]]:
|
||||
"""Extract node count, tool count, and tags from agent.json."""
|
||||
try:
|
||||
data = json.loads(agent_json_path.read_text())
|
||||
nodes = data.get("nodes", [])
|
||||
node_count = len(nodes)
|
||||
tools: set[str] = set()
|
||||
for node in nodes:
|
||||
tools.update(node.get("tools", []))
|
||||
tags = data.get("agent", {}).get("tags", [])
|
||||
return node_count, len(tools), tags
|
||||
except Exception:
|
||||
return 0, 0, []
|
||||
def _extract_agent_stats(agent_path: Path) -> tuple[int, int, list[str]]:
|
||||
"""Extract node count, tool count, and tags from an agent directory.
|
||||
|
||||
Prefers agent.py (AST-parsed) over agent.json for node/tool counts
|
||||
since agent.json may be stale. Tags are only available from agent.json.
|
||||
"""
|
||||
import ast
|
||||
|
||||
node_count, tool_count, tags = 0, 0, []
|
||||
|
||||
# Try agent.py first — source of truth for nodes
|
||||
agent_py = agent_path / "agent.py"
|
||||
if agent_py.exists():
|
||||
try:
|
||||
tree = ast.parse(agent_py.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
# Find `nodes = [...]` assignment
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == "nodes":
|
||||
if isinstance(node.value, ast.List):
|
||||
node_count = len(node.value.elts)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fall back to / supplement from agent.json
|
||||
agent_json = agent_path / "agent.json"
|
||||
if agent_json.exists():
|
||||
try:
|
||||
data = json.loads(agent_json.read_text(encoding="utf-8"))
|
||||
json_nodes = data.get("nodes", [])
|
||||
if node_count == 0:
|
||||
node_count = len(json_nodes)
|
||||
# Tool count: use whichever source gave us nodes, but agent.json
|
||||
# has the structured tool lists so prefer it for tool counting
|
||||
tools: set[str] = set()
|
||||
for n in json_nodes:
|
||||
tools.update(n.get("tools", []))
|
||||
tool_count = len(tools)
|
||||
tags = data.get("agent", {}).get("tags", [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return node_count, tool_count, tags
|
||||
|
||||
|
||||
def discover_agents() -> dict[str, list[AgentEntry]]:
|
||||
@@ -113,14 +144,13 @@ def discover_agents() -> dict[str, list[AgentEntry]]:
|
||||
config_fallback_name = path.name.replace("_", " ").title()
|
||||
used_config = name != config_fallback_name
|
||||
|
||||
agent_json = path / "agent.json"
|
||||
node_count, tool_count, tags = 0, 0, []
|
||||
if agent_json.exists():
|
||||
node_count, tool_count, tags = _extract_agent_stats(agent_json)
|
||||
if not used_config:
|
||||
# config.py didn't provide values, fall back to agent.json
|
||||
node_count, tool_count, tags = _extract_agent_stats(path)
|
||||
if not used_config:
|
||||
# config.py didn't provide values, fall back to agent.json
|
||||
agent_json = path / "agent.json"
|
||||
if agent_json.exists():
|
||||
try:
|
||||
data = json.loads(agent_json.read_text())
|
||||
data = json.loads(agent_json.read_text(encoding="utf-8"))
|
||||
meta = data.get("agent", {})
|
||||
name = meta.get("name", name)
|
||||
desc = meta.get("description", desc)
|
||||
|
||||
@@ -160,20 +160,9 @@ class CredentialSetupScreen(ModalScreen[bool | None]):
|
||||
aden_input = self.query_one("#key-aden", Input)
|
||||
aden_key = aden_input.value.strip()
|
||||
if aden_key:
|
||||
os.environ["ADEN_API_KEY"] = aden_key
|
||||
# Persist to shell config
|
||||
try:
|
||||
from aden_tools.credentials.shell_config import (
|
||||
add_env_var_to_shell_config,
|
||||
)
|
||||
from framework.credentials.key_storage import save_aden_api_key
|
||||
|
||||
add_env_var_to_shell_config(
|
||||
"ADEN_API_KEY",
|
||||
aden_key,
|
||||
comment="Aden Platform API key",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
save_aden_api_key(aden_key)
|
||||
configured += 1 # ADEN_API_KEY itself counts as configured
|
||||
|
||||
# Run Aden sync for all Aden-backed creds (best-effort)
|
||||
|
||||
@@ -488,7 +488,7 @@ class ChatRepl(Vertical):
|
||||
if not state_file.exists():
|
||||
continue
|
||||
|
||||
with open(state_file) as f:
|
||||
with open(state_file, encoding="utf-8") as f:
|
||||
state = json.load(f)
|
||||
|
||||
status = state.get("status", "").lower()
|
||||
@@ -547,7 +547,7 @@ class ChatRepl(Vertical):
|
||||
|
||||
# Read session state
|
||||
try:
|
||||
with open(state_file) as f:
|
||||
with open(state_file, encoding="utf-8") as f:
|
||||
state = json.load(f)
|
||||
|
||||
# Track this session for /resume <number> lookup
|
||||
@@ -599,7 +599,7 @@ class ChatRepl(Vertical):
|
||||
try:
|
||||
import json
|
||||
|
||||
with open(state_file) as f:
|
||||
with open(state_file, encoding="utf-8") as f:
|
||||
state = json.load(f)
|
||||
|
||||
# Basic info
|
||||
@@ -640,7 +640,7 @@ class ChatRepl(Vertical):
|
||||
# Load and show checkpoints
|
||||
for i, cp_file in enumerate(checkpoint_files[-5:], 1): # Last 5
|
||||
try:
|
||||
with open(cp_file) as f:
|
||||
with open(cp_file, encoding="utf-8") as f:
|
||||
cp_data = json.load(f)
|
||||
|
||||
cp_id = cp_data.get("checkpoint_id", cp_file.stem)
|
||||
@@ -687,7 +687,7 @@ class ChatRepl(Vertical):
|
||||
|
||||
import json
|
||||
|
||||
with open(state_file) as f:
|
||||
with open(state_file, encoding="utf-8") as f:
|
||||
state = json.load(f)
|
||||
|
||||
# Resume from session state (not checkpoint)
|
||||
@@ -868,27 +868,17 @@ class ChatRepl(Vertical):
|
||||
self._write_history(f"[dim]{traceback.format_exc()}[/dim]")
|
||||
|
||||
async def _cmd_pause(self) -> None:
|
||||
"""Immediately pause execution by cancelling task (same as Ctrl+Z)."""
|
||||
# Check if there's a current execution
|
||||
if not self._current_exec_id:
|
||||
self._write_history("[bold yellow]No active execution to pause[/bold yellow]")
|
||||
self._write_history(" Start an execution first, then use /pause during execution")
|
||||
return
|
||||
|
||||
# Find and cancel the execution task - executor will catch and save state
|
||||
task_cancelled = False
|
||||
for stream in self.runtime._streams.values():
|
||||
exec_id = self._current_exec_id
|
||||
task = stream._execution_tasks.get(exec_id)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
task_cancelled = True
|
||||
self._write_history("[bold green]⏸ Execution paused - state saved[/bold green]")
|
||||
self._write_history(" Resume later with: [bold]/resume[/bold]")
|
||||
break
|
||||
|
||||
if not task_cancelled:
|
||||
self._write_history("[bold yellow]Execution already completed[/bold yellow]")
|
||||
"""Immediately pause execution by cancelling all running tasks (same as Ctrl+Z)."""
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.runtime.cancel_all_tasks_async(), self._agent_loop
|
||||
)
|
||||
result = await asyncio.wrap_future(future)
|
||||
if result:
|
||||
self._current_exec_id = None
|
||||
self._write_history("[bold green]⏸ All executions stopped[/bold green]")
|
||||
self._write_history(" Resume later with: [bold]/resume[/bold]")
|
||||
else:
|
||||
self._write_history("[bold yellow]No active executions[/bold yellow]")
|
||||
|
||||
async def _cmd_coder(self, reason: str = "") -> None:
|
||||
"""User-initiated escalation to Hive Coder."""
|
||||
@@ -1112,7 +1102,7 @@ class ChatRepl(Vertical):
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(state_file) as f:
|
||||
with open(state_file, encoding="utf-8") as f:
|
||||
state = json.load(f)
|
||||
|
||||
status = state.get("status", "").lower()
|
||||
@@ -1460,10 +1450,6 @@ class ChatRepl(Vertical):
|
||||
indicator.update("Preparing question...")
|
||||
return
|
||||
|
||||
if tool_name == "escalate_to_coder":
|
||||
indicator.update("Escalating to coder...")
|
||||
return
|
||||
|
||||
# Update indicator to show tool activity
|
||||
indicator.update(f"Using tool: {tool_name}...")
|
||||
|
||||
@@ -1475,7 +1461,7 @@ class ChatRepl(Vertical):
|
||||
|
||||
def handle_tool_completed(self, tool_name: str, result: str, is_error: bool) -> None:
|
||||
"""Handle a tool call completing."""
|
||||
if tool_name in ("ask_user", "escalate_to_coder"):
|
||||
if tool_name == "ask_user":
|
||||
return
|
||||
|
||||
result_str = str(result)
|
||||
|
||||
@@ -38,6 +38,7 @@ def _linux_file_dialog() -> subprocess.CompletedProcess | None:
|
||||
"--title=Select a PDF file",
|
||||
"--file-filter=PDF files (*.pdf)|*.pdf",
|
||||
],
|
||||
encoding="utf-8",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
@@ -54,6 +55,7 @@ def _linux_file_dialog() -> subprocess.CompletedProcess | None:
|
||||
".",
|
||||
"PDF files (*.pdf)",
|
||||
],
|
||||
encoding="utf-8",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
@@ -79,6 +81,7 @@ def _pick_pdf_subprocess() -> Path | None:
|
||||
'POSIX path of (choose file of type {"com.adobe.pdf"} '
|
||||
'with prompt "Select a PDF file")',
|
||||
],
|
||||
encoding="utf-8",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
@@ -93,6 +96,7 @@ def _pick_pdf_subprocess() -> Path | None:
|
||||
)
|
||||
result = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", ps_script],
|
||||
encoding="utf-8",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
|
||||
@@ -25,6 +25,7 @@ EVENT_FORMAT: dict[EventType, tuple[str, str]] = {
|
||||
EventType.NODE_LOOP_STARTED: ("@@", "cyan"),
|
||||
EventType.NODE_LOOP_ITERATION: ("..", "dim"),
|
||||
EventType.NODE_LOOP_COMPLETED: ("@@", "dim"),
|
||||
EventType.LLM_TURN_COMPLETE: ("◆", "green"),
|
||||
EventType.NODE_STALLED: ("!!", "bold yellow"),
|
||||
EventType.NODE_INPUT_BLOCKED: ("!!", "yellow"),
|
||||
EventType.GOAL_PROGRESS: ("%%", "blue"),
|
||||
@@ -87,6 +88,12 @@ def extract_event_text(event: AgentEvent) -> str:
|
||||
return f"State changed: {data.get('key', 'unknown')}"
|
||||
elif et == EventType.CLIENT_INPUT_REQUESTED:
|
||||
return "Waiting for user input"
|
||||
elif et == EventType.LLM_TURN_COMPLETE:
|
||||
stop = data.get("stop_reason", "?")
|
||||
model = data.get("model", "?")
|
||||
inp = data.get("input_tokens", 0)
|
||||
out = data.get("output_tokens", 0)
|
||||
return f"{model} → {stop} ({inp}+{out} tokens)"
|
||||
else:
|
||||
return f"{et.value}: {data}"
|
||||
|
||||
|
||||
@@ -199,10 +199,11 @@ def _copy_to_clipboard(text: str) -> None:
|
||||
"""Copy text to system clipboard using platform-native tools."""
|
||||
try:
|
||||
if sys.platform == "darwin":
|
||||
subprocess.run(["pbcopy"], input=text.encode(), check=True, timeout=5)
|
||||
subprocess.run(["pbcopy"], encoding="utf-8", input=text.encode(), check=True, timeout=5)
|
||||
elif sys.platform == "win32":
|
||||
subprocess.run(
|
||||
["clip.exe"],
|
||||
encoding="utf-8",
|
||||
input=text.encode("utf-16le"),
|
||||
check=True,
|
||||
timeout=5,
|
||||
@@ -211,6 +212,7 @@ def _copy_to_clipboard(text: str) -> None:
|
||||
try:
|
||||
subprocess.run(
|
||||
["xclip", "-selection", "clipboard"],
|
||||
encoding="utf-8",
|
||||
input=text.encode(),
|
||||
check=True,
|
||||
timeout=5,
|
||||
@@ -218,6 +220,7 @@ def _copy_to_clipboard(text: str) -> None:
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
subprocess.run(
|
||||
["xsel", "--clipboard", "--input"],
|
||||
encoding="utf-8",
|
||||
input=text.encode(),
|
||||
check=True,
|
||||
timeout=5,
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface AgentCredentialRequirement {
|
||||
direct_api_key_supported: boolean;
|
||||
aden_supported: boolean;
|
||||
credential_key: string;
|
||||
alternative_group: string | null;
|
||||
}
|
||||
|
||||
export const credentialsApi = {
|
||||
@@ -45,7 +46,4 @@ export const credentialsApi = {
|
||||
"/credentials/check-agent",
|
||||
{ agent_path: agentPath },
|
||||
),
|
||||
|
||||
saveAdenKey: (key: string) =>
|
||||
api.post<{ saved: boolean }>("/credentials/aden-key", { key }),
|
||||
};
|
||||
|
||||
@@ -37,6 +37,13 @@ export const executionApi = {
|
||||
chat: (sessionId: string, message: string) =>
|
||||
api.post<ChatResult>(`/sessions/${sessionId}/chat`, { message }),
|
||||
|
||||
/** Queue context for the queen without triggering an LLM response. */
|
||||
queenContext: (sessionId: string, message: string) =>
|
||||
api.post<ChatResult>(`/sessions/${sessionId}/queen-context`, { message }),
|
||||
|
||||
workerInput: (sessionId: string, message: string) =>
|
||||
api.post<ChatResult>(`/sessions/${sessionId}/worker-input`, { message }),
|
||||
|
||||
stop: (sessionId: string, executionId: string) =>
|
||||
api.post<StopResult>(`/sessions/${sessionId}/stop`, {
|
||||
execution_id: executionId,
|
||||
@@ -47,6 +54,9 @@ export const executionApi = {
|
||||
execution_id: executionId,
|
||||
}),
|
||||
|
||||
cancelQueen: (sessionId: string) =>
|
||||
api.post<{ cancelled: boolean }>(`/sessions/${sessionId}/cancel-queen`),
|
||||
|
||||
resume: (sessionId: string, workerSessionId: string, checkpointId?: string) =>
|
||||
api.post<ResumeResult>(`/sessions/${sessionId}/resume`, {
|
||||
session_id: workerSessionId,
|
||||
|
||||
@@ -13,11 +13,12 @@ export const sessionsApi = {
|
||||
// --- Session lifecycle ---
|
||||
|
||||
/** Create a session. If agentPath is provided, loads worker in one step. */
|
||||
create: (agentPath?: string, agentId?: string, model?: string) =>
|
||||
create: (agentPath?: string, agentId?: string, model?: string, initialPrompt?: string) =>
|
||||
api.post<LiveSession>("/sessions", {
|
||||
agent_path: agentPath,
|
||||
agent_id: agentId,
|
||||
model,
|
||||
initial_prompt: initialPrompt,
|
||||
}),
|
||||
|
||||
/** List all active sessions. */
|
||||
@@ -65,6 +66,10 @@ export const sessionsApi = {
|
||||
graphs: (sessionId: string) =>
|
||||
api.get<{ graphs: string[] }>(`/sessions/${sessionId}/graphs`),
|
||||
|
||||
/** Get queen conversation history for a session. */
|
||||
queenMessages: (sessionId: string) =>
|
||||
api.get<{ messages: Message[] }>(`/sessions/${sessionId}/queen-messages`),
|
||||
|
||||
// --- Worker session browsing (persisted execution runs) ---
|
||||
|
||||
workerSessions: (sessionId: string) =>
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface LiveSession {
|
||||
loaded_at: number;
|
||||
uptime_seconds: number;
|
||||
intro_message?: string;
|
||||
/** Queen operating mode — "building", "staging", or "running" */
|
||||
queen_mode?: "building" | "staging" | "running";
|
||||
/** Present in 409 conflict responses when worker is still loading */
|
||||
loading?: boolean;
|
||||
}
|
||||
@@ -26,6 +28,9 @@ export interface EntryPoint {
|
||||
name: string;
|
||||
entry_node: string;
|
||||
trigger_type: string;
|
||||
trigger_config?: Record<string, unknown>;
|
||||
/** Seconds until the next timer fire (only present for timer entry points). */
|
||||
next_fire_in?: number;
|
||||
}
|
||||
|
||||
export interface DiscoverEntry {
|
||||
@@ -130,6 +135,8 @@ export interface Message {
|
||||
is_transition_marker?: boolean;
|
||||
is_client_input?: boolean;
|
||||
tool_calls?: unknown[];
|
||||
/** Epoch seconds from file mtime — used for cross-conversation ordering */
|
||||
created_at?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -150,6 +157,7 @@ export interface NodeSpec {
|
||||
client_facing: boolean;
|
||||
success_criteria: string | null;
|
||||
system_prompt: string;
|
||||
sub_agents?: string[];
|
||||
// Runtime enrichment (when session_id provided)
|
||||
visit_count?: number;
|
||||
has_failures?: boolean;
|
||||
@@ -178,6 +186,7 @@ export interface GraphTopology {
|
||||
nodes: NodeSpec[];
|
||||
edges: GraphEdge[];
|
||||
entry_node: string;
|
||||
entry_points?: EntryPoint[];
|
||||
}
|
||||
|
||||
export interface NodeCriteria {
|
||||
@@ -262,7 +271,10 @@ export type EventTypeName =
|
||||
| "webhook_received"
|
||||
| "custom"
|
||||
| "escalation_requested"
|
||||
| "worker_loaded";
|
||||
| "worker_loaded"
|
||||
| "credentials_required"
|
||||
| "queen_mode_changed"
|
||||
| "subagent_report";
|
||||
|
||||
export interface AgentEvent {
|
||||
type: EventTypeName;
|
||||
|
||||
@@ -3,10 +3,15 @@ import { Play, Pause, Loader2, CheckCircle2 } from "lucide-react";
|
||||
|
||||
export type NodeStatus = "running" | "complete" | "pending" | "error" | "looping";
|
||||
|
||||
export type NodeType = "execution" | "trigger";
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
status: NodeStatus;
|
||||
nodeType?: NodeType;
|
||||
triggerType?: string;
|
||||
triggerConfig?: Record<string, unknown>;
|
||||
next?: string[];
|
||||
backEdges?: string[];
|
||||
iterations?: number;
|
||||
@@ -25,6 +30,8 @@ interface AgentGraphProps {
|
||||
onPause?: () => void;
|
||||
version?: string;
|
||||
runState?: RunState;
|
||||
building?: boolean;
|
||||
queenMode?: "building" | "staging" | "running";
|
||||
}
|
||||
|
||||
// --- Extracted RunButton so hover state survives parent re-renders ---
|
||||
@@ -116,14 +123,30 @@ const statusColors: Record<NodeStatus, { dot: string; bg: string; border: string
|
||||
},
|
||||
};
|
||||
|
||||
function formatLabel(id: string): string {
|
||||
return id
|
||||
.split("-")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
// Trigger node palette — cool blue-gray, visually distinct from amber execution nodes
|
||||
const triggerColors = {
|
||||
bg: "hsl(210,25%,14%)",
|
||||
border: "hsl(210,30%,30%)",
|
||||
text: "hsl(210,30%,65%)",
|
||||
icon: "hsl(210,40%,55%)",
|
||||
};
|
||||
|
||||
const triggerIcons: Record<string, string> = {
|
||||
webhook: "\u26A1", // lightning bolt
|
||||
timer: "\u23F1", // stopwatch
|
||||
api: "\u2192", // right arrow
|
||||
event: "\u223F", // sine wave
|
||||
};
|
||||
|
||||
/** Truncate label to fit within `availablePx` at the given fontSize. */
|
||||
function truncateLabel(label: string, availablePx: number, fontSize: number): string {
|
||||
const avgCharW = fontSize * 0.58;
|
||||
const maxChars = Math.floor(availablePx / avgCharW);
|
||||
if (label.length <= maxChars) return label;
|
||||
return label.slice(0, Math.max(maxChars - 1, 1)) + "\u2026";
|
||||
}
|
||||
|
||||
export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, onPause, version, runState: externalRunState }: AgentGraphProps) {
|
||||
export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, onPause, version, runState: externalRunState, building, queenMode }: AgentGraphProps) {
|
||||
const [localRunState, setLocalRunState] = useState<RunState>("idle");
|
||||
const runState = externalRunState ?? localRunState;
|
||||
const runBtnRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -255,10 +278,17 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, o
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<RunButton runState={runState} disabled={nodes.length === 0} onRun={handleRun} onPause={onPause ?? (() => {})} btnRef={runBtnRef} />
|
||||
<RunButton runState={runState} disabled={nodes.length === 0 || queenMode === "building"} onRun={handleRun} onPause={onPause ?? (() => {})} btnRef={runBtnRef} />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center px-5">
|
||||
<p className="text-xs text-muted-foreground/60 text-center italic">No pipeline configured yet.<br/>Chat with the Queen to get started.</p>
|
||||
{building ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary/60" />
|
||||
<p className="text-xs text-muted-foreground/80 text-center">Building agent...</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground/60 text-center italic">No pipeline configured yet.<br/>Chat with the Queen to get started.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -380,15 +410,89 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, o
|
||||
);
|
||||
};
|
||||
|
||||
const renderTriggerNode = (node: GraphNode, i: number) => {
|
||||
const pos = nodePos(i);
|
||||
const icon = triggerIcons[node.triggerType || ""] || "\u26A1";
|
||||
const triggerFontSize = nodeW < 140 ? 10.5 : 11.5;
|
||||
const triggerAvailW = nodeW - 38;
|
||||
const triggerDisplayLabel = truncateLabel(node.label, triggerAvailW, triggerFontSize);
|
||||
const nextFireIn = node.triggerConfig?.next_fire_in as number | undefined;
|
||||
|
||||
// Format countdown for display below node
|
||||
let countdownLabel: string | null = null;
|
||||
if (nextFireIn != null && nextFireIn > 0) {
|
||||
const h = Math.floor(nextFireIn / 3600);
|
||||
const m = Math.floor((nextFireIn % 3600) / 60);
|
||||
const s = Math.floor(nextFireIn % 60);
|
||||
countdownLabel = h > 0
|
||||
? `next in ${h}h ${String(m).padStart(2, "0")}m`
|
||||
: `next in ${m}m ${String(s).padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
return (
|
||||
<g key={node.id} onClick={() => onNodeClick?.(node)} style={{ cursor: onNodeClick ? "pointer" : "default" }}>
|
||||
<title>{node.label}</title>
|
||||
{/* Pill-shaped background with dashed border */}
|
||||
<rect
|
||||
x={pos.x} y={pos.y}
|
||||
width={nodeW} height={NODE_H}
|
||||
rx={NODE_H / 2}
|
||||
fill={triggerColors.bg}
|
||||
stroke={triggerColors.border}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
|
||||
{/* Trigger type icon */}
|
||||
<text
|
||||
x={pos.x + 18} y={pos.y + NODE_H / 2}
|
||||
fill={triggerColors.icon} fontSize={13}
|
||||
textAnchor="middle" dominantBaseline="middle"
|
||||
>
|
||||
{icon}
|
||||
</text>
|
||||
|
||||
{/* Label */}
|
||||
<text
|
||||
x={pos.x + 32} y={pos.y + NODE_H / 2}
|
||||
fill={triggerColors.text}
|
||||
fontSize={triggerFontSize}
|
||||
fontWeight={500}
|
||||
dominantBaseline="middle"
|
||||
letterSpacing="0.01em"
|
||||
>
|
||||
{triggerDisplayLabel}
|
||||
</text>
|
||||
|
||||
{/* Countdown label below node */}
|
||||
{countdownLabel && (
|
||||
<text
|
||||
x={pos.x + nodeW / 2} y={pos.y + NODE_H + 13}
|
||||
fill="hsl(210,30%,50%)" fontSize={9.5}
|
||||
textAnchor="middle" fontStyle="italic" opacity={0.7}
|
||||
>
|
||||
{countdownLabel}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNode = (node: GraphNode, i: number) => {
|
||||
if (node.nodeType === "trigger") return renderTriggerNode(node, i);
|
||||
|
||||
const pos = nodePos(i);
|
||||
const isActive = node.status === "running" || node.status === "looping";
|
||||
const isDone = node.status === "complete";
|
||||
const colors = statusColors[node.status];
|
||||
const clipId = `clip-label-${node.id}`;
|
||||
|
||||
const fontSize = nodeW < 140 ? 10.5 : 12.5;
|
||||
const labelAvailW = nodeW - 38;
|
||||
const displayLabel = truncateLabel(node.label, labelAvailW, fontSize);
|
||||
|
||||
return (
|
||||
<g key={node.id} onClick={() => onNodeClick?.(node)} style={{ cursor: onNodeClick ? "pointer" : "default" }}>
|
||||
<title>{node.label}</title>
|
||||
{/* Ambient glow for active nodes */}
|
||||
{isActive && (
|
||||
<>
|
||||
@@ -436,20 +540,16 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, o
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Label -- properly capitalized, clipped for narrow nodes */}
|
||||
<clipPath id={clipId}>
|
||||
<rect x={pos.x + 30} y={pos.y} width={nodeW - 38} height={NODE_H} />
|
||||
</clipPath>
|
||||
{/* Label -- truncated with ellipsis for narrow nodes */}
|
||||
<text
|
||||
x={pos.x + 32} y={pos.y + NODE_H / 2}
|
||||
fill={isActive ? "hsl(45,90%,85%)" : isDone ? "hsl(40,20%,75%)" : "hsl(35,10%,45%)"}
|
||||
fontSize={nodeW < 140 ? 10.5 : 12.5}
|
||||
fontSize={fontSize}
|
||||
fontWeight={isActive ? 600 : isDone ? 500 : 400}
|
||||
dominantBaseline="middle"
|
||||
letterSpacing="0.01em"
|
||||
clipPath={`url(#${clipId})`}
|
||||
>
|
||||
{formatLabel(node.id)}
|
||||
{displayLabel}
|
||||
</text>
|
||||
|
||||
{/* Status label for active nodes */}
|
||||
@@ -500,18 +600,26 @@ export default function AgentGraph({ nodes, title: _title, onNodeClick, onRun, o
|
||||
</div>
|
||||
|
||||
{/* Graph */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-3 pb-5">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-3 pb-5 relative">
|
||||
<svg
|
||||
width={svgWidth}
|
||||
height={svgHeight}
|
||||
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
|
||||
className="select-none"
|
||||
className={`select-none${building ? " opacity-30" : ""}`}
|
||||
style={{ fontFamily: "'Inter', system-ui, sans-serif" }}
|
||||
>
|
||||
{forwardEdges.map((e, i) => renderForwardEdge(e, i))}
|
||||
{backEdges.map((e, i) => renderBackEdge(e, i))}
|
||||
{nodes.map((n, i) => renderNode(n, i))}
|
||||
</svg>
|
||||
{building && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary/60" />
|
||||
<p className="text-xs text-muted-foreground/80">Rebuilding agent...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo, useState, useRef, useEffect } from "react";
|
||||
import { Send, Crown, Cpu } from "lucide-react";
|
||||
import { formatAgentDisplayName } from "@/lib/chat-helpers";
|
||||
import { Send, Square, Crown, Cpu, Check, Loader2 } from "lucide-react";
|
||||
import MarkdownContent from "@/components/MarkdownContent";
|
||||
import QuestionWidget from "@/components/QuestionWidget";
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
@@ -9,31 +9,142 @@ export interface ChatMessage {
|
||||
agentColor: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
type?: "system" | "agent" | "user";
|
||||
type?: "system" | "agent" | "user" | "tool_status" | "worker_input_request";
|
||||
role?: "queen" | "worker";
|
||||
/** Which worker thread this message belongs to (worker agent name) */
|
||||
thread?: string;
|
||||
/** Epoch ms when this message was first created — used for ordering queen/worker interleaving */
|
||||
createdAt?: number;
|
||||
}
|
||||
|
||||
interface ChatPanelProps {
|
||||
messages: ChatMessage[];
|
||||
onSend: (message: string, thread: string) => void;
|
||||
isWaiting?: boolean;
|
||||
/** When true a worker is thinking (not yet streaming) */
|
||||
isWorkerWaiting?: boolean;
|
||||
/** When true the queen is busy (typing or streaming) — shows the stop button */
|
||||
isBusy?: boolean;
|
||||
activeThread: string;
|
||||
/** When true, the agent is waiting for user input — changes placeholder text */
|
||||
awaitingInput?: boolean;
|
||||
/** When true, the input is disabled (e.g. during loading) */
|
||||
disabled?: boolean;
|
||||
/** Called when user clicks the stop button to cancel the queen's current turn */
|
||||
onCancel?: () => void;
|
||||
/** Pending question from ask_user — replaces textarea when present */
|
||||
pendingQuestion?: string | null;
|
||||
/** Options for the pending question */
|
||||
pendingOptions?: string[] | null;
|
||||
/** Called when user submits an answer to the pending question */
|
||||
onQuestionSubmit?: (answer: string, isOther: boolean) => void;
|
||||
/** Called when user dismisses the pending question without answering */
|
||||
onQuestionDismiss?: () => void;
|
||||
/** Queen operating mode — shown as a tag on queen messages */
|
||||
queenMode?: "building" | "staging" | "running";
|
||||
}
|
||||
|
||||
const queenColor = "hsl(45,95%,58%)";
|
||||
const workerColor = "hsl(220,60%,55%)";
|
||||
|
||||
function getColor(_agent: string, role?: "queen" | "worker"): string {
|
||||
if (role === "queen") return queenColor;
|
||||
return "hsl(220,60%,55%)";
|
||||
return workerColor;
|
||||
}
|
||||
|
||||
const MessageBubble = memo(function MessageBubble({ msg }: { msg: ChatMessage }) {
|
||||
// Honey-drizzle palette — based on color-hex.com/color-palette/80116
|
||||
// #8e4200 · #db6f02 · #ff9624 · #ffb825 · #ffd69c + adjacent warm tones
|
||||
const TOOL_HEX = [
|
||||
"#db6f02", // rich orange
|
||||
"#ffb825", // golden yellow
|
||||
"#ff9624", // bright orange
|
||||
"#c48820", // warm bronze
|
||||
"#e89530", // honey
|
||||
"#d4a040", // goldenrod
|
||||
"#cc7a10", // caramel
|
||||
"#e5a820", // sunflower
|
||||
];
|
||||
|
||||
function toolHex(name: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) | 0;
|
||||
return TOOL_HEX[Math.abs(hash) % TOOL_HEX.length];
|
||||
}
|
||||
|
||||
function ToolActivityRow({ content }: { content: string }) {
|
||||
let tools: { name: string; done: boolean }[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
tools = parsed.tools || [];
|
||||
} catch {
|
||||
// Legacy plain-text fallback
|
||||
return (
|
||||
<div className="flex gap-3 pl-10">
|
||||
<span className="text-[11px] text-muted-foreground bg-muted/40 px-3 py-1 rounded-full border border-border/40">
|
||||
{content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tools.length === 0) return null;
|
||||
|
||||
// Group by tool name → count done vs running
|
||||
const grouped = new Map<string, { done: number; running: number }>();
|
||||
for (const t of tools) {
|
||||
const entry = grouped.get(t.name) || { done: 0, running: 0 };
|
||||
if (t.done) entry.done++;
|
||||
else entry.running++;
|
||||
grouped.set(t.name, entry);
|
||||
}
|
||||
|
||||
// Build pill list: running first, then done
|
||||
const runningPills: { name: string; count: number }[] = [];
|
||||
const donePills: { name: string; count: number }[] = [];
|
||||
for (const [name, counts] of grouped) {
|
||||
if (counts.running > 0) runningPills.push({ name, count: counts.running });
|
||||
if (counts.done > 0) donePills.push({ name, count: counts.done });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 pl-10">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{runningPills.map((p) => {
|
||||
const hex = toolHex(p.name);
|
||||
return (
|
||||
<span
|
||||
key={`run-${p.name}`}
|
||||
className="inline-flex items-center gap-1 text-[11px] px-2.5 py-0.5 rounded-full"
|
||||
style={{ color: hex, backgroundColor: `${hex}18`, border: `1px solid ${hex}35` }}
|
||||
>
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||
{p.name}
|
||||
{p.count > 1 && (
|
||||
<span className="text-[10px] font-medium opacity-70">×{p.count}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{donePills.map((p) => {
|
||||
const hex = toolHex(p.name);
|
||||
return (
|
||||
<span
|
||||
key={`done-${p.name}`}
|
||||
className="inline-flex items-center gap-1 text-[11px] px-2.5 py-0.5 rounded-full"
|
||||
style={{ color: hex, backgroundColor: `${hex}18`, border: `1px solid ${hex}35` }}
|
||||
>
|
||||
<Check className="w-2.5 h-2.5" />
|
||||
{p.name}
|
||||
{p.count > 1 && (
|
||||
<span className="text-[10px] opacity-80">×{p.count}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MessageBubble = memo(function MessageBubble({ msg, queenMode }: { msg: ChatMessage; queenMode?: "building" | "staging" | "running" }) {
|
||||
const isUser = msg.type === "user";
|
||||
const isQueen = msg.role === "queen";
|
||||
const color = getColor(msg.agent, msg.role);
|
||||
@@ -48,6 +159,10 @@ const MessageBubble = memo(function MessageBubble({ msg }: { msg: ChatMessage })
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.type === "tool_status") {
|
||||
return <ToolActivityRow content={msg.content} />;
|
||||
}
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
@@ -84,7 +199,13 @@ const MessageBubble = memo(function MessageBubble({ msg }: { msg: ChatMessage })
|
||||
isQueen ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{isQueen ? "Queen" : "Worker"}
|
||||
{isQueen
|
||||
? queenMode === "running"
|
||||
? "running mode"
|
||||
: queenMode === "staging"
|
||||
? "staging mode"
|
||||
: "building mode"
|
||||
: "Worker"}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -97,18 +218,20 @@ const MessageBubble = memo(function MessageBubble({ msg }: { msg: ChatMessage })
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, (prev, next) => prev.msg.id === next.msg.id && prev.msg.content === next.msg.content);
|
||||
}, (prev, next) => prev.msg.id === next.msg.id && prev.msg.content === next.msg.content && prev.queenMode === next.queenMode);
|
||||
|
||||
export default function ChatPanel({ messages, onSend, isWaiting, activeThread, awaitingInput, disabled }: ChatPanelProps) {
|
||||
export default function ChatPanel({ messages, onSend, isWaiting, isWorkerWaiting, isBusy, activeThread, disabled, onCancel, pendingQuestion, pendingOptions, onQuestionSubmit, onQuestionDismiss, queenMode }: ChatPanelProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [readMap, setReadMap] = useState<Record<string, number>>({});
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const stickToBottom = useRef(true);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const threadMessages = messages.filter((m) => {
|
||||
if (m.type === "system" && !m.thread) return false;
|
||||
return m.thread === activeThread;
|
||||
});
|
||||
console.log('[ChatPanel] render: messages:', messages.length, 'threadMessages:', threadMessages.length, 'activeThread:', activeThread, 'threads:', [...new Set(messages.map(m => m.thread))]);
|
||||
|
||||
// Mark current thread as read
|
||||
useEffect(() => {
|
||||
@@ -119,20 +242,33 @@ export default function ChatPanel({ messages, onSend, isWaiting, activeThread, a
|
||||
// Suppress unused var
|
||||
void readMap;
|
||||
|
||||
const lastMsg = threadMessages[threadMessages.length - 1];
|
||||
// Autoscroll: only when user is already near the bottom
|
||||
const handleScroll = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
stickToBottom.current = distFromBottom < 80;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [threadMessages.length, lastMsg?.content]);
|
||||
if (stickToBottom.current) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [threadMessages, pendingQuestion, isWaiting, isWorkerWaiting]);
|
||||
|
||||
// Always start pinned to bottom when switching threads
|
||||
useEffect(() => {
|
||||
stickToBottom.current = true;
|
||||
}, [activeThread]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim()) return;
|
||||
onSend(input.trim(), activeThread);
|
||||
setInput("");
|
||||
if (textareaRef.current) textareaRef.current.style.height = "auto";
|
||||
};
|
||||
|
||||
const activeWorkerLabel = formatAgentDisplayName(activeThread);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-w-0">
|
||||
{/* Compact sub-header */}
|
||||
@@ -141,15 +277,44 @@ export default function ChatPanel({ messages, onSend, isWaiting, activeThread, a
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-auto scrollbar-hide px-5 py-4 space-y-3">
|
||||
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-auto px-5 py-4 space-y-3">
|
||||
{threadMessages.map((msg) => (
|
||||
<MessageBubble key={msg.id} msg={msg} />
|
||||
<div key={msg.id}>
|
||||
<MessageBubble msg={msg} queenMode={queenMode} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isWaiting && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-7 h-7 rounded-xl bg-muted flex items-center justify-center">
|
||||
<Cpu className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<div
|
||||
className="flex-shrink-0 w-9 h-9 rounded-xl flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: `${queenColor}18`,
|
||||
border: `1.5px solid ${queenColor}35`,
|
||||
boxShadow: `0 0 12px ${queenColor}20`,
|
||||
}}
|
||||
>
|
||||
<Crown className="w-4 h-4" style={{ color: queenColor }} />
|
||||
</div>
|
||||
<div className="border border-primary/20 bg-primary/5 rounded-2xl rounded-tl-md px-4 py-3">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: "0ms" }} />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: "150ms" }} />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: "300ms" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isWorkerWaiting && !isWaiting && (
|
||||
<div className="flex gap-3">
|
||||
<div
|
||||
className="flex-shrink-0 w-7 h-7 rounded-xl flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: `${workerColor}18`,
|
||||
border: `1.5px solid ${workerColor}35`,
|
||||
}}
|
||||
>
|
||||
<Cpu className="w-3.5 h-3.5" style={{ color: workerColor }} />
|
||||
</div>
|
||||
<div className="bg-muted/60 rounded-2xl rounded-tl-md px-4 py-3">
|
||||
<div className="flex gap-1.5">
|
||||
@@ -163,31 +328,57 @@ export default function ChatPanel({ messages, onSend, isWaiting, activeThread, a
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<form onSubmit={handleSubmit} className="p-4 border-t border-border">
|
||||
<div className="flex items-center gap-3 bg-muted/40 rounded-xl px-4 py-2.5 border border-border focus-within:border-primary/40 transition-colors">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={
|
||||
disabled
|
||||
? "Connecting to agent..."
|
||||
: awaitingInput
|
||||
? "Agent is waiting for your response..."
|
||||
: `Message ${activeWorkerLabel}...`
|
||||
}
|
||||
disabled={disabled}
|
||||
className="flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || disabled}
|
||||
className="p-2 rounded-lg bg-primary text-primary-foreground disabled:opacity-30 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/* Input area — question widget replaces textarea when a question is pending */}
|
||||
{pendingQuestion && pendingOptions && onQuestionSubmit ? (
|
||||
<QuestionWidget
|
||||
question={pendingQuestion}
|
||||
options={pendingOptions}
|
||||
onSubmit={onQuestionSubmit}
|
||||
onDismiss={onQuestionDismiss}
|
||||
/>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="p-4">
|
||||
<div className="flex items-center gap-3 bg-muted/40 rounded-xl px-4 py-2.5 border border-border focus-within:border-primary/40 transition-colors">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
const ta = e.target;
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = `${Math.min(ta.scrollHeight, 160)}px`;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
placeholder={disabled ? "Connecting to agent..." : "Message Queen Bee..."}
|
||||
disabled={disabled}
|
||||
className="flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-50 disabled:cursor-not-allowed resize-none overflow-y-auto"
|
||||
/>
|
||||
{isBusy && onCancel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="p-2 rounded-lg bg-amber-500/15 text-amber-400 border border-amber-500/40 hover:bg-amber-500/25 transition-colors"
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || disabled}
|
||||
className="p-2 rounded-lg bg-primary text-primary-foreground disabled:opacity-30 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { KeyRound, Check, AlertCircle, X, Shield, Loader2, Trash2, ExternalLink } from "lucide-react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { KeyRound, Check, AlertCircle, X, Shield, Loader2, Trash2, ExternalLink, Pencil } from "lucide-react";
|
||||
import { credentialsApi, type AgentCredentialRequirement } from "@/api/credentials";
|
||||
|
||||
export interface Credential {
|
||||
@@ -40,6 +40,7 @@ interface CredentialRow {
|
||||
adenSupported: boolean; // whether this credential uses OAuth via Aden
|
||||
valid: boolean | null; // true = health check passed, false = failed, null = not checked
|
||||
validationMessage: string | null;
|
||||
alternativeGroup: string | null; // non-null when multiple providers can satisfy a tool
|
||||
}
|
||||
|
||||
function requirementToRow(r: AgentCredentialRequirement): CredentialRow {
|
||||
@@ -54,6 +55,7 @@ function requirementToRow(r: AgentCredentialRequirement): CredentialRow {
|
||||
adenSupported: r.aden_supported,
|
||||
valid: r.valid,
|
||||
validationMessage: r.validation_message,
|
||||
alternativeGroup: r.alternative_group ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,6 +63,16 @@ function requirementToRow(r: AgentCredentialRequirement): CredentialRow {
|
||||
// Cleared on save/delete so the next fetch picks up updated availability.
|
||||
const credentialCache = new Map<string, AgentCredentialRequirement[]>();
|
||||
|
||||
/** Clear cached credential requirements so the next modal open fetches fresh data.
|
||||
* Call with a specific path to clear one entry, or no args to clear all. */
|
||||
export function clearCredentialCache(agentPath?: string) {
|
||||
if (agentPath) {
|
||||
credentialCache.delete(agentPath);
|
||||
} else {
|
||||
credentialCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
interface CredentialsModalProps {
|
||||
agentType: string;
|
||||
agentLabel: string;
|
||||
@@ -89,9 +101,9 @@ export default function CredentialsModal({
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasAdenKey, setHasAdenKey] = useState(true); // assume true until backend says otherwise
|
||||
const [adenKeyInput, setAdenKeyInput] = useState("");
|
||||
const [savingAdenKey, setSavingAdenKey] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const pendingAdenAuth = useRef(false);
|
||||
const lastFocusFetch = useRef(0);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
setError(null);
|
||||
@@ -107,8 +119,7 @@ export default function CredentialsModal({
|
||||
|
||||
// Real agent — ask backend what credentials it actually needs
|
||||
setLoading(true);
|
||||
const { required, has_aden_key } = await credentialsApi.checkAgent(agentPath);
|
||||
setHasAdenKey(has_aden_key);
|
||||
const { required } = await credentialsApi.checkAgent(agentPath);
|
||||
credentialCache.set(agentPath, required);
|
||||
setRows(required.map(requirementToRow));
|
||||
} else {
|
||||
@@ -124,6 +135,7 @@ export default function CredentialsModal({
|
||||
adenSupported: false,
|
||||
valid: null,
|
||||
validationMessage: null,
|
||||
alternativeGroup: null,
|
||||
})));
|
||||
} else {
|
||||
setRows([]);
|
||||
@@ -139,54 +151,73 @@ export default function CredentialsModal({
|
||||
fetchStatus();
|
||||
setEditingId(null);
|
||||
setInputValue("");
|
||||
setAdenKeyInput("");
|
||||
setDeletingId(null);
|
||||
}
|
||||
}, [open, fetchStatus]);
|
||||
|
||||
const handleSaveAdenKey = async () => {
|
||||
if (!adenKeyInput.trim()) return;
|
||||
setSavingAdenKey(true);
|
||||
try {
|
||||
await credentialsApi.saveAdenKey(adenKeyInput.trim());
|
||||
setAdenKeyInput("");
|
||||
// Re-fetch when user returns to window (e.g. after completing OAuth on Aden).
|
||||
// Uses "focus" instead of "visibilitychange" because window.open("_blank")
|
||||
// doesn't reliably trigger visibilitychange — the original tab may never
|
||||
// lose visibility. "focus" fires reliably when the user clicks back.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleFocus = () => {
|
||||
// Debounce: skip if we fetched within the last 3 seconds
|
||||
const now = Date.now();
|
||||
if (now - lastFocusFetch.current < 3000) return;
|
||||
lastFocusFetch.current = now;
|
||||
if (agentPath) credentialCache.delete(agentPath);
|
||||
onCredentialChange?.();
|
||||
await fetchStatus();
|
||||
} catch {
|
||||
setError("Failed to save Aden API Key");
|
||||
} finally {
|
||||
setSavingAdenKey(false);
|
||||
}
|
||||
};
|
||||
fetchStatus();
|
||||
if (pendingAdenAuth.current) {
|
||||
pendingAdenAuth.current = false;
|
||||
setEditingId("aden_api_key");
|
||||
setInputValue("");
|
||||
}
|
||||
};
|
||||
window.addEventListener("focus", handleFocus);
|
||||
return () => window.removeEventListener("focus", handleFocus);
|
||||
}, [open, agentPath, fetchStatus]);
|
||||
|
||||
const handleConnect = async (row: CredentialRow) => {
|
||||
if (editingId === row.id) {
|
||||
if (inputValue.trim()) {
|
||||
// Has input — save the key
|
||||
setSaving(true);
|
||||
try {
|
||||
await credentialsApi.save(row.id, { [row.credentialKey]: inputValue.trim() });
|
||||
setEditingId(null);
|
||||
setInputValue("");
|
||||
if (agentPath) credentialCache.delete(agentPath);
|
||||
onCredentialChange?.();
|
||||
await fetchStatus();
|
||||
} catch {
|
||||
setError(`Failed to save ${row.name}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Empty input on aden_api_key — fall through to re-open Aden
|
||||
if (row.id !== "aden_api_key") return;
|
||||
}
|
||||
|
||||
if (row.id === "aden_api_key" && row.adenSupported) {
|
||||
// Aden Platform key — open Aden so user can grab key from Developers tab
|
||||
window.open("https://hive.adenhq.com/", "_blank", "noopener");
|
||||
pendingAdenAuth.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.adenSupported) {
|
||||
// OAuth credential — redirect to Aden platform
|
||||
window.open("https://hive.adenhq.com/", "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingId === row.id) {
|
||||
// Already editing — save
|
||||
if (!inputValue.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await credentialsApi.save(row.id, { [row.credentialKey]: inputValue.trim() });
|
||||
setEditingId(null);
|
||||
setInputValue("");
|
||||
if (agentPath) credentialCache.delete(agentPath);
|
||||
onCredentialChange?.();
|
||||
await fetchStatus();
|
||||
} catch {
|
||||
setError(`Failed to save ${row.name}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
} else {
|
||||
// Start editing — show inline API key input
|
||||
setEditingId(row.id);
|
||||
setInputValue("");
|
||||
}
|
||||
// Start editing — show inline API key input
|
||||
setEditingId(row.id);
|
||||
setInputValue("");
|
||||
setDeletingId(null);
|
||||
};
|
||||
|
||||
const handleDisconnect = async (row: CredentialRow) => {
|
||||
@@ -207,12 +238,29 @@ export default function CredentialsModal({
|
||||
if (!open) return null;
|
||||
|
||||
const connectedCount = rows.filter(c => c.connected).length;
|
||||
const requiredCount = rows.filter(c => c.required).length;
|
||||
const requiredConnected = rows.filter(c => c.required && c.connected).length;
|
||||
const invalidCount = rows.filter(c => c.valid === false).length;
|
||||
const missingCount = requiredCount - requiredConnected;
|
||||
const allRequiredMet = requiredConnected === requiredCount && invalidCount === 0;
|
||||
const needsAdenKeyInput = !hasAdenKey && rows.some(r => r.adenSupported);
|
||||
|
||||
// Alternative groups (e.g. send_email → resend OR google): satisfied if ANY is connected & valid
|
||||
const altGroups = new Map<string, boolean>();
|
||||
for (const c of rows) {
|
||||
if (!c.alternativeGroup) continue;
|
||||
if (!altGroups.has(c.alternativeGroup)) altGroups.set(c.alternativeGroup, false);
|
||||
if (c.connected && c.valid !== false) altGroups.set(c.alternativeGroup, true);
|
||||
}
|
||||
const altGroupsSatisfied = altGroups.size === 0 || [...altGroups.values()].every(Boolean);
|
||||
|
||||
// Non-alternative required credentials
|
||||
const nonAltRequired = rows.filter(c => c.required && !c.alternativeGroup);
|
||||
const nonAltMet = nonAltRequired.every(c => c.connected && c.valid !== false);
|
||||
|
||||
const allRequiredMet = nonAltMet && altGroupsSatisfied;
|
||||
|
||||
// For status banner counts
|
||||
const nonAltMissing = nonAltRequired.filter(c => !c.connected).length;
|
||||
const altGroupsMissing = [...altGroups.values()].filter(v => !v).length;
|
||||
const missingCount = nonAltMissing + altGroupsMissing;
|
||||
|
||||
const adenPlatformConnected = rows.find(r => r.id === "aden_api_key")?.connected ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -248,7 +296,9 @@ export default function CredentialsModal({
|
||||
{allRequiredMet ? (
|
||||
<>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
All required credentials connected ({connectedCount}/{rows.length} total)
|
||||
{rows.length === 0
|
||||
? "No required credentials!"
|
||||
: `All required credentials connected (${connectedCount}/${rows.length} total)`}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -275,50 +325,6 @@ export default function CredentialsModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aden API Key section */}
|
||||
{!loading && needsAdenKeyInput && (
|
||||
<div className="mx-5 mt-4 px-3 py-3 rounded-lg border border-amber-500/30 bg-amber-500/5">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<KeyRound className="w-3.5 h-3.5 text-amber-600" />
|
||||
<span className="text-sm font-medium text-foreground">Aden API Key</span>
|
||||
<span className="text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded text-destructive/70 bg-destructive/10">
|
||||
Required
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mb-2">
|
||||
Required to connect OAuth integrations below.{" "}
|
||||
<a
|
||||
href="https://hive.adenhq.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-0.5"
|
||||
>
|
||||
Get your key at hive.adenhq.com
|
||||
<ExternalLink className="w-2.5 h-2.5" />
|
||||
</a>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={adenKeyInput}
|
||||
onChange={(e) => setAdenKeyInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSaveAdenKey();
|
||||
}}
|
||||
placeholder="Paste your ADEN_API_KEY..."
|
||||
className="flex-1 px-3 py-1.5 rounded-md border border-border bg-background text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveAdenKey}
|
||||
disabled={savingAdenKey || !adenKeyInput.trim()}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{savingAdenKey ? <Loader2 className="w-3 h-3 animate-spin" /> : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Credential list */}
|
||||
{!loading && (
|
||||
<div className="p-5 space-y-2">
|
||||
@@ -338,13 +344,23 @@ export default function CredentialsModal({
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{row.name}</span>
|
||||
{row.required && (
|
||||
<span className={`text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${
|
||||
row.connected
|
||||
? "text-emerald-600/70 bg-emerald-500/10"
|
||||
: "text-destructive/70 bg-destructive/10"
|
||||
}`}>
|
||||
Required
|
||||
</span>
|
||||
row.alternativeGroup ? (
|
||||
<span className={`text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${
|
||||
row.connected
|
||||
? "text-emerald-600/70 bg-emerald-500/10"
|
||||
: "text-amber-600/70 bg-amber-500/10"
|
||||
}`}>
|
||||
Either
|
||||
</span>
|
||||
) : (
|
||||
<span className={`text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${
|
||||
row.connected
|
||||
? "text-emerald-600/70 bg-emerald-500/10"
|
||||
: "text-destructive/70 bg-destructive/10"
|
||||
}`}>
|
||||
Required
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">{row.description}</p>
|
||||
@@ -355,25 +371,53 @@ export default function CredentialsModal({
|
||||
{row.connected ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{row.valid === false ? (
|
||||
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium bg-destructive/10 text-destructive" title={row.validationMessage || "Invalid"}>
|
||||
<button
|
||||
onClick={() => handleConnect(row)}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium bg-destructive/10 text-destructive hover:bg-destructive/15 transition-colors"
|
||||
title={row.validationMessage || "Invalid — click to update"}
|
||||
>
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Invalid
|
||||
</span>
|
||||
{row.adenSupported ? "Reauthorize" : "Update Key"}
|
||||
</button>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium bg-primary/10 text-primary">
|
||||
<Check className="w-3 h-3" />
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDisconnect(row)}
|
||||
disabled={saving}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
title="Disconnect"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
{(row.id === "aden_api_key" || !row.adenSupported) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingId(editingId === row.id ? null : row.id);
|
||||
setInputValue("");
|
||||
setDeletingId(null);
|
||||
}}
|
||||
disabled={saving}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
title="Update key"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
{!(row.adenSupported && row.id !== "aden_api_key") && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setDeletingId(deletingId === row.id ? null : row.id);
|
||||
if (editingId) { setEditingId(null); setInputValue(""); }
|
||||
}}
|
||||
disabled={saving}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
title="Delete credential"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : row.adenSupported && !adenPlatformConnected && row.id !== "aden_api_key" ? (
|
||||
<span className="text-[11px] text-muted-foreground italic flex-shrink-0">
|
||||
Connect Aden Platform key first
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleConnect(row)}
|
||||
@@ -395,8 +439,34 @@ export default function CredentialsModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline delete confirmation */}
|
||||
{deletingId === row.id && (
|
||||
<div className="mt-1.5 flex items-center gap-2 px-3 py-2 rounded-lg border border-destructive/30 bg-destructive/5">
|
||||
<AlertCircle className="w-3.5 h-3.5 text-destructive flex-shrink-0" />
|
||||
<span className="text-xs text-destructive flex-1">
|
||||
Permanently delete this API key?
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDeletingId(null);
|
||||
handleDisconnect(row);
|
||||
}}
|
||||
disabled={saving}
|
||||
className="px-3 py-1 rounded-md text-xs font-medium bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? <Loader2 className="w-3 h-3 animate-spin" /> : "Delete"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingId(null)}
|
||||
className="px-2 py-1 rounded-md text-xs text-muted-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline API key input */}
|
||||
{editingId === row.id && !row.connected && (
|
||||
{editingId === row.id && (
|
||||
<div className="mt-1.5 flex gap-2 px-3">
|
||||
<input
|
||||
type="password"
|
||||
@@ -406,7 +476,7 @@ export default function CredentialsModal({
|
||||
if (e.key === "Enter") handleConnect(row);
|
||||
if (e.key === "Escape") { setEditingId(null); setInputValue(""); }
|
||||
}}
|
||||
placeholder={`Paste your ${row.name} API key...`}
|
||||
placeholder={`${row.connected ? "Enter new" : "Paste your"} ${row.name} API key...`}
|
||||
autoFocus
|
||||
className="flex-1 px-3 py-1.5 rounded-md border border-border bg-background text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||
/>
|
||||
|
||||
@@ -20,9 +20,19 @@ interface ToolCredential {
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface SubagentReport {
|
||||
subagent_id: string;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
status?: "running" | "complete" | "error";
|
||||
}
|
||||
|
||||
interface NodeDetailPanelProps {
|
||||
node: GraphNode | null;
|
||||
nodeSpec?: NodeSpec | null;
|
||||
allNodeSpecs?: NodeSpec[];
|
||||
subagentReports?: SubagentReport[];
|
||||
sessionId?: string;
|
||||
graphId?: string;
|
||||
workerSessionId?: string | null;
|
||||
@@ -137,7 +147,7 @@ function LogsTab({ nodeId, isActive: _isActive, sessionId, graphId, workerSessio
|
||||
}, [lines]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto scrollbar-hide bg-background/80 rounded-xl border border-border/20 font-mono text-[10.5px] leading-relaxed p-3">
|
||||
<div className="flex-1 overflow-auto bg-background/80 rounded-xl border border-border/20 font-mono text-[10.5px] leading-relaxed p-3">
|
||||
{lines.map((line, i) => {
|
||||
const isWarn = line.includes(" WARN ");
|
||||
const isErr = line.includes(" ERROR ");
|
||||
@@ -175,7 +185,7 @@ function SystemPromptTab({ systemPrompt }: { systemPrompt?: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto scrollbar-hide flex flex-col gap-2">
|
||||
<div className="flex-1 overflow-auto flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">System Prompt</p>
|
||||
<button
|
||||
@@ -195,10 +205,96 @@ function SystemPromptTab({ systemPrompt }: { systemPrompt?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentsTab() {
|
||||
function SubagentStatusBadge({ status }: { status?: "running" | "complete" | "error" }) {
|
||||
if (!status) return null;
|
||||
if (status === "running") {
|
||||
return (
|
||||
<span className="ml-auto flex items-center gap-1 text-[10px] font-medium flex-shrink-0" style={{ color: "hsl(45,95%,58%)" }}>
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75" style={{ backgroundColor: "hsl(45,95%,58%)" }} />
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5" style={{ backgroundColor: "hsl(45,95%,58%)" }} />
|
||||
</span>
|
||||
Running
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "complete") {
|
||||
return (
|
||||
<span className="ml-auto flex items-center gap-1 text-[10px] font-medium flex-shrink-0" style={{ color: "hsl(43,70%,45%)" }}>
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
Complete
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-xs text-muted-foreground/60 italic text-center">No subagents assigned to this node.</p>
|
||||
<span className="ml-auto flex items-center gap-1 text-[10px] font-medium flex-shrink-0" style={{ color: "hsl(0,65%,55%)" }}>
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Failed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentsTab({ subAgentIds, allNodeSpecs, subagentReports }: { subAgentIds: string[]; allNodeSpecs: NodeSpec[]; subagentReports: SubagentReport[] }) {
|
||||
if (subAgentIds.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-xs text-muted-foreground/60 italic text-center">No subagents assigned to this node.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1">Sub-agents ({subAgentIds.length})</p>
|
||||
{subAgentIds.map(saId => {
|
||||
const spec = allNodeSpecs.find(n => n.id === saId);
|
||||
const reports = subagentReports.filter(r => r.subagent_id === saId);
|
||||
// Derive status from latest report that has a status field
|
||||
const latestStatus = [...reports].reverse().find(r => r.status)?.status;
|
||||
// Progress messages are reports without a status field (from report_to_parent)
|
||||
const progressReports = reports.filter(r => !r.status);
|
||||
|
||||
return (
|
||||
<div key={saId} className="rounded-xl border border-border/20 overflow-hidden">
|
||||
<div className="p-3 bg-muted/30">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Bot className="w-3.5 h-3.5 text-primary/70 flex-shrink-0" />
|
||||
<span className="text-xs font-medium text-foreground truncate">{spec?.name || saId}</span>
|
||||
<SubagentStatusBadge status={latestStatus} />
|
||||
</div>
|
||||
{spec?.description && (
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed mt-1">{spec.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Static info: tools + output keys */}
|
||||
<div className="px-3 py-2 border-t border-border/15 bg-muted/15">
|
||||
{spec?.tools && spec.tools.length > 0 && (
|
||||
<div className="mb-1.5">
|
||||
<span className="text-[10px] text-muted-foreground font-medium">Tools: </span>
|
||||
<span className="text-[10px] text-foreground/70">{spec.tools.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
{spec?.output_keys && spec.output_keys.length > 0 && (
|
||||
<div>
|
||||
<span className="text-[10px] text-muted-foreground font-medium">Outputs: </span>
|
||||
<span className="text-[10px] text-foreground/70 font-mono">{spec.output_keys.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live progress reports (from report_to_parent) */}
|
||||
{progressReports.length > 0 && (
|
||||
<div className="px-3 py-2 border-t border-border/15 bg-background/60">
|
||||
<p className="text-[10px] text-muted-foreground font-medium mb-1">Reports ({progressReports.length})</p>
|
||||
{progressReports.map((r, i) => (
|
||||
<div key={i} className="text-[10.5px] text-foreground/70 leading-relaxed py-0.5">{r.message}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -213,7 +309,7 @@ const tabs: { id: Tab; label: string; Icon: React.FC<{ className?: string }> }[]
|
||||
{ id: "subagents", label: "Subagents", Icon: ({ className }) => <Bot className={className} /> },
|
||||
];
|
||||
|
||||
export default function NodeDetailPanel({ node, nodeSpec, sessionId, graphId, workerSessionId, nodeLogs, actionPlan, onClose }: NodeDetailPanelProps) {
|
||||
export default function NodeDetailPanel({ node, nodeSpec, allNodeSpecs, subagentReports, sessionId, graphId, workerSessionId, nodeLogs, actionPlan, onClose }: NodeDetailPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("overview");
|
||||
const [realTools, setRealTools] = useState<ToolInfo[] | null>(null);
|
||||
const [realCriteria, setRealCriteria] = useState<NodeCriteria | null>(null);
|
||||
@@ -295,7 +391,7 @@ export default function NodeDetailPanel({ node, nodeSpec, sessionId, graphId, wo
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-border/30 flex-shrink-0 px-2 pt-1 overflow-x-auto scrollbar-hide">
|
||||
{tabs.map(tab => (
|
||||
{tabs.filter(t => t.id !== "subagents" || (nodeSpec?.sub_agents && nodeSpec.sub_agents.length > 0)).map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
@@ -312,7 +408,7 @@ export default function NodeDetailPanel({ node, nodeSpec, sessionId, graphId, wo
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-auto scrollbar-hide px-4 py-4 flex flex-col gap-3">
|
||||
<div className="flex-1 overflow-auto px-4 py-4 flex flex-col gap-3">
|
||||
{activeTab === "overview" && (
|
||||
<>
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Action Plan</p>
|
||||
@@ -397,8 +493,12 @@ export default function NodeDetailPanel({ node, nodeSpec, sessionId, graphId, wo
|
||||
<SystemPromptTab systemPrompt={nodeSpec?.system_prompt} />
|
||||
)}
|
||||
|
||||
{activeTab === "subagents" && (
|
||||
<SubagentsTab />
|
||||
{activeTab === "subagents" && nodeSpec?.sub_agents && (
|
||||
<SubagentsTab
|
||||
subAgentIds={nodeSpec.sub_agents}
|
||||
allNodeSpecs={allNodeSpecs || []}
|
||||
subagentReports={subagentReports || []}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Send, MessageCircleQuestion, X } from "lucide-react";
|
||||
|
||||
export interface QuestionWidgetProps {
|
||||
/** The question text shown to the user */
|
||||
question: string;
|
||||
/** 1-3 predefined options. The UI appends an "Other" free-text option. */
|
||||
options: string[];
|
||||
/** Called with the selected option label or custom text, and whether "Other" was chosen */
|
||||
onSubmit: (answer: string, isOther: boolean) => void;
|
||||
/** Called when user dismisses the question without answering */
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export default function QuestionWidget({ question, options, onSubmit, onDismiss }: QuestionWidgetProps) {
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [customText, setCustomText] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// "Other" is always the last option index
|
||||
const otherIndex = options.length;
|
||||
const isOtherSelected = selected === otherIndex;
|
||||
|
||||
// Focus the text input when "Other" is selected
|
||||
useEffect(() => {
|
||||
if (isOtherSelected) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [isOtherSelected]);
|
||||
|
||||
const canSubmit = selected !== null && (!isOtherSelected || customText.trim().length > 0);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!canSubmit || submitted) return;
|
||||
setSubmitted(true);
|
||||
if (isOtherSelected) {
|
||||
onSubmit(customText.trim(), true);
|
||||
} else {
|
||||
onSubmit(options[selected!], false);
|
||||
}
|
||||
}, [canSubmit, submitted, isOtherSelected, customText, options, selected, onSubmit]);
|
||||
|
||||
// Keyboard: Enter to submit, number keys to select (only when text input is not focused)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (submitted) return;
|
||||
const inTextInput = e.target === inputRef.current;
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Number keys 1-4 select options — skip when typing in the "Other" field
|
||||
if (!inTextInput) {
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= options.length + 1) {
|
||||
e.preventDefault();
|
||||
setSelected(num - 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleSubmit, submitted, options.length]);
|
||||
|
||||
if (submitted) return null;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="p-4">
|
||||
<div className="bg-card border border-border rounded-xl shadow-sm overflow-hidden">
|
||||
{/* Header / Question */}
|
||||
<div className="px-5 pt-4 pb-3 flex items-start gap-3">
|
||||
<div className="w-7 h-7 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<MessageCircleQuestion className="w-3.5 h-3.5 text-primary" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground leading-relaxed flex-1">{question}</p>
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors flex-shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="px-5 pb-3 space-y-1.5">
|
||||
{options.map((option, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setSelected(idx)}
|
||||
className={`w-full text-left px-4 py-2.5 rounded-lg border text-sm transition-colors ${
|
||||
selected === idx
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border/60 bg-muted/20 text-foreground hover:border-primary/40 hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground mr-2">{idx + 1}.</span>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* "Other" — inline text input that auto-selects on focus */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={customText}
|
||||
onFocus={() => setSelected(otherIndex)}
|
||||
onChange={(e) => {
|
||||
setSelected(otherIndex);
|
||||
setCustomText(e.target.value);
|
||||
}}
|
||||
placeholder="Type a custom response..."
|
||||
className={`w-full px-4 py-2.5 rounded-lg border border-dashed text-sm transition-colors bg-transparent placeholder:text-muted-foreground focus:outline-none ${
|
||||
isOtherSelected
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border text-muted-foreground hover:border-primary/40"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="px-5 pb-4">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Crown, X } from "lucide-react";
|
||||
import { loadPersistedTabs, savePersistedTabs, TAB_STORAGE_KEY, type PersistedTabState } from "@/lib/tab-persistence";
|
||||
import { sessionsApi } from "@/api/sessions";
|
||||
|
||||
export interface TopBarTab {
|
||||
agentType: string;
|
||||
@@ -50,6 +51,14 @@ export default function TopBar({ tabs: tabsProp, onTabClick, onCloseTab, canClos
|
||||
onCloseTab(agentType);
|
||||
return;
|
||||
}
|
||||
// Kill the backend session (queen/judge/worker) even outside workspace
|
||||
sessionsApi.list()
|
||||
.then(({ sessions }) => {
|
||||
const match = sessions.find(s => s.agent_path === agentType);
|
||||
if (match) return sessionsApi.stop(match.session_id);
|
||||
})
|
||||
.catch(() => {}); // fire-and-forget
|
||||
|
||||
// Fallback: update localStorage directly (non-workspace pages)
|
||||
setPersisted(prev => {
|
||||
if (!prev) return null;
|
||||
@@ -82,7 +91,7 @@ export default function TopBar({ tabs: tabsProp, onTabClick, onCloseTab, canClos
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<button onClick={() => navigate("/")} className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-shrink-0">
|
||||
<Crown className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-semibold text-primary">Hive</span>
|
||||
<span className="text-sm font-semibold text-primary">Open Hive</span>
|
||||
</button>
|
||||
|
||||
{tabs.length > 0 && (
|
||||
|
||||
@@ -80,28 +80,39 @@ export function useMultiSSE({ sessions, onEvent }: UseMultiSSEOptions) {
|
||||
const onEventRef = useRef(onEvent);
|
||||
onEventRef.current = onEvent;
|
||||
|
||||
const sourcesRef = useRef(new Map<string, EventSource>());
|
||||
// Track both the EventSource and its session ID so we can detect session changes
|
||||
const sourcesRef = useRef(new Map<string, { es: EventSource; sessionId: string }>());
|
||||
|
||||
// Diff-based open/close — runs on every `sessions` change
|
||||
useEffect(() => {
|
||||
const current = sourcesRef.current;
|
||||
const desired = new Set(Object.keys(sessions));
|
||||
|
||||
// Close connections for sessions no longer in the map
|
||||
for (const [agentType, es] of current) {
|
||||
if (!desired.has(agentType)) {
|
||||
es.close();
|
||||
// Close connections for removed agents OR changed session IDs
|
||||
for (const [agentType, entry] of current) {
|
||||
if (!desired.has(agentType) || sessions[agentType] !== entry.sessionId) {
|
||||
console.log('[SSE] closing:', agentType, entry.sessionId, desired.has(agentType) ? '(session changed)' : '(removed)');
|
||||
entry.es.close();
|
||||
current.delete(agentType);
|
||||
}
|
||||
}
|
||||
|
||||
// Open connections for newly added sessions
|
||||
// Open connections for new/changed sessions
|
||||
for (const [agentType, sessionId] of Object.entries(sessions)) {
|
||||
if (!sessionId || current.has(agentType)) continue;
|
||||
|
||||
const url = `/api/sessions/${sessionId}/events`;
|
||||
console.log('[SSE] opening:', agentType, sessionId);
|
||||
const es = new EventSource(url);
|
||||
|
||||
es.onopen = () => {
|
||||
console.log('[SSE] connected:', agentType, sessionId);
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
console.error('[SSE] error:', agentType, sessionId, 'readyState:', es.readyState);
|
||||
};
|
||||
|
||||
es.onmessage = (e: MessageEvent) => {
|
||||
try {
|
||||
const event: AgentEvent = JSON.parse(e.data);
|
||||
@@ -112,14 +123,14 @@ export function useMultiSSE({ sessions, onEvent }: UseMultiSSEOptions) {
|
||||
}
|
||||
};
|
||||
|
||||
current.set(agentType, es);
|
||||
current.set(agentType, { es, sessionId });
|
||||
}
|
||||
}, [sessions]);
|
||||
|
||||
// Close all on unmount only
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const es of sourcesRef.current.values()) es.close();
|
||||
for (const entry of sourcesRef.current.values()) entry.es.close();
|
||||
sourcesRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -87,9 +87,63 @@
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
textarea {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar hide utility */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
*:hover,
|
||||
*:active {
|
||||
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||
}
|
||||
|
||||
/* Webkit (Chrome/Safari/Edge) — thin overlay track */
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
*:hover::-webkit-scrollbar-thumb,
|
||||
*:active::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Light mode adjustments */
|
||||
:root:not(.dark) *:hover,
|
||||
:root:not(.dark) *:active {
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||
}
|
||||
|
||||
:root:not(.dark) *:hover::-webkit-scrollbar-thumb,
|
||||
:root:not(.dark) *:active::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
:root:not(.dark) *::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* Keep scrollbar-hide for elements that truly need no scrollbar (e.g. tab bars) */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
@@ -113,3 +167,12 @@
|
||||
.animate-in.slide-in-from-right {
|
||||
animation: slide-in-from-right 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Slide-up animation for question widget */
|
||||
@keyframes slide-in-from-bottom {
|
||||
from { transform: translateY(16px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
.animate-in.slide-in-from-bottom {
|
||||
animation: slide-in-from-bottom 0.25s ease-out;
|
||||
}
|
||||
|
||||
@@ -160,7 +160,97 @@ describe("sseEventToChatMessage", () => {
|
||||
expect(result!.id).toBe("stream-exec-123-chat");
|
||||
});
|
||||
|
||||
it("falls back to '0' when both turnId and execution_id are null", () => {
|
||||
it("combines execution_id and turnId to differentiate loop iterations", () => {
|
||||
const event = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "chat",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "hello" },
|
||||
});
|
||||
const r1 = sseEventToChatMessage(event, "t", undefined, 1);
|
||||
const r2 = sseEventToChatMessage(event, "t", undefined, 2);
|
||||
expect(r1!.id).toBe("stream-exec-1-1-chat");
|
||||
expect(r2!.id).toBe("stream-exec-1-2-chat");
|
||||
expect(r1!.id).not.toBe(r2!.id);
|
||||
});
|
||||
|
||||
it("same execution_id + same turnId produces same ID (streaming upsert within iteration)", () => {
|
||||
const e1 = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "chat",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "partial" },
|
||||
});
|
||||
const e2 = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "chat",
|
||||
execution_id: "exec-1",
|
||||
data: { snapshot: "partial response" },
|
||||
});
|
||||
expect(sseEventToChatMessage(e1, "t", undefined, 3)!.id).toBe(
|
||||
sseEventToChatMessage(e2, "t", undefined, 3)!.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses data.iteration over turnId when present", () => {
|
||||
const event = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: null,
|
||||
data: { snapshot: "hello", iteration: 5 },
|
||||
});
|
||||
const result = sseEventToChatMessage(event, "t", undefined, 2);
|
||||
expect(result!.id).toBe("stream-5-queen");
|
||||
});
|
||||
|
||||
it("falls back to turnId when data.iteration is absent", () => {
|
||||
const event = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: null,
|
||||
data: { snapshot: "hello" },
|
||||
});
|
||||
const result = sseEventToChatMessage(event, "t", undefined, 2);
|
||||
expect(result!.id).toBe("stream-2-queen");
|
||||
});
|
||||
|
||||
it("different iterations from same node produce different message IDs", () => {
|
||||
const e1 = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "",
|
||||
data: { snapshot: "first response", iteration: 0 },
|
||||
});
|
||||
const e2 = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "",
|
||||
data: { snapshot: "second response", iteration: 3 },
|
||||
});
|
||||
const r1 = sseEventToChatMessage(e1, "t");
|
||||
const r2 = sseEventToChatMessage(e2, "t");
|
||||
expect(r1!.id).not.toBe(r2!.id);
|
||||
});
|
||||
|
||||
it("same iteration produces same ID for streaming upsert", () => {
|
||||
const e1 = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "",
|
||||
data: { snapshot: "partial", iteration: 2 },
|
||||
});
|
||||
const e2 = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "queen",
|
||||
execution_id: "",
|
||||
data: { snapshot: "partial response", iteration: 2 },
|
||||
});
|
||||
expect(sseEventToChatMessage(e1, "t")!.id).toBe(
|
||||
sseEventToChatMessage(e2, "t")!.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses timestamp fallback when both turnId and execution_id are null", () => {
|
||||
const event = makeEvent({
|
||||
type: "client_output_delta",
|
||||
node_id: "chat",
|
||||
@@ -168,7 +258,7 @@ describe("sseEventToChatMessage", () => {
|
||||
data: { snapshot: "hello" },
|
||||
});
|
||||
const result = sseEventToChatMessage(event, "t");
|
||||
expect(result!.id).toBe("stream-0-chat");
|
||||
expect(result!.id).toMatch(/^stream-t-\d+-chat$/);
|
||||
});
|
||||
|
||||
it("converts client_input_requested with prompt to message", () => {
|
||||
|
||||
@@ -37,8 +37,11 @@ export function backendMessageToChatMessage(
|
||||
thread: string,
|
||||
agentDisplayName?: string,
|
||||
): ChatMessage {
|
||||
// Use file-mtime created_at (epoch seconds → ms) for cross-conversation
|
||||
// ordering; fall back to seq for backwards compatibility.
|
||||
const createdAt = msg.created_at ? msg.created_at * 1000 : msg.seq;
|
||||
return {
|
||||
id: `backend-${msg.seq}`,
|
||||
id: `backend-${msg._node_id}-${msg.seq}`,
|
||||
agent: msg.role === "user" ? "You" : agentDisplayName || msg._node_id || "Agent",
|
||||
agentColor: "",
|
||||
content: msg.content,
|
||||
@@ -46,6 +49,7 @@ export function backendMessageToChatMessage(
|
||||
type: msg.role === "user" ? "user" : undefined,
|
||||
role: msg.role === "user" ? undefined : "worker",
|
||||
thread,
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,40 +65,41 @@ export function sseEventToChatMessage(
|
||||
agentDisplayName?: string,
|
||||
turnId?: number,
|
||||
): ChatMessage | null {
|
||||
// turnId disambiguates messages across response turns. Within a single
|
||||
// turn the ID stays stable so the upsert logic can replace the previous
|
||||
// snapshot (streaming). Across turns, different turnIds produce different
|
||||
// IDs so each response gets its own bubble.
|
||||
const idKey = turnId != null ? String(turnId) : (event.execution_id ?? "0");
|
||||
// Combine execution_id (unique per execution) with turnId (increments per
|
||||
// loop iteration) so each iteration gets its own bubble while streaming
|
||||
// deltas within one iteration still share the same ID for upsert.
|
||||
const eid = event.execution_id ?? "";
|
||||
const tid = turnId != null ? String(turnId) : "";
|
||||
const idKey = eid && tid ? `${eid}-${tid}` : eid || tid || `t-${Date.now()}`;
|
||||
// Use the backend event timestamp for message ordering
|
||||
const createdAt = event.timestamp ? new Date(event.timestamp).getTime() : Date.now();
|
||||
|
||||
switch (event.type) {
|
||||
case "client_output_delta": {
|
||||
// Prefer backend-provided iteration (reliable, embedded in event data)
|
||||
// over frontend turnCounter (can desync when SSE queue drops events).
|
||||
const iter = event.data?.iteration;
|
||||
const iterTid = iter != null ? String(iter) : tid;
|
||||
const iterIdKey = eid && iterTid ? `${eid}-${iterTid}` : eid || iterTid || `t-${Date.now()}`;
|
||||
|
||||
const snapshot = (event.data?.snapshot as string) || (event.data?.content as string) || "";
|
||||
if (!snapshot) return null;
|
||||
return {
|
||||
id: `stream-${idKey}-${event.node_id}`,
|
||||
id: `stream-${iterIdKey}-${event.node_id}`,
|
||||
agent: agentDisplayName || event.node_id || "Agent",
|
||||
agentColor: "",
|
||||
content: snapshot,
|
||||
timestamp: "",
|
||||
role: "worker",
|
||||
thread,
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
case "client_input_requested": {
|
||||
const prompt = (event.data?.prompt as string) || "";
|
||||
if (!prompt) return null;
|
||||
return {
|
||||
id: `input-req-${idKey}-${event.node_id}`,
|
||||
agent: agentDisplayName || event.node_id || "Agent",
|
||||
agentColor: "",
|
||||
content: prompt,
|
||||
timestamp: "",
|
||||
role: "worker",
|
||||
thread,
|
||||
};
|
||||
}
|
||||
case "client_input_requested":
|
||||
// Handled explicitly in handleSSEEvent (workspace.tsx) so it can
|
||||
// create a worker_input_request message and set awaitingInput state.
|
||||
return null;
|
||||
|
||||
case "llm_text_delta": {
|
||||
const snapshot = (event.data?.snapshot as string) || (event.data?.content as string) || "";
|
||||
@@ -107,6 +112,7 @@ export function sseEventToChatMessage(
|
||||
timestamp: "",
|
||||
role: "worker",
|
||||
thread,
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,6 +125,7 @@ export function sseEventToChatMessage(
|
||||
timestamp: "",
|
||||
type: "system",
|
||||
thread,
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,6 +139,7 @@ export function sseEventToChatMessage(
|
||||
timestamp: "",
|
||||
type: "system",
|
||||
thread,
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user