System reminders
System reminders are typed transcript injections for a running agent session. They let Harn add ambient context at turn boundaries, such as token-pressure warnings, idle-session nudges, truncated-tool-output notices, post-compact recaps, host file-change alerts, and runtime corrective feedback, without a producer having to place a turn itself. Producers work in lifecycle terms — body, tags, TTL, authority — and the runtime decides when a directive is delivered and where it lands. Embedders can opt into durable delivery; see append-only placement.
A reminder is always a structured system_reminder transcript event. It
has a stable lifecycle shape, can be deduped, can expire after a finite
number of turns, can choose whether it survives compaction, and can state
how far it should propagate to sub-agents, and declares contract, corrective,
or advisory authority. Every provider receives the same model-facing context
envelope in one trailing user slot. Lifecycle events are observable through
EventLog records and host-facing ACP session/update metadata.
What and why#
Use reminders when the current agent should keep going, but needs fresh ambient context before its next model turn. Triggers are the different primitive: they start or schedule work. Reminders modify an already running session.
Good reminder candidates are transient facts that should be visible now but not preserved forever:
- context-window pressure
- a file changed while the agent was idle
- a tool result was truncated before the model saw it
- compaction replaced older turns with a recap
- workspace memory produced a temporary caution for the current task
Do not use reminders for durable user requirements, audit records, long-lived memory, or background task dispatch. Put those in the normal user/system prompt path, receipts, memory, or triggers.
ReminderSpec shape#
Every producer ultimately normalizes to the same ReminderSpec lifecycle
fields:
| Field | Type | Default | Meaning |
|---|---|---|---|
body | string | required | Reminder text rendered into the next model turn. Must be non-empty for strict producer APIs. |
tags | list of strings | [] | Tags for querying, clearing, diagnostics, and provider conventions. |
dedupe_key | string or nil | nil | Newer pending reminders with the same key replace older pending reminders. |
ttl_turns | positive int or nil | nil | Finite reminders expire after this many post-turn lifecycle passes. nil persists until cleared or compacted away. |
preserve_on_compact | bool | false | When true, compaction copies the reminder event into the compacted transcript. |
propagate | "all", "session", or "none" | "session" | Controls sub-agent inheritance. |
authority | "contract", "corrective", or "advisory" | "contract" | Conflict precedence inside the model-facing envelope. Contract overrides corrective; corrective overrides advisory. |
role_hint | "system", "developer", "user_block", or "ephemeral_cache" | "system" | Retained for transcript and replay compatibility. It no longer selects a model-facing slot. |
source | "stdlib_provider", "hook", "bridge", "in_pipeline", or "inherited" | producer-specific | Origin marker used by audit and propagation. |
fired_at_turn | int | producer-specific | Turn index when the reminder was created. Pipelines without a turn counter use 0. |
id | string | generated | Stable reminder id for audit, replay, and clearing. |
originating_agent_id | string or nil | nil | Set when a reminder is inherited from a parent session. |
The transcript event uses the standard event envelope plus a typed
reminder payload. metadata mirrors that payload so generic transcript
observers can inspect the same fields without special-casing the
reminder slot.
{
"id": "0190abcd-...",
"kind": "system_reminder",
"role": "developer",
"visibility": "public",
"text": "Approaching context window cap.",
"blocks": [
{
"type": "text",
"text": "Approaching context window cap.",
"visibility": "public"
}
],
"reminder": {
"id": "0190abcd-...",
"tags": ["token_pressure"],
"dedupe_key": "token_pressure",
"ttl_turns": 3,
"preserve_on_compact": true,
"propagate": "session",
"role_hint": "developer",
"authority": "contract",
"source": "stdlib_provider",
"body": "Approaching context window cap.",
"fired_at_turn": 4
},
"metadata": {
"id": "0190abcd-...",
"tags": ["token_pressure"],
"dedupe_key": "token_pressure",
"ttl_turns": 3,
"preserve_on_compact": true,
"propagate": "session",
"role_hint": "developer",
"authority": "contract",
"source": "stdlib_provider",
"body": "Approaching context window cap.",
"fired_at_turn": 4
}
}
transcript_reminder_event({...}) is the lenient event builder for
pipeline code that already has a normalized reminder-like dict. The
stricter producer APIs below validate required fields and enum values.
Producing reminders#
From a pipeline#
Use transcript.inject_reminder(transcript, options) to append a pending
reminder event to a transcript. It is a pure transform: the input
transcript is unchanged and the returned transcript contains the new
event.
const injected = transcript.inject_reminder(transcript(), {
body: "Approaching context window cap.",
tags: ["token_pressure"],
dedupe_key: "token_pressure",
ttl_turns: 3,
preserve_on_compact: true,
propagate: "session",
role_hint: "developer",
authority: "contract",
})
const next_transcript = injected.transcript
harness.stdio.log(injected.reminder_id)
harness.stdio.log(injected.deduped_count)
The returned transcript has one additional system_reminder event and
the same durable message list as the input transcript. body is
required and must be non-empty. Optional tags, dedupe_key,
ttl_turns, preserve_on_compact, propagate, role_hint, and authority fields
are validated; unknown option keys fail fast.
When dedupe_key is set, injection first removes any pending reminder
events with the same key from the input transcript. The new reminder is
then appended, and deduped_count reports how many older reminders were
replaced. When an active EventLog is installed, replacement also emits a
transcript.reminder.deduped record on
transcript.reminder.lifecycle; every successful injection emits
transcript.reminder.injected.
Use transcript.clear_reminders(transcript, selector) to remove
pending reminders:
const cleared = transcript.clear_reminders(next_transcript, {
tag: "token_pressure",
})
harness.stdio.log(cleared.removed_count)
Selectors support id, tag, and dedupe_key. At least one selector
is required. If multiple selectors are present, a reminder must match all
of them to be removed. This builtin is also a pure transform and returns
{transcript, removed_count}.
From a hook#
Tool, persona, step, and session hooks can return reminder effects from
events that support transcript mutation. A hook may return
{reminder: {...}, then?: ...}, a bare reminder spec, or a session-level
effect list.
fn install_truncation_hook(harness: Harness) {
harness.tools.register_hook({
pattern: "read_file",
post: { ctx ->
if ctx.result.truncated {
return {
reminder: {
body: "The file read was truncated; inspect the"
+ " specific range before editing.",
tags: ["truncation"],
dedupe_key: "read_file:truncated",
ttl_turns: 1,
propagate: "none",
},
}
}
},
})
}
Worker lifecycle events are observational and reject reminder effects with
HARN-RMD-008. Move mutating reminders to a session, tool, step, or
persona hook.
From a provider#
agent_loop(harness, ...) evaluates canonical reminder providers by default.
Register a custom provider with
harness.agent.register_reminder_provider({id, subscribes_to, evaluate}). The evaluate closure receives
{event, session, session_id, payload, options, config} and may return
nil, a bare reminder spec, a {reminder: {...}} effect, or a list of
effects.
fn install_reminder_provider(harness: Harness) {
harness.agent.register_reminder_provider({
id: "workspace_guard",
subscribes_to: ["session_idle"],
evaluate: { ctx ->
if ctx.config?.enabled == false {
return nil
}
return {
reminder: {
body: "Workspace may have changed while idle; re-check"
+ " touched files.",
tags: ["workspace"],
dedupe_key: "workspace:idled",
ttl_turns: 1,
propagate: "session",
},
}
},
})
agent_loop(harness, task, system, {
reminders: {
config: {
workspace_guard: {enabled: true},
},
},
})
}
harness.agent.clear_reminder_providers() removes user-defined providers,
mainly for
tests. Canonical stdlib providers remain available through agent_loop
unless disabled with reminder options.
From a host bridge#
Bridge and ACP-style hosts inject ambient context with the session/remind
JSON-RPC notification. This is distinct from session/inject, which is always
user-role input.
{
"jsonrpc": "2.0",
"method": "session/remind",
"params": {
"body": "The workspace changed while you were idle; re-read src/lib.rs before editing.",
"tags": ["workspace"],
"dedupe_key": "workspace-change",
"ttl_turns": 2,
"role_hint": "system",
"mode": "interrupt_immediate",
"_meta": {
"harn": {
"origin": "file-watcher"
}
}
}
}
mode accepts the same delivery values as queued user messages:
interrupt_immediate, finish_step, and audit_only. The mode
determines which agent-loop seam drains the reminder:
| Mode | Drained at | Behavior |
|---|---|---|
interrupt_immediate | iteration_start, pre_tool_dispatch, post_tool_dispatch, iteration_end, daemon_idle_pre, daemon_idle_post | When it arrives at pre_tool_dispatch (between LLM return and tool dispatch), the pending tool batch is skipped and the reminder lands in the next iteration's prompt. At every other seam, the reminder is appended to the transcript and visible on the next prompt build. |
finish_step | iteration_start, post_tool_dispatch, iteration_end | Drained at iteration boundaries only. Never short-circuits a tool batch. The model sees this reminder on the next prompt build — including the final iteration when the loop is about to terminate. |
audit_only | loop_exit | Drained once the loop terminates, before finalize. The reminder lands in the transcript audit, but the model never sees it — no further LLM call runs after loop_exit. Use this for record-keeping and replay; use finish_step if the model must react to the reminder before the agent terminates (harn#2212). |
See steering seams for the full seam catalog.
Host-specific extension fields belong under _meta. Invalid reminder
payloads fail with HARN-RMD-002; unknown top-level reminder options
fail with HARN-RMD-001.
Hosts can inspect the bridge queue with session/pending_injections. The
response is {pendingCount, injections} in FIFO order and includes both
pending user-message injects and pending reminders. Reminder rows carry the
stable reminderId, mode, body, tags, dedupeKey, ttlTurns,
roleHint, and source fields.
Hosts can revoke a queued reminder with session/revoke_reminder before a
checkpoint drains it. The response returns status: "revoked" or
"already_revoked" for idempotent repeats; races after delivery return a
structured already_delivered error.
Canonical stdlib providers#
Canonical providers are enabled by default inside agent_loop(harness, ...).
Bare harness.llm.call(...) renders pending reminders from the selected session
but does not evaluate providers.
| Provider | Subscribes to | Config keys | Reminder behavior |
|---|---|---|---|
token_pressure | on_budget_threshold | context_window | Fires at about 70%, 85%, and 95% context use. Uses tag and dedupe key token_pressure, ttl_turns: 2, role_hint: "developer", propagate: "session", and sets preserve_on_compact: true at the critical threshold. |
idle_nudge | session_idle | idle_seconds or seconds | Fires when daemon idle wake interval reaches the threshold, defaulting to 60 seconds. Uses tag idle, dedupe key idle_nudge, ttl_turns: 1, and propagate: "none". |
tool_output_truncated | post_tool_use | none | Fires when tool output was truncated or compacted before the model saw it. Uses tag truncation, dedupe key tool_output_truncated:<tool_name>, ttl_turns: 1, and propagate: "none". |
post_compact_recap | post_compact | none | Fires after compaction archives messages. Uses tag recap, dedupe key post_compact_recap, ttl_turns: 2, and propagate: "session". |
resume_continuity | worker_resumed | none | Fires before the first resumed agent_loop turn. Uses tag and dedupe key resume_continuity, ttl_turns: 1, preserve_on_compact: true, and propagate: "none"; includes suspension reason, resume cause, optional resume input, and a pre-suspend digest for continue_transcript: false. |
project_facts | session_start, on_budget_threshold | namespace, scope, root, max_facts, min_confidence, kind_filter, relevance_query, refresh_ratio | Recalls typed harn.fact.v1 records via lexical (BM25) match against the session task and top-K by score (records that BM25 cannot score still surface, ranked by recency). Defaults: namespace project/facts, max_facts: 5, min_confidence: 0.5, query derived from the session task. Uses tag and dedupe key project_facts, ttl_turns: 1, and propagate: "session" so the same body refreshes on context-budget pressure and re-injects on the next session start. The on_budget_threshold refresh only fires once context usage crosses refresh_ratio (default 0.70) so per-turn recall stays bounded. Skipped silently when no schema-matched facts pass the filter. |
workspace_anchor | session_start, on_budget_threshold | none | Renders the active session workspace anchor and mounted roots when one is set. Uses tag and dedupe key workspace_anchor, ttl_turns: 1, and propagate: "session". |
grounded_review | post_tool_use, post_step, post_agent_turn | max_findings, limit, text_scan, include_warnings | Fires only when payloads carry concrete verifier/runtime evidence: explicit tool runtime errors, non-accepted routing verifier signals, parse errors, undefined-name diagnostics, error-severity diagnostics, or failure lines from known verification commands such as cargo check, harn lint, pytest, go test, and tsc. Uses tags grounded_review and code_review, ttl_turns: 2, role_hint: "developer", and propagate: "none". Warnings and style nits are suppressed unless include_warnings: true; no LLM-only critique can fire the provider without a concrete signal. |
Disable every provider with reminders: false or
reminders: {enabled: false}. Disable selected providers by prefixing the
provider id with -:
agent_loop(harness, task, system, {
reminders: {
providers: ["-token_pressure", "-idle_nudge"],
config: {
token_pressure: {context_window: 128000},
idle_nudge: {idle_seconds: 120},
},
},
})
The provider list has a hard diagnostic guard: enabling more than eight
distinct providers raises HARN-RMD-007.
One context-directive envelope#
harness.llm.call(...) loads pending directives from the active session_id
transcript. The projection boundary applies one policy across structural
reminders and runtime feedback:
- explicit
dedupe_keycollisions collapse by authority, then recency; - whitespace-normalized duplicate bodies collapse by authority, then recency;
- survivors are ordered
contract,corrective, thenadvisory; - every survivor renders as
<directive authority="...">...</directive>in one<context-directives speaker="...">envelope; finite directives also render theirttl_turnscontract on that element; - the envelope becomes one new trailing user turn and never edits an existing message.
This slot and register are identical for Anthropic, OpenAI, Gemini, and local
routes. The system string remains byte-stable as directives change. Diagnostic
identity such as source, tags, dedupe key, and runtime feedback kind stays in
typed events and is never copied into the model-visible envelope. The lifetime
is the exception because a durable historical message must tell the model when
its instruction stopped applying. The envelope's speaker attribute is the
other: it says whether harness machinery or the person wrote the block, so the
model can act on a harness directive without answering it back to the reader.
role_hint selects that speaker (user_block means the person, every other
hint means the harness) but does not create a second rendering path.
The envelope always travels as a user message. user is the only
mid-conversation role every provider dialect accepts, so the speaker is carried
by the attribute rather than the wire role.
Append-only placement#
Provider prompt caches are prefix caches. Anthropic requires byte-identical segments up to the breakpoint, OpenAI matches an initial run of tokens, vLLM chains one hash per KV block over the tokens before it, and a llama.cpp slot re-prefills from the first divergent token to the end. A directive that moves between requests therefore invalidates the whole prompt on every turn.
Transcript construction holds one invariant:
the serialized message array at request N+1 begins with the serialized message array at request N.
The envelope is committed into durable history at the turn boundary that emits it. Later turns re-send those exact bytes at the same index:
- the envelope is always its own trailing
userturn and never folds into an existing message; - deduplication means "do not re-issue" — a directive whose rendered text is already committed is not emitted again, and no earlier turn is edited to remove a stale one;
- turns are immutable once their boundary passes. Compaction stays the one sanctioned prefix break, and it is already evented.
Two consequences follow. A delivered directive stays visible until compaction
because withdrawing it would rewrite history. A finite directive carries
ttl_turns="N" in its model-facing element; it applies only to the N assistant
turns immediately after the message where it first appears. Seeing that message
later in history does not renew the lifetime. A directive without a finite TTL
stays active until a later correction or compaction. Directives are ordinary
transcript messages, so a transcript contains one user turn per distinct
directive emission. Code that counts messages or scans for text should filter
by role and directive lifetime.
For reminder providers, an active preserve_on_compact reminder without a TTL
and with the same dedupe_key and lifecycle fields is one emission even when
its provider is evaluated by several subscribed events. Harn retains that
reminder atomically and records unchanged_durable_reminder in the iteration's
skipped reports. Changing its body or lifecycle fields emits a new directive.
Finite reminders keep their turn lifetime by renewing on provider evaluation;
expiry or revocation makes a later equal reminder new again.
ttl_turns, preserve_on_compact, and dedupe_key still govern the pending
queue and compaction. TTL also travels with the delivered directive so its
meaning survives append-only history; expiry removes it from the pending queue
without rewriting the earlier message.
Compaction interaction#
The pending queue lives in transcript events while a delivered directive is a
durable message. Normal agent-session post-turn processing decrements finite
ttl_turns. ttl_turns: 1 applies to the assistant turn immediately after the
directive message, expires at the next post-turn boundary, and emits an expiry
lifecycle event when an EventLog is active. The earlier message remains in
history with the finite lifetime written into its directive element. Compaction
is what removes that historical message from the model's view, and it is the
one sanctioned prefix break.
transcript_compact(...) applies reminder lifecycle processing before it
rebuilds the transcript:
- finite TTLs decrement at the pre-compaction boundary
- expired reminders are dropped
- directives sharing a
dedupe_keyor normalized body collapse by authority, then recency - only
preserve_on_compact: truereminders are copied into the compacted transcript - custom compactors receive surviving reminder payloads as their second argument
const compacted = transcript_compact(snapshot, {
strategy: "custom",
custom_compactor: { messages, reminders ->
return transcript({
messages: [
{
role: "system",
content: "Summary plus " + str(len(reminders))
+ " active reminders.",
},
],
})
},
})
Common gotcha: preserve_on_compact: false plus no finite ttl_turns
creates a reminder that can live forever during normal turns but vanish
at the next compaction. HARN-RMD-004 flags this shape. Add a finite
TTL, or set preserve_on_compact: true when the reminder is meant to be
durable across compaction.
Sub-agent propagation#
When a parent agent hands work to a child session, Harn filters pending
reminders into the handoff envelope's reminder_propagation field. The
child seeds inherited reminders into its transcript before its first
turn. Inherited copies rewrite source to "inherited" and set
originating_agent_id to the session that first emitted the reminder.
propagate | Behavior |
|---|---|
"all" | Inherit into direct children and allow inherited copies to continue into deeper descendants. |
"session" | Inherit into direct children from the originating session only; inherited copies are not re-forwarded. |
"none" | Keep local to the current session. |
Use "all" sparingly for durable policy context. Use "session" for
task-scoped context that direct helpers need. Use "none" for local
observations such as truncated tool output or daemon idle nudges.
Diagnostic codes#
The reminder lifecycle diagnostic family is HARN-RMD-NNN. Use
harn explain HARN-RMD-005 or the
diagnostic catalog for the
current explanation and remediation.
| Code | Meaning |
|---|---|
HARN-RMD-001 | Unknown option key or invalid strict producer option shape. |
HARN-RMD-002 | Invalid bridge reminder payload shape. |
HARN-RMD-003 | Retired provider-specific role-hint diagnostic; retained for compatibility. |
HARN-RMD-004 | Discardable reminder has no finite TTL. |
HARN-RMD-005 | Unknown propagate value. |
HARN-RMD-006 | Reminder provider returned a malformed reminder spec. |
HARN-RMD-007 | Too many reminder providers are enabled. |
HARN-RMD-008 | Hook event does not support reminder effects. |
EventLog events#
Reminder producers and lifecycle boundaries emit audit records on the
transcript.reminder.lifecycle topic when an EventLog is active. Each
payload carries session_id, task_id, and agent_id so host and
replay tooling can correlate reminder state with the active agent turn
and tool call. Use event_log.subscribe(...) with
kind_prefix: "transcript.reminder." to stream only reminder lifecycle
events.
| Event kind | Emitted when | Key payload fields |
|---|---|---|
transcript.reminder.injected | A new directive enters the pending queue. | reminder_id, tags, dedupe_key, source, role_hint, authority, ttl_turns, propagate |
transcript.reminder.fired | A pending directive is rendered into the next provider request. | reminder_id, turn_number, authority, rendered_role |
transcript.reminder.deduped | A new reminder or compaction replaces older pending reminders with the same dedupe_key. | replaced_id, replacing_id, dedupe_key |
transcript.reminder.expired | A reminder is removed by TTL expiry, compaction, or an explicit clear operation. | reminder_id, reason (ttl, compaction, or cleared) |
transcript.reminder.dropped | A malformed or unrenderable reminder cannot be used for the next provider request. | reminder_id, reason (invalid or capability_mismatch) |
transcript.reminder.inherited | A child session receives an inherited reminder. | reminder_id, originating_agent_id, sub_agent_id, propagate |
transcript.reminder.provider_evaluated | A registered provider evaluated against a hook event. | provider_id, event, fired |
Rendered reminders also surface to ACP clients as session/update
notifications with sessionUpdate: "reminder_emitted" and
_meta.harn.reminder metadata. Tool-call hook reminders attach a compact
lifecycle report to ToolCallReceipt.audit.reminders when
with_audit_log(...) is active. The receipt report identifies the hook
event, tool call, reminder id, tags, dedupe key, source, role hint, authority, TTL,
and dedupe count without copying the reminder body into the receipt.
Cookbook#
Token-pressure thresholds#
Use the canonical provider and override only the context window when the route or harness cannot infer it.
agent_loop(harness, task, system, {
reminders: {
config: {
token_pressure: {context_window: 128000},
},
},
})
Project facts at session start#
Persist project facts with store_fact so the canonical project_facts
provider can recall them on the next session. Tune the namespace and
filters per loop when the defaults are too broad.
store_fact({
kind: "Decision",
claim: "Build cancels in-flight tool calls on SIGINT before"
+ " clearing state.",
confidence: 0.92,
evidence: [{kind: "FileRange", ref: "crates/harn-vm/src/cancel.rs:1"}],
})
agent_loop(harness, task, system, {
reminders: {
config: {
project_facts: {
max_facts: 8,
min_confidence: 0.6,
kind_filter: ["decision", "constraint"],
},
},
},
})
File-changed reminder from a hook#
Use a dedupe key per file so repeated file-watcher events collapse into one pending nudge.
pipeline main(harness: Harness) {
harness.agent.register_session_hook(
"file_edited", { _hook_harness, event ->
const path = to_string(event?.path ?? "")
return {
reminder: {
body: "File changed externally: " + path + ". Re-read it"
+ " before editing.",
tags: ["workspace", "file_changed"],
dedupe_key: "file_changed:" + path,
ttl_turns: 2,
propagate: "session",
},
}
})
}
Memory-derived reminder#
Use propagate: "all" only when downstream agents need the same caution.
const memory_warning = "Customer prefers patch-sized PRs and explicit"
+ " verification."
const injected = transcript.inject_reminder(transcript(), {
body: memory_warning,
tags: ["memory"],
dedupe_key: "memory:customer-pr-style",
ttl_turns: 4,
preserve_on_compact: true,
propagate: "all",
role_hint: "developer",
})
Clear stale reminders after the condition resolves#
Clear by dedupe_key for singleton reminders and by tag for groups.
const cleared = transcript.clear_reminders(current_transcript, {
dedupe_key: "workspace-change",
})
Host-injected idle workspace nudge#
Bridge hosts should use session/remind, not session/inject, when the
content is ambient context rather than user text.
{
"jsonrpc": "2.0",
"method": "session/remind",
"params": {
"body": "Dependencies changed while the agent was idle; rerun the narrow test before continuing.",
"tags": ["workspace", "deps"],
"dedupe_key": "workspace:deps",
"ttl_turns": 1,
"propagate": "none",
"authority": "advisory"
}
}
One-turn advisory#
Use advisory for helpful context that must yield to an active contract or
corrective instruction.
transcript.inject_reminder(transcript(), {
body: "Large policy excerpt applies for this turn only.",
tags: ["policy"],
dedupe_key: "policy:turn",
ttl_turns: 1,
authority: "advisory",
propagate: "none",
})
Custom compactor reads reminders#
Custom compactors receive the surviving reminder payloads after TTL and dedupe processing.
transcript_compact(snapshot, {
strategy: "custom",
custom_compactor: { messages, reminders ->
for reminder in reminders {
harness.stdio.log(reminder.body)
}
return transcript({messages: messages})
},
})
Cross-references#
- Epic: #1815 -- System Reminders and Ambient Context Injection
- Provider capability flags: #1665
- Host-neutral context envelope: #1680
- Hook recipes and background context maintenance: #1681
- Hooks reference: Hooks (tool, persona, session lifecycle)
- Bridge reminder injection: Bridge protocol
- Builtin reference: Builtin functions