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]>
Zero engine crate changes. All security controls enforced at the bridge
boundary in EffectBridgeAdapter:
1. Tool approval (v1: Tool::requires_approval):
- Checks each tool's approval requirement with actual params
- Always → returns EngineError::LeaseDenied (blocks execution)
- UnlessAutoApproved → checks auto_approved set, blocks if not approved
- Never → proceeds
- Per-session auto_approved HashSet (for future "always" handling)
2. Hook interception (v1: BeforeToolCall):
- Runs HookEvent::ToolCall before every execution
- HookOutcome::Reject → blocks with reason
- HookError::Rejected → blocks with reason
- Hook errors → fail-open (logged, execution continues)
3. Output sanitization (v1: sanitize_tool_output + wrap_for_llm):
- Leak detection: API keys in tool output are redacted
- Policy enforcement: content policy rules applied
- Length truncation: output capped at 100KB
- XML boundary protection: prevents injection via tool output
4. Sensitive param redaction (v1: redact_params):
- Tool's sensitive_params() consulted before hooks see parameters
- Redacted params sent to hooks, original params used for execution
5. available_actions() now sets requires_approval based on each tool's
default approval requirement, so the engine's PolicyEngine can
gate tools it hasn't seen before.
6. Actual execution timing measured via Instant::now() (replaces
placeholder Duration::from_millis(1)).
Accessor visibility: hooks() widened to pub(crate).
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Add Mission type and MissionManager for recurring thread scheduling
- Add ReliabilityTracker for per-capability success/failure/latency tracking
- Add reflection executor that spawns CodeAct threads for post-completion reflection
- Extend PolicyEngine with provenance-aware taint checking (LLM-generated data
requires approval for financial/external-write effects)
- Extend Store trait with mission CRUD methods
- Add conversation surface tracking, compaction token fix, context memory injection
- Wire new modules through lib.rs re-exports and bridge adapters
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
From trace analysis: web_search returned a JSON string, which was
wrapped as serde_json::json!(string) creating a Value::String containing
JSON. When Monty got this as MontyObject::String, the Python code
couldn't index it with result['title'] → TypeError.
Fix: try parsing the tool output string as JSON first. If valid, use the
parsed Value (becomes a Python dict/list). If not valid JSON, keep as
string. This means web_search results are directly indexable in Python:
results = web_search(query="...")
print(results["results"][0]["title"]) # works now
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Root cause from trace analysis: the LLM writes `web_search()` (valid
Python identifier) but the tool registry has `web-search` (with hyphen).
The EffectBridgeAdapter couldn't find the tool → "Tool not found" error
→ model fabricated fake data instead.
Fixes:
- available_actions(): converts tool names from hyphens to underscores
(web-search → web_search) so the system prompt lists valid Python names
- execute_action(): tries the original name first, then falls back to
hyphenated form (web_search → web-search) for tool registry lookup
- Same conversion in router's capability registry builder
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
After every thread completes, ThreadManager now automatically runs:
1. Retrospective trace analysis (non-LLM, always):
- Detects 8 issue categories (tool errors, code errors, missing
outputs, excessive steps, hallucination risk, etc.)
- Logs issues at warn level when found
2. Trace file recording (when ENGINE_V2_TRACE=1):
- Writes full JSON trace to engine_trace_{timestamp}.json
3. LLM reflection (when enable_reflection=true):
- Calls reflection pipeline to produce Summary, Lesson, Issue docs
- Saves docs to store for future context retrieval
- Enabled by default in the bridge router
All three run inside the spawned tokio task after exec.run() completes,
before saving the final thread state. No external wiring needed.
Removed duplicate trace recording from the router — it's now handled
by ThreadManager automatically.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Enable with ENGINE_V2_TRACE=1 to get full execution traces and
automatic issue detection after each thread completes.
Trace recording (executor/trace.rs):
- build_trace(): captures full thread state — messages (with full
content), events, step count, token usage, detected issues
- write_trace(): writes JSON to engine_trace_{timestamp}.json
- log_trace_summary(): logs summary + issues at info/warn level
Retrospective analyzer detects 8 issue categories:
- thread_failure: thread ended in Failed state
- no_response: no assistant message generated
- tool_error: specific tool failures with error details
- code_error: Python errors (NameError, SyntaxError, etc.) in output
- missing_tool_output: tool results exist but not in system messages
- excessive_steps: >10 steps (may be stuck in loop)
- no_tools_used: single-step answer without tools (hallucination risk)
- mixed_mode: text responses without code blocks (prompt not followed)
Thread state now saved to store after execution completes (for trace
access after join_thread).
Usage:
ENGINE_V2=true ENGINE_V2_TRACE=1 cargo run
# After each message: trace JSON + issue log in terminal
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Engine v2 now shows live progress in the CLI (and any channel):
- "Thinking..." when a step starts
- Tool name + success/error when actions execute
- "Processing results..." when a step completes
Implementation:
- ThreadManager holds a broadcast::Sender<ThreadEvent> (capacity 256)
- ExecutionLoop.emit_event() writes to thread.events AND broadcasts
- ThreadManager.subscribe_events() returns a receiver
- Router uses tokio::select! to listen for events while waiting for
thread completion, forwarding them as StatusUpdate to the channel
This replaces the polling approach with zero-latency event streaming.
Agent.channels visibility widened to pub(crate) for bridge access.
102 tests passing, zero clippy warnings.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Covers the exact failure modes discovered during live testing:
- extract_repl_block: standard ```repl fenced block
- extract_python_block: ```python marker
- extract_py_block: ```py shorthand
- extract_bare_backtick_block: bare ``` with Python content
- skip_non_python_language: ```json should NOT be extracted
- no_code_blocks_returns_none: plain text, no fences
- multiple_code_blocks_concatenated: two ```repl blocks with
explanation between them → concatenated with \n\n
- mixed_thinking_and_code: model outputs explanation + two
```python blocks (the Hyperliquid case) → both extracted
- repl_preferred_over_bare: ```repl takes priority over bare ```
- empty_code_block_skipped: empty fenced block returns None
- unclosed_block_returns_none: no closing ``` returns None
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Two bugs fixed:
1. The no-tools completion path (used by CodeAct since we send empty
actions) returned LlmResponse::Text without checking for code blocks.
Code blocks were rendered as markdown text instead of being executed.
2. extract_code_block now:
- Handles bare ``` fences (skips non-Python languages)
- Collects ALL code blocks in the response and concatenates them
(models often split code across multiple blocks with explanation)
- Tries markers in order: ```repl, ```python, ```py, then bare ```
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The engine now operates in CodeAct/RLM mode:
System prompt (executor/prompt.rs):
- Instructs LLM to write Python in ```repl fenced blocks
- Documents available tools as callable Python functions
- Documents llm_query(), llm_query_batched(), FINAL()
- Documents context variables (context, goal, step_number, previous_results)
- Strategy guidance: examine context, break into steps, use tools, call FINAL()
Code block detection (bridge/llm_adapter.rs):
- extract_code_block() scans LLM text responses for ```repl or ```python blocks
- When detected, returns LlmResponse::Code instead of LlmResponse::Text
- The ExecutionLoop routes Code responses through Monty for execution
No structured tool definitions sent to LLM:
- Tools are described in the system prompt as Python functions
- The LLM call sends empty actions array, forcing text-mode responses
- This ensures the LLM writes code blocks (CodeAct) instead of
structured tool calls (which would bypass the REPL)
85 tests passing, zero clippy warnings.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The engine was creating a fresh ThreadManager and InMemoryStore per
message, losing all context between turns. A follow-up question like
"what are the latest 10 issues?" had no memory of the prior "how many
issues" response.
Fixes:
- EngineState (ThreadManager, ConversationManager, InMemoryStore) now
persists across messages via OnceLock, initialized on first use
- ConversationManager builds message history from prior conversation
entries (user messages + agent responses) and passes it to new threads
- ThreadManager.spawn_thread_with_history() accepts initial_messages
that are prepended before the current user message
- System notifications (thread started/completed) are filtered out of
the history (not useful as LLM context)
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The LLM bridge was missing several defaults that the existing
Reasoning.respond_with_tools() sets:
- tool_choice: "auto" when tools are present (required by some providers)
- max_tokens: 4096 (default)
- temperature: 0.7 (default)
- When no tools (force_text): use plain complete() instead of
complete_with_tools() with empty tools array — matches existing
no-tools fallback path
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Strategy C parallel deployment: when ENGINE_V2=true env var is set,
user messages route through the engine instead of the existing agentic
loop. All existing behavior is unchanged when the flag is off.
Bridge module (src/bridge/):
- LlmBridgeAdapter: wraps LlmProvider as engine LlmBackend, converts
ThreadMessage↔ChatMessage, ActionDef↔ToolDefinition, depth-based
model routing (primary vs cheap_llm)
- EffectBridgeAdapter: wraps ToolRegistry+SafetyLayer as EffectExecutor,
routes tool calls through existing execute_tool_with_safety pipeline
- InMemoryStore: HashMap-backed Store impl (no DB tables needed yet)
- EngineRouter: is_engine_v2_enabled() + handle_with_engine() that
builds engine from Agent deps and processes messages end-to-end
Integration touchpoint (4 lines in agent_loop.rs):
After hook processing, before session resolution, check ENGINE_V2
flag and route UserInput through the engine path.
Accessor visibility widened: llm(), cheap_llm(), safety(), tools()
changed from pub(super) to pub(crate) for bridge access.
85 engine tests + main crate clippy clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>