1use 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#[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#[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
87pub 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
140pub 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}