Skip to main content

agents_kit/
tool.rs

1//! Managed tool inventory and per-prompt selection.
2//!
3//! [`ToolInfo`] describes tools without owning their executable handlers.
4//! [`ToolSelection`] restricts the effective catalog for a prompt; named
5//! selections are validated when placed in a request or backend configuration.
6//! Executable tools themselves belong to `tools-kit`.
7
8use std::collections::BTreeSet;
9
10use crate::KitError;
11
12/// Portable per-prompt selection over the complete effective tool catalog.
13#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
14#[serde(rename_all = "snake_case", tag = "mode", content = "names")]
15pub enum ToolSelection {
16    /// Exposes the complete catalog and lets the backend decide whether to call a tool.
17    Auto,
18    /// Exposes no portable tool for this prompt.
19    None,
20    /// Exposes the complete nonempty catalog and requires at least one call.
21    Required,
22    /// Exposes only the named tools without requiring a call.
23    Allowed(Vec<String>),
24    /// Exposes only the named tools and requires at least one of them.
25    RequiredOneOf(Vec<String>),
26}
27
28impl ToolSelection {
29    pub(crate) fn validate(&self) -> Result<(), KitError> {
30        match self {
31            Self::Auto | Self::None | Self::Required => Ok(()),
32            Self::Allowed(names) | Self::RequiredOneOf(names) => Self::validate_names(names),
33        }
34    }
35
36    fn validate_names(names: &[String]) -> Result<(), KitError> {
37        if names.is_empty() {
38            return Err(KitError::invalid_input(
39                "named tool selection must not be empty",
40            ));
41        }
42        let mut unique = BTreeSet::new();
43        for name in names {
44            if name.is_empty()
45                || name.len() > 128
46                || name.trim() != name
47                || name.split('.').any(str::is_empty)
48                || !name
49                    .bytes()
50                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
51            {
52                return Err(KitError::invalid_input(
53                    "tool selection names must be canonical portable names",
54                ));
55            }
56            if !unique.insert(name) {
57                return Err(KitError::invalid_input(
58                    "tool selection names must not contain duplicates",
59                ));
60            }
61        }
62        Ok(())
63    }
64}
65
66#[derive(Clone, Debug, PartialEq)]
67/// Describes one agents-kit-managed tool installed for a workspace.
68///
69/// ```
70/// use agents_kit::tool::ToolInfo;
71///
72/// let tool = ToolInfo::new("lookup", Some("Looks up a value".into()), None)?;
73/// assert_eq!(tool.name(), "lookup");
74/// # Ok::<(), agents_kit::KitError>(())
75/// ```
76pub struct ToolInfo {
77    name: String,
78    description: Option<String>,
79    input_schema: Option<serde_json::Value>,
80}
81
82impl ToolInfo {
83    /// Validates and constructs managed tool inventory information.
84    ///
85    /// # Errors
86    /// Returns [`crate::error::ErrorKind::InvalidInput`] for an empty or padded
87    /// name, or a present empty or padded description. The optional schema is
88    /// inventory metadata; this constructor does not compile it.
89    ///
90    /// ```
91    /// use agents_kit::tool::ToolInfo;
92    /// let tool = ToolInfo::new("lookup", None, None)?;
93    /// assert_eq!(tool.name(), "lookup");
94    /// assert!(ToolInfo::new("", None, None).is_err());
95    /// # Ok::<(), agents_kit::KitError>(())
96    /// ```
97    pub fn new(
98        name: impl Into<String>,
99        description: Option<String>,
100        input_schema: Option<serde_json::Value>,
101    ) -> Result<Self, KitError> {
102        let name = name.into();
103        if name.is_empty() || name.trim() != name {
104            return Err(KitError::invalid_input(
105                "tool name must be nonempty and unpadded",
106            ));
107        }
108        if description
109            .as_deref()
110            .is_some_and(|value| value.is_empty() || value.trim() != value)
111        {
112            return Err(KitError::invalid_input(
113                "tool description must be nonempty and unpadded when present",
114            ));
115        }
116        Ok(Self {
117            name,
118            description,
119            input_schema,
120        })
121    }
122
123    /// Returns the unique managed tool name.
124    ///
125    /// ```
126    /// use agents_kit::tool::ToolInfo;
127    /// assert_eq!(ToolInfo::new("lookup", None, None)?.name(), "lookup");
128    /// # Ok::<(), agents_kit::KitError>(())
129    /// ```
130    pub fn name(&self) -> &str {
131        &self.name
132    }
133
134    /// Returns the optional caller-facing description.
135    ///
136    /// ```
137    /// use agents_kit::tool::ToolInfo;
138    /// let tool = ToolInfo::new("lookup", Some("Find a record".into()), None)?;
139    /// assert_eq!(tool.description(), Some("Find a record"));
140    /// # Ok::<(), agents_kit::KitError>(())
141    /// ```
142    pub fn description(&self) -> Option<&str> {
143        self.description.as_deref()
144    }
145
146    /// Returns the optional source-neutral input schema.
147    ///
148    /// ```
149    /// use agents_kit::tool::ToolInfo;
150    /// let schema = serde_json::json!({"type": "object"});
151    /// let tool = ToolInfo::new("lookup", None, Some(schema.clone()))?;
152    /// assert_eq!(tool.input_schema(), Some(&schema));
153    /// # Ok::<(), agents_kit::KitError>(())
154    /// ```
155    pub fn input_schema(&self) -> Option<&serde_json::Value> {
156        self.input_schema.as_ref()
157    }
158}