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