diff --git a/docs/configuration.md b/docs/configuration.md index 126ea42..1abb9b8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -289,6 +289,43 @@ features: Heartbeats are background tasks where the agent can review pending work. +#### Custom Heartbeat Prompt + +You can customize what the agent is told during heartbeats. The custom text replaces the default body while keeping the silent mode envelope (time, trigger metadata, and messaging instructions). + +Inline in YAML: + +```yaml +features: + heartbeat: + enabled: true + intervalMin: 60 + prompt: "Check your todo list and work on the highest priority item." +``` + +From a file (re-read each tick, so edits take effect without restart): + +```yaml +features: + heartbeat: + enabled: true + intervalMin: 60 + promptFile: ./prompts/heartbeat.md +``` + +Via environment variable: + +```bash +HEARTBEAT_PROMPT="Review recent conversations" npm start +``` + +Precedence: `prompt` (inline YAML) > `HEARTBEAT_PROMPT` (env var) > `promptFile` (file) > built-in default. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `features.heartbeat.prompt` | string | _(none)_ | Custom heartbeat prompt text | +| `features.heartbeat.promptFile` | string | _(none)_ | Path to prompt file (relative to working dir) | + ### Cron Jobs ```yaml @@ -298,6 +335,23 @@ features: Enable scheduled tasks. See [Cron Setup](./cron-setup.md). +### No-Reply (Opt-Out) + +The agent can choose not to respond to a message by sending exactly: + +``` + +``` + +When the bot receives this marker, it suppresses the response and nothing is sent to the channel. This is useful in group chats where the agent shouldn't reply to every message. + +The agent is taught about this behavior in two places: + +- **System prompt**: A "Choosing Not to Reply" section explains when to use it (messages not directed at the agent, simple acknowledgments, conversations between other users, etc.) +- **Message envelope**: Group messages include a hint reminding the agent of the `` option. DMs do not include this hint. + +The bot also handles this gracefully during streaming -- it holds back partial output while the response could still become ``, so users never see a partial match leak through. + ## Polling Configuration Background polling for integrations like Gmail. Runs independently of agent cron jobs. diff --git a/src/config/types.ts b/src/config/types.ts index b14ecad..f9b828f 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -31,6 +31,8 @@ export interface AgentConfig { heartbeat?: { enabled: boolean; intervalMin?: number; + prompt?: string; // Custom heartbeat prompt (replaces default body) + promptFile?: string; // Path to prompt file (re-read each tick for live editing) }; maxToolCalls?: number; }; @@ -81,6 +83,8 @@ export interface LettaBotConfig { heartbeat?: { enabled: boolean; intervalMin?: number; + prompt?: string; // Custom heartbeat prompt (replaces default body) + promptFile?: string; // Path to prompt file (re-read each tick for live editing) }; inlineImages?: boolean; // Send images directly to the LLM (default: true). Set false to only send file paths. maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100) diff --git a/src/core/prompts.ts b/src/core/prompts.ts index 21b4db4..922a6f9 100644 --- a/src/core/prompts.ts +++ b/src/core/prompts.ts @@ -53,6 +53,32 @@ If you have nothing to do → just end your turn (no output needed) `.trim(); } +/** + * Custom heartbeat prompt - wraps user-provided text with silent mode envelope + */ +export function buildCustomHeartbeatPrompt( + customPrompt: string, + time: string, + timezone: string, + intervalMinutes: number +): string { + return ` +${SILENT_MODE_PREFIX} + +TRIGGER: Scheduled heartbeat +TIME: ${time} (${timezone}) +NEXT HEARTBEAT: in ${intervalMinutes} minutes + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +YOUR TEXT OUTPUT IS PRIVATE - only you can see it. +To actually contact your human, run: + lettabot-message send --text "Your message here" + +${customPrompt} +`.trim(); +} + /** * Cron job prompt (silent mode) - for background scheduled tasks */ diff --git a/src/cron/heartbeat.test.ts b/src/cron/heartbeat.test.ts new file mode 100644 index 0000000..df93967 --- /dev/null +++ b/src/cron/heartbeat.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { writeFileSync, mkdirSync, unlinkSync, rmSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { HeartbeatService, type HeartbeatConfig } from './heartbeat.js'; +import { buildCustomHeartbeatPrompt, SILENT_MODE_PREFIX } from '../core/prompts.js'; +import type { AgentSession } from '../core/interfaces.js'; + +// ── buildCustomHeartbeatPrompt ────────────────────────────────────────── + +describe('buildCustomHeartbeatPrompt', () => { + it('includes silent mode prefix', () => { + const result = buildCustomHeartbeatPrompt('Do something', '12:00 PM', 'UTC', 60); + expect(result).toContain(SILENT_MODE_PREFIX); + }); + + it('includes time and interval metadata', () => { + const result = buildCustomHeartbeatPrompt('Do something', '3:30 PM', 'America/Los_Angeles', 45); + expect(result).toContain('TIME: 3:30 PM (America/Los_Angeles)'); + expect(result).toContain('NEXT HEARTBEAT: in 45 minutes'); + }); + + it('includes custom prompt text in body', () => { + const result = buildCustomHeartbeatPrompt('Check your todo list.', '12:00 PM', 'UTC', 60); + expect(result).toContain('Check your todo list.'); + }); + + it('includes lettabot-message instructions', () => { + const result = buildCustomHeartbeatPrompt('Custom task', '12:00 PM', 'UTC', 60); + expect(result).toContain('lettabot-message send --text'); + }); + + it('does NOT include default body text', () => { + const result = buildCustomHeartbeatPrompt('Custom task', '12:00 PM', 'UTC', 60); + expect(result).not.toContain('This is your time'); + expect(result).not.toContain('Pursue curiosities'); + }); +}); + +// ── HeartbeatService prompt resolution ────────────────────────────────── + +function createMockBot(): AgentSession { + return { + registerChannel: vi.fn(), + setGroupBatcher: vi.fn(), + processGroupBatch: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + sendToAgent: vi.fn().mockResolvedValue('ok'), + deliverToChannel: vi.fn(), + getStatus: vi.fn().mockReturnValue({ agentId: 'test', channels: [] }), + setAgentId: vi.fn(), + reset: vi.fn(), + getLastMessageTarget: vi.fn().mockReturnValue(null), + getLastUserMessageTime: vi.fn().mockReturnValue(null), + }; +} + +function createConfig(overrides: Partial = {}): HeartbeatConfig { + return { + enabled: true, + intervalMinutes: 30, + workingDir: tmpdir(), + ...overrides, + }; +} + +describe('HeartbeatService prompt resolution', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = resolve(tmpdir(), `heartbeat-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + }); + + afterEach(() => { + try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it('uses default prompt when no custom prompt is set', async () => { + const bot = createMockBot(); + const service = new HeartbeatService(bot, createConfig({ workingDir: tmpDir })); + + await service.trigger(); + + const sentMessage = (bot.sendToAgent as ReturnType).mock.calls[0][0] as string; + expect(sentMessage).toContain('This is your time'); + expect(sentMessage).toContain(SILENT_MODE_PREFIX); + }); + + it('uses inline prompt when set', async () => { + const bot = createMockBot(); + const service = new HeartbeatService(bot, createConfig({ + workingDir: tmpDir, + prompt: 'Check your todo list and work on the top item.', + })); + + await service.trigger(); + + const sentMessage = (bot.sendToAgent as ReturnType).mock.calls[0][0] as string; + expect(sentMessage).toContain('Check your todo list and work on the top item.'); + expect(sentMessage).not.toContain('This is your time'); + expect(sentMessage).toContain(SILENT_MODE_PREFIX); + }); + + it('uses promptFile when no inline prompt is set', async () => { + const promptPath = resolve(tmpDir, 'heartbeat-prompt.txt'); + writeFileSync(promptPath, 'Research quantum computing papers.'); + + const bot = createMockBot(); + const service = new HeartbeatService(bot, createConfig({ + workingDir: tmpDir, + promptFile: 'heartbeat-prompt.txt', + })); + + await service.trigger(); + + const sentMessage = (bot.sendToAgent as ReturnType).mock.calls[0][0] as string; + expect(sentMessage).toContain('Research quantum computing papers.'); + expect(sentMessage).not.toContain('This is your time'); + }); + + it('inline prompt takes precedence over promptFile', async () => { + const promptPath = resolve(tmpDir, 'heartbeat-prompt.txt'); + writeFileSync(promptPath, 'FROM FILE'); + + const bot = createMockBot(); + const service = new HeartbeatService(bot, createConfig({ + workingDir: tmpDir, + prompt: 'FROM INLINE', + promptFile: 'heartbeat-prompt.txt', + })); + + await service.trigger(); + + const sentMessage = (bot.sendToAgent as ReturnType).mock.calls[0][0] as string; + expect(sentMessage).toContain('FROM INLINE'); + expect(sentMessage).not.toContain('FROM FILE'); + }); + + it('re-reads promptFile on each tick (live reload)', async () => { + const promptPath = resolve(tmpDir, 'heartbeat-prompt.txt'); + writeFileSync(promptPath, 'Version 1'); + + const bot = createMockBot(); + const service = new HeartbeatService(bot, createConfig({ + workingDir: tmpDir, + promptFile: 'heartbeat-prompt.txt', + })); + + // First tick + await service.trigger(); + const firstMessage = (bot.sendToAgent as ReturnType).mock.calls[0][0] as string; + expect(firstMessage).toContain('Version 1'); + + // Update file + writeFileSync(promptPath, 'Version 2'); + + // Second tick + await service.trigger(); + const secondMessage = (bot.sendToAgent as ReturnType).mock.calls[1][0] as string; + expect(secondMessage).toContain('Version 2'); + expect(secondMessage).not.toContain('Version 1'); + }); + + it('falls back to default when promptFile does not exist', async () => { + const bot = createMockBot(); + const service = new HeartbeatService(bot, createConfig({ + workingDir: tmpDir, + promptFile: 'nonexistent.txt', + })); + + await service.trigger(); + + const sentMessage = (bot.sendToAgent as ReturnType).mock.calls[0][0] as string; + // Should fall back to default since file doesn't exist + expect(sentMessage).toContain('This is your time'); + }); + + it('falls back to default when promptFile is empty', async () => { + const promptPath = resolve(tmpDir, 'empty.txt'); + writeFileSync(promptPath, ' \n '); + + const bot = createMockBot(); + const service = new HeartbeatService(bot, createConfig({ + workingDir: tmpDir, + promptFile: 'empty.txt', + })); + + await service.trigger(); + + const sentMessage = (bot.sendToAgent as ReturnType).mock.calls[0][0] as string; + // Empty/whitespace file should fall back to default + expect(sentMessage).toContain('This is your time'); + }); +}); diff --git a/src/cron/heartbeat.ts b/src/cron/heartbeat.ts index b9e6845..7692224 100644 --- a/src/cron/heartbeat.ts +++ b/src/cron/heartbeat.ts @@ -7,11 +7,11 @@ * The agent must use `lettabot-message` CLI via Bash to contact the user. */ -import { appendFileSync, mkdirSync } from 'node:fs'; +import { appendFileSync, mkdirSync, readFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import type { AgentSession } from '../core/interfaces.js'; import type { TriggerContext } from '../core/types.js'; -import { buildHeartbeatPrompt } from '../core/prompts.js'; +import { buildHeartbeatPrompt, buildCustomHeartbeatPrompt } from '../core/prompts.js'; import { getDataDir } from '../utils/paths.js'; @@ -46,6 +46,9 @@ export interface HeartbeatConfig { // Custom heartbeat prompt (optional) prompt?: string; + // Path to prompt file (re-read each tick for live editing) + promptFile?: string; + // Target for delivery (optional - defaults to last messaged) target?: { channel: string; @@ -168,8 +171,20 @@ export class HeartbeatService { }; try { - // Build the heartbeat message with clear SILENT MODE indication - const message = buildHeartbeatPrompt(formattedTime, timezone, this.config.intervalMinutes); + // Resolve custom prompt: inline config > promptFile (re-read each tick) > default + let customPrompt = this.config.prompt; + if (!customPrompt && this.config.promptFile) { + try { + const promptPath = resolve(this.config.workingDir, this.config.promptFile); + customPrompt = readFileSync(promptPath, 'utf-8').trim(); + } catch (err) { + console.error(`[Heartbeat] Failed to read promptFile "${this.config.promptFile}":`, err); + } + } + + const message = customPrompt + ? buildCustomHeartbeatPrompt(customPrompt, formattedTime, timezone, this.config.intervalMinutes) + : buildHeartbeatPrompt(formattedTime, timezone, this.config.intervalMinutes); console.log(`[Heartbeat] Sending prompt (SILENT MODE):\n${'─'.repeat(50)}\n${message}\n${'─'.repeat(50)}\n`); diff --git a/src/main.ts b/src/main.ts index 5933231..6e40090 100644 --- a/src/main.ts +++ b/src/main.ts @@ -545,7 +545,8 @@ async function main() { const heartbeatService = new HeartbeatService(bot, { enabled: heartbeatConfig?.enabled ?? false, intervalMinutes: heartbeatConfig?.intervalMin ?? 30, - prompt: process.env.HEARTBEAT_PROMPT, + prompt: heartbeatConfig?.prompt || process.env.HEARTBEAT_PROMPT, + promptFile: heartbeatConfig?.promptFile, workingDir: globalConfig.workingDir, target: parseHeartbeatTarget(process.env.HEARTBEAT_TARGET), });