Skip to main content

monoloop_contracts/
input.rs

1//! Caller-owned canonical transaction input (provider-neutral).
2//!
3//! Monoloop validates and encodes; it never authors or rewrites messages.
4
5use crate::id::{IdentityError, ToolName, MAX_IDENTITY_BYTES};
6use crate::limits::InputLimits;
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10/// Ordered canonical messages for one transaction submission.
11#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
12pub struct CanonicalInput {
13    messages: Vec<CanonicalMessage>,
14}
15
16impl CanonicalInput {
17    /// Validate and construct input under the given limits.
18    pub fn try_new(
19        messages: Vec<CanonicalMessage>,
20        limits: &InputLimits,
21    ) -> Result<Self, InputValidationError> {
22        if messages.is_empty() {
23            return Err(InputValidationError::EmptyMessages);
24        }
25        if messages.len() > limits.max_messages {
26            return Err(InputValidationError::TooManyMessages {
27                count: messages.len(),
28                max: limits.max_messages,
29            });
30        }
31
32        let mut aggregate_text = 0usize;
33        let mut seen_tool_call_ids: Vec<String> = Vec::new();
34
35        for (index, msg) in messages.iter().enumerate() {
36            msg.validate(limits, index, &mut aggregate_text, &mut seen_tool_call_ids)?;
37        }
38
39        if aggregate_text > limits.max_aggregate_text_bytes {
40            return Err(InputValidationError::AggregateTextTooLarge {
41                bytes: aggregate_text,
42                max: limits.max_aggregate_text_bytes,
43            });
44        }
45
46        Ok(Self { messages })
47    }
48
49    /// Borrow messages in caller order.
50    pub fn messages(&self) -> &[CanonicalMessage] {
51        &self.messages
52    }
53
54    /// Consume into messages.
55    pub fn into_messages(self) -> Vec<CanonicalMessage> {
56        self.messages
57    }
58}
59
60/// One typed canonical message.
61#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
62pub enum CanonicalMessage {
63    /// System message (text parts required).
64    System {
65        /// Text content parts (non-empty).
66        content: Vec<TextPart>,
67        /// Optional bounded name.
68        name: Option<String>,
69    },
70    /// User message (text parts required).
71    User {
72        /// Text content parts (non-empty).
73        content: Vec<TextPart>,
74        /// Optional bounded name.
75        name: Option<String>,
76    },
77    /// Assistant message (text and/or tool calls).
78    Assistant {
79        /// Text parts (may be empty when tool_calls is non-empty).
80        content: Vec<TextPart>,
81        /// Historical or current assistant tool calls.
82        tool_calls: Vec<CanonicalAssistantToolCall>,
83    },
84    /// Tool result correlated to a preceding assistant tool call.
85    Tool {
86        /// Provider/tool-call id referenced by a prior assistant call.
87        tool_call_id: String,
88        /// Result text parts (non-empty).
89        content: Vec<TextPart>,
90    },
91}
92
93/// Non-empty text content part.
94#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
95pub struct TextPart {
96    text: String,
97}
98
99impl TextPart {
100    /// Construct a non-empty text part.
101    pub fn try_new(
102        text: impl Into<String>,
103        max_bytes: usize,
104    ) -> Result<Self, InputValidationError> {
105        let text = text.into();
106        if text.is_empty() {
107            return Err(InputValidationError::EmptyTextPart);
108        }
109        if text.len() > max_bytes {
110            return Err(InputValidationError::TextPartTooLarge {
111                bytes: text.len(),
112                max: max_bytes,
113            });
114        }
115        Ok(Self { text })
116    }
117
118    /// Borrow text.
119    pub fn text(&self) -> &str {
120        &self.text
121    }
122}
123
124/// Historical or live assistant tool call embedded in input.
125#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
126pub struct CanonicalAssistantToolCall {
127    /// Correlation id for a later [`CanonicalMessage::Tool`].
128    pub tool_call_id: String,
129    /// Tool name.
130    pub tool_name: ToolName,
131    /// JSON arguments object/value.
132    pub arguments: serde_json::Value,
133}
134
135impl CanonicalMessage {
136    fn validate(
137        &self,
138        limits: &InputLimits,
139        index: usize,
140        aggregate_text: &mut usize,
141        seen_tool_call_ids: &mut Vec<String>,
142    ) -> Result<(), InputValidationError> {
143        match self {
144            Self::System { content, name } | Self::User { content, name } => {
145                validate_name(name, limits)?;
146                validate_text_parts(content, limits, true, aggregate_text)?;
147            }
148            Self::Assistant {
149                content,
150                tool_calls,
151            } => {
152                if content.is_empty() && tool_calls.is_empty() {
153                    return Err(InputValidationError::EmptyAssistant { index });
154                }
155                validate_text_parts(content, limits, false, aggregate_text)?;
156                if tool_calls.len() > limits.max_tool_calls {
157                    return Err(InputValidationError::TooManyToolCalls {
158                        count: tool_calls.len(),
159                        max: limits.max_tool_calls,
160                    });
161                }
162                for call in tool_calls {
163                    validate_tool_call_id(&call.tool_call_id, limits)?;
164                    if seen_tool_call_ids.iter().any(|id| id == &call.tool_call_id) {
165                        return Err(InputValidationError::DuplicateToolCallId {
166                            id: call.tool_call_id.clone(),
167                        });
168                    }
169                    seen_tool_call_ids.push(call.tool_call_id.clone());
170                    validate_json_value(
171                        &call.arguments,
172                        limits.max_json_depth,
173                        limits.max_tool_argument_bytes,
174                    )?;
175                    let _ = call.tool_name.as_str(); // already validated at ToolName construction
176                }
177            }
178            Self::Tool {
179                tool_call_id,
180                content,
181            } => {
182                validate_tool_call_id(tool_call_id, limits)?;
183                if !seen_tool_call_ids.iter().any(|id| id == tool_call_id) {
184                    return Err(InputValidationError::UnknownToolCallId {
185                        id: tool_call_id.clone(),
186                    });
187                }
188                validate_text_parts(content, limits, true, aggregate_text)?;
189            }
190        }
191        Ok(())
192    }
193}
194
195fn validate_name(name: &Option<String>, limits: &InputLimits) -> Result<(), InputValidationError> {
196    if let Some(n) = name {
197        if n.is_empty() {
198            return Err(InputValidationError::EmptyName);
199        }
200        if n.len() > limits.max_name_bytes {
201            return Err(InputValidationError::NameTooLong {
202                bytes: n.len(),
203                max: limits.max_name_bytes,
204            });
205        }
206        if n.chars().any(|c| c.is_control()) {
207            return Err(InputValidationError::ControlCharacter);
208        }
209    }
210    Ok(())
211}
212
213fn validate_tool_call_id(id: &str, limits: &InputLimits) -> Result<(), InputValidationError> {
214    if id.is_empty() {
215        return Err(InputValidationError::EmptyToolCallId);
216    }
217    if id.len() > limits.max_tool_call_id_bytes {
218        return Err(InputValidationError::ToolCallIdTooLong {
219            bytes: id.len(),
220            max: limits.max_tool_call_id_bytes,
221        });
222    }
223    if id.chars().any(|c| c.is_control()) {
224        return Err(InputValidationError::ControlCharacter);
225    }
226    Ok(())
227}
228
229fn validate_text_parts(
230    parts: &[TextPart],
231    limits: &InputLimits,
232    require_non_empty: bool,
233    aggregate_text: &mut usize,
234) -> Result<(), InputValidationError> {
235    if require_non_empty && parts.is_empty() {
236        return Err(InputValidationError::EmptyTextParts);
237    }
238    if parts.len() > limits.max_content_parts {
239        return Err(InputValidationError::TooManyContentParts {
240            count: parts.len(),
241            max: limits.max_content_parts,
242        });
243    }
244    for p in parts {
245        if p.text.is_empty() {
246            return Err(InputValidationError::EmptyTextPart);
247        }
248        if p.text.len() > limits.max_text_part_bytes {
249            return Err(InputValidationError::TextPartTooLarge {
250                bytes: p.text.len(),
251                max: limits.max_text_part_bytes,
252            });
253        }
254        *aggregate_text = aggregate_text.saturating_add(p.text.len());
255    }
256    Ok(())
257}
258
259fn validate_json_value(
260    value: &serde_json::Value,
261    max_depth: u32,
262    max_bytes: usize,
263) -> Result<(), InputValidationError> {
264    let depth = json_depth(value);
265    if depth > max_depth {
266        return Err(InputValidationError::JsonTooDeep {
267            depth,
268            max: max_depth,
269        });
270    }
271    let encoded = serde_json::to_vec(value).map_err(|_| InputValidationError::JsonEncodeFailed)?;
272    if encoded.len() > max_bytes {
273        return Err(InputValidationError::ToolArgumentsTooLarge {
274            bytes: encoded.len(),
275            max: max_bytes,
276        });
277    }
278    Ok(())
279}
280
281fn json_depth(value: &serde_json::Value) -> u32 {
282    match value {
283        serde_json::Value::Array(items) => 1 + items.iter().map(json_depth).max().unwrap_or(0),
284        serde_json::Value::Object(map) => 1 + map.values().map(json_depth).max().unwrap_or(0),
285        _ => 1,
286    }
287}
288
289/// Helper to build a single-user-text input under default limits.
290pub fn user_text_input(text: impl Into<String>) -> Result<CanonicalInput, InputValidationError> {
291    let limits = InputLimits::default();
292    let part = TextPart::try_new(text, limits.max_text_part_bytes)?;
293    CanonicalInput::try_new(
294        vec![CanonicalMessage::User {
295            content: vec![part],
296            name: None,
297        }],
298        &limits,
299    )
300}
301
302/// Canonical input validation failure.
303#[derive(Clone, Debug, Error, PartialEq, Eq)]
304pub enum InputValidationError {
305    /// No messages.
306    #[error("canonical input requires at least one message")]
307    EmptyMessages,
308    /// Too many messages.
309    #[error("message count {count} exceeds max {max}")]
310    TooManyMessages {
311        /// Actual count.
312        count: usize,
313        /// Configured max.
314        max: usize,
315    },
316    /// Aggregate text too large.
317    #[error("aggregate text bytes {bytes} exceeds max {max}")]
318    AggregateTextTooLarge {
319        /// Actual bytes.
320        bytes: usize,
321        /// Configured max.
322        max: usize,
323    },
324    /// System/User/Tool without text parts.
325    #[error("message requires at least one text part")]
326    EmptyTextParts,
327    /// Empty text part.
328    #[error("text part must be non-empty")]
329    EmptyTextPart,
330    /// Text part too large.
331    #[error("text part bytes {bytes} exceeds max {max}")]
332    TextPartTooLarge {
333        /// Actual bytes.
334        bytes: usize,
335        /// Configured max.
336        max: usize,
337    },
338    /// Too many content parts.
339    #[error("content part count {count} exceeds max {max}")]
340    TooManyContentParts {
341        /// Actual count.
342        count: usize,
343        /// Configured max.
344        max: usize,
345    },
346    /// Assistant with neither text nor tool calls.
347    #[error("assistant message at index {index} is empty")]
348    EmptyAssistant {
349        /// Message index.
350        index: usize,
351    },
352    /// Too many tool calls.
353    #[error("tool call count {count} exceeds max {max}")]
354    TooManyToolCalls {
355        /// Actual count.
356        count: usize,
357        /// Configured max.
358        max: usize,
359    },
360    /// Duplicate tool_call_id in input.
361    #[error("duplicate tool_call_id {id}")]
362    DuplicateToolCallId {
363        /// Offending id.
364        id: String,
365    },
366    /// Tool message references unknown id.
367    #[error("tool message references unknown tool_call_id {id}")]
368    UnknownToolCallId {
369        /// Offending id.
370        id: String,
371    },
372    /// Empty tool_call_id.
373    #[error("tool_call_id must be non-empty")]
374    EmptyToolCallId,
375    /// tool_call_id too long.
376    #[error("tool_call_id bytes {bytes} exceeds max {max}")]
377    ToolCallIdTooLong {
378        /// Actual bytes.
379        bytes: usize,
380        /// Configured max.
381        max: usize,
382    },
383    /// Empty name.
384    #[error("message name must be non-empty when present")]
385    EmptyName,
386    /// Name too long.
387    #[error("name bytes {bytes} exceeds max {max}")]
388    NameTooLong {
389        /// Actual bytes.
390        bytes: usize,
391        /// Configured max.
392        max: usize,
393    },
394    /// Control character in a string field.
395    #[error("input string must not contain control characters")]
396    ControlCharacter,
397    /// JSON nesting too deep.
398    #[error("JSON depth {depth} exceeds max {max}")]
399    JsonTooDeep {
400        /// Actual depth.
401        depth: u32,
402        /// Configured max.
403        max: u32,
404    },
405    /// Tool arguments JSON too large.
406    #[error("tool argument bytes {bytes} exceeds max {max}")]
407    ToolArgumentsTooLarge {
408        /// Actual bytes.
409        bytes: usize,
410        /// Configured max.
411        max: usize,
412    },
413    /// JSON encode failed (unexpected).
414    #[error("JSON encode failed")]
415    JsonEncodeFailed,
416    /// Identity construction failed (tool name).
417    #[error(transparent)]
418    Identity(#[from] IdentityError),
419}
420
421// Silence unused constant import if only used in docs elsewhere.
422const _: usize = MAX_IDENTITY_BYTES;
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use crate::id::ToolName;
428
429    #[test]
430    fn requires_messages_and_text() {
431        let limits = InputLimits::default();
432        assert!(CanonicalInput::try_new(vec![], &limits).is_err());
433        let empty_user = CanonicalMessage::User {
434            content: vec![],
435            name: None,
436        };
437        assert!(CanonicalInput::try_new(vec![empty_user], &limits).is_err());
438    }
439
440    #[test]
441    fn tool_must_reference_prior_assistant_call() {
442        let limits = InputLimits::default();
443        let part = TextPart::try_new("ok", limits.max_text_part_bytes).unwrap();
444        let bad = CanonicalMessage::Tool {
445            tool_call_id: "missing".into(),
446            content: vec![part],
447        };
448        assert!(matches!(
449            CanonicalInput::try_new(vec![bad], &limits),
450            Err(InputValidationError::UnknownToolCallId { .. })
451        ));
452    }
453
454    #[test]
455    fn historical_tool_round_trip_ok() {
456        let limits = InputLimits::default();
457        let call = CanonicalAssistantToolCall {
458            tool_call_id: "c1".into(),
459            tool_name: ToolName::try_new("search").unwrap(),
460            arguments: serde_json::json!({"q": "x"}),
461        };
462        let messages = vec![
463            CanonicalMessage::User {
464                content: vec![TextPart::try_new("hi", limits.max_text_part_bytes).unwrap()],
465                name: None,
466            },
467            CanonicalMessage::Assistant {
468                content: vec![],
469                tool_calls: vec![call],
470            },
471            CanonicalMessage::Tool {
472                tool_call_id: "c1".into(),
473                content: vec![TextPart::try_new("result", limits.max_text_part_bytes).unwrap()],
474            },
475        ];
476        let input = CanonicalInput::try_new(messages, &limits).unwrap();
477        let json = serde_json::to_string(&input).unwrap();
478        let back: CanonicalInput = serde_json::from_str(&json).unwrap();
479        assert_eq!(input, back);
480    }
481}