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

TIC said nothing about what the agent does, and named the wrong thing: the
tick belongs to the scheduler, which is generic and lives outside it. The
agent's only decision is whether an incoming event deserves an interruption
— it sorts, it never acts — so it is now event-triage, matching the
functional naming of the two memory lints.

- agents/tic/ -> agents/event-triage/, module tic/ -> event_triage/,
  TicManager -> EventTriageManager, TicConfig -> EventTriageConfig
- agent id and chat source: "tic" -> "event-triage"
- config keys: tic.* -> event_triage.*, and the config.yml section tic: ->
  event_triage: (greenfield: previously set values fall back to defaults)
- i18n en/it/fr: Event triage / Triage eventi / Tri des evenements; dropped
  the stale "TIC sessions" mention from the debug-pages description
- docs/system-agents.md, docs/index.md, docs/settings.md, CLAUDE.md, SKALD.md
This commit is contained in:
2026-07-28 21:59:37 +01:00
parent 0b793d56ae
commit 046f060fcd
50 changed files with 251 additions and 240 deletions
+9 -9
View File
@@ -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