mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 17:19:24 +00:00
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(¶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]>
This commit is contained in:
@@ -7,9 +7,7 @@ use ironclaw_engine::{
|
||||
TokenUsage,
|
||||
};
|
||||
|
||||
use crate::llm::{
|
||||
ChatMessage, LlmProvider, Role, ToolCall, ToolCompletionRequest, ToolDefinition,
|
||||
};
|
||||
use crate::llm::{ChatMessage, LlmProvider, Role, ToolCall, ToolCompletionRequest, ToolDefinition};
|
||||
|
||||
/// Wraps an existing `LlmProvider` to implement the engine's `LlmBackend` trait.
|
||||
pub struct LlmBridgeAdapter {
|
||||
@@ -105,12 +103,13 @@ impl LlmBackend for LlmBridgeAdapter {
|
||||
request.metadata = config.metadata.clone();
|
||||
|
||||
// Call provider
|
||||
let response = provider
|
||||
.complete_with_tools(request)
|
||||
.await
|
||||
.map_err(|e| EngineError::Llm {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
let response =
|
||||
provider
|
||||
.complete_with_tools(request)
|
||||
.await
|
||||
.map_err(|e| EngineError::Llm {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
// Convert response — check for code blocks (CodeAct/RLM pattern)
|
||||
let llm_response = if !response.tool_calls.is_empty() {
|
||||
|
||||
Reference in New Issue
Block a user