feat: conversation review — a nightly report on a supervised person's conversations
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:
2026-08-02 20:30:27 +01:00
parent e6818408cb
commit 4f10528368
19 changed files with 2394 additions and 35 deletions
+104 -2
View File
@@ -214,6 +214,7 @@ pub(super) fn spawn_system_agents(skald: &Arc<super::Skald>, event_triage_config
event_triage_config,
Arc::clone(&skald.rt.config),
Arc::clone(&skald.rt.db),
Arc::clone(&skald.rt.system_bus),
);
// Interval keys, so a change in the UI cuts the current wait short for
@@ -293,8 +294,9 @@ async fn agents_pass(skald: &Arc<super::Skald>, agents: &[Arc<dyn SystemAgent>])
continue;
}
match agent.scope() {
AgentScope::PerUser => per_user_pass(skald, agent.as_ref()).await,
AgentScope::Instance => instance_pass(skald, agent.as_ref()).await,
AgentScope::PerUser => per_user_pass(skald, agent.as_ref()).await,
AgentScope::Instance => instance_pass(skald, agent.as_ref()).await,
AgentScope::PerSubject => subject_pass(skald, agent.as_ref()).await,
}
}
}
@@ -352,6 +354,104 @@ async fn instance_pass(skald: &Arc<super::Skald>, agent: &dyn SystemAgent) {
);
}
/// Run `agent` once per supervised subject, each pass inside a supervisor's
/// runtime.
///
/// Three properties, and each one is a decision rather than a detail:
///
/// - **The iteration is over subjects, not supervisors.** Two parents watching
/// the same child must produce one review of that child, not two. Whichever of
/// them is available lends their runtime; the report is filed against the
/// subject and every supervisor reads the same row.
/// - **The subject does not need to be logged in.** `open_unencrypted` opens
/// their file directly when it has no key, which is what makes a 4am pass
/// possible at all — nobody is at a keyboard then. An encrypted subject has no
/// such door and is reviewed only while their own session is live.
/// - **Due-ness is not checked here.** Unlike the other two passes, it is per
/// subject and lives in `system_agent_coverage`; the agent answers it inside
/// `has_work`. See [`AgentScope::PerSubject`].
async fn subject_pass(skald: &Arc<super::Skald>, agent: &dyn SystemAgent) {
let subjects = match crate::db::supervision::subjects(&skald.rt.db).await {
Ok(s) => s,
Err(e) => {
warn!(agent = agent.id(), error = %e,
"system-agents: cannot read the supervision edges, skipping this pass");
return;
}
};
for subject_id in subjects {
if skald.rt.shutdown_token.is_cancelled() {
return;
}
let subject = match skald.users().get(&subject_id).await {
Ok(Some(u)) if u.active => u,
Ok(_) => continue, // deleted or deactivated: nothing to review
Err(e) => {
warn!(agent = agent.id(), user = %subject_id, error = %e,
"system-agents: cannot read the subject, skipping them");
continue;
}
};
// Their database, without asking them to be present — as long as it has
// no key. A refusal here is the honest case, not a failure: an encrypted
// person cannot be read while they are away, by anyone.
let subject_pool = match skald.users().open_unencrypted(&subject_id).await {
Ok(p) => p,
Err(e) => {
info!(agent = agent.id(), user = %subject_id, reason = %e,
"system-agents: skipped — the subject's database cannot be read right now");
continue;
}
};
// Somebody entitled to the result has to lend a runtime for the work to
// happen in. First unlocked supervisor wins, in the edge's stable order.
let Some(host) = first_unlocked_supervisor(skald, &subject_id).await else {
info!(agent = agent.id(), user = %subject_id,
"system-agents: skipped — none of this person's supervisors has logged in \
since the last restart, so the pass has no runtime to run in");
continue;
};
let Some(ctx) = skald.user_context(&host).await else {
warn!(agent = agent.id(), supervisor = %host,
"system-agents: skipped — could not resolve the supervisor's runtime");
continue;
};
let run_ctx = AgentRunCtx {
user_id: &host,
pool: &ctx.pool,
sessions: &ctx.sessions,
hub: &ctx.chat_hub,
subject: Some(system_agents::AgentSubject {
user_id: &subject_id,
username: &subject.username,
pool: &subject_pool,
}),
run_id: None,
};
// One subject's failure must not end the pass for everyone after them.
if let Err(e) = system_agents::run_and_record(agent, &run_ctx).await {
warn!(agent = agent.id(), user = %subject_id, error = %e,
"system-agents: pass failed");
}
}
}
/// The first supervisor of `subject` whose runtime is live, in the edge's stable
/// order — so the same one is picked pass after pass rather than alternating.
async fn first_unlocked_supervisor(skald: &Arc<super::Skald>, subject: &str) -> Option<String> {
let supervisors = crate::db::supervision::supervisors_of(&skald.rt.db, subject)
.await
.unwrap_or_default();
supervisors.into_iter().find(|s| skald.users().is_unlocked(s))
}
/// The common tail: skip a locked user, resolve their runtime, check due-ness,
/// run and record.
async fn run_one(
@@ -390,6 +490,8 @@ async fn run_one(
pool: &ctx.pool,
sessions: &ctx.sessions,
hub: &ctx.chat_hub,
subject: None,
run_id: None,
};
// One user's failure must not end the pass for everyone after them.