diff --git a/TODO.md b/TODO.md index 164c7083..de7cfb6d 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,23 @@ the item when done — git log is the history. ## Architecture +- [ ] Broker raw-upstream recorder + no-connect replay harness (Layer 2 + bug debug surface). When a community user reports a broker-specific + normalize bug — IBKR's `request-bridge.ts:470 .abs()`, the proto + decoder's empty `if (cp.secType !== undefined)` body, a hypothetical + CCXT `entryPrice` mis-parse — code-reading alone is slow and + imprecise. Add a dev-mode raw-upstream recorder per broker (IBKR: + EWrapper callback args; CCXT: `fetchBalance` / `fetchPositions` + return values; Alpaca: REST response bodies), append-only JSONL to + `data/trading//upstream/session-.jsonl`. Pair with a + replay tool that constructs the broker without network (currently + `init()` forces connect) and re-fires the recorded events through + the same normalize pipeline, returning `getPositions` / + `getAccount`. Prerequisite: factor `init()` to allow no-connect + construction for IBKR / CCXT / Alpaca. Lets future broker-bug + diagnosis happen offline against a recorded session instead of + requiring live broker re-attachment. Companion harness ideas in + `~/.claude/plans/simulator-moonlit-otter.md`. - [ ] Extract `derivePositionMath(raw): { marketValue, unrealizedPnL }` shared util. Today's IBroker contract requires every broker's `getPositions` to multiply by `multiplier` when computing diff --git a/src/ai-providers/types.ts b/src/ai-providers/types.ts index fcd5c730..046894fc 100644 --- a/src/ai-providers/types.ts +++ b/src/ai-providers/types.ts @@ -14,10 +14,23 @@ export type ProviderEvent = // ==================== Types ==================== +/** A tool the AI invoked during this generation. Captured by AgentCenter + * as `tool_use` events stream through the pipeline. Used by AgentWork's + * outputGate to detect intent-signal tools like `notify_user`. */ +export interface ToolCallSummary { + id: string + name: string + input: unknown +} + export interface ProviderResult { text: string media: MediaAttachment[] mediaUrls?: string[] + /** Tool calls observed during this generation, in invocation order. + * AgentCenter populates this when it synthesizes the final done event; + * individual providers don't need to fill it themselves. */ + toolCalls?: ReadonlyArray } // ==================== GenerateOpts ==================== diff --git a/src/core/agent-center.ts b/src/core/agent-center.ts index e132a605..bb2a5598 100644 --- a/src/core/agent-center.ts +++ b/src/core/agent-center.ts @@ -13,6 +13,7 @@ */ import type { AskOptions, ProviderResult, ProviderEvent, GenerateOpts } from './ai-provider-manager.js' +import type { ToolCallSummary } from '../ai-providers/types.js' import type { ResolvedProfile } from './config.js' import { GenerateRouter, StreamableResult } from './ai-provider-manager.js' import { resolveProfile, resolveCredential } from './config.js' @@ -131,6 +132,11 @@ export class AgentCenter { let currentAssistantBlocks: ContentBlock[] = [] let currentUserBlocks: ContentBlock[] = [] let finalResult: ProviderResult | null = null + // Tool calls observed during this generation, captured for the final + // done event so AgentWork (and any other consumer awaiting the + // ProviderResult) can inspect what the AI invoked without having to + // re-stream the events themselves. + const toolCalls: ToolCallSummary[] = [] for await (const event of source) { switch (event.type) { @@ -143,6 +149,7 @@ export class AgentCenter { // Unified logging — all providers get this now logToolCall(event.name, event.input) this.toolCallLog?.start(event.id, event.name, event.input, session.id) + toolCalls.push({ id: event.id, name: event.name, input: event.input }) currentAssistantBlocks.push({ type: 'tool_use', id: event.id, @@ -227,7 +234,7 @@ export class AgentCenter { ] await session.appendAssistant(finalBlocks, provider.providerTag) - // 9. Yield done with merged media + // 9. Yield done with merged media + observed tool calls const mediaUrls = mediaBlocks.map(b => (b as { type: 'image'; url: string }).url) yield { type: 'done', @@ -235,6 +242,7 @@ export class AgentCenter { text: finalResult.text, media: allMedia, mediaUrls, + toolCalls, }, } } diff --git a/src/core/agent-event.spec.ts b/src/core/agent-event.spec.ts index ff4c0e36..ea522ccb 100644 --- a/src/core/agent-event.spec.ts +++ b/src/core/agent-event.spec.ts @@ -6,10 +6,9 @@ import type { AgentEventMap } from './agent-event.js' describe('AgentEventSchemas', () => { const expectedTypes: (keyof AgentEventMap)[] = [ - 'cron.fire', 'cron.done', 'cron.error', - 'heartbeat.done', 'heartbeat.skip', 'heartbeat.error', + 'cron.fire', 'message.received', 'message.sent', - 'task.requested', 'task.done', 'task.error', + 'agent.work.requested', 'agent.work.done', 'agent.work.skip', 'agent.work.error', ] it('should have a schema for every key in AgentEventMap', () => { @@ -46,93 +45,95 @@ describe('validateEventPayload', () => { })).toThrow(/Invalid payload.*cron\.fire/) }) - // -- cron.done -- - it('should accept valid cron.done payload', () => { - expect(() => validateEventPayload('cron.done', { - jobId: 'abc', jobName: 'test', reply: 'ok', durationMs: 100, + // -- message.received -- + it('should accept valid message.received payload', () => { + expect(() => validateEventPayload('message.received', { + channel: 'web', to: 'default', prompt: 'hello', })).not.toThrow() }) - // -- cron.error -- - it('should accept valid cron.error payload', () => { - expect(() => validateEventPayload('cron.error', { - jobId: 'abc', jobName: 'test', error: 'boom', durationMs: 50, + // -- message.sent -- + it('should accept valid message.sent payload', () => { + expect(() => validateEventPayload('message.sent', { + channel: 'web', to: 'default', prompt: 'hello', reply: 'hi', durationMs: 300, })).not.toThrow() }) - // -- heartbeat.done -- - it('should accept valid heartbeat.done payload', () => { - expect(() => validateEventPayload('heartbeat.done', { - reply: 'all good', reason: 'CHAT_YES', durationMs: 200, delivered: true, - })).not.toThrow() + it('should reject message.sent with missing reply', () => { + expect(() => validateEventPayload('message.sent', { + channel: 'web', to: 'default', prompt: 'hello', durationMs: 300, + })).toThrow(/Invalid payload.*message\.sent/) }) - // -- heartbeat.skip -- - it('should accept heartbeat.skip with optional parsedReason', () => { - expect(() => validateEventPayload('heartbeat.skip', { - reason: 'ack', parsedReason: 'All systems normal.', + // -- agent.work.requested -- + it('should accept valid agent.work.requested payload', () => { + expect(() => validateEventPayload('agent.work.requested', { + source: 'task', + prompt: 'investigate', })).not.toThrow() }) - it('should accept heartbeat.skip without parsedReason', () => { - expect(() => validateEventPayload('heartbeat.skip', { - reason: 'outside-active-hours', + it('should accept agent.work.requested with metadata', () => { + expect(() => validateEventPayload('agent.work.requested', { + source: 'cron', + prompt: 'check market', + metadata: { jobId: 'abc', jobName: 'daily' }, })).not.toThrow() }) - it('should reject heartbeat.skip with missing reason', () => { - expect(() => validateEventPayload('heartbeat.skip', { - parsedReason: 'something', - })).toThrow(/Invalid payload.*heartbeat\.skip/) + it('should reject agent.work.requested with unknown source', () => { + expect(() => validateEventPayload('agent.work.requested', { + source: 'bogus', + prompt: 'x', + })).toThrow(/Invalid payload.*agent\.work\.requested/) }) - // -- heartbeat.error -- - it('should accept valid heartbeat.error payload', () => { - expect(() => validateEventPayload('heartbeat.error', { - error: 'timeout', durationMs: 5000, - })).not.toThrow() + it('should reject agent.work.requested without prompt', () => { + expect(() => validateEventPayload('agent.work.requested', { + source: 'task', + })).toThrow(/Invalid payload.*agent\.work\.requested/) }) - // -- message.received -- - it('should accept valid message.received payload', () => { - expect(() => validateEventPayload('message.received', { - channel: 'web', to: 'default', prompt: 'hello', + // -- agent.work.done -- + it('should accept valid agent.work.done payload', () => { + expect(() => validateEventPayload('agent.work.done', { + source: 'heartbeat', + reply: 'BTC alert', + durationMs: 200, + delivered: true, })).not.toThrow() }) - // -- message.sent -- - it('should accept valid message.sent payload', () => { - expect(() => validateEventPayload('message.sent', { - channel: 'web', to: 'default', prompt: 'hello', reply: 'hi', durationMs: 300, - })).not.toThrow() + it('should reject agent.work.done with missing delivered field', () => { + expect(() => validateEventPayload('agent.work.done', { + source: 'heartbeat', + reply: 'x', + durationMs: 100, + })).toThrow(/Invalid payload.*agent\.work\.done/) }) - it('should reject message.sent with missing reply', () => { - expect(() => validateEventPayload('message.sent', { - channel: 'web', to: 'default', prompt: 'hello', durationMs: 300, - })).toThrow(/Invalid payload.*message\.sent/) - }) - - // -- task.* -- - it('should accept valid task.requested payload', () => { - expect(() => validateEventPayload('task.requested', { - prompt: 'check overnight moves', + // -- agent.work.skip -- + it('should accept valid agent.work.skip payload', () => { + expect(() => validateEventPayload('agent.work.skip', { + source: 'heartbeat', + reason: 'outside-active-hours', })).not.toThrow() }) - it('should reject task.requested without prompt', () => { - expect(() => validateEventPayload('task.requested', {})).toThrow(/Invalid payload.*task\.requested/) - }) - - it('should accept valid task.done payload', () => { - expect(() => validateEventPayload('task.done', { - prompt: 'hi', reply: 'ok', durationMs: 120, + it('should accept agent.work.skip with arbitrary metadata', () => { + expect(() => validateEventPayload('agent.work.skip', { + source: 'heartbeat', + reason: 'duplicate', + metadata: { parsedReason: 'BTC alert (first 80 chars)' }, })).not.toThrow() }) - it('should accept valid task.error payload', () => { - expect(() => validateEventPayload('task.error', { - prompt: 'hi', error: 'boom', durationMs: 50, + // -- agent.work.error -- + it('should accept valid agent.work.error payload', () => { + expect(() => validateEventPayload('agent.work.error', { + source: 'cron', + error: 'AI down', + durationMs: 5, })).not.toThrow() }) @@ -146,4 +147,13 @@ describe('validateEventPayload', () => { it('should pass for unregistered type with null payload', () => { expect(() => validateEventPayload('unknown.type', null)).not.toThrow() }) + + // -- legacy types (now removed from internal map but accepted on webhook wire) -- + it('legacy task.requested type is no longer in AgentEventMap', () => { + // The webhook layer handles wire-level legacy alias translation. + // Validation against the canonical type happens after translation. + expect(AgentEventSchemas).not.toHaveProperty('task.requested') + expect(AgentEventSchemas).not.toHaveProperty('heartbeat.done') + expect(AgentEventSchemas).not.toHaveProperty('cron.done') + }) }) diff --git a/src/core/agent-event.ts b/src/core/agent-event.ts index b50c9f78..29b368f0 100644 --- a/src/core/agent-event.ts +++ b/src/core/agent-event.ts @@ -16,43 +16,13 @@ import { Type, type TSchema } from '@sinclair/typebox' import AjvPkg from 'ajv' +import type { NotificationSource } from './notifications-store.js' // Re-export CronFirePayload from its canonical location export type { CronFirePayload } from '../task/cron/engine.js' // ==================== Payload Interfaces ==================== -export interface CronDonePayload { - jobId: string - jobName: string - reply: string - durationMs: number -} - -export interface CronErrorPayload { - jobId: string - jobName: string - error: string - durationMs: number -} - -export interface HeartbeatDonePayload { - reply: string - reason: string - durationMs: number - delivered: boolean -} - -export interface HeartbeatSkipPayload { - reason: string - parsedReason?: string -} - -export interface HeartbeatErrorPayload { - error: string - durationMs: number -} - export interface MessageReceivedPayload { channel: string to: string @@ -67,20 +37,49 @@ export interface MessageSentPayload { durationMs: number } -export interface TaskRequestedPayload { +// ==================== Canonical AgentWork events ==================== +// +// All "Alice runs an async task" flows funnel through these four +// canonical events instead of per-trigger-source event types. The +// `source` field on each payload is the routing key — consumers +// filter on it instead of subscribing to separate event types. +// +// `agent.work.requested` is externally-ingestable (webhook). The +// done/skip/error events are internal-only. + +export interface AgentWorkRequestedPayload { + /** Which trigger source produced this work request. Drives the + * agent-work-listener's source-registry lookup. */ + source: NotificationSource + /** The AI prompt to execute. */ prompt: string + /** Trigger-specific metadata, surfaced back on the canonical + * done/skip/error events via per-source payload builders. */ + metadata?: Record } -export interface TaskDonePayload { - prompt: string +export interface AgentWorkDonePayload { + source: NotificationSource reply: string durationMs: number + /** Did the notification actually reach the connector? */ + delivered: boolean + metadata?: Record } -export interface TaskErrorPayload { - prompt: string +export interface AgentWorkSkipPayload { + source: NotificationSource + /** Free-form reason — e.g. 'ack' | 'duplicate' | 'empty' | + * 'outside-active-hours' | per-source extension. */ + reason: string + metadata?: Record +} + +export interface AgentWorkErrorPayload { + source: NotificationSource error: string durationMs: number + metadata?: Record } // ==================== Event Map ==================== @@ -90,16 +89,12 @@ import type { CronFirePayload } from '../task/cron/engine.js' export interface AgentEventMap { 'cron.fire': CronFirePayload - 'cron.done': CronDonePayload - 'cron.error': CronErrorPayload - 'heartbeat.done': HeartbeatDonePayload - 'heartbeat.skip': HeartbeatSkipPayload - 'heartbeat.error': HeartbeatErrorPayload 'message.received': MessageReceivedPayload 'message.sent': MessageSentPayload - 'task.requested': TaskRequestedPayload - 'task.done': TaskDonePayload - 'task.error': TaskErrorPayload + 'agent.work.requested': AgentWorkRequestedPayload + 'agent.work.done': AgentWorkDonePayload + 'agent.work.skip': AgentWorkSkipPayload + 'agent.work.error': AgentWorkErrorPayload } // ==================== TypeBox Schemas ==================== @@ -110,37 +105,6 @@ const CronFireSchema = Type.Object({ payload: Type.String(), }) -const CronDoneSchema = Type.Object({ - jobId: Type.String(), - jobName: Type.String(), - reply: Type.String(), - durationMs: Type.Number(), -}) - -const CronErrorSchema = Type.Object({ - jobId: Type.String(), - jobName: Type.String(), - error: Type.String(), - durationMs: Type.Number(), -}) - -const HeartbeatDoneSchema = Type.Object({ - reply: Type.String(), - reason: Type.String(), - durationMs: Type.Number(), - delivered: Type.Boolean(), -}) - -const HeartbeatSkipSchema = Type.Object({ - reason: Type.String(), - parsedReason: Type.Optional(Type.String()), -}) - -const HeartbeatErrorSchema = Type.Object({ - error: Type.String(), - durationMs: Type.Number(), -}) - const MessageReceivedSchema = Type.Object({ channel: Type.String(), to: Type.String(), @@ -155,20 +119,44 @@ const MessageSentSchema = Type.Object({ durationMs: Type.Number(), }) -const TaskRequestedSchema = Type.Object({ +// ---- Canonical agent-work event schemas ---- +// +// `source` is constrained to the NotificationSource union literal set. +// Free-form `metadata` is `unknown` at validation time (downstream +// shape decided per-source). + +const SourceUnion = Type.Union([ + Type.Literal('heartbeat'), + Type.Literal('cron'), + Type.Literal('task'), + Type.Literal('manual'), +]) + +const AgentWorkRequestedSchema = Type.Object({ + source: SourceUnion, prompt: Type.String(), + metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())), }) -const TaskDoneSchema = Type.Object({ - prompt: Type.String(), +const AgentWorkDoneSchema = Type.Object({ + source: SourceUnion, reply: Type.String(), durationMs: Type.Number(), + delivered: Type.Boolean(), + metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())), }) -const TaskErrorSchema = Type.Object({ - prompt: Type.String(), +const AgentWorkSkipSchema = Type.Object({ + source: SourceUnion, + reason: Type.String(), + metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())), +}) + +const AgentWorkErrorSchema = Type.Object({ + source: SourceUnion, error: Type.String(), durationMs: Type.Number(), + metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())), }) // ==================== AgentEvents — metadata registry ==================== @@ -190,26 +178,6 @@ export const AgentEvents: { [K in keyof AgentEventMap]: AgentEventMeta } = { schema: CronFireSchema, description: 'Cron scheduler timer fired for a registered job.', }, - 'cron.done': { - schema: CronDoneSchema, - description: 'Cron job was routed through the AI and completed successfully.', - }, - 'cron.error': { - schema: CronErrorSchema, - description: 'Cron job routing through the AI failed.', - }, - 'heartbeat.done': { - schema: HeartbeatDoneSchema, - description: 'Heartbeat produced content and (attempted to) deliver a notification.', - }, - 'heartbeat.skip': { - schema: HeartbeatSkipSchema, - description: 'Heartbeat fired but no notification was sent (HEARTBEAT_OK, duplicate, outside active hours, or empty).', - }, - 'heartbeat.error': { - schema: HeartbeatErrorSchema, - description: 'Heartbeat invocation errored.', - }, 'message.received': { schema: MessageReceivedSchema, description: 'A user message arrived on a connector (Web chat, Telegram, etc.).', @@ -218,18 +186,22 @@ export const AgentEvents: { [K in keyof AgentEventMap]: AgentEventMeta } = { schema: MessageSentSchema, description: 'An assistant reply was dispatched on a connector.', }, - 'task.requested': { - schema: TaskRequestedSchema, + 'agent.work.requested': { + schema: AgentWorkRequestedSchema, external: true, - description: 'External caller asked Alice to run a one-shot task with the given prompt. Ingestible via POST /api/events/ingest.', + description: 'Canonical request to dispatch an AgentWork task. Carries `source` (which trigger produced it) plus the AI prompt. Ingestible via POST /api/events/ingest; the webhook layer also accepts the legacy `task.requested` event type and translates it to this canonical form.', + }, + 'agent.work.done': { + schema: AgentWorkDoneSchema, + description: 'An AgentWork task completed and its reply was dispatched. Filter on payload.source to attribute to a specific trigger (heartbeat / cron / task).', }, - 'task.done': { - schema: TaskDoneSchema, - description: 'A requested task completed and its reply was dispatched.', + 'agent.work.skip': { + schema: AgentWorkSkipSchema, + description: 'An AgentWork task was suppressed before delivery (dedup, empty content, outside active hours, AI declined to notify, …). Filter on payload.source for trigger attribution.', }, - 'task.error': { - schema: TaskErrorSchema, - description: 'A requested task failed during execution.', + 'agent.work.error': { + schema: AgentWorkErrorSchema, + description: 'An AgentWork task failed during execution. Filter on payload.source for trigger attribution.', }, } diff --git a/src/core/agent-work-listener.spec.ts b/src/core/agent-work-listener.spec.ts new file mode 100644 index 00000000..9556125e --- /dev/null +++ b/src/core/agent-work-listener.spec.ts @@ -0,0 +1,420 @@ +/** + * AgentWorkListener — comprehensive coverage of the canonical dispatch + * point for `agent.work.requested` events. + * + * Covers: source registry, dispatch by source field, canonical event + * emission with source + metadata, unknown-source drop behaviour, + * per-source gate application, multi-source independence. + * + * The AgentWorkRunner itself is covered in agent-work.spec.ts; this + * file tests the listener that sits in front of it. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' +import { createEventLog, type EventLog } from './event-log.js' +import { createListenerRegistry, type ListenerRegistry } from './listener-registry.js' +import { ConnectorCenter } from './connector-center.js' +import { createMemoryNotificationsStore, type INotificationsStore } from './notifications-store.js' +import { SessionStore } from './session.js' +import { AgentWorkRunner } from './agent-work.js' +import { createAgentWorkListener, type AgentWorkListener } from './agent-work-listener.js' +import type { AgentCenter } from './agent-center.js' +import type { ProviderResult, ToolCallSummary } from '../ai-providers/types.js' +import type { AgentWorkDonePayload, AgentWorkSkipPayload, AgentWorkErrorPayload } from './agent-event.js' + +// ==================== Helpers ==================== + +function tempPath(ext: string): string { + return join(tmpdir(), `agent-work-listener-test-${randomUUID()}.${ext}`) +} + +interface MockAgentCenter { + askWithSession: ReturnType + setResult(result: Partial): void + setShouldThrow(err: Error | null): void + callCount(): number +} + +function createMockAgentCenter(): MockAgentCenter { + let result: ProviderResult = { text: 'mock reply', media: [] } + let shouldThrow: Error | null = null + let calls = 0 + const askWithSession = vi.fn(async () => { + calls++ + if (shouldThrow) throw shouldThrow + return result + }) + return { + askWithSession, + setResult(next) { result = { text: 'mock reply', media: [], ...next } }, + setShouldThrow(err) { shouldThrow = err }, + callCount() { return calls }, + } +} + +// ==================== Test suite ==================== + +describe('AgentWorkListener', () => { + let eventLog: EventLog + let registry: ListenerRegistry + let mockAgent: MockAgentCenter + let store: INotificationsStore + let connectorCenter: ConnectorCenter + let runner: AgentWorkRunner + let listener: AgentWorkListener + + beforeEach(async () => { + eventLog = await createEventLog({ logPath: tempPath('jsonl') }) + registry = createListenerRegistry(eventLog) + await registry.start() + mockAgent = createMockAgentCenter() + store = createMemoryNotificationsStore() + connectorCenter = new ConnectorCenter({ notificationsStore: store }) + runner = new AgentWorkRunner({ + agentCenter: mockAgent as unknown as AgentCenter, + connectorCenter, + logger: { warn: vi.fn(), error: vi.fn() }, + }) + listener = createAgentWorkListener({ + runner, + registry, + logger: { warn: vi.fn(), error: vi.fn() }, + }) + await listener.start() + }) + + afterEach(async () => { + listener.stop() + await registry.stop() + await eventLog._resetForTest() + }) + + // ==================== Source registry ==================== + + describe('source registry', () => { + it('starts empty', () => { + expect(listener.listSources()).toEqual([]) + }) + + it('registers a source', () => { + listener.registerSource({ + source: 'cron', + session: new SessionStore('test/cron'), + preamble: () => 'cron context', + }) + expect(listener.listSources()).toEqual(['cron']) + }) + + it('registers multiple sources', () => { + listener.registerSource({ source: 'cron', session: new SessionStore('test/cron'), preamble: () => 'a' }) + listener.registerSource({ source: 'heartbeat', session: new SessionStore('test/hb'), preamble: () => 'b' }) + listener.registerSource({ source: 'task', session: new SessionStore('test/task'), preamble: () => 'c' }) + expect([...listener.listSources()].sort()).toEqual(['cron', 'heartbeat', 'task']) + }) + + it('re-registering the same source overwrites', () => { + const preamble1 = vi.fn(() => 'first') + const preamble2 = vi.fn(() => 'second') + listener.registerSource({ source: 'cron', session: new SessionStore('test/cron'), preamble: preamble1 }) + listener.registerSource({ source: 'cron', session: new SessionStore('test/cron'), preamble: preamble2 }) + expect(listener.listSources()).toEqual(['cron']) + // Verify the second config is the active one (will be confirmed via dispatch below in a separate test) + }) + }) + + // ==================== Dispatch ==================== + + describe('dispatch by source field', () => { + it('routes to the matching source config', async () => { + const preambleCron = vi.fn(() => 'cron preamble') + const preambleHb = vi.fn(() => 'hb preamble') + listener.registerSource({ source: 'cron', session: new SessionStore('test/cron'), preamble: preambleCron }) + listener.registerSource({ source: 'heartbeat', session: new SessionStore('test/hb'), preamble: preambleHb }) + + await eventLog.append('agent.work.requested', { + source: 'cron', + prompt: 'do cron work', + }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) + }) + expect(preambleCron).toHaveBeenCalled() + expect(preambleHb).not.toHaveBeenCalled() + }) + + it('emits agent.work.done with the source baked into payload', async () => { + listener.registerSource({ source: 'task', session: new SessionStore('test/task'), preamble: () => 'task' }) + mockAgent.setResult({ text: 'task reply' }) + + await eventLog.append('agent.work.requested', { + source: 'task', + prompt: 'task prompt', + }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) + }) + + const done = eventLog.recent({ type: 'agent.work.done' })[0].payload as AgentWorkDonePayload + expect(done.source).toBe('task') + expect(done.reply).toBe('task reply') + expect(done.delivered).toBe(true) + }) + + it('threads payload metadata through preamble', async () => { + const preamble = vi.fn((meta?: Record) => `job=${meta?.jobName ?? '?'}`) + listener.registerSource({ source: 'cron', session: new SessionStore('test/cron'), preamble }) + + await eventLog.append('agent.work.requested', { + source: 'cron', + prompt: 'p', + metadata: { jobName: 'daily-report', jobId: 'abc' }, + }) + await vi.waitFor(() => { + expect(preamble).toHaveBeenCalled() + }) + expect(preamble.mock.calls[0][0]).toEqual({ jobName: 'daily-report', jobId: 'abc' }) + }) + + it('passes metadata through to agent.work.done payload via default builder', async () => { + listener.registerSource({ source: 'cron', session: new SessionStore('test/cron'), preamble: () => 'p' }) + + await eventLog.append('agent.work.requested', { + source: 'cron', + prompt: 'p', + metadata: { jobId: 'job-1', jobName: 'daily' }, + }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) + }) + + const done = eventLog.recent({ type: 'agent.work.done' })[0].payload as AgentWorkDonePayload + expect(done.metadata).toEqual({ jobId: 'job-1', jobName: 'daily' }) + }) + + it('per-source buildDoneMetadata overrides the default', async () => { + listener.registerSource({ + source: 'cron', + session: new SessionStore('test/cron'), + preamble: () => 'p', + buildDoneMetadata: () => ({ derived: 'custom' }), + }) + + await eventLog.append('agent.work.requested', { + source: 'cron', + prompt: 'p', + metadata: { jobId: 'job-1' }, + }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) + }) + + const done = eventLog.recent({ type: 'agent.work.done' })[0].payload as AgentWorkDonePayload + expect(done.metadata).toEqual({ derived: 'custom' }) + }) + }) + + // ==================== Unknown source ==================== + + describe('unknown source', () => { + it('drops the event silently and logs a warning', async () => { + const logger = { warn: vi.fn(), error: vi.fn() } + // Re-create listener with our logger + listener.stop() + listener = createAgentWorkListener({ runner, registry, logger }) + await listener.start() + + // No source registered. Send an event. + await eventLog.append('agent.work.requested', { + source: 'cron', + prompt: 'orphan', + }) + + // Wait briefly to ensure handler had a chance to run + await new Promise((r) => setTimeout(r, 50)) + + expect(logger.warn).toHaveBeenCalled() + expect(logger.warn.mock.calls[0][0]).toContain("no source registered") + // No done / skip / error emitted + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(0) + expect(eventLog.recent({ type: 'agent.work.skip' })).toHaveLength(0) + expect(eventLog.recent({ type: 'agent.work.error' })).toHaveLength(0) + }) + }) + + // ==================== Gate application ==================== + + describe('outputGate from source config', () => { + /** notify_user-style gate, exercised end-to-end through the listener. */ + function notifyUserGate(probe: { text: string; media: unknown[]; toolCalls: ReadonlyArray }) { + const call = probe.toolCalls.find((c) => c.name === 'notify_user') + if (!call) return { kind: 'skip' as const, reason: 'ack', payload: {} } + const text = ((call.input ?? {}) as { text?: string }).text ?? '' + return { kind: 'deliver' as const, text, media: probe.media as never } + } + + it('delivers when AI calls notify_user', async () => { + listener.registerSource({ + source: 'heartbeat', + session: new SessionStore('test/hb'), + preamble: () => 'hb', + outputGate: notifyUserGate, + }) + mockAgent.setResult({ + text: 'raw', + toolCalls: [{ id: 't1', name: 'notify_user', input: { text: 'BTC alert' } }], + }) + + await eventLog.append('agent.work.requested', { + source: 'heartbeat', + prompt: 'check market', + }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) + }) + + const done = eventLog.recent({ type: 'agent.work.done' })[0].payload as AgentWorkDonePayload + // The runner delivers via connectorCenter; reply comes from buildDonePayload (result.text not the gate text) + expect(done.source).toBe('heartbeat') + expect(done.delivered).toBe(true) + + // What actually got pushed to the user is the gate's text + const { entries } = await store.read() + expect(entries[0].text).toBe('BTC alert') + }) + + it('skips when outputGate returns skip — emits agent.work.skip', async () => { + listener.registerSource({ + source: 'heartbeat', + session: new SessionStore('test/hb'), + preamble: () => 'hb', + outputGate: notifyUserGate, + }) + mockAgent.setResult({ text: 'no notify intent', toolCalls: [] }) + + await eventLog.append('agent.work.requested', { + source: 'heartbeat', + prompt: 'check market', + }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.skip' })).toHaveLength(1) + }) + + const skip = eventLog.recent({ type: 'agent.work.skip' })[0].payload as AgentWorkSkipPayload + expect(skip.source).toBe('heartbeat') + expect(skip.reason).toBe('ack') + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(0) + }) + + it('onDelivered runs on successful delivery, not on skip', async () => { + const onDelivered = vi.fn() + listener.registerSource({ + source: 'heartbeat', + session: new SessionStore('test/hb'), + preamble: () => 'hb', + outputGate: notifyUserGate, + onDelivered, + }) + + // Skip case — no notify_user call + mockAgent.setResult({ text: '', toolCalls: [] }) + await eventLog.append('agent.work.requested', { source: 'heartbeat', prompt: 'p1' }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.skip' })).toHaveLength(1) + }) + expect(onDelivered).not.toHaveBeenCalled() + + // Deliver case + mockAgent.setResult({ + text: 'r', + toolCalls: [{ id: 't1', name: 'notify_user', input: { text: 'real alert' } }], + }) + await eventLog.append('agent.work.requested', { source: 'heartbeat', prompt: 'p2' }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) + }) + expect(onDelivered).toHaveBeenCalledTimes(1) + expect(onDelivered.mock.calls[0][0]).toBe('real alert') + }) + }) + + // ==================== Errors ==================== + + describe('errors', () => { + it('emits agent.work.error with source on AI failure', async () => { + listener.registerSource({ + source: 'cron', + session: new SessionStore('test/cron'), + preamble: () => 'cron', + }) + mockAgent.setShouldThrow(new Error('AI down')) + + await eventLog.append('agent.work.requested', { source: 'cron', prompt: 'p' }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.error' })).toHaveLength(1) + }) + + const err = eventLog.recent({ type: 'agent.work.error' })[0].payload as AgentWorkErrorPayload + expect(err.source).toBe('cron') + expect(err.error).toBe('AI down') + }) + + it('per-source buildErrorMetadata flows through to the error payload', async () => { + listener.registerSource({ + source: 'cron', + session: new SessionStore('test/cron'), + preamble: () => 'cron', + buildErrorMetadata: (_req, err) => ({ jobId: 'failed-job', errorClass: err.name }), + }) + mockAgent.setShouldThrow(new Error('explode')) + + await eventLog.append('agent.work.requested', { + source: 'cron', + prompt: 'p', + metadata: { jobId: 'job-99' }, + }) + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.error' })).toHaveLength(1) + }) + + const err = eventLog.recent({ type: 'agent.work.error' })[0].payload as AgentWorkErrorPayload + expect(err.metadata).toEqual({ jobId: 'failed-job', errorClass: 'Error' }) + }) + }) + + // ==================== Concurrent independent sources ==================== + + describe('multi-source independence', () => { + it('two sources can be active and both work', async () => { + listener.registerSource({ source: 'cron', session: new SessionStore('test/cron'), preamble: () => 'a' }) + listener.registerSource({ source: 'task', session: new SessionStore('test/task'), preamble: () => 'b' }) + + await eventLog.append('agent.work.requested', { source: 'cron', prompt: 'cron work' }) + await eventLog.append('agent.work.requested', { source: 'task', prompt: 'task work' }) + + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(2) + }) + + const done = eventLog.recent({ type: 'agent.work.done' }).map(e => (e.payload as AgentWorkDonePayload).source) + expect(done.sort()).toEqual(['cron', 'task']) + }) + }) + + // ==================== Lifecycle ==================== + + describe('lifecycle', () => { + it('stop() removes the listener from the registry', async () => { + listener.registerSource({ source: 'cron', session: new SessionStore('test/cron'), preamble: () => 'p' }) + listener.stop() + + await eventLog.append('agent.work.requested', { source: 'cron', prompt: 'after stop' }) + await new Promise((r) => setTimeout(r, 50)) + + expect(mockAgent.callCount()).toBe(0) + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(0) + }) + }) +}) diff --git a/src/core/agent-work-listener.ts b/src/core/agent-work-listener.ts new file mode 100644 index 00000000..a832010c --- /dev/null +++ b/src/core/agent-work-listener.ts @@ -0,0 +1,179 @@ +/** + * AgentWorkListener — single dispatch point for all `agent.work.requested` + * events. + * + * Subscribes to one canonical event type; routes each event to the right + * per-source configuration (preamble, session, gates, payload-metadata + * builders) by looking up `payload.source` in a runtime registry. + * + * Each trigger source (heartbeat, cron, webhook) registers its config + * at startup. Adding a new trigger source = registering one more + * `AgentWorkSourceConfig`; no new listener, no new event type. + * + * The runner emits canonical `agent.work.{done,skip,error}` events + * with the source label baked into the payload — downstream consumers + * (Diary, etc.) filter on the source field rather than subscribing + * to per-source event types. + */ + +import type { Listener, ListenerContext } from './listener.js' +import type { ListenerRegistry } from './listener-registry.js' +import type { ISessionStore } from './session.js' +import type { NotificationSource } from './notifications-store.js' +import type { AgentWorkRequest, AgentWorkRunner, AgentWorkSkip } from './agent-work.js' +import type { ProviderResult } from '../ai-providers/types.js' +import type { AgentWorkRequestedPayload } from './agent-event.js' + +// ==================== Source config ==================== + +/** Per-source configuration. Each trigger source registers one of these + * at startup; the listener uses it to build an AgentWorkRequest when + * an event arrives with the matching `source` field. */ +export interface AgentWorkSourceConfig { + source: NotificationSource + /** Session scope for this source. All work from a given source shares + * the same conversation history. */ + session: ISessionStore + /** Build the AI history preamble. Receives the event's `metadata` + * so per-event context (e.g. cron job name) can be threaded in. */ + preamble: (metadata: Record | undefined) => string + /** Optional post-AI gate — decides deliver vs skip (with reason). */ + outputGate?: AgentWorkRequest['outputGate'] + /** Optional bookkeeping callback after a successful delivery. */ + onDelivered?: AgentWorkRequest['onDelivered'] + /** Source-specific metadata to attach to the `agent.work.done` payload. + * Defaults to passing through the request metadata. */ + buildDoneMetadata?: ( + req: AgentWorkRequest, + result: ProviderResult, + ) => Record | undefined + /** Source-specific metadata for `agent.work.skip` payloads. */ + buildSkipMetadata?: ( + req: AgentWorkRequest, + skip: AgentWorkSkip, + ) => Record | undefined + /** Source-specific metadata for `agent.work.error` payloads. */ + buildErrorMetadata?: ( + req: AgentWorkRequest, + err: Error, + ) => Record | undefined +} + +// ==================== Listener types ==================== + +const AGENT_WORK_EMITS = [ + 'agent.work.done', + 'agent.work.skip', + 'agent.work.error', +] as const +type AgentWorkEmits = typeof AGENT_WORK_EMITS + +export interface AgentWorkListenerOpts { + runner: AgentWorkRunner + registry: ListenerRegistry + /** Inject logger for tests (defaults to console). */ + logger?: Pick +} + +export interface AgentWorkListener { + start(): Promise + stop(): void + /** Register a source config. Idempotent on `config.source` — re-registering + * the same source overwrites the previous entry. */ + registerSource(config: AgentWorkSourceConfig): void + /** List registered source labels — surfaced by the Automation Flow UI. */ + listSources(): ReadonlyArray + /** Expose the raw listener for direct testing. */ + readonly listener: Listener<'agent.work.requested', AgentWorkEmits> +} + +// ==================== Factory ==================== + +export function createAgentWorkListener(opts: AgentWorkListenerOpts): AgentWorkListener { + const { runner, registry } = opts + const logger = opts.logger ?? console + const sources = new Map() + let registered = false + + const listener: Listener<'agent.work.requested', AgentWorkEmits> = { + name: 'agent-work-listener', + subscribes: 'agent.work.requested', + emits: AGENT_WORK_EMITS, + async handle( + entry, + ctx: ListenerContext, + ): Promise { + const payload = entry.payload as AgentWorkRequestedPayload + const config = sources.get(payload.source) + if (!config) { + // Unknown source — typically a misconfigured trigger or a + // legitimately-new source whose registration hasn't run yet. + // Don't emit (we don't know which source to attribute to); + // log loudly and drop. + logger.warn( + `agent-work-listener: no source registered for '${payload.source}'; dropping (prompt: ${payload.prompt.slice(0, 60)})`, + ) + return + } + + // Build the AgentWorkRequest from the event + source config. + // emitNames are FIXED canonical — the runner emits agent.work.* + // events regardless of source. The source field is baked into + // each payload via the build*Payload functions below. + const request: AgentWorkRequest = { + prompt: payload.prompt, + session: config.session, + preamble: config.preamble(payload.metadata), + metadata: { source: config.source, ...(payload.metadata ?? {}) }, + outputGate: config.outputGate, + onDelivered: config.onDelivered, + emitNames: { + done: 'agent.work.done', + skip: 'agent.work.skip', + error: 'agent.work.error', + }, + buildDonePayload: (req, result, durationMs, delivered) => ({ + source: config.source, + reply: result.text, + durationMs, + delivered, + metadata: config.buildDoneMetadata?.(req, result) ?? payload.metadata, + }), + buildSkipPayload: (req, skip) => ({ + source: config.source, + reason: skip.reason, + metadata: config.buildSkipMetadata?.(req, skip) ?? payload.metadata, + }), + buildErrorPayload: (req, err, durationMs) => ({ + source: config.source, + error: err.message, + durationMs, + metadata: config.buildErrorMetadata?.(req, err) ?? payload.metadata, + }), + } + + await runner.run(request, ctx.emit as never) + }, + } + + return { + listener, + async start() { + if (registered) return + registry.register(listener) + registered = true + }, + stop() { + if (registered) { + registry.unregister(listener.name) + registered = false + } + }, + registerSource(config) { + sources.set(config.source, config) + }, + listSources() { + return [...sources.keys()] + }, + } +} diff --git a/src/core/agent-work.spec.ts b/src/core/agent-work.spec.ts new file mode 100644 index 00000000..83894d02 --- /dev/null +++ b/src/core/agent-work.spec.ts @@ -0,0 +1,625 @@ +/** + * AgentWork — comprehensive coverage of the runner's pipeline behaviour. + * + * The runner is the load-bearing primitive for every "Alice does an + * async task outside chat" path (heartbeat / cron / task-router / + * future async triggers). Test coverage here is intentionally thorough: + * gate combinations, error paths, tool-call observation, hook + * misbehaviour. Trigger-source-specific tests (active-hours, dedup, + * STATUS replacement) live in the heartbeat / cron / task-router spec + * files; this file exercises the abstraction itself. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { AgentWorkRunner, type AgentWorkRequest, type AgentWorkEmitFn } from './agent-work.js' +import type { AgentCenter } from './agent-center.js' +import { ConnectorCenter } from './connector-center.js' +import { createMemoryNotificationsStore, type INotificationsStore } from './notifications-store.js' +import type { ProviderResult, ToolCallSummary } from '../ai-providers/types.js' + +// ==================== Helpers ==================== + +interface AgentCenterMock { + askWithSession: ReturnType + setResult(result: Partial): void + setShouldThrow(err: Error | null): void + callCount(): number + lastCall(): { prompt: string; preamble: string | undefined } | null +} + +/** Mocks AgentCenter.askWithSession with a Promise-shaped return. + * We exploit StreamableResult's PromiseLike contract — the runner + * awaits the result, so a plain Promise mock satisfies it. */ +function createMockAgentCenter(): AgentCenterMock { + let result: ProviderResult = { text: 'mock reply', media: [] } + let shouldThrow: Error | null = null + const calls: Array<{ prompt: string; preamble: string | undefined }> = [] + + const askWithSession = vi.fn(async (prompt: string, _session: unknown, opts?: { historyPreamble?: string }) => { + calls.push({ prompt, preamble: opts?.historyPreamble }) + if (shouldThrow) throw shouldThrow + return result + }) + + return { + askWithSession, + setResult(next) { result = { text: 'mock reply', media: [], ...next } }, + setShouldThrow(err) { shouldThrow = err }, + callCount() { return calls.length }, + lastCall() { return calls[calls.length - 1] ?? null }, + } +} + +/** Records emit() calls for assertion. */ +function createEmitRecorder() { + const events: Array<{ type: string; payload: object }> = [] + const emit: AgentWorkEmitFn = async (type, payload) => { + events.push({ type, payload }) + return { seq: events.length, ts: Date.now() } + } + return { events, emit } +} + +/** Minimal request factory — overlay caller-specific fields onto sane defaults. */ +function makeRequest(overrides: Partial = {}): AgentWorkRequest { + return { + prompt: 'do something', + session: { id: 'test/session' } as never, // session not introspected by runner; fake is fine + preamble: 'You are operating in test context.', + metadata: { source: 'cron' }, + emitNames: { done: 'cron.done', skip: 'cron.skip', error: 'cron.error' }, + buildDonePayload: (req, result, durationMs, delivered) => ({ + reply: result.text, + durationMs, + delivered, + }), + buildErrorPayload: (req, err, durationMs) => ({ + error: err.message, + durationMs, + }), + ...overrides, + } +} + +function createRunner(agentCenter: AgentCenterMock, store?: INotificationsStore) { + const notificationsStore = store ?? createMemoryNotificationsStore() + const connectorCenter = new ConnectorCenter({ notificationsStore }) + const logger = { warn: vi.fn(), error: vi.fn() } + const runner = new AgentWorkRunner({ + agentCenter: agentCenter as unknown as AgentCenter, + connectorCenter, + logger, + }) + return { runner, connectorCenter, notificationsStore, logger } +} + +// ==================== Tests ==================== + +describe('AgentWorkRunner — default behaviour (no gates)', () => { + let mock: AgentCenterMock + let runner: AgentWorkRunner + let store: INotificationsStore + let emitRec: ReturnType + + beforeEach(() => { + mock = createMockAgentCenter() + const made = createRunner(mock) + runner = made.runner + store = made.notificationsStore + emitRec = createEmitRecorder() + }) + + it('invokes AI with the prompt + preamble', async () => { + await runner.run(makeRequest({ prompt: 'hello', preamble: 'context X' }), emitRec.emit) + expect(mock.callCount()).toBe(1) + expect(mock.lastCall()?.prompt).toBe('hello') + expect(mock.lastCall()?.preamble).toBe('context X') + }) + + it('delivers result.text via connectorCenter.notify', async () => { + mock.setResult({ text: 'AI says hi' }) + await runner.run(makeRequest(), emitRec.emit) + const { entries } = await store.read() + expect(entries).toHaveLength(1) + expect(entries[0].text).toBe('AI says hi') + expect(entries[0].source).toBe('cron') + }) + + it('emits done with delivered=true and durationMs >= 0', async () => { + mock.setResult({ text: 'reply' }) + await runner.run(makeRequest(), emitRec.emit) + expect(emitRec.events).toHaveLength(1) + expect(emitRec.events[0].type).toBe('cron.done') + const payload = emitRec.events[0].payload as { reply: string; durationMs: number; delivered: boolean } + expect(payload.reply).toBe('reply') + expect(payload.delivered).toBe(true) + expect(payload.durationMs).toBeGreaterThanOrEqual(0) + }) + + it('does not emit skip or error on the happy path', async () => { + await runner.run(makeRequest(), emitRec.emit) + expect(emitRec.events.filter(e => e.type === 'cron.skip')).toHaveLength(0) + expect(emitRec.events.filter(e => e.type === 'cron.error')).toHaveLength(0) + }) + + it('returns outcome=delivered', async () => { + const result = await runner.run(makeRequest(), emitRec.emit) + expect(result.outcome).toBe('delivered') + }) +}) + +describe('AgentWorkRunner — inputGate', () => { + let mock: AgentCenterMock + let runner: AgentWorkRunner + let store: INotificationsStore + let logger: { warn: ReturnType; error: ReturnType } + let emitRec: ReturnType + + beforeEach(() => { + mock = createMockAgentCenter() + const made = createRunner(mock) + runner = made.runner + store = made.notificationsStore + logger = made.logger + emitRec = createEmitRecorder() + }) + + it('returns null → AI is invoked', async () => { + await runner.run( + makeRequest({ inputGate: () => null }), + emitRec.emit, + ) + expect(mock.callCount()).toBe(1) + }) + + it('returns skip → AI is NOT invoked', async () => { + await runner.run( + makeRequest({ + inputGate: () => ({ reason: 'outside-hours', payload: { reason: 'outside-hours' } }), + }), + emitRec.emit, + ) + expect(mock.callCount()).toBe(0) + }) + + it('skip → emits skip event with skip.payload', async () => { + await runner.run( + makeRequest({ + inputGate: () => ({ reason: 'outside-hours', payload: { reason: 'outside-hours', detail: 'asia/tokyo' } }), + }), + emitRec.emit, + ) + expect(emitRec.events).toHaveLength(1) + expect(emitRec.events[0].type).toBe('cron.skip') + expect(emitRec.events[0].payload).toEqual({ reason: 'outside-hours', detail: 'asia/tokyo' }) + }) + + it('skip → outcome=skipped with skipReason set', async () => { + const result = await runner.run( + makeRequest({ + inputGate: () => ({ reason: 'outside-hours', payload: {} }), + }), + emitRec.emit, + ) + expect(result.outcome).toBe('skipped') + expect(result.skipReason).toBe('outside-hours') + }) + + it('skip → no notification appended', async () => { + await runner.run( + makeRequest({ + inputGate: () => ({ reason: 'gated', payload: {} }), + }), + emitRec.emit, + ) + const { entries } = await store.read() + expect(entries).toHaveLength(0) + }) + + it('skip → buildSkipPayload override is used when provided', async () => { + await runner.run( + makeRequest({ + inputGate: () => ({ reason: 'gated', payload: { from: 'gate' } }), + buildSkipPayload: (_req, skip) => ({ reason: skip.reason, customField: 'override' }), + }), + emitRec.emit, + ) + expect(emitRec.events[0].payload).toEqual({ reason: 'gated', customField: 'override' }) + }) + + it('skip but emitNames.skip undefined → silent suppression with warning', async () => { + await runner.run( + makeRequest({ + emitNames: { done: 'cron.done', error: 'cron.error' }, // no skip + inputGate: () => ({ reason: 'gated', payload: {} }), + }), + emitRec.emit, + ) + expect(emitRec.events).toHaveLength(0) // nothing emitted + expect(logger.warn).toHaveBeenCalled() + expect(logger.warn.mock.calls[0][0]).toContain('skip=') + }) +}) + +describe('AgentWorkRunner — outputGate', () => { + let mock: AgentCenterMock + let runner: AgentWorkRunner + let store: INotificationsStore + let emitRec: ReturnType + + beforeEach(() => { + mock = createMockAgentCenter() + const made = createRunner(mock) + runner = made.runner + store = made.notificationsStore + emitRec = createEmitRecorder() + }) + + it('default (omitted) delivers result.text', async () => { + mock.setResult({ text: 'untouched reply' }) + await runner.run(makeRequest(), emitRec.emit) + const { entries } = await store.read() + expect(entries[0].text).toBe('untouched reply') + }) + + it('deliver decision uses the gate text not result.text', async () => { + mock.setResult({ text: 'raw AI text' }) + await runner.run( + makeRequest({ + outputGate: () => ({ kind: 'deliver', text: 'rewritten by gate', media: [] }), + }), + emitRec.emit, + ) + const { entries } = await store.read() + expect(entries[0].text).toBe('rewritten by gate') + }) + + it('skip decision → emits skip event with reason, no notify', async () => { + await runner.run( + makeRequest({ + outputGate: () => ({ kind: 'skip', reason: 'duplicate', payload: { reason: 'duplicate' } }), + }), + emitRec.emit, + ) + expect(emitRec.events.find(e => e.type === 'cron.skip')).toBeDefined() + expect(emitRec.events.find(e => e.type === 'cron.done')).toBeUndefined() + const { entries } = await store.read() + expect(entries).toHaveLength(0) + }) + + it('skip decision → outcome=skipped with reason', async () => { + const result = await runner.run( + makeRequest({ + outputGate: () => ({ kind: 'skip', reason: 'duplicate', payload: {} }), + }), + emitRec.emit, + ) + expect(result.outcome).toBe('skipped') + expect(result.skipReason).toBe('duplicate') + }) + + it('receives probe with text, media, toolCalls', async () => { + const observed: Array<{ text: string; mediaLen: number; toolCallCount: number }> = [] + mock.setResult({ + text: 'AI text', + media: [{ type: 'image', path: '/tmp/x.png' }], + toolCalls: [{ id: 't1', name: 'foo', input: { x: 1 } }], + }) + await runner.run( + makeRequest({ + outputGate: (probe) => { + observed.push({ + text: probe.text, + mediaLen: probe.media.length, + toolCallCount: probe.toolCalls.length, + }) + return { kind: 'deliver', text: probe.text, media: probe.media } + }, + }), + emitRec.emit, + ) + expect(observed).toHaveLength(1) + expect(observed[0]).toEqual({ text: 'AI text', mediaLen: 1, toolCallCount: 1 }) + }) + + it('toolCalls undefined in result → probe gets empty array', async () => { + let observedLen = -1 + mock.setResult({ text: 'reply' /* no toolCalls */ }) + await runner.run( + makeRequest({ + outputGate: (probe) => { + observedLen = probe.toolCalls.length + return { kind: 'deliver', text: probe.text, media: probe.media } + }, + }), + emitRec.emit, + ) + expect(observedLen).toBe(0) + }) +}) + +describe('AgentWorkRunner — notify_user-style tool inspection', () => { + let mock: AgentCenterMock + let runner: AgentWorkRunner + let store: INotificationsStore + let emitRec: ReturnType + + beforeEach(() => { + mock = createMockAgentCenter() + const made = createRunner(mock) + runner = made.runner + store = made.notificationsStore + emitRec = createEmitRecorder() + }) + + /** The reference outputGate shape that heartbeat will use to replace + * the STATUS regex protocol. Tested here so the AgentWork primitive + * guarantees this idiom keeps working. */ + function notifyUserGate(probe: { text: string; media: unknown[]; toolCalls: ReadonlyArray }) { + const call = probe.toolCalls.find((c) => c.name === 'notify_user') + if (!call) return { kind: 'skip' as const, reason: 'ack', payload: { reason: 'ack' } } + const text = ((call.input ?? {}) as { text?: string }).text ?? '' + if (!text.trim()) return { kind: 'skip' as const, reason: 'empty', payload: { reason: 'empty' } } + return { kind: 'deliver' as const, text, media: probe.media as never } + } + + it('AI calls notify_user → delivers tool args, not result.text', async () => { + mock.setResult({ + text: 'I have decided to notify the user', // raw AI text — should NOT be delivered + toolCalls: [{ id: 't1', name: 'notify_user', input: { text: 'BTC dropped 5%', urgency: 'important' } }], + }) + await runner.run(makeRequest({ outputGate: notifyUserGate }), emitRec.emit) + const { entries } = await store.read() + expect(entries).toHaveLength(1) + expect(entries[0].text).toBe('BTC dropped 5%') + }) + + it('AI does not call notify_user → skip with reason=ack', async () => { + mock.setResult({ text: 'nothing to report', toolCalls: [] }) + const result = await runner.run(makeRequest({ outputGate: notifyUserGate }), emitRec.emit) + expect(result.skipReason).toBe('ack') + expect((await store.read()).entries).toHaveLength(0) + }) + + it('AI calls notify_user with empty text → skip reason=empty', async () => { + mock.setResult({ + text: '', + toolCalls: [{ id: 't1', name: 'notify_user', input: { text: ' ' } }], + }) + const result = await runner.run(makeRequest({ outputGate: notifyUserGate }), emitRec.emit) + expect(result.skipReason).toBe('empty') + }) +}) + +describe('AgentWorkRunner — AI invocation errors', () => { + let mock: AgentCenterMock + let runner: AgentWorkRunner + let store: INotificationsStore + let emitRec: ReturnType + + beforeEach(() => { + mock = createMockAgentCenter() + const made = createRunner(mock) + runner = made.runner + store = made.notificationsStore + emitRec = createEmitRecorder() + }) + + it('AI throws → emits error event with caller-shaped payload', async () => { + mock.setShouldThrow(new Error('engine boom')) + await runner.run(makeRequest(), emitRec.emit) + expect(emitRec.events).toHaveLength(1) + expect(emitRec.events[0].type).toBe('cron.error') + expect(emitRec.events[0].payload).toMatchObject({ error: 'engine boom' }) + }) + + it('AI throws → outcome=errored', async () => { + mock.setShouldThrow(new Error('boom')) + const result = await runner.run(makeRequest(), emitRec.emit) + expect(result.outcome).toBe('errored') + }) + + it('AI throws → no notification appended', async () => { + mock.setShouldThrow(new Error('boom')) + await runner.run(makeRequest(), emitRec.emit) + expect((await store.read()).entries).toHaveLength(0) + }) + + it('AI throws → no done event emitted', async () => { + mock.setShouldThrow(new Error('boom')) + await runner.run(makeRequest(), emitRec.emit) + expect(emitRec.events.find(e => e.type === 'cron.done')).toBeUndefined() + }) + + it('AI throws non-Error → wraps in Error', async () => { + mock.setShouldThrow('string error' as unknown as Error) + await runner.run(makeRequest(), emitRec.emit) + expect(emitRec.events[0].payload).toMatchObject({ error: 'string error' }) + }) + + it('error event emit failure is logged, run does not throw', async () => { + mock.setShouldThrow(new Error('boom')) + const made = createRunner(mock) + const flakyEmit: AgentWorkEmitFn = vi.fn(async () => { + throw new Error('emit fail') + }) + // Should NOT throw despite emit failing + const result = await made.runner.run(makeRequest(), flakyEmit) + expect(result.outcome).toBe('errored') + expect(made.logger.error).toHaveBeenCalled() + }) +}) + +describe('AgentWorkRunner — notify failure', () => { + let mock: AgentCenterMock + let store: INotificationsStore + let emitRec: ReturnType + + beforeEach(() => { + mock = createMockAgentCenter() + store = createMemoryNotificationsStore() + emitRec = createEmitRecorder() + }) + + it('notify throw → done event emitted with delivered=false', async () => { + // Inject a connectorCenter whose notify throws + const connectorCenter = new ConnectorCenter({ notificationsStore: store }) + vi.spyOn(connectorCenter, 'notify').mockRejectedValue(new Error('notify boom')) + const logger = { warn: vi.fn(), error: vi.fn() } + const runner = new AgentWorkRunner({ + agentCenter: mock as unknown as AgentCenter, + connectorCenter, + logger, + }) + + await runner.run(makeRequest(), emitRec.emit) + + const doneEvent = emitRec.events.find(e => e.type === 'cron.done') + expect(doneEvent).toBeDefined() + expect((doneEvent!.payload as { delivered: boolean }).delivered).toBe(false) + expect(logger.warn).toHaveBeenCalled() + }) + + it('notify throw → outcome still delivered (work itself succeeded)', async () => { + const connectorCenter = new ConnectorCenter({ notificationsStore: store }) + vi.spyOn(connectorCenter, 'notify').mockRejectedValue(new Error('notify boom')) + const runner = new AgentWorkRunner({ + agentCenter: mock as unknown as AgentCenter, + connectorCenter, + logger: { warn: vi.fn(), error: vi.fn() }, + }) + const result = await runner.run(makeRequest(), emitRec.emit) + expect(result.outcome).toBe('delivered') + }) + + it('notify throw → onDelivered NOT called', async () => { + const connectorCenter = new ConnectorCenter({ notificationsStore: store }) + vi.spyOn(connectorCenter, 'notify').mockRejectedValue(new Error('notify boom')) + const runner = new AgentWorkRunner({ + agentCenter: mock as unknown as AgentCenter, + connectorCenter, + logger: { warn: vi.fn(), error: vi.fn() }, + }) + const onDelivered = vi.fn() + await runner.run(makeRequest({ onDelivered }), emitRec.emit) + expect(onDelivered).not.toHaveBeenCalled() + }) +}) + +describe('AgentWorkRunner — onDelivered hook', () => { + let mock: AgentCenterMock + let runner: AgentWorkRunner + let logger: { warn: ReturnType; error: ReturnType } + let emitRec: ReturnType + + beforeEach(() => { + mock = createMockAgentCenter() + const made = createRunner(mock) + runner = made.runner + logger = made.logger + emitRec = createEmitRecorder() + }) + + it('called with delivered text after successful notify', async () => { + mock.setResult({ text: 'hello' }) + const onDelivered = vi.fn() + await runner.run(makeRequest({ onDelivered }), emitRec.emit) + expect(onDelivered).toHaveBeenCalledTimes(1) + expect(onDelivered.mock.calls[0][0]).toBe('hello') + }) + + it('NOT called when outputGate skips', async () => { + const onDelivered = vi.fn() + await runner.run( + makeRequest({ + onDelivered, + outputGate: () => ({ kind: 'skip', reason: 'duplicate', payload: {} }), + }), + emitRec.emit, + ) + expect(onDelivered).not.toHaveBeenCalled() + }) + + it('NOT called when inputGate skips', async () => { + const onDelivered = vi.fn() + await runner.run( + makeRequest({ + onDelivered, + inputGate: () => ({ reason: 'gated', payload: {} }), + }), + emitRec.emit, + ) + expect(onDelivered).not.toHaveBeenCalled() + }) + + it('throw is caught, run completes, done emitted', async () => { + const onDelivered = vi.fn(() => { throw new Error('hook boom') }) + const result = await runner.run(makeRequest({ onDelivered }), emitRec.emit) + expect(result.outcome).toBe('delivered') + expect(emitRec.events.find(e => e.type === 'cron.done')).toBeDefined() + expect(logger.warn).toHaveBeenCalled() + }) +}) + +describe('AgentWorkRunner — clock injection', () => { + it('durationMs uses injected now()', async () => { + const mock = createMockAgentCenter() + const store = createMemoryNotificationsStore() + const connectorCenter = new ConnectorCenter({ notificationsStore: store }) + let t = 1000 + const runner = new AgentWorkRunner({ + agentCenter: mock as unknown as AgentCenter, + connectorCenter, + now: () => { + const v = t + t += 250 // every call advances by 250ms + return v + }, + logger: { warn: vi.fn(), error: vi.fn() }, + }) + const emitRec = createEmitRecorder() + await runner.run(makeRequest(), emitRec.emit) + const done = emitRec.events.find(e => e.type === 'cron.done')! + // start=1000, end=1250 → duration 250 + expect((done.payload as { durationMs: number }).durationMs).toBe(250) + }) +}) + +describe('AgentWorkRunner — source label flows through', () => { + it('connectorCenter receives metadata.source as the source label', async () => { + const mock = createMockAgentCenter() + const store = createMemoryNotificationsStore() + const connectorCenter = new ConnectorCenter({ notificationsStore: store }) + const notifySpy = vi.spyOn(connectorCenter, 'notify') + const runner = new AgentWorkRunner({ + agentCenter: mock as unknown as AgentCenter, + connectorCenter, + logger: { warn: vi.fn(), error: vi.fn() }, + }) + const emitRec = createEmitRecorder() + await runner.run(makeRequest({ metadata: { source: 'heartbeat' } }), emitRec.emit) + expect(notifySpy).toHaveBeenCalledWith( + 'mock reply', + expect.objectContaining({ source: 'heartbeat' }), + ) + }) +}) + +describe('AgentWorkRunner — concurrent runs (stateless runner)', () => { + it('two parallel run() calls do not interfere', async () => { + const mock = createMockAgentCenter() + const made = createRunner(mock) + const emit1 = createEmitRecorder() + const emit2 = createEmitRecorder() + const [r1, r2] = await Promise.all([ + made.runner.run(makeRequest({ prompt: 'A', metadata: { source: 'cron' } }), emit1.emit), + made.runner.run(makeRequest({ prompt: 'B', metadata: { source: 'task' } }), emit2.emit), + ]) + expect(r1.outcome).toBe('delivered') + expect(r2.outcome).toBe('delivered') + expect(mock.callCount()).toBe(2) + // Each emit recorder got its own done event + expect(emit1.events).toHaveLength(1) + expect(emit2.events).toHaveLength(1) + }) +}) diff --git a/src/core/agent-work.ts b/src/core/agent-work.ts new file mode 100644 index 00000000..c538e5eb --- /dev/null +++ b/src/core/agent-work.ts @@ -0,0 +1,297 @@ +/** + * AgentWork — core primitive for "Alice does an async task outside chat". + * + * The shape: a piece of work has a `prompt` (what to do), a `session` + * (continuity), a `preamble` (context hint), optional input/output + * gates, and a set of event names to emit on completion. + * + * The runner threads it through: + * inputGate → AI call → outputGate → notify → emit + * + * Three trigger sources today consume this primitive — heartbeat (with + * active-hours inputGate + notify_user-inspecting outputGate + dedup + * onDelivered), cron (no gates, default delivery), task-router (same). + * Future sources (factor mining, asset monitoring, etc.) plug in + * without re-implementing the gate→AI→notify→emit pipeline. + * + * The runner itself is stateless — construct once at startup with + * shared deps, call run() per request with the per-call emit fn from + * the listener context. + */ + +import type { AgentCenter } from './agent-center.js' +import type { ConnectorCenter } from './connector-center.js' +import type { ISessionStore } from './session.js' +import type { NotificationSource } from './notifications-store.js' +import type { ProviderResult, ToolCallSummary } from '../ai-providers/types.js' +import type { MediaAttachment } from './types.js' + +// ==================== Request / Result types ==================== + +/** Probe handed to outputGate — combines AI text/media plus the tool + * calls observed during generation. The latter is the mechanism by + * which structured tools like `notify_user` signal intent. */ +export interface AgentWorkResultProbe { + text: string + media: MediaAttachment[] + toolCalls: ReadonlyArray +} + +/** Skip decision — emitted as the configured `skip` event. */ +export interface AgentWorkSkip { + reason: string + /** Caller-shaped payload for the skip event. Free-form so each trigger + * source can attach its own metadata (parsedReason, reason-detail, …). */ + payload: object +} + +/** Output gate decision — deliver to the user or skip silently. */ +export type OutputGateDecision = + | { kind: 'deliver'; text: string; media: MediaAttachment[] } + | { kind: 'skip'; reason: string; payload: object } + +export interface AgentWorkRequest { + /** What Alice is asked to do (the AI prompt). */ + prompt: string + + /** Conversation scope. Same SessionStore reused across submissions + * from the same trigger source = continuous conversation. */ + session: ISessionStore + + /** Pre-prompt context — passed to agentCenter.askWithSession via + * AskOptions.historyPreamble. */ + preamble: string + + /** Used as connectorCenter.notify source label, plus available to + * gate functions for trigger-specific decisions. The source must + * match the NotificationSource union; adding a new trigger source + * means widening that union in notifications-store.ts. */ + metadata: { source: NotificationSource; [k: string]: unknown } + + /** Pre-AI guard. Return non-null to short-circuit (skip event emitted, + * AI never invoked). Used by heartbeat for active-hours filtering; + * used by cron's listener for own-job filtering (though that filter + * lives outside the request — pre-listener-handle, since it gates + * whether to even build the request). */ + inputGate?: (req: AgentWorkRequest) => AgentWorkSkip | null + + /** Post-AI gate. Decides notify vs skip based on AI result + observed + * tool calls. Default behaviour: deliver result.text unconditionally + * (matches today's cron / task-router semantics). */ + outputGate?: (probe: AgentWorkResultProbe, req: AgentWorkRequest) => OutputGateDecision + + /** Bookkeeping callback after a successful delivery — used by heartbeat + * to record the dedup window state. Not called for skip / error. */ + onDelivered?: (text: string, req: AgentWorkRequest) => void + + /** Names of the events this work emits. + * - done: on successful delivery (always) + * - error: on AI invocation throw (always) + * - skip: on inputGate / outputGate skip — REQUIRED if either gate + * can return a skip; the runner treats a missing skip name + * as a programming error and falls back to silent suppression. */ + emitNames: { done: string; skip?: string; error: string } + + /** Construct the payload for the `done` event. Caller-shaped so each + * trigger source can include its own identifiers (jobId, prompt, etc.). */ + buildDonePayload: ( + req: AgentWorkRequest, + result: ProviderResult, + durationMs: number, + delivered: boolean, + ) => object + + /** Construct the payload for the `skip` event. Defaults to the skip + * decision's `payload` field if not supplied. */ + buildSkipPayload?: (req: AgentWorkRequest, skip: AgentWorkSkip) => object + + /** Construct the payload for the `error` event. */ + buildErrorPayload: (req: AgentWorkRequest, err: Error, durationMs: number) => object +} + +export interface AgentWorkRunResult { + outcome: 'delivered' | 'skipped' | 'errored' + durationMs: number + /** When `outcome === 'skipped'`, the reason that was attached to the + * skip event. Useful for callers that want to do bookkeeping outside + * the event log. */ + skipReason?: string +} + +export interface AgentWorkRunnerDeps { + agentCenter: AgentCenter + connectorCenter: ConnectorCenter + /** Inject the wall clock for tests. */ + now?: () => number + /** Inject a logger for tests; defaults to console. */ + logger?: Pick +} + +/** The emit function shape — a permissive superset of the typed + * ListenerContext.emit signatures so the runner can accept any + * listener's emit without entangling its type generics. The caller + * is responsible for passing emit names that match its declared + * emits set; the runner doesn't validate. */ +export type AgentWorkEmitFn = ( + type: string, + payload: object, +) => Promise + +// ==================== Runner ==================== + +/** Stateless executor for AgentWork requests. Construct once with + * shared deps; call run(req, emit) per work submission. + * + * Class form (rather than free function) for parity with AgentCenter / + * ConnectorCenter / NotificationsStore — keeps `src/core/` style + * consistent and gives a stable hook point for future deps injection + * (rate limiting, observability, etc.) without API churn at call sites. */ +export class AgentWorkRunner { + private readonly agentCenter: AgentCenter + private readonly connectorCenter: ConnectorCenter + private readonly now: () => number + private readonly logger: Pick + + constructor(deps: AgentWorkRunnerDeps) { + this.agentCenter = deps.agentCenter + this.connectorCenter = deps.connectorCenter + this.now = deps.now ?? Date.now + this.logger = deps.logger ?? console + } + + async run( + req: AgentWorkRequest, + emit: AgentWorkEmitFn, + ): Promise { + const startMs = this.now() + + // ---- 1. inputGate ------------------------------------------------ + const skipBeforeAI = req.inputGate?.(req) + if (skipBeforeAI) { + await this.emitSkip(req, skipBeforeAI, emit) + return { + outcome: 'skipped', + durationMs: this.now() - startMs, + skipReason: skipBeforeAI.reason, + } + } + + // ---- 2. AI invocation ------------------------------------------- + let result: ProviderResult + try { + result = await this.agentCenter.askWithSession(req.prompt, req.session, { + historyPreamble: req.preamble, + }) + } catch (err) { + const e = err instanceof Error ? err : new Error(String(err)) + const durationMs = this.now() - startMs + try { + await emit( + req.emitNames.error, + req.buildErrorPayload(req, e, durationMs), + ) + } catch (emitErr) { + this.logger.error( + `agent-work[${req.metadata.source}]: emit error event failed:`, + emitErr, + ) + } + return { outcome: 'errored', durationMs } + } + + // ---- 3. outputGate ---------------------------------------------- + const probe: AgentWorkResultProbe = { + text: result.text, + media: result.media, + toolCalls: result.toolCalls ?? [], + } + const decision: OutputGateDecision = req.outputGate + ? req.outputGate(probe, req) + : { kind: 'deliver', text: result.text, media: result.media } + + if (decision.kind === 'skip') { + await this.emitSkip( + req, + { reason: decision.reason, payload: decision.payload }, + emit, + ) + return { + outcome: 'skipped', + durationMs: this.now() - startMs, + skipReason: decision.reason, + } + } + + // ---- 4. Notify -------------------------------------------------- + let delivered = false + try { + await this.connectorCenter.notify(decision.text, { + media: decision.media, + source: req.metadata.source, + }) + delivered = true + } catch (sendErr) { + // notify failure isn't fatal to the work — log and continue; + // the done event flag tells consumers whether the user actually + // got a push. Connectors that surface notifications make their + // own delivery decisions downstream. + this.logger.warn( + `agent-work[${req.metadata.source}]: notify failed:`, + sendErr, + ) + } + + // ---- 5. onDelivered hook --------------------------------------- + if (delivered) { + try { + req.onDelivered?.(decision.text, req) + } catch (hookErr) { + // Hook failure shouldn't lose the done event; log and proceed. + this.logger.warn( + `agent-work[${req.metadata.source}]: onDelivered hook threw:`, + hookErr, + ) + } + } + + // ---- 6. Emit done ---------------------------------------------- + const durationMs = this.now() - startMs + try { + await emit( + req.emitNames.done, + req.buildDonePayload(req, result, durationMs, delivered), + ) + } catch (emitErr) { + this.logger.error( + `agent-work[${req.metadata.source}]: emit done event failed:`, + emitErr, + ) + } + + return { outcome: 'delivered', durationMs } + } + + private async emitSkip( + req: AgentWorkRequest, + skip: AgentWorkSkip, + emit: AgentWorkEmitFn, + ): Promise { + if (!req.emitNames.skip) { + // Programming error — caller declared a gate that can return skip + // but didn't declare a skip event name. Log and silently drop. + this.logger.warn( + `agent-work[${req.metadata.source}]: skip='${skip.reason}' but no emitNames.skip configured — suppressing`, + ) + return + } + const payload = req.buildSkipPayload?.(req, skip) ?? skip.payload + try { + await emit(req.emitNames.skip, payload) + } catch (emitErr) { + this.logger.error( + `agent-work[${req.metadata.source}]: emit skip event failed:`, + emitErr, + ) + } + } +} diff --git a/src/core/duration.spec.ts b/src/core/duration.spec.ts new file mode 100644 index 00000000..5a427698 --- /dev/null +++ b/src/core/duration.spec.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { parseDuration } from './duration.js' + +describe('parseDuration', () => { + it('parses minutes-only', () => { + expect(parseDuration('30m')).toBe(30 * 60 * 1000) + }) + + it('parses hours-only', () => { + expect(parseDuration('1h')).toBe(60 * 60 * 1000) + }) + + it('parses seconds-only', () => { + expect(parseDuration('45s')).toBe(45 * 1000) + }) + + it('parses combined h+m+s', () => { + expect(parseDuration('2h15m30s')).toBe((2 * 3600 + 15 * 60 + 30) * 1000) + }) + + it('parses h+m subset', () => { + expect(parseDuration('1h30m')).toBe((3600 + 30 * 60) * 1000) + }) + + it('parses m+s subset', () => { + expect(parseDuration('5m30s')).toBe((5 * 60 + 30) * 1000) + }) + + it('trims surrounding whitespace', () => { + expect(parseDuration(' 30m ')).toBe(30 * 60 * 1000) + }) + + it('returns null for unparseable format', () => { + expect(parseDuration('30 minutes')).toBeNull() + expect(parseDuration('1d')).toBeNull() + expect(parseDuration('')).toBeNull() + expect(parseDuration('abc')).toBeNull() + }) + + it('returns null for zero-total duration', () => { + expect(parseDuration('0m')).toBeNull() + expect(parseDuration('0h0m0s')).toBeNull() + }) +}) diff --git a/src/core/duration.ts b/src/core/duration.ts new file mode 100644 index 00000000..c721fb56 --- /dev/null +++ b/src/core/duration.ts @@ -0,0 +1,22 @@ +/** + * Duration parsing shared between the cron-engine (user-defined cron jobs) + * and the Pump primitive (internal scheduled-task timers like heartbeat + * and snapshot). + * + * Format: `hms` with any subset of parts present in that order + * (e.g. "30m", "1h", "5m30s", "2h15m"). Trims whitespace. Zero-total + * durations return null — a zero-interval pump or cron job is almost + * certainly a config bug, so we surface it as "unparseable" rather than + * fire-forever-loop. + */ + +export function parseDuration(s: string): number | null { + const re = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/ + const m = re.exec(s.trim()) + if (!m) return null + const h = Number(m[1] ?? 0) + const min = Number(m[2] ?? 0) + const sec = Number(m[3] ?? 0) + if (h === 0 && min === 0 && sec === 0) return null + return (h * 3600 + min * 60 + sec) * 1000 +} diff --git a/src/core/listener-registry.spec.ts b/src/core/listener-registry.spec.ts index 7b310355..ef92b11c 100644 --- a/src/core/listener-registry.spec.ts +++ b/src/core/listener-registry.spec.ts @@ -223,13 +223,13 @@ describe('ListenerRegistry', () => { registry.register({ name: 'cron-echo', subscribes: 'cron.fire', - emits: ['cron.done'] as const, - async handle(entry, ctx) { - await ctx.emit('cron.done', { - jobId: entry.payload.jobId, - jobName: entry.payload.jobName, + emits: ['agent.work.done'] as const, + async handle(_entry, ctx) { + await ctx.emit('agent.work.done', { + source: 'cron', reply: 'ok', durationMs: 10, + delivered: true, }) }, }) @@ -240,10 +240,10 @@ describe('ListenerRegistry', () => { }) await flush() - const done = eventLog.recent({ type: 'cron.done' }) + const done = eventLog.recent({ type: 'agent.work.done' }) expect(done).toHaveLength(1) expect(done[0].causedBy).toBe(parent.seq) - expect(done[0].payload).toMatchObject({ jobId: 'j1', reply: 'ok' }) + expect(done[0].payload).toMatchObject({ source: 'cron', reply: 'ok' }) }) it('should reject emit of un-declared event type at runtime', async () => { @@ -254,12 +254,12 @@ describe('ListenerRegistry', () => { registry.register({ name: 'naughty', subscribes: 'cron.fire', - emits: ['cron.done'] as const, + emits: ['agent.work.done'] as const, async handle(_entry, ctx) { // Cast past the type system to simulate a misuse await (ctx.emit as unknown as (t: string, p: unknown) => Promise)( - 'heartbeat.done', - { reply: 'x', reason: '', durationMs: 0, delivered: false }, + 'message.sent', + { channel: 'web', to: 'x', prompt: 'p', reply: 'r', durationMs: 0 }, ) }, }) @@ -271,7 +271,7 @@ describe('ListenerRegistry', () => { // Error was caught by registry's error isolation and logged expect(errors.length).toBeGreaterThan(0) const msg = String((errors[0] as unknown[])[1]) - expect(msg).toMatch(/naughty.*heartbeat\.done.*declared emits.*cron\.done/) + expect(msg).toMatch(/naughty.*message\.sent.*declared emits.*agent\.work\.done/) } finally { console.error = origErr } @@ -287,8 +287,8 @@ describe('ListenerRegistry', () => { subscribes: 'cron.fire', async handle(_entry, ctx) { await (ctx.emit as unknown as (t: string, p: unknown) => Promise)( - 'cron.done', - { jobId: '', jobName: '', reply: '', durationMs: 0 }, + 'agent.work.done', + { source: 'cron', reply: '', durationMs: 0, delivered: false }, ) }, }) @@ -309,11 +309,11 @@ describe('ListenerRegistry', () => { registry.register({ name: 'cron-override', subscribes: 'cron.fire', - emits: ['cron.done'] as const, + emits: ['agent.work.done'] as const, async handle(_entry, ctx) { await ctx.emit( - 'cron.done', - { jobId: 'j1', jobName: 'x', reply: 'ok', durationMs: 0 }, + 'agent.work.done', + { source: 'cron', reply: 'ok', durationMs: 0, delivered: true }, { causedBy: 999 }, ) }, @@ -323,7 +323,7 @@ describe('ListenerRegistry', () => { await eventLog.append('cron.fire', { jobId: 'j1', jobName: 'x', payload: 'p' }) await flush() - const [done] = eventLog.recent({ type: 'cron.done' }) + const [done] = eventLog.recent({ type: 'agent.work.done' }) expect(done.causedBy).toBe(999) }) }) @@ -433,12 +433,12 @@ describe('ListenerRegistry', () => { await eventLog.append('cron.fire', { jobId: 'j', jobName: 'x', payload: 'p' }) await eventLog.append('message.received', { channel: 'web', to: 'default', prompt: 'hi' }) - await eventLog.append('heartbeat.skip', { reason: 'test' }) + await eventLog.append('agent.work.skip', { source: 'heartbeat', reason: 'test' }) await flush() expect(seenTypes.has('cron.fire')).toBe(true) expect(seenTypes.has('message.received')).toBe(true) - expect(seenTypes.has('heartbeat.skip')).toBe(true) + expect(seenTypes.has('agent.work.skip')).toBe(true) }) it('should ignore events not in AgentEventMap', async () => { @@ -468,12 +468,12 @@ describe('ListenerRegistry', () => { name: 'router', subscribes: 'cron.fire', emits: '*', - async handle(entry, ctx) { - await ctx.emit('cron.done', { - jobId: entry.payload.jobId, - jobName: entry.payload.jobName, + async handle(_entry, ctx) { + await ctx.emit('agent.work.done', { + source: 'cron', reply: 'ok', durationMs: 1, + delivered: true, }) }, }) @@ -482,7 +482,7 @@ describe('ListenerRegistry', () => { await eventLog.append('cron.fire', { jobId: 'j', jobName: 'x', payload: 'p' }) await flush() - const done = eventLog.recent({ type: 'cron.done' }) + const done = eventLog.recent({ type: 'agent.work.done' }) expect(done).toHaveLength(1) }) @@ -552,8 +552,8 @@ describe('ListenerRegistry', () => { }) await expect( (handle.emit as unknown as (t: string, p: unknown) => Promise)( - 'heartbeat.done', - { jobId: 'j', durationMs: 1 }, + 'agent.work.done', + { source: 'cron', reply: 'x', durationMs: 1, delivered: false }, ), ).rejects.toThrow(/cron-engine.*declared emits/) }) diff --git a/src/core/pump.spec.ts b/src/core/pump.spec.ts new file mode 100644 index 00000000..5bea61d0 --- /dev/null +++ b/src/core/pump.spec.ts @@ -0,0 +1,337 @@ +/** + * Pump — comprehensive coverage of the interval-scheduled callback + * primitive. Uses vitest's fake timers (vi.useFakeTimers) so we can + * advance the clock without sleeping. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { createPump } from './pump.js' + +describe('Pump', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + // ==================== Construction ==================== + + describe('construction', () => { + it('throws on invalid duration', () => { + expect(() => createPump({ + name: 'bad', + every: 'not-a-duration', + onTick: async () => {}, + })).toThrow(/invalid duration/) + }) + + it('throws on zero duration', () => { + expect(() => createPump({ + name: 'bad', + every: '0m', + onTick: async () => {}, + })).toThrow(/invalid duration/) + }) + + it('parses common formats successfully', () => { + expect(() => createPump({ name: 'a', every: '30m', onTick: async () => {} })).not.toThrow() + expect(() => createPump({ name: 'a', every: '1h', onTick: async () => {} })).not.toThrow() + expect(() => createPump({ name: 'a', every: '5m30s', onTick: async () => {} })).not.toThrow() + }) + }) + + // ==================== Lifecycle ==================== + + describe('lifecycle', () => { + it('does not fire before start()', async () => { + const onTick = vi.fn(async () => {}) + createPump({ name: 'p', every: '1m', onTick }) + await vi.advanceTimersByTimeAsync(10 * 60 * 1000) + expect(onTick).not.toHaveBeenCalled() + }) + + it('start() arms the timer and onTick fires after the interval', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.start() + await vi.advanceTimersByTimeAsync(60_000) + expect(onTick).toHaveBeenCalledTimes(1) + }) + + it('re-arms after a tick completes (recurring)', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.start() + await vi.advanceTimersByTimeAsync(60_000) + await vi.advanceTimersByTimeAsync(60_000) + await vi.advanceTimersByTimeAsync(60_000) + expect(onTick).toHaveBeenCalledTimes(3) + }) + + it('stop() clears the pending timer; no further fires', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.start() + pump.stop() + await vi.advanceTimersByTimeAsync(10 * 60 * 1000) + expect(onTick).not.toHaveBeenCalled() + }) + + it('stop() during in-flight tick: tick completes, no re-arm', async () => { + let resolveTick: () => void = () => {} + const onTick = vi.fn(() => new Promise((resolve) => { resolveTick = resolve })) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.start() + + await vi.advanceTimersByTimeAsync(60_000) // schedule first tick + + // Tick is now in flight (onTick promise pending). + pump.stop() + resolveTick() // let the tick complete + await vi.runAllTimersAsync() + + expect(onTick).toHaveBeenCalledTimes(1) + // No further fires + await vi.advanceTimersByTimeAsync(10 * 60 * 1000) + expect(onTick).toHaveBeenCalledTimes(1) + }) + + it('start() is idempotent — calling twice does not double-arm', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.start() + pump.start() + await vi.advanceTimersByTimeAsync(60_000) + expect(onTick).toHaveBeenCalledTimes(1) // not 2 + }) + }) + + // ==================== Enable / disable ==================== + + describe('setEnabled', () => { + it('disabled state — no fires even after start', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick, enabled: false }) + pump.start() + await vi.advanceTimersByTimeAsync(10 * 60 * 1000) + expect(onTick).not.toHaveBeenCalled() + expect(pump.isEnabled()).toBe(false) + }) + + it('setEnabled(true) on a disabled pump re-arms', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick, enabled: false }) + pump.start() + await vi.advanceTimersByTimeAsync(60_000) + expect(onTick).not.toHaveBeenCalled() + + pump.setEnabled(true) + await vi.advanceTimersByTimeAsync(60_000) + expect(onTick).toHaveBeenCalledTimes(1) + }) + + it('setEnabled(false) cancels the pending timer', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.start() + pump.setEnabled(false) + await vi.advanceTimersByTimeAsync(60_000) + expect(onTick).not.toHaveBeenCalled() + }) + + it('setEnabled is a no-op after stop()', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.start() + pump.stop() + pump.setEnabled(true) + await vi.advanceTimersByTimeAsync(60_000) + expect(onTick).not.toHaveBeenCalled() + }) + + it('setEnabled to same value is a no-op', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.start() + pump.setEnabled(true) // already true + await vi.advanceTimersByTimeAsync(60_000) + expect(onTick).toHaveBeenCalledTimes(1) // not double-armed + }) + }) + + // ==================== runNow ==================== + + describe('runNow', () => { + it('invokes onTick immediately', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + await pump.runNow() + expect(onTick).toHaveBeenCalledTimes(1) + }) + + it('runNow works without start() ever being called', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + await pump.runNow() + expect(onTick).toHaveBeenCalledTimes(1) + }) + + it('runNow is a no-op after stop()', async () => { + const onTick = vi.fn(async () => {}) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.stop() + await pump.runNow() + expect(onTick).not.toHaveBeenCalled() + }) + + it('runNow respects serial guard — awaits in-flight tick', async () => { + let resolveTick: () => void = () => {} + const onTick = vi.fn(() => new Promise((resolve) => { resolveTick = resolve })) + const pump = createPump({ name: 'p', every: '1m', onTick }) + pump.start() + await vi.advanceTimersByTimeAsync(60_000) // first tick in flight + + // runNow shouldn't fire a second concurrent tick + const runNowPromise = pump.runNow() + resolveTick() + await runNowPromise + + // Only one onTick invocation (the scheduled one) + expect(onTick).toHaveBeenCalledTimes(1) + }) + }) + + // ==================== Error backoff ==================== + + describe('error backoff', () => { + it('consecutive errors trigger increasing backoff', async () => { + let calls = 0 + const onTick = vi.fn(async () => { + calls++ + throw new Error('always fails') + }) + const pump = createPump({ + name: 'p', + every: '1m', + onTick, + errorBackoffMs: [30_000, 60_000, 300_000], + logger: { log: () => {}, warn: () => {}, error: () => {} }, + }) + pump.start() + + // first scheduled fire at 60s → throws + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(1) + + // next fire is backoff[0] = 30s later, not 60s + await vi.advanceTimersByTimeAsync(30_000) + expect(calls).toBe(2) + + // backoff[1] = 60s + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(3) + + // backoff[2] = 300s + await vi.advanceTimersByTimeAsync(300_000) + expect(calls).toBe(4) + + // clamped to last entry on more failures + await vi.advanceTimersByTimeAsync(300_000) + expect(calls).toBe(5) + }) + + it('successful tick resets consecutiveErrors', async () => { + let calls = 0 + let throwOnNext = true + const onTick = vi.fn(async () => { + calls++ + if (throwOnNext) throw new Error('fail') + }) + const pump = createPump({ + name: 'p', + every: '1m', + onTick, + errorBackoffMs: [30_000, 60_000], + logger: { log: () => {}, warn: () => {}, error: () => {} }, + }) + pump.start() + + await vi.advanceTimersByTimeAsync(60_000) // fail #1 + expect(calls).toBe(1) + + throwOnNext = false + await vi.advanceTimersByTimeAsync(30_000) // backoff[0]; succeeds + expect(calls).toBe(2) + + // Now next interval is back to normal 60s (errors reset) + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(3) + }) + + it('onTick error does not kill the pump', async () => { + let calls = 0 + const onTick = vi.fn(async () => { + calls++ + if (calls === 1) throw new Error('one bad apple') + }) + const pump = createPump({ + name: 'p', + every: '1m', + onTick, + errorBackoffMs: [30_000], + logger: { log: () => {}, warn: () => {}, error: () => {} }, + }) + pump.start() + + await vi.advanceTimersByTimeAsync(60_000) // fail + await vi.advanceTimersByTimeAsync(30_000) // succeed (backoff) + await vi.advanceTimersByTimeAsync(60_000) // back to normal + expect(calls).toBe(3) + }) + }) + + // ==================== Serial guard ==================== + + describe('serial guard', () => { + it('drops a fire when previous tick still in flight (serial=true default)', async () => { + let resolveTick: () => void = () => {} + let calls = 0 + const onTick = vi.fn(() => { + calls++ + return new Promise((resolve) => { resolveTick = resolve }) + }) + const pump = createPump({ + name: 'p', + every: '1m', + onTick, + logger: { log: () => {}, warn: () => {}, error: () => {} }, + }) + pump.start() + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(1) + // 60s later — onTick still pending. The next timer DOES fire but + // is dropped at the serial check; no extra onTick call. + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBe(1) + // Resolve the in-flight tick; pump re-arms + resolveTick() + await vi.runAllTimersAsync() + // After re-arm, the next fire happens at the configured interval + // (advance through it) + await vi.advanceTimersByTimeAsync(60_000) + expect(calls).toBeGreaterThanOrEqual(2) + }) + }) + + // ==================== Properties ==================== + + describe('properties', () => { + it('exposes name and every', () => { + const pump = createPump({ name: 'foo', every: '30m', onTick: async () => {} }) + expect(pump.name).toBe('foo') + expect(pump.every).toBe('30m') + }) + }) +}) diff --git a/src/core/pump.ts b/src/core/pump.ts new file mode 100644 index 00000000..d8829675 --- /dev/null +++ b/src/core/pump.ts @@ -0,0 +1,190 @@ +/** + * Pump — interval-scheduled callback primitive. + * + * The replacement for "register an internal cron job + listen for cron.fire + * + filter by jobName" for system services like heartbeat and snapshot that + * are just "fire onTick every N minutes". Owns a setTimeout chain (not + * setInterval, so the next fire is always spaced after onTick completes). + * + * Cron-engine remains for **user-defined** cron jobs (the Automation > Cron + * UI). Pump is the right primitive when: + * - The schedule is `every ` (no cron expressions) + * - The callback is private to one module (no need for event-log fan-out + * to other listeners) + * - Lifecycle (enable/disable, runNow, error backoff) belongs to the + * owning module + * + * Behaviour: + * - `serial: true` (default): a fire while a previous tick is in flight + * is dropped (logged), not queued + * - On onTick throw: log, increment consecutiveErrors, next fire is + * delayed by `errorBackoffMs[consecutiveErrors-1]` (capped at the + * last entry); reset on a successful tick + * - `stop()` is terminal — clears the pending timer, marks stopped; + * subsequent calls to `setEnabled` / `runNow` are no-ops + */ + +import { parseDuration } from './duration.js' + +const DEFAULT_ERROR_BACKOFF_MS: readonly number[] = [ + 30_000, // 30s + 60_000, // 1m + 300_000, // 5m + 900_000, // 15m + 3_600_000, // 1h +] + +export interface PumpOpts { + /** Identifier — used in log lines so multiple pumps stay distinguishable. */ + name: string + /** Tick interval, parsed via shared parseDuration ("30m", "1h", "5m30s"). */ + every: string + /** Initial enabled state. Default true. */ + enabled?: boolean + /** Called on each tick. May throw — pump catches and backoffs. */ + onTick: () => Promise + /** When true (default), a fire that arrives while the previous tick is + * still in flight is dropped. When false, ticks overlap freely. */ + serial?: boolean + /** Backoff schedule for consecutive errors. Index is + * `consecutiveErrors - 1`, clamped to the last entry. */ + errorBackoffMs?: readonly number[] + /** Inject clock for tests. */ + now?: () => number + /** Inject scheduler — vitest fake timers also work via the global. */ + setTimeoutFn?: typeof setTimeout + clearTimeoutFn?: typeof clearTimeout + /** Inject logger. */ + logger?: Pick +} + +export interface Pump { + /** Arm the timer if enabled. Idempotent — calling twice is a no-op. */ + start(): void + /** Terminal stop. Clears the pending timer; sets stopped flag. */ + stop(): void + /** Toggle enabled. `true` re-arms if not already armed; `false` clears + * the pending timer (in-flight tick continues but doesn't reschedule). */ + setEnabled(enabled: boolean): void + isEnabled(): boolean + /** Manually invoke onTick once now, outside the schedule. Returns when + * the tick completes (success or caught error). Respects serial guard: + * if a tick is already in flight, awaits the in-flight one. */ + runNow(): Promise + readonly every: string + readonly name: string +} + +export function createPump(opts: PumpOpts): Pump { + const name = opts.name + const every = opts.every + const onTick = opts.onTick + const serial = opts.serial ?? true + const backoff = opts.errorBackoffMs ?? DEFAULT_ERROR_BACKOFF_MS + const setTimeoutFn = opts.setTimeoutFn ?? setTimeout + const clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout + const logger = opts.logger ?? console + + const intervalMs = parseDuration(every) + if (intervalMs === null) { + throw new Error(`pump[${name}]: invalid duration '${every}'`) + } + + let enabled = opts.enabled ?? true + let stopped = false + let started = false + let processing = false + let consecutiveErrors = 0 + let timer: ReturnType | null = null + /** Promise of the in-flight tick (if any) — runNow can await this. */ + let inFlight: Promise | null = null + + function nextDelayMs(): number { + if (consecutiveErrors <= 0) return intervalMs! + const idx = Math.min(consecutiveErrors - 1, backoff.length - 1) + return backoff[idx] + } + + function armNext(): void { + if (stopped || !enabled) return + if (timer !== null) return // already armed + timer = setTimeoutFn(() => { + timer = null + void tick() + }, nextDelayMs()) + } + + function clearTimer(): void { + if (timer !== null) { + clearTimeoutFn(timer) + timer = null + } + } + + async function tick(): Promise { + if (stopped) return + if (serial && processing) { + // Drop this fire — the previous tick hasn't finished. Don't arm + // another timer; whichever tick wins eventually re-arms. + logger.warn(`pump[${name}]: tick dropped (still processing previous)`) + return + } + processing = true + const runOne = async (): Promise => { + try { + await onTick() + consecutiveErrors = 0 + } catch (err) { + consecutiveErrors++ + logger.error( + `pump[${name}]: onTick error (consecutive=${consecutiveErrors}):`, + err, + ) + } finally { + processing = false + if (inFlight === thisRun) inFlight = null + armNext() + } + } + const thisRun = runOne() + inFlight = thisRun + await thisRun + } + + return { + name, + every, + start() { + if (started || stopped) return + started = true + armNext() + }, + stop() { + stopped = true + clearTimer() + }, + setEnabled(next: boolean) { + if (stopped) return + if (enabled === next) return + enabled = next + if (next) { + armNext() + } else { + clearTimer() + } + }, + isEnabled() { + return enabled + }, + async runNow() { + if (stopped) return + // If a tick is already in flight (serial-protected), don't double-fire; + // just wait for it. Otherwise invoke a fresh tick. + if (processing && inFlight) { + await inFlight + return + } + await tick() + }, + } +} diff --git a/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/src/domain/trading/brokers/ccxt/CcxtBroker.ts index f23133c4..7ce6e4c3 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -25,6 +25,7 @@ import { type TpSlParams, } from '../types.js' import '../../contract-ext.js' +import { aggregateAccountFromPositions } from '../../position-math.js' import { buildPosition } from '../contract-builder.js' import { CCXT_CREDENTIAL_FIELDS, type CcxtBrokerConfig, type CcxtMarket, type FundingRate, type OrderBook, type OrderBookLevel } from './ccxt-types.js' import { MAX_INIT_RETRIES, INIT_RETRY_BASE_MS } from './ccxt-types.js' @@ -226,34 +227,51 @@ export class CcxtBroker implements IBroker { // Use the exchange's own default types (set in its CCXT class describe()). // Skip 'option' type — option markets are typically thousands of contracts // (Bybit alone has ~10k+) and rarely useful for automated trading. - const allTypes = (fmOpts['types'] ?? []) as string[] + const originalTypes = fmOpts['types'] + const allTypes = (originalTypes ?? []) as string[] const types = allTypes.length > 0 ? allTypes.filter(t => t !== 'option') : ['spot', 'linear', 'inverse'] // fallback for exchanges that don't declare types - const allMarkets: unknown[] = [] - for (const type of types) { - for (let attempt = 1; attempt <= MAX_INIT_RETRIES; attempt++) { - try { - const prevTypes = fmOpts['types'] - fmOpts['types'] = [type] - const markets = await origFetchMarkets(params) - fmOpts['types'] = prevTypes - allMarkets.push(...markets) - break - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - if (attempt < MAX_INIT_RETRIES) { - const delay = INIT_RETRY_BASE_MS * Math.pow(2, attempt - 1) - console.warn(`CcxtBroker[${accountId}]: fetchMarkets(${type}) attempt ${attempt}/${MAX_INIT_RETRIES} failed, retrying in ${delay}ms...`) - await new Promise(r => setTimeout(r, delay)) - } else { - console.warn(`CcxtBroker[${accountId}]: fetchMarkets(${type}) failed after ${MAX_INIT_RETRIES} attempts: ${msg} — skipping`) + try { + const allMarkets: unknown[] = [] + for (const type of types) { + let lastErr: unknown + let success = false + for (let attempt = 1; attempt <= MAX_INIT_RETRIES; attempt++) { + try { + fmOpts['types'] = [type] + const markets = await origFetchMarkets(params) + allMarkets.push(...markets) + success = true + break + } catch (err) { + lastErr = err + if (attempt < MAX_INIT_RETRIES) { + const delay = INIT_RETRY_BASE_MS * Math.pow(2, attempt - 1) + const msg = err instanceof Error ? err.message : String(err) + console.warn(`CcxtBroker[${accountId}]: fetchMarkets(${type}) attempt ${attempt}/${MAX_INIT_RETRIES} failed, retrying in ${delay}ms... (${msg.slice(0, 160)})`) + await new Promise(r => setTimeout(r, delay)) + } } } + if (!success) { + // A CCXT account is a full-spectrum interface — every market type + // the exchange supports must load, or the broker refuses to come + // up. Silently dropping a type (e.g. spot) would understate + // netLiquidation and hide real holdings, producing wrong snapshots + // forever until process restart. Whether the user actively trades + // that type is their decision, not ours. + const msg = lastErr instanceof Error ? lastErr.message : String(lastErr) + throw new Error( + `CcxtBroker[${accountId}]: fetchMarkets(${type}) failed after ${MAX_INIT_RETRIES} attempts: ${msg}`, + ) + } } + return allMarkets as Awaited> + } finally { + fmOpts['types'] = originalTypes } - return allMarkets as Awaited> } try { @@ -682,7 +700,7 @@ export class CcxtBroker implements IBroker { // snapshot that may not update between funding/settlement cycles). let unrealizedPnL = new Decimal(0) let realizedPnL = new Decimal(0) - let totalPositionValue = new Decimal(0) + const aggregateInputs: Array<{ side: 'long' | 'short'; marketValue: string }> = [] for (const p of rawPositions) { unrealizedPnL = unrealizedPnL.plus(new Decimal(String(p.unrealizedPnl ?? 0))) realizedPnL = realizedPnL.plus(new Decimal(String((p as unknown as Record).realizedPnl ?? 0))) @@ -691,7 +709,8 @@ export class CcxtBroker implements IBroker { const contractSize = new Decimal(String(p.contractSize ?? 1)) const quantity = contracts.mul(contractSize) const markPrice = new Decimal(String(p.markPrice ?? 0)) - totalPositionValue = totalPositionValue.plus(quantity.mul(markPrice)) + const side: 'long' | 'short' = p.side === 'short' ? 'short' : 'long' + aggregateInputs.push({ side, marketValue: quantity.mul(markPrice).toString() }) } // Fold spot holdings (BTC/ETH/etc balances) into position value. @@ -699,10 +718,10 @@ export class CcxtBroker implements IBroker { // an asset — so they count toward netLiquidation, not totalCashValue. const spotHoldings = await this.fetchSpotHoldings(balance) for (const sp of spotHoldings) { - totalPositionValue = totalPositionValue.plus(new Decimal(sp.marketValue)) + aggregateInputs.push({ side: 'long', marketValue: sp.marketValue }) } - const netLiquidation = free.plus(totalPositionValue) + const { netLiquidation } = aggregateAccountFromPositions(free, aggregateInputs) return { baseCurrency: 'USD', diff --git a/src/domain/trading/brokers/contract-builder.spec.ts b/src/domain/trading/brokers/contract-builder.spec.ts index c75196ea..8e4eb81e 100644 --- a/src/domain/trading/brokers/contract-builder.spec.ts +++ b/src/domain/trading/brokers/contract-builder.spec.ts @@ -143,4 +143,63 @@ describe('buildPosition — pass-through vs derive', () => { }) expect(p.avgCostSource).toBe('wallet') }) + + it('throws when OPT contract reaches buildPosition with multiplier=1 (upstream decode bug guard)', async () => { + // Simulates a broker callback path that constructs Contract directly + // (e.g. IBKR's request-bridge populates Contract from EWrapper args, not + // via buildContract+assertContract). If TWS misdecodes the option + // multiplier and the bridge passes through, buildPosition is the last + // line of defense before snapshot persists a 100x-undercount. + const { Contract } = await import('@traderalice/ibkr') + const rawContract = new Contract() + rawContract.symbol = 'AAPL' + rawContract.secType = 'OPT' + rawContract.lastTradeDateOrContractMonth = '20260720' + rawContract.strike = 150 + rawContract.right = 'C' + // No multiplier on the raw Contract — exactly the upstream-decode-loss case. + expect(() => buildPosition({ + contract: rawContract, + currency: 'USD', + side: 'long', + quantity: new Decimal(1), + avgCost: '5', + marketPrice: '5', + realizedPnL: '0', + })).toThrow(/multiplier='1'/) + }) + + it('throws when FOP contract reaches buildPosition with multiplier=1', async () => { + const { Contract } = await import('@traderalice/ibkr') + const rawContract = new Contract() + rawContract.symbol = 'ES' + rawContract.secType = 'FOP' + rawContract.lastTradeDateOrContractMonth = '20260620' + rawContract.strike = 5000 + rawContract.right = 'P' + expect(() => buildPosition({ + contract: rawContract, + currency: 'USD', + side: 'short', + quantity: new Decimal(1), + avgCost: '50', + marketPrice: '50', + realizedPnL: '0', + })).toThrow(/multiplier='1'/) + }) + + it('STK with multiplier=1 is allowed (canonical default)', () => { + const stkContract = buildContract({ + symbol: 'AAPL', secType: 'STK', exchange: 'SMART', currency: 'USD', + }) + expect(() => buildPosition({ + contract: stkContract, + currency: 'USD', + side: 'long', + quantity: new Decimal(100), + avgCost: '150', + marketPrice: '160', + realizedPnL: '0', + })).not.toThrow() + }) }) diff --git a/src/domain/trading/brokers/contract-builder.ts b/src/domain/trading/brokers/contract-builder.ts index f2509648..91b58daa 100644 --- a/src/domain/trading/brokers/contract-builder.ts +++ b/src/domain/trading/brokers/contract-builder.ts @@ -107,6 +107,22 @@ export function buildPosition(input: BuildPositionInput): Position { const multiplier = input.multiplier ?? (input.contract.multiplier ? input.contract.multiplier : '1') + // OPT/FOP positions with multiplier=1 are virtually always an upstream + // decode bug — every real US equity option / futures option has a + // contract multiplier > 1 (typically 100). A passing '1' here means the + // broker either failed to fetch the multiplier or dropped it during + // normalization; downstream marketValue / unrealizedPnL will be wrong + // by ~100x. Refuse to construct the Position rather than ship a silent + // 100x-underreported number. + if ((input.contract.secType === 'OPT' || input.contract.secType === 'FOP') + && (multiplier === '1' || multiplier === '')) { + throw new Error( + `buildPosition: ${input.contract.secType} ${input.contract.symbol ?? '?'} ` + + `has multiplier='${multiplier}' — upstream broker decode is missing the contract ` + + `multiplier. Position values would be off by ~100x.`, + ) + } + // Either pass-through or derive — never both. let marketValue: string let unrealizedPnL: string diff --git a/src/domain/trading/brokers/ibkr/IbkrBroker.ts b/src/domain/trading/brokers/ibkr/IbkrBroker.ts index 4129dd4f..3fcc16cc 100644 --- a/src/domain/trading/brokers/ibkr/IbkrBroker.ts +++ b/src/domain/trading/brokers/ibkr/IbkrBroker.ts @@ -36,6 +36,7 @@ import { type TpSlParams, } from '../types.js' import '../../contract-ext.js' +import { aggregateAccountFromPositions } from '../../position-math.js' import { RequestBridge } from './request-bridge.js' import { resolveSymbol } from './ibkr-contracts.js' import type { IbkrBrokerConfig } from './ibkr-types.js' @@ -267,15 +268,13 @@ export class IbkrBroker implements IBroker { if (!download) throw new BrokerError('NETWORK', 'Account data not yet available') const totalCashValue = new Decimal(download.values.get('TotalCashValue') ?? '0') - let totalMarketValue = new Decimal(0) let positionUnrealizedPnL = new Decimal(0) for (const pos of download.positions) { - totalMarketValue = totalMarketValue.plus(pos.marketValue) positionUnrealizedPnL = positionUnrealizedPnL.plus(pos.unrealizedPnL) } const netLiquidation = download.positions.length > 0 - ? totalCashValue.plus(totalMarketValue) + ? aggregateAccountFromPositions(totalCashValue, download.positions).netLiquidation : new Decimal(download.values.get('NetLiquidation') ?? '0') const unrealizedPnL = download.positions.length > 0 diff --git a/src/domain/trading/brokers/mock/MockBroker.spec.ts b/src/domain/trading/brokers/mock/MockBroker.spec.ts index 31062326..9714487c 100644 --- a/src/domain/trading/brokers/mock/MockBroker.spec.ts +++ b/src/domain/trading/brokers/mock/MockBroker.spec.ts @@ -626,3 +626,85 @@ describe('multiplier discipline (regression)', () => { expect(positions[0].quantity.toNumber()).toBeCloseTo(0.01) }) }) + +// QA finding 2026-05-09: across all brokers that self-compute netLiq via +// `cash + Σ(marketValue)` (Mock / IBKR / CCXT), a SELL-to-open short +// double-counts the position. The premium received is added to cash, AND +// the (unsigned) marketValue is added on top — leaving netLiq inflated by +// 2 × |marketValue|. For OPT this is amplified by the 100x multiplier and +// shows up as the "options direction is wrong" report from the community. +describe('short positions — netLiquidation aggregation', () => { + it('short OPT (SELL to open) leaves netLiquidation flat at mark = entry', async () => { + const acc = new MockBroker({ id: 'mock-paper', cash: 10_000 }) + const optionKey = 'SPY-20260117-P-580' + acc.setMarkPrice(optionKey, '5.80') + acc.externalTrade({ + nativeKey: optionKey, + side: 'SELL', + quantity: '1', + price: '5.80', + contract: { + symbol: 'SPY', secType: 'OPT', localSymbol: optionKey, + lastTradeDateOrContractMonth: '20260117', strike: 580, right: 'P', + multiplier: '100', + }, + }) + + const account = await acc.getAccount() + // Premium received → cash = 10000 + 1×5.80×100 = 10580 + expect(account.totalCashValue).toBe('10580') + // No PnL (mark unchanged from entry), so netLiq should equal the original equity. + // Pre-fix: getAccount returns 11160 (10580 + 580 mv added on top — short ignored). + expect(account.netLiquidation).toBe('10000') + }) + + it('short STK (SELL to open) leaves netLiquidation flat at mark = entry', async () => { + const acc = new MockBroker({ id: 'mock-paper', cash: 10_000 }) + acc.setMarkPrice('NVDA', '100') + acc.externalTrade({ + nativeKey: 'NVDA', + side: 'SELL', + quantity: '50', + price: '100', + contract: { symbol: 'NVDA', secType: 'STK' }, + }) + + const account = await acc.getAccount() + // Short proceeds → cash = 10000 + 50×100 = 15000 + expect(account.totalCashValue).toBe('15000') + // Mark unchanged → netLiq unchanged at 10000. + expect(account.netLiquidation).toBe('10000') + }) + + it('mixed long + short — netLiquidation reflects net equity', async () => { + const acc = new MockBroker({ id: 'mock-paper', cash: 10_000 }) + + // Long 10 NVDA @ $50, mark @ $60 → +$100 unrealized PnL + acc.setMarkPrice('NVDA', '60') + acc.externalTrade({ + nativeKey: 'NVDA', + side: 'BUY', + quantity: '10', + price: '50', + contract: { symbol: 'NVDA', secType: 'STK' }, + }) + // cash now = 10000 - 500 = 9500; long mv at mark = 600 → +100 PnL + + // Short 5 TSLA @ $200, mark @ $180 → +$100 unrealized PnL (mark dropped, good for short) + acc.setMarkPrice('TSLA', '180') + acc.externalTrade({ + nativeKey: 'TSLA', + side: 'SELL', + quantity: '5', + price: '200', + contract: { symbol: 'TSLA', secType: 'STK' }, + }) + // cash now = 9500 + 1000 = 10500; short notional at mark = 900 → +100 PnL + + const account = await acc.getAccount() + // cash = 10500; long contributes +600, short contributes -900 → netLiq = 10500 + 600 - 900 = 10200 + // Equivalently: starting equity 10000 + 100 (long PnL) + 100 (short PnL) = 10200 + expect(account.netLiquidation).toBe('10200') + expect(account.unrealizedPnL).toBe('200') + }) +}) diff --git a/src/domain/trading/brokers/mock/MockBroker.ts b/src/domain/trading/brokers/mock/MockBroker.ts index 117e0a0d..3a8a7967 100644 --- a/src/domain/trading/brokers/mock/MockBroker.ts +++ b/src/domain/trading/brokers/mock/MockBroker.ts @@ -31,7 +31,7 @@ import type { TpSlParams, } from '../types.js' import '../../contract-ext.js' -import { derivePositionMath } from '../../position-math.js' +import { derivePositionMath, aggregateAccountFromPositions } from '../../position-math.js' import { buildContract, buildPosition } from '../contract-builder.js' import type { SecType } from '../../contract-discipline.js' @@ -376,7 +376,7 @@ export class MockBroker implements IBroker { if (this._accountOverride) return this._accountOverride let unrealizedPnL = new Decimal(0) - let marketValueAcc = new Decimal(0) + const aggregateInputs: Array<{ side: 'long' | 'short'; marketValue: string }> = [] for (const pos of this._positions.values()) { const price = pos.marketPriceOverride ?? this._markPriceFor(pos.contract) ?? pos.avgCost const { marketValue, unrealizedPnL: pnl } = derivePositionMath({ @@ -386,13 +386,14 @@ export class MockBroker implements IBroker { multiplier: pos.contract.multiplier || '1', side: pos.side, }) - marketValueAcc = marketValueAcc.plus(marketValue) + aggregateInputs.push({ side: pos.side, marketValue }) unrealizedPnL = unrealizedPnL.plus(pnl) } + const { netLiquidation } = aggregateAccountFromPositions(this._cash, aggregateInputs) return { baseCurrency: 'USD', - netLiquidation: this._cash.plus(marketValueAcc).toString(), + netLiquidation: netLiquidation.toString(), totalCashValue: this._cash.toString(), unrealizedPnL: unrealizedPnL.toString(), realizedPnL: this._realizedPnL.toString(), diff --git a/src/domain/trading/position-math.spec.ts b/src/domain/trading/position-math.spec.ts index 869b3550..bef001d4 100644 --- a/src/domain/trading/position-math.spec.ts +++ b/src/domain/trading/position-math.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import Decimal from 'decimal.js' -import { derivePositionMath, pnlOf, multiplierToDecimal } from './position-math.js' +import { derivePositionMath, pnlOf, multiplierToDecimal, aggregateAccountFromPositions } from './position-math.js' describe('derivePositionMath', () => { it('STK long — multiplier=1: marketValue and PnL are simple products', () => { @@ -87,3 +87,52 @@ describe('multiplierToDecimal', () => { expect(multiplierToDecimal('0').toNumber()).toBe(1) }) }) + +describe('aggregateAccountFromPositions', () => { + it('cash only — no positions', () => { + const r = aggregateAccountFromPositions('10000', []) + expect(r.netLiquidation.toString()).toBe('10000') + expect(r.totalMarketValue.toString()).toBe('0') + }) + + it('long-only — netLiq = cash + Σ(marketValue)', () => { + const r = aggregateAccountFromPositions('5000', [ + { side: 'long', marketValue: '1500' }, + { side: 'long', marketValue: '2500' }, + ]) + expect(r.netLiquidation.toString()).toBe('9000') + expect(r.totalMarketValue.toString()).toBe('4000') + }) + + it('short-only — short marketValue subtracts (premium already in cash)', () => { + // Premium received from selling already lives in cash. The short's + // notional marketValue is a liability — subtract from equity. + const r = aggregateAccountFromPositions('10580', [ + { side: 'short', marketValue: '580' }, + ]) + expect(r.netLiquidation.toString()).toBe('10000') + expect(r.totalMarketValue.toString()).toBe('-580') + }) + + it('mixed long + short', () => { + // cash 10500, long mv +600 (NVDA 10@60), short notional 900 (TSLA 5@180) + // → netLiq = 10500 + 600 - 900 = 10200 + const r = aggregateAccountFromPositions('10500', [ + { side: 'long', marketValue: '600' }, + { side: 'short', marketValue: '900' }, + ]) + expect(r.netLiquidation.toString()).toBe('10200') + expect(r.totalMarketValue.toString()).toBe('-300') + }) + + it('accepts Decimal inputs equivalently to strings', () => { + const a = aggregateAccountFromPositions(new Decimal('1000'), [ + { side: 'short', marketValue: new Decimal('200') }, + ]) + const b = aggregateAccountFromPositions('1000', [ + { side: 'short', marketValue: '200' }, + ]) + expect(a.netLiquidation.toString()).toBe(b.netLiquidation.toString()) + expect(a.totalMarketValue.toString()).toBe(b.totalMarketValue.toString()) + }) +}) diff --git a/src/domain/trading/position-math.ts b/src/domain/trading/position-math.ts index d93c9e4d..f34dc5c0 100644 --- a/src/domain/trading/position-math.ts +++ b/src/domain/trading/position-math.ts @@ -77,3 +77,42 @@ export function pnlOf(input: PositionMathInput): string { const sideSign = input.side === 'long' ? 1 : -1 return qty.mul(mark.minus(avg)).mul(mult).mul(sideSign).toString() } + +// ==================== Account aggregation ==================== + +export interface AggregateInput { + side: 'long' | 'short' + marketValue: Decimal | string | number +} + +export interface AggregateOutput { + /** cash + Σ(marketValue × side_sign). */ + netLiquidation: Decimal + /** Σ(marketValue × side_sign). Signed: positive for net-long books, negative for net-short. */ + totalMarketValue: Decimal +} + +/** + * Compute account equity from cash + per-position marketValues. + * + * The `Position.marketValue` convention in this codebase is always-positive + * (notional), with side carried separately. Naively summing those into + * netLiquidation double-counts short positions: a SELL-to-open short adds + * its premium to cash AND its marketValue gets added on top, leaving netLiq + * inflated by 2 × |short marketValue|. This helper applies side sign during + * aggregation so short positions correctly subtract their notional from + * equity. Use it everywhere a broker's getAccount() builds netLiq from + * positions instead of reading an upstream-reported equity field. + */ +export function aggregateAccountFromPositions( + cash: Decimal | string | number, + positions: Iterable, +): AggregateOutput { + const cashDec = toDecimal(cash) + let total = new Decimal(0) + for (const p of positions) { + const mv = toDecimal(p.marketValue) + total = total.plus(p.side === 'short' ? mv.neg() : mv) + } + return { netLiquidation: cashDec.plus(total), totalMarketValue: total } +} diff --git a/src/domain/trading/snapshot/builder.ts b/src/domain/trading/snapshot/builder.ts index 3454fdc4..12c43710 100644 --- a/src/domain/trading/snapshot/builder.ts +++ b/src/domain/trading/snapshot/builder.ts @@ -6,6 +6,7 @@ * Never fabricates zero-value placeholders. */ +import { UNSET_DOUBLE } from '@traderalice/ibkr' import type { UnifiedTradingAccount } from '../UnifiedTradingAccount.js' import type { UTASnapshot, SnapshotTrigger } from './types.js' @@ -50,6 +51,11 @@ export async function buildSnapshot( marketValue: p.marketValue, unrealizedPnL: p.unrealizedPnL, realizedPnL: p.realizedPnL, + ...(p.contract.secType && { secType: p.contract.secType }), + ...(p.multiplier && p.multiplier !== '1' && { multiplier: p.multiplier }), + ...(p.contract.strike != null && p.contract.strike !== UNSET_DOUBLE && { strike: p.contract.strike }), + ...(p.contract.right && { right: p.contract.right }), + ...(p.contract.lastTradeDateOrContractMonth && { expiry: p.contract.lastTradeDateOrContractMonth }), })), openOrders: orders .filter(o => o.orderState.status === 'Submitted' || o.orderState.status === 'PreSubmitted') diff --git a/src/domain/trading/snapshot/scheduler.ts b/src/domain/trading/snapshot/scheduler.ts index 3bf6c75b..eb9c39e0 100644 --- a/src/domain/trading/snapshot/scheduler.ts +++ b/src/domain/trading/snapshot/scheduler.ts @@ -1,20 +1,20 @@ /** - * Snapshot scheduler — periodic snapshots via cron engine. + * Snapshot scheduler — periodic snapshots via Pump. * - * Registers a cron job (`__snapshot__`) and registers a `cron.fire` listener - * with the ListenerRegistry. When fired, captures snapshots for all accounts. + * Pre-AgentWork refactor, this registered an internal `__snapshot__` + * cron job and subscribed to `cron.fire` filtered by jobName. That was + * conceptual debt — snapshot is a state-persistence service, not an + * AI-work event consumer. It now drives a private Pump (interval-based + * timer) and calls into `snapshotService.takeAllSnapshots('scheduled')` + * directly. No event-log involvement at the timer layer. * - * Follows the same pattern as the heartbeat system. + * UTA post-push / post-reject hooks still call + * `snapshotService.takeSnapshot(id, trigger)` directly — those paths + * are unchanged. */ -import type { EventLogEntry } from '../../../core/event-log.js' -import type { CronFirePayload } from '../../../core/agent-event.js' -import type { CronEngine } from '../../../task/cron/engine.js' import type { SnapshotService } from './service.js' -import type { Listener } from '../../../core/listener.js' -import type { ListenerRegistry } from '../../../core/listener-registry.js' - -const SNAPSHOT_JOB_NAME = '__snapshot__' +import { createPump, type Pump } from '../../../core/pump.js' export interface SnapshotConfig { enabled: boolean @@ -24,72 +24,34 @@ export interface SnapshotConfig { export interface SnapshotScheduler { start(): Promise stop(): void - readonly listener: Listener<'cron.fire'> + /** Manually trigger a scheduled snapshot run (e.g. for tests / UI). */ + runNow(): Promise } export function createSnapshotScheduler(deps: { snapshotService: SnapshotService - cronEngine: CronEngine - registry: ListenerRegistry config: SnapshotConfig }): SnapshotScheduler { - const { snapshotService, cronEngine, registry, config } = deps - - let processing = false - let registered = false - - async function handleFire(entry: EventLogEntry): Promise { - const payload = entry.payload - if (payload.jobName !== SNAPSHOT_JOB_NAME) return - if (processing) return - - processing = true - try { - await snapshotService.takeAllSnapshots('scheduled') - } catch (err) { - console.warn('snapshot-scheduler: error:', err instanceof Error ? err.message : err) - } finally { - processing = false - } - } + const { snapshotService, config } = deps - const listener: Listener<'cron.fire'> = { + const pump: Pump = createPump({ name: 'snapshot', - subscribes: 'cron.fire', - handle: handleFire, - } + every: config.every, + enabled: config.enabled, + onTick: async () => { + await snapshotService.takeAllSnapshots('scheduled') + }, + }) return { - listener, async start() { - // Find or create the cron job - const existing = cronEngine.list().find(j => j.name === SNAPSHOT_JOB_NAME) - if (existing) { - await cronEngine.update(existing.id, { - schedule: { kind: 'every', every: config.every }, - enabled: config.enabled, - }) - } else { - await cronEngine.add({ - name: SNAPSHOT_JOB_NAME, - schedule: { kind: 'every', every: config.every }, - payload: '', - enabled: config.enabled, - }) - } - - // Register listener exactly once - if (!registered) { - registry.register(listener) - registered = true - } + pump.start() }, - stop() { - if (registered) { - registry.unregister(listener.name) - registered = false - } + pump.stop() + }, + async runNow() { + await pump.runNow() }, } } diff --git a/src/domain/trading/snapshot/snapshot.spec.ts b/src/domain/trading/snapshot/snapshot.spec.ts index cc7ebc85..01d32d6e 100644 --- a/src/domain/trading/snapshot/snapshot.spec.ts +++ b/src/domain/trading/snapshot/snapshot.spec.ts @@ -10,8 +10,6 @@ import type { UnifiedTradingAccountOptions } from '../UnifiedTradingAccount.js' import { MockBroker, makeContract, makePosition, makeOpenOrder } from '../brokers/mock/index.js' import { UTAManager } from '../uta-manager.js' import { createEventLog, type EventLog } from '../../../core/event-log.js' -import { createListenerRegistry, type ListenerRegistry } from '../../../core/listener-registry.js' -import { createCronEngine, type CronEngine } from '../../../task/cron/engine.js' import { buildSnapshot } from './builder.js' import { createSnapshotStore, type SnapshotStore } from './store.js' import { createSnapshotService, type SnapshotService } from './service.js' @@ -105,6 +103,39 @@ describe('Snapshot Builder', () => { expect(typeof snap!.positions[0].avgCost).toBe('string') }) + // #3b — OPT/FOP metadata round-trips into the snapshot so the UI can + // render strike / right / multiplier badges from a persisted snapshot + // without needing the broker contract registry to be in-memory. + it('OPT positions carry secType + multiplier + strike + right + expiry', async () => { + const optContract = makeContract({ symbol: 'SPY', aliceId: 'mock-paper|SPY-OPT' }) + optContract.secType = 'OPT' + optContract.lastTradeDateOrContractMonth = '20260117' + optContract.strike = 580 + optContract.right = 'P' + optContract.multiplier = '100' + broker.setPositions([makePosition({ contract: optContract, multiplier: '100' })]) + + const snap = await buildSnapshot(uta, 'manual') + expect(snap).not.toBeNull() + const p = snap!.positions[0] + expect(p.secType).toBe('OPT') + expect(p.multiplier).toBe('100') + expect(p.strike).toBe(580) + expect(p.right).toBe('P') + expect(p.expiry).toBe('20260117') + }) + + // STK positions skip the multiplier field (defaults to '1' — would be + // noise in every snapshot row). + it('STK positions omit multiplier when multiplier=1', async () => { + broker.setPositions([makePosition()]) // default STK, multiplier: '1' + const snap = await buildSnapshot(uta, 'manual') + expect(snap).not.toBeNull() + expect(snap!.positions[0]).not.toHaveProperty('multiplier') + expect(snap!.positions[0]).not.toHaveProperty('strike') + expect(snap!.positions[0]).not.toHaveProperty('right') + }) + // #4 it('only includes Submitted/PreSubmitted orders', async () => { const contract = makeContract({ symbol: 'AAPL' }) @@ -448,21 +479,10 @@ describe('Snapshot Service', () => { // ==================== Scheduler Tests ==================== describe('Snapshot Scheduler', () => { - let eventLog: EventLog - let listenerRegistry: ListenerRegistry - let cronEngine: CronEngine let scheduler: SnapshotScheduler let mockService: SnapshotService - beforeEach(async () => { - const logPath = tempPath('jsonl') - const storePath = tempPath('json') - eventLog = await createEventLog({ logPath }) - listenerRegistry = createListenerRegistry(eventLog) - await listenerRegistry.start() - cronEngine = createCronEngine({ registry: listenerRegistry, storePath }) - await cronEngine.start() - + beforeEach(() => { mockService = { takeSnapshot: vi.fn(async () => null), takeAllSnapshots: vi.fn(async () => {}), @@ -472,108 +492,76 @@ describe('Snapshot Scheduler', () => { scheduler = createSnapshotScheduler({ snapshotService: mockService, - cronEngine, - registry: listenerRegistry, config: { enabled: true, every: '15m' }, }) }) - afterEach(async () => { + afterEach(() => { scheduler?.stop() - cronEngine.stop() - await listenerRegistry.stop() - await eventLog._resetForTest() }) - // #26 - it('registers __snapshot__ cron job on start', async () => { + // #26: runNow invokes takeAllSnapshots + it('runNow invokes takeAllSnapshots("scheduled")', async () => { await scheduler.start() - - const jobs = cronEngine.list() - const snapshotJob = jobs.find(j => j.name === '__snapshot__') - expect(snapshotJob).toBeDefined() - expect(snapshotJob!.enabled).toBe(true) + await scheduler.runNow() + expect(mockService.takeAllSnapshots).toHaveBeenCalledWith('scheduled') }) - // #27 - it('reuses existing job on repeated start (idempotent)', async () => { + // #27: idempotent start + it('start is idempotent — multiple calls do not double-fire', async () => { await scheduler.start() - const jobsBefore = cronEngine.list().filter(j => j.name === '__snapshot__') - await scheduler.start() - const jobsAfter = cronEngine.list().filter(j => j.name === '__snapshot__') - - expect(jobsBefore).toHaveLength(1) - expect(jobsAfter).toHaveLength(1) - expect(jobsBefore[0].id).toBe(jobsAfter[0].id) - }) - - // #28 - it('fires takeAllSnapshots on cron.fire event', async () => { - await scheduler.start() - - // Trigger the cron job manually - const job = cronEngine.list().find(j => j.name === '__snapshot__')! - await cronEngine.runNow(job.id) - - // Give the async handler time to complete - await new Promise(r => setTimeout(r, 50)) - - expect(mockService.takeAllSnapshots).toHaveBeenCalledWith('scheduled') + await scheduler.runNow() + expect(mockService.takeAllSnapshots).toHaveBeenCalledTimes(1) }) - // #29 - it('ignores cron.fire for other jobs', async () => { - await scheduler.start() - - // Create a different job and fire it - const otherId = await cronEngine.add({ - name: 'other-job', - schedule: { kind: 'every', every: '1h' }, - payload: '', + // #28: disabled scheduler doesn't fire on its own (but runNow still works) + it('disabled config: pump does not auto-fire, runNow still works', async () => { + scheduler = createSnapshotScheduler({ + snapshotService: mockService, + config: { enabled: false, every: '15m' }, }) - await cronEngine.runNow(otherId) - - await new Promise(r => setTimeout(r, 50)) - - expect(mockService.takeAllSnapshots).not.toHaveBeenCalled() + await scheduler.start() + // No auto-fire — runNow explicitly invokes + await scheduler.runNow() + expect(mockService.takeAllSnapshots).toHaveBeenCalledTimes(1) }) - // #30 + // #29: serial guard prevents concurrent fires it('processing lock prevents concurrent fires', async () => { - // Make takeAllSnapshots slow - let resolveFirst: () => void + let resolveFirst: () => void = () => {} const firstCall = new Promise(r => { resolveFirst = r }) - ;(mockService.takeAllSnapshots as any).mockImplementationOnce(async () => { - await firstCall - }) + ;(mockService.takeAllSnapshots as ReturnType) + .mockImplementationOnce(async () => { await firstCall }) await scheduler.start() - const job = cronEngine.list().find(j => j.name === '__snapshot__')! - - // Fire twice quickly - await cronEngine.runNow(job.id) - await new Promise(r => setTimeout(r, 10)) - await cronEngine.runNow(job.id) - await new Promise(r => setTimeout(r, 10)) - - // Second fire should be skipped (processing=true) - resolveFirst!() - await new Promise(r => setTimeout(r, 50)) - + // Kick off two concurrent runNow calls; the second should await the first. + const r1 = scheduler.runNow() + const r2 = scheduler.runNow() + resolveFirst() + await Promise.all([r1, r2]) + // Only one invocation — second was coalesced into the in-flight one expect(mockService.takeAllSnapshots).toHaveBeenCalledTimes(1) }) - // #31 - it('stop() unsubscribes from events', async () => { + // #30: stop terminates the pump + it('stop() prevents future runNow from firing', async () => { await scheduler.start() scheduler.stop() + await scheduler.runNow() + expect(mockService.takeAllSnapshots).not.toHaveBeenCalled() + }) - const job = cronEngine.list().find(j => j.name === '__snapshot__')! - await cronEngine.runNow(job.id) - await new Promise(r => setTimeout(r, 50)) + // #31: snapshot service errors are swallowed at the pump level (not fatal) + it('takeAllSnapshots error does not crash the scheduler', async () => { + ;(mockService.takeAllSnapshots as ReturnType) + .mockRejectedValueOnce(new Error('takeAll fail')) - expect(mockService.takeAllSnapshots).not.toHaveBeenCalled() + await scheduler.start() + await scheduler.runNow() // doesn't throw + // pump still functional + await scheduler.runNow() + expect(mockService.takeAllSnapshots).toHaveBeenCalledTimes(2) }) }) diff --git a/src/domain/trading/snapshot/types.ts b/src/domain/trading/snapshot/types.ts index 337334be..8892be83 100644 --- a/src/domain/trading/snapshot/types.ts +++ b/src/domain/trading/snapshot/types.ts @@ -37,6 +37,16 @@ export interface UTASnapshot { marketValue: string unrealizedPnL: string realizedPnL: string + /** Contract metadata captured from the broker. Persisted so the UI can + * re-render OPT/FUT/FOP positions (multiplier badge, strike, right, + * expiry tag) without rehydrating the broker's contract registry — + * the broker session that wrote the snapshot may not be running when + * the snapshot is read back. */ + secType?: string + multiplier?: string + strike?: number + right?: string + expiry?: string }> openOrders: Array<{ diff --git a/src/main.ts b/src/main.ts index 6e000456..038addee 100644 --- a/src/main.ts +++ b/src/main.ts @@ -35,6 +35,8 @@ import { ConnectorCenter } from './core/connector-center.js' import { createNotificationsStore } from './core/notifications-store.js' import { ToolCenter } from './core/tool-center.js' import { AgentCenter } from './core/agent-center.js' +import { AgentWorkRunner } from './core/agent-work.js' +import { createNotifyUserTool } from './tool/notify-user.js' import { GenerateRouter } from './core/ai-provider-manager.js' import { VercelAIProvider } from './ai-providers/vercel-ai-sdk/vercel-provider.js' import { AgentSdkProvider } from './ai-providers/agent-sdk/agent-sdk-provider.js' @@ -46,7 +48,7 @@ import { createEventBus } from './core/event-bus.js' import { createCronEngine, createCronListener, createCronTools } from './task/cron/index.js' import { createHeartbeat } from './task/heartbeat/index.js' import { createMetricsListener } from './task/metrics/index.js' -import { createTaskRouter } from './task/task-router/index.js' +import { createAgentWorkListener } from './core/agent-work-listener.js' import { NewsCollectorStore, NewsCollector } from './domain/news/index.js' import { createNewsArchiveTools } from './tool/news.js' @@ -244,6 +246,7 @@ async function main() { } toolCenter.register(createAnalysisTools(equityClient, cryptoClient, currencyClient, commodityClient), 'analysis') toolCenter.register(createEconomyTools(economyClient, commodityClient), 'economy') + toolCenter.register(createNotifyUserTool(), 'notify') console.log(`tool-center: ${toolCenter.list().length} tools registered`) @@ -278,37 +281,80 @@ async function main() { // Session awareness tools (registered here because they need connectorCenter) toolCenter.register(createSessionTools(connectorCenter), 'session') + // ==================== AgentWork runner — shared by all autonomous trigger sources ==================== + + const agentWorkRunner = new AgentWorkRunner({ agentCenter, connectorCenter }) + + // ==================== AgentWork Listener (single dispatch point) ==================== + // + // Owns all `agent.work.requested` traffic. Each trigger source + // (cron / heartbeat / webhook) registers its source config and + // emits the canonical event; the listener routes by source field + // and runs the AgentWork pipeline. + + const agentWorkListener = createAgentWorkListener({ + runner: agentWorkRunner, + registry: listenerRegistry, + }) + await agentWorkListener.start() + + // Register the `task` (webhook-triggered) source inline. Unlike + // heartbeat and cron, there's no listener-side wrapper — the + // webhook ingest endpoint emits agent.work.requested directly + // (or translates the legacy task.requested wire format). + const taskSession = new SessionStore('task/default') + await taskSession.restore() + agentWorkListener.registerSource({ + source: 'task', + session: taskSession, + preamble: () => + 'You are handling an externally-triggered task (session: task/default). Follow the prompt and reply with what the caller needs.', + buildDoneMetadata: (req) => ({ prompt: req.prompt }), + buildErrorMetadata: (req) => ({ prompt: req.prompt }), + }) + + // ==================== One-time migration: clean orphan internal cron jobs ==================== + // + // Prior to the Pump refactor, heartbeat and snapshot registered their + // schedules as internal cron jobs (__heartbeat__, __snapshot__) inside + // the cron engine. They now own private Pumps. Any pre-existing entries + // in data/cron/jobs.json are orphan disk state — clean them up so + // they don't show in the Automation > Cron UI and don't take up + // memory in cron-engine. + for (const job of cronEngine.list()) { + if (job.name.startsWith('__') && job.name.endsWith('__')) { + console.log(`cron: removing orphan internal job ${job.name} (${job.id})`) + await cronEngine.remove(job.id) + } + } + // ==================== Cron Listener ==================== const cronSession = new SessionStore('cron/default') await cronSession.restore() - const cronListener = createCronListener({ connectorCenter, agentCenter, registry: listenerRegistry, session: cronSession }) + const cronListener = createCronListener({ agentWorkListener, registry: listenerRegistry, session: cronSession }) await cronListener.start() - // ==================== Snapshot Scheduler ==================== + // ==================== Snapshot Scheduler (Pump-driven) ==================== - const snapshotScheduler = createSnapshotScheduler({ snapshotService, cronEngine, registry: listenerRegistry, config: config.snapshot }) + const snapshotScheduler = createSnapshotScheduler({ snapshotService, config: config.snapshot }) await snapshotScheduler.start() if (config.snapshot.enabled) { console.log(`snapshot: scheduler started (every ${config.snapshot.every})`) } - // ==================== Heartbeat ==================== + // ==================== Heartbeat (Pump-driven) ==================== const heartbeat = createHeartbeat({ config: config.heartbeat, - connectorCenter, cronEngine, agentCenter, registry: listenerRegistry, + agentWorkListener, registry: listenerRegistry, + session: new SessionStore('heartbeat'), }) await heartbeat.start() if (config.heartbeat.enabled) { console.log(`heartbeat: enabled (every ${config.heartbeat.every})`) } - // ==================== Task Router (external `task.requested` handler) ==================== - - const taskRouter = createTaskRouter({ connectorCenter, agentCenter, registry: listenerRegistry }) - await taskRouter.start() - // ==================== Event Metrics (wildcard observer) ==================== const metricsListener = createMetricsListener({ registry: listenerRegistry }) diff --git a/src/task/cron/engine.ts b/src/task/cron/engine.ts index 0cbd05e0..f745ca55 100644 --- a/src/task/cron/engine.ts +++ b/src/task/cron/engine.ts @@ -17,6 +17,9 @@ import { dirname } from 'node:path' import { randomUUID } from 'node:crypto' import type { ListenerRegistry } from '../../core/listener-registry.js' import type { ProducerHandle } from '../../core/producer.js' +import { parseDuration } from '../../core/duration.js' + +export { parseDuration } // ==================== Types ==================== @@ -311,17 +314,6 @@ export function computeNextRun(schedule: CronSchedule, afterMs: number): number } } -export function parseDuration(s: string): number | null { - const re = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/ - const m = re.exec(s.trim()) - if (!m) return null - const h = Number(m[1] ?? 0) - const min = Number(m[2] ?? 0) - const sec = Number(m[3] ?? 0) - if (h === 0 && min === 0 && sec === 0) return null - return (h * 3600 + min * 60 + sec) * 1000 -} - /** * Minimal cron expression parser (minute hour dom month dow). * Returns the next fire time after `afterMs`, or null if unparseable. diff --git a/src/task/cron/listener.spec.ts b/src/task/cron/listener.spec.ts index 35d4af9c..3c18e164 100644 --- a/src/task/cron/listener.spec.ts +++ b/src/task/cron/listener.spec.ts @@ -9,6 +9,9 @@ import { SessionStore } from '../../core/session.js' import type { CronFirePayload } from './engine.js' import { ConnectorCenter } from '../../core/connector-center.js' import { createMemoryNotificationsStore } from '../../core/notifications-store.js' +import { AgentWorkRunner } from '../../core/agent-work.js' +import { createAgentWorkListener, type AgentWorkListener } from '../../core/agent-work-listener.js' +import type { AgentWorkDonePayload, AgentWorkErrorPayload } from '../../core/agent-event.js' function tempPath(ext: string): string { return join(tmpdir(), `cron-listener-test-${randomUUID()}.${ext}`) @@ -24,13 +27,11 @@ function createMockEngine(response = 'AI reply') { calls, setResponse(text: string) { response = text }, setShouldFail(val: boolean) { shouldFail = val }, - // Partial Engine mock — only askWithSession is needed askWithSession: vi.fn(async (prompt: string, session: SessionStore) => { calls.push({ prompt, session }) if (shouldFail) throw new Error('engine error') return { text: response, media: [] } }), - // Stubs for other Engine methods ask: vi.fn(), } } @@ -39,32 +40,39 @@ describe('cron listener', () => { let eventLog: EventLog let registry: ListenerRegistry let cronListener: CronListener + let agentWorkListener: AgentWorkListener let mockEngine: ReturnType let session: SessionStore - let logPath: string let connectorCenter: ConnectorCenter let notificationsStore: ReturnType beforeEach(async () => { - logPath = tempPath('jsonl') - eventLog = await createEventLog({ logPath }) + eventLog = await createEventLog({ logPath: tempPath('jsonl') }) registry = createListenerRegistry(eventLog) + await registry.start() mockEngine = createMockEngine() session = new SessionStore(`test/cron-${randomUUID()}`) notificationsStore = createMemoryNotificationsStore() connectorCenter = new ConnectorCenter({ notificationsStore }) - cronListener = createCronListener({ + const runner = new AgentWorkRunner({ + agentCenter: mockEngine as never, connectorCenter, - agentCenter: mockEngine as any, + }) + agentWorkListener = createAgentWorkListener({ runner, registry }) + await agentWorkListener.start() + + cronListener = createCronListener({ + agentWorkListener, registry, session, }) await cronListener.start() - await registry.start() }) afterEach(async () => { + cronListener.stop() + agentWorkListener.stop() await registry.stop() await eventLog._resetForTest() }) @@ -72,26 +80,24 @@ describe('cron listener', () => { // ==================== Basic functionality ==================== describe('event handling', () => { - it('should call engine.askWithSession on cron.fire', async () => { + it('emits agent.work.requested on cron.fire', async () => { await eventLog.append('cron.fire', { jobId: 'abc12345', jobName: 'test-job', payload: 'Check the market', } satisfies CronFirePayload) - // Wait for async handler await vi.waitFor(() => { - expect(mockEngine.askWithSession).toHaveBeenCalledTimes(1) + expect(eventLog.recent({ type: 'agent.work.requested' })).toHaveLength(1) }) - expect(mockEngine.askWithSession).toHaveBeenCalledWith( - 'Check the market', - session, - expect.objectContaining({ historyPreamble: expect.any(String) }), - ) + const req = eventLog.recent({ type: 'agent.work.requested' })[0].payload as { source: string; prompt: string; metadata: { jobId: string; jobName: string } } + expect(req.source).toBe('cron') + expect(req.prompt).toBe('Check the market') + expect(req.metadata).toEqual({ jobId: 'abc12345', jobName: 'test-job' }) }) - it('should write cron.done event on success', async () => { + it('downstream agent.work.done payload carries source=cron + reply', async () => { const fireEntry = await eventLog.append('cron.fire', { jobId: 'abc12345', jobName: 'test-job', @@ -99,26 +105,28 @@ describe('cron listener', () => { } satisfies CronFirePayload) await vi.waitFor(() => { - const done = eventLog.recent({ type: 'cron.done' }) - expect(done).toHaveLength(1) + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) }) - const done = eventLog.recent({ type: 'cron.done' }) - expect(done[0].payload).toMatchObject({ - jobId: 'abc12345', - jobName: 'test-job', - reply: 'AI reply', - }) - expect((done[0].payload as any).durationMs).toBeGreaterThanOrEqual(0) - expect(done[0].causedBy).toBe(fireEntry.seq) + const done = eventLog.recent({ type: 'agent.work.done' })[0] + const payload = done.payload as AgentWorkDonePayload + expect(payload.source).toBe('cron') + expect(payload.reply).toBe('AI reply') + expect(payload.durationMs).toBeGreaterThanOrEqual(0) + expect(payload.delivered).toBe(true) + expect(payload.metadata).toMatchObject({ jobId: 'abc12345', jobName: 'test-job' }) + // causality: done is caused by the requested event, which is caused by fire + expect(typeof done.causedBy).toBe('number') }) - it('should not react to other event types', async () => { - await eventLog.append('some.other.event', { data: 'hello' }) + // Note: pre-Pump refactor, cron-router filtered `__*__` jobs to avoid + // double-handling heartbeat / snapshot. After the Pump migration, these + // internal jobs no longer live in the cron engine at all — so the + // filter has been removed. Test deleted alongside the dead code. - // Give it a moment + it('does not react to other event types', async () => { + await eventLog.append('message.received' as never, { channel: 'web', to: 'x', prompt: 'p' }) await new Promise((r) => setTimeout(r, 50)) - expect(mockEngine.askWithSession).not.toHaveBeenCalled() }) }) @@ -126,9 +134,9 @@ describe('cron listener', () => { // ==================== Delivery ==================== describe('delivery', () => { - it('should append AI reply to the notifications store', async () => { - const delivered: string[] = [] - notificationsStore.onAppended((entry) => { delivered.push(entry.text) }) + it('appends AI reply to notifications store with source=cron', async () => { + const delivered: Array<{ text: string; source?: string }> = [] + notificationsStore.onAppended((entry) => { delivered.push({ text: entry.text, source: entry.source }) }) await eventLog.append('cron.fire', { jobId: 'abc12345', @@ -140,62 +148,37 @@ describe('cron listener', () => { expect(delivered).toHaveLength(1) }) - expect(delivered[0]).toBe('AI reply') - - const { entries } = await notificationsStore.read() - expect(entries[0].source).toBe('cron') - }) - - it('should handle notify failure gracefully', async () => { - // Force the underlying append to throw — cron listener must keep - // the loop alive (still emit cron.done). - notificationsStore.append = async () => { throw new Error('store failed') } - - await eventLog.append('cron.fire', { - jobId: 'abc12345', - jobName: 'test-job', - payload: 'Hello', - } satisfies CronFirePayload) - - await vi.waitFor(() => { - const done = eventLog.recent({ type: 'cron.done' }) - expect(done).toHaveLength(1) - }) + expect(delivered[0]).toEqual({ text: 'AI reply', source: 'cron' }) }) }) // ==================== Error handling ==================== describe('error handling', () => { - it('should write cron.error on engine failure', async () => { + it('emits agent.work.error on engine failure', async () => { mockEngine.setShouldFail(true) - const fireEntry = await eventLog.append('cron.fire', { + await eventLog.append('cron.fire', { jobId: 'abc12345', jobName: 'test-job', payload: 'Will fail', } satisfies CronFirePayload) await vi.waitFor(() => { - const errors = eventLog.recent({ type: 'cron.error' }) - expect(errors).toHaveLength(1) + expect(eventLog.recent({ type: 'agent.work.error' })).toHaveLength(1) }) - const errors = eventLog.recent({ type: 'cron.error' }) - expect(errors[0].payload).toMatchObject({ - jobId: 'abc12345', - jobName: 'test-job', - error: 'engine error', - }) - expect((errors[0].payload as any).durationMs).toBeGreaterThanOrEqual(0) - expect(errors[0].causedBy).toBe(fireEntry.seq) + const err = eventLog.recent({ type: 'agent.work.error' })[0].payload as AgentWorkErrorPayload + expect(err.source).toBe('cron') + expect(err.error).toBe('engine error') + expect(err.metadata).toMatchObject({ jobId: 'abc12345', jobName: 'test-job' }) }) }) // ==================== Lifecycle ==================== describe('lifecycle', () => { - it('should stop receiving events after registry.stop()', async () => { + it('stops emitting after registry.stop()', async () => { await registry.stop() await eventLog.append('cron.fire', { @@ -204,14 +187,13 @@ describe('cron listener', () => { payload: 'Should not fire', } satisfies CronFirePayload) - // Give it a moment await new Promise((r) => setTimeout(r, 50)) expect(mockEngine.askWithSession).not.toHaveBeenCalled() }) - it('should be idempotent on repeated start()', async () => { - await cronListener.start() // second call — should be a no-op + it('is idempotent on repeated start()', async () => { + await cronListener.start() // No error }) }) diff --git a/src/task/cron/listener.ts b/src/task/cron/listener.ts index e4366aae..8927bdef 100644 --- a/src/task/cron/listener.ts +++ b/src/task/cron/listener.ts @@ -1,37 +1,32 @@ /** - * Cron Listener — subscribes to `cron.fire` events from the EventLog - * and routes them through the AgentCenter for processing. + * Cron Listener — translates user-defined cron job fires into + * canonical `agent.work.requested` events. The agent-work-listener + * picks them up and runs the AI dispatch pipeline. * - * Flow: - * eventLog 'cron.fire' → agentCenter.askWithSession(payload, session) - * → connectorCenter.notify(reply) - * → ctx.emit 'cron.done' / 'cron.error' + * Serial-execution lock so concurrent fires don't overlap. No + * notification policy lives here — every successful cron reply is + * pushed (the AgentWork default). Cron jobs that want + * AI-decides-to-notify semantics can teach their prompt about + * `notify_user`; the source config registered here doesn't reference + * any output gate, so the default deliver-result.text behaviour wins. * - * The listener owns a dedicated SessionStore for cron conversations, - * independent of user chat sessions (Telegram, Web, etc.). + * Internal jobs (heartbeat / snapshot) no longer live in the cron + * engine — they each own their own Pump now. So there's no + * `__*__` filter needed in this listener anymore. */ import type { EventLogEntry } from '../../core/event-log.js' import type { CronFirePayload } from '../../core/agent-event.js' -import type { AgentCenter } from '../../core/agent-center.js' import { SessionStore } from '../../core/session.js' -import type { ConnectorCenter } from '../../core/connector-center.js' import type { Listener, ListenerContext } from '../../core/listener.js' import type { ListenerRegistry } from '../../core/listener-registry.js' +import type { AgentWorkListener, AgentWorkSourceConfig } from '../../core/agent-work-listener.js' -/** Internal jobs (prefixed with __) have dedicated handlers and should not be routed to the AI. */ -function isInternalJob(name: string): boolean { - return name.startsWith('__') && name.endsWith('__') -} - -// ==================== Types ==================== - -const CRON_EMITS = ['cron.done', 'cron.error'] as const +const CRON_EMITS = ['agent.work.requested'] as const type CronEmits = typeof CRON_EMITS export interface CronListenerOpts { - connectorCenter: ConnectorCenter - agentCenter: AgentCenter + agentWorkListener: AgentWorkListener /** Registry to auto-register this listener with. */ registry: ListenerRegistry /** Optional: inject a session for testing. Otherwise creates a dedicated cron session. */ @@ -39,23 +34,37 @@ export interface CronListenerOpts { } export interface CronListener { - /** Register the listener with the registry (idempotent). */ start(): Promise - /** Unregister the listener from the registry. */ stop(): void - /** Expose the raw Listener object (for testing `handle()` directly). */ readonly listener: Listener<'cron.fire', CronEmits> } -// ==================== Factory ==================== - export function createCronListener(opts: CronListenerOpts): CronListener { - const { connectorCenter, agentCenter, registry } = opts + const { agentWorkListener, registry } = opts const session = opts.session ?? new SessionStore('cron/default') let processing = false let registered = false + const sourceConfig: AgentWorkSourceConfig = { + source: 'cron', + session, + preamble: (metadata) => { + const jobName = (metadata as { jobName?: string } | undefined)?.jobName + return `You are operating in the cron job context (session: cron/default${jobName ? `, job: ${jobName}` : ''}). This is an automated cron job execution.` + }, + // No output gate — every successful reply is pushed (default + // AgentWork behaviour matches today's cron semantics). + buildDoneMetadata: (req) => { + const m = req.metadata as { jobId?: string; jobName?: string } + return { jobId: m.jobId, jobName: m.jobName } + }, + buildErrorMetadata: (req) => { + const m = req.metadata as { jobId?: string; jobName?: string } + return { jobId: m.jobId, jobName: m.jobName } + }, + } + const listener: Listener<'cron.fire', CronEmits> = { name: 'cron-router', subscribes: 'cron.fire', @@ -66,49 +75,17 @@ export function createCronListener(opts: CronListenerOpts): CronListener { ): Promise { const payload = entry.payload - // Guard: internal jobs (__heartbeat__, __snapshot__, etc.) have dedicated handlers - if (isInternalJob(payload.jobName)) return - - // Guard: skip if already processing (serial execution) if (processing) { console.warn(`cron-listener: skipping job ${payload.jobId} (already processing)`) return } processing = true - const startMs = Date.now() - try { - // Ask the AI engine with the cron payload - const result = await agentCenter.askWithSession(payload.payload, session, { - historyPreamble: `You are operating in the cron job context (session: cron/default, job: ${payload.jobName}). This is an automated cron job execution.`, - }) - - // Append to notifications store; connectors fan out via onAppended. - try { - await connectorCenter.notify(result.text, { - media: result.media, - source: 'cron', - }) - } catch (sendErr) { - console.warn(`cron-listener: notify failed for job ${payload.jobId}:`, sendErr) - } - - // Log success - await ctx.emit('cron.done', { - jobId: payload.jobId, - jobName: payload.jobName, - reply: result.text, - durationMs: Date.now() - startMs, - }) - } catch (err) { - console.error(`cron-listener: error processing job ${payload.jobId}:`, err) - - await ctx.emit('cron.error', { - jobId: payload.jobId, - jobName: payload.jobName, - error: err instanceof Error ? err.message : String(err), - durationMs: Date.now() - startMs, + await ctx.emit('agent.work.requested', { + source: 'cron', + prompt: payload.payload, + metadata: { jobId: payload.jobId, jobName: payload.jobName }, }) } finally { processing = false @@ -121,6 +98,7 @@ export function createCronListener(opts: CronListenerOpts): CronListener { async start() { if (registered) return registry.register(listener) + agentWorkListener.registerSource(sourceConfig) registered = true }, stop() { diff --git a/src/task/heartbeat/heartbeat.spec.ts b/src/task/heartbeat/heartbeat.spec.ts index d44e7652..ad5d3be8 100644 --- a/src/task/heartbeat/heartbeat.spec.ts +++ b/src/task/heartbeat/heartbeat.spec.ts @@ -1,24 +1,46 @@ +/** + * Heartbeat tests — Pump-driven trigger source. + * + * Post-Pump refactor, heartbeat no longer subscribes to cron.fire. + * It owns a private Pump. Tests trigger ticks via `heartbeat.runNow()` + * (which delegates to `pump.runNow()`) rather than `cronEngine.runNow()`. + * + * The full pipeline test path: + * heartbeat.runNow() + * → pump.runNow() → onTick + * → active-hours pre-filter (skip → emit agent.work.skip directly) + * → producer.emit('agent.work.requested') for the canonical event + * → agent-work-listener picks up the request + * → source-config-driven AgentWorkRunner.run() + * → notify_user inspection + dedup gate + * → emit agent.work.{done,skip,error} + */ + import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { join } from 'node:path' import { tmpdir } from 'node:os' import { randomUUID } from 'node:crypto' import { createEventLog, type EventLog } from '../../core/event-log.js' import { createListenerRegistry, type ListenerRegistry } from '../../core/listener-registry.js' -import { createCronEngine, type CronEngine } from '../cron/engine.js' import { createHeartbeat, - parseHeartbeatResponse, isWithinActiveHours, HeartbeatDedup, - HEARTBEAT_JOB_NAME, type Heartbeat, type HeartbeatConfig, } from './heartbeat.js' import { SessionStore } from '../../core/session.js' import { ConnectorCenter } from '../../core/connector-center.js' import { createMemoryNotificationsStore } from '../../core/notifications-store.js' +import { AgentWorkRunner } from '../../core/agent-work.js' +import { createAgentWorkListener, type AgentWorkListener } from '../../core/agent-work-listener.js' +import type { ToolCallSummary } from '../../ai-providers/types.js' +import type { + AgentWorkDonePayload, + AgentWorkSkipPayload, + AgentWorkErrorPayload, +} from '../../core/agent-event.js' -// Mock writeConfigSection to avoid disk writes in tests vi.mock('../../core/config.js', () => ({ writeConfigSection: vi.fn(async () => ({})), })) @@ -39,111 +61,89 @@ function makeConfig(overrides: Partial = {}): HeartbeatConfig { // ==================== Mock Engine ==================== -const CHAT_YES_RESPONSE = `STATUS: CHAT_YES -REASON: Significant price movement detected. -CONTENT: Market alert: BTC dropped 5%` +interface MockEngineState { + text: string + toolCalls: ToolCallSummary[] + shouldThrow: Error | null +} -function createMockEngine(response = CHAT_YES_RESPONSE) { +function createMockEngine(initial: Partial = {}) { + const state: MockEngineState = { + text: '', + toolCalls: [], + shouldThrow: null, + ...initial, + } return { - _response: response, - setResponse(text: string) { this._response = text }, - askWithSession: vi.fn(async function (this: any) { - return { text: this._response, media: [] } + state, + setNotifyUserCall(text: string) { + state.toolCalls = [{ id: randomUUID(), name: 'notify_user', input: { text } }] + }, + setNoToolCall() { state.toolCalls = [] }, + setRawText(text: string) { state.text = text }, + setShouldThrow(err: Error | null) { state.shouldThrow = err }, + askWithSession: vi.fn(async () => { + if (state.shouldThrow) throw state.shouldThrow + return { text: state.text, media: [], toolCalls: state.toolCalls } }), ask: vi.fn(), } } +// ==================== Integration suite ==================== + describe('heartbeat', () => { let eventLog: EventLog let listenerRegistry: ListenerRegistry - let cronEngine: CronEngine let heartbeat: Heartbeat let mockEngine: ReturnType let session: SessionStore let connectorCenter: ConnectorCenter let notificationsStore: ReturnType + let agentWorkListener: AgentWorkListener beforeEach(async () => { - const logPath = tempPath('jsonl') - const storePath = tempPath('json') - eventLog = await createEventLog({ logPath }) + eventLog = await createEventLog({ logPath: tempPath('jsonl') }) listenerRegistry = createListenerRegistry(eventLog) - await listenerRegistry.start() // Start registry so late registrations subscribe immediately - cronEngine = createCronEngine({ registry: listenerRegistry, storePath }) - await cronEngine.start() + await listenerRegistry.start() mockEngine = createMockEngine() session = new SessionStore(`test/heartbeat-${randomUUID()}`) notificationsStore = createMemoryNotificationsStore() connectorCenter = new ConnectorCenter({ notificationsStore }) + const runner = new AgentWorkRunner({ + agentCenter: mockEngine as never, + connectorCenter, + }) + agentWorkListener = createAgentWorkListener({ runner, registry: listenerRegistry }) + await agentWorkListener.start() }) afterEach(async () => { heartbeat?.stop() - cronEngine.stop() + agentWorkListener.stop() await listenerRegistry.stop() await eventLog._resetForTest() }) - // ==================== Start / Idempotency ==================== + // ==================== Lifecycle ==================== - describe('start', () => { - it('should register a cron job on start', async () => { + describe('lifecycle', () => { + it('start() is idempotent', async () => { heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, - }) - - await heartbeat.start() - - const jobs = cronEngine.list() - expect(jobs).toHaveLength(1) - expect(jobs[0].name).toBe(HEARTBEAT_JOB_NAME) - expect(jobs[0].schedule).toEqual({ kind: 'every', every: '30m' }) - }) - - it('should be idempotent (update existing job, not create duplicate)', async () => { - heartbeat = createHeartbeat({ - config: makeConfig({ every: '30m' }), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkListener, registry: listenerRegistry, session, }) - await heartbeat.start() - heartbeat.stop() - - // Start again with different interval - heartbeat = createHeartbeat({ - config: makeConfig({ every: '1h' }), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, - }) - - await heartbeat.start() - - const jobs = cronEngine.list() - expect(jobs).toHaveLength(1) // not 2 - expect(jobs[0].schedule).toEqual({ kind: 'every', every: '1h' }) + await heartbeat.start() // no error }) - it('should register disabled job when config.enabled is false', async () => { + it('start() respects config.enabled', async () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkListener, registry: listenerRegistry, session, }) - await heartbeat.start() - - const jobs = cronEngine.list() - expect(jobs).toHaveLength(1) - expect(jobs[0].enabled).toBe(false) expect(heartbeat.isEnabled()).toBe(false) }) }) @@ -151,103 +151,102 @@ describe('heartbeat', () => { // ==================== Event Handling ==================== describe('event handling', () => { - it('should call AI and write heartbeat.done on real response', async () => { + it('delivers when AI calls notify_user', async () => { const delivered: string[] = [] notificationsStore.onAppended((entry) => { delivered.push(entry.text) }) + mockEngine.setNotifyUserCall('BTC dropped 5% to $87,200') + heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - - // Simulate cron.fire for heartbeat - await cronEngine.runNow(cronEngine.list()[0].id) + await heartbeat.runNow() await vi.waitFor(() => { - const done = eventLog.recent({ type: 'heartbeat.done' }) - expect(done).toHaveLength(1) + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) }) - expect(delivered).toHaveLength(1) - expect(delivered[0]).toBe('Market alert: BTC dropped 5%') + expect(delivered).toEqual(['BTC dropped 5% to $87,200']) + const done = eventLog.recent({ type: 'agent.work.done' })[0].payload as AgentWorkDonePayload + expect(done.source).toBe('heartbeat') + expect(done.delivered).toBe(true) + }) - const done = eventLog.recent({ type: 'heartbeat.done' }) - expect(done[0].payload).toMatchObject({ - reply: 'Market alert: BTC dropped 5%', - delivered: true, + it('skips with reason=ack when AI does not call notify_user', async () => { + mockEngine.setRawText('Checked, nothing notable.') + mockEngine.setNoToolCall() + + heartbeat = createHeartbeat({ + config: makeConfig(), + agentWorkListener, registry: listenerRegistry, session, }) + await heartbeat.start() + await heartbeat.runNow() + + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'agent.work.skip' })).toHaveLength(1) + }) + + const skip = eventLog.recent({ type: 'agent.work.skip' })[0].payload as AgentWorkSkipPayload + expect(skip.source).toBe('heartbeat') + expect(skip.reason).toBe('ack') + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(0) }) - it('should skip HEARTBEAT_OK responses', async () => { - mockEngine.setResponse('STATUS: HEARTBEAT_OK\nREASON: All systems normal.') + it('skips with reason=empty when notify_user.text is blank', async () => { + mockEngine.setNotifyUserCall(' ') heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - - await cronEngine.runNow(cronEngine.list()[0].id) + await heartbeat.runNow() await vi.waitFor(() => { - const skips = eventLog.recent({ type: 'heartbeat.skip' }) - expect(skips).toHaveLength(1) + expect(eventLog.recent({ type: 'agent.work.skip' })).toHaveLength(1) }) - const skips = eventLog.recent({ type: 'heartbeat.skip' }) - expect(skips[0].payload).toMatchObject({ reason: 'ack', parsedReason: 'All systems normal.' }) - - // Should NOT have heartbeat.done - expect(eventLog.recent({ type: 'heartbeat.done' })).toHaveLength(0) + const skip = eventLog.recent({ type: 'agent.work.skip' })[0].payload as AgentWorkSkipPayload + expect(skip.reason).toBe('empty') }) - it('should deliver unparsed responses (fail-open)', async () => { - const delivered: string[] = [] - notificationsStore.onAppended((entry) => { delivered.push(entry.text) }) - - // Raw text without structured format - mockEngine.setResponse('BTC just crashed 15%, major liquidation event!') + it('does NOT regex-parse STATUS-shaped raw text — anti-regression', async () => { + mockEngine.setRawText('STATUS: CHAT_YES\nCONTENT: should NOT be delivered') + mockEngine.setNoToolCall() heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - - await cronEngine.runNow(cronEngine.list()[0].id) + await heartbeat.runNow() await vi.waitFor(() => { - const done = eventLog.recent({ type: 'heartbeat.done' }) - expect(done).toHaveLength(1) + expect(eventLog.recent({ type: 'agent.work.skip' })).toHaveLength(1) }) - expect(delivered).toHaveLength(1) - expect(delivered[0]).toBe('BTC just crashed 15%, major liquidation event!') + const { entries } = await notificationsStore.read() + expect(entries).toHaveLength(0) }) - it('should ignore non-heartbeat cron.fire events', async () => { + it('no longer subscribes to cron.fire (decoupled from cron-engine)', async () => { heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - // Fire a non-heartbeat cron event + // Fire a cron.fire event with the legacy __heartbeat__ jobName. + // Pre-refactor, this would have driven heartbeat. Post-refactor, + // heartbeat is fully decoupled — no AI call should happen. await eventLog.append('cron.fire', { - jobId: 'other-job', - jobName: 'check-eth', - payload: 'Check ETH price', + jobId: 'legacy-id', + jobName: '__heartbeat__', + payload: 'should be ignored', }) - await new Promise((r) => setTimeout(r, 50)) expect(mockEngine.askWithSession).not.toHaveBeenCalled() @@ -257,377 +256,291 @@ describe('heartbeat', () => { // ==================== Active Hours ==================== describe('active hours', () => { - it('should skip when outside active hours', async () => { - // Set active hours to a window that excludes the test time + it('emits agent.work.skip with reason=outside-active-hours, without invoking AI', async () => { const fakeNow = new Date('2025-06-15T03:00:00').getTime() // 3 AM local heartbeat = createHeartbeat({ config: makeConfig({ activeHours: { start: '09:00', end: '22:00', timezone: 'local' }, }), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkListener, registry: listenerRegistry, session, now: () => fakeNow, }) await heartbeat.start() - - await cronEngine.runNow(cronEngine.list()[0].id) + await heartbeat.runNow() await vi.waitFor(() => { - const skips = eventLog.recent({ type: 'heartbeat.skip' }) - expect(skips).toHaveLength(1) + expect(eventLog.recent({ type: 'agent.work.skip' })).toHaveLength(1) }) - const skips = eventLog.recent({ type: 'heartbeat.skip' }) - expect(skips[0].payload).toMatchObject({ reason: 'outside-active-hours' }) + const skip = eventLog.recent({ type: 'agent.work.skip' })[0].payload as AgentWorkSkipPayload + expect(skip.source).toBe('heartbeat') + expect(skip.reason).toBe('outside-active-hours') expect(mockEngine.askWithSession).not.toHaveBeenCalled() + // No agent.work.requested emitted (pre-emit gate) + const reqs = eventLog.recent({ type: 'agent.work.requested' }) + expect(reqs.filter(e => (e.payload as { source: string }).source === 'heartbeat')).toHaveLength(0) }) }) // ==================== Dedup ==================== describe('dedup', () => { - it('should suppress duplicate messages within 24h', async () => { + it('suppresses duplicate notify_user texts within the dedup window', async () => { const delivered: string[] = [] notificationsStore.onAppended((entry) => { delivered.push(entry.text) }) + mockEngine.setNotifyUserCall('BTC dropped 5%') + heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - const jobId = cronEngine.list()[0].id - - // First fire — should deliver. Wait for heartbeat.done to ensure - // the full handleFire cycle (including dedup.record) has completed - // before triggering the second fire. - await cronEngine.runNow(jobId) + await heartbeat.runNow() await vi.waitFor(() => { - expect(eventLog.recent({ type: 'heartbeat.done' })).toHaveLength(1) + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) }) - // Second fire (same response) — should be suppressed - await cronEngine.runNow(jobId) + await heartbeat.runNow() await vi.waitFor(() => { - const skips = eventLog.recent({ type: 'heartbeat.skip' }) - expect(skips.some((s) => (s.payload as any).reason === 'duplicate')).toBe(true) + const skips = eventLog.recent({ type: 'agent.work.skip' }) + expect(skips.some(s => (s.payload as AgentWorkSkipPayload).reason === 'duplicate')).toBe(true) }) - expect(delivered).toHaveLength(1) // still 1, not 2 + expect(delivered).toHaveLength(1) + }) + + it('different notify_user texts are not deduped', async () => { + const delivered: string[] = [] + notificationsStore.onAppended((entry) => { delivered.push(entry.text) }) + + heartbeat = createHeartbeat({ + config: makeConfig(), + agentWorkListener, registry: listenerRegistry, session, + }) + await heartbeat.start() + + mockEngine.setNotifyUserCall('First alert') + await heartbeat.runNow() + await vi.waitFor(() => { expect(delivered).toHaveLength(1) }) + + mockEngine.setNotifyUserCall('Second different alert') + await heartbeat.runNow() + await vi.waitFor(() => { expect(delivered).toHaveLength(2) }) + + expect(delivered).toEqual(['First alert', 'Second different alert']) }) }) // ==================== Error Handling ==================== describe('error handling', () => { - it('should write heartbeat.error on engine failure', async () => { - mockEngine.askWithSession.mockRejectedValueOnce(new Error('AI down')) + it('emits agent.work.error on AI failure', async () => { + mockEngine.setShouldThrow(new Error('AI down')) heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - - await cronEngine.runNow(cronEngine.list()[0].id) + await heartbeat.runNow() await vi.waitFor(() => { - const errors = eventLog.recent({ type: 'heartbeat.error' }) - expect(errors).toHaveLength(1) + expect(eventLog.recent({ type: 'agent.work.error' })).toHaveLength(1) }) - const errors = eventLog.recent({ type: 'heartbeat.error' }) - expect(errors[0].payload).toMatchObject({ error: 'AI down' }) + const err = eventLog.recent({ type: 'agent.work.error' })[0].payload as AgentWorkErrorPayload + expect(err.source).toBe('heartbeat') + expect(err.error).toBe('AI down') }) - it('should handle notify failure gracefully', async () => { - // Force the underlying append to reject so the heartbeat sees a - // failed notify path. Heartbeat must still emit heartbeat.done - // (with delivered=false) and not crash. + it('handles notify failure — emits done with delivered=false', async () => { + mockEngine.setNotifyUserCall('alert text') const originalAppend = notificationsStore.append.bind(notificationsStore) notificationsStore.append = async () => { throw new Error('store failed') } heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - - await cronEngine.runNow(cronEngine.list()[0].id) + await heartbeat.runNow() await vi.waitFor(() => { - const done = eventLog.recent({ type: 'heartbeat.done' }) - expect(done).toHaveLength(1) + expect(eventLog.recent({ type: 'agent.work.done' })).toHaveLength(1) }) - const done = eventLog.recent({ type: 'heartbeat.done' }) - expect((done[0].payload as any).delivered).toBe(false) + const done = eventLog.recent({ type: 'agent.work.done' })[0].payload as AgentWorkDonePayload + expect(done.delivered).toBe(false) notificationsStore.append = originalAppend }) }) - // ==================== Lifecycle ==================== + // ==================== stop ==================== - describe('lifecycle', () => { - it('should stop listening after stop()', async () => { + describe('stop', () => { + it('runNow is a no-op after stop()', async () => { heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() heartbeat.stop() - await cronEngine.runNow(cronEngine.list()[0].id) + await heartbeat.runNow() await new Promise((r) => setTimeout(r, 50)) expect(mockEngine.askWithSession).not.toHaveBeenCalled() }) }) - // ==================== setEnabled / isEnabled ==================== + // ==================== setEnabled ==================== describe('setEnabled', () => { - it('should enable a previously disabled heartbeat', async () => { + it('enables a previously disabled heartbeat', async () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - expect(heartbeat.isEnabled()).toBe(false) - expect(cronEngine.list()[0].enabled).toBe(false) await heartbeat.setEnabled(true) - expect(heartbeat.isEnabled()).toBe(true) - expect(cronEngine.list()[0].enabled).toBe(true) }) - it('should disable an enabled heartbeat', async () => { + it('disables an enabled heartbeat', async () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: true }), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() - expect(heartbeat.isEnabled()).toBe(true) await heartbeat.setEnabled(false) - expect(heartbeat.isEnabled()).toBe(false) - expect(cronEngine.list()[0].enabled).toBe(false) }) - it('should persist config via writeConfigSection', async () => { + it('persists config via writeConfigSection', async () => { const { writeConfigSection } = await import('../../core/config.js') heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() await heartbeat.setEnabled(true) - expect(writeConfigSection).toHaveBeenCalledWith('heartbeat', expect.objectContaining({ enabled: true })) + expect(writeConfigSection).toHaveBeenCalledWith( + 'heartbeat', + expect.objectContaining({ enabled: true }), + ) }) - it('should allow firing after setEnabled(true)', async () => { + it('runNow ignores the enabled flag (always fires for manual trigger)', async () => { const delivered: string[] = [] notificationsStore.onAppended((entry) => { delivered.push(entry.text) }) + mockEngine.setNotifyUserCall('manual-fire') + heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkListener, registry: listenerRegistry, session, }) await heartbeat.start() + // Even though enabled=false, manual runNow should still work + await heartbeat.runNow() - // Enable heartbeat - await heartbeat.setEnabled(true) - - // Fire — should process since now enabled - await cronEngine.runNow(cronEngine.list()[0].id) - - await vi.waitFor(() => { - expect(delivered).toHaveLength(1) - }) + await vi.waitFor(() => { expect(delivered).toHaveLength(1) }) + expect(delivered[0]).toBe('manual-fire') }) }) }) -// ==================== Unit Tests: parseHeartbeatResponse ==================== - -describe('parseHeartbeatResponse', () => { - it('should parse HEARTBEAT_OK', () => { - const r = parseHeartbeatResponse('STATUS: HEARTBEAT_OK\nREASON: All good.') - expect(r.status).toBe('HEARTBEAT_OK') - expect(r.reason).toBe('All good.') - expect(r.content).toBe('') - expect(r.unparsed).toBe(false) - }) - - it('should treat former CHAT_NO as unparsed (fail-open to CHAT_YES)', () => { - const r = parseHeartbeatResponse('STATUS: CHAT_NO\nREASON: Nothing worth reporting.') - expect(r.status).toBe('CHAT_YES') - expect(r.unparsed).toBe(true) - }) - - it('should parse CHAT_YES with content', () => { - const r = parseHeartbeatResponse( - 'STATUS: CHAT_YES\nREASON: Price alert.\nCONTENT: BTC dropped 8% to $87,200.', - ) - expect(r.status).toBe('CHAT_YES') - expect(r.reason).toBe('Price alert.') - expect(r.content).toBe('BTC dropped 8% to $87,200.') - expect(r.unparsed).toBe(false) - }) - - it('should parse CHAT_YES with multiline content', () => { - const r = parseHeartbeatResponse( - 'STATUS: CHAT_YES\nREASON: Multiple alerts.\nCONTENT: Line 1\nLine 2\nLine 3', - ) - expect(r.status).toBe('CHAT_YES') - expect(r.content).toBe('Line 1\nLine 2\nLine 3') - }) - - it('should be case-insensitive for STATUS field', () => { - const r = parseHeartbeatResponse('status: heartbeat_ok\nreason: ok') - expect(r.status).toBe('HEARTBEAT_OK') - }) - - it('should handle extra whitespace', () => { - const r = parseHeartbeatResponse(' STATUS: HEARTBEAT_OK \n REASON: All quiet. ') - expect(r.status).toBe('HEARTBEAT_OK') - expect(r.reason).toBe('All quiet.') - }) - - it('should fail-open on unparseable response (deliver it)', () => { - const r = parseHeartbeatResponse('Something unexpected happened with BTC!') - expect(r.status).toBe('CHAT_YES') - expect(r.content).toBe('Something unexpected happened with BTC!') - expect(r.unparsed).toBe(true) - }) - - it('should handle empty input', () => { - const r = parseHeartbeatResponse('') - expect(r.status).toBe('HEARTBEAT_OK') - expect(r.content).toBe('') - expect(r.unparsed).toBe(false) - }) - - it('should handle response with only STATUS line', () => { - const r = parseHeartbeatResponse('STATUS: HEARTBEAT_OK') - expect(r.status).toBe('HEARTBEAT_OK') - expect(r.reason).toBe('') - }) - - it('should handle CHAT_YES without CONTENT field', () => { - const r = parseHeartbeatResponse('STATUS: CHAT_YES\nREASON: Want to say hi.') - expect(r.status).toBe('CHAT_YES') - expect(r.content).toBe('') - }) -}) - -// ==================== Unit Tests: isWithinActiveHours ==================== +// ==================== Unit: isWithinActiveHours ==================== describe('isWithinActiveHours', () => { - it('should return true when no active hours configured', () => { + it('returns true when no active hours configured', () => { expect(isWithinActiveHours(null)).toBe(true) }) - it('should return true within normal range', () => { - // 15:00 local → within 09:00-22:00 + it('returns true within normal range', () => { const ts = todayAt(15, 0).getTime() expect(isWithinActiveHours( - { start: '09:00', end: '22:00', timezone: 'local' }, - ts, + { start: '09:00', end: '22:00', timezone: 'local' }, ts, )).toBe(true) }) - it('should return false outside normal range', () => { - // 03:00 local → outside 09:00-22:00 + it('returns false outside normal range', () => { const ts = todayAt(3, 0).getTime() expect(isWithinActiveHours( - { start: '09:00', end: '22:00', timezone: 'local' }, - ts, + { start: '09:00', end: '22:00', timezone: 'local' }, ts, )).toBe(false) }) - it('should handle overnight range (22:00 → 06:00)', () => { - const ts = todayAt(23, 0).getTime() + it('handles overnight range (22:00 → 06:00)', () => { expect(isWithinActiveHours( { start: '22:00', end: '06:00', timezone: 'local' }, - ts, + todayAt(23, 0).getTime(), )).toBe(true) - - const ts2 = todayAt(3, 0).getTime() expect(isWithinActiveHours( { start: '22:00', end: '06:00', timezone: 'local' }, - ts2, + todayAt(3, 0).getTime(), )).toBe(true) - - const ts3 = todayAt(12, 0).getTime() expect(isWithinActiveHours( { start: '22:00', end: '06:00', timezone: 'local' }, - ts3, + todayAt(12, 0).getTime(), )).toBe(false) }) - it('should handle invalid format gracefully (return true)', () => { + it('handles invalid format gracefully (returns true)', () => { expect(isWithinActiveHours( { start: 'invalid', end: '22:00', timezone: 'local' }, )).toBe(true) }) }) -// ==================== Unit Tests: HeartbeatDedup ==================== +// ==================== Unit: HeartbeatDedup ==================== describe('HeartbeatDedup', () => { - it('should not flag first message as duplicate', () => { + it('does not flag first message as duplicate', () => { const d = new HeartbeatDedup() expect(d.isDuplicate('hello')).toBe(false) }) - it('should flag same text within window', () => { + it('flags same text within window', () => { const d = new HeartbeatDedup(1000) d.record('hello', 100) expect(d.isDuplicate('hello', 500)).toBe(true) }) - it('should not flag same text after window expires', () => { + it('does not flag same text after window expires', () => { const d = new HeartbeatDedup(1000) d.record('hello', 100) expect(d.isDuplicate('hello', 1200)).toBe(false) }) - it('should not flag different text', () => { + it('does not flag different text', () => { const d = new HeartbeatDedup(1000) d.record('hello', 100) expect(d.isDuplicate('world', 500)).toBe(false) }) + + it('exposes lastText', () => { + const d = new HeartbeatDedup() + expect(d.lastText).toBeNull() + d.record('first', 100) + expect(d.lastText).toBe('first') + d.record('second', 200) + expect(d.lastText).toBe('second') + }) }) // ==================== Helpers ==================== -/** Create a Date set to today at the given local hour and minute. */ function todayAt(h: number, m: number): Date { const d = new Date() d.setHours(h, m, 0, 0) diff --git a/src/task/heartbeat/heartbeat.ts b/src/task/heartbeat/heartbeat.ts index e924f600..ee482347 100644 --- a/src/task/heartbeat/heartbeat.ts +++ b/src/task/heartbeat/heartbeat.ts @@ -1,36 +1,41 @@ /** - * Heartbeat — periodic AI self-check, built on top of the cron engine. + * Heartbeat — periodic Alice self-check, Pump-driven. * - * Registers a cron job (`__heartbeat__`) that fires at a configured interval. - * When fired, calls the AI engine and filters the response: - * 1. Active hours guard — skip if outside configured window - * 2. AI call — agentCenter.askWithSession(prompt, heartbeatSession) - * 3. Ack token filter — skip if AI says "nothing to report" - * 4. Dedup — skip if same text was sent within 24h - * 5. Send — connectorCenter.notify(text) + * Heartbeat is a recurring "ping Alice every N minutes" service. Prior + * to this commit, it piggy-backed on the cron engine: registered an + * internal `__heartbeat__` cron job, subscribed to `cron.fire` filtered + * by jobName, did its work in the handler. That was conceptual debt — + * the cron engine should be reserved for user-defined cron jobs from + * the Automation > Cron UI, and heartbeat's lifecycle (active-hours, + * dedup, hot enable/disable, configured prompt) doesn't belong in a + * "user cron job" shape. * - * Events written to eventLog: - * - heartbeat.done { reply, durationMs, delivered } - * - heartbeat.skip { reason } - * - heartbeat.error { error, durationMs } + * Now: heartbeat owns a private Pump for its schedule and a + * ProducerHandle for `agent.work.{requested,skip}` emits. The cron + * engine is no longer in its dependency graph. + * + * On each tick: + * 1. Active-hours pre-filter. Outside hours → emit + * `agent.work.skip { source: 'heartbeat', reason: 'outside-active-hours' }` + * and return; AI is never invoked, no token cost. + * 2. Otherwise emit `agent.work.requested { source: 'heartbeat', + * prompt }`. The agent-work-listener routes it through the + * heartbeat source config (notify_user inspection + dedup gate) + * registered at start(). + * + * State heartbeat owns: HeartbeatDedup (24h window), active-hours + * config, the Pump, the ProducerHandle, the source config registered + * with agent-work-listener. AgentWork pipeline state (sessions, + * AI invocation) lives elsewhere. */ -import type { EventLogEntry } from '../../core/event-log.js' -import type { CronFirePayload } from '../../core/agent-event.js' -import type { AgentCenter } from '../../core/agent-center.js' import { SessionStore } from '../../core/session.js' -import type { ConnectorCenter } from '../../core/connector-center.js' import { writeConfigSection } from '../../core/config.js' -import type { CronEngine } from '../cron/engine.js' -import type { Listener, ListenerContext } from '../../core/listener.js' import type { ListenerRegistry } from '../../core/listener-registry.js' - -const HEARTBEAT_EMITS = ['heartbeat.done', 'heartbeat.skip', 'heartbeat.error'] as const -type HeartbeatEmits = typeof HEARTBEAT_EMITS - -// ==================== Constants ==================== - -export const HEARTBEAT_JOB_NAME = '__heartbeat__' +import type { ProducerHandle } from '../../core/producer.js' +import { createPump, type Pump } from '../../core/pump.js' +import type { AgentWorkListener, AgentWorkSourceConfig } from '../../core/agent-work-listener.js' +import type { AgentWorkResultProbe } from '../../core/agent-work.js' // ==================== Config ==================== @@ -51,29 +56,15 @@ export interface HeartbeatConfig { export const DEFAULT_HEARTBEAT_CONFIG: HeartbeatConfig = { enabled: false, every: '30m', - prompt: `Check if anything needs attention. Respond using the structured format below. - -## Response Format - -STATUS: HEARTBEAT_OK | CHAT_YES -REASON: -CONTENT: - -## Rules + prompt: `You're Alice in the heartbeat monitoring loop. The system pings you periodically so you can check on what's happening — markets, watchlists, pending items, anything trade-relevant the user might want surfaced. -- If in doubt, prefer CHAT_YES over HEARTBEAT_OK — better to over-report than to miss something. -- Keep CONTENT concise but actionable. +If something is genuinely worth flagging — a notable move, a finished analysis, an answer to a question they've been waiting on — call the \`notify_user\` tool with a concise message in the user's language. -## Examples +If there's nothing worth surfacing, simply respond briefly with what you observed (or with nothing at all). Don't call \`notify_user\` out of politeness; reserve it for genuinely useful pushes — the user gets pinged whenever it fires. -If nothing to report: -STATUS: HEARTBEAT_OK -REASON: All systems normal, no alerts or notable changes. - -If you want to send a message: -STATUS: CHAT_YES -REASON: Significant price movement detected. -CONTENT: BTC just dropped 8% in the last hour — now at $87,200. This may trigger stop-losses.`, +In short: +- silence = nothing pushed +- \`notify_user("...")\` = a push lands in the user's inbox`, activeHours: null, } @@ -81,10 +72,12 @@ CONTENT: BTC just dropped 8% in the last hour — now at $87,200. This may trigg export interface HeartbeatOpts { config: HeartbeatConfig - connectorCenter: ConnectorCenter - cronEngine: CronEngine - agentCenter: AgentCenter - /** Registry to auto-register the heartbeat listener with. */ + /** Where to register the heartbeat source config so the agent-work + * pipeline knows how to handle heartbeat-sourced requests. */ + agentWorkListener: AgentWorkListener + /** Listener registry — used to declare the heartbeat producer so its + * agent.work.{requested,skip} emits are validated + show in the + * topology graph. */ registry: ListenerRegistry /** Optional: inject a session for testing. */ session?: SessionStore @@ -95,236 +88,123 @@ export interface HeartbeatOpts { export interface Heartbeat { start(): Promise stop(): void - /** Hot-toggle heartbeat on/off (persists to config + updates cron job). */ + /** Hot-toggle heartbeat on/off (persists to config + updates pump). */ setEnabled(enabled: boolean): Promise /** Current enabled state. */ isEnabled(): boolean - /** Expose the raw listener for direct testing. */ - readonly listener: Listener<'cron.fire', HeartbeatEmits> + /** Manually trigger a heartbeat tick — used by tests and "run now" UI. */ + runNow(): Promise } // ==================== Factory ==================== export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { - const { config, connectorCenter, cronEngine, agentCenter, registry } = opts + const { config, agentWorkListener, registry } = opts const session = opts.session ?? new SessionStore('heartbeat') const now = opts.now ?? Date.now - let jobId: string | null = null - let processing = false let enabled = config.enabled - let registered = false + let started = false + let producer: ProducerHandle | null = null + let pump: Pump | null = null const dedup = new HeartbeatDedup() - async function handleFire( - entry: EventLogEntry, - ctx: ListenerContext, - ): Promise { - const payload = entry.payload - - // Only handle our own job - if (payload.jobName !== HEARTBEAT_JOB_NAME) return - - // Guard: skip if already processing - if (processing) return - - processing = true - const startMs = now() - console.log(`heartbeat: firing at ${new Date(startMs).toISOString()}`) - - try { - // 1. Active hours guard - if (!isWithinActiveHours(config.activeHours, now())) { - console.log('heartbeat: skipped (outside active hours)') - await ctx.emit('heartbeat.skip', { reason: 'outside-active-hours' }) - return - } - - // 2. Call AI - const result = await agentCenter.askWithSession(payload.payload, session, { - historyPreamble: 'You are operating in the heartbeat monitoring context (session: heartbeat). The following is the recent heartbeat conversation history.', - }) - const durationMs = now() - startMs - - // 3. Parse structured response - const parsed = parseHeartbeatResponse(result.text) - - if (parsed.status === 'HEARTBEAT_OK') { - console.log(`heartbeat: HEARTBEAT_OK — ${parsed.reason || 'no reason'} (${durationMs}ms)`) - await ctx.emit('heartbeat.skip', { - reason: 'ack', - parsedReason: parsed.reason, - }) - return + // ---- Source config (registered with agent-work-listener) ---- + // + // Output-side semantics (notify_user inspection + dedup gate) live + // here, closing over the dedup instance heartbeat owns. The + // agent-work-listener calls these when an agent.work.requested event + // with source='heartbeat' arrives. + const sourceConfig: AgentWorkSourceConfig = { + source: 'heartbeat', + session, + preamble: () => + 'You are operating in the heartbeat monitoring context (session: heartbeat). The following is the recent heartbeat conversation history.', + outputGate: (probe: AgentWorkResultProbe) => { + const call = probe.toolCalls.find((c) => c.name === 'notify_user') + if (!call) { + return { kind: 'skip', reason: 'ack', payload: { reason: 'ack' } } } - - // CHAT_YES (or unparsed fallback) - const text = parsed.content || result.text + const text = ((call.input ?? {}) as { text?: string }).text ?? '' if (!text.trim()) { - console.log(`heartbeat: skipped (empty content) (${durationMs}ms)`) - await ctx.emit('heartbeat.skip', { reason: 'empty' }) - return + return { kind: 'skip', reason: 'empty', payload: { reason: 'empty' } } } - - // 4. Dedup if (dedup.isDuplicate(text, now())) { - console.log(`heartbeat: skipped (duplicate) (${durationMs}ms)`) - await ctx.emit('heartbeat.skip', { reason: 'duplicate' }) - return + return { + kind: 'skip', + reason: 'duplicate', + payload: { reason: 'duplicate', parsedReason: text.slice(0, 80) }, + } } - - // 5. Append to notifications store. Connectors (Web bell, Telegram - // inline, …) subscribe to the store's onAppended event and - // surface the notification however suits their transport. - let appended = false - try { - await connectorCenter.notify(text, { - media: result.media, - source: 'heartbeat', - }) - appended = true - // Record dedup unconditionally on append: the notification exists - // in the canonical record, even if a particular connector chose - // not to surface it (e.g. Telegram skipping when user is inactive). - dedup.record(text, now()) - } catch (sendErr) { - console.warn('heartbeat: notify failed:', sendErr) - } - - console.log(`heartbeat: CHAT_YES — appended=${appended} (${durationMs}ms)`) - - // 6. Done event - await ctx.emit('heartbeat.done', { - reply: text, - reason: parsed.reason, - durationMs, - delivered: appended, - }) - } catch (err) { - console.error('heartbeat: error:', err) - await ctx.emit('heartbeat.error', { - error: err instanceof Error ? err.message : String(err), - durationMs: now() - startMs, - }) - } finally { - processing = false - } + return { kind: 'deliver', text, media: probe.media } + }, + onDelivered: (text) => dedup.record(text, now()), } - const listener: Listener<'cron.fire', HeartbeatEmits> = { - name: 'heartbeat', - subscribes: 'cron.fire', - emits: HEARTBEAT_EMITS, - handle: handleFire, - } + /** The pump's tick callback — active-hours guard then emit. */ + async function onTick(): Promise { + const startMs = now() + console.log(`heartbeat: firing at ${new Date(startMs).toISOString()}`) - /** Ensure the cron job exists and listener is registered (idempotent). */ - async function ensureJobAndListener(): Promise { - // Idempotent: find existing heartbeat job or create one - const existing = cronEngine.list().find((j) => j.name === HEARTBEAT_JOB_NAME) - if (existing) { - jobId = existing.id - await cronEngine.update(existing.id, { - schedule: { kind: 'every', every: config.every }, - payload: config.prompt, - enabled, - }) - } else { - jobId = await cronEngine.add({ - name: HEARTBEAT_JOB_NAME, - schedule: { kind: 'every', every: config.every }, - payload: config.prompt, - enabled, + if (!isWithinActiveHours(config.activeHours, now())) { + await producer!.emit('agent.work.skip', { + source: 'heartbeat', + reason: 'outside-active-hours', }) + console.log(`heartbeat: skipped (outside-active-hours)`) + return } - // Register listener exactly once - if (!registered) { - registry.register(listener) - registered = true - } + await producer!.emit('agent.work.requested', { + source: 'heartbeat', + prompt: config.prompt, + }) } return { - listener, async start() { - // Always register job + listener (even if disabled) so setEnabled can toggle later - await ensureJobAndListener() + if (started) return + started = true + + producer = registry.declareProducer({ + name: 'heartbeat', + emits: ['agent.work.requested', 'agent.work.skip'] as const, + }) + agentWorkListener.registerSource(sourceConfig) + + pump = createPump({ + name: 'heartbeat', + every: config.every, + enabled, + onTick, + }) + pump.start() }, stop() { - // Unregister the listener so a subsequent start() re-registers cleanly. - // Don't delete the cron job — it persists for restart recovery. - if (registered) { - registry.unregister(listener.name) - registered = false - } + if (!started) return + pump?.stop() + pump = null + producer?.dispose() + producer = null + started = false }, async setEnabled(newEnabled: boolean) { enabled = newEnabled - - // Ensure infrastructure exists (handles cold enable when start() was called with disabled) - await ensureJobAndListener() - - // Persist to config file + pump?.setEnabled(newEnabled) await writeConfigSection('heartbeat', { ...config, enabled: newEnabled }) }, isEnabled() { return enabled }, - } -} - -// ==================== Response Parser ==================== - -export type HeartbeatStatus = 'HEARTBEAT_OK' | 'CHAT_YES' -export interface ParsedHeartbeatResponse { - status: HeartbeatStatus - reason: string - content: string - /** True when the raw response couldn't be parsed into the structured format. */ - unparsed: boolean -} - -/** - * Parse a structured heartbeat response from the AI. - * - * Expected format: - * STATUS: HEARTBEAT_OK | CHAT_YES - * REASON: - * CONTENT: (only for CHAT_YES) - * - * If the response doesn't match the expected format, treats the entire - * raw text as a CHAT_YES message (fail-open: deliver rather than swallow). - */ -export function parseHeartbeatResponse(raw: string): ParsedHeartbeatResponse { - const trimmed = raw.trim() - if (!trimmed) { - return { status: 'HEARTBEAT_OK', reason: 'empty response', content: '', unparsed: false } - } - - // Extract STATUS field (case-insensitive, allows leading whitespace on the line) - const statusMatch = /^\s*STATUS:\s*(HEARTBEAT_OK|CHAT_YES)\s*$/im.exec(trimmed) - if (!statusMatch) { - // Fail-open: can't parse → treat as a message to deliver - return { status: 'CHAT_YES', reason: 'unparsed response', content: trimmed, unparsed: true } + async runNow() { + if (pump) await pump.runNow() + }, } - - const status = statusMatch[1].toUpperCase() as HeartbeatStatus - - // Extract REASON field (everything after "REASON:" until next field or end) - const reasonMatch = /^\s*REASON:\s*(.+?)(?=\n\s*(?:STATUS|CONTENT):|\s*$)/ims.exec(trimmed) - const reason = reasonMatch?.[1]?.trim() ?? '' - - // Extract CONTENT field (everything after "CONTENT:" to end) - const contentMatch = /^\s*CONTENT:\s*(.+)/ims.exec(trimmed) - const content = contentMatch?.[1]?.trim() ?? '' - - return { status, reason, content, unparsed: false } } // ==================== Active Hours ==================== @@ -347,12 +227,9 @@ export function isWithinActiveHours( const nowMinutes = currentMinutesInTimezone(timezone, nowMs) - // Normal range (e.g. 09:00 → 22:00) if (startMinutes <= endMinutes) { return nowMinutes >= startMinutes && nowMinutes < endMinutes } - - // Overnight range (e.g. 22:00 → 06:00) return nowMinutes >= startMinutes || nowMinutes < endMinutes } @@ -367,11 +244,9 @@ function parseHHMM(s: string): number | null { function currentMinutesInTimezone(tz: string, nowMs?: number): number { const date = nowMs ? new Date(nowMs) : new Date() - if (tz === 'local') { return date.getHours() * 60 + date.getMinutes() } - try { const fmt = new Intl.DateTimeFormat('en-US', { timeZone: tz, @@ -391,10 +266,14 @@ function currentMinutesInTimezone(tz: string, nowMs?: number): number { // ==================== Dedup ==================== /** - * Suppress identical heartbeat messages within a time window (default 24h). + * Suppress identical heartbeat notify_user texts within a time window + * (default 24h). In-memory only — restart loses dedup state. Acceptable + * trade-off: heartbeats are coarse-grained (~30m), restart-window + * collisions are rare, single-duplicate cost is low. */ export class HeartbeatDedup { - private lastText: string | null = null + /** Public for callers that want to inspect the last-delivered text. */ + public lastText: string | null = null private lastSentAt = 0 private windowMs: number diff --git a/src/task/heartbeat/index.ts b/src/task/heartbeat/index.ts index ddad214f..969498cd 100644 --- a/src/task/heartbeat/index.ts +++ b/src/task/heartbeat/index.ts @@ -1,3 +1,3 @@ -export { createHeartbeat, HEARTBEAT_JOB_NAME } from './heartbeat.js' +export { createHeartbeat, DEFAULT_HEARTBEAT_CONFIG } from './heartbeat.js' export type { Heartbeat, HeartbeatConfig, HeartbeatOpts } from './heartbeat.js' -export { parseHeartbeatResponse, isWithinActiveHours, HeartbeatDedup } from './heartbeat.js' +export { isWithinActiveHours, HeartbeatDedup } from './heartbeat.js' diff --git a/src/task/task-router/index.ts b/src/task/task-router/index.ts deleted file mode 100644 index fbb36f95..00000000 --- a/src/task/task-router/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { createTaskRouter } from './listener.js' -export type { TaskRouter, TaskRouterOpts } from './listener.js' diff --git a/src/task/task-router/listener.spec.ts b/src/task/task-router/listener.spec.ts deleted file mode 100644 index a7fc8b87..00000000 --- a/src/task/task-router/listener.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { randomUUID } from 'node:crypto' -import { createEventLog, type EventLog } from '../../core/event-log.js' -import { createListenerRegistry, type ListenerRegistry } from '../../core/listener-registry.js' -import { createTaskRouter, type TaskRouter } from './listener.js' -import { SessionStore } from '../../core/session.js' -import type { TaskRequestedPayload } from '../../core/agent-event.js' -import { ConnectorCenter } from '../../core/connector-center.js' - -function tempPath(ext: string): string { - return join(tmpdir(), `task-router-test-${randomUUID()}.${ext}`) -} - -function createMockEngine(response = 'AI reply') { - const calls: Array<{ prompt: string; session: SessionStore }> = [] - let shouldFail = false - - return { - calls, - setResponse(text: string) { response = text }, - setShouldFail(val: boolean) { shouldFail = val }, - askWithSession: vi.fn(async (prompt: string, session: SessionStore) => { - calls.push({ prompt, session }) - if (shouldFail) throw new Error('engine error') - return { text: response, media: [] } - }), - ask: vi.fn(), - } -} - -describe('task router', () => { - let eventLog: EventLog - let registry: ListenerRegistry - let taskRouter: TaskRouter - let mockEngine: ReturnType - let session: SessionStore - let connectorCenter: ConnectorCenter - let logPath: string - - beforeEach(async () => { - logPath = tempPath('jsonl') - eventLog = await createEventLog({ logPath }) - registry = createListenerRegistry(eventLog) - mockEngine = createMockEngine() - session = new SessionStore(`test/task-${randomUUID()}`) - connectorCenter = new ConnectorCenter() - - taskRouter = createTaskRouter({ - connectorCenter, - agentCenter: mockEngine as any, - registry, - session, - }) - await taskRouter.start() - await registry.start() - }) - - afterEach(async () => { - await registry.stop() - await eventLog._resetForTest() - }) - - describe('event handling', () => { - it('calls agent on task.requested with the provided prompt', async () => { - await eventLog.append('task.requested', { - prompt: 'What is the price of BTC?', - } satisfies TaskRequestedPayload) - - await vi.waitFor(() => { - expect(mockEngine.askWithSession).toHaveBeenCalledTimes(1) - }) - expect(mockEngine.askWithSession).toHaveBeenCalledWith( - 'What is the price of BTC?', - session, - expect.objectContaining({ historyPreamble: expect.any(String) }), - ) - }) - - it('emits task.done with reply on success', async () => { - mockEngine.setResponse('BTC is around $65,000') - await eventLog.append('task.requested', { - prompt: 'BTC price?', - } satisfies TaskRequestedPayload) - - await vi.waitFor(() => { - const done = eventLog.recent({ type: 'task.done' }) - expect(done).toHaveLength(1) - expect((done[0].payload as { reply: string }).reply).toBe('BTC is around $65,000') - expect((done[0].payload as { prompt: string }).prompt).toBe('BTC price?') - }) - }) - - it('emits task.error when the agent throws', async () => { - mockEngine.setShouldFail(true) - const origErr = console.error - console.error = () => {} - - try { - await eventLog.append('task.requested', { - prompt: 'This will fail', - } satisfies TaskRequestedPayload) - - await vi.waitFor(() => { - const err = eventLog.recent({ type: 'task.error' }) - expect(err).toHaveLength(1) - expect((err[0].payload as { error: string }).error).toBe('engine error') - }) - } finally { - console.error = origErr - } - }) - - it('sets causedBy on emitted task.done', async () => { - const fireEntry = await eventLog.append('task.requested', { - prompt: 'hi', - } satisfies TaskRequestedPayload) - - await vi.waitFor(() => { - const done = eventLog.recent({ type: 'task.done' }) - expect(done).toHaveLength(1) - expect(done[0].causedBy).toBe(fireEntry.seq) - }) - }) - }) - - describe('registry integration', () => { - it('registers with the listener registry', () => { - const info = registry.list().find((l) => l.name === 'task-router') - expect(info).toBeDefined() - expect(info?.subscribes).toEqual(['task.requested']) - expect(info?.emits).toEqual(['task.done', 'task.error']) - }) - - it('stop() unregisters it', () => { - taskRouter.stop() - const info = registry.list().find((l) => l.name === 'task-router') - expect(info).toBeUndefined() - }) - }) -}) diff --git a/src/task/task-router/listener.ts b/src/task/task-router/listener.ts deleted file mode 100644 index 69c25a20..00000000 --- a/src/task/task-router/listener.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Task Router — subscribes to externally-ingested `task.requested` events - * and routes them through the AgentCenter for one-shot processing. - * - * Flow: - * POST /api/events/ingest { type: 'task.requested', payload: { prompt } } - * → eventLog 'task.requested' - * → agentCenter.askWithSession(prompt, session) - * → connectorCenter.notify(reply) - * → ctx.emit 'task.done' / 'task.error' - * - * The listener owns a dedicated SessionStore for externally-triggered tasks - * (`task/default`), independent of cron, heartbeat, and chat sessions. - */ - -import type { EventLogEntry } from '../../core/event-log.js' -import type { TaskRequestedPayload } from '../../core/agent-event.js' -import type { AgentCenter } from '../../core/agent-center.js' -import { SessionStore } from '../../core/session.js' -import type { ConnectorCenter } from '../../core/connector-center.js' -import type { Listener, ListenerContext } from '../../core/listener.js' -import type { ListenerRegistry } from '../../core/listener-registry.js' - -// ==================== Types ==================== - -const TASK_EMITS = ['task.done', 'task.error'] as const -type TaskEmits = typeof TASK_EMITS - -export interface TaskRouterOpts { - connectorCenter: ConnectorCenter - agentCenter: AgentCenter - /** Registry to auto-register this listener with. */ - registry: ListenerRegistry - /** Optional: inject a session for testing. Otherwise creates `task/default`. */ - session?: SessionStore -} - -export interface TaskRouter { - /** Register the listener with the registry (idempotent). */ - start(): Promise - /** Unregister the listener from the registry. */ - stop(): void - /** Expose the raw Listener object (for testing `handle()` directly). */ - readonly listener: Listener<'task.requested', TaskEmits> -} - -// ==================== Factory ==================== - -export function createTaskRouter(opts: TaskRouterOpts): TaskRouter { - const { connectorCenter, agentCenter, registry } = opts - const session = opts.session ?? new SessionStore('task/default') - - let processing = false - let registered = false - - const listener: Listener<'task.requested', TaskEmits> = { - name: 'task-router', - subscribes: 'task.requested', - emits: TASK_EMITS, - async handle( - entry: EventLogEntry, - ctx: ListenerContext, - ): Promise { - const payload = entry.payload - - // Guard: skip if already processing (serial execution, same as cron-router) - if (processing) { - console.warn(`task-router: skipping (already processing)`) - return - } - - processing = true - const startMs = Date.now() - - try { - const result = await agentCenter.askWithSession(payload.prompt, session, { - historyPreamble: `You are handling an externally-triggered task (session: task/default). Follow the prompt and reply with what the caller needs.`, - }) - - try { - await connectorCenter.notify(result.text, { - media: result.media, - source: 'task', - }) - } catch (sendErr) { - console.warn(`task-router: send failed:`, sendErr) - } - - await ctx.emit('task.done', { - prompt: payload.prompt, - reply: result.text, - durationMs: Date.now() - startMs, - }) - } catch (err) { - console.error(`task-router: error:`, err) - - await ctx.emit('task.error', { - prompt: payload.prompt, - error: err instanceof Error ? err.message : String(err), - durationMs: Date.now() - startMs, - }) - } finally { - processing = false - } - }, - } - - return { - listener, - async start() { - if (registered) return - registry.register(listener) - registered = true - }, - stop() { - if (registered) { - registry.unregister(listener.name) - registered = false - } - }, - } -} diff --git a/src/tool/notify-user.ts b/src/tool/notify-user.ts new file mode 100644 index 00000000..d3c2fbd2 --- /dev/null +++ b/src/tool/notify-user.ts @@ -0,0 +1,63 @@ +import { tool } from 'ai' +import { z } from 'zod' + +/** + * notify_user — Alice's structured way to express "deliver this to + * the user" intent during autonomous work. + * + * Used by the heartbeat trigger source to replace the legacy STATUS + * regex protocol (`STATUS: HEARTBEAT_OK | CHAT_YES + CONTENT: ...`). + * The runner-side outputGate inspects the captured tool calls in + * `ProviderResult.toolCalls`; if `notify_user` was invoked, the gate + * routes the tool's `text` arg through the configured dedup window + * and into `connectorCenter.notify`. + * + * **Why no side-effects in execute**: the actual delivery is gated + * by the AgentWork outputGate (heartbeat applies dedup, future + * triggers might apply other policies). Putting `connectorCenter.notify` + * inside the tool's execute would make those gates impossible without + * per-tool-instance source state. The runner-side gate is the right + * control point. The tool's job is purely to signal intent + arguments. + * + * Globally registered by ToolCenter — every session sees it in its + * tool catalog. But only sessions whose persona prompt teaches Alice + * when to call it (today: heartbeat) actually exercise it. cron and + * task-router sessions don't reference it in their preambles, so AI + * keeps its current "every reply pushes" behaviour. + */ +export function createNotifyUserTool() { + return { + notify_user: tool({ + description: [ + 'Send a notification to the user. Use this during autonomous', + 'work (heartbeat / cron / external task) when something is', + 'worth surfacing — a market event, a finished analysis,', + 'a heads-up. Write the body in the user\'s language. Do not', + 'call this redundantly — one call per cycle is the norm. If', + 'nothing is worth flagging, simply do not call this tool.', + ].join(' '), + inputSchema: z.object({ + text: z + .string() + .min(1) + .describe( + 'The notification body, in the user\'s language. Keep it concise — under ~300 chars where possible.', + ), + urgency: z + .enum(['info', 'important']) + .optional() + .describe( + '"info" (default) for routine surfacing; "important" for time-sensitive matters the user should see promptly.', + ), + }), + execute: async ({ text, urgency }) => { + // Intent-only signal — the AgentWork runner's outputGate + // observes this call via ProviderResult.toolCalls and routes + // through dedup / connectorCenter.notify. Returning success + // here doesn't mean the user has been pinged yet; it means + // Alice's intent has been recorded for the runner to act on. + return { acknowledged: true, text, urgency: urgency ?? 'info' } + }, + }), + } +} diff --git a/src/webui/__tests__/diary.spec.ts b/src/webui/__tests__/diary.spec.ts index accaefd7..f6df4795 100644 --- a/src/webui/__tests__/diary.spec.ts +++ b/src/webui/__tests__/diary.spec.ts @@ -1,10 +1,10 @@ /** * Tests for the diary route's event-log → cycle projection. * - * The HTTP endpoint itself is a thin wrapper around `buildDiaryCycles` + - * `session.readActive()` + `toChatHistory()`. The join/filter logic that's - * worth locking in is event-type → outcome classification and reason - * selection. + * Post-AgentWork refactor, diary reads canonical `agent.work.*` events + * (filtered by `source === 'heartbeat'`) instead of the old + * `heartbeat.*` event types. These tests exercise the projection + * functions on synthetic fixtures of the new shape. */ import { describe, it, expect } from 'vitest' @@ -17,33 +17,35 @@ import type { EventLogEntry } from '../../core/event-log.js' // ==================== Fixtures ==================== -const done = (seq: number, ts: number, delivered: boolean, reason = 'test', durationMs = 100): EventLogEntry => ({ - seq, ts, type: 'heartbeat.done', - payload: { reply: 'hi', reason, durationMs, delivered }, +const done = (seq: number, ts: number, delivered: boolean, durationMs = 100): EventLogEntry => ({ + seq, ts, type: 'agent.work.done', + payload: { source: 'heartbeat', reply: 'hi', durationMs, delivered }, }) const skip = (seq: number, ts: number, reason: string, parsedReason?: string): EventLogEntry => ({ - seq, ts, type: 'heartbeat.skip', - payload: parsedReason !== undefined ? { reason, parsedReason } : { reason }, + seq, ts, type: 'agent.work.skip', + payload: parsedReason !== undefined + ? { source: 'heartbeat', reason, metadata: { parsedReason } } + : { source: 'heartbeat', reason }, }) const err = (seq: number, ts: number, error: string, durationMs = 50): EventLogEntry => ({ - seq, ts, type: 'heartbeat.error', - payload: { error, durationMs }, + seq, ts, type: 'agent.work.error', + payload: { source: 'heartbeat', error, durationMs }, }) // ==================== Tests ==================== describe('outcomeFromEvent', () => { const cases: Array<[string, EventLogEntry, DiaryOutcome]> = [ - ['heartbeat.done delivered=true → "delivered"', done(1, 0, true), 'delivered'], - ['heartbeat.done delivered=false → "silent-ok"', done(1, 0, false), 'silent-ok'], - ['heartbeat.skip reason=ack → "silent-ok"', skip(1, 0, 'ack'), 'silent-ok'], - ['heartbeat.skip reason=duplicate → "duplicate"', skip(1, 0, 'duplicate'), 'duplicate'], - ['heartbeat.skip reason=empty → "empty"', skip(1, 0, 'empty'), 'empty'], - ['heartbeat.skip reason=outside-active-hours → "outside-hours"', skip(1, 0, 'outside-active-hours'), 'outside-hours'], - ['heartbeat.skip unknown reason → "silent-ok"', skip(1, 0, 'something-else'), 'silent-ok'], - ['heartbeat.error → "error"', err(1, 0, 'boom'), 'error'], + ['agent.work.done delivered=true → "delivered"', done(1, 0, true), 'delivered'], + ['agent.work.done delivered=false → "silent-ok"', done(1, 0, false), 'silent-ok'], + ['agent.work.skip reason=ack → "silent-ok"', skip(1, 0, 'ack'), 'silent-ok'], + ['agent.work.skip reason=duplicate → "duplicate"', skip(1, 0, 'duplicate'), 'duplicate'], + ['agent.work.skip reason=empty → "empty"', skip(1, 0, 'empty'), 'empty'], + ['agent.work.skip reason=outside-active-hours → "outside-hours"', skip(1, 0, 'outside-active-hours'), 'outside-hours'], + ['agent.work.skip unknown reason → "silent-ok"', skip(1, 0, 'something-else'), 'silent-ok'], + ['agent.work.error → "error"', err(1, 0, 'boom'), 'error'], ] for (const [label, entry, expected] of cases) { it(label, () => expect(outcomeFromEvent(entry)).toBe(expected)) @@ -55,7 +57,7 @@ describe('outcomeFromEvent', () => { }) describe('buildDiaryCycles', () => { - it('surfaces error message as reason for heartbeat.error', () => { + it('surfaces error message as reason for agent.work.error', () => { const cycles = buildDiaryCycles([err(5, 1000, 'network timeout', 250)]) expect(cycles[0]).toMatchObject({ seq: 5, @@ -66,9 +68,10 @@ describe('buildDiaryCycles', () => { }) }) - it('prefers parsedReason over machine reason for skip events', () => { - // parsedReason is the AI's own wording — more useful to show humans than the machine code "ack". - const cycles = buildDiaryCycles([skip(5, 1000, 'ack', 'market is quiet, watching for a breakout')]) + it('prefers parsedReason (metadata) over machine reason for skip events', () => { + // parsedReason is heartbeat's truncated notify_user text on duplicate + // skips — more useful to show humans than the machine code "duplicate". + const cycles = buildDiaryCycles([skip(5, 1000, 'duplicate', 'market is quiet, watching for a breakout')]) expect(cycles[0].reason).toBe('market is quiet, watching for a breakout') }) @@ -88,7 +91,7 @@ describe('buildDiaryCycles', () => { it('includes durationMs for done and error, omits for skip', () => { const cycles = buildDiaryCycles([ - done(1, 0, true, 'x', 150), + done(1, 0, true, 150), skip(2, 0, 'ack'), err(3, 0, 'oops', 42), ]) @@ -97,10 +100,11 @@ describe('buildDiaryCycles', () => { expect(cycles[2].durationMs).toBe(42) }) - it('omits reason for done when the payload reason is empty', () => { - // heartbeat.done stores its reason even on delivered cycles; an empty string - // would render as a blank tag, which is noise. - const cycles = buildDiaryCycles([done(1, 0, true, '', 100)]) + it('done events have no source-specific reason in the canonical payload', () => { + // Unlike the old heartbeat.done which carried a `reason` field, the + // canonical agent.work.done only has source/reply/durationMs/delivered. + // A done cycle therefore has no `reason` in the projected output. + const cycles = buildDiaryCycles([done(1, 0, true, 100)]) expect(cycles[0].reason).toBeUndefined() }) }) diff --git a/src/webui/plugin.ts b/src/webui/plugin.ts index cdd931a1..bcd2d8e8 100644 --- a/src/webui/plugin.ts +++ b/src/webui/plugin.ts @@ -41,7 +41,7 @@ export class WebPlugin implements Plugin { /** SSE clients grouped by channel ID. Default channel: 'default'. */ private sseByChannel = new Map>() private unregisterConnector?: () => void - private ingestProducer?: ProducerHandle + private ingestProducer?: ProducerHandle constructor(private config: WebConfig) {} @@ -89,7 +89,7 @@ export class WebPlugin implements Plugin { // Extend this tuple when adding new `external: true` event types. this.ingestProducer = ctx.listenerRegistry.declareProducer({ name: 'webhook-ingest', - emits: ['task.requested'] as const, + emits: ['agent.work.requested'] as const, }) // ==================== Mount route modules ==================== diff --git a/src/webui/routes/diary.ts b/src/webui/routes/diary.ts index 46bb4177..bdfa0bea 100644 --- a/src/webui/routes/diary.ts +++ b/src/webui/routes/diary.ts @@ -3,11 +3,17 @@ * * This is the "status" surface (as opposed to the Chat "notification" surface): * a passive view of what Alice has been thinking across recent heartbeat cycles, - * including silent HEARTBEAT_OK acknowledgements that never reach Chat. + * including silent acknowledgements that never reach Chat. * * Data sources (joined by timestamp proximity): - * - SessionStore('heartbeat') → full AI turns (prompt, reasoning, tool calls, reply) - * - EventLog heartbeat.{done,skip,error} → outcome metadata (delivered, reason, durationMs) + * - SessionStore('heartbeat') → full AI turns (prompt, reasoning, tool calls, reply) + * - EventLog agent.work.{done,skip,error} filtered by source='heartbeat' + * → outcome metadata (delivered, reason, durationMs) + * + * Heartbeat-attributable events all flow through the canonical AgentWork + * event types now; we filter on `payload.source === 'heartbeat'`. (Pre-AgentWork + * the heartbeat module had its own `heartbeat.*` event types; consolidated + * during the AgentWork upstreams refactor.) * * Deliberately polling-only (no SSE). Heartbeat fires ~every 30min; the overhead * of a persistent subscription is not justified for this frequency. @@ -18,20 +24,20 @@ import type { EngineContext } from '../../core/types.js' import { SessionStore, toChatHistory, type ChatHistoryItem } from '../../core/session.js' import type { EventLogEntry } from '../../core/event-log.js' import type { - HeartbeatDonePayload, - HeartbeatSkipPayload, - HeartbeatErrorPayload, + AgentWorkDonePayload, + AgentWorkSkipPayload, + AgentWorkErrorPayload, } from '../../core/agent-event.js' // ==================== Types ==================== export type DiaryOutcome = - | 'delivered' // heartbeat.done, delivered=true — CHAT_YES pushed to Chat - | 'silent-ok' // heartbeat.done delivered=false, or skip.reason=ack — silent HEARTBEAT_OK - | 'duplicate' // skip.reason=duplicate — same content as recent cycle - | 'empty' // skip.reason=empty — AI produced no content - | 'outside-hours' // skip.reason=outside-active-hours — quiet-hours guard tripped - | 'error' // heartbeat.error — AI call threw + | 'delivered' // agent.work.done, delivered=true, source=heartbeat + | 'silent-ok' // agent.work.done delivered=false, or skip.reason=ack + | 'duplicate' // skip.reason=duplicate + | 'empty' // skip.reason=empty + | 'outside-hours' // skip.reason=outside-active-hours + | 'error' // agent.work.error, source=heartbeat export interface DiaryCycle { seq: number @@ -49,7 +55,7 @@ export interface DiaryHistoryResponse { // ==================== Constants ==================== -const HEARTBEAT_EVENT_TYPES = ['heartbeat.done', 'heartbeat.skip', 'heartbeat.error'] as const +const AGENT_WORK_EVENT_TYPES = ['agent.work.done', 'agent.work.skip', 'agent.work.error'] as const /** Slack when joining session entries to cycles by timestamp — covers cron.fire → session.appendUser → ... → event.append gaps. */ const INCREMENTAL_SLACK_MS = 5_000 @@ -72,13 +78,19 @@ function getHeartbeatSession(): SessionStore { // ==================== Event → cycle mapping ==================== -/** Classify a heartbeat event into a user-visible outcome. */ +/** Type predicate: is this canonical agent-work event a heartbeat one? */ +function isHeartbeatAgentWorkEvent(entry: EventLogEntry): boolean { + const payload = entry.payload as { source?: string } | null | undefined + return payload?.source === 'heartbeat' +} + +/** Classify a heartbeat-sourced agent-work event into a user-visible outcome. */ export function outcomeFromEvent(entry: EventLogEntry): DiaryOutcome { - if (entry.type === 'heartbeat.done') { - return (entry.payload as HeartbeatDonePayload).delivered ? 'delivered' : 'silent-ok' + if (entry.type === 'agent.work.done') { + return (entry.payload as AgentWorkDonePayload).delivered ? 'delivered' : 'silent-ok' } - if (entry.type === 'heartbeat.skip') { - const reason = (entry.payload as HeartbeatSkipPayload).reason + if (entry.type === 'agent.work.skip') { + const reason = (entry.payload as AgentWorkSkipPayload).reason switch (reason) { case 'ack': return 'silent-ok' case 'duplicate': return 'duplicate' @@ -87,7 +99,7 @@ export function outcomeFromEvent(entry: EventLogEntry): DiaryOutcome { default: return 'silent-ok' } } - if (entry.type === 'heartbeat.error') return 'error' + if (entry.type === 'agent.work.error') return 'error' return 'silent-ok' } @@ -98,16 +110,19 @@ export function buildDiaryCycles(events: EventLogEntry[]): DiaryCycle[] { let reason: string | undefined let durationMs: number | undefined - if (e.type === 'heartbeat.done') { - const p = e.payload as HeartbeatDonePayload - reason = p.reason || undefined + if (e.type === 'agent.work.done') { + const p = e.payload as AgentWorkDonePayload durationMs = p.durationMs - } else if (e.type === 'heartbeat.skip') { - const p = e.payload as HeartbeatSkipPayload - // parsedReason is the AI's own wording; prefer it over the machine-facing reason code. - reason = p.parsedReason ?? p.reason - } else if (e.type === 'heartbeat.error') { - const p = e.payload as HeartbeatErrorPayload + // No source-specific reason field — done events just have reply text. + } else if (e.type === 'agent.work.skip') { + const p = e.payload as AgentWorkSkipPayload + // The heartbeat outputGate stuffs the (truncated) notify_user text into + // metadata.parsedReason for duplicate skips. Prefer that over the + // machine-facing reason code. + const parsedReason = (p.metadata as { parsedReason?: string } | undefined)?.parsedReason + reason = parsedReason ?? p.reason + } else if (e.type === 'agent.work.error') { + const p = e.payload as AgentWorkErrorPayload reason = p.error durationMs = p.durationMs } @@ -136,10 +151,12 @@ export function createDiaryRoutes(ctx: EngineContext) { // The ring buffer (~500 entries) gets saturated by high-frequency events // (snapshot.skipped, account.health), evicting older heartbeat entries — // the activity we care about here fires only ~every 30min. - // One disk scan with in-memory type filtering is cheaper than three. + // One disk scan with in-memory type+source filtering is cheaper than three. const allEvents = await ctx.eventLog.read({ afterSeq }) - const eventTypes = new Set(HEARTBEAT_EVENT_TYPES) - const events = allEvents.filter((e) => eventTypes.has(e.type)) + const typeSet = new Set(AGENT_WORK_EVENT_TYPES) + const events = allEvents.filter( + (e) => typeSet.has(e.type) && isHeartbeatAgentWorkEvent(e), + ) const cycles = buildDiaryCycles(events).slice(-limit) // Read heartbeat session entries. diff --git a/src/webui/routes/events.ts b/src/webui/routes/events.ts index 9f4a1c41..5e113d82 100644 --- a/src/webui/routes/events.ts +++ b/src/webui/routes/events.ts @@ -10,7 +10,7 @@ interface EventsDeps { ctx: EngineContext /** Producer for webhook-ingested events. Narrowed to the set of external * types; extend its declaration in WebPlugin when adding new external types. */ - ingestProducer: ProducerHandle + ingestProducer: ProducerHandle } /** Event log routes: GET /, GET /recent, GET /stream (SSE), POST /ingest, GET /auth-status */ @@ -78,7 +78,19 @@ export function createEventsRoutes({ ctx, ingestProducer }: EventsDeps) { const { type, payload } = body as { type: string; payload: unknown } - if (!isExternalEventType(type)) { + // Legacy alias: `task.requested` is the pre-AgentWork name for what + // is now `agent.work.requested { source: 'task' }`. Accept it on + // the wire and translate to the canonical form so existing external + // integrations don't break. Memory: "Don't delete our own exports". + let canonicalType = type + let canonicalPayload = payload + if (type === 'task.requested') { + canonicalType = 'agent.work.requested' + const p = (payload ?? {}) as { prompt?: string } + canonicalPayload = { source: 'task', prompt: p.prompt ?? '' } + } + + if (!isExternalEventType(canonicalType)) { return c.json( { error: `Event type '${type}' is not in the external allowlist` }, 403, @@ -86,7 +98,7 @@ export function createEventsRoutes({ ctx, ingestProducer }: EventsDeps) { } try { - validateEventPayload(type, payload) + validateEventPayload(canonicalType, canonicalPayload) } catch (err) { const msg = err instanceof Error ? err.message : String(err) return c.json({ error: msg }, 400) @@ -97,7 +109,7 @@ export function createEventsRoutes({ ctx, ingestProducer }: EventsDeps) { t: string, p: unknown, ) => Promise<{ seq: number; ts: number; type: string; payload: unknown }> - )(type, payload) + )(canonicalType, canonicalPayload) return c.json(entry, 201) })