diff --git a/Cargo.lock b/Cargo.lock index 00a937c7..3f4da1ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,20 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -52,6 +66,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "ambient-authority" version = "0.0.2" @@ -145,6 +165,21 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -157,18 +192,6 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" -[[package]] -name = "bitvec" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - [[package]] name = "blake2" version = "0.10.6" @@ -196,6 +219,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bstr" version = "1.13.1" @@ -214,10 +243,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "bytemuck" -version = "1.25.2" +name = "bytecount" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "byteorder" @@ -717,6 +746,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -765,6 +803,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -804,6 +853,17 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -846,6 +906,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" +dependencies = [ + "num", + "num-bigint", +] + [[package]] name = "fs-set-times" version = "0.20.3" @@ -877,12 +947,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "futures" version = "0.3.34" @@ -1001,9 +1065,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1070,6 +1136,8 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -1082,6 +1150,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -1224,6 +1298,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -1611,6 +1686,59 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f622c574a29fb3034a0a90645afee194dfedc75fc124228801b6e77041075bd1" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a968fffd387b2ab005bf00c1851f5d26e90df05b6f7ad609ed0d620033cca5e7" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39b7670345e5ce87371339d13705dbb9276b7e3e934bbe1efa2c6734a1f65a96" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "getrandom 0.3.4", + "num-cmp", + "num-traits", + "serde_json", + "zmij", +] + [[package]] name = "keyring" version = "3.6.3" @@ -1649,24 +1777,6 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" -[[package]] -name = "libdeflate-sys" -version = "1.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7870e5fbd2766179a937c725fb11f4ca0ef025d982beb61bd3ce755425bd19c" -dependencies = [ - "cc", -] - -[[package]] -name = "libdeflater" -version = "1.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cbb8285d2e2bd4b8d4155840ae81929354b4ba0d7b3c272c84761ffa2141e1f" -dependencies = [ - "libdeflate-sys", -] - [[package]] name = "libm" version = "0.2.16" @@ -1763,6 +1873,12 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -1835,6 +1951,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -1845,6 +1975,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1860,6 +2005,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1893,7 +2059,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openhuman" -version = "0.64.12" +version = "0.64.15" dependencies = [ "aes-gcm", "aho-corasick", @@ -1904,6 +2070,7 @@ dependencies = [ "bytes", "chacha20poly1305", "chrono", + "chrono-tz", "directories", "dirs 6.0.0", "dotenvy", @@ -1913,6 +2080,7 @@ dependencies = [ "futures-util", "hex", "hkdf", + "hmac 0.13.0", "hostname", "iana-time-zone", "keyring", @@ -1929,7 +2097,9 @@ dependencies = [ "schemars", "serde", "serde_json", + "serde_path_to_error", "sha2 0.10.9", + "sha2 0.11.0", "sysinfo", "tar", "tempfile", @@ -1941,6 +2111,7 @@ dependencies = [ "tinyagents-registry", "tinyagents-runtime", "tinyagents-session", + "tinyagents-tasks", "tinybox-core", "tinybox-docker", "tinybox-jail", @@ -1950,6 +2121,7 @@ dependencies = [ "tinycomputer-accessibility", "tinyconnectors", "tinyconnectors-bus", + "tinyflows-drivers", "tinyflows-schedule", "tinyflows-sqlite", "tinyinference-core", @@ -1969,6 +2141,7 @@ dependencies = [ "tinyruntime-pyserver", "tinysearch-bus", "tinyskills", + "tinystoragedrivers", "tinytools", "tinytools-agent", "tinytools-std", @@ -1990,10 +2163,13 @@ dependencies = [ [[package]] name = "openhuman-embed" -version = "0.64.12" +version = "0.64.15" dependencies = [ "anyhow", "async-trait", + "chrono", + "futures-core", + "jsonschema", "log", "openhuman", "serde", @@ -2001,10 +2177,14 @@ dependencies = [ "tempfile", "thiserror", "tinyagents-session", + "tinyinference-embeddings", + "tinyinference-llm", "tinymcp-bus", "tinymemory-api", "tinymemory-tools", + "tinytools", "tokio", + "tokio-stream", "url", "uuid", ] @@ -2059,18 +2239,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] -name = "oxipng" -version = "9.1.5" +name = "outref" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c613f0f566526a647c7473f6a8556dbce22c91b13485ee4b4ec7ab648e4973" -dependencies = [ - "bitvec", - "indexmap", - "libdeflater", - "log", - "rgb", - "rustc-hash", -] +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" [[package]] name = "parking_lot" @@ -2332,12 +2504,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - [[package]] name = "rand" version = "0.8.8" @@ -2450,6 +2616,23 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "referencing" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558f3855529c0a7e78a48e409fa653456e55f1b167eab4109c16a255b7727e3f" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.13.1" @@ -2512,6 +2695,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -2532,15 +2716,6 @@ dependencies = [ "webpki-roots 1.0.9", ] -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" -dependencies = [ - "bytemuck", -] - [[package]] name = "ring" version = "0.17.14" @@ -2856,6 +3031,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -2890,6 +3076,19 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.7" @@ -3027,6 +3226,27 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3088,12 +3308,6 @@ dependencies = [ "windows", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "tar" version = "0.4.46" @@ -3179,15 +3393,16 @@ dependencies = [ [[package]] name = "tinyagents-definition" -version = "2.1.3" +version = "2.1.4" dependencies = [ "async-trait", "serde", + "tinytools", ] [[package]] name = "tinyagents-graph" -version = "2.1.3" +version = "2.1.4" dependencies = [ "anyhow", "async-trait", @@ -3195,8 +3410,11 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha2 0.11.0", "tinyagents-harness", + "tinyagents-tasks", "tinyinference-llm", + "tinystoragedrivers-core", "tinytools", "tokio", "tracing", @@ -3204,7 +3422,7 @@ dependencies = [ [[package]] name = "tinyagents-harness" -version = "2.1.3" +version = "2.1.4" dependencies = [ "anyhow", "async-trait", @@ -3214,7 +3432,6 @@ dependencies = [ "dirs 6.0.0", "flate2", "futures", - "oxipng", "regex", "reqwest", "rusqlite", @@ -3229,8 +3446,10 @@ dependencies = [ "tinyinference-core", "tinyinference-embeddings", "tinyinference-llm", + "tinystoragedrivers-core", "tinytools", "tinytools-agent", + "tinytools-std", "tokio", "tracing", "uuid", @@ -3240,7 +3459,7 @@ dependencies = [ [[package]] name = "tinyagents-orchestration" -version = "2.1.3" +version = "2.1.4" dependencies = [ "anyhow", "async-trait", @@ -3253,15 +3472,17 @@ dependencies = [ "tinyagents-harness", "tinyagents-runtime", "tinyagents-session", + "tinyagents-tasks", "tinyinference-llm", "tinytools", "tokio", + "tracing", "uuid", ] [[package]] name = "tinyagents-registry" -version = "2.1.3" +version = "2.1.4" dependencies = [ "anyhow", "chrono", @@ -3275,7 +3496,7 @@ dependencies = [ [[package]] name = "tinyagents-runtime" -version = "2.1.3" +version = "2.1.4" dependencies = [ "async-trait", "chrono", @@ -3293,7 +3514,7 @@ dependencies = [ [[package]] name = "tinyagents-session" -version = "2.1.3" +version = "2.1.4" dependencies = [ "anyhow", "async-trait", @@ -3309,15 +3530,33 @@ dependencies = [ "tempfile", "thiserror", "tinyagents-harness", + "tinyagents-tasks", + "tinystoragedrivers-core", + "tinystoragedrivers-sqlite", "tinytools-agent", "tokio", "tracing", "uuid", ] +[[package]] +name = "tinyagents-tasks" +version = "2.1.4" +dependencies = [ + "anyhow", + "async-trait", + "serde", + "serde_json", + "tinyagents-harness", + "tinyinference-llm", + "tinytools", + "tokio", + "tracing", +] + [[package]] name = "tinybox-core" -version = "0.1.15" +version = "0.1.16" dependencies = [ "async-trait", "serde", @@ -3326,7 +3565,7 @@ dependencies = [ [[package]] name = "tinybox-docker" -version = "0.1.15" +version = "0.1.16" dependencies = [ "async-trait", "tinybox-core", @@ -3335,7 +3574,7 @@ dependencies = [ [[package]] name = "tinybox-jail" -version = "0.1.15" +version = "0.1.16" dependencies = [ "landlock", "log", @@ -3347,7 +3586,7 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.1.2" +version = "0.1.3" dependencies = [ "async-trait", "serde", @@ -3361,7 +3600,7 @@ dependencies = [ [[package]] name = "tinybus-macros" -version = "0.1.2" +version = "0.1.3" dependencies = [ "proc-macro2", "quote", @@ -3370,7 +3609,7 @@ dependencies = [ [[package]] name = "tinychannels-bus" -version = "0.1.10" +version = "0.1.13" dependencies = [ "anyhow", "async-trait", @@ -3391,7 +3630,7 @@ dependencies = [ [[package]] name = "tinychannels-runtime" -version = "0.1.10" +version = "0.1.13" dependencies = [ "anyhow", "async-trait", @@ -3405,7 +3644,7 @@ dependencies = [ [[package]] name = "tinycomputer-accessibility" -version = "0.9.1" +version = "0.10.1" dependencies = [ "log", "serde", @@ -3415,7 +3654,7 @@ dependencies = [ [[package]] name = "tinyconnectors" -version = "0.12.3" +version = "0.13.2" dependencies = [ "async-trait", "chrono", @@ -3434,7 +3673,7 @@ dependencies = [ [[package]] name = "tinyconnectors-bus" -version = "0.12.3" +version = "0.13.2" dependencies = [ "serde", "serde_json", @@ -3442,15 +3681,27 @@ dependencies = [ [[package]] name = "tinyconnectors-sync" -version = "0.12.3" +version = "0.13.2" dependencies = [ "async-trait", - "chrono", "serde", "serde_json", "thiserror", "tinyconnectors-bus", +] + +[[package]] +name = "tinyflows-drivers" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "serde", + "serde_json", + "tinyflows-schedule", + "tinystoragedrivers-core", "tracing", + "uuid", ] [[package]] @@ -3475,6 +3726,7 @@ dependencies = [ "serde", "serde_json", "tinyflows-schedule", + "tinystoragedrivers-sqlite", "tracing", "uuid", ] @@ -3521,10 +3773,12 @@ version = "0.2.1" dependencies = [ "anyhow", "async-trait", + "chrono", "openhuman", "openhuman-embed", "serde", "serde_json", + "serde_yaml_ng", "tempfile", "thiserror", "tinyhivemind-core", @@ -3549,7 +3803,7 @@ dependencies = [ [[package]] name = "tinyinference-core" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "httpdate", @@ -3559,7 +3813,7 @@ dependencies = [ [[package]] name = "tinyinference-embeddings" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "once_cell", @@ -3576,7 +3830,7 @@ dependencies = [ [[package]] name = "tinyinference-llm" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -3585,7 +3839,6 @@ dependencies = [ "reqwest", "serde", "serde_json", - "sha2 0.11.0", "thiserror", "tinyinference-core", "tinytools-agent", @@ -3595,7 +3848,7 @@ dependencies = [ [[package]] name = "tinyinference-local" -version = "0.3.0" +version = "0.3.1" dependencies = [ "base64 0.23.1", "futures-util", @@ -3616,7 +3869,7 @@ dependencies = [ [[package]] name = "tinyinference-providers" -version = "0.3.0" +version = "0.3.1" dependencies = [ "base64 0.23.1", "rand 0.10.2", @@ -3629,7 +3882,7 @@ dependencies = [ [[package]] name = "tinyinference-voice" -version = "0.3.0" +version = "0.3.1" dependencies = [ "base64 0.23.1", "reqwest", @@ -3656,7 +3909,7 @@ dependencies = [ [[package]] name = "tinyjuice" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "async-trait", @@ -3682,7 +3935,7 @@ dependencies = [ [[package]] name = "tinyjuice-bus" -version = "0.6.0" +version = "0.6.1" dependencies = [ "serde", "serde_json", @@ -3690,7 +3943,7 @@ dependencies = [ [[package]] name = "tinymcp" -version = "0.4.0" +version = "0.6.0" dependencies = [ "anyhow", "async-trait", @@ -3715,7 +3968,7 @@ dependencies = [ [[package]] name = "tinymcp-bus" -version = "0.4.0" +version = "0.6.0" dependencies = [ "schemars", "serde", @@ -3724,10 +3977,11 @@ dependencies = [ [[package]] name = "tinymemory-api" -version = "1.23.4" +version = "1.27.2" dependencies = [ "async-trait", "chrono", + "chrono-tz", "serde", "serde_json", "sha2 0.11.0", @@ -3736,7 +3990,7 @@ dependencies = [ [[package]] name = "tinymemory-integrations" -version = "1.23.4" +version = "1.27.2" dependencies = [ "async-trait", "chrono", @@ -3759,7 +4013,7 @@ dependencies = [ [[package]] name = "tinymemory-tools" -version = "1.23.4" +version = "1.27.2" dependencies = [ "chrono", "futures", @@ -3772,7 +4026,7 @@ dependencies = [ [[package]] name = "tinyruntime-bus" -version = "0.2.11" +version = "0.2.12" dependencies = [ "serde", "serde_json", @@ -3780,7 +4034,7 @@ dependencies = [ [[package]] name = "tinyruntime-pyserver" -version = "0.2.11" +version = "0.2.12" dependencies = [ "serde", "serde_json", @@ -3791,7 +4045,7 @@ dependencies = [ [[package]] name = "tinysearch-bus" -version = "0.3.6" +version = "0.4.0" dependencies = [ "serde", "serde_json", @@ -3799,7 +4053,7 @@ dependencies = [ [[package]] name = "tinyskills" -version = "0.2.8" +version = "0.2.10" dependencies = [ "cap-fs-ext", "cap-std", @@ -3813,6 +4067,65 @@ dependencies = [ "url", ] +[[package]] +name = "tinystoragedrivers" +version = "0.5.0" +dependencies = [ + "tinystoragedrivers-core", + "tinystoragedrivers-file", + "tinystoragedrivers-secrets", + "tinystoragedrivers-sqlite", +] + +[[package]] +name = "tinystoragedrivers-core" +version = "0.5.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "thiserror", + "tokio", +] + +[[package]] +name = "tinystoragedrivers-file" +version = "0.5.0" +dependencies = [ + "async-trait", + "libc", + "serde", + "serde_json", + "tinystoragedrivers-core", + "tokio", +] + +[[package]] +name = "tinystoragedrivers-secrets" +version = "0.5.0" +dependencies = [ + "async-trait", + "chacha20poly1305", + "fs4", + "hkdf", + "keyring", + "serde_json", + "sha2 0.10.9", + "tinystoragedrivers-core", + "tokio", + "zeroize", +] + +[[package]] +name = "tinystoragedrivers-sqlite" +version = "0.5.0" +dependencies = [ + "rusqlite", + "serde_json", + "tinystoragedrivers-core", + "tokio", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -3849,7 +4162,7 @@ version = "0.5.0" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", + "base64 0.23.1", "fs2", "futures-util", "glob", @@ -3860,7 +4173,7 @@ dependencies = [ "reqwest", "rustix", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "tinytools", "tokio", "tracing", @@ -3921,6 +4234,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-tungstenite" version = "0.29.0" @@ -4128,6 +4453,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.26" @@ -4244,6 +4575,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -4256,6 +4597,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -4720,15 +5067,6 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "x25519-dalek" version = "2.0.1" @@ -4908,3 +5246,7 @@ dependencies = [ "log", "simd-adler32", ] + +[[patch.unused]] +name = "tinyinference-decisions" +version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 7b23c1f3..3573c514 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,17 +55,17 @@ tinyhivemind-tools = { path = "crates/tinyhivemind-tools" } # host writes the same four lines against its own. `tinytools` is pinned at # the commit OpenHuman's own tree vendors, which is what makes the unification # exact; `.github/scripts/assert-openhuman-pin.sh` checks all three revs. -openhuman-embed = { git = "https://github.com/tinyhumansai/openhuman", rev = "9aebda6e5746d6a0030b5239b1e022b4a9ab45ec", default-features = false } -openhuman = { git = "https://github.com/tinyhumansai/openhuman", rev = "9aebda6e5746d6a0030b5239b1e022b4a9ab45ec", default-features = false } -tinytools = { git = "https://github.com/tinyhumansai/tinytools", rev = "8e5008c375f9e717e6cbb81fa9532599b98ff0a8" } -tinytools-agent = { git = "https://github.com/tinyhumansai/tinytools", rev = "8e5008c375f9e717e6cbb81fa9532599b98ff0a8" } +openhuman-embed = { git = "https://github.com/tinyhumansai/openhuman", rev = "bc59324d2ef7aaac2d7ead81097f1f9c264dc3bd", default-features = false } +openhuman = { git = "https://github.com/tinyhumansai/openhuman", rev = "bc59324d2ef7aaac2d7ead81097f1f9c264dc3bd", default-features = false } +tinytools = { git = "https://github.com/tinyhumansai/tinytools", rev = "8a87a26293341c51afa11bccfdbe920def7ca9d6" } +tinytools-agent = { git = "https://github.com/tinyhumansai/tinytools", rev = "8a87a26293341c51afa11bccfdbe920def7ca9d6" } # The memory engine contract and the per-agent lifecycle OpenHuman runs its # seats' memory through, for the adapter's `Recall`/`Remember` over the same # engine and layout. Git sources pinned at the commit OpenHuman's tree vendors # and patched onto that copy below, for the same one-copy reason as the tool # crates; `.github/scripts/assert-openhuman-pin.sh` checks the rev. -tinymemory-api = { git = "https://github.com/tinyhumansai/tinymemory", rev = "581a2bc853467d77f4b76debb3066524a4a46142" } -tinymemory-tools = { git = "https://github.com/tinyhumansai/tinymemory", rev = "581a2bc853467d77f4b76debb3066524a4a46142" } +tinymemory-api = { git = "https://github.com/tinyhumansai/tinymemory", rev = "f30b9bbb7319a6a7c2fb1aaa3670281f1dc4a9ba" } +tinymemory-tools = { git = "https://github.com/tinyhumansai/tinymemory", rev = "f30b9bbb7319a6a7c2fb1aaa3670281f1dc4a9ba" } # Derive macros for the crate-wide error type in `crates/tinyhivemind-core/src/error/`. # Every dependency entry should carry a comment like this one saying why it is # here. @@ -77,6 +77,10 @@ serde = { version = "1", features = ["derive"] } # Pins the exact JSON object shape and required fields of host-facing DTOs in # unit tests without adding JSON handling to the runtime dependency graph. serde_json = "1" +# Typed Markdown profile frontmatter in the OpenHuman configuration adapter. +serde_yaml_ng = "0.10" +# Fixed-time validation through OpenHuman's native cron schedule parser. +chrono = { version = "0.4", default-features = false, features = ["std"] } # Drives coordinator scheduling, adapter turn deadlines/progress, and async # tests. Pure core remains executor-neutral and exposes boxed host futures. tokio = { version = "1", features = ["macros", "rt", "sync"] } @@ -158,7 +162,13 @@ tinymemory-api = { path = "vendor/openhuman/vendor/tinymemory/crates/tinymemory- tinymemory-tools = { path = "vendor/openhuman/vendor/tinymemory/crates/tinymemory-tools" } tinymemory-integrations = { path = "vendor/openhuman/vendor/tinymemory/crates/tinymemory-integrations" } +# Storage contracts must resolve to the harness copy across git and path users. +[patch."https://github.com/tinyhumansai/tinystoragedrivers"] +tinystoragedrivers-core = { path = "vendor/openhuman/vendor/tinyagents/vendor/tinystoragedrivers/crates/tinystoragedrivers-core" } +tinystoragedrivers-sqlite = { path = "vendor/openhuman/vendor/tinyagents/vendor/tinystoragedrivers/crates/tinystoragedrivers-sqlite" } + [patch."https://github.com/tinyhumansai/tinyinference"] +tinyinference-decisions = { path = "vendor/openhuman/vendor/tinyagents/vendor/tinyinference/crates/tinyinference-decisions" } tinyinference-core = { path = "vendor/openhuman/vendor/tinyagents/vendor/tinyinference/crates/tinyinference-core" } tinyinference-embeddings = { path = "vendor/openhuman/vendor/tinyagents/vendor/tinyinference/crates/tinyinference-embeddings" } tinyinference-llm = { path = "vendor/openhuman/vendor/tinyagents/vendor/tinyinference/crates/tinyinference-llm" } diff --git a/ROADMAP.md b/ROADMAP.md index 2aac9670..252b2186 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,6 +10,7 @@ consumer builds. | Phase | What lands | State | | --- | --- | --- | +| OpenHuman configuration | One runtime, reusable Markdown profiles, persistent seats, narrowed hive permissions and native workflows; [accepted contract](docs/specs/openhuman-hive-config.md), [example manifests](examples/openhuman/hives/README.md) | Implemented for review in #113 | | P0 | Reshape the TinyBus module template into a plain library workspace | **done** | | P1 | Chat identity: `MAIN_THREAD_ID`, `GENERAL_DESK`, `is_general_chat`, `same_conversation` | **done** | | P2 | Desk types, then the membership algebra behind `DeskSet<'a>` | **done** | diff --git a/crates/tinyhivemind-core/src/driver/conduct/test/wire.rs b/crates/tinyhivemind-core/src/driver/conduct/test/wire.rs index 8674263e..9597d20c 100644 --- a/crates/tinyhivemind-core/src/driver/conduct/test/wire.rs +++ b/crates/tinyhivemind-core/src/driver/conduct/test/wire.rs @@ -426,3 +426,13 @@ fn conduct_policy_wire_preserves_conversation_and_episode_walls() { policy ); } + +#[test] +fn conduct_policy_wire_form_and_omitted_defaults_are_stable() { + let value: crate::driver::ConductPolicy = serde_json::from_str("{}").unwrap(); + assert_eq!(value, crate::driver::ConductPolicy::default()); + assert_eq!( + serde_json::to_value(value).unwrap(), + serde_json::json!({"child_turn_wall":6,"turn_wall":60}) + ); +} diff --git a/crates/tinyhivemind-core/src/driver/conduct/types.rs b/crates/tinyhivemind-core/src/driver/conduct/types.rs index 59d98a77..7c0329cd 100644 --- a/crates/tinyhivemind-core/src/driver/conduct/types.rs +++ b/crates/tinyhivemind-core/src/driver/conduct/types.rs @@ -13,7 +13,7 @@ use std::collections::BTreeMap; /// The walls a conducted episode runs inside. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] +#[serde(default, deny_unknown_fields)] pub struct ConductPolicy { /// Turns a conversation may take before it concludes without an answer. pub child_turn_wall: u64, diff --git a/crates/tinyhivemind-core/src/hive/division/test.rs b/crates/tinyhivemind-core/src/hive/division/test.rs index 681cf6f9..111b5027 100644 --- a/crates/tinyhivemind-core/src/hive/division/test.rs +++ b/crates/tinyhivemind-core/src/hive/division/test.rs @@ -372,3 +372,13 @@ fn division_policy_wire_preserves_width_and_directory_choice() { policy ); } + +#[test] +fn division_policy_wire_form_and_omitted_defaults_are_stable() { + let value: DivisionPolicy = serde_json::from_str("{}").unwrap(); + assert_eq!(value, DivisionPolicy::default()); + assert_eq!( + serde_json::to_value(value).unwrap(), + serde_json::json!({"round_width":4,"follow_directory":true}) + ); +} diff --git a/crates/tinyhivemind-core/src/hive/division/types.rs b/crates/tinyhivemind-core/src/hive/division/types.rs index 0731e4d6..3d04d731 100644 --- a/crates/tinyhivemind-core/src/hive/division/types.rs +++ b/crates/tinyhivemind-core/src/hive/division/types.rs @@ -184,7 +184,7 @@ impl Division { /// default that made a caller opt into the thing that wins would be the wrong /// way round. See `docs/experiments/2026-09-09-variety-and-roles.md`. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] +#[serde(default, deny_unknown_fields)] pub struct DivisionPolicy { /// Assignments one round may authorize concurrently. /// diff --git a/crates/tinyhivemind-hives/src/README.md b/crates/tinyhivemind-hives/src/README.md index bbecc351..0f1929c2 100644 --- a/crates/tinyhivemind-hives/src/README.md +++ b/crates/tinyhivemind-hives/src/README.md @@ -8,3 +8,6 @@ | `storage/` | Serializable snapshots and transactional storage implementations | OpenHuman types belong in the adapter crate. Core algebra remains pure. + +The coordinator freezes per-hive `HiveSettings` and scheduled job provenance at +acceptance; its settings module owns the typed configuration boundary. diff --git a/crates/tinyhivemind-hives/src/coordinator/README.md b/crates/tinyhivemind-hives/src/coordinator/README.md index ee46fd27..34293677 100644 --- a/crates/tinyhivemind-hives/src/coordinator/README.md +++ b/crates/tinyhivemind-hives/src/coordinator/README.md @@ -1,9 +1,16 @@ # Dynamic coordinator +`release_parked` matches the unique reservation, native session, episode, +accepted message identities and scheduled provenance against a completed +parked turn in the transaction that releases it. Approval adapters use this +port to reject early answers and stale records, including a later park on the +same input. `release_with` remains the host's manual release operation. + | File | Responsibility | | --- | --- | | `mod.rs` | Shared handle, dynamic APIs, and authorization snapshots | | `transaction.rs` | Writer gate, incremental commits outside the live lock, conflict reload | +| `settings.rs` | Per-hive native policy and retained roles, frozen at acceptance | | `types.rs` | Host runner port and public payloads | | `messaging.rs` | Atomic acceptance, retry IDs, starters, attribution, and private reads | | `observe.rs` | Host transcript, committed-revision watch, episode status | @@ -68,3 +75,26 @@ the same transaction as interruption. Its input is not acknowledged, and its reply and staged episode actions are discarded. Later inputs continue that conversation, including after SQLite reopen. Empty or changed session IDs fail validation without replacing a prior binding. + +`configure_hive(id, HiveSettings)` configures future episodes. Acceptance freezes +native completion options, routing thresholds, and roles; reconfiguration never +changes already accepted work. Roles reach native routing candidates, rendered +briefs, and `TurnRequest::teammates`. Retained role entries survive leave/rejoin. +Both per-hive widths must fit the global concurrent-turn cap; routing width is +intersected with completion width. Retention remains global: differing per-hive +retention settings are refused rather than ignored. + +`send_scheduled_as_host(job_id, request)` commits scheduled authority with the +accepted transcript row and episode. Every descendant assignment and claimed +turn carries `scheduled_job_id`, including queued or recovered work. Retrying +with a changed job identity or interactive origin fails with `MessageConflict`. +Sends attributed to an actively scheduled agent inherit the same durable authority; +forwarding to another agent or hive cannot strip it. The host must scope automation +authority from each captured request explicitly. + +The current completion coordinator attaches no semantic routing provider +(`BroadcastRouting.primary` and `.reasoning` are absent). Broadcasts therefore +use the driver's deterministic single-owner fallback. Native routing policy and +roles reach the routing request, and widths remain bounded, but confidence, +clarification and high-impact thresholds cannot change fallback selection. +Configuring these thresholds does not activate a model client. diff --git a/crates/tinyhivemind-hives/src/coordinator/conduct.rs b/crates/tinyhivemind-hives/src/coordinator/conduct.rs index d8b15f14..d41a942c 100644 --- a/crates/tinyhivemind-hives/src/coordinator/conduct.rs +++ b/crates/tinyhivemind-hives/src/coordinator/conduct.rs @@ -30,7 +30,11 @@ struct Environment { routing: RoutingPolicy, } impl Environment { - fn new(hive: &HiveInfo, options: &CoordinatorOptions) -> Result { + fn new( + hive: &HiveInfo, + options: &CoordinatorOptions, + settings: Option<&crate::HiveSettings>, + ) -> Result { let graph = HiveGraph::new( Desk { id: hive.hive_id.clone(), @@ -44,7 +48,7 @@ impl Environment { .map(|id| RouteCandidate { id: id.clone(), label: id.clone(), - role: None, + role: settings.and_then(|settings| settings.roles.get(id).cloned()), description: None, capabilities: Vec::new(), learned_topics: Vec::new(), @@ -59,9 +63,8 @@ impl Environment { .map(|id| AgentBinding::new(id, Seat(id.clone()))) .collect(), )?; - Ok(Self { - hive: bound, - routing: RoutingPolicy { + let mut routing = settings.map_or( + RoutingPolicy { minimum_confidence: Probability::ZERO, high_impact_minimum_confidence: Probability::ONE, clarification_threshold: Probability::ONE, @@ -69,6 +72,12 @@ impl Environment { round_width: options.round_width, choice_option_limit: 8, }, + |settings| settings.routing.clone(), + ); + routing.round_width = routing.round_width.min(options.round_width); + Ok(Self { + hive: bound, + routing, }) } fn driver(&self, options: &CoordinatorOptions) -> Result> { @@ -133,6 +142,7 @@ fn append( let only_for = narrow_readers(state, &destination, thread, only_for)?; let sequence = next_sequence(state)?; state.messages.push(Message { + scheduled_job_id: episode.scheduled_job_id.clone(), message_id: format!("hivemind:event:{sequence}"), sequence, sender, @@ -203,7 +213,12 @@ pub(super) async fn prepare(state: &mut StoredState, options: &CoordinatorOption if running || !episode.pending.is_empty() || episode.waiting { continue; } - let environment = Environment::new(&episode.hive, options)?; + let effective = episode + .settings + .as_ref() + .map(|settings| settings.options.clone()); + let options = effective.as_ref().unwrap_or(options); + let environment = Environment::new(&episode.hive, options, episode.settings.as_ref())?; let driver = environment.driver(options)?; let mut conductor = environment.conductor(&driver, &episode, options)?; if episode.wave_open { @@ -278,7 +293,12 @@ pub(super) fn open( options: &CoordinatorOptions, ) -> Result<(Vec, String)> { let mut episode = state.episodes[index].clone(); - let environment = Environment::new(&episode.hive, options)?; + let effective = episode + .settings + .as_ref() + .map(|settings| settings.options.clone()); + let options = effective.as_ref().unwrap_or(options); + let environment = Environment::new(&episode.hive, options, episode.settings.as_ref())?; let driver = environment.driver(options)?; let mut conductor = environment.conductor(&driver, &episode, options)?; let messages: Vec<_> = state @@ -331,7 +351,16 @@ pub(super) fn open( opened_it: false, }; } - let brief = brief.render(); + let mut brief = brief.render(); + let teammates = teammates(&episode); + for teammate in &teammates { + if let Some(role) = &teammate.role { + brief.push_str("\n@"); + brief.push_str(&teammate.id); + brief.push_str(": "); + brief.push_str(role); + } + } checkpoint(&conductor, &mut episode)?; state.episodes[index] = episode; Ok((messages, brief)) @@ -348,7 +377,12 @@ pub(super) fn record( if episode.finished { return Ok(()); } - let environment = Environment::new(&episode.hive, options)?; + let effective = episode + .settings + .as_ref() + .map(|settings| settings.options.clone()); + let options = effective.as_ref().unwrap_or(options); + let environment = Environment::new(&episode.hive, options, episode.settings.as_ref())?; let driver = environment.driver(options)?; let mut conductor = environment.conductor(&driver, &episode, options)?; let mut calls: Vec<_> = actions @@ -392,7 +426,12 @@ pub(super) fn release( { continue; } - let environment = Environment::new(&episode.hive, options)?; + let effective = episode + .settings + .as_ref() + .map(|settings| settings.options.clone()); + let options = effective.as_ref().unwrap_or(options); + let environment = Environment::new(&episode.hive, options, episode.settings.as_ref())?; let driver = environment.driver(options)?; let mut conductor = environment.conductor(&driver, &episode, options)?; conductor.resume_seat(agent_id); @@ -420,7 +459,12 @@ fn prune_removed( .cloned() .collect(); if !removed.is_empty() { - let environment = Environment::new(&episode.hive, options)?; + let effective = episode + .settings + .as_ref() + .map(|settings| settings.options.clone()); + let options = effective.as_ref().unwrap_or(options); + let environment = Environment::new(&episode.hive, options, episode.settings.as_ref())?; let driver = environment.driver(options)?; let mut conductor = environment.conductor(&driver, episode, options)?; for turn in &removed { @@ -457,3 +501,37 @@ pub(super) fn prune_pending(state: &mut StoredState, options: &CoordinatorOption } Ok(changed) } + +/// Explicit host-neutral teammate payload, using the frozen episode roles. +pub(super) fn teammates( + episode: &EpisodeRecord, +) -> Vec { + episode + .hive + .members + .iter() + .map(|id| tinyhivemind_core::runtime::BriefedTeammate { + id: id.clone(), + label: id.clone(), + role: episode + .settings + .as_ref() + .and_then(|settings| settings.roles.get(id).cloned()), + description: None, + }) + .collect() +} + +#[cfg(test)] +mod test; + +/// Visible teammates exclude the viewing agent. +pub(super) fn teammates_for( + episode: &EpisodeRecord, + agent_id: &str, +) -> Vec { + teammates(episode) + .into_iter() + .filter(|teammate| teammate.id != agent_id) + .collect() +} diff --git a/crates/tinyhivemind-hives/src/coordinator/conduct/README.md b/crates/tinyhivemind-hives/src/coordinator/conduct/README.md new file mode 100644 index 00000000..b903bd0b --- /dev/null +++ b/crates/tinyhivemind-hives/src/coordinator/conduct/README.md @@ -0,0 +1,8 @@ +# Conductor test support + +| File | Responsibility | +| --- | --- | +| `test.rs` | Native routing thresholds, width intersection, and role propagation | + +The conductor implementation remains in the parent `conduct.rs`; this directory +holds its private behavior tests. diff --git a/crates/tinyhivemind-hives/src/coordinator/conduct/test.rs b/crates/tinyhivemind-hives/src/coordinator/conduct/test.rs new file mode 100644 index 00000000..84922711 --- /dev/null +++ b/crates/tinyhivemind-hives/src/coordinator/conduct/test.rs @@ -0,0 +1,27 @@ +//! Native routing configuration and membership role propagation. +use super::*; + +#[test] +fn configured_roles_and_routing_thresholds_reach_the_native_routing_request() -> Result<()> { + let hive = HiveInfo { + hive_id: "one".into(), + name: "One".into(), + description: None, + members: vec!["agent".into()], + }; + let mut settings = crate::HiveSettings::default(); + settings.roles.insert("agent".into(), "reviewer".into()); + settings.routing.minimum_confidence = Probability::ONE; + settings.routing.choice_option_limit = 3; + settings.routing.round_width = 4; + let environment = Environment::new(&hive, &settings.options, Some(&settings))?; + let request = + environment + .hive + .desk_request("work", Vec::new(), None, 0, environment.routing.clone()); + assert_eq!(request.candidates[0].role.as_deref(), Some("reviewer")); + assert_eq!(request.policy.minimum_confidence, Probability::ONE); + assert_eq!(request.policy.choice_option_limit, 3); + assert_eq!(request.policy.round_width, 1); + Ok(()) +} diff --git a/crates/tinyhivemind-hives/src/coordinator/messaging.rs b/crates/tinyhivemind-hives/src/coordinator/messaging.rs index 52b99945..1d88ceab 100644 --- a/crates/tinyhivemind-hives/src/coordinator/messaging.rs +++ b/crates/tinyhivemind-hives/src/coordinator/messaging.rs @@ -6,6 +6,7 @@ use crate::{Delivery, DeliveryStatus, EpisodeRecord, Error, Result, StoredState} use std::collections::BTreeSet; impl Coordinator { /// Accept and enqueue a message without waiting for a response. + /// Sends by an active scheduled agent inherit its captured scheduled authority. /// # Errors /// Returns unknown sender/destination, missing membership, invalid thread, /// conflicting retry identity or persistence errors. @@ -13,7 +14,7 @@ impl Coordinator { if request.sender == HOST_ID { return Err(Error::InvalidIdentifier("sender")); } - self.accept(request).await + self.accept(request, None).await } /// Submit through the reserved host identity rather than impersonating an agent. /// The request's sender field is overwritten. @@ -21,30 +22,39 @@ impl Coordinator { /// Returns destination, visibility, retry or storage validation errors. pub async fn send_as_host(&self, mut request: SendMessage) -> Result { request.sender = HOST_ID.into(); - self.accept(request).await + self.accept(request, None).await } - async fn accept(&self, request: SendMessage) -> Result { + /// Accept host work carrying durable scheduled authority. + /// # Errors + /// Returns invalid job identity, destination, retry, visibility or storage errors. + pub async fn send_scheduled_as_host( + &self, + job_id: &str, + mut request: SendMessage, + ) -> Result { + identifier(job_id, "scheduled job id")?; + request.sender = HOST_ID.into(); + self.accept(request, Some(job_id.to_owned())).await + } + async fn accept( + &self, + request: SendMessage, + scheduled_job_id: Option, + ) -> Result { identifier(&request.message_id, "message id")?; if request.message_id.starts_with("hivemind:") { return Err(Error::InvalidIdentifier("reserved message id")); } let retention = self.inner.options.retention; self.update(|state| { - if let Some(old) = state.accepted.get(&request.message_id) { - if old != &request { - return Err(Error::MessageConflict(request.message_id.clone())); - } - let old_message = state - .messages - .iter() - .find(|msg| msg.message_id == request.message_id) - .ok_or_else(|| { - Error::InvalidState("accepted message missing transcript row".into()) - })?; - return Ok(Receipt { - message_id: request.message_id.clone(), - sequence: old_message.sequence, - }); + let scheduled_job_id = scheduled_job_id.clone().or_else(|| { + state + .running + .get(&request.sender) + .and_then(|running| running.request.scheduled_job_id.clone()) + }); + if let Some(receipt) = retry_receipt(state, &request, scheduled_job_id.as_deref())? { + return Ok(receipt); } let recipients = recipients(state, &request)?; let only_for = narrow_readers( @@ -57,6 +67,7 @@ impl Coordinator { let episode_id = matches!(request.destination, Destination::Hive(_)) .then(|| format!("episode:{sequence}")); let message = Message { + scheduled_job_id: scheduled_job_id.clone(), message_id: request.message_id.clone(), sequence, sender: request.sender.clone(), @@ -94,6 +105,8 @@ impl Coordinator { let id = episode_id .ok_or_else(|| Error::InvalidState("hive message has no episode".into()))?; state.episodes.push(EpisodeRecord { + settings: state.hive_settings.get(&hive.hive_id).cloned(), + scheduled_job_id: scheduled_job_id.clone(), episode_id: id, hive, opened_at: sequence, @@ -392,3 +405,29 @@ pub(super) fn narrow_readers( } Ok(readers) } + +/// Retry authority is part of the same atomic identity as the authored payload. +fn retry_receipt( + state: &StoredState, + request: &SendMessage, + scheduled_job_id: Option<&str>, +) -> Result> { + let Some(old) = state.accepted.get(&request.message_id) else { + return Ok(None); + }; + if old != request { + return Err(Error::MessageConflict(request.message_id.clone())); + } + let message = state + .messages + .iter() + .find(|msg| msg.message_id == request.message_id) + .ok_or_else(|| Error::InvalidState("accepted message missing transcript row".into()))?; + if message.scheduled_job_id.as_deref() != scheduled_job_id { + return Err(Error::MessageConflict(request.message_id.clone())); + } + Ok(Some(Receipt { + message_id: request.message_id.clone(), + sequence: message.sequence, + })) +} diff --git a/crates/tinyhivemind-hives/src/coordinator/mod.rs b/crates/tinyhivemind-hives/src/coordinator/mod.rs index ae7db687..b4b8ee8e 100644 --- a/crates/tinyhivemind-hives/src/coordinator/mod.rs +++ b/crates/tinyhivemind-hives/src/coordinator/mod.rs @@ -3,6 +3,8 @@ mod conduct; mod messaging; mod observe; mod scheduler; +mod settings; +pub use settings::HiveSettings; #[cfg(test)] mod test; mod transaction; @@ -339,6 +341,7 @@ impl Coordinator { known_agent(state, agent_id)?; if let Some(agent) = state.agents.get_mut(agent_id) { agent.parked = false; + agent.parked_turn = None; if let Some(note) = ¬e { agent.resumption = Some(note.clone()); } @@ -347,6 +350,39 @@ impl Coordinator { }) .await } + /// Release only the exact completed parked turn captured by a host approval. + /// The identity check and release share one storage transaction. + /// # Errors + /// Unknown agent, running/unparked or mismatched turn, conductor or storage failure. + pub async fn release_parked( + &self, + agent_id: &str, + turn: &crate::ParkedTurn, + note: Option, + ) -> Result<()> { + self.update(|state| { + known_agent(state, agent_id)?; + let agent = state + .agents + .get_mut(agent_id) + .ok_or_else(|| Error::UnknownAgent(agent_id.into()))?; + if state.running.contains_key(agent_id) + || !agent.parked + || agent.parked_turn.as_ref() != Some(turn) + { + return Err(Error::InvalidState( + "approval does not match a completed parked turn".into(), + )); + } + agent.parked = false; + agent.parked_turn = None; + if let Some(note) = ¬e { + agent.resumption = Some(note.clone()); + } + conduct::release(state, agent_id, &self.inner.options) + }) + .await + } /// Inspect uncertain turn records without replaying them. /// # Errors /// Returns a poisoned shared lock error. diff --git a/crates/tinyhivemind-hives/src/coordinator/scheduler.rs b/crates/tinyhivemind-hives/src/coordinator/scheduler.rs index 63e28947..30d6e072 100644 --- a/crates/tinyhivemind-hives/src/coordinator/scheduler.rs +++ b/crates/tinyhivemind-hives/src/coordinator/scheduler.rs @@ -178,40 +178,57 @@ impl Coordinator { .filter(|hive| hive.members.contains(&agent_id)) .cloned() .collect(); - let (messages, episode, turn, delivery_sequence) = match work { - Work::Direct(index) => { - let delivery = &next.deliveries[index]; - let message = next - .messages - .iter() - .find(|msg| msg.sequence == delivery.sequence) - .cloned() - .ok_or_else(|| { - Error::InvalidState("direct delivery missing message".into()) - })?; - let sequence = delivery.sequence; - next.deliveries[index].status = DeliveryStatus::Running; - (vec![message], None, None, Some(sequence)) - } - Work::Episode(index, turn_index) => { - let turn = next.episodes[index].pending[turn_index].clone(); - let (messages, brief) = conduct::open(next, index, &turn, &self.inner.options)?; - let record = &next.episodes[index]; - let context = EpisodeContext { - episode_id: record.episode_id.clone(), - hive_id: record.hive.hive_id.clone(), - thread: turn.thread().map(|root| root.0).or(record.thread), - brief, - }; - removals.push((index, agent_id.clone())); - (messages, Some(context), Some(turn), None) - } - }; - let agent = next.agents.get_mut(&agent_id); - let (session_id, resumption) = agent.map_or((None, None), |agent| { - (agent.session_id.clone(), agent.resumption.take()) - }); + let (messages, episode, turn, delivery_sequence, scheduled_job_id, teammates) = + match work { + Work::Direct(index) => { + let delivery = &next.deliveries[index]; + let message = next + .messages + .iter() + .find(|msg| msg.sequence == delivery.sequence) + .cloned() + .ok_or_else(|| { + Error::InvalidState("direct delivery missing message".into()) + })?; + let sequence = delivery.sequence; + next.deliveries[index].status = DeliveryStatus::Running; + let origin = message.scheduled_job_id.clone(); + ( + vec![message], + None, + None, + Some(sequence), + origin, + Vec::new(), + ) + } + Work::Episode(index, turn_index) => { + let turn = next.episodes[index].pending[turn_index].clone(); + let (messages, brief) = + conduct::open(next, index, &turn, &self.inner.options)?; + let record = &next.episodes[index]; + let context = EpisodeContext { + episode_id: record.episode_id.clone(), + hive_id: record.hive.hive_id.clone(), + thread: turn.thread().map(|root| root.0).or(record.thread), + brief, + }; + removals.push((index, agent_id.clone())); + ( + messages, + Some(context), + Some(turn), + None, + record.scheduled_job_id.clone(), + conduct::teammates_for(record, &agent_id), + ) + } + }; + let (session_id, resumption) = take_continuation(next, &agent_id); let request = TurnRequest { + turn_id: format!("{}:{}:{agent_id}", next.writer_epoch, next.revision), + scheduled_job_id, + teammates, agent_id: agent_id.clone(), session_id, messages, @@ -297,7 +314,7 @@ impl Coordinator { .agents .get_mut(agent_id) .ok_or_else(|| Error::UnknownAgent(agent_id.into()))?; - agent.parked = outcome.disposition == TurnDisposition::Parked; + record_parked(agent, &outcome, &running.request); if let Some(sequence) = running.delivery_sequence { for delivery in &mut state.deliveries { if delivery.sequence == sequence && delivery.agent_id == agent_id { @@ -311,6 +328,7 @@ impl Coordinator { if let Some(body) = &outcome.reply { let sequence = super::messaging::next_sequence(state)?; state.messages.push(super::Message { + scheduled_job_id: running.request.scheduled_job_id.clone(), message_id: format!("hivemind:event:{sequence}"), sequence, sender: agent_id.into(), @@ -368,3 +386,36 @@ fn candidates(state: &crate::StoredState) -> Vec<(u64, String, Work)> { candidates.sort_by(|left, right| (&left.0, &left.1).cmp(&(&right.0, &right.1))); candidates } + +/// Take the one-shot release note while preserving the continuing session. +fn take_continuation(state: &mut StoredState, agent_id: &str) -> (Option, Option) { + state + .agents + .get_mut(agent_id) + .map_or((None, None), |agent| { + (agent.session_id.clone(), agent.resumption.take()) + }) +} + +/// Retain exact parked provenance for transactional approval release. +fn record_parked( + agent: &mut crate::AgentRecord, + outcome: &TurnOutcome, + request: &super::TurnRequest, +) { + agent.parked = outcome.disposition == TurnDisposition::Parked; + agent.parked_turn = agent.parked.then(|| crate::ParkedTurn { + turn_id: request.turn_id.clone(), + session_id: outcome.session_id.clone(), + episode_id: request + .episode + .as_ref() + .map(|episode| episode.episode_id.clone()), + message_ids: request + .messages + .iter() + .map(|message| message.message_id.clone()) + .collect(), + scheduled_job_id: request.scheduled_job_id.clone(), + }); +} diff --git a/crates/tinyhivemind-hives/src/coordinator/settings.rs b/crates/tinyhivemind-hives/src/coordinator/settings.rs new file mode 100644 index 00000000..463dbcc5 --- /dev/null +++ b/crates/tinyhivemind-hives/src/coordinator/settings.rs @@ -0,0 +1,83 @@ +//! Per-hive host-neutral configuration captured at message acceptance. +use super::{Coordinator, CoordinatorOptions, identifier, known_agent}; +use crate::{Error, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use tinyhivemind_core::{embed::RoutingPolicy, runtime::responder::Probability}; + +/// Completion and routing configuration for one hive. +/// +/// Accepted episodes retain a snapshot even when the live hive is reconfigured. +/// Roles survive membership changes so a rejoining agent keeps its role. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct HiveSettings { + /// Per-hive completion bounds; round width cannot exceed the global cap. + pub options: CoordinatorOptions, + /// Native routing thresholds and choice limits. Routing width is intersected + /// with completion width and cannot exceed the global concurrent-turn cap. + /// The coordinator currently has no semantic router: thresholds are passed + /// to native routing, while broadcasts use its single-owner fallback. + pub routing: RoutingPolicy, + /// Registered identities and their role in this hive. + pub roles: BTreeMap, +} +impl Default for HiveSettings { + fn default() -> Self { + Self { + options: CoordinatorOptions::default(), + routing: RoutingPolicy { + minimum_confidence: Probability::ZERO, + high_impact_minimum_confidence: Probability::ONE, + clarification_threshold: Probability::ONE, + high_impact_threshold: Probability::ONE, + round_width: 1, + choice_option_limit: 8, + }, + roles: BTreeMap::new(), + } + } +} +impl Coordinator { + /// Configure future episodes of an existing hive atomically. + /// + /// Global retention and concurrent-turn admission stay coordinator-owned; + /// per-hive `options.retention` must equal the global retention setting. + /// # Errors + /// Returns unknown hive/role agent, invalid bounds or role, or storage errors. + pub async fn configure_hive(&self, hive_id: &str, settings: HiveSettings) -> Result<()> { + if settings.options.round_width == 0 + || settings.options.round_width > self.inner.options.round_width + || settings.routing.round_width == 0 + || settings.routing.round_width > self.inner.options.round_width + || settings.routing.choice_option_limit == 0 + || settings.options.conduct_policy.turn_wall == 0 + || settings.options.conduct_policy.child_turn_wall == 0 + || settings.options.retention != self.inner.options.retention + { + return Err(Error::InvalidOptions); + } + self.update(|state| { + if !state.hives.contains_key(hive_id) { + return Err(Error::UnknownHive(hive_id.into())); + } + for (agent, role) in &settings.roles { + known_agent(state, agent)?; + identifier(role, "hive role")?; + } + state.hive_settings.insert(hive_id.into(), settings.clone()); + Ok(()) + }) + .await + } + /// Read the current explicit configuration; unconfigured hives use global defaults. + /// # Errors + /// Returns unknown hive or a poisoned shared lock. + pub fn hive_settings(&self, hive_id: &str) -> Result> { + let live = self.lock()?; + if !live.durable.hives.contains_key(hive_id) { + return Err(Error::UnknownHive(hive_id.into())); + } + Ok(live.durable.hive_settings.get(hive_id).cloned()) + } +} diff --git a/crates/tinyhivemind-hives/src/coordinator/test/README.md b/crates/tinyhivemind-hives/src/coordinator/test/README.md index a256c623..a2712df0 100644 --- a/crates/tinyhivemind-hives/src/coordinator/test/README.md +++ b/crates/tinyhivemind-hives/src/coordinator/test/README.md @@ -22,3 +22,6 @@ pending recovered work, mismatches and failed storage commits. `finalization.rs` covers failed finalizers, retained sessions, suppressed staged actions, invalid session rejection, and SQLite reopen. + +`settings.rs` pins frozen per-hive policy/roles and durable scheduled authority, +including recovered work, delegated children and origin-conflicting retries. diff --git a/crates/tinyhivemind-hives/src/coordinator/test/mod.rs b/crates/tinyhivemind-hives/src/coordinator/test/mod.rs index 642d2bec..560abc89 100644 --- a/crates/tinyhivemind-hives/src/coordinator/test/mod.rs +++ b/crates/tinyhivemind-hives/src/coordinator/test/mod.rs @@ -307,3 +307,4 @@ mod starters; mod transactions; mod options; +mod settings; diff --git a/crates/tinyhivemind-hives/src/coordinator/test/registration.rs b/crates/tinyhivemind-hives/src/coordinator/test/registration.rs index 35f3e82c..a80f7f54 100644 --- a/crates/tinyhivemind-hives/src/coordinator/test/registration.rs +++ b/crates/tinyhivemind-hives/src/coordinator/test/registration.rs @@ -164,3 +164,12 @@ async fn session_registration_validates_before_publishing_and_storage_failure_is fn completed_turn(request: TurnRequest) -> TurnFuture { Box::pin(async move { Ok(done(&request)) }) } + +#[test] +fn coordinator_options_wire_form_and_omitted_defaults_are_stable() { + let value: crate::CoordinatorOptions = serde_json::from_str("{}").unwrap(); + assert_eq!( + serde_json::to_value(value).unwrap(), + serde_json::json!({"round_width":1,"conduct_policy":{"child_turn_wall":6,"turn_wall":60},"broadcast_budget":null,"retention":{"settled_episodes":null,"delivered":null,"interrupted":null,"pending_per_agent":null}}) + ); +} diff --git a/crates/tinyhivemind-hives/src/coordinator/test/release.rs b/crates/tinyhivemind-hives/src/coordinator/test/release.rs index 1d877854..b84634be 100644 --- a/crates/tinyhivemind-hives/src/coordinator/test/release.rs +++ b/crates/tinyhivemind-hives/src/coordinator/test/release.rs @@ -2,6 +2,102 @@ #![allow(clippy::unwrap_used)] use super::*; +#[tokio::test] +async fn old_approval_cannot_release_a_new_park_of_the_same_direct_delivery() { + let c = setup().await; + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let captured = seen.clone(); + add(&c, "a", move |request| { + let captured = captured.clone(); + Box::pin(async move { + captured.lock().unwrap().push(request.clone()); + let mut outcome = done(&request); + outcome.disposition = TurnDisposition::Parked; + Ok(outcome) + }) + }) + .await; + c.send_as_host(message("same-input", Destination::Agent("a".into()))) + .await + .unwrap(); + assert_eq!(c.run_until_idle().await.unwrap().parked, 1); + let first = seen.lock().unwrap()[0].clone(); + let old = crate::ParkedTurn { + turn_id: first.turn_id.clone(), + session_id: "session:a".into(), + episode_id: None, + message_ids: first + .messages + .iter() + .map(|message| message.message_id.clone()) + .collect(), + scheduled_job_id: None, + }; + // Manual host release leaves an old adapter approval outside coordinator storage. + c.release("a").await.unwrap(); + assert_eq!(c.run_until_idle().await.unwrap().parked, 1); + let second = seen.lock().unwrap()[1].clone(); + assert_eq!(first.messages, second.messages); + assert_ne!(first.turn_id, second.turn_id); + assert!( + c.release_parked("a", &old, Some("stale approval".into())) + .await + .is_err() + ); + assert_eq!(c.run_until_idle().await.unwrap().completed, 0); + let current = crate::ParkedTurn { + turn_id: second.turn_id, + ..old + }; + c.release_parked("a", ¤t, Some("current approval".into())) + .await + .unwrap(); +} + +#[tokio::test] +async fn correlated_release_refuses_stale_identity_and_preserves_the_parked_turn() { + let c = setup().await; + let seen = parks_once(&c).await; + c.send_as_host(message("task", Destination::Agent("a".into()))) + .await + .unwrap(); + c.run_until_idle().await.unwrap(); + let request = seen.lock().unwrap()[0].clone(); + let mut identity = crate::ParkedTurn { + turn_id: request.turn_id.clone(), + session_id: "session:a".into(), + episode_id: None, + message_ids: request + .messages + .iter() + .map(|message| message.message_id.clone()) + .collect(), + scheduled_job_id: None, + }; + identity.message_ids = vec!["stale".into()]; + assert!( + c.release_parked("a", &identity, Some("stale note".into())) + .await + .is_err() + ); + assert_eq!(c.run_until_idle().await.unwrap().completed, 0); + identity.message_ids = request + .messages + .iter() + .map(|message| message.message_id.clone()) + .collect(); + c.release_parked("a", &identity, Some("matching note".into())) + .await + .unwrap(); + assert_eq!(c.run_until_idle().await.unwrap().completed, 1); + assert_eq!( + seen.lock().unwrap()[1].resumption.as_deref(), + Some("matching note") + ); + assert!(c.release_parked("a", &identity, None).await.is_err()); + assert!(c.release_parked("missing", &identity, None).await.is_err()); +} + async fn parks_once(c: &Coordinator) -> Arc>> { let seen = Arc::new(std::sync::Mutex::new(Vec::new())); let recorded = seen.clone(); @@ -85,6 +181,9 @@ async fn release_without_a_note_resumes_as_before_and_notes_survive_restart() { #[test] fn turn_requests_without_a_note_keep_their_wire_form() { let request = TurnRequest { + turn_id: String::new(), + scheduled_job_id: None, + teammates: Vec::new(), agent_id: "a".into(), session_id: None, messages: Vec::new(), diff --git a/crates/tinyhivemind-hives/src/coordinator/test/settings.rs b/crates/tinyhivemind-hives/src/coordinator/test/settings.rs new file mode 100644 index 00000000..d3dab768 --- /dev/null +++ b/crates/tinyhivemind-hives/src/coordinator/test/settings.rs @@ -0,0 +1,519 @@ +//! Frozen hive policy and durable scheduled authority regressions. +use super::*; + +#[tokio::test] +async fn settings_are_frozen_per_episode_and_survive_restart() { + let store = Arc::new(MemoryStorage::new()); + let global = CoordinatorOptions { + round_width: 3, + ..Default::default() + }; + let c = Coordinator::new("runtime".into(), store.clone(), global.clone()) + .await + .unwrap(); + for id in ["a", "b"] { + add(&c, id, |r| Box::pin(async move { Ok(done(&r)) })).await; + } + hive(&c, "one", &["a", "b"]).await; + hive(&c, "two", &["a", "b"]).await; + let mut settings = HiveSettings::default(); + settings.roles.insert("b".into(), "reviewer".into()); + settings.options.conduct_policy.turn_wall = 2; + settings.routing.choice_option_limit = 3; + c.configure_hive("one", settings.clone()).await.unwrap(); + c.send_as_host(message("first", Destination::Hive("one".into()))) + .await + .unwrap(); + settings.roles.insert("b".into(), "solver".into()); + settings.options.round_width = 2; + settings.routing.round_width = 2; + c.configure_hive("one", settings.clone()).await.unwrap(); + c.configure_hive("two", settings.clone()).await.unwrap(); + c.send_as_host(message("second", Destination::Hive("two".into()))) + .await + .unwrap(); + let state = c.lock().unwrap().durable.clone(); + assert_eq!( + state.episodes[0].settings.as_ref().unwrap().roles["b"], + "reviewer" + ); + assert_eq!( + state.episodes[0] + .settings + .as_ref() + .unwrap() + .options + .round_width, + 1 + ); + assert_eq!( + state.episodes[1] + .settings + .as_ref() + .unwrap() + .options + .round_width, + 2 + ); + c.leave_hive("one", "b").await.unwrap(); + c.join_hive("one", "b").await.unwrap(); + let restarted = Coordinator::new("runtime".into(), store, global) + .await + .unwrap(); + assert_eq!( + restarted.hive_settings("one").unwrap().unwrap().roles["b"], + "solver" + ); + settings.options.round_width = 4; + assert!(matches!( + restarted.configure_hive("one", settings).await, + Err(Error::InvalidOptions) + )); +} + +#[tokio::test] +async fn scheduled_identity_survives_queueing_and_rejects_origin_changes() { + let store = Arc::new(MemoryStorage::new()); + let c = Coordinator::new( + "runtime".into(), + store.clone(), + CoordinatorOptions::default(), + ) + .await + .unwrap(); + add(&c, "a", |r| Box::pin(async move { Ok(done(&r)) })).await; + hive(&c, "one", &["a"]).await; + let input = message("scheduled", Destination::Hive("one".into())); + let receipt = c + .send_scheduled_as_host("job", input.clone()) + .await + .unwrap(); + assert_eq!( + c.send_scheduled_as_host("job", input.clone()) + .await + .unwrap(), + receipt + ); + assert!(matches!( + c.send_as_host(input.clone()).await, + Err(Error::MessageConflict(_)) + )); + assert!(matches!( + c.send_scheduled_as_host("other", input).await, + Err(Error::MessageConflict(_)) + )); + let restarted = Coordinator::new("runtime".into(), store, CoordinatorOptions::default()) + .await + .unwrap(); + add(&restarted, "a", |r| { + Box::pin(async move { + assert_eq!(r.scheduled_job_id.as_deref(), Some("job")); + Ok(done(&r)) + }) + }) + .await; + assert!(restarted.run_until_idle().await.unwrap().completed > 0); +} + +#[tokio::test] +async fn per_hive_width_conduct_walls_and_roles_drive_actual_turns() { + let c = Coordinator::new( + "runtime".into(), + Arc::new(MemoryStorage::new()), + CoordinatorOptions { + round_width: 3, + ..Default::default() + }, + ) + .await + .unwrap(); + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + for id in ["a", "b"] { + let seen = seen.clone(); + add(&c, id, move |r| { + seen.lock().unwrap().push(r.clone()); + Box::pin(async move { Ok(done(&r)) }) + }) + .await; + } + for (name, width, wall, role) in [("one", 1, 2, "reviewer"), ("two", 2, 4, "solver")] { + hive(&c, name, &["a", "b"]).await; + let mut settings = HiveSettings::default(); + settings.options.round_width = width; + settings.options.conduct_policy.turn_wall = wall; + settings.routing.round_width = width; + settings.roles.insert("b".into(), role.into()); + c.configure_hive(name, settings).await.unwrap(); + c.send_as_host(message(name, Destination::Hive(name.into()))) + .await + .unwrap(); + } + c.advance().await.unwrap(); + let state = c.lock().unwrap().durable.clone(); + assert_eq!(state.episodes[0].pending.len(), 1); + assert_eq!(state.episodes[1].pending.len(), 2); + c.run_until_idle().await.unwrap(); + let seen = seen.lock().unwrap(); + for (name, count, role) in [("one", 2, "reviewer"), ("two", 4, "solver")] { + let turns: Vec<_> = seen + .iter() + .filter(|r| r.episode.as_ref().unwrap().hive_id == name) + .collect(); + assert_eq!(turns.len(), count); + assert!(turns[0].episode.as_ref().unwrap().brief.contains(role)); + assert_eq!( + turns + .iter() + .find(|turn| turn.agent_id == "a") + .unwrap() + .teammates + .iter() + .find(|t| t.id == "b") + .unwrap() + .role + .as_deref(), + Some(role) + ); + } +} + +#[tokio::test] +async fn scheduled_authority_is_inherited_by_private_children_and_generated_rows() { + let c = setup().await; + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + for id in ["a", "b"] { + let c2 = c.clone(); + let seen = seen.clone(); + add(&c, id, move |r| { + let c = c2.clone(); + let seen = seen.clone(); + Box::pin(async move { + assert_eq!(r.scheduled_job_id.as_deref(), Some("cron")); + seen.lock().unwrap().push(r.clone()); + let ep = r.episode.as_ref().unwrap(); + let action = if r.agent_id == "a" && ep.thread.is_none() && r.session_id.is_none() { + EpisodeAction::Ask { + agents: vec!["b".into()], + body: "review".into(), + } + } else { + EpisodeAction::Complete { + body: "done".into(), + } + }; + c.submit_action(&r.agent_id, &ep.episode_id, action).await?; + Ok(done(&r)) + }) + }) + .await; + } + hive(&c, "one", &["a", "b"]).await; + let mut input = message("work", Destination::Hive("one".into())); + input.starters = vec!["a".into()]; + c.send_scheduled_as_host("cron", input).await.unwrap(); + c.run_until_idle().await.unwrap(); + assert!( + seen.lock() + .unwrap() + .iter() + .any(|r| r.agent_id == "b" && r.episode.as_ref().unwrap().thread.is_some()) + ); + assert!( + c.read_transcript(None) + .unwrap() + .iter() + .all(|m| m.scheduled_job_id.as_deref() == Some("cron")) + ); +} + +#[tokio::test] +async fn hive_settings_refuse_unsupported_retention_and_unknown_roles() { + let c = setup().await; + hive(&c, "one", &[]).await; + let mut settings = HiveSettings::default(); + settings.options.retention.delivered = Some(1); + assert!(matches!( + c.configure_hive("one", settings).await, + Err(Error::InvalidOptions) + )); + let mut settings = HiveSettings::default(); + settings.roles.insert("unknown".into(), "role".into()); + assert!(matches!( + c.configure_hive("one", settings).await, + Err(Error::UnknownAgent(_)) + )); + assert!(matches!( + c.hive_settings("missing"), + Err(Error::UnknownHive(_)) + )); +} + +#[tokio::test] +async fn configured_hives_share_the_global_concurrent_turn_cap() { + let c = Coordinator::new( + "runtime".into(), + Arc::new(MemoryStorage::new()), + CoordinatorOptions { + round_width: 3, + ..Default::default() + }, + ) + .await + .unwrap(); + let permits = Arc::new(tokio::sync::Semaphore::new(0)); + let (started, mut starts) = tokio::sync::mpsc::unbounded_channel(); + for id in ["a", "b", "c", "d"] { + let c2 = c.clone(); + let permits = permits.clone(); + let started = started.clone(); + add(&c, id, move |r| { + let c = c2.clone(); + let permits = permits.clone(); + let started = started.clone(); + Box::pin(async move { + started.send(r.agent_id.clone()).unwrap(); + permits.acquire().await.unwrap().forget(); + c.submit_action( + &r.agent_id, + &r.episode.as_ref().unwrap().episode_id, + EpisodeAction::Complete { + body: "done".into(), + }, + ) + .await?; + Ok(done(&r)) + }) + }) + .await; + } + for (name, members) in [("one", ["a", "b"]), ("two", ["c", "d"])] { + hive(&c, name, &members).await; + let mut settings = HiveSettings::default(); + settings.options.round_width = 2; + settings.routing.round_width = 2; + c.configure_hive(name, settings).await.unwrap(); + c.send_as_host(message(name, Destination::Hive(name.into()))) + .await + .unwrap(); + } + let runner = c.clone(); + let drain = tokio::spawn(async move { runner.run_until_idle().await }); + for _ in 0..3 { + starts.recv().await.unwrap(); + } + assert!(starts.try_recv().is_err()); + assert_eq!(c.lock().unwrap().durable.running.len(), 3); + permits.add_permits(4); + assert_eq!(drain.await.unwrap().unwrap().completed, 4); +} + +#[test] +fn legacy_turns_and_messages_decode_without_new_metadata() { + let request = TurnRequest { + turn_id: String::new(), + scheduled_job_id: None, + teammates: Vec::new(), + agent_id: "a".into(), + session_id: None, + messages: Vec::new(), + memberships: Vec::new(), + episode: None, + resumption: None, + }; + let encoded = serde_json::to_value(&request).unwrap(); + assert!(encoded.get("scheduled_job_id").is_none()); + assert!(encoded.get("teammates").is_none()); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + request + ); + let row = Message { + scheduled_job_id: None, + message_id: "m".into(), + sequence: 0, + sender: "a".into(), + destination: Destination::Agent("b".into()), + body: "hi".into(), + thread: None, + episode_id: None, + only_for: Vec::new(), + }; + let encoded = serde_json::to_value(&row).unwrap(); + assert!(encoded.get("scheduled_job_id").is_none()); + assert_eq!(serde_json::from_value::(encoded).unwrap(), row); +} + +#[tokio::test] +async fn bound_agent_sends_cannot_strip_scheduled_authority() { + for destination in [ + Destination::Agent("b".into()), + Destination::Hive("two".into()), + ] { + let c = setup().await; + let writer = c.clone(); + add(&c, "a", move |r| { + let c = writer.clone(); + let destination = destination.clone(); + Box::pin(async move { + let mut forwarded = message("forwarded", destination); + if matches!(forwarded.destination, Destination::Hive(_)) { + forwarded.starters = vec!["b".into()]; + } + c.send(forwarded).await?; + c.submit_action( + "a", + &r.episode.as_ref().unwrap().episode_id, + EpisodeAction::Complete { + body: "done".into(), + }, + ) + .await?; + Ok(done(&r)) + }) + }) + .await; + let consumer = c.clone(); + add(&c, "b", move |r| { + let c = consumer.clone(); + Box::pin(async move { + assert_eq!(r.scheduled_job_id.as_deref(), Some("cron")); + if let Some(episode) = &r.episode { + c.submit_action( + "b", + &episode.episode_id, + EpisodeAction::Complete { + body: "done".into(), + }, + ) + .await?; + } + Ok(done(&r)) + }) + }) + .await; + hive(&c, "one", &["a"]).await; + hive(&c, "two", &["a", "b"]).await; + c.send_scheduled_as_host("cron", message("work", Destination::Hive("one".into()))) + .await + .unwrap(); + c.run_until_idle().await.unwrap(); + assert_eq!( + c.read_transcript(None) + .unwrap() + .iter() + .find(|m| m.message_id == "forwarded") + .unwrap() + .scheduled_job_id + .as_deref(), + Some("cron") + ); + } +} + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn sqlite_reopen_retains_frozen_settings_and_scheduled_authority() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("hives.sqlite"); + let c = Coordinator::new( + "runtime".into(), + Arc::new(crate::SqliteStorage::open(&path).unwrap()), + CoordinatorOptions::default(), + ) + .await + .unwrap(); + for id in ["a", "b"] { + add(&c, id, |r| Box::pin(async move { Ok(done(&r)) })).await; + } + hive(&c, "one", &["a", "b"]).await; + let mut settings = HiveSettings::default(); + settings.roles.insert("b".into(), "reviewer".into()); + settings.options.conduct_policy.turn_wall = 2; + c.configure_hive("one", settings).await.unwrap(); + let mut input = message("work", Destination::Hive("one".into())); + input.starters = vec!["a".into()]; + let receipt = c + .send_scheduled_as_host("cron", input.clone()) + .await + .unwrap(); + c.advance().await.unwrap(); + drop(c); + let c = Coordinator::new( + "runtime".into(), + Arc::new(crate::SqliteStorage::open(&path).unwrap()), + CoordinatorOptions::default(), + ) + .await + .unwrap(); + assert_eq!( + c.hive_settings("one").unwrap().unwrap().roles["b"], + "reviewer" + ); + assert_eq!( + c.send_scheduled_as_host("cron", input).await.unwrap(), + receipt + ); + add(&c, "a", |r| { + Box::pin(async move { + assert_eq!(r.scheduled_job_id.as_deref(), Some("cron")); + assert!(r.episode.as_ref().unwrap().brief.contains("reviewer")); + assert_eq!(r.teammates[0].role.as_deref(), Some("reviewer")); + Ok(done(&r)) + }) + }) + .await; + assert_eq!(c.run_until_idle().await.unwrap().completed, 2); +} + +#[tokio::test] +async fn broadcast_uses_single_owner_fallback_within_the_configured_routing_ceiling() { + let c = Coordinator::new( + "runtime".into(), + Arc::new(MemoryStorage::new()), + CoordinatorOptions { + round_width: 3, + ..Default::default() + }, + ) + .await + .unwrap(); + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + for id in ["a", "b", "c"] { + let coordinator = c.clone(); + let seen = seen.clone(); + add(&c, id, move |r| { + let c = coordinator.clone(); + let seen = seen.clone(); + Box::pin(async move { + seen.lock().unwrap().push(r.agent_id.clone()); + let action = if r.agent_id == "a" && r.session_id.is_none() { + EpisodeAction::Broadcast { + body: "delegate this work".into(), + } + } else { + EpisodeAction::Complete { + body: "done".into(), + } + }; + c.submit_action(&r.agent_id, &r.episode.as_ref().unwrap().episode_id, action) + .await?; + Ok(done(&r)) + }) + }) + .await; + } + hive(&c, "one", &["a", "b", "c"]).await; + let mut settings = HiveSettings::default(); + settings.options.round_width = 2; + settings.routing.round_width = 1; + settings.routing.minimum_confidence = tinyhivemind_core::runtime::responder::Probability::ONE; + c.configure_hive("one", settings).await.unwrap(); + let mut input = message("work", Destination::Hive("one".into())); + input.starters = vec!["a".into()]; + c.send_as_host(input).await.unwrap(); + c.run_until_idle().await.unwrap(); + let seen = seen.lock().unwrap(); + assert!(seen.contains(&"b".into())); + assert!(!seen.contains(&"c".into())); + assert!(c.lock().unwrap().durable.episodes[0].finished); +} diff --git a/crates/tinyhivemind-hives/src/coordinator/types.rs b/crates/tinyhivemind-hives/src/coordinator/types.rs index f2123c4f..7b44f221 100644 --- a/crates/tinyhivemind-hives/src/coordinator/types.rs +++ b/crates/tinyhivemind-hives/src/coordinator/types.rs @@ -57,6 +57,15 @@ impl Default for CoordinatorOptions { /// One continuing agent turn with its captured authorization context. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct TurnRequest { + /// Unique durable reservation identity, renewed even when the same input is resumed. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub turn_id: String, + /// Durable scheduled authority, absent for interactive work. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheduled_job_id: Option, + /// Teammate roles captured from the episode's hive configuration. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub teammates: Vec, /// Bound agent identity. pub agent_id: String, /// Previously returned continuing session identity. @@ -129,6 +138,9 @@ pub enum Destination { /// Durable attributed message. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct Message { + /// Scheduled job that authorized this work, retained through delegation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheduled_job_id: Option, /// Caller retry identity, or a coordinator-generated event identity. pub message_id: String, /// Globally monotonic durable sequence. diff --git a/crates/tinyhivemind-hives/src/storage/README.md b/crates/tinyhivemind-hives/src/storage/README.md index 3261a0a2..76b26eb5 100644 --- a/crates/tinyhivemind-hives/src/storage/README.md +++ b/crates/tinyhivemind-hives/src/storage/README.md @@ -28,3 +28,9 @@ Storage is replaceable by the host. A newly loaded coordinator records running reservations as interrupted, and requires supplied handles to be reattached before unstarted jobs run. Durable session IDs remain bound across process runtimes. + +`StoredState::hive_settings` retains native policy and role metadata separately +from current memberships. Every episode freezes its settings and scheduled job +identity. Transcript rows persist scheduled authority on `Message`, while +running reservations retain it in `TurnRequest`; legacy rows default to no +explicit settings or scheduled origin. diff --git a/crates/tinyhivemind-hives/src/storage/mod.rs b/crates/tinyhivemind-hives/src/storage/mod.rs index 28d20a12..b6566b95 100644 --- a/crates/tinyhivemind-hives/src/storage/mod.rs +++ b/crates/tinyhivemind-hives/src/storage/mod.rs @@ -10,7 +10,7 @@ use crate::{Error, Result}; pub use sqlite::SqliteStorage; use std::{future::Future, pin::Pin, sync::Mutex}; pub use types::{ - AgentRecord, Delivery, DeliveryStatus, EpisodeRecord, RetentionPolicy, RunningTurn, + AgentRecord, Delivery, DeliveryStatus, EpisodeRecord, ParkedTurn, RetentionPolicy, RunningTurn, StoredState, TranscriptRow, }; diff --git a/crates/tinyhivemind-hives/src/storage/test.rs b/crates/tinyhivemind-hives/src/storage/test.rs index 30f286c5..49308531 100644 --- a/crates/tinyhivemind-hives/src/storage/test.rs +++ b/crates/tinyhivemind-hives/src/storage/test.rs @@ -6,6 +6,7 @@ use crate::{Destination, Message, SendMessage}; pub(super) fn row(sequence: u64, accepted: bool) -> TranscriptRow { let message = Message { + scheduled_job_id: None, message_id: format!("m{sequence}"), sequence, sender: "a".into(), @@ -142,6 +143,8 @@ fn retention_keeps_recent_settled_episodes_and_deliveries_only() { let mut state = StoredState::default(); for (index, finished) in [true, true, false, true].into_iter().enumerate() { state.episodes.push(EpisodeRecord { + settings: None, + scheduled_job_id: None, episode_id: format!("e{index}"), hive: crate::HiveInfo { hive_id: "h".into(), @@ -209,6 +212,8 @@ fn retention_keeps_a_settled_episode_a_running_turn_still_reports_to() { let mut state = StoredState::default(); for id in ["running", "idle"] { state.episodes.push(EpisodeRecord { + settings: None, + scheduled_job_id: None, episode_id: id.into(), hive: crate::HiveInfo { hive_id: id.into(), @@ -231,6 +236,9 @@ fn retention_keeps_a_settled_episode_a_running_turn_still_reports_to() { "a".into(), RunningTurn { request: crate::TurnRequest { + turn_id: String::new(), + scheduled_job_id: None, + teammates: Vec::new(), agent_id: "a".into(), session_id: None, messages: Vec::new(), @@ -258,3 +266,13 @@ fn retention_keeps_a_settled_episode_a_running_turn_still_reports_to() { assert_eq!(state.episodes.len(), 1); assert_eq!(state.episodes[0].episode_id, "running"); } + +#[test] +fn retention_policy_wire_form_and_omitted_defaults_are_stable() { + let value: crate::RetentionPolicy = serde_json::from_str("{}").unwrap(); + assert_eq!(value, crate::RetentionPolicy::default()); + assert_eq!( + serde_json::to_value(value).unwrap(), + serde_json::json!({"settled_episodes":null,"delivered":null,"interrupted":null,"pending_per_agent":null}) + ); +} diff --git a/crates/tinyhivemind-hives/src/storage/types.rs b/crates/tinyhivemind-hives/src/storage/types.rs index c789c138..ab006a12 100644 --- a/crates/tinyhivemind-hives/src/storage/types.rs +++ b/crates/tinyhivemind-hives/src/storage/types.rs @@ -13,6 +13,9 @@ use tinyhivemind_core::driver::{ConductorState, Turn}; /// reassembles both with [`StoredState::append`]. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub struct StoredState { + /// Explicit per-hive policy and retained membership roles. + #[serde(default)] + pub hive_settings: BTreeMap, /// CAS revision; each commit advances exactly one. pub revision: u64, /// Writer epoch: claimed by exactly one live Coordinator at a time. A @@ -69,6 +72,7 @@ impl StoredState { pub(crate) fn without_transcript(&self) -> Self { Self { revision: self.revision, + hive_settings: self.hive_settings.clone(), writer_epoch: self.writer_epoch, next_sequence: self.next_sequence, hives: self.hives.clone(), @@ -206,10 +210,27 @@ pub struct AgentRecord { pub session_id: Option, /// Agent waits for explicit host release. pub parked: bool, + /// Exact completed turn awaiting release; older snapshots have no correlated identity. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parked_turn: Option, /// Release note awaiting the agent's next claimed turn. #[serde(default, skip_serializing_if = "Option::is_none")] pub resumption: Option, } +/// Authenticated identity of a completed parked turn. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ParkedTurn { + /// Reservation identity distinguishing resumed attempts of the same input. + pub turn_id: String, + /// Continuing native session which returned the parked outcome. + pub session_id: String, + /// Captured episode, absent for a direct delivery. + pub episode_id: Option, + /// Accepted input message identities, in delivery order. + pub message_ids: Vec, + /// Trusted automation provenance of the parked turn. + pub scheduled_job_id: Option, +} /// One direct-agent inbox entry. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct Delivery { @@ -235,6 +256,12 @@ pub enum DeliveryStatus { /// One conducted hive episode, frozen membership and wave included. #[derive(Clone, Debug, Deserialize, Serialize)] pub struct EpisodeRecord { + /// Configuration frozen at acceptance; absent on legacy/unconfigured work. + #[serde(default)] + pub settings: Option, + /// Scheduled authority inherited by every descendant assignment. + #[serde(default)] + pub scheduled_job_id: Option, /// Durable episode identity. pub episode_id: String, /// Membership snapshot for the core driver. diff --git a/crates/tinyhivemind-openhuman/Cargo.toml b/crates/tinyhivemind-openhuman/Cargo.toml index 0619717a..8f376dd6 100644 --- a/crates/tinyhivemind-openhuman/Cargo.toml +++ b/crates/tinyhivemind-openhuman/Cargo.toml @@ -21,7 +21,7 @@ offline = ["dep:wiremock"] [dependencies] # Supplied handles and their continuing turn API. -openhuman-embed.workspace = true +openhuman-embed = { workspace = true, features = ["mcp", "skills", "storage-sqlite"] } # Host usage and policy types remain OpenHuman's own. openhuman.workspace = true # Native tool trait and results used by permanent attachments. @@ -33,6 +33,10 @@ anyhow.workspace = true # Render schemas and attributed input. serde_json.workspace = true serde.workspace = true +# Canonical typed profile metadata in Markdown frontmatter. +serde_yaml_ng.workspace = true +# Native schedule validation before constructing a deployment. +chrono.workspace = true thiserror.workspace = true # Turn timeout and progress channels. tokio = { workspace = true, features = ["rt", "sync", "time"] } @@ -43,7 +47,8 @@ tinyhivemind-lang.workspace = true # Durable messaging and cross-hive scheduling. tinyhivemind-hives.workspace = true # The engine contract and agent memory lifecycle behind hive `Recall`/`Remember`. -tinymemory-api.workspace = true +# The deterministic in-process memory selector uses the contract reference engine. +tinymemory-api = { workspace = true, features = ["conformance"] } tinymemory-tools.workspace = true # Deterministic standalone host fixtures. wiremock = { workspace = true, optional = true } @@ -52,7 +57,7 @@ wiremock = { workspace = true, optional = true } tempfile.workspace = true # The routing surfaces, for a test that builds its own hive. -tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time", "test-util"] } wiremock.workspace = true # The in-memory reference engine, for offline `Recall`/`Remember` tests. tinymemory-api = { workspace = true, features = ["conformance"] } diff --git a/crates/tinyhivemind-openhuman/README.md b/crates/tinyhivemind-openhuman/README.md index a8c16e2f..85027327 100644 --- a/crates/tinyhivemind-openhuman/README.md +++ b/crates/tinyhivemind-openhuman/README.md @@ -96,5 +96,8 @@ Runnable host construction and topology proofs are in See the [OpenCompany migration guide](../../docs/opencompany-migration.md) for construction, single-runtime dependency unification, and recovery. +The [configuration module](src/config/README.md) parses reusable manifests and +Markdown profiles before runtime construction. + For a model-backed package registration check, see the [live language example](examples/README.md). diff --git a/crates/tinyhivemind-openhuman/examples/live_language/test.rs b/crates/tinyhivemind-openhuman/examples/live_language/test.rs index 06adaf13..133488ea 100644 --- a/crates/tinyhivemind-openhuman/examples/live_language/test.rs +++ b/crates/tinyhivemind-openhuman/examples/live_language/test.rs @@ -15,6 +15,7 @@ fn message(sequence: u64, sender: &str, episode: &str, body: &str) -> Message { body: body.into(), thread: None, episode_id: Some(episode.into()), + scheduled_job_id: None, only_for: vec![sender.into()], } } diff --git a/crates/tinyhivemind-openhuman/src/README.md b/crates/tinyhivemind-openhuman/src/README.md index 90d8ca70..f91bf722 100644 --- a/crates/tinyhivemind-openhuman/src/README.md +++ b/crates/tinyhivemind-openhuman/src/README.md @@ -2,6 +2,8 @@ | Path | Responsibility | | --- | --- | +| `deploy/` | One-runtime manifest assembly, captured permissions, approvals and workflows | +| `config/` | Typed manifests, relative Markdown loading and permission validation | | `host/` | Supplied handle registration, continuing sessions, host hooks and management | | `tools/` | Stable native specifications, argument validation and bound execution | | `memory/` | Hive-shared OpenHuman memory: the hive root, per-seat memory agent ids, seat binding, and core `Recall`/`Remember` | @@ -11,6 +13,6 @@ | `error.rs` | Typed adapter errors | | `lib.rs` | Public exports and registration example | -Agent construction, MCP connections, skills, the memory engine and original -prompts belong to the host; `memory/` only decides which root and memory agent +The host supplies runtime construction ports; `config/` provides the typed +deployment input for MCP, skills, memory and prompts; `memory/` only decides which root and memory agent id each seat is bound to. Durable scheduling and episode state belong to `tinyhivemind-hives`. diff --git a/crates/tinyhivemind-openhuman/src/config/README.md b/crates/tinyhivemind-openhuman/src/config/README.md new file mode 100644 index 00000000..1bdecd35 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/config/README.md @@ -0,0 +1,45 @@ +# Manifest configuration + +`HiveConfig::from_json` and `from_value` parse canonical JSON without opening +files or resolving credentials. `load_dir` explicitly reads `hive.json`, +`profiles/*.md` and `context/*.md` relative to its supplied root. YAML profile +frontmatter configures the typed profile; its Markdown body supplies the prompt. +No parser error includes input contents. + +Call `validate` before constructing runtime objects. `ValidatedHiveConfig` +exposes immutable data through `config()`; consuming it with `into_config()` +returns editable data without retaining validation proof. See the +[accepted contract](../../../../docs/specs/openhuman-hive-config.md). + +| File | Responsibility | +| --- | --- | +| `mod.rs` | Public configuration exports | +| `types.rs` | Canonical serde fields and immutable validated wrapper | +| `parse.rs` | JSON, relative Markdown/YAML loading, inline-secret refusal | +| `validate.rs` | References, identities, limits, narrowing and MCP consistency | +| `test/` | Wire, loader and validation regression tests | + +Named `SecretRef` values encode as `{"env":"NAME"}` or `{"store":"KEY"}`. +MCP environment and header values require references. Seat overrides have no +provider credential field. Runtime-only ports and resolved secrets never enter +the manifest. + +Permission layers compose as an intersection; omitted optional allowlists and +approval/rule fields inherit. Explicit allowlists are subsets. Denylists must +retain parent denials. Conservative rule equivalence requires identical explicit +argument-sensitive rules and approval policies, except explicit disabling of +approval authority. Deployment must enforce every layer, including inherited +ones. Runtime, profile and seat turn limits can only decrease. + +`normalized_mcp` combines runtime/profile/seat declarations, sorts tool names, +and rejects conflicting named connections. Membership MCP declarations assert +the complete same set; use two seats to change connections. + +Adapter hive defaults use episode `round_width=1`, `revealed_width=1`. Each +width is independently bounded by runtime concurrency; blind rounds may be +wider than revealed rounds. Core defaults remain unchanged. Other policies +retain core and coordinator defaults. + +Directory loading ignores conventional `README.md` files, case-insensitively, +in both `profiles/` and `context/`. These document the directories without +creating an extra profile or context entry. Other Markdown names load normally. diff --git a/crates/tinyhivemind-openhuman/src/config/mod.rs b/crates/tinyhivemind-openhuman/src/config/mod.rs new file mode 100644 index 00000000..2ae67627 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/config/mod.rs @@ -0,0 +1,11 @@ +//! Typed manifests, relative Markdown loading and validation without runtime construction. +mod parse; +mod types; +mod validate; +pub use crate::error::ConfigError; +pub use parse::load_dir; +pub use types::*; +/// Manifest parsing and validation result. +pub type Result = std::result::Result; +#[cfg(test)] +mod test; diff --git a/crates/tinyhivemind-openhuman/src/config/parse.rs b/crates/tinyhivemind-openhuman/src/config/parse.rs new file mode 100644 index 00000000..b1238af4 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/config/parse.rs @@ -0,0 +1,246 @@ +//! Side-effect-free canonical parsing and explicit relative directory loading. +use super::{ConfigError, HiveConfig, Profile, Result}; +use serde_json::Value; +use std::{fs, path::Path}; + +impl HiveConfig { + /// Parse canonical JSON without resolving credentials or opening files. + /// + /// # Errors + /// Returns `Json` for malformed/unknown fields and `InlineSecret` for literals. + pub fn from_json(input: &str) -> Result { + let value = serde_json::from_str(input).map_err(|_| ConfigError::Json)?; + Self::from_value(value) + } + /// Parse a canonical value without runtime construction. + /// + /// # Errors + /// Returns typed structure or inline credential failures. + pub fn from_value(value: Value) -> Result { + reject_inline(&value, "config")?; + let original = value.clone(); + let config: Self = serde_json::from_value(value).map_err(|_| ConfigError::Json)?; + let canonical = serde_json::to_value(&config).map_err(|_| ConfigError::Json)?; + reject_unknown(&original, &canonical)?; + Ok(config) + } + /// Read `hive.json`, Markdown profiles and contexts beneath one root. + /// Conventional `README.md` files are directory documentation, matched + /// case-insensitively, and are not loaded as profiles or context. + /// + /// # Errors + /// Returns typed IO, JSON, frontmatter or duplicate declaration failures. + pub fn load_dir(root: impl AsRef) -> Result { + let root = root.as_ref(); + let mut config = Self::from_json(&read(&root.join("hive.json"))?)?; + for path in markdown_files(&root.join("profiles"))? { + let text = read(&path)?; + let (meta, body) = frontmatter(&text, &path)?; + let mut profile: Profile = if let Some(meta) = meta { + let value: Value = serde_yaml_ng::from_str(meta) + .map_err(|_| ConfigError::Frontmatter(path.display().to_string()))?; + reject_inline(&value, "profile")?; + let profile: Profile = serde_json::from_value(value.clone()) + .map_err(|_| ConfigError::Frontmatter(path.display().to_string()))?; + let canonical = serde_json::to_value(&profile).map_err(|_| ConfigError::Json)?; + reject_unknown(&value, &canonical) + .map_err(|_| ConfigError::Frontmatter(path.display().to_string()))?; + profile + } else { + Profile::default() + }; + if profile.id.is_empty() { + profile.id = stem(&path)?; + } + body.clone_into(&mut profile.system_prompt); + if config.profiles.iter().any(|p| p.id == profile.id) { + return Err(ConfigError::DuplicateId { + section: "profile".into(), + id: profile.id, + }); + } + config.profiles.push(profile); + } + for path in markdown_files(&root.join("context"))? { + let text = read(&path)?; + let (meta, body) = frontmatter(&text, &path)?; + if let Some(meta) = meta { + serde_yaml_ng::from_str::(meta) + .map_err(|_| ConfigError::Frontmatter(path.display().to_string()))?; + } + let id = stem(&path)?; + if config + .contexts + .insert(id.clone(), body.to_owned()) + .is_some() + { + return Err(ConfigError::DuplicateId { + section: "context".into(), + id, + }); + } + } + Ok(config) + } +} +/// Load a manifest directory; delegates to `HiveConfig::load_dir`. +/// +/// # Errors +/// Returns the same typed parse and filesystem errors as the associated method. +pub fn load_dir(root: impl AsRef) -> Result { + HiveConfig::load_dir(root) +} +fn read(path: &Path) -> Result { + fs::read_to_string(path).map_err(|source| ConfigError::Io { + path: path.display().to_string(), + source, + }) +} +fn stem(path: &Path) -> Result { + path.file_stem() + .and_then(|s| s.to_str()) + .map(str::to_owned) + .ok_or_else(|| ConfigError::InvalidId("markdown filename".into())) +} +fn markdown_files(dir: &Path) -> Result> { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), + Err(source) => { + return Err(ConfigError::Io { + path: dir.display().to_string(), + source, + }); + } + }; + let mut paths = Vec::new(); + for entry in entries { + let entry = entry.map_err(|source| ConfigError::Io { + path: dir.display().to_string(), + source, + })?; + let path = entry.path(); + let is_readme = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("README.md")); + if !is_readme && path.extension().is_some_and(|s| s == "md") { + paths.push(path); + } + } + paths.sort(); + Ok(paths) +} +fn frontmatter<'a>(text: &'a str, path: &Path) -> Result<(Option<&'a str>, &'a str)> { + let Some(first) = text.lines().next() else { + return Ok((None, text)); + }; + if first != "---" { + return Ok((None, text)); + } + let start = text + .find('\n') + .map(|p| p + 1) + .ok_or_else(|| ConfigError::Frontmatter(path.display().to_string()))?; + let mut offset = start; + for line in text[start..].split_inclusive('\n') { + if line.trim_end_matches(['\r', '\n']) == "---" { + return Ok((Some(&text[start..offset]), &text[offset + line.len()..])); + } + offset += line.len(); + } + Err(ConfigError::Frontmatter(path.display().to_string())) +} +pub(super) fn reject_inline(value: &Value, field: &str) -> Result<()> { + match value { + Value::Object(map) => { + for (key, child) in map { + let next = format!("{field}.{key}"); + if key == "endpoint" && child.as_str().is_some_and(endpoint_has_secret) { + return Err(ConfigError::InlineSecret { field: next }); + } + if matches!( + key.as_str(), + "credential" | "api_key" | "password" | "token" | "secret" + ) && !child.is_null() + { + let safe = child.as_object().is_some_and(|m| { + m.len() == 1 + && m.iter().all(|(k, v)| { + matches!(k.as_str(), "env" | "store") + && v.as_str().is_some_and(|s| !s.trim().is_empty()) + }) + }); + if !safe { + return Err(ConfigError::InlineSecret { field: next }); + } + } + if matches!(key.as_str(), "env" | "headers") && child.is_object() { + for item in child.as_object().into_iter().flat_map(|m| m.values()) { + let safe = item.as_object().is_some_and(|m| { + m.len() == 1 + && m.iter().all(|(k, v)| { + matches!(k.as_str(), "env" | "store") + && v.as_str().is_some_and(|s| !s.trim().is_empty()) + }) + }); + if !safe { + return Err(ConfigError::InlineSecret { field: next }); + } + } + } else { + reject_inline(child, &next)?; + } + } + } + Value::Array(items) => { + for child in items { + reject_inline(child, field)?; + } + } + _ => {} + } + Ok(()) +} + +fn endpoint_has_secret(endpoint: &str) -> bool { + let authority = endpoint + .split_once("://") + .map(|(_, tail)| tail.split('/').next().unwrap_or(tail)); + authority.is_some_and(|a| a.contains('@')) + || endpoint.split_once('?').is_some_and(|(_, query)| { + query.split('&').any(|item| { + item.split_once('=').is_some_and(|(key, _)| { + matches!( + key.to_ascii_lowercase().as_str(), + "token" | "api_key" | "apikey" | "password" | "secret" | "access_token" + ) + }) + }) + }) +} + +// Existing native policies intentionally accept unknown fields outside manifests. +// Compare the raw structure to its fully typed wire form at this boundary. +fn reject_unknown(raw: &Value, canonical: &Value) -> Result<()> { + match (raw, canonical) { + (Value::Object(raw), Value::Object(typed)) => { + for (key, value) in raw { + let decoded = typed.get(key).ok_or(ConfigError::Json)?; + // Native ToolRules already denies unknown fields, but omits + // optional nulls and empty predicates from its serialized form. + if key == "tool_rules" { + continue; + } + reject_unknown(value, decoded)?; + } + } + (Value::Array(raw), Value::Array(typed)) => { + for (value, decoded) in raw.iter().zip(typed) { + reject_unknown(value, decoded)?; + } + } + _ => {} + } + Ok(()) +} diff --git a/crates/tinyhivemind-openhuman/src/config/test/README.md b/crates/tinyhivemind-openhuman/src/config/test/README.md new file mode 100644 index 00000000..f38ac965 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/config/test/README.md @@ -0,0 +1,7 @@ +# Manifest regression tests + +| File | Coverage | +| --- | --- | +| `mod.rs` | Canonical default manifest and native policy wire forms | +| `validation.rs` | Typed errors, permissions, limits and immutable MCP | +| `directory.rs` | Relative loading, frontmatter and credential reference wire | diff --git a/crates/tinyhivemind-openhuman/src/config/test/directory.rs b/crates/tinyhivemind-openhuman/src/config/test/directory.rs new file mode 100644 index 00000000..9a59300e --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/config/test/directory.rs @@ -0,0 +1,225 @@ +//! Relative Markdown/frontmatter loading and safe credential references. +use super::*; +use std::fs; +#[test] +fn directory_loads_body_context_and_yaml_with_relative_paths() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + fs::create_dir(dir.path().join("profiles"))?; + fs::create_dir(dir.path().join("context"))?; + fs::write(dir.path().join("hive.json"), "{}")?; + fs::write( + dir.path().join("profiles/reader.md"), + "---\ncontext: [task]\nmodel:\n temperature: 0\n---\nReview carefully.\n---\nOrdinary separator.", + )?; + fs::write( + dir.path().join("context/task.md"), + "---\ntitle: Task\n---\nTask details.", + )?; + let c = HiveConfig::load_dir(dir.path())?; + assert_eq!(c.profiles[0].id, "reader"); + assert!(c.profiles[0].system_prompt.contains("\n---\n")); + assert_eq!(c.contexts["task"], "Task details."); + c.validate()?; + Ok(()) +} +#[test] +fn missing_files_malformed_frontmatter_and_duplicate_profiles_are_typed() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + assert!(matches!( + HiveConfig::load_dir(dir.path()), + Err(ConfigError::Io { .. }) + )); + fs::write(dir.path().join("hive.json"), "{}")?; + fs::create_dir(dir.path().join("profiles"))?; + for body in ["---\nid: a", "---\ninvalid: [\n---\nbody"] { + fs::write(dir.path().join("profiles/a.md"), body)?; + assert!(matches!( + HiveConfig::load_dir(dir.path()), + Err(ConfigError::Frontmatter(_)) + )); + } + fs::write(dir.path().join("profiles/a.md"), "body")?; + fs::write(dir.path().join("hive.json"), r#"{"profiles":[{"id":"a"}]}"#)?; + assert!(matches!( + HiveConfig::load_dir(dir.path()), + Err(ConfigError::DuplicateId { .. }) + )); + Ok(()) +} +#[test] +fn credentials_have_exact_reference_wire_forms() -> anyhow::Result<()> { + assert_eq!( + serde_json::to_value(SecretRef::Env("KEY".into()))?, + serde_json::json!({"env":"KEY"}) + ); + assert_eq!( + serde_json::to_value(SecretRef::Store("key".into()))?, + serde_json::json!({"store":"key"}) + ); + HiveConfig::from_json( + r#"{"runtime":{"provider":{"credential":{"env":"KEY"}},"mcp":[{"id":"a","transport":"http","endpoint":"http://localhost","headers":{"Authorization":{"store":"token"}}}]}}"#, + )?; + assert!(matches!( + HiveConfig::from_json(r#"{"runtime":{"mcp":[{"env":{"SECRET":"literal"}}]}}"#), + Err(ConfigError::InlineSecret { .. }) + )); + assert!(matches!( + HiveConfig::from_json(r#"{"seats":[{"overrides":{"api_key":"literal"}}]}"#), + Err(ConfigError::InlineSecret { .. }) + )); + Ok(()) +} + +#[test] +fn provider_urls_cannot_smuggle_literal_credentials() { + for endpoint in [ + "https://user:password@example.test/v1", + "https://example.test?api_key=literal", + ] { + let value = serde_json::json!({"runtime":{"provider":{"endpoint":endpoint}}}); + assert!(matches!( + HiveConfig::from_value(value), + Err(ConfigError::InlineSecret { .. }) + )); + } +} + +#[test] +fn native_policy_unknown_fields_are_rejected_only_at_manifest_boundary() -> anyhow::Result<()> { + for policy in ["episode", "routing", "conduct", "division", "coordinator"] { + let mut value = serde_json::to_value(minimal())?; + value["hives"][0][policy]["unknown_setting"] = serde_json::json!(true); + assert!(matches!( + HiveConfig::from_value(value), + Err(ConfigError::Json) + )); + } + Ok(()) +} +#[test] +fn empty_secret_reference_and_mutated_credential_url_are_rejected() { + assert!(matches!( + HiveConfig::from_json(r#"{"runtime":{"provider":{"credential":{"env":""}}}}"#), + Err(ConfigError::InlineSecret { .. }) + )); + let mut c = minimal(); + c.runtime.provider.endpoint = Some("https://a:b@example.test".into()); + assert!(matches!( + c.validate(), + Err(ConfigError::InlineSecret { .. }) + )); +} + +#[test] +fn unknown_tool_rules_key_and_approval_fields_cannot_bypass_strict_parsing() -> anyhow::Result<()> { + let base = serde_json::to_value(minimal())?; + for policy in ["episode", "routing"] { + let mut value = base.clone(); + value["hives"][0][policy]["tool_rules"] = serde_json::json!({}); + assert!(matches!( + HiveConfig::from_value(value), + Err(ConfigError::Json) + )); + } + let approval = serde_json::json!({"enabled":true,"default":"deny","rules":[],"approver":{"kind":"absent"},"allow_grants":false,"max_grant_ttl":null}); + let mut value = base.clone(); + value["permission_profiles"] = serde_json::json!([{"id":"p","approval":approval.clone()}]); + value["permission_profiles"][0]["approval"]["unknown"] = serde_json::json!(true); + assert!(matches!( + HiveConfig::from_value(value), + Err(ConfigError::Json) + )); + let mut value = base; + value["permission_profiles"] = serde_json::json!([{"id":"p","approval":approval}]); + value["permission_profiles"][0]["approval"]["rules"] = serde_json::json!([{"effect":null,"verb":null,"target":null,"verdict":"deny","tool_rules":{}}]); + assert!(matches!( + HiveConfig::from_value(value), + Err(ConfigError::Json) + )); + Ok(()) +} + +#[test] +fn directory_readmes_do_not_become_profiles_or_context() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + fs::create_dir(dir.path().join("profiles"))?; + fs::create_dir(dir.path().join("context"))?; + fs::write(dir.path().join("hive.json"), "{}")?; + for name in ["README.md", "readme.md", "ReadMe.md"] { + fs::write(dir.path().join("profiles").join(name), "---\ninvalid: [")?; + fs::write(dir.path().join("context").join(name), "---\ninvalid: [")?; + } + fs::write(dir.path().join("profiles/reader.md"), "Read carefully.")?; + fs::write( + dir.path().join("context/readme-example.md"), + "Task details.", + )?; + let config = crate::config::load_dir(dir.path())?; + assert_eq!(config.profiles.len(), 1); + assert_eq!(config.profiles[0].id, "reader"); + assert_eq!(config.contexts.len(), 1); + assert_eq!(config.contexts["readme-example"], "Task details."); + Ok(()) +} + +#[test] +fn directory_rejects_typed_frontmatter_and_context_collisions() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + fs::write(dir.path().join("hive.json"), "{}")?; + fs::create_dir(dir.path().join("profiles"))?; + for body in [ + "---", + "---\niterations: 1\n---\nbody", + "---\nid: []\n---\nbody", + ] { + fs::write(dir.path().join("profiles/a.md"), body)?; + assert!(matches!( + crate::config::load_dir(dir.path()), + Err(ConfigError::Frontmatter(_)) + )); + } + fs::write( + dir.path().join("profiles/a.md"), + "---\napi_key: literal\n---\nbody", + )?; + assert!(matches!( + crate::config::load_dir(dir.path()), + Err(ConfigError::InlineSecret { .. }) + )); + fs::write(dir.path().join("profiles/a.md"), "")?; + fs::create_dir(dir.path().join("context"))?; + fs::write( + dir.path().join("context/task.md"), + "---\ninvalid: [\n---\nbody", + )?; + assert!(matches!( + crate::config::load_dir(dir.path()), + Err(ConfigError::Frontmatter(_)) + )); + fs::write(dir.path().join("context/task.md"), "body")?; + fs::write( + dir.path().join("hive.json"), + r#"{"contexts":{"task":"inline"}}"#, + )?; + assert!( + matches!(crate::config::load_dir(dir.path()), Err(ConfigError::DuplicateId {section, ..}) if section == "context") + ); + fs::remove_file(dir.path().join("profiles/a.md"))?; + fs::remove_dir(dir.path().join("profiles"))?; + fs::write(dir.path().join("profiles"), "a file is not a directory")?; + assert!(matches!( + crate::config::load_dir(dir.path()), + Err(ConfigError::Io { .. }) + )); + Ok(()) +} + +#[test] +fn workflow_defaults_and_validation_view_preserve_canonical_settings() -> anyhow::Result<()> { + let config = HiveConfig::from_json(r#"{"profiles":[{"id":"worker"}],"seats":[{"id":"reader","profile":"worker"}],"workflows":[{"id":"daily","schedule":"0 0 * * *","target":{"seat":"reader"},"prompt":"Review"}]}"#)?.validate()?; + let workflow = &config.config().workflows[0]; + assert!(workflow.enabled); + assert!(workflow.single_flight); + assert_eq!(workflow.retries, 0); + Ok(()) +} diff --git a/crates/tinyhivemind-openhuman/src/config/test/fixtures/README.md b/crates/tinyhivemind-openhuman/src/config/test/fixtures/README.md new file mode 100644 index 00000000..662d3888 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/config/test/fixtures/README.md @@ -0,0 +1,8 @@ +# Wire fixture + +`full.json` exercises runtime, profiles, permissions, seats, hive membership, +workflow, context, memory and reference-only credentials. Tests round-trip the +fully defaulted canonical value and validate it without network access. + +The neighboring `mod.rs` embeds this committed fixture with +`include_str!("fixtures/full.json")`. diff --git a/crates/tinyhivemind-openhuman/src/config/test/fixtures/full.json b/crates/tinyhivemind-openhuman/src/config/test/fixtures/full.json new file mode 100644 index 00000000..157a96eb --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/config/test/fixtures/full.json @@ -0,0 +1,16 @@ +{ + "runtime": { + "provider": { "kind": "openai", "endpoint": "http://localhost:8000/v1", "credential": { "env": "MODEL_KEY" } }, + "model": { "name": "fixture", "temperature": 0, "max_tokens": 512 }, + "session_store": "memory", "memory_engine": "disabled", "autonomy": "supervised", "concurrency": 4, + "limits": { "timeout_ms": 10000, "iterations": 8, "budget_usd": 1 }, + "mcp": [{ "id": "docs", "transport": "http", "endpoint": "http://localhost:8001", "tools": ["Read"], "headers": { "Authorization": { "store": "docs-key" } } }], + "skills": [], "include_user_skills": false, "max_agents": 2 + }, + "permission_profiles": [{ "id": "reader", "access": "read_only", "sandbox": "read_only", "allow_tools": ["Read"], "deny_tools": ["Write"], "send_operations": ["send"] }], + "profiles": [{ "id": "worker", "system_prompt": "Review the task", "context": ["task"], "permission_profile": "reader", "tool_scope": { "named": ["Read"] }, "memory": { "root": "seat:alice" }, "limits": { "iterations": 4 } }], + "seats": [{ "id": "alice", "profile": "worker", "overrides": { "model": { "temperature": 0 }, "limits": { "iterations": 2 } } }], + "hives": [{ "id": "desk", "name": "Review desk", "memory_root": "hive:desk", "members": [{ "seat": "alice", "role": "reviewer", "context": ["task"] }] }], + "workflows": [{ "id": "daily", "schedule": "0 8 * * *", "target": { "hive": "desk" }, "prompt": "Review the task", "enabled": true, "retries": 0, "single_flight": true }], + "contexts": { "task": "A deterministic fixture task" } +} diff --git a/crates/tinyhivemind-openhuman/src/config/test/mod.rs b/crates/tinyhivemind-openhuman/src/config/test/mod.rs new file mode 100644 index 00000000..9d1eb509 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/config/test/mod.rs @@ -0,0 +1,101 @@ +//! Manifest wire and validation regressions. +use super::*; + +fn minimal() -> HiveConfig { + HiveConfig::from_json( + r#"{"profiles":[{"id":"worker"}],"seats":[{"id":"alice","profile":"worker"}],"hives":[{"id":"desk","members":[{"seat":"alice","role":"reviewer"}]}]}"#, + ) + .unwrap_or_else(|error| unreachable!("valid fixture: {error}")) +} + +#[test] +fn canonical_manifest_round_trips_and_validates() -> anyhow::Result<()> { + let config = minimal(); + let encoded = serde_json::to_value(&config)?; + assert_eq!(encoded["runtime"]["concurrency"], 4); + assert_eq!(encoded["hives"][0]["members"][0]["role"], "reviewer"); + HiveConfig::from_value(encoded)?.validate()?; + Ok(()) +} + +#[test] +fn rejects_inline_provider_secrets_without_echoing_them() { + let error = HiveConfig::from_json(r#"{"runtime":{"provider":{"credential":"private-value"}}}"#) + .err() + .unwrap_or_else(|| unreachable!("literal credentials must be rejected")); + assert!(matches!(error, ConfigError::InlineSecret { .. })); + assert!(!format!("{error:?}").contains("private-value")); +} + +#[test] +fn resolves_profile_and_seat_references_before_permissions() { + let mut config = minimal(); + config.seats[0].profile = "missing".into(); + assert!(matches!( + config.validate(), + Err(ConfigError::UnknownProfile(_)) + )); +} + +#[test] +fn denies_empty_roles_and_width_above_runtime_cap() { + let mut config = minimal(); + config.hives[0].members[0].role.clear(); + assert!(matches!( + config.validate(), + Err(ConfigError::InvalidRole(_)) + )); + let mut config = minimal(); + config.runtime.concurrency = 1; + config.hives[0].coordinator.round_width = 2; + assert!(matches!( + config.validate(), + Err(ConfigError::InvalidWidth(_)) + )); +} + +#[test] +fn policy_serde_preserves_defaults() -> anyhow::Result<()> { + use tinyhivemind_core::{driver::ConductPolicy, hive::DivisionPolicy}; + let conduct: ConductPolicy = serde_json::from_str("{}")?; + assert_eq!( + serde_json::to_value(conduct)?, + serde_json::json!({"child_turn_wall":6,"turn_wall":60}) + ); + let division: DivisionPolicy = serde_json::from_str("{}")?; + assert_eq!(division, DivisionPolicy::default()); + let coordinator: tinyhivemind_hives::CoordinatorOptions = serde_json::from_str("{}")?; + assert_eq!(coordinator.round_width, 1); + let retention: tinyhivemind_hives::RetentionPolicy = serde_json::from_str("{}")?; + assert_eq!(retention, tinyhivemind_hives::RetentionPolicy::default()); + Ok(()) +} +mod directory; +mod validation; + +#[test] +fn full_fixture_pins_every_configuration_section_and_round_trips() -> anyhow::Result<()> { + let config = HiveConfig::from_json(include_str!("fixtures/full.json"))?; + config.clone().validate()?; + let first = serde_json::to_value(config)?; + let second = serde_json::to_value(HiveConfig::from_value(first.clone())?)?; + assert_eq!(first, second); + assert_eq!( + first["runtime"]["provider"]["credential"], + serde_json::json!({"env":"MODEL_KEY"}) + ); + assert_eq!( + first["runtime"]["mcp"][0]["headers"]["Authorization"], + serde_json::json!({"store":"docs-key"}) + ); + assert_eq!( + first["profiles"][0]["tool_scope"], + serde_json::json!({"named":["Read"]}) + ); + assert_eq!( + first["workflows"][0]["target"], + serde_json::json!({"hive":"desk"}) + ); + assert_eq!(first["hives"][0]["episode"]["round_width"], 1); + Ok(()) +} diff --git a/crates/tinyhivemind-openhuman/src/config/test/validation.rs b/crates/tinyhivemind-openhuman/src/config/test/validation.rs new file mode 100644 index 00000000..a27e4d23 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/config/test/validation.rs @@ -0,0 +1,335 @@ +//! Every typed configuration failure and conservative authority dimension. +use super::*; +#[test] +fn malformed_json_and_unknown_fields_are_typed() { + for input in ["{", r#"{"unrecognized":true}"#] { + assert!(matches!( + HiveConfig::from_json(input), + Err(ConfigError::Json) + )); + } +} +#[test] +fn duplicate_ids_are_rejected_in_each_namespace() { + let mut c = minimal(); + c.profiles.push(c.profiles[0].clone()); + assert!(matches!(c.validate(), Err(ConfigError::DuplicateId { .. }))); + let mut c = minimal(); + c.permission_profiles = vec![ + PermissionProfile { + id: "p".into(), + ..Default::default() + }; + 2 + ]; + assert!(matches!(c.validate(), Err(ConfigError::DuplicateId { .. }))); + let mut c = minimal(); + c.seats.push(c.seats[0].clone()); + assert!(matches!(c.validate(), Err(ConfigError::DuplicateId { .. }))); + let mut c = minimal(); + c.hives.push(c.hives[0].clone()); + assert!(matches!(c.validate(), Err(ConfigError::DuplicateId { .. }))); + let mut c = minimal(); + let repeated = c.hives[0].members[0].clone(); + c.hives[0].members.push(repeated); + assert!(matches!(c.validate(), Err(ConfigError::DuplicateId { .. }))); + let mut c = minimal(); + c.workflows = vec![job(); 2]; + assert!(matches!(c.validate(), Err(ConfigError::DuplicateId { .. }))); +} +fn job() -> Workflow { + Workflow { + id: "daily".into(), + schedule: "0 8 * * *".into(), + target: WorkflowTarget::Seat("alice".into()), + prompt: "review".into(), + enabled: true, + retries: 0, + single_flight: true, + } +} +#[test] +fn missing_references_are_typed() { + let mut c = minimal(); + c.profiles[0].permission_profile = Some("missing".into()); + assert!(matches!( + c.validate(), + Err(ConfigError::UnknownPermissionProfile(_)) + )); + let mut c = minimal(); + c.profiles[0].subagents.push("missing".into()); + assert!(matches!(c.validate(), Err(ConfigError::UnknownProfile(_)))); + let mut c = minimal(); + c.hives[0].members[0].seat = "missing".into(); + assert!(matches!(c.validate(), Err(ConfigError::UnknownSeat(_)))); + let mut c = minimal(); + c.workflows = vec![job()]; + c.workflows[0].target = WorkflowTarget::Hive("missing".into()); + assert!(matches!(c.validate(), Err(ConfigError::UnknownHive(_)))); + let mut c = minimal(); + c.profiles[0].context = vec!["missing".into()]; + assert!(matches!(c.validate(), Err(ConfigError::UnknownContext(_)))); +} +#[test] +fn empty_hives_bad_ids_limits_and_selectors_are_typed() { + let mut c = minimal(); + c.hives[0].members.clear(); + assert!(matches!(c.validate(), Err(ConfigError::EmptyHive(_)))); + for id in ["", "UPPER", "../path", "bad.id"] { + let mut c = minimal(); + c.seats[0].id = id.into(); + c.hives[0].members[0].seat = id.into(); + assert!(matches!(c.validate(), Err(ConfigError::InvalidId(_)))); + } + let mut c = minimal(); + c.runtime.limits.timeout_ms = Some(0); + assert!(matches!(c.validate(), Err(ConfigError::InvalidLimit(_)))); + let mut c = minimal(); + c.profiles[0].limits.iterations = Some(0); + assert!(matches!(c.validate(), Err(ConfigError::InvalidLimit(_)))); + let mut c = minimal(); + c.seats[0].overrides.limits.budget_usd = Some(-1.0); + assert!(matches!(c.validate(), Err(ConfigError::InvalidLimit(_)))); + let mut c = minimal(); + c.runtime.memory_engine = "invented".into(); + assert!(matches!( + c.validate(), + Err(ConfigError::UnsupportedSetting(_)) + )); + let mut c = minimal(); + c.workflows = vec![job()]; + c.workflows[0].schedule = "90 90 * * *".into(); + assert!(matches!(c.validate(), Err(ConfigError::InvalidWorkflow(_)))); +} +#[test] +fn permission_restrictions_allow_subsets_and_reject_each_widening() { + let parent = PermissionProfile { + id: "baseline".into(), + access: Access::ReadOnly, + sandbox: Sandbox::ReadOnly, + allow_tools: Some(vec!["Read".into(), "List".into()]), + deny_tools: vec!["Write".into()], + send_destinations: Some(vec!["desk".into()]), + send_operations: Some(vec!["send".into()]), + ..Default::default() + }; + let child = PermissionProfile { + id: "narrow".into(), + allow_tools: Some(vec!["Read".into()]), + ..parent.clone() + }; + let setup = |child: PermissionProfile| { + let mut c = minimal(); + c.runtime.permission_profile = Some("baseline".into()); + c.hives[0].members[0].permission_profile = Some("narrow".into()); + c.permission_profiles = vec![parent.clone(), child]; + c + }; + setup(child.clone()) + .validate() + .unwrap_or_else(|error| unreachable!("valid fixture: {error}")); + let mut variants = vec![]; + let mut c = child.clone(); + c.access = Access::Autonomous; + variants.push(c); + let mut c = child.clone(); + c.sandbox = Sandbox::None; + variants.push(c); + let mut c = child.clone(); + c.allow_tools = Some(vec!["Write".into()]); + variants.push(c); + let mut c = child.clone(); + c.deny_tools.clear(); + variants.push(c); + let mut c = child.clone(); + c.send_destinations = Some(vec!["other".into()]); + variants.push(c); + let mut c = child; + c.send_operations = Some(vec!["broadcast".into()]); + variants.push(c); + for variant in variants { + assert!(matches!( + setup(variant).validate(), + Err(ConfigError::PermissionWidening(_)) + )); + } +} +fn server(id: &str) -> McpServer { + McpServer { + id: id.into(), + transport: "http".into(), + endpoint: format!("https://{id}.example.test"), + args: vec![], + env: std::collections::BTreeMap::default(), + headers: std::collections::BTreeMap::default(), + tools: Some(vec!["Read".into(), "List".into()]), + deny_tools: vec![], + } +} +#[test] +fn immutable_mcp_normalizes_order_and_rejects_connection_changes() { + let mut c = minimal(); + c.runtime.mcp = vec![server("a"), server("b")]; + c.hives[0].members[0].mcp = Some(vec![server("b"), server("a")]); + c.validate() + .unwrap_or_else(|error| unreachable!("valid fixture: {error}")); + let mut c = minimal(); + c.runtime.mcp = vec![server("a")]; + let mut changed = server("a"); + changed.endpoint = "https://elsewhere.test".into(); + c.hives[0].members[0].mcp = Some(vec![changed]); + assert!(matches!(c.validate(), Err(ConfigError::ConflictingMcp(_)))); +} +#[test] +fn zero_temperature_valid_and_parent_limits_cannot_widen() { + let mut c = minimal(); + c.profiles[0].model.temperature = Some(0.0); + c.validate() + .unwrap_or_else(|error| unreachable!("valid fixture: {error}")); + let mut c = minimal(); + c.runtime.limits.iterations = Some(4); + c.profiles[0].limits.iterations = Some(5); + assert!(matches!( + c.validate(), + Err(ConfigError::PermissionWidening(_)) + )); +} + +#[test] +fn approval_grants_and_rules_cannot_be_replaced_with_permissive_defaults() { + use tinyhivemind_core::approval::{ApprovalPolicy, ApproverRule, DefaultVerdict}; + let policy = ApprovalPolicy { + enabled: true, + default: DefaultVerdict::Deny, + rules: vec![], + approver: ApproverRule::Absent, + allow_grants: false, + max_grant_ttl: None, + }; + let parent = PermissionProfile { + id: "parent".into(), + approval: Some(policy.clone()), + ..Default::default() + }; + let child = PermissionProfile { + id: "child".into(), + approval: Some(policy), + ..Default::default() + }; + let setup = |child: PermissionProfile| { + let mut c = minimal(); + c.runtime.permission_profile = Some("parent".into()); + c.hives[0].members[0].permission_profile = Some("child".into()); + c.permission_profiles = vec![parent.clone(), child]; + c + }; + setup(child.clone()) + .validate() + .unwrap_or_else(|error| unreachable!("valid fixture: {error}")); + let mut grant = child.clone(); + grant + .approval + .as_mut() + .unwrap_or_else(|| unreachable!("fixture approval is present")) + .allow_grants = true; + assert!(matches!( + setup(grant).validate(), + Err(ConfigError::PermissionWidening(_)) + )); + let mut fallback = child.clone(); + fallback + .approval + .as_mut() + .unwrap_or_else(|| unreachable!("fixture approval is present")) + .default = DefaultVerdict::Ask; + assert!(matches!( + setup(fallback).validate(), + Err(ConfigError::PermissionWidening(_)) + )); + let mut approver = child; + approver + .approval + .as_mut() + .unwrap_or_else(|| unreachable!("fixture approval is present")) + .approver = ApproverRule::Person { id: "other".into() }; + assert!(matches!( + setup(approver).validate(), + Err(ConfigError::PermissionWidening(_)) + )); +} +#[test] +fn omitted_intermediate_allowlist_does_not_erase_ancestor_restrictions() { + let mut c = minimal(); + c.runtime.permission_profile = Some("parent".into()); + c.profiles[0].permission_profile = Some("middle".into()); + c.hives[0].members[0].permission_profile = Some("child".into()); + c.permission_profiles = vec![ + PermissionProfile { + id: "parent".into(), + allow_tools: Some(vec!["Read".into()]), + ..Default::default() + }, + PermissionProfile { + id: "middle".into(), + ..Default::default() + }, + PermissionProfile { + id: "child".into(), + allow_tools: Some(vec!["Write".into()]), + ..Default::default() + }, + ]; + assert!(matches!( + c.validate(), + Err(ConfigError::PermissionWidening(_)) + )); +} + +#[test] +fn host_only_cannot_replace_finite_names_or_connect_mcp() { + let mut c = minimal(); + c.profiles[0].tool_scope = ToolScope::Named(vec!["Read".into()]); + c.seats[0].overrides.tool_scope = Some(ToolScope::HostOnly); + assert!(matches!( + c.validate(), + Err(ConfigError::PermissionWidening(_)) + )); + let mut c = minimal(); + c.profiles[0].tool_scope = ToolScope::HostOnly; + c.runtime.mcp = vec![server("a")]; + assert!(matches!( + c.validate(), + Err(ConfigError::UnsupportedSetting(_)) + )); +} + +#[test] +fn scheduled_prompts_must_be_nonempty() { + let mut c = minimal(); + let mut workflow = job(); + workflow.prompt = " ".into(); + c.workflows = vec![workflow]; + assert!(matches!(c.validate(), Err(ConfigError::InvalidWorkflow(_)))); +} + +#[test] +fn blind_and_revealed_widths_are_independently_bounded() -> anyhow::Result<()> { + let mut config = minimal(); + config.runtime.concurrency = 4; + config.hives[0].episode = tinyhivemind_core::hive::EpisodePolicy::default(); + assert_eq!(config.hives[0].episode.round_width, 4); + assert_eq!(config.hives[0].episode.revealed_width, 1); + config.clone().validate()?; + config.hives[0].episode.round_width = 5; + assert!(matches!( + config.clone().validate(), + Err(ConfigError::InvalidWidth(_)) + )); + config.hives[0].episode.round_width = 4; + config.hives[0].episode.revealed_width = 5; + assert!(matches!( + config.validate(), + Err(ConfigError::InvalidWidth(_)) + )); + Ok(()) +} diff --git a/crates/tinyhivemind-openhuman/src/config/types.rs b/crates/tinyhivemind-openhuman/src/config/types.rs new file mode 100644 index 00000000..d2fde416 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/config/types.rs @@ -0,0 +1,416 @@ +//! Canonical serializable manifest and its runtime-independent selections. +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use tinyhivemind_core::{ + approval::ApprovalPolicy, + driver::ConductPolicy, + embed::RoutingPolicy, + hive::{DivisionPolicy, EpisodePolicy}, +}; +use tinyhivemind_hives::CoordinatorOptions; + +/// Credential location; never a resolved credential. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum SecretRef { + /// Environment variable name. + Env(String), + /// Host secret-store key. + Store(String), +} +/// Provider endpoint and credential reference. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct Provider { + /// Provider selector; `openai` supports compatible endpoints. + pub kind: String, + /// Optional compatible HTTP endpoint. + pub endpoint: Option, + /// Credential reference resolved only during deployment. + pub credential: Option, +} +/// Model and sampling defaults. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct Model { + /// Provider model identifier. + pub name: Option, + /// Sampling temperature, including zero. + pub temperature: Option, + /// Maximum generated tokens per model request. + pub max_tokens: Option, +} +/// Turn resource limits; omission inherits the parent. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct Limits { + /// Wall-clock timeout in milliseconds. + pub timeout_ms: Option, + /// Maximum model/tool iterations. + pub iterations: Option, + /// Maximum monetary model spend in dollars. + pub budget_usd: Option, +} +/// Ordered access tiers, from least to greatest authority. +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, Eq, PartialEq, Ord, PartialOrd)] +#[serde(rename_all = "snake_case")] +pub enum Access { + /// No mutating tools. + ReadOnly, + /// Human-supervised mutation. + #[default] + Supervised, + /// Autonomous mutation subject to other gates. + Autonomous, +} +/// Shell/file confinement selection. +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Sandbox { + /// Access and path rules only. + #[default] + None, + /// Disable writes and execution. + ReadOnly, + /// Host-provided jail or Docker backend. + Sandboxed, +} +/// Tool visibility selector. +#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ToolScope { + /// Every registered tool subject to permission gates. + #[default] + Wildcard, + /// Only explicitly named tools. + Named(Vec), + /// Only host-supplied tools, with read-only access. + HostOnly, +} +/// Immutable MCP connection and server tool selection. +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Ord, PartialOrd)] +#[serde(deny_unknown_fields)] +pub struct McpServer { + /// Unique connection name. + pub id: String, + /// `stdio`, `sse` or `http`. + pub transport: String, + /// URL for network transports, executable for stdio. + pub endpoint: String, + /// Stdio command arguments. + #[serde(default)] + pub args: Vec, + /// Credential-bearing environment values, all references. + #[serde(default)] + pub env: BTreeMap, + /// Credential-bearing HTTP headers, all references. + #[serde(default)] + pub headers: BTreeMap, + /// None exposes all server tools; Some limits them. + #[serde(default)] + pub tools: Option>, + /// Server tools denied independently of its allowlist. + #[serde(default)] + pub deny_tools: Vec, +} +/// Fixed lifetime agent memory binding. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct Memory { + /// Logical namespace root; absent defaults to `seat:`. + /// Deployment encodes it with [`crate::deploy::native_memory_root`]. + pub root: Option, +} +/// Named authority baseline or restriction. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct PermissionProfile { + /// Unique profile id. + pub id: String, + /// Access tier. + pub access: Access, + /// Sandbox confinement. + pub sandbox: Sandbox, + /// None inherits unrestricted names; Some is a finite allowlist. + pub allow_tools: Option>, + /// Tool names always denied. + pub deny_tools: Vec, + /// Allowed direct/hive destination ids; None is unrestricted. + pub send_destinations: Option>, + /// Allowed send operations; None is unrestricted. + pub send_operations: Option>, + /// Native total core approval policy; omission inherits. + pub approval: Option, + /// Native argument-sensitive tool rules; omission inherits. + pub tool_rules: Option, +} +/// Global runtime baseline, shared by every seat. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct RuntimeSection { + /// Provider connection. + pub provider: Provider, + /// Default model settings. + pub model: Model, + /// Workspace directory; absent creates an ephemeral workspace. + pub workspace: Option, + /// Session store selector (`memory`, `sqlite`, or `host`). + pub session_store: String, + /// Memory engine selector (`disabled`, `memory`, or `host`). + pub memory_engine: String, + /// Optional named authority baseline. + pub permission_profile: Option, + /// Baseline confinement. + pub sandbox: Sandbox, + /// Global tool rules. + pub tool_rules: Option, + /// Baseline MCP connections. + pub mcp: Vec, + /// Enabled skill directories. + pub skills: Vec, + /// Autonomy tier. + pub autonomy: Access, + /// Runtime limit defaults. + pub limits: Limits, + /// Global coordinator-turn bound across hives. Native cron has a separate + /// scheduler ceiling with this value; direct turns bypass coordinator admission. + pub concurrency: usize, + /// Include installed user skills in the runtime. + pub include_user_skills: bool, + /// Maximum seats; omission leaves the deployment unbounded. + pub max_agents: Option, + /// Start scheduler services after deployment construction. + pub start_scheduler: bool, +} +impl Default for RuntimeSection { + fn default() -> Self { + Self { + provider: Provider::default(), + model: Model::default(), + workspace: None, + session_store: "memory".into(), + memory_engine: "disabled".into(), + permission_profile: None, + sandbox: Sandbox::None, + tool_rules: None, + mcp: vec![], + skills: vec![], + autonomy: Access::Supervised, + limits: Limits::default(), + concurrency: 4, + include_user_skills: false, + max_agents: None, + start_scheduler: false, + } + } +} +/// Reusable agent template. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct Profile { + /// Unique template id. + pub id: String, + /// Optional builtin definition name. + pub definition_base: Option, + /// Replace the entire composed system prompt when true. + pub bare_prompt: bool, + /// Override user-skill discovery. + pub include_user_skills: Option, + /// Fixed system prompt body. + pub system_prompt: String, + /// Named Markdown context, in prompt composition order. + pub context: Vec, + /// Model/sampling overrides. + pub model: Model, + /// Optional authority restriction. + pub permission_profile: Option, + /// Tool visibility. + pub tool_scope: ToolScope, + /// Additional denied tool names. + pub deny_tools: Vec, + /// Additional immutable MCP servers. + pub mcp: Vec, + /// Additional enabled skills. + pub skills: Vec, + /// Allowed subagent template ids. + pub subagents: Vec, + /// Fixed memory binding override. + pub memory: Option, + /// Turn limits. + pub limits: Limits, +} +/// Typed dynamic or configured seat overrides; credential fields are absent. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct SeatOverrides { + /// Replacement fixed system prompt body. + pub system_prompt: Option, + /// Additional context references. + pub context: Vec, + /// Model/sampling overrides. + pub model: Model, + /// Optional authority restriction. + pub permission_profile: Option, + /// Optional tool visibility restriction. + pub tool_scope: Option, + /// Additional denied tool names. + pub deny_tools: Vec, + /// Additional immutable MCP connections. + pub mcp: Vec, + /// Additional skills. + pub skills: Vec, + /// Allowed subagent template ids; omission inherits. + pub subagents: Option>, + /// Fixed memory binding override. + pub memory: Option, + /// Turn limit overrides. + pub limits: Limits, +} +/// One persistent agent/session identity. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct Seat { + /// OpenHuman-compatible agent id. + pub id: String, + /// Reusable profile reference. + pub profile: String, + /// Typed final seat settings. + pub overrides: SeatOverrides, +} +/// Per-hive captured overlay; cannot rebind memory or credentials. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct Membership { + /// Existing seat id. + pub seat: String, + /// Freeform nonempty routing/delegation role. + pub role: String, + /// Additional context references. + pub context: Vec, + /// Optional further authority restriction. + pub permission_profile: Option, + /// Optional assertion of the immutable seat MCP set. + pub mcp: Option>, +} +/// Hive membership and native policy values. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct HiveSpec { + /// Unique hive identity. + pub id: String, + /// Display name; empty falls back to id. + pub name: String, + /// Members, in declaration order. + pub members: Vec, + /// Shared recall namespace; does not rebind seats. + pub memory_root: Option, + /// Pure deliberation episode policy. + pub episode: EpisodePolicy, + /// Semantic completion routing policy. + pub routing: RoutingPolicy, + /// Per-hive scheduling settings. + pub coordinator: CoordinatorOptions, + /// Pure deliberation task division policy. + pub division: DivisionPolicy, + /// Completion turn walls. + pub conduct: ConductPolicy, +} +impl Default for HiveSpec { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + members: Vec::new(), + memory_root: None, + episode: EpisodePolicy { + round_width: 1, + ..EpisodePolicy::default() + }, + routing: RoutingPolicy { + minimum_confidence: tinyhivemind_core::responder::Probability::new(600_000) + .unwrap_or(tinyhivemind_core::responder::Probability::ONE), + high_impact_minimum_confidence: tinyhivemind_core::responder::Probability::new( + 800_000, + ) + .unwrap_or(tinyhivemind_core::responder::Probability::ONE), + clarification_threshold: tinyhivemind_core::responder::Probability::new(700_000) + .unwrap_or(tinyhivemind_core::responder::Probability::ONE), + high_impact_threshold: tinyhivemind_core::responder::Probability::new(700_000) + .unwrap_or(tinyhivemind_core::responder::Probability::ONE), + round_width: 4, + choice_option_limit: 8, + }, + coordinator: CoordinatorOptions::default(), + division: DivisionPolicy::default(), + conduct: ConductPolicy::default(), + } + } +} +/// Explicit workflow dispatch target. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowTarget { + /// Native isolated agent cron session. + Seat(String), + /// Authorized coordinator hive message. + Hive(String), +} +/// Scheduled named prompt; services start separately. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Workflow { + /// Unique job id. + pub id: String, + /// Cron expression, parsed before runtime construction. + pub schedule: String, + /// Authenticated deployment target. + pub target: WorkflowTarget, + /// Prompt submitted when the job runs. + pub prompt: String, + /// Whether this job is enabled. + #[serde(default = "enabled")] + pub enabled: bool, + /// Maximum retry count. + #[serde(default)] + pub retries: u32, + /// At most one invocation at once. + #[serde(default = "enabled")] + pub single_flight: bool, +} +fn enabled() -> bool { + true +} +/// Canonical configuration; parsing has no runtime or secret side effects. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct HiveConfig { + /// One runtime's defaults. + pub runtime: RuntimeSection, + /// Reusable agent templates. + pub profiles: Vec, + /// Named baseline and restriction policies. + pub permission_profiles: Vec, + /// Persistent seats. + pub seats: Vec, + /// Hives sharing the seats. + pub hives: Vec, + /// Named cron workflows. + pub workflows: Vec, + /// Named Markdown context bodies, resolved by directory loading. + pub contexts: BTreeMap, +} +/// Manifest that passed all referential and authority checks. +#[derive(Clone, Debug)] +pub struct ValidatedHiveConfig(pub(super) HiveConfig); +impl ValidatedHiveConfig { + /// Immutable validated manifest. + #[must_use] + pub fn config(&self) -> &HiveConfig { + &self.0 + } + /// Consume validation proof to recover an editable manifest. + #[must_use] + pub fn into_config(self) -> HiveConfig { + self.0 + } +} diff --git a/crates/tinyhivemind-openhuman/src/config/validate.rs b/crates/tinyhivemind-openhuman/src/config/validate.rs new file mode 100644 index 00000000..fe42c7d2 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/config/validate.rs @@ -0,0 +1,467 @@ +//! Referential validation and conservative authority intersection. +use super::{ + ConfigError, HiveConfig, Limits, McpServer, Membership, Model, PermissionProfile, Profile, + Result, Sandbox, Seat, ToolScope, ValidatedHiveConfig, WorkflowTarget, +}; +use std::collections::BTreeSet; + +impl HiveConfig { + /// Validate references and permissions without constructing any runtime. + /// + /// # Errors + /// Returns the first typed identity, reference, limit or authority failure. + pub fn validate(self) -> Result { + super::parse::reject_inline( + &serde_json::to_value(&self).map_err(|_| ConfigError::Json)?, + "config", + )?; + unique("profile", self.profiles.iter().map(|v| v.id.as_str()))?; + unique( + "permission", + self.permission_profiles.iter().map(|v| v.id.as_str()), + )?; + unique("seat", self.seats.iter().map(|v| v.id.as_str()))?; + unique("hive", self.hives.iter().map(|v| v.id.as_str()))?; + unique("workflow", self.workflows.iter().map(|v| v.id.as_str()))?; + self.check_references()?; + self.check_settings()?; + self.check_authority()?; + Ok(ValidatedHiveConfig(self)) + } + /// Find the reusable profile for a seat. + /// + /// # Errors + /// Returns `UnknownProfile` if the seat references an absent template. + pub fn profile_for(&self, seat: &Seat) -> Result<&Profile> { + self.profiles + .iter() + .find(|p| p.id == seat.profile) + .ok_or_else(|| ConfigError::UnknownProfile(seat.profile.clone())) + } + /// Runtime/profile/seat/membership authority layers, in precedence order. + /// + /// # Errors + /// Returns unknown-profile or unknown-permission-reference errors. + pub fn permission_layers( + &self, + seat: &Seat, + member: Option<&Membership>, + ) -> Result> { + let profile = self.profile_for(seat)?; + let mut layers = vec![]; + for reference in [ + self.runtime.permission_profile.as_ref(), + profile.permission_profile.as_ref(), + seat.overrides.permission_profile.as_ref(), + member.and_then(|m| m.permission_profile.as_ref()), + ] + .into_iter() + .flatten() + { + layers.push( + self.permission_profiles + .iter() + .find(|p| &p.id == reference) + .ok_or_else(|| ConfigError::UnknownPermissionProfile(reference.clone()))?, + ); + } + Ok(layers) + } + /// One normalized immutable runtime/profile/seat connection set. + /// + /// # Errors + /// Returns `UnknownProfile` or `ConflictingMcp` for conflicting named servers. + pub fn normalized_mcp(&self, seat: &Seat) -> Result> { + let profile = self.profile_for(seat)?; + let mut servers = std::collections::BTreeMap::new(); + for server in self + .runtime + .mcp + .iter() + .chain(&profile.mcp) + .chain(&seat.overrides.mcp) + { + let server = normalize_server(server.clone()); + if servers.get(&server.id).is_some_and(|old| old != &server) { + return Err(ConfigError::ConflictingMcp(seat.id.clone())); + } + servers.insert(server.id.clone(), server); + } + Ok(servers.into_values().collect()) + } + fn check_references(&self) -> Result<()> { + for reference in self + .runtime + .permission_profile + .iter() + .chain( + self.profiles + .iter() + .filter_map(|p| p.permission_profile.as_ref()), + ) + .chain( + self.seats + .iter() + .filter_map(|s| s.overrides.permission_profile.as_ref()), + ) + .chain( + self.hives + .iter() + .flat_map(|h| &h.members) + .filter_map(|m| m.permission_profile.as_ref()), + ) + { + if !self.permission_profiles.iter().any(|p| &p.id == reference) { + return Err(ConfigError::UnknownPermissionProfile(reference.clone())); + } + } + for profile in &self.profiles { + self.context_refs(&profile.context)?; + for child in &profile.subagents { + self.profile_ref(child)?; + } + } + for seat in &self.seats { + self.profile_for(seat)?; + self.context_refs(&seat.overrides.context)?; + for child in seat.overrides.subagents.iter().flatten() { + self.profile_ref(child)?; + } + } + for hive in &self.hives { + unique("member", hive.members.iter().map(|m| m.seat.as_str()))?; + for member in &hive.members { + self.seat_ref(&member.seat)?; + self.context_refs(&member.context)?; + } + } + for job in &self.workflows { + match &job.target { + WorkflowTarget::Seat(id) => { + self.seat_ref(id)?; + } + WorkflowTarget::Hive(id) if !self.hives.iter().any(|h| &h.id == id) => { + return Err(ConfigError::UnknownHive(id.clone())); + } + WorkflowTarget::Hive(_) => {} + } + } + Ok(()) + } + fn profile_ref(&self, id: &str) -> Result<()> { + if self.profiles.iter().any(|p| p.id == id) { + Ok(()) + } else { + Err(ConfigError::UnknownProfile(id.into())) + } + } + fn seat_ref(&self, id: &str) -> Result<&Seat> { + self.seats + .iter() + .find(|s| s.id == id) + .ok_or_else(|| ConfigError::UnknownSeat(id.into())) + } + fn context_refs(&self, refs: &[String]) -> Result<()> { + for id in refs { + if !self.contexts.contains_key(id) { + return Err(ConfigError::UnknownContext(id.clone())); + } + } + Ok(()) + } + fn check_settings(&self) -> Result<()> { + let runtime = &self.runtime; + if runtime.concurrency == 0 { + return Err(ConfigError::InvalidWidth("runtime".into())); + } + if runtime + .max_agents + .is_some_and(|max| max == 0 || self.seats.len() > max) + { + return Err(ConfigError::InvalidLimit("runtime.max_agents".into())); + } + for (field, value, allowed) in [ + ( + "provider", + runtime.provider.kind.as_str(), + &["", "openai", "openrouter", "anthropic"][..], + ), + ( + "session_store", + runtime.session_store.as_str(), + &["memory", "sqlite", "host"][..], + ), + ( + "memory_engine", + runtime.memory_engine.as_str(), + &["disabled", "memory", "host"][..], + ), + ] { + if !allowed.contains(&value) { + return Err(ConfigError::UnsupportedSetting(field.into())); + } + } + limits(&runtime.limits, "runtime")?; + model(&runtime.model, "runtime")?; + for profile in &self.profiles { + limits(&profile.limits, &profile.id)?; + model(&profile.model, &profile.id)?; + } + for seat in &self.seats { + let bytes = seat.id.as_bytes(); + if bytes.len() > 64 + || !bytes + .first() + .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + || bytes.iter().any(|b| { + !b.is_ascii_lowercase() && !b.is_ascii_digit() && !matches!(b, b'_' | b'-') + }) + { + return Err(ConfigError::InvalidId(seat.id.clone())); + } + limits(&seat.overrides.limits, &seat.id)?; + model(&seat.overrides.model, &seat.id)?; + } + for hive in &self.hives { + if hive.members.is_empty() { + return Err(ConfigError::EmptyHive(hive.id.clone())); + } + for member in &hive.members { + if member.role.trim().is_empty() { + return Err(ConfigError::InvalidRole(member.seat.clone())); + } + } + let cap = runtime.concurrency; + if hive.coordinator.round_width == 0 + || hive.coordinator.round_width > cap + || hive.routing.round_width == 0 + || hive.routing.round_width > cap + || hive.episode.round_width == 0 + || hive.episode.revealed_width == 0 + || hive.episode.round_width as usize > cap + || hive.episode.revealed_width as usize > cap + || hive.division.round_width == 0 + || hive.division.round_width as usize > cap + { + return Err(ConfigError::InvalidWidth(hive.id.clone())); + } + if hive.conduct.turn_wall == 0 + || hive.conduct.child_turn_wall == 0 + || hive.coordinator.conduct_policy.turn_wall == 0 + || hive.coordinator.conduct_policy.child_turn_wall == 0 + || hive.episode.turn_budget == 0 + { + return Err(ConfigError::InvalidLimit(hive.id.clone())); + } + } + self.check_workflows_and_mcp()?; + Ok(()) + } + fn check_workflows_and_mcp(&self) -> Result<()> { + for job in &self.workflows { + if job.prompt.trim().is_empty() + || job.schedule.split_whitespace().count() != 5 + || openhuman_core::cron::validate_schedule( + &openhuman_core::cron::Schedule::Cron { + expr: job.schedule.clone(), + tz: None, + active_hours: None, + }, + chrono::DateTime::from_timestamp(0, 0).unwrap_or_default(), + ) + .is_err() + { + return Err(ConfigError::InvalidWorkflow(job.id.clone())); + } + } + for seat in &self.seats { + for server in self.normalized_mcp(seat)? { + if server.id.trim().is_empty() + || server.endpoint.trim().is_empty() + || !matches!(server.transport.as_str(), "stdio" | "sse" | "http") + { + return Err(ConfigError::UnsupportedSetting( + "mcp transport or endpoint".into(), + )); + } + } + } + Ok(()) + } + fn check_authority(&self) -> Result<()> { + let baseline = PermissionProfile { + access: self.runtime.autonomy, + sandbox: self.runtime.sandbox, + tool_rules: self.runtime.tool_rules.clone(), + ..PermissionProfile::default() + }; + for seat in &self.seats { + let profile = self.profile_for(seat)?; + let scope = seat + .overrides + .tool_scope + .as_ref() + .unwrap_or(&profile.tool_scope); + if scope == &ToolScope::HostOnly && !self.normalized_mcp(seat)?.is_empty() { + return Err(ConfigError::UnsupportedSetting("host_only mcp".into())); + } + limits_narrow(&self.runtime.limits, &profile.limits, &seat.id)?; + let effective_limits = Limits { + timeout_ms: profile.limits.timeout_ms.or(self.runtime.limits.timeout_ms), + iterations: profile.limits.iterations.or(self.runtime.limits.iterations), + budget_usd: profile.limits.budget_usd.or(self.runtime.limits.budget_usd), + }; + limits_narrow(&effective_limits, &seat.overrides.limits, &seat.id)?; + let layers = self.permission_layers(seat, None)?; + for (index, layer) in layers.iter().enumerate() { + narrows(&baseline, layer, &seat.id)?; + for ancestor in &layers[..index] { + narrows(ancestor, layer, &seat.id)?; + } + } + if let Some(scope) = &seat.overrides.tool_scope + && !scope_narrows(&profile.tool_scope, scope) + { + return Err(ConfigError::PermissionWidening(seat.id.clone())); + } + if let Some(children) = &seat.overrides.subagents + && children.iter().any(|id| !profile.subagents.contains(id)) + { + return Err(ConfigError::PermissionWidening(seat.id.clone())); + } + for hive in &self.hives { + for member in hive.members.iter().filter(|m| m.seat == seat.id) { + if let Some(id) = &member.permission_profile { + let child = self + .permission_profiles + .iter() + .find(|p| &p.id == id) + .ok_or_else(|| ConfigError::UnknownPermissionProfile(id.clone()))?; + narrows(&baseline, child, &seat.id)?; + for ancestor in &layers { + narrows(ancestor, child, &seat.id)?; + } + } + if let Some(mcp) = &member.mcp { + let mut requested: Vec<_> = + mcp.iter().cloned().map(normalize_server).collect(); + requested.sort(); + requested.dedup(); + let mut expected = self.normalized_mcp(seat)?; + expected.sort(); + if requested != expected { + return Err(ConfigError::ConflictingMcp(seat.id.clone())); + } + } + } + } + } + Ok(()) + } +} +fn unique<'a>(section: &str, ids: impl Iterator) -> Result<()> { + let mut seen = BTreeSet::new(); + for id in ids { + if id.trim().is_empty() { + return Err(ConfigError::InvalidId(section.into())); + } + if !seen.insert(id) { + return Err(ConfigError::DuplicateId { + section: section.into(), + id: id.into(), + }); + } + } + Ok(()) +} +fn limits(limits: &Limits, id: &str) -> Result<()> { + if limits.timeout_ms == Some(0) + || limits.iterations == Some(0) + || limits + .budget_usd + .is_some_and(|b| !b.is_finite() || b <= 0.0) + { + return Err(ConfigError::InvalidLimit(id.into())); + } + Ok(()) +} +fn model(model: &Model, id: &str) -> Result<()> { + if model.name.as_ref().is_some_and(|n| n.trim().is_empty()) + || model.max_tokens == Some(0) + || model + .temperature + .is_some_and(|t| !t.is_finite() || !(0.0..=2.0).contains(&t)) + { + return Err(ConfigError::InvalidLimit(id.into())); + } + Ok(()) +} +fn subset(parent: Option<&Vec>, child: Option<&Vec>) -> bool { + match (parent, child) { + (Some(p), Some(c)) => c.iter().all(|id| p.contains(id)), + (None, _) | (Some(_), None) => true, + } +} +fn narrows(parent: &PermissionProfile, child: &PermissionProfile, id: &str) -> Result<()> { + let sandbox_ok = parent.sandbox == Sandbox::None + || child.sandbox == parent.sandbox + || child.sandbox == Sandbox::ReadOnly; + let approval_ok = match (&parent.approval, &child.approval) { + (None, _) | (Some(_), None) => true, + (Some(p), Some(c)) => { + p == c + || (!c.enabled && p.approver == c.approver && p.rules == c.rules && !c.allow_grants) + } + }; + let rules_ok = match (&parent.tool_rules, &child.tool_rules) { + (Some(p), Some(c)) => p == c, + _ => true, + }; + if child.access > parent.access + || !sandbox_ok + || !subset(parent.allow_tools.as_ref(), child.allow_tools.as_ref()) + || parent + .deny_tools + .iter() + .any(|t| !child.deny_tools.contains(t)) + || !subset( + parent.send_destinations.as_ref(), + child.send_destinations.as_ref(), + ) + || !subset( + parent.send_operations.as_ref(), + child.send_operations.as_ref(), + ) + || !approval_ok + || !rules_ok + { + return Err(ConfigError::PermissionWidening(id.into())); + } + Ok(()) +} +fn scope_narrows(parent: &ToolScope, child: &ToolScope) -> bool { + match (parent, child) { + (ToolScope::HostOnly, ToolScope::HostOnly) | (ToolScope::Wildcard, _) => true, + (ToolScope::Named(p), ToolScope::Named(c)) => c.iter().all(|n| p.contains(n)), + _ => false, + } +} +fn normalize_server(mut server: McpServer) -> McpServer { + if let Some(tools) = &mut server.tools { + tools.sort(); + tools.dedup(); + } + server.deny_tools.sort(); + server.deny_tools.dedup(); + server +} + +fn limits_narrow(parent: &Limits, child: &Limits, id: &str) -> Result<()> { + if matches!((parent.timeout_ms, child.timeout_ms), (Some(p), Some(c)) if c > p) + || matches!((parent.iterations, child.iterations), (Some(p), Some(c)) if c > p) + || matches!((parent.budget_usd, child.budget_usd), (Some(p), Some(c)) if c > p) + { + return Err(ConfigError::PermissionWidening(id.into())); + } + Ok(()) +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/README.md b/crates/tinyhivemind-openhuman/src/deploy/README.md new file mode 100644 index 00000000..edfe4b72 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/README.md @@ -0,0 +1,73 @@ +# Manifest deployment + +`HiveDeployment::build` resolves a validated manifest into one native runtime, +reusable templates, fixed seats and configured coordinator hives. `build_with` +accepts host storage, native configuration, provider, tools, consent and approval +ports. Credentials resolve during construction and are omitted from deployment +and factory diagnostics. + +| File | Responsibility | +| --- | --- | +| `mod.rs` | Assembly, runtime/seat/policy access, pending decisions and scheduler lifetime | +| `types.rs` | Typed errors, secret resolver, host ports and pure hive policy view | +| `runtime.rs` | Native runtime settings | +| `compatibility.rs` | Preflight checks for unsupported native representations | +| `memory_root.rs` | Injective logical root encoding for native namespaces | +| `profile.rs` | Templates, prompts, fixed memory roots and seat definitions | +| `permission.rs` | Additive baseline, membership, child and pure approval gates | +| `rules.rs` | Argument rules and authenticated tool/context metadata | +| `hooks.rs` | Captured turn overlays, model budgets, parking and outbound policy | +| `factory.rs` | Typed dynamic seats using the same catalog and runtime | +| `workflow.rs` | Native agent jobs and weakly captured coordinator system jobs | +| `test/` | Offline construction, effects, approvals, jobs and cancellation fixtures | + +Membership policies intersect the runtime, profile and seat baseline. The runner +binds continuing sessions before installing hooks; native cron sessions cannot +inherit an unrelated active hive's permissions. Tool rules requiring unavailable +metadata refuse the call. `EffectClassifier::subject` and `rule_context` supply +trusted application metadata; authored arguments cannot replace actor, session, +hive, episode or scheduled-job identity. Unknown effects refuse execution. +Declared `delegate_` tools coordinate internally; child calls retain +parent and child restrictions. + +Native tool denials abort a turn and remain failed/interrupted coordinator +records. Hosts can explicitly release a seat with a safe note and enqueue new +completion work, retaining the failure without replaying its effect. + +Ask decisions create a correlated adapter pending request and invoke the optional +native `ApprovalHandler` explicitly. The attempted call is refused and coordinator +work parks. Await the answer with `wait_decision`, then use `release` to provide a +host resumption note. Release does not replay the attempted effect or mint a +standing grant. It atomically matches the completed parked reservation; early +answers and stale approvals cannot release running or later work, even when +the later turn resumes the same input. Hosts can supply an `ApprovalContext` to share roster, epochs, +consent state and sequence ordering with the application. + +Seat memory roots are fixed across hives, defaulting to `seat:`. `native_memory_root` encodes every logical UTF-8 root below +`project:hivemind` using 64-byte hex chunks as `team` segments; roots over +384 bytes fail before boot. This includes roots resembling native namespaces. +Hive shared +memory is accessed separately through `memory(hive_id)`. `policy(hive_id)` exposes +pure `EpisodePolicy` and `DivisionPolicy`; completion scheduling consumes its +own configured coordinator, routing and conduct settings. + +Native seat cron runs an isolated session and retains baseline tool/approval and +iteration restrictions. It has no coordinator membership overlay or parking +hook, and cannot send into conductor work. Configured native seat cron timeout +or model-budget combinations fail before runtime creation because those native +paths lack the runner's enforcement ports. Hive jobs enqueue durable scheduled +messages and explicitly scope each resulting turn as cron automation. Scheduled +external effects are refused for builtin, MCP and host tools. + +Registration starts no scheduler. `start_scheduler` starts the native scheduler +explicitly after assembly; deployment drop aborts it. Coordinator admission and +native scheduler admission have independent ceilings, both configured by +`runtime.concurrency`; direct agent turns bypass coordinator admission. +System handlers capture weak host state, allowing deployment drop to release the +process singleton runtime. + +Preflight rejects settings the native API cannot faithfully represent: multiple +skill roots, SSE MCP, an empty MCP allowlist, transport-inapplicable MCP fields, +child instance-only settings, differing per-hive retention, and model budgets +without positive conservative per-call reservation bounds. SQLite session storage +uses native storage-backed session stores and requires a workspace directory. diff --git a/crates/tinyhivemind-openhuman/src/deploy/compatibility.rs b/crates/tinyhivemind-openhuman/src/deploy/compatibility.rs new file mode 100644 index 00000000..9350e0fa --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/compatibility.rs @@ -0,0 +1,188 @@ +//! Reject native representations that cannot retain manifest semantics. +use super::{ + BuildOptions, DeployResult, + runtime::{budget_micros, unsupported}, +}; +use crate::config::{HiveConfig, WorkflowTarget}; +pub(super) fn preflight(config: &HiveConfig, options: &BuildOptions) -> DeployResult<()> { + ports(config, options)?; + seats(config, options)?; + children(config)?; + mcp(config)?; + for hive in &config.hives { + if let Some(root) = &hive.memory_root { + super::native_memory_root(root)?; + } + if hive.coordinator.retention != options.retention { + return Err(unsupported("hive.coordinator.retention is global")); + } + } + Ok(()) +} +fn ports(config: &HiveConfig, options: &BuildOptions) -> DeployResult<()> { + let runtime = &config.runtime; + if options.provider.is_some() + && (runtime.provider.endpoint.is_some() || runtime.provider.credential.is_some()) + { + return Err(unsupported( + "runtime.provider conflicts with injected provider", + )); + } + if runtime.provider.credential.is_some() + && runtime.provider.endpoint.is_none() + && runtime.provider.kind.is_empty() + { + return Err(unsupported( + "runtime.provider credential requires endpoint or kind", + )); + } + if options.call_budget.is_some_and(|call| { + call.input_tokens == 0 || call.output_tokens == 0 || call.cost_micros == 0 + }) { + return Err(unsupported("call_budget bounds must be positive")); + } + if runtime.session_store == "host" && options.session_store.is_none() { + return Err(unsupported("runtime.session_store")); + } + if runtime.session_store == "sqlite" && runtime.workspace.is_none() { + return Err(unsupported( + "runtime.session_store sqlite requires workspace", + )); + } + if runtime.memory_engine == "host" && options.memory_engine.is_none() { + return Err(unsupported("runtime.memory_engine")); + } + if runtime.provider.kind == "anthropic" && options.provider.is_none() { + return Err(unsupported( + "runtime.provider anthropic requires host provider", + )); + } + if runtime.skills.len() > 1 { + return Err(unsupported("runtime.skills supports one bundle root")); + } + Ok(()) +} +fn seats(config: &HiveConfig, options: &BuildOptions) -> DeployResult<()> { + for seat in &config.seats { + let profile = config.profile_for(seat)?; + let root = seat + .overrides + .memory + .as_ref() + .or(profile.memory.as_ref()) + .and_then(|memory| memory.root.clone()) + .unwrap_or_else(|| format!("seat:{}", seat.id)); + super::native_memory_root(&root)?; + let roots: Vec<_> = config + .runtime + .skills + .iter() + .chain(&profile.skills) + .chain(&seat.overrides.skills) + .collect(); + if roots.len() > 1 { + return Err(unsupported("seat.skills supports one bundle root")); + } + let limits = super::profile::limits(config, seat)?; + if limits + .budget_usd + .is_some_and(|usd| budget_micros(usd).is_none()) + { + return Err(unsupported( + "limits.budget_usd exceeds native cost precision", + )); + } + if limits.budget_usd.is_some() && options.call_budget.is_none() { + return Err(unsupported("limits.budget_usd requires call_budget")); + } + if config + .workflows + .iter() + .any(|job| matches!(&job.target, WorkflowTarget::Seat(id) if id == &seat.id)) + && (limits.timeout_ms.is_some() || limits.budget_usd.is_some()) + { + return Err(unsupported( + "native seat cron timeout/budget requires native baseline support", + )); + } + } + Ok(()) +} +fn children(config: &HiveConfig) -> DeployResult<()> { + for children in config + .profiles + .iter() + .map(|profile| &profile.subagents) + .chain( + config + .seats + .iter() + .filter_map(|seat| seat.overrides.subagents.as_ref()), + ) + { + for child in children { + let child = config + .profiles + .iter() + .find(|profile| &profile.id == child) + .ok_or_else(|| crate::config::ConfigError::UnknownProfile(child.clone()))?; + if !child.subagents.is_empty() + || !child.mcp.is_empty() + || !child.skills.is_empty() + || child.memory.is_some() + || child.model.name.is_some() + || child.model.max_tokens.is_some() + || child.limits.timeout_ms.is_some() + || child.limits.budget_usd.is_some() + || child.include_user_skills.is_some() + { + return Err(unsupported( + "subagent profile requires native instance settings", + )); + } + if child + .permission_profile + .as_ref() + .and_then(|id| { + config + .permission_profiles + .iter() + .find(|policy| &policy.id == id) + }) + .is_some_and(|policy| { + policy.send_operations.is_some() || policy.send_destinations.is_some() + }) + { + return Err(unsupported( + "subagent send policy requires an authenticated child host belt", + )); + } + } + } + Ok(()) +} +fn mcp(config: &HiveConfig) -> DeployResult<()> { + for server in config + .runtime + .mcp + .iter() + .chain(config.profiles.iter().flat_map(|profile| &profile.mcp)) + .chain(config.seats.iter().flat_map(|seat| &seat.overrides.mcp)) + { + if server.transport == "sse" { + return Err(unsupported( + "mcp.sse requires a native transport constructor", + )); + } + if server.tools.as_ref().is_some_and(Vec::is_empty) { + return Err(unsupported("mcp empty allowlist means all in native API")); + } + if server.transport != "stdio" && (!server.env.is_empty() || !server.args.is_empty()) { + return Err(unsupported("mcp network env/args")); + } + if server.transport == "stdio" && !server.headers.is_empty() { + return Err(unsupported("mcp stdio headers")); + } + } + Ok(()) +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/factory.rs b/crates/tinyhivemind-openhuman/src/deploy/factory.rs new file mode 100644 index 00000000..bc66e80f --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/factory.rs @@ -0,0 +1,109 @@ +//! Dynamic agents use the same typed catalog and lowering path as fixed seats. +use super::{DeployError, DeployResult, Permissions, SecretResolver, profile, runtime}; +use crate::{ + AgentFactory, AgentFuture, + config::{Seat, SeatOverrides}, +}; +use std::sync::{Arc, PoisonError}; +/// Instantiates typed manifest profiles on the deployment's existing runtime. +#[derive(Clone)] +pub struct ManifestFactory { + runtime: Arc, + pub(super) permissions: Arc, + secrets: Arc, + options: super::BuildOptions, + creation: Arc>, +} +impl std::fmt::Debug for ManifestFactory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ManifestFactory") + .field("runtime_id", &self.runtime.runtime_id()) + .finish_non_exhaustive() + } +} +impl ManifestFactory { + pub(super) fn new( + runtime: Arc, + permissions: Arc, + secrets: Arc, + options: super::BuildOptions, + ) -> Self { + Self { + runtime, + permissions, + secrets, + options, + creation: Arc::new(tokio::sync::Mutex::new(())), + } + } + /// Build one new seat; callers then register it with their host. + /// # Errors + /// Unknown profiles, widening, credential literals or native creation failure. + pub async fn create_seat( + &self, + id: impl Into, + template: impl Into, + overrides: SeatOverrides, + ) -> DeployResult { + let _creation = self.creation.lock().await; + let mut config = self.permissions.config(); + let seat = Seat { + id: id.into(), + profile: template.into(), + overrides, + }; + config.seats.push(seat.clone()); + let config = config.validate()?.into_config(); + let limits = profile::limits(&config, &seat)?; + if limits.budget_usd.is_some() && self.options.call_budget.is_none() { + return Err(runtime::unsupported( + "limits.budget_usd requires call_budget", + )); + } + runtime::preflight(&config, &self.options)?; + let spec = profile::seat( + &config, + &seat, + self.secrets.as_ref(), + self.permissions.clone(), + )?; + let agent = self + .runtime + .agent(spec) + .map_err(|_| DeployError::Agent(seat.id.clone()))?; + *self + .permissions + .config + .lock() + .unwrap_or_else(PoisonError::into_inner) = config; + Ok(agent) + } +} +impl AgentFactory for ManifestFactory { + fn create(&self, template: String, value: serde_json::Value) -> AgentFuture { + let factory = self.clone(); + Box::pin(async move { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct Request { + id: String, + #[serde(default)] + overrides: SeatOverrides, + } + let request: Request = serde_json::from_value(value) + .map_err(|_| crate::Error::Unauthorized("invalid typed agent settings".into()))?; + factory + .create_seat(request.id, template, request.overrides) + .await + .map_err(|_| crate::Error::Unauthorized("manifest agent creation refused".into())) + }) + } +} +pub(super) struct DenyManagement; +impl crate::ManagementAuthorizer for DenyManagement { + fn authorize(&self, _: &str, _: &crate::ManagementRequest) -> crate::Result<()> { + Err(crate::Error::Unauthorized( + "management requires a host authorizer".into(), + )) + } +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/hooks.rs b/crates/tinyhivemind-openhuman/src/deploy/hooks.rs new file mode 100644 index 00000000..baca4ba7 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/hooks.rs @@ -0,0 +1,220 @@ +//! Captured membership context, turn gates and outbound send restrictions. +use super::{BuildOptions, Permissions, profile}; +use crate::{HostedTurn, SendAuthorizer, SendRequest, TurnHooks, TurnOptions, TurnScope}; +use std::{ + sync::{Arc, PoisonError}, + time::Duration, +}; +use tinyhivemind_hives::TurnDisposition; +pub(super) struct Hooks { + pub permissions: Arc, + pub extra: Option>, + pub call: Option, +} +impl Hooks { + pub(super) fn new(permissions: Arc, options: &BuildOptions) -> Self { + Self { + permissions, + extra: options.hooks.clone(), + call: options.call_budget, + } + } +} +impl TurnHooks for Hooks { + fn context(&self, scope: &TurnScope) -> String { + self.permissions + .active + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(scope.agent_id.clone(), scope.clone()); + let config = self.permissions.config(); + let mut text = String::new(); + if let Some(member) = Permissions::member(&config, scope) { + for id in &member.context { + if let Some(context) = config.contexts.get(id) { + text.push_str(context); + text.push('\n'); + } + } + } + if let Some(extra) = &self.extra { + text.push_str(&extra.context(scope)); + } + text + } + fn prepare(&self, scope: &TurnScope) -> TurnOptions { + self.extra + .as_ref() + .map_or_else(TurnOptions::default, |extra| extra.prepare(scope)) + } + fn turn_timeout(&self, scope: &TurnScope) -> Option { + let config = self.permissions.config(); + let configured = config + .seats + .iter() + .find(|seat| seat.id == scope.agent_id) + .and_then(|seat| profile::limits(&config, seat).ok()) + .and_then(|limits| limits.timeout_ms) + .map(Duration::from_millis); + let extra = self + .extra + .as_ref() + .and_then(|hooks| hooks.turn_timeout(scope)); + match (configured, extra) { + (Some(configured), Some(extra)) => Some(configured.min(extra)), + (configured, extra) => configured.or(extra), + } + } + fn configure( + &self, + scope: &TurnScope, + mut turn: openhuman_embed::Turn, + ) -> openhuman_embed::Turn { + let config = self.permissions.config(); + if let Some(seat) = config.seats.iter().find(|seat| seat.id == scope.agent_id) + && let Ok(limits) = profile::limits(&config, seat) + && let (Some(usd), Some(call)) = (limits.budget_usd, self.call) + { + turn = turn.budget(openhuman_embed::budget::ModelBudget { + ledger: openhuman_embed::budget::Budget::new( + openhuman_embed::budget::SpendLimits { + tokens: None, + cost_micros: super::runtime::budget_micros(usd), + }, + ), + call, + }); + } + let permissions = self.permissions.clone(); + let captured = scope.clone(); + turn = turn.can_use_tool(move |context| { + let permissions = permissions.clone(); + let captured = captured.clone(); + Box::pin(async move { + permissions + .decide(&captured.agent_id, Some(&captured), context) + .await + }) + }); + match &self.extra { + Some(extra) => extra.configure(scope, turn), + None => turn, + } + } + fn progress(&self, scope: &TurnScope) -> Option { + self.extra.as_ref().and_then(|extra| extra.progress(scope)) + } + fn wrap_turn<'a>(&'a self, scope: &'a TurnScope, turn: HostedTurn<'a>) -> HostedTurn<'a> { + match &self.extra { + Some(extra) => extra.wrap_turn(scope, turn), + None => turn, + } + } + fn after_turn( + &self, + scope: &TurnScope, + usage: Option<&openhuman_core::agent::tinyagents::host::LastTurnUsage>, + ) -> crate::Result { + self.permissions + .active + .lock() + .unwrap_or_else(PoisonError::into_inner) + .remove(&scope.agent_id); + let extra = self + .extra + .as_ref() + .map(|extra| extra.after_turn(scope, usage)) + .transpose()? + .unwrap_or(TurnDisposition::Completed); + if self + .permissions + .pending + .lock() + .unwrap_or_else(PoisonError::into_inner) + .values() + .any(|pending| pending.scope.as_ref() == Some(scope)) + { + Ok(TurnDisposition::Parked) + } else { + Ok(extra) + } + } +} +impl SendAuthorizer for Hooks { + fn authorize(&self, actor: &str, request: &SendRequest) -> crate::Result<()> { + self.authorize_in_session(actor, request, None) + } + fn authorize_in_session( + &self, + actor: &str, + request: &SendRequest, + session: Option<&str>, + ) -> crate::Result<()> { + let config = self.permissions.config(); + let seat = config + .seats + .iter() + .find(|seat| seat.id == actor) + .ok_or_else(|| crate::Error::SendDenied("unknown seat".into()))?; + let active = self + .permissions + .active + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(actor) + .cloned(); + if active + .as_ref() + .is_some_and(|scope| scope.session_id.as_deref() != session) + { + return Err(crate::Error::SendDenied( + "send belongs to another active session".into(), + )); + } + let member = active + .as_ref() + .and_then(|scope| Permissions::member(&config, scope)); + let (operation, destinations) = match request { + SendRequest::Agent { agent_id, .. } => ("agent", vec![agent_id.as_str()]), + SendRequest::Hive { hive_id, .. } => ("hive", vec![hive_id.as_str()]), + SendRequest::Ask { + episode_id, agents, .. + } => { + if active + .as_ref() + .and_then(|scope| scope.episode.as_ref()) + .is_none_or(|episode| &episode.episode_id != episode_id) + { + return Err(crate::Error::SendDenied("inactive episode".into())); + } + ("ask", agents.iter().map(String::as_str).collect()) + } + SendRequest::Broadcast { episode_id, .. } => { + let episode = active + .as_ref() + .and_then(|scope| scope.episode.as_ref()) + .filter(|episode| &episode.episode_id == episode_id) + .ok_or_else(|| crate::Error::SendDenied("inactive episode".into()))?; + ("broadcast", vec![episode.hive_id.as_str()]) + } + }; + for policy in config + .permission_layers(seat, member) + .map_err(|_| crate::Error::SendDenied("invalid policy".into()))? + { + if policy + .send_operations + .as_ref() + .is_some_and(|names| !names.iter().any(|name| name == operation)) + || policy.send_destinations.as_ref().is_some_and(|names| { + destinations + .iter() + .any(|id| !names.iter().any(|name| name == id)) + }) + { + return Err(crate::Error::SendDenied("send permission denied".into())); + } + } + Ok(()) + } +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/memory_root.rs b/crates/tinyhivemind-openhuman/src/deploy/memory_root.rs new file mode 100644 index 00000000..09aa9528 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/memory_root.rs @@ -0,0 +1,19 @@ +//! Logical root identities encode injectively into native namespace segments. +use super::{DeployResult, runtime::unsupported}; +/// Encode a logical root as a stable native namespace below `project:hivemind`. +/// Every UTF-8 byte becomes two hex digits, grouped into supported `team` segments. +/// All roots use this encoding, so a logical root resembling an encoded root cannot collide. +/// # Errors +/// Returns `UnsupportedSetting` for blank roots or roots exceeding 384 UTF-8 bytes, +/// the native root's seven-segment bound, reserving one segment for each agent. +pub fn native_memory_root(logical: &str) -> DeployResult { + if logical.trim().is_empty() || logical.len() > 384 { + return Err(unsupported("memory root exceeds native namespace bounds")); + } + let mut root = String::from("project:hivemind"); + for chunk in logical.as_bytes().chunks(64) { + root.push_str("/team:"); + root.extend(chunk.iter().map(|byte| format!("{byte:02x}"))); + } + Ok(root) +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/mod.rs b/crates/tinyhivemind-openhuman/src/deploy/mod.rs new file mode 100644 index 00000000..de0cf626 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/mod.rs @@ -0,0 +1,337 @@ +//! Assemble validated manifests on one `OpenHuman` runtime, without background work during construction. +mod compatibility; +mod factory; +mod hooks; +mod memory_root; +mod permission; +mod profile; +mod rules; +mod runtime; +mod types; +mod workflow; +use crate::{OpenHumanHost, config::ValidatedHiveConfig}; +pub use factory::ManifestFactory; +pub use memory_root::native_memory_root; +use permission::Permissions; +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex, PoisonError}, +}; +use tinyhivemind_hives::{Coordinator, CoordinatorOptions, HiveInfo, HiveSettings, MemoryStorage}; +pub use types::*; +/// One runtime, registered seat handles and host-owned coordinator. +/// Native seat cron creates isolated sessions; coordinator turns continue each seat's bound session. +pub struct HiveDeployment { + runtime: Arc, + host: OpenHumanHost, + seats: Seats, + config: crate::config::HiveConfig, + policies: BTreeMap, + permissions: Arc, + factory: ManifestFactory, + scheduler: Mutex>>, +} +impl std::fmt::Debug for HiveDeployment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HiveDeployment") + .field("runtime_id", &self.runtime.runtime_id()) + .field("seats", &self.seats.keys().collect::>()) + .finish_non_exhaustive() + } +} +impl HiveDeployment { + /// Build using in-process stores and no optional host ports. + /// # Errors + /// Unsupported settings, missing credentials or refused runtime/coordinator operations. + pub async fn build( + config: ValidatedHiveConfig, + secrets: Arc, + ) -> DeployResult { + Box::pin(Self::build_with(config, secrets, BuildOptions::default())).await + } + /// Lower the manifest with injected native host ports, validating compatibility before boot. + /// # Errors + /// Unsupported settings, missing credentials or refused runtime/coordinator operations. + pub async fn build_with( + validated: ValidatedHiveConfig, + secrets: Arc, + options: BuildOptions, + ) -> DeployResult { + let config = validated.into_config(); + runtime::preflight(&config, &options)?; + let runtime = + Arc::new(Box::pin(runtime::build(&config, secrets.as_ref(), &options)).await?); + for profile in &config.profiles { + runtime + .define_template(&profile.id, profile::definition(&config, profile)) + .map_err(|_| DeployError::Agent(profile.id.clone()))?; + } + let coordinator = Coordinator::new( + runtime.runtime_id().into(), + options + .coordinator_storage + .clone() + .unwrap_or_else(|| Arc::new(MemoryStorage::new())), + CoordinatorOptions { + round_width: config.runtime.concurrency, + retention: options.retention, + ..Default::default() + }, + ) + .await + .map_err(|_| DeployError::Coordinator)?; + let permissions = Arc::new(Permissions::new(config.clone(), &options)); + let hooks = Arc::new(hooks::Hooks::new(permissions.clone(), &options)); + let factory = ManifestFactory::new( + runtime.clone(), + permissions.clone(), + secrets.clone(), + options.clone(), + ); + let mut host = OpenHumanHost::new(runtime.runtime_id().into(), coordinator) + .map_err(|_| DeployError::Coordinator)? + .with_hooks(hooks.clone()) + .map_err(|_| DeployError::Coordinator)? + .with_send_policy(hooks) + .map_err(|_| DeployError::Coordinator)? + .with_management( + Arc::new(factory.clone()), + options + .management + .clone() + .unwrap_or_else(|| Arc::new(factory::DenyManagement)), + ) + .map_err(|_| DeployError::Coordinator)?; + // Deployment owns every source in its replacement belt. Legacy hosts + // retain supplied agents' own tools unless they explicitly opt in. + let tools = options.tools.clone().unwrap_or_else(|| { + Arc::new(|_| openhuman_embed::HostTurnTools::advertised(Vec::new())) + }); + host = host + .with_tools(tools) + .map_err(|_| DeployError::Coordinator)?; + let mut seats = BTreeMap::new(); + for seat in &config.seats { + let spec = profile::seat(&config, seat, secrets.as_ref(), permissions.clone())?; + let agent = host + .register_spec(&runtime, spec) + .await + .map_err(|_| DeployError::Agent(seat.id.clone()))?; + seats.insert(seat.id.clone(), agent); + } + let policies = register_hives(&host, &config).await?; + let deployment = Self { + runtime, + host, + seats, + config, + policies, + permissions, + factory, + scheduler: Mutex::new(None), + }; + workflow::register(&deployment)?; + if deployment.config.runtime.start_scheduler { + deployment.start_scheduler()?; + } + Ok(deployment) + } + /// Shared runtime; every seat belongs to this identity. + #[must_use] + pub fn runtime(&self) -> &openhuman_embed::Runtime { + &self.runtime + } + /// Permanent tools and continuing-session coordinator. + #[must_use] + pub fn host(&self) -> &OpenHumanHost { + &self.host + } + /// Fixed seats declared by the manifest. + #[must_use] + pub fn seats(&self) -> &Seats { + &self.seats + } + /// Factory sharing the runtime and typed policy catalog. + #[must_use] + pub fn factory(&self) -> &ManifestFactory { + &self.factory + } + /// Pure deliberation policy for a declared hive; completion uses its separate coordinator settings. + #[must_use] + pub fn policy(&self, hive: &str) -> Option<&HivePolicyView> { + self.policies.get(hive) + } + /// Shared memory view, without rebinding any seat's memory root. + /// # Errors + /// Unknown hive, absent recall root or disabled native memory. + pub fn memory(&self, hive: &str) -> DeployResult { + let root = self + .policy(hive) + .and_then(|policy| policy.memory_root.as_deref()) + .ok_or_else(|| runtime::unsupported("hive.memory_root"))?; + self.runtime + .memory(&native_memory_root(root)?) + .map_err(|_| DeployError::Runtime) + } + /// Replace the fallback consent snapshot for subsequent tool calls. + /// A supplied `ApprovalContext` remains authoritative when present. + pub fn update_approval(&self, snapshot: ApprovalSnapshot) { + *self + .permissions + .snapshot + .lock() + .unwrap_or_else(PoisonError::into_inner) = snapshot; + } + /// Sanitized adapter-owned pending questions, including correlated handler answers. + #[must_use] + pub fn pending(&self) -> Vec { + self.permissions + .pending + .lock() + .unwrap_or_else(PoisonError::into_inner) + .values() + .cloned() + .collect() + } + /// Wait for a correlated handler answer; original tool calls remain refused. + pub async fn wait_decision( + &self, + request_id: &str, + ) -> Option { + loop { + let changed = self.permissions.changed.notified(); + let pending = self + .permissions + .pending + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(request_id) + .cloned()?; + if let Some(answer) = pending.decision { + return Some(answer); + } + changed.await; + } + } + /// Release a parked coordinator seat with the answer as a note, never replaying the original call. + /// # Errors + /// Unknown/unanswered request, isolated cron question or coordinator refusal. + pub async fn release(&self, request_id: &str) -> DeployResult<()> { + let pending = self + .permissions + .pending + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(request_id) + .cloned() + .ok_or(DeployError::Coordinator)?; + let scope = pending.scope.ok_or(DeployError::Coordinator)?; + let answer = pending.decision.ok_or(DeployError::Coordinator)?; + let turn = tinyhivemind_hives::ParkedTurn { + turn_id: scope.turn_id, + session_id: scope.session_id.ok_or(DeployError::Coordinator)?, + episode_id: scope.episode.map(|episode| episode.episode_id), + message_ids: scope.message_ids, + scheduled_job_id: scope.scheduled_job_id, + }; + self.host.coordinator().release_parked(&scope.agent_id, &turn, Some(format!("Host approval decision: {answer:?}. Original call was refused; no effect has been replayed."))).await.map_err(|_| DeployError::Coordinator)?; + self.permissions + .pending + .lock() + .unwrap_or_else(PoisonError::into_inner) + .remove(request_id); + Ok(()) + } + /// Start the native scheduler only after deployment assembly. Idempotent. + /// # Errors + /// Native runtime configuration unavailable. + pub fn start_scheduler(&self) -> DeployResult<()> { + let mut scheduler = self + .scheduler + .lock() + .unwrap_or_else(PoisonError::into_inner); + if scheduler.is_none() { + let config = self + .runtime + .core_runtime() + .context() + .embedder_config() + .cloned() + .ok_or(DeployError::Runtime)?; + *scheduler = Some(tokio::spawn(async move { + let _ = Box::pin(openhuman_core::cron::scheduler::run(config)).await; + })); + } + Ok(()) + } +} +async fn register_hives( + host: &OpenHumanHost, + config: &crate::config::HiveConfig, +) -> DeployResult> { + let mut policies = BTreeMap::new(); + for hive in &config.hives { + host.coordinator() + .create_hive(HiveInfo { + hive_id: hive.id.clone(), + name: if hive.name.is_empty() { + hive.id.clone() + } else { + hive.name.clone() + }, + description: None, + members: hive + .members + .iter() + .map(|member| member.seat.clone()) + .collect(), + }) + .await + .map_err(|_| DeployError::Coordinator)?; + let mut options = hive.coordinator.clone(); + options.conduct_policy.turn_wall = + options.conduct_policy.turn_wall.min(hive.conduct.turn_wall); + options.conduct_policy.child_turn_wall = options + .conduct_policy + .child_turn_wall + .min(hive.conduct.child_turn_wall); + host.coordinator() + .configure_hive( + &hive.id, + HiveSettings { + options, + routing: hive.routing.clone(), + roles: hive + .members + .iter() + .map(|member| (member.seat.clone(), member.role.clone())) + .collect(), + }, + ) + .await + .map_err(|_| DeployError::Coordinator)?; + policies.insert( + hive.id.clone(), + HivePolicyView { + episode: hive.episode, + division: hive.division, + memory_root: hive.memory_root.clone(), + }, + ); + } + Ok(policies) +} +impl Drop for HiveDeployment { + fn drop(&mut self) { + if let Some(task) = self + .scheduler + .get_mut() + .unwrap_or_else(PoisonError::into_inner) + .take() + { + task.abort(); + } + } +} +#[cfg(test)] +mod test; diff --git a/crates/tinyhivemind-openhuman/src/deploy/permission.rs b/crates/tinyhivemind-openhuman/src/deploy/permission.rs new file mode 100644 index 00000000..b05670db --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/permission.rs @@ -0,0 +1,557 @@ +//! Additive tool gates and correlated approval questions. +use super::{ApprovalSnapshot, BuildOptions, EffectClassifier, PendingRequest}; +use crate::{ + TurnScope, + config::{ + Access, HiveConfig, Membership, PermissionProfile, Profile, Sandbox, Seat, ToolScope, + }, +}; +use openhuman_embed::seams::{ToolHookContext, ToolHookDecision}; +use std::{ + collections::BTreeMap, + sync::{ + Arc, Mutex, PoisonError, + atomic::{AtomicU64, Ordering}, + }, +}; +use tinyhivemind_core::{ + approval::{ + Action, ActionTarget, ApprovalDecision, ApprovalPolicy, ApprovalRequest, ApprovalRule, + ApproverRule, DefaultVerdict, Effect, RuleVerdict, approve, + }, + desk::{Desk, DeskSet, ResponderMode}, + dispatch::DispatchConversation, + roster::{Roster, RosterMember}, +}; +pub(super) struct Permissions { + pub config: Mutex, + pub active: Mutex>, + pub pending: Mutex>, + pub snapshot: Mutex, + live_context: Option>, + classifier: Option>, + handler: Option>, + sequence: AtomicU64, + pub changed: tokio::sync::Notify, +} +impl Permissions { + pub(super) fn new(config: HiveConfig, options: &BuildOptions) -> Self { + Self { + config: Mutex::new(config), + active: Mutex::new(BTreeMap::new()), + pending: Mutex::new(BTreeMap::new()), + snapshot: Mutex::new(options.approval.clone()), + live_context: options.approval_context.clone(), + classifier: options.classifier.clone(), + handler: options.approval_handler.clone(), + sequence: AtomicU64::new(0), + changed: tokio::sync::Notify::new(), + } + } + pub(super) fn config(&self) -> HiveConfig { + self.config + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + pub(super) fn member<'a>(config: &'a HiveConfig, scope: &TurnScope) -> Option<&'a Membership> { + scope + .episode + .as_ref() + .and_then(|episode| config.hives.iter().find(|hive| hive.id == episode.hive_id)) + .and_then(|hive| { + hive.members + .iter() + .find(|member| member.seat == scope.agent_id) + }) + } + pub(super) fn decide( + self: &Arc, + seat_id: &str, + scope: Option<&TurnScope>, + context: &ToolHookContext, + ) -> std::future::Ready { + std::future::ready(self.decide_now(seat_id, scope, context)) + } + fn decide_now( + self: &Arc, + seat_id: &str, + scope: Option<&TurnScope>, + context: &ToolHookContext, + ) -> ToolHookDecision { + let captured = scope.cloned().or_else(|| { + self.active + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(seat_id) + .filter(|scope| { + scope.session_id.is_some() && scope.session_id == context.session_id + }) + .cloned() + }); + let scope = captured.as_ref(); + let config = self.config(); + let Some(seat) = config.seats.iter().find(|seat| seat.id == seat_id) else { + return deny("unknown seat"); + }; + let Ok(profile) = config.profile_for(seat) else { + return deny("unknown profile"); + }; + let Ok(layers) = + config.permission_layers(seat, scope.and_then(|scope| Self::member(&config, scope))) + else { + return deny("invalid permission layers"); + }; + let children = seat + .overrides + .subagents + .as_ref() + .unwrap_or(&profile.subagents); + if matches!( + context.tool_name.as_str(), + "hivemind_ask" | "hivemind_broadcast" | "hivemind_post" | "hivemind_complete" + ) && scope.is_none() + { + return deny("episode action requires authenticated continuing session"); + } + let effect = if children + .iter() + .any(|id| context.tool_name == format!("delegate_{id}")) + { + Effect::ReadOnly + } else { + classify(context, self.classifier.as_deref()) + }; + if let Err(reason) = basic_gate(&config, seat, profile, &layers, context, scope, effect) { + return deny(reason); + } + let child = match child_policy(&config, seat, profile, context, effect) { + Ok(child) => child, + Err(reason) => return deny(reason), + }; + let rules = config.runtime.tool_rules.iter().chain( + layers + .iter() + .chain(child.iter()) + .filter_map(|policy| policy.tool_rules.as_ref()), + ); + let force_ask = + match super::rules::gate(rules, context, seat_id, scope, self.classifier.as_deref()) { + Ok(ask) => ask, + Err(reason) => return deny(reason), + }; + let snapshot = self.live_context.as_ref().map_or_else( + || { + self.snapshot + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + }, + |context| context.snapshot(), + ); + let sequence = self.next_sequence(&snapshot); + let mut request = request(seat_id, context, captured.as_ref(), &snapshot, sequence); + request.action.effect = effect; + let access = layers + .iter() + .chain(child.iter()) + .map(|policy| policy.access) + .min() + .unwrap_or(config.runtime.autonomy) + .min(config.runtime.autonomy); + let policies: Vec<_> = layers + .iter() + .chain(child.iter()) + .filter_map(|policy| policy.approval.as_ref()) + .collect(); + match approval_gate(&config, &request, &snapshot, &policies, access) { + Err(reason) => deny(reason), + Ok(ask) if ask || force_ask => { + self.ask(seat_id, context, captured, sequence); + deny("awaiting correlated host approval; original call refused") + } + Ok(_) => ToolHookDecision::Proceed, + } + } + fn next_sequence(&self, snapshot: &ApprovalSnapshot) -> u64 { + self.live_context.as_ref().map_or_else( + || { + self.sequence.fetch_max( + snapshot + .grants + .iter() + .map(|grant| grant.granted_at_sequence) + .max() + .unwrap_or(0), + Ordering::Relaxed, + ); + self.sequence + .fetch_add(1, Ordering::Relaxed) + .saturating_add(1) + }, + |context| context.next_sequence(), + ) + } + fn ask( + self: &Arc, + seat: &str, + context: &ToolHookContext, + scope: Option, + sequence: u64, + ) { + let mut pending = self.pending.lock().unwrap_or_else(PoisonError::into_inner); + if pending.values().any(|request| { + request.request.agent_id.as_deref() == Some(seat) + && request.request.tool_call_id.as_deref() == Some(&context.call_id) + && request.scope == scope + }) { + return; + } + let id = format!("hive-approval-{sequence}"); + let mut native = openhuman_embed::PendingApproval::new( + &id, + &context.tool_name, + "classified action requires approval", + serde_json::json!({"argument_count":context.arguments.as_object().map_or(0, serde_json::Map::len)}), + None, + ); + native.tool_call_id = Some(context.call_id.clone()); + native.agent_id = Some(seat.into()); + pending.insert( + id.clone(), + PendingRequest { + request: native.clone(), + scope, + decision: None, + }, + ); + drop(pending); + if let Some(handler) = &self.handler { + let handler = handler.clone(); + let weak = Arc::downgrade(self); + tokio::spawn(async move { + let answer = handler.decide(&native).await; + if let Some(engine) = weak.upgrade() { + if let Some(pending) = engine + .pending + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get_mut(&id) + { + pending.decision = Some(answer); + } + engine.changed.notify_waiters(); + } + }); + } + } +} +fn basic_gate( + config: &HiveConfig, + seat: &Seat, + profile: &Profile, + layers: &[&PermissionProfile], + context: &ToolHookContext, + scope: Option<&TurnScope>, + effect: Effect, +) -> Result<(), &'static str> { + let children = seat + .overrides + .subagents + .as_ref() + .unwrap_or(&profile.subagents); + if context + .agent_id + .as_ref() + .is_none_or(|id| id != &seat.id && !children.contains(id)) + { + return Err("unauthenticated actor"); + } + let tool_scope = seat + .overrides + .tool_scope + .as_ref() + .unwrap_or(&profile.tool_scope); + if matches!(tool_scope, ToolScope::Named(names) if !names.contains(&context.tool_name)) { + return Err("tool scope denied"); + } + let external_mutation = effect == Effect::Mutating && !internal(&context.tool_name); + let scheduled = scope.is_some_and(|scope| scope.scheduled_job_id.is_some()) + || matches!( + openhuman_core::agent::turn_origin::current(), + Some( + openhuman_core::agent::turn_origin::AgentTurnOrigin::TrustedAutomation { + source: openhuman_core::agent::turn_origin::TrustedAutomationSource::Cron, + .. + } + ) + ); + if scheduled + && scope.is_none() + && internal(&context.tool_name) + && !matches!( + context.tool_name.as_str(), + "hivemind_read" | "hivemind_list_hives" | "hivemind_list_agents" + ) + { + return Err("isolated cron has no conductor send authority"); + } + if effect == Effect::Unclassified || scheduled && external_mutation { + return Err("unknown or scheduled external effect"); + } + if matches!(tool_scope, ToolScope::HostOnly) && external_mutation { + return Err("host-only tools must be readonly"); + } + if layers + .iter() + .any(|policy| profile_denies(policy, context, external_mutation)) + || (config.runtime.autonomy == Access::ReadOnly + || config.runtime.sandbox == Sandbox::ReadOnly) + && external_mutation + || profile.deny_tools.contains(&context.tool_name) + || seat.overrides.deny_tools.contains(&context.tool_name) + { + return Err("tool permission denied"); + } + Ok(()) +} +fn profile_denies( + policy: &PermissionProfile, + context: &ToolHookContext, + external_mutation: bool, +) -> bool { + policy.deny_tools.contains(&context.tool_name) + || policy + .allow_tools + .as_ref() + .is_some_and(|names| !names.contains(&context.tool_name)) + || (policy.access == Access::ReadOnly || policy.sandbox == Sandbox::ReadOnly) + && external_mutation +} +fn child_policy<'a>( + config: &'a HiveConfig, + seat: &Seat, + _: &Profile, + context: &ToolHookContext, + effect: Effect, +) -> Result, &'static str> { + let Some(id) = context.agent_id.as_ref().filter(|id| *id != &seat.id) else { + return Ok(None); + }; + let Some(child) = config.profiles.iter().find(|profile| &profile.id == id) else { + return Err("unknown child"); + }; + if child.deny_tools.contains(&context.tool_name) + || matches!(&child.tool_scope, ToolScope::Named(names) if !names.contains(&context.tool_name)) + || child.tool_scope == ToolScope::HostOnly + && effect == Effect::Mutating + && !internal(&context.tool_name) + { + return Err("child tool denied"); + } + let policy = child.permission_profile.as_ref().and_then(|id| { + config + .permission_profiles + .iter() + .find(|policy| &policy.id == id) + }); + if policy.is_some_and(|policy| { + profile_denies( + policy, + context, + effect == Effect::Mutating && !internal(&context.tool_name), + ) + }) { + return Err("child permission denied"); + } + Ok(policy) +} +fn request( + seat_id: &str, + context: &ToolHookContext, + scope: Option<&TurnScope>, + snapshot: &ApprovalSnapshot, + sequence: u64, +) -> ApprovalRequest { + let conversation = scope.map_or( + DispatchConversation { + desk_id: format!("direct:{seat_id}"), + thread_root: None, + }, + |scope| DispatchConversation { + desk_id: scope.episode.as_ref().map_or_else( + || format!("direct:{seat_id}"), + |episode| episode.hive_id.clone(), + ), + thread_root: scope.thread, + }, + ); + ApprovalRequest { + epoch: snapshot.epoch, + sequence, + call_id: context.call_id.clone(), + actor_id: seat_id.into(), + conversation, + action: Action { + verb: context.tool_name.clone(), + target: context + .arguments + .get("path") + .and_then(serde_json::Value::as_str) + .map_or_else( + || ActionTarget::Named { + name: context.tool_name.clone(), + }, + |path| ActionTarget::Resource { path: path.into() }, + ), + effect: classify(context, None), + }, + } +} +fn approval_gate( + config: &HiveConfig, + request: &ApprovalRequest, + snapshot: &ApprovalSnapshot, + policies: &[&ApprovalPolicy], + access: Access, +) -> Result { + let members: Vec<_> = config + .seats + .iter() + .map(|seat| RosterMember { + id: seat.id.clone(), + name: None, + }) + .collect(); + let roster = Roster::new(&members, &snapshot.people, &[]); + let desks: Vec<_> = config + .hives + .iter() + .map(|hive| Desk { + id: hive.id.clone(), + name: hive.id.clone(), + description: None, + members: hive + .members + .iter() + .map(|member| member.seat.clone()) + .collect(), + responder_mode: ResponderMode::Lead, + }) + .collect(); + let desks = DeskSet::new(&desks, &[], &[], &[], &[]); + let fallback = default_policy(access); + let policies = if policies.is_empty() { + vec![&fallback] + } else { + policies.to_vec() + }; + let mut ask = false; + for policy in policies { + match approve( + request, + policy, + &snapshot.grants, + &snapshot.refusals, + &roster, + &desks, + snapshot.now, + ) { + ApprovalDecision::Deny { .. } => return Err("core approval denied"), + ApprovalDecision::Ask { .. } => ask = true, + ApprovalDecision::Allow { .. } => {} + } + } + Ok(ask) +} +pub(super) fn deny(reason: &str) -> ToolHookDecision { + ToolHookDecision::Deny(reason.into()) +} +pub(super) fn internal(name: &str) -> bool { + matches!( + name, + "hivemind_send_agent" + | "hivemind_send_hive" + | "hivemind_ask" + | "hivemind_broadcast" + | "hivemind_post" + | "hivemind_complete" + | "hivemind_read" + | "hivemind_list_agents" + | "hivemind_list_hives" + ) +} +pub(super) fn classify(context: &ToolHookContext, custom: Option<&dyn EffectClassifier>) -> Effect { + if internal(&context.tool_name) + || matches!( + context.tool_name.as_str(), + "read" + | "read_file" + | "file_read" + | "glob" + | "grep" + | "ls" + | "list_files" + | "recall" + | "memory_recall" + | "tool_search" + | "mcp_list_tools" + | "mcp_list_servers" + ) + { + Effect::ReadOnly + } else if matches!( + context.tool_name.as_str(), + "shell" + | "bash" + | "exec" + | "write" + | "write_file" + | "file_write" + | "edit" + | "file_edit" + | "apply_patch" + | "remember" + | "memory_remember" + | "http_request" + | "fetch" + | "web_fetch" + | "hivemind_create_agent" + | "hivemind_create_hive" + | "hivemind_join_hive" + | "hivemind_leave_hive" + ) { + Effect::Mutating + } else { + custom.map_or(Effect::Unclassified, |classifier| { + classifier.classify(context) + }) + } +} +fn default_policy(access: Access) -> ApprovalPolicy { + ApprovalPolicy { + enabled: true, + default: DefaultVerdict::Deny, + rules: vec![ + ApprovalRule { + effect: Some(Effect::ReadOnly), + verb: None, + target: None, + verdict: RuleVerdict::Allow, + }, + ApprovalRule { + effect: Some(Effect::Mutating), + verb: None, + target: None, + verdict: if access == Access::Autonomous { + RuleVerdict::Allow + } else { + RuleVerdict::Deny + }, + }, + ], + approver: ApproverRule::Absent, + allow_grants: false, + max_grant_ttl: None, + } +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/profile.rs b/crates/tinyhivemind-openhuman/src/deploy/profile.rs new file mode 100644 index 00000000..64062919 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/profile.rs @@ -0,0 +1,217 @@ +//! Definitions, immutable connections and fixed seat lowering. +use super::{DeployError, DeployResult, SecretResolver, permission::Permissions}; +use crate::config::{HiveConfig, Limits, Profile, Seat, ToolScope}; +use openhuman_embed::{AgentDefinitionSpec, AgentSpec, MemoryBinding, ToolScopeSpec}; +use std::sync::Arc; +pub(super) fn limits(config: &HiveConfig, seat: &Seat) -> DeployResult { + let profile = config.profile_for(seat)?; + Ok(Limits { + timeout_ms: seat + .overrides + .limits + .timeout_ms + .or(profile.limits.timeout_ms) + .or(config.runtime.limits.timeout_ms), + iterations: seat + .overrides + .limits + .iterations + .or(profile.limits.iterations) + .or(config.runtime.limits.iterations), + budget_usd: seat + .overrides + .limits + .budget_usd + .or(profile.limits.budget_usd) + .or(config.runtime.limits.budget_usd), + }) +} +pub(super) fn scope(value: &ToolScope) -> ToolScopeSpec { + match value { + ToolScope::Wildcard => ToolScopeSpec::Wildcard, + ToolScope::Named(names) => ToolScopeSpec::Named(names.clone()), + ToolScope::HostOnly => ToolScopeSpec::HostOnly, + } +} +pub(super) fn prompt(config: &HiveConfig, profile: &Profile, seat: Option<&Seat>) -> String { + let mut prompt = seat + .and_then(|seat| seat.overrides.system_prompt.clone()) + .unwrap_or_else(|| profile.system_prompt.clone()); + for id in profile + .context + .iter() + .chain(seat.into_iter().flat_map(|seat| &seat.overrides.context)) + { + if let Some(context) = config.contexts.get(id) { + prompt.push('\n'); + prompt.push_str(context); + } + } + prompt +} +pub(super) fn definition(config: &HiveConfig, profile: &Profile) -> AgentDefinitionSpec { + let mut definition = profile + .definition_base + .as_ref() + .map_or_else(AgentDefinitionSpec::new, |base| { + AgentDefinitionSpec::from_base(base.clone()) + }); + definition = if profile.bare_prompt { + definition.bare_prompt(prompt(config, profile, None)) + } else { + definition.system_prompt(prompt(config, profile, None)) + }; + definition = definition + .tools(scope(&profile.tool_scope)) + .disallow_tools(profile.deny_tools.clone()); + if let Some(n) = profile.limits.iterations { + definition = definition.max_iterations(n); + } + if let Some(t) = profile.model.temperature { + definition = definition.temperature(t); + } + if let Some(policy) = profile.permission_profile.as_ref().and_then(|id| { + config + .permission_profiles + .iter() + .find(|policy| &policy.id == id) + }) { + definition = definition.sandbox(super::runtime::sandbox(policy.sandbox)); + if let Some(rules) = &policy.tool_rules { + definition = definition.tool_rules(rules.clone()); + } + } + definition +} +pub(super) fn mcp( + server: &crate::config::McpServer, + secrets: &dyn SecretResolver, +) -> DeployResult { + let mut native = if server.transport == "stdio" { + openhuman_embed::McpServer::stdio(&server.id, &server.endpoint, server.args.clone()) + } else { + openhuman_embed::McpServer::http(&server.id, &server.endpoint) + }; + let env = server + .env + .iter() + .map(|(name, value)| Ok((name.clone(), secrets.resolve(value)?))) + .collect::>>()?; + native = native.env(env).deny_tools(server.deny_tools.clone()); + if let Some(tools) = &server.tools { + native = native.allow_tools(tools.clone()); + } + if !server.headers.is_empty() { + let headers = server + .headers + .iter() + .map(|(name, value)| { + Ok(openhuman_embed::HttpHeader { + name: name.clone(), + value: secrets.resolve(value)?, + }) + }) + .collect::>>()?; + native = native.auth(openhuman_embed::McpAuthConfig::Headers { headers }); + } + Ok(native) +} +pub(super) fn seat( + config: &HiveConfig, + seat: &Seat, + secrets: &dyn SecretResolver, + permissions: Arc, +) -> DeployResult { + let profile = config.profile_for(seat)?; + let limits = limits(config, seat)?; + let permission_layers = config.permission_layers(seat, None)?; + let access = permission_layers + .iter() + .map(|policy| policy.access) + .min() + .unwrap_or(config.runtime.autonomy) + .min(config.runtime.autonomy); + let mut definition = AgentDefinitionSpec::new() + .tools(scope( + seat.overrides + .tool_scope + .as_ref() + .unwrap_or(&profile.tool_scope), + )) + .disallow_tools(seat.overrides.deny_tools.clone()); + if profile.bare_prompt { + definition = definition.bare_prompt(prompt(config, profile, Some(seat))); + } else { + definition = definition.system_prompt(prompt(config, profile, Some(seat))); + } + let root = seat + .overrides + .memory + .as_ref() + .or(profile.memory.as_ref()) + .and_then(|memory| memory.root.clone()) + .unwrap_or_else(|| format!("seat:{}", seat.id)); + let mut model = profile.model.clone(); + model.temperature = seat.overrides.model.temperature.or(model.temperature); + model.max_tokens = seat.overrides.model.max_tokens.or(model.max_tokens); + model.name = seat.overrides.model.name.clone().or(model.name); + if permission_layers + .iter() + .any(|policy| policy.sandbox == crate::config::Sandbox::ReadOnly) + { + definition = definition.sandbox(openhuman_embed::SandboxModeSpec::ReadOnly); + } else if permission_layers + .iter() + .any(|policy| policy.sandbox == crate::config::Sandbox::Sandboxed) + { + definition = definition.sandbox(openhuman_embed::SandboxModeSpec::Sandboxed); + } + let mut spec = AgentSpec::new(&seat.id) + .extends(&seat.profile) + .definition(definition) + .access(super::runtime::access(access)) + .memory(MemoryBinding::new(&seat.id).root(super::native_memory_root(&root)?)) + .model_defaults(super::runtime::model(&model, limits.iterations)); + if let Some(name) = &model.name { + spec = spec.model(name); + } + if let Some(include) = profile.include_user_skills { + spec = spec.include_user_skills(include); + } + if let Some(path) = profile.skills.first().or(seat.overrides.skills.first()) { + spec = spec.skills_dir(path); + } + for server in config.normalized_mcp(seat)? { + if !config + .runtime + .mcp + .iter() + .any(|baseline| baseline.id == server.id) + { + spec = spec.mcp(mcp(&server, secrets)?); + } + } + let subagents = seat + .overrides + .subagents + .as_ref() + .unwrap_or(&profile.subagents); + let mut definitions = Vec::new(); + for id in subagents { + let child = config + .profiles + .iter() + .find(|profile| &profile.id == id) + .ok_or_else(|| { + DeployError::Config(crate::config::ConfigError::UnknownProfile(id.clone())) + })?; + definitions.push((id.clone(), self::definition(config, child))); + } + spec = spec.subagents(definitions); + let id = seat.id.clone(); + Ok(spec.can_use_tool(move |context| { + let permissions = permissions.clone(); + let id = id.clone(); + Box::pin(async move { permissions.decide(&id, None, context).await }) + })) +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/rules.rs b/crates/tinyhivemind-openhuman/src/deploy/rules.rs new file mode 100644 index 00000000..c9f2c98d --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/rules.rs @@ -0,0 +1,84 @@ +//! Argument rules use authenticated context and refuse unknown required metadata. +use super::EffectClassifier; +use crate::TurnScope; +use openhuman_embed::seams::ToolHookContext; +use tinytools::{ApprovalDirective, RuleContext, Surface, ToolMatcher, ToolRules, ToolSubject}; +pub(super) fn gate<'a>( + rules: impl Iterator, + context: &ToolHookContext, + seat_id: &str, + scope: Option<&TurnScope>, + classifier: Option<&dyn EffectClassifier>, +) -> Result { + let mut subject = classifier + .and_then(|classifier| classifier.subject(context)) + .unwrap_or_else(|| ToolSubject::named(&context.tool_name)); + // A host can supply metadata, never replace the authenticated tool name. + subject.name.clone_from(&context.tool_name); + let mut attributes = classifier.map_or_else(RuleContext::new, |classifier| { + classifier.rule_context(context) + }); + attributes + .attributes + .insert("agent_id".into(), seat_id.into()); + attributes.attributes.remove("hive_id"); + attributes.attributes.remove("episode_id"); + attributes.attributes.remove("job_id"); + if let Some(session) = &context.session_id { + attributes + .attributes + .insert("session_id".into(), session.clone()); + } else { + attributes.attributes.remove("session_id"); + } + if let Some(scope) = scope { + if let Some(episode) = &scope.episode { + attributes + .attributes + .insert("hive_id".into(), episode.hive_id.clone()); + attributes + .attributes + .insert("episode_id".into(), episode.episode_id.clone()); + } + if let Some(job) = &scope.scheduled_job_id { + attributes.attributes.insert("job_id".into(), job.clone()); + } + } + let mut ask = false; + for rules in rules { + for rule in &rules.rules { + if !rule.on.is_empty() && !rule.on.contains(&Surface::Call) { + continue; + } + if unknown(&rule.matcher, &subject) + || rule + .except + .as_ref() + .is_some_and(|matcher| unknown(matcher, &subject)) + || rule.when.keys().any(|key| attributes.get(key).is_none()) + { + return Err("tool rule requires unknown metadata"); + } + } + let verdict = rules.evaluate( + &subject, + &attributes, + Surface::Call, + Some(&context.arguments), + ); + if !verdict.callable { + return Err("tool rule denied"); + } + ask |= verdict.approval == ApprovalDirective::Required; + } + Ok(ask) +} +fn unknown(matcher: &ToolMatcher, subject: &ToolSubject) -> bool { + matcher.family.is_some() && subject.family.is_none() + || matcher.tags.is_some() && subject.tags.is_empty() + || matcher.category.is_some() && subject.category.is_none() + || matcher.exposure.is_some() && subject.exposure.is_none() + || (matcher.permission_at_least.is_some() || matcher.permission_at_most.is_some()) + && subject.permission.is_none() + || matcher.side_effects.is_some() && subject.side_effects.is_none() +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/runtime.rs b/crates/tinyhivemind-openhuman/src/deploy/runtime.rs new file mode 100644 index 00000000..423dd423 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/runtime.rs @@ -0,0 +1,156 @@ +//! Native runtime construction and early lowering compatibility checks. +use super::{BuildOptions, DeployError, DeployResult, SecretResolver}; +use crate::config::{Access, HiveConfig, Model, Sandbox}; +use openhuman_embed::{Runtime, RuntimeBuilder, RuntimeModule, SkillsPolicy, Workspace}; +use std::sync::Arc; +pub(super) fn unsupported(setting: &str) -> DeployError { + crate::config::ConfigError::UnsupportedSetting(setting.into()).into() +} +pub(super) use super::compatibility::preflight; +pub(super) fn access(value: Access) -> openhuman_embed::Access { + match value { + Access::ReadOnly => openhuman_embed::Access::readonly(), + Access::Supervised => openhuman_embed::Access::supervised(), + Access::Autonomous => openhuman_embed::Access::full(), + } +} +pub(super) fn sandbox(value: Sandbox) -> openhuman_embed::SandboxModeSpec { + match value { + Sandbox::None => openhuman_embed::SandboxModeSpec::None, + Sandbox::ReadOnly => openhuman_embed::SandboxModeSpec::ReadOnly, + Sandbox::Sandboxed => openhuman_embed::SandboxModeSpec::Sandboxed, + } +} +pub(super) fn model(value: &Model, iterations: Option) -> openhuman_embed::ModelDefaults { + openhuman_embed::ModelDefaults { + temperature: value.temperature, + max_tokens: value.max_tokens, + max_iterations: iterations, + ..Default::default() + } +} +pub(super) async fn build( + config: &HiveConfig, + secrets: &dyn SecretResolver, + options: &BuildOptions, +) -> DeployResult { + let section = &config.runtime; + let mut native = options.config.clone().unwrap_or_default(); + native.agent.tool_dispatcher = "auto".into(); + native.scheduler.max_concurrent = section.concurrency; + native.memory.recall.enabled = section.memory_engine != "disabled"; + native.memory.conversations.enabled = section.memory_engine != "disabled"; + let mut builder = RuntimeBuilder::new() + .config(native) + .modules([ + RuntimeModule::Agent, + RuntimeModule::Inference, + RuntimeModule::Memory, + RuntimeModule::Mcp, + RuntimeModule::Skills, + RuntimeModule::Automation, + ]) + .services(openhuman_embed::ServiceSet::none()) + .access(access(section.autonomy)) + .sandbox(sandbox(section.sandbox)) + .model_defaults(model(§ion.model, section.limits.iterations)) + .skills(SkillsPolicy { + root: section.skills.first().map(Into::into), + include_user_skills: section.include_user_skills, + }); + builder = match §ion.workspace { + Some(path) => builder.workspace_dir(path), + None => builder.workspace(Workspace::Ephemeral), + }; + if let Some(url) = &options.backend_url { + builder = builder.backend_url(url); + } + builder = builder.max_agents(section.max_agents.unwrap_or(usize::MAX)); + if let Some(rules) = §ion.tool_rules { + builder = builder.tool_rules(rules.clone()); + } + builder = builder.provider(provider(section, secrets, options)?); + match section.session_store.as_str() { + "memory" => { + builder = + builder.session_store(Arc::new(openhuman_embed::InMemorySessionStores::new())); + } + "host" => { + if let Some(port) = &options.session_store { + builder = builder.session_store(port.clone()); + } + } + "sqlite" => { + let path = section + .workspace + .as_ref() + .ok_or_else(|| unsupported("runtime.workspace"))?; + std::fs::create_dir_all(path).map_err(|_| DeployError::Runtime)?; + builder = builder.storage(format!("sqlite://{path}/sessions.sqlite")); + } + _ => return Err(unsupported("runtime.session_store")), + } + match section.memory_engine.as_str() { + "disabled" => {} + "memory" => { + builder = builder.memory_engine(Arc::new( + tinymemory_api::conformance::reference::ReferenceEngine::new(), + )); + } + "host" => { + if let Some(port) = &options.memory_engine { + builder = builder.memory_engine(port.clone()); + } + } + _ => return Err(unsupported("runtime.memory_engine")), + } + let mut servers = Vec::new(); + for server in §ion.mcp { + servers.push(super::profile::mcp(server, secrets)?); + } + Box::pin(builder.mcp_baseline(servers).build()) + .await + .map_err(|_| DeployError::Runtime) +} + +pub(super) fn budget_micros(usd: f64) -> Option { + format!("{:.0}", (usd * 1_000_000.0).floor()).parse().ok() +} + +fn provider( + section: &crate::config::RuntimeSection, + secrets: &dyn SecretResolver, + options: &BuildOptions, +) -> DeployResult { + let mut provider = if let Some(provider) = &options.provider { + provider.clone() + } else if let Some(endpoint) = + section + .provider + .endpoint + .as_deref() + .or(match section.provider.kind.as_str() { + "openrouter" => Some("https://openrouter.ai/api/v1"), + "openai" => Some("https://api.openai.com/v1"), + _ => None, + }) + { + let key = section + .provider + .credential + .as_ref() + .map(|reference| secrets.resolve(reference)) + .transpose()? + .unwrap_or_default(); + openhuman_embed::Provider::openai_compatible(endpoint, key) + } else { + openhuman_embed::Provider::inherit() + }; + if let Some(name) = §ion.model.name { + provider = provider.model(name); + } + Ok(provider) +} +#[cfg(test)] +#[path = "test/runtime.rs"] +mod test; diff --git a/crates/tinyhivemind-openhuman/src/deploy/test/README.md b/crates/tinyhivemind-openhuman/src/deploy/test/README.md new file mode 100644 index 00000000..2a5ab1fc --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/test/README.md @@ -0,0 +1,23 @@ +# Deployment regression tests + +All model and MCP traffic uses loopback wiremock fixtures. Native runtime tests +share the host tests' process lock; approval folds need no native runtime. + +| File | Behavior | +| --- | --- | +| `mod.rs` | Missing host ports fail before runtime construction | +| `support.rs` | Manifest, runtime, context and credential fixtures | +| `preflight.rs` | Unsupported native representations and reservation bounds | +| `construction.rs` | One runtime, roles, memory, storage and cycle-free drop | +| `permission.rs` | Captured restrictions, unknown effects and explicit Ask handler | +| `turns.rs` | Actual effects, continuing roles, parking and release | +| `factory.rs` | Typed dynamic creation and catalog restrictions | +| `workflow.rs` | Concurrent invocation identity, isolation and durable private-child origin | +| `mcp.rs` | Real MCP envelopes refuse scheduled effects before remote execution | +| `cancellation.rs` | Native deadlines reap registered subprocesses before returning | +| `delegation.rs` | Real native delegates preserve referenced-child effect restrictions | +| `sends.rs` | All four sends enforce membership, destinations and authenticated sessions | +| `runtime.rs` | Provider credentials, diagnostic redaction and native bound mappings | +| `profile.rs` | Native inherited definitions, memory-safe tool surfaces, skills and MCP configuration | + +`timeout.rs` verifies inherited deadlines and host-hook narrowing. diff --git a/crates/tinyhivemind-openhuman/src/deploy/test/cancellation.rs b/crates/tinyhivemind-openhuman/src/deploy/test/cancellation.rs new file mode 100644 index 00000000..6a539c76 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/test/cancellation.rs @@ -0,0 +1,140 @@ +//! Native deadlines return only after owned subprocess cleanup has finished. +#[cfg(unix)] +use super::{ + support::*, + turns::{Script, message, mount}, +}; +#[cfg(unix)] +use crate::deploy::*; +#[cfg(unix)] +use std::{ + path::PathBuf, + sync::{Arc, PoisonError}, + time::Duration, +}; +#[cfg(unix)] +struct SlowTool(PathBuf); +#[cfg(unix)] +#[async_trait::async_trait] +impl tinytools::Tool for SlowTool { + fn name(&self) -> &'static str { + "slow" + } + fn description(&self) -> &'static str { + "Run a registered cancellable subprocess" + } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type":"object","properties":{}}) + } + async fn execute(&self, _: serde_json::Value) -> anyhow::Result { + let mut command = tokio::process::Command::new("sh"); + command + .args(["-c", "printf %s $$ > \"$1\"; exec sleep 600", "sh"]) + .arg(&self.0); + let output = openhuman_embed::process::command_output( + &mut command, + Vec::new(), + Duration::from_secs(7200), + ) + .await?; + Ok(tinytools::ToolResult::success(String::from_utf8_lossy( + &output.stdout, + ))) + } + fn policy(&self) -> tinytools::ToolPolicy { + tinytools::ToolPolicy::classified().with_side_effects(tinytools::ToolSideEffects { + destructive: true, + ..Default::default() + }) + } +} +#[cfg(unix)] +struct Classifier; +#[cfg(unix)] +impl EffectClassifier for Classifier { + fn classify( + &self, + context: &openhuman_embed::seams::ToolHookContext, + ) -> tinyhivemind_core::approval::Effect { + if context.tool_name == "slow" { + tinyhivemind_core::approval::Effect::Mutating + } else { + tinyhivemind_core::approval::Effect::Unclassified + } + } +} +#[cfg(unix)] +#[test] +fn expired_turn_reaps_a_registered_command_before_returning() -> anyhow::Result<()> { + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + std::thread::Builder::new() + .stack_size(16 * 1024 * 1024) + .spawn(|| -> anyhow::Result<()> { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(async { + let directory = tempfile::tempdir()?; + let marker = directory.path().join("pid"); + let (mut options, _backend, model) = build_options().await; + mount( + &model, + Script { + action: Some("slow".into()), + ..Default::default() + }, + ) + .await; + let tool_marker = marker.clone(); + options.tools = Some(Arc::new(move |_| { + openhuman_embed::HostTurnTools::advertised(vec![Box::new(SlowTool( + tool_marker.clone(), + ))]) + })); + options.classifier = Some(Arc::new(Classifier)); + let mut config = manifest()?; + config.profiles[0].limits.timeout_ms = Some(3_600_000); + let deployment = Box::pin(HiveDeployment::build_with( + config.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + deployment + .host() + .coordinator() + .send_as_host(message("slow", "a")) + .await?; + let coordinator = deployment.host().coordinator().clone(); + let task = tokio::spawn(async move { coordinator.run_until_idle().await }); + let pid = tokio::time::timeout(Duration::from_secs(60), async { + loop { + if let Ok(pid) = std::fs::read_to_string(&marker) + && !pid.is_empty() + { + break pid; + } + tokio::task::yield_now().await; + } + }) + .await?; + tokio::time::pause(); + tokio::time::advance(Duration::from_secs(3601)).await; + let report = task.await??; + tokio::time::resume(); + assert!(report.failed > 0); + let alive = std::process::Command::new("kill") + .args(["-0", pid.trim()]) + .output()? + .status + .success(); + assert!(!alive, "runner returned before the child was reaped"); + Ok::<_, anyhow::Error>(()) + }) + })? + .join() + .map_err(|_| anyhow::anyhow!("fixture thread failed"))??; + Ok(()) +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/test/construction.rs b/crates/tinyhivemind-openhuman/src/deploy/test/construction.rs new file mode 100644 index 00000000..1a6db394 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/test/construction.rs @@ -0,0 +1,188 @@ +//! One-runtime registration, limits, native storage and cycle-free ownership. +use super::support::*; +use crate::deploy::*; +use std::sync::{Arc, PoisonError}; +#[test] +fn ordinary_host_registration_preserves_supplied_agent_tools() -> anyhow::Result<()> { + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + run(async { + let (options, _backend, model) = build_options().await; + super::turns::mount( + &model, + super::turns::Script { + action: Some("write".into()), + ..Default::default() + }, + ) + .await; + let deployment = Box::pin(HiveDeployment::build_with( + manifest()?.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + let count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let supplied = super::turns::source(count.clone()); + let agent = deployment.runtime().agent( + openhuman_embed::AgentSpec::new("legacy") + .access(openhuman_embed::Access::full()) + .tools(move |context| supplied(context)), + )?; + let coordinator = tinyhivemind_hives::Coordinator::new( + deployment.runtime().runtime_id().into(), + Arc::new(tinyhivemind_hives::MemoryStorage::new()), + tinyhivemind_hives::CoordinatorOptions::default(), + ) + .await?; + let host = + crate::OpenHumanHost::new(deployment.runtime().runtime_id().into(), coordinator)?; + host.register_agent(agent).await?; + let mut message = super::turns::message("legacy", "a"); + message.destination = tinyhivemind_hives::Destination::Agent("legacy".into()); + message.starters.clear(); + host.coordinator().send_as_host(message).await?; + let report = host.coordinator().run_until_idle().await?; + assert_eq!(report.failed, 0, "{report:?}"); + assert_eq!(count.load(std::sync::atomic::Ordering::Relaxed), 1); + Ok(()) + }) +} +#[test] +fn builds_two_hives_and_fixed_memory_seats_then_releases_runtime() -> anyhow::Result<()> { + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + run(async { + let (options, _backend, _model) = build_options().await; + let mut config = manifest()?; + config.runtime.memory_engine = "memory".into(); + config.profiles[0].limits.iterations = Some(7); + config.seats[0].overrides.memory = Some(crate::config::Memory { + root: Some("explicit".into()), + }); + config.hives[0].memory_root = Some("shared".into()); + config.hives[0].name = "Shared desk".into(); + let deployment = Box::pin(HiveDeployment::build_with( + config.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + assert_eq!(deployment.seats().len(), 2); + deployment.update_approval(ApprovalSnapshot::default()); + deployment.start_scheduler()?; + let scheduler_id = deployment + .scheduler + .lock() + .unwrap_or_else(PoisonError::into_inner) + .as_ref() + .ok_or_else(|| anyhow::anyhow!("scheduler did not start"))? + .id(); + deployment.start_scheduler()?; + assert_eq!( + deployment + .scheduler + .lock() + .unwrap_or_else(PoisonError::into_inner) + .as_ref() + .ok_or_else(|| anyhow::anyhow!("scheduler disappeared"))? + .id(), + scheduler_id + ); + + assert!( + deployment + .seats() + .values() + .all(|agent| agent.runtime_id() == deployment.runtime().runtime_id()) + ); + assert_eq!(deployment.host().coordinator().list_hives()?.len(), 2); + assert_eq!( + deployment + .host() + .coordinator() + .hive_settings("a")? + .ok_or_else(|| anyhow::anyhow!("missing settings"))? + .roles + .get("alice") + .map(String::as_str), + Some("lead") + ); + assert_eq!( + deployment + .policy("a") + .and_then(|policy| policy.memory_root.as_deref()), + Some("shared") + ); + assert_eq!( + deployment.memory("a")?.root(), + native_memory_root("shared")? + ); + assert_eq!( + deployment.seats()["alice"].config().memory.root.as_deref(), + Some(native_memory_root("explicit")?.as_str()) + ); + assert_eq!( + deployment.seats()["bob"].config().memory.root.as_deref(), + Some(native_memory_root("seat:bob")?.as_str()) + ); + assert!(deployment.memory("b").is_err()); + assert!(format!("{deployment:?}").contains("alice")); + assert!(!format!("{:?}", deployment.factory()).contains("fixture")); + drop(deployment); + let (options, _backend2, _model2) = build_options().await; + let deployment = Box::pin(HiveDeployment::build_with( + manifest()?.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + assert_eq!(deployment.runtime().agent_ids().len(), 2); + Ok::<_, anyhow::Error>(()) + }) +} +#[test] +fn sqlite_and_host_storage_lower_to_native_ports() -> anyhow::Result<()> { + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + run(async { + let directory = tempfile::tempdir()?; + let mut config = manifest()?; + config.runtime.workspace = Some(directory.path().to_string_lossy().into_owned()); + config.runtime.session_store = "sqlite".into(); + let (options, _backend, _model) = build_options().await; + let deployment = Box::pin(HiveDeployment::build_with( + config.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + assert!(deployment.runtime().storage().is_some()); + assert!(directory.path().join("sessions.sqlite").exists()); + drop(deployment); + let mut config = manifest()?; + config.runtime.session_store = "host".into(); + config.runtime.memory_engine = "host".into(); + let (mut options, _backend2, _model2) = build_options().await; + options.session_store = Some(Arc::new(openhuman_embed::InMemorySessionStores::new())); + options.memory_engine = Some(Arc::new( + tinymemory_api::conformance::reference::ReferenceEngine::new(), + )); + let deployment = Box::pin(HiveDeployment::build_with( + config.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + assert!( + deployment + .runtime() + .memory(&native_memory_root("host-shared")?) + .is_ok() + ); + Ok::<_, anyhow::Error>(()) + }) +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/test/delegation.rs b/crates/tinyhivemind-openhuman/src/deploy/test/delegation.rs new file mode 100644 index 00000000..22064e2d --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/test/delegation.rs @@ -0,0 +1,109 @@ +//! Native delegation preserves the child's declared restrictions. +use super::{ + support::*, + turns::{message, source}, +}; +use crate::{config::Profile, deploy::HiveDeployment}; +use serde_json::{Value, json}; +use std::sync::{ + Arc, PoisonError, + atomic::{AtomicUsize, Ordering}, +}; +use wiremock::{Request, Respond, ResponseTemplate}; + +#[derive(Clone)] +struct DelegateScript(Arc); +impl Respond for DelegateScript { + fn respond(&self, request: &Request) -> ResponseTemplate { + let Ok(body) = serde_json::from_slice::(&request.body) else { + return ResponseTemplate::new(400); + }; + let Some(messages) = body["messages"].as_array() else { + return ResponseTemplate::new(400); + }; + let Some(last) = messages.last() else { + return ResponseTemplate::new(400); + }; + let episode = messages + .iter() + .filter_map(|message| message["content"].as_str()) + .filter_map(|text| { + text.find('{') + .and_then(|start| serde_json::from_str::(&text[start..]).ok()) + }) + .find_map(|turn| turn["episode"]["episode_id"].as_str().map(str::to_owned)); + let completed = messages.iter().any(|message| { + message["tool_calls"].as_array().is_some_and(|calls| { + calls + .iter() + .any(|call| call["function"]["name"] == "hivemind_complete") + }) + }); + let call = if completed { + None + } else if last["role"] == "user" { + if episode.is_some() { + Some(( + "delegate_helper", + json!({"prompt":"Attempt the restricted write", "blocking":true}), + )) + } else { + self.0.fetch_add(1, Ordering::Relaxed); + Some(("write", json!({}))) + } + } else { + episode + .as_ref() + .map(|id| ("hivemind_complete", json!({"episode_id":id,"body":"done"}))) + }; + let message = call.as_ref().map_or_else( + || json!({"role":"assistant","content":"done"}), + |(name, arguments)| json!({"role":"assistant","content":null,"tool_calls":[{"id":"delegate-fixture","type":"function","function":{"name":name,"arguments":arguments.to_string()}}]})); + ResponseTemplate::new(200).set_body_json(json!({"id":"fixture","object":"chat.completion","created":0,"model":"fixture","choices":[{"index":0,"message":message,"finish_reason":if call.is_some(){"tool_calls"}else{"stop"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}})) + } +} + +#[test] +fn native_delegate_cannot_execute_a_child_denied_effect() -> anyhow::Result<()> { + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + run(async { + let (mut options, _backend, model) = build_options().await; + let child_requests = Arc::new(AtomicUsize::new(0)); + // `mount` accepts the shared episode script; delegation needs a distinct + // response for the native child's plain-text task. + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/v1/chat/completions")) + .respond_with(DelegateScript(child_requests.clone())) + .mount(&model) + .await; + let count = Arc::new(AtomicUsize::new(0)); + options.tools = Some(source(count.clone())); + let mut config = manifest()?; + config.profiles.push(Profile { + id: "helper".into(), + deny_tools: vec!["write".into()], + ..Default::default() + }); + config.profiles[0].subagents.push("helper".into()); + let deployment = Box::pin(HiveDeployment::build_with( + config.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + deployment + .host() + .coordinator() + .send_as_host(message("delegate", "a")) + .await?; + let _report = deployment.host().coordinator().run_until_idle().await?; + assert!( + child_requests.load(Ordering::Relaxed) > 0, + "native child never ran" + ); + assert_eq!(count.load(Ordering::Relaxed), 0); + Ok(()) + }) +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/test/factory.rs b/crates/tinyhivemind-openhuman/src/deploy/test/factory.rs new file mode 100644 index 00000000..5419e13a --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/test/factory.rs @@ -0,0 +1,192 @@ +//! Dynamic templates retain one runtime and typed narrowing. +use super::support::*; +use crate::{AgentFactory, config::*, deploy::*}; +use std::sync::{Arc, PoisonError}; +struct AllowManagement; +#[test] +fn factory_refuses_unbounded_dynamic_budget_and_native_identity_without_mutating_catalog() +-> anyhow::Result<()> { + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + run(async { + let (options, _backend, _model) = build_options().await; + let config = manifest()?; + let deployment = Box::pin(HiveDeployment::build_with( + config.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + let diagnostic = format!("{:?}", deployment.factory()); + assert!(diagnostic.contains(deployment.runtime().runtime_id())); + assert!(!diagnostic.contains("fixture")); + let budget = SeatOverrides { + limits: Limits { + budget_usd: Some(0.1), + ..Default::default() + }, + ..Default::default() + }; + assert!(deployment.factory().create_seat("budget", "worker", budget).await.is_err_and(|error| matches!(error, DeployError::Config(ConfigError::UnsupportedSetting(ref setting)) if setting.contains("call_budget")))); + assert!( + deployment + .factory() + .create_seat("critic", "worker", SeatOverrides::default()) + .await + .is_err_and(|error| matches!(error, DeployError::Agent(ref id) if id=="critic")) + ); + assert_eq!( + deployment.host().coordinator().list_agents()?, + ["alice", "bob"] + ); + assert!( + !deployment + .factory() + .permissions + .config() + .seats + .iter() + .any(|seat| seat.id == "budget" || seat.id == "critic") + ); + Ok(()) + }) +} +#[test] +fn model_management_requires_an_explicit_host_authorizer() -> anyhow::Result<()> { + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + run(async { + let (options, _backend, model) = build_options().await; + super::turns::mount( + &model, + super::turns::Script { + action: Some("hivemind_create_agent".into()), + arguments: Some( + serde_json::json!({"template":"worker","config":{"id":"unauthorized"}}), + ), + ..Default::default() + }, + ) + .await; + let deployment = Box::pin(HiveDeployment::build_with( + manifest()?.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + deployment + .host() + .coordinator() + .send_as_host(super::turns::message("default-management", "a")) + .await?; + deployment.host().coordinator().run_until_idle().await?; + assert!( + !deployment + .host() + .coordinator() + .list_agents()? + .contains(&"unauthorized".into()) + ); + assert!( + !deployment + .runtime() + .agent_ids() + .contains(&"unauthorized".into()) + ); + Ok(()) + }) +} +impl crate::ManagementAuthorizer for AllowManagement { + fn authorize(&self, _: &str, _: &crate::ManagementRequest) -> crate::Result<()> { + Ok(()) + } +} +#[test] +fn model_management_creates_only_known_typed_profiles() -> anyhow::Result<()> { + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + run(async { + for (template, overrides, allowed) in [ + ("worker", serde_json::json!({}), true), + ("unknown", serde_json::json!({}), false), + ("worker", serde_json::json!({"api_key":"literal"}), false), + ] { + let (mut options, _backend, model) = build_options().await; + options.management = Some(Arc::new(AllowManagement)); + super::turns::mount(&model, super::turns::Script { + action: Some("hivemind_create_agent".into()), + arguments: Some(serde_json::json!({"template":template,"config":{"id":"dynamic","overrides":overrides}})), + ..Default::default() + }).await; + let deployment = Box::pin(HiveDeployment::build_with( + manifest()?.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + deployment + .host() + .coordinator() + .send_as_host(super::turns::message("manage", "a")) + .await?; + let _report = deployment.host().coordinator().run_until_idle().await?; + assert_eq!( + deployment + .host() + .coordinator() + .list_agents()? + .contains(&"dynamic".into()), + allowed + ); + } + Ok(()) + }) +} +#[test] +fn dynamic_creation_uses_the_catalog_and_refuses_unknown_or_secret_overrides() -> anyhow::Result<()> +{ + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + run(async { + let (options, _backend, _model) = build_options().await; + let deployment = Box::pin(HiveDeployment::build_with( + manifest()?.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + assert!( + deployment + .factory() + .create_seat("new", "unknown", SeatOverrides::default()) + .await + .is_err() + ); + assert!( + deployment + .factory() + .create( + "worker".into(), + serde_json::json!({"id":"new","overrides":{"api_key":"literal"}}) + ) + .await + .is_err() + ); + let agent = deployment.factory().create("worker".into(),serde_json::json!({"id":"new","overrides":{"deny_tools":["write"],"limits":{"iterations":2}}})).await?; + assert_eq!(agent.runtime_id(), deployment.runtime().runtime_id()); + deployment.host().register_agent(agent).await?; + assert_eq!(deployment.host().coordinator().list_agents()?.len(), 3); + assert!( + deployment + .factory() + .create_seat("new", "worker", SeatOverrides::default()) + .await + .is_err() + ); + Ok::<_, anyhow::Error>(()) + }) +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/test/mcp.rs b/crates/tinyhivemind-openhuman/src/deploy/test/mcp.rs new file mode 100644 index 00000000..9a5a7950 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/test/mcp.rs @@ -0,0 +1,119 @@ +//! Real MCP envelopes retain scheduled denials before remote effects. +use super::{ + support::*, + turns::{Script, message, mount}, +}; +use crate::{config::*, deploy::*}; +use serde_json::{Value, json}; +use std::sync::{ + Arc, PoisonError, + atomic::{AtomicUsize, Ordering}, +}; +struct Remote(Arc); +impl wiremock::Respond for Remote { + fn respond(&self, request: &wiremock::Request) -> wiremock::ResponseTemplate { + let body: Value = serde_json::from_slice(&request.body).unwrap_or_default(); + let result = match body["method"].as_str().unwrap_or_default() { + "initialize" => { + json!({"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"remote","version":"1"}}) + } + "notifications/initialized" => return wiremock::ResponseTemplate::new(202), + "tools/list" => { + json!({"tools":[{"name":"mutate","description":"Write fixture","inputSchema":{"type":"object"}}]}) + } + "tools/call" => { + self.0.fetch_add(1, Ordering::Relaxed); + json!({"content":[{"type":"text","text":"written"}]}) + } + _ => json!({}), + }; + wiremock::ResponseTemplate::new(200) + .set_body_json(json!({"jsonrpc":"2.0","id":body["id"],"result":result})) + } +} +struct RemoteEffects; +impl EffectClassifier for RemoteEffects { + fn classify( + &self, + context: &openhuman_embed::seams::ToolHookContext, + ) -> tinyhivemind_core::approval::Effect { + if context.tool_name == "mcp_call_tool" + && context.arguments["server"] == "remote" + && context.arguments["tool"] == "mutate" + || context.tool_name == "mcp_remote_mutate" + { + tinyhivemind_core::approval::Effect::Mutating + } else { + tinyhivemind_core::approval::Effect::Unclassified + } + } +} +#[test] +fn scheduled_mcp_envelope_and_private_children_never_reach_remote_effect() -> anyhow::Result<()> { + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + run(async { + let remote = wiremock::MockServer::start().await; + let count = Arc::new(AtomicUsize::new(0)); + wiremock::Mock::given(wiremock::matchers::method("POST")) + .respond_with(Remote(count.clone())) + .mount(&remote) + .await; + let (mut options, _backend, model) = build_options().await; + options.classifier = Some(Arc::new(RemoteEffects)); + let script = Script { + action: Some("mcp_call_tool".into()), + arguments: Some(json!({"server":"remote","tool":"mutate","arguments":{}})), + child: true, + ..Default::default() + }; + mount(&model, script).await; + let mut config = manifest()?; + config.runtime.mcp.push(McpServer { + id: "remote".into(), + transport: "http".into(), + endpoint: remote.uri(), + args: Vec::new(), + env: std::collections::BTreeMap::default(), + headers: std::collections::BTreeMap::default(), + tools: None, + deny_tools: Vec::new(), + }); + let deployment = Box::pin(HiveDeployment::build_with( + config.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + deployment + .host() + .coordinator() + .send_scheduled_as_host("trusted-job", message("mcp-scheduled", "a")) + .await?; + let report = deployment.host().coordinator().run_until_idle().await?; + assert!(report.failed > 0, "{report:?}"); + assert_eq!(count.load(Ordering::Relaxed), 0); + // The first queued message retains origin after the enqueue call returned. + assert!( + deployment + .host() + .coordinator() + .read_transcript(None)? + .iter() + .any(|row| !row.only_for.is_empty()) + ); + deployment + .host() + .coordinator() + .send_as_host(message("mcp-interactive", "a")) + .await?; + let report = deployment.host().coordinator().run_until_idle().await?; + assert_eq!(report.failed, 0, "{report:?}"); + assert!( + count.load(Ordering::Relaxed) > 0, + "interactive MCP call must reach the fixture" + ); + Ok(()) + }) +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/test/mod.rs b/crates/tinyhivemind-openhuman/src/deploy/test/mod.rs new file mode 100644 index 00000000..2bc8f755 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/test/mod.rs @@ -0,0 +1,34 @@ +//! Deployment construction and policy regression tests. +use crate::config::HiveConfig; +use crate::deploy::{EnvironmentSecrets, HiveDeployment}; +#[tokio::test] +async fn refuses_unavailable_host_session_storage_before_starting_runtime() -> anyhow::Result<()> { + let config = HiveConfig::from_json(r#"{"runtime":{"session_store":"host"}}"#)?.validate()?; + let result = Box::pin(HiveDeployment::build( + config, + std::sync::Arc::new(EnvironmentSecrets), + )) + .await; + assert!(result.is_err_and(|error| error.to_string().contains("session_store"))); + Ok(()) +} + +mod construction; +mod permission; +mod support; + +mod preflight; + +mod factory; +mod turns; + +mod workflow; + +mod cancellation; + +mod delegation; +mod mcp; +mod sends; + +mod profile; +mod timeout; diff --git a/crates/tinyhivemind-openhuman/src/deploy/test/permission.rs b/crates/tinyhivemind-openhuman/src/deploy/test/permission.rs new file mode 100644 index 00000000..ea4c7282 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/test/permission.rs @@ -0,0 +1,251 @@ +//! Pure approval integration, tool narrowing and trusted scheduled effects. +use super::support::*; +use crate::deploy::*; +use openhuman_embed::seams::ToolHookDecision; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +#[tokio::test] +async fn tool_allowed_in_a_is_denied_in_b_and_under_scheduled_origin() -> anyhow::Result<()> { + let engine = permissions(manifest()?, &BuildOptions::default()); + assert!(matches!( + engine + .decide("alice", Some(&scope("a")), &context("write")) + .await, + ToolHookDecision::Proceed + )); + assert!(matches!( + engine + .decide("alice", Some(&scope("b")), &context("write")) + .await, + ToolHookDecision::Deny(_) + )); + let mut scheduled = scope("a"); + scheduled.scheduled_job_id = Some("job".into()); + for name in ["write", "shell", "http_request"] { + assert!(matches!( + engine + .decide("alice", Some(&scheduled), &context(name)) + .await, + ToolHookDecision::Deny(_) + )); + } + assert!(matches!( + engine + .decide("alice", Some(&scheduled), &context("hivemind_complete")) + .await, + ToolHookDecision::Proceed + )); + assert!(matches!( + engine + .decide("alice", Some(&scope("a")), &context("unrecognized")) + .await, + ToolHookDecision::Deny(_) + )); + let mut forged = context("read"); + forged.agent_id = Some("outsider".into()); + assert!(matches!( + engine.decide("alice", None, &forged).await, + ToolHookDecision::Deny(_) + )); + Ok(()) +} +struct Handler(AtomicUsize); +#[async_trait::async_trait] +impl openhuman_embed::ApprovalHandler for Handler { + async fn decide( + &self, + request: &openhuman_embed::PendingApproval, + ) -> openhuman_embed::ApprovalDecision { + self.0.fetch_add(1, Ordering::Relaxed); + assert_eq!(request.tool_call_id.as_deref(), Some("call")); + assert!(!format!("{request:?}").contains("secret-user-payload")); + openhuman_embed::ApprovalDecision::ApproveOnce + } +} +#[tokio::test] +async fn ask_invokes_an_explicit_correlated_handler_and_original_call_is_denied() +-> anyhow::Result<()> { + let mut config = manifest()?; + config + .permission_profiles + .push(crate::config::PermissionProfile { + id: "ask".into(), + access: crate::config::Access::Autonomous, + approval: Some(tinyhivemind_core::approval::ApprovalPolicy { + enabled: true, + default: tinyhivemind_core::approval::DefaultVerdict::Ask, + rules: Vec::new(), + approver: tinyhivemind_core::approval::ApproverRule::Person { id: "human".into() }, + allow_grants: false, + max_grant_ttl: None, + }), + ..Default::default() + }); + config.runtime.permission_profile = Some("ask".into()); + let handler = Arc::new(Handler(AtomicUsize::new(0))); + let options = BuildOptions { + approval_handler: Some(handler.clone()), + approval: ApprovalSnapshot { + people: vec![tinyhivemind_core::roster::Person { + id: "human".into(), + label: "Human".into(), + }], + ..Default::default() + }, + ..Default::default() + }; + let engine = permissions(config, &options); + let turn = scope("a"); + assert!(matches!( + engine.decide("alice", Some(&turn), &context("write")).await, + ToolHookDecision::Deny(_) + )); + let pending = engine + .pending + .lock() + .map_err(|_| anyhow::anyhow!("poison"))? + .values() + .next() + .cloned() + .ok_or_else(|| anyhow::anyhow!("missing question"))?; + assert_eq!(pending.scope, Some(turn)); + let changed = engine.changed.notified(); + if engine + .pending + .lock() + .map_err(|_| anyhow::anyhow!("poison"))? + .values() + .any(|pending| pending.decision.is_none()) + { + changed.await; + } + assert_eq!(handler.0.load(Ordering::Relaxed), 1); + assert!(engine.pending.lock().map_err(|_| anyhow::anyhow!("poison"))?.values().all(|pending| pending.decision == Some(openhuman_embed::ApprovalDecision::ApproveOnce))); + Ok(()) +} +#[tokio::test] +async fn readonly_baseline_and_argument_rules_survive_turn_allows() -> anyhow::Result<()> { + let mut config = manifest()?; + config.runtime.autonomy = crate::config::Access::ReadOnly; + let engine = permissions(config, &BuildOptions::default()); + assert!(matches!( + engine + .decide("alice", Some(&scope("a")), &context("write")) + .await, + ToolHookDecision::Deny(_) + )); + assert!(matches!( + engine.decide("alice", None, &context("read")).await, + ToolHookDecision::Proceed + )); + let mut config = manifest()?; + config.runtime.tool_rules = Some(tinytools::ToolRules::deny_all()); + let engine = permissions(config, &BuildOptions::default()); + assert!(matches!( + engine.decide("alice", None, &context("read")).await, + ToolHookDecision::Deny(_) + )); + Ok(()) +} +#[tokio::test] +async fn argument_rules_and_unknown_metadata_cannot_be_bypassed() -> anyhow::Result<()> { + let mut config = manifest()?; + config.runtime.tool_rules = Some(serde_json::from_value( + serde_json::json!({"rules":[{"effect":"deny","match":{"name":"read","arg":{"pointer":"/path","value":"secret*"}}}]}), + )?); + let engine = permissions(config, &BuildOptions::default()); + assert!(matches!( + engine + .decide("alice", Some(&scope("a")), &context("read")) + .await, + ToolHookDecision::Deny(_) + )); + let mut allowed = context("read"); + allowed.arguments = serde_json::json!({"path":"public"}); + assert!(matches!( + engine.decide("alice", Some(&scope("a")), &allowed).await, + ToolHookDecision::Proceed + )); + for matcher in [ + serde_json::json!({"family":"private"}), + serde_json::json!({"tags":"sensitive"}), + serde_json::json!({"permission_at_least":"Write"}), + serde_json::json!({"side_effects":["writes_files"]}), + ] { + let mut config = manifest()?; + config.runtime.tool_rules = Some(serde_json::from_value( + serde_json::json!({"rules":[{"effect":"deny","match":matcher}]}), + )?); + let engine = permissions(config, &BuildOptions::default()); + assert!(matches!( + engine + .decide("alice", Some(&scope("a")), &context("read")) + .await, + ToolHookDecision::Deny(_) + )); + } + Ok(()) +} +#[tokio::test] +async fn isolated_session_never_captures_active_hive_or_episode_authority() -> anyhow::Result<()> { + let engine = permissions(manifest()?, &BuildOptions::default()); + engine + .active + .lock() + .map_err(|_| anyhow::anyhow!("poison"))? + .insert("alice".into(), scope("b")); + assert!(matches!( + engine.decide("alice", None, &context("write")).await, + ToolHookDecision::Deny(_) + )); + let mut isolated = context("write"); + isolated.session_id = Some("cron-isolated".into()); + assert!(matches!( + engine.decide("alice", None, &isolated).await, + ToolHookDecision::Proceed + )); + isolated.tool_name = "hivemind_complete".into(); + assert!(matches!( + engine.decide("alice", None, &isolated).await, + ToolHookDecision::Deny(_) + )); + Ok(()) +} +#[tokio::test] +async fn declared_delegates_are_internal_but_child_rules_still_narrow_effects() -> anyhow::Result<()> +{ + let mut config = manifest()?; + config.profiles.push(crate::config::Profile { + id: "helper".into(), + deny_tools: vec!["write".into()], + ..Default::default() + }); + config.profiles[0].subagents.push("helper".into()); + let engine = permissions(config, &BuildOptions::default()); + assert!(matches!( + engine + .decide("alice", Some(&scope("a")), &context("delegate_helper")) + .await, + ToolHookDecision::Proceed + )); + assert!(matches!( + engine + .decide("alice", Some(&scope("a")), &context("delegate_unknown")) + .await, + ToolHookDecision::Deny(_) + )); + let mut child = context("write"); + child.agent_id = Some("helper".into()); + assert!(matches!( + engine.decide("alice", Some(&scope("a")), &child).await, + ToolHookDecision::Deny(_) + )); + child.tool_name = "read".into(); + assert!(matches!( + engine.decide("alice", Some(&scope("a")), &child).await, + ToolHookDecision::Proceed + )); + Ok(()) +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/test/preflight.rs b/crates/tinyhivemind-openhuman/src/deploy/test/preflight.rs new file mode 100644 index 00000000..28259720 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/test/preflight.rs @@ -0,0 +1,143 @@ +//! Every refused native selector fails before runtime construction. +use super::support::*; +use crate::{ + config::*, + deploy::{BuildOptions, runtime}, +}; +#[test] +fn refuses_unlowerable_ports_and_native_settings() -> anyhow::Result<()> { + let mut cases: Vec = Vec::new(); + let mut config = manifest()?; + config.runtime.memory_engine = "host".into(); + cases.push(config); + let mut config = manifest()?; + config.runtime.session_store = "sqlite".into(); + cases.push(config); + let mut config = manifest()?; + config.runtime.provider.kind = "anthropic".into(); + cases.push(config); + let mut config = manifest()?; + config.runtime.skills = vec!["a".into(), "b".into()]; + cases.push(config); + let mut config = manifest()?; + config.runtime.skills = vec!["a".into()]; + config.profiles[0].skills = vec!["b".into()]; + cases.push(config); + let mut config = manifest()?; + config.runtime.limits.budget_usd = Some(1.0); + cases.push(config); + let mut config = manifest()?; + config.hives[0].coordinator.retention.settled_episodes = Some(0); + cases.push(config); + let mut config = manifest()?; + config.runtime.provider.credential = Some(SecretRef::Env("KEY".into())); + cases.push(config); + let server = McpServer { + id: "remote".into(), + transport: "sse".into(), + endpoint: "https://host".into(), + args: Vec::new(), + env: std::collections::BTreeMap::default(), + headers: std::collections::BTreeMap::default(), + tools: None, + deny_tools: Vec::new(), + }; + let mut config = manifest()?; + config.runtime.mcp.push(server.clone()); + cases.push(config); + let mut config = manifest()?; + let mut server = server.clone(); + server.transport = "http".into(); + server.tools = Some(Vec::new()); + config.runtime.mcp.push(server.clone()); + cases.push(config); + let mut config = manifest()?; + let mut server = server.clone(); + server.transport = "http".into(); + server.args.push("ignored".into()); + config.runtime.mcp.push(server.clone()); + cases.push(config); + let mut config = manifest()?; + server.transport = "stdio".into(); + server + .headers + .insert("Authorization".into(), SecretRef::Env("KEY".into())); + config.runtime.mcp.push(server.clone()); + cases.push(config); + let mut config = manifest()?; + config.profiles[0].limits.timeout_ms = Some(100); + config.workflows.push(Workflow { + id: "cron".into(), + schedule: "0 8 * * *".into(), + target: WorkflowTarget::Seat("alice".into()), + prompt: "task".into(), + enabled: true, + retries: 0, + single_flight: true, + }); + cases.push(config); + for config in cases { + assert!(runtime::preflight(&config, &BuildOptions::default()).is_err()); + } + let options = BuildOptions { + call_budget: Some(openhuman_embed::budget::CallBudget { + input_tokens: 0, + output_tokens: 1, + cost_micros: 1, + }), + ..Default::default() + }; + assert!(runtime::preflight(&manifest()?, &options).is_err()); + assert!(runtime::preflight(&manifest()?, &BuildOptions::default()).is_ok()); + Ok(()) +} +#[test] +fn child_instance_only_settings_are_explicitly_refused() -> anyhow::Result<()> { + let mut config = manifest()?; + config.profiles.push(Profile { + id: "child".into(), + memory: Some(Memory { + root: Some("private".into()), + }), + ..Default::default() + }); + config.profiles[0].subagents.push("child".into()); + assert!(runtime::preflight(&config, &BuildOptions::default()).is_err()); + config.profiles[1].memory = None; + assert!(runtime::preflight(&config, &BuildOptions::default()).is_ok()); + config.permission_profiles[0].send_destinations = Some(vec!["a".into()]); + config.profiles[1].permission_profile = Some("narrow".into()); + assert!(runtime::preflight(&config, &BuildOptions::default()).is_err()); + Ok(()) +} +#[test] +fn logical_memory_roots_encode_without_collisions_and_reserve_native_agent_depth() +-> anyhow::Result<()> { + use crate::deploy::native_memory_root; + for root in [ + "seat:alice", + "shared", + "team:shared", + "é😀", + &"a".repeat(384), + ] { + let native = native_memory_root(root)?; + assert!(openhuman_core::memory::scope::validate_root(&native).is_ok()); + assert_ne!(native_memory_root(&native).ok(), Some(native)); + } + assert_ne!( + native_memory_root("seat:alice")?, + native_memory_root("seat:bob")? + ); + assert!(native_memory_root("").is_err()); + assert!(native_memory_root(&"é".repeat(193)).is_err()); + let mut config = manifest()?; + config.hives[0].memory_root = Some("a".repeat(385)); + assert!(runtime::preflight(&config, &BuildOptions::default()).is_err()); + config.hives[0].memory_root = None; + config.seats[0].overrides.memory = Some(Memory { + root: Some("a".repeat(385)), + }); + assert!(runtime::preflight(&config, &BuildOptions::default()).is_err()); + Ok(()) +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/test/profile.rs b/crates/tinyhivemind-openhuman/src/deploy/test/profile.rs new file mode 100644 index 00000000..ae7bb89e --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/test/profile.rs @@ -0,0 +1,238 @@ +//! Native agent definitions and config retain profile inheritance and connections. +use super::support::*; +use crate::{config::*, deploy::*}; +use std::{ + collections::BTreeMap, + sync::{Arc, PoisonError}, +}; +fn server(id: &str) -> McpServer { + McpServer { + id: id.into(), + transport: "http".into(), + endpoint: "http://127.0.0.1:9/mcp".into(), + args: Vec::new(), + env: BTreeMap::new(), + headers: BTreeMap::new(), + tools: None, + deny_tools: Vec::new(), + } +} +#[test] +fn inherited_runtime_mcp_is_registered_once_after_tool_list_normalization() -> anyhow::Result<()> { + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + run(async { + let (options, _backend, _model) = build_options().await; + let mut config = manifest()?; + let mut inherited = server("shared"); + inherited.tools = Some(vec!["z".into(), "a".into(), "a".into()]); + inherited.deny_tools = vec!["blocked".into(), "blocked".into()]; + config.runtime.mcp.push(inherited); + let deployment = Box::pin(HiveDeployment::build_with( + config.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + for agent in deployment.seats().values() { + assert_eq!( + agent + .config() + .mcp_client + .servers + .iter() + .filter(|server| server.name == "shared") + .count(), + 1, + "inherited server registered twice" + ); + } + Ok(()) + }) +} +#[test] +fn host_only_profile_and_readonly_permissions_remove_native_mutating_surfaces() -> anyhow::Result<()> +{ + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + run(async { + let (options, _backend, _model) = build_options().await; + let mut config = manifest()?; + config.profiles[0].tool_scope = ToolScope::HostOnly; + config.profiles[0].permission_profile = Some("narrow".into()); + config.permission_profiles[0].access = Access::ReadOnly; + config.permission_profiles[0].sandbox = Sandbox::ReadOnly; + let deployment = Box::pin(HiveDeployment::build_with( + config.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + let hosted = openhuman_core::agent::host_agents::resolve("alice") + .ok_or_else(|| anyhow::anyhow!("missing native hosted agent"))?; + assert!( + matches!(hosted.definition.tools, openhuman_core::agent::harness::definition::ToolScope::Named(ref names) if names.is_empty()) + ); + assert!(matches!( + hosted.definition.sandbox_mode, + openhuman_core::agent::harness::definition::SandboxMode::ReadOnly + )); + assert_eq!(hosted.definition.subagents.len(), 0); + assert!(matches!( + deployment.seats()["alice"].config().autonomy.level, + openhuman_core::security::AutonomyLevel::ReadOnly + )); + Ok(()) + }) +} +#[test] +fn native_definition_retains_bare_prompt_sampling_tools_skills_and_mcp_credentials() +-> anyhow::Result<()> { + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + run(async { + let (options, _backend, model) = build_options().await; + super::turns::mount(&model, super::turns::Script::default()).await; + let directory = tempfile::tempdir()?; + let bundle = directory.path().join("fixture-skill"); + std::fs::create_dir_all(&bundle)?; + std::fs::write( + bundle.join("SKILL.md"), + "---\nname: fixture-skill\ndescription: fixture\n---\nRead carefully\n", + )?; + let config = configured_profile(directory.path())?; + let deployment = Box::pin(HiveDeployment::build_with( + config.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + let agent = &deployment.seats()["alice"]; + assert_eq!(agent.provider().model_id(), Some("seat-model")); + Box::pin( + agent + .turn("Inspect configured sampling") + .session("profile-proof") + .send(), + ) + .await?; + let requests = model + .received_requests() + .await + .ok_or_else(|| anyhow::anyhow!("provider request recording unavailable"))?; + let request = requests + .iter() + .find(|request| { + request.method.as_str() == "POST" + && request.url.path().ends_with("/chat/completions") + }) + .ok_or_else(|| anyhow::anyhow!("no native provider dispatch"))?; + let body: serde_json::Value = serde_json::from_slice(&request.body)?; + assert_eq!(body["model"], "seat-model"); + assert_eq!(body["temperature"], 0.2); + assert_eq!(body["max_tokens"], 111); + assert_eq!( + std::fs::read_to_string(agent.skills_dir().join("fixture-skill/SKILL.md"))?, + std::fs::read_to_string(bundle.join("SKILL.md"))? + ); + let hosted = openhuman_core::agent::host_agents::resolve("alice") + .ok_or_else(|| anyhow::anyhow!("missing native hosted agent"))?; + assert!(!hosted.context.user_skill_roots()); + let definition = hosted.definition; + assert!( + matches!(definition.system_prompt, openhuman_core::agent::harness::definition::PromptSource::Verbatim(ref prompt) if prompt == "Seat prompt\nProfile context\nSeat context") + ); + assert_eq!(definition.temperature, 0.2); + assert_eq!(definition.max_iterations, 2); + assert!( + matches!(definition.tools, openhuman_core::agent::harness::definition::ToolScope::Named(ref names) if names.contains(&"file_read".into())) + ); + assert!(matches!( + definition.sandbox_mode, + openhuman_core::agent::harness::definition::SandboxMode::Sandboxed + )); + assert!(definition.tool_rules.is_some()); + let native = &agent.config().mcp_client.servers; + let local = native + .iter() + .find(|server| server.name == "local") + .ok_or_else(|| anyhow::anyhow!("missing local MCP"))?; + assert_eq!(local.command, "sh"); + assert_eq!(local.args, ["-c", "exit 0"]); + assert_eq!( + local.env.get("FIXTURE_AUTH").map(String::as_str), + Some("fixture") + ); + assert_eq!(local.allowed_tools, ["read"]); + assert_eq!(local.disallowed_tools, ["write"]); + let remote = native + .iter() + .find(|server| server.name == "remote") + .ok_or_else(|| anyhow::anyhow!("missing remote MCP"))?; + assert!( + matches!(&remote.auth, openhuman_embed::McpAuthConfig::Headers { headers } if headers.len()==1 && headers[0].name=="Authorization" && headers[0].value=="fixture") + ); + Ok(()) + }) +} + +fn configured_profile(skills_root: &std::path::Path) -> anyhow::Result { + let mut config = manifest()?; + config + .contexts + .insert("profile-context".into(), "Profile context".into()); + config + .contexts + .insert("seat-context".into(), "Seat context".into()); + let profile = &mut config.profiles[0]; + profile.definition_base = Some("orchestrator".into()); + profile.tool_scope = ToolScope::Named(vec!["file_read".into()]); + profile.bare_prompt = true; + profile.system_prompt = "Profile prompt".into(); + profile.context.push("profile-context".into()); + profile.model = Model { + name: Some("profile-model".into()), + temperature: Some(0.6), + max_tokens: Some(333), + }; + profile.limits.iterations = Some(7); + profile + .skills + .push(skills_root.to_string_lossy().into_owned()); + profile.include_user_skills = Some(false); + profile.permission_profile = Some("narrow".into()); + let mut local = server("local"); + local.transport = "stdio".into(); + local.endpoint = "sh".into(); + local.args = vec!["-c".into(), "exit 0".into()]; + local + .env + .insert("FIXTURE_AUTH".into(), SecretRef::Store("local-key".into())); + local.tools = Some(vec!["read".into()]); + local.deny_tools.push("write".into()); + profile.mcp.push(local); + let mut remote = server("remote"); + remote.headers.insert( + "Authorization".into(), + SecretRef::Store("header-key".into()), + ); + profile.mcp.push(remote); + config.permission_profiles[0].sandbox = Sandbox::Sandboxed; + config.permission_profiles[0].tool_rules = Some(serde_json::from_value( + serde_json::json!({"rules":[{"effect":"deny","match":{"name":"blocked"}}]}), + )?); + let seat = &mut config.seats[0]; + seat.overrides.system_prompt = Some("Seat prompt".into()); + seat.overrides.context.push("seat-context".into()); + seat.overrides.model = Model { + name: Some("seat-model".into()), + temperature: Some(0.2), + max_tokens: Some(111), + }; + seat.overrides.limits.iterations = Some(2); + seat.overrides.tool_scope = Some(ToolScope::Named(vec!["file_read".into()])); + Ok(config) +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/test/runtime.rs b/crates/tinyhivemind-openhuman/src/deploy/test/runtime.rs new file mode 100644 index 00000000..b467dc13 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/test/runtime.rs @@ -0,0 +1,77 @@ +//! Provider selection resolves declared credentials and sanitizes host diagnostics. +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +struct Resolver(AtomicUsize); +impl SecretResolver for Resolver { + fn resolve(&self, _: &crate::config::SecretRef) -> DeployResult { + self.0.fetch_add(1, Ordering::Relaxed); + Ok("private-fixture-credential".into()) + } +} +#[test] +fn known_provider_kinds_resolve_credentials_without_an_explicit_endpoint() -> anyhow::Result<()> { + let resolver = Resolver(AtomicUsize::new(0)); + for kind in ["openai", "openrouter"] { + let mut section = crate::config::RuntimeSection::default(); + section.provider.kind = kind.into(); + section.provider.credential = Some(crate::config::SecretRef::Store("key".into())); + section.model.name = Some("configured-model".into()); + let _provider = provider(§ion, &resolver, &BuildOptions::default())?; + } + assert_eq!(resolver.0.load(Ordering::Relaxed), 2); + let options = BuildOptions { + provider: Some(openhuman_embed::Provider::openai_compatible( + "https://fixture", + "private-fixture-credential", + )), + ..Default::default() + }; + let _provider = provider( + &crate::config::RuntimeSection::default(), + &resolver, + &options, + )?; + assert_eq!(resolver.0.load(Ordering::Relaxed), 2); + assert!(!format!("{options:?}").contains("private-fixture-credential")); + let _provider = provider( + &crate::config::RuntimeSection::default(), + &resolver, + &BuildOptions::default(), + )?; + assert!( + super::super::EnvironmentSecrets + .resolve(&crate::config::SecretRef::Store("key".into())) + .is_err() + ); + assert!( + super::super::EnvironmentSecrets + .resolve(&crate::config::SecretRef::Env( + "TINYHIVEMIND_MISSING_TEST_CREDENTIAL_8675309".into() + )) + .is_err() + ); + Ok(()) +} +#[test] +fn numeric_and_policy_mappings_preserve_declared_bounds() { + assert_eq!(budget_micros(1.25), Some(1_250_000)); + assert_eq!(budget_micros(0.000_001_9), Some(1)); + assert_eq!(budget_micros(f64::MAX), None); + for value in [Access::ReadOnly, Access::Supervised, Access::Autonomous] { + let _native = access(value); + } + for value in [Sandbox::None, Sandbox::ReadOnly, Sandbox::Sandboxed] { + let _native = sandbox(value); + } + let defaults = model( + &Model { + name: Some("fixture".into()), + temperature: Some(0.25), + max_tokens: Some(123), + }, + Some(7), + ); + assert_eq!(defaults.temperature, Some(0.25)); + assert_eq!(defaults.max_tokens, Some(123)); + assert_eq!(defaults.max_iterations, Some(7)); +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/test/sends.rs b/crates/tinyhivemind-openhuman/src/deploy/test/sends.rs new file mode 100644 index 00000000..b052c99d --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/test/sends.rs @@ -0,0 +1,93 @@ +//! Every outbound operation intersects captured membership and session authority. +use super::support::*; +use crate::{ + SendAuthorizer, SendRequest, TurnHooks, + deploy::{BuildOptions, hooks::Hooks}, +}; +fn requests() -> [SendRequest; 4] { + [ + SendRequest::Agent { + agent_id: "bob".into(), + body: "work".into(), + }, + SendRequest::Hive { + hive_id: "a".into(), + body: "work".into(), + thread: None, + only_for: Vec::new(), + }, + SendRequest::Ask { + episode_id: "episode".into(), + agents: vec!["bob".into()], + body: "work".into(), + }, + SendRequest::Broadcast { + episode_id: "episode".into(), + body: "work".into(), + }, + ] +} +#[test] +fn sends_intersect_all_operations_destinations_and_authenticated_sessions() -> anyhow::Result<()> { + let options = BuildOptions::default(); + let mut config = manifest()?; + config.permission_profiles[0].send_operations = Some(Vec::new()); + let hooks = Hooks::new(permissions(config.clone(), &options), &options); + hooks.context(&scope("a")); + for request in requests() { + assert!( + hooks + .authorize_in_session("alice", &request, Some("session")) + .is_ok() + ); + assert!(hooks.authorize("alice", &request).is_err()); + assert!( + hooks + .authorize_in_session("alice", &request, Some("forged")) + .is_err() + ); + assert!( + hooks + .authorize_in_session("unknown", &request, Some("session")) + .is_err() + ); + } + hooks.context(&scope("b")); + for request in requests() { + assert!( + hooks + .authorize_in_session("alice", &request, Some("session")) + .is_err() + ); + } + config.permission_profiles[0].send_operations = None; + config.permission_profiles[0].send_destinations = Some(vec!["other".into()]); + let hooks = Hooks::new(permissions(config, &options), &options); + hooks.context(&scope("b")); + for request in requests() { + assert!( + hooks + .authorize_in_session("alice", &request, Some("session")) + .is_err() + ); + } + let hooks = Hooks::new(permissions(manifest()?, &options), &options); + for request in requests().into_iter().skip(2) { + assert!(hooks.authorize("alice", &request).is_err()); + } + let mut forged = scope("a"); + forged + .episode + .as_mut() + .ok_or_else(|| anyhow::anyhow!("fixture has no episode"))? + .episode_id = "another".into(); + hooks.context(&forged); + for request in requests().into_iter().skip(2) { + assert!( + hooks + .authorize_in_session("alice", &request, Some("session")) + .is_err() + ); + } + Ok(()) +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/test/support.rs b/crates/tinyhivemind-openhuman/src/deploy/test/support.rs new file mode 100644 index 00000000..1deeb1a9 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/test/support.rs @@ -0,0 +1,87 @@ +//! Offline fixtures use one shared process runtime lock. +use crate::{ + config::HiveConfig, + deploy::{BuildOptions, DeployResult, SecretResolver}, +}; +use std::sync::Arc; +pub(super) fn executor() -> anyhow::Result { + Ok(tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(16 * 1024 * 1024) + .build()?) +} +pub(super) fn run( + future: impl std::future::Future> + Send + 'static, +) -> anyhow::Result { + executor()?.block_on(async move { tokio::spawn(future).await? }) +} +pub(super) fn manifest() -> anyhow::Result { + Ok(HiveConfig::from_json( + r#"{ + "runtime":{"autonomy":"autonomous","model":{"name":"fixture"}}, + "profiles":[{"id":"worker","system_prompt":"Act only using the current episode"}], + "seats":[{"id":"alice","profile":"worker"},{"id":"bob","profile":"worker"}], + "permission_profiles":[{"id":"narrow","access":"autonomous","deny_tools":["write"]}], + "hives":[{"id":"a","members":[{"seat":"alice","role":"lead"},{"seat":"bob","role":"reviewer"}]},{"id":"b","members":[{"seat":"alice","role":"observer","permission_profile":"narrow"}]}] +}"#, + )?) +} +pub(super) struct Secrets; +impl SecretResolver for Secrets { + fn resolve(&self, _: &crate::config::SecretRef) -> DeployResult { + Ok("fixture".into()) + } +} +pub(super) async fn build_options() -> (BuildOptions, wiremock::MockServer, wiremock::MockServer) { + let backend = crate::offline::backend().await; + let model = wiremock::MockServer::start().await; + let options = BuildOptions { + config: Some(crate::offline::config()), + backend_url: Some(backend.uri()), + provider: Some( + openhuman_embed::Provider::openai_compatible(format!("{}/v1", model.uri()), "fixture") + .model("fixture"), + ), + ..Default::default() + }; + (options, backend, model) +} +pub(super) fn scope(hive: &str) -> crate::TurnScope { + crate::TurnScope { + turn_id: "turn".into(), + scheduled_job_id: None, + session_id: Some("session".into()), + agent_id: "alice".into(), + episode: Some(tinyhivemind_hives::EpisodeContext { + episode_id: "episode".into(), + hive_id: hive.into(), + thread: None, + brief: String::new(), + }), + message_ids: vec!["message".into()], + senders: vec!["hivemind:host".into()], + destination: tinyhivemind_hives::Destination::Hive(hive.into()), + thread: None, + } +} +pub(super) fn context(name: &str) -> openhuman_embed::seams::ToolHookContext { + openhuman_embed::seams::ToolHookContext { + event: openhuman_core::agent::hooks::ToolHookEvent::PreToolUse, + call_id: "call".into(), + tool_name: name.into(), + arguments: serde_json::json!({"path":"secret-user-payload"}), + success: None, + duration_ms: None, + output: None, + error: None, + session_id: Some("session".into()), + agent_id: Some("alice".into()), + cwd: None, + } +} +pub(super) fn permissions( + config: HiveConfig, + options: &BuildOptions, +) -> Arc { + Arc::new(super::super::Permissions::new(config, options)) +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/test/timeout.rs b/crates/tinyhivemind-openhuman/src/deploy/test/timeout.rs new file mode 100644 index 00000000..185ccb7a --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/test/timeout.rs @@ -0,0 +1,51 @@ +//! Authoritative deadline inheritance and host narrowing. +use super::support::*; +use crate::{TurnHooks, TurnScope, deploy::BuildOptions}; +use std::{sync::Arc, time::Duration}; +struct Deadline(Duration); +impl TurnHooks for Deadline { + fn turn_timeout(&self, _: &TurnScope) -> Option { + Some(self.0) + } +} +#[test] +fn timeout_inherits_the_closest_limit_and_host_hooks_only_narrow() -> anyhow::Result<()> { + let mut config = manifest()?; + config.runtime.limits.timeout_ms = Some(1000); + config.profiles[0].limits.timeout_ms = Some(800); + config.seats[0].overrides.limits.timeout_ms = Some(600); + let scope = scope("a"); + for (extra, expected) in [ + (None, 600), + (Some(900), 600), + (Some(400), 400), + (Some(0), 0), + ] { + let options = BuildOptions { + hooks: extra + .map(|ms| Arc::new(Deadline(Duration::from_millis(ms))) as Arc), + ..Default::default() + }; + let hooks = + super::super::hooks::Hooks::new(permissions(config.clone(), &options), &options); + assert_eq!( + hooks.turn_timeout(&scope), + Some(Duration::from_millis(expected)) + ); + } + config.seats[0].overrides.limits.timeout_ms = None; + assert_eq!( + super::super::profile::limits(&config, &config.seats[0])?.timeout_ms, + Some(800) + ); + config.profiles[0].limits.timeout_ms = None; + assert_eq!( + super::super::profile::limits(&config, &config.seats[0])?.timeout_ms, + Some(1000) + ); + config.runtime.limits.timeout_ms = None; + let options = BuildOptions::default(); + let hooks = super::super::hooks::Hooks::new(permissions(config, &options), &options); + assert_eq!(hooks.turn_timeout(&scope), None); + Ok(()) +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/test/turns.rs b/crates/tinyhivemind-openhuman/src/deploy/test/turns.rs new file mode 100644 index 00000000..470c25c2 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/test/turns.rs @@ -0,0 +1,392 @@ +//! Real native host-tool calls obey captured membership and approval boundaries. +use super::support::*; +use crate::{config::*, deploy::*}; +use serde_json::{Value, json}; +use std::collections::BTreeSet; +use std::sync::{ + Arc, Mutex, PoisonError, + atomic::{AtomicUsize, Ordering}, +}; +use tinyhivemind_hives::{Destination, SendMessage}; +use wiremock::{ + Mock, Request, Respond, ResponseTemplate, + matchers::{method, path}, +}; +#[derive(Clone, Default)] +pub(super) struct Script { + pub calls: Arc, + pub seen: Arc>>, + pub action: Option, + pub arguments: Option, + pub child: bool, + pub parents: Arc>>, +} +impl Respond for Script { + fn respond(&self, request: &Request) -> ResponseTemplate { + let body: Value = serde_json::from_slice(&request.body).unwrap_or(Value::Null); + let last = body["messages"].as_array().and_then(|rows| rows.last()); + let text = last + .filter(|row| row["role"] == "user") + .and_then(|row| row["content"].as_str()); + let turn = text + .and_then(|text| text.find('{').map(|start| &text[start..])) + .and_then(|text| serde_json::from_str::(text).ok()); + let episode = turn + .as_ref() + .and_then(|turn| turn["episode"]["episode_id"].as_str()); + let mut calls = Vec::new(); + if let Some(text) = text { + self.seen + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(text.into()); + } + if let Some(episode) = episode { + if turn + .as_ref() + .is_some_and(|turn| turn["resumption"].is_null()) + { + if let Some(name) = &self.action + && !(self.child + && turn + .as_ref() + .is_some_and(|turn| turn["agent_id"] == "alice")) + { + calls.push(( + name.clone(), + self.arguments.clone().unwrap_or_else(|| json!({})), + )); + } + if self.child + && turn + .as_ref() + .is_some_and(|turn| turn["agent_id"] == "alice") + && self + .parents + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(episode.into()) + { + calls.push(( + "hivemind_ask".into(), + json!({"episode_id":episode,"agents":["bob"],"body":"private child work"}), + )); + } + } + calls.push(( + "hivemind_complete".into(), + json!({"episode_id":episode,"body":"done"}), + )); + } else if text.is_some() + && let Some(name) = &self.action + { + calls.push(( + name.clone(), + self.arguments.clone().unwrap_or_else(|| json!({})), + )); + } + let tools: Vec<_> = calls.into_iter().map(|(name,args)| json!({"id":format!("call-{}",self.calls.fetch_add(1,Ordering::Relaxed)),"type":"function","function":{"name":name,"arguments":args.to_string()}})).collect(); + let message = if tools.is_empty() { + json!({"role":"assistant","content":"done"}) + } else { + json!({"role":"assistant","content":null,"tool_calls":tools}) + }; + ResponseTemplate::new(200).set_body_json(json!({"id":"fixture","object":"chat.completion","created":0,"model":"fixture","choices":[{"index":0,"message":message,"finish_reason":if episode.is_some() || text.is_some() && self.action.is_some() { "tool_calls" } else { "stop" }}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}})) + } +} +pub(super) async fn mount(server: &wiremock::MockServer, script: Script) { + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(script) + .mount(server) + .await; +} +pub(super) struct WriteTool(pub Arc); +#[async_trait::async_trait] +impl tinytools::Tool for WriteTool { + fn name(&self) -> &'static str { + "write" + } + fn description(&self) -> &'static str { + "A classified write fixture" + } + fn parameters_schema(&self) -> Value { + json!({"type":"object","properties":{}}) + } + async fn execute(&self, _: Value) -> anyhow::Result { + self.0.fetch_add(1, Ordering::Relaxed); + Ok(tinytools::ToolResult::success("written")) + } + fn policy(&self) -> tinytools::ToolPolicy { + tinytools::ToolPolicy::classified().with_side_effects(tinytools::ToolSideEffects { + writes_files: true, + ..Default::default() + }) + } +} +pub(super) fn source(count: Arc) -> openhuman_embed::HostTools { + Arc::new(move |_| { + openhuman_embed::HostTurnTools::advertised(vec![Box::new(WriteTool(count.clone()))]) + }) +} +pub(super) fn message(id: &str, hive: &str) -> SendMessage { + SendMessage { + message_id: id.into(), + sender: String::new(), + destination: Destination::Hive(hive.into()), + body: "work".into(), + thread: None, + only_for: Vec::new(), + starters: vec!["alice".into()], + } +} +#[test] +fn configured_dollar_budget_refuses_reservation_before_provider_dispatch() -> anyhow::Result<()> { + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + run(async { + let (mut options, _backend, model) = build_options().await; + let script = Script::default(); + mount(&model, script.clone()).await; + options.call_budget = Some(openhuman_embed::budget::CallBudget { + input_tokens: 100_000, + output_tokens: 20, + cost_micros: 2_000_000, + }); + let mut config = manifest()?; + config.profiles[0].limits.budget_usd = Some(1.0); + let deployment = Box::pin(HiveDeployment::build_with( + config.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + deployment + .host() + .coordinator() + .send_as_host(message("budget", "a")) + .await?; + let report = deployment.host().coordinator().run_until_idle().await?; + assert_eq!(report.failed, 1, "{report:?}"); + assert!( + model + .received_requests() + .await + .ok_or_else(|| anyhow::anyhow!("request recording disabled"))? + .iter() + .all(|request| request.method.as_str() != "POST" + || !request.url.path().ends_with("/chat/completions")), + "provider received an unreserved inference request" + ); + Ok(()) + }) +} +#[test] +fn one_continuing_seat_uses_two_roles_and_tool_restrictions() -> anyhow::Result<()> { + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + run(async { + let (mut options, _backend, model) = build_options().await; + let script = Script { + action: Some("write".into()), + ..Default::default() + }; + mount(&model, script.clone()).await; + let count = Arc::new(AtomicUsize::new(0)); + options.tools = Some(source(count.clone())); + let mut config = manifest()?; + config + .contexts + .insert("note".into(), "Only hive B receives this context".into()); + config.hives[1].members[0].context.push("note".into()); + let storage = Arc::new(tinyhivemind_hives::MemoryStorage::new()); + options.coordinator_storage = Some(storage.clone()); + let deployment = Box::pin(HiveDeployment::build_with( + config.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + for hive in ["a", "b"] { + deployment + .host() + .coordinator() + .send_as_host(message(hive, hive)) + .await?; + let report = deployment.host().coordinator().run_until_idle().await?; + assert_eq!(report.failed, usize::from(hive == "b"), "{report:?}"); + if hive == "b" { + deployment + .host() + .coordinator() + .release_with( + "alice", + Some( + "The write was refused. Finish the hive safely without replaying it." + .into(), + ), + ) + .await?; + deployment + .host() + .coordinator() + .send_as_host(message("b-safe", "b")) + .await?; + let resumed = deployment.host().coordinator().run_until_idle().await?; + assert_eq!(resumed.failed, 0, "{resumed:?}"); + assert!(resumed.completed > 0); + } + } + assert_eq!(count.load(Ordering::Relaxed), 1); + let seen = script + .seen + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + assert!( + seen.iter() + .any(|text| text.contains("@alice: lead") && !text.contains("Only hive B")) + ); + assert!( + seen.iter() + .any(|text| text.contains("@alice: observer") && text.contains("Only hive B")) + ); + let state = tinyhivemind_hives::Storage::load(storage.as_ref()).await?; + assert!(state.episodes.iter().all(|episode| episode.finished)); + for hive in ["a", "b"] { + let last = state + .episodes + .iter() + .rev() + .find(|episode| episode.hive.hive_id == hive) + .ok_or_else(|| anyhow::anyhow!("missing episode"))?; + assert!(last.failure.is_none(), "safe completion must succeed"); + } + assert!( + state + .agents + .get("alice") + .and_then(|agent| agent.session_id.as_ref()) + .is_some() + ); + Ok::<_, anyhow::Error>(()) + }) +} +struct AllowHandler; +struct CleanupGate { + entered: Arc, + finish: Arc, +} +impl crate::TurnHooks for CleanupGate { + fn wrap_turn<'a>( + &'a self, + _: &'a crate::TurnScope, + turn: crate::HostedTurn<'a>, + ) -> crate::HostedTurn<'a> { + Box::pin(async move { + let result = turn.await; + self.entered.notify_one(); + self.finish.notified().await; + result + }) + } +} +#[async_trait::async_trait] +impl openhuman_embed::ApprovalHandler for AllowHandler { + async fn decide( + &self, + _: &openhuman_embed::PendingApproval, + ) -> openhuman_embed::ApprovalDecision { + openhuman_embed::ApprovalDecision::ApproveOnce + } +} +#[test] +fn ask_parks_then_explicit_release_continues_without_replaying_effect() -> anyhow::Result<()> { + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + run(async { + let (mut options, _backend, model) = build_options().await; + mount( + &model, + Script { + action: Some("write".into()), + ..Default::default() + }, + ) + .await; + let count = Arc::new(AtomicUsize::new(0)); + options.tools = Some(source(count.clone())); + options.approval_handler = Some(Arc::new(AllowHandler)); + let entered = Arc::new(tokio::sync::Notify::new()); + let finish = Arc::new(tokio::sync::Notify::new()); + options.hooks = Some(Arc::new(CleanupGate { + entered: entered.clone(), + finish: finish.clone(), + })); + options + .approval + .people + .push(tinyhivemind_core::roster::Person { + id: "human".into(), + label: "Human".into(), + }); + let mut config = manifest()?; + let policy: tinyhivemind_core::approval::ApprovalPolicy = serde_json::from_value( + json!({"enabled":true,"default":"deny","rules":[{"effect":"read_only","verb":null,"target":null,"verdict":"allow"},{"effect":"mutating","verb":null,"target":null,"verdict":"ask"}],"approver":{"kind":"person","id":"human"},"allow_grants":false,"max_grant_ttl":null}), + )?; + config.permission_profiles.push(PermissionProfile { + id: "ask".into(), + access: Access::Autonomous, + approval: Some(policy), + ..Default::default() + }); + config.runtime.permission_profile = Some("ask".into()); + let deployment = Box::pin(HiveDeployment::build_with( + config.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + deployment + .host() + .coordinator() + .send_as_host(message("question", "a")) + .await?; + let coordinator = deployment.host().coordinator().clone(); + let drain = tokio::spawn(async move { coordinator.run_until_idle().await }); + entered.notified().await; + assert_eq!(count.load(Ordering::Relaxed), 0); + let request = deployment + .pending() + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("missing approval"))?; + let answer = deployment.wait_decision(&request.request.request_id).await; + assert_eq!(answer, Some(openhuman_embed::ApprovalDecision::ApproveOnce)); + assert!( + deployment + .release(&request.request.request_id) + .await + .is_err(), + "running turn was released before finalization" + ); + assert_eq!(deployment.pending().len(), 1); + finish.notify_one(); + let report = drain.await??; + assert_eq!(report.parked, 1, "{report:?}"); + deployment.release(&request.request.request_id).await?; + let coordinator = deployment.host().coordinator().clone(); + let drain = tokio::spawn(async move { coordinator.run_until_idle().await }); + entered.notified().await; + finish.notify_one(); + let report = drain.await??; + assert_eq!(report.failed, 0, "{report:?}"); + assert_eq!(count.load(Ordering::Relaxed), 0); + assert!(deployment.pending().is_empty()); + assert!(deployment.release("unknown").await.is_err()); + Ok::<_, anyhow::Error>(()) + }) +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/test/workflow.rs b/crates/tinyhivemind-openhuman/src/deploy/test/workflow.rs new file mode 100644 index 00000000..c7982fc4 --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/test/workflow.rs @@ -0,0 +1,323 @@ +//! Direct job triggering pins scheduled authority and native isolated sessions. +use super::{ + support::*, + turns::{Script, message, mount, source}, +}; +use crate::{config::*, deploy::*}; +use std::future::Future; +use std::sync::{ + Arc, PoisonError, + atomic::{AtomicUsize, Ordering}, +}; +#[test] +fn hive_job_provenance_reaches_first_and_private_child_turns() -> anyhow::Result<()> { + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + run(async { + let (mut options, _backend, model) = build_options().await; + let script = Script { + action: Some("write".into()), + child: true, + ..Default::default() + }; + mount(&model, script.clone()).await; + let writes = Arc::new(AtomicUsize::new(0)); + options.tools = Some(source(writes.clone())); + let mut config = manifest()?; + config.workflows.push(Workflow { + id: "hive-job".into(), + schedule: "0 8 * * *".into(), + target: WorkflowTarget::Hive("a".into()), + prompt: "work".into(), + enabled: true, + retries: 0, + single_flight: true, + }); + let storage = Arc::new(tinyhivemind_hives::MemoryStorage::new()); + options.coordinator_storage = Some(storage.clone()); + let deployment = Box::pin(HiveDeployment::build_with( + config.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + let job = deployment + .runtime() + .cron() + .list()? + .into_iter() + .find(|job| job.name == "hive-job") + .ok_or_else(|| anyhow::anyhow!("missing job"))?; + assert_eq!(job.retries, Some(0)); + assert!(job.enabled && job.single_flight); + let run = deployment.runtime().cron().run_now("hive-job").await?; + assert!(!run.success, "{:?}", run.output); + assert_eq!(writes.load(Ordering::Relaxed), 0); + let state = tinyhivemind_hives::Storage::load(storage.as_ref()).await?; + assert!( + deployment + .host() + .coordinator() + .read_transcript(None)? + .iter() + .any(|message| !message.only_for.is_empty()) + ); + assert!( + state + .episodes + .iter() + .all(|episode| episode.scheduled_job_id.as_deref() == Some(job.id.as_str())) + ); + assert!( + script + .seen + .lock() + .unwrap_or_else(PoisonError::into_inner) + .iter() + .any(|text| text.contains("\"agent_id\":\"bob\"")) + ); + assert!(state.episodes.iter().all(|episode| episode.finished)); + Ok::<_, anyhow::Error>(()) + }) +} +#[test] +fn native_seat_job_keeps_baseline_denials_and_isolates_session() -> anyhow::Result<()> { + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + run(async { + let (mut options, _backend, model) = build_options().await; + mount( + &model, + Script { + action: Some("write".into()), + ..Default::default() + }, + ) + .await; + let writes = Arc::new(AtomicUsize::new(0)); + options.tools = Some(source(writes.clone())); + let mut config = manifest()?; + config.profiles[0].limits.iterations = Some(2); + config.workflows.push(Workflow { + id: "seat-job".into(), + schedule: "0 8 * * *".into(), + target: WorkflowTarget::Seat("alice".into()), + prompt: "native isolated work".into(), + enabled: true, + retries: 0, + single_flight: false, + }); + let storage = Arc::new(tinyhivemind_hives::MemoryStorage::new()); + options.coordinator_storage = Some(storage.clone()); + let deployment = Box::pin(HiveDeployment::build_with( + config.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + deployment + .host() + .coordinator() + .bind_session("alice", "continuing-session") + .await?; + let job = deployment + .runtime() + .cron() + .list()? + .into_iter() + .find(|job| job.name == "seat-job") + .ok_or_else(|| anyhow::anyhow!("missing job"))?; + assert!(!job.single_flight); + assert!( + matches!(job.target,openhuman_embed::JobTarget::Agent { ref agent_id,.. } if agent_id == "alice") + ); + assert!( + !deployment + .runtime() + .cron() + .run_now("seat-job") + .await? + .success + ); + assert_eq!(writes.load(Ordering::Relaxed), 0); + let state = tinyhivemind_hives::Storage::load(storage.as_ref()).await?; + assert_eq!( + state + .agents + .get("alice") + .and_then(|agent| agent.session_id.as_deref()), + Some("continuing-session") + ); + assert!(state.episodes.is_empty()); + // The same native agent still admits the ordinary coordinator operation. + deployment + .host() + .coordinator() + .send_as_host(message("ordinary", "a")) + .await?; + let report = deployment.host().coordinator().run_until_idle().await?; + assert_eq!(report.failed, 0); + assert_eq!(writes.load(Ordering::Relaxed), 1); + Ok::<_, anyhow::Error>(()) + }) +} +#[test] +fn isolated_native_cron_cannot_forward_work_with_interactive_origin() -> anyhow::Result<()> { + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + run(async { + let (options, _backend, model) = build_options().await; + mount( + &model, + Script { + action: Some("hivemind_send_hive".into()), + arguments: Some(serde_json::json!({"hive_id":"a","body":"forward"})), + ..Default::default() + }, + ) + .await; + let mut config = manifest()?; + config.workflows.push(Workflow { + id: "forward".into(), + schedule: "0 8 * * *".into(), + target: WorkflowTarget::Seat("alice".into()), + prompt: "try forwarding".into(), + enabled: true, + retries: 0, + single_flight: true, + }); + let deployment = Box::pin(HiveDeployment::build_with( + config.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + assert!( + !deployment + .runtime() + .cron() + .run_now("forward") + .await? + .success + ); + assert_eq!( + deployment.host().coordinator().read_transcript(None)?.len(), + 0 + ); + let report = deployment.host().coordinator().run_until_idle().await?; + assert_eq!(report.completed, 0); + Ok(()) + }) +} + +struct PausedAppend { + inner: tinyhivemind_hives::MemoryStorage, + pause: std::sync::atomic::AtomicBool, + entered: tokio::sync::Notify, + resume: tokio::sync::Notify, +} +impl tinyhivemind_hives::Storage for PausedAppend { + fn load(&self) -> tinyhivemind_hives::StorageFuture<'_, tinyhivemind_hives::StoredState> { + tinyhivemind_hives::Storage::load(&self.inner) + } + fn commit<'a>( + &'a self, + commit: tinyhivemind_hives::Commit<'a>, + ) -> tinyhivemind_hives::StorageFuture<'a, ()> { + Box::pin(async move { + if !commit.appended.is_empty() && self.pause.swap(false, Ordering::SeqCst) { + self.entered.notify_one(); + self.resume.notified().await; + } + tinyhivemind_hives::Storage::commit(&self.inner, commit).await + }) + } +} +#[test] +fn overlapping_hive_job_invocations_append_distinct_messages() -> anyhow::Result<()> { + let _lock = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + run(async { + let (mut options, _backend, model) = build_options().await; + mount(&model, Script::default()).await; + let storage = Arc::new(PausedAppend { + inner: tinyhivemind_hives::MemoryStorage::new(), + pause: std::sync::atomic::AtomicBool::new(false), + entered: tokio::sync::Notify::new(), + resume: tokio::sync::Notify::new(), + }); + options.coordinator_storage = Some(storage.clone()); + let mut config = manifest()?; + config.workflows.push(Workflow { + id: "overlap".into(), + schedule: "0 8 * * *".into(), + target: WorkflowTarget::Hive("a".into()), + prompt: "work".into(), + enabled: true, + retries: 0, + single_flight: false, + }); + let deployment = Box::pin(HiveDeployment::build_with( + config.validate()?, + Arc::new(Secrets), + options, + )) + .await?; + storage.pause.store(true, Ordering::SeqCst); + let job_id = deployment + .runtime() + .cron() + .list()? + .into_iter() + .find(|job| job.name == "overlap") + .ok_or_else(|| anyhow::anyhow!("missing overlap job"))? + .id; + let prefix = format!("cron:{job_id}:"); + let context = openhuman_embed::SystemJobContext { + job_id, + name: "overlap".into(), + }; + let first = tokio::spawn(openhuman_core::cron::system_job_handlers::dispatch( + context.clone(), + )); + storage.entered.notified().await; + let mut second = Box::pin(openhuman_core::cron::system_job_handlers::dispatch(context)); + // Poll through message-ID allocation while the first durable append is held. + std::future::poll_fn(|cx| { + assert!(second.as_mut().poll(cx).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + storage.resume.notify_one(); + let (first, second) = tokio::join!(first, second); + assert!(first?.is_some_and(|result| result.is_ok())); + assert!(second.is_some_and(|result| result.is_ok())); + let transcript = deployment.host().coordinator().read_transcript(None)?; + let messages: Vec<_> = transcript + .iter() + .filter(|message| message.message_id.starts_with(&prefix)) + .collect(); + assert_eq!( + messages.len(), + 2, + "each invocation must be durably accepted" + ); + assert_ne!(messages[0].message_id, messages[1].message_id); + Ok(()) + }) +} + +#[test] +fn invocation_identity_exhaustion_never_wraps_or_reuses_a_number() { + let counter = std::sync::atomic::AtomicU64::new(u64::MAX - 1); + assert_eq!( + super::super::workflow::next_invocation(&counter), + Ok(u64::MAX - 1) + ); + assert!(super::super::workflow::next_invocation(&counter).is_err()); + assert_eq!(counter.load(Ordering::Relaxed), u64::MAX); +} diff --git a/crates/tinyhivemind-openhuman/src/deploy/types.rs b/crates/tinyhivemind-openhuman/src/deploy/types.rs new file mode 100644 index 00000000..2d0e3e4e --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/types.rs @@ -0,0 +1,166 @@ +//! Deployment ports, resource policy and credential resolution. +use crate::config::SecretRef; +use openhuman_embed::{ApprovalHandler, HostTools, RuntimeConfig, SessionStoreProvider}; +use std::{collections::BTreeMap, sync::Arc}; +use tinyhivemind_core::approval::{ConsentEpoch, Millis, RememberedRefusal, StandingGrant}; +/// Errors identify structural operations, never native credential-bearing payloads. +#[derive(Debug, thiserror::Error)] +pub enum DeployError { + /// Configuration cannot be faithfully lowered by the selected ports. + #[error(transparent)] + Config(#[from] crate::config::ConfigError), + /// A credential reference could not be resolved. + #[error("cannot resolve secret reference")] + Secret, + /// The runtime refused construction. + #[error("runtime construction failed")] + Runtime, + /// A template or agent could not be registered. + #[error("agent construction failed for {0}")] + Agent(String), + /// Durable coordinator operations failed. + #[error("coordinator operation failed")] + Coordinator, + /// A workflow could not be registered or triggered. + #[error("workflow operation failed for {0}")] + Workflow(String), +} +/// Deployment result, with credential-safe errors. +pub type DeployResult = std::result::Result; +/// Resolve references only during deployment, without returning diagnostic payloads. +pub trait SecretResolver: Send + Sync { + /// Return the value or a sanitized resolution failure. + /// # Errors + /// Return `DeployError::Secret` when absent or unavailable. + fn resolve(&self, reference: &SecretRef) -> DeployResult; +} +/// Resolve environment references; store references require a host resolver. +#[derive(Debug)] +pub struct EnvironmentSecrets; +impl SecretResolver for EnvironmentSecrets { + fn resolve(&self, reference: &SecretRef) -> DeployResult { + match reference { + SecretRef::Env(name) => std::env::var(name).map_err(|_| DeployError::Secret), + SecretRef::Store(_) => Err(DeployError::Secret), + } + } +} +/// Trusted effect classifier for application and MCP tools. +/// Unknown names must return `Unclassified`; arguments never grant authority. +pub trait EffectClassifier: Send + Sync { + /// Trusted metadata for argument-sensitive rule evaluation. Unknown fields + /// remain unknown and rules requiring them refuse the call. + fn subject( + &self, + _context: &openhuman_embed::seams::ToolHookContext, + ) -> Option { + None + } + /// Additional host-authenticated rule attributes. Captured actor/session/hive + /// attributes supersede these values. + fn rule_context( + &self, + _context: &openhuman_embed::seams::ToolHookContext, + ) -> tinytools::RuleContext { + tinytools::RuleContext::new() + } + /// Describe the externally visible effect of this authenticated tool call. + fn classify( + &self, + context: &openhuman_embed::seams::ToolHookContext, + ) -> tinyhivemind_core::approval::Effect; +} +/// Host snapshot for the pure approval fold. Hosts advance consent epochs on new direction. +#[derive(Clone, Debug)] +pub struct ApprovalSnapshot { + /// People eligible to answer core approval questions. + pub people: Vec, + /// Current consent epoch. + pub epoch: ConsentEpoch, + /// Monotonic host reading. + pub now: Millis, + /// Previously issued authority. + pub grants: Vec, + /// Same-epoch refusals. + pub refusals: Vec, +} +impl Default for ApprovalSnapshot { + fn default() -> Self { + Self { + people: Vec::new(), + epoch: ConsentEpoch(0), + now: Millis(0), + grants: Vec::new(), + refusals: Vec::new(), + } + } +} +/// Supplies live consent state and sequence numbers from the host's shared ordering. +/// Capturing state does not execute or authorize a tool itself. +pub trait ApprovalContext: Send + Sync { + /// Current person/grant/refusal/epoch snapshot. + fn snapshot(&self) -> ApprovalSnapshot; + /// Mint the next sequence in the same ordering as standing grants. + fn next_sequence(&self) -> u64; +} +/// Optional host ports. No credentials or provider payloads are formatted by Debug. +#[derive(Clone, Default)] +pub struct BuildOptions { + /// Runtime config baseline, for host-only Docker or local runtime wiring. + pub config: Option, + /// Incidental backend endpoint, separate from model inference. + pub backend_url: Option, + /// Host-owned transcript/session stores, required by selector `host`. + pub session_store: Option>, + /// Host-owned memory engine, required by selector `host`. + pub memory_engine: Option>, + /// Host-owned durable coordinator store; otherwise in-memory. + pub coordinator_storage: Option>, + /// Retention is global to the durable coordinator store. + pub retention: tinyhivemind_hives::RetentionPolicy, + /// Trusted native provider, required for non-OpenAI provider kinds. + pub provider: Option, + /// Additional host tool source, retained in every episode replacement belt. + pub tools: Option, + /// Application/MCP classification beyond builtin known effects. + pub classifier: Option>, + /// Explicit handler for adapter-owned Ask requests. + pub approval_handler: Option>, + /// Host-owned consent/roster/grant/refusal snapshot. + pub approval: ApprovalSnapshot, + /// Optional live authority snapshot and shared sequence source. + pub approval_context: Option>, + /// Conservative per-request bound required when dollar budgets are declared. + pub call_budget: Option, + /// Optional explicit management authorization; default denies agent management. + pub management: Option>, + /// Extra hooks compose with deployment hooks and cannot override denials. + pub hooks: Option>, +} +impl std::fmt::Debug for BuildOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BuildOptions").finish_non_exhaustive() + } +} +/// Pure deliberation settings, deliberately exposed separately from completion scheduling. +#[derive(Clone, Debug)] +pub struct HivePolicyView { + /// Accepted pure episode policy. + pub episode: tinyhivemind_core::hive::EpisodePolicy, + /// Accepted pure task division policy. + pub division: tinyhivemind_core::hive::DivisionPolicy, + /// Optional shared recall namespace; seat memory bindings remain fixed. + pub memory_root: Option, +} +/// A correlated adapter-owned question; raw arguments are never retained here. +#[derive(Clone, Debug)] +pub struct PendingRequest { + /// Native sanitized request passed explicitly to the host handler. + pub request: openhuman_embed::PendingApproval, + /// Captured authoritative turn scope, absent for isolated native cron turns. + pub scope: Option, + /// Handler answer, waiting for an explicit host release. + pub decision: Option, +} +/// Seat handles sharing the deployment's single runtime. +pub type Seats = BTreeMap; diff --git a/crates/tinyhivemind-openhuman/src/deploy/workflow.rs b/crates/tinyhivemind-openhuman/src/deploy/workflow.rs new file mode 100644 index 00000000..81205e0f --- /dev/null +++ b/crates/tinyhivemind-openhuman/src/deploy/workflow.rs @@ -0,0 +1,96 @@ +//! Native isolated seat cron and durable coordinator hive jobs. +use super::{DeployError, DeployResult, HiveDeployment}; +use crate::config::WorkflowTarget; +use openhuman_embed::{JobSchedule, JobSpec}; +use std::sync::{ + Arc, Weak, + atomic::{AtomicU64, Ordering}, +}; +use tinyhivemind_hives::{Destination, SendMessage}; +pub(super) fn register(deployment: &HiveDeployment) -> DeployResult<()> { + for workflow in &deployment.config.workflows { + let schedule = JobSchedule::Cron { + expr: workflow.schedule.clone(), + tz: None, + }; + let mut spec = match &workflow.target { + WorkflowTarget::Seat(seat) => { + JobSpec::agent(&workflow.id, seat, &workflow.prompt, schedule) + } + WorkflowTarget::Hive(hive) => { + let weak: Weak = Arc::downgrade(&deployment.host.inner); + let hive = hive.clone(); + let prompt = workflow.prompt.clone(); + // Native system-job contexts expose no run ID. A runtime UUID + // namespaces this checked counter across deployment restarts; + // allocation is independent of asynchronous transcript commits. + let runtime_id = deployment.runtime.runtime_id().to_owned(); + let invocations = AtomicU64::new(0); + deployment + .runtime + .on_system_job(&workflow.id, move |context| { + let weak = weak.clone(); + let hive = hive.clone(); + let prompt = prompt.clone(); + let message_id = next_invocation(&invocations).map(|invocation| { + format!("cron:{}:{runtime_id}:{invocation}", context.job_id) + }); + async move { + let host = weak + .upgrade() + .ok_or_else(|| "deployment unavailable".to_string())?; + host.coordinator + .send_scheduled_as_host( + &context.job_id, + SendMessage { + message_id: message_id?, + sender: String::new(), + destination: Destination::Hive(hive), + body: prompt, + thread: None, + only_for: Vec::new(), + starters: Vec::new(), + }, + ) + .await + .map_err(|_| "scheduled enqueue failed".to_string())?; + let report = host + .coordinator + .run_until_idle() + .await + .map_err(|_| "scheduled drain failed".to_string())?; + if report.failed > 0 { + return Err("scheduled turn failed".to_string()); + } + Ok(()) + } + }) + .map_err(|_| DeployError::Workflow(workflow.id.clone()))?; + JobSpec::system(&workflow.id, &workflow.id, schedule) + } + }; + spec.enabled = workflow.enabled; + spec.retries = Some(workflow.retries); + spec.single_flight = workflow.single_flight; + deployment + .runtime + .cron() + .upsert(spec) + .map_err(|_| DeployError::Workflow(workflow.id.clone()))?; + } + Ok(()) +} + +// Compare-and-swap keeps checked allocation compatible with the workspace MSRV. +pub(super) fn next_invocation(counter: &AtomicU64) -> Result { + let mut current = counter.load(Ordering::Relaxed); + loop { + let next = current + .checked_add(1) + .ok_or_else(|| "workflow invocation identities exhausted".to_string())?; + match counter.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => return Ok(current), + Err(actual) => current = actual, + } + } +} diff --git a/crates/tinyhivemind-openhuman/src/error.rs b/crates/tinyhivemind-openhuman/src/error.rs index 0e5e367d..e85bc91f 100644 --- a/crates/tinyhivemind-openhuman/src/error.rs +++ b/crates/tinyhivemind-openhuman/src/error.rs @@ -88,3 +88,79 @@ pub enum Error { #[error(transparent)] Harness(#[from] anyhow::Error), } + +/// Runtime-independent manifest failures; never contain resolved credentials. +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + /// Invalid canonical JSON or unknown typed fields. + #[error("invalid config json")] + Json, + /// Invalid YAML delimiters or metadata. + #[error("invalid frontmatter in {0}")] + Frontmatter(String), + /// Filesystem read failed. + #[error("cannot read config file {path}")] + Io { + /// Relative or requested path, without file contents. + path: String, + /// Underlying filesystem failure. + #[source] + source: std::io::Error, + }, + /// Duplicate named declaration or membership. + #[error("duplicate {section} id {id}")] + DuplicateId { + /// Declaration namespace. + section: String, + /// Repeated id. + id: String, + }, + /// Absent template reference. + #[error("unknown profile {0}")] + UnknownProfile(String), + /// Absent authority reference. + #[error("unknown permission profile {0}")] + UnknownPermissionProfile(String), + /// Absent seat reference. + #[error("unknown seat {0}")] + UnknownSeat(String), + /// Absent hive reference. + #[error("unknown hive {0}")] + UnknownHive(String), + /// Absent Markdown context reference. + #[error("unknown context {0}")] + UnknownContext(String), + /// Empty role label. + #[error("invalid role for seat {0}")] + InvalidRole(String), + /// Overlay broadens its inherited authority. + #[error("permission widening in {0}")] + PermissionWidening(String), + /// Membership changes a live seat connection set. + #[error("conflicting mcp connections for seat {0}; use separate seats")] + ConflictingMcp(String), + /// Invalid bounded concurrency. + #[error("invalid width in {0}")] + InvalidWidth(String), + /// A credential field contains a literal. + #[error("inline secret in {field}")] + InlineSecret { + /// Structural field path only. + field: String, + }, + /// Empty hive cannot run. + #[error("empty hive {0}")] + EmptyHive(String), + /// Invalid declaration or native seat identifier. + #[error("invalid id in {0}")] + InvalidId(String), + /// Nonpositive or impossible resource limit. + #[error("invalid limit in {0}")] + InvalidLimit(String), + /// Malformed scheduling declaration. + #[error("invalid workflow {0}")] + InvalidWorkflow(String), + /// Selector cannot be lowered by the adapter. + #[error("unsupported setting {0}")] + UnsupportedSetting(String), +} diff --git a/crates/tinyhivemind-openhuman/src/host/README.md b/crates/tinyhivemind-openhuman/src/host/README.md index c46c41ed..e15161f8 100644 --- a/crates/tinyhivemind-openhuman/src/host/README.md +++ b/crates/tinyhivemind-openhuman/src/host/README.md @@ -20,6 +20,11 @@ before sharing or registering the host. Factories and attached tools carry weak host references to prevent a cycle. The host's progress sender must have a reader throughout each turn. +Ordinary supplied agents retain their own tool sources. `with_tools` opts into +replacement belts owned by the host, combining its extra source with the +episode-bound Hivemind source. Manifest deployments use that explicit ownership +to preserve extras while rebuilding the captured permissions for each turn. + Existing host conversations use the coordinator's atomic session registration API. A concurrently running scheduler sees the supplied session from its first claim. A claimed runner waits for attachment activation; cancellation during diff --git a/crates/tinyhivemind-openhuman/src/host/hooks_test.rs b/crates/tinyhivemind-openhuman/src/host/hooks_test.rs index 49f2e84a..92a013c4 100644 --- a/crates/tinyhivemind-openhuman/src/host/hooks_test.rs +++ b/crates/tinyhivemind-openhuman/src/host/hooks_test.rs @@ -163,6 +163,9 @@ fn the_turn_timeout_is_configurable_and_nonzero() { let failed = tinyhivemind_hives::AgentRunner::run( runner.as_ref(), tinyhivemind_hives::TurnRequest { + turn_id: String::new(), + scheduled_job_id: None, + teammates: Vec::new(), agent_id: "slow".into(), session_id: None, messages: vec![], diff --git a/crates/tinyhivemind-openhuman/src/host/memory_test.rs b/crates/tinyhivemind-openhuman/src/host/memory_test.rs index 67e9f071..a624f8a3 100644 --- a/crates/tinyhivemind-openhuman/src/host/memory_test.rs +++ b/crates/tinyhivemind-openhuman/src/host/memory_test.rs @@ -56,7 +56,7 @@ fn every_seat_registered_with_hive_memory_is_bound_under_the_hive_root() { host.hive_memory().map(HiveMemory::root), Some("team:hive-1") ); - for seat in ["scout", "critic", "writer"] { + for seat in ["scout", "reviewer-seat", "writer"] { let agent = host .register_spec(&runtime, AgentSpec::new(seat)) .await @@ -64,7 +64,7 @@ fn every_seat_registered_with_hive_memory_is_bound_under_the_hive_root() { assert_eq!(bound(&agent), (Some(seat), Some("team:hive-1"))); } let registered: Vec<_> = host.inner.agents.lock().unwrap().keys().cloned().collect(); - assert_eq!(registered, ["critic", "scout", "writer"]); + assert_eq!(registered, ["reviewer-seat", "scout", "writer"]); }); } @@ -79,7 +79,7 @@ fn seats_carry_no_binding_when_hive_memory_is_off() { .unwrap(); assert_eq!(bound(&agent), (None, None)); // An unbound agent built elsewhere registers as before. - let other = runtime.agent(AgentSpec::new("critic")).unwrap(); + let other = runtime.agent(AgentSpec::new("reviewer-seat")).unwrap(); host.register_agent(other).await.unwrap(); }); } @@ -109,7 +109,9 @@ fn rejects_a_seat_built_without_the_hive_binding() { matches!(&error, Error::UnboundSeat { seat, .. } if seat == "scout"), "{error}" ); - let elsewhere = hive("hive-2").bind(AgentSpec::new("critic")).unwrap(); + let elsewhere = hive("hive-2") + .bind(AgentSpec::new("reviewer-seat")) + .unwrap(); let error = host .register_agent(runtime.agent(elsewhere).unwrap()) .await diff --git a/crates/tinyhivemind-openhuman/src/host/mod.rs b/crates/tinyhivemind-openhuman/src/host/mod.rs index 829fed23..76e0ec51 100644 --- a/crates/tinyhivemind-openhuman/src/host/mod.rs +++ b/crates/tinyhivemind-openhuman/src/host/mod.rs @@ -21,6 +21,7 @@ pub(crate) struct Inner { management: Option, hooks: Arc, memory: Option, + extra_tools: Option, turn_timeout: Duration, pub send_policy: Option>, } @@ -63,6 +64,7 @@ impl OpenHumanHost { management: None, hooks: Arc::new(DefaultHooks), memory: None, + extra_tools: None, turn_timeout: TURN_TIMEOUT, send_policy: None, }), @@ -91,6 +93,14 @@ impl OpenHumanHost { inner.hooks = hooks; Ok(self) } + /// Supply extra host tools in every continuing turn's replacement belt. + /// Configure before registering or sharing this host. + /// # Errors + /// Refuse settings after registration or sharing. + pub fn with_tools(mut self, tools: HostTools) -> Result { + self.configurable_inner()?.extra_tools = Some(tools); + Ok(self) + } /// Gate the outbound tools — `hivemind_send_agent`, `hivemind_send_hive`, /// `hivemind_ask`, `hivemind_broadcast` — behind `policy`; configure /// before sharing or registration. Without one every send is admitted. @@ -219,13 +229,29 @@ impl OpenHumanHost { let managed = self.inner.management.is_some(); let activation = Arc::new(Activation::default()); let attached_activation = activation.clone(); - let source: HostTools = Arc::new(move |_| { - let tools = crate::tools::belt(&actor, &weak, &attached_activation, managed); + let extra = self.inner.extra_tools.clone(); + let source: HostTools = Arc::new(move |context| { + let tools = crate::tools::belt_in_session( + &actor, + &weak, + &attached_activation, + managed, + context.session_id(), + ); let permanent = tools.iter().map(|tool| tool.name().to_owned()).collect(); - HostTurnTools { + let mut belt = HostTurnTools { permanent, ..HostTurnTools::advertised(tools) + }; + if let Some(extra) = &extra { + let other = extra(context); + belt.tools.extend(other.tools); + belt.permanent.extend(other.permanent); + belt.visible.extend(other.visible); + belt.withheld.extend(other.withheld); + belt.policy = other.policy; } + belt }); agent.attach_tools("hivemind", source.clone())?; let runner = Arc::new(SuppliedRunner { @@ -233,6 +259,7 @@ impl OpenHumanHost { hooks: self.inner.hooks.clone(), activation: activation.clone(), timeout: self.inner.turn_timeout, + source: self.inner.extra_tools.as_ref().map(|_| source.clone()), }); // Keep the exact source for retry even if durable registration fails. entries.insert( diff --git a/crates/tinyhivemind-openhuman/src/host/replace_test.rs b/crates/tinyhivemind-openhuman/src/host/replace_test.rs index bf07deba..73157c3d 100644 --- a/crates/tinyhivemind-openhuman/src/host/replace_test.rs +++ b/crates/tinyhivemind-openhuman/src/host/replace_test.rs @@ -131,6 +131,9 @@ fn replacement_waits_for_the_running_turn_and_a_failed_build_leaves_no_handle() let failed = tinyhivemind_hives::AgentRunner::run( runner(&host).as_ref(), tinyhivemind_hives::TurnRequest { + turn_id: String::new(), + scheduled_job_id: None, + teammates: Vec::new(), agent_id: "swap".into(), session_id: None, messages: vec![], diff --git a/crates/tinyhivemind-openhuman/src/host/runner.rs b/crates/tinyhivemind-openhuman/src/host/runner.rs index 326db812..d235e497 100644 --- a/crates/tinyhivemind-openhuman/src/host/runner.rs +++ b/crates/tinyhivemind-openhuman/src/host/runner.rs @@ -15,6 +15,7 @@ pub(super) struct SuppliedRunner { pub hooks: Arc, pub activation: Arc, pub timeout: Duration, + pub source: Option, } impl AgentRunner for SuppliedRunner { fn run(&self, request: TurnRequest) -> TurnFuture { @@ -22,31 +23,61 @@ impl AgentRunner for SuppliedRunner { let hooks = self.hooks.clone(); let activation = self.activation.clone(); let timeout = self.timeout; + let source = self.source.clone(); Box::pin(async move { activation.wait().await; let handle = handle.read_owned().await; let agent = handle .clone() .ok_or_else(|| map_error(&Error::NoHandle(request.agent_id.clone())))?; - let scope = TurnScope::from_request(&request); + let mut scope = TurnScope::from_request(&request); + let session = request + .session_id + .clone() + .unwrap_or_else(|| format!("hivemind:{}:{}", agent.runtime_id(), request.agent_id)); + scope.session_id = Some(session.clone()); + let timeout = hooks.turn_timeout(&scope).unwrap_or(timeout); + if timeout.is_zero() { + return Err(map_error(&Error::InvalidTurnTimeout)); + } let usage = Arc::new(Mutex::new(None)); let meter = usage.clone(); - let prompt = render(&request)?; + let prompt = format!("{}\n{}", hooks.context(&scope), render(&request)?); let mut turn = agent.turn(prompt).meter(move |value| { *meter.lock().unwrap_or_else(PoisonError::into_inner) = value; }); - if let Some(session) = request.session_id { - turn = turn.session(session); + turn = turn.session(session.clone()); + if let Some(source) = source { + turn = turn.tools(move |context| source(context)); } turn = configure(turn, &hooks.prepare(&scope)); + turn = hooks.configure(&scope, turn).timeout(timeout); + if let Some(job_id) = &scope.scheduled_job_id { + turn = turn.origin( + openhuman_core::agent::turn_origin::AgentTurnOrigin::TrustedAutomation { + job_id: job_id.clone(), + source: openhuman_core::agent::turn_origin::TrustedAutomationSource::Cron, + }, + ); + } if let Some(progress) = hooks.progress(&scope) { turn = turn.on_progress(progress); } let run = Box::pin(async move { - Box::pin(tokio::time::timeout(timeout, turn.send())) - .await - .map_err(|_| Error::TimedOut)? - .map_err(|e| Error::Harness(anyhow::anyhow!(e.to_string()))) + let started = tokio::time::Instant::now(); + turn.send().await.map_err(|error| { + // The native deadline relay can cancel the session before + // its deadline arm wins, returning a cancellation RPC error. + // Dispatch has already awaited cleanup; use our authoritative + // bound without inspecting credential-bearing native text. + if matches!(error, openhuman_embed::CoreError::DeadlineExceeded { .. }) + || started.elapsed() >= timeout + { + Error::TimedOut + } else { + Error::Harness(anyhow::anyhow!("native turn failed")) + } + }) }); let settled = hooks.wrap_turn(&scope, run).await; let last = usage.lock().unwrap_or_else(PoisonError::into_inner).take(); @@ -59,8 +90,17 @@ impl AgentRunner for SuppliedRunner { .unwrap_or_else(|error| TurnDisposition::Failed(error.to_string())), }), Err(error) => { - let _ = finalized; - Err(map_error(&error)) + if !matches!(error, Error::TimedOut) + && matches!(finalized, Ok(TurnDisposition::Parked)) + { + Ok(TurnOutcome { + session_id: session, + reply: None, + disposition: TurnDisposition::Parked, + }) + } else { + Err(map_error(&error)) + } } } }) diff --git a/crates/tinyhivemind-openhuman/src/host/runner_test.rs b/crates/tinyhivemind-openhuman/src/host/runner_test.rs index 8a1e9d4f..2b04683d 100644 --- a/crates/tinyhivemind-openhuman/src/host/runner_test.rs +++ b/crates/tinyhivemind-openhuman/src/host/runner_test.rs @@ -5,6 +5,9 @@ use super::*; #[test] fn renders_attribution_and_explicit_episode_without_reseeding() { let request = TurnRequest { + turn_id: String::new(), + scheduled_job_id: None, + teammates: Vec::new(), agent_id: "a".into(), session_id: Some("history".into()), messages: vec![], @@ -25,6 +28,9 @@ fn renders_attribution_and_explicit_episode_without_reseeding() { #[test] fn renders_a_release_note_ahead_of_the_attributed_context() { let request = TurnRequest { + turn_id: String::new(), + scheduled_job_id: None, + teammates: Vec::new(), agent_id: "a".into(), session_id: None, messages: vec![], diff --git a/crates/tinyhivemind-openhuman/src/host/test.rs b/crates/tinyhivemind-openhuman/src/host/test.rs index bce880d5..ee9f67e8 100644 --- a/crates/tinyhivemind-openhuman/src/host/test.rs +++ b/crates/tinyhivemind-openhuman/src/host/test.rs @@ -2,10 +2,10 @@ // Test assertions deliberately panic on invalid fixture construction. #![allow(clippy::unwrap_used)] use super::*; +use crate::RUNTIME_LOCK; use crate::offline; use openhuman_embed::{AgentSpec, Runtime, Workspace}; use tinyhivemind_hives::{CoordinatorOptions, MemoryStorage}; -static RUNTIME_LOCK: Mutex<()> = Mutex::new(()); fn executor() -> tokio::runtime::Runtime { tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -75,6 +75,9 @@ fn supplied_clones_attach_once_and_drop_services_without_cycle() { ); assert!(format!("{bound:?}").contains("agent")); let scope = TurnScope::from_request(&tinyhivemind_hives::TurnRequest { + turn_id: String::new(), + scheduled_job_id: None, + teammates: Vec::new(), agent_id: "agent".into(), session_id: None, messages: vec![], @@ -96,6 +99,9 @@ fn supplied_clones_attach_once_and_drop_services_without_cycle() { reply: "passthrough".into(), session_id: "s".into(), usage: None, + structured: None, + finish_reason: None, + answered_model: None, }) }), ) @@ -311,7 +317,7 @@ fn continuing_runner_preserves_history_and_finalizes_all_outcomes() { .system_prompt("HOST_CONFIGURED_PROMPT")).unwrap(); host.register_agent(agent).await.unwrap(); let runner = host.inner.agents.lock().unwrap()["metered"].runner.clone(); - let mut request = TurnRequest { agent_id:"metered".into(),session_id:None,messages:vec![],memberships:vec![],episode:None,resumption:None }; + let mut request = TurnRequest { turn_id: String::new(), scheduled_job_id: None, teammates: Vec::new(), agent_id:"metered".into(),session_id:None,messages:vec![],memberships:vec![],episode:None,resumption:None }; let first = runner.run(request.clone()).await.unwrap(); assert_eq!(first.reply.as_deref(),Some("HOST_REPLY")); request.session_id = Some(first.session_id.clone()); diff --git a/crates/tinyhivemind-openhuman/src/host/types.rs b/crates/tinyhivemind-openhuman/src/host/types.rs index 1e8b8ed5..62775a7a 100644 --- a/crates/tinyhivemind-openhuman/src/host/types.rs +++ b/crates/tinyhivemind-openhuman/src/host/types.rs @@ -52,6 +52,17 @@ pub trait SendAuthorizer: Send + Sync { /// Return a refusal, conventionally [`crate::Error::SendDenied`], whose /// message the model reads. fn authorize(&self, actor: &str, request: &SendRequest) -> Result<()>; + /// Authorize a send from the native session bound by the tool source. + /// # Errors + /// Returns the same refusal as `authorize` unless the host narrows sessions. + fn authorize_in_session( + &self, + actor: &str, + request: &SendRequest, + _session: Option<&str>, + ) -> Result<()> { + self.authorize(actor, request) + } } /// Outbound operation offered to the host [`SendAuthorizer`]. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] @@ -133,6 +144,12 @@ pub type TurnProgressSink = /// approval, or file a card per hive, episode and thread rather than per agent. #[derive(Clone, Debug, Eq, PartialEq)] pub struct TurnScope { + /// Durable reservation identity from the authoritative coordinator request. + pub turn_id: String, + /// Native session authenticated by the runner before any hooks execute. + pub session_id: Option, + /// Durable trusted scheduled provenance; cannot be supplied by message text. + pub scheduled_job_id: Option, /// The agent taking the turn. pub agent_id: String, /// Active conductor assignment, absent for a direct message. @@ -164,6 +181,9 @@ impl TurnScope { (None, None) => Destination::Agent(request.agent_id.clone()), }; Self { + turn_id: request.turn_id.clone(), + scheduled_job_id: request.scheduled_job_id.clone(), + session_id: request.session_id.clone(), agent_id: request.agent_id.clone(), episode: request.episode.clone(), message_ids: request @@ -189,8 +209,25 @@ pub struct TurnOptions { } /// Optional per-turn options, progress, usage, approval and scope hooks. /// -/// Called in order: `prepare`, `progress`, `wrap_turn`, `after_turn`. +/// Called in order: `turn_timeout`, `context`, `prepare`, `configure`, +/// `progress`, `wrap_turn`, then `after_turn`. pub trait TurnHooks: Send + Sync { + /// Override the host's fallback native deadline for this captured turn. + /// `None` retains the fallback; a zero duration refuses dispatch. + /// This deadline is applied after `configure` so timeout classification and + /// native cancellation use the same bound. + fn turn_timeout(&self, _scope: &TurnScope) -> Option { + None + } + /// Add context bound to this authoritative coordinator request. + fn context(&self, _scope: &TurnScope) -> String { + String::new() + } + /// Configure a native turn, adding permission hooks or model budgets. + /// Use `turn_timeout` for deadline overrides; its bound is applied afterward. + fn configure(&self, _scope: &TurnScope, turn: openhuman_embed::Turn) -> openhuman_embed::Turn { + turn + } /// Choose this turn's options before it is built. fn prepare(&self, _scope: &TurnScope) -> TurnOptions { TurnOptions::default() diff --git a/crates/tinyhivemind-openhuman/src/host/types_test.rs b/crates/tinyhivemind-openhuman/src/host/types_test.rs index d3a7dc69..4d9be0aa 100644 --- a/crates/tinyhivemind-openhuman/src/host/types_test.rs +++ b/crates/tinyhivemind-openhuman/src/host/types_test.rs @@ -49,6 +49,7 @@ fn turn_scope_names_the_episode_its_thread_and_distinct_senders() { use super::TurnScope; use tinyhivemind_hives::{Destination, EpisodeContext, Message, TurnRequest}; let row = |id: &str, sender: &str, thread| Message { + scheduled_job_id: None, message_id: id.into(), sequence: 0, sender: sender.into(), @@ -65,6 +66,9 @@ fn turn_scope_names_the_episode_its_thread_and_distinct_senders() { brief: "brief".into(), }; let request = TurnRequest { + turn_id: String::new(), + scheduled_job_id: None, + teammates: Vec::new(), agent_id: "a".into(), session_id: None, messages: vec![ @@ -86,6 +90,7 @@ fn turn_scope_names_the_episode_its_thread_and_distinct_senders() { let mut direct = request; direct.episode = None; direct.messages = vec![Message { + scheduled_job_id: None, destination: Destination::Agent("a".into()), thread: None, ..row("d1", tinyhivemind_hives::HOST_ID, None) diff --git a/crates/tinyhivemind-openhuman/src/lib.rs b/crates/tinyhivemind-openhuman/src/lib.rs index 83819974..d4d8233c 100644 --- a/crates/tinyhivemind-openhuman/src/lib.rs +++ b/crates/tinyhivemind-openhuman/src/lib.rs @@ -11,6 +11,8 @@ //! host.register_agent(agent).await?; //! # Ok(()) } //! ``` +pub mod config; +pub mod deploy; mod error; mod host; pub mod journal; @@ -27,3 +29,5 @@ pub use host::{ }; pub use journal::MemoryLog; pub use memory::{HiveMemory, HiveMemoryStore}; +#[cfg(test)] +pub(crate) static RUNTIME_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); diff --git a/crates/tinyhivemind-openhuman/src/memory/store_test.rs b/crates/tinyhivemind-openhuman/src/memory/store_test.rs index ebdef91d..72c6f387 100644 --- a/crates/tinyhivemind-openhuman/src/memory/store_test.rs +++ b/crates/tinyhivemind-openhuman/src/memory/store_test.rs @@ -424,6 +424,11 @@ fn the_store_takes_the_hive_budget_and_reports_itself() { #[test] fn from_config_needs_an_engine_openhuman_would_bind() { + // Engine binding is process-global, shared with runtime-backed fixtures. + let _guard = crate::RUNTIME_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + openhuman_core::memory::engine::clear_host_engine(); let mut config = RuntimeConfig::default(); config.memory.engine = String::new(); let error = diff --git a/crates/tinyhivemind-openhuman/src/tools/direct_test.rs b/crates/tinyhivemind-openhuman/src/tools/direct_test.rs index 8114957f..863c9929 100644 --- a/crates/tinyhivemind-openhuman/src/tools/direct_test.rs +++ b/crates/tinyhivemind-openhuman/src/tools/direct_test.rs @@ -37,6 +37,7 @@ async fn read_tool_observes_direct_replies_as_its_bound_caller() { let activation = Arc::new(Activation::default()); activation.activate(); let tool = |actor: &str, kind| HiveTool { + session: None, actor: actor.into(), host: Arc::downgrade(&host.inner), kind, diff --git a/crates/tinyhivemind-openhuman/src/tools/mod.rs b/crates/tinyhivemind-openhuman/src/tools/mod.rs index e33f5d93..608c6a68 100644 --- a/crates/tinyhivemind-openhuman/src/tools/mod.rs +++ b/crates/tinyhivemind-openhuman/src/tools/mod.rs @@ -8,17 +8,28 @@ use std::sync::Weak; use tinyhivemind_hives::{Destination, EpisodeAction, HiveInfo, SendMessage}; use tinytools::{PermissionLevel, Tool, ToolResult}; use types::Kind; +#[cfg(test)] pub(crate) fn belt( actor: &str, host: &Weak, activation: &std::sync::Arc, managed: bool, +) -> Vec> { + belt_in_session(actor, host, activation, managed, None) +} +pub(crate) fn belt_in_session( + actor: &str, + host: &Weak, + activation: &std::sync::Arc, + managed: bool, + session: Option<&str>, ) -> Vec> { Kind::all(managed) .into_iter() .map(|kind| { Box::new(HiveTool { actor: actor.into(), + session: session.map(str::to_owned), host: host.clone(), kind, activation: activation.clone(), @@ -28,6 +39,7 @@ pub(crate) fn belt( } struct HiveTool { actor: String, + session: Option, host: Weak, kind: Kind, activation: std::sync::Arc, @@ -45,8 +57,22 @@ impl Tool for HiveTool { } fn permission_level(&self) -> PermissionLevel { match self.kind { - Kind::ListHives | Kind::ListAgents | Kind::Read => PermissionLevel::ReadOnly, - _ => PermissionLevel::Write, + Kind::CreateHive | Kind::CreateAgent | Kind::JoinHive | Kind::LeaveHive => { + PermissionLevel::Write + } + // Protocol state is internal to the shared conversation. Actor, + // episode and send policy checks still authorize each operation. + _ => PermissionLevel::ReadOnly, + } + } + fn policy(&self) -> tinytools::ToolPolicy { + if self.permission_level() == PermissionLevel::ReadOnly { + tinytools::ToolPolicy::classified().with_side_effects(tinytools::ToolSideEffects { + read_only: true, + ..Default::default() + }) + } else { + tinytools::ToolPolicy::default() } } async fn execute(&self, args: Value) -> anyhow::Result { @@ -74,7 +100,7 @@ impl HiveTool { let actor = &self.actor; if let (Some(policy), Some(request)) = (&host.inner.send_policy, outbound(self.kind, &args)) { - policy.authorize(actor, &request)?; + policy.authorize_in_session(actor, &request, self.session.as_deref())?; } match self.kind { Kind::ListHives => Ok(serde_json::to_value( diff --git a/crates/tinyhivemind-openhuman/src/tools/policy_test.rs b/crates/tinyhivemind-openhuman/src/tools/policy_test.rs index e1cb524c..a72b500b 100644 --- a/crates/tinyhivemind-openhuman/src/tools/policy_test.rs +++ b/crates/tinyhivemind-openhuman/src/tools/policy_test.rs @@ -44,6 +44,7 @@ async fn policed() -> (OpenHumanHost, Arc) { } fn tool(host: &OpenHumanHost, kind: Kind) -> HiveTool { HiveTool { + session: None, actor: "a".into(), host: Arc::downgrade(&host.inner), kind, diff --git a/crates/tinyhivemind-openhuman/src/tools/test.rs b/crates/tinyhivemind-openhuman/src/tools/test.rs index 17e9d9c0..a6929c64 100644 --- a/crates/tinyhivemind-openhuman/src/tools/test.rs +++ b/crates/tinyhivemind-openhuman/src/tools/test.rs @@ -44,6 +44,7 @@ async fn stable_vocabulary_has_one_schema_and_rejects_impersonation() { ); assert!(Kind::ListAgents.validate(&serde_json::json!([])).is_err()); let tool = HiveTool { + session: None, actor: "a".into(), host: Weak::new(), kind: Kind::ListAgents, @@ -88,6 +89,7 @@ async fn bound_native_calls_validate_destinations_and_manage_membership() { .with_management(Arc::new(Factory), Arc::new(Authorize)) .unwrap(); let call = |kind| HiveTool { + session: None, actor: "a".into(), host: Arc::downgrade(&host.inner), kind, @@ -155,6 +157,7 @@ async fn bound_native_calls_validate_destinations_and_manage_membership() { ) .await; let denied = HiveTool { + session: None, actor: "b".into(), host: Arc::downgrade(&host.inner), kind: Kind::CreateHive, @@ -177,6 +180,7 @@ impl tinyhivemind_hives::AgentRunner for ActiveTools { let episode = request.episode.unwrap(); for kind in [Kind::Post, Kind::Complete] { let tool = HiveTool { + session: None, actor: request.agent_id.clone(), host: weak.clone(), kind, diff --git a/deny.toml b/deny.toml index f5beddee..16d77200 100644 --- a/deny.toml +++ b/deny.toml @@ -30,6 +30,13 @@ allow = [ ] confidence-threshold = 0.9 +# The pinned OpenHuman embed stack reaches this OSI-approved, permissive license +# through jsonschema; keep the exception scoped to this exact transitive crate. +[[licenses.exceptions]] +name = "borrow-or-share" +version = "0.2.4" +allow = ["MIT-0"] + [bans] # Duplicate versions bloat build times; review them rather than ignoring them. multiple-versions = "warn" @@ -63,6 +70,11 @@ skip = [ # manifest-level wildcard finding, and its dependency tree stays subject to # every other ban. { name = "tinyjuice", version = "0.6.0" }, + # tinyflows is vendored inside the pinned OpenHuman submodule. Its SQLite + # crate uses a tagged git dependency but omits the version cargo-deny + # requires for public packages; the lockfile and OpenHuman pin guard fix + # the resolved source. Skip this crate's manifest wildcard finding only. + { name = "tinyflows-sqlite", version = "0.1.0" }, ] # Crates that must never enter the dependency graph. deny = [] diff --git a/docs/plans/2026-10-10-openhuman-hive-config.md b/docs/plans/2026-10-10-openhuman-hive-config.md new file mode 100644 index 00000000..88a1790c --- /dev/null +++ b/docs/plans/2026-10-10-openhuman-hive-config.md @@ -0,0 +1,273 @@ +# Implement configurable OpenHuman hives + +Status: Accepted implementation sequence for +[#113](https://github.com/tinyhumansai/tinyhivemind/issues/113), delivered in +one ready-for-review PR. Contract: +[openhuman-hive-config](../specs/openhuman-hive-config.md). + +The host defines one runtime, registers reusable templates, creates one +persistent agent per seat, then joins seats to any number of hives. Memory +bindings stay fixed. Hive permissions intersect seat authority. No new crate, +second transcript journal, `ProfileRuntime` or runtime-global turn overlay. + +## 1. Refresh and prove the OpenHuman boundary (#114) + +1. In `vendor/openhuman`, inspect the latest canonical main APIs and advance + the gitlink to a reviewed commit that includes `RuntimeBuilder` defaults, + templates, agent memory, permission hooks, `Turn::tools` and awaited timeout. + Keep the vendor as a submodule; do not patch its source to hide mismatches. +2. Update only API-incompatible adapter/example call sites. Inspect + `crates/tinyhivemind-openhuman/src/host/mod.rs`, `host/runner.rs` and + `examples/openhuman/src/` before changing assumptions about host belts. +3. Test current supplied-agent registration, continuing sessions and native + tool binding against the new pin. Pin the fact that `Turn::tools` replaces + host tools while `can_use_tool` callbacks compose through denial. +4. Run focused adapter tests; stop on upstream incompatibility that prevents + the existing contract. Do not introduce broad lints or ignored tests. + +## 2. Record the accepted behavior (#115) + +Create `docs/specs/openhuman-hive-config.md` before code. Link this plan and +both README indexes. Settle per-seat memory (`seat:` unless explicitly +bound), immutable MCP connections, named credential references, policy +narrowing and actual workflow lowering. The spec remains authoritative if an +issue's API sketch differs from the current upstream API. + +## 3. Add policy serde without changing defaults (#116) + +Files: + +- `crates/tinyhivemind-core/src/driver/conduct/mod.rs` and its test module. +- `crates/tinyhivemind-core/src/hive/division/types.rs` and its test module. +- `crates/tinyhivemind-hives/src/coordinator/types.rs` and matching tests. +- The `RetentionPolicy` definition/test module, needed by `CoordinatorOptions`. + +Write wire-form and empty-object/default tests first. Add `Serialize` and +`Deserialize` with `#[serde(default)]` where omission inherits current defaults. +Keep existing serialized enum spelling consistent with adjacent core types. +Verify `cargo test -p tinyhivemind-core`, `cargo test -p tinyhivemind-hives` and +`.github/scripts/assert-pure.sh`. No new runtime/transport dependency in core. + +## 4. Parse and validate the manifest (#116) + +Create the adapter's `src/config/`: + +| File | Ownership | +| --- | --- | +| `mod.rs` | Module docs, small deliberate exports and `#[cfg(test)] mod test;` | +| `types.rs` | `HiveConfig`, `RuntimeSection`, `Profile`, `PermissionProfile`, `Seat`, `HiveSpec`, `Membership`, `Workflow`, `SecretRef`, validated wrapper | +| `parse.rs` | JSON/value parsing, directory loading, Markdown/frontmatter parsing | +| `validate.rs` | Referential checks and effective-policy/MCP/limit validation | +| `README.md` | Wire contract, precedence and file map | +| `test.rs` or `test/` | Behavioral tests and shared fixtures | + +Add `ConfigError` to `src/error.rs` and deliberate exports in `src/lib.rs`. +Use the existing core/hives policy types in `HiveSpec`, not copied structs. +Use typed override fields rather than an arbitrary secret-bearing JSON blob. + +Public boundary: + +```rust +HiveConfig::from_json(&str) -> Result +HiveConfig::from_value(serde_json::Value) -> Result +HiveConfig::load_dir(path) -> Result +HiveConfig::validate(self) -> Result +``` + +The validated wrapper's inner value is private; provide immutable access or +consuming access to validated data. Parsing must not resolve a credential or +construct OpenHuman objects. Injected ports belong to deployment build options, +not the serde data type. + +Test-first sequence: + +1. Full JSON fixture round trip; pin every type's wire form and defaults. +2. Unknown references and duplicates in every namespace and membership. +3. Zero/invalid limits, empty role labels and empty hives. +4. Every widening dimension: access, sandbox, tool allow/deny, send, + approval/grants. Test a valid restriction independently. +5. Same MCP set in different orders succeeds; different endpoints, + transports, credential refs or tool selections fail for one shared seat. +6. Reject inline secrets in provider, MCP and dynamic override fields. +7. Load Markdown profiles/context relative to config root, strip valid + frontmatter, preserve body/order, reject malformed metadata and duplicate + definitions. Missing files return a typed IO error. +8. Workflow target/schedule checks and unsupported runtime selector errors. + +Then implement the smallest parser and validator that passes those tests. +Split files along behavioral seams before reaching 700 Rust/500 Markdown +lines. Update crate and src README indexes. + +## 5. Give the coordinator explicit hive settings (#117) + +Inspect `crates/tinyhivemind-hives/src/coordinator/conduct.rs`, coordinator state +and routing before choosing the smallest host-neutral API. Add a narrow +per-hive policy/role configuration boundary that retains existing defaults for +unconfigured hosts. Use host-neutral `HiveSettings` with +`options: CoordinatorOptions`, `routing: RoutingPolicy` and +`roles: BTreeMap`. Persist settings and freeze them atomically +in `EpisodeRecord` when opening work. Later settings changes affect new +episodes only. No OpenHuman types may enter hives or core. + +Write regressions first proving two hives actually use different configured +conduct/routing/coordinator bounds, and proving a member's role reaches +`RouteCandidate`/`BriefedTeammate`. Configure policy before opening any episode. +Intersect `HiveSpec.coordinator.conduct_policy` and the separate hive +`conduct` field using the minimum `turn_wall` and minimum `child_turn_wall`; +test both orderings so neither configured bound is ignored. Omitted settings +keep existing core defaults. Preserve the global concurrency bound. +Joining/leaving/recovering must not +throw away retained role/policy metadata. Expose EpisodePolicy/DivisionPolicy +through an explicit per-hive deployment policy view for pure deliberation +hosts; reject unsupported application to the completion-only path. Do not +merely render policies in a +prompt while the coordinator uses old global defaults. + +## 6. Lower the validated config (#117) + +Create `src/deploy/mod.rs`, `types.rs`, runtime/profile/seat lowering modules, +`permission.rs`, `workflow.rs`, tests and `README.md`. The deployment owns the +single `Runtime`, `OpenHumanHost` and seat-to-`Agent` map. Its public boundary +is `HiveDeployment::build(validated, secrets)` plus a build-options variant +for host-owned memory/session/approval/sandbox ports. Build may be async to +match upstream runtime construction; document the exact implemented signature. + +Test with a local wiremock endpoint and isolated runtime lifetime: + +1. Runtime defaults lower to the matching `RuntimeBuilder` methods; a declared + unsupported value errors rather than being ignored. Explicit built-in + storage/engine choices and injected ports both have observable tests. +2. Register each profile through `define_template`; retain additional + profile-only settings for lowering onto `AgentSpec`. +3. Create seats through `AgentSpec::new(id).extends(profile)` and + `host.register_spec`, applying model, access, MCP, skills, subagents, + tool scope and limits. Assert every seat shares one runtime identity. +4. Preserve explicit memory binding; otherwise assign `seat:`. Assert a + seat in two hives has one registration and one continuing session. +5. Authorize deployment's create/join operations, create empty shells only + internally if needed, add members and configure role/policy before turns. +6. Resolve `SecretRef` only here through a host resolver. Test missing refs + and prove resolved values appear in neither Debug nor errors. + +`ManifestFactory` stores the runtime and validated profile catalog and +implements existing `AgentFactory::create`. Dynamic `CreateAgent` requests +must parse typed nonsecret overrides, validate their permissions and use the +same seat-lowering path; test known and unknown templates, inline-secret +rejection and narrowing. Ensure dynamically created handles receive the same +memory/turn policy treatment when registered by the host. + +## 7. Enforce permissions and captured turn overlays (#117) + +Modify `src/host/runner.rs` and the smallest host settings boundary needed to +capture each hive's role/context/tool restrictions. Keep custom hooks +composable; never install a process-global current-hive variable. + +Regression-first tasks: + +1. Run the same agent in hive A and B with different roles and context, + capture provider requests, and verify each turn uses the correct overlay. +2. Set `Turn::tools` to the episode-bound host belt, and apply effective + `can_use_tool` to builtin/MCP/host tools. Prove A allows and B denies the + same classified operation. Agent-level denials must survive turn allows. +3. Map authenticated call context to core `ApprovalRequest` and `Action`; + use explicit classification and deny `Unclassified`. +4. Invoke `approve` with supplied actor/person roster, monotonic sequence, + consent epoch, grants and refusals. Test Allow, Deny and Ask independently. +5. On hook Ask, record an adapter-owned pending request with call correlation + and explicitly invoke the injectable `ApprovalHandler`; native approval + subscriptions alone cannot observe a hook-created request. Deny the + original tool call, return Parked in `after_turn`, then accept the + asynchronous correlated handler decision and explicitly release with a + resumption note. Test handler invocation, no mutation before approval, + correlation, release, and no automatic replay of the refused call or turn. +6. Enforce all send operations through the seat plus active-hive + `SendAuthorizer`; a direct message uses the seat baseline. +7. Replace outer drop-only timeout with `Turn::timeout` or an awaited + cancellation handle. Test an interrupted slow tool releases its scope + before the runner returns; retain session/uncertain-effect rules. +8. Lower per-seat iterations and model budget, testing explicit settings and + fallback to the existing host timeout. + +## 8. Register workflows (#117) + +Seat workflows use `JobSpec` / `JobTarget::Agent`. Upstream creates isolated +cron sessions and runs outside the coordinator runner: install baseline +permission/sandbox/iteration/timeout/budget policy at agent scope. Do not +promise continuing coordinator sessions, membership overlays or coordinator +parking for these jobs. Test isolation and baseline enforcement directly. + +Current upstream has no `JobTarget::Hive`; use its supported System-job handler +to enqueue an authorized hive message and drain coordinator work. Add a +host-neutral scheduled-origin/job-id value at the coordinator message/episode +boundary: optional `scheduled_job_id` on `Message` and `TurnRequest`, plus +`send_scheduled_as_host` for trusted provenance. Persist it with that work, +atomically with captured episode settings. Test that retry deduplication +rejects changing an accepted message's origin. Carry +that value into child/delegated assignments and queued/recovered work. In the +OpenHuman runner, explicitly scope each such turn as +`TrustedAutomation { Cron }`; do not rely on task-local origin at enqueue or +on the System-job handler's surrounding scope. + +Write regressions first for mutating builtin, MCP and host tools in the first +scheduled hive turn and a delegated turn. Assert refusal happens before the +effect executes even when normal tool/approval rules permit it. Add a queued +or recovered-work test proving automation provenance survives after the +handler returns. Untrusted message content must not remove that restriction. +Register and trigger jobs directly in tests without waiting for real time. + +Build with cron services inactive while assembling templates, agents and +hives. Register all jobs after validation and start services only when the +configured lifecycle explicitly requests it. Verify schedule, enabled state, +retries and single-flight are actually retained. Errors must identify job ids +without including credentials or prompts. + +## 9. Migrate examples and prove equivalence (#118) + +Create checked-in config directories under `examples/openhuman/hives/` named +`basic_hive`, `deepswe_hive`, `pe1006_hive` and `multi_hive`, each with +`hive.json`, `profiles/*.md` and `context/*.md` as needed. + +- `basic_hive`: alice/bob, read-only access, ephemeral workspace. +- `deepswe_hive`: lead/implementer/tester/reviewer; existing MCP endpoints and + allowlists, Named scope, 16 iterations, temperature zero, width four. +- `pe1006_hive`: theory/solver/checker/lead/researcher; existing disallow rules, + memory, AGENTS/MEMORY/TASK templates moved to context files. +- `multi_hive`: three profiles, four seats, two hives; a shared seat has + different roles and narrowed authority in the second hive. + +Write lowering-equivalence tests for each migrated binary before replacing +its manual seat setup. Keep CLI flags and genuine runtime-only Docker wiring +through build options. Offline basic and multi-hive runs use wiremock; both +multi-hive episodes must settle, and the same tool must succeed in A and be +refused in B. `--live` reads `OPENROUTER_API_KEY` through a reference and is +optional verification, never a deterministic-test dependency. + +Update `examples/openhuman/README.md`, `ROADMAP.md`, crate/module READMEs and +wiki `Host-Integration`. The wiki is its own repo: push its documentation +branch before committing the pointer bump, and keep code changes in the one +canonical upstream PR. Link spec/plan from #113's PR, which closes the parent +and stages when their acceptance is satisfied. + +## 10. Verify and publish one reviewable change + +Run the four commands from the repository root and read their full results: + +```sh +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo build --all-targets --all-features +cargo test --all-features +.github/scripts/assert-pure.sh +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features +``` + +Also build the standalone OpenHuman workspace and run its offline basic and +multi-hive binaries plus lowering-equivalence tests. Check per-file coverage +at the repository's 90% threshold where supported; record any deliberate +untested external/lifecycle edge with manual verification rather than claim +unmeasured coverage. Check source/Markdown line caps and local links. + +Do not squash automatic checkpoints. Push this feature branch and open one +ready PR against `tinyhumansai/tinyhivemind` with issue links, public behavior, +actual verification and any material limitation. Do not deploy a scheduled +GitHub writer as part of this finite config implementation. diff --git a/docs/plans/README.md b/docs/plans/README.md index 498bd104..3462de30 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -29,6 +29,8 @@ See [`example-retry-policy.md`](example-retry-policy.md) for a test-first sample ## Plans +- [`2026-10-10-openhuman-hive-config.md`](2026-10-10-openhuman-hive-config.md) — accepted implementation sequence for configuration, deployment and multi-hive examples. + - [`chat-identity.md`](chat-identity.md) — implemented P1 conversation identity. - [`desks.md`](desks.md) — P2 desk DTOs, validation, and membership overlay. - [`mentions.md`](mentions.md) — P3 roster and mention resolution. diff --git a/docs/specs/README.md b/docs/specs/README.md index c551c0d5..8e60beb7 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -24,6 +24,8 @@ See [`example-retry-policy.md`](example-retry-policy.md) for a complete sample. ## Current specifications +- [`openhuman-hive-config.md`](openhuman-hive-config.md) — accepted: one OpenHuman runtime, reusable profiles, persistent seats across hives, permission narrowing and configured workflows. + - [`dynamic-hives.md`](dynamic-hives.md) — implemented: supplied agents on one runtime, permanent tools, dynamic membership, durable coordination, and continuing sessions. See the [migration guide](../opencompany-migration.md) diff --git a/docs/specs/openhuman-hive-config.md b/docs/specs/openhuman-hive-config.md new file mode 100644 index 00000000..1940119c --- /dev/null +++ b/docs/specs/openhuman-hive-config.md @@ -0,0 +1,333 @@ +# Configurable OpenHuman hives + +Status: Accepted. Owner: the OpenHuman adapter. Implements +[#113](https://github.com/tinyhumansai/tinyhivemind/issues/113), stages +[#114](https://github.com/tinyhumansai/tinyhivemind/issues/114)– +[#118](https://github.com/tinyhumansai/tinyhivemind/issues/118). +Implementation order: [the plan](../plans/2026-10-10-openhuman-hive-config.md). + +## Problem and boundary + +A host currently repeats provider, prompt, permission, memory, MCP and seat +construction in each example. A reusable manifest defines the runtime once, +configures agents many times, and allocates those agents as seats into many +hives. Parsing a manifest must not construct agents or resolve credentials. + +Implementation belongs in `crates/tinyhivemind-openhuman`: it already names +OpenHuman types and owns agent configuration. The hives crate supplies the +necessary coordinator policy and role boundary. Core changes only add serde to +`ConductPolicy` and `DivisionPolicy`; `CoordinatorOptions` and its embedded +`RetentionPolicy` get serde in hives. +Existing `EpisodePolicy`, `RoutingPolicy` and `ApprovalPolicy` are reused. +Coordinator changes beyond serde are a necessary boundary expansion: per-hive +policy, membership-role and scheduled-origin metadata must reach the existing +host-neutral coordinator. They belong in hives rather than OpenHuman-owned +algebra. No new crate, runtime, storage journal or ADR is required. + +[#112](https://github.com/tinyhumansai/tinyhivemind/issues/112)'s future +`tinyhivemind-lang` layer lowers into this manifest. It must use this wire +contract rather than maintain a second deployment schema. `ProfileRuntime` +(openhuman#7250) is out of scope: it cannot coexist with `Runtime` in one +process and represents a future multi-tenant mode. + +## Sections and precedence + +JSON is canonical, with typed serde representations and defaults. Reject +unknown fields rather than silently ignoring operational settings. Each +named collection has unique, nonempty ids; seat ids also obey OpenHuman's +agent-id syntax. Declaration order is preserved where it affects prompts. + +| Section | Meaning | Lowered value | +| --- | --- | --- | +| `runtime` | Exactly one provider/default model, workspace, session store, memory engine, sandbox, skills policy, MCP baseline, autonomy, tool rules and limits | `RuntimeBuilder` | +| `profiles` | Reusable system prompt/context, model/sampling, allowed/denied tools and scope, MCP, skills, subagents, permission reference, memory and turn limits | `Runtime::define_template` plus `AgentDefinitionSpec` and agent-spec settings | +| `permission_profiles` | Named access tier, `SandboxModeSpec`, tool rules, send policy, core approval policy and grants | `Access`, sandbox, `can_use_tool`, `SendAuthorizer` | +| `seats` | Seat id, profile id and typed overrides | `AgentSpec::new(id).extends(profile)` through `OpenHumanHost::register_spec` | +| `hives` | Hive id/name, members and per-hive policies/memory root | Coordinator `ManagementRequest::CreateHive` / `JoinHive` and hive policy/role settings | +| `workflows` | Named schedule, prompt and seat or hive target | `Runtime::cron`: seat `JobTarget::Agent`, hive System-job handler | + +Runtime defaults precede profile settings; seat overrides precede membership +turn overlays. Overlays may change role, prompt context and restrictions, +never a provider credential, MCP connection or memory binding. Model defaults +and limits use typed optional fields so omission inherits a value and an +explicit zero or invalid value is rejected. Temperature zero is valid. + +Provider credentials and MCP credential-bearing environment/header values +are `SecretRef`: exactly `{ "env": "NAME" }` or `{ "store": "KEY" }`. +Resolved secrets never enter a serializable manifest or error/Debug output. +References are resolved during deployment by a host-supplied resolver; +missing secrets return a typed error containing only the reference name. +A generic JSON escape hatch cannot smuggle inline credentials into overrides. + +Runtime storage and engine selections are manifest data; the deployment +constructs supported built-in selections, and accepts explicit host-owned +ports for custom engines/session stores or runtime-only sandbox wiring. An +unsupported selector is an error, not an ignored configuration field. + +## Prompt and context files + +`HiveConfig::from_json(&str)` and `from_value(Value)` parse without filesystem +access. `HiveConfig::load_dir(path)` reads `hive.json`, `profiles/*.md` and +`context/*.md`; the free `load_dir` convenience may delegate to it. +Files are resolved relative to that directory, never the caller's cwd. + +Markdown may start with YAML frontmatter between `---` delimiter lines. +Profile frontmatter carries typed profile fields; its body supplies the +system prompt. The filename stem supplies the id when frontmatter omits it. +Context files retain their Markdown body after removing frontmatter; named +context references must resolve. Malformed or unterminated frontmatter is a +typed parse error. Empty Markdown bodies and ordinary Markdown containing +later `---` lines remain valid. JSON and Markdown defining the same profile +id is a duplicate error rather than unspecified precedence. + +Context is composed in declaration order: profile context, seat context, then +membership context. It is instructional text, not configuration authority; +its contents cannot alter sandbox, approval or tools. Scheduled prompts and +messages likewise cannot declare trusted permissions. + +## One seat across many hives + +A seat is one `Agent` on the one runtime, with one persistent coordinator +session. Register it once even when several hives reference it. Direct +messages continue that session using seat defaults. Hive turns capture the +active hive and membership before running and apply that captured overlay. +Never infer the active hive from a mutable global or the seat's first member. +Native seat-target cron jobs are the explicit exception: upstream runs them +in isolated cron sessions outside the coordinator. They reuse the configured +Agent but do not continue or replace its persistent coordinator session. + +Membership has a nonempty freeform `role` label, optional context and optional +permission-profile restriction. The role appears both in the turn prompt +preamble and in `RouteCandidate.role` / `BriefedTeammate.role`; it must reach +routing and delegation, not merely a descriptive JSON field. Role labels are +values, not a hidden registry. An invalid/empty role is a typed role error; +an explicit role reference, if introduced later, must reject unknown labels. + +Each hive configures `EpisodePolicy`, `RoutingPolicy`, `CoordinatorOptions`, +`DivisionPolicy` and `ConductPolicy`. The completion coordinator selects that +hive's routing/coordinator/conduct policy when opening and scheduling work. +`HiveSpec.coordinator.conduct_policy` and the separate hive `conduct` field +are simultaneous bounds: lower each effective `turn_wall` and +`child_turn_wall` to the minimum of their two configured values. Neither +field overrides or silently discards the other; omitted fields retain the +existing defaults. Host-neutral `HiveSettings` contains coordinator options, +routing and a seat-to-role map. Capture these settings atomically when opening +an episode and retain that frozen snapshot in `EpisodeRecord`, so a later +settings update cannot change a running episode's authorization or policy. +`EpisodePolicy` and `DivisionPolicy` belong to the separate pure deliberation +mechanism; expose the configured values through the deployment's per-hive +policy view for hosts invoking that mechanism. They must not be presented as +completion-coordinator settings when that coordinator does not consume them. +An attempt to apply unsupported nondefault settings through a completion-only +path returns `UnsupportedSetting`. Its global concurrency cap still bounds +all hives together; a hive's round width cannot exceed that cap. Native cron +scheduler admission has a separate ceiling with the same runtime concurrency +value. Direct native turns bypass coordinator admission; these ceilings do not +claim a combined bound over every native turn. +Persist or otherwise retain policy and role data alongside hive state so +joining, leaving or recovering a hive cannot discard its configured behavior. +Do not parse hive policies and then run every hive with global defaults. + +### Memory decision + +Use option (a): a default per-seat root `seat:`, shared by that seat +across all its hives. This follows the existing one-session identity and does +not require an impossible per-turn `MemoryBinding` switch. It also avoids +making membership order determine ownership of memory. + +Logical roots cannot be passed verbatim to the pinned native namespace parser, +which has no `seat` segment kind. Encode every logical UTF-8 root injectively +below `project:hivemind`, using 64-byte chunks rendered as lowercase hex +`team:` segments. Encode native-looking logical roots too, avoiding +pass-through collisions. Reject blank roots or roots over 384 UTF-8 bytes +before boot: at most seven root segments leave one native agent segment. +Expose `native_memory_root` for hosts sharing the same physical namespace. + +An explicit profile/seat memory binding overrides that default and remains +fixed for the agent's lifetime. A hive's memory root describes its shared +recall namespace; joining it does not silently rebind the seat. Configuring +two seats is the supported way to obtain isolated sessions or different +roots for the same reusable profile. The deployment configures the binding on the seat spec and does not install +a host-wide hive memory wrapper that would overwrite it. Existing hosts +using `with_hive_memory` retain their established behavior. + +### MCP and tools + +MCP connections are fixed at agent creation. Runtime baseline and profile/ +seat MCP declarations form the seat's one normalized server set. If a +membership declares a different set, validation returns `ConflictingMcp` and +explains that two seats are required. Reordering the same servers is harmless; +changes to endpoint, transport, credential reference or tool allowlist are +meaningful changes. Denying use of a connected server's tools per hive does +not require a new connection set. + +Latest OpenHuman `Turn::tools` replaces the turn's host belt, including attached +sources; an empty belt revokes host tools. Builtin tools still follow the agent +definition. Agent and turn permission callbacks are additive: any denial wins. +Thus build the correct episode-bound host belt and install an effective +permission callback that also restricts builtin and MCP calls. Tool visibility +alone is not authorization, and a resumed prompt's stale catalogue must not +restore a denied tool. + +## Permission and approval contract + +The effective authority is the intersection of runtime baseline, profile, +seat overrides and membership restrictions. A hive must never widen a seat. +Validation rejects widening in access tier, sandbox, tool allow/deny, send +scope and approval behavior. Where arbitrary rule equivalence cannot be +proved, require a conservative structural restriction and reject ambiguous +widening; a claimed restriction is not accepted merely because it has a +smaller allowlist while removing a denial or weakening the sandbox. + +A narrower allowlist is a subset; denylists retain every inherited denial. +Read-only cannot become supervised or autonomous. A restricted sandbox +cannot become disabled. Send destinations/operations can only be removed. +Approval rules, approver and standing-grant authority remain inherited or +are explicitly narrowed, never replaced with a more permissive default. +Runtime checks intersect all layers even after validation. + +The bridge classifies each tool call into a core `ApprovalRequest` and +`Action`, using authenticated seat identity, active conversation, call id, +host sequence, consent epoch and grant snapshot. Tool classification is +explicit: known read operations are `ReadOnly`; writes and external effects +are `Mutating`; unknown tools/effects are `Unclassified` and deny. Do not +classify every tool as read-only to make an example run. + +Call `tinyhivemind_core::approval::approve` for the total verdict. `Allow` +proceeds; native `Deny` refuses the effect and aborts the native turn. Ordinary +denials retain a failed/interrupted coordinator record. A host may explicitly +release a seat with a safe resumption note and enqueue new completion work; +the refused call and failed assignment are never replayed. `Ask` goes to OpenHuman's +`ApprovalHandler` through an adapter-owned correlated pending request. +A hook returning Ask creates neither a native approval-gate record nor a +native approval event; listening only to native `ApprovalHandler` subscriptions +would miss it. The bridge explicitly invokes the injectable handler with a +pending request/call correlation id and never waits for a nonexistent native +event. Returning OpenHuman `Ask` from `can_use_tool` alone denies the call and +is not an approval workflow. +For turns run by the coordinator, a pending host decision makes +`TurnHooks::after_turn` return `TurnDisposition::Parked`. Native seat cron +jobs run outside these hooks: agent-scoped approval settings enforce their +baseline, and they do not claim coordinator parking or host-release behavior. Deny the original tool call without executing its effect. After the turn +parks, the asynchronous handler decision is correlated to the adapter pending +request; explicit host release supplies the decision as a resumption note. +No uncertain turn or refused tool call is automatically replayed. A newly +requested effect must pass the current approval gate again. + +The derived `SendAuthorizer` covers direct sends, hive sends, asks and +broadcasts using the captured active-hive overlay as well as seat policy. +Management remains explicitly authorized; `ManifestFactory` implements the +existing `AgentFactory` and validates dynamic overrides against configured +profiles. Unknown templates and secret-bearing overrides fail before creation. + +Timeout is profile/seat specific, falling back to `with_turn_timeout`. +Use `Turn::timeout` or awaited `cancellation_handle` rather than dropping a +`tokio::time::timeout` future while tool effects may continue. Iteration and +model-budget limits are also lowered; they are not documentation-only fields. +Native seat cron jobs receive agent-scoped limit/stop policy rather than +assuming the coordinator runner applies timeouts or budgets to them. + +## Workflows + +Workflow names are unique and targets resolve before deployment. A seat target +lowers directly to `JobTarget::Agent`. Upstream runs it in an isolated cron +session, outside `OpenHumanHost`'s coordinator runner. Agent-scoped model, +tool/approval, sandbox, iteration, timeout and budget limits must therefore +enforce its baseline independently of coordinator hooks. Hive membership +turn overlays and coordinator parking are not promised for native seat jobs. +Native seat jobs cannot borrow an active continuing session's send authority. +A timeout or model-budget setting unsupported by the native agent baseline +returns `UnsupportedSetting` before runtime creation. + +A hive target uses upstream's supported System-job handler to enqueue an +authorized host message to that hive and drain coordinator work; upstream +has no `JobTarget::Hive`. This is a deliberate correction to stage #117's +Agent-only sketch. Persist the scheduled job identity and automation origin +with the accepted message and episode, then capture it in each `TurnRequest`. +The host-neutral representation is optional `scheduled_job_id` on `Message` +and `TurnRequest`; absent preserves existing interactive behavior. A +`send_scheduled_as_host` entry point accepts trusted job provenance and checks +that retry deduplication cannot change the already accepted message's origin. +Capture provenance and the episode settings atomically, before dispatch. +Resumed, queued and delegated work inherits that origin from durable state; +it must not depend on whatever task-local origin happened to surround enqueue +or drain. Scope every resulting turn explicitly as +`TrustedAutomation { Cron }` before dispatch, including child/delegated turns. +Untrusted messages cannot mint or clear that origin. + +Scheduled turns cannot use write or network tools (openhuman#7264). Native +seat jobs and hive-origin turns retain that restriction for builtin, MCP and +host tools; an otherwise permissive approval grant cannot override it. Never +relabel a scheduled turn as interactive to gain permissions. Jobs retain +schedule, enabled state, retry and single-flight settings. Registering jobs +and starting scheduler services are separate explicit runtime operations. +Construct with cron inactive, configure all seats/hives/jobs, then start +services only when requested. An offline test may register/run a job without +sleeping for a wall-clock deadline. + +## Errors and validation order + +Parsing/validation returns the first typed `ConfigError`; it never starts a +runtime. `ValidatedHiveConfig` is constructible only through validation. +Error messages are lowercase, without trailing punctuation or resolved secret +values. Every listed branch needs a deterministic test. + +| Error family | Trigger | +| --- | --- | +| `Json` / frontmatter parse | Malformed canonical JSON or YAML metadata | +| `Io` | Missing/unreadable config, prompt or context file | +| `DuplicateId` | Duplicate profile, permission, seat, hive or workflow id; repeated hive member | +| `UnknownProfile` | Seat/subagent/dynamic template references an absent profile | +| `UnknownPermissionProfile` | Profile, seat or membership references an absent permission profile | +| `UnknownSeat` | Hive member or workflow references an absent seat | +| `UnknownHive` | Workflow references an absent hive | +| `UnknownContext` | Unresolved named Markdown context | +| `InvalidRole` / future `UnknownRole` | Empty role label, or unresolved explicit role reference | +| `PermissionWidening` | Any overlay adds authority above its parent | +| `ConflictingMcp` | Same seat requires different connections across hives | +| `InvalidWidth` | Zero width or a blind, revealed, routing, division or coordinator width above the runtime cap | +| `InlineSecret` | Credential-bearing field contains a literal instead of `SecretRef` | +| `EmptyHive` | No members at deployment | +| `InvalidId` / `InvalidLimit` | Invalid agent identity, zero timeout/iteration cap or impossible budget | +| `InvalidWorkflow` | Invalid schedule or unsupported target dispatch | +| `UnsupportedSetting` | Declared selector cannot be lowered by the selected deployment ports | + +Resolve ids and references before comparing effective policies. Validate +permissions and immutable MCP requirements before constructing any agent. +Deployment errors separately cover secret resolution, provider/runtime build, +agent/template creation, coordinator operations and cron registration. Errors +identify the failing section/id without rendering credential-bearing values. + +## Acceptance + +- One runtime, one agent/session per seat, with different captured role/context + overlays for that seat's turns in two hives. +- All declared runtime, profile, seat and hive policies reach their matching + runtime or coordinator APIs; no field is silently ignored. +- Permission narrowing permits a tool in hive A and denies it in hive B; + attempted widening fails validation. Unknown effects fail closed. +- Coordinator approval tests cover allow, deny, ask/parking and explicit + release; timeout tests prove cancellation is awaited. Native seat cron + tests enforce agent-scoped baseline limits in isolated sessions. +- Scheduled hive provenance survives queueing/recovery and delegation; write + and network effects from builtin/MCP/host tools are denied before execution + in both the first scheduled turn and its delegated turns. +- Serde representation and defaults are pinned for every payload, with a full + fixture and every typed error covered. Files meet the coverage and size caps. +- `basic_hive`, `deepswe_hive` and `pe1006_hive` use checked-in config + directories while keeping their CLI flags and runtime-only host settings. + Equivalence tests compare their former construction with the loaded config. +- `multi_hive` runs offline with wiremock; one runtime, three profiles, four + seats, two hives, differing roles/restrictions and both episodes complete. +- Root contract checks, purity and rustdoc pass; standalone examples build + and offline basic/multi-hive runs pass. Live runs are optional. + +## Pin maintenance + +This PR refreshes the pin on demand. #113's proposed scheduled GitHub bump +writer is deferred until its schedule, ownership and acceptance are specified; +no unattended repository mutation is installed by the configuration layer. +Existing pin-consistency assertions remain required. + +Open questions: none. The memory choice and additive permission semantics are +settled above; future config-language work consumes this contract. diff --git a/examples/openhuman/Cargo.lock b/examples/openhuman/Cargo.lock index 82483bbf..5a59d3f5 100644 --- a/examples/openhuman/Cargo.lock +++ b/examples/openhuman/Cargo.lock @@ -43,6 +43,20 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -52,6 +66,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "ambient-authority" version = "0.0.2" @@ -125,9 +145,9 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" dependencies = [ "derive_arbitrary", ] @@ -162,7 +182,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -195,6 +215,21 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -207,18 +242,6 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" -[[package]] -name = "bitvec" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - [[package]] name = "blake2" version = "0.10.6" @@ -246,6 +269,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bstr" version = "1.13.1" @@ -264,10 +293,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "bytemuck" -version = "1.25.2" +name = "bytecount" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "byteorder" @@ -325,9 +354,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.7" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +checksum = "50a649af8a827553c29fb0cb4bd4a6f1a0dd695bd3232b9bc98bd9c8a3ffbb8b" dependencies = [ "find-msvc-tools", "shlex", @@ -574,9 +603,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ "cmov", ] @@ -670,13 +699,13 @@ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "derive_arbitrary" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.7", ] [[package]] @@ -749,7 +778,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -773,6 +802,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -844,6 +882,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -868,9 +917,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "flate2" @@ -883,6 +932,17 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -925,6 +985,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" +dependencies = [ + "num", + "num-bigint", +] + [[package]] name = "fs-set-times" version = "0.20.3" @@ -933,7 +1003,7 @@ checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" dependencies = [ "io-lifetimes 2.0.4", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -956,12 +1026,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - [[package]] name = "futures" version = "0.3.34" @@ -1018,7 +1082,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -1080,9 +1144,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1117,9 +1183,9 @@ checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "h2" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" dependencies = [ "atomic-waker", "bytes", @@ -1149,6 +1215,8 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -1161,6 +1229,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -1273,9 +1347,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", @@ -1295,14 +1369,15 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -1327,16 +1402,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -1360,7 +1436,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1584,9 +1660,9 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.37" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" dependencies = [ "defmt", "jiff-core", @@ -1611,9 +1687,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.37" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" dependencies = [ "jiff-core", "proc-macro2", @@ -1623,9 +1699,9 @@ dependencies = [ [[package]] name = "jiff-tzdb" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" [[package]] name = "jiff-tzdb-platform" @@ -1687,15 +1763,68 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f622c574a29fb3034a0a90645afee194dfedc75fc124228801b6e77041075bd1" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a968fffd387b2ab005bf00c1851f5d26e90df05b6f7ad609ed0d620033cca5e7" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39b7670345e5ce87371339d13705dbb9276b7e3e934bbe1efa2c6734a1f65a96" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "getrandom 0.3.4", + "num-cmp", + "num-traits", + "serde_json", + "zmij", +] + [[package]] name = "keyring" version = "3.6.3" @@ -1724,33 +1853,15 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libdeflate-sys" -version = "1.26.1" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7870e5fbd2766179a937c725fb11f4ca0ef025d982beb61bd3ce755425bd19c" -dependencies = [ - "cc", -] - -[[package]] -name = "libdeflater" -version = "1.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cbb8285d2e2bd4b8d4155840ae81929354b4ba0d7b3c272c84761ffa2141e1f" -dependencies = [ - "libdeflate-sys", -] +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libm" @@ -1760,9 +1871,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "libc", ] @@ -1848,6 +1959,12 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -1876,9 +1993,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", @@ -1920,6 +2037,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -1930,6 +2061,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1945,6 +2091,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1984,7 +2151,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openhuman" -version = "0.64.12" +version = "0.64.15" dependencies = [ "aes-gcm", "aho-corasick", @@ -1995,6 +2162,7 @@ dependencies = [ "bytes", "chacha20poly1305", "chrono", + "chrono-tz", "directories", "dirs 6.0.0", "dotenvy", @@ -2004,6 +2172,7 @@ dependencies = [ "futures-util", "hex", "hkdf", + "hmac 0.13.0", "hostname", "iana-time-zone", "keyring", @@ -2012,7 +2181,7 @@ dependencies = [ "nu-ansi-term", "once_cell", "parking_lot", - "rand 0.10.2", + "rand 0.10.3", "regex", "reqwest", "rusqlite", @@ -2020,7 +2189,9 @@ dependencies = [ "schemars", "serde", "serde_json", + "serde_path_to_error", "sha2 0.10.9", + "sha2 0.11.0", "sysinfo", "tar", "tempfile", @@ -2032,6 +2203,7 @@ dependencies = [ "tinyagents-registry", "tinyagents-runtime", "tinyagents-session", + "tinyagents-tasks", "tinybox-core", "tinybox-docker", "tinybox-jail", @@ -2042,6 +2214,7 @@ dependencies = [ "tinycomputer-bus", "tinyconnectors", "tinyconnectors-bus", + "tinyflows-drivers", "tinyflows-schedule", "tinyflows-sqlite", "tinyinference-core", @@ -2061,6 +2234,7 @@ dependencies = [ "tinyruntime-pyserver", "tinysearch-bus", "tinyskills", + "tinystoragedrivers", "tinytools", "tinytools-agent", "tinytools-std", @@ -2082,10 +2256,13 @@ dependencies = [ [[package]] name = "openhuman-embed" -version = "0.64.12" +version = "0.64.15" dependencies = [ "anyhow", "async-trait", + "chrono", + "futures-core", + "jsonschema", "log", "openhuman", "serde", @@ -2093,10 +2270,14 @@ dependencies = [ "tempfile", "thiserror", "tinyagents-session", + "tinyinference-embeddings", + "tinyinference-llm", "tinymcp-bus", "tinymemory-api", "tinymemory-tools", + "tinytools", "tokio", + "tokio-stream", "url", "uuid", ] @@ -2151,18 +2332,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] -name = "oxipng" -version = "9.1.5" +name = "outref" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c613f0f566526a647c7473f6a8556dbce22c91b13485ee4b4ec7ab648e4973" -dependencies = [ - "bitvec", - "indexmap", - "libdeflater", - "log", - "rgb", - "rustc-hash", -] +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" [[package]] name = "parking_lot" @@ -2325,9 +2498,9 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "ppv-lite86" @@ -2369,14 +2542,14 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.18" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand 0.10.3", "rand_pcg", "ring", "rustc-hash", @@ -2391,9 +2564,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc", @@ -2424,12 +2597,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - [[package]] name = "rand" version = "0.8.8" @@ -2451,9 +2618,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20 0.10.2", "getrandom 0.4.3", @@ -2539,7 +2706,24 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", +] + +[[package]] +name = "referencing" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558f3855529c0a7e78a48e409fa653456e55f1b167eab4109c16a255b7727e3f" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", ] [[package]] @@ -2604,6 +2788,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -2624,15 +2809,6 @@ dependencies = [ "webpki-roots 1.0.9", ] -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" -dependencies = [ - "bytemuck", -] - [[package]] name = "ring" version = "0.17.14" @@ -2837,7 +3013,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -2921,7 +3097,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -2932,14 +3108,14 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.152" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "1741ab7a6cc54a03a89b5d563ed60075c277d9e3cfa73ad0c1f23f23974703c6" dependencies = [ "itoa", "memchr", @@ -2948,11 +3124,22 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -2982,6 +3169,19 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.7" @@ -3064,9 +3264,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "slab" @@ -3076,9 +3276,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.1" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" [[package]] name = "socket2" @@ -3119,6 +3319,27 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3138,9 +3359,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc" dependencies = [ "proc-macro2", "quote", @@ -3164,7 +3385,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -3180,12 +3401,6 @@ dependencies = [ "windows", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "tar" version = "0.4.46" @@ -3212,22 +3427,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -3271,15 +3486,16 @@ dependencies = [ [[package]] name = "tinyagents-definition" -version = "2.1.3" +version = "2.1.4" dependencies = [ "async-trait", "serde", + "tinytools", ] [[package]] name = "tinyagents-graph" -version = "2.1.3" +version = "2.1.4" dependencies = [ "anyhow", "async-trait", @@ -3287,8 +3503,11 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha2 0.11.0", "tinyagents-harness", + "tinyagents-tasks", "tinyinference-llm", + "tinystoragedrivers-core", "tinytools", "tokio", "tracing", @@ -3296,7 +3515,7 @@ dependencies = [ [[package]] name = "tinyagents-harness" -version = "2.1.3" +version = "2.1.4" dependencies = [ "anyhow", "async-trait", @@ -3306,7 +3525,6 @@ dependencies = [ "dirs 6.0.0", "flate2", "futures", - "oxipng", "regex", "reqwest", "rusqlite", @@ -3321,8 +3539,10 @@ dependencies = [ "tinyinference-core", "tinyinference-embeddings", "tinyinference-llm", + "tinystoragedrivers-core", "tinytools", "tinytools-agent", + "tinytools-std", "tokio", "tracing", "uuid", @@ -3332,7 +3552,7 @@ dependencies = [ [[package]] name = "tinyagents-orchestration" -version = "2.1.3" +version = "2.1.4" dependencies = [ "anyhow", "async-trait", @@ -3345,15 +3565,17 @@ dependencies = [ "tinyagents-harness", "tinyagents-runtime", "tinyagents-session", + "tinyagents-tasks", "tinyinference-llm", "tinytools", "tokio", + "tracing", "uuid", ] [[package]] name = "tinyagents-registry" -version = "2.1.3" +version = "2.1.4" dependencies = [ "anyhow", "chrono", @@ -3367,7 +3589,7 @@ dependencies = [ [[package]] name = "tinyagents-runtime" -version = "2.1.3" +version = "2.1.4" dependencies = [ "async-trait", "chrono", @@ -3385,7 +3607,7 @@ dependencies = [ [[package]] name = "tinyagents-session" -version = "2.1.3" +version = "2.1.4" dependencies = [ "anyhow", "async-trait", @@ -3401,15 +3623,33 @@ dependencies = [ "tempfile", "thiserror", "tinyagents-harness", + "tinyagents-tasks", + "tinystoragedrivers-core", + "tinystoragedrivers-sqlite", "tinytools-agent", "tokio", "tracing", "uuid", ] +[[package]] +name = "tinyagents-tasks" +version = "2.1.4" +dependencies = [ + "anyhow", + "async-trait", + "serde", + "serde_json", + "tinyagents-harness", + "tinyinference-llm", + "tinytools", + "tokio", + "tracing", +] + [[package]] name = "tinybox-core" -version = "0.1.15" +version = "0.1.16" dependencies = [ "async-trait", "serde", @@ -3418,7 +3658,7 @@ dependencies = [ [[package]] name = "tinybox-docker" -version = "0.1.15" +version = "0.1.16" dependencies = [ "async-trait", "tinybox-core", @@ -3427,7 +3667,7 @@ dependencies = [ [[package]] name = "tinybox-jail" -version = "0.1.15" +version = "0.1.16" dependencies = [ "landlock", "log", @@ -3439,10 +3679,11 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.1.2" +version = "0.1.3" dependencies = [ "async-trait", "flate2", + "libc", "serde", "serde_json", "sha2 0.10.9", @@ -3459,7 +3700,7 @@ dependencies = [ [[package]] name = "tinybus-macros" -version = "0.1.2" +version = "0.1.3" dependencies = [ "proc-macro2", "quote", @@ -3468,7 +3709,7 @@ dependencies = [ [[package]] name = "tinychannels-bus" -version = "0.1.10" +version = "0.1.13" dependencies = [ "anyhow", "async-trait", @@ -3476,7 +3717,7 @@ dependencies = [ "hex", "hmac 0.13.0", "parking_lot", - "rand 0.10.2", + "rand 0.10.3", "schemars", "serde", "serde_json", @@ -3489,11 +3730,11 @@ dependencies = [ [[package]] name = "tinychannels-runtime" -version = "0.1.10" +version = "0.1.13" dependencies = [ "anyhow", "async-trait", - "rand 0.10.2", + "rand 0.10.3", "tinychannels-bus", "tokio", "tokio-util", @@ -3503,7 +3744,7 @@ dependencies = [ [[package]] name = "tinycomputer-accessibility" -version = "0.9.1" +version = "0.10.1" dependencies = [ "log", "serde", @@ -3513,7 +3754,7 @@ dependencies = [ [[package]] name = "tinycomputer-bus" -version = "0.9.1" +version = "0.10.1" dependencies = [ "serde", "serde_json", @@ -3521,7 +3762,7 @@ dependencies = [ [[package]] name = "tinyconnectors" -version = "0.12.3" +version = "0.13.2" dependencies = [ "async-trait", "chrono", @@ -3540,7 +3781,7 @@ dependencies = [ [[package]] name = "tinyconnectors-bus" -version = "0.12.3" +version = "0.13.2" dependencies = [ "serde", "serde_json", @@ -3548,15 +3789,27 @@ dependencies = [ [[package]] name = "tinyconnectors-sync" -version = "0.12.3" +version = "0.13.2" dependencies = [ "async-trait", - "chrono", "serde", "serde_json", "thiserror", "tinyconnectors-bus", +] + +[[package]] +name = "tinyflows-drivers" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "serde", + "serde_json", + "tinyflows-schedule", + "tinystoragedrivers-core", "tracing", + "uuid", ] [[package]] @@ -3581,6 +3834,7 @@ dependencies = [ "serde", "serde_json", "tinyflows-schedule", + "tinystoragedrivers-sqlite", "tracing", "uuid", ] @@ -3624,10 +3878,12 @@ version = "0.2.1" dependencies = [ "anyhow", "async-trait", + "chrono", "openhuman", "openhuman-embed", "serde", "serde_json", + "serde_yaml_ng", "thiserror", "tinyhivemind-core", "tinyhivemind-hives", @@ -3644,6 +3900,7 @@ name = "tinyhivemind-openhuman-example" version = "0.0.0" dependencies = [ "anyhow", + "async-trait", "env_logger", "futures", "openhuman", @@ -3656,6 +3913,7 @@ dependencies = [ "tinyhivemind-hives", "tinyhivemind-openhuman", "tinyhivemind-tools", + "tinytools", "tokio", "wait-timeout", "wiremock", @@ -3672,7 +3930,7 @@ dependencies = [ [[package]] name = "tinyinference-core" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "httpdate", @@ -3682,7 +3940,7 @@ dependencies = [ [[package]] name = "tinyinference-embeddings" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "once_cell", @@ -3699,7 +3957,7 @@ dependencies = [ [[package]] name = "tinyinference-llm" -version = "0.3.0" +version = "0.3.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -3708,7 +3966,6 @@ dependencies = [ "reqwest", "serde", "serde_json", - "sha2 0.11.0", "thiserror", "tinyinference-core", "tinytools-agent", @@ -3718,7 +3975,7 @@ dependencies = [ [[package]] name = "tinyinference-local" -version = "0.3.0" +version = "0.3.1" dependencies = [ "base64 0.23.1", "futures-util", @@ -3739,10 +3996,10 @@ dependencies = [ [[package]] name = "tinyinference-providers" -version = "0.3.0" +version = "0.3.1" dependencies = [ "base64 0.23.1", - "rand 0.10.2", + "rand 0.10.3", "reqwest", "serde", "serde_json", @@ -3752,7 +4009,7 @@ dependencies = [ [[package]] name = "tinyinference-voice" -version = "0.3.0" +version = "0.3.1" dependencies = [ "base64 0.23.1", "reqwest", @@ -3767,7 +4024,7 @@ dependencies = [ [[package]] name = "tinyjuice" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "async-trait", @@ -3793,7 +4050,7 @@ dependencies = [ [[package]] name = "tinyjuice-bus" -version = "0.6.0" +version = "0.6.1" dependencies = [ "serde", "serde_json", @@ -3801,7 +4058,7 @@ dependencies = [ [[package]] name = "tinymcp" -version = "0.4.0" +version = "0.6.0" dependencies = [ "anyhow", "async-trait", @@ -3826,7 +4083,7 @@ dependencies = [ [[package]] name = "tinymcp-bus" -version = "0.4.0" +version = "0.6.0" dependencies = [ "schemars", "serde", @@ -3835,10 +4092,11 @@ dependencies = [ [[package]] name = "tinymemory-api" -version = "1.23.4" +version = "1.27.2" dependencies = [ "async-trait", "chrono", + "chrono-tz", "serde", "serde_json", "sha2 0.11.0", @@ -3847,7 +4105,7 @@ dependencies = [ [[package]] name = "tinymemory-integrations" -version = "1.23.4" +version = "1.27.2" dependencies = [ "async-trait", "chrono", @@ -3870,7 +4128,7 @@ dependencies = [ [[package]] name = "tinymemory-tools" -version = "1.23.4" +version = "1.27.2" dependencies = [ "chrono", "futures", @@ -3883,7 +4141,7 @@ dependencies = [ [[package]] name = "tinyruntime-bus" -version = "0.2.11" +version = "0.2.12" dependencies = [ "serde", "serde_json", @@ -3891,7 +4149,7 @@ dependencies = [ [[package]] name = "tinyruntime-pyserver" -version = "0.2.11" +version = "0.2.12" dependencies = [ "serde", "serde_json", @@ -3902,7 +4160,7 @@ dependencies = [ [[package]] name = "tinysearch-bus" -version = "0.3.6" +version = "0.4.0" dependencies = [ "serde", "serde_json", @@ -3910,7 +4168,7 @@ dependencies = [ [[package]] name = "tinyskills" -version = "0.2.8" +version = "0.2.10" dependencies = [ "cap-fs-ext", "cap-std", @@ -3924,6 +4182,65 @@ dependencies = [ "url", ] +[[package]] +name = "tinystoragedrivers" +version = "0.5.0" +dependencies = [ + "tinystoragedrivers-core", + "tinystoragedrivers-file", + "tinystoragedrivers-secrets", + "tinystoragedrivers-sqlite", +] + +[[package]] +name = "tinystoragedrivers-core" +version = "0.5.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "thiserror", + "tokio", +] + +[[package]] +name = "tinystoragedrivers-file" +version = "0.5.0" +dependencies = [ + "async-trait", + "libc", + "serde", + "serde_json", + "tinystoragedrivers-core", + "tokio", +] + +[[package]] +name = "tinystoragedrivers-secrets" +version = "0.5.0" +dependencies = [ + "async-trait", + "chacha20poly1305", + "fs4", + "hkdf", + "keyring", + "serde_json", + "sha2 0.10.9", + "tinystoragedrivers-core", + "tokio", + "zeroize", +] + +[[package]] +name = "tinystoragedrivers-sqlite" +version = "0.5.0" +dependencies = [ + "rusqlite", + "serde_json", + "tinystoragedrivers-core", + "tokio", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -3960,7 +4277,7 @@ version = "0.5.0" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", + "base64 0.23.1", "fs2", "futures-util", "glob", @@ -3971,7 +4288,7 @@ dependencies = [ "reqwest", "rustix", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "tinytools", "tokio", "tracing", @@ -3986,9 +4303,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ "bytes", "libc", @@ -4009,7 +4326,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -4024,14 +4341,26 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.5" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-tungstenite" version = "0.29.0" @@ -4052,9 +4381,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", @@ -4067,9 +4396,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.6+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", @@ -4082,27 +4411,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tower" @@ -4235,9 +4564,15 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicase" -version = "2.9.0" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" [[package]] name = "unicode-ident" @@ -4351,9 +4686,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.26.1" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -4361,6 +4696,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -4373,6 +4718,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -4394,9 +4745,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -4418,9 +4769,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -4431,19 +4782,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.78" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4451,22 +4803,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -4486,9 +4838,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -4568,7 +4920,7 @@ version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" dependencies = [ - "windows-core", + "windows-core 0.57.0", "windows-targets 0.52.6", ] @@ -4578,12 +4930,25 @@ version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" dependencies = [ - "windows-implement", - "windows-interface", - "windows-result", + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + [[package]] name = "windows-implement" version = "0.57.0" @@ -4595,6 +4960,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-interface" version = "0.57.0" @@ -4606,6 +4982,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -4621,6 +5008,24 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4630,6 +5035,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -4799,7 +5213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" dependencies = [ "bitflags 2.13.2", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4837,15 +5251,6 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "x25519-dalek" version = "2.0.1" @@ -4881,30 +5286,30 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.57" +version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.57" +version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" dependencies = [ "proc-macro2", "quote", @@ -4928,15 +5333,15 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ "zeroize_derive", ] @@ -4982,7 +5387,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.7", ] [[package]] @@ -5025,3 +5430,7 @@ dependencies = [ "log", "simd-adler32", ] + +[[patch.unused]] +name = "tinyinference-decisions" +version = "0.3.1" diff --git a/examples/openhuman/Cargo.toml b/examples/openhuman/Cargo.toml index 3f217bfa..6620904f 100644 --- a/examples/openhuman/Cargo.toml +++ b/examples/openhuman/Cargo.toml @@ -12,11 +12,13 @@ default-run = "tinyhivemind-openhuman-example" [dependencies] anyhow = "1" +# The multi-hive host supplies one classified native filesystem proof tool. +async-trait = "0.1" +tinytools = { git = "https://github.com/tinyhumansai/tinytools", rev = "8a87a26293341c51afa11bccfdbe920def7ca9d6" } futures = "0.3" # The live hive uses OpenHuman's native runtime, agents, sessions, and TinyCortex module. openhuman-embed = { path = "../../vendor/openhuman/crates/openhuman-embed", default-features = false, features = ["mcp", "modules", "skills"] } -# The DeepSWE adapter registers its per-seat definitions with OpenHuman's -# runtime registry so the current hosted harness can resolve them at turn time. +# Core configuration and sandbox types used by the standalone host examples. openhuman = { path = "../../vendor/openhuman/crates/openhuman-core", default-features = false } # Public research sources are mirrored into the isolated run workspace before turns start. reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } @@ -64,7 +66,13 @@ tinymemory-api = { path = "../../vendor/openhuman/vendor/tinymemory/crates/tinym tinymemory-tools = { path = "../../vendor/openhuman/vendor/tinymemory/crates/tinymemory-tools" } tinymemory-integrations = { path = "../../vendor/openhuman/vendor/tinymemory/crates/tinymemory-integrations" } +# Storage contracts must resolve to the harness copy across git and path users. +[patch."https://github.com/tinyhumansai/tinystoragedrivers"] +tinystoragedrivers-core = { path = "../../vendor/openhuman/vendor/tinyagents/vendor/tinystoragedrivers/crates/tinystoragedrivers-core" } +tinystoragedrivers-sqlite = { path = "../../vendor/openhuman/vendor/tinyagents/vendor/tinystoragedrivers/crates/tinystoragedrivers-sqlite" } + [patch."https://github.com/tinyhumansai/tinyinference"] +tinyinference-decisions = { path = "../../vendor/openhuman/vendor/tinyagents/vendor/tinyinference/crates/tinyinference-decisions" } tinyinference-core = { path = "../../vendor/openhuman/vendor/tinyagents/vendor/tinyinference/crates/tinyinference-core" } tinyinference-embeddings = { path = "../../vendor/openhuman/vendor/tinyagents/vendor/tinyinference/crates/tinyinference-embeddings" } tinyinference-llm = { path = "../../vendor/openhuman/vendor/tinyagents/vendor/tinyinference/crates/tinyinference-llm" } diff --git a/examples/openhuman/README.md b/examples/openhuman/README.md index a0458742..eef1407e 100644 --- a/examples/openhuman/README.md +++ b/examples/openhuman/README.md @@ -1,9 +1,10 @@ # Supplied OpenHuman agents and dynamic hives -The host constructs one OpenHuman `Runtime` and configures each `Agent` before -registration. TinyHivemind receives the existing handles through `OpenHumanHost`; -the host keeps control of providers, MCP servers, skills, memory, and prompts. -Each agent continues one conversation across all its joined hives. +`HiveDeployment` builds one OpenHuman `Runtime` from a validated manifest, +registers reusable profiles and persistent seats, and joins them to configured +hives. Runtime-only model routes, Docker paths and storage ports remain host +inputs. `OpenHumanHost` also accepts independently constructed agents. Each +coordinator seat continues one conversation across all its joined hives. For a short introduction, run the offline two-agent example: @@ -11,6 +12,33 @@ For a short introduction, run the offline two-agent example: cargo run --manifest-path examples/openhuman/Cargo.toml --bin basic_hive ``` +The [checked-in manifests](hives/README.md) provide the profiles, permission +baselines, memberships, policies and Markdown context for `basic_hive`, +`deepswe_hive`, `pe1006_hive` and `multi_hive`. The three existing binaries load +these files before adding CLI-selected endpoints and run paths. + +```sh +cargo run --manifest-path examples/openhuman/Cargo.toml --bin multi_hive +cargo test --manifest-path examples/openhuman/Cargo.toml --test manifests +``` + +`multi_hive` uses a local model by default: three profiles create four seats on +one runtime; two hives share the same seat with implementer and observer roles. +The same native `file_write` succeeds in hive A and is refused in hive B. The +native denial aborts B's turn and remains an audited failure. The host explicitly +releases the seat with a safe note and submits a fresh assignment, which completes +without replaying the write. Both hives finish successfully, and the shared seat +keeps its original native session. `--live` resolves `OPENROUTER_API_KEY` through +the manifest reference; `OPENROUTER_MODEL` optionally selects the model. + +The pure completion experiments retain their host-owned `CompletionDriver` and +outbox tools. Deployment supplies their native seats, MCP connections and +configured policy views; per-turn `cwd` keeps the Docker or durable Euler acting +root. PE's configured CortexDB engine is supplied through the native engine port. +Native harness initialization starts explicitly after assembly. The current +OpenHuman `memory_queue` service flag has no queue implementation; the shared +Markdown memory tools remain active independently. + For Docker-isolated offline and live OpenRouter runs, use the [basic hive run script](basic-hive/README.md). @@ -275,3 +303,7 @@ checks. A later clean run staged a newly published public implementation, required the checker to execute its built-in brute-force checkpoints, and independently matched the sealed oracle. The answer and derivation remain outside the repository; the run artifacts stay under the ignored workspace. + +The multi-hive host injects a native, classified `file_write` tool scoped to an +isolated `proof.txt`. The deployment replaces the native host tool belt, so this +example explicitly supplies its filesystem tool through `BuildOptions.tools`. diff --git a/examples/openhuman/hives/README.md b/examples/openhuman/hives/README.md new file mode 100644 index 00000000..da450bea --- /dev/null +++ b/examples/openhuman/hives/README.md @@ -0,0 +1,35 @@ +# Checked-in OpenHuman hives + +| Directory | Runtime and seats | +| --- | --- | +| `basic_hive/` | Read-only Alice and Bob; ephemeral workspace and continuing host sessions | +| `deepswe_hive/` | Lead, implementer, tester and reviewer; two MCP servers, 16 iterations, temperature zero, width four | +| `pe1006_hive/` | Five mathematical/research seats; native CortexDB port, durable Markdown memory, sealed/research context and original workspace templates | +| `multi_hive/` | Three reusable profiles, four persistent seats, two hives sharing one seat with narrower review authority | + +Each `hive.json` is parsed through `HiveConfig::load_dir`. Profile Markdown +frontmatter supplies typed template metadata; its body supplies the native +system prompt. Context Markdown is selected explicitly by filename stem. +Conventional `README.md` files are ignored by both directory scanners. + +Runtime-only executable paths, outboxes, Docker arguments and model routes are +set by the binary before validation. No manifest carries a literal credential. +The multi-hive live provider uses `{"env":"OPENROUTER_API_KEY"}`; offline runs +replace that reference with a trusted loopback provider port and a non-secret +fixture token. No operator credential is needed. + +The Euler binary resolves its existing configured native CortexDB engine and +injects it through `BuildOptions.memory_engine`. If native memory is off because +its credential is unavailable, it continues with durable Markdown memory, as +before. The deterministic lowering test substitutes the native in-process +reference engine and never inspects operator credentials. Its acting directory +is selected per turn; workspace templates remain non-overwriting. + +The DeepSWE and Euler hosts retain their pure completion drivers. Declared +routing values feed those drivers; the deployment policy view supplies the +DeepSWE episode width. Coordinator membership and role settings use the same +manifest. Direct native turns are admitted by the pure driver rather than the +coordinator's separate global concurrency gate. + +See the [example guide](../README.md) for commands and the native denial/safe +recovery behavior demonstrated by `multi_hive`. diff --git a/examples/openhuman/hives/basic_hive/README.md b/examples/openhuman/hives/basic_hive/README.md new file mode 100644 index 00000000..cc836cdd --- /dev/null +++ b/examples/openhuman/hives/basic_hive/README.md @@ -0,0 +1,5 @@ +# basic_hive manifest + +`hive.json` declares the runtime, seats and memberships. `profiles/` contains +reusable native template metadata and prompts; `context/` carries shared +Markdown source content. See the [manifest index](../README.md). diff --git a/examples/openhuman/hives/basic_hive/context/README.md b/examples/openhuman/hives/basic_hive/context/README.md new file mode 100644 index 00000000..dfc5dc6e --- /dev/null +++ b/examples/openhuman/hives/basic_hive/context/README.md @@ -0,0 +1,8 @@ +# basic_hive context + +| File | Purpose | +| --- | --- | +| `release.md` | Checked-in release Markdown context | + +The config loader ignores this README. Context bodies are selected +explicitly by filename stem. diff --git a/examples/openhuman/hives/basic_hive/context/release.md b/examples/openhuman/hives/basic_hive/context/release.md new file mode 100644 index 00000000..adb996f9 --- /dev/null +++ b/examples/openhuman/hives/basic_hive/context/release.md @@ -0,0 +1 @@ +Plan and review a release. Private assignments belong only to their named seat. diff --git a/examples/openhuman/hives/basic_hive/hive.json b/examples/openhuman/hives/basic_hive/hive.json new file mode 100644 index 00000000..78fbd956 --- /dev/null +++ b/examples/openhuman/hives/basic_hive/hive.json @@ -0,0 +1,31 @@ +{ + "runtime": { + "autonomy": "read_only" + }, + "seats": [ + { + "id": "alice", + "profile": "alice" + }, + { + "id": "bob", + "profile": "bob" + } + ], + "hives": [ + { + "id": "release", + "name": "Release", + "members": [ + { + "seat": "alice", + "role": "planner" + }, + { + "seat": "bob", + "role": "reviewer" + } + ] + } + ] +} diff --git a/examples/openhuman/hives/basic_hive/profiles/README.md b/examples/openhuman/hives/basic_hive/profiles/README.md new file mode 100644 index 00000000..02e61a47 --- /dev/null +++ b/examples/openhuman/hives/basic_hive/profiles/README.md @@ -0,0 +1,9 @@ +# basic_hive profiles + +| File | Purpose | +| --- | --- | +| `alice.md` | Native template metadata and alice system prompt | +| `bob.md` | Native template metadata and bob system prompt | + +The config loader ignores this README. Profile frontmatter is typed YAML; +the body supplies the system prompt. diff --git a/examples/openhuman/hives/basic_hive/profiles/alice.md b/examples/openhuman/hives/basic_hive/profiles/alice.md new file mode 100644 index 00000000..c5cae864 --- /dev/null +++ b/examples/openhuman/hives/basic_hive/profiles/alice.md @@ -0,0 +1,6 @@ +--- +{ + "id": "alice" +} +--- +You are Alice. Answer ordinary host turns briefly. For an Incoming attributed Hivemind context, read the episode_id from its JSON and call the native hivemind_complete tool with that episode_id and a short result. A prose claim of completion is insufficient. diff --git a/examples/openhuman/hives/basic_hive/profiles/bob.md b/examples/openhuman/hives/basic_hive/profiles/bob.md new file mode 100644 index 00000000..5868702f --- /dev/null +++ b/examples/openhuman/hives/basic_hive/profiles/bob.md @@ -0,0 +1,6 @@ +--- +{ + "id": "bob" +} +--- +You are Bob. Answer ordinary host turns briefly. For an Incoming attributed Hivemind context, read the episode_id from its JSON and call the native hivemind_complete tool with that episode_id and a short result. A prose claim of completion is insufficient. diff --git a/examples/openhuman/hives/deepswe_hive/README.md b/examples/openhuman/hives/deepswe_hive/README.md new file mode 100644 index 00000000..beb05314 --- /dev/null +++ b/examples/openhuman/hives/deepswe_hive/README.md @@ -0,0 +1,5 @@ +# deepswe_hive manifest + +`hive.json` declares the runtime, seats and memberships. `profiles/` contains +reusable native template metadata and prompts; `context/` carries shared +Markdown source content. See the [manifest index](../README.md). diff --git a/examples/openhuman/hives/deepswe_hive/context/README.md b/examples/openhuman/hives/deepswe_hive/context/README.md new file mode 100644 index 00000000..d6a29c95 --- /dev/null +++ b/examples/openhuman/hives/deepswe_hive/context/README.md @@ -0,0 +1,8 @@ +# deepswe_hive context + +| File | Purpose | +| --- | --- | +| `protocol.md` | Checked-in protocol Markdown context | + +The config loader ignores this README. Context bodies are selected +explicitly by filename stem. diff --git a/examples/openhuman/hives/deepswe_hive/context/protocol.md b/examples/openhuman/hives/deepswe_hive/context/protocol.md new file mode 100644 index 00000000..2be8922e --- /dev/null +++ b/examples/openhuman/hives/deepswe_hive/context/protocol.md @@ -0,0 +1 @@ +Use mcp_list_tools to discover the deepswe and tinyhive servers, then mcp_call_tool to invoke their tools. End by actually invoking mcp_call_tool exactly once with server tinyhive, tool broadcast or complete_episode, and a concrete evidence message. Do not print the call as JSON or prose. Your turn is invalid until its tool result says accepted from your seat; after acceptance, make no more tool calls. Do not access the network, credentials, browser, integrations, or paths outside /workspace. diff --git a/examples/openhuman/hives/deepswe_hive/hive.json b/examples/openhuman/hives/deepswe_hive/hive.json new file mode 100644 index 00000000..19b71df5 --- /dev/null +++ b/examples/openhuman/hives/deepswe_hive/hive.json @@ -0,0 +1,98 @@ +{ + "runtime": { + "autonomy": "autonomous", + "model": { + "temperature": 0.0 + }, + "concurrency": 4, + "mcp": [ + { + "id": "deepswe", + "transport": "stdio", + "endpoint": "deepswe_hive", + "args": [ + "--mcp-workspace" + ], + "tools": [ + "file_read", + "file_write", + "file_edit", + "shell", + "test" + ] + } + ] + }, + "seats": [ + { + "id": "lead", + "profile": "lead" + }, + { + "id": "implementer", + "profile": "implementer" + }, + { + "id": "tester", + "profile": "tester" + }, + { + "id": "reviewer", + "profile": "reviewer" + } + ], + "hives": [ + { + "id": "deepswe", + "members": [ + { + "seat": "lead", + "role": "lead" + }, + { + "seat": "implementer", + "role": "implementer" + }, + { + "seat": "tester", + "role": "tester" + }, + { + "seat": "reviewer", + "role": "reviewer" + } + ], + "episode": { + "turn_budget": 12, + "round_width": 4, + "revealed_width": 1, + "blind_round": true, + "dominance_cap": 50, + "repetition_cap": 3, + "distance": "sequence", + "quorum": { + "threshold": 2, + "window": 30, + "require_grounded": true + }, + "weights": { + "recency": 5, + "importance": 30, + "relevance": 20, + "half_life": 20 + } + }, + "routing": { + "minimum_confidence": 0, + "high_impact_minimum_confidence": 0, + "clarification_threshold": 1000000, + "high_impact_threshold": 1000000, + "round_width": 4, + "choice_option_limit": 4 + }, + "coordinator": { + "round_width": 4 + } + } + ] +} diff --git a/examples/openhuman/hives/deepswe_hive/profiles/README.md b/examples/openhuman/hives/deepswe_hive/profiles/README.md new file mode 100644 index 00000000..98bb1eb8 --- /dev/null +++ b/examples/openhuman/hives/deepswe_hive/profiles/README.md @@ -0,0 +1,11 @@ +# deepswe_hive profiles + +| File | Purpose | +| --- | --- | +| `implementer.md` | Native template metadata and implementer system prompt | +| `lead.md` | Native template metadata and lead system prompt | +| `reviewer.md` | Native template metadata and reviewer system prompt | +| `tester.md` | Native template metadata and tester system prompt | + +The config loader ignores this README. Profile frontmatter is typed YAML; +the body supplies the system prompt. diff --git a/examples/openhuman/hives/deepswe_hive/profiles/implementer.md b/examples/openhuman/hives/deepswe_hive/profiles/implementer.md new file mode 100644 index 00000000..c256b7f6 --- /dev/null +++ b/examples/openhuman/hives/deepswe_hive/profiles/implementer.md @@ -0,0 +1,32 @@ +--- +{ + "id": "implementer", + "model": { + "temperature": 0.0 + }, + "limits": { + "iterations": 16 + }, + "tool_scope": { + "named": [ + "mcp_list_tools", + "mcp_call_tool" + ] + }, + "mcp": [ + { + "id": "tinyhive", + "transport": "stdio", + "endpoint": "deepswe_hive", + "args": [ + "--mcp-hive" + ], + "tools": [ + "broadcast", + "complete_episode" + ] + } + ] +} +--- +You are the implementer seat in a hermetic DeepSWE hive. Use mcp_list_tools to discover the deepswe and tinyhive servers, then use mcp_call_tool to invoke their tools. End by actually invoking mcp_call_tool exactly once with server tinyhive, tool broadcast or complete_episode, and a concrete evidence message. Do not print the call as JSON or prose. Your turn is invalid until its tool result says accepted from @implementer; after acceptance, make no more tool calls. diff --git a/examples/openhuman/hives/deepswe_hive/profiles/lead.md b/examples/openhuman/hives/deepswe_hive/profiles/lead.md new file mode 100644 index 00000000..b0c84121 --- /dev/null +++ b/examples/openhuman/hives/deepswe_hive/profiles/lead.md @@ -0,0 +1,32 @@ +--- +{ + "id": "lead", + "model": { + "temperature": 0.0 + }, + "limits": { + "iterations": 16 + }, + "tool_scope": { + "named": [ + "mcp_list_tools", + "mcp_call_tool" + ] + }, + "mcp": [ + { + "id": "tinyhive", + "transport": "stdio", + "endpoint": "deepswe_hive", + "args": [ + "--mcp-hive" + ], + "tools": [ + "broadcast", + "complete_episode" + ] + } + ] +} +--- +You are the lead seat in a hermetic DeepSWE hive. Use mcp_list_tools to discover the deepswe and tinyhive servers, then use mcp_call_tool to invoke their tools. End by actually invoking mcp_call_tool exactly once with server tinyhive, tool broadcast or complete_episode, and a concrete evidence message. Do not print the call as JSON or prose. Your turn is invalid until its tool result says accepted from @lead; after acceptance, make no more tool calls. diff --git a/examples/openhuman/hives/deepswe_hive/profiles/reviewer.md b/examples/openhuman/hives/deepswe_hive/profiles/reviewer.md new file mode 100644 index 00000000..37c7ffa3 --- /dev/null +++ b/examples/openhuman/hives/deepswe_hive/profiles/reviewer.md @@ -0,0 +1,32 @@ +--- +{ + "id": "reviewer", + "model": { + "temperature": 0.0 + }, + "limits": { + "iterations": 16 + }, + "tool_scope": { + "named": [ + "mcp_list_tools", + "mcp_call_tool" + ] + }, + "mcp": [ + { + "id": "tinyhive", + "transport": "stdio", + "endpoint": "deepswe_hive", + "args": [ + "--mcp-hive" + ], + "tools": [ + "broadcast", + "complete_episode" + ] + } + ] +} +--- +You are the reviewer seat in a hermetic DeepSWE hive. Use mcp_list_tools to discover the deepswe and tinyhive servers, then use mcp_call_tool to invoke their tools. End by actually invoking mcp_call_tool exactly once with server tinyhive, tool broadcast or complete_episode, and a concrete evidence message. Do not print the call as JSON or prose. Your turn is invalid until its tool result says accepted from @reviewer; after acceptance, make no more tool calls. diff --git a/examples/openhuman/hives/deepswe_hive/profiles/tester.md b/examples/openhuman/hives/deepswe_hive/profiles/tester.md new file mode 100644 index 00000000..bffe324e --- /dev/null +++ b/examples/openhuman/hives/deepswe_hive/profiles/tester.md @@ -0,0 +1,32 @@ +--- +{ + "id": "tester", + "model": { + "temperature": 0.0 + }, + "limits": { + "iterations": 16 + }, + "tool_scope": { + "named": [ + "mcp_list_tools", + "mcp_call_tool" + ] + }, + "mcp": [ + { + "id": "tinyhive", + "transport": "stdio", + "endpoint": "deepswe_hive", + "args": [ + "--mcp-hive" + ], + "tools": [ + "broadcast", + "complete_episode" + ] + } + ] +} +--- +You are the tester seat in a hermetic DeepSWE hive. Use mcp_list_tools to discover the deepswe and tinyhive servers, then use mcp_call_tool to invoke their tools. End by actually invoking mcp_call_tool exactly once with server tinyhive, tool broadcast or complete_episode, and a concrete evidence message. Do not print the call as JSON or prose. Your turn is invalid until its tool result says accepted from @tester; after acceptance, make no more tool calls. diff --git a/examples/openhuman/hives/multi_hive/README.md b/examples/openhuman/hives/multi_hive/README.md new file mode 100644 index 00000000..06272b3a --- /dev/null +++ b/examples/openhuman/hives/multi_hive/README.md @@ -0,0 +1,5 @@ +# multi_hive manifest + +`hive.json` declares the runtime, seats and memberships. `profiles/` contains +reusable native template metadata and prompts; `context/` carries shared +Markdown source content. See the [manifest index](../README.md). diff --git a/examples/openhuman/hives/multi_hive/context/README.md b/examples/openhuman/hives/multi_hive/context/README.md new file mode 100644 index 00000000..7ba75568 --- /dev/null +++ b/examples/openhuman/hives/multi_hive/context/README.md @@ -0,0 +1,8 @@ +# multi_hive context + +| File | Purpose | +| --- | --- | +| `review.md` | Checked-in review Markdown context | + +The config loader ignores this README. Context bodies are selected +explicitly by filename stem. diff --git a/examples/openhuman/hives/multi_hive/context/review.md b/examples/openhuman/hives/multi_hive/context/review.md new file mode 100644 index 00000000..018d74d1 --- /dev/null +++ b/examples/openhuman/hives/multi_hive/context/review.md @@ -0,0 +1 @@ +In hive B the shared seat observes evidence and cannot modify files. diff --git a/examples/openhuman/hives/multi_hive/hive.json b/examples/openhuman/hives/multi_hive/hive.json new file mode 100644 index 00000000..b30e5783 --- /dev/null +++ b/examples/openhuman/hives/multi_hive/hive.json @@ -0,0 +1,77 @@ +{ + "runtime": { + "autonomy": "autonomous", + "model": { + "name": "openai/gpt-oss-120b:nitro", + "temperature": 0.0 + }, + "provider": { + "kind": "openrouter", + "credential": { + "env": "OPENROUTER_API_KEY" + } + } + }, + "permission_profiles": [ + { + "id": "readonly", + "access": "read_only" + } + ], + "seats": [ + { + "id": "shared", + "profile": "worker" + }, + { + "id": "architect", + "profile": "planner" + }, + { + "id": "reviewer", + "profile": "reviewer" + }, + { + "id": "helper", + "profile": "worker" + } + ], + "hives": [ + { + "id": "a", + "name": "Implementation", + "members": [ + { + "seat": "shared", + "role": "implementer" + }, + { + "seat": "architect", + "role": "planner" + }, + { + "seat": "helper", + "role": "assistant" + } + ] + }, + { + "id": "b", + "name": "Review", + "members": [ + { + "seat": "shared", + "role": "observer", + "permission_profile": "readonly", + "context": [ + "review" + ] + }, + { + "seat": "reviewer", + "role": "reviewer" + } + ] + } + ] +} diff --git a/examples/openhuman/hives/multi_hive/profiles/README.md b/examples/openhuman/hives/multi_hive/profiles/README.md new file mode 100644 index 00000000..7e672082 --- /dev/null +++ b/examples/openhuman/hives/multi_hive/profiles/README.md @@ -0,0 +1,10 @@ +# multi_hive profiles + +| File | Purpose | +| --- | --- | +| `planner.md` | Native template metadata and planner system prompt | +| `reviewer.md` | Native template metadata and reviewer system prompt | +| `worker.md` | Native template metadata and worker system prompt | + +The config loader ignores this README. Profile frontmatter is typed YAML; +the body supplies the system prompt. diff --git a/examples/openhuman/hives/multi_hive/profiles/planner.md b/examples/openhuman/hives/multi_hive/profiles/planner.md new file mode 100644 index 00000000..58504d4b --- /dev/null +++ b/examples/openhuman/hives/multi_hive/profiles/planner.md @@ -0,0 +1,9 @@ +--- +{ + "id": "planner", + "limits": { + "iterations": 8 + } +} +--- +You are a planner. Read the attributed hive assignment and its episode_id. Use the requested native tool then hivemind_complete. When a host resumption note says a write was refused, finish the new assignment with hivemind_complete and never replay the write. diff --git a/examples/openhuman/hives/multi_hive/profiles/reviewer.md b/examples/openhuman/hives/multi_hive/profiles/reviewer.md new file mode 100644 index 00000000..e8373fd5 --- /dev/null +++ b/examples/openhuman/hives/multi_hive/profiles/reviewer.md @@ -0,0 +1,9 @@ +--- +{ + "id": "reviewer", + "limits": { + "iterations": 8 + } +} +--- +You are a reviewer. Read the attributed hive assignment and its episode_id. Use the requested native tool then hivemind_complete. When a host resumption note says a write was refused, finish the new assignment with hivemind_complete and never replay the write. diff --git a/examples/openhuman/hives/multi_hive/profiles/worker.md b/examples/openhuman/hives/multi_hive/profiles/worker.md new file mode 100644 index 00000000..4b91989d --- /dev/null +++ b/examples/openhuman/hives/multi_hive/profiles/worker.md @@ -0,0 +1,9 @@ +--- +{ + "id": "worker", + "limits": { + "iterations": 8 + } +} +--- +You are a worker. Read the attributed hive assignment and its episode_id. Use the requested native tool then hivemind_complete. When a host resumption note says a write was refused, finish the new assignment with hivemind_complete and never replay the write. diff --git a/examples/openhuman/hives/pe1006_hive/README.md b/examples/openhuman/hives/pe1006_hive/README.md new file mode 100644 index 00000000..ca56430c --- /dev/null +++ b/examples/openhuman/hives/pe1006_hive/README.md @@ -0,0 +1,5 @@ +# pe1006_hive manifest + +`hive.json` declares the runtime, seats and memberships. `profiles/` contains +reusable native template metadata and prompts; `context/` carries shared +Markdown source content. See the [manifest index](../README.md). diff --git a/examples/openhuman/hives/pe1006_hive/context/README.md b/examples/openhuman/hives/pe1006_hive/context/README.md new file mode 100644 index 00000000..e1159e18 --- /dev/null +++ b/examples/openhuman/hives/pe1006_hive/context/README.md @@ -0,0 +1,13 @@ +# pe1006_hive context + +| File | Purpose | +| --- | --- | +| `agents.md` | Checked-in agents Markdown context | +| `memory.md` | Checked-in memory Markdown context | +| `research.md` | Checked-in research Markdown context | +| `sealed.md` | Checked-in sealed Markdown context | +| `task.md` | Checked-in task Markdown context | + +The config loader ignores this README. The manifest selects context +bodies explicitly by filename stem. Workspace templates are also embedded by +the Euler binary. diff --git a/examples/openhuman/hives/pe1006_hive/context/agents.md b/examples/openhuman/hives/pe1006_hive/context/agents.md new file mode 100644 index 00000000..f5bdb221 --- /dev/null +++ b/examples/openhuman/hives/pe1006_hive/context/agents.md @@ -0,0 +1,26 @@ +# Hive Agents + +This directory is the durable shared workspace for every OpenHuman agent in +the hive. Read this file and `MEMORY.md` at the start of every turn. + +## Working agreement + +- Write code, derivations, evidence, and source notes under this workspace. +- Prefix role-owned working files with `theory_`, `solver_`, `checker_`, + `researcher_`, or `lead_` to make ownership clear. +- Never overwrite another role's evidence. Create a correction that names the + contradicted file and explains why. +- Add only reproduced facts to `MEMORY.md`; label hypotheses and rejected + approaches explicitly. +- A checker may write `SIGNED` only after running the cited command against + files that exist here. Matching two supplied samples is not sufficient. +- Do not store credentials, API keys, the private answer oracle, or secrets. +- Only the researcher may access the public web. Every web claim needs a URL. + +## Roles + +- `researcher`: locate and summarize public evidence with exact URLs. +- `theory`: derive the mathematical structure and state proof obligations. +- `solver`: implement justified methods and record reproducible commands. +- `checker`: attack claims, run independent checks, and fail closed. +- `lead`: reconcile the desk and report solved only after checker sign-off. diff --git a/examples/openhuman/hives/pe1006_hive/context/memory.md b/examples/openhuman/hives/pe1006_hive/context/memory.md new file mode 100644 index 00000000..297669dc --- /dev/null +++ b/examples/openhuman/hives/pe1006_hive/context/memory.md @@ -0,0 +1,23 @@ +# Hive Memory + +Durable shared learnings for this workspace. Keep entries concise and +evidence-linked. This file is agent-maintained and survives individual runs. + +## Established + +- The official statement is in `TASK.md`. +- `Psi(3) = 20302` and `Psi(10) mod 101001001 = 10699667` are supplied checks. + +## Rejected + +- Do not infer a global recurrence solely from a fitted finite prefix. +- Do not treat a finite forbidden-pattern language as the Fibonacci factor set. + +## Open work + +- Derive and independently verify an exact scalable method for the target. + +## Run notes + +Add dated, attributed entries here. Include the command and relative evidence +path for every claimed computation. diff --git a/examples/openhuman/hives/pe1006_hive/context/research.md b/examples/openhuman/hives/pe1006_hive/context/research.md new file mode 100644 index 00000000..3c814461 --- /dev/null +++ b/examples/openhuman/hives/pe1006_hive/context/research.md @@ -0,0 +1 @@ +You are the only seat allowed to access the public web. Use shell commands such as curl to search and fetch public sources. Return direct source URLs, distinguish a claimed answer from a derivation, and never treat one copied number as verification. Do not inspect this repository, inherited solution files, or any filesystem path outside the named workspace. Keep the desk message below 1800 characters. diff --git a/examples/openhuman/hives/pe1006_hive/context/sealed.md b/examples/openhuman/hives/pe1006_hive/context/sealed.md new file mode 100644 index 00000000..1f1ab86a --- /dev/null +++ b/examples/openhuman/hives/pe1006_hive/context/sealed.md @@ -0,0 +1 @@ +Use only the statement, this desk transcript, and computations in the shared workspace. Do not search the web, inspect this repository, use inherited solution memory, or read outside the workspace. Never invent a residue. Keep the desk message below 1800 characters and name concrete files or checks. diff --git a/examples/openhuman/hives/pe1006_hive/context/task.md b/examples/openhuman/hives/pe1006_hive/context/task.md new file mode 100644 index 00000000..1017155a --- /dev/null +++ b/examples/openhuman/hives/pe1006_hive/context/task.md @@ -0,0 +1,15 @@ +Starting with two strings S_0 = 0 and S_1 = 01, define S_n as the +concatenation S_(n-1)S_(n-2) for n >= 2. + +For example, S_2 = 010, S_3 = 01001, and S_4 = 01001010. + +A string is called a Fibonacci subword if it is a contiguous substring of +some S_n. For every positive integer k there are exactly k+1 different +Fibonacci subwords of length k. Interpret each as a decimal number, ignoring +leading zeroes, and let Psi(k) be the sum of their squares. + +For k = 3 the four subwords are 001, 010, 100, and 101, so +Psi(3) = 20302. You are also given +Psi(10) = 10699667 (mod 101001001). + +Find Psi(10^18) mod 101001001. diff --git a/examples/openhuman/hives/pe1006_hive/hive.json b/examples/openhuman/hives/pe1006_hive/hive.json new file mode 100644 index 00000000..51f06997 --- /dev/null +++ b/examples/openhuman/hives/pe1006_hive/hive.json @@ -0,0 +1,71 @@ +{ + "runtime": { + "autonomy": "autonomous", + "memory_engine": "host", + "model": { + "temperature": 0.0 + }, + "concurrency": 5 + }, + "seats": [ + { + "id": "theory", + "profile": "theory" + }, + { + "id": "solver", + "profile": "solver" + }, + { + "id": "checker", + "profile": "checker" + }, + { + "id": "lead", + "profile": "lead" + }, + { + "id": "researcher", + "profile": "researcher" + } + ], + "hives": [ + { + "id": "pe1006", + "members": [ + { + "seat": "theory", + "role": "theory" + }, + { + "seat": "solver", + "role": "solver" + }, + { + "seat": "checker", + "role": "checker" + }, + { + "seat": "lead", + "role": "lead" + }, + { + "seat": "researcher", + "role": "researcher" + } + ], + "memory_root": "pe1006", + "routing": { + "minimum_confidence": 0, + "high_impact_minimum_confidence": 0, + "clarification_threshold": 1000000, + "high_impact_threshold": 1000000, + "round_width": 5, + "choice_option_limit": 8 + }, + "coordinator": { + "round_width": 4 + } + } + ] +} diff --git a/examples/openhuman/hives/pe1006_hive/profiles/README.md b/examples/openhuman/hives/pe1006_hive/profiles/README.md new file mode 100644 index 00000000..f01ab157 --- /dev/null +++ b/examples/openhuman/hives/pe1006_hive/profiles/README.md @@ -0,0 +1,12 @@ +# pe1006_hive profiles + +| File | Purpose | +| --- | --- | +| `checker.md` | Native template metadata and checker system prompt | +| `lead.md` | Native template metadata and lead system prompt | +| `researcher.md` | Native template metadata and researcher system prompt | +| `solver.md` | Native template metadata and solver system prompt | +| `theory.md` | Native template metadata and theory system prompt | + +The config loader ignores this README. Profile frontmatter is typed YAML; +the body supplies the system prompt. diff --git a/examples/openhuman/hives/pe1006_hive/profiles/checker.md b/examples/openhuman/hives/pe1006_hive/profiles/checker.md new file mode 100644 index 00000000..dcb6e087 --- /dev/null +++ b/examples/openhuman/hives/pe1006_hive/profiles/checker.md @@ -0,0 +1,45 @@ +--- +{ + "id": "checker", + "model": { + "temperature": 0.0 + }, + "limits": { + "iterations": 12 + }, + "tool_scope": { + "named": [ + "file_read", + "file_write", + "mcp_list_tools", + "mcp_call_tool", + "shell" + ] + }, + "deny_tools": [ + "run_code", + "ask_docs" + ], + "mcp": [ + { + "id": "tinyhive", + "transport": "stdio", + "endpoint": "pe1006_hive", + "args": [ + "--hive-tools" + ], + "tools": [ + "broadcast", + "complete_episode", + "hive_memory_recall", + "hive_memory_note", + "hive_memory_forget" + ] + } + ], + "context": [ + "sealed" + ] +} +--- +You are the adversarial verifier. Independently reproduce samples, attack extrapolations, and sign only an exact candidate supported by code. diff --git a/examples/openhuman/hives/pe1006_hive/profiles/lead.md b/examples/openhuman/hives/pe1006_hive/profiles/lead.md new file mode 100644 index 00000000..3fd75b7c --- /dev/null +++ b/examples/openhuman/hives/pe1006_hive/profiles/lead.md @@ -0,0 +1,45 @@ +--- +{ + "id": "lead", + "model": { + "temperature": 0.0 + }, + "limits": { + "iterations": 12 + }, + "tool_scope": { + "named": [ + "file_read", + "file_write", + "mcp_list_tools", + "mcp_call_tool", + "shell" + ] + }, + "deny_tools": [ + "run_code", + "ask_docs" + ], + "mcp": [ + { + "id": "tinyhive", + "transport": "stdio", + "endpoint": "pe1006_hive", + "args": [ + "--hive-tools" + ], + "tools": [ + "broadcast", + "complete_episode", + "hive_memory_recall", + "hive_memory_note", + "hive_memory_forget" + ] + } + ], + "context": [ + "sealed" + ] +} +--- +You coordinate the desk. Reconcile disagreements, demand missing evidence, and state a final residue only after checker sign-off. diff --git a/examples/openhuman/hives/pe1006_hive/profiles/researcher.md b/examples/openhuman/hives/pe1006_hive/profiles/researcher.md new file mode 100644 index 00000000..85b4ce56 --- /dev/null +++ b/examples/openhuman/hives/pe1006_hive/profiles/researcher.md @@ -0,0 +1,45 @@ +--- +{ + "id": "researcher", + "model": { + "temperature": 0.0 + }, + "limits": { + "iterations": 12 + }, + "tool_scope": { + "named": [ + "file_read", + "file_write", + "mcp_list_tools", + "mcp_call_tool", + "shell" + ] + }, + "deny_tools": [ + "run_code", + "ask_docs" + ], + "mcp": [ + { + "id": "tinyhive", + "transport": "stdio", + "endpoint": "pe1006_hive", + "args": [ + "--hive-tools" + ], + "tools": [ + "broadcast", + "complete_episode", + "hive_memory_recall", + "hive_memory_note", + "hive_memory_forget" + ] + } + ], + "context": [ + "research" + ] +} +--- +You are the web researcher. Locate public derivations, implementations, or corroborating results and report exact URLs plus useful mathematical steps. diff --git a/examples/openhuman/hives/pe1006_hive/profiles/solver.md b/examples/openhuman/hives/pe1006_hive/profiles/solver.md new file mode 100644 index 00000000..f388af3c --- /dev/null +++ b/examples/openhuman/hives/pe1006_hive/profiles/solver.md @@ -0,0 +1,45 @@ +--- +{ + "id": "solver", + "model": { + "temperature": 0.0 + }, + "limits": { + "iterations": 12 + }, + "tool_scope": { + "named": [ + "file_read", + "file_write", + "mcp_list_tools", + "mcp_call_tool", + "shell" + ] + }, + "deny_tools": [ + "run_code", + "ask_docs" + ], + "mcp": [ + { + "id": "tinyhive", + "transport": "stdio", + "endpoint": "pe1006_hive", + "args": [ + "--hive-tools" + ], + "tools": [ + "broadcast", + "complete_episode", + "hive_memory_recall", + "hive_memory_note", + "hive_memory_forget" + ] + } + ], + "context": [ + "sealed" + ] +} +--- +You are the implementation specialist. Turn proven formulas into exact modular code, run it, and report reproducible commands and residues. diff --git a/examples/openhuman/hives/pe1006_hive/profiles/theory.md b/examples/openhuman/hives/pe1006_hive/profiles/theory.md new file mode 100644 index 00000000..52bc31e6 --- /dev/null +++ b/examples/openhuman/hives/pe1006_hive/profiles/theory.md @@ -0,0 +1,45 @@ +--- +{ + "id": "theory", + "model": { + "temperature": 0.0 + }, + "limits": { + "iterations": 12 + }, + "tool_scope": { + "named": [ + "file_read", + "file_write", + "mcp_list_tools", + "mcp_call_tool", + "shell" + ] + }, + "deny_tools": [ + "run_code", + "ask_docs" + ], + "mcp": [ + { + "id": "tinyhive", + "transport": "stdio", + "endpoint": "pe1006_hive", + "args": [ + "--hive-tools" + ], + "tools": [ + "broadcast", + "complete_episode", + "hive_memory_recall", + "hive_memory_note", + "hive_memory_forget" + ] + } + ], + "context": [ + "sealed" + ] +} +--- +You are the Fibonacci-word combinatorics specialist. Derive exact structure and logarithmic formulas; test every claimed identity on small k. diff --git a/examples/openhuman/src/README.md b/examples/openhuman/src/README.md new file mode 100644 index 00000000..843ebfc9 --- /dev/null +++ b/examples/openhuman/src/README.md @@ -0,0 +1,12 @@ +# OpenHuman example source + +| File | Purpose | +| --- | --- | +| `lib.rs` | Manifest loading, deployment ports and exact experiment MCP effect classification | +| `test.rs` | Exact MCP effect classification and fail-closed unknown verbs | +| `main.rs` | Supplied-agent and dynamic-registration proof | +| `proof/` | Offline topology, catalogue and management assertions | +| `bin/` | Manifest-backed basic, multi-hive, DeepSWE and Euler executables | + +The standalone crate keeps its own lockfile and target directory. See the +[parent guide](../README.md) for commands and host constraints. diff --git a/examples/openhuman/src/bin/README.md b/examples/openhuman/src/bin/README.md index fa75917d..2424fc97 100644 --- a/examples/openhuman/src/bin/README.md +++ b/examples/openhuman/src/bin/README.md @@ -2,6 +2,7 @@ | File | Purpose | | --- | --- | +| `multi_hive.rs` | Offline/live one-runtime shared-seat proof: distinct roles, allowed/refused file writes, explicit safe recovery, continuing session. | | `basic_hive.rs` | Offline or live two-agent OpenHuman quickstart: host sessions, hive membership, private tasks, leaving, and session continuity. | | `deepswe_hive.rs` | Hermetic four-agent external software-engineering adapter with host-side OpenHuman and Docker-confined tools. | | `deepswe_hive/` | Task validation, MCP tools, Docker confinement, and contract tests for the DeepSWE adapter. | diff --git a/examples/openhuman/src/bin/basic_hive.rs b/examples/openhuman/src/bin/basic_hive.rs index 7b24ea53..4fdcb4d4 100644 --- a/examples/openhuman/src/bin/basic_hive.rs +++ b/examples/openhuman/src/bin/basic_hive.rs @@ -5,16 +5,15 @@ //! Pass `--live` and `OPENROUTER_API_KEY` to use a real OpenRouter model. use anyhow::{Context, Result, ensure}; -use openhuman_embed::{Access, AgentSpec, Provider, Runtime, Workspace}; +use openhuman_embed::Provider; use serde_json::{Value, json}; use std::sync::{ Arc, atomic::{AtomicUsize, Ordering}, }; -use tinyhivemind_hives::{ - Coordinator, CoordinatorOptions, Destination, HiveInfo, MemoryStorage, SendMessage, Storage, -}; -use tinyhivemind_openhuman::OpenHumanHost; +use tinyhivemind_hives::{Destination, MemoryStorage, SendMessage, Storage}; +use tinyhivemind_openhuman::deploy::BuildOptions; +use tinyhivemind_openhuman_example::{deploy, manifest}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; @@ -120,21 +119,20 @@ async fn run(mode: Mode) -> Result<()> { (None, route) } }; - let runtime = Runtime::builder() - .config(tinyhivemind_openhuman::offline::config()) - .workspace(Workspace::Ephemeral) - .backend_url(backend.uri()) - .provider(route) - .access(Access::readonly()) - .build() - .await?; - - // The host owns agent construction and ordinary OpenHuman conversations. - let instructions = "Answer ordinary host turns briefly. For an Incoming attributed Hivemind context, read the episode_id from its JSON and call the native hivemind_complete tool with that episode_id and a short result. A prose claim of completion is insufficient."; - let alice = runtime - .agent(AgentSpec::new("alice").system_prompt(format!("You are Alice. {instructions}")))?; - let bob = runtime - .agent(AgentSpec::new("bob").system_prompt(format!("You are Bob. {instructions}")))?; + let storage = Arc::new(MemoryStorage::new()); + let deployment = deploy( + manifest("basic_hive")?, + BuildOptions { + config: Some(tinyhivemind_openhuman::offline::config()), + backend_url: Some(backend.uri()), + provider: Some(route), + coordinator_storage: Some(storage.clone()), + ..Default::default() + }, + ) + .await?; + let alice = deployment.seats()["alice"].clone(); + let bob = deployment.seats()["bob"].clone(); let alice_session = alice .turn("ALICE_BEFORE_HIVE: plan a release") .session("alice-session") @@ -149,29 +147,9 @@ async fn run(mode: Mode) -> Result<()> { .session_id; println!("Before the hive: Alice and Bob each have a host conversation."); - let storage = Arc::new(MemoryStorage::new()); - let coordinator = Coordinator::new( - runtime.runtime_id().into(), - storage.clone(), - CoordinatorOptions::default(), - ) - .await?; - let host = OpenHumanHost::new(runtime.runtime_id().into(), coordinator.clone())?; - host.register_agent_in_session(alice.clone(), &alice_session) - .await?; - host.register_agent_in_session(bob.clone(), &bob_session) - .await?; - coordinator - .create_hive(HiveInfo { - hive_id: "release".into(), - name: "Release".into(), - description: Some("Plan and review a release".into()), - members: Vec::new(), - }) - .await?; - for agent in [&alice, &bob] { - coordinator.join_hive("release", agent.id()).await?; - } + let coordinator = deployment.host().coordinator(); + coordinator.bind_session("alice", &alice_session).await?; + coordinator.bind_session("bob", &bob_session).await?; println!("In the hive: {:?}", coordinator.list_hives()?[0].members); // Each private task reaches its named member through the real adapter. diff --git a/examples/openhuman/src/bin/deepswe_hive.rs b/examples/openhuman/src/bin/deepswe_hive.rs index 9db967a3..da98fa7f 100644 --- a/examples/openhuman/src/bin/deepswe_hive.rs +++ b/examples/openhuman/src/bin/deepswe_hive.rs @@ -2,25 +2,19 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; -use std::sync::Arc; use std::time::Duration; use futures::future::join_all; -use openhuman_core::agent::registry::types::{ - AgentRegistryEntry, AgentRegistrySource, AgentSubagentPolicy, -}; -use openhuman_embed::{ - Access, Agent, AgentDefinitionSpec, AgentSpec, CoreError, McpServer, Provider, Runtime, - RuntimeConfig, ToolScopeSpec, Workspace, -}; +use openhuman_embed::{Agent, CoreError, Provider, RuntimeConfig}; use serde::{Deserialize, Serialize}; use tinyhivemind_core::driver::{ AgentBinding, BoundHive, BroadcastRouting, CommittedUtterance, CompletionDriver, HiveGraph, }; use tinyhivemind_core::hive::{CompletionEpisodeState, CompletionStep, completion_status}; use tinyhivemind_core::runtime::desk::{Desk, ResponderMode}; -use tinyhivemind_core::runtime::responder::Probability; use tinyhivemind_openhuman::RegisteredAgent; +use tinyhivemind_openhuman::deploy::BuildOptions; +use tinyhivemind_openhuman_example::{deploy, manifest}; use wiremock::matchers::any; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -42,7 +36,6 @@ use task::Task; const DEFAULT_MODEL: &str = "openai/gpt-oss-120b:nitro"; const MAX_TURNS: u32 = 24; const MAX_SEAT_ATTEMPTS: u32 = 3; -const ROUND_WIDTH: usize = 4; const TURN_TIMEOUT: Duration = Duration::from_secs(600); const SEATS: [&str; 4] = ["lead", "implementer", "tester", "reviewer"]; @@ -170,20 +163,39 @@ async fn run_prepared_with_mcp_executable( }))) .mount(&backend) .await; - let runtime = Arc::new( - Runtime::builder() - .config(offline_config()) - .workspace(Workspace::dir(artifacts.runtime)) - .backend_url(backend.uri()) - .provider(Provider::openai_compatible(&cli.api_base, api_key).model(&cli.model)) - .access(Access::full()) - .build() - .await?, - ); + let mut config = manifest("deepswe_hive")?; + config.runtime.workspace = Some(artifacts.runtime.display().to_string()); + config.runtime.mcp[0].endpoint = mcp_executable.display().to_string(); + config.runtime.mcp[0].args = sandbox.mcp_args(); + for profile in &mut config.profiles { + let mcp = &mut profile.mcp[0]; + mcp.endpoint = mcp_executable.display().to_string(); + mcp.args = vec![ + "--mcp-hive".into(), + "--agent".into(), + profile.id.clone(), + "--outbox".into(), + outbox + .join(format!("{}.jsonl", profile.id)) + .display() + .to_string(), + ]; + } + let routing_policy = config.hives[0].routing.clone(); + let deployment = deploy( + config, + BuildOptions { + config: Some(offline_config()), + backend_url: Some(backend.uri()), + provider: Some(Provider::openai_compatible(&cli.api_base, api_key).model(&cli.model)), + ..Default::default() + }, + ) + .await?; let bindings = SEATS .iter() - .map(|id| instantiate(&runtime, &sandbox, &outbox, mcp_executable, id)) - .collect::>>()?; + .map(|id| AgentBinding::new(*id, RegisteredAgent(deployment.seats()[*id].clone()))) + .collect(); let hive = BoundHive::new( HiveGraph::new( Desk { @@ -197,7 +209,14 @@ async fn run_prepared_with_mcp_executable( ), bindings, )?; - let driver = CompletionDriver::new(&hive, ROUND_WIDTH)?; + let round_width = usize::try_from( + deployment + .policy("deepswe") + .ok_or_else(|| anyhow::anyhow!("missing declared hive policy"))? + .episode + .round_width, + )?; + let driver = CompletionDriver::new(&hive, round_width)?; let episode = CompletionEpisodeState::opened( tinyhivemind_core::runtime::Conversation { desk_id: hive.desk().id.clone(), @@ -264,14 +283,6 @@ async fn run_prepared_with_mcp_executable( }); } turns = turns.saturating_add(u32::try_from(pending.agents().len()).unwrap_or(u32::MAX)); - let routing_policy = tinyhivemind_core::embed::RoutingPolicy { - minimum_confidence: Probability::ZERO, - high_impact_minimum_confidence: Probability::ZERO, - clarification_threshold: Probability::ONE, - high_impact_threshold: Probability::ONE, - round_width: ROUND_WIDTH, - choice_option_limit: 4, - }; let routing = committed.iter().any(|event| { matches!( event.utterance, @@ -358,7 +369,14 @@ async fn run_seat( ) }); let prompt = seat_prompt(&task, &id, &delta, retry_instruction.as_deref()); - let send = tokio::time::timeout(turn_timeout, agent.turn(prompt).send()).await; + let started = tokio::time::Instant::now(); + let outcome = agent + .turn(prompt) + .cwd(&task.repo_path) + .timeout(turn_timeout) + .send() + .await; + let send = deadline_result(outcome, started.elapsed(), turn_timeout); let utterances = mcp::drain(&outbox)?; if utterances.len() > 1 { anyhow::bail!( @@ -408,6 +426,19 @@ async fn run_seat( unreachable!("the bounded attempt loop either returns or reports exhaustion") } +fn deadline_result( + result: Result, + elapsed: Duration, + timeout: Duration, +) -> Result, CoreError> { + match result { + Err(error) if matches!(error, CoreError::DeadlineExceeded { .. }) || elapsed >= timeout => { + Err(error) + } + result => Ok(result), + } +} + fn accepted_action_reply( id: &str, utterance: &tinyhivemind_core::runtime::speech::Utterance, @@ -445,7 +476,13 @@ fn retryable_provider_error(error: &CoreError) -> bool { .and_then(serde_json::Value::as_bool) .unwrap_or_else(|| retryable_provider_message(message)), CoreError::Rpc { message, .. } => retryable_provider_message(message), - CoreError::Unavailable { .. } + CoreError::Cancelled { .. } + | CoreError::DeadlineExceeded { .. } + | CoreError::StructuredOutput { .. } + | CoreError::BudgetExceeded { .. } + | CoreError::TurnCancelled { .. } + | CoreError::AgentRemoved { .. } + | CoreError::Unavailable { .. } | CoreError::Encode { .. } | CoreError::Decode { .. } | CoreError::InsecureRoute { .. } @@ -488,72 +525,6 @@ fn seat_prompt(task: &Task, id: &str, delta: &str, retry_instruction: Option<&st prompt } -fn instantiate( - runtime: &Runtime, - sandbox: &DockerSandbox, - outbox_dir: &Path, - executable: &Path, - id: &str, -) -> anyhow::Result> { - let workspace = McpServer::stdio("deepswe", executable.to_string_lossy(), sandbox.mcp_args()) - .allow_tools(["file_read", "file_write", "file_edit", "shell", "test"]) - .description("Docker-confined local checkout tools"); - let hive = McpServer::stdio( - "tinyhive", - executable.to_string_lossy(), - [ - "--mcp-hive".to_string(), - "--agent".to_string(), - id.to_string(), - "--outbox".to_string(), - outbox_dir.join(format!("{id}.jsonl")).display().to_string(), - ], - ) - .allow_tools(["broadcast", "complete_episode"]) - .description("Local TinyHiveMind completion tools"); - let tools = vec!["mcp_list_tools".into(), "mcp_call_tool".into()]; - let definition_prompt = format!( - "You are the {id} seat in a hermetic DeepSWE hive. Use mcp_list_tools \ - to discover the deepswe and tinyhive servers, then use mcp_call_tool \ - to invoke their tools. End by actually invoking mcp_call_tool exactly \ - once with server tinyhive, tool broadcast or complete_episode, and a \ - concrete evidence message. Do not print the call as JSON or prose. \ - Your turn is invalid until its tool result says accepted from @{id}; \ - after acceptance, make no more tool calls." - ); - let registry_entry = AgentRegistryEntry { - id: format!("deepswe-{id}"), - name: format!("DeepSWE {id}"), - description: "Hermetic DeepSWE hive seat".into(), - source: AgentRegistrySource::Custom, - enabled: true, - model: None, - system_prompt: Some(definition_prompt.clone()), - tool_allowlist: tools.clone(), - tool_denylist: Vec::new(), - subagents: AgentSubagentPolicy::default(), - tags: Vec::new(), - metadata: serde_json::Value::Null, - }; - runtime - .agent( - AgentSpec::new(format!("deepswe-{id}")) - .definition( - AgentDefinitionSpec::new() - .system_prompt(definition_prompt) - .tools(ToolScopeSpec::Named(tools.clone())) - .max_iterations(16) - .temperature(0.0), - ) - .mcp(workspace) - .mcp(hive) - .config(move |config| config.agent_registry.entries.push(registry_entry)) - .action_dir(sandbox.repo_path()), - ) - .map(|agent| AgentBinding::new(id, RegisteredAgent(agent))) - .map_err(Into::into) -} - fn visible_delta(visibility: &Visibility, id: &str, transcript: &[DeskMessage]) -> String { let seen = visibility.0.get(id); transcript diff --git a/examples/openhuman/src/bin/deepswe_hive/sandbox.rs b/examples/openhuman/src/bin/deepswe_hive/sandbox.rs index 9c882661..5e758438 100644 --- a/examples/openhuman/src/bin/deepswe_hive/sandbox.rs +++ b/examples/openhuman/src/bin/deepswe_hive/sandbox.rs @@ -237,10 +237,6 @@ exit "${statuses[0]}" require_success(output, "capture final patch")?; Ok(String::from_utf8(patch)?) } - pub(super) fn repo_path(&self) -> &Path { - &self.config.repo_path - } - pub(super) fn mcp_args(&self) -> Vec { vec![ "--mcp-workspace".into(), diff --git a/examples/openhuman/src/bin/deepswe_hive/test.rs b/examples/openhuman/src/bin/deepswe_hive/test.rs index 2e33e7df..6c87aaaa 100644 --- a/examples/openhuman/src/bin/deepswe_hive/test.rs +++ b/examples/openhuman/src/bin/deepswe_hive/test.rs @@ -16,6 +16,10 @@ use super::{ }; use crate::task::Task; +#[path = "test/deadline.rs"] +mod deadline; +#[path = "test/docker.rs"] +mod docker; #[path = "test/retry.rs"] mod retry; #[path = "test/security.rs"] @@ -513,168 +517,6 @@ fn sandbox_runs_as_the_checkout_owner() { ); } -#[test] -fn live_real_docker_fixture_flow_confines_actions_and_captures_new_files() { - if std::env::var_os("DEEPSWE_REAL_DOCKER_TEST").is_none() { - return; - } - let _docker_guard = real_docker_test_guard(); - let directory = TempDir::new().expect("disposable fixture"); - copy_fixture( - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/deepswe_repo"), - directory.path(), - ); - git(directory.path(), &["init", "-q"]); - git( - directory.path(), - &["config", "user.email", "fixture@example.invalid"], - ); - git( - directory.path(), - &["config", "user.name", "DeepSWE Fixture"], - ); - git(directory.path(), &["add", "."]); - git(directory.path(), &["commit", "-qm", "fixture"]); - let head = git_output(directory.path(), &["rev-parse", "HEAD"]); - let task = Task { - instance_id: "docker-fixture".into(), - repo_path: directory.path().to_path_buf(), - base_commit: head.trim().into(), - problem_statement: "Make the fixture pass".into(), - test_command: "sh test.sh".into(), - }; - task.validate().expect("valid checked-in fixture copy"); - let sandbox = DockerSandbox::preflight(SandboxConfig::from_env(task.repo_path.clone())) - .expect("real Docker preflight"); - let args = action_arguments(&sandbox).expect("mount arguments"); - assert!(args.windows(2).any(|pair| pair == ["--network", "none"])); - assert!( - args.iter() - .any(|arg| arg.contains("dst=/workspace/.git,readonly")) - ); - assert!(args.windows(2).any(|pair| pair == ["--memory", "1g"])); - assert!(args.windows(2).any(|pair| pair == ["--cpus", "2"])); - assert!(args.windows(2).any(|pair| pair == ["--pids-limit", "256"])); - sandbox - .file_write("new.txt", "all new\n") - .expect("container new file"); - let new_only_patch = sandbox - .patch(&task.base_commit) - .expect("all-new-file patch"); - assert!(new_only_patch.contains("diff --git a/new.txt b/new.txt")); - assert!(!new_only_patch.contains("answer.txt")); - sandbox - .shell("truncate -s 1048576 huge-sparse.bin") - .expect("large sparse file"); - let oversized = patch_with_limits(&sandbox, &task.base_commit, Duration::from_secs(10), 128) - .expect_err("oversized patch rejected"); - assert!(oversized.to_string().contains("exceeds 128-byte limit")); - assert_eq!( - oversized.downcast_ref::(), - Some(&InspectorFailure::PatchTooLarge { max_bytes: 128 }) - ); - std::fs::remove_file(directory.path().join("huge-sparse.bin")).expect("remove sparse fixture"); - sandbox - .file_edit("answer.txt", "wrong", "right") - .expect("container edit"); - assert_eq!( - sandbox.file_read("new.txt").expect("container read"), - "all new\n" - ); - let sentinel = directory - .path() - .parent() - .expect("parent") - .join("host-sentinel"); - std::fs::write(&sentinel, "safe\n").expect("sentinel"); - #[cfg(unix)] - std::os::unix::fs::symlink(&sentinel, directory.path().join("escape")).expect("escape symlink"); - assert!(sandbox.file_write("escape", "damaged\n").is_err()); - assert_eq!( - std::fs::read_to_string(&sentinel).expect("sentinel read"), - "safe\n" - ); - let env = sandbox.shell("env").expect("container env"); - assert!(!env.stdout.contains("OPENROUTER")); - assert!(!env.stdout.contains("API_KEY")); - assert!(!env.stdout.contains("DEEPSWE_TEST_SECRET")); - let history = sandbox - .shell("git reset --hard HEAD; git clean -fdx; git commit --allow-empty -m pwn") - .expect("history commands confined"); - assert_ne!(history.code, Some(0)); - assert_eq!(git_output(directory.path(), &["rev-parse", "HEAD"]), head); - let network = sandbox - .shell("test ! -e /sys/class/net/eth0 || ! ip route 2>/dev/null | grep -q default") - .expect("network probe"); - assert_eq!(network.code, Some(0)); - let test = sandbox.shell(&task.test_command).expect("final test"); - assert_eq!(test.code, Some(0)); - let marker = directory - .path() - .parent() - .expect("parent") - .join("diff-driver-ran"); - std::fs::write( - directory.path().join(".gitattributes"), - "answer.txt diff=hostile\n", - ) - .expect("attributes"); - git( - directory.path(), - &[ - "config", - "diff.hostile.command", - &format!("touch {}", marker.display()), - ], - ); - let patch = sandbox.patch(&task.base_commit).expect("inspector patch"); - assert!(patch.contains("+right")); - assert!(patch.contains("diff --git a/new.txt b/new.txt")); - assert!( - !marker.exists(), - "repository diff command must not run on the host" - ); - assert_eq!(git_output(directory.path(), &["rev-parse", "HEAD"]), head); - - let linked_parent = TempDir::new().expect("worktree parent"); - let linked = linked_parent.path().join("linked"); - let linked_text = linked.to_string_lossy().into_owned(); - git( - directory.path(), - &["worktree", "add", "-qb", "linked-fixture", &linked_text], - ); - assert!(linked.join(".git").is_file()); - let linked_head = git_output(&linked, &["rev-parse", "HEAD"]); - let linked_task = Task { - instance_id: "docker-worktree-fixture".into(), - repo_path: linked.clone(), - base_commit: linked_head.trim().into(), - problem_statement: "Add a file without exposing worktree history".into(), - test_command: "test -f linked-new.txt".into(), - }; - linked_task - .validate() - .expect("valid linked worktree fixture"); - let linked_sandbox = - DockerSandbox::preflight(SandboxConfig::from_env(linked_task.repo_path.clone())) - .expect("worktree Docker preflight"); - linked_sandbox - .file_write("linked-new.txt", "linked\n") - .expect("worktree new file"); - assert_ne!( - linked_sandbox - .shell("git reset --hard HEAD") - .expect("worktree history probe") - .code, - Some(0) - ); - let linked_patch = linked_sandbox - .patch(&linked_task.base_commit) - .expect("worktree inspector patch"); - assert!(linked_patch.contains("diff --git a/linked-new.txt b/linked-new.txt")); - assert_eq!(git_output(&linked, &["rev-parse", "HEAD"]), linked_head); -} - fn copy_fixture(source: std::path::PathBuf, destination: &Path) { for entry in std::fs::read_dir(source).expect("fixture directory") { let entry = entry.expect("fixture entry"); diff --git a/examples/openhuman/src/bin/deepswe_hive/test/README.md b/examples/openhuman/src/bin/deepswe_hive/test/README.md index 86573701..5890b4e7 100644 --- a/examples/openhuman/src/bin/deepswe_hive/test/README.md +++ b/examples/openhuman/src/bin/deepswe_hive/test/README.md @@ -2,6 +2,8 @@ | File | Purpose | | --- | --- | +| `docker.rs` | Optional real Docker confinement and patch-capture integration proof. | +| `deadline.rs` | Distinguishes cancellation RPCs at the selected native deadline from early provider errors. | | `security.rs` | Exercises hostile Git/output paths, gitlink refusal, bounded staged action input and patch capture, and verified container cleanup. | | `retry.rs` | Scripts loopback provider failures and native MCP outboxes to prove exactly-once reconciliation, bounded safe retries, timeout refusal, and atomic round commit. | | `retry/` | Holds focused response builders used by the retry scenarios. | diff --git a/examples/openhuman/src/bin/deepswe_hive/test/deadline.rs b/examples/openhuman/src/bin/deepswe_hive/test/deadline.rs new file mode 100644 index 00000000..5a653b61 --- /dev/null +++ b/examples/openhuman/src/bin/deepswe_hive/test/deadline.rs @@ -0,0 +1,34 @@ +//! Native cancellation RPC errors keep fail-closed deadline accounting. +use super::super::{CoreError, deadline_result}; +use std::time::Duration; + +#[test] +fn cancellation_rpc_at_the_selected_deadline_is_a_timeout() { + let error = CoreError::Rpc { + method: "inference.agent_chat", + message: "fixture cancellation".into(), + }; + assert!( + deadline_result::<()>(Err(error), Duration::from_secs(2), Duration::from_secs(2)).is_err() + ); +} + +#[test] +fn an_early_rpc_failure_keeps_provider_failure_accounting() { + let error = CoreError::Rpc { + method: "inference.agent_chat", + message: "fixture provider failure".into(), + }; + assert!(matches!( + deadline_result::<()>(Err(error), Duration::from_millis(1), Duration::from_secs(2)), + Ok(Err(CoreError::Rpc { .. })) + )); +} + +#[test] +fn native_deadline_error_is_a_timeout_even_before_clock_accounting() { + let error = CoreError::DeadlineExceeded { + method: "inference.agent_chat", + }; + assert!(deadline_result::<()>(Err(error), Duration::ZERO, Duration::from_secs(2)).is_err()); +} diff --git a/examples/openhuman/src/bin/deepswe_hive/test/docker.rs b/examples/openhuman/src/bin/deepswe_hive/test/docker.rs new file mode 100644 index 00000000..6adb8540 --- /dev/null +++ b/examples/openhuman/src/bin/deepswe_hive/test/docker.rs @@ -0,0 +1,164 @@ +//! Optional real Docker confinement and patch-capture integration proof. +use super::*; + +#[test] +fn live_real_docker_fixture_flow_confines_actions_and_captures_new_files() { + if std::env::var_os("DEEPSWE_REAL_DOCKER_TEST").is_none() { + return; + } + let _docker_guard = real_docker_test_guard(); + let directory = TempDir::new().expect("disposable fixture"); + copy_fixture( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/deepswe_repo"), + directory.path(), + ); + git(directory.path(), &["init", "-q"]); + git( + directory.path(), + &["config", "user.email", "fixture@example.invalid"], + ); + git( + directory.path(), + &["config", "user.name", "DeepSWE Fixture"], + ); + git(directory.path(), &["add", "."]); + git(directory.path(), &["commit", "-qm", "fixture"]); + let head = git_output(directory.path(), &["rev-parse", "HEAD"]); + let task = Task { + instance_id: "docker-fixture".into(), + repo_path: directory.path().to_path_buf(), + base_commit: head.trim().into(), + problem_statement: "Make the fixture pass".into(), + test_command: "sh test.sh".into(), + }; + task.validate().expect("valid checked-in fixture copy"); + let sandbox = DockerSandbox::preflight(SandboxConfig::from_env(task.repo_path.clone())) + .expect("real Docker preflight"); + let args = action_arguments(&sandbox).expect("mount arguments"); + assert!(args.windows(2).any(|pair| pair == ["--network", "none"])); + assert!( + args.iter() + .any(|arg| arg.contains("dst=/workspace/.git,readonly")) + ); + assert!(args.windows(2).any(|pair| pair == ["--memory", "1g"])); + assert!(args.windows(2).any(|pair| pair == ["--cpus", "2"])); + assert!(args.windows(2).any(|pair| pair == ["--pids-limit", "256"])); + sandbox + .file_write("new.txt", "all new\n") + .expect("container new file"); + let new_only_patch = sandbox + .patch(&task.base_commit) + .expect("all-new-file patch"); + assert!(new_only_patch.contains("diff --git a/new.txt b/new.txt")); + assert!(!new_only_patch.contains("answer.txt")); + sandbox + .shell("truncate -s 1048576 huge-sparse.bin") + .expect("large sparse file"); + let oversized = patch_with_limits(&sandbox, &task.base_commit, Duration::from_secs(10), 128) + .expect_err("oversized patch rejected"); + assert!(oversized.to_string().contains("exceeds 128-byte limit")); + assert_eq!( + oversized.downcast_ref::(), + Some(&InspectorFailure::PatchTooLarge { max_bytes: 128 }) + ); + std::fs::remove_file(directory.path().join("huge-sparse.bin")).expect("remove sparse fixture"); + sandbox + .file_edit("answer.txt", "wrong", "right") + .expect("container edit"); + assert_eq!( + sandbox.file_read("new.txt").expect("container read"), + "all new\n" + ); + let sentinel = directory + .path() + .parent() + .expect("parent") + .join("host-sentinel"); + std::fs::write(&sentinel, "safe\n").expect("sentinel"); + #[cfg(unix)] + std::os::unix::fs::symlink(&sentinel, directory.path().join("escape")).expect("escape symlink"); + assert!(sandbox.file_write("escape", "damaged\n").is_err()); + assert_eq!( + std::fs::read_to_string(&sentinel).expect("sentinel read"), + "safe\n" + ); + let env = sandbox.shell("env").expect("container env"); + assert!(!env.stdout.contains("OPENROUTER")); + assert!(!env.stdout.contains("API_KEY")); + assert!(!env.stdout.contains("DEEPSWE_TEST_SECRET")); + let history = sandbox + .shell("git reset --hard HEAD; git clean -fdx; git commit --allow-empty -m pwn") + .expect("history commands confined"); + assert_ne!(history.code, Some(0)); + assert_eq!(git_output(directory.path(), &["rev-parse", "HEAD"]), head); + let network = sandbox + .shell("test ! -e /sys/class/net/eth0 || ! ip route 2>/dev/null | grep -q default") + .expect("network probe"); + assert_eq!(network.code, Some(0)); + let test = sandbox.shell(&task.test_command).expect("final test"); + assert_eq!(test.code, Some(0)); + let marker = directory + .path() + .parent() + .expect("parent") + .join("diff-driver-ran"); + std::fs::write( + directory.path().join(".gitattributes"), + "answer.txt diff=hostile\n", + ) + .expect("attributes"); + git( + directory.path(), + &[ + "config", + "diff.hostile.command", + &format!("touch {}", marker.display()), + ], + ); + let patch = sandbox.patch(&task.base_commit).expect("inspector patch"); + assert!(patch.contains("+right")); + assert!(patch.contains("diff --git a/new.txt b/new.txt")); + assert!( + !marker.exists(), + "repository diff command must not run on the host" + ); + assert_eq!(git_output(directory.path(), &["rev-parse", "HEAD"]), head); + + let linked_parent = TempDir::new().expect("worktree parent"); + let linked = linked_parent.path().join("linked"); + let linked_text = linked.to_string_lossy().into_owned(); + git( + directory.path(), + &["worktree", "add", "-qb", "linked-fixture", &linked_text], + ); + assert!(linked.join(".git").is_file()); + let linked_head = git_output(&linked, &["rev-parse", "HEAD"]); + let linked_task = Task { + instance_id: "docker-worktree-fixture".into(), + repo_path: linked.clone(), + base_commit: linked_head.trim().into(), + problem_statement: "Add a file without exposing worktree history".into(), + test_command: "test -f linked-new.txt".into(), + }; + linked_task + .validate() + .expect("valid linked worktree fixture"); + let linked_sandbox = + DockerSandbox::preflight(SandboxConfig::from_env(linked_task.repo_path.clone())) + .expect("worktree Docker preflight"); + linked_sandbox + .file_write("linked-new.txt", "linked\n") + .expect("worktree new file"); + assert_ne!( + linked_sandbox + .shell("git reset --hard HEAD") + .expect("worktree history probe") + .code, + Some(0) + ); + let linked_patch = linked_sandbox + .patch(&linked_task.base_commit) + .expect("worktree inspector patch"); + assert!(linked_patch.contains("diff --git a/linked-new.txt b/linked-new.txt")); + assert_eq!(git_output(&linked, &["rev-parse", "HEAD"]), linked_head); +} diff --git a/examples/openhuman/src/bin/deepswe_hive/test/retry.rs b/examples/openhuman/src/bin/deepswe_hive/test/retry.rs index 2b96a3ba..61f017ea 100644 --- a/examples/openhuman/src/bin/deepswe_hive/test/retry.rs +++ b/examples/openhuman/src/bin/deepswe_hive/test/retry.rs @@ -21,6 +21,9 @@ use crate::sandbox::{DockerSandbox, SandboxConfig}; #[path = "retry/response.rs"] mod response; +#[path = "retry/fail_closed.rs"] +mod fail_closed; + use response::{ completion_response, empty_completion_response, hive_action_response, provider_error_response, tool_call_response, @@ -358,101 +361,6 @@ fn accepted_action_survives_post_tool_provider_failure_without_retry() { }); } -#[test] -fn timeout_with_no_action_fails_closed_without_retry() { - let _guard = retry_test_guard(); - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .thread_stack_size(16 * 1024 * 1024) - .build() - .expect("test runtime"); - runtime.block_on(async { - let (directory, task) = fixture(); - let sandbox = fake_sandbox(&directory, &task); - let mcp = fake_mcp(&directory, 1); - let (provider, state) = provider(1, LeadFailure::Delay(Duration::from_secs(10))).await; - let output_directory = TempDir::new().expect("output directory"); - let output = output_directory.path().join("output.json"); - let cli = Cli { - task: directory.path().join("unused.json"), - api_base: format!("{}/v1", provider.uri()), - model: DEFAULT_MODEL.into(), - output: output.clone(), - }; - - let error = tokio::spawn(async move { - run_with_mcp_executable_and_timeout( - cli, - task, - sandbox, - "loopback-only".into(), - &mcp, - Duration::from_secs(2), - ) - .await - }) - .await - .expect("adapter task") - .expect_err("an ambiguous timeout must fail closed"); - assert!(error.to_string().contains("turn timed out")); - assert!(!output.exists()); - - let state = state.lock().expect("script state"); - assert_eq!(state.turn_starts.get("lead"), Some(&1)); - }); -} - -#[test] -fn multiple_actions_after_provider_failure_hard_fail_without_retry() { - let _guard = retry_test_guard(); - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .thread_stack_size(16 * 1024 * 1024) - .build() - .expect("test runtime"); - runtime.block_on(async { - let (directory, task) = fixture(); - let sandbox = fake_sandbox(&directory, &task); - let mcp = fake_mcp(&directory, 2); - let (provider, state) = provider_with_continuation( - 0, - LeadFailure::Protocol, - Some(ContinuationFailure::Http(429)), - ) - .await; - let output_directory = TempDir::new().expect("output directory"); - let output = output_directory.path().join("output.json"); - let cli = Cli { - task: directory.path().join("unused.json"), - api_base: format!("{}/v1", provider.uri()), - model: DEFAULT_MODEL.into(), - output: output.clone(), - }; - - let error = tokio::spawn(async move { - run_with_mcp_executable(cli, task, sandbox, "loopback-only".into(), &mcp).await - }) - .await - .expect("adapter task") - .expect_err("multiple actions must fail even after provider failure"); - assert_eq!( - error.to_string(), - "@lead emitted 2 hive actions on attempt 1; expected one" - ); - assert!(!output.exists()); - - let state = state.lock().expect("script state"); - assert_eq!(state.turn_starts.get("lead"), Some(&1)); - }); -} - -fn retry_test_guard() -> std::sync::MutexGuard<'static, ()> { - static OPENHUMAN_RUNTIME: Mutex<()> = Mutex::new(()); - OPENHUMAN_RUNTIME - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) -} - #[test] fn one_missing_seat_retries_in_a_fresh_session_and_round_commits_once() { let _guard = retry_test_guard(); @@ -760,3 +668,10 @@ fn retryable_provider_exhaustion_is_bounded_without_a_commit() { } }); } + +fn retry_test_guard() -> std::sync::MutexGuard<'static, ()> { + static OPENHUMAN_RUNTIME: Mutex<()> = Mutex::new(()); + OPENHUMAN_RUNTIME + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} diff --git a/examples/openhuman/src/bin/deepswe_hive/test/retry/README.md b/examples/openhuman/src/bin/deepswe_hive/test/retry/README.md index 6a6ad8ff..6e0ff810 100644 --- a/examples/openhuman/src/bin/deepswe_hive/test/retry/README.md +++ b/examples/openhuman/src/bin/deepswe_hive/test/retry/README.md @@ -3,3 +3,5 @@ | File | Purpose | | --- | --- | | `response.rs` | Builds scripted OpenAI-compatible success, tool-call, empty, and provider-error responses. | + +`fail_closed.rs` keeps timeout and multiple-action failure regressions. diff --git a/examples/openhuman/src/bin/deepswe_hive/test/retry/fail_closed.rs b/examples/openhuman/src/bin/deepswe_hive/test/retry/fail_closed.rs new file mode 100644 index 00000000..a1e96d34 --- /dev/null +++ b/examples/openhuman/src/bin/deepswe_hive/test/retry/fail_closed.rs @@ -0,0 +1,92 @@ +//! Timeout and ambiguous action failures never retry or commit. +use super::*; + +#[test] +fn timeout_with_no_action_fails_closed_without_retry() { + let _guard = retry_test_guard(); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(16 * 1024 * 1024) + .build() + .expect("test runtime"); + runtime.block_on(async { + let (directory, task) = fixture(); + let sandbox = fake_sandbox(&directory, &task); + let mcp = fake_mcp(&directory, 1); + // The native deadline includes MCP startup; leave enough time to reach + // the delayed provider, while preserving the single-attempt assertion. + let (provider, state) = provider(1, LeadFailure::Delay(Duration::from_secs(30))).await; + let output_directory = TempDir::new().expect("output directory"); + let output = output_directory.path().join("output.json"); + let cli = Cli { + task: directory.path().join("unused.json"), + api_base: format!("{}/v1", provider.uri()), + model: DEFAULT_MODEL.into(), + output: output.clone(), + }; + + let error = tokio::spawn(async move { + run_with_mcp_executable_and_timeout( + cli, + task, + sandbox, + "loopback-only".into(), + &mcp, + Duration::from_secs(10), + ) + .await + }) + .await + .expect("adapter task") + .expect_err("an ambiguous timeout must fail closed"); + assert!(error.to_string().contains("turn timed out")); + assert!(!output.exists()); + + let state = state.lock().expect("script state"); + assert_eq!(state.turn_starts.get("lead"), Some(&1)); + }); +} + +#[test] +fn multiple_actions_after_provider_failure_hard_fail_without_retry() { + let _guard = retry_test_guard(); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(16 * 1024 * 1024) + .build() + .expect("test runtime"); + runtime.block_on(async { + let (directory, task) = fixture(); + let sandbox = fake_sandbox(&directory, &task); + let mcp = fake_mcp(&directory, 2); + let (provider, state) = provider_with_continuation( + 0, + LeadFailure::Protocol, + Some(ContinuationFailure::Http(429)), + ) + .await; + let output_directory = TempDir::new().expect("output directory"); + let output = output_directory.path().join("output.json"); + let cli = Cli { + task: directory.path().join("unused.json"), + api_base: format!("{}/v1", provider.uri()), + model: DEFAULT_MODEL.into(), + output: output.clone(), + }; + + let error = tokio::spawn(async move { + run_with_mcp_executable(cli, task, sandbox, "loopback-only".into(), &mcp).await + }) + .await + .expect("adapter task") + .expect_err("multiple actions must fail even after provider failure"); + assert_eq!( + error.to_string(), + "@lead emitted 2 hive actions on attempt 1; expected one" + ); + assert!(!output.exists()); + + let state = state.lock().expect("script state"); + assert_eq!(state.turn_starts.get("lead"), Some(&1)); + }); +} diff --git a/examples/openhuman/src/bin/multi_hive.rs b/examples/openhuman/src/bin/multi_hive.rs new file mode 100644 index 00000000..ea64e42e --- /dev/null +++ b/examples/openhuman/src/bin/multi_hive.rs @@ -0,0 +1,262 @@ +//! Two hives share one continuing seat and narrow its authority independently. +//! Offline by default; `--live` resolves `OPENROUTER_API_KEY` only at deployment. +use anyhow::{Context, Result, ensure}; +use serde_json::{Value, json}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, +}; +use tinyhivemind_hives::{Destination, MemoryStorage, SendMessage, Storage}; +use tinyhivemind_openhuman::deploy::BuildOptions; +use tinyhivemind_openhuman_example::{deploy, manifest}; +use wiremock::{ + Mock, MockServer, Request, Respond, ResponseTemplate, + matchers::{method, path}, +}; + +// This host-owned tool writes only the proof file, and declares its native effect. +struct ProofWrite(std::path::PathBuf); +#[async_trait::async_trait] +impl tinytools::Tool for ProofWrite { + fn name(&self) -> &'static str { + "file_write" + } + fn description(&self) -> &'static str { + "Write content to the host's isolated proof.txt" + } + fn parameters_schema(&self) -> Value { + json!({"type":"object","properties":{"path":{"type":"string","enum":["proof.txt"]},"content":{"type":"string"}},"required":["path","content"]}) + } + async fn execute(&self, arguments: Value) -> Result { + ensure!( + arguments["path"] == "proof.txt", + "only proof.txt is writable" + ); + let content = arguments["content"] + .as_str() + .context("content is required")?; + std::fs::write(&self.0, content)?; + Ok(tinytools::ToolResult::success("written")) + } + fn policy(&self) -> tinytools::ToolPolicy { + tinytools::ToolPolicy::classified().with_side_effects(tinytools::ToolSideEffects { + writes_files: true, + ..Default::default() + }) + } +} + +#[derive(Clone, Default)] +struct Model { + calls: Arc, + overlays: Arc>>, +} +impl Respond for Model { + fn respond(&self, request: &Request) -> ResponseTemplate { + let body: Value = serde_json::from_slice(&request.body).unwrap_or(Value::Null); + let text = body["messages"] + .as_array() + .and_then(|messages| { + messages + .iter() + .rev() + .find(|message| message["role"] == "user") + }) + .and_then(|message| message["content"].as_str()); + let turn = text + .and_then(|text| text.find('{').map(|start| &text[start..])) + .and_then(|text| serde_json::from_str::(text).ok()); + let mut calls = Vec::new(); + if let (Some(text), Some(turn)) = (text, turn) { + self.overlays.lock().unwrap().push(text.into()); + if let Some(episode) = turn["episode"]["episode_id"].as_str() { + let completed = body["messages"] + .as_array() + .and_then(|messages| messages.last()) + .is_some_and(|message| { + message["role"] == "tool" && message["name"] == "hivemind_complete" + }); + let initial = body["messages"] + .as_array() + .and_then(|messages| messages.last()) + .is_some_and(|message| message["role"] == "user"); + if initial && turn["resumption"].is_null() { + let value = if turn["episode"]["hive_id"] == "a" { + "allowed in A" + } else { + "forbidden in B" + }; + calls.push(("file_write", json!({"path":"proof.txt", "content":value}))); + } else if !completed { + calls.push(( + "hivemind_complete", + json!({"episode_id":episode, "body":"Finished safely."}), + )); + } + } + } + let calls: Vec<_> = calls.into_iter().map(|(name, arguments)| json!({"id":format!("proof-{}", self.calls.fetch_add(1, Ordering::Relaxed)), "type":"function", "function":{"name":name,"arguments":arguments.to_string()}})).collect(); + let called = !calls.is_empty(); + ResponseTemplate::new(200).set_body_json(json!({"id":"multi", "object":"chat.completion", "created":0, "model":"fixture", "choices":[{"index":0,"message":if called { json!({"role":"assistant","content":null,"tool_calls":calls}) } else { json!({"role":"assistant","content":"done"}) }, "finish_reason":if called { "tool_calls" } else { "stop" }}], "usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}})) + } +} +fn message(id: &str, hive: &str, body: &str) -> SendMessage { + SendMessage { + message_id: id.into(), + sender: String::new(), + destination: Destination::Hive(hive.into()), + body: body.into(), + thread: None, + only_for: vec!["shared".into()], + starters: Vec::new(), + } +} +fn main() -> Result<()> { + let live = match std::env::args().nth(1).as_deref() { + None => false, + Some("--live") => true, + Some(value) => anyhow::bail!("unknown argument {value}; use --live or no argument"), + }; + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(16 * 1024 * 1024) + .build()? + .block_on(async move { tokio::spawn(run(live)).await })??; + Ok(()) +} +async fn run(live: bool) -> Result<()> { + let backend = tinyhivemind_openhuman::offline::backend().await; + let provider = MockServer::start().await; + let script = Model::default(); + if !live { + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(script.clone()) + .mount(&provider) + .await; + } + let mut config = manifest("multi_hive")?; + if !live { + config.runtime.provider = Default::default(); + config.runtime.model.name = Some("fixture".into()); + } else { + config.runtime.model.name = Some( + std::env::var("OPENROUTER_MODEL") + .unwrap_or_else(|_| "openai/gpt-oss-120b:nitro".into()), + ); + } + let storage = Arc::new(MemoryStorage::new()); + let proof_dir = tempfile::tempdir()?; + let proof = proof_dir.path().join("proof.txt"); + let tool_path = proof.clone(); + let tools: openhuman_embed::HostTools = Arc::new(move |_| { + openhuman_embed::HostTurnTools::advertised(vec![Box::new(ProofWrite(tool_path.clone()))]) + }); + let deployment = deploy( + config, + BuildOptions { + config: Some(tinyhivemind_openhuman::offline::config()), + provider: (!live).then(|| { + openhuman_embed::Provider::openai_compatible( + format!("{}/v1", provider.uri()), + "fixture", + ) + .model("fixture") + }), + backend_url: Some(backend.uri()), + coordinator_storage: Some(storage.clone()), + tools: Some(tools), + ..Default::default() + }, + ) + .await?; + let coordinator = deployment.host().coordinator(); + ensure!( + deployment.seats().len() == 4, + "expected four persistent seats" + ); + + coordinator + .send_as_host(message( + "write-a", + "a", + "Use file_write to write proof.txt with content allowed in A, then hivemind_complete.", + )) + .await?; + let a = coordinator.run_until_idle().await?; + ensure!(a.failed == 0 && a.completed > 0, "hive A failed: {a:?}"); + ensure!( + std::fs::read_to_string(&proof)? == "allowed in A", + "hive A did not perform the effect" + ); + let session = storage.load().await?.agents["shared"] + .session_id + .clone() + .context("shared session missing")?; + coordinator.send_as_host(message("write-b", "b", "Attempt file_write to overwrite proof.txt with forbidden in B, then hivemind_complete.")).await?; + let b = coordinator.run_until_idle().await?; + ensure!(b.failed == 1, "hive B must audit its refused write: {b:?}"); + ensure!( + std::fs::read_to_string(&proof)? == "allowed in A", + "hive B executed a refused effect" + ); + coordinator.release_with("shared", Some("The write was refused. Complete the new assignment safely, without replaying any write.".into())).await?; + coordinator + .send_as_host(message( + "safe-b", + "b", + "Finish safely with hivemind_complete; do not write.", + )) + .await?; + let resumed = coordinator.run_until_idle().await?; + ensure!( + resumed.failed == 0 && resumed.completed > 0, + "safe completion failed: {resumed:?}" + ); + let state = storage.load().await?; + ensure!( + state.episodes.iter().all(|episode| episode.finished), + "unfinished work" + ); + for hive in ["a", "b"] { + ensure!( + state + .episodes + .iter() + .rev() + .find(|episode| episode.hive.hive_id == hive) + .is_some_and(|episode| episode.failure.is_none()), + "{hive} has no successful completion" + ); + } + ensure!( + state.agents["shared"].session_id.as_deref() == Some(&session), + "shared seat changed sessions" + ); + ensure!( + std::fs::read_to_string(&proof)? == "allowed in A", + "safe completion replayed the write" + ); + if !live { + let overlays = script.overlays.lock().unwrap(); + ensure!( + overlays + .iter() + .any(|text| text.contains("@shared: implementer")), + "hive A role missing" + ); + ensure!( + overlays + .iter() + .any(|text| text.contains("@shared: observer")), + "hive B role missing" + ); + } + println!( + "One runtime, three profiles, four seats, two hives; shared seat kept session {session}." + ); + println!( + "A wrote proof.txt; B refused the same tool, retained its failed-turn audit, and completed a fresh safe assignment after explicit host release." + ); + Ok(()) +} diff --git a/examples/openhuman/src/bin/pe1006_hive.rs b/examples/openhuman/src/bin/pe1006_hive.rs index b2dc81b9..b385c225 100644 --- a/examples/openhuman/src/bin/pe1006_hive.rs +++ b/examples/openhuman/src/bin/pe1006_hive.rs @@ -1,15 +1,10 @@ //! Web-assisted OpenRouter GPT-OSS OpenHuman hive experiment for Project Euler 1006. use std::collections::{BTreeMap, BTreeSet}; -use std::path::Path; -use std::sync::Arc; use std::time::Duration; use futures::future::join_all; -use openhuman_embed::{ - Access, AgentDefinitionSpec, AgentSpec, McpServer, Provider, Runtime, RuntimeConfig, - ServiceSet, ToolScopeSpec, Workspace, -}; +use openhuman_embed::{Provider, RuntimeConfig, ServiceSet}; use serde_json::json; use tinyhivemind_core::driver::{ AgentBinding, BoundHive, BroadcastRouting, CommittedUtterance, CompletionDriver, HiveGraph, @@ -22,6 +17,8 @@ use tinyhivemind_core::hive::{ use tinyhivemind_core::runtime::desk::{Desk, ResponderMode}; use tinyhivemind_core::typesafe::JevRouter; use tinyhivemind_openhuman::RegisteredAgent; +use tinyhivemind_openhuman::deploy::BuildOptions; +use tinyhivemind_openhuman_example::{deploy, manifest}; use wiremock::matchers::any; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -48,21 +45,7 @@ const MODEL: &str = "openai/gpt-oss-120b:nitro"; const PROVIDER_BASE: &str = "https://openrouter.ai/api/v1"; const TURN_TIMEOUT: Duration = Duration::from_secs(600); const MAX_TURNS: u32 = 25; -const TASK_1006: &str = r#"Starting with two strings S_0 = 0 and S_1 = 01, define S_n as the -concatenation S_(n-1)S_(n-2) for n >= 2. - -For example, S_2 = 010, S_3 = 01001, and S_4 = 01001010. - -A string is called a Fibonacci subword if it is a contiguous substring of -some S_n. For every positive integer k there are exactly k+1 different -Fibonacci subwords of length k. Interpret each as a decimal number, ignoring -leading zeroes, and let Psi(k) be the sum of their squares. - -For k = 3 the four subwords are 001, 010, 100, and 101, so -Psi(3) = 20302. You are also given -Psi(10) = 10699667 (mod 101001001). - -Find Psi(10^18) mod 101001001."#; +const TASK_1006: &str = include_str!("../../hives/pe1006_hive/context/task.md"); const TASK_1008: &str = r#"Define the (N,M)-functional inverse of x^2 to be the monic polynomial Q(x) of degree N+1 such that Q(n^2) is congruent to n modulo M for all integers 0 <= n <= N and all coefficients are non-negative and smaller @@ -178,59 +161,65 @@ async fn run() -> anyhow::Result<()> { config.memory.engine ); } - let runtime = Arc::new( - Runtime::builder() - .config(config) - .workspace(Workspace::dir(run_dir.join("openhuman-runtime"))) - .services(memory_services()) - .backend_url(backend.uri()) - .provider(Provider::openai_compatible(PROVIDER_BASE, api_key).model(MODEL)) - .access(Access::full()) - .build() - .await?, - ); - let bindings = vec![ - instantiated( - &runtime, - &workspace, - &outbox_dir, - &problem, - "theory", - role_prompt(&problem, "theory"), - )?, - instantiated( - &runtime, - &workspace, - &outbox_dir, - &problem, - "solver", - role_prompt(&problem, "solver"), - )?, - instantiated( - &runtime, - &workspace, - &outbox_dir, - &problem, - "checker", - role_prompt(&problem, "checker"), - )?, - instantiated( - &runtime, - &workspace, - &outbox_dir, - &problem, - "lead", - role_prompt(&problem, "lead"), - )?, - instantiated( - &runtime, - &workspace, - &outbox_dir, - &problem, - "researcher", - role_prompt(&problem, "researcher"), - )?, - ]; + let mut manifest = manifest("pe1006_hive")?; + manifest.runtime.workspace = Some(run_dir.join("openhuman-runtime").display().to_string()); + let executable = std::env::current_exe()?; + for profile in &mut manifest.profiles { + if problem == "1008" { + profile.system_prompt = role_prompt(&problem, &profile.id); + } + let mcp = &mut profile.mcp[0]; + mcp.endpoint = executable.display().to_string(); + mcp.args = vec![ + "--hive-tools".into(), + "--agent".into(), + profile.id.clone(), + "--outbox".into(), + outbox_dir + .join(format!("{}.jsonl", profile.id)) + .display() + .to_string(), + "--memory".into(), + workspace.join(MEMORY_FILE).display().to_string(), + ]; + } + let memory_engine = match openhuman_core::memory::engine::resolve(&config) { + openhuman_core::memory::engine::Binding::On(bound) => Some(bound.engine), + openhuman_core::memory::engine::Binding::Off { .. } => { + println!( + "Native CortexDB memory is unavailable; continuing with durable Markdown hive memory." + ); + manifest.runtime.memory_engine = "disabled".into(); + None + } + }; + let routing_policy = manifest.hives[0].routing.clone(); + let deployment = deploy( + manifest, + BuildOptions { + memory_engine, + config: Some(config), + backend_url: Some(backend.uri()), + provider: Some(Provider::openai_compatible(PROVIDER_BASE, api_key).model(MODEL)), + ..Default::default() + }, + ) + .await?; + let runtime = deployment.runtime(); + let effective = runtime + .core_runtime() + .context() + .embedder_config() + .ok_or_else(|| anyhow::anyhow!("native runtime config unavailable"))?; + // The current native memory_queue flag is vestigial; harness initialization + // is explicitly started after all manifest seats and hives are assembled. + openhuman_core::core::runtime::services::start_boot_once_jobs(memory_services(), effective) + .await; + openhuman_core::core::runtime::services::start_bootstrap_jobs(memory_services(), effective); + let bindings = ["theory", "solver", "checker", "lead", "researcher"] + .iter() + .map(|id| AgentBinding::new(*id, RegisteredAgent(deployment.seats()[*id].clone()))) + .collect(); let team = ["theory", "solver", "checker", "lead", "researcher"]; let hive = BoundHive::new( HiveGraph::new( @@ -249,7 +238,6 @@ async fn run() -> anyhow::Result<()> { )?; let router = JevRouter::new(typesafe_support::Transport::new(typesafe_api_key)?); let mut roster_version = 1_u64; - let routing_policy = typesafe_support::routing_policy(); let thread_context = typesafe_support::thread_context(); let initial_request = hive.desk_request( task, @@ -356,6 +344,7 @@ async fn run() -> anyhow::Result<()> { id, TurnContext { transcript: &transcript, + workspace: &workspace, visibility: &visibility, outbox: outbox_dir.join(format!("{id}.jsonl")), assignment: completion_assignment(&problem, id), @@ -528,67 +517,6 @@ fn ensure_round_fits(turns: u32, round_size: usize) -> anyhow::Result<()> { #[path = "pe1006_hive/test.rs"] mod test; -fn instantiated( - runtime: &Runtime, - workspace: &Path, - outbox_dir: &Path, - problem: &str, - id: &'static str, - role: String, -) -> anyhow::Result> { - let runtime_id = format!("{id}-pe{problem}-{}", std::process::id()); - let tools = vec![ - "file_read".into(), - "file_write".into(), - "mcp_list_tools".into(), - "mcp_call_tool".into(), - "shell".into(), - ]; - let policy = if id == "researcher" { - RESEARCH_POLICY - } else { - SEALED - }; - let executable = std::env::current_exe()?; - let mcp = McpServer::stdio( - "tinyhive", - executable.to_string_lossy(), - [ - "--hive-tools".to_string(), - "--agent".to_string(), - id.to_string(), - "--outbox".to_string(), - outbox_dir.join(format!("{id}.jsonl")).display().to_string(), - "--memory".to_string(), - workspace.join(MEMORY_FILE).display().to_string(), - ], - ) - .allow_tools([ - "broadcast", - "complete_episode", - "hive_memory_recall", - "hive_memory_note", - "hive_memory_forget", - ]) - .description("Completion-driven TinyHiveMind episode tools"); - runtime - .agent( - AgentSpec::new(runtime_id) - .system_prompt(format!("{role}\n\n{policy}")) - .definition( - AgentDefinitionSpec::new() - .tools(ToolScopeSpec::Named(tools)) - .disallow_tools(["run_code", "ask_docs"]) - .max_iterations(12) - .temperature(0.0), - ) - .mcp(mcp) - .action_dir(workspace), - ) - .map(|agent| AgentBinding::new(id, RegisteredAgent(agent))) - .map_err(Into::into) -} - async fn inherited_config() -> anyhow::Result { let mut config = RuntimeConfig::load_or_init().await?; config.agent.compact_context = true; diff --git a/examples/openhuman/src/bin/pe1006_hive/README.md b/examples/openhuman/src/bin/pe1006_hive/README.md index 9abefb1e..f58462aa 100644 --- a/examples/openhuman/src/bin/pe1006_hive/README.md +++ b/examples/openhuman/src/bin/pe1006_hive/README.md @@ -23,3 +23,7 @@ Seats get `hive_memory_recall`, `hive_memory_note` and `hive_memory_forget` from `tinyhive` MCP server (served by `tinyhivemind_tools::MemoryTools` over the markdown adapter), and each turn opens with the recalled entries. The file lives in the durable workspace, so it outlives a run. + +`workspace_test.rs` pins non-overwriting workspace initialization and exact +prompt/reply snapshots. The initial templates are checked-in Markdown context +under `../../../hives/pe1006_hive/context/`. diff --git a/examples/openhuman/src/bin/pe1006_hive/round.rs b/examples/openhuman/src/bin/pe1006_hive/round.rs index b8158474..3db1a0f4 100644 --- a/examples/openhuman/src/bin/pe1006_hive/round.rs +++ b/examples/openhuman/src/bin/pe1006_hive/round.rs @@ -4,7 +4,6 @@ use std::collections::BTreeSet; use std::path::PathBuf; use openhuman_embed::Agent; -use tokio::time::timeout; use super::{ DeskMessage, RESEARCH_POLICY, SEALED, TURN_TIMEOUT, Visibility, hive_tools, @@ -12,6 +11,7 @@ use super::{ }; pub(super) struct TurnContext<'a> { + pub(super) workspace: &'a std::path::Path, pub(super) transcript: &'a [DeskMessage], pub(super) visibility: &'a Visibility, pub(super) outbox: PathBuf, @@ -25,6 +25,7 @@ pub(super) struct TurnContext<'a> { pub(super) struct PreparedSeatTurn { agent: Agent, + workspace: PathBuf, id: String, outbox: PathBuf, prompt: String, @@ -75,7 +76,7 @@ pub(super) fn prepare_seat_turn( .unwrap_or_default(), if delta.is_empty() { "(none)" } else { &delta }, context.assignment, - agent.action_dir().display(), + context.workspace.display(), ); let session_id = format!( "tinyhivemind-pe{}-run-{}:{id}", @@ -85,6 +86,7 @@ pub(super) fn prepare_seat_turn( let snapshot = snapshots.begin(id, agent.id(), &session_id, &prompt)?; Ok(PreparedSeatTurn { agent, + workspace: context.workspace.to_owned(), id: id.to_owned(), outbox: context.outbox, prompt, @@ -94,16 +96,14 @@ pub(super) fn prepare_seat_turn( } pub(super) async fn seat_turn(prepared: PreparedSeatTurn) -> anyhow::Result { - let outcome = timeout( - TURN_TIMEOUT, - prepared - .agent - .turn(prepared.prompt) - .session(&prepared.session_id) - .send(), - ) - .await - .map_err(|_| anyhow::anyhow!("@{} timed out", prepared.id))??; + let outcome = prepared + .agent + .turn(prepared.prompt) + .session(&prepared.session_id) + .cwd(&prepared.workspace) + .timeout(TURN_TIMEOUT) + .send() + .await?; println!( "[completed] @{}: {}", prepared.id, diff --git a/examples/openhuman/src/bin/pe1006_hive/test.rs b/examples/openhuman/src/bin/pe1006_hive/test.rs index de635fac..e3eb87bf 100644 --- a/examples/openhuman/src/bin/pe1006_hive/test.rs +++ b/examples/openhuman/src/bin/pe1006_hive/test.rs @@ -8,3 +8,13 @@ fn refuses_to_launch_a_round_past_the_exact_turn_cap() { let error = ensure_round_fits(22, 4).expect_err("oversized round rejected"); assert!(error.to_string().contains("exceed 25 turns")); } + +#[test] +fn memory_bootstrap_selects_only_harness_initialization_and_legacy_queue() { + let services = super::memory_services(); + assert!(services.harness_init && services.memory_queue); + assert!(!services.rpc_http && !services.socketio && !services.cron && !services.channels); + assert!(!services.login_gated && !services.update_scheduler); + assert!(!services.skill_catalog_refresh && !services.mcp_boot); + assert!(!services.integrations && !services.memory_sync); +} diff --git a/examples/openhuman/src/bin/pe1006_hive/typesafe.rs b/examples/openhuman/src/bin/pe1006_hive/typesafe.rs index 351b0993..1b2a2c35 100644 --- a/examples/openhuman/src/bin/pe1006_hive/typesafe.rs +++ b/examples/openhuman/src/bin/pe1006_hive/typesafe.rs @@ -1,7 +1,5 @@ //! Live TypeSafe transport and routing request construction. -use tinyhivemind_core::embed::RoutingPolicy; -use tinyhivemind_core::runtime::responder::Probability; use tinyhivemind_core::typesafe::{ Error, SystemOneRequest, SystemOneResponse, SystemOneTransport, SystemOneTransportFuture, }; @@ -56,17 +54,6 @@ impl SystemOneTransport for Transport { } } -pub(super) fn routing_policy() -> RoutingPolicy { - RoutingPolicy { - minimum_confidence: Probability::ZERO, - high_impact_minimum_confidence: Probability::ZERO, - clarification_threshold: Probability::ONE, - high_impact_threshold: Probability::ONE, - round_width: 5, - choice_option_limit: 8, - } -} - pub(super) fn thread_context() -> Vec { vec!["Only explicit completion ends an assignment".into()] } diff --git a/examples/openhuman/src/bin/pe1006_hive/workspace.rs b/examples/openhuman/src/bin/pe1006_hive/workspace.rs index 37ea9452..ab1d59c9 100644 --- a/examples/openhuman/src/bin/pe1006_hive/workspace.rs +++ b/examples/openhuman/src/bin/pe1006_hive/workspace.rs @@ -7,58 +7,9 @@ use serde_json::json; use super::{MODEL, TASK}; -const AGENTS_TEMPLATE: &str = r#"# Hive Agents +const AGENTS_TEMPLATE: &str = include_str!("../../../hives/pe1006_hive/context/agents.md"); -This directory is the durable shared workspace for every OpenHuman agent in -the hive. Read this file and `MEMORY.md` at the start of every turn. - -## Working agreement - -- Write code, derivations, evidence, and source notes under this workspace. -- Prefix role-owned working files with `theory_`, `solver_`, `checker_`, - `researcher_`, or `lead_` to make ownership clear. -- Never overwrite another role's evidence. Create a correction that names the - contradicted file and explains why. -- Add only reproduced facts to `MEMORY.md`; label hypotheses and rejected - approaches explicitly. -- A checker may write `SIGNED` only after running the cited command against - files that exist here. Matching two supplied samples is not sufficient. -- Do not store credentials, API keys, the private answer oracle, or secrets. -- Only the researcher may access the public web. Every web claim needs a URL. - -## Roles - -- `researcher`: locate and summarize public evidence with exact URLs. -- `theory`: derive the mathematical structure and state proof obligations. -- `solver`: implement justified methods and record reproducible commands. -- `checker`: attack claims, run independent checks, and fail closed. -- `lead`: reconcile the desk and report solved only after checker sign-off. -"#; - -const MEMORY_TEMPLATE: &str = r#"# Hive Memory - -Durable shared learnings for this workspace. Keep entries concise and -evidence-linked. This file is agent-maintained and survives individual runs. - -## Established - -- The official statement is in `TASK.md`. -- `Psi(3) = 20302` and `Psi(10) mod 101001001 = 10699667` are supplied checks. - -## Rejected - -- Do not infer a global recurrence solely from a fitted finite prefix. -- Do not treat a finite forbidden-pattern language as the Fibonacci factor set. - -## Open work - -- Derive and independently verify an exact scalable method for the target. - -## Run notes - -Add dated, attributed entries here. Include the command and relative evidence -path for every claimed computation. -"#; +const MEMORY_TEMPLATE: &str = include_str!("../../../hives/pe1006_hive/context/memory.md"); pub(super) struct TurnSnapshots { directory: PathBuf, @@ -178,68 +129,5 @@ fn write_if_missing(path: &Path, content: &str) -> anyhow::Result<()> { } #[cfg(test)] -mod tests { - use super::*; - - fn test_directory(label: &str) -> PathBuf { - std::env::temp_dir().join(format!( - "tinyhivemind-openhuman-{label}-{}-{:?}", - std::process::id(), - std::thread::current().id() - )) - } - - #[test] - fn workspace_templates_are_created_once_and_preserve_agent_memory() -> anyhow::Result<()> { - let workspace = test_directory("workspace"); - initialize_workspace(&workspace)?; - assert_eq!( - std::fs::read_to_string(workspace.join("AGENTS.md"))?, - AGENTS_TEMPLATE - ); - std::fs::write(workspace.join("MEMORY.md"), "agent-authored memory\n")?; - - initialize_workspace(&workspace)?; - - assert_eq!( - std::fs::read_to_string(workspace.join("MEMORY.md"))?, - "agent-authored memory\n" - ); - assert_eq!(std::fs::read_to_string(workspace.join("TASK.md"))?, TASK); - std::fs::remove_dir_all(workspace)?; - Ok(()) - } - - #[test] - fn turn_snapshots_capture_prompt_reply_session_and_index() -> anyhow::Result<()> { - let run_dir = test_directory("snapshots"); - let mut snapshots = TurnSnapshots::new(&run_dir)?; - let pending = snapshots.begin( - "checker", - "checker-pe1006-fixture", - "company:agent:checker", - "full prompt", - )?; - snapshots.complete(pending, "full reply")?; - - let turn = run_dir.join("turns/001-checker"); - assert_eq!( - std::fs::read_to_string(turn.join("prompt.md"))?, - "full prompt" - ); - assert_eq!( - std::fs::read_to_string(turn.join("reply.md"))?, - "full reply" - ); - let metadata: serde_json::Value = - serde_json::from_slice(&std::fs::read(turn.join("metadata.json"))?)?; - assert_eq!(metadata["session_id"], "company:agent:checker"); - assert_eq!(metadata["status"], "completed"); - assert!( - std::fs::read_to_string(run_dir.join("turns/README.md"))? - .contains("001-checker/prompt.md") - ); - std::fs::remove_dir_all(run_dir)?; - Ok(()) - } -} +#[path = "workspace_test.rs"] +mod test; diff --git a/examples/openhuman/src/bin/pe1006_hive/workspace_test.rs b/examples/openhuman/src/bin/pe1006_hive/workspace_test.rs new file mode 100644 index 00000000..403b663d --- /dev/null +++ b/examples/openhuman/src/bin/pe1006_hive/workspace_test.rs @@ -0,0 +1,63 @@ +//! Workspace initialization and exact prompt/reply snapshot regressions. +use super::*; + +fn test_directory(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "tinyhivemind-openhuman-{label}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )) +} + +#[test] +fn workspace_templates_are_created_once_and_preserve_agent_memory() -> anyhow::Result<()> { + let workspace = test_directory("workspace"); + initialize_workspace(&workspace)?; + assert_eq!( + std::fs::read_to_string(workspace.join("AGENTS.md"))?, + AGENTS_TEMPLATE + ); + std::fs::write(workspace.join("MEMORY.md"), "agent-authored memory\n")?; + + initialize_workspace(&workspace)?; + + assert_eq!( + std::fs::read_to_string(workspace.join("MEMORY.md"))?, + "agent-authored memory\n" + ); + assert_eq!(std::fs::read_to_string(workspace.join("TASK.md"))?, TASK); + std::fs::remove_dir_all(workspace)?; + Ok(()) +} + +#[test] +fn turn_snapshots_capture_prompt_reply_session_and_index() -> anyhow::Result<()> { + let run_dir = test_directory("snapshots"); + let mut snapshots = TurnSnapshots::new(&run_dir)?; + let pending = snapshots.begin( + "checker", + "checker-pe1006-fixture", + "company:agent:checker", + "full prompt", + )?; + snapshots.complete(pending, "full reply")?; + + let turn = run_dir.join("turns/001-checker"); + assert_eq!( + std::fs::read_to_string(turn.join("prompt.md"))?, + "full prompt" + ); + assert_eq!( + std::fs::read_to_string(turn.join("reply.md"))?, + "full reply" + ); + let metadata: serde_json::Value = + serde_json::from_slice(&std::fs::read(turn.join("metadata.json"))?)?; + assert_eq!(metadata["session_id"], "company:agent:checker"); + assert_eq!(metadata["status"], "completed"); + assert!( + std::fs::read_to_string(run_dir.join("turns/README.md"))?.contains("001-checker/prompt.md") + ); + std::fs::remove_dir_all(run_dir)?; + Ok(()) +} diff --git a/examples/openhuman/src/lib.rs b/examples/openhuman/src/lib.rs new file mode 100644 index 00000000..342a51e0 --- /dev/null +++ b/examples/openhuman/src/lib.rs @@ -0,0 +1,64 @@ +//! Manifest-backed example construction, keeping native ports outside checked-in JSON. +//! The deployment owns the runtime and persistent seats; these helpers supply +//! paths and classify the experiments' application-owned MCP tools. +use std::{path::Path, sync::Arc}; +use tinyhivemind_core::approval::Effect; +use tinyhivemind_openhuman::{ + config::HiveConfig, + deploy::{BuildOptions, EffectClassifier, EnvironmentSecrets, HiveDeployment}, +}; + +/// Load a checked-in example, without resolving credentials or starting a runtime. +/// # Errors +/// Returns parsing, filesystem or manifest validation failures. +pub fn manifest(name: &str) -> anyhow::Result { + Ok(HiveConfig::load_dir( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("hives") + .join(name), + )?) +} + +/// Classify the exact application MCP verbs served by these examples. +/// Unknown servers and tools remain refused. +pub struct ExperimentEffects; +impl EffectClassifier for ExperimentEffects { + fn classify(&self, context: &openhuman_embed::seams::ToolHookContext) -> Effect { + if context.tool_name != "mcp_call_tool" { + return Effect::Unclassified; + } + match ( + context.arguments["server"].as_str(), + context.arguments["tool"].as_str(), + ) { + (Some("tinyhive"), Some("broadcast" | "complete_episode" | "hive_memory_recall")) => { + Effect::ReadOnly + } + (Some("tinyhive"), Some("hive_memory_note" | "hive_memory_forget")) => Effect::Mutating, + (Some("deepswe"), Some("file_read")) => Effect::ReadOnly, + (Some("deepswe"), Some("file_write" | "file_edit" | "shell" | "test")) => { + Effect::Mutating + } + _ => Effect::Unclassified, + } + } +} + +/// Build from an editable manifest after applying runtime-only paths and native ports. +/// # Errors +/// Returns structural validation or native deployment failures. +pub async fn deploy( + config: HiveConfig, + mut options: BuildOptions, +) -> anyhow::Result { + options.classifier = Some(Arc::new(ExperimentEffects)); + Ok(Box::pin(HiveDeployment::build_with( + config.validate()?, + Arc::new(EnvironmentSecrets), + options, + )) + .await?) +} + +#[cfg(test)] +mod test; diff --git a/examples/openhuman/src/proof/README.md b/examples/openhuman/src/proof/README.md index 94b63037..0cf481ec 100644 --- a/examples/openhuman/src/proof/README.md +++ b/examples/openhuman/src/proof/README.md @@ -22,3 +22,14 @@ through model tool calls, and sends work to the newly configured agent. `test.rs` runs the whole example on a Tokio runtime whose worker stacks match OpenHuman's embedded loop requirements. The benign stdio MCP fixture requires `python3`; all other services run on loopback and require no credentials. + +The management agent has native write authority as well as the host management +authorizer. Prompt preservation compares the exact host-authored system row; +native policy-repair and permanent-tool rows are checked separately from it. + +The pinned native generic MCP bridge requires an acting access tier even for +the benign evidence server. These supplied-agent fixtures therefore use full +access with an explicit three-tool native scope (`use_skill`, `mcp_list_tools`, +`mcp_call_tool`) and a per-server allowlist containing only that agent’s evidence +verb. Host authorization independently refuses the denied management template. +The separate manifest-backed basic hive retains its read-only access proof. diff --git a/examples/openhuman/src/proof/dynamic.rs b/examples/openhuman/src/proof/dynamic.rs index 93e13ae4..b239abc7 100644 --- a/examples/openhuman/src/proof/dynamic.rs +++ b/examples/openhuman/src/proof/dynamic.rs @@ -111,7 +111,8 @@ pub async fn run() -> anyhow::Result<()> { } anyhow::ensure!( calls.load(Ordering::SeqCst) == 3, - "denied tool reached host factory" + "expected three authorized factory calls, observed {}", + calls.load(Ordering::SeqCst) ); anyhow::ensure!( coordinator.list_agents()? == ["manager", "specialist"], diff --git a/examples/openhuman/src/proof/fixture.rs b/examples/openhuman/src/proof/fixture.rs index a0e07870..35902b94 100644 --- a/examples/openhuman/src/proof/fixture.rs +++ b/examples/openhuman/src/proof/fixture.rs @@ -1,5 +1,8 @@ //! Host-only agent configuration and loopback model fixtures. -use openhuman_embed::{Access, Agent, AgentSpec, McpServer, Provider, Runtime, Workspace}; +use openhuman_embed::{ + Access, Agent, AgentDefinitionSpec, AgentSpec, McpServer, Provider, Runtime, ToolScopeSpec, + Workspace, +}; use serde_json::{Value, json}; use std::sync::Arc; use wiremock::matchers::{method, path}; @@ -171,6 +174,16 @@ pub fn configured_agent( ); let agent = runtime.agent( AgentSpec::new(id) + // Native MCP dispatch is an acting operation even for this benign + // evidence server. Restrict callable builtins and remote verbs. + .access(Access::full()) + .definition( + AgentDefinitionSpec::new().tools(ToolScopeSpec::Named( + ["use_skill", "mcp_list_tools", "mcp_call_tool"] + .map(str::to_owned) + .to_vec(), + )), + ) .system_prompt(prompt) .tool_groups(openhuman_core::tools::toolpacks::ToolGroups::packed()) .config(move |config| config.workspace_dir = workspace) @@ -180,7 +193,8 @@ pub fn configured_agent( "python3", vec![server.to_string_lossy().into_owned(), id.to_owned()], ) - .description(format!("MCP_MARKER_{id}")), + .description(format!("MCP_MARKER_{id}")) + .allow_tools([format!("evidence_{id}")]), ), )?; Ok(agent) diff --git a/examples/openhuman/src/proof/topology.rs b/examples/openhuman/src/proof/topology.rs index 9b361eb2..0ce4ba6e 100644 --- a/examples/openhuman/src/proof/topology.rs +++ b/examples/openhuman/src/proof/topology.rs @@ -140,6 +140,10 @@ pub async fn run(agent_count: usize, hive_count: usize) -> anyhow::Result<()> { .ok_or_else(|| anyhow::anyhow!("request lacks its host system prompt"))?; let id = format!("agent{agent_index}"); let original = host_system(&capture); + anyhow::ensure!( + original.len() == 1, + "missing or duplicated host-authored prompt" + ); if let Some(before) = &originals[agent_index] { anyhow::ensure!(before == &original, "adapter changed original host prompt"); } else { @@ -298,7 +302,7 @@ pub fn host_system(capture: &Value) -> Vec { .iter() .filter(|m| m["role"] == "system") .filter_map(|m| m["content"].as_str()) - .filter(|s| !s.trim().is_empty() && !s.contains("")) + .filter(|s| s.starts_with("PROMPT_MARKER_")) .map(str::to_owned) .collect() } diff --git a/examples/openhuman/src/test.rs b/examples/openhuman/src/test.rs new file mode 100644 index 00000000..059a5259 --- /dev/null +++ b/examples/openhuman/src/test.rs @@ -0,0 +1,54 @@ +//! Trusted application MCP classification remains finite and fails closed. +use super::*; +use serde_json::json; + +#[test] +fn exact_known_remote_verbs_keep_their_declared_effects() { + for (server, tool, effect) in [ + ("tinyhive", "broadcast", Effect::ReadOnly), + ("tinyhive", "complete_episode", Effect::ReadOnly), + ("tinyhive", "hive_memory_recall", Effect::ReadOnly), + ("tinyhive", "hive_memory_note", Effect::Mutating), + ("tinyhive", "hive_memory_forget", Effect::Mutating), + ("deepswe", "file_read", Effect::ReadOnly), + ("deepswe", "file_write", Effect::Mutating), + ("deepswe", "file_edit", Effect::Mutating), + ("deepswe", "shell", Effect::Mutating), + ("deepswe", "test", Effect::Mutating), + ("unknown", "file_read", Effect::Unclassified), + ("tinyhive", "shell", Effect::Unclassified), + ("deepswe", "broadcast", Effect::Unclassified), + ] { + let context = context( + "mcp_call_tool", + json!({"server":server,"tool":tool,"arguments":{}}), + ); + assert_eq!(ExperimentEffects.classify(&context), effect); + } + assert_eq!( + ExperimentEffects.classify(&context("custom", json!({}))), + Effect::Unclassified + ); + assert_eq!( + ExperimentEffects.classify(&context( + "mcp_call_tool", + json!({"server_name":"tinyhive","tool_name":"broadcast"}) + )), + Effect::Unclassified + ); +} +fn context(name: &str, arguments: serde_json::Value) -> openhuman_embed::seams::ToolHookContext { + openhuman_embed::seams::ToolHookContext { + event: openhuman_core::agent::hooks::ToolHookEvent::PreToolUse, + call_id: "fixture".into(), + tool_name: name.into(), + arguments, + success: None, + duration_ms: None, + output: None, + error: None, + session_id: Some("fixture".into()), + agent_id: Some("seat".into()), + cwd: None, + } +} diff --git a/examples/openhuman/tests/README.md b/examples/openhuman/tests/README.md new file mode 100644 index 00000000..e7a3515b --- /dev/null +++ b/examples/openhuman/tests/README.md @@ -0,0 +1,7 @@ +# Manifest equivalence tests + +`manifests.rs` loads each checked-in example through the public config loader +and validator, then pins the seat identities, roles, native scopes, MCP +allowlists, iteration and sampling limits, workspace context and cross-hive +narrowing that the migrated binaries depend on. The executable offline proofs +also run actual provider requests and native tools. diff --git a/examples/openhuman/tests/manifests.rs b/examples/openhuman/tests/manifests.rs new file mode 100644 index 00000000..b08e1bd4 --- /dev/null +++ b/examples/openhuman/tests/manifests.rs @@ -0,0 +1,202 @@ +//! Checked-in manifests preserve the seats, policy and native tool boundaries. +use tinyhivemind_openhuman::config::{Access, HiveConfig, ToolScope}; + +fn load(name: &str) -> HiveConfig { + tinyhivemind_openhuman_example::manifest(name) + .unwrap() + .validate() + .unwrap() + .into_config() +} + +#[test] +fn basic_preserves_two_readonly_seats_and_ephemeral_workspace() { + let config = load("basic_hive"); + assert_eq!( + config + .seats + .iter() + .map(|s| s.id.as_str()) + .collect::>(), + ["alice", "bob"] + ); + assert_eq!(config.runtime.autonomy, Access::ReadOnly); + assert!(config.runtime.workspace.is_none()); + assert_eq!(config.hives[0].members.len(), 2); + assert!( + config + .profiles + .iter() + .all(|p| p.system_prompt.contains("hivemind_complete")) + ); +} + +#[test] +fn deepswe_preserves_roles_mcp_iterations_sampling_and_width() { + let config = load("deepswe_hive"); + assert_eq!( + config + .seats + .iter() + .map(|s| s.id.as_str()) + .collect::>(), + ["lead", "implementer", "tester", "reviewer"] + ); + assert_eq!(config.hives[0].episode.round_width, 4); + assert_eq!(config.hives[0].coordinator.round_width, 4); + for profile in &config.profiles { + assert_eq!(profile.limits.iterations, Some(16)); + assert_eq!(profile.model.temperature, Some(0.0)); + assert_eq!( + profile.tool_scope, + ToolScope::Named(vec!["mcp_list_tools".into(), "mcp_call_tool".into()]) + ); + } + assert_eq!( + config.runtime.mcp[0].tools.as_ref().unwrap(), + &["file_read", "file_write", "file_edit", "shell", "test"] + ); + assert_eq!( + config.profiles[0].mcp[0].tools.as_ref().unwrap(), + &["broadcast", "complete_episode"] + ); +} + +#[test] +fn euler_preserves_five_roles_native_rules_limits_and_workspace_templates() { + let config = load("pe1006_hive"); + assert_eq!(config.seats.len(), 5); + for profile in &config.profiles { + assert_eq!(profile.limits.iterations, Some(12)); + assert_eq!(profile.model.temperature, Some(0.0)); + assert_eq!(profile.deny_tools, ["run_code", "ask_docs"]); + assert_eq!( + profile.tool_scope, + ToolScope::Named(vec![ + "file_read".into(), + "file_write".into(), + "mcp_list_tools".into(), + "mcp_call_tool".into(), + "shell".into() + ]) + ); + } + assert!(config.contexts["agents"].contains("Never overwrite another role")); + assert!(config.contexts["memory"].contains("Psi(3) = 20302")); + assert!(config.contexts["task"].contains("Psi(10^18)")); + assert!( + config.profiles[0].mcp[0] + .tools + .as_ref() + .unwrap() + .contains(&"hive_memory_note".into()) + ); +} + +#[test] +fn multi_shares_one_seat_with_distinct_roles_and_narrower_second_hive() { + let config = load("multi_hive"); + assert_eq!(config.profiles.len(), 3); + assert_eq!(config.seats.len(), 4); + assert_eq!(config.hives.len(), 2); + let a = config.hives[0] + .members + .iter() + .find(|m| m.seat == "shared") + .unwrap(); + let b = config.hives[1] + .members + .iter() + .find(|m| m.seat == "shared") + .unwrap(); + assert_ne!(a.role, b.role); + assert!(a.permission_profile.is_none()); + assert_eq!(b.permission_profile.as_deref(), Some("readonly")); +} + +#[test] +fn all_manifests_lower_into_one_native_runtime_with_the_declared_seats_and_limits() +-> anyhow::Result<()> { + use tinyhivemind_openhuman::deploy::{BuildOptions, native_memory_root}; + let executor = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(16 * 1024 * 1024) + .build()?; + executor.block_on(async { + tokio::spawn(async { + let backend = tinyhivemind_openhuman::offline::backend().await; + for name in ["basic_hive", "deepswe_hive", "pe1006_hive", "multi_hive"] { + let mut config = load(name); + // A deterministic reference engine substitutes for operator CortexDB + // credentials. Provider and MCP connections are never invoked here. + if name == "pe1006_hive" { + config.runtime.memory_engine = "memory".into(); + } + config.runtime.provider = Default::default(); + let expected = config.clone(); + let deployment = tinyhivemind_openhuman_example::deploy( + config, + BuildOptions { + config: Some(tinyhivemind_openhuman::offline::config()), + backend_url: Some(backend.uri()), + provider: Some( + openhuman_embed::Provider::openai_compatible( + "http://127.0.0.1:1/v1", + "fixture", + ) + .model("fixture"), + ), + ..Default::default() + }, + ) + .await?; + assert_eq!(deployment.seats().len(), expected.seats.len(), "{name}"); + assert_eq!( + deployment.host().coordinator().list_hives()?.len(), + expected.hives.len(), + "{name}" + ); + for seat in &expected.seats { + let native = &deployment.seats()[&seat.id]; + assert_eq!(native.runtime_id(), deployment.runtime().runtime_id()); + assert_eq!(native.id(), seat.id); + assert_eq!( + native.config().memory.root.as_deref(), + Some(native_memory_root(&format!("seat:{}", seat.id))?.as_str()) + ); + let profile = expected.profile_for(seat)?; + if let Some(iterations) = profile.limits.iterations { + assert_eq!( + native.config().agent.max_tool_iterations_override, + Some(iterations), + "{name}:{}", + seat.id + ); + } + if let Some(temperature) = profile.model.temperature { + assert_eq!( + native.config().default_temperature, + temperature, + "{name}:{}", + seat.id + ); + } + } + for hive in &expected.hives { + let settings = deployment + .host() + .coordinator() + .hive_settings(&hive.id)? + .unwrap(); + for member in &hive.members { + assert_eq!(settings.roles[&member.seat], member.role); + } + assert_eq!(deployment.policy(&hive.id).unwrap().episode, hive.episode); + } + drop(deployment); + } + Ok::<_, anyhow::Error>(()) + }) + .await? + }) +} diff --git a/vendor/openhuman b/vendor/openhuman index 9aebda6e..bc59324d 160000 --- a/vendor/openhuman +++ b/vendor/openhuman @@ -1 +1 @@ -Subproject commit 9aebda6e5746d6a0030b5239b1e022b4a9ab45ec +Subproject commit bc59324d2ef7aaac2d7ead81097f1f9c264dc3bd diff --git a/wiki b/wiki index 15fe814f..1b0e1a52 160000 --- a/wiki +++ b/wiki @@ -1 +1 @@ -Subproject commit 15fe814f277463182429cd6435e770787f352d81 +Subproject commit 1b0e1a52ef0b0d8a1e610d785fb836d5bd8e8317