converge_core/
agents.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//! Example agents for testing and demonstration.
8//!
9// Agent trait returns &str, but we return literals. This is fine.
10#![allow(clippy::unnecessary_literal_bound)]
11//!
12//! These agents prove the core convergence properties:
13//! - `SeedAgent`: Emits initial facts, stops when done
14//! - `ReactOnceAgent`: Reacts to changes, stops after one contribution
15//!
16//! # Example
17//!
18//! ```
19//! use converge_core::{Engine, Context, ContextKey};
20//! use converge_core::agents::{SeedAgent, ReactOnceAgent};
21//!
22//! let mut engine = Engine::new();
23//! engine.register(SeedAgent::new("seed-1", "initial value"));
24//! engine.register(ReactOnceAgent::new("hyp-1", "derived insight"));
25//!
26//! let result = engine.run(Context::new()).expect("converges");
27//! assert!(result.converged);
28//! assert!(result.context.has(ContextKey::Seeds));
29//! assert!(result.context.has(ContextKey::Hypotheses));
30//! ```
31
32use crate::agent::Agent;
33use crate::context::{Context, ContextKey, Fact};
34use crate::effect::AgentEffect;
35
36/// An agent that emits an initial seed fact once.
37///
38/// Demonstrates:
39/// - Agent with no dependencies (runs first)
40/// - Self-terminating behavior (checks if already contributed)
41/// - Monotonic context evolution
42pub struct SeedAgent {
43    fact_id: String,
44    content: String,
45}
46
47impl SeedAgent {
48    /// Creates a new seed agent.
49    #[must_use]
50    pub fn new(fact_id: impl Into<String>, content: impl Into<String>) -> Self {
51        Self {
52            fact_id: fact_id.into(),
53            content: content.into(),
54        }
55    }
56}
57
58impl Agent for SeedAgent {
59    fn name(&self) -> &str {
60        "SeedAgent"
61    }
62
63    fn dependencies(&self) -> &[ContextKey] {
64        &[] // No dependencies = eligible on first cycle
65    }
66
67    fn accepts(&self, ctx: &Context) -> bool {
68        // Only run if we haven't contributed yet
69        !ctx.get(ContextKey::Seeds)
70            .iter()
71            .any(|f| f.id == self.fact_id)
72    }
73
74    fn execute(&self, _ctx: &Context) -> AgentEffect {
75        AgentEffect::with_fact(Fact {
76            key: ContextKey::Seeds,
77            id: self.fact_id.clone(),
78            content: self.content.clone(),
79        })
80    }
81}
82
83/// An agent that reacts to seeds by emitting a hypothesis once.
84///
85/// Demonstrates:
86/// - Dependency-driven activation (only runs when Seeds change)
87/// - Data-driven behavior (reads context to decide)
88/// - Self-terminating (checks if already contributed)
89pub struct ReactOnceAgent {
90    fact_id: String,
91    content: String,
92}
93
94impl ReactOnceAgent {
95    /// Creates a new reactive agent.
96    #[must_use]
97    pub fn new(fact_id: impl Into<String>, content: impl Into<String>) -> Self {
98        Self {
99            fact_id: fact_id.into(),
100            content: content.into(),
101        }
102    }
103}
104
105impl Agent for ReactOnceAgent {
106    fn name(&self) -> &str {
107        "ReactOnceAgent"
108    }
109
110    fn dependencies(&self) -> &[ContextKey] {
111        &[ContextKey::Seeds] // Only wake when Seeds change
112    }
113
114    fn accepts(&self, ctx: &Context) -> bool {
115        // Run if: seeds exist AND we haven't contributed
116        ctx.has(ContextKey::Seeds)
117            && !ctx
118                .get(ContextKey::Hypotheses)
119                .iter()
120                .any(|f| f.id == self.fact_id)
121    }
122
123    fn execute(&self, _ctx: &Context) -> AgentEffect {
124        AgentEffect::with_fact(Fact {
125            key: ContextKey::Hypotheses,
126            id: self.fact_id.clone(),
127            content: self.content.clone(),
128        })
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::engine::Engine;
136
137    #[test]
138    fn seed_agent_emits_once() {
139        let mut engine = Engine::new();
140        engine.register(SeedAgent::new("s1", "value"));
141
142        let result = engine.run(Context::new()).expect("converges");
143
144        assert!(result.converged);
145        assert_eq!(result.context.get(ContextKey::Seeds).len(), 1);
146    }
147
148    #[test]
149    fn react_once_agent_chains_from_seed() {
150        let mut engine = Engine::new();
151        engine.register(SeedAgent::new("s1", "seed"));
152        engine.register(ReactOnceAgent::new("h1", "hypothesis"));
153
154        let result = engine.run(Context::new()).expect("converges");
155
156        assert!(result.converged);
157        assert!(result.context.has(ContextKey::Seeds));
158        assert!(result.context.has(ContextKey::Hypotheses));
159    }
160
161    #[test]
162    fn multiple_seeds_all_converge() {
163        let mut engine = Engine::new();
164        engine.register(SeedAgent::new("s1", "first"));
165        engine.register(SeedAgent::new("s2", "second"));
166        engine.register(SeedAgent::new("s3", "third"));
167
168        let result = engine.run(Context::new()).expect("converges");
169
170        assert!(result.converged);
171        assert_eq!(result.context.get(ContextKey::Seeds).len(), 3);
172    }
173
174    #[test]
175    fn chain_of_three_converges() {
176        /// Third agent in the chain.
177        struct StrategyAgent;
178
179        impl Agent for StrategyAgent {
180            fn name(&self) -> &str {
181                "StrategyAgent"
182            }
183
184            fn dependencies(&self) -> &[ContextKey] {
185                &[ContextKey::Hypotheses]
186            }
187
188            fn accepts(&self, ctx: &Context) -> bool {
189                ctx.has(ContextKey::Hypotheses) && !ctx.has(ContextKey::Strategies)
190            }
191
192            fn execute(&self, _ctx: &Context) -> AgentEffect {
193                AgentEffect::with_fact(Fact {
194                    key: ContextKey::Strategies,
195                    id: "strat-1".into(),
196                    content: "derived strategy".into(),
197                })
198            }
199        }
200
201        let mut engine = Engine::new();
202        engine.register(SeedAgent::new("s1", "seed"));
203        engine.register(ReactOnceAgent::new("h1", "hypothesis"));
204        engine.register(StrategyAgent);
205
206        let result = engine.run(Context::new()).expect("converges");
207
208        assert!(result.converged);
209        assert!(result.context.has(ContextKey::Seeds));
210        assert!(result.context.has(ContextKey::Hypotheses));
211        assert!(result.context.has(ContextKey::Strategies));
212    }
213}