docs(engine): update architecture plan with RLM cross-reference learnings

Comprehensive update after cross-referencing against official RLM
(alexzhang13/rlm), fast-rlm (avbiswas/fast-rlm), Prime Intellect
(verifiers/RLMEnv), rlm-rs (zircote/rlm-rs), and Google ADK RLM.

Changes:
- Mark Phases 1-3 as DONE with commit refs and test counts
- Add "Key Influences" section documenting all reference implementations
- Phase 3: full table of implemented RLM features with sources
- Phase 3: "Remaining gaps" table with which phase addresses each
- Phase 4: expanded with compaction (85% context), rlm_query() (full
  recursive sub-agent), dual model routing, budget controls (USD,
  timeout, tokens, consecutive errors), lazy loading, pass-by-reference
- Add "RLM Execution Model" cross-cutting section
- Add "Implementation Progress" tracking table
- Remove stale "TO IMPLEMENT" markers (all Phase 3 work is done)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
2026-03-21 22:44:47 -07:00
co-authored by Claude Opus 4.6
parent 953833208e
commit ff1107179a
+206 -364
View File
@@ -1,7 +1,8 @@
# IronClaw Engine v2: Unified Thread-Capability-CodeAct Architecture
**Date:** 2026-03-20
**Status:** Draft
**Updated:** 2026-03-22
**Status:** In Progress (Phases 1-3 complete)
**Goal:** Replace IronClaw's ~10 fragmented abstractions with a unified execution model built on 5 primitives: Thread, Step, Capability, MemoryDoc, Project. Developed as a standalone crate (`ironclaw_engine`) that can be swapped in when it passes all acceptance tests.
---
@@ -29,6 +30,15 @@ IronClaw currently has Session, Job, Routine, Channel, Tool, Skill, Hook, Observ
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
## Key Influences
- **RLM paper** (arXiv:2512.24601, Zhang/Kraska/Khattab, MIT) — context as variable, FINAL() termination, recursive sub-calls, output truncation, compaction
- **Official RLM impl** (alexzhang13/rlm) — 30 max iterations, 20K char truncation, compaction at 85% context, scaffold restoration, FINAL_VAR regex fallback, consecutive error counting, budget/timeout/token limits with inheritance to child RLMs
- **fast-rlm** (avbiswas/fast-rlm) — Step 0 orientation preamble, parallel `asyncio.gather` sub-calls, dual model routing (stronger root, cheaper sub), dual system prompts (leaf vs non-leaf), 2K char truncation (aggressive but fast), fresh runtime per sub-agent
- **Prime Intellect** (verifiers/RLMEnv) — answer dictionary pattern (`{"content": "", "ready": True}`), tools restricted to sub-LLMs only, `llm_batch()` for parallel dispatch, 8K char truncation, FIFO-based sandbox communication, per-REPL-call 120s timeout
- **rlm-rs** (zircote/rlm-rs) — Rust CLI using pass-by-reference chunk IDs, tree-sitter code-aware chunking, hybrid BGE-M3+BM25 search with RRF, SQLite persistence
- **Google ADK RLM** — lazy Path objects (data stays on disk/GCS until code accesses it), massive parallelism with global concurrency limits
## The Five Primitives
| Primitive | Purpose | Replaces |
@@ -46,6 +56,7 @@ Single crate: `crates/ironclaw_engine/`
```
crates/ironclaw_engine/
Cargo.toml
CLAUDE.md
src/
lib.rs # Public API, re-exports
@@ -53,352 +64,198 @@ crates/ironclaw_engine/
mod.rs
error.rs # EngineError, ThreadError, StepError, CapabilityError
thread.rs # Thread, ThreadId, ThreadState, ThreadType, ThreadConfig
step.rs # Step, StepId, StepStatus, ExecutionTier, ActionCall, ActionResult
step.rs # Step, StepId, StepStatus, ExecutionTier, ActionCall, ActionResult, LlmResponse
capability.rs # Capability, ActionDef, EffectType, CapabilityLease, PolicyRule
memory.rs # MemoryDoc, DocId, DocType
project.rs # Project, ProjectId
event.rs # ThreadEvent, EventKind (event sourcing)
event.rs # ThreadEvent, EventKind (16 variants for event sourcing)
provenance.rs # Provenance enum (User, System, ToolOutput, LlmGenerated, etc.)
message.rs # ThreadMessage, MessageRole
conversation.rs # ConversationSurface, ConversationEntry (Phase 5)
mission.rs # Mission, MissionId (Phase 4)
traits/ # External dependency abstractions
traits/ # External dependency abstractions (host implements these)
mod.rs
llm.rs # LlmBackend trait
store.rs # Store trait (thread/step/event/project/doc/lease CRUD)
store.rs # Store trait (18 CRUD methods)
effect.rs # EffectExecutor trait
code_runner.rs # CodeRunner trait (Phase 3)
capability/ # Capability management
mod.rs
registry.rs # CapabilityRegistry
lease.rs # LeaseManager (grant, check, consume, revoke, expire)
policy.rs # PolicyEngine (deterministic effect-level allow/deny)
provenance.rs # ProvenanceTracker (taint analysis at effect boundaries, Phase 4)
policy.rs # PolicyEngine (deterministic effect-level allow/deny/approve)
provenance.rs # ProvenanceTracker (taint analysis, Phase 4)
runtime/ # Thread lifecycle management
mod.rs
manager.rs # ThreadManager (spawn, supervise, stop, inject messages)
manager.rs # ThreadManager (spawn, supervise, stop, inject, join)
tree.rs # ThreadTree (parent-child relationships)
messaging.rs # ThreadMailbox, ThreadSignal (inter-thread communication)
conversation.rs # ConversationManager (UI surface → thread routing, Phase 5)
messaging.rs # ThreadSignal, ThreadOutcome, signal channels
conversation.rs # ConversationManager (Phase 5)
executor/ # Step execution
mod.rs
loop_engine.rs # ExecutionLoop (core loop replacing run_agentic_loop)
loop_engine.rs # ExecutionLoop (core loop, handles Text/ActionCalls/Code)
structured.rs # Tier 0: structured tool calls
scripting.rs # Tier 1: embedded Python via Monty (Phase 3)
context.rs # Context builder (thread state + project docs + capabilities)
scripting.rs # Tier 1: embedded Python via Monty (RLM pattern)
context.rs # Context builder (messages + actions from leases)
intent.rs # Tool intent nudge detection
memory/ # Memory document system
mod.rs
store.rs # MemoryStore (project-scoped doc operations)
retrieval.rs # RetrievalEngine (context building from project docs)
store.rs # MemoryStore (project-scoped doc CRUD)
retrieval.rs # RetrievalEngine (stub, Phase 4)
reflection/ # Post-thread reflection pipeline
reflection/ # Post-thread reflection (stub, Phase 4)
mod.rs
pipeline.rs # ReflectionPipeline (summarize, extract lessons, detect issues)
learning.rs # Tool reliability learning, playbook promotion
testing/ # Test utilities (cfg(test))
mod.rs
mock_llm.rs # MockLlmBackend (queued responses)
mock_store.rs # MockStore (in-memory HashMap storage)
mock_effect.rs # MockEffectExecutor (configurable results)
```
Dependencies (minimal — no main crate dependency):
Dependencies:
- `tokio` (sync, time, macros, rt), `serde` + `serde_json`, `thiserror`, `tracing`, `uuid`, `chrono`, `async-trait`
- `monty` (git dep) — embedded Python interpreter for CodeAct (Tier 1)
- `monty` (git dep from pydantic/monty) — embedded Python interpreter for CodeAct
---
## Phase 1: Foundation (Types + Traits + Thread Lifecycle)
## Phase 1: Foundation — DONE
**Goal:** Get the crate compiling with all core types, trait definitions, and thread state machine. No execution yet.
**Commit:** `8be19a4`
### 1.1 Crate scaffolding
- Create `crates/ironclaw_engine/Cargo.toml` (follow `ironclaw_safety` pattern)
- Add to workspace `members` in root `Cargo.toml`
All core types, trait definitions, and thread state machine. 32 tests.
### 1.2 Core types
All files in `src/types/`. Pure data structures with `Serialize`/`Deserialize`.
**`error.rs`** — Error hierarchy:
- `EngineError` (top-level: Thread, Step, Capability, Store, Llm, Effect, InvalidTransition, NotFound, LeaseExpired, LeaseDenied, MaxIterations)
- `ThreadError` (AlreadyRunning, Terminal, ParentNotRunning)
- `StepError` (Timeout, ActionDenied)
- `CapabilityError` (NotFound, EffectDenied)
**`thread.rs`** — Thread state machine:
```
Created → Running → Waiting → Running (resume)
→ Suspended → Running (resume)
→ Completed → Reflecting → Done
→ Failed
```
- `ThreadState::can_transition_to(target) -> bool`
- `ThreadState::is_terminal() -> bool` (Done, Failed)
- `ThreadState::is_active() -> bool` (Running, Waiting)
- `Thread::transition_to(state) -> Result<(), EngineError>` (validates + records event)
**`step.rs`** — Step + LLM response types:
- `LlmResponse::Text(String)` or `LlmResponse::ActionCalls { calls, content }`
- `ActionCall { id, action_name, parameters }`
- `ActionResult { call_id, action_name, output, is_error, duration }`
- `TokenUsage { input_tokens, output_tokens, cache_read_tokens, cache_write_tokens }`
- `ExecutionTier` enum — Phase 1: only `Structured`
**`capability.rs`** — Effect typing + leases:
- `EffectType` enum: ReadLocal, ReadExternal, WriteLocal, WriteExternal, CredentialedNetwork, Compute, Financial
- `ActionDef { name, description, parameters_schema, effects: Vec<EffectType>, requires_approval }`
- `Capability { name, description, actions, knowledge: Vec<String>, policies: Vec<PolicyRule> }`
- `CapabilityLease { id, thread_id, capability_name, granted_actions, granted_at, expires_at, max_uses, uses_remaining, revoked }`
- `PolicyRule { name, condition: PolicyCondition, effect: PolicyEffect }`
- `PolicyCondition` enum: Always, ActionMatches { pattern }, EffectTypeIs(EffectType)
- `PolicyEffect` enum: Allow, Deny, RequireApproval
**`message.rs`** — Engine's own message type (simpler than `ChatMessage`):
- `MessageRole` enum: System, User, Assistant, ActionResult
- `ThreadMessage { role, content, provenance, action_call_id, action_name, action_calls, timestamp }`
- Constructors: `system()`, `user()`, `assistant()`, `assistant_with_actions()`, `action_result()`
**`event.rs`** — Event sourcing:
- `EventKind` enum: StateChanged, StepStarted, StepCompleted, StepFailed, ActionExecuted, ActionFailed, LeaseGranted, LeaseRevoked, LeaseExpired, MessageAdded, ChildSpawned, ChildCompleted, ApprovalRequested, ApprovalReceived
**`project.rs`**, **`memory.rs`**, **`provenance.rs`** — Straightforward structs.
### 1.3 Trait definitions
- `LlmBackend``complete(messages, actions, config) -> LlmOutput`, `model_name() -> &str`
- `Store` — Thread/Step/Event/Project/MemoryDoc/Lease CRUD (~20 methods)
- `EffectExecutor``execute_action(name, params, lease, ctx) -> ActionResult`, `available_actions(leases) -> Vec<ActionDef>`
### 1.4 Tests
- Thread state machine: all valid/invalid transitions
- ThreadMessage constructors
- CapabilityLease expiry checks (time-based, use-based)
### Verification
```bash
cargo check -p ironclaw_engine
cargo clippy -p ironclaw_engine --all-targets -- -D warnings
cargo test -p ironclaw_engine
```
- Types: Thread (state machine), Step (LlmResponse, ActionCall, ActionResult, TokenUsage), Capability (ActionDef, EffectType, CapabilityLease, PolicyRule), MemoryDoc (DocType), Project, ThreadEvent (EventKind), ThreadMessage, Provenance, EngineError
- Traits: LlmBackend, Store (18 methods), EffectExecutor
- Tests: state machine transitions (valid/invalid), lease expiry (time/use), message constructors
---
## Phase 2: Execution Engine (Tier 0 — Structured Tool Calls)
## Phase 2: Execution Engine (Tier 0)DONE
**Goal:** A working execution loop that is functionally equivalent to the current `run_agentic_loop()`. Thread spawning, capability leasing, policy enforcement, event logging.
**Commit:** `bf7dfb8`
### 2.1 Capability management
- `CapabilityRegistry` — register/get/list capabilities and their actions
- `LeaseManager` — grant leases (scoped, time-limited, use-limited), check validity, consume uses, revoke, expire stale. State: `RwLock<HashMap<LeaseId, CapabilityLease>>`
- `PolicyEngine` — deterministic evaluation: `evaluate(action_def, lease, thread_context) -> PolicyDecision`. Check order: global policies → capability policies → action-level `requires_approval` → effect type against lease. Deny > RequireApproval > Allow
Working execution loop equivalent to `run_agentic_loop()`. 74 tests.
### 2.2 Thread runtime
- `ThreadTree` — in-memory parent-child tracking. `add_child()`, `parent_of()`, `children_of()`, `remove()`, `ancestors()`
- `ThreadMailbox` + `ThreadSignal``mpsc`-based inter-thread messaging. Signals: Stop, Suspend, Resume, InjectMessage, ChildCompleted
- `ThreadManager` — orchestrator. `spawn_thread()` creates thread + leases + `ExecutionLoop`, wraps in tokio task. `stop_thread()`, `inject_message()`, `get_thread_state()`. Holds `Arc<dyn Store/LlmBackend/EffectExecutor>` + capability/lease/policy
### 2.3 Execution loop
- `build_step_context()` — assemble messages + action definitions from thread state + active leases
- `execute_action_calls()`Tier 0: for each ActionCall, find lease → check policy → consume use → call `EffectExecutor` → record result + event. Returns NeedApproval if policy requires it
- `ExecutionLoop::run()` — core loop mirroring `run_agentic_loop()`:
1. `signal_rx.try_recv()` → handle Stop, Suspend, InjectMessage
2. `build_step_context()` → messages + actions
3. `llm.complete()` → LlmOutput
4. If `LlmResponse::Text` → check tool intent nudge, return if final
5. If `LlmResponse::ActionCalls``execute_action_calls()`, add results to messages
6. Record Step, emit events
7. Check max_iterations, force_text on final iterations
8. Repeat
### 2.4 Memory stubs
- `MemoryStore` — thin wrapper: `create_doc()`, `get_doc()`, `update_doc()`, `list_by_type()`
- `RetrievalEngine` — stub returning empty vec
### 2.5 Tests (comprehensive)
- Simple text response: MockLlm returns text → thread Created→Running→Completed→Done
- Tool call then text: MockLlm returns ActionCalls then text → effect executor called, result in messages
- Multi-tool parallel: Multiple ActionCalls in one response → all executed, all results recorded
- Max iterations: MockLlm always returns actions → loop stops at limit
- Stop signal: Send Stop → clean termination
- Inject message: Send InjectMessage during loop → appears in context
- Lease expiry (uses): max_uses=1 → first OK, second fails
- Lease expiry (time): expires_at in past → immediate failure
- Policy deny: Financial effect blocked → ActionDenied
- Policy require approval: → returns NeedApproval outcome
- Event sourcing: run loop → verify all events recorded in order
- Tool intent nudge: "Let me search..." text → nudge injected, capped at max
- Child thread spawning: spawn from parent → tree relationships correct, child completion event on parent
### Verification
```bash
cargo test -p ironclaw_engine
# All existing tests still pass:
cargo test
```
- **CapabilityRegistry** — register/get/list capabilities and actions (5 tests)
- **LeaseManager** — grant, check, consume, revoke, expire. `RwLock<HashMap>` (7 tests)
- **PolicyEngine** — deterministic: global policies → capability policies → action requires_approval → effect type. Deny > RequireApproval > Allow (8 tests)
- **ThreadTree** — parent-child relationships (5 tests)
- **ThreadSignal/ThreadOutcome** — mpsc-based inter-thread messaging
- **ThreadManager** — spawn as tokio tasks, stop, inject messages, join (3 tests)
- **ExecutionLoop** — signals → context → LLM call → handle Text/ActionCalls → record step + events → repeat (6 tests)
- **execute_action_calls()**lease lookup → policy → consume EffectExecutor
- **signals_tool_intent()** — nudge detection (6 tests)
- **MemoryStore** + **RetrievalEngine** — stubs for Phase 4
---
## Phase 3: CodeAct Executor (Tier 1 — Monty Python + RLM Pattern)
## Phase 3: CodeAct Executor (Tier 1 — Monty + RLM) — DONE
**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.
**Commits:** `b59a0b9`, `9538332`
**Status:** Implemented (Phases 3.13.3). Phases 3.43.5 are the RLM enhancements.
LLMs write Python code that composes tools, queries thread context as data, and recursively spawns sub-agents. Uses Monty interpreter with the RLM (Recursive Language Model) pattern.
### 3.1 Monty integration (DONE)
### What was built
`executor/scripting.rs` — Embeds the Monty Python interpreter (git dep, v0.0.8).
**Monty integration** (`executor/scripting.rs`):
- Embeds Pydantic's Monty Python interpreter (git dep, v0.0.8)
- `MontyRun::new(code, "step.py", input_names)``runner.start(inputs, tracker, print)` → loop over `RunProgress` suspension points
- Resource limits: 30s timeout, 64MB memory, 1M allocations, recursion depth 1000
- All execution wrapped in `catch_unwind` (Monty can panic)
- `monty_to_json()` / `json_to_monty()` bidirectional conversion
**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)
**RLM features** (cross-referenced against official RLM, fast-rlm, Prime Intellect):
**Resource limits:** 30s timeout, 64MB memory, 1M allocations, recursion depth 1000.
| Feature | Implementation | Reference |
|---|---|---|
| Context as variables | `context`, `goal`, `step_number`, `previous_results` injected as Monty inputs | RLM paper §3 |
| `FINAL(answer)` | FunctionCall handler sets `final_answer`, loop exits | Official RLM, fast-rlm |
| `FINAL_VAR(name)` | FunctionCall handler stores var name reference | Official RLM |
| `llm_query(prompt, context)` | FunctionCall → single-shot `LlmBackend::complete()` with force_text | All three impls |
| `llm_query_batched(prompts)` | FunctionCall → parallel `tokio::spawn` for each prompt, collect results | fast-rlm asyncio.gather, Prime Intellect llm_batch |
| Output truncation (8K chars) | `compact_output_metadata()` with `[TRUNCATED: last N chars]` or `[FULL OUTPUT]` prefix | Prime Intellect 8192, Official 20K, fast-rlm 2K |
| Step 0 orientation | Auto-inject context metadata (msg count, total chars, goal, preview) before first code step | fast-rlm Step 0 auto-print |
| Error-to-LLM flow | Parse/runtime/name/OS errors return as stdout content, not EngineError. LLM can self-correct. | Official RLM (errors in stderr shown to LLM) |
| Tool dispatch | Unknown functions suspend VM → lease → policy → EffectExecutor → resume | Original design |
| OS call denial | `RunProgress::OsCall``OSError` exception | Original design |
| Async denial | `RunProgress::ResolveFutures` → error in stdout | Original design |
**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
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"])
```
**LlmResponse::Code** variant + **ExecutionTier::Scripting** — the `ExecutionLoop` routes `Code` to `scripting::execute_code()`.
**Type conversion:** `monty_to_json()` / `json_to_monty()` bidirectional conversion between `MontyObject` and `serde_json::Value`.
### Remaining gaps (future phases)
### 3.2 LlmResponse::Code variant (DONE)
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
```
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 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
| Gap | Where it fits | Source |
|---|---|---|
| `rlm_query()` (child gets own REPL + full RLM loop) | Phase 4 — needs ThreadManager in CodeAct runtime | Official RLM |
| Dual model routing (cheaper model for sub-calls) | Phase 4 — LlmBackend needs `complete_with_model(model, ...)` | fast-rlm, Official RLM |
| Compaction at 85% context limit | Phase 4 — summarize history, reset messages | Official RLM |
| Persistent REPL state across code steps | Monty limitation (fresh MontyRun per step) — monitor Monty roadmap | Official RLM LocalREPL |
| Scaffold restoration (prevent code overwriting context/llm_query) | Not needed — Monty creates fresh execution per step | Official RLM |
| `SHOW_VARS()` listing | Monty limitation — no namespace access from host | Official RLM |
| Consecutive error counting + threshold | Phase 4 — add `max_consecutive_errors` to ThreadConfig | Official RLM |
| USD budget tracking | Phase 4 — needs cost data from LlmBackend | Official RLM, fast-rlm |
| answer dictionary pattern (`{"content":"","ready":True}`) | Alternative to FINAL() — lower priority, FINAL() works | Prime Intellect |
| Tools restricted to sub-LLMs only | Design decision for Phase 4 — evaluate tradeoffs | Prime Intellect |
| Lazy Path objects (data on disk until accessed) | Phase 4 retrieval — avoid loading full context upfront | Google ADK |
| Pass-by-reference chunk IDs for sub-agents | Phase 4 retrieval — sub-agents get IDs not content | rlm-rs |
| Code-aware chunking (tree-sitter) | Phase 4 retrieval — for code repositories | rlm-rs |
---
## Phase 4: Memory, Reflection, and Learning
**Goal:** The agent learns from its work. Completed threads produce structured knowledge (summaries, lessons, playbooks) that improve future threads.
**Goal:** The agent learns from its work. Completed threads produce structured knowledge. Context building uses project-scoped retrieval, not raw history replay.
### 4.1 Project-scoped retrieval
- `RetrievalEngine::retrieve_context(project_id, query, max_docs)` — keyword + semantic search over project's memory docs
- Context builder uses retrieval: thread state + project docs (summaries, lessons, playbooks) + capability descriptions
- The LLM gets relevant project knowledge, not raw history
- Context builder: thread state + project docs (summaries, lessons, playbooks) + capability descriptions
- **Lazy loading** (Google ADK pattern): data stays in storage until code explicitly accesses it via variables
- **Pass-by-reference** (rlm-rs pattern): sub-agents receive chunk IDs, fetch content on demand
### 4.2 Reflection pipeline
After thread completes (state → Completed), optionally spawns a Reflection-type thread:
1. **Summarize** produce `DocType::Summary` doc
2. **Extract lessons**scan for failures, workarounds, discoveries → produce `DocType::Lesson` docs
3. **Detect issues**find problems that weren't resolved → produce `DocType::Issue` docs
4. **Detect missing capabilities** → "no tool available" patterns → produce `DocType::Spec` docs
5. **Promote playbooks** → successful multi-step procedures → produce `DocType::Playbook` docs
1. **Summarize**`DocType::Summary`
2. **Extract lessons**`DocType::Lesson` (from failures, workarounds, discoveries)
3. **Detect issues**`DocType::Issue` (unresolved problems)
4. **Detect missing capabilities**`DocType::Spec` ("no tool available" patterns)
5. **Promote playbooks**`DocType::Playbook` (successful multi-step procedures)
Reflection is itself a thread running CodeAct — it's recursive.
### 4.3 Provenance tracking
Every data value tagged with origin:
- `Provenance::User` — direct user input
- `Provenance::System` — system prompt, config
- `Provenance::ToolOutput { action_name }` — result from a capability action
- `Provenance::LlmGenerated` — LLM output
- `Provenance::Reflection { source_thread_id }` — from reflection pipeline
- `Provenance::MemoryRetrieval { doc_id }` — from project memory
### 4.3 Compaction (from RLM)
When message history tokens reach **85% of model context limit** (per official RLM):
1. Ask LLM to "summarize progress so far" with instructions to preserve intermediate results
2. Replace message history with `[system, summary, "continue..."]`
3. Append full trajectory to a `history` variable accessible from code
- Requires token counting — add `count_tokens(messages, model)` utility (tiktoken or char-estimate fallback, per official RLM `token_utils.py`)
The policy engine uses provenance at effect boundaries:
- LlmGenerated data cannot flow into Financial effects without approval
- ToolOutput from untrusted sources triggers extra validation
- User-provenance data is trusted (no taint)
### 4.4 `rlm_query()` — full recursive sub-agent
Unlike `llm_query()` (single-shot text completion), `rlm_query(prompt)` spawns a **child thread with its own CodeAct executor**:
- Child gets own REPL, own context variable, own iteration budget
- Child can call `llm_query()` and tools but NOT `rlm_query()` (depth limit)
- Budget/timeout inheritance: child gets `remaining_budget - spent`, `remaining_timeout - elapsed`
- Returns child's `FINAL()` answer as a string variable
### 4.4 Missions (long-running goals)
### 4.5 Dual model routing
`LlmBackend` gains optional depth-based model selection:
- depth=0 (root): use primary model (e.g., GPT-5, Claude Opus)
- depth=1+ (sub-calls): use cheaper model (e.g., GPT-5-mini, Claude Haiku)
- Configurable via `ThreadConfig` or `LlmCallConfig`
### 4.6 Budget controls (from RLM cross-reference)
Add to `ThreadConfig`:
- `max_budget_usd: Option<f64>` — cumulative USD cost limit (needs cost data from LlmBackend)
- `max_timeout: Option<Duration>` — wall-clock timeout for entire thread
- `max_tokens_total: Option<u64>` — cumulative input+output token limit
- `max_consecutive_errors: Option<u32>` — consecutive steps with errors before termination
- All limits inherited by child threads with remaining budget
### 4.7 Provenance tracking
Every data value tagged with origin. Policy engine uses provenance at effect boundaries:
- LlmGenerated → Financial effects: require approval
- ToolOutput from untrusted sources: extra validation
- User-provenance: trusted
### 4.8 Missions (long-running goals)
```rust
pub struct Mission {
pub id: MissionId,
@@ -406,27 +263,24 @@ pub struct Mission {
pub goal: String,
pub status: MissionStatus, // Active, Paused, Completed, Failed
pub cadence: MissionCadence, // Cron, OnEvent, OnPush, Manual
pub thread_history: Vec<ThreadId>, // past threads spawned by this mission
pub thread_history: Vec<ThreadId>,
pub success_criteria: Option<String>,
}
```
Missions spawn threads on cadence, track progress across runs, and adapt based on reflection docs.
### 4.5 Tool reliability learning
Track per-action metrics:
- Success rate (EMA)
- Avg latency
- Common failure patterns
- Last N results
### 4.9 Tool reliability learning
Track per-action EMA metrics (success rate, latency, failure patterns). Feed into context builder.
Feed into context builder so the LLM knows "this tool has been flaky recently."
### 4.6 Tests
- Reflection produces correct doc types for a completed thread with failures
### 4.10 Tests
- Reflection produces correct doc types from a completed thread with failures
- Retrieval returns project-scoped docs, not cross-project
- Compaction triggers at 85% context, preserves intermediate results
- `rlm_query()` spawns child thread, returns answer, respects budget inheritance
- Dual model routing: root uses primary, sub-calls use cheaper
- Budget exceeded → `BudgetExceededError` with partial answer
- Consecutive errors threshold → termination
- Provenance taint blocks financial effects from LLM-generated data
- Mission spawns thread on cadence, tracks history
- Tool reliability metrics update correctly after successes/failures
---
@@ -477,33 +331,32 @@ The existing `Channel` trait stays. A bridge adapter translates:
**Goal:** Full CodeAct with WASM sandbox (Tier 2) and Docker container (Tier 3). Two-phase commit for high-stakes effects.
### 6.1 Tier 2: WASM sandbox
- Embed Python interpreter (RustPython) or use Starlark compiled to WASM
- Leverage existing `wasmtime` infrastructure from `src/tools/wasm/`
- Fuel metering, memory limits, network allowlisting (all existing)
- Runtime API exposed via WIT interface (extend existing `wit/tool.wit`)
- Candidate runtimes: RustPython compiled to WASM, or Monty if it gains WASM support
### 6.2 Tier 3: Docker container
- Leverage existing `src/sandbox/` + `src/orchestrator/` infrastructure
- Full Python runtime with `thread.*` and `tools.*` available via HTTP proxy
- Full CPython with pip packages, shell access, filesystem
- `llm_query()` / `llm_query_batched()` / tool calls available via HTTP proxy to orchestrator
- Network access through existing sandbox proxy (domain allowlist, credential injection)
- This is the production path for complex CodeAct (data science, system admin, etc.)
### 6.3 Automatic tier selection
Analyze LLM-generated code:
- Pure `tools.*` calls, no I/O → Tier 1 (embedded)
- Uses `tools.web_fetch` or HTTP → Tier 2 (WASM, allowlisted network)
- Uses `tools.shell`, `import os`, filesystem → Tier 3 (Docker)
- Falls back gracefully: if Tier 1 fails with capability error, promote to Tier 2/3
Analyze LLM-generated code statically or try-and-promote:
- Pure function calls, no I/O → Tier 1 (Monty, in-process)
- Uses HTTP/network tools → Tier 2 (WASM, allowlisted network)
- Uses `import os`, shell, filesystem, pip packages → Tier 3 (Docker)
- Falls back gracefully: if Tier 1 fails with capability error, promote to Tier 3
### 6.4 Two-phase commit
For `WriteExternal` + `Financial` effects:
1. **Simulate** — dry-run the effect, return preview
1. **Simulate** — dry-run, return preview
2. **Approve** — user or policy approves
3. **Execute** — actual effect
Replaces current binary approve/deny with richer commit policies:
- `CommitPolicy::Direct` — execute immediately (ReadLocal, ReadExternal)
- `CommitPolicy::Approved` — needs approval before execution (WriteExternal)
- `CommitPolicy::TwoPhase` — simulate → approve → execute (Financial, production deploys)
Commit policies: `Direct` (ReadLocal, ReadExternal), `Approved` (WriteExternal), `TwoPhase` (Financial, production deploys).
### 6.5 Tests
- Tier selection routes correctly based on code analysis
@@ -516,55 +369,34 @@ Replaces current binary approve/deny with richer commit policies:
## Phase 7: Main Crate Integration
**Goal:** Bridge adapters connect the engine to existing IronClaw infrastructure. Feature-flagged swap.
**Goal:** Bridge adapters connect the engine to existing IronClaw infrastructure.
### 7.1 Bridge adapters (`src/bridge/`)
- `LlmBridgeAdapter` — wraps `Arc<dyn LlmProvider>`, converts `ThreadMessage``ChatMessage`, `ActionDef``ToolDefinition`
- `LlmBridgeAdapter` — wraps `Arc<dyn LlmProvider>`, converts `ThreadMessage``ChatMessage`, `ActionDef``ToolDefinition`. Implements depth-based model routing via existing `cheap_llm` in `AgentDeps`
- `StoreBridgeAdapter` — wraps `Arc<dyn Database>`, maps engine CRUD to existing sub-traits. New tables for threads/projects/docs/leases/events (migration V14+)
- `EffectBridgeAdapter` — wraps `ToolRegistry` + `SafetyLayer`. On `execute_action()`: lookup tool → validate params via safety → execute → sanitize output → return. This is where safety logic lives (not in the engine)
- `EffectBridgeAdapter` — wraps `ToolRegistry` + `SafetyLayer`. On `execute_action()`: lookup tool → validate params → execute → sanitize output → return. Safety logic lives here, not in the engine
### 7.2 Database migrations
New tables:
- `engine_threads` (id, goal, type, state, project_id, parent_id, config_json, metadata, timestamps)
- `engine_steps` (id, thread_id, sequence, status, tier, request_json, response_json, results_json, tokens_json, timestamps)
- `engine_events` (id, thread_id, timestamp, kind_json)
- `engine_projects` (id, name, description, metadata, timestamps)
- `engine_memory_docs` (id, project_id, doc_type, title, content, source_thread_id, tags_json, metadata, timestamps)
- `engine_capability_leases` (id, thread_id, capability_name, granted_actions_json, granted_at, expires_at, max_uses, uses_remaining, revoked)
New tables (both PostgreSQL and libSQL):
- `engine_threads`, `engine_steps`, `engine_events`, `engine_projects`, `engine_memory_docs`, `engine_capability_leases`
Both PostgreSQL and libSQL backends (per existing dual-backend requirement).
### 7.3 Integration strategy
Implement `EngineV2Delegate` that wraps `ExecutionLoop` but presents the `LoopDelegate` interface. The existing dispatcher calls `run_agentic_loop()` with either ChatDelegate or EngineV2Delegate. This enables gradual migration without a flag day.
### 7.3 Feature-flagged swap
```rust
// In app.rs or agent_loop.rs:
#[cfg(feature = "engine_v2")]
{
let engine = ironclaw_engine::ThreadManager::new(
Arc::new(LlmBridgeAdapter::new(llm_provider)),
Arc::new(StoreBridgeAdapter::new(database)),
Arc::new(EffectBridgeAdapter::new(tool_registry, safety)),
);
// Use engine for thread management
}
```
### 7.4 Alternative LoopDelegate
Implement a `EngineV2Delegate` that wraps the engine's `ExecutionLoop` but presents the `LoopDelegate` interface. This enables gradual migration — the existing dispatcher calls `run_agentic_loop()` with either the old ChatDelegate or the new EngineV2Delegate.
### 7.5 Acceptance testing
Use existing `TestRig` + `TraceLlm` infrastructure:
### 7.4 Acceptance testing
Use existing `TestRig` + `TraceLlm`:
- Load pre-recorded LLM trace fixtures
- Drive the engine via bridge adapters
- Drive engine via bridge adapters
- Compare output with `verify_trace_expects()`
- All existing fixture tests must pass with identical results
- All existing fixture tests must pass
When all tests pass: remove feature flag, make engine the default, deprecate old path.
When all tests pass: make engine the default, deprecate old path.
### 7.6 Tests
- Bridge adapter conversion: ThreadMessage ↔ ChatMessage round-trips correctly
### 7.5 Tests
- Bridge adapter conversion: ThreadMessage ↔ ChatMessage round-trips
- End-to-end: TestRig drives engine, same output as old loop
- Migration: new tables created for both PostgreSQL and libSQL
- Feature flag: both paths compile and pass tests
- Migration: new tables for both backends
- Both paths compile and pass
---
@@ -582,55 +414,65 @@ When all tests pass: remove feature flag, make engine the default, deprecate old
### 8.2 Slim down main crate
- Agent module becomes thin adapter over engine
- `app.rs` orchestrates engine startup instead of manually wiring channels/tools/sessions
- `app.rs` orchestrates engine startup
- Remove `LoopDelegate` and its three implementations
- Remove `SessionManager`, `Scheduler` (replaced by `ThreadManager`)
### 8.3 Sub-crate extraction
Once engine boundaries are stable, split internal modules into sub-crates if beneficial:
- `ironclaw_types` — shared types usable by WASM extensions
Once boundaries stabilize, split if beneficial:
- `ironclaw_types` — shared types for WASM extensions
- `ironclaw_capability` — if used by tooling/CLI independently
- `ironclaw_codeact` — if the code runner grows complex
- `ironclaw_codeact` — if code runner grows complex
---
## Cross-Cutting Concerns
### Security Model
- **Capability leases** replace static permissions. Scoped per-thread, time-limited, use-limited. Blast radius bounded by lease
- **Effect typing** on every action. Policy engine uses effect types (not tool names) for allow/deny
- **Provenance tracking** (Phase 4). Data tagged with origin; taint analysis at effect boundaries
- **Capability leases** replace static permissions. Scoped, time-limited, use-limited. Blast radius bounded
- **Effect typing** on every action. Policy engine uses effect types for allow/deny
- **Provenance tracking** (Phase 4). Taint analysis at effect boundaries
- **Two-phase commit** (Phase 6) for WriteExternal + Financial effects
- **Safety at adapter boundary**. The engine is pure orchestration; `SafetyLayer` (sanitization, leak detection, injection checking) is applied in `EffectBridgeAdapter`
- **Safety at adapter boundary**. Engine is pure orchestration; `SafetyLayer` applied in `EffectBridgeAdapter`
- **Monty sandboxing**: no filesystem (OsCall denied), no network (no imports), resource-limited, catch_unwind for panics
### Observability
- **Event sourcing** replaces ad-hoc `ObserverEvent`. Every thread has a complete event log
- **Trace-based testing** (Phase 4+). Use event logs as golden traces for regression testing
- **Thread-structural events** (thread.started, step.completed, action.executed) vs current per-subsystem events
- **Event sourcing** replaces ad-hoc `ObserverEvent`. Every thread has complete event log (16 event kinds)
- **Trace-based testing** (Phase 4+). Event logs as golden traces
- **Thread-structural events** (thread.started, step.completed, action.executed) vs per-subsystem
### RLM Execution Model
- **Context as variable**: thread messages/goal/results injected as Python variables, not LLM attention input
- **Output truncation**: 8K chars between steps (configurable), with `[TRUNCATED]`/`[FULL OUTPUT]` prefixes
- **Step 0 orientation**: auto-inject context metadata before first code step
- **FINAL()/FINAL_VAR()**: explicit termination from within code
- **llm_query()/llm_query_batched()**: recursive/parallel sub-agent calls
- **Error transparency**: Python errors flow to LLM for self-correction, not step termination
- **Symbolic composition**: sub-agent results stored as variables, not injected into parent context
### Backward Compatibility
- Engine runs alongside existing code (feature flag)
- Engine runs alongside existing code via `EngineV2Delegate` adapter
- Bridge adapters translate between engine and existing types
- WASM tools/channels unchanged — they implement `Tool`/`Channel` traits, which the bridge wraps
- MCP tools unchanged same adapter principle
- Existing tests unmodified — they test the old path; new tests validate the engine
- WASM tools/channels unchanged (bridge wraps `Tool`/`Channel` traits)
- MCP tools unchanged (same adapter principle)
- Existing tests unmodified — they test the old path
---
## Implementation Order Summary
## Implementation Progress
| Phase | Scope | Depends on | Key deliverable |
|-------|-------|------------|-----------------|
| **1** | Types + traits + state machine | Nothing | Compiling crate with all type definitions |
| **2** | Tier 0 executor + capability + runtime | Phase 1 | Working execution loop equivalent to `run_agentic_loop()` |
| **3** | CodeAct (Tier 1 embedded scripting) | Phase 2 | LLMs write code that composes tools |
| **4** | Reflection + retrieval + provenance + missions | Phase 2 | Agent learns from work, project-scoped memory |
| **5** | Conversation surface + channel integration | Phase 2 | UI separated from execution |
| **6** | Tier 2-3 + two-phase commit | Phase 3 | Full sandboxed code execution |
| **7** | Main crate bridge + acceptance tests | Phase 2+ | Engine passes all existing tests via adapters |
| **8** | Cleanup + migration | Phase 7 | Old abstractions removed |
| Phase | Scope | Status | Tests | Commits |
|-------|-------|--------|-------|---------|
| **1** | Types + traits + state machine | **DONE** | 32 | `8be19a4` |
| **2** | Tier 0 executor + capability + runtime | **DONE** | 74 | `bf7dfb8` |
| **3** | CodeAct (Monty + RLM pattern) | **DONE** | 74 | `b59a0b9`, `9538332` |
| **4** | Reflection + retrieval + compaction + rlm_query + budget | Planned | — | — |
| **5** | Conversation surface + channel integration | Planned | — | — |
| **6** | Tier 2-3 + two-phase commit | Planned | — | — |
| **7** | Main crate bridge + acceptance tests | Planned | — | — |
| **8** | Cleanup + migration | Planned | — | — |
Phases 3, 4, 5 can proceed in parallel after Phase 2 is complete.
Phases 4, 5, 6 can proceed in parallel. Phase 7 depends on Phase 2+ being stable. Phase 8 depends on Phase 7 passing acceptance tests.
---
@@ -648,5 +490,5 @@ cargo clippy --all --benches --tests --examples --all-features
cargo test
# Phase 7+ acceptance:
cargo test --features engine_v2 # engine-driven tests match existing fixtures
cargo test # engine-driven tests match existing fixtures via EngineV2Delegate
```