mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
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]>
166 lines
6.1 KiB
Rust
166 lines
6.1 KiB
Rust
//! Tier 0 executor: structured tool calls.
|
|
//!
|
|
//! Executes action calls by delegating to the `EffectExecutor` trait,
|
|
//! checking leases and policies for each call.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use crate::capability::lease::LeaseManager;
|
|
use crate::capability::policy::{PolicyDecision, PolicyEngine};
|
|
use crate::runtime::messaging::ThreadOutcome;
|
|
use crate::traits::effect::{EffectExecutor, ThreadExecutionContext};
|
|
use crate::types::error::EngineError;
|
|
use crate::types::event::EventKind;
|
|
use crate::types::step::{ActionCall, ActionResult};
|
|
use crate::types::thread::Thread;
|
|
|
|
/// Result of executing a batch of action calls.
|
|
pub struct ActionBatchResult {
|
|
/// Results for each action call (in order).
|
|
pub results: Vec<ActionResult>,
|
|
/// Events generated during execution.
|
|
pub events: Vec<EventKind>,
|
|
/// If set, execution was interrupted and the thread needs approval.
|
|
pub need_approval: Option<ThreadOutcome>,
|
|
}
|
|
|
|
/// Execute a batch of action calls using the Tier 0 (structured) approach.
|
|
///
|
|
/// For each action call:
|
|
/// 1. Find the lease that grants this action
|
|
/// 2. Check policy (deny/allow/approve)
|
|
/// 3. Consume a lease use
|
|
/// 4. Call `EffectExecutor::execute_action()`
|
|
/// 5. Record result and emit event
|
|
///
|
|
/// Stops at the first action that requires approval.
|
|
pub async fn execute_action_calls(
|
|
calls: &[ActionCall],
|
|
thread: &Thread,
|
|
effects: &Arc<dyn EffectExecutor>,
|
|
leases: &LeaseManager,
|
|
policy: &PolicyEngine,
|
|
context: &ThreadExecutionContext,
|
|
capability_policies: &[crate::types::capability::PolicyRule],
|
|
) -> Result<ActionBatchResult, EngineError> {
|
|
let mut results = Vec::with_capacity(calls.len());
|
|
let mut events = Vec::new();
|
|
|
|
for call in calls {
|
|
// 1. Find the lease for this action
|
|
let lease = match leases
|
|
.find_lease_for_action(thread.id, &call.action_name)
|
|
.await
|
|
{
|
|
Some(l) => l,
|
|
None => {
|
|
let error_result = ActionResult {
|
|
call_id: call.id.clone(),
|
|
action_name: call.action_name.clone(),
|
|
output: serde_json::json!({"error": format!(
|
|
"no active lease covers action '{}'", call.action_name
|
|
)}),
|
|
is_error: true,
|
|
duration: std::time::Duration::ZERO,
|
|
};
|
|
events.push(EventKind::ActionFailed {
|
|
step_id: context.step_id,
|
|
action_name: call.action_name.clone(),
|
|
call_id: call.id.clone(),
|
|
error: format!("no lease for action '{}'", call.action_name),
|
|
});
|
|
results.push(error_result);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// 2. Find the action definition and check policy
|
|
let action_def = effects
|
|
.available_actions(std::slice::from_ref(&lease))
|
|
.await?
|
|
.into_iter()
|
|
.find(|a| a.name == call.action_name);
|
|
|
|
if let Some(ref action_def) = action_def {
|
|
let decision = policy.evaluate(action_def, &lease, capability_policies);
|
|
match decision {
|
|
PolicyDecision::Deny { reason } => {
|
|
let error_result = ActionResult {
|
|
call_id: call.id.clone(),
|
|
action_name: call.action_name.clone(),
|
|
output: serde_json::json!({"error": format!("denied: {reason}")}),
|
|
is_error: true,
|
|
duration: std::time::Duration::ZERO,
|
|
};
|
|
events.push(EventKind::ActionFailed {
|
|
step_id: context.step_id,
|
|
action_name: call.action_name.clone(),
|
|
call_id: call.id.clone(),
|
|
error: reason,
|
|
});
|
|
results.push(error_result);
|
|
continue;
|
|
}
|
|
PolicyDecision::RequireApproval { .. } => {
|
|
events.push(EventKind::ApprovalRequested {
|
|
action_name: call.action_name.clone(),
|
|
call_id: call.id.clone(),
|
|
});
|
|
return Ok(ActionBatchResult {
|
|
results,
|
|
events,
|
|
need_approval: Some(ThreadOutcome::NeedApproval {
|
|
action_name: call.action_name.clone(),
|
|
call_id: call.id.clone(),
|
|
parameters: call.parameters.clone(),
|
|
}),
|
|
});
|
|
}
|
|
PolicyDecision::Allow => {}
|
|
}
|
|
}
|
|
|
|
// 3. Consume a lease use
|
|
leases.consume_use(lease.id).await?;
|
|
|
|
// 4. Execute the action
|
|
let result = effects
|
|
.execute_action(&call.action_name, call.parameters.clone(), &lease, context)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(action_result) => {
|
|
events.push(EventKind::ActionExecuted {
|
|
step_id: context.step_id,
|
|
action_name: call.action_name.clone(),
|
|
call_id: call.id.clone(),
|
|
duration_ms: action_result.duration.as_millis() as u64,
|
|
});
|
|
results.push(action_result);
|
|
}
|
|
Err(e) => {
|
|
let error_result = ActionResult {
|
|
call_id: call.id.clone(),
|
|
action_name: call.action_name.clone(),
|
|
output: serde_json::json!({"error": e.to_string()}),
|
|
is_error: true,
|
|
duration: std::time::Duration::ZERO,
|
|
};
|
|
events.push(EventKind::ActionFailed {
|
|
step_id: context.step_id,
|
|
action_name: call.action_name.clone(),
|
|
call_id: call.id.clone(),
|
|
error: e.to_string(),
|
|
});
|
|
results.push(error_result);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(ActionBatchResult {
|
|
results,
|
|
events,
|
|
need_approval: None,
|
|
})
|
|
}
|