use converge_pack::{
AgentEffect, Context, ContextKey, ProposedFact, Provenance, ProvenanceSource, Suggestor,
TextPayload,
};
use crate::provenance::ORGANISM_RUNTIME_PROVENANCE;
fn proposed_text_fact(
key: ContextKey,
id: impl Into<converge_pack::ProposalId>,
content: impl Into<String>,
) -> ProposedFact {
ORGANISM_RUNTIME_PROVENANCE.proposed_fact(key, id, TextPayload::new(content))
}
pub struct RoleStallSuggestor {
watched_key: ContextKey,
role_label: String,
min_progress: usize,
deps: Vec<ContextKey>,
}
impl RoleStallSuggestor {
#[must_use]
pub fn new(watched_key: ContextKey, role_label: impl Into<String>) -> Self {
let deps = OUTPUT_KEYS
.iter()
.copied()
.filter(|k| *k != watched_key)
.collect();
Self {
watched_key,
role_label: role_label.into(),
min_progress: 3,
deps,
}
}
#[must_use]
pub fn with_min_progress(mut self, min_progress: usize) -> Self {
self.min_progress = min_progress;
self
}
fn fact_id(&self) -> String {
format!("{FACT_PREFIX}:{}", self.role_label)
}
fn already_emitted(&self, ctx: &dyn Context) -> bool {
let target = self.fact_id();
ctx.get(ContextKey::Diagnostic)
.iter()
.any(|f| f.id().as_str() == target)
}
fn progress_elsewhere(&self, ctx: &dyn Context) -> usize {
const PROGRESS_KEYS: &[ContextKey] = &[
ContextKey::Signals,
ContextKey::Proposals,
ContextKey::Evaluations,
ContextKey::Strategies,
ContextKey::Constraints,
ContextKey::Hypotheses,
ContextKey::Diagnostic,
ContextKey::Votes,
ContextKey::Disagreements,
ContextKey::ConsensusOutcomes,
];
PROGRESS_KEYS
.iter()
.filter(|k| **k != self.watched_key)
.map(|k| ctx.get(*k).len())
.sum()
}
}
const FACT_PREFIX: &str = "stall";
const OUTPUT_KEYS: &[ContextKey] = &[
ContextKey::Signals,
ContextKey::Strategies,
ContextKey::Proposals,
ContextKey::Evaluations,
ContextKey::Constraints,
ContextKey::Hypotheses,
ContextKey::Diagnostic,
];
#[async_trait::async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Suggestor for RoleStallSuggestor {
fn name(&self) -> &'static str {
"role-stall"
}
fn dependencies(&self) -> &[ContextKey] {
&self.deps
}
fn provenance(&self) -> Provenance {
ORGANISM_RUNTIME_PROVENANCE.provenance()
}
fn accepts(&self, ctx: &dyn Context) -> bool {
if self.already_emitted(ctx) {
return false;
}
if !ctx.get(self.watched_key).is_empty() {
return false;
}
self.progress_elsewhere(ctx) >= self.min_progress
}
async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
let progress = self.progress_elsewhere(ctx);
let payload = serde_json::json!({
"agent": "role-stall",
"role": self.role_label,
"watched_key": format!("{:?}", self.watched_key),
"progress_elsewhere": progress,
"recommendation": format!(
"consider an alternate descriptor for role `{}`; nothing under {:?} after {} facts elsewhere",
self.role_label, self.watched_key, progress,
),
"severity": "stall",
});
AgentEffect::with_proposal(proposed_text_fact(
ContextKey::Diagnostic,
self.fact_id(),
payload.to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::formation::Formation;
struct ProductiveStrategist;
#[async_trait::async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl Suggestor for ProductiveStrategist {
fn name(&self) -> &'static str {
"productive-strategist"
}
fn dependencies(&self) -> &[ContextKey] {
&[ContextKey::Seeds]
}
fn provenance(&self) -> Provenance {
ORGANISM_RUNTIME_PROVENANCE.provenance()
}
fn accepts(&self, ctx: &dyn Context) -> bool {
ctx.has(ContextKey::Seeds) && !ctx.has(ContextKey::Strategies)
}
async fn execute(&self, _ctx: &dyn Context) -> AgentEffect {
AgentEffect::with_proposal(proposed_text_fact(
ContextKey::Strategies,
"strat-1".to_string(),
"{\"plan\": \"draft\"}".to_string(),
))
}
}
#[tokio::test]
async fn fires_when_watched_key_empty_and_others_progressing() {
let result = Formation::new("stall-test")
.agent(ProductiveStrategist)
.agent(
RoleStallSuggestor::new(ContextKey::Evaluations, "evaluator").with_min_progress(1),
)
.seed(ContextKey::Seeds, "s1", "seed", "test")
.run()
.await
.expect("formation runs");
let diagnostic = result.converge_result.context.get(ContextKey::Diagnostic);
let stall = diagnostic
.iter()
.find(|f| f.id().as_str() == "stall:evaluator")
.expect("stall fact emitted for the missing evaluator role");
let payload: serde_json::Value =
serde_json::from_str(stall.text().unwrap_or_default()).expect("payload is JSON");
assert_eq!(payload["role"], "evaluator");
assert_eq!(payload["severity"], "stall");
assert!(payload["progress_elsewhere"].as_u64().unwrap() >= 1);
}
#[tokio::test]
async fn quiet_when_watched_key_is_active() {
let result = Formation::new("no-stall")
.agent(ProductiveStrategist)
.agent(
RoleStallSuggestor::new(ContextKey::Strategies, "strategist").with_min_progress(1),
)
.seed(ContextKey::Seeds, "s1", "seed", "test")
.run()
.await
.expect("formation runs");
let diagnostic = result.converge_result.context.get(ContextKey::Diagnostic);
assert!(
diagnostic.iter().all(|f| !f.id().starts_with("stall:")),
"stall should NOT fire when the watched role is producing"
);
}
#[tokio::test]
async fn quiet_until_min_progress_threshold() {
let result = Formation::new("threshold")
.agent(ProductiveStrategist)
.agent(
RoleStallSuggestor::new(ContextKey::Evaluations, "evaluator").with_min_progress(99),
)
.seed(ContextKey::Seeds, "s1", "seed", "test")
.run()
.await
.expect("formation runs");
let diagnostic = result.converge_result.context.get(ContextKey::Diagnostic);
assert!(
diagnostic.iter().all(|f| !f.id().starts_with("stall:")),
"stall should NOT fire below min_progress threshold"
);
}
}