bob_runtime/
progressive_tools.rs1use std::collections::HashSet;
25
26use bob_core::types::ToolDescriptor;
27
28#[derive(Debug)]
34pub struct ProgressiveToolView {
35 all_tools: Vec<ToolDescriptor>,
37 activated: HashSet<String>,
39}
40
41impl ProgressiveToolView {
42 #[must_use]
46 pub fn new(tools: Vec<ToolDescriptor>) -> Self {
47 Self { all_tools: tools, activated: HashSet::new() }
48 }
49
50 pub fn activate(&mut self, tool_id: &str) {
52 self.activated.insert(tool_id.to_string());
53 }
54
55 pub fn activate_hints(&mut self, text: &str) {
60 for tool in &self.all_tools {
61 let hint = format!("${}", tool.id);
62 if text.contains(&hint) {
63 self.activated.insert(tool.id.clone());
64 }
65 }
66 }
67
68 #[must_use]
70 pub fn is_activated(&self, tool_id: &str) -> bool {
71 self.activated.contains(tool_id)
72 }
73
74 #[must_use]
76 pub fn activated_count(&self) -> usize {
77 self.activated.len()
78 }
79
80 #[must_use]
82 pub fn total_count(&self) -> usize {
83 self.all_tools.len()
84 }
85
86 #[must_use]
91 pub fn summary_prompt(&self) -> String {
92 if self.all_tools.is_empty() {
93 return String::new();
94 }
95
96 let mut buf =
97 String::from("<tool_view>\nAvailable tools (use $name to request full schema):\n");
98 for tool in &self.all_tools {
99 let marker = if self.activated.contains(&tool.id) { " [active]" } else { "" };
100 buf.push_str(&format!(" - {}: {}{}\n", tool.id, tool.description, marker));
101 }
102 buf.push_str("</tool_view>");
103 buf
104 }
105
106 #[must_use]
111 pub fn activated_tools(&self) -> Vec<ToolDescriptor> {
112 self.all_tools.iter().filter(|t| self.activated.contains(&t.id)).cloned().collect()
113 }
114
115 #[must_use]
117 pub fn all_tools(&self) -> &[ToolDescriptor] {
118 &self.all_tools
119 }
120}
121
122#[cfg(test)]
125mod tests {
126 use bob_core::types::ToolSource;
127 use serde_json::json;
128
129 use super::*;
130
131 fn make_tool(id: &str, desc: &str) -> ToolDescriptor {
132 ToolDescriptor {
133 id: id.to_string(),
134 description: desc.to_string(),
135 input_schema: json!({"type": "object", "properties": {"path": {"type": "string"}}}),
136 source: ToolSource::Local,
137 }
138 }
139
140 #[test]
141 fn new_view_has_no_activated_tools() {
142 let view = ProgressiveToolView::new(vec![
143 make_tool("file.read", "Read a file"),
144 make_tool("shell.exec", "Run a command"),
145 ]);
146
147 assert_eq!(view.activated_count(), 0);
148 assert_eq!(view.total_count(), 2);
149 assert!(view.activated_tools().is_empty());
150 }
151
152 #[test]
153 fn activate_adds_tool_to_active_set() {
154 let mut view = ProgressiveToolView::new(vec![
155 make_tool("file.read", "Read a file"),
156 make_tool("shell.exec", "Run a command"),
157 ]);
158
159 view.activate("file.read");
160
161 assert_eq!(view.activated_count(), 1);
162 assert!(view.is_activated("file.read"));
163 assert!(!view.is_activated("shell.exec"));
164
165 let active = view.activated_tools();
166 assert_eq!(active.len(), 1);
167 assert_eq!(active[0].id, "file.read");
168 }
169
170 #[test]
171 fn activate_hints_detects_dollar_prefix() {
172 let mut view = ProgressiveToolView::new(vec![
173 make_tool("file.read", "Read a file"),
174 make_tool("shell.exec", "Run a command"),
175 ]);
176
177 view.activate_hints("I'll use $file.read to check the config");
178
179 assert!(view.is_activated("file.read"));
180 assert!(!view.is_activated("shell.exec"));
181 }
182
183 #[test]
184 fn summary_prompt_lists_all_tools() {
185 let mut view = ProgressiveToolView::new(vec![
186 make_tool("file.read", "Read a file"),
187 make_tool("shell.exec", "Run a command"),
188 ]);
189
190 view.activate("file.read");
191 let summary = view.summary_prompt();
192
193 assert!(summary.contains("file.read"));
194 assert!(summary.contains("shell.exec"));
195 assert!(summary.contains("[active]"));
196 assert!(summary.contains("<tool_view>"));
197 }
198
199 #[test]
200 fn empty_tool_list_produces_empty_summary() {
201 let view = ProgressiveToolView::new(vec![]);
202 assert!(view.summary_prompt().is_empty());
203 }
204
205 #[test]
206 fn duplicate_activation_is_idempotent() {
207 let mut view = ProgressiveToolView::new(vec![make_tool("file.read", "Read a file")]);
208
209 view.activate("file.read");
210 view.activate("file.read");
211
212 assert_eq!(view.activated_count(), 1);
213 }
214}