From d2c1973b2d60d9ec341cb6006f1c44e0ee7b5e2e Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Fri, 14 Aug 2026 16:52:48 -0300 Subject: [PATCH 1/2] feat(fp,web,workflow,sandbox-code-runner,scrapling): opt-in guidance injection via configuration entries, hot-applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move each worker's system-prompt usage guidance behind an inject_guidance knob (ON by default) in its builtin-configuration entry: flipping it binds or unbinds the pre-generate hook live, no restart, shrinking agent prompts when the guidance is not wanted. The non-harness half of the token-usage work. The config plumbing all five share lives in the new crates/config-client (iii-sdk range dep, the console-ui precedent): retry ladder with a NOT_FOUND fast-path, seed-only-when-nothing-stored (configuration::register REPLACES the stored value whenever initial_value is supplied, so the pre-check is load-bearing), case-SENSITIVE NOT_FOUND so an absent config plane never reads as "nothing stored yet", serialized reloads with the fetch inside the lock, and a post-bind boot refresh closing the fetch->bind gap; scrapling mirrors the same semantics in Python. All five treat the config path as best-effort at boot — warn and run on defaults rather than taking the worker's real surface off the bus (docs/sops/configuration.md now documents the cosmetic-knob exception). Also in this change: - sandbox-code-runner seeds on-config-change + ui-content into the claims registry (seeded_ids, the code-runner pattern), closing a boot window where a guest register_function could claim a late-registered worker id and abort the process via the SDK's duplicate-id panic. - sandbox-code-runner's dead custom console form is removed (it predated the entry and would have hidden the knob behind stale timeout fields); the console's schema-generated form renders the entry, and the README documents it. - fp ships no injected UI for its one boolean; the fp/ui package, build.rs, and src/ui.rs are gone and the schema form serves the knob. - llm-router resolves composite "provider::model" ids (the console display form) at the choke points: catalog queries retry an exact miss via the split pair with supports delegating to get, and chat/route/count_tokens split known-provider composites before dispatch — metadata and routing can never disagree about the same id. Rebased over the provider-lifecycle hardening (#812): availability checks run against the split pair. - workflow's stamp-reply and inject-guidance hook responses are typed structs (the interface publish gate refuses AnyValue response schemas); inject-guidance also adopts fp's rule of preserving the harness prompt on an empty/drifted base instead of replacing it with guidance alone, and stamp-reply's no-op answers an explicit continue (parsed identically to the old null). - provider-llamacpp, github-copilot, kimi, and openrouter tag their router-ready handlers internal, keeping the default engine::functions::list free of provider plumbing; the four providers' lockfiles are regenerated so the per-worker --locked gates resolve. - rust-security-audit audits every changed lockfile with a full fetch: the old --no-fetch on later iterations made each lockfile after the first fail its yanked lookups against a half-warmed index; the workflow-convention test pinning the old flag is updated, and the audit's first real catch on these lockfiles — quinn-proto RUSTSEC-2026-0185 in the kimi and web locks — is patched by a lock-only bump. - '!::on-config-change' denies for all five workers (web and workflow were missing theirs too) and configuration dependencies in the fp / sandbox-code-runner / workflow / scrapling manifests. --- .../scripts/tests/test_rust_ci_workflows.py | 8 +- .github/workflows/rust-security-audit.yml | 14 +- crates/config-client/Cargo.lock | 1934 +++++++++++++++++ crates/config-client/Cargo.toml | 28 + crates/config-client/src/lib.rs | 358 +++ docs/sops/configuration.md | 12 + fp/Cargo.lock | 13 + fp/Cargo.toml | 5 +- fp/README.md | 19 +- fp/iii-permissions.yaml | 3 + fp/iii.worker.yaml | 3 + fp/src/config.rs | 73 + fp/src/configuration.rs | 76 + fp/src/guidance.rs | 111 +- fp/src/lib.rs | 9 +- fp/src/main.rs | 45 +- iii-permissions.yaml | 9 + llm-router/src/catalog/handlers.rs | 7 +- llm-router/src/catalog/queries.rs | 138 +- llm-router/src/chat/chat.rs | 32 +- llm-router/src/count_tokens.rs | 40 +- llm-router/src/routing.rs | 64 +- llm-router/tests/integration.rs | 74 +- provider-github-copilot/src/register.rs | 5 +- provider-kimi/src/register.rs | 5 +- provider-llamacpp/src/register.rs | 8 +- provider-openrouter/src/register.rs | 5 +- sandbox-code-runner/Cargo.lock | 13 + sandbox-code-runner/Cargo.toml | 3 + sandbox-code-runner/README.md | 12 + sandbox-code-runner/iii.worker.yaml | 3 + sandbox-code-runner/src/configuration.rs | 185 ++ sandbox-code-runner/src/functions/mod.rs | 59 +- sandbox-code-runner/src/lib.rs | 1 + sandbox-code-runner/src/main.rs | 34 +- sandbox-code-runner/src/ui.rs | 5 + sandbox-code-runner/ui/page.tsx | 7 +- .../ui/src/page/ConfigForm.tsx | 146 -- sandbox-code-runner/ui/src/page/index.ts | 7 +- sandbox-code-runner/ui/src/styles/page.css | 60 - scrapling/README.md | 13 +- scrapling/iii-permissions.yaml | 3 + scrapling/iii.worker.yaml | 3 + scrapling/src/configuration.py | 178 ++ scrapling/src/guidance.py | 67 +- scrapling/src/main.py | 31 +- scrapling/tests/test_configuration.py | 140 ++ scrapling/tests/test_guidance.py | 45 +- web/Cargo.lock | 13 + web/Cargo.toml | 3 + web/README.md | 8 +- web/src/config.rs | 17 +- web/src/configuration.rs | 237 +- web/src/main.rs | 58 +- workflow/Cargo.lock | 13 + workflow/Cargo.toml | 3 + workflow/README.md | 15 +- workflow/iii.worker.yaml | 5 +- workflow/src/config.rs | 12 + workflow/src/configuration.rs | 291 +-- workflow/src/functions/inject_guidance.rs | 88 +- workflow/src/functions/stamp_reply.rs | 91 +- workflow/src/main.rs | 33 +- 63 files changed, 4192 insertions(+), 808 deletions(-) create mode 100644 crates/config-client/Cargo.lock create mode 100644 crates/config-client/Cargo.toml create mode 100644 crates/config-client/src/lib.rs create mode 100644 fp/src/config.rs create mode 100644 fp/src/configuration.rs create mode 100644 sandbox-code-runner/src/configuration.rs delete mode 100644 sandbox-code-runner/ui/src/page/ConfigForm.tsx create mode 100644 scrapling/src/configuration.py create mode 100644 scrapling/tests/test_configuration.py diff --git a/.github/scripts/tests/test_rust_ci_workflows.py b/.github/scripts/tests/test_rust_ci_workflows.py index 4ced61217..7a26b1a2a 100644 --- a/.github/scripts/tests/test_rust_ci_workflows.py +++ b/.github/scripts/tests/test_rust_ci_workflows.py @@ -114,4 +114,10 @@ def test_rust_security_audit_is_narrow_on_prs_and_complete_on_schedule() -> None assert install["with"]["tool"] == "cargo-audit@0.22.2" assert "git diff --name-only -z" in run assert "find . -name Cargo.lock" in run - assert "cargo audit --no-fetch --file" in run + assert "cargo audit --file" in run + # Every iteration fetches: the yanked check resolves crates against the + # index entries fetched by its own invocation, so a no-fetch pass only + # sees whatever the first lockfile happened to warm — each additional + # lockfile in a PR then fails its yanked lookups. (The workflow's comment + # may name the flag; only the invocation form is forbidden.) + assert "cargo audit --no-fetch" not in run diff --git a/.github/workflows/rust-security-audit.yml b/.github/workflows/rust-security-audit.yml index e2669b53e..214dcddf0 100644 --- a/.github/workflows/rust-security-audit.yml +++ b/.github/workflows/rust-security-audit.yml @@ -54,15 +54,15 @@ jobs: fi status=0 - first=1 + # No --no-fetch on the later iterations: the yanked check resolves + # crates against the index entries fetched by THIS invocation, so a + # --no-fetch pass only sees whatever the first lockfile happened to + # warm — every additional lockfile in a PR then fails its yanked + # lookups with "No such crate in crates.io index". The repeat + # advisory-db fetch is a fast no-op once the clone is fresh. for lockfile in "${lockfiles[@]}"; do echo "::group::cargo audit --file $lockfile" - if (( first )); then - cargo audit --file "$lockfile" || status=1 - first=0 - else - cargo audit --no-fetch --file "$lockfile" || status=1 - fi + cargo audit --file "$lockfile" || status=1 echo "::endgroup::" done diff --git a/crates/config-client/Cargo.lock b/crates/config-client/Cargo.lock new file mode 100644 index 000000000..81fbe071c --- /dev/null +++ b/crates/config-client/Cargo.lock @@ -0,0 +1,1934 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "iii-config-client" +version = "0.1.0" +dependencies = [ + "iii-sdk", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "iii-helpers" +version = "0.21.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bdc7bbc3abfde934a62cdc5d3045adf52914dfc1ed6c20f8af691fc561dc55" +dependencies = [ + "futures-util", + "opentelemetry", + "opentelemetry-http", + "opentelemetry_sdk", + "reqwest", + "schemars", + "serde", + "serde_json", + "sysinfo", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "iii-sdk" +version = "0.21.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dd563a1d2f55f893d9a433b747f0bf9bc426656413b136b6ed3a699f3c757b2" +dependencies = [ + "async-trait", + "futures-util", + "hostname", + "iii-helpers", + "reqwest", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", + "uuid", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand 0.9.5", + "thiserror", + "tokio", + "tokio-stream", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +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", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[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.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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/config-client/Cargo.toml b/crates/config-client/Cargo.toml new file mode 100644 index 000000000..a476bb910 --- /dev/null +++ b/crates/config-client/Cargo.toml @@ -0,0 +1,28 @@ +[workspace] + +[package] +name = "iii-config-client" +version = "0.1.0" +edition = "2021" +description = "Worker-side client for the builtin configuration worker — safe seeding, NOT_FOUND-aware reads, retrying RPCs, serialized hot-reload, and the knob-following trigger binding slot" +license = "Apache-2.0" +repository = "https://github.com/iii-hq/workers" +# Deliberately unpublished: workers in this repo link it by path +# (iii-config-client = { path = "../crates/config-client" }), the same way +# they link crates/console-ui. +publish = false + +[dependencies] +# Range, not an exact pin — same rationale as crates/console-ui: workers on +# this repo's SDK line carry their own exact pins (0.21.6 for most, 0.21.8 +# for the harness) and cargo must be able to unify them with this crate. +iii-sdk = ">=0.21.6, <0.22" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Must stay on the same schemars major as iii-sdk so the derived schemas line up. +schemars = "0.8" +tokio = { version = "1", features = ["sync", "time"] } +tracing = "0.1" + +[dev-dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/crates/config-client/src/lib.rs b/crates/config-client/src/lib.rs new file mode 100644 index 000000000..4486e81c3 --- /dev/null +++ b/crates/config-client/src/lib.rs @@ -0,0 +1,358 @@ +//! Client plumbing for the builtin `configuration` worker, shared by the +//! workers whose entries carry live console knobs (fp, web, workflow, +//! sandbox-code-runner) so the retry/NOT_FOUND/seeding/reload rules exist +//! once instead of drifting per worker (docs/sops/configuration.md). +//! +//! Split of responsibilities: the worker keeps its config type, schema, +//! parse, and what *applying* a config means; this crate owns how to talk to +//! the configuration worker — and the two rules that are easy to get subtly +//! wrong: +//! +//! - **Seeding**: `configuration::register` REPLACES the stored value +//! whenever `initial_value` is supplied (engine `store.rs` — "Existing +//! entries keep their value unless `initial_value` is supplied"), so a +//! seed or built-in default is installed only when nothing is stored yet. +//! - **Reload serialization**: every reload runs under one lock with the +//! fetch INSIDE it, so overlapping `configuration:updated` deliveries +//! converge on the latest authoritative value instead of racing +//! (docs/sops/configuration.md §6). + +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use iii_sdk::errors::Error; +use iii_sdk::protocol::{RegisterTriggerInput, TriggerRequest}; +use iii_sdk::trigger::Trigger; +use iii_sdk::{IIIClient, RegisterFunction}; +use serde_json::{json, Value}; + +const TIMEOUT_MS: u64 = 5_000; +const RETRIES: u32 = 3; +const RETRY_BACKOFF_MS: u64 = 250; + +/// A worker's configuration entry: the identity + schema half of +/// `configuration::register`. +pub struct EntrySpec { + pub id: &'static str, + pub name: &'static str, + pub description: &'static str, + pub schema: Value, + /// Installed as `initial_value` when nothing is stored yet and no + /// explicit seed is given. + pub default_value: Value, +} + +/// Register the entry's schema (idempotent, safe to call every boot). `seed` +/// (a `--config` value) or the built-in default becomes `initial_value` ONLY +/// when nothing is stored yet — see the module doc for why the pre-check is +/// load-bearing, not an optimization. +pub async fn register( + iii: &IIIClient, + spec: &EntrySpec, + seed: Option, +) -> Result<(), String> { + let mut payload = json!({ + "id": spec.id, + "name": spec.name, + "description": spec.description, + "schema": spec.schema, + }); + if fetch(iii, spec.id).await?.is_none() { + payload["initial_value"] = seed.unwrap_or_else(|| spec.default_value.clone()); + } + trigger_with_retry(iii, "configuration::register", payload).await?; + Ok(()) +} + +/// The stored value, or `None` when nothing is stored yet (a `NOT_FOUND` +/// entry, or a stored explicit `null` — the placeholder an unseeded +/// registration persists). +/// +/// The missing-entry code is the configuration worker's uppercase literal +/// `NOT_FOUND` and the match is deliberately case-SENSITIVE: the engine's +/// missing-FUNCTION code is lowercase `function_not_found`, and a +/// configuration worker that is absent or unroutable must surface as an +/// error, never read as "nothing stored yet". +pub async fn fetch(iii: &IIIClient, id: &str) -> Result, String> { + match trigger_with_retry(iii, "configuration::get", json!({ "id": id })).await { + Ok(resp) => Ok(resp.get("value").cloned().filter(|v| !v.is_null())), + Err(e) if e.contains("NOT_FOUND") => Ok(None), + Err(e) => Err(e), + } +} + +async fn trigger_with_retry( + iii: &IIIClient, + function_id: &str, + payload: Value, +) -> Result { + let mut last_err = String::new(); + for attempt in 1..=RETRIES { + match iii + .trigger(TriggerRequest { + function_id: function_id.to_string(), + payload: payload.clone(), + action: None, + timeout_ms: Some(TIMEOUT_MS), + }) + .await + { + Ok(v) => return Ok(v), + Err(e) => { + last_err = e.to_string(); + // NOT_FOUND is a definitive answer (nothing stored yet, the + // normal first-ever boot), not a transient failure — hand it + // straight to the caller instead of retrying and warning. + if last_err.contains("NOT_FOUND") { + return Err(last_err); + } + if attempt < RETRIES { + tracing::warn!( + function_id, + attempt, + error = %last_err, + "configuration RPC failed; retrying" + ); + tokio::time::sleep(Duration::from_millis( + RETRY_BACKOFF_MS * u64::from(attempt), + )) + .await; + } + } + } + } + Err(format!( + "{function_id} failed after {RETRIES} attempts: {last_err}" + )) +} + +/// Best-effort trigger binding: a transient failure must not brick boot or a +/// reload — it surfaces as a `None` handle (and a warn) and is retried on +/// the next config event. +pub fn try_bind(iii: &IIIClient, input: RegisterTriggerInput) -> Option { + let (trigger_type, function_id) = (input.trigger_type.clone(), input.function_id.clone()); + match iii.register_trigger(input) { + Ok(handle) => { + tracing::info!(trigger_type, function_id, "trigger binding requested"); + Some(handle) + } + Err(e) => { + tracing::warn!(trigger_type, function_id, error = %e, "trigger binding failed"); + None + } + } +} + +/// A live trigger binding that follows a boolean knob (the guidance hooks). +/// The mutex serialises concurrent reconciles; it is sync and never held +/// across an await. +#[derive(Clone, Default)] +pub struct BindingSlot(Arc>>); + +impl BindingSlot { + /// Reconcile the live binding with `enabled`: on → `bind()` once; off → + /// unregister and drop the handle. Idempotent under repeated config + /// events, and a failed bind (`None`) retries on the next event. + pub fn reconcile( + &self, + enabled: bool, + bind: impl FnOnce() -> Option, + on_msg: &str, + off_msg: &str, + ) { + let mut slot = self.0.lock().unwrap_or_else(|p| p.into_inner()); + match (enabled, slot.is_some()) { + (true, false) => { + *slot = bind(); + if slot.is_some() { + tracing::info!("{}", on_msg); + } + } + (false, true) => { + if let Some(handle) = slot.take() { + handle.unregister(); + } + tracing::info!("{}", off_msg); + } + _ => {} + } + } + + /// Whether a binding is currently held (test/introspection helper). + pub fn is_bound(&self) -> bool { + self.0.lock().unwrap_or_else(|p| p.into_inner()).is_some() + } +} + +/// Trigger payload for `::on-config-change`. Advisory only: handlers +/// re-fetch the authoritative value and ignore it, so a direct call can +/// never inject config. +#[derive(Debug, Default, serde::Deserialize, schemars::JsonSchema)] +pub struct OnConfigChangeEvent { + /// Configuration id that changed. + #[serde(default)] + pub id: Option, +} + +/// Ack returned by the internal `::on-config-change` handler. +#[derive(Debug, serde::Serialize, schemars::JsonSchema)] +pub struct OnConfigChangeResponse { + pub ok: bool, +} + +type BoxFut = Pin + Send>>; + +/// A serialized reload: `run` executes the worker's fetch→parse→apply under +/// one shared lock, with the fetch INSIDE it — whichever reload applies +/// later also fetched later, so a slow, older `configuration::get` response +/// can never overwrite a newer state. +#[derive(Clone)] +pub struct Reload { + f: Arc BoxFut + Send + Sync>, + lock: Arc>, +} + +impl Reload { + pub async fn run(&self) { + let _serialized = self.lock.lock().await; + (self.f)().await; + } +} + +/// Register the internal `fn_id` reload handler (typed, `internal`-tagged — +/// keep it out of the callable catalog agents browse, and denied to agents +/// in iii-permissions.yaml) and bind it to `configuration:updated` for +/// `config_id`. Every delivery runs `reload` through the same serialized +/// [`Reload`]. +/// +/// Returns that [`Reload`]: call `.run()` once right after this registration +/// to close the boot gap — an update landing between the boot-time fetch and +/// this binding fired into nothing, and without the extra pass it would stay +/// invisible until the NEXT update or a restart. +pub fn on_change( + iii: &Arc, + config_id: &'static str, + fn_id: &'static str, + description: &'static str, + reload: F, +) -> Result +where + F: Fn() -> Fut + Send + Sync + 'static, + Fut: Future + Send + 'static, +{ + let reload = Reload { + f: Arc::new(move || Box::pin(reload()) as BoxFut), + lock: Arc::new(tokio::sync::Mutex::new(())), + }; + + let for_handler = reload.clone(); + iii.register_function( + fn_id, + RegisterFunction::new_async(move |_event: OnConfigChangeEvent| { + let reload = for_handler.clone(); + async move { + reload.run().await; + Ok::(OnConfigChangeResponse { ok: true }) + } + }) + .description(description) + .metadata(json!({ "internal": true })), + ); + + iii.register_trigger(RegisterTriggerInput { + trigger_type: "configuration".to_string(), + function_id: fn_id.to_string(), + config: json!({ "configuration_id": config_id, "event_types": ["configuration:updated"] }), + metadata: None, + })?; + Ok(reload) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// `IIIClient::new` only builds local state — no network — and + /// `register_trigger` queues locally, so a real `Trigger` handle is + /// available engine-free (the same trick the workers' own tests use). + fn client() -> Arc { + Arc::new(IIIClient::new("ws://127.0.0.1:1")) + } + + fn some_binding(iii: &IIIClient) -> Option { + try_bind( + iii, + RegisterTriggerInput { + trigger_type: "harness::hook::pre-generate".to_string(), + function_id: "test::hook".to_string(), + config: json!({ "on_error": "fail_open" }), + metadata: None, + }, + ) + } + + #[test] + fn binding_slot_reconciles_on_off_and_is_idempotent() { + let iii = client(); + let slot = BindingSlot::default(); + let binds = AtomicUsize::new(0); + + let bind = || { + binds.fetch_add(1, Ordering::SeqCst); + some_binding(&iii) + }; + slot.reconcile(true, bind, "on", "off"); + assert!(slot.is_bound()); + // Repeated `on` events must not re-bind. + slot.reconcile(true, bind, "on", "off"); + assert_eq!(binds.load(Ordering::SeqCst), 1); + + slot.reconcile(false, bind, "on", "off"); + assert!(!slot.is_bound()); + // Repeated `off` events are a no-op too. + slot.reconcile(false, bind, "on", "off"); + assert_eq!(binds.load(Ordering::SeqCst), 1); + + // A failed bind (None) leaves the slot empty so the next event retries. + slot.reconcile(true, || None, "on", "off"); + assert!(!slot.is_bound()); + } + + #[tokio::test] + async fn reload_serializes_and_runs_every_call() { + let ran = Arc::new(AtomicUsize::new(0)); + let counted = ran.clone(); + let iii = client(); + let reload = on_change( + &iii, + "test", + "test::on-config-change", + "test reload", + move || { + let ran = counted.clone(); + async move { + ran.fetch_add(1, Ordering::SeqCst); + } + }, + ) + .expect("registration succeeds engine-free"); + + reload.run().await; + reload.run().await; + assert_eq!(ran.load(Ordering::SeqCst), 2); + } + + /// The event payload is advisory and lenient: `{}`, a full `{id}`, and + /// junk fields must all deserialize (the handler re-fetches anyway). + #[test] + fn on_config_change_event_is_lenient() { + let empty: OnConfigChangeEvent = serde_json::from_value(json!({})).unwrap(); + assert!(empty.id.is_none()); + let full: OnConfigChangeEvent = + serde_json::from_value(json!({ "id": "fp", "extra": 1 })).unwrap(); + assert_eq!(full.id.as_deref(), Some("fp")); + } +} diff --git a/docs/sops/configuration.md b/docs/sops/configuration.md index 3c5a16d4e..0fe489e02 100644 --- a/docs/sops/configuration.md +++ b/docs/sops/configuration.md @@ -191,6 +191,18 @@ Common to both tiers: aborts startup. Bind the configuration trigger **last** so handlers close over fully-built state. +**Exception — cosmetic-knob entries.** Workers whose entry carries only +defaulted tuning/prompt knobs (the `inject_guidance` workers: `fp`, `web`, +`workflow`, `sandbox-code-runner`, `scrapling`) treat the whole config path as +best-effort: register/fetch/bind failures warn and the worker runs on +built-in defaults rather than taking its real function surface off the bus, +recovering on the next configuration event or restart. Their shared plumbing +(retry ladder, case-sensitive `NOT_FOUND` rule, seed-only-when-nothing-stored, +serialized reload with the fetch inside the lock, and the post-bind boot +refresh that closes the fetch→bind gap) lives in +[`crates/config-client`](../../crates/config-client/src/lib.rs) — new +integrations of this shape should link it rather than copying the pattern. + ### d. No shipped `config.yaml` Integrated workers **omit** `config.yaml` from the repo. Defaults live in diff --git a/fp/Cargo.lock b/fp/Cargo.lock index bb3d87053..4a0e9ae8b 100644 --- a/fp/Cargo.lock +++ b/fp/Cargo.lock @@ -304,6 +304,7 @@ version = "0.2.6" dependencies = [ "anyhow", "clap", + "iii-config-client", "iii-sdk", "schemars", "serde", @@ -643,6 +644,18 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "iii-config-client" +version = "0.1.0" +dependencies = [ + "iii-sdk", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "iii-helpers" version = "0.21.6" diff --git a/fp/Cargo.toml b/fp/Cargo.toml index 3cd1c5b4c..40550f1a5 100644 --- a/fp/Cargo.toml +++ b/fp/Cargo.toml @@ -18,9 +18,12 @@ path = "src/lib.rs" [dependencies] iii-sdk = "=0.21.6" +# Shared plumbing for the builtin `configuration` worker (seeding, retries, +# NOT_FOUND semantics, serialized hot-reload, guidance binding slot). +iii-config-client = { path = "../crates/config-client" } # Must stay on the same schemars major as iii-sdk so the derived schemas line up. schemars = "0.8" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time"] } serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" diff --git a/fp/README.md b/fp/README.md index 23dbb9e9f..269846441 100644 --- a/fp/README.md +++ b/fp/README.md @@ -18,12 +18,19 @@ arguments burns its context window and stalls the provider stream mid-call. and its result lands in the next step's payload — so the value flows worker→worker and the chat only ever sees per-step sizes and a preview. -While the worker is connected it also injects a usage section into the agent -system prompt via the harness `pre-generate` hook (`fp::inject-guidance`), -so the guidance is presence-gated: no fp worker, no prompt text. The -binding is one-shot at startup and relies on the engine's recoverable -triggers (iii #1962): bound before the harness is up, it parks as a pending -intent and activates when the harness registers the trigger type. +While connected (and unless turned off) the worker also injects a usage +section into the agent system prompt via the harness `pre-generate` hook +(`fp::inject-guidance`). The knob lives in the builtin `configuration` +worker under the `fp` entry: `inject_guidance` is ON by default; turn it +off in the console's config dialog (or via `configuration::set`) to save +the ~750 tokens per generation (the harness's `# Granted functions` catalog +still advertises the `fp::*` ids) — it hot-applies, the worker binds or +unbinds the hook on the spot, no restart. The console renders the entry with +its schema-generated form. The guidance stays presence-gated either way +(no fp worker, no prompt text). When enabled, the binding relies on the +engine's recoverable triggers (iii #1962): bound before the harness is up, +it parks as a pending intent and activates when the harness registers the +trigger type. ## Functions diff --git a/fp/iii-permissions.yaml b/fp/iii-permissions.yaml index 36b39f407..627c9d737 100644 --- a/fp/iii-permissions.yaml +++ b/fp/iii-permissions.yaml @@ -17,6 +17,9 @@ version: 1 rules: + # Internal hot-reload hook — engine trigger dispatch only, never + # agent-callable (the same pattern as the other on-config-change denies). + - '!fp::on-config-change' - fp::get - fp::pick - fp::omit diff --git a/fp/iii.worker.yaml b/fp/iii.worker.yaml index 85af500e3..e31aeb6cd 100644 --- a/fp/iii.worker.yaml +++ b/fp/iii.worker.yaml @@ -7,3 +7,6 @@ license: Apache-2.0 bin: fp tags: [fp, functional, pipeline, transform, lodash] description: Lodash-style value transforms (fp::get/pick/take/…) and fp::pipe — worker-side pipelines that move big values function→function without routing them through the model. + +dependencies: + configuration: "^0.21.6" diff --git a/fp/src/config.rs b/fp/src/config.rs new file mode 100644 index 000000000..7680460a1 --- /dev/null +++ b/fp/src/config.rs @@ -0,0 +1,73 @@ +//! Config for the fp worker, owned by the builtin `configuration` worker +//! (registered under id `fp`). One knob: whether the `fp::pipe` guidance is +//! injected into agent system prompts. Flip it in the console's config +//! dialog (or via `configuration::set`) — it hot-applies, no restart. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct FpConfig { + /// Append the `fp::pipe` usage guidance (~750 tokens) to every agent + /// system prompt via the harness `pre-generate` hook. On by default; + /// turn it off to shrink prompts (the harness's `# Granted functions` + /// catalog still advertises the `fp::*` ids) or for discovery + /// evaluations, where an advertised worker cannot be discovered — for + /// those, store the off value BEFORE the worker's first boot (seed + /// `./data/configuration/fp.yaml` or call `configuration::set` first), + /// since boot binds the hook before any later flip could land. + pub inject_guidance: bool, +} + +impl Default for FpConfig { + fn default() -> Self { + Self { + inject_guidance: true, + } + } +} + +impl FpConfig { + pub fn json_schema() -> serde_json::Value { + serde_json::to_value(schemars::schema_for!(FpConfig)).expect("FpConfig schema serializes") + } + + /// Parse from the flat JSON object the configuration worker stores; + /// missing keys fall back to defaults (`#[serde(default)]`). + pub fn from_json(v: &serde_json::Value) -> Result { + serde_json::from_value(v.clone()).map_err(|e| format!("invalid fp config: {e}")) + } + + pub fn to_json(&self) -> serde_json::Value { + serde_json::to_value(self).expect("FpConfig serializes") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn injection_defaults_on() { + assert!(FpConfig::default().inject_guidance); + let parsed = FpConfig::from_json(&json!({})).unwrap(); + assert!(parsed.inject_guidance); + } + + #[test] + fn parses_the_stored_flat_shape() { + // `false` is the non-default value, so this parse is discriminating. + let flat = FpConfig::from_json(&json!({ "inject_guidance": false })).unwrap(); + assert!(!flat.inject_guidance); + } + + #[test] + fn round_trips_through_json() { + let cfg = FpConfig { + inject_guidance: false, + }; + assert_eq!(FpConfig::from_json(&cfg.to_json()).unwrap(), cfg); + } +} diff --git a/fp/src/configuration.rs b/fp/src/configuration.rs new file mode 100644 index 000000000..6e94ad241 --- /dev/null +++ b/fp/src/configuration.rs @@ -0,0 +1,76 @@ +//! fp's entry in the builtin `configuration` worker (plumbing shared via +//! `crates/config-client`): register the `FpConfig` schema (+ default seed) +//! at boot, read the authoritative value, and bind a `configuration` +//! trigger so `configuration:updated` re-fetches and applies the change — +//! for fp that means binding or unbinding the `fp::inject-guidance` +//! pre-generate hook at runtime. + +use std::sync::Arc; + +use iii_config_client as config_client; +use iii_sdk::errors::Error; +use iii_sdk::IIIClient; + +use crate::config::FpConfig; +use crate::guidance; + +pub const CONFIG_ID: &str = "fp"; +pub const CONFIG_FN_ID: &str = "fp::on-config-change"; + +fn spec() -> config_client::EntrySpec { + config_client::EntrySpec { + id: CONFIG_ID, + name: "fp", + description: "fp worker settings — whether fp::pipe usage guidance is injected into agent system prompts (on by default).", + schema: FpConfig::json_schema(), + default_value: FpConfig::default().to_json(), + } +} + +pub async fn register_config(iii: &IIIClient) -> Result<(), String> { + config_client::register(iii, &spec(), None).await +} + +pub async fn fetch_config(iii: &IIIClient) -> Result { + match config_client::fetch(iii, CONFIG_ID).await? { + Some(v) => FpConfig::from_json(&v), + None => { + tracing::info!("no configuration value found; using built-in defaults"); + Ok(FpConfig::default()) + } + } +} + +/// Register `fp::on-config-change` and bind it to `configuration:updated` +/// for the `fp` entry. Every delivery re-fetches the authoritative value +/// under the shared reload lock and reconciles the guidance binding; the +/// returned [`config_client::Reload`] lets boot run one extra pass to close +/// the fetch→bind gap. +pub fn register_config_trigger( + iii: &Arc, + state: guidance::GuidanceState, +) -> Result { + let engine = iii.clone(); + config_client::on_change( + iii, + CONFIG_ID, + CONFIG_FN_ID, + "Internal: reload fp settings from the authoritative configuration on change.", + move || { + let engine = engine.clone(); + let state = state.clone(); + async move { + match fetch_config(&engine).await { + Ok(cfg) => { + guidance::apply(&engine, &state, cfg.inject_guidance); + tracing::info!( + inject_guidance = cfg.inject_guidance, + "fp configuration reloaded" + ); + } + Err(e) => tracing::error!(error = %e, "config-change: keeping previous config"), + } + } + }, + ) +} diff --git a/fp/src/guidance.rs b/fp/src/guidance.rs index 4c7ffb32a..73716c4ce 100644 --- a/fp/src/guidance.rs +++ b/fp/src/guidance.rs @@ -1,7 +1,10 @@ //! `fp::inject-guidance` — a `pre_generate` hook that contributes the //! `fp::pipe` / transform usage guidance to the agent's system prompt, -//! ONLY while this worker is connected. The hook is bound at worker startup; -//! binding order does not matter: the engine parks a binding whose trigger +//! ONLY while this worker is connected AND the `fp` configuration's +//! `inject_guidance` is on (the default; see src/configuration.rs — the +//! console config dialog is the flip surface, and flips hot-apply by +//! binding/unbinding the trigger, no restart). Binding order does not +//! matter: the engine parks a binding whose trigger //! type is not registered yet as a pending intent and activates it when the //! type appears ("recoverable triggers", engine/src/trigger.rs — this also //! covers a harness restart, which re-parks and re-activates the binding). @@ -14,11 +17,10 @@ use std::sync::Arc; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::json; use iii_sdk::errors::Error; use iii_sdk::protocol::RegisterTriggerInput; -use iii_sdk::trigger::Trigger; use iii_sdk::{IIIClient, RegisterFunction}; pub const GUIDANCE_HOOK_ID: &str = "fp::inject-guidance"; @@ -162,52 +164,12 @@ async fn handle(event: PreGenerateEvent) -> Result { }) } -/// Best-effort trigger binding: a transient failure must not brick boot — it -/// surfaces as a `None` handle. -fn bind(iii: &IIIClient, trigger_type: &str, function_id: &str, config: Value) -> Option { - match iii.register_trigger(RegisterTriggerInput { - trigger_type: trigger_type.to_string(), - function_id: function_id.to_string(), - config, - metadata: Some(json!({ "inject_prompt": GUIDANCE })), - }) { - Ok(handle) => { - tracing::info!(trigger_type, function_id, "trigger binding requested"); - Some(handle) - } - Err(e) => { - tracing::warn!(trigger_type, function_id, error = %e, "trigger binding failed"); - None - } - } -} - -/// Register the guidance hook function and bind it to the harness -/// pre-generate trigger type. One shot: if the harness is not up yet, the -/// engine parks the binding as a pending intent and activates it when the -/// type registers (recoverable triggers), so there is nothing to watch or -/// retry. `on_error: fail_open` is MANDATORY: pre_generate defaults -/// fail-CLOSED, which would abort generation if this hook ever errored/timed -/// out; a missing guidance section must never block a turn. -/// Skip the guidance hook entirely when `FP_INJECT_GUIDANCE=0`. -/// -/// The hook's whole job is to advertise `fp::*` inside the agent's system -/// prompt. That is right for production and wrong for an evaluation of -/// whether an agent DISCOVERS fp on its own — an advertised worker cannot be -/// discovered. Off by absence: unset means inject, as always. -fn guidance_enabled() -> bool { - guidance_enabled_for(std::env::var("FP_INJECT_GUIDANCE").ok().as_deref()) -} - -fn guidance_enabled_for(value: Option<&str>) -> bool { - !matches!(value, Some("0" | "false" | "off")) -} - -pub fn setup(iii: &Arc) { - if !guidance_enabled() { - tracing::info!("FP_INJECT_GUIDANCE disabled; fp::* stays out of the agent system prompt"); - return; - } +/// Register the guidance hook FUNCTION unconditionally at boot. Registering +/// the function is inert — the guidance reaches prompts only while a trigger +/// binding exists, and [`apply`] owns that binding. Keeping the function +/// always registered is what lets a config flip enable injection without a +/// worker restart. +pub fn register_hook(iii: &Arc) { iii.register_function( GUIDANCE_HOOK_ID, RegisterFunction::new_async( @@ -216,12 +178,38 @@ pub fn setup(iii: &Arc) { .description(GUIDANCE_HOOK_DESC) .metadata(json!({ "internal": true })), ); +} - bind( - iii, - PRE_GENERATE_TRIGGER_TYPE, - GUIDANCE_HOOK_ID, - json!({ "on_error": "fail_open" }), +/// The live pre-generate binding, if any. Shared with the configuration +/// change handler so a config flip can bind/unbind at runtime. +pub type GuidanceState = iii_config_client::BindingSlot; + +/// Reconcile the live binding with the configured `inject_guidance` value: +/// on → bind once; off → unregister and drop the handle. Idempotent under +/// repeated config events, and a failed bind retries on the next event. +/// +/// Binding is one shot: if the harness is not up yet, the engine parks the +/// binding as a pending intent and activates it when the type registers +/// (recoverable triggers), so there is nothing to watch or retry. +/// `on_error: fail_open` is MANDATORY: pre_generate defaults fail-CLOSED, +/// which would abort generation if this hook ever errored/timed out; a +/// missing guidance section must never block a turn. +pub fn apply(iii: &IIIClient, state: &GuidanceState, enabled: bool) { + state.reconcile( + enabled, + || { + iii_config_client::try_bind( + iii, + RegisterTriggerInput { + trigger_type: PRE_GENERATE_TRIGGER_TYPE.to_string(), + function_id: GUIDANCE_HOOK_ID.to_string(), + config: json!({ "on_error": "fail_open" }), + metadata: Some(json!({ "inject_prompt": GUIDANCE })), + }, + ) + }, + "inject_guidance on: appending fp::pipe guidance to agent system prompts", + "inject_guidance off: fp::pipe guidance stays out of agent system prompts", ); } @@ -274,19 +262,6 @@ mod tests { ); } - #[test] - fn guidance_can_be_switched_off_for_discovery_evals() { - // Absence and any other value keep the production behaviour. - assert!(guidance_enabled_for(None)); - assert!(guidance_enabled_for(Some("1"))); - for off in ["0", "false", "off"] { - assert!( - !guidance_enabled_for(Some(off)), - "{off} must disable injection" - ); - } - } - #[test] fn guidance_mandates_present() { // The HARD RULE and its teachable edges must survive edits — these diff --git a/fp/src/lib.rs b/fp/src/lib.rs index 7d547540a..d8a9b4f61 100644 --- a/fp/src/lib.rs +++ b/fp/src/lib.rs @@ -1,10 +1,13 @@ //! fp worker library surface: the ten pure transforms (`util`), the //! trigger-binding guard (`condition`), the -//! worker-side pipeline (`pipe`), and the system-prompt guidance hook -//! (`guidance`). The `fp` binary (src/main.rs) wires these onto the bus; -//! the lib target exists so `tests/` can exercise the public contract. +//! worker-side pipeline (`pipe`), the system-prompt guidance hook +//! (`guidance`), and its `configuration`-worker knob (`config` / +//! `configuration`). The `fp` binary (src/main.rs) wires these onto the +//! bus; the lib target exists so `tests/` can exercise the public contract. pub mod condition; +pub mod config; +pub mod configuration; pub mod guidance; pub mod pipe; pub mod util; diff --git a/fp/src/main.rs b/fp/src/main.rs index dd2a31a10..e39d52cf5 100644 --- a/fp/src/main.rs +++ b/fp/src/main.rs @@ -1,5 +1,6 @@ -//! `fp` binary entry: connect, register the transforms + `fp::pipe`, bind -//! the harness pre-generate guidance hook, then sleep until Ctrl+C. +//! `fp` binary entry: connect, register the transforms + `fp::pipe`, +//! register the `fp` configuration (whose `inject_guidance` knob binds or +//! unbinds the pre-generate guidance hook, hot), then sleep until Ctrl+C. use std::sync::Arc; @@ -9,7 +10,7 @@ use iii_sdk::errors::Error; use iii_sdk::runtime::WorkerMetadata; use iii_sdk::{register_worker, IIIClient, InitOptions, RegisterFunction}; -use fp::{condition, guidance, pipe, util}; +use fp::{condition, configuration, guidance, pipe, util}; #[derive(Parser, Debug)] #[command( @@ -51,9 +52,43 @@ async fn main() -> Result<()> { )); register_functions(&iii); - guidance::setup(&iii); + guidance::register_hook(&iii); - tracing::info!("fp ready: fp::pipe + 18 transforms + guidance injection"); + // The `configuration` worker (an engine builtin, like for web/cron) owns + // the authoritative `fp` config; `inject_guidance` defaults ON and + // hot-applies on change by binding/unbinding the guidance hook. + // + // Best-effort on purpose: the entry carries one cosmetic prompt knob, so + // unlike the full config integrations (docs/sops/configuration.md §4c) a + // configuration-worker failure must not take fp::pipe and the transforms + // off the bus — warn, run on defaults, and recover on the next + // configuration event or restart. + if let Err(e) = configuration::register_config(&iii).await { + tracing::warn!(error = %e, "registering fp configuration schema failed; continuing"); + } + let cfg = match configuration::fetch_config(&iii).await { + Ok(cfg) => cfg, + Err(e) => { + tracing::warn!(error = %e, "loading fp configuration failed; using defaults"); + fp::config::FpConfig::default() + } + }; + let state = guidance::GuidanceState::default(); + guidance::apply(&iii, &state, cfg.inject_guidance); + match configuration::register_config_trigger(&iii, state) { + // One serialized re-fetch to close the boot gap: an update landing + // between the fetch above and the binding just registered fired into + // nothing, and would otherwise stay invisible until the NEXT change. + Ok(reload) => reload.run().await, + Err(e) => { + tracing::warn!(error = %e, "registering configuration change trigger failed; the inject_guidance knob is frozen until restart"); + } + } + + tracing::info!( + inject_guidance = cfg.inject_guidance, + "fp ready: fp::pipe + 18 transforms + configuration hot-reload" + ); tokio::signal::ctrl_c().await?; tracing::info!("fp shutting down"); iii.shutdown_async().await; diff --git a/iii-permissions.yaml b/iii-permissions.yaml index 7b7e3cfaf..bdacdf224 100644 --- a/iii-permissions.yaml +++ b/iii-permissions.yaml @@ -208,6 +208,15 @@ rules: # on-config-change denies. - '!code-runner::on-config-change' + # The guidance-knob configuration entries (web/workflow/fp/ + # sandbox-code-runner/scrapling): internal hot-reload hooks, same pattern + # as the other on-config-change denies. + - '!web::on-config-change' + - '!workflow::on-config-change' + - '!fp::on-config-change' + - '!sandbox-code-runner::on-config-change' + - '!scrapling::on-config-change' + # Read-only / introspection (extend below for your tools). - state::get - state::list diff --git a/llm-router/src/catalog/handlers.rs b/llm-router/src/catalog/handlers.rs index 6fbd9d01d..73186d1a4 100644 --- a/llm-router/src/catalog/handlers.rs +++ b/llm-router/src/catalog/handlers.rs @@ -105,8 +105,11 @@ pub fn make_models_supports( move |req: ModelsSupportsRequest| { let catalog = catalog.clone(); Box::pin(async move { - let supported = - models_supports(&catalog, &req.provider, &req.id, &req.capability).await; + // Same empty-as-unset rule as `get`/`budget`: supports must answer + // for every id `get` resolves, or capability gates silently + // downgrade (harness output contracts fell to `submit_result`). + let provider = (!req.provider.is_empty()).then_some(req.provider.as_str()); + let supported = models_supports(&catalog, provider, &req.id, &req.capability).await; Ok(ModelsSupportsResponse { supported }) }) } diff --git a/llm-router/src/catalog/queries.rs b/llm-router/src/catalog/queries.rs index f286394c4..98c46fb4e 100644 --- a/llm-router/src/catalog/queries.rs +++ b/llm-router/src/catalog/queries.rs @@ -24,7 +24,18 @@ pub async fn models_list( capability: Option<&str>, ) -> Vec { let mut models = match provider { - Some(p) => store.slice(p).await, + Some(p) => { + let slice = store.slice(p).await; + // A composite `provider::model` id handed in where a provider id + // belongs (the same console display form `models_get` retries): + // an empty slice retries as the prefix's slice, so filtering by + // the id the console shows lists that provider instead of + // nothing. Exact first — this only fills an empty listing. + match composite_retry(None, p) { + Some((prefix, _)) if slice.is_empty() => store.slice(prefix).await, + _ => slice, + } + } None => store.all().await, }; if let Some(cap) = capability { @@ -34,6 +45,55 @@ pub async fn models_list( } pub async fn models_get(store: &CatalogStore, provider: Option<&str>, id: &str) -> Option { + if let Some(model) = lookup(store, provider, id).await { + return Some(model); + } + // Composite `provider::model` ids — the console's display form, and so + // what callers copy out of it and hand to `harness::send` — match nothing + // above, because the catalog keys provider and id separately. Retry once + // with the prefix stripped so a composite resolves like the split form. + // Without this the miss is silent: budget returns null, context-manager + // takes its conservative 8k fallback, and a healthy session dies with a + // bewildering `context/overflow` instead of an unknown-model error. + // Exact lookups run first, so this can only turn a None into a Some. + let (prefix, rest) = composite_retry(provider, id)?; + lookup(store, Some(prefix), rest).await +} + +/// The `(provider, id)` pair a composite `provider::model` id should retry as. +/// `None` when the id carries no `::` prefix, when either half is empty, or +/// when the caller named a provider that contradicts the prefix — a +/// contradiction is a caller bug, not something to resolve by guessing. +/// Splits at the FIRST `::` so a model id may itself contain one. +fn composite_retry<'a>(provider: Option<&str>, id: &'a str) -> Option<(&'a str, &'a str)> { + let (prefix, rest) = id.split_once("::")?; + if prefix.is_empty() || rest.is_empty() || provider.is_some_and(|p| p != prefix) { + return None; + } + Some((prefix, rest)) +} + +/// The effective `(provider, model)` pair for a caller-supplied reference: +/// a composite `provider::model` id splits — same rule as [`composite_retry`] +/// — when its prefix names a provider `known` recognizes; anything else +/// passes through untouched, so an id that merely contains `::` keeps meaning +/// itself. Dispatch consumers (routing, chat, count_tokens) resolve through +/// this BEFORE lookup; the catalog queries instead retry AFTER an exact miss, +/// so a literal catalog id containing `::` still wins there. +pub fn effective_model_ref<'a>( + provider: Option<&'a str>, + model: &'a str, + known: impl Fn(&str) -> bool, +) -> (Option<&'a str>, &'a str) { + match composite_retry(provider, model) { + Some((prefix, rest)) if known(prefix) => (Some(prefix), rest), + _ => (provider, model), + } +} + +/// One catalog lookup: exact `(provider, id)` when the provider is known, +/// otherwise the unique-owner match. +async fn lookup(store: &CatalogStore, provider: Option<&str>, id: &str) -> Option { match provider { Some(p) => store.get(p, id).await, // Provider-less lookup: `router::chat` resolves these via routing, but @@ -58,13 +118,18 @@ fn find_by_id(models: Vec, id: &str) -> Option { /// Unknown model → false; request-shaping callers use models::get → null for /// the fail-open cold-window rule (spec § Capability defaults). +/// Resolution delegates to [`models_get`] — composite ids and provider-less +/// lookups included — so `supports` can never disagree with `get` about +/// whether an id exists. (A get/supports split silently downgraded every +/// harness JSON output contract to the `submit_result` fallback: budget +/// resolved the composite, supports reported it unsupported.) pub async fn models_supports( store: &CatalogStore, - provider: &str, + provider: Option<&str>, id: &str, capability: &str, ) -> bool { - match store.get(provider, id).await { + match models_get(store, provider, id).await { Some(m) => model_supports(&m, capability), None => false, } @@ -119,6 +184,73 @@ mod tests { assert_eq!(find_by_id(vec![], "claude-sonnet-4"), None); } + // Composite `provider::model` ids are the console's display form, so they + // are what callers copy into `harness::send`. Before this retry the lookup + // missed silently: budget returned null, context-manager fell to its + // conservative 8k fallback, and a healthy session died with a bewildering + // `context/overflow`. Store-backed resolution is covered in + // tests/integration.rs; the split decision is pure and pinned here. + #[test] + fn composite_ids_retry_as_provider_and_model() { + // No prefix → no retry (the exact lookup already had its chance). + assert_eq!(composite_retry(None, "claude-sonnet-4"), None); + // Composite, provider unknown to the caller → split it. + assert_eq!( + composite_retry(None, "openai-codex::codex/gpt-5.6-luna"), + Some(("openai-codex", "codex/gpt-5.6-luna")) + ); + // Caller's provider agrees with the prefix → still splits. + assert_eq!( + composite_retry(Some("openai-codex"), "openai-codex::codex/gpt-5.6-luna"), + Some(("openai-codex", "codex/gpt-5.6-luna")) + ); + // Caller's provider contradicts the prefix → refuse rather than guess. + assert_eq!( + composite_retry(Some("anthropic"), "openai-codex::codex/gpt-5.6-luna"), + None + ); + // Splits at the FIRST separator, so a model id may contain one. + assert_eq!( + composite_retry(None, "prov::weird::model"), + Some(("prov", "weird::model")) + ); + // Degenerate halves resolve to nothing rather than a bogus lookup. + assert_eq!(composite_retry(None, "::model"), None); + assert_eq!(composite_retry(None, "prov::"), None); + assert_eq!(composite_retry(None, "::"), None); + } + + // Dispatch (routing, chat, count_tokens) resolves composites BEFORE + // lookup, gated on the prefix naming a known provider — the same split + // rule as the catalog queries' retry-after-miss, so metadata and dispatch + // can never disagree about which pair an id means. + #[test] + fn effective_model_ref_splits_only_known_provider_prefixes() { + let known = |p: &str| p == "openai-codex"; + // Known prefix → exactly as if the caller had passed the pair. + assert_eq!( + effective_model_ref(None, "openai-codex::codex/gpt-5.6-luna", known), + (Some("openai-codex"), "codex/gpt-5.6-luna") + ); + // Unknown prefix → untouched: an id may contain `::` without naming a + // provider, and such ids must keep meaning themselves. + assert_eq!( + effective_model_ref(None, "weird::thing", known), + (None, "weird::thing") + ); + // Caller's provider contradicts the prefix → untouched; the caller + // bug stays visible downstream instead of being resolved by guessing. + assert_eq!( + effective_model_ref(Some("anthropic"), "openai-codex::codex/gpt-5.6-luna", known), + (Some("anthropic"), "openai-codex::codex/gpt-5.6-luna") + ); + // Plain ids pass through with the caller's provider intact. + assert_eq!( + effective_model_ref(Some("openai-codex"), "codex/gpt-5.6-luna", known), + (Some("openai-codex"), "codex/gpt-5.6-luna") + ); + } + // Store-backed list/get/supports flows are exercised against a real engine // in tests/integration.rs; the capability mapping is pure and pinned here. #[test] diff --git a/llm-router/src/chat/chat.rs b/llm-router/src/chat/chat.rs index 5b73a2b5d..3e816fdd8 100644 --- a/llm-router/src/chat/chat.rs +++ b/llm-router/src/chat/chat.rs @@ -21,7 +21,7 @@ use serde::Deserialize; use serde_json::{json, Value}; use uuid::Uuid; -use crate::catalog::queries::model_supports; +use crate::catalog::queries::{effective_model_ref, model_supports}; use crate::catalog::store::CatalogStore; use crate::channels::create_router_channel; use crate::config::state::{snapshot, ConfigCell}; @@ -273,9 +273,30 @@ fn provider_call_saturated_response( impl ChatPipeline { pub async fn run( &self, - call: ChatCall, + mut call: ChatCall, sink: Arc, ) -> Result { + // Composite `provider::model` ids — the console's display form — + // resolve in models::get/budget; dispatch must agree. Split once, up + // front, when the prefix names a registered or catalog provider, so + // routing, the structured-output gate, the output budget, and the + // provider payload all see the id the provider actually serves — + // exactly as if the caller had passed the pair. Unknown prefixes stay + // literal: an id may contain `::` without naming a provider. + let registered_providers = self.registry.ids().await; + let catalog_ids = self.catalog.model_ids().await; + { + let (provider, model) = + effective_model_ref(call.provider.as_deref(), &call.model, |p| { + registered_providers.iter().any(|r| r == p) + || catalog_ids.iter().any(|(owner, _)| owner == p) + }); + let (provider, model) = (provider.map(str::to_owned), model.to_owned()); + call.provider = provider; + call.model = model; + } + let call = call; // frozen: nothing below mutates the normalized pair + // A pre-stream failure must still leave exactly one terminal frame on // the sink. Without it, `router::complete`'s drain blocks for its full // reader budget and `router::chat` consumers never see a terminal. @@ -317,16 +338,13 @@ impl ChatPipeline { let candidates = decide(&DecideInput { model: call.model.clone(), provider: call.provider.clone(), - registered_providers: provider_records - .iter() - .map(|record| record.declaration.id.clone()) - .collect(), + registered_providers, available_providers: provider_records .iter() .filter(|record| record.available) .map(|record| record.declaration.id.clone()) .collect(), - catalog: self.catalog.model_ids().await, + catalog: catalog_ids, heuristics: settings.routing_heuristics.clone(), default_provider: settings.default_provider.clone(), }) diff --git a/llm-router/src/count_tokens.rs b/llm-router/src/count_tokens.rs index b54c9b571..3b56a5e9b 100644 --- a/llm-router/src/count_tokens.rs +++ b/llm-router/src/count_tokens.rs @@ -17,6 +17,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::json; +use crate::catalog::queries::effective_model_ref; use crate::catalog::store::CatalogStore; use crate::config::state::{snapshot, ConfigCell}; use crate::registry::store::RegistryStore; @@ -101,25 +102,38 @@ pub fn make_count_tokens( ) .into()); } - // Same inputs, same decide(), same error codes as the chat - // pipeline's routing step. let config = snapshot(&config); let heuristics = config.settings().routing_heuristics.clone(); let default_provider = config.settings().default_provider.clone(); let providers = registry.list().await; + let registered_providers: Vec = providers + .iter() + .map(|record| record.declaration.id.clone()) + .collect(); + let available_providers: Vec = providers + .iter() + .filter(|record| record.available) + .map(|record| record.declaration.id.clone()) + .collect(); + let catalog_ids = catalog.model_ids().await; + // Composite `provider::model` ids split exactly as `router::chat`'s + // entry does, so the provider counts the same split id a chat with + // this request would stream. + let (provider, model) = { + let (p, m) = effective_model_ref(req.provider.as_deref(), &model, |p| { + registered_providers.iter().any(|r| r == p) + || catalog_ids.iter().any(|(owner, _)| owner == p) + }); + (p.map(str::to_owned), m.to_owned()) + }; + // Same inputs, same decide(), same error codes as the chat + // pipeline's routing step. let candidates = decide(&DecideInput { model: model.clone(), - provider: req.provider, - registered_providers: providers - .iter() - .map(|record| record.declaration.id.clone()) - .collect(), - available_providers: providers - .iter() - .filter(|record| record.available) - .map(|record| record.declaration.id.clone()) - .collect(), - catalog: catalog.model_ids().await, + provider, + registered_providers, + available_providers, + catalog: catalog_ids, heuristics, default_provider, }) diff --git a/llm-router/src/routing.rs b/llm-router/src/routing.rs index 015ff1654..1274c9799 100644 --- a/llm-router/src/routing.rs +++ b/llm-router/src/routing.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use futures::future::BoxFuture; use iii_sdk::errors::Error; +use crate::catalog::queries::effective_model_ref; use crate::catalog::store::CatalogStore; use crate::config::state::{snapshot, ConfigCell}; use crate::registry::store::RegistryStore; @@ -36,8 +37,17 @@ pub fn decide(input: &DecideInput) -> Result, RouterError> { let registered = |id: &str| input.registered_providers.iter().any(|p| p == id); let available = |id: &str| input.available_providers.iter().any(|p| p == id); + // 0. Composite `provider::model` ids (the console's display form) resolve + // in models::get/budget — dispatch must agree instead of shipping the + // literal string to the default provider. Split when the prefix names a + // registered or catalog provider, so the composite routes exactly like the + // explicit pair; unknown prefixes stay literal (`::` is legal in an id). + let (provider, model) = effective_model_ref(input.provider.as_deref(), &input.model, |p| { + registered(p) || input.catalog.iter().any(|(owner, _)| owner == p) + }); + // 1. Explicit provider — sole candidate; cold-catalog tolerant; typos loud. - if let Some(provider) = &input.provider { + if let Some(provider) = provider { if !registered(provider) { return Err(RouterError::new( RouterCode::UnknownProvider, @@ -50,7 +60,7 @@ pub fn decide(input: &DecideInput) -> Result, RouterError> { format!("provider {provider} unavailable"), )); } - return Ok(vec![provider.clone()]); + return Ok(vec![provider.to_string()]); } // 2. Unique available catalog owner; 2+ available owners → ambiguous (the @@ -60,7 +70,7 @@ pub fn decide(input: &DecideInput) -> Result, RouterError> { let owners: Vec<&str> = input .catalog .iter() - .filter(|(provider, ids)| registered(provider) && ids.iter().any(|m| m == &input.model)) + .filter(|(provider, ids)| registered(provider) && ids.iter().any(|m| m == model)) .map(|(p, _)| p.as_str()) .collect(); let mut available_owners: Vec<&str> = owners @@ -75,8 +85,7 @@ pub fn decide(input: &DecideInput) -> Result, RouterError> { return Err(RouterError::new( RouterCode::AmbiguousModel, format!( - "ambiguous model {} (providers: {})", - input.model, + "ambiguous model {model} (providers: {})", available_owners.join(", ") ), )) @@ -97,7 +106,7 @@ pub fn decide(input: &DecideInput) -> Result, RouterError> { let Ok(re) = regex::Regex::new(&h.pattern) else { continue; // an invalid operator regex never takes the router down }; - if re.is_match(&input.model) { + if re.is_match(model) { if available(&h.provider) { return Ok(vec![h.provider.clone()]); } @@ -123,8 +132,7 @@ pub fn decide(input: &DecideInput) -> Result, RouterError> { return Err(RouterError::new( RouterCode::ProviderUnavailable, format!( - "no available provider for model {} (unavailable: {})", - input.model, + "no available provider for model {model} (unavailable: {})", unavailable_matches.join(", ") ), )); @@ -133,7 +141,7 @@ pub fn decide(input: &DecideInput) -> Result, RouterError> { // 6. Loud failure. Err(RouterError::new( RouterCode::NoProviderForModel, - format!("no provider registered for model {}", input.model), + format!("no provider registered for model {model}"), )) } @@ -259,6 +267,44 @@ mod tests { assert_eq!(decide(&input).unwrap(), vec!["openai"]); } + // Composite `provider::model` ids (the console's display form) resolve in + // models::get/budget — dispatch must agree. A known prefix routes exactly + // like the explicit pair; a `::` id whose prefix names no provider stays + // literal and behaves as it always did. + #[test] + fn step0_composite_model_ids_route_like_the_explicit_pair() { + let mut input = base(); + // Registered prefix → step 1, even when the split model isn't + // cataloged yet (cold-catalog tolerant, like the explicit pair). + input.model = "anthropic::brand-new".into(); + assert_eq!(decide(&input).unwrap(), vec!["anthropic"]); + // The harness's shape: explicit provider agreeing with the prefix. + input.model = "anthropic::claude-sonnet-4".into(); + input.provider = Some("anthropic".into()); + assert_eq!(decide(&input).unwrap(), vec!["anthropic"]); + // Contradiction → no split; the explicit provider wins, as before. + input.provider = Some("openai".into()); + assert_eq!(decide(&input).unwrap(), vec!["openai"]); + // Non-provider prefix stays literal: no owner, no default → the same + // loud error as before, never a guessed split. + input.provider = None; + input.model = "weird::thing".into(); + assert_eq!( + decide(&input).unwrap_err().code, + RouterCode::NoProviderForModel + ); + // Catalog-owner prefix whose provider lost registration: the loud + // unknown-provider the explicit pair would get — not a silent + // default-provider dispatch of the literal composite. + input.model = "lmstudio::local-llama".into(); + input.default_provider = Some("anthropic".into()); + input.registered_providers.retain(|p| p != "lmstudio"); + assert_eq!( + decide(&input).unwrap_err().code, + RouterCode::UnknownProvider + ); + } + #[test] fn step4_default_provider_makes_routing_total_step5_throws_otherwise() { let mut input = base(); diff --git a/llm-router/tests/integration.rs b/llm-router/tests/integration.rs index 18c177bfa..addd35cc0 100644 --- a/llm-router/tests/integration.rs +++ b/llm-router/tests/integration.rs @@ -406,7 +406,7 @@ async fn start_live_provider(url: &str, opts: ProviderOptions) -> LiveProvider { let token = token_for_refresh.lock().unwrap().clone(); let models: Vec = discovered .iter() - .map(|id| json!({ "id": id, "provider": "real", "context_window": 100000, "max_output_tokens": 8192 })) + .map(|id| json!({ "id": id, "provider": "real", "context_window": 100000, "max_output_tokens": 8192, "supports_vision": true })) .collect(); async move { iii.trigger(TriggerRequest { @@ -619,6 +619,35 @@ async fn route_previews_the_same_provider_chat_executes() { .expect_err("ghost model cannot route"); assert_eq!(remote_code(&err), "router/no_provider_for_model"); + // a composite `provider::model` id (the console's display form) previews + // and executes on the embedded provider, with the split id on the wire — + // dispatch agrees with the models surface about what the id means. + let route = call( + &consumer, + "router::route", + json!({ "model": "real::live-1" }), + ) + .await + .expect("composite id routes"); + assert_eq!(route["provider"], "real"); + let (writer_ref, _frames, pump) = consumer_channel(&consumer).await; + let res = consumer + .trigger(TriggerRequest { + function_id: "router::chat".into(), + payload: json!({ "writer_ref": writer_ref, "model": "real::live-1", "messages": [] }), + action: None, + timeout_ms: Some(30_000), + }) + .await + .expect("composite chat succeeds"); + assert_eq!(res["ok"], true, "chat response: {res}"); + assert_eq!(res["provider"], "real"); + assert_eq!( + res["model"], "live-1", + "the split id is what the provider served" + ); + let _ = tokio::time::timeout(Duration::from_secs(5), pump).await; + consumer.shutdown(); router_iii.shutdown(); } @@ -1015,6 +1044,49 @@ async fn paste_a_key_kicks_debounced_discovery_and_models_land() { .unwrap(); assert_eq!(sup["supported"], false); // flag absent on the discovered model + // Composite `provider::model` ids (the console's display form) resolve + // across the whole models surface, not just get: the same id must never + // resolve in get/budget yet read as unsupported or list nothing. + let got = call( + &router_iii, + "router::models::get", + json!({ "id": "real::disc-1" }), + ) + .await + .unwrap(); + assert_eq!(got["model"]["id"], "disc-1"); + let budget = call( + &router_iii, + "router::models::budget", + json!({ "provider": "real", "id": "real::disc-1" }), + ) + .await + .unwrap(); + assert_eq!(budget["model"]["id"], "disc-1"); + let sup = call( + &router_iii, + "router::models::supports", + json!({ "provider": "real", "id": "real::disc-1", "capability": "vision" }), + ) + .await + .unwrap(); + assert_eq!(sup["supported"], true); // discovered flag — proves resolution + let list = call( + &router_iii, + "router::models::list", + json!({ "provider": "real::disc-1" }), + ) + .await + .unwrap(); + let listed: Vec<&str> = list["models"] + .as_array() + .map(|a| a.iter().filter_map(|m| m["id"].as_str()).collect()) + .unwrap_or_default(); + assert!( + listed.contains(&"disc-1"), + "composite provider filter lists the prefix's slice; have {listed:?}" + ); + router_iii.shutdown(); } diff --git a/provider-github-copilot/src/register.rs b/provider-github-copilot/src/register.rs index 48b3b9214..844ba52d9 100644 --- a/provider-github-copilot/src/register.rs +++ b/provider-github-copilot/src/register.rs @@ -223,7 +223,10 @@ pub async fn register_provider(iii: IIIClient) -> Result<(), Error> { Ok::<_, Error>(ProviderReadyAck { ok: true }) } }) - .description(surface::ON_ROUTER_READY_DESC), + .description(surface::ON_ROUTER_READY_DESC) + // Invoked by id (the router's ready fan-out), never discovered — + // tagged internal like every other provider's ready handler. + .metadata(json!({ "internal": true })), ); } let _ = iii.register_trigger(RegisterTriggerInput { diff --git a/provider-kimi/src/register.rs b/provider-kimi/src/register.rs index 938de6dff..66993ae16 100644 --- a/provider-kimi/src/register.rs +++ b/provider-kimi/src/register.rs @@ -172,7 +172,10 @@ pub async fn register_provider(iii: IIIClient) -> Result<(), Error> { Ok::<_, Error>(ProviderReadyAck { ok: true }) } }) - .description(surface::ON_ROUTER_READY_DESC), + .description(surface::ON_ROUTER_READY_DESC) + // Invoked by id (the router's ready fan-out), never discovered — + // tagged internal like every other provider's ready handler. + .metadata(json!({ "internal": true })), ); } let _ = iii.register_trigger(RegisterTriggerInput { diff --git a/provider-llamacpp/src/register.rs b/provider-llamacpp/src/register.rs index 4c55d7ffd..cebdfbab6 100644 --- a/provider-llamacpp/src/register.rs +++ b/provider-llamacpp/src/register.rs @@ -184,7 +184,13 @@ pub async fn register_provider(iii: IIIClient) -> Result<(), Error> { Ok::<_, Error>(ProviderReadyAck { ok: true }) } }) - .description(surface::ON_ROUTER_READY_DESC), + .description(surface::ON_ROUTER_READY_DESC) + // Invoked by id (the router's ready fan-out and its re-discovery + // nudge), never discovered — same as every other provider's ready + // handler. Untagged, this was the ONE provider handler visible in + // the default `engine::functions::list`, which made a router-side + // provider sweep look like it worked while finding 1 of 4. + .metadata(json!({ "internal": true })), ); } diff --git a/provider-openrouter/src/register.rs b/provider-openrouter/src/register.rs index 2be97b23a..3dee958d8 100644 --- a/provider-openrouter/src/register.rs +++ b/provider-openrouter/src/register.rs @@ -160,7 +160,10 @@ pub async fn register_provider(iii: IIIClient) -> Result<(), Error> { Ok::<_, Error>(ProviderReadyAck { ok: true }) } }) - .description(surface::ON_ROUTER_READY_DESC), + .description(surface::ON_ROUTER_READY_DESC) + // Invoked by id (the router's ready fan-out), never discovered — + // tagged internal like every other provider's ready handler. + .metadata(json!({ "internal": true })), ); } let _ = iii.register_trigger(RegisterTriggerInput { diff --git a/sandbox-code-runner/Cargo.lock b/sandbox-code-runner/Cargo.lock index 887e3bfce..39c715f9b 100644 --- a/sandbox-code-runner/Cargo.lock +++ b/sandbox-code-runner/Cargo.lock @@ -665,6 +665,18 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "iii-config-client" +version = "0.1.0" +dependencies = [ + "iii-sdk", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "iii-console-ui" version = "0.1.0" @@ -1226,6 +1238,7 @@ dependencies = [ "base64", "clap", "futures", + "iii-config-client", "iii-console-ui", "iii-helpers", "iii-sdk", diff --git a/sandbox-code-runner/Cargo.toml b/sandbox-code-runner/Cargo.toml index a2a780ead..3ab5386e6 100644 --- a/sandbox-code-runner/Cargo.toml +++ b/sandbox-code-runner/Cargo.toml @@ -19,6 +19,9 @@ iii-helpers = "=0.21.6" # Worker-side injectable console UI (content function + console:script/style # triggers + hot-reload watcher) — direct link, never published. iii-console-ui = { path = "../crates/console-ui" } +# Shared plumbing for the builtin `configuration` worker (seeding, retries, +# NOT_FOUND semantics, serialized hot-reload, guidance binding slot). +iii-config-client = { path = "../crates/config-client" } tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "sync", "signal", "time"] } futures = "0.3" serde = { version = "1", features = ["derive"] } diff --git a/sandbox-code-runner/README.md b/sandbox-code-runner/README.md index 9a4561db1..e27b4190e 100644 --- a/sandbox-code-runner/README.md +++ b/sandbox-code-runner/README.md @@ -227,6 +227,18 @@ image allowlist are the iii-sandbox daemon's configuration — sandbox-code-runner deliberately duplicates none of them; a daemon-side refusal (e.g. capacity) maps to `sandbox-code-runner::capacity`. +Separate from that file config, the builtin `configuration` worker owns the +`sandbox-code-runner` entry with the worker's one live knob: +`inject_guidance` (ON by default) appends the usage guidance to every agent +system prompt via the harness `pre-generate` hook. Turn it off in the +console's config dialog (the schema-generated form) or via +`configuration::set` to shrink prompts — the harness's `# Granted functions` +catalog still advertises the `sandbox-code-runner::*` ids. It hot-applies: +the worker binds or unbinds the hook on change, no restart. The entry +persists under `./data/configuration/sandbox-code-runner.yaml`, and a +configuration-worker outage at boot is non-fatal (the worker warns and runs +on the defaults). + ## Errors | code | meaning | diff --git a/sandbox-code-runner/iii.worker.yaml b/sandbox-code-runner/iii.worker.yaml index 458929f5d..a3d9f5cfa 100644 --- a/sandbox-code-runner/iii.worker.yaml +++ b/sandbox-code-runner/iii.worker.yaml @@ -7,6 +7,9 @@ bin: sandbox-code-runner tags: [nodejs, python, run, sandbox, microvm] description: Run Node.js and Python in iii-sandbox microVMs — run code, register bus functions from working source, and tear down runtimes on demand. Guest code gets the real iii-sdk client as a global `iii`, lazily connected to the engine. +dependencies: + configuration: "^0.21.6" + # sandbox-code-runner has no V8 (or any other platform-restricted) # dependency — everything it links (iii-sdk, tokio, serde, schemars, clap, # uuid, base64…) builds cleanly on every default triple. No `targets:` diff --git a/sandbox-code-runner/src/configuration.rs b/sandbox-code-runner/src/configuration.rs new file mode 100644 index 000000000..de9e05902 --- /dev/null +++ b/sandbox-code-runner/src/configuration.rs @@ -0,0 +1,185 @@ +//! This worker's entry in the builtin `configuration` worker (plumbing +//! shared via `crates/config-client`): register the schema (+ default seed) +//! at boot, read the authoritative value, and bind a `configuration` +//! trigger so `configuration:updated` re-fetches and applies the change — +//! which here means binding or unbinding the +//! `sandbox-code-runner::inject-guidance` pre-generate hook at runtime. +//! +//! Deliberately separate from [`crate::config`]: that is the operator FILE +//! config (timeouts, idle TTL) loaded once at boot; this entry carries the +//! knobs meant to flip live from the console. + +use std::sync::Arc; + +use iii_config_client as config_client; +use iii_sdk::errors::Error; +use iii_sdk::protocol::RegisterTriggerInput; +use iii_sdk::IIIClient; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::functions::inject_guidance; + +pub const CONFIG_ID: &str = "sandbox-code-runner"; +/// Internal hot-reload hook; denied to agents in iii-permissions.yaml and +/// seeded into the runtime-id registry (`functions::seeded_ids`) so a guest +/// `register_function` cannot claim it. +pub const CONFIG_FN_ID: &str = "sandbox-code-runner::on-config-change"; + +/// The `sandbox-code-runner` configuration entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct RunnerSharedConfig { + /// Append the sandbox-code-runner usage guidance to every agent system + /// prompt via the harness `pre-generate` hook. On by default; turn it + /// off to shrink prompts (the harness's `# Granted functions` catalog + /// still advertises the `sandbox-code-runner::*` ids). Hot-applies — + /// the worker binds or unbinds the hook on change. + pub inject_guidance: bool, +} + +impl Default for RunnerSharedConfig { + fn default() -> Self { + Self { + inject_guidance: true, + } + } +} + +impl RunnerSharedConfig { + pub fn json_schema() -> Value { + serde_json::to_value(schemars::schema_for!(RunnerSharedConfig)) + .expect("RunnerSharedConfig schema serializes") + } + + /// Parse from the flat JSON object the configuration worker stores; + /// missing keys fall back to defaults (`#[serde(default)]`). + pub fn from_json(v: &Value) -> Result { + serde_json::from_value(v.clone()) + .map_err(|e| format!("invalid sandbox-code-runner config: {e}")) + } + + pub fn to_json(&self) -> Value { + serde_json::to_value(self).expect("RunnerSharedConfig serializes") + } +} + +fn spec() -> config_client::EntrySpec { + config_client::EntrySpec { + id: CONFIG_ID, + name: "sandbox-code-runner", + description: "sandbox-code-runner settings — whether its usage guidance is injected into agent system prompts (on by default).", + schema: RunnerSharedConfig::json_schema(), + default_value: RunnerSharedConfig::default().to_json(), + } +} + +pub async fn register_config(iii: &IIIClient) -> Result<(), String> { + config_client::register(iii, &spec(), None).await +} + +pub async fn fetch_config(iii: &IIIClient) -> Result { + match config_client::fetch(iii, CONFIG_ID).await? { + Some(v) => RunnerSharedConfig::from_json(&v), + None => { + tracing::info!("no configuration value found; using built-in defaults"); + Ok(RunnerSharedConfig::default()) + } + } +} + +/// The live pre-generate guidance binding, if any. Shared with the +/// configuration change handler so a config flip can bind/unbind at runtime. +pub type GuidanceState = config_client::BindingSlot; + +/// Reconcile the live guidance binding with the configured `inject_guidance` +/// value: on → bind once; off → unregister and drop the handle. Idempotent +/// under repeated config events, and a failed bind retries on the next event. +/// +/// `on_error: fail_open` is MANDATORY — `pre_generate` defaults to +/// fail-CLOSED, and a missing guidance line must never abort an agent's turn. +pub fn apply_guidance(iii: &IIIClient, state: &GuidanceState, enabled: bool) { + state.reconcile( + enabled, + || { + config_client::try_bind( + iii, + RegisterTriggerInput { + trigger_type: "harness::hook::pre-generate".to_string(), + function_id: inject_guidance::GUIDANCE_HOOK_ID.to_string(), + config: json!({ "on_error": "fail_open" }), + metadata: Some(json!({ + "inject_prompt": inject_guidance::CODE_RUNNER_GUIDANCE + })), + }, + ) + }, + "inject_guidance on: appending sandbox-code-runner guidance to agent system prompts", + "inject_guidance off: sandbox-code-runner guidance stays out of agent system prompts", + ); +} + +/// Register `sandbox-code-runner::on-config-change` and bind it to +/// `configuration:updated` for this worker's entry. Every delivery +/// re-fetches the authoritative value under the shared reload lock and +/// reconciles the guidance binding; the returned [`config_client::Reload`] +/// lets boot run one extra pass to close the fetch→bind gap. +pub fn register_config_trigger( + iii: &Arc, + state: GuidanceState, +) -> Result { + let engine = iii.clone(); + config_client::on_change( + iii, + CONFIG_ID, + CONFIG_FN_ID, + "Internal: reload sandbox-code-runner settings from the authoritative configuration on change.", + move || { + let engine = engine.clone(); + let state = state.clone(); + async move { + match fetch_config(&engine).await { + Ok(cfg) => { + apply_guidance(&engine, &state, cfg.inject_guidance); + tracing::info!( + inject_guidance = cfg.inject_guidance, + "sandbox-code-runner configuration reloaded" + ); + } + Err(e) => tracing::error!(error = %e, "config-change: keeping previous config"), + } + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn injection_defaults_on() { + assert!(RunnerSharedConfig::default().inject_guidance); + assert!( + RunnerSharedConfig::from_json(&json!({})) + .unwrap() + .inject_guidance + ); + } + + #[test] + fn parses_the_stored_flat_shape() { + // `false` is the non-default value, so this parse is discriminating. + let flat = RunnerSharedConfig::from_json(&json!({ "inject_guidance": false })).unwrap(); + assert!(!flat.inject_guidance); + } + + #[test] + fn round_trips_through_json() { + let cfg = RunnerSharedConfig { + inject_guidance: false, + }; + assert_eq!(RunnerSharedConfig::from_json(&cfg.to_json()).unwrap(), cfg); + } +} diff --git a/sandbox-code-runner/src/functions/mod.rs b/sandbox-code-runner/src/functions/mod.rs index d6df3f2d2..6ed24b236 100644 --- a/sandbox-code-runner/src/functions/mod.rs +++ b/sandbox-code-runner/src/functions/mod.rs @@ -84,13 +84,32 @@ pub const STATIC_IDS: &[&str] = &[ inject_guidance::GUIDANCE_HOOK_ID, ]; +/// Every id this worker owns, for seeding the runtime manager's id registry. +/// +/// STRICTLY LARGER than `STATIC_IDS`, which is only what `register_all` +/// registers: the console UI's content function is published later by +/// `crate::ui::register`, and the config reload hook later still by +/// `configuration::register_config_trigger` — after two awaited +/// configuration RPCs, a window in which a guest `register_function` could +/// otherwise claim the id and drive the SDK's duplicate-id panic when the +/// worker's own registration lands (aborting the whole process). Same +/// pattern and reasoning as code-runner's `seeded_ids`. +pub fn seeded_ids() -> Vec<&'static str> { + let mut ids = STATIC_IDS.to_vec(); + ids.push(crate::ui::CONTENT_FUNCTION_ID); + ids.push(crate::configuration::CONFIG_FN_ID); + ids +} + pub fn register_all(iii: &Arc, manager: &Arc) { - // Seed the local claims registry with this worker's own ids BEFORE + // Seed the local claims registry with EVERY id this worker will register + // (`seeded_ids`, not `STATIC_IDS`: the ui-content and on-config-change + // registrations land after guest-facing `register` is live) BEFORE // registering anything, so `RuntimeManager::register`'s reservation // check refuses a caller-supplied `sandbox-code-runner::*` id from the // moment this function starts, rather than depending on the // `engine::functions::info` probe (a network round trip) to catch it. - manager.seed_static_ids(STATIC_IDS); + manager.seed_static_ids(&seeded_ids()); let mut registered: Vec<&str> = Vec::new(); @@ -158,26 +177,6 @@ pub fn register_all(iii: &Arc, manager: &Arc) { tracing::info!("sandbox-code-runner functions registered"); } -/// Bind the `pre_generate` hook so the guidance reaches the agent's system -/// prompt while this worker is connected. `on_error: fail_open` is -/// MANDATORY — `pre_generate` defaults to fail-CLOSED, and a missing -/// guidance line must never abort an agent's turn. -pub fn setup_harness_hooks(iii: &Arc) { - match iii.register_trigger(iii_sdk::protocol::RegisterTriggerInput { - trigger_type: "harness::hook::pre-generate".to_string(), - function_id: inject_guidance::GUIDANCE_HOOK_ID.to_string(), - config: serde_json::json!({ "on_error": "fail_open" }), - metadata: Some(serde_json::json!({ - "inject_prompt": inject_guidance::CODE_RUNNER_GUIDANCE - })), - }) { - Ok(_) => tracing::info!( - "sandbox-code-runner pre-generate hook bound (guidance injection active)" - ), - Err(e) => tracing::warn!(error = %e, "guidance hook binding failed; continuing without it"), - } -} - pub struct FunctionSpec { pub function_id: &'static str, pub description: &'static str, @@ -251,4 +250,20 @@ mod tests { ); register_all(&iii, &manager); } + + /// The ids registered OUTSIDE `register_all` (ui-content by + /// `ui::register`, on-config-change by `register_config_trigger`) must be + /// reserved by the seed all the same: an unseeded late registration is + /// claimable by a guest during the boot window, and the claim ends in the + /// SDK's duplicate-id process abort when the worker's own registration + /// lands. + #[test] + fn seeded_ids_cover_the_late_registrations() { + let ids = seeded_ids(); + for id in STATIC_IDS { + assert!(ids.contains(id), "seed lost a static id: {id}"); + } + assert!(ids.contains(&crate::ui::CONTENT_FUNCTION_ID)); + assert!(ids.contains(&crate::configuration::CONFIG_FN_ID)); + } } diff --git a/sandbox-code-runner/src/lib.rs b/sandbox-code-runner/src/lib.rs index 84a3ade62..213e10c32 100644 --- a/sandbox-code-runner/src/lib.rs +++ b/sandbox-code-runner/src/lib.rs @@ -5,6 +5,7 @@ //! delegated over the bus to the iii-sandbox daemon's `sandbox::*` triggers. pub mod config; +pub mod configuration; pub mod engine; pub mod error; pub mod events; diff --git a/sandbox-code-runner/src/main.rs b/sandbox-code-runner/src/main.rs index d55b95d25..c4fe75438 100644 --- a/sandbox-code-runner/src/main.rs +++ b/sandbox-code-runner/src/main.rs @@ -8,7 +8,7 @@ use iii_sdk::{register_worker, InitOptions}; use sandbox_code_runner::engine::{Engine as _, IIIEngine}; use sandbox_code_runner::error::{classify_probe_error, ProbeOutcome}; use sandbox_code_runner::manager::RuntimeManager; -use sandbox_code_runner::{config, events, functions, manifest}; +use sandbox_code_runner::{config, configuration, events, functions, manifest}; #[derive(Parser, Debug)] #[command( @@ -87,10 +87,40 @@ async fn main() -> Result<()> { manager.set_events(emitter.clone()); sandbox_code_runner::fleet_watch::spawn(engine.clone(), emitter); functions::register_all(&iii, &manager); - functions::setup_harness_hooks(&iii); // Injected console UI: the function-trigger cards for the ops above. sandbox_code_runner::ui::register(&iii); + // The builtin `configuration` worker owns this worker's console-facing + // knobs; `inject_guidance` defaults on and hot-applies on change by + // binding/unbinding the pre-generate guidance hook. + // + // Best-effort on purpose: the entry carries one cosmetic prompt knob, so + // unlike the full config integrations (docs/sops/configuration.md §4c) a + // configuration-worker failure must not take the run/register surface off + // the bus — warn, run on defaults, and recover on the next configuration + // event or restart. + if let Err(e) = configuration::register_config(&iii).await { + tracing::warn!(error = %e, "registering sandbox-code-runner configuration schema failed; continuing"); + } + let shared_cfg = match configuration::fetch_config(&iii).await { + Ok(cfg) => cfg, + Err(e) => { + tracing::warn!(error = %e, "loading sandbox-code-runner configuration failed; using defaults"); + configuration::RunnerSharedConfig::default() + } + }; + let guidance = configuration::GuidanceState::default(); + configuration::apply_guidance(&iii, &guidance, shared_cfg.inject_guidance); + match configuration::register_config_trigger(&iii, guidance) { + // One serialized re-fetch to close the boot gap: an update landing + // between the fetch above and the binding just registered fired into + // nothing, and would otherwise stay invisible until the NEXT change. + Ok(reload) => reload.run().await, + Err(e) => { + tracing::warn!(error = %e, "registering configuration change trigger failed; the inject_guidance knob is frozen until restart"); + } + } + // Startup probe: is the iii-sandbox daemon serving? Fail OPEN — the // operator may add it later, and every call meanwhile errors with the // daemon's own message — but say it loudly once, at boot, instead of diff --git a/sandbox-code-runner/src/ui.rs b/sandbox-code-runner/src/ui.rs index 354d44b8e..e14d8e191 100644 --- a/sandbox-code-runner/src/ui.rs +++ b/sandbox-code-runner/src/ui.rs @@ -34,6 +34,11 @@ use iii_sdk::IIIClient; pub const PAGE_PATH: &str = "sandbox-code-runner/page.js"; pub const STYLES_PATH: &str = "sandbox-code-runner/styles.css"; +/// The console content function `iii-console-ui` registers for this worker +/// (its `::ui-content` default). Named here so +/// `functions::seeded_ids` can reserve it in the runtime-id registry before +/// any guest-facing surface is live. +pub const CONTENT_FUNCTION_ID: &str = "sandbox-code-runner::ui-content"; /// Built by `build.rs` (esbuild over `ui/`). const PAGE_JS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/page.js")); diff --git a/sandbox-code-runner/ui/page.tsx b/sandbox-code-runner/ui/page.tsx index 8aca2f22d..d8151ea93 100644 --- a/sandbox-code-runner/ui/page.tsx +++ b/sandbox-code-runner/ui/page.tsx @@ -12,8 +12,8 @@ * teardown) * - src/sandbox-family/ — the sandbox::* daemon family cards * (formerly first-party in the console) - * - src/page/ — the sandbox fleet page (#/ext/sandbox), - * the worker config form, and the session chip + * - src/page/ — the sandbox fleet page (#/ext/sandbox) + * and the session chip * - src/lib/shared.tsx — the frame the op cards share * * Registrations go through `host` so the loader disposes them on hot reload / @@ -23,7 +23,7 @@ import type { Host } from '@iii-dev/console-ui' import { createSandboxCodeRunnerRenderers } from './src/function-trigger-message' -import { SandboxConfigForm, SandboxPage, createSandboxSessionChip } from './src/page' +import { SandboxPage, createSandboxSessionChip } from './src/page' import { createSandboxFamilyRenderer } from './src/sandbox-family' export default function setup(host: Host) { @@ -37,7 +37,6 @@ export default function setup(host: Host) { title: 'sandbox', render: (props) => , }), - host.configForms.register('sandbox-code-runner', SandboxConfigForm), host.chat?.registerSessionChip(createSandboxSessionChip(host)), ].filter((remove) => remove !== undefined) diff --git a/sandbox-code-runner/ui/src/page/ConfigForm.tsx b/sandbox-code-runner/ui/src/page/ConfigForm.tsx deleted file mode 100644 index 9b9379a8c..000000000 --- a/sandbox-code-runner/ui/src/page/ConfigForm.tsx +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Custom configuration form for the `sandbox-code-runner` configuration - * entry — registered through `host.configForms`, replacing the console's - * generic schema-driven form for this worker only. Mirrors the llm-router - * form's contract: the form edits the working draft via `onChange`; dirty - * tracking, save/reset, validation and the SaveBar stay host-owned. - * - * Three knobs (config.rs): default_timeout_ms (exec deadline when a - * request carries none), max_timeout_ms (requests clamp to this), - * idle_ttl_secs (sent to sandbox::create as idle_timeout_secs; the worker - * floors it at 30s). Empty input = "use the default" — the key is deleted - * from the entry, never written as 0. - */ - -import { type ConfigFormProps, type JsonValue } from '@iii-dev/console-ui' -import { useEffect, useRef } from 'react' -import { formatMs, formatSecs } from './format' - -type JsonObject = { [key: string]: JsonValue } - -function asObject(v: JsonValue | undefined): JsonObject { - return v && typeof v === 'object' && !Array.isArray(v) ? { ...v } : {} -} - -interface Field { - key: string - label: string - defaultValue: number - echo: (n: number) => string - hint?: string -} - -const FIELDS: Field[] = [ - { - key: 'default_timeout_ms', - label: 'default timeout (ms)', - defaultValue: 5_000, - echo: formatMs, - hint: 'used when a run/exec request carries no timeout_ms', - }, - { - key: 'max_timeout_ms', - label: 'max timeout (ms)', - defaultValue: 30_000, - echo: formatMs, - hint: 'requested timeouts clamp to this ceiling', - }, - { - key: 'idle_ttl_secs', - label: 'idle ttl (secs)', - defaultValue: 900, - echo: formatSecs, - hint: 'idle_timeout_secs on sandbox::create — the worker floors it at 30s', - }, -] - -export function SandboxConfigForm(props: ConfigFormProps) { - const value = asObject(props.value) - - const defaultTimeout = Number(value.default_timeout_ms ?? 5_000) - const maxTimeout = Number(value.max_timeout_ms ?? 30_000) - const defaultOverMax = - Number.isFinite(defaultTimeout) && - Number.isFinite(maxTimeout) && - defaultTimeout > maxTimeout - - // Deep-link focus (#/workers/configuration/sandbox-code-runner/): - // the host only scroll-focuses the generic form's DOM ids, so honoring - // the request is this override's job. - const rootRef = useRef(null) - useEffect(() => { - const field = props.focusField?.[0] - if (!field || !rootRef.current) return - const el = rootRef.current.querySelector( - `[data-field="${CSS.escape(field)}"]`, - ) - el?.scrollIntoView({ block: 'center' }) - el?.focus() - }, [props.focusField]) - - return ( -
- - custom form · shipped by the sandbox-code-runner worker - - -
- {FIELDS.map((field) => { - const set = typeof value[field.key] === 'number' - const effective = set ? (value[field.key] as number) : field.defaultValue - return ( -
- - { - const next = { ...value } - const raw = e.target.value.trim() - const n = Number(raw) - if (raw === '' || !Number.isFinite(n) || n <= 0) { - delete next[field.key] - } else { - next[field.key] = n - } - props.onChange(next) - }} - /> -
- {set - ? `= ${field.echo(effective)}` - : `default · ${field.echo(effective)}`} -
- {field.hint ? ( -
{field.hint}
- ) : null} -
- ) - })} -
- - {defaultOverMax ? ( -
- default timeout exceeds max — every default-timeout run will be - clamped down to {formatMs(maxTimeout)}. -
- ) : null} - - {props.errors && props.errors.size > 0 ? ( -
- {[...props.errors].map(([path, message]) => ( -
- {path} {message} -
- ))} -
- ) : null} -
- ) -} diff --git a/sandbox-code-runner/ui/src/page/index.ts b/sandbox-code-runner/ui/src/page/index.ts index b31158985..6244f9e71 100644 --- a/sandbox-code-runner/ui/src/page/index.ts +++ b/sandbox-code-runner/ui/src/page/index.ts @@ -2,14 +2,17 @@ * The sandbox fleet page module — everything page.tsx registers: * * - `SandboxPage` → `host.pages.register` (#/ext/sandbox) - * - `SandboxConfigForm` → `host.configForms.register('sandbox-code-runner', …)` * - `createSandboxSessionChip`→ `host.chat?.registerSessionChip` (feature- * detected: older consoles have no chat slot) * + * The `sandbox-code-runner` configuration entry has no custom form: the + * console's schema-generated form renders it (its one knob is + * `inject_guidance`; the operator FILE config — timeouts, idle TTL — never + * lived in that entry). + * * The stylesheet lives at src/styles/page.css (`cr-page-*` rules under the * worker's `[data-iii-ui="sandbox-code-runner"]` scope). */ -export { SandboxConfigForm } from './ConfigForm' export { createSandboxSessionChip } from './chip' export { SandboxPage } from './SandboxPage' diff --git a/sandbox-code-runner/ui/src/styles/page.css b/sandbox-code-runner/ui/src/styles/page.css index 7b4d541cd..f3a2356de 100644 --- a/sandbox-code-runner/ui/src/styles/page.css +++ b/sandbox-code-runner/ui/src/styles/page.css @@ -1064,66 +1064,6 @@ color: var(--color-ink-faint); } -/* ── configuration form ────────────────────────────────────────────── */ - -[data-iii-ui="sandbox-code-runner"] .cr-page-cfg { - display: flex; - flex-direction: column; - gap: 12px; -} -[data-iii-ui="sandbox-code-runner"] .cr-page-cfg-caption { - font-family: var(--font-mono, ui-monospace, monospace); - font-size: 10.5px; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--color-ink-ghost); -} -[data-iii-ui="sandbox-code-runner"] .cr-page-cfg-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 14px; -} -[data-iii-ui="sandbox-code-runner"] .cr-page-cfg-label { - display: block; - font-size: 11.5px; - color: var(--color-ink-faint); - margin-bottom: 4px; -} -[data-iii-ui="sandbox-code-runner"] .cr-page-cfg-input { - width: 100%; - border: 1px solid var(--color-edge); - background: var(--color-panel); - color: var(--color-ink); - font-family: var(--font-mono, ui-monospace, monospace); - font-size: 12.5px; - padding: 5px 8px; -} -[data-iii-ui="sandbox-code-runner"] .cr-page-cfg-input:focus-visible { - outline: 1px solid var(--color-rule-focus); - outline-offset: -1px; -} -[data-iii-ui="sandbox-code-runner"] .cr-page-cfg-echo { - font-family: var(--font-mono, ui-monospace, monospace); - font-size: 11px; - color: var(--color-ink-ghost); - margin-top: 3px; -} -[data-iii-ui="sandbox-code-runner"] .cr-page-cfg-hint { - font-size: 11px; - color: var(--color-ink-faint); - margin-top: 2px; -} -[data-iii-ui="sandbox-code-runner"] .cr-page-cfg-warning { - font-size: 12px; - color: var(--color-warn); - background: var(--color-warn-muted); - padding: 6px 10px; -} -[data-iii-ui="sandbox-code-runner"] .cr-page-cfg-errors { - font-size: 12px; - color: var(--color-alert); -} - /* ── session chip ──────────────────────────────────────────────────── */ [data-iii-ui="sandbox-code-runner"] .cr-page-chip-root { diff --git a/scrapling/README.md b/scrapling/README.md index e757ae25f..6b9f9c93f 100644 --- a/scrapling/README.md +++ b/scrapling/README.md @@ -6,10 +6,15 @@ on the iii bus: fast HTTP with TLS impersonation, a Camoufox anti-bot browser, a full Playwright/Chromium browser, screenshots, and CSS/XPath/regex/adaptive extraction. -While connected it also injects a usage section into the agent system prompt -via the harness `pre-generate` hook (`scrapling::inject-guidance`), so the -guidance is presence-gated: no scrapling worker, no prompt text. The binding is -one-shot at startup and relies on the engine's recoverable triggers (iii #1962, +While connected (and unless turned off) it also injects a usage section into +the agent system prompt via the harness `pre-generate` hook +(`scrapling::inject-guidance`). `inject_guidance` in the `scrapling` +configuration entry is ON by default; turning it off (the console's config +dialog, or `configuration::set`) hot-applies with no restart, and the +harness's `# Granted functions` catalog still advertises the `scrapling::*` +ids. The guidance stays presence-gated: no +scrapling worker, no prompt text. When enabled, the binding relies on the +engine's recoverable triggers (iii #1962, engine ≥ 0.21.8): bound before the harness is up, it parks as a pending intent and activates when the harness registers the trigger type. On older engines the bind is silently dropped. diff --git a/scrapling/iii-permissions.yaml b/scrapling/iii-permissions.yaml index 0c94fc148..64edf37fa 100644 --- a/scrapling/iii-permissions.yaml +++ b/scrapling/iii-permissions.yaml @@ -9,6 +9,9 @@ version: 1 rules: + # Internal hot-reload hook — engine trigger dispatch only, never + # agent-callable (the same pattern as the other on-config-change denies). + - '!scrapling::on-config-change' - scrapling::extract - scrapling::css - scrapling::xpath diff --git a/scrapling/iii.worker.yaml b/scrapling/iii.worker.yaml index a139b33db..bc807251e 100644 --- a/scrapling/iii.worker.yaml +++ b/scrapling/iii.worker.yaml @@ -19,6 +19,9 @@ description: >- env: III_URL: "ws://localhost:49134" +dependencies: + configuration: "^0.21.6" + # Must name an engine-preset image ref verbatim: bundle workers may not pull # arbitrary base images, and this is how a non-node bundle picks its rootfs # (runtime.kind is deprecated). diff --git a/scrapling/src/configuration.py b/scrapling/src/configuration.py new file mode 100644 index 000000000..edd789304 --- /dev/null +++ b/scrapling/src/configuration.py @@ -0,0 +1,178 @@ +"""Integration with the builtin `configuration` worker: register the +`scrapling` config schema (+ default seed) at boot, read the authoritative +value, and bind a `configuration` trigger so `configuration:updated` +re-fetches and applies the change — which here means binding or unbinding +the `scrapling::inject-guidance` pre-generate hook at runtime. +Mirrors the Rust workers' shared plumbing (workers/crates/config-client): +same retry ladder, same case-SENSITIVE `NOT_FOUND` rule, same serialized +reload. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Any + +from . import guidance + +log = logging.getLogger("scrapling.configuration") + +CONFIG_ID = "scrapling" +CONFIG_FN_ID = "scrapling::on-config-change" +CONFIG_TIMEOUT_MS = 5_000 +CONFIG_RETRIES = 3 +CONFIG_RETRY_BACKOFF_S = 0.25 + +DEFAULTS: dict[str, Any] = {"inject_guidance": True} + +CONFIG_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "inject_guidance": { + "type": "boolean", + "default": True, + "description": ( + "Append the scrapling usage guidance to every agent system prompt via the " + "harness pre-generate hook. On by default; turn it off to shrink prompts " + "(the harness's granted-functions catalog still advertises the scrapling::* " + "ids). Hot-applies — the worker binds or unbinds the hook on change." + ), + }, + }, +} + +ON_CONFIG_CHANGE_REQUEST: dict[str, Any] = {"type": "object", "properties": {}} +ON_CONFIG_CHANGE_RESPONSE: dict[str, Any] = { + "type": "object", + "properties": {"ok": {"type": "boolean"}}, + "required": ["ok"], +} + + +def parse_config(value: Any) -> dict[str, Any]: + """Missing keys fall back to defaults; the configuration worker stores + the flat object the schema describes.""" + cfg = dict(DEFAULTS) + if isinstance(value, dict) and isinstance(value.get("inject_guidance"), bool): + cfg["inject_guidance"] = value["inject_guidance"] + return cfg + + +def _is_not_found(e: Exception) -> bool: + """The configuration worker's missing-entry code is the uppercase literal + `NOT_FOUND`. Deliberately case-SENSITIVE: the engine's missing-FUNCTION + code is lowercase `function_not_found` (vendored SDK iii.py), and a + configuration worker that is absent or unroutable must surface as an + error, never read as "nothing stored yet".""" + return "NOT_FOUND" in str(e) + + +def _trigger_with_retry(iii: Any, function_id: str, payload: dict[str, Any]) -> Any: + """Boot-path RPC with the Rust siblings' retry ladder. `NOT_FOUND` is a + definitive answer (nothing stored yet, the normal first-ever boot), not a + transient failure — it is raised immediately instead of retried.""" + last: Exception | None = None + for attempt in range(1, CONFIG_RETRIES + 1): + try: + return iii.trigger( + { + "function_id": function_id, + "payload": payload, + "timeout_ms": CONFIG_TIMEOUT_MS, + } + ) + except Exception as e: # noqa: BLE001 — classified below + last = e + if _is_not_found(e): + raise + if attempt < CONFIG_RETRIES: + log.warning( + "configuration RPC %s failed (attempt %d); retrying: %s", + function_id, + attempt, + e, + ) + time.sleep(CONFIG_RETRY_BACKOFF_S * attempt) + raise RuntimeError(f"{function_id} failed after {CONFIG_RETRIES} attempts: {last}") from last + + +def _try_get_value(iii: Any) -> Any | None: + """The stored value, or None when the entry does not exist yet.""" + try: + resp = _trigger_with_retry(iii, "configuration::get", {"id": CONFIG_ID}) + except Exception as e: # noqa: BLE001 — NOT_FOUND is the normal first boot + if _is_not_found(e): + return None + raise + return (resp or {}).get("value") if isinstance(resp, dict) else None + + +def register_config(iii: Any) -> None: + """Register the schema, seeding the default only when nothing is stored: + `configuration::register` REPLACES the stored value whenever + `initial_value` is supplied, so the pre-check is what makes calling this + every boot safe.""" + payload: dict[str, Any] = { + "id": CONFIG_ID, + "name": "scrapling", + "description": ( + "scrapling worker settings — whether its usage guidance is injected into " + "agent system prompts (on by default)." + ), + "schema": CONFIG_SCHEMA, + } + if _try_get_value(iii) is None: + payload["initial_value"] = dict(DEFAULTS) + _trigger_with_retry(iii, "configuration::register", payload) + + +def fetch_config(iii: Any) -> dict[str, Any]: + """The authoritative config, defaulted when nothing is stored.""" + return parse_config(_try_get_value(iii)) + + +def register_config_trigger(iii: Any, state: guidance.GuidanceState) -> None: + """Register `scrapling::on-config-change` and bind it to + `configuration:updated` for the `scrapling` entry. Every delivery + re-fetches the authoritative value and reconciles the guidance binding, + serialized by one lock with the fetch INSIDE it — overlapping deliveries + converge on the latest authoritative value instead of racing.""" + + reload_lock = asyncio.Lock() + + async def on_config_change(_payload: dict[str, Any] | None) -> dict[str, Any]: + async with reload_lock: + try: + resp = await iii.trigger_async( + { + "function_id": "configuration::get", + "payload": {"id": CONFIG_ID}, + "timeout_ms": CONFIG_TIMEOUT_MS, + } + ) + cfg = parse_config((resp or {}).get("value") if isinstance(resp, dict) else None) + except Exception as e: # noqa: BLE001 — keep the previous config on any failure + log.error("config-change: fetch failed; keeping previous config: %s", e) + return {"ok": False} + guidance.apply(iii, state, cfg["inject_guidance"]) + log.info("scrapling configuration reloaded (inject_guidance=%s)", cfg["inject_guidance"]) + return {"ok": True} + + iii.register_function( + CONFIG_FN_ID, + on_config_change, + description="Internal: reload scrapling settings from the authoritative configuration on change.", + request_format=ON_CONFIG_CHANGE_REQUEST, + response_format=ON_CONFIG_CHANGE_RESPONSE, + metadata={"internal": True}, + ) + + iii.register_trigger( + { + "type": "configuration", + "function_id": CONFIG_FN_ID, + "config": {"configuration_id": CONFIG_ID, "event_types": ["configuration:updated"]}, + } + ) diff --git a/scrapling/src/guidance.py b/scrapling/src/guidance.py index e7392622b..2b98a0a2c 100644 --- a/scrapling/src/guidance.py +++ b/scrapling/src/guidance.py @@ -1,7 +1,9 @@ """`scrapling::inject-guidance` — a harness `pre_generate` hook that appends the scrapling usage guidance to the agent system prompt, only while this worker is -connected. The binding dies with the worker, so the guidance is presence-gated -for free: a deployment without scrapling never pays for it. +connected AND the `scrapling` configuration's `inject_guidance` is on (the +default; see configuration.py — flips hot-apply by binding/unbinding the +trigger, no restart). The binding dies with the worker, so the guidance stays +presence-gated: a deployment without scrapling never pays for it. The bind is one shot: if the harness is not up yet, the engine parks the binding as a pending intent and activates it when the trigger type registers @@ -13,6 +15,7 @@ from __future__ import annotations import logging +import threading from typing import Any log = logging.getLogger("scrapling.guidance") @@ -102,28 +105,60 @@ async def inject_guidance(payload: dict[str, Any] | None) -> dict[str, Any]: return mutations_for(base if isinstance(base, str) else "") -def setup(iii: Any) -> None: - """Register the hook function and bind it one-shot. Call once at boot.""" +def register_hook(iii: Any) -> None: + """Register the hook FUNCTION unconditionally at boot. Registering it is + inert — the guidance reaches prompts only while a trigger binding exists, + and :func:`apply` owns that binding. Always-registered is what lets a + config flip enable injection without a worker restart.""" iii.register_function( HOOK_ID, inject_guidance, description=( "Internal pre_generate hook: appends scrapling usage guidance to the agent system prompt. " - "Bound to harness::hook::pre-generate at worker startup; not called directly." + "Bound to harness::hook::pre-generate while inject_guidance is on; not called directly." ), request_format=PRE_GENERATE_REQUEST, response_format=PRE_GENERATE_RESPONSE, metadata={"internal": True}, ) - # on_error fail_open is MANDATORY: pre_generate defaults fail-CLOSED, and a - # missing guidance line must never abort a turn. - iii.register_trigger( - { - "type": HOOK_TRIGGER_TYPE, - "function_id": HOOK_ID, - "config": {"on_error": "fail_open"}, - "metadata": {"inject_prompt": GUIDANCE}, - } - ) - log.info("scrapling pre-generate hook bound (guidance injection active)") + +class GuidanceState: + """The live pre-generate binding, if any. Shared with the configuration + change handler so a config flip can bind/unbind at runtime; the lock + serialises concurrent `configuration:updated` deliveries.""" + + def __init__(self) -> None: + self.trigger: Any | None = None + self.lock = threading.Lock() + + +def apply(iii: Any, state: GuidanceState, enabled: bool) -> None: + """Reconcile the live binding with the configured `inject_guidance`: + on → bind once; off → unregister and drop the handle. Idempotent under + repeated config events, and a failed bind retries on the next event. + + on_error fail_open is MANDATORY: pre_generate defaults fail-CLOSED, and a + missing guidance line must never abort a turn.""" + with state.lock: + if enabled and state.trigger is None: + try: + state.trigger = iii.register_trigger( + { + "type": HOOK_TRIGGER_TYPE, + "function_id": HOOK_ID, + "config": {"on_error": "fail_open"}, + "metadata": {"inject_prompt": GUIDANCE}, + } + ) + except Exception as e: # noqa: BLE001 — a failed bind must not kill the worker + log.warning("guidance hook binding failed; continuing without it: %s", e) + return + log.info("inject_guidance on: appending scrapling guidance to agent system prompts") + elif not enabled and state.trigger is not None: + trigger, state.trigger = state.trigger, None + try: + trigger.unregister() + except Exception as e: # noqa: BLE001 — already-gone bindings unregister as a no-op + log.warning("guidance hook unregister failed: %s", e) + log.info("inject_guidance off: scrapling guidance stays out of agent system prompts") diff --git a/scrapling/src/main.py b/scrapling/src/main.py index ff6227a4c..67cc3f060 100644 --- a/scrapling/src/main.py +++ b/scrapling/src/main.py @@ -10,7 +10,7 @@ import yaml from iii import InitOptions, register_worker -from . import guidance, sessions, storage +from . import configuration, guidance, sessions, storage from .handlers import create_handlers from .schemas import FUNCTIONS @@ -73,7 +73,34 @@ def main() -> None: response_format=spec["response"], ) - guidance.setup(iii) + # The builtin `configuration` worker owns the `scrapling` entry; + # `inject_guidance` defaults ON and hot-applies on change by + # binding/unbinding the pre-generate guidance hook. + # + # Best-effort on purpose: the entry carries one cosmetic prompt knob, so a + # configuration-worker failure must not take the scrapling::* surface off + # the bus — warn, run on defaults, and recover on the next configuration + # event or restart. + log = logging.getLogger("scrapling.main") + guidance.register_hook(iii) + try: + configuration.register_config(iii) + except Exception as e: # noqa: BLE001 — best-effort boot dependency + log.warning("registering scrapling configuration schema failed; continuing: %s", e) + try: + worker_cfg = configuration.fetch_config(iii) + except Exception as e: # noqa: BLE001 — best-effort boot dependency + log.warning("loading scrapling configuration failed; using defaults: %s", e) + worker_cfg = dict(configuration.DEFAULTS) + guidance_state = guidance.GuidanceState() + guidance.apply(iii, guidance_state, worker_cfg["inject_guidance"]) + try: + configuration.register_config_trigger(iii, guidance_state) + except Exception as e: # noqa: BLE001 — knob frozen until restart, worker still serves + log.warning( + "registering configuration change trigger failed; inject_guidance frozen until restart: %s", + e, + ) iii.add_connection_state_listener(lambda state: _print_connected(state, url)) diff --git a/scrapling/tests/test_configuration.py b/scrapling/tests/test_configuration.py new file mode 100644 index 000000000..fef6d6d4c --- /dev/null +++ b/scrapling/tests/test_configuration.py @@ -0,0 +1,140 @@ +"""Configuration-worker integration: parse defaults, seeding, retry/NOT_FOUND +classification, and the config-change handler reconciling the guidance +binding.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from src import configuration, guidance + + +class FakeTrigger: + """What register_trigger hands back: a handle whose unregister must + actually be called by the unbind path (a bare object() here once let the + unbind half of the reconcile test pass vacuously).""" + + def __init__(self) -> None: + self.unregistered = 0 + + def unregister(self) -> None: + self.unregistered += 1 + + +class FakeIII: + """Duck-types the slices of IIIClient that configuration touches. `store` + is the configuration worker's stored value; `None` means no entry yet + (configuration::get raises NOT_FOUND). `error` (an Exception) makes every + RPC raise it — the config-plane-down cases.""" + + def __init__(self, store: Any | None = None, error: Exception | None = None): + self.store = store + self.error = error + self.calls = 0 + self.registered_payloads: list[dict[str, Any]] = [] + self.functions: dict[str, Any] = {} + self.trigger_binds: list[dict[str, Any]] = [] + self.triggers: list[FakeTrigger] = [] + + def trigger(self, req): + self.calls += 1 + if self.error is not None: + raise self.error + if req["function_id"] == "configuration::get": + if self.store is None: + raise RuntimeError("remote error (NOT_FOUND): configuration 'scrapling' not found") + return {"value": self.store} + if req["function_id"] == "configuration::register": + self.registered_payloads.append(req["payload"]) + return {} + raise AssertionError(f"unexpected trigger: {req['function_id']}") + + async def trigger_async(self, req): + return self.trigger(req) + + def register_function(self, function_id, handler, **kwargs): + self.functions[function_id] = handler + + def register_trigger(self, spec): + self.trigger_binds.append(spec) + handle = FakeTrigger() + self.triggers.append(handle) + return handle + + +def test_parse_config_defaults_on_and_reads_the_flat_shape(): + assert configuration.parse_config(None) == {"inject_guidance": True} + assert configuration.parse_config({}) == {"inject_guidance": True} + # `False` is the non-default value, so this parse is discriminating. + assert configuration.parse_config({"inject_guidance": False}) == {"inject_guidance": False} + # Non-boolean junk keeps the default rather than propagating, and unknown + # keys (including a worker-name wrapper, which the configuration worker + # never stores) are ignored. + assert configuration.parse_config({"inject_guidance": "no"}) == {"inject_guidance": True} + assert configuration.parse_config({"scrapling": {"inject_guidance": False}}) == {"inject_guidance": True} + + +def test_register_config_seeds_default_only_when_nothing_stored(): + empty = FakeIII(store=None) + configuration.register_config(empty) + assert empty.registered_payloads[0]["initial_value"] == {"inject_guidance": True} + + populated = FakeIII(store={"inject_guidance": False}) + configuration.register_config(populated) + assert "initial_value" not in populated.registered_payloads[0] + + +def test_fetch_config_defaults_when_missing_and_reads_stored(): + # NOT_FOUND is definitive: no retries burned on the normal first boot. + missing = FakeIII(store=None) + assert configuration.fetch_config(missing) == {"inject_guidance": True} + assert missing.calls == 1 + assert configuration.fetch_config(FakeIII(store={"inject_guidance": False})) == {"inject_guidance": False} + + +def test_transient_failures_retry_then_raise(monkeypatch): + """A config plane that is down — including the engine's lowercase + `function_not_found` when the configuration worker is absent — must + surface as an error after the retry ladder, never read as "nothing + stored" (which would silently flip a stored OFF back to the default).""" + monkeypatch.setattr(configuration, "CONFIG_RETRY_BACKOFF_S", 0) + down = FakeIII(error=RuntimeError("remote error (function_not_found): configuration::get")) + try: + configuration.fetch_config(down) + except RuntimeError as e: + assert "function_not_found" in str(e) + else: + raise AssertionError("fetch_config must raise when the config plane is down") + assert down.calls == configuration.CONFIG_RETRIES + + +def test_config_change_handler_reconciles_the_binding(): + iii = FakeIII(store={"inject_guidance": True}) + state = guidance.GuidanceState() + configuration.register_config_trigger(iii, state) + + # The handler and its configuration:updated binding are registered. + assert configuration.CONFIG_FN_ID in iii.functions + assert iii.trigger_binds[0]["config"]["configuration_id"] == configuration.CONFIG_ID + + handler = iii.functions[configuration.CONFIG_FN_ID] + + async def scenario() -> None: + # One loop for both deliveries: the reload lock binds to the loop + # that first acquires it (the SDK's single loop in production). + + # Stored value on → the handler binds the guidance hook. + assert await handler({}) == {"ok": True} + assert state.trigger is not None + bound = state.trigger + assert any(b["type"] == guidance.HOOK_TRIGGER_TYPE for b in iii.trigger_binds) + + # Stored value off → the handler unbinds it — and the handle's + # unregister really runs (not just the slot going empty). + iii.store = {"inject_guidance": False} + assert await handler({}) == {"ok": True} + assert state.trigger is None + assert bound.unregistered == 1 + + asyncio.run(scenario()) diff --git a/scrapling/tests/test_guidance.py b/scrapling/tests/test_guidance.py index 6ff9e5dc8..73afdc138 100644 --- a/scrapling/tests/test_guidance.py +++ b/scrapling/tests/test_guidance.py @@ -1,4 +1,4 @@ -"""Guidance injection: pure mutation logic + the one-shot hook bind.""" +"""Guidance injection: pure mutation logic + the config-driven hook bind.""" from __future__ import annotations @@ -8,13 +8,22 @@ from src import guidance +class FakeTrigger: + def __init__(self): + self.unregistered = False + + def unregister(self): + self.unregistered = True + + class FakeIII: - """Duck-types the slice of IIIClient that guidance.setup touches.""" + """Duck-types the slice of IIIClient that guidance touches.""" def __init__(self): self.functions: dict[str, dict[str, Any]] = {} self.handlers: dict[str, Any] = {} self.trigger_binds: list[dict[str, Any]] = [] + self.trigger_handles: list[FakeTrigger] = [] def register_function(self, function_id, handler, **kwargs): self.functions[function_id] = kwargs @@ -22,6 +31,9 @@ def register_function(self, function_id, handler, **kwargs): def register_trigger(self, spec): self.trigger_binds.append(spec) + handle = FakeTrigger() + self.trigger_handles.append(handle) + return handle # ---- pure mutation logic ---------------------------------------------------- @@ -64,18 +76,32 @@ def test_guidance_names_the_full_surface(): assert needle in guidance.GUIDANCE, f"missing: {needle}" -# ---- setup: one-shot bind ---------------------------------------------------- +# ---- config-driven bind ------------------------------------------------------ -def test_setup_registers_hook_and_binds_one_shot(): +def test_register_hook_registers_the_function_without_binding(): iii = FakeIII() - guidance.setup(iii) + guidance.register_hook(iii) assert set(iii.functions) == {guidance.HOOK_ID} kwargs = iii.functions[guidance.HOOK_ID] assert kwargs["request_format"].get("type"), "untyped request schema" assert kwargs["response_format"].get("type"), "untyped response schema" + # Registering the function is inert: no binding until apply(True). + assert iii.trigger_binds == [] + +def test_apply_binds_on_and_unbinds_off_idempotently(): + iii = FakeIII() + state = guidance.GuidanceState() + + # Off with no binding: nothing happens. + guidance.apply(iii, state, False) + assert iii.trigger_binds == [] + + # On: binds exactly once, with the declarative inject_prompt metadata. + guidance.apply(iii, state, True) + guidance.apply(iii, state, True) assert iii.trigger_binds == [ { "type": guidance.HOOK_TRIGGER_TYPE, @@ -84,3 +110,12 @@ def test_setup_registers_hook_and_binds_one_shot(): "metadata": {"inject_prompt": guidance.GUIDANCE}, } ] + + # Off: unregisters the live handle and empties the slot. + guidance.apply(iii, state, False) + assert iii.trigger_handles[0].unregistered + assert state.trigger is None + + # Back on: binds a fresh handle. + guidance.apply(iii, state, True) + assert len(iii.trigger_binds) == 2 diff --git a/web/Cargo.lock b/web/Cargo.lock index c4c1dfd97..f9183aba7 100644 --- a/web/Cargo.lock +++ b/web/Cargo.lock @@ -777,6 +777,18 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "iii-config-client" +version = "0.1.0" +dependencies = [ + "iii-sdk", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "iii-console-ui" version = "0.1.0" @@ -2275,6 +2287,7 @@ dependencies = [ "clap", "futures", "htmd", + "iii-config-client", "iii-console-ui", "iii-sdk", "ipnet", diff --git a/web/Cargo.toml b/web/Cargo.toml index 489d00491..aff22faa9 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -17,6 +17,9 @@ path = "src/lib.rs" [dependencies] iii-sdk = "=0.21.6" iii-console-ui = { path = "../crates/console-ui" } +# Shared plumbing for the builtin `configuration` worker (seeding, retries, +# NOT_FOUND semantics, serialized hot-reload, guidance binding slot). +iii-config-client = { path = "../crates/config-client" } arc-swap = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time", "net"] } serde = { version = "1", features = ["derive"] } diff --git a/web/README.md b/web/README.md index 2b76ddde4..edac225e3 100644 --- a/web/README.md +++ b/web/README.md @@ -11,8 +11,12 @@ The worker is a faithful Rust port of the original TypeScript `web::fetch` implementation — same request fields and success/error/image envelopes, so existing callers and the harness consumer are unaffected. -While connected it also injects a usage section into the agent system prompt -via the harness `pre-generate` hook (`web::inject-guidance`), so the guidance +While connected (and unless turned off) it also injects a usage section into +the agent system prompt via the harness `pre-generate` hook +(`web::inject-guidance`). `inject_guidance` in the `web` configuration entry +is ON by default; turning it off (the console's config dialog, or +`configuration::set`) hot-applies with no restart, and the harness's +`# Granted functions` catalog still advertises `web::fetch`. The guidance is presence-gated: no web worker, no prompt text. The binding is one-shot at startup and relies on the engine's recoverable triggers (iii #1962, engine ≥ 0.21.8): bound before the harness is up, it parks as a pending intent and diff --git a/web/src/config.rs b/web/src/config.rs index 171dda9b2..580d3763f 100644 --- a/web/src/config.rs +++ b/web/src/config.rs @@ -30,6 +30,12 @@ pub struct WebConfig { /// Allow requests to loopback (`127.0.0.0/8`, `::1`). Other private /// ranges stay blocked regardless. pub allow_loopback: bool, + /// Append the `web::fetch` usage guidance to every agent system prompt + /// via the harness `pre-generate` hook. On by default; turn it off to + /// shrink prompts (the harness's `# Granted functions` catalog still + /// advertises `web::fetch`). Hot-applies — the worker binds or unbinds + /// the hook on change. + pub inject_guidance: bool, } impl Default for WebConfig { @@ -43,6 +49,7 @@ impl Default for WebConfig { max_redirects: 5, user_agent: "iii-harness/0.1 (+web::fetch)".to_string(), allow_loopback: true, + inject_guidance: true, } } } @@ -52,12 +59,11 @@ impl WebConfig { serde_json::to_value(schemars::schema_for!(WebConfig)).expect("WebConfig schema serializes") } - /// Parse from a JSON object; missing keys fall back to defaults - /// (`#[serde(default)]`). The configuration worker may store the value - /// under a `web` wrapper or flat — accept both. + /// Parse from the flat JSON object the configuration worker stores (and + /// the `--config` seed file uses); missing keys fall back to defaults + /// (`#[serde(default)]`). pub fn from_json(v: &serde_json::Value) -> Result { - let inner = v.get("web").unwrap_or(v); - serde_json::from_value(inner.clone()).map_err(|e| format!("invalid web config: {e}")) + serde_json::from_value(v.clone()).map_err(|e| format!("invalid web config: {e}")) } pub fn to_json(&self) -> serde_json::Value { @@ -84,6 +90,7 @@ mod tests { assert_eq!(c.max_redirects, 5); assert_eq!(c.user_agent, "iii-harness/0.1 (+web::fetch)"); assert!(c.allow_loopback); + assert!(c.inject_guidance, "guidance injection defaults on"); } #[test] diff --git a/web/src/configuration.rs b/web/src/configuration.rs index c10e0bf8a..77ded844e 100644 --- a/web/src/configuration.rs +++ b/web/src/configuration.rs @@ -1,189 +1,128 @@ -//! Integration with the `configuration` worker: register a JSON Schema + -//! seed at boot, read the authoritative (env-expanded) value, and bind a +//! Integration with the builtin `configuration` worker (plumbing shared via +//! `crates/config-client`): register the `WebConfig` schema + optional seed +//! at boot, read the authoritative (env-expanded) value, and bind a //! `configuration` trigger so `configuration:updated` re-fetches and applies -//! the change. All WebConfig fields hot-reload (no topology partition). +//! the change. All WebConfig fields hot-reload (no topology partition); +//! `inject_guidance` additionally binds or unbinds the +//! `web::inject-guidance` pre-generate hook. -use std::time::Duration; +use std::sync::Arc; +use iii_config_client as config_client; use iii_sdk::errors::Error; -use iii_sdk::protocol::{RegisterTriggerInput, TriggerRequest}; -use iii_sdk::trigger::Trigger; -use iii_sdk::{IIIClient, RegisterFunction}; -use serde_json::{json, Value}; +use iii_sdk::protocol::RegisterTriggerInput; +use iii_sdk::IIIClient; +use serde_json::json; use crate::config::{SharedConfig, WebConfig}; use crate::functions::inject_guidance; pub const CONFIG_ID: &str = "web"; -const CONFIG_FN_ID: &str = "web::on-config-change"; -const CONFIG_TIMEOUT_MS: u64 = 5_000; -const CONFIG_RETRIES: u32 = 3; +pub const CONFIG_FN_ID: &str = "web::on-config-change"; #[derive(Clone)] pub struct SharedState { pub config: SharedConfig, + pub guidance: GuidanceState, } -/// Best-effort trigger binding: a transient failure must not brick boot — it surfaces -/// as a `None` handle. -fn bind(iii: &IIIClient, trigger_type: &str, function_id: &str, config: Value) -> Option { - match iii.register_trigger(RegisterTriggerInput { - trigger_type: trigger_type.to_string(), - function_id: function_id.to_string(), - config, - metadata: Some(json!({ "inject_prompt": inject_guidance::WEB_GUIDANCE })), - }) { - Ok(handle) => { - tracing::info!(trigger_type, function_id, "trigger binding requested"); - Some(handle) - } - Err(e) => { - tracing::warn!(trigger_type, function_id, error = %e, "trigger binding failed"); - None - } - } +/// The live pre-generate guidance binding, if any. Shared with the +/// configuration change handler so a config flip can bind/unbind at runtime. +pub type GuidanceState = config_client::BindingSlot; + +/// Reconcile the live `web::inject-guidance` binding with the configured +/// `inject_guidance` value: on → bind once; off → unregister and drop the +/// handle. Idempotent under repeated config events, and a failed bind +/// retries on the next event. +/// +/// Binding is one shot: if the harness is not up yet, the engine parks the +/// binding as a pending intent and activates it when the trigger type +/// registers (recoverable triggers, iii #1962). `on_error: fail_open` is +/// MANDATORY: pre_generate defaults fail-CLOSED, which would abort +/// generation if this hook ever errored/timed out; a missing guidance line +/// must never block a turn. +pub fn apply_guidance(iii: &IIIClient, state: &GuidanceState, enabled: bool) { + state.reconcile( + enabled, + || { + config_client::try_bind( + iii, + RegisterTriggerInput { + trigger_type: "harness::hook::pre-generate".to_string(), + function_id: inject_guidance::GUIDANCE_HOOK_ID.to_string(), + config: json!({ "on_error": "fail_open" }), + metadata: Some(json!({ "inject_prompt": inject_guidance::WEB_GUIDANCE })), + }, + ) + }, + "inject_guidance on: appending web::fetch guidance to agent system prompts", + "inject_guidance off: web::fetch guidance stays out of agent system prompts", + ); } -/// Register the `web::inject-guidance` pre_generate hook. One shot: if the harness is -/// not up yet, the engine parks the binding as a pending intent and activates it when -/// the trigger type registers (recoverable triggers, iii #1962) — and re-parks/ -/// re-activates it across harness restarts. Nothing to watch or retry. -/// `on_error: fail_open` is MANDATORY: pre_generate defaults fail-CLOSED, which would -/// abort generation if this hook ever errored/timed out; a missing guidance line must -/// never block a turn. -pub fn setup_harness_hooks(iii: &IIIClient) { - let _ = bind( - iii, - "harness::hook::pre-generate", - inject_guidance::GUIDANCE_HOOK_ID, - json!({ "on_error": "fail_open" }), - ); +fn spec() -> config_client::EntrySpec { + config_client::EntrySpec { + id: CONFIG_ID, + name: "web", + description: + "Timeouts, byte caps, user-agent, and loopback policy for the web::fetch worker.", + schema: WebConfig::json_schema(), + default_value: WebConfig::default().to_json(), + } } +/// Register the schema. A `--config` seed (like the built-in default) is +/// installed only when nothing is stored yet — `configuration::register` +/// REPLACES the stored value whenever `initial_value` is supplied, so an +/// unconditional seed would clobber operator console edits on every boot. pub async fn register_config(iii: &IIIClient, seed: Option<&WebConfig>) -> Result<(), String> { - let mut payload = json!({ - "id": CONFIG_ID, - "name": "web", - "description": "Timeouts, byte caps, user-agent, and loopback policy for the web::fetch worker.", - "schema": WebConfig::json_schema(), - }); - if let Some(seed) = seed { - payload["initial_value"] = seed.to_json(); - } else if should_seed_default(iii).await? { - payload["initial_value"] = WebConfig::default().to_json(); - } - trigger_with_retry(iii, "configuration::register", payload).await?; - Ok(()) + config_client::register(iii, &spec(), seed.map(WebConfig::to_json)).await } pub async fn fetch_config(iii: &IIIClient) -> Result { - match try_get_value(iii).await? { - Some(v) if !v.is_null() => WebConfig::from_json(&v), - _ => { + match config_client::fetch(iii, CONFIG_ID).await? { + Some(v) => WebConfig::from_json(&v), + None => { tracing::info!("no configuration value found; using built-in defaults"); Ok(WebConfig::default()) } } } -async fn should_seed_default(iii: &IIIClient) -> Result { - match try_get_value(iii).await? { - None => Ok(true), - Some(v) if v.is_null() => Ok(true), - Some(_) => Ok(false), - } -} - -async fn try_get_value(iii: &IIIClient) -> Result, String> { - match trigger_with_retry(iii, "configuration::get", json!({ "id": CONFIG_ID })).await { - Ok(resp) => Ok(resp.get("value").cloned()), - Err(e) if e.contains("NOT_FOUND") => Ok(None), - Err(e) => Err(e), - } -} - pub async fn apply_config(state: &SharedState, cfg: WebConfig) { state.config.store(std::sync::Arc::new(cfg)); } -#[derive(Debug, Default, serde::Deserialize, schemars::JsonSchema)] -struct OnConfigChangeRequest {} - -#[derive(Debug, serde::Serialize, schemars::JsonSchema)] -struct OnConfigChangeResponse { - ok: bool, -} - -pub fn register_config_trigger(iii: &IIIClient, state: SharedState) -> Result<(), Error> { - let st = state.clone(); +/// Register `web::on-config-change` and bind it to `configuration:updated` +/// for the `web` entry. Every delivery does ONE re-fetch under the shared +/// reload lock and applies it to both the config snapshot and the guidance +/// binding (so the two can never settle from different fetches); the +/// returned [`config_client::Reload`] lets boot run one extra pass to close +/// the fetch→bind gap. +pub fn register_config_trigger( + iii: &Arc, + state: SharedState, +) -> Result { let engine = iii.clone(); - iii.register_function( + config_client::on_change( + iii, + CONFIG_ID, CONFIG_FN_ID, - RegisterFunction::new_async(move |_req: OnConfigChangeRequest| { - let st = st.clone(); + "Internal: reload web settings from the authoritative configuration on change.", + move || { let engine = engine.clone(); + let state = state.clone(); async move { - on_config_change(&engine, &st).await; - Ok::(OnConfigChangeResponse { ok: true }) - } - }) - .description( - "Internal: reload web settings from the authoritative configuration on change.", - ) - .metadata(json!({ "internal": true })), - ); - - iii.register_trigger(RegisterTriggerInput { - trigger_type: "configuration".to_string(), - function_id: CONFIG_FN_ID.to_string(), - config: json!({ "configuration_id": CONFIG_ID, "event_types": ["configuration:updated"] }), - metadata: None, - })?; - Ok(()) -} - -async fn on_config_change(iii: &IIIClient, state: &SharedState) { - match fetch_config(iii).await { - Ok(cfg) => { - apply_config(state, cfg).await; - tracing::info!("web configuration reloaded"); - } - Err(e) => tracing::error!(error = %e, "config-change: keeping previous config"), - } -} - -async fn trigger_with_retry( - iii: &IIIClient, - function_id: &str, - payload: Value, -) -> Result { - let mut last_err = String::new(); - for attempt in 1..=CONFIG_RETRIES { - match iii - .trigger(TriggerRequest { - function_id: function_id.to_string(), - payload: payload.clone(), - action: None, - timeout_ms: Some(CONFIG_TIMEOUT_MS), - }) - .await - { - Ok(v) => return Ok(v), - Err(e) => { - last_err = e.to_string(); - if attempt < CONFIG_RETRIES { - tracing::warn!( - function_id, - attempt, - error = %last_err, - "configuration RPC failed; retrying" - ); - tokio::time::sleep(Duration::from_millis(250 * u64::from(attempt))).await; + match fetch_config(&engine).await { + Ok(cfg) => { + let inject = cfg.inject_guidance; + apply_config(&state, cfg).await; + apply_guidance(&engine, &state.guidance, inject); + tracing::info!(inject_guidance = inject, "web configuration reloaded"); + } + Err(e) => tracing::error!(error = %e, "config-change: keeping previous config"), } } - } - } - Err(format!( - "{function_id} failed after {CONFIG_RETRIES} attempts: {last_err}" - )) + }, + ) } diff --git a/web/src/main.rs b/web/src/main.rs index a290c4aea..d2bab9458 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -4,7 +4,7 @@ use std::sync::Arc; -use anyhow::{Context, Result}; +use anyhow::Result; use clap::Parser; use iii_sdk::runtime::WorkerMetadata; use iii_sdk::{register_worker, InitOptions}; @@ -86,14 +86,21 @@ async fn main() -> Result<()> { } }); - configuration::register_config(&iii, seed.as_ref()) - .await - .map_err(anyhow::Error::msg) - .context("registering web configuration schema")?; - let cfg = configuration::fetch_config(&iii) - .await - .map_err(anyhow::Error::msg) - .context("loading web configuration")?; + // Best-effort on purpose: every WebConfig field has a safe default and + // hot-reloads, so unlike the full config integrations + // (docs/sops/configuration.md §4c) a configuration-worker failure must + // not take web::fetch off the bus — warn, run on defaults, and recover on + // the next configuration event or restart. + if let Err(e) = configuration::register_config(&iii, seed.as_ref()).await { + tracing::warn!(error = %e, "registering web configuration schema failed; continuing"); + } + let cfg = match configuration::fetch_config(&iii).await { + Ok(cfg) => cfg, + Err(e) => { + tracing::warn!(error = %e, "loading web configuration failed; using defaults"); + WebConfig::default() + } + }; tracing::info!( max_timeout_ms = cfg.max_timeout_ms, max_response_bytes = cfg.max_response_bytes, @@ -101,6 +108,7 @@ async fn main() -> Result<()> { "loaded web configuration" ); + let inject_guidance = cfg.inject_guidance; let shared = cfg.into_shared(); functions::register_all(&iii, &shared); @@ -108,22 +116,28 @@ async fn main() -> Result<()> { // responses) through the console's injectable UI contract. web::ui::register(&iii); - // Bind the harness pre-generate hook (web::inject-guidance). One shot: the engine - // parks the binding until the harness registers the trigger type (recoverable - // triggers, iii #1962). Presence-gated: the guidance is injected only while this - // worker is connected. - configuration::setup_harness_hooks(&iii); + // Reconcile the pre-generate guidance binding (web::inject-guidance) with + // the configured `inject_guidance` (on by default); config changes + // re-reconcile it live via the trigger below. + let state = configuration::SharedState { + config: shared.clone(), + guidance: configuration::GuidanceState::default(), + }; + configuration::apply_guidance(&iii, &state.guidance, inject_guidance); - configuration::register_config_trigger( - &iii, - configuration::SharedState { - config: shared.clone(), - }, - ) - .context("registering configuration change trigger")?; + match configuration::register_config_trigger(&iii, state) { + // One serialized re-fetch to close the boot gap: an update landing + // between the fetch above and the binding just registered fired into + // nothing, and would otherwise stay invisible until the NEXT change. + Ok(reload) => reload.run().await, + Err(e) => { + tracing::warn!(error = %e, "registering configuration change trigger failed; config frozen until restart"); + } + } tracing::info!( - "web ready: web::fetch + injectable console UI + guidance injection + configuration hot-reload" + inject_guidance, + "web ready: web::fetch + injectable console UI + configuration hot-reload" ); tokio::signal::ctrl_c().await?; tracing::info!("web shutting down"); diff --git a/workflow/Cargo.lock b/workflow/Cargo.lock index afb980d1b..66a826c63 100644 --- a/workflow/Cargo.lock +++ b/workflow/Cargo.lock @@ -619,6 +619,18 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "iii-config-client" +version = "0.1.0" +dependencies = [ + "iii-sdk", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "iii-helpers" version = "0.21.6" @@ -2125,6 +2137,7 @@ dependencies = [ "anyhow", "async-trait", "clap", + "iii-config-client", "iii-helpers", "iii-sdk", "schemars", diff --git a/workflow/Cargo.toml b/workflow/Cargo.toml index 6b854bd00..bfc650bd1 100644 --- a/workflow/Cargo.toml +++ b/workflow/Cargo.toml @@ -16,6 +16,9 @@ path = "src/lib.rs" [dependencies] iii-sdk = "=0.21.6" +# Shared plumbing for the builtin `configuration` worker (seeding, retries, +# NOT_FOUND semantics, serialized hot-reload, guidance binding slot). +iii-config-client = { path = "../crates/config-client" } iii-helpers = "=0.21.6" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "signal", "time"] } serde = { version = "1", features = ["derive"] } diff --git a/workflow/README.md b/workflow/README.md index b0c57ff06..c0cab05f6 100644 --- a/workflow/README.md +++ b/workflow/README.md @@ -5,12 +5,15 @@ A deterministic DAG orchestrator running over the iii harness. Accepts state in state; fans out nodes as child harness sessions with deterministic ids so duplicate deliveries are idempotent. -The three harness hook bindings (turn-completed → wake, pre-trigger → -stamp-reply, pre-generate → inject-guidance) are one-shot at startup and rely -on the engine's recoverable triggers (iii #1962, engine ≥ 0.21.8): bound -before the harness is up, they park as pending intents and activate when the -harness registers the trigger types. On older engines the binds are silently -dropped. +The two always-on harness hook bindings (turn-completed → wake, pre-trigger → +stamp-reply) are one-shot at startup and rely on the engine's recoverable +triggers (iii #1962, engine ≥ 0.21.8): bound before the harness is up, they +park as pending intents and activate when the harness registers the trigger +types. On older engines the binds are silently dropped. The third hook — +pre-generate → inject-guidance — follows the `inject_guidance` knob in the +`workflow` configuration entry (ON by default): turning it off shrinks +prompts (the harness's `# Granted functions` catalog still advertises the +`workflow::*` ids) and the worker binds or unbinds it hot, no restart. --- diff --git a/workflow/iii.worker.yaml b/workflow/iii.worker.yaml index d7acb0c6b..523b57c8c 100644 --- a/workflow/iii.worker.yaml +++ b/workflow/iii.worker.yaml @@ -6,4 +6,7 @@ manifest: Cargo.toml license: Apache-2.0 bin: workflow tags: [workflow, orchestration, multi-agent, durable, automation] -description: Deterministic, crash-resumable multi-agent workflow orchestrator over harness turns. \ No newline at end of file +description: Deterministic, crash-resumable multi-agent workflow orchestrator over harness turns. + +dependencies: + configuration: "^0.21.6" \ No newline at end of file diff --git a/workflow/src/config.rs b/workflow/src/config.rs index f143a4a35..9025f2bc6 100644 --- a/workflow/src/config.rs +++ b/workflow/src/config.rs @@ -24,6 +24,14 @@ pub struct WorkerConfig { /// failed. Hot-applies via config-cell swap (not structural). #[serde(default = "default_max_node_retries")] pub max_node_retries: u32, + + /// Append the workflow orchestration guidance to every agent system + /// prompt via the harness `pre-generate` hook. On by default; turn it + /// off to shrink prompts (the harness's `# Granted functions` catalog + /// still advertises the `workflow::*` ids). Hot-applies — the worker + /// binds or unbinds the hook on change. + #[serde(default = "default_inject_guidance")] + pub inject_guidance: bool, } fn default_pending_timeout_ms() -> u64 { @@ -38,6 +46,9 @@ fn default_dispatch_timeout_ms() -> u64 { fn default_max_node_retries() -> u32 { 1 } +fn default_inject_guidance() -> bool { + true +} impl Default for WorkerConfig { fn default() -> Self { @@ -46,6 +57,7 @@ impl Default for WorkerConfig { sweep_expression: default_sweep_expression(), dispatch_timeout_ms: default_dispatch_timeout_ms(), max_node_retries: default_max_node_retries(), + inject_guidance: default_inject_guidance(), } } } diff --git a/workflow/src/configuration.rs b/workflow/src/configuration.rs index b1630791a..ddc52f0d9 100644 --- a/workflow/src/configuration.rs +++ b/workflow/src/configuration.rs @@ -1,5 +1,6 @@ -//! Integration with the `configuration` worker — register the schema, fetch -//! the authoritative value at boot, and hot-reload it when it changes. +//! Integration with the builtin `configuration` worker (plumbing shared via +//! `crates/config-client`) — register the schema, fetch the authoritative +//! value at boot, and hot-reload it when it changes. //! //! `sweep_expression` is the one STRUCTURAL field (the cron binding for the //! node-timeout sweep). On a change the handler re-binds the trigger live @@ -9,70 +10,49 @@ //! [`Deps::cfg`](crate::functions::Deps::cfg); a change swaps the snapshot. use std::sync::Arc; -use std::time::Duration; +use iii_config_client as config_client; use iii_sdk::errors::Error; -use iii_sdk::protocol::{RegisterTriggerInput, TriggerRequest}; +use iii_sdk::protocol::RegisterTriggerInput; use iii_sdk::trigger::Trigger; -use iii_sdk::{IIIClient, RegisterFunction}; -use serde_json::{json, Value}; +use iii_sdk::IIIClient; +use serde_json::json; use crate::config::WorkerConfig; // Reuse the ConfigCell type declared in functions::mod — do NOT redefine. use crate::functions::ConfigCell; pub const CONFIG_ID: &str = "workflow"; -const CONFIG_FN_ID: &str = "workflow::on-config-change"; +pub const CONFIG_FN_ID: &str = "workflow::on-config-change"; pub const SWEEP_ID: &str = "workflow::sweep"; -const CONFIG_TIMEOUT_MS: u64 = 5_000; -const CONFIG_RETRIES: u32 = 3; -const CONFIG_RETRY_BACKOFF_MS: u64 = 250; +fn spec() -> config_client::EntrySpec { + config_client::EntrySpec { + id: CONFIG_ID, + name: "Workflow", + description: "Workflow worker settings: default node-pending timeout, \ + cron sweep schedule, RPC dispatch timeout, and max node retries.", + schema: WorkerConfig::json_schema(), + default_value: WorkerConfig::default().to_json(), + } +} -/// Register the `workflow` configuration schema. The built-in default is seeded -/// as `initial_value` only when nothing is stored yet (safe to call every boot). +/// Register the `workflow` configuration schema. The built-in default is +/// seeded as `initial_value` only when nothing is stored yet (safe to call +/// every boot). pub async fn register_config(iii: &IIIClient) -> Result<(), String> { - let mut payload = json!({ - "id": CONFIG_ID, - "name": "Workflow", - "description": "Workflow worker settings: default node-pending timeout, \ - cron sweep schedule, RPC dispatch timeout, and max node retries.", - "schema": WorkerConfig::json_schema(), - }); - if should_seed_default_value(iii).await? { - payload["initial_value"] = WorkerConfig::default().to_json(); - } - trigger_with_retry(iii, "configuration::register", payload).await?; - Ok(()) + config_client::register(iii, &spec(), None).await } /// Read the live `workflow` configuration (env-expanded by the configuration /// worker — `from_json` does NOT re-expand). pub async fn fetch_config(iii: &IIIClient) -> Result { - let value = try_get_config_value(iii) - .await? - .ok_or_else(|| format!("configuration `{CONFIG_ID}` not found"))?; - if value.is_null() { - tracing::info!("no configuration value found; using built-in default configuration"); - return Ok(WorkerConfig::default()); - } - WorkerConfig::from_json(&value) -} - -async fn should_seed_default_value(iii: &IIIClient) -> Result { - match try_get_config_value(iii).await? { - None => Ok(true), - Some(value) if value.is_null() => Ok(true), - Some(_) => Ok(false), - } -} - -/// Returns `Ok(None)` when the entry does not exist. -async fn try_get_config_value(iii: &IIIClient) -> Result, String> { - match trigger_with_retry(iii, "configuration::get", json!({ "id": CONFIG_ID })).await { - Ok(resp) => Ok(resp.get("value").cloned()), - Err(e) if e.to_ascii_uppercase().contains("NOT_FOUND") => Ok(None), - Err(e) => Err(e), + match config_client::fetch(iii, CONFIG_ID).await? { + Some(v) => WorkerConfig::from_json(&v), + None => { + tracing::info!("no configuration value found; using built-in default configuration"); + Ok(WorkerConfig::default()) + } } } @@ -83,60 +63,54 @@ pub async fn apply_config(cell: &ConfigCell, cfg: WorkerConfig) { *cell.write().await = Arc::new(cfg); } -/// Live handle for the one hot-reloadable trigger binding — the cron sweep. +/// Live handles for the hot-reloadable trigger bindings: the cron sweep and +/// the pre-generate guidance hook (bound only while `inject_guidance` is on). pub struct TriggerHandles { pub sweep: std::sync::Mutex>, + pub guidance: config_client::BindingSlot, } -/// Best-effort binding: the cron trigger type always exists (engine built-in), -/// but a transient failure must not brick boot — it surfaces as a `None` handle. -fn bind( - iii: &IIIClient, - trigger_type: &str, - function_id: &str, - config: Value, - metadata: Option, -) -> Option { - match iii.register_trigger(RegisterTriggerInput { - trigger_type: trigger_type.to_string(), - function_id: function_id.to_string(), - config, - metadata, - }) { - Ok(handle) => { - tracing::info!(trigger_type, function_id, "trigger binding requested"); - Some(handle) - } - Err(e) => { - tracing::warn!(trigger_type, function_id, error = %e, "trigger binding failed"); - None - } - } -} - -/// Bind the three harness hooks (turn-completed → wake, pre-trigger → stamp-reply, -/// pre-generate → inject-guidance). One shot: if the harness is not up yet, the -/// engine parks each binding as a pending intent and activates it when the trigger -/// type registers (recoverable triggers, iii #1962) — and re-parks/re-activates -/// them across harness restarts. Nothing to watch or retry. +/// Bind the two always-on harness hooks (turn-completed → wake, pre-trigger → +/// stamp-reply). One shot: if the harness is not up yet, the engine parks each +/// binding as a pending intent and activates it when the trigger type registers +/// (recoverable triggers, iii #1962) — and re-parks/re-activates them across +/// harness restarts. Nothing to watch or retry. The pre-generate guidance hook +/// is NOT bound here — it follows the `inject_guidance` config knob via +/// [`apply_guidance`]. pub fn setup_harness_hooks(iii: &IIIClient) { let _ = bind_turn_completed(iii); let _ = bind_pre_trigger_hook(iii); - let _ = bind_pre_generate_hook(iii); tracing::info!( "workflow harness hooks registered: turn-completed → wake, pre-trigger → \ - stamp-reply, pre-generate → inject-guidance (guidance injection active)" + stamp-reply" ); } -/// (Re)bind the cron node-timeout sweep from the current config. +/// Reconcile the live `workflow::inject-guidance` binding with the configured +/// `inject_guidance` value: on → bind once; off → unregister and drop the +/// handle. Idempotent under repeated config events, and a failed bind retries +/// on the next event. +pub fn apply_guidance(iii: &IIIClient, handles: &TriggerHandles, enabled: bool) { + handles.guidance.reconcile( + enabled, + || bind_pre_generate_hook(iii), + "inject_guidance on: appending workflow guidance to agent system prompts", + "inject_guidance off: workflow guidance stays out of agent system prompts", + ); +} + +/// (Re)bind the cron node-timeout sweep from the current config. Best-effort +/// (the cron trigger type always exists, but a transient failure must not +/// brick boot): a failure surfaces as a `None` handle. pub fn bind_sweep(iii: &IIIClient, cfg: &WorkerConfig) -> Option { - bind( + config_client::try_bind( iii, - "cron", - SWEEP_ID, - json!({ "expression": cfg.sweep_expression }), - None, + RegisterTriggerInput { + trigger_type: "cron".to_string(), + function_id: SWEEP_ID.to_string(), + config: json!({ "expression": cfg.sweep_expression }), + metadata: None, + }, ) } @@ -145,12 +119,14 @@ pub fn bind_sweep(iii: &IIIClient, cfg: &WorkerConfig) -> Option { /// top-level turns, so a `parent_session_id` filter would never match. The cron /// sweep is the durable fallback if this best-effort bind fails (harness not up). fn bind_turn_completed(iii: &IIIClient) -> Option { - bind( + config_client::try_bind( iii, - "harness::turn-completed", - crate::functions::wake::WAKE_ID, - json!({}), - None, + RegisterTriggerInput { + trigger_type: "harness::turn-completed".to_string(), + function_id: crate::functions::wake::WAKE_ID.to_string(), + config: json!({}), + metadata: None, + }, ) } @@ -164,12 +140,14 @@ fn bind_turn_completed(iii: &IIIClient) -> Option { /// auto-stamp (the run still happens; it only loses console nesting / reply /// delivery on that one call) — strictly better than denying it. fn bind_pre_trigger_hook(iii: &IIIClient) -> Option { - bind( + config_client::try_bind( iii, - "harness::hook::pre-trigger", - crate::functions::stamp_reply::STAMP_REPLY_ID, - json!({ "functions": ["workflow::start"], "on_error": "fail_open", "timeout_ms": 30000 }), - None, + RegisterTriggerInput { + trigger_type: "harness::hook::pre-trigger".to_string(), + function_id: crate::functions::stamp_reply::STAMP_REPLY_ID.to_string(), + config: json!({ "functions": ["workflow::start"], "on_error": "fail_open", "timeout_ms": 30000 }), + metadata: None, + }, ) } @@ -181,14 +159,16 @@ fn bind_pre_trigger_hook(iii: &IIIClient) -> Option { /// if this hook ever errored or timed out; a missing guidance line must never block /// a turn. Best-effort bind like the others. fn bind_pre_generate_hook(iii: &IIIClient) -> Option { - bind( + config_client::try_bind( iii, - "harness::hook::pre-generate", - crate::functions::inject_guidance::GUIDANCE_HOOK_ID, - json!({ "on_error": "fail_open" }), - Some(json!({ - "inject_prompt": crate::functions::inject_guidance::WORKFLOW_GUIDANCE - })), + RegisterTriggerInput { + trigger_type: "harness::hook::pre-generate".to_string(), + function_id: crate::functions::inject_guidance::GUIDANCE_HOOK_ID.to_string(), + config: json!({ "on_error": "fail_open" }), + metadata: Some(json!({ + "inject_prompt": crate::functions::inject_guidance::WORKFLOW_GUIDANCE + })), + }, ) } @@ -204,58 +184,31 @@ fn rebind_slot(slot: &std::sync::Mutex>, new: Option) { } } -/// Internal `workflow::on-config-change` trigger payload. -#[derive(Debug, Default, serde::Deserialize, schemars::JsonSchema)] -pub struct OnConfigChangeEvent { - /// Configuration id that changed (advisory; the handler re-fetches). - #[serde(default)] - pub id: Option, -} - -/// Ack returned by the internal `workflow::on-config-change` handler. -#[derive(Debug, serde::Serialize, schemars::JsonSchema)] -pub struct OnConfigChangeResponse { - pub ok: bool, -} - -/// Register the internal config-change handler and bind a `configuration` -/// trigger. `handles` holds the live cron `Trigger` the handler re-binds when -/// `sweep_expression` changes. +/// Register the internal `workflow::on-config-change` handler and bind a +/// `configuration` trigger. `handles` holds the live cron `Trigger` the +/// handler re-binds when `sweep_expression` changes. Every delivery runs the +/// reload under the shared lock (fetch inside it); the returned +/// [`config_client::Reload`] lets boot run one extra pass to close the +/// fetch→bind gap. pub fn register_config_trigger( iii: &Arc, cell: ConfigCell, handles: Arc, -) -> Result<(), Error> { - let cell_for_fn = cell.clone(); - let handles_for_fn = handles.clone(); +) -> Result { let engine = iii.clone(); - iii.register_function( + config_client::on_change( + iii, + CONFIG_ID, CONFIG_FN_ID, - RegisterFunction::new_async(move |_event: OnConfigChangeEvent| { - let cell = cell_for_fn.clone(); - let handles = handles_for_fn.clone(); + "Internal: hot-reload workflow config — re-binds the cron sweep on a \ + sweep_expression change and swaps the per-call tuning snapshot otherwise.", + move || { let engine = engine.clone(); - async move { - on_config_change(&engine, &cell, &handles).await; - Ok::(OnConfigChangeResponse { ok: true }) - } - }) - .description( - "Internal: hot-reload workflow config — re-binds the cron sweep on a \ - sweep_expression change and swaps the per-call tuning snapshot otherwise.", - ), - ); - - iii.register_trigger(RegisterTriggerInput { - trigger_type: "configuration".to_string(), - function_id: CONFIG_FN_ID.to_string(), - config: json!({ - "configuration_id": CONFIG_ID, - "event_types": ["configuration:updated"], - }), - metadata: None, - })?; - Ok(()) + let cell = cell.clone(); + let handles = handles.clone(); + async move { on_config_change(&engine, &cell, &handles).await } + }, + ) } /// Reload from the AUTHORITATIVE configuration. The caller-supplied trigger @@ -292,42 +245,10 @@ async fn on_config_change(iii: &IIIClient, cell: &ConfigCell, handles: &TriggerH } } + let inject = applied.inject_guidance; apply_config(cell, applied).await; - tracing::info!("workflow configuration reloaded"); -} - -async fn trigger_with_retry( - iii: &IIIClient, - function_id: &str, - payload: Value, -) -> Result { - let mut last_err = String::new(); - for attempt in 1..=CONFIG_RETRIES { - match iii - .trigger(TriggerRequest { - function_id: function_id.to_string(), - payload: payload.clone(), - action: None, - timeout_ms: Some(CONFIG_TIMEOUT_MS), - }) - .await - { - Ok(v) => return Ok(v), - Err(e) => { - last_err = e.to_string(); - if attempt < CONFIG_RETRIES { - tracing::warn!(function_id, attempt, error = %last_err, "configuration RPC failed; retrying"); - tokio::time::sleep(Duration::from_millis( - CONFIG_RETRY_BACKOFF_MS * u64::from(attempt), - )) - .await; - } - } - } - } - Err(format!( - "{function_id} failed after {CONFIG_RETRIES} attempts: {last_err}" - )) + apply_guidance(iii, handles, inject); + tracing::info!(inject_guidance = inject, "workflow configuration reloaded"); } #[cfg(test)] diff --git a/workflow/src/functions/inject_guidance.rs b/workflow/src/functions/inject_guidance.rs index 45fa4e402..4c3553974 100644 --- a/workflow/src/functions/inject_guidance.rs +++ b/workflow/src/functions/inject_guidance.rs @@ -6,8 +6,7 @@ //! hand-duplicated (and drifting) across the four static prompt variants. use schemars::JsonSchema; -use serde::Deserialize; -use serde_json::{json, Value}; +use serde::{Deserialize, Serialize}; pub const GUIDANCE_HOOK_ID: &str = "workflow::inject-guidance"; pub const GUIDANCE_HOOK_DESC: &str = @@ -36,21 +35,51 @@ pub struct GenerateContext { pub system_prompt: String, } -/// Append the workflow guidance to the base prompt. Pure, so it's unit-testable. -/// The harness OVERWRITES `system_prompt` with what we return (it does not merge), -/// so we must return the FULL prompt (base + guidance), not just the addition. -fn enrich(base: &str) -> String { +/// Hook envelope returned to the harness: the mutations to apply to the +/// generation. +#[derive(Debug, Serialize, JsonSchema)] +pub struct PreGenerateResponse { + pub mutations: PreGenerateMutations, +} + +/// The harness applies `system_prompt` only when the key is present +/// (`HookRunner`'s `parse_mutations`), so `None` serializes to an empty +/// object: the safe no-op that preserves the harness's assembled prompt. +#[derive(Debug, Default, Serialize, JsonSchema)] +pub struct PreGenerateMutations { + /// Full replacement system prompt (base + appended guidance). The harness + /// overwrites, it does not merge. + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, +} + +/// Build the `pre_generate` mutations for a given base prompt. Pure, so it's +/// unit-testable. +/// +/// Returns NO `system_prompt` when `base` is empty. A missing or renamed +/// `generate.system_prompt` field deserializes to `""` (schema drift), and a +/// fail-open hook must PRESERVE the harness's assembled prompt, never replace +/// it with the guidance alone (fp's hook established this rule). For a real, +/// non-empty base we append the guidance and return the FULL prompt (the +/// harness overwrites, it does not merge). +fn mutations_for(base: &str) -> PreGenerateMutations { if base.is_empty() { - WORKFLOW_GUIDANCE.to_string() + PreGenerateMutations::default() } else { - format!("{base}\n\n{WORKFLOW_GUIDANCE}") + PreGenerateMutations { + system_prompt: Some(format!("{base}\n\n{WORKFLOW_GUIDANCE}")), + } } } /// `pre_generate` hook entrypoint: return a `system_prompt` mutation that appends /// the workflow guidance. Bound `fail_open`, so an error here never blocks a turn. -pub async fn handle(event: PreGenerateEvent) -> Result { - Ok(json!({ "mutations": { "system_prompt": enrich(&event.generate.system_prompt) } })) +pub async fn handle( + event: PreGenerateEvent, +) -> Result { + Ok(PreGenerateResponse { + mutations: mutations_for(&event.generate.system_prompt), + }) } #[cfg(test)] @@ -58,8 +87,11 @@ mod tests { use super::*; #[test] - fn enrich_appends_guidance_after_base() { - let out = enrich("BASE PROMPT"); + fn appends_guidance_after_a_real_base() { + let m = mutations_for("BASE PROMPT"); + let out = m + .system_prompt + .expect("a non-empty base yields a system_prompt mutation"); assert!( out.starts_with("BASE PROMPT\n\n"), "the base prompt must be preserved, guidance appended after it" @@ -75,8 +107,34 @@ mod tests { } #[test] - fn enrich_handles_empty_base() { - // A missing/empty base must not produce a leading blank block. - assert_eq!(enrich(""), WORKFLOW_GUIDANCE); + fn empty_base_emits_no_system_prompt_mutation() { + // A missing/malformed hook payload (system_prompt absent → "") must + // PRESERVE the harness prompt: emit no system_prompt key so the + // harness keeps its own, rather than replacing the whole prompt with + // the guidance alone. The wire shape must stay `{"mutations": {}}` — + // the harness applies system_prompt only when the key is present. + let wire = serde_json::to_value(PreGenerateResponse { + mutations: mutations_for(""), + }) + .expect("response serializes"); + assert_eq!(wire, serde_json::json!({ "mutations": {} })); + } + + /// Mirrors the registry publish gate (`collect_worker_interface.py`): the + /// derived response schema must carry a schema-defining keyword, not the + /// AnyValue schema. + #[test] + fn response_schema_passes_the_publish_typed_gate() { + let schema = schemars::r#gen::SchemaSettings::draft07() + .into_generator() + .into_root_schema_for::(); + let value = serde_json::to_value(schema).expect("schema serializes"); + let obj = value.as_object().expect("schema is an object"); + assert!( + ["type", "properties", "$ref"] + .iter() + .any(|k| obj.contains_key(*k)), + "PreGenerateResponse schema is untyped: {value}" + ); } } diff --git a/workflow/src/functions/stamp_reply.rs b/workflow/src/functions/stamp_reply.rs index 39d7035e3..6d4524cf5 100644 --- a/workflow/src/functions/stamp_reply.rs +++ b/workflow/src/functions/stamp_reply.rs @@ -6,19 +6,47 @@ //! with a `reply_to` object, this hook stamps the TRUE caller identity //! (`session_id` / `model` / `provider`, taken from the harness-supplied hook //! envelope) into `reply_to`, OVERWRITING anything the agent supplied — so an -//! agent cannot direct a run's result into another session. If the call has no -//! `reply_to`, the hook is a no-op (returns `null` = continue unchanged). +//! agent cannot direct a run's result into another session. If there is no +//! caller identity to stamp, the hook answers a bare `continue` with no +//! mutations (the harness parses that identically to `null`). //! //! The harness routes deserialization failures / hook errors through the //! binding's `on_error: fail_open`, so a hiccup here never blocks //! `workflow::start` — it just skips the auto-stamp. use schemars::JsonSchema; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; pub const STAMP_REPLY_ID: &str = "workflow::stamp-reply"; +/// Hook outcome returned to the harness: always `continue`, with rewritten +/// `workflow::start` arguments when there was a caller identity to stamp. +/// The harness's `parse_output` treats a missing/`"continue"` decision and +/// absent mutations as "continue unchanged", so the no-op case is the same +/// wire the old `null` reply produced — but the response schema stays typed +/// (the registry publish gate refuses AnyValue response schemas). +#[derive(Debug, Serialize, JsonSchema)] +pub struct StampReplyResponse { + /// Always `"continue"` — this hook never denies or holds a call. + pub decision: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub mutations: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct StampReplyMutations { + /// The call's arguments with the caller identity stamped in. + pub arguments: Value, +} + +fn cont(mutations: Option) -> StampReplyResponse { + StampReplyResponse { + decision: "continue".to_string(), + mutations, + } +} + /// The slice of the `pre_trigger` hook envelope we need (lenient: ignores the /// other envelope fields the harness sends). #[derive(Debug, Deserialize, JsonSchema)] @@ -47,15 +75,15 @@ pub struct StampCall { pub arguments: Value, } -/// Pure decision: return the `continue` outcome with rewritten arguments when -/// the call opted into `reply_to`, else `Value::Null` (continue unchanged). -pub fn decide(event: StampReplyEvent) -> Value { +/// Pure decision: `continue` with rewritten arguments when there was a caller +/// identity to stamp, else `continue` with no mutations (unchanged call). +pub fn decide(event: StampReplyEvent) -> StampReplyResponse { let Some(session_id) = event.session_id.filter(|s| !s.is_empty()) else { - return Value::Null; // no caller identity to stamp + return cont(None); // no caller identity to stamp }; let mut args = event.call.map(|c| c.arguments).unwrap_or(Value::Null); let Some(obj) = args.as_object_mut() else { - return Value::Null; + return cont(None); }; // Always record the orchestrator session so node sessions can nest under it // in the console tree — independent of reply_to/await. Identity comes from @@ -83,14 +111,14 @@ pub fn decide(event: StampReplyEvent) -> Value { } obj.insert("reply_to".into(), Value::Object(reply)); } - json!({ "decision": "continue", "mutations": { "arguments": args } }) + cont(Some(StampReplyMutations { arguments: args })) } /// Pre_trigger hook handler: stamp the caller identity into the call's arguments /// (see `decide`) and ALWAYS continue. The hook never parks or blocks the harness /// turn — `workflow::start` is fire-and-forget; the outcome comes back via /// `reply_to` / `notify`. -pub async fn handle(event: StampReplyEvent) -> Result { +pub async fn handle(event: StampReplyEvent) -> Result { Ok(decide(event)) } @@ -107,16 +135,21 @@ mod tests { .expect("envelope") } + /// The decision as the harness sees it on the wire. + fn wire(response: StampReplyResponse) -> Value { + serde_json::to_value(response).expect("response serializes") + } + #[test] fn stamps_session_and_model_when_reply_to_present() { - let out = decide(event( + let out = wire(decide(event( "sess_1", "m1", json!({ "definition": { "version": 1 }, "reply_to": {} }), - )); + ))); let args = &out["mutations"]["arguments"]; let rt = &args["reply_to"]; assert_eq!(out["decision"], "continue"); @@ -130,7 +163,7 @@ mod tests { fn captures_caller_function_policy_into_reply_to() { // The envelope carries the caller turn's dispatch policy; decide stamps it // into reply_to so delivery can wake the caller with its own reach. - let out = decide( + let out = wire(decide( serde_json::from_value(json!({ "session_id": "sess_1", "model": "m1", @@ -138,7 +171,7 @@ mod tests { "call": { "arguments": { "definition": { "version": 1 }, "reply_to": {} } }, })) .expect("envelope"), - ); + )); let rt = &out["mutations"]["arguments"]["reply_to"]; assert_eq!(rt["functions"]["allow"][0], "*"); assert_eq!(rt["functions"]["deny"][0], "workflow::*"); @@ -147,13 +180,13 @@ mod tests { #[test] fn overwrites_agent_supplied_session_id() { // Spoof attempt: agent points reply_to at someone else's session. - let out = decide(event( + let out = wire(decide(event( "real_caller", "m1", json!({ "reply_to": { "session_id": "victim", "template": "done:" } }), - )); + ))); let rt = &out["mutations"]["arguments"]["reply_to"]; assert_eq!(rt["session_id"], "real_caller"); // overwritten assert_eq!(rt["template"], "done:"); // template preserved @@ -163,11 +196,11 @@ mod tests { fn stamps_caller_session_when_reply_to_absent() { // Fire-and-forget start (no reply_to): still capture the caller session // so the run's node sessions can nest under the orchestrator chat. - let out = decide(event( + let out = wire(decide(event( "sess_1", "m1", json!({ "definition": { "version": 1 } }), - )); + ))); assert_eq!(out["decision"], "continue"); let args = &out["mutations"]["arguments"]; assert_eq!(args["caller_session_id"], "sess_1"); @@ -178,6 +211,26 @@ mod tests { fn no_op_without_caller_session() { let ev: StampReplyEvent = serde_json::from_value(json!({ "call": { "arguments": { "reply_to": {} } } })).unwrap(); - assert!(decide(ev).is_null()); + // A bare continue with no mutations key — the harness parses this + // identically to the old `null` reply (continue unchanged). + assert_eq!(wire(decide(ev)), json!({ "decision": "continue" })); + } + + /// Mirrors the registry publish gate (`collect_worker_interface.py`): the + /// derived response schema must carry a schema-defining keyword, not the + /// AnyValue schema. + #[test] + fn response_schema_passes_the_publish_typed_gate() { + let schema = schemars::r#gen::SchemaSettings::draft07() + .into_generator() + .into_root_schema_for::(); + let value = serde_json::to_value(schema).expect("schema serializes"); + let obj = value.as_object().expect("schema is an object"); + assert!( + ["type", "properties", "$ref"] + .iter() + .any(|k| obj.contains_key(*k)), + "StampReplyResponse schema is untyped: {value}" + ); } } diff --git a/workflow/src/main.rs b/workflow/src/main.rs index 61347dbaf..e55202254 100644 --- a/workflow/src/main.rs +++ b/workflow/src/main.rs @@ -17,7 +17,7 @@ use std::sync::Arc; -use anyhow::{Context, Result}; +use anyhow::Result; use clap::Parser; use iii_helpers::observability::OtelConfig; use iii_sdk::runtime::WorkerMetadata; @@ -86,15 +86,14 @@ async fn main() -> Result<()> { tracing::warn!("--config seeding not wired in MVP; using stored/default config"); } - configuration::register_config(&iii) - .await - .map_err(anyhow::Error::msg) - .context("registering workflow configuration schema")?; // Don't brick boot on a transient config-worker hiccup: warn and come up // inert on defaults, then recover on the next config-change hot-reload (same // resilience stance as on_config_change keeping the previous config on a // fetch failure). Matches the warn-and-default convention of the other // worker binaries in this repo. + if let Err(e) = configuration::register_config(&iii).await { + tracing::warn!(error = %e, "registering workflow configuration schema failed; continuing"); + } let cfg = match configuration::fetch_config(&iii).await { Ok(cfg) => cfg, Err(e) => { @@ -117,16 +116,21 @@ async fn main() -> Result<()> { functions::register_all(&iii, &deps); // Bind the cron sweep; retain the handle so a sweep_expression change - // re-binds it live. + // re-binds it live. The guidance slot starts empty — apply_guidance below + // fills it only when the config asks for injection. let handles = Arc::new(TriggerHandles { sweep: std::sync::Mutex::new(configuration::bind_sweep(&iii, &cfg)), + guidance: Default::default(), }); - // Bind the three HARNESS-provided hooks (turn-completed → wake, hook::pre-trigger - // → reply stamping, hook::pre-generate → guidance injection). One shot: the engine - // parks each binding until the harness registers the trigger type (recoverable + // Bind the two always-on HARNESS-provided hooks (turn-completed → wake, + // hook::pre-trigger → reply stamping). One shot: the engine parks each + // binding until the harness registers the trigger type (recoverable // triggers, iii #1962) and re-activates them across harness restarts. + // The pre-generate guidance hook follows the `inject_guidance` config + // knob instead (on by default), hot-applied on config changes. configuration::setup_harness_hooks(&iii); + configuration::apply_guidance(&iii, &handles, cfg.inject_guidance); // Crash recovery: a parked AwaitingNodes run has no enqueued tick. // Re-drive each non-terminal run so it can make progress. @@ -144,8 +148,15 @@ async fn main() -> Result<()> { } // LAST: bind the configuration-change trigger. - configuration::register_config_trigger(&iii, cell, handles) - .context("registering the configuration change trigger")?; + match configuration::register_config_trigger(&iii, cell, handles) { + // One serialized re-fetch to close the boot gap: an update landing + // between the boot fetch and the binding just registered fired into + // nothing, and would otherwise stay invisible until the NEXT change. + Ok(reload) => reload.run().await, + Err(e) => { + tracing::warn!(error = %e, "registering the configuration change trigger failed; config frozen until restart"); + } + } tracing::info!("workflow ready"); From 75a2af4bc08b85969ebe3be18db33f5663a27d51 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Tue, 18 Aug 2026 14:39:27 -0300 Subject: [PATCH 2/2] fix(ci): bump h2 to 0.4.16 (RUSTSEC-2026-0258) and sync provider testkit lockfile with post-release provider versions --- crates/provider-integration-testkit/Cargo.lock | 16 ++++++++-------- web/Cargo.lock | 18 +++++++++--------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/crates/provider-integration-testkit/Cargo.lock b/crates/provider-integration-testkit/Cargo.lock index 09c30bb16..74b9f6e64 100644 --- a/crates/provider-integration-testkit/Cargo.lock +++ b/crates/provider-integration-testkit/Cargo.lock @@ -1326,7 +1326,7 @@ dependencies = [ [[package]] name = "provider-anthropic" -version = "1.2.4" +version = "1.2.5" dependencies = [ "clap", "futures", @@ -1343,7 +1343,7 @@ dependencies = [ [[package]] name = "provider-claude-code" -version = "0.1.3" +version = "0.1.4" dependencies = [ "clap", "futures", @@ -1360,7 +1360,7 @@ dependencies = [ [[package]] name = "provider-deepseek" -version = "0.1.2" +version = "0.1.5" dependencies = [ "clap", "futures", @@ -1401,7 +1401,7 @@ dependencies = [ [[package]] name = "provider-kimi" -version = "1.1.3" +version = "1.1.4" dependencies = [ "clap", "futures", @@ -1418,7 +1418,7 @@ dependencies = [ [[package]] name = "provider-openai" -version = "1.2.3" +version = "1.2.4" dependencies = [ "clap", "futures", @@ -1435,7 +1435,7 @@ dependencies = [ [[package]] name = "provider-openai-codex" -version = "0.4.2" +version = "0.4.3" dependencies = [ "base64 0.22.1", "clap", @@ -1472,7 +1472,7 @@ dependencies = [ [[package]] name = "provider-xai" -version = "1.3.2" +version = "1.3.3" dependencies = [ "clap", "futures", @@ -1489,7 +1489,7 @@ dependencies = [ [[package]] name = "provider-zai" -version = "0.5.2" +version = "0.5.3" dependencies = [ "clap", "futures", diff --git a/web/Cargo.lock b/web/Cargo.lock index f9183aba7..6e6260c1d 100644 --- a/web/Cargo.lock +++ b/web/Cargo.lock @@ -47,7 +47,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -58,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -332,7 +332,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -501,9 +501,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -997,7 +997,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1464,7 +1464,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1728,7 +1728,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1829,7 +1829,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]]