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