use devboy_core::{
CostModel, FollowUpLink, SideEffectClass, ToolCategory, ToolEnricher, ToolSchema,
ToolValueModel, ValueClass,
};
use serde_json::Value;
pub struct ConfluenceSchemaEnricher;
impl ConfluenceSchemaEnricher {
pub fn new() -> Self {
Self
}
}
impl Default for ConfluenceSchemaEnricher {
fn default() -> Self {
Self::new()
}
}
impl ToolEnricher for ConfluenceSchemaEnricher {
fn supported_categories(&self) -> &[ToolCategory] {
&[ToolCategory::KnowledgeBase]
}
fn enrich_schema(&self, _tool_name: &str, _schema: &mut ToolSchema) {
}
fn transform_args(&self, _tool_name: &str, _args: &mut Value) {
}
fn value_model(&self, tool_name: &str) -> Option<ToolValueModel> {
let model = match tool_name {
"search_knowledge_base" => ToolValueModel {
value_class: ValueClass::Supporting,
cost_model: CostModel {
typical_kb: 4.0,
max_kb: Some(40.0),
freshness_ttl_s: Some(60),
..CostModel::default()
},
follow_up: vec![FollowUpLink {
tool: "get_knowledge_base_page".into(),
probability: 0.55,
projection: Some("id".into()),
projection_arg: Some("pageId".into()),
}],
side_effect_class: SideEffectClass::ReadOnly,
..ToolValueModel::default()
},
"list_knowledge_base_pages" => ToolValueModel {
value_class: ValueClass::Supporting,
cost_model: CostModel {
typical_kb: 3.0,
max_kb: Some(30.0),
freshness_ttl_s: Some(60),
..CostModel::default()
},
follow_up: vec![FollowUpLink {
tool: "get_knowledge_base_page".into(),
probability: 0.50,
projection: Some("id".into()),
projection_arg: Some("pageId".into()),
}],
side_effect_class: SideEffectClass::ReadOnly,
..ToolValueModel::default()
},
"get_knowledge_base_page" => ToolValueModel {
value_class: ValueClass::Critical,
cost_model: CostModel {
typical_kb: 8.0,
max_kb: Some(80.0),
freshness_ttl_s: Some(300),
..CostModel::default()
},
side_effect_class: SideEffectClass::ReadOnly,
..ToolValueModel::default()
},
"get_knowledge_base_spaces" => ToolValueModel {
value_class: ValueClass::Supporting,
cost_model: CostModel {
typical_kb: 1.5,
max_kb: Some(15.0),
freshness_ttl_s: Some(1800),
..CostModel::default()
},
side_effect_class: SideEffectClass::ReadOnly,
..ToolValueModel::default()
},
"create_knowledge_base_page" | "update_knowledge_base_page" => ToolValueModel {
value_class: ValueClass::Critical,
cost_model: CostModel {
typical_kb: 8.0,
max_kb: Some(80.0),
..CostModel::default()
},
side_effect_class: SideEffectClass::MutatesExternal,
invalidates: vec!["get_knowledge_base_page".into()],
..ToolValueModel::default()
},
_ => return None,
};
Some(model)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn confluence_enricher_supports_knowledge_base_category() {
let enricher = ConfluenceSchemaEnricher::new();
assert_eq!(
enricher.supported_categories(),
&[ToolCategory::KnowledgeBase]
);
}
#[test]
fn confluence_enricher_leaves_schema_unchanged() {
let enricher = ConfluenceSchemaEnricher::new();
let original = json!({
"type": "object",
"properties": {
"spaceKey": { "type": "string" },
"parentId": { "type": "string" }
},
"required": ["spaceKey"]
});
let mut schema = ToolSchema::from_json(&original);
enricher.enrich_schema("list_knowledge_base_pages", &mut schema);
assert_eq!(schema.to_json(), original);
}
#[test]
fn confluence_enricher_leaves_args_unchanged() {
let enricher = ConfluenceSchemaEnricher::new();
let mut args = json!({
"query": "architecture",
"spaceKey": "ENG",
"rawQuery": false
});
let expected = args.clone();
enricher.transform_args("search_knowledge_base", &mut args);
assert_eq!(args, expected);
}
#[test]
fn paper3_search_chains_to_get_page_with_page_id_projection() {
let enricher = ConfluenceSchemaEnricher::new();
let model = enricher.value_model("search_knowledge_base").unwrap();
assert_eq!(model.follow_up.len(), 1);
let follow_up = &model.follow_up[0];
assert_eq!(follow_up.tool, "get_knowledge_base_page");
assert_eq!(follow_up.projection.as_deref(), Some("id"));
assert_eq!(follow_up.projection_arg.as_deref(), Some("pageId"));
}
#[test]
fn paper3_list_chains_to_get_page() {
let enricher = ConfluenceSchemaEnricher::new();
let model = enricher.value_model("list_knowledge_base_pages").unwrap();
assert_eq!(model.follow_up.len(), 1);
let follow_up = &model.follow_up[0];
assert_eq!(follow_up.tool, "get_knowledge_base_page");
}
#[test]
fn paper3_get_page_is_read_only_with_long_ttl() {
let enricher = ConfluenceSchemaEnricher::new();
let model = enricher.value_model("get_knowledge_base_page").unwrap();
assert_eq!(model.side_effect_class, SideEffectClass::ReadOnly);
assert_eq!(model.value_class, ValueClass::Critical);
assert!(model.cost_model.freshness_ttl_s.unwrap_or(0) >= 300);
assert!(model.side_effect_class.is_speculatable());
assert_eq!(model.cost_model.max_kb, Some(80.0));
}
#[test]
fn paper3_mutating_endpoints_are_never_speculatable() {
let enricher = ConfluenceSchemaEnricher::new();
for tool_name in ["create_knowledge_base_page", "update_knowledge_base_page"] {
let model = enricher.value_model(tool_name).unwrap();
assert_eq!(model.side_effect_class, SideEffectClass::MutatesExternal);
assert!(!model.side_effect_class.is_speculatable());
assert!(
model
.invalidates
.iter()
.any(|t| t == "get_knowledge_base_page")
);
}
}
}