Skip to main content

aa_core/topology/
edge.rs

1//! Domain types and trait for the agent-graph mesh edge model (AAASM-985).
2
3/// The six relationship kinds that can exist between agents in the topology graph.
4///
5/// Serialises to / deserialises from the snake_case wire string
6/// (e.g. `"delegates_to"`, `"calls"`) when the `serde` feature is active.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
9#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
10pub enum EdgeType {
11    /// Agent A has granted authority to Agent B to act on its behalf.
12    DelegatesTo,
13    /// Agent A invokes Agent B as a sub-agent or tool.
14    Calls,
15    /// Agent A reads data owned or produced by Agent B.
16    Reads,
17    /// Agent A writes data that Agent B owns or consumes.
18    Writes,
19    /// Agent A approves an action or output of Agent B.
20    Approves,
21    /// Agent A sends a message to Agent B.
22    Messages,
23}
24
25impl EdgeType {
26    /// Returns the canonical snake_case wire string for this edge type.
27    pub fn as_str(&self) -> &'static str {
28        match self {
29            EdgeType::DelegatesTo => "delegates_to",
30            EdgeType::Calls => "calls",
31            EdgeType::Reads => "reads",
32            EdgeType::Writes => "writes",
33            EdgeType::Approves => "approves",
34            EdgeType::Messages => "messages",
35        }
36    }
37
38    /// All six valid edge type variants in declaration order.
39    pub const ALL: &'static [EdgeType] = &[
40        EdgeType::DelegatesTo,
41        EdgeType::Calls,
42        EdgeType::Reads,
43        EdgeType::Writes,
44        EdgeType::Approves,
45        EdgeType::Messages,
46    ];
47}
48
49impl core::fmt::Display for EdgeType {
50    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
51        f.write_str(self.as_str())
52    }
53}
54
55/// Error returned when a string cannot be parsed into an [`EdgeType`].
56#[cfg(feature = "alloc")]
57#[derive(Debug)]
58pub struct UnknownEdgeType(pub alloc::string::String);
59
60#[cfg(feature = "alloc")]
61impl core::fmt::Display for UnknownEdgeType {
62    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
63        write!(f, "unknown edge type: {:?}", self.0)
64    }
65}
66
67#[cfg(feature = "alloc")]
68impl core::convert::TryFrom<&str> for EdgeType {
69    type Error = UnknownEdgeType;
70
71    fn try_from(s: &str) -> Result<Self, Self::Error> {
72        match s {
73            "delegates_to" => Ok(EdgeType::DelegatesTo),
74            "calls" => Ok(EdgeType::Calls),
75            "reads" => Ok(EdgeType::Reads),
76            "writes" => Ok(EdgeType::Writes),
77            "approves" => Ok(EdgeType::Approves),
78            "messages" => Ok(EdgeType::Messages),
79            other => Err(UnknownEdgeType(alloc::string::String::from(other))),
80        }
81    }
82}
83
84#[cfg(feature = "std")]
85impl std::str::FromStr for EdgeType {
86    type Err = UnknownEdgeType;
87
88    fn from_str(s: &str) -> Result<Self, Self::Err> {
89        EdgeType::try_from(s)
90    }
91}
92
93/// Input for recording a new directed edge between two agents.
94#[cfg(feature = "std")]
95#[derive(Debug, Clone)]
96pub struct NewEdge {
97    /// The agent that originates the relationship.
98    pub source: crate::identity::AgentId,
99    /// The agent that is the target of the relationship.
100    pub target: crate::identity::AgentId,
101    /// The kind of relationship.
102    pub edge_type: EdgeType,
103    /// Optional structured metadata (e.g. graph name, reason, key names).
104    pub metadata: Option<serde_json::Value>,
105}
106
107/// A recorded directed edge between two agents in the topology graph.
108#[cfg(feature = "std")]
109#[derive(Debug, Clone)]
110pub struct Edge {
111    /// Auto-assigned monotonically increasing identifier.
112    pub id: i64,
113    /// The agent that originates the relationship.
114    pub source: crate::identity::AgentId,
115    /// The agent that is the target of the relationship.
116    pub target: crate::identity::AgentId,
117    /// The kind of relationship.
118    pub edge_type: EdgeType,
119    /// When this edge was recorded.
120    pub created_at: chrono::DateTime<chrono::Utc>,
121    /// Optional structured metadata attached at emission time.
122    pub metadata: Option<serde_json::Value>,
123}
124
125/// Error returned by [`EdgeRepo`] operations.
126#[cfg(feature = "std")]
127#[derive(Debug, thiserror::Error)]
128pub enum EdgeRepoError {
129    /// The requested operation cannot be completed in the backing store.
130    #[error("edge store error: {0}")]
131    Store(String),
132}
133
134/// Async repository abstraction for the agent-graph edge store.
135///
136/// Implementations are provided by `InMemoryEdgeRepo` (tests and single-node
137/// deployments) and will be backed by a persistent store in production.
138/// All list methods return results ordered newest-first and silently cap
139/// `limit` at 1 000.
140#[cfg(feature = "std")]
141#[async_trait::async_trait]
142pub trait EdgeRepo: Send + Sync {
143    /// Record a new directed edge. Returns the auto-assigned `id`.
144    async fn insert(&self, edge: NewEdge) -> Result<i64, EdgeRepoError>;
145
146    /// Return up to `limit` outgoing edges from `source`, newest first.
147    ///
148    /// If `edge_type` is `Some`, only edges of that type are returned.
149    /// `limit` is silently capped at 1 000 by implementations.
150    async fn list_outgoing(
151        &self,
152        source: crate::identity::AgentId,
153        edge_type: Option<EdgeType>,
154        limit: u32,
155    ) -> Result<Vec<Edge>, EdgeRepoError>;
156
157    /// Return up to `limit` incoming edges to `target`, newest first.
158    ///
159    /// If `edge_type` is `Some`, only edges of that type are returned.
160    /// `limit` is silently capped at 1 000 by implementations.
161    async fn list_incoming(
162        &self,
163        target: crate::identity::AgentId,
164        edge_type: Option<EdgeType>,
165        limit: u32,
166    ) -> Result<Vec<Edge>, EdgeRepoError>;
167
168    /// Return up to `limit` edges of `edge_type` with `created_at >= since`, newest first.
169    ///
170    /// `limit` is silently capped at 1 000 by implementations.
171    async fn list_by_type(
172        &self,
173        edge_type: EdgeType,
174        since: chrono::DateTime<chrono::Utc>,
175        limit: u32,
176    ) -> Result<Vec<Edge>, EdgeRepoError>;
177}
178
179/// Test-only [`EdgeRepo`] that stores edges in memory with no secondary indexes.
180///
181/// Use this as a test double in unit tests that depend on [`EdgeRepo`] but
182/// whose assertion target is not the edge storage logic itself.
183/// Gated on the `test-utils` feature.
184#[cfg(all(feature = "std", feature = "test-utils"))]
185pub struct MockEdgeRepo {
186    inner: std::sync::Mutex<Vec<Edge>>,
187    next_id: std::sync::atomic::AtomicI64,
188}
189
190#[cfg(all(feature = "std", feature = "test-utils"))]
191impl MockEdgeRepo {
192    /// Create an empty `MockEdgeRepo`.
193    pub fn new() -> Self {
194        Self {
195            inner: std::sync::Mutex::new(Vec::new()),
196            next_id: std::sync::atomic::AtomicI64::new(1),
197        }
198    }
199
200    /// Return a snapshot of all recorded edges in insertion order.
201    pub fn snapshot(&self) -> Vec<Edge> {
202        self.inner.lock().expect("mock lock poisoned").clone()
203    }
204}
205
206#[cfg(all(feature = "std", feature = "test-utils"))]
207impl Default for MockEdgeRepo {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213#[cfg(all(feature = "std", feature = "test-utils"))]
214#[async_trait::async_trait]
215impl EdgeRepo for MockEdgeRepo {
216    async fn insert(&self, edge: NewEdge) -> Result<i64, EdgeRepoError> {
217        let id = self.next_id.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
218        let record = Edge {
219            id,
220            source: edge.source,
221            target: edge.target,
222            edge_type: edge.edge_type,
223            created_at: chrono::Utc::now(),
224            metadata: edge.metadata,
225        };
226        self.inner.lock().expect("mock lock poisoned").push(record);
227        Ok(id)
228    }
229
230    async fn list_outgoing(
231        &self,
232        source: crate::identity::AgentId,
233        edge_type: Option<EdgeType>,
234        limit: u32,
235    ) -> Result<Vec<Edge>, EdgeRepoError> {
236        let data = self.inner.lock().expect("mock lock poisoned");
237        Ok(data
238            .iter()
239            .filter(|e| e.source == source && edge_type.map_or(true, |et| e.edge_type == et))
240            .rev()
241            .take((limit as usize).min(1000))
242            .cloned()
243            .collect())
244    }
245
246    async fn list_incoming(
247        &self,
248        target: crate::identity::AgentId,
249        edge_type: Option<EdgeType>,
250        limit: u32,
251    ) -> Result<Vec<Edge>, EdgeRepoError> {
252        let data = self.inner.lock().expect("mock lock poisoned");
253        Ok(data
254            .iter()
255            .filter(|e| e.target == target && edge_type.map_or(true, |et| e.edge_type == et))
256            .rev()
257            .take((limit as usize).min(1000))
258            .cloned()
259            .collect())
260    }
261
262    async fn list_by_type(
263        &self,
264        edge_type: EdgeType,
265        since: chrono::DateTime<chrono::Utc>,
266        limit: u32,
267    ) -> Result<Vec<Edge>, EdgeRepoError> {
268        let data = self.inner.lock().expect("mock lock poisoned");
269        Ok(data
270            .iter()
271            .filter(|e| e.edge_type == edge_type && e.created_at >= since)
272            .rev()
273            .take((limit as usize).min(1000))
274            .cloned()
275            .collect())
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use core::convert::TryFrom;
283
284    #[test]
285    fn all_six_variants_parse_from_wire_strings() {
286        let cases = [
287            ("delegates_to", EdgeType::DelegatesTo),
288            ("calls", EdgeType::Calls),
289            ("reads", EdgeType::Reads),
290            ("writes", EdgeType::Writes),
291            ("approves", EdgeType::Approves),
292            ("messages", EdgeType::Messages),
293        ];
294        for (s, expected) in cases {
295            assert_eq!(EdgeType::try_from(s).unwrap(), expected, "parsing {s:?}");
296        }
297    }
298
299    #[test]
300    fn unknown_string_returns_error() {
301        assert!(EdgeType::try_from("follows").is_err());
302        assert!(EdgeType::try_from("").is_err());
303    }
304
305    #[test]
306    fn as_str_round_trips() {
307        for &variant in EdgeType::ALL {
308            assert_eq!(EdgeType::try_from(variant.as_str()).unwrap(), variant);
309        }
310    }
311
312    #[test]
313    fn from_str_parses_all_six_variants() {
314        use std::str::FromStr;
315        for &variant in EdgeType::ALL {
316            assert_eq!(EdgeType::from_str(variant.as_str()).unwrap(), variant);
317        }
318    }
319
320    #[test]
321    fn str_parse_parses_all_six_variants() {
322        for &variant in EdgeType::ALL {
323            assert_eq!(variant.as_str().parse::<EdgeType>().unwrap(), variant);
324        }
325    }
326
327    #[test]
328    fn display_matches_as_str() {
329        for &variant in EdgeType::ALL {
330            assert_eq!(format!("{variant}"), variant.as_str());
331        }
332    }
333
334    #[test]
335    fn all_contains_all_six_variants() {
336        assert_eq!(EdgeType::ALL.len(), 6);
337    }
338}