converge_pack/agent.rs
1// Copyright 2024-2026 Reflective Labs
2
3// SPDX-License-Identifier: MIT
4
5//! The Suggestor trait — the contract all suggestor implementations satisfy.
6//!
7//! # Why this shape?
8//!
9//! Suggestors in Converge are not actors, not services, not workflow steps.
10//! They are pure functions over context: given the current state of shared
11//! context, a suggestor decides whether to act (`accepts`) and what to
12//! contribute (`execute`).
13//!
14//! This design makes suggestors:
15//! - **Deterministic**: same context → same decision.
16//! - **Composable**: suggestors don't know about each other, only about context.
17//! - **Testable**: mock the context, assert the effect.
18//!
19//! # Critical rules
20//!
21//! - `accepts()` must be **pure** — no side effects, no I/O, no mutations.
22//! - `execute()` is **read-only** — it reads context and returns an effect.
23//! - Suggestors **never call other suggestors** — all communication via shared context.
24//! - **Idempotency is context-based** — check for existing contributions in
25//! context, not internal state. Internal `has_run` flags violate the
26//! "context is the only shared state" axiom.
27
28use crate::context::{Context, ContextKey};
29use crate::effect::AgentEffect;
30use crate::fact::Provenance;
31
32/// The core suggestor contract.
33///
34/// Every suggestor in the Converge ecosystem implements this trait — whether
35/// it wraps an LLM, a policy engine, an optimizer, analytics, knowledge
36/// retrieval, or a simple rule.
37///
38/// The engine calls `accepts()` to determine eligibility, then `execute()`
39/// to collect effects. Effects are merged by the engine in deterministic
40/// registration order via [`crate::types::SuggestorId`].
41///
42/// # Async
43///
44/// `execute()` is async, allowing suggestors to call LLM providers, search
45/// backends, and other I/O without blocking. The engine awaits each
46/// suggestor and controls concurrency — suggestors don't need to manage
47/// their own parallelism.
48///
49/// # Thread Safety
50///
51/// Suggestors must be `Send + Sync` because the engine may execute eligible
52/// suggestors concurrently in the future.
53#[async_trait::async_trait]
54pub trait Suggestor: Send + Sync {
55 /// Human-readable name, used for logging and provenance.
56 ///
57 /// Must be unique within a convergence run. Deterministic execution order
58 /// is derived from registration order, not lexical name sorting.
59 fn name(&self) -> &str;
60
61 /// Context keys this suggestor reads from.
62 ///
63 /// The engine uses this to determine when a suggestor becomes eligible:
64 /// a suggestor is a candidate when at least one of its dependency keys
65 /// has been modified since the last cycle.
66 fn dependencies(&self) -> &[ContextKey];
67
68 /// Pure predicate: should this suggestor execute given the current context?
69 ///
70 /// # Contract
71 ///
72 /// - Must be **pure**: no side effects, no I/O, no state mutation.
73 /// - Must be **deterministic**: same context → same answer.
74 /// - Must check **idempotency via context**: look for your own
75 /// contributions in context (both `Proposals` and target key),
76 /// not internal flags.
77 fn accepts(&self, ctx: &dyn Context) -> bool;
78
79 /// Produce effects given the current context.
80 ///
81 /// # Contract
82 ///
83 /// - **Read-only**: do not mutate context. Return effects instead.
84 /// - Effects are collected by the engine and merged after all
85 /// eligible suggestors have executed.
86 /// - For LLM suggestors: emit `ProposedFact` to `ContextKey::Proposals`,
87 /// not directly to the target key.
88 async fn execute(&self, ctx: &dyn Context) -> AgentEffect;
89
90 /// Typed provenance for this suggestor's emitted facts and execution span.
91 ///
92 /// Used by both the engine's tracing layer and by `ProposedFact::new`
93 /// callers that want to cite the suggestor as the proposal's origin. The
94 /// emitted proposal still remains the authoritative audit boundary: every
95 /// [`ProposedFact`](crate::ProposedFact) must carry a non-empty
96 /// [`Provenance`](crate::Provenance).
97 ///
98 /// The default impl derives the provenance from [`name`](Self::name),
99 /// which is sufficient for span labels and for proposal provenance when
100 /// the suggestor's identity is exhausted by its name. Fact-emitting
101 /// suggestors that carry richer identity claims (versioned model,
102 /// capability scope, source URI) should override this with their
103 /// crate-canonical typed provenance constructor:
104 ///
105 /// ```ignore
106 /// fn provenance(&self) -> Provenance { ARBITER_PROVENANCE.clone() }
107 /// ```
108 ///
109 /// Empty provenance on an emitted proposal is a kernel error; the
110 /// default impl above never produces empty provenance as long as
111 /// `name()` is non-empty.
112 fn provenance(&self) -> Provenance {
113 Provenance::new(self.name())
114 }
115
116 /// Algorithmic complexity of this suggestor's core computation.
117 ///
118 /// Returns a short string describing time complexity and practical scale
119 /// guidance. `None` means negligible / not applicable (e.g. pure LLM
120 /// calls where latency is network-bound, not algorithmic).
121 ///
122 /// Examples: `"O(n³) — n = agents/tasks, practical for n ≤ 500"`
123 fn complexity_hint(&self) -> Option<&'static str> {
124 None
125 }
126}