Skip to main content

harn_vm/orchestration/
safe_function_tools.rs

1//! Allowlisted Harn function-to-agent-tool adapter.
2//!
3//! The author of a workflow patch needs *some* deterministic, side-effect-free
4//! way to inspect a bundle, simulate a patch, and ask the validator whether
5//! the result is sane. We deliberately do **not** expose every Harn stdlib
6//! function as a model-callable tool — that would require schema generation,
7//! capability classification, and an audit trail for every entrypoint we ship.
8//!
9//! Instead, this module hosts a small, hand-curated registry of functions
10//! that:
11//!
12//! - have no side effects (Read / Search / Think kinds only),
13//! - have a stable JSON-shaped argument schema we can validate up front,
14//! - declare an explicit ACP-compatible [`ToolAnnotations`] entry,
15//! - dispatch into a deterministic Rust handler in this crate.
16//!
17//! The first version is scoped to the workflow-patch authoring loop: load
18//! a bundle, validate it, preview its graph, and dry-run a patch. Adding
19//! more entries is a deliberate, reviewed change — there is no auto
20//! discovery.
21//!
22//! Hosts surface this registry to a model by enumerating the descriptors,
23//! then dispatch tool calls back through [`SafeFunctionTool::dispatch`].
24//! The execution policy is enforced by the host; this layer only describes
25//! the contract.
26
27use std::path::Path;
28
29use serde::{Deserialize, Serialize};
30
31use super::workflow_bundle::{
32    load_workflow_bundle, preview_workflow_bundle, validate_workflow_bundle, WorkflowBundle,
33};
34use super::workflow_patch::{bundle_capability_ceiling, validate_workflow_patch, WorkflowPatch};
35use super::CapabilityPolicy;
36use crate::tool_annotations::{SideEffectLevel, ToolAnnotations, ToolArgSchema, ToolKind};
37
38pub const SAFE_FUNCTION_TOOL_SCHEMA_VERSION: u32 = 1;
39
40/// Public descriptor for one safe function tool. Hosts can serialize
41/// these directly into a tool listing surface for an agent.
42#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
43pub struct SafeFunctionToolDescriptor {
44    pub name: String,
45    pub description: String,
46    pub annotations: ToolAnnotations,
47    pub arg_schema: serde_json::Value,
48}
49
50/// Internal registry entry. Splits the descriptor (data the host serializes)
51/// from the dispatch handler (function pointer, not serializable).
52#[derive(Clone)]
53pub struct SafeFunctionTool {
54    pub descriptor: SafeFunctionToolDescriptor,
55    pub dispatch: fn(&serde_json::Value) -> Result<serde_json::Value, SafeFunctionToolError>,
56}
57
58#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
59pub struct SafeFunctionToolError {
60    pub code: String,
61    pub message: String,
62}
63
64impl std::fmt::Display for SafeFunctionToolError {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        write!(f, "{}: {}", self.code, self.message)
67    }
68}
69
70impl std::error::Error for SafeFunctionToolError {}
71
72impl SafeFunctionToolError {
73    pub fn invalid_args(message: impl Into<String>) -> Self {
74        Self {
75            code: "invalid_arguments".to_string(),
76            message: message.into(),
77        }
78    }
79    pub fn io(message: impl Into<String>) -> Self {
80        Self {
81            code: "io_error".to_string(),
82            message: message.into(),
83        }
84    }
85}
86
87/// All currently-allowlisted safe function tools. Hosts that need to
88/// expose a subset can filter by `name`. Adding an entry here is a
89/// deliberate review: the new function must be deterministic, side-
90/// effect-free, and carry an ACP-aligned [`ToolAnnotations`] value.
91pub fn safe_function_tools() -> Vec<SafeFunctionTool> {
92    vec![
93        SafeFunctionTool {
94            descriptor: SafeFunctionToolDescriptor {
95                name: "workflow_bundle_validate".to_string(),
96                description:
97                    "Validate a workflow bundle JSON file at PATH and return the validator report."
98                        .to_string(),
99                annotations: read_only_annotations("workspace", &["read_text"]),
100                arg_schema: bundle_path_schema(),
101            },
102            dispatch: dispatch_workflow_bundle_validate,
103        },
104        SafeFunctionTool {
105            descriptor: SafeFunctionToolDescriptor {
106                name: "workflow_bundle_preview".to_string(),
107                description:
108                    "Preview a workflow bundle: return the normalized graph, validation report, mermaid text, triggers, connectors, and editable fields."
109                        .to_string(),
110                annotations: read_only_annotations("workspace", &["read_text"]),
111                arg_schema: bundle_path_schema(),
112            },
113            dispatch: dispatch_workflow_bundle_preview,
114        },
115        SafeFunctionTool {
116            descriptor: SafeFunctionToolDescriptor {
117                name: "workflow_bundle_capability_ceiling".to_string(),
118                description:
119                    "Compute the capability ceiling that running this bundle would request of its parent runtime."
120                        .to_string(),
121                annotations: think_annotations(),
122                arg_schema: bundle_path_schema(),
123            },
124            dispatch: dispatch_workflow_bundle_capability_ceiling,
125        },
126        SafeFunctionTool {
127            descriptor: SafeFunctionToolDescriptor {
128                name: "workflow_patch_validate".to_string(),
129                description:
130                    "Apply a workflow patch JSON to a bundle in memory, run bundle validation, compute the structural diff and capability delta, and return the report."
131                        .to_string(),
132                annotations: read_only_annotations("workspace", &["read_text"]),
133                arg_schema: patch_validate_schema(),
134            },
135            dispatch: dispatch_workflow_patch_validate,
136        },
137    ]
138}
139
140/// Find a safe function tool by its model-visible name.
141pub fn find_safe_function_tool(name: &str) -> Option<SafeFunctionTool> {
142    safe_function_tools()
143        .into_iter()
144        .find(|tool| tool.descriptor.name == name)
145}
146
147fn read_only_annotations(capability: &str, ops: &[&str]) -> ToolAnnotations {
148    let mut capabilities = std::collections::BTreeMap::new();
149    capabilities.insert(
150        capability.to_string(),
151        ops.iter().map(|s| (*s).to_string()).collect(),
152    );
153    ToolAnnotations {
154        kind: ToolKind::Read,
155        side_effect_level: SideEffectLevel::ReadOnly,
156        arg_schema: ToolArgSchema {
157            path_params: vec!["bundle".to_string()],
158            ..ToolArgSchema::default()
159        },
160        capabilities,
161        emits_artifacts: false,
162        result_readers: Vec::new(),
163        inline_result: true,
164        ..ToolAnnotations::default()
165    }
166}
167
168fn think_annotations() -> ToolAnnotations {
169    ToolAnnotations {
170        kind: ToolKind::Think,
171        side_effect_level: SideEffectLevel::ReadOnly,
172        arg_schema: ToolArgSchema {
173            path_params: vec!["bundle".to_string()],
174            ..ToolArgSchema::default()
175        },
176        capabilities: std::collections::BTreeMap::new(),
177        emits_artifacts: false,
178        result_readers: Vec::new(),
179        inline_result: true,
180        ..ToolAnnotations::default()
181    }
182}
183
184fn bundle_path_schema() -> serde_json::Value {
185    serde_json::json!({
186        "type": "object",
187        "required": ["bundle"],
188        "additionalProperties": false,
189        "properties": {
190            "bundle": {
191                "type": "string",
192                "description": "Filesystem path to a portable workflow bundle JSON file.",
193            }
194        }
195    })
196}
197
198fn patch_validate_schema() -> serde_json::Value {
199    serde_json::json!({
200        "type": "object",
201        "required": ["bundle", "patch"],
202        "additionalProperties": false,
203        "properties": {
204            "bundle": {
205                "type": "string",
206                "description": "Filesystem path to a portable workflow bundle JSON file.",
207            },
208            "patch": {
209                "description": "Either an inline workflow patch object or a path string to a patch JSON file.",
210                "oneOf": [
211                    { "type": "string" },
212                    { "type": "object" }
213                ]
214            },
215            "parent_ceiling": {
216                "description": "Optional CapabilityPolicy of the executing parent context. The patch is rejected when it widens this ceiling.",
217                "type": "object"
218            }
219        }
220    })
221}
222
223fn dispatch_workflow_bundle_validate(
224    args: &serde_json::Value,
225) -> Result<serde_json::Value, SafeFunctionToolError> {
226    let bundle = load_bundle_arg(args)?;
227    let report = validate_workflow_bundle(&bundle);
228    serde_json::to_value(report).map_err(|err| SafeFunctionToolError::io(err.to_string()))
229}
230
231fn dispatch_workflow_bundle_preview(
232    args: &serde_json::Value,
233) -> Result<serde_json::Value, SafeFunctionToolError> {
234    let bundle = load_bundle_arg(args)?;
235    let preview = preview_workflow_bundle(&bundle);
236    serde_json::to_value(preview).map_err(|err| SafeFunctionToolError::io(err.to_string()))
237}
238
239fn dispatch_workflow_bundle_capability_ceiling(
240    args: &serde_json::Value,
241) -> Result<serde_json::Value, SafeFunctionToolError> {
242    let bundle = load_bundle_arg(args)?;
243    let ceiling = bundle_capability_ceiling(&bundle);
244    serde_json::to_value(ceiling).map_err(|err| SafeFunctionToolError::io(err.to_string()))
245}
246
247fn dispatch_workflow_patch_validate(
248    args: &serde_json::Value,
249) -> Result<serde_json::Value, SafeFunctionToolError> {
250    let bundle = load_bundle_arg(args)?;
251    let patch = load_patch_arg(args)?;
252    let parent_ceiling = match args.get("parent_ceiling") {
253        Some(value) if !value.is_null() => Some(
254            serde_json::from_value::<CapabilityPolicy>(value.clone())
255                .map_err(|err| SafeFunctionToolError::invalid_args(err.to_string()))?,
256        ),
257        _ => None,
258    };
259    let report = validate_workflow_patch(&bundle, &patch, parent_ceiling.as_ref());
260    serde_json::to_value(report).map_err(|err| SafeFunctionToolError::io(err.to_string()))
261}
262
263fn load_bundle_arg(args: &serde_json::Value) -> Result<WorkflowBundle, SafeFunctionToolError> {
264    let path = args
265        .get("bundle")
266        .and_then(|value| value.as_str())
267        .ok_or_else(|| SafeFunctionToolError::invalid_args("bundle must be a string path"))?;
268    load_workflow_bundle(Path::new(path))
269        .map_err(|err| SafeFunctionToolError::io(format!("failed to load bundle {path}: {err}")))
270}
271
272fn load_patch_arg(args: &serde_json::Value) -> Result<WorkflowPatch, SafeFunctionToolError> {
273    let value = args
274        .get("patch")
275        .ok_or_else(|| SafeFunctionToolError::invalid_args("patch is required"))?;
276    let raw = match value {
277        serde_json::Value::String(path) => std::fs::read_to_string(path)
278            .map(|text| serde_json::from_str(&text))
279            .map_err(|err| {
280                SafeFunctionToolError::io(format!("failed to read patch {path}: {err}"))
281            })?
282            .map_err(|err| SafeFunctionToolError::invalid_args(err.to_string()))?,
283        other => serde_json::from_value(other.clone())
284            .map_err(|err| SafeFunctionToolError::invalid_args(err.to_string()))?,
285    };
286    Ok(raw)
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn registry_entries_are_readonly_or_think_only() {
295        for tool in safe_function_tools() {
296            let kind = tool.descriptor.annotations.kind;
297            assert!(
298                matches!(
299                    kind,
300                    ToolKind::Read | ToolKind::Search | ToolKind::Think | ToolKind::Fetch
301                ),
302                "tool {} has non-read kind {kind:?}",
303                tool.descriptor.name
304            );
305            let level = tool.descriptor.annotations.side_effect_level;
306            assert!(
307                matches!(level, SideEffectLevel::None | SideEffectLevel::ReadOnly),
308                "tool {} has non-readonly side-effect level {level:?}",
309                tool.descriptor.name
310            );
311        }
312    }
313
314    #[test]
315    fn registry_entries_have_object_argument_schemas() {
316        for tool in safe_function_tools() {
317            let schema = &tool.descriptor.arg_schema;
318            assert_eq!(
319                schema.get("type").and_then(|t| t.as_str()),
320                Some("object"),
321                "tool {} arg_schema must declare type=object",
322                tool.descriptor.name,
323            );
324            assert!(
325                schema.get("properties").is_some(),
326                "tool {} arg_schema must declare properties",
327                tool.descriptor.name,
328            );
329        }
330    }
331
332    #[test]
333    fn find_safe_function_tool_returns_none_for_unknown_name() {
334        assert!(find_safe_function_tool("does_not_exist").is_none());
335        assert!(find_safe_function_tool("workflow_bundle_validate").is_some());
336    }
337
338    fn write_pr_monitor_fixture(dir: &std::path::Path) -> std::path::PathBuf {
339        let path = dir.join("pr-monitor.bundle.json");
340        std::fs::write(
341            &path,
342            super::super::workflow_test_fixtures::PR_MONITOR_BUNDLE_JSON,
343        )
344        .unwrap();
345        path
346    }
347
348    #[test]
349    fn dispatch_validate_returns_validator_report() {
350        let temp = tempfile::tempdir().unwrap();
351        let bundle_path = write_pr_monitor_fixture(temp.path());
352        let tool = find_safe_function_tool("workflow_bundle_validate").unwrap();
353        let result = (tool.dispatch)(&serde_json::json!({"bundle": bundle_path})).unwrap();
354        assert_eq!(result["valid"], serde_json::Value::Bool(true));
355        assert_eq!(result["bundle_id"], "github-pr-monitor");
356    }
357
358    #[test]
359    fn dispatch_patch_validate_handles_inline_patch() {
360        let temp = tempfile::tempdir().unwrap();
361        let bundle_path = write_pr_monitor_fixture(temp.path());
362        let tool = find_safe_function_tool("workflow_patch_validate").unwrap();
363        let inline_patch = serde_json::json!({
364            "schema_version": 1,
365            "id": "test-patch",
366            "operations": [
367                { "op": "insert_node", "node_id": "verifier", "node": {"kind": "action", "task_label": "verify"} },
368                { "op": "add_edge", "from": "query_logs", "to": "verifier" }
369            ]
370        });
371        let result = (tool.dispatch)(&serde_json::json!({
372            "bundle": bundle_path,
373            "patch": inline_patch
374        }))
375        .unwrap();
376        assert_eq!(result["valid"], serde_json::Value::Bool(true));
377        assert_eq!(result["patch_id"], "test-patch");
378    }
379
380    #[test]
381    fn dispatch_invalid_arguments_returns_error_code() {
382        let tool = find_safe_function_tool("workflow_bundle_validate").unwrap();
383        let err = (tool.dispatch)(&serde_json::json!({"bundle": 42})).unwrap_err();
384        assert_eq!(err.code, "invalid_arguments");
385    }
386}