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}