converge_core/agent.rs
1// Copyright 2024-2025 Aprio One AB, Sweden
2// Author: Kenneth Pernyer, kenneth@aprio.one
3// SPDX-License-Identifier: LicenseRef-Proprietary
4// All rights reserved. This source code is proprietary and confidential.
5// Unauthorized copying, modification, or distribution is strictly prohibited.
6
7//! Agent trait and types for Converge.
8//!
9//! Agents are semantic capabilities that observe context and emit effects.
10//! They never call each other, never control flow, and never decide termination.
11
12use crate::context::{Context, ContextKey};
13use crate::effect::AgentEffect;
14
15/// Unique identifier for a registered agent.
16///
17/// Assigned monotonically at registration time.
18/// Used for deterministic effect merge ordering (see DECISIONS.md ยง1).
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub struct AgentId(pub(crate) u32);
21
22impl AgentId {
23 /// Returns the raw numeric ID.
24 #[must_use]
25 pub fn as_u32(self) -> u32 {
26 self.0
27 }
28}
29
30impl std::fmt::Display for AgentId {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 write!(f, "Agent({})", self.0)
33 }
34}
35
36/// A semantic capability that observes context and emits effects.
37///
38/// # Contract
39///
40/// Agents must:
41/// - Declare their dependencies (which context keys they care about)
42/// - Be pure in `accepts()` โ no side effects
43/// - Only read context in `execute()` โ never mutate
44/// - Return effects, not modify state directly
45///
46/// Agents must NOT:
47/// - Call other agents
48/// - Control execution flow
49/// - Decide when to terminate
50///
51/// # Example
52///
53/// ```ignore
54/// struct SeedAgent;
55///
56/// impl Agent for SeedAgent {
57/// fn name(&self) -> &str { "SeedAgent" }
58///
59/// fn dependencies(&self) -> &[ContextKey] { &[] }
60///
61/// fn accepts(&self, ctx: &Context) -> bool {
62/// !ctx.has(ContextKey::Seeds)
63/// }
64///
65/// fn execute(&self, _ctx: &Context) -> AgentEffect {
66/// AgentEffect::with_fact(Fact { ... })
67/// }
68/// }
69/// ```
70pub trait Agent: Send + Sync {
71 /// Human-readable name for debugging and tracing.
72 fn name(&self) -> &str;
73
74 /// Context keys this agent depends on.
75 ///
76 /// The engine uses this to build the dependency index.
77 /// An agent is only considered for re-evaluation when
78 /// one of its dependencies changes.
79 ///
80 /// Return `&[]` for agents that should run on every cycle
81 /// (e.g., seed agents that check for absence of data).
82 fn dependencies(&self) -> &[ContextKey];
83
84 /// Returns true if this agent should execute given the current context.
85 ///
86 /// This must be:
87 /// - Pure (no side effects)
88 /// - Deterministic (same context โ same result)
89 /// - Fast (called frequently)
90 fn accepts(&self, ctx: &Context) -> bool;
91
92 /// Execute the agent's logic and return effects.
93 ///
94 /// The agent receives immutable access to context.
95 /// All contributions must be returned as an `AgentEffect`.
96 ///
97 /// This may:
98 /// - Read context
99 /// - Call external tools (LLMs, APIs)
100 /// - Perform computation
101 ///
102 /// This must NOT:
103 /// - Mutate any shared state
104 /// - Call other agents
105 /// - Block indefinitely (respect timeouts)
106 fn execute(&self, ctx: &Context) -> AgentEffect;
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use crate::context::Fact;
113
114 /// A minimal test agent that emits one fact then stops.
115 struct TestAgent {
116 fact_id: String,
117 }
118
119 impl Agent for TestAgent {
120 fn name(&self) -> &'static str {
121 "TestAgent"
122 }
123
124 fn dependencies(&self) -> &[ContextKey] {
125 &[ContextKey::Seeds]
126 }
127
128 fn accepts(&self, ctx: &Context) -> bool {
129 // Only run if our fact doesn't exist yet
130 !ctx.get(ContextKey::Seeds)
131 .iter()
132 .any(|f| f.id == self.fact_id)
133 }
134
135 fn execute(&self, _ctx: &Context) -> AgentEffect {
136 AgentEffect::with_fact(Fact {
137 key: ContextKey::Seeds,
138 id: self.fact_id.clone(),
139 content: "test content".into(),
140 })
141 }
142 }
143
144 #[test]
145 fn agent_accepts_when_fact_missing() {
146 let agent = TestAgent {
147 fact_id: "test-1".into(),
148 };
149 let ctx = Context::new();
150
151 assert!(agent.accepts(&ctx));
152 }
153
154 #[test]
155 fn agent_rejects_when_fact_present() {
156 let agent = TestAgent {
157 fact_id: "test-1".into(),
158 };
159 let mut ctx = Context::new();
160 let _ = ctx.add_fact(Fact {
161 key: ContextKey::Seeds,
162 id: "test-1".into(),
163 content: "already here".into(),
164 });
165
166 assert!(!agent.accepts(&ctx));
167 }
168
169 #[test]
170 fn agent_produces_effect() {
171 let agent = TestAgent {
172 fact_id: "test-1".into(),
173 };
174 let ctx = Context::new();
175
176 let effect = agent.execute(&ctx);
177 assert_eq!(effect.facts.len(), 1);
178 assert_eq!(effect.facts[0].id, "test-1");
179 }
180
181 #[test]
182 fn agent_id_ordering() {
183 let a = AgentId(1);
184 let b = AgentId(2);
185 let c = AgentId(1);
186
187 assert!(a < b);
188 assert_eq!(a, c);
189 }
190}