use crate::config::AgentDefinition;
use crate::persona::PersonaRegistry;
use chrono::{DateTime, SecondsFormat, Utc};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::LazyLock;
use terraphim_tracker::IssueComment;
pub(crate) use crate::dispatcher::LEGACY_PROJECT_ID;
static MENTION_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"@adf:(?:(?P<project>[a-z][a-z0-9-]{1,39})/)?(?P<agent>[a-z][a-z0-9-]{1,39})\b")
.unwrap()
});
#[derive(Debug, Clone, PartialEq)]
pub enum MentionResolution {
AgentName,
PersonaName { persona: String },
}
#[derive(Debug, Clone, PartialEq)]
pub struct MentionTokens {
pub project: Option<String>,
pub agent: String,
}
pub fn parse_mention_tokens(text: &str) -> Vec<MentionTokens> {
MENTION_RE
.captures_iter(text)
.map(|caps| MentionTokens {
project: caps.name("project").map(|m| m.as_str().to_string()),
agent: caps
.name("agent")
.map(|m| m.as_str().to_string())
.unwrap_or_default(),
})
.collect()
}
#[derive(Debug, Clone)]
pub struct DetectedMention {
pub issue_number: u64,
pub comment_id: u64,
pub raw_mention: String,
pub agent_name: String,
pub resolution: MentionResolution,
pub comment_body: String,
pub mentioner: String,
pub timestamp: String,
pub project_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MentionCursor {
pub last_seen_at: String,
#[serde(skip)]
pub dispatches_this_tick: u32,
#[serde(default)]
pub processed_comment_ids: std::collections::HashSet<u64>,
}
impl MentionCursor {
pub fn now() -> Self {
Self {
last_seen_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
dispatches_this_tick: 0,
processed_comment_ids: std::collections::HashSet::new(),
}
}
pub fn is_processed(&self, comment_id: u64) -> bool {
self.processed_comment_ids.contains(&comment_id)
}
pub fn mark_processed(&mut self, comment_id: u64) {
self.processed_comment_ids.insert(comment_id);
if self.processed_comment_ids.len() > 10_000 {
let to_remove: Vec<u64> = self
.processed_comment_ids
.iter()
.take(5_000)
.copied()
.collect();
for id in to_remove {
self.processed_comment_ids.remove(&id);
}
}
}
async fn sqlite_op() -> Option<opendal::Operator> {
let storage = terraphim_persistence::DeviceStorage::instance()
.await
.ok()?;
let (op, _) = storage.ops.get("sqlite")?;
Some(op.clone())
}
fn cursor_key(project_id: &str) -> String {
format!("adf/mention_cursor/{}", project_id)
}
pub async fn load_or_now(project_id: &str) -> Self {
let key = Self::cursor_key(project_id);
if let Some(op) = Self::sqlite_op().await {
if let Ok(bs) = op.read(&key).await {
if let Ok(cursor) = serde_json::from_slice::<Self>(&bs.to_vec()) {
tracing::info!(
project = project_id,
last_seen_at = %cursor.last_seen_at,
"loaded MentionCursor from persistence"
);
return cursor;
}
tracing::warn!(
project = project_id,
"failed to deserialize MentionCursor, starting fresh"
);
} else {
tracing::info!(
project = project_id,
"no persisted MentionCursor found, starting fresh"
);
}
} else {
tracing::warn!(
project = project_id,
"DeviceStorage sqlite not available, using in-memory cursor"
);
}
Self::now()
}
pub async fn save(&self, project_id: &str) {
let key = Self::cursor_key(project_id);
if let Some(op) = Self::sqlite_op().await {
if let Ok(json) = serde_json::to_string(self) {
if let Err(e) = op.write(&key, json).await {
tracing::warn!(project = project_id, ?e, "failed to save MentionCursor");
} else {
tracing::debug!(
project = project_id,
last_seen_at = %self.last_seen_at,
"saved MentionCursor"
);
}
} else {
tracing::warn!(project = project_id, "failed to serialize MentionCursor");
}
} else {
tracing::warn!(
project = project_id,
"DeviceStorage sqlite not available, cursor not persisted"
);
}
}
pub fn advance_to(&mut self, timestamp: &str) {
if let Ok(parsed) = DateTime::parse_from_rfc3339(timestamp) {
let utc = parsed.with_timezone(&Utc);
let utc_str = utc.to_rfc3339_opts(SecondsFormat::Secs, true);
if let Ok(current) = DateTime::parse_from_rfc3339(&self.last_seen_at) {
if utc > current.with_timezone(&Utc) {
self.last_seen_at = utc_str;
}
} else {
self.last_seen_at = utc_str;
}
}
}
}
impl Default for MentionCursor {
fn default() -> Self {
Self::now()
}
}
pub async fn migrate_legacy_mention_cursor(projects: &[crate::config::Project]) {
let legacy_key = "adf/mention_cursor";
let Some(op) = MentionCursor::sqlite_op().await else {
tracing::debug!("mention cursor migration skipped: no sqlite operator");
return;
};
let legacy_bytes = match op.read(legacy_key).await {
Ok(bs) => bs,
Err(_) => {
tracing::debug!("no legacy mention cursor at `adf/mention_cursor`, nothing to migrate");
return;
}
};
let Ok(cursor) = serde_json::from_slice::<MentionCursor>(&legacy_bytes.to_vec()) else {
tracing::warn!(
"legacy mention cursor is unparsable; deleting it so per-project keys start clean"
);
let _ = op.delete(legacy_key).await;
return;
};
let Ok(json) = serde_json::to_string(&cursor) else {
tracing::warn!("failed to serialize legacy mention cursor during migration");
return;
};
let mut targets: Vec<String> = projects.iter().map(|p| p.id.clone()).collect();
targets.push(LEGACY_PROJECT_ID.to_string());
let mut written = 0usize;
for pid in &targets {
let key = MentionCursor::cursor_key(pid);
match op.stat(&key).await {
Ok(_) => {
tracing::debug!(
project = pid.as_str(),
"skipping legacy-cursor migration: per-project cursor already present"
);
}
Err(_) => {
if let Err(e) = op.write(&key, json.clone()).await {
tracing::warn!(
project = pid.as_str(),
?e,
"failed to write migrated MentionCursor"
);
} else {
written += 1;
}
}
}
}
match op.delete(legacy_key).await {
Ok(()) => tracing::info!(
migrated_to = written,
last_seen_at = %cursor.last_seen_at,
"migrated legacy mention cursor to per-project keys"
),
Err(e) => tracing::warn!(?e, "failed to delete legacy mention cursor after migration"),
}
}
pub fn resolve_persona_mention(
raw: &str,
agents: &[AgentDefinition],
personas: &PersonaRegistry,
context: &str,
) -> Option<(String, MentionResolution)> {
if let Some(agent) = agents.iter().find(|a| a.name == raw) {
return Some((agent.name.clone(), MentionResolution::AgentName));
}
if personas.get(raw).is_some() {
let matching_agents: Vec<&AgentDefinition> = agents
.iter()
.filter(|a| {
a.persona
.as_ref()
.map(|p| p.eq_ignore_ascii_case(raw))
.unwrap_or(false)
})
.collect();
match matching_agents.len() {
0 => return None,
1 => {
return Some((
matching_agents[0].name.clone(),
MentionResolution::PersonaName {
persona: raw.to_string(),
},
));
}
_ => {
let context_lower = context.to_lowercase();
let mut best_agent = &matching_agents[0];
let mut best_score = 0usize;
for agent in &matching_agents {
let score = agent
.capabilities
.iter()
.filter(|cap| context_lower.contains(&cap.to_lowercase()))
.count();
if score > best_score || (score == best_score && agent.name < best_agent.name) {
best_score = score;
best_agent = agent;
}
}
return Some((
best_agent.name.clone(),
MentionResolution::PersonaName {
persona: raw.to_string(),
},
));
}
}
}
None
}
pub fn resolve_mention(
detected_project: Option<&str>,
hinted_project: &str,
agent_name: &str,
agents: &[AgentDefinition],
) -> Option<AgentDefinition> {
if let Some(proj) = detected_project {
let matches: Vec<&AgentDefinition> = agents
.iter()
.filter(|a| a.name == agent_name && a.project.as_deref() == Some(proj))
.collect();
return match matches.len() {
1 => Some(matches[0].clone()),
_ => None,
};
}
if hinted_project == LEGACY_PROJECT_ID {
let matches: Vec<&AgentDefinition> =
agents.iter().filter(|a| a.name == agent_name).collect();
return match matches.len() {
1 => Some(matches[0].clone()),
_ => None,
};
}
let hinted: Vec<&AgentDefinition> = agents
.iter()
.filter(|a| a.name == agent_name && a.project.as_deref() == Some(hinted_project))
.collect();
if hinted.len() == 1 {
return Some(hinted[0].clone());
}
if hinted.len() > 1 {
return None;
}
let unbound: Vec<&AgentDefinition> = agents
.iter()
.filter(|a| a.name == agent_name && a.project.is_none())
.collect();
match unbound.len() {
1 => Some(unbound[0].clone()),
_ => None,
}
}
pub fn parse_mentions(
comment: &IssueComment,
issue_number: u64,
agents: &[AgentDefinition],
personas: &PersonaRegistry,
hinted_project: &str,
) -> Vec<DetectedMention> {
let mut mentions = Vec::new();
for cap in MENTION_RE.captures_iter(&comment.body) {
let raw_agent = cap.name("agent").map(|m| m.as_str()).unwrap_or_default();
if let Some((agent_name, resolution)) =
resolve_persona_mention(raw_agent, agents, personas, &comment.body)
{
mentions.push(DetectedMention {
issue_number,
comment_id: comment.id,
raw_mention: raw_agent.to_string(),
agent_name,
resolution,
comment_body: comment.body.clone(),
mentioner: comment.user.login.clone(),
timestamp: comment.created_at.clone(),
project_id: hinted_project.to_string(),
});
} else {
tracing::warn!(
raw_mention = raw_agent,
issue = issue_number,
"unresolved @adf mention"
);
}
}
mentions
}
pub struct MentionTracker {
max_dispatches_per_issue: u32,
dispatches_per_issue: HashMap<u64, u32>,
}
impl MentionTracker {
pub fn new(max_dispatches_per_issue: u32) -> Self {
Self {
max_dispatches_per_issue,
dispatches_per_issue: HashMap::new(),
}
}
pub fn limit_exceeded(&self, issue_number: u64) -> bool {
self.dispatches_per_issue
.get(&issue_number)
.map(|&d| d >= self.max_dispatches_per_issue)
.unwrap_or(false)
}
pub fn record_dispatch(&mut self, issue_number: u64) {
*self.dispatches_per_issue.entry(issue_number).or_insert(0) += 1;
}
pub fn reset(&mut self) {
self.dispatches_per_issue.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AgentLayer;
use terraphim_types::persona::PersonaDefinition;
fn test_agent_default() -> AgentDefinition {
AgentDefinition {
name: String::new(),
layer: AgentLayer::Growth,
cli_tool: "echo".to_string(),
task: "test task".to_string(),
schedule: None,
model: None,
capabilities: vec![],
max_memory_bytes: None,
budget_monthly_cents: None,
provider: None,
persona: None,
terraphim_role: None,
skill_chain: vec![],
sfia_skills: vec![],
fallback_provider: None,
fallback_model: None,
grace_period_secs: None,
max_cpu_seconds: None,
pre_check: None,
gitea_issue: None,
event_only: false,
evolution_enabled: false,
rlm_enabled: None,
bypass_kg_routing: false,
enabled: true,
project: None,
}
}
fn test_agents() -> Vec<AgentDefinition> {
vec![
AgentDefinition {
name: "security-sentinel".into(),
persona: Some("Vigil".into()),
capabilities: vec!["security".into(), "audit".into(), "vulnerability".into()],
..test_agent_default()
},
AgentDefinition {
name: "compliance-watchdog".into(),
persona: Some("Vigil".into()),
capabilities: vec!["compliance".into(), "license".into(), "gdpr".into()],
..test_agent_default()
},
AgentDefinition {
name: "spec-validator".into(),
persona: Some("Carthos".into()),
capabilities: vec![
"specification".into(),
"architecture".into(),
"domain".into(),
],
..test_agent_default()
},
AgentDefinition {
name: "product-development".into(),
persona: Some("Carthos".into()),
capabilities: vec![
"product-development".into(),
"roadmap-prioritization".into(),
"feature-prioritization".into(),
"backlog-shaping".into(),
],
..test_agent_default()
},
]
}
fn test_persona_definition(name: &str) -> PersonaDefinition {
PersonaDefinition {
agent_name: name.to_string(),
role_name: format!("{} Role", name),
name_origin: format!("Test origin for {}", name),
vibe: "Test vibe".to_string(),
symbol: "T".to_string(),
core_characteristics: vec![],
speech_style: "Test style".to_string(),
terraphim_nature: "Test nature".to_string(),
sfia_title: format!("{} Engineer", name),
primary_level: 4,
guiding_phrase: "Test phrase".to_string(),
level_essence: "Test essence".to_string(),
sfia_skills: vec![],
}
}
fn test_personas() -> PersonaRegistry {
let mut registry = PersonaRegistry::new();
registry.insert(test_persona_definition("Vigil"));
registry.insert(test_persona_definition("Carthos"));
registry
}
fn make_comment(id: u64, body: &str, login: &str) -> IssueComment {
IssueComment {
id,
body: body.into(),
user: terraphim_tracker::CommentUser {
login: login.into(),
},
issue_number: 0, created_at: "2026-03-30T00:00:00Z".into(),
updated_at: "2026-03-30T00:00:00Z".into(),
}
}
#[test]
fn test_parse_single_mention_agent_name() {
let agents = test_agents();
let personas = test_personas();
let comment = make_comment(1, "Please @adf:security-sentinel review this code", "alice");
let mentions = parse_mentions(&comment, 42, &agents, &personas, LEGACY_PROJECT_ID);
assert_eq!(mentions.len(), 1);
assert_eq!(mentions[0].agent_name, "security-sentinel");
assert_eq!(mentions[0].resolution, MentionResolution::AgentName);
assert_eq!(mentions[0].raw_mention, "security-sentinel");
assert_eq!(mentions[0].project_id, LEGACY_PROJECT_ID);
}
#[test]
fn test_parse_single_mention_persona() {
let agents = test_agents();
let personas = test_personas();
let comment = make_comment(2, "@adf:vigil check for security vulnerabilities", "alice");
let mentions = parse_mentions(&comment, 42, &agents, &personas, LEGACY_PROJECT_ID);
assert_eq!(mentions.len(), 1);
assert_eq!(mentions[0].agent_name, "security-sentinel");
assert!(matches!(
mentions[0].resolution,
MentionResolution::PersonaName { .. }
));
}
#[test]
fn test_parse_multiple_mentions() {
let agents = test_agents();
let personas = test_personas();
let comment = make_comment(3, "@adf:vigil and @adf:carthos please review", "bob");
let mentions = parse_mentions(&comment, 42, &agents, &personas, LEGACY_PROJECT_ID);
assert_eq!(mentions.len(), 2);
}
#[test]
fn test_parse_no_mentions() {
let agents = test_agents();
let personas = test_personas();
let comment = make_comment(4, "No mentions here", "alice");
let mentions = parse_mentions(&comment, 42, &agents, &personas, LEGACY_PROJECT_ID);
assert!(mentions.is_empty());
}
#[test]
fn test_parse_ignores_regular_mentions() {
let agents = test_agents();
let personas = test_personas();
let comment = make_comment(5, "@alice please review", "bob");
let mentions = parse_mentions(&comment, 42, &agents, &personas, LEGACY_PROJECT_ID);
assert!(mentions.is_empty());
}
#[test]
fn test_parse_stamps_hinted_project_id() {
let agents = test_agents();
let personas = test_personas();
let comment = make_comment(6, "Please @adf:security-sentinel look at this", "alice");
let mentions = parse_mentions(&comment, 42, &agents, &personas, "odilo");
assert_eq!(mentions.len(), 1);
assert_eq!(mentions[0].project_id, "odilo");
}
#[test]
fn test_resolve_persona_single_agent() {
let agents = test_agents();
let personas = test_personas();
let result = resolve_persona_mention("carthos", &agents, &personas, "some context");
assert!(result.is_some());
let (name, res) = result.unwrap();
assert_eq!(name, "product-development");
assert!(matches!(res, MentionResolution::PersonaName { .. }));
}
#[test]
fn test_resolve_persona_multiple_agents_keyword_match() {
let agents = test_agents();
let personas = test_personas();
let result =
resolve_persona_mention("vigil", &agents, &personas, "check license compliance");
assert!(result.is_some());
let (name, _) = result.unwrap();
assert_eq!(name, "compliance-watchdog");
}
#[test]
fn test_resolve_unknown_name() {
let agents = test_agents();
let personas = test_personas();
let result = resolve_persona_mention("nonexistent", &agents, &personas, "context");
assert!(result.is_none());
}
#[test]
fn test_mention_cursor_now() {
let cursor = MentionCursor::now();
let parsed = chrono::DateTime::parse_from_rfc3339(&cursor.last_seen_at);
assert!(parsed.is_ok());
assert_eq!(cursor.dispatches_this_tick, 0);
}
#[test]
fn test_mention_cursor_advance() {
let mut cursor = MentionCursor::now();
cursor.last_seen_at = "2026-04-03T10:00:00Z".to_string();
cursor.advance_to("2026-04-03T12:00:00Z");
assert_eq!(cursor.last_seen_at, "2026-04-03T12:00:00Z");
cursor.advance_to("2026-04-03T08:00:00Z");
assert_eq!(cursor.last_seen_at, "2026-04-03T12:00:00Z");
}
#[test]
fn test_mention_tracker_issue_limit() {
let mut tracker = MentionTracker::new(3);
assert!(!tracker.limit_exceeded(42));
tracker.record_dispatch(42);
tracker.record_dispatch(42);
tracker.record_dispatch(42);
assert!(tracker.limit_exceeded(42));
assert!(!tracker.limit_exceeded(99));
}
#[test]
fn test_mention_tracker_reset() {
let mut tracker = MentionTracker::new(2);
tracker.record_dispatch(42);
tracker.record_dispatch(42);
assert!(tracker.limit_exceeded(42));
tracker.reset();
assert!(!tracker.limit_exceeded(42));
}
}