149 lines
4.2 KiB
TypeScript
149 lines
4.2 KiB
TypeScript
import { existsSync, readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import type { LLMProvider, ModelMessage } from '../provider/index.ts';
|
|
|
|
const HEARTBEAT_TOOL = [
|
|
{
|
|
type: 'function' as const,
|
|
function: {
|
|
name: 'heartbeat_decision',
|
|
description: 'Decide whether to act on this heartbeat tick.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
action: {
|
|
type: 'string',
|
|
enum: ['skip', 'run'],
|
|
description: '"skip" to do nothing, "run" to execute the tasks.',
|
|
},
|
|
tasks: {
|
|
type: 'array',
|
|
items: { type: 'string' },
|
|
description: 'List of tasks to perform (only when action is "run").',
|
|
},
|
|
reason: { type: 'string', description: 'Brief reason for the decision.' },
|
|
},
|
|
required: ['action'],
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
export type HeartbeatExecuteCallback = (tasks: string[]) => Promise<string>;
|
|
export type HeartbeatNotifyCallback = (content: string) => Promise<void>;
|
|
|
|
export class HeartbeatService {
|
|
private _workspace: string;
|
|
private _provider: LLMProvider;
|
|
private _model: string;
|
|
private _intervalMs: number;
|
|
private _onExecute: HeartbeatExecuteCallback;
|
|
private _onNotify: HeartbeatNotifyCallback;
|
|
private _timer: ReturnType<typeof setTimeout> | null = null;
|
|
private _running = false;
|
|
|
|
constructor(opts: {
|
|
workspace: string;
|
|
provider: LLMProvider;
|
|
model: string;
|
|
intervalMinutes: number;
|
|
onExecute: HeartbeatExecuteCallback;
|
|
onNotify: HeartbeatNotifyCallback;
|
|
}) {
|
|
this._workspace = opts.workspace;
|
|
this._provider = opts.provider;
|
|
this._model = opts.model;
|
|
this._intervalMs = opts.intervalMinutes * 60 * 1000;
|
|
this._onExecute = opts.onExecute;
|
|
this._onNotify = opts.onNotify;
|
|
}
|
|
|
|
start(): void {
|
|
if (this._running) return;
|
|
this._running = true;
|
|
this._schedule();
|
|
console.info(`[heartbeat] Started (interval: ${this._intervalMs / 60000}min)`);
|
|
}
|
|
|
|
stop(): void {
|
|
this._running = false;
|
|
if (this._timer) {
|
|
clearTimeout(this._timer);
|
|
this._timer = null;
|
|
}
|
|
}
|
|
|
|
async triggerNow(): Promise<void> {
|
|
await this._tick();
|
|
}
|
|
|
|
private _schedule(): void {
|
|
if (!this._running) return;
|
|
this._timer = setTimeout(() => {
|
|
void this._tick().finally(() => this._schedule());
|
|
}, this._intervalMs);
|
|
}
|
|
|
|
private async _tick(): Promise<void> {
|
|
const heartbeatContent = this._loadHeartbeatMd();
|
|
if (!heartbeatContent) {
|
|
console.debug('[heartbeat] No HEARTBEAT.md found, skipping tick.');
|
|
return;
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
const messages: ModelMessage[] = [
|
|
{
|
|
role: 'system',
|
|
content:
|
|
'You are a heartbeat agent. Read the HEARTBEAT.md instructions and decide whether to act on this tick. Call heartbeat_decision with action="skip" or action="run".',
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: `Current time: ${now}\n\n## HEARTBEAT.md\n\n${heartbeatContent}`,
|
|
},
|
|
];
|
|
|
|
const { response } = await this._provider.chatWithRetry({
|
|
messages,
|
|
tools: HEARTBEAT_TOOL,
|
|
model: this._model,
|
|
toolChoice: 'required',
|
|
});
|
|
|
|
const decision = response.toolCalls[0];
|
|
if (!decision) {
|
|
console.debug('[heartbeat] No decision tool call returned, skipping.');
|
|
return;
|
|
}
|
|
|
|
const action =
|
|
typeof decision.arguments['action'] === 'string' ? decision.arguments['action'] : 'skip';
|
|
if (action !== 'run') {
|
|
const reason =
|
|
typeof decision.arguments['reason'] === 'string' ? decision.arguments['reason'] : '';
|
|
console.debug(`[heartbeat] Decision: skip (${reason})`);
|
|
return;
|
|
}
|
|
|
|
const tasks = Array.isArray(decision.arguments['tasks'])
|
|
? (decision.arguments['tasks'] as string[])
|
|
: [];
|
|
|
|
console.info(`[heartbeat] Decision: run (${tasks.length} tasks)`);
|
|
|
|
try {
|
|
const result = await this._onExecute(tasks);
|
|
await this._onNotify(result);
|
|
} catch (err) {
|
|
console.error(`[heartbeat] Execution failed: ${String(err)}`);
|
|
}
|
|
}
|
|
|
|
private _loadHeartbeatMd(): string | null {
|
|
const path = join(this._workspace, 'HEARTBEAT.md');
|
|
if (!existsSync(path)) return null;
|
|
return readFileSync(path, 'utf8');
|
|
}
|
|
}
|