fix(heartbeat): prioritize user messages over in-flight heartbeats (#594)
Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
@@ -535,12 +535,20 @@ features:
|
|||||||
heartbeat:
|
heartbeat:
|
||||||
enabled: true
|
enabled: true
|
||||||
intervalMin: 60 # Check every 60 minutes
|
intervalMin: 60 # Check every 60 minutes
|
||||||
skipRecentUserMin: 5 # Skip auto-heartbeats for N minutes after user message (0 disables)
|
skipRecentPolicy: fraction # fixed | fraction | off
|
||||||
|
skipRecentFraction: 0.5 # Used when policy=fraction (0-1)
|
||||||
|
# skipRecentUserMin: 5 # Used when policy=fixed (0 disables)
|
||||||
|
interruptOnUserMessage: true # Cancel in-flight heartbeat when user messages arrive
|
||||||
```
|
```
|
||||||
|
|
||||||
Heartbeats are background tasks where the agent can review pending work.
|
Heartbeats are background tasks where the agent can review pending work.
|
||||||
If the user messaged recently, automatic heartbeats are skipped by default for 5 minutes (`skipRecentUserMin`).
|
By default, automatic heartbeats skip for half of the heartbeat interval (`skipRecentPolicy: fraction` with `skipRecentFraction: 0.5`).
|
||||||
Set this to `0` to disable skipping. Manual `/heartbeat` bypasses the skip check.
|
- `fixed`: use `skipRecentUserMin`.
|
||||||
|
- `fraction`: use `ceil(intervalMin * skipRecentFraction)`.
|
||||||
|
- `off`: disable recent-user skipping.
|
||||||
|
|
||||||
|
`interruptOnUserMessage` defaults to `true`, so live user messages cancel in-flight heartbeat runs on the same conversation key.
|
||||||
|
Manual `/heartbeat` bypasses the recent-user skip check.
|
||||||
|
|
||||||
#### Custom Heartbeat Prompt
|
#### Custom Heartbeat Prompt
|
||||||
|
|
||||||
@@ -571,13 +579,19 @@ Via environment variable:
|
|||||||
```bash
|
```bash
|
||||||
HEARTBEAT_PROMPT="Review recent conversations" npm start
|
HEARTBEAT_PROMPT="Review recent conversations" npm start
|
||||||
# Optional: HEARTBEAT_SKIP_RECENT_USER_MIN=0 to disable recent-user skip
|
# Optional: HEARTBEAT_SKIP_RECENT_USER_MIN=0 to disable recent-user skip
|
||||||
|
# Optional: HEARTBEAT_SKIP_RECENT_POLICY=fixed|fraction|off
|
||||||
|
# Optional: HEARTBEAT_SKIP_RECENT_FRACTION=0.5
|
||||||
|
# Optional: HEARTBEAT_INTERRUPT_ON_USER_MESSAGE=true
|
||||||
```
|
```
|
||||||
|
|
||||||
Precedence: `prompt` (inline YAML) > `HEARTBEAT_PROMPT` (env var) > `promptFile` (file) > built-in default.
|
Precedence: `prompt` (inline YAML) > `HEARTBEAT_PROMPT` (env var) > `promptFile` (file) > built-in default.
|
||||||
|
|
||||||
| Field | Type | Default | Description |
|
| Field | Type | Default | Description |
|
||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `features.heartbeat.skipRecentUserMin` | number | `5` | Skip auto-heartbeats for N minutes after a user message. Set `0` to disable. |
|
| `features.heartbeat.skipRecentPolicy` | `'fixed' \| 'fraction' \| 'off'` | `'fraction'` | How recent-user skipping is calculated. |
|
||||||
|
| `features.heartbeat.skipRecentFraction` | number | `0.5` | Fraction of `intervalMin` used when policy is `fraction` (range: `0`-`1`). |
|
||||||
|
| `features.heartbeat.skipRecentUserMin` | number | `5` | Skip auto-heartbeats for N minutes when policy is `fixed`. Set `0` to disable fixed-window skipping. |
|
||||||
|
| `features.heartbeat.interruptOnUserMessage` | boolean | `true` | Cancel in-flight heartbeat runs when a user message arrives on the same conversation key. |
|
||||||
| `features.heartbeat.prompt` | string | _(none)_ | Custom heartbeat prompt text |
|
| `features.heartbeat.prompt` | string | _(none)_ | Custom heartbeat prompt text |
|
||||||
| `features.heartbeat.promptFile` | string | _(none)_ | Path to prompt file (relative to working dir) |
|
| `features.heartbeat.promptFile` | string | _(none)_ | Path to prompt file (relative to working dir) |
|
||||||
|
|
||||||
|
|||||||
@@ -116,11 +116,16 @@ features:
|
|||||||
heartbeat:
|
heartbeat:
|
||||||
enabled: true
|
enabled: true
|
||||||
intervalMin: 60 # Default: 60 minutes
|
intervalMin: 60 # Default: 60 minutes
|
||||||
skipRecentUserMin: 5 # Skip auto-heartbeats for N minutes after user messages (0 disables)
|
skipRecentPolicy: fraction # fixed | fraction | off
|
||||||
|
skipRecentFraction: 0.5 # Used when policy=fraction (0-1)
|
||||||
|
# skipRecentUserMin: 5 # Used when policy=fixed (0 disables)
|
||||||
|
interruptOnUserMessage: true # Cancel in-flight heartbeat when user messages arrive
|
||||||
```
|
```
|
||||||
|
|
||||||
By default, automatic heartbeats are skipped for 5 minutes after a user message to avoid immediate follow-up noise.
|
By default, automatic heartbeats are skipped for half the heartbeat interval (`skipRecentPolicy: fraction`, `skipRecentFraction: 0.5`).
|
||||||
- Set `skipRecentUserMin: 0` to disable this skip behavior.
|
- Use `skipRecentPolicy: fixed` + `skipRecentUserMin` for a fixed window.
|
||||||
|
- Use `skipRecentPolicy: off` to disable recent-user skipping.
|
||||||
|
- `interruptOnUserMessage: true` prioritizes live user messages by cancelling in-flight heartbeat runs on the same key.
|
||||||
- Manual `/heartbeat` always bypasses the skip check.
|
- Manual `/heartbeat` always bypasses the skip check.
|
||||||
|
|
||||||
### Manual Trigger
|
### Manual Trigger
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ SLACK_APP_TOKEN=xapp-...
|
|||||||
| `CRON_ENABLED` | `false` | Enable cron jobs |
|
| `CRON_ENABLED` | `false` | Enable cron jobs |
|
||||||
| `HEARTBEAT_ENABLED` | `false` | Enable heartbeat service |
|
| `HEARTBEAT_ENABLED` | `false` | Enable heartbeat service |
|
||||||
| `HEARTBEAT_INTERVAL_MIN` | `30` | Heartbeat interval (minutes). Also enables heartbeat when set |
|
| `HEARTBEAT_INTERVAL_MIN` | `30` | Heartbeat interval (minutes). Also enables heartbeat when set |
|
||||||
| `HEARTBEAT_SKIP_RECENT_USER_MIN` | `5` | Skip automatic heartbeats for N minutes after user messages (`0` disables) |
|
| `HEARTBEAT_SKIP_RECENT_POLICY` | `fraction` | Recent-user skip policy (`fixed`, `fraction`, `off`) |
|
||||||
|
| `HEARTBEAT_SKIP_RECENT_FRACTION` | `0.5` | Fraction of interval used when policy is `fraction` |
|
||||||
|
| `HEARTBEAT_SKIP_RECENT_USER_MIN` | `5` | Skip window in minutes when policy is `fixed` (`0` disables) |
|
||||||
|
| `HEARTBEAT_INTERRUPT_ON_USER_MESSAGE` | `true` | Cancel in-flight heartbeat when a user message arrives on the same key |
|
||||||
| `HEARTBEAT_TARGET` | - | Target chat (e.g., `telegram:123456`) |
|
| `HEARTBEAT_TARGET` | - | Target chat (e.g., `telegram:123456`) |
|
||||||
| `OPENAI_API_KEY` | - | For voice message transcription |
|
| `OPENAI_API_KEY` | - | For voice message transcription |
|
||||||
| `API_HOST` | `0.0.0.0` on Railway | Optional override for API bind address |
|
| `API_HOST` | `0.0.0.0` on Railway | Optional override for API bind address |
|
||||||
|
|||||||
@@ -97,7 +97,10 @@ features:
|
|||||||
heartbeat:
|
heartbeat:
|
||||||
enabled: false
|
enabled: false
|
||||||
intervalMin: 30
|
intervalMin: 30
|
||||||
# skipRecentUserMin: 5 # Skip auto-heartbeats for N minutes after user message (0 disables)
|
# skipRecentPolicy: fraction # fixed | fraction | off
|
||||||
|
# skipRecentFraction: 0.5 # Used when policy=fraction (0-1)
|
||||||
|
# skipRecentUserMin: 5 # Used when policy=fixed (0 disables)
|
||||||
|
# interruptOnUserMessage: true # Cancel in-flight heartbeat when user messages arrive
|
||||||
# sendFileDir: ./data/outbound # Restrict <send-file> directive to this directory (default: data/outbound)
|
# sendFileDir: ./data/outbound # Restrict <send-file> directive to this directory (default: data/outbound)
|
||||||
# sendFileMaxSize: 52428800 # Max file size in bytes for <send-file> (default: 50MB)
|
# sendFileMaxSize: 52428800 # Max file size in bytes for <send-file> (default: 50MB)
|
||||||
# sendFileCleanup: false # Allow <send-file cleanup="true"> to delete files after send (default: false)
|
# sendFileCleanup: false # Allow <send-file cleanup="true"> to delete files after send (default: false)
|
||||||
|
|||||||
@@ -289,6 +289,9 @@ Environment:
|
|||||||
SLACK_APP_TOKEN Slack app token (xapp-...)
|
SLACK_APP_TOKEN Slack app token (xapp-...)
|
||||||
HEARTBEAT_INTERVAL_MIN Heartbeat interval in minutes
|
HEARTBEAT_INTERVAL_MIN Heartbeat interval in minutes
|
||||||
HEARTBEAT_SKIP_RECENT_USER_MIN Skip auto-heartbeats after user messages (0 disables)
|
HEARTBEAT_SKIP_RECENT_USER_MIN Skip auto-heartbeats after user messages (0 disables)
|
||||||
|
HEARTBEAT_SKIP_RECENT_POLICY Heartbeat skip policy (fixed, fraction, off)
|
||||||
|
HEARTBEAT_SKIP_RECENT_FRACTION Fraction of interval to skip when policy=fraction
|
||||||
|
HEARTBEAT_INTERRUPT_ON_USER_MESSAGE Cancel in-flight heartbeat on user message (true/false)
|
||||||
CRON_ENABLED Enable cron jobs (true/false)
|
CRON_ENABLED Enable cron jobs (true/false)
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,47 @@ describe('config TUI helpers', () => {
|
|||||||
expect(updated.providers?.[0].name).toBe('OpenAI');
|
expect(updated.providers?.[0].name).toBe('OpenAI');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('extract/apply preserves heartbeat policy and preemption fields', () => {
|
||||||
|
const config: LettaBotConfig = {
|
||||||
|
...makeBaseConfig(),
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
name: 'Primary',
|
||||||
|
channels: {},
|
||||||
|
features: {
|
||||||
|
heartbeat: {
|
||||||
|
enabled: true,
|
||||||
|
intervalMin: 30,
|
||||||
|
skipRecentPolicy: 'fraction',
|
||||||
|
skipRecentFraction: 0.5,
|
||||||
|
interruptOnUserMessage: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const draft = extractCoreDraft(config);
|
||||||
|
const heartbeat = draft.features.heartbeat;
|
||||||
|
if (!heartbeat) {
|
||||||
|
throw new Error('Expected heartbeat settings in extracted draft');
|
||||||
|
}
|
||||||
|
expect(heartbeat.skipRecentPolicy).toBe('fraction');
|
||||||
|
expect(heartbeat.skipRecentFraction).toBe(0.5);
|
||||||
|
expect(heartbeat.interruptOnUserMessage).toBe(true);
|
||||||
|
|
||||||
|
heartbeat.skipRecentPolicy = 'fixed';
|
||||||
|
heartbeat.skipRecentUserMin = 7;
|
||||||
|
delete heartbeat.skipRecentFraction;
|
||||||
|
heartbeat.interruptOnUserMessage = false;
|
||||||
|
|
||||||
|
const updated = applyCoreDraft(config, draft);
|
||||||
|
expect(updated.agents?.[0].features?.heartbeat?.skipRecentPolicy).toBe('fixed');
|
||||||
|
expect(updated.agents?.[0].features?.heartbeat?.skipRecentUserMin).toBe(7);
|
||||||
|
expect(updated.agents?.[0].features?.heartbeat?.skipRecentFraction).toBeUndefined();
|
||||||
|
expect(updated.agents?.[0].features?.heartbeat?.interruptOnUserMessage).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('getCoreDraftWarnings flags missing API key and no enabled channels', () => {
|
it('getCoreDraftWarnings flags missing API key and no enabled channels', () => {
|
||||||
const draft: CoreConfigDraft = {
|
const draft: CoreConfigDraft = {
|
||||||
server: { mode: 'api', apiKey: undefined, baseUrl: undefined },
|
server: { mode: 'api', apiKey: undefined, baseUrl: undefined },
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ function getPrimaryAgent(config: LettaBotConfig): AgentConfig | null {
|
|||||||
|
|
||||||
function normalizeFeatures(source?: AgentConfig['features']): NonNullable<AgentConfig['features']> {
|
function normalizeFeatures(source?: AgentConfig['features']): NonNullable<AgentConfig['features']> {
|
||||||
const features = deepClone(source ?? {});
|
const features = deepClone(source ?? {});
|
||||||
|
const skipRecentPolicy = features.heartbeat?.skipRecentPolicy
|
||||||
|
?? (features.heartbeat?.skipRecentUserMin !== undefined ? 'fixed' : 'fraction');
|
||||||
return {
|
return {
|
||||||
...features,
|
...features,
|
||||||
cron: typeof features.cron === 'boolean' ? features.cron : false,
|
cron: typeof features.cron === 'boolean' ? features.cron : false,
|
||||||
@@ -64,6 +66,9 @@ function normalizeFeatures(source?: AgentConfig['features']): NonNullable<AgentC
|
|||||||
enabled: features.heartbeat?.enabled ?? false,
|
enabled: features.heartbeat?.enabled ?? false,
|
||||||
intervalMin: features.heartbeat?.intervalMin ?? 60,
|
intervalMin: features.heartbeat?.intervalMin ?? 60,
|
||||||
skipRecentUserMin: features.heartbeat?.skipRecentUserMin,
|
skipRecentUserMin: features.heartbeat?.skipRecentUserMin,
|
||||||
|
skipRecentPolicy,
|
||||||
|
skipRecentFraction: features.heartbeat?.skipRecentFraction,
|
||||||
|
interruptOnUserMessage: features.heartbeat?.interruptOnUserMessage ?? true,
|
||||||
prompt: features.heartbeat?.prompt,
|
prompt: features.heartbeat?.prompt,
|
||||||
promptFile: features.heartbeat?.promptFile,
|
promptFile: features.heartbeat?.promptFile,
|
||||||
target: features.heartbeat?.target,
|
target: features.heartbeat?.target,
|
||||||
@@ -183,7 +188,7 @@ export function formatCoreDraftSummary(draft: CoreConfigDraft, configPath: strin
|
|||||||
[
|
[
|
||||||
'Heartbeat',
|
'Heartbeat',
|
||||||
draft.features.heartbeat?.enabled
|
draft.features.heartbeat?.enabled
|
||||||
? `✓ ${draft.features.heartbeat.intervalMin ?? 60}min`
|
? `✓ ${draft.features.heartbeat.intervalMin ?? 60}min • ${draft.features.heartbeat.skipRecentPolicy ?? 'fraction'} • preempt ${draft.features.heartbeat.interruptOnUserMessage === false ? 'off' : 'on'}`
|
||||||
: '✗ Disabled',
|
: '✗ Disabled',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@@ -370,6 +375,62 @@ async function editFeatures(draft: CoreConfigDraft): Promise<void> {
|
|||||||
});
|
});
|
||||||
if (p.isCancel(interval)) return;
|
if (p.isCancel(interval)) return;
|
||||||
draft.features.heartbeat.intervalMin = Number(interval.trim());
|
draft.features.heartbeat.intervalMin = Number(interval.trim());
|
||||||
|
|
||||||
|
const skipPolicy = await p.select({
|
||||||
|
message: 'Heartbeat skip policy after user activity',
|
||||||
|
options: [
|
||||||
|
{ value: 'fraction', label: 'Fraction of interval', hint: 'default: 0.5 × interval' },
|
||||||
|
{ value: 'fixed', label: 'Fixed minutes', hint: 'manual skip window (legacy behavior)' },
|
||||||
|
{ value: 'off', label: 'Disabled', hint: 'never skip based on recent user message' },
|
||||||
|
],
|
||||||
|
initialValue: draft.features.heartbeat.skipRecentPolicy ?? 'fraction',
|
||||||
|
});
|
||||||
|
if (p.isCancel(skipPolicy)) return;
|
||||||
|
draft.features.heartbeat.skipRecentPolicy = skipPolicy as 'fixed' | 'fraction' | 'off';
|
||||||
|
|
||||||
|
if (skipPolicy === 'fixed') {
|
||||||
|
const skipMin = await p.text({
|
||||||
|
message: 'Skip heartbeats for this many minutes after user messages',
|
||||||
|
placeholder: '5',
|
||||||
|
initialValue: String(draft.features.heartbeat.skipRecentUserMin ?? 5),
|
||||||
|
validate: (value) => {
|
||||||
|
const parsed = Number(value.trim());
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||||
|
return 'Enter a non-negative number';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (p.isCancel(skipMin)) return;
|
||||||
|
draft.features.heartbeat.skipRecentUserMin = Number(skipMin.trim());
|
||||||
|
delete draft.features.heartbeat.skipRecentFraction;
|
||||||
|
} else if (skipPolicy === 'fraction') {
|
||||||
|
const skipFraction = await p.text({
|
||||||
|
message: 'Skip window as fraction of interval (0-1)',
|
||||||
|
placeholder: '0.5',
|
||||||
|
initialValue: String(draft.features.heartbeat.skipRecentFraction ?? 0.5),
|
||||||
|
validate: (value) => {
|
||||||
|
const parsed = Number(value.trim());
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
|
||||||
|
return 'Enter a number between 0 and 1';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (p.isCancel(skipFraction)) return;
|
||||||
|
draft.features.heartbeat.skipRecentFraction = Number(skipFraction.trim());
|
||||||
|
delete draft.features.heartbeat.skipRecentUserMin;
|
||||||
|
} else {
|
||||||
|
delete draft.features.heartbeat.skipRecentUserMin;
|
||||||
|
delete draft.features.heartbeat.skipRecentFraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interruptOnUserMessage = await p.confirm({
|
||||||
|
message: 'Interrupt in-flight heartbeat when a user message arrives?',
|
||||||
|
initialValue: draft.features.heartbeat.interruptOnUserMessage !== false,
|
||||||
|
});
|
||||||
|
if (p.isCancel(interruptOnUserMessage)) return;
|
||||||
|
draft.features.heartbeat.interruptOnUserMessage = interruptOnUserMessage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -262,14 +262,17 @@ describe('server.api config (canonical location)', () => {
|
|||||||
expect(env.API_CORS_ORIGIN).toBe('*');
|
expect(env.API_CORS_ORIGIN).toBe('*');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('configToEnv should map heartbeat skip window env var', () => {
|
it('configToEnv should map heartbeat skip/preemption env vars', () => {
|
||||||
const config: LettaBotConfig = {
|
const config: LettaBotConfig = {
|
||||||
...DEFAULT_CONFIG,
|
...DEFAULT_CONFIG,
|
||||||
features: {
|
features: {
|
||||||
heartbeat: {
|
heartbeat: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
intervalMin: 30,
|
intervalMin: 30,
|
||||||
|
skipRecentPolicy: 'fraction',
|
||||||
|
skipRecentFraction: 0.5,
|
||||||
skipRecentUserMin: 4,
|
skipRecentUserMin: 4,
|
||||||
|
interruptOnUserMessage: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -277,6 +280,9 @@ describe('server.api config (canonical location)', () => {
|
|||||||
const env = configToEnv(config);
|
const env = configToEnv(config);
|
||||||
expect(env.HEARTBEAT_INTERVAL_MIN).toBe('30');
|
expect(env.HEARTBEAT_INTERVAL_MIN).toBe('30');
|
||||||
expect(env.HEARTBEAT_SKIP_RECENT_USER_MIN).toBe('4');
|
expect(env.HEARTBEAT_SKIP_RECENT_USER_MIN).toBe('4');
|
||||||
|
expect(env.HEARTBEAT_SKIP_RECENT_POLICY).toBe('fraction');
|
||||||
|
expect(env.HEARTBEAT_SKIP_RECENT_FRACTION).toBe('0.5');
|
||||||
|
expect(env.HEARTBEAT_INTERRUPT_ON_USER_MESSAGE).toBe('false');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('configToEnv should fall back to top-level api (deprecated)', () => {
|
it('configToEnv should fall back to top-level api (deprecated)', () => {
|
||||||
|
|||||||
@@ -527,6 +527,15 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
|
|||||||
if (config.features.heartbeat.skipRecentUserMin !== undefined) {
|
if (config.features.heartbeat.skipRecentUserMin !== undefined) {
|
||||||
env.HEARTBEAT_SKIP_RECENT_USER_MIN = String(config.features.heartbeat.skipRecentUserMin);
|
env.HEARTBEAT_SKIP_RECENT_USER_MIN = String(config.features.heartbeat.skipRecentUserMin);
|
||||||
}
|
}
|
||||||
|
if (config.features.heartbeat.skipRecentPolicy !== undefined) {
|
||||||
|
env.HEARTBEAT_SKIP_RECENT_POLICY = config.features.heartbeat.skipRecentPolicy;
|
||||||
|
}
|
||||||
|
if (config.features.heartbeat.skipRecentFraction !== undefined) {
|
||||||
|
env.HEARTBEAT_SKIP_RECENT_FRACTION = String(config.features.heartbeat.skipRecentFraction);
|
||||||
|
}
|
||||||
|
if (config.features.heartbeat.interruptOnUserMessage !== undefined) {
|
||||||
|
env.HEARTBEAT_INTERRUPT_ON_USER_MESSAGE = config.features.heartbeat.interruptOnUserMessage ? 'true' : 'false';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (config.features?.sleeptime) {
|
if (config.features?.sleeptime) {
|
||||||
if (config.features.sleeptime.trigger) {
|
if (config.features.sleeptime.trigger) {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ describe('normalizeAgents', () => {
|
|||||||
'BLUESKY_NOTIFICATIONS_ENABLED', 'BLUESKY_NOTIFICATIONS_INTERVAL_SEC', 'BLUESKY_NOTIFICATIONS_LIMIT',
|
'BLUESKY_NOTIFICATIONS_ENABLED', 'BLUESKY_NOTIFICATIONS_INTERVAL_SEC', 'BLUESKY_NOTIFICATIONS_LIMIT',
|
||||||
'BLUESKY_NOTIFICATIONS_PRIORITY', 'BLUESKY_NOTIFICATIONS_REASONS',
|
'BLUESKY_NOTIFICATIONS_PRIORITY', 'BLUESKY_NOTIFICATIONS_REASONS',
|
||||||
'HEARTBEAT_ENABLED', 'HEARTBEAT_INTERVAL_MIN', 'HEARTBEAT_SKIP_RECENT_USER_MIN',
|
'HEARTBEAT_ENABLED', 'HEARTBEAT_INTERVAL_MIN', 'HEARTBEAT_SKIP_RECENT_USER_MIN',
|
||||||
|
'HEARTBEAT_SKIP_RECENT_POLICY', 'HEARTBEAT_SKIP_RECENT_FRACTION', 'HEARTBEAT_INTERRUPT_ON_USER_MESSAGE',
|
||||||
'SLEEPTIME_TRIGGER', 'SLEEPTIME_BEHAVIOR', 'SLEEPTIME_STEP_COUNT',
|
'SLEEPTIME_TRIGGER', 'SLEEPTIME_BEHAVIOR', 'SLEEPTIME_STEP_COUNT',
|
||||||
'CRON_ENABLED',
|
'CRON_ENABLED',
|
||||||
];
|
];
|
||||||
@@ -424,6 +425,30 @@ describe('normalizeAgents', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should pick up heartbeat policy and preemption settings from env vars', () => {
|
||||||
|
process.env.HEARTBEAT_ENABLED = 'true';
|
||||||
|
process.env.HEARTBEAT_INTERVAL_MIN = '30';
|
||||||
|
process.env.HEARTBEAT_SKIP_RECENT_POLICY = 'fraction';
|
||||||
|
process.env.HEARTBEAT_SKIP_RECENT_FRACTION = '0.5';
|
||||||
|
process.env.HEARTBEAT_INTERRUPT_ON_USER_MESSAGE = 'false';
|
||||||
|
|
||||||
|
const config: LettaBotConfig = {
|
||||||
|
server: { mode: 'cloud' },
|
||||||
|
agent: { name: 'TestBot', model: 'test' },
|
||||||
|
channels: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const agents = normalizeAgents(config);
|
||||||
|
|
||||||
|
expect(agents[0].features?.heartbeat).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
intervalMin: 30,
|
||||||
|
skipRecentPolicy: 'fraction',
|
||||||
|
skipRecentFraction: 0.5,
|
||||||
|
interruptOnUserMessage: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should pick up sleeptime from env vars when YAML features is empty', () => {
|
it('should pick up sleeptime from env vars when YAML features is empty', () => {
|
||||||
process.env.SLEEPTIME_TRIGGER = 'step-count';
|
process.env.SLEEPTIME_TRIGGER = 'step-count';
|
||||||
process.env.SLEEPTIME_BEHAVIOR = 'reminder';
|
process.env.SLEEPTIME_BEHAVIOR = 'reminder';
|
||||||
@@ -511,6 +536,8 @@ describe('normalizeAgents', () => {
|
|||||||
it('should not override YAML heartbeat with env vars', () => {
|
it('should not override YAML heartbeat with env vars', () => {
|
||||||
process.env.HEARTBEAT_ENABLED = 'true';
|
process.env.HEARTBEAT_ENABLED = 'true';
|
||||||
process.env.HEARTBEAT_INTERVAL_MIN = '99';
|
process.env.HEARTBEAT_INTERVAL_MIN = '99';
|
||||||
|
process.env.HEARTBEAT_SKIP_RECENT_POLICY = 'off';
|
||||||
|
process.env.HEARTBEAT_INTERRUPT_ON_USER_MESSAGE = 'false';
|
||||||
|
|
||||||
const config: LettaBotConfig = {
|
const config: LettaBotConfig = {
|
||||||
server: { mode: 'cloud' },
|
server: { mode: 'cloud' },
|
||||||
@@ -521,6 +548,8 @@ describe('normalizeAgents', () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
intervalMin: 10,
|
intervalMin: 10,
|
||||||
skipRecentUserMin: 3,
|
skipRecentUserMin: 3,
|
||||||
|
skipRecentPolicy: 'fixed',
|
||||||
|
interruptOnUserMessage: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -530,6 +559,8 @@ describe('normalizeAgents', () => {
|
|||||||
// YAML values should win
|
// YAML values should win
|
||||||
expect(agents[0].features?.heartbeat?.intervalMin).toBe(10);
|
expect(agents[0].features?.heartbeat?.intervalMin).toBe(10);
|
||||||
expect(agents[0].features?.heartbeat?.skipRecentUserMin).toBe(3);
|
expect(agents[0].features?.heartbeat?.skipRecentUserMin).toBe(3);
|
||||||
|
expect(agents[0].features?.heartbeat?.skipRecentPolicy).toBe('fixed');
|
||||||
|
expect(agents[0].features?.heartbeat?.interruptOnUserMessage).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not override YAML sleeptime with env vars', () => {
|
it('should not override YAML sleeptime with env vars', () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { createLogger } from '../logger.js';
|
|||||||
const log = createLogger('Config');
|
const log = createLogger('Config');
|
||||||
export type ServerMode = 'api' | 'docker' | 'cloud' | 'selfhosted';
|
export type ServerMode = 'api' | 'docker' | 'cloud' | 'selfhosted';
|
||||||
export type CanonicalServerMode = 'api' | 'docker';
|
export type CanonicalServerMode = 'api' | 'docker';
|
||||||
|
export type HeartbeatSkipRecentPolicy = 'fixed' | 'fraction' | 'off';
|
||||||
|
|
||||||
export function canonicalizeServerMode(mode?: ServerMode): CanonicalServerMode {
|
export function canonicalizeServerMode(mode?: ServerMode): CanonicalServerMode {
|
||||||
return mode === 'docker' || mode === 'selfhosted' ? 'docker' : 'api';
|
return mode === 'docker' || mode === 'selfhosted' ? 'docker' : 'api';
|
||||||
@@ -89,6 +90,9 @@ export interface AgentConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
intervalMin?: number;
|
intervalMin?: number;
|
||||||
skipRecentUserMin?: number; // Skip auto-heartbeats for N minutes after user message (0 disables)
|
skipRecentUserMin?: number; // Skip auto-heartbeats for N minutes after user message (0 disables)
|
||||||
|
skipRecentPolicy?: HeartbeatSkipRecentPolicy; // 'fixed' | 'fraction' | 'off'
|
||||||
|
skipRecentFraction?: number; // Fraction of intervalMin when policy=fraction (0-1)
|
||||||
|
interruptOnUserMessage?: boolean; // Cancel in-flight heartbeat when user messages arrive
|
||||||
prompt?: string; // Custom heartbeat prompt (replaces default body)
|
prompt?: string; // Custom heartbeat prompt (replaces default body)
|
||||||
promptFile?: string; // Path to prompt file (re-read each tick for live editing)
|
promptFile?: string; // Path to prompt file (re-read each tick for live editing)
|
||||||
target?: string; // Delivery target ("telegram:123", "slack:C123", etc.)
|
target?: string; // Delivery target ("telegram:123", "slack:C123", etc.)
|
||||||
@@ -185,6 +189,9 @@ export interface LettaBotConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
intervalMin?: number;
|
intervalMin?: number;
|
||||||
skipRecentUserMin?: number; // Skip auto-heartbeats for N minutes after user message (0 disables)
|
skipRecentUserMin?: number; // Skip auto-heartbeats for N minutes after user message (0 disables)
|
||||||
|
skipRecentPolicy?: HeartbeatSkipRecentPolicy; // 'fixed' | 'fraction' | 'off'
|
||||||
|
skipRecentFraction?: number; // Fraction of intervalMin when policy=fraction (0-1)
|
||||||
|
interruptOnUserMessage?: boolean; // Cancel in-flight heartbeat when user messages arrive
|
||||||
prompt?: string; // Custom heartbeat prompt (replaces default body)
|
prompt?: string; // Custom heartbeat prompt (replaces default body)
|
||||||
promptFile?: string; // Path to prompt file (re-read each tick for live editing)
|
promptFile?: string; // Path to prompt file (re-read each tick for live editing)
|
||||||
target?: string; // Delivery target ("telegram:123", "slack:C123", etc.)
|
target?: string; // Delivery target ("telegram:123", "slack:C123", etc.)
|
||||||
@@ -804,11 +811,29 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
|
|||||||
const skipRecentUserMin = process.env.HEARTBEAT_SKIP_RECENT_USER_MIN
|
const skipRecentUserMin = process.env.HEARTBEAT_SKIP_RECENT_USER_MIN
|
||||||
? parseInt(process.env.HEARTBEAT_SKIP_RECENT_USER_MIN, 10)
|
? parseInt(process.env.HEARTBEAT_SKIP_RECENT_USER_MIN, 10)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const skipRecentPolicyRaw = process.env.HEARTBEAT_SKIP_RECENT_POLICY;
|
||||||
|
const skipRecentPolicy = skipRecentPolicyRaw === 'fixed'
|
||||||
|
|| skipRecentPolicyRaw === 'fraction'
|
||||||
|
|| skipRecentPolicyRaw === 'off'
|
||||||
|
? skipRecentPolicyRaw
|
||||||
|
: undefined;
|
||||||
|
const skipRecentFraction = process.env.HEARTBEAT_SKIP_RECENT_FRACTION
|
||||||
|
? Number(process.env.HEARTBEAT_SKIP_RECENT_FRACTION)
|
||||||
|
: undefined;
|
||||||
|
const interruptOnUserMessageRaw = process.env.HEARTBEAT_INTERRUPT_ON_USER_MESSAGE;
|
||||||
|
const interruptOnUserMessage = interruptOnUserMessageRaw === 'true'
|
||||||
|
? true
|
||||||
|
: interruptOnUserMessageRaw === 'false'
|
||||||
|
? false
|
||||||
|
: undefined;
|
||||||
|
|
||||||
features.heartbeat = {
|
features.heartbeat = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
...(Number.isFinite(intervalMin) ? { intervalMin } : {}),
|
...(Number.isFinite(intervalMin) ? { intervalMin } : {}),
|
||||||
...(Number.isFinite(skipRecentUserMin) ? { skipRecentUserMin } : {}),
|
...(Number.isFinite(skipRecentUserMin) ? { skipRecentUserMin } : {}),
|
||||||
|
...(skipRecentPolicy ? { skipRecentPolicy } : {}),
|
||||||
|
...(Number.isFinite(skipRecentFraction) ? { skipRecentFraction } : {}),
|
||||||
|
...(interruptOnUserMessage !== undefined ? { interruptOnUserMessage } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -288,6 +288,8 @@ export class LettaBot implements AgentSession {
|
|||||||
private processing = false; // Global lock for shared mode
|
private processing = false; // Global lock for shared mode
|
||||||
private processingKeys: Set<string> = new Set(); // Per-key locks for per-channel mode
|
private processingKeys: Set<string> = new Set(); // Per-key locks for per-channel mode
|
||||||
private cancelledKeys: Set<string> = new Set(); // Tracks keys where /cancel was issued
|
private cancelledKeys: Set<string> = new Set(); // Tracks keys where /cancel was issued
|
||||||
|
private backgroundCancelledKeys: Set<string> = new Set(); // Tracks background runs cancelled by live user activity
|
||||||
|
private activeBackgroundTriggerByKey: Map<string, string> = new Map();
|
||||||
private sendSequence = 0; // Monotonic counter for desync diagnostics
|
private sendSequence = 0; // Monotonic counter for desync diagnostics
|
||||||
// Forward-looking: stale-result detection via runIds becomes active once the
|
// Forward-looking: stale-result detection via runIds becomes active once the
|
||||||
// SDK surfaces non-empty result run_ids. Until then, this map mostly stays
|
// SDK surfaces non-empty result run_ids. Until then, this map mostly stays
|
||||||
@@ -972,6 +974,23 @@ export class LettaBot implements AgentSession {
|
|||||||
// Message queue
|
// Message queue
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
|
private maybePreemptHeartbeatForUserMessage(convKey: string): void {
|
||||||
|
if (this.config.interruptHeartbeatOnUserMessage === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeBackgroundTriggerByKey.get(convKey) !== 'heartbeat') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.backgroundCancelledKeys.add(convKey);
|
||||||
|
const session = this.sessionManager.getSession(convKey);
|
||||||
|
if (session) {
|
||||||
|
session.abort().catch(() => {});
|
||||||
|
}
|
||||||
|
log.info(`Preempted in-flight heartbeat due to user message (key=${convKey})`);
|
||||||
|
}
|
||||||
|
|
||||||
private async handleMessage(msg: InboundMessage, adapter: ChannelAdapter): Promise<void> {
|
private async handleMessage(msg: InboundMessage, adapter: ChannelAdapter): Promise<void> {
|
||||||
// AskUserQuestion support: if the agent is waiting for a user answer,
|
// AskUserQuestion support: if the agent is waiting for a user answer,
|
||||||
// intercept this message and resolve the pending promise instead of
|
// intercept this message and resolve the pending promise instead of
|
||||||
@@ -987,6 +1006,8 @@ export class LettaBot implements AgentSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.maybePreemptHeartbeatForUserMessage(incomingConvKey);
|
||||||
|
|
||||||
log.info(`Message from ${msg.userId} on ${msg.channel}: ${msg.text}`);
|
log.info(`Message from ${msg.userId} on ${msg.channel}: ${msg.text}`);
|
||||||
|
|
||||||
if (msg.isGroup && this.groupBatcher) {
|
if (msg.isGroup && this.groupBatcher) {
|
||||||
@@ -1881,7 +1902,9 @@ export class LettaBot implements AgentSession {
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const isSilent = context?.outputMode === 'silent';
|
const isSilent = context?.outputMode === 'silent';
|
||||||
const convKey = this.resolveHeartbeatConversationKey();
|
const convKey = this.resolveHeartbeatConversationKey();
|
||||||
|
const triggerType = context?.type ?? 'heartbeat';
|
||||||
const acquired = await this.acquireLock(convKey);
|
const acquired = await this.acquireLock(convKey);
|
||||||
|
this.activeBackgroundTriggerByKey.set(convKey, triggerType);
|
||||||
|
|
||||||
const sendT0 = performance.now();
|
const sendT0 = performance.now();
|
||||||
const sendTurnId = this.turnLogger ? generateTurnId() : '';
|
const sendTurnId = this.turnLogger ? generateTurnId() : '';
|
||||||
@@ -1892,15 +1915,29 @@ export class LettaBot implements AgentSession {
|
|||||||
let retried = false;
|
let retried = false;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
|
if (this.backgroundCancelledKeys.has(convKey)) {
|
||||||
|
log.info(`sendToAgent: background run pre-cancelled by live user activity (key=${convKey})`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
const { session, stream } = await this.sessionManager.runSession(text, { convKey, retried });
|
const { session, stream } = await this.sessionManager.runSession(text, { convKey, retried });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (this.backgroundCancelledKeys.has(convKey)) {
|
||||||
|
session.abort().catch(() => {});
|
||||||
|
log.info(`sendToAgent: background run cancelled before stream start (key=${convKey})`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
let response = '';
|
let response = '';
|
||||||
let sawStaleDuplicateResult = false;
|
let sawStaleDuplicateResult = false;
|
||||||
let approvalRetryPending = false;
|
let approvalRetryPending = false;
|
||||||
let usedMessageCli = false;
|
let usedMessageCli = false;
|
||||||
let lastErrorDetail: StreamErrorDetail | undefined;
|
let lastErrorDetail: StreamErrorDetail | undefined;
|
||||||
for await (const msg of stream()) {
|
for await (const msg of stream()) {
|
||||||
|
if (this.backgroundCancelledKeys.has(convKey)) {
|
||||||
|
session.abort().catch(() => {});
|
||||||
|
log.info(`sendToAgent: cancelled in-flight background stream (key=${convKey})`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
sendTurnAcc?.feedRaw(msg);
|
sendTurnAcc?.feedRaw(msg);
|
||||||
if (msg.type === 'tool_call') {
|
if (msg.type === 'tool_call') {
|
||||||
this.sessionManager.syncTodoToolCall(msg);
|
this.sessionManager.syncTodoToolCall(msg);
|
||||||
@@ -1930,6 +1967,10 @@ export class LettaBot implements AgentSession {
|
|||||||
|
|
||||||
// TODO(letta-code-sdk#31): Remove once SDK handles HITL approvals in bypassPermissions mode.
|
// TODO(letta-code-sdk#31): Remove once SDK handles HITL approvals in bypassPermissions mode.
|
||||||
if (msg.success === false || msg.error) {
|
if (msg.success === false || msg.error) {
|
||||||
|
if (this.backgroundCancelledKeys.has(convKey)) {
|
||||||
|
log.info(`sendToAgent: cancelled heartbeat produced terminal error result; suppressing (key=${convKey})`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
// Enrich opaque errors from run metadata (mirrors processMessage logic).
|
// Enrich opaque errors from run metadata (mirrors processMessage logic).
|
||||||
const convId = typeof msg.conversationId === 'string' ? msg.conversationId : undefined;
|
const convId = typeof msg.conversationId === 'string' ? msg.conversationId : undefined;
|
||||||
if (this.store.agentId &&
|
if (this.store.agentId &&
|
||||||
@@ -2041,11 +2082,17 @@ export class LettaBot implements AgentSession {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Invalidate on stream errors so next call gets a fresh subprocess
|
// Invalidate on stream errors so next call gets a fresh subprocess
|
||||||
this.sessionManager.invalidateSession(convKey);
|
this.sessionManager.invalidateSession(convKey);
|
||||||
|
if (this.backgroundCancelledKeys.has(convKey)) {
|
||||||
|
log.info(`sendToAgent: background run ended after cancellation (key=${convKey})`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
this.activeBackgroundTriggerByKey.delete(convKey);
|
||||||
|
this.backgroundCancelledKeys.delete(convKey);
|
||||||
if (this.config.reuseSession === false) {
|
if (this.config.reuseSession === false) {
|
||||||
this.sessionManager.invalidateSession(convKey);
|
this.sessionManager.invalidateSession(convKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -882,6 +882,66 @@ describe('SDK session contract', () => {
|
|||||||
expect(processSpy).toHaveBeenCalledWith('slack');
|
expect(processSpy).toHaveBeenCalledWith('slack');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preempts an in-flight heartbeat when a user message arrives on the same key', async () => {
|
||||||
|
const streamStarted = deferred<void>();
|
||||||
|
const releaseStream = deferred<void>();
|
||||||
|
let aborted = false;
|
||||||
|
|
||||||
|
const mockSession = {
|
||||||
|
initialize: vi.fn(async () => undefined),
|
||||||
|
send: vi.fn(async (_message: unknown) => undefined),
|
||||||
|
stream: vi.fn(() =>
|
||||||
|
(async function* () {
|
||||||
|
streamStarted.resolve();
|
||||||
|
await releaseStream.promise;
|
||||||
|
if (aborted) {
|
||||||
|
throw new Error('aborted');
|
||||||
|
}
|
||||||
|
yield { type: 'assistant', content: 'heartbeat reply' };
|
||||||
|
yield { type: 'result', success: true };
|
||||||
|
})()
|
||||||
|
),
|
||||||
|
abort: vi.fn(async () => {
|
||||||
|
aborted = true;
|
||||||
|
releaseStream.resolve();
|
||||||
|
}),
|
||||||
|
close: vi.fn(() => undefined),
|
||||||
|
recoverPendingApprovals: vi.fn(async () => ({ recovered: false, unsupported: true, detail: 'mock' })),
|
||||||
|
agentId: 'agent-contract-test',
|
||||||
|
conversationId: 'conversation-contract-test',
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(resumeSession).mockReturnValue(mockSession as never);
|
||||||
|
|
||||||
|
const bot = new LettaBot({
|
||||||
|
workingDir: join(dataDir, 'working'),
|
||||||
|
allowedTools: [],
|
||||||
|
interruptHeartbeatOnUserMessage: true,
|
||||||
|
});
|
||||||
|
const botInternal = bot as any;
|
||||||
|
const processQueueSpy = vi.spyOn(botInternal, 'processQueue').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const heartbeatPromise = bot.sendToAgent('heartbeat');
|
||||||
|
await streamStarted.promise;
|
||||||
|
|
||||||
|
await botInternal.handleMessage({
|
||||||
|
userId: 'u1',
|
||||||
|
channel: 'telegram',
|
||||||
|
chatId: 'c1',
|
||||||
|
text: 'hi during heartbeat',
|
||||||
|
timestamp: new Date(),
|
||||||
|
isGroup: false,
|
||||||
|
}, {} as any);
|
||||||
|
|
||||||
|
const response = await heartbeatPromise;
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(response).toBe('');
|
||||||
|
expect(mockSession.abort).toHaveBeenCalledTimes(1);
|
||||||
|
expect(botInternal.processing).toBe(false);
|
||||||
|
expect(processQueueSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('LRU eviction in per-chat mode does not close active keys', async () => {
|
it('LRU eviction in per-chat mode does not close active keys', async () => {
|
||||||
const createdSession = {
|
const createdSession = {
|
||||||
initialize: vi.fn(async () => undefined),
|
initialize: vi.fn(async () => undefined),
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export interface BotConfig {
|
|||||||
// Conversation routing
|
// Conversation routing
|
||||||
conversationMode?: 'disabled' | 'shared' | 'per-channel' | 'per-chat'; // Default: shared
|
conversationMode?: 'disabled' | 'shared' | 'per-channel' | 'per-chat'; // Default: shared
|
||||||
heartbeatConversation?: string; // "dedicated" | "last-active" | "<channel>" (default: last-active)
|
heartbeatConversation?: string; // "dedicated" | "last-active" | "<channel>" (default: last-active)
|
||||||
|
interruptHeartbeatOnUserMessage?: boolean; // Default true. Cancel in-flight heartbeat on user message.
|
||||||
conversationOverrides?: string[]; // Channels that always use their own conversation (shared mode)
|
conversationOverrides?: string[]; // Channels that always use their own conversation (shared mode)
|
||||||
maxSessions?: number; // Max concurrent sessions in per-chat mode (default: 10, LRU eviction)
|
maxSessions?: number; // Max concurrent sessions in per-chat mode (default: 10, LRU eviction)
|
||||||
reuseSession?: boolean; // Reuse SDK subprocess across messages (default: true). Set false to eliminate stream state bleed at cost of ~5s latency per message.
|
reuseSession?: boolean; // Reuse SDK subprocess across messages (default: true). Set false to eliminate stream state bleed at cost of ~5s latency per message.
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ describe('HeartbeatService prompt resolution', () => {
|
|||||||
|
|
||||||
const service = new HeartbeatService(bot, createConfig({
|
const service = new HeartbeatService(bot, createConfig({
|
||||||
workingDir: tmpDir,
|
workingDir: tmpDir,
|
||||||
|
skipRecentPolicy: 'fixed',
|
||||||
skipRecentUserMinutes: 5,
|
skipRecentUserMinutes: 5,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -267,6 +268,7 @@ describe('HeartbeatService prompt resolution', () => {
|
|||||||
|
|
||||||
const service = new HeartbeatService(bot, createConfig({
|
const service = new HeartbeatService(bot, createConfig({
|
||||||
workingDir: tmpDir,
|
workingDir: tmpDir,
|
||||||
|
skipRecentPolicy: 'fixed',
|
||||||
skipRecentUserMinutes: 0,
|
skipRecentUserMinutes: 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -274,6 +276,40 @@ describe('HeartbeatService prompt resolution', () => {
|
|||||||
|
|
||||||
expect(bot.sendToAgent).toHaveBeenCalledTimes(1);
|
expect(bot.sendToAgent).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defaults to fraction policy (0.5 of interval) when no fixed window is configured', async () => {
|
||||||
|
const bot = createMockBot();
|
||||||
|
(bot.getLastUserMessageTime as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
new Date(Date.now() - 10 * 60 * 1000),
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new HeartbeatService(bot, createConfig({
|
||||||
|
workingDir: tmpDir,
|
||||||
|
intervalMinutes: 30,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await (service as any).runHeartbeat(false);
|
||||||
|
|
||||||
|
// 30m * 0.5 => 15m skip window; 10m ago should skip.
|
||||||
|
expect(bot.sendToAgent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not skip when skipRecentPolicy is off', async () => {
|
||||||
|
const bot = createMockBot();
|
||||||
|
(bot.getLastUserMessageTime as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
new Date(Date.now() - 1 * 60 * 1000),
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new HeartbeatService(bot, createConfig({
|
||||||
|
workingDir: tmpDir,
|
||||||
|
skipRecentPolicy: 'off',
|
||||||
|
skipRecentUserMinutes: 60,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await (service as any).runHeartbeat(false);
|
||||||
|
|
||||||
|
expect(bot.sendToAgent).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Memfs health check ─────────────────────────────────────────────────
|
// ── Memfs health check ─────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export interface HeartbeatConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
intervalMinutes: number;
|
intervalMinutes: number;
|
||||||
skipRecentUserMinutes?: number; // Default 5. Set to 0 to disable skip logic.
|
skipRecentUserMinutes?: number; // Default 5. Set to 0 to disable skip logic.
|
||||||
|
skipRecentPolicy?: 'fixed' | 'fraction' | 'off';
|
||||||
|
skipRecentFraction?: number; // Used when policy=fraction. Expected range: 0-1.
|
||||||
workingDir: string;
|
workingDir: string;
|
||||||
agentKey: string;
|
agentKey: string;
|
||||||
|
|
||||||
@@ -80,12 +82,51 @@ export class HeartbeatService {
|
|||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSkipWindowMs(): number {
|
private getSkipRecentPolicy(): 'fixed' | 'fraction' | 'off' {
|
||||||
const raw = this.config.skipRecentUserMinutes;
|
const configured = this.config.skipRecentPolicy;
|
||||||
if (raw === undefined || !Number.isFinite(raw) || raw < 0) {
|
if (configured === 'fixed' || configured === 'fraction' || configured === 'off') {
|
||||||
return 5 * 60 * 1000; // default: 5 minutes
|
return configured;
|
||||||
}
|
}
|
||||||
return Math.floor(raw * 60 * 1000);
|
|
||||||
|
// Backward compatibility: if explicit minutes are configured, preserve the
|
||||||
|
// historical fixed-window behavior unless policy is explicitly set.
|
||||||
|
if (this.config.skipRecentUserMinutes !== undefined) {
|
||||||
|
return 'fixed';
|
||||||
|
}
|
||||||
|
|
||||||
|
// New default: skip for half the heartbeat interval.
|
||||||
|
return 'fraction';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSkipWindow(): { policy: 'fixed' | 'fraction' | 'off'; minutes: number; milliseconds: number } {
|
||||||
|
const policy = this.getSkipRecentPolicy();
|
||||||
|
|
||||||
|
if (policy === 'off') {
|
||||||
|
return { policy, minutes: 0, milliseconds: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (policy === 'fraction') {
|
||||||
|
const rawFraction = this.config.skipRecentFraction;
|
||||||
|
const fraction = rawFraction !== undefined && Number.isFinite(rawFraction)
|
||||||
|
? Math.max(0, Math.min(1, rawFraction))
|
||||||
|
: 0.5;
|
||||||
|
const minutes = Math.ceil(Math.max(0, this.config.intervalMinutes) * fraction);
|
||||||
|
return {
|
||||||
|
policy,
|
||||||
|
minutes,
|
||||||
|
milliseconds: Math.floor(minutes * 60 * 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = this.config.skipRecentUserMinutes;
|
||||||
|
const minutes = (raw === undefined || !Number.isFinite(raw) || raw < 0)
|
||||||
|
? 5
|
||||||
|
: raw;
|
||||||
|
return {
|
||||||
|
policy,
|
||||||
|
minutes,
|
||||||
|
milliseconds: Math.floor(minutes * 60 * 1000),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -207,17 +248,19 @@ export class HeartbeatService {
|
|||||||
|
|
||||||
// Skip if user sent a message in the configured window (unless manual trigger)
|
// Skip if user sent a message in the configured window (unless manual trigger)
|
||||||
if (!skipRecentCheck) {
|
if (!skipRecentCheck) {
|
||||||
const skipWindowMs = this.getSkipWindowMs();
|
const { policy, minutes: skipWindowMin, milliseconds: skipWindowMs } = this.getSkipWindow();
|
||||||
const lastUserMessage = this.bot.getLastUserMessageTime();
|
const lastUserMessage = this.bot.getLastUserMessageTime();
|
||||||
if (skipWindowMs > 0 && lastUserMessage) {
|
if (skipWindowMs > 0 && lastUserMessage) {
|
||||||
const msSinceLastMessage = now.getTime() - lastUserMessage.getTime();
|
const msSinceLastMessage = now.getTime() - lastUserMessage.getTime();
|
||||||
|
|
||||||
if (msSinceLastMessage < skipWindowMs) {
|
if (msSinceLastMessage < skipWindowMs) {
|
||||||
const minutesAgo = Math.round(msSinceLastMessage / 60000);
|
const minutesAgo = Math.round(msSinceLastMessage / 60000);
|
||||||
log.info(`User messaged ${minutesAgo}m ago - skipping heartbeat`);
|
log.info(`User messaged ${minutesAgo}m ago - skipping heartbeat (policy=${policy}, window=${skipWindowMin}m)`);
|
||||||
logEvent('heartbeat_skipped_recent_user', {
|
logEvent('heartbeat_skipped_recent_user', {
|
||||||
lastUserMessage: lastUserMessage.toISOString(),
|
lastUserMessage: lastUserMessage.toISOString(),
|
||||||
minutesAgo,
|
minutesAgo,
|
||||||
|
skipPolicy: policy,
|
||||||
|
skipWindowMin,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/main.ts
24
src/main.ts
@@ -218,6 +218,19 @@ function ensureRequiredTools(tools: string[]): string[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseOptionalBoolean(raw?: string): boolean | undefined {
|
||||||
|
if (raw === 'true') return true;
|
||||||
|
if (raw === 'false') return false;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHeartbeatSkipRecentPolicy(raw?: string): 'fixed' | 'fraction' | 'off' | undefined {
|
||||||
|
if (raw === 'fixed' || raw === 'fraction' || raw === 'off') {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Global config (shared across all agents)
|
// Global config (shared across all agents)
|
||||||
const globalConfig = {
|
const globalConfig = {
|
||||||
workingDir: getWorkingDir(),
|
workingDir: getWorkingDir(),
|
||||||
@@ -232,6 +245,9 @@ const globalConfig = {
|
|||||||
attachmentsMaxAgeDays: resolveAttachmentsMaxAgeDays(),
|
attachmentsMaxAgeDays: resolveAttachmentsMaxAgeDays(),
|
||||||
cronEnabled: process.env.CRON_ENABLED === 'true', // Legacy env var fallback
|
cronEnabled: process.env.CRON_ENABLED === 'true', // Legacy env var fallback
|
||||||
heartbeatSkipRecentUserMin: parseNonNegativeNumber(process.env.HEARTBEAT_SKIP_RECENT_USER_MIN),
|
heartbeatSkipRecentUserMin: parseNonNegativeNumber(process.env.HEARTBEAT_SKIP_RECENT_USER_MIN),
|
||||||
|
heartbeatSkipRecentPolicy: parseHeartbeatSkipRecentPolicy(process.env.HEARTBEAT_SKIP_RECENT_POLICY),
|
||||||
|
heartbeatSkipRecentFraction: parseNonNegativeNumber(process.env.HEARTBEAT_SKIP_RECENT_FRACTION),
|
||||||
|
heartbeatInterruptOnUserMessage: parseOptionalBoolean(process.env.HEARTBEAT_INTERRUPT_ON_USER_MESSAGE),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate LETTA_API_KEY is set for API mode (docker mode doesn't require it)
|
// Validate LETTA_API_KEY is set for API mode (docker mode doesn't require it)
|
||||||
@@ -350,6 +366,7 @@ async function main() {
|
|||||||
const cronStorePath = cronStoreFilename
|
const cronStorePath = cronStoreFilename
|
||||||
? resolve(getCronDataDir(), cronStoreFilename)
|
? resolve(getCronDataDir(), cronStoreFilename)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const heartbeatConfig = agentConfig.features?.heartbeat;
|
||||||
|
|
||||||
const bot = new LettaBot({
|
const bot = new LettaBot({
|
||||||
workingDir: resolvedWorkingDir,
|
workingDir: resolvedWorkingDir,
|
||||||
@@ -366,6 +383,10 @@ async function main() {
|
|||||||
display: agentConfig.features?.display,
|
display: agentConfig.features?.display,
|
||||||
conversationMode: agentConfig.conversations?.mode || 'shared',
|
conversationMode: agentConfig.conversations?.mode || 'shared',
|
||||||
heartbeatConversation: agentConfig.conversations?.heartbeat || 'last-active',
|
heartbeatConversation: agentConfig.conversations?.heartbeat || 'last-active',
|
||||||
|
interruptHeartbeatOnUserMessage:
|
||||||
|
heartbeatConfig?.interruptOnUserMessage
|
||||||
|
?? globalConfig.heartbeatInterruptOnUserMessage
|
||||||
|
?? true,
|
||||||
conversationOverrides: agentConfig.conversations?.perChannel,
|
conversationOverrides: agentConfig.conversations?.perChannel,
|
||||||
maxSessions: agentConfig.conversations?.maxSessions,
|
maxSessions: agentConfig.conversations?.maxSessions,
|
||||||
reuseSession: agentConfig.conversations?.reuseSession,
|
reuseSession: agentConfig.conversations?.reuseSession,
|
||||||
@@ -469,11 +490,12 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Per-agent heartbeat
|
// Per-agent heartbeat
|
||||||
const heartbeatConfig = agentConfig.features?.heartbeat;
|
|
||||||
const heartbeatService = new HeartbeatService(bot, {
|
const heartbeatService = new HeartbeatService(bot, {
|
||||||
enabled: heartbeatConfig?.enabled ?? false,
|
enabled: heartbeatConfig?.enabled ?? false,
|
||||||
intervalMinutes: heartbeatConfig?.intervalMin ?? 240,
|
intervalMinutes: heartbeatConfig?.intervalMin ?? 240,
|
||||||
skipRecentUserMinutes: heartbeatConfig?.skipRecentUserMin ?? globalConfig.heartbeatSkipRecentUserMin,
|
skipRecentUserMinutes: heartbeatConfig?.skipRecentUserMin ?? globalConfig.heartbeatSkipRecentUserMin,
|
||||||
|
skipRecentPolicy: heartbeatConfig?.skipRecentPolicy ?? globalConfig.heartbeatSkipRecentPolicy,
|
||||||
|
skipRecentFraction: heartbeatConfig?.skipRecentFraction ?? globalConfig.heartbeatSkipRecentFraction,
|
||||||
agentKey: agentConfig.name,
|
agentKey: agentConfig.name,
|
||||||
memfs: resolvedMemfs,
|
memfs: resolvedMemfs,
|
||||||
prompt: heartbeatConfig?.prompt || process.env.HEARTBEAT_PROMPT,
|
prompt: heartbeatConfig?.prompt || process.env.HEARTBEAT_PROMPT,
|
||||||
|
|||||||
Reference in New Issue
Block a user