Files
optimclaw/src/bridge/llm_adapter.rs
T
[email protected]andClaude Opus 4.6 e82dcbd5e6 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]>
2026-03-23 21:10:40 -07:00

361 lines
12 KiB
Rust

//! LLM bridge adapter — wraps `LlmProvider` as `ironclaw_engine::LlmBackend`.
use std::sync::Arc;
use ironclaw_engine::{
ActionDef, EngineError, LlmBackend, LlmCallConfig, LlmOutput, LlmResponse, ThreadMessage,
TokenUsage,
};
use crate::llm::{ChatMessage, LlmProvider, Role, ToolCall, ToolCompletionRequest, ToolDefinition};
/// Wraps an existing `LlmProvider` to implement the engine's `LlmBackend` trait.
pub struct LlmBridgeAdapter {
provider: Arc<dyn LlmProvider>,
/// Optional cheaper provider for sub-calls (depth > 0).
cheap_provider: Option<Arc<dyn LlmProvider>>,
}
impl LlmBridgeAdapter {
pub fn new(
provider: Arc<dyn LlmProvider>,
cheap_provider: Option<Arc<dyn LlmProvider>>,
) -> Self {
Self {
provider,
cheap_provider,
}
}
fn provider_for_depth(&self, depth: u32) -> &Arc<dyn LlmProvider> {
if depth > 0 {
self.cheap_provider.as_ref().unwrap_or(&self.provider)
} else {
&self.provider
}
}
}
#[async_trait::async_trait]
impl LlmBackend for LlmBridgeAdapter {
async fn complete(
&self,
messages: &[ThreadMessage],
actions: &[ActionDef],
config: &LlmCallConfig,
) -> Result<LlmOutput, EngineError> {
let provider = self.provider_for_depth(config.depth);
// Convert messages
let chat_messages: Vec<ChatMessage> = messages.iter().map(thread_msg_to_chat).collect();
// Convert actions to tool definitions
let tools: Vec<ToolDefinition> = if config.force_text {
vec![] // No tools when forcing text
} else {
actions.iter().map(action_def_to_tool_def).collect()
};
// Build request — match the existing Reasoning.respond_with_tools() defaults
let max_tokens = config.max_tokens.unwrap_or(4096);
let temperature = config.temperature.unwrap_or(0.7);
if tools.is_empty() {
// No tools: use plain completion (matches existing no-tools path)
let mut request = crate::llm::CompletionRequest::new(chat_messages)
.with_max_tokens(max_tokens)
.with_temperature(temperature);
request.metadata = config.metadata.clone();
let response = provider
.complete(request)
.await
.map_err(|e| EngineError::Llm {
reason: e.to_string(),
})?;
// Check for code blocks in the response (CodeAct/RLM pattern)
let llm_response = match extract_code_block(&response.content) {
Some(code) => LlmResponse::Code {
code,
content: Some(response.content),
},
None => LlmResponse::Text(response.content),
};
return Ok(LlmOutput {
response: llm_response,
usage: TokenUsage {
input_tokens: u64::from(response.input_tokens),
output_tokens: u64::from(response.output_tokens),
cache_read_tokens: u64::from(response.cache_read_input_tokens),
cache_write_tokens: u64::from(response.cache_creation_input_tokens),
cost_usd: 0.0,
},
});
}
// With tools: use tool completion (matches existing tools path)
let mut request = ToolCompletionRequest::new(chat_messages, tools)
.with_max_tokens(max_tokens)
.with_temperature(temperature)
.with_tool_choice("auto");
request.metadata = config.metadata.clone();
// Call provider
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() {
LlmResponse::ActionCalls {
calls: response
.tool_calls
.iter()
.map(|tc| ironclaw_engine::ActionCall {
id: tc.id.clone(),
action_name: tc.name.clone(),
parameters: tc.arguments.clone(),
})
.collect(),
content: response.content.clone(),
}
} else {
let text = response.content.unwrap_or_default();
// Detect ```repl or ```python fenced code blocks
match extract_code_block(&text) {
Some(code) => LlmResponse::Code {
code,
content: Some(text),
},
None => LlmResponse::Text(text),
}
};
Ok(LlmOutput {
response: llm_response,
usage: TokenUsage {
input_tokens: u64::from(response.input_tokens),
output_tokens: u64::from(response.output_tokens),
cache_read_tokens: u64::from(response.cache_read_input_tokens),
cache_write_tokens: u64::from(response.cache_creation_input_tokens),
cost_usd: 0.0, // TODO: populate from provider cost data when available
},
})
}
fn model_name(&self) -> &str {
self.provider.model_name()
}
}
// ── Conversion helpers ──────────────────────────────────────
fn thread_msg_to_chat(msg: &ThreadMessage) -> ChatMessage {
use ironclaw_engine::MessageRole;
let role = match msg.role {
MessageRole::System => Role::System,
MessageRole::User => Role::User,
MessageRole::Assistant => Role::Assistant,
MessageRole::ActionResult => Role::Tool,
};
let mut chat = ChatMessage {
role,
content: msg.content.clone(),
content_parts: Vec::new(),
tool_call_id: msg.action_call_id.clone(),
name: msg.action_name.clone(),
tool_calls: None,
};
// Convert action calls if present (assistant message with tool calls)
if let Some(ref calls) = msg.action_calls {
chat.tool_calls = Some(
calls
.iter()
.map(|c| ToolCall {
id: c.id.clone(),
name: c.action_name.clone(),
arguments: c.parameters.clone(),
})
.collect(),
);
}
chat
}
fn action_def_to_tool_def(action: &ActionDef) -> ToolDefinition {
ToolDefinition {
name: action.name.clone(),
description: action.description.clone(),
parameters: action.parameters_schema.clone(),
}
}
/// Extract Python code from fenced code blocks in the LLM response.
///
/// Tries these markers in order: ```repl, ```python, ```py, then bare ```
/// (if the content looks like Python). Collects ALL code blocks in the
/// response and concatenates them (models sometimes split code across
/// multiple blocks with explanation text between them).
fn extract_code_block(text: &str) -> Option<String> {
let mut all_code = Vec::new();
// Try specific markers first, then bare backticks
for marker in ["```repl", "```python", "```py", "```"] {
let mut search_from = 0;
while let Some(start) = text[search_from..].find(marker) {
let abs_start = search_from + start;
let after_marker = abs_start + marker.len();
// For bare ```, skip if it's actually ```someotherlang
if marker == "```" && text[after_marker..].starts_with(|c: char| c.is_alphabetic()) {
let lang: String = text[after_marker..]
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.collect();
if !["repl", "python", "py"].contains(&lang.as_str()) {
search_from = after_marker;
continue;
}
}
// Skip to next line after the marker
let code_start = text[after_marker..]
.find('\n')
.map(|i| after_marker + i + 1)
.unwrap_or(after_marker);
// Find closing ```
if let Some(end) = text[code_start..].find("```") {
let code = text[code_start..code_start + end].trim();
if !code.is_empty() {
all_code.push(code.to_string());
}
search_from = code_start + end + 3;
} else {
break;
}
}
// If we found code with a specific marker, use it (don't fall through to bare)
if !all_code.is_empty() {
break;
}
}
if all_code.is_empty() {
return None;
}
Some(all_code.join("\n\n"))
}
#[cfg(test)]
mod tests {
use super::*;
// ── extract_code_block tests ────────────────────────────
#[test]
fn extract_repl_block() {
let text = "Some explanation\n```repl\nx = 1 + 2\nprint(x)\n```\nMore text";
let code = extract_code_block(text).unwrap();
assert_eq!(code, "x = 1 + 2\nprint(x)");
}
#[test]
fn extract_python_block() {
let text = "Let me compute:\n```python\nresult = sum([1,2,3])\n```";
let code = extract_code_block(text).unwrap();
assert_eq!(code, "result = sum([1,2,3])");
}
#[test]
fn extract_py_block() {
let text = "```py\nprint('hello')\n```";
let code = extract_code_block(text).unwrap();
assert_eq!(code, "print('hello')");
}
#[test]
fn extract_bare_backtick_block() {
let text = "Here's the code:\n```\nx = 42\nFINAL(x)\n```";
let code = extract_code_block(text).unwrap();
assert_eq!(code, "x = 42\nFINAL(x)");
}
#[test]
fn skip_non_python_language() {
let text = "```json\n{\"key\": \"value\"}\n```\nThat's the config.";
assert!(extract_code_block(text).is_none());
}
#[test]
fn no_code_blocks_returns_none() {
let text = "Just a plain text response with no code.";
assert!(extract_code_block(text).is_none());
}
#[test]
fn multiple_code_blocks_concatenated() {
let text = "\
Let me search first:\n\
```repl\nresult = web_search(query=\"test\")\nprint(result)\n```\n\
Now let's process:\n\
```repl\nFINAL(result['title'])\n```";
let code = extract_code_block(text).unwrap();
assert!(code.contains("web_search"));
assert!(code.contains("FINAL"));
// Two blocks joined by double newline
assert!(code.contains("\n\n"));
}
#[test]
fn mixed_thinking_and_code() {
// Simulates a model that outputs explanation + code (the Hyperliquid case)
let text = "\
Let me help you explore the relationship between Hyperliquid's price and revenue.\n\
\n\
First, let's gather some data:\n\
\n\
```python\nsearch_results = web_search(\n query=\"Hyperliquid revenue\",\n count=5\n)\nprint(search_results)\n```\n\
\n\
And also check the token price:\n\
\n\
```python\ntoken_data = web_search(\n query=\"Hyperliquid token price\",\n count=3\n)\nprint(token_data)\n```";
let code = extract_code_block(text).unwrap();
assert!(code.contains("web_search"));
assert!(code.contains("Hyperliquid revenue"));
assert!(code.contains("Hyperliquid token price"));
}
#[test]
fn repl_preferred_over_bare() {
// If both ```repl and bare ``` exist, prefer ```repl
let text = "```\nignored\n```\n```repl\nused = True\n```";
let code = extract_code_block(text).unwrap();
assert_eq!(code, "used = True");
}
#[test]
fn empty_code_block_skipped() {
let text = "```python\n\n```\nThat was empty.";
assert!(extract_code_block(text).is_none());
}
#[test]
fn unclosed_block_returns_none() {
let text = "```python\nprint('no closing fence')";
assert!(extract_code_block(text).is_none());
}
}