Files
optimclaw/crates/ironclaw_engine/CLAUDE.md
T
[email protected]andClaude Opus 4.6 e82dcbd5e6 feat(bridge): implement tool approval flow for engine v2
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(&params) — 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]>
2026-03-23 21:10:40 -07:00

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:

  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 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

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

  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 + Tier 1 — structured tool calls (Tier 0) and embedded Python via Monty (Tier 1, CodeAct)
  5. Engine owns its message typeThreadMessage 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

Follows the main crate's conventions from /CLAUDE.md:

  • No .unwrap() or .expect() in production code (tests are fine)
  • thiserror for 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, RwLock for concurrent access