Skip to main content

llm_tool_runtime/
registry.rs

1use 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    /// Creates an empty registry.
29    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    /// Returns the registered tool implementation for the supplied name.
42    pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
43        self.tools.get(name).cloned()
44    }
45
46    /// Returns cloned descriptors for every registered tool.
47    pub fn descriptors(&self) -> Vec<ToolDescriptor> {
48        self.tools
49            .values()
50            .map(|tool| tool.descriptor().clone())
51            .collect()
52    }
53
54    /// Computes which tools may be exposed for a planner request.
55    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
153/// Validates a JSON argument payload against a descriptor input schema.
154pub 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}