use async_trait::async_trait;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
pub success: bool,
pub output: String,
pub error: Option<String>,
}
impl ToolResult {
pub fn from_gui_report(report: &GuiActionReport) -> Self {
let success = report.verification_status == VerificationStatus::Verified;
let output = serde_json::to_string_pretty(report).unwrap_or_default();
let error = if success {
None
} else {
Some(format!(
"GUI verification: {:?}",
report.verification_status
))
};
Self {
success,
output,
error,
}
}
pub fn diagnostic_output(&self) -> String {
if self.success {
return self.output.clone();
}
let error = self
.error
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let output = self.output.trim();
let output = (!output.is_empty()).then_some(output);
match (error, output) {
(Some(error), Some(output))
if output != error
&& output != format!("Error: {error}")
&& !output.ends_with(error) =>
{
format!("Error: {error}\n{output}")
}
(Some(error), _) => format!("Error: {error}"),
(None, Some(output)) => output.to_string(),
(None, None) => "Error: tool returned no detail".to_string(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VerificationStatus {
Verified,
Failed,
Ambiguous,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum PreObservationStrategy {
#[default]
None,
Auto,
Explicit { keys: Vec<String> },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum WaitStrategy {
#[default]
None,
FixedMs { ms: u64 },
DomEvent { event: String, timeout_ms: u64 },
AccessibilityEvent {
notification: String,
timeout_ms: u64,
},
SelectorPresent { selector: String, timeout_ms: u64 },
PollUntilVerified {
poll_interval_ms: u64,
timeout_ms: u64,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReversibilityLevel {
Reversible,
PartiallyReversible,
Irreversible,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum GuiExpectationKind {
FieldValueEquals {
selector: String,
value: String,
},
FocusedElementIs {
selector: String,
},
CheckboxChecked {
selector: String,
checked: bool,
},
WindowTitleContains {
#[serde(alias = "title_contains")]
substring: String,
},
DialogPresent {
present: bool,
},
UrlIs {
url: String,
},
UrlHostIs {
host: String,
},
FileExists {
path: String,
},
DownloadCompleted {
path: String,
},
FrontWindowElementCountChanged {
#[serde(default)]
role: Option<String>,
#[serde(default)]
title_contains: Option<String>,
#[serde(default)]
description_contains: Option<String>,
#[serde(default)]
value_contains: Option<String>,
#[serde(default = "default_min_increase")]
min_increase: u32,
},
ElementAtCoordinate {
x: i64,
y: i64,
expected_element: String,
#[serde(default)]
tolerance_px: u32,
},
AXAttributeEquals {
attribute: String,
value: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuiExpectation {
#[serde(flatten)]
pub kind: GuiExpectationKind,
#[serde(default = "default_required")]
pub required: bool,
}
fn default_required() -> bool {
true
}
fn default_min_increase() -> u32 {
1
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuiObservation {
pub evidence: serde_json::Value,
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuiActionReport {
pub execution_ok: bool,
pub pre_observation: Option<GuiObservation>,
pub post_observation: Option<GuiObservation>,
pub verification_status: VerificationStatus,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub expectation_results: Vec<ExpectationResult>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_diff: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExpectationResult {
pub status: VerificationStatus,
pub expected: serde_json::Value,
pub actual: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSpec {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters_schema(&self) -> serde_json::Value;
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;
fn spec(&self) -> ToolSpec {
ToolSpec {
name: self.name().to_string(),
description: self.description().to_string(),
parameters: self.parameters_schema(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct DummyTool;
#[async_trait]
impl Tool for DummyTool {
fn name(&self) -> &str {
"dummy_tool"
}
fn description(&self) -> &str {
"A deterministic test tool"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"value": { "type": "string" }
}
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
Ok(ToolResult {
success: true,
output: args
.get("value")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
error: None,
})
}
}
#[test]
fn spec_uses_tool_metadata_and_schema() {
let tool = DummyTool;
let spec = tool.spec();
assert_eq!(spec.name, "dummy_tool");
assert_eq!(spec.description, "A deterministic test tool");
assert_eq!(spec.parameters["type"], "object");
assert_eq!(spec.parameters["properties"]["value"]["type"], "string");
}
#[tokio::test]
async fn execute_returns_expected_output() {
let tool = DummyTool;
let result = tool
.execute(serde_json::json!({ "value": "hello-tool" }))
.await
.unwrap();
assert!(result.success);
assert_eq!(result.output, "hello-tool");
assert!(result.error.is_none());
}
#[test]
fn tool_result_serialization_roundtrip() {
let result = ToolResult {
success: false,
output: String::new(),
error: Some("boom".into()),
};
let json = serde_json::to_string(&result).unwrap();
let parsed: ToolResult = serde_json::from_str(&json).unwrap();
assert!(!parsed.success);
assert_eq!(parsed.error.as_deref(), Some("boom"));
}
#[test]
fn diagnostic_output_prefers_error_for_failures() {
let result = ToolResult {
success: false,
output: String::new(),
error: Some("Missing 'action' parameter".into()),
};
assert_eq!(
result.diagnostic_output(),
"Error: Missing 'action' parameter"
);
}
#[test]
fn diagnostic_output_keeps_distinct_output_context() {
let result = ToolResult {
success: false,
output: "stdout details".into(),
error: Some("osascript failed".into()),
};
assert_eq!(
result.diagnostic_output(),
"Error: osascript failed\nstdout details"
);
}
#[test]
fn diagnostic_output_avoids_duplicate_error_text() {
let result = ToolResult {
success: false,
output: "Error: osascript failed".into(),
error: Some("osascript failed".into()),
};
assert_eq!(result.diagnostic_output(), "Error: osascript failed");
}
}