Skip to main content

substrate_core/
skill_port.rs

1//! SkillPort + ToolRegistry — named invokable capabilities with typed schemas.
2//!
3//! Core defines descriptors, schema validation, and port contracts;
4//! `substrate-skills` provides an in-memory registry implementation.
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9use crate::error::{Result, SubstrateError};
10
11/// Metadata for a registered skill (tool).
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct SkillDescriptor {
14    /// Unique skill name used at invoke time.
15    pub name: String,
16    /// Human-readable description.
17    pub description: String,
18    /// JSON Schema describing valid invoke input.
19    pub input_schema: Value,
20    /// JSON Schema describing the invoke output shape.
21    pub output_schema: Value,
22}
23
24/// Handler invoked after input schema validation succeeds.
25pub trait SkillHandler: Send + Sync {
26    /// Run the skill with validated `input` JSON.
27    fn invoke(&self, input: Value) -> Result<Value>;
28}
29
30/// Invoke registered skills and list available capabilities.
31pub trait SkillPort: Send + Sync {
32    /// Invoke `name` with `input`. Implementations MUST validate `input`
33    /// against the skill's `input_schema` before calling the handler.
34    fn invoke(&self, name: &str, input: Value) -> Result<Value>;
35
36    /// Return descriptors for all registered skills.
37    fn list_skills(&self) -> Vec<SkillDescriptor>;
38}
39
40/// Registry of named skills: register, lookup, list, and schema validation.
41pub trait ToolRegistry: Send + Sync {
42    /// Register a skill. Returns an error if `name` is already taken or schemas
43    /// are invalid.
44    fn register(
45        &mut self,
46        descriptor: SkillDescriptor,
47        handler: Box<dyn SkillHandler>,
48    ) -> Result<()>;
49
50    /// Look up a skill descriptor by name.
51    fn lookup(&self, name: &str) -> Option<&SkillDescriptor>;
52
53    /// List all registered skill descriptors.
54    fn list(&self) -> Vec<SkillDescriptor>;
55
56    /// Validate `input` against the named skill's `input_schema`.
57    fn validate_input(&self, name: &str, input: &Value) -> Result<()>;
58}
59
60/// Validate `value` against a JSON Schema subset (`type`, `properties`, `required`).
61pub fn validate_json_schema(value: &Value, schema: &Value) -> Result<()> {
62    let Some(schema_obj) = schema.as_object() else {
63        return Err(SubstrateError::SchemaValidation(
64            "schema must be a JSON object".into(),
65        ));
66    };
67
68    if let Some(expected_type) = schema_obj.get("type").and_then(|t| t.as_str()) {
69        let matches = match expected_type {
70            "object" => value.is_object(),
71            "array" => value.is_array(),
72            "string" => value.is_string(),
73            "number" => value.is_number(),
74            "integer" => value.as_i64().is_some(),
75            "boolean" => value.is_boolean(),
76            "null" => value.is_null(),
77            other => {
78                return Err(SubstrateError::SchemaValidation(format!(
79                    "unsupported schema type: {other}"
80                )));
81            }
82        };
83        if !matches {
84            return Err(SubstrateError::SchemaValidation(format!(
85                "expected type {expected_type}, got {value}"
86            )));
87        }
88    }
89
90    if let Some(required) = schema_obj.get("required").and_then(|r| r.as_array()) {
91        let obj = value
92            .as_object()
93            .ok_or_else(|| SubstrateError::SchemaValidation("value must be an object".into()))?;
94        for key in required {
95            let key_str = key.as_str().ok_or_else(|| {
96                SubstrateError::SchemaValidation("required entry must be a string".into())
97            })?;
98            if !obj.contains_key(key_str) {
99                return Err(SubstrateError::SchemaValidation(format!(
100                    "missing required field: {key_str}"
101                )));
102            }
103        }
104    }
105
106    if let (Some(props), Some(obj)) = (
107        schema_obj.get("properties").and_then(|p| p.as_object()),
108        value.as_object(),
109    ) {
110        for (key, prop_schema) in props {
111            if let Some(field_value) = obj.get(key) {
112                validate_json_schema(field_value, prop_schema)?;
113            }
114        }
115    }
116
117    Ok(())
118}