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 host_prompt_allowed: bool,
56}
57
58impl ActivationCapability {
59 pub fn new(
60 catalog: ToolCatalog,
61 session_set: SharedSessionToolSet,
62 authority: ActivationAuthority,
63 ) -> Self {
64 Self {
65 catalog,
66 session_set,
67 authority,
68 host_prompt_allowed: true,
69 }
70 }
71
72 pub fn with_host_prompt(mut self, allowed: bool) -> Self {
76 self.host_prompt_allowed = allowed;
77 self
78 }
79
80 pub fn host_prompt_allowed(&self) -> bool {
81 self.host_prompt_allowed
82 }
83
84 pub fn catalog(&self) -> &ToolCatalog {
85 &self.catalog
86 }
87
88 pub fn session_set(&self) -> &SharedSessionToolSet {
89 &self.session_set
90 }
91
92 pub fn authority(&self) -> ActivationAuthority {
93 self.authority
94 }
95
96 pub fn revoke_exact_mcp_grant(
102 &self,
103 session: &crate::tools::activation::SessionId,
104 server: &str,
105 server_tool_name: &str,
106 ) -> bool {
107 let mut set = self
108 .session_set
109 .write()
110 .unwrap_or_else(std::sync::PoisonError::into_inner);
111 if set.session() != session {
112 return false;
113 }
114 set.revoke_exact(&ToolId::mcp(server, server_tool_name))
115 .is_ok()
116 }
117
118 pub fn revoke_exact_extension_grant(
124 &self,
125 session: &crate::tools::activation::SessionId,
126 plugin_id: &str,
127 tool_name: &str,
128 ) -> bool {
129 let mut set = self
130 .session_set
131 .write()
132 .unwrap_or_else(std::sync::PoisonError::into_inner);
133 if set.session() != session {
134 return false;
135 }
136 set.revoke_exact(&ToolId::extension(plugin_id, tool_name))
137 .is_ok()
138 }
139}
140
141impl std::fmt::Debug for ActivationCapability {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 f.debug_struct("ActivationCapability")
144 .field("authority", &self.authority)
145 .finish_non_exhaustive()
146 }
147}
148
149fn require_capability(ctx: &ToolContext) -> Result<ActivationCapability> {
150 ctx.capabilities.tool_activation.clone().ok_or_else(|| {
151 RuntimeError::Tool("tool discovery/activation is not available in this context".to_string())
152 })
153}
154
155async fn confirm_activation_with_host(
165 prompt: Option<&crate::tools::SecretPromptHandle>,
166 tool_ids: &[ToolId],
167) -> ActivationAuthority {
168 let Some(prompt) = prompt else {
169 return ActivationAuthority::Unauthorized;
170 };
171 let mut ids: Vec<&str> = tool_ids.iter().map(ToolId::as_str).collect();
172 ids.sort_unstable();
173 let question = format!(
174 "The model requests session-scoped activation of {} exact tool id(s):\n{}\n\
175 Type y/yes to allow exactly these tools; anything else denies.",
176 ids.len(),
177 ids.join("\n")
178 );
179 match prompt
180 .prompt(
181 crate::session::CONFIRM_ACTIVATION_PROMPT_TITLE.to_string(),
182 question,
183 )
184 .await
185 {
186 Some(answer) => {
187 let normalized = answer.trim().to_ascii_lowercase();
188 if normalized == "y" || normalized == "yes" {
189 ActivationAuthority::ModelConfirmed
190 } else {
191 ActivationAuthority::Unauthorized
192 }
193 }
194 None => ActivationAuthority::Unauthorized,
195 }
196}
197
198fn require_query(params: &Value) -> Result<DiscoveryQuery> {
199 let raw = params["query"]
200 .as_str()
201 .ok_or_else(|| RuntimeError::Tool("Missing 'query' parameter (string)".to_string()))?;
202 DiscoveryQuery::parse(raw)
203 .map_err(|err| RuntimeError::Tool(format!("invalid discovery query: {err}")))
204}
205
206pub struct SearchToolsTool;
212
213#[async_trait::async_trait]
214impl Tool for SearchToolsTool {
215 fn effect(&self) -> crate::tools::catalog::ToolEffect {
216 crate::tools::catalog::ToolEffect::ReadOnly
217 }
218
219 fn name(&self) -> &str {
220 "search_tools"
221 }
222
223 fn description(&self) -> &str {
224 "Search locally known tools by keyword. Returns bounded compact \
225 descriptors with stable tool ids (never full schemas). Use \
226 activate_tools with an exact returned id to request activation."
227 }
228
229 fn origin(&self) -> ToolOrigin {
230 ToolOrigin::Builtin
231 }
232
233 fn parameters(&self) -> Value {
234 json!({
235 "type": "object",
236 "properties": {
237 "query": {
238 "type": "string",
239 "description": "Case-insensitive keyword matched against tool ids, summaries, and tags."
240 }
241 },
242 "required": ["query"]
243 })
244 }
245
246 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
247 let capability = require_capability(&ctx)?;
248 let query = require_query(¶ms)?;
249 let limits = SearchLimits::new(SEARCH_TOOLS_MAX_RESULTS, SEARCH_TOOLS_MAX_RESULT_BYTES)
250 .expect("static search_tools budgets are within caps");
251 let index = DiscoveryIndex::build(capability.catalog())
252 .map_err(|err| RuntimeError::Tool(format!("tool discovery unavailable: {err}")))?;
253 let results = index.search(&query, &limits);
254 let body = json!({
255 "generation": results.generation(),
256 "truncated": results.truncated(),
257 "tools": results.hits(),
258 });
259 serde_json::to_string(&body)
260 .map_err(|err| RuntimeError::Tool(format!("failed to serialize search results: {err}")))
261 }
262}
263
264pub struct ActivateToolsTool;
273
274#[async_trait::async_trait]
275impl Tool for ActivateToolsTool {
276 fn name(&self) -> &str {
277 "activate_tools"
278 }
279
280 fn description(&self) -> &str {
281 "Request session-scoped activation of exact tool ids returned by \
282 search_tools. Activation is subject to the host's \
283 tools.activation_confirm policy (auto: granted; prompt: the user is \
284 asked y/n; deny: always refused); only the exact requested tools are \
285 activated for this session."
286 }
287
288 fn origin(&self) -> ToolOrigin {
289 ToolOrigin::Builtin
290 }
291
292 fn parameters(&self) -> Value {
293 json!({
294 "type": "object",
295 "properties": {
296 "tools": {
297 "type": "array",
298 "items": {"type": "string"},
299 "description": "Exact stable tool ids (namespace:name) from search_tools. No wildcards."
300 }
301 },
302 "required": ["tools"]
303 })
304 }
305
306 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
307 let capability = require_capability(&ctx)?;
308 let requested = params["tools"].as_array().ok_or_else(|| {
309 RuntimeError::Tool("Missing 'tools' parameter (array of tool id strings)".to_string())
310 })?;
311 if requested.is_empty() {
312 return Err(RuntimeError::Tool(
313 "no tool ids requested: 'tools' must name at least one exact id".to_string(),
314 ));
315 }
316 if requested.len() > ACTIVATE_TOOLS_MAX_BATCH {
317 return Err(RuntimeError::Tool(format!(
318 "too many tool ids requested: {} exceeds limit {ACTIVATE_TOOLS_MAX_BATCH}",
319 requested.len()
320 )));
321 }
322 let mut tool_ids = Vec::with_capacity(requested.len());
326 for entry in requested {
327 let raw = entry
328 .as_str()
329 .ok_or_else(|| RuntimeError::Tool("tool ids must be strings".to_string()))?;
330 let id = ToolId::parse(raw).map_err(|err| {
331 RuntimeError::Tool(format!(
332 "invalid tool id {:?}: {err} (exact namespace:name ids only; \
333 wildcards and source-wide requests are not accepted)",
334 BoundedText::new(raw, ID_ECHO_MAX_BYTES).text
335 ))
336 })?;
337 tool_ids.push(id);
338 }
339 let authority = match capability.authority() {
350 ActivationAuthority::ModelConfirmed => ActivationAuthority::ModelConfirmed,
351 ActivationAuthority::Unauthorized if !capability.host_prompt_allowed() => {
352 ActivationAuthority::Unauthorized
353 }
354 ActivationAuthority::Unauthorized => {
355 confirm_activation_with_host(ctx.capabilities.secret_prompt.as_ref(), &tool_ids)
356 .await
357 }
358 };
359 let (activated, schema_generation) = {
365 let mut set = capability
366 .session_set()
367 .write()
368 .unwrap_or_else(std::sync::PoisonError::into_inner);
369 let applied =
370 activate_model_initiated(authority, &mut set, capability.catalog(), &tool_ids)
371 .map_err(|err| RuntimeError::Tool(format!("tool activation denied: {err}")))?;
372 (applied, set.schema_generation())
373 };
374 let ids: Vec<&str> = {
375 let mut sorted: Vec<&ToolId> = tool_ids.iter().collect();
376 sorted.sort();
377 sorted.into_iter().map(ToolId::as_str).collect()
378 };
379 let body = json!({
380 "activated": ids,
381 "count": activated,
382 "schema_generation": schema_generation,
383 });
384 serde_json::to_string(&body).map_err(|err| {
385 RuntimeError::Tool(format!("failed to serialize activation result: {err}"))
386 })
387 }
388}