Skip to main content

runtime/tools/
describe.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The face of `describe` (`crates/runtime/Spec.lean` ยง8-60): what an
7//! admitted capability is, read from the run's catalog. Its answer is an
8//! ordinary tool result appended at the end of the conversation, so the
9//! request's tool list and its prompt cache do not move (runtime D21).
10
11use std::sync::{Arc, Mutex};
12
13use kernel::{
14    AxCode, AxError, CostTier, Effect, Payload, RenderIntent, Temporal, Tool, ToolCall, ToolMeta,
15    ToolName, ToolOutcome,
16};
17use serde_json::Value;
18
19use crate::catalog::Catalog;
20
21const ACTION: &str = "describe";
22
23/// The tool itself; it holds the catalog rather than a copy, as `read`
24/// does, so what it answers is what the run was admitted.
25pub struct DescribeTool {
26    catalog: Arc<Mutex<Catalog>>,
27    meta: ToolMeta,
28}
29
30impl DescribeTool {
31    /// The name this tool answers to, read by its registration and by
32    /// `mode::core_tools`.
33    pub const NAME: &'static str = "describe";
34
35    /// # Errors
36    /// Propagates a malformed parameter schema, which is a build-time
37    /// defect rather than a runtime one.
38    pub fn new(catalog: Arc<Mutex<Catalog>>) -> Result<DescribeTool, AxError> {
39        Ok(DescribeTool {
40            catalog,
41            meta: ToolMeta {
42                name: ToolName::parse(Self::NAME)?,
43                disclosure: "Get the whole guide of a capability this building admits: a tool's \
44                             description and input schema, or a skill's line. Give its whole \
45                             name, or a few words to get the closest names."
46                    .to_owned(),
47                params: Payload::of(&serde_json::json!({
48                    "type": "object",
49                    "properties": {
50                        "name": { "type": "string", "description": "a name from the dormant \
51                            index, or a few words of what you need" },
52                    },
53                    "required": ["name"],
54                }))?,
55                effect: Effect::Read,
56                cost_tier: CostTier::Free,
57                timeout: None,
58                render: RenderIntent::Generic,
59                temporal: Temporal::Timeless,
60            },
61        })
62    }
63}
64
65impl Tool for DescribeTool {
66    fn meta(&self) -> &ToolMeta {
67        &self.meta
68    }
69
70    fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
71        if call.name != self.meta.name {
72            return Err(AxError::failure(
73                AxCode::InvalidArgs,
74                ACTION,
75                format!("call routed to the wrong tool: {}", call.name.as_str()),
76            )
77            .with_recovery(format!(
78                "call `{}`, the name this tool answers to",
79                Self::NAME
80            )));
81        }
82        let asked = call
83            .args
84            .as_map()
85            .get("name")
86            .and_then(Value::as_str)
87            .ok_or_else(|| {
88                AxError::failure(
89                    AxCode::InvalidArgs,
90                    ACTION,
91                    "missing string argument `name`",
92                )
93                .with_recovery("pass `name`: a capability's name, or a few words")
94            })?;
95        let guide = self
96            .catalog
97            .lock()
98            .map_err(|_| poisoned())?
99            .describe(asked)?;
100        Ok(ToolOutcome {
101            result: Payload::of(&serde_json::json!({ "text": guide }))?,
102            attachments: Vec::new(),
103        })
104    }
105}
106
107/// The catalog's lock was left by a thread that died; the same answer
108/// `read` gives (`crates/runtime/spec/Tools/Read.lean`).
109pub(super) fn poisoned() -> AxError {
110    AxError::failure(
111        AxCode::StorageFatal,
112        ACTION,
113        "the catalog was left locked by a thread that died",
114    )
115    .with_recovery(
116        "end this run and resume it: the catalog cannot be trusted again inside a process \
117         where a thread died holding it",
118    )
119}