Skip to main content

harn_vm/
tool_annotations.rs

1//! Tool annotations — the single source of truth for tool semantics.
2//!
3//! These types describe what a tool does at a semantic level. The VM
4//! consumes them to make policy decisions (read-only vs mutating, which
5//! argument holds the workspace path, which aliases to normalize, etc.)
6//! without hardcoding tool names or file-extension lists. Pipeline
7//! authors declare a `ToolAnnotations` value per tool in their
8//! `CapabilityPolicy.tool_annotations` registry; everything downstream
9//! is driven by that declaration.
10//!
11//! This alignment is ACP-compliant: `ToolKind` matches the canonical
12//! tool-kind vocabulary from the [Agent Client Protocol schema]
13//! (https://agentclientprotocol.com/protocol/schema) one-for-one.
14
15use std::collections::BTreeMap;
16
17use serde::{Deserialize, Serialize};
18
19/// Canonical tool-kind vocabulary. Matches the ACP `ToolKind` enum so
20/// harn-cli's ACP server can forward the value unchanged in
21/// `sessionUpdate` variants.
22///
23/// The VM treats `Read`, `Search`, `Think`, and `Fetch` as read-only
24/// for concurrent-dispatch purposes. `Other` is intentionally NOT
25/// treated as read-only — unannotated tools should not slip through
26/// as auto-approved by default (fail-safe).
27#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum ToolKind {
30    /// Reads file/workspace content without mutation.
31    Read,
32    /// Mutates workspace content (write, patch, edit).
33    Edit,
34    /// Removes content irreversibly.
35    Delete,
36    /// Relocates or renames content.
37    Move,
38    /// Queries indexes or directories; no mutation.
39    Search,
40    /// Runs a subprocess or a shell command.
41    Execute,
42    /// Pure reasoning/thought invocation, no side effects.
43    Think,
44    /// Retrieves remote content (HTTP, MCP fetch, etc.).
45    Fetch,
46    /// Anything that doesn't map cleanly into the canonical kinds.
47    /// Not treated as read-only — the fail-safe default.
48    #[default]
49    Other,
50}
51
52impl ToolKind {
53    /// Read-only tools can dispatch concurrently without risking
54    /// conflicting state mutations. `Other` is excluded by design —
55    /// unannotated tools must not auto-approve as read-only.
56    pub fn is_read_only(&self) -> bool {
57        matches!(self, Self::Read | Self::Search | Self::Think | Self::Fetch)
58    }
59
60    /// Coarse mutation-classification string used in tool-call
61    /// telemetry and pre/post bridge payloads. Derived directly from
62    /// the kind — the VM no longer guesses from tool names.
63    pub fn mutation_class(&self) -> &'static str {
64        match self {
65            Self::Read | Self::Search | Self::Think | Self::Fetch => "read_only",
66            Self::Edit => "workspace_write",
67            Self::Delete | Self::Move => "destructive",
68            Self::Execute => "ambient_side_effect",
69            Self::Other => "other",
70        }
71    }
72}
73
74/// Rough side-effect taxonomy for the capability-ceiling check.
75#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum SideEffectLevel {
78    /// No side effect declared (conservative default; permission logic
79    /// treats this as "unknown → deny unless explicitly allowed").
80    #[default]
81    None,
82    /// Pure reads only.
83    ReadOnly,
84    /// Writes to workspace files.
85    WorkspaceWrite,
86    /// Runs subprocesses.
87    ProcessExec,
88    /// Reaches external services over the network.
89    Network,
90}
91
92impl SideEffectLevel {
93    /// Numeric rank used by the policy intersector and side-effect
94    /// ceiling check. Higher rank ⇒ more invasive.
95    pub fn rank(&self) -> usize {
96        match self {
97            Self::None => 0,
98            Self::ReadOnly => 1,
99            Self::WorkspaceWrite => 2,
100            Self::ProcessExec => 3,
101            Self::Network => 4,
102        }
103    }
104
105    /// Short string used in policy documents, bridge payloads, and
106    /// error messages. Stable wire identifier.
107    pub fn as_str(&self) -> &'static str {
108        match self {
109            Self::None => "none",
110            Self::ReadOnly => "read_only",
111            Self::WorkspaceWrite => "workspace_write",
112            Self::ProcessExec => "process_exec",
113            Self::Network => "network",
114        }
115    }
116
117    /// Parse from the stable string used in policy documents. Unknown
118    /// values deserialize to `None` (the conservative default).
119    pub fn parse(value: &str) -> Self {
120        match value {
121            "none" => Self::None,
122            "read_only" => Self::ReadOnly,
123            "workspace_write" => Self::WorkspaceWrite,
124            "process_exec" => Self::ProcessExec,
125            "network" => Self::Network,
126            _ => Self::None,
127        }
128    }
129}
130
131/// Declarative description of a tool's argument shape. The VM uses
132/// this to:
133///
134/// - resolve `ToolArgConstraint` lookups (`path_params`),
135/// - rewrite high-level aliases to canonical keys without any
136///   per-tool hardcoded branches (`arg_aliases`),
137/// - validate presence of required arguments at the dispatch boundary
138///   (`required`).
139#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
140#[serde(default)]
141pub struct ToolArgSchema {
142    /// Argument keys whose values are workspace-relative paths.
143    /// First matching key whose value is a string wins.
144    pub path_params: Vec<String>,
145    /// Alias → canonical key. When a tool call arrives with an alias
146    /// in its argument object, the VM rewrites the key to the canonical
147    /// form before dispatch (generic; no tool-name branches).
148    pub arg_aliases: BTreeMap<String, String>,
149    /// Argument keys that must be present (non-null) on every call.
150    pub required: Vec<String>,
151}
152
153/// Full annotations for one tool. Pipelines populate one of these per
154/// tool in the capability-policy registry; the VM consults the registry
155/// on every tool call.
156#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
157#[serde(default)]
158pub struct ToolAnnotations {
159    /// ACP-aligned tool-kind classification.
160    pub kind: ToolKind,
161    /// Required side-effect level for the capability ceiling check.
162    pub side_effect_level: SideEffectLevel,
163    /// Argument shape declarations.
164    pub arg_schema: ToolArgSchema,
165    /// Capability operations requested by this tool (e.g.
166    /// `"workspace": ["read_text", "list"]`).
167    pub capabilities: BTreeMap<String, Vec<String>>,
168    /// True when the tool may return only a handle/reference to a large
169    /// output artifact instead of inline output. Execute tools with this
170    /// flag must also declare an inspection route.
171    pub emits_artifacts: bool,
172    /// Tool names that can inspect artifacts/results emitted by this tool.
173    pub result_readers: Vec<String>,
174    /// Explicit escape hatch for tools whose results are always complete
175    /// inline, even though they are execute-like.
176    pub inline_result: bool,
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn tool_kind_serde_roundtrip() {
185        for (kind, expected) in [
186            (ToolKind::Read, "\"read\""),
187            (ToolKind::Edit, "\"edit\""),
188            (ToolKind::Delete, "\"delete\""),
189            (ToolKind::Move, "\"move\""),
190            (ToolKind::Search, "\"search\""),
191            (ToolKind::Execute, "\"execute\""),
192            (ToolKind::Think, "\"think\""),
193            (ToolKind::Fetch, "\"fetch\""),
194            (ToolKind::Other, "\"other\""),
195        ] {
196            let encoded = serde_json::to_string(&kind).unwrap();
197            assert_eq!(encoded, expected);
198            let decoded: ToolKind = serde_json::from_str(expected).unwrap();
199            assert_eq!(decoded, kind);
200        }
201    }
202
203    #[test]
204    fn only_read_search_think_fetch_are_read_only() {
205        assert!(ToolKind::Read.is_read_only());
206        assert!(ToolKind::Search.is_read_only());
207        assert!(ToolKind::Think.is_read_only());
208        assert!(ToolKind::Fetch.is_read_only());
209        // Fail-safe: Other is NOT read-only.
210        assert!(!ToolKind::Other.is_read_only());
211        assert!(!ToolKind::Edit.is_read_only());
212        assert!(!ToolKind::Delete.is_read_only());
213        assert!(!ToolKind::Move.is_read_only());
214        assert!(!ToolKind::Execute.is_read_only());
215    }
216
217    #[test]
218    fn mutation_class_derived_from_kind() {
219        assert_eq!(ToolKind::Read.mutation_class(), "read_only");
220        assert_eq!(ToolKind::Search.mutation_class(), "read_only");
221        assert_eq!(ToolKind::Edit.mutation_class(), "workspace_write");
222        assert_eq!(ToolKind::Delete.mutation_class(), "destructive");
223        assert_eq!(ToolKind::Move.mutation_class(), "destructive");
224        assert_eq!(ToolKind::Execute.mutation_class(), "ambient_side_effect");
225        assert_eq!(ToolKind::Other.mutation_class(), "other");
226    }
227
228    #[test]
229    fn side_effect_level_round_trip() {
230        for level in [
231            SideEffectLevel::None,
232            SideEffectLevel::ReadOnly,
233            SideEffectLevel::WorkspaceWrite,
234            SideEffectLevel::ProcessExec,
235            SideEffectLevel::Network,
236        ] {
237            assert_eq!(SideEffectLevel::parse(level.as_str()), level);
238            let encoded = serde_json::to_string(&level).unwrap();
239            let decoded: SideEffectLevel = serde_json::from_str(&encoded).unwrap();
240            assert_eq!(decoded, level);
241        }
242    }
243
244    #[test]
245    fn side_effect_level_rank_orders() {
246        assert!(SideEffectLevel::None.rank() < SideEffectLevel::ReadOnly.rank());
247        assert!(SideEffectLevel::ReadOnly.rank() < SideEffectLevel::WorkspaceWrite.rank());
248        assert!(SideEffectLevel::WorkspaceWrite.rank() < SideEffectLevel::ProcessExec.rank());
249        assert!(SideEffectLevel::ProcessExec.rank() < SideEffectLevel::Network.rank());
250    }
251
252    #[test]
253    fn arg_schema_defaults_empty() {
254        let schema = ToolArgSchema::default();
255        assert!(schema.path_params.is_empty());
256        assert!(schema.arg_aliases.is_empty());
257        assert!(schema.required.is_empty());
258    }
259
260    #[test]
261    fn annotations_default_result_routes_empty() {
262        let annotations = ToolAnnotations::default();
263        assert!(!annotations.emits_artifacts);
264        assert!(annotations.result_readers.is_empty());
265        assert!(!annotations.inline_result);
266    }
267}