Skip to main content

devboy_core/
enricher.rs

1//! Tool enrichment traits and schema utilities.
2//!
3//! This module defines the `ToolEnricher` trait and `ToolSchema` struct
4//! that enable dynamic modification of MCP tool schemas. Provider crates
5//! implement `ToolEnricher` to adapt tool schemas to their capabilities.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashMap;
10
11use crate::tool_category::ToolCategory;
12use crate::tool_value_model::ToolValueModel;
13
14/// Trait for plugins that dynamically modify tool schemas and transform arguments.
15///
16/// Enrichers are executed in registration order by the `Executor`.
17/// Each enricher declares which tool categories it supports — only tools
18/// from those categories will be enriched and shown in `list_tools()`.
19pub trait ToolEnricher: Send + Sync {
20    /// Which tool categories this provider/enricher supports.
21    /// Tools from other categories won't be shown when this enricher is active.
22    fn supported_categories(&self) -> &[ToolCategory];
23
24    /// Modify the tool schema during `tools/list`.
25    fn enrich_schema(&self, tool_name: &str, schema: &mut ToolSchema);
26
27    /// Transform arguments before tool execution.
28    fn transform_args(&self, tool_name: &str, args: &mut Value);
29
30    /// Optional: provider-shipped value model for `tool_name`. Returned
31    /// models are merged into `AdaptiveConfig.tools` at startup so the
32    /// Paper 3 enrichment planner can read them via
33    /// `effective_tool_value_model`.
34    ///
35    /// Default impl returns `None` — built-in enrichers that do not
36    /// participate in the planner can ignore the method entirely.
37    fn value_model(&self, _tool_name: &str) -> Option<ToolValueModel> {
38        None
39    }
40
41    /// Build the JSON arguments for a *speculatively pre-fetched*
42    /// follow-up call.
43    ///
44    /// Given the tool that just produced `prev_result` (`prev_tool`),
45    /// the follow-up tool's `FollowUpLink` (with `projection` /
46    /// `projection_arg` set), the host asks the enricher: "what `args`
47    /// should I pass to `<follow-up tool>`?"
48    ///
49    /// Returns:
50    ///
51    /// - `Some(json)` — emit one prefetch request per object in the
52    ///   returned array (planner caps at `max_parallel_prefetches`).
53    ///   Top-level shape is `[{ <args1> }, { <args2> }, …]`.
54    /// - `None` (default) — provider has no opinion; the host falls
55    ///   back to the generic projection in `link.projection_arg`.
56    ///
57    /// Built-in enrichers should override this for the high-volume
58    /// follow-up chains identified in `paper3_corpus_findings.md`
59    /// (Glob → Read, Grep → Read, WebSearch → WebFetch, …).
60    fn project_args(
61        &self,
62        _prev_tool: &str,
63        _prev_result: &Value,
64        _link: &crate::tool_value_model::FollowUpLink,
65    ) -> Option<Value> {
66        None
67    }
68
69    /// Optional dynamic rate-limit host for `tool_name`, derived from
70    /// runtime `args`. Provider returns the network host the call
71    /// will hit (e.g. `Some("api.github.com")`) so the speculative
72    /// dispatcher can cap concurrent in-flight prefetches per host.
73    ///
74    /// Default: `None` — host falls back to
75    /// `ToolValueModel::rate_limit_host` (the static configuration
76    /// value), and if that is also `None` the prefetch is uncapped.
77    ///
78    /// Override this for tools whose target host is per-call —
79    /// `WebFetch` (host from `url` arg), `WebSearch` against multiple
80    /// search engines, MCP wrappers around generic HTTP clients.
81    fn rate_limit_host(&self, _tool_name: &str, _args: &Value) -> Option<String> {
82        None
83    }
84}
85
86/// JSON Schema property definition for a tool parameter.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct PropertySchema {
89    /// JSON Schema type: "string", "number", "integer", "boolean", "array", "object"
90    #[serde(rename = "type")]
91    pub schema_type: String,
92
93    /// Human-readable description of this parameter.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub description: Option<String>,
96
97    /// Allowed values (enum constraint).
98    #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
99    pub enum_values: Option<Vec<String>>,
100
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub default: Option<Value>,
103
104    /// Minimum value (for number/integer).
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub minimum: Option<f64>,
107
108    /// Maximum value (for number/integer).
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub maximum: Option<f64>,
111
112    /// Items schema (for array type).
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub items: Option<Box<PropertySchema>>,
115
116    /// Marker that this field was added/modified by an enricher.
117    #[serde(rename = "x-enriched", skip_serializing_if = "Option::is_none")]
118    pub enriched: Option<bool>,
119}
120
121impl PropertySchema {
122    /// Create a string property.
123    pub fn string(description: &str) -> Self {
124        Self {
125            schema_type: "string".into(),
126            description: Some(description.into()),
127            ..Default::default()
128        }
129    }
130
131    /// Create a string property with enum values.
132    pub fn string_enum(values: &[&str], description: &str) -> Self {
133        Self {
134            schema_type: "string".into(),
135            description: Some(description.into()),
136            enum_values: Some(values.iter().map(|s| s.to_string()).collect()),
137            enriched: Some(true),
138            ..Default::default()
139        }
140    }
141
142    /// Create a number property.
143    pub fn number(description: &str) -> Self {
144        Self {
145            schema_type: "number".into(),
146            description: Some(description.into()),
147            ..Default::default()
148        }
149    }
150
151    /// Create an integer property with optional min/max.
152    pub fn integer(description: &str, min: Option<f64>, max: Option<f64>) -> Self {
153        Self {
154            schema_type: "integer".into(),
155            description: Some(description.into()),
156            minimum: min,
157            maximum: max,
158            ..Default::default()
159        }
160    }
161
162    /// Create a boolean property.
163    pub fn boolean(description: &str) -> Self {
164        Self {
165            schema_type: "boolean".into(),
166            description: Some(description.into()),
167            ..Default::default()
168        }
169    }
170
171    /// Create an array property with items schema.
172    pub fn array(items: PropertySchema, description: &str) -> Self {
173        Self {
174            schema_type: "array".into(),
175            description: Some(description.into()),
176            items: Some(Box::new(items)),
177            ..Default::default()
178        }
179    }
180}
181
182impl Default for PropertySchema {
183    fn default() -> Self {
184        Self {
185            schema_type: "string".into(),
186            description: None,
187            enum_values: None,
188            default: None,
189            minimum: None,
190            maximum: None,
191            items: None,
192            enriched: None,
193        }
194    }
195}
196
197/// Tool input schema with typed property definitions.
198///
199/// Represents a JSON Schema `{ type: "object", properties: {...}, required: [...] }`.
200/// Uses `PropertySchema` for type-safe parameter definitions.
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct ToolSchema {
203    /// Parameter definitions keyed by parameter name.
204    pub properties: HashMap<String, PropertySchema>,
205    /// List of required parameter names.
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub required: Vec<String>,
208}
209
210impl ToolSchema {
211    /// Create an empty schema.
212    pub fn new() -> Self {
213        Self {
214            properties: HashMap::new(),
215            required: Vec::new(),
216        }
217    }
218
219    /// Create from a JSON Schema value (for backward compatibility).
220    pub fn from_json(schema: &Value) -> Self {
221        serde_json::from_value::<ToolSchema>(schema.clone()).unwrap_or_else(|_| {
222            // Fallback: manual parsing for non-standard JSON
223            let properties = schema
224                .get("properties")
225                .and_then(|p| {
226                    serde_json::from_value::<HashMap<String, PropertySchema>>(p.clone()).ok()
227                })
228                .unwrap_or_default();
229            let required = schema
230                .get("required")
231                .and_then(|r| r.as_array())
232                .map(|arr| {
233                    arr.iter()
234                        .filter_map(|v| v.as_str().map(String::from))
235                        .collect()
236                })
237                .unwrap_or_default();
238            Self {
239                properties,
240                required,
241            }
242        })
243    }
244
245    /// Convert to a JSON Schema value.
246    pub fn to_json(&self) -> Value {
247        let mut schema = serde_json::json!({
248            "type": "object",
249            "properties": self.properties,
250        });
251        if !self.required.is_empty() {
252            schema["required"] = serde_json::json!(self.required);
253        }
254        schema
255    }
256
257    /// Add a string parameter with enum values.
258    pub fn add_enum_param(&mut self, name: &str, values: &[&str], description: &str) {
259        self.properties.insert(
260            name.into(),
261            PropertySchema::string_enum(values, description),
262        );
263    }
264
265    /// Set enum values on an existing parameter.
266    pub fn set_enum(&mut self, param: &str, values: &[String]) {
267        if let Some(prop) = self.properties.get_mut(param) {
268            prop.enum_values = Some(values.to_vec());
269            prop.enriched = Some(true);
270        }
271    }
272
273    /// Add a typed property.
274    pub fn add_property(&mut self, name: &str, prop: PropertySchema) {
275        self.properties.insert(name.into(), prop);
276    }
277
278    /// Add a parameter with a raw JSON Schema value (backward compat).
279    pub fn add_param(&mut self, name: &str, schema: Value) {
280        if let Ok(prop) = serde_json::from_value::<PropertySchema>(schema) {
281            self.properties.insert(name.into(), prop);
282        }
283    }
284
285    /// Remove parameters not supported by the current provider.
286    pub fn remove_params(&mut self, names: &[&str]) {
287        for name in names {
288            self.properties.remove(*name);
289            self.required.retain(|r| r != *name);
290        }
291    }
292
293    /// Set whether a parameter is required.
294    pub fn set_required(&mut self, param: &str, required: bool) {
295        if required {
296            if !self.required.contains(&param.to_string()) {
297                self.required.push(param.into());
298            }
299        } else {
300            self.required.retain(|r| r != param);
301        }
302    }
303
304    /// Update a parameter's description.
305    pub fn set_description(&mut self, param: &str, desc: &str) {
306        if let Some(prop) = self.properties.get_mut(param) {
307            prop.description = Some(desc.into());
308        }
309    }
310
311    /// Set a default value for a parameter.
312    pub fn set_default(&mut self, param: &str, value: Value) {
313        if let Some(prop) = self.properties.get_mut(param) {
314            prop.default = Some(value);
315        }
316    }
317}
318
319impl Default for ToolSchema {
320    fn default() -> Self {
321        Self::new()
322    }
323}
324
325/// Convert a human-readable field name to a safe `cf_` parameter name.
326///
327/// Examples:
328/// - `"Story Points"` → `"cf_story_points"`
329/// - `"Risk Level"` → `"cf_risk_level"`
330pub fn sanitize_field_name(name: &str) -> String {
331    let sanitized: String = name
332        .chars()
333        .map(|c| {
334            if c.is_ascii_alphanumeric() {
335                c.to_ascii_lowercase()
336            } else {
337                '_'
338            }
339        })
340        .collect();
341    let collapsed = sanitized
342        .split('_')
343        .filter(|s| !s.is_empty())
344        .collect::<Vec<_>>()
345        .join("_");
346    format!("cf_{collapsed}")
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn test_sanitize_field_name() {
355        assert_eq!(sanitize_field_name("Story Points"), "cf_story_points");
356        assert_eq!(sanitize_field_name("Risk Level"), "cf_risk_level");
357        assert_eq!(
358            sanitize_field_name("My Custom Field!"),
359            "cf_my_custom_field"
360        );
361        assert_eq!(sanitize_field_name("simple"), "cf_simple");
362        // Non-ASCII becomes underscore
363        assert_eq!(sanitize_field_name("Приоритет"), "cf_");
364    }
365
366    #[test]
367    fn test_property_schema_constructors() {
368        let s = PropertySchema::string("A description");
369        assert_eq!(s.schema_type, "string");
370        assert_eq!(s.description.as_deref(), Some("A description"));
371
372        let e = PropertySchema::string_enum(&["a", "b"], "Pick one");
373        assert_eq!(e.enum_values, Some(vec!["a".to_string(), "b".to_string()]));
374        assert_eq!(e.enriched, Some(true));
375
376        let n = PropertySchema::number("Count");
377        assert_eq!(n.schema_type, "number");
378
379        let i = PropertySchema::integer("Limit", Some(1.0), Some(100.0));
380        assert_eq!(i.minimum, Some(1.0));
381        assert_eq!(i.maximum, Some(100.0));
382
383        let b = PropertySchema::boolean("Flag");
384        assert_eq!(b.schema_type, "boolean");
385
386        let a = PropertySchema::array(PropertySchema::string("item"), "List");
387        assert_eq!(a.schema_type, "array");
388        assert!(a.items.is_some());
389    }
390
391    #[test]
392    fn test_tool_schema_add_enum_param() {
393        let mut schema = ToolSchema::new();
394        schema.add_enum_param("status", &["open", "closed"], "Issue status");
395        let prop = schema.properties.get("status").unwrap();
396        assert_eq!(prop.schema_type, "string");
397        assert_eq!(
398            prop.enum_values,
399            Some(vec!["open".to_string(), "closed".to_string()])
400        );
401        assert_eq!(prop.enriched, Some(true));
402    }
403
404    #[test]
405    fn test_tool_schema_remove_params() {
406        let mut schema = ToolSchema::from_json(&serde_json::json!({
407            "type": "object",
408            "properties": {
409                "title": { "type": "string" },
410                "priority": { "type": "string" },
411            },
412            "required": ["title", "priority"],
413        }));
414        schema.remove_params(&["priority"]);
415        assert!(!schema.properties.contains_key("priority"));
416        assert_eq!(schema.required, vec!["title"]);
417    }
418
419    #[test]
420    fn test_tool_schema_roundtrip() {
421        let mut schema = ToolSchema::new();
422        schema.add_property("title", PropertySchema::string("Title"));
423        schema.set_required("title", true);
424
425        let json = schema.to_json();
426        assert_eq!(json["properties"]["title"]["type"], "string");
427        assert_eq!(json["required"], serde_json::json!(["title"]));
428
429        let restored = ToolSchema::from_json(&json);
430        assert!(restored.properties.contains_key("title"));
431        assert_eq!(restored.required, vec!["title"]);
432    }
433
434    #[test]
435    fn test_tool_schema_set_enum() {
436        let mut schema = ToolSchema::new();
437        schema.add_property("state", PropertySchema::string("Filter by state"));
438        schema.set_enum(
439            "state",
440            &["opened".into(), "closed".into(), "merged".into()],
441        );
442        let state = schema.properties.get("state").unwrap();
443        assert_eq!(
444            state.enum_values,
445            Some(vec![
446                "opened".to_string(),
447                "closed".to_string(),
448                "merged".to_string()
449            ])
450        );
451        assert_eq!(state.enriched, Some(true));
452        // Original description preserved
453        assert_eq!(state.description.as_deref(), Some("Filter by state"));
454    }
455
456    #[test]
457    fn test_tool_schema_set_required() {
458        let mut schema = ToolSchema::new();
459        schema.required = vec!["title".into()];
460
461        schema.set_required("description", true);
462        assert_eq!(schema.required, vec!["title", "description"]);
463
464        schema.set_required("title", false);
465        assert_eq!(schema.required, vec!["description"]);
466
467        // Idempotent
468        schema.set_required("description", true);
469        assert_eq!(schema.required, vec!["description"]);
470    }
471
472    #[test]
473    fn test_tool_schema_set_default() {
474        let mut schema = ToolSchema::new();
475        schema.add_property("limit", PropertySchema::integer("Max results", None, None));
476        schema.set_default("limit", serde_json::json!(20));
477        assert_eq!(
478            schema.properties.get("limit").unwrap().default,
479            Some(serde_json::json!(20))
480        );
481    }
482
483    #[test]
484    fn test_tool_schema_add_param_from_json() {
485        let mut schema = ToolSchema::new();
486        schema.add_param(
487            "cf_risk",
488            serde_json::json!({
489                "type": "string",
490                "enum": ["Low", "Medium", "High"],
491                "description": "Risk level",
492                "x-enriched": true,
493            }),
494        );
495        let prop = schema.properties.get("cf_risk").unwrap();
496        assert_eq!(prop.schema_type, "string");
497        assert_eq!(
498            prop.enum_values,
499            Some(vec![
500                "Low".to_string(),
501                "Medium".to_string(),
502                "High".to_string()
503            ])
504        );
505    }
506
507    #[test]
508    fn test_from_json_backward_compat() {
509        let json = serde_json::json!({
510            "type": "object",
511            "properties": {
512                "state": {
513                    "type": "string",
514                    "enum": ["open", "closed"],
515                    "description": "Issue state"
516                },
517                "limit": {
518                    "type": "integer",
519                    "minimum": 1,
520                    "maximum": 100
521                }
522            },
523            "required": ["state"]
524        });
525
526        let schema = ToolSchema::from_json(&json);
527        assert_eq!(schema.properties.len(), 2);
528        assert_eq!(schema.required, vec!["state"]);
529
530        let state = schema.properties.get("state").unwrap();
531        assert_eq!(state.schema_type, "string");
532        assert_eq!(
533            state.enum_values,
534            Some(vec!["open".to_string(), "closed".to_string()])
535        );
536
537        let limit = schema.properties.get("limit").unwrap();
538        assert_eq!(limit.schema_type, "integer");
539        assert_eq!(limit.minimum, Some(1.0));
540        assert_eq!(limit.maximum, Some(100.0));
541    }
542}