cel_brief/sources/
tool_catalog.rs1use async_trait::async_trait;
9
10use crate::source::{Contribution, ContributionContent, Source, SourceError};
11use crate::types::{BriefContext, Priority, SourceId, ToolSchema};
12
13#[derive(Debug, Clone)]
19pub struct ToolCatalogSource {
20 id: SourceId,
21 tools: Vec<ToolSchema>,
22}
23
24impl ToolCatalogSource {
25 pub fn new(tools: impl IntoIterator<Item = ToolSchema>) -> Self {
29 let id = SourceId::new("tool_catalog");
30 let tools = tools
31 .into_iter()
32 .map(|mut t| {
33 t.source = id.clone();
34 t
35 })
36 .collect();
37 ToolCatalogSource { id, tools }
38 }
39
40 pub fn with_id(mut self, id: impl Into<SourceId>) -> Self {
43 self.id = id.into();
44 for tool in &mut self.tools {
45 tool.source = self.id.clone();
46 }
47 self
48 }
49
50 pub fn tools(&self) -> &[ToolSchema] {
52 &self.tools
53 }
54
55 fn estimate_tokens(tool: &ToolSchema) -> usize {
57 let schema_len = serde_json::to_string(&tool.input_schema)
59 .map(|s| s.len())
60 .unwrap_or(0);
61 (tool.description.len() + schema_len + tool.name.len()).div_ceil(4)
62 }
63}
64
65#[async_trait]
66impl Source for ToolCatalogSource {
67 fn id(&self) -> SourceId {
68 self.id.clone()
69 }
70
71 fn priority(&self) -> Priority {
72 Priority::High
73 }
74
75 async fn contribute(&self, _ctx: &BriefContext) -> Result<Vec<Contribution>, SourceError> {
76 if self.tools.is_empty() {
77 return Err(SourceError::Skipped("empty tool catalog".into()));
78 }
79 Ok(self
80 .tools
81 .iter()
82 .map(|tool| {
83 let est = Self::estimate_tokens(tool);
84 Contribution {
85 content: ContributionContent::Tool {
86 schema: tool.clone(),
87 },
88 estimated_tokens: est,
89 importance: 0.9,
90 redactable: false,
91 tags: vec!["tool".into()],
92 }
93 })
94 .collect())
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101 use crate::types::TokenBudget;
102 use serde_json::json;
103
104 fn schema(name: &str) -> ToolSchema {
105 ToolSchema {
106 name: name.into(),
107 description: format!("Test tool {name}"),
108 input_schema: json!({"type":"object","properties":{}}),
109 source: SourceId::new("__unset__"),
110 }
111 }
112
113 #[tokio::test]
114 async fn emits_one_contribution_per_tool() {
115 let src = ToolCatalogSource::new(vec![schema("alpha"), schema("beta")]);
116 let ctx = BriefContext::new(TokenBudget::default());
117 let contributions = src.contribute(&ctx).await.expect("ok");
118 assert_eq!(contributions.len(), 2);
119
120 for c in &contributions {
121 assert_eq!(c.importance, 0.9);
122 assert!(!c.redactable);
123 match &c.content {
124 ContributionContent::Tool { schema } => {
125 assert_eq!(schema.source, SourceId::new("tool_catalog"));
126 }
127 other => panic!("expected Tool, got {other:?}"),
128 }
129 }
130 }
131
132 #[tokio::test]
133 async fn empty_catalog_is_skipped() {
134 let src = ToolCatalogSource::new(Vec::<ToolSchema>::new());
135 let ctx = BriefContext::new(TokenBudget::default());
136 match src.contribute(&ctx).await {
137 Err(SourceError::Skipped(_)) => {}
138 other => panic!("expected Skipped, got {other:?}"),
139 }
140 }
141
142 #[tokio::test]
143 async fn with_id_rewrites_tool_source() {
144 let src = ToolCatalogSource::new(vec![schema("alpha")]).with_id("example_tools");
145 assert_eq!(src.id(), SourceId::new("example_tools"));
146 let ctx = BriefContext::new(TokenBudget::default());
147 let contributions = src.contribute(&ctx).await.expect("ok");
148 match &contributions[0].content {
149 ContributionContent::Tool { schema } => {
150 assert_eq!(schema.source, SourceId::new("example_tools"));
151 }
152 other => panic!("expected Tool, got {other:?}"),
153 }
154 }
155
156 #[tokio::test]
157 async fn priority_is_high() {
158 let src = ToolCatalogSource::new(vec![schema("alpha")]);
159 assert_eq!(src.priority(), Priority::High);
160 }
161}