Skip to main content

devboy_confluence/
enricher.rs

1//! Confluence schema enricher.
2//!
3//! Confluence currently exposes the KnowledgeBase tool category without
4//! provider-specific schema mutations. This enricher exists so the executor
5//! can register a KnowledgeBase-capable provider consistently, and so
6//! Paper 3 value models for KB tools have a stable home in this crate.
7
8use devboy_core::{
9    CostModel, FollowUpLink, SideEffectClass, ToolCategory, ToolEnricher, ToolSchema,
10    ToolValueModel, ValueClass,
11};
12use serde_json::Value;
13
14/// Static schema enricher for Confluence knowledge base tools.
15///
16/// Today this enricher only advertises category support and leaves schemas
17/// and arguments unchanged. Confluence-specific schema hints can be added
18/// here later without changing the provider shape.
19pub struct ConfluenceSchemaEnricher;
20
21impl ConfluenceSchemaEnricher {
22    /// Build a default Confluence schema enricher.
23    pub fn new() -> Self {
24        Self
25    }
26}
27
28impl Default for ConfluenceSchemaEnricher {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34impl ToolEnricher for ConfluenceSchemaEnricher {
35    fn supported_categories(&self) -> &[ToolCategory] {
36        &[ToolCategory::KnowledgeBase]
37    }
38
39    fn enrich_schema(&self, _tool_name: &str, _schema: &mut ToolSchema) {
40        // No-op for now. The provider already exposes the correct base
41        // KnowledgeBase schemas; this enricher mainly acts as the category
42        // claim for Confluence until richer provider-specific hints land.
43    }
44
45    fn transform_args(&self, _tool_name: &str, _args: &mut Value) {
46        // No-op for now.
47    }
48
49    /// Paper 3 value models for the six KB tools.
50    ///
51    /// Read endpoints are `ReadOnly` with realistic typical/max sizes
52    /// drawn from observed Confluence pages (a single page can be tens
53    /// of KB once attachments / large tables / embedded images are
54    /// rendered as storage XML). `search_knowledge_base` and
55    /// `list_knowledge_base_pages` declare a `get_knowledge_base_page`
56    /// follow-up with `pageId` projection so the planner can prefetch
57    /// the most likely next call. Mutating endpoints are
58    /// `MutatesExternal` and never speculatable.
59    fn value_model(&self, tool_name: &str) -> Option<ToolValueModel> {
60        let model = match tool_name {
61            "search_knowledge_base" => ToolValueModel {
62                value_class: ValueClass::Supporting,
63                cost_model: CostModel {
64                    typical_kb: 4.0,
65                    max_kb: Some(40.0),
66                    freshness_ttl_s: Some(60),
67                    ..CostModel::default()
68                },
69                follow_up: vec![FollowUpLink {
70                    tool: "get_knowledge_base_page".into(),
71                    probability: 0.55,
72                    projection: Some("id".into()),
73                    projection_arg: Some("pageId".into()),
74                }],
75                side_effect_class: SideEffectClass::ReadOnly,
76                ..ToolValueModel::default()
77            },
78            "list_knowledge_base_pages" => ToolValueModel {
79                value_class: ValueClass::Supporting,
80                cost_model: CostModel {
81                    typical_kb: 3.0,
82                    max_kb: Some(30.0),
83                    freshness_ttl_s: Some(60),
84                    ..CostModel::default()
85                },
86                follow_up: vec![FollowUpLink {
87                    tool: "get_knowledge_base_page".into(),
88                    probability: 0.50,
89                    projection: Some("id".into()),
90                    projection_arg: Some("pageId".into()),
91                }],
92                side_effect_class: SideEffectClass::ReadOnly,
93                ..ToolValueModel::default()
94            },
95            "get_knowledge_base_page" => ToolValueModel {
96                value_class: ValueClass::Critical,
97                cost_model: CostModel {
98                    typical_kb: 8.0,
99                    max_kb: Some(80.0),
100                    freshness_ttl_s: Some(300),
101                    ..CostModel::default()
102                },
103                side_effect_class: SideEffectClass::ReadOnly,
104                ..ToolValueModel::default()
105            },
106            "get_knowledge_base_spaces" => ToolValueModel {
107                value_class: ValueClass::Supporting,
108                cost_model: CostModel {
109                    typical_kb: 1.5,
110                    max_kb: Some(15.0),
111                    // Space tree is fairly stable — long TTL is safe.
112                    freshness_ttl_s: Some(1800),
113                    ..CostModel::default()
114                },
115                side_effect_class: SideEffectClass::ReadOnly,
116                ..ToolValueModel::default()
117            },
118            "create_knowledge_base_page" | "update_knowledge_base_page" => ToolValueModel {
119                value_class: ValueClass::Critical,
120                cost_model: CostModel {
121                    typical_kb: 8.0,
122                    max_kb: Some(80.0),
123                    ..CostModel::default()
124                },
125                side_effect_class: SideEffectClass::MutatesExternal,
126                // A successful update invalidates any cached snapshot of
127                // this page. Search / list responses include only
128                // metadata so they are not invalidated here.
129                invalidates: vec!["get_knowledge_base_page".into()],
130                ..ToolValueModel::default()
131            },
132            _ => return None,
133        };
134        Some(model)
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use serde_json::json;
142
143    #[test]
144    fn confluence_enricher_supports_knowledge_base_category() {
145        let enricher = ConfluenceSchemaEnricher::new();
146        assert_eq!(
147            enricher.supported_categories(),
148            &[ToolCategory::KnowledgeBase]
149        );
150    }
151
152    #[test]
153    fn confluence_enricher_leaves_schema_unchanged() {
154        let enricher = ConfluenceSchemaEnricher::new();
155        let original = json!({
156            "type": "object",
157            "properties": {
158                "spaceKey": { "type": "string" },
159                "parentId": { "type": "string" }
160            },
161            "required": ["spaceKey"]
162        });
163        let mut schema = ToolSchema::from_json(&original);
164
165        enricher.enrich_schema("list_knowledge_base_pages", &mut schema);
166
167        assert_eq!(schema.to_json(), original);
168    }
169
170    #[test]
171    fn confluence_enricher_leaves_args_unchanged() {
172        let enricher = ConfluenceSchemaEnricher::new();
173        let mut args = json!({
174            "query": "architecture",
175            "spaceKey": "ENG",
176            "rawQuery": false
177        });
178        let expected = args.clone();
179
180        enricher.transform_args("search_knowledge_base", &mut args);
181
182        assert_eq!(args, expected);
183    }
184
185    #[test]
186    fn paper3_search_chains_to_get_page_with_page_id_projection() {
187        let enricher = ConfluenceSchemaEnricher::new();
188        let model = enricher.value_model("search_knowledge_base").unwrap();
189        assert_eq!(model.follow_up.len(), 1);
190        let follow_up = &model.follow_up[0];
191        assert_eq!(follow_up.tool, "get_knowledge_base_page");
192        assert_eq!(follow_up.projection.as_deref(), Some("id"));
193        assert_eq!(follow_up.projection_arg.as_deref(), Some("pageId"));
194    }
195
196    #[test]
197    fn paper3_list_chains_to_get_page() {
198        let enricher = ConfluenceSchemaEnricher::new();
199        let model = enricher.value_model("list_knowledge_base_pages").unwrap();
200        assert_eq!(model.follow_up.len(), 1);
201        let follow_up = &model.follow_up[0];
202        assert_eq!(follow_up.tool, "get_knowledge_base_page");
203    }
204
205    #[test]
206    fn paper3_get_page_is_read_only_with_long_ttl() {
207        let enricher = ConfluenceSchemaEnricher::new();
208        let model = enricher.value_model("get_knowledge_base_page").unwrap();
209        assert_eq!(model.side_effect_class, SideEffectClass::ReadOnly);
210        assert_eq!(model.value_class, ValueClass::Critical);
211        assert!(model.cost_model.freshness_ttl_s.unwrap_or(0) >= 300);
212        assert!(model.side_effect_class.is_speculatable());
213        assert_eq!(model.cost_model.max_kb, Some(80.0));
214    }
215
216    #[test]
217    fn paper3_mutating_endpoints_are_never_speculatable() {
218        let enricher = ConfluenceSchemaEnricher::new();
219        for tool_name in ["create_knowledge_base_page", "update_knowledge_base_page"] {
220            let model = enricher.value_model(tool_name).unwrap();
221            assert_eq!(model.side_effect_class, SideEffectClass::MutatesExternal);
222            assert!(!model.side_effect_class.is_speculatable());
223            assert!(
224                model
225                    .invalidates
226                    .iter()
227                    .any(|t| t == "get_knowledge_base_page")
228            );
229        }
230    }
231}