agentguard_core/
action.rs

1//! Agent actions: tool calls, optionally with an operation.
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5
6/// A tool-call action: e.g. `send_email`, or `s3::PutObject`.
7///
8/// Cedar action UID format: `Action::"ToolCall::<tool>"` or
9/// `Action::"ToolCall::<tool>::<operation>"`.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11#[non_exhaustive]
12pub struct AgentAction {
13    /// Tool name (e.g. `send_email`, `s3`, `repo_read`).
14    pub tool: String,
15    /// Optional operation within the tool (e.g. `PutObject` for `s3`).
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub operation: Option<String>,
18}
19
20impl AgentAction {
21    /// Construct an action for a whole-tool call (no specific operation).
22    pub fn tool(name: impl Into<String>) -> Self {
23        Self {
24            tool: name.into(),
25            operation: None,
26        }
27    }
28
29    /// Construct an action for a specific operation within a tool.
30    pub fn tool_op(name: impl Into<String>, op: impl Into<String>) -> Self {
31        Self {
32            tool: name.into(),
33            operation: Some(op.into()),
34        }
35    }
36
37    /// Cedar action UID like `Action::"ToolCall::send_email"`.
38    #[deprecated(
39        since = "0.2.1",
40        note = "Use the Display impl to write into an existing buffer (no allocation)."
41    )]
42    pub fn action_uid(&self) -> String {
43        match &self.operation {
44            Some(op) => format!("Action::\"ToolCall::{}::{}\"", self.tool, op),
45            None => format!("Action::\"ToolCall::{}\"", self.tool),
46        }
47    }
48
49    /// Just the ID portion (without the `Action::` namespace).
50    pub fn action_id(&self) -> String {
51        match &self.operation {
52            Some(op) => format!("ToolCall::{}::{}", self.tool, op),
53            None => format!("ToolCall::{}", self.tool),
54        }
55    }
56}
57
58impl fmt::Display for AgentAction {
59    /// Writes the Cedar action UID into the formatter without allocating
60    /// a `String`. Equivalent to the deprecated `action_uid()` but
61    /// allocation-free.
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match &self.operation {
64            Some(op) => write!(f, "Action::\"ToolCall::{}::{}\"", self.tool, op),
65            None => write!(f, "Action::\"ToolCall::{}\"", self.tool),
66        }
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn tool_action_uid() {
76        let a = AgentAction::tool("send_email");
77        assert_eq!(format!("{}", a), "Action::\"ToolCall::send_email\"");
78        assert_eq!(a.action_id(), "ToolCall::send_email");
79    }
80
81    #[test]
82    fn tool_op_action_uid() {
83        let a = AgentAction::tool_op("s3", "PutObject");
84        assert_eq!(format!("{}", a), "Action::\"ToolCall::s3::PutObject\"");
85        assert_eq!(a.action_id(), "ToolCall::s3::PutObject");
86    }
87
88    #[test]
89    fn action_without_operation_omits_double_colon() {
90        let a = AgentAction::tool("search");
91        // Single-tool action: no `::op` segment in the action id.
92        assert_eq!(a.action_id(), "ToolCall::search");
93        assert!(!a.action_id().contains("::ToolCall::"));
94    }
95
96    #[test]
97    fn action_serde_round_trip() {
98        let a = AgentAction::tool_op("email", "send");
99        let json = serde_json::to_string(&a).unwrap();
100        let parsed: AgentAction = serde_json::from_str(&json).unwrap();
101        assert_eq!(parsed, a);
102    }
103
104    #[test]
105    fn action_without_operation_serializes_no_op_field() {
106        let a = AgentAction::tool("solo");
107        let json = serde_json::to_value(&a).unwrap();
108        // The `operation` field has `skip_serializing_if = "Option::is_none"`,
109        // so it should not appear in the serialized form.
110        assert!(json.get("operation").is_none());
111    }
112}