mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
* refactor: deduplicate tool parameter extraction and remove dead stub tools Delete 4 never-registered stub tools (marketplace, restaurant, ecommerce, taskrabbit) removing ~625 lines of dead code. Add require_str/require_param helpers to tool.rs and refactor ~30 call sites across 10 tool files from 4-6 line inline extractions to single-line calls. Consolidate worker HTTP client with get_json/post_json helpers, reducing boilerplate in 4 methods. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: return JSON from orchestrator /complete endpoint The report_complete handler returned bare StatusCode::OK (no body), which broke the post_json helper that expects a JSON response. Return {"status": "ok"} for consistency with other worker endpoints. Addresses review feedback on PR #98. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
334 lines
9.3 KiB
Rust
334 lines
9.3 KiB
Rust
//! Tool trait and types.
|
|
|
|
use std::time::Duration;
|
|
|
|
use async_trait::async_trait;
|
|
use rust_decimal::Decimal;
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
|
|
use crate::context::JobContext;
|
|
|
|
/// Where a tool should execute: orchestrator process or inside a container.
|
|
///
|
|
/// Orchestrator tools run in the main agent process (memory access, job mgmt, etc).
|
|
/// Container tools run inside Docker containers (shell, file ops, code mods).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum ToolDomain {
|
|
/// Safe to run in the orchestrator (pure functions, memory, job management).
|
|
Orchestrator,
|
|
/// Must run inside a sandboxed container (filesystem, shell, code).
|
|
Container,
|
|
}
|
|
|
|
/// Error type for tool execution.
|
|
#[derive(Debug, Error)]
|
|
pub enum ToolError {
|
|
#[error("Invalid parameters: {0}")]
|
|
InvalidParameters(String),
|
|
|
|
#[error("Execution failed: {0}")]
|
|
ExecutionFailed(String),
|
|
|
|
#[error("Timeout after {0:?}")]
|
|
Timeout(Duration),
|
|
|
|
#[error("Not authorized: {0}")]
|
|
NotAuthorized(String),
|
|
|
|
#[error("Rate limited, retry after {0:?}")]
|
|
RateLimited(Option<Duration>),
|
|
|
|
#[error("External service error: {0}")]
|
|
ExternalService(String),
|
|
|
|
#[error("Sandbox error: {0}")]
|
|
Sandbox(String),
|
|
}
|
|
|
|
/// Output from a tool execution.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ToolOutput {
|
|
/// The result data.
|
|
pub result: serde_json::Value,
|
|
/// Cost incurred (if any).
|
|
pub cost: Option<Decimal>,
|
|
/// Time taken.
|
|
pub duration: Duration,
|
|
/// Raw output before sanitization (for debugging).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub raw: Option<String>,
|
|
}
|
|
|
|
impl ToolOutput {
|
|
/// Create a successful output with a JSON result.
|
|
pub fn success(result: serde_json::Value, duration: Duration) -> Self {
|
|
Self {
|
|
result,
|
|
cost: None,
|
|
duration,
|
|
raw: None,
|
|
}
|
|
}
|
|
|
|
/// Create a text output.
|
|
pub fn text(text: impl Into<String>, duration: Duration) -> Self {
|
|
Self {
|
|
result: serde_json::Value::String(text.into()),
|
|
cost: None,
|
|
duration,
|
|
raw: None,
|
|
}
|
|
}
|
|
|
|
/// Set the cost.
|
|
pub fn with_cost(mut self, cost: Decimal) -> Self {
|
|
self.cost = Some(cost);
|
|
self
|
|
}
|
|
|
|
/// Set the raw output.
|
|
pub fn with_raw(mut self, raw: impl Into<String>) -> Self {
|
|
self.raw = Some(raw.into());
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Definition of a tool's parameters using JSON Schema.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ToolSchema {
|
|
pub name: String,
|
|
pub description: String,
|
|
pub parameters: serde_json::Value,
|
|
}
|
|
|
|
impl ToolSchema {
|
|
/// Create a new tool schema.
|
|
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
|
|
Self {
|
|
name: name.into(),
|
|
description: description.into(),
|
|
parameters: serde_json::json!({
|
|
"type": "object",
|
|
"properties": {},
|
|
"required": []
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Set the parameters schema.
|
|
pub fn with_parameters(mut self, parameters: serde_json::Value) -> Self {
|
|
self.parameters = parameters;
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Trait for tools that the agent can use.
|
|
#[async_trait]
|
|
pub trait Tool: Send + Sync {
|
|
/// Get the tool name.
|
|
fn name(&self) -> &str;
|
|
|
|
/// Get a description of what the tool does.
|
|
fn description(&self) -> &str;
|
|
|
|
/// Get the JSON Schema for the tool's parameters.
|
|
fn parameters_schema(&self) -> serde_json::Value;
|
|
|
|
/// Execute the tool with the given parameters.
|
|
async fn execute(
|
|
&self,
|
|
params: serde_json::Value,
|
|
ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError>;
|
|
|
|
/// Estimate the cost of running this tool with the given parameters.
|
|
fn estimated_cost(&self, _params: &serde_json::Value) -> Option<Decimal> {
|
|
None
|
|
}
|
|
|
|
/// Estimate how long this tool will take with the given parameters.
|
|
fn estimated_duration(&self, _params: &serde_json::Value) -> Option<Duration> {
|
|
None
|
|
}
|
|
|
|
/// Whether this tool's output needs sanitization.
|
|
///
|
|
/// Returns true for tools that interact with external services,
|
|
/// where the output might contain malicious content.
|
|
fn requires_sanitization(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
/// Whether this tool requires explicit user approval before execution.
|
|
///
|
|
/// Returns false by default since most tools run in a sandboxed/virtualized
|
|
/// environment. Only tools that make external network calls or perform
|
|
/// destructive operations should return true.
|
|
///
|
|
/// When true, the agent will prompt the user for confirmation before
|
|
/// executing this tool.
|
|
fn requires_approval(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Maximum time this tool is allowed to run before the caller kills it.
|
|
/// Override for long-running tools like sandbox execution.
|
|
/// Default: 60 seconds.
|
|
fn execution_timeout(&self) -> Duration {
|
|
Duration::from_secs(60)
|
|
}
|
|
|
|
/// Where this tool should execute.
|
|
///
|
|
/// `Orchestrator` tools run in the main agent process (safe, no FS access).
|
|
/// `Container` tools run inside Docker containers (shell, file ops).
|
|
///
|
|
/// Default: `Orchestrator` (safe for the main process).
|
|
fn domain(&self) -> ToolDomain {
|
|
ToolDomain::Orchestrator
|
|
}
|
|
|
|
/// Get the tool schema for LLM function calling.
|
|
fn schema(&self) -> ToolSchema {
|
|
ToolSchema {
|
|
name: self.name().to_string(),
|
|
description: self.description().to_string(),
|
|
parameters: self.parameters_schema(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extract a required string parameter from a JSON object.
|
|
///
|
|
/// Returns `ToolError::InvalidParameters` if the key is missing or not a string.
|
|
pub fn require_str<'a>(params: &'a serde_json::Value, name: &str) -> Result<&'a str, ToolError> {
|
|
params
|
|
.get(name)
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| ToolError::InvalidParameters(format!("missing '{}' parameter", name)))
|
|
}
|
|
|
|
/// Extract a required parameter of any type from a JSON object.
|
|
///
|
|
/// Returns `ToolError::InvalidParameters` if the key is missing.
|
|
pub fn require_param<'a>(
|
|
params: &'a serde_json::Value,
|
|
name: &str,
|
|
) -> Result<&'a serde_json::Value, ToolError> {
|
|
params
|
|
.get(name)
|
|
.ok_or_else(|| ToolError::InvalidParameters(format!("missing '{}' parameter", name)))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A simple no-op tool for testing.
|
|
#[derive(Debug)]
|
|
pub struct EchoTool;
|
|
|
|
#[async_trait]
|
|
impl Tool for EchoTool {
|
|
fn name(&self) -> &str {
|
|
"echo"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Echoes back the input message. Useful for testing."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"message": {
|
|
"type": "string",
|
|
"description": "The message to echo back"
|
|
}
|
|
},
|
|
"required": ["message"]
|
|
})
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
params: serde_json::Value,
|
|
_ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
let message = require_str(¶ms, "message")?;
|
|
|
|
Ok(ToolOutput::text(message, Duration::from_millis(1)))
|
|
}
|
|
|
|
fn requires_sanitization(&self) -> bool {
|
|
false // Echo is a trusted internal tool
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_echo_tool() {
|
|
let tool = EchoTool;
|
|
let ctx = JobContext::default();
|
|
|
|
let result = tool
|
|
.execute(serde_json::json!({"message": "hello"}), &ctx)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(result.result, serde_json::json!("hello"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_schema() {
|
|
let tool = EchoTool;
|
|
let schema = tool.schema();
|
|
|
|
assert_eq!(schema.name, "echo");
|
|
assert!(!schema.description.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_execution_timeout_default() {
|
|
let tool = EchoTool;
|
|
assert_eq!(tool.execution_timeout(), Duration::from_secs(60));
|
|
}
|
|
|
|
#[test]
|
|
fn test_require_str_present() {
|
|
let params = serde_json::json!({"name": "alice"});
|
|
assert_eq!(require_str(¶ms, "name").unwrap(), "alice");
|
|
}
|
|
|
|
#[test]
|
|
fn test_require_str_missing() {
|
|
let params = serde_json::json!({});
|
|
let err = require_str(¶ms, "name").unwrap_err();
|
|
assert!(err.to_string().contains("missing 'name'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_require_str_wrong_type() {
|
|
let params = serde_json::json!({"name": 42});
|
|
let err = require_str(¶ms, "name").unwrap_err();
|
|
assert!(err.to_string().contains("missing 'name'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_require_param_present() {
|
|
let params = serde_json::json!({"data": [1, 2, 3]});
|
|
assert_eq!(
|
|
require_param(¶ms, "data").unwrap(),
|
|
&serde_json::json!([1, 2, 3])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_require_param_missing() {
|
|
let params = serde_json::json!({});
|
|
let err = require_param(¶ms, "data").unwrap_err();
|
|
assert!(err.to_string().contains("missing 'data'"));
|
|
}
|
|
}
|