Files
optimclaw/src/tools/tool.rs
T
ca8d5c6b5e refactor: deduplicate tool code and remove dead stubs (#98)
* 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]>
2026-02-15 05:39:52 +00:00

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(&params, "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(&params, "name").unwrap(), "alice");
}
#[test]
fn test_require_str_missing() {
let params = serde_json::json!({});
let err = require_str(&params, "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(&params, "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(&params, "data").unwrap(),
&serde_json::json!([1, 2, 3])
);
}
#[test]
fn test_require_param_missing() {
let params = serde_json::json!({});
let err = require_param(&params, "data").unwrap_err();
assert!(err.to_string().contains("missing 'data'"));
}
}