Skip to main content

leviath_core/mime/
inbound.rs

1//! A part on its way in: bytes a caller attached to a task, a region value
2//! or a message, before the run's store has them.
3//!
4//! This is the one shape every ingress speaks. `lev run --attach` builds
5//! one from a file, the HTTP API from an upload or a workdir path, the
6//! Agent Client Protocol from a content block, a sub-agent spawn from a
7//! parent's part. The bytes ride base64 on the newline-JSON control socket,
8//! which is a text transport; once a run has them they live in its blob
9//! store and only a [`super::BlobRef`] travels further.
10
11use serde::{Deserialize, Serialize};
12
13use super::{Delivery, MimeType};
14
15/// Bytes a caller attached, and where they should land.
16#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
17pub struct InboundPart {
18    /// The region to write the part to. `None` means wherever the text it
19    /// came with lands: the task region at spawn, the target region of a
20    /// message.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub region: Option<String>,
23    /// The part's name, usually the file name it came from.
24    pub name: String,
25    /// The type the caller declared, when it did. Sniffed otherwise.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub mime_type: Option<MimeType>,
28    /// How the part should reach a model, when the caller has a preference.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub deliver: Option<Delivery>,
31    /// Text to write beside the part in the same entry, when the caller gave
32    /// some (an `--attach` with no caption writes the part alone).
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub caption: Option<String>,
35    /// The bytes, base64 on the wire.
36    #[serde(with = "base64_bytes")]
37    pub data: Vec<u8>,
38}
39
40impl std::fmt::Debug for InboundPart {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("InboundPart")
43            .field("region", &self.region)
44            .field("name", &self.name)
45            .field("mime_type", &self.mime_type)
46            .field("deliver", &self.deliver)
47            .field("caption", &self.caption)
48            .field("data", &format_args!("{} bytes", self.data.len()))
49            .finish()
50    }
51}
52
53impl InboundPart {
54    /// A part from a file's bytes, named after the file.
55    pub fn from_bytes(name: impl Into<String>, data: Vec<u8>) -> Self {
56        Self {
57            region: None,
58            name: name.into(),
59            mime_type: None,
60            deliver: None,
61            caption: None,
62            data,
63        }
64    }
65
66    /// The same part, bound for `region`.
67    pub fn in_region(mut self, region: impl Into<String>) -> Self {
68        self.region = Some(region.into());
69        self
70    }
71
72    /// The same part, with its type declared.
73    pub fn typed(mut self, mime_type: MimeType) -> Self {
74        self.mime_type = Some(mime_type);
75        self
76    }
77
78    /// The same part, with a delivery preference.
79    pub fn delivered(mut self, deliver: Delivery) -> Self {
80        self.deliver = Some(deliver);
81        self
82    }
83
84    /// The same part, with text to write beside it.
85    pub fn captioned(mut self, caption: impl Into<String>) -> Self {
86        self.caption = Some(caption.into());
87        self
88    }
89}
90
91/// Bytes as a base64 string, for the transports that carry JSON.
92mod base64_bytes {
93    use base64::Engine;
94    use serde::{Deserialize, Deserializer, Serializer};
95
96    pub fn serialize<S: Serializer>(bytes: &[u8], s: S) -> Result<S::Ok, S::Error> {
97        s.serialize_str(&base64::engine::general_purpose::STANDARD.encode(bytes))
98    }
99
100    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
101        let text = String::deserialize(d)?;
102        base64::engine::general_purpose::STANDARD
103            .decode(text.as_bytes())
104            .map_err(serde::de::Error::custom)
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn builds_and_round_trips_as_base64() {
114        let part = InboundPart::from_bytes("a.png", vec![1, 2, 3])
115            .in_region("art")
116            .typed(MimeType::parse("image/png").unwrap())
117            .delivered(Delivery::Native)
118            .captioned("the hero");
119        let json = serde_json::to_string(&part).unwrap();
120        assert!(json.contains("\"data\":\"AQID\""), "{json}");
121        let back: InboundPart = serde_json::from_str(&json).unwrap();
122        assert_eq!(back, part);
123        assert!(format!("{part:?}").contains("3 bytes"));
124        assert!(!format!("{part:?}").contains("[1, 2, 3]"));
125        assert!(serde_json::from_str::<InboundPart>("{\"name\":\"x\",\"data\":\"!!\"}").is_err());
126        assert!(serde_json::from_str::<InboundPart>("{\"name\":\"x\",\"data\":5}").is_err());
127        let bare: InboundPart = serde_json::from_str("{\"name\":\"x\",\"data\":\"\"}").unwrap();
128        assert!(bare.data.is_empty() && bare.region.is_none());
129        assert_eq!(InboundPart::default().name, "");
130    }
131}