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