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
@@ -0,0 +1,709 @@
//! The conversation review — a nightly read of what a supervised person and the
//! assistant said to each other, turned into one report.
//!
//! The first [`AgentScope::PerSubject`] agent, and the reason that scope exists.
//! Everything it reads belongs to the subject; everything it leaves behind — the
//! ephemeral session, the run row — belongs to the supervisor whose runtime it
//! borrowed; and the one thing that crosses between them is the report, in
//! `system.db`, where the people entitled to it can read it.
//!
//! ## One report per person, never one per conversation
//!
//! A day's activity is spread over however many sessions somebody happened to
//! open, and reviewing them one at a time would produce a stack of fragments
//! nobody can act on — the useful signal is often *across* conversations (the
//! same subject raised twice, in two places, hours apart). So a pass takes the
//! whole window at once: every session, in one transcript, one turn, one report.
//!
//! ## What the model is shown, and what it is not
//!
//! Only what was **said** — see [`chat_history::conversation_window`] for the
//! four exclusions and why each one exists. Tool calls and their results are not
//! filtered out so much as absent by construction: they live in a different
//! table. The consequence is real and the prompt says so plainly, because a model
//! shown a gap will otherwise narrate over it — a web search the assistant ran is
//! invisible, query included.
//!
//! ## Why the report is the turn's own answer
//!
//! There is no `save_report` tool. The final assistant message *is* the body, and
//! this module writes the row. A tool would have to be whitelisted past the
//! approval gate — an unattended pass auto-denies anything gated — and would add
//! a way for the pass to silently produce nothing at all. The cost of not having
//! one is that the model cannot set a severity; see [`REPORT_SEVERITY`].
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use chrono::{DateTime, Duration, Local, TimeZone, Utc};
use sqlx::SqlitePool;
use tracing::warn;
use core_api::system_bus::{SystemEvent, SystemEventBus};
use core_api::{ConfigProperty, ConfigSet, PropertyType};
use crate::config_store::GlobalConfigManager;
use crate::db::chat_history::{self, TranscriptLine};
use crate::db::{reports, system_agent_coverage};
use super::{
AgentOutcome, AgentRunCtx, AgentScope, SystemAgent, configured_run_context,
enabled_from_config, enabled_property, run_ephemeral_turn, security_group_property,
};
pub const CONVERSATION_REVIEW_AGENT: &str = "conversation-review";
/// The chat `source` a pass runs under, and the `kind` of the reports it writes.
/// Same string on purpose: one name to grep for when tracing where a report came
/// from.
const REVIEW_SOURCE: &str = "conversation-review";
pub const ENABLED_KEY: &str = "conversation_review.enabled";
pub const SECURITY_GROUP_KEY: &str = "conversation_review.security_group";
pub const RUN_AT_HOUR_KEY: &str = "conversation_review.run_at_hour";
/// 4am: late enough that the day is over, early enough that the report is waiting
/// when somebody wakes up.
const DEFAULT_RUN_AT_HOUR: u32 = 4;
const DAY_SECS: u64 = 24 * 60 * 60;
/// How far back the very first pass for a subject looks. Their history may go
/// back months; opening with a report on all of it would be expensive, mostly
/// stale, and unlike every report after it.
const FIRST_WINDOW_HOURS: i64 = 24;
/// Caps on what one turn is shown. A day of chatter is normally far below these;
/// they exist so that an outlier costs a truncated report rather than a refused
/// request.
const MAX_MESSAGES: i64 = 600;
const MAX_MESSAGE_CHARS: usize = 2_000;
/// What the agent answers when the window holds nothing worth writing up.
///
/// A sentinel rather than a judgement call in the parser: "did the model mean
/// there was nothing?" is not a question worth asking of prose, and getting it
/// wrong in the lenient direction files an empty report every single night.
pub const NOTHING_TO_REPORT: &str = "NOTHING_TO_REPORT";
/// Every report this agent files carries the same severity.
///
/// Not laziness — a consequence of the report being the turn's own answer: there
/// is no structured channel for the model to grade its own finding on, and
/// inferring one from prose would be a guess presented as a fact. `notice` is the
/// honest middle: this was worth writing down, and a human decides how much it
/// matters. A grade would come from giving the agent a structured hand-off, which
/// is a change to make deliberately rather than by parsing.
const REPORT_SEVERITY: &str = reports::SEVERITY_NOTICE;
pub fn config_set() -> ConfigSet {
ConfigSet {
name: "Conversation review".into(),
description: "A daily read of the conversations of the people someone supervises. For one \
subject at a time it reads everything they and the assistant said to each \
other since the previous review, and writes a single report about the whole \
stretch — not one per conversation. The report is stored for the people who \
supervise that person; the subject does not see it. Tool calls are not \
included, so what a connector did on their behalf is outside what it can \
see. Nobody is reviewed unless a supervision link says so."
.into(),
properties: vec![
enabled_property(
ENABLED_KEY,
"Enable the conversation review for the whole instance. When disabled, nobody is \
reviewed, whatever the supervision links say.",
),
security_group_property(SECURITY_GROUP_KEY),
ConfigProperty {
key: RUN_AT_HOUR_KEY.into(),
name: "Run at (hour)".into(),
description: "Hour of the day, 023 in this machine's local time, after which \
the review runs. It runs once per day per person; if the machine \
was off at that hour, the next start catches up and the report \
covers the whole stretch that was missed."
.into(),
property_type: PropertyType::Int,
default_value: Some(DEFAULT_RUN_AT_HOUR.to_string()),
},
],
owner: Some(CONVERSATION_REVIEW_AGENT.into()),
}
}
pub struct ConversationReviewAgent {
config_store: Arc<GlobalConfigManager>,
/// `system.db` — the supervision edges, the coverage watermarks and the
/// reports all live here.
registry_pool: Arc<SqlitePool>,
system_bus: Arc<SystemEventBus>,
}
impl ConversationReviewAgent {
pub fn new(
config_store: Arc<GlobalConfigManager>,
registry_pool: Arc<SqlitePool>,
system_bus: Arc<SystemEventBus>,
) -> Arc<Self> {
Arc::new(Self { config_store, registry_pool, system_bus })
}
async fn run_at_hour(&self) -> u32 {
match self.config_store.get(RUN_AT_HOUR_KEY).await {
Ok(Some(v)) => v.trim().parse::<u32>().ok().filter(|h| *h <= 23).unwrap_or(DEFAULT_RUN_AT_HOUR),
_ => DEFAULT_RUN_AT_HOUR,
}
}
/// The window this pass would cover, or `None` when the subject is not due.
///
/// Both halves of scheduling live here, together, because they are one
/// question: *is there a stretch of time we have not looked at yet, ending
/// after today's hour?* Splitting them across `is_due` and `has_work` was what
/// made the first sketch wrong — the attempt marker moves before the work, so
/// by the time the agent ran, the window it was meant to cover had already
/// been marked as covered.
async fn window_for(
&self,
subject: &str,
now: DateTime<Utc>,
) -> Result<Option<(String, String)>> {
let covered = system_agent_coverage::covered_through(
&self.registry_pool,
CONVERSATION_REVIEW_AGENT,
subject,
)
.await?;
let start = covered.unwrap_or_else(|| {
system_agent_coverage::stamp(now - Duration::hours(FIRST_WINDOW_HOURS))
});
// Due when the covered stretch stops before the most recent occurrence of
// the configured hour. That single comparison is what makes the schedule
// survive downtime: a machine off for three days simply finds a watermark
// three days old, and covers all of it in one pass.
let boundary = system_agent_coverage::stamp(
most_recent_occurrence(&Local, self.run_at_hour().await, now),
);
if start >= boundary {
return Ok(None);
}
Ok(Some((start, system_agent_coverage::stamp(now))))
}
}
#[async_trait]
impl SystemAgent for ConversationReviewAgent {
fn id(&self) -> &'static str { CONVERSATION_REVIEW_AGENT }
fn scope(&self) -> AgentScope { AgentScope::PerSubject }
fn config_set(&self) -> ConfigSet { config_set() }
fn interval_key(&self) -> &'static str { RUN_AT_HOUR_KEY }
async fn is_enabled(&self) -> bool {
enabled_from_config(&self.config_store, ENABLED_KEY).await
}
/// Daily. Only feeds the scheduler's sleep computation — the actual cadence is
/// the hour-of-day check in [`Self::window_for`], and the tick is clamped well
/// below a day regardless.
async fn interval_secs(&self) -> u64 { DAY_SECS }
async fn has_work(&self, ctx: &AgentRunCtx<'_>) -> Result<bool> {
let Some(subject) = ctx.subject else {
warn!(agent = CONVERSATION_REVIEW_AGENT, "no subject on the run context; skipping");
return Ok(false);
};
let Some((since, until)) = self.window_for(subject.user_id, Utc::now()).await? else {
return Ok(false);
};
// Due, but the stretch may still be empty — somebody who did not open the
// assistant yesterday should collect no run row and no report.
let n = chat_history::conversation_window_count(subject.pool, &since, &until).await?;
Ok(n > 0)
}
async fn run(&self, ctx: &AgentRunCtx<'_>) -> Result<AgentOutcome> {
let subject = ctx.subject.ok_or_else(|| anyhow::anyhow!("no subject on the run context"))?;
let now = Utc::now();
let Some((since, until)) = self.window_for(subject.user_id, now).await? else {
// `has_work` said yes a moment ago; only a concurrent pass could land
// here, and the scheduler is single-instance. Treat it as a no-op
// rather than an error.
return Ok(AgentOutcome {
session_id: None,
stats: serde_json::json!({ "skipped": "not due" }),
});
};
let total = chat_history::conversation_window_count(subject.pool, &since, &until).await?;
let lines = chat_history::conversation_window(subject.pool, &since, &until, MAX_MESSAGES).await?;
let dropped = (total - lines.len() as i64).max(0) as usize;
let sessions = distinct_sessions(&lines);
let transcript = build_transcript(subject.username, &lines, dropped);
let prompt = build_prompt(subject.username, &since, &until, &transcript);
// The security group is the **acting** user's business: the pass runs in
// the supervisor's runtime, on their permissions, and reconciling against
// the subject's role would hand a restricted account's tool set to the
// person reviewing it.
let rc = configured_run_context(
&self.config_store,
&self.registry_pool,
SECURITY_GROUP_KEY,
ctx.user_id,
)
.await;
// Who the report is about, in the system prompt rather than the trigger
// message: an age, a name and a sex change what counts as worth reporting
// — the same sentence reads differently from a nine-year-old and from a
// seventeen-year-old — so the model must have it before it reads a word of
// the transcript. It cannot come from `__USER_PROFILE__`, which resolves
// the session owner, and the session belongs to the supervisor.
let mut substitutions = std::collections::HashMap::new();
substitutions.insert(
"SUBJECT_PROFILE".to_string(),
crate::loop_adapters::system::render_user_profile_section(
&self.registry_pool,
subject.user_id,
)
.await
.unwrap_or_else(|e| {
warn!(user = %subject.user_id, error = %e, "conversation-review: no subject profile");
"unknown".to_string()
}),
);
let (session_id, _) = run_ephemeral_turn(
CONVERSATION_REVIEW_AGENT,
REVIEW_SOURCE,
&prompt,
rc.as_ref(),
"Conversation review",
substitutions,
ctx,
)
.await?;
// The turn ran in the supervisor's runtime, so its answer is in their file.
let answer = chat_history::last_assistant_for_session(ctx.pool, session_id)
.await?
.unwrap_or_default();
let report_id = match parse_report(&answer) {
None => None,
Some(ParsedReport { title, summary, body }) => {
let title = title.unwrap_or_else(|| {
format!("Conversation review — {}{}", subject.username, &until[..10])
});
let id = reports::create(&self.registry_pool, &reports::NewReport {
kind: CONVERSATION_REVIEW_AGENT,
title: &title,
summary: summary.as_deref(),
body: &body,
severity: REPORT_SEVERITY,
subject_user_id: Some(subject.user_id),
audience: reports::AUDIENCE_SUPERVISORS,
period_start: Some(&since),
period_end: Some(&until),
produced_by: CONVERSATION_REVIEW_AGENT,
producer_user_id: Some(ctx.user_id),
run_id: ctx.run_id,
metadata: Some(&serde_json::json!({
"messages_examined": lines.len(),
"messages_dropped": dropped,
"sessions": sessions,
}).to_string()),
})
.await?;
// Announced, not delivered. Who should hear about a new report —
// the supervisors, a badge, a future digest — is not this agent's
// business, and wiring it here would make every new recipient a
// change to the reviewer.
let _ = self.system_bus.send(SystemEvent::ReportCreated {
report_id: id,
kind: CONVERSATION_REVIEW_AGENT.to_string(),
subject_user_id: Some(subject.user_id.to_string()),
});
Some(id)
}
};
// Only now, and only here: the watermark moves because the stretch was
// actually looked at. A pass that failed above never reaches this line, so
// the same window is offered again next time — a duplicate report being a
// nuisance and a missed window being a blind spot.
system_agent_coverage::advance(
&self.registry_pool,
CONVERSATION_REVIEW_AGENT,
subject.user_id,
&until,
)
.await?;
Ok(AgentOutcome {
session_id: Some(session_id),
stats: serde_json::json!({
"subject": subject.user_id,
"window_start": since,
"window_end": until,
"messages_examined": lines.len(),
"messages_dropped": dropped,
"sessions": sessions,
"report_id": report_id,
}),
})
}
}
// ── Transcript ────────────────────────────────────────────────────────────────
fn distinct_sessions(lines: &[TranscriptLine]) -> usize {
let mut seen: Vec<i64> = Vec::new();
for l in lines {
if !seen.contains(&l.session_id) {
seen.push(l.session_id);
}
}
seen.len()
}
/// Render the window as a readable transcript, grouped by conversation.
///
/// **Prose, not JSON**, and the choice is about what the model does with it: a
/// dialogue read as a dialogue is what these models are best at, JSON spends
/// tokens on syntax, and — the deciding argument — nothing machine-readable comes
/// back this way. The structured artefact is the report, on the other end.
///
/// Grouped by session rather than strictly chronological because the question
/// "what was this conversation about" is answered by contiguity; sessions are
/// ordered by when each one was first spoken in, so the day still reads forwards.
fn build_transcript(subject_label: &str, lines: &[TranscriptLine], dropped: usize) -> String {
if lines.is_empty() {
return "(no messages in this window)".to_string();
}
let mut out = String::new();
if dropped > 0 {
out.push_str(&format!(
"> Note: {dropped} older message(s) in this window were left out to fit. What follows \
is the most recent part of the stretch.\n\n",
));
}
let mut order: Vec<i64> = Vec::new();
for l in lines {
if !order.contains(&l.session_id) {
order.push(l.session_id);
}
}
for session_id in order {
let head = lines.iter().find(|l| l.session_id == session_id).expect("session came from lines");
let title = head.session_title.as_deref().filter(|t| !t.is_empty()).unwrap_or("untitled");
out.push_str(&format!(
"\n## Conversation {session_id}\"{title}\" (via {}, assistant: {})\n\n",
head.source, head.agent_id,
));
for line in lines.iter().filter(|l| l.session_id == session_id) {
let who = if line.role == "user" { subject_label } else { "assistant" };
out.push_str(&format!(
"[{}] {who}: {}\n\n",
line.created_at,
truncate(&line.content, MAX_MESSAGE_CHARS),
));
}
}
out
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let kept: String = s.chars().take(max).collect();
format!("{kept}… [truncated]")
}
/// The trigger message. Thin on purpose: *how* to review is the agent's
/// `AGENT.md`, and a second copy of it here would be one to keep in step.
fn build_prompt(subject_label: &str, since: &str, until: &str, transcript: &str) -> String {
format!(
"[REVIEW] Scheduled review of {subject_label}'s conversations\n\
Window: {since}{until} (UTC)\n\n\
Below is everything {subject_label} and the assistant said to each other in that window, \
grouped by conversation. Tool calls and their results are not included.\n\n\
Read it, and write the report. If there is nothing worth reporting, answer with \
`{NOTHING_TO_REPORT}` and nothing else.\n\n\
---\n\n{transcript}"
)
}
// ── The answer ────────────────────────────────────────────────────────────────
struct ParsedReport {
title: Option<String>,
summary: Option<String>,
body: String,
}
/// Turn the turn's answer into a report, or `None` for "nothing to report".
///
/// Deliberately shallow, and it only works because the report's shape is fixed
/// by the prompt: a leading heading, then one summary paragraph, then sections.
/// So the heading becomes the title — a document's first heading *is* its title —
/// and the opening paragraph becomes the summary, whole rather than by its first
/// line, because a paragraph written to be the summary is exactly what
/// `reports.summary` is for. Anything more would be parsing prose, which is how a
/// report ends up filed under half a sentence.
fn parse_report(answer: &str) -> Option<ParsedReport> {
let answer = answer.trim();
if answer.is_empty() {
return None;
}
// Lenient on the sentinel: a model that adds a sentence after it still means
// the same thing, and the alternative is filing that sentence as a report.
if answer.lines().next().is_some_and(|l| l.trim().starts_with(NOTHING_TO_REPORT)) {
return None;
}
let mut lines = answer.lines().peekable();
let mut title = None;
if let Some(first) = lines.peek() {
if let Some(heading) = first.trim().strip_prefix("# ") {
let heading = heading.trim();
if !heading.is_empty() {
title = Some(heading.to_string());
lines.next();
}
}
}
let body: String = lines.collect::<Vec<_>>().join("\n").trim().to_string();
let body = if body.is_empty() { answer.to_string() } else { body };
Some(ParsedReport { title, summary: leading_paragraph(&body), body })
}
/// The first paragraph of prose: everything from the first ordinary line up to
/// the blank line that ends it, flattened onto one line.
///
/// Headings and rules are skipped on the way in, so a body that opens with a
/// `## Summary` heading yields the paragraph under it rather than the word
/// "Summary".
fn leading_paragraph(body: &str) -> Option<String> {
let mut para: Vec<&str> = Vec::new();
for line in body.lines().map(str::trim) {
let skippable = line.is_empty() || line.starts_with('#') || line.starts_with("---");
match (skippable, para.is_empty()) {
(true, true) => continue, // still looking for the paragraph
(true, false) => break, // it just ended
(false, _) => para.push(line),
}
}
(!para.is_empty()).then(|| truncate(&para.join(" "), 400))
}
/// The most recent moment at which the local clock read `hour:00`, at or before
/// `now`.
///
/// Generic over the timezone so it can be tested without depending on where the
/// machine is. Resolution goes through the timezone rather than arithmetic on
/// UTC, so an hour that a DST jump skipped is handled instead of silently landing
/// an hour out: today's candidate and yesterday's are both resolved, and the
/// latest one that exists and has already passed wins.
fn most_recent_occurrence<Tz: TimeZone>(tz: &Tz, hour: u32, now: DateTime<Utc>) -> DateTime<Utc> {
let local_now = now.with_timezone(tz);
let today = local_now.date_naive();
let mut best: Option<DateTime<Utc>> = None;
for back in 0..=1 {
let Some(day) = today.checked_sub_days(chrono::Days::new(back)) else { continue };
let Some(naive) = day.and_hms_opt(hour.min(23), 0, 0) else { continue };
// `.earliest()` is `None` inside a DST gap — that wall-clock time did not
// happen on that day, so there is nothing to pick.
let Some(candidate) = tz.from_local_datetime(&naive).earliest() else { continue };
let candidate = candidate.with_timezone(&Utc);
if candidate <= now && best.is_none_or(|b| candidate > b) {
best = Some(candidate);
}
}
// Neither candidate resolved (a DST gap on both days, which no real zone does):
// fall back to a full day back, which is never later than the true answer.
best.unwrap_or(now - Duration::days(1))
}
#[cfg(test)]
mod tests {
use super::*;
fn line(session_id: i64, title: &str, role: &str, content: &str, at: &str) -> TranscriptLine {
TranscriptLine {
session_id,
session_title: Some(title.to_string()),
source: "web".into(),
agent_id: "kid".into(),
role: role.into(),
content: content.into(),
created_at: at.into(),
}
}
#[test]
fn the_transcript_groups_by_conversation_and_names_the_person() {
let lines = vec![
line(12, "Homework", "user", "help me with history", "2026-07-28 21:04:00"),
line(12, "Homework", "assistant", "sure", "2026-07-28 21:04:30"),
line(15, "", "user", "are you awake", "2026-07-29 02:31:00"),
line(12, "Homework", "user", "one more thing", "2026-07-29 07:00:00"),
];
let t = build_transcript("luca", &lines, 0);
// Two conversations, in the order they were first spoken in.
assert_eq!(t.matches("## Conversation").count(), 2);
assert!(t.find("Conversation 12").unwrap() < t.find("Conversation 15").unwrap());
// A session with no title still reads as something.
assert!(t.contains("\"untitled\""));
// The person is named; the machine is not named after them.
assert!(t.contains("luca: help me with history"));
assert!(t.contains("assistant: sure"));
// Later messages of an earlier conversation stay with it.
let block12 = &t[t.find("Conversation 12").unwrap()..t.find("Conversation 15").unwrap()];
assert!(block12.contains("one more thing"));
// Timestamps survive: "at 2am" is half the finding.
assert!(t.contains("[2026-07-29 02:31:00]"));
}
#[test]
fn dropped_messages_are_declared_not_hidden() {
let lines = vec![line(1, "t", "user", "hi", "2026-07-28 21:04:00")];
let t = build_transcript("luca", &lines, 42);
assert!(t.contains("42 older message(s)"), "a truncated window must say so");
assert!(build_transcript("luca", &[], 0).contains("no messages"));
}
#[test]
fn long_messages_are_truncated_with_a_marker() {
let long = "x".repeat(MAX_MESSAGE_CHARS + 500);
let t = build_transcript("luca", &[line(1, "t", "user", &long, "2026-07-28 21:04:00")], 0);
assert!(t.contains("[truncated]"));
assert!(t.len() < long.len() + 500);
}
#[test]
fn the_sentinel_files_nothing() {
assert!(parse_report(NOTHING_TO_REPORT).is_none());
assert!(parse_report(" NOTHING_TO_REPORT \n").is_none());
assert!(parse_report("NOTHING_TO_REPORT — quiet day").is_none(),
"a model that explains itself still means nothing to report");
assert!(parse_report("").is_none());
assert!(parse_report(" \n ").is_none());
}
/// The shape the prompt asks for: heading, summary paragraph, then sections.
#[test]
fn the_report_shape_maps_onto_the_row() {
let answer = "# Late-night messages\n\
\n\
Three conversations after midnight, all about the same worry.\n\
Nothing was said that needs acting on tonight.\n\
\n\
## What happened\n\
\n\
Detail follows.\n\
\n\
## Worth knowing\n\
\n\
More detail.";
let parsed = parse_report(answer).expect("this is a report");
assert_eq!(parsed.title.as_deref(), Some("Late-night messages"));
assert!(!parsed.body.starts_with('#'), "the title is not repeated in the body");
assert!(parsed.body.contains("## What happened"), "the sections stay in the body");
// The whole opening paragraph, on one line — not just its first sentence.
assert_eq!(
parsed.summary.as_deref(),
Some("Three conversations after midnight, all about the same worry. \
Nothing was said that needs acting on tonight."),
);
}
#[test]
fn a_summary_under_its_own_heading_is_still_found() {
let parsed = parse_report("# Title\n\n## Summary\n\nThe paragraph that matters.\n\n## Detail\n\nx")
.expect("this is a report");
assert_eq!(parsed.summary.as_deref(), Some("The paragraph that matters."),
"a heading must not be mistaken for the paragraph it introduces");
}
#[test]
fn a_report_without_a_heading_keeps_its_whole_body() {
let parsed = parse_report("Nothing structural, just prose.\n\nMore prose.")
.expect("this is a report");
assert!(parsed.title.is_none(), "the caller supplies a title when the model gives none");
assert!(parsed.body.starts_with("Nothing structural"));
assert_eq!(parsed.summary.as_deref(), Some("Nothing structural, just prose."));
// A `#` that is not a heading (no space) is body, not a title.
let parsed = parse_report("#hashtag not a heading").expect("this is a report");
assert!(parsed.title.is_none());
assert_eq!(parsed.body, "#hashtag not a heading");
}
#[test]
fn the_daily_boundary_is_the_most_recent_occurrence_of_the_hour() {
let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
// Later the same day: today's 04:00.
assert_eq!(
most_recent_occurrence(&Utc, 4, at("2026-07-29T09:00:00Z")),
at("2026-07-29T04:00:00Z"),
);
// Before it: yesterday's.
assert_eq!(
most_recent_occurrence(&Utc, 4, at("2026-07-29T02:00:00Z")),
at("2026-07-28T04:00:00Z"),
);
// Exactly on the hour counts as passed, so the pass fires at 04:00 sharp.
assert_eq!(
most_recent_occurrence(&Utc, 4, at("2026-07-29T04:00:00Z")),
at("2026-07-29T04:00:00Z"),
);
// Midnight is an hour like any other.
assert_eq!(
most_recent_occurrence(&Utc, 0, at("2026-07-29T00:30:00Z")),
at("2026-07-29T00:00:00Z"),
);
// A machine that was off for days still gets one boundary, not none: what
// makes the missed window recoverable is that the watermark is older than
// this, not that the boundary moved.
assert_eq!(
most_recent_occurrence(&Utc, 4, at("2026-08-02T05:00:00Z")),
at("2026-08-02T04:00:00Z"),
);
}
#[test]
fn the_prompt_states_the_window_and_the_tool_blind_spot() {
let p = build_prompt("luca", "2026-07-28 04:00:00", "2026-07-29 04:00:00", "");
assert!(p.contains("luca"));
assert!(p.contains("2026-07-28 04:00:00"));
assert!(p.contains("Tool calls and their results are not included"));
assert!(p.contains(NOTHING_TO_REPORT));
}
}
@@ -186,8 +186,10 @@ impl MemoryLintAgent {
/// `classify_memory` gives the fs-tools).
fn store_pool<'a>(&'a self, ctx: &'a AgentRunCtx<'_>) -> &'a SqlitePool {
match self.scope {
AgentScope::PerUser => ctx.pool,
AgentScope::Instance => &self.registry_pool,
// A lint is never per-subject; anything but the instance store is the
// caller's own.
_ => ctx.pool,
}
}
}
@@ -240,6 +242,7 @@ impl SystemAgent for MemoryLintAgent {
&build_prompt(self.root, notes.len()),
rc.as_ref(),
"Memory lint",
std::collections::HashMap::new(),
ctx,
)
.await?;
+90 -20
View File
@@ -29,6 +29,7 @@
//! `start` for that agent — safe only because the scheduler is sequential and
//! single-instance.
pub mod conversation_review;
pub mod memory_lint;
use std::collections::HashMap;
@@ -68,6 +69,26 @@ pub enum AgentScope {
/// working unchanged, at the price of needing an admin who has logged in
/// since the last restart.
Instance,
/// One pass per **supervised subject**, run inside a supervisor's runtime.
///
/// For work done *about* one person *for* another (`crate::db::supervision`).
/// The two halves come apart here in a way neither other variant needs: the
/// data read is the subject's, while the runtime doing the reading — the
/// ephemeral session, the LLM turn, the run log — belongs to a supervisor.
/// Which is the point: everything the pass leaves behind lands in the
/// watcher's file, not the watched one's.
///
/// Two consequences worth knowing before writing one:
///
/// - **Due-ness is per subject and does not go through [`is_due`].** That
/// helper keys scheduler state by agent within one file, which would
/// collapse every subject sharing a supervisor into a single clock. These
/// agents answer scheduling themselves inside [`SystemAgent::has_work`],
/// against `crate::db::system_agent_coverage`.
/// - **The subject need not be logged in**, as long as their database is not
/// encrypted (`UserManager::open_unencrypted`). An encrypted subject is
/// readable only while their own session is live — no key, no pass.
PerSubject,
}
/// What one pass did, for the run log.
@@ -78,13 +99,37 @@ pub struct AgentOutcome {
pub stats: serde_json::Value,
}
/// Who a [`AgentScope::PerSubject`] pass is *about*, when that is not the person
/// whose runtime it is running in.
#[derive(Clone, Copy)]
pub struct AgentSubject<'a> {
pub user_id: &'a str,
pub username: &'a str,
/// The subject's database, opened for reading. Not necessarily an unlocked
/// session's pool — see `UserManager::open_unencrypted`.
pub pool: &'a SqlitePool,
}
/// One user's runtime, unpacked from their `UserContext` by the scheduler.
///
/// The four leading fields always describe the runtime **the pass executes in**,
/// which for every scope but [`AgentScope::PerSubject`] is also whom the pass is
/// about. Keeping that meaning fixed is what lets `run_ephemeral_turn` stay
/// unaware of the distinction: it always writes into the acting runtime.
#[derive(Clone, Copy)]
pub struct AgentRunCtx<'a> {
pub user_id: &'a str,
/// The user's own (unlocked) database.
pub pool: &'a SqlitePool,
pub sessions: &'a Arc<ChatSessionManager>,
pub hub: &'a Arc<ChatHub>,
/// Set only for [`AgentScope::PerSubject`]: the person being looked at.
pub subject: Option<AgentSubject<'a>>,
/// The `system_agent_runs` row this pass is being recorded under, filled in by
/// [`run_and_record`] before it calls [`SystemAgent::run`]. Lets an agent that
/// produces a durable artefact point back at the run that made it — across
/// files, where a foreign key cannot reach.
pub run_id: Option<i64>,
}
#[async_trait]
@@ -98,8 +143,11 @@ pub trait SystemAgent: Send + Sync {
/// `owned_by(self.id())`, or it lands on the general Config page instead.
fn config_set(&self) -> ConfigSet;
/// The config key holding the interval. The scheduler watches it so a change
/// in the UI reschedules without a restart.
/// The config key that governs this agent's cadence. The scheduler watches it
/// so a change in the UI reschedules without a restart.
///
/// Usually the interval itself; for an agent that runs at a fixed time of day
/// it is the hour, which is the key that moves the next pass just the same.
fn interval_key(&self) -> &'static str;
/// Instance-wide on/off switch, re-read every pass.
@@ -128,6 +176,7 @@ pub fn registry(
event_triage_config: crate::config::EventTriageConfig,
config_store: Arc<GlobalConfigManager>,
registry_pool: Arc<SqlitePool>,
system_bus: Arc<core_api::system_bus::SystemEventBus>,
) -> Vec<Arc<dyn SystemAgent>> {
vec![
crate::event_triage::EventTriageManager::new(
@@ -139,7 +188,11 @@ pub fn registry(
Arc::clone(&config_store),
Arc::clone(&registry_pool),
),
memory_lint::MemoryLintAgent::shared(config_store, registry_pool),
memory_lint::MemoryLintAgent::shared(
Arc::clone(&config_store),
Arc::clone(&registry_pool),
),
conversation_review::ConversationReviewAgent::new(config_store, registry_pool, system_bus),
]
}
@@ -154,6 +207,7 @@ pub fn config_sets() -> Vec<ConfigSet> {
crate::event_triage::config_set(),
memory_lint::private_config_set(),
memory_lint::shared_config_set(),
conversation_review::config_set(),
]
}
@@ -187,9 +241,17 @@ pub async fn run_and_record(
) -> Result<Option<AgentOutcome>> {
// Step 1 — the attempt counts even if there is nothing to do, or an idle
// agent is asked again on every single tick.
if let Err(e) = system_agent_state::mark_attempt(ctx.pool, agent.id()).await {
warn!(agent = agent.id(), user = %ctx.user_id, error = %e,
"system-agents: could not record the attempt");
//
// Skipped for a per-subject pass, and not as an optimisation: that state is
// keyed by agent inside one file, so several subjects sharing a supervisor
// would overwrite each other's row and the first subject of the evening would
// silently stand for all of them. Those agents keep their own per-subject
// watermark (`db::system_agent_coverage`) and are gated by `has_work` alone.
if agent.scope() != AgentScope::PerSubject {
if let Err(e) = system_agent_state::mark_attempt(ctx.pool, agent.id()).await {
warn!(agent = agent.id(), user = %ctx.user_id, error = %e,
"system-agents: could not record the attempt");
}
}
// Step 2 — nothing to do leaves no trace.
@@ -197,9 +259,11 @@ pub async fn run_and_record(
return Ok(None);
}
// Step 3 — open the row, then work.
// Step 3 — open the row, then work. The pass runs with the row's id in hand,
// so whatever it produces can name the run that produced it.
let run_id = system_agent_runs::start(ctx.pool, agent.id()).await?;
let started = Instant::now();
let ctx = &AgentRunCtx { run_id: Some(run_id), ..*ctx };
match agent.run(ctx).await {
Ok(outcome) => {
@@ -249,12 +313,19 @@ pub async fn run_and_record(
/// `notify()` as the only way out. Sharing it is what keeps a new agent from
/// re-deriving the two subtleties below.
pub async fn run_ephemeral_turn(
agent_id: &str,
source: &str,
prompt: &str,
run_context: Option<&RunContext>,
notify_label: &str,
ctx: &AgentRunCtx<'_>,
agent_id: &str,
source: &str,
prompt: &str,
run_context: Option<&RunContext>,
notify_label: &str,
// `<!-- KEY -->` placeholders in the agent's `AGENT.md`, resolved for this
// pass. The two the system context resolves by itself (`__USER_PROFILE__`,
// `__SHARED_FOLDERS__`) describe the *session owner*, which for a pass about
// somebody else is the wrong person — so an agent that needs the subject's
// details supplies them here, under its own key, rather than being handed a
// profile that silently means the runtime's owner.
substitutions: HashMap<String, String>,
ctx: &AgentRunCtx<'_>,
) -> Result<(i64, usize)> {
// A fresh ephemeral session per pass. ChatHub is bypassed on purpose: a
// system agent is not a user-facing source and must not take over the
@@ -285,7 +356,7 @@ pub async fn run_ephemeral_turn(
None,
None,
vec![notify],
HashMap::new(),
substitutions,
tx,
true,
None,
@@ -436,13 +507,11 @@ mod tests {
// Constructing the agents touches no table — the pool is only a handle
// they hold on to — so an empty database is enough here.
let pool = Arc::new(SqlitePool::connect("sqlite::memory:").await.unwrap());
let config = Arc::new(GlobalConfigManager::new(
Arc::clone(&pool),
Arc::new(core_api::system_bus::SystemEventBus::new()),
));
let bus = Arc::new(core_api::system_bus::SystemEventBus::new());
let config = Arc::new(GlobalConfigManager::new(Arc::clone(&pool), Arc::clone(&bus)));
let scheduled: Vec<&str> =
registry(Default::default(), config, pool).iter().map(|a| a.id()).collect();
let scheduled: Vec<&str> = registry(Default::default(), config, pool, bus)
.iter().map(|a| a.id()).collect();
let configured: Vec<String> = config_sets()
.into_iter()
.map(|s| s.owner.expect("a system agent's config set must be owned by it"))
@@ -462,6 +531,7 @@ mod tests {
(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),
(conversation_review::config_set(), conversation_review::RUN_AT_HOUR_KEY),
] {
assert!(
set.properties.iter().any(|p| p.key == key),