Skip to main content

made_core/value_objects/
agent_kind.rs

1//! [`AgentKind`] value object.
2//!
3//! Identifier that tells the [`AgentFactoryPort`](crate::ports::AgentFactoryPort)
4//! which provider adapter should materialize an agent from a
5//! descriptor. MADE does not enumerate kinds itself —
6//! operators pick the labels (`"noop"`, `"vllm"`, `"anthropic"`,
7//! `"openai"`, `"rule"`, `"human"`, …) that match the factories wired
8//! in their composition root.
9
10use std::fmt;
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::DomainError;
15
16const MAX_AGENT_KIND_LEN: usize = 64;
17
18#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
19#[serde(transparent)]
20pub struct AgentKind(String);
21
22impl AgentKind {
23    pub fn new(raw: impl Into<String>) -> Result<Self, DomainError> {
24        let trimmed = raw.into().trim().to_owned();
25        if trimmed.is_empty() {
26            return Err(DomainError::EmptyField {
27                field: "agent.kind",
28            });
29        }
30        if trimmed.len() > MAX_AGENT_KIND_LEN {
31            return Err(DomainError::FieldTooLong {
32                field: "agent.kind",
33                actual: trimmed.len(),
34                max: MAX_AGENT_KIND_LEN,
35            });
36        }
37        if trimmed.chars().any(char::is_control) {
38            return Err(DomainError::InvalidCharacters {
39                field: "agent.kind",
40            });
41        }
42        Ok(Self(trimmed))
43    }
44
45    #[must_use]
46    pub fn as_str(&self) -> &str {
47        &self.0
48    }
49
50    #[must_use]
51    pub fn into_inner(self) -> String {
52        self.0
53    }
54}
55
56impl fmt::Display for AgentKind {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        f.write_str(&self.0)
59    }
60}
61
62impl TryFrom<&str> for AgentKind {
63    type Error = DomainError;
64    fn try_from(value: &str) -> Result<Self, Self::Error> {
65        Self::new(value)
66    }
67}
68
69impl TryFrom<String> for AgentKind {
70    type Error = DomainError;
71    fn try_from(value: String) -> Result<Self, Self::Error> {
72        Self::new(value)
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn arbitrary_label_is_accepted() {
82        let k = AgentKind::new("vllm").unwrap();
83        assert_eq!(k.as_str(), "vllm");
84    }
85
86    #[test]
87    fn label_is_trimmed() {
88        assert_eq!(AgentKind::new("  noop  ").unwrap().as_str(), "noop");
89    }
90
91    #[test]
92    fn empty_is_rejected() {
93        assert!(matches!(
94            AgentKind::new("   ").unwrap_err(),
95            DomainError::EmptyField {
96                field: "agent.kind"
97            }
98        ));
99    }
100
101    #[test]
102    fn overlong_is_rejected() {
103        let err = AgentKind::new("a".repeat(MAX_AGENT_KIND_LEN + 1)).unwrap_err();
104        assert!(matches!(err, DomainError::FieldTooLong { .. }));
105    }
106
107    #[test]
108    fn control_characters_are_rejected() {
109        assert!(matches!(
110            AgentKind::new("bad\nkind").unwrap_err(),
111            DomainError::InvalidCharacters {
112                field: "agent.kind"
113            }
114        ));
115    }
116
117    #[test]
118    fn display_matches_inner() {
119        assert_eq!(AgentKind::new("rule").unwrap().to_string(), "rule");
120    }
121
122    #[test]
123    fn serde_is_transparent() {
124        let k = AgentKind::new("anthropic").unwrap();
125        assert_eq!(serde_json::to_string(&k).unwrap(), "\"anthropic\"");
126    }
127}