Skip to main content

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}