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    }
165}
166
167fn think_annotations() -> ToolAnnotations {
168    ToolAnnotations {
169        kind: ToolKind::Think,
170        side_effect_level: SideEffectLevel::ReadOnly,
171        arg_schema: ToolArgSchema {
172            path_params: vec!["bundle".to_string()],
173            ..ToolArgSchema::default()
174        },
175        capabilities: std::collections::BTreeMap::new(),
176        emits_artifacts: false,
177        result_readers: Vec::new(),
178        inline_result: true,
179    }
180}
181
182fn bundle_path_schema() -> serde_json::Value {
183    serde_json::json!({
184        "type": "object",
185        "required": ["bundle"],
186        "additionalProperties": false,
187        "properties": {
188            "bundle": {
189                "type": "string",
190                "description": "Filesystem path to a portable workflow bundle JSON file.",
191            }
192        }
193    })
194}
195
196fn patch_validate_schema() -> serde_json::Value {
197    serde_json::json!({
198        "type": "object",
199        "required": ["bundle", "patch"],
200        "additionalProperties": false,
201        "properties": {
202            "bundle": {
203                "type": "string",
204                "description": "Filesystem path to a portable workflow bundle JSON file.",
205            },
206            "patch": {
207                "description": "Either an inline workflow patch object or a path string to a patch JSON file.",
208                "oneOf": [
209                    { "type": "string" },
210                    { "type": "object" }
211                ]
212            },
213            "parent_ceiling": {
214                "description": "Optional CapabilityPolicy of the executing parent context. The patch is rejected when it widens this ceiling.",
215                "type": "object"
216            }
217        }
218    })
219}
220
221fn dispatch_workflow_bundle_validate(
222    args: &serde_json::Value,
223) -> Result<serde_json::Value, SafeFunctionToolError> {
224    let bundle = load_bundle_arg(args)?;
225    let report = validate_workflow_bundle(&bundle);
226    serde_json::to_value(report).map_err(|err| SafeFunctionToolError::io(err.to_string()))
227}
228
229fn dispatch_workflow_bundle_preview(
230    args: &serde_json::Value,
231) -> Result<serde_json::Value, SafeFunctionToolError> {
232    let bundle = load_bundle_arg(args)?;
233    let preview = preview_workflow_bundle(&bundle);
234    serde_json::to_value(preview).map_err(|err| SafeFunctionToolError::io(err.to_string()))
235}
236
237fn dispatch_workflow_bundle_capability_ceiling(
238    args: &serde_json::Value,
239) -> Result<serde_json::Value, SafeFunctionToolError> {
240    let bundle = load_bundle_arg(args)?;
241    let ceiling = bundle_capability_ceiling(&bundle);
242    serde_json::to_value(ceiling).map_err(|err| SafeFunctionToolError::io(err.to_string()))
243}
244
245fn dispatch_workflow_patch_validate(
246    args: &serde_json::Value,
247) -> Result<serde_json::Value, SafeFunctionToolError> {
248    let bundle = load_bundle_arg(args)?;
249    let patch = load_patch_arg(args)?;
250    let parent_ceiling = match args.get("parent_ceiling") {
251        Some(value) if !value.is_null() => Some(
252            serde_json::from_value::<CapabilityPolicy>(value.clone())
253                .map_err(|err| SafeFunctionToolError::invalid_args(err.to_string()))?,
254        ),
255        _ => None,
256    };
257    let report = validate_workflow_patch(&bundle, &patch, parent_ceiling.as_ref());
258    serde_json::to_value(report).map_err(|err| SafeFunctionToolError::io(err.to_string()))
259}
260
261fn load_bundle_arg(args: &serde_json::Value) -> Result<WorkflowBundle, SafeFunctionToolError> {
262    let path = args
263        .get("bundle")
264        .and_then(|value| value.as_str())
265        .ok_or_else(|| SafeFunctionToolError::invalid_args("bundle must be a string path"))?;
266    load_workflow_bundle(Path::new(path))
267        .map_err(|err| SafeFunctionToolError::io(format!("failed to load bundle {path}: {err}")))
268}
269
270fn load_patch_arg(args: &serde_json::Value) -> Result<WorkflowPatch, SafeFunctionToolError> {
271    let value = args
272        .get("patch")
273        .ok_or_else(|| SafeFunctionToolError::invalid_args("patch is required"))?;
274    let raw = match value {
275        serde_json::Value::String(path) => std::fs::read_to_string(path)
276            .map(|text| serde_json::from_str(&text))
277            .map_err(|err| {
278                SafeFunctionToolError::io(format!("failed to read patch {path}: {err}"))
279            })?
280            .map_err(|err| SafeFunctionToolError::invalid_args(err.to_string()))?,
281        other => serde_json::from_value(other.clone())
282            .map_err(|err| SafeFunctionToolError::invalid_args(err.to_string()))?,
283    };
284    Ok(raw)
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn registry_entries_are_readonly_or_think_only() {
293        for tool in safe_function_tools() {
294            let kind = tool.descriptor.annotations.kind;
295            assert!(
296                matches!(
297                    kind,
298                    ToolKind::Read | ToolKind::Search | ToolKind::Think | ToolKind::Fetch
299                ),
300                "tool {} has non-read kind {kind:?}",
301                tool.descriptor.name
302            );
303            let level = tool.descriptor.annotations.side_effect_level;
304            assert!(
305                matches!(level, SideEffectLevel::None | SideEffectLevel::ReadOnly),
306                "tool {} has non-readonly side-effect level {level:?}",
307                tool.descriptor.name
308            );
309        }
310    }
311
312    #[test]
313    fn registry_entries_have_object_argument_schemas() {
314        for tool in safe_function_tools() {
315            let schema = &tool.descriptor.arg_schema;
316            assert_eq!(
317                schema.get("type").and_then(|t| t.as_str()),
318                Some("object"),
319                "tool {} arg_schema must declare type=object",
320                tool.descriptor.name,
321            );
322            assert!(
323                schema.get("properties").is_some(),
324                "tool {} arg_schema must declare properties",
325                tool.descriptor.name,
326            );
327        }
328    }
329
330    #[test]
331    fn find_safe_function_tool_returns_none_for_unknown_name() {
332        assert!(find_safe_function_tool("does_not_exist").is_none());
333        assert!(find_safe_function_tool("workflow_bundle_validate").is_some());
334    }
335
336    fn write_pr_monitor_fixture(dir: &std::path::Path) -> std::path::PathBuf {
337        let path = dir.join("pr-monitor.bundle.json");
338        std::fs::write(
339            &path,
340            super::super::workflow_test_fixtures::PR_MONITOR_BUNDLE_JSON,
341        )
342        .unwrap();
343        path
344    }
345
346    #[test]
347    fn dispatch_validate_returns_validator_report() {
348        let temp = tempfile::tempdir().unwrap();
349        let bundle_path = write_pr_monitor_fixture(temp.path());
350        let tool = find_safe_function_tool("workflow_bundle_validate").unwrap();
351        let result = (tool.dispatch)(&serde_json::json!({"bundle": bundle_path})).unwrap();
352        assert_eq!(result["valid"], serde_json::Value::Bool(true));
353        assert_eq!(result["bundle_id"], "github-pr-monitor");
354    }
355
356    #[test]
357    fn dispatch_patch_validate_handles_inline_patch() {
358        let temp = tempfile::tempdir().unwrap();
359        let bundle_path = write_pr_monitor_fixture(temp.path());
360        let tool = find_safe_function_tool("workflow_patch_validate").unwrap();
361        let inline_patch = serde_json::json!({
362            "schema_version": 1,
363            "id": "test-patch",
364            "operations": [
365                { "op": "insert_node", "node_id": "verifier", "node": {"kind": "action", "task_label": "verify"} },
366                { "op": "add_edge", "from": "query_logs", "to": "verifier" }
367            ]
368        });
369        let result = (tool.dispatch)(&serde_json::json!({
370            "bundle": bundle_path,
371            "patch": inline_patch
372        }))
373        .unwrap();
374        assert_eq!(result["valid"], serde_json::Value::Bool(true));
375        assert_eq!(result["patch_id"], "test-patch");
376    }
377
378    #[test]
379    fn dispatch_invalid_arguments_returns_error_code() {
380        let tool = find_safe_function_tool("workflow_bundle_validate").unwrap();
381        let err = (tool.dispatch)(&serde_json::json!({"bundle": 42})).unwrap_err();
382        assert_eq!(err.code, "invalid_arguments");
383    }
384}