TIC said nothing about what the agent does, and named the wrong thing: the tick belongs to the scheduler, which is generic and lives outside it. The agent's only decision is whether an incoming event deserves an interruption — it sorts, it never acts — so it is now event-triage, matching the functional naming of the two memory lints. - agents/tic/ -> agents/event-triage/, module tic/ -> event_triage/, TicManager -> EventTriageManager, TicConfig -> EventTriageConfig - agent id and chat source: "tic" -> "event-triage" - config keys: tic.* -> event_triage.*, and the config.yml section tic: -> event_triage: (greenfield: previously set values fall back to defaults) - i18n en/it/fr: Event triage / Triage eventi / Tri des evenements; dropped the stale "TIC sessions" mention from the debug-pages description - docs/system-agents.md, docs/index.md, docs/settings.md, CLAUDE.md, SKALD.md
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
//! Event triage — the background event processor, and the first of the
|
||||
//! **system agents**.
|
||||
//!
|
||||
//! A system agent runs on a user's behalf without being asked. This one's job is
|
||||
//! to look at the events the user's connectors pushed since the last pass (new
|
||||
//! mail, a calendar change, a WhatsApp message), decide which of them are worth
|
||||
//! interrupting the user for, and `notify()` those. It only ever sorts — it
|
||||
//! never acts on an event, which is why this is triage and not a handler.
|
||||
//!
|
||||
//! **It is per-user, and that is not an implementation detail.** The events it
|
||||
//! reads live in `mcp_events` inside the caller's own encrypted database, the
|
||||
//! connectors that produced them run inside the caller's container, and the
|
||||
//! notification it emits goes to the caller's own hub. This manager therefore
|
||||
//! owns no timer and no user list: it implements
|
||||
//! [`SystemAgent`](crate::system_agents::SystemAgent), one pass for one user,
|
||||
//! and the instance-wide scheduler (`skald::wiring::spawn_system_agents`)
|
||||
//! decides who to run it for and when — sequentially, skipping anyone whose
|
||||
//! database is still locked.
|
||||
//!
|
||||
//! The run is recorded in `system_agent_runs` in that same user's database, so
|
||||
//! the trace of what was triaged for someone is readable by them and by nobody
|
||||
//! else. Opening and closing that row is
|
||||
//! [`crate::system_agents::run_and_record`]'s job, not this module's: every
|
||||
//! agent needs it identically, and the ordering rules around it are subtle
|
||||
//! enough that one copy is the only safe number.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use sqlx::SqlitePool;
|
||||
use tracing::info;
|
||||
|
||||
use core_api::{ConfigProperty, ConfigSet, PropertyType};
|
||||
|
||||
use crate::config::EventTriageConfig;
|
||||
use crate::config_store::GlobalConfigManager;
|
||||
use crate::db::mcp_events;
|
||||
use crate::system_agents::{
|
||||
AgentOutcome, AgentRunCtx, AgentScope, SystemAgent, configured_run_context,
|
||||
enabled_from_config, enabled_property, interval_from_config, run_ephemeral_turn,
|
||||
security_group_property,
|
||||
};
|
||||
|
||||
/// The chat `source` the ephemeral triage sessions carry. Kept distinct from the
|
||||
/// user-facing sources (`web`, `talk`, `telegram`) so a pass never lands in a
|
||||
/// conversation someone is reading.
|
||||
const EVENT_TRIAGE_SOURCE: &str = "event-triage";
|
||||
/// The agent id, in `agents/event-triage/`, and the `agent_id` of its
|
||||
/// `system_agent_runs` rows.
|
||||
pub const EVENT_TRIAGE_AGENT: &str = "event-triage";
|
||||
|
||||
pub const EVENT_TRIAGE_ENABLED_KEY: &str = "event_triage.enabled";
|
||||
pub const EVENT_TRIAGE_SECURITY_GROUP_KEY: &str = "event_triage.security_group";
|
||||
pub const EVENT_TRIAGE_INTERVAL_MINUTES_KEY: &str = "event_triage.interval_minutes";
|
||||
|
||||
pub fn config_set() -> ConfigSet {
|
||||
ConfigSet {
|
||||
name: "Event triage".into(),
|
||||
description: "Event triage is a background agent that runs for every user, one at a time. \
|
||||
For each user it reads the events their own connectors have pushed since the \
|
||||
last run (new mail, calendar changes, incoming messages), decides — via an \
|
||||
LLM call — which of them are worth surfacing, and sends those to that user as \
|
||||
notifications. It reads only that user's events and writes only to their own \
|
||||
conversation; a user who has not logged in since the last restart is skipped, \
|
||||
because their database is still encrypted. Each run is recorded on the System \
|
||||
agents page, visible to the user it ran for.".into(),
|
||||
properties: vec![
|
||||
enabled_property(
|
||||
EVENT_TRIAGE_ENABLED_KEY,
|
||||
"Enable or disable event triage for the whole instance. When disabled, no events \
|
||||
are processed for anyone.",
|
||||
),
|
||||
security_group_property(EVENT_TRIAGE_SECURITY_GROUP_KEY),
|
||||
ConfigProperty {
|
||||
key: EVENT_TRIAGE_INTERVAL_MINUTES_KEY.into(),
|
||||
name: "Check interval (minutes)".into(),
|
||||
description: "How long between passes for each user, in minutes. Counted per \
|
||||
person from their own last pass. Leave empty to use the value from \
|
||||
config.yml (event_triage.interval_secs)."
|
||||
.into(),
|
||||
property_type: PropertyType::Int,
|
||||
default_value: Some("15".into()),
|
||||
},
|
||||
],
|
||||
owner: Some(EVENT_TRIAGE_AGENT.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What one pass did, for the run log. Counters only — never event contents.
|
||||
pub struct EventTriageRun {
|
||||
pub session_id: i64,
|
||||
pub events_processed: usize,
|
||||
pub notifications_emitted: usize,
|
||||
}
|
||||
|
||||
pub struct EventTriageManager {
|
||||
config: EventTriageConfig,
|
||||
config_store: Arc<GlobalConfigManager>,
|
||||
/// `system.db` — read to resolve each user's role when validating the
|
||||
/// configured security group. Never written.
|
||||
registry_pool: Arc<SqlitePool>,
|
||||
}
|
||||
|
||||
impl EventTriageManager {
|
||||
pub fn new(
|
||||
config: EventTriageConfig,
|
||||
config_store: Arc<GlobalConfigManager>,
|
||||
registry_pool: Arc<SqlitePool>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self { config, config_store, registry_pool })
|
||||
}
|
||||
|
||||
/// One pass for one user, over that user's own runtime.
|
||||
async fn triage(&self, ctx: &AgentRunCtx<'_>) -> Result<EventTriageRun> {
|
||||
let events = mcp_events::pending_limited(ctx.pool, self.config.batch_size).await?;
|
||||
info!(user = %ctx.user_id, count = events.len(), "event triage: processing event batch");
|
||||
|
||||
// Mark as processed BEFORE running the agent — a crash mid-turn then costs
|
||||
// this batch rather than replaying it forever. The loss is visible: the run
|
||||
// row closes as `failed` with the error.
|
||||
let ids: Vec<i64> = events.iter().map(|e| e.id).collect();
|
||||
mcp_events::mark_processed(ctx.pool, &ids).await?;
|
||||
|
||||
let rc = configured_run_context(
|
||||
&self.config_store,
|
||||
&self.registry_pool,
|
||||
EVENT_TRIAGE_SECURITY_GROUP_KEY,
|
||||
ctx.user_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
let (session_id, notified) = run_ephemeral_turn(
|
||||
EVENT_TRIAGE_AGENT,
|
||||
EVENT_TRIAGE_SOURCE,
|
||||
&build_prompt(&events),
|
||||
rc.as_ref(),
|
||||
"Event triage",
|
||||
ctx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(EventTriageRun {
|
||||
session_id,
|
||||
events_processed: events.len(),
|
||||
notifications_emitted: notified,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemAgent for EventTriageManager {
|
||||
fn id(&self) -> &'static str { EVENT_TRIAGE_AGENT }
|
||||
|
||||
fn scope(&self) -> AgentScope { AgentScope::PerUser }
|
||||
|
||||
fn config_set(&self) -> ConfigSet { config_set() }
|
||||
|
||||
fn interval_key(&self) -> &'static str { EVENT_TRIAGE_INTERVAL_MINUTES_KEY }
|
||||
|
||||
async fn is_enabled(&self) -> bool {
|
||||
enabled_from_config(&self.config_store, EVENT_TRIAGE_ENABLED_KEY).await
|
||||
}
|
||||
|
||||
/// Seconds between passes: the Settings value (minutes) wins, else `config.yml`.
|
||||
async fn interval_secs(&self) -> u64 {
|
||||
interval_from_config(
|
||||
&self.config_store,
|
||||
EVENT_TRIAGE_INTERVAL_MINUTES_KEY,
|
||||
60,
|
||||
self.config.interval_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// No pending events means no pass at all — and no row. The batch is re-read
|
||||
/// in [`EventTriageManager::triage`]; it is one indexed query on a small
|
||||
/// table, and paying it twice is cheaper than a trait shaped around carrying
|
||||
/// the rows.
|
||||
async fn has_work(&self, ctx: &AgentRunCtx<'_>) -> Result<bool> {
|
||||
let events = mcp_events::pending_limited(ctx.pool, self.config.batch_size).await?;
|
||||
Ok(!events.is_empty())
|
||||
}
|
||||
|
||||
async fn run(&self, ctx: &AgentRunCtx<'_>) -> Result<AgentOutcome> {
|
||||
let run = self.triage(ctx).await?;
|
||||
Ok(AgentOutcome {
|
||||
session_id: Some(run.session_id),
|
||||
stats: serde_json::json!({
|
||||
"events_processed": run.events_processed,
|
||||
"notifications_emitted": run.notifications_emitted,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Prompt builder ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn build_prompt(events: &[crate::db::mcp_events::McpEvent]) -> String {
|
||||
use std::fmt::Write;
|
||||
|
||||
let n = events.len();
|
||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
|
||||
let mut out = format!("[event triage] {n} pending event(s) — {now}\n");
|
||||
|
||||
for (i, ev) in events.iter().enumerate() {
|
||||
let _ = write!(
|
||||
out,
|
||||
"\n=== Event {}/{n} ===\nSource: {}\nType: {}\nReceived: {}\nPayload:\n{}\n",
|
||||
i + 1,
|
||||
ev.source,
|
||||
ev.method,
|
||||
ev.created_at,
|
||||
indent_payload(&ev.payload),
|
||||
);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Pretty-print a JSON payload with 2-space indent, falling back to raw string.
|
||||
fn indent_payload(payload: &str) -> String {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(payload) {
|
||||
if let Ok(pretty) = serde_json::to_string_pretty(&v) {
|
||||
return pretty.lines().map(|l| format!(" {l}")).collect::<Vec<_>>().join("\n");
|
||||
}
|
||||
}
|
||||
format!(" {payload}")
|
||||
}
|
||||
Reference in New Issue
Block a user