Skip to main content

agent_engine/tools/
discovery.rs

1//! Model-facing discovery and exact-activation builtins (Task 17, spec
2//! §4.2, §7.2, §7.3, §7.7): `search_tools` and `activate_tools`.
3//!
4//! Both tools operate ONLY on the capability context the host injects into
5//! [`crate::tools::ToolCapabilities::tool_activation`]:
6//!
7//! - a PASSIVE catalog snapshot (compact descriptors + digests; reading it
8//!   starts no process, touches no network, exposes no schema);
9//! - the RETAINED per-stream [`SharedSessionToolSet`] handle, so a
10//!   confirmed activation mutates the same set the `ExecutionGate` and the
11//!   next provider round consume;
12//! - a host-supplied [`ActivationAuthority`], populated exclusively by host
13//!   policy — never from model-authored JSON arguments.
14//!
15//! `search_tools` returns bounded compact descriptors with stable
16//! [`ToolId`]s only — no full schemas, no factories, no source paths, no
17//! credentials. `activate_tools` performs deterministic atomic bulk
18//! activation of EXACT known ids through host grant issuance; source-wide,
19//! provider-wide, wildcard, unknown, and malformed identities fail typed
20//! with zero mutation. Confirmation authority is host-only: explicit host
21//! auto-approve authorizes directly; otherwise the interactive host prompt
22//! (when present) must approve the bounded exact id list with y/yes. Absent
23//! prompt or any other answer, the request is denied before any grant, set,
24//! or schema-generation mutation — model-authored JSON never confers
25//! authority.
26
27use 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
38/// Default bounded result-count budget for `search_tools`.
39pub const SEARCH_TOOLS_MAX_RESULTS: usize = 16;
40/// Default bounded serialized byte budget for `search_tools` hits.
41pub const SEARCH_TOOLS_MAX_RESULT_BYTES: usize = 8 * 1024;
42/// Maximum number of ids in one `activate_tools` batch.
43pub const ACTIVATE_TOOLS_MAX_BATCH: usize = 16;
44/// Echo bound for hostile identity strings inside typed denials.
45const ID_ECHO_MAX_BYTES: usize = 128;
46
47/// Capability context for the discovery/activation builtins. Constructed by
48/// the host per round from the round's catalog snapshot, the retained
49/// session-set handle, and host confirmation policy.
50#[derive(Clone)]
51pub struct ActivationCapability {
52    catalog: ToolCatalog,
53    session_set: SharedSessionToolSet,
54    authority: ActivationAuthority,
55    /// Whether `activate_tools` may ask the host (the y/n confirm dialog)
56    /// when `authority` is `Unauthorized`. `false` = `tools.activation_confirm
57    /// = deny`: fail closed without ever raising a prompt.
58    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    /// Set whether the host confirm prompt may be raised for an
76    /// `Unauthorized` authority (`tools.activation_confirm`: `prompt` →
77    /// true, `deny` → false). Default true.
78    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    /// Narrow MCP grant invalidation (Task 19): revoke EXACTLY one MCP
100    /// `ToolId` from the RETAINED session set, and only when the caller's
101    /// session identity matches the retained set's session. Core tools and
102    /// sibling activations are untouched; the catalog is never mutated.
103    /// Returns true iff an activation was actually removed.
104    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    /// Narrow extension grant invalidation (Task 20): revoke EXACTLY one
122    /// extension `ToolId` from the RETAINED session set, and only when the
123    /// caller's session identity matches the retained set's session. Core
124    /// tools and sibling activations are untouched; the catalog is never
125    /// mutated. Returns true iff an activation was actually removed.
126    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
158/// Interactive host confirmation for model-initiated activation (M1).
159///
160/// Returns `ModelConfirmed` ONLY when the host's interactive prompt exists
161/// and the host answers an explicit `y`/`yes` (case-insensitive, trimmed)
162/// to the bounded exact id list. Every other outcome — no prompt handle, a
163/// closed prompt channel, cancellation (`None`), or any other answer —
164/// stays `Unauthorized` (fail-closed). The id list shown to the host is
165/// exactly the parsed batch: each id passed `ToolId::parse` (bounded,
166/// canonical) and the batch is capped at [`ACTIVATE_TOOLS_MAX_BATCH`].
167async 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
209// ── search_tools ────────────────────────────────────────────────────────────
210
211/// Bounded, deterministic, descriptor-only capability search over the
212/// passive catalog snapshot. Pure and local: no factory invocation, no
213/// process start, no network, no schema exposure, no grant.
214pub 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(&params)?;
254        let limits = SearchLimits::new(SEARCH_TOOLS_MAX_RESULTS, SEARCH_TOOLS_MAX_RESULT_BYTES)
255            .expect("static search_tools budgets are within caps");
256        // A tool withheld from this session is not there to be found.
257        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
280/// Recovery advice for a search that matched nothing. An empty query is
281/// rejected, so listing everything is not an option to offer.
282fn 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
290// ── activate_tools ──────────────────────────────────────────────────────────
291
292/// Model-initiated deterministic exact activation. Every requested id must
293/// be an exact known cataloged `ToolId`; the whole batch validates first
294/// (host grant issuance: exact id, current generation/digest, source trust)
295/// and applies atomically in stable id order with exactly one session
296/// schema-generation advance. Requires host-supplied confirmation
297/// authority; a model-authored JSON flag can never substitute for it.
298pub 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    /// The text summary first (compaction, previews and non-Anthropic
337    /// providers read it), then one `tool_reference` per activated tool,
338    /// keyed by canonical tool id. On the Anthropic transport with deferred
339    /// loading, the wire layer resolves each id to its wire name and sends
340    /// only the references, which makes the deferred definitions callable
341    /// without changing the cached prompt prefix. Everywhere else the
342    /// references are dropped and the text stands alone.
343    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
355/// Typed activation failure → tool error. An unknown id is usually a
356/// guessed one: say that ids must be exact and come from search_tools.
357fn 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    /// Authorize and apply one activation batch. Returns the JSON summary
373    /// text and the sorted canonical ids that were requested and are now
374    /// active for the session.
375    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        // Parse-at-boundary: every entry must be an exact canonical ToolId.
392        // Source-wide ("builtin"), wildcard ("ns:*"), empty, and oversized
393        // spellings fail typed here, before any authority/grant work.
394        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        // Host confirmation policy (M1): authority is host-only and exact.
410        // `tools.activation_confirm = auto` (default) or explicit server
411        // auto-approve authorizes directly; `prompt`: when an
412        // interactive host prompt exists, the HOST is asked to approve this
413        // bounded exact id list and only an explicit y/yes authorizes.
414        // Absent prompt, denial, or cancellation stays fail-closed. The
415        // model-authored JSON arguments are never consulted for authority.
416        // The prompt await happens strictly BEFORE the session-set lock is
417        // taken; no lock is held across an await.
418        // `tools.activation_confirm = deny` never raises the prompt.
419        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        // Authority check + host grant issuance + atomic bulk activation.
430        // The write guard is short-lived and never held across an await; the
431        // resulting schema generation is captured inside the SAME critical
432        // section (M3) so the reported value is exactly the completed
433        // batch's generation, never a later concurrent one.
434        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}