feat(engine): Phase 3 — Monty Python executor with RLM pattern

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) <[email protected]>
This commit is contained in:
2026-03-21 21:32:52 -07:00
co-authored by Claude Opus 4.6
parent bf7dfb8c49
commit b59a0b9e42
8 changed files with 1278 additions and 83 deletions
Generated
+431 -8
View File
@@ -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",
+26 -6
View File
@@ -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
+1
View File
@@ -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"
@@ -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);
}
}
}
}
@@ -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;
@@ -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<ActionResult>,
/// Events generated during execution.
pub events: Vec<EventKind>,
/// If set, execution was interrupted for approval.
pub need_approval: Option<crate::runtime::messaging::ThreadOutcome>,
/// 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<String>, Vec<MontyObject>) {
let mut names = Vec::new();
let mut values = Vec::new();
// `context` — thread messages as a list of dicts
let messages: Vec<MontyObject> = 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<dyn LlmBackend>,
effects: &Arc<dyn EffectExecutor>,
leases: &LeaseManager,
policy: &PolicyEngine,
context: &ThreadExecutionContext,
capability_policies: &[crate::types::capability::PolicyRule],
) -> Result<CodeExecutionResult, EngineError> {
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<dyn LlmBackend>,
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<String> {
// 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<dyn EffectExecutor>,
leases: &LeaseManager,
policy: &PolicyEngine,
context: &ThreadExecutionContext,
capability_policies: &[crate::types::capability::PolicyRule],
action_results: &mut Vec<ActionResult>,
events: &mut Vec<EventKind>,
) -> 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<T: monty::ResourceTracker>(
result: Result<RunProgress<T>, MontyException>,
) -> Result<RunProgress<T>, 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<String, serde_json::Value> = 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::<Vec<_>>(),
),
}
}
/// 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)
}
+11 -3
View File
@@ -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<ActionCall>,
content: Option<String>,
},
/// 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<String>,
},
}
/// A request from the LLM to execute a capability action.
+111 -65
View File
@@ -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<CodeResult, EngineError>;
}
```
**Status:** Implemented (Phases 3.13.3). Phases 3.43.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
---