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 serde_json::json;
127
128 use super::*;
129
130 fn make_tool(id: &str, desc: &str) -> ToolDescriptor {
131 ToolDescriptor::new(id, desc).with_input_schema(
132 json!({"type": "object", "properties": {"path": {"type": "string"}}}),
133 )
134 }
135
136 #[test]
137 fn new_view_has_no_activated_tools() {
138 let view = ProgressiveToolView::new(vec![
139 make_tool("file.read", "Read a file"),
140 make_tool("shell.exec", "Run a command"),
141 ]);
142
143 assert_eq!(view.activated_count(), 0);
144 assert_eq!(view.total_count(), 2);
145 assert!(view.activated_tools().is_empty());
146 }
147
148 #[test]
149 fn activate_adds_tool_to_active_set() {
150 let mut view = ProgressiveToolView::new(vec![
151 make_tool("file.read", "Read a file"),
152 make_tool("shell.exec", "Run a command"),
153 ]);
154
155 view.activate("file.read");
156
157 assert_eq!(view.activated_count(), 1);
158 assert!(view.is_activated("file.read"));
159 assert!(!view.is_activated("shell.exec"));
160
161 let active = view.activated_tools();
162 assert_eq!(active.len(), 1);
163 assert_eq!(active[0].id, "file.read");
164 }
165
166 #[test]
167 fn activate_hints_detects_dollar_prefix() {
168 let mut view = ProgressiveToolView::new(vec![
169 make_tool("file.read", "Read a file"),
170 make_tool("shell.exec", "Run a command"),
171 ]);
172
173 view.activate_hints("I'll use $file.read to check the config");
174
175 assert!(view.is_activated("file.read"));
176 assert!(!view.is_activated("shell.exec"));
177 }
178
179 #[test]
180 fn summary_prompt_lists_all_tools() {
181 let mut view = ProgressiveToolView::new(vec![
182 make_tool("file.read", "Read a file"),
183 make_tool("shell.exec", "Run a command"),
184 ]);
185
186 view.activate("file.read");
187 let summary = view.summary_prompt();
188
189 assert!(summary.contains("file.read"));
190 assert!(summary.contains("shell.exec"));
191 assert!(summary.contains("[active]"));
192 assert!(summary.contains("<tool_view>"));
193 }
194
195 #[test]
196 fn empty_tool_list_produces_empty_summary() {
197 let view = ProgressiveToolView::new(vec![]);
198 assert!(view.summary_prompt().is_empty());
199 }
200
201 #[test]
202 fn duplicate_activation_is_idempotent() {
203 let mut view = ProgressiveToolView::new(vec![make_tool("file.read", "Read a file")]);
204
205 view.activate("file.read");
206 view.activate("file.read");
207
208 assert_eq!(view.activated_count(), 1);
209 }
210}