rename the TIC system agent to event triage
Nightly Build / build (push) Successful in 7m16s

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:
2026-07-28 21:59:37 +01:00
parent 0b793d56ae
commit 046f060fcd
50 changed files with 251 additions and 240 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ pub trait LiveInput: Send + Sync {
/// Per-turn metadata.
#[derive(Debug, Clone, Default)]
pub struct TurnMeta {
/// Synthetic turn (TIC/notify) — no user echo semantics.
/// Synthetic turn (event triage, notify) — no user echo semantics.
pub synthetic: bool,
/// Interactive surface (web chat, telegram, …).
pub interactive: bool,
+1 -1
View File
@@ -143,7 +143,7 @@ pub struct FrameRecord {
pub struct NewMessage {
pub role: Role,
pub content: String,
/// TIC/notify/injection: not echoed to the UI as a user message.
/// Event triage, notify, injection: not echoed to the UI as a user message.
pub synthetic: bool,
pub reasoning: Option<String>,
/// Attachments, command display, … (host free-form).
+3 -3
View File
@@ -78,12 +78,12 @@ pub struct ChatEvent {
pub role: ChatEventRole,
pub content: String,
/// True for system-generated messages that look like user turns
/// (TicManager ticks, notification briefings).
/// (EventTriageManager passes, notification briefings).
pub is_synthetic: bool,
/// True when a real user is actively participating in the session
/// (web, telegram). False for automated sessions (cron, tic).
/// (web, telegram). False for automated sessions (cron, event-triage).
pub is_interactive: bool,
/// True for short-lived task sessions (cron, tic) that have no
/// True for short-lived task sessions (cron, event-triage) that have no
/// long-term conversational value (e.g. skip Honcho memory sink).
pub is_ephemeral: bool,
/// Non-empty only for assistant messages that triggered tool calls.
+1 -1
View File
@@ -24,7 +24,7 @@ pub struct InboundDataMessage {
// ── Global event envelope ─────────────────────────────────────────────────────
/// Envelope that wraps every event on the global broadcast bus.
/// `source` is `None` for system/background events (cron, tic, plugins).
/// `source` is `None` for system/background events (cron, event-triage, plugins).
#[derive(Clone)]
pub struct GlobalEvent {
pub source: Option<String>,
+2 -1
View File
@@ -160,7 +160,8 @@ pub trait Tool: Send + Sync {
fn root_agent_only(&self) -> bool { false }
/// If true, this tool is only available to interactive sessions (web, telegram, mobile, voice).
/// Non-interactive background sessions (cron, tic) will not receive this tool definition.
/// Non-interactive background sessions (cron, event-triage) will not receive
/// this tool definition.
fn interactive_only(&self) -> bool { false }
/// Full OpenAI-format tool definition ready to be sent to the LLM.
+1 -1
View File
@@ -356,7 +356,7 @@ impl McpServer {
// `notifications/message` is the MCP logging utility
// (deprecated 2026-07-28): route it to the per-server
// log file, not to the notification queue that feeds
// TIC — otherwise log records masquerade as business
// event triage — otherwise log records masquerade as business
// events. Every other notification (e.g. the custom
// `event/*` methods) flows on to `notification_tx`.
if msg.get("method").and_then(Value::as_str) == Some("notifications/message") {
+3 -3
View File
@@ -6,7 +6,7 @@
//! a business `event/ping` notification to stdout. The test asserts that:
//! - the stderr banner arrives on `log_tx` tagged `stderr`,
//! - the `notifications/message` arrives on `log_tx` with its MCP level, and is
//! **not** delivered to `notification_tx` (it's diverted away from TIC),
//! **not** delivered to `notification_tx` (it's diverted away from event triage),
//! - the business `event/ping` still arrives on `notification_tx`.
//! Skipped if `python3` is absent.
@@ -48,10 +48,10 @@ while True:
elif method == "notifications/initialized":
# A diagnostic banner on stderr (the primary, future-proof log source).
print("startup banner on stderr", file=sys.stderr, flush=True)
# An MCP logging record (should be diverted to the log file, NOT TIC).
# An MCP logging record (should be diverted to the log file, NOT event triage).
send({"jsonrpc": "2.0", "method": "notifications/message",
"params": {"level": "warning", "logger": "test", "data": "disk almost full"}})
# A business event (should still reach the notification queue / TIC).
# A business event (should still reach the notification queue / event triage).
send({"jsonrpc": "2.0", "method": "event/ping", "params": {"n": 1}})
elif method == "tools/list":
send({"jsonrpc": "2.0", "id": mid, "result": {"tools": []}})
+2 -2
View File
@@ -21,7 +21,7 @@ pub const DEFAULT_CHAT_AGENT: &str = "assistant";
/// `project-coordinator`). Not dispatchable as a sub-agent, not a valid task root.
/// - `Task`: a task executor. Dispatchable by a parent agent **and** a valid root of a
/// scheduled/async task (e.g. `software-engineer`, `researcher`, `generalist`).
/// - `System`: a hidden background agent wired into the runtime by id (e.g. `tic`).
/// - `System`: a hidden background agent wired into the runtime by id (e.g. `event-triage`).
/// Never listed, never user-chattable, never dispatchable from the tool surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -114,7 +114,7 @@ pub struct AgentMeta {
/// When true (the default, including when the key is absent), the skills index
/// (`skills/index.md`) is injected into this agent's system prompt so it can
/// discover and use installed skills. Set false for background agents that don't
/// need them (e.g. TIC) to save tokens.
/// need them (e.g. event triage) to save tokens.
#[serde(default = "default_true")]
pub inject_skills: bool,
/// Path to the agent's icon image file (relative to the agent's directory).
+1 -1
View File
@@ -44,7 +44,7 @@ pub(super) struct SourceInbox {
/// consumer to seed a turn. No coalescing: any further queued messages are drained
/// into the running turn at its round boundaries (see `drain_leading_user`).
///
/// Empty queue → `None`. Synthetic messages (notification/TIC) and plain user
/// Empty queue → `None`. Synthetic messages (notification/event triage) and plain user
/// messages are treated identically here; only `drain_leading_user` distinguishes
/// them, leaving synthetic ones for the notification path.
pub(super) fn build_unit(
+5 -5
View File
@@ -50,11 +50,11 @@ const SOURCE_COALESCE_DEBOUNCE_MS: u64 = 0;
/// one live, persistent session per `source`, reachable over WebSocket and addressed
/// by source id through the `sources` table.
///
/// It is **not** a runner for background / non-interactive agents (cron jobs, TIC,
/// sub-agent tasks). Those go through `TaskManager` / `ChatSessionManager` directly and
/// must not be routed here — they are not user-facing, have no broadcast audience, and
/// should not appear in the `sources` table. (Historically this class was misused to
/// drive non-interactive agents; keep that boundary.)
/// It is **not** a runner for background / non-interactive agents (cron jobs, event
/// triage, sub-agent tasks). Those go through `TaskManager` / `ChatSessionManager`
/// directly and must not be routed here — they are not user-facing, have no broadcast
/// audience, and should not appear in the `sources` table. (Historically this class was
/// misused to drive non-interactive agents; keep that boundary.)
pub struct ChatHub {
db: Arc<SqlitePool>,
session_mgr: Arc<ChatSessionManager>,
+1 -1
View File
@@ -11,7 +11,7 @@
//! It is a stateless service (all state lives in the DB), shared via `Arc`
//! across every [`ChatSessionHandler`](crate::session::handler). Triggered at
//! the **start of a turn** when the previous turn's `input_tokens` exceeded the
//! threshold, or manually via `force_compact`. Ephemeral sessions (cron, tic)
//! threshold, or manually via `force_compact`. Ephemeral sessions (cron, event-triage)
//! are always skipped.
//!
//! ```text
+12 -9
View File
@@ -61,20 +61,23 @@ pub struct CompactionConfig {
pub strength: Option<LlmStrength>,
}
/// TIC background event processor settings.
/// Event-triage background processor settings.
#[derive(Debug, Clone, Deserialize)]
pub struct TicConfig {
pub struct EventTriageConfig {
/// Interval between ticks, in seconds. Default: 900 (15 minutes).
#[serde(default = "default_tic_interval_secs")]
#[serde(default = "default_event_triage_interval_secs")]
pub interval_secs: u64,
/// Maximum number of events processed per tick. Default: 50.
#[serde(default = "default_tic_batch_size")]
#[serde(default = "default_event_triage_batch_size")]
pub batch_size: i64,
}
impl Default for TicConfig {
impl Default for EventTriageConfig {
fn default() -> Self {
Self { interval_secs: default_tic_interval_secs(), batch_size: default_tic_batch_size() }
Self {
interval_secs: default_event_triage_interval_secs(),
batch_size: default_event_triage_batch_size(),
}
}
}
@@ -103,8 +106,8 @@ pub struct LlmRequestsLogConfig {
fn default_true() -> bool { true }
fn default_keep_recent() -> usize { 6 }
fn default_tic_interval_secs() -> u64 { 900 }
fn default_tic_batch_size() -> i64 { 50 }
fn default_event_triage_interval_secs() -> u64 { 900 }
fn default_event_triage_batch_size() -> i64 { 50 }
// ── CoreConfig ────────────────────────────────────────────────────────────────
@@ -112,7 +115,7 @@ fn default_tic_batch_size() -> i64 { 50 }
/// No HTTP/server knowledge. Derived from `Config` via `Config::into_split()`.
pub struct CoreConfig {
pub llm: LlmConfig,
pub tic: TicConfig,
pub event_triage: EventTriageConfig,
pub cron: CronConfig,
pub timezone: Option<String>,
}
+1 -1
View File
@@ -38,7 +38,7 @@ pub struct ChatMessage {
pub status: String,
pub input_tokens: Option<i64>,
pub output_tokens: Option<i64>,
/// True for messages injected synthetically (e.g. TIC notifications) — not
/// True for messages injected synthetically (e.g. event triage notifications) — not
/// typed by a real user. Stored in DB so the UI can skip them on reload.
pub is_synthetic: bool,
/// Chain-of-thought from reasoning models (e.g. DeepSeek thinking mode).
+2 -2
View File
@@ -5,9 +5,9 @@ pub struct ChatSession {
pub source: String,
pub agent_id: String,
/// True when a real user is actively participating (web, telegram).
/// False for fully automated sessions (cron, tic).
/// False for fully automated sessions (cron, event-triage).
pub is_interactive: bool,
/// True for short-lived task sessions (cron, tic) with no long-term
/// True for short-lived task sessions (cron, event-triage) with no long-term
/// conversational value. May be used to skip memory / analytics sinks.
pub is_ephemeral: bool,
/// Optional RunContext JSON blob assigned to this session.
+1 -1
View File
@@ -58,7 +58,7 @@ pub async fn mark_processed(pool: &SqlitePool, ids: &[i64]) -> Result<()> {
// ── Read ──────────────────────────────────────────────────────────────────────
/// Oldest N pending (unprocessed) events, ordered oldest-first.
/// Used by TicManager to fetch a bounded batch each tick.
/// Used by EventTriageManager to fetch a bounded batch each pass.
pub async fn pending_limited(pool: &SqlitePool, limit: i64) -> Result<Vec<McpEvent>> {
let rows = sqlx::query_as::<_, (i64, String, String, String, bool, Option<String>, String)>(
"SELECT id, source, method, payload, processed, processed_at, created_at
+10 -10
View File
@@ -898,14 +898,14 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
.await?;
// Execution log of the **system agents** — the background agents the instance
// runs on a user's behalf without being asked (TIC is the first and, today,
// the only one). The sibling of `job_runs`: same shape, but keyed on the agent
// instead of a scheduled job, because a system agent has no user-authored row
// to point at.
// runs on a user's behalf without being asked (event triage is the first and,
// today, the only one). The sibling of `job_runs`: same shape, but keyed on the
// agent instead of a scheduled job, because a system agent has no user-authored
// row to point at.
//
// Owner table, and that is the whole privacy story: a run of TIC summarises
// what landed in this user's inbox, so it belongs in *their* encrypted file
// and nowhere else. There is deliberately no `user_id` column — the file is
// Owner table, and that is the whole privacy story: an event-triage run
// summarises what landed in this user's inbox, so it belongs in *their*
// encrypted file and nowhere else. There is deliberately no `user_id` column — the file is
// the owner (§5.1). An admin reading `system.db` learns nothing about it.
//
// A user whose database is still locked is skipped by the scheduler and
@@ -948,8 +948,8 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
// productive run aged out.
//
// Persisting it is what makes a long interval survive a restart. An in-memory
// deadline is fine at TIC's scale — a few minutes, re-armed on boot — but a
// weekly agent on a machine rebooted every few days would have its deadline
// deadline is fine at event triage's scale — a few minutes, re-armed on boot —
// but a weekly agent on a machine rebooted every few days would have its deadline
// reset before it ever fired, and would simply never run.
//
// Owner table for the same reason as the run log: when an agent last ran for
@@ -1166,7 +1166,7 @@ mod tests {
one("INSERT INTO scheduled_jobs (id, title, cron, prompt, session_id) VALUES (1, 't', '* * * * *', 'p', 1)")
.await.unwrap();
one("INSERT INTO job_runs (job_id, started_at, status) VALUES (1, 'now', 'completed')").await.unwrap();
one("INSERT INTO system_agent_runs (agent_id, started_at, status) VALUES ('tic', 'now', 'running')").await.unwrap();
one("INSERT INTO system_agent_runs (agent_id, started_at, status) VALUES ('event-triage', 'now', 'running')").await.unwrap();
// Owner table with a BARE `catalog_name` ref — proves it stands alone with
// FKs on (an owner→registry FK here would die on this INSERT).
one("INSERT INTO mcp_user_servers (name, catalog_name, source) VALUES ('u', 'whatsapp', 'local_script')").await.unwrap();
@@ -28,7 +28,7 @@ pub struct SystemAgentRun {
pub completed_at: Option<String>,
pub duration_ms: Option<i64>,
pub status: String,
/// Free-form JSON with the agent's own counters (TIC: events processed,
/// Free-form JSON with the agent's own counters (event triage: events processed,
/// notifications emitted). Never the event contents.
pub stats: Option<String>,
pub error: Option<String>,
@@ -65,19 +65,19 @@ mod tests {
#[tokio::test]
async fn never_attempted_reads_as_due() {
let pool = pool().await;
assert!(last_attempt_at(&pool, "tic").await.unwrap().is_none());
assert!(seconds_since_attempt(&pool, "tic").await.unwrap().is_none());
assert!(last_attempt_at(&pool, "event-triage").await.unwrap().is_none());
assert!(seconds_since_attempt(&pool, "event-triage").await.unwrap().is_none());
}
#[tokio::test]
async fn an_attempt_is_recorded_and_then_overwritten() {
let pool = pool().await;
mark_attempt(&pool, "tic").await.unwrap();
let first = last_attempt_at(&pool, "tic").await.unwrap().unwrap();
mark_attempt(&pool, "event-triage").await.unwrap();
let first = last_attempt_at(&pool, "event-triage").await.unwrap().unwrap();
// Fresh attempt: still one row for this agent, and the age is small.
mark_attempt(&pool, "tic").await.unwrap();
assert!(seconds_since_attempt(&pool, "tic").await.unwrap().unwrap() < 5);
mark_attempt(&pool, "event-triage").await.unwrap();
assert!(seconds_since_attempt(&pool, "event-triage").await.unwrap().unwrap() < 5);
assert!(!first.is_empty());
let rows = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM system_agent_state")
@@ -90,8 +90,8 @@ mod tests {
#[tokio::test]
async fn agents_do_not_share_a_row() {
let pool = pool().await;
mark_attempt(&pool, "tic").await.unwrap();
assert!(seconds_since_attempt(&pool, "tic").await.unwrap().is_some());
mark_attempt(&pool, "event-triage").await.unwrap();
assert!(seconds_since_attempt(&pool, "event-triage").await.unwrap().is_some());
assert!(seconds_since_attempt(&pool, "memory-lint").await.unwrap().is_none());
}
}
@@ -1,9 +1,11 @@
//! TIC — the background event processor, and the first of the **system agents**.
//! 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. TIC's job is to
//! look at the events the user's connectors pushed since the last tick (new
//! 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.
//! 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
@@ -16,10 +18,11 @@
//! database is still locked.
//!
//! The run is recorded in `system_agent_runs` in that same user's database, so
//! the trace of what TIC did 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 TIC's: every agent needs it identically, and the ordering rules
//! around it are subtle enough that one copy is the only safe number.
//! 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;
@@ -30,7 +33,7 @@ use tracing::info;
use core_api::{ConfigProperty, ConfigSet, PropertyType};
use crate::config::TicConfig;
use crate::config::EventTriageConfig;
use crate::config_store::GlobalConfigManager;
use crate::db::mcp_events;
use crate::system_agents::{
@@ -39,78 +42,79 @@ use crate::system_agents::{
security_group_property,
};
/// The chat `source` TIC's ephemeral sessions carry. Kept distinct from the
/// user-facing sources (`web`, `talk`, `telegram`) so a tick never lands in a
/// 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 TIC_SOURCE: &str = "tic";
/// The agent id, in `agents/tic/`, and the `agent_id` of its `system_agent_runs` rows.
pub const TIC_AGENT: &str = "tic";
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 TIC_ENABLED_KEY: &str = "tic.enabled";
pub const TIC_SECURITY_GROUP_KEY: &str = "tic.security_group";
pub const TIC_INTERVAL_MINUTES_KEY: &str = "tic.interval_minutes";
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: "TIC Agent".into(),
description: "TIC 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 \
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(
TIC_ENABLED_KEY,
"Enable or disable the TIC agent for the whole instance. When disabled, no events \
EVENT_TRIAGE_ENABLED_KEY,
"Enable or disable event triage for the whole instance. When disabled, no events \
are processed for anyone.",
),
security_group_property(TIC_SECURITY_GROUP_KEY),
security_group_property(EVENT_TRIAGE_SECURITY_GROUP_KEY),
ConfigProperty {
key: TIC_INTERVAL_MINUTES_KEY.into(),
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 (tic.interval_secs)."
config.yml (event_triage.interval_secs)."
.into(),
property_type: PropertyType::Int,
default_value: Some("15".into()),
},
],
owner: Some(TIC_AGENT.into()),
owner: Some(EVENT_TRIAGE_AGENT.into()),
}
}
/// What one tick did, for the run log. Counters only — never event contents.
pub struct TicRun {
/// 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 TicManager {
config: TicConfig,
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 TicManager {
impl EventTriageManager {
pub fn new(
config: TicConfig,
config: EventTriageConfig,
config_store: Arc<GlobalConfigManager>,
registry_pool: Arc<SqlitePool>,
) -> Arc<Self> {
Arc::new(Self { config, config_store, registry_pool })
}
/// One tick for one user, over that user's own runtime.
async fn tick(&self, ctx: &AgentRunCtx<'_>) -> Result<TicRun> {
/// 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(), "TIC: processing event batch");
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
@@ -121,22 +125,22 @@ impl TicManager {
let rc = configured_run_context(
&self.config_store,
&self.registry_pool,
TIC_SECURITY_GROUP_KEY,
EVENT_TRIAGE_SECURITY_GROUP_KEY,
ctx.user_id,
)
.await;
let (session_id, notified) = run_ephemeral_turn(
TIC_AGENT,
TIC_SOURCE,
EVENT_TRIAGE_AGENT,
EVENT_TRIAGE_SOURCE,
&build_prompt(&events),
rc.as_ref(),
"TIC",
"Event triage",
ctx,
)
.await?;
Ok(TicRun {
Ok(EventTriageRun {
session_id,
events_processed: events.len(),
notifications_emitted: notified,
@@ -145,40 +149,41 @@ impl TicManager {
}
#[async_trait]
impl SystemAgent for TicManager {
fn id(&self) -> &'static str { TIC_AGENT }
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 { TIC_INTERVAL_MINUTES_KEY }
fn interval_key(&self) -> &'static str { EVENT_TRIAGE_INTERVAL_MINUTES_KEY }
async fn is_enabled(&self) -> bool {
enabled_from_config(&self.config_store, TIC_ENABLED_KEY).await
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,
TIC_INTERVAL_MINUTES_KEY,
EVENT_TRIAGE_INTERVAL_MINUTES_KEY,
60,
self.config.interval_secs,
)
.await
}
/// No pending events means no tick at all — and no row. The batch is re-read
/// in [`TicManager::tick`]; it is one indexed query on a small table, and
/// paying it twice is cheaper than a trait shaped around carrying the rows.
/// 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.tick(ctx).await?;
let run = self.triage(ctx).await?;
Ok(AgentOutcome {
session_id: Some(run.session_id),
stats: serde_json::json!({
@@ -196,7 +201,7 @@ fn build_prompt(events: &[crate::db::mcp_events::McpEvent]) -> String {
let n = events.len();
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
let mut out = format!("[TIC] {n} pending event(s) — {now}\n");
let mut out = format!("[event triage] {n} pending event(s) — {now}\n");
for (i, ev) in events.iter().enumerate() {
let _ = write!(
+1 -1
View File
@@ -43,7 +43,7 @@ pub mod service_manager;
pub mod session;
pub mod setup;
pub mod system_agents;
pub mod tic;
pub mod event_triage;
pub mod tool_catalog;
pub mod tool_discovery;
pub mod tools;
+1 -1
View File
@@ -61,7 +61,7 @@ pub struct McpManager {
/// Whether a runtime's server-pushed notifications are persisted to `mcp_events`.
///
/// `mcp_events` is an **owner** table and its only consumer is TIC, which is
/// `mcp_events` is an **owner** table and its only consumer is event triage, which is
/// per-user: an event is something that happened to *someone*. The global
/// runtime has no owner — its pool is `system.db` — so persisting there would
/// produce rows nobody can attribute and nobody will ever read.
+1 -1
View File
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// A structured notification produced by a background agent (TIC) or the cron
/// A structured notification produced by a background agent (event triage) or the cron
/// runner and delivered to the user's home conversation through `ChatHub`.
///
/// This replaces the previous free-text `String` briefing. Carrying `source`,
@@ -69,7 +69,7 @@ impl ChatSessionHandler {
base_tool_defs.push(update_scratchpad_tool_def());
base_tool_defs.push(write_todos_tool_def());
// `ask_user_clarification` is available to every agent except hidden `system`
// agents (e.g. TIC), which have no user-facing channel. Interactive sessions
// agents (e.g. event triage), which have no user-facing channel. Interactive sessions
// emit AgentQuestion inline (plus the Inbox); background sessions rely on the
// Inbox alone.
let is_system = meta
@@ -80,7 +80,7 @@ impl ChatSessionHandler {
base_tool_defs.push(super::ask_user_clarification_tool_def());
}
// Background sessions (cron, tic): remove tools that only make sense in
// Background sessions (cron, event-triage): remove tools that only make sense in
// interactive sessions (e.g. read_notification, which is synthetically
// injected by ChatHub and returns EMPTY if called directly).
if !self.is_interactive {
+4 -4
View File
@@ -54,7 +54,7 @@ pub struct PendingMsg {
/// messages at each round boundary and inject them live into the running turn.
///
/// Passed as `Some` only for the root interactive turn. Sub-agents, resume, and
/// non-interactive runners (cron, TIC) pass `None` — they never inject.
/// non-interactive runners (cron, event triage) pass `None` — they never inject.
#[async_trait]
pub trait PendingUserInput: Send + Sync {
/// Drains the leading run of queued non-synthetic user messages, one entry
@@ -256,7 +256,7 @@ pub struct ChatSessionHandler {
pub(super) source: String,
/// True when a real user is actively participating (web, telegram).
pub(super) is_interactive: bool,
/// True for short-lived automated sessions (cron, tic).
/// True for short-lived automated sessions (cron, event-triage).
pub(super) is_ephemeral: bool,
pub(super) tools: Arc<ToolRegistry>,
pub(super) mcp: Arc<dyn McpProvider>,
@@ -270,7 +270,7 @@ pub struct ChatSessionHandler {
/// Prevents concurrent handle_message calls on the same session.
pub(super) processing: Mutex<()>,
/// When true, any tool call that would require human approval is automatically
/// denied instead of blocking. Used by TicManager and other headless runners
/// denied instead of blocking. Used by EventTriageManager and other headless runners
/// that cannot process approval requests.
pub(super) auto_deny_approvals: Arc<AtomicBool>,
/// Tool-call ids the user already approved via a resolve endpoint after a restart
@@ -478,7 +478,7 @@ impl ChatSessionHandler {
system_substitutions: HashMap<String, String>,
tx: mpsc::Sender<ServerEvent>,
// True for system-generated messages injected as user turns
// (TicManager ticks, notification briefings from ChatHub).
// (EventTriageManager passes, notification briefings from ChatHub).
is_synthetic: bool,
// Structured metadata persisted on the user turn (e.g. file attachments).
// The projection derives the LLM-facing block; the UI renders chips.
+2 -2
View File
@@ -160,7 +160,7 @@ impl Integrations {
/// injected by `main.rs`; `start_enabled()` runs later, from `WebFrontend`).
pub(super) fn build(rt: &Runtime, plugins: Vec<Arc<dyn Plugin>>) -> Self {
// The global runtime has no owner, so its notifications are not persisted:
// `mcp_events` is per-user and its only reader (TIC) runs per-user.
// `mcp_events` is per-user and its only reader (event triage) runs per-user.
let mcp = Arc::new(McpManager::new(
Arc::clone(&rt.db),
rt.shutdown_token.clone(),
@@ -397,7 +397,7 @@ impl Conversation {
chat_hub.register("web").await;
chat_hub.register("talk").await;
// TIC is deliberately absent: it is a system agent that runs *per user*,
// Event triage is deliberately absent: it is a system agent that runs *per user*,
// over that user's own events, sessions and hub. Building it here would
// bind it to the ownerless stack above (§19) — which is precisely the bug
// that made it inert. It is constructed by `wiring::spawn_system_agents`.
+1 -1
View File
@@ -113,7 +113,7 @@ impl Skald {
// Likewise the system-agent scheduler: it resolves a per-user runtime for
// each user it runs an agent for (blueprint §13).
spawn_system_agents(&skald, config.tic.clone());
spawn_system_agents(&skald, config.event_triage.clone());
Ok(skald)
}
+1 -1
View File
@@ -242,7 +242,7 @@ impl UserContextFactory {
user_shutdown.clone(),
"data",
// This user's connectors push into this user's `mcp_events`, which is
// what TIC reads on their behalf.
// what event triage reads on their behalf.
crate::mcp::EventLog::Persist,
));
// NOTE: per-user MCP elicitation (interactive connector login, §15) is
+15 -15
View File
@@ -15,7 +15,7 @@ use std::time::Duration;
use core_api::system_bus::{RecvError, SystemEvent};
use tracing::{info, warn};
use crate::config::{CoreConfig, TicConfig};
use crate::config::{CoreConfig, EventTriageConfig};
use crate::elicitation::ElicitationBridge;
use crate::system_agents::{self, AgentRunCtx, AgentScope, SystemAgent};
@@ -175,22 +175,22 @@ pub(super) fn spawn_user_lifecycle(skald: &Arc<super::Skald>) {
}
/// Spawns the **system-agent scheduler** — the one instance-wide timer behind
/// every background agent nobody asked for (TIC, the two memory lints).
/// every background agent nobody asked for (event triage, the two memory lints).
///
/// **One loop for all of them.** The agents differ by three orders of magnitude
/// in cadence — TIC every few minutes, a lint every week — which is exactly the
/// case that tempts a second loop. It stays one because the wake-up decides
/// nothing: [`base_tick`] only picks how often to *look*, and whether an agent
/// actually runs for a given user is [`system_agents::is_due`] against state in
/// that user's own database. Adding an agent therefore adds a registry entry,
/// never a task.
/// in cadence — event triage every few minutes, a lint every week — which is
/// exactly the case that tempts a second loop. It stays one because the wake-up
/// decides nothing: [`base_tick`] only picks how often to *look*, and whether an
/// agent actually runs for a given user is [`system_agents::is_due`] against
/// state in that user's own database. Adding an agent therefore adds a registry
/// entry, never a task.
///
/// **Due-ness is persisted, not counted from boot.** An in-memory deadline is
/// fine at TIC's scale but silently breaks a weekly agent: every restart re-arms
/// it, so on a machine rebooted every few days it would never fire once. Reading
/// the last attempt from `system_agent_state` makes a long interval survive
/// restarts, and has the pleasant side effect that a user who logs in after a
/// long absence is picked up on the next pass rather than a week later.
/// fine at event triage's scale but silently breaks a weekly agent: every restart
/// re-arms it, so on a machine rebooted every few days it would never fire once.
/// Reading the last attempt from `system_agent_state` makes a long interval
/// survive restarts, and has the pleasant side effect that a user who logs in
/// after a long absence is picked up on the next pass rather than a week later.
///
/// A user whose database is still locked is **skipped**, and that is the normal
/// case rather than an error: the pool is the unlock token (§9), so a user who
@@ -202,7 +202,7 @@ pub(super) fn spawn_user_lifecycle(skald: &Arc<super::Skald>) {
/// Spawned after `Skald` is fully built, like [`spawn_user_lifecycle`] and for
/// the same reason: it resolves each user's runtime through `Skald::user_context`.
/// The back-reference is [`std::sync::Weak`].
pub(super) fn spawn_system_agents(skald: &Arc<super::Skald>, tic_config: TicConfig) {
pub(super) fn spawn_system_agents(skald: &Arc<super::Skald>, event_triage_config: EventTriageConfig) {
let weak = Arc::downgrade(skald);
let shutdown = skald.rt.shutdown_token.clone();
let mut sys_rx = skald.rt.system_bus.subscribe();
@@ -211,7 +211,7 @@ pub(super) fn spawn_system_agents(skald: &Arc<super::Skald>, tic_config: TicConf
// `SystemAgent` impl — no loop of its own, which is the whole point: a second
// scheduler would be a fourth global bus in disguise.
let agents = system_agents::registry(
tic_config,
event_triage_config,
Arc::clone(&skald.rt.config),
Arc::clone(&skald.rt.db),
);
@@ -59,7 +59,7 @@ pub const SHARED_INTERVAL_DAYS_KEY: &str = "memory_lint_shared.interval_days";
/// The interval property, in **days**.
///
/// The unit is per-agent on purpose. TIC is configured in minutes because it
/// The unit is per-agent on purpose. Event triage is configured in minutes because it
/// runs in minutes; asking an admin to type `10080` for "weekly" would be a
/// worse form of the same field.
fn interval_days_property(key: &str, description: &str) -> ConfigProperty {
+18 -18
View File
@@ -1,16 +1,16 @@
//! System agents — the background agents the instance runs on a user's behalf.
//!
//! A system agent runs without being asked. TIC was the first, and everything it
//! needed turned out to be general: an on/off switch, an interval, a security
//! group reconciled against the user's own role, an ephemeral session, and a run
//! recorded in the user's own database. This module is that shape, extracted, so
//! a second agent is a [`SystemAgent`] impl and nothing else — no timer of its
//! own, no bookkeeping of its own, no scheduler of its own.
//! A system agent runs without being asked. Event triage was the first, and
//! everything it needed turned out to be general: an on/off switch, an interval,
//! a security group reconciled against the user's own role, an ephemeral
//! session, and a run recorded in the user's own database. This module is that
//! shape, extracted, so a second agent is a [`SystemAgent`] impl and nothing
//! else — no timer of its own, no bookkeeping of its own, no scheduler of its own.
//!
//! **The unit of work is one agent for one user.** The instance-wide scheduler
//! (`skald::wiring::spawn_system_agents`) decides who and when; an agent decides
//! only what. That split is what made TIC per-user correct, and it is why an
//! agent never sees the user list.
//! only what. That split is what made event triage per-user correct, and it is
//! why an agent never sees the user list.
//!
//! ## Why the work is split in three
//!
@@ -125,13 +125,13 @@ pub trait SystemAgent: Send + Sync {
/// like "the agent runs but has no settings" or "the settings page edits keys
/// nothing reads".
pub fn registry(
tic_config: crate::config::TicConfig,
config_store: Arc<GlobalConfigManager>,
registry_pool: Arc<SqlitePool>,
event_triage_config: crate::config::EventTriageConfig,
config_store: Arc<GlobalConfigManager>,
registry_pool: Arc<SqlitePool>,
) -> Vec<Arc<dyn SystemAgent>> {
vec![
crate::tic::TicManager::new(
tic_config,
crate::event_triage::EventTriageManager::new(
event_triage_config,
Arc::clone(&config_store),
Arc::clone(&registry_pool),
),
@@ -151,7 +151,7 @@ pub fn registry(
/// keeps the two honest.
pub fn config_sets() -> Vec<ConfigSet> {
vec![
crate::tic::config_set(),
crate::event_triage::config_set(),
memory_lint::private_config_set(),
memory_lint::shared_config_set(),
]
@@ -414,9 +414,9 @@ mod tests {
use super::*;
#[test]
fn tic_config_set_is_owned_by_tic() {
let set = crate::tic::config_set();
assert_eq!(set.owner.as_deref(), Some(crate::tic::TIC_AGENT));
fn event_triage_config_set_is_owned_by_event_triage() {
let set = crate::event_triage::config_set();
assert_eq!(set.owner.as_deref(), Some(crate::event_triage::EVENT_TRIAGE_AGENT));
}
#[test]
@@ -459,7 +459,7 @@ mod tests {
// The scheduler watches `interval_key()` for live changes; a key that is
// not in the set is one nothing can ever edit.
for (set, key) in [
(crate::tic::config_set(), crate::tic::TIC_INTERVAL_MINUTES_KEY),
(crate::event_triage::config_set(), crate::event_triage::EVENT_TRIAGE_INTERVAL_MINUTES_KEY),
(memory_lint::private_config_set(), memory_lint::PRIVATE_INTERVAL_DAYS_KEY),
(memory_lint::shared_config_set(), memory_lint::SHARED_INTERVAL_DAYS_KEY),
] {
+1 -1
View File
@@ -9,7 +9,7 @@ use crate::session::handler::{InterfaceTool, ToolFuture};
/// Build a `notify` InterfaceTool bound to the given `ChatHub`.
///
/// `default_source` is used as the notification `source` only when the caller
/// omits one (kept for callers like TIC that pass a fixed origin tag). Normally
/// omits one (kept for callers like event triage that pass a fixed origin tag). Normally
/// the agent supplies `source` explicitly from the event it is surfacing.
pub fn make_tool(hub: Arc<ChatHub>, default_source: impl Into<String>) -> InterfaceTool {
let default_source = default_source.into();