llm_tool_runtime/
registry.rs1use crate::{
2 ToolDescriptor, ToolError, ToolErrorClass, ToolExposureMode, ToolExposurePolicy,
3 ToolPlannerStage,
4};
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::{BTreeMap, BTreeSet};
9use std::sync::Arc;
10
11#[async_trait]
12pub trait Tool: Send + Sync {
13 fn descriptor(&self) -> &ToolDescriptor;
14
15 async fn invoke(
16 &self,
17 ctx: &crate::ToolCtx,
18 call: &crate::ToolCall,
19 ) -> Result<crate::ToolResult, ToolError>;
20}
21
22#[derive(Default, Clone)]
23pub struct ToolRegistry {
24 tools: BTreeMap<String, Arc<dyn Tool>>,
25}
26
27impl ToolRegistry {
28 pub fn new() -> Self {
30 Self::default()
31 }
32
33 pub fn register<T>(&mut self, tool: T)
34 where
35 T: Tool + 'static,
36 {
37 self.tools
38 .insert(tool.descriptor().name.clone(), Arc::new(tool));
39 }
40
41 pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
43 self.tools.get(name).cloned()
44 }
45
46 pub fn descriptors(&self) -> Vec<ToolDescriptor> {
48 self.tools
49 .values()
50 .map(|tool| tool.descriptor().clone())
51 .collect()
52 }
53
54 pub fn plan_exposure(&self, request: &ToolExposureRequest) -> ToolExposurePlan {
56 let mut allowed = Vec::new();
57 let mut decisions = Vec::new();
58 let allowed_names = request
59 .allowed_names
60 .as_ref()
61 .map(|names| names.iter().cloned().collect::<BTreeSet<_>>());
62
63 for tool in self.tools.values() {
64 let descriptor = tool.descriptor();
65 let mut reason = None;
66
67 if descriptor.exposure_mode == ToolExposureMode::Hidden && !request.include_hidden {
68 reason = Some("hidden".to_string());
69 }
70
71 if reason.is_none() {
72 if let Some(ref allowed_name_set) = allowed_names {
73 if !allowed_name_set.contains(&descriptor.name) {
74 reason = Some("not_in_allowed_subset".to_string());
75 }
76 } else if descriptor.exposure_mode == ToolExposureMode::OptIn {
77 reason = Some("opt_in".to_string());
78 }
79 }
80
81 if reason.is_none()
82 && !planner_stage_allowed(
83 &descriptor.exposure_policy,
84 request.planner_stage.clone(),
85 )
86 {
87 reason = Some("planner_stage_blocked".to_string());
88 }
89
90 let exposed = reason.is_none();
91 if exposed {
92 allowed.push(tool.clone());
93 }
94
95 decisions.push(ToolExposureDecision {
96 tool_name: descriptor.name.clone(),
97 exposed,
98 reason: reason.unwrap_or_else(|| "selected".to_string()),
99 });
100 }
101
102 if let Some(limit) = request.max_tools {
103 allowed.truncate(limit);
104 }
105
106 ToolExposurePlan {
107 tools: allowed,
108 decisions,
109 }
110 }
111}
112
113fn planner_stage_allowed(policy: &ToolExposurePolicy, stage: ToolPlannerStage) -> bool {
114 if policy.allowed_planner_stages.is_empty() {
115 return true;
116 }
117
118 policy.allowed_planner_stages.contains(&stage)
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ToolExposureRequest {
123 pub allowed_names: Option<Vec<String>>,
124 pub planner_stage: ToolPlannerStage,
125 pub include_hidden: bool,
126 pub max_tools: Option<usize>,
127}
128
129impl Default for ToolExposureRequest {
130 fn default() -> Self {
131 Self {
132 allowed_names: None,
133 planner_stage: ToolPlannerStage::Execution,
134 include_hidden: false,
135 max_tools: None,
136 }
137 }
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct ToolExposureDecision {
142 pub tool_name: String,
143 pub exposed: bool,
144 pub reason: String,
145}
146
147#[derive(Clone)]
148pub struct ToolExposurePlan {
149 pub tools: Vec<Arc<dyn Tool>>,
150 pub decisions: Vec<ToolExposureDecision>,
151}
152
153pub fn validate_arguments_against_schema(
155 schema: &Value,
156 arguments: &Value,
157) -> Result<(), ToolError> {
158 validate_value(schema, arguments, "$")
159 .map_err(|message| ToolError::new(ToolErrorClass::InvalidArguments, message))
160}
161
162#[allow(clippy::collapsible_match)]
163fn validate_value(schema: &Value, value: &Value, path: &str) -> Result<(), String> {
164 if let Some(enum_values) = schema.get("enum").and_then(|value| value.as_array()) {
165 if !enum_values.iter().any(|candidate| candidate == value) {
166 return Err(format!("{path}: value is not in enum"));
167 }
168 }
169
170 if let Some(expected_type) = schema.get("type").and_then(|value| value.as_str()) {
171 match expected_type {
172 "object" => validate_object(schema, value, path)?,
173 "array" => validate_array(schema, value, path)?,
174 "string" => {
175 if !value.is_string() {
176 return Err(format!("{path}: expected string"));
177 }
178 }
179 "number" => {
180 if !value.is_number() {
181 return Err(format!("{path}: expected number"));
182 }
183 }
184 "integer" => {
185 if value.as_i64().is_none() && value.as_u64().is_none() {
186 return Err(format!("{path}: expected integer"));
187 }
188 }
189 "boolean" => {
190 if !value.is_boolean() {
191 return Err(format!("{path}: expected boolean"));
192 }
193 }
194 "null" => {
195 if !value.is_null() {
196 return Err(format!("{path}: expected null"));
197 }
198 }
199 _ => {}
200 }
201 }
202
203 Ok(())
204}
205
206fn validate_object(schema: &Value, value: &Value, path: &str) -> Result<(), String> {
207 let object = value
208 .as_object()
209 .ok_or_else(|| format!("{path}: expected object"))?;
210
211 let required = schema
212 .get("required")
213 .and_then(|value| value.as_array())
214 .cloned()
215 .unwrap_or_default();
216
217 for required_key in required.iter().filter_map(|value| value.as_str()) {
218 if !object.contains_key(required_key) {
219 return Err(format!("{path}: missing required field {required_key}"));
220 }
221 }
222
223 let properties = schema
224 .get("properties")
225 .and_then(|value| value.as_object())
226 .cloned()
227 .unwrap_or_default();
228
229 if schema
230 .get("additionalProperties")
231 .and_then(|value| value.as_bool())
232 == Some(false)
233 {
234 for key in object.keys() {
235 if !properties.contains_key(key) {
236 return Err(format!("{path}: unexpected field {key}"));
237 }
238 }
239 }
240
241 for (key, property_schema) in properties {
242 if let Some(field_value) = object.get(&key) {
243 validate_value(&property_schema, field_value, &format!("{path}.{key}"))?;
244 }
245 }
246
247 Ok(())
248}
249
250fn validate_array(schema: &Value, value: &Value, path: &str) -> Result<(), String> {
251 let items = value
252 .as_array()
253 .ok_or_else(|| format!("{path}: expected array"))?;
254
255 if let Some(item_schema) = schema.get("items") {
256 for (index, item) in items.iter().enumerate() {
257 validate_value(item_schema, item, &format!("{path}[{index}]"))?;
258 }
259 }
260
261 Ok(())
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn json_schema_validation_rejects_missing_required_fields() {
270 let schema = serde_json::json!({
271 "type": "object",
272 "required": ["query"],
273 "properties": {
274 "query": {"type": "string"},
275 "limit": {"type": "integer"}
276 },
277 "additionalProperties": false
278 });
279
280 let err = validate_arguments_against_schema(&schema, &serde_json::json!({"limit": 1}))
281 .unwrap_err();
282 assert!(err.message.contains("missing required field query"));
283 }
284
285 #[test]
286 fn json_schema_validation_rejects_unknown_fields() {
287 let schema = serde_json::json!({
288 "type": "object",
289 "properties": {
290 "query": {"type": "string"}
291 },
292 "additionalProperties": false
293 });
294
295 let err = validate_arguments_against_schema(
296 &schema,
297 &serde_json::json!({"query": "ok", "extra": true}),
298 )
299 .unwrap_err();
300 assert!(err.message.contains("unexpected field extra"));
301 }
302}