1#![allow(clippy::unnecessary_literal_bound)]
8use crate::agent::Suggestor;
30use crate::context::{ContextKey, ProposedFact, TextPayload};
31use crate::effect::AgentEffect;
32use converge_pack::{Provenance, ProvenanceSource};
33
34#[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
47pub const CONVERGE_CORE_PROVENANCE: ConvergeCore = ConvergeCore;
49
50pub struct SeedSuggestor {
57 fact_id: String,
58 content: String,
59}
60
61impl SeedSuggestor {
62 #[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 &[] }
81
82 fn accepts(&self, ctx: &dyn crate::Context) -> bool {
83 !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
103pub struct ReactOnceSuggestor {
110 fact_id: String,
111 content: String,
112}
113
114impl ReactOnceSuggestor {
115 #[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] }
134
135 fn accepts(&self, ctx: &dyn crate::Context) -> bool {
136 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 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}