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}