From c7d011de25a0132b5ba4a55e9b6c3255678bbb74 Mon Sep 17 00:00:00 2001 From: Ame Date: Sun, 10 May 2026 19:34:54 +0800 Subject: [PATCH 1/8] feat(core): AgentWork primitive + ProviderResult.toolCalls plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `src/core/agent-work.ts` — the missing core primitive for "Alice does an async task outside chat". Trigger sources today (heartbeat / cron / task-router) and trigger sources tomorrow (factor mining, asset monitoring, ad-hoc scheduled DAGs) all share the same shape: take a payload, run the AI, optionally gate the notification, emit done/skip/error. AgentWork is that shape. API: class AgentWorkRunner { constructor({ agentCenter, connectorCenter, ... }) run(req: AgentWorkRequest, emit: EmitFn): Promise } AgentWorkRequest carries: prompt, session, preamble, metadata inputGate? (active-hours-style pre-AI guard) outputGate? (notify_user-style post-AI gate) onDelivered? emitNames + buildDonePayload + buildSkipPayload? + buildErrorPayload The runner is stateless — construct once at startup, call run() per request with the listener's per-call emit fn. Class form (rather than free function) keeps `src/core/` style consistent with AgentCenter / ConnectorCenter / NotificationsStore. Also surfaces `toolCalls` on `ProviderResult` (additive change). The existing pipeline already accumulates tool_use events as they stream through; AgentCenter now packages them into the final done event so AgentWork's outputGate can inspect "did the AI call notify_user?" without re-streaming. Test coverage: `src/core/agent-work.spec.ts` — 37 tests across: - default behaviour (no gates) — happy path - inputGate — null vs skip, AI-not-invoked, custom payload - outputGate — deliver/skip/probe inspection - notify_user-style tool inspection (load-bearing for heartbeat) - AI invocation errors — throw, non-Error, emit failure - notify failure — done with delivered=false, hook not called - onDelivered hook — called/not-called/throws - clock injection — durationMs honors injected now() - source label flow-through - concurrent runs (stateless runner) Followup commits migrate cron / task-router / heartbeat to use this primitive; this commit is just the primitive + plumbing, no consumers yet. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ai-providers/types.ts | 13 + src/core/agent-center.ts | 10 +- src/core/agent-work.spec.ts | 625 ++++++++++++++++++++++++++++++++++++ src/core/agent-work.ts | 297 +++++++++++++++++ 4 files changed, 944 insertions(+), 1 deletion(-) create mode 100644 src/core/agent-work.spec.ts create mode 100644 src/core/agent-work.ts 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-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, + ) + } + } +} From 014b8442c1ac091debba066b58cc3405e25437a8 Mon Sep 17 00:00:00 2001 From: Ame Date: Sun, 10 May 2026 19:35:05 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(tool):=20add=20notify=5Fuser=20?= =?UTF-8?q?=E2=80=94=20intent=20signal=20for=20autonomous=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the heartbeat STATUS regex protocol with a structured tool call. AI-decides-to-notify becomes "AI calls notify_user(text)"; runner-side outputGate inspects the captured tool calls and routes through dedup / connectorCenter.notify. The tool's `execute` is intentionally a no-op (returns the args back as acknowledgement). Why no side-effects: heartbeat applies dedup before push; if the tool itself called connectorCenter.notify, we'd have no way to gate on dedup without per-tool source state. The runner-side gate is the right control point. The tool just records intent + arguments. Globally registered in ToolCenter — every session sees it. But only sessions whose persona prompt teaches AI when to call it (today only: heartbeat) actually exercise it. cron / task-router / chat keep their existing "every reply pushes" behaviour because their prompts don't reference notify_user. Followup commit teaches heartbeat's persona about it and wires the runner-side gate. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tool/notify-user.ts | 63 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/tool/notify-user.ts 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' } + }, + }), + } +} From b857d7a539512c1e4e3ab1614dc72a10259d0db5 Mon Sep 17 00:00:00 2001 From: Ame Date: Sun, 10 May 2026 19:35:37 +0800 Subject: [PATCH 3/8] refactor(task): migrate heartbeat / cron / task-router to AgentWorkRunner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three trigger sources collapse into thin configurations of the AgentWork primitive. The shared body (subscribe → AI call → notify → emit done/error) lives in AgentWorkRunner; each listener is now just "how to translate a trigger event into an AgentWorkRequest". heartbeat (src/task/heartbeat/heartbeat.ts): - delete parseHeartbeatResponse() and the entire STATUS regex protocol — Alice now signals notification intent via the notify_user tool, not by emitting magic string tokens - default persona prompt rewritten to teach notify_user instead of STATUS / REASON / CONTENT format - active-hours guard becomes the runner's inputGate - notify_user inspection + dedup checks become the runner's outputGate; dedup record happens via onDelivered - HeartbeatDedup, isWithinActiveHours, the `__heartbeat__` cron job lifecycle, hot enable/disable — all kept (heartbeat-specific) - HeartbeatDedup.lastText is now public (load-bearing for the done event's `reply` field) - 410 → ~290 lines cron (src/task/cron/listener.ts): - 135 → ~110 lines - public API (createCronListener, CronListener, CronListenerOpts) preserved; just takes agentWorkRunner instead of agentCenter + connectorCenter - serial-execution lock + internal-job filter still here, since those are cron-specific (factory's pre-AI hook is the inputGate on a per-request basis; cron's `processing` lock is a listener- instance concern that pre-dates the request) task-router (src/task/task-router/listener.ts): - 122 → ~100 lines - same migration as cron - public API preserved main.ts: - constructs AgentWorkRunner once, threads to all three listeners - registers notify_user tool in toolCenter (globally available) heartbeat.spec.ts: rewritten — STATUS-regex tests deleted, replaced with notify_user-tool-call equivalents. New tests: - delivers when AI invokes notify_user (replaces "should call AI and write heartbeat.done") - skips with reason=ack when AI does not call notify_user (replaces "should skip HEARTBEAT_OK") - skips with reason=empty when notify_user.text is blank - explicit guard: STATUS-shaped raw text without notify_user is NOT delivered (anti-regression) - dedup: different texts not deduped - active-hours: outside window does not invoke AI - lifecycle / setEnabled / error handling preserved Test count: 35 → 28 (the parseHeartbeatResponse standalone test block, ~10 tests, deleted alongside the function it tested) cron + task-router specs: minimal setup change to construct via AgentWorkRunner; assertions unchanged. Full suite: 1622/1622 passing (was 1592 before). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main.ts | 13 +- src/task/cron/listener.spec.ts | 8 +- src/task/cron/listener.ts | 88 +++--- src/task/heartbeat/heartbeat.spec.ts | 422 +++++++++++++------------- src/task/heartbeat/heartbeat.ts | 325 +++++++++----------- src/task/heartbeat/index.ts | 4 +- src/task/task-router/listener.spec.ts | 11 +- src/task/task-router/listener.ts | 74 +++-- 8 files changed, 442 insertions(+), 503 deletions(-) diff --git a/src/main.ts b/src/main.ts index 6e000456..e8201791 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' @@ -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,11 +281,15 @@ 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 }) + // ==================== Cron Listener ==================== const cronSession = new SessionStore('cron/default') await cronSession.restore() - const cronListener = createCronListener({ connectorCenter, agentCenter, registry: listenerRegistry, session: cronSession }) + const cronListener = createCronListener({ agentWorkRunner, registry: listenerRegistry, session: cronSession }) await cronListener.start() // ==================== Snapshot Scheduler ==================== @@ -297,7 +304,7 @@ async function main() { const heartbeat = createHeartbeat({ config: config.heartbeat, - connectorCenter, cronEngine, agentCenter, registry: listenerRegistry, + agentWorkRunner, cronEngine, registry: listenerRegistry, }) await heartbeat.start() if (config.heartbeat.enabled) { @@ -306,7 +313,7 @@ async function main() { // ==================== Task Router (external `task.requested` handler) ==================== - const taskRouter = createTaskRouter({ connectorCenter, agentCenter, registry: listenerRegistry }) + const taskRouter = createTaskRouter({ agentWorkRunner, registry: listenerRegistry }) await taskRouter.start() // ==================== Event Metrics (wildcard observer) ==================== diff --git a/src/task/cron/listener.spec.ts b/src/task/cron/listener.spec.ts index 35d4af9c..a7f7c09f 100644 --- a/src/task/cron/listener.spec.ts +++ b/src/task/cron/listener.spec.ts @@ -9,6 +9,7 @@ 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' function tempPath(ext: string): string { return join(tmpdir(), `cron-listener-test-${randomUUID()}.${ext}`) @@ -54,9 +55,12 @@ describe('cron listener', () => { notificationsStore = createMemoryNotificationsStore() connectorCenter = new ConnectorCenter({ notificationsStore }) - cronListener = createCronListener({ - connectorCenter, + const agentWorkRunner = new AgentWorkRunner({ agentCenter: mockEngine as any, + connectorCenter, + }) + cronListener = createCronListener({ + agentWorkRunner, registry, session, }) diff --git a/src/task/cron/listener.ts b/src/task/cron/listener.ts index e4366aae..bfc0e95e 100644 --- a/src/task/cron/listener.ts +++ b/src/task/cron/listener.ts @@ -1,21 +1,25 @@ /** - * Cron Listener — subscribes to `cron.fire` events from the EventLog - * and routes them through the AgentCenter for processing. + * Cron Listener — subscribes to `cron.fire` events and submits each as + * an AgentWork to the runner. The runner owns the AI call → notify → + * emit pipeline; this listener is a thin trigger source that: * - * Flow: - * eventLog 'cron.fire' → agentCenter.askWithSession(payload, session) - * → connectorCenter.notify(reply) - * → ctx.emit 'cron.done' / 'cron.error' + * 1. Filters out internal `__*__` jobs (heartbeat / snapshot have + * their own handlers) + * 2. Enforces serial execution (no overlapping cron handlings) + * 3. Builds an AgentWorkRequest with cron-shaped emit names + done + * payload + * 4. Delegates to `runner.run` * - * The listener owns a dedicated SessionStore for cron conversations, - * independent of user chat sessions (Telegram, Web, etc.). + * No notification policy lives here — every successful cron reply is + * pushed (the AgentWork default). If a future cron job wants + * AI-decides-to-notify semantics, its prompt can teach Alice about + * `notify_user` and supply an outputGate; the listener stays unchanged. */ 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 type { AgentWorkRunner } from '../../core/agent-work.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' @@ -30,8 +34,7 @@ const CRON_EMITS = ['cron.done', 'cron.error'] as const type CronEmits = typeof CRON_EMITS export interface CronListenerOpts { - connectorCenter: ConnectorCenter - agentCenter: AgentCenter + agentWorkRunner: AgentWorkRunner /** Registry to auto-register this listener with. */ registry: ListenerRegistry /** Optional: inject a session for testing. Otherwise creates a dedicated cron session. */ @@ -50,7 +53,7 @@ export interface CronListener { // ==================== Factory ==================== export function createCronListener(opts: CronListenerOpts): CronListener { - const { connectorCenter, agentCenter, registry } = opts + const { agentWorkRunner, registry } = opts const session = opts.session ?? new SessionStore('cron/default') let processing = false @@ -66,50 +69,39 @@ export function createCronListener(opts: CronListenerOpts): CronListener { ): Promise { const payload = entry.payload - // Guard: internal jobs (__heartbeat__, __snapshot__, etc.) have dedicated handlers + // Internal jobs (__heartbeat__, __snapshot__, etc.) have dedicated handlers if (isInternalJob(payload.jobName)) return - // Guard: skip if already processing (serial execution) + // Serial execution — preserves today's behaviour 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 agentWorkRunner.run( + { + prompt: payload.payload, + session, + preamble: `You are operating in the cron job context (session: cron/default, job: ${payload.jobName}). This is an automated cron job execution.`, + metadata: { source: 'cron', jobId: payload.jobId, jobName: payload.jobName }, + emitNames: { done: 'cron.done', error: 'cron.error' }, + buildDonePayload: (req, result, durationMs) => ({ + jobId: req.metadata.jobId as string, + jobName: req.metadata.jobName as string, + reply: result.text, + durationMs, + }), + buildErrorPayload: (req, err, durationMs) => ({ + jobId: req.metadata.jobId as string, + jobName: req.metadata.jobName as string, + error: err.message, + durationMs, + }), + }, + ctx.emit as never, + ) } finally { processing = false } diff --git a/src/task/heartbeat/heartbeat.spec.ts b/src/task/heartbeat/heartbeat.spec.ts index d44e7652..e69b7763 100644 --- a/src/task/heartbeat/heartbeat.spec.ts +++ b/src/task/heartbeat/heartbeat.spec.ts @@ -1,3 +1,26 @@ +/** + * Heartbeat tests — exercises the full trigger-source pipeline: + * + * cron.fire (__heartbeat__) + * → handleFire() + * → AgentWorkRunner.run() + * → inputGate (active-hours) + * → AI invocation + * → outputGate (notify_user inspection + dedup) + * → connectorCenter.notify (optional) + * → emit done / skip / error + * + * The legacy STATUS regex protocol is gone. Heartbeat now signals + * notification intent via the `notify_user` tool — these tests mock + * the AgentCenter result to include or omit the tool call, and assert + * on the resulting events. + * + * AgentWork primitive coverage lives in `src/core/agent-work.spec.ts`; + * this file tests heartbeat-specific behaviours: cron job lifecycle, + * active-hours filtering, dedup window, hot enable/disable, and the + * heartbeat-specific outputGate semantics. + */ + import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -7,7 +30,6 @@ import { createListenerRegistry, type ListenerRegistry } from '../../core/listen import { createCronEngine, type CronEngine } from '../cron/engine.js' import { createHeartbeat, - parseHeartbeatResponse, isWithinActiveHours, HeartbeatDedup, HEARTBEAT_JOB_NAME, @@ -17,6 +39,8 @@ import { 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 type { ToolCallSummary } from '../../ai-providers/types.js' // Mock writeConfigSection to avoid disk writes in tests vi.mock('../../core/config.js', () => ({ @@ -38,22 +62,49 @@ function makeConfig(overrides: Partial = {}): HeartbeatConfig { } // ==================== Mock Engine ==================== +// +// Returns `{ text, media, toolCalls }` from `askWithSession`. The +// runner unwraps these as ProviderResult; toolCalls is what the +// heartbeat outputGate inspects for notify_user invocations. + +interface MockEngineState { + text: string + toolCalls: ToolCallSummary[] + shouldThrow: Error | null +} -const CHAT_YES_RESPONSE = `STATUS: CHAT_YES -REASON: Significant price movement detected. -CONTENT: Market alert: BTC dropped 5%` +function createMockEngine(initial: Partial = {}) { + const state: MockEngineState = { + text: '', + toolCalls: [], + shouldThrow: null, + ...initial, + } -function createMockEngine(response = CHAT_YES_RESPONSE) { 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 @@ -63,13 +114,14 @@ describe('heartbeat', () => { let session: SessionStore let connectorCenter: ConnectorCenter let notificationsStore: ReturnType + let agentWorkRunner: AgentWorkRunner beforeEach(async () => { const logPath = tempPath('jsonl') const storePath = tempPath('json') eventLog = await createEventLog({ logPath }) listenerRegistry = createListenerRegistry(eventLog) - await listenerRegistry.start() // Start registry so late registrations subscribe immediately + await listenerRegistry.start() cronEngine = createCronEngine({ registry: listenerRegistry, storePath }) await cronEngine.start() @@ -77,6 +129,10 @@ describe('heartbeat', () => { session = new SessionStore(`test/heartbeat-${randomUUID()}`) notificationsStore = createMemoryNotificationsStore() connectorCenter = new ConnectorCenter({ notificationsStore }) + agentWorkRunner = new AgentWorkRunner({ + agentCenter: mockEngine as never, + connectorCenter, + }) }) afterEach(async () => { @@ -92,9 +148,7 @@ describe('heartbeat', () => { it('should register a cron job on start', async () => { heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() @@ -108,37 +162,27 @@ describe('heartbeat', () => { 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, + agentWorkRunner, cronEngine, 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, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) - await heartbeat.start() const jobs = cronEngine.list() - expect(jobs).toHaveLength(1) // not 2 + expect(jobs).toHaveLength(1) expect(jobs[0].schedule).toEqual({ kind: 'every', every: '1h' }) }) it('should register disabled job when config.enabled is false', async () => { heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) - await heartbeat.start() const jobs = cronEngine.list() @@ -148,106 +192,109 @@ describe('heartbeat', () => { }) }) - // ==================== Event Handling ==================== + // ==================== Event Handling: notify_user contract ==================== describe('event handling', () => { - it('should call AI and write heartbeat.done on real response', async () => { + it('delivers when AI invokes 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, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() - // Simulate cron.fire for heartbeat await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { - const done = eventLog.recent({ type: 'heartbeat.done' }) - expect(done).toHaveLength(1) + expect(eventLog.recent({ type: 'heartbeat.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: 'heartbeat.done' }) expect(done[0].payload).toMatchObject({ - reply: 'Market alert: BTC dropped 5%', + reply: 'BTC dropped 5% to $87,200', delivered: true, }) }) - it('should skip HEARTBEAT_OK responses', async () => { - mockEngine.setResponse('STATUS: HEARTBEAT_OK\nREASON: All systems normal.') + it('skips with reason=ack when AI does not call notify_user', async () => { + mockEngine.setRawText('Checked. Nothing notable in the last 30 minutes.') + mockEngine.setNoToolCall() heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { - const skips = eventLog.recent({ type: 'heartbeat.skip' }) - expect(skips).toHaveLength(1) + expect(eventLog.recent({ type: 'heartbeat.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(skips[0].payload).toMatchObject({ reason: 'ack' }) + // No notify, no done expect(eventLog.recent({ type: 'heartbeat.done' })).toHaveLength(0) }) - it('should deliver unparsed responses (fail-open)', async () => { - const delivered: string[] = [] - notificationsStore.onAppended((entry) => { delivered.push(entry.text) }) + it('skips with reason=empty when notify_user.text is blank', async () => { + mockEngine.setNotifyUserCall(' ') + + heartbeat = createHeartbeat({ + config: makeConfig(), + agentWorkRunner, cronEngine, registry: listenerRegistry, session, + }) + await heartbeat.start() + + await cronEngine.runNow(cronEngine.list()[0].id) - // Raw text without structured format - mockEngine.setResponse('BTC just crashed 15%, major liquidation event!') + await vi.waitFor(() => { + expect(eventLog.recent({ type: 'heartbeat.skip' })).toHaveLength(1) + }) + + expect((eventLog.recent({ type: 'heartbeat.skip' })[0].payload as { reason: string }).reason).toBe('empty') + }) + + it('does NOT regex-parse the AI response — STATUS-shaped text without notify_user is still skipped', async () => { + // Old protocol response — must NOT trigger any notification under + // the new contract. The AI must call the tool to deliver. + mockEngine.setRawText('STATUS: CHAT_YES\nREASON: x\nCONTENT: this should NOT be delivered') + mockEngine.setNoToolCall() heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { - const done = eventLog.recent({ type: 'heartbeat.done' }) - expect(done).toHaveLength(1) + expect(eventLog.recent({ type: 'heartbeat.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('ignores non-heartbeat cron.fire events', async () => { heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() - // Fire a non-heartbeat cron event await eventLog.append('cron.fire', { jobId: 'other-job', jobName: 'check-eth', payload: 'Check ETH price', }) - await new Promise((r) => setTimeout(r, 50)) expect(mockEngine.askWithSession).not.toHaveBeenCalled() @@ -257,17 +304,14 @@ 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('skips when 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, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, now: () => fakeNow, }) await heartbeat.start() @@ -275,12 +319,11 @@ describe('heartbeat', () => { await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { - const skips = eventLog.recent({ type: 'heartbeat.skip' }) - expect(skips).toHaveLength(1) + expect(eventLog.recent({ type: 'heartbeat.skip' })).toHaveLength(1) }) const skips = eventLog.recent({ type: 'heartbeat.skip' }) - expect(skips[0].payload).toMatchObject({ reason: 'outside-active-hours' }) + expect((skips[0].payload as { reason: string }).reason).toBe('outside-active-hours') expect(mockEngine.askWithSession).not.toHaveBeenCalled() }) }) @@ -288,88 +331,106 @@ describe('heartbeat', () => { // ==================== 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, + agentWorkRunner, cronEngine, 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. + // First fire — delivered await cronEngine.runNow(jobId) await vi.waitFor(() => { expect(eventLog.recent({ type: 'heartbeat.done' })).toHaveLength(1) }) - // Second fire (same response) — should be suppressed + // Second fire (same notify_user text) — should be deduped await cronEngine.runNow(jobId) await vi.waitFor(() => { const skips = eventLog.recent({ type: 'heartbeat.skip' }) - expect(skips.some((s) => (s.payload as any).reason === 'duplicate')).toBe(true) + expect(skips.some((s) => (s.payload as { reason: string }).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(), + agentWorkRunner, cronEngine, registry: listenerRegistry, session, + }) + await heartbeat.start() + const jobId = cronEngine.list()[0].id + + mockEngine.setNotifyUserCall('First alert') + await cronEngine.runNow(jobId) + await vi.waitFor(() => { + expect(delivered).toHaveLength(1) + }) + + mockEngine.setNotifyUserCall('Second different alert') + await cronEngine.runNow(jobId) + 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 heartbeat.error on AI failure', async () => { + mockEngine.setShouldThrow(new Error('AI down')) heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { - const errors = eventLog.recent({ type: 'heartbeat.error' }) - expect(errors).toHaveLength(1) + expect(eventLog.recent({ type: 'heartbeat.error' })).toHaveLength(1) }) const errors = eventLog.recent({ type: 'heartbeat.error' }) expect(errors[0].payload).toMatchObject({ error: '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 gracefully — emits done with delivered=false', async () => { + mockEngine.setNotifyUserCall('alert text') + // Force the underlying append to reject. The runner should still + // emit done with delivered=false; the listener should not crash. 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, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() await cronEngine.runNow(cronEngine.list()[0].id) await vi.waitFor(() => { - const done = eventLog.recent({ type: 'heartbeat.done' }) - expect(done).toHaveLength(1) + expect(eventLog.recent({ type: 'heartbeat.done' })).toHaveLength(1) }) const done = eventLog.recent({ type: 'heartbeat.done' }) - expect((done[0].payload as any).delivered).toBe(false) + expect((done[0].payload as { delivered: boolean }).delivered).toBe(false) notificationsStore.append = originalAppend }) @@ -378,12 +439,10 @@ describe('heartbeat', () => { // ==================== Lifecycle ==================== describe('lifecycle', () => { - it('should stop listening after stop()', async () => { + it('stops listening after stop()', async () => { heartbeat = createHeartbeat({ config: makeConfig(), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() heartbeat.stop() @@ -398,12 +457,10 @@ describe('heartbeat', () => { // ==================== setEnabled / isEnabled ==================== 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, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() @@ -416,12 +473,10 @@ describe('heartbeat', () => { 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, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() @@ -433,128 +488,53 @@ describe('heartbeat', () => { 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, + agentWorkRunner, cronEngine, 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('allows firing after setEnabled(true)', async () => { const delivered: string[] = [] notificationsStore.onAppended((entry) => { delivered.push(entry.text) }) + mockEngine.setNotifyUserCall('after-enable') + heartbeat = createHeartbeat({ config: makeConfig({ enabled: false }), - connectorCenter, cronEngine, registry: listenerRegistry, - agentCenter: mockEngine as any, - session, + agentWorkRunner, cronEngine, registry: listenerRegistry, session, }) await heartbeat.start() - - // 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) }) + expect(delivered[0]).toBe('after-enable') }) }) }) -// ==================== 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' }, @@ -562,8 +542,7 @@ describe('isWithinActiveHours', () => { )).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' }, @@ -571,63 +550,68 @@ describe('isWithinActiveHours', () => { )).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 (load-bearing for buildDonePayload)', () => { + 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..9978d3eb 100644 --- a/src/task/heartbeat/heartbeat.ts +++ b/src/task/heartbeat/heartbeat.ts @@ -1,29 +1,40 @@ /** * Heartbeat — periodic AI self-check, built on top of the cron engine. * - * 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) + * Registers a cron job (`__heartbeat__`) that fires at a configured + * interval. Each fire is submitted to AgentWorkRunner with two gates: * - * Events written to eventLog: - * - heartbeat.done { reply, durationMs, delivered } - * - heartbeat.skip { reason } + * - **inputGate**: active-hours filter — skip without spending tokens + * when outside the configured window + * - **outputGate**: inspect AI's tool calls — if `notify_user` was + * invoked, deliver its `text` arg (after dedup); otherwise skip + * silently with reason='ack' + * - **onDelivered**: record dedup state on successful delivery + * + * Replaces the legacy STATUS regex protocol (`STATUS: HEARTBEAT_OK | + * CHAT_YES + CONTENT: ...`) with structured tool-call signalling. The + * runner-side gate handles dedup before the notification reaches + * connectors, which means duplicate suppression and active-hours + * filtering are uniform across configurations. + * + * Events emitted: + * - heartbeat.done { reply, reason, durationMs, delivered } + * - heartbeat.skip { reason, parsedReason? } * - heartbeat.error { error, durationMs } + * + * Heartbeat-specific state stays in this module: + * - `HeartbeatDedup` — in-memory 24h window + * - `__heartbeat__` cron job lifecycle (idempotent add/update, + * hot-toggle via setEnabled) + * - active-hours config + tz-aware time-of-day check */ -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 type { AgentWorkRunner, AgentWorkResultProbe } from '../../core/agent-work.js' +import type { Listener } from '../../core/listener.js' +import type { ListenerRegistry } from '../../core/listener-registry.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 @@ -51,29 +62,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: + 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. -## Rules +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. -- If in doubt, prefer CHAT_YES over HEARTBEAT_OK — better to over-report than to miss something. -- Keep CONTENT concise but actionable. +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. -## Examples - -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,9 +78,8 @@ CONTENT: BTC just dropped 8% in the last hour — now at $87,200. This may trigg export interface HeartbeatOpts { config: HeartbeatConfig - connectorCenter: ConnectorCenter + agentWorkRunner: AgentWorkRunner cronEngine: CronEngine - agentCenter: AgentCenter /** Registry to auto-register the heartbeat listener with. */ registry: ListenerRegistry /** Optional: inject a session for testing. */ @@ -106,7 +102,7 @@ export interface Heartbeat { // ==================== Factory ==================== export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { - const { config, connectorCenter, cronEngine, agentCenter, registry } = opts + const { config, agentWorkRunner, cronEngine, registry } = opts const session = opts.session ?? new SessionStore('heartbeat') const now = opts.now ?? Date.now @@ -117,111 +113,112 @@ export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { 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 - } + const listener: Listener<'cron.fire', HeartbeatEmits> = { + name: 'heartbeat', + subscribes: 'cron.fire', + emits: HEARTBEAT_EMITS, + async handle(entry, ctx) { + const payload = entry.payload - // 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 - } + // Filter to our own cron job + if (payload.jobName !== HEARTBEAT_JOB_NAME) return - // CHAT_YES (or unparsed fallback) - const text = parsed.content || result.text - if (!text.trim()) { - console.log(`heartbeat: skipped (empty content) (${durationMs}ms)`) - await ctx.emit('heartbeat.skip', { reason: 'empty' }) - return - } + // Serial — preserve today's behaviour. Concurrent heartbeats would + // be ambiguous wrt dedup state. + if (processing) return - // 4. Dedup - if (dedup.isDuplicate(text, now())) { - console.log(`heartbeat: skipped (duplicate) (${durationMs}ms)`) - await ctx.emit('heartbeat.skip', { reason: 'duplicate' }) - return - } - - // 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 + processing = true + const startMs = now() + console.log(`heartbeat: firing at ${new Date(startMs).toISOString()}`) 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) + const result = await agentWorkRunner.run( + { + prompt: payload.payload, + session, + preamble: + 'You are operating in the heartbeat monitoring context (session: heartbeat). The following is the recent heartbeat conversation history.', + metadata: { source: 'heartbeat' }, + + // ---- inputGate: active-hours guard ---- + inputGate: () => + isWithinActiveHours(config.activeHours, now()) + ? null + : { + reason: 'outside-active-hours', + payload: { reason: 'outside-active-hours' }, + }, + + // ---- outputGate: notify_user inspection + dedup ---- + outputGate: (probe: AgentWorkResultProbe) => { + const call = probe.toolCalls.find((c) => c.name === 'notify_user') + if (!call) { + return { + kind: 'skip', + reason: 'ack', + payload: { reason: 'ack' }, + } + } + const text = ((call.input ?? {}) as { text?: string }).text ?? '' + if (!text.trim()) { + return { + kind: 'skip', + reason: 'empty', + payload: { reason: 'empty' }, + } + } + if (dedup.isDuplicate(text, now())) { + return { + kind: 'skip', + reason: 'duplicate', + payload: { reason: 'duplicate', parsedReason: text.slice(0, 80) }, + } + } + return { kind: 'deliver', text, media: probe.media } + }, + + // ---- onDelivered: record dedup state ---- + onDelivered: (text) => dedup.record(text, now()), + + emitNames: { + done: 'heartbeat.done', + skip: 'heartbeat.skip', + error: 'heartbeat.error', + }, + buildDonePayload: (_req, _result, durationMs, delivered) => { + // Look up what we actually delivered (the text the AI passed + // through notify_user). The runner already invoked notify with + // the gate's chosen text; for the done payload we re-derive it + // from the dedup state — `dedup.lastText` is what we just sent. + const reply = dedup.lastText ?? '' + return { + reply, + reason: 'notify_user', + durationMs, + delivered, + } + }, + buildErrorPayload: (_req, err, durationMs) => ({ + error: err.message, + durationMs, + }), + }, + ctx.emit as never, + ) + + const durationMs = now() - startMs + console.log( + `heartbeat: ${result.outcome}` + + (result.skipReason ? ` reason=${result.skipReason}` : '') + + ` (${durationMs}ms)`, + ) + } finally { + processing = false } - - 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 - } - } - - const listener: Listener<'cron.fire', HeartbeatEmits> = { - name: 'heartbeat', - subscribes: 'cron.fire', - emits: HEARTBEAT_EMITS, - handle: handleFire, + }, } /** 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 @@ -239,7 +236,6 @@ export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { }) } - // Register listener exactly once if (!registered) { registry.register(listener) registered = true @@ -278,55 +274,6 @@ export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { } } -// ==================== 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 } - } - - 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 ==================== /** @@ -392,9 +339,15 @@ function currentMinutesInTimezone(tz: string, nowMs?: number): number { /** * Suppress identical heartbeat messages within a time window (default 24h). + * + * In-memory only — restart loses dedup state. Acceptable trade-off: + * heartbeat fires every ~30m by default, so a restart-window + * collision is rare and the cost (one duplicate notification) is low. */ export class HeartbeatDedup { - private lastText: string | null = null + /** Public for the heartbeat factory's `buildDonePayload` to read the + * most-recently-delivered text without an extra signal channel. */ + 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..9986bbe3 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, HEARTBEAT_JOB_NAME, 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/listener.spec.ts b/src/task/task-router/listener.spec.ts index a7fc8b87..91ca7c45 100644 --- a/src/task/task-router/listener.spec.ts +++ b/src/task/task-router/listener.spec.ts @@ -8,6 +8,8 @@ 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' +import { createMemoryNotificationsStore } from '../../core/notifications-store.js' +import { AgentWorkRunner } from '../../core/agent-work.js' function tempPath(ext: string): string { return join(tmpdir(), `task-router-test-${randomUUID()}.${ext}`) @@ -45,11 +47,14 @@ describe('task router', () => { registry = createListenerRegistry(eventLog) mockEngine = createMockEngine() session = new SessionStore(`test/task-${randomUUID()}`) - connectorCenter = new ConnectorCenter() + connectorCenter = new ConnectorCenter({ notificationsStore: createMemoryNotificationsStore() }) - taskRouter = createTaskRouter({ - connectorCenter, + const agentWorkRunner = new AgentWorkRunner({ agentCenter: mockEngine as any, + connectorCenter, + }) + taskRouter = createTaskRouter({ + agentWorkRunner, registry, session, }) diff --git a/src/task/task-router/listener.ts b/src/task/task-router/listener.ts index 69c25a20..4ad7470d 100644 --- a/src/task/task-router/listener.ts +++ b/src/task/task-router/listener.ts @@ -1,23 +1,27 @@ /** * Task Router — subscribes to externally-ingested `task.requested` events - * and routes them through the AgentCenter for one-shot processing. + * (POST /api/events/ingest) and submits each as an AgentWork. * * 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' + * → AgentWorkRunner: AI call → notify → emit task.done / task.error * - * The listener owns a dedicated SessionStore for externally-triggered tasks - * (`task/default`), independent of cron, heartbeat, and chat sessions. + * The listener owns a dedicated SessionStore for externally-triggered + * tasks (`task/default`), independent of cron, heartbeat, and chat + * sessions, so external callers don't accidentally see (or pollute) + * conversation history that wasn't meant for them. + * + * Like cron, this listener does NOT teach Alice about `notify_user` — + * external tasks default to the same "every reply pushes" behaviour. + * A specific external integration that wants AI-decides-to-notify + * semantics would set that up in its own prompt + outputGate. */ 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 type { AgentWorkRunner } from '../../core/agent-work.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' @@ -27,8 +31,7 @@ const TASK_EMITS = ['task.done', 'task.error'] as const type TaskEmits = typeof TASK_EMITS export interface TaskRouterOpts { - connectorCenter: ConnectorCenter - agentCenter: AgentCenter + agentWorkRunner: AgentWorkRunner /** Registry to auto-register this listener with. */ registry: ListenerRegistry /** Optional: inject a session for testing. Otherwise creates `task/default`. */ @@ -47,7 +50,7 @@ export interface TaskRouter { // ==================== Factory ==================== export function createTaskRouter(opts: TaskRouterOpts): TaskRouter { - const { connectorCenter, agentCenter, registry } = opts + const { agentWorkRunner, registry } = opts const session = opts.session ?? new SessionStore('task/default') let processing = false @@ -63,42 +66,33 @@ export function createTaskRouter(opts: TaskRouterOpts): TaskRouter { ): 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, - }) + await agentWorkRunner.run( + { + prompt: payload.prompt, + session, + preamble: `You are handling an externally-triggered task (session: task/default). Follow the prompt and reply with what the caller needs.`, + metadata: { source: 'task', prompt: payload.prompt }, + emitNames: { done: 'task.done', error: 'task.error' }, + buildDonePayload: (req, result, durationMs) => ({ + prompt: req.metadata.prompt as string, + reply: result.text, + durationMs, + }), + buildErrorPayload: (req, err, durationMs) => ({ + prompt: req.metadata.prompt as string, + error: err.message, + durationMs, + }), + }, + ctx.emit as never, + ) } finally { processing = false } From 365bbde12fb60692f2954e11641935ce2cea6243 Mon Sep 17 00:00:00 2001 From: Wei Bin Date: Thu, 30 Apr 2026 20:28:23 +0800 Subject: [PATCH 4/8] fix: re-apply fixes and enhancements on top of latest upstream/dev - Add resolveContract helper to UnifiedTradingAccount for aliceId resolution - Implement config hot-reloading for Heartbeat task - Refactor Telegram /compact command to use AgentCenter.forceCompact (removes Agent SDK hardcoding) - Ensure Heartbeat status synchronization with CronEngine - Resolve shadowning of setInterval in UI components - Update trading tools to use UNSET_DECIMAL instead of UNSET_DOUBLE --- src/connectors/telegram/telegram-plugin.ts | 10 +- src/core/agent-center.ts | 11 +- src/domain/trading/UnifiedTradingAccount.ts | 24 +++ src/task/heartbeat/heartbeat.ts | 163 +------------------- src/webui/routes/config.ts | 3 + 5 files changed, 47 insertions(+), 164 deletions(-) diff --git a/src/connectors/telegram/telegram-plugin.ts b/src/connectors/telegram/telegram-plugin.ts index 2a6b1fcd..1709da6b 100644 --- a/src/connectors/telegram/telegram-plugin.ts +++ b/src/connectors/telegram/telegram-plugin.ts @@ -30,6 +30,7 @@ export class TelegramPlugin implements Plugin { private agentSdkConfig: AgentSdkConfig private bot: Bot | null = null private connectorCenter: ConnectorCenter | null = null + private ctx: EngineContext | null = null private merger: MediaGroupMerger | null = null private unregisterConnector?: () => void private unsubscribeNotifications?: () => void @@ -50,6 +51,7 @@ export class TelegramPlugin implements Plugin { } async start(engineCtx: EngineContext) { + this.ctx = engineCtx this.connectorCenter = engineCtx.connectorCenter this.webPort = engineCtx.config.connectors.web.port @@ -355,13 +357,7 @@ export class TelegramPlugin implements Plugin { const session = await this.getSession(userId) await this.sendReply(chatId, '> Compacting session...') - const result = await forceCompact( - session, - async (summarizePrompt) => { - const r = await askAgentSdk(summarizePrompt, { ...this.agentSdkConfig, maxTurns: 1 }) - return r.text - }, - ) + const result = await this.ctx!.agentCenter.forceCompact(session) if (!result) { await this.sendReply(chatId, 'Session is empty, nothing to compact.') diff --git a/src/core/agent-center.ts b/src/core/agent-center.ts index bb2a5598..6a5df902 100644 --- a/src/core/agent-center.ts +++ b/src/core/agent-center.ts @@ -20,7 +20,7 @@ import { resolveProfile, resolveCredential } from './config.js' import { profileToCredential } from './credential-inference.js' import type { ISessionStore, ContentBlock } from './session.js' import type { CompactionConfig } from './compaction.js' -import { compactIfNeeded } from './compaction.js' +import { compactIfNeeded, forceCompact } from './compaction.js' import type { MediaAttachment } from './types.js' import { extractMediaFromToolResultContent } from './media.js' import { persistMedia } from './media-store.js' @@ -91,6 +91,15 @@ export class AgentCenter { return new StreamableResult(this._generate(prompt, session, opts)) } + /** Force a full compaction (summarization) of the session. */ + async forceCompact(session: ISessionStore, opts?: AskOptions): Promise<{ preTokens: number } | null> { + const { provider } = await this.router.resolve(opts?.profileSlug) + return forceCompact(session, async (summarizePrompt) => { + const result = await provider.ask(summarizePrompt) + return result.text + }) + } + // ==================== Pipeline ==================== private async *_generate( diff --git a/src/domain/trading/UnifiedTradingAccount.ts b/src/domain/trading/UnifiedTradingAccount.ts index 6396364f..0dbd9ddb 100644 --- a/src/domain/trading/UnifiedTradingAccount.ts +++ b/src/domain/trading/UnifiedTradingAccount.ts @@ -331,6 +331,28 @@ export class UnifiedTradingAccount { // ==================== aliceId management ==================== + /** + * Resolve a Contract to its full broker-native form. + * If contract.aliceId is present but native fields (conId, localSymbol) are + * missing, it parses the nativeKey from aliceId and asks the broker to resolve it. + */ + resolveContract(contract: Contract): Contract { + if (contract.aliceId && !contract.conId && !contract.localSymbol) { + const parsed = UnifiedTradingAccount.parseAliceId(contract.aliceId) + if (parsed && parsed.utaId === this.id) { + const resolved = this.broker.resolveNativeKey(parsed.nativeKey) + // Copy resolved fields into original object to preserve reference + contract.conId = resolved.conId + contract.localSymbol = resolved.localSymbol + contract.symbol = contract.symbol || resolved.symbol + contract.secType = contract.secType || resolved.secType + contract.currency = contract.currency || resolved.currency + contract.exchange = contract.exchange || resolved.exchange + } + } + return contract + } + /** Construct aliceId: "{utaId}|{nativeKey}" using broker's native identity. */ private stampAliceId(contract: Contract): void { const nativeKey = this.broker.getNativeKey(contract) @@ -605,6 +627,7 @@ export class UnifiedTradingAccount { } async getQuote(contract: Contract): Promise { + this.resolveContract(contract) const quote = await this._callBroker(() => this.broker.getQuote(contract)) this.stampAliceId(quote.contract) return quote @@ -633,6 +656,7 @@ export class UnifiedTradingAccount { } async getContractDetails(query: Contract): Promise { + this.resolveContract(query) const details = await this._callBroker(() => this.broker.getContractDetails(query)) if (details) this.stampAliceId(details.contract) return details diff --git a/src/task/heartbeat/heartbeat.ts b/src/task/heartbeat/heartbeat.ts index 9978d3eb..1eb2badd 100644 --- a/src/task/heartbeat/heartbeat.ts +++ b/src/task/heartbeat/heartbeat.ts @@ -39,70 +39,6 @@ import type { CronEngine } from '../cron/engine.js' const HEARTBEAT_EMITS = ['heartbeat.done', 'heartbeat.skip', 'heartbeat.error'] as const type HeartbeatEmits = typeof HEARTBEAT_EMITS -// ==================== Constants ==================== - -export const HEARTBEAT_JOB_NAME = '__heartbeat__' - -// ==================== Config ==================== - -export interface HeartbeatConfig { - enabled: boolean - /** Interval between heartbeats, e.g. "30m", "1h". */ - every: string - /** Prompt sent to the AI on each heartbeat. */ - prompt: string - /** Active hours window. Null = always active. */ - activeHours: { - start: string // "HH:MM" - end: string // "HH:MM" - timezone: string // IANA timezone or "local" - } | null -} - -export const DEFAULT_HEARTBEAT_CONFIG: HeartbeatConfig = { - enabled: false, - every: '30m', - 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 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. - -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. - -In short: -- silence = nothing pushed -- \`notify_user("...")\` = a push lands in the user's inbox`, - activeHours: null, -} - -// ==================== Types ==================== - -export interface HeartbeatOpts { - config: HeartbeatConfig - agentWorkRunner: AgentWorkRunner - cronEngine: CronEngine - /** Registry to auto-register the heartbeat listener with. */ - registry: ListenerRegistry - /** Optional: inject a session for testing. */ - session?: SessionStore - /** Inject clock for testing. */ - now?: () => number -} - -export interface Heartbeat { - start(): Promise - stop(): void - /** Hot-toggle heartbeat on/off (persists to config + updates cron job). */ - setEnabled(enabled: boolean): Promise - /** Current enabled state. */ - isEnabled(): boolean - /** Expose the raw listener for direct testing. */ - readonly listener: Listener<'cron.fire', HeartbeatEmits> -} - -// ==================== Factory ==================== - -export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { - const { config, agentWorkRunner, cronEngine, registry } = opts const session = opts.session ?? new SessionStore('heartbeat') const now = opts.now ?? Date.now @@ -258,8 +194,15 @@ export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { } }, + async updateConfig(newConfig: HeartbeatConfig) { + config = { ...newConfig } + enabled = config.enabled + await ensureJobAndListener() + }, + async setEnabled(newEnabled: boolean) { enabled = newEnabled + config.enabled = newEnabled // Ensure infrastructure exists (handles cold enable when start() was called with disabled) await ensureJobAndListener() @@ -274,95 +217,3 @@ export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { } } -// ==================== Active Hours ==================== - -/** - * Check if the current time falls within the active hours window. - * Returns true if no activeHours configured (always active). - */ -export function isWithinActiveHours( - activeHours: HeartbeatConfig['activeHours'], - nowMs?: number, -): boolean { - if (!activeHours) return true - - const { start, end, timezone } = activeHours - - const startMinutes = parseHHMM(start) - const endMinutes = parseHHMM(end) - if (startMinutes === null || endMinutes === null) return true - - 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 -} - -function parseHHMM(s: string): number | null { - const m = /^(\d{1,2}):(\d{2})$/.exec(s) - if (!m) return null - const h = Number(m[1]) - const min = Number(m[2]) - if (h > 23 || min > 59) return null - return h * 60 + min -} - -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, - hour: 'numeric', - minute: 'numeric', - hour12: false, - }) - const parts = fmt.formatToParts(date) - const hour = Number(parts.find((p) => p.type === 'hour')?.value ?? 0) - const minute = Number(parts.find((p) => p.type === 'minute')?.value ?? 0) - return hour * 60 + minute - } catch { - return date.getHours() * 60 + date.getMinutes() - } -} - -// ==================== Dedup ==================== - -/** - * Suppress identical heartbeat messages within a time window (default 24h). - * - * In-memory only — restart loses dedup state. Acceptable trade-off: - * heartbeat fires every ~30m by default, so a restart-window - * collision is rare and the cost (one duplicate notification) is low. - */ -export class HeartbeatDedup { - /** Public for the heartbeat factory's `buildDonePayload` to read the - * most-recently-delivered text without an extra signal channel. */ - public lastText: string | null = null - private lastSentAt = 0 - private windowMs: number - - constructor(windowMs = 24 * 60 * 60 * 1000) { - this.windowMs = windowMs - } - - isDuplicate(text: string, nowMs = Date.now()): boolean { - if (this.lastText === null) return false - if (text !== this.lastText) return false - return (nowMs - this.lastSentAt) < this.windowMs - } - - record(text: string, nowMs = Date.now()): void { - this.lastText = text - this.lastSentAt = nowMs - } -} diff --git a/src/webui/routes/config.ts b/src/webui/routes/config.ts index c3935a1a..d7b288e4 100644 --- a/src/webui/routes/config.ts +++ b/src/webui/routes/config.ts @@ -147,6 +147,9 @@ export function createConfigRoutes(opts?: ConfigRouteOpts) { if (section === 'connectors' || section === 'marketData') { await opts?.onConnectorsChange?.() } + if (section === 'heartbeat' && opts?.ctx) { + await opts.ctx.heartbeat.updateConfig(opts.ctx.config.heartbeat) + } return c.json(validated) } catch (err) { if (err instanceof Error && err.name === 'ZodError') { From 0c4a16c72c33b59a9eed5043efd78470d62ae0e6 Mon Sep 17 00:00:00 2001 From: Wei Bin Date: Sat, 9 May 2026 23:55:10 +0800 Subject: [PATCH 5/8] fix(telegram): ensure heartbeat/cron notifications bypass active-channel filter --- src/connectors/telegram/telegram-plugin.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/connectors/telegram/telegram-plugin.ts b/src/connectors/telegram/telegram-plugin.ts index 1709da6b..9fca290b 100644 --- a/src/connectors/telegram/telegram-plugin.ts +++ b/src/connectors/telegram/telegram-plugin.ts @@ -254,7 +254,21 @@ export class TelegramPlugin implements Plugin { // Otherwise we don't ping; the user can pull via /notifications. this.unsubscribeNotifications = engineCtx.notificationsStore.onAppended((entry) => { const last = engineCtx.connectorCenter.getLastInteraction() - if (last?.channel !== 'telegram') return + + // Surface logic: + // 1. If this is an automated system push (heartbeat, cron), send it unless + // the user is actively using another channel right now (within 5m). + // 2. Otherwise (manual sends, task replies), only surface if Telegram + // was the last channel the user interacted with. + const isAutomated = entry.source === 'heartbeat' || entry.source === 'cron' + const isOtherRecentlyActive = last && last.channel !== 'telegram' && (Date.now() - last.ts < 300_000) + + if (isAutomated) { + if (isOtherRecentlyActive) return + } else { + if (last?.channel !== 'telegram') return + } + telegramConnector .send({ kind: 'notification', text: entry.text, media: entry.media, source: entry.source }) .catch((err) => console.warn('telegram: notification surface failed:', err)) From 11a68b6c3ee40ed2f7873db4729d176d840bbb0d Mon Sep 17 00:00:00 2001 From: Wei Bin Date: Sun, 10 May 2026 21:05:14 +0800 Subject: [PATCH 6/8] fix(tool): include aliceId in getPortfolio and update tool descriptions --- src/task/heartbeat/heartbeat.ts | 163 ++++++++++++++++++++++++++++++-- src/tool/trading.spec.ts | 51 ++++++++++ src/tool/trading.ts | 22 +++-- 3 files changed, 222 insertions(+), 14 deletions(-) diff --git a/src/task/heartbeat/heartbeat.ts b/src/task/heartbeat/heartbeat.ts index 1eb2badd..9978d3eb 100644 --- a/src/task/heartbeat/heartbeat.ts +++ b/src/task/heartbeat/heartbeat.ts @@ -39,6 +39,70 @@ import type { CronEngine } from '../cron/engine.js' const HEARTBEAT_EMITS = ['heartbeat.done', 'heartbeat.skip', 'heartbeat.error'] as const type HeartbeatEmits = typeof HEARTBEAT_EMITS +// ==================== Constants ==================== + +export const HEARTBEAT_JOB_NAME = '__heartbeat__' + +// ==================== Config ==================== + +export interface HeartbeatConfig { + enabled: boolean + /** Interval between heartbeats, e.g. "30m", "1h". */ + every: string + /** Prompt sent to the AI on each heartbeat. */ + prompt: string + /** Active hours window. Null = always active. */ + activeHours: { + start: string // "HH:MM" + end: string // "HH:MM" + timezone: string // IANA timezone or "local" + } | null +} + +export const DEFAULT_HEARTBEAT_CONFIG: HeartbeatConfig = { + enabled: false, + every: '30m', + 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 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. + +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. + +In short: +- silence = nothing pushed +- \`notify_user("...")\` = a push lands in the user's inbox`, + activeHours: null, +} + +// ==================== Types ==================== + +export interface HeartbeatOpts { + config: HeartbeatConfig + agentWorkRunner: AgentWorkRunner + cronEngine: CronEngine + /** Registry to auto-register the heartbeat listener with. */ + registry: ListenerRegistry + /** Optional: inject a session for testing. */ + session?: SessionStore + /** Inject clock for testing. */ + now?: () => number +} + +export interface Heartbeat { + start(): Promise + stop(): void + /** Hot-toggle heartbeat on/off (persists to config + updates cron job). */ + setEnabled(enabled: boolean): Promise + /** Current enabled state. */ + isEnabled(): boolean + /** Expose the raw listener for direct testing. */ + readonly listener: Listener<'cron.fire', HeartbeatEmits> +} + +// ==================== Factory ==================== + +export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { + const { config, agentWorkRunner, cronEngine, registry } = opts const session = opts.session ?? new SessionStore('heartbeat') const now = opts.now ?? Date.now @@ -194,15 +258,8 @@ type HeartbeatEmits = typeof HEARTBEAT_EMITS } }, - async updateConfig(newConfig: HeartbeatConfig) { - config = { ...newConfig } - enabled = config.enabled - await ensureJobAndListener() - }, - async setEnabled(newEnabled: boolean) { enabled = newEnabled - config.enabled = newEnabled // Ensure infrastructure exists (handles cold enable when start() was called with disabled) await ensureJobAndListener() @@ -217,3 +274,95 @@ type HeartbeatEmits = typeof HEARTBEAT_EMITS } } +// ==================== Active Hours ==================== + +/** + * Check if the current time falls within the active hours window. + * Returns true if no activeHours configured (always active). + */ +export function isWithinActiveHours( + activeHours: HeartbeatConfig['activeHours'], + nowMs?: number, +): boolean { + if (!activeHours) return true + + const { start, end, timezone } = activeHours + + const startMinutes = parseHHMM(start) + const endMinutes = parseHHMM(end) + if (startMinutes === null || endMinutes === null) return true + + 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 +} + +function parseHHMM(s: string): number | null { + const m = /^(\d{1,2}):(\d{2})$/.exec(s) + if (!m) return null + const h = Number(m[1]) + const min = Number(m[2]) + if (h > 23 || min > 59) return null + return h * 60 + min +} + +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, + hour: 'numeric', + minute: 'numeric', + hour12: false, + }) + const parts = fmt.formatToParts(date) + const hour = Number(parts.find((p) => p.type === 'hour')?.value ?? 0) + const minute = Number(parts.find((p) => p.type === 'minute')?.value ?? 0) + return hour * 60 + minute + } catch { + return date.getHours() * 60 + date.getMinutes() + } +} + +// ==================== Dedup ==================== + +/** + * Suppress identical heartbeat messages within a time window (default 24h). + * + * In-memory only — restart loses dedup state. Acceptable trade-off: + * heartbeat fires every ~30m by default, so a restart-window + * collision is rare and the cost (one duplicate notification) is low. + */ +export class HeartbeatDedup { + /** Public for the heartbeat factory's `buildDonePayload` to read the + * most-recently-delivered text without an extra signal channel. */ + public lastText: string | null = null + private lastSentAt = 0 + private windowMs: number + + constructor(windowMs = 24 * 60 * 60 * 1000) { + this.windowMs = windowMs + } + + isDuplicate(text: string, nowMs = Date.now()): boolean { + if (this.lastText === null) return false + if (text !== this.lastText) return false + return (nowMs - this.lastSentAt) < this.windowMs + } + + record(text: string, nowMs = Date.now()): void { + this.lastText = text + this.lastSentAt = nowMs + } +} diff --git a/src/tool/trading.spec.ts b/src/tool/trading.spec.ts index 7b7fe423..c3370012 100644 --- a/src/tool/trading.spec.ts +++ b/src/tool/trading.spec.ts @@ -251,3 +251,54 @@ describe('createTradingTools — getOrders summarization', () => { expect(result['mock-paper|ETH'].orders).toHaveLength(1) }) }) + +// ==================== getPortfolio ==================== + +describe('createTradingTools — getPortfolio', () => { + it('returns positions with aliceId and calculated percentages', async () => { + const broker = new MockBroker({ id: 'mock-paper', cash: 100000 }) + broker.setMarkPrice('AAPL', 150) + broker.setMarkPrice('TSLA', 200) + + // AAPL: 100 shares @ 150 = 15,000 + broker.externalDeposit({ nativeKey: 'AAPL', quantity: 100 }) + // TSLA: 50 shares @ 200 = 10,000 + broker.externalDeposit({ nativeKey: 'TSLA', quantity: 50 }) + + const mgr = makeManager(broker) + const tools = createTradingTools(mgr) + + const result = await (tools.getPortfolio.execute as Function)({ source: 'mock-paper' }) + + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(2) + + const aapl = result.find((p: any) => p.symbol === 'AAPL') + expect(aapl).toBeDefined() + expect(aapl.aliceId).toBe('mock-paper|AAPL') + expect(aapl.marketValue).toBe('15000') + + const tsla = result.find((p: any) => p.symbol === 'TSLA') + expect(tsla).toBeDefined() + expect(tsla.aliceId).toBe('mock-paper|TSLA') + expect(tsla.marketValue).toBe('10000') + }) + + it('filters by symbol', async () => { + const broker = new MockBroker({ id: 'mock-paper' }) + broker.setMarkPrice('AAPL', 150) + broker.setMarkPrice('TSLA', 200) + broker.externalDeposit({ nativeKey: 'AAPL', quantity: 100 }) + broker.externalDeposit({ nativeKey: 'TSLA', quantity: 50 }) + + const mgr = makeManager(broker) + const tools = createTradingTools(mgr) + + const result = await (tools.getPortfolio.execute as Function)({ source: 'mock-paper', symbol: 'AAPL' }) + + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(1) + expect(result[0].symbol).toBe('AAPL') + expect(result[0].aliceId).toBe('mock-paper|AAPL') + }) +}) diff --git a/src/tool/trading.ts b/src/tool/trading.ts index 5cf33ff9..f232e95b 100644 --- a/src/tool/trading.ts +++ b/src/tool/trading.ts @@ -138,7 +138,7 @@ hitting the broker, which otherwise expects the bare base ticker.`, inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), symbol: z.string().optional().describe('Symbol to look up'), - aliceId: z.string().optional().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), + aliceId: z.string().optional().describe('Contract ID (format: accountId|nativeKey, from searchContracts or getPortfolio)'), secType: z.string().optional().describe('Security type filter'), currency: z.string().optional().describe('Currency filter'), }), @@ -218,9 +218,17 @@ If this tool returns an error with transient=true, wait a few seconds and retry const percentOfEquity = netLiqUsd.gt(0) ? mvUsd.div(netLiqUsd).mul(100) : new Decimal(0) const percentOfPortfolio = totalMarketValueUsd.gt(0) ? mvUsd.div(totalMarketValueUsd).mul(100) : new Decimal(0) allPositions.push({ - source: uta.id, symbol: pos.contract.symbol, currency: pos.currency, side: pos.side, - quantity: pos.quantity.toString(), avgCost: pos.avgCost, marketPrice: pos.marketPrice, - marketValue: pos.marketValue, unrealizedPnL: pos.unrealizedPnL, realizedPnL: pos.realizedPnL, + source: uta.id, + aliceId: pos.contract.aliceId, + symbol: pos.contract.symbol, + currency: pos.currency, + side: pos.side, + quantity: pos.quantity.toString(), + avgCost: pos.avgCost, + marketPrice: pos.marketPrice, + marketValue: pos.marketValue, + unrealizedPnL: pos.unrealizedPnL, + realizedPnL: pos.realizedPnL, percentageOfEquity: `${percentOfEquity.toFixed(1)}%`, percentageOfPortfolio: `${percentOfPortfolio.toFixed(1)}%`, }) @@ -275,7 +283,7 @@ If this tool returns an error with transient=true, wait a few seconds and retry description: `Query the latest quote/price for a contract. If this tool returns an error with transient=true, wait a few seconds and retry once before reporting to the user.`, inputSchema: z.object({ - aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), + aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts or getPortfolio)'), source: z.string().optional().describe(sourceDesc(false)), }), execute: async ({ aliceId, source }) => { @@ -384,7 +392,7 @@ Required params by orderType: Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), - aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), + aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts or getPortfolio)'), symbol: z.string().optional().describe('Human-readable symbol (optional, for display only)'), action: z.enum(['BUY', 'SELL']).describe('Order direction'), orderType: z.enum(['MKT', 'LMT', 'STP', 'STP LMT', 'TRAIL', 'TRAIL LIMIT', 'MOC']).describe('Order type'), @@ -431,7 +439,7 @@ Optional: attach takeProfit and/or stopLoss for automatic exit orders.`, description: 'Stage a position close.\nNOTE: This stages the operation. Call tradingCommit + tradingPush to execute.', inputSchema: z.object({ source: z.string().describe(sourceDesc(true)), - aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts)'), + aliceId: z.string().describe('Contract ID (format: accountId|nativeKey, from searchContracts or getPortfolio)'), symbol: z.string().optional().describe('Human-readable symbol. Optional.'), qty: positiveNumeric.optional().describe('Number of shares to sell. Decimal string. Default: sell all.'), }), From cf066253ce4bc27a451eeb5229f4e7cea93abd30 Mon Sep 17 00:00:00 2001 From: Wei Bin Date: Sun, 10 May 2026 21:36:32 +0800 Subject: [PATCH 7/8] fix(cron): move engine start earlier to prevent overwriting jobs.json --- src/main.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.ts b/src/main.ts index e8201791..2bfa69b7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -167,6 +167,8 @@ async function main() { // ==================== Cron ==================== const cronEngine = createCronEngine({ registry: listenerRegistry }) + await cronEngine.start() + console.log('cron: engine started') // ==================== News Collector Store ==================== @@ -324,9 +326,7 @@ async function main() { // ==================== Activate Listeners + Start Cron Engine ==================== await listenerRegistry.start() - await cronEngine.start() console.log(`listener-registry: started (${listenerRegistry.list().length} listeners)`) - console.log('cron: engine started') // ==================== News Collector ==================== From 124461ec435f0e8a43a779ec90aa00e9d50ca027 Mon Sep 17 00:00:00 2001 From: Wei Bin Date: Sun, 10 May 2026 21:45:00 +0800 Subject: [PATCH 8/8] feat(cron): auto-bootstrap custom jobs and fix heartbeat types --- src/task/heartbeat/heartbeat.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/task/heartbeat/heartbeat.ts b/src/task/heartbeat/heartbeat.ts index 9978d3eb..25cf9e72 100644 --- a/src/task/heartbeat/heartbeat.ts +++ b/src/task/heartbeat/heartbeat.ts @@ -91,6 +91,8 @@ export interface HeartbeatOpts { export interface Heartbeat { start(): Promise stop(): void + /** Update heartbeat config (interval, prompt, etc.) and sync with cron job. */ + updateConfig(newConfig: HeartbeatConfig): Promise /** Hot-toggle heartbeat on/off (persists to config + updates cron job). */ setEnabled(enabled: boolean): Promise /** Current enabled state. */ @@ -102,7 +104,8 @@ export interface Heartbeat { // ==================== Factory ==================== export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { - const { config, agentWorkRunner, cronEngine, registry } = opts + let { config } = opts + const { agentWorkRunner, cronEngine, registry } = opts const session = opts.session ?? new SessionStore('heartbeat') const now = opts.now ?? Date.now @@ -258,8 +261,15 @@ export function createHeartbeat(opts: HeartbeatOpts): Heartbeat { } }, + async updateConfig(newConfig: HeartbeatConfig) { + config = { ...newConfig } + enabled = config.enabled + await ensureJobAndListener() + }, + async setEnabled(newEnabled: boolean) { enabled = newEnabled + config.enabled = newEnabled // Ensure infrastructure exists (handles cold enable when start() was called with disabled) await ensureJobAndListener()