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:
@@ -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).
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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(®istry_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),
|
||||
] {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user