use std::cmp::Ordering;
use std::fmt;
use std::str::FromStr;
use mti::prelude::*;
use crate::agent_prefix::AgentPrefix;
use crate::constants::{AGENT_SUFFIX_LENGTH, MAX_AGENT_ID_LENGTH, MAX_AGENT_PREFIX_LENGTH};
use crate::error::AgentIdError;
const BASE32_ALPHABET: &str = "0123456789abcdefghjkmnpqrstvwxyz";
const FALLBACK_TYPE_CLASS: &str = "agent";
fn sanitize_prefix(input: &str) -> String {
let mut out = String::new();
for c in input.chars().map(|c| c.to_ascii_lowercase()) {
if out.len() == MAX_AGENT_PREFIX_LENGTH {
break;
}
match c {
'a'..='z' => out.push(c),
'_' if !out.is_empty() && !out.ends_with('_') => out.push('_'),
_ => {}
}
}
while out.ends_with('_') {
out.pop();
}
if out.is_empty() {
FALLBACK_TYPE_CLASS.to_string()
} else {
out
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AgentId {
prefix: AgentPrefix,
suffix: String,
inner: MagicTypeId,
}
impl AgentId {
#[must_use]
pub fn new(prefix: &str) -> Self {
let sanitized = sanitize_prefix(prefix);
let Ok(agent_prefix) = AgentPrefix::parse(&sanitized) else {
unreachable!("sanitize_prefix returns a parsable prefix, got {sanitized:?}")
};
Self::mint(agent_prefix)
}
pub fn try_new(prefix: &str) -> Result<Self, AgentIdError> {
let agent_prefix = AgentPrefix::parse(prefix).map_err(AgentIdError::InvalidPrefix)?;
Ok(Self::mint(agent_prefix))
}
fn mint(prefix: AgentPrefix) -> Self {
let suffix = TypeIdSuffix::new::<V7>();
let suffix_string = suffix.to_string();
let inner = MagicTypeId::new(prefix.as_str().create_prefix_sanitized(), suffix);
Self {
prefix,
suffix: suffix_string,
inner,
}
}
pub fn parse(input: &str) -> Result<Self, AgentIdError> {
if input.is_empty() {
return Err(AgentIdError::Empty);
}
if input.len() > MAX_AGENT_ID_LENGTH {
return Err(AgentIdError::TooLong {
max: MAX_AGENT_ID_LENGTH,
actual: input.len(),
});
}
let sep_idx = input.rfind('_').ok_or(AgentIdError::MissingSeparator)?;
if sep_idx == 0 {
return Err(AgentIdError::InvalidPrefix(
crate::error::AgentPrefixError::Empty,
));
}
let prefix_str = &input[..sep_idx];
let suffix_str = &input[sep_idx + 1..];
let prefix = AgentPrefix::parse(prefix_str).map_err(AgentIdError::InvalidPrefix)?;
Self::validate_suffix(suffix_str)?;
let inner =
MagicTypeId::from_str(input).map_err(|e| AgentIdError::TypeIdError(e.to_string()))?;
Ok(Self {
prefix,
suffix: suffix_str.to_string(),
inner,
})
}
#[must_use]
pub const fn prefix(&self) -> &AgentPrefix {
&self.prefix
}
#[must_use]
pub fn suffix(&self) -> &str {
&self.suffix
}
#[must_use]
pub const fn inner(&self) -> &MagicTypeId {
&self.inner
}
pub fn uuid(&self) -> Result<uuid::Uuid, AgentIdError> {
self.inner
.uuid()
.map_err(|e| AgentIdError::TypeIdError(e.to_string()))
}
fn validate_suffix(suffix: &str) -> Result<(), AgentIdError> {
if suffix.chars().count() != AGENT_SUFFIX_LENGTH {
return Err(AgentIdError::InvalidSuffix {
value: suffix.to_string(),
reason: "suffix must be exactly 26 characters",
});
}
let first = suffix
.chars()
.next()
.expect("already checked suffix is not empty");
if !('0'..='7').contains(&first) {
return Err(AgentIdError::InvalidSuffix {
value: suffix.to_string(),
reason: "first character must be 0-7",
});
}
for c in suffix.chars() {
if !BASE32_ALPHABET.contains(c) {
return Err(AgentIdError::InvalidSuffix {
value: suffix.to_string(),
reason: "contains invalid base32 character",
});
}
}
Ok(())
}
}
impl fmt::Display for AgentId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.inner)
}
}
impl FromStr for AgentId {
type Err = AgentIdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl AsRef<MagicTypeId> for AgentId {
fn as_ref(&self) -> &MagicTypeId {
&self.inner
}
}
impl TryFrom<&str> for AgentId {
type Error = AgentIdError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::parse(s)
}
}
impl PartialOrd for AgentId {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for AgentId {
fn cmp(&self, other: &Self) -> Ordering {
let (this, that): (&str, &str) = (self.inner.as_ref(), other.inner.as_ref());
this.cmp(that)
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for AgentId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for AgentId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Self::parse(&s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_new_id() {
let id = AgentId::new("llm_chat");
assert_eq!(id.prefix().as_str(), "llm_chat");
assert_eq!(id.suffix().len(), 26);
}
#[test]
fn creating_an_id_from_an_invalid_prefix_sanitizes_rather_than_panics() {
let id = AgentId::new("LLM Chat!");
assert_eq!(id.prefix().as_str(), "llmchat");
assert_eq!(id.suffix().len(), AGENT_SUFFIX_LENGTH);
}
#[test]
fn sanitizing_keeps_a_prefix_the_grammar_already_accepts() {
for prefix in [
"llm",
"a",
"llm_chat",
"llm_chat_streaming",
"composite",
&"a".repeat(MAX_AGENT_PREFIX_LENGTH),
] {
assert_eq!(sanitize_prefix(prefix), prefix);
assert_eq!(AgentId::new(prefix).prefix().as_str(), prefix);
}
}
#[test]
fn sanitizing_drops_what_the_grammar_has_no_room_for() {
assert_eq!(sanitize_prefix("LLM"), "llm");
assert_eq!(sanitize_prefix("llm-chat"), "llmchat");
assert_eq!(sanitize_prefix("llm2chat"), "llmchat");
assert_eq!(sanitize_prefix("llm chat"), "llmchat");
assert_eq!(sanitize_prefix("__llm__chat__"), "llm_chat");
assert_eq!(sanitize_prefix("llm\u{0130}chat"), "llmchat");
assert_eq!(sanitize_prefix("\u{1f300}"), FALLBACK_TYPE_CLASS);
}
#[test]
fn sanitizing_a_prefix_down_to_nothing_yields_the_fallback_class() {
for prefix in ["", "123", "!!!", "___", "-", " "] {
assert_eq!(
sanitize_prefix(prefix),
FALLBACK_TYPE_CLASS,
"{prefix:?} left something behind"
);
assert_eq!(AgentId::new(prefix).prefix().as_str(), FALLBACK_TYPE_CLASS);
}
}
#[test]
fn sanitizing_cuts_to_the_maximum_prefix_length() {
let sanitized = sanitize_prefix(&"a".repeat(MAX_AGENT_PREFIX_LENGTH + 37));
assert_eq!(sanitized.len(), MAX_AGENT_PREFIX_LENGTH);
let sanitized = sanitize_prefix(&format!("{}_b", "a".repeat(MAX_AGENT_PREFIX_LENGTH)));
assert_eq!(sanitized.len(), MAX_AGENT_PREFIX_LENGTH);
assert!(!sanitized.ends_with('_'));
}
#[test]
fn sanitizing_is_total() {
for prefix in [
"",
"!!!",
"___",
"9",
"_a",
"a_",
"A__B",
"\u{0130}",
"\u{200b}llm",
&"_".repeat(200),
&format!("{}_x", "z".repeat(MAX_AGENT_PREFIX_LENGTH - 1)),
] {
let sanitized = sanitize_prefix(prefix);
assert!(
AgentPrefix::parse(&sanitized).is_ok(),
"{prefix:?} sanitized to {sanitized:?}, which the grammar refuses"
);
}
}
#[test]
fn try_new_reports_what_new_would_have_rewritten() {
assert!(AgentId::try_new("llm_chat").is_ok());
for prefix in ["LLM Chat!", "", "123", "llm-chat"] {
assert!(
AgentId::try_new(prefix).is_err(),
"{prefix:?} was accepted verbatim"
);
}
}
#[test]
fn a_minted_id_always_parses_back() {
for prefix in ["llm_chat", "LLM Chat!", "123", "", &"a".repeat(200)] {
let minted = AgentId::new(prefix).to_string();
assert!(
AgentId::parse(&minted).is_ok(),
"{prefix:?} minted {minted:?}, which does not parse"
);
}
}
#[test]
fn parse_valid_id() {
let id = AgentId::parse("llm_chat_01h455vb4pex5vsknk084sn02q").unwrap();
assert_eq!(id.prefix().as_str(), "llm_chat");
assert_eq!(id.suffix(), "01h455vb4pex5vsknk084sn02q");
}
#[test]
fn parse_single_letter_prefix() {
let id = AgentId::parse("a_01h455vb4pex5vsknk084sn02q").unwrap();
assert_eq!(id.prefix().as_str(), "a");
assert_eq!(id.suffix(), "01h455vb4pex5vsknk084sn02q");
}
#[test]
fn parse_simple_prefix() {
let id = AgentId::parse("llm_01h455vb4pex5vsknk084sn02q").unwrap();
assert_eq!(id.prefix().as_str(), "llm");
}
#[test]
fn parse_prefix_with_consecutive_underscores() {
let id = AgentId::parse("llm__chat_01h455vb4pex5vsknk084sn02q").unwrap();
assert_eq!(id.prefix().as_str(), "llm__chat");
assert_eq!(id.suffix(), "01h455vb4pex5vsknk084sn02q");
}
#[test]
fn parse_empty_fails() {
let result = AgentId::parse("");
assert!(matches!(result, Err(AgentIdError::Empty)));
}
#[test]
fn parse_too_long_fails() {
let long_prefix = "a".repeat(65);
let input = format!("{long_prefix}_01h455vb4pex5vsknk084sn02q");
let result = AgentId::parse(&input);
assert!(matches!(result, Err(AgentIdError::TooLong { .. })));
}
#[test]
fn parse_missing_separator_fails() {
let result = AgentId::parse("llm01h455vb4pex5vsknk084sn02q");
assert!(matches!(result, Err(AgentIdError::MissingSeparator)));
}
#[test]
fn parse_invalid_prefix_fails() {
let result = AgentId::parse("LLM_01h455vb4pex5vsknk084sn02q");
assert!(matches!(result, Err(AgentIdError::InvalidPrefix(_))));
}
#[test]
fn parse_suffix_wrong_length_fails() {
let result = AgentId::parse("llm_01h455vb4pex");
assert!(matches!(result, Err(AgentIdError::InvalidSuffix { .. })));
}
#[test]
fn parse_suffix_invalid_first_char_fails() {
let result = AgentId::parse("llm_91h455vb4pex5vsknk084sn02q");
assert!(matches!(result, Err(AgentIdError::InvalidSuffix { .. })));
}
#[test]
fn parse_suffix_invalid_base32_char_fails() {
let result = AgentId::parse("llm_01h455vb4pex5vsknk084sn0iq");
assert!(matches!(result, Err(AgentIdError::InvalidSuffix { .. })));
}
#[test]
fn a_non_ascii_char_cannot_impersonate_an_alphabet_byte() {
let suffix = format!("0{}\u{0130}", "1".repeat(24));
assert_eq!(suffix.chars().count(), AGENT_SUFFIX_LENGTH);
let result = AgentId::parse(&format!("llm_{suffix}"));
let Err(AgentIdError::InvalidSuffix { reason, .. }) = result else {
panic!("a non-ASCII character in the suffix must be refused: {result:?}");
};
assert!(
reason.contains("base32"),
"refused for the wrong reason: {reason}"
);
}
#[test]
fn suffix_length_is_counted_in_characters() {
let suffix = format!("0{}1", "\u{0130}".repeat(12));
assert_eq!(suffix.len(), AGENT_SUFFIX_LENGTH);
assert_eq!(suffix.chars().count(), 14);
let result = AgentId::parse(&format!("llm_{suffix}"));
let Err(AgentIdError::InvalidSuffix { reason, .. }) = result else {
panic!("a fourteen-character suffix must be refused: {result:?}");
};
assert!(
reason.contains("26 characters"),
"refused for the wrong reason: {reason}"
);
}
#[test]
fn roundtrip_display_parse() {
let id1 = AgentId::new("llm_chat");
let display = id1.to_string();
let id2 = AgentId::parse(&display).unwrap();
assert_eq!(id1.prefix(), id2.prefix());
assert_eq!(id1.suffix(), id2.suffix());
}
#[test]
fn ordering_is_the_order_of_the_rendered_id() {
let ids = [
AgentId::parse("a_01h455vb4pex5vsknk084sn02q").unwrap(),
AgentId::parse("llm_01h455vb4pex5vsknk084sn02q").unwrap(),
AgentId::parse("llm_chat_01h455vb4pex5vsknk084sn02q").unwrap(),
AgentId::parse("llm_01h455vb4pex5vsknk084sn03q").unwrap(),
AgentId::parse("rule_01h455vb4pex5vsknk084sn02q").unwrap(),
];
for left in &ids {
for right in &ids {
assert_eq!(
left.cmp(right),
left.to_string().cmp(&right.to_string()),
"{left} vs {right}"
);
assert_eq!(left.cmp(right) == Ordering::Equal, left == right);
}
}
}
#[test]
fn ordering_is_not_the_underlying_type_id_ordering() {
let older_rule = AgentId::parse("rule_01h455vb4pex5vsknk084sn02q").unwrap();
let newer_llm = AgentId::parse("llm_01h455vb4pex5vsknk084sn03q").unwrap();
assert!(newer_llm < older_rule);
assert!(older_rule.inner() < newer_llm.inner());
}
}