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::{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
35/// Default bounded result-count budget for `search_tools`.
36pub const SEARCH_TOOLS_MAX_RESULTS: usize = 16;
37/// Default bounded serialized byte budget for `search_tools` hits.
38pub const SEARCH_TOOLS_MAX_RESULT_BYTES: usize = 8 * 1024;
39/// Maximum number of ids in one `activate_tools` batch.
40pub const ACTIVATE_TOOLS_MAX_BATCH: usize = 16;
41/// Echo bound for hostile identity strings inside typed denials.
42const ID_ECHO_MAX_BYTES: usize = 128;
43
44/// Capability context for the discovery/activation builtins. Constructed by
45/// the host per round from the round's catalog snapshot, the retained
46/// session-set handle, and host confirmation policy.
47#[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    /// Narrow MCP grant invalidation (Task 19): revoke EXACTLY one MCP
80    /// `ToolId` from the RETAINED session set, and only when the caller's
81    /// session identity matches the retained set's session. Core tools and
82    /// sibling activations are untouched; the catalog is never mutated.
83    /// Returns true iff an activation was actually removed.
84    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    /// Narrow extension grant invalidation (Task 20): revoke EXACTLY one
102    /// extension `ToolId` from the RETAINED session set, and only when the
103    /// caller's session identity matches the retained set's session. Core
104    /// tools and sibling activations are untouched; the catalog is never
105    /// mutated. Returns true iff an activation was actually removed.
106    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
138/// Interactive host confirmation for model-initiated activation (M1).
139///
140/// Returns `ModelConfirmed` ONLY when the host's interactive prompt exists
141/// and the host answers an explicit `y`/`yes` (case-insensitive, trimmed)
142/// to the bounded exact id list. Every other outcome — no prompt handle, a
143/// closed prompt channel, cancellation (`None`), or any other answer —
144/// stays `Unauthorized` (fail-closed). The id list shown to the host is
145/// exactly the parsed batch: each id passed `ToolId::parse` (bounded,
146/// canonical) and the batch is capped at [`ACTIVATE_TOOLS_MAX_BATCH`].
147async 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
186// ── search_tools ────────────────────────────────────────────────────────────
187
188/// Bounded, deterministic, descriptor-only capability search over the
189/// passive catalog snapshot. Pure and local: no factory invocation, no
190/// process start, no network, no schema exposure, no grant.
191pub 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(&params)?;
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
244// ── activate_tools ──────────────────────────────────────────────────────────
245
246/// Model-initiated deterministic exact activation. Every requested id must
247/// be an exact known cataloged `ToolId`; the whole batch validates first
248/// (host grant issuance: exact id, current generation/digest, source trust)
249/// and applies atomically in stable id order with exactly one session
250/// schema-generation advance. Requires host-supplied confirmation
251/// authority; a model-authored JSON flag can never substitute for it.
252pub 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        // Parse-at-boundary: every entry must be an exact canonical ToolId.
301        // Source-wide ("builtin"), wildcard ("ns:*"), empty, and oversized
302        // spellings fail typed here, before any authority/grant work.
303        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        // Host confirmation policy (M1): authority is host-only and exact.
318        // Explicit host auto-approve authorizes directly; otherwise, when an
319        // interactive host prompt exists, the HOST is asked to approve this
320        // bounded exact id list and only an explicit y/yes authorizes.
321        // Absent prompt, denial, or cancellation stays fail-closed. The
322        // model-authored JSON arguments are never consulted for authority.
323        // The prompt await happens strictly BEFORE the session-set lock is
324        // taken; no lock is held across an await.
325        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        // Authority check + host grant issuance + atomic bulk activation.
333        // The write guard is short-lived and never held across an await; the
334        // resulting schema generation is captured inside the SAME critical
335        // section (M3) so the reported value is exactly the completed
336        // batch's generation, never a later concurrent one.
337        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}