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]>
This commit is contained in:
2026-03-23 21:10:40 -07:00
co-authored by Claude Opus 4.6
parent 38fc870223
commit e82dcbd5e6
31 changed files with 2125 additions and 436 deletions
+10 -4
View File
@@ -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
+4 -1
View File
@@ -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());
}
}
+8 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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(&registry_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
_ => {}
}
}
+23 -6
View File
@@ -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;
}