From b59a0b9e4249f1c88198650d676a073f0a80035c Mon Sep 17 00:00:00 2001 From: "ilblackdragon@gmail.com" Date: Sat, 21 Mar 2026 21:32:52 -0700 Subject: [PATCH] =?UTF-8?q?feat(engine):=20Phase=203=20=E2=80=94=20Monty?= =?UTF-8?q?=20Python=20executor=20with=20RLM=20pattern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CodeAct execution (Tier 1) using the Monty embedded Python interpreter, following the Recursive Language Model (RLM) pattern from arXiv:2512.24601. Key additions: - executor/scripting.rs: Monty integration with FunctionCall-based tool dispatch, catch_unwind panic safety, resource limits (30s, 64MB, 1M allocs) - LlmResponse::Code variant + ExecutionTier::Scripting - Context-as-variables (RLM 3.4): thread messages, goal, step_number, previous_results injected as Python variables — LLM context stays lean while code accesses data selectively - llm_query(prompt, context) (RLM 3.5): recursive subagent calls from within Python code — results stored as variables, not injected into parent's attention window (symbolic composition) - Compact output metadata between code steps instead of full stdout - MontyObject ↔ serde_json::Value bidirectional conversion - Updated architecture plan with RLM design principles 74 tests passing, zero clippy warnings. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 439 ++++++++++++- crates/ironclaw_engine/CLAUDE.md | 32 +- crates/ironclaw_engine/Cargo.toml | 1 + .../src/executor/loop_engine.rs | 78 ++- crates/ironclaw_engine/src/executor/mod.rs | 1 + .../ironclaw_engine/src/executor/scripting.rs | 620 ++++++++++++++++++ crates/ironclaw_engine/src/types/step.rs | 14 +- .../2026-03-20-engine-v2-architecture.md | 176 +++-- 8 files changed, 1278 insertions(+), 83 deletions(-) create mode 100644 crates/ironclaw_engine/src/executor/scripting.rs diff --git a/Cargo.lock b/Cargo.lock index d43eddde..dda3b37e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,7 +80,9 @@ checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "const-random", + "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy 0.8.42", ] @@ -386,12 +388,51 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attribute-derive" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77" +dependencies = [ + "attribute-derive-macro", + "derive-where", + "manyhow", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn 2.0.117", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -964,6 +1005,21 @@ dependencies = [ "which", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -1106,6 +1162,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -1246,6 +1313,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -1436,12 +1512,32 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1597,7 +1693,7 @@ dependencies = [ "rustc-hash 2.1.1", "serde", "smallvec", - "target-lexicon", + "target-lexicon 0.12.16", ] [[package]] @@ -1644,7 +1740,7 @@ dependencies = [ "cranelift-codegen", "log", "smallvec", - "target-lexicon", + "target-lexicon 0.12.16", ] [[package]] @@ -1661,7 +1757,7 @@ checksum = "bb2e75d1bd43dfec10924798f15e6474f1dbf63b0024506551aa19394dbe72ab" dependencies = [ "cranelift-codegen", "libc", - "target-lexicon", + "target-lexicon 0.12.16", ] [[package]] @@ -1724,6 +1820,12 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crokey" version = "1.4.0" @@ -2054,6 +2156,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -2418,6 +2531,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -2698,6 +2822,30 @@ dependencies = [ "version_check", ] +[[package]] +name = "get-size-derive2" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b6d1e2f75c16bfbcd0f95d84f99858a6e2f885c2287d1f5c3a96e8444a34b4" +dependencies = [ + "attribute-derive", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "get-size2" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49cf31a6d70300cf81461098f7797571362387ef4bf85d32ac47eaa59b3a5a1a" +dependencies = [ + "compact_str", + "get-size-derive2", + "hashbrown 0.16.1", + "ordermap", + "smallvec", +] + [[package]] name = "getopts" version = "0.2.24" @@ -2823,6 +2971,15 @@ dependencies = [ "zerocopy 0.8.42", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2873,6 +3030,20 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -3402,6 +3573,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + [[package]] name = "io-extras" version = "0.18.4" @@ -3537,6 +3714,7 @@ version = "0.1.0" dependencies = [ "async-trait", "chrono", + "monty", "pretty_assertions", "serde", "serde_json", @@ -3567,6 +3745,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is-macro" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -3612,6 +3802,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -3994,6 +4193,29 @@ dependencies = [ "libc", ] +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + [[package]] name = "markup5ever" version = "0.36.1" @@ -4130,6 +4352,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "monty" +version = "0.0.8" +source = "git+https://github.com/pydantic/monty.git?branch=main#60538204fd9cd420d1a93042bb52f7168fb9fca2" +dependencies = [ + "ahash 0.8.12", + "fancy-regex", + "hashbrown 0.16.1", + "indexmap 2.13.0", + "itertools 0.14.0", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "postcard", + "pyo3-build-config", + "ruff_python_ast", + "ruff_python_parser", + "ruff_text_size", + "serde", + "smallvec", + "strum", +] + [[package]] name = "nanoid" version = "0.4.0" @@ -4237,6 +4483,7 @@ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", + "serde", ] [[package]] @@ -4453,6 +4700,15 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ordermap" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfa78c92071bbd3628c22b1a964f7e0eb201dc1456555db072beb1662ecd6715" +dependencies = [ + "indexmap 2.13.0", +] + [[package]] name = "outref" version = "0.5.2" @@ -4849,6 +5105,7 @@ dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", + "heapless", "serde", ] @@ -4950,6 +5207,17 @@ dependencies = [ "toml_edit 0.25.4+spec-1.1.0", ] +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -5023,6 +5291,15 @@ dependencies = [ "sptr", ] +[[package]] +name = "pyo3-build-config" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf94ee265674bf76c09fa430b0e99c26e319c945d96ca0d5a8215f31bf81cf7" +dependencies = [ + "target-lexicon 0.13.5", +] + [[package]] name = "quinn" version = "0.11.9" @@ -5087,6 +5364,28 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quote-use" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "r-efi" version = "5.3.0" @@ -5513,6 +5812,72 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ruff_python_ast" +version = "0.0.0" +source = "git+https://github.com/astral-sh/ruff.git?rev=6ded4bed1651e30b34dd04cdaa50c763036abb0d#6ded4bed1651e30b34dd04cdaa50c763036abb0d" +dependencies = [ + "aho-corasick", + "bitflags 2.11.0", + "compact_str", + "get-size2", + "is-macro", + "memchr", + "ruff_python_trivia", + "ruff_source_file", + "ruff_text_size", + "rustc-hash 2.1.1", + "thiserror 2.0.18", +] + +[[package]] +name = "ruff_python_parser" +version = "0.0.0" +source = "git+https://github.com/astral-sh/ruff.git?rev=6ded4bed1651e30b34dd04cdaa50c763036abb0d#6ded4bed1651e30b34dd04cdaa50c763036abb0d" +dependencies = [ + "bitflags 2.11.0", + "bstr", + "compact_str", + "get-size2", + "memchr", + "ruff_python_ast", + "ruff_python_trivia", + "ruff_text_size", + "rustc-hash 2.1.1", + "static_assertions", + "unicode-ident", + "unicode-normalization", + "unicode_names2", +] + +[[package]] +name = "ruff_python_trivia" +version = "0.0.0" +source = "git+https://github.com/astral-sh/ruff.git?rev=6ded4bed1651e30b34dd04cdaa50c763036abb0d#6ded4bed1651e30b34dd04cdaa50c763036abb0d" +dependencies = [ + "itertools 0.14.0", + "ruff_source_file", + "ruff_text_size", + "unicode-ident", +] + +[[package]] +name = "ruff_source_file" +version = "0.0.0" +source = "git+https://github.com/astral-sh/ruff.git?rev=6ded4bed1651e30b34dd04cdaa50c763036abb0d#6ded4bed1651e30b34dd04cdaa50c763036abb0d" +dependencies = [ + "memchr", + "ruff_text_size", +] + +[[package]] +name = "ruff_text_size" +version = "0.0.0" +source = "git+https://github.com/astral-sh/ruff.git?rev=6ded4bed1651e30b34dd04cdaa50c763036abb0d#6ded4bed1651e30b34dd04cdaa50c763036abb0d" +dependencies = [ + "get-size2", +] + [[package]] name = "rust_decimal" version = "1.40.0" @@ -6275,6 +6640,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -6373,6 +6747,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -6487,6 +6882,12 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -7376,6 +7777,28 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_names2" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1673eca9782c84de5f81b82e4109dcfb3611c8ba0d52930ec4a9478f547b2dd" +dependencies = [ + "phf 0.11.3", + "unicode_names2_generator", +] + +[[package]] +name = "unicode_names2_generator" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91e5b84611016120197efd7dc93ef76774f4e084cd73c9fb3ea4a86c570c56e" +dependencies = [ + "getopts", + "log", + "phf_codegen 0.11.3", + "rand 0.8.5", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -7752,7 +8175,7 @@ dependencies = [ "serde_json", "smallvec", "sptr", - "target-lexicon", + "target-lexicon 0.12.16", "wasm-encoder 0.221.3", "wasmparser 0.221.3", "wasmtime-asm-macros", @@ -7839,7 +8262,7 @@ dependencies = [ "log", "object 0.36.7", "smallvec", - "target-lexicon", + "target-lexicon 0.12.16", "thiserror 1.0.69", "wasmparser 0.221.3", "wasmtime-environ", @@ -7866,7 +8289,7 @@ dependencies = [ "serde", "serde_derive", "smallvec", - "target-lexicon", + "target-lexicon 0.12.16", "wasm-encoder 0.221.3", "wasmparser 0.221.3", "wasmprinter", @@ -7968,7 +8391,7 @@ dependencies = [ "cranelift-codegen", "gimli", "object 0.36.7", - "target-lexicon", + "target-lexicon 0.12.16", "wasmparser 0.221.3", "wasmtime-cranelift", "wasmtime-environ", @@ -8183,7 +8606,7 @@ dependencies = [ "gimli", "regalloc2", "smallvec", - "target-lexicon", + "target-lexicon 0.12.16", "wasmparser 0.221.3", "wasmtime-cranelift", "wasmtime-environ", diff --git a/crates/ironclaw_engine/CLAUDE.md b/crates/ironclaw_engine/CLAUDE.md index ad62a5c7..e2116ce2 100644 --- a/crates/ironclaw_engine/CLAUDE.md +++ b/crates/ironclaw_engine/CLAUDE.md @@ -86,15 +86,34 @@ The engine defines three traits that the host crate implements: ## Execution Loop -`ExecutionLoop::run()` mirrors `run_agentic_loop()`: +`ExecutionLoop::run()` handles three `LlmResponse` variants: 1. Check signals (Stop, InjectMessage) via `mpsc::Receiver` 2. Build context (messages + available actions from active leases) 3. Call LLM via `LlmBackend::complete()` -4. If text: check tool intent nudge, return if final response -5. If action calls: for each call, find lease → check policy → consume use → execute via `EffectExecutor` → record result -6. Record Step, emit ThreadEvents -7. Repeat until: text response, stop signal, max iterations, or approval needed +4. **If `Text`**: check tool intent nudge, return if final response +5. **If `ActionCalls`** (Tier 0): for each call, find lease → check policy → consume use → execute via `EffectExecutor` → record result +6. **If `Code`** (Tier 1): execute Python via Monty with context-as-variables and `llm_query()` support → compact metadata in context +7. Record Step, emit ThreadEvents +8. Repeat until: text response, stop signal, max iterations, or approval needed + +## CodeAct / Monty Integration (Tier 1) + +Python execution via Monty interpreter (`executor/scripting.rs`). Follows the RLM (Recursive Language Model) pattern. + +**Context as variables** (not attention input): +- Thread messages injected as `context` Python variable +- Thread goal as `goal`, step index as `step_number` +- Prior action results as `previous_results` dict +- The LLM's chat context stays lean; full data lives in REPL variables + +**Tool dispatch**: Unknown function calls suspend the VM → lease check → policy check → `EffectExecutor` → result returned to Python. + +**`llm_query(prompt, context)`**: Recursive subagent call. Suspends VM → spawns single-shot LLM call → returns text result as Python string. Results stay as variables (symbolic composition), not injected into parent's attention window. + +**Compact output metadata**: Between code steps, only a summary is added to chat context (`"[code output] stdout (4532 chars): The results show..."`) — not the full output. This prevents context bloat across iterations. + +**Resource limits**: 30s timeout, 64MB memory, 1M allocations. All execution wrapped in `catch_unwind` for Monty panic safety. ## Capability Leases @@ -125,8 +144,9 @@ CredentialedNetwork, Compute, Financial 1. **No dependency on main `ironclaw` crate** — clean separation, testable in isolation 2. **No safety logic** — sanitization/leak detection is applied at the adapter boundary (`EffectExecutor` impl) 3. **Event sourcing from day one** — every thread records a complete event log via `ThreadEvent` -4. **Tier 0 only (MVP)** — structured tool calls. CodeAct (Tier 1-3) added in Phase 3 +4. **Tier 0 + Tier 1** — structured tool calls (Tier 0) and embedded Python via Monty (Tier 1, CodeAct) 5. **Engine owns its message type** — `ThreadMessage` is simpler than `ChatMessage`; bridge adapters handle conversion +6. **RLM pattern** — context as variable (not attention input), recursive `llm_query()`, compact output metadata between steps ## Code Style diff --git a/crates/ironclaw_engine/Cargo.toml b/crates/ironclaw_engine/Cargo.toml index 7d33e189..7f649d2b 100644 --- a/crates/ironclaw_engine/Cargo.toml +++ b/crates/ironclaw_engine/Cargo.toml @@ -16,6 +16,7 @@ dist = false [dependencies] async-trait = "0.1" chrono = { version = "0.4", features = ["serde"] } +monty = { git = "https://github.com/pydantic/monty.git", branch = "main" } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" diff --git a/crates/ironclaw_engine/src/executor/loop_engine.rs b/crates/ironclaw_engine/src/executor/loop_engine.rs index 47131793..60730656 100644 --- a/crates/ironclaw_engine/src/executor/loop_engine.rs +++ b/crates/ironclaw_engine/src/executor/loop_engine.rs @@ -21,7 +21,7 @@ use crate::traits::llm::{LlmBackend, LlmCallConfig}; use crate::types::error::EngineError; use crate::types::event::EventKind; use crate::types::message::ThreadMessage; -use crate::types::step::{LlmResponse, Step, StepStatus}; +use crate::types::step::{ExecutionTier, LlmResponse, Step, StepStatus}; use crate::types::thread::{Thread, ThreadState}; /// The core execution loop for a thread. @@ -216,6 +216,82 @@ impl ExecutionLoop { return Ok(outcome); } } + + LlmResponse::Code { code, content } => { + nudge_count = 0; + + // Record assistant message with the code + self.thread.add_message(ThreadMessage::assistant( + content.unwrap_or_else(|| format!("```python\n{code}\n```")), + )); + + step.status = StepStatus::Executing; + step.tier = ExecutionTier::Scripting; + + let exec_ctx = ThreadExecutionContext { + thread_id: self.thread.id, + thread_type: self.thread.thread_type, + project_id: self.thread.project_id, + user_id: self.user_id.clone(), + step_id: step.id, + }; + + // Execute via Monty (with LLM for recursive llm_query) + let code_result = crate::executor::scripting::execute_code( + &code, + &self.thread, + &self.llm, + &self.effects, + &self.leases, + &self.policy, + &exec_ctx, + &[], + ) + .await?; + + // Track recursive LLM token usage + self.thread.total_tokens_used += + code_result.recursive_tokens.total(); + + // Record events + for event_kind in code_result.events { + self.thread.add_event(event_kind); + } + + // Add action results as messages + for result in &code_result.action_results { + self.thread.add_message(ThreadMessage::action_result( + &result.call_id, + &result.action_name, + serde_json::to_string(&result.output).unwrap_or_default(), + )); + } + + step.action_results = code_result.action_results; + + // Use compact metadata for output (RLM pattern: + // keep LLM context lean, full data in REPL variables) + let metadata = crate::executor::scripting::compact_output_metadata( + &code_result.stdout, + &code_result.return_value, + ); + self.thread.add_message(ThreadMessage::system(metadata)); + + step.status = StepStatus::Completed; + step.completed_at = Some(chrono::Utc::now()); + self.thread.add_event(EventKind::StepCompleted { + step_id: step.id, + tokens: step.tokens_used, + }); + self.thread.step_count += 1; + + // Check if approval is needed + if let Some(outcome) = code_result.need_approval { + self.thread + .transition_to(ThreadState::Waiting, Some("awaiting approval".into()))?; + return Ok(outcome); + } + } } } diff --git a/crates/ironclaw_engine/src/executor/mod.rs b/crates/ironclaw_engine/src/executor/mod.rs index 10baeced..43b7e3c7 100644 --- a/crates/ironclaw_engine/src/executor/mod.rs +++ b/crates/ironclaw_engine/src/executor/mod.rs @@ -8,6 +8,7 @@ pub mod context; pub mod intent; pub mod loop_engine; +pub mod scripting; pub mod structured; pub use loop_engine::ExecutionLoop; diff --git a/crates/ironclaw_engine/src/executor/scripting.rs b/crates/ironclaw_engine/src/executor/scripting.rs new file mode 100644 index 00000000..14cdda61 --- /dev/null +++ b/crates/ironclaw_engine/src/executor/scripting.rs @@ -0,0 +1,620 @@ +//! Tier 1 executor: embedded Python via Monty. +//! +//! Executes LLM-generated Python code using the Monty interpreter. Tool +//! calls happen as regular function calls in the code — Monty suspends at +//! each unknown function, and we delegate to the `EffectExecutor`. +//! +//! Follows the RLM (Recursive Language Model) pattern: thread context is +//! injected as Python variables (not LLM attention input), and `llm_query()` +//! enables recursive subagent spawning from within code. + +use std::sync::Arc; +use std::time::Duration; + +use monty::{ + ExcType, ExtFunctionResult, LimitedTracker, MontyException, MontyObject, MontyRun, + NameLookupResult, PrintWriter, ResourceLimits, RunProgress, +}; +use tracing::{debug, warn}; + +use crate::capability::lease::LeaseManager; +use crate::capability::policy::{PolicyDecision, PolicyEngine}; +use crate::traits::effect::{EffectExecutor, ThreadExecutionContext}; +use crate::traits::llm::{LlmBackend, LlmCallConfig}; +use crate::types::error::EngineError; +use crate::types::event::EventKind; +use crate::types::message::{MessageRole, ThreadMessage}; +use crate::types::step::{ActionResult, LlmResponse, TokenUsage}; +use crate::types::thread::Thread; + +/// Result of executing a code block. +pub struct CodeExecutionResult { + /// The Python return value, converted to JSON. + pub return_value: serde_json::Value, + /// Captured print output. + pub stdout: String, + /// All action calls that were made during execution. + pub action_results: Vec, + /// Events generated during execution. + pub events: Vec, + /// If set, execution was interrupted for approval. + pub need_approval: Option, + /// Tokens used by recursive llm_query() calls. + pub recursive_tokens: TokenUsage, +} + +/// Default resource limits for Monty execution. +fn default_limits() -> ResourceLimits { + ResourceLimits::new() + .max_duration(Duration::from_secs(30)) + .max_allocations(1_000_000) + .max_memory(64 * 1024 * 1024) // 64 MB +} + +/// Maximum length of output metadata included in LLM context. +const OUTPUT_METADATA_MAX_PREVIEW: usize = 120; + +/// Build a compact metadata summary of code output instead of the full text. +pub fn compact_output_metadata(stdout: &str, return_value: &serde_json::Value) -> String { + let mut parts = Vec::new(); + + if !stdout.is_empty() { + let preview: String = stdout.chars().take(OUTPUT_METADATA_MAX_PREVIEW).collect(); + let truncated = if stdout.len() > OUTPUT_METADATA_MAX_PREVIEW { + "..." + } else { + "" + }; + parts.push(format!( + "stdout ({} chars): {preview}{truncated}", + stdout.len() + )); + } + + if *return_value != serde_json::Value::Null { + let val_str = serde_json::to_string(return_value).unwrap_or_default(); + let preview: String = val_str.chars().take(OUTPUT_METADATA_MAX_PREVIEW).collect(); + let truncated = if val_str.len() > OUTPUT_METADATA_MAX_PREVIEW { + "..." + } else { + "" + }; + parts.push(format!( + "return ({} chars): {preview}{truncated}", + val_str.len() + )); + } + + if parts.is_empty() { + "[code executed, no output]".into() + } else { + format!("[code output] {}", parts.join("; ")) + } +} + +// ── Context injection (RLM 3.4) ──────────────────────────── + +/// Build Monty input variables from thread state. +/// +/// Injects thread context as Python variables so the LLM's code can +/// access it selectively (RLM pattern: context as variable, not attention input). +fn build_context_inputs(thread: &Thread) -> (Vec, Vec) { + let mut names = Vec::new(); + let mut values = Vec::new(); + + // `context` — thread messages as a list of dicts + let messages: Vec = thread + .messages + .iter() + .map(|msg| { + let mut pairs = vec![ + ( + MontyObject::String("role".into()), + MontyObject::String(format!("{:?}", msg.role)), + ), + ( + MontyObject::String("content".into()), + MontyObject::String(msg.content.clone()), + ), + ]; + if let Some(ref name) = msg.action_name { + pairs.push(( + MontyObject::String("action_name".into()), + MontyObject::String(name.clone()), + )); + } + MontyObject::dict(pairs) + }) + .collect(); + names.push("context".into()); + values.push(MontyObject::List(messages)); + + // `goal` — the thread's goal string + names.push("goal".into()); + values.push(MontyObject::String(thread.goal.clone())); + + // `step_number` — current step index + names.push("step_number".into()); + values.push(MontyObject::Int(thread.step_count as i64)); + + // `previous_results` — dict of {call_id: result_json} from prior action results + let result_pairs: Vec<(MontyObject, MontyObject)> = thread + .messages + .iter() + .filter(|m| m.role == MessageRole::ActionResult) + .filter_map(|m| { + let call_id = m.action_call_id.as_ref()?; + Some(( + MontyObject::String(call_id.clone()), + MontyObject::String(m.content.clone()), + )) + }) + .collect(); + names.push("previous_results".into()); + values.push(MontyObject::dict(result_pairs)); + + (names, values) +} + +/// Execute a Python code block using Monty. +/// +/// Thread context is injected as Python variables (RLM pattern). +/// Unknown function calls suspend the VM and route to the `EffectExecutor`. +/// `llm_query(prompt, context)` calls spawn recursive child LLM calls. +#[allow(clippy::too_many_arguments)] +pub async fn execute_code( + code: &str, + thread: &Thread, + llm: &Arc, + effects: &Arc, + leases: &LeaseManager, + policy: &PolicyEngine, + context: &ThreadExecutionContext, + capability_policies: &[crate::types::capability::PolicyRule], +) -> Result { + let mut stdout = String::new(); + let mut action_results = Vec::new(); + let mut events = Vec::new(); + let mut recursive_tokens = TokenUsage::default(); + + // Build context variables (RLM 3.4) + let (input_names, input_values) = build_context_inputs(thread); + + // Parse and compile (wrap in catch_unwind — Monty 0.0.x can panic) + let runner = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + MontyRun::new(code.to_string(), "step.py", input_names) + })) { + Ok(Ok(runner)) => runner, + Ok(Err(e)) => { + return Err(EngineError::Effect { + reason: format!("Python parse error: {e}"), + }); + } + Err(_) => { + return Err(EngineError::Effect { + reason: "Monty VM panicked during code parsing".into(), + }); + } + }; + + // Start execution with resource limits and context inputs + let tracker = LimitedTracker::new(default_limits()); + + let run_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + runner.start(input_values, tracker, PrintWriter::Collect(&mut stdout)) + })); + + let mut progress = match run_result { + Ok(Ok(p)) => p, + Ok(Err(e)) => { + return Err(EngineError::Effect { + reason: format!("Python execution error: {e}"), + }); + } + Err(_) => { + return Err(EngineError::Effect { + reason: "Monty VM panicked during execution start".into(), + }); + } + }; + + // Drive the execution loop — suspend at each function call + let mut call_counter = 0u32; + loop { + match progress { + RunProgress::Complete(obj) => { + return Ok(CodeExecutionResult { + return_value: monty_to_json(&obj), + stdout, + action_results, + events, + need_approval: None, + recursive_tokens, + }); + } + + RunProgress::FunctionCall(call) => { + call_counter += 1; + let call_id = format!("code_call_{call_counter}"); + let action_name = call.function_name.clone(); + let params = monty_args_to_json(&call.args, &call.kwargs); + + debug!(action = %action_name, call_id = %call_id, "Monty: function call"); + + // Handle llm_query() — recursive subagent call (RLM 3.5) + let ext_result = if action_name == "llm_query" { + handle_llm_query(&call.args, &call.kwargs, llm, &mut recursive_tokens).await + } else { + // Regular tool dispatch through lease + policy + let dispatch = dispatch_action( + &action_name, + &call_id, + params.clone(), + thread, + effects, + leases, + policy, + context, + capability_policies, + &mut action_results, + &mut events, + ) + .await; + + match dispatch { + DispatchResult::Ok(r) => r, + DispatchResult::NeedApproval => { + return Ok(CodeExecutionResult { + return_value: serde_json::Value::Null, + stdout, + action_results, + events, + need_approval: Some( + crate::runtime::messaging::ThreadOutcome::NeedApproval { + action_name, + call_id, + parameters: params, + }, + ), + recursive_tokens, + }); + } + } + }; + + // Resume Monty + progress = resume_monty( + call.resume(ext_result, PrintWriter::Collect(&mut stdout)), + )?; + } + + RunProgress::NameLookup(lookup) => { + let name = lookup.name.clone(); + debug!(name = %name, "Monty: unresolved name"); + progress = resume_monty( + lookup.resume( + NameLookupResult::Undefined, + PrintWriter::Collect(&mut stdout), + ), + )?; + } + + RunProgress::OsCall(os_call) => { + warn!(function = ?os_call.function, "Monty: OS call denied"); + let err = ExtFunctionResult::Error(MontyException::new( + ExcType::OSError, + Some("OS operations are not permitted in CodeAct scripts".into()), + )); + progress = resume_monty( + os_call.resume(err, PrintWriter::Collect(&mut stdout)), + )?; + } + + RunProgress::ResolveFutures(_) => { + return Err(EngineError::Effect { + reason: "async/await is not supported in CodeAct scripts".into(), + }); + } + } + } +} + +// ── llm_query() — recursive subagent (RLM 3.5) ───────────── + +/// Handle a `llm_query(prompt, context)` call from within Python code. +/// +/// Spawns a single-shot LLM call with the given prompt and context. +/// The result is returned as a MontyObject (string), not injected into +/// the parent's attention window (RLM pattern: symbolic composition). +async fn handle_llm_query( + args: &[MontyObject], + kwargs: &[(MontyObject, MontyObject)], + llm: &Arc, + recursive_tokens: &mut TokenUsage, +) -> ExtFunctionResult { + // Extract prompt (first arg or kwarg "prompt") + let prompt = extract_string_arg(args, kwargs, "prompt", 0); + let context_arg = extract_string_arg(args, kwargs, "context", 1); + + let prompt = match prompt { + Some(p) => p, + None => { + return ExtFunctionResult::Error(MontyException::new( + ExcType::TypeError, + Some("llm_query() requires a 'prompt' argument".into()), + )); + } + }; + + // Build messages for the child LLM call + let mut messages = Vec::new(); + if let Some(ctx) = context_arg { + messages.push(ThreadMessage::system(format!( + "You are a sub-agent. Here is the context:\n\n{ctx}" + ))); + } + messages.push(ThreadMessage::user(prompt)); + + // Make the LLM call (no tools — pure text completion) + let config = LlmCallConfig { + force_text: true, + ..LlmCallConfig::default() + }; + + match llm.complete(&messages, &[], &config).await { + Ok(output) => { + recursive_tokens.input_tokens += output.usage.input_tokens; + recursive_tokens.output_tokens += output.usage.output_tokens; + + let response_text = match output.response { + LlmResponse::Text(text) => text, + LlmResponse::ActionCalls { content, .. } => content.unwrap_or_default(), + LlmResponse::Code { content, .. } => content.unwrap_or_default(), + }; + ExtFunctionResult::Return(MontyObject::String(response_text)) + } + Err(e) => ExtFunctionResult::Error(MontyException::new( + ExcType::RuntimeError, + Some(format!("llm_query failed: {e}")), + )), + } +} + +/// Extract a string argument by name (kwarg) or position (positional arg). +fn extract_string_arg( + args: &[MontyObject], + kwargs: &[(MontyObject, MontyObject)], + name: &str, + position: usize, +) -> Option { + // Check kwargs first + for (k, v) in kwargs { + if let MontyObject::String(key) = k + && key == name + { + return Some(monty_to_string(v)); + } + } + // Then positional + args.get(position).map(monty_to_string) +} + +/// Convert any MontyObject to a string representation. +fn monty_to_string(obj: &MontyObject) -> String { + match obj { + MontyObject::String(s) => s.clone(), + MontyObject::None => "None".into(), + MontyObject::Bool(b) => b.to_string(), + MontyObject::Int(i) => i.to_string(), + MontyObject::Float(f) => f.to_string(), + other => serde_json::to_string(&monty_to_json(other)).unwrap_or_else(|_| format!("{other:?}")), + } +} + +// ── Dispatch result ───────────────────────────────────────── + +enum DispatchResult { + Ok(ExtFunctionResult), + NeedApproval, +} + +/// Dispatch an action call through lease + policy + effect executor. +#[allow(clippy::too_many_arguments)] +async fn dispatch_action( + action_name: &str, + call_id: &str, + params: serde_json::Value, + thread: &Thread, + effects: &Arc, + leases: &LeaseManager, + policy: &PolicyEngine, + context: &ThreadExecutionContext, + capability_policies: &[crate::types::capability::PolicyRule], + action_results: &mut Vec, + events: &mut Vec, +) -> DispatchResult { + // Find lease + let lease = match leases.find_lease_for_action(thread.id, action_name).await { + Some(l) => l, + None => { + events.push(EventKind::ActionFailed { + step_id: context.step_id, + action_name: action_name.into(), + call_id: call_id.into(), + error: format!("no lease for action '{action_name}'"), + }); + return DispatchResult::Ok(ExtFunctionResult::NotFound(action_name.into())); + } + }; + + // Find action definition and check policy + let action_def = effects + .available_actions(std::slice::from_ref(&lease)) + .await + .ok() + .and_then(|actions| actions.into_iter().find(|a| a.name == action_name)); + + if let Some(ref action_def) = action_def { + match policy.evaluate(action_def, &lease, capability_policies) { + PolicyDecision::Deny { reason } => { + events.push(EventKind::ActionFailed { + step_id: context.step_id, + action_name: action_name.into(), + call_id: call_id.into(), + error: reason.clone(), + }); + return DispatchResult::Ok(ExtFunctionResult::Error(MontyException::new( + ExcType::RuntimeError, + Some(format!("denied: {reason}")), + ))); + } + PolicyDecision::RequireApproval { .. } => { + events.push(EventKind::ApprovalRequested { + action_name: action_name.into(), + call_id: call_id.into(), + }); + return DispatchResult::NeedApproval; + } + PolicyDecision::Allow => {} + } + } + + // Consume lease use + if let Err(e) = leases.consume_use(lease.id).await { + return DispatchResult::Ok(ExtFunctionResult::Error(MontyException::new( + ExcType::RuntimeError, + Some(format!("lease exhausted: {e}")), + ))); + } + + // Execute the action + match effects + .execute_action(action_name, params, &lease, context) + .await + { + Ok(result) => { + events.push(EventKind::ActionExecuted { + step_id: context.step_id, + action_name: action_name.into(), + call_id: call_id.into(), + duration_ms: result.duration.as_millis() as u64, + }); + let monty_obj = json_to_monty(&result.output); + action_results.push(result); + DispatchResult::Ok(ExtFunctionResult::Return(monty_obj)) + } + Err(e) => { + action_results.push(ActionResult { + call_id: call_id.into(), + action_name: action_name.into(), + output: serde_json::json!({"error": e.to_string()}), + is_error: true, + duration: Duration::ZERO, + }); + events.push(EventKind::ActionFailed { + step_id: context.step_id, + action_name: action_name.into(), + call_id: call_id.into(), + error: e.to_string(), + }); + DispatchResult::Ok(ExtFunctionResult::Error(MontyException::new( + ExcType::RuntimeError, + Some(e.to_string()), + ))) + } + } +} + +/// Wrap Monty resume results with error conversion. +fn resume_monty( + result: Result, MontyException>, +) -> Result, EngineError> { + result.map_err(|e| EngineError::Effect { + reason: format!("Python execution error: {e}"), + }) +} + +// ── MontyObject ↔ JSON conversion ─────────────────────────── + +/// Convert a MontyObject to serde_json::Value. +fn monty_to_json(obj: &MontyObject) -> serde_json::Value { + match obj { + MontyObject::None => serde_json::Value::Null, + MontyObject::Bool(b) => serde_json::Value::Bool(*b), + MontyObject::Int(i) => serde_json::json!(i), + MontyObject::BigInt(i) => serde_json::Value::String(i.to_string()), + MontyObject::Float(f) => serde_json::json!(f), + MontyObject::String(s) => serde_json::Value::String(s.clone()), + MontyObject::List(items) | MontyObject::Tuple(items) => { + serde_json::Value::Array(items.iter().map(monty_to_json).collect()) + } + MontyObject::Dict(pairs) => { + let map: serde_json::Map = pairs + .into_iter() + .map(|(k, v)| { + let key = match k { + MontyObject::String(s) => s.clone(), + other => format!("{other:?}"), + }; + (key, monty_to_json(v)) + }) + .collect(); + serde_json::Value::Object(map) + } + MontyObject::Set(items) | MontyObject::FrozenSet(items) => { + serde_json::Value::Array(items.iter().map(monty_to_json).collect()) + } + MontyObject::Bytes(b) => serde_json::Value::String( + b.iter().map(|byte| format!("{byte:02x}")).collect(), + ), + other => serde_json::Value::String(format!("{other:?}")), + } +} + +/// Convert serde_json::Value to MontyObject. +fn json_to_monty(val: &serde_json::Value) -> MontyObject { + match val { + serde_json::Value::Null => MontyObject::None, + serde_json::Value::Bool(b) => MontyObject::Bool(*b), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + MontyObject::Int(i) + } else if let Some(f) = n.as_f64() { + MontyObject::Float(f) + } else { + MontyObject::String(n.to_string()) + } + } + serde_json::Value::String(s) => MontyObject::String(s.clone()), + serde_json::Value::Array(arr) => { + MontyObject::List(arr.iter().map(json_to_monty).collect()) + } + serde_json::Value::Object(map) => MontyObject::dict( + map.iter() + .map(|(k, v)| (MontyObject::String(k.clone()), json_to_monty(v))) + .collect::>(), + ), + } +} + +/// Convert Monty function call args + kwargs to a JSON object. +fn monty_args_to_json( + args: &[MontyObject], + kwargs: &[(MontyObject, MontyObject)], +) -> serde_json::Value { + let mut map = serde_json::Map::new(); + if !args.is_empty() { + map.insert( + "_args".into(), + serde_json::Value::Array(args.iter().map(monty_to_json).collect()), + ); + } + for (k, v) in kwargs { + let key = match k { + MontyObject::String(s) => s.clone(), + other => format!("{other:?}"), + }; + map.insert(key, monty_to_json(v)); + } + serde_json::Value::Object(map) +} diff --git a/crates/ironclaw_engine/src/types/step.rs b/crates/ironclaw_engine/src/types/step.rs index 1395ba09..d2b5529c 100644 --- a/crates/ironclaw_engine/src/types/step.rs +++ b/crates/ironclaw_engine/src/types/step.rs @@ -41,10 +41,11 @@ pub enum StepStatus { /// Which execution tier handles the step's code/actions. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ExecutionTier { - /// Tier 0: structured tool calls (MVP). + /// Tier 0: structured tool calls. Structured, + /// Tier 1: embedded Python via Monty. + Scripting, // Future tiers: - // Scripting, // Tier 1: embedded Starlark/Rhai // Wasm, // Tier 2: WASM sandbox // Container, // Tier 3: Docker container } @@ -84,7 +85,7 @@ impl Step { // ── LLM response types ───────────────────────────────────── -/// Response from the LLM: either text or action calls. +/// Response from the LLM: text, action calls, or executable code. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum LlmResponse { /// Final text response. @@ -94,6 +95,13 @@ pub enum LlmResponse { calls: Vec, content: Option, }, + /// Executable Python code (CodeAct). Tool calls happen as function + /// calls within the code; the runtime suspends at each one and + /// delegates to the EffectExecutor. + Code { + code: String, + content: Option, + }, } /// A request from the LLM to execute a capability action. diff --git a/docs/plans/2026-03-20-engine-v2-architecture.md b/docs/plans/2026-03-20-engine-v2-architecture.md index 629cee2b..9948ebe6 100644 --- a/docs/plans/2026-03-20-engine-v2-architecture.md +++ b/docs/plans/2026-03-20-engine-v2-architecture.md @@ -25,7 +25,9 @@ IronClaw currently has Session, Job, Routine, Channel, Tool, Skill, Hook, Observ 4. **Effects, not commands** — capabilities declare their effect types; a deterministic policy engine enforces boundaries 5. **Memory is docs, not logs** — durable knowledge is structured (summaries, lessons, playbooks), not raw history 6. **CodeAct for capable models** — LLMs write code that composes tools, queries history, and spawns threads -7. **Event sourcing from day one** — every thread records a complete execution trace for replay/debugging/reflection +7. **Context as variable, not attention input** (RLM pattern) — thread context is a Python variable in the REPL, not tokens in the LLM window. The model writes code to selectively access it, avoiding context rot on long inputs +8. **Recursive subagent spawning** (RLM pattern) — code can call `llm_query()` to spawn child threads inline. Results are stored as variables, not injected into the parent's context window +9. **Event sourcing from day one** — every thread records a complete execution trace for replay/debugging/reflection ## The Five Primitives @@ -86,7 +88,7 @@ crates/ironclaw_engine/ mod.rs loop_engine.rs # ExecutionLoop (core loop replacing run_agentic_loop) structured.rs # Tier 0: structured tool calls - scripting.rs # Tier 1: embedded Starlark/Rhai (Phase 3) + scripting.rs # Tier 1: embedded Python via Monty (Phase 3) context.rs # Context builder (thread state + project docs + capabilities) intent.rs # Tool intent nudge detection @@ -109,7 +111,7 @@ crates/ironclaw_engine/ Dependencies (minimal — no main crate dependency): - `tokio` (sync, time, macros, rt), `serde` + `serde_json`, `thiserror`, `tracing`, `uuid`, `chrono`, `async-trait` -- Phase 3 adds: `starlark` or `rhai` (embedded scripting) +- `monty` (git dep) — embedded Python interpreter for CodeAct (Tier 1) --- @@ -242,80 +244,124 @@ cargo test --- -## Phase 3: CodeAct Executor (Tier 1 — Embedded Scripting) +## Phase 3: CodeAct Executor (Tier 1 — Monty Python + RLM Pattern) -**Goal:** LLMs can write code (Starlark or Rhai) that composes tools, uses control flow, and queries thread context. This is the key differentiator from the current tool-call model. +**Goal:** LLMs write Python code that composes tools, uses control flow, queries thread context as data, and recursively spawns sub-agents. Uses the Monty interpreter (Pydantic) for sandboxed in-process execution. Follows the Recursive Language Model (RLM) pattern: context as a variable, not attention input. -### 3.1 Code runner trait -```rust -#[async_trait] -pub trait CodeRunner: Send + Sync { - async fn execute( - &self, - code: &str, - runtime_api: &dyn RuntimeApi, - config: &CodeRunnerConfig, - ) -> Result; -} -``` +**Status:** Implemented (Phases 3.1–3.3). Phases 3.4–3.5 are the RLM enhancements. -### 3.2 Runtime API -The API surface that code executes against: +### 3.1 Monty integration (DONE) + +`executor/scripting.rs` — Embeds the Monty Python interpreter (git dep, v0.0.8). + +**Execution model:** +1. `MontyRun::new(code, "step.py", input_names)` — parse Python code +2. `runner.start(inputs, tracker, print_writer)` — begin execution with resource limits +3. Loop over `RunProgress` suspension points: + - `FunctionCall` → find lease → check policy → call `EffectExecutor` → resume with result + - `NameLookup` → resolve or raise `NameError` + - `OsCall` → deny with `OSError` + - `ResolveFutures` → error (async not supported) + - `Complete` → return value + captured stdout +4. All execution wrapped in `catch_unwind` (Monty 0.0.x can panic) + +**Resource limits:** 30s timeout, 64MB memory, 1M allocations, recursion depth 1000. + +**Tool dispatch:** Unknown function calls in Python suspend the VM via `RunProgress::FunctionCall`. The engine routes through the same lease → policy → `EffectExecutor` pipeline as structured tool calls: ```python -# Thread operations (read-only access to thread state) -thread.messages # list of messages -thread.messages[-1] # last message -thread.messages.filter(role="tool") # filter by role -thread.goal # thread's goal -thread.state # current state - -# Capability actions (tool calls) -tools.web_fetch(url="...") # invoke capability action -tools.memory_search(query="...") # invoke capability action -result = tools.shell(cmd="...") # invoke capability action - -# Output -thread.reply("response") # send final response -thread.think("note") # add to context, not visible to user - -# Thread spawning -child = thread.spawn(goal="...", capabilities=["web_fetch"]) -results = thread.join([child1, child2, child3]) # fan-out/fan-in +result = web_fetch(url="https://example.com") # suspends → EffectExecutor +data = memory_search(query="deployment") # suspends → EffectExecutor +for item in result["items"]: # control flow in Python + memory_write(key=item["id"], value=item["summary"]) ``` -### 3.3 Tier selection -The executor analyzes the LLM response to route: -- JSON tool calls → Tier 0 (structured, existing path) -- Code block with only `tools.*` calls → Tier 1 (embedded scripting) -- Code with `import os`, `tools.shell` → Tier 3 (Docker, Phase 6) +**Type conversion:** `monty_to_json()` / `json_to_monty()` bidirectional conversion between `MontyObject` and `serde_json::Value`. -### 3.4 Starlark/Rhai integration -Add `starlark` or `rhai` as dependency. Implement `CodeRunner`: -- Parse code -- Bind `thread.*` and `tools.*` namespaces to Rust callbacks -- Fuel metering (prevent infinite loops) -- Execute with timeout -- Capture output + side effects +### 3.2 LlmResponse::Code variant (DONE) -### 3.5 Prompt transformation -When CodeAct is enabled, the system prompt changes from prose tool descriptions to API documentation: +New `LlmResponse::Code { code, content }` variant alongside `Text` and `ActionCalls`. The `ExecutionLoop` routes `Code` responses to `scripting::execute_code()` instead of `structured::execute_action_calls()`. + +### 3.3 ExecutionLoop integration (DONE) + +The loop handles `LlmResponse::Code`: +- Records assistant message with code +- Sets `step.tier = ExecutionTier::Scripting` +- Executes via `scripting::execute_code()` +- Records events and action results +- Captures stdout + return value as context for next iteration +- Handles `NeedApproval` outcome (pauses thread) + +### 3.4 RLM: Context as variables (TO IMPLEMENT) + +Inspired by Recursive Language Models (arXiv:2512.24601). The key insight: **the prompt is an environment variable, not attention input.** The LLM never sees the full thread context in its window — it writes code to access it selectively. + +**Implementation:** +- Pass thread state as Monty input variables via `MontyRun::new(code, "step.py", input_names)`: + - `context` — full thread message history as a Python list of dicts + - `goal` — the thread's goal string + - `step_number` — current step index + - `previous_results` — dict of `{call_id: result}` from prior steps +- Use compact output metadata between code steps: `"[code output: 4,532 chars]"` instead of full stdout in chat history +- The LLM's chat context stays lean; the full data lives in REPL variables + +**Before (current):** Full context in LLM attention window ``` -Available API: - tools.web_fetch(url: str, headers: dict = None) -> Response - tools.memory_search(query: str, limit: int = 10) -> List[Memory] - thread.reply(content: str) - thread.spawn(goal: str, capabilities: list = None) -> Thread - -Write code to accomplish the user's request. +System: You are an agent... +User: Analyze these 1000 items... +[1000 items in context] +Assistant: ```python result = web_fetch(...)``` ``` +**After (RLM pattern):** Context as a variable +``` +System: You have access to `context` (1000 items) and `previous_results`. +Write Python to accomplish the goal. +Assistant: ```python +items = context # never loaded into LLM window +for batch in [items[i:i+100] for i in range(0, len(items), 100)]: + result = llm_query("summarize these items", batch) + # result is a variable, not injected into parent context +``` + +### 3.5 RLM: Recursive `llm_query()` within code (TO IMPLEMENT) + +Expose `llm_query(prompt, context)` as a callable inside the Monty environment. When code calls it, Monty suspends via `FunctionCall`. The engine: +1. Spawns a child thread with the given prompt and context +2. Runs the child to completion (inline, blocking the parent's code) +3. Returns the child's result as a `MontyObject` + +This enables the core RLM patterns: +```python +# Partition + Map + Reduce +chunks = [context[i:i+1000] for i in range(0, len(context), 1000)] +summaries = [] +for chunk in chunks: + summary = llm_query("Summarize this section", chunk) + summaries.append(summary) # variable, not in parent's LLM context +final = llm_query("Combine these summaries", summaries) + +# Verification +answer = llm_query("What is X?", context) +verified = llm_query(f"Is this answer correct: {answer}", context) +``` + +**Key RLM properties preserved:** +- **Symbolic handle to context** — the parent LLM never sees child outputs in its attention window +- **Unbounded output** — variables in the REPL can exceed the context window +- **Recursive decomposition** — the model decides how to partition work, not the architect + ### 3.6 Tests -- Simple code: `tools.web_fetch(url="...")` → action executed, result captured -- Control flow: `for item in tools.search(...): tools.memory_write(...)` → multiple actions -- Thread data access: `thread.messages[-1].content` → correct value -- Fuel exhaustion: infinite loop → timeout error -- Tier selection: structured JSON → Tier 0, code block → Tier 1 -- Error handling: code raises exception → step fails gracefully +- **Simple code execution:** `x = 1 + 2` → returns 3, no tool calls +- **Tool call from code:** `result = web_fetch(url="...")` → `FunctionCall` suspension → effect executor called → result returned to Python +- **Multiple tool calls in loop:** `for i in range(3): fetch(url=urls[i])` → 3 effect executor calls +- **Context as variable:** Code accesses `context[0]` → correct value from thread messages +- **Compact metadata:** After code step, context has metadata summary not full stdout +- **`llm_query()` recursive call:** Code calls `llm_query("summarize", data)` → child thread spawned → result returned as variable +- **Resource limits:** Infinite loop → Monty `TimeoutError` +- **OS call denied:** `import os; os.listdir(".")` → `OSError` +- **VM panic recovery:** Monty panics → `catch_unwind` returns `EngineError`, thread doesn't crash +- **Policy deny in code:** Code calls denied action → Python `RuntimeError` raised +- **Approval needed in code:** Code calls approval-required action → `NeedApproval` returned, code halted ---