devboy_confluence/
enricher.rs1use devboy_core::{
9 CostModel, FollowUpLink, SideEffectClass, ToolCategory, ToolEnricher, ToolSchema,
10 ToolValueModel, ValueClass,
11};
12use serde_json::Value;
13
14pub struct ConfluenceSchemaEnricher;
20
21impl ConfluenceSchemaEnricher {
22 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 }
44
45 fn transform_args(&self, _tool_name: &str, _args: &mut Value) {
46 }
48
49 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 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 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}