Skip to main content

codex_protocol/
agent_path.rs

1use schemars::JsonSchema;
2use serde::Deserialize;
3use serde::Serialize;
4use std::fmt;
5use std::ops::Deref;
6use std::str::FromStr;
7use ts_rs::TS;
8
9#[derive(
10    Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema, TS,
11)]
12#[serde(try_from = "String", into = "String")]
13#[schemars(with = "String")]
14#[ts(type = "string")]
15pub struct AgentPath(String);
16
17impl AgentPath {
18    pub const ROOT: &str = "/root";
19    pub const MORPHEUS: &str = "/morpheus";
20    const ROOT_SEGMENT: &str = "root";
21
22    pub fn root() -> Self {
23        Self(Self::ROOT.to_string())
24    }
25
26    pub fn morpheus() -> Self {
27        Self(Self::MORPHEUS.to_string())
28    }
29
30    pub fn from_string(path: String) -> Result<Self, String> {
31        validate_absolute_path(path.as_str())?;
32        Ok(Self(path))
33    }
34
35    pub fn as_str(&self) -> &str {
36        self.0.as_str()
37    }
38
39    pub fn is_root(&self) -> bool {
40        self.as_str() == Self::ROOT
41    }
42
43    pub fn name(&self) -> &str {
44        if self.is_root() {
45            return Self::ROOT_SEGMENT;
46        }
47        self.as_str()
48            .rsplit('/')
49            .next()
50            .filter(|segment| !segment.is_empty())
51            .unwrap_or(Self::ROOT_SEGMENT)
52    }
53
54    pub fn join(&self, agent_name: &str) -> Result<Self, String> {
55        validate_agent_name(agent_name)?;
56        Self::from_string(format!("{self}/{agent_name}"))
57    }
58
59    pub fn resolve(&self, reference: &str) -> Result<Self, String> {
60        if reference.is_empty() {
61            return Err("agent path must not be empty".to_string());
62        }
63        if reference == Self::ROOT {
64            return Ok(Self::root());
65        }
66        if reference.starts_with('/') {
67            return Self::try_from(reference);
68        }
69
70        validate_relative_reference(reference)?;
71        Self::from_string(format!("{self}/{reference}"))
72    }
73}
74
75impl TryFrom<String> for AgentPath {
76    type Error = String;
77
78    fn try_from(value: String) -> Result<Self, Self::Error> {
79        Self::from_string(value)
80    }
81}
82
83impl TryFrom<&str> for AgentPath {
84    type Error = String;
85
86    fn try_from(value: &str) -> Result<Self, Self::Error> {
87        Self::from_string(value.to_string())
88    }
89}
90
91impl From<AgentPath> for String {
92    fn from(value: AgentPath) -> Self {
93        value.0
94    }
95}
96
97impl FromStr for AgentPath {
98    type Err = String;
99
100    fn from_str(s: &str) -> Result<Self, Self::Err> {
101        Self::try_from(s)
102    }
103}
104
105impl AsRef<str> for AgentPath {
106    fn as_ref(&self) -> &str {
107        self.as_str()
108    }
109}
110
111impl Deref for AgentPath {
112    type Target = str;
113
114    fn deref(&self) -> &Self::Target {
115        self.as_str()
116    }
117}
118
119impl fmt::Display for AgentPath {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        f.write_str(self.as_str())
122    }
123}
124
125fn validate_agent_name(agent_name: &str) -> Result<(), String> {
126    if agent_name.is_empty() {
127        return Err("agent_name must not be empty".to_string());
128    }
129    if agent_name == AgentPath::ROOT_SEGMENT {
130        return Err("agent_name `root` is reserved".to_string());
131    }
132    if agent_name == "." || agent_name == ".." {
133        return Err(format!("agent_name `{agent_name}` is reserved"));
134    }
135    if agent_name.contains('/') {
136        return Err("agent_name must not contain `/`".to_string());
137    }
138    if !agent_name
139        .chars()
140        .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
141    {
142        return Err(
143            "agent_name must use only lowercase letters, digits, and underscores".to_string(),
144        );
145    }
146    Ok(())
147}
148
149fn validate_absolute_path(path: &str) -> Result<(), String> {
150    if path == AgentPath::MORPHEUS {
151        return Ok(());
152    }
153
154    let Some(stripped) = path.strip_prefix('/') else {
155        return Err("absolute agent paths must start with `/root` or be `/morpheus`".to_string());
156    };
157    let mut segments = stripped.split('/');
158    let Some(root) = segments.next() else {
159        return Err("absolute agent path must not be empty".to_string());
160    };
161    if root != AgentPath::ROOT_SEGMENT {
162        return Err("absolute agent paths must start with `/root` or be `/morpheus`".to_string());
163    }
164    if stripped.ends_with('/') {
165        return Err("absolute agent path must not end with `/`".to_string());
166    }
167    for segment in segments {
168        validate_agent_name(segment)?;
169    }
170    Ok(())
171}
172
173fn validate_relative_reference(reference: &str) -> Result<(), String> {
174    if reference.ends_with('/') {
175        return Err("relative agent path must not end with `/`".to_string());
176    }
177    for segment in reference.split('/') {
178        validate_agent_name(segment)?;
179    }
180    Ok(())
181}
182
183#[cfg(test)]
184mod tests {
185    use super::AgentPath;
186    use pretty_assertions::assert_eq;
187
188    #[test]
189    fn root_has_expected_name() {
190        let root = AgentPath::root();
191        assert_eq!(root.as_str(), AgentPath::ROOT);
192        assert_eq!(root.name(), "root");
193        assert!(root.is_root());
194    }
195
196    #[test]
197    fn morpheus_has_expected_name() {
198        let morpheus = AgentPath::morpheus();
199        assert_eq!(morpheus.as_str(), AgentPath::MORPHEUS);
200        assert_eq!(morpheus.name(), "morpheus");
201        assert!(!morpheus.is_root());
202    }
203
204    #[test]
205    fn join_builds_child_paths() {
206        let root = AgentPath::root();
207        let child = root.join("researcher").expect("child path");
208        assert_eq!(child.as_str(), "/root/researcher");
209        assert_eq!(child.name(), "researcher");
210    }
211
212    #[test]
213    fn resolve_supports_relative_and_absolute_references() {
214        let current = AgentPath::try_from("/root/researcher").expect("path");
215        assert_eq!(
216            current.resolve("worker").expect("relative path"),
217            AgentPath::try_from("/root/researcher/worker").expect("path")
218        );
219        assert_eq!(
220            current.resolve("/root/other").expect("absolute path"),
221            AgentPath::try_from("/root/other").expect("path")
222        );
223    }
224
225    #[test]
226    fn invalid_names_and_paths_are_rejected() {
227        assert_eq!(
228            AgentPath::root().join("BadName"),
229            Err("agent_name must use only lowercase letters, digits, and underscores".to_string())
230        );
231        assert_eq!(
232            AgentPath::try_from("/not-root"),
233            Err("absolute agent paths must start with `/root` or be `/morpheus`".to_string())
234        );
235        assert_eq!(
236            AgentPath::root().resolve("../sibling"),
237            Err("agent_name `..` is reserved".to_string())
238        );
239    }
240}