mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +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:
+10
-4
@@ -1004,10 +1004,16 @@ impl Agent {
|
||||
}
|
||||
|
||||
// Engine V2 routing (Strategy C: parallel deployment)
|
||||
if let Submission::UserInput { ref content } = submission
|
||||
&& crate::bridge::is_engine_v2_enabled()
|
||||
{
|
||||
return crate::bridge::handle_with_engine(self, message, content).await;
|
||||
if crate::bridge::is_engine_v2_enabled() {
|
||||
match &submission {
|
||||
Submission::UserInput { content } => {
|
||||
return crate::bridge::handle_with_engine(self, message, content).await;
|
||||
}
|
||||
Submission::ApprovalResponse { approved, always } => {
|
||||
return crate::bridge::handle_approval(self, message, *approved, *always).await;
|
||||
}
|
||||
_ => {} // Other submissions fall through to v1
|
||||
}
|
||||
}
|
||||
|
||||
// Hydrate thread from DB if it's a historical thread not in memory
|
||||
|
||||
@@ -53,7 +53,10 @@ impl EffectBridgeAdapter {
|
||||
/// Mark a tool as auto-approved (user said "always").
|
||||
#[allow(dead_code)]
|
||||
pub async fn auto_approve_tool(&self, tool_name: &str) {
|
||||
self.auto_approved.write().await.insert(tool_name.to_string());
|
||||
self.auto_approved
|
||||
.write()
|
||||
.await
|
||||
.insert(tool_name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+1
-1
@@ -9,4 +9,4 @@ mod llm_adapter;
|
||||
mod router;
|
||||
mod store_adapter;
|
||||
|
||||
pub use router::{handle_with_engine, is_engine_v2_enabled};
|
||||
pub use router::{handle_approval, handle_with_engine, is_engine_v2_enabled};
|
||||
|
||||
+134
-30
@@ -24,13 +24,23 @@ pub fn is_engine_v2_enabled() -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Pending approval info stored between the NeedApproval outcome and the user's response.
|
||||
struct PendingApproval {
|
||||
action_name: String,
|
||||
/// The user message that triggered this (for re-submission after approval).
|
||||
original_content: String,
|
||||
}
|
||||
|
||||
/// Persistent engine state that lives across messages.
|
||||
struct EngineState {
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
conversation_manager: ConversationManager,
|
||||
effect_adapter: Arc<EffectBridgeAdapter>,
|
||||
#[allow(dead_code)]
|
||||
store: Arc<InMemoryStore>,
|
||||
default_project_id: ironclaw_engine::ProjectId,
|
||||
/// Currently pending approval (if any).
|
||||
pending_approval: RwLock<Option<PendingApproval>>,
|
||||
}
|
||||
|
||||
/// Global engine state, initialized on first use.
|
||||
@@ -93,7 +103,7 @@ async fn get_or_init_engine(agent: &Agent) -> Result<(), Error> {
|
||||
|
||||
let thread_manager = Arc::new(ThreadManager::new(
|
||||
llm_adapter,
|
||||
effect_adapter,
|
||||
effect_adapter.clone(),
|
||||
store.clone(),
|
||||
Arc::new(capabilities),
|
||||
leases,
|
||||
@@ -115,18 +125,88 @@ async fn get_or_init_engine(agent: &Agent) -> Result<(), Error> {
|
||||
*guard = Some(EngineState {
|
||||
thread_manager,
|
||||
conversation_manager,
|
||||
effect_adapter,
|
||||
store: store.clone(),
|
||||
default_project_id: project_id,
|
||||
pending_approval: RwLock::new(None),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle a user message through the engine v2 pipeline.
|
||||
/// Handle an approval response (yes/no/always) for engine v2.
|
||||
///
|
||||
/// Conversations and threads persist across messages within the same
|
||||
/// agent lifetime. Each (channel, user) pair gets a conversation;
|
||||
/// consecutive messages inject into the active thread or spawn a new one.
|
||||
/// Called from `handle_message` when the user responds to an approval request.
|
||||
pub async fn handle_approval(
|
||||
agent: &Agent,
|
||||
message: &IncomingMessage,
|
||||
approved: bool,
|
||||
always: bool,
|
||||
) -> Result<Option<String>, Error> {
|
||||
get_or_init_engine(agent).await?;
|
||||
|
||||
let lock = ENGINE_STATE.get().expect("engine initialized");
|
||||
let guard = lock.read().await;
|
||||
let state = guard.as_ref().expect("engine initialized");
|
||||
|
||||
// Take the pending approval
|
||||
let pending = state.pending_approval.write().await.take();
|
||||
let pending = match pending {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
debug!("engine v2: no pending approval, ignoring");
|
||||
return Ok(Some("No pending approval.".into()));
|
||||
}
|
||||
};
|
||||
|
||||
if !approved {
|
||||
let _ = agent
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::Status("Tool call denied.".into()),
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
return Ok(Some(format!(
|
||||
"Denied: tool '{}' was not executed.",
|
||||
pending.action_name
|
||||
)));
|
||||
}
|
||||
|
||||
// Approved — add to auto-approved set
|
||||
info!(
|
||||
tool = %pending.action_name,
|
||||
always,
|
||||
"engine v2: tool approved"
|
||||
);
|
||||
|
||||
// Convert Python name back to registry name for auto-approve
|
||||
let registry_name = pending.action_name.replace('_', "-");
|
||||
state
|
||||
.effect_adapter
|
||||
.auto_approve_tool(&pending.action_name)
|
||||
.await;
|
||||
state.effect_adapter.auto_approve_tool(®istry_name).await;
|
||||
|
||||
if always {
|
||||
info!(tool = %pending.action_name, "engine v2: tool auto-approved for session");
|
||||
}
|
||||
|
||||
// Re-process the original message — the tool will now pass approval
|
||||
let _ = agent
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::Thinking("Re-executing with approval...".into()),
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
|
||||
handle_with_engine(agent, message, &pending.original_content).await
|
||||
}
|
||||
|
||||
/// Handle a user message through the engine v2 pipeline.
|
||||
pub async fn handle_with_engine(
|
||||
agent: &Agent,
|
||||
message: &IncomingMessage,
|
||||
@@ -193,17 +273,15 @@ pub async fn handle_with_engine(
|
||||
// Forward events to the channel while waiting for thread completion
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Check for events from the execution loop
|
||||
event = event_rx.recv() => {
|
||||
match event {
|
||||
Ok(ref evt) if evt.thread_id == thread_id => {
|
||||
forward_event_to_channel(evt, channels, channel_name, metadata).await;
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
_ => {} // Event for a different thread, or lagged
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Also check if the thread has finished (in case we miss the events)
|
||||
_ = tokio::time::sleep(std::time::Duration::from_millis(500)) => {
|
||||
if !state.thread_manager.is_running(thread_id).await {
|
||||
break;
|
||||
@@ -213,12 +291,16 @@ pub async fn handle_with_engine(
|
||||
}
|
||||
|
||||
// Join the thread to get the outcome
|
||||
let outcome = state.thread_manager.join_thread(thread_id).await.map_err(|e| {
|
||||
crate::error::Error::from(crate::error::JobError::ContextError {
|
||||
id: uuid::Uuid::nil(),
|
||||
reason: format!("engine v2 join error: {e}"),
|
||||
})
|
||||
})?;
|
||||
let outcome = state
|
||||
.thread_manager
|
||||
.join_thread(thread_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::error::Error::from(crate::error::JobError::ContextError {
|
||||
id: uuid::Uuid::nil(),
|
||||
reason: format!("engine v2 join error: {e}"),
|
||||
})
|
||||
})?;
|
||||
|
||||
// Record outcome in conversation
|
||||
state
|
||||
@@ -228,7 +310,6 @@ pub async fn handle_with_engine(
|
||||
|
||||
// Note: trace recording, retrospective analysis, and LLM reflection
|
||||
// all run automatically inside ThreadManager after the thread completes.
|
||||
// See crates/ironclaw_engine/src/runtime/manager.rs.
|
||||
|
||||
// Convert outcome to response
|
||||
match outcome {
|
||||
@@ -237,17 +318,45 @@ pub async fn handle_with_engine(
|
||||
Ok(response)
|
||||
}
|
||||
ThreadOutcome::Stopped => Ok(Some("Thread was stopped.".into())),
|
||||
ThreadOutcome::MaxIterations => {
|
||||
Ok(Some("Reached maximum iterations without completing.".into()))
|
||||
}
|
||||
ThreadOutcome::MaxIterations => Ok(Some(
|
||||
"Reached maximum iterations without completing.".into(),
|
||||
)),
|
||||
ThreadOutcome::Failed { error } => Ok(Some(format!("Error: {error}"))),
|
||||
ThreadOutcome::NeedApproval {
|
||||
action_name,
|
||||
call_id: _,
|
||||
parameters: _,
|
||||
} => Ok(Some(format!(
|
||||
"Action '{action_name}' requires approval (not yet supported in engine v2)"
|
||||
))),
|
||||
parameters,
|
||||
} => {
|
||||
// Store pending approval for when the user responds
|
||||
*state.pending_approval.write().await = Some(PendingApproval {
|
||||
action_name: action_name.clone(),
|
||||
original_content: content.to_string(),
|
||||
});
|
||||
|
||||
// Send approval request to channel (matches v1 ApprovalNeeded format)
|
||||
let _ = agent
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::ApprovalNeeded {
|
||||
request_id: uuid::Uuid::new_v4().to_string(),
|
||||
tool_name: action_name.clone(),
|
||||
description: format!(
|
||||
"Tool '{}' requires approval to execute.",
|
||||
action_name
|
||||
),
|
||||
parameters,
|
||||
allow_always: true,
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Some(format!(
|
||||
"Tool '{}' requires approval. Reply 'yes' to approve, 'always' to auto-approve, or 'no' to deny.",
|
||||
action_name
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,10 +379,7 @@ async fn forward_event_to_channel(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
EventKind::ActionExecuted {
|
||||
action_name,
|
||||
..
|
||||
} => {
|
||||
EventKind::ActionExecuted { action_name, .. } => {
|
||||
let _ = channels
|
||||
.send_status(
|
||||
channel_name,
|
||||
@@ -288,9 +394,7 @@ async fn forward_event_to_channel(
|
||||
.await;
|
||||
}
|
||||
EventKind::ActionFailed {
|
||||
action_name,
|
||||
error,
|
||||
..
|
||||
action_name, error, ..
|
||||
} => {
|
||||
let _ = channels
|
||||
.send_status(
|
||||
@@ -314,6 +418,6 @@ async fn forward_event_to_channel(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
_ => {} // Other events don't need channel status
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ use std::collections::HashMap;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use ironclaw_engine::{
|
||||
CapabilityLease, DocId, EngineError, LeaseId, MemoryDoc, Project, ProjectId, Step, Thread,
|
||||
ThreadEvent, ThreadId, ThreadState, Store,
|
||||
CapabilityLease, DocId, EngineError, LeaseId, MemoryDoc, Project, ProjectId, Step, Store,
|
||||
Thread, ThreadEvent, ThreadId, ThreadState,
|
||||
types::mission::{Mission, MissionId, MissionStatus},
|
||||
};
|
||||
|
||||
@@ -109,7 +109,10 @@ impl Store for InMemoryStore {
|
||||
async fn append_events(&self, events: &[ThreadEvent]) -> Result<(), EngineError> {
|
||||
let mut store = self.events.write().await;
|
||||
for event in events {
|
||||
store.entry(event.thread_id).or_default().push(event.clone());
|
||||
store
|
||||
.entry(event.thread_id)
|
||||
.or_default()
|
||||
.push(event.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -191,7 +194,10 @@ impl Store for InMemoryStore {
|
||||
// ── Mission ──────────────────────────────────────────────
|
||||
|
||||
async fn save_mission(&self, mission: &Mission) -> Result<(), EngineError> {
|
||||
self.missions.write().await.insert(mission.id, mission.clone());
|
||||
self.missions
|
||||
.write()
|
||||
.await
|
||||
.insert(mission.id, mission.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -200,10 +206,21 @@ impl Store for InMemoryStore {
|
||||
}
|
||||
|
||||
async fn list_missions(&self, project_id: ProjectId) -> Result<Vec<Mission>, EngineError> {
|
||||
Ok(self.missions.read().await.values().filter(|m| m.project_id == project_id).cloned().collect())
|
||||
Ok(self
|
||||
.missions
|
||||
.read()
|
||||
.await
|
||||
.values()
|
||||
.filter(|m| m.project_id == project_id)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn update_mission_status(&self, id: MissionId, status: MissionStatus) -> Result<(), EngineError> {
|
||||
async fn update_mission_status(
|
||||
&self,
|
||||
id: MissionId,
|
||||
status: MissionStatus,
|
||||
) -> Result<(), EngineError> {
|
||||
if let Some(mission) = self.missions.write().await.get_mut(&id) {
|
||||
mission.status = status;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user