Compare commits
1106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b8ed521c0 | |||
| 219e603de6 | |||
| 7663a5bce8 | |||
| f2841b945d | |||
| faff64c413 | |||
| 6fbcdc1d87 | |||
| 69a11af949 | |||
| 9ef272020e | |||
| 258cfe7de5 | |||
| 0d53b21133 | |||
| 0ccb28ffab | |||
| b30b571b44 | |||
| bc44c3a401 | |||
| 7fbf57cbb7 | |||
| 67d094f51a | |||
| 873af04c6e | |||
| 2f0439dca8 | |||
| 1920192656 | |||
| f56feaf821 | |||
| 4cbd5a4c6c | |||
| 65aa5629e8 | |||
| 7193d09bed | |||
| 49f8fae0b4 | |||
| e1a490756e | |||
| 91bfaf36e3 | |||
| 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 | |||
| 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 | |||
| c13b02c7d9 | |||
| 9e72801c28 | |||
| 3a3d538b73 | |||
| b11bca0c67 | |||
| faf8975b42 | |||
| 863168880e | |||
| 384a1f0560 | |||
| 4bd1b1b9e6 | |||
| 8c3866a014 | |||
| 61283d9bd6 | |||
| 585a7186d4 | |||
| 72a31c2a65 | |||
| 10d9e54857 | |||
| e68695ee92 | |||
| 11379fc0ef | |||
| 6d102382bd | |||
| 56335927e7 | |||
| a3fe994b22 | |||
| 5754bdcc78 | |||
| eef2fa9ffb | |||
| 7286907cd4 | |||
| 754e33a1ae | |||
| 1fbb431f1b | |||
| 0ad52b90d8 | |||
| c44b12cc8b | |||
| 8381c95617 | |||
| 3963855d1d | |||
| 51154a3070 | |||
| b11b43bbe1 | |||
| 7a7ece1805 | |||
| 28a71b70a8 | |||
| 33d3a13fde | |||
| 5ea278a08d | |||
| fd95f8da28 | |||
| 86f4645d1c | |||
| 2d05e96cd5 | |||
| c1d5952ad9 | |||
| ebeac68707 | |||
| 72673e12fb | |||
| 3867d3926b | |||
| 0b2b7a2622 | |||
| 3951ee1a7d | |||
| 1afde51c7b | |||
| cbeef18f0a | |||
| de5fcab933 | |||
| a7a2100472 | |||
| 1947d8c3ca | |||
| 55c63736ef | |||
| a2b68d893f | |||
| fd06e43d9c | |||
| b550f6efa0 | |||
| 47adf88773 | |||
| 9c44d3b793 | |||
| 9b89ac694e | |||
| 8748da38cf | |||
| f697dc99fb | |||
| 630d8208cf | |||
| ecb038c955 | |||
| 77ff31cec6 | |||
| 9b342dc593 | |||
| 5ea8677a5d | |||
| ad879de6ff | |||
| 97f5b3423f | |||
| 4968207eef | |||
| f859e2203a | |||
| fb3dad4354 | |||
| adc82c6a65 | |||
| 96084fea16 | |||
| 6f52026c84 | |||
| 3576218ea9 | |||
| 4c662db530 | |||
| da1ce4e5a7 | |||
| c4944c5662 | |||
| d892f87651 | |||
| 447f23d157 | |||
| aa12f0d295 | |||
| 795266aab4 | |||
| 4e4ef121f9 | |||
| ddb9126955 | |||
| bac6d6dd68 | |||
| de9226aae0 | |||
| 16e1ab1a87 | |||
| 54287e06ad | |||
| 3451570541 | |||
| b33de5f0e1 | |||
| 2d5ef20d4d | |||
| 177346b159 | |||
| 08819b1609 | |||
| e5e939f344 | |||
| 0d51d25482 | |||
| 35b1332551 | |||
| 52586a024b | |||
| 05a314b121 | |||
| a0a5b10df0 | |||
| 04bac93c14 | |||
| 8e262e2270 | |||
| 4961d3ba8c | |||
| 733bb4d2dd | |||
| ba31c760a6 | |||
| a388bc6837 | |||
| 3f5bbbf1e3 | |||
| 002da15375 | |||
| 005609da3a | |||
| 182d9ca6f9 | |||
| a6b43f8016 | |||
| 31700fa8da | |||
| 6b475ec1cf | |||
| 1b27844c52 | |||
| 3a0b91f7ab | |||
| 82108e32fa | |||
| 28f4fecfb3 | |||
| ff1bb08217 | |||
| 10617fee0d | |||
| 866103ddf4 | |||
| fcfaca6bd0 | |||
| 4c7d9ab0fb | |||
| 061aec4b3d | |||
| 047f4a1a0c | |||
| f12ab10725 | |||
| 0882fa6ce5 | |||
| 7994b90dfa | |||
| 04b6a80370 | |||
| 0b87e4c45d | |||
| 9c7e846828 | |||
| 30bd0e483a | |||
| 13cc93c334 | |||
| 564b1bb752 | |||
| 2f31a92d31 | |||
| fd89c7f56f | |||
| 35738c8279 | |||
| a0d14b8a25 | |||
| 9c781ed78e | |||
| 460a24e34a | |||
| 0f8627f17a | |||
| 8ae030e16e | |||
| 3c6467c814 | |||
| 2f11f0c911 | |||
| c3ae67fb1d | |||
| 8c750c7edd | |||
| 571838a289 | |||
| dafaaae792 | |||
| b45e14efb4 | |||
| e70cbf26e2 | |||
| daafdc3704 | |||
| 6661934fed | |||
| f568728de1 | |||
| 263d35bbd6 | |||
| bece21d217 | |||
| d4788e147a | |||
| f4594ecf37 | |||
| 8f1462cb79 | |||
| 76d4d0de69 | |||
| 6ab4e1d641 | |||
| fc0c3e169f | |||
| 4760f95bda | |||
| c5d87c99fd | |||
| f53f403022 | |||
| b887b2951e | |||
| 842b69b155 | |||
| d6c34106fc | |||
| 67cbd31280 | |||
| cd0cf69099 | |||
| cf877f2b49 | |||
| 6f34cb2c8a | |||
| a04a8a866d | |||
| b88aa2b53c | |||
| 356cab19eb | |||
| 7c6d5fa446 | |||
| 2dae3e47fd | |||
| 6fce789607 | |||
| 9bbb5b38e6 | |||
| ac73aa93bf | |||
| 52a56e4a10 | |||
| a1cede510d | |||
| 682c10e873 | |||
| 5605e24a0d | |||
| f7268a44d9 | |||
| af7a4ff4e8 | |||
| 60b9c0d763 | |||
| 5c550270c6 | |||
| e03fd48e48 | |||
| 6420c74c24 | |||
| ad74351530 | |||
| 1b5f656429 | |||
| 132d84c529 | |||
| a03b378e9b | |||
| 74635e1d7d | |||
| 893053ede7 | |||
| 596ec6fec5 | |||
| 5863b83172 | |||
| 20c92b197a | |||
| 8c9baa62b0 | |||
| ec9c6b4666 | |||
| 8a73e5c119 | |||
| 717f0eee9a | |||
| 09fb47f089 | |||
| b46d943e71 | |||
| 262eaa6d84 | |||
| b980d6f6ab | |||
| 61f27369ef | |||
| 204b0b4744 | |||
| fc1a48f3bc | |||
| 060f320cd1 | |||
| 1b6ebb1e42 | |||
| bff32bcaa3 | |||
| 7dfc75b3e6 | |||
| 2920b5ab01 | |||
| 81ad0467b0 | |||
| 115ca55ea0 | |||
| f2814a26e6 | |||
| 4d309950b0 | |||
| 39216a4c12 | |||
| c7fa621aeb | |||
| 5914d28cbe | |||
| 8c3ad3d70a | |||
| 9eb3fc6285 | |||
| e95f7e7339 | |||
| d949551399 | |||
| a7dbd85ed4 | |||
| 1f288dab1c | |||
| 021754d941 | |||
| 7412904fbf | |||
| cd1976e2b9 | |||
| 5f3e9379a3 | |||
| 0e565d6cea | |||
| 67b249dcd5 | |||
| bbf1c8c790 | |||
| 9744363342 | |||
| 6fe8439e94 | |||
| 8e61ffe377 | |||
| 723476f7a7 | |||
| 0f253027ae | |||
| 6053895a82 | |||
| 44a8b453b5 | |||
| 26511fe962 | |||
| ce5893216a | |||
| 4e821e4dbf | |||
| d11e97de59 | |||
| 4b10d3e360 | |||
| e04479930f | |||
| 8a8c4cc3f5 | |||
| 1e06ff611e | |||
| 1edc7bb9c7 | |||
| ceffa38717 | |||
| 7b1e0af155 | |||
| 7b15616e29 | |||
| ae205fa3f2 | |||
| bd7d2277d8 | |||
| 99ed00fd02 | |||
| f7af5f9ee8 | |||
| e5bcc8005f | |||
| 352d285212 | |||
| 3ef60f9d14 | |||
| a103312127 | |||
| 3d0bba4167 | |||
| 3df718cc14 | |||
| c7497a180e | |||
| 3f39039a21 | |||
| 88fbd90fcc | |||
| e0bf09dd78 | |||
| 3e158b07af | |||
| 5319ed7ee1 | |||
| 978904d2a4 | |||
| 4d876ecc54 | |||
| ba327d0b9e | |||
| b69cf3523c | |||
| 4d8c8e9308 | |||
| 669a05892b | |||
| b70885934c | |||
| 722b087fc0 | |||
| 4898a9759a | |||
| 2c2fa25580 | |||
| 56496d7dbd | |||
| 0c7ea272db | |||
| dd0696e44d | |||
| dcda273e0b | |||
| f3b159c650 | |||
| 06df037e28 | |||
| e814e516d1 | |||
| 0375e068ed | |||
| 34ffc533d3 | |||
| 5e4f322fc0 | |||
| c02e45f1aa | |||
| a7217f138c | |||
| ea2ea1a4ae | |||
| 9e11947687 | |||
| 47117281e1 | |||
| 032dd13f5a | |||
| 3502f25048 | |||
| 13d8ebbeff | |||
| 93c026fe31 | |||
| e515977b96 | |||
| 045490a097 | |||
| b25903fb7f | |||
| acf4bd5152 | |||
| 1f5711e1a1 | |||
| ca2dd90313 | |||
| 21e07f3b65 | |||
| e8a06ddd34 | |||
| 34cc09904f | |||
| f6bba8b62f | |||
| d241ad60f8 | |||
| 5a3fcf9a8a | |||
| 1f8a47203f | |||
| 7240090274 | |||
| 2e6a47c2df | |||
| 7f5ecd7913 | |||
| 105b98b113 | |||
| 114e65ab41 | |||
| 0fc13a5cc3 | |||
| 2efa0e01df | |||
| e651799e9e | |||
| fcd3e514de | |||
| 7ab41de3a2 | |||
| 58e023f277 | |||
| a98f2d5b86 | |||
| 6044369fdf | |||
| eca43231c0 | |||
| 6763077887 | |||
| f85ff8a2f8 | |||
| 1a5c3480e6 | |||
| 69a7fe7b92 | |||
| a5418d760f | |||
| 0deeb87c63 | |||
| d1d5f49c5a | |||
| 917e23ccc8 | |||
| 988922304f | |||
| ab2bd726c3 | |||
| 713fefb163 | |||
| 83140a1398 | |||
| cafa6dd930 | |||
| 82e1af1a7a | |||
| 30c3dc9205 | |||
| 9a3c6703e1 | |||
| e26468aa19 | |||
| fe14992696 | |||
| d0775b95c6 | |||
| 96121b5757 | |||
| 11c003c48d | |||
| fbe72c58ae | |||
| 816156e87f | |||
| 7bceab3cea | |||
| 83d7f56728 | |||
| 76deba2a6a | |||
| d9d048b9e3 | |||
| 930f417729 | |||
| 8e214d06c1 | |||
| 63e0348963 | |||
| b46a5f0247 | |||
| 79dfd90068 | |||
| f9d5c7c751 | |||
| 8958fb2d88 | |||
| 40e74e408b | |||
| 3c51f2ac36 | |||
| 170a0918f7 | |||
| e3da3b619c | |||
| 97440f9e8a | |||
| 6e32513b79 | |||
| 520e1963ee | |||
| 843b9b55e2 | |||
| ccd305ff96 | |||
| 3bd0d1e48c | |||
| d9bfa8e675 | |||
| 27746147e2 | |||
| 3a0b642980 | |||
| 8c0241f087 | |||
| 958d016174 | |||
| 913d318ada | |||
| 8212920cb7 | |||
| 6414be7bd4 | |||
| ac62a82d08 | |||
| a670548a57 | |||
| c4a7463f9d | |||
| edf0ac5270 | |||
| 8ff6b76f37 | |||
| c9f9eb365c | |||
| 7a17c115d3 | |||
| 9a2a11055f | |||
| f21aecd91c | |||
| 4aef73c1d7 | |||
| 906480a6e8 | |||
| 9df147b450 | |||
| b71b4b0fc2 | |||
| 1bd2510c52 | |||
| 28b81092f9 | |||
| 4b9a3abba6 | |||
| 0c76b6dcb1 | |||
| 090a85b41b | |||
| 992d573573 | |||
| 9e768e660b | |||
| 26b9ed362e | |||
| 765f7cae58 | |||
| b455c8a2ad | |||
| 976ae75fde | |||
| 9da91b5319 | |||
| 2493beaf5a | |||
| d63dd021ab | |||
| da25e0ffa5 | |||
| 697ba89314 | |||
| b6c65ab5d5 | |||
| 162f9a55ad | |||
| e484fdfa51 | |||
| 77d9ccf2e4 | |||
| 94e39ee09e | |||
| 373ad77008 | |||
| 661b0c0038 | |||
| 8ed38bf0e2 | |||
| 4d675dfff7 | |||
| b42a3293f1 | |||
| 87e9bf853d | |||
| c56f78422a | |||
| ac311e10ba | |||
| 0297520263 | |||
| 4803552a7a | |||
| b8d85ff723 | |||
| 7d571dfaec | |||
| ba02e53bdd | |||
| 153e6142ff | |||
| 228449c9d8 | |||
| c65eed8802 | |||
| 40d32f2e01 | |||
| c83aac5e12 | |||
| 48b9241247 | |||
| 7779bc5336 | |||
| beec549f74 | |||
| 310698ecc0 | |||
| 4f719c4778 | |||
| 4cc00f3bdc | |||
| 1f9c47fef1 | |||
| 80a4980640 | |||
| 8dbe424f5a | |||
| ec9bf033e6 | |||
| a2d21ec7bc | |||
| 06ccc853ee | |||
| 4847332161 | |||
| 8c1ee54725 | |||
| 5e537d9d55 | |||
| d6b95067a1 | |||
| 32cae75ef5 | |||
| 21e7554cdb | |||
| e07703c01f | |||
| 374442e900 | |||
| a1a0ec5ddb | |||
| 1fd56b079c | |||
| a12163d63f | |||
| 0cd6f21980 | |||
| a88fc1d75c | |||
| 87b0037fcd | |||
| 767d32d420 | |||
| e9bde26611 | |||
| c02f40622c | |||
| 929dc24e93 | |||
| 8cfb533fef | |||
| 3328a388b3 | |||
| 8f632eb005 | |||
| c8ee961436 | |||
| 6fd7efece6 | |||
| bc9f6b0af8 | |||
| 7d48f17867 | |||
| 776583b3ad | |||
| 9c28dae583 | |||
| 59a315b90b | |||
| 866518f188 | |||
| 736ae65a1d | |||
| 76c9f7c9a9 | |||
| 32ad225d7f | |||
| e5428bec5c | |||
| 7ae6f67470 | |||
| faf534511b | |||
| 594bceb8f5 | |||
| 9dc0f48ec9 | |||
| 9d11f834b8 | |||
| a4abf3eb2b | |||
| 269d72d073 | |||
| c8f5dccbd2 | |||
| 8b797ee73f | |||
| de38adb1e4 | |||
| c169bcc5d8 | |||
| 131b72cd0c | |||
| 80ea286beb | |||
| ce5a2d4a81 | |||
| 3499be782e | |||
| 7f489cee46 | |||
| 3c2d669a2f | |||
| 16603ae49c | |||
| bf6bd9ce7f | |||
| a54c0f6f46 | |||
| beeed11d48 | |||
| ec36e96499 | |||
| 9ecd4980e4 | |||
| 64446ff9b6 | |||
| e3d2262292 | |||
| 891cfa387a | |||
| f0243fddf2 | |||
| 85ff8e364b | |||
| 75f1afe8e3 | |||
| 7b660311e5 | |||
| 98a493296d | |||
| bc2a42aed2 | |||
| 8b501d9091 | |||
| 25331590a7 | |||
| cddae0ed18 | |||
| 9dca42be27 | |||
| a1f3fe4d55 | |||
| 0304b392b2 | |||
| ae9b4e82fe | |||
| 4bac5e4c46 | |||
| c4d3400ec4 | |||
| 1da9bb0c0f | |||
| 760ed51ad3 | |||
| bff9f8976e | |||
| b71628e211 | |||
| 6d0a3b952a | |||
| 8c1cb1f55b | |||
| 66214384a9 | |||
| 6d6646887c | |||
| 6f8db0ed08 | |||
| 6aaf6836ea | |||
| 4f2348f50e | |||
| 873fcd5822 | |||
| a08f3a8925 | |||
| 2a98d3a489 | |||
| b681ba03b1 | |||
| fe775a36c0 | |||
| 2df9adcb43 | |||
| c756cbf6d5 | |||
| d0ac67c9d3 | |||
| 47cd55052f | |||
| fb203b5bdf | |||
| 6ee47e243d | |||
| c1844b7a9d | |||
| 99a29e79e5 | |||
| 589a66ef26 | |||
| 3f960763cb | |||
| 15f8f3783c | |||
| a2b045c7e3 | |||
| 055cef2fdc | |||
| 6c6c69cbc3 | |||
| 6fe0062e6e | |||
| 26b8b2f448 | |||
| 7e40d6950a | |||
| 590bfa92cb | |||
| f0e89a1720 | |||
| 575563b1e8 | |||
| 82ea0e47ce | |||
| 2f57ca10f7 | |||
| 75c2d541c4 | |||
| b666f8b50b | |||
| 09f9322676 | |||
| f9a864ef93 | |||
| 27f28afe9c | |||
| 8f85722fef | |||
| 5588445a01 | |||
| 40529b5722 | |||
| cee632f50c | |||
| 3453e3aa05 | |||
| 8de637c421 | |||
| 6c75de862c | |||
| 2971134882 | |||
| 6e79860b43 | |||
| 3f6bdda2a0 | |||
| 74d0287ec5 | |||
| 51e81d80fc | |||
| a73239dd98 | |||
| d68783a612 | |||
| a28ea40a7d | |||
| f2492bd4d4 | |||
| deb7f2f72a | |||
| d989d9c65a | |||
| 4173c606ab | |||
| a01430d20f | |||
| 2a8f775732 | |||
| 5b00445c05 | |||
| 8b828dd139 | |||
| 9d156325e0 | |||
| 221712128d | |||
| e9fc36f2d3 | |||
| 305b880b1d | |||
| 4a0d9b2855 | |||
| 92c65d69ea | |||
| 910a8968c4 | |||
| 7519c73f2a | |||
| bf402aaa18 | |||
| cdb4679c5a | |||
| 1a9dce89b4 | |||
| cf1e4d7f88 | |||
| f2f0b4fc61 | |||
| b21dd25181 | |||
| 04a18bcbe5 | |||
| 7f66dd67eb | |||
| cfa03b89c8 | |||
| 9866d7a22b | |||
| 331a6e442f | |||
| 7fae57f311 | |||
| 1c2295b2b5 | |||
| 1f653969a9 | |||
| fa43ca3785 | |||
| b4a2c3bd14 | |||
| 2d4ec4f462 | |||
| 1e8b933da0 | |||
| 4310852ee6 | |||
| 853f1e9873 | |||
| ae5fe84fb2 | |||
| 92b538d5ae | |||
| 5351703949 | |||
| 48b1e0e038 | |||
| 7ba8169444 | |||
| d090c954ae | |||
| 9bee1666f1 | |||
| fb94637339 |
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"agent-builder": {
|
||||
"command": "uv",
|
||||
"args": ["run", "--directory", "core", "-m", "framework.mcp.agent_builder_server"],
|
||||
"disabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/hive
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/hive-concepts
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/hive-create
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/hive-credentials
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/hive-patterns
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/hive-test
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description: hive-concepts
|
||||
---
|
||||
|
||||
use hive-concepts skill
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description: hive-create
|
||||
---
|
||||
|
||||
use hive-create skill
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description: hive-credentials
|
||||
---
|
||||
|
||||
use hive-credentials skill
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description: hive-patterns
|
||||
---
|
||||
|
||||
use hive-patterns skill
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description: hive-test
|
||||
---
|
||||
|
||||
use hive-test skill
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
description: hive
|
||||
---
|
||||
|
||||
use hive skill
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/hive
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/hive-concepts
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/hive-create
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/hive-credentials
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/hive-patterns
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/hive-test
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__agent-builder__create_session",
|
||||
"mcp__agent-builder__set_goal",
|
||||
"mcp__agent-builder__add_node",
|
||||
"mcp__agent-builder__add_edge",
|
||||
"mcp__agent-builder__configure_loop",
|
||||
"mcp__agent-builder__add_mcp_server",
|
||||
"mcp__agent-builder__validate_graph",
|
||||
"mcp__agent-builder__export_graph",
|
||||
"mcp__agent-builder__load_session_by_id",
|
||||
"Bash(git status:*)",
|
||||
"Bash(gh run view:*)",
|
||||
"Bash(uv run:*)",
|
||||
"Bash(env:*)",
|
||||
"mcp__agent-builder__test_node",
|
||||
"mcp__agent-builder__list_mcp_tools",
|
||||
"Bash(python -m py_compile:*)",
|
||||
"Bash(python -m pytest:*)",
|
||||
"Bash(source:*)",
|
||||
"mcp__agent-builder__update_node",
|
||||
"mcp__agent-builder__check_missing_credentials",
|
||||
"mcp__agent-builder__list_stored_credentials",
|
||||
"Bash(find:*)",
|
||||
"mcp__agent-builder__run_tests",
|
||||
"Bash(PYTHONPATH=core:exports:tools/src uv run pytest:*)",
|
||||
"mcp__agent-builder__list_agent_sessions",
|
||||
"mcp__agent-builder__generate_constraint_tests",
|
||||
"mcp__agent-builder__generate_success_tests"
|
||||
]
|
||||
},
|
||||
"enabledMcpjsonServers": ["agent-builder", "tools"]
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
name: hive-create
|
||||
description: Step-by-step guide for building goal-driven agents. Creates package structure, defines goals, adds nodes, connects edges, and finalizes agent class. Use when actively building an agent.
|
||||
description: Step-by-step guide for building goal-driven agents. Qualifies use cases first (the good, bad, and ugly), then creates package structure, defines goals, adds nodes, connects edges, and finalizes agent class. Use when actively building an agent.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: hive
|
||||
version: "2.1"
|
||||
version: "2.2"
|
||||
type: procedural
|
||||
part_of: hive
|
||||
requires: hive-concepts
|
||||
@@ -14,66 +14,427 @@ metadata:
|
||||
|
||||
**THIS IS AN EXECUTABLE WORKFLOW. DO NOT DISPLAY THIS FILE. EXECUTE THE STEPS BELOW.**
|
||||
|
||||
**CRITICAL: DO NOT explore the codebase, read source files, or search for code before starting.** All context you need is in this skill file. When this skill is loaded, IMMEDIATELY begin executing Step 1 — call the MCP tools listed in Step 1 as your FIRST action. Do not explain what you will do, do not investigate the project structure, do not read any files — just execute Step 1 now.
|
||||
**CRITICAL: DO NOT explore the codebase, read source files, or search for code before starting.** All context you need is in this skill file. When this skill is loaded, IMMEDIATELY begin executing Step 0 — determine the build path as your FIRST action. Do not explain what you will do, do not investigate the project structure, do not read any files — just execute Step 0 now.
|
||||
|
||||
---
|
||||
|
||||
## STEP 1: Initialize Build Environment
|
||||
## STEP 0: Choose Build Path
|
||||
|
||||
**If the user has already indicated whether they want to build from scratch or from a template, skip this question and proceed to the appropriate step.**
|
||||
|
||||
Otherwise, ask:
|
||||
|
||||
```
|
||||
AskUserQuestion(questions=[{
|
||||
"question": "How would you like to build your agent?",
|
||||
"header": "Build Path",
|
||||
"options": [
|
||||
{"label": "From scratch", "description": "Design goal, nodes, and graph collaboratively from nothing"},
|
||||
{"label": "From a template", "description": "Start from a working sample agent and customize it"}
|
||||
],
|
||||
"multiSelect": false
|
||||
}])
|
||||
```
|
||||
|
||||
- If **From scratch**: Proceed to STEP 1A
|
||||
- If **From a template**: Proceed to STEP 1B
|
||||
|
||||
---
|
||||
|
||||
## STEP 1A: Initialize Build Environment (From Scratch)
|
||||
|
||||
**EXECUTE THESE TOOL CALLS NOW** (silent setup — no user interaction needed):
|
||||
|
||||
1. Register the hive-tools MCP server:
|
||||
1. Check for existing sessions:
|
||||
|
||||
```
|
||||
mcp__agent-builder__add_mcp_server(
|
||||
name="hive-tools",
|
||||
transport="stdio",
|
||||
command="python",
|
||||
args='["mcp_server.py", "--stdio"]',
|
||||
cwd="tools",
|
||||
description="Hive tools MCP server"
|
||||
)
|
||||
mcp__agent-builder__list_sessions()
|
||||
```
|
||||
|
||||
- If a session with this agent name already exists, load it with `mcp__agent-builder__load_session_by_id(session_id="...")` and skip to step 3.
|
||||
- If no matching session exists, proceed to step 2.
|
||||
|
||||
2. Create a build session (replace AGENT_NAME with the user's requested agent name in snake_case):
|
||||
|
||||
```
|
||||
mcp__agent-builder__create_session(name="AGENT_NAME")
|
||||
```
|
||||
|
||||
3. Discover available tools:
|
||||
3. Register the hive-tools MCP server:
|
||||
|
||||
```
|
||||
mcp__agent-builder__add_mcp_server(
|
||||
name="hive-tools",
|
||||
transport="stdio",
|
||||
command="uv",
|
||||
args='["run", "python", "mcp_server.py", "--stdio"]',
|
||||
cwd="tools",
|
||||
description="Hive tools MCP server"
|
||||
)
|
||||
```
|
||||
|
||||
4. Discover available tools:
|
||||
|
||||
```
|
||||
mcp__agent-builder__list_mcp_tools()
|
||||
```
|
||||
|
||||
4. Create the package directory:
|
||||
5. Create the package directory:
|
||||
|
||||
```bash
|
||||
mkdir -p exports/AGENT_NAME/nodes
|
||||
```
|
||||
|
||||
**Save the tool list for step 3** — you will need it for node design in STEP 3.
|
||||
**Save the tool list for STEP 4** — you will need it for node design.
|
||||
|
||||
**THEN immediately proceed to STEP 2** (do NOT display setup results to the user — just move on).
|
||||
|
||||
---
|
||||
|
||||
## STEP 1B: Initialize Build Environment (From Template)
|
||||
|
||||
**EXECUTE THESE STEPS NOW:**
|
||||
|
||||
### 1B.1: Discover available templates
|
||||
|
||||
List the template directories and read each template's `agent.json` to get its name and description:
|
||||
|
||||
```bash
|
||||
ls examples/templates/
|
||||
```
|
||||
|
||||
For each directory found, read `examples/templates/TEMPLATE_DIR/agent.json` with the Read tool and extract:
|
||||
- `agent.name` — the template's display name
|
||||
- `agent.description` — what the template does
|
||||
|
||||
### 1B.2: Present templates to user
|
||||
|
||||
Show the user a table of available templates:
|
||||
|
||||
> **Available Templates:**
|
||||
>
|
||||
> | # | Template | Description |
|
||||
> |---|----------|-------------|
|
||||
> | 1 | [name from agent.json] | [description from agent.json] |
|
||||
> | 2 | ... | ... |
|
||||
|
||||
Then ask the user to pick a template and provide a name for their new agent:
|
||||
|
||||
```
|
||||
AskUserQuestion(questions=[{
|
||||
"question": "Which template would you like to start from?",
|
||||
"header": "Template",
|
||||
"options": [
|
||||
{"label": "[template 1 name]", "description": "[template 1 description]"},
|
||||
{"label": "[template 2 name]", "description": "[template 2 description]"},
|
||||
...
|
||||
],
|
||||
"multiSelect": false
|
||||
}, {
|
||||
"question": "What should the new agent be named? (snake_case)",
|
||||
"header": "Agent Name",
|
||||
"options": [
|
||||
{"label": "Use template name", "description": "Keep the original template name as-is"},
|
||||
{"label": "Custom name", "description": "I'll provide a new snake_case name"}
|
||||
],
|
||||
"multiSelect": false
|
||||
}])
|
||||
```
|
||||
|
||||
### 1B.3: Copy template to exports
|
||||
|
||||
```bash
|
||||
cp -r examples/templates/TEMPLATE_DIR exports/NEW_AGENT_NAME
|
||||
```
|
||||
|
||||
### 1B.4: Create session and register MCP (same logic as STEP 1A)
|
||||
|
||||
First, check for existing sessions:
|
||||
|
||||
```
|
||||
mcp__agent-builder__list_sessions()
|
||||
```
|
||||
|
||||
- If a session with this agent name already exists, load it with `mcp__agent-builder__load_session_by_id(session_id="...")` and skip to `list_mcp_tools`.
|
||||
- If no matching session exists, create one:
|
||||
|
||||
```
|
||||
mcp__agent-builder__create_session(name="NEW_AGENT_NAME")
|
||||
```
|
||||
|
||||
Then register MCP and discover tools:
|
||||
|
||||
```
|
||||
mcp__agent-builder__add_mcp_server(
|
||||
name="hive-tools",
|
||||
transport="stdio",
|
||||
command="uv",
|
||||
args='["run", "python", "mcp_server.py", "--stdio"]',
|
||||
cwd="tools",
|
||||
description="Hive tools MCP server"
|
||||
)
|
||||
```
|
||||
|
||||
```
|
||||
mcp__agent-builder__list_mcp_tools()
|
||||
```
|
||||
|
||||
### 1B.5: Load template into builder session
|
||||
|
||||
Import the entire agent definition in one call:
|
||||
|
||||
```
|
||||
mcp__agent-builder__import_from_export(agent_json_path="exports/NEW_AGENT_NAME/agent.json")
|
||||
```
|
||||
|
||||
This reads the agent.json and populates the builder session with the goal, all nodes, and all edges.
|
||||
|
||||
**THEN immediately proceed to STEP 2.**
|
||||
|
||||
---
|
||||
|
||||
## STEP 2: Define Goal Together with User
|
||||
**A responsible engineer doesn't jump into building. First, understand the problem and be transparent about what the framework can and cannot do.**
|
||||
|
||||
**If starting from a template**, the goal is already loaded in the builder session. Present the existing goal to the user using the format below and ask for approval. Skip the collaborative drafting questions — go straight to presenting and asking "Do you approve this goal, or would you like to modify it?"
|
||||
|
||||
**If the user has NOT already described what they want to build**, start by asking what kind of agent they have in mind:
|
||||
|
||||
```
|
||||
AskUserQuestion(questions=[{
|
||||
"question": "What kind of agent do you want to build? Select an option below, or choose 'Other' to describe your own.",
|
||||
"header": "Agent type",
|
||||
"options": [
|
||||
{"label": "Data collection", "description": "Gathers information from the web, analyzes it, and produces a report or sends outreach (e.g. market research, news digest, email campaigns, competitive analysis)"},
|
||||
{"label": "Workflow automation", "description": "Automates a multi-step business process end-to-end (e.g. lead qualification, content publishing pipeline, data entry)"},
|
||||
{"label": "Personal assistant", "description": "Handles recurring tasks or monitors for events and acts on them (e.g. daily briefings, meeting prep, file organization)"}
|
||||
],
|
||||
"multiSelect": false
|
||||
}])
|
||||
```
|
||||
|
||||
Use the user's selection (or their custom description if they chose "Other") as context when shaping the goal below. If the user already described what they want before this step, skip the question and proceed directly.
|
||||
|
||||
**DO NOT propose a complete goal on your own.** Instead, collaborate with the user to define it.
|
||||
|
||||
**START by asking the user to help shape the goal:**
|
||||
### 2a: Fast Discovery (3-8 Turns)
|
||||
|
||||
> I've set up the build environment and discovered [N] available tools. Let's define the goal for your agent together.
|
||||
>
|
||||
> To get started, can you help me understand:
|
||||
>
|
||||
> 1. **What should this agent accomplish?** (the core purpose)
|
||||
> 2. **How will we know it succeeded?** (what does "done" look like)
|
||||
> 3. **Are there any hard constraints?** (things it must never do, quality bars, etc.)
|
||||
**The core principle**: Discovery should feel like progress, not paperwork. The stakeholder should walk away feeling like you understood them faster than anyone else would have.
|
||||
|
||||
**WAIT for the user to respond.** Use their input to draft:
|
||||
**Communication sytle**: Be concise. Say less. Mean more. Impatient stakeholders don't want a wall of text — they want to know you get it. Every sentence you say should either move the conversation forward or prove you understood something. If it does neither, cut it.
|
||||
|
||||
**Ask Question Rules: Respect Their Time.** Every question must earn its place by:
|
||||
1. **Preventing a costly wrong turn** — you're about to build the wrong thing
|
||||
2. **Unlocking a shortcut** — their answer lets you simplify the design
|
||||
3. **Surfacing a dealbreaker** — there's a constraint that changes everything
|
||||
4. **Provide Options** - Provide options to your questions if possible, but also always allow the user to type something beyong the options.
|
||||
|
||||
If a question doesn't do one of these, don't ask it. Make an assumption, state it, and move on.
|
||||
|
||||
---
|
||||
|
||||
#### 2a.1: Let Them Talk, But Listen Like an Architect
|
||||
|
||||
When the stakeholder describes what they want, don't just hear the words — listen for the architecture underneath. While they talk, mentally construct:
|
||||
|
||||
- **The actors**: Who are the people/systems involved?
|
||||
- **The trigger**: What kicks off the workflow?
|
||||
- **The core loop**: What's the main thing that happens repeatedly?
|
||||
- **The output**: What's the valuable thing produced at the end?
|
||||
- **The pain**: What about today's situation is broken, slow, or missing?
|
||||
|
||||
You are extracting a **domain model** from natural language in real time. Most stakeholders won't give you this structure explicitly — they'll give you a story. Your job is to hear the structure inside the story.
|
||||
|
||||
| They say... | You're hearing... |
|
||||
|-------------|-------------------|
|
||||
| Nouns they repeat | Your entities |
|
||||
| Verbs they emphasize | Your core operations |
|
||||
| Frustrations they mention | Your design constraints |
|
||||
| Workarounds they describe | What the system must replace |
|
||||
| People they name | Your user types |
|
||||
|
||||
---
|
||||
|
||||
#### 2a.2: Use Domain Knowledge to Fill In the Blanks
|
||||
|
||||
You have broad knowledge of how systems work. Use it aggressively.
|
||||
|
||||
If they say "I need a research agent," you already know it probably involves: search, summarization, source tracking, and iteration. Don't ask about each — use them as your starting mental model and let their specifics override your defaults.
|
||||
|
||||
If they say "I need to monitor files and alert me," you know this probably involves: watch patterns, triggers, notifications, and state tracking.
|
||||
|
||||
**The key move**: Take your general knowledge of the domain and merge it with the specifics they've given you. The result is a draft understanding that's 60-80% right before you've asked a single question. Your questions close the remaining 20-40%.
|
||||
|
||||
---
|
||||
|
||||
#### 2a.3: Play Back a Proposed Model (Not a List of Questions)
|
||||
|
||||
After listening, present a **concrete picture** of what you think they need. Make it specific enough that they can spot what's wrong.
|
||||
|
||||
**Pattern: "Here's what I heard — tell me where I'm off"**
|
||||
|
||||
> "OK here's how I'm picturing this: [User type] needs to [core action]. Right now they're [current painful workflow]. What you want is [proposed solution that replaces the pain].
|
||||
>
|
||||
> The way I'd structure this: [key entities] connected by [key relationships], with the main flow being [trigger → steps → outcome].
|
||||
>
|
||||
> For the MVP, I'd focus on [the one thing that delivers the most value] and hold off on [things that can wait].
|
||||
>
|
||||
> Before I start — [1-2 specific questions you genuinely can't infer]."
|
||||
|
||||
Why this works:
|
||||
- **Proves you were listening** — they don't feel like they have to repeat themselves
|
||||
- **Shows competence** — you're already thinking in systems
|
||||
- **Fast to correct** — "no, it's more like X" takes 10 seconds vs. answering 15 questions
|
||||
- **Creates momentum** — heading toward building, not more talking
|
||||
|
||||
---
|
||||
|
||||
#### 2a.4: Ask Only What You Cannot Infer
|
||||
|
||||
Your questions should be **narrow, specific, and consequential**. Never ask what you could answer yourself.
|
||||
|
||||
**Good questions** (high-stakes, can't infer):
|
||||
- "Who's the primary user — you or your end customers?"
|
||||
- "Is this replacing a spreadsheet, or is there literally nothing today?"
|
||||
- "Does this need to integrate with anything, or standalone?"
|
||||
- "Is there existing data to migrate, or starting fresh?"
|
||||
|
||||
**Bad questions** (low-stakes, inferable):
|
||||
- "What should happen if there's an error?" *(handle gracefully, obviously)*
|
||||
- "Should it have search?" *(if there's a list, yes)*
|
||||
- "How should we handle permissions?" *(follow standard patterns)*
|
||||
- "What tools should I use?" *(your call, not theirs)*
|
||||
|
||||
---
|
||||
|
||||
#### Conversation Flow (3-5 Turns)
|
||||
|
||||
| Turn | Who | What |
|
||||
|------|-----|------|
|
||||
| 1 | User | Describes what they need |
|
||||
| 2 | Agent | Plays back understanding as a proposed model. Asks 1-2 critical questions max. |
|
||||
| 3 | User | Corrects, confirms, or adds detail |
|
||||
| 4 | Agent | Adjusts model, confirms MVP scope, states assumptions, declares starting point |
|
||||
| *(5)* | *(Only if Turn 3 revealed something that fundamentally changes the approach)* |
|
||||
|
||||
**AFTER the conversation, IMMEDIATELY proceed to 2b. DO NOT skip to building.**
|
||||
|
||||
---
|
||||
|
||||
#### Anti-Patterns
|
||||
|
||||
| Don't | Do Instead |
|
||||
|-------|------------|
|
||||
| Open with a list of questions | Open with what you understood from their request |
|
||||
| "What are your requirements?" | "Here's what I think you need — am I right?" |
|
||||
| Ask about every edge case | Handle with smart defaults, flag in summary |
|
||||
| 10+ turn discovery conversation | 3-8 turns. Start building, iterate with real software. |
|
||||
| Being lazy nd not understand what user want to achieve | Understand "what" and "why |
|
||||
| Ask for permission to start | State your plan and start |
|
||||
| Wait for certainty | Start at 80% confidence, iterate the rest |
|
||||
| Ask what tech/tools to use | That's your job. Decide, disclose, move on. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
### 2b: Capability Assessment
|
||||
|
||||
**After the user responds, analyze the fit.** Present this assessment honestly:
|
||||
|
||||
> **Framework Fit Assessment**
|
||||
>
|
||||
> Based on what you've described, here's my honest assessment of how well this framework fits your use case:
|
||||
>
|
||||
> **What Works Well (The Good):**
|
||||
> - [List 2-4 things the framework handles well for this use case]
|
||||
> - Examples: multi-turn conversations, human-in-the-loop review, tool orchestration, structured outputs
|
||||
>
|
||||
> **Limitations to Be Aware Of (The Bad):**
|
||||
> - [List 2-3 limitations that apply but are workable]
|
||||
> - Examples: LLM latency means not suitable for sub-second responses, context window limits for very large documents, cost per run for heavy tool usage
|
||||
>
|
||||
> **Potential Deal-Breakers (The Ugly):**
|
||||
> - [List any significant challenges or missing capabilities — be honest]
|
||||
> - Examples: no tool available for X, would require custom MCP server, framework not designed for Y
|
||||
|
||||
**Be specific.** Reference the actual tools discovered in Step 1. If the user needs `send_email` but it's not available, say so. If they need real-time streaming from a database, explain that's not how the framework works.
|
||||
|
||||
### 2c: Gap Analysis
|
||||
|
||||
**Identify specific gaps** between what the user wants and what you can deliver:
|
||||
|
||||
| Requirement | Framework Support | Gap/Workaround |
|
||||
|-------------|-------------------|----------------|
|
||||
| [User need] | [✅ Supported / ⚠️ Partial / ❌ Not supported] | [How to handle or why it's a problem] |
|
||||
|
||||
**Examples of gaps to identify:**
|
||||
- Missing tools (user needs X, but only Y and Z are available)
|
||||
- Scope issues (user wants to process 10,000 items, but LLM rate limits apply)
|
||||
- Interaction mismatches (user wants CLI-only, but agent is designed for TUI)
|
||||
- Data flow issues (user needs to persist state across runs, but sessions are isolated)
|
||||
- Latency requirements (user needs instant responses, but LLM calls take seconds)
|
||||
|
||||
### 2d: Recommendation
|
||||
|
||||
**Give a clear recommendation:**
|
||||
|
||||
> **My Recommendation:**
|
||||
>
|
||||
> [One of these three:]
|
||||
>
|
||||
> **✅ PROCEED** — This is a good fit. The framework handles your core needs well. [List any minor caveats.]
|
||||
>
|
||||
> **⚠️ PROCEED WITH SCOPE ADJUSTMENT** — This can work, but we should adjust: [specific changes]. Without these adjustments, you'll hit [specific problems].
|
||||
>
|
||||
> **🛑 RECONSIDER** — This framework may not be the right tool for this job because [specific reasons]. Consider instead: [alternatives — simpler script, different framework, custom solution].
|
||||
|
||||
### 2e: Get Explicit Acknowledgment
|
||||
|
||||
**CALL AskUserQuestion:**
|
||||
|
||||
```
|
||||
AskUserQuestion(questions=[{
|
||||
"question": "Based on this assessment, how would you like to proceed?",
|
||||
"header": "Proceed",
|
||||
"options": [
|
||||
{"label": "Proceed as described", "description": "I understand the limitations, let's build it"},
|
||||
{"label": "Adjust scope", "description": "Let's modify the requirements to fit better"},
|
||||
{"label": "More questions", "description": "I have questions about the assessment"},
|
||||
{"label": "Reconsider", "description": "Maybe this isn't the right approach"}
|
||||
],
|
||||
"multiSelect": false
|
||||
}])
|
||||
```
|
||||
|
||||
**WAIT for user response.**
|
||||
|
||||
- If **Proceed**: Move to STEP 3
|
||||
- If **Adjust scope**: Discuss what to change, update your notes, re-assess if needed
|
||||
- If **More questions**: Answer them honestly, then ask again
|
||||
- If **Reconsider**: Discuss alternatives. If they decide to proceed anyway, that's their informed choice
|
||||
|
||||
---
|
||||
|
||||
## STEP 3: Define Goal Together with User
|
||||
|
||||
**Now that the use case is qualified, collaborate on the goal definition.**
|
||||
|
||||
**START by synthesizing what you learned:**
|
||||
|
||||
> Based on our discussion, here's my understanding of the goal:
|
||||
>
|
||||
> **Core purpose:** [what you understood from 2a]
|
||||
> **Success looks like:** [what you inferred]
|
||||
> **Key constraints:** [what you inferred]
|
||||
>
|
||||
> Let me refine this with you:
|
||||
>
|
||||
> 1. **What should this agent accomplish?** (confirm or correct my understanding)
|
||||
> 2. **How will we know it succeeded?** (what specific outcomes matter)
|
||||
> 3. **Are there any hard constraints?** (things it must never do, quality bars)
|
||||
|
||||
**WAIT for the user to respond.** Use their input (and the agent type they selected) to draft:
|
||||
|
||||
- Goal ID (kebab-case)
|
||||
- Goal name
|
||||
@@ -115,12 +476,14 @@ AskUserQuestion(questions=[{
|
||||
|
||||
**WAIT for user response.**
|
||||
|
||||
- If **Approve**: Call `mcp__agent-builder__set_goal(...)` with the goal details, then proceed to STEP 3
|
||||
- If **Approve**: Call `mcp__agent-builder__set_goal(...)` with the goal details, then proceed to STEP 4
|
||||
- If **Modify**: Ask what they want to change, update the draft, ask again
|
||||
|
||||
---
|
||||
|
||||
## STEP 3: Design Conceptual Nodes
|
||||
## STEP 4: Design Conceptual Nodes
|
||||
|
||||
**If starting from a template**, the nodes are already loaded in the builder session. Present the existing nodes using the table format below and ask for approval. Skip the design phase.
|
||||
|
||||
**BEFORE designing nodes**, review the available tools from Step 1. Nodes can ONLY use tools that exist.
|
||||
|
||||
@@ -129,7 +492,7 @@ AskUserQuestion(questions=[{
|
||||
- node_id (kebab-case)
|
||||
- name
|
||||
- description
|
||||
- node_type: `"event_loop"` (recommended for all LLM work) or `"function"` (deterministic, no LLM)
|
||||
- node_type: `"event_loop"` (the only valid type; use `client_facing: True` for HITL)
|
||||
- input_keys (what data this node receives)
|
||||
- output_keys (what data this node produces)
|
||||
- tools (ONLY tools that exist from Step 1 — empty list if no tools needed)
|
||||
@@ -173,12 +536,14 @@ AskUserQuestion(questions=[{
|
||||
|
||||
**WAIT for user response.**
|
||||
|
||||
- If **Approve**: Proceed to STEP 4
|
||||
- If **Approve**: Proceed to STEP 5
|
||||
- If **Modify**: Ask what they want to change, update design, ask again
|
||||
|
||||
---
|
||||
|
||||
## STEP 4: Design Full Graph and Review
|
||||
## STEP 5: Design Full Graph and Review
|
||||
|
||||
**If starting from a template**, the edges are already loaded in the builder session. Render the existing graph as ASCII art and present it to the user for approval. Skip the edge design phase.
|
||||
|
||||
**DETERMINE the edges** connecting the approved nodes. For each edge:
|
||||
|
||||
@@ -188,6 +553,26 @@ AskUserQuestion(questions=[{
|
||||
- condition_expr (Python expression, only if conditional)
|
||||
- priority (positive = forward, negative = feedback/loop-back)
|
||||
|
||||
**DETERMINE the graph lifecycle.** Not every agent needs a terminal node:
|
||||
|
||||
| Pattern | `terminal_nodes` | When to Use |
|
||||
|---------|-------------------|-------------|
|
||||
| **Linear (finish)** | `["last-node"]` | Agent completes a task and exits (batch processing, one-shot generation) |
|
||||
| **Forever-alive (loop)** | `[]` (empty) | Agent stays alive for continuous interaction (research assistant, personal assistant, monitoring) |
|
||||
|
||||
**Forever-alive pattern:** The deep_research_agent example uses `terminal_nodes=[]`. Every leaf node has edges that loop back to earlier nodes, creating a perpetual session. The agent only stops when the user explicitly exits. This is the preferred pattern for interactive, multi-turn agents.
|
||||
|
||||
**Key design rules for forever-alive graphs:**
|
||||
- Every node must have at least one outgoing edge (no dead ends)
|
||||
- Client-facing nodes block for user input — these are the natural "pause points"
|
||||
- The user controls when to stop, not the graph
|
||||
- Sessions accumulate memory across loops — plan for conversation compaction
|
||||
- Use `conversation_mode="continuous"` to preserve conversation history across node transitions
|
||||
- `max_iterations` should be set high (e.g., 100) since the agent is designed to run indefinitely
|
||||
- The agent will NOT enter a "completed" execution state — this is intentional, not a bug
|
||||
|
||||
**Ask the user** which lifecycle pattern fits their agent. Default to forever-alive for interactive agents, linear for batch/one-shot tasks.
|
||||
|
||||
**RENDER the complete graph as ASCII art.** Make it large and clear — the user needs to see and understand the full workflow at a glance.
|
||||
|
||||
**IMPORTANT: Make the ASCII art BIG and READABLE.** Use a box-and-arrow style with generous spacing. Do NOT make it tiny or compressed. Example format:
|
||||
@@ -288,16 +673,38 @@ AskUserQuestion(questions=[{
|
||||
|
||||
**WAIT for user response.**
|
||||
|
||||
- If **Approve**: Proceed to STEP 5
|
||||
- If **Approve**: Proceed to STEP 6
|
||||
- If **Modify**: Ask what they want to change, update the graph, re-render, ask again
|
||||
|
||||
---
|
||||
|
||||
## STEP 5: Build the Agent
|
||||
## STEP 6: Build the Agent
|
||||
|
||||
**NOW — and only now — write the actual code.** The user has approved the goal, nodes, and graph.
|
||||
|
||||
### 5a: Register nodes and edges with MCP
|
||||
### 6a: Register nodes and edges with MCP
|
||||
**If starting from a template**, the copied files will be overwritten with the approved design. You MUST replace every occurrence of the old template name with the new agent name. Here is the complete checklist — miss NONE of these:
|
||||
|
||||
| File | What to rename |
|
||||
|------|---------------|
|
||||
| `config.py` | `AgentMetadata.name` — the display name shown in TUI agent selection |
|
||||
| `config.py` | `AgentMetadata.description` — agent description |
|
||||
| `config.py` | `AgentMetadata.intro_message` — greeting shown to user when TUI loads |
|
||||
| `agent.py` | Module docstring (line 1) |
|
||||
| `agent.py` | `class OldNameAgent:` → `class NewNameAgent:` |
|
||||
| `agent.py` | `GraphSpec(id="old-name-graph")` → `GraphSpec(id="new-name-graph")` — shown in TUI status bar |
|
||||
| `agent.py` | Storage path: `Path.home() / ".hive" / "agents" / "old_name"` → `"new_name"` |
|
||||
| `__main__.py` | Module docstring (line 1) |
|
||||
| `__main__.py` | `from .agent import ... OldNameAgent` → `NewNameAgent` |
|
||||
| `__main__.py` | CLI help string in `def cli()` docstring |
|
||||
| `__main__.py` | All `OldNameAgent()` instantiations |
|
||||
| `__main__.py` | Storage path (duplicated from agent.py) |
|
||||
| `__main__.py` | Shell banner string (e.g. `"=== Old Name Agent ==="`) |
|
||||
| `__init__.py` | Package docstring |
|
||||
| `__init__.py` | `from .agent import OldNameAgent` import |
|
||||
| `__init__.py` | `__all__` list entry |
|
||||
|
||||
**If starting from a template and no modifications were made in Steps 2-5**, the nodes and edges are already registered. Skip to validation (`mcp__agent-builder__validate_graph()`). If modifications were made, re-register the changed nodes/edges (the MCP tools handle duplicates by overwriting).
|
||||
|
||||
**FOR EACH approved node**, call:
|
||||
|
||||
@@ -337,9 +744,9 @@ mcp__agent-builder__validate_graph()
|
||||
```
|
||||
|
||||
- If invalid: Fix the issues and re-validate
|
||||
- If valid: Continue to 5b
|
||||
- If valid: Continue to 6b
|
||||
|
||||
### 5b: Write Python package files
|
||||
### 6b: Write Python package files
|
||||
|
||||
**EXPORT the graph data:**
|
||||
|
||||
@@ -349,7 +756,7 @@ mcp__agent-builder__export_graph()
|
||||
|
||||
**THEN write the Python package files** using the exported data. Create these files in `exports/AGENT_NAME/`:
|
||||
|
||||
1. `config.py` - Runtime configuration with model settings
|
||||
1. `config.py` - Runtime configuration with model settings and `AgentMetadata` (including `intro_message` — the greeting shown when TUI loads)
|
||||
2. `nodes/__init__.py` - All NodeSpec definitions
|
||||
3. `agent.py` - Goal, edges, graph config, and agent class
|
||||
4. `__init__.py` - Package exports
|
||||
@@ -369,8 +776,8 @@ mcp__agent-builder__export_graph()
|
||||
{
|
||||
"hive-tools": {
|
||||
"transport": "stdio",
|
||||
"command": "python",
|
||||
"args": ["mcp_server.py", "--stdio"],
|
||||
"command": "uv",
|
||||
"args": ["run", "python", "mcp_server.py", "--stdio"],
|
||||
"cwd": "../../tools",
|
||||
"description": "Hive tools MCP server"
|
||||
}
|
||||
@@ -379,6 +786,7 @@ mcp__agent-builder__export_graph()
|
||||
|
||||
- NO `"mcpServers"` wrapper (that's Claude Desktop format, NOT hive format)
|
||||
- `cwd` MUST be `"../../tools"` (relative from `exports/AGENT_NAME/` to `tools/`)
|
||||
- `command` MUST be `"uv"` with `"args": ["run", "python", ...]` (NOT bare `"python"` which fails on Mac)
|
||||
|
||||
**Use the example agent** at `.claude/skills/hive-create/examples/deep_research_agent/` as a template for file structure and patterns. It demonstrates: STEP 1/STEP 2 prompts, client-facing nodes, feedback loops, nullable_output_keys, and data tools.
|
||||
|
||||
@@ -398,7 +806,7 @@ mcp__agent-builder__export_graph()
|
||||
|
||||
---
|
||||
|
||||
## STEP 6: Verify and Test
|
||||
## STEP 7: Verify and Test
|
||||
|
||||
**RUN validation:**
|
||||
|
||||
@@ -409,11 +817,34 @@ cd /home/timothy/oss/hive && PYTHONPATH=exports uv run python -m AGENT_NAME vali
|
||||
- If valid: Agent is complete!
|
||||
- If errors: Fix the issues and re-run
|
||||
|
||||
**TELL the user the agent is ready** and suggest next steps:
|
||||
**TELL the user the agent is ready** and display the next steps box:
|
||||
|
||||
- Run with mock mode to test without API calls
|
||||
- Use `/hive-test` skill for comprehensive testing
|
||||
- Use `/hive-credentials` if the agent needs API keys
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ✅ AGENT BUILD COMPLETE │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ NEXT STEPS: │
|
||||
│ │
|
||||
│ 1. SET UP CREDENTIALS (if agent uses tools like web_search, send_email): │
|
||||
│ │
|
||||
│ /hive-credentials --agent AGENT_NAME │
|
||||
│ │
|
||||
│ 2. RUN YOUR AGENT: │
|
||||
│ │
|
||||
│ hive tui │
|
||||
│ │
|
||||
│ Then select your agent from the list and press Enter. │
|
||||
│ │
|
||||
│ 3. DEBUG ANY ISSUES: │
|
||||
│ │
|
||||
│ /hive-debugger │
|
||||
│ │
|
||||
│ The debugger monitors runtime logs, identifies retry loops, │
|
||||
│ tool failures, and missing outputs, and provides fix recommendations. │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -421,8 +852,7 @@ cd /home/timothy/oss/hive && PYTHONPATH=exports uv run python -m AGENT_NAME vali
|
||||
|
||||
| Type | tools param | Use when |
|
||||
| ------------ | ----------------------- | --------------------------------------- |
|
||||
| `event_loop` | `'["tool1"]'` or `'[]'` | LLM-powered work with or without tools |
|
||||
| `function` | N/A | Deterministic Python operations, no LLM |
|
||||
| `event_loop` | `'["tool1"]'` or `'[]'` | All agent work (with or without tools, HITL via client_facing) |
|
||||
|
||||
---
|
||||
|
||||
@@ -483,7 +913,7 @@ EventLoopNodes are **auto-created** by `GraphExecutor` at runtime. Both direct `
|
||||
from framework.graph.executor import GraphExecutor
|
||||
from framework.runtime.core import Runtime
|
||||
|
||||
storage_path = Path.home() / ".hive" / "my_agent"
|
||||
storage_path = Path.home() / ".hive" / "agents" / "my_agent"
|
||||
storage_path.mkdir(parents=True, exist_ok=True)
|
||||
runtime = Runtime(storage_path)
|
||||
|
||||
@@ -501,16 +931,113 @@ result = await executor.execute(graph=graph, goal=goal, input_data=input_data)
|
||||
|
||||
---
|
||||
|
||||
## REFERENCE: Graph Lifecycle & Conversation Memory
|
||||
|
||||
### Terminal vs Forever-Alive Graphs
|
||||
|
||||
Agents have two lifecycle patterns:
|
||||
|
||||
**Linear (terminal) graphs** have `terminal_nodes=["last-node"]`. Execution ends when the terminal node completes. The session enters a "completed" state. Use for batch processing, one-shot generation, and fire-and-forget tasks.
|
||||
|
||||
**Forever-alive graphs** have `terminal_nodes=[]` (empty). Every node has at least one outgoing edge — the graph loops indefinitely. The session **never enters a "completed" state** — this is intentional. The agent stays alive until the user explicitly exits. Use for interactive assistants, research tools, and any agent where the user drives the conversation.
|
||||
|
||||
The deep_research_agent example demonstrates this: `report` loops back to either `research` (dig deeper) or `intake` (new topic). The agent is a persistent, interactive assistant.
|
||||
|
||||
### Continuous Conversation Mode
|
||||
|
||||
When `conversation_mode="continuous"` is set on the GraphSpec, the framework preserves a **single conversation thread** across all node transitions:
|
||||
|
||||
**What the framework does automatically:**
|
||||
- **Inherits conversation**: Same message history carries forward to the next node
|
||||
- **Composes layered system prompts**: Identity (agent-level) + Narrative (auto-generated state summary) + Focus (per-node instructions)
|
||||
- **Inserts transition markers**: At each node boundary, a "State of the World" message showing completed phases, current memory, and available data files
|
||||
- **Accumulates tools**: Once a tool becomes available, it stays available in subsequent nodes
|
||||
- **Compacts opportunistically**: At phase transitions, old tool results are pruned to stay within token budget
|
||||
|
||||
**What this means for agent builders:**
|
||||
- Nodes don't need to re-explain context — the conversation carries it forward
|
||||
- Output keys from earlier nodes are available in memory for edge conditions and later nodes
|
||||
- For forever-alive agents, conversation memory persists across the entire session lifetime
|
||||
- Plan for compaction: very long sessions will have older tool results summarized automatically
|
||||
|
||||
**When to use continuous mode:**
|
||||
- Interactive agents with client-facing nodes (always)
|
||||
- Multi-phase workflows where context matters across phases
|
||||
- Forever-alive agents that loop indefinitely
|
||||
|
||||
**When NOT to use continuous mode:**
|
||||
- Embarrassingly parallel fan-out nodes (each branch should be independent)
|
||||
- Stateless utility agents that process items independently
|
||||
|
||||
---
|
||||
|
||||
## REFERENCE: Framework Capabilities for Qualification
|
||||
|
||||
Use this reference during STEP 2 to give accurate, honest assessments.
|
||||
|
||||
### What the Framework Does Well (The Good)
|
||||
|
||||
| Capability | Description |
|
||||
|------------|-------------|
|
||||
| Multi-turn conversations | Client-facing nodes stream to users and block for input |
|
||||
| Human-in-the-loop review | Approval checkpoints with feedback loops back to earlier nodes |
|
||||
| Tool orchestration | LLM can call multiple tools, framework handles execution |
|
||||
| Structured outputs | `set_output` produces validated, typed outputs |
|
||||
| Parallel execution | Fan-out/fan-in for concurrent node execution |
|
||||
| Context management | Automatic compaction and spillover for large data |
|
||||
| Error recovery | Retry logic, judges, and feedback edges for self-correction |
|
||||
| Session persistence | State saved to disk, resumable sessions |
|
||||
|
||||
### Framework Limitations (The Bad)
|
||||
|
||||
| Limitation | Impact | Workaround |
|
||||
|------------|--------|------------|
|
||||
| LLM latency | 2-10+ seconds per turn | Not suitable for real-time/low-latency needs |
|
||||
| Context window limits | ~128K tokens max | Use data tools for spillover, design for chunking |
|
||||
| Cost per run | LLM API calls cost money | Budget planning, caching where possible |
|
||||
| Rate limits | API throttling on heavy usage | Backoff, queue management |
|
||||
| Node boundaries lose context | Outputs must be serialized | Prefer fewer, richer nodes |
|
||||
| Single-threaded within node | One LLM call at a time per node | Use fan-out for parallelism |
|
||||
|
||||
### Not Designed For (The Ugly)
|
||||
|
||||
| Use Case | Why It's Problematic | Alternative |
|
||||
|----------|---------------------|-------------|
|
||||
| Persistent background daemons (no user) | Forever-alive graphs need a user at client-facing nodes; no autonomous background polling without user | External scheduler triggering agent runs |
|
||||
| Sub-second responses | LLM latency is inherent | Traditional code, no LLM |
|
||||
| Processing millions of items | Context windows and rate limits | Batch processing + sampling |
|
||||
| Real-time streaming data | No built-in pub/sub or streaming input | Custom MCP server + agent |
|
||||
| Guaranteed determinism | LLM outputs vary | Traditional code for deterministic parts |
|
||||
| Offline/air-gapped | Requires LLM API access | Local models (not currently supported) |
|
||||
| Multi-user concurrency | Single-user session model | Separate agent instances per user |
|
||||
|
||||
### Tool Availability Reality Check
|
||||
|
||||
**Before promising any capability, check `list_mcp_tools()`.** Common gaps:
|
||||
|
||||
- **Email**: May not have `send_email` — check before promising email automation
|
||||
- **Calendar**: May not have calendar APIs — check before promising scheduling
|
||||
- **Database**: May not have SQL tools — check before promising data queries
|
||||
- **File system**: Has data tools but not arbitrary filesystem access
|
||||
- **External APIs**: Depends entirely on what MCP servers are registered
|
||||
|
||||
---
|
||||
|
||||
## COMMON MISTAKES TO AVOID
|
||||
|
||||
1. **Using tools that don't exist** - Always check `mcp__agent-builder__list_mcp_tools()` first
|
||||
2. **Wrong entry_points format** - Must be `{"start": "node-id"}`, NOT a set or list
|
||||
3. **Skipping validation** - Always validate nodes and graph before proceeding
|
||||
4. **Not waiting for approval** - Always ask user before major steps
|
||||
5. **Displaying this file** - Execute the steps, don't show documentation
|
||||
6. **Too many thin nodes** - Prefer fewer, richer nodes (4 nodes > 8 nodes)
|
||||
7. **Missing STEP 1/STEP 2 in client-facing prompts** - Client-facing nodes need explicit phases to prevent premature set_output
|
||||
8. **Forgetting nullable_output_keys** - Mark input_keys that only arrive on certain edges (e.g., feedback) as nullable on the receiving node
|
||||
9. **Adding framework gating for LLM behavior** - Fix prompts or use judges, not ad-hoc code
|
||||
10. **Writing code before user approves the graph** - Always get approval on goal, nodes, and graph BEFORE writing any agent code
|
||||
11. **Wrong mcp_servers.json format** - Use flat format (no `"mcpServers"` wrapper), and `cwd` must be `"../../tools"` not `"tools"`
|
||||
1. **Skipping use case qualification** - A responsible engineer qualifies the use case BEFORE building. Be transparent about what works, what doesn't, and what's problematic
|
||||
2. **Hiding limitations** - Don't oversell the framework. If a tool doesn't exist or a capability is missing, say so upfront
|
||||
3. **Using tools that don't exist** - Always check `mcp__agent-builder__list_mcp_tools()` first
|
||||
4. **Wrong entry_points format** - Must be `{"start": "node-id"}`, NOT a set or list
|
||||
5. **Skipping validation** - Always validate nodes and graph before proceeding
|
||||
6. **Not waiting for approval** - Always ask user before major steps
|
||||
7. **Displaying this file** - Execute the steps, don't show documentation
|
||||
8. **Too many thin nodes** - Prefer fewer, richer nodes (4 nodes > 8 nodes)
|
||||
9. **Missing STEP 1/STEP 2 in client-facing prompts** - Client-facing nodes need explicit phases to prevent premature set_output
|
||||
10. **Forgetting nullable_output_keys** - Mark input_keys that only arrive on certain edges (e.g., feedback) as nullable on the receiving node
|
||||
11. **Adding framework gating for LLM behavior** - Fix prompts or use judges, not ad-hoc code
|
||||
12. **Writing code before user approves the graph** - Always get approval on goal, nodes, and graph BEFORE writing any agent code
|
||||
13. **Wrong mcp_servers.json format** - Use flat format (no `"mcpServers"` wrapper), `cwd` must be `"../../tools"`, and `command` must be `"uv"` with args `["run", "python", ...]`
|
||||
14. **Assuming all agents need terminal nodes** - Interactive agents often work best with `terminal_nodes=[]` (forever-alive pattern). The agent never enters "completed" state — this is intentional. Only batch/one-shot agents need terminal nodes
|
||||
15. **Creating dead-end nodes in forever-alive graphs** - Every node must have at least one outgoing edge. A node with no outgoing edges will cause execution to end unexpectedly, breaking the forever-alive loop
|
||||
16. **Not using continuous conversation mode for interactive agents** - Multi-phase interactive agents should use `conversation_mode="continuous"` to preserve context across node transitions. Without it, each node starts with a blank conversation and loses all prior context
|
||||
|
||||
@@ -90,7 +90,7 @@ def tui(mock, verbose, debug):
|
||||
agent._event_bus = EventBus()
|
||||
agent._tool_registry = ToolRegistry()
|
||||
|
||||
storage_path = Path.home() / ".hive" / "deep_research_agent"
|
||||
storage_path = Path.home() / ".hive" / "agents" / "deep_research_agent"
|
||||
storage_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
mcp_config_path = Path(__file__).parent / "mcp_servers.json"
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""Agent graph construction for Deep Research Agent."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from framework.graph import EdgeSpec, EdgeCondition, Goal, SuccessCriterion, Constraint
|
||||
from framework.graph.edge import GraphSpec
|
||||
from framework.graph.executor import ExecutionResult, GraphExecutor
|
||||
from framework.runtime.event_bus import EventBus
|
||||
from framework.runtime.core import Runtime
|
||||
from framework.graph.executor import ExecutionResult
|
||||
from framework.graph.checkpoint_config import CheckpointConfig
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.runner.tool_registry import ToolRegistry
|
||||
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 (
|
||||
@@ -120,13 +123,31 @@ edges = [
|
||||
condition_expr="needs_more_research == False",
|
||||
priority=2,
|
||||
),
|
||||
# report -> research (user wants deeper research on current topic)
|
||||
EdgeSpec(
|
||||
id="report-to-research",
|
||||
source="report",
|
||||
target="research",
|
||||
condition=EdgeCondition.CONDITIONAL,
|
||||
condition_expr="str(next_action).lower() == 'more_research'",
|
||||
priority=2,
|
||||
),
|
||||
# report -> intake (user wants a new topic — default when not more_research)
|
||||
EdgeSpec(
|
||||
id="report-to-intake",
|
||||
source="report",
|
||||
target="intake",
|
||||
condition=EdgeCondition.CONDITIONAL,
|
||||
condition_expr="str(next_action).lower() != 'more_research'",
|
||||
priority=1,
|
||||
),
|
||||
]
|
||||
|
||||
# Graph configuration
|
||||
entry_node = "intake"
|
||||
entry_points = {"start": "intake"}
|
||||
pause_nodes = []
|
||||
terminal_nodes = ["report"]
|
||||
terminal_nodes = []
|
||||
|
||||
|
||||
class DeepResearchAgent:
|
||||
@@ -136,6 +157,12 @@ class DeepResearchAgent:
|
||||
Flow: intake -> research -> review -> report
|
||||
^ |
|
||||
+-- feedback loop (if user wants more)
|
||||
|
||||
Uses AgentRuntime for proper session management:
|
||||
- Session-scoped storage (sessions/{session_id}/)
|
||||
- Checkpointing for resume capability
|
||||
- Runtime logging
|
||||
- Data folder for save_data/load_data
|
||||
"""
|
||||
|
||||
def __init__(self, config=None):
|
||||
@@ -147,10 +174,10 @@ class DeepResearchAgent:
|
||||
self.entry_points = entry_points
|
||||
self.pause_nodes = pause_nodes
|
||||
self.terminal_nodes = terminal_nodes
|
||||
self._executor: GraphExecutor | None = None
|
||||
self._graph: GraphSpec | None = None
|
||||
self._event_bus: EventBus | None = None
|
||||
self._agent_runtime: AgentRuntime | None = None
|
||||
self._tool_registry: ToolRegistry | None = None
|
||||
self._storage_path: Path | None = None
|
||||
|
||||
def _build_graph(self) -> GraphSpec:
|
||||
"""Build the GraphSpec."""
|
||||
@@ -168,19 +195,23 @@ 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",
|
||||
identity_prompt=(
|
||||
"You are a rigorous research agent. You search for information "
|
||||
"from diverse, authoritative sources, analyze findings critically, "
|
||||
"and produce well-cited reports. You never fabricate information — "
|
||||
"every claim must trace back to a source you actually retrieved."
|
||||
),
|
||||
)
|
||||
|
||||
def _setup(self, mock_mode=False) -> GraphExecutor:
|
||||
"""Set up the executor with all components."""
|
||||
from pathlib import Path
|
||||
def _setup(self, mock_mode=False) -> None:
|
||||
"""Set up the agent runtime with sessions, checkpoints, and logging."""
|
||||
self._storage_path = Path.home() / ".hive" / "agents" / "deep_research_agent"
|
||||
self._storage_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
storage_path = Path.home() / ".hive" / "deep_research_agent"
|
||||
storage_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._event_bus = EventBus()
|
||||
self._tool_registry = ToolRegistry()
|
||||
|
||||
mcp_config_path = Path(__file__).parent / "mcp_servers.json"
|
||||
@@ -199,47 +230,63 @@ class DeepResearchAgent:
|
||||
tools = list(self._tool_registry.get_tools().values())
|
||||
|
||||
self._graph = self._build_graph()
|
||||
runtime = Runtime(storage_path)
|
||||
|
||||
self._executor = GraphExecutor(
|
||||
runtime=runtime,
|
||||
checkpoint_config = CheckpointConfig(
|
||||
enabled=True,
|
||||
checkpoint_on_node_start=False,
|
||||
checkpoint_on_node_complete=True,
|
||||
checkpoint_max_age_days=7,
|
||||
async_checkpoint=True,
|
||||
)
|
||||
|
||||
entry_point_specs = [
|
||||
EntryPointSpec(
|
||||
id="default",
|
||||
name="Default",
|
||||
entry_node=self.entry_node,
|
||||
trigger_type="manual",
|
||||
isolation_level="shared",
|
||||
)
|
||||
]
|
||||
|
||||
self._agent_runtime = create_agent_runtime(
|
||||
graph=self._graph,
|
||||
goal=self.goal,
|
||||
storage_path=self._storage_path,
|
||||
entry_points=entry_point_specs,
|
||||
llm=llm,
|
||||
tools=tools,
|
||||
tool_executor=tool_executor,
|
||||
event_bus=self._event_bus,
|
||||
storage_path=storage_path,
|
||||
loop_config=self._graph.loop_config,
|
||||
checkpoint_config=checkpoint_config,
|
||||
)
|
||||
|
||||
return self._executor
|
||||
|
||||
async def start(self, mock_mode=False) -> None:
|
||||
"""Set up the agent (initialize executor and tools)."""
|
||||
if self._executor is None:
|
||||
"""Set up and start the agent runtime."""
|
||||
if self._agent_runtime is None:
|
||||
self._setup(mock_mode=mock_mode)
|
||||
if not self._agent_runtime.is_running:
|
||||
await self._agent_runtime.start()
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Clean up resources."""
|
||||
self._executor = None
|
||||
self._event_bus = None
|
||||
"""Stop the agent runtime and clean up."""
|
||||
if self._agent_runtime and self._agent_runtime.is_running:
|
||||
await self._agent_runtime.stop()
|
||||
self._agent_runtime = None
|
||||
|
||||
async def trigger_and_wait(
|
||||
self,
|
||||
entry_point: str,
|
||||
input_data: dict,
|
||||
entry_point: str = "default",
|
||||
input_data: dict | None = None,
|
||||
timeout: float | None = None,
|
||||
session_state: dict | None = None,
|
||||
) -> ExecutionResult | None:
|
||||
"""Execute the graph and wait for completion."""
|
||||
if self._executor is None:
|
||||
if self._agent_runtime is None:
|
||||
raise RuntimeError("Agent not started. Call start() first.")
|
||||
if self._graph is None:
|
||||
raise RuntimeError("Graph not built. Call start() first.")
|
||||
|
||||
return await self._executor.execute(
|
||||
graph=self._graph,
|
||||
goal=self.goal,
|
||||
input_data=input_data,
|
||||
return await self._agent_runtime.trigger_and_wait(
|
||||
entry_point_id=entry_point,
|
||||
input_data=input_data or {},
|
||||
session_state=session_state,
|
||||
)
|
||||
|
||||
@@ -250,7 +297,7 @@ class DeepResearchAgent:
|
||||
await self.start(mock_mode=mock_mode)
|
||||
try:
|
||||
result = await self.trigger_and_wait(
|
||||
"start", context, session_state=session_state
|
||||
"default", context, session_state=session_state
|
||||
)
|
||||
return result or ExecutionResult(success=False, error="Execution timeout")
|
||||
finally:
|
||||
|
||||
@@ -1,33 +1,8 @@
|
||||
"""Runtime configuration."""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_preferred_model() -> str:
|
||||
"""Load preferred model from ~/.hive/configuration.json."""
|
||||
config_path = Path.home() / ".hive" / "configuration.json"
|
||||
if config_path.exists():
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
llm = config.get("llm", {})
|
||||
if llm.get("provider") and llm.get("model"):
|
||||
return f"{llm['provider']}/{llm['model']}"
|
||||
except Exception:
|
||||
pass
|
||||
return "anthropic/claude-sonnet-4-20250514"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeConfig:
|
||||
model: str = field(default_factory=_load_preferred_model)
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 40000
|
||||
api_key: str | None = None
|
||||
api_base: str | None = None
|
||||
from dataclasses import dataclass
|
||||
|
||||
from framework.config import RuntimeConfig
|
||||
|
||||
default_config = RuntimeConfig()
|
||||
|
||||
@@ -41,6 +16,11 @@ class AgentMetadata:
|
||||
"multi-source search, quality evaluation, and synthesis - with TUI conversation "
|
||||
"at key checkpoints for user guidance and feedback."
|
||||
)
|
||||
intro_message: str = (
|
||||
"Hi! I'm your deep research assistant. Tell me a topic and I'll investigate it "
|
||||
"thoroughly — searching multiple sources, evaluating quality, and synthesizing "
|
||||
"a comprehensive report. What would you like me to research?"
|
||||
)
|
||||
|
||||
|
||||
metadata = AgentMetadata()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"hive-tools": {
|
||||
"transport": "stdio",
|
||||
"command": "python",
|
||||
"args": ["mcp_server.py", "--stdio"],
|
||||
"command": "uv",
|
||||
"args": ["run", "python", "mcp_server.py", "--stdio"],
|
||||
"cwd": "../../tools",
|
||||
"description": "Hive tools MCP server providing web_search, web_scrape, and write_to_file"
|
||||
}
|
||||
|
||||
@@ -10,8 +10,13 @@ intake_node = NodeSpec(
|
||||
description="Discuss the research topic with the user, clarify scope, and confirm direction",
|
||||
node_type="event_loop",
|
||||
client_facing=True,
|
||||
max_node_visits=0,
|
||||
input_keys=["topic"],
|
||||
output_keys=["research_brief"],
|
||||
success_criteria=(
|
||||
"The research brief is specific and actionable: it states the topic, "
|
||||
"the key questions to answer, the desired scope, and depth."
|
||||
),
|
||||
system_prompt="""\
|
||||
You are a research intake specialist. The user wants to research a topic.
|
||||
Have a brief conversation to clarify what they need.
|
||||
@@ -38,10 +43,14 @@ research_node = NodeSpec(
|
||||
name="Research",
|
||||
description="Search the web, fetch source content, and compile findings",
|
||||
node_type="event_loop",
|
||||
max_node_visits=3,
|
||||
max_node_visits=0,
|
||||
input_keys=["research_brief", "feedback"],
|
||||
output_keys=["findings", "sources", "gaps"],
|
||||
nullable_output_keys=["feedback"],
|
||||
success_criteria=(
|
||||
"Findings reference at least 3 distinct sources with URLs. "
|
||||
"Key claims are substantiated by fetched content, not generated."
|
||||
),
|
||||
system_prompt="""\
|
||||
You are a research agent. Given a research brief, find and analyze sources.
|
||||
|
||||
@@ -56,18 +65,32 @@ Work in phases:
|
||||
and any contradictions between sources.
|
||||
|
||||
Important:
|
||||
- Work in batches of 3-4 tool calls at a time to manage context
|
||||
- Work in batches of 3-4 tool calls at a time — never more than 10 per turn
|
||||
- After each batch, assess whether you have enough material
|
||||
- Prefer quality over quantity — 5 good sources beat 15 thin ones
|
||||
- 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)
|
||||
|
||||
When done, use set_output:
|
||||
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.")
|
||||
- set_output("sources", [{"url": "...", "title": "...", "summary": "..."}])
|
||||
- set_output("gaps", "What aspects of the research brief are NOT well-covered yet, if any.")
|
||||
""",
|
||||
tools=["web_search", "web_scrape", "load_data", "save_data", "list_data_files"],
|
||||
tools=[
|
||||
"web_search",
|
||||
"web_scrape",
|
||||
"load_data",
|
||||
"save_data",
|
||||
"append_data",
|
||||
"list_data_files",
|
||||
],
|
||||
)
|
||||
|
||||
# Node 3: Review (client-facing)
|
||||
@@ -78,9 +101,13 @@ review_node = NodeSpec(
|
||||
description="Present findings to user and decide whether to research more or write the report",
|
||||
node_type="event_loop",
|
||||
client_facing=True,
|
||||
max_node_visits=3,
|
||||
max_node_visits=0,
|
||||
input_keys=["findings", "sources", "gaps", "research_brief"],
|
||||
output_keys=["needs_more_research", "feedback"],
|
||||
success_criteria=(
|
||||
"The user has been presented with findings and has explicitly indicated "
|
||||
"whether they want more research or are ready for the report."
|
||||
),
|
||||
system_prompt="""\
|
||||
Present the research findings to the user clearly and concisely.
|
||||
|
||||
@@ -109,49 +136,73 @@ report_node = NodeSpec(
|
||||
description="Write a cited HTML report from the findings and present it to the user",
|
||||
node_type="event_loop",
|
||||
client_facing=True,
|
||||
max_node_visits=0,
|
||||
input_keys=["findings", "sources", "research_brief"],
|
||||
output_keys=["delivery_status"],
|
||||
output_keys=["delivery_status", "next_action"],
|
||||
success_criteria=(
|
||||
"An HTML report has been saved, the file link has been presented to the user, "
|
||||
"and the user has indicated what they want to do next."
|
||||
),
|
||||
system_prompt="""\
|
||||
Write a comprehensive research report as an HTML file and present it to the user.
|
||||
Write a research report as an HTML file and present it to the user.
|
||||
|
||||
**STEP 1 — Write the HTML report (tool calls, NO text to user yet):**
|
||||
IMPORTANT: save_data requires TWO separate arguments: filename and data.
|
||||
Call it like: save_data(filename="report.html", data="<html>...</html>")
|
||||
Do NOT use _raw, do NOT nest arguments inside a JSON string.
|
||||
|
||||
1. Compose a complete, self-contained HTML document with embedded CSS styling.
|
||||
Use a clean, readable design: max-width container, pleasant typography,
|
||||
numbered citation links, a table of contents, and a references section.
|
||||
**STEP 1 — Write and save the HTML report (tool calls, NO text to user yet):**
|
||||
|
||||
Report structure inside the HTML:
|
||||
- Title & date
|
||||
- Executive Summary (2-3 paragraphs)
|
||||
- Table of Contents
|
||||
- Findings (organized by theme, with [n] citation links)
|
||||
- Analysis (synthesis, implications, areas of debate)
|
||||
- Conclusion (key takeaways, confidence assessment)
|
||||
- References (numbered list with clickable URLs)
|
||||
Build a clean HTML document. Keep the HTML concise — aim for clarity over length.
|
||||
Use minimal embedded CSS (a few lines of style, not a full framework).
|
||||
|
||||
Requirements:
|
||||
- Every factual claim must cite its source with [n] notation
|
||||
- Be objective — present multiple viewpoints where sources disagree
|
||||
- Distinguish well-supported conclusions from speculation
|
||||
- Answer the original research questions from the brief
|
||||
Report structure:
|
||||
- Title & date
|
||||
- Executive Summary (2-3 paragraphs)
|
||||
- Key Findings (organized by theme, with [n] citation links)
|
||||
- Analysis (synthesis, implications)
|
||||
- Conclusion (key takeaways)
|
||||
- References (numbered list with clickable URLs)
|
||||
|
||||
2. Save the HTML file:
|
||||
save_data(filename="report.html", data=<your_html>)
|
||||
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.
|
||||
|
||||
3. Get the clickable link:
|
||||
serve_file_to_user(filename="report.html", label="Research Report")
|
||||
Save the HTML:
|
||||
save_data(filename="report.html", data="<html>...</html>")
|
||||
|
||||
Then get the clickable link:
|
||||
serve_file_to_user(filename="report.html", label="Research Report")
|
||||
|
||||
If save_data fails, simplify and shorten the HTML, then retry.
|
||||
|
||||
**STEP 2 — Present the link to the user (text only, NO tool calls):**
|
||||
|
||||
Tell the user the report is ready and include the file:// URI from
|
||||
serve_file_to_user so they can click it to open. Give a brief summary
|
||||
of what the report covers. Ask if they have questions.
|
||||
of what the report covers. Ask if they have questions or want to continue.
|
||||
|
||||
**STEP 3 — After the user responds:**
|
||||
- Answer follow-up questions from the research material
|
||||
- When the user is satisfied: set_output("delivery_status", "completed")
|
||||
- Answer any follow-up questions from the research material
|
||||
- When the user is ready to move on, ask what they'd like to do next:
|
||||
- Research a new topic?
|
||||
- Dig deeper into the current topic?
|
||||
- Then call set_output:
|
||||
- set_output("delivery_status", "completed")
|
||||
- set_output("next_action", "new_topic") — if they want a new topic
|
||||
- set_output("next_action", "more_research") — if they want deeper research
|
||||
""",
|
||||
tools=["save_data", "serve_file_to_user", "load_data", "list_data_files"],
|
||||
tools=[
|
||||
"save_data",
|
||||
"append_data",
|
||||
"edit_data",
|
||||
"serve_file_to_user",
|
||||
"load_data",
|
||||
"list_data_files",
|
||||
],
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -141,6 +141,12 @@ for f in ~/.zshrc ~/.bashrc ~/.profile; do [ -f "$f" ] && grep -q 'HIVE_CREDENTI
|
||||
- **In shell config but NOT in current session** — run `source ~/.zshrc` (or `~/.bashrc`) first, then proceed
|
||||
- **Not set anywhere** — `EncryptedFileStorage` will auto-generate one. After storing, tell the user to persist it: `export HIVE_CREDENTIAL_KEY="{generated_key}"` in their shell profile
|
||||
|
||||
> **⚠️ IMPORTANT: After adding `HIVE_CREDENTIAL_KEY` to the user's shell config, always display:**
|
||||
> ```
|
||||
> ⚠️ Environment variables were added to your shell config.
|
||||
> Open a NEW TERMINAL for them to take effect outside this session.
|
||||
> ```
|
||||
|
||||
#### Option 1: Aden Platform (OAuth)
|
||||
|
||||
This is the recommended flow for supported integrations (HubSpot, etc.).
|
||||
@@ -202,6 +208,12 @@ if success:
|
||||
print(f"Run: {source_cmd}")
|
||||
```
|
||||
|
||||
> **⚠️ IMPORTANT: After adding `ADEN_API_KEY` to the user's shell config, always display:**
|
||||
> ```
|
||||
> ⚠️ Environment variables were added to your shell config.
|
||||
> Open a NEW TERMINAL for them to take effect outside this session.
|
||||
> ```
|
||||
|
||||
Also save to `~/.hive/configuration.json` for the framework:
|
||||
|
||||
```python
|
||||
@@ -460,9 +472,14 @@ result: HealthCheckResult = check_credential_health("hubspot", token_value)
|
||||
The local encrypted store requires `HIVE_CREDENTIAL_KEY` to encrypt/decrypt credentials.
|
||||
|
||||
- If the user doesn't have one, `EncryptedFileStorage` will auto-generate one and log it
|
||||
- The user MUST persist this key (e.g., in `~/.bashrc` or a secrets manager)
|
||||
- The user MUST persist this key (e.g., in `~/.bashrc`/`~/.zshrc` or a secrets manager)
|
||||
- Without this key, stored credentials cannot be decrypted
|
||||
- This is the ONLY secret that should live in `~/.bashrc` or environment config
|
||||
|
||||
**Shell config rule:** Only TWO keys belong in shell config (`~/.zshrc`/`~/.bashrc`):
|
||||
- `HIVE_CREDENTIAL_KEY` — encryption key for the credential store
|
||||
- `ADEN_API_KEY` — Aden platform auth key (needed before the store can sync)
|
||||
|
||||
All other API keys (Brave, Google, HubSpot, etc.) must go in the encrypted store only. **Never offer to add them to shell config.**
|
||||
|
||||
If `HIVE_CREDENTIAL_KEY` is not set:
|
||||
|
||||
@@ -475,6 +492,7 @@ If `HIVE_CREDENTIAL_KEY` is not set:
|
||||
- **NEVER** log, print, or echo credential values in tool output
|
||||
- **NEVER** store credentials in plaintext files, git-tracked files, or agent configs
|
||||
- **NEVER** hardcode credentials in source code
|
||||
- **NEVER** offer to save API keys to shell config (`~/.zshrc`/`~/.bashrc`) — the **only** keys that belong in shell config are `HIVE_CREDENTIAL_KEY` and `ADEN_API_KEY`. All other credentials (Brave, Google, HubSpot, GitHub, Resend, etc.) go in the encrypted store only.
|
||||
- **ALWAYS** use `SecretStr` from Pydantic when handling credential values in Python
|
||||
- **ALWAYS** use the local encrypted store (`~/.hive/credentials`) for persistence
|
||||
- **ALWAYS** run health checks before storing credentials (when possible)
|
||||
@@ -490,7 +508,7 @@ All credential specs are defined in `tools/src/aden_tools/credentials/`:
|
||||
| `llm.py` | LLM Providers | `anthropic` | No |
|
||||
| `search.py` | Search Tools | `brave_search`, `google_search`, `google_cse` | No |
|
||||
| `email.py` | Email | `resend` | No |
|
||||
| `integrations.py` | Integrations | `github`, `hubspot` | No / Yes |
|
||||
| `integrations.py` | Integrations | `github`, `hubspot`, `google_calendar_oauth` | No / Yes |
|
||||
|
||||
**Note:** Additional LLM providers (Cerebras, Groq, OpenAI) are handled by LiteLLM via environment
|
||||
variables (`CEREBRAS_API_KEY`, `GROQ_API_KEY`, `OPENAI_API_KEY`) but are not yet in CREDENTIAL_SPECS.
|
||||
@@ -596,5 +614,27 @@ All credentials are now configured:
|
||||
✓ brave_search (BRAVE_SEARCH_API_KEY) — already in encrypted store
|
||||
✓ google_search (GOOGLE_API_KEY) — stored in encrypted store
|
||||
✓ google_cse (GOOGLE_CSE_ID) — stored in encrypted store
|
||||
Your agent is ready to run!
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ✅ CREDENTIALS CONFIGURED │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ OPEN A NEW TERMINAL before running commands below. │
|
||||
│ Environment variables were saved to your shell config but │
|
||||
│ only take effect in new terminal sessions. │
|
||||
│ │
|
||||
│ NEXT STEPS: │
|
||||
│ │
|
||||
│ 1. RUN YOUR AGENT: │
|
||||
│ │
|
||||
│ hive tui │
|
||||
│ │
|
||||
│ 2. IF YOU ENCOUNTER ISSUES, USE THE DEBUGGER: │
|
||||
│ │
|
||||
│ /hive-debugger │
|
||||
│ │
|
||||
│ The debugger analyzes runtime logs, identifies retry loops, tool │
|
||||
│ failures, stalled execution, and provides actionable fix suggestions. │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+719
-973
File diff suppressed because it is too large
Load Diff
@@ -1,351 +1,333 @@
|
||||
# Example: Testing a YouTube Research Agent
|
||||
# Example: Iterative Testing of a Research Agent
|
||||
|
||||
This example walks through testing a YouTube research agent that finds relevant videos based on a topic.
|
||||
This example walks through the full iterative test loop for a research agent that searches the web, reviews findings, and produces a cited report.
|
||||
|
||||
## Prerequisites
|
||||
## Agent Structure
|
||||
|
||||
- Agent built with hive-create skill at `exports/youtube-research/`
|
||||
- Goal defined with success criteria and constraints
|
||||
|
||||
## Step 1: Load the Goal
|
||||
|
||||
First, load the goal that was defined during the Goal stage:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "youtube-research",
|
||||
"name": "YouTube Research Agent",
|
||||
"description": "Find relevant YouTube videos on a given topic",
|
||||
"success_criteria": [
|
||||
{
|
||||
"id": "find_videos",
|
||||
"description": "Find 3-5 relevant videos",
|
||||
"metric": "video_count",
|
||||
"target": "3-5",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"id": "relevance",
|
||||
"description": "Videos must be relevant to the topic",
|
||||
"metric": "relevance_score",
|
||||
"target": ">0.8",
|
||||
"weight": 0.8
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"id": "api_limits",
|
||||
"description": "Must not exceed YouTube API rate limits",
|
||||
"constraint_type": "hard",
|
||||
"category": "technical"
|
||||
},
|
||||
{
|
||||
"id": "content_safety",
|
||||
"description": "Must filter out inappropriate content",
|
||||
"constraint_type": "hard",
|
||||
"category": "safety"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
exports/deep_research_agent/
|
||||
├── agent.py # Goal + graph: intake → research → review → report
|
||||
├── nodes/__init__.py # Node definitions (system_prompt, input/output keys)
|
||||
├── config.py # Model config
|
||||
├── mcp_servers.json # Tools: web_search, web_scrape
|
||||
└── tests/ # Test files (we'll create these)
|
||||
```
|
||||
|
||||
## Step 2: Get Constraint Test Guidelines
|
||||
**Goal:** "Rigorous Interactive Research" — find 5+ diverse sources, cite every claim, produce a complete report.
|
||||
|
||||
During the Goal stage (or early Eval), get test guidelines for constraints:
|
||||
---
|
||||
|
||||
## Phase 1: Generate Tests
|
||||
|
||||
### Read the goal
|
||||
|
||||
```python
|
||||
result = generate_constraint_tests(
|
||||
goal_id="youtube-research",
|
||||
goal_json='<goal JSON above>',
|
||||
agent_path="exports/youtube-research"
|
||||
)
|
||||
Read(file_path="exports/deep_research_agent/agent.py")
|
||||
# Extract: goal_id="rigorous-interactive-research"
|
||||
# success_criteria: source-diversity (>=5), citation-coverage (100%), report-completeness (90%)
|
||||
# constraints: no-hallucination, source-attribution
|
||||
```
|
||||
|
||||
**The result contains guidelines (not generated tests):**
|
||||
- `output_file`: Where to write tests
|
||||
- `file_header`: Imports and fixtures to use
|
||||
- `test_template`: Format for test functions
|
||||
- `constraints_formatted`: The constraints to test
|
||||
- `test_guidelines`: Rules for writing tests
|
||||
|
||||
## Step 3: Write Constraint Tests
|
||||
|
||||
Using the guidelines, write tests directly with the Write tool:
|
||||
|
||||
```python
|
||||
# Write constraint tests using the provided file_header and guidelines
|
||||
Write(
|
||||
file_path="exports/youtube-research/tests/test_constraints.py",
|
||||
content='''
|
||||
"""Constraint tests for youtube-research agent."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from exports.youtube_research import default_agent
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.environ.get("ANTHROPIC_API_KEY") and not os.environ.get("MOCK_MODE"),
|
||||
reason="API key required for real testing."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_constraint_api_limits_respected():
|
||||
"""Verify API rate limits are not exceeded."""
|
||||
import time
|
||||
mock_mode = bool(os.environ.get("MOCK_MODE"))
|
||||
|
||||
for i in range(10):
|
||||
result = await default_agent.run({"topic": f"test_{i}"}, mock_mode=mock_mode)
|
||||
time.sleep(0.1)
|
||||
|
||||
# Should complete without rate limit errors
|
||||
assert "rate limit" not in str(result).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_constraint_content_safety_filter():
|
||||
"""Verify inappropriate content is filtered."""
|
||||
mock_mode = bool(os.environ.get("MOCK_MODE"))
|
||||
result = await default_agent.run({"topic": "general topic"}, mock_mode=mock_mode)
|
||||
|
||||
for video in result.videos:
|
||||
assert video.safe_for_work is True
|
||||
assert video.age_restricted is False
|
||||
'''
|
||||
)
|
||||
```
|
||||
|
||||
## Step 4: Get Success Criteria Test Guidelines
|
||||
|
||||
After the agent is built, get success criteria test guidelines:
|
||||
### Get test guidelines
|
||||
|
||||
```python
|
||||
result = generate_success_tests(
|
||||
goal_id="youtube-research",
|
||||
goal_json='<goal JSON>',
|
||||
node_names="search_node,filter_node,rank_node,format_node",
|
||||
tool_names="youtube_search,video_details,channel_info",
|
||||
agent_path="exports/youtube-research"
|
||||
goal_id="rigorous-interactive-research",
|
||||
goal_json='{"id": "rigorous-interactive-research", "success_criteria": [{"id": "source-diversity", "description": "Use multiple diverse sources", "target": ">=5"}, {"id": "citation-coverage", "description": "Every claim cites its source", "target": "100%"}, {"id": "report-completeness", "description": "Report answers the research questions", "target": "90%"}]}',
|
||||
node_names="intake,research,review,report",
|
||||
tool_names="web_search,web_scrape",
|
||||
agent_path="exports/deep_research_agent"
|
||||
)
|
||||
```
|
||||
|
||||
## Step 5: Write Success Criteria Tests
|
||||
|
||||
Using the guidelines, write success criteria tests:
|
||||
### Write tests
|
||||
|
||||
```python
|
||||
Write(
|
||||
file_path="exports/youtube-research/tests/test_success_criteria.py",
|
||||
content='''
|
||||
"""Success criteria tests for youtube-research agent."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from exports.youtube_research import default_agent
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.environ.get("ANTHROPIC_API_KEY") and not os.environ.get("MOCK_MODE"),
|
||||
reason="API key required for real testing."
|
||||
)
|
||||
|
||||
file_path="exports/deep_research_agent/tests/test_success_criteria.py",
|
||||
content=result["file_header"] + '''
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_videos_happy_path():
|
||||
"""Test finding videos for a common topic."""
|
||||
mock_mode = bool(os.environ.get("MOCK_MODE"))
|
||||
result = await default_agent.run({"topic": "machine learning"}, mock_mode=mock_mode)
|
||||
|
||||
assert result.success
|
||||
assert 3 <= len(result.videos) <= 5
|
||||
assert all(v.title for v in result.videos)
|
||||
assert all(v.video_id for v in result.videos)
|
||||
|
||||
async def test_success_source_diversity(runner, auto_responder, mock_mode):
|
||||
"""At least 5 diverse sources are found."""
|
||||
await auto_responder.start()
|
||||
try:
|
||||
result = await runner.run({"query": "impact of remote work on productivity"})
|
||||
finally:
|
||||
await auto_responder.stop()
|
||||
assert result.success, f"Agent failed: {result.error}"
|
||||
output = result.output or {}
|
||||
sources = output.get("sources", [])
|
||||
if isinstance(sources, list):
|
||||
assert len(sources) >= 5, f"Expected >= 5 sources, got {len(sources)}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_videos_minimum_boundary():
|
||||
"""Test at minimum threshold (3 videos)."""
|
||||
mock_mode = bool(os.environ.get("MOCK_MODE"))
|
||||
result = await default_agent.run({"topic": "niche topic xyz"}, mock_mode=mock_mode)
|
||||
|
||||
assert len(result.videos) >= 3
|
||||
|
||||
async def test_success_citation_coverage(runner, auto_responder, mock_mode):
|
||||
"""Every factual claim in the report cites its source."""
|
||||
await auto_responder.start()
|
||||
try:
|
||||
result = await runner.run({"query": "climate change effects on agriculture"})
|
||||
finally:
|
||||
await auto_responder.stop()
|
||||
assert result.success, f"Agent failed: {result.error}"
|
||||
output = result.output or {}
|
||||
report = output.get("report", "")
|
||||
# Check that report contains numbered references
|
||||
assert "[1]" in str(report) or "[source" in str(report).lower(), "Report lacks citations"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relevance_score_threshold():
|
||||
"""Test relevance scoring meets threshold."""
|
||||
mock_mode = bool(os.environ.get("MOCK_MODE"))
|
||||
result = await default_agent.run({"topic": "python programming"}, mock_mode=mock_mode)
|
||||
|
||||
for video in result.videos:
|
||||
assert video.relevance_score > 0.8
|
||||
|
||||
async def test_success_report_completeness(runner, auto_responder, mock_mode):
|
||||
"""Report addresses the original research question."""
|
||||
query = "pros and cons of nuclear energy"
|
||||
await auto_responder.start()
|
||||
try:
|
||||
result = await runner.run({"query": query})
|
||||
finally:
|
||||
await auto_responder.stop()
|
||||
assert result.success, f"Agent failed: {result.error}"
|
||||
output = result.output or {}
|
||||
report = output.get("report", "")
|
||||
assert len(str(report)) > 200, f"Report too short: {len(str(report))} chars"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_videos_no_results_graceful():
|
||||
"""Test graceful handling of no results."""
|
||||
mock_mode = bool(os.environ.get("MOCK_MODE"))
|
||||
result = await default_agent.run({"topic": "xyznonexistent123"}, mock_mode=mock_mode)
|
||||
async def test_empty_query_handling(runner, auto_responder, mock_mode):
|
||||
"""Agent handles empty input gracefully."""
|
||||
await auto_responder.start()
|
||||
try:
|
||||
result = await runner.run({"query": ""})
|
||||
finally:
|
||||
await auto_responder.stop()
|
||||
output = result.output or {}
|
||||
assert not result.success or output.get("error"), "Should handle empty query"
|
||||
|
||||
# Should not crash, return empty or message
|
||||
assert result.videos == [] or result.message
|
||||
@pytest.mark.asyncio
|
||||
async def test_feedback_loop_terminates(runner, auto_responder, mock_mode):
|
||||
"""Feedback loop between review and research terminates."""
|
||||
await auto_responder.start()
|
||||
try:
|
||||
result = await runner.run({"query": "quantum computing basics"})
|
||||
finally:
|
||||
await auto_responder.stop()
|
||||
visits = result.node_visit_counts or {}
|
||||
for node_id, count in visits.items():
|
||||
assert count <= 5, f"Node {node_id} visited {count} times"
|
||||
'''
|
||||
)
|
||||
```
|
||||
|
||||
## Step 6: Run All Tests
|
||||
---
|
||||
|
||||
Execute all tests:
|
||||
## Phase 2: First Execution
|
||||
|
||||
```python
|
||||
result = run_tests(
|
||||
goal_id="youtube-research",
|
||||
agent_path="exports/youtube-research",
|
||||
test_types='["all"]',
|
||||
parallel=4
|
||||
run_tests(
|
||||
goal_id="rigorous-interactive-research",
|
||||
agent_path="exports/deep_research_agent",
|
||||
fail_fast=True
|
||||
)
|
||||
```
|
||||
|
||||
**Results:**
|
||||
|
||||
**Result:**
|
||||
```json
|
||||
{
|
||||
"goal_id": "youtube-research",
|
||||
"overall_passed": false,
|
||||
"summary": {
|
||||
"total": 6,
|
||||
"passed": 5,
|
||||
"failed": 1,
|
||||
"pass_rate": "83.3%"
|
||||
},
|
||||
"duration_ms": 4521,
|
||||
"results": [
|
||||
{"test_id": "test_constraint_api_001", "passed": true, "duration_ms": 1234},
|
||||
{"test_id": "test_constraint_content_001", "passed": true, "duration_ms": 456},
|
||||
{"test_id": "test_success_001", "passed": true, "duration_ms": 789},
|
||||
{"test_id": "test_success_002", "passed": true, "duration_ms": 654},
|
||||
{"test_id": "test_success_003", "passed": true, "duration_ms": 543},
|
||||
{"test_id": "test_success_004", "passed": false, "duration_ms": 845,
|
||||
"error_category": "IMPLEMENTATION_ERROR",
|
||||
"error_message": "TypeError: 'NoneType' object has no attribute 'videos'"}
|
||||
]
|
||||
"overall_passed": false,
|
||||
"summary": {"total": 5, "passed": 3, "failed": 2, "pass_rate": "60.0%"},
|
||||
"failures": [
|
||||
{"test_name": "test_success_source_diversity", "details": "AssertionError: Expected >= 5 sources, got 2"},
|
||||
{"test_name": "test_success_citation_coverage", "details": "AssertionError: Report lacks citations"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Step 7: Debug the Failed Test
|
||||
---
|
||||
|
||||
## Phase 3: Analyze (Iteration 1)
|
||||
|
||||
### Debug the first failure
|
||||
|
||||
```python
|
||||
result = debug_test(
|
||||
goal_id="youtube-research",
|
||||
test_name="test_find_videos_no_results_graceful",
|
||||
agent_path="exports/youtube-research"
|
||||
debug_test(
|
||||
goal_id="rigorous-interactive-research",
|
||||
test_name="test_success_source_diversity",
|
||||
agent_path="exports/deep_research_agent"
|
||||
)
|
||||
# Category: ASSERTION_FAILURE — Expected >= 5 sources, got 2
|
||||
```
|
||||
|
||||
### Find the session and inspect memory
|
||||
|
||||
```python
|
||||
list_agent_sessions(
|
||||
agent_work_dir="~/.hive/agents/deep_research_agent",
|
||||
status="completed",
|
||||
limit=1
|
||||
)
|
||||
# → session_20260209_150000_abc12345
|
||||
|
||||
get_agent_session_memory(
|
||||
agent_work_dir="~/.hive/agents/deep_research_agent",
|
||||
session_id="session_20260209_150000_abc12345",
|
||||
key="research_results"
|
||||
)
|
||||
# → Only 2 sources found. LLM stopped searching after 2 queries.
|
||||
```
|
||||
|
||||
### Check LLM behavior in the research node
|
||||
|
||||
```python
|
||||
query_runtime_log_raw(
|
||||
agent_work_dir="~/.hive/agents/deep_research_agent",
|
||||
run_id="session_20260209_150000_abc12345",
|
||||
node_id="research"
|
||||
)
|
||||
# → LLM called web_search twice, got results, immediately called set_output.
|
||||
# → Prompt doesn't instruct it to find at least 5 sources.
|
||||
```
|
||||
|
||||
**Root cause:** The research node's system_prompt doesn't specify minimum source requirements.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Fix (Iteration 1)
|
||||
|
||||
```python
|
||||
Read(file_path="exports/deep_research_agent/nodes/__init__.py")
|
||||
|
||||
# Fix the research node prompt
|
||||
Edit(
|
||||
file_path="exports/deep_research_agent/nodes/__init__.py",
|
||||
old_string='system_prompt="Search for information on the user\'s topic using web search."',
|
||||
new_string='system_prompt="Search for information on the user\'s topic using web search. You MUST find at least 5 diverse, authoritative sources. Use multiple different search queries with varied keywords. Do NOT call set_output until you have gathered at least 5 distinct sources from different domains."'
|
||||
)
|
||||
```
|
||||
|
||||
**Debug Output:**
|
||||
---
|
||||
|
||||
## Phase 5: Recover & Resume (Iteration 1)
|
||||
|
||||
The fix is to the `research` node. Since this was a `run_tests` execution (no checkpoints), we re-run from scratch:
|
||||
|
||||
```python
|
||||
run_tests(
|
||||
goal_id="rigorous-interactive-research",
|
||||
agent_path="exports/deep_research_agent",
|
||||
fail_fast=True
|
||||
)
|
||||
```
|
||||
|
||||
**Result:**
|
||||
```json
|
||||
{
|
||||
"test_id": "test_success_004",
|
||||
"test_name": "test_find_videos_no_results_graceful",
|
||||
"input": {"topic": "xyznonexistent123"},
|
||||
"expected": "Empty list or message",
|
||||
"actual": {"error": "TypeError: 'NoneType' object has no attribute 'videos'"},
|
||||
"passed": false,
|
||||
"error_message": "TypeError: 'NoneType' object has no attribute 'videos'",
|
||||
"error_category": "IMPLEMENTATION_ERROR",
|
||||
"stack_trace": "Traceback (most recent call last):\n File \"filter_node.py\", line 42\n for video in result.videos:\nTypeError: 'NoneType' object has no attribute 'videos'",
|
||||
"logs": [
|
||||
{"timestamp": "2026-01-20T10:00:01", "node": "search_node", "level": "INFO", "msg": "Searching for: xyznonexistent123"},
|
||||
{"timestamp": "2026-01-20T10:00:02", "node": "search_node", "level": "WARNING", "msg": "No results found"},
|
||||
{"timestamp": "2026-01-20T10:00:02", "node": "filter_node", "level": "ERROR", "msg": "NoneType error"}
|
||||
],
|
||||
"runtime_data": {
|
||||
"execution_path": ["start", "search_node", "filter_node"],
|
||||
"node_outputs": {
|
||||
"search_node": null
|
||||
}
|
||||
},
|
||||
"suggested_fix": "Add null check in filter_node before accessing .videos attribute",
|
||||
"iteration_guidance": {
|
||||
"stage": "Agent",
|
||||
"action": "Fix the code in nodes/edges",
|
||||
"restart_required": false,
|
||||
"description": "The goal is correct, but filter_node doesn't handle null results from search_node."
|
||||
}
|
||||
"overall_passed": false,
|
||||
"summary": {"total": 5, "passed": 4, "failed": 1, "pass_rate": "80.0%"},
|
||||
"failures": [
|
||||
{"test_name": "test_success_citation_coverage", "details": "AssertionError: Report lacks citations"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Step 8: Iterate Based on Category
|
||||
Source diversity now passes. Citation coverage still fails.
|
||||
|
||||
Since this is an **IMPLEMENTATION_ERROR**, we:
|
||||
---
|
||||
|
||||
1. **Don't restart** the Goal → Agent → Eval flow
|
||||
2. **Fix the agent** using hive-create skill:
|
||||
- Modify `filter_node` to handle null results
|
||||
3. **Re-run Eval** (tests only)
|
||||
|
||||
### Fix in hive-create:
|
||||
## Phase 3: Analyze (Iteration 2)
|
||||
|
||||
```python
|
||||
# Update the filter_node to handle null
|
||||
add_node(
|
||||
node_id="filter_node",
|
||||
name="Filter Node",
|
||||
description="Filter and rank videos",
|
||||
node_type="function",
|
||||
input_keys=["search_results"],
|
||||
output_keys=["filtered_videos"],
|
||||
system_prompt="""
|
||||
Filter videos by relevance.
|
||||
IMPORTANT: Handle case where search_results is None or empty.
|
||||
Return empty list if no results.
|
||||
"""
|
||||
debug_test(
|
||||
goal_id="rigorous-interactive-research",
|
||||
test_name="test_success_citation_coverage",
|
||||
agent_path="exports/deep_research_agent"
|
||||
)
|
||||
# Category: ASSERTION_FAILURE — Report lacks citations
|
||||
|
||||
# Check what the report node produced
|
||||
list_agent_sessions(
|
||||
agent_work_dir="~/.hive/agents/deep_research_agent",
|
||||
status="completed",
|
||||
limit=1
|
||||
)
|
||||
# → session_20260209_151500_def67890
|
||||
|
||||
get_agent_session_memory(
|
||||
agent_work_dir="~/.hive/agents/deep_research_agent",
|
||||
session_id="session_20260209_151500_def67890",
|
||||
key="report"
|
||||
)
|
||||
# → Report text exists but uses no numbered references.
|
||||
# → Sources are in memory but report node doesn't cite them.
|
||||
```
|
||||
|
||||
**Root cause:** The report node's prompt doesn't instruct the LLM to include numbered citations.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Fix (Iteration 2)
|
||||
|
||||
```python
|
||||
Edit(
|
||||
file_path="exports/deep_research_agent/nodes/__init__.py",
|
||||
old_string='system_prompt="Write a comprehensive report based on the research findings."',
|
||||
new_string='system_prompt="Write a comprehensive report based on the research findings. You MUST include numbered citations [1], [2], etc. for every factual claim. At the end, include a References section listing all sources with their URLs. Every claim must be traceable to a specific source."'
|
||||
)
|
||||
```
|
||||
|
||||
### Re-export and re-test:
|
||||
---
|
||||
|
||||
## Phase 5: Resume (Iteration 2)
|
||||
|
||||
The fix is to the `report` node (the last node). To demonstrate checkpoint recovery, run via CLI:
|
||||
|
||||
```bash
|
||||
# Run via CLI to get checkpoints
|
||||
uv run hive run exports/deep_research_agent --input '{"topic": "climate change effects"}'
|
||||
|
||||
# After it runs, find the clean checkpoint before report
|
||||
list_agent_checkpoints(
|
||||
agent_work_dir="~/.hive/agents/deep_research_agent",
|
||||
session_id="session_20260209_152000_ghi34567",
|
||||
is_clean="true"
|
||||
)
|
||||
# → cp_node_complete_review_152100 (after review, before report)
|
||||
|
||||
# Resume — skips intake, research, review entirely
|
||||
uv run hive run exports/deep_research_agent \
|
||||
--resume-session session_20260209_152000_ghi34567 \
|
||||
--checkpoint cp_node_complete_review_152100
|
||||
```
|
||||
|
||||
Only the `report` node re-runs with the fixed prompt, using research data from the checkpoint.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Final Verification
|
||||
|
||||
```python
|
||||
# Re-export the fixed agent
|
||||
export_graph(path="exports/youtube-research")
|
||||
|
||||
# Re-run tests
|
||||
result = run_tests(
|
||||
goal_id="youtube-research",
|
||||
agent_path="exports/youtube-research",
|
||||
test_types='["all"]'
|
||||
run_tests(
|
||||
goal_id="rigorous-interactive-research",
|
||||
agent_path="exports/deep_research_agent"
|
||||
)
|
||||
```
|
||||
|
||||
**Updated Results:**
|
||||
|
||||
**Result:**
|
||||
```json
|
||||
{
|
||||
"goal_id": "youtube-research",
|
||||
"overall_passed": true,
|
||||
"summary": {
|
||||
"total": 6,
|
||||
"passed": 6,
|
||||
"failed": 0,
|
||||
"pass_rate": "100.0%"
|
||||
}
|
||||
"overall_passed": true,
|
||||
"summary": {"total": 5, "passed": 5, "failed": 0, "pass_rate": "100.0%"}
|
||||
}
|
||||
```
|
||||
|
||||
All tests pass.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
1. **Got guidelines** for constraint tests during Goal stage
|
||||
2. **Wrote** constraint tests using Write tool
|
||||
3. **Got guidelines** for success criteria tests during Eval stage
|
||||
4. **Wrote** success criteria tests using Write tool
|
||||
5. **Ran** tests in parallel
|
||||
6. **Debugged** the one failure
|
||||
7. **Categorized** as IMPLEMENTATION_ERROR
|
||||
8. **Fixed** the agent (not the goal)
|
||||
9. **Re-ran** Eval only (didn't restart full flow)
|
||||
10. **Passed** all tests
|
||||
| Iteration | Failure | Root Cause | Fix | Recovery |
|
||||
|-----------|---------|------------|-----|----------|
|
||||
| 1 | Source diversity (2 < 5) | Research prompt too vague | Added "at least 5 sources" to prompt | Re-run (no checkpoints) |
|
||||
| 2 | No citations in report | Report prompt lacks citation instructions | Added citation requirements | Checkpoint resume (skipped 3 nodes) |
|
||||
|
||||
The agent is now validated and ready for production use.
|
||||
**Key takeaways:**
|
||||
- Phase 3 analysis (session memory + L3 logs) identified root causes without guessing
|
||||
- Checkpoint recovery in iteration 2 saved time by skipping 3 expensive nodes
|
||||
- Final `run_tests` confirms all scenarios pass end-to-end
|
||||
|
||||
@@ -12,19 +12,25 @@ metadata:
|
||||
- hive-patterns
|
||||
- hive-test
|
||||
- hive-credentials
|
||||
- hive-debugger
|
||||
---
|
||||
|
||||
# Agent Development Workflow
|
||||
|
||||
**THIS IS AN EXECUTABLE WORKFLOW. DO NOT explore the codebase or read source files. ROUTE to the correct skill IMMEDIATELY.**
|
||||
|
||||
When this skill is loaded, determine what the user needs and invoke the appropriate skill NOW:
|
||||
- **User wants to build an agent** → Invoke `/hive-create` immediately
|
||||
- **User wants to test an agent** → Invoke `/hive-test` immediately
|
||||
- **User wants to learn concepts** → Invoke `/hive-concepts` immediately
|
||||
- **User wants patterns/optimization** → Invoke `/hive-patterns` immediately
|
||||
- **User wants to set up credentials** → Invoke `/hive-credentials` immediately
|
||||
- **Unclear what user needs** → Ask the user (do NOT explore the codebase to figure it out)
|
||||
When this skill is loaded, **ALWAYS use the AskUserQuestion tool** to present options:
|
||||
|
||||
```
|
||||
Use AskUserQuestion with these options:
|
||||
- "Build a new agent" → Then invoke /hive-create
|
||||
- "Test an existing agent" → Then invoke /hive-test
|
||||
- "Learn agent concepts" → Then invoke /hive-concepts
|
||||
- "Optimize agent design" → Then invoke /hive-patterns
|
||||
- "Set up credentials" → Then invoke /hive-credentials
|
||||
- "Debug a failing agent" → Then invoke /hive-debugger
|
||||
- "Other" (please describe what you want to achieve)
|
||||
```
|
||||
|
||||
**DO NOT:** Read source files, explore the codebase, search for code, or do any investigation before routing. The sub-skills handle all of that.
|
||||
|
||||
@@ -41,6 +47,7 @@ This workflow orchestrates specialized skills to take you from initial concept t
|
||||
3. **Optimize Design** → `/hive-patterns` (optional)
|
||||
4. **Setup Credentials** → `/hive-credentials` (if agent uses tools requiring API keys)
|
||||
5. **Test & Validate** → `/hive-test`
|
||||
6. **Debug Issues** → `/hive-debugger` (if agent fails at runtime)
|
||||
|
||||
## When to Use This Workflow
|
||||
|
||||
@@ -63,13 +70,13 @@ Use this meta-skill when:
|
||||
"Need client-facing nodes or feedback loops" → hive-patterns
|
||||
"Set up API keys for my agent" → hive-credentials
|
||||
"Test my agent" → hive-test
|
||||
"My agent is failing/stuck/has errors" → hive-debugger
|
||||
"Not sure what I need" → Read phases below, then decide
|
||||
"Agent has structure but needs implementation" → See agent directory STATUS.md
|
||||
```
|
||||
|
||||
## Phase 0: Understand Concepts (Optional)
|
||||
|
||||
**Duration**: 5-10 minutes
|
||||
**Skill**: `/hive-concepts`
|
||||
**Input**: Questions about agent architecture
|
||||
|
||||
@@ -91,9 +98,8 @@ Use this meta-skill when:
|
||||
|
||||
## Phase 1: Build Agent Structure
|
||||
|
||||
**Duration**: 15-30 minutes
|
||||
**Skill**: `/hive-create`
|
||||
**Input**: User requirements ("Build an agent that...")
|
||||
**Input**: User requirements ("Build an agent that...") or a template to start from
|
||||
|
||||
### What This Phase Does
|
||||
|
||||
@@ -162,7 +168,6 @@ exports/agent_name/
|
||||
|
||||
## Phase 1.5: Optimize Design (Optional)
|
||||
|
||||
**Duration**: 10-15 minutes
|
||||
**Skill**: `/hive-patterns`
|
||||
**Input**: Completed agent structure
|
||||
|
||||
@@ -187,22 +192,21 @@ exports/agent_name/
|
||||
|
||||
## Phase 2: Test & Validate
|
||||
|
||||
**Duration**: 20-40 minutes
|
||||
**Skill**: `/hive-test`
|
||||
**Input**: Working agent from Phase 1
|
||||
|
||||
### What This Phase Does
|
||||
|
||||
Creates comprehensive test suite:
|
||||
- Constraint tests (verify hard requirements)
|
||||
- Success criteria tests (measure goal achievement)
|
||||
- Edge case tests (handle failures gracefully)
|
||||
- Integration tests (end-to-end workflows)
|
||||
Guides the creation and execution of a comprehensive test suite:
|
||||
- Constraint tests
|
||||
- Success criteria tests
|
||||
- Edge case tests
|
||||
- Integration tests
|
||||
|
||||
### Process
|
||||
|
||||
1. **Analyze agent** - Read goal, constraints, success criteria
|
||||
2. **Generate tests** - Create pytest files in `exports/agent_name/tests/`
|
||||
2. **Generate tests** - The calling agent writes pytest files in `exports/agent_name/tests/` using hive-test guidelines and templates
|
||||
3. **User approval** - Review and approve each test
|
||||
4. **Run evaluation** - Execute tests and collect results
|
||||
5. **Debug failures** - Identify and fix issues
|
||||
@@ -283,6 +287,19 @@ User: "Build an agent (first time)"
|
||||
→ Done: Production-ready agent
|
||||
```
|
||||
|
||||
### Pattern 1c: Build from Template
|
||||
|
||||
```
|
||||
User: "Build an agent based on the deep research template"
|
||||
→ Use /hive-create
|
||||
→ Select "From a template" path
|
||||
→ Pick template, name new agent
|
||||
→ Review/modify goal, nodes, graph
|
||||
→ Agent exported with customizations
|
||||
→ Use /hive-test
|
||||
→ Done: Customized agent
|
||||
```
|
||||
|
||||
### Pattern 2: Test Existing Agent
|
||||
|
||||
```
|
||||
@@ -345,11 +362,23 @@ hive (meta-skill)
|
||||
│ ├── Fan-out/fan-in parallel execution
|
||||
│ └── Context management and anti-patterns
|
||||
│
|
||||
└── hive-test
|
||||
├── Reads agent goal
|
||||
├── Generates tests
|
||||
├── Runs evaluation
|
||||
└── Reports results
|
||||
├── hive-credentials (utility)
|
||||
│ ├── Detects missing credentials
|
||||
│ ├── Offers auth method choices (Aden OAuth, direct API key)
|
||||
│ ├── Stores securely in ~/.hive/credentials
|
||||
│ └── Validates with health checks
|
||||
│
|
||||
├── hive-test (validation)
|
||||
│ ├── Reads agent goal
|
||||
│ ├── Generates tests
|
||||
│ ├── Runs evaluation
|
||||
│ └── Reports results
|
||||
│
|
||||
└── hive-debugger (troubleshooting)
|
||||
├── Monitors runtime logs (L1/L2/L3)
|
||||
├── Identifies retry loops, tool failures
|
||||
├── Categorizes issues (10 categories)
|
||||
└── Provides fix recommendations
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -376,6 +405,13 @@ hive (meta-skill)
|
||||
- Use `/hive-test` to debug and iterate
|
||||
- Fix agent code and re-run tests
|
||||
|
||||
### "Agent is failing at runtime"
|
||||
|
||||
- Use `/hive-debugger` to analyze runtime logs
|
||||
- The debugger identifies retry loops, tool failures, and stalled execution
|
||||
- Get actionable fix recommendations with code changes
|
||||
- Monitor the agent in real-time during TUI sessions
|
||||
|
||||
### "Not sure which phase I'm in"
|
||||
|
||||
Run these checks:
|
||||
@@ -448,7 +484,9 @@ This workflow provides a proven path from concept to production-ready agent:
|
||||
1. **Learn** with `/hive-concepts` → Understand fundamentals (optional)
|
||||
2. **Build** with `/hive-create` → Get validated structure
|
||||
3. **Optimize** with `/hive-patterns` → Apply best practices (optional)
|
||||
4. **Test** with `/hive-test` → Get verified functionality
|
||||
4. **Configure** with `/hive-credentials` → Set up API keys (if needed)
|
||||
5. **Test** with `/hive-test` → Get verified functionality
|
||||
6. **Debug** with `/hive-debugger` → Fix runtime issues (if needed)
|
||||
|
||||
The workflow is **flexible** - skip phases as needed, iterate freely, and adapt to your specific requirements. The goal is **production-ready agents** built with **consistent, repeatable processes**.
|
||||
|
||||
@@ -465,6 +503,7 @@ The workflow is **flexible** - skip phases as needed, iterate freely, and adapt
|
||||
- Have clear requirements
|
||||
- Ready to write code
|
||||
- Want step-by-step guidance
|
||||
- Want to start from an existing template and customize it
|
||||
|
||||
**Choose hive-patterns when:**
|
||||
- Agent structure complete
|
||||
@@ -478,3 +517,10 @@ The workflow is **flexible** - skip phases as needed, iterate freely, and adapt
|
||||
- Ready to validate functionality
|
||||
- Need comprehensive test coverage
|
||||
- Testing feedback loops, output keys, or fan-out
|
||||
|
||||
**Choose hive-debugger when:**
|
||||
- Agent is failing or stuck at runtime
|
||||
- Seeing retry loops or escalations
|
||||
- Tool calls are failing
|
||||
- Need to understand why a node isn't completing
|
||||
- Want real-time monitoring of agent execution
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Project-level Codex config for Hive.
|
||||
# Keep this file minimal: MCP connectivity + skill discovery.
|
||||
|
||||
[mcp_servers.agent-builder]
|
||||
command = "uv"
|
||||
args = ["run", "--directory", "core", "-m", "framework.mcp.agent_builder_server"]
|
||||
cwd = "."
|
||||
@@ -46,6 +46,7 @@ coverage/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
vite.config.d.ts
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
@@ -69,8 +70,13 @@ exports/*
|
||||
.agent-builder-sessions/*
|
||||
|
||||
.claude/settings.local.json
|
||||
.claude/skills/ship-it/
|
||||
|
||||
.venv
|
||||
|
||||
docs/github-issues/*
|
||||
core/tests/*dumps/*
|
||||
|
||||
screenshots/*
|
||||
|
||||
.gemini/*
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
"command": "uv",
|
||||
"args": ["run", "-m", "framework.mcp.agent_builder_server"],
|
||||
"cwd": "core"
|
||||
},
|
||||
"tools": {
|
||||
"command": "uv",
|
||||
"args": ["run", "mcp_server.py", "--stdio"],
|
||||
"cwd": "tools"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"agent-builder": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"python",
|
||||
"-m",
|
||||
"framework.mcp.agent_builder_server"
|
||||
],
|
||||
"cwd": "core",
|
||||
"env": {
|
||||
"PYTHONPATH": "../tools/src"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"python",
|
||||
"mcp_server.py",
|
||||
"--stdio"
|
||||
],
|
||||
"cwd": "tools",
|
||||
"env": {
|
||||
"PYTHONPATH": "src"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/hive
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/hive-concepts
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/hive-create
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/hive-credentials
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/hive-debugger
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/hive-patterns
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/hive-test
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.claude/skills/triage-issue
|
||||
@@ -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.
|
||||
+194
-28
@@ -1,41 +1,207 @@
|
||||
# Changelog
|
||||
# Release Notes
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
**Release Date:** February 18, 2026
|
||||
**Tag:** v0.5.1
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
## The Hive Gets a Brain
|
||||
|
||||
## [Unreleased]
|
||||
v0.5.1 is our most ambitious release yet. Hive agents can now **build other agents** -- the new Hive Coder meta-agent writes, tests, and fixes agent packages from natural language. The runtime grows multi-graph support so one session can orchestrate multiple agents simultaneously. The TUI gets a complete overhaul with an in-app agent picker, live streaming, and seamless escalation to the Coder. And we're now provider-agnostic: Claude Code subscriptions, OpenAI-compatible endpoints, and any LiteLLM-supported model work out of the box.
|
||||
|
||||
### Added
|
||||
- Initial project structure
|
||||
- React frontend (honeycomb) with Vite and TypeScript
|
||||
- Node.js backend (hive) with Express and TypeScript
|
||||
- Docker Compose configuration for local development
|
||||
- Configuration system via `config.yaml`
|
||||
- GitHub Actions CI/CD workflows
|
||||
- Comprehensive documentation
|
||||
---
|
||||
|
||||
### Changed
|
||||
- N/A
|
||||
## Highlights
|
||||
|
||||
### Deprecated
|
||||
- N/A
|
||||
### Hive Coder -- The Agent That Builds Agents
|
||||
|
||||
### Removed
|
||||
- N/A
|
||||
A native meta-agent that lives inside the framework at `core/framework/agents/hive_coder/`. Give it a natural-language specification and it produces a complete agent package -- goal definition, node prompts, edge routing, MCP tool wiring, tests, and all boilerplate files.
|
||||
|
||||
```bash
|
||||
# Launch the Coder directly
|
||||
hive code
|
||||
|
||||
### Fixed
|
||||
- tools: Fixed web_scrape tool attempting to parse non-HTML content (PDF, JSON) as HTML (#487)
|
||||
# Or escalate from any running agent (TUI)
|
||||
Ctrl+E # or /coder in chat
|
||||
```
|
||||
|
||||
### Security
|
||||
- N/A
|
||||
The Coder ships with:
|
||||
|
||||
## [0.1.0] - 2025-01-13
|
||||
- **Reference documentation** -- anti-patterns, construction guide, and design patterns baked into its system prompt
|
||||
- **Guardian watchdog** -- an event-driven monitor that catches agent failures and triggers automatic remediation
|
||||
- **Coder Tools MCP server** -- file I/O, fuzzy-match editing, git snapshots, and sandboxed shell execution (`tools/coder_tools_server.py`)
|
||||
- **Test generation** -- structural tests for forever-alive agents that don't hang on `runner.run()`
|
||||
|
||||
### Added
|
||||
- Initial release
|
||||
### Multi-Graph Agent Runtime
|
||||
|
||||
[Unreleased]: https://github.com/adenhq/hive/compare/v0.1.0...HEAD
|
||||
[0.1.0]: https://github.com/adenhq/hive/releases/tag/v0.1.0
|
||||
`AgentRuntime` now supports loading, managing, and switching between multiple agent graphs within a single session. Six new lifecycle tools give agents (and the TUI) full control:
|
||||
|
||||
```python
|
||||
# Load a second agent into the runtime
|
||||
await runtime.add_graph("exports/deep_research_agent")
|
||||
|
||||
# Tools available to agents:
|
||||
# load_agent, unload_agent, start_agent, restart_agent, list_agents, get_user_presence
|
||||
```
|
||||
|
||||
The Hive Coder uses multi-graph internally -- when you escalate from a worker agent, the Coder loads as a separate graph while the worker stays alive in the background.
|
||||
|
||||
### TUI Revamp
|
||||
|
||||
The Terminal UI gets a ground-up rebuild with five major additions:
|
||||
|
||||
- **Agent Picker** (Ctrl+A) -- tabbed modal screen for browsing Your Agents, Framework agents, and Examples with metadata badges (node count, tool count, session count, tags)
|
||||
- **Runtime-optional startup** -- TUI launches without a pre-loaded agent, showing the picker on first open
|
||||
- **Live streaming pane** -- dedicated RichLog widget shows LLM tokens as they arrive, replacing the old one-token-per-line display
|
||||
- **PDF attachments** -- `/attach` and `/detach` commands with native OS file dialog (macOS, Linux, Windows)
|
||||
- **Multi-graph commands** -- `/graphs`, `/graph <id>`, `/load <path>`, `/unload <id>` for managing agent graphs in-session
|
||||
|
||||
### Provider-Agnostic LLM Support
|
||||
|
||||
Hive is no longer Anthropic-only. v0.5.1 adds first-class support for:
|
||||
|
||||
- **Claude Code subscriptions** -- `use_claude_code_subscription: true` in `~/.hive/configuration.json` reads OAuth tokens from `~/.claude/.credentials.json` with automatic refresh
|
||||
- **OpenAI-compatible endpoints** -- `api_base` config routes traffic through any compatible API (Azure OpenAI, vLLM, Ollama, etc.)
|
||||
- **Any LiteLLM model** -- `RuntimeConfig` now passes `api_key`, `api_base`, and `extra_kwargs` through to LiteLLM
|
||||
|
||||
The quickstart script auto-detects Claude Code subscriptions and ZAI Code installations.
|
||||
|
||||
---
|
||||
|
||||
## What's New
|
||||
|
||||
### Architecture & Runtime
|
||||
|
||||
- **Hive Coder meta-agent** -- Natural-language agent builder with reference docs, guardian watchdog, and `hive code` CLI command. (@TimothyZhang7)
|
||||
- **Multi-graph agent sessions** -- `add_graph`/`remove_graph` on AgentRuntime with 6 lifecycle tools (`load_agent`, `unload_agent`, `start_agent`, `restart_agent`, `list_agents`, `get_user_presence`). (@TimothyZhang7)
|
||||
- **Claude Code subscription support** -- OAuth token refresh via `use_claude_code_subscription` config, auto-detection in quickstart, LiteLLM header patching. (@TimothyZhang7)
|
||||
- **OpenAI-compatible endpoint support** -- `api_base` and `extra_kwargs` in `RuntimeConfig` for any OpenAI-compatible API. (@TimothyZhang7)
|
||||
- **Remove deprecated node types** -- Delete `FlexibleGraphExecutor`, `WorkerNode`, `HybridJudge`, `CodeSandbox`, `Plan`, `FunctionNode`, `LLMNode`, `RouterNode`. Deprecated types (`llm_tool_use`, `llm_generate`, `function`, `router`, `human_input`) now raise `RuntimeError` with migration guidance. (@TimothyZhang7)
|
||||
- **Interactive credential setup** -- Guided `CredentialSetupSession` with health checks and encrypted storage, accessible via `hive setup-credentials` or automatic prompting on credential errors. (@RichardTang-Aden)
|
||||
- **Pre-start confirmation prompt** -- Interactive prompt before agent execution allowing credential updates or abort. (@RichardTang-Aden)
|
||||
- **Event bus multi-graph support** -- `graph_id` on events, `filter_graph` on subscriptions, `ESCALATION_REQUESTED` event type, `exclude_own_graph` filter. (@TimothyZhang7)
|
||||
|
||||
### TUI Improvements
|
||||
|
||||
- **In-app agent picker** (Ctrl+A) -- Tabbed modal for browsing agents with metadata badges (nodes, tools, sessions, tags). (@TimothyZhang7)
|
||||
- **Runtime-optional TUI startup** -- Launches without a pre-loaded agent, shows agent picker on startup. (@TimothyZhang7)
|
||||
- **Hive Coder escalation** (Ctrl+E) -- Escalate to Hive Coder and return; also available via `/coder` and `/back` chat commands. (@TimothyZhang7)
|
||||
- **PDF attachment support** -- `/attach` and `/detach` commands with native OS file dialog. (@TimothyZhang7)
|
||||
- **Streaming output pane** -- Dedicated RichLog widget for live LLM token streaming. (@TimothyZhang7)
|
||||
- **Multi-graph TUI commands** -- `/graphs`, `/graph <id>`, `/load <path>`, `/unload <id>`. (@TimothyZhang7)
|
||||
- **Agent Guardian watchdog** -- Event-driven monitor that catches secondary agent failures and triggers automatic remediation, with `--no-guardian` CLI flag. (@TimothyZhang7)
|
||||
|
||||
### New Tool Integrations
|
||||
|
||||
| Tool | Description | Contributor |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
|
||||
| **Discord** | 4 MCP tools (`discord_list_guilds`, `discord_list_channels`, `discord_send_message`, `discord_get_messages`) with rate-limit retry and channel filtering | @mishrapravin114 |
|
||||
| **Exa Search API** | 4 AI-powered search tools (`exa_search`, `exa_find_similar`, `exa_get_contents`, `exa_answer`) with neural/keyword search, domain filters, and citation-backed answers | @JeetKaria06 |
|
||||
| **Razorpay** | 6 payment processing tools for payments, invoices, payment links, and refunds with HTTP Basic Auth | @shivamshahi07 |
|
||||
| **Google Docs** | Document creation, reading, and editing with OAuth credential support | @haliaeetusvocifer |
|
||||
| **Gmail enhancements** | Expanded mail operations for inbox management | @bryanadenhq |
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- **Default node type → `event_loop`** -- `NodeSpec.node_type` defaults to `"event_loop"` instead of `"llm_tool_use"`. (@TimothyZhang7)
|
||||
- **Default `max_node_visits` → 0 (unlimited)** -- Nodes default to unlimited visits, reducing friction for feedback loops and forever-alive agents. (@TimothyZhang7)
|
||||
- **Remove `function` field from NodeSpec** -- Follows deprecation of `FunctionNode`. (@TimothyZhang7)
|
||||
- **LiteLLM OAuth patch** -- Correct header construction for OAuth tokens (remove `x-api-key` when Bearer token is present). (@TimothyZhang7)
|
||||
- **Orchestrator config centralization** -- Reads `api_key`, `api_base`, `extra_kwargs` from centralized `~/.hive/configuration.json`. (@TimothyZhang7)
|
||||
- **System prompt datetime injection** -- All system prompts now include current date/time for time-aware agent behavior. (@TimothyZhang7)
|
||||
- **Utils module exports** -- Proper `__init__.py` exports for the utils module. (@Siddharth2624)
|
||||
- **Increased default max_tokens** -- Opus 4.6 defaults to 32768, Sonnet 4.5 to 16384 (up from 8192). (@TimothyZhang7)
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Flush WIP accumulator outputs on cancel/failure so edge conditions see correct values on resume
|
||||
- Stall detection state preserved across resume (no more resets on checkpoint restore)
|
||||
- Skip client-facing blocking for event-triggered executions (timer/webhook)
|
||||
- Executor retry override scoped to actual EventLoopNode instances only
|
||||
- Add `_awaiting_input` flag to EventLoopNode to prevent input injection race conditions
|
||||
- Fix TUI streaming display (tokens no longer appear one-per-line)
|
||||
- Fix `_return_from_escalation` crash when ChatRepl widgets not yet mounted
|
||||
- Fix tools registration problems for Google Docs credentials (@RichardTang-Aden)
|
||||
- Fix email agent version conflicts (@RichardTang-Aden)
|
||||
- Fix coder tool timeouts (120s for tests, 300s cap for commands)
|
||||
|
||||
## Documentation
|
||||
|
||||
- Clarify installation and prevent root pip install misuse (@paarths-collab)
|
||||
|
||||
---
|
||||
|
||||
## Agent Updates
|
||||
|
||||
- **Email Inbox Management** -- Consolidate `gmail_inbox_guardian` and `inbox_management` into a single unified agent with updated prompts and config. (@RichardTang-Aden, @bryanadenhq)
|
||||
- **Job Hunter** -- Updated node prompts, config, and agent metadata; added PDF resume selection. (@bryanadenhq)
|
||||
- **Deep Research Agent** -- Revised node implementations with updated prompts and output handling.
|
||||
- **Tech News Reporter** -- Revised node prompts for improved output quality.
|
||||
- **Vulnerability Assessment** -- Expanded prompts with more detailed assessment instructions. (@bryanadenhq)
|
||||
|
||||
---
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
- **Deprecated node types raise `RuntimeError`** -- `llm_tool_use`, `llm_generate`, `function`, `router`, `human_input` now fail instead of warning. Migrate to `event_loop`.
|
||||
- **`NodeSpec.node_type` defaults to `"event_loop"`** (was `"llm_tool_use"`)
|
||||
- **`NodeSpec.max_node_visits` defaults to `0` / unlimited** (was `1`)
|
||||
- **`NodeSpec.function` field removed** -- `FunctionNode` is deleted; use event_loop nodes with tools instead.
|
||||
|
||||
---
|
||||
|
||||
## Community Contributors
|
||||
|
||||
A huge thank you to everyone who contributed to this release:
|
||||
|
||||
- **Richard Tang** (@RichardTang-Aden) -- Interactive credential setup, pre-start confirmation, email agent consolidation, tool registration fixes, lint and formatting
|
||||
- **Pravin Mishra** (@mishrapravin114) -- Discord integration with 4 MCP tools
|
||||
- **Jeet Karia** (@JeetKaria06) -- Exa Search API integration with 4 AI-powered search tools
|
||||
- **Shivam Shahi** (@shivamshahi07) -- Razorpay payment processing integration
|
||||
- **Siddharth Varshney** (@Siddharth2624) -- Utils module exports
|
||||
- **@haliaeetusvocifer** -- Google Docs integration with OAuth support
|
||||
- **Bryan** (@bryanadenhq) -- PDF selection, inbox agent fixes, Job Hunter and Vulnerability Assessment updates
|
||||
- **@paarths-collab** -- Documentation improvements
|
||||
|
||||
---
|
||||
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
git pull origin main
|
||||
uv sync
|
||||
```
|
||||
|
||||
### Migration Guide
|
||||
|
||||
If your agents use deprecated node types, update them:
|
||||
|
||||
```python
|
||||
# Before (v0.5.0) -- these now raise RuntimeError
|
||||
NodeSpec(node_type="llm_tool_use", ...)
|
||||
NodeSpec(node_type="function", function=my_func, ...)
|
||||
|
||||
# After (v0.5.1) -- use event_loop for everything
|
||||
NodeSpec(node_type="event_loop", ...) # or just omit node_type (it's the default now)
|
||||
```
|
||||
|
||||
If your agents set `max_node_visits=1` explicitly, they'll still work. The only change is the _default_ -- new agents without an explicit value now get unlimited visits.
|
||||
|
||||
To try the new Hive Coder:
|
||||
|
||||
```bash
|
||||
# Launch Coder directly
|
||||
hive code
|
||||
|
||||
# Or from TUI -- press Ctrl+E to escalate
|
||||
hive tui
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What's Next
|
||||
|
||||
- **Agent-to-agent communication** -- one agent's output triggers another agent's entry point
|
||||
- **Cost visibility** -- detailed runtime log of LLM costs per node and per session
|
||||
- **Persistent webhook subscriptions** -- survive agent restarts without re-registering
|
||||
- **Remote agent deployment** -- run agents as long-lived services with HTTP APIs
|
||||
|
||||
+11
-7
@@ -1,10 +1,10 @@
|
||||
# Contributing to Aden Agent Framework
|
||||
|
||||
Thank you for your interest in contributing to the Aden Agent Framework! This document provides guidelines and information for contributors. We’re especially looking for help building tools, integrations([check #2805](https://github.com/adenhq/hive/issues/2805)), and example agents for the framework. If you’re interested in extending its functionality, this is the perfect place to start.
|
||||
Thank you for your interest in contributing to the Aden Agent Framework! This document provides guidelines and information for contributors. We’re especially looking for help building tools, integrations ([check #2805](https://github.com/adenhq/hive/issues/2805)), and example agents for the framework. If you’re interested in extending its functionality, this is the perfect place to start.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
By participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
By participating in this project, you agree to abide by our [Code of Conduct](docs/CODE_OF_CONDUCT.md).
|
||||
|
||||
## Issue Assignment Policy
|
||||
|
||||
@@ -49,8 +49,8 @@ You may submit PRs without prior assignment for:
|
||||
make check # Lint and format checks (ruff check + ruff format --check on core/ and tools/)
|
||||
make test # Core tests (cd core && pytest tests/ -v)
|
||||
```
|
||||
6. Commit your changes following our commit conventions
|
||||
7. Push to your fork and submit a Pull Request
|
||||
8. Commit your changes following our commit conventions
|
||||
9. Push to your fork and submit a Pull Request
|
||||
|
||||
## Development Setup
|
||||
|
||||
@@ -99,8 +99,7 @@ docs(readme): update installation instructions
|
||||
2. Update documentation if needed
|
||||
3. Add tests for new functionality
|
||||
4. Ensure `make check` and `make test` pass
|
||||
5. Update the CHANGELOG.md if applicable
|
||||
6. Request review from maintainers
|
||||
5. Request review from maintainers
|
||||
|
||||
### PR Title Format
|
||||
|
||||
@@ -127,6 +126,8 @@ feat(component): add new feature description
|
||||
- Use meaningful variable and function names
|
||||
- Keep functions focused and small
|
||||
|
||||
For linting and formatting (Ruff, pre-commit hooks), see [Linting & Formatting Setup](docs/contributing-lint-setup.md).
|
||||
|
||||
## Testing
|
||||
|
||||
> **Note:** When testing agents in `exports/`, always set PYTHONPATH:
|
||||
@@ -145,6 +146,9 @@ make test
|
||||
# Or run tests directly
|
||||
cd core && pytest tests/ -v
|
||||
|
||||
# Run tools package tests (when contributing to tools/)
|
||||
cd tools && uv run pytest tests/ -v
|
||||
|
||||
# Run tests for a specific agent
|
||||
PYTHONPATH=exports uv run python -m agent_name test
|
||||
```
|
||||
@@ -159,4 +163,4 @@ By submitting a Pull Request, you agree that your contributions will be licensed
|
||||
|
||||
Feel free to open an issue for questions or join our [Discord community](https://discord.com/invite/MXE49hrKDk).
|
||||
|
||||
Thank you for contributing!
|
||||
Thank you for contributing!
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: lint format check test install-hooks help
|
||||
.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) | \
|
||||
@@ -26,3 +26,12 @@ test: ## Run all tests
|
||||
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
|
||||
|
||||
frontend-build: ## Build frontend for production
|
||||
cd core/frontend && npm run build
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img width="100%" alt="Hive Banner" src="https://storage.googleapis.com/aden-prod-assets/website/aden-title-card.png" />
|
||||
<img width="100%" alt="Hive Banner" src="https://github.com/user-attachments/assets/a027429b-5d3c-4d34-88e4-0feaeaabbab3" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -13,16 +13,19 @@
|
||||
<a href="docs/i18n/ko.md">한국어</a>
|
||||
</p>
|
||||
|
||||
[](https://github.com/adenhq/hive/blob/main/LICENSE)
|
||||
[](https://www.ycombinator.com/companies/aden)
|
||||
[](https://discord.com/invite/MXE49hrKDk)
|
||||
[](https://x.com/aden_hq)
|
||||
[](https://www.linkedin.com/company/teamaden/)
|
||||
<p align="center">
|
||||
<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>
|
||||
<a href="https://www.linkedin.com/company/teamaden/"><img src="https://custom-icon-badges.demolab.com/badge/LinkedIn-0A66C2?logo=linkedin-white&logoColor=fff" alt="LinkedIn" /></a>
|
||||
<img src="https://img.shields.io/badge/MCP-102_Tools-00ADD8?style=flat-square" alt="MCP" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/AI_Agents-Self--Improving-brightgreen?style=flat-square" alt="AI Agents" />
|
||||
<img src="https://img.shields.io/badge/Multi--Agent-Systems-blue?style=flat-square" alt="Multi-Agent" />
|
||||
<img src="https://img.shields.io/badge/Goal--Driven-Development-purple?style=flat-square" alt="Goal-Driven" />
|
||||
<img src="https://img.shields.io/badge/Headless-Development-purple?style=flat-square" alt="Headless" />
|
||||
<img src="https://img.shields.io/badge/Human--in--the--Loop-orange?style=flat-square" alt="HITL" />
|
||||
<img src="https://img.shields.io/badge/Production--Ready-red?style=flat-square" alt="Production" />
|
||||
</p>
|
||||
@@ -30,15 +33,16 @@
|
||||
<img src="https://img.shields.io/badge/OpenAI-supported-412991?style=flat-square&logo=openai" alt="OpenAI" />
|
||||
<img src="https://img.shields.io/badge/Anthropic-supported-d4a574?style=flat-square" alt="Anthropic" />
|
||||
<img src="https://img.shields.io/badge/Google_Gemini-supported-4285F4?style=flat-square&logo=google" alt="Gemini" />
|
||||
<img src="https://img.shields.io/badge/MCP-19_Tools-00ADD8?style=flat-square" alt="MCP" />
|
||||
</p>
|
||||
|
||||
## Overview
|
||||
|
||||
Build 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://www.youtube.com/watch?v=XDOG9fOaLjU)
|
||||
|
||||
## Who Is Hive For?
|
||||
|
||||
Hive is designed for developers and teams who want to build **production-grade AI agents** without manually wiring complex workflows.
|
||||
@@ -46,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**
|
||||
@@ -58,49 +62,42 @@ Hive may not be the best fit if you’re only experimenting with simple agent ch
|
||||
Use Hive when you need:
|
||||
|
||||
- Long-running, autonomous agents
|
||||
- Multi-agent coordination
|
||||
- Strong guardrails, process, and controls
|
||||
- Continuous improvement based on failures
|
||||
- Strong monitoring, safety, and budget controls
|
||||
- Multi-agent coordination
|
||||
- A framework that evolves with your goals
|
||||
|
||||
## What is Aden
|
||||
|
||||
<p align="center">
|
||||
<img width="100%" alt="Aden Architecture" src="docs/assets/aden-architecture-diagram.jpg" />
|
||||
</p>
|
||||
|
||||
Aden is a platform for building, deploying, operating, and adapting AI agents:
|
||||
|
||||
- **Build** - A Coding Agent generates specialized Worker Agents (Sales, Marketing, Ops) from natural language goals
|
||||
- **Deploy** - Headless deployment with CI/CD integration and full API lifecycle management
|
||||
- **Operate** - Real-time monitoring, observability, and runtime guardrails keep agents reliable
|
||||
- **Adapt** - Continuous evaluation, supervision, and adaptation ensure agents improve over time
|
||||
- **Infra** - Shared memory, LLM integrations, tools, and skills power every agent
|
||||
|
||||
## Quick Links
|
||||
|
||||
- **[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
|
||||
<!-- - **[Roadmap](https://adenhq.com/roadmap)** - Upcoming features and plans -->
|
||||
- **[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
|
||||
|
||||
## Quick Start
|
||||
|
||||
## Prerequisites
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.11+ for agent development
|
||||
- Claude Code or Cursor for utilizing agent skills
|
||||
- An LLM provider that powers the agents
|
||||
|
||||
> **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.
|
||||
|
||||
### Installation
|
||||
|
||||
> **Note**
|
||||
> Hive uses a `uv` workspace layout and is not installed with `pip install`.
|
||||
> Running `pip install -e .` from the repository root will create a placeholder package and Hive will not function correctly.
|
||||
> Please use the quickstart script below to set up the environment.
|
||||
|
||||
```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,46 +106,55 @@ This sets up:
|
||||
|
||||
- **framework** - Core agent runtime and graph executor (in `core/.venv`)
|
||||
- **aden_tools** - MCP tools for agent capabilities (in `tools/.venv`)
|
||||
- All required Python dependencies
|
||||
- **credential store** - Encrypted API key storage (`~/.hive/credentials`)
|
||||
- **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-test
|
||||
<img width="2500" height="1214" alt="Image" src="https://github.com/user-attachments/assets/1ce19141-a78b-46f5-8d64-dbf987e048f4" />
|
||||
|
||||
# Run your agent
|
||||
PYTHONPATH=exports uv run python -m your_agent_name run --input '{...}'
|
||||
```
|
||||
### Use Template Agents
|
||||
|
||||
**[📖 Complete Setup Guide](docs/environment-setup.md)** - Detailed instructions for agent development
|
||||
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.
|
||||
|
||||
### Cursor IDE Support
|
||||
### Run Agents
|
||||
|
||||
Skills are also available in Cursor. To enable:
|
||||
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.
|
||||
|
||||
1. Open Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`)
|
||||
2. Run `MCP: Enable` to enable MCP servers
|
||||
3. Restart Cursor to load the MCP servers from `.cursor/mcp.json`
|
||||
4. Type `/` in Agent chat and search for skills (e.g., `/hive-create`)
|
||||
<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
|
||||
- **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/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.
|
||||
- **Business system connectivity** - Hive Framework is designed to connect to all kinds of business systems as tools, such as CRM, support, messaging, data, file, and internal APIs via MCP.
|
||||
|
||||
## Why Aden
|
||||
|
||||
Hive focuses on generating agents that run real business processes rather than generic agents. Instead of requiring you to manually design workflows, define agent interactions, and handle failures reactively, Hive flips the paradigm: **you describe [outcomes](docs/key_concepts/goals_outcome.md), and the system builds itself**—delivering an outcome-driven, [adaptive](docs/key_concepts/evolution.md) experience with an easy-to-use set of tools and integrations.
|
||||
Hive focuses on generating agents that run real business processes rather than generic agents. Instead of requiring you to manually design workflows, define agent interactions, and handle failures reactively, Hive flips the paradigm: **you describe outcomes, and the system builds itself**—delivering an outcome-driven, adaptive experience with an easy-to-use set of tools and integrations.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
@@ -182,9 +188,9 @@ flowchart LR
|
||||
style V6 fill:#fff,stroke:#ed8c00,stroke-width:1px,color:#cc5d00
|
||||
```
|
||||
|
||||
### The Aden Advantage
|
||||
### The Hive Advantage
|
||||
|
||||
| Traditional Frameworks | Aden |
|
||||
| Traditional Frameworks | Hive |
|
||||
| -------------------------- | -------------------------------------- |
|
||||
| Hardcode agent workflows | Describe goals in natural language |
|
||||
| Manual graph definition | Auto-generated agent graphs |
|
||||
@@ -201,36 +207,6 @@ 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 pre-built Agents (Coming Soon)
|
||||
|
||||
### Run a sample agent
|
||||
|
||||
Aden Hive provides a list of featured agents that you can use and build on top of.
|
||||
|
||||
### Run an agent shared by others
|
||||
|
||||
Put the agent in `exports/` and run `PYTHONPATH=exports uv run python -m your_agent_name run --input '{...}'`
|
||||
|
||||
For building and running goal-driven agents with the framework:
|
||||
|
||||
```bash
|
||||
# One-time setup
|
||||
./quickstart.sh
|
||||
|
||||
# This sets up:
|
||||
# - framework package (core runtime)
|
||||
# - aden_tools package (MCP tools)
|
||||
# - All Python dependencies
|
||||
|
||||
# Build new agents using Agent Skills
|
||||
claude> /hive
|
||||
|
||||
# Run agents
|
||||
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
|
||||
@@ -238,109 +214,133 @@ See [environment-setup.md](docs/environment-setup.md) for complete setup instruc
|
||||
- [Configuration Guide](docs/configuration.md) - All configuration options
|
||||
- [Architecture Overview](docs/architecture/README.md) - System design and structure
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- [Goals & Outcome-Driven Development](docs/key_concepts/goals_outcome.md) - Why Hive is outcome-driven and how goals define success
|
||||
- [The Agent Graph](docs/key_concepts/graph.md) - Nodes, edges, shared memory, and how agents execute
|
||||
- [The Worker Agent](docs/key_concepts/worker_agent.md) - Sessions, iterations, headless execution, and the runtime
|
||||
- [Evolution](docs/key_concepts/evolution.md) - How agents improve across generations through failure data
|
||||
|
||||
## Roadmap
|
||||
|
||||
Aden Hive Agent Framework aims to help developers build outcome-oriented, self-adaptive agents. See [roadmap.md](docs/roadmap.md) for details.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Foundation
|
||||
direction LR
|
||||
subgraph arch["Architecture"]
|
||||
a1["Node-Based Architecture"]:::done
|
||||
a2["Python SDK"]:::done
|
||||
a3["LLM Integration"]:::done
|
||||
a4["Communication Protocol"]:::done
|
||||
end
|
||||
subgraph ca["Coding Agent"]
|
||||
b1["Goal Creation Session"]:::done
|
||||
b2["Worker Agent Creation"]
|
||||
b3["MCP Tools"]:::done
|
||||
end
|
||||
subgraph wa["Worker Agent"]
|
||||
c1["Human-in-the-Loop"]:::done
|
||||
c2["Callback Handlers"]:::done
|
||||
c3["Intervention Points"]:::done
|
||||
c4["Streaming Interface"]
|
||||
end
|
||||
subgraph cred["Credentials"]
|
||||
d1["Setup Process"]:::done
|
||||
d2["Pluggable Sources"]:::done
|
||||
d3["Enterprise Secrets"]
|
||||
d4["Integration Tools"]:::done
|
||||
end
|
||||
subgraph tools["Tools"]
|
||||
e1["File Use"]:::done
|
||||
e2["Memory STM/LTM"]:::done
|
||||
e3["Web Search/Scraper"]:::done
|
||||
e4["CSV/PDF"]:::done
|
||||
e5["Excel/Email"]
|
||||
end
|
||||
subgraph core["Core"]
|
||||
f1["Eval System"]
|
||||
f2["Pydantic Validation"]:::done
|
||||
f3["Documentation"]:::done
|
||||
f4["Adaptiveness"]
|
||||
f5["Sample Agents"]
|
||||
end
|
||||
end
|
||||
flowchart TB
|
||||
%% Main Entity
|
||||
User([User])
|
||||
|
||||
subgraph Expansion
|
||||
direction LR
|
||||
subgraph intel["Intelligence"]
|
||||
g1["Guardrails"]
|
||||
g2["Streaming Mode"]
|
||||
g3["Image Generation"]
|
||||
g4["Semantic Search"]
|
||||
%% =========================================
|
||||
%% EXTERNAL EVENT SOURCES
|
||||
%% =========================================
|
||||
subgraph ExtEventSource [External Event Source]
|
||||
E_Sch["Schedulers"]
|
||||
E_WH["Webhook"]
|
||||
E_SSE["SSE"]
|
||||
end
|
||||
subgraph mem["Memory Iteration"]
|
||||
h1["Message Model & Sessions"]
|
||||
h2["Storage Migration"]
|
||||
h3["Context Building"]
|
||||
h4["Proactive Compaction"]
|
||||
h5["Token Tracking"]
|
||||
end
|
||||
subgraph evt["Event System"]
|
||||
i1["Event Bus for Nodes"]
|
||||
end
|
||||
subgraph cas["Coding Agent Support"]
|
||||
j1["Claude Code"]
|
||||
j2["Cursor"]
|
||||
j3["Opencode"]
|
||||
j4["Antigravity"]
|
||||
end
|
||||
subgraph plat["Platform"]
|
||||
k1["JavaScript/TypeScript SDK"]
|
||||
k2["Custom Tool Integrator"]
|
||||
k3["Windows Support"]
|
||||
end
|
||||
subgraph dep["Deployment"]
|
||||
l1["Self-Hosted"]
|
||||
l2["Cloud Services"]
|
||||
l3["CI/CD Pipeline"]
|
||||
end
|
||||
subgraph tmpl["Templates"]
|
||||
m1["Sales Agent"]
|
||||
m2["Marketing Agent"]
|
||||
m3["Analytics Agent"]
|
||||
m4["Training Agent"]
|
||||
m5["Smart Form Agent"]
|
||||
end
|
||||
end
|
||||
|
||||
classDef done fill:#9e9e9e,color:#fff,stroke:#757575
|
||||
%% =========================================
|
||||
%% SYSTEM NODES
|
||||
%% =========================================
|
||||
subgraph WorkerBees [Worker Bees]
|
||||
WB_C["Conversation"]
|
||||
WB_SP["System prompt"]
|
||||
|
||||
subgraph Graph [Graph]
|
||||
direction TB
|
||||
N1["Node"] --> N2["Node"] --> N3["Node"]
|
||||
N1 -.-> AN["Active Node"]
|
||||
N2 -.-> AN
|
||||
N3 -.-> AN
|
||||
|
||||
%% Nested Event Loop Node
|
||||
subgraph EventLoopNode [Event Loop Node]
|
||||
ELN_L["listener"]
|
||||
ELN_SP["System Prompt<br/>(Task)"]
|
||||
ELN_EL["Event loop"]
|
||||
ELN_C["Conversation"]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
subgraph JudgeNode [Judge]
|
||||
J_C["Criteria"]
|
||||
J_P["Principles"]
|
||||
J_EL["Event loop"] <--> J_S["Scheduler"]
|
||||
end
|
||||
|
||||
subgraph QueenBee [Queen Bee]
|
||||
QB_SP["System prompt"]
|
||||
QB_EL["Event loop"]
|
||||
QB_C["Conversation"]
|
||||
end
|
||||
|
||||
subgraph Infra [Infra]
|
||||
SA["Sub Agent"]
|
||||
TR["Tool Registry"]
|
||||
WTM["Write through Conversation Memory<br/>(Logs/RAM/Harddrive)"]
|
||||
SM["Shared Memory<br/>(State/Harddrive)"]
|
||||
EB["Event Bus<br/>(RAM)"]
|
||||
CS["Credential Store<br/>(Harddrive/Cloud)"]
|
||||
end
|
||||
|
||||
subgraph PC [PC]
|
||||
B["Browser"]
|
||||
CB["Codebase<br/>v 0.0.x ... v n.n.n"]
|
||||
end
|
||||
|
||||
%% =========================================
|
||||
%% CONNECTIONS & DATA FLOW
|
||||
%% =========================================
|
||||
|
||||
%% External Event Routing
|
||||
E_Sch --> ELN_L
|
||||
E_WH --> ELN_L
|
||||
E_SSE --> ELN_L
|
||||
ELN_L -->|"triggers"| ELN_EL
|
||||
|
||||
%% User Interactions
|
||||
User -->|"Talk"| WB_C
|
||||
User -->|"Talk"| QB_C
|
||||
User -->|"Read/Write Access"| CS
|
||||
|
||||
%% Inter-System Logic
|
||||
ELN_C <-->|"Mirror"| WB_C
|
||||
WB_C -->|"Focus"| AN
|
||||
|
||||
WorkerBees -->|"Inquire"| JudgeNode
|
||||
JudgeNode -->|"Approve"| WorkerBees
|
||||
|
||||
%% Judge Alignments
|
||||
J_C <-.->|"aligns"| WB_SP
|
||||
J_P <-.->|"aligns"| QB_SP
|
||||
|
||||
%% Escalate path
|
||||
J_EL -->|"Report (Escalate)"| QB_EL
|
||||
|
||||
%% Pub/Sub Logic
|
||||
AN -->|"publish"| EB
|
||||
EB -->|"subscribe"| QB_C
|
||||
|
||||
%% Infra and Process Spawning
|
||||
ELN_EL -->|"Spawn"| SA
|
||||
SA -->|"Inform"| ELN_EL
|
||||
SA -->|"Starts"| B
|
||||
B -->|"Report"| ELN_EL
|
||||
TR -->|"Assigned"| ELN_EL
|
||||
CB -->|"Modify Worker Bee"| WB_C
|
||||
|
||||
%% =========================================
|
||||
%% SHARED MEMORY & LOGS ACCESS
|
||||
%% =========================================
|
||||
|
||||
%% Worker Bees Access (link to node inside Graph subgraph)
|
||||
AN <-->|"Read/Write"| WTM
|
||||
AN <-->|"Read/Write"| SM
|
||||
|
||||
%% Queen Bee Access
|
||||
QB_C <-->|"Read/Write"| WTM
|
||||
QB_EL <-->|"Read/Write"| SM
|
||||
|
||||
%% Credentials Access
|
||||
CS -->|"Read Access"| QB_C
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -375,13 +375,9 @@ This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENS
|
||||
|
||||
## Frequently Asked Questions (FAQ)
|
||||
|
||||
**Q: Does Hive depend on LangChain or other agent frameworks?**
|
||||
|
||||
No. Hive is built from the ground up with no dependencies on LangChain, CrewAI, or other agent frameworks. The framework is designed to be lean and flexible, generating agent graphs dynamically rather than relying on predefined components.
|
||||
|
||||
**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?**
|
||||
|
||||
@@ -389,20 +385,12 @@ Yes! Hive supports local models through LiteLLM. Simply use the model name forma
|
||||
|
||||
**Q: What makes Hive different from other agent frameworks?**
|
||||
|
||||
Hive generates your entire agent system from natural language [goals](docs/key_concepts/goals_outcome.md) using a coding agent—you don't hardcode workflows or manually define graphs. When agents fail, the framework automatically captures failure data, [evolves the agent graph](docs/key_concepts/evolution.md), and redeploys. This self-improving loop is unique to Aden.
|
||||
Hive generates your entire agent system from natural language goals using a coding agent—you don't hardcode workflows or manually define graphs. When agents fail, the framework automatically captures failure data, [evolves the agent graph](docs/key_concepts/evolution.md), and redeploys. This self-improving loop is unique to Aden.
|
||||
|
||||
**Q: Is Hive open-source?**
|
||||
|
||||
Yes, Hive is fully open-source under the Apache License 2.0. We actively encourage community contributions and collaboration.
|
||||
|
||||
**Q: Does Hive collect data from users?**
|
||||
|
||||
Hive collects telemetry data for monitoring and observability purposes, including token usage, latency metrics, and cost tracking. Content capture (prompts and responses) is configurable and stored with team-scoped data isolation. All data stays within your infrastructure when self-hosted.
|
||||
|
||||
**Q: What deployment options does Hive support?**
|
||||
|
||||
Hive supports self-hosted deployments via Python packages. See the [Environment Setup Guide](docs/environment-setup.md) for installation instructions. Cloud deployment options and Kubernetes-ready configurations are on the roadmap.
|
||||
|
||||
**Q: Can Hive handle complex, production-scale use cases?**
|
||||
|
||||
Yes. Hive is explicitly designed for production environments with features like automatic failure recovery, real-time observability, cost controls, and horizontal scaling support. The framework handles both simple automations and complex multi-agent workflows.
|
||||
@@ -411,15 +399,11 @@ Yes. Hive is explicitly designed for production environments with features like
|
||||
|
||||
Yes, Hive fully supports [human-in-the-loop](docs/key_concepts/graph.md#human-in-the-loop) workflows through intervention nodes that pause execution for human input. These include configurable timeouts and escalation policies, allowing seamless collaboration between human experts and AI agents.
|
||||
|
||||
**Q: What monitoring and debugging tools does Hive provide?**
|
||||
|
||||
Hive includes comprehensive observability features: real-time WebSocket streaming for live agent execution monitoring, TimescaleDB-powered analytics for cost and performance metrics, health check endpoints for Kubernetes integration, and MCP tools for agent execution, including file operations, web search, data processing, and more.
|
||||
|
||||
**Q: What programming languages does Hive support?**
|
||||
|
||||
The Hive framework is built in Python. A JavaScript/TypeScript SDK is on the roadmap.
|
||||
|
||||
**Q: Can Aden agents interact with external tools and APIs?**
|
||||
**Q: Can Hive agents interact with external tools and APIs?**
|
||||
|
||||
Yes. Aden's SDK-wrapped nodes provide built-in tool access, and the framework supports flexible tool ecosystems. Agents can integrate with external APIs, databases, and services through the node architecture.
|
||||
|
||||
@@ -435,18 +419,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](docs/key_concepts/evolution.md) 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](docs/key_concepts/goals_outcome.md), 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.
|
||||
|
||||
**Q: Does Aden offer enterprise support?**
|
||||
|
||||
For enterprise inquiries, contact the Aden team through [adenhq.com](https://adenhq.com) or join our [Discord community](https://discord.com/invite/MXE49hrKDk) for support and discussions.
|
||||
|
||||
---
|
||||
|
||||
<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
|
||||
@@ -1,4 +1,5 @@
|
||||
exports/
|
||||
docs/
|
||||
.agent-builder-sessions/
|
||||
.pytest_cache/
|
||||
**/__pycache__/
|
||||
@@ -82,7 +82,7 @@ Register an MCP server as a tool source for your agent.
|
||||
"example_tool"
|
||||
],
|
||||
"total_mcp_servers": 1,
|
||||
"note": "MCP server 'tools' registered with 6 tools. These tools can now be used in llm_tool_use nodes."
|
||||
"note": "MCP server 'tools' registered with 6 tools. These tools can now be used in event_loop nodes."
|
||||
}
|
||||
```
|
||||
|
||||
@@ -149,7 +149,7 @@ List tools available from registered MCP servers.
|
||||
]
|
||||
},
|
||||
"total_tools": 6,
|
||||
"note": "Use these tool names in the 'tools' parameter when adding llm_tool_use nodes"
|
||||
"note": "Use these tool names in the 'tools' parameter when adding event_loop nodes"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -246,7 +246,7 @@ Here's a complete workflow for building an agent with MCP tools:
|
||||
"node_id": "web-searcher",
|
||||
"name": "Web Search",
|
||||
"description": "Search the web for information",
|
||||
"node_type": "llm_tool_use",
|
||||
"node_type": "event_loop",
|
||||
"input_keys": "[\"query\"]",
|
||||
"output_keys": "[\"search_results\"]",
|
||||
"system_prompt": "Search for {query} using the web_search tool",
|
||||
|
||||
@@ -119,7 +119,7 @@ builder = WorkflowBuilder()
|
||||
builder.add_node(
|
||||
node_id="researcher",
|
||||
name="Web Researcher",
|
||||
node_type="llm_tool_use",
|
||||
node_type="event_loop",
|
||||
system_prompt="Research the topic using web_search",
|
||||
tools=["web_search"], # Tool from tools MCP server
|
||||
input_keys=["topic"],
|
||||
@@ -137,7 +137,7 @@ Tools from MCP servers can be referenced in your agent.json just like built-in t
|
||||
{
|
||||
"id": "searcher",
|
||||
"name": "Web Searcher",
|
||||
"node_type": "llm_tool_use",
|
||||
"node_type": "event_loop",
|
||||
"system_prompt": "Search for information about {topic}",
|
||||
"tools": ["web_search", "web_scrape"],
|
||||
"input_keys": ["topic"],
|
||||
|
||||
+17
-70
@@ -103,31 +103,20 @@ Add a processing node to the agent graph.
|
||||
- `node_id` (string, required): Unique node identifier
|
||||
- `name` (string, required): Human-readable name
|
||||
- `description` (string, required): What this node does
|
||||
- `node_type` (string, required): One of: `llm_generate`, `llm_tool_use`, `router`, `function`
|
||||
- `node_type` (string, required): Must be `event_loop` (the only valid type)
|
||||
- `input_keys` (string, required): JSON array of input variable names
|
||||
- `output_keys` (string, required): JSON array of output variable names
|
||||
- `system_prompt` (string, optional): System prompt for LLM nodes
|
||||
- `tools` (string, optional): JSON array of tool names for tool_use nodes
|
||||
- `routes` (string, optional): JSON object of route mappings for router nodes
|
||||
- `system_prompt` (string, optional): System prompt for the LLM
|
||||
- `tools` (string, optional): JSON array of tool names
|
||||
- `client_facing` (boolean, optional): Set to true for human-in-the-loop interaction
|
||||
|
||||
**Node Types:**
|
||||
**Node Type:**
|
||||
|
||||
1. **llm_generate**: Uses LLM to generate output from inputs
|
||||
- Requires: `system_prompt`
|
||||
- Tools: Not used
|
||||
|
||||
2. **llm_tool_use**: Uses LLM with tools to accomplish tasks
|
||||
- Requires: `system_prompt`, `tools`
|
||||
- Tools: Array of tool names (e.g., `["web_search", "web_fetch"]`)
|
||||
|
||||
3. **router**: LLM-powered routing to different paths
|
||||
- Requires: `system_prompt`, `routes`
|
||||
- Routes: Object mapping route names to target node IDs
|
||||
- Example: `{"pass": "success_node", "fail": "retry_node"}`
|
||||
|
||||
4. **function**: Executes a pre-defined function
|
||||
- System prompt describes the function behavior
|
||||
- No LLM calls, pure computation
|
||||
**event_loop**: LLM-powered node with self-correction loop
|
||||
- Requires: `system_prompt`
|
||||
- Optional: `tools` (array of tool names, e.g., `["web_search", "web_fetch"]`)
|
||||
- Optional: `client_facing` (set to true for HITL / user interaction)
|
||||
- Supports: iterative refinement, judge-based evaluation, tool use, streaming
|
||||
|
||||
**Example:**
|
||||
```json
|
||||
@@ -135,7 +124,7 @@ Add a processing node to the agent graph.
|
||||
"node_id": "search_sources",
|
||||
"name": "Search Sources",
|
||||
"description": "Searches for relevant sources on the topic",
|
||||
"node_type": "llm_tool_use",
|
||||
"node_type": "event_loop",
|
||||
"input_keys": "[\"topic\", \"search_queries\"]",
|
||||
"output_keys": "[\"sources\", \"source_count\"]",
|
||||
"system_prompt": "Search for sources using the provided queries...",
|
||||
@@ -198,7 +187,7 @@ Export the validated graph as an agent specification.
|
||||
|
||||
**What it does:**
|
||||
1. Validates the graph
|
||||
2. Auto-generates missing edges from router routes
|
||||
2. Validates edge connectivity
|
||||
3. Writes files to disk:
|
||||
- `exports/{agent-name}/agent.json` - Full agent specification
|
||||
- `exports/{agent-name}/README.md` - Auto-generated documentation
|
||||
@@ -252,47 +241,6 @@ Test the complete agent graph with sample inputs.
|
||||
|
||||
---
|
||||
|
||||
### Evaluation Rules
|
||||
|
||||
#### `add_evaluation_rule`
|
||||
Add a rule for the HybridJudge to evaluate node outputs.
|
||||
|
||||
**Parameters:**
|
||||
- `rule_id` (string, required): Unique rule identifier
|
||||
- `description` (string, required): What this rule checks
|
||||
- `condition` (string, required): Python expression to evaluate
|
||||
- `action` (string, required): Action to take: `accept`, `retry`, `escalate`
|
||||
- `priority` (integer, optional): Rule priority (default: 0)
|
||||
- `feedback_template` (string, optional): Feedback message template
|
||||
|
||||
**Condition Examples:**
|
||||
- `'result.get("success") == True'` - Check for success flag
|
||||
- `'result.get("error_type") == "timeout"'` - Check error type
|
||||
- `'len(result.get("data", [])) > 0'` - Check for non-empty data
|
||||
|
||||
**Example:**
|
||||
```json
|
||||
{
|
||||
"rule_id": "timeout_retry",
|
||||
"description": "Retry on timeout errors",
|
||||
"condition": "result.get('error_type') == 'timeout'",
|
||||
"action": "retry",
|
||||
"priority": 10,
|
||||
"feedback_template": "Timeout occurred, retrying..."
|
||||
}
|
||||
```
|
||||
|
||||
#### `list_evaluation_rules`
|
||||
List all configured evaluation rules.
|
||||
|
||||
#### `remove_evaluation_rule`
|
||||
Remove an evaluation rule.
|
||||
|
||||
**Parameters:**
|
||||
- `rule_id` (string, required): Rule to remove
|
||||
|
||||
---
|
||||
|
||||
## Example Workflow
|
||||
|
||||
Here's a complete workflow for building a research agent:
|
||||
@@ -320,7 +268,7 @@ add_node(
|
||||
node_id="planner",
|
||||
name="Research Planner",
|
||||
description="Creates research strategy",
|
||||
node_type="llm_generate",
|
||||
node_type="event_loop",
|
||||
input_keys='["topic"]',
|
||||
output_keys='["strategy", "queries"]',
|
||||
system_prompt="Analyze topic and create research plan..."
|
||||
@@ -330,7 +278,7 @@ add_node(
|
||||
node_id="searcher",
|
||||
name="Search Sources",
|
||||
description="Find relevant sources",
|
||||
node_type="llm_tool_use",
|
||||
node_type="event_loop",
|
||||
input_keys='["queries"]',
|
||||
output_keys='["sources"]',
|
||||
system_prompt="Search for sources...",
|
||||
@@ -359,10 +307,9 @@ The exported agent will be saved to `exports/research-agent/`.
|
||||
|
||||
1. **Start with the goal**: Define clear success criteria before building nodes
|
||||
2. **Test nodes individually**: Use `test_node` to verify each node works
|
||||
3. **Use router nodes for branching**: Don't create edges manually for routers - define routes and they'll be auto-generated
|
||||
4. **Add evaluation rules**: Help the judge evaluate outputs deterministically
|
||||
5. **Validate early, validate often**: Run `validate_graph` after adding nodes/edges
|
||||
6. **Check exports**: Review the generated README.md to verify your agent structure
|
||||
3. **Use conditional edges for branching**: Define condition_expr on edges for decision points
|
||||
4. **Validate early, validate often**: Run `validate_graph` after adding nodes/edges
|
||||
5. **Check exports**: Review the generated README.md to verify your agent structure
|
||||
|
||||
---
|
||||
|
||||
|
||||
+10
-10
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ To use the agent builder with Claude Desktop or other MCP clients, add this to y
|
||||
The MCP server provides tools for:
|
||||
- Creating agent building sessions
|
||||
- Defining goals with success criteria
|
||||
- Adding nodes (llm_generate, llm_tool_use, router, function)
|
||||
- Adding nodes (event_loop only)
|
||||
- Connecting nodes with edges
|
||||
- Validating and exporting agent graphs
|
||||
- Testing nodes and full agent graphs
|
||||
@@ -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())
|
||||
@@ -68,7 +68,7 @@ from framework.graph.event_loop_node import ( # noqa: E402
|
||||
)
|
||||
from framework.graph.executor import GraphExecutor # noqa: E402
|
||||
from framework.graph.goal import Goal # noqa: E402
|
||||
from framework.graph.node import NodeSpec # noqa: E402
|
||||
from framework.graph.node import NodeContext, NodeProtocol, NodeResult, NodeSpec # noqa: E402
|
||||
from framework.llm.litellm import LiteLLMProvider # noqa: E402
|
||||
from framework.runner.tool_registry import ToolRegistry # noqa: E402
|
||||
from framework.runtime.core import Runtime # noqa: E402
|
||||
@@ -654,7 +654,7 @@ NODE_SPECS = {
|
||||
id="sender",
|
||||
name="Sender",
|
||||
description="Send approved campaign emails",
|
||||
node_type="function",
|
||||
node_type="event_loop",
|
||||
input_keys=["approved_emails"],
|
||||
output_keys=["send_results"],
|
||||
),
|
||||
@@ -823,11 +823,20 @@ def _send_email_via_resend(
|
||||
return {"error": f"Network error: {e}"}
|
||||
|
||||
|
||||
class SenderNode(NodeProtocol):
|
||||
"""Node wrapper for send_emails function."""
|
||||
|
||||
async def execute(self, ctx: NodeContext) -> NodeResult:
|
||||
approved = ctx.input_data.get("approved_emails", "")
|
||||
result_str = send_emails(approved_emails=approved)
|
||||
ctx.memory.write("send_results", result_str)
|
||||
return NodeResult(success=True, output={"send_results": result_str})
|
||||
|
||||
|
||||
def send_emails(approved_emails: str = "") -> str:
|
||||
"""Send approved campaign emails via Resend, or log if unconfigured.
|
||||
|
||||
Called by FunctionNode which unpacks input_keys as kwargs.
|
||||
Returns a JSON string (FunctionNode wraps it in NodeResult).
|
||||
Returns a JSON string.
|
||||
"""
|
||||
approved = approved_emails
|
||||
if not approved:
|
||||
@@ -1759,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),
|
||||
@@ -1780,7 +1789,7 @@ async def _run_pipeline(websocket, initial_message: str):
|
||||
)
|
||||
for nid, impl in nodes.items():
|
||||
executor.register_node(nid, impl)
|
||||
executor.register_function("sender", send_emails)
|
||||
executor.register_node("sender", SenderNode())
|
||||
|
||||
# --- Event forwarding: bus → WebSocket ---
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,8 +4,8 @@ Minimal Manual Agent Example
|
||||
This example demonstrates how to build and run an agent programmatically
|
||||
without using the Claude Code CLI or external LLM APIs.
|
||||
|
||||
It uses 'function' nodes to define logic in pure Python, making it perfect
|
||||
for understanding the core runtime loop:
|
||||
It uses custom NodeProtocol implementations to define logic in pure Python,
|
||||
making it perfect for understanding the core runtime loop:
|
||||
Setup -> Graph definition -> Execution -> Result
|
||||
|
||||
Run with:
|
||||
@@ -16,22 +16,33 @@ import asyncio
|
||||
|
||||
from framework.graph import EdgeCondition, EdgeSpec, Goal, GraphSpec, NodeSpec
|
||||
from framework.graph.executor import GraphExecutor
|
||||
from framework.graph.node import NodeContext, NodeProtocol, NodeResult
|
||||
from framework.runtime.core import Runtime
|
||||
|
||||
|
||||
# 1. Define Node Logic (Pure Python Functions)
|
||||
def greet(name: str) -> str:
|
||||
# 1. Define Node Logic (Custom NodeProtocol implementations)
|
||||
class GreeterNode(NodeProtocol):
|
||||
"""Generate a simple greeting."""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
async def execute(self, ctx: NodeContext) -> NodeResult:
|
||||
name = ctx.input_data.get("name", "World")
|
||||
greeting = f"Hello, {name}!"
|
||||
ctx.memory.write("greeting", greeting)
|
||||
return NodeResult(success=True, output={"greeting": greeting})
|
||||
|
||||
|
||||
def uppercase(greeting: str) -> str:
|
||||
class UppercaserNode(NodeProtocol):
|
||||
"""Convert text to uppercase."""
|
||||
return greeting.upper()
|
||||
|
||||
async def execute(self, ctx: NodeContext) -> NodeResult:
|
||||
greeting = ctx.input_data.get("greeting") or ctx.memory.read("greeting") or ""
|
||||
result = greeting.upper()
|
||||
ctx.memory.write("final_greeting", result)
|
||||
return NodeResult(success=True, output={"final_greeting": result})
|
||||
|
||||
|
||||
async def main():
|
||||
print("🚀 Setting up Manual Agent...")
|
||||
print("Setting up Manual Agent...")
|
||||
|
||||
# 2. Define the Goal
|
||||
# Every agent needs a goal with success criteria
|
||||
@@ -55,8 +66,7 @@ async def main():
|
||||
id="greeter",
|
||||
name="Greeter",
|
||||
description="Generates a simple greeting",
|
||||
node_type="function",
|
||||
function="greet", # Matches the registered function name
|
||||
node_type="event_loop",
|
||||
input_keys=["name"],
|
||||
output_keys=["greeting"],
|
||||
)
|
||||
@@ -65,8 +75,7 @@ async def main():
|
||||
id="uppercaser",
|
||||
name="Uppercaser",
|
||||
description="Converts greeting to uppercase",
|
||||
node_type="function",
|
||||
function="uppercase",
|
||||
node_type="event_loop",
|
||||
input_keys=["greeting"],
|
||||
output_keys=["final_greeting"],
|
||||
)
|
||||
@@ -98,23 +107,23 @@ async def main():
|
||||
runtime = Runtime(storage_path=Path("./agent_logs"))
|
||||
executor = GraphExecutor(runtime=runtime)
|
||||
|
||||
# 7. Register Function Implementations
|
||||
# Connect string names in NodeSpecs to actual Python functions
|
||||
executor.register_function("greeter", greet)
|
||||
executor.register_function("uppercaser", uppercase)
|
||||
# 7. Register Node Implementations
|
||||
# Connect node IDs in the graph to actual Python implementations
|
||||
executor.register_node("greeter", GreeterNode())
|
||||
executor.register_node("uppercaser", UppercaserNode())
|
||||
|
||||
# 8. Execute Agent
|
||||
print("▶ Executing agent with input: name='Alice'...")
|
||||
print("Executing agent with input: name='Alice'...")
|
||||
|
||||
result = await executor.execute(graph=graph, goal=goal, input_data={"name": "Alice"})
|
||||
|
||||
# 9. Verify Results
|
||||
if result.success:
|
||||
print("\n✅ Success!")
|
||||
print("\nSuccess!")
|
||||
print(f"Path taken: {' -> '.join(result.path)}")
|
||||
print(f"Final output: {result.output.get('final_greeting')}")
|
||||
else:
|
||||
print(f"\n❌ Failed: {result.error}")
|
||||
print(f"\nFailed: {result.error}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -122,7 +122,7 @@ async def example_4_custom_agent_with_mcp_tools():
|
||||
node_id="web-searcher",
|
||||
name="Web Search",
|
||||
description="Search the web for information",
|
||||
node_type="llm_tool_use",
|
||||
node_type="event_loop",
|
||||
system_prompt="Search for {query} and return the top results. Use the web_search tool.",
|
||||
tools=["web_search"], # This tool comes from tools MCP server
|
||||
input_keys=["query"],
|
||||
@@ -133,7 +133,7 @@ async def example_4_custom_agent_with_mcp_tools():
|
||||
node_id="summarizer",
|
||||
name="Summarize Results",
|
||||
description="Summarize the search results",
|
||||
node_type="llm_generate",
|
||||
node_type="event_loop",
|
||||
system_prompt="Summarize the following search results in 2-3 sentences: {search_results}",
|
||||
input_keys=["search_results"],
|
||||
output_keys=["summary"],
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"name": "tools",
|
||||
"description": "Aden tools including web search, file operations, and PDF reading",
|
||||
"transport": "stdio",
|
||||
"command": "python",
|
||||
"args": ["mcp_server.py", "--stdio"],
|
||||
"command": "uv",
|
||||
"args": ["run", "python", "mcp_server.py", "--stdio"],
|
||||
"cwd": "../tools",
|
||||
"env": {
|
||||
"BRAVE_SEARCH_API_KEY": "${BRAVE_SEARCH_API_KEY}"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Framework-provided agents."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
FRAMEWORK_AGENTS_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def list_framework_agents() -> list[Path]:
|
||||
"""List all framework agent directories."""
|
||||
return sorted(
|
||||
[p for p in FRAMEWORK_AGENTS_DIR.iterdir() if p.is_dir() and (p / "agent.py").exists()],
|
||||
key=lambda p: p.name,
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Credential Tester — verify credentials (Aden OAuth + local API keys) via live API calls.
|
||||
|
||||
Interactive agent that lists all testable accounts, lets the user pick one,
|
||||
loads the provider's tools, and runs a chat session to test the credential.
|
||||
"""
|
||||
|
||||
from .agent import (
|
||||
CredentialTesterAgent,
|
||||
_list_aden_accounts,
|
||||
_list_env_fallback_accounts,
|
||||
_list_local_accounts,
|
||||
configure_for_account,
|
||||
conversation_mode,
|
||||
edges,
|
||||
entry_node,
|
||||
entry_points,
|
||||
get_tools_for_provider,
|
||||
goal,
|
||||
identity_prompt,
|
||||
list_connected_accounts,
|
||||
loop_config,
|
||||
nodes,
|
||||
pause_nodes,
|
||||
requires_account_selection,
|
||||
skip_credential_validation,
|
||||
terminal_nodes,
|
||||
)
|
||||
from .config import default_config
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
__all__ = [
|
||||
"CredentialTesterAgent",
|
||||
"configure_for_account",
|
||||
"conversation_mode",
|
||||
"default_config",
|
||||
"edges",
|
||||
"entry_node",
|
||||
"entry_points",
|
||||
"get_tools_for_provider",
|
||||
"goal",
|
||||
"identity_prompt",
|
||||
"list_connected_accounts",
|
||||
"loop_config",
|
||||
"nodes",
|
||||
"pause_nodes",
|
||||
"requires_account_selection",
|
||||
"skip_credential_validation",
|
||||
"terminal_nodes",
|
||||
# Internal list helpers (exposed for testing)
|
||||
"_list_aden_accounts",
|
||||
"_list_local_accounts",
|
||||
"_list_env_fallback_accounts",
|
||||
]
|
||||
@@ -0,0 +1,148 @@
|
||||
"""CLI entry point for Credential Tester agent."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
from .agent import CredentialTesterAgent
|
||||
|
||||
|
||||
def setup_logging(verbose=False, debug=False):
|
||||
if debug:
|
||||
level, fmt = logging.DEBUG, "%(asctime)s %(name)s: %(message)s"
|
||||
elif verbose:
|
||||
level, fmt = logging.INFO, "%(message)s"
|
||||
else:
|
||||
level, fmt = logging.WARNING, "%(levelname)s: %(message)s"
|
||||
logging.basicConfig(level=level, format=fmt, stream=sys.stderr)
|
||||
|
||||
|
||||
def pick_account(agent: CredentialTesterAgent) -> dict | None:
|
||||
"""Interactive account picker. Returns selected account dict or None."""
|
||||
accounts = agent.list_accounts()
|
||||
if not accounts:
|
||||
click.echo("No connected accounts found.")
|
||||
click.echo("Set ADEN_API_KEY and connect accounts at https://app.adenhq.com")
|
||||
return None
|
||||
|
||||
click.echo("\nConnected accounts:\n")
|
||||
for i, acct in enumerate(accounts, 1):
|
||||
provider = acct.get("provider", "?")
|
||||
alias = acct.get("alias", "?")
|
||||
identity = acct.get("identity", {})
|
||||
detail_parts = [f"{k}: {v}" for k, v in identity.items() if v]
|
||||
detail = f" ({', '.join(detail_parts)})" if detail_parts else ""
|
||||
click.echo(f" {i}. {provider}/{alias}{detail}")
|
||||
|
||||
click.echo()
|
||||
while True:
|
||||
choice = click.prompt("Pick an account to test", type=int, default=1)
|
||||
if 1 <= choice <= len(accounts):
|
||||
return accounts[choice - 1]
|
||||
click.echo(f"Invalid choice. Enter 1-{len(accounts)}.")
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version="1.0.0")
|
||||
def cli():
|
||||
"""Credential Tester — verify synced credentials via live API calls."""
|
||||
pass
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--verbose", "-v", is_flag=True)
|
||||
@click.option("--debug", is_flag=True)
|
||||
def tui(verbose, debug):
|
||||
"""Launch TUI to test a credential interactively."""
|
||||
setup_logging(verbose=verbose, debug=debug)
|
||||
|
||||
try:
|
||||
from framework.tui.app import AdenTUI
|
||||
except ImportError:
|
||||
click.echo("TUI requires 'textual'. Install with: pip install textual")
|
||||
sys.exit(1)
|
||||
|
||||
agent = CredentialTesterAgent()
|
||||
account = pick_account(agent)
|
||||
if account is None:
|
||||
sys.exit(1)
|
||||
|
||||
agent.select_account(account)
|
||||
provider = account.get("provider", "?")
|
||||
alias = account.get("alias", "?")
|
||||
click.echo(f"\nTesting {provider}/{alias}...\n")
|
||||
|
||||
async def run_tui():
|
||||
agent._setup()
|
||||
runtime = agent._agent_runtime
|
||||
await runtime.start()
|
||||
try:
|
||||
app = AdenTUI(runtime)
|
||||
await app.run_async()
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
asyncio.run(run_tui())
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--verbose", "-v", is_flag=True)
|
||||
@click.option("--debug", is_flag=True)
|
||||
def shell(verbose, debug):
|
||||
"""Interactive CLI session to test a credential."""
|
||||
setup_logging(verbose=verbose, debug=debug)
|
||||
asyncio.run(_interactive_shell(verbose))
|
||||
|
||||
|
||||
async def _interactive_shell(verbose=False):
|
||||
agent = CredentialTesterAgent()
|
||||
account = pick_account(agent)
|
||||
if account is None:
|
||||
return
|
||||
|
||||
agent.select_account(account)
|
||||
provider = account.get("provider", "?")
|
||||
alias = account.get("alias", "?")
|
||||
|
||||
click.echo(f"\nTesting {provider}/{alias}")
|
||||
click.echo("Type your requests or 'quit' to exit.\n")
|
||||
|
||||
await agent.start()
|
||||
|
||||
try:
|
||||
result = await agent._agent_runtime.trigger_and_wait(
|
||||
entry_point_id="start",
|
||||
input_data={},
|
||||
)
|
||||
if result:
|
||||
click.echo(f"\nSession ended: {'success' if result.success else result.error}")
|
||||
except KeyboardInterrupt:
|
||||
click.echo("\nGoodbye!")
|
||||
finally:
|
||||
await agent.stop()
|
||||
|
||||
|
||||
@cli.command(name="list")
|
||||
def list_accounts():
|
||||
"""List all connected accounts."""
|
||||
agent = CredentialTesterAgent()
|
||||
accounts = agent.list_accounts()
|
||||
|
||||
if not accounts:
|
||||
click.echo("No connected accounts found.")
|
||||
return
|
||||
|
||||
click.echo("\nConnected accounts:\n")
|
||||
for acct in accounts:
|
||||
provider = acct.get("provider", "?")
|
||||
alias = acct.get("alias", "?")
|
||||
identity = acct.get("identity", {})
|
||||
detail_parts = [f"{k}: {v}" for k, v in identity.items() if v]
|
||||
detail = f" ({', '.join(detail_parts)})" if detail_parts else ""
|
||||
click.echo(f" {provider}/{alias}{detail}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -0,0 +1,621 @@
|
||||
"""Credential Tester agent — verify credentials via live API calls.
|
||||
|
||||
Supports both Aden OAuth2-synced accounts AND locally-stored API key accounts.
|
||||
Aden accounts use account="alias" routing; local accounts inject the key into
|
||||
the session environment so tools read it without an account= parameter.
|
||||
|
||||
When loaded via AgentRunner.load() (TUI picker, ``hive run``), the module-level
|
||||
``nodes`` / ``edges`` variables provide a static graph. The TUI detects
|
||||
``requires_account_selection`` and shows an account picker *before* starting
|
||||
the agent. ``configure_for_account()`` then scopes the node's tools to the
|
||||
selected provider.
|
||||
|
||||
When used directly (``CredentialTesterAgent``), the graph is built dynamically
|
||||
after the user picks an account programmatically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from framework.graph import Goal, NodeSpec, SuccessCriterion
|
||||
from framework.graph.checkpoint_config import CheckpointConfig
|
||||
from framework.graph.edge import GraphSpec
|
||||
from framework.graph.executor import ExecutionResult
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.runner.tool_registry import ToolRegistry
|
||||
from framework.runtime.agent_runtime import AgentRuntime, create_agent_runtime
|
||||
from framework.runtime.execution_stream import EntryPointSpec
|
||||
|
||||
from .config import default_config
|
||||
from .nodes import build_tester_node
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from framework.runner import AgentRunner
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Goal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
goal = Goal(
|
||||
id="credential-tester",
|
||||
name="Credential Tester",
|
||||
description="Verify that a credential can make real API calls.",
|
||||
success_criteria=[
|
||||
SuccessCriterion(
|
||||
id="api-call-success",
|
||||
description="At least one API call succeeds using the credential",
|
||||
metric="api_call_success",
|
||||
target="true",
|
||||
weight=1.0,
|
||||
),
|
||||
],
|
||||
constraints=[],
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_tools_for_provider(provider_name: str) -> list[str]:
|
||||
"""Collect tool names for a credential by credential_id OR credential_group.
|
||||
|
||||
Matches on both ``credential_id`` (e.g. "google" → Gmail tools) and
|
||||
``credential_group`` (e.g. "google_custom_search" → all google search tools).
|
||||
"""
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
|
||||
tools: list[str] = []
|
||||
for spec in CREDENTIAL_SPECS.values():
|
||||
if spec.credential_id == provider_name or spec.credential_group == provider_name:
|
||||
tools.extend(spec.tools)
|
||||
return sorted(set(tools))
|
||||
|
||||
|
||||
def _list_aden_accounts() -> list[dict]:
|
||||
"""List active accounts from the Aden platform (requires ADEN_API_KEY)."""
|
||||
import os
|
||||
|
||||
api_key = os.environ.get("ADEN_API_KEY")
|
||||
if not api_key:
|
||||
return []
|
||||
|
||||
try:
|
||||
from framework.credentials.aden.client import AdenClientConfig, AdenCredentialClient
|
||||
|
||||
client = AdenCredentialClient(
|
||||
AdenClientConfig(
|
||||
base_url=os.environ.get("ADEN_API_URL", "https://api.adenhq.com"),
|
||||
)
|
||||
)
|
||||
try:
|
||||
integrations = client.list_integrations()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
return [
|
||||
{
|
||||
"provider": c.provider,
|
||||
"alias": c.alias,
|
||||
"identity": {"email": c.email} if c.email else {},
|
||||
"integration_id": c.integration_id,
|
||||
"source": "aden",
|
||||
}
|
||||
for c in integrations
|
||||
if c.status == "active"
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _list_local_accounts() -> list[dict]:
|
||||
"""List named local API key accounts from LocalCredentialRegistry."""
|
||||
try:
|
||||
from framework.credentials.local.registry import LocalCredentialRegistry
|
||||
|
||||
return [
|
||||
info.to_account_dict() for info in LocalCredentialRegistry.default().list_accounts()
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _list_env_fallback_accounts() -> list[dict]:
|
||||
"""Surface configured-but-unregistered credentials as testable entries.
|
||||
|
||||
Detects credentials available via env vars OR stored in the encrypted
|
||||
store in the old flat format (e.g. ``brave_search`` with no alias).
|
||||
These are users who haven't yet run ``save_account()`` but have a working key.
|
||||
Shows with alias="default" and status="unknown".
|
||||
"""
|
||||
import os
|
||||
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
|
||||
# Collect IDs in encrypted store (includes old flat entries like "brave_search")
|
||||
try:
|
||||
from framework.credentials.storage import EncryptedFileStorage
|
||||
|
||||
encrypted_ids: set[str] = set(EncryptedFileStorage().list_all())
|
||||
except Exception:
|
||||
encrypted_ids = set()
|
||||
|
||||
def _is_configured(cred_name: str, spec) -> bool:
|
||||
# 1. Env var present
|
||||
if os.environ.get(spec.env_var):
|
||||
return True
|
||||
# 2. Old flat encrypted entry (no slash — new entries have {x}/{y})
|
||||
if cred_name in encrypted_ids:
|
||||
return True
|
||||
return False
|
||||
|
||||
seen_groups: set[str] = set()
|
||||
accounts: list[dict] = []
|
||||
|
||||
for cred_name, spec in CREDENTIAL_SPECS.items():
|
||||
if not spec.direct_api_key_supported or not spec.tools:
|
||||
continue
|
||||
|
||||
if spec.credential_group:
|
||||
if spec.credential_group in seen_groups:
|
||||
continue
|
||||
group_available = all(
|
||||
_is_configured(n, s)
|
||||
for n, s in CREDENTIAL_SPECS.items()
|
||||
if s.credential_group == spec.credential_group
|
||||
)
|
||||
if not group_available:
|
||||
continue
|
||||
seen_groups.add(spec.credential_group)
|
||||
provider = spec.credential_group
|
||||
else:
|
||||
if not _is_configured(cred_name, spec):
|
||||
continue
|
||||
provider = cred_name
|
||||
|
||||
accounts.append(
|
||||
{
|
||||
"provider": provider,
|
||||
"alias": "default",
|
||||
"identity": {},
|
||||
"integration_id": None,
|
||||
"source": "local",
|
||||
"status": "unknown",
|
||||
}
|
||||
)
|
||||
|
||||
return accounts
|
||||
|
||||
|
||||
def list_connected_accounts() -> list[dict]:
|
||||
"""List all testable accounts: Aden-synced + named local + env-var fallbacks."""
|
||||
aden = _list_aden_accounts()
|
||||
local = _list_local_accounts()
|
||||
|
||||
# Show env-var fallbacks only for credentials not already in the named registry
|
||||
local_providers = {a["provider"] for a in local}
|
||||
env_fallbacks = [
|
||||
a for a in _list_env_fallback_accounts() if a["provider"] not in local_providers
|
||||
]
|
||||
|
||||
return aden + local + env_fallbacks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level hooks (read by AgentRunner.load / TUI)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
skip_credential_validation = True
|
||||
"""Don't validate credentials at load time — we don't know which provider yet."""
|
||||
|
||||
requires_account_selection = True
|
||||
"""Signal TUI to show account picker before starting the agent."""
|
||||
|
||||
|
||||
def configure_for_account(runner: AgentRunner, account: dict) -> None:
|
||||
"""Scope the tester node's tools to the selected provider.
|
||||
|
||||
Handles both Aden accounts (account= routing) and local accounts
|
||||
(session-level env var injection, no account= parameter in prompt).
|
||||
"""
|
||||
provider = account["provider"]
|
||||
source = account.get("source", "aden")
|
||||
alias = account.get("alias", "unknown")
|
||||
identity = account.get("identity", {})
|
||||
tools = get_tools_for_provider(provider)
|
||||
|
||||
if source == "aden":
|
||||
tools.append("get_account_info")
|
||||
email = identity.get("email", "")
|
||||
detail = f" (email: {email})" if email else ""
|
||||
_configure_aden_node(runner, provider, alias, detail, tools)
|
||||
else:
|
||||
status = account.get("status", "unknown")
|
||||
_activate_local_account(provider, alias)
|
||||
_configure_local_node(runner, provider, alias, identity, tools, status)
|
||||
|
||||
|
||||
def _activate_local_account(credential_id: str, alias: str) -> None:
|
||||
"""Inject a named local account's key into the session environment.
|
||||
|
||||
Handles three cases:
|
||||
1. Named account in LocalCredentialRegistry (new format: {credential_id}/{alias})
|
||||
2. Old flat credential in EncryptedFileStorage (id == credential_id, no alias)
|
||||
3. Env var already set — skip injection (nothing to do)
|
||||
"""
|
||||
import os
|
||||
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
|
||||
# Collect specs for this credential (handles grouped credentials too)
|
||||
group_specs = [
|
||||
(cred_name, spec)
|
||||
for cred_name, spec in CREDENTIAL_SPECS.items()
|
||||
if spec.credential_group == credential_id
|
||||
or spec.credential_id == credential_id
|
||||
or cred_name == credential_id
|
||||
]
|
||||
# Deduplicate — credential_id and credential_group may both match the same spec
|
||||
seen_env_vars: set[str] = set()
|
||||
|
||||
try:
|
||||
from framework.credentials.local.registry import LocalCredentialRegistry
|
||||
from framework.credentials.storage import EncryptedFileStorage
|
||||
|
||||
registry = LocalCredentialRegistry.default()
|
||||
flat_storage = EncryptedFileStorage()
|
||||
|
||||
for _cred_name, spec in group_specs:
|
||||
if spec.env_var in seen_env_vars:
|
||||
continue
|
||||
# If env var is already set, nothing to do for this one
|
||||
if os.environ.get(spec.env_var):
|
||||
seen_env_vars.add(spec.env_var)
|
||||
continue
|
||||
|
||||
seen_env_vars.add(spec.env_var)
|
||||
|
||||
# Determine key name based on spec
|
||||
key_name = "api_key"
|
||||
if spec.credential_group and "cse" in spec.env_var.lower():
|
||||
key_name = "cse_id"
|
||||
|
||||
key: str | None = None
|
||||
|
||||
# 1. Try named account in registry (new format)
|
||||
if alias != "default":
|
||||
key = registry.get_key(credential_id, alias, key_name)
|
||||
else:
|
||||
# For "default" alias, check registry first, then fall back to flat store
|
||||
key = registry.get_key(credential_id, "default", key_name)
|
||||
|
||||
# 2. Fall back to old flat encrypted entry (id == credential_id, no alias)
|
||||
if key is None:
|
||||
flat_cred = flat_storage.load(credential_id)
|
||||
if flat_cred is not None:
|
||||
key = flat_cred.get_key(key_name) or flat_cred.get_default_key()
|
||||
|
||||
if key:
|
||||
os.environ[spec.env_var] = key
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _configure_aden_node(
|
||||
runner: AgentRunner,
|
||||
provider: str,
|
||||
alias: str,
|
||||
detail: str,
|
||||
tools: list[str],
|
||||
) -> None:
|
||||
for node in runner.graph.nodes:
|
||||
if node.id == "tester":
|
||||
node.tools = sorted(set(tools))
|
||||
node.system_prompt = f"""\
|
||||
You are a credential tester for the account: {provider}/{alias}{detail}
|
||||
|
||||
# Instructions
|
||||
|
||||
1. Suggest a simple read-only API call to verify the credential works \
|
||||
(e.g. list messages, list channels, list contacts).
|
||||
2. Execute the call when the user agrees.
|
||||
3. Report the result: success (with sample data) or failure (with error).
|
||||
4. Let the user request additional API calls to further test the credential.
|
||||
|
||||
# Account routing
|
||||
|
||||
IMPORTANT: Always pass `account="{alias}"` when calling any tool. \
|
||||
This routes the API call to the correct credential. Never use the email \
|
||||
or any other identifier — always use the alias exactly as shown.
|
||||
|
||||
# Rules
|
||||
|
||||
- Start with read-only operations (list, get) before write operations.
|
||||
- Always confirm with the user before performing write operations.
|
||||
- If a call fails, report the exact error — this helps diagnose credential issues.
|
||||
- Be concise. No emojis.
|
||||
"""
|
||||
break
|
||||
|
||||
runner.intro_message = (
|
||||
f"Testing {provider}/{alias}{detail} — "
|
||||
f"{len(tools)} tools loaded. "
|
||||
"I'll suggest a read-only API call to verify the credential works."
|
||||
)
|
||||
|
||||
|
||||
def _configure_local_node(
|
||||
runner: AgentRunner,
|
||||
provider: str,
|
||||
alias: str,
|
||||
identity: dict,
|
||||
tools: list[str],
|
||||
status: str,
|
||||
) -> None:
|
||||
identity_parts = [f"{k}: {v}" for k, v in identity.items() if v]
|
||||
detail = f" ({', '.join(identity_parts)})" if identity_parts else ""
|
||||
status_note = " [key not yet validated]" if status == "unknown" else ""
|
||||
|
||||
for node in runner.graph.nodes:
|
||||
if node.id == "tester":
|
||||
node.tools = sorted(set(tools))
|
||||
node.system_prompt = f"""\
|
||||
You are a credential tester for the local API key: {provider}/{alias}{detail}{status_note}
|
||||
|
||||
# Instructions
|
||||
|
||||
1. Suggest a simple test call to verify the credential works \
|
||||
(e.g. search for "test", list items, get profile info).
|
||||
2. Execute the call when the user agrees.
|
||||
3. Report the result: success (with sample data) or failure (with error).
|
||||
4. Let the user request additional API calls to further test the credential.
|
||||
|
||||
# Rules
|
||||
|
||||
- Do NOT pass an `account` parameter — this credential is injected \
|
||||
directly into the session environment and tools read it automatically.
|
||||
- Start with read-only operations before write operations.
|
||||
- Always confirm with the user before performing write operations.
|
||||
- If a call fails, report the exact error — this helps diagnose credential issues.
|
||||
- Be concise. No emojis.
|
||||
"""
|
||||
break
|
||||
|
||||
runner.intro_message = (
|
||||
f"Testing {provider}/{alias}{detail} — "
|
||||
f"{len(tools)} tools loaded. "
|
||||
"I'll suggest a test API call to verify the credential works."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level graph variables (read by AgentRunner.load)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
nodes = [
|
||||
NodeSpec(
|
||||
id="tester",
|
||||
name="Credential Tester",
|
||||
description=(
|
||||
"Interactive credential testing — lets the user pick an account "
|
||||
"and verify it via API calls."
|
||||
),
|
||||
node_type="event_loop",
|
||||
client_facing=True,
|
||||
max_node_visits=0,
|
||||
input_keys=[],
|
||||
output_keys=[],
|
||||
tools=["get_account_info"],
|
||||
system_prompt="""\
|
||||
You are a credential tester. Your job is to help the user verify that their \
|
||||
connected accounts and API keys can make real API calls.
|
||||
|
||||
# Startup
|
||||
|
||||
1. Call ``get_account_info`` to list the user's connected accounts.
|
||||
2. Present the list and ask the user which account to test.
|
||||
3. Once they pick one, note the account's **alias** (e.g. "Timothy", "work-slack").
|
||||
4. Suggest a simple read-only API call to verify the credential works \
|
||||
(e.g. list messages, list channels, list contacts).
|
||||
5. Execute the call when the user agrees.
|
||||
6. Report the result: success (with sample data) or failure (with error).
|
||||
7. Let the user request additional API calls to further test the credential.
|
||||
|
||||
# Account routing (Aden accounts only)
|
||||
|
||||
IMPORTANT: For Aden-synced accounts, always pass the account's **alias** as the \
|
||||
``account`` parameter when calling any tool. For local API key accounts, do NOT \
|
||||
pass an account parameter — they are pre-injected into the session.
|
||||
|
||||
# Rules
|
||||
|
||||
- Start with read-only operations (list, get) before write operations.
|
||||
- Always confirm with the user before performing write operations.
|
||||
- If a call fails, report the exact error — this helps diagnose credential issues.
|
||||
- Be concise. No emojis.
|
||||
""",
|
||||
),
|
||||
]
|
||||
|
||||
edges = []
|
||||
|
||||
entry_node = "tester"
|
||||
entry_points = {"start": "tester"}
|
||||
pause_nodes = []
|
||||
terminal_nodes = [] # Forever-alive: loops until user exits
|
||||
|
||||
conversation_mode = "continuous"
|
||||
identity_prompt = (
|
||||
"You are a credential tester that verifies connected accounts and API keys "
|
||||
"can make real API calls."
|
||||
)
|
||||
loop_config = {
|
||||
"max_iterations": 50,
|
||||
"max_tool_calls_per_turn": 30,
|
||||
"max_history_tokens": 32000,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Programmatic agent class (used by __main__.py CLI)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CredentialTesterAgent:
|
||||
"""Interactive agent that tests a specific credential via API calls.
|
||||
|
||||
Usage:
|
||||
agent = CredentialTesterAgent()
|
||||
accounts = agent.list_accounts()
|
||||
agent.select_account(accounts[0])
|
||||
await agent.start()
|
||||
await agent.stop()
|
||||
"""
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = config or default_config
|
||||
self._selected_account: dict | None = None
|
||||
self._agent_runtime: AgentRuntime | None = None
|
||||
self._tool_registry: ToolRegistry | None = None
|
||||
self._storage_path: Path | None = None
|
||||
|
||||
def list_accounts(self) -> list[dict]:
|
||||
"""List all testable accounts (Aden + local named + env-var fallbacks)."""
|
||||
return list_connected_accounts()
|
||||
|
||||
def select_account(self, account: dict) -> None:
|
||||
"""Select an account to test.
|
||||
|
||||
Args:
|
||||
account: Account dict from list_accounts() with
|
||||
provider, alias, identity, source keys.
|
||||
"""
|
||||
self._selected_account = account
|
||||
|
||||
@property
|
||||
def selected_provider(self) -> str:
|
||||
if self._selected_account is None:
|
||||
raise RuntimeError("No account selected. Call select_account() first.")
|
||||
return self._selected_account["provider"]
|
||||
|
||||
@property
|
||||
def selected_alias(self) -> str:
|
||||
if self._selected_account is None:
|
||||
raise RuntimeError("No account selected. Call select_account() first.")
|
||||
return self._selected_account.get("alias", "unknown")
|
||||
|
||||
def _build_graph(self) -> GraphSpec:
|
||||
provider = self.selected_provider
|
||||
alias = self.selected_alias
|
||||
source = self._selected_account.get("source", "aden")
|
||||
identity = self._selected_account.get("identity", {})
|
||||
tools = get_tools_for_provider(provider)
|
||||
|
||||
if source == "local":
|
||||
_activate_local_account(provider, alias)
|
||||
elif source == "aden":
|
||||
tools.append("get_account_info")
|
||||
|
||||
tester_node = build_tester_node(
|
||||
provider=provider,
|
||||
alias=alias,
|
||||
tools=tools,
|
||||
identity=identity,
|
||||
source=source,
|
||||
)
|
||||
|
||||
return GraphSpec(
|
||||
id="credential-tester-graph",
|
||||
goal_id=goal.id,
|
||||
version="1.0.0",
|
||||
entry_node="tester",
|
||||
entry_points={"start": "tester"},
|
||||
terminal_nodes=[],
|
||||
pause_nodes=[],
|
||||
nodes=[tester_node],
|
||||
edges=[],
|
||||
default_model=self.config.model,
|
||||
max_tokens=self.config.max_tokens,
|
||||
loop_config={
|
||||
"max_iterations": 50,
|
||||
"max_tool_calls_per_turn": 30,
|
||||
"max_history_tokens": 32000,
|
||||
},
|
||||
conversation_mode="continuous",
|
||||
identity_prompt=(
|
||||
f"You are testing the {provider}/{alias} credential. "
|
||||
"Help the user verify it works by making real API calls."
|
||||
),
|
||||
)
|
||||
|
||||
def _setup(self) -> None:
|
||||
if self._selected_account is None:
|
||||
raise RuntimeError("No account selected. Call select_account() first.")
|
||||
|
||||
self._storage_path = Path.home() / ".hive" / "agents" / "credential_tester"
|
||||
self._storage_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._tool_registry = ToolRegistry()
|
||||
|
||||
mcp_config_path = Path(__file__).parent / "mcp_servers.json"
|
||||
if mcp_config_path.exists():
|
||||
self._tool_registry.load_mcp_config(mcp_config_path)
|
||||
|
||||
extra_kwargs = getattr(self.config, "extra_kwargs", {}) or {}
|
||||
llm = LiteLLMProvider(
|
||||
model=self.config.model,
|
||||
api_key=self.config.api_key,
|
||||
api_base=self.config.api_base,
|
||||
**extra_kwargs,
|
||||
)
|
||||
|
||||
tool_executor = self._tool_registry.get_executor()
|
||||
tools = list(self._tool_registry.get_tools().values())
|
||||
|
||||
graph = self._build_graph()
|
||||
|
||||
self._agent_runtime = create_agent_runtime(
|
||||
graph=graph,
|
||||
goal=goal,
|
||||
storage_path=self._storage_path,
|
||||
entry_points=[
|
||||
EntryPointSpec(
|
||||
id="start",
|
||||
name="Test Credential",
|
||||
entry_node="tester",
|
||||
trigger_type="manual",
|
||||
isolation_level="isolated",
|
||||
),
|
||||
],
|
||||
llm=llm,
|
||||
tools=tools,
|
||||
tool_executor=tool_executor,
|
||||
checkpoint_config=CheckpointConfig(enabled=False),
|
||||
graph_id="credential_tester",
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Set up and start the agent runtime."""
|
||||
if self._agent_runtime is None:
|
||||
self._setup()
|
||||
if not self._agent_runtime.is_running:
|
||||
await self._agent_runtime.start()
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the agent runtime."""
|
||||
if self._agent_runtime and self._agent_runtime.is_running:
|
||||
await self._agent_runtime.stop()
|
||||
self._agent_runtime = None
|
||||
|
||||
async def run(self) -> ExecutionResult:
|
||||
"""Run the agent (convenience for single execution)."""
|
||||
await self.start()
|
||||
try:
|
||||
result = await self._agent_runtime.trigger_and_wait(
|
||||
entry_point_id="start",
|
||||
input_data={},
|
||||
)
|
||||
return result or ExecutionResult(success=False, error="Execution timeout")
|
||||
finally:
|
||||
await self.stop()
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Runtime configuration for Credential Tester agent."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from framework.config import RuntimeConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentMetadata:
|
||||
name: str = "Credential Tester"
|
||||
version: str = "1.0.0"
|
||||
description: str = (
|
||||
"Test connected accounts by making real API calls. "
|
||||
"Pick an account, verify credentials work, and explore available tools."
|
||||
)
|
||||
|
||||
|
||||
metadata = AgentMetadata()
|
||||
default_config = RuntimeConfig(temperature=0.3)
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"hive-tools": {
|
||||
"transport": "stdio",
|
||||
"command": "uv",
|
||||
"args": ["run", "python", "mcp_server.py", "--stdio"],
|
||||
"cwd": "../../../../tools",
|
||||
"description": "Hive tools MCP server with provider-specific tools"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Node definitions for Credential Tester agent."""
|
||||
|
||||
from framework.graph import NodeSpec
|
||||
|
||||
|
||||
def build_tester_node(
|
||||
provider: str,
|
||||
alias: str,
|
||||
tools: list[str],
|
||||
identity: dict[str, str],
|
||||
source: str = "aden",
|
||||
) -> NodeSpec:
|
||||
"""Build the tester node dynamically for the selected account.
|
||||
|
||||
Args:
|
||||
provider: Provider / credential name (e.g. "google", "brave_search").
|
||||
alias: User-set alias (e.g. "Timothy", "work").
|
||||
tools: Tool names available for this provider.
|
||||
identity: Identity dict (email, workspace, etc.) for context.
|
||||
source: "aden" or "local" — controls routing instructions in the prompt.
|
||||
"""
|
||||
detail_parts = [f"{k}: {v}" for k, v in identity.items() if v]
|
||||
detail = f" ({', '.join(detail_parts)})" if detail_parts else ""
|
||||
|
||||
if source == "aden":
|
||||
routing_section = f"""\
|
||||
# Account routing
|
||||
|
||||
IMPORTANT: Always pass `account="{alias}"` when calling any tool. \
|
||||
This routes the API call to the correct credential. Never use the email \
|
||||
or any other identifier — always use the alias exactly as shown.
|
||||
"""
|
||||
else:
|
||||
routing_section = """\
|
||||
# Credential routing
|
||||
|
||||
This is a local API key credential — do NOT pass an `account` parameter. \
|
||||
The key is pre-injected into the session environment and tools read it automatically.
|
||||
"""
|
||||
|
||||
account_label = "account" if source == "aden" else "local API key"
|
||||
|
||||
return NodeSpec(
|
||||
id="tester",
|
||||
name="Credential Tester",
|
||||
description=(
|
||||
f"Interactive testing node for {provider}/{alias}. "
|
||||
f"Has access to all {provider} tools to verify the credential works."
|
||||
),
|
||||
node_type="event_loop",
|
||||
client_facing=True,
|
||||
max_node_visits=0,
|
||||
input_keys=[],
|
||||
output_keys=[],
|
||||
tools=tools,
|
||||
system_prompt=f"""\
|
||||
You are a credential tester for the {account_label}: {provider}/{alias}{detail}
|
||||
|
||||
Your job is to help the user verify that this credential works by making \
|
||||
real API calls using the available tools.
|
||||
|
||||
{routing_section}
|
||||
# Instructions
|
||||
|
||||
1. Start by greeting the user and confirming which account you're testing.
|
||||
2. Suggest a simple, safe, read-only API call to verify the credential works \
|
||||
(e.g. list messages, list channels, list contacts, search for "test").
|
||||
3. Execute the call when the user agrees.
|
||||
4. Report the result clearly: success (with sample data) or failure (with error).
|
||||
5. Let the user request additional API calls to further test the credential.
|
||||
|
||||
# Available tools
|
||||
|
||||
You have access to {len(tools)} tools for {provider}:
|
||||
{chr(10).join(f"- {t}" for t in tools)}
|
||||
|
||||
# Rules
|
||||
|
||||
- Start with read-only operations (list, get) before write operations (create, update, delete).
|
||||
- Always confirm with the user before performing write operations.
|
||||
- If a call fails, report the exact error — this helps diagnose credential issues.
|
||||
- Be concise. No emojis.
|
||||
""",
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Hive Coder — Native coding agent that builds Hive agent packages.
|
||||
|
||||
Deeply understands the agent framework and produces complete Python packages
|
||||
with goals, nodes, edges, system prompts, MCP configuration, and tests
|
||||
from natural language specifications.
|
||||
"""
|
||||
|
||||
from .agent import (
|
||||
HiveCoderAgent,
|
||||
conversation_mode,
|
||||
default_agent,
|
||||
edges,
|
||||
entry_node,
|
||||
entry_points,
|
||||
goal,
|
||||
identity_prompt,
|
||||
loop_config,
|
||||
nodes,
|
||||
pause_nodes,
|
||||
terminal_nodes,
|
||||
)
|
||||
from .config import AgentMetadata, RuntimeConfig, default_config, metadata
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
__all__ = [
|
||||
"HiveCoderAgent",
|
||||
"default_agent",
|
||||
"goal",
|
||||
"nodes",
|
||||
"edges",
|
||||
"entry_node",
|
||||
"entry_points",
|
||||
"pause_nodes",
|
||||
"terminal_nodes",
|
||||
"conversation_mode",
|
||||
"identity_prompt",
|
||||
"loop_config",
|
||||
"RuntimeConfig",
|
||||
"AgentMetadata",
|
||||
"default_config",
|
||||
"metadata",
|
||||
]
|
||||
@@ -0,0 +1,223 @@
|
||||
"""CLI entry point for Hive Coder agent."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
from .agent import HiveCoderAgent, default_agent
|
||||
|
||||
|
||||
def setup_logging(verbose=False, debug=False):
|
||||
"""Configure logging for execution visibility."""
|
||||
if debug:
|
||||
level, fmt = logging.DEBUG, "%(asctime)s %(name)s: %(message)s"
|
||||
elif verbose:
|
||||
level, fmt = logging.INFO, "%(message)s"
|
||||
else:
|
||||
level, fmt = logging.WARNING, "%(levelname)s: %(message)s"
|
||||
logging.basicConfig(level=level, format=fmt, stream=sys.stderr)
|
||||
logging.getLogger("framework").setLevel(level)
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version="1.0.0")
|
||||
def cli():
|
||||
"""Hive Coder — Build Hive agent packages from natural language."""
|
||||
pass
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--request", "-r", type=str, required=True, help="What agent to build")
|
||||
@click.option("--mock", is_flag=True, help="Run in mock mode")
|
||||
@click.option("--quiet", "-q", is_flag=True, help="Only output result JSON")
|
||||
@click.option("--verbose", "-v", is_flag=True, help="Show execution details")
|
||||
@click.option("--debug", is_flag=True, help="Show debug logging")
|
||||
def run(request, mock, quiet, verbose, debug):
|
||||
"""Execute agent building from a request."""
|
||||
if not quiet:
|
||||
setup_logging(verbose=verbose, debug=debug)
|
||||
|
||||
context = {"user_request": request}
|
||||
|
||||
result = asyncio.run(default_agent.run(context, mock_mode=mock))
|
||||
|
||||
output_data = {
|
||||
"success": result.success,
|
||||
"steps_executed": result.steps_executed,
|
||||
"output": result.output,
|
||||
}
|
||||
if result.error:
|
||||
output_data["error"] = result.error
|
||||
|
||||
click.echo(json.dumps(output_data, indent=2, default=str))
|
||||
sys.exit(0 if result.success else 1)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--mock", is_flag=True, help="Run in mock mode")
|
||||
@click.option("--verbose", "-v", is_flag=True, help="Show execution details")
|
||||
@click.option("--debug", is_flag=True, help="Show debug logging")
|
||||
def tui(mock, verbose, debug):
|
||||
"""Launch the TUI dashboard for interactive agent building."""
|
||||
setup_logging(verbose=verbose, debug=debug)
|
||||
|
||||
try:
|
||||
from framework.tui.app import AdenTUI
|
||||
except ImportError:
|
||||
click.echo("TUI requires the 'textual' package. Install with: pip install textual")
|
||||
sys.exit(1)
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.runner.tool_registry import ToolRegistry
|
||||
from framework.runtime.agent_runtime import create_agent_runtime
|
||||
from framework.runtime.execution_stream import EntryPointSpec
|
||||
|
||||
async def run_with_tui():
|
||||
agent = HiveCoderAgent()
|
||||
|
||||
agent._tool_registry = ToolRegistry()
|
||||
|
||||
storage_path = Path.home() / ".hive" / "agents" / "hive_coder"
|
||||
storage_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
mcp_config_path = Path(__file__).parent / "mcp_servers.json"
|
||||
if mcp_config_path.exists():
|
||||
agent._tool_registry.load_mcp_config(mcp_config_path)
|
||||
|
||||
llm = None
|
||||
if not mock:
|
||||
llm = LiteLLMProvider(
|
||||
model=agent.config.model,
|
||||
api_key=agent.config.api_key,
|
||||
api_base=agent.config.api_base,
|
||||
)
|
||||
|
||||
tools = list(agent._tool_registry.get_tools().values())
|
||||
tool_executor = agent._tool_registry.get_executor()
|
||||
graph = agent._build_graph()
|
||||
|
||||
runtime = create_agent_runtime(
|
||||
graph=graph,
|
||||
goal=agent.goal,
|
||||
storage_path=storage_path,
|
||||
entry_points=[
|
||||
EntryPointSpec(
|
||||
id="start",
|
||||
name="Build Agent",
|
||||
entry_node="coder",
|
||||
trigger_type="manual",
|
||||
isolation_level="isolated",
|
||||
),
|
||||
],
|
||||
llm=llm,
|
||||
tools=tools,
|
||||
tool_executor=tool_executor,
|
||||
)
|
||||
|
||||
await runtime.start()
|
||||
|
||||
try:
|
||||
app = AdenTUI(runtime)
|
||||
await app.run_async()
|
||||
finally:
|
||||
await runtime.stop()
|
||||
|
||||
asyncio.run(run_with_tui())
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--json", "output_json", is_flag=True)
|
||||
def info(output_json):
|
||||
"""Show agent information."""
|
||||
info_data = default_agent.info()
|
||||
if output_json:
|
||||
click.echo(json.dumps(info_data, indent=2))
|
||||
else:
|
||||
click.echo(f"Agent: {info_data['name']}")
|
||||
click.echo(f"Version: {info_data['version']}")
|
||||
click.echo(f"Description: {info_data['description']}")
|
||||
click.echo(f"\nNodes: {', '.join(info_data['nodes'])}")
|
||||
click.echo(f"Client-facing: {', '.join(info_data['client_facing_nodes'])}")
|
||||
click.echo(f"Entry: {info_data['entry_node']}")
|
||||
click.echo(f"Terminal: {', '.join(info_data['terminal_nodes']) or '(forever-alive)'}")
|
||||
|
||||
|
||||
@cli.command()
|
||||
def validate():
|
||||
"""Validate agent structure."""
|
||||
validation = default_agent.validate()
|
||||
if validation["valid"]:
|
||||
click.echo("Agent is valid")
|
||||
if validation["warnings"]:
|
||||
for warning in validation["warnings"]:
|
||||
click.echo(f" WARNING: {warning}")
|
||||
else:
|
||||
click.echo("Agent has errors:")
|
||||
for error in validation["errors"]:
|
||||
click.echo(f" ERROR: {error}")
|
||||
sys.exit(0 if validation["valid"] else 1)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--verbose", "-v", is_flag=True)
|
||||
def shell(verbose):
|
||||
"""Interactive agent building session (CLI, no TUI)."""
|
||||
asyncio.run(_interactive_shell(verbose))
|
||||
|
||||
|
||||
async def _interactive_shell(verbose=False):
|
||||
"""Async interactive shell."""
|
||||
setup_logging(verbose=verbose)
|
||||
|
||||
click.echo("=== Hive Coder ===")
|
||||
click.echo("Describe the agent you want to build (or 'quit' to exit):\n")
|
||||
|
||||
agent = HiveCoderAgent()
|
||||
await agent.start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
request = await asyncio.get_event_loop().run_in_executor(None, input, "Build> ")
|
||||
if request.lower() in ["quit", "exit", "q"]:
|
||||
click.echo("Goodbye!")
|
||||
break
|
||||
|
||||
if not request.strip():
|
||||
continue
|
||||
|
||||
click.echo("\nBuilding agent...\n")
|
||||
|
||||
result = await agent.trigger_and_wait("default", {"user_request": request})
|
||||
|
||||
if result is None:
|
||||
click.echo("\n[Execution timed out]\n")
|
||||
continue
|
||||
|
||||
if result.success:
|
||||
output = result.output or {}
|
||||
agent_name = output.get("agent_name", "unknown")
|
||||
validation = output.get("validation_result", "unknown")
|
||||
click.echo(f"\nAgent '{agent_name}' built. Validation: {validation}\n")
|
||||
else:
|
||||
click.echo(f"\nBuild failed: {result.error}\n")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
click.echo("\nGoodbye!")
|
||||
break
|
||||
except Exception as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
await agent.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Agent graph construction for Hive Coder."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from framework.graph import Constraint, Goal, SuccessCriterion
|
||||
from framework.graph.checkpoint_config import CheckpointConfig
|
||||
from framework.graph.edge import GraphSpec
|
||||
from framework.graph.executor import ExecutionResult
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.runner.tool_registry import ToolRegistry
|
||||
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 coder_node, queen_node
|
||||
|
||||
# ticket_receiver is no longer needed — the queen runs as an independent
|
||||
# GraphExecutor and receives escalation tickets via inject_event().
|
||||
# Keeping the import commented for reference:
|
||||
# from .ticket_receiver import TICKET_RECEIVER_ENTRY_POINT
|
||||
|
||||
# Goal definition
|
||||
goal = Goal(
|
||||
id="agent-builder",
|
||||
name="Hive Agent Builder",
|
||||
description=(
|
||||
"Build complete, validated Hive agent packages from natural language "
|
||||
"specifications. Produces production-ready Python packages with goals, "
|
||||
"nodes, edges, system prompts, MCP configuration, and tests."
|
||||
),
|
||||
success_criteria=[
|
||||
SuccessCriterion(
|
||||
id="valid-package",
|
||||
description="Generated agent package passes structural validation",
|
||||
metric="validation_pass",
|
||||
target="true",
|
||||
weight=0.30,
|
||||
),
|
||||
SuccessCriterion(
|
||||
id="complete-files",
|
||||
description=(
|
||||
"All required files generated: agent.py, config.py, "
|
||||
"nodes/__init__.py, __init__.py, __main__.py, mcp_servers.json"
|
||||
),
|
||||
metric="file_count",
|
||||
target=">=6",
|
||||
weight=0.25,
|
||||
),
|
||||
SuccessCriterion(
|
||||
id="user-satisfaction",
|
||||
description="User reviews and approves the generated agent",
|
||||
metric="user_approval",
|
||||
target="true",
|
||||
weight=0.25,
|
||||
),
|
||||
SuccessCriterion(
|
||||
id="framework-compliance",
|
||||
description=(
|
||||
"Generated code follows framework patterns: STEP 1/STEP 2 "
|
||||
"for client-facing, correct imports, entry_points format"
|
||||
),
|
||||
metric="pattern_compliance",
|
||||
target="100%",
|
||||
weight=0.20,
|
||||
),
|
||||
],
|
||||
constraints=[
|
||||
Constraint(
|
||||
id="dynamic-tool-discovery",
|
||||
description=(
|
||||
"Always discover available tools dynamically via "
|
||||
"list_agent_tools before referencing tools in agent designs"
|
||||
),
|
||||
constraint_type="hard",
|
||||
category="correctness",
|
||||
),
|
||||
Constraint(
|
||||
id="no-fabricated-tools",
|
||||
description="Only reference tools that exist in hive-tools MCP",
|
||||
constraint_type="hard",
|
||||
category="correctness",
|
||||
),
|
||||
Constraint(
|
||||
id="valid-python",
|
||||
description="All generated Python files must be syntactically correct",
|
||||
constraint_type="hard",
|
||||
category="correctness",
|
||||
),
|
||||
Constraint(
|
||||
id="self-verification",
|
||||
description="Run validation after writing code; fix errors before presenting",
|
||||
constraint_type="hard",
|
||||
category="quality",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# Nodes: primary coder node only. The queen runs as an independent
|
||||
# GraphExecutor with queen_node — not as part of this graph.
|
||||
nodes = [coder_node]
|
||||
|
||||
# No edges needed — single forever-alive event_loop node
|
||||
edges = []
|
||||
|
||||
# Graph configuration
|
||||
entry_node = "coder"
|
||||
entry_points = {"start": "coder"}
|
||||
pause_nodes = []
|
||||
terminal_nodes = [] # Forever-alive: loops until user exits
|
||||
|
||||
# No async entry points needed — the queen is now an independent executor,
|
||||
# not a secondary graph receiving events via add_graph().
|
||||
async_entry_points = []
|
||||
|
||||
# Module-level variables read by AgentRunner.load()
|
||||
conversation_mode = "continuous"
|
||||
identity_prompt = (
|
||||
"You are Hive Coder, the best agent-building coding agent on the planet. "
|
||||
"You deeply understand the Hive agent framework at the source code level "
|
||||
"and produce production-ready agent packages from natural language. "
|
||||
"You can dynamically discover available framework tools, inspect runtime "
|
||||
"sessions and checkpoints from agents you build, and run their test suites. "
|
||||
"You follow coding agent discipline: read before writing, verify "
|
||||
"assumptions by reading actual code, adhere to project conventions, "
|
||||
"self-verify with validation, and fix your own errors. You are concise, "
|
||||
"direct, and technically rigorous. No emojis. No fluff."
|
||||
)
|
||||
loop_config = {
|
||||
"max_iterations": 100,
|
||||
"max_tool_calls_per_turn": 30,
|
||||
"max_history_tokens": 32000,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Queen graph — runs as an independent persistent conversation in the TUI.
|
||||
# Loaded by _load_judge_and_queen() in app.py, NOT by AgentRunner.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
queen_goal = Goal(
|
||||
id="queen-manager",
|
||||
name="Queen Manager",
|
||||
description=(
|
||||
"Manage the worker agent lifecycle and serve as the user's primary "
|
||||
"interactive interface. Triage health escalations from the judge."
|
||||
),
|
||||
success_criteria=[],
|
||||
constraints=[],
|
||||
)
|
||||
|
||||
queen_graph = GraphSpec(
|
||||
id="queen-graph",
|
||||
goal_id=queen_goal.id,
|
||||
version="1.0.0",
|
||||
entry_node="queen",
|
||||
entry_points={"start": "queen"},
|
||||
terminal_nodes=[],
|
||||
pause_nodes=[],
|
||||
nodes=[queen_node],
|
||||
edges=[],
|
||||
conversation_mode="continuous",
|
||||
loop_config={
|
||||
"max_iterations": 999_999,
|
||||
"max_tool_calls_per_turn": 30,
|
||||
"max_history_tokens": 32000,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class HiveCoderAgent:
|
||||
"""
|
||||
Hive Coder — builds Hive agent packages from natural language.
|
||||
|
||||
Single-node architecture: the coder runs in a continuous while(true) loop.
|
||||
The queen runs as an independent GraphExecutor (loaded by the TUI via
|
||||
_load_judge_and_queen), not as part of this graph.
|
||||
"""
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = config or default_config
|
||||
self.goal = goal
|
||||
self.nodes = nodes
|
||||
self.edges = edges
|
||||
self.entry_node = entry_node
|
||||
self.entry_points = entry_points
|
||||
self.pause_nodes = pause_nodes
|
||||
self.terminal_nodes = terminal_nodes
|
||||
self.async_entry_points = async_entry_points
|
||||
self._graph: GraphSpec | None = None
|
||||
self._agent_runtime: AgentRuntime | None = None
|
||||
self._tool_registry: ToolRegistry | None = None
|
||||
self._storage_path: Path | None = None
|
||||
|
||||
def _build_graph(self) -> GraphSpec:
|
||||
"""Build the GraphSpec."""
|
||||
return GraphSpec(
|
||||
id="hive-coder-graph",
|
||||
goal_id=self.goal.id,
|
||||
version="1.0.0",
|
||||
entry_node=self.entry_node,
|
||||
entry_points=self.entry_points,
|
||||
terminal_nodes=self.terminal_nodes,
|
||||
pause_nodes=self.pause_nodes,
|
||||
nodes=self.nodes,
|
||||
edges=self.edges,
|
||||
default_model=self.config.model,
|
||||
max_tokens=self.config.max_tokens,
|
||||
loop_config=loop_config,
|
||||
conversation_mode=conversation_mode,
|
||||
identity_prompt=identity_prompt,
|
||||
async_entry_points=self.async_entry_points,
|
||||
)
|
||||
|
||||
def _setup(self, mock_mode=False) -> None:
|
||||
"""Set up the agent runtime."""
|
||||
self._storage_path = Path.home() / ".hive" / "agents" / "hive_coder"
|
||||
self._storage_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._tool_registry = ToolRegistry()
|
||||
|
||||
mcp_config_path = Path(__file__).parent / "mcp_servers.json"
|
||||
if mcp_config_path.exists():
|
||||
self._tool_registry.load_mcp_config(mcp_config_path)
|
||||
|
||||
llm = None
|
||||
if not mock_mode:
|
||||
llm = LiteLLMProvider(
|
||||
model=self.config.model,
|
||||
api_key=self.config.api_key,
|
||||
api_base=self.config.api_base,
|
||||
)
|
||||
|
||||
tool_executor = self._tool_registry.get_executor()
|
||||
tools = list(self._tool_registry.get_tools().values())
|
||||
|
||||
self._graph = self._build_graph()
|
||||
|
||||
checkpoint_config = CheckpointConfig(
|
||||
enabled=True,
|
||||
checkpoint_on_node_start=False,
|
||||
checkpoint_on_node_complete=True,
|
||||
checkpoint_max_age_days=7,
|
||||
async_checkpoint=True,
|
||||
)
|
||||
|
||||
entry_point_specs = [
|
||||
EntryPointSpec(
|
||||
id="default",
|
||||
name="Default",
|
||||
entry_node=self.entry_node,
|
||||
trigger_type="manual",
|
||||
isolation_level="shared",
|
||||
),
|
||||
]
|
||||
|
||||
self._agent_runtime = create_agent_runtime(
|
||||
graph=self._graph,
|
||||
goal=self.goal,
|
||||
storage_path=self._storage_path,
|
||||
entry_points=entry_point_specs,
|
||||
llm=llm,
|
||||
tools=tools,
|
||||
tool_executor=tool_executor,
|
||||
checkpoint_config=checkpoint_config,
|
||||
graph_id="hive_coder",
|
||||
)
|
||||
|
||||
async def start(self, mock_mode=False) -> None:
|
||||
"""Set up and start the agent runtime."""
|
||||
if self._agent_runtime is None:
|
||||
self._setup(mock_mode=mock_mode)
|
||||
if not self._agent_runtime.is_running:
|
||||
await self._agent_runtime.start()
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the agent runtime and clean up."""
|
||||
if self._agent_runtime and self._agent_runtime.is_running:
|
||||
await self._agent_runtime.stop()
|
||||
self._agent_runtime = None
|
||||
|
||||
async def trigger_and_wait(
|
||||
self,
|
||||
entry_point: str = "default",
|
||||
input_data: dict | None = None,
|
||||
timeout: float | None = None,
|
||||
session_state: dict | None = None,
|
||||
) -> ExecutionResult | None:
|
||||
"""Execute the graph and wait for completion."""
|
||||
if self._agent_runtime is None:
|
||||
raise RuntimeError("Agent not started. Call start() first.")
|
||||
|
||||
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: dict, mock_mode=False, session_state=None) -> ExecutionResult:
|
||||
"""Run the agent (convenience method for single execution)."""
|
||||
await self.start(mock_mode=mock_mode)
|
||||
try:
|
||||
result = await self.trigger_and_wait("default", context, session_state=session_state)
|
||||
return result or ExecutionResult(success=False, error="Execution timeout")
|
||||
finally:
|
||||
await self.stop()
|
||||
|
||||
def info(self):
|
||||
"""Get agent information."""
|
||||
return {
|
||||
"name": metadata.name,
|
||||
"version": metadata.version,
|
||||
"description": metadata.description,
|
||||
"goal": {
|
||||
"name": self.goal.name,
|
||||
"description": self.goal.description,
|
||||
},
|
||||
"nodes": [n.id for n in self.nodes],
|
||||
"edges": [e.id for e in self.edges],
|
||||
"entry_node": self.entry_node,
|
||||
"entry_points": self.entry_points,
|
||||
"pause_nodes": self.pause_nodes,
|
||||
"terminal_nodes": self.terminal_nodes,
|
||||
"client_facing_nodes": [n.id for n in self.nodes if n.client_facing],
|
||||
}
|
||||
|
||||
def validate(self):
|
||||
"""Validate agent structure."""
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
node_ids = {node.id for node in self.nodes}
|
||||
for edge in self.edges:
|
||||
if edge.source not in node_ids:
|
||||
errors.append(f"Edge {edge.id}: source '{edge.source}' not found")
|
||||
if edge.target not in node_ids:
|
||||
errors.append(f"Edge {edge.id}: target '{edge.target}' not found")
|
||||
|
||||
if self.entry_node not in node_ids:
|
||||
errors.append(f"Entry node '{self.entry_node}' not found")
|
||||
|
||||
for terminal in self.terminal_nodes:
|
||||
if terminal not in node_ids:
|
||||
errors.append(f"Terminal node '{terminal}' not found")
|
||||
|
||||
for ep_id, node_id in self.entry_points.items():
|
||||
if node_id not in node_ids:
|
||||
errors.append(f"Entry point '{ep_id}' references unknown node '{node_id}'")
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
# Create default instance
|
||||
default_agent = HiveCoderAgent()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Runtime configuration for Hive Coder agent."""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_preferred_model() -> str:
|
||||
"""Load preferred model from ~/.hive/configuration.json."""
|
||||
config_path = Path.home() / ".hive" / "configuration.json"
|
||||
if config_path.exists():
|
||||
try:
|
||||
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"):
|
||||
return f"{llm['provider']}/{llm['model']}"
|
||||
except Exception:
|
||||
pass
|
||||
return "anthropic/claude-sonnet-4-20250514"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeConfig:
|
||||
model: str = field(default_factory=_load_preferred_model)
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 8000
|
||||
api_key: str | None = None
|
||||
api_base: str | None = None
|
||||
|
||||
|
||||
default_config = RuntimeConfig()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentMetadata:
|
||||
name: str = "Hive Coder"
|
||||
version: str = "1.0.0"
|
||||
description: str = (
|
||||
"Native coding agent that builds production-ready Hive agent packages "
|
||||
"from natural language specifications. Deeply understands the agent framework "
|
||||
"and produces complete Python packages with goals, nodes, edges, system prompts, "
|
||||
"MCP configuration, and tests."
|
||||
)
|
||||
intro_message: str = (
|
||||
"I'm Hive Coder — I build Hive agents. Describe what kind of agent "
|
||||
"you want to create and I'll design, implement, and validate it for you."
|
||||
)
|
||||
|
||||
|
||||
metadata = AgentMetadata()
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"coder-tools": {
|
||||
"transport": "stdio",
|
||||
"command": "uv",
|
||||
"args": ["run", "python", "coder_tools_server.py", "--stdio"],
|
||||
"cwd": "../../../../tools",
|
||||
"description": "Unsandboxed file system tools for code generation and validation"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,961 @@
|
||||
"""Node definitions for Hive Coder agent."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from framework.graph import NodeSpec
|
||||
|
||||
# Load reference docs at import time so they're always in the system prompt.
|
||||
# No voluntary read_file() calls needed — the LLM gets everything upfront.
|
||||
_ref_dir = Path(__file__).parent.parent / "reference"
|
||||
_framework_guide = (_ref_dir / "framework_guide.md").read_text(encoding="utf-8")
|
||||
_file_templates = (_ref_dir / "file_templates.md").read_text(encoding="utf-8")
|
||||
_anti_patterns = (_ref_dir / "anti_patterns.md").read_text(encoding="utf-8")
|
||||
_gcu_guide_path = _ref_dir / "gcu_guide.md"
|
||||
_gcu_guide = _gcu_guide_path.read_text(encoding="utf-8") if _gcu_guide_path.exists() else ""
|
||||
|
||||
|
||||
def _is_gcu_enabled() -> bool:
|
||||
try:
|
||||
from framework.config import get_gcu_enabled
|
||||
|
||||
return get_gcu_enabled()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _build_appendices() -> str:
|
||||
parts = (
|
||||
"\n\n# Appendix: Framework Reference\n\n"
|
||||
+ _framework_guide
|
||||
+ "\n\n# Appendix: File Templates\n\n"
|
||||
+ _file_templates
|
||||
+ "\n\n# Appendix: Anti-Patterns\n\n"
|
||||
+ _anti_patterns
|
||||
)
|
||||
if _is_gcu_enabled() and _gcu_guide:
|
||||
parts += "\n\n# Appendix: GCU Browser Automation Guide\n\n" + _gcu_guide
|
||||
return parts
|
||||
|
||||
|
||||
# Shared appendices — appended to every coding node's system prompt.
|
||||
_appendices = _build_appendices()
|
||||
|
||||
# Tools available to both coder (worker) and queen.
|
||||
_SHARED_TOOLS = [
|
||||
# File I/O
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"list_directory",
|
||||
"search_files",
|
||||
"run_command",
|
||||
"undo_changes",
|
||||
# Meta-agent
|
||||
"list_agent_tools",
|
||||
"validate_agent_tools",
|
||||
"list_agents",
|
||||
"list_agent_sessions",
|
||||
"get_agent_session_state",
|
||||
"get_agent_session_memory",
|
||||
"list_agent_checkpoints",
|
||||
"get_agent_checkpoint",
|
||||
"run_agent_tests",
|
||||
]
|
||||
|
||||
# Queen mode-specific tool sets.
|
||||
# Building mode: full coding + agent construction tools.
|
||||
_QUEEN_BUILDING_TOOLS = _SHARED_TOOLS + [
|
||||
"load_built_agent",
|
||||
"list_credentials",
|
||||
]
|
||||
|
||||
# Staging mode: agent loaded but not yet running — inspect, configure, launch.
|
||||
_QUEEN_STAGING_TOOLS = [
|
||||
# Read-only (inspect agent files, logs)
|
||||
"read_file",
|
||||
"list_directory",
|
||||
"search_files",
|
||||
"run_command",
|
||||
# Agent inspection
|
||||
"list_credentials",
|
||||
"get_worker_status",
|
||||
# Launch or go back
|
||||
"run_agent_with_input",
|
||||
"stop_worker_and_edit",
|
||||
]
|
||||
|
||||
# Running mode: worker is executing — monitor and control.
|
||||
_QUEEN_RUNNING_TOOLS = [
|
||||
# Read-only coding (for inspecting logs, files)
|
||||
"read_file",
|
||||
"list_directory",
|
||||
"search_files",
|
||||
"run_command",
|
||||
# Credentials
|
||||
"list_credentials",
|
||||
# Worker lifecycle
|
||||
"stop_worker",
|
||||
"stop_worker_and_edit",
|
||||
"get_worker_status",
|
||||
"inject_worker_message",
|
||||
# Monitoring
|
||||
"get_worker_health_summary",
|
||||
"notify_operator",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared agent-building knowledge: core mandates, tool docs, meta-agent
|
||||
# capabilities, and workflow phases 1-6. Both the coder (worker) and
|
||||
# queen compose their system prompts from this block + role-specific
|
||||
# additions.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_agent_builder_knowledge = """\
|
||||
|
||||
# Core Mandates
|
||||
|
||||
- **Read before writing.** NEVER write code from assumptions. Read \
|
||||
reference agents and templates first. Read every file before editing.
|
||||
- **Conventions first.** Follow existing project patterns exactly. \
|
||||
Analyze imports, structure, and style in reference agents.
|
||||
- **Verify assumptions.** Never assume a class, import, or pattern \
|
||||
exists. Read actual source to confirm. Search if unsure.
|
||||
- **Discover tools dynamically.** NEVER reference tools from static \
|
||||
docs. Always run list_agent_tools() to see what actually exists.
|
||||
- **Professional objectivity.** If a use case is a poor fit for the \
|
||||
framework, say so. Technical accuracy over validation.
|
||||
- **Concise.** No emojis. No preambles. No postambles. Substance only.
|
||||
- **Self-verify.** After writing code, run validation and tests. Fix \
|
||||
errors yourself. Don't declare success until validation passes.
|
||||
|
||||
# Tools
|
||||
|
||||
## File I/O
|
||||
- read_file(path, offset?, limit?) — read with line numbers
|
||||
- write_file(path, content) — create/overwrite, auto-mkdir
|
||||
- edit_file(path, old_text, new_text, replace_all?) — fuzzy-match edit
|
||||
- list_directory(path, recursive?) — list contents
|
||||
- search_files(pattern, path?, include?) — regex search
|
||||
- run_command(command, cwd?, timeout?) — shell execution
|
||||
- undo_changes(path?) — restore from git snapshot
|
||||
|
||||
## Meta-Agent
|
||||
- list_agent_tools(server_config_path?, output_schema?, group?) — discover \
|
||||
available tools grouped by category. output_schema: "simple" (default) or \
|
||||
"full" (includes input_schema). group: "all" (default) or a prefix like \
|
||||
"gmail". Call FIRST before designing.
|
||||
- validate_agent_tools(agent_path) — validate that all tools declared \
|
||||
in an agent's nodes actually exist. Call after building.
|
||||
- list_agents() — list all agent packages in exports/ with session counts
|
||||
- list_agent_sessions(agent_name, status?, limit?) — list sessions
|
||||
- get_agent_session_state(agent_name, session_id) — full session state
|
||||
- get_agent_session_memory(agent_name, session_id, key?) — memory data
|
||||
- list_agent_checkpoints(agent_name, session_id) — list checkpoints
|
||||
- get_agent_checkpoint(agent_name, session_id, checkpoint_id?) — load checkpoint
|
||||
- run_agent_tests(agent_name, test_types?, fail_fast?) — run pytest with parsing
|
||||
|
||||
# Meta-Agent Capabilities
|
||||
|
||||
You are not just a file writer. You have deep integration with the \
|
||||
Hive framework:
|
||||
|
||||
## Tool Discovery (MANDATORY before designing)
|
||||
Before designing any agent, run list_agent_tools() to discover all \
|
||||
available tools. ONLY use tools from this list in your node definitions. \
|
||||
NEVER guess or fabricate tool names from memory.
|
||||
|
||||
list_agent_tools() # names + descriptions
|
||||
list_agent_tools(output_schema="full") # include input_schema
|
||||
list_agent_tools(group="gmail") # only gmail_* tools
|
||||
list_agent_tools("exports/{agent_name}/mcp_servers.json") # specific agent
|
||||
|
||||
## Agent Awareness
|
||||
Run list_agents() to see what agents already exist. Read their code \
|
||||
for patterns:
|
||||
read_file("exports/{name}/agent.py")
|
||||
read_file("exports/{name}/nodes/__init__.py")
|
||||
|
||||
## Post-Build Testing
|
||||
After writing agent code, validate structurally AND run tests:
|
||||
run_command("python -c 'from {name} import default_agent; \\
|
||||
print(default_agent.validate())'")
|
||||
run_agent_tests("{name}")
|
||||
|
||||
## Debugging Built Agents
|
||||
When a user says "my agent is failing" or "debug this agent":
|
||||
1. list_agent_sessions("{agent_name}") — find the session
|
||||
2. get_agent_session_state("{agent_name}", "{session_id}") — see status
|
||||
3. get_agent_session_memory("{agent_name}", "{session_id}") — inspect data
|
||||
4. list_agent_checkpoints / get_agent_checkpoint — trace execution
|
||||
|
||||
# Agent Building Workflow
|
||||
|
||||
You operate in a continuous loop. The user describes what they want, \
|
||||
you build it. No rigid phases — use judgment. But the general flow is:
|
||||
|
||||
## 1. Understand & Qualify (3-5 turns)
|
||||
|
||||
This is ONE conversation, not two phases. Discovery and qualification \
|
||||
happen together. Surface problems as you find them, not in a batch.
|
||||
|
||||
**Before your first response**, silently run list_agent_tools() and \
|
||||
consult the **Framework Reference** appendix. Know what's possible \
|
||||
before you speak.
|
||||
|
||||
### How to respond to the user's first message
|
||||
|
||||
**Listen like an architect.** While they talk, hear the structure:
|
||||
- **The actors**: Who are the people/systems involved?
|
||||
- **The trigger**: What kicks off the workflow?
|
||||
- **The core loop**: What's the main thing that happens repeatedly?
|
||||
- **The output**: What's the valuable thing produced?
|
||||
- **The pain**: What about today is broken, slow, or missing?
|
||||
|
||||
| They say... | You're hearing... |
|
||||
|-------------|-------------------|
|
||||
| Nouns they repeat | Your entities |
|
||||
| Verbs they emphasize | Your core operations |
|
||||
| Frustrations they mention | Your design constraints |
|
||||
| Workarounds they describe | What the system must replace |
|
||||
|
||||
**Use domain knowledge aggressively.** If they say "research agent," \
|
||||
you already know it involves search, summarization, source tracking, \
|
||||
iteration. Don't ask about each — use them as defaults and let their \
|
||||
specifics override. Merge your general knowledge with their specifics: \
|
||||
60-80% right before you ask a single question.
|
||||
|
||||
### Play back a model WITH qualification baked in
|
||||
|
||||
Don't separate "here's what I understood" from "here's what might be \
|
||||
a problem." Weave them together. Your playback should sound like:
|
||||
|
||||
"Here's how I'm picturing this: [concrete proposed solution]. \
|
||||
The framework handles [X and Y] well for this. [One concern: Z tool \
|
||||
doesn't exist, so we'd use W instead / Z would need real-time which \
|
||||
isn't a fit, but we could do polling]. For MVP I'd focus on \
|
||||
[highest-value thing]. Before I start — [1-2 questions]."
|
||||
|
||||
If there's a deal-breaker, lead with it: "Before I go further — \
|
||||
this needs [X] which the framework can't do because [Y]. We could \
|
||||
[workaround] or reconsider the approach. What do you think?"
|
||||
|
||||
**Surface problems immediately. Don't save them for a formal review.**
|
||||
|
||||
### Ask only what you CANNOT infer
|
||||
|
||||
Every question must earn its place by preventing a costly wrong turn, \
|
||||
unlocking a shortcut, or surfacing a dealbreaker.
|
||||
|
||||
Good questions: "Who's the primary user?", "Is this replacing \
|
||||
something or net new?", "Does this integrate with anything?"
|
||||
|
||||
Bad questions (DON'T ask): "What should happen on error?", "Should \
|
||||
it have search?", "What tools should I use?" — these are your job.
|
||||
|
||||
### Conversation flow
|
||||
|
||||
| Turn | Who | What |
|
||||
|------|-----|------|
|
||||
| 1 | User | Describes what they need |
|
||||
| 2 | You | Play back model with concerns baked in. 1-2 questions max. |
|
||||
| 3 | User | Corrects, confirms, or adds detail |
|
||||
| 4 | You | Adjust model, confirm scope, move to design |
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
| Don't | Do instead |
|
||||
|-------|------------|
|
||||
| Open with a list of questions | Open with what you understood |
|
||||
| Separate "assessment" dump | Weave concerns into your playback |
|
||||
| Good/Bad/Ugly formal section | Mention issues naturally in context |
|
||||
| Ask about every edge case | Smart defaults, flag in summary |
|
||||
| 10+ turn discovery | 3-5 turns, then start building |
|
||||
| Wait for certainty | Start at 80% confidence, iterate |
|
||||
| Ask what tech/tools to use | Decide, disclose, move on |
|
||||
|
||||
## 3. Design
|
||||
|
||||
Design the agent architecture:
|
||||
- Goal: id, name, description, 3-5 success criteria, 2-4 constraints
|
||||
- Nodes: **2-4 nodes MAXIMUM** (see rules below)
|
||||
- Edges: on_success for linear, conditional for routing
|
||||
- Lifecycle: ALWAYS forever-alive (`terminal_nodes=[]`) unless the user \
|
||||
explicitly requests a one-shot/batch agent. Forever-alive agents loop \
|
||||
continuously — the user exits by closing the TUI. This is the standard \
|
||||
pattern for all interactive agents.
|
||||
|
||||
### Node Design Rules
|
||||
|
||||
Each node boundary serializes outputs to shared memory \
|
||||
and DESTROYS all in-context information (tool results, reasoning, history). \
|
||||
Use as many nodes as the use case requires, but don't create nodes without \
|
||||
tools — merge them into nodes that do real work.
|
||||
|
||||
**MERGE nodes when:**
|
||||
- Node has NO tools (pure LLM reasoning) → merge into predecessor/successor
|
||||
- Node sets only 1 trivial output → collapse into predecessor
|
||||
- Multiple consecutive autonomous nodes → combine into one rich node
|
||||
- A "report" or "summary" node → merge into the client-facing node
|
||||
- A "confirm" or "schedule" node that calls no external service → remove
|
||||
|
||||
**SEPARATE nodes only when:**
|
||||
- Client-facing vs autonomous (different interaction models)
|
||||
- Fundamentally different tool sets
|
||||
- Fan-out parallelism (parallel branches MUST be separate)
|
||||
|
||||
**Typical patterns (queen manages intake — NO client-facing intake node):**
|
||||
- 2 nodes: `process (autonomous) → review (client-facing) → process`
|
||||
- 1 node: `process (autonomous)` — simplest; queen handles all interaction
|
||||
- WRONG: 7 nodes where half have no tools and just do LLM reasoning
|
||||
- WRONG: Intake node that asks the user for requirements — the queen does intake
|
||||
|
||||
Read reference agents before designing:
|
||||
list_agents()
|
||||
read_file("exports/deep_research_agent/agent.py")
|
||||
read_file("exports/deep_research_agent/nodes/__init__.py")
|
||||
|
||||
Present the design to the user. Lead with a large ASCII graph inside \
|
||||
a code block so it renders in monospace. Make it visually prominent — \
|
||||
use box-drawing characters and clear flow arrows:
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ process (autonomous) │
|
||||
│ in: user_request │
|
||||
│ tools: web_search, │
|
||||
│ save_data │
|
||||
└────────────┬────────────┘
|
||||
│ on_success
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ review (client-facing) │
|
||||
│ tools: set_output │
|
||||
└────────────┬────────────┘
|
||||
│ on_success
|
||||
└──────► back to process
|
||||
```
|
||||
|
||||
The queen owns intake: she gathers user requirements, then calls \
|
||||
`run_agent_with_input(task)` with a structured task description. \
|
||||
When building the agent, design the entry node's `input_keys` to \
|
||||
match what the queen will provide at run time. No client-facing \
|
||||
intake node in the worker.
|
||||
|
||||
Follow the graph with a brief summary of each node's purpose. \
|
||||
Get user approval before implementing.
|
||||
|
||||
## 4. Implement
|
||||
|
||||
Consult the **File Templates** and **Anti-Patterns** appendices below.
|
||||
|
||||
Write files in order:
|
||||
1. mkdir -p exports/{name}/nodes exports/{name}/tests
|
||||
2. config.py — RuntimeConfig + AgentMetadata
|
||||
3. nodes/__init__.py — NodeSpec definitions with system prompts
|
||||
4. agent.py — Goal, edges, graph, agent class
|
||||
5. __init__.py — package exports
|
||||
6. __main__.py — CLI with click
|
||||
7. mcp_servers.json — tool server config
|
||||
8. tests/ — fixtures
|
||||
|
||||
### Critical Rules
|
||||
|
||||
**Imports** (must match exactly — only import what you use):
|
||||
```python
|
||||
from framework.graph import (
|
||||
NodeSpec, EdgeSpec, EdgeCondition,
|
||||
Goal, SuccessCriterion, Constraint,
|
||||
)
|
||||
from framework.graph.edge import GraphSpec
|
||||
from framework.graph.executor import ExecutionResult
|
||||
from framework.graph.checkpoint_config import CheckpointConfig
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.runner.tool_registry import ToolRegistry
|
||||
from framework.runtime.agent_runtime import (
|
||||
AgentRuntime, create_agent_runtime,
|
||||
)
|
||||
from framework.runtime.execution_stream import EntryPointSpec
|
||||
```
|
||||
For agents with async entry points (timers, webhooks, events), also add:
|
||||
```python
|
||||
from framework.graph.edge import GraphSpec, AsyncEntryPointSpec
|
||||
from framework.runtime.agent_runtime import (
|
||||
AgentRuntime, AgentRuntimeConfig, create_agent_runtime,
|
||||
)
|
||||
```
|
||||
NEVER `from core.framework...` — PYTHONPATH includes core/.
|
||||
|
||||
**__init__.py MUST re-export ALL module-level variables** \
|
||||
(THIS IS THE #1 SOURCE OF AGENT LOAD FAILURES):
|
||||
The runner imports the package (__init__.py), NOT agent.py. It reads \
|
||||
goal, nodes, edges, entry_node, entry_points, pause_nodes, \
|
||||
terminal_nodes, conversation_mode, identity_prompt, loop_config via \
|
||||
getattr(). If ANY are missing from __init__.py, they silently default \
|
||||
to None or {} — causing "must define goal, nodes, edges" or "node X \
|
||||
is unreachable" errors. The __init__.py MUST import and re-export \
|
||||
ALL of these from .agent:
|
||||
```python
|
||||
from .agent import (
|
||||
MyAgent, default_agent, goal, nodes, edges,
|
||||
entry_node, entry_points, pause_nodes, terminal_nodes,
|
||||
conversation_mode, identity_prompt, loop_config,
|
||||
)
|
||||
```
|
||||
|
||||
**entry_points**: `{"start": "first-node-id"}`
|
||||
The first node should be an autonomous processing node (NOT a \
|
||||
client-facing intake). For agents with multiple entry points, \
|
||||
add them: `{"start": "process", "reminder": "check"}`
|
||||
|
||||
**conversation_mode** — ONLY two valid values:
|
||||
- `"continuous"` — recommended for interactive agents (context carries \
|
||||
across node transitions)
|
||||
- Omit entirely — for isolated per-node conversations
|
||||
NEVER use: "client_facing", "interactive", "adaptive", or any other \
|
||||
value. These DO NOT EXIST.
|
||||
|
||||
**loop_config** — ONLY three valid keys:
|
||||
```python
|
||||
loop_config = {
|
||||
"max_iterations": 100,
|
||||
"max_tool_calls_per_turn": 30,
|
||||
"max_history_tokens": 32000,
|
||||
}
|
||||
```
|
||||
NEVER add: "strategy", "mode", "timeout", or other keys.
|
||||
|
||||
**mcp_servers.json**:
|
||||
```json
|
||||
{
|
||||
"hive-tools": {
|
||||
"transport": "stdio",
|
||||
"command": "uv",
|
||||
"args": ["run", "python", "mcp_server.py", "--stdio"],
|
||||
"cwd": "../../tools"
|
||||
}
|
||||
}
|
||||
```
|
||||
NO "mcpServers" wrapper. cwd "../../tools". command "uv".
|
||||
|
||||
**Storage**: `Path.home() / ".hive" / "agents" / "{name}"`
|
||||
|
||||
**Client-facing system prompts** (review/approval nodes only, NOT intake) \
|
||||
— STEP 1/STEP 2 pattern:
|
||||
```
|
||||
STEP 1 — Present to user (text only, NO tool calls):
|
||||
[instructions]
|
||||
|
||||
STEP 2 — After user responds, call set_output:
|
||||
[set_output calls]
|
||||
```
|
||||
The queen manages intake. Workers should NOT have a client-facing node \
|
||||
that asks for requirements. Use client_facing=True only for review or \
|
||||
approval checkpoints mid-execution.
|
||||
|
||||
**Autonomous system prompts** — set_output in SEPARATE turn.
|
||||
|
||||
**Tools** — NEVER fabricate tool names. Common hallucinations: \
|
||||
csv_read, csv_write, csv_append, file_upload, database_query. \
|
||||
If list_agent_tools() shows these don't exist, use alternatives \
|
||||
(e.g. save_data/load_data for data persistence).
|
||||
|
||||
**Node rules**:
|
||||
- **NO intake nodes.** 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.
|
||||
- Don't abuse nodes without tools — merge them into a node that does work.
|
||||
- A node with 0 tools is NOT a real node — merge it.
|
||||
- node_type "event_loop" for all regular graph nodes. Use "gcu" ONLY for
|
||||
browser automation subagents (see GCU appendix). GCU nodes MUST be in a
|
||||
parent node's sub_agents list, NEVER connected via edges, and NEVER used
|
||||
as entry/terminal nodes.
|
||||
- max_node_visits default is 0 (unbounded) — correct for forever-alive. \
|
||||
Only set >0 in one-shot agents with bounded feedback loops.
|
||||
- Feedback inputs: nullable_output_keys
|
||||
- terminal_nodes=[] for forever-alive (the default)
|
||||
- Every node MUST have at least one outgoing edge (no dead ends)
|
||||
- Agents are forever-alive unless user explicitly asks for one-shot
|
||||
|
||||
**Agent class**: CamelCase name, default_agent at module level. \
|
||||
Constructor takes `config=None`. Follow the exact pattern in \
|
||||
file_templates.md — do NOT invent constructor params like \
|
||||
`llm_provider` or `tool_registry`.
|
||||
|
||||
**Module-level variables** (read by AgentRunner.load()):
|
||||
goal, nodes, edges, entry_node, entry_points, pause_nodes,
|
||||
terminal_nodes, conversation_mode, identity_prompt, loop_config
|
||||
|
||||
For agents with async triggers, also export:
|
||||
async_entry_points, runtime_config
|
||||
|
||||
**Async entry points** (timers, webhooks, events):
|
||||
When an agent needs scheduled tasks, webhook reactions, or event-driven \
|
||||
triggers, use `AsyncEntryPointSpec` (from framework.graph.edge) and \
|
||||
`AgentRuntimeConfig` (from framework.runtime.agent_runtime):
|
||||
- Timer (cron): `trigger_type="timer"`, \
|
||||
`trigger_config={"cron": "0 9 * * *"}` — standard 5-field cron expression \
|
||||
(e.g. `"0 9 * * MON-FRI"` weekdays 9am, `"*/30 * * * *"` every 30 min)
|
||||
- Timer (interval): `trigger_type="timer"`, \
|
||||
`trigger_config={"interval_minutes": 20, "run_immediately": False}`
|
||||
- Event (for webhooks): `trigger_type="event"`, \
|
||||
`trigger_config={"event_types": ["webhook_received"]}`
|
||||
- `isolation_level="shared"` so async runs can read primary session memory
|
||||
- `runtime_config = AgentRuntimeConfig(webhook_routes=[...])` for HTTP webhooks
|
||||
- Reference: `exports/gmail_inbox_guardian/agent.py`
|
||||
- Full docs: see **Framework Reference** appendix (Async Entry Points section)
|
||||
|
||||
## 5. Verify
|
||||
|
||||
Run FOUR validation steps after writing. All must pass:
|
||||
|
||||
**Step A — Class validation** (checks graph structure):
|
||||
```
|
||||
run_command("python -c 'from {name} import default_agent; \\
|
||||
print(default_agent.validate())'")
|
||||
```
|
||||
|
||||
**Step B — Runner load test** (checks package export contract — \
|
||||
THIS IS THE SAME PATH THE TUI USES):
|
||||
```
|
||||
run_command("python -c 'from framework.runner.runner import \\
|
||||
AgentRunner; r = AgentRunner.load(\"exports/{name}\"); \\
|
||||
print(\"AgentRunner.load: OK\")'")
|
||||
```
|
||||
This catches missing __init__.py exports, bad conversation_mode, \
|
||||
invalid loop_config, and unreachable nodes. If Step A passes but \
|
||||
Step B fails, the problem is in __init__.py exports.
|
||||
|
||||
**Step C — Tool validation** (checks that declared tools actually exist \
|
||||
in the agent's MCP servers — catches hallucinated tool names):
|
||||
```
|
||||
validate_agent_tools("exports/{name}")
|
||||
```
|
||||
If any tools are missing: fix the node definitions to use only tools \
|
||||
that exist. Run list_agent_tools() to see what's available.
|
||||
|
||||
**Step D — Run tests:**
|
||||
```
|
||||
run_agent_tests("{name}")
|
||||
```
|
||||
|
||||
If anything fails: read error, fix with edit_file, re-validate. Up to 3x.
|
||||
|
||||
**CRITICAL: Testing forever-alive agents**
|
||||
Most agents use `terminal_nodes=[]` (forever-alive). This means \
|
||||
`runner.run()` NEVER returns — it hangs forever waiting for a \
|
||||
terminal node that doesn't exist. Agent tests MUST be structural:
|
||||
- Validate graph, node specs, edges, tools, prompts
|
||||
- Check goal/constraints/success criteria definitions
|
||||
- Test `AgentRunner.load()` succeeds (structural, no API key needed)
|
||||
- NEVER call `runner.run()` or `trigger_and_wait()` in tests for \
|
||||
forever-alive agents — they will hang and time out.
|
||||
When you restructure an agent (change nodes/edges), always update \
|
||||
the tests to match. Stale tests referencing old node names will fail.
|
||||
|
||||
## 6. Present
|
||||
|
||||
Show the user what you built: agent name, goal summary, graph (same \
|
||||
ASCII style as Design), files created, validation status. Offer to \
|
||||
revise or build another.
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coder-specific: set_output after presentation + standalone phase 7
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_coder_completion = """
|
||||
After user confirms satisfaction:
|
||||
set_output("agent_name", "the_agent_name")
|
||||
set_output("validation_result", "valid")
|
||||
|
||||
If building another agent, just start the loop again — no need to \
|
||||
set_output until the user is done.
|
||||
|
||||
## 7. Live Test (optional)
|
||||
|
||||
After the user approves, offer to load and run the agent in-session.
|
||||
|
||||
If running with a queen (server/frontend):
|
||||
```
|
||||
load_built_agent("exports/{name}") # loads as the session worker
|
||||
```
|
||||
The frontend updates automatically — the user sees the agent's graph, \
|
||||
the tab renames, and you can delegate via start_worker(task).
|
||||
|
||||
If running standalone (TUI):
|
||||
```
|
||||
load_agent("exports/{name}") # registers as secondary graph
|
||||
start_agent("{name}") # triggers default entry point
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Queen-specific: extra tool docs, behavior, phase 7, style
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_queen_tools_docs = """
|
||||
|
||||
## Operating Modes
|
||||
|
||||
You operate in one of three modes. Your available tools change based on the \
|
||||
mode. The system notifies you when a mode change occurs.
|
||||
|
||||
### BUILDING mode (default)
|
||||
You have full coding tools for building and modifying agents:
|
||||
- File I/O: read_file, write_file, edit_file, list_directory, search_files, \
|
||||
run_command, undo_changes
|
||||
- Meta-agent: list_agent_tools, validate_agent_tools, \
|
||||
list_agents, list_agent_sessions, get_agent_session_state, get_agent_session_memory, \
|
||||
list_agent_checkpoints, get_agent_checkpoint, run_agent_tests
|
||||
- load_built_agent(agent_path) — Load the agent and switch to STAGING mode
|
||||
- list_credentials(credential_id?) — List authorized credentials
|
||||
|
||||
When you finish building an agent, call load_built_agent(path) to stage it.
|
||||
|
||||
### STAGING mode (agent loaded, not yet running)
|
||||
The agent is loaded and ready to run. You can inspect it and launch it:
|
||||
- Read-only: read_file, list_directory, search_files, run_command
|
||||
- list_credentials(credential_id?) — Verify credentials are configured
|
||||
- get_worker_status() — Check the loaded worker
|
||||
- run_agent_with_input(task) — Start the worker and switch to RUNNING mode
|
||||
- stop_worker_and_edit() — Go back to BUILDING mode
|
||||
|
||||
In STAGING mode you do NOT have write tools. If you need to modify the agent, \
|
||||
call stop_worker_and_edit() to go back to BUILDING mode.
|
||||
|
||||
### RUNNING mode (worker is executing)
|
||||
The worker is running. You have monitoring and lifecycle tools:
|
||||
- Read-only: read_file, list_directory, search_files, run_command
|
||||
- get_worker_status() — Check worker status (idle, running, waiting)
|
||||
- inject_worker_message(content) — Send a message to the running worker
|
||||
- get_worker_health_summary() — Read the latest health data
|
||||
- notify_operator(ticket_id, analysis, urgency) — Alert the user (use sparingly)
|
||||
- stop_worker() — Stop the worker and return to STAGING mode, then ask the user what to do next
|
||||
- stop_worker_and_edit() — Stop the worker and switch back to BUILDING mode
|
||||
|
||||
In RUNNING mode you do NOT have write tools or agent construction tools. \
|
||||
If you need to modify the agent, call stop_worker_and_edit() to switch back \
|
||||
to BUILDING mode. To stop the worker and ask the user what to do next, call \
|
||||
stop_worker() to return to STAGING mode.
|
||||
|
||||
### Mode transitions
|
||||
- load_built_agent(path) → switches to STAGING mode
|
||||
- run_agent_with_input(task) → starts worker, switches to RUNNING mode
|
||||
- stop_worker() → stops worker, switches to STAGING mode (ask user: re-run or edit?)
|
||||
- stop_worker_and_edit() → stops worker (if running), switches to BUILDING mode
|
||||
"""
|
||||
|
||||
_queen_behavior = """
|
||||
# Behavior
|
||||
|
||||
## CRITICAL RULE — ask_user tool
|
||||
|
||||
Every response that ends with a question, a prompt, or expects user \
|
||||
input MUST finish with a call to ask_user(prompt, options). This is \
|
||||
NON-NEGOTIABLE. The system CANNOT detect that you are waiting for \
|
||||
input unless you call ask_user. You MUST call ask_user as the LAST \
|
||||
action in your response.
|
||||
|
||||
NEVER end a response with a question in text without calling ask_user. \
|
||||
NEVER rely on the user seeing your text and replying — call ask_user.
|
||||
|
||||
Always provide 2-4 short options that cover the most likely answers. \
|
||||
The user can always type a custom response.
|
||||
|
||||
Examples:
|
||||
- ask_user("What do you need?",
|
||||
["Build a new agent", "Run the loaded worker", "Help with code"])
|
||||
- ask_user("Which pattern?",
|
||||
["Simple 2-node", "Rich with feedback", "Custom"])
|
||||
- ask_user("Ready to proceed?",
|
||||
["Yes, go ahead", "Let me change something"])
|
||||
|
||||
## Greeting and identity
|
||||
|
||||
When the user greets you or asks what you can do, respond concisely \
|
||||
(under 10 lines). DO NOT list internal processes. Focus on:
|
||||
1. Direct capabilities: coding, agent building & debugging.
|
||||
2. What the loaded worker does (one sentence from Worker Profile). \
|
||||
If no worker is loaded, say so.
|
||||
3. THEN call ask_user to prompt them — do NOT just write text.
|
||||
|
||||
## Direct coding
|
||||
You can do any coding task directly — reading files, writing code, running \
|
||||
commands, building agents, debugging. For quick tasks, do them yourself.
|
||||
|
||||
## Worker delegation
|
||||
The worker is a specialized agent (see Worker Profile at the end of this \
|
||||
prompt). It can ONLY do what its goal and tools allow.
|
||||
|
||||
**Decision rule — read the Worker Profile first:**
|
||||
- The user's request directly matches the worker's goal → use \
|
||||
run_agent_with_input(task) (if in staging) or load then run (if in building)
|
||||
- Anything else → do it yourself. Do NOT reframe user requests into \
|
||||
subtasks to justify delegation.
|
||||
- Building, modifying, or configuring agents is ALWAYS your job. Never \
|
||||
delegate agent construction to the worker, even as a "research" subtask.
|
||||
|
||||
## When the user says "run", "execute", or "start" (without specifics)
|
||||
|
||||
The loaded worker is described in the Worker Profile below. You MUST \
|
||||
ask the user what task or input they want using ask_user — do NOT \
|
||||
invent a task, do NOT call list_agents() or list directories. \
|
||||
The worker is already loaded. Just ask for the specific input the \
|
||||
worker needs (e.g., a research topic, a target domain, a job description). \
|
||||
NEVER call run_agent_with_input until the user has provided their input.
|
||||
|
||||
If NO worker is loaded, say so and offer to build one.
|
||||
|
||||
## When in staging mode (agent loaded, not running):
|
||||
- Tell the user the agent is loaded and ready.
|
||||
- For tasks matching the worker's goal: ALWAYS ask the user for their \
|
||||
specific input BEFORE calling run_agent_with_input(task). NEVER make up \
|
||||
or assume what the user wants. Use ask_user to collect the task details \
|
||||
(e.g., topic, target, requirements). Once you have the user's answer, \
|
||||
compose a structured task description from their input and call \
|
||||
run_agent_with_input(task). The worker has no intake node — it receives \
|
||||
your task and starts processing.
|
||||
- If the user wants to modify the agent, call stop_worker_and_edit().
|
||||
|
||||
## When idle (worker not running):
|
||||
- Greet the user. Mention what the worker can do in one sentence.
|
||||
- For tasks matching the worker's goal, use run_agent_with_input(task) \
|
||||
(if in staging) or load the agent first (if in building).
|
||||
- For everything else, do it directly.
|
||||
|
||||
## When the user clicks Run (external event notification)
|
||||
When you receive an event that the user clicked Run:
|
||||
- If the worker started successfully, briefly acknowledge it — do NOT \
|
||||
repeat the full status. The user can see the graph is running.
|
||||
- If the worker failed to start (credential or structural error), \
|
||||
explain the problem clearly and help fix it. For credential errors, \
|
||||
guide the user to set up the missing credentials. For structural \
|
||||
issues, offer to fix the agent graph directly.
|
||||
|
||||
## When worker is running — GO SILENT
|
||||
|
||||
Once you call start_worker(), your job is DONE. Do NOT call ask_user, \
|
||||
do NOT call get_worker_status(), do NOT emit any text. Just stop. \
|
||||
The worker owns the conversation now — it has its own client-facing \
|
||||
nodes that talk to the user directly.
|
||||
|
||||
**After start_worker, your ENTIRE response should be ONE short \
|
||||
confirmation sentence with NO tool calls.** Example: \
|
||||
"Started the vulnerability assessment." — that's it. No ask_user, \
|
||||
no get_worker_status, no follow-up questions.
|
||||
|
||||
You only wake up again when:
|
||||
- The user explicitly addresses you (not answering a worker question)
|
||||
- A worker question is forwarded to you for relay
|
||||
- An escalation ticket arrives from the judge
|
||||
- The worker finishes
|
||||
|
||||
If the user explicitly asks about progress, call get_worker_status() \
|
||||
ONCE and report. Do NOT poll or check proactively.
|
||||
|
||||
For escalation tickets: low/transient → acknowledge silently. \
|
||||
High/critical → notify the user with a brief analysis.
|
||||
|
||||
## When the worker asks the user a question:
|
||||
- The user's answer is routed to you with context: \
|
||||
[Worker asked: "...", Options: ...] User answered: "...".
|
||||
- If the user is answering the worker's question normally, relay it \
|
||||
using inject_worker_message(answer_text). Then go silent again.
|
||||
- If the user is rejecting the approach, asking to stop, or giving \
|
||||
you an instruction, handle it yourself — do NOT relay.
|
||||
|
||||
## Showing or describing the loaded worker
|
||||
|
||||
When the user asks to "show the graph", "describe the agent", or \
|
||||
"re-generate the graph", read the Worker Profile and present the \
|
||||
worker's current architecture as an ASCII diagram. Use the processing \
|
||||
stages, tools, and edges from the loaded worker. Do NOT enter the \
|
||||
agent building workflow — you are describing what already exists, not \
|
||||
building something new.
|
||||
|
||||
## Modifying the loaded worker
|
||||
|
||||
When the user asks to change, modify, or update the loaded worker \
|
||||
(e.g., "change the report node", "add a node", "delete node X"):
|
||||
|
||||
1. Call stop_worker_and_edit() — this stops the worker and gives you \
|
||||
coding tools (switches to BUILDING mode).
|
||||
2. Use the **Path** from the Worker Profile to locate the agent files.
|
||||
3. Read the relevant files (nodes/__init__.py, agent.py, etc.).
|
||||
4. Make the requested changes using edit_file / write_file.
|
||||
5. Run validation (default_agent.validate(), AgentRunner.load(), \
|
||||
validate_agent_tools()).
|
||||
6. **Reload the modified worker**: call load_built_agent("{path}") \
|
||||
so the changes take effect immediately (switches to STAGING mode). \
|
||||
Then call run_agent_with_input(task) to restart execution.
|
||||
|
||||
Do NOT skip step 6 — without reloading, the user will still be \
|
||||
interacting with the old version.
|
||||
"""
|
||||
|
||||
_queen_phase_7 = """
|
||||
## 7. Load into Session
|
||||
|
||||
After building and verifying, load the agent into the current session:
|
||||
load_built_agent("exports/{name}")
|
||||
This switches to STAGING mode — the user sees the agent's graph and \
|
||||
the tab name updates. Then call run_agent_with_input(task) to start it. \
|
||||
Do NOT tell the user to run `python -m {name} run` — load and run it here.
|
||||
"""
|
||||
|
||||
_queen_style = """
|
||||
# Style
|
||||
|
||||
- Concise. No fluff. Direct. No emojis.
|
||||
- **One phase per response.** Stop after each phase and get user \
|
||||
confirmation before moving on. Never combine understand + design + \
|
||||
implement in one response.
|
||||
- When starting the worker, describe what you told it in one sentence.
|
||||
- When an escalation arrives, lead with severity and recommended action.
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Single node — like opencode's while(true) loop.
|
||||
# One continuous context handles the entire workflow:
|
||||
# discover → design → implement → verify → present → iterate.
|
||||
coder_node = NodeSpec(
|
||||
id="coder",
|
||||
name="Hive Coder",
|
||||
description=(
|
||||
"Autonomous coding agent that builds Hive agent packages. "
|
||||
"Handles the full lifecycle: understanding user intent, "
|
||||
"designing architecture, writing code, validating, and "
|
||||
"iterating on feedback — all in one continuous conversation."
|
||||
),
|
||||
node_type="event_loop",
|
||||
client_facing=True,
|
||||
max_node_visits=0,
|
||||
input_keys=["user_request"],
|
||||
output_keys=["agent_name", "validation_result"],
|
||||
success_criteria=(
|
||||
"A complete, validated Hive agent package exists at "
|
||||
"exports/{agent_name}/ and passes structural validation."
|
||||
),
|
||||
tools=_SHARED_TOOLS
|
||||
+ [
|
||||
# Graph lifecycle tools (multi-graph sessions)
|
||||
"load_agent",
|
||||
"unload_agent",
|
||||
"start_agent",
|
||||
"restart_agent",
|
||||
"get_user_presence",
|
||||
],
|
||||
system_prompt=(
|
||||
"You are Hive Coder, the best agent-building coding agent. You build "
|
||||
"production-ready Hive agent packages from natural language.\n"
|
||||
+ _agent_builder_knowledge
|
||||
+ _coder_completion
|
||||
+ _appendices
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
ticket_triage_node = NodeSpec(
|
||||
id="ticket_triage",
|
||||
name="Ticket Triage",
|
||||
description=(
|
||||
"Queen's triage node. Receives an EscalationTicket from the Health Judge "
|
||||
"via event-driven entry point and decides: dismiss or notify the operator."
|
||||
),
|
||||
node_type="event_loop",
|
||||
client_facing=True, # Operator can chat with queen once connected (Ctrl+Q)
|
||||
max_node_visits=0,
|
||||
input_keys=["ticket"],
|
||||
output_keys=["intervention_decision"],
|
||||
nullable_output_keys=["intervention_decision"],
|
||||
success_criteria=(
|
||||
"A clear intervention decision: either dismissed with documented reasoning, "
|
||||
"or operator notified via notify_operator with specific analysis."
|
||||
),
|
||||
tools=["notify_operator"],
|
||||
system_prompt="""\
|
||||
You are the Queen (Hive Coder). The Worker Health Judge has escalated a worker \
|
||||
issue to you. The ticket is in your memory under key "ticket". Read it carefully.
|
||||
|
||||
## Dismiss criteria — do NOT call notify_operator:
|
||||
- severity is "low" AND steps_since_last_accept < 8
|
||||
- Cause is clearly a transient issue (single API timeout, brief stall that \
|
||||
self-resolved based on the evidence)
|
||||
- Evidence shows the agent is making real progress despite bad verdicts
|
||||
|
||||
## Intervene criteria — call notify_operator:
|
||||
- severity is "high" or "critical"
|
||||
- steps_since_last_accept >= 10 with no sign of recovery
|
||||
- stall_minutes > 4 (worker definitively stuck)
|
||||
- Evidence shows a doom loop (same error, same tool, no progress)
|
||||
- Cause suggests a logic bug, missing configuration, or unrecoverable state
|
||||
|
||||
## When intervening:
|
||||
Call notify_operator with:
|
||||
ticket_id: <ticket["ticket_id"]>
|
||||
analysis: "<2-3 sentences: what is wrong, why it matters, suggested action>"
|
||||
urgency: "<low|medium|high|critical>"
|
||||
|
||||
## After deciding:
|
||||
set_output("intervention_decision", "dismissed: <reason>" or "escalated: <summary>")
|
||||
|
||||
Be conservative but not passive. You are the last quality gate before the human \
|
||||
is disturbed. One unnecessary alert is less costly than alert fatigue — but \
|
||||
genuine stuck agents must be caught.
|
||||
""",
|
||||
)
|
||||
|
||||
ALL_QUEEN_TRIAGE_TOOLS = ["notify_operator"]
|
||||
|
||||
|
||||
queen_node = NodeSpec(
|
||||
id="queen",
|
||||
name="Queen",
|
||||
description=(
|
||||
"User's primary interactive interface with full coding capability. "
|
||||
"Can build agents directly or delegate to the worker. Manages the "
|
||||
"worker agent lifecycle and triages health escalations from the judge."
|
||||
),
|
||||
node_type="event_loop",
|
||||
client_facing=True,
|
||||
max_node_visits=0,
|
||||
input_keys=["greeting"],
|
||||
output_keys=[],
|
||||
nullable_output_keys=[],
|
||||
success_criteria=(
|
||||
"User's intent is understood, coding tasks are completed correctly, "
|
||||
"and the worker is managed effectively when delegated to."
|
||||
),
|
||||
tools=sorted(set(_QUEEN_BUILDING_TOOLS + _QUEEN_STAGING_TOOLS + _QUEEN_RUNNING_TOOLS)),
|
||||
system_prompt=(
|
||||
"You are the Queen — the user's primary interface. You are a coding agent "
|
||||
"with the same capabilities as the Hive Coder worker, PLUS the ability to "
|
||||
"manage the worker's lifecycle.\n"
|
||||
+ _agent_builder_knowledge
|
||||
+ _queen_tools_docs
|
||||
+ _queen_behavior
|
||||
+ _queen_phase_7
|
||||
+ _queen_style
|
||||
+ _appendices
|
||||
),
|
||||
)
|
||||
|
||||
ALL_QUEEN_TOOLS = sorted(set(_QUEEN_BUILDING_TOOLS + _QUEEN_STAGING_TOOLS + _QUEEN_RUNNING_TOOLS))
|
||||
|
||||
__all__ = [
|
||||
"coder_node",
|
||||
"ticket_triage_node",
|
||||
"queen_node",
|
||||
"ALL_QUEEN_TRIAGE_TOOLS",
|
||||
"ALL_QUEEN_TOOLS",
|
||||
"_QUEEN_BUILDING_TOOLS",
|
||||
"_QUEEN_STAGING_TOOLS",
|
||||
"_QUEEN_RUNNING_TOOLS",
|
||||
]
|
||||
@@ -0,0 +1,113 @@
|
||||
# Common Mistakes When Building Hive Agents
|
||||
|
||||
## Critical Errors
|
||||
|
||||
1. **Using tools that don't exist** — Always verify tools are available in the hive-tools MCP server before assigning them to nodes. Never guess tool names.
|
||||
|
||||
2. **Wrong entry_points format** — MUST be `{"start": "first-node-id"}`. NOT a set, NOT `{node_id: [keys]}`.
|
||||
|
||||
3. **Wrong mcp_servers.json format** — Flat dict (no `"mcpServers"` wrapper). `cwd` must be `"../../tools"`. `command` must be `"uv"` with args `["run", "python", ...]`.
|
||||
|
||||
4. **Missing STEP 1/STEP 2 in client-facing prompts** — Without explicit phases, the LLM calls set_output before the user responds. Always use the pattern.
|
||||
|
||||
5. **Forgetting nullable_output_keys** — When a node receives inputs from multiple edges and some inputs only arrive on certain edges (e.g., feedback), mark those as nullable. Without this, the executor blocks waiting for a value that will never arrive.
|
||||
|
||||
6. **Creating dead-end nodes in forever-alive graphs** — Every node must have at least one outgoing edge. A node with no outgoing edges ends the execution, breaking the loop.
|
||||
|
||||
7. **Setting max_node_visits to a non-zero value in forever-alive agents** — The framework default is `max_node_visits=0` (unbounded). Setting it to any positive value (e.g., 1) means the node stops executing after that many visits, silently breaking the forever-alive loop. Only set `max_node_visits > 0` in one-shot agents with feedback loops that need bounded retries.
|
||||
|
||||
7. **Missing module-level exports in `__init__.py`** — The runner loads agents via `importlib.import_module(package_name)`, which imports `__init__.py`. It then reads `goal`, `nodes`, `edges`, `entry_node`, `entry_points`, `pause_nodes`, `terminal_nodes`, `conversation_mode`, `identity_prompt`, `loop_config` via `getattr()`. If ANY of these are missing from `__init__.py`, they default to `None` or `{}` — causing "must define goal, nodes, edges" errors or "node X is unreachable" validation failures. **ALL module-level variables from agent.py must be re-exported in `__init__.py`.**
|
||||
|
||||
## Value Errors
|
||||
|
||||
8. **Invalid `conversation_mode` value** — Only two valid values: `"continuous"` (recommended for interactive agents) or omit entirely (for isolated per-node conversations). Values like `"client_facing"`, `"interactive"`, `"adaptive"` do NOT exist and will cause runtime errors.
|
||||
|
||||
9. **Invalid `loop_config` keys** — Only three valid keys: `max_iterations` (int), `max_tool_calls_per_turn` (int), `max_history_tokens` (int). Keys like `"strategy"`, `"mode"`, `"timeout"` are NOT valid and are silently ignored or cause errors.
|
||||
|
||||
10. **Fabricating tools that don't exist** — Never guess tool names. Always verify via `list_agent_tools()` before designing and `validate_agent_tools()` after building. Common hallucinations: `csv_read`, `csv_write`, `csv_append`, `file_upload`, `database_query`, `bulk_fetch_emails`. If a required tool doesn't exist, redesign the agent to use tools that DO exist (e.g., `save_data`/`load_data` for data persistence).
|
||||
|
||||
## Design Errors
|
||||
|
||||
11. **Too many thin nodes** — Hard limit: **2-4 nodes** for most agents. Each node boundary serializes outputs to shared memory and loses all in-context information (tool results, intermediate reasoning, conversation history). A node with 0 tools that just does LLM reasoning is NOT a real node — merge it into its predecessor or successor.
|
||||
|
||||
**Merge when:**
|
||||
- Node has NO tools — pure LLM reasoning belongs in the node that produces or consumes its data
|
||||
- Node sets only 1 trivial output (e.g., `set_output("done", "true")`) — collapse into predecessor
|
||||
- Multiple consecutive autonomous nodes with same/similar tools — combine into one
|
||||
- A "report" or "summary" node that just presents analysis — merge into the client-facing node
|
||||
- A "schedule" or "confirm" node that doesn't actually schedule anything — remove entirely
|
||||
|
||||
**Keep separate when:**
|
||||
- Client-facing vs autonomous — different interaction models require separate nodes
|
||||
- Fundamentally different tool sets (e.g., web search vs file I/O)
|
||||
- Fan-out parallelism — parallel branches MUST be separate nodes
|
||||
|
||||
**Bad example** (7 nodes — WAY too many):
|
||||
```
|
||||
profile_setup → daily_intake → update_tracker → analyze_progress → generate_plan → schedule_reminders → report
|
||||
```
|
||||
`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** (2 nodes):
|
||||
```
|
||||
process (autonomous: track + analyze + plan) → review (client-facing) → process (loop back)
|
||||
```
|
||||
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.
|
||||
|
||||
13. **Not using continuous conversation mode** — Interactive agents should use `conversation_mode="continuous"`. Without it, each node starts with blank context.
|
||||
|
||||
14. **Adding terminal nodes by default** — ALL agents should use `terminal_nodes=[]` (forever-alive) unless the user explicitly requests a one-shot/batch agent. Forever-alive is the standard pattern. Every node must have at least one outgoing edge. Dead-end nodes break the loop.
|
||||
|
||||
15. **Calling set_output in same turn as tool calls** — Instruct the LLM to call set_output in a SEPARATE turn from real tool calls.
|
||||
|
||||
## File Template Errors
|
||||
|
||||
16. **Wrong import paths** — Use `from framework.graph import ...`, NOT `from core.framework.graph import ...`. The PYTHONPATH includes `core/`.
|
||||
|
||||
17. **Missing storage path** — Agent class must set `self._storage_path = Path.home() / ".hive" / "agents" / "agent_name"`.
|
||||
|
||||
18. **Missing mcp_servers.json** — Without this, the agent has no tools at runtime.
|
||||
|
||||
19. **Bare `python` command in mcp_servers.json** — Use `"command": "uv"` with args `["run", "python", ...]`.
|
||||
|
||||
## Testing Errors
|
||||
|
||||
20. **Using `runner.run()` on forever-alive agents** — `runner.run()` calls `trigger_and_wait()` which blocks until the graph reaches a terminal node. Forever-alive agents have `terminal_nodes=[]`, so **`runner.run()` hangs forever**. This is the #1 cause of stuck test suites.
|
||||
|
||||
**For forever-alive agents, write structural tests instead:**
|
||||
- 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()` succeeds (structural, no API key needed)
|
||||
|
||||
**What NOT to do:**
|
||||
```python
|
||||
# WRONG — hangs forever on forever-alive agents
|
||||
result = await runner.run({"topic": "quantum computing"})
|
||||
```
|
||||
|
||||
**Correct pattern for structure tests:**
|
||||
```python
|
||||
def test_research_has_web_tools(self):
|
||||
assert "web_search" in research_node.tools
|
||||
|
||||
def test_research_routes_back_to_interact(self):
|
||||
edges_to_interact = [e for e in edges if e.source == "research" and e.target == "interact"]
|
||||
assert edges_to_interact
|
||||
```
|
||||
|
||||
21. **Stale tests after agent restructuring** — When you change an agent's node count or names (e.g., 4 nodes → 2 nodes), the tests MUST be updated too. Tests referencing old node names (e.g., `"review"`, `"report"`) will fail or hang. Always check that test assertions match the current `nodes/__init__.py`.
|
||||
|
||||
22. **Running full integration tests without API keys** — Structural tests (validate, import) work without keys. Full integration tests need `ANTHROPIC_API_KEY`. Use `pytest.skip()` in the runner fixture when `_setup()` fails due to missing credentials.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,567 @@
|
||||
# Agent File Templates
|
||||
|
||||
Complete code templates for each file in a Hive agent package.
|
||||
|
||||
## config.py
|
||||
|
||||
```python
|
||||
"""Runtime configuration."""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_preferred_model() -> str:
|
||||
"""Load preferred model from ~/.hive/configuration.json."""
|
||||
config_path = Path.home() / ".hive" / "configuration.json"
|
||||
if config_path.exists():
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
llm = config.get("llm", {})
|
||||
if llm.get("provider") and llm.get("model"):
|
||||
return f"{llm['provider']}/{llm['model']}"
|
||||
except Exception:
|
||||
pass
|
||||
return "anthropic/claude-sonnet-4-20250514"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeConfig:
|
||||
model: str = field(default_factory=_load_preferred_model)
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 40000
|
||||
api_key: str | None = None
|
||||
api_base: str | None = None
|
||||
|
||||
|
||||
default_config = RuntimeConfig()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentMetadata:
|
||||
name: str = "My Agent Name"
|
||||
version: str = "1.0.0"
|
||||
description: str = "What this agent does."
|
||||
intro_message: str = "Welcome! What would you like me to do?"
|
||||
|
||||
|
||||
metadata = AgentMetadata()
|
||||
```
|
||||
|
||||
## nodes/__init__.py
|
||||
|
||||
```python
|
||||
"""Node definitions for My Agent."""
|
||||
|
||||
from framework.graph import NodeSpec
|
||||
|
||||
# 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",
|
||||
max_node_visits=0, # Unlimited for forever-alive
|
||||
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 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 in a SEPARATE turn:
|
||||
- set_output("results", "structured results")
|
||||
""",
|
||||
tools=["web_search", "web_scrape", "save_data", "load_data", "list_data_files"],
|
||||
)
|
||||
|
||||
# Node 3: Review (client-facing)
|
||||
review_node = NodeSpec(
|
||||
id="review",
|
||||
name="Review",
|
||||
description="Present results for user approval",
|
||||
node_type="event_loop",
|
||||
client_facing=True,
|
||||
max_node_visits=0,
|
||||
input_keys=["results", "user_request"],
|
||||
output_keys=["next_action", "feedback"],
|
||||
nullable_output_keys=["feedback"],
|
||||
success_criteria="User has reviewed and decided next steps.",
|
||||
system_prompt="""\
|
||||
Present the results to the user.
|
||||
|
||||
**STEP 1 — Present (text only, NO tool calls):**
|
||||
1. Summary of work done
|
||||
2. Key results
|
||||
3. Ask: satisfied, or want changes?
|
||||
|
||||
**STEP 2 — After user responds, call set_output:**
|
||||
- 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__ = ["process_node", "review_node"]
|
||||
```
|
||||
|
||||
## agent.py
|
||||
|
||||
```python
|
||||
"""Agent graph construction for My Agent."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from framework.graph import EdgeSpec, EdgeCondition, Goal, SuccessCriterion, Constraint
|
||||
from framework.graph.edge import GraphSpec
|
||||
from framework.graph.executor import ExecutionResult
|
||||
from framework.graph.checkpoint_config import CheckpointConfig
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.runner.tool_registry import ToolRegistry
|
||||
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 process_node, review_node
|
||||
|
||||
# Goal definition
|
||||
goal = Goal(
|
||||
id="my-agent-goal",
|
||||
name="My Agent Goal",
|
||||
description="What this agent achieves.",
|
||||
success_criteria=[
|
||||
SuccessCriterion(id="sc-1", description="...", metric="...", target="...", weight=0.5),
|
||||
SuccessCriterion(id="sc-2", description="...", metric="...", target="...", weight=0.5),
|
||||
],
|
||||
constraints=[
|
||||
Constraint(id="c-1", description="...", constraint_type="hard", category="quality"),
|
||||
],
|
||||
)
|
||||
|
||||
# Node list
|
||||
nodes = [process_node, review_node]
|
||||
|
||||
# Edge definitions
|
||||
edges = [
|
||||
EdgeSpec(id="process-to-review", source="process", target="review",
|
||||
condition=EdgeCondition.ON_SUCCESS, priority=1),
|
||||
# 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 next task (queen sends new input)
|
||||
EdgeSpec(id="review-done", source="review", target="process",
|
||||
condition=EdgeCondition.CONDITIONAL,
|
||||
condition_expr="str(next_action).lower() == 'done'", priority=1),
|
||||
]
|
||||
|
||||
# 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
|
||||
|
||||
# Module-level vars read by AgentRunner.load()
|
||||
conversation_mode = "continuous"
|
||||
identity_prompt = "You are a helpful agent."
|
||||
loop_config = {"max_iterations": 100, "max_tool_calls_per_turn": 20, "max_history_tokens": 32000}
|
||||
|
||||
|
||||
class MyAgent:
|
||||
def __init__(self, config=None):
|
||||
self.config = config or default_config
|
||||
self.goal = goal
|
||||
self.nodes = nodes
|
||||
self.edges = edges
|
||||
self.entry_node = entry_node # "process" — autonomous entry
|
||||
self.entry_points = entry_points
|
||||
self.pause_nodes = pause_nodes
|
||||
self.terminal_nodes = terminal_nodes
|
||||
self._graph = None
|
||||
self._agent_runtime = None
|
||||
self._tool_registry = None
|
||||
self._storage_path = None
|
||||
|
||||
def _build_graph(self):
|
||||
return GraphSpec(
|
||||
id="my-agent-graph",
|
||||
goal_id=self.goal.id,
|
||||
version="1.0.0",
|
||||
entry_node=self.entry_node,
|
||||
entry_points=self.entry_points,
|
||||
terminal_nodes=self.terminal_nodes,
|
||||
pause_nodes=self.pause_nodes,
|
||||
nodes=self.nodes,
|
||||
edges=self.edges,
|
||||
default_model=self.config.model,
|
||||
max_tokens=self.config.max_tokens,
|
||||
loop_config=loop_config,
|
||||
conversation_mode=conversation_mode,
|
||||
identity_prompt=identity_prompt,
|
||||
)
|
||||
|
||||
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 = 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()
|
||||
self._agent_runtime = create_agent_runtime(
|
||||
graph=self._graph, goal=self.goal, storage_path=self._storage_path,
|
||||
entry_points=[EntryPointSpec(id="default", name="Default", entry_node=self.entry_node,
|
||||
trigger_type="manual", isolation_level="shared")],
|
||||
llm=llm, tools=tools, tool_executor=tool_executor,
|
||||
checkpoint_config=CheckpointConfig(enabled=True, checkpoint_on_node_complete=True,
|
||||
checkpoint_max_age_days=7, async_checkpoint=True),
|
||||
)
|
||||
|
||||
async def start(self):
|
||||
if self._agent_runtime is None:
|
||||
self._setup()
|
||||
if not self._agent_runtime.is_running:
|
||||
await self._agent_runtime.start()
|
||||
|
||||
async def stop(self):
|
||||
if self._agent_runtime and self._agent_runtime.is_running:
|
||||
await self._agent_runtime.stop()
|
||||
self._agent_runtime = None
|
||||
|
||||
async def trigger_and_wait(self, entry_point="default", input_data=None, timeout=None, session_state=None):
|
||||
if self._agent_runtime is None:
|
||||
raise RuntimeError("Agent not started. Call start() first.")
|
||||
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, 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")
|
||||
finally:
|
||||
await self.stop()
|
||||
|
||||
def info(self):
|
||||
return {
|
||||
"name": metadata.name, "version": metadata.version, "description": metadata.description,
|
||||
"goal": {"name": self.goal.name, "description": self.goal.description},
|
||||
"nodes": [n.id for n in self.nodes], "edges": [e.id for e in self.edges],
|
||||
"entry_node": self.entry_node, "entry_points": self.entry_points,
|
||||
"terminal_nodes": self.terminal_nodes,
|
||||
"client_facing_nodes": [n.id for n in self.nodes if n.client_facing],
|
||||
}
|
||||
|
||||
def validate(self):
|
||||
errors, warnings = [], []
|
||||
node_ids = {n.id for n in self.nodes}
|
||||
for e in self.edges:
|
||||
if e.source not in node_ids: errors.append(f"Edge {e.id}: source '{e.source}' not found")
|
||||
if e.target not in node_ids: errors.append(f"Edge {e.id}: target '{e.target}' not found")
|
||||
if self.entry_node not in node_ids: errors.append(f"Entry node '{self.entry_node}' not found")
|
||||
for t in self.terminal_nodes:
|
||||
if t not in node_ids: errors.append(f"Terminal node '{t}' not found")
|
||||
for ep_id, nid in self.entry_points.items():
|
||||
if nid not in node_ids: errors.append(f"Entry point '{ep_id}' references unknown node '{nid}'")
|
||||
return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings}
|
||||
|
||||
|
||||
default_agent = MyAgent()
|
||||
```
|
||||
|
||||
## agent.py — Async Entry Points Variant
|
||||
|
||||
When an agent needs timers, webhooks, or event-driven triggers, add
|
||||
`async_entry_points` and optionally `runtime_config` as module-level variables.
|
||||
These are IN ADDITION to the standard variables above.
|
||||
|
||||
```python
|
||||
# Additional imports for async entry points
|
||||
from framework.graph.edge import GraphSpec, AsyncEntryPointSpec
|
||||
from framework.runtime.agent_runtime import (
|
||||
AgentRuntime, AgentRuntimeConfig, create_agent_runtime,
|
||||
)
|
||||
|
||||
# ... (goal, nodes, edges, entry_node, entry_points, etc. as above) ...
|
||||
|
||||
# Async entry points — event-driven triggers
|
||||
async_entry_points = [
|
||||
# Timer with cron: daily at 9am
|
||||
AsyncEntryPointSpec(
|
||||
id="daily-check",
|
||||
name="Daily Check",
|
||||
entry_node="process-node",
|
||||
trigger_type="timer",
|
||||
trigger_config={"cron": "0 9 * * *"},
|
||||
isolation_level="shared",
|
||||
max_concurrent=1,
|
||||
),
|
||||
# Timer with fixed interval: every 20 minutes
|
||||
AsyncEntryPointSpec(
|
||||
id="scheduled-check",
|
||||
name="Scheduled Check",
|
||||
entry_node="process-node",
|
||||
trigger_type="timer",
|
||||
trigger_config={"interval_minutes": 20, "run_immediately": False},
|
||||
isolation_level="shared",
|
||||
max_concurrent=1,
|
||||
),
|
||||
# Event: reacts to webhook events
|
||||
AsyncEntryPointSpec(
|
||||
id="webhook-event",
|
||||
name="Webhook Event Handler",
|
||||
entry_node="process-node",
|
||||
trigger_type="event",
|
||||
trigger_config={"event_types": ["webhook_received"]},
|
||||
isolation_level="shared",
|
||||
max_concurrent=10,
|
||||
),
|
||||
]
|
||||
|
||||
# Webhook server config (only needed if using webhooks)
|
||||
runtime_config = AgentRuntimeConfig(
|
||||
webhook_host="127.0.0.1",
|
||||
webhook_port=8080,
|
||||
webhook_routes=[
|
||||
{
|
||||
"source_id": "my-source",
|
||||
"path": "/webhooks/my-source",
|
||||
"methods": ["POST"],
|
||||
},
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
**Key rules for async entry points:**
|
||||
- `async_entry_points` is a list of `AsyncEntryPointSpec` (NOT `EntryPointSpec`)
|
||||
- `runtime_config` is `AgentRuntimeConfig` (NOT `RuntimeConfig` from config.py)
|
||||
- Valid trigger_types: `timer`, `event`, `webhook`, `manual`, `api`
|
||||
- Valid isolation_levels: `isolated`, `shared`, `synchronized`
|
||||
- Timer trigger_config (cron): `{"cron": "0 9 * * *"}` — standard 5-field cron expression
|
||||
- Timer trigger_config (interval): `{"interval_minutes": float, "run_immediately": bool}`
|
||||
- Event trigger_config: `{"event_types": ["webhook_received"], "filter_stream": "...", "filter_node": "..."}`
|
||||
- Use `isolation_level="shared"` for async entry points that need to read
|
||||
the primary session's memory (e.g., user-configured rules)
|
||||
- The `_build_graph()` method passes `async_entry_points` to GraphSpec
|
||||
- Reference: `exports/gmail_inbox_guardian/agent.py`
|
||||
|
||||
## __init__.py
|
||||
|
||||
**CRITICAL:** The runner imports the package (`__init__.py`) and reads ALL module-level
|
||||
variables via `getattr()`. Every variable defined in `agent.py` that the runner needs
|
||||
MUST be re-exported here. Missing exports cause silent failures (variables default to
|
||||
`None` or `{}`), leading to "must define goal, nodes, edges" errors or graph validation
|
||||
failures like "node X is unreachable".
|
||||
|
||||
```python
|
||||
"""My Agent — description."""
|
||||
|
||||
from .agent import (
|
||||
MyAgent,
|
||||
default_agent,
|
||||
goal,
|
||||
nodes,
|
||||
edges,
|
||||
entry_node,
|
||||
entry_points,
|
||||
pause_nodes,
|
||||
terminal_nodes,
|
||||
conversation_mode,
|
||||
identity_prompt,
|
||||
loop_config,
|
||||
)
|
||||
from .config import default_config, metadata
|
||||
|
||||
__all__ = [
|
||||
"MyAgent",
|
||||
"default_agent",
|
||||
"goal",
|
||||
"nodes",
|
||||
"edges",
|
||||
"entry_node",
|
||||
"entry_points",
|
||||
"pause_nodes",
|
||||
"terminal_nodes",
|
||||
"conversation_mode",
|
||||
"identity_prompt",
|
||||
"loop_config",
|
||||
"default_config",
|
||||
"metadata",
|
||||
]
|
||||
```
|
||||
|
||||
**If the agent uses async entry points**, also import and export:
|
||||
```python
|
||||
from .agent import (
|
||||
...,
|
||||
async_entry_points,
|
||||
runtime_config, # Only if using webhooks
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
...,
|
||||
"async_entry_points",
|
||||
"runtime_config",
|
||||
]
|
||||
```
|
||||
|
||||
## __main__.py
|
||||
|
||||
```python
|
||||
"""CLI entry point for My Agent."""
|
||||
|
||||
import asyncio, json, logging, sys
|
||||
import click
|
||||
from .agent import default_agent, MyAgent
|
||||
|
||||
|
||||
def setup_logging(verbose=False, debug=False):
|
||||
if debug: level, fmt = logging.DEBUG, "%(asctime)s %(name)s: %(message)s"
|
||||
elif verbose: level, fmt = logging.INFO, "%(message)s"
|
||||
else: level, fmt = logging.WARNING, "%(levelname)s: %(message)s"
|
||||
logging.basicConfig(level=level, format=fmt, stream=sys.stderr)
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version="1.0.0")
|
||||
def cli():
|
||||
"""My Agent — description."""
|
||||
pass
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--topic", "-t", required=True)
|
||||
@click.option("--verbose", "-v", is_flag=True)
|
||||
def run(topic, verbose):
|
||||
"""Execute the agent."""
|
||||
setup_logging(verbose=verbose)
|
||||
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()
|
||||
def tui():
|
||||
"""Launch TUI dashboard."""
|
||||
from pathlib import Path
|
||||
from framework.tui.app import AdenTUI
|
||||
from framework.llm import LiteLLMProvider
|
||||
from framework.runner.tool_registry import ToolRegistry
|
||||
from framework.runtime.agent_runtime import create_agent_runtime
|
||||
from framework.runtime.execution_stream import EntryPointSpec
|
||||
|
||||
async def run_tui():
|
||||
agent = MyAgent()
|
||||
agent._tool_registry = ToolRegistry()
|
||||
storage = Path.home() / ".hive" / "agents" / "my_agent"
|
||||
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 = 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="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:
|
||||
app = AdenTUI(runtime)
|
||||
await app.run_async()
|
||||
finally:
|
||||
await runtime.stop()
|
||||
asyncio.run(run_tui())
|
||||
|
||||
|
||||
@cli.command()
|
||||
def info():
|
||||
"""Show agent info."""
|
||||
data = default_agent.info()
|
||||
click.echo(f"Agent: {data['name']}\nVersion: {data['version']}\nDescription: {data['description']}")
|
||||
click.echo(f"Nodes: {', '.join(data['nodes'])}\nClient-facing: {', '.join(data['client_facing_nodes'])}")
|
||||
|
||||
|
||||
@cli.command()
|
||||
def validate():
|
||||
"""Validate agent structure."""
|
||||
v = default_agent.validate()
|
||||
if v["valid"]: click.echo("Agent is valid")
|
||||
else:
|
||||
click.echo("Errors:")
|
||||
for e in v["errors"]: click.echo(f" {e}")
|
||||
sys.exit(0 if v["valid"] else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
```
|
||||
|
||||
## mcp_servers.json
|
||||
|
||||
```json
|
||||
{
|
||||
"hive-tools": {
|
||||
"transport": "stdio",
|
||||
"command": "uv",
|
||||
"args": ["run", "python", "mcp_server.py", "--stdio"],
|
||||
"cwd": "../../tools",
|
||||
"description": "Hive tools MCP server"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**CRITICAL FORMAT RULES:**
|
||||
- NO `"mcpServers"` wrapper (flat dict, not nested)
|
||||
- `cwd` MUST be `"../../tools"` (relative from `exports/AGENT_NAME/` to `tools/`)
|
||||
- `command` MUST be `"uv"` with `"args": ["run", "python", ...]` (NOT bare `"python"`)
|
||||
|
||||
## tests/conftest.py
|
||||
|
||||
```python
|
||||
"""Test fixtures."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_repo_root = Path(__file__).resolve().parents[3]
|
||||
for _p in ["exports", "core"]:
|
||||
_path = str(_repo_root / _p)
|
||||
if _path not in sys.path:
|
||||
sys.path.insert(0, _path)
|
||||
|
||||
AGENT_PATH = str(Path(__file__).resolve().parents[1])
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def agent_module():
|
||||
"""Import the agent package for structural validation."""
|
||||
import importlib
|
||||
return importlib.import_module(Path(AGENT_PATH).name)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def runner_loaded():
|
||||
"""Load the agent through AgentRunner (structural only, no LLM needed)."""
|
||||
from framework.runner.runner import AgentRunner
|
||||
return AgentRunner.load(AGENT_PATH)
|
||||
```
|
||||
|
||||
## entry_points Format
|
||||
|
||||
MUST be: `{"start": "first-node-id"}`
|
||||
NOT: `{"first-node-id": ["input_keys"]}` (WRONG)
|
||||
NOT: `{"first-node-id"}` (WRONG — this is a set)
|
||||
@@ -0,0 +1,441 @@
|
||||
# Hive Agent Framework — Condensed Reference
|
||||
|
||||
## Architecture
|
||||
|
||||
Agents are Python packages in `exports/`:
|
||||
```
|
||||
exports/my_agent/
|
||||
├── __init__.py # MUST re-export ALL module-level vars from agent.py
|
||||
├── __main__.py # CLI (run, tui, info, validate, shell)
|
||||
├── agent.py # Graph construction (goal, edges, agent class)
|
||||
├── config.py # Runtime config
|
||||
├── nodes/__init__.py # Node definitions (NodeSpec)
|
||||
├── mcp_servers.json # MCP tool server config
|
||||
└── tests/ # pytest tests
|
||||
```
|
||||
|
||||
## Agent Loading Contract
|
||||
|
||||
`AgentRunner.load()` imports the package (`__init__.py`) and reads these
|
||||
module-level variables via `getattr()`:
|
||||
|
||||
| Variable | Required | Default if missing | Consequence |
|
||||
|----------|----------|--------------------|-------------|
|
||||
| `goal` | YES | `None` | **FATAL** — "must define goal, nodes, edges" |
|
||||
| `nodes` | YES | `None` | **FATAL** — same error |
|
||||
| `edges` | YES | `None` | **FATAL** — same error |
|
||||
| `entry_node` | no | `nodes[0].id` | Probably wrong node |
|
||||
| `entry_points` | no | `{}` | **Nodes unreachable** — validation fails |
|
||||
| `terminal_nodes` | no | `[]` | OK for forever-alive |
|
||||
| `pause_nodes` | no | `[]` | OK |
|
||||
| `conversation_mode` | no | not passed | Isolated mode (no context carryover) |
|
||||
| `identity_prompt` | no | not passed | No agent-level identity |
|
||||
| `loop_config` | no | `{}` | No iteration limits |
|
||||
| `async_entry_points` | no | `[]` | No async triggers (timers, webhooks, events) |
|
||||
| `runtime_config` | no | `None` | No webhook server |
|
||||
|
||||
**CRITICAL:** `__init__.py` MUST import and re-export ALL of these from
|
||||
`agent.py`. Missing exports silently fall back to defaults, causing
|
||||
hard-to-debug failures.
|
||||
|
||||
**Why `default_agent.validate()` is NOT sufficient:**
|
||||
`validate()` checks the agent CLASS's internal graph (self.nodes, self.edges).
|
||||
These are always correct because the constructor references agent.py's module
|
||||
vars directly. But `AgentRunner.load()` reads from the PACKAGE (`__init__.py`),
|
||||
not the class. So `validate()` passes while `AgentRunner.load()` fails.
|
||||
Always test with `AgentRunner.load("exports/{name}")` — this is the same
|
||||
code path the TUI and `hive run` use.
|
||||
|
||||
## Goal
|
||||
|
||||
Defines success criteria and constraints:
|
||||
```python
|
||||
goal = Goal(
|
||||
id="kebab-case-id",
|
||||
name="Display Name",
|
||||
description="What the agent does",
|
||||
success_criteria=[
|
||||
SuccessCriterion(id="sc-id", description="...", metric="...", target="...", weight=0.25),
|
||||
],
|
||||
constraints=[
|
||||
Constraint(id="c-id", description="...", constraint_type="hard", category="quality"),
|
||||
],
|
||||
)
|
||||
```
|
||||
- 3-5 success criteria, weights sum to 1.0
|
||||
- 1-5 constraints (hard/soft, categories: quality, accuracy, interaction, functional)
|
||||
|
||||
## NodeSpec Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| id | str | required | kebab-case identifier |
|
||||
| name | str | required | Display name |
|
||||
| description | str | required | What the node does |
|
||||
| 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 |
|
||||
| tools | list[str] | [] | Tool names from MCP servers |
|
||||
| client_facing | bool | False | If True, streams to user and blocks for input |
|
||||
| nullable_output_keys | list[str] | [] | Keys that may remain unset |
|
||||
| max_node_visits | int | 0 | 0=unlimited (default); >1 for one-shot feedback loops |
|
||||
| max_retries | int | 3 | Retries on failure |
|
||||
| success_criteria | str | "" | Natural language for judge evaluation |
|
||||
|
||||
## EdgeSpec Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| id | str | kebab-case identifier |
|
||||
| source | str | Source node ID |
|
||||
| target | str | Target node ID |
|
||||
| condition | EdgeCondition | ON_SUCCESS, ON_FAILURE, ALWAYS, CONDITIONAL |
|
||||
| condition_expr | str | Python expression evaluated against memory (for CONDITIONAL) |
|
||||
| priority | int | Positive=forward (evaluated first), negative=feedback (loop-back) |
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### STEP 1/STEP 2 (Client-Facing Nodes)
|
||||
```
|
||||
**STEP 1 — Respond to the user (text only, NO tool calls):**
|
||||
[Present information, ask questions]
|
||||
|
||||
**STEP 2 — After the user responds, call set_output:**
|
||||
- set_output("key", "value based on user response")
|
||||
```
|
||||
This prevents premature set_output before user interaction.
|
||||
|
||||
### Fewer, Richer Nodes (CRITICAL)
|
||||
|
||||
**Hard limit: 2-4 nodes for most agents.** Never exceed 5 unless the user
|
||||
explicitly requests a complex multi-phase pipeline.
|
||||
|
||||
Each node boundary serializes outputs to shared memory and **destroys** all
|
||||
in-context information: tool call results, intermediate reasoning, conversation
|
||||
history. A research node that searches, fetches, and analyzes in ONE node keeps
|
||||
all source material in its conversation context. Split across 3 nodes, each
|
||||
downstream node only sees the serialized summary string.
|
||||
|
||||
**Decision framework — merge unless ANY of these apply:**
|
||||
1. **Client-facing boundary** — Autonomous and client-facing work MUST be
|
||||
separate nodes (different interaction models)
|
||||
2. **Disjoint tool sets** — If tools are fundamentally different (e.g., web
|
||||
search vs database), separate nodes make sense
|
||||
3. **Parallel execution** — Fan-out branches must be separate nodes
|
||||
|
||||
**Red flags that you have too many nodes:**
|
||||
- A node with 0 tools (pure LLM reasoning) → merge into predecessor/successor
|
||||
- A node that sets only 1 trivial output → collapse into predecessor
|
||||
- Multiple consecutive autonomous nodes → combine into one rich node
|
||||
- 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 (2 nodes):**
|
||||
```
|
||||
process (autonomous) ←→ review (client-facing)
|
||||
```
|
||||
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:
|
||||
```
|
||||
process (autonomous) — loops back to itself
|
||||
```
|
||||
|
||||
### nullable_output_keys
|
||||
For inputs that only arrive on certain edges:
|
||||
```python
|
||||
research_node = NodeSpec(
|
||||
input_keys=["brief", "feedback"],
|
||||
nullable_output_keys=["feedback"], # Only present on feedback edge
|
||||
max_node_visits=3,
|
||||
)
|
||||
```
|
||||
|
||||
### Mutually Exclusive Outputs
|
||||
For routing decisions:
|
||||
```python
|
||||
review_node = NodeSpec(
|
||||
output_keys=["approved", "feedback"],
|
||||
nullable_output_keys=["approved", "feedback"], # Node sets one or the other
|
||||
)
|
||||
```
|
||||
|
||||
### Forever-Alive Pattern
|
||||
`terminal_nodes=[]` — every node has outgoing edges, graph loops until user exits.
|
||||
Use `conversation_mode="continuous"` to preserve context across transitions.
|
||||
|
||||
### set_output
|
||||
- Synthetic tool injected by framework
|
||||
- Call separately from real tool calls (separate turn)
|
||||
- `set_output("key", "value")` stores to shared memory
|
||||
|
||||
## Edge Conditions
|
||||
|
||||
| Condition | When |
|
||||
|-----------|------|
|
||||
| ON_SUCCESS | Node completed successfully |
|
||||
| ON_FAILURE | Node failed |
|
||||
| ALWAYS | Unconditional |
|
||||
| CONDITIONAL | condition_expr evaluates to True against memory |
|
||||
|
||||
condition_expr examples:
|
||||
- `"needs_more_research == True"`
|
||||
- `"str(next_action).lower() == 'new_agent'"`
|
||||
- `"feedback is not None"`
|
||||
|
||||
## Graph Lifecycle
|
||||
|
||||
| Pattern | terminal_nodes | When |
|
||||
|---------|---------------|------|
|
||||
| **Forever-alive** | `[]` | **DEFAULT for all agents** |
|
||||
| Linear | `["last-node"]` | Only if user explicitly requests one-shot/batch |
|
||||
|
||||
**Forever-alive is the default.** Always use `terminal_nodes=[]`.
|
||||
The framework default for `max_node_visits` is 0 (unbounded), so
|
||||
nodes work correctly in forever-alive loops without explicit override.
|
||||
Only set `max_node_visits > 0` in one-shot agents with feedback loops.
|
||||
Every node must have at least one outgoing edge — no dead ends. The
|
||||
user exits by closing the TUI. Only use terminal nodes if the user
|
||||
explicitly asks for a batch/one-shot agent that runs once and exits.
|
||||
|
||||
## Continuous Conversation Mode
|
||||
|
||||
`conversation_mode` has ONLY two valid states:
|
||||
- `"continuous"` — recommended for interactive agents
|
||||
- Omit entirely — isolated per-node conversations (each node starts fresh)
|
||||
|
||||
**INVALID values** (do NOT use): `"client_facing"`, `"interactive"`,
|
||||
`"adaptive"`, `"shared"`. These do not exist in the framework.
|
||||
|
||||
When `conversation_mode="continuous"`:
|
||||
- Same conversation thread carries across node transitions
|
||||
- Layered system prompts: identity (agent-level) + narrative + focus (per-node)
|
||||
- Transition markers inserted at boundaries
|
||||
- Compaction happens opportunistically at phase transitions
|
||||
|
||||
## loop_config
|
||||
|
||||
Only three valid keys:
|
||||
```python
|
||||
loop_config = {
|
||||
"max_iterations": 100, # Max LLM turns per node visit
|
||||
"max_tool_calls_per_turn": 20, # Max tool calls per LLM response
|
||||
"max_history_tokens": 32000, # Triggers conversation compaction
|
||||
}
|
||||
```
|
||||
**INVALID keys** (do NOT use): `"strategy"`, `"mode"`, `"timeout"`,
|
||||
`"temperature"`. These are silently ignored or cause errors.
|
||||
|
||||
## Data Tools (Spillover)
|
||||
|
||||
For large data that exceeds context:
|
||||
- `save_data(filename, data)` — Write to session data dir
|
||||
- `load_data(filename, offset, limit)` — Read with pagination
|
||||
- `list_data_files()` — List files
|
||||
- `serve_file_to_user(filename, label)` — Clickable file:// URI
|
||||
|
||||
`data_dir` is auto-injected by framework — LLM never sees it.
|
||||
|
||||
## Fan-Out / Fan-In
|
||||
|
||||
Multiple ON_SUCCESS edges from same source → parallel execution via asyncio.gather().
|
||||
- Parallel nodes must have disjoint output_keys
|
||||
- Only one branch may have client_facing nodes
|
||||
- Fan-in node gets all outputs in shared memory
|
||||
|
||||
## Judge System
|
||||
|
||||
- **Implicit** (default): ACCEPTs when LLM finishes with no tool calls and all required outputs set
|
||||
- **SchemaJudge**: Validates against Pydantic model
|
||||
- **Custom**: Implement `evaluate(context) -> JudgeVerdict`
|
||||
|
||||
Judge is the SOLE acceptance mechanism — no ad-hoc framework gating.
|
||||
|
||||
## Async Entry Points (Webhooks, Timers, Events)
|
||||
|
||||
For agents that need to react to external events (incoming emails, scheduled
|
||||
tasks, API calls), use `AsyncEntryPointSpec` and optionally `AgentRuntimeConfig`.
|
||||
|
||||
### Imports
|
||||
```python
|
||||
from framework.graph.edge import GraphSpec, AsyncEntryPointSpec
|
||||
from framework.runtime.agent_runtime import AgentRuntime, AgentRuntimeConfig, create_agent_runtime
|
||||
```
|
||||
Note: `AsyncEntryPointSpec` is in `framework.graph.edge` (the graph/declarative layer).
|
||||
`AgentRuntimeConfig` is in `framework.runtime.agent_runtime` (the runtime layer).
|
||||
|
||||
### AsyncEntryPointSpec Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| id | str | required | Unique identifier |
|
||||
| name | str | required | Human-readable name |
|
||||
| entry_node | str | required | Node ID to start execution from |
|
||||
| trigger_type | str | `"manual"` | `webhook`, `api`, `timer`, `event`, `manual` |
|
||||
| trigger_config | dict | `{}` | Trigger-specific config (see below) |
|
||||
| isolation_level | str | `"shared"` | `isolated`, `shared`, `synchronized` |
|
||||
| priority | int | `0` | Execution priority (higher = more priority) |
|
||||
| max_concurrent | int | `10` | Max concurrent executions |
|
||||
|
||||
### Trigger Types
|
||||
|
||||
**timer** — Fires on a schedule. Two modes: cron expressions or fixed interval.
|
||||
|
||||
Cron (preferred for precise scheduling):
|
||||
```python
|
||||
AsyncEntryPointSpec(
|
||||
id="daily-digest",
|
||||
name="Daily Digest",
|
||||
entry_node="check-node",
|
||||
trigger_type="timer",
|
||||
trigger_config={"cron": "0 9 * * *"}, # daily at 9am
|
||||
isolation_level="shared",
|
||||
max_concurrent=1,
|
||||
)
|
||||
```
|
||||
- `cron` (str) — standard cron expression (5 fields: min hour dom month dow)
|
||||
- Examples: `"0 9 * * *"` (daily 9am), `"0 9 * * MON-FRI"` (weekdays 9am), `"*/30 * * * *"` (every 30 min)
|
||||
|
||||
Fixed interval (simpler, for polling-style tasks):
|
||||
```python
|
||||
AsyncEntryPointSpec(
|
||||
id="scheduled-check",
|
||||
name="Scheduled Check",
|
||||
entry_node="check-node",
|
||||
trigger_type="timer",
|
||||
trigger_config={"interval_minutes": 20, "run_immediately": False},
|
||||
isolation_level="shared",
|
||||
max_concurrent=1,
|
||||
)
|
||||
```
|
||||
- `interval_minutes` (float) — how often to fire
|
||||
- `run_immediately` (bool, default False) — fire once on startup
|
||||
|
||||
**event** — Subscribes to EventBus (e.g., webhook events):
|
||||
```python
|
||||
AsyncEntryPointSpec(
|
||||
id="email-event",
|
||||
name="Email Event Handler",
|
||||
entry_node="process-emails",
|
||||
trigger_type="event",
|
||||
trigger_config={"event_types": ["webhook_received"]},
|
||||
isolation_level="shared",
|
||||
max_concurrent=10,
|
||||
)
|
||||
```
|
||||
- `event_types` (list[str]) — EventType values to subscribe to
|
||||
- `filter_stream` (str, optional) — only receive from this stream
|
||||
- `filter_node` (str, optional) — only receive from this node
|
||||
|
||||
**webhook** — HTTP endpoint (requires AgentRuntimeConfig):
|
||||
The webhook server publishes `WEBHOOK_RECEIVED` events on the EventBus.
|
||||
An `event` trigger type with `event_types: ["webhook_received"]` subscribes
|
||||
to those events. The flow is:
|
||||
```
|
||||
HTTP POST /webhooks/gmail → WebhookServer → EventBus (WEBHOOK_RECEIVED)
|
||||
→ event entry point → triggers graph execution from entry_node
|
||||
```
|
||||
|
||||
**manual** — Triggered programmatically via `runtime.trigger()`.
|
||||
|
||||
### Isolation Levels
|
||||
|
||||
| Level | Meaning |
|
||||
|-------|---------|
|
||||
| `isolated` | Private state per execution |
|
||||
| `shared` | Eventual consistency — async executions can read primary session memory |
|
||||
| `synchronized` | Shared with write locks (use when ordering matters) |
|
||||
|
||||
For most async patterns, use `shared` — the async execution reads the primary
|
||||
session's memory (e.g., user-configured rules) and runs its own workflow.
|
||||
|
||||
### AgentRuntimeConfig (for webhook servers)
|
||||
|
||||
```python
|
||||
from framework.runtime.agent_runtime import AgentRuntimeConfig
|
||||
|
||||
runtime_config = AgentRuntimeConfig(
|
||||
webhook_host="127.0.0.1",
|
||||
webhook_port=8080,
|
||||
webhook_routes=[
|
||||
{
|
||||
"source_id": "gmail",
|
||||
"path": "/webhooks/gmail",
|
||||
"methods": ["POST"],
|
||||
"secret": None, # Optional HMAC-SHA256 secret
|
||||
},
|
||||
],
|
||||
)
|
||||
```
|
||||
`runtime_config` is a module-level variable read by `AgentRunner.load()`.
|
||||
The runner passes it to `create_agent_runtime()`. On `runtime.start()`,
|
||||
if webhook_routes is non-empty, an embedded HTTP server starts.
|
||||
|
||||
### Session Sharing
|
||||
|
||||
Timer and event triggers automatically call `_get_primary_session_state()`
|
||||
before execution. This finds the active user-facing session and provides
|
||||
its memory to the async execution, filtered to only the async entry node's
|
||||
`input_keys`. This means the async flow can read user-configured values
|
||||
(like rules, preferences) without needing separate configuration.
|
||||
|
||||
### Module-Level Variables
|
||||
|
||||
Agents with async entry points must export two additional variables:
|
||||
```python
|
||||
# In agent.py:
|
||||
async_entry_points = [AsyncEntryPointSpec(...), ...]
|
||||
runtime_config = AgentRuntimeConfig(...) # Only if using webhooks
|
||||
```
|
||||
|
||||
Both must be re-exported from `__init__.py`:
|
||||
```python
|
||||
from .agent import (
|
||||
..., async_entry_points, runtime_config,
|
||||
)
|
||||
```
|
||||
|
||||
### Reference Agent
|
||||
|
||||
See `exports/gmail_inbox_guardian/agent.py` for a complete example with:
|
||||
- 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
|
||||
|
||||
## Framework Capabilities
|
||||
|
||||
**Works well:** Multi-turn conversations, HITL review, tool orchestration, structured outputs, parallel execution, context management, error recovery, session persistence.
|
||||
|
||||
**Limitations:** LLM latency (2-10s/turn), context window limits (~128K), cost per run, rate limits, node boundaries lose context.
|
||||
|
||||
**Not designed for:** Sub-second responses, millions of items, real-time streaming, guaranteed determinism, offline/air-gapped.
|
||||
|
||||
## Tool Discovery
|
||||
|
||||
Do NOT rely on a static tool list — it will be outdated. Always use
|
||||
`list_agent_tools()` to discover available tools, grouped by category.
|
||||
|
||||
```
|
||||
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}")`
|
||||
|
||||
Common tool categories (verify via list_agent_tools):
|
||||
- **Web**: search, scrape, PDF
|
||||
- **Data**: save/load/append/list data files, serve to user
|
||||
- **File**: view, write, replace, diff, list, grep
|
||||
- **Communication**: email, gmail, slack, telegram
|
||||
- **CRM**: hubspot, apollo, calcom
|
||||
- **GitHub**: stargazers, user profiles, repos
|
||||
- **Vision**: image analysis
|
||||
- **Time**: current time
|
||||
@@ -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)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Test fixtures for Hive Coder agent."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
_repo_root = Path(__file__).resolve().parents[3]
|
||||
for _p in ["exports", "core"]:
|
||||
_path = str(_repo_root / _p)
|
||||
if _path not in sys.path:
|
||||
sys.path.insert(0, _path)
|
||||
|
||||
AGENT_PATH = str(Path(__file__).resolve().parents[1])
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def mock_mode():
|
||||
return True
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
async def runner(tmp_path_factory, mock_mode):
|
||||
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()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Queen's ticket receiver entry point.
|
||||
|
||||
When the Worker Health Judge emits a WORKER_ESCALATION_TICKET event on the
|
||||
shared EventBus, this entry point fires and routes to the ``ticket_triage``
|
||||
node, where the Queen deliberates and decides whether to notify the operator.
|
||||
|
||||
Isolation level is ``isolated`` — the queen's triage memory is kept separate
|
||||
from the worker's shared memory. Each ticket triage runs in its own context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from framework.graph.edge import AsyncEntryPointSpec
|
||||
|
||||
TICKET_RECEIVER_ENTRY_POINT = AsyncEntryPointSpec(
|
||||
id="ticket_receiver",
|
||||
name="Worker Escalation Ticket Receiver",
|
||||
entry_node="ticket_triage",
|
||||
trigger_type="event",
|
||||
trigger_config={
|
||||
"event_types": ["worker_escalation_ticket"],
|
||||
# Do not fire on our own graph's events (prevents loops if queen
|
||||
# somehow emits a worker_escalation_ticket for herself)
|
||||
"exclude_own_graph": True,
|
||||
},
|
||||
isolation_level="isolated",
|
||||
)
|
||||
@@ -245,20 +245,14 @@ class GraphBuilder:
|
||||
warnings.append(f"Node '{node.id}' should have a description")
|
||||
|
||||
# Type-specific validation
|
||||
if node.node_type == "llm_tool_use":
|
||||
if not node.tools:
|
||||
errors.append(f"LLM tool node '{node.id}' must specify tools")
|
||||
if not node.system_prompt:
|
||||
warnings.append(f"LLM node '{node.id}' should have a system_prompt")
|
||||
if node.node_type == "event_loop":
|
||||
if node.tools and not node.system_prompt:
|
||||
warnings.append(f"Event loop node '{node.id}' should have a system_prompt")
|
||||
|
||||
if node.node_type == "router":
|
||||
if not node.routes:
|
||||
errors.append(f"Router node '{node.id}' must specify routes")
|
||||
|
||||
if node.node_type == "function":
|
||||
if not node.function:
|
||||
errors.append(f"Function node '{node.id}' must specify function name")
|
||||
|
||||
# Check input/output keys
|
||||
if not node.input_keys:
|
||||
suggestions.append(f"Consider specifying input_keys for '{node.id}'")
|
||||
@@ -400,9 +394,13 @@ class GraphBuilder:
|
||||
if not terminal_candidates and self.session.nodes:
|
||||
warnings.append("No terminal nodes found (all nodes have outgoing edges)")
|
||||
|
||||
# Check reachability
|
||||
# Check reachability from ALL entry candidates (not just the first one).
|
||||
# Agents with async entry points have multiple nodes with no incoming
|
||||
# edges (e.g., a primary entry node and an event-driven entry node).
|
||||
if entry_candidates and self.session.nodes:
|
||||
reachable = self._compute_reachable(entry_candidates[0])
|
||||
reachable = set()
|
||||
for candidate in entry_candidates:
|
||||
reachable |= self._compute_reachable(candidate)
|
||||
unreachable = [n.id for n in self.session.nodes if n.id not in reachable]
|
||||
if unreachable:
|
||||
errors.append(f"Unreachable nodes: {unreachable}")
|
||||
@@ -443,14 +441,15 @@ class GraphBuilder:
|
||||
self.session.test_cases.append(test)
|
||||
self._save_session()
|
||||
|
||||
def run_test(
|
||||
async def run_test_async(
|
||||
self,
|
||||
test: TestCase,
|
||||
executor_factory: Callable,
|
||||
) -> TestResult:
|
||||
"""
|
||||
Run a single test case.
|
||||
Run a single test case asynchronously.
|
||||
|
||||
This method is safe to call from async contexts (Jupyter, FastAPI, etc.).
|
||||
executor_factory should return a configured GraphExecutor.
|
||||
"""
|
||||
self._require_phase([BuildPhase.ADDING_NODES, BuildPhase.ADDING_EDGES, BuildPhase.TESTING])
|
||||
@@ -462,14 +461,10 @@ class GraphBuilder:
|
||||
executor = executor_factory()
|
||||
|
||||
# Run the test
|
||||
import asyncio
|
||||
|
||||
result = asyncio.run(
|
||||
executor.execute(
|
||||
graph=graph,
|
||||
goal=self.session.goal,
|
||||
input_data=test.input,
|
||||
)
|
||||
result = await executor.execute(
|
||||
graph=graph,
|
||||
goal=self.session.goal,
|
||||
input_data=test.input,
|
||||
)
|
||||
|
||||
# Check result
|
||||
@@ -499,6 +494,36 @@ class GraphBuilder:
|
||||
|
||||
return test_result
|
||||
|
||||
def run_test(
|
||||
self,
|
||||
test: TestCase,
|
||||
executor_factory: Callable,
|
||||
) -> TestResult:
|
||||
"""
|
||||
Run a single test case.
|
||||
|
||||
This is a synchronous wrapper around run_test_async().
|
||||
If called from an async context (Jupyter, FastAPI, etc.), use run_test_async() instead.
|
||||
|
||||
executor_factory should return a configured GraphExecutor.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# Check if an event loop is already running
|
||||
# get_running_loop() returns a loop if one exists, or raises RuntimeError if none exists
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No event loop running - safe to use asyncio.run()
|
||||
return asyncio.run(self.run_test_async(test, executor_factory))
|
||||
|
||||
# Event loop is running - cannot use asyncio.run()
|
||||
raise RuntimeError(
|
||||
"Cannot call run_test() from an async context. "
|
||||
"An event loop is already running. "
|
||||
"Please use 'await builder.run_test_async(test, executor_factory)' instead."
|
||||
)
|
||||
|
||||
def run_all_tests(self, executor_factory: Callable) -> list[TestResult]:
|
||||
"""Run all test cases."""
|
||||
results = []
|
||||
@@ -635,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()
|
||||
|
||||
@@ -729,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]:
|
||||
|
||||
+17
-3
@@ -11,9 +11,9 @@ Usage:
|
||||
|
||||
Testing commands:
|
||||
hive test-run <agent_path> --goal <goal_id>
|
||||
hive test-debug <goal_id> <test_id>
|
||||
hive test-list <goal_id>
|
||||
hive test-stats <goal_id>
|
||||
hive test-debug <agent_path> <test_name>
|
||||
hive test-list <agent_path>
|
||||
hive test-stats <agent_path>
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -44,11 +44,25 @@ def _configure_paths():
|
||||
if exports_str not in sys.path:
|
||||
sys.path.insert(0, exports_str)
|
||||
|
||||
# Add examples/templates/ to sys.path so template agents are importable
|
||||
templates_dir = project_root / "examples" / "templates"
|
||||
if templates_dir.is_dir():
|
||||
templates_str = str(templates_dir)
|
||||
if templates_str not in sys.path:
|
||||
sys.path.insert(0, templates_str)
|
||||
|
||||
# Ensure core/ is also in sys.path (for non-editable-install scenarios)
|
||||
core_str = str(project_root / "core")
|
||||
if (project_root / "core").is_dir() and core_str not in sys.path:
|
||||
sys.path.insert(0, core_str)
|
||||
|
||||
# Add core/framework/agents/ so framework agents are importable as top-level packages
|
||||
framework_agents_dir = project_root / "core" / "framework" / "agents"
|
||||
if framework_agents_dir.is_dir():
|
||||
fa_str = str(framework_agents_dir)
|
||||
if fa_str not in sys.path:
|
||||
sys.path.insert(0, fa_str)
|
||||
|
||||
|
||||
def main():
|
||||
_configure_paths()
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Shared Hive configuration utilities.
|
||||
|
||||
Centralises reading of ~/.hive/configuration.json so that the runner
|
||||
and every agent template share one implementation instead of copy-pasting
|
||||
helper functions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from framework.graph.edge import DEFAULT_MAX_TOKENS
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Low-level config file access
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HIVE_CONFIG_FILE = Path.home() / ".hive" / "configuration.json"
|
||||
|
||||
|
||||
def get_hive_config() -> dict[str, Any]:
|
||||
"""Load hive configuration from ~/.hive/configuration.json."""
|
||||
if not HIVE_CONFIG_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(HIVE_CONFIG_FILE, encoding="utf-8-sig") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derived helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_preferred_model() -> str:
|
||||
"""Return the user's preferred LLM model string (e.g. 'anthropic/claude-sonnet-4-20250514')."""
|
||||
llm = get_hive_config().get("llm", {})
|
||||
if llm.get("provider") and llm.get("model"):
|
||||
return f"{llm['provider']}/{llm['model']}"
|
||||
return "anthropic/claude-sonnet-4-20250514"
|
||||
|
||||
|
||||
def get_max_tokens() -> int:
|
||||
"""Return the configured max_tokens, falling back to DEFAULT_MAX_TOKENS."""
|
||||
return get_hive_config().get("llm", {}).get("max_tokens", DEFAULT_MAX_TOKENS)
|
||||
|
||||
|
||||
def get_api_key() -> str | None:
|
||||
"""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. 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", {})
|
||||
|
||||
# Claude Code subscription: read OAuth token directly
|
||||
if llm.get("use_claude_code_subscription"):
|
||||
try:
|
||||
from framework.runner.runner import get_claude_code_token
|
||||
|
||||
token = get_claude_code_token()
|
||||
if token:
|
||||
return token
|
||||
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:
|
||||
return os.environ.get(api_key_env_var)
|
||||
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."""
|
||||
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]:
|
||||
"""Return extra kwargs for LiteLLMProvider (e.g. OAuth headers).
|
||||
|
||||
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"):
|
||||
api_key = get_api_key()
|
||||
if api_key:
|
||||
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 {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RuntimeConfig – shared across agent templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeConfig:
|
||||
"""Agent runtime configuration loaded from ~/.hive/configuration.json."""
|
||||
|
||||
model: str = field(default_factory=get_preferred_model)
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = field(default_factory=get_max_tokens)
|
||||
api_key: str | None = field(default_factory=get_api_key)
|
||||
api_base: str | None = field(default_factory=get_api_base)
|
||||
extra_kwargs: dict[str, Any] = field(default_factory=get_llm_extra_kwargs)
|
||||
@@ -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,
|
||||
@@ -59,6 +67,13 @@ from .provider import (
|
||||
CredentialProvider,
|
||||
StaticProvider,
|
||||
)
|
||||
from .setup import (
|
||||
CredentialSetupSession,
|
||||
MissingCredential,
|
||||
SetupResult,
|
||||
load_agent_nodes,
|
||||
run_credential_setup_cli,
|
||||
)
|
||||
from .storage import (
|
||||
CompositeStorage,
|
||||
CredentialStorage,
|
||||
@@ -68,6 +83,12 @@ from .storage import (
|
||||
)
|
||||
from .store import CredentialStore
|
||||
from .template import TemplateResolver
|
||||
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
|
||||
@@ -84,6 +105,14 @@ try:
|
||||
except ImportError:
|
||||
_ADEN_AVAILABLE = False
|
||||
|
||||
# Local credential registry (named API key accounts with identity metadata)
|
||||
try:
|
||||
from .local import LocalAccountInfo, LocalCredentialRegistry
|
||||
|
||||
_LOCAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
_LOCAL_AVAILABLE = False
|
||||
|
||||
__all__ = [
|
||||
# Main store
|
||||
"CredentialStore",
|
||||
@@ -111,12 +140,34 @@ __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",
|
||||
"load_agent_nodes",
|
||||
"run_credential_setup_cli",
|
||||
# Aden sync (optional - requires httpx)
|
||||
"AdenSyncProvider",
|
||||
"AdenCredentialClient",
|
||||
"AdenClientConfig",
|
||||
"AdenCachedStorage",
|
||||
# Local credential registry (optional - requires cryptography)
|
||||
"LocalCredentialRegistry",
|
||||
"LocalAccountInfo",
|
||||
]
|
||||
|
||||
# Track Aden availability for runtime checks
|
||||
ADEN_AVAILABLE = _ADEN_AVAILABLE
|
||||
LOCAL_AVAILABLE = _LOCAL_AVAILABLE
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
"""
|
||||
Aden Credential Client.
|
||||
|
||||
HTTP client for communicating with the Aden authentication server.
|
||||
The Aden server handles OAuth2 authorization flows and token management.
|
||||
This client fetches tokens and delegates refresh operations to Aden.
|
||||
HTTP client for the Aden authentication server.
|
||||
Aden holds all OAuth secrets; agents receive only short-lived access tokens.
|
||||
|
||||
API (all endpoints authenticated with Bearer {api_key}):
|
||||
|
||||
GET /v1/credentials — list integrations
|
||||
GET /v1/credentials/{integration_id} — get access token (auto-refreshes)
|
||||
POST /v1/credentials/{integration_id}/refresh — force refresh
|
||||
GET /v1/credentials/{integration_id}/validate — check validity
|
||||
|
||||
Integration IDs are base64-encoded hashes assigned by the Aden platform
|
||||
(e.g. "Z29vZ2xlOlRpbW90aHk6MTYwNjc6MTM2ODQ"), NOT provider names.
|
||||
|
||||
Usage:
|
||||
# API key loaded from ADEN_API_KEY environment variable by default
|
||||
client = AdenCredentialClient(AdenClientConfig(
|
||||
base_url="https://api.adenhq.com",
|
||||
))
|
||||
|
||||
# Or explicitly provide the API key
|
||||
client = AdenCredentialClient(AdenClientConfig(
|
||||
base_url="https://api.adenhq.com",
|
||||
api_key="your-api-key",
|
||||
))
|
||||
# List what's connected
|
||||
for info in client.list_integrations():
|
||||
print(f"{info.provider}/{info.alias}: {info.status}")
|
||||
|
||||
# Fetch a credential
|
||||
response = client.get_credential("hubspot")
|
||||
if response:
|
||||
print(f"Token expires at: {response.expires_at}")
|
||||
|
||||
# Request a refresh
|
||||
refreshed = client.request_refresh("hubspot")
|
||||
# Get an access token
|
||||
cred = client.get_credential(info.integration_id)
|
||||
print(cred.access_token)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -88,8 +90,7 @@ class AdenClientConfig:
|
||||
"""Base URL of the Aden server (e.g., 'https://api.adenhq.com')."""
|
||||
|
||||
api_key: str | None = None
|
||||
"""Agent's API key for authenticating with Aden.
|
||||
If not provided, loaded from ADEN_API_KEY environment variable."""
|
||||
"""Agent API key. Loaded from ADEN_API_KEY env var if not provided."""
|
||||
|
||||
tenant_id: str | None = None
|
||||
"""Optional tenant ID for multi-tenant deployments."""
|
||||
@@ -104,7 +105,6 @@ class AdenClientConfig:
|
||||
"""Base delay between retries in seconds (exponential backoff)."""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Load API key from environment if not provided."""
|
||||
if self.api_key is None:
|
||||
self.api_key = os.environ.get("ADEN_API_KEY")
|
||||
if not self.api_key:
|
||||
@@ -115,71 +115,124 @@ class AdenClientConfig:
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdenCredentialResponse:
|
||||
"""Response from Aden server containing credential data."""
|
||||
class AdenIntegrationInfo:
|
||||
"""An integration from GET /v1/credentials.
|
||||
|
||||
Example response item::
|
||||
|
||||
{
|
||||
"integration_id": "Z29vZ2xlOlRpbW90aHk6MTYwNjc6MTM2ODQ",
|
||||
"provider": "google",
|
||||
"alias": "Timothy",
|
||||
"status": "active",
|
||||
"email": "timothy@acho.io",
|
||||
"expires_at": "2026-02-20T21:46:04.863Z"
|
||||
}
|
||||
"""
|
||||
|
||||
integration_id: str
|
||||
"""Unique identifier for the integration (e.g., 'hubspot')."""
|
||||
"""Base64-encoded hash ID assigned by Aden."""
|
||||
|
||||
integration_type: str
|
||||
"""Type of integration (e.g., 'hubspot', 'github', 'slack')."""
|
||||
provider: str
|
||||
"""Provider type (e.g. "google", "slack", "hubspot")."""
|
||||
|
||||
access_token: str
|
||||
"""The access token for API calls."""
|
||||
alias: str
|
||||
"""User-set alias on the Aden platform."""
|
||||
|
||||
token_type: str = "Bearer"
|
||||
"""Token type (usually 'Bearer')."""
|
||||
status: str
|
||||
"""Status: "active", "expired", "requires_reauth"."""
|
||||
|
||||
email: str = ""
|
||||
"""Email associated with this connection."""
|
||||
|
||||
expires_at: datetime | None = None
|
||||
"""When the access token expires (UTC)."""
|
||||
"""When the current access token expires."""
|
||||
|
||||
scopes: list[str] = field(default_factory=list)
|
||||
"""OAuth2 scopes granted to this token."""
|
||||
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
"""Additional integration-specific metadata."""
|
||||
# Backward compat — old code reads integration_type
|
||||
@property
|
||||
def integration_type(self) -> str:
|
||||
return self.provider
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls, data: dict[str, Any], integration_id: str | None = None
|
||||
) -> AdenCredentialResponse:
|
||||
"""Create from API response dictionary."""
|
||||
def from_dict(cls, data: dict[str, Any]) -> AdenIntegrationInfo:
|
||||
expires_at = None
|
||||
if data.get("expires_at"):
|
||||
expires_at = datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00"))
|
||||
|
||||
return cls(
|
||||
integration_id=integration_id or data.get("alias", data.get("provider", "")),
|
||||
integration_type=data.get("provider", ""),
|
||||
access_token=data["access_token"],
|
||||
token_type=data.get("token_type", "Bearer"),
|
||||
integration_id=data.get("integration_id", ""),
|
||||
provider=data.get("provider", ""),
|
||||
alias=data.get("alias", ""),
|
||||
status=data.get("status", "unknown"),
|
||||
email=data.get("email", ""),
|
||||
expires_at=expires_at,
|
||||
scopes=data.get("scopes", []),
|
||||
metadata={"email": data.get("email")} if data.get("email") else {},
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdenIntegrationInfo:
|
||||
"""Information about an available integration."""
|
||||
class AdenCredentialResponse:
|
||||
"""Response from GET /v1/credentials/{integration_id}.
|
||||
|
||||
Example::
|
||||
|
||||
{
|
||||
"access_token": "ya29.a0AfH6SM...",
|
||||
"token_type": "Bearer",
|
||||
"expires_at": "2026-02-20T12:00:00.000Z",
|
||||
"provider": "google",
|
||||
"alias": "Timothy",
|
||||
"email": "timothy@acho.io"
|
||||
}
|
||||
"""
|
||||
|
||||
integration_id: str
|
||||
integration_type: str
|
||||
status: str # "active", "requires_reauth", "expired"
|
||||
"""The integration_id used in the request."""
|
||||
|
||||
access_token: str
|
||||
"""Short-lived access token for API calls."""
|
||||
|
||||
token_type: str = "Bearer"
|
||||
|
||||
expires_at: datetime | None = None
|
||||
|
||||
provider: str = ""
|
||||
"""Provider type (e.g. "google")."""
|
||||
|
||||
alias: str = ""
|
||||
"""User-set alias."""
|
||||
|
||||
email: str = ""
|
||||
"""Email associated with this connection."""
|
||||
|
||||
scopes: list[str] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Backward compat
|
||||
@property
|
||||
def integration_type(self) -> str:
|
||||
return self.provider
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> AdenIntegrationInfo:
|
||||
"""Create from API response dictionary."""
|
||||
def from_dict(cls, data: dict[str, Any], integration_id: str = "") -> AdenCredentialResponse:
|
||||
expires_at = None
|
||||
if data.get("expires_at"):
|
||||
expires_at = datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00"))
|
||||
|
||||
# Build metadata from email if present
|
||||
metadata = data.get("metadata") or {}
|
||||
if not metadata and data.get("email"):
|
||||
metadata = {"email": data["email"]}
|
||||
|
||||
return cls(
|
||||
integration_id=data["integration_id"],
|
||||
integration_type=data.get("provider", data["integration_id"]),
|
||||
status=data.get("status", "unknown"),
|
||||
integration_id=integration_id or data.get("integration_id", ""),
|
||||
access_token=data["access_token"],
|
||||
token_type=data.get("token_type", "Bearer"),
|
||||
expires_at=expires_at,
|
||||
provider=data.get("provider", ""),
|
||||
alias=data.get("alias", ""),
|
||||
email=data.get("email", ""),
|
||||
scopes=data.get("scopes", []),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
@@ -187,56 +240,33 @@ class AdenCredentialClient:
|
||||
"""
|
||||
HTTP client for Aden credential server.
|
||||
|
||||
Handles communication with the Aden authentication server,
|
||||
including fetching credentials, requesting refreshes, and
|
||||
reporting usage statistics.
|
||||
|
||||
The client automatically handles:
|
||||
- Retries with exponential backoff for transient failures
|
||||
- Proper error classification (auth, not found, rate limit, etc.)
|
||||
- Request headers for authentication and tenant isolation
|
||||
|
||||
Usage:
|
||||
# API key loaded from ADEN_API_KEY environment variable
|
||||
config = AdenClientConfig(
|
||||
client = AdenCredentialClient(AdenClientConfig(
|
||||
base_url="https://api.adenhq.com",
|
||||
)
|
||||
))
|
||||
|
||||
client = AdenCredentialClient(config)
|
||||
# List integrations
|
||||
for info in client.list_integrations():
|
||||
print(f"{info.provider}/{info.alias}: {info.status}")
|
||||
|
||||
# Fetch a credential
|
||||
cred = client.get_credential("hubspot")
|
||||
if cred:
|
||||
headers = {"Authorization": f"Bearer {cred.access_token}"}
|
||||
# Get access token (uses base64 integration_id, NOT provider name)
|
||||
cred = client.get_credential(info.integration_id)
|
||||
headers = {"Authorization": f"Bearer {cred.access_token}"}
|
||||
|
||||
# List all integrations
|
||||
integrations = client.list_integrations()
|
||||
for info in integrations:
|
||||
print(f"{info.integration_id}: {info.status}")
|
||||
|
||||
# Clean up
|
||||
client.close()
|
||||
"""
|
||||
|
||||
def __init__(self, config: AdenClientConfig):
|
||||
"""
|
||||
Initialize the Aden client.
|
||||
|
||||
Args:
|
||||
config: Client configuration including base URL and API key.
|
||||
"""
|
||||
self.config = config
|
||||
self._client: httpx.Client | None = None
|
||||
|
||||
def _get_client(self) -> httpx.Client:
|
||||
"""Get or create the HTTP client."""
|
||||
if self._client is None:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.config.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "hive-credential-store/1.0",
|
||||
}
|
||||
|
||||
if self.config.tenant_id:
|
||||
headers["X-Tenant-ID"] = self.config.tenant_id
|
||||
|
||||
@@ -245,7 +275,6 @@ class AdenCredentialClient:
|
||||
timeout=self.config.timeout,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
return self._client
|
||||
|
||||
def _request_with_retry(
|
||||
@@ -262,10 +291,13 @@ class AdenCredentialClient:
|
||||
try:
|
||||
response = client.request(method, path, **kwargs)
|
||||
|
||||
# Handle specific error codes
|
||||
if response.status_code == 401:
|
||||
raise AdenAuthenticationError("Agent API key is invalid or revoked")
|
||||
|
||||
if response.status_code == 403:
|
||||
data = response.json()
|
||||
raise AdenClientError(data.get("message", "Forbidden"))
|
||||
|
||||
if response.status_code == 404:
|
||||
raise AdenNotFoundError(f"Integration not found: {path}")
|
||||
|
||||
@@ -278,14 +310,15 @@ class AdenCredentialClient:
|
||||
|
||||
if response.status_code == 400:
|
||||
data = response.json()
|
||||
if data.get("error") == "refresh_failed":
|
||||
msg = data.get("message", "Bad request")
|
||||
if data.get("error") == "refresh_failed" or "refresh" in msg.lower():
|
||||
raise AdenRefreshError(
|
||||
data.get("message", "Token refresh failed"),
|
||||
msg,
|
||||
requires_reauthorization=data.get("requires_reauthorization", False),
|
||||
reauthorization_url=data.get("reauthorization_url"),
|
||||
)
|
||||
raise AdenClientError(f"Bad request: {msg}")
|
||||
|
||||
# Success or other error
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
@@ -306,30 +339,40 @@ class AdenCredentialClient:
|
||||
AdenRefreshError,
|
||||
AdenRateLimitError,
|
||||
):
|
||||
# Don't retry these errors
|
||||
raise
|
||||
|
||||
# Should not reach here, but just in case
|
||||
raise AdenClientError(
|
||||
f"Request failed after {self.config.retry_attempts} attempts"
|
||||
) from last_error
|
||||
|
||||
def get_credential(self, integration_id: str) -> AdenCredentialResponse | None:
|
||||
def list_integrations(self) -> list[AdenIntegrationInfo]:
|
||||
"""
|
||||
Fetch the current credential for an integration.
|
||||
List all integrations for this agent's team.
|
||||
|
||||
The Aden server may refresh the token internally if it's expired
|
||||
before returning it.
|
||||
|
||||
Args:
|
||||
integration_id: The integration identifier (e.g., 'hubspot').
|
||||
GET /v1/credentials → {"integrations": [...]}
|
||||
|
||||
Returns:
|
||||
Credential response with access token, or None if not found.
|
||||
List of AdenIntegrationInfo with integration_id, provider,
|
||||
alias, status, email, expires_at.
|
||||
"""
|
||||
response = self._request_with_retry("GET", "/v1/credentials")
|
||||
data = response.json()
|
||||
return [AdenIntegrationInfo.from_dict(item) for item in data.get("integrations", [])]
|
||||
|
||||
Raises:
|
||||
AdenAuthenticationError: If API key is invalid.
|
||||
AdenClientError: For connection failures.
|
||||
# Alias
|
||||
list_connections = list_integrations
|
||||
|
||||
def get_credential(self, integration_id: str) -> AdenCredentialResponse | None:
|
||||
"""
|
||||
Get access token for an integration. Auto-refreshes if near expiry.
|
||||
|
||||
GET /v1/credentials/{integration_id}
|
||||
|
||||
Args:
|
||||
integration_id: Base64 hash ID from list_integrations().
|
||||
|
||||
Returns:
|
||||
AdenCredentialResponse with access_token, or None if not found.
|
||||
"""
|
||||
try:
|
||||
response = self._request_with_retry("GET", f"/v1/credentials/{integration_id}")
|
||||
@@ -340,100 +383,34 @@ class AdenCredentialClient:
|
||||
|
||||
def request_refresh(self, integration_id: str) -> AdenCredentialResponse:
|
||||
"""
|
||||
Request the Aden server to refresh the token.
|
||||
Force refresh the access token.
|
||||
|
||||
Use this when the local store detects an expired or near-expiry token.
|
||||
The Aden server handles the actual OAuth2 refresh token flow.
|
||||
POST /v1/credentials/{integration_id}/refresh
|
||||
|
||||
Args:
|
||||
integration_id: The integration identifier.
|
||||
integration_id: Base64 hash ID.
|
||||
|
||||
Returns:
|
||||
Credential response with new access token.
|
||||
|
||||
Raises:
|
||||
AdenRefreshError: If refresh fails (may require re-authorization).
|
||||
AdenNotFoundError: If integration not found.
|
||||
AdenAuthenticationError: If API key is invalid.
|
||||
AdenRateLimitError: If rate limited.
|
||||
AdenCredentialResponse with new access_token.
|
||||
"""
|
||||
response = self._request_with_retry("POST", f"/v1/credentials/{integration_id}/refresh")
|
||||
data = response.json()
|
||||
return AdenCredentialResponse.from_dict(data, integration_id=integration_id)
|
||||
|
||||
def list_integrations(self) -> list[AdenIntegrationInfo]:
|
||||
"""
|
||||
List all integrations available for this agent/tenant.
|
||||
|
||||
Returns:
|
||||
List of integration info objects.
|
||||
|
||||
Raises:
|
||||
AdenAuthenticationError: If API key is invalid.
|
||||
AdenClientError: For connection failures.
|
||||
"""
|
||||
response = self._request_with_retry("GET", "/v1/credentials")
|
||||
data = response.json()
|
||||
return [AdenIntegrationInfo.from_dict(item) for item in data.get("integrations", [])]
|
||||
|
||||
def validate_token(self, integration_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Check if a token is still valid without fetching it.
|
||||
Check if an integration's OAuth connection is valid.
|
||||
|
||||
Args:
|
||||
integration_id: The integration identifier.
|
||||
GET /v1/credentials/{integration_id}/validate
|
||||
|
||||
Returns:
|
||||
Dict with 'valid' bool and optional 'expires_at', 'reason',
|
||||
'requires_reauthorization', 'reauthorization_url'.
|
||||
|
||||
Raises:
|
||||
AdenNotFoundError: If integration not found.
|
||||
AdenAuthenticationError: If API key is invalid.
|
||||
{"valid": bool, "status": str, "expires_at": str, "error": str|null}
|
||||
"""
|
||||
response = self._request_with_retry("GET", f"/v1/credentials/{integration_id}/validate")
|
||||
return response.json()
|
||||
|
||||
def report_usage(
|
||||
self,
|
||||
integration_id: str,
|
||||
operation: str,
|
||||
status: str = "success",
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Report credential usage statistics to Aden.
|
||||
|
||||
This is optional and used for analytics/billing.
|
||||
|
||||
Args:
|
||||
integration_id: The integration identifier.
|
||||
operation: Operation name (e.g., 'api_call').
|
||||
status: Operation status ('success', 'error').
|
||||
metadata: Additional operation metadata.
|
||||
"""
|
||||
try:
|
||||
self._request_with_retry(
|
||||
"POST",
|
||||
f"/v1/credentials/{integration_id}/usage",
|
||||
json={
|
||||
"operation": operation,
|
||||
"status": status,
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"metadata": metadata or {},
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
# Usage reporting is best-effort, don't fail on errors
|
||||
logger.warning(f"Failed to report usage for '{integration_id}': {e}")
|
||||
|
||||
def health_check(self) -> dict[str, Any]:
|
||||
"""
|
||||
Check Aden server health and connectivity.
|
||||
|
||||
Returns:
|
||||
Dict with 'status', 'version', 'timestamp', and optionally 'error'.
|
||||
"""
|
||||
"""Check Aden server health."""
|
||||
try:
|
||||
client = self._get_client()
|
||||
response = client.get("/health")
|
||||
@@ -441,26 +418,17 @@ class AdenCredentialClient:
|
||||
data = response.json()
|
||||
data["latency_ms"] = response.elapsed.total_seconds() * 1000
|
||||
return data
|
||||
return {
|
||||
"status": "degraded",
|
||||
"error": f"Unexpected status code: {response.status_code}",
|
||||
}
|
||||
return {"status": "degraded", "error": f"HTTP {response.status_code}"}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"error": str(e),
|
||||
}
|
||||
return {"status": "unhealthy", "error": str(e)}
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the HTTP client and release resources."""
|
||||
if self._client:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
|
||||
def __enter__(self) -> AdenCredentialClient:
|
||||
"""Context manager entry."""
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
"""Context manager exit."""
|
||||
self.close()
|
||||
|
||||
@@ -282,8 +282,8 @@ class AdenSyncProvider(CredentialProvider):
|
||||
"""
|
||||
Sync all credentials from Aden server to local store.
|
||||
|
||||
Fetches the list of available integrations from Aden and
|
||||
populates the local credential store with current tokens.
|
||||
Calls GET /v1/credentials to list integrations, then fetches
|
||||
access tokens for each active one.
|
||||
|
||||
Args:
|
||||
store: The credential store to populate.
|
||||
@@ -298,9 +298,7 @@ class AdenSyncProvider(CredentialProvider):
|
||||
|
||||
for info in integrations:
|
||||
if info.status != "active":
|
||||
logger.warning(
|
||||
f"Skipping integration '{info.integration_id}': status={info.status}"
|
||||
)
|
||||
logger.warning(f"Skipping connection '{info.alias}': status={info.status}")
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -308,9 +306,9 @@ class AdenSyncProvider(CredentialProvider):
|
||||
if cred:
|
||||
store.save_credential(cred)
|
||||
synced += 1
|
||||
logger.info(f"Synced credential '{info.integration_id}' from Aden")
|
||||
logger.info(f"Synced credential '{info.alias}' from Aden")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync '{info.integration_id}': {e}")
|
||||
logger.warning(f"Failed to sync '{info.alias}': {e}")
|
||||
|
||||
except AdenClientError as e:
|
||||
logger.error(f"Failed to list integrations from Aden: {e}")
|
||||
@@ -373,6 +371,21 @@ class AdenSyncProvider(CredentialProvider):
|
||||
value=SecretStr(aden_response.integration_type),
|
||||
)
|
||||
|
||||
# Store alias (user-set name from Aden platform)
|
||||
if aden_response.alias:
|
||||
credential.keys["_alias"] = CredentialKey(
|
||||
name="_alias",
|
||||
value=SecretStr(aden_response.alias),
|
||||
)
|
||||
|
||||
# Persist Aden metadata as identity keys
|
||||
for meta_key, meta_value in (aden_response.metadata or {}).items():
|
||||
if meta_value and isinstance(meta_value, str):
|
||||
credential.keys[f"_identity_{meta_key}"] = CredentialKey(
|
||||
name=f"_identity_{meta_key}",
|
||||
value=SecretStr(meta_value),
|
||||
)
|
||||
|
||||
# Update timestamps
|
||||
credential.last_refreshed = datetime.now(UTC)
|
||||
credential.provider_id = self.provider_id
|
||||
@@ -400,12 +413,27 @@ class AdenSyncProvider(CredentialProvider):
|
||||
),
|
||||
}
|
||||
|
||||
# Store alias (user-set name from Aden platform)
|
||||
if aden_response.alias:
|
||||
keys["_alias"] = CredentialKey(
|
||||
name="_alias",
|
||||
value=SecretStr(aden_response.alias),
|
||||
)
|
||||
|
||||
if aden_response.scopes:
|
||||
keys["scope"] = CredentialKey(
|
||||
name="scope",
|
||||
value=SecretStr(" ".join(aden_response.scopes)),
|
||||
)
|
||||
|
||||
# Persist Aden metadata as identity keys
|
||||
for meta_key, meta_value in (aden_response.metadata or {}).items():
|
||||
if meta_value and isinstance(meta_value, str):
|
||||
keys[f"_identity_{meta_key}"] = CredentialKey(
|
||||
name=f"_identity_{meta_key}",
|
||||
value=SecretStr(meta_value),
|
||||
)
|
||||
|
||||
return CredentialObject(
|
||||
id=aden_response.integration_id,
|
||||
credential_type=CredentialType.OAUTH2,
|
||||
|
||||
@@ -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(
|
||||
@@ -114,8 +114,10 @@ class AdenCachedStorage(CredentialStorage):
|
||||
self._cache_ttl = timedelta(seconds=cache_ttl_seconds)
|
||||
self._prefer_local = prefer_local
|
||||
self._cache_timestamps: dict[str, datetime] = {}
|
||||
# Index: provider name (e.g., "hubspot") -> credential hash ID
|
||||
self._provider_index: dict[str, str] = {}
|
||||
# Index: provider name (e.g., "hubspot") -> list of credential hash IDs
|
||||
self._provider_index: dict[str, list[str]] = {}
|
||||
# Index: "provider:alias" -> credential hash ID (for alias-based routing)
|
||||
self._alias_index: dict[str, str] = {}
|
||||
|
||||
def save(self, credential: CredentialObject) -> None:
|
||||
"""
|
||||
@@ -160,14 +162,16 @@ class AdenCachedStorage(CredentialStorage):
|
||||
CredentialObject if found, None otherwise.
|
||||
"""
|
||||
# Check provider index first — Aden-synced credentials take priority
|
||||
resolved_id = self._provider_index.get(credential_id)
|
||||
if resolved_id and resolved_id != credential_id:
|
||||
result = self._load_by_id(resolved_id)
|
||||
if result is not None:
|
||||
logger.info(
|
||||
f"Loaded credential '{credential_id}' via provider index (id='{resolved_id}')"
|
||||
)
|
||||
return result
|
||||
resolved_ids = self._provider_index.get(credential_id)
|
||||
if resolved_ids:
|
||||
for rid in resolved_ids:
|
||||
if rid != credential_id:
|
||||
result = self._load_by_id(rid)
|
||||
if result is not None:
|
||||
logger.info(
|
||||
f"Loaded credential '{credential_id}' via provider index (id='{rid}')"
|
||||
)
|
||||
return result
|
||||
|
||||
# Direct lookup (exact credential_id match)
|
||||
return self._load_by_id(credential_id)
|
||||
@@ -189,25 +193,42 @@ class AdenCachedStorage(CredentialStorage):
|
||||
logger.debug(f"Using cached credential '{credential_id}'")
|
||||
return local_cred
|
||||
|
||||
# Try to fetch from Aden
|
||||
# If nothing local, there's nothing to refresh from Aden.
|
||||
# sync_all() already fetched all available credentials — anything
|
||||
# not in local storage doesn't exist on the Aden server.
|
||||
if local_cred is None:
|
||||
return None
|
||||
|
||||
# Try to refresh stale local credential from Aden
|
||||
try:
|
||||
aden_cred = self._aden_provider.fetch_from_aden(credential_id)
|
||||
if aden_cred:
|
||||
# Update local cache
|
||||
self.save(aden_cred)
|
||||
logger.debug(f"Fetched credential '{credential_id}' from Aden")
|
||||
return aden_cred
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch '{credential_id}' from Aden: {e}")
|
||||
logger.info(f"Using stale cached credential '{credential_id}'")
|
||||
return local_cred
|
||||
|
||||
# Fall back to local cache if Aden fails
|
||||
if local_cred:
|
||||
logger.info(f"Using stale cached credential '{credential_id}'")
|
||||
return local_cred
|
||||
|
||||
# Return local credential if it exists (may be None)
|
||||
return local_cred
|
||||
|
||||
def load_all_for_provider(self, provider_name: str) -> list[CredentialObject]:
|
||||
"""Load all credentials for a given provider type.
|
||||
|
||||
Args:
|
||||
provider_name: Provider name (e.g. "google", "slack").
|
||||
|
||||
Returns:
|
||||
List of CredentialObjects for all accounts of this provider.
|
||||
"""
|
||||
results: list[CredentialObject] = []
|
||||
for cid in self._provider_index.get(provider_name, []):
|
||||
cred = self._load_by_id(cid)
|
||||
if cred:
|
||||
results.append(cred)
|
||||
return results
|
||||
|
||||
def delete(self, credential_id: str) -> bool:
|
||||
"""
|
||||
Delete credential from local cache.
|
||||
@@ -246,9 +267,11 @@ class AdenCachedStorage(CredentialStorage):
|
||||
if self._local.exists(credential_id):
|
||||
return True
|
||||
# Check provider index
|
||||
resolved_id = self._provider_index.get(credential_id)
|
||||
if resolved_id and resolved_id != credential_id:
|
||||
return self._local.exists(resolved_id)
|
||||
resolved_ids = self._provider_index.get(credential_id)
|
||||
if resolved_ids:
|
||||
for rid in resolved_ids:
|
||||
if rid != credential_id and self._local.exists(rid):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_cache_fresh(self, credential_id: str) -> bool:
|
||||
@@ -285,13 +308,15 @@ class AdenCachedStorage(CredentialStorage):
|
||||
|
||||
def _index_provider(self, credential: CredentialObject) -> None:
|
||||
"""
|
||||
Index a credential by its provider/integration type.
|
||||
Index a credential by its provider/integration type and alias.
|
||||
|
||||
Aden credentials carry an ``_integration_type`` key whose value is
|
||||
the provider name (e.g., ``hubspot``). This method maps that
|
||||
provider name to the credential's hash ID so that subsequent
|
||||
``load("hubspot")`` calls resolve to the correct credential.
|
||||
|
||||
Also indexes by ``_alias`` for alias-based multi-account routing.
|
||||
|
||||
Args:
|
||||
credential: The credential to index.
|
||||
"""
|
||||
@@ -300,19 +325,45 @@ class AdenCachedStorage(CredentialStorage):
|
||||
return
|
||||
provider_name = integration_type_key.value.get_secret_value()
|
||||
if provider_name:
|
||||
self._provider_index[provider_name] = credential.id
|
||||
if provider_name not in self._provider_index:
|
||||
self._provider_index[provider_name] = []
|
||||
if credential.id not in self._provider_index[provider_name]:
|
||||
self._provider_index[provider_name].append(credential.id)
|
||||
logger.debug(f"Indexed provider '{provider_name}' -> '{credential.id}'")
|
||||
|
||||
# Index by alias for multi-account routing
|
||||
alias_key = credential.keys.get("_alias")
|
||||
if alias_key:
|
||||
alias = alias_key.value.get_secret_value()
|
||||
if alias:
|
||||
self._alias_index[f"{provider_name}:{alias}"] = credential.id
|
||||
|
||||
def load_by_alias(self, provider_name: str, alias: str) -> CredentialObject | None:
|
||||
"""Load a credential by provider name and alias.
|
||||
|
||||
Args:
|
||||
provider_name: Provider type (e.g. "google", "slack").
|
||||
alias: User-set alias from the Aden platform.
|
||||
|
||||
Returns:
|
||||
CredentialObject if found, None otherwise.
|
||||
"""
|
||||
cred_id = self._alias_index.get(f"{provider_name}:{alias}")
|
||||
if cred_id:
|
||||
return self._load_by_id(cred_id)
|
||||
return None
|
||||
|
||||
def rebuild_provider_index(self) -> int:
|
||||
"""
|
||||
Rebuild the provider index from all locally cached credentials.
|
||||
Rebuild the provider and alias indexes from all locally cached credentials.
|
||||
|
||||
Useful after loading from disk when the in-memory index is empty.
|
||||
Useful after loading from disk when the in-memory indexes are empty.
|
||||
|
||||
Returns:
|
||||
Number of provider mappings indexed.
|
||||
"""
|
||||
self._provider_index.clear()
|
||||
self._alias_index.clear()
|
||||
indexed = 0
|
||||
for cred_id in self._local.list_all():
|
||||
cred = self._local.load(cred_id)
|
||||
@@ -328,8 +379,8 @@ class AdenCachedStorage(CredentialStorage):
|
||||
"""
|
||||
Sync all credentials from Aden server to local cache.
|
||||
|
||||
Fetches the list of available integrations from Aden and
|
||||
updates the local cache with current tokens.
|
||||
Calls GET /v1/credentials to list active integrations,
|
||||
then fetches tokens for each.
|
||||
|
||||
Returns:
|
||||
Number of credentials synced.
|
||||
@@ -341,9 +392,7 @@ class AdenCachedStorage(CredentialStorage):
|
||||
|
||||
for info in integrations:
|
||||
if info.status != "active":
|
||||
logger.warning(
|
||||
f"Skipping integration '{info.integration_id}': status={info.status}"
|
||||
)
|
||||
logger.warning(f"Skipping integration '{info.alias}': status={info.status}")
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -351,9 +400,9 @@ class AdenCachedStorage(CredentialStorage):
|
||||
if cred:
|
||||
self.save(cred)
|
||||
synced += 1
|
||||
logger.info(f"Synced credential '{info.integration_id}' from Aden")
|
||||
logger.info(f"Synced credential '{info.alias}' from Aden")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync '{info.integration_id}': {e}")
|
||||
logger.warning(f"Failed to sync '{info.alias}': {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list integrations from Aden: {e}")
|
||||
|
||||
@@ -61,11 +61,13 @@ def mock_client(aden_config):
|
||||
def aden_response():
|
||||
"""Create a sample Aden credential response."""
|
||||
return AdenCredentialResponse(
|
||||
integration_id="hubspot",
|
||||
integration_type="hubspot",
|
||||
integration_id="aHVic3BvdDp0ZXN0OjEzNjExOjExNTI1",
|
||||
access_token="test-access-token",
|
||||
token_type="Bearer",
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=1),
|
||||
provider="hubspot",
|
||||
alias="My HubSpot",
|
||||
email="test@example.com",
|
||||
scopes=["crm.objects.contacts.read", "crm.objects.contacts.write"],
|
||||
metadata={"portal_id": "12345"},
|
||||
)
|
||||
@@ -108,18 +110,20 @@ class TestAdenCredentialResponse:
|
||||
"""Tests for AdenCredentialResponse dataclass."""
|
||||
|
||||
def test_from_dict_basic(self):
|
||||
"""Test creating response from dict."""
|
||||
"""Test creating response from dict (real get-token format)."""
|
||||
data = {
|
||||
"integration_id": "github",
|
||||
"integration_type": "github",
|
||||
"access_token": "ghp_xxxxx",
|
||||
"token_type": "Bearer",
|
||||
"provider": "github",
|
||||
"alias": "Work",
|
||||
}
|
||||
|
||||
response = AdenCredentialResponse.from_dict(data)
|
||||
response = AdenCredentialResponse.from_dict(data, integration_id="Z2l0aHViOldvcms6MTIzNDU")
|
||||
|
||||
assert response.integration_id == "github"
|
||||
assert response.integration_type == "github"
|
||||
assert response.integration_id == "Z2l0aHViOldvcms6MTIzNDU"
|
||||
assert response.access_token == "ghp_xxxxx"
|
||||
assert response.provider == "github"
|
||||
assert response.integration_type == "github" # backward compat property
|
||||
assert response.token_type == "Bearer"
|
||||
assert response.expires_at is None
|
||||
assert response.scopes == []
|
||||
@@ -127,19 +131,23 @@ class TestAdenCredentialResponse:
|
||||
def test_from_dict_full(self):
|
||||
"""Test creating response with all fields."""
|
||||
data = {
|
||||
"integration_id": "hubspot",
|
||||
"integration_type": "hubspot",
|
||||
"access_token": "token123",
|
||||
"token_type": "Bearer",
|
||||
"expires_at": "2026-01-28T15:30:00Z",
|
||||
"provider": "hubspot",
|
||||
"alias": "My HubSpot",
|
||||
"email": "test@example.com",
|
||||
"scopes": ["read", "write"],
|
||||
"metadata": {"key": "value"},
|
||||
}
|
||||
|
||||
response = AdenCredentialResponse.from_dict(data)
|
||||
response = AdenCredentialResponse.from_dict(data, integration_id="aHVic3BvdDp0ZXN0")
|
||||
|
||||
assert response.integration_id == "hubspot"
|
||||
assert response.integration_id == "aHVic3BvdDp0ZXN0"
|
||||
assert response.access_token == "token123"
|
||||
assert response.provider == "hubspot"
|
||||
assert response.alias == "My HubSpot"
|
||||
assert response.email == "test@example.com"
|
||||
assert response.expires_at is not None
|
||||
assert response.scopes == ["read", "write"]
|
||||
assert response.metadata == {"key": "value"}
|
||||
@@ -149,21 +157,44 @@ class TestAdenIntegrationInfo:
|
||||
"""Tests for AdenIntegrationInfo dataclass."""
|
||||
|
||||
def test_from_dict(self):
|
||||
"""Test creating integration info from dict."""
|
||||
"""Test creating integration info from real API format."""
|
||||
data = {
|
||||
"integration_id": "slack",
|
||||
"integration_type": "slack",
|
||||
"integration_id": "c2xhY2s6V29yayBTbGFjazoxMjM0NQ",
|
||||
"provider": "slack",
|
||||
"alias": "Work Slack",
|
||||
"status": "active",
|
||||
"expires_at": "2026-02-01T00:00:00Z",
|
||||
"email": "user@example.com",
|
||||
"expires_at": "2026-02-20T21:46:04.863Z",
|
||||
}
|
||||
|
||||
info = AdenIntegrationInfo.from_dict(data)
|
||||
|
||||
assert info.integration_id == "slack"
|
||||
assert info.integration_type == "slack"
|
||||
assert info.integration_id == "c2xhY2s6V29yayBTbGFjazoxMjM0NQ"
|
||||
assert info.provider == "slack"
|
||||
assert info.integration_type == "slack" # backward compat property
|
||||
assert info.alias == "Work Slack"
|
||||
assert info.email == "user@example.com"
|
||||
assert info.status == "active"
|
||||
assert info.expires_at is not None
|
||||
|
||||
def test_from_dict_minimal(self):
|
||||
"""Test creating integration info with minimal fields."""
|
||||
data = {
|
||||
"integration_id": "Z29vZ2xlOlRpbW90aHk6MTYwNjc",
|
||||
"provider": "google",
|
||||
"alias": "Timothy",
|
||||
"status": "requires_reauth",
|
||||
}
|
||||
|
||||
info = AdenIntegrationInfo.from_dict(data)
|
||||
|
||||
assert info.integration_id == "Z29vZ2xlOlRpbW90aHk6MTYwNjc"
|
||||
assert info.provider == "google"
|
||||
assert info.alias == "Timothy"
|
||||
assert info.status == "requires_reauth"
|
||||
assert info.email == ""
|
||||
assert info.expires_at is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AdenSyncProvider Tests
|
||||
@@ -220,10 +251,11 @@ class TestAdenSyncProvider:
|
||||
|
||||
def test_refresh_success(self, provider, mock_client, aden_response):
|
||||
"""Test successful credential refresh."""
|
||||
hash_id = "aHVic3BvdDp0ZXN0OjEzNjExOjExNTI1"
|
||||
mock_client.request_refresh.return_value = aden_response
|
||||
|
||||
cred = CredentialObject(
|
||||
id="hubspot",
|
||||
id=hash_id,
|
||||
credential_type=CredentialType.OAUTH2,
|
||||
keys={
|
||||
"access_token": CredentialKey(
|
||||
@@ -239,7 +271,7 @@ class TestAdenSyncProvider:
|
||||
assert refreshed.keys["access_token"].value.get_secret_value() == "test-access-token"
|
||||
assert refreshed.keys["_aden_managed"].value.get_secret_value() == "true"
|
||||
assert refreshed.last_refreshed is not None
|
||||
mock_client.request_refresh.assert_called_once_with("hubspot")
|
||||
mock_client.request_refresh.assert_called_once_with(hash_id)
|
||||
|
||||
def test_refresh_requires_reauth(self, provider, mock_client):
|
||||
"""Test refresh that requires re-authorization."""
|
||||
@@ -339,12 +371,13 @@ class TestAdenSyncProvider:
|
||||
|
||||
def test_fetch_from_aden(self, provider, mock_client, aden_response):
|
||||
"""Test fetching credential from Aden."""
|
||||
hash_id = "aHVic3BvdDp0ZXN0OjEzNjExOjExNTI1"
|
||||
mock_client.get_credential.return_value = aden_response
|
||||
|
||||
cred = provider.fetch_from_aden("hubspot")
|
||||
cred = provider.fetch_from_aden(hash_id)
|
||||
|
||||
assert cred is not None
|
||||
assert cred.id == "hubspot"
|
||||
assert cred.id == hash_id
|
||||
assert cred.keys["access_token"].value.get_secret_value() == "test-access-token"
|
||||
assert cred.auto_refresh is True
|
||||
|
||||
@@ -360,13 +393,15 @@ class TestAdenSyncProvider:
|
||||
"""Test syncing all credentials."""
|
||||
mock_client.list_integrations.return_value = [
|
||||
AdenIntegrationInfo(
|
||||
integration_id="hubspot",
|
||||
integration_type="hubspot",
|
||||
integration_id="aHVic3BvdDp0ZXN0OjEzNjExOjExNTI1",
|
||||
provider="hubspot",
|
||||
alias="My HubSpot",
|
||||
status="active",
|
||||
),
|
||||
AdenIntegrationInfo(
|
||||
integration_id="github",
|
||||
integration_type="github",
|
||||
integration_id="Z2l0aHViOnRlc3Q6OTk5",
|
||||
provider="github",
|
||||
alias="Work GitHub",
|
||||
status="requires_reauth", # Should be skipped
|
||||
),
|
||||
]
|
||||
@@ -376,7 +411,7 @@ class TestAdenSyncProvider:
|
||||
synced = provider.sync_all(store)
|
||||
|
||||
assert synced == 1 # Only active one was synced
|
||||
assert store.get_credential("hubspot") is not None
|
||||
assert store.get_credential("aHVic3BvdDp0ZXN0OjEzNjExOjExNTI1") is not None
|
||||
|
||||
def test_validate_via_aden(self, provider, mock_client):
|
||||
"""Test validation via Aden introspection."""
|
||||
@@ -608,7 +643,7 @@ class TestAdenCachedStorage:
|
||||
|
||||
cached_storage.save(cred)
|
||||
|
||||
assert cached_storage._provider_index["hubspot"] == "aHVic3BvdDp0ZXN0OjEzNjExOjExNTI1"
|
||||
assert cached_storage._provider_index["hubspot"] == ["aHVic3BvdDp0ZXN0OjEzNjExOjExNTI1"]
|
||||
|
||||
def test_load_by_provider_name(self, cached_storage):
|
||||
"""Test load resolves provider name to hash-based credential ID."""
|
||||
@@ -711,8 +746,8 @@ class TestAdenCachedStorage:
|
||||
indexed = cached_storage.rebuild_provider_index()
|
||||
|
||||
assert indexed == 2
|
||||
assert cached_storage._provider_index["hubspot"] == "hash_hub"
|
||||
assert cached_storage._provider_index["slack"] == "hash_slack"
|
||||
assert cached_storage._provider_index["hubspot"] == ["hash_hub"]
|
||||
assert cached_storage._provider_index["slack"] == ["hash_slack"]
|
||||
|
||||
def test_save_without_integration_type_no_index(self, cached_storage):
|
||||
"""Test save does not index credentials without _integration_type key."""
|
||||
@@ -743,19 +778,23 @@ class TestAdenIntegration:
|
||||
|
||||
def test_full_workflow(self, mock_client, aden_response):
|
||||
"""Test full workflow: sync, get, refresh."""
|
||||
hash_id = "aHVic3BvdDp0ZXN0OjEzNjExOjExNTI1"
|
||||
|
||||
# Setup
|
||||
mock_client.list_integrations.return_value = [
|
||||
AdenIntegrationInfo(
|
||||
integration_id="hubspot",
|
||||
integration_type="hubspot",
|
||||
integration_id=hash_id,
|
||||
provider="hubspot",
|
||||
alias="My HubSpot",
|
||||
status="active",
|
||||
),
|
||||
]
|
||||
mock_client.get_credential.return_value = aden_response
|
||||
mock_client.request_refresh.return_value = AdenCredentialResponse(
|
||||
integration_id="hubspot",
|
||||
integration_type="hubspot",
|
||||
integration_id=hash_id,
|
||||
access_token="refreshed-token",
|
||||
provider="hubspot",
|
||||
alias="My HubSpot",
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=2),
|
||||
scopes=[],
|
||||
)
|
||||
@@ -772,8 +811,8 @@ class TestAdenIntegration:
|
||||
synced = provider.sync_all(store)
|
||||
assert synced == 1
|
||||
|
||||
# Get credential
|
||||
cred = store.get_credential("hubspot")
|
||||
# Get credential by hash ID
|
||||
cred = store.get_credential(hash_id)
|
||||
assert cred is not None
|
||||
assert cred.keys["access_token"].value.get_secret_value() == "test-access-token"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Local credential registry — named API key accounts with identity metadata.
|
||||
|
||||
Provides feature parity with Aden OAuth credentials for locally-stored API keys:
|
||||
aliases, identity metadata, status tracking, CRUD, and health validation.
|
||||
|
||||
Usage:
|
||||
from framework.credentials.local import LocalCredentialRegistry, LocalAccountInfo
|
||||
|
||||
registry = LocalCredentialRegistry.default()
|
||||
|
||||
# Add a named account
|
||||
info, health = registry.save_account("brave_search", "work", "BSA-xxx")
|
||||
|
||||
# List all stored local accounts
|
||||
for account in registry.list_accounts():
|
||||
print(f"{account.credential_id}/{account.alias}: {account.status}")
|
||||
if account.identity.is_known:
|
||||
print(f" Identity: {account.identity.label}")
|
||||
|
||||
# Re-validate a stored account
|
||||
result = registry.validate_account("github", "personal")
|
||||
"""
|
||||
|
||||
from .models import LocalAccountInfo
|
||||
from .registry import LocalCredentialRegistry
|
||||
|
||||
__all__ = [
|
||||
"LocalAccountInfo",
|
||||
"LocalCredentialRegistry",
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
Data models for the local credential registry.
|
||||
|
||||
LocalAccountInfo mirrors AdenIntegrationInfo, giving local API key credentials
|
||||
the same identity/status metadata as Aden OAuth credentials.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from framework.credentials.models import CredentialIdentity
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalAccountInfo:
|
||||
"""
|
||||
A locally-stored named credential account.
|
||||
|
||||
Mirrors AdenIntegrationInfo so local and Aden accounts can be treated
|
||||
uniformly in the credential tester and account selection UI.
|
||||
|
||||
Attributes:
|
||||
credential_id: The logical credential name (e.g. "brave_search", "github")
|
||||
alias: User-provided name for this account (e.g. "work", "personal")
|
||||
status: "active" | "failed" | "unknown"
|
||||
identity: Email, username, workspace, or account_id extracted from health check
|
||||
last_validated: When the key was last verified against the live API
|
||||
created_at: When this account was first stored
|
||||
"""
|
||||
|
||||
credential_id: str
|
||||
alias: str
|
||||
status: str = "unknown"
|
||||
identity: CredentialIdentity = field(default_factory=CredentialIdentity)
|
||||
last_validated: datetime | None = None
|
||||
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||
|
||||
@property
|
||||
def storage_id(self) -> str:
|
||||
"""The key used in EncryptedFileStorage: '{credential_id}/{alias}'."""
|
||||
return f"{self.credential_id}/{self.alias}"
|
||||
|
||||
def to_account_dict(self) -> dict:
|
||||
"""
|
||||
Format compatible with AccountSelectionScreen and configure_for_account().
|
||||
|
||||
Same shape as Aden account dicts, with source='local' added.
|
||||
"""
|
||||
return {
|
||||
"provider": self.credential_id,
|
||||
"alias": self.alias,
|
||||
"identity": self.identity.to_dict(),
|
||||
"integration_id": None,
|
||||
"source": "local",
|
||||
"status": self.status,
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
Local Credential Registry.
|
||||
|
||||
Manages named local API key accounts stored in EncryptedFileStorage.
|
||||
Mirrors the Aden integration model so local credentials have feature parity:
|
||||
aliases, identity metadata, status tracking, CRUD, and health validation.
|
||||
|
||||
Storage convention:
|
||||
{credential_id}/{alias} → CredentialObject
|
||||
e.g. "brave_search/work" → { api_key: "BSA-xxx", _alias: "work",
|
||||
_integration_type: "brave_search",
|
||||
_status: "active",
|
||||
_identity_username: "acme", ... }
|
||||
|
||||
Usage:
|
||||
registry = LocalCredentialRegistry.default()
|
||||
|
||||
# Add a new account
|
||||
info, health = registry.save_account("brave_search", "work", "BSA-xxx")
|
||||
print(info.status, info.identity.label)
|
||||
|
||||
# List all accounts
|
||||
for account in registry.list_accounts():
|
||||
print(f"{account.credential_id}/{account.alias}: {account.status}")
|
||||
|
||||
# Get the raw API key for a specific account
|
||||
key = registry.get_key("github", "personal")
|
||||
|
||||
# Re-validate a stored account
|
||||
result = registry.validate_account("github", "personal")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from framework.credentials.models import CredentialIdentity, CredentialObject
|
||||
from framework.credentials.storage import EncryptedFileStorage
|
||||
|
||||
from .models import LocalAccountInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aden_tools.credentials.health_check import HealthCheckResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SEPARATOR = "/"
|
||||
|
||||
|
||||
class LocalCredentialRegistry:
|
||||
"""
|
||||
Named local API key account store backed by EncryptedFileStorage.
|
||||
|
||||
Provides the same list/save/get/delete/validate surface as the Aden
|
||||
client, but for locally-stored API keys.
|
||||
"""
|
||||
|
||||
def __init__(self, storage: EncryptedFileStorage) -> None:
|
||||
self._storage = storage
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Listing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_accounts(self, credential_id: str | None = None) -> list[LocalAccountInfo]:
|
||||
"""
|
||||
List all stored local accounts.
|
||||
|
||||
Args:
|
||||
credential_id: If given, filter to this credential type only.
|
||||
|
||||
Returns:
|
||||
List of LocalAccountInfo sorted by credential_id then alias.
|
||||
"""
|
||||
all_ids = self._storage.list_all()
|
||||
accounts: list[LocalAccountInfo] = []
|
||||
|
||||
for storage_id in all_ids:
|
||||
if _SEPARATOR not in storage_id:
|
||||
continue # Skip legacy un-aliased entries
|
||||
|
||||
try:
|
||||
cred_obj = self._storage.load(storage_id)
|
||||
except Exception as exc:
|
||||
logger.debug("Skipping unreadable credential %s: %s", storage_id, exc)
|
||||
continue
|
||||
|
||||
if cred_obj is None:
|
||||
continue
|
||||
|
||||
info = self._to_account_info(cred_obj)
|
||||
if info is None:
|
||||
continue
|
||||
|
||||
if credential_id and info.credential_id != credential_id:
|
||||
continue
|
||||
|
||||
accounts.append(info)
|
||||
|
||||
return sorted(accounts, key=lambda a: (a.credential_id, a.alias))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Save / add
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def save_account(
|
||||
self,
|
||||
credential_id: str,
|
||||
alias: str,
|
||||
api_key: str,
|
||||
run_health_check: bool = True,
|
||||
extra_keys: dict[str, str] | None = None,
|
||||
) -> tuple[LocalAccountInfo, HealthCheckResult | None]:
|
||||
"""
|
||||
Store a named account, optionally validating it first.
|
||||
|
||||
Args:
|
||||
credential_id: Logical credential name (e.g. "brave_search").
|
||||
alias: User-chosen name (e.g. "work"). Defaults to "default".
|
||||
api_key: The raw API key / token value.
|
||||
run_health_check: If True, verify the key against the live API
|
||||
and extract identity metadata. Failure still saves with
|
||||
status="failed" so the user can re-validate later.
|
||||
extra_keys: Additional key/value pairs to store (e.g.
|
||||
cse_id for google_custom_search).
|
||||
|
||||
Returns:
|
||||
(LocalAccountInfo, HealthCheckResult | None)
|
||||
"""
|
||||
alias = alias or "default"
|
||||
health_result: HealthCheckResult | None = None
|
||||
identity: dict[str, str] = {}
|
||||
status = "active"
|
||||
|
||||
if run_health_check:
|
||||
try:
|
||||
from aden_tools.credentials.health_check import check_credential_health
|
||||
|
||||
kwargs: dict[str, Any] = {}
|
||||
if extra_keys and "cse_id" in extra_keys:
|
||||
kwargs["cse_id"] = extra_keys["cse_id"]
|
||||
|
||||
health_result = check_credential_health(credential_id, api_key, **kwargs)
|
||||
status = "active" if health_result.valid else "failed"
|
||||
identity = health_result.details.get("identity", {})
|
||||
except Exception as exc:
|
||||
logger.warning("Health check failed for %s/%s: %s", credential_id, alias, exc)
|
||||
status = "unknown"
|
||||
|
||||
storage_id = f"{credential_id}{_SEPARATOR}{alias}"
|
||||
now = datetime.now(UTC)
|
||||
|
||||
cred_obj = CredentialObject(id=storage_id)
|
||||
cred_obj.set_key("api_key", api_key)
|
||||
cred_obj.set_key("_alias", alias)
|
||||
cred_obj.set_key("_integration_type", credential_id)
|
||||
cred_obj.set_key("_status", status)
|
||||
|
||||
if extra_keys:
|
||||
for k, v in extra_keys.items():
|
||||
cred_obj.set_key(k, v)
|
||||
|
||||
if identity:
|
||||
valid_fields = set(CredentialIdentity.model_fields)
|
||||
filtered = {k: v for k, v in identity.items() if k in valid_fields}
|
||||
if filtered:
|
||||
cred_obj.set_identity(**filtered)
|
||||
|
||||
cred_obj.last_refreshed = now if run_health_check else None
|
||||
self._storage.save(cred_obj)
|
||||
|
||||
account_info = LocalAccountInfo(
|
||||
credential_id=credential_id,
|
||||
alias=alias,
|
||||
status=status,
|
||||
identity=cred_obj.identity,
|
||||
last_validated=cred_obj.last_refreshed,
|
||||
created_at=cred_obj.created_at,
|
||||
)
|
||||
return account_info, health_result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Get
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_account(self, credential_id: str, alias: str) -> CredentialObject | None:
|
||||
"""Load the raw CredentialObject for a specific account."""
|
||||
return self._storage.load(f"{credential_id}{_SEPARATOR}{alias}")
|
||||
|
||||
def get_key(self, credential_id: str, alias: str, key_name: str = "api_key") -> str | None:
|
||||
"""
|
||||
Return the stored secret value for a specific account.
|
||||
|
||||
Args:
|
||||
credential_id: Logical credential name (e.g. "brave_search").
|
||||
alias: Account alias (e.g. "work").
|
||||
key_name: Key within the credential (default "api_key").
|
||||
|
||||
Returns:
|
||||
The secret value, or None if not found.
|
||||
"""
|
||||
cred = self.get_account(credential_id, alias)
|
||||
if cred is None:
|
||||
return None
|
||||
return cred.get_key(key_name)
|
||||
|
||||
def get_account_info(self, credential_id: str, alias: str) -> LocalAccountInfo | None:
|
||||
"""Load a LocalAccountInfo for a specific account."""
|
||||
cred = self.get_account(credential_id, alias)
|
||||
if cred is None:
|
||||
return None
|
||||
return self._to_account_info(cred)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Delete
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def delete_account(self, credential_id: str, alias: str) -> bool:
|
||||
"""
|
||||
Remove a stored account.
|
||||
|
||||
Returns:
|
||||
True if the account existed and was deleted, False otherwise.
|
||||
"""
|
||||
return self._storage.delete(f"{credential_id}{_SEPARATOR}{alias}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Validate
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def validate_account(self, credential_id: str, alias: str) -> HealthCheckResult:
|
||||
"""
|
||||
Re-run health check for a stored account and update its status.
|
||||
|
||||
Args:
|
||||
credential_id: Logical credential name.
|
||||
alias: Account alias.
|
||||
|
||||
Returns:
|
||||
HealthCheckResult from the live API check.
|
||||
|
||||
Raises:
|
||||
KeyError: If the account doesn't exist.
|
||||
"""
|
||||
from aden_tools.credentials.health_check import HealthCheckResult, check_credential_health
|
||||
|
||||
cred = self.get_account(credential_id, alias)
|
||||
if cred is None:
|
||||
raise KeyError(f"No local account found: {credential_id}/{alias}")
|
||||
|
||||
api_key = cred.get_key("api_key")
|
||||
if not api_key:
|
||||
return HealthCheckResult(valid=False, message="No api_key stored for this account")
|
||||
|
||||
try:
|
||||
kwargs: dict[str, Any] = {}
|
||||
cse_id = cred.get_key("cse_id")
|
||||
if cse_id:
|
||||
kwargs["cse_id"] = cse_id
|
||||
|
||||
result = check_credential_health(credential_id, api_key, **kwargs)
|
||||
except Exception as exc:
|
||||
result = HealthCheckResult(
|
||||
valid=False,
|
||||
message=f"Health check error: {exc}",
|
||||
details={"error": str(exc)},
|
||||
)
|
||||
|
||||
# Update status and timestamp in-place
|
||||
new_status = "active" if result.valid else "failed"
|
||||
cred.set_key("_status", new_status)
|
||||
cred.last_refreshed = datetime.now(UTC)
|
||||
|
||||
# Re-extract identity if available
|
||||
identity = result.details.get("identity", {})
|
||||
if identity:
|
||||
valid_fields = set(CredentialIdentity.model_fields)
|
||||
filtered = {k: v for k, v in identity.items() if k in valid_fields}
|
||||
if filtered:
|
||||
cred.set_identity(**filtered)
|
||||
|
||||
self._storage.save(cred)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Factory
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def default(cls) -> LocalCredentialRegistry:
|
||||
"""Create a registry using the default encrypted storage at ~/.hive/credentials."""
|
||||
return cls(EncryptedFileStorage())
|
||||
|
||||
@classmethod
|
||||
def at_path(cls, path: str | Path) -> LocalCredentialRegistry:
|
||||
"""Create a registry using a custom storage path."""
|
||||
return cls(EncryptedFileStorage(base_path=path))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _to_account_info(self, cred_obj: CredentialObject) -> LocalAccountInfo | None:
|
||||
"""Build LocalAccountInfo from a CredentialObject."""
|
||||
cred_type_key = cred_obj.keys.get("_integration_type")
|
||||
if cred_type_key is None:
|
||||
return None
|
||||
cred_id = cred_type_key.get_secret_value()
|
||||
|
||||
alias_key = cred_obj.keys.get("_alias")
|
||||
alias = alias_key.get_secret_value() if alias_key else cred_obj.id.split(_SEPARATOR, 1)[-1]
|
||||
|
||||
status_key = cred_obj.keys.get("_status")
|
||||
status = status_key.get_secret_value() if status_key else "unknown"
|
||||
|
||||
return LocalAccountInfo(
|
||||
credential_id=cred_id,
|
||||
alias=alias,
|
||||
status=status,
|
||||
identity=cred_obj.identity,
|
||||
last_validated=cred_obj.last_refreshed,
|
||||
created_at=cred_obj.created_at,
|
||||
)
|
||||
@@ -70,6 +70,29 @@ class CredentialKey(BaseModel):
|
||||
return self.value.get_secret_value()
|
||||
|
||||
|
||||
class CredentialIdentity(BaseModel):
|
||||
"""Identity information for a credential (whose account is this?)."""
|
||||
|
||||
email: str | None = None
|
||||
username: str | None = None
|
||||
workspace: str | None = None
|
||||
account_id: str | None = None
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
"""Best human-readable identifier for display."""
|
||||
return self.email or self.username or self.workspace or self.account_id or "unknown"
|
||||
|
||||
@property
|
||||
def is_known(self) -> bool:
|
||||
"""Whether any identity field is populated."""
|
||||
return bool(self.email or self.username or self.workspace or self.account_id)
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
"""Return only non-None identity fields."""
|
||||
return {k: v for k, v in self.model_dump().items() if v is not None}
|
||||
|
||||
|
||||
class CredentialObject(BaseModel):
|
||||
"""
|
||||
A credential object containing one or more keys.
|
||||
@@ -202,6 +225,35 @@ class CredentialObject(BaseModel):
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def identity(self) -> CredentialIdentity:
|
||||
"""Extract identity from ``_identity_*`` keys in the vault."""
|
||||
fields = {}
|
||||
for key_name, key_obj in self.keys.items():
|
||||
if key_name.startswith("_identity_"):
|
||||
field_name = key_name[len("_identity_") :]
|
||||
if field_name in CredentialIdentity.model_fields:
|
||||
fields[field_name] = key_obj.value.get_secret_value()
|
||||
return CredentialIdentity(**fields)
|
||||
|
||||
@property
|
||||
def provider_type(self) -> str | None:
|
||||
"""Return the integration/provider type (e.g. 'google', 'slack')."""
|
||||
key = self.keys.get("_integration_type")
|
||||
return key.value.get_secret_value() if key else None
|
||||
|
||||
@property
|
||||
def alias(self) -> str | None:
|
||||
"""Return the user-set alias from the Aden platform."""
|
||||
key = self.keys.get("_alias")
|
||||
return key.value.get_secret_value() if key else None
|
||||
|
||||
def set_identity(self, **fields: str) -> None:
|
||||
"""Persist identity fields as ``_identity_*`` keys."""
|
||||
for field_name, value in fields.items():
|
||||
if value:
|
||||
self.set_key(f"_identity_{field_name}", value)
|
||||
|
||||
|
||||
class CredentialUsageSpec(BaseModel):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,618 @@
|
||||
"""
|
||||
Interactive credential setup for CLI applications.
|
||||
|
||||
Provides a modular, reusable credential setup flow that can be triggered
|
||||
when validate_agent_credentials() fails. Works with both TUI and headless CLIs.
|
||||
|
||||
Usage:
|
||||
from framework.credentials.setup import CredentialSetupSession
|
||||
|
||||
# From agent path
|
||||
session = CredentialSetupSession.from_agent_path("exports/my-agent")
|
||||
result = session.run_interactive()
|
||||
|
||||
# From nodes directly
|
||||
session = CredentialSetupSession.from_nodes(nodes)
|
||||
result = session.run_interactive()
|
||||
|
||||
# With custom I/O (for integration with other UIs)
|
||||
session = CredentialSetupSession(
|
||||
missing=missing_creds,
|
||||
input_fn=my_input,
|
||||
print_fn=my_print,
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from framework.graph import NodeSpec
|
||||
|
||||
|
||||
# ANSI colors for terminal output
|
||||
class Colors:
|
||||
RED = "\033[0;31m"
|
||||
GREEN = "\033[0;32m"
|
||||
YELLOW = "\033[1;33m"
|
||||
BLUE = "\033[0;34m"
|
||||
CYAN = "\033[0;36m"
|
||||
BOLD = "\033[1m"
|
||||
DIM = "\033[2m"
|
||||
NC = "\033[0m" # No Color
|
||||
|
||||
@classmethod
|
||||
def disable(cls):
|
||||
"""Disable colors (for non-TTY output)."""
|
||||
cls.RED = cls.GREEN = cls.YELLOW = cls.BLUE = ""
|
||||
cls.CYAN = cls.BOLD = cls.DIM = cls.NC = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class MissingCredential:
|
||||
"""A credential that needs to be configured."""
|
||||
|
||||
credential_name: str
|
||||
"""Internal credential name (e.g., 'brave_search')"""
|
||||
|
||||
env_var: str
|
||||
"""Environment variable name (e.g., 'BRAVE_SEARCH_API_KEY')"""
|
||||
|
||||
description: str
|
||||
"""Human-readable description"""
|
||||
|
||||
help_url: str
|
||||
"""URL where user can obtain credential"""
|
||||
|
||||
api_key_instructions: str
|
||||
"""Step-by-step instructions for getting API key"""
|
||||
|
||||
tools: list[str] = field(default_factory=list)
|
||||
"""Tools that require this credential"""
|
||||
|
||||
node_types: list[str] = field(default_factory=list)
|
||||
"""Node types that require this credential"""
|
||||
|
||||
aden_supported: bool = False
|
||||
"""Whether Aden OAuth flow is supported"""
|
||||
|
||||
direct_api_key_supported: bool = True
|
||||
"""Whether direct API key entry is supported"""
|
||||
|
||||
credential_id: str = ""
|
||||
"""Credential store ID"""
|
||||
|
||||
credential_key: str = "api_key"
|
||||
"""Key name within the credential"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SetupResult:
|
||||
"""Result of credential setup session."""
|
||||
|
||||
success: bool
|
||||
"""Whether all required credentials were configured"""
|
||||
|
||||
configured: list[str] = field(default_factory=list)
|
||||
"""Credentials that were successfully set up"""
|
||||
|
||||
skipped: list[str] = field(default_factory=list)
|
||||
"""Credentials user chose to skip"""
|
||||
|
||||
errors: list[str] = field(default_factory=list)
|
||||
"""Any errors encountered"""
|
||||
|
||||
|
||||
class CredentialSetupSession:
|
||||
"""
|
||||
Interactive credential setup session.
|
||||
|
||||
Can be used by any CLI (runner, coding agent, etc.) to guide users
|
||||
through credential configuration when validation fails.
|
||||
|
||||
Example:
|
||||
from framework.credentials.setup import CredentialSetupSession
|
||||
from framework.credentials.models import CredentialError
|
||||
|
||||
try:
|
||||
validate_agent_credentials(nodes)
|
||||
except CredentialError:
|
||||
session = CredentialSetupSession.from_nodes(nodes)
|
||||
result = session.run_interactive()
|
||||
if result.success:
|
||||
# Retry - credentials are now configured
|
||||
validate_agent_credentials(nodes)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
missing: list[MissingCredential],
|
||||
input_fn: Callable[[str], str] | None = None,
|
||||
print_fn: Callable[[str], None] | None = None,
|
||||
password_fn: Callable[[str], str] | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the setup session.
|
||||
|
||||
Args:
|
||||
missing: List of credentials that need setup
|
||||
input_fn: Custom input function (default: built-in input)
|
||||
print_fn: Custom print function (default: built-in print)
|
||||
password_fn: Custom password input function (default: getpass.getpass)
|
||||
"""
|
||||
self.missing = missing
|
||||
self.input_fn = input_fn or input
|
||||
self.print_fn = print_fn or print
|
||||
self.password_fn = password_fn or getpass.getpass
|
||||
|
||||
# Disable colors if not a TTY
|
||||
if not sys.stdout.isatty():
|
||||
Colors.disable()
|
||||
|
||||
@classmethod
|
||||
def from_nodes(cls, nodes: list[NodeSpec]) -> CredentialSetupSession:
|
||||
"""Create a setup session by detecting missing credentials from 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
|
||||
def from_agent_path(
|
||||
cls,
|
||||
agent_path: str | Path,
|
||||
*,
|
||||
missing_only: bool = True,
|
||||
) -> CredentialSetupSession:
|
||||
"""Create a setup session for an agent by path.
|
||||
|
||||
Args:
|
||||
agent_path: Path to agent folder.
|
||||
missing_only: If True (default), only include credentials that
|
||||
are NOT yet available. If False, include all required
|
||||
credentials regardless of availability.
|
||||
"""
|
||||
from framework.credentials.validation import _status_to_missing, validate_agent_credentials
|
||||
|
||||
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."""
|
||||
configured: list[str] = []
|
||||
skipped: list[str] = []
|
||||
errors: list[str] = []
|
||||
|
||||
if not self.missing:
|
||||
self._print(f"\n{Colors.GREEN}✓ All credentials are already configured!{Colors.NC}\n")
|
||||
return SetupResult(success=True)
|
||||
|
||||
self._print_header()
|
||||
|
||||
# Ensure HIVE_CREDENTIAL_KEY is set before storing anything
|
||||
if not self._ensure_credential_key():
|
||||
return SetupResult(
|
||||
success=False,
|
||||
errors=["Failed to initialize credential store encryption key"],
|
||||
)
|
||||
|
||||
for cred in self.missing:
|
||||
try:
|
||||
result = self._setup_single_credential(cred)
|
||||
if result:
|
||||
configured.append(cred.credential_name)
|
||||
else:
|
||||
skipped.append(cred.credential_name)
|
||||
except KeyboardInterrupt:
|
||||
self._print(f"\n{Colors.YELLOW}Setup interrupted.{Colors.NC}")
|
||||
skipped.append(cred.credential_name)
|
||||
break
|
||||
except Exception as e:
|
||||
errors.append(f"{cred.credential_name}: {e}")
|
||||
|
||||
self._print_summary(configured, skipped, errors)
|
||||
|
||||
return SetupResult(
|
||||
success=len(errors) == 0 and len(skipped) == 0,
|
||||
configured=configured,
|
||||
skipped=skipped,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
def _print(self, msg: str) -> None:
|
||||
"""Print a message."""
|
||||
self.print_fn(msg)
|
||||
|
||||
def _input(self, prompt: str) -> str:
|
||||
"""Get input from user."""
|
||||
return self.input_fn(prompt)
|
||||
|
||||
def _print_header(self) -> None:
|
||||
"""Print the setup header."""
|
||||
self._print("")
|
||||
self._print(f"{Colors.YELLOW}{'=' * 60}{Colors.NC}")
|
||||
self._print(f"{Colors.BOLD} CREDENTIAL SETUP{Colors.NC}")
|
||||
self._print(f"{Colors.YELLOW}{'=' * 60}{Colors.NC}")
|
||||
self._print("")
|
||||
self._print(f" {len(self.missing)} credential(s) need to be configured:")
|
||||
for cred in self.missing:
|
||||
affected = cred.tools or cred.node_types
|
||||
self._print(f" • {cred.env_var} ({', '.join(affected)})")
|
||||
self._print("")
|
||||
|
||||
def _ensure_credential_key(self) -> bool:
|
||||
"""Ensure HIVE_CREDENTIAL_KEY is available for encrypted storage."""
|
||||
from .key_storage import generate_and_save_credential_key, load_credential_key
|
||||
|
||||
if load_credential_key():
|
||||
return True
|
||||
|
||||
# Generate a new key
|
||||
self._print(f"{Colors.YELLOW}Initializing credential store...{Colors.NC}")
|
||||
try:
|
||||
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 _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}")
|
||||
self._print(f"{Colors.BOLD}Setting up: {cred.credential_name}{Colors.NC}")
|
||||
affected = cred.tools or cred.node_types
|
||||
self._print(f"{Colors.DIM}Required for: {', '.join(affected)}{Colors.NC}")
|
||||
if cred.description:
|
||||
self._print(f"{Colors.DIM}{cred.description}{Colors.NC}")
|
||||
self._print(f"{Colors.CYAN}{'─' * 60}{Colors.NC}")
|
||||
|
||||
# Show auth options
|
||||
options = self._get_auth_options(cred)
|
||||
choice = self._prompt_choice(options)
|
||||
|
||||
if choice == "skip":
|
||||
return False
|
||||
elif choice == "aden":
|
||||
return self._setup_via_aden(cred)
|
||||
elif choice == "direct":
|
||||
return self._setup_direct_api_key(cred)
|
||||
|
||||
return False
|
||||
|
||||
def _get_auth_options(self, cred: MissingCredential) -> list[tuple[str, str, str]]:
|
||||
"""Get available auth options as (key, label, description) tuples."""
|
||||
options = []
|
||||
|
||||
if cred.direct_api_key_supported:
|
||||
options.append(
|
||||
(
|
||||
"direct",
|
||||
"Enter API key directly",
|
||||
"Paste your API key from the provider's dashboard",
|
||||
)
|
||||
)
|
||||
|
||||
if cred.aden_supported:
|
||||
options.append(
|
||||
(
|
||||
"aden",
|
||||
"Use Aden Platform (OAuth)",
|
||||
"Secure OAuth2 flow via hive.adenhq.com",
|
||||
)
|
||||
)
|
||||
|
||||
options.append(
|
||||
(
|
||||
"skip",
|
||||
"Skip for now",
|
||||
"Configure this credential later",
|
||||
)
|
||||
)
|
||||
|
||||
return options
|
||||
|
||||
def _prompt_choice(self, options: list[tuple[str, str, str]]) -> str:
|
||||
"""Prompt user to choose from options."""
|
||||
self._print("")
|
||||
for i, (key, label, desc) in enumerate(options, 1):
|
||||
if key == "skip":
|
||||
self._print(f" {Colors.DIM}{i}) {label}{Colors.NC}")
|
||||
else:
|
||||
self._print(f" {Colors.CYAN}{i}){Colors.NC} {label}")
|
||||
self._print(f" {Colors.DIM}{desc}{Colors.NC}")
|
||||
self._print("")
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice_str = self._input(f"Select option (1-{len(options)}): ").strip()
|
||||
if not choice_str:
|
||||
continue
|
||||
choice_num = int(choice_str)
|
||||
if 1 <= choice_num <= len(options):
|
||||
return options[choice_num - 1][0]
|
||||
except ValueError:
|
||||
pass
|
||||
self._print(f"{Colors.RED}Invalid choice. Enter 1-{len(options)}{Colors.NC}")
|
||||
|
||||
def _setup_direct_api_key(self, cred: MissingCredential) -> bool:
|
||||
"""Guide user through direct API key setup."""
|
||||
# Show instructions
|
||||
if cred.api_key_instructions:
|
||||
self._print(f"\n{Colors.BOLD}Setup Instructions:{Colors.NC}")
|
||||
self._print(cred.api_key_instructions)
|
||||
|
||||
if cred.help_url:
|
||||
self._print(f"\n{Colors.CYAN}Get your API key at:{Colors.NC} {cred.help_url}")
|
||||
|
||||
# Collect key (use password input to hide the value)
|
||||
self._print("")
|
||||
try:
|
||||
api_key = self.password_fn(f"Paste your {cred.env_var}: ").strip()
|
||||
except Exception:
|
||||
# Fallback to regular input if password input fails
|
||||
api_key = self._input(f"Paste your {cred.env_var}: ").strip()
|
||||
|
||||
if not api_key:
|
||||
self._print(f"{Colors.YELLOW}No value entered. Skipping.{Colors.NC}")
|
||||
return False
|
||||
|
||||
# Health check
|
||||
health_result = self._run_health_check(cred, api_key)
|
||||
if health_result is not None:
|
||||
if health_result["valid"]:
|
||||
self._print(f"{Colors.GREEN}✓ {health_result['message']}{Colors.NC}")
|
||||
else:
|
||||
self._print(f"{Colors.YELLOW}⚠ {health_result['message']}{Colors.NC}")
|
||||
confirm = self._input("Continue anyway? [y/N]: ").strip().lower()
|
||||
if confirm != "y":
|
||||
return False
|
||||
|
||||
# Store credential
|
||||
self._store_credential(cred, api_key)
|
||||
return True
|
||||
|
||||
def _setup_via_aden(self, cred: MissingCredential) -> bool:
|
||||
"""Guide user through Aden OAuth flow."""
|
||||
self._print(f"\n{Colors.BOLD}Aden Platform Setup{Colors.NC}")
|
||||
self._print("This will sync credentials from your Aden account.")
|
||||
self._print("")
|
||||
|
||||
# Check for ADEN_API_KEY
|
||||
aden_key = os.environ.get("ADEN_API_KEY")
|
||||
if not aden_key:
|
||||
self._print("You need an Aden API key to use this method.")
|
||||
self._print(f"{Colors.CYAN}Get one at:{Colors.NC} https://hive.adenhq.com")
|
||||
self._print("")
|
||||
|
||||
try:
|
||||
aden_key = self.password_fn("Paste your ADEN_API_KEY: ").strip()
|
||||
except Exception:
|
||||
aden_key = self._input("Paste your ADEN_API_KEY: ").strip()
|
||||
|
||||
if not aden_key:
|
||||
self._print(f"{Colors.YELLOW}No key entered. Skipping.{Colors.NC}")
|
||||
return False
|
||||
|
||||
# Persist to encrypted store and set os.environ
|
||||
from .key_storage import save_aden_api_key
|
||||
|
||||
save_aden_api_key(aden_key)
|
||||
|
||||
# Sync from Aden
|
||||
try:
|
||||
from framework.credentials import CredentialStore
|
||||
|
||||
store = CredentialStore.with_aden_sync(
|
||||
base_url="https://api.adenhq.com",
|
||||
auto_sync=True,
|
||||
)
|
||||
|
||||
# Check if the credential was synced
|
||||
cred_id = cred.credential_id or cred.credential_name
|
||||
if store.is_available(cred_id):
|
||||
self._print(f"{Colors.GREEN}✓ {cred.credential_name} synced from Aden{Colors.NC}")
|
||||
# Export to current session
|
||||
try:
|
||||
value = store.get_key(cred_id, cred.credential_key)
|
||||
if value:
|
||||
os.environ[cred.env_var] = value
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
else:
|
||||
self._print(
|
||||
f"{Colors.YELLOW}⚠ {cred.credential_name} not found in Aden account.{Colors.NC}"
|
||||
)
|
||||
self._print("Please connect this integration on https://hive.adenhq.com first.")
|
||||
return False
|
||||
except Exception as e:
|
||||
self._print(f"{Colors.RED}Failed to sync from Aden: {e}{Colors.NC}")
|
||||
return False
|
||||
|
||||
def _run_health_check(self, cred: MissingCredential, value: str) -> dict[str, Any] | None:
|
||||
"""Run health check on credential value."""
|
||||
try:
|
||||
from aden_tools.credentials import check_credential_health
|
||||
|
||||
result = check_credential_health(cred.credential_name, value)
|
||||
return {
|
||||
"valid": result.valid,
|
||||
"message": result.message,
|
||||
"details": result.details,
|
||||
}
|
||||
except Exception:
|
||||
# No health checker available
|
||||
return None
|
||||
|
||||
def _store_credential(self, cred: MissingCredential, value: str) -> None:
|
||||
"""Store credential in encrypted store and export to env."""
|
||||
from pydantic import SecretStr
|
||||
|
||||
from framework.credentials import CredentialKey, CredentialObject, CredentialStore
|
||||
|
||||
try:
|
||||
store = CredentialStore.with_encrypted_storage()
|
||||
cred_id = cred.credential_id or cred.credential_name
|
||||
key_name = cred.credential_key or "api_key"
|
||||
|
||||
cred_obj = CredentialObject(
|
||||
id=cred_id,
|
||||
name=cred.description or cred.credential_name,
|
||||
keys={key_name: CredentialKey(name=key_name, value=SecretStr(value))},
|
||||
)
|
||||
store.save_credential(cred_obj)
|
||||
self._print(f"{Colors.GREEN}✓ Stored in ~/.hive/credentials/{Colors.NC}")
|
||||
except Exception as e:
|
||||
self._print(f"{Colors.YELLOW}⚠ Could not store in credential store: {e}{Colors.NC}")
|
||||
|
||||
# Export to current session
|
||||
os.environ[cred.env_var] = value
|
||||
self._print(f"{Colors.GREEN}✓ Exported to current session{Colors.NC}")
|
||||
|
||||
def _print_summary(self, configured: list[str], skipped: list[str], errors: list[str]) -> None:
|
||||
"""Print final summary."""
|
||||
self._print("")
|
||||
self._print(f"{Colors.YELLOW}{'=' * 60}{Colors.NC}")
|
||||
self._print(f"{Colors.BOLD} SETUP COMPLETE{Colors.NC}")
|
||||
self._print(f"{Colors.YELLOW}{'=' * 60}{Colors.NC}")
|
||||
|
||||
if configured:
|
||||
self._print(f"\n{Colors.GREEN}✓ Configured:{Colors.NC}")
|
||||
for name in configured:
|
||||
self._print(f" • {name}")
|
||||
|
||||
if skipped:
|
||||
self._print(f"\n{Colors.YELLOW}⏭ Skipped:{Colors.NC}")
|
||||
for name in skipped:
|
||||
self._print(f" • {name}")
|
||||
|
||||
if errors:
|
||||
self._print(f"\n{Colors.RED}✗ Errors:{Colors.NC}")
|
||||
for err in errors:
|
||||
self._print(f" • {err}")
|
||||
|
||||
if not skipped and not errors:
|
||||
self._print(f"\n{Colors.GREEN}All credentials configured successfully!{Colors.NC}")
|
||||
elif skipped:
|
||||
self._print(f"\n{Colors.YELLOW}Note: Skipped credentials must be configured ")
|
||||
self._print(f"before running the agent.{Colors.NC}")
|
||||
|
||||
self._print("")
|
||||
|
||||
|
||||
def load_agent_nodes(agent_path: str | Path) -> list:
|
||||
"""Load NodeSpec list from an agent's agent.py or agent.json.
|
||||
|
||||
Args:
|
||||
agent_path: Path to agent directory.
|
||||
|
||||
Returns:
|
||||
List of NodeSpec objects (empty list if agent can't be loaded).
|
||||
"""
|
||||
agent_path = Path(agent_path)
|
||||
agent_py = agent_path / "agent.py"
|
||||
agent_json = agent_path / "agent.json"
|
||||
|
||||
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:
|
||||
"""Load nodes from a Python-based agent."""
|
||||
import importlib.util
|
||||
|
||||
agent_py = agent_path / "agent.py"
|
||||
if not agent_py.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
# Add agent path and its parent to sys.path so imports work
|
||||
paths_to_add = [str(agent_path), str(agent_path.parent)]
|
||||
for p in paths_to_add:
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"{agent_path.name}.agent",
|
||||
agent_py,
|
||||
submodule_search_locations=[str(agent_path)],
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return getattr(module, "nodes", [])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _load_nodes_from_json_agent(agent_json: Path) -> list:
|
||||
"""Load nodes from a JSON-based agent."""
|
||||
try:
|
||||
with open(agent_json, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
from framework.graph import NodeSpec
|
||||
|
||||
nodes_data = data.get("graph", {}).get("nodes", [])
|
||||
nodes = []
|
||||
for node_data in nodes_data:
|
||||
nodes.append(
|
||||
NodeSpec(
|
||||
id=node_data.get("id", ""),
|
||||
name=node_data.get("name", ""),
|
||||
description=node_data.get("description", ""),
|
||||
node_type=node_data.get("node_type", ""),
|
||||
tools=node_data.get("tools", []),
|
||||
input_keys=node_data.get("input_keys", []),
|
||||
output_keys=node_data.get("output_keys", []),
|
||||
)
|
||||
)
|
||||
return nodes
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def run_credential_setup_cli(agent_path: str | Path | None = None) -> int:
|
||||
"""
|
||||
Standalone CLI entry point for credential setup.
|
||||
|
||||
Can be called from:
|
||||
- `hive setup-credentials <agent>`
|
||||
- After CredentialError in runner CLI
|
||||
- From coding agent CLI
|
||||
|
||||
Args:
|
||||
agent_path: Optional path to agent directory
|
||||
|
||||
Returns:
|
||||
Exit code (0 = success, 1 = failure/skipped)
|
||||
"""
|
||||
if agent_path:
|
||||
session = CredentialSetupSession.from_agent_path(agent_path)
|
||||
else:
|
||||
# No agent specified - detect from current context or show error
|
||||
print("Usage: hive setup-credentials <agent_path>")
|
||||
return 1
|
||||
|
||||
result = session.run_interactive()
|
||||
return 0 if result.success else 1
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -362,6 +362,59 @@ class CredentialStore:
|
||||
"""
|
||||
return self._storage.list_all()
|
||||
|
||||
def list_accounts(self, provider_name: str) -> list[dict[str, Any]]:
|
||||
"""List all accounts for a provider type with their identities.
|
||||
|
||||
Args:
|
||||
provider_name: Provider type name (e.g. "google", "slack").
|
||||
|
||||
Returns:
|
||||
List of dicts with credential_id, provider, alias, identity, label.
|
||||
"""
|
||||
if hasattr(self._storage, "load_all_for_provider"):
|
||||
creds = self._storage.load_all_for_provider(provider_name)
|
||||
else:
|
||||
cred = self.get_credential(provider_name)
|
||||
creds = [cred] if cred else []
|
||||
return [
|
||||
{
|
||||
"credential_id": c.id,
|
||||
"provider": provider_name,
|
||||
"alias": c.alias,
|
||||
"identity": c.identity.to_dict(),
|
||||
}
|
||||
for c in creds
|
||||
]
|
||||
|
||||
def get_credential_by_alias(self, provider_name: str, alias: str) -> CredentialObject | None:
|
||||
"""Find a credential by provider name and alias.
|
||||
|
||||
Args:
|
||||
provider_name: Provider type name (e.g. "google").
|
||||
alias: User-set alias from the Aden platform.
|
||||
|
||||
Returns:
|
||||
CredentialObject if found, None otherwise.
|
||||
"""
|
||||
# LLMs sometimes pass "provider/alias" as the alias (e.g. "google/wrok"
|
||||
# instead of just "wrok"). Strip the provider prefix when present.
|
||||
if alias.startswith(f"{provider_name}/"):
|
||||
alias = alias[len(provider_name) + 1 :]
|
||||
|
||||
if hasattr(self._storage, "load_by_alias"):
|
||||
return self._storage.load_by_alias(provider_name, alias)
|
||||
|
||||
# Scan fallback for storage backends without alias index
|
||||
if hasattr(self._storage, "load_all_for_provider"):
|
||||
for cred in self._storage.load_all_for_provider(provider_name):
|
||||
if cred.alias == alias:
|
||||
return cred
|
||||
return None
|
||||
|
||||
def get_credential_by_identity(self, provider_name: str, label: str) -> CredentialObject | None:
|
||||
"""Alias for get_credential_by_alias (backward compat)."""
|
||||
return self.get_credential_by_alias(provider_name, label)
|
||||
|
||||
def is_available(self, credential_id: str) -> bool:
|
||||
"""
|
||||
Check if a credential is available.
|
||||
@@ -374,6 +427,10 @@ class CredentialStore:
|
||||
"""
|
||||
return self.get_credential(credential_id, refresh_if_needed=False) is not None
|
||||
|
||||
def exists(self, credential_id: str) -> bool:
|
||||
"""Check if a credential exists in storage without triggering provider fetches."""
|
||||
return self._storage.exists(credential_id)
|
||||
|
||||
# --- Validation ---
|
||||
|
||||
def validate_for_usage(self, credential_id: str) -> list[str]:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user