Skip to main content

agent_uri/
agent_id.rs

1//! Agent identifier type using `TypeID` format.
2//!
3//! # Grammar Reference
4//!
5//! The agent ID grammar is defined in `grammar.abnf`:
6//!
7//! ```abnf
8//! agent-id     = agent-prefix "_" type-suffix
9//! agent-prefix = type-class *( "_" type-modifier )
10//! type-class   = "llm" / "rule" / "human" / "composite"
11//!              / "sensor" / "actuator" / "hybrid"
12//!              / extension-class
13//! type-suffix  = suffix-first 25( BASE32-TYPEID )
14//! suffix-first = %x30-37  ; "0" through "7"
15//! ```
16//!
17//! Maximum agent ID length: 90 characters (63 prefix + 1 underscore + 26 suffix).
18
19use std::cmp::Ordering;
20use std::fmt;
21use std::str::FromStr;
22
23use mti::prelude::*;
24
25use crate::agent_prefix::AgentPrefix;
26use crate::constants::{AGENT_SUFFIX_LENGTH, MAX_AGENT_ID_LENGTH};
27use crate::error::AgentIdError;
28
29/// Base32 alphabet for `TypeID` suffix (Crockford-derived).
30/// Excludes: i, l, o, u (visually ambiguous).
31const BASE32_ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
32
33/// A validated agent identifier in `TypeID` format.
34///
35/// The agent ID uniquely identifies an agent and encodes:
36/// - Semantic classification (the prefix)
37/// - Unique identity (the `UUIDv7` suffix)
38/// - Creation timestamp (encoded in `UUIDv7`)
39///
40/// # Format
41///
42/// `<prefix>_<suffix>` where:
43/// - prefix: 1-63 lowercase letters and underscores
44/// - suffix: 26 character base32-encoded `UUIDv7`
45///
46/// # Examples
47///
48/// ```
49/// use agent_uri::AgentId;
50///
51/// let id = AgentId::parse("llm_01h455vb4pex5vsknk084sn02q").unwrap();
52/// assert_eq!(id.prefix().as_str(), "llm");
53/// assert_eq!(id.suffix(), "01h455vb4pex5vsknk084sn02q");
54///
55/// // Create a new agent ID
56/// let id = AgentId::new("llm_chat");
57/// assert_eq!(id.suffix().len(), 26);
58/// ```
59#[derive(Debug, Clone, PartialEq, Eq, Hash)]
60pub struct AgentId {
61    prefix: AgentPrefix,
62    suffix: String,
63    inner: MagicTypeId,
64}
65
66impl AgentId {
67    /// Creates a new agent ID with the given prefix and a fresh `UUIDv7`.
68    ///
69    /// # Panics
70    ///
71    /// Panics if the prefix is invalid. Use `AgentId::try_new` for fallible creation.
72    #[must_use]
73    pub fn new(prefix: &str) -> Self {
74        Self::try_new(prefix).expect("valid prefix")
75    }
76
77    /// Creates a new agent ID with the given prefix and a fresh `UUIDv7`.
78    ///
79    /// # Errors
80    ///
81    /// Returns `AgentIdError` if the prefix is invalid.
82    pub fn try_new(prefix: &str) -> Result<Self, AgentIdError> {
83        let agent_prefix = AgentPrefix::parse(prefix).map_err(AgentIdError::InvalidPrefix)?;
84        let type_id = prefix.create_type_id::<V7>();
85        let suffix = type_id
86            .suffix_str()
87            .map_err(|e| AgentIdError::TypeIdError(e.to_string()))?;
88
89        Ok(Self {
90            prefix: agent_prefix,
91            suffix,
92            inner: type_id,
93        })
94    }
95
96    /// Parses an agent ID from a string.
97    ///
98    /// # Errors
99    ///
100    /// Returns `AgentIdError` if:
101    /// - The input is empty
102    /// - The input exceeds 90 characters
103    /// - The prefix is invalid
104    /// - The suffix is invalid (not valid base32 or wrong length)
105    /// - The separator is missing
106    pub fn parse(input: &str) -> Result<Self, AgentIdError> {
107        if input.is_empty() {
108            return Err(AgentIdError::Empty);
109        }
110
111        if input.len() > MAX_AGENT_ID_LENGTH {
112            return Err(AgentIdError::TooLong {
113                max: MAX_AGENT_ID_LENGTH,
114                actual: input.len(),
115            });
116        }
117
118        // Find the last underscore (separator between prefix and suffix)
119        let sep_idx = input.rfind('_').ok_or(AgentIdError::MissingSeparator)?;
120
121        if sep_idx == 0 {
122            return Err(AgentIdError::InvalidPrefix(
123                crate::error::AgentPrefixError::Empty,
124            ));
125        }
126
127        let prefix_str = &input[..sep_idx];
128        let suffix_str = &input[sep_idx + 1..];
129
130        // Validate prefix
131        let prefix = AgentPrefix::parse(prefix_str).map_err(AgentIdError::InvalidPrefix)?;
132
133        // Validate suffix
134        Self::validate_suffix(suffix_str)?;
135
136        // Parse as MagicTypeId
137        let inner =
138            MagicTypeId::from_str(input).map_err(|e| AgentIdError::TypeIdError(e.to_string()))?;
139
140        Ok(Self {
141            prefix,
142            suffix: suffix_str.to_string(),
143            inner,
144        })
145    }
146
147    /// Returns the prefix (semantic classification).
148    #[must_use]
149    pub const fn prefix(&self) -> &AgentPrefix {
150        &self.prefix
151    }
152
153    /// Returns the suffix (base32-encoded `UUIDv7`).
154    #[must_use]
155    pub fn suffix(&self) -> &str {
156        &self.suffix
157    }
158
159    /// Returns the underlying [`MagicTypeId`].
160    #[must_use]
161    pub const fn inner(&self) -> &MagicTypeId {
162        &self.inner
163    }
164
165    /// Returns the UUID from the suffix.
166    ///
167    /// # Errors
168    ///
169    /// Returns an error if UUID extraction fails.
170    pub fn uuid(&self) -> Result<uuid::Uuid, AgentIdError> {
171        self.inner
172            .uuid()
173            .map_err(|e| AgentIdError::TypeIdError(e.to_string()))
174    }
175
176    fn validate_suffix(suffix: &str) -> Result<(), AgentIdError> {
177        if suffix.len() != AGENT_SUFFIX_LENGTH {
178            return Err(AgentIdError::InvalidSuffix {
179                value: suffix.to_string(),
180                reason: "suffix must be exactly 26 characters",
181            });
182        }
183
184        // First character must be 0-7 (prevent 130-bit overflow)
185        let first = suffix
186            .chars()
187            .next()
188            .expect("already checked suffix is not empty");
189        if !('0'..='7').contains(&first) {
190            return Err(AgentIdError::InvalidSuffix {
191                value: suffix.to_string(),
192                reason: "first character must be 0-7",
193            });
194        }
195
196        // All characters must be valid base32
197        for c in suffix.chars() {
198            if !BASE32_ALPHABET.contains(&(c as u8)) {
199                return Err(AgentIdError::InvalidSuffix {
200                    value: suffix.to_string(),
201                    reason: "contains invalid base32 character",
202                });
203            }
204        }
205
206        Ok(())
207    }
208}
209
210impl fmt::Display for AgentId {
211    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212        write!(f, "{}", self.inner)
213    }
214}
215
216impl FromStr for AgentId {
217    type Err = AgentIdError;
218
219    fn from_str(s: &str) -> Result<Self, Self::Err> {
220        Self::parse(s)
221    }
222}
223
224impl AsRef<MagicTypeId> for AgentId {
225    fn as_ref(&self) -> &MagicTypeId {
226        &self.inner
227    }
228}
229
230impl TryFrom<&str> for AgentId {
231    type Error = AgentIdError;
232
233    fn try_from(s: &str) -> Result<Self, Self::Error> {
234        Self::parse(s)
235    }
236}
237
238impl PartialOrd for AgentId {
239    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
240        Some(self.cmp(other))
241    }
242}
243
244impl Ord for AgentId {
245    fn cmp(&self, other: &Self) -> Ordering {
246        self.to_string().cmp(&other.to_string())
247    }
248}
249
250#[cfg(feature = "serde")]
251impl serde::Serialize for AgentId {
252    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
253    where
254        S: serde::Serializer,
255    {
256        serializer.serialize_str(&self.to_string())
257    }
258}
259
260#[cfg(feature = "serde")]
261impl<'de> serde::Deserialize<'de> for AgentId {
262    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
263    where
264        D: serde::Deserializer<'de>,
265    {
266        let s = String::deserialize(deserializer)?;
267        Self::parse(&s).map_err(serde::de::Error::custom)
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn create_new_id() {
277        let id = AgentId::new("llm_chat");
278        assert_eq!(id.prefix().as_str(), "llm_chat");
279        assert_eq!(id.suffix().len(), 26);
280    }
281
282    #[test]
283    fn parse_valid_id() {
284        let id = AgentId::parse("llm_chat_01h455vb4pex5vsknk084sn02q").unwrap();
285        assert_eq!(id.prefix().as_str(), "llm_chat");
286        assert_eq!(id.suffix(), "01h455vb4pex5vsknk084sn02q");
287    }
288
289    #[test]
290    fn parse_simple_prefix() {
291        let id = AgentId::parse("llm_01h455vb4pex5vsknk084sn02q").unwrap();
292        assert_eq!(id.prefix().as_str(), "llm");
293    }
294
295    #[test]
296    fn parse_empty_fails() {
297        let result = AgentId::parse("");
298        assert!(matches!(result, Err(AgentIdError::Empty)));
299    }
300
301    #[test]
302    fn parse_too_long_fails() {
303        let long_prefix = "a".repeat(65);
304        let input = format!("{long_prefix}_01h455vb4pex5vsknk084sn02q");
305        let result = AgentId::parse(&input);
306        assert!(matches!(result, Err(AgentIdError::TooLong { .. })));
307    }
308
309    #[test]
310    fn parse_missing_separator_fails() {
311        let result = AgentId::parse("llm01h455vb4pex5vsknk084sn02q");
312        assert!(matches!(result, Err(AgentIdError::MissingSeparator)));
313    }
314
315    #[test]
316    fn parse_invalid_prefix_fails() {
317        let result = AgentId::parse("LLM_01h455vb4pex5vsknk084sn02q");
318        assert!(matches!(result, Err(AgentIdError::InvalidPrefix(_))));
319    }
320
321    #[test]
322    fn parse_suffix_wrong_length_fails() {
323        let result = AgentId::parse("llm_01h455vb4pex");
324        assert!(matches!(result, Err(AgentIdError::InvalidSuffix { .. })));
325    }
326
327    #[test]
328    fn parse_suffix_invalid_first_char_fails() {
329        // First char must be 0-7
330        let result = AgentId::parse("llm_91h455vb4pex5vsknk084sn02q");
331        assert!(matches!(result, Err(AgentIdError::InvalidSuffix { .. })));
332    }
333
334    #[test]
335    fn parse_suffix_invalid_base32_char_fails() {
336        // 'i' is not in base32 alphabet
337        let result = AgentId::parse("llm_01h455vb4pex5vsknk084sn0iq");
338        assert!(matches!(result, Err(AgentIdError::InvalidSuffix { .. })));
339    }
340
341    #[test]
342    fn roundtrip_display_parse() {
343        let id1 = AgentId::new("llm_chat");
344        let display = id1.to_string();
345        let id2 = AgentId::parse(&display).unwrap();
346        assert_eq!(id1.prefix(), id2.prefix());
347        assert_eq!(id1.suffix(), id2.suffix());
348    }
349}