Skip to main content

atm_core/
tool.rs

1//! Vendor-neutral identity for tools an agent can invoke.
2//!
3//! Tool names are an *open set* — Claude has built-in tools, MCP plugins
4//! generate `mcp__<server>__<name>` at runtime, and each vendor adds its
5//! own. So the enum is closed for **well-known** tools (those we
6//! special-case for permission gating or display) and falls through to
7//! `Other(String)` for everything else.
8//!
9//! Wire format: serializes as the bare tool-name string (e.g. `"Bash"`,
10//! `"AskUserQuestion"`, `"mcp__github__list_issues"`). The
11//! `serde(into/from = "String")` attribute makes this transparent — the
12//! enum is purely an internal representation.
13
14use serde::{Deserialize, Serialize};
15
16/// A tool an agent can invoke.
17///
18/// Variants are limited to tools the daemon special-cases (interactive
19/// permission-gated tools, common tools we render with a friendlier
20/// label). Everything else lives in `Other(String)` and round-trips
21/// losslessly.
22#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
23#[serde(into = "String", from = "String")]
24pub enum Tool {
25    // === Interactive (Claude permission-gated) ===
26    /// `AskUserQuestion` — pauses and prompts the user.
27    AskUserQuestion,
28    /// `EnterPlanMode` — pauses for plan approval.
29    EnterPlanMode,
30    /// `ExitPlanMode` — pauses presenting the plan.
31    ExitPlanMode,
32
33    // === Common — recognized so the UI gets a stable label ===
34    Bash,
35    Read,
36    Write,
37    Edit,
38    Grep,
39    Glob,
40    Task,
41    WebSearch,
42    WebFetch,
43    TodoWrite,
44    NotebookEdit,
45    NotebookRead,
46
47    /// Any other tool name — MCP tools, vendor-specific tools, future
48    /// well-known tools we haven't promoted to a variant yet.
49    Other(String),
50}
51
52impl Tool {
53    /// True for tools whose `PreToolUse` event means the session is
54    /// awaiting user input rather than running.
55    ///
56    /// Adapter authors: this classification is Claude-driven today (pi
57    /// has no equivalent event — its permission gating is extension-
58    /// mediated and surfaces via `NeedsInputReason::PermissionGate`).
59    /// If a vendor's tool genuinely blocks waiting on user input,
60    /// promote it to a variant rather than relying on string matching.
61    #[must_use]
62    pub fn is_interactive(&self) -> bool {
63        matches!(
64            self,
65            Self::AskUserQuestion | Self::EnterPlanMode | Self::ExitPlanMode
66        )
67    }
68
69    /// Canonical wire-format string for this tool.
70    #[must_use]
71    pub fn as_str(&self) -> &str {
72        match self {
73            Self::AskUserQuestion => "AskUserQuestion",
74            Self::EnterPlanMode => "EnterPlanMode",
75            Self::ExitPlanMode => "ExitPlanMode",
76            Self::Bash => "Bash",
77            Self::Read => "Read",
78            Self::Write => "Write",
79            Self::Edit => "Edit",
80            Self::Grep => "Grep",
81            Self::Glob => "Glob",
82            Self::Task => "Task",
83            Self::WebSearch => "WebSearch",
84            Self::WebFetch => "WebFetch",
85            Self::TodoWrite => "TodoWrite",
86            Self::NotebookEdit => "NotebookEdit",
87            Self::NotebookRead => "NotebookRead",
88            Self::Other(s) => s.as_str(),
89        }
90    }
91}
92
93impl std::fmt::Display for Tool {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        f.write_str(self.as_str())
96    }
97}
98
99impl Tool {
100    /// Canonical lookup for known variants. Returns `None` for anything
101    /// that should fall through to `Other(_)`. Input should already be
102    /// trimmed by the caller.
103    fn try_from_known(s: &str) -> Option<Self> {
104        Some(match s {
105            "AskUserQuestion" => Self::AskUserQuestion,
106            "EnterPlanMode" => Self::EnterPlanMode,
107            "ExitPlanMode" => Self::ExitPlanMode,
108            "Bash" => Self::Bash,
109            "Read" => Self::Read,
110            "Write" => Self::Write,
111            "Edit" => Self::Edit,
112            "Grep" => Self::Grep,
113            "Glob" => Self::Glob,
114            "Task" => Self::Task,
115            "WebSearch" => Self::WebSearch,
116            "WebFetch" => Self::WebFetch,
117            "TodoWrite" => Self::TodoWrite,
118            "NotebookEdit" => Self::NotebookEdit,
119            "NotebookRead" => Self::NotebookRead,
120            _ => return None,
121        })
122    }
123}
124
125impl From<&str> for Tool {
126    fn from(s: &str) -> Self {
127        // Trim leading/trailing whitespace so " Bash " still resolves
128        // to the canonical variant — preserves earlier `is_interactive_tool`
129        // tolerance behavior.
130        let trimmed = s.trim();
131        Self::try_from_known(trimmed).unwrap_or_else(|| Self::Other(trimmed.to_string()))
132    }
133}
134
135impl From<String> for Tool {
136    fn from(s: String) -> Self {
137        // Avoid allocation when we hit a known variant by checking the
138        // borrow first; only fall back to consuming the String on Other.
139        let trimmed = s.trim();
140        if let Some(known) = Self::try_from_known(trimmed) {
141            return known;
142        }
143        // Reuse the owned String when no whitespace was trimmed.
144        if trimmed.len() == s.len() {
145            Self::Other(s)
146        } else {
147            Self::Other(trimmed.to_string())
148        }
149    }
150}
151
152impl From<Tool> for String {
153    fn from(t: Tool) -> Self {
154        match t {
155            Tool::Other(s) => s,
156            other => other.as_str().to_string(),
157        }
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn known_tools_roundtrip() {
167        for variant in [
168            Tool::AskUserQuestion,
169            Tool::EnterPlanMode,
170            Tool::ExitPlanMode,
171            Tool::Bash,
172            Tool::Read,
173            Tool::Write,
174            Tool::Edit,
175            Tool::Grep,
176            Tool::Glob,
177            Tool::Task,
178            Tool::WebSearch,
179            Tool::WebFetch,
180            Tool::TodoWrite,
181            Tool::NotebookEdit,
182            Tool::NotebookRead,
183        ] {
184            let s = variant.as_str().to_string();
185            assert_eq!(Tool::from(s), variant);
186        }
187    }
188
189    #[test]
190    fn unknown_tool_round_trips_via_other() {
191        assert_eq!(
192            Tool::from("mcp__github__list_issues"),
193            Tool::Other("mcp__github__list_issues".to_string())
194        );
195        assert_eq!(
196            Tool::from("custom_pi_tool"),
197            Tool::Other("custom_pi_tool".to_string())
198        );
199    }
200
201    #[test]
202    fn is_interactive_only_for_three() {
203        assert!(Tool::AskUserQuestion.is_interactive());
204        assert!(Tool::EnterPlanMode.is_interactive());
205        assert!(Tool::ExitPlanMode.is_interactive());
206
207        for not_interactive in [
208            Tool::Bash,
209            Tool::Read,
210            Tool::Write,
211            Tool::Edit,
212            Tool::Grep,
213            Tool::Glob,
214            Tool::Task,
215            Tool::WebSearch,
216            Tool::WebFetch,
217            Tool::TodoWrite,
218            Tool::NotebookEdit,
219            Tool::NotebookRead,
220            Tool::Other("anything".into()),
221        ] {
222            assert!(
223                !not_interactive.is_interactive(),
224                "{not_interactive} should not be interactive"
225            );
226        }
227    }
228
229    #[test]
230    fn whitespace_trimmed_into_canonical_variant() {
231        assert_eq!(Tool::from("  AskUserQuestion  "), Tool::AskUserQuestion);
232        assert_eq!(Tool::from("\tEnterPlanMode\n"), Tool::EnterPlanMode);
233        assert!(Tool::from("  AskUserQuestion  ").is_interactive());
234    }
235
236    #[test]
237    fn case_sensitive_match() {
238        assert_eq!(
239            Tool::from("askuserquestion"),
240            Tool::Other("askuserquestion".into())
241        );
242        assert_eq!(
243            Tool::from("ASKUSERQUESTION"),
244            Tool::Other("ASKUSERQUESTION".into())
245        );
246        assert!(!Tool::from("askuserquestion").is_interactive());
247    }
248
249    #[test]
250    fn empty_string_becomes_empty_other_and_is_not_interactive() {
251        assert_eq!(Tool::from(""), Tool::Other(String::new()));
252        assert!(!Tool::from("").is_interactive());
253        assert!(!Tool::from("   ").is_interactive());
254    }
255
256    #[test]
257    fn serde_roundtrip_known_and_other() {
258        // Wire format is just the bare tool name string.
259        assert_eq!(serde_json::to_string(&Tool::Bash).unwrap(), "\"Bash\"");
260        assert_eq!(
261            serde_json::to_string(&Tool::Other("mcp__plugin__do_thing".into())).unwrap(),
262            "\"mcp__plugin__do_thing\""
263        );
264
265        assert_eq!(
266            serde_json::from_str::<Tool>("\"Bash\"").unwrap(),
267            Tool::Bash
268        );
269        assert_eq!(
270            serde_json::from_str::<Tool>("\"mcp__x__y\"").unwrap(),
271            Tool::Other("mcp__x__y".into())
272        );
273    }
274}