tools_kit/interaction.rs
1//! Caller input requested by a suspended tool invocation.
2//!
3//! Declare capabilities before execution. Preserve the pending request id in
4//! [`ToolInteractionResponse`](crate::interaction::ToolInteractionResponse); text replies are strings and form replies are objects.
5
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use crate::{ToolError, name::ToolName, schema::JsonSchema};
10
11/// Interaction behavior declared by one tool.
12///
13/// # Examples
14///
15/// ```
16/// use tools_kit::interaction::ToolInteractionCapabilities;
17/// let capabilities = ToolInteractionCapabilities { text: true, ..Default::default() };
18/// assert!(capabilities.text);
19/// assert!(!capabilities.form);
20/// ```
21///
22#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct ToolInteractionCapabilities {
25 /// Whether the tool may request structured form input.
26 pub form: bool,
27 /// Whether form responses may be validated against the supplied schema.
28 pub form_schema_validation: bool,
29 /// Whether the tool may request single-line text input.
30 pub text: bool,
31 /// Whether the tool may request multiline text input.
32 pub multiline_text: bool,
33}
34
35/// Opaque interaction id.
36/// Keep the returned identity unchanged when constructing the response. Its spelling
37/// is opaque; do not derive invocation or tool identity from it.
38///
39/// # Examples
40///
41/// ```
42/// use tools_kit::interaction::ToolInteractionId;
43/// let id = ToolInteractionId::new();
44/// let encoded = serde_json::to_value(&id).unwrap();
45/// assert_eq!(encoded.as_str(), Some(id.as_str()));
46/// ```
47///
48#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
49#[serde(transparent)]
50pub struct ToolInteractionId(String);
51
52impl ToolInteractionId {
53 /// Creates a fresh opaque interaction id.
54 ///
55 /// # Examples
56 ///
57 /// ```
58 /// use tools_kit::interaction::ToolInteractionId;
59 /// let id = ToolInteractionId::new();
60 /// let encoded = serde_json::to_value(&id).unwrap();
61 /// assert_eq!(encoded.as_str(), Some(id.as_str()));
62 /// ```
63 ///
64 #[must_use]
65 pub fn new() -> Self {
66 Self(Uuid::new_v4().to_string())
67 }
68
69 /// Borrows the serialized interaction id.
70 ///
71 /// # Examples
72 ///
73 /// ```
74 /// use tools_kit::interaction::ToolInteractionId;
75 /// let id = ToolInteractionId::new();
76 /// let encoded = serde_json::to_value(&id).unwrap();
77 /// assert_eq!(encoded.as_str(), Some(id.as_str()));
78 /// ```
79 ///
80 #[must_use]
81 pub fn as_str(&self) -> &str {
82 &self.0
83 }
84}
85
86impl Default for ToolInteractionId {
87 fn default() -> Self {
88 Self::new()
89 }
90}
91
92/// Source-neutral interaction request produced by a tool handler.
93///
94/// # Examples
95///
96/// ```
97/// use tools_kit::{declaration::ToolName, interaction::{ToolInteractionId, ToolInteractionKind, ToolInteractionRequest, ToolInteractionResponse}};
98/// let request = ToolInteractionRequest {
99/// id: ToolInteractionId::new(), tool_name: ToolName::new("ask")?,
100/// call_id: Some("call-1".into()), prompt: "Which city?".into(),
101/// kind: ToolInteractionKind::Text { multiline: false },
102/// };
103/// let response = ToolInteractionResponse::submit(request.id.clone(), serde_json::json!("Chicago"));
104/// assert_eq!(response.interaction_id(), &request.id);
105///
106/// # Ok::<(), tools_kit::ToolError>(())
107/// ```
108///
109#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct ToolInteractionRequest {
112 /// Opaque id used to correlate the caller response.
113 pub id: ToolInteractionId,
114 /// Tool that requested the interaction.
115 pub tool_name: ToolName,
116 /// Optional caller-owned invocation correlation id.
117 pub call_id: Option<String>,
118 /// Human-readable prompt shown to the caller.
119 pub prompt: String,
120 /// Input shape requested from the caller.
121 pub kind: ToolInteractionKind,
122}
123
124impl ToolInteractionRequest {
125 pub(crate) fn validate_response(
126 &self,
127 response: &ToolInteractionResponse,
128 ) -> Result<(), ToolError> {
129 if response.interaction_id() != &self.id {
130 return Err(ToolError::InvalidInteraction {
131 id: response.interaction_id().as_str().to_owned(),
132 message: format!(
133 "response id does not match pending interaction `{}`",
134 self.id.as_str()
135 ),
136 });
137 }
138
139 match (response, &self.kind) {
140 (
141 ToolInteractionResponse::Submit { content, .. },
142 ToolInteractionKind::Form {
143 schema,
144 schema_validation: true,
145 },
146 ) => {
147 if !content.is_object() {
148 return Err(ToolError::InvalidInteraction {
149 id: self.id.as_str().to_owned(),
150 message: "form responses must be JSON objects".to_owned(),
151 });
152 }
153 let issues = JsonSchema::validate_raw(schema, content)?;
154 if issues.is_empty() {
155 Ok(())
156 } else {
157 Err(ToolError::InvalidArguments {
158 tool: self.tool_name.clone(),
159 message: "form response failed schema validation".to_owned(),
160 issues,
161 })
162 }
163 }
164 (
165 ToolInteractionResponse::Submit { content, .. },
166 ToolInteractionKind::Form {
167 schema_validation: false,
168 ..
169 },
170 ) if content.is_object() => Ok(()),
171 (ToolInteractionResponse::Submit { content, .. }, ToolInteractionKind::Text { .. })
172 if content.is_string() =>
173 {
174 Ok(())
175 }
176 (ToolInteractionResponse::Submit { .. }, _) => Err(ToolError::InvalidInteraction {
177 id: self.id.as_str().to_owned(),
178 message: "interaction response content does not match request kind".to_owned(),
179 }),
180 (
181 ToolInteractionResponse::Decline { .. } | ToolInteractionResponse::Cancel { .. },
182 _,
183 ) => Ok(()),
184 }
185 }
186}
187
188/// Source-neutral interaction request kind.
189#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
190#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
191pub enum ToolInteractionKind {
192 /// Requests a JSON object described by a JSON Schema.
193 Form {
194 /// JSON Schema describing the expected object.
195 schema: serde_json::Value,
196 /// Whether the response must be validated against `schema`.
197 schema_validation: bool,
198 },
199 /// Requests textual input.
200 Text {
201 /// Whether the response may contain multiple lines.
202 multiline: bool,
203 },
204}
205
206/// Caller response to a pending tool interaction.
207#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
208#[serde(tag = "decision", rename_all = "snake_case", deny_unknown_fields)]
209pub enum ToolInteractionResponse {
210 /// Supplies caller input and resumes execution.
211 Submit {
212 /// Id of the interaction being resolved.
213 id: ToolInteractionId,
214 /// Submitted text or form value.
215 content: serde_json::Value,
216 },
217 /// Declines to provide the requested input.
218 Decline {
219 /// Id of the interaction being resolved.
220 id: ToolInteractionId,
221 /// Optional caller-safe explanation.
222 reason: Option<String>,
223 },
224 /// Cancels the pending interaction and invocation.
225 Cancel {
226 /// Id of the interaction being resolved.
227 id: ToolInteractionId,
228 /// Optional caller-safe cancellation reason.
229 reason: Option<String>,
230 },
231}
232
233pub(crate) enum InteractionResolution {
234 Submit(serde_json::Value),
235 Decline(Option<String>),
236 Cancel(Option<String>),
237}
238
239impl ToolInteractionResponse {
240 /// Creates a submitted interaction response.
241 ///
242 /// # Examples
243 ///
244 /// ```
245 /// use tools_kit::interaction::{ToolInteractionId, ToolInteractionResponse};
246 /// let id = ToolInteractionId::new();
247 /// let response = ToolInteractionResponse::submit(id.clone(), serde_json::json!("Chicago"));
248 /// assert_eq!(response.interaction_id(), &id);
249 /// ```
250 ///
251 #[must_use]
252 pub fn submit(id: ToolInteractionId, content: serde_json::Value) -> Self {
253 Self::Submit { id, content }
254 }
255
256 /// Creates a declined interaction response.
257 ///
258 /// # Examples
259 ///
260 /// ```
261 /// use tools_kit::interaction::{ToolInteractionId, ToolInteractionResponse};
262 /// let id = ToolInteractionId::new();
263 /// let response = ToolInteractionResponse::decline(id.clone(), Some("not available".into()));
264 /// assert_eq!(response.interaction_id(), &id);
265 /// ```
266 ///
267 #[must_use]
268 pub fn decline(id: ToolInteractionId, reason: Option<String>) -> Self {
269 Self::Decline { id, reason }
270 }
271
272 /// Creates a cancelled interaction response.
273 ///
274 /// # Examples
275 ///
276 /// ```
277 /// use tools_kit::interaction::{ToolInteractionId, ToolInteractionResponse};
278 /// let id = ToolInteractionId::new();
279 /// let response = ToolInteractionResponse::cancel(id.clone(), None);
280 /// assert_eq!(response.interaction_id(), &id);
281 /// ```
282 ///
283 #[must_use]
284 pub fn cancel(id: ToolInteractionId, reason: Option<String>) -> Self {
285 Self::Cancel { id, reason }
286 }
287
288 /// Borrows the id of the interaction being resolved.
289 ///
290 /// # Examples
291 ///
292 /// ```
293 /// use tools_kit::interaction::{ToolInteractionId, ToolInteractionResponse};
294 /// let id = ToolInteractionId::new();
295 /// let response = ToolInteractionResponse::submit(id.clone(), serde_json::json!({"approved":true}));
296 /// assert_eq!(response.interaction_id(), &id);
297 /// ```
298 ///
299 #[must_use]
300 pub fn interaction_id(&self) -> &ToolInteractionId {
301 match self {
302 Self::Submit { id, .. } | Self::Decline { id, .. } | Self::Cancel { id, .. } => id,
303 }
304 }
305
306 pub(crate) fn into_resolution(self) -> InteractionResolution {
307 match self {
308 Self::Submit { content, .. } => InteractionResolution::Submit(content),
309 Self::Decline { reason, .. } => InteractionResolution::Decline(reason),
310 Self::Cancel { reason, .. } => InteractionResolution::Cancel(reason),
311 }
312 }
313}