Adds a complete approval flow that mirrors v1 behavior, using the
existing v1 security controls (Tool::requires_approval, auto-approve
sets, StatusUpdate::ApprovalNeeded).
## How it works
### Step 1: Tool blocked at execution
When the LLM's code calls a tool (e.g., `shell("ls")`):
1. EffectBridgeAdapter.execute_action() looks up the Tool object
2. Calls tool.requires_approval(¶ms) — returns ApprovalRequirement
3. If Always → EngineError::LeaseDenied (always blocks)
4. If UnlessAutoApproved → checks auto_approved HashSet → if not in set,
returns EngineError::LeaseDenied
5. If Never → proceeds to execution
### Step 2: Engine returns NeedApproval
The LeaseDenied error propagates through:
- CodeAct path: becomes Python RuntimeError, code halts, thread returns
NeedApproval with action_name + parameters
- Structured path: same via ActionResult.is_error
### Step 3: Router stores pending approval
- PendingApproval { action_name, original_content } stored on EngineState
- StatusUpdate::ApprovalNeeded sent to channel (shows approval card in
CLI/web with tool name, parameters, yes/always/no buttons)
- Returns text: "Tool 'shell' requires approval. Reply yes/always/no."
### Step 4: User responds
handle_message() intercepts Submission::ApprovalResponse when ENGINE_V2:
- 'yes' → auto_approve_tool(name) on EffectBridgeAdapter, re-processes
original message (tool now passes the approval check on second run)
- 'always' → same + logs for session persistence
- 'no' → returns "Denied: tool was not executed."
### Key design choice
Instead of pausing/resuming mid-execution (which needs engine changes
to freeze/restore the Monty VM state), we auto-approve the tool and
re-run the full message. The EffectBridgeAdapter's auto_approved set
persists across runs, so the second execution passes immediately.
This trades one extra LLM call for zero engine modifications.
## Files changed
- src/bridge/router.rs: PendingApproval struct, handle_approval(),
NeedApproval → StatusUpdate::ApprovalNeeded conversion
- src/bridge/mod.rs: export handle_approval
- src/agent/agent_loop.rs: intercept ApprovalResponse for engine v2
- src/bridge/effect_adapter.rs: fmt fixes
151 tests passing, clippy + fmt clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
8.8 KiB
IronClaw Engine Crate
Unified thread-capability-CodeAct execution model. Replaces ~10 separate abstractions (Session, Job, Routine, Channel, Tool, Skill, Hook, Observer, Extension, LoopDelegate) with 5 primitives.
Full Architecture Plan
See docs/plans/2026-03-20-engine-v2-architecture.md for the 8-phase roadmap.
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 |
Build & Test
cargo check -p ironclaw_engine
cargo clippy -p ironclaw_engine --all-targets -- -D warnings
cargo test -p ironclaw_engine
Module Map
src/
├── lib.rs # Public API, re-exports
├── types/ # Core data structures (no async, no I/O)
│ ├── thread.rs # Thread, ThreadId, ThreadState (state machine), ThreadType, ThreadConfig
│ ├── step.rs # Step, StepId, LlmResponse, ActionCall, ActionResult, TokenUsage
│ ├── capability.rs # Capability, ActionDef, EffectType, CapabilityLease, PolicyRule
│ ├── memory.rs # MemoryDoc, DocId, DocType (Summary/Lesson/Playbook/Issue/Spec/Note)
│ ├── project.rs # Project, ProjectId
│ ├── event.rs # ThreadEvent, EventKind (18 variants for event sourcing)
│ ├── message.rs # ThreadMessage, MessageRole
│ ├── provenance.rs # Provenance enum (User/System/ToolOutput/LlmGenerated/etc.)
│ ├── conversation.rs # ConversationSurface, ConversationEntry, EntrySender
│ ├── mission.rs # Mission, MissionId, MissionCadence, MissionStatus
│ └── error.rs # EngineError, ThreadError, StepError, CapabilityError
├── traits/ # External dependency abstractions (host implements these)
│ ├── llm.rs # LlmBackend trait
│ ├── store.rs # Store trait (20 CRUD methods)
│ └── effect.rs # EffectExecutor trait
├── capability/ # Capability management
│ ├── registry.rs # CapabilityRegistry — register/get/list capabilities
│ ├── lease.rs # LeaseManager — grant/check/consume/revoke/expire leases
│ └── policy.rs # PolicyEngine — deterministic effect-level allow/deny/approve + provenance taint
├── runtime/ # Thread lifecycle management
│ ├── manager.rs # ThreadManager — spawn, stop, inject messages, join threads
│ ├── conversation.rs # ConversationManager — routes UI messages to threads
│ ├── mission.rs # MissionManager — long-running goals that spawn threads on cadence
│ ├── tree.rs # ThreadTree — parent-child relationships
│ └── messaging.rs # ThreadSignal, ThreadOutcome, signal channels
├── executor/ # Step execution
│ ├── loop_engine.rs # ExecutionLoop — core loop replacing run_agentic_loop()
│ ├── structured.rs # Tier 0: structured tool call execution
│ ├── scripting.rs # Tier 1: embedded Python via Monty (CodeAct/RLM)
│ ├── context.rs # Context builder (messages + actions from leases + memory docs)
│ ├── compaction.rs # Context compaction when approaching model context limit
│ ├── prompt.rs # System prompt construction (CodeAct preamble/postamble)
│ ├── intent.rs # Tool intent nudge detection
│ └── trace.rs # Execution trace recording and retrospective analysis
├── memory/ # Memory document system
│ ├── store.rs # MemoryStore — project-scoped doc CRUD
│ └── retrieval.rs # RetrievalEngine — keyword-based context retrieval from project docs
├── reflection/ # Post-thread reflection pipeline
│ ├── pipeline.rs # reflect() (CodeAct) + reflect_simple() (direct LLM) + output parsing
│ └── executor.rs # ReflectionExecutor — read-only tools for reflection threads
└── reliability.rs # ReliabilityTracker — per-action success rate and latency via EMA
Thread State Machine
Created → Running → Waiting → Running (resume)
→ Suspended → Running (resume)
→ Completed → Reflecting → Done
→ Failed
Validated by ThreadState::can_transition_to(). Terminal states: Done, Failed.
External Trait Boundaries
The engine defines three traits that the host crate implements:
| Trait | Purpose | Host wraps |
|---|---|---|
LlmBackend |
complete(messages, actions, config) -> LlmOutput |
LlmProvider |
Store |
Thread/Step/Event/Project/Doc/Lease CRUD | Database (PostgreSQL + libSQL) |
EffectExecutor |
execute_action(name, params, lease, ctx) -> ActionResult |
ToolRegistry + SafetyLayer |
Execution Loop
ExecutionLoop::run() handles three LlmResponse variants:
- Check signals (Stop, InjectMessage) via
mpsc::Receiver - Build context (messages + available actions from active leases)
- Call LLM via
LlmBackend::complete() - If
Text: check tool intent nudge, return if final response - If
ActionCalls(Tier 0): for each call, find lease → check policy → consume use → execute viaEffectExecutor→ record result - If
Code(Tier 1): execute Python via Monty with context-as-variables andllm_query()support → compact metadata in context - Record Step, emit ThreadEvents
- 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
contextPython variable - Thread goal as
goal, step index asstep_number - Prior action results as
previous_resultsdict - 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
Threads don't have static permissions. They receive leases — scoped, time-limited, use-limited grants:
CapabilityLease {
thread_id, capability_name, granted_actions,
expires_at: Option<DateTime>, // time-limited
max_uses: Option<u32>, // use-limited
revoked: bool,
}
The PolicyEngine evaluates actions against leases deterministically: Deny > RequireApproval > Allow.
Effect Types
Every action declares its side effects. The policy engine uses these for allow/deny:
ReadLocal, ReadExternal, WriteLocal, WriteExternal,
CredentialedNetwork, Compute, Financial
Key Design Decisions
- No dependency on main
ironclawcrate — clean separation, testable in isolation - No safety logic — sanitization/leak detection is applied at the adapter boundary (
EffectExecutorimpl) - Event sourcing from day one — every thread records a complete event log via
ThreadEvent - Tier 0 + Tier 1 — structured tool calls (Tier 0) and embedded Python via Monty (Tier 1, CodeAct)
- Engine owns its message type —
ThreadMessageis simpler thanChatMessage; bridge adapters handle conversion - RLM pattern — context as variable (not attention input), recursive
llm_query(), compact output metadata between steps
Code Style
Follows the main crate's conventions from /CLAUDE.md:
- No
.unwrap()or.expect()in production code (tests are fine) thiserrorfor error types- Map errors with context
- Prefer strong types over strings (newtypes for IDs)
- All I/O is async with tokio
Arc<T>for shared state,RwLockfor concurrent access