agent_engine/tools/
discovery.rs1use serde_json::{json, Value};
28
29use super::activation::{activate_model_initiated, ActivationAuthority, SharedSessionToolSet};
30use super::catalog::{DiscoveryIndex, DiscoveryQuery, SearchLimits, ToolCatalog, ToolId};
31use super::{Tool, ToolContext, ToolOrigin};
32use crate::{Result, RuntimeError};
33use agent_core::BoundedText;
34
35pub const SEARCH_TOOLS_MAX_RESULTS: usize = 16;
37pub const SEARCH_TOOLS_MAX_RESULT_BYTES: usize = 8 * 1024;
39pub const ACTIVATE_TOOLS_MAX_BATCH: usize = 16;
41const ID_ECHO_MAX_BYTES: usize = 128;
43
44#[derive(Clone)]
48pub struct ActivationCapability {
49 catalog: ToolCatalog,
50 session_set: SharedSessionToolSet,
51 authority: ActivationAuthority,
52}
53
54impl ActivationCapability {
55 pub fn new(
56 catalog: ToolCatalog,
57 session_set: SharedSessionToolSet,
58 authority: ActivationAuthority,
59 ) -> Self {
60 Self {
61 catalog,
62 session_set,
63 authority,
64 }
65 }
66
67 pub fn catalog(&self) -> &ToolCatalog {
68 &self.catalog
69 }
70
71 pub fn session_set(&self) -> &SharedSessionToolSet {
72 &self.session_set
73 }
74
75 pub fn authority(&self) -> ActivationAuthority {
76 self.authority
77 }
78
79 pub fn revoke_exact_mcp_grant(
85 &self,
86 session: &crate::tools::activation::SessionId,
87 server: &str,
88 server_tool_name: &str,
89 ) -> bool {
90 let mut set = self
91 .session_set
92 .write()
93 .unwrap_or_else(std::sync::PoisonError::into_inner);
94 if set.session() != session {
95 return false;
96 }
97 set.revoke_exact(&ToolId::mcp(server, server_tool_name))
98 .is_ok()
99 }
100
101 pub fn revoke_exact_extension_grant(
107 &self,
108 session: &crate::tools::activation::SessionId,
109 plugin_id: &str,
110 tool_name: &str,
111 ) -> bool {
112 let mut set = self
113 .session_set
114 .write()
115 .unwrap_or_else(std::sync::PoisonError::into_inner);
116 if set.session() != session {
117 return false;
118 }
119 set.revoke_exact(&ToolId::extension(plugin_id, tool_name))
120 .is_ok()
121 }
122}
123
124impl std::fmt::Debug for ActivationCapability {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 f.debug_struct("ActivationCapability")
127 .field("authority", &self.authority)
128 .finish_non_exhaustive()
129 }
130}
131
132fn require_capability(ctx: &ToolContext) -> Result<ActivationCapability> {
133 ctx.capabilities.tool_activation.clone().ok_or_else(|| {
134 RuntimeError::Tool("tool discovery/activation is not available in this context".to_string())
135 })
136}
137
138async fn confirm_activation_with_host(
148 prompt: Option<&crate::tools::SecretPromptHandle>,
149 tool_ids: &[ToolId],
150) -> ActivationAuthority {
151 let Some(prompt) = prompt else {
152 return ActivationAuthority::Unauthorized;
153 };
154 let mut ids: Vec<&str> = tool_ids.iter().map(ToolId::as_str).collect();
155 ids.sort_unstable();
156 let question = format!(
157 "The model requests session-scoped activation of {} exact tool id(s):\n{}\n\
158 Type y/yes to allow exactly these tools; anything else denies.",
159 ids.len(),
160 ids.join("\n")
161 );
162 match prompt
163 .prompt("Confirm tool activation".to_string(), question)
164 .await
165 {
166 Some(answer) => {
167 let normalized = answer.trim().to_ascii_lowercase();
168 if normalized == "y" || normalized == "yes" {
169 ActivationAuthority::ModelConfirmed
170 } else {
171 ActivationAuthority::Unauthorized
172 }
173 }
174 None => ActivationAuthority::Unauthorized,
175 }
176}
177
178fn require_query(params: &Value) -> Result<DiscoveryQuery> {
179 let raw = params["query"]
180 .as_str()
181 .ok_or_else(|| RuntimeError::Tool("Missing 'query' parameter (string)".to_string()))?;
182 DiscoveryQuery::parse(raw)
183 .map_err(|err| RuntimeError::Tool(format!("invalid discovery query: {err}")))
184}
185
186pub struct SearchToolsTool;
192
193#[async_trait::async_trait]
194impl Tool for SearchToolsTool {
195 fn effect(&self) -> crate::tools::catalog::ToolEffect {
196 crate::tools::catalog::ToolEffect::ReadOnly
197 }
198
199 fn name(&self) -> &str {
200 "search_tools"
201 }
202
203 fn description(&self) -> &str {
204 "Search locally known tools by keyword. Returns bounded compact \
205 descriptors with stable tool ids (never full schemas). Use \
206 activate_tools with an exact returned id to request activation."
207 }
208
209 fn origin(&self) -> ToolOrigin {
210 ToolOrigin::Builtin
211 }
212
213 fn parameters(&self) -> Value {
214 json!({
215 "type": "object",
216 "properties": {
217 "query": {
218 "type": "string",
219 "description": "Case-insensitive keyword matched against tool ids, summaries, and tags."
220 }
221 },
222 "required": ["query"]
223 })
224 }
225
226 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
227 let capability = require_capability(&ctx)?;
228 let query = require_query(¶ms)?;
229 let limits = SearchLimits::new(SEARCH_TOOLS_MAX_RESULTS, SEARCH_TOOLS_MAX_RESULT_BYTES)
230 .expect("static search_tools budgets are within caps");
231 let index = DiscoveryIndex::build(capability.catalog())
232 .map_err(|err| RuntimeError::Tool(format!("tool discovery unavailable: {err}")))?;
233 let results = index.search(&query, &limits);
234 let body = json!({
235 "generation": results.generation(),
236 "truncated": results.truncated(),
237 "tools": results.hits(),
238 });
239 serde_json::to_string(&body)
240 .map_err(|err| RuntimeError::Tool(format!("failed to serialize search results: {err}")))
241 }
242}
243
244pub struct ActivateToolsTool;
253
254#[async_trait::async_trait]
255impl Tool for ActivateToolsTool {
256 fn name(&self) -> &str {
257 "activate_tools"
258 }
259
260 fn description(&self) -> &str {
261 "Request session-scoped activation of exact tool ids returned by \
262 search_tools. Activation is subject to host confirmation policy; \
263 only the exact requested tools are activated for this session."
264 }
265
266 fn origin(&self) -> ToolOrigin {
267 ToolOrigin::Builtin
268 }
269
270 fn parameters(&self) -> Value {
271 json!({
272 "type": "object",
273 "properties": {
274 "tools": {
275 "type": "array",
276 "items": {"type": "string"},
277 "description": "Exact stable tool ids (namespace:name) from search_tools. No wildcards."
278 }
279 },
280 "required": ["tools"]
281 })
282 }
283
284 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
285 let capability = require_capability(&ctx)?;
286 let requested = params["tools"].as_array().ok_or_else(|| {
287 RuntimeError::Tool("Missing 'tools' parameter (array of tool id strings)".to_string())
288 })?;
289 if requested.is_empty() {
290 return Err(RuntimeError::Tool(
291 "no tool ids requested: 'tools' must name at least one exact id".to_string(),
292 ));
293 }
294 if requested.len() > ACTIVATE_TOOLS_MAX_BATCH {
295 return Err(RuntimeError::Tool(format!(
296 "too many tool ids requested: {} exceeds limit {ACTIVATE_TOOLS_MAX_BATCH}",
297 requested.len()
298 )));
299 }
300 let mut tool_ids = Vec::with_capacity(requested.len());
304 for entry in requested {
305 let raw = entry
306 .as_str()
307 .ok_or_else(|| RuntimeError::Tool("tool ids must be strings".to_string()))?;
308 let id = ToolId::parse(raw).map_err(|err| {
309 RuntimeError::Tool(format!(
310 "invalid tool id {:?}: {err} (exact namespace:name ids only; \
311 wildcards and source-wide requests are not accepted)",
312 BoundedText::new(raw, ID_ECHO_MAX_BYTES).text
313 ))
314 })?;
315 tool_ids.push(id);
316 }
317 let authority = match capability.authority() {
326 ActivationAuthority::ModelConfirmed => ActivationAuthority::ModelConfirmed,
327 ActivationAuthority::Unauthorized => {
328 confirm_activation_with_host(ctx.capabilities.secret_prompt.as_ref(), &tool_ids)
329 .await
330 }
331 };
332 let (activated, schema_generation) = {
338 let mut set = capability
339 .session_set()
340 .write()
341 .unwrap_or_else(std::sync::PoisonError::into_inner);
342 let applied =
343 activate_model_initiated(authority, &mut set, capability.catalog(), &tool_ids)
344 .map_err(|err| RuntimeError::Tool(format!("tool activation denied: {err}")))?;
345 (applied, set.schema_generation())
346 };
347 let ids: Vec<&str> = {
348 let mut sorted: Vec<&ToolId> = tool_ids.iter().collect();
349 sorted.sort();
350 sorted.into_iter().map(ToolId::as_str).collect()
351 };
352 let body = json!({
353 "activated": ids,
354 "count": activated,
355 "schema_generation": schema_generation,
356 });
357 serde_json::to_string(&body).map_err(|err| {
358 RuntimeError::Tool(format!("failed to serialize activation result: {err}"))
359 })
360 }
361}