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    /// Empty when [`Self::any_of`] is set — JSON Schema treats `type`
91    /// and `anyOf` as alternatives, and the serializer skips empty
92    /// `type` on the wire so the rendered schema stays valid for
93    /// LLM tool-call validators.
94    #[serde(rename = "type", default, skip_serializing_if = "String::is_empty")]
95    pub schema_type: String,
96
97    /// Human-readable description of this parameter.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub description: Option<String>,
100
101    /// Allowed values (enum constraint).
102    #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
103    pub enum_values: Option<Vec<String>>,
104
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub default: Option<Value>,
107
108    /// Minimum value (for number/integer).
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub minimum: Option<f64>,
111
112    /// Maximum value (for number/integer).
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub maximum: Option<f64>,
115
116    /// Items schema (for array type).
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub items: Option<Box<PropertySchema>>,
119
120    /// Schema alternatives — used when a parameter accepts shapes
121    /// that can't be unified under one `type` (e.g. a Jira
122    /// customfield that's a select on Project A and free text on
123    /// Project B). Mutually exclusive with `schema_type` per JSON
124    /// Schema's `anyOf` semantics — when set, [`Self::schema_type`]
125    /// is empty and the serializer skips it.
126    #[serde(rename = "anyOf", default, skip_serializing_if = "Option::is_none")]
127    pub any_of: Option<Vec<PropertySchema>>,
128
129    /// Marker that this field was added/modified by an enricher.
130    #[serde(rename = "x-enriched", skip_serializing_if = "Option::is_none")]
131    pub enriched: Option<bool>,
132}
133
134impl PropertySchema {
135    /// Create a string property.
136    pub fn string(description: &str) -> Self {
137        Self {
138            schema_type: "string".into(),
139            description: Some(description.into()),
140            ..Default::default()
141        }
142    }
143
144    /// Create a string property with enum values.
145    pub fn string_enum(values: &[&str], description: &str) -> Self {
146        Self {
147            schema_type: "string".into(),
148            description: Some(description.into()),
149            enum_values: Some(values.iter().map(|s| s.to_string()).collect()),
150            enriched: Some(true),
151            ..Default::default()
152        }
153    }
154
155    /// Create a number property.
156    pub fn number(description: &str) -> Self {
157        Self {
158            schema_type: "number".into(),
159            description: Some(description.into()),
160            ..Default::default()
161        }
162    }
163
164    /// Create an integer property with optional min/max.
165    pub fn integer(description: &str, min: Option<f64>, max: Option<f64>) -> Self {
166        Self {
167            schema_type: "integer".into(),
168            description: Some(description.into()),
169            minimum: min,
170            maximum: max,
171            ..Default::default()
172        }
173    }
174
175    /// Create a boolean property.
176    pub fn boolean(description: &str) -> Self {
177        Self {
178            schema_type: "boolean".into(),
179            description: Some(description.into()),
180            ..Default::default()
181        }
182    }
183
184    /// Create an object property.
185    pub fn object(description: &str) -> Self {
186        Self {
187            schema_type: "object".into(),
188            description: Some(description.into()),
189            ..Default::default()
190        }
191    }
192
193    /// Create an array property with items schema.
194    pub fn array(items: PropertySchema, description: &str) -> Self {
195        Self {
196            schema_type: "array".into(),
197            description: Some(description.into()),
198            items: Some(Box::new(items)),
199            ..Default::default()
200        }
201    }
202
203    /// Create a schema that accepts any of several alternatives —
204    /// JSON Schema's `anyOf`. Used when a parameter can take
205    /// shapes that don't fit under a single `type` (e.g. a custom
206    /// field with different option lists across projects). The
207    /// outer schema carries the description and `anyOf` array;
208    /// `schema_type` is left empty so the wire format is a valid
209    /// `anyOf`-only schema.
210    pub fn any_of(description: &str, schemas: Vec<PropertySchema>) -> Self {
211        Self {
212            schema_type: String::new(),
213            description: Some(description.into()),
214            any_of: Some(schemas),
215            enriched: Some(true),
216            ..Default::default()
217        }
218    }
219}
220
221impl Default for PropertySchema {
222    fn default() -> Self {
223        Self {
224            schema_type: "string".into(),
225            description: None,
226            enum_values: None,
227            default: None,
228            minimum: None,
229            maximum: None,
230            items: None,
231            any_of: None,
232            enriched: None,
233        }
234    }
235}
236
237/// Tool input schema with typed property definitions.
238///
239/// Represents a JSON Schema `{ type: "object", properties: {...}, required: [...] }`.
240/// Uses `PropertySchema` for type-safe parameter definitions.
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct ToolSchema {
243    /// Parameter definitions keyed by parameter name.
244    pub properties: HashMap<String, PropertySchema>,
245    /// List of required parameter names.
246    #[serde(default, skip_serializing_if = "Vec::is_empty")]
247    pub required: Vec<String>,
248}
249
250impl ToolSchema {
251    /// Create an empty schema.
252    pub fn new() -> Self {
253        Self {
254            properties: HashMap::new(),
255            required: Vec::new(),
256        }
257    }
258
259    /// Create from a JSON Schema value (for backward compatibility).
260    pub fn from_json(schema: &Value) -> Self {
261        serde_json::from_value::<ToolSchema>(schema.clone()).unwrap_or_else(|_| {
262            // Fallback: manual parsing for non-standard JSON
263            let properties = schema
264                .get("properties")
265                .and_then(|p| {
266                    serde_json::from_value::<HashMap<String, PropertySchema>>(p.clone()).ok()
267                })
268                .unwrap_or_default();
269            let required = schema
270                .get("required")
271                .and_then(|r| r.as_array())
272                .map(|arr| {
273                    arr.iter()
274                        .filter_map(|v| v.as_str().map(String::from))
275                        .collect()
276                })
277                .unwrap_or_default();
278            Self {
279                properties,
280                required,
281            }
282        })
283    }
284
285    /// Convert to a JSON Schema value.
286    pub fn to_json(&self) -> Value {
287        let mut schema = serde_json::json!({
288            "type": "object",
289            "properties": self.properties,
290        });
291        if !self.required.is_empty() {
292            schema["required"] = serde_json::json!(self.required);
293        }
294        schema
295    }
296
297    /// Add a string parameter with enum values.
298    pub fn add_enum_param(&mut self, name: &str, values: &[&str], description: &str) {
299        self.properties.insert(
300            name.into(),
301            PropertySchema::string_enum(values, description),
302        );
303    }
304
305    /// Set enum values on an existing parameter.
306    pub fn set_enum(&mut self, param: &str, values: &[String]) {
307        if let Some(prop) = self.properties.get_mut(param) {
308            prop.enum_values = Some(values.to_vec());
309            prop.enriched = Some(true);
310        }
311    }
312
313    /// Add a typed property.
314    pub fn add_property(&mut self, name: &str, prop: PropertySchema) {
315        self.properties.insert(name.into(), prop);
316    }
317
318    /// Add a parameter with a raw JSON Schema value (backward compat).
319    pub fn add_param(&mut self, name: &str, schema: Value) {
320        if let Ok(prop) = serde_json::from_value::<PropertySchema>(schema) {
321            self.properties.insert(name.into(), prop);
322        }
323    }
324
325    /// Remove parameters not supported by the current provider.
326    pub fn remove_params(&mut self, names: &[&str]) {
327        for name in names {
328            self.properties.remove(*name);
329            self.required.retain(|r| r != *name);
330        }
331    }
332
333    /// Set whether a parameter is required.
334    pub fn set_required(&mut self, param: &str, required: bool) {
335        if required {
336            if !self.required.contains(&param.to_string()) {
337                self.required.push(param.into());
338            }
339        } else {
340            self.required.retain(|r| r != param);
341        }
342    }
343
344    /// Update a parameter's description.
345    pub fn set_description(&mut self, param: &str, desc: &str) {
346        if let Some(prop) = self.properties.get_mut(param) {
347            prop.description = Some(desc.into());
348        }
349    }
350
351    /// Set a default value for a parameter.
352    pub fn set_default(&mut self, param: &str, value: Value) {
353        if let Some(prop) = self.properties.get_mut(param) {
354            prop.default = Some(value);
355        }
356    }
357}
358
359impl Default for ToolSchema {
360    fn default() -> Self {
361        Self::new()
362    }
363}
364
365/// Convert a human-readable field name to a safe `cf_` parameter name.
366///
367/// Examples:
368/// - `"Story Points"` → `"cf_story_points"`
369/// - `"Risk Level"` → `"cf_risk_level"`
370pub fn sanitize_field_name(name: &str) -> String {
371    let sanitized: String = name
372        .chars()
373        .map(|c| {
374            if c.is_ascii_alphanumeric() {
375                c.to_ascii_lowercase()
376            } else {
377                '_'
378            }
379        })
380        .collect();
381    let collapsed = sanitized
382        .split('_')
383        .filter(|s| !s.is_empty())
384        .collect::<Vec<_>>()
385        .join("_");
386    format!("cf_{collapsed}")
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn test_sanitize_field_name() {
395        assert_eq!(sanitize_field_name("Story Points"), "cf_story_points");
396        assert_eq!(sanitize_field_name("Risk Level"), "cf_risk_level");
397        assert_eq!(
398            sanitize_field_name("My Custom Field!"),
399            "cf_my_custom_field"
400        );
401        assert_eq!(sanitize_field_name("simple"), "cf_simple");
402        // Non-ASCII becomes underscore
403        assert_eq!(sanitize_field_name("Приоритет"), "cf_");
404    }
405
406    #[test]
407    fn test_property_schema_constructors() {
408        let s = PropertySchema::string("A description");
409        assert_eq!(s.schema_type, "string");
410        assert_eq!(s.description.as_deref(), Some("A description"));
411
412        let e = PropertySchema::string_enum(&["a", "b"], "Pick one");
413        assert_eq!(e.enum_values, Some(vec!["a".to_string(), "b".to_string()]));
414        assert_eq!(e.enriched, Some(true));
415
416        let n = PropertySchema::number("Count");
417        assert_eq!(n.schema_type, "number");
418
419        let i = PropertySchema::integer("Limit", Some(1.0), Some(100.0));
420        assert_eq!(i.minimum, Some(1.0));
421        assert_eq!(i.maximum, Some(100.0));
422
423        let b = PropertySchema::boolean("Flag");
424        assert_eq!(b.schema_type, "boolean");
425
426        let o = PropertySchema::object("Values by key");
427        assert_eq!(o.schema_type, "object");
428
429        let a = PropertySchema::array(PropertySchema::string("item"), "List");
430        assert_eq!(a.schema_type, "array");
431        assert!(a.items.is_some());
432    }
433
434    /// `any_of` produces a JSON Schema with no top-level `type` —
435    /// the wire shape is `{"description": ..., "anyOf": [...]}`,
436    /// which is what JSON Schema validators expect for alternatives.
437    #[test]
438    fn test_property_schema_any_of_constructor() {
439        let alt = PropertySchema::any_of(
440            "Severity (varies per project)",
441            vec![
442                PropertySchema::string_enum(&["High", "Medium", "Low"], "Project A"),
443                PropertySchema::string_enum(&["P1", "P2", "P3"], "Project B"),
444            ],
445        );
446        assert_eq!(alt.schema_type, "");
447        assert_eq!(
448            alt.description.as_deref(),
449            Some("Severity (varies per project)")
450        );
451        assert_eq!(alt.enriched, Some(true));
452        let variants = alt.any_of.as_ref().expect("anyOf set");
453        assert_eq!(variants.len(), 2);
454        assert_eq!(variants[0].enum_values.as_ref().unwrap()[0], "High");
455        assert_eq!(variants[1].enum_values.as_ref().unwrap()[0], "P1");
456    }
457
458    /// Empty `schema_type` is skipped during JSON serialisation so
459    /// the rendered schema is valid `anyOf`-only — no stray
460    /// `"type": ""` ending up on the wire. We check the parsed
461    /// outer object specifically, since inner variants legitimately
462    /// carry their own `type`.
463    #[test]
464    fn test_property_schema_any_of_serialization_omits_empty_type() {
465        let alt = PropertySchema::any_of(
466            "alt",
467            vec![PropertySchema::string("a"), PropertySchema::number("b")],
468        );
469        let value = serde_json::to_value(&alt).unwrap();
470        let obj = value.as_object().expect("object");
471        assert!(
472            !obj.contains_key("type"),
473            "outer object must not have type: {value}"
474        );
475        assert!(obj.contains_key("anyOf"), "missing anyOf: {value}");
476        // Inner variants keep their `type` — that's expected.
477        let any_of = obj["anyOf"].as_array().unwrap();
478        assert_eq!(any_of[0]["type"], "string");
479        assert_eq!(any_of[1]["type"], "number");
480    }
481
482    #[test]
483    fn test_tool_schema_add_enum_param() {
484        let mut schema = ToolSchema::new();
485        schema.add_enum_param("status", &["open", "closed"], "Issue status");
486        let prop = schema.properties.get("status").unwrap();
487        assert_eq!(prop.schema_type, "string");
488        assert_eq!(
489            prop.enum_values,
490            Some(vec!["open".to_string(), "closed".to_string()])
491        );
492        assert_eq!(prop.enriched, Some(true));
493    }
494
495    #[test]
496    fn test_tool_schema_remove_params() {
497        let mut schema = ToolSchema::from_json(&serde_json::json!({
498            "type": "object",
499            "properties": {
500                "title": { "type": "string" },
501                "priority": { "type": "string" },
502            },
503            "required": ["title", "priority"],
504        }));
505        schema.remove_params(&["priority"]);
506        assert!(!schema.properties.contains_key("priority"));
507        assert_eq!(schema.required, vec!["title"]);
508    }
509
510    #[test]
511    fn test_tool_schema_roundtrip() {
512        let mut schema = ToolSchema::new();
513        schema.add_property("title", PropertySchema::string("Title"));
514        schema.set_required("title", true);
515
516        let json = schema.to_json();
517        assert_eq!(json["properties"]["title"]["type"], "string");
518        assert_eq!(json["required"], serde_json::json!(["title"]));
519
520        let restored = ToolSchema::from_json(&json);
521        assert!(restored.properties.contains_key("title"));
522        assert_eq!(restored.required, vec!["title"]);
523    }
524
525    #[test]
526    fn test_tool_schema_set_enum() {
527        let mut schema = ToolSchema::new();
528        schema.add_property("state", PropertySchema::string("Filter by state"));
529        schema.set_enum(
530            "state",
531            &["opened".into(), "closed".into(), "merged".into()],
532        );
533        let state = schema.properties.get("state").unwrap();
534        assert_eq!(
535            state.enum_values,
536            Some(vec![
537                "opened".to_string(),
538                "closed".to_string(),
539                "merged".to_string()
540            ])
541        );
542        assert_eq!(state.enriched, Some(true));
543        // Original description preserved
544        assert_eq!(state.description.as_deref(), Some("Filter by state"));
545    }
546
547    #[test]
548    fn test_tool_schema_set_required() {
549        let mut schema = ToolSchema::new();
550        schema.required = vec!["title".into()];
551
552        schema.set_required("description", true);
553        assert_eq!(schema.required, vec!["title", "description"]);
554
555        schema.set_required("title", false);
556        assert_eq!(schema.required, vec!["description"]);
557
558        // Idempotent
559        schema.set_required("description", true);
560        assert_eq!(schema.required, vec!["description"]);
561    }
562
563    #[test]
564    fn test_tool_schema_set_default() {
565        let mut schema = ToolSchema::new();
566        schema.add_property("limit", PropertySchema::integer("Max results", None, None));
567        schema.set_default("limit", serde_json::json!(20));
568        assert_eq!(
569            schema.properties.get("limit").unwrap().default,
570            Some(serde_json::json!(20))
571        );
572    }
573
574    #[test]
575    fn test_tool_schema_add_param_from_json() {
576        let mut schema = ToolSchema::new();
577        schema.add_param(
578            "cf_risk",
579            serde_json::json!({
580                "type": "string",
581                "enum": ["Low", "Medium", "High"],
582                "description": "Risk level",
583                "x-enriched": true,
584            }),
585        );
586        let prop = schema.properties.get("cf_risk").unwrap();
587        assert_eq!(prop.schema_type, "string");
588        assert_eq!(
589            prop.enum_values,
590            Some(vec![
591                "Low".to_string(),
592                "Medium".to_string(),
593                "High".to_string()
594            ])
595        );
596    }
597
598    #[test]
599    fn test_from_json_backward_compat() {
600        let json = serde_json::json!({
601            "type": "object",
602            "properties": {
603                "state": {
604                    "type": "string",
605                    "enum": ["open", "closed"],
606                    "description": "Issue state"
607                },
608                "limit": {
609                    "type": "integer",
610                    "minimum": 1,
611                    "maximum": 100
612                }
613            },
614            "required": ["state"]
615        });
616
617        let schema = ToolSchema::from_json(&json);
618        assert_eq!(schema.properties.len(), 2);
619        assert_eq!(schema.required, vec!["state"]);
620
621        let state = schema.properties.get("state").unwrap();
622        assert_eq!(state.schema_type, "string");
623        assert_eq!(
624            state.enum_values,
625            Some(vec!["open".to_string(), "closed".to_string()])
626        );
627
628        let limit = schema.properties.get("limit").unwrap();
629        assert_eq!(limit.schema_type, "integer");
630        assert_eq!(limit.minimum, Some(1.0));
631        assert_eq!(limit.maximum, Some(100.0));
632    }
633}