feat: conversation review — a nightly report on a supervised person's conversations
Nightly Build / build (push) Successful in 7m40s
Nightly Build / build (push) Successful in 7m40s
The first AgentScope::PerSubject system agent, and the reason that scope exists. Once a night, for each person with a supervision edge, it reads every message that person and the assistant exchanged since the previous review — across all their conversations — and writes one report for the people who supervise them. Schema (all registry except reports): - supervision(subject_user_id, supervisor_user_id): the generic §0.1 edge, answering both 'whom does a background agent look at' and 'who may read what it produced', with real FKs so deleting a user cascades both ways - system_agent_coverage(agent_id, subject_user_id, covered_through): the per-subject watermark that makes 'everything since last time' a window — neither system_agent_runs (history for humans) nor system_agent_state (advances before the work), and advanced only on a completed pass so a crash re-covers instead of skipping - reports (owner schema, the second two-homes table after memory_docs): instance rows land in system.db, deliberately cleartext to the box owner, who is the intended reader (§2); the subject cannot see them structurally The pass reads the subject's database inside a supervisor's runtime, so the ephemeral session and run row land in the watcher's file; iteration is over subjects, so two parents watching one child get one review; and the subject need not be logged in when their space is unencrypted — via the new UserManager::open_unencrypted, which refuses an encrypted user outright (no key to be had) and never registers the pool as unlocked. The agent declares the new AgentMeta flag allow_tools: false, so its turn gets an empty tool registry — nothing for a prompt injection in the transcript to call — and produces its report as its final assistant message, read back from chat_history and parsed (NOTHING_TO_REPORT sentinel, no row on quiet days). chat_history::conversation_window is the transcript query; its four filters (non-ephemeral, depth 0, non-synthetic, non-empty) each guard a specific way the review would otherwise be wrong, and tool calls are absent by construction. Cadence is Run at (hour) rather than Interval — 4am local by default — with due-ness answered inside has_work against the coverage watermark, so a machine off for three days covers the whole stretch in one pass. Reports announce ReportCreated on the system bus (no subscriber yet). run_ephemeral_turn gains a per-pass system_substitutions map, which the review uses to hand the model the subject's profile under __SUBJECT_PROFILE__ — the system-context substitutions describe the session owner, the wrong person here. docs/system-agents.md gains the conversation review section; CLAUDE.md documents the scope, the tables and the tool-less design.
This commit is contained in:
@@ -24,12 +24,15 @@ pub mod oauth_providers;
|
||||
pub mod plugins;
|
||||
pub mod plugin_access;
|
||||
pub mod plugin_user_configs;
|
||||
pub mod reports;
|
||||
pub mod role_capabilities;
|
||||
pub mod roles;
|
||||
pub mod scheduled_jobs;
|
||||
pub mod scratchpad;
|
||||
pub mod shared_folders;
|
||||
pub mod sources;
|
||||
pub mod supervision;
|
||||
pub mod system_agent_coverage;
|
||||
pub mod system_agent_runs;
|
||||
pub mod system_agent_state;
|
||||
pub mod tool_permission_groups;
|
||||
@@ -679,6 +682,76 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// The supervision edge (§0.1): one person's activity may be read on another's
|
||||
// behalf. **A generic edge between two users, and nothing more** — the domain
|
||||
// reading of it ("a parent watches a child") lives in the seed data and the UI
|
||||
// copy, never here, so a pivot to a mentor watching a trainee, or a care worker
|
||||
// watching a resident, renames nothing.
|
||||
//
|
||||
// It answers two questions with one table, which is why it is an edge and not a
|
||||
// per-agent list of subjects: *whom does a background agent look at* (the
|
||||
// distinct subjects) and *who may read what it produced* (the supervisors of a
|
||||
// given subject). The second is what the reports' `audience = 'supervisors'`
|
||||
// resolves against.
|
||||
//
|
||||
// Both FKs are registry→registry (same file), so they are allowed and the
|
||||
// cascade is real: deleting a user takes their edges with them, in both
|
||||
// directions.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS supervision (
|
||||
subject_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
supervisor_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (subject_user_id, supervisor_user_id)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_supervision_supervisor
|
||||
ON supervision(supervisor_user_id)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// How far a background agent has *processed* a subject — the watermark that
|
||||
// makes "everything since last time" a well-defined window.
|
||||
//
|
||||
// **Not `system_agent_runs`, and not `system_agent_state`**, though it sits
|
||||
// between them and the difference is the whole reason it exists:
|
||||
//
|
||||
// `system_agent_state` when an agent last *attempted* a pass. Advances on
|
||||
// every tick, including idle ones, and is marked
|
||||
// *before* the work — so it can never delimit the
|
||||
// window the work is about.
|
||||
// this table how far the work actually got. Advances **only on a
|
||||
// completed pass**, so a crash mid-pass re-covers the
|
||||
// same stretch next time. For a review, a duplicate
|
||||
// report is a nuisance and a skipped window is a blind
|
||||
// spot: at-least-once is the only acceptable direction.
|
||||
//
|
||||
// The obvious alternative — deriving the watermark from the last report's
|
||||
// `period_end` — fails on a single ordinary action: a supervisor deleting an
|
||||
// old report would move the scheduler's window back and regenerate the very
|
||||
// report they discarded. A document is the user's to delete; scheduler state is
|
||||
// not, so they cannot be the same row.
|
||||
//
|
||||
// Registry, not owner, for a reason specific to how these passes run: the pass
|
||||
// executes inside *some* supervisor's runtime, and which one depends on who is
|
||||
// logged in tonight. A watermark in the acting user's file would give one
|
||||
// subject two unsynchronised clocks.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS system_agent_coverage (
|
||||
agent_id TEXT NOT NULL,
|
||||
subject_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
covered_through TEXT NOT NULL, -- UTC 'YYYY-MM-DD HH:MM:SS'
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (agent_id, subject_user_id)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1128,6 +1201,68 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
|
||||
sqlx::query(trigger).execute(pool).await?;
|
||||
}
|
||||
|
||||
// Reports — the documents system agents write about a stretch of time
|
||||
// (blueprint §13). Like `memory_docs` above, one owner schema backs **two
|
||||
// homes**, and which file a row lands in *is* its audience:
|
||||
//
|
||||
// `{userid}.db` a report that belongs to that user, about that user —
|
||||
// their weekly "what you struggled to get done" digest.
|
||||
// Behind SQLCipher: nobody else can read it, admin included.
|
||||
// `system.db` an instance report, written about someone *for* the
|
||||
// people who supervise them. Cleartext to whoever owns the
|
||||
// box, deliberately — they are the intended reader (§2).
|
||||
//
|
||||
// That split is why nothing here filters by reader: a report's subject can
|
||||
// never see an instance report about them, because their tools only ever
|
||||
// touch their own pool. The invisibility is structural, not a rule someone
|
||||
// has to remember in each query.
|
||||
//
|
||||
// The producer's scope decides the file with no extra concept:
|
||||
// `AgentScope::PerUser` writes into `ctx.pool`, `AgentScope::Instance` into
|
||||
// the registry pool the agent already holds.
|
||||
//
|
||||
// `subject_user_id` / `producer_user_id` / `run_id` are **bare** columns, not
|
||||
// foreign keys: `users` lives in the registry (an owner→registry FK would
|
||||
// fail every INSERT), and for an instance row the `system_agent_runs` trace
|
||||
// sits in the *acting* user's file. They are snapshots, and a deleted user
|
||||
// leaves them dangling on purpose — the report outlives the account.
|
||||
//
|
||||
// `kind` is free-form producer-declared text, never an enum (§0.1). Rows are
|
||||
// immutable once written: the only UPDATE is the read acknowledgement.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL, -- producer-declared type, not an enum
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT, -- one line: lists + notification text
|
||||
body TEXT NOT NULL DEFAULT '', -- markdown
|
||||
severity TEXT NOT NULL DEFAULT 'info', -- 'info' | 'notice' | 'alert'
|
||||
subject_user_id TEXT, -- who it is about (bare snapshot)
|
||||
audience TEXT NOT NULL DEFAULT 'owner', -- 'owner' | 'admins' | 'supervisors'
|
||||
period_start TEXT, -- the window it covers
|
||||
period_end TEXT,
|
||||
produced_by TEXT NOT NULL, -- system agent id
|
||||
producer_user_id TEXT, -- whose runtime ran the pass
|
||||
run_id INTEGER, -- system_agent_runs.id (bare snapshot)
|
||||
metadata TEXT, -- JSON counters; never contents
|
||||
read_at TEXT, -- shared acknowledgement: first reader wins
|
||||
read_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Listing is always newest-first, optionally narrowed to one subject. `kind`
|
||||
// is deliberately unindexed: a handful of rows a week means a scan is
|
||||
// cheaper than the index it would need.
|
||||
for index in [
|
||||
"CREATE INDEX IF NOT EXISTS idx_reports_created ON reports(created_at DESC, id DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_reports_subject ON reports(subject_user_id, created_at DESC)",
|
||||
] {
|
||||
sqlx::query(index).execute(pool).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1186,6 +1321,11 @@ mod tests {
|
||||
one("INSERT INTO llm_request_payloads (request_id, request_json) VALUES ('r1', '{}')").await.unwrap();
|
||||
// Fires the AFTER INSERT trigger into the external-content FTS5 table.
|
||||
one("INSERT INTO memory_docs (path, content) VALUES ('notes/x.md', 'hello world')").await.unwrap();
|
||||
// Bare `subject_user_id` / `producer_user_id` (registry `users`) and a
|
||||
// bare `run_id` that points at no row in this file — an FK on any of the
|
||||
// three would die right here.
|
||||
one("INSERT INTO reports (kind, title, body, produced_by, subject_user_id, producer_user_id, run_id)
|
||||
VALUES ('conversation-review', 't', 'b', 'agent', 'u-absent', 'u-also-absent', 4242)").await.unwrap();
|
||||
|
||||
// ...and the FTS index actually answers a MATCH.
|
||||
let (hits,): (i64,) = sqlx::query_as(
|
||||
|
||||
Reference in New Issue
Block a user