feat: custom heartbeat prompt via YAML config or file (#233)
* feat: custom heartbeat prompt via YAML config or file Wire up the existing but unused HeartbeatConfig.prompt field so users can customize what the agent sees during heartbeats. Adds three ways to set it: inline YAML (prompt), file-based (promptFile, re-read each tick for live editing), and env var (HEARTBEAT_PROMPT). Also documents the <no-reply/> opt-out behavior. Fixes #232 Written by Cameron ◯ Letta Code "The only way to do great work is to love what you do." -- Steve Jobs * test: add coverage for heartbeat prompt resolution Tests buildCustomHeartbeatPrompt and HeartbeatService prompt resolution: - default prompt fallback - inline prompt usage - promptFile loading - inline > promptFile precedence - live reload (file re-read each tick) - graceful fallback on missing file - empty file falls back to default Written by Cameron ◯ Letta Code "The only way to do great work is to love what you do." -- Steve Jobs
This commit is contained in:
@@ -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:
|
||||
|
||||
```
|
||||
<no-reply/>
|
||||
```
|
||||
|
||||
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 `<no-reply/>` 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 `<no-reply/>`, so users never see a partial match leak through.
|
||||
|
||||
## Polling Configuration
|
||||
|
||||
Background polling for integrations like Gmail. Runs independently of agent cron jobs.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
196
src/cron/heartbeat.test.ts
Normal file
196
src/cron/heartbeat.test.ts
Normal file
@@ -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> = {}): 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock.calls[0][0] as string;
|
||||
// Empty/whitespace file should fall back to default
|
||||
expect(sentMessage).toContain('This is your time');
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user