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, TextPayload};
31use crate::effect::AgentEffect;
32use converge_pack::{Provenance, ProvenanceSource};
33
34/// Canonical provenance marker for reference suggestors shipped by
35/// `converge-core` itself (e.g., [`SeedSuggestor`], [`ReactOnceSuggestor`]).
36/// External crates declare their own marker; this one exists so the core's
37/// own demonstration suggestors satisfy the empty-provenance contract.
38#[derive(Copy, Clone, Debug)]
39pub struct ConvergeCore;
40
41impl ProvenanceSource for ConvergeCore {
42    fn as_str(&self) -> &'static str {
43        "converge-core"
44    }
45}
46
47/// Canonical provenance const for [`ConvergeCore`].
48pub const CONVERGE_CORE_PROVENANCE: ConvergeCore = ConvergeCore;
49
50/// A suggestor that emits an initial seed proposal once.
51///
52/// Demonstrates:
53/// - Suggestor with no dependencies (runs first)
54/// - Self-terminating behavior (checks if already contributed)
55/// - Monotonic context evolution
56pub struct SeedSuggestor {
57    fact_id: String,
58    content: String,
59}
60
61impl SeedSuggestor {
62    /// Creates a new seed suggestor.
63    #[must_use]
64    pub fn new(fact_id: impl Into<String>, content: impl Into<String>) -> Self {
65        Self {
66            fact_id: fact_id.into(),
67            content: content.into(),
68        }
69    }
70}
71
72#[async_trait::async_trait]
73impl Suggestor for SeedSuggestor {
74    fn name(&self) -> &str {
75        "SeedSuggestor"
76    }
77
78    fn dependencies(&self) -> &[ContextKey] {
79        &[] // No dependencies = eligible on first cycle
80    }
81
82    fn accepts(&self, ctx: &dyn crate::Context) -> bool {
83        // Only run if we haven't contributed yet
84        !ctx.get(ContextKey::Seeds)
85            .iter()
86            .any(|f| f.id().as_str() == self.fact_id)
87    }
88
89    async fn execute(&self, _ctx: &dyn crate::Context) -> AgentEffect {
90        AgentEffect::with_proposal(ProposedFact::new(
91            ContextKey::Seeds,
92            self.fact_id.clone(),
93            TextPayload::new(self.content.clone()),
94            self.provenance(),
95        ))
96    }
97
98    fn provenance(&self) -> Provenance {
99        CONVERGE_CORE_PROVENANCE.provenance()
100    }
101}
102
103/// A suggestor that reacts to seeds by emitting a hypothesis once.
104///
105/// Demonstrates:
106/// - Dependency-driven activation (only runs when Seeds change)
107/// - Data-driven behavior (reads context to decide)
108/// - Self-terminating (checks if already contributed)
109pub struct ReactOnceSuggestor {
110    fact_id: String,
111    content: String,
112}
113
114impl ReactOnceSuggestor {
115    /// Creates a new reactive suggestor.
116    #[must_use]
117    pub fn new(fact_id: impl Into<String>, content: impl Into<String>) -> Self {
118        Self {
119            fact_id: fact_id.into(),
120            content: content.into(),
121        }
122    }
123}
124
125#[async_trait::async_trait]
126impl Suggestor for ReactOnceSuggestor {
127    fn name(&self) -> &str {
128        "ReactOnceSuggestor"
129    }
130
131    fn dependencies(&self) -> &[ContextKey] {
132        &[ContextKey::Seeds] // Only wake when Seeds change
133    }
134
135    fn accepts(&self, ctx: &dyn crate::Context) -> bool {
136        // Run if: seeds exist AND we haven't contributed
137        ctx.has(ContextKey::Seeds)
138            && !ctx
139                .get(ContextKey::Hypotheses)
140                .iter()
141                .any(|f| f.id().as_str() == self.fact_id)
142    }
143
144    fn provenance(&self) -> Provenance {
145        CONVERGE_CORE_PROVENANCE.provenance()
146    }
147
148    async fn execute(&self, _ctx: &dyn crate::Context) -> AgentEffect {
149        AgentEffect::with_proposal(ProposedFact::new(
150            ContextKey::Hypotheses,
151            self.fact_id.clone(),
152            TextPayload::new(self.content.clone()),
153            self.provenance(),
154        ))
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::context::ContextState;
162    use crate::engine::Engine;
163
164    #[tokio::test]
165    async fn seed_agent_emits_once() {
166        let mut engine = Engine::new();
167        engine.register_suggestor(SeedSuggestor::new("s1", "value"));
168
169        let result = engine.run(ContextState::new()).await.expect("converges");
170
171        assert!(result.converged);
172        assert_eq!(result.context.get(ContextKey::Seeds).len(), 1);
173    }
174
175    #[tokio::test]
176    async fn react_once_agent_chains_from_seed() {
177        let mut engine = Engine::new();
178        engine.register_suggestor(SeedSuggestor::new("s1", "seed"));
179        engine.register_suggestor(ReactOnceSuggestor::new("h1", "hypothesis"));
180
181        let result = engine.run(ContextState::new()).await.expect("converges");
182
183        assert!(result.converged);
184        assert!(result.context.has(ContextKey::Seeds));
185        assert!(result.context.has(ContextKey::Hypotheses));
186    }
187
188    #[tokio::test]
189    async fn multiple_seeds_all_converge() {
190        let mut engine = Engine::new();
191        engine.register_suggestor(SeedSuggestor::new("s1", "first"));
192        engine.register_suggestor(SeedSuggestor::new("s2", "second"));
193        engine.register_suggestor(SeedSuggestor::new("s3", "third"));
194
195        let result = engine.run(ContextState::new()).await.expect("converges");
196
197        assert!(result.converged);
198        assert_eq!(result.context.get(ContextKey::Seeds).len(), 3);
199    }
200
201    #[tokio::test]
202    async fn chain_of_three_converges() {
203        /// Third suggestor in the chain.
204        struct StrategyAgent;
205
206        #[async_trait::async_trait]
207        impl Suggestor for StrategyAgent {
208            fn name(&self) -> &str {
209                "StrategyAgent"
210            }
211
212            fn dependencies(&self) -> &[ContextKey] {
213                &[ContextKey::Hypotheses]
214            }
215
216            fn accepts(&self, ctx: &dyn crate::Context) -> bool {
217                ctx.has(ContextKey::Hypotheses) && !ctx.has(ContextKey::Strategies)
218            }
219
220            async fn execute(&self, _ctx: &dyn crate::Context) -> AgentEffect {
221                AgentEffect::with_proposal(ProposedFact::new(
222                    ContextKey::Strategies,
223                    "strat-1",
224                    TextPayload::new("derived strategy"),
225                    self.provenance(),
226                ))
227            }
228
229            fn provenance(&self) -> Provenance {
230                CONVERGE_CORE_PROVENANCE.provenance()
231            }
232        }
233
234        let mut engine = Engine::new();
235        engine.register_suggestor(SeedSuggestor::new("s1", "seed"));
236        engine.register_suggestor(ReactOnceSuggestor::new("h1", "hypothesis"));
237        engine.register_suggestor(StrategyAgent);
238
239        let result = engine.run(ContextState::new()).await.expect("converges");
240
241        assert!(result.converged);
242        assert!(result.context.has(ContextKey::Seeds));
243        assert!(result.context.has(ContextKey::Hypotheses));
244        assert!(result.context.has(ContextKey::Strategies));
245    }
246}