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]>
361 lines
12 KiB
Rust
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());
|
|
}
|
|
}
|