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 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}