Skip to main content

lash_remote_protocol/protocol/
tools.rs

1#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2pub struct RemoteToolGrant {
3    pub protocol_version: u32,
4    #[serde(default, skip_serializing_if = "Option::is_none")]
5    pub id: Option<String>,
6    pub name: String,
7    #[serde(default, skip_serializing_if = "String::is_empty")]
8    pub description: String,
9    #[serde(default = "default_input_schema")]
10    pub input_schema: serde_json::Value,
11    #[serde(default)]
12    pub output_schema: serde_json::Value,
13    #[serde(default, skip_serializing_if = "Vec::is_empty")]
14    pub input_schema_projections: Vec<RemoteSchemaProjectionOverride>,
15    #[serde(default, skip_serializing_if = "Vec::is_empty")]
16    pub output_schema_projections: Vec<RemoteSchemaProjectionOverride>,
17    #[serde(default, skip_serializing_if = "RemoteToolOutputContract::is_static")]
18    pub output_contract: RemoteToolOutputContract,
19    #[serde(default, skip_serializing_if = "Vec::is_empty")]
20    pub examples: Vec<String>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub availability: Option<RemoteToolAvailability>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub activation: Option<RemoteToolActivation>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub argument_projection: Option<RemoteToolArgumentProjectionPolicy>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub scheduling: Option<RemoteToolScheduling>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub retry_policy: Option<RemoteToolRetryPolicy>,
31    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
32    pub bindings: BTreeMap<String, serde_json::Value>,
33}
34
35impl RemoteToolGrant {
36    pub fn binding_call_path(&self, binding_key: &str) -> Result<String, RemoteProtocolError> {
37        let binding = self.required_call_path_binding(binding_key)?;
38        Ok(format!(
39            "{}.{}",
40            binding.module_path.join("."),
41            binding.operation
42        ))
43    }
44
45    pub fn validate(&self) -> Result<(), RemoteProtocolError> {
46        ensure_protocol_version(self.protocol_version)?;
47        if self.name.trim().is_empty() {
48            return Err(RemoteProtocolError::InvalidToolGrant {
49                tool_name: self.name.clone(),
50                message: "tool grant name cannot be empty".to_string(),
51            });
52        }
53        for key in self.bindings.keys() {
54            if key.trim().is_empty() {
55                return Err(RemoteProtocolError::InvalidToolGrant {
56                    tool_name: self.name.clone(),
57                    message: "tool grant binding keys cannot be empty".to_string(),
58                });
59            }
60        }
61        Ok(())
62    }
63
64    pub fn validate_all(grants: &[Self]) -> Result<(), RemoteProtocolError> {
65        let mut seen = HashSet::new();
66        for grant in grants {
67            grant.validate()?;
68            for call_path in grant.call_path_bindings()? {
69                if !seen.insert(call_path.clone()) {
70                    return Err(RemoteProtocolError::DuplicateRemoteCallPath { call_path });
71                }
72            }
73        }
74        Ok(())
75    }
76
77    pub fn call_path_bindings(&self) -> Result<Vec<String>, RemoteProtocolError> {
78        let mut paths = Vec::new();
79        for (key, value) in &self.bindings {
80            if let Some(binding) = RemoteCallPathBinding::from_value(value) {
81                validate_call_path_binding(&self.name, key, &binding)?;
82                paths.push(format!(
83                    "{}.{}",
84                    binding.module_path.join("."),
85                    binding.operation
86                ));
87            }
88        }
89        Ok(paths)
90    }
91
92    fn required_call_path_binding(
93        &self,
94        binding_key: &str,
95    ) -> Result<RemoteCallPathBinding, RemoteProtocolError> {
96        let Some(value) = self.bindings.get(binding_key) else {
97            return Err(RemoteProtocolError::MissingToolBinding {
98                tool_name: self.name.clone(),
99                binding: binding_key.to_string(),
100            });
101        };
102        let Some(binding) = RemoteCallPathBinding::from_value(value) else {
103            return Err(RemoteProtocolError::InvalidToolGrant {
104                tool_name: self.name.clone(),
105                message: format!("tool binding `{binding_key}` does not expose a call path"),
106            });
107        };
108        validate_call_path_binding(&self.name, binding_key, &binding)?;
109        Ok(binding)
110    }
111}
112
113#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
114pub struct RemoteCallPathBinding {
115    pub module_path: Vec<String>,
116    pub operation: String,
117}
118
119impl RemoteCallPathBinding {
120    fn from_value(value: &serde_json::Value) -> Option<Self> {
121        let module_path = value
122            .get("module_path")?
123            .as_array()?
124            .iter()
125            .map(|part| part.as_str().map(ToOwned::to_owned))
126            .collect::<Option<Vec<_>>>()?;
127        let operation = value.get("operation")?.as_str()?.to_string();
128        Some(Self {
129            module_path,
130            operation,
131        })
132    }
133}
134
135fn validate_call_path_binding(
136    tool_name: &str,
137    binding_key: &str,
138    binding: &RemoteCallPathBinding,
139) -> Result<(), RemoteProtocolError> {
140    if binding.module_path.is_empty() {
141        return Err(RemoteProtocolError::InvalidToolGrant {
142            tool_name: tool_name.to_string(),
143            message: format!("tool binding `{binding_key}` requires an explicit module path"),
144        });
145    }
146    if binding.module_path.iter().any(|part| part.trim().is_empty()) {
147        return Err(RemoteProtocolError::InvalidToolGrant {
148            tool_name: tool_name.to_string(),
149            message: format!(
150                "tool binding `{binding_key}` module path cannot contain empty segments"
151            ),
152        });
153    }
154    if binding.operation.trim().is_empty() {
155        return Err(RemoteProtocolError::InvalidToolGrant {
156            tool_name: tool_name.to_string(),
157            message: format!("tool binding `{binding_key}` requires an explicit operation"),
158        });
159    }
160    Ok(())
161}
162
163fn default_input_schema() -> serde_json::Value {
164    serde_json::json!({
165        "type": "object",
166        "properties": {},
167        "additionalProperties": true
168    })
169}
170
171#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
172pub struct RemoteSchemaProjectionOverride {
173    pub profile: String,
174    pub schema: serde_json::Value,
175}
176
177#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
178#[serde(rename_all = "snake_case")]
179pub enum RemoteToolAvailability {
180    Off,
181    Searchable,
182    Callable,
183    #[default]
184    Showcased,
185}
186
187#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
188#[serde(rename_all = "snake_case")]
189pub enum RemoteToolActivation {
190    #[default]
191    Always,
192    Internal,
193}
194
195#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
196#[serde(rename_all = "snake_case")]
197pub enum RemoteToolScheduling {
198    #[default]
199    Parallel,
200    Serial,
201}
202
203#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
204#[serde(tag = "kind", rename_all = "snake_case")]
205pub enum RemoteToolOutputContract {
206    #[default]
207    Static,
208    FromInputSchema {
209        input_field: String,
210        #[serde(default, skip_serializing_if = "Option::is_none")]
211        default_schema: Option<serde_json::Value>,
212    },
213}
214
215impl RemoteToolOutputContract {
216    fn is_static(&self) -> bool {
217        matches!(self, Self::Static)
218    }
219}
220
221#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
222#[serde(tag = "kind", rename_all = "snake_case")]
223pub enum RemoteToolArgumentProjectionPolicy {
224    #[default]
225    MaterializeProjectedValues,
226    PreserveProjectedRefsInField {
227        field: String,
228    },
229}
230
231#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
232#[serde(tag = "type", rename_all = "snake_case")]
233pub enum RemoteToolRetryPolicy {
234    #[default]
235    Never,
236    Safe {
237        max_attempts: u32,
238        base_delay_ms: u64,
239        max_delay_ms: u64,
240    },
241    Idempotent {
242        max_attempts: u32,
243        base_delay_ms: u64,
244        max_delay_ms: u64,
245    },
246}