18 KiB
Engine v2 Architecture
This document describes the IronClaw Engine v2 architecture for new contributors. It covers the execution model, the Python orchestrator, the bridge layer, and how everything fits together.
Overview
IronClaw Engine v2 replaces ~10 fragmented abstractions (Session, Job, Routine, Channel, Tool, Skill, Hook, Observer, Extension, LoopDelegate) with a unified model built on 5 primitives. The engine lives in crates/ironclaw_engine/ as a standalone crate with no dependency on the main ironclaw crate.
The key architectural innovation: the execution loop is Python code running inside the Monty interpreter, not Rust. Rust provides the infrastructure (LLM calls, tool execution, safety, persistence). Python provides the orchestration (tool dispatch, output formatting, state management). This makes the glue layer self-modifiable at runtime by the self-improvement Mission.
Five Primitives
| Primitive | Purpose | Replaces |
|---|---|---|
| Thread | Unit of work with lifecycle, parent-child tree, capability leases | Session + Job + Routine + Sub-agent |
| Step | Unit of execution (one LLM call + its action executions) | Agentic loop iteration + tool calls |
| Capability | Unit of effect (actions + knowledge + policies) | Tool + Skill + Hook + Extension |
| MemoryDoc | Unit of durable knowledge (summaries, lessons, playbooks) | Workspace memory blobs |
| Project | Unit of context (scopes memory, threads, missions) | Flat workspace namespace |
Execution Model
The Two-Layer Architecture
Rust Layer (stable kernel — rarely changes)
├── LlmBackend trait → make LLM API calls
├── EffectExecutor trait → run tools with safety/policy/hooks
├── Store trait → persist threads, steps, events, docs
├── LeaseManager → grant/check/consume/revoke capability leases
├── PolicyEngine → deterministic allow/deny/require-approval
├── ThreadManager → spawn, stop, inject messages, join threads
├── Monty VM → embedded Python interpreter
└── Safety layer → sanitization, leak detection, policy enforcement
Python Layer (self-modifiable orchestrator — where bugs get fixed)
├── The step loop → call LLM → handle response → repeat
├── Tool dispatch → name resolution, alias mapping
├── Output formatting → truncation, context assembly
├── State management → persisted_state dict across code steps
├── FINAL() extraction → parse termination signals from text
├── Tool intent nudging → detect when LLM describes instead of acts
└── Doc injection → format memory docs for context
How It Works
-
Bootstrap (
ExecutionLoop::run()inloop_engine.rs, ~80 lines):- Transition thread to Running state
- Inject CodeAct system prompt (with runtime prompt overlay if available)
- Load versioned Python orchestrator from Store (or compiled-in default)
- Execute orchestrator via Monty VM
- Map return value to
ThreadOutcome - Persist final state
-
Orchestrator (
orchestrator/default.py, ~230 lines):- Calls host functions to interact with Rust infrastructure
- Runs the step loop: check signals → check budget → call LLM → handle response
- For text responses: extract FINAL(), check nudge, or complete
- For code responses: run user code in nested Monty VM, format output
- For action calls: execute each action, handle approval flow
- Returns outcome dict:
{outcome, response, error, ...}
-
Host functions (Rust, called via Monty's suspension mechanism):
__llm_complete__→ callLlmBackend::complete()__execute_code_step__→ run user CodeAct code in a nested Monty VM__execute_action__→ execute a tool with lease + policy + safety__check_signals__→ poll for stop/inject signals__emit_event__→ broadcast ThreadEvent + record in thread__add_message__→ append message to thread history__save_checkpoint__→ persist state to thread metadata__transition_to__→ validated thread state transition__retrieve_docs__→ query memory docs from Store__check_budget__→ remaining tokens/time/USD__get_actions__→ available tool definitions from leases
Nested Execution (CodeAct)
When the LLM responds with Python code, the orchestrator calls __execute_code_step__(code, state). This suspends the orchestrator VM and creates a second Monty VM for the user's code:
Orchestrator VM (Monty #1)
→ calls __execute_code_step__(code, state)
→ suspends
→ Rust creates Monty #2 (user code VM)
→ User code calls web_search() → suspends → Rust executes tool → resumes
→ User code calls FINAL("answer") → terminates
→ Rust collects results
→ Orchestrator VM resumes with results dict
→ Orchestrator formats output, decides next step
This is the same mechanism as rlm_query() (recursive sub-agent). Each VM owns its own heap — no shared state, no locks.
Thread State Machine
Created → Running → Waiting → Running (resume)
→ Suspended → Running (resume)
→ Completed → Reflecting → Done
→ Failed
Terminal states: Done, Failed. Validated by ThreadState::can_transition_to().
Bridge Layer (src/bridge/)
The bridge connects the engine to existing IronClaw infrastructure:
| Adapter | Wraps | Purpose |
|---|---|---|
LlmBridgeAdapter |
LlmProvider |
Converts ThreadMessage ↔ ChatMessage, depth-based model routing, code block detection |
EffectBridgeAdapter |
ToolRegistry + SafetyLayer |
Tool execution with all v1 security controls, name normalization (underscore ↔ hyphen), rate limiting |
HybridStore |
Workspace |
In-memory for ephemeral data, workspace files for MemoryDocs |
EngineRouter |
Agent |
Routes messages through engine when ENGINE_V2=true, manages SSE events |
Enabling Engine v2
Set ENGINE_V2=true environment variable. The router in src/bridge/router.rs intercepts messages and routes them through the engine instead of the v1 agent loop.
For trace debugging: ENGINE_V2_TRACE=1 writes full JSON traces to engine_trace_*.json.
Memory and Reflection
MemoryDoc Types
| Type | Purpose | Produced By |
|---|---|---|
Summary |
What a thread accomplished | Reflection (always) |
Lesson |
Durable learning from experience | Reflection (on errors) |
Playbook |
Reusable multi-step procedure | Reflection (on success with 2+ tools) |
Issue |
Detected problem for follow-up | Reflection (on failure) |
Spec |
Missing capability request | Reflection (on "not found" errors) |
Note |
Working memory / scratch | Self-improvement, orchestrator code |
Reflection Pipeline
After a thread completes with enable_reflection: true:
- Trace analysis (non-LLM, always runs) — detects 8 issue categories
- LLM reflection — spawns a Reflection-type CodeAct thread with read-only tools
- Doc production — creates Summary, Lesson, Issue, Spec, Playbook docs
- Persistence — saves docs to Store (HybridStore → workspace files)
- Event firing — if issues detected, fires OnSystemEvent missions (self-improvement)
Context Injection
On each LLM call, build_step_context() retrieves up to 5 relevant MemoryDocs from the project and appends them to the system prompt as "## Prior Knowledge". This gives the LLM access to lessons, playbooks, and known issues from prior threads.
Missions
Missions are long-running goals that spawn threads over time. They replace v1 Routines.
Mission
├── goal: "Increase test coverage to 80%"
├── cadence: Cron("0 9 * * *") | OnSystemEvent | Manual | Webhook
├── current_focus: "Write tests for auth module" (evolves)
├── approach_history: ["Analyzed codebase", "Added 15 tests for db"]
├── thread_history: [thread_1, thread_2, ...]
└── max_threads_per_day: 10
How Missions Fire
- Cron: Background ticker checks every 60s, fires missions with past
next_fire_at - OnSystemEvent: Event listener subscribes to ThreadManager events, fires matching missions when threads complete with issues
- Manual:
mission_fire(id)from CodeAct or API - Webhook: Bridge routes incoming webhooks to matching missions
Meta-Prompt Generation
When a mission fires, build_meta_prompt() assembles:
- Mission goal + success criteria
- Current focus (what to work on next)
- Approach history (what was tried and what happened)
- Project knowledge (relevant MemoryDocs)
- Trigger payload (event data, trace issues)
The thread runs with this context and returns: what it accomplished, what to focus on next, whether the goal is achieved. process_mission_outcome() extracts these and updates the mission.
Capability System
Leases
Threads don't have static permissions. They receive leases — scoped, time-limited, use-limited grants:
CapabilityLease {
thread_id,
capability_name,
granted_actions: ["web_search", "read_file", ...],
expires_at: Option<DateTime>,
max_uses: Option<u32>,
revoked: bool,
}
Policy Engine
The PolicyEngine evaluates actions against leases deterministically:
- Check global denied effects (e.g., deny all Financial)
- Check capability-level policies (per-action rules)
- Check action's
requires_approvalflag - Check effect types against lease grant
Decision priority: Deny > RequireApproval > Allow
Effect Types
Every action declares its side effects:
ReadLocal, ReadExternal, WriteLocal, WriteExternal,
CredentialedNetwork, Compute, Financial
Integration Scaling Strategy
The Problem: Tool List Bloat
A naive approach to adding third-party integrations (Slack, GitHub, Stripe, etc.) is to register each API action as a separate tool — slack_post_message, slack_list_channels, github_create_issue, etc. This fails for LLM-based agents:
- Each tool definition costs ~80-120 tokens in the tool list, sent on every request
- 200 actions = ~20,000 tokens always-on context cost
- LLM tool selection accuracy degrades significantly beyond ~20-30 tools
- The LLM still has to construct correct parameters — deterministic execution doesn't help if the LLM picks the wrong tool or hallucinates params
This was confirmed by studying Pica (formerly IntegrationOS), which supports 200+ platforms via data-driven definitions in MongoDB. Pica's approach works for programmatic API access, but registering all those actions as LLM tools would degrade agent performance.
The Solution: Capabilities as Knowledge-Bearing Definitions
In engine v2, Capabilities replace both Tools and Skills. A Capability bundles actions (what it can do) with knowledge (how to do it). For API integrations, this means:
- The
httpaction is always available (one tool in the LLM's action list) - Each integration is a Capability with knowledge text that teaches the LLM how to call that platform's API
- Capabilities are loaded on-demand based on thread context, not registered globally
- The LLM reads the knowledge, constructs the correct
httpcall
User: "post hello to #general on slack"
↓
Capability activation: "slack-api" loaded into thread context
↓
LLM reads knowledge: learns endpoints, auth pattern, body format
↓
LLM calls `http` action:
POST https://slack.com/api/chat.postMessage
headers: {"Authorization": "Bearer {SLACK_BOT_TOKEN}"}
body: {"channel": "C01234", "text": "hello"}
↓
EffectExecutor: policy check → credential injection → SSRF protection → leak detection → response
Token Cost Comparison
| Scenario | Dedicated Tools (200 actions) | Capability + http |
|---|---|---|
| User asks about Slack | ~20,000 (all tools in list) | ~700 (http action + slack knowledge) |
| User asks about nothing | ~20,000 (still there) | ~200 (just http action) |
| Tool selection accuracy | Degrades with count | Always picks http — no confusion |
| Adding a new platform | Define N tool schemas + executor | Write knowledge text (markdown) |
What a Capability Definition Looks Like
name: slack-api
description: Slack Web API — post messages, manage channels, search, react
knowledge: |
Base: `https://slack.com/api`
Auth header: `Authorization: Bearer {SLACK_BOT_TOKEN}`
All POST bodies are JSON with `Content-Type: application/json`.
**Post message**: POST `/chat.postMessage` body `{"channel":"<id>","text":"<msg>"}`
**List channels**: GET `/conversations.list?types=public_channel&limit=100`
**Search**: GET `/search.messages?query=<text>`
**Add reaction**: POST `/reactions.add` body `{"channel":"<id>","timestamp":"<ts>","name":"<emoji>"}`
All responses: `{"ok": true, ...}` or `{"ok": false, "error": "<code>"}`.
Paginate with `cursor` param when `response_metadata.next_cursor` is non-empty.
actions: [http]
effects: [CredentialedNetwork, ReadExternal, WriteExternal]
policies:
requires_secret: SLACK_BOT_TOKEN
~350 tokens of knowledge covers 4+ actions. The LLM generalizes the pattern to other Slack endpoints from training data.
Classification of v1 Built-in Tools
Studied all 37 v1 built-in tools to determine which fit the knowledge-driven pattern:
Can be knowledge-driven (HTTP API wrappers):
image_gen,image_analyze,image_edit— pure HTTP calls to external APIs with auth
Already a generic action (the execution engine):
http— the action that knowledge-driven Capabilities delegate to
Must remain dedicated actions (complex local logic):
shell— 4-layer command validation, Docker sandbox, environment scrubbingfile(read/write/list/patch) — local filesystem with path traversal preventionmemory_*— hybrid FTS + vector search, prompt injection detectionjob_*— Docker container lifecycle, context isolationroutine_*— database-backed CRON schedulingextension_tools,skill_tools— registry and system managementsecrets_tools— encrypted store managementjson,time,echo— pure local computationmessage,restart,tool_info— internal agent control
Takeaway: Only 3 of 37 existing tools are HTTP wrappers. The value is not converting existing tools — it's enabling hundreds of new integrations (Slack, GitHub, Jira, Stripe, Salesforce, etc.) without writing Rust.
Where Dedicated Actions Still Win
- Autonomous/headless threads — Missions and background threads with no human oversight benefit from deterministic execution for their 1-2 critical integrations. Register those specific actions via leases.
- OAuth token acquisition — The LLM cannot perform redirect-based OAuth flows. A dedicated
oauth_initaction handles the redirect dance and stores tokens in the secrets system. The Capability knowledge then instructs the LLM to calloauth_initbefore using the API. - High-frequency reliability-critical paths — If a specific integration is called thousands of times and must never fail, a dedicated action avoids LLM reasoning variance.
Comparison with Pica's Approach
Pica uses a data-driven model where each API action is a MongoDB document (ConnectionModelDefinition) with base URL, path, method, auth method, schemas, and JavaScript transform functions. A generic executor dispatches requests. Key patterns:
- Handlebars secret injection — entire definition rendered as template with user's secrets as context
- Passthrough + Unified dual mode — raw HTTP proxy or normalized CRUD via CommonModels
- JS sandbox transforms —
fromCommonModel/toCommonModelfunctions for data mapping knowledgefield — free-text documentation per action for AI tool discovery
Pica's model is optimized for programmatic API access (SDK calls from code). For LLM agents, the Capability-as-knowledge approach is superior because it avoids tool list bloat while leveraging the LLM's ability to construct HTTP calls from documentation. The two approaches share the insight that integrations should be data, not code.
Key Files
| File | Purpose |
|---|---|
crates/ironclaw_engine/orchestrator/default.py |
The Python execution loop (v0) |
crates/ironclaw_engine/src/executor/orchestrator.rs |
Host functions + versioning + loading |
crates/ironclaw_engine/src/executor/loop_engine.rs |
Bootstrap (loads + runs orchestrator) |
crates/ironclaw_engine/src/executor/scripting.rs |
Monty VM integration, user code execution |
crates/ironclaw_engine/src/runtime/manager.rs |
ThreadManager (spawn, stop, join, reflection) |
crates/ironclaw_engine/src/runtime/mission.rs |
MissionManager (lifecycle, firing, self-improvement) |
crates/ironclaw_engine/src/types/ |
All core data structures |
crates/ironclaw_engine/src/traits/ |
LlmBackend, Store, EffectExecutor |
src/bridge/router.rs |
Engine v2 entry point from main crate |
src/bridge/effect_adapter.rs |
Tool execution bridge with safety |
src/bridge/llm_adapter.rs |
LLM provider bridge |
src/bridge/store_adapter.rs |
HybridStore (in-memory + workspace) |
Testing
cargo check -p ironclaw_engine # compiles
cargo clippy -p ironclaw_engine --all-targets -- -D warnings # zero warnings
cargo test -p ironclaw_engine # 189 tests
cargo clippy --all --all-features # full crate
cargo test # full suite
Design Influences
- RLM paper (arXiv:2512.24601) — context as variable, FINAL() termination, recursive sub-calls
- karpathy/autoresearch — the self-improvement loop as a program.md, fixed-budget evaluation, git as state machine
- Official RLM impl (alexzhang13/rlm) — 30 max iterations, compaction at 85%, budget inheritance
- fast-rlm (avbiswas/fast-rlm) — Step 0 orientation, parallel sub-calls, dual model routing
- Pica/IntegrationOS (withoneai/pica) — data-driven integration definitions, Handlebars secret injection, knowledge fields for AI tool discovery. Validated the "integrations as data" principle; diverged on execution model (knowledge-driven Capabilities instead of per-action tool registration)
See also: docs/plans/2026-03-20-engine-v2-architecture.md for the full 8-phase roadmap.