Skip to main content

bob_runtime/
progressive_tools.rs

1//! # Progressive Tool View
2//!
3//! Token-efficient tool definition management for LLM requests.
4//!
5//! ## Problem
6//!
7//! Sending full JSON Schema definitions for all tools on every LLM request
8//! wastes significant tokens. A workspace with 10+ MCP tools can easily
9//! consume 700+ tokens on schema alone — even for simple chat messages
10//! that don't need any tools.
11//!
12//! ## Solution
13//!
14//! `ProgressiveToolView` implements a **"menu first, recipe on demand"** strategy:
15//!
16//! 1. **Initial state** (~50 tokens): system prompt includes only tool names and one-line
17//!    descriptions.
18//! 2. **On activation**: when the LLM mentions `$tool_name` or actually calls a tool, that tool's
19//!    full schema is included in subsequent requests.
20//!
21//! This typically saves **90%+** of tool-related token usage for simple
22//! conversations, and progressively reveals schemas as the agent needs them.
23
24use std::collections::HashSet;
25
26use bob_core::types::ToolDescriptor;
27
28/// Token-efficient tool view that progressively reveals tool schemas.
29///
30/// Only activated tools have their full JSON Schema included in LLM requests.
31/// Inactive tools appear only as compact name + description entries in the
32/// system prompt.
33#[derive(Debug)]
34pub struct ProgressiveToolView {
35    /// All available tools (full descriptors).
36    all_tools: Vec<ToolDescriptor>,
37    /// Set of tool IDs that have been activated (full schema should be sent).
38    activated: HashSet<String>,
39}
40
41impl ProgressiveToolView {
42    /// Create a new progressive view from the full tool list.
43    ///
44    /// All tools start as inactive (compact view only).
45    #[must_use]
46    pub fn new(tools: Vec<ToolDescriptor>) -> Self {
47        Self { all_tools: tools, activated: HashSet::new() }
48    }
49
50    /// Activate a specific tool by ID (its full schema will be sent).
51    pub fn activate(&mut self, tool_id: &str) {
52        self.activated.insert(tool_id.to_string());
53    }
54
55    /// Scan LLM output text for `$tool_name` hints and activate mentioned tools.
56    ///
57    /// This allows the LLM to signal interest in a tool before actually calling it,
58    /// so the full schema is available on the next turn.
59    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    /// Returns `true` if the given tool has been activated.
69    #[must_use]
70    pub fn is_activated(&self, tool_id: &str) -> bool {
71        self.activated.contains(tool_id)
72    }
73
74    /// Returns the number of currently activated tools.
75    #[must_use]
76    pub fn activated_count(&self) -> usize {
77        self.activated.len()
78    }
79
80    /// Returns the total number of tools in the view.
81    #[must_use]
82    pub fn total_count(&self) -> usize {
83        self.all_tools.len()
84    }
85
86    /// Returns a compact summary prompt listing all tools by name and
87    /// description, suitable for injection into the system prompt.
88    ///
89    /// Returns an empty string when no tools are available.
90    #[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    /// Returns full descriptors for activated tools only.
107    ///
108    /// These are the tools whose complete JSON Schema should be sent to the LLM
109    /// (either in the prompt or via native tool calling API).
110    #[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    /// Returns all tool descriptors regardless of activation state.
116    #[must_use]
117    pub fn all_tools(&self) -> &[ToolDescriptor] {
118        &self.all_tools
119    }
120}
121
122// ── Tests ────────────────────────────────────────────────────────────
123
124#[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}