system agents: generalise the scheduler and add the two memory lints
Nightly Build / build (push) Successful in 7m14s
Nightly Build / build (push) Successful in 7m14s
Memory is kept as a maintained wiki, and a wiki nobody prunes rots. This adds
the scheduled maintenance pass, and generalises the machinery TIC had grown so
that a background agent is a trait impl rather than a loop of its own.
Two lint agents, not one. The private pass runs per user over `user-memory/`
and reports to them; the shared pass runs once over `shared-memory/`, where the
interesting defect is different — a note failing the table rule, i.e. private
business written where every member can read it. It names the note and the
category without repeating the content, since restating it spreads the very
thing being flagged. Both share `agents/common/memory-lint.md`.
Both are read-only, and that is 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 is auto-denied. Read-only is the only design that works
here, not merely the safe one.
One scheduler 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` picks only how often to look,
and whether an agent runs for a user is `is_due` against persisted state.
Due-ness moves out of the run log into a new owner table, `system_agent_state`.
The two answer different questions: the run log skips idle ticks so it stays a
history rather than a heartbeat, while scheduling needs every attempt. Reading
due-ness off the log would re-run an idle agent on 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.
The shared store belongs to nobody, so `AgentScope::Instance` runs that pass as
the first unlocked admin. An ownerless run would write its trace into system.db,
which the runs endpoint shows to nobody by design, and its notify() would have
no recipient; attributing it to a user keeps the whole per-user surface working
unchanged.
Settings move to where the run log is. `ConfigSet` gains `owner`, so placement
is data on the set rather than a page that knows set names; the System agents
page grows one tab per agent holding its description, its settings (admin only)
and its runs — "why did this do nothing last night?" is half a schedule
question and half a log question. The form is shared with the Config page, and
writes still go through PUT /api/config/{key}.
Fixes an authorization gap found on the way: neither /api/config handler took
the caller into account, so any authenticated session could read and write
instance-wide config. The sidebar hiding the page is presentation, not access
control. Both are now admin-gated.
This commit is contained in:
+48
-20
@@ -2,53 +2,81 @@
|
||||
|
||||
A **system agent** is an assistant that runs in the background on someone's behalf, without being asked. Nobody starts it and nobody is waiting for its answer: it wakes up on a schedule, looks at something, and gets in touch only if there is a reason to.
|
||||
|
||||
Today there is exactly one: **TIC**.
|
||||
There are three:
|
||||
|
||||
## What TIC does
|
||||
| Agent | What it watches | How often |
|
||||
| --- | --- | --- |
|
||||
| **TIC** | 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 |
|
||||
|
||||
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 the user.
|
||||
They share three habits worth stating once, because they explain most of what people ask:
|
||||
|
||||
- **They only read and report.** None of them changes anything. If something needs doing, they say so and the person decides.
|
||||
- **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
|
||||
|
||||
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.
|
||||
|
||||
An empty run is a correct run. TIC is not supposed to find something every time.
|
||||
TIC never replies to a message or moves a calendar event. If an event needs an action, it says so in the notification.
|
||||
|
||||
TIC only **reads and reports**. It never replies to a message, moves a calendar event, or changes anything — if an event needs an action, it says so in the notification and the user decides.
|
||||
## The two memory lints
|
||||
|
||||
## It runs per person, and only sees one person's things
|
||||
Memory is kept as a small wiki rather than a pile of notes (see [memory.md](memory.md)): notes cross-reference each other, an `index.md` says where things are, and a `log.md` records every change. That works while somebody maintains it — and quietly rots when nobody does. Contradictions stay unresolved, dates go by, notes lose the last line that pointed at them, the same fact ends up written twice in two places that slowly disagree.
|
||||
|
||||
This is the part worth being precise about, because people ask.
|
||||
The lints are the scheduled maintenance pass. Once a week they re-read a store and report what has drifted:
|
||||
|
||||
TIC runs separately for each user. When it runs for someone, it reads only the events from **that person's own connectors**, consults only **their private memory**, and delivers notifications only to **them**. Two people on the same instance never see each other's events through TIC, and the admin does not see anyone's.
|
||||
- facts whose date has passed — a renewal now due, a plan that already happened
|
||||
- questions somebody was asked to confirm and never did
|
||||
- notes nothing links to any more, and index lines pointing at notes that no longer exist
|
||||
- two notes saying the same thing, especially when they have started to disagree
|
||||
|
||||
The same applies to the record of what it did: each run is written into that user's own encrypted database, so the run history on the **System agents** page is personal — every user, admin included, sees their own and nobody else's.
|
||||
**They never fix anything.** They report, and a person decides. This is deliberate: an automated pass reading a store several people built over months is guessing, and a wrong guess destroys something somebody meant. Reading a report costs thirty seconds; a wrong edit can lose a fact nobody notices is gone until they need it.
|
||||
|
||||
There are two of them because the two stores are not the same job.
|
||||
|
||||
**Private memory lint** runs for each person over their own notes, and reports to them alone.
|
||||
|
||||
**Shared memory lint** runs once over the group's shared store, and looks for one extra thing that only exists there: **a note that fails the table rule** — one person's private business written somewhere every member can read. The rule is that something belongs in shared memory only if you would say it out loud with every member in the room; health, school results, money, worries and one member's opinion of another do not. When it finds one it says *which note* and *what kind of problem*, without repeating the sensitive content — restating it in a notification would spread it further, which is exactly the harm being flagged.
|
||||
|
||||
The shared store belongs to nobody in particular, so that pass runs **as the admin** and its report goes to them. That is about who can act on it, not about privacy: everything in shared memory is already readable by every member.
|
||||
|
||||
## Why a run can be missing
|
||||
|
||||
Users are handled one at a time, and a user is **skipped** if they have not logged in since the server last restarted.
|
||||
|
||||
This is not a fault, it is how the encryption works: a person's data is unreadable until they log in and their password unlocks it. Until that happens there is nothing for TIC to read and nowhere for it to write. Their events are not lost — they keep accumulating, and the first run after they log in picks up everything waiting.
|
||||
This is not a fault, it is how the encryption works: a person's data is unreadable until they log in and their password unlocks it. Until that happens there is nothing to read and nowhere to write. Nothing is lost — events keep accumulating, and the first run after they log in picks up everything waiting.
|
||||
|
||||
So if someone asks "why didn't it tell me about that email from this morning?", the first thing to check is whether they had logged in at the time.
|
||||
So if someone asks "why didn't it tell me about that email from this morning?", the first thing to check is whether they had logged in at the time. The same applies to the shared memory lint: it needs an admin who has logged in since the restart.
|
||||
|
||||
Schedules are counted **per person from their own last run**, and they survive a restart — so a weekly pass stays weekly even on a machine that gets rebooted every few days.
|
||||
|
||||
## The System agents page
|
||||
|
||||
Sidebar → **System agents**. One row per run, newest first:
|
||||
Sidebar → **System agents**. There is one tab per agent, plus **All**. A tab holds that agent's description, its settings (admin only), and its run history — because "why did this do nothing last night?" is usually half a settings question and half a log question.
|
||||
|
||||
Each row is one run, newest first:
|
||||
|
||||
- **Agent** — which system agent ran (`tic`).
|
||||
- **Started** and **Duration**.
|
||||
- **Status** — completed, failed, or still running.
|
||||
- **Result** — how many events it looked at and how many notifications it produced, or the error if it failed.
|
||||
- **Result** — the agent's own counters (events looked at, notes read, notifications sent), or the error if it failed.
|
||||
|
||||
Clicking a row opens the conversation the run happened in, for anyone who wants to see the reasoning.
|
||||
|
||||
A run appears **only when there were events to look at**. Long gaps between rows mean quiet connectors, not a broken agent — if there is nothing new, TIC does no work and records nothing.
|
||||
A run appears **only when there was something to look at**. Long gaps mean quiet connectors or an untouched memory store, not a broken agent.
|
||||
|
||||
**The run history is personal.** Each run is written into that user's own encrypted database, so every user — the admin included — sees their own runs and nobody else's. There is no instance-wide view.
|
||||
|
||||
## What the admin can change
|
||||
|
||||
On the admin's Config page (see [settings.md](settings.md)), under **TIC Agent**:
|
||||
Each agent's tab carries the same three settings, visible only to an admin:
|
||||
|
||||
- **Enabled** — turns TIC on or off for the whole instance, for everyone.
|
||||
- **Check Interval (minutes)** — how often a pass over all users starts.
|
||||
- **Security Group** — which tools TIC may use during a run. This 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.
|
||||
- **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.
|
||||
- **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 TIC is enabled, it runs for everyone who has logged in.
|
||||
There is no per-user on/off switch: if an agent is enabled, it runs for everyone who has logged in.
|
||||
|
||||
Reference in New Issue
Block a user