1use serde_json::{json, Value};
28
29use super::activation::{
30 activate_model_initiated, ActivationAuthority, GrantIssuanceError, HostActivationError,
31 SharedSessionToolSet,
32};
33use super::catalog::{DiscoveryIndex, DiscoveryQuery, SearchLimits, ToolCatalog, ToolId};
34use super::{Tool, ToolContext, ToolOrigin, ToolOutput};
35use crate::{Result, RuntimeError};
36use agent_core::BoundedText;
37
38pub const SEARCH_TOOLS_MAX_RESULTS: usize = 16;
40pub const SEARCH_TOOLS_MAX_RESULT_BYTES: usize = 8 * 1024;
42pub const ACTIVATE_TOOLS_MAX_BATCH: usize = 16;
44const ID_ECHO_MAX_BYTES: usize = 128;
46
47#[derive(Clone)]
51pub struct ActivationCapability {
52 catalog: ToolCatalog,
53 session_set: SharedSessionToolSet,
54 authority: ActivationAuthority,
55 host_prompt_allowed: bool,
59}
60
61impl ActivationCapability {
62 pub fn new(
63 catalog: ToolCatalog,
64 session_set: SharedSessionToolSet,
65 authority: ActivationAuthority,
66 ) -> Self {
67 Self {
68 catalog,
69 session_set,
70 authority,
71 host_prompt_allowed: true,
72 }
73 }
74
75 pub fn with_host_prompt(mut self, allowed: bool) -> Self {
79 self.host_prompt_allowed = allowed;
80 self
81 }
82
83 pub fn host_prompt_allowed(&self) -> bool {
84 self.host_prompt_allowed
85 }
86
87 pub fn catalog(&self) -> &ToolCatalog {
88 &self.catalog
89 }
90
91 pub fn session_set(&self) -> &SharedSessionToolSet {
92 &self.session_set
93 }
94
95 pub fn authority(&self) -> ActivationAuthority {
96 self.authority
97 }
98
99 pub fn revoke_exact_mcp_grant(
105 &self,
106 session: &crate::tools::activation::SessionId,
107 server: &str,
108 server_tool_name: &str,
109 ) -> bool {
110 let mut set = self
111 .session_set
112 .write()
113 .unwrap_or_else(std::sync::PoisonError::into_inner);
114 if set.session() != session {
115 return false;
116 }
117 set.revoke_exact(&ToolId::mcp(server, server_tool_name))
118 .is_ok()
119 }
120
121 pub fn revoke_exact_extension_grant(
127 &self,
128 session: &crate::tools::activation::SessionId,
129 plugin_id: &str,
130 tool_name: &str,
131 ) -> bool {
132 let mut set = self
133 .session_set
134 .write()
135 .unwrap_or_else(std::sync::PoisonError::into_inner);
136 if set.session() != session {
137 return false;
138 }
139 set.revoke_exact(&ToolId::extension(plugin_id, tool_name))
140 .is_ok()
141 }
142}
143
144impl std::fmt::Debug for ActivationCapability {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 f.debug_struct("ActivationCapability")
147 .field("authority", &self.authority)
148 .finish_non_exhaustive()
149 }
150}
151
152fn require_capability(ctx: &ToolContext) -> Result<ActivationCapability> {
153 ctx.capabilities.tool_activation.clone().ok_or_else(|| {
154 RuntimeError::Tool("tool discovery/activation is not available in this context".to_string())
155 })
156}
157
158async fn confirm_activation_with_host(
168 prompt: Option<&crate::tools::SecretPromptHandle>,
169 tool_ids: &[ToolId],
170) -> ActivationAuthority {
171 let Some(prompt) = prompt else {
172 return ActivationAuthority::Unauthorized;
173 };
174 let mut ids: Vec<&str> = tool_ids.iter().map(ToolId::as_str).collect();
175 ids.sort_unstable();
176 let question = format!(
177 "The model requests session-scoped activation of {} exact tool id(s):\n{}\n\
178 Allow exactly these tools? Anything but Allow / y denies.",
179 ids.len(),
180 ids.join("\n")
181 );
182 match prompt
183 .prompt(
184 crate::session::CONFIRM_ACTIVATION_PROMPT_TITLE.to_string(),
185 question,
186 )
187 .await
188 {
189 Some(answer) => {
190 let normalized = answer.trim().to_ascii_lowercase();
191 if normalized == "y" || normalized == "yes" {
192 ActivationAuthority::ModelConfirmed
193 } else {
194 ActivationAuthority::Unauthorized
195 }
196 }
197 None => ActivationAuthority::Unauthorized,
198 }
199}
200
201fn require_query(params: &Value) -> Result<DiscoveryQuery> {
202 let raw = params["query"]
203 .as_str()
204 .ok_or_else(|| RuntimeError::Tool("Missing 'query' parameter (string)".to_string()))?;
205 DiscoveryQuery::parse(raw)
206 .map_err(|err| RuntimeError::Tool(format!("invalid discovery query: {err}")))
207}
208
209pub struct SearchToolsTool;
215
216#[async_trait::async_trait]
217impl Tool for SearchToolsTool {
218 fn effect(&self) -> crate::tools::catalog::ToolEffect {
219 crate::tools::catalog::ToolEffect::ReadOnly
220 }
221
222 fn name(&self) -> &str {
223 "search_tools"
224 }
225
226 fn description(&self) -> &str {
227 "Search locally known tools by keywords, in any order. Ranked: exact \
228 id or name first, then the whole query as a substring, then the \
229 most keywords matched. Returns compact descriptors with stable tool \
230 ids (never full schemas). Use activate_tools with an exact returned \
231 id to request activation."
232 }
233
234 fn origin(&self) -> ToolOrigin {
235 ToolOrigin::Builtin
236 }
237
238 fn parameters(&self) -> Value {
239 json!({
240 "type": "object",
241 "properties": {
242 "query": {
243 "type": "string",
244 "description": "Case-insensitive keywords matched against tool ids, summaries and tags. _ - : . also separate words: web_fetch and web fetch both work."
245 }
246 },
247 "required": ["query"]
248 })
249 }
250
251 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
252 let capability = require_capability(&ctx)?;
253 let query = require_query(¶ms)?;
254 let limits = SearchLimits::new(SEARCH_TOOLS_MAX_RESULTS, SEARCH_TOOLS_MAX_RESULT_BYTES)
255 .expect("static search_tools budgets are within caps");
256 let catalog = {
258 let set = capability
259 .session_set()
260 .read()
261 .unwrap_or_else(std::sync::PoisonError::into_inner);
262 capability.catalog().without(set.withheld())
263 };
264 let index = DiscoveryIndex::build(&catalog)
265 .map_err(|err| RuntimeError::Tool(format!("tool discovery unavailable: {err}")))?;
266 let results = index.search(&query, &limits);
267 let mut body = json!({
268 "generation": results.generation(),
269 "truncated": results.truncated(),
270 "tools": results.hits(),
271 });
272 if results.hits().is_empty() && !results.truncated() {
273 body["hint"] = Value::String(no_match_hint(query.needle()));
274 }
275 serde_json::to_string(&body)
276 .map_err(|err| RuntimeError::Tool(format!("failed to serialize search results: {err}")))
277 }
278}
279
280fn no_match_hint(needle: &str) -> String {
283 format!(
284 "No tools matched '{needle}'. Try one shorter keyword or a synonym. \
285 Don't guess ids: activate_tools needs an exact id, as search_tools \
286 returns it."
287 )
288}
289
290pub struct ActivateToolsTool;
299
300#[async_trait::async_trait]
301impl Tool for ActivateToolsTool {
302 fn name(&self) -> &str {
303 "activate_tools"
304 }
305
306 fn description(&self) -> &str {
307 "Request session-scoped activation of exact tool ids returned by \
308 search_tools. Activation is subject to the host's \
309 tools.activation_confirm policy (auto: granted; prompt: the user is \
310 asked y/n; deny: always refused); only the exact requested tools are \
311 activated for this session."
312 }
313
314 fn origin(&self) -> ToolOrigin {
315 ToolOrigin::Builtin
316 }
317
318 fn parameters(&self) -> Value {
319 json!({
320 "type": "object",
321 "properties": {
322 "tools": {
323 "type": "array",
324 "items": {"type": "string"},
325 "description": "Exact stable tool ids (namespace:name) from search_tools. No wildcards."
326 }
327 },
328 "required": ["tools"]
329 })
330 }
331
332 async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
333 self.activate(params, ctx).await.map(|(summary, _)| summary)
334 }
335
336 async fn execute_rich(&self, params: Value, ctx: ToolContext) -> Result<ToolOutput> {
344 let (summary, ids) = self.activate(params, ctx).await?;
345 let mut blocks = Vec::with_capacity(ids.len() + 1);
346 blocks.push(json!({"type": "text", "text": summary}));
347 blocks.extend(
348 ids.iter()
349 .map(|id| json!({"type": "tool_reference", "tool_name": id})),
350 );
351 Ok(ToolOutput::Blocks { blocks, summary })
352 }
353}
354
355fn activation_denied(err: HostActivationError) -> RuntimeError {
358 match err {
359 HostActivationError::Issuance(GrantIssuanceError::UnknownTool(id)) => {
360 RuntimeError::Tool(format!(
361 "tool activation denied: unknown tool id '{id}'; nothing was \
362 activated. activate_tools needs exact ids (namespace:name) as \
363 search_tools returns them: search for the tool instead of \
364 guessing its id."
365 ))
366 }
367 err => RuntimeError::Tool(format!("tool activation denied: {err}")),
368 }
369}
370
371impl ActivateToolsTool {
372 async fn activate(&self, params: Value, ctx: ToolContext) -> Result<(String, Vec<String>)> {
376 let capability = require_capability(&ctx)?;
377 let requested = params["tools"].as_array().ok_or_else(|| {
378 RuntimeError::Tool("Missing 'tools' parameter (array of tool id strings)".to_string())
379 })?;
380 if requested.is_empty() {
381 return Err(RuntimeError::Tool(
382 "no tool ids requested: 'tools' must name at least one exact id".to_string(),
383 ));
384 }
385 if requested.len() > ACTIVATE_TOOLS_MAX_BATCH {
386 return Err(RuntimeError::Tool(format!(
387 "too many tool ids requested: {} exceeds limit {ACTIVATE_TOOLS_MAX_BATCH}",
388 requested.len()
389 )));
390 }
391 let mut tool_ids = Vec::with_capacity(requested.len());
395 for entry in requested {
396 let raw = entry
397 .as_str()
398 .ok_or_else(|| RuntimeError::Tool("tool ids must be strings".to_string()))?;
399 let id = ToolId::parse(raw).map_err(|err| {
400 RuntimeError::Tool(format!(
401 "invalid tool id {:?}: {err} (exact namespace:name ids as \
402 search_tools returns them; wildcards and source-wide \
403 requests are not accepted)",
404 BoundedText::new(raw, ID_ECHO_MAX_BYTES).text
405 ))
406 })?;
407 tool_ids.push(id);
408 }
409 let authority = match capability.authority() {
420 ActivationAuthority::ModelConfirmed => ActivationAuthority::ModelConfirmed,
421 ActivationAuthority::Unauthorized if !capability.host_prompt_allowed() => {
422 ActivationAuthority::Unauthorized
423 }
424 ActivationAuthority::Unauthorized => {
425 confirm_activation_with_host(ctx.capabilities.secret_prompt.as_ref(), &tool_ids)
426 .await
427 }
428 };
429 let (activated, schema_generation) = {
435 let mut set = capability
436 .session_set()
437 .write()
438 .unwrap_or_else(std::sync::PoisonError::into_inner);
439 let applied =
440 activate_model_initiated(authority, &mut set, capability.catalog(), &tool_ids)
441 .map_err(activation_denied)?;
442 (applied, set.schema_generation())
443 };
444 let ids: Vec<&str> = {
445 let mut sorted: Vec<&ToolId> = tool_ids.iter().collect();
446 sorted.sort();
447 sorted.into_iter().map(ToolId::as_str).collect()
448 };
449 let body = json!({
450 "activated": ids,
451 "count": activated,
452 "schema_generation": schema_generation,
453 });
454 let summary = serde_json::to_string(&body).map_err(|err| {
455 RuntimeError::Tool(format!("failed to serialize activation result: {err}"))
456 })?;
457 let ids = ids.into_iter().map(str::to_string).collect();
458 Ok((summary, ids))
459 }
460}