| 1 | //! Model-visible automation tools over `AutomationManager`. |
| 2 | |
| 3 | use std::path::PathBuf; |
| 4 | |
| 5 | use async_trait::async_trait; |
| 6 | use serde_json::{Value, json}; |
| 7 | |
| 8 | use crate::automation_manager::{ |
| 9 | AutomationStatus, CreateAutomationRequest, UpdateAutomationRequest, |
| 10 | }; |
| 11 | use crate::tools::spec::{ |
| 12 | ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, |
| 13 | optional_str, optional_u64, required_str, |
| 14 | }; |
| 15 | |
| 16 | pub struct AutomationCreateTool; |
| 17 | pub struct AutomationListTool; |
| 18 | pub struct AutomationReadTool; |
| 19 | pub struct AutomationUpdateTool; |
| 20 | pub struct AutomationPauseTool; |
| 21 | pub struct AutomationResumeTool; |
| 22 | pub struct AutomationDeleteTool; |
| 23 | pub struct AutomationRunTool; |
| 24 | |
| 25 | #[async_trait] |
| 26 | impl ToolSpec for AutomationCreateTool { |
| 27 | fn name(&self) -> &'static str { |
| 28 | "automation_create" |
| 29 | } |
| 30 | |
| 31 | fn description(&self) -> &'static str { |
| 32 | "Create a durable scheduled automation. Creation requires approval and recurrence is constrained to supported HOURLY/WEEKLY RRULE forms. Runs enqueue normal durable tasks." |
| 33 | } |
| 34 | |
| 35 | fn input_schema(&self) -> Value { |
| 36 | json!({ |
| 37 | "type": "object", |
| 38 | "properties": { |
| 39 | "name": { "type": "string" }, |
| 40 | "prompt": { "type": "string" }, |
| 41 | "rrule": { |
| 42 | "type": "string", |
| 43 | "description": "Supported: FREQ=HOURLY;INTERVAL=N[;BYDAY=MO,TU] or FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=30" |
| 44 | }, |
| 45 | "cwds": { "type": "array", "items": { "type": "string" } }, |
| 46 | "paused": { "type": "boolean", "default": false } |
| 47 | }, |
| 48 | "required": ["name", "prompt", "rrule"], |
| 49 | "additionalProperties": false |
| 50 | }) |
| 51 | } |
| 52 | |
| 53 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 54 | vec![ToolCapability::RequiresApproval] |
| 55 | } |
| 56 | |
| 57 | fn approval_requirement(&self) -> ApprovalRequirement { |
| 58 | ApprovalRequirement::Required |
| 59 | } |
| 60 | |
| 61 | async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> { |
| 62 | let manager = context |
| 63 | .runtime |
| 64 | .automations |
| 65 | .as_ref() |
| 66 | .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?; |
| 67 | let manager = manager.lock().await; |
| 68 | let req = CreateAutomationRequest { |
| 69 | name: required_str(&input, "name")?.to_string(), |
| 70 | prompt: required_str(&input, "prompt")?.to_string(), |
| 71 | rrule: required_str(&input, "rrule")?.to_string(), |
| 72 | cwds: string_array(&input, "cwds")? |
| 73 | .into_iter() |
| 74 | .map(PathBuf::from) |
| 75 | .collect(), |
| 76 | status: Some( |
| 77 | if input |
| 78 | .get("paused") |
| 79 | .and_then(Value::as_bool) |
| 80 | .unwrap_or(false) |
| 81 | { |
| 82 | AutomationStatus::Paused |
| 83 | } else { |
| 84 | AutomationStatus::Active |
| 85 | }, |
| 86 | ), |
| 87 | }; |
| 88 | let automation = manager |
| 89 | .create_automation(req) |
| 90 | .map_err(|e| ToolError::execution_failed(e.to_string()))?; |
| 91 | ToolResult::json(&automation).map_err(|e| ToolError::execution_failed(e.to_string())) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | #[async_trait] |
| 96 | impl ToolSpec for AutomationListTool { |
| 97 | fn name(&self) -> &'static str { |
| 98 | "automation_list" |
| 99 | } |
| 100 | |
| 101 | fn description(&self) -> &'static str { |
| 102 | "List durable automations with status, next run, and last run timestamps." |
| 103 | } |
| 104 | |
| 105 | fn input_schema(&self) -> Value { |
| 106 | json!({ |
| 107 | "type": "object", |
| 108 | "properties": { |
| 109 | "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 } |
| 110 | }, |
| 111 | "additionalProperties": false |
| 112 | }) |
| 113 | } |
| 114 | |
| 115 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 116 | vec![ToolCapability::ReadOnly] |
| 117 | } |
| 118 | |
| 119 | async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> { |
| 120 | let manager = context |
| 121 | .runtime |
| 122 | .automations |
| 123 | .as_ref() |
| 124 | .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?; |
| 125 | let manager = manager.lock().await; |
| 126 | let mut automations = manager |
| 127 | .list_automations() |
| 128 | .map_err(|e| ToolError::execution_failed(e.to_string()))?; |
| 129 | automations.truncate(optional_u64(&input, "limit", 50).clamp(1, 100) as usize); |
| 130 | ToolResult::json(&automations).map_err(|e| ToolError::execution_failed(e.to_string())) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | #[async_trait] |
| 135 | impl ToolSpec for AutomationReadTool { |
| 136 | fn name(&self) -> &'static str { |
| 137 | "automation_read" |
| 138 | } |
| 139 | |
| 140 | fn description(&self) -> &'static str { |
| 141 | "Read one durable automation plus recent run records." |
| 142 | } |
| 143 | |
| 144 | fn input_schema(&self) -> Value { |
| 145 | automation_id_schema(true) |
| 146 | } |
| 147 | |
| 148 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 149 | vec![ToolCapability::ReadOnly] |
| 150 | } |
| 151 | |
| 152 | fn approval_requirement(&self) -> ApprovalRequirement { |
| 153 | ApprovalRequirement::Auto |
| 154 | } |
| 155 | |
| 156 | async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> { |
| 157 | let manager = context |
| 158 | .runtime |
| 159 | .automations |
| 160 | .as_ref() |
| 161 | .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?; |
| 162 | let manager = manager.lock().await; |
| 163 | let id = required_str(&input, "automation_id")?; |
| 164 | let automation = manager |
| 165 | .get_automation(id) |
| 166 | .map_err(|e| ToolError::execution_failed(e.to_string()))?; |
| 167 | let runs = manager |
| 168 | .list_runs(id, Some(20)) |
| 169 | .map_err(|e| ToolError::execution_failed(e.to_string()))?; |
| 170 | ToolResult::json(&json!({ "automation": automation, "recent_runs": runs })) |
| 171 | .map_err(|e| ToolError::execution_failed(e.to_string())) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | #[async_trait] |
| 176 | impl ToolSpec for AutomationUpdateTool { |
| 177 | fn name(&self) -> &'static str { |
| 178 | "automation_update" |
| 179 | } |
| 180 | |
| 181 | fn description(&self) -> &'static str { |
| 182 | "Update a durable automation. Requires approval; recurrence remains constrained to supported RRULE forms." |
| 183 | } |
| 184 | |
| 185 | fn input_schema(&self) -> Value { |
| 186 | json!({ |
| 187 | "type": "object", |
| 188 | "properties": { |
| 189 | "automation_id": { "type": "string" }, |
| 190 | "name": { "type": "string" }, |
| 191 | "prompt": { "type": "string" }, |
| 192 | "rrule": { "type": "string" }, |
| 193 | "cwds": { "type": "array", "items": { "type": "string" } }, |
| 194 | "status": { "type": "string", "enum": ["active", "paused"] } |
| 195 | }, |
| 196 | "required": ["automation_id"], |
| 197 | "additionalProperties": false |
| 198 | }) |
| 199 | } |
| 200 | |
| 201 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 202 | vec![ToolCapability::RequiresApproval] |
| 203 | } |
| 204 | |
| 205 | fn approval_requirement(&self) -> ApprovalRequirement { |
| 206 | ApprovalRequirement::Required |
| 207 | } |
| 208 | |
| 209 | async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> { |
| 210 | let manager = context |
| 211 | .runtime |
| 212 | .automations |
| 213 | .as_ref() |
| 214 | .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?; |
| 215 | let manager = manager.lock().await; |
| 216 | let status = optional_str(&input, "status").map(|value| match value { |
| 217 | "paused" => AutomationStatus::Paused, |
| 218 | _ => AutomationStatus::Active, |
| 219 | }); |
| 220 | let req = UpdateAutomationRequest { |
| 221 | name: optional_str(&input, "name").map(ToString::to_string), |
| 222 | prompt: optional_str(&input, "prompt").map(ToString::to_string), |
| 223 | rrule: optional_str(&input, "rrule").map(ToString::to_string), |
| 224 | cwds: if input.get("cwds").is_some() { |
| 225 | Some( |
| 226 | string_array(&input, "cwds")? |
| 227 | .into_iter() |
| 228 | .map(PathBuf::from) |
| 229 | .collect(), |
| 230 | ) |
| 231 | } else { |
| 232 | None |
| 233 | }, |
| 234 | status, |
| 235 | }; |
| 236 | let automation = manager |
| 237 | .update_automation(required_str(&input, "automation_id")?, req) |
| 238 | .map_err(|e| ToolError::execution_failed(e.to_string()))?; |
| 239 | ToolResult::json(&automation).map_err(|e| ToolError::execution_failed(e.to_string())) |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | macro_rules! write_automation_tool { |
| 244 | ($ty:ident, $name:literal, $desc:literal, $method:ident) => { |
| 245 | #[async_trait] |
| 246 | impl ToolSpec for $ty { |
| 247 | fn name(&self) -> &'static str { |
| 248 | $name |
| 249 | } |
| 250 | fn description(&self) -> &'static str { |
| 251 | $desc |
| 252 | } |
| 253 | fn input_schema(&self) -> Value { |
| 254 | automation_id_schema(true) |
| 255 | } |
| 256 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 257 | vec![ToolCapability::RequiresApproval] |
| 258 | } |
| 259 | fn approval_requirement(&self) -> ApprovalRequirement { |
| 260 | ApprovalRequirement::Required |
| 261 | } |
| 262 | async fn execute( |
| 263 | &self, |
| 264 | input: Value, |
| 265 | context: &ToolContext, |
| 266 | ) -> Result<ToolResult, ToolError> { |
| 267 | let manager = |
| 268 | context.runtime.automations.as_ref().ok_or_else(|| { |
| 269 | ToolError::not_available("AutomationManager is not attached") |
| 270 | })?; |
| 271 | let manager = manager.lock().await; |
| 272 | let automation = manager |
| 273 | .$method(required_str(&input, "automation_id")?) |
| 274 | .map_err(|e| ToolError::execution_failed(e.to_string()))?; |
| 275 | ToolResult::json(&automation) |
| 276 | .map_err(|e| ToolError::execution_failed(e.to_string())) |
| 277 | } |
| 278 | } |
| 279 | }; |
| 280 | } |
| 281 | |
| 282 | write_automation_tool!( |
| 283 | AutomationPauseTool, |
| 284 | "automation_pause", |
| 285 | "Pause a durable automation. Requires approval.", |
| 286 | pause_automation |
| 287 | ); |
| 288 | write_automation_tool!( |
| 289 | AutomationResumeTool, |
| 290 | "automation_resume", |
| 291 | "Resume a paused durable automation. Requires approval.", |
| 292 | resume_automation |
| 293 | ); |
| 294 | write_automation_tool!( |
| 295 | AutomationDeleteTool, |
| 296 | "automation_delete", |
| 297 | "Delete a durable automation and its run history. Requires approval.", |
| 298 | delete_automation |
| 299 | ); |
| 300 | |
| 301 | #[async_trait] |
| 302 | impl ToolSpec for AutomationRunTool { |
| 303 | fn name(&self) -> &'static str { |
| 304 | "automation_run" |
| 305 | } |
| 306 | |
| 307 | fn description(&self) -> &'static str { |
| 308 | "Run an automation now. The run enqueues a normal durable task and returns linked task/thread/turn ids as they become available." |
| 309 | } |
| 310 | |
| 311 | fn input_schema(&self) -> Value { |
| 312 | automation_id_schema(true) |
| 313 | } |
| 314 | |
| 315 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 316 | vec![ToolCapability::RequiresApproval] |
| 317 | } |
| 318 | |
| 319 | fn approval_requirement(&self) -> ApprovalRequirement { |
| 320 | ApprovalRequirement::Required |
| 321 | } |
| 322 | |
| 323 | async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> { |
| 324 | let manager = context |
| 325 | .runtime |
| 326 | .automations |
| 327 | .as_ref() |
| 328 | .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?; |
| 329 | let task_manager = context |
| 330 | .runtime |
| 331 | .task_manager |
| 332 | .as_ref() |
| 333 | .ok_or_else(|| ToolError::not_available("TaskManager is not attached"))?; |
| 334 | let manager = manager.lock().await; |
| 335 | let run = manager |
| 336 | .run_now(required_str(&input, "automation_id")?, task_manager) |
| 337 | .await |
| 338 | .map_err(|e| ToolError::execution_failed(e.to_string()))?; |
| 339 | ToolResult::json(&run).map_err(|e| ToolError::execution_failed(e.to_string())) |
| 340 | } |
| 341 | } |
| 342 | |
| 343 | fn automation_id_schema(require_id: bool) -> Value { |
| 344 | let mut schema = json!({ |
| 345 | "type": "object", |
| 346 | "properties": { |
| 347 | "automation_id": { "type": "string" } |
| 348 | }, |
| 349 | "additionalProperties": false |
| 350 | }); |
| 351 | if require_id { |
| 352 | schema["required"] = json!(["automation_id"]); |
| 353 | } |
| 354 | schema |
| 355 | } |
| 356 | |
| 357 | fn string_array(input: &Value, field: &str) -> Result<Vec<String>, ToolError> { |
| 358 | Ok(input |
| 359 | .get(field) |
| 360 | .and_then(Value::as_array) |
| 361 | .map(|items| { |
| 362 | items |
| 363 | .iter() |
| 364 | .filter_map(Value::as_str) |
| 365 | .map(ToString::to_string) |
| 366 | .collect::<Vec<_>>() |
| 367 | }) |
| 368 | .unwrap_or_default()) |
| 369 | } |
| 370 | |
| 371 | #[cfg(test)] |
| 372 | mod tests { |
| 373 | use super::*; |
| 374 | use crate::tools::spec::ToolSpec; |
| 375 | |
| 376 | #[test] |
| 377 | fn create_schema_exposes_rrule() { |
| 378 | let schema = AutomationCreateTool.input_schema(); |
| 379 | assert!(schema["properties"]["rrule"].is_object()); |
| 380 | assert_eq!(schema["required"][0], "name"); |
| 381 | } |
| 382 | } |
| 383 |