Skip to main content

aa_core/
agent.rs

1//! Agent execution context carrying identity, PID, and metadata.
2//!
3//! The primary type is [`AgentContext`], which flows through every governance
4//! event in the system. Requires the `alloc` feature.
5
6#[cfg(feature = "alloc")]
7use alloc::{collections::BTreeMap, string::String};
8
9#[cfg(feature = "alloc")]
10use crate::{
11    identity::{AgentId, SessionId},
12    time::Timestamp,
13    GovernanceLevel,
14};
15
16/// Identity carrier for an agent execution.
17///
18/// `AgentContext` flows through every governance event in the system.
19/// It captures the stable agent identity, per-session identity, process ID,
20/// start time, any additional runtime metadata, and optional topology/lineage
21/// fields that describe the agent's position in a delegation hierarchy.
22///
23/// Requires the `alloc` feature.
24#[cfg(feature = "alloc")]
25#[derive(Debug, Clone, PartialEq)]
26#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
27pub struct AgentContext {
28    /// Stable identifier for the agent (UUID v4 bytes).
29    pub agent_id: AgentId,
30    /// Per-execution session identifier (UUID v4 bytes).
31    pub session_id: SessionId,
32    /// OS process ID of the agent process.
33    pub pid: u32,
34    /// Nanoseconds since the Unix epoch when this context was created.
35    pub started_at: Timestamp,
36    /// Extensible key-value metadata attached to this execution context.
37    ///
38    /// Keys are owned `String` so the map is serde-compatible and accepts
39    /// both string-literal keys and computed keys at runtime.
40    pub metadata: BTreeMap<String, String>,
41    /// Governance level (L0–L3) carried for level-conditional policy rules.
42    ///
43    /// Populated by the gateway from the agent's `AgentRecord` (defined in
44    /// `aa-gateway`) at the boundary between transport and the policy
45    /// engine. Defaults to [`GovernanceLevel::L0Discover`] so old serialised
46    /// contexts — and callers that have not yet been updated — deserialise
47    /// or construct without churn.
48    #[cfg_attr(feature = "serde", serde(default))]
49    pub governance_level: GovernanceLevel,
50    /// The agent that spawned this one; `None` for root agents.
51    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
52    pub parent_agent_id: Option<AgentId>,
53    /// Team this agent belongs to; `None` if no team is assigned.
54    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
55    pub team_id: Option<String>,
56    /// Delegation depth — 0 for root agents, incremented by 1 per delegation level.
57    #[cfg_attr(feature = "serde", serde(default))]
58    pub depth: u32,
59    /// Human-readable reason the parent delegated to this agent.
60    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
61    pub delegation_reason: Option<String>,
62    /// Tool or framework that triggered the spawn (e.g. `"langgraph.subgraph"`).
63    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
64    pub spawned_by_tool: Option<String>,
65    /// Root of the delegation chain — the top-level agent that ultimately spawned this one.
66    ///
67    /// For root agents this equals `Some(agent_id)`.  For sub-agents it is set
68    /// server-side to `parent.root_agent_id.unwrap_or(parent.agent_id)` so that
69    /// any node in a delegation chain can resolve its root in O(1).
70    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none", default))]
71    pub root_agent_id: Option<AgentId>,
72}
73
74#[cfg(all(feature = "alloc", feature = "std"))]
75impl AgentContext {
76    /// Construct an [`AgentContext`] stamped at the current wall-clock time.
77    ///
78    /// `metadata` is initialised empty; insert entries after construction.
79    pub fn now(agent_id: AgentId, session_id: SessionId, pid: u32) -> Self {
80        Self {
81            started_at: Timestamp::from(std::time::SystemTime::now()),
82            agent_id,
83            session_id,
84            pid,
85            metadata: BTreeMap::new(),
86            governance_level: GovernanceLevel::default(),
87            parent_agent_id: None,
88            team_id: None,
89            depth: 0,
90            delegation_reason: None,
91            spawned_by_tool: None,
92            root_agent_id: None,
93        }
94    }
95
96    /// Return a fresh [`AgentContextBuilder`] for topology-aware construction.
97    pub fn builder() -> AgentContextBuilder {
98        AgentContextBuilder::new()
99    }
100}
101
102/// Fluent builder for [`AgentContext`] that allows populating optional topology
103/// and lineage fields before stamping the context at construction time.
104///
105/// Obtain one via [`AgentContext::builder()`].
106#[cfg(feature = "alloc")]
107pub struct AgentContextBuilder {
108    parent_agent_id: Option<AgentId>,
109    team_id: Option<String>,
110    depth: u32,
111    delegation_reason: Option<String>,
112    spawned_by_tool: Option<String>,
113    root_agent_id: Option<AgentId>,
114}
115
116#[cfg(feature = "alloc")]
117impl AgentContextBuilder {
118    fn new() -> Self {
119        Self {
120            parent_agent_id: None,
121            team_id: None,
122            depth: 0,
123            delegation_reason: None,
124            spawned_by_tool: None,
125            root_agent_id: None,
126        }
127    }
128
129    /// Set the agent that spawned this one.
130    pub fn parent_agent_id(mut self, id: AgentId) -> Self {
131        self.parent_agent_id = Some(id);
132        self
133    }
134
135    /// Set the team this agent belongs to.
136    pub fn team_id(mut self, id: String) -> Self {
137        self.team_id = Some(id);
138        self
139    }
140
141    /// Set the delegation depth (0 = root).
142    pub fn depth(mut self, d: u32) -> Self {
143        self.depth = d;
144        self
145    }
146
147    /// Set the human-readable reason the parent delegated to this agent.
148    pub fn delegation_reason(mut self, r: String) -> Self {
149        self.delegation_reason = Some(r);
150        self
151    }
152
153    /// Set the tool or framework that triggered the spawn.
154    pub fn spawned_by_tool(mut self, t: String) -> Self {
155        self.spawned_by_tool = Some(t);
156        self
157    }
158
159    /// Set the root agent of the delegation chain.
160    pub fn root_agent_id(mut self, id: AgentId) -> Self {
161        self.root_agent_id = Some(id);
162        self
163    }
164}
165
166#[cfg(all(feature = "alloc", feature = "std"))]
167impl AgentContextBuilder {
168    /// Consume the builder and construct an [`AgentContext`] stamped at the
169    /// current wall-clock time. `metadata` is initialised empty.
170    pub fn build(self, agent_id: AgentId, session_id: SessionId, pid: u32) -> AgentContext {
171        AgentContext {
172            started_at: Timestamp::from(std::time::SystemTime::now()),
173            agent_id,
174            session_id,
175            pid,
176            metadata: BTreeMap::new(),
177            governance_level: GovernanceLevel::default(),
178            parent_agent_id: self.parent_agent_id,
179            team_id: self.team_id,
180            depth: self.depth,
181            delegation_reason: self.delegation_reason,
182            spawned_by_tool: self.spawned_by_tool,
183            root_agent_id: self.root_agent_id,
184        }
185    }
186}
187
188#[cfg(all(test, feature = "alloc"))]
189mod tests {
190    use super::*;
191
192    const AGENT_BYTES: [u8; 16] = [1; 16];
193    const SESSION_BYTES: [u8; 16] = [2; 16];
194
195    fn make_context() -> AgentContext {
196        AgentContext {
197            agent_id: AgentId::from_bytes(AGENT_BYTES),
198            session_id: SessionId::from_bytes(SESSION_BYTES),
199            pid: 42,
200            started_at: Timestamp::from_nanos(1_000_000),
201            metadata: BTreeMap::new(),
202            governance_level: GovernanceLevel::default(),
203            parent_agent_id: None,
204            team_id: None,
205            depth: 0,
206            delegation_reason: None,
207            spawned_by_tool: None,
208            root_agent_id: None,
209        }
210    }
211
212    #[test]
213    fn field_access() {
214        let ctx = make_context();
215        assert_eq!(ctx.agent_id.as_bytes(), &AGENT_BYTES);
216        assert_eq!(ctx.session_id.as_bytes(), &SESSION_BYTES);
217        assert_eq!(ctx.pid, 42);
218        assert_eq!(ctx.started_at.as_nanos(), 1_000_000);
219        assert!(ctx.metadata.is_empty());
220    }
221
222    #[test]
223    fn clone_equals_original() {
224        let ctx = make_context();
225        assert_eq!(ctx.clone(), ctx);
226    }
227
228    #[test]
229    fn equality() {
230        let a = make_context();
231        let b = make_context();
232        assert_eq!(a, b);
233    }
234
235    #[test]
236    fn inequality_on_different_pid() {
237        let a = make_context();
238        let mut b = make_context();
239        b.pid = 99;
240        assert_ne!(a, b);
241    }
242
243    #[cfg(feature = "std")]
244    #[test]
245    fn now_constructor_sets_nonzero_timestamp() {
246        let ctx = AgentContext::now(
247            AgentId::from_bytes(AGENT_BYTES),
248            SessionId::from_bytes(SESSION_BYTES),
249            std::process::id(),
250        );
251        assert!(ctx.started_at.as_nanos() > 0);
252        assert!(ctx.metadata.is_empty());
253    }
254
255    #[cfg(feature = "std")]
256    #[test]
257    fn builder_defaults_give_root_agent() {
258        let ctx = AgentContext::builder().build(
259            AgentId::from_bytes(AGENT_BYTES),
260            SessionId::from_bytes(SESSION_BYTES),
261            42,
262        );
263        assert_eq!(ctx.depth, 0);
264        assert!(ctx.parent_agent_id.is_none());
265        assert!(ctx.team_id.is_none());
266        assert!(ctx.delegation_reason.is_none());
267        assert!(ctx.spawned_by_tool.is_none());
268        assert!(ctx.root_agent_id.is_none());
269    }
270
271    #[cfg(feature = "std")]
272    #[test]
273    fn builder_sets_parent_and_team() {
274        let parent = AgentId::from_bytes([9u8; 16]);
275        let ctx = AgentContext::builder()
276            .parent_agent_id(parent)
277            .team_id("team-alpha".into())
278            .depth(1)
279            .build(
280                AgentId::from_bytes(AGENT_BYTES),
281                SessionId::from_bytes(SESSION_BYTES),
282                42,
283            );
284        assert_eq!(ctx.parent_agent_id, Some(parent));
285        assert_eq!(ctx.team_id.as_deref(), Some("team-alpha"));
286        assert_eq!(ctx.depth, 1);
287    }
288
289    #[cfg(feature = "std")]
290    #[test]
291    fn builder_sets_delegation_fields() {
292        let ctx = AgentContext::builder()
293            .delegation_reason("summarise results".into())
294            .spawned_by_tool("langgraph.subgraph".into())
295            .build(
296                AgentId::from_bytes(AGENT_BYTES),
297                SessionId::from_bytes(SESSION_BYTES),
298                42,
299            );
300        assert_eq!(ctx.delegation_reason.as_deref(), Some("summarise results"));
301        assert_eq!(ctx.spawned_by_tool.as_deref(), Some("langgraph.subgraph"));
302    }
303
304    #[cfg(feature = "serde")]
305    #[test]
306    fn serde_round_trip() {
307        let original = make_context();
308        let json = serde_json::to_string(&original).expect("serialize");
309        let restored: AgentContext = serde_json::from_str(&json).expect("deserialize");
310        assert_eq!(original, restored);
311    }
312
313    #[cfg(feature = "serde")]
314    #[test]
315    fn serde_round_trip_with_topology_fields() {
316        let root = AgentId::from_bytes([7u8; 16]);
317        let original = AgentContext {
318            agent_id: AgentId::from_bytes(AGENT_BYTES),
319            session_id: SessionId::from_bytes(SESSION_BYTES),
320            pid: 42,
321            started_at: Timestamp::from_nanos(1_000_000),
322            metadata: BTreeMap::new(),
323            governance_level: GovernanceLevel::default(),
324            parent_agent_id: Some(AgentId::from_bytes([9u8; 16])),
325            team_id: Some("team-alpha".into()),
326            depth: 2,
327            delegation_reason: Some("summarise results".into()),
328            spawned_by_tool: Some("langgraph.subgraph".into()),
329            root_agent_id: Some(root),
330        };
331        let json = serde_json::to_string(&original).expect("serialize");
332        let restored: AgentContext = serde_json::from_str(&json).expect("deserialize");
333        assert_eq!(original, restored);
334    }
335
336    #[cfg(feature = "serde")]
337    #[test]
338    fn serde_backward_compat_missing_topology_fields() {
339        // Contexts serialised before topology fields were added must still
340        // deserialise — new fields must default to their zero values.
341        let json = r#"{
342            "agent_id":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
343            "session_id":[2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2],
344            "pid":42,
345            "started_at":1000000,
346            "metadata":{}
347        }"#;
348        let restored: AgentContext = serde_json::from_str(json).expect("deserialize");
349        assert_eq!(restored.depth, 0);
350        assert!(restored.parent_agent_id.is_none());
351        assert!(restored.team_id.is_none());
352        assert!(restored.delegation_reason.is_none());
353        assert!(restored.spawned_by_tool.is_none());
354        assert!(restored.root_agent_id.is_none());
355    }
356
357    #[cfg(feature = "serde")]
358    #[test]
359    fn agent_context_defaults_to_l0_when_governance_level_missing() {
360        // Old serialised contexts written before `governance_level` was
361        // added must still deserialise — the field must default to
362        // `L0Discover`. This is the runtime-stable backward-compat
363        // guarantee called out by AAASM-1041.
364        let json = r#"{
365            "agent_id":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
366            "session_id":[2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2],
367            "pid":42,
368            "started_at":1000000,
369            "metadata":{}
370        }"#;
371        let restored: AgentContext = serde_json::from_str(json).expect("deserialize");
372        assert_eq!(restored.governance_level, GovernanceLevel::L0Discover);
373    }
374}