Skip to main content

converge_core/
agents.rs

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