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