1use serde_json::Value;
9
10#[derive(Clone, Debug)]
16pub enum ToolAction {
17 FileAccess(String),
19 FileWrite(String, Vec<u8>),
21 NetworkEgress(String, u16),
23 ShellCommand(String),
25 McpTool(String, Value),
27 Patch(String, String),
29 CodeExecution { language: String, code: String },
31 BrowserAction {
33 verb: String,
34 target: Option<String>,
35 },
36 DatabaseQuery { database: String, query: String },
38 ExternalApiCall { service: String, endpoint: String },
40 MemoryWrite { store: String, key: String },
42 MemoryRead { store: String, key: Option<String> },
44 Unknown,
46}
47
48impl ToolAction {
49 pub fn filesystem_path(&self) -> Option<&str> {
51 match self {
52 Self::FileAccess(path) | Self::FileWrite(path, _) | Self::Patch(path, _) => {
53 Some(path.as_str())
54 }
55 _ => None,
56 }
57 }
58}
59
60pub fn extract_action(tool_name: &str, arguments: &Value) -> ToolAction {
66 let tool = tool_name.to_lowercase();
67
68 if matches!(
70 tool.as_str(),
71 "read_file" | "read" | "file_read" | "get_file" | "cat"
72 ) {
73 if let Some(path) = extract_path(arguments) {
74 return ToolAction::FileAccess(path);
75 }
76 }
77
78 if matches!(
80 tool.as_str(),
81 "write_file" | "write" | "file_write" | "create_file" | "put_file" | "edit_file" | "edit"
82 ) {
83 if let Some(path) = extract_path(arguments) {
84 let content = arguments
85 .get("content")
86 .and_then(|v| v.as_str())
87 .unwrap_or("")
88 .as_bytes()
89 .to_vec();
90 return ToolAction::FileWrite(path, content);
91 }
92 }
93
94 if matches!(tool.as_str(), "filesystem" | "fs" | "file") {
97 if let Some(path) = extract_path(arguments) {
98 let is_write = arguments
99 .get("action")
100 .and_then(|v| v.as_str())
101 .map(|a| {
102 let a = a.to_lowercase();
103 a == "write" || a == "create" || a == "append"
104 })
105 .unwrap_or(false)
106 || arguments.get("content").is_some();
107
108 if is_write {
109 let content = arguments
110 .get("content")
111 .and_then(|v| v.as_str())
112 .unwrap_or("")
113 .as_bytes()
114 .to_vec();
115 return ToolAction::FileWrite(path, content);
116 } else {
117 return ToolAction::FileAccess(path);
118 }
119 }
120 }
121
122 if matches!(tool.as_str(), "apply_patch" | "patch" | "apply_diff") {
124 if let Some(path) = extract_path(arguments) {
125 let diff = arguments
126 .get("diff")
127 .or_else(|| arguments.get("patch"))
128 .and_then(|v| v.as_str())
129 .unwrap_or("")
130 .to_string();
131 return ToolAction::Patch(path, diff);
132 }
133 }
134
135 if matches!(
137 tool.as_str(),
138 "bash" | "shell" | "run_command" | "exec" | "execute" | "run" | "shell_exec" | "terminal"
139 ) {
140 if let Some(cmd) = arguments
141 .get("command")
142 .or_else(|| arguments.get("cmd"))
143 .or_else(|| arguments.get("input"))
144 .and_then(|v| v.as_str())
145 {
146 return ToolAction::ShellCommand(cmd.to_string());
147 }
148 }
149
150 if matches!(
152 tool.as_str(),
153 "http_request" | "fetch" | "curl" | "http" | "request" | "web_request"
154 ) {
155 if let Some(url) = arguments
156 .get("url")
157 .or_else(|| arguments.get("uri"))
158 .and_then(|v| v.as_str())
159 {
160 if let Some((host, port)) = parse_host_port(url) {
161 return ToolAction::NetworkEgress(host, port);
162 }
163 }
164 }
165
166 if matches!(
168 tool.as_str(),
169 "python"
170 | "python_exec"
171 | "run_python"
172 | "eval"
173 | "evaluate"
174 | "code_exec"
175 | "exec_code"
176 | "run_code"
177 | "notebook"
178 | "notebook_cell"
179 | "repl"
180 | "jupyter"
181 | "ipython"
182 ) {
183 let code = arguments
184 .get("code")
185 .or_else(|| arguments.get("source"))
186 .or_else(|| arguments.get("snippet"))
187 .or_else(|| arguments.get("script"))
188 .or_else(|| arguments.get("input"))
189 .and_then(|v| v.as_str())
190 .unwrap_or("")
191 .to_string();
192 let language = arguments
193 .get("language")
194 .or_else(|| arguments.get("lang"))
195 .and_then(|v| v.as_str())
196 .map(String::from)
197 .unwrap_or_else(|| infer_language_from_tool(&tool));
198 return ToolAction::CodeExecution { language, code };
199 }
200
201 if matches!(
203 tool.as_str(),
204 "browser"
205 | "browser_action"
206 | "browser_navigate"
207 | "navigate"
208 | "goto"
209 | "click"
210 | "type"
211 | "screenshot"
212 | "browser_click"
213 | "browser_type"
214 | "browser_screenshot"
215 | "playwright"
216 | "puppeteer"
217 | "selenium"
218 ) {
219 let verb = arguments
220 .get("action")
221 .or_else(|| arguments.get("verb"))
222 .and_then(|v| v.as_str())
223 .map(String::from)
224 .unwrap_or_else(|| tool.clone());
225 let target = arguments
226 .get("url")
227 .or_else(|| arguments.get("target"))
228 .or_else(|| arguments.get("href"))
229 .or_else(|| arguments.get("selector"))
230 .and_then(|v| v.as_str())
231 .map(String::from);
232 return ToolAction::BrowserAction { verb, target };
233 }
234
235 if matches!(
238 tool.as_str(),
239 "sql"
240 | "query"
241 | "db_query"
242 | "database"
243 | "execute_sql"
244 | "run_sql"
245 | "postgres"
246 | "mysql"
247 | "sqlite"
248 | "snowflake"
249 | "bigquery"
250 | "redshift"
251 | "mongo"
252 | "mongodb"
253 | "redis"
254 ) {
255 if let Some(q) = arguments
256 .get("query")
257 .or_else(|| arguments.get("sql"))
258 .or_else(|| arguments.get("statement"))
259 .or_else(|| arguments.get("command"))
260 .and_then(|v| v.as_str())
261 {
262 let database = arguments
263 .get("database")
264 .or_else(|| arguments.get("db"))
265 .or_else(|| arguments.get("connection"))
266 .and_then(|v| v.as_str())
267 .map(String::from)
268 .unwrap_or_else(|| tool.clone());
269 return ToolAction::DatabaseQuery {
270 database,
271 query: q.to_string(),
272 };
273 }
274 }
275
276 if matches!(
278 tool.as_str(),
279 "memory_write"
280 | "remember"
281 | "store_memory"
282 | "vector_upsert"
283 | "vector_write"
284 | "upsert"
285 | "pinecone_upsert"
286 | "weaviate_write"
287 | "qdrant_upsert"
288 ) {
289 let store = arguments
290 .get("collection")
291 .or_else(|| arguments.get("index"))
292 .or_else(|| arguments.get("namespace"))
293 .or_else(|| arguments.get("store"))
294 .and_then(|v| v.as_str())
295 .map(String::from)
296 .unwrap_or_else(|| tool.clone());
297 let key = arguments
298 .get("id")
299 .or_else(|| arguments.get("key"))
300 .or_else(|| arguments.get("memory_id"))
301 .and_then(|v| v.as_str())
302 .map(String::from)
303 .unwrap_or_default();
304 return ToolAction::MemoryWrite { store, key };
305 }
306
307 if matches!(
309 tool.as_str(),
310 "memory_read"
311 | "recall"
312 | "retrieve_memory"
313 | "vector_query"
314 | "vector_search"
315 | "similarity_search"
316 | "pinecone_query"
317 | "weaviate_search"
318 | "qdrant_search"
319 ) {
320 let store = arguments
321 .get("collection")
322 .or_else(|| arguments.get("index"))
323 .or_else(|| arguments.get("namespace"))
324 .or_else(|| arguments.get("store"))
325 .and_then(|v| v.as_str())
326 .map(String::from)
327 .unwrap_or_else(|| tool.clone());
328 let key = arguments
329 .get("id")
330 .or_else(|| arguments.get("key"))
331 .or_else(|| arguments.get("memory_id"))
332 .and_then(|v| v.as_str())
333 .map(String::from);
334 return ToolAction::MemoryRead { store, key };
335 }
336
337 if let Some(service) = detect_api_service(&tool) {
339 let endpoint = arguments
340 .get("endpoint")
341 .or_else(|| arguments.get("path"))
342 .or_else(|| arguments.get("action"))
343 .or_else(|| arguments.get("method"))
344 .and_then(|v| v.as_str())
345 .map(String::from)
346 .unwrap_or_else(|| tool.clone());
347 return ToolAction::ExternalApiCall { service, endpoint };
348 }
349
350 if tool.starts_with("mcp_") || tool.contains("mcp") {
352 return ToolAction::McpTool(tool_name.to_string(), arguments.clone());
353 }
354
355 ToolAction::McpTool(tool_name.to_string(), arguments.clone())
358}
359
360fn infer_language_from_tool(tool: &str) -> String {
361 match tool {
362 "python" | "python_exec" | "run_python" | "jupyter" | "ipython" | "notebook"
363 | "notebook_cell" => "python".to_string(),
364 "repl" => "javascript".to_string(),
365 _ => "unknown".to_string(),
366 }
367}
368
369fn detect_api_service(tool: &str) -> Option<String> {
370 for prefix in [
371 "slack_",
372 "stripe_",
373 "github_",
374 "gitlab_",
375 "jira_",
376 "twilio_",
377 "sendgrid_",
378 "pagerduty_",
379 "opsgenie_",
380 "zendesk_",
381 "salesforce_",
382 "hubspot_",
383 "notion_",
384 "linear_",
385 "intercom_",
386 ] {
387 if let Some(rest) = tool.strip_prefix(prefix) {
388 if !rest.is_empty() {
389 let service = prefix.trim_end_matches('_').to_string();
390 return Some(service);
391 }
392 }
393 }
394 None
395}
396
397fn extract_path(arguments: &Value) -> Option<String> {
398 arguments
399 .get("path")
400 .or_else(|| arguments.get("file"))
401 .or_else(|| arguments.get("file_path"))
402 .or_else(|| arguments.get("filename"))
403 .and_then(|v| v.as_str())
404 .map(String::from)
405}
406
407fn parse_host_port(url: &str) -> Option<(String, u16)> {
408 let url = url.trim();
409 if url.is_empty() {
410 return None;
411 }
412
413 let lowered = url.to_ascii_lowercase();
414 if lowered.starts_with("data:")
415 || lowered.starts_with("javascript:")
416 || lowered.starts_with("about:")
417 || lowered.starts_with("file:")
418 {
419 return None;
420 }
421
422 let (rest, default_port, parsed_as_url) = if lowered.starts_with("https://") {
423 (&url["https://".len()..], 443, true)
424 } else if lowered.starts_with("http://") {
425 (&url["http://".len()..], 80, true)
426 } else if let Some(rest) = url.strip_prefix("//") {
427 (rest, 443, true)
428 } else {
429 (url, 443, false)
430 };
431
432 let host_with_port = rest.split(['/', '?', '#']).next().unwrap_or(rest);
433 let host_without_userinfo = host_with_port
434 .rsplit_once('@')
435 .map(|(_, host)| host)
436 .unwrap_or(host_with_port);
437
438 let (host, port) = if let Some(bracketed) = host_without_userinfo.strip_prefix('[') {
439 let (host, remainder) = bracketed.split_once(']')?;
440 let port = if remainder.is_empty() {
441 default_port
442 } else if let Some(port_str) = remainder.strip_prefix(':') {
443 port_str.parse::<u16>().ok()?
444 } else {
445 return None;
446 };
447 (host.to_string(), port)
448 } else {
449 split_host_port(host_without_userinfo, default_port)
450 };
451
452 let host = host.trim_matches(|c: char| c == '/' || c == '.');
453 let looks_like_host = host.contains('.') || host == "localhost" || host.contains(':');
454 if host.is_empty() || (!parsed_as_url && !looks_like_host) {
455 return None;
456 }
457
458 Some((host.to_ascii_lowercase(), port))
459}
460
461fn split_host_port(host_with_port: &str, default_port: u16) -> (String, u16) {
462 if let Some((host, port_str)) = host_with_port.rsplit_once(':') {
463 if let Ok(port) = port_str.parse::<u16>() {
464 return (host.to_string(), port);
465 }
466 }
467 (host_with_port.to_string(), default_port)
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 #[test]
475 fn extract_file_access() {
476 let args = serde_json::json!({"path": "/etc/shadow"});
477 let action = extract_action("read_file", &args);
478 assert!(matches!(action, ToolAction::FileAccess(ref p) if p == "/etc/shadow"));
479 }
480
481 #[test]
482 fn extract_file_write() {
483 let args = serde_json::json!({"path": "/tmp/out.txt", "content": "hello"});
484 let action = extract_action("write_file", &args);
485 assert!(matches!(action, ToolAction::FileWrite(ref p, _) if p == "/tmp/out.txt"));
486 }
487
488 #[test]
489 fn extract_shell_command() {
490 let args = serde_json::json!({"command": "ls -la"});
491 let action = extract_action("bash", &args);
492 assert!(matches!(action, ToolAction::ShellCommand(ref c) if c == "ls -la"));
493 }
494
495 #[test]
496 fn extract_network_egress() {
497 let args = serde_json::json!({"url": "https://evil.com/api"});
498 let action = extract_action("http_request", &args);
499 assert!(matches!(action, ToolAction::NetworkEgress(ref h, 443) if h == "evil.com"));
500 }
501
502 #[test]
503 fn extract_network_with_port() {
504 let args = serde_json::json!({"url": "http://localhost:8080/health"});
505 let action = extract_action("fetch", &args);
506 assert!(matches!(action, ToolAction::NetworkEgress(ref h, 8080) if h == "localhost"));
507 }
508
509 #[test]
510 fn extract_network_with_scheme_relative_url() {
511 let args = serde_json::json!({"url": "//169.254.169.254/latest"});
512 let action = extract_action("http_request", &args);
513 assert!(matches!(action, ToolAction::NetworkEgress(ref h, 443) if h == "169.254.169.254"));
514 }
515
516 #[test]
517 fn extract_network_with_mixed_case_scheme() {
518 let args = serde_json::json!({"url": "HTTPS://Example.COM/api"});
519 let action = extract_action("fetch", &args);
520 assert!(matches!(action, ToolAction::NetworkEgress(ref h, 443) if h == "example.com"));
521 }
522
523 #[test]
524 fn extract_network_strips_userinfo_and_ipv6_brackets() {
525 let userinfo_args = serde_json::json!({"url": "https://user:pass@evil.com/path"});
526 let userinfo_action = extract_action("http_request", &userinfo_args);
527 assert!(
528 matches!(userinfo_action, ToolAction::NetworkEgress(ref h, 443) if h == "evil.com")
529 );
530
531 let ipv6_args = serde_json::json!({"url": "https://[fd00:ec2::254]/latest"});
532 let ipv6_action = extract_action("http_request", &ipv6_args);
533 assert!(
534 matches!(ipv6_action, ToolAction::NetworkEgress(ref h, 443) if h == "fd00:ec2::254")
535 );
536 }
537
538 #[test]
539 fn extract_network_strips_query_and_fragment_from_authority() {
540 let query_args = serde_json::json!({"url": "https://metadata.google.internal?x=1"});
541 let query_action = extract_action("http_request", &query_args);
542 assert!(matches!(
543 query_action,
544 ToolAction::NetworkEgress(ref h, 443) if h == "metadata.google.internal"
545 ));
546
547 let fragment_args = serde_json::json!({"url": "https://metadata.google.internal#anchor"});
548 let fragment_action = extract_action("fetch", &fragment_args);
549 assert!(matches!(
550 fragment_action,
551 ToolAction::NetworkEgress(ref h, 443) if h == "metadata.google.internal"
552 ));
553 }
554
555 #[test]
556 fn unknown_tool_becomes_mcp_tool() {
557 let args = serde_json::json!({"foo": "bar"});
558 let action = extract_action("custom_tool", &args);
559 assert!(matches!(action, ToolAction::McpTool(_, _)));
560 }
561
562 #[test]
563 fn filesystem_tool_read_by_default() {
564 let args = serde_json::json!({"path": "/etc/shadow"});
565 let action = extract_action("filesystem", &args);
566 assert!(
567 matches!(action, ToolAction::FileAccess(ref p) if p == "/etc/shadow"),
568 "expected FileAccess for filesystem tool with path-only params, got: {action:?}"
569 );
570 }
571
572 #[test]
573 fn filesystem_tool_explicit_read_action() {
574 let args = serde_json::json!({"path": "/etc/shadow", "action": "read"});
575 let action = extract_action("filesystem", &args);
576 assert!(
577 matches!(action, ToolAction::FileAccess(ref p) if p == "/etc/shadow"),
578 "expected FileAccess for filesystem tool with action=read, got: {action:?}"
579 );
580 }
581
582 #[test]
583 fn filesystem_tool_write_action() {
584 let args = serde_json::json!({"path": "/tmp/out.txt", "action": "write", "content": "hi"});
585 let action = extract_action("filesystem", &args);
586 assert!(
587 matches!(action, ToolAction::FileWrite(ref p, _) if p == "/tmp/out.txt"),
588 "expected FileWrite for filesystem tool with action=write, got: {action:?}"
589 );
590 }
591
592 #[test]
593 fn filesystem_tool_write_inferred_from_content() {
594 let args = serde_json::json!({"path": "/tmp/out.txt", "content": "data"});
595 let action = extract_action("filesystem", &args);
596 assert!(
597 matches!(action, ToolAction::FileWrite(ref p, _) if p == "/tmp/out.txt"),
598 "expected FileWrite for filesystem tool with content field, got: {action:?}"
599 );
600 }
601
602 #[test]
603 fn fs_tool_alias() {
604 let args = serde_json::json!({"path": "/etc/passwd"});
605 let action = extract_action("fs", &args);
606 assert!(
607 matches!(action, ToolAction::FileAccess(ref p) if p == "/etc/passwd"),
608 "expected FileAccess for fs tool alias, got: {action:?}"
609 );
610 }
611
612 #[test]
613 fn file_tool_alias() {
614 let args = serde_json::json!({"path": "/etc/passwd"});
615 let action = extract_action("file", &args);
616 assert!(
617 matches!(action, ToolAction::FileAccess(ref p) if p == "/etc/passwd"),
618 "expected FileAccess for file tool alias, got: {action:?}"
619 );
620 }
621
622 #[test]
623 fn extract_code_execution_python() {
624 let args = serde_json::json!({"code": "import os; os.listdir('.')"});
625 let action = extract_action("python", &args);
626 match action {
627 ToolAction::CodeExecution { language, code } => {
628 assert_eq!(language, "python");
629 assert!(code.contains("os.listdir"));
630 }
631 other => panic!("expected CodeExecution, got: {other:?}"),
632 }
633 }
634
635 #[test]
636 fn extract_code_execution_explicit_language() {
637 let args = serde_json::json!({"source": "console.log(1)", "language": "javascript"});
638 let action = extract_action("eval", &args);
639 match action {
640 ToolAction::CodeExecution { language, code } => {
641 assert_eq!(language, "javascript");
642 assert_eq!(code, "console.log(1)");
643 }
644 other => panic!("expected CodeExecution, got: {other:?}"),
645 }
646 }
647
648 #[test]
649 fn extract_browser_navigate() {
650 let args = serde_json::json!({"url": "https://example.com"});
651 let action = extract_action("navigate", &args);
652 match action {
653 ToolAction::BrowserAction { verb, target } => {
654 assert_eq!(verb, "navigate");
655 assert_eq!(target.as_deref(), Some("https://example.com"));
656 }
657 other => panic!("expected BrowserAction, got: {other:?}"),
658 }
659 }
660
661 #[test]
662 fn extract_browser_click_with_selector() {
663 let args = serde_json::json!({"action": "click", "selector": "#submit"});
664 let action = extract_action("browser", &args);
665 match action {
666 ToolAction::BrowserAction { verb, target } => {
667 assert_eq!(verb, "click");
668 assert_eq!(target.as_deref(), Some("#submit"));
669 }
670 other => panic!("expected BrowserAction, got: {other:?}"),
671 }
672 }
673
674 #[test]
675 fn extract_database_query() {
676 let args = serde_json::json!({"query": "SELECT * FROM users", "database": "prod"});
677 let action = extract_action("sql", &args);
678 match action {
679 ToolAction::DatabaseQuery { database, query } => {
680 assert_eq!(database, "prod");
681 assert!(query.contains("SELECT"));
682 }
683 other => panic!("expected DatabaseQuery, got: {other:?}"),
684 }
685 }
686
687 #[test]
688 fn extract_database_query_default_db() {
689 let args = serde_json::json!({"query": "SELECT 1"});
690 let action = extract_action("postgres", &args);
691 match action {
692 ToolAction::DatabaseQuery { database, .. } => {
693 assert_eq!(database, "postgres");
694 }
695 other => panic!("expected DatabaseQuery, got: {other:?}"),
696 }
697 }
698
699 #[test]
700 fn extract_memory_write() {
701 let args = serde_json::json!({"collection": "agent-notes", "id": "mem-42"});
702 let action = extract_action("vector_upsert", &args);
703 match action {
704 ToolAction::MemoryWrite { store, key } => {
705 assert_eq!(store, "agent-notes");
706 assert_eq!(key, "mem-42");
707 }
708 other => panic!("expected MemoryWrite, got: {other:?}"),
709 }
710 }
711
712 #[test]
713 fn extract_memory_read_with_key() {
714 let args = serde_json::json!({"namespace": "session-1", "id": "fact-7"});
715 let action = extract_action("recall", &args);
716 match action {
717 ToolAction::MemoryRead { store, key } => {
718 assert_eq!(store, "session-1");
719 assert_eq!(key.as_deref(), Some("fact-7"));
720 }
721 other => panic!("expected MemoryRead, got: {other:?}"),
722 }
723 }
724
725 #[test]
726 fn extract_memory_read_without_key() {
727 let args = serde_json::json!({"collection": "facts"});
728 let action = extract_action("vector_query", &args);
729 match action {
730 ToolAction::MemoryRead { store, key } => {
731 assert_eq!(store, "facts");
732 assert!(key.is_none());
733 }
734 other => panic!("expected MemoryRead, got: {other:?}"),
735 }
736 }
737
738 #[test]
739 fn extract_external_api_call_slack() {
740 let args = serde_json::json!({"endpoint": "chat.postMessage"});
741 let action = extract_action("slack_send_message", &args);
742 match action {
743 ToolAction::ExternalApiCall { service, endpoint } => {
744 assert_eq!(service, "slack");
745 assert_eq!(endpoint, "chat.postMessage");
746 }
747 other => panic!("expected ExternalApiCall, got: {other:?}"),
748 }
749 }
750
751 #[test]
752 fn extract_external_api_call_stripe_default_endpoint() {
753 let args = serde_json::json!({});
754 let action = extract_action("stripe_create_charge", &args);
755 match action {
756 ToolAction::ExternalApiCall { service, endpoint } => {
757 assert_eq!(service, "stripe");
758 assert_eq!(endpoint, "stripe_create_charge");
759 }
760 other => panic!("expected ExternalApiCall, got: {other:?}"),
761 }
762 }
763
764 #[test]
765 fn filesystem_tool_actions_expose_target_path() {
766 let read = extract_action(
767 "filesystem",
768 &serde_json::json!({"path": "/repo/src/lib.rs"}),
769 );
770 let write = extract_action(
771 "filesystem",
772 &serde_json::json!({"path": "/repo/src/lib.rs", "action": "write", "content": "hi"}),
773 );
774 let patch = extract_action(
775 "apply_patch",
776 &serde_json::json!({"path": "/repo/src/lib.rs", "patch": "@@ -1 +1 @@"}),
777 );
778
779 assert_eq!(read.filesystem_path(), Some("/repo/src/lib.rs"));
780 assert_eq!(write.filesystem_path(), Some("/repo/src/lib.rs"));
781 assert_eq!(patch.filesystem_path(), Some("/repo/src/lib.rs"));
782 }
783}