Status: IMPLEMENTATION_READY
Date: 2026-04-12
Package: @agent-assistant/proactive
Version target: v0.1.0 (pre-1.0, provisional)
Roadmap stage: v1.3 (after core, sessions, surfaces, memory, connectivity, routing, coordination land)
Scope reference: docs/architecture/v1-proactive-scope.md
@agent-assistant/proactive provides the decision layer for proactive assistant behavior. Proactive behavior is any assistant action that originates without a direct user message in the current turn.
Owns:
ProactiveEngine— the central evaluator for follow-up rules and watch rulesFollowUpRule— product-supplied rule definition with condition, suppression policy, and routing hintWatchRule— long-running monitoring rule with interval, condition, and actionReminderPolicy— configurable policy controlling reminder cadence, cooldown, and suppressionFollowUpDecision— output of follow-up evaluation; tells the caller what to do (fire | suppress)WatchTrigger— output of watch rule evaluation; tells the caller a condition was metSchedulerBinding— interface contract between the engine and external scheduling infrastructure (relaycron)InMemorySchedulerBinding— test adapter for the scheduler binding interfaceFollowUpEvidenceSource— optional interface for injecting evidence (memory entries, session metadata) into evaluation- Reminder state tracking — in-memory per-session, per-rule reminder counts and last-sent timestamps
- Suppression logic — compares
scheduledAtvs.lastActivityAtto gate follow-up firing - Watch rule lifecycle —
pause,cancel,list, re-scheduling after evaluation - Error types —
ProactiveError,RuleNotFoundError,SchedulerBindingError
Does NOT own:
- Domain-specific rule definitions (→ product repos)
- Product-specific timing thresholds (→ product configuration)
- Scheduling infrastructure — timers, cron registration, dispatch (→ Relay foundation / relaycron)
- Memory persistence and retrieval (→
@agent-assistant/memory, accessed throughFollowUpEvidenceSource) - Session lifecycle (→
@agent-assistant/sessions; engine reads session values from evaluation context) - Outbound message delivery (→
@agent-assistant/surfaces+ Relay runtime) - Cross-agent coordination of proactive actions (→
@agent-assistant/coordination, v1.2) - Proactive action risk classification or approval gates (→
@agent-assistant/policy, v2)
- This package does not register a capability handler on the runtime. Products write their own
proactivecapability handler and call engine methods inside it. - This package does not implement a scheduler. The
SchedulerBindinginterface is the extent of its scheduler involvement. - This package does not own persistent rule storage. v1 is in-memory only.
- This package does not emit connectivity signals. That is a product-layer concern in v1.
- This package does not inspect message text or apply NLP. All conditions are function-based, returning
boolean | Promise<boolean>. - This package does not rate-limit proactive actions across the assistant fleet. That belongs in
@agent-assistant/policy.
The proactive engine communicates with external scheduling infrastructure (relaycron) through a SchedulerBinding interface. This keeps the engine testable without a live scheduler.
interface SchedulerBinding {
/** Request a wake-up at the given time. Returns a bindingId the engine uses to cancel. */
requestWakeUp(at: Date, context: WakeUpContext): Promise<string>;
/** Cancel a previously requested wake-up by its bindingId. No-op if already fired or not found. */
cancelWakeUp(bindingId: string): Promise<void>;
}
interface WakeUpContext {
sessionId: string;
ruleId?: string;
scheduledAt: string; // ISO-8601; stored so the engine can detect if user became active since
metadata?: Record<string, unknown>;
}The package ships InMemorySchedulerBinding for tests and local development. It:
- Records all
requestWakeUpcalls in an accessiblependingWakeUpsmap - Supports manual trigger via
trigger(bindingId): Promise<void> - No-ops on
cancelWakeUpfor unknown IDs
Products wire a real relaycron binding (not in this package) that implements this interface for production use.
At wake-up time, relaycron dispatches a synthetic InboundMessage to the assistant runtime. The proactive package does not receive a callback. The product's proactive capability handler is invoked by the runtime, reads the WakeUpContext from the message payload, and calls engine methods directly.
This means the engine has no async lifecycle of its own — it is purely a decision library called by the handler.
interface FollowUpRule {
id: string;
/**
* Condition function. Receives the evaluation context and optional evidence.
* Returns true if the assistant should consider following up.
*/
condition(
context: FollowUpEvaluationContext,
evidence: EvidenceEntry[]
): boolean | Promise<boolean>;
/** Human-readable description for logging. */
description?: string;
/** ReminderPolicy governs suppression. If omitted, defaults are used. */
policy?: ReminderPolicy;
/** Routing hint passed through to the caller's routing logic. Defaults to 'cheap'. */
routingHint?: 'cheap' | 'fast' | 'deep';
/** Free-form message template. The caller is responsible for final rendering. */
messageTemplate?: string;
}The engine receives all session signal values as plain data. It does not import or call any session package directly.
interface FollowUpEvaluationContext {
sessionId: string;
/** ISO-8601 — when the wake-up was originally scheduled. Used for suppression comparison. */
scheduledAt: string;
/** ISO-8601 — the session's last user activity timestamp. */
lastActivityAt: string;
/** Optional pre-fetched evidence entries from the evidence source. */
evidence?: EvidenceEntry[];
}interface FollowUpDecision {
ruleId: string;
sessionId: string;
/** What the engine recommends the caller do. */
action: 'fire' | 'suppress';
/**
* suppressionReason is present when action === 'suppress'.
* - 'user_active': user became active after the wake-up was scheduled
* - 'cooldown': too soon after the previous reminder
* - 'max_reminders': rule's maxReminders count has been reached
*/
suppressionReason?: 'user_active' | 'cooldown' | 'max_reminders';
/** Routing hint from the rule. Callers should use this to select a model tier. */
routingHint: 'cheap' | 'fast' | 'deep';
/** Message template from the rule, if provided. Caller renders the final message. */
messageTemplate?: string;
}The engine applies suppression checks in order. The first matching condition wins:
- user_active:
lastActivityAt > scheduledAt— user became active after the wake-up was scheduled. Always suppress. - max_reminders: reminder count for this
(sessionId, ruleId)pair has reachedpolicy.maxReminders. Suppress. - cooldown:
now - lastReminderSentAt < policy.cooldownMs. Suppress. - condition false: the rule's condition function returns false. Suppress (no suppressionReason set; action is
suppress). - no suppression: action is
fire. Engine increments reminder count and recordslastReminderSentAt.
defer is not available in v1. The engine always returns fire or suppress. Deferred retry logic is reserved for v1.1.
interface ReminderPolicy {
/** Maximum number of reminders to send per (sessionId, ruleId). Default: 3. */
maxReminders?: number;
/**
* Minimum milliseconds between reminders for the same (sessionId, ruleId).
* Default: 3_600_000 (1 hour).
*/
cooldownMs?: number;
/**
* If true, suppress the reminder when the user became active after the wake-up was scheduled
* (lastActivityAt > scheduledAt).
* Default: true.
*/
suppressWhenActive?: boolean;
}
const DEFAULT_REMINDER_POLICY: Required<ReminderPolicy> = {
maxReminders: 3,
cooldownMs: 3_600_000,
suppressWhenActive: true,
};Reminder state is stored in-memory in the engine instance. It is keyed by ${sessionId}:${ruleId} and tracks:
reminderCount: numberlastReminderSentAt: string | null(ISO-8601)
Reminder state resets when:
- The rule is removed via
engine.removeFollowUpRule(ruleId) - The session key is explicitly cleared via
engine.resetReminderState(sessionId, ruleId)
There is no automatic reset on session closure in v1. Products manage this via the capability handler.
interface WatchRule {
id: string;
/**
* Condition function evaluated on each scheduled check.
* Returns true if the watch rule should trigger an action.
*/
condition(context: WatchEvaluationContext): boolean | Promise<boolean>;
/**
* Action descriptor. The engine does not execute actions — it returns WatchTrigger
* objects that the caller handles.
*/
action: WatchAction;
/**
* Interval in milliseconds between condition checks.
* The engine requests a new wake-up after every evaluation regardless of trigger result.
*/
intervalMs: number;
/** Optional description for logging and observability. */
description?: string;
}
interface WatchEvaluationContext {
ruleId: string;
/** ISO-8601 — when this evaluation was scheduled. */
scheduledAt: string;
/** Product-supplied metadata from the original WakeUpContext. */
metadata?: Record<string, unknown>;
}
interface WatchAction {
/** Short descriptor used by the caller to identify what to do. */
type: string;
/** Optional payload passed through to the caller unchanged. */
payload?: Record<string, unknown>;
}interface WatchTrigger {
ruleId: string;
triggeredAt: string; // ISO-8601 — when the condition returned true
action: WatchAction;
context: WatchEvaluationContext;
}interface ProactiveEngine {
// --- Follow-up rules ---
registerFollowUpRule(rule: FollowUpRule): void;
removeFollowUpRule(ruleId: string): void; // throws RuleNotFoundError if not found
listFollowUpRules(): FollowUpRule[];
evaluateFollowUp(context: FollowUpEvaluationContext): Promise<FollowUpDecision[]>;
resetReminderState(sessionId: string, ruleId?: string): void;
// --- Watch rules ---
registerWatchRule(rule: WatchRule): void;
pauseWatchRule(ruleId: string): void; // throws RuleNotFoundError if not found
resumeWatchRule(ruleId: string): void; // throws RuleNotFoundError if not found
cancelWatchRule(ruleId: string): void; // throws RuleNotFoundError if not found
listWatchRules(): WatchRuleStatus[];
evaluateWatchRules(context: WatchEvaluationContext): Promise<WatchTrigger[]>;
}
interface WatchRuleStatus {
rule: WatchRule;
status: 'active' | 'paused' | 'cancelled';
lastEvaluatedAt: string | null; // ISO-8601
nextWakeUpBindingId: string | null;
}After every evaluateWatchRules() call, the engine requests a new wake-up for each active (non-paused, non-cancelled) rule:
nextWakeUpAt = new Date(Date.now() + rule.intervalMs)
bindingId = await schedulerBinding.requestWakeUp(nextWakeUpAt, {
sessionId: context.metadata?.sessionId ?? '_watch',
ruleId: rule.id,
scheduledAt: nextWakeUpAt.toISOString(),
metadata: context.metadata,
})
The previous bindingId is cancelled before requesting the new one to avoid duplicate wake-ups.
Paused rules do not re-schedule. Cancelled rules do not re-schedule and cannot be re-activated; products must re-register.
Stale-thread detection is a follow-up evaluation pattern, not a separate engine concept. A product implements it by registering a follow-up rule whose condition checks whether the session has been inactive for a threshold duration.
Reference implementation pattern:
engine.registerFollowUpRule({
id: 'stale-thread',
description: 'Follow up on sessions silent for more than 24 hours',
condition: (ctx) => {
const inactiveMs = new Date(ctx.scheduledAt).getTime()
- new Date(ctx.lastActivityAt).getTime();
return inactiveMs > 24 * 60 * 60 * 1000;
},
policy: {
maxReminders: 2,
cooldownMs: 24 * 60 * 60 * 1000, // 24h between reminders
suppressWhenActive: true,
},
routingHint: 'cheap',
messageTemplate: 'Checking in — any updates on this thread?',
});The engine handles suppression (cooldown, max count, user-active check). The caller handles scheduling the initial wake-up and dispatching the outbound message after receiving a fire decision.
The FollowUpEvidenceSource interface is optional. Products wire it to provide evidence to follow-up rule conditions. If an evidence source is configured, the engine calls it once per evaluateFollowUp() invocation. Products can alternatively pre-fetch evidence and pass it in context.evidence.
interface FollowUpEvidenceSource {
getRecentEntries(
sessionId: string,
options?: { limit?: number; tags?: string[] }
): Promise<EvidenceEntry[]>;
}
interface EvidenceEntry {
id: string;
content: string;
tags: string[];
createdAt: string; // ISO-8601
metadata?: Record<string, unknown>;
}Engine behavior with a configured evidence source:
When evaluateFollowUp() is called and an evidenceSource is configured, the engine fetches evidence once per evaluateFollowUp() call for the sessionId. The same evidence array is passed as the second argument to every rule's condition function.
Engine behavior without an evidence source:
Evidence is an empty array [] passed to all condition functions.
Products can pre-fetch evidence themselves and pass it in context.evidence to avoid the engine fetching it per-rule.
interface ProactiveEngineConfig {
/** Optional evidence source wired to a memory store or other evidence backend. */
evidenceSource?: FollowUpEvidenceSource;
/** Required scheduler binding. Wire InMemorySchedulerBinding for tests. */
schedulerBinding: SchedulerBinding;
/** Default reminder policy applied when a rule does not specify its own policy. */
defaultReminderPolicy?: ReminderPolicy;
}
function createProactiveEngine(config: ProactiveEngineConfig): ProactiveEngine;No runtime dependency on any other @agent-assistant/* package. All integration is through the interfaces above, wired by product code.
class ProactiveError extends Error {
readonly code: string;
}
class RuleNotFoundError extends ProactiveError {
readonly ruleId: string;
readonly ruleType: 'followUp' | 'watch';
readonly code = 'RULE_NOT_FOUND';
}
class SchedulerBindingError extends ProactiveError {
readonly bindingId?: string;
readonly code = 'SCHEDULER_BINDING_ERROR';
readonly cause: unknown;
}packages/proactive/
package.json — nanoid runtime dep only
tsconfig.json — ES2022, NodeNext, strict
src/
types.ts — all exported types, interfaces, error classes (~150 lines)
proactive.ts — createProactiveEngine factory, evaluation logic, reminder state (~400 lines)
index.ts — public re-exports (~25 lines)
proactive.test.ts — 41 tests (~500 lines)
README.md
| Dependency | Type | Reason |
|---|---|---|
nanoid |
Runtime | Rule ID and binding ID generation |
vitest |
Dev | Testing |
typescript |
Dev | Build |
Zero runtime dependency on any @agent-assistant/* package.
| Category | Count | Coverage |
|---|---|---|
| Type structural tests | 4 | Required types, interfaces, and error classes exist and are exported |
| Follow-up rule registration | 5 | Register, list, remove, duplicate-id rejection, validation (missing id) |
| Follow-up evaluation — basic | 4 | Single rule fires, single rule suppresses on condition false, multiple rules evaluated, empty rule list |
| Follow-up evaluation — suppression | 5 | user_active suppression, cooldown suppression, max_reminders suppression, suppress-when-active=false override, suppression reason field |
| Follow-up evaluation — evidence | 3 | Evidence source called, evidence passed to condition, pre-fetched evidence in context used without re-fetching |
| Follow-up evaluation — routing | 2 | routingHint passed through, default routingHint is 'cheap' |
| Reminder state | 4 | Count incremented on fire, reset via resetReminderState(sessionId, ruleId), reset all for session, state not shared across sessions |
| Watch rule lifecycle | 6 | Register, pause (skips evaluation), resume, cancel (skips evaluation + no re-schedule), list statuses, re-register after cancel |
| Watch rule evaluation | 5 | Condition true → WatchTrigger returned, condition false → no trigger, re-scheduling after evaluation, paused rule not evaluated, multiple rules mixed trigger |
| Scheduler binding | 4 | requestWakeUp called on watch re-schedule, cancelWakeUp called before re-schedule, InMemorySchedulerBinding records pending, InMemorySchedulerBinding manual trigger |
| Error handling | 3 | RuleNotFoundError on remove unknown, RuleNotFoundError on pause unknown, SchedulerBindingError wraps binding failure |
| Total | 45 |
| Capability | Target | Reason |
|---|---|---|
| Real relaycron binding implementation | v1.1 | Requires relaycron API to be stable |
| Persistent rule storage adapter | v1.1 | v1 is in-memory only |
| Session archival trigger | v1.1 | Depends on memory promotion workflow being stable |
| Automatic memory-to-evidence pipeline | v1.1 | Requires @agent-assistant/memory integration |
| Coordination-driven proactive actions | v1.2 | Multi-agent proactive behavior |
| Traits-aware proactive voice | v1.2 | Depends on @agent-assistant/traits |
defer action in FollowUpDecision |
v1.1 | Retry scheduling for deferred evaluations |
| Proactive action budgets / rate limiting | v2 | Belongs in @agent-assistant/policy |
| Distributed watch rule evaluation | v2+ | Single-process only in v1 |
| Event-stream watchers | v1.2 | v1 is poll-based; event-driven watchers are additive |
| Cross-session rule scoping | v1.1 | v1 rules are scoped to a single session |
V1_PROACTIVE_SPEC_READY