lc_tools/extended/computer/
actions.rs1use serde_json::Value;
8
9use lc_core::tools::ToolError;
10
11use super::screen::ComputerUseTool;
12
13#[derive(Debug, Clone)]
19pub enum ComputerMode {
20 AnthropicApi,
22}
23
24#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
30pub struct ComputerUseInput {
31 pub action: String,
33
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub coordinate: Option<Vec<i32>>,
37
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub text: Option<String>,
41
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub keys: Option<Vec<String>>,
45
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub direction: Option<String>,
49
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub amount: Option<i32>,
53
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub duration_ms: Option<u64>,
57}
58
59#[derive(Debug, serde::Serialize)]
61pub struct ComputerUseOutput {
62 pub action: String,
64 pub result: String,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub screenshot_base64: Option<String>,
69}
70
71impl ComputerUseTool {
76 pub(super) fn build_anthropic_request(
78 &self,
79 action_input: &ComputerUseInput,
80 ) -> Result<Value, ToolError> {
81 let tool_input = self.build_tool_input(action_input)?;
82 let tool_input_str = serde_json::to_string(&tool_input).unwrap_or_default();
83
84 Ok(serde_json::json!({
85 "model": "claude-sonnet-4-20250514",
86 "max_tokens": 4096,
87 "tools": [
88 {
89 "type": "computer_20250124",
90 "name": "computer",
91 "display_width_px": self.display_width,
92 "display_height_px": self.display_height,
93 }
94 ],
95 "messages": [
96 {
97 "role": "user",
98 "content": format!(
99 "Use the computer tool with these parameters: {}",
100 tool_input_str
101 )
102 }
103 ],
104 "tool_choice": {
105 "type": "tool",
106 "name": "computer"
107 }
108 }))
109 }
110
111 pub(super) fn build_tool_input(&self, input: &ComputerUseInput) -> Result<Value, ToolError> {
114 match input.action.as_str() {
115 "screenshot" => Ok(serde_json::json!({
116 "action": "screenshot"
117 })),
118 "click" => {
119 let coord = input.coordinate.as_deref().unwrap_or(&[0, 0]);
120 let button = if input
121 .text
122 .as_deref()
123 .unwrap_or("left")
124 .eq_ignore_ascii_case("right")
125 {
126 "right"
127 } else {
128 "left"
129 };
130 Ok(serde_json::json!({
131 "action": "mouse_click",
132 "coordinate": coord,
133 "text": button
134 }))
135 }
136 "type" => Ok(serde_json::json!({
137 "action": "type",
138 "text": input.text.as_deref().unwrap_or("")
139 })),
140 "scroll" => {
141 let coord = input.coordinate.as_deref().unwrap_or(&[0, 0]);
142 let direction = input.direction.as_deref().unwrap_or("down");
143 let scroll_direction = if direction.eq_ignore_ascii_case("up") {
144 "up"
145 } else if direction.eq_ignore_ascii_case("left") {
146 "left"
147 } else if direction.eq_ignore_ascii_case("right") {
148 "right"
149 } else {
150 "down"
151 };
152 Ok(serde_json::json!({
153 "action": "scroll",
154 "coordinate": coord,
155 "direction": scroll_direction,
156 "amount": input.amount.unwrap_or(3)
157 }))
158 }
159 "key_press" => Ok(serde_json::json!({
160 "action": "key",
161 "text": input.keys.as_deref().unwrap_or(&[]).join("+")
162 })),
163 "wait" => Ok(serde_json::json!({
164 "action": "wait",
165 "duration_ms": input.duration_ms.unwrap_or(1000)
166 })),
167 _ => Err(ToolError::InvalidInput(format!(
168 "Unknown action: '{}'. Valid actions: screenshot, click, type, scroll, key_press, wait",
169 input.action
170 ))),
171 }
172 }
173
174 pub(super) async fn call_anthropic_api(&self, body: &Value) -> Result<String, ToolError> {
176 let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
177
178 let resp = self
179 .client
180 .post(&url)
181 .header("x-api-key", &self.api_key)
182 .header("anthropic-version", "2023-06-01")
183 .header("anthropic-beta", "computer-use-2025-01-24")
184 .header("content-type", "application/json")
185 .json(body)
186 .send()
187 .await
188 .map_err(|e| {
189 ToolError::ExecutionFailed(format!("Anthropic API request failed: {}", e))
190 })?;
191
192 let status = resp.status();
193 let text = resp.text().await.map_err(|e| {
194 ToolError::ExecutionFailed(format!("Failed to read response body: {}", e))
195 })?;
196
197 if !status.is_success() {
198 return Err(ToolError::ExecutionFailed(format!(
199 "Anthropic API returned status {}: {}",
200 status, text
201 )));
202 }
203
204 Ok(text)
205 }
206
207 pub(super) async fn execute_anthropic(
209 &self,
210 input: &ComputerUseInput,
211 ) -> Result<ComputerUseOutput, ToolError> {
212 if self.api_key.is_empty() {
213 return Err(ToolError::InvalidInput(
214 "API key is required for AnthropicApi mode".to_string(),
215 ));
216 }
217
218 self.validate_input(input)?;
219
220 let body = self.build_anthropic_request(input)?;
221 let response_text = self.call_anthropic_api(&body).await?;
222
223 let screenshot_base64 = if input.action == "screenshot" {
224 serde_json::from_str::<Value>(&response_text)
225 .ok()
226 .and_then(|v| {
227 v.get("content")?.as_array()?.iter().find_map(|block| {
228 if block.get("type")?.as_str()? == "image" {
229 block.get("source")?.get("data")?.as_str().map(String::from)
230 } else {
231 None
232 }
233 })
234 })
235 } else {
236 None
237 };
238
239 Ok(ComputerUseOutput {
240 action: input.action.clone(),
241 result: response_text,
242 screenshot_base64,
243 })
244 }
245
246 pub(super) fn validate_input(&self, input: &ComputerUseInput) -> Result<(), ToolError> {
248 let valid_actions = ["screenshot", "click", "type", "scroll", "key_press", "wait"];
249
250 if !valid_actions.contains(&input.action.as_str()) {
251 return Err(ToolError::InvalidInput(format!(
252 "Unknown action: '{}'. Valid actions: {:?}",
253 input.action, valid_actions
254 )));
255 }
256
257 match input.action.as_str() {
258 "click" => {
259 if input.coordinate.is_none() {
260 return Err(ToolError::InvalidInput(
261 "'click' action requires 'coordinate' field as [x, y]".to_string(),
262 ));
263 }
264 }
265 "scroll" => {
266 if input.coordinate.is_none() {
267 return Err(ToolError::InvalidInput(
268 "'scroll' action requires 'coordinate' field as [x, y]".to_string(),
269 ));
270 }
271 if input.direction.is_none() {
272 return Err(ToolError::InvalidInput(
273 "'scroll' action requires 'direction' field (up/down/left/right)"
274 .to_string(),
275 ));
276 }
277 }
278 "type" => {
279 if input.text.is_none() {
280 return Err(ToolError::InvalidInput(
281 "'type' action requires 'text' field".to_string(),
282 ));
283 }
284 }
285 "key_press" => {
286 if input.keys.is_none() {
287 return Err(ToolError::InvalidInput(
288 "'key_press' action requires 'keys' field".to_string(),
289 ));
290 }
291 }
292 _ => {}
293 }
294
295 if let Some(ref coord) = input.coordinate {
296 if coord.len() != 2 {
297 return Err(ToolError::InvalidInput(
298 "coordinate must be exactly [x, y]".to_string(),
299 ));
300 }
301 if coord[0] < 0 || coord[0] as u32 > self.display_width {
302 return Err(ToolError::InvalidInput(format!(
303 "x coordinate {} out of bounds (0-{})",
304 coord[0], self.display_width
305 )));
306 }
307 if coord[1] < 0 || coord[1] as u32 > self.display_height {
308 return Err(ToolError::InvalidInput(format!(
309 "y coordinate {} out of bounds (0-{})",
310 coord[1], self.display_height
311 )));
312 }
313 }
314
315 Ok(())
316 }
317}