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, ToolOutput};
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    /// Whether `activate_tools` may ask the host (the y/n confirm dialog)
53    /// when `authority` is `Unauthorized`. `false` = `tools.activation_confirm
54    /// = deny`: fail closed without ever raising a prompt.
55    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    /// Set whether the host confirm prompt may be raised for an
73    /// `Unauthorized` authority (`tools.activation_confirm`: `prompt` →
74    /// true, `deny` → false). Default true.
75    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    /// Narrow MCP grant invalidation (Task 19): revoke EXACTLY one MCP
97    /// `ToolId` from the RETAINED session set, and only when the caller's
98    /// session identity matches the retained set's session. Core tools and
99    /// sibling activations are untouched; the catalog is never mutated.
100    /// Returns true iff an activation was actually removed.
101    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    /// Narrow extension grant invalidation (Task 20): revoke EXACTLY one
119    /// extension `ToolId` from the RETAINED session set, and only when the
120    /// caller's session identity matches the retained set's session. Core
121    /// tools and sibling activations are untouched; the catalog is never
122    /// mutated. Returns true iff an activation was actually removed.
123    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
155/// Interactive host confirmation for model-initiated activation (M1).
156///
157/// Returns `ModelConfirmed` ONLY when the host's interactive prompt exists
158/// and the host answers an explicit `y`/`yes` (case-insensitive, trimmed)
159/// to the bounded exact id list. Every other outcome — no prompt handle, a
160/// closed prompt channel, cancellation (`None`), or any other answer —
161/// stays `Unauthorized` (fail-closed). The id list shown to the host is
162/// exactly the parsed batch: each id passed `ToolId::parse` (bounded,
163/// canonical) and the batch is capped at [`ACTIVATE_TOOLS_MAX_BATCH`].
164async 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
206// ── search_tools ────────────────────────────────────────────────────────────
207
208/// Bounded, deterministic, descriptor-only capability search over the
209/// passive catalog snapshot. Pure and local: no factory invocation, no
210/// process start, no network, no schema exposure, no grant.
211pub 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(&params)?;
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
264// ── activate_tools ──────────────────────────────────────────────────────────
265
266/// Model-initiated deterministic exact activation. Every requested id must
267/// be an exact known cataloged `ToolId`; the whole batch validates first
268/// (host grant issuance: exact id, current generation/digest, source trust)
269/// and applies atomically in stable id order with exactly one session
270/// schema-generation advance. Requires host-supplied confirmation
271/// authority; a model-authored JSON flag can never substitute for it.
272pub 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        self.activate(params, ctx).await.map(|(summary, _)| summary)
308    }
309
310    /// The text summary first (compaction, previews and non-Anthropic
311    /// providers read it), then one `tool_reference` per activated tool,
312    /// keyed by canonical tool id. On the Anthropic transport with deferred
313    /// loading, the wire layer resolves each id to its wire name and sends
314    /// only the references, which makes the deferred definitions callable
315    /// without changing the cached prompt prefix. Everywhere else the
316    /// references are dropped and the text stands alone.
317    async fn execute_rich(&self, params: Value, ctx: ToolContext) -> Result<ToolOutput> {
318        let (summary, ids) = self.activate(params, ctx).await?;
319        let mut blocks = Vec::with_capacity(ids.len() + 1);
320        blocks.push(json!({"type": "text", "text": summary}));
321        blocks.extend(
322            ids.iter()
323                .map(|id| json!({"type": "tool_reference", "tool_name": id})),
324        );
325        Ok(ToolOutput::Blocks { blocks, summary })
326    }
327}
328
329impl ActivateToolsTool {
330    /// Authorize and apply one activation batch. Returns the JSON summary
331    /// text and the sorted canonical ids that were requested and are now
332    /// active for the session.
333    async fn activate(&self, params: Value, ctx: ToolContext) -> Result<(String, Vec<String>)> {
334        let capability = require_capability(&ctx)?;
335        let requested = params["tools"].as_array().ok_or_else(|| {
336            RuntimeError::Tool("Missing 'tools' parameter (array of tool id strings)".to_string())
337        })?;
338        if requested.is_empty() {
339            return Err(RuntimeError::Tool(
340                "no tool ids requested: 'tools' must name at least one exact id".to_string(),
341            ));
342        }
343        if requested.len() > ACTIVATE_TOOLS_MAX_BATCH {
344            return Err(RuntimeError::Tool(format!(
345                "too many tool ids requested: {} exceeds limit {ACTIVATE_TOOLS_MAX_BATCH}",
346                requested.len()
347            )));
348        }
349        // Parse-at-boundary: every entry must be an exact canonical ToolId.
350        // Source-wide ("builtin"), wildcard ("ns:*"), empty, and oversized
351        // spellings fail typed here, before any authority/grant work.
352        let mut tool_ids = Vec::with_capacity(requested.len());
353        for entry in requested {
354            let raw = entry
355                .as_str()
356                .ok_or_else(|| RuntimeError::Tool("tool ids must be strings".to_string()))?;
357            let id = ToolId::parse(raw).map_err(|err| {
358                RuntimeError::Tool(format!(
359                    "invalid tool id {:?}: {err} (exact namespace:name ids only; \
360                     wildcards and source-wide requests are not accepted)",
361                    BoundedText::new(raw, ID_ECHO_MAX_BYTES).text
362                ))
363            })?;
364            tool_ids.push(id);
365        }
366        // Host confirmation policy (M1): authority is host-only and exact.
367        // `tools.activation_confirm = auto` (default) or explicit server
368        // auto-approve authorizes directly; `prompt`: when an
369        // interactive host prompt exists, the HOST is asked to approve this
370        // bounded exact id list and only an explicit y/yes authorizes.
371        // Absent prompt, denial, or cancellation stays fail-closed. The
372        // model-authored JSON arguments are never consulted for authority.
373        // The prompt await happens strictly BEFORE the session-set lock is
374        // taken; no lock is held across an await.
375        // `tools.activation_confirm = deny` never raises the prompt.
376        let authority = match capability.authority() {
377            ActivationAuthority::ModelConfirmed => ActivationAuthority::ModelConfirmed,
378            ActivationAuthority::Unauthorized if !capability.host_prompt_allowed() => {
379                ActivationAuthority::Unauthorized
380            }
381            ActivationAuthority::Unauthorized => {
382                confirm_activation_with_host(ctx.capabilities.secret_prompt.as_ref(), &tool_ids)
383                    .await
384            }
385        };
386        // Authority check + host grant issuance + atomic bulk activation.
387        // The write guard is short-lived and never held across an await; the
388        // resulting schema generation is captured inside the SAME critical
389        // section (M3) so the reported value is exactly the completed
390        // batch's generation, never a later concurrent one.
391        let (activated, schema_generation) = {
392            let mut set = capability
393                .session_set()
394                .write()
395                .unwrap_or_else(std::sync::PoisonError::into_inner);
396            let applied =
397                activate_model_initiated(authority, &mut set, capability.catalog(), &tool_ids)
398                    .map_err(|err| RuntimeError::Tool(format!("tool activation denied: {err}")))?;
399            (applied, set.schema_generation())
400        };
401        let ids: Vec<&str> = {
402            let mut sorted: Vec<&ToolId> = tool_ids.iter().collect();
403            sorted.sort();
404            sorted.into_iter().map(ToolId::as_str).collect()
405        };
406        let body = json!({
407            "activated": ids,
408            "count": activated,
409            "schema_generation": schema_generation,
410        });
411        let summary = serde_json::to_string(&body).map_err(|err| {
412            RuntimeError::Tool(format!("failed to serialize activation result: {err}"))
413        })?;
414        let ids = ids.into_iter().map(str::to_string).collect();
415        Ok((summary, ids))
416    }
417}