From 2f7121d9ab9a589ee5a3808701041aea4f05dc64 Mon Sep 17 00:00:00 2001 From: Sam Paniagua Date: Mon, 3 Aug 2026 16:43:32 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(icp):=20add=20agent=20runtime=20canist?= =?UTF-8?q?er=20Phases=200=E2=80=932?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embed knolo-agent-core/scheduler in an ICP canister host with deterministic control-plane execution (Phase 1) and pack-gated tools, ic-llm, knowledge retrieval, timers, and cycles/budget observation (Phase 2). Document constraints and ADR-001; ship a local dfx example and CHANGELOG entry. --- .gitignore | 8 + CHANGELOG.md | 62 ++ Cargo.lock | 514 +++++++++++++ Cargo.toml | 2 + FUTURE.md | 303 ++++++++ README.md | 2 + crates/knolo-agent-icp/Cargo.toml | 25 + .../knolo-agent-icp/candid/agent_runtime.did | 78 ++ crates/knolo-agent-icp/src/budget.rs | 80 ++ crates/knolo-agent-icp/src/definition.rs | 153 ++++ crates/knolo-agent-icp/src/dto.rs | 152 ++++ crates/knolo-agent-icp/src/effects.rs | 206 +++++ crates/knolo-agent-icp/src/engine.rs | 613 +++++++++++++++ crates/knolo-agent-icp/src/executor.rs | 346 +++++++++ crates/knolo-agent-icp/src/host.rs | 22 + crates/knolo-agent-icp/src/knowledge.rs | 88 +++ crates/knolo-agent-icp/src/lib.rs | 708 ++++++++++++++++++ crates/knolo-agent-icp/src/tools_host.rs | 193 +++++ docs/README.md | 1 + docs/architecture/README.md | 8 +- .../architecture/adr-001-icp-agent-runtime.md | 53 ++ docs/architecture/icp-constraints-matrix.md | 68 ++ docs/wasm.md | 8 + examples/icp-agent-canister/README.md | 69 ++ examples/icp-agent-canister/dfx.json | 23 + .../fixtures/host-effects.definition.json | 122 +++ .../fixtures/initial-state.json | 8 + .../fixtures/portable-counter.definition.json | 50 ++ .../scripts/run-deterministic.sh | 52 ++ 29 files changed, 4016 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md create mode 100644 FUTURE.md create mode 100644 crates/knolo-agent-icp/Cargo.toml create mode 100644 crates/knolo-agent-icp/candid/agent_runtime.did create mode 100644 crates/knolo-agent-icp/src/budget.rs create mode 100644 crates/knolo-agent-icp/src/definition.rs create mode 100644 crates/knolo-agent-icp/src/dto.rs create mode 100644 crates/knolo-agent-icp/src/effects.rs create mode 100644 crates/knolo-agent-icp/src/engine.rs create mode 100644 crates/knolo-agent-icp/src/executor.rs create mode 100644 crates/knolo-agent-icp/src/host.rs create mode 100644 crates/knolo-agent-icp/src/knowledge.rs create mode 100644 crates/knolo-agent-icp/src/lib.rs create mode 100644 crates/knolo-agent-icp/src/tools_host.rs create mode 100644 docs/architecture/adr-001-icp-agent-runtime.md create mode 100644 docs/architecture/icp-constraints-matrix.md create mode 100644 examples/icp-agent-canister/README.md create mode 100644 examples/icp-agent-canister/dfx.json create mode 100644 examples/icp-agent-canister/fixtures/host-effects.definition.json create mode 100644 examples/icp-agent-canister/fixtures/initial-state.json create mode 100644 examples/icp-agent-canister/fixtures/portable-counter.definition.json create mode 100755 examples/icp-agent-canister/scripts/run-deterministic.sh diff --git a/.gitignore b/.gitignore index 84a97a8..b8ab2e9 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ generated/ **/wasm-bindgen/ **/*.wasm +# ICP / dfx local replica state +.dfx/ +**/.dfx/ + # Tool and editor state .idea/ .vscode/ @@ -24,6 +28,10 @@ generated/ *.swp *.swo +# Local planning artifacts (session / accepted drafts) +# Promote decisions to docs/architecture/ ADRs when ready to commit. +.plans/ + # Local configuration and secrets .env .env.* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e325930 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +All notable changes to this workspace are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/) for +published artifacts (`knolo-agent`, `knolo-agent-core`, `@knolo/agents`). +Workspace-only crates (for example `knolo-agent-icp`, `knolo-agent-wasm`) are +called out explicitly and may evolve without a crates.io release. + +## [Unreleased] + +### Added + +#### ICP agent runtime (Phases 0–2) + +- New workspace crate **`knolo-agent-icp`** (`publish = false`): Internet Computer + canister host for the Knolo control plane (`ic-cdk` 0.17, Candid, `ic-llm` 1.1, + `ic-cdk-timers` 0.11). +- **Phase 0 — discovery** + - Architecture decision record: + [`docs/architecture/adr-001-icp-agent-runtime.md`](docs/architecture/adr-001-icp-agent-runtime.md) + - Constraints matrix (Wasm size, portability, knolo-core ICP reuse): + [`docs/architecture/icp-constraints-matrix.md`](docs/architecture/icp-constraints-matrix.md) + - Confirmed `knolo-agent-core` and `knolo-agent` build for + `wasm32-unknown-unknown` without a pure `no_std` rewrite. +- **Phase 1 — minimal PoC** + - Deterministic in-canister scheduler path using `knolo-agent::runtime::Scheduler`. + - Candid surface: `health`, `inspect`, `load_definition`, `clear_definition`, + `start_execution`, `step`, `resume`, `get_events`, `get_checkpoint`. + - Ordered events, checkpoints, resume, and step-slicing for pure graphs. + - Local example: [`examples/icp-agent-canister/`](examples/icp-agent-canister/) + (`dfx` + `scripts/run-deterministic.sh` + portable-counter fixture). +- **Phase 2 — full host adapter + effects** + - Pack-gated tools (`echo`, optional `https_get`) via Knolo policy + budget ledger. + - LLM via **ic-llm** with suspend/resume (`await_llm`); deterministic mock in + native unit tests (network-free). + - Retrieval via knolo-core knowledge canister principal (`search`) or mock + fallback when unset. + - Timers for `host.auto_continue` on `step_slice` suspensions. + - Cycles observation + Knolo budget snapshot query: `get_budget`. + - Effect drain API: `continue_effects`. + - Host-effects fixture and implementation id `host-effects-v1`. + - Release Wasm size ~1.52 MiB (Phase 2); Phase 1 baseline was ~1.20 MiB. +- Documentation cross-links in README, architecture index, WASM notes, and + `FUTURE.md` platform target status. +- Root `.gitignore` entries for `.plans/` (local planning notes) and `.dfx/`. + +### Notes + +- `knolo-agent-icp` is workspace-validated only; not published separately. +- Browser `knolo-agent-wasm` remains a separate path from the ICP canister host. +- Live LLM runs require a reachable LLM canister; pure deterministic graphs run + without it. +- Phase 3+ (not in this change): `ic-stable-structures`, production hardening, + multi-agent handoff, DX/CLI templates. + +### Unchanged published crates + +- No intentional public API changes to `knolo-agent`, `knolo-agent-core`, or + `@knolo/agents` in this branch; version numbers remain as on `main` / prior + release line until a coordinated publish. diff --git a/Cargo.lock b/Cargo.lock index e9da40f..343574a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,62 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "binrw" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ad120d555272286c1017d25165ab8bd74806f13fc85b258484ec7e4ce75458f" +dependencies = [ + "array-init", + "binrw_derive", + "bytemuck", +] + +[[package]] +name = "binrw_derive" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92e0e9baae4dc82c7bad7715ca40c0a5c71539057bf2ea04a5c29c980410b" +dependencies = [ + "either", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -11,6 +67,63 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "candid" +version = "0.10.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdec808f2220b263bbc88557a2a44fb6b2db5875c99731ad00aae575ad59215c" +dependencies = [ + "anyhow", + "binrw", + "byteorder", + "candid_derive", + "hex", + "ic_principal", + "leb128", + "num-bigint", + "num-traits", + "paste", + "pretty", + "serde", + "serde_bytes", + "stacker", + "thiserror", +] + +[[package]] +name = "candid_derive" +version = "0.10.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee4839d8e442990b2853556e57ee9f6f4527371bbbfd7b4dcdd4f3c7d910964" +dependencies = [ + "lazy_static", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -26,6 +139,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -36,6 +158,12 @@ dependencies = [ "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" @@ -46,6 +174,106 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -56,6 +284,90 @@ dependencies = [ "version_check", ] +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "ic-cdk" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a7344f41493cbf591f13ae9f90181076f808a83af799815c3074b19c693d2e" +dependencies = [ + "candid", + "ic-cdk-executor", + "ic-cdk-macros", + "ic0", + "serde", + "serde_bytes", +] + +[[package]] +name = "ic-cdk-executor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "903057edd3d4ff4b3fe44a64eaee1ceb73f579ba29e3ded372b63d291d7c16c2" + +[[package]] +name = "ic-cdk-macros" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cbaa50fa36d3e0616114becf81faa95a099e0d60948ed6978f30f1c77399fd" +dependencies = [ + "candid", + "proc-macro2", + "quote", + "serde", + "serde_tokenstream", + "syn", +] + +[[package]] +name = "ic-cdk-timers" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "292b84c5b8e57e12bf26306be81ec145ab9641ab12317a6f88e5c22af55e7acd" +dependencies = [ + "futures", + "ic-cdk", + "ic0", + "serde", + "serde_bytes", + "slotmap", +] + +[[package]] +name = "ic-llm" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa0e74462ec292f110b27f9c50d7b152f9172be2326053cc1290d58bcf438c2c" +dependencies = [ + "candid", + "ic-cdk", + "serde", +] + +[[package]] +name = "ic0" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de254dd67bbd58073e23dc1c8553ba12fa1dc610a19de94ad2bbcd0460c067f" + +[[package]] +name = "ic_principal" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2732829022822ec69021c336d23b32a053e07abdd08553c71407d6e2d1675d" +dependencies = [ + "crc32fast", + "data-encoding", + "serde", + "sha2", + "thiserror", +] + [[package]] name = "itoa" version = "1.0.18" @@ -80,6 +392,22 @@ dependencies = [ "sha2", ] +[[package]] +name = "knolo-agent-icp" +version = "0.1.1" +dependencies = [ + "candid", + "ic-cdk", + "ic-cdk-macros", + "ic-cdk-timers", + "ic-llm", + "knolo-agent", + "knolo-agent-core", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "knolo-agent-wasm" version = "0.1.1" @@ -89,6 +417,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" + [[package]] name = "libc" version = "0.2.186" @@ -101,6 +441,67 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pretty" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156" +dependencies = [ + "arrayvec", + "typed-arena", + "unicode-width", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -110,6 +511,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quote" version = "1.0.45" @@ -129,6 +540,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -162,6 +583,18 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_tokenstream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c49585c52c01f13c5c2ebb333f14f6885d76daa768d8a037d28017ec538c69" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", +] + [[package]] name = "sha2" version = "0.10.9" @@ -173,6 +606,40 @@ dependencies = [ "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 = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys", +] + [[package]] name = "syn" version = "2.0.117" @@ -184,6 +651,32 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typenum" version = "1.20.0" @@ -196,12 +689,33 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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 = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 3190290..e72180e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/knolo-agent-core", "crates/knolo-agent", "crates/knolo-agent-wasm", + "crates/knolo-agent-icp", ] [workspace.package] @@ -14,6 +15,7 @@ version = "0.1.1" [workspace.dependencies] knolo-agent-core = { path = "crates/knolo-agent-core", version = "0.1.1" } +knolo-agent = { path = "crates/knolo-agent", version = "0.1.1" } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" diff --git a/FUTURE.md b/FUTURE.md new file mode 100644 index 0000000..a347b3c --- /dev/null +++ b/FUTURE.md @@ -0,0 +1,303 @@ +# Future Work — Knolo Agents + +Knolo Agents is an early `0.x` project. The items below are intentional next +steps grounded in the current repository, documented limitations, and remaining +contract gaps. They are not a redesign wishlist, and they are not evidence of +unfinished chaos: the control-plane core already works. + +## Current status summary + +What is solid today: + +- **Rust is the authoritative runtime.** `knolo-agent-core` owns portable + contracts (graphs, state schemas, events, packs, policy, HITL, handoffs, + checkpoints, replay requests). `knolo-agent` owns the native scheduler, host + effect boundaries, pack loading, and durable runtime integrations. +- **Pack-constrained authority is real.** Native `.knolo` declarations and + `.knolo.json` manifests load into immutable policy; missing grants deny by + default. Tool resource budgets are enforced before execution. +- **Deterministic control plane basics exist.** Ordered events, graph hashing, + checkpoint artifact binding, and contiguous-sequence replay verification are + in place. The TypeScript engine supports the portable subset: state, routing, + and suspension, with explicit engine selection and no silent fallback. +- **Boundaries are explicit.** Tools, retrieval, Cortex, ClaimGraph, clocks, and + storage are host-injected. `@knolo/core` is a peer dependency, never vendored. + +What is deliberately incomplete: full state-level TypeScript replay, standalone +WASM execution, deeper native pack identity for agent graphs, shared ownership of +run budgets across pack/core contracts, production multi-agent and live-core +examples, evaluation harnesses, and pre-1.0 API freeze work. + +## Priority roadmap + +### P0 — Highest leverage + +#### Stronger TypeScript deterministic replay (full state snapshots) + +- **What:** Today `Agent.replay` validates contiguous event sequences, and + `replayDeterministic` re-executes and compares the control-plane event trace + (excluding wall-clock timestamps). That is ordering/kind fidelity, not full + run reconstruction. Extend portable replay so each step can be checked against + recorded state snapshots (revision, schema id, value, provenance), not only + event kinds and sequence numbers. +- **Why it matters:** Consumers building on `@knolo/agents` need to audit and + re-verify local runs without the Rust runtime. Control-plane ordering alone + cannot catch silent state divergence or incomplete patch application. +- **Rough acceptance criteria:** + - Replay fixtures record ordered events **and** per-step (or per-revision) + state snapshots. + - `replayDeterministic` (or a successor API) fails closed when state diverges + even if event kinds still match. + - Tests cover happy path, truncated history, mutated state mid-trace, and + timestamp-insensitive comparison. + - Scope stays within the portable engine capabilities (no fake tool/network + effects inside TypeScript-only replay). + +#### Expose run budgets through shared pack / core contracts + +- **What:** Graph definitions already carry `ExecutionLimitsV1` + (`max_steps`, `max_tokens`, `max_cost_micros`, `timeout_ms`), and both engines + enforce them. Native `.knolo` files may declare `budget.max_steps` and + `budget.max_cost_micros`, but the native parser only validates those fields + and discards them: they are not stored on `PackDeclarationV1` and are not + compiled into pack policy. Tool-level `ResourceBudgetV1` remains separate. +- **Why it matters:** Packs are the least-authority surface. Run budgets that + exist only as graph/runtime fields can be tightened or loosened outside pack + review. Shared contract ownership makes authority inspectable and comparable + across handoffs, packs, and schedulers. +- **Rough acceptance criteria:** + - `PackDeclarationV1` (and JSON companion) include run-limit fields that map + cleanly to graph limits, without conflating tool call budgets and step/cost + budgets. + - Native parse retains and enforces those fields; malformed or zero budgets + fail closed before execution. + - Handoff authority narrowing continues to use the same limit vocabulary. + - Marked **depends on `@knolo/core` / shared contract alignment** if pack + schema ownership lives upstream of this repo. + +#### Full standalone WASM execution path + +- **What:** `knolo-agent-wasm` is a versioned JSON protocol adapter. It accepts + shared graphs and supports **inspect**; run/resume responses currently fail + with “execution requires host node dispatch.” TypeScript `engine: "wasm"` + requires an explicit adapter and never falls back—but the published story is + still inspection + host-dispatched handlers, not a self-contained portable + runtime. +- **Why it matters:** Local-first and embeddable hosts need a complete portable + path that validates graphs, advances state/routing/suspension, and emits the + same event model without shipping the full native host. +- **Rough acceptance criteria:** + - WASM protocol handles `run` / `resume` for the portable capability set + (state, routing, suspension) with host-supplied node results only where the + contract already requires host effects. + - Conformance fixtures shared with `knolo-agent-core` pass under + `wasm32-unknown-unknown` and through the TypeScript WASM adapter. + - Limitations remain explicit in inspection output; tools/retrieval/durable + effects stay host-bound. + - Decide whether `knolo-agent-wasm` remains workspace-validated or becomes a + separately versioned published artifact (see packaging below). + +### P1 — Important before broader adoption + +#### Deeper native `.knolo` support for agent graph / definition identity + +- **What:** Native packs are first-class for authority (tools, namespaces, + capabilities, tool-resource budgets). Agent graph and definition references + remain an **explicit overlay** via `load_agent_native(..., PackAgentReferenceV1)` + because those definitions are owned by core/runtime. Argument constraints are + present on the JSON manifest path but not parsed from the current native + textual format (native loads leave `argument_constraints` empty). +- **Why it matters:** Operators should review one pack artifact that both grants + authority and names the agent surface it applies to, without a second + out-of-band overlay when core can supply stable identity. +- **Rough acceptance criteria:** + - Optional native fields (or a companion core-owned binary store feed) can + supply graph/definition references without moving policy enforcement into + agents. + - Overlay remains supported for development; when both are present, native + authority wins and conflicts fail closed. + - Native packs can express the same tool argument constraints the JSON path + already supports. + - **Depends on `@knolo/core` / `knolo-core-rust` binary store work** as + documented in `docs/packs.md`; agents keep the `PackDeclarationV1` boundary. + +#### Production-quality examples (HITL, suspend/resume, multi-agent, host effects) + +- **What:** `examples/packs/` covers small named scenarios; Rust has + `pack_e2e` and a thin `complete` host-boundary demo; TypeScript + `examples/typescript/complete.ts` exercises interfaces with mocks. Runnable + end-to-end stories for HITL approval loops, durable checkpoint resume, real + host tool injection, and multi-agent handoff **execution** (not only envelope + validation) are still thin. `examples/rust/` is currently empty. +- **Why it matters:** Adoption depends on copy-pasteable, fail-closed examples + that match production trust boundaries—not interface sketches. +- **Rough acceptance criteria:** + - At least one runnable Rust example per major host concern: tools+policy, + HITL suspend/resume with artifact hashes, multi-agent narrowed handoff, + checkpoint store round-trip, deterministic replay of a real event log. + - TypeScript example(s) that resume from a checkpoint and demonstrate + pack-gated definition compilation without inventing provider SDKs. + - Each example uses a least-authority pack from `examples/packs/` and + documents expected deny paths. + +#### Richer multi-agent patterns and ClaimGraph collaboration + +- **What:** Handoff contracts (`HandoffEnvelopeV1`, authority narrowing) and + ClaimGraph injection (`ClaimGraphCapability`, explicit mutation approval) + exist. What is missing are shared, inspectable multi-agent **patterns**: + parent→child→return flows with projected state, collaborative claim + proposals with dual approval, and event/log shapes that make collaboration + auditable. +- **Why it matters:** Multi-agent value in Knolo is least-authority + composition, not free-form agent swarms. Patterns should stay pack-constrained + and replayable. +- **Rough acceptance criteria:** + - One documented pattern for nested handoff with return contract verification. + - One documented ClaimGraph collaboration pattern (propose → approve → commit) + using injected storage only. + - Fixtures and tests prove authority escalation is rejected and commits never + occur without policy or human approval. + +#### Live integration demos with real `@knolo/core` Cortex + ClaimGraph + +- **What:** Cortex and ClaimGraph modules are typed adapters. Examples use + in-process fakes. There is no documented demo wiring a real compatible + `@knolo/core` peer for Cortex query/context and ClaimGraph read/commit. +- **Why it matters:** The core boundary is a product feature; without a live + demo, consumers must invent integration themselves. +- **Rough acceptance criteria:** + - Optional demo or docs path that depends on a published `@knolo/core` + version range (currently documented as `^3.5.0` for TypeScript). + - No vendoring of core source, credentials, or storage into this repository. + - Demo fails closed when core is absent (explicit error, not partial silent + stubs). + - **Depends on `@knolo/core` availability and stable capability APIs.** + +#### Evaluation / scoring harness aligned with the control plane + +- **What:** No first-party eval harness exists. Any scoring should consume + ordered events, artifact hashes, and recorded state—not opaque chat logs. +- **Why it matters:** Governed agents need inspectable evaluation: did the run + stay within pack authority, hit step budgets, produce expected terminal + results, and remain replayable? +- **Rough acceptance criteria:** + - Deterministic fixtures: fixed graphs, packs, host fakes, expected event + traces and outcomes. + - Metrics derived from control-plane artifacts (terminal status, step/cost + usage, policy denials, suspension reasons)—not free-text LLM scores as the + primary signal. + - Unit tests remain network-free; live model scoring, if any, is an optional + host-provided effect with explicit authorization. + +### P2 — Later / optional + +#### Performance and edge benchmarks (WASM + local-first) + +- **What:** No benchmark suite for graph validation, scheduler steps, pack + compile, WASM inspect/run, or checkpoint I/O. +- **Why it matters:** Local-first and WASM paths need known cost floors before + embedding in constrained hosts. +- **Rough direction:** Criterion (or equivalent) benches for hot paths; edge + cases for large graphs, long event logs, and tight step budgets. Keep + benchmarks offline and deterministic. + +#### Packaging, docs, and API stability toward 1.0 + +- **What:** Artifacts version independently (`knolo-agent`, `knolo-agent-core`, + `@knolo/agents`). WASM is workspace-validated, not separately published by + the release workflow. Public APIs are allowed to evolve before 1.0. Docs + still describe early-release limitations that must stay accurate as gaps + close. +- **Why it matters:** Downstream integrators need a clear compatibility matrix, + dry-run publish discipline, and a known freeze bar. +- **Rough direction:** + - Keep release checklist and compatibility matrix current + (`docs/releasing.md`, `docs/compatibility.md`). + - Explicitly list which surfaces are experimental vs stable-on-path-to-1.0. + - Resolve whether WASM is a published crate/package or remains an embedder + adapter only. + - Sync documented versions with published tags; avoid drift between workspace + crate versions and user-facing “early 0.x” messaging. + +#### Optional conveniences (only if they preserve the model) + +- Host SDK helpers for common tool registries and filesystem checkpoint stores + beyond the current minimal implementations. +- Richer redaction rule packs for production event sinks. +- Conformance suite expansion as portable contracts grow (without bloating the + TypeScript engine into a second full runtime). + +#### ICP agent runtime canister (platform target) + +- **What:** Host `knolo-agent-core` + scheduler inside an ICP canister so the + canister is the Host: packs, checkpoints, events, tools, ic-llm / outcalls, + and optional calls to knolo-core knowledge canisters. +- **Status (Phase 0–2 landed):** Workspace crate `knolo-agent-icp`, ADR-001, + constraints matrix, deterministic Candid control plane, pack-gated tools, + ic-llm suspend/resume, knowledge retrieval principal, timers for + `auto_continue`, cycles + Knolo budget snapshot (`get_budget`), + `examples/icp-agent-canister` dfx smoke. Release Wasm ~1.52 MiB. +- **Next (Phase 3+):** `ic-stable-structures` upgrade-safe schemas, security + hardening, multi-agent handoff, DX/CLI templates. +- **Docs:** `docs/architecture/adr-001-icp-agent-runtime.md`, + `docs/architecture/icp-constraints-matrix.md`. Local planning notes in + `.plans/` (gitignored). + +## Explicit non-goals (for now) + +Knolo Agents is deliberately **not** trying to become: + +- A LangChain-style provider/orchestration framework with implicit tool discovery + or hidden network access. +- A model provider, vector database, job queue, or application data layer. +- A place that vendors, re-exports, or ships `@knolo/core` storage + implementations or credentials. +- A non-deterministic “agent swarm” runtime without pack authority, ordered + events, or inspectable handoffs. +- A silent multi-engine fallback layer (TypeScript ↔ WASM ↔ Rust must remain + explicit). +- A general-purpose WASM sandbox that grants filesystem, network, or clock + authority by default. + +## Notes + +### Dependencies on `@knolo/core` + +| Future item | Core dependency | +| --- | --- | +| Live Cortex / ClaimGraph demos | Requires compatible published core APIs and peer install. | +| Deeper native pack graph/definition identity | Prefer core-owned binary store or stable definition ids feeding `PackDeclarationV1` / agent references; agents must not absorb storage. | +| Shared run budgets on pack contracts | May require coordinated pack schema version if core owns pack serialization. | +| Evaluation harness | May optionally score core-backed retrieval/claims evidence; harness itself stays in agents or a separate package. | + +### Sequencing constraints + +1. **Shared budget contracts** before treating pack-level `max_steps` / + `max_cost_micros` as authoritative policy (today they are validated and + dropped on the native path). +2. **TypeScript state-snapshot replay** before marketing TS as fully + audit-equivalent for portable graphs. +3. **WASM execute/resume** after portable contracts and conformance fixtures are + the single source of truth (avoid a second graph semantics). +4. **Production examples** after pack/budget contracts stabilize enough that + examples will not thrash. +5. **1.0 freeze** only after P0 items and compatibility docs match shipped + behavior. + +### Already in good shape (do not re-list as missing) + +These are present and should be extended carefully rather than rewritten: + +- Graph validation and content hashing for resume/replay compatibility. +- Rust scheduler limits, retries, checkpoints, and fail-closed resume hash + checks. +- Pack compile → policy for tools/namespaces/capability bindings. +- Replay modes (`verify_only`, `mocked_effects`, `live_effects`) with live + effects requiring explicit authorization. +- HITL suspension tokens bound to artifact hashes and resume schema. +- Authority-narrowing handoff envelopes. +- Explicit TypeScript/WASM engine selection with no silent fallback. +- Least-authority example packs and the `pack_e2e` allowed/denied/replay path. + +When closing a gap, update this file, the README “Current status and +limitations” section, and the relevant `docs/` page in the same change. diff --git a/README.md b/README.md index 8ea9aaf..e8c5c1d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ job queue, or application-specific data layer. | `knolo-agent-core` | Portable contracts, graph/state validation, policy types, events, replay, checkpoints, and pack declarations. | | `knolo-agent` | Native Rust scheduler, host effect boundaries, policy enforcement, pack loading, and durable runtime integrations. | | `knolo-agent-wasm` | Small JSON/WASM protocol adapter for embedding the portable contracts. Not currently published separately. | +| `knolo-agent-icp` | ICP canister host for the control plane (Phase 1 PoC: deterministic graphs, checkpoints, events). Workspace-only. | | `@knolo/agents` | Typed TypeScript builders, the deterministic state/routing/suspension engine, and explicit WASM integration. | | `@knolo/core` | Separate peer dependency owned by the consumer; it can provide Cortex and ClaimGraph implementations. | @@ -78,6 +79,7 @@ or WASM engine explicitly. - [Rust runtime examples](crates/knolo-agent/examples/) - [TypeScript example](examples/typescript/complete.ts) +- [ICP agent runtime example](examples/icp-agent-canister/) - [Pack declarations](examples/packs/) - [Documentation index](docs/README.md) - [Release checklist](docs/releasing.md) diff --git a/crates/knolo-agent-icp/Cargo.toml b/crates/knolo-agent-icp/Cargo.toml new file mode 100644 index 0000000..e2dadce --- /dev/null +++ b/crates/knolo-agent-icp/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "knolo-agent-icp" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +description = "ICP canister host for the Knolo agent control plane." +publish = false +repository = "https://github.com/knolo-ai/knolo-agents" + +[lib] +crate-type = ["cdylib", "rlib"] +path = "src/lib.rs" + +[dependencies] +candid = "0.10" +ic-cdk = "0.17" +ic-cdk-macros = "0.17" +ic-cdk-timers = "0.11" +ic-llm = "1.1" +knolo-agent = { workspace = true } +knolo-agent-core = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } diff --git a/crates/knolo-agent-icp/candid/agent_runtime.did b/crates/knolo-agent-icp/candid/agent_runtime.did new file mode 100644 index 0000000..b2d7651 --- /dev/null +++ b/crates/knolo-agent-icp/candid/agent_runtime.did @@ -0,0 +1,78 @@ +type HealthDto = record { + ok : bool; + message : text; +}; + +type InspectionDto = record { + ok : bool; + engine : text; + graph_loaded : bool; + graph_id : opt text; + graph_hash : opt text; + implementation_id : opt text; + execution_count : nat64; + capabilities : vec text; + limitations : vec text; + message : text; +}; + +type StatusDto = record { + kind : text; + detail : text; +}; + +type RunReportDto = record { + ok : bool; + execution_id : text; + status : StatusDto; + steps : nat64; + tokens : nat64; + cost_micros : nat64; + state_json : text; + event_count : nat64; + message : text; +}; + +type EventsDto = record { + ok : bool; + execution_id : text; + events_json : text; + message : text; +}; + +type CheckpointDto = record { + ok : bool; + execution_id : text; + present : bool; + checkpoint_json : text; + message : text; +}; + +type BudgetDto = record { + ok : bool; + tool_calls : nat64; + tool_units : nat64; + llm_calls : nat64; + retrieval_calls : nat64; + effect_rounds : nat64; + knolo_steps : nat64; + knolo_tokens : nat64; + knolo_cost_micros : nat64; + cycles_spent_observed : nat64; + last_cycles_balance : opt nat64; + message : text; +}; + +service : { + health : () -> (HealthDto) query; + inspect : () -> (InspectionDto) query; + get_budget : () -> (BudgetDto) query; + load_definition : (text) -> (HealthDto); + clear_definition : () -> (HealthDto); + start_execution : (text, text) -> (RunReportDto); + step : (text, nat32) -> (RunReportDto); + resume : (text) -> (RunReportDto); + continue_effects : (text) -> (RunReportDto); + get_events : (text) -> (EventsDto) query; + get_checkpoint : (text) -> (CheckpointDto) query; +} diff --git a/crates/knolo-agent-icp/src/budget.rs b/crates/knolo-agent-icp/src/budget.rs new file mode 100644 index 0000000..8486fa8 --- /dev/null +++ b/crates/knolo-agent-icp/src/budget.rs @@ -0,0 +1,80 @@ +//! Knolo budget ledger + cycles observation for the ICP host. +use knolo_agent::policy::BudgetLedger; +use knolo_agent_core::pack::CompiledPolicyV1; +use serde::{Deserialize, Serialize}; + +/// Dual view: Knolo resource usage + optional cycles observations. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct HostBudgetSnapshotV1 { + pub tool_calls: u64, + pub tool_units: u64, + pub tool_duration_ms: u64, + pub llm_calls: u64, + pub retrieval_calls: u64, + pub effect_rounds: u64, + /// Cycles observed at last effect boundary (when available). + pub last_cycles_balance: Option, + /// Sum of measured cycle deltas across effect calls (best-effort). + pub cycles_spent_observed: u128, + pub knolo_cost_micros: u64, + pub knolo_tokens: u64, + pub knolo_steps: u64, +} + +#[derive(Debug, Default)] +pub struct HostBudgetTracker { + pub ledger: BudgetLedger, + pub snapshot: HostBudgetSnapshotV1, +} + +impl HostBudgetTracker { + pub fn note_llm(&mut self, tokens: u64, cost_micros: u64) { + self.snapshot.llm_calls = self.snapshot.llm_calls.saturating_add(1); + self.snapshot.knolo_tokens = self.snapshot.knolo_tokens.saturating_add(tokens); + self.snapshot.knolo_cost_micros = + self.snapshot.knolo_cost_micros.saturating_add(cost_micros); + } + + pub fn note_retrieval(&mut self, cost_micros: u64) { + self.snapshot.retrieval_calls = self.snapshot.retrieval_calls.saturating_add(1); + self.snapshot.knolo_cost_micros = + self.snapshot.knolo_cost_micros.saturating_add(cost_micros); + } + + pub fn note_tool_usage(&mut self, calls: u64, units: u64, duration_ms: u64) { + self.snapshot.tool_calls = self.snapshot.tool_calls.saturating_add(calls); + self.snapshot.tool_units = self.snapshot.tool_units.saturating_add(units); + self.snapshot.tool_duration_ms = self.snapshot.tool_duration_ms.saturating_add(duration_ms); + } + + pub fn note_effect_round(&mut self) { + self.snapshot.effect_rounds = self.snapshot.effect_rounds.saturating_add(1); + } + + pub fn note_cycles_delta(&mut self, before: u128, after: u128) { + self.snapshot.last_cycles_balance = Some(after); + if before >= after { + self.snapshot.cycles_spent_observed = self + .snapshot + .cycles_spent_observed + .saturating_add(before - after); + } + } + + pub fn sync_run_totals(&mut self, steps: u64, tokens: u64, cost_micros: u64) { + self.snapshot.knolo_steps = steps; + // Prefer max so effect notes are not wiped by lower scheduler totals. + self.snapshot.knolo_tokens = self.snapshot.knolo_tokens.max(tokens); + self.snapshot.knolo_cost_micros = self.snapshot.knolo_cost_micros.max(cost_micros); + } + + pub fn policy_budget_exhausted(&self, policy: Option<&CompiledPolicyV1>) -> bool { + let Some(p) = policy else { + return false; + }; + let b = p.budget(); + self.snapshot.tool_calls >= b.max_calls + || self.snapshot.tool_units > b.max_units + || self.snapshot.tool_duration_ms > b.max_duration_ms + } +} diff --git a/crates/knolo-agent-icp/src/definition.rs b/crates/knolo-agent-icp/src/definition.rs new file mode 100644 index 0000000..bc0c908 --- /dev/null +++ b/crates/knolo-agent-icp/src/definition.rs @@ -0,0 +1,153 @@ +//! Versioned agent definition bundles loaded into the canister. +use knolo_agent_core::{ + graph::{CompiledGraphV1, GraphDefinitionV1}, + pack::{CompiledPolicyV1, PackDeclarationV1}, + state::StateSchemaV1, + CoreError, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// Soft ceiling for a single definition JSON payload (ingress-friendly). +pub const MAX_DEFINITION_BYTES: usize = 2 * 1024 * 1024; + +/// Host / effects configuration (Phase 2). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct HostConfigV1 { + /// When true, Suspend `step_slice` schedules a timer continuation. + #[serde(default)] + pub auto_continue: bool, + /// Timer delay for auto-continue (nanoseconds). Default 1s. + #[serde(default = "default_timer_ns")] + pub timer_ns: u64, + /// Prefer ic-llm for nodes that request LLM effects. + #[serde(default = "default_true")] + pub llm_enabled: bool, + /// Model id string for documentation; mapped to `ic_llm::Model` when known. + #[serde(default = "default_model")] + pub llm_model: String, + /// Optional knowledge canister principal (text). Empty = retrieval disabled. + #[serde(default)] + pub knowledge_canister: Option, + /// Allow HTTPS outcall tools (still pack-gated). + #[serde(default)] + pub allow_https_tools: bool, + /// Max effect resolutions per `start`/`continue_effects` call (instruction guard). + #[serde(default = "default_max_effect_rounds")] + pub max_effect_rounds: u32, +} + +impl Default for HostConfigV1 { + fn default() -> Self { + Self { + auto_continue: false, + timer_ns: default_timer_ns(), + llm_enabled: true, + llm_model: default_model(), + knowledge_canister: None, + allow_https_tools: false, + max_effect_rounds: default_max_effect_rounds(), + } + } +} + +fn default_timer_ns() -> u64 { + 1_000_000_000 +} +fn default_true() -> bool { + true +} +fn default_model() -> String { + "llama3.1:8b".into() +} +fn default_max_effect_rounds() -> u32 { + 8 +} + +/// JSON envelope accepted by `load_definition`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AgentDefinitionBundleV1 { + pub version: u16, + /// Built-in node implementation id. + pub implementation_id: String, + #[serde(default = "default_hash")] + pub pack_hash: String, + #[serde(default = "default_hash")] + pub policy_hash: String, + #[serde(default = "default_hash")] + pub contract_hash: String, + pub graph: GraphDefinitionV1, + pub schema: StateSchemaV1, + /// Optional least-authority pack (tools / namespaces / budgets). + #[serde(default)] + pub pack: Option, + #[serde(default)] + pub host: HostConfigV1, +} + +fn default_hash() -> String { + "none".into() +} + +#[derive(Debug, Clone)] +pub struct LoadedDefinition { + pub bundle: AgentDefinitionBundleV1, + pub compiled: CompiledGraphV1, + pub node_implementation_hash: String, + pub definition_json: String, + pub policy: Option, +} + +impl AgentDefinitionBundleV1 { + pub fn parse(json: &str) -> Result { + if json.is_empty() { + return Err(CoreError::Host("definition JSON was empty".into())); + } + if json.len() > MAX_DEFINITION_BYTES { + return Err(CoreError::Host(format!( + "definition is too large: {} bytes exceeds the {} byte limit", + json.len(), + MAX_DEFINITION_BYTES + ))); + } + let bundle: Self = serde_json::from_str(json) + .map_err(|e| CoreError::Host(format!("invalid definition JSON: {e}")))?; + if bundle.version != 1 { + return Err(CoreError::Host(format!( + "unsupported definition version {}", + bundle.version + ))); + } + if bundle.implementation_id.trim().is_empty() { + return Err(CoreError::Host( + "implementation_id must be non-empty".into(), + )); + } + if bundle.schema.id.as_str() != bundle.graph.state_schema.as_str() { + return Err(CoreError::Host( + "schema.id must match graph.state_schema".into(), + )); + } + Ok(bundle) + } + + pub fn load(json: &str) -> Result { + let bundle = Self::parse(json)?; + let compiled = bundle.graph.compile()?; + let node_implementation_hash = + format!("{:x}", Sha256::digest(bundle.implementation_id.as_bytes())); + let policy = match &bundle.pack { + Some(pack) => Some(pack.compile().map_err(|e| CoreError::PackLoad(e))?), + None => None, + }; + Ok(LoadedDefinition { + bundle, + compiled, + node_implementation_hash, + definition_json: json.to_owned(), + policy, + }) + } +} diff --git a/crates/knolo-agent-icp/src/dto.rs b/crates/knolo-agent-icp/src/dto.rs new file mode 100644 index 0000000..15a2f8e --- /dev/null +++ b/crates/knolo-agent-icp/src/dto.rs @@ -0,0 +1,152 @@ +//! Candid DTOs for the agent runtime canister surface. +use crate::budget::HostBudgetSnapshotV1; +use crate::engine::ExecutionRecord; +use candid::CandidType; +use serde::Deserialize; + +#[derive(CandidType, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct HealthDto { + pub ok: bool, + pub message: String, +} + +#[derive(CandidType, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct InspectionDto { + pub ok: bool, + pub engine: String, + pub graph_loaded: bool, + pub graph_id: Option, + pub graph_hash: Option, + pub implementation_id: Option, + pub execution_count: u64, + pub capabilities: Vec, + pub limitations: Vec, + pub message: String, +} + +#[derive(CandidType, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct StatusDto { + pub kind: String, + pub detail: String, +} + +#[derive(CandidType, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct RunReportDto { + pub ok: bool, + pub execution_id: String, + pub status: StatusDto, + pub steps: u64, + pub tokens: u64, + pub cost_micros: u64, + pub state_json: String, + pub event_count: u64, + pub message: String, +} + +#[derive(CandidType, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct EventsDto { + pub ok: bool, + pub execution_id: String, + pub events_json: String, + pub message: String, +} + +#[derive(CandidType, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct CheckpointDto { + pub ok: bool, + pub execution_id: String, + pub present: bool, + pub checkpoint_json: String, + pub message: String, +} + +#[derive(CandidType, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct BudgetDto { + pub ok: bool, + pub tool_calls: u64, + pub tool_units: u64, + pub llm_calls: u64, + pub retrieval_calls: u64, + pub effect_rounds: u64, + pub knolo_steps: u64, + pub knolo_tokens: u64, + pub knolo_cost_micros: u64, + pub cycles_spent_observed: u64, + pub last_cycles_balance: Option, + pub message: String, +} + +impl HealthDto { + pub fn ok(message: impl Into) -> Self { + Self { + ok: true, + message: message.into(), + } + } + pub fn err(message: impl Into) -> Self { + Self { + ok: false, + message: message.into(), + } + } +} + +impl From<&ExecutionRecord> for RunReportDto { + fn from(r: &ExecutionRecord) -> Self { + let state_json = serde_json::to_string(&r.state).unwrap_or_else(|_| "{}".into()); + Self { + ok: r.status_kind != "failed", + execution_id: r.execution_id.clone(), + status: StatusDto { + kind: r.status_kind.clone(), + detail: r.status_detail.clone(), + }, + steps: r.steps, + tokens: r.tokens, + cost_micros: r.cost_micros, + state_json, + event_count: r.events.len() as u64, + message: format!("status={}", r.status_kind), + } + } +} + +impl RunReportDto { + pub fn err(execution_id: impl Into, message: impl Into) -> Self { + Self { + ok: false, + execution_id: execution_id.into(), + status: StatusDto { + kind: "error".into(), + detail: String::new(), + }, + steps: 0, + tokens: 0, + cost_micros: 0, + state_json: "{}".into(), + event_count: 0, + message: message.into(), + } + } +} + +impl From<&HostBudgetSnapshotV1> for BudgetDto { + fn from(s: &HostBudgetSnapshotV1) -> Self { + Self { + ok: true, + tool_calls: s.tool_calls, + tool_units: s.tool_units, + llm_calls: s.llm_calls, + retrieval_calls: s.retrieval_calls, + effect_rounds: s.effect_rounds, + knolo_steps: s.knolo_steps, + knolo_tokens: s.knolo_tokens, + knolo_cost_micros: s.knolo_cost_micros, + cycles_spent_observed: s.cycles_spent_observed.min(u64::MAX as u128) as u64, + last_cycles_balance: s + .last_cycles_balance + .map(|v| v.min(u64::MAX as u128) as u64), + message: "budget snapshot".into(), + } + } +} diff --git a/crates/knolo-agent-icp/src/effects.rs b/crates/knolo-agent-icp/src/effects.rs new file mode 100644 index 0000000..dc6996f --- /dev/null +++ b/crates/knolo-agent-icp/src/effects.rs @@ -0,0 +1,206 @@ +//! Async host effect resolution (LLM, tools, retrieval, timers). +use crate::engine::{AgentEngine, ExecutionRecord}; +use crate::knowledge::{mock_retrieve, parse_principal, retrieve_from_canister}; +use knolo_agent_core::{retrieval::RetrievalQueryV1, CoreError}; +use serde_json::{json, Value}; + +/// Resolve a single pending host effect if the execution is suspended for one. +pub async fn resolve_one_effect( + engine: &mut AgentEngine, + execution_id: &str, +) -> Result, CoreError> { + let status = engine + .executions + .get(execution_id) + .map(|r| { + ( + r.status_kind.clone(), + r.status_detail.clone(), + r.state.clone(), + ) + }) + .ok_or_else(|| CoreError::Host(format!("unknown execution '{execution_id}'")))?; + + if status.0 != "suspended" { + return Ok(None); + } + + engine.budget.note_effect_round(); + let before = cycles_balance(); + + let record = match status.1.as_str() { + "await_llm" => { + let prompt = status + .2 + .value + .pointer("/prompt") + .and_then(Value::as_str) + .unwrap_or("Say hello from Knolo ICP agent.") + .to_string(); + let (text, tokens, cost) = llm_prompt(&prompt).await?; + engine.budget.note_llm(tokens, cost); + let value = json!({ + "text": text, + "tokens": tokens, + "cost_micros": cost, + "provider": "ic-llm", + }); + engine.inject_effect_and_resume(execution_id, "llm", value)? + } + "await_tool" => { + let value = engine.run_tool_for_pending(execution_id)?; + engine.inject_effect_and_resume(execution_id, "tool", value)? + } + "await_retrieve" => { + let q_text = status + .2 + .value + .pointer("/query") + .and_then(Value::as_str) + .unwrap_or("alpha") + .to_string(); + let limit = status + .2 + .value + .pointer("/limit") + .and_then(Value::as_u64) + .unwrap_or(5) as u32; + let query = RetrievalQueryV1 { + version: 1, + text: q_text, + limit, + }; + let result = match engine + .definition + .as_ref() + .and_then(|d| d.bundle.host.knowledge_canister.as_ref()) + { + Some(p) if !p.is_empty() => { + let principal = parse_principal(p)?; + retrieve_from_canister(principal, &query).await? + } + _ => mock_retrieve(&query), + }; + engine.budget.note_retrieval(5); + let value = serde_json::to_value(&result) + .map_err(|e| CoreError::Host(format!("serialize retrieval: {e}")))?; + engine.inject_effect_and_resume(execution_id, "retrieve", value)? + } + "step_slice" => { + // Timer path schedules externally; here we just resume one more slice. + engine.step(execution_id, 1)? + } + _ => return Ok(None), + }; + + let after = cycles_balance(); + if let (Some(b), Some(a)) = (before, after) { + engine.budget.note_cycles_delta(b, a); + } + Ok(Some(record)) +} + +/// Drain automatic host effects until terminal, HITL, max rounds, or step_slice with timer. +#[allow(dead_code)] +pub async fn resolve_effects_loop( + engine: &mut AgentEngine, + execution_id: &str, +) -> Result { + let max_rounds = engine + .definition + .as_ref() + .map(|d| d.bundle.host.max_effect_rounds) + .unwrap_or(8); + + for _ in 0..max_rounds { + let current = engine + .executions + .get(execution_id) + .cloned() + .ok_or_else(|| CoreError::Host(format!("unknown execution '{execution_id}'")))?; + + if current.status_kind != "suspended" { + return Ok(current); + } + + // Leave HITL for human resume. + if current.status_detail == "hitl_approval" { + return Ok(current); + } + + // step_slice with auto_continue: schedule timer and return (canister layer). + if current.status_detail == "step_slice" { + let auto = engine + .definition + .as_ref() + .map(|d| d.bundle.host.auto_continue) + .unwrap_or(false); + if auto { + return Ok(current); + } + } + + match resolve_one_effect(engine, execution_id).await? { + Some(_) => continue, + None => { + return engine + .executions + .get(execution_id) + .cloned() + .ok_or_else(|| CoreError::Host("execution disappeared".into())); + } + } + } + + engine + .executions + .get(execution_id) + .cloned() + .ok_or_else(|| CoreError::Host("execution disappeared after effect rounds".into())) +} + +async fn llm_prompt(prompt: &str) -> Result<(String, u64, u64), CoreError> { + #[cfg(target_arch = "wasm32")] + { + use ic_llm::Model; + let model = Model::Llama3_1_8B; + let text = ic_llm::prompt(model, prompt).await; + let tokens = (text.len() as u64 / 4).max(1); + Ok((text, tokens, tokens.saturating_mul(10))) + } + #[cfg(not(target_arch = "wasm32"))] + { + // Deterministic mock for unit tests (no network). + let text = format!("mock-llm-response: {prompt}"); + let tokens = 4; + Ok((text, tokens, 40)) + } +} + +fn cycles_balance() -> Option { + #[cfg(target_arch = "wasm32")] + { + Some(ic_cdk::api::canister_balance128()) + } + #[cfg(not(target_arch = "wasm32"))] + { + None + } +} + +/// Schedule a one-shot timer that resumes `execution_id` (wasm only). +pub fn schedule_auto_continue(execution_id: String, delay_ns: u64) { + #[cfg(target_arch = "wasm32")] + { + use std::time::Duration; + let _ = ic_cdk_timers::set_timer(Duration::from_nanos(delay_ns), move || { + ic_cdk::spawn(async move { + let _ = crate::timer_continue_execution(execution_id).await; + }); + }); + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (execution_id, delay_ns); + } +} diff --git a/crates/knolo-agent-icp/src/engine.rs b/crates/knolo-agent-icp/src/engine.rs new file mode 100644 index 0000000..99c733d --- /dev/null +++ b/crates/knolo-agent-icp/src/engine.rs @@ -0,0 +1,613 @@ +//! Pure control-plane engine: load definition, start/step/resume, inject effects. +use crate::budget::HostBudgetTracker; +use crate::definition::LoadedDefinition; +use crate::executor::{is_host_effect_suspend, DeterministicExecutor}; +use crate::host::{empty_sink, empty_store, fixed_clock}; +use crate::tools_host::{default_registry, execute_tool_call}; +use knolo_agent::host::ToolRegistry; +use knolo_agent::runtime::{ + ExecutionReportV1, ExecutionStatusV1, RuntimePolicyV1, Scheduler, VecEventSink, +}; +use knolo_agent_core::{ + checkpoint::CheckpointV1, event::ExecutionEventV1, node::CheckpointStore, + pack::CompiledPolicyV1, state::StateSnapshot, CoreError, ExecutionId, NodeId, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::str::FromStr; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ExecutionRecord { + pub execution_id: String, + pub status_kind: String, + pub status_detail: String, + pub steps: u64, + pub tokens: u64, + pub cost_micros: u64, + pub state: StateSnapshot, + pub events: Vec, + pub last_checkpoint: Option, + pub pending_resume: Option, + #[serde(default)] + pub effect_cache: BTreeMap, + #[serde(default)] + pub timer_scheduled: bool, +} + +#[derive(Default)] +pub struct AgentEngine { + pub definition: Option, + pub executions: BTreeMap, + pub store: knolo_agent::checkpoint::InMemoryCheckpointStore, + pub budget: HostBudgetTracker, + pub tools: ToolRegistry, +} + +impl AgentEngine { + pub fn load_definition(&mut self, json: &str) -> Result { + let loaded = crate::definition::AgentDefinitionBundleV1::load(json)?; + let allow_https = loaded.bundle.host.allow_https_tools; + let msg = format!( + "Loaded graph '{}' with implementation '{}'.", + loaded.compiled.definition().id, + loaded.bundle.implementation_id + ); + self.definition = Some(loaded); + self.executions.clear(); + self.store = empty_store(); + self.budget = HostBudgetTracker::default(); + self.tools = default_registry(allow_https); + Ok(msg) + } + + pub fn clear_definition(&mut self) -> String { + let had = self.definition.take().is_some(); + self.executions.clear(); + self.store = empty_store(); + self.budget = HostBudgetTracker::default(); + self.tools = ToolRegistry::default(); + if had { + "Definition cleared.".into() + } else { + "No definition was loaded.".into() + } + } + + pub fn policy(&self) -> Option<&CompiledPolicyV1> { + self.definition.as_ref().and_then(|d| d.policy.as_ref()) + } + + pub fn start_execution( + &mut self, + execution_id: &str, + initial_state_json: &str, + ) -> Result { + let state = parse_state(initial_state_json)?; + let id = ExecutionId::from_str(execution_id) + .map_err(|e| CoreError::Host(format!("invalid execution_id: {e}")))?; + if self.executions.contains_key(execution_id) { + return Err(CoreError::Host(format!( + "execution '{execution_id}' already exists" + ))); + } + let report = self.run_from_start(&id, state, None, BTreeMap::new())?; + let record = self.record_from_report(execution_id, report, BTreeMap::new())?; + self.budget + .sync_run_totals(record.steps, record.tokens, record.cost_micros); + self.executions + .insert(execution_id.to_owned(), record.clone()); + Ok(record) + } + + pub fn step( + &mut self, + execution_id: &str, + max_node_steps: u32, + ) -> Result { + let existing = self + .executions + .get(execution_id) + .cloned() + .ok_or_else(|| CoreError::Host(format!("unknown execution '{execution_id}'")))?; + + if is_terminal(&existing.status_kind) { + return Err(CoreError::Host(format!( + "execution '{execution_id}' is already terminal ({})", + existing.status_kind + ))); + } + + let budget = if max_node_steps == 0 { + None + } else { + Some(max_node_steps) + }; + + if existing.status_kind == "suspended" + && !is_host_effect_suspend(&existing.status_detail) + && existing.status_detail != "hitl_approval" + { + return Err(CoreError::Host(format!( + "execution is suspended for '{}'; use resume after host input", + existing.status_detail + ))); + } + + if existing.status_kind == "suspended" + && existing.status_detail == "hitl_approval" + && max_node_steps != 0 + { + return Err(CoreError::Host( + "HITL suspension requires resume(), not step()".into(), + )); + } + + let checkpoint = self.checkpoint_for(&existing, execution_id)?; + let id = ExecutionId::from_str(execution_id) + .map_err(|e| CoreError::Host(format!("invalid execution_id: {e}")))?; + let report = self.run_resume( + &id, + checkpoint, + budget, + false, + existing.effect_cache.clone(), + )?; + let record = self.merge_step_report(execution_id, &existing, report)?; + self.budget + .sync_run_totals(record.steps, record.tokens, record.cost_micros); + self.executions + .insert(execution_id.to_owned(), record.clone()); + Ok(record) + } + + pub fn resume(&mut self, execution_id: &str) -> Result { + let existing = self + .executions + .get(execution_id) + .cloned() + .ok_or_else(|| CoreError::Host(format!("unknown execution '{execution_id}'")))?; + + let hitl_approved = + existing.status_kind == "suspended" && existing.status_detail == "hitl_approval"; + + let checkpoint = if hitl_approved { + self.checkpoint_for_hitl_resume(execution_id, &existing)? + } else { + self.checkpoint_for(&existing, execution_id)? + }; + + let id = ExecutionId::from_str(execution_id) + .map_err(|e| CoreError::Host(format!("invalid execution_id: {e}")))?; + let report = self.run_resume( + &id, + checkpoint, + None, + hitl_approved, + existing.effect_cache.clone(), + )?; + let mut record = self.merge_step_report(execution_id, &existing, report)?; + record.timer_scheduled = false; + self.budget + .sync_run_totals(record.steps, record.tokens, record.cost_micros); + self.executions + .insert(execution_id.to_owned(), record.clone()); + Ok(record) + } + + /// Inject a host effect result and resume the suspended node. + pub fn inject_effect_and_resume( + &mut self, + execution_id: &str, + effect: &str, + value: Value, + ) -> Result { + let mut existing = self + .executions + .get(execution_id) + .cloned() + .ok_or_else(|| CoreError::Host(format!("unknown execution '{execution_id}'")))?; + if existing.status_kind != "suspended" { + return Err(CoreError::Host(format!( + "execution is not suspended (status={})", + existing.status_kind + ))); + } + let expected = match effect { + "llm" => "await_llm", + "tool" => "await_tool", + "retrieve" => "await_retrieve", + other => { + return Err(CoreError::Host(format!("unknown effect kind '{other}'"))); + } + }; + if existing.status_detail != expected { + return Err(CoreError::Host(format!( + "execution suspended for '{}' but inject is for '{expected}'", + existing.status_detail + ))); + } + existing.effect_cache.insert(effect.into(), value); + self.executions + .insert(execution_id.to_owned(), existing.clone()); + self.resume(execution_id) + } + + /// Execute pack-gated tool into effect cache payload (does not resume). + pub fn run_tool_for_pending(&mut self, execution_id: &str) -> Result { + let existing = self + .executions + .get(execution_id) + .ok_or_else(|| CoreError::Host(format!("unknown execution '{execution_id}'")))?; + if existing.status_detail != "await_tool" { + return Err(CoreError::Host("not awaiting tool".into())); + } + let tool_id = existing + .state + .value + .pointer("/tool_id") + .and_then(Value::as_str) + .unwrap_or("echo"); + let arguments = existing + .state + .value + .pointer("/tool_args") + .cloned() + .unwrap_or_else(|| serde_json::json!({ "message": "hello-from-icp" })); + let policy = self.policy().cloned(); + let result = execute_tool_call( + &mut self.tools, + policy.as_ref(), + &mut self.budget.ledger, + tool_id, + arguments, + &format!("{execution_id}-tool"), + )?; + self.budget.note_tool_usage( + result.usage.calls, + result.usage.units, + result.usage.duration_ms, + ); + Ok(serde_json::json!({ + "tool_id": result.tool_id.as_str(), + "call_id": result.call_id, + "value": result.value, + "usage": { + "calls": result.usage.calls, + "units": result.usage.units, + "duration_ms": result.usage.duration_ms, + }, + "cost_micros": result.usage.units.saturating_mul(10), + })) + } + + fn checkpoint_for( + &self, + existing: &ExecutionRecord, + execution_id: &str, + ) -> Result { + if let Some(cp) = existing + .pending_resume + .clone() + .or_else(|| existing.last_checkpoint.clone()) + { + return Ok(cp); + } + if let Ok(id) = ExecutionId::from_str(execution_id) { + if let Ok(Some(cp)) = self.store.load(&id) { + return Ok(cp); + } + } + // Synthesize for first-node effect suspends. + self.synthesize_suspend_checkpoint(execution_id, existing) + } + + fn synthesize_suspend_checkpoint( + &self, + execution_id: &str, + existing: &ExecutionRecord, + ) -> Result { + let def = self + .definition + .as_ref() + .ok_or_else(|| CoreError::Host("no definition loaded".into()))?; + let pending = existing + .events + .iter() + .rev() + .find_map(|e| e.node_id.clone()) + .unwrap_or_else(|| def.compiled.definition().entry.clone()); + let id = ExecutionId::from_str(execution_id) + .map_err(|e| CoreError::Host(format!("invalid execution_id: {e}")))?; + Ok(CheckpointV1 { + version: 1, + execution_id: id, + graph_hash: def.compiled.hash().into(), + pack_hash: def.bundle.pack_hash.clone(), + policy_hash: def.bundle.policy_hash.clone(), + node_implementation_hash: def.node_implementation_hash.clone(), + contract_hash: def.bundle.contract_hash.clone(), + state: existing.state.clone(), + pending_node: pending, + event_cursor: existing.events.last().map(|e| e.sequence + 1).unwrap_or(1), + steps: existing.steps, + tokens: existing.tokens, + cost_micros: existing.cost_micros, + }) + } + + fn checkpoint_for_hitl_resume( + &self, + execution_id: &str, + existing: &ExecutionRecord, + ) -> Result { + let mut cp = self.checkpoint_for(existing, execution_id)?; + if cp.pending_node.as_str() != "await_human" { + cp.pending_node = + NodeId::from_str("await_human").map_err(|e| CoreError::Host(e.to_string()))?; + cp.state = existing.state.clone(); + cp.steps = existing.steps; + cp.tokens = existing.tokens; + cp.cost_micros = existing.cost_micros; + cp.event_cursor = existing.events.last().map(|e| e.sequence + 1).unwrap_or(1); + } + Ok(cp) + } + + fn run_from_start( + &mut self, + id: &ExecutionId, + state: StateSnapshot, + step_budget: Option, + effect_cache: BTreeMap, + ) -> Result { + let def = self + .definition + .as_ref() + .ok_or_else(|| CoreError::Host("no definition loaded".into()))?; + let policy = policy_from(def); + let mut executor = DeterministicExecutor::new(def.bundle.implementation_id.clone()) + .with_effect_cache(effect_cache); + if let Some(n) = step_budget { + executor = executor.with_step_budget(n); + } + let mut sink = empty_sink(); + let clock = fixed_clock(); + let mut store = std::mem::take(&mut self.store); + let report = { + let mut scheduler = Scheduler::new( + &def.compiled, + &def.bundle.schema, + &mut executor, + &mut sink, + &clock, + &mut store, + policy, + ); + scheduler.run(id.clone(), state, || false) + }; + self.store = store; + report + } + + fn run_resume( + &mut self, + _id: &ExecutionId, + checkpoint: CheckpointV1, + step_budget: Option, + hitl_approved: bool, + effect_cache: BTreeMap, + ) -> Result { + let def = self + .definition + .as_ref() + .ok_or_else(|| CoreError::Host("no definition loaded".into()))?; + let policy = policy_from(def); + let mut executor = DeterministicExecutor::new(def.bundle.implementation_id.clone()) + .with_hitl_approved(hitl_approved) + .with_effect_cache(effect_cache); + if let Some(n) = step_budget { + executor = executor.with_step_budget(n); + } + let mut sink = empty_sink(); + let clock = fixed_clock(); + let mut store = std::mem::take(&mut self.store); + let report = { + let mut scheduler = Scheduler::new( + &def.compiled, + &def.bundle.schema, + &mut executor, + &mut sink, + &clock, + &mut store, + policy, + ); + scheduler.resume(checkpoint, || false) + }; + self.store = store; + report + } + + fn record_from_report( + &self, + execution_id: &str, + report: ExecutionReportV1, + effect_cache: BTreeMap, + ) -> Result { + let (status_kind, status_detail) = status_parts(&report.status); + let id = ExecutionId::from_str(execution_id) + .map_err(|e| CoreError::Host(format!("invalid execution_id: {e}")))?; + let last_checkpoint = self.store.load(&id)?; + let pending_resume = if status_kind == "suspended" && is_host_effect_suspend(&status_detail) + { + last_checkpoint.clone().or_else(|| { + self.synthesize_suspend_checkpoint( + execution_id, + &ExecutionRecord { + execution_id: execution_id.into(), + status_kind: status_kind.clone(), + status_detail: status_detail.clone(), + steps: report.steps, + tokens: report.tokens, + cost_micros: report.cost_micros, + state: report.state.clone(), + events: report.events.clone(), + last_checkpoint: None, + pending_resume: None, + effect_cache: effect_cache.clone(), + timer_scheduled: false, + }, + ) + .ok() + }) + } else { + None + }; + Ok(ExecutionRecord { + execution_id: execution_id.into(), + status_kind, + status_detail, + steps: report.steps, + tokens: report.tokens, + cost_micros: report.cost_micros, + state: report.state, + events: report.events, + last_checkpoint: last_checkpoint.or_else(|| pending_resume.clone()), + pending_resume, + effect_cache, + timer_scheduled: false, + }) + } + + fn merge_step_report( + &self, + execution_id: &str, + previous: &ExecutionRecord, + report: ExecutionReportV1, + ) -> Result { + let (status_kind, status_detail) = status_parts(&report.status); + let id = ExecutionId::from_str(execution_id) + .map_err(|e| CoreError::Host(format!("invalid execution_id: {e}")))?; + let last_checkpoint = self + .store + .load(&id)? + .or_else(|| previous.last_checkpoint.clone()); + let mut pending_resume = + if status_kind == "suspended" && is_host_effect_suspend(&status_detail) { + last_checkpoint.clone() + } else { + None + }; + if status_kind == "suspended" + && is_host_effect_suspend(&status_detail) + && pending_resume.is_none() + { + pending_resume = self + .synthesize_suspend_checkpoint( + execution_id, + &ExecutionRecord { + execution_id: execution_id.into(), + status_kind: status_kind.clone(), + status_detail: status_detail.clone(), + steps: report.steps, + tokens: report.tokens, + cost_micros: report.cost_micros, + state: report.state.clone(), + events: report.events.clone(), + last_checkpoint: last_checkpoint.clone(), + pending_resume: None, + effect_cache: previous.effect_cache.clone(), + timer_scheduled: false, + }, + ) + .ok(); + } + + let mut events = previous.events.clone(); + let base = events.last().map(|e| e.sequence).unwrap_or(0); + for mut e in report.events { + if e.sequence <= base { + e.sequence = base + e.sequence.saturating_add(1); + } + events.push(e); + } + + Ok(ExecutionRecord { + execution_id: execution_id.into(), + status_kind, + status_detail, + steps: report.steps, + tokens: report.tokens, + cost_micros: report.cost_micros, + state: report.state, + events, + last_checkpoint: last_checkpoint.or_else(|| pending_resume.clone()), + pending_resume, + effect_cache: previous.effect_cache.clone(), + timer_scheduled: false, + }) + } +} + +fn is_terminal(kind: &str) -> bool { + matches!(kind, "terminated" | "failed" | "cancelled") +} + +fn policy_from(def: &LoadedDefinition) -> RuntimePolicyV1 { + RuntimePolicyV1 { + max_retries: 0, + retry_delay_ms: 0, + pack_hash: def.bundle.pack_hash.clone(), + policy_hash: def.bundle.policy_hash.clone(), + node_implementation_hash: def.node_implementation_hash.clone(), + contract_hash: def.bundle.contract_hash.clone(), + } +} + +fn parse_state(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| CoreError::Host(format!("invalid state JSON: {e}"))) +} + +fn status_parts(status: &ExecutionStatusV1) -> (String, String) { + match status { + ExecutionStatusV1::Suspended(reason) => ("suspended".into(), reason.clone()), + ExecutionStatusV1::Terminated(v) => ( + "terminated".into(), + serde_json::to_string(v).unwrap_or_else(|_| "null".into()), + ), + ExecutionStatusV1::Failed(e) => ("failed".into(), e.clone()), + ExecutionStatusV1::Cancelled => ("cancelled".into(), String::new()), + } +} + +pub fn start_with_budget( + engine: &mut AgentEngine, + execution_id: &str, + initial_state_json: &str, + max_node_steps: u32, +) -> Result { + let state = parse_state(initial_state_json)?; + let id = ExecutionId::from_str(execution_id) + .map_err(|e| CoreError::Host(format!("invalid execution_id: {e}")))?; + if engine.executions.contains_key(execution_id) { + return Err(CoreError::Host(format!( + "execution '{execution_id}' already exists" + ))); + } + let budget = if max_node_steps == 0 { + None + } else { + Some(max_node_steps) + }; + let report = engine.run_from_start(&id, state, budget, BTreeMap::new())?; + let record = engine.record_from_report(execution_id, report, BTreeMap::new())?; + engine + .executions + .insert(execution_id.to_owned(), record.clone()); + Ok(record) +} + +#[allow(dead_code)] +fn _sink_typecheck() -> VecEventSink { + empty_sink() +} diff --git a/crates/knolo-agent-icp/src/executor.rs b/crates/knolo-agent-icp/src/executor.rs new file mode 100644 index 0000000..e08d57d --- /dev/null +++ b/crates/knolo-agent-icp/src/executor.rs @@ -0,0 +1,346 @@ +//! Node implementations: pure Phase 1 demos + Phase 2 host-effects graph. +use knolo_agent_core::{ + node::{NodeExecutionV1, NodeExecutor, NodeOutcomeV1, NodeRequest}, + state::{PatchOperation, StatePatch}, + CoreError, +}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +/// Built-in executors keyed by `implementation_id` on the definition bundle. +#[derive(Debug, Clone)] +pub struct DeterministicExecutor { + pub implementation_id: String, + /// Optional remaining node executions before forced suspend (step slicing). + pub remaining_steps: Option, + /// After HITL resume, human-gate nodes may proceed. + pub hitl_approved: bool, + /// Injected host effect results keyed by effect name (`llm`, `tool`, `retrieve`). + pub effect_cache: BTreeMap, +} + +impl DeterministicExecutor { + pub fn new(implementation_id: impl Into) -> Self { + Self { + implementation_id: implementation_id.into(), + remaining_steps: None, + hitl_approved: false, + effect_cache: BTreeMap::new(), + } + } + + pub fn with_step_budget(mut self, max_steps: u32) -> Self { + self.remaining_steps = Some(max_steps); + self + } + + pub fn with_hitl_approved(mut self, approved: bool) -> Self { + self.hitl_approved = approved; + self + } + + pub fn with_effect_cache(mut self, cache: BTreeMap) -> Self { + self.effect_cache = cache; + self + } + + fn consume_step_budget(&mut self) { + if let Some(left) = self.remaining_steps.as_mut() { + if *left > 0 { + *left -= 1; + } + } + } +} + +impl NodeExecutor for DeterministicExecutor { + fn execute(&mut self, request: NodeRequest<'_>) -> Result { + if let Some(0) = self.remaining_steps { + return Ok(NodeExecutionV1 { + outcome: NodeOutcomeV1::Suspend { + reason: "step_slice".into(), + patch: None, + }, + tokens: 0, + cost_micros: 0, + }); + } + self.consume_step_budget(); + + match self.implementation_id.as_str() { + "portable-counter-v1" => portable_counter(request), + "a-b-terminate-v1" => a_b_terminate(request), + "suspend-demo-v1" => suspend_demo(request, self.hitl_approved), + "host-effects-v1" => host_effects(request, &self.effect_cache), + other => Err(CoreError::Host(format!( + "unknown implementation_id '{other}'" + ))), + } + } +} + +fn portable_counter(request: NodeRequest<'_>) -> Result { + let id = request.node_id.as_str(); + if id == "increment" { + let count = request + .state + .value + .pointer("/count") + .and_then(Value::as_u64) + .unwrap_or(0); + return Ok(NodeExecutionV1 { + outcome: NodeOutcomeV1::Continue { + patch: StatePatch { + base_revision: request.state.revision, + operations: BTreeMap::from([( + "/count".into(), + PatchOperation::Set(json!(count + 1)), + )]), + }, + }, + tokens: 1, + cost_micros: 1, + }); + } + if id == "done" { + return Ok(NodeExecutionV1 { + outcome: NodeOutcomeV1::Terminate { + result: json!({ "ok": true, "count": request.state.value.pointer("/count") }), + patch: None, + }, + tokens: 1, + cost_micros: 1, + }); + } + Err(CoreError::Host(format!( + "portable-counter-v1 has no behavior for node '{id}'" + ))) +} + +fn a_b_terminate(request: NodeRequest<'_>) -> Result { + let id = request.node_id.as_str(); + if id == "a" { + return Ok(NodeExecutionV1 { + outcome: NodeOutcomeV1::Continue { + patch: StatePatch { + base_revision: request.state.revision, + operations: BTreeMap::from([( + "/n".into(), + PatchOperation::Set(json!(request.state.revision + 1)), + )]), + }, + }, + tokens: 1, + cost_micros: 1, + }); + } + if id == "b" { + return Ok(NodeExecutionV1 { + outcome: NodeOutcomeV1::Terminate { + result: json!("ok"), + patch: None, + }, + tokens: 1, + cost_micros: 1, + }); + } + Err(CoreError::Host(format!( + "a-b-terminate-v1 has no behavior for node '{id}'" + ))) +} + +fn suspend_demo( + request: NodeRequest<'_>, + hitl_approved: bool, +) -> Result { + let id = request.node_id.as_str(); + if id == "work" { + return Ok(NodeExecutionV1 { + outcome: NodeOutcomeV1::Continue { + patch: StatePatch { + base_revision: request.state.revision, + operations: BTreeMap::from([( + "/phase".into(), + PatchOperation::Set(json!("worked")), + )]), + }, + }, + tokens: 1, + cost_micros: 1, + }); + } + if id == "await_human" { + if hitl_approved { + return Ok(NodeExecutionV1 { + outcome: NodeOutcomeV1::Continue { + patch: StatePatch { + base_revision: request.state.revision, + operations: BTreeMap::from([( + "/phase".into(), + PatchOperation::Set(json!("approved")), + )]), + }, + }, + tokens: 1, + cost_micros: 1, + }); + } + return Ok(NodeExecutionV1 { + outcome: NodeOutcomeV1::Suspend { + reason: "hitl_approval".into(), + patch: None, + }, + tokens: 0, + cost_micros: 0, + }); + } + if id == "finish" { + return Ok(NodeExecutionV1 { + outcome: NodeOutcomeV1::Terminate { + result: json!({ "approved": true }), + patch: None, + }, + tokens: 1, + cost_micros: 1, + }); + } + Err(CoreError::Host(format!( + "suspend-demo-v1 has no behavior for node '{id}'" + ))) +} + +/// Phase 2 graph: prepare → llm → tool → retrieve → done. +/// +/// Effect nodes suspend with `await_*` until the host injects a result into +/// `effect_cache` and resumes. This keeps `NodeExecutor` synchronous while +/// allowing async canister effects (ic-llm, outcalls, inter-canister). +fn host_effects( + request: NodeRequest<'_>, + cache: &BTreeMap, +) -> Result { + let id = request.node_id.as_str(); + match id { + "prepare" => Ok(NodeExecutionV1 { + outcome: NodeOutcomeV1::Continue { + patch: StatePatch { + base_revision: request.state.revision, + operations: BTreeMap::from([( + "/phase".into(), + PatchOperation::Set(json!("prepared")), + )]), + }, + }, + tokens: 0, + cost_micros: 0, + }), + "llm" => { + if let Some(result) = cache.get("llm") { + return Ok(NodeExecutionV1 { + outcome: NodeOutcomeV1::Continue { + patch: StatePatch { + base_revision: request.state.revision, + operations: BTreeMap::from([ + ("/phase".into(), PatchOperation::Set(json!("llm_done"))), + ("/llm_result".into(), PatchOperation::Set(result.clone())), + ]), + }, + }, + tokens: result.get("tokens").and_then(Value::as_u64).unwrap_or(8), + cost_micros: result + .get("cost_micros") + .and_then(Value::as_u64) + .unwrap_or(100), + }); + } + Ok(NodeExecutionV1 { + outcome: NodeOutcomeV1::Suspend { + reason: "await_llm".into(), + patch: None, + }, + tokens: 0, + cost_micros: 0, + }) + } + "tool" => { + if let Some(result) = cache.get("tool") { + return Ok(NodeExecutionV1 { + outcome: NodeOutcomeV1::Continue { + patch: StatePatch { + base_revision: request.state.revision, + operations: BTreeMap::from([ + ("/phase".into(), PatchOperation::Set(json!("tool_done"))), + ("/tool_result".into(), PatchOperation::Set(result.clone())), + ]), + }, + }, + tokens: 0, + cost_micros: result + .get("cost_micros") + .and_then(Value::as_u64) + .unwrap_or(10), + }); + } + Ok(NodeExecutionV1 { + outcome: NodeOutcomeV1::Suspend { + reason: "await_tool".into(), + patch: None, + }, + tokens: 0, + cost_micros: 0, + }) + } + "retrieve" => { + if let Some(result) = cache.get("retrieve") { + return Ok(NodeExecutionV1 { + outcome: NodeOutcomeV1::Continue { + patch: StatePatch { + base_revision: request.state.revision, + operations: BTreeMap::from([ + ("/phase".into(), PatchOperation::Set(json!("retrieve_done"))), + ( + "/retrieval_result".into(), + PatchOperation::Set(result.clone()), + ), + ]), + }, + }, + tokens: 0, + cost_micros: 5, + }); + } + Ok(NodeExecutionV1 { + outcome: NodeOutcomeV1::Suspend { + reason: "await_retrieve".into(), + patch: None, + }, + tokens: 0, + cost_micros: 0, + }) + } + "done" => Ok(NodeExecutionV1 { + outcome: NodeOutcomeV1::Terminate { + result: json!({ + "ok": true, + "phase": request.state.value.pointer("/phase"), + "llm": request.state.value.pointer("/llm_result"), + "tool": request.state.value.pointer("/tool_result"), + "retrieval": request.state.value.pointer("/retrieval_result"), + }), + patch: None, + }, + tokens: 0, + cost_micros: 0, + }), + other => Err(CoreError::Host(format!( + "host-effects-v1 has no behavior for node '{other}'" + ))), + } +} + +/// Suspend reasons that the host can resolve automatically. +pub fn is_host_effect_suspend(reason: &str) -> bool { + matches!( + reason, + "await_llm" | "await_tool" | "await_retrieve" | "step_slice" + ) +} diff --git a/crates/knolo-agent-icp/src/host.rs b/crates/knolo-agent-icp/src/host.rs new file mode 100644 index 0000000..c710e08 --- /dev/null +++ b/crates/knolo-agent-icp/src/host.rs @@ -0,0 +1,22 @@ +//! ICP Host adapters for Phase 1: clock, checkpoints, event sink (in-memory). +use knolo_agent::checkpoint::InMemoryCheckpointStore; +use knolo_agent::runtime::{FixedClock, VecEventSink}; + +pub type IcpCheckpointStore = InMemoryCheckpointStore; +pub type IcpEventSink = VecEventSink; +pub type IcpClock = FixedClock; + +/// Deterministic wall-clock for Phase 1 conformance (matches native FixedClock tests). +pub const DETERMINISTIC_NOW_MS: u64 = 1; + +pub fn fixed_clock() -> IcpClock { + FixedClock(DETERMINISTIC_NOW_MS) +} + +pub fn empty_store() -> IcpCheckpointStore { + InMemoryCheckpointStore::default() +} + +pub fn empty_sink() -> IcpEventSink { + VecEventSink::default() +} diff --git a/crates/knolo-agent-icp/src/knowledge.rs b/crates/knolo-agent-icp/src/knowledge.rs new file mode 100644 index 0000000..dca7813 --- /dev/null +++ b/crates/knolo-agent-icp/src/knowledge.rs @@ -0,0 +1,88 @@ +//! Inter-canister retrieval against knolo-core knowledge canisters. +use candid::{CandidType, Principal}; +use knolo_agent_core::{ + retrieval::{EvidenceProvenanceV1, RetrievalEvidenceV1, RetrievalQueryV1, RetrievalResultV1}, + CoreError, +}; +use serde::Deserialize; +use serde_json::json; + +/// Mirrors knolo-core `packages/icp-canister` HitDto. +#[derive(CandidType, Deserialize, Clone, Debug, PartialEq)] +pub struct KnowledgeHitDto { + pub block_id: u64, + pub score: f64, + pub text: String, + pub source: Option, + pub namespace: Option, +} + +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +pub fn hits_to_retrieval(hits: Vec) -> RetrievalResultV1 { + RetrievalResultV1 { + version: 1, + evidence: hits + .into_iter() + .map(|h| RetrievalEvidenceV1 { + content: json!({ + "text": h.text, + "block_id": h.block_id, + "namespace": h.namespace, + }), + score_micros: (h.score.clamp(0.0, 1.0) * 1_000_000.0) as u32, + provenance: EvidenceProvenanceV1 { + source_id: h.source.unwrap_or_else(|| "knowledge".into()), + locator: format!("block:{}", h.block_id), + content_hash: format!("{:016x}", h.block_id), + }, + }) + .collect(), + } +} + +/// Mock retrieval for unit tests / offline. +pub fn mock_retrieve(query: &RetrievalQueryV1) -> RetrievalResultV1 { + RetrievalResultV1 { + version: 1, + evidence: vec![RetrievalEvidenceV1 { + content: json!({ "text": format!("mock hit for: {}", query.text) }), + score_micros: 900_000, + provenance: EvidenceProvenanceV1 { + source_id: "mock".into(), + locator: "mock:0".into(), + content_hash: "mock".into(), + }, + }], + } +} + +/// Async inter-canister search (canister only). +#[cfg(target_arch = "wasm32")] +pub async fn retrieve_from_canister( + canister: Principal, + query: &RetrievalQueryV1, +) -> Result { + let top_k = query.limit.max(1).min(50); + let result: Result<(Vec,), _> = + ic_cdk::call(canister, "search", (query.text.clone(), top_k)).await; + match result { + Ok((hits,)) => Ok(hits_to_retrieval(hits)), + Err((code, msg)) => Err(CoreError::Host(format!( + "knowledge search failed ({code:?}): {msg}" + ))), + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn retrieve_from_canister( + _canister: Principal, + query: &RetrievalQueryV1, +) -> Result { + // Native unit tests never hit real canisters. + Ok(mock_retrieve(query)) +} + +pub fn parse_principal(text: &str) -> Result { + Principal::from_text(text.trim()) + .map_err(|e| CoreError::Host(format!("invalid knowledge principal: {e}"))) +} diff --git a/crates/knolo-agent-icp/src/lib.rs b/crates/knolo-agent-icp/src/lib.rs new file mode 100644 index 0000000..072113f --- /dev/null +++ b/crates/knolo-agent-icp/src/lib.rs @@ -0,0 +1,708 @@ +//! ICP canister host for the Knolo agent control plane (Phase 1 + Phase 2). +//! +//! Phase 1: pure deterministic graphs, checkpoints, ordered events. +//! Phase 2: pack-gated tools, ic-llm, knowledge retrieval, timers, cycles budget. +mod budget; +mod definition; +mod dto; +mod effects; +mod engine; +mod executor; +mod host; +mod knowledge; +mod tools_host; + +pub use definition::{ + AgentDefinitionBundleV1, HostConfigV1, LoadedDefinition, MAX_DEFINITION_BYTES, +}; +pub use dto::*; +pub use engine::{start_with_budget, AgentEngine, ExecutionRecord}; +pub use executor::DeterministicExecutor; +pub use host::{fixed_clock, DETERMINISTIC_NOW_MS}; +pub use tools_host::permissive_tools_pack; + +use candid::CandidType; +use engine::AgentEngine as Engine; +use ic_cdk::api::{caller, is_controller}; +use ic_cdk::storage::{stable_restore, stable_save}; +use ic_cdk_macros::{post_upgrade, pre_upgrade, query, update}; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; + +thread_local! { + static ENGINE: RefCell = RefCell::new(Engine::default()); +} + +#[derive(CandidType, Deserialize, Serialize, Clone, Debug, Default)] +struct StableSnapshot { + definition_json: Option, + executions_json: String, + /// Added in Phase 2; optional for upgrade from Phase 1 snapshots. + #[serde(default)] + budget_json: String, +} + +// --- Candid surface ---------------------------------------------------------- + +#[query] +fn health() -> HealthDto { + ENGINE.with(|e| { + let eng = e.borrow(); + if eng.definition.is_some() { + HealthDto::ok("Agent runtime ready (definition loaded). Phase 2 effects enabled.") + } else { + HealthDto { + ok: false, + message: "No definition loaded. Call load_definition first.".into(), + } + } + }) +} + +#[query] +fn inspect() -> InspectionDto { + ENGINE.with(|e| { + let eng = e.borrow(); + match eng.definition.as_ref() { + Some(def) => { + let host = &def.bundle.host; + InspectionDto { + ok: true, + engine: "icp".into(), + graph_loaded: true, + graph_id: Some(def.compiled.definition().id.to_string()), + graph_hash: Some(def.compiled.hash().into()), + implementation_id: Some(def.bundle.implementation_id.clone()), + execution_count: eng.executions.len() as u64, + capabilities: vec![ + "state".into(), + "routing".into(), + "suspension".into(), + "checkpoints".into(), + "ordered_events".into(), + "deterministic_replay".into(), + "pack_policy".into(), + "tools".into(), + "llm".into(), + "retrieval".into(), + "timers".into(), + "cycles_budget".into(), + ], + limitations: vec![ + if host.llm_enabled { + "llm via ic-llm (async suspend/resume)".into() + } else { + "llm disabled".into() + }, + if host.knowledge_canister.is_some() { + "retrieval via knowledge canister".into() + } else { + "retrieval falls back to mock without knowledge_canister".into() + }, + if host.allow_https_tools { + "https tools allowed (outcall/mock)".into() + } else { + "https tools disabled".into() + }, + "in-memory checkpoints until phase 3 stable structures".into(), + ], + message: "Phase 2: host effects + packs on ICP.".into(), + } + } + None => InspectionDto { + ok: false, + engine: "icp".into(), + graph_loaded: false, + graph_id: None, + graph_hash: None, + implementation_id: None, + execution_count: 0, + capabilities: vec!["state".into(), "routing".into(), "suspension".into()], + limitations: vec!["no definition loaded".into()], + message: "Load a definition to inspect a graph.".into(), + }, + } + }) +} + +#[query] +fn get_budget() -> BudgetDto { + ENGINE.with(|e| BudgetDto::from(&e.borrow().budget.snapshot)) +} + +#[update] +fn load_definition(json: String) -> HealthDto { + if let Err(err) = require_controller() { + return err; + } + let result = ENGINE.with(|e| e.borrow_mut().load_definition(&json)); + match result { + Ok(msg) => { + if let Err(persist_err) = persist_current() { + return HealthDto::err(format!("loaded but failed to persist: {persist_err}")); + } + HealthDto::ok(msg) + } + Err(err) => HealthDto::err(err.to_string()), + } +} + +#[update] +fn clear_definition() -> HealthDto { + if let Err(err) = require_controller() { + return err; + } + let msg = ENGINE.with(|e| e.borrow_mut().clear_definition()); + if let Err(persist_err) = persist_current() { + return HealthDto::err(format!("{msg} Persist failed: {persist_err}")); + } + HealthDto::ok(msg) +} + +#[update] +async fn start_execution(execution_id: String, initial_state_json: String) -> RunReportDto { + let started = ENGINE.with(|e| { + e.borrow_mut() + .start_execution(&execution_id, &initial_state_json) + }); + let record = match started { + Ok(r) => r, + Err(err) => return RunReportDto::err(execution_id, err.to_string()), + }; + let _ = record; + let finished = continue_effects_inner(execution_id.clone()).await; + let _ = persist_current(); + finished +} + +#[update] +async fn step(execution_id: String, max_node_steps: u32) -> RunReportDto { + let stepped = ENGINE.with(|e| e.borrow_mut().step(&execution_id, max_node_steps)); + match stepped { + Ok(_) => { + let finished = continue_effects_inner(execution_id).await; + let _ = persist_current(); + finished + } + Err(err) => RunReportDto::err(execution_id, err.to_string()), + } +} + +#[update] +async fn resume(execution_id: String) -> RunReportDto { + let resumed = ENGINE.with(|e| e.borrow_mut().resume(&execution_id)); + match resumed { + Ok(_) => { + let finished = continue_effects_inner(execution_id).await; + let _ = persist_current(); + finished + } + Err(err) => RunReportDto::err(execution_id, err.to_string()), + } +} + +#[update] +async fn continue_effects(execution_id: String) -> RunReportDto { + let finished = continue_effects_inner(execution_id).await; + let _ = persist_current(); + finished +} + +#[query] +fn get_events(execution_id: String) -> EventsDto { + ENGINE.with(|e| { + let eng = e.borrow(); + match eng.executions.get(&execution_id) { + Some(record) => { + let events_json = + serde_json::to_string(&record.events).unwrap_or_else(|_| "[]".into()); + EventsDto { + ok: true, + execution_id, + events_json, + message: format!("{} events", record.events.len()), + } + } + None => EventsDto { + ok: false, + execution_id, + events_json: "[]".into(), + message: "unknown execution".into(), + }, + } + }) +} + +#[query] +fn get_checkpoint(execution_id: String) -> CheckpointDto { + ENGINE.with(|e| { + let eng = e.borrow(); + match eng.executions.get(&execution_id) { + Some(record) => match &record.last_checkpoint { + Some(cp) => CheckpointDto { + ok: true, + execution_id, + present: true, + checkpoint_json: serde_json::to_string(cp).unwrap_or_else(|_| "{}".into()), + message: "checkpoint present".into(), + }, + None => CheckpointDto { + ok: true, + execution_id, + present: false, + checkpoint_json: "null".into(), + message: "no checkpoint stored for this execution".into(), + }, + }, + None => CheckpointDto { + ok: false, + execution_id, + present: false, + checkpoint_json: "null".into(), + message: "unknown execution".into(), + }, + } + }) +} + +// --- Effect loop + timers ---------------------------------------------------- + +async fn continue_effects_inner(execution_id: String) -> RunReportDto { + // Drive automatic effects outside RefCell borrow across awaits. + loop { + let snapshot = ENGINE.with(|e| e.borrow().executions.get(&execution_id).cloned()); + let Some(current) = snapshot else { + return RunReportDto::err(execution_id, "unknown execution"); + }; + if current.status_kind != "suspended" { + return RunReportDto::from(¤t); + } + if current.status_detail == "hitl_approval" { + return RunReportDto::from(¤t); + } + + if current.status_detail == "step_slice" { + let (auto, delay) = ENGINE.with(|e| { + e.borrow() + .definition + .as_ref() + .map(|d| (d.bundle.host.auto_continue, d.bundle.host.timer_ns)) + .unwrap_or((false, 0)) + }); + if auto && !current.timer_scheduled { + ENGINE.with(|e| { + if let Some(r) = e.borrow_mut().executions.get_mut(&execution_id) { + r.timer_scheduled = true; + } + }); + effects::schedule_auto_continue(execution_id.clone(), delay); + let updated = ENGINE.with(|e| { + e.borrow() + .executions + .get(&execution_id) + .cloned() + .expect("execution") + }); + return RunReportDto::from(&updated); + } + } + + let resolved = ENGINE.with(|e| { + // Cannot await inside with — use take pattern + std::mem::take(&mut *e.borrow_mut()) + }); + let mut eng = resolved; + let result = effects::resolve_one_effect(&mut eng, &execution_id).await; + ENGINE.with(|e| *e.borrow_mut() = eng); + match result { + Ok(Some(_)) => continue, + Ok(None) => { + let current = ENGINE.with(|e| { + e.borrow() + .executions + .get(&execution_id) + .cloned() + .expect("execution") + }); + return RunReportDto::from(¤t); + } + Err(err) => return RunReportDto::err(execution_id, err.to_string()), + } + } +} + +/// Invoked by timer callback (wasm). +pub async fn timer_continue_execution(execution_id: String) -> RunReportDto { + let stepped = ENGINE.with(|e| { + if let Some(r) = e.borrow_mut().executions.get_mut(&execution_id) { + r.timer_scheduled = false; + } + e.borrow_mut().step(&execution_id, 1) + }); + match stepped { + Ok(_) => { + let finished = continue_effects_inner(execution_id).await; + let _ = persist_current(); + finished + } + Err(err) => RunReportDto::err(execution_id, err.to_string()), + } +} + +// --- Persistence ------------------------------------------------------------- + +#[pre_upgrade] +fn pre_upgrade() { + if let Err(err) = persist_current() { + ic_cdk::trap(&format!("pre_upgrade persist failed: {err}")); + } +} + +#[post_upgrade] +fn post_upgrade() { + let snapshot: StableSnapshot = match load_snapshot() { + Ok(s) => s, + Err(err) => ic_cdk::trap(&format!("post_upgrade load failed: {err}")), + }; + if let Err(err) = restore_engine(snapshot) { + ic_cdk::trap(&format!("post_upgrade restore failed: {err}")); + } +} + +fn persist_current() -> Result<(), String> { + let snapshot = ENGINE.with(|e| { + let eng = e.borrow(); + StableSnapshot { + definition_json: eng.definition.as_ref().map(|d| d.definition_json.clone()), + executions_json: serde_json::to_string(&eng.executions).unwrap_or_else(|_| "{}".into()), + budget_json: serde_json::to_string(&eng.budget.snapshot) + .unwrap_or_else(|_| "{}".into()), + } + }); + stable_save((snapshot,)).map_err(|e| format!("stable_save: {e}")) +} + +fn load_snapshot() -> Result { + stable_restore::<(StableSnapshot,)>() + .map(|(s,)| s) + .map_err(|e| format!("stable_restore: {e}")) +} + +fn restore_engine(snapshot: StableSnapshot) -> Result<(), String> { + ENGINE.with(|e| { + let mut eng = e.borrow_mut(); + *eng = Engine::default(); + if let Some(json) = snapshot.definition_json { + eng.load_definition(&json) + .map_err(|err| format!("reload definition: {err}"))?; + } + if !snapshot.executions_json.is_empty() && snapshot.executions_json != "{}" { + let map: std::collections::BTreeMap = + serde_json::from_str(&snapshot.executions_json) + .map_err(|err| format!("decode executions: {err}"))?; + eng.executions = map; + let checkpoints: Vec<_> = eng + .executions + .iter() + .filter_map(|(id, record)| { + record + .last_checkpoint + .as_ref() + .map(|cp| (id.clone(), cp.clone())) + }) + .collect(); + for (id, cp) in checkpoints { + use knolo_agent_core::node::CheckpointStore; + eng.store + .save(&cp) + .map_err(|err| format!("restore checkpoint {id}: {err}"))?; + } + } + if !snapshot.budget_json.is_empty() && snapshot.budget_json != "{}" { + if let Ok(snap) = serde_json::from_str(&snapshot.budget_json) { + eng.budget.snapshot = snap; + } + } + Ok(()) + }) +} + +fn require_controller() -> Result<(), HealthDto> { + let principal = caller(); + if is_controller(&principal) { + Ok(()) + } else { + Err(HealthDto::err(format!( + "Unauthorized: caller {principal} is not a controller of this canister." + ))) + } +} + +// --- Native unit tests ------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use knolo_agent_core::event::EventKindV1; + use serde_json::json; + + fn portable_definition() -> String { + json!({ + "version": 1, + "implementation_id": "portable-counter-v1", + "pack_hash": "pack-none", + "policy_hash": "policy-none", + "contract_hash": "contract-none", + "graph": { + "version": 1, + "id": "portable-counter", + "state_schema": "counter-state", + "entry": "increment", + "nodes": [ + { "id": "increment", "terminal": false, "reads": ["/count"], "writes": ["/count"] }, + { "id": "done", "terminal": true, "reads": ["/count"], "writes": [] } + ], + "transitions": [ + { "id": "increment.continue.done", "from": "increment", "route": "continue", "to": "done" } + ], + "cycles": [], + "limits": { "max_steps": 10, "max_tokens": 100, "max_cost_micros": 1000, "timeout_ms": 30000 } + }, + "schema": { + "version": 1, + "id": "counter-state", + "paths": { "/count": "Number" }, + "required": ["/count"] + } + }) + .to_string() + } + + fn host_effects_definition() -> String { + let pack = permissive_tools_pack(false); + json!({ + "version": 1, + "implementation_id": "host-effects-v1", + "pack": pack, + "host": { + "auto_continue": false, + "llm_enabled": true, + "llm_model": "llama3.1:8b", + "knowledge_canister": null, + "allow_https_tools": false, + "max_effect_rounds": 8 + }, + "graph": { + "version": 1, + "id": "host-effects", + "state_schema": "effects-state", + "entry": "prepare", + "nodes": [ + { "id": "prepare", "terminal": false, "reads": ["/phase"], "writes": ["/phase"] }, + { "id": "llm", "terminal": false, "reads": ["/prompt", "/phase"], "writes": ["/phase", "/llm_result"] }, + { "id": "tool", "terminal": false, "reads": ["/tool_id", "/tool_args", "/phase"], "writes": ["/phase", "/tool_result"] }, + { "id": "retrieve", "terminal": false, "reads": ["/query", "/phase"], "writes": ["/phase", "/retrieval_result"] }, + { "id": "done", "terminal": true, "reads": ["/phase", "/llm_result", "/tool_result", "/retrieval_result"], "writes": [] } + ], + "transitions": [ + { "id": "t1", "from": "prepare", "route": "continue", "to": "llm" }, + { "id": "t2", "from": "llm", "route": "continue", "to": "tool" }, + { "id": "t3", "from": "tool", "route": "continue", "to": "retrieve" }, + { "id": "t4", "from": "retrieve", "route": "continue", "to": "done" } + ], + "cycles": [], + "limits": { "max_steps": 20, "max_tokens": 10000, "max_cost_micros": 100000, "timeout_ms": 60000 } + }, + "schema": { + "version": 1, + "id": "effects-state", + "paths": { + "/phase": "String", + "/prompt": "String", + "/tool_id": "String", + "/tool_args": "Object", + "/query": "String", + "/llm_result": "Object", + "/tool_result": "Object", + "/retrieval_result": "Object" + }, + "required": ["/phase", "/prompt"] + } + }) + .to_string() + } + + fn ab_definition() -> String { + json!({ + "version": 1, + "implementation_id": "a-b-terminate-v1", + "pack_hash": "p", + "policy_hash": "policy", + "contract_hash": "contracts", + "graph": { + "version": 1, + "id": "g", + "state_schema": "s", + "entry": "a", + "nodes": [ + { "id": "a", "terminal": false, "reads": [], "writes": ["/n"] }, + { "id": "b", "terminal": true, "reads": [], "writes": [] } + ], + "transitions": [ + { "id": "next", "from": "a", "route": "continue", "to": "b" } + ], + "cycles": [], + "limits": { "max_steps": 20, "max_tokens": 100, "max_cost_micros": 100, "timeout_ms": 100 } + }, + "schema": { + "version": 1, + "id": "s", + "paths": { "/n": "Number" }, + "required": ["/n"] + } + }) + .to_string() + } + + #[test] + fn portable_counter_runs_deterministically() { + let mut eng = AgentEngine::default(); + eng.load_definition(&portable_definition()).unwrap(); + let state = json!({ + "schema_id": "counter-state", + "revision": 0, + "value": { "count": 0 }, + "provenance": null + }) + .to_string(); + let r = eng.start_execution("run-1", &state).unwrap(); + assert_eq!(r.status_kind, "terminated"); + assert_eq!(r.steps, 2); + assert!(r + .events + .windows(2) + .all(|w| w[1].sequence == w[0].sequence + 1)); + assert!(r + .events + .iter() + .any(|e| matches!(e.kind, EventKindV1::Terminated))); + } + + #[test] + fn host_effects_suspend_inject_and_complete() { + let mut eng = AgentEngine::default(); + eng.load_definition(&host_effects_definition()).unwrap(); + let state = json!({ + "schema_id": "effects-state", + "revision": 0, + "value": { + "phase": "init", + "prompt": "ping", + "tool_id": "echo", + "tool_args": { "message": "knolo" }, + "query": "alpha" + }, + "provenance": null + }) + .to_string(); + + let r = eng.start_execution("fx-1", &state).unwrap(); + assert_eq!(r.status_kind, "suspended"); + assert_eq!(r.status_detail, "await_llm"); + + // Inject LLM, tool (pack-gated), and retrieval results — same path as async host. + let r = eng + .inject_effect_and_resume( + "fx-1", + "llm", + json!({ "text": "mock-llm", "tokens": 4, "cost_micros": 40 }), + ) + .unwrap(); + assert_eq!(r.status_detail, "await_tool"); + eng.budget.note_llm(4, 40); + + let tool_value = eng.run_tool_for_pending("fx-1").unwrap(); + let r = eng + .inject_effect_and_resume("fx-1", "tool", tool_value) + .unwrap(); + assert_eq!(r.status_detail, "await_retrieve"); + + let retrieval = knowledge::mock_retrieve(&knolo_agent_core::retrieval::RetrievalQueryV1 { + version: 1, + text: "alpha".into(), + limit: 5, + }); + eng.budget.note_retrieval(5); + let r = eng + .inject_effect_and_resume("fx-1", "retrieve", serde_json::to_value(retrieval).unwrap()) + .unwrap(); + assert_eq!(r.status_kind, "terminated"); + assert!(eng.budget.snapshot.llm_calls >= 1); + assert!(eng.budget.snapshot.tool_calls >= 1); + assert!(eng.budget.snapshot.retrieval_calls >= 1); + assert!(r.state.value.pointer("/llm_result").is_some()); + assert!(r.state.value.pointer("/tool_result").is_some()); + assert!(r.state.value.pointer("/retrieval_result").is_some()); + } + + #[test] + fn tool_denied_without_pack_grant() { + let mut def: serde_json::Value = serde_json::from_str(&host_effects_definition()).unwrap(); + // Empty tools set with zero budget is invalid; use pack that doesn't include echo. + def["pack"]["tools"] = json!([]); + // compile requires max_calls > 0 still + let mut eng = AgentEngine::default(); + // pack with no tools will fail compile if max_calls ok but tools empty is fine + eng.load_definition(&def.to_string()).unwrap(); + let state = json!({ + "schema_id": "effects-state", + "revision": 0, + "value": { + "phase": "init", + "prompt": "ping", + "tool_id": "echo", + "tool_args": { "message": "x" }, + "query": "q" + }, + "provenance": null + }) + .to_string(); + eng.start_execution("deny-1", &state).unwrap(); + // inject llm to reach tool + eng.inject_effect_and_resume( + "deny-1", + "llm", + json!({ "text": "ok", "tokens": 1, "cost_micros": 1 }), + ) + .unwrap(); + let err = eng.run_tool_for_pending("deny-1").unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("PolicyDenied") || msg.contains("not granted") || msg.contains("tool"), + "unexpected: {msg}" + ); + } + + #[test] + fn step_slice_then_resume() { + let mut eng = AgentEngine::default(); + eng.load_definition(&ab_definition()).unwrap(); + let state = json!({ + "schema_id": "s", + "revision": 0, + "value": { "n": 0 }, + "provenance": null + }) + .to_string(); + let r = start_with_budget(&mut eng, "sliced", &state, 1).unwrap(); + assert_eq!(r.status_kind, "suspended"); + assert_eq!(r.status_detail, "step_slice"); + let r2 = eng.resume("sliced").unwrap(); + assert_eq!(r2.status_kind, "terminated"); + } + + #[test] + fn definition_size_and_schema_guards() { + assert!(AgentDefinitionBundleV1::parse("").is_err()); + } +} diff --git a/crates/knolo-agent-icp/src/tools_host.rs b/crates/knolo-agent-icp/src/tools_host.rs new file mode 100644 index 0000000..fff47e1 --- /dev/null +++ b/crates/knolo-agent-icp/src/tools_host.rs @@ -0,0 +1,193 @@ +//! Pack-gated in-canister tools for the ICP host. +use knolo_agent::host::ToolRegistry; +use knolo_agent::policy::BudgetLedger; +use knolo_agent::tool::ToolImplementation; +use knolo_agent_core::{ + pack::CompiledPolicyV1, + tool::{ResourceBudgetV1, ResourceUsageV1, ToolCallV1, ToolDefinition, ToolResultV1}, + CapabilityId, CoreError, NamespaceId, ToolId, +}; +use serde_json::{json, Value}; +use std::str::FromStr; + +/// Built-in echo tool (deterministic; no network). +pub struct EchoTool { + def: ToolDefinition, +} + +impl EchoTool { + pub fn new() -> Self { + Self { + def: ToolDefinition { + version: 1, + id: ToolId::from_str("echo").unwrap(), + namespace: NamespaceId::from_str("tools").unwrap(), + capability: CapabilityId::from_str("echo").unwrap(), + argument_contract: json!({ + "type": "object", + "properties": { "message": { "type": "string" } }, + "required": ["message"] + }), + result_contract: json!({ + "type": "object", + "properties": { "echo": { "type": "string" } }, + "required": ["echo"] + }), + }, + } + } +} + +impl Default for EchoTool { + fn default() -> Self { + Self::new() + } +} + +impl ToolImplementation for EchoTool { + fn definition(&self) -> &ToolDefinition { + &self.def + } + fn execute(&mut self, arguments: &Value) -> Result<(Value, ResourceUsageV1), CoreError> { + let message = arguments + .get("message") + .and_then(Value::as_str) + .unwrap_or(""); + Ok(( + json!({ "echo": message }), + ResourceUsageV1 { + calls: 1, + units: 1, + duration_ms: 0, + }, + )) + } +} + +/// HTTPS tool placeholder — fails closed unless host allows and URL is present. +/// Real outcalls are performed by the canister layer when configured. +pub struct HttpsGetTool { + def: ToolDefinition, + pub allow: bool, + /// Injected response for tests / after outcall. + pub canned: Option, +} + +impl HttpsGetTool { + pub fn new(allow: bool) -> Self { + Self { + def: ToolDefinition { + version: 1, + id: ToolId::from_str("https_get").unwrap(), + namespace: NamespaceId::from_str("tools").unwrap(), + capability: CapabilityId::from_str("https").unwrap(), + argument_contract: json!({ + "type": "object", + "properties": { "url": { "type": "string" } }, + "required": ["url"] + }), + result_contract: json!({ + "type": "object", + "properties": { + "status": { "type": "number" }, + "body": { "type": "string" } + }, + "required": ["status", "body"] + }), + }, + allow, + canned: None, + } + } +} + +impl ToolImplementation for HttpsGetTool { + fn definition(&self) -> &ToolDefinition { + &self.def + } + fn execute(&mut self, arguments: &Value) -> Result<(Value, ResourceUsageV1), CoreError> { + if !self.allow { + return Err(CoreError::Host( + "https_get disabled by host config (allow_https_tools=false)".into(), + )); + } + let url = arguments.get("url").and_then(Value::as_str).unwrap_or(""); + if url.is_empty() { + return Err(CoreError::Host("https_get requires url".into())); + } + if let Some(body) = &self.canned { + return Ok(( + json!({ "status": 200, "body": body }), + ResourceUsageV1 { + calls: 1, + units: 10, + duration_ms: 1, + }, + )); + } + // Canister layer should fulfill via outcall before execute; without body, fail closed. + Err(CoreError::Host(format!( + "https_get for '{url}' has no outcall result; inject body or use mock" + ))) + } +} + +pub fn default_registry(allow_https: bool) -> ToolRegistry { + let mut reg = ToolRegistry::default(); + let _ = reg.register(EchoTool::new()); + let _ = reg.register(HttpsGetTool::new(allow_https)); + reg +} + +pub fn execute_tool_call( + registry: &mut ToolRegistry, + policy: Option<&CompiledPolicyV1>, + ledger: &mut BudgetLedger, + tool_id: &str, + arguments: Value, + call_id: &str, +) -> Result { + let policy = policy.ok_or_else(|| { + CoreError::Host("tool execution requires a pack policy on the definition".into()) + })?; + let call = ToolCallV1 { + version: 1, + call_id: call_id.into(), + tool_id: ToolId::from_str(tool_id) + .map_err(|e| CoreError::Host(format!("invalid tool_id: {e}")))?, + arguments, + }; + let mut audit = Vec::new(); + registry.execute(policy, ledger, call, &mut audit) +} + +/// Minimal open pack granting echo (+ optional https). +pub fn permissive_tools_pack(include_https: bool) -> knolo_agent_core::pack::PackDeclarationV1 { + use knolo_agent_core::pack::PackDeclarationV1; + use std::collections::{BTreeMap, BTreeSet}; + let mut tools = BTreeSet::from([ToolId::from_str("echo").unwrap()]); + let mut bindings = BTreeMap::from([( + CapabilityId::from_str("echo").unwrap(), + "builtin:echo".into(), + )]); + if include_https { + tools.insert(ToolId::from_str("https_get").unwrap()); + bindings.insert( + CapabilityId::from_str("https").unwrap(), + "builtin:https".into(), + ); + } + PackDeclarationV1 { + version: 1, + id: knolo_agent_core::PackId::from_str("icp-host-pack").unwrap(), + tools, + namespaces: BTreeSet::from([NamespaceId::from_str("tools").unwrap()]), + argument_constraints: BTreeMap::new(), + budget: ResourceBudgetV1 { + max_calls: 32, + max_units: 10_000, + max_duration_ms: 60_000, + }, + capability_bindings: bindings, + } +} diff --git a/docs/README.md b/docs/README.md index 646846a..28bad4c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,5 +4,6 @@ - [Packs](packs.md), [policy](policy-enforcement.md), and [tools](tools.md) - [Retrieval](retrieval.md) and the [`@knolo/core` boundary](core-boundary.md) - [Checkpoints](checkpoints.md), [replay](replay.md), and [WASM](wasm.md) +- [ICP agent runtime ADR](architecture/adr-001-icp-agent-runtime.md) and [constraints matrix](architecture/icp-constraints-matrix.md) - [Security model](security.md), [compatibility](compatibility.md), and [releases](releasing.md) - [Repository audit](repository-audit.md) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 5d74fcb..b1da893 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -6,7 +6,13 @@ emits ordered events, and checkpoints before an external suspension. Hosts injec tools, storage, clocks, and capabilities. No provider is discovered implicitly. `knolo-agent-core` owns portable contracts, `knolo-agent` owns native execution, -`knolo-agent-wasm` exposes the JSON protocol, and `@knolo/agents` owns ergonomic +`knolo-agent-wasm` exposes the JSON protocol, `knolo-agent-icp` hosts the control +plane inside an ICP canister (Phase 1 PoC), and `@knolo/agents` owns ergonomic TypeScript builders. `@knolo/core` is a separately published peer dependency. It owns Cortex and ClaimGraph data and implementations; this repository contains only typed injection interfaces and never vendors, re-exports, or publishes core. + +ICP architecture decisions and constraints: + +- [ADR-001: ICP agent runtime](adr-001-icp-agent-runtime.md) +- [ICP constraints matrix](icp-constraints-matrix.md) diff --git a/docs/architecture/adr-001-icp-agent-runtime.md b/docs/architecture/adr-001-icp-agent-runtime.md new file mode 100644 index 0000000..fb147b2 --- /dev/null +++ b/docs/architecture/adr-001-icp-agent-runtime.md @@ -0,0 +1,53 @@ +# ADR-001: ICP Agent Runtime Canister + +- **Status:** Accepted (Phase 0 / Phase 1 / Phase 2) +- **Date:** 2026-08-03 +- **Context:** Host Knolo’s deterministic control plane on the Internet Computer. + +## Decision + +1. **Topology (Phase 1–3):** Single long-lived **multi-tenant agent runtime canister** + embedding `knolo-agent-core` + `knolo-agent::runtime::Scheduler` and an ICP Host. + Per-agent factory canisters are deferred until isolation/scaling requires them. + +2. **Wasm target:** `wasm32-unknown-unknown` with `std` via `ic-cdk`. Pure `no_std` + is **not** required. Residual host OS APIs stay out of the hot path; the + filesystem checkpoint store is never used in the canister. + +3. **LLM / effects (Phase 2 landed):** Prefer **ic-llm** (`ic-llm` 1.1 + + `ic-cdk` 0.17). HTTPS tools are pack-gated and optional. Effect nodes use + `await_llm` / `await_tool` / `await_retrieve` suspend reasons; the canister + resolves them asynchronously then resumes. + +4. **Knowledge coupling:** Loose **Candid** coupling to knolo-core knowledge + canisters (`search`). Without `host.knowledge_canister`, retrieval uses a + deterministic mock. Do not vendor knolo-core storage. + +5. **Async model:** Keep the synchronous `NodeExecutor` contract. Map long + effects to **Suspend → checkpoint → timer/message resume**. + `host.auto_continue` schedules `ic-cdk-timers` for `step_slice`. + +6. **Persistence:** Phase 1–2 use thread-local state + coarse `stable_save` of + definition + execution records + budget snapshot. Phase 3 migrates to + `ic-stable-structures` with versioned schemas. + +7. **Separation from `knolo-agent-wasm`:** Browser JSON protocol adapter remains + a separate crate and path. The ICP canister is a full Host runtime, not the + inspect-only WASM ABI. + +## Consequences + +- New workspace crate: `crates/knolo-agent-icp` (`publish = false` until stable). +- Candid surface: `load_definition`, `start_execution`, `step`, `resume`, + `continue_effects`, `inspect`, `get_events`, `get_checkpoint`, `get_budget`, + `health`. +- Local `examples/icp-agent-canister` for dfx deploy/smoke. +- Conformance: pure deterministic fixtures match native scheduler semantics; + host-effects fixture covered by unit tests with mocks. + +## Non-goals (through Phase 2) + +- Mainnet production hardening, full cycles billing product, multi-agent handoff. +- Making ICP the default `@knolo/agents` engine. +- Rewriting core as pure `no_std`. +- Phase 3 stable-structures schema migrations (next). diff --git a/docs/architecture/icp-constraints-matrix.md b/docs/architecture/icp-constraints-matrix.md new file mode 100644 index 0000000..685a589 --- /dev/null +++ b/docs/architecture/icp-constraints-matrix.md @@ -0,0 +1,68 @@ +# ICP constraints matrix (Phase 0) + +Measured against Knolo Agents workspace as of 2026-08-03. Update when Wasm +payload or scheduler cost changes. + +## Portability audit + +| Component | `wasm32-unknown-unknown` | Notes | +| --- | --- | --- | +| `knolo-agent-core` | **Pass** | Deps: serde, serde_json, sha2 only. Uses `std` collections. | +| `knolo-agent` | **Pass** | Scheduler/host traits compile. `FilesystemCheckpointStore` is unused on ICP. | +| `knolo-agent-icp` | **Pass** (Phase 1) | `ic-cdk` 0.17 + candid 0.10; pure engine unit-tested on host. | + +**Conclusion:** Pure `no_std` rewrite is not required for ICP. + +## Size & limits + +| Dimension | Observation / limit | Knolo implication | +| --- | --- | --- | +| knolo-core knowledge canister Wasm (baseline) | ~860 KiB release | Size baseline from sibling project. | +| `knolo-agent-icp` release Wasm (Phase 1) | ~1.20 MiB | Baseline before effects. | +| `knolo-agent-icp` release Wasm (Phase 2) | **~1.52 MiB** (`1592236` bytes) | Includes ic-llm + timers; re-measure after Phase 3. | +| Definition ingress | Soft cap **2 MiB** (`MAX_DEFINITION_BYTES`) | Same order as knolo-core `MAX_PACK_BYTES`. | +| Update instruction limit | Replica-enforced | Prefer `step` slicing + checkpoints for long graphs (Phase 1 supports step budget via engine). | +| Query vs update | Queries free of consensus write | `inspect`, `get_events`, `get_checkpoint`, `health` are queries. | +| Stable memory (Phase 1) | Coarse `stable_save` of definition + executions JSON | Fine for PoC; upgrade to `ic-stable-structures` in Phase 3. | + +## Cost / latency (qualitative Phase 0) + +| Path | Latency class | Phase | +| --- | --- | --- | +| Pure in-canister steps | Low (local compute) | 1 | +| ic-llm | Medium–high | 2 | +| HTTPS outcalls | High + transform | 2 | +| Inter-canister knowledge `search` | Medium | 2 | + +Map Knolo `max_cost_micros` / step / token limits to cycles **observability** in +Phase 2–3; Phase 1 enforces graph `ExecutionLimitsV1` only. + +## knolo-core ICP patterns to reuse + +| Pattern | Source | Agent reuse | +| --- | --- | --- | +| Controller-gated mutations | `packages/icp-canister` | `load_definition` / `clear_definition` | +| Soft payload size cap | `MAX_PACK_BYTES` | `MAX_DEFINITION_BYTES` | +| Candid DTOs + health | knowledge canister | agent runtime DID | +| dfx custom canister build | `examples/icp-knowledge-canister` | `examples/icp-agent-canister` | +| CLI `knolo icp …` | `@knolo/cli` | Phase 4 for agents | +| pre/post_upgrade snapshot | knowledge canister | Phase 1; improve Phase 3 | + +## Risks tracked + +| Risk | Phase 0 status | +| --- | --- | +| Wasm size blow-up | Monitor release Wasm of `knolo-agent-icp`; keep effects out of Phase 1. | +| Instruction limit on deep graphs | Step slice + resume; graph `max_steps`. | +| Upgrade of rich state | Phase 3 stable structures. | +| Divergence from native | Shared fixtures + unit tests in `knolo-agent-icp`. | + +## Measurement commands + +```bash +cargo check -p knolo-agent-core --target wasm32-unknown-unknown +cargo check -p knolo-agent --target wasm32-unknown-unknown +cargo test -p knolo-agent-icp +cargo build -p knolo-agent-icp --target wasm32-unknown-unknown --release +wc -c target/wasm32-unknown-unknown/release/knolo_agent_icp.wasm +``` diff --git a/docs/wasm.md b/docs/wasm.md index 7323669..abed1d3 100644 --- a/docs/wasm.md +++ b/docs/wasm.md @@ -5,3 +5,11 @@ select `engine: "wasm"` and provide an adapter; absence is an error and never causes a fallback. WASM receives no filesystem, network, clock, or credential authority unless the embedding host explicitly supplies it. + +## ICP canister Wasm (separate path) + +`knolo-agent-icp` is a different `wasm32-unknown-unknown` target: an Internet +Computer **host runtime** (Candid + `ic-cdk`), not the browser JSON protocol. +Build with `cargo build -p knolo-agent-icp --target wasm32-unknown-unknown --release`. +See [ADR-001](architecture/adr-001-icp-agent-runtime.md) and +[examples/icp-agent-canister](../examples/icp-agent-canister/). diff --git a/examples/icp-agent-canister/README.md b/examples/icp-agent-canister/README.md new file mode 100644 index 0000000..0812211 --- /dev/null +++ b/examples/icp-agent-canister/README.md @@ -0,0 +1,69 @@ +# icp-agent-canister + +Local `dfx` example for the Knolo **agent runtime** canister +(`crates/knolo-agent-icp`) — Phase 1 control plane + Phase 2 host effects. + +## What works + +**Phase 1 (always, offline-friendly)** + +- deterministic graph execution +- ordered events, checkpoints, resume / step slicing + +**Phase 2 (effects)** + +- pack-gated tools (`echo`, optional `https_get`) +- LLM via **ic-llm** (`await_llm` suspend → inter-canister prompt → resume) +- retrieval via knolo-core knowledge canister principal (or mock without one) +- timers for `auto_continue` on `step_slice` +- cycles observation + Knolo budget snapshot (`get_budget`) + +Unit tests resolve effects with deterministic mocks (no network). Live LLM +requires a reachable LLM canister (mainnet id used by `ic-llm`, or a local +deploy). Without it, pure definitions like `portable-counter` still run. + +## Prerequisites + +```bash +rustup target add wasm32-unknown-unknown +# dfx 0.20.x recommended +``` + +From the **repository root**: + +```bash +cargo test -p knolo-agent-icp +cargo build -p knolo-agent-icp --target wasm32-unknown-unknown --release +``` + +## Run (Phase 1 smoke) + +```bash +cd examples/icp-agent-canister +TERM=xterm-256color dfx start --background +TERM=xterm-256color dfx deploy +TERM=xterm-256color bash scripts/run-deterministic.sh +``` + +## Candid surface + +| Method | Kind | Purpose | +| --- | --- | --- | +| `health` | query | Ready if definition loaded | +| `inspect` | query | Graph hash, capabilities, limitations | +| `get_budget` | query | Knolo + cycles budget snapshot | +| `load_definition` | update (controller) | JSON agent definition (+ pack/host) | +| `clear_definition` | update (controller) | Clear graph + executions | +| `start_execution` | update | Run + auto-resolve host effects | +| `step` / `resume` | update | Step-slice / HITL / effect resume | +| `continue_effects` | update | Drain pending host effects | +| `get_events` | query | Ordered event log JSON | +| `get_checkpoint` | query | Last checkpoint JSON | + +Fixtures: + +- `fixtures/portable-counter.definition.json` — pure Phase 1 +- `fixtures/host-effects.definition.json` — Phase 2 effect graph (needs LLM for live run) + +See `docs/architecture/adr-001-icp-agent-runtime.md` and +`docs/architecture/icp-constraints-matrix.md`. diff --git a/examples/icp-agent-canister/dfx.json b/examples/icp-agent-canister/dfx.json new file mode 100644 index 0000000..127cb95 --- /dev/null +++ b/examples/icp-agent-canister/dfx.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "dfx": "0.20.0", + "canisters": { + "knolo_agent_runtime": { + "type": "custom", + "candid": "../../crates/knolo-agent-icp/candid/agent_runtime.did", + "wasm": "../../target/wasm32-unknown-unknown/release/knolo_agent_icp.wasm", + "build": [ + "cargo build --target wasm32-unknown-unknown --release -p knolo-agent-icp --manifest-path ../../Cargo.toml" + ], + "metadata": [ + { + "name": "candid:service" + } + ], + "declarations": { + "bindings": ["did"], + "output": "./.dfx/local/declarations/knolo_agent_runtime" + } + } + } +} diff --git a/examples/icp-agent-canister/fixtures/host-effects.definition.json b/examples/icp-agent-canister/fixtures/host-effects.definition.json new file mode 100644 index 0000000..f9a0b75 --- /dev/null +++ b/examples/icp-agent-canister/fixtures/host-effects.definition.json @@ -0,0 +1,122 @@ +{ + "version": 1, + "implementation_id": "host-effects-v1", + "pack_hash": "pack-host", + "policy_hash": "policy-host", + "contract_hash": "contract-host", + "host": { + "auto_continue": false, + "timer_ns": 1000000000, + "llm_enabled": true, + "llm_model": "llama3.1:8b", + "knowledge_canister": null, + "allow_https_tools": false, + "max_effect_rounds": 8 + }, + "pack": { + "version": 1, + "id": "icp-host-pack", + "tools": ["echo"], + "namespaces": ["tools"], + "argument_constraints": {}, + "budget": { + "max_calls": 32, + "max_units": 10000, + "max_duration_ms": 60000 + }, + "capability_bindings": { + "echo": "builtin:echo" + } + }, + "graph": { + "version": 1, + "id": "host-effects", + "state_schema": "effects-state", + "entry": "prepare", + "nodes": [ + { + "id": "prepare", + "terminal": false, + "reads": ["/phase"], + "writes": ["/phase"] + }, + { + "id": "llm", + "terminal": false, + "reads": ["/prompt", "/phase"], + "writes": ["/phase", "/llm_result"] + }, + { + "id": "tool", + "terminal": false, + "reads": ["/tool_id", "/tool_args", "/phase"], + "writes": ["/phase", "/tool_result"] + }, + { + "id": "retrieve", + "terminal": false, + "reads": ["/query", "/phase"], + "writes": ["/phase", "/retrieval_result"] + }, + { + "id": "done", + "terminal": true, + "reads": [ + "/phase", + "/llm_result", + "/tool_result", + "/retrieval_result" + ], + "writes": [] + } + ], + "transitions": [ + { + "id": "t1", + "from": "prepare", + "route": "continue", + "to": "llm" + }, + { + "id": "t2", + "from": "llm", + "route": "continue", + "to": "tool" + }, + { + "id": "t3", + "from": "tool", + "route": "continue", + "to": "retrieve" + }, + { + "id": "t4", + "from": "retrieve", + "route": "continue", + "to": "done" + } + ], + "cycles": [], + "limits": { + "max_steps": 20, + "max_tokens": 10000, + "max_cost_micros": 100000, + "timeout_ms": 60000 + } + }, + "schema": { + "version": 1, + "id": "effects-state", + "paths": { + "/phase": "String", + "/prompt": "String", + "/tool_id": "String", + "/tool_args": "Object", + "/query": "String", + "/llm_result": "Object", + "/tool_result": "Object", + "/retrieval_result": "Object" + }, + "required": ["/phase", "/prompt"] + } +} diff --git a/examples/icp-agent-canister/fixtures/initial-state.json b/examples/icp-agent-canister/fixtures/initial-state.json new file mode 100644 index 0000000..3bbfdaa --- /dev/null +++ b/examples/icp-agent-canister/fixtures/initial-state.json @@ -0,0 +1,8 @@ +{ + "schema_id": "counter-state", + "revision": 0, + "value": { + "count": 0 + }, + "provenance": null +} diff --git a/examples/icp-agent-canister/fixtures/portable-counter.definition.json b/examples/icp-agent-canister/fixtures/portable-counter.definition.json new file mode 100644 index 0000000..e580e78 --- /dev/null +++ b/examples/icp-agent-canister/fixtures/portable-counter.definition.json @@ -0,0 +1,50 @@ +{ + "version": 1, + "implementation_id": "portable-counter-v1", + "pack_hash": "pack-none", + "policy_hash": "policy-none", + "contract_hash": "contract-none", + "graph": { + "version": 1, + "id": "portable-counter", + "state_schema": "counter-state", + "entry": "increment", + "nodes": [ + { + "id": "increment", + "terminal": false, + "reads": ["/count"], + "writes": ["/count"] + }, + { + "id": "done", + "terminal": true, + "reads": ["/count"], + "writes": [] + } + ], + "transitions": [ + { + "id": "increment.continue.done", + "from": "increment", + "route": "continue", + "to": "done" + } + ], + "cycles": [], + "limits": { + "max_steps": 10, + "max_tokens": 100, + "max_cost_micros": 1000, + "timeout_ms": 30000 + } + }, + "schema": { + "version": 1, + "id": "counter-state", + "paths": { + "/count": "Number" + }, + "required": ["/count"] + } +} diff --git a/examples/icp-agent-canister/scripts/run-deterministic.sh b/examples/icp-agent-canister/scripts/run-deterministic.sh new file mode 100755 index 0000000..706c2e0 --- /dev/null +++ b/examples/icp-agent-canister/scripts/run-deterministic.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Local dfx smoke: load pure definition, run, inspect events + checkpoint. +set -euo pipefail + +export TERM="${TERM:-xterm-256color}" + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +ARGS_DIR="$(mktemp -d)" +trap 'rm -rf "$ARGS_DIR"' EXIT +export ARGS_DIR + +python3 <<'PY' +import json +import os +from pathlib import Path + +root = Path(".") +defn = (root / "fixtures/portable-counter.definition.json").read_text() +state = (root / "fixtures/initial-state.json").read_text() +out = Path(os.environ["ARGS_DIR"]) + +def candid_text(s: str) -> str: + return json.dumps(s) + +(out / "load.did").write_text(f"({candid_text(defn)})\n") +(out / "start.did").write_text( + f"({candid_text('demo-run-1')}, {candid_text(state)})\n" +) +print("wrote candid args to", out) +PY + +echo "== health ==" +dfx canister call knolo_agent_runtime health + +echo "== load_definition ==" +dfx canister call knolo_agent_runtime load_definition --argument-file "$ARGS_DIR/load.did" + +echo "== inspect ==" +dfx canister call knolo_agent_runtime inspect + +echo "== start_execution ==" +dfx canister call knolo_agent_runtime start_execution --argument-file "$ARGS_DIR/start.did" + +echo "== get_events ==" +dfx canister call knolo_agent_runtime get_events '("demo-run-1")' + +echo "== get_checkpoint ==" +dfx canister call knolo_agent_runtime get_checkpoint '("demo-run-1")' + +echo "OK: deterministic run completed" From 217780ae52e6176de891c186e37e9f1ae6f8ccc7 Mon Sep 17 00:00:00 2001 From: Sam Paniagua Date: Mon, 3 Aug 2026 17:25:35 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(icp):=20complete=20agent=20runtime=20P?= =?UTF-8?q?hases=203=E2=80=934?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add upgrade-safe ic-stable-structures persistence, runtime limits and caller hardening, multi-agent handoff, scripts/templates, TS client, and cost/security docs for the ICP agent canister. --- CHANGELOG.md | 35 +- Cargo.lock | 10 + FUTURE.md | 18 +- README.md | 2 +- crates/knolo-agent-icp/Cargo.toml | 1 + .../knolo-agent-icp/candid/agent_runtime.did | 48 ++ crates/knolo-agent-icp/src/auth.rs | 80 +++ crates/knolo-agent-icp/src/dto.rs | 86 +++ crates/knolo-agent-icp/src/engine.rs | 151 +++++- crates/knolo-agent-icp/src/handoff.rs | 211 ++++++++ crates/knolo-agent-icp/src/lib.rs | 476 ++++++++++++++--- crates/knolo-agent-icp/src/limits.rs | 122 +++++ crates/knolo-agent-icp/src/stable_store.rs | 493 ++++++++++++++++++ docs/README.md | 2 +- docs/architecture/README.md | 12 +- .../architecture/adr-001-icp-agent-runtime.md | 45 +- docs/architecture/icp-constraints-matrix.md | 13 +- docs/architecture/icp-cost-guide.md | 86 +++ docs/architecture/icp-security-checklist.md | 63 +++ examples/icp-agent-canister/README.md | 77 ++- .../fixtures/handoff-parent.authority.json | 6 + .../fixtures/handoff.envelope.json | 14 + .../icp-agent-canister/scripts/run-handoff.sh | 18 + packages/agents/README.md | 7 +- packages/agents/src/icp/index.ts | 337 ++++++++++++ packages/agents/src/index.ts | 1 + packages/agents/tests/runtime.test.mjs | 172 +++++- scripts/icp/build.sh | 11 + scripts/icp/deploy-local.sh | 16 + scripts/icp/init-template.sh | 64 +++ scripts/icp/load-definition.sh | 13 + 31 files changed, 2548 insertions(+), 142 deletions(-) create mode 100644 crates/knolo-agent-icp/src/auth.rs create mode 100644 crates/knolo-agent-icp/src/handoff.rs create mode 100644 crates/knolo-agent-icp/src/limits.rs create mode 100644 crates/knolo-agent-icp/src/stable_store.rs create mode 100644 docs/architecture/icp-cost-guide.md create mode 100644 docs/architecture/icp-security-checklist.md create mode 100644 examples/icp-agent-canister/fixtures/handoff-parent.authority.json create mode 100644 examples/icp-agent-canister/fixtures/handoff.envelope.json create mode 100755 examples/icp-agent-canister/scripts/run-handoff.sh create mode 100644 packages/agents/src/icp/index.ts create mode 100755 scripts/icp/build.sh create mode 100755 scripts/icp/deploy-local.sh create mode 100755 scripts/icp/init-template.sh create mode 100755 scripts/icp/load-definition.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index e325930..d6f1974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,11 @@ called out explicitly and may evolve without a crates.io release. ### Added -#### ICP agent runtime (Phases 0–2) +#### ICP agent runtime (Phases 0–4) - New workspace crate **`knolo-agent-icp`** (`publish = false`): Internet Computer canister host for the Knolo control plane (`ic-cdk` 0.17, Candid, `ic-llm` 1.1, - `ic-cdk-timers` 0.11). + `ic-cdk-timers` 0.11, `ic-stable-structures` 0.6). - **Phase 0 — discovery** - Architecture decision record: [`docs/architecture/adr-001-icp-agent-runtime.md`](docs/architecture/adr-001-icp-agent-runtime.md) @@ -42,6 +42,26 @@ called out explicitly and may evolve without a crates.io release. - Effect drain API: `continue_effects`. - Host-effects fixture and implementation id `host-effects-v1`. - Release Wasm size ~1.52 MiB (Phase 2); Phase 1 baseline was ~1.20 MiB. +- **Phase 3 — persistence, hardening, multi-agent handoff** + - Versioned **`ic-stable-structures`** schema v1: definition, pack meta, + executions, checkpoints, events, budget, runtime limits, handoffs. + - Upgrade flush/reload via MemoryManager (Phase 1–2 `stable_save` not migrated). + - Runtime limits + caller allowlist / controller-only runs (`set_limits`, + `get_limits`); concurrent execution and event log caps; cycles reserve. + - Multi-agent handoff: `accept_handoff`, `forward_handoff`, `get_handoff` + using `HandoffEnvelopeV1` authority narrowing. + - Ops queries: `get_store_stats`, `list_executions`. + - Security checklist: + [`docs/architecture/icp-security-checklist.md`](docs/architecture/icp-security-checklist.md). + - Release Wasm size ~1.80 MiB (`1882138` bytes). +- **Phase 4 — DX, packaging, ecosystem** + - Scripts: [`scripts/icp/`](scripts/icp/) (`build`, `deploy-local`, + `load-definition`, `init-template`). + - TypeScript client: `IcpAgentRuntimeClient` + candid DTOs in `@knolo/agents` + (`packages/agents/src/icp/`); optional `@dfinity/*` peers for live actors. + - Cost guide: + [`docs/architecture/icp-cost-guide.md`](docs/architecture/icp-cost-guide.md). + - Handoff fixtures + `scripts/run-handoff.sh` in the dfx example. - Documentation cross-links in README, architecture index, WASM notes, and `FUTURE.md` platform target status. - Root `.gitignore` entries for `.plans/` (local planning notes) and `.dfx/`. @@ -52,11 +72,12 @@ called out explicitly and may evolve without a crates.io release. - Browser `knolo-agent-wasm` remains a separate path from the ICP canister host. - Live LLM runs require a reachable LLM canister; pure deterministic graphs run without it. -- Phase 3+ (not in this change): `ic-stable-structures`, production hardening, - multi-agent handoff, DX/CLI templates. +- Optional later: factory topology, AgentForge registry, production HTTPS + transforms, mainnet ops runbooks beyond the checklist. ### Unchanged published crates -- No intentional public API changes to `knolo-agent`, `knolo-agent-core`, or - `@knolo/agents` in this branch; version numbers remain as on `main` / prior - release line until a coordinated publish. +- No intentional public API changes to `knolo-agent` or `knolo-agent-core` in + this branch. `@knolo/agents` gains additive ICP client exports (non-breaking). + Version numbers remain as on `main` / prior release line until a coordinated + publish. diff --git a/Cargo.lock b/Cargo.lock index 343574a..976d034 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -349,6 +349,15 @@ dependencies = [ "serde", ] +[[package]] +name = "ic-stable-structures" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d30d4cf17aff1024e13133897048bcba580e063c9000571ab766ca37e2996f4" +dependencies = [ + "ic_principal", +] + [[package]] name = "ic0" version = "0.23.0" @@ -401,6 +410,7 @@ dependencies = [ "ic-cdk-macros", "ic-cdk-timers", "ic-llm", + "ic-stable-structures", "knolo-agent", "knolo-agent-core", "serde", diff --git a/FUTURE.md b/FUTURE.md index a347b3c..fd26475 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -232,15 +232,23 @@ examples, evaluation harnesses, and pre-1.0 API freeze work. - **What:** Host `knolo-agent-core` + scheduler inside an ICP canister so the canister is the Host: packs, checkpoints, events, tools, ic-llm / outcalls, and optional calls to knolo-core knowledge canisters. -- **Status (Phase 0–2 landed):** Workspace crate `knolo-agent-icp`, ADR-001, +- **Status (Phase 0–4 landed):** Workspace crate `knolo-agent-icp`, ADR-001, constraints matrix, deterministic Candid control plane, pack-gated tools, ic-llm suspend/resume, knowledge retrieval principal, timers for `auto_continue`, cycles + Knolo budget snapshot (`get_budget`), - `examples/icp-agent-canister` dfx smoke. Release Wasm ~1.52 MiB. -- **Next (Phase 3+):** `ic-stable-structures` upgrade-safe schemas, security - hardening, multi-agent handoff, DX/CLI templates. + **Phase 3:** `ic-stable-structures` schema v1 (definition, pack meta, + executions, checkpoints, events, budget, limits, handoffs), controller/run + auth, DoS limits, multi-agent `accept_handoff` / `forward_handoff`, security + checklist. **Phase 4:** `scripts/icp/*` (build/deploy/load/init-template), + `@knolo/agents` `IcpAgentRuntimeClient`, cost guide, expanded dfx example. + Release Wasm ~1.80 MiB (Phase 3). +- **Optional later:** AgentForge-style registry, factory-per-agent topology, + HTTPS outcall production transforms, mainnet ops runbooks beyond the + checklist. - **Docs:** `docs/architecture/adr-001-icp-agent-runtime.md`, - `docs/architecture/icp-constraints-matrix.md`. Local planning notes in + `docs/architecture/icp-constraints-matrix.md`, + `docs/architecture/icp-cost-guide.md`, + `docs/architecture/icp-security-checklist.md`. Local planning notes in `.plans/` (gitignored). ## Explicit non-goals (for now) diff --git a/README.md b/README.md index e8c5c1d..83ddf42 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ job queue, or application-specific data layer. | `knolo-agent-core` | Portable contracts, graph/state validation, policy types, events, replay, checkpoints, and pack declarations. | | `knolo-agent` | Native Rust scheduler, host effect boundaries, policy enforcement, pack loading, and durable runtime integrations. | | `knolo-agent-wasm` | Small JSON/WASM protocol adapter for embedding the portable contracts. Not currently published separately. | -| `knolo-agent-icp` | ICP canister host for the control plane (Phase 1 PoC: deterministic graphs, checkpoints, events). Workspace-only. | +| `knolo-agent-icp` | ICP canister host for the control plane (Phases 0–4: deterministic runtime, host effects, stable structures, handoff, DX). Workspace-only. | | `@knolo/agents` | Typed TypeScript builders, the deterministic state/routing/suspension engine, and explicit WASM integration. | | `@knolo/core` | Separate peer dependency owned by the consumer; it can provide Cortex and ClaimGraph implementations. | diff --git a/crates/knolo-agent-icp/Cargo.toml b/crates/knolo-agent-icp/Cargo.toml index e2dadce..7c86e56 100644 --- a/crates/knolo-agent-icp/Cargo.toml +++ b/crates/knolo-agent-icp/Cargo.toml @@ -18,6 +18,7 @@ ic-cdk = "0.17" ic-cdk-macros = "0.17" ic-cdk-timers = "0.11" ic-llm = "1.1" +ic-stable-structures = "0.6" knolo-agent = { workspace = true } knolo-agent-core = { workspace = true } serde = { workspace = true } diff --git a/crates/knolo-agent-icp/candid/agent_runtime.did b/crates/knolo-agent-icp/candid/agent_runtime.did index b2d7651..5c4bb03 100644 --- a/crates/knolo-agent-icp/candid/agent_runtime.did +++ b/crates/knolo-agent-icp/candid/agent_runtime.did @@ -14,6 +14,8 @@ type InspectionDto = record { capabilities : vec text; limitations : vec text; message : text; + schema_version : nat32; + handoff_count : nat64; }; type StatusDto = record { @@ -63,16 +65,62 @@ type BudgetDto = record { message : text; }; +type LimitsDto = record { + ok : bool; + max_concurrent_executions : nat32; + max_events_per_execution : nat32; + max_execution_id_len : nat32; + max_state_bytes : nat32; + max_handoff_bytes : nat32; + require_controller_for_runs : bool; + allowed_callers : vec text; + min_cycles_reserve : nat64; + message : text; +}; + +type StoreStatsDto = record { + ok : bool; + schema_version : nat32; + execution_count : nat64; + checkpoint_count : nat64; + event_entry_count : nat64; + handoff_count : nat64; + has_definition : bool; + message : text; +}; + +type ExecutionListDto = record { + ok : bool; + execution_ids : vec text; + message : text; +}; + +type HandoffDto = record { + ok : bool; + handoff_id : text; + execution_id : text; + destination : text; + status : text; + message : text; +}; + service : { health : () -> (HealthDto) query; inspect : () -> (InspectionDto) query; get_budget : () -> (BudgetDto) query; + get_limits : () -> (LimitsDto) query; + get_store_stats : () -> (StoreStatsDto) query; + list_executions : () -> (ExecutionListDto) query; load_definition : (text) -> (HealthDto); clear_definition : () -> (HealthDto); + set_limits : (nat32, nat32, nat32, bool, vec text, nat64) -> (LimitsDto); start_execution : (text, text) -> (RunReportDto); step : (text, nat32) -> (RunReportDto); resume : (text) -> (RunReportDto); continue_effects : (text) -> (RunReportDto); + accept_handoff : (text, text, text, text) -> (HandoffDto); + forward_handoff : (text, text, text, text, text) -> (HandoffDto); + get_handoff : (text) -> (HandoffDto) query; get_events : (text) -> (EventsDto) query; get_checkpoint : (text) -> (CheckpointDto) query; } diff --git a/crates/knolo-agent-icp/src/auth.rs b/crates/knolo-agent-icp/src/auth.rs new file mode 100644 index 0000000..c88a635 --- /dev/null +++ b/crates/knolo-agent-icp/src/auth.rs @@ -0,0 +1,80 @@ +//! Caller authorization for controller-gated and run-gated methods (Phase 3). +use crate::dto::HealthDto; +use crate::limits::RuntimeLimitsV1; +use candid::Principal; + +/// Controller check used for load_definition / clear / set_limits. +pub fn require_controller(caller: Principal, is_controller: bool) -> Result<(), HealthDto> { + if is_controller { + Ok(()) + } else { + Err(HealthDto::err(format!( + "Unauthorized: caller {caller} is not a controller of this canister." + ))) + } +} + +/// Run authorization: controllers always allowed; otherwise allowed_callers or open. +pub fn require_run_access( + caller: Principal, + is_controller: bool, + limits: &RuntimeLimitsV1, +) -> Result<(), HealthDto> { + if is_controller { + return Ok(()); + } + if limits.require_controller_for_runs { + return Err(HealthDto::err(format!( + "Unauthorized: runs require a controller (caller {caller})." + ))); + } + if limits.allowed_callers.is_empty() { + return Ok(()); + } + let text = caller.to_text(); + if limits.allowed_callers.iter().any(|p| p == &text) { + Ok(()) + } else { + Err(HealthDto::err(format!( + "Unauthorized: caller {caller} is not in allowed_callers." + ))) + } +} + +/// Cycles reserve guard (best-effort; 0 disables). +pub fn require_cycles_reserve( + balance: Option, + limits: &RuntimeLimitsV1, +) -> Result<(), String> { + if limits.min_cycles_reserve == 0 { + return Ok(()); + } + match balance { + Some(b) if b < limits.min_cycles_reserve => Err(format!( + "cycles reserve too low: balance {b} < min {}", + limits.min_cycles_reserve + )), + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_runs_when_no_allowlist() { + let limits = RuntimeLimitsV1::default(); + let anon = Principal::anonymous(); + assert!(require_run_access(anon, false, &limits).is_ok()); + } + + #[test] + fn allowlist_enforced() { + let mut limits = RuntimeLimitsV1::default(); + limits.allowed_callers = vec!["aaaaa-aa".into()]; + let anon = Principal::anonymous(); + assert!(require_run_access(anon, false, &limits).is_err()); + assert!(require_run_access(anon, true, &limits).is_ok()); + } +} diff --git a/crates/knolo-agent-icp/src/dto.rs b/crates/knolo-agent-icp/src/dto.rs index 15a2f8e..00f1953 100644 --- a/crates/knolo-agent-icp/src/dto.rs +++ b/crates/knolo-agent-icp/src/dto.rs @@ -1,6 +1,8 @@ //! Candid DTOs for the agent runtime canister surface. use crate::budget::HostBudgetSnapshotV1; use crate::engine::ExecutionRecord; +use crate::limits::RuntimeLimitsV1; +use crate::stable_store::StoreStats; use candid::CandidType; use serde::Deserialize; @@ -22,6 +24,41 @@ pub struct InspectionDto { pub capabilities: Vec, pub limitations: Vec, pub message: String, + pub schema_version: u32, + pub handoff_count: u64, +} + +#[derive(CandidType, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct LimitsDto { + pub ok: bool, + pub max_concurrent_executions: u32, + pub max_events_per_execution: u32, + pub max_execution_id_len: u32, + pub max_state_bytes: u32, + pub max_handoff_bytes: u32, + pub require_controller_for_runs: bool, + pub allowed_callers: Vec, + pub min_cycles_reserve: u64, + pub message: String, +} + +#[derive(CandidType, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct StoreStatsDto { + pub ok: bool, + pub schema_version: u32, + pub execution_count: u64, + pub checkpoint_count: u64, + pub event_entry_count: u64, + pub handoff_count: u64, + pub has_definition: bool, + pub message: String, +} + +#[derive(CandidType, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct ExecutionListDto { + pub ok: bool, + pub execution_ids: Vec, + pub message: String, } #[derive(CandidType, Deserialize, Clone, Debug, PartialEq, Eq)] @@ -150,3 +187,52 @@ impl From<&HostBudgetSnapshotV1> for BudgetDto { } } } + +impl From<&RuntimeLimitsV1> for LimitsDto { + fn from(l: &RuntimeLimitsV1) -> Self { + Self { + ok: true, + max_concurrent_executions: l.max_concurrent_executions, + max_events_per_execution: l.max_events_per_execution, + max_execution_id_len: l.max_execution_id_len, + max_state_bytes: l.max_state_bytes, + max_handoff_bytes: l.max_handoff_bytes, + require_controller_for_runs: l.require_controller_for_runs, + allowed_callers: l.allowed_callers.clone(), + min_cycles_reserve: l.min_cycles_reserve.min(u64::MAX as u128) as u64, + message: "runtime limits".into(), + } + } +} + +impl From<&StoreStats> for StoreStatsDto { + fn from(s: &StoreStats) -> Self { + Self { + ok: true, + schema_version: s.schema_version, + execution_count: s.execution_count, + checkpoint_count: s.checkpoint_count, + event_entry_count: s.event_entry_count, + handoff_count: s.handoff_count, + has_definition: s.has_definition, + message: "stable store stats".into(), + } + } +} + +impl LimitsDto { + pub fn err(message: impl Into) -> Self { + Self { + ok: false, + max_concurrent_executions: 0, + max_events_per_execution: 0, + max_execution_id_len: 0, + max_state_bytes: 0, + max_handoff_bytes: 0, + require_controller_for_runs: false, + allowed_callers: vec![], + min_cycles_reserve: 0, + message: message.into(), + } + } +} diff --git a/crates/knolo-agent-icp/src/engine.rs b/crates/knolo-agent-icp/src/engine.rs index 99c733d..9209684 100644 --- a/crates/knolo-agent-icp/src/engine.rs +++ b/crates/knolo-agent-icp/src/engine.rs @@ -2,7 +2,11 @@ use crate::budget::HostBudgetTracker; use crate::definition::LoadedDefinition; use crate::executor::{is_host_effect_suspend, DeterministicExecutor}; +use crate::handoff::{ + authority_from_pack, parse_and_validate_envelope, parse_authority, HandoffRecordV1, +}; use crate::host::{empty_sink, empty_store, fixed_clock}; +use crate::limits::{PackMetaV1, RuntimeLimitsV1}; use crate::tools_host::{default_registry, execute_tool_call}; use knolo_agent::host::ToolRegistry; use knolo_agent::runtime::{ @@ -33,6 +37,9 @@ pub struct ExecutionRecord { pub effect_cache: BTreeMap, #[serde(default)] pub timer_scheduled: bool, + /// Optional handoff that created this execution. + #[serde(default)] + pub handoff_id: Option, } #[derive(Default)] @@ -42,6 +49,9 @@ pub struct AgentEngine { pub store: knolo_agent::checkpoint::InMemoryCheckpointStore, pub budget: HostBudgetTracker, pub tools: ToolRegistry, + pub limits: RuntimeLimitsV1, + pub pack_meta: Option, + pub handoffs: BTreeMap, } impl AgentEngine { @@ -53,8 +63,18 @@ impl AgentEngine { loaded.compiled.definition().id, loaded.bundle.implementation_id ); + self.pack_meta = Some(PackMetaV1 { + pack_hash: loaded.bundle.pack_hash.clone(), + policy_hash: loaded.bundle.policy_hash.clone(), + contract_hash: loaded.bundle.contract_hash.clone(), + graph_id: loaded.compiled.definition().id.to_string(), + graph_hash: loaded.compiled.hash().into(), + implementation_id: loaded.bundle.implementation_id.clone(), + pack_id: loaded.bundle.pack.as_ref().map(|p| p.id.to_string()), + }); self.definition = Some(loaded); self.executions.clear(); + self.handoffs.clear(); self.store = empty_store(); self.budget = HostBudgetTracker::default(); self.tools = default_registry(allow_https); @@ -64,6 +84,8 @@ impl AgentEngine { pub fn clear_definition(&mut self) -> String { let had = self.definition.take().is_some(); self.executions.clear(); + self.handoffs.clear(); + self.pack_meta = None; self.store = empty_store(); self.budget = HostBudgetTracker::default(); self.tools = ToolRegistry::default(); @@ -78,26 +100,122 @@ impl AgentEngine { self.definition.as_ref().and_then(|d| d.policy.as_ref()) } + pub fn set_limits(&mut self, limits: RuntimeLimitsV1) -> Result<(), CoreError> { + if limits.version != 1 { + return Err(CoreError::Host(format!( + "unsupported limits version {}", + limits.version + ))); + } + if limits.max_concurrent_executions == 0 { + return Err(CoreError::Host( + "max_concurrent_executions must be > 0".into(), + )); + } + self.limits = limits; + Ok(()) + } + + fn guard_new_execution(&self, execution_id: &str, state_json: &str) -> Result<(), CoreError> { + self.limits + .validate_execution_id(execution_id) + .map_err(CoreError::Host)?; + self.limits + .validate_state_bytes(state_json) + .map_err(CoreError::Host)?; + self.limits + .check_capacity(self.executions.len()) + .map_err(CoreError::Host)?; + if self.executions.contains_key(execution_id) { + return Err(CoreError::Host(format!( + "execution '{execution_id}' already exists" + ))); + } + Ok(()) + } + pub fn start_execution( &mut self, execution_id: &str, initial_state_json: &str, ) -> Result { + self.guard_new_execution(execution_id, initial_state_json)?; let state = parse_state(initial_state_json)?; let id = ExecutionId::from_str(execution_id) .map_err(|e| CoreError::Host(format!("invalid execution_id: {e}")))?; - if self.executions.contains_key(execution_id) { + let report = self.run_from_start(&id, state, None, BTreeMap::new())?; + let mut record = self.record_from_report(execution_id, report, BTreeMap::new())?; + self.apply_event_cap(&mut record); + self.budget + .sync_run_totals(record.steps, record.tokens, record.cost_micros); + self.executions + .insert(execution_id.to_owned(), record.clone()); + Ok(record) + } + + /// Accept a narrowed multi-agent handoff and start a local execution. + pub fn accept_handoff( + &mut self, + execution_id: &str, + envelope_json: &str, + state_json: &str, + parent_authority_json: &str, + ) -> Result<(ExecutionRecord, HandoffRecordV1), CoreError> { + self.guard_new_execution(execution_id, state_json)?; + let def = self + .definition + .as_ref() + .ok_or_else(|| CoreError::Host("no definition loaded".into()))?; + let graph_limits = &def.compiled.definition().limits; + let pack_auth = authority_from_pack( + def.bundle.pack.as_ref(), + graph_limits.max_steps, + graph_limits.max_cost_micros, + ); + let parent = parse_authority(parent_authority_json)?; + let envelope = + parse_and_validate_envelope(envelope_json, &parent, &pack_auth, &self.limits)?; + + // Destination must match the loaded graph (this runtime hosts one graph). + let loaded_id = def.compiled.definition().id.as_str(); + if envelope.destination.as_str() != loaded_id { return Err(CoreError::Host(format!( - "execution '{execution_id}' already exists" + "handoff destination '{}' does not match loaded graph '{loaded_id}'", + envelope.destination ))); } + + let handoff_id = format!("handoff-{execution_id}"); + let state = parse_state(state_json)?; + let id = ExecutionId::from_str(execution_id) + .map_err(|e| CoreError::Host(format!("invalid execution_id: {e}")))?; let report = self.run_from_start(&id, state, None, BTreeMap::new())?; - let record = self.record_from_report(execution_id, report, BTreeMap::new())?; + let mut record = self.record_from_report(execution_id, report, BTreeMap::new())?; + record.handoff_id = Some(handoff_id.clone()); + self.apply_event_cap(&mut record); self.budget .sync_run_totals(record.steps, record.tokens, record.cost_micros); self.executions .insert(execution_id.to_owned(), record.clone()); - Ok(record) + + let hrecord = HandoffRecordV1 { + version: 1, + handoff_id: handoff_id.clone(), + execution_id: execution_id.into(), + destination: envelope.destination.to_string(), + return_contract: envelope.return_contract.clone(), + parent_authority: parent, + child_authority: envelope.authority_projection.clone(), + status: "accepted".into(), + peer_canister: None, + message: "handoff accepted; execution started".into(), + }; + self.handoffs.insert(handoff_id, hrecord.clone()); + Ok((record, hrecord)) + } + + fn apply_event_cap(&self, record: &mut ExecutionRecord) { + self.limits.truncate_events_if_needed(&mut record.events); } pub fn step( @@ -456,6 +574,7 @@ impl AgentEngine { pending_resume: None, effect_cache: effect_cache.clone(), timer_scheduled: false, + handoff_id: None, }, ) .ok() @@ -463,7 +582,7 @@ impl AgentEngine { } else { None }; - Ok(ExecutionRecord { + let mut record = ExecutionRecord { execution_id: execution_id.into(), status_kind, status_detail, @@ -476,7 +595,10 @@ impl AgentEngine { pending_resume, effect_cache, timer_scheduled: false, - }) + handoff_id: None, + }; + self.apply_event_cap(&mut record); + Ok(record) } fn merge_step_report( @@ -518,6 +640,7 @@ impl AgentEngine { pending_resume: None, effect_cache: previous.effect_cache.clone(), timer_scheduled: false, + handoff_id: previous.handoff_id.clone(), }, ) .ok(); @@ -532,7 +655,7 @@ impl AgentEngine { events.push(e); } - Ok(ExecutionRecord { + let mut record = ExecutionRecord { execution_id: execution_id.into(), status_kind, status_detail, @@ -545,7 +668,10 @@ impl AgentEngine { pending_resume, effect_cache: previous.effect_cache.clone(), timer_scheduled: false, - }) + handoff_id: previous.handoff_id.clone(), + }; + self.apply_event_cap(&mut record); + Ok(record) } } @@ -586,21 +712,18 @@ pub fn start_with_budget( initial_state_json: &str, max_node_steps: u32, ) -> Result { + engine.guard_new_execution(execution_id, initial_state_json)?; let state = parse_state(initial_state_json)?; let id = ExecutionId::from_str(execution_id) .map_err(|e| CoreError::Host(format!("invalid execution_id: {e}")))?; - if engine.executions.contains_key(execution_id) { - return Err(CoreError::Host(format!( - "execution '{execution_id}' already exists" - ))); - } let budget = if max_node_steps == 0 { None } else { Some(max_node_steps) }; let report = engine.run_from_start(&id, state, budget, BTreeMap::new())?; - let record = engine.record_from_report(execution_id, report, BTreeMap::new())?; + let mut record = engine.record_from_report(execution_id, report, BTreeMap::new())?; + engine.apply_event_cap(&mut record); engine .executions .insert(execution_id.to_owned(), record.clone()); diff --git a/crates/knolo-agent-icp/src/handoff.rs b/crates/knolo-agent-icp/src/handoff.rs new file mode 100644 index 0000000..1f0d134 --- /dev/null +++ b/crates/knolo-agent-icp/src/handoff.rs @@ -0,0 +1,211 @@ +//! Multi-agent handoff via `HandoffEnvelopeV1` (Phase 3). +//! +//! Accepts narrowed envelopes, validates against parent + pack authority, and +//! starts a local execution. Optional inter-canister forward targets a peer +//! agent runtime's `accept_handoff` method. +use crate::limits::RuntimeLimitsV1; +use candid::{CandidType, Principal}; +use knolo_agent_core::handoff::{AuthorityV1, HandoffEnvelopeV1}; +use knolo_agent_core::pack::PackDeclarationV1; +use knolo_agent_core::CoreError; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// Durable audit record for an accepted or forwarded handoff. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct HandoffRecordV1 { + pub version: u16, + pub handoff_id: String, + pub execution_id: String, + pub destination: String, + pub return_contract: String, + pub parent_authority: AuthorityV1, + pub child_authority: AuthorityV1, + pub status: String, + pub peer_canister: Option, + pub message: String, +} + +/// Build pack authority used as the hard ceiling for handoff narrowing. +pub fn authority_from_pack( + pack: Option<&PackDeclarationV1>, + graph_max_steps: u64, + graph_max_cost_micros: u64, +) -> AuthorityV1 { + match pack { + Some(p) => AuthorityV1 { + capabilities: p + .capability_bindings + .keys() + .map(|c| c.to_string()) + .collect::>(), + namespaces: p + .namespaces + .iter() + .map(|n| n.to_string()) + .collect::>(), + max_steps: graph_max_steps, + max_cost_micros: graph_max_cost_micros, + }, + None => AuthorityV1 { + capabilities: BTreeSet::new(), + namespaces: BTreeSet::new(), + max_steps: graph_max_steps, + max_cost_micros: graph_max_cost_micros, + }, + } +} + +/// Parse and validate a handoff envelope against parent + pack authority. +pub fn parse_and_validate_envelope( + envelope_json: &str, + parent: &AuthorityV1, + pack: &AuthorityV1, + limits: &RuntimeLimitsV1, +) -> Result { + limits + .validate_handoff_bytes(envelope_json) + .map_err(CoreError::Host)?; + let envelope: HandoffEnvelopeV1 = serde_json::from_str(envelope_json) + .map_err(|e| CoreError::Host(format!("invalid handoff envelope JSON: {e}")))?; + envelope.validate(parent, pack)?; + Ok(envelope) +} + +pub fn parse_authority(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| CoreError::Host(format!("invalid authority JSON: {e}"))) +} + +/// Candid-friendly handoff DTO. +#[derive(CandidType, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct HandoffDto { + pub ok: bool, + pub handoff_id: String, + pub execution_id: String, + pub destination: String, + pub status: String, + pub message: String, +} + +impl HandoffDto { + pub fn from_record(r: &HandoffRecordV1) -> Self { + Self { + ok: r.status == "accepted" || r.status == "forwarded", + handoff_id: r.handoff_id.clone(), + execution_id: r.execution_id.clone(), + destination: r.destination.clone(), + status: r.status.clone(), + message: r.message.clone(), + } + } + + pub fn err(message: impl Into) -> Self { + Self { + ok: false, + handoff_id: String::new(), + execution_id: String::new(), + destination: String::new(), + status: "error".into(), + message: message.into(), + } + } +} + +/// Inter-canister call to a peer agent runtime (wasm only). +pub async fn forward_to_peer( + peer: Principal, + execution_id: String, + envelope_json: String, + state_json: String, + parent_authority_json: String, +) -> Result { + #[cfg(target_arch = "wasm32")] + { + let result: Result<(HandoffDto,), _> = ic_cdk::api::call::call( + peer, + "accept_handoff", + ( + execution_id, + envelope_json, + state_json, + parent_authority_json, + ), + ) + .await + .map_err(|(code, msg)| CoreError::Host(format!("handoff forward ({code:?}): {msg}"))); + result.map(|(dto,)| dto) + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = ( + peer, + execution_id, + envelope_json, + state_json, + parent_authority_json, + ); + Err(CoreError::Host( + "inter-canister handoff forward is only available on wasm32 ICP host".into(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn rejects_authority_escalation() { + let parent = AuthorityV1 { + capabilities: BTreeSet::from(["read".into()]), + namespaces: BTreeSet::from(["ns".into()]), + max_steps: 10, + max_cost_micros: 100, + }; + let pack = parent.clone(); + let envelope = json!({ + "version": 1, + "destination": "child-graph", + "state_projection": { "/x": "/x" }, + "authority_projection": { + "capabilities": ["read", "write"], + "namespaces": ["ns"], + "max_steps": 5, + "max_cost_micros": 50 + }, + "return_contract": "child-return-v1" + }) + .to_string(); + let limits = RuntimeLimitsV1::default(); + let err = parse_and_validate_envelope(&envelope, &parent, &pack, &limits).unwrap_err(); + assert!(err.to_string().contains("escalation") || err.to_string().contains("authority")); + } + + #[test] + fn accepts_narrowed_envelope() { + let parent = AuthorityV1 { + capabilities: BTreeSet::from(["read".into(), "write".into()]), + namespaces: BTreeSet::from(["ns".into()]), + max_steps: 10, + max_cost_micros: 100, + }; + let pack = parent.clone(); + let envelope = json!({ + "version": 1, + "destination": "child-graph", + "state_projection": { "/x": "/x" }, + "authority_projection": { + "capabilities": ["read"], + "namespaces": ["ns"], + "max_steps": 5, + "max_cost_micros": 50 + }, + "return_contract": "child-return-v1" + }) + .to_string(); + let limits = RuntimeLimitsV1::default(); + let env = parse_and_validate_envelope(&envelope, &parent, &pack, &limits).unwrap(); + assert_eq!(env.destination.as_str(), "child-graph"); + } +} diff --git a/crates/knolo-agent-icp/src/lib.rs b/crates/knolo-agent-icp/src/lib.rs index 072113f..2600211 100644 --- a/crates/knolo-agent-icp/src/lib.rs +++ b/crates/knolo-agent-icp/src/lib.rs @@ -1,15 +1,20 @@ -//! ICP canister host for the Knolo agent control plane (Phase 1 + Phase 2). +//! ICP canister host for the Knolo agent control plane (Phases 1–3). //! //! Phase 1: pure deterministic graphs, checkpoints, ordered events. //! Phase 2: pack-gated tools, ic-llm, knowledge retrieval, timers, cycles budget. +//! Phase 3: ic-stable-structures, DoS/auth hardening, multi-agent handoff. +mod auth; mod budget; mod definition; mod dto; mod effects; mod engine; mod executor; +mod handoff; mod host; mod knowledge; +mod limits; +mod stable_store; mod tools_host; pub use definition::{ @@ -18,30 +23,21 @@ pub use definition::{ pub use dto::*; pub use engine::{start_with_budget, AgentEngine, ExecutionRecord}; pub use executor::DeterministicExecutor; +pub use handoff::{HandoffDto, HandoffRecordV1}; pub use host::{fixed_clock, DETERMINISTIC_NOW_MS}; +pub use limits::{PackMetaV1, RuntimeLimitsV1}; pub use tools_host::permissive_tools_pack; -use candid::CandidType; use engine::AgentEngine as Engine; use ic_cdk::api::{caller, is_controller}; -use ic_cdk::storage::{stable_restore, stable_save}; use ic_cdk_macros::{post_upgrade, pre_upgrade, query, update}; -use serde::{Deserialize, Serialize}; +use knolo_agent_core::node::CheckpointStore; use std::cell::RefCell; thread_local! { static ENGINE: RefCell = RefCell::new(Engine::default()); } -#[derive(CandidType, Deserialize, Serialize, Clone, Debug, Default)] -struct StableSnapshot { - definition_json: Option, - executions_json: String, - /// Added in Phase 2; optional for upgrade from Phase 1 snapshots. - #[serde(default)] - budget_json: String, -} - // --- Candid surface ---------------------------------------------------------- #[query] @@ -49,7 +45,9 @@ fn health() -> HealthDto { ENGINE.with(|e| { let eng = e.borrow(); if eng.definition.is_some() { - HealthDto::ok("Agent runtime ready (definition loaded). Phase 2 effects enabled.") + HealthDto::ok( + "Agent runtime ready (definition loaded). Phase 3 stable structures enabled.", + ) } else { HealthDto { ok: false, @@ -61,6 +59,7 @@ fn health() -> HealthDto { #[query] fn inspect() -> InspectionDto { + let stats = stable_store::store_stats(); ENGINE.with(|e| { let eng = e.borrow(); match eng.definition.as_ref() { @@ -87,6 +86,9 @@ fn inspect() -> InspectionDto { "retrieval".into(), "timers".into(), "cycles_budget".into(), + "stable_structures".into(), + "multi_agent_handoff".into(), + "runtime_limits".into(), ], limitations: vec![ if host.llm_enabled { @@ -104,9 +106,18 @@ fn inspect() -> InspectionDto { } else { "https tools disabled".into() }, - "in-memory checkpoints until phase 3 stable structures".into(), + format!( + "stable schema v{} (ic-stable-structures)", + stats.schema_version + ), + format!( + "max concurrent executions={}", + eng.limits.max_concurrent_executions + ), ], - message: "Phase 2: host effects + packs on ICP.".into(), + message: "Phase 3: upgrade-safe host with handoff + hardening.".into(), + schema_version: stats.schema_version, + handoff_count: eng.handoffs.len() as u64, } } None => InspectionDto { @@ -120,6 +131,8 @@ fn inspect() -> InspectionDto { capabilities: vec!["state".into(), "routing".into(), "suspension".into()], limitations: vec!["no definition loaded".into()], message: "Load a definition to inspect a graph.".into(), + schema_version: stats.schema_version, + handoff_count: 0, }, } }) @@ -130,6 +143,28 @@ fn get_budget() -> BudgetDto { ENGINE.with(|e| BudgetDto::from(&e.borrow().budget.snapshot)) } +#[query] +fn get_limits() -> LimitsDto { + ENGINE.with(|e| LimitsDto::from(&e.borrow().limits)) +} + +#[query] +fn get_store_stats() -> StoreStatsDto { + StoreStatsDto::from(&stable_store::store_stats()) +} + +#[query] +fn list_executions() -> ExecutionListDto { + ENGINE.with(|e| { + let ids: Vec = e.borrow().executions.keys().cloned().collect(); + ExecutionListDto { + ok: true, + message: format!("{} executions", ids.len()), + execution_ids: ids, + } + }) +} + #[update] fn load_definition(json: String) -> HealthDto { if let Err(err) = require_controller() { @@ -159,17 +194,58 @@ fn clear_definition() -> HealthDto { HealthDto::ok(msg) } +#[update] +fn set_limits( + max_concurrent_executions: u32, + max_events_per_execution: u32, + max_state_bytes: u32, + require_controller_for_runs: bool, + allowed_callers: Vec, + min_cycles_reserve: u64, +) -> LimitsDto { + if let Err(err) = require_controller() { + return LimitsDto::err(err.message); + } + let mut limits = ENGINE.with(|e| e.borrow().limits.clone()); + if max_concurrent_executions > 0 { + limits.max_concurrent_executions = max_concurrent_executions; + } + if max_events_per_execution > 0 { + limits.max_events_per_execution = max_events_per_execution; + } + if max_state_bytes > 0 { + limits.max_state_bytes = max_state_bytes; + } + limits.require_controller_for_runs = require_controller_for_runs; + limits.allowed_callers = allowed_callers; + limits.min_cycles_reserve = min_cycles_reserve as u128; + match ENGINE.with(|e| e.borrow_mut().set_limits(limits.clone())) { + Ok(()) => { + if let Err(err) = persist_current() { + return LimitsDto::err(format!("limits set but persist failed: {err}")); + } + LimitsDto::from(&limits) + } + Err(err) => LimitsDto::err(err.to_string()), + } +} + #[update] async fn start_execution(execution_id: String, initial_state_json: String) -> RunReportDto { + if let Err(err) = require_run_auth() { + return RunReportDto::err(execution_id, err.message); + } + if let Err(err) = require_cycles_guard() { + return RunReportDto::err(execution_id, err); + } let started = ENGINE.with(|e| { e.borrow_mut() .start_execution(&execution_id, &initial_state_json) }); - let record = match started { + let _record = match started { Ok(r) => r, Err(err) => return RunReportDto::err(execution_id, err.to_string()), }; - let _ = record; let finished = continue_effects_inner(execution_id.clone()).await; let _ = persist_current(); finished @@ -177,6 +253,12 @@ async fn start_execution(execution_id: String, initial_state_json: String) -> Ru #[update] async fn step(execution_id: String, max_node_steps: u32) -> RunReportDto { + if let Err(err) = require_run_auth() { + return RunReportDto::err(execution_id, err.message); + } + if let Err(err) = require_cycles_guard() { + return RunReportDto::err(execution_id, err); + } let stepped = ENGINE.with(|e| e.borrow_mut().step(&execution_id, max_node_steps)); match stepped { Ok(_) => { @@ -190,6 +272,12 @@ async fn step(execution_id: String, max_node_steps: u32) -> RunReportDto { #[update] async fn resume(execution_id: String) -> RunReportDto { + if let Err(err) = require_run_auth() { + return RunReportDto::err(execution_id, err.message); + } + if let Err(err) = require_cycles_guard() { + return RunReportDto::err(execution_id, err); + } let resumed = ENGINE.with(|e| e.borrow_mut().resume(&execution_id)); match resumed { Ok(_) => { @@ -203,11 +291,128 @@ async fn resume(execution_id: String) -> RunReportDto { #[update] async fn continue_effects(execution_id: String) -> RunReportDto { + if let Err(err) = require_run_auth() { + return RunReportDto::err(execution_id, err.message); + } let finished = continue_effects_inner(execution_id).await; let _ = persist_current(); finished } +/// Accept a multi-agent handoff envelope and start a local execution. +#[update] +async fn accept_handoff( + execution_id: String, + envelope_json: String, + state_json: String, + parent_authority_json: String, +) -> HandoffDto { + if let Err(err) = require_run_auth() { + return HandoffDto::err(err.message); + } + if let Err(err) = require_cycles_guard() { + return HandoffDto::err(err); + } + let accepted = ENGINE.with(|e| { + e.borrow_mut().accept_handoff( + &execution_id, + &envelope_json, + &state_json, + &parent_authority_json, + ) + }); + match accepted { + Ok((_record, hrecord)) => { + // Drain effects for the new execution. + let _ = continue_effects_inner(execution_id).await; + let _ = persist_current(); + HandoffDto::from_record(&hrecord) + } + Err(err) => HandoffDto::err(err.to_string()), + } +} + +/// Forward a handoff envelope to a peer agent runtime canister. +#[update] +async fn forward_handoff( + peer_text: String, + execution_id: String, + envelope_json: String, + state_json: String, + parent_authority_json: String, +) -> HandoffDto { + if let Err(err) = require_run_auth() { + return HandoffDto::err(err.message); + } + if let Err(err) = require_cycles_guard() { + return HandoffDto::err(err); + } + let peer = match knowledge::parse_principal(&peer_text) { + Ok(p) => p, + Err(err) => return HandoffDto::err(err.to_string()), + }; + // Validate locally first (fail closed before inter-canister spend). + let validated = ENGINE.with(|e| { + let eng = e.borrow(); + let def = eng + .definition + .as_ref() + .ok_or_else(|| knolo_agent_core::CoreError::Host("no definition loaded".into()))?; + let graph_limits = &def.compiled.definition().limits; + let pack_auth = handoff::authority_from_pack( + def.bundle.pack.as_ref(), + graph_limits.max_steps, + graph_limits.max_cost_micros, + ); + let parent = handoff::parse_authority(&parent_authority_json)?; + handoff::parse_and_validate_envelope(&envelope_json, &parent, &pack_auth, &eng.limits) + }); + if let Err(err) = validated { + return HandoffDto::err(err.to_string()); + } + + match handoff::forward_to_peer( + peer, + execution_id.clone(), + envelope_json, + state_json, + parent_authority_json, + ) + .await + { + Ok(dto) => { + let hrecord = HandoffRecordV1 { + version: 1, + handoff_id: format!("forward-{execution_id}"), + execution_id, + destination: dto.destination.clone(), + return_contract: String::new(), + parent_authority: Default::default(), + child_authority: Default::default(), + status: "forwarded".into(), + peer_canister: Some(peer_text), + message: dto.message.clone(), + }; + ENGINE.with(|e| { + e.borrow_mut() + .handoffs + .insert(hrecord.handoff_id.clone(), hrecord); + }); + let _ = persist_current(); + dto + } + Err(err) => HandoffDto::err(err.to_string()), + } +} + +#[query] +fn get_handoff(handoff_id: String) -> HandoffDto { + ENGINE.with(|e| match e.borrow().handoffs.get(&handoff_id) { + Some(r) => HandoffDto::from_record(r), + None => HandoffDto::err("unknown handoff"), + }) +} + #[query] fn get_events(execution_id: String) -> EventsDto { ENGINE.with(|e| { @@ -268,7 +473,6 @@ fn get_checkpoint(execution_id: String) -> CheckpointDto { // --- Effect loop + timers ---------------------------------------------------- async fn continue_effects_inner(execution_id: String) -> RunReportDto { - // Drive automatic effects outside RefCell borrow across awaits. loop { let snapshot = ENGINE.with(|e| e.borrow().executions.get(&execution_id).cloned()); let Some(current) = snapshot else { @@ -307,10 +511,7 @@ async fn continue_effects_inner(execution_id: String) -> RunReportDto { } } - let resolved = ENGINE.with(|e| { - // Cannot await inside with — use take pattern - std::mem::take(&mut *e.borrow_mut()) - }); + let resolved = ENGINE.with(|e| std::mem::take(&mut *e.borrow_mut())); let mut eng = resolved; let result = effects::resolve_one_effect(&mut eng, &execution_id).await; ENGINE.with(|e| *e.borrow_mut() = eng); @@ -349,10 +550,11 @@ pub async fn timer_continue_execution(execution_id: String) -> RunReportDto { } } -// --- Persistence ------------------------------------------------------------- +// --- Persistence (ic-stable-structures) -------------------------------------- #[pre_upgrade] fn pre_upgrade() { + // Data already lives in stable structures; flush RAM view once more. if let Err(err) = persist_current() { ic_cdk::trap(&format!("pre_upgrade persist failed: {err}")); } @@ -360,68 +562,57 @@ fn pre_upgrade() { #[post_upgrade] fn post_upgrade() { - let snapshot: StableSnapshot = match load_snapshot() { - Ok(s) => s, - Err(err) => ic_cdk::trap(&format!("post_upgrade load failed: {err}")), - }; - if let Err(err) = restore_engine(snapshot) { + if let Err(err) = restore_from_stable() { ic_cdk::trap(&format!("post_upgrade restore failed: {err}")); } } fn persist_current() -> Result<(), String> { - let snapshot = ENGINE.with(|e| { + let snap = ENGINE.with(|e| { let eng = e.borrow(); - StableSnapshot { + stable_store::StableEngineSnapshot { + schema_version: stable_store::STABLE_SCHEMA_VERSION, definition_json: eng.definition.as_ref().map(|d| d.definition_json.clone()), - executions_json: serde_json::to_string(&eng.executions).unwrap_or_else(|_| "{}".into()), - budget_json: serde_json::to_string(&eng.budget.snapshot) - .unwrap_or_else(|_| "{}".into()), + pack_meta: eng.pack_meta.clone(), + executions: eng.executions.clone(), + budget: eng.budget.snapshot.clone(), + limits: eng.limits.clone(), + handoffs: eng.handoffs.clone(), } }); - stable_save((snapshot,)).map_err(|e| format!("stable_save: {e}")) -} - -fn load_snapshot() -> Result { - stable_restore::<(StableSnapshot,)>() - .map(|(s,)| s) - .map_err(|e| format!("stable_restore: {e}")) + stable_store::persist_snapshot(&snap) } -fn restore_engine(snapshot: StableSnapshot) -> Result<(), String> { +fn restore_from_stable() -> Result<(), String> { + let snap = stable_store::load_snapshot()?; ENGINE.with(|e| { let mut eng = e.borrow_mut(); *eng = Engine::default(); - if let Some(json) = snapshot.definition_json { + eng.limits = snap.limits; + eng.budget.snapshot = snap.budget; + eng.handoffs = snap.handoffs; + eng.pack_meta = snap.pack_meta; + if let Some(json) = snap.definition_json { eng.load_definition(&json) .map_err(|err| format!("reload definition: {err}"))?; + // load_definition clears executions; restore after. } - if !snapshot.executions_json.is_empty() && snapshot.executions_json != "{}" { - let map: std::collections::BTreeMap = - serde_json::from_str(&snapshot.executions_json) - .map_err(|err| format!("decode executions: {err}"))?; - eng.executions = map; - let checkpoints: Vec<_> = eng - .executions - .iter() - .filter_map(|(id, record)| { - record - .last_checkpoint - .as_ref() - .map(|cp| (id.clone(), cp.clone())) - }) - .collect(); - for (id, cp) in checkpoints { - use knolo_agent_core::node::CheckpointStore; - eng.store - .save(&cp) - .map_err(|err| format!("restore checkpoint {id}: {err}"))?; - } - } - if !snapshot.budget_json.is_empty() && snapshot.budget_json != "{}" { - if let Ok(snap) = serde_json::from_str(&snapshot.budget_json) { - eng.budget.snapshot = snap; - } + eng.executions = snap.executions; + // Rebuild in-memory checkpoint store from records. + let checkpoints: Vec<_> = eng + .executions + .iter() + .filter_map(|(id, record)| { + record + .last_checkpoint + .as_ref() + .map(|cp| (id.clone(), cp.clone())) + }) + .collect(); + for (id, cp) in checkpoints { + eng.store + .save(&cp) + .map_err(|err| format!("restore checkpoint {id}: {err}"))?; } Ok(()) }) @@ -429,13 +620,28 @@ fn restore_engine(snapshot: StableSnapshot) -> Result<(), String> { fn require_controller() -> Result<(), HealthDto> { let principal = caller(); - if is_controller(&principal) { - Ok(()) - } else { - Err(HealthDto::err(format!( - "Unauthorized: caller {principal} is not a controller of this canister." - ))) - } + auth::require_controller(principal, is_controller(&principal)) +} + +fn require_run_auth() -> Result<(), HealthDto> { + let principal = caller(); + let limits = ENGINE.with(|e| e.borrow().limits.clone()); + auth::require_run_access(principal, is_controller(&principal), &limits) +} + +fn require_cycles_guard() -> Result<(), String> { + let limits = ENGINE.with(|e| e.borrow().limits.clone()); + let balance = { + #[cfg(target_arch = "wasm32")] + { + Some(ic_cdk::api::canister_balance128()) + } + #[cfg(not(target_arch = "wasm32"))] + { + None + } + }; + auth::require_cycles_reserve(balance, &limits) } // --- Native unit tests ------------------------------------------------------- @@ -444,7 +650,9 @@ fn require_controller() -> Result<(), HealthDto> { mod tests { use super::*; use knolo_agent_core::event::EventKindV1; + use knolo_agent_core::handoff::AuthorityV1; use serde_json::json; + use std::collections::BTreeSet; fn portable_definition() -> String { json!({ @@ -610,7 +818,6 @@ mod tests { assert_eq!(r.status_kind, "suspended"); assert_eq!(r.status_detail, "await_llm"); - // Inject LLM, tool (pack-gated), and retrieval results — same path as async host. let r = eng .inject_effect_and_resume( "fx-1", @@ -648,11 +855,8 @@ mod tests { #[test] fn tool_denied_without_pack_grant() { let mut def: serde_json::Value = serde_json::from_str(&host_effects_definition()).unwrap(); - // Empty tools set with zero budget is invalid; use pack that doesn't include echo. def["pack"]["tools"] = json!([]); - // compile requires max_calls > 0 still let mut eng = AgentEngine::default(); - // pack with no tools will fail compile if max_calls ok but tools empty is fine eng.load_definition(&def.to_string()).unwrap(); let state = json!({ "schema_id": "effects-state", @@ -668,7 +872,6 @@ mod tests { }) .to_string(); eng.start_execution("deny-1", &state).unwrap(); - // inject llm to reach tool eng.inject_effect_and_resume( "deny-1", "llm", @@ -705,4 +908,115 @@ mod tests { fn definition_size_and_schema_guards() { assert!(AgentDefinitionBundleV1::parse("").is_err()); } + + #[test] + fn concurrent_execution_limit_enforced() { + let mut eng = AgentEngine::default(); + eng.load_definition(&portable_definition()).unwrap(); + eng.set_limits(RuntimeLimitsV1 { + max_concurrent_executions: 1, + ..RuntimeLimitsV1::default() + }) + .unwrap(); + let state = json!({ + "schema_id": "counter-state", + "revision": 0, + "value": { "count": 0 }, + "provenance": null + }) + .to_string(); + eng.start_execution("only-one", &state).unwrap(); + let err = eng.start_execution("second", &state).unwrap_err(); + assert!(err.to_string().contains("max concurrent")); + } + + #[test] + fn handoff_accept_and_reject_escalation() { + let mut eng = AgentEngine::default(); + eng.load_definition(&portable_definition()).unwrap(); + let parent = AuthorityV1 { + capabilities: BTreeSet::from(["echo".into()]), + namespaces: BTreeSet::from(["tools".into()]), + max_steps: 10, + max_cost_micros: 1000, + }; + let envelope = json!({ + "version": 1, + "destination": "portable-counter", + "state_projection": { "/count": "/count" }, + "authority_projection": { + "capabilities": [], + "namespaces": [], + "max_steps": 5, + "max_cost_micros": 100 + }, + "return_contract": "counter-return-v1" + }) + .to_string(); + let state = json!({ + "schema_id": "counter-state", + "revision": 0, + "value": { "count": 0 }, + "provenance": null + }) + .to_string(); + let parent_json = serde_json::to_string(&parent).unwrap(); + let (record, h) = eng + .accept_handoff("handoff-run", &envelope, &state, &parent_json) + .unwrap(); + assert_eq!(record.status_kind, "terminated"); + assert_eq!(h.status, "accepted"); + assert!(eng.handoffs.contains_key(&h.handoff_id)); + + // Escalation rejected. + let bad = json!({ + "version": 1, + "destination": "portable-counter", + "state_projection": {}, + "authority_projection": { + "capabilities": ["admin"], + "namespaces": [], + "max_steps": 5, + "max_cost_micros": 100 + }, + "return_contract": "x" + }) + .to_string(); + let err = eng + .accept_handoff("bad-handoff", &bad, &state, &parent_json) + .unwrap_err(); + assert!( + err.to_string().contains("escalation") || err.to_string().contains("authority"), + "{}", + err + ); + } + + #[test] + fn stable_snapshot_round_trip() { + let mut eng = AgentEngine::default(); + eng.load_definition(&portable_definition()).unwrap(); + let state = json!({ + "schema_id": "counter-state", + "revision": 0, + "value": { "count": 0 }, + "provenance": null + }) + .to_string(); + eng.start_execution("persist-me", &state).unwrap(); + let snap = stable_store::StableEngineSnapshot { + schema_version: stable_store::STABLE_SCHEMA_VERSION, + definition_json: eng.definition.as_ref().map(|d| d.definition_json.clone()), + pack_meta: eng.pack_meta.clone(), + executions: eng.executions.clone(), + budget: eng.budget.snapshot.clone(), + limits: eng.limits.clone(), + handoffs: eng.handoffs.clone(), + }; + stable_store::persist_snapshot(&snap).unwrap(); + let loaded = stable_store::load_snapshot().unwrap(); + assert!(loaded.definition_json.is_some()); + assert!(loaded.executions.contains_key("persist-me")); + assert_eq!(loaded.schema_version, stable_store::STABLE_SCHEMA_VERSION); + } } diff --git a/crates/knolo-agent-icp/src/limits.rs b/crates/knolo-agent-icp/src/limits.rs new file mode 100644 index 0000000..c3a7d17 --- /dev/null +++ b/crates/knolo-agent-icp/src/limits.rs @@ -0,0 +1,122 @@ +//! DoS and resource limits for the multi-tenant agent runtime (Phase 3). +use serde::{Deserialize, Serialize}; + +/// Soft ceilings enforced by the canister host (not graph limits). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RuntimeLimitsV1 { + /// Maximum concurrent execution records retained in stable memory. + pub max_concurrent_executions: u32, + /// Maximum ordered events retained per execution. + pub max_events_per_execution: u32, + /// Maximum length of an execution id string. + pub max_execution_id_len: u32, + /// Maximum initial state JSON size (bytes). + pub max_state_bytes: u32, + /// Maximum handoff envelope JSON size (bytes). + pub max_handoff_bytes: u32, + /// Maximum total size of a serialized execution record (approx). + pub max_execution_record_bytes: u32, + /// When true, only controllers may start/step/resume executions. + pub require_controller_for_runs: bool, + /// Principals allowed to run executions (text). Empty = any caller + /// (unless `require_controller_for_runs`). + #[serde(default)] + pub allowed_callers: Vec, + /// Refuse new work when canister balance is below this (0 = disabled). + pub min_cycles_reserve: u128, + /// Schema version of this limits blob (for migrations). + pub version: u16, +} + +impl Default for RuntimeLimitsV1 { + fn default() -> Self { + Self { + max_concurrent_executions: 32, + max_events_per_execution: 10_000, + max_execution_id_len: 128, + max_state_bytes: 512 * 1024, + max_handoff_bytes: 256 * 1024, + max_execution_record_bytes: 2 * 1024 * 1024, + require_controller_for_runs: false, + allowed_callers: Vec::new(), + min_cycles_reserve: 0, + version: 1, + } + } +} + +impl RuntimeLimitsV1 { + pub fn validate_execution_id(&self, execution_id: &str) -> Result<(), String> { + if execution_id.is_empty() { + return Err("execution_id must be non-empty".into()); + } + if execution_id.len() > self.max_execution_id_len as usize { + return Err(format!( + "execution_id length {} exceeds max {}", + execution_id.len(), + self.max_execution_id_len + )); + } + if !execution_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') + { + return Err("execution_id may only contain ASCII alphanumerics, '-', '_', '.'".into()); + } + Ok(()) + } + + pub fn validate_state_bytes(&self, state_json: &str) -> Result<(), String> { + if state_json.len() > self.max_state_bytes as usize { + return Err(format!( + "state JSON size {} exceeds max {}", + state_json.len(), + self.max_state_bytes + )); + } + Ok(()) + } + + pub fn validate_handoff_bytes(&self, envelope_json: &str) -> Result<(), String> { + if envelope_json.len() > self.max_handoff_bytes as usize { + return Err(format!( + "handoff envelope size {} exceeds max {}", + envelope_json.len(), + self.max_handoff_bytes + )); + } + Ok(()) + } + + pub fn check_capacity(&self, current_executions: usize) -> Result<(), String> { + if current_executions >= self.max_concurrent_executions as usize { + return Err(format!( + "max concurrent executions reached ({})", + self.max_concurrent_executions + )); + } + Ok(()) + } + + pub fn truncate_events_if_needed(&self, events: &mut Vec) { + let max = self.max_events_per_execution as usize; + if events.len() > max { + // Keep the newest events (tail); drop oldest. + let drop_n = events.len() - max; + events.drain(0..drop_n); + } + } +} + +/// Pack / definition identity metadata stored alongside the loaded definition. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct PackMetaV1 { + pub pack_hash: String, + pub policy_hash: String, + pub contract_hash: String, + pub graph_id: String, + pub graph_hash: String, + pub implementation_id: String, + pub pack_id: Option, +} diff --git a/crates/knolo-agent-icp/src/stable_store.rs b/crates/knolo-agent-icp/src/stable_store.rs new file mode 100644 index 0000000..ff06fdd --- /dev/null +++ b/crates/knolo-agent-icp/src/stable_store.rs @@ -0,0 +1,493 @@ +//! Versioned stable-memory schemas via `ic-stable-structures` (Phase 3). +//! +//! Layout (MemoryManager virtual memories): +//! - 0: schema version cell +//! - 1: definition JSON cell +//! - 2: pack meta JSON cell +//! - 3: executions BTreeMap (id → ExecutionRecord JSON) +//! - 4: checkpoints BTreeMap (id → Checkpoint JSON) +//! - 5: events BTreeMap (`{id}\\x1f{seq}` → event JSON) +//! - 6: budget JSON cell +//! - 7: runtime limits JSON cell +//! - 8: handoffs BTreeMap (id → handoff record JSON) +//! +//! Hot path still uses the in-RAM `AgentEngine`; this store is the upgrade-safe +//! source of truth flushed after mutations and reloaded on upgrade. +use crate::budget::HostBudgetSnapshotV1; +use crate::engine::ExecutionRecord; +use crate::handoff::HandoffRecordV1; +use crate::limits::{PackMetaV1, RuntimeLimitsV1}; +use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory}; +use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap, StableCell}; +use knolo_agent_core::checkpoint::CheckpointV1; +use knolo_agent_core::event::ExecutionEventV1; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::collections::BTreeMap; + +/// Current stable schema version written to memory 0. +pub const STABLE_SCHEMA_VERSION: u32 = 1; + +type Memory = VirtualMemory; + +const MEM_SCHEMA: MemoryId = MemoryId::new(0); +const MEM_DEFINITION: MemoryId = MemoryId::new(1); +const MEM_PACK_META: MemoryId = MemoryId::new(2); +const MEM_EXECUTIONS: MemoryId = MemoryId::new(3); +const MEM_CHECKPOINTS: MemoryId = MemoryId::new(4); +const MEM_EVENTS: MemoryId = MemoryId::new(5); +const MEM_BUDGET: MemoryId = MemoryId::new(6); +const MEM_LIMITS: MemoryId = MemoryId::new(7); +const MEM_HANDOFFS: MemoryId = MemoryId::new(8); + +thread_local! { + static MEMORY_MANAGER: RefCell> = + RefCell::new(MemoryManager::init(DefaultMemoryImpl::default())); + + static SCHEMA: RefCell> = RefCell::new( + StableCell::init( + MEMORY_MANAGER.with(|m| m.borrow().get(MEM_SCHEMA)), + 0, + ) + .expect("init schema cell"), + ); + + static DEFINITION: RefCell> = RefCell::new( + StableCell::init( + MEMORY_MANAGER.with(|m| m.borrow().get(MEM_DEFINITION)), + String::new(), + ) + .expect("init definition cell"), + ); + + static PACK_META: RefCell> = RefCell::new( + StableCell::init( + MEMORY_MANAGER.with(|m| m.borrow().get(MEM_PACK_META)), + String::new(), + ) + .expect("init pack meta cell"), + ); + + static EXECUTIONS: RefCell> = RefCell::new( + StableBTreeMap::init(MEMORY_MANAGER.with(|m| m.borrow().get(MEM_EXECUTIONS))), + ); + + static CHECKPOINTS: RefCell> = RefCell::new( + StableBTreeMap::init(MEMORY_MANAGER.with(|m| m.borrow().get(MEM_CHECKPOINTS))), + ); + + static EVENTS: RefCell> = RefCell::new( + StableBTreeMap::init(MEMORY_MANAGER.with(|m| m.borrow().get(MEM_EVENTS))), + ); + + static BUDGET: RefCell> = RefCell::new( + StableCell::init( + MEMORY_MANAGER.with(|m| m.borrow().get(MEM_BUDGET)), + String::new(), + ) + .expect("init budget cell"), + ); + + static LIMITS: RefCell> = RefCell::new( + StableCell::init( + MEMORY_MANAGER.with(|m| m.borrow().get(MEM_LIMITS)), + String::new(), + ) + .expect("init limits cell"), + ); + + static HANDOFFS: RefCell> = RefCell::new( + StableBTreeMap::init(MEMORY_MANAGER.with(|m| m.borrow().get(MEM_HANDOFFS))), + ); +} + +/// Snapshot loaded from stable structures into RAM. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct StableEngineSnapshot { + pub schema_version: u32, + pub definition_json: Option, + pub pack_meta: Option, + pub executions: BTreeMap, + pub budget: HostBudgetSnapshotV1, + pub limits: RuntimeLimitsV1, + pub handoffs: BTreeMap, +} + +fn event_key(execution_id: &str, sequence: u64) -> String { + format!("{execution_id}\u{1f}{sequence}") +} + +/// Ensure schema version is stamped; migrate when older schemas appear. +pub fn ensure_schema() -> Result<(), String> { + SCHEMA.with(|cell| { + let mut cell = cell.borrow_mut(); + let current = *cell.get(); + if current == 0 { + cell.set(STABLE_SCHEMA_VERSION) + .map_err(|e| format!("set schema version: {e:?}"))?; + return Ok(()); + } + if current == STABLE_SCHEMA_VERSION { + return Ok(()); + } + // Future: migrate current → STABLE_SCHEMA_VERSION here. + if current > STABLE_SCHEMA_VERSION { + return Err(format!( + "stable schema version {current} is newer than supported {STABLE_SCHEMA_VERSION}" + )); + } + // v1 is the first structured schema; nothing older than 1 uses MemoryManager. + cell.set(STABLE_SCHEMA_VERSION) + .map_err(|e| format!("migrate schema version: {e:?}"))?; + Ok(()) + }) +} + +pub fn schema_version() -> u32 { + SCHEMA.with(|c| *c.borrow().get()) +} + +pub fn persist_definition(definition_json: Option<&str>) -> Result<(), String> { + DEFINITION.with(|cell| { + cell.borrow_mut() + .set(definition_json.unwrap_or("").to_owned()) + .map_err(|e| format!("persist definition: {e:?}")) + })?; + Ok(()) +} + +pub fn persist_pack_meta(meta: Option<&PackMetaV1>) -> Result<(), String> { + let json = match meta { + Some(m) => serde_json::to_string(m).map_err(|e| e.to_string())?, + None => String::new(), + }; + PACK_META.with(|cell| { + cell.borrow_mut() + .set(json) + .map_err(|e| format!("persist pack meta: {e:?}")) + })?; + Ok(()) +} + +pub fn persist_budget(budget: &HostBudgetSnapshotV1) -> Result<(), String> { + let json = serde_json::to_string(budget).map_err(|e| e.to_string())?; + BUDGET.with(|cell| { + cell.borrow_mut() + .set(json) + .map_err(|e| format!("persist budget: {e:?}")) + })?; + Ok(()) +} + +pub fn persist_limits(limits: &RuntimeLimitsV1) -> Result<(), String> { + let json = serde_json::to_string(limits).map_err(|e| e.to_string())?; + LIMITS.with(|cell| { + cell.borrow_mut() + .set(json) + .map_err(|e| format!("persist limits: {e:?}")) + })?; + Ok(()) +} + +pub fn persist_execution(record: &ExecutionRecord) -> Result<(), String> { + let id = &record.execution_id; + let json = serde_json::to_string(record).map_err(|e| e.to_string())?; + EXECUTIONS.with(|map| { + map.borrow_mut().insert(id.clone(), json); + }); + + // Explicit checkpoint map for upgrade-safe direct load. + if let Some(cp) = &record.last_checkpoint { + let cp_json = serde_json::to_string(cp).map_err(|e| e.to_string())?; + CHECKPOINTS.with(|map| { + map.borrow_mut().insert(id.clone(), cp_json); + }); + } + + // Versioned event log entries (keyed by execution + sequence). + EVENTS.with(|map| { + let mut map = map.borrow_mut(); + // Drop prior events for this execution then rewrite (simple, correct). + let prefix = format!("{id}\u{1f}"); + let stale: Vec = map + .iter() + .filter_map(|(k, _)| { + if k.starts_with(&prefix) { + Some(k) + } else { + None + } + }) + .collect(); + for k in stale { + map.remove(&k); + } + for ev in &record.events { + if let Ok(ej) = serde_json::to_string(ev) { + map.insert(event_key(id, ev.sequence), ej); + } + } + }); + Ok(()) +} + +#[allow(dead_code)] +pub fn remove_execution(execution_id: &str) -> Result<(), String> { + EXECUTIONS.with(|map| { + map.borrow_mut().remove(&execution_id.to_owned()); + }); + CHECKPOINTS.with(|map| { + map.borrow_mut().remove(&execution_id.to_owned()); + }); + EVENTS.with(|map| { + let mut map = map.borrow_mut(); + let prefix = format!("{execution_id}\u{1f}"); + let stale: Vec = map + .iter() + .filter_map(|(k, _)| { + if k.starts_with(&prefix) { + Some(k) + } else { + None + } + }) + .collect(); + for k in stale { + map.remove(&k); + } + }); + Ok(()) +} + +pub fn clear_all_executions() -> Result<(), String> { + EXECUTIONS.with(|map| { + let keys: Vec = map.borrow().iter().map(|(k, _)| k).collect(); + let mut map = map.borrow_mut(); + for k in keys { + map.remove(&k); + } + }); + CHECKPOINTS.with(|map| { + let keys: Vec = map.borrow().iter().map(|(k, _)| k).collect(); + let mut map = map.borrow_mut(); + for k in keys { + map.remove(&k); + } + }); + EVENTS.with(|map| { + let keys: Vec = map.borrow().iter().map(|(k, _)| k).collect(); + let mut map = map.borrow_mut(); + for k in keys { + map.remove(&k); + } + }); + Ok(()) +} + +pub fn persist_handoff(record: &HandoffRecordV1) -> Result<(), String> { + let json = serde_json::to_string(record).map_err(|e| e.to_string())?; + HANDOFFS.with(|map| { + map.borrow_mut().insert(record.handoff_id.clone(), json); + }); + Ok(()) +} + +pub fn clear_handoffs() -> Result<(), String> { + HANDOFFS.with(|map| { + let keys: Vec = map.borrow().iter().map(|(k, _)| k).collect(); + let mut map = map.borrow_mut(); + for k in keys { + map.remove(&k); + } + }); + Ok(()) +} + +/// Persist the full engine-facing snapshot (definition, packs, runs, budget, limits, handoffs). +pub fn persist_snapshot(snap: &StableEngineSnapshot) -> Result<(), String> { + ensure_schema()?; + SCHEMA.with(|cell| { + cell.borrow_mut() + .set(STABLE_SCHEMA_VERSION) + .map_err(|e| format!("stamp schema: {e:?}")) + })?; + persist_definition(snap.definition_json.as_deref())?; + persist_pack_meta(snap.pack_meta.as_ref())?; + persist_budget(&snap.budget)?; + persist_limits(&snap.limits)?; + + // Replace execution maps entirely for consistency. + clear_all_executions()?; + for record in snap.executions.values() { + persist_execution(record)?; + } + + clear_handoffs()?; + for h in snap.handoffs.values() { + persist_handoff(h)?; + } + Ok(()) +} + +pub fn load_snapshot() -> Result { + ensure_schema()?; + let schema_version = schema_version(); + let definition_json = DEFINITION.with(|c| { + let s = c.borrow().get().clone(); + if s.is_empty() { + None + } else { + Some(s) + } + }); + let pack_meta = PACK_META.with(|c| { + let s = c.borrow().get().clone(); + if s.is_empty() { + None + } else { + serde_json::from_str(&s).ok() + } + }); + let budget = BUDGET.with(|c| { + let s = c.borrow().get().clone(); + if s.is_empty() { + HostBudgetSnapshotV1::default() + } else { + serde_json::from_str(&s).unwrap_or_default() + } + }); + let limits = LIMITS.with(|c| { + let s = c.borrow().get().clone(); + if s.is_empty() { + RuntimeLimitsV1::default() + } else { + serde_json::from_str(&s).unwrap_or_default() + } + }); + + let mut executions = BTreeMap::new(); + EXECUTIONS.with(|map| { + for (id, json) in map.borrow().iter() { + if let Ok(mut record) = serde_json::from_str::(&json) { + // Prefer explicit event map if present (authoritative ordered log). + let mut from_map: Vec = Vec::new(); + EVENTS.with(|ev| { + let prefix = format!("{id}\u{1f}"); + for (k, ej) in ev.borrow().iter() { + if k.starts_with(&prefix) { + if let Ok(e) = serde_json::from_str::(&ej) { + from_map.push(e); + } + } + } + }); + if !from_map.is_empty() { + from_map.sort_by_key(|e| e.sequence); + record.events = from_map; + } + // Prefer explicit checkpoint map. + CHECKPOINTS.with(|cp| { + if let Some(cj) = cp.borrow().get(&id) { + if let Ok(checkpoint) = serde_json::from_str::(&cj) { + record.last_checkpoint = Some(checkpoint); + } + } + }); + executions.insert(id, record); + } + } + }); + + let mut handoffs = BTreeMap::new(); + HANDOFFS.with(|map| { + for (id, json) in map.borrow().iter() { + if let Ok(h) = serde_json::from_str::(&json) { + handoffs.insert(id, h); + } + } + }); + + Ok(StableEngineSnapshot { + schema_version, + definition_json, + pack_meta, + executions, + budget, + limits, + handoffs, + }) +} + +/// Stats for inspect / ops. +pub fn store_stats() -> StoreStats { + StoreStats { + schema_version: schema_version(), + execution_count: EXECUTIONS.with(|m| m.borrow().len()), + checkpoint_count: CHECKPOINTS.with(|m| m.borrow().len()), + event_entry_count: EVENTS.with(|m| m.borrow().len()), + handoff_count: HANDOFFS.with(|m| m.borrow().len()), + has_definition: DEFINITION.with(|c| !c.borrow().get().is_empty()), + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct StoreStats { + pub schema_version: u32, + pub execution_count: u64, + pub checkpoint_count: u64, + pub event_entry_count: u64, + pub handoff_count: u64, + pub has_definition: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + use knolo_agent_core::state::StateSnapshot; + use serde_json::json; + + #[test] + fn round_trip_execution_and_events() { + use std::str::FromStr; + ensure_schema().unwrap(); + let record = ExecutionRecord { + execution_id: "rt-1".into(), + status_kind: "terminated".into(), + status_detail: "null".into(), + steps: 2, + tokens: 0, + cost_micros: 0, + state: StateSnapshot { + schema_id: knolo_agent_core::StateSchemaId::from_str("s").unwrap(), + revision: 1, + value: json!({ "n": 1 }), + provenance: None, + }, + events: vec![], + last_checkpoint: None, + pending_resume: None, + effect_cache: BTreeMap::new(), + timer_scheduled: false, + handoff_id: None, + }; + persist_execution(&record).unwrap(); + let snap = load_snapshot().unwrap(); + assert!(snap.executions.contains_key("rt-1")); + remove_execution("rt-1").unwrap(); + } + + #[test] + fn limits_and_budget_cells() { + let limits = RuntimeLimitsV1 { + max_concurrent_executions: 4, + ..RuntimeLimitsV1::default() + }; + persist_limits(&limits).unwrap(); + let mut budget = HostBudgetSnapshotV1::default(); + budget.tool_calls = 3; + persist_budget(&budget).unwrap(); + let snap = load_snapshot().unwrap(); + assert_eq!(snap.limits.max_concurrent_executions, 4); + assert_eq!(snap.budget.tool_calls, 3); + } +} diff --git a/docs/README.md b/docs/README.md index 28bad4c..fd15fe5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,6 @@ - [Packs](packs.md), [policy](policy-enforcement.md), and [tools](tools.md) - [Retrieval](retrieval.md) and the [`@knolo/core` boundary](core-boundary.md) - [Checkpoints](checkpoints.md), [replay](replay.md), and [WASM](wasm.md) -- [ICP agent runtime ADR](architecture/adr-001-icp-agent-runtime.md) and [constraints matrix](architecture/icp-constraints-matrix.md) +- [ICP agent runtime ADR](architecture/adr-001-icp-agent-runtime.md), [constraints matrix](architecture/icp-constraints-matrix.md), [cost guide](architecture/icp-cost-guide.md), [security checklist](architecture/icp-security-checklist.md) - [Security model](security.md), [compatibility](compatibility.md), and [releases](releasing.md) - [Repository audit](repository-audit.md) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index b1da893..c0684de 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -7,12 +7,16 @@ tools, storage, clocks, and capabilities. No provider is discovered implicitly. `knolo-agent-core` owns portable contracts, `knolo-agent` owns native execution, `knolo-agent-wasm` exposes the JSON protocol, `knolo-agent-icp` hosts the control -plane inside an ICP canister (Phase 1 PoC), and `@knolo/agents` owns ergonomic -TypeScript builders. `@knolo/core` is a separately published peer dependency. It -owns Cortex and ClaimGraph data and implementations; this repository contains -only typed injection interfaces and never vendors, re-exports, or publishes core. +plane inside an ICP canister (Phases 0–4: deterministic runtime, host effects, +stable structures, DX), and `@knolo/agents` owns ergonomic TypeScript builders +plus an optional ICP client. `@knolo/core` is a separately published peer +dependency. It owns Cortex and ClaimGraph data and implementations; this +repository contains only typed injection interfaces and never vendors, +re-exports, or publishes core. ICP architecture decisions and constraints: - [ADR-001: ICP agent runtime](adr-001-icp-agent-runtime.md) - [ICP constraints matrix](icp-constraints-matrix.md) +- [ICP cost guide](icp-cost-guide.md) +- [ICP security checklist](icp-security-checklist.md) diff --git a/docs/architecture/adr-001-icp-agent-runtime.md b/docs/architecture/adr-001-icp-agent-runtime.md index fb147b2..9b7311d 100644 --- a/docs/architecture/adr-001-icp-agent-runtime.md +++ b/docs/architecture/adr-001-icp-agent-runtime.md @@ -1,6 +1,6 @@ # ADR-001: ICP Agent Runtime Canister -- **Status:** Accepted (Phase 0 / Phase 1 / Phase 2) +- **Status:** Accepted (Phase 0 / Phase 1 / Phase 2 / Phase 3; Phase 4 DX ongoing) - **Date:** 2026-08-03 - **Context:** Host Knolo’s deterministic control plane on the Internet Computer. @@ -27,27 +27,46 @@ effects to **Suspend → checkpoint → timer/message resume**. `host.auto_continue` schedules `ic-cdk-timers` for `step_slice`. -6. **Persistence:** Phase 1–2 use thread-local state + coarse `stable_save` of - definition + execution records + budget snapshot. Phase 3 migrates to - `ic-stable-structures` with versioned schemas. +6. **Persistence (Phase 3 landed):** **`ic-stable-structures`** with versioned + schema (`STABLE_SCHEMA_VERSION = 1`) for definition, pack meta, executions, + checkpoints, event log entries, budget, runtime limits, and handoffs. + Hot path keeps an in-RAM `AgentEngine`; mutations flush to stable maps. + Upgrade path: `pre_upgrade` flush + `post_upgrade` reload. Phase 1–2 + `stable_save` snapshots are not migrated (PoC break is acceptable). -7. **Separation from `knolo-agent-wasm`:** Browser JSON protocol adapter remains +7. **Hardening (Phase 3):** Controller-gated definition/limits mutations; + optional run allowlist / controller-only runs; concurrent execution caps; + event log caps; cycles reserve guard; security checklist documented. + +8. **Multi-agent handoff (Phase 3):** `accept_handoff` validates + `HandoffEnvelopeV1` against parent + pack authority (fail closed on + escalation). Destination must match the loaded graph. `forward_handoff` + performs inter-canister accept on a peer runtime. + +9. **Separation from `knolo-agent-wasm`:** Browser JSON protocol adapter remains a separate crate and path. The ICP canister is a full Host runtime, not the inspect-only WASM ABI. +10. **DX (Phase 4):** Agents-owned scripts under `scripts/icp/`, dfx example + + scaffold template, TypeScript `IcpAgentRuntimeClient` in `@knolo/agents` + (optional `@dfinity/*` peers for live calls), cost and security guides. + ## Consequences -- New workspace crate: `crates/knolo-agent-icp` (`publish = false` until stable). -- Candid surface: `load_definition`, `start_execution`, `step`, `resume`, - `continue_effects`, `inspect`, `get_events`, `get_checkpoint`, `get_budget`, +- Workspace crate: `crates/knolo-agent-icp` (`publish = false` until stable). +- Candid surface (Phase 3): `load_definition`, `start_execution`, `step`, + `resume`, `continue_effects`, `inspect`, `get_events`, `get_checkpoint`, + `get_budget`, `get_limits`, `set_limits`, `get_store_stats`, + `list_executions`, `accept_handoff`, `forward_handoff`, `get_handoff`, `health`. -- Local `examples/icp-agent-canister` for dfx deploy/smoke. +- Local `examples/icp-agent-canister` for dfx deploy/smoke + handoff. - Conformance: pure deterministic fixtures match native scheduler semantics; - host-effects fixture covered by unit tests with mocks. + host-effects and handoff covered by unit tests (network-free). -## Non-goals (through Phase 2) +## Non-goals -- Mainnet production hardening, full cycles billing product, multi-agent handoff. +- Mainnet SaaS billing product. - Making ICP the default `@knolo/agents` engine. - Rewriting core as pure `no_std`. -- Phase 3 stable-structures schema migrations (next). +- Automatic migration from Phase 1–2 `stable_save` blob format. +- AgentForge registry integration (optional later). diff --git a/docs/architecture/icp-constraints-matrix.md b/docs/architecture/icp-constraints-matrix.md index 685a589..63d739e 100644 --- a/docs/architecture/icp-constraints-matrix.md +++ b/docs/architecture/icp-constraints-matrix.md @@ -19,11 +19,12 @@ payload or scheduler cost changes. | --- | --- | --- | | knolo-core knowledge canister Wasm (baseline) | ~860 KiB release | Size baseline from sibling project. | | `knolo-agent-icp` release Wasm (Phase 1) | ~1.20 MiB | Baseline before effects. | -| `knolo-agent-icp` release Wasm (Phase 2) | **~1.52 MiB** (`1592236` bytes) | Includes ic-llm + timers; re-measure after Phase 3. | +| `knolo-agent-icp` release Wasm (Phase 2) | ~1.52 MiB (`1592236` bytes) | Includes ic-llm + timers. | +| `knolo-agent-icp` release Wasm (Phase 3) | **~1.80 MiB** (`1882138` bytes) | + `ic-stable-structures` 0.6 + handoff/hardening. | | Definition ingress | Soft cap **2 MiB** (`MAX_DEFINITION_BYTES`) | Same order as knolo-core `MAX_PACK_BYTES`. | | Update instruction limit | Replica-enforced | Prefer `step` slicing + checkpoints for long graphs (Phase 1 supports step budget via engine). | -| Query vs update | Queries free of consensus write | `inspect`, `get_events`, `get_checkpoint`, `health` are queries. | -| Stable memory (Phase 1) | Coarse `stable_save` of definition + executions JSON | Fine for PoC; upgrade to `ic-stable-structures` in Phase 3. | +| Query vs update | Queries free of consensus write | `inspect`, `get_events`, `get_checkpoint`, `get_budget`, `get_limits`, `get_store_stats`, `list_executions`, `get_handoff`, `health` are queries. | +| Stable memory (Phase 3) | **`ic-stable-structures`** schema v1 (definition, pack meta, executions, checkpoints, events, budget, limits, handoffs) | Upgrade-safe maps; Phase 1–2 `stable_save` not migrated. | ## Cost / latency (qualitative Phase 0) @@ -45,8 +46,8 @@ Phase 2–3; Phase 1 enforces graph `ExecutionLimitsV1` only. | Soft payload size cap | `MAX_PACK_BYTES` | `MAX_DEFINITION_BYTES` | | Candid DTOs + health | knowledge canister | agent runtime DID | | dfx custom canister build | `examples/icp-knowledge-canister` | `examples/icp-agent-canister` | -| CLI `knolo icp …` | `@knolo/cli` | Phase 4 for agents | -| pre/post_upgrade snapshot | knowledge canister | Phase 1; improve Phase 3 | +| CLI `knolo icp …` | `@knolo/cli` | Agents: `scripts/icp/*` + scaffold; TS client in `@knolo/agents` | +| pre/post_upgrade + stable structures | knowledge still snapshot | Agent runtime: MemoryManager + versioned maps (Phase 3) | ## Risks tracked @@ -54,7 +55,7 @@ Phase 2–3; Phase 1 enforces graph `ExecutionLimitsV1` only. | --- | --- | | Wasm size blow-up | Monitor release Wasm of `knolo-agent-icp`; keep effects out of Phase 1. | | Instruction limit on deep graphs | Step slice + resume; graph `max_steps`. | -| Upgrade of rich state | Phase 3 stable structures. | +| Upgrade of rich state | Phase 3 stable structures landed; re-test on each Wasm bump. | | Divergence from native | Shared fixtures + unit tests in `knolo-agent-icp`. | ## Measurement commands diff --git a/docs/architecture/icp-cost-guide.md b/docs/architecture/icp-cost-guide.md new file mode 100644 index 0000000..a73dc56 --- /dev/null +++ b/docs/architecture/icp-cost-guide.md @@ -0,0 +1,86 @@ +# ICP agent runtime cost guide (Phase 4) + +Rough cost model for operating `knolo-agent-icp`. Numbers are order-of-magnitude +guidance for planning; always measure on your replica and workload. + +## Cost surfaces + +| Surface | Who pays | What drives cost | +| --- | --- | --- | +| Ingress messages | Caller (update) / free (query) | Definition size, start/step/resume frequency | +| Canister compute (instructions) | Cycles | Graph steps, serde, policy checks, effect rounds | +| Stable memory | Cycles | Packs, executions, checkpoints, event logs | +| ic-llm | Cycles | Prompt length, model, call count | +| HTTPS outcalls | Cycles + latency | URL size, response transform, frequency | +| Inter-canister (knowledge / handoff) | Cycles | Payload size, hops, peer work | + +Knolo tracks a **dual budget**: + +- **Knolo ledger:** steps, tokens, cost_micros, tool calls/units (from packs + + graph limits). +- **Cycles observation:** best-effort balance deltas around effect resolution + (`get_budget`). + +These are correlated but not identical. Pack policy fails closed on Knolo +budgets; cycles reserve fails closed on `min_cycles_reserve`. + +## Query vs update + +Prefer **queries** for inspect paths: + +- `health`, `inspect`, `get_budget`, `get_limits`, `get_store_stats` +- `list_executions`, `get_events`, `get_checkpoint`, `get_handoff` + +Use **updates** only for state changes (`load_definition`, `start_execution`, +`step`, `resume`, `continue_effects`, handoff accept/forward, `set_limits`). + +## Step slicing + +Deep graphs should not run unbounded in one update: + +1. Call `step(execution_id, n)` with a small `n`, or +2. Set `host.auto_continue` so `step_slice` suspensions schedule timers. + +Timers cost extra messages but keep each message under instruction limits. + +## LLM and tools + +| Pattern | Cycles risk | Mitigation | +| --- | --- | --- | +| ic-llm every node | High | Cache results in effect cache; fewer prompts; tighter graph | +| Mock / offline deterministic | Low | Use pure fixtures (`portable-counter`) for CI | +| HTTPS tools | High + non-deterministic | Pack-deny by default; transform + size caps | +| Knowledge `search` | Medium | Bound `limit`; small result payloads | + +## Stable memory growth + +Phase 3 stores versioned maps for definitions, executions, checkpoints, events, +budget, limits, and handoffs. Growth drivers: + +- `max_concurrent_executions` × average record size +- `max_events_per_execution` (oldest events drop when capped) +- Definition JSON (soft cap 2 MiB) + +Operators should call `get_store_stats` periodically and clear definitions or +archive off-chain when no longer needed. + +## Wasm size + +Release Wasm for `knolo-agent-icp` (Phase 3) is approximately **1.80 MiB** +(measure with `wc -c target/wasm32-unknown-unknown/release/knolo_agent_icp.wasm`). +Larger Wasm increases install/upgrade cost; keep optional features gated. + +## Operational recipe + +1. Load a least-authority definition once (controller). +2. Set `RuntimeLimitsV1` appropriate for tenancy. +3. Set `min_cycles_reserve` above expected upgrade + reply cost. +4. Run with step budgets; monitor `get_budget` after effect-heavy runs. +5. Prefer pure deterministic graphs in automated tests (no LLM cycles). + +## Related docs + +- [ADR-001](adr-001-icp-agent-runtime.md) +- [Constraints matrix](icp-constraints-matrix.md) +- [Security checklist](icp-security-checklist.md) +- Example: [`examples/icp-agent-canister/`](../../examples/icp-agent-canister/) diff --git a/docs/architecture/icp-security-checklist.md b/docs/architecture/icp-security-checklist.md new file mode 100644 index 0000000..ddf9e91 --- /dev/null +++ b/docs/architecture/icp-security-checklist.md @@ -0,0 +1,63 @@ +# ICP agent runtime security checklist (Phase 3) + +Use this checklist before mainnet deploy of `knolo-agent-icp`. It complements +[`docs/security.md`](../security.md) with canister-specific controls. + +## Controllers and callers + +- [ ] Controllers are limited to ops principals (not application users). +- [ ] `load_definition`, `clear_definition`, and `set_limits` are controller-only. +- [ ] For public multi-tenant runtimes, either set `allowed_callers` or enable + `require_controller_for_runs` after bootstrap. +- [ ] Anonymous principal is not in `allowed_callers` unless intentional and + pack-gated effects cannot drain cycles. + +## Packs and policy + +- [ ] Production definitions include a least-authority pack (tools, namespaces, + capability bindings, tool budgets). +- [ ] Tool deny paths are tested with the loaded pack. +- [ ] Graph `ExecutionLimitsV1` (`max_steps`, tokens, cost, timeout) are tight + enough for the expected workload. + +## DoS and ingress + +- [ ] `MAX_DEFINITION_BYTES` (2 MiB) remains acceptable for your ingress path. +- [ ] `RuntimeLimitsV1` caps concurrent executions, events per run, state size, + and handoff envelope size. +- [ ] `min_cycles_reserve` is set so residual balance covers upgrades and replies. + +## Upgrade safety + +- [ ] Stable schema version is known (`get_store_stats` / `inspect.schema_version`). +- [ ] Upgrade path tested on a local replica: load definition → run → upgrade + Wasm → `list_executions` / `get_events` / `get_checkpoint` still return data. +- [ ] Phase 1–2 `stable_save` snapshots are **not** expected to migrate; redeploy + or re-load definitions after first Phase 3 install. + +## Effects and reentrancy + +- [ ] Long LLM / tool / retrieval work goes through suspend → await → resume + (never unbounded work in one update). +- [ ] `max_effect_rounds` and step budgets bound instruction use per message. +- [ ] HTTPS tools remain disabled unless pack grants and host `allow_https_tools` + are both true; outcall transforms are reviewed if enabled. + +## Multi-agent handoff + +- [ ] Handoff envelopes are validated against parent + pack authority (no + escalation). +- [ ] Destination graph id must match the loaded graph on the accepting canister. +- [ ] Inter-canister `forward_handoff` targets are trusted principals only. + +## Observability + +- [ ] `get_budget` dual view (Knolo steps/tokens/cost + cycles observed) is + monitored. +- [ ] Ordered events and checkpoints are retained within limits for audit. + +## Explicit non-goals (still) + +- Full product billing / SaaS metering. +- Making ICP the default `@knolo/agents` engine. +- Browser `knolo-agent-wasm` as the canister binary. diff --git a/examples/icp-agent-canister/README.md b/examples/icp-agent-canister/README.md index 0812211..81b4b83 100644 --- a/examples/icp-agent-canister/README.md +++ b/examples/icp-agent-canister/README.md @@ -1,7 +1,8 @@ # icp-agent-canister Local `dfx` example for the Knolo **agent runtime** canister -(`crates/knolo-agent-icp`) — Phase 1 control plane + Phase 2 host effects. +(`crates/knolo-agent-icp`) — Phases 1–3 control plane, host effects, and +upgrade-safe stable memory. ## What works @@ -18,9 +19,17 @@ Local `dfx` example for the Knolo **agent runtime** canister - timers for `auto_continue` on `step_slice` - cycles observation + Knolo budget snapshot (`get_budget`) +**Phase 3 (persistence & hardening)** + +- `ic-stable-structures` versioned schemas (packs meta, executions, checkpoints, + events, budget, limits, handoffs) +- runtime limits / allowlists (`set_limits`, `get_limits`) +- multi-agent handoff (`accept_handoff`, `forward_handoff`, `get_handoff`) +- store stats (`get_store_stats`, `list_executions`) + Unit tests resolve effects with deterministic mocks (no network). Live LLM -requires a reachable LLM canister (mainnet id used by `ic-llm`, or a local -deploy). Without it, pure definitions like `portable-counter` still run. +requires a reachable LLM canister. Without it, pure definitions like +`portable-counter` still run. ## Prerequisites @@ -33,6 +42,8 @@ From the **repository root**: ```bash cargo test -p knolo-agent-icp +bash scripts/icp/build.sh +# or: cargo build -p knolo-agent-icp --target wasm32-unknown-unknown --release ``` @@ -45,25 +56,77 @@ TERM=xterm-256color dfx deploy TERM=xterm-256color bash scripts/run-deterministic.sh ``` +Or from repo root: + +```bash +bash scripts/icp/deploy-local.sh +bash scripts/icp/load-definition.sh +``` + +## Handoff smoke (Phase 3) + +After definition is loaded: + +```bash +TERM=xterm-256color bash scripts/run-handoff.sh +``` + +## Scaffold a new dfx project + +```bash +bash scripts/icp/init-template.sh ./my-agent-canister +# edit dfx.json paths → build → deploy +``` + +## TypeScript client + +`@knolo/agents` exports `IcpAgentRuntimeClient` and candid-aligned DTO types. +Wire an actor from `@dfinity/agent` (optional peer) to the canister IDL, then: + +```ts +import { IcpAgentRuntimeClient, portableCounterDefinition } from "@knolo/agents"; + +const client = new IcpAgentRuntimeClient(actor); +await client.loadDefinition(portableCounterDefinition()); +const report = await client.startExecution("run-1", { + schema_id: "counter-state", + revision: 0, + value: { count: 0 }, + provenance: null, +}); +``` + ## Candid surface | Method | Kind | Purpose | | --- | --- | --- | | `health` | query | Ready if definition loaded | -| `inspect` | query | Graph hash, capabilities, limitations | +| `inspect` | query | Graph hash, capabilities, schema version | | `get_budget` | query | Knolo + cycles budget snapshot | +| `get_limits` | query | Runtime DoS / auth limits | +| `get_store_stats` | query | Stable structure counts | +| `list_executions` | query | Execution ids | | `load_definition` | update (controller) | JSON agent definition (+ pack/host) | | `clear_definition` | update (controller) | Clear graph + executions | +| `set_limits` | update (controller) | Configure concurrent/event/auth limits | | `start_execution` | update | Run + auto-resolve host effects | | `step` / `resume` | update | Step-slice / HITL / effect resume | | `continue_effects` | update | Drain pending host effects | +| `accept_handoff` | update | Validate envelope + start local run | +| `forward_handoff` | update | Inter-canister handoff to peer runtime | +| `get_handoff` | query | Handoff audit record | | `get_events` | query | Ordered event log JSON | | `get_checkpoint` | query | Last checkpoint JSON | Fixtures: - `fixtures/portable-counter.definition.json` — pure Phase 1 -- `fixtures/host-effects.definition.json` — Phase 2 effect graph (needs LLM for live run) +- `fixtures/host-effects.definition.json` — Phase 2 effect graph +- `fixtures/handoff.envelope.json` + `handoff-parent.authority.json` — Phase 3 + +## Docs -See `docs/architecture/adr-001-icp-agent-runtime.md` and -`docs/architecture/icp-constraints-matrix.md`. +- [`docs/architecture/adr-001-icp-agent-runtime.md`](../../docs/architecture/adr-001-icp-agent-runtime.md) +- [`docs/architecture/icp-constraints-matrix.md`](../../docs/architecture/icp-constraints-matrix.md) +- [`docs/architecture/icp-cost-guide.md`](../../docs/architecture/icp-cost-guide.md) +- [`docs/architecture/icp-security-checklist.md`](../../docs/architecture/icp-security-checklist.md) diff --git a/examples/icp-agent-canister/fixtures/handoff-parent.authority.json b/examples/icp-agent-canister/fixtures/handoff-parent.authority.json new file mode 100644 index 0000000..70b636e --- /dev/null +++ b/examples/icp-agent-canister/fixtures/handoff-parent.authority.json @@ -0,0 +1,6 @@ +{ + "capabilities": ["echo", "handoff.delegate"], + "namespaces": ["tools", "examples.handoff"], + "max_steps": 10, + "max_cost_micros": 1000 +} diff --git a/examples/icp-agent-canister/fixtures/handoff.envelope.json b/examples/icp-agent-canister/fixtures/handoff.envelope.json new file mode 100644 index 0000000..f716038 --- /dev/null +++ b/examples/icp-agent-canister/fixtures/handoff.envelope.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "destination": "portable-counter", + "state_projection": { + "/count": "/count" + }, + "authority_projection": { + "capabilities": [], + "namespaces": [], + "max_steps": 5, + "max_cost_micros": 100 + }, + "return_contract": "counter-return-v1" +} diff --git a/examples/icp-agent-canister/scripts/run-handoff.sh b/examples/icp-agent-canister/scripts/run-handoff.sh new file mode 100755 index 0000000..436160a --- /dev/null +++ b/examples/icp-agent-canister/scripts/run-handoff.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Accept a multi-agent handoff into the portable-counter definition (local dfx). +# Prerequisites: definition loaded (run-deterministic.sh or load_definition). +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +EXAMPLE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$EXAMPLE" +export TERM="${TERM:-xterm-256color}" + +ENV_JSON="$(python3 -c 'import json,sys; print(json.dumps(open(sys.argv[1]).read()))' "$EXAMPLE/fixtures/handoff.envelope.json")" +STATE_JSON="$(python3 -c 'import json,sys; print(json.dumps(open(sys.argv[1]).read()))' "$EXAMPLE/fixtures/initial-state.json")" +AUTH_JSON="$(python3 -c 'import json,sys; print(json.dumps(open(sys.argv[1]).read()))' "$EXAMPLE/fixtures/handoff-parent.authority.json")" + +dfx canister call knolo_agent_runtime accept_handoff \ + "(\"handoff-demo-1\", $ENV_JSON, $STATE_JSON, $AUTH_JSON)" +dfx canister call knolo_agent_runtime get_events '("handoff-demo-1")' +dfx canister call knolo_agent_runtime get_store_stats +echo "Handoff smoke complete." diff --git a/packages/agents/README.md b/packages/agents/README.md index 0f28855..4a07c8c 100644 --- a/packages/agents/README.md +++ b/packages/agents/README.md @@ -45,8 +45,11 @@ package never silently falls back to another engine. Tool calls, retrieval, and durable effects remain host-bound or Rust/WASM integrations. The package also exports pack references, replay validation, checkpoint/HITL -contracts, Cortex and ClaimGraph injection interfaces, and multi-agent authority -helpers. See the repository [examples](../../examples/typescript/complete.ts) and +contracts, Cortex and ClaimGraph injection interfaces, multi-agent authority +helpers, and an **ICP canister client** (`IcpAgentRuntimeClient` + candid-aligned +DTOs). The ICP client does not hard-depend on `@dfinity/agent`; pass an actor +built from your dfx declarations for live calls. See +[`examples/icp-agent-canister/`](../../examples/icp-agent-canister/) and [architecture documentation](../../docs/architecture/README.md). ## Development diff --git a/packages/agents/src/icp/index.ts b/packages/agents/src/icp/index.ts new file mode 100644 index 0000000..6efc76c --- /dev/null +++ b/packages/agents/src/icp/index.ts @@ -0,0 +1,337 @@ +/** + * TypeScript client types and helpers for the Knolo ICP agent runtime canister. + * + * This module is **engine-agnostic**: it does not hard-depend on `@dfinity/agent`. + * Pass an actor (or thin call adapter) created with `@dfinity/agent` + the + * canister IDL from `examples/icp-agent-canister` or your dfx declarations. + * + * Optional peers for live calls: + * - `@dfinity/agent` + * - `@dfinity/candid` + * - `@dfinity/principal` + */ + +/** Candid-aligned health / status DTOs (Phase 3 surface). */ +export interface HealthDto { + ok: boolean; + message: string; +} + +export interface InspectionDto { + ok: boolean; + engine: string; + graph_loaded: boolean; + graph_id: string | null | []; + graph_hash: string | null | []; + implementation_id: string | null | []; + execution_count: bigint | number; + capabilities: string[]; + limitations: string[]; + message: string; + schema_version: number; + handoff_count: bigint | number; +} + +export interface StatusDto { + kind: string; + detail: string; +} + +export interface RunReportDto { + ok: boolean; + execution_id: string; + status: StatusDto; + steps: bigint | number; + tokens: bigint | number; + cost_micros: bigint | number; + state_json: string; + event_count: bigint | number; + message: string; +} + +export interface EventsDto { + ok: boolean; + execution_id: string; + events_json: string; + message: string; +} + +export interface CheckpointDto { + ok: boolean; + execution_id: string; + present: boolean; + checkpoint_json: string; + message: string; +} + +export interface BudgetDto { + ok: boolean; + tool_calls: bigint | number; + tool_units: bigint | number; + llm_calls: bigint | number; + retrieval_calls: bigint | number; + effect_rounds: bigint | number; + knolo_steps: bigint | number; + knolo_tokens: bigint | number; + knolo_cost_micros: bigint | number; + cycles_spent_observed: bigint | number; + last_cycles_balance: bigint | number | null | []; + message: string; +} + +export interface LimitsDto { + ok: boolean; + max_concurrent_executions: number; + max_events_per_execution: number; + max_execution_id_len: number; + max_state_bytes: number; + max_handoff_bytes: number; + require_controller_for_runs: boolean; + allowed_callers: string[]; + min_cycles_reserve: bigint | number; + message: string; +} + +export interface StoreStatsDto { + ok: boolean; + schema_version: number; + execution_count: bigint | number; + checkpoint_count: bigint | number; + event_entry_count: bigint | number; + handoff_count: bigint | number; + has_definition: boolean; + message: string; +} + +export interface ExecutionListDto { + ok: boolean; + execution_ids: string[]; + message: string; +} + +export interface HandoffDto { + ok: boolean; + handoff_id: string; + execution_id: string; + destination: string; + status: string; + message: string; +} + +/** Minimal actor surface matching `agent_runtime.did` (Phase 3). */ +export interface AgentRuntimeActor { + health: () => Promise; + inspect: () => Promise; + get_budget: () => Promise; + get_limits: () => Promise; + get_store_stats: () => Promise; + list_executions: () => Promise; + load_definition: (json: string) => Promise; + clear_definition: () => Promise; + set_limits: ( + maxConcurrent: number, + maxEvents: number, + maxStateBytes: number, + requireController: boolean, + allowedCallers: string[], + minCyclesReserve: bigint | number, + ) => Promise; + start_execution: (executionId: string, initialStateJson: string) => Promise; + step: (executionId: string, maxNodeSteps: number) => Promise; + resume: (executionId: string) => Promise; + continue_effects: (executionId: string) => Promise; + accept_handoff: ( + executionId: string, + envelopeJson: string, + stateJson: string, + parentAuthorityJson: string, + ) => Promise; + forward_handoff: ( + peerText: string, + executionId: string, + envelopeJson: string, + stateJson: string, + parentAuthorityJson: string, + ) => Promise; + get_handoff: (handoffId: string) => Promise; + get_events: (executionId: string) => Promise; + get_checkpoint: (executionId: string) => Promise; +} + +/** + * Ergonomic wrapper around an ICP agent runtime actor. + * Construct with any object that implements {@link AgentRuntimeActor} + * (typically from `Actor.createActor` in `@dfinity/agent`). + */ +export class IcpAgentRuntimeClient { + constructor(private readonly actor: AgentRuntimeActor) {} + + health(): Promise { + return this.actor.health(); + } + + inspect(): Promise { + return this.actor.inspect(); + } + + getBudget(): Promise { + return this.actor.get_budget(); + } + + getLimits(): Promise { + return this.actor.get_limits(); + } + + getStoreStats(): Promise { + return this.actor.get_store_stats(); + } + + listExecutions(): Promise { + return this.actor.list_executions(); + } + + loadDefinition(definition: unknown): Promise { + const json = typeof definition === "string" ? definition : JSON.stringify(definition); + return this.actor.load_definition(json); + } + + clearDefinition(): Promise { + return this.actor.clear_definition(); + } + + setLimits(opts: { + maxConcurrentExecutions?: number; + maxEventsPerExecution?: number; + maxStateBytes?: number; + requireControllerForRuns?: boolean; + allowedCallers?: string[]; + minCyclesReserve?: bigint | number; + }): Promise { + return this.actor.set_limits( + opts.maxConcurrentExecutions ?? 0, + opts.maxEventsPerExecution ?? 0, + opts.maxStateBytes ?? 0, + opts.requireControllerForRuns ?? false, + opts.allowedCallers ?? [], + opts.minCyclesReserve ?? 0, + ); + } + + startExecution(executionId: string, initialState: unknown): Promise { + const stateJson = + typeof initialState === "string" ? initialState : JSON.stringify(initialState); + return this.actor.start_execution(executionId, stateJson); + } + + step(executionId: string, maxNodeSteps = 1): Promise { + return this.actor.step(executionId, maxNodeSteps); + } + + resume(executionId: string): Promise { + return this.actor.resume(executionId); + } + + continueEffects(executionId: string): Promise { + return this.actor.continue_effects(executionId); + } + + acceptHandoff( + executionId: string, + envelope: unknown, + state: unknown, + parentAuthority: unknown, + ): Promise { + return this.actor.accept_handoff( + executionId, + typeof envelope === "string" ? envelope : JSON.stringify(envelope), + typeof state === "string" ? state : JSON.stringify(state), + typeof parentAuthority === "string" + ? parentAuthority + : JSON.stringify(parentAuthority), + ); + } + + forwardHandoff( + peerCanisterId: string, + executionId: string, + envelope: unknown, + state: unknown, + parentAuthority: unknown, + ): Promise { + return this.actor.forward_handoff( + peerCanisterId, + executionId, + typeof envelope === "string" ? envelope : JSON.stringify(envelope), + typeof state === "string" ? state : JSON.stringify(state), + typeof parentAuthority === "string" + ? parentAuthority + : JSON.stringify(parentAuthority), + ); + } + + getHandoff(handoffId: string): Promise { + return this.actor.get_handoff(handoffId); + } + + getEvents(executionId: string): Promise { + return this.actor.get_events(executionId); + } + + getCheckpoint(executionId: string): Promise { + return this.actor.get_checkpoint(executionId); + } +} + +/** + * Build a portable counter definition JSON for smoke tests (Phase 1 pure graph). + * Off-chain only — pass the string to `loadDefinition`. + */ +export function portableCounterDefinition(): Record { + return { + version: 1, + implementation_id: "portable-counter-v1", + pack_hash: "pack-none", + policy_hash: "policy-none", + contract_hash: "contract-none", + graph: { + version: 1, + id: "portable-counter", + state_schema: "counter-state", + entry: "increment", + nodes: [ + { id: "increment", terminal: false, reads: ["/count"], writes: ["/count"] }, + { id: "done", terminal: true, reads: ["/count"], writes: [] }, + ], + transitions: [ + { + id: "increment.continue.done", + from: "increment", + route: "continue", + to: "done", + }, + ], + cycles: [], + limits: { + max_steps: 10, + max_tokens: 100, + max_cost_micros: 1000, + timeout_ms: 30000, + }, + }, + schema: { + version: 1, + id: "counter-state", + paths: { "/count": "Number" }, + required: ["/count"], + }, + }; +} + +export function portableCounterInitialState(count = 0): Record { + return { + schema_id: "counter-state", + revision: 0, + value: { count }, + provenance: null, + }; +} diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index 2c399f0..d747fcd 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -8,3 +8,4 @@ export * from "./claims/index.js"; export * from "./multi-agent/index.js"; export * from "./hitl/index.js"; export * from "./replay/index.js"; +export * from "./icp/index.js"; diff --git a/packages/agents/tests/runtime.test.mjs b/packages/agents/tests/runtime.test.mjs index 6fcfc98..24ae760 100644 --- a/packages/agents/tests/runtime.test.mjs +++ b/packages/agents/tests/runtime.test.mjs @@ -1,6 +1,17 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { Agent, defineAgent, entry, node, stateSchema, terminal, transition } from "../dist/index.js"; +import { + Agent, + defineAgent, + entry, + IcpAgentRuntimeClient, + node, + portableCounterDefinition, + portableCounterInitialState, + stateSchema, + terminal, + transition, +} from "../dist/index.js"; function definition(capabilities) { const state = stateSchema("counter-state", { count: "Number" }); @@ -39,3 +50,162 @@ test("engine limitations and cancellation are explicit", async () => { const report = await Agent.load({ definition: definition(), engine: "typescript" }).run({ count: 0 }, { signal: controller.signal }); assert.equal(report.status.type, "cancelled"); }); + +test("ICP client helpers produce loadable definition JSON and wrap actors", async () => { + const def = portableCounterDefinition(); + assert.equal(def.implementation_id, "portable-counter-v1"); + assert.equal(portableCounterInitialState(3).value.count, 3); + let loaded = null; + const actor = { + health: async () => ({ ok: true, message: "ready" }), + inspect: async () => ({ + ok: true, + engine: "icp", + graph_loaded: true, + graph_id: [], + graph_hash: [], + implementation_id: [], + execution_count: 0n, + capabilities: [], + limitations: [], + message: "ok", + schema_version: 1, + handoff_count: 0n, + }), + get_budget: async () => ({ + ok: true, + tool_calls: 0n, + tool_units: 0n, + llm_calls: 0n, + retrieval_calls: 0n, + effect_rounds: 0n, + knolo_steps: 0n, + knolo_tokens: 0n, + knolo_cost_micros: 0n, + cycles_spent_observed: 0n, + last_cycles_balance: [], + message: "budget", + }), + get_limits: async () => ({ + ok: true, + max_concurrent_executions: 32, + max_events_per_execution: 10000, + max_execution_id_len: 128, + max_state_bytes: 524288, + max_handoff_bytes: 262144, + require_controller_for_runs: false, + allowed_callers: [], + min_cycles_reserve: 0n, + message: "limits", + }), + get_store_stats: async () => ({ + ok: true, + schema_version: 1, + execution_count: 0n, + checkpoint_count: 0n, + event_entry_count: 0n, + handoff_count: 0n, + has_definition: false, + message: "stats", + }), + list_executions: async () => ({ ok: true, execution_ids: [], message: "0" }), + load_definition: async (json) => { + loaded = json; + return { ok: true, message: "loaded" }; + }, + clear_definition: async () => ({ ok: true, message: "cleared" }), + set_limits: async () => ({ + ok: true, + max_concurrent_executions: 1, + max_events_per_execution: 1, + max_execution_id_len: 1, + max_state_bytes: 1, + max_handoff_bytes: 1, + require_controller_for_runs: false, + allowed_callers: [], + min_cycles_reserve: 0n, + message: "ok", + }), + start_execution: async () => ({ + ok: true, + execution_id: "x", + status: { kind: "terminated", detail: "null" }, + steps: 2n, + tokens: 0n, + cost_micros: 0n, + state_json: "{}", + event_count: 1n, + message: "ok", + }), + step: async () => ({ + ok: true, + execution_id: "x", + status: { kind: "terminated", detail: "null" }, + steps: 1n, + tokens: 0n, + cost_micros: 0n, + state_json: "{}", + event_count: 1n, + message: "ok", + }), + resume: async () => ({ + ok: true, + execution_id: "x", + status: { kind: "terminated", detail: "null" }, + steps: 1n, + tokens: 0n, + cost_micros: 0n, + state_json: "{}", + event_count: 1n, + message: "ok", + }), + continue_effects: async () => ({ + ok: true, + execution_id: "x", + status: { kind: "terminated", detail: "null" }, + steps: 1n, + tokens: 0n, + cost_micros: 0n, + state_json: "{}", + event_count: 1n, + message: "ok", + }), + accept_handoff: async () => ({ + ok: true, + handoff_id: "h", + execution_id: "x", + destination: "portable-counter", + status: "accepted", + message: "ok", + }), + forward_handoff: async () => ({ + ok: true, + handoff_id: "f", + execution_id: "x", + destination: "portable-counter", + status: "forwarded", + message: "ok", + }), + get_handoff: async () => ({ + ok: false, + handoff_id: "", + execution_id: "", + destination: "", + status: "error", + message: "unknown", + }), + get_events: async () => ({ ok: true, execution_id: "x", events_json: "[]", message: "0" }), + get_checkpoint: async () => ({ + ok: true, + execution_id: "x", + present: false, + checkpoint_json: "null", + message: "none", + }), + }; + const client = new IcpAgentRuntimeClient(actor); + const health = await client.health(); + assert.equal(health.ok, true); + await client.loadDefinition(def); + assert.ok(typeof loaded === "string" && loaded.includes("portable-counter")); +}); diff --git a/scripts/icp/build.sh b/scripts/icp/build.sh new file mode 100755 index 0000000..db8c6a8 --- /dev/null +++ b/scripts/icp/build.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Build the Knolo agent runtime canister Wasm (release). +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +rustup target add wasm32-unknown-unknown >/dev/null 2>&1 || true +cargo build -p knolo-agent-icp --target wasm32-unknown-unknown --release +WASM="target/wasm32-unknown-unknown/release/knolo_agent_icp.wasm" +BYTES="$(wc -c < "$WASM" | tr -d ' ')" +echo "Built $WASM ($BYTES bytes)" diff --git a/scripts/icp/deploy-local.sh b/scripts/icp/deploy-local.sh new file mode 100755 index 0000000..0b6127c --- /dev/null +++ b/scripts/icp/deploy-local.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Deploy knolo_agent_runtime to a local dfx replica. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +EXAMPLE="$ROOT/examples/icp-agent-canister" +cd "$ROOT" +bash "$ROOT/scripts/icp/build.sh" +cd "$EXAMPLE" +export TERM="${TERM:-xterm-256color}" +if ! dfx ping >/dev/null 2>&1; then + echo "Starting local replica..." + dfx start --background --clean +fi +dfx deploy knolo_agent_runtime +dfx canister call knolo_agent_runtime health +echo "Deployed. Canister id: $(dfx canister id knolo_agent_runtime)" diff --git a/scripts/icp/init-template.sh b/scripts/icp/init-template.sh new file mode 100755 index 0000000..d810693 --- /dev/null +++ b/scripts/icp/init-template.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Scaffold a minimal dfx project that points at knolo-agent-icp. +# Usage: init-template.sh [target-dir] +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TARGET="${1:-./knolo-icp-agent-scaffold}" + +if [[ -e "$TARGET" ]]; then + echo "Refusing to overwrite existing path: $TARGET" >&2 + exit 1 +fi + +mkdir -p "$TARGET/scripts" "$TARGET/fixtures" +# Relative path from TARGET to repo root (best-effort when nested under repo). +# Default: assume user copies and edits paths. +cat > "$TARGET/dfx.json" <<'EOF' +{ + "version": 1, + "dfx": "0.20.0", + "canisters": { + "knolo_agent_runtime": { + "type": "custom", + "candid": "path/to/knolo-agents/crates/knolo-agent-icp/candid/agent_runtime.did", + "wasm": "path/to/knolo-agents/target/wasm32-unknown-unknown/release/knolo_agent_icp.wasm", + "build": [ + "cargo build --target wasm32-unknown-unknown --release -p knolo-agent-icp --manifest-path path/to/knolo-agents/Cargo.toml" + ], + "metadata": [{ "name": "candid:service" }] + } + } +} +EOF + +cp "$ROOT/examples/icp-agent-canister/fixtures/portable-counter.definition.json" "$TARGET/fixtures/" 2>/dev/null || true +cp "$ROOT/examples/icp-agent-canister/fixtures/initial-state.json" "$TARGET/fixtures/" 2>/dev/null || true + +cat > "$TARGET/README.md" <<'EOF' +# Knolo ICP agent runtime scaffold + +1. Edit `dfx.json` so candid/wasm/build paths point at your knolo-agents checkout. +2. `rustup target add wasm32-unknown-unknown` +3. From knolo-agents root: `bash scripts/icp/build.sh` +4. `dfx start --background && dfx deploy` +5. Load `fixtures/portable-counter.definition.json` via `load_definition`. + +See knolo-agents docs: +- `docs/architecture/adr-001-icp-agent-runtime.md` +- `docs/architecture/icp-cost-guide.md` +- `docs/architecture/icp-security-checklist.md` +- TypeScript client: `@knolo/agents` → `IcpAgentRuntimeClient` +EOF + +cat > "$TARGET/scripts/run-smoke.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +# After deploy + load_definition, run a deterministic execution. +STATE='{"schema_id":"counter-state","revision":0,"value":{"count":0},"provenance":null}' +dfx canister call knolo_agent_runtime start_execution '("smoke-1", '"$(python3 -c "import json,sys; print(json.dumps(sys.argv[1]))" "$STATE")"')' +dfx canister call knolo_agent_runtime get_budget +EOF +chmod +x "$TARGET/scripts/run-smoke.sh" + +echo "Scaffold written to $TARGET" +echo "Update dfx.json paths, then build and deploy." diff --git a/scripts/icp/load-definition.sh b/scripts/icp/load-definition.sh new file mode 100755 index 0000000..33379df --- /dev/null +++ b/scripts/icp/load-definition.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Load a definition JSON into the local agent runtime canister. +# Usage: load-definition.sh [path-to-definition.json] +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +EXAMPLE="$ROOT/examples/icp-agent-canister" +DEF="${1:-$EXAMPLE/fixtures/portable-counter.definition.json}" +cd "$EXAMPLE" +export TERM="${TERM:-xterm-256color}" +# Escape for candid text: pass via file + $(cat) carefully. +JSON="$(python3 -c 'import json,sys; print(json.dumps(open(sys.argv[1]).read()))' "$DEF")" +dfx canister call knolo_agent_runtime load_definition "($JSON)" +dfx canister call knolo_agent_runtime inspect