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:
@@ -108,7 +108,7 @@ Two rules keep the boundary real, and both are enforced by the compiler:
|
||||
| `crates/skald-core/src/plugin/` | Plugin system: discovery, enable/disable, tool registration, per-user access grants + per-user config |
|
||||
| `crates/skald-core/src/cron/` | Scheduled job runner |
|
||||
| `crates/skald-core/src/system_agents/` | The `SystemAgent` trait + `run_and_record` + the shared ephemeral-turn/run-context machinery, plus `registry()` (the one enumeration of the agents) and `memory_lint.rs` (the two lint agents). See the system-agents section |
|
||||
| `crates/skald-core/src/tic/` | `TicManager`: one pass of the TIC system agent for **one** user. No timer of its own — the instance-wide scheduler is `skald::wiring::spawn_system_agents` |
|
||||
| `crates/skald-core/src/event_triage/` | `EventTriageManager`: one pass of the event-triage system agent for **one** user. No timer of its own — the instance-wide scheduler is `skald::wiring::spawn_system_agents` |
|
||||
| `crates/skald-core/src/compactor.rs` | Context compaction **policy** — when to compact and with which model; the mechanics are `agent_loop::compaction`. Model for the summary call: the instance-wide Settings pick (`compaction_model`, a `PropertyType::LlmModel` config property declared by `compactor::config_set`) wins; else AUTO by `compaction.strength` (config.yml); a missing configured model degrades to the same AUTO path |
|
||||
| `crates/skald-core/src/approval/` | Approval rules engine |
|
||||
| `crates/skald-core/src/clarification/` | `ClarificationManager`: background-session question/answer |
|
||||
@@ -142,7 +142,7 @@ Schema is greenfield (no migrations, §0), but a purely **additive** column land
|
||||
|
||||
**Prompt substitutions**: an `AGENT.md` may carry `<!-- KEY -->` placeholders; `agents::resolve_includes` turns each into a `__KEY__` sentinel, replaced at request time. Two are resolved by the system-context source itself (`loop_adapters/system.rs`) from the session owner (`user_id`) + registry (`shared_pool`), so every source (WS, mobile, cron, sub-agents) gets them with no caller plumbing: `__SHARED_FOLDERS__` (the user's shared-folders table) and `__USER_PROFILE__` (the owner's directory profile: `Name`, `Date of birth` with age computed at build time, `Sex`, `Preferred language`, admin `Notes` — unset values render as explicit `unknown` / `not specified`, the `Notes` line is omitted when empty). Any other key comes from the per-call `SendMessageOptions::system_substitutions` map.
|
||||
|
||||
`system.db` still gets **both** bucket functions — but no longer because the migration is unstarted. It gets the owner schema because it *is* the owner of **shared** memory (`memory_docs`) plus, for now, the globally-scoped `secrets` (`SecretsStore` is built on the system pool and shared by reference into every `UserContext`; the global runtime's *config* now lives in the registry table `mcp_global_servers`, and per-user connector config in each user's owner `mcp_user_servers`). The global runtime no longer writes `mcp_events` there: notification persistence is an explicit `McpManager::new` argument (`EventLog::{Persist,Discard}`), `Discard` for the ownerless global runtime and `Persist` for each per-user one, because an event belongs to whoever it happened to and its only reader (TIC) is per-user. Every *other* owner table is created there but never written to anymore — the global owner-bound managers that would write them (chat/jobs/etc.) are inert (see "Current state"). Fully dropping `create_owner_tables` from `system.db` is blocked on the §4 scope decision for secrets, not on call-site migration.
|
||||
`system.db` still gets **both** bucket functions — but no longer because the migration is unstarted. It gets the owner schema because it *is* the owner of **shared** memory (`memory_docs`) plus, for now, the globally-scoped `secrets` (`SecretsStore` is built on the system pool and shared by reference into every `UserContext`; the global runtime's *config* now lives in the registry table `mcp_global_servers`, and per-user connector config in each user's owner `mcp_user_servers`). The global runtime no longer writes `mcp_events` there: notification persistence is an explicit `McpManager::new` argument (`EventLog::{Persist,Discard}`), `Discard` for the ownerless global runtime and `Persist` for each per-user one, because an event belongs to whoever it happened to and its only reader (event triage) is per-user. Every *other* owner table is created there but never written to anymore — the global owner-bound managers that would write them (chat/jobs/etc.) are inert (see "Current state"). Fully dropping `create_owner_tables` from `system.db` is blocked on the §4 scope decision for secrets, not on call-site migration.
|
||||
|
||||
`users` (`crates/skald-core/src/db/users.rs`) holds the directory plus auth material. It lives in the system DB, which the box owner can read, so it must never store anything that derives a user's key. `Credentials` is an enum mirroring the table's `CHECK`: an encrypted user carries a **wrapped DEK** (whose AEAD tag *is* the password verifier — hence no `password_hash`); a cleartext user carries an ordinary verifier, or none. `User` is deliberately not `Serialize` and its `Debug` redacts key material — use `User::summary()` for anything leaving the process. `role_id` references `roles(id)` (the `roles` table is now seeded before `users` in `create_registry_tables`). A nullable `locale` column (additive via `ensure_column`) holds the per-user UI language override; role-driven conventions live in the free-form `roles.attrs` JSON — never new columns per attribute — parsed at a **single point** by the typed `db::roles::RoleAttrs` (`ui_mode`, `permission_groups`, `chat_agent`): `ui_mode` (see the frontend section) plus the role's **security-group set** (`roles.permission_group` = the default group, `attrs.permission_groups` = additional allowed groups; `Role::effective_groups()` = the union, `roles::role_allows_group()` gates it with `admin` short-circuiting to all). See the security-group picker in the frontend section. The role's **default entry (chat) agent** is `attrs.chat_agent` — the neutral `chat`-type agent members of the role land on (§0.1: data, not an enum). Resolved by `roles::default_chat_agent_for_user(registry_pool, user_id)` — the single seam behind both the per-user `ChatHub`'s `default_agent` (snapshotted at login in `UserContextFactory::build`, like fs/MCP access, so **every** session-creation path — explicit `provision_session`, lazy WS `get_or_create_session`, notify — honors it) and `provisioning_for_source`'s non-project branch. Falls back to `agents::DEFAULT_CHAT_AGENT` (`"assistant"`, the renamed former `main`) when unset. Seeded: `admin`/`member` → `assistant`, `children` → `kid` (Companion). A per-user override is future work, layering on top in the same resolver. The stack **root frame** is created with the session's own `agent_id` (not a literal) — `config.agent_id` (from the frame) drives which prompt runs, so a wrong id there silently runs the wrong agent. The admin-managed **directory profile** lives in three more additive columns — `birthdate` (ISO `YYYY-MM-DD`), `sex` (free text), `notes` (admin-authored) — edited only from the Users admin page (`set_directory_fields`; validation — real non-future date, length caps — lives in the `users_mgmt` API, not the db layer) and rendered into agent prompts by the `__USER_PROFILE__` substitution (see above). They are directory metadata written *by* the admin *about* the user, so the registry is their honest home under the §2 threat model.
|
||||
|
||||
@@ -217,15 +217,15 @@ For a per-user connector whose credential is produced by **pairing** (`auth.type
|
||||
|
||||
**Deferred:** SSH and other §15 device kinds (would reuse the `login_status` contract), `deliver.as=file`, and non-Google OAuth providers are unimplemented paths that error clearly rather than half-work. No boot seed of catalog presets; the admin populates the catalog from the Marketplace.
|
||||
|
||||
## System agents (TIC, memory lints)
|
||||
## System agents (event triage, memory lints)
|
||||
|
||||
A **system agent** runs on a user's behalf without being asked. There are three — TIC (the background event processor) and the two memory lints — behind **one** scheduler, and the machinery is deliberately shaped so a fourth is a trait impl plus one line in a registry.
|
||||
A **system agent** runs on a user's behalf without being asked. There are three — event triage (the background event processor) and the two memory lints — behind **one** scheduler, and the machinery is deliberately shaped so a fourth is a trait impl plus one line in a registry.
|
||||
|
||||
**The unit of work is one agent for one user**, and every part of the design falls out of that. TIC's events (`mcp_events`) are in the caller's own encrypted database, pushed there by connectors in the caller's container; the notification goes to the caller's hub; the trace (`system_agent_runs`) is in that same file. So an agent owns **no timer and no user list**: it implements `SystemAgent` (`crates/skald-core/src/system_agents/`) — `has_work` + `run` over an `AgentRunCtx` unpacked from that user's `UserContext` — and `skald::wiring::spawn_system_agents` decides who and when. Building TIC against the ownerless `Conversation` bundle was exactly what made the pre-multi-user version inert: it wrote sessions into `system.db`, notified a hub with no subscribers, and resolved tool paths against a container that does not exist.
|
||||
**The unit of work is one agent for one user**, and every part of the design falls out of that. the triage agent's events (`mcp_events`) are in the caller's own encrypted database, pushed there by connectors in the caller's container; the notification goes to the caller's hub; the trace (`system_agent_runs`) is in that same file. So an agent owns **no timer and no user list**: it implements `SystemAgent` (`crates/skald-core/src/system_agents/`) — `has_work` + `run` over an `AgentRunCtx` unpacked from that user's `UserContext` — and `skald::wiring::spawn_system_agents` decides who and when. Building it against the ownerless `Conversation` bundle was exactly what made the pre-multi-user version inert: it wrote sessions into `system.db`, notified a hub with no subscribers, and resolved tool paths against a container that does not exist.
|
||||
|
||||
**One loop for cadences three orders of magnitude apart.** TIC runs every few minutes, a lint weekly — the case that tempts a second loop. It stays one because the wake-up decides nothing: `base_tick` (min enabled interval, clamped to [60s, 15min]) only picks how often to *look*, and whether an agent runs for a given user is `system_agents::is_due` against persisted state. A second scheduler would be a fourth global bus in disguise.
|
||||
**One loop for cadences three orders of magnitude apart.** Event triage runs every few minutes, a lint weekly — the case that tempts a second loop. It stays one because the wake-up decides nothing: `base_tick` (min enabled interval, clamped to [60s, 15min]) only picks how often to *look*, and whether an agent runs for a given user is `system_agents::is_due` against persisted state. A second scheduler would be a fourth global bus in disguise.
|
||||
|
||||
**Due-ness is persisted, not counted from boot** — the new owner table `system_agent_state(agent_id, last_attempt_at)` (accessor `db/system_agent_state.rs`). It is deliberately **not** `system_agent_runs`: the run log is a history for the human and skips idle ticks, while scheduling needs *every* attempt, so reading due-ness off the log would re-run an idle agent every tick and never bring a weekly one due once its last productive run aged out. Persisting it is also what makes a long interval survive a restart — an in-memory deadline is fine at TIC's scale but a weekly agent on a box rebooted every few days would have it re-armed before it ever fired, and would simply never run. Side benefit: a user who logs in after a long absence is picked up on the next pass.
|
||||
**Due-ness is persisted, not counted from boot** — the new owner table `system_agent_state(agent_id, last_attempt_at)` (accessor `db/system_agent_state.rs`). It is deliberately **not** `system_agent_runs`: the run log is a history for the human and skips idle ticks, while scheduling needs *every* attempt, so reading due-ness off the log would re-run an idle agent every tick and never bring a weekly one due once its last productive run aged out. Persisting it is also what makes a long interval survive a restart — an in-memory deadline is fine at event triage's scale but a weekly agent on a box rebooted every few days would have it re-armed before it ever fired, and would simply never run. Side benefit: a user who logs in after a long absence is picked up on the next pass.
|
||||
|
||||
**`run_and_record` orders the three steps, once, for everybody**: mark the attempt (always, even for an idle pass) → `has_work` (`false` writes nothing at all, or the run log becomes a heartbeat) → open the run row, then work. The `start`/`finish` split (unlike `job_runs`, written once at the end) leaves a visible `running` row when the process dies mid-pass, swept to `failed` by the next `start` for that agent — safe precisely because the scheduler is sequential and single-instance, at both levels (agents in order, then users in order).
|
||||
|
||||
@@ -243,7 +243,7 @@ A **system agent** runs on a user's behalf without being asked. There are three
|
||||
|
||||
**Read-only, enforced twice.** The prompt says report-never-repair, and `shared-memory/*` writes are already `@fs_write require` — so an agent that tried to fix something would raise an approval card from an unattended pass, which `run_ephemeral_turn` auto-denies. Read-only is not a convention here, it is the only thing that works. `has_work` is "the store is non-empty", so a member who never uses memory collects no weekly row and no weekly notification.
|
||||
|
||||
**Interval units are per-agent**: TIC in minutes, the lints in days (`interval_from_config` takes the unit). Asking an admin to type `10080` for "weekly" would be a worse version of the same field.
|
||||
**Interval units are per-agent**: event triage in minutes, the lints in days (`interval_from_config` takes the unit). Asking an admin to type `10080` for "weekly" would be a worse version of the same field.
|
||||
|
||||
### Where the settings live
|
||||
|
||||
@@ -251,7 +251,7 @@ A **system agent** runs on a user's behalf without being asked. There are three
|
||||
|
||||
`/api/config` serves only owner-less sets and is now **admin-gated** (`caps::require_admin`), read *and* write: before this, both handlers ignored the caller entirely, so any authenticated session could read and change instance config — the sidebar hiding the page is presentation, not authorization. `GET /api/system-agents` lists the agents, with `config` resolved (via the shared `config::render_sets`) only for an admin and `Value::Null` for everyone else; writes still go through `PUT /api/config/{key}`, so the gate and the known-key check exist in one place.
|
||||
|
||||
UI: `#system-agents` (`web/components/system-agents.js`, sidebar group `extensions`, **visible to everyone** — the run log is the caller's own). **One tab per agent, plus "All"**, each tab holding that agent's description, its settings (admin only) and its runs — the tab is the agent, not the kind of information, because "why did this do nothing last night?" is half a schedule question and half a log question. The settings form is `web/components/shared/config-form.js` (`ConfigFormController`), shared with `config-page.js` so an owned set renders identically wherever it is edited. It replaced the old `#tic` "TIC Sessions" debug page, which listed `chat_sessions WHERE source='tic'` and so inferred runs from leftover ephemeral sessions rather than recording them.
|
||||
UI: `#system-agents` (`web/components/system-agents.js`, sidebar group `extensions`, **visible to everyone** — the run log is the caller's own). **One tab per agent, plus "All"**, each tab holding that agent's description, its settings (admin only) and its runs — the tab is the agent, not the kind of information, because "why did this do nothing last night?" is half a schedule question and half a log question. The settings form is `web/components/shared/config-form.js` (`ConfigFormController`), shared with `config-page.js` so an owned set renders identically wherever it is edited. It replaced a since-removed debug page (`#tic`, from when the triage agent was called TIC), which listed `chat_sessions WHERE source='tic'` and so inferred runs from leftover ephemeral sessions rather than recording them.
|
||||
|
||||
## Multimodal attachments
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ All agents now have **Vector Paintings** icons (painterly vector, warm and famil
|
||||
|
||||
| Agent | Animal | Status |
|
||||
|-------|--------|--------|
|
||||
| TIC | 🕷️ Spider | ✅ |
|
||||
| Event triage | 🕷️ Spider | ✅ |
|
||||
| Private Memory Lint | ✨ Firefly | ✅ |
|
||||
| Shared Memory Lint | 🐝 Bee | ✅ |
|
||||
### Refactoring — completed ✅
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ System agents (`type: "system"`) are invisible background agents that maintain t
|
||||
|
||||
| Agent | Animal | Role | Elements | Palette |
|
||||
|-------|--------|------|----------|---------|
|
||||
| **TIC** 👁️ | Spider 🕷️ | Watchful guardian | Sensor nodes, glowing web, radar arcs, notification symbols (bell, letter, calendar) | Dark purple, amber, soft cyan, warm grey |
|
||||
| **Event triage** 👁️ | Spider 🕷️ | Watchful guardian | Sensor nodes, glowing web, radar arcs, notification symbols (bell, letter, calendar) | Dark purple, amber, soft cyan, warm grey |
|
||||
| **Private Memory Lint** 🧹 | Firefly ✨ | Private memory caretaker | Glowing lantern, memory fragments, tiny notes, sparkles | Warm gold, amber, soft teal, gentle green |
|
||||
| **Shared Memory Lint** 🧹 | Bee 🐝 | Shared space caretaker | Scroll with guidelines, honey dipper, honeycomb shapes, tiny documents | Warm amber, gold, soft teal, honey |
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# TIC — Background Event Processor
|
||||
# Event triage — Background Event Processor
|
||||
|
||||
You are **TIC**, an ephemeral background agent. You are not part of a user conversation. You run silently, in the background, as a periodic tick of the system.
|
||||
You are **event triage**, an ephemeral background agent. You are not part of a user conversation. You run silently, in the background, as a periodic pass of the system.
|
||||
|
||||
Your name is what your job is: you **sort** incoming events by whether they deserve the user's attention. You never act on one.
|
||||
|
||||
You always run **for one specific user**. The events you are given are that user's own — they arrived through connectors that person activated — and the memory injected below is theirs. Everything you decide is on their behalf and reaches nobody else.
|
||||
|
||||
@@ -19,11 +21,11 @@ You receive a batch of pending events collected from external sources (email, Wh
|
||||
|
||||
## Your lifecycle
|
||||
|
||||
This is an **ephemeral session**. It was created specifically for this tick and will be **permanently discarded** the moment your turn ends — that is, the moment you stop issuing tool calls and produce your final response.
|
||||
This is an **ephemeral session**. It was created specifically for this pass and will be **permanently discarded** the moment your turn ends — that is, the moment you stop issuing tool calls and produce your final response.
|
||||
|
||||
- There is no user waiting on the other end. Do not write conversational responses.
|
||||
- Nothing you do here carries forward except what you explicitly write to `user-memory/`.
|
||||
- Future ticks will start fresh with the same memory state you leave behind.
|
||||
- Future passes will start fresh with the same memory state you leave behind.
|
||||
|
||||
**Do not linger.** Reach a decision, act if needed, return.
|
||||
|
||||
@@ -99,7 +101,7 @@ Be efficient. Only fetch what you actually need to make a decision.
|
||||
- Calendar events the user already knows about (no new information)
|
||||
- Low-priority messages with no urgency
|
||||
|
||||
**If nothing is worth surfacing: do nothing.** Return without calling `notify`. An empty tick is a correct tick — do not manufacture notifications just to seem active.
|
||||
**If nothing is worth surfacing: do nothing.** Return without calling `notify`. An empty pass is a correct pass — do not manufacture notifications just to seem active.
|
||||
|
||||
---
|
||||
|
||||
@@ -140,7 +142,7 @@ You are producing **structured data, not a message to the user.** The main agent
|
||||
|
||||
<!-- INCLUDE: common/memory.md -->
|
||||
|
||||
TIC reads memory primarily to evaluate relevance. Write to memory only when you discover something genuinely new and durable — for example, a new contact who wrote for the first time, or a project status update that changes what the user needs to monitor.
|
||||
You read memory primarily to evaluate relevance. Write to memory only when you discover something genuinely new and durable — for example, a new contact who wrote for the first time, or a project status update that changes what the user needs to monitor.
|
||||
|
||||
---
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "Event triage",
|
||||
"description": "Hidden background agent. Spawned periodically by the scheduler. Processes pending MCP events (email, WhatsApp, calendar), evaluates relevance, and notifies the user via notify() when something is worth surfacing. Ephemeral: session is discarded as soon as the turn ends.",
|
||||
"friendly_description": "Background agent that periodically reviews incoming email, WhatsApp, and calendar events and pings you when something matters.",
|
||||
"i18n": {
|
||||
"it": {
|
||||
"name": "Triage eventi",
|
||||
"friendly_description": "Agente in background che esamina periodicamente email, WhatsApp ed eventi del calendario e ti avvisa quando qualcosa è importante."
|
||||
},
|
||||
"fr": {
|
||||
"name": "Tri des événements",
|
||||
"friendly_description": "Agent en arrière-plan qui examine périodiquement les e-mails, WhatsApp et les événements du calendrier et vous avertit quand quelque chose compte."
|
||||
}
|
||||
},
|
||||
"type": "system",
|
||||
"inject_skills": false,
|
||||
"inject_memory": ["user-memory/index.md"],
|
||||
"icon": "icon.png",
|
||||
"strength": "low"
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "TIC",
|
||||
"description": "Hidden background agent. Spawned periodically by the scheduler. Processes pending MCP events (email, WhatsApp, calendar), evaluates relevance, and notifies the user via notify() when something is worth surfacing. Ephemeral: session is discarded as soon as the turn ends.",
|
||||
"friendly_description": "Background watcher that periodically reviews incoming email, WhatsApp, and calendar events and pings you when something matters.",
|
||||
"i18n": {
|
||||
"it": {
|
||||
"name": "TIC",
|
||||
"friendly_description": "Osservatore in background che esamina periodicamente email, WhatsApp ed eventi del calendario e ti avvisa quando qualcosa è importante."
|
||||
},
|
||||
"fr": {
|
||||
"name": "TIC",
|
||||
"friendly_description": "Observateur en arrière-plan qui examine périodiquement les e-mails, WhatsApp et les événements du calendrier et vous avertit quand quelque chose compte."
|
||||
}
|
||||
},
|
||||
"type": "system",
|
||||
"inject_skills": false,
|
||||
"inject_memory": ["user-memory/index.md"],
|
||||
"icon": "icon.png",
|
||||
"strength": "low"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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": []}})
|
||||
|
||||
@@ -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();
|
||||
|
||||
+6
-6
@@ -85,14 +85,14 @@ llm:
|
||||
# strength: low # LLM strength for summary generation
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── TIC background event processor ─────────────────────────────────────────
|
||||
# TIC runs periodically to process pending MCP events (email, calendar, WhatsApp)
|
||||
# and decide whether to surface a notification to the user.
|
||||
# ── Event triage (background event processor) ──────────────────────────────
|
||||
# Event triage runs periodically to process pending MCP events (email, calendar,
|
||||
# WhatsApp) and decide whether to surface a notification to the user.
|
||||
#
|
||||
# interval_secs — how often TIC runs (default: 900 = 15 minutes)
|
||||
# batch_size — max events processed per tick (default: 50)
|
||||
# interval_secs — how often it runs (default: 900 = 15 minutes)
|
||||
# batch_size — max events processed per pass (default: 50)
|
||||
#
|
||||
# tic:
|
||||
# event_triage:
|
||||
# interval_secs: 900
|
||||
# batch_size: 50
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ This index will grow over time. Right now it covers memory, projects, system age
|
||||
| --- | --- |
|
||||
| [memory.md](memory.md) | Private and shared memory: what goes where, the indexes and history log, why some shared facts can't be changed on request |
|
||||
| [projects.md](projects.md) | Projects: shared folders with their own assistant chat, a live file explorer, and member sharing |
|
||||
| [system-agents.md](system-agents.md) | Background agents that run on a schedule (TIC, the two memory lints): what they watch, why they only ever report, why a run can be skipped, and their settings |
|
||||
| [system-agents.md](system-agents.md) | Background agents that run on a schedule (event triage, the two memory lints): what they watch, why they only ever report, why a run can be skipped, and their settings |
|
||||
| [settings.md](settings.md) | The admin's Config page: interface language, the compaction model picker, debug mode |
|
||||
|
||||
## Plugins
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ Each setting is saved individually with its own **Save** button (a few, like the
|
||||
|
||||
## Background agents — not here
|
||||
|
||||
The settings for the background agents (TIC, the two memory lints) are **not** on this page. Each one is configured on its own tab of the **System agents** page, next to that agent's run history — see [system-agents.md](system-agents.md).
|
||||
The settings for the background agents (event triage, the two memory lints) are **not** on this page. Each one is configured on its own tab of the **System agents** page, next to that agent's run history — see [system-agents.md](system-agents.md).
|
||||
|
||||
They are still admin-only, and still instance-wide. They simply live where their run log is, because "why did this agent do nothing last night?" is usually answered half by the schedule and half by the log.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ There are three:
|
||||
|
||||
| Agent | What it watches | How often |
|
||||
| --- | --- | --- |
|
||||
| **TIC** | events arriving from that person's connectors | every few minutes |
|
||||
| **Event triage** | events arriving from that person's connectors | every few minutes |
|
||||
| **Private memory lint** | that person's own memory notes | weekly |
|
||||
| **Shared memory lint** | the group's shared memory | weekly |
|
||||
|
||||
@@ -16,13 +16,13 @@ They share three habits worth stating once, because they explain most of what pe
|
||||
- **An empty run is a correct run.** They are not supposed to find something every time, and they stay quiet when they don't.
|
||||
- **They run per person, on that person's own things**, with one exception noted below.
|
||||
|
||||
## TIC
|
||||
## Event triage
|
||||
|
||||
Connectors (Gmail, a calendar, WhatsApp…) push events into the system as they happen — a new message arrives, a meeting is moved. Those events pile up quietly; nothing interrupts anyone.
|
||||
|
||||
Every so often TIC wakes up and reads the batch that accumulated since last time. For each event it decides whether it is worth the interruption, using what it knows about that person from their private memory: who matters to them, what they are working on, what they have said they want to be told about. Events that pass become notifications in their Inbox. Events that don't are simply marked as seen — a newsletter or a group chat with nothing relevant in it produces nothing.
|
||||
Every so often event triage wakes up and reads the batch that accumulated since last time. For each event it decides whether it is worth the interruption, using what it knows about that person from their private memory: who matters to them, what they are working on, what they have said they want to be told about. Events that pass become notifications in their Inbox. Events that don't are simply marked as seen — a newsletter or a group chat with nothing relevant in it produces nothing.
|
||||
|
||||
TIC never replies to a message or moves a calendar event. If an event needs an action, it says so in the notification.
|
||||
The name is the limit of the job: it **sorts**, it never acts. It will not reply to a message or move a calendar event. If an event needs an action, it says so in the notification and the person decides.
|
||||
|
||||
## The two memory lints
|
||||
|
||||
@@ -76,7 +76,7 @@ A run appears **only when there was something to look at**. Long gaps mean quiet
|
||||
Each agent's tab carries the same three settings, visible only to an admin:
|
||||
|
||||
- **Enabled** — turns that agent on or off for the whole instance, for everyone.
|
||||
- **Interval** — how long between passes for each person. TIC is in minutes, the lints in days.
|
||||
- **Interval** — how long between passes for each person. Event triage is in minutes, the lints in days.
|
||||
- **Security group** — which tools the agent may use during a run. It is re-checked against each user's own role: if their role does not allow that group, their run uses their role's default group instead. Nobody's background agent gets more access than their role would give them.
|
||||
|
||||
There is no per-user on/off switch: if an agent is enabled, it runs for everyone who has logged in.
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@ use serde::Deserialize;
|
||||
|
||||
pub use core_api::provider::LlmStrength;
|
||||
pub use skald_core::config::{
|
||||
LlmConfig, TicConfig, CronConfig,
|
||||
LlmConfig, EventTriageConfig, CronConfig,
|
||||
CompactionConfig, DatetimeConfig, LlmRequestsLogConfig,
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub marketplace: MarketplaceConfig,
|
||||
#[serde(default)]
|
||||
pub tic: TicConfig,
|
||||
pub event_triage: EventTriageConfig,
|
||||
#[serde(default)]
|
||||
pub cron: CronConfig,
|
||||
/// Global IANA timezone name (e.g. `"Europe/Rome"`).
|
||||
@@ -63,7 +63,7 @@ impl Config {
|
||||
(
|
||||
skald_core::config::CoreConfig {
|
||||
llm: self.llm,
|
||||
tic: self.tic,
|
||||
event_triage: self.event_triage,
|
||||
cron: self.cron,
|
||||
timezone: self.timezone,
|
||||
},
|
||||
|
||||
@@ -53,7 +53,7 @@ pub fn router() -> Router<Arc<Skald>> {
|
||||
// Custom slash commands (file-based, read-only listing for autocomplete + /help)
|
||||
.route("/commands", get(commands::list))
|
||||
.route("/sessions", get(sessions::list_sessions).post(sessions::create))
|
||||
// System agents (TIC, memory lints) — the caller's own run history, plus
|
||||
// System agents (event triage, memory lints) — the caller's own run history, plus
|
||||
// the agent list (settings included only for an admin).
|
||||
.route("/system-agents", get(system_agents::list_agents))
|
||||
.route("/system-agents/runs", get(system_agents::list_runs))
|
||||
|
||||
@@ -593,7 +593,7 @@ fn build_items<'a>(
|
||||
let failed = msg.status == "failed";
|
||||
match msg.role {
|
||||
chat_history::Role::User => {
|
||||
// Skip synthetic messages (TIC notifications, etc.) — they are
|
||||
// Skip synthetic messages (event triage notifications, etc.) — they are
|
||||
// injected as user turns for the LLM but must not appear in the UI.
|
||||
if msg.is_synthetic {
|
||||
continue;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! System agents — the background agents the instance runs on a user's behalf
|
||||
//! (blueprint §13): TIC and the two memory lints.
|
||||
//! (blueprint §13): event triage and the two memory lints.
|
||||
//!
|
||||
//! **This page has two audiences, and the split is the whole design.**
|
||||
//!
|
||||
@@ -88,7 +88,7 @@ pub async fn list_agents(
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListRunsQuery {
|
||||
/// Narrow to one agent (`tic`). Omitted = every system agent.
|
||||
/// Narrow to one agent (`event-triage`). Omitted = every system agent.
|
||||
pub agent_id: Option<String>,
|
||||
#[serde(default = "default_page")]
|
||||
pub page: i64,
|
||||
|
||||
@@ -21,7 +21,7 @@ function formatTime(iso) {
|
||||
}
|
||||
|
||||
function sourceBadgeClass(source) {
|
||||
const map = { tic: 'bg-warning text-dark', cron: 'bg-info text-dark', web: 'bg-primary', telegram: 'bg-success', mobile: 'bg-secondary' };
|
||||
const map = { 'event-triage': 'bg-warning text-dark', cron: 'bg-info text-dark', web: 'bg-primary', telegram: 'bg-success', mobile: 'bg-secondary' };
|
||||
return map[source] ?? 'bg-secondary';
|
||||
}
|
||||
|
||||
|
||||
+10
-10
@@ -125,7 +125,7 @@ export default {
|
||||
'llmr.filter.agent_id': 'Agent ID',
|
||||
'llmr.filter.agent_ph': 'e.g. assistant',
|
||||
'llmr.filter.source': 'Source',
|
||||
'llmr.filter.source_ph': 'e.g. web, tic, cron',
|
||||
'llmr.filter.source_ph': 'e.g. web, event-triage, cron',
|
||||
'llmr.filter.from': 'From',
|
||||
'llmr.filter.to': 'To',
|
||||
'llmr.filter.apply': 'Apply',
|
||||
@@ -184,12 +184,12 @@ export default {
|
||||
|
||||
'config.prop.ui_locale.name': 'Language',
|
||||
'config.prop.ui_locale.desc': 'Default interface language for the whole instance. Each user can override it on their profile.',
|
||||
'config.prop.tic__enabled.name': 'Enabled',
|
||||
'config.prop.tic__enabled.desc': 'Enable or disable the TIC agent for the whole instance. When disabled, no events are processed for anyone.',
|
||||
'config.prop.tic__security_group.name': 'Security group',
|
||||
'config.prop.tic__security_group.desc': 'Tool permission group applied to each TIC run. It is re-checked against each user\'s own role: a user whose role does not allow this group runs under their role\'s default group instead. Leave empty to always use the role default.',
|
||||
'config.prop.tic__interval_minutes.name': 'Check interval (minutes)',
|
||||
'config.prop.tic__interval_minutes.desc': '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.prop.event_triage__enabled.name': 'Enabled',
|
||||
'config.prop.event_triage__enabled.desc': 'Enable or disable event triage for the whole instance. When disabled, no events are processed for anyone.',
|
||||
'config.prop.event_triage__security_group.name': 'Security group',
|
||||
'config.prop.event_triage__security_group.desc': 'Tool permission group applied to each event-triage run. It is re-checked against each user\'s own role: a user whose role does not allow this group runs under their role\'s default group instead. Leave empty to always use the role default.',
|
||||
'config.prop.event_triage__interval_minutes.name': 'Check interval (minutes)',
|
||||
'config.prop.event_triage__interval_minutes.desc': '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).',
|
||||
|
||||
'config.prop.memory_lint_private__enabled.name': 'Enabled',
|
||||
'config.prop.memory_lint_private__enabled.desc': 'Enable the private memory lint for the whole instance. When disabled, nobody\'s private store is checked.',
|
||||
@@ -336,7 +336,7 @@ export default {
|
||||
|
||||
// ── Settings (config page extras) ──────────────────────────────────────────
|
||||
'config.debug': 'Debug mode',
|
||||
'config.debug.desc': 'Show developer pages (LLM requests, TIC sessions) in the sidebar.',
|
||||
'config.debug.desc': 'Show developer pages (LLM requests) in the sidebar.',
|
||||
|
||||
// ── First-run setup ────────────────────────────────────────────────────────
|
||||
'setup.title': 'Welcome to Skald',
|
||||
@@ -971,8 +971,8 @@ export default {
|
||||
'system_agents.tab.all': 'All',
|
||||
'system_agents.settings': 'Settings',
|
||||
|
||||
'system_agents.agent.tic.name': 'TIC',
|
||||
'system_agents.agent.tic.desc': 'Reads the events your connectors receive — new mail, calendar changes, incoming messages — decides which of them are worth your attention, and notifies you about those. It runs for one person at a time and reads only that person\'s events.',
|
||||
'system_agents.agent.event-triage.name': 'Event triage',
|
||||
'system_agents.agent.event-triage.desc': 'Reads the events your connectors receive — new mail, calendar changes, incoming messages — decides which of them are worth your attention, and notifies you about those. It runs for one person at a time and reads only that person\'s events.',
|
||||
'system_agents.agent.memory-lint-private.name': 'Private memory lint',
|
||||
'system_agents.agent.memory-lint-private.desc': 'A periodic check-up of your own memory. It looks for facts whose date has gone by, questions you were asked and never answered, notes the index has lost track of, and duplicates worth merging — then tells you what it found. It never edits your notes.',
|
||||
'system_agents.agent.memory-lint-shared.name': 'Shared memory lint',
|
||||
|
||||
+10
-10
@@ -125,7 +125,7 @@ export default {
|
||||
'llmr.filter.agent_id': 'ID de l\'agent',
|
||||
'llmr.filter.agent_ph': 'ex. assistant',
|
||||
'llmr.filter.source': 'Source',
|
||||
'llmr.filter.source_ph': 'ex. web, tic, cron',
|
||||
'llmr.filter.source_ph': 'ex. web, event-triage, cron',
|
||||
'llmr.filter.from': 'De',
|
||||
'llmr.filter.to': 'À',
|
||||
'llmr.filter.apply': 'Appliquer',
|
||||
@@ -184,12 +184,12 @@ export default {
|
||||
|
||||
'config.prop.ui_locale.name': 'Langue',
|
||||
'config.prop.ui_locale.desc': 'Langue d\'interface par défaut pour l\'ensemble de l\'instance. Chaque utilisateur peut la modifier dans son profil.',
|
||||
'config.prop.tic__enabled.name': 'Activé',
|
||||
'config.prop.tic__enabled.desc': 'Activer ou désactiver l\'agent TIC pour toute l\'instance. Lorsqu\'il est désactivé, aucun événement n\'est traité pour personne.',
|
||||
'config.prop.tic__security_group.name': 'Groupe de sécurité',
|
||||
'config.prop.tic__security_group.desc': 'Groupe de permissions d\'outils appliqué à chaque exécution de TIC. Il est revérifié selon le rôle de chaque utilisateur : si son rôle n\'autorise pas ce groupe, l\'exécution utilise le groupe par défaut de son rôle. Laissez vide pour toujours utiliser celui du rôle.',
|
||||
'config.prop.tic__interval_minutes.name': 'Intervalle de vérification (minutes)',
|
||||
'config.prop.tic__interval_minutes.desc': 'Temps écoulé entre deux passages pour chaque utilisateur, en minutes. Compté par personne depuis son propre dernier passage. Laissez vide pour utiliser la valeur de config.yml (tic.interval_secs).',
|
||||
'config.prop.event_triage__enabled.name': 'Activé',
|
||||
'config.prop.event_triage__enabled.desc': 'Activer ou désactiver le tri des événements pour toute l\'instance. Lorsqu\'il est désactivé, aucun événement n\'est traité pour personne.',
|
||||
'config.prop.event_triage__security_group.name': 'Groupe de sécurité',
|
||||
'config.prop.event_triage__security_group.desc': 'Groupe de permissions d\'outils appliqué à chaque exécution du tri des événements. Il est revérifié selon le rôle de chaque utilisateur : si son rôle n\'autorise pas ce groupe, l\'exécution utilise le groupe par défaut de son rôle. Laissez vide pour toujours utiliser celui du rôle.',
|
||||
'config.prop.event_triage__interval_minutes.name': 'Intervalle de vérification (minutes)',
|
||||
'config.prop.event_triage__interval_minutes.desc': 'Temps écoulé entre deux passages pour chaque utilisateur, en minutes. Compté par personne depuis son propre dernier passage. Laissez vide pour utiliser la valeur de config.yml (event_triage.interval_secs).',
|
||||
|
||||
'config.prop.memory_lint_private__enabled.name': 'Activé',
|
||||
'config.prop.memory_lint_private__enabled.desc': 'Activer l\'entretien de la mémoire privée pour toute l\'instance. Lorsqu\'il est désactivé, la mémoire privée de personne n\'est vérifiée.',
|
||||
@@ -336,7 +336,7 @@ export default {
|
||||
|
||||
// ── Settings (config page extras) ──────────────────────────────────────────
|
||||
'config.debug': 'Mode débogage',
|
||||
'config.debug.desc': 'Afficher les pages développeur (requêtes LLM, sessions TIC) dans la barre latérale.',
|
||||
'config.debug.desc': 'Afficher les pages développeur (requêtes LLM) dans la barre latérale.',
|
||||
|
||||
// ── First-run setup ────────────────────────────────────────────────────────
|
||||
'setup.title': 'Bienvenue sur Skald',
|
||||
@@ -961,8 +961,8 @@ export default {
|
||||
'system_agents.tab.all': 'Tous',
|
||||
'system_agents.settings': 'Paramètres',
|
||||
|
||||
'system_agents.agent.tic.name': 'TIC',
|
||||
'system_agents.agent.tic.desc': 'Lit les événements reçus par vos connecteurs — nouveaux e-mails, changements d\'agenda, messages entrants — décide lesquels méritent votre attention et ne vous signale que ceux-là. Il s\'exécute pour une personne à la fois et ne lit que les événements de cette personne.',
|
||||
'system_agents.agent.event-triage.name': 'Tri des événements',
|
||||
'system_agents.agent.event-triage.desc': 'Lit les événements reçus par vos connecteurs — nouveaux e-mails, changements d\'agenda, messages entrants — décide lesquels méritent votre attention et ne vous signale que ceux-là. Il s\'exécute pour une personne à la fois et ne lit que les événements de cette personne.',
|
||||
'system_agents.agent.memory-lint-private.name': 'Entretien de la mémoire privée',
|
||||
'system_agents.agent.memory-lint-private.desc': 'Une vérification périodique de votre mémoire. Elle recherche les faits dont la date est passée, les questions qui vous ont été posées et restées sans réponse, les notes que l\'index a perdues de vue et les doublons à fusionner — puis vous dit ce qu\'elle a trouvé. Elle ne modifie jamais vos notes.',
|
||||
'system_agents.agent.memory-lint-shared.name': 'Entretien de la mémoire partagée',
|
||||
|
||||
+10
-10
@@ -125,7 +125,7 @@ export default {
|
||||
'llmr.filter.agent_id': 'ID Agente',
|
||||
'llmr.filter.agent_ph': 'es. assistant',
|
||||
'llmr.filter.source': 'Sorgente',
|
||||
'llmr.filter.source_ph': 'es. web, tic, cron',
|
||||
'llmr.filter.source_ph': 'es. web, event-triage, cron',
|
||||
'llmr.filter.from': 'Da',
|
||||
'llmr.filter.to': 'A',
|
||||
'llmr.filter.apply': 'Applica',
|
||||
@@ -208,12 +208,12 @@ export default {
|
||||
|
||||
'config.prop.ui_locale.name': 'Lingua',
|
||||
'config.prop.ui_locale.desc': 'Lingua predefinita per l\'intera istanza. Ogni utente può modificarla nel proprio profilo.',
|
||||
'config.prop.tic__enabled.name': 'Attivo',
|
||||
'config.prop.tic__enabled.desc': 'Attiva o disattiva l\'agente TIC per l\'intera istanza. Quando è disattivato, non viene elaborato alcun evento per nessuno.',
|
||||
'config.prop.tic__security_group.name': 'Gruppo di sicurezza',
|
||||
'config.prop.tic__security_group.desc': 'Gruppo di permessi strumenti applicato a ogni esecuzione di TIC. Viene riverificato sul ruolo di ciascun utente: se il ruolo non consente questo gruppo, l\'esecuzione usa il gruppo predefinito del ruolo. Lascia vuoto per usare sempre il predefinito del ruolo.',
|
||||
'config.prop.tic__interval_minutes.name': 'Intervallo di controllo (minuti)',
|
||||
'config.prop.tic__interval_minutes.desc': 'Quanto tempo passa tra un giro e l\'altro per ciascun utente, in minuti. Conteggiato per persona a partire dal suo ultimo giro. Lascia vuoto per usare il valore da config.yml (tic.interval_secs).',
|
||||
'config.prop.event_triage__enabled.name': 'Attivo',
|
||||
'config.prop.event_triage__enabled.desc': 'Attiva o disattiva il triage eventi per l\'intera istanza. Quando è disattivato, non viene elaborato alcun evento per nessuno.',
|
||||
'config.prop.event_triage__security_group.name': 'Gruppo di sicurezza',
|
||||
'config.prop.event_triage__security_group.desc': 'Gruppo di permessi strumenti applicato a ogni esecuzione del triage eventi. Viene riverificato sul ruolo di ciascun utente: se il ruolo non consente questo gruppo, l\'esecuzione usa il gruppo predefinito del ruolo. Lascia vuoto per usare sempre il predefinito del ruolo.',
|
||||
'config.prop.event_triage__interval_minutes.name': 'Intervallo di controllo (minuti)',
|
||||
'config.prop.event_triage__interval_minutes.desc': 'Quanto tempo passa tra un giro e l\'altro per ciascun utente, in minuti. Conteggiato per persona a partire dal suo ultimo giro. Lascia vuoto per usare il valore da config.yml (event_triage.interval_secs).',
|
||||
|
||||
'config.prop.memory_lint_private__enabled.name': 'Attivo',
|
||||
'config.prop.memory_lint_private__enabled.desc': 'Attiva la manutenzione della memoria privata per l\'intera istanza. Quando è disattivata, la memoria privata di nessuno viene controllata.',
|
||||
@@ -336,7 +336,7 @@ export default {
|
||||
|
||||
// ── Impostazioni (extra pagina config) ─────────────────────────────────────
|
||||
'config.debug': 'Modalità debug',
|
||||
'config.debug.desc': 'Mostra le pagine per sviluppatori (richieste LLM, sessioni TIC) nella barra laterale.',
|
||||
'config.debug.desc': 'Mostra le pagine per sviluppatori (richieste LLM) nella barra laterale.',
|
||||
|
||||
// ── Configurazione iniziale ────────────────────────────────────────────────
|
||||
'setup.title': 'Benvenuto in Skald',
|
||||
@@ -961,8 +961,8 @@ export default {
|
||||
'system_agents.tab.all': 'Tutti',
|
||||
'system_agents.settings': 'Impostazioni',
|
||||
|
||||
'system_agents.agent.tic.name': 'TIC',
|
||||
'system_agents.agent.tic.desc': 'Legge gli eventi che arrivano dai tuoi connettori — nuove email, modifiche al calendario, messaggi in arrivo — decide quali meritano la tua attenzione e ti avvisa solo di quelli. Viene eseguito per una persona alla volta e legge solo gli eventi di quella persona.',
|
||||
'system_agents.agent.event-triage.name': 'Triage eventi',
|
||||
'system_agents.agent.event-triage.desc': 'Legge gli eventi che arrivano dai tuoi connettori — nuove email, modifiche al calendario, messaggi in arrivo — decide quali meritano la tua attenzione e ti avvisa solo di quelli. Viene eseguito per una persona alla volta e legge solo gli eventi di quella persona.',
|
||||
'system_agents.agent.memory-lint-private.name': 'Manutenzione memoria privata',
|
||||
'system_agents.agent.memory-lint-private.desc': 'Un controllo periodico della tua memoria. Cerca fatti la cui data è ormai passata, domande che ti sono state poste e a cui non hai mai risposto, note di cui l\'indice ha perso traccia e duplicati da unire — poi ti dice cosa ha trovato. Non modifica mai le tue note.',
|
||||
'system_agents.agent.memory-lint-shared.name': 'Manutenzione memoria condivisa',
|
||||
|
||||
Reference in New Issue
Block a user