Agents: add inferred commitments

This commit is contained in:
Vignesh Natarajan
2026-04-29 00:29:49 -07:00
committed by Vignesh
parent 95a1356278
commit 8e4035d09a
24 changed files with 2660 additions and 18 deletions

View File

@@ -2229,6 +2229,7 @@ Docs: https://docs.openclaw.ai
- Memory/active-memory: default QMD recall to search and surface better search-path telemetry so memory-backed recall works more predictably out of the box. (#65068) Thanks @Takhoffman.
- Docs/providers: expand bundled provider docs with richer capability, env-var, and setup guidance across provider pages.
- Docs/memory-wiki: add the recommended QMD + bridge-mode hybrid recipe plus zero-artifact troubleshooting guidance for `memory-wiki` bridge setups. (#63165) Thanks @sercada and @vincentkoc.
- Agents/commitments: add default-on inferred follow-up commitments with hidden batched extraction, per-agent/per-channel scoping, heartbeat delivery, CLI management, and heartbeat-interval due-time clamping so magical check-ins do not echo immediately. (#74189) Thanks @vignesh07.
### Fixes

View File

@@ -1,4 +1,4 @@
b6640810820e0f54631e8006fa35798f84139b162ee472d150994571b730226a config-baseline.json
d63d3aa51c0c38a315cadbff01715844b73ecc35909b6bbb6cd318af59f3d2cc config-baseline.core.json
bc53a2242782d03e6392671c154481fb4cd8dc5b35cc41a69b056d3ead28be97 config-baseline.json
861a230a4e66cb8986270a85f63e857077506a3bc75ec3754dfebd17a3ea9f0c config-baseline.core.json
9f5fad66a49fa618d64a963470aa69fed9fe4b4639cc4321f9ec04bfb2f8aa50 config-baseline.channel.json
c4231c2194206547af8ad94342dc00aadb734f43cb49cc79d4c46bdbb80c3f95 config-baseline.plugin.json

View File

@@ -6,6 +6,8 @@ import { resolveModelAuthMode } from "../../agents/model-auth.js";
import { isCliProvider } from "../../agents/model-selection.js";
import { queueEmbeddedPiMessage } from "../../agents/pi-embedded-runner/runs.js";
import { deriveContextPromptTokens, hasNonzeroUsage, normalizeUsage } from "../../agents/usage.js";
import { enqueueCommitmentExtraction } from "../../commitments/runtime.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
loadSessionStore,
resolveSessionPluginStatusLines,
@@ -792,6 +794,71 @@ function buildInlineRawTracePayload(params: {
};
}
function joinCommitmentAssistantText(payloads: ReplyPayload[]): string {
return payloads
.filter((payload) => !payload.isError && !payload.isReasoning && !payload.isCompactionNotice)
.map((payload) => payload.text?.trim())
.filter((text): text is string => Boolean(text))
.join("\n")
.trim();
}
function enqueueCommitmentExtractionForTurn(params: {
cfg: OpenClawConfig;
commandBody: string;
isHeartbeat: boolean;
followupRun: FollowupRun;
sessionCtx: TemplateContext;
sessionKey?: string;
replyToChannel?: string;
payloads: ReplyPayload[];
runId: string;
}): void {
if (params.isHeartbeat) {
return;
}
const userText =
params.commandBody.trim() ||
params.sessionCtx.BodyStripped?.trim() ||
params.sessionCtx.BodyForCommands?.trim() ||
params.sessionCtx.CommandBody?.trim() ||
params.sessionCtx.RawBody?.trim() ||
params.sessionCtx.Body?.trim() ||
"";
const assistantText = joinCommitmentAssistantText(params.payloads);
const sessionKey = params.sessionKey ?? params.followupRun.run.sessionKey;
const channel =
params.replyToChannel ??
params.followupRun.run.messageProvider ??
params.sessionCtx.Surface ??
params.sessionCtx.Provider;
if (!userText || !assistantText || !sessionKey || !channel) {
return;
}
const to = resolveOriginMessageTo({
originatingTo: params.sessionCtx.OriginatingTo,
to: params.sessionCtx.To,
});
enqueueCommitmentExtraction({
cfg: params.cfg,
agentId: params.followupRun.run.agentId,
sessionKey,
channel,
...(params.sessionCtx.AccountId ? { accountId: params.sessionCtx.AccountId } : {}),
...(to ? { to } : {}),
...(params.sessionCtx.MessageThreadId !== undefined
? { threadId: String(params.sessionCtx.MessageThreadId) }
: {}),
...(params.followupRun.run.senderId ? { senderId: params.followupRun.run.senderId } : {}),
userText,
assistantText,
...(params.sessionCtx.MessageSidFull || params.sessionCtx.MessageSid
? { sourceMessageId: params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid }
: {}),
sourceRunId: params.runId,
});
}
function refreshSessionEntryFromStore(params: {
storePath?: string;
sessionKey?: string;
@@ -1378,6 +1445,18 @@ export async function runReplyAgent(params: {
? appendUnscheduledReminderNote(replyPayloads)
: replyPayloads;
enqueueCommitmentExtractionForTurn({
cfg,
commandBody,
isHeartbeat,
followupRun,
sessionCtx,
sessionKey,
replyToChannel,
payloads: replyPayloads,
runId,
});
await signalTypingIfNeeded(guardedReplyPayloads, typingSignals);
if (isDiagnosticsEnabled(cfg) && hasNonzeroUsage(usage)) {

View File

@@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({
sessionsCommand: vi.fn(),
sessionsCleanupCommand: vi.fn(),
exportTrajectoryCommand: vi.fn(),
commitmentsListCommand: vi.fn(),
commitmentsDismissCommand: vi.fn(),
tasksListCommand: vi.fn(),
tasksAuditCommand: vi.fn(),
tasksMaintenanceCommand: vi.fn(),
@@ -30,6 +32,8 @@ const healthCommand = mocks.healthCommand;
const sessionsCommand = mocks.sessionsCommand;
const sessionsCleanupCommand = mocks.sessionsCleanupCommand;
const exportTrajectoryCommand = mocks.exportTrajectoryCommand;
const commitmentsListCommand = mocks.commitmentsListCommand;
const commitmentsDismissCommand = mocks.commitmentsDismissCommand;
const tasksListCommand = mocks.tasksListCommand;
const tasksAuditCommand = mocks.tasksAuditCommand;
const tasksMaintenanceCommand = mocks.tasksMaintenanceCommand;
@@ -62,6 +66,11 @@ vi.mock("../../commands/export-trajectory.js", () => ({
exportTrajectoryCommand: mocks.exportTrajectoryCommand,
}));
vi.mock("../../commands/commitments.js", () => ({
commitmentsListCommand: mocks.commitmentsListCommand,
commitmentsDismissCommand: mocks.commitmentsDismissCommand,
}));
vi.mock("../../commands/tasks.js", () => ({
tasksListCommand: mocks.tasksListCommand,
tasksAuditCommand: mocks.tasksAuditCommand,
@@ -100,6 +109,8 @@ describe("registerStatusHealthSessionsCommands", () => {
sessionsCommand.mockResolvedValue(undefined);
sessionsCleanupCommand.mockResolvedValue(undefined);
exportTrajectoryCommand.mockResolvedValue(undefined);
commitmentsListCommand.mockResolvedValue(undefined);
commitmentsDismissCommand.mockResolvedValue(undefined);
tasksListCommand.mockResolvedValue(undefined);
tasksAuditCommand.mockResolvedValue(undefined);
tasksMaintenanceCommand.mockResolvedValue(undefined);
@@ -406,6 +417,31 @@ describe("registerStatusHealthSessionsCommands", () => {
);
});
it("runs commitments list with filters", async () => {
await runCli(["commitments", "--json", "--agent", "work", "--status", "snoozed"]);
expect(commitmentsListCommand).toHaveBeenCalledWith(
expect.objectContaining({
json: true,
agent: "work",
status: "snoozed",
all: false,
}),
runtime,
);
});
it("runs commitments dismiss with id forwarding", async () => {
await runCli(["commitments", "dismiss", "cm_1", "cm_2"]);
expect(commitmentsDismissCommand).toHaveBeenCalledWith(
expect.objectContaining({
ids: ["cm_1", "cm_2"],
}),
runtime,
);
});
it("does not register the legacy top-level flows command", () => {
const program = new Command();
registerStatusHealthSessionsCommands(program);

View File

@@ -1,4 +1,5 @@
import type { Command } from "commander";
import { commitmentsDismissCommand, commitmentsListCommand } from "../../commands/commitments.js";
import { exportTrajectoryCommand } from "../../commands/export-trajectory.js";
import { flowsCancelCommand, flowsListCommand, flowsShowCommand } from "../../commands/flows.js";
import { healthCommand } from "../../commands/health.js";
@@ -258,6 +259,79 @@ export function registerStatusHealthSessionsCommands(program: Command) {
});
});
const commitmentsCmd = program
.command("commitments")
.description("List and manage inferred follow-up commitments")
.option("--json", "Output JSON instead of text", false)
.option("--agent <id>", "Agent id to inspect")
.option("--status <status>", "Filter by status (pending, sent, dismissed, snoozed, expired)")
.option("--all", "Show all statuses", false)
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["openclaw commitments", "List pending inferred follow-ups."],
["openclaw commitments --all", "List all inferred follow-ups."],
["openclaw commitments --agent work", "List one agent's inferred follow-ups."],
["openclaw commitments dismiss cm_abc123", "Dismiss a follow-up."],
])}`,
)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await commitmentsListCommand(
{
json: Boolean(opts.json),
agent: opts.agent as string | undefined,
status: opts.status as string | undefined,
all: Boolean(opts.all),
},
defaultRuntime,
);
});
});
commitmentsCmd.enablePositionalOptions();
commitmentsCmd
.command("list")
.description("List inferred follow-up commitments")
.option("--json", "Output JSON instead of text", false)
.option("--agent <id>", "Agent id to inspect")
.option("--status <status>", "Filter by status (pending, sent, dismissed, snoozed, expired)")
.option("--all", "Show all statuses", false)
.action(async (opts, command) => {
const parentOpts = command.parent?.opts() as
| { json?: boolean; agent?: string; status?: string; all?: boolean }
| undefined;
await runCommandWithRuntime(defaultRuntime, async () => {
await commitmentsListCommand(
{
json: Boolean(opts.json || parentOpts?.json),
agent: (opts.agent as string | undefined) ?? parentOpts?.agent,
status: (opts.status as string | undefined) ?? parentOpts?.status,
all: Boolean(opts.all || parentOpts?.all),
},
defaultRuntime,
);
});
});
commitmentsCmd
.command("dismiss <ids...>")
.description("Dismiss inferred follow-up commitments")
.option("--json", "Output JSON instead of text", false)
.action(async (ids: string[], opts, command) => {
const parentOpts = command.parent?.opts() as { json?: boolean } | undefined;
await runCommandWithRuntime(defaultRuntime, async () => {
await commitmentsDismissCommand(
{
ids,
json: Boolean(opts.json || parentOpts?.json),
},
defaultRuntime,
);
});
});
const tasksCmd = program
.command("tasks")
.description("Inspect durable background tasks and TaskFlow state")

150
src/commands/commitments.ts Normal file
View File

@@ -0,0 +1,150 @@
import {
listCommitments,
markCommitmentsStatus,
resolveCommitmentStorePath,
} from "../commitments/store.js";
import type { CommitmentRecord, CommitmentStatus } from "../commitments/types.js";
import { loadConfig } from "../config/config.js";
import { info } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { isRich, theme } from "../terminal/theme.js";
const STATUS_VALUES = new Set<CommitmentStatus>([
"pending",
"sent",
"dismissed",
"snoozed",
"expired",
]);
function truncate(value: string, maxChars: number): string {
return value.length <= maxChars ? value : `${value.slice(0, maxChars - 1)}...`;
}
function parseStatus(raw: string | undefined, runtime: RuntimeEnv): CommitmentStatus | undefined {
const status = normalizeOptionalString(raw);
if (!status) {
return undefined;
}
if (STATUS_VALUES.has(status as CommitmentStatus)) {
return status as CommitmentStatus;
}
runtime.error(`Unknown commitment status: ${status}`);
runtime.exit(1);
return undefined;
}
function isActiveCommitment(commitment: CommitmentRecord): boolean {
return commitment.status === "pending" || commitment.status === "snoozed";
}
function formatDue(ms: number): string {
return new Date(ms).toISOString();
}
function formatRows(commitments: CommitmentRecord[], rich: boolean): string[] {
const header = [
"ID".padEnd(16),
"Status".padEnd(10),
"Kind".padEnd(16),
"Due".padEnd(24),
"Scope".padEnd(28),
"Suggested text",
].join(" ");
const lines = [rich ? theme.heading(header) : header];
for (const commitment of commitments) {
const scope = truncate(
[commitment.agentId, commitment.channel, commitment.to ?? commitment.sessionKey]
.filter(Boolean)
.join("/"),
28,
);
lines.push(
[
truncate(commitment.id, 16).padEnd(16),
commitment.status.padEnd(10),
commitment.kind.padEnd(16),
formatDue(commitment.dueWindow.earliestMs).padEnd(24),
scope.padEnd(28),
truncate(commitment.suggestedText, 90),
].join(" "),
);
}
return lines;
}
export async function commitmentsListCommand(
opts: { json?: boolean; status?: string; all?: boolean; agent?: string },
runtime: RuntimeEnv,
): Promise<void> {
const cfg = loadConfig();
const status = opts.all ? undefined : parseStatus(opts.status ?? "pending", runtime);
if (!opts.all && opts.status && !status) {
return;
}
const commitments = (
await listCommitments({
cfg,
status,
agentId: normalizeOptionalString(opts.agent),
})
).filter((commitment) => opts.all || status || isActiveCommitment(commitment));
if (opts.json) {
runtime.log(
JSON.stringify(
{
count: commitments.length,
status: status ?? (opts.all ? null : "pending"),
agentId: normalizeOptionalString(opts.agent) ?? null,
store: resolveCommitmentStorePath(cfg.commitments?.store),
commitments,
},
null,
2,
),
);
return;
}
runtime.log(info(`Commitments: ${commitments.length}`));
runtime.log(info(`Store: ${resolveCommitmentStorePath(cfg.commitments?.store)}`));
if (status) {
runtime.log(info(`Status filter: ${status}`));
}
if (opts.agent) {
runtime.log(info(`Agent filter: ${opts.agent}`));
}
if (commitments.length === 0) {
runtime.log("No commitments found.");
return;
}
for (const line of formatRows(commitments, isRich())) {
runtime.log(line);
}
}
export async function commitmentsDismissCommand(
opts: { ids: string[]; json?: boolean },
runtime: RuntimeEnv,
): Promise<void> {
const ids = opts.ids.map((id) => id.trim()).filter(Boolean);
if (ids.length === 0) {
runtime.error("At least one commitment id is required.");
runtime.exit(1);
return;
}
const cfg = loadConfig();
await markCommitmentsStatus({
cfg,
ids,
status: "dismissed",
nowMs: Date.now(),
});
if (opts.json) {
runtime.log(JSON.stringify({ dismissed: ids }, null, 2));
return;
}
runtime.log(info(`Dismissed commitments: ${ids.join(", ")}`));
}

View File

@@ -1615,6 +1615,46 @@ describe("doctor config flow", () => {
expect(result.cfg.plugins?.entries?.browser?.enabled).toBe(true);
});
it("preserves commitments config on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
commitments: {
enabled: true,
categories: {
careCheckIns: "gentle",
eventCheckIns: false,
},
extraction: {
enabled: true,
batchMaxItems: 4,
},
delivery: {
maxPerHeartbeat: 2,
expireAfterHours: 48,
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
expect(result.cfg.commitments).toEqual({
enabled: true,
categories: {
careCheckIns: "gentle",
eventCheckIns: false,
},
extraction: {
enabled: true,
batchMaxItems: 4,
},
delivery: {
maxPerHeartbeat: 2,
expireAfterHours: 48,
},
});
});
it("preserves discord streaming intent while stripping unsupported keys on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,

106
src/commitments/config.ts Normal file
View File

@@ -0,0 +1,106 @@
import { resolveUserTimezone } from "../agents/date-time.js";
import type { OpenClawConfig } from "../config/config.js";
import type { CommitmentsConfig } from "../config/types.commitments.js";
export const DEFAULT_COMMITMENT_EXTRACTION_DEBOUNCE_MS = 15_000;
export const DEFAULT_COMMITMENT_BATCH_MAX_ITEMS = 8;
export const DEFAULT_COMMITMENT_CONFIDENCE_THRESHOLD = 0.72;
export const DEFAULT_COMMITMENT_CARE_CONFIDENCE_THRESHOLD = 0.86;
export const DEFAULT_COMMITMENT_EXTRACTION_TIMEOUT_SECONDS = 45;
export const DEFAULT_COMMITMENT_MAX_PER_HEARTBEAT = 3;
export const DEFAULT_COMMITMENT_EXPIRE_AFTER_HOURS = 72;
export type ResolvedCommitmentsConfig = {
enabled: boolean;
store?: string;
categories: {
eventCheckIns: boolean;
deadlineCheckIns: boolean;
openLoops: boolean;
careCheckIns: false | "gentle" | true;
};
extraction: {
enabled: boolean;
model?: string;
debounceMs: number;
batchMaxItems: number;
confidenceThreshold: number;
careConfidenceThreshold: number;
timeoutSeconds: number;
};
delivery: {
maxPerHeartbeat: number;
expireAfterHours: number;
};
};
function positiveInt(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.floor(value)
: fallback;
}
function nonnegativeNumber(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
}
function resolveCareCheckIns(
value: CommitmentsConfig["categories"] | undefined,
): false | "gentle" | true {
const raw = value?.careCheckIns;
if (raw === false) {
return false;
}
if (raw === true) {
return true;
}
return "gentle";
}
export function resolveCommitmentsConfig(cfg?: OpenClawConfig): ResolvedCommitmentsConfig {
const raw = cfg?.commitments;
const extraction = raw?.extraction;
const delivery = raw?.delivery;
return {
enabled: raw?.enabled !== false,
store: raw?.store,
categories: {
eventCheckIns: raw?.categories?.eventCheckIns !== false,
deadlineCheckIns: raw?.categories?.deadlineCheckIns !== false,
openLoops: raw?.categories?.openLoops !== false,
careCheckIns: resolveCareCheckIns(raw?.categories),
},
extraction: {
enabled: extraction?.enabled !== false,
model: extraction?.model?.trim() || undefined,
debounceMs: nonnegativeNumber(
extraction?.debounceMs,
DEFAULT_COMMITMENT_EXTRACTION_DEBOUNCE_MS,
),
batchMaxItems: positiveInt(extraction?.batchMaxItems, DEFAULT_COMMITMENT_BATCH_MAX_ITEMS),
confidenceThreshold: nonnegativeNumber(
extraction?.confidenceThreshold,
DEFAULT_COMMITMENT_CONFIDENCE_THRESHOLD,
),
careConfidenceThreshold: nonnegativeNumber(
extraction?.careConfidenceThreshold,
DEFAULT_COMMITMENT_CARE_CONFIDENCE_THRESHOLD,
),
timeoutSeconds: positiveInt(
extraction?.timeoutSeconds,
DEFAULT_COMMITMENT_EXTRACTION_TIMEOUT_SECONDS,
),
},
delivery: {
maxPerHeartbeat: positiveInt(delivery?.maxPerHeartbeat, DEFAULT_COMMITMENT_MAX_PER_HEARTBEAT),
expireAfterHours: positiveInt(
delivery?.expireAfterHours,
DEFAULT_COMMITMENT_EXPIRE_AFTER_HOURS,
),
},
};
}
export function resolveCommitmentTimezone(cfg?: OpenClawConfig): string {
return resolveUserTimezone(cfg?.agents?.defaults?.userTimezone);
}

View File

@@ -0,0 +1,175 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
parseCommitmentExtractionOutput,
persistCommitmentExtractionResult,
validateCommitmentCandidates,
} from "./extraction.js";
import { loadCommitmentStore } from "./store.js";
import type { CommitmentCandidate, CommitmentExtractionItem } from "./types.js";
describe("commitment extraction", () => {
const tmpDirs: string[] = [];
const nowMs = Date.parse("2026-04-29T16:00:00.000Z");
afterEach(async () => {
await Promise.all(tmpDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
tmpDirs.length = 0;
});
async function createConfig(): Promise<OpenClawConfig> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-commitments-"));
tmpDirs.push(tmpDir);
return {
commitments: {
store: path.join(tmpDir, "commitments.json"),
},
};
}
function item(overrides?: Partial<CommitmentExtractionItem>): CommitmentExtractionItem {
return {
itemId: "turn-1",
nowMs,
timezone: "America/Los_Angeles",
agentId: "main",
sessionKey: "agent:main:telegram:user-1",
channel: "telegram",
to: "15551234567",
userText: "I have an interview tomorrow.",
assistantText: "Good luck. I hope it goes well.",
existingPending: [],
...overrides,
};
}
function candidate(overrides?: Partial<CommitmentCandidate>): CommitmentCandidate {
return {
itemId: "turn-1",
kind: "event_check_in",
sensitivity: "routine",
source: "inferred_user_context",
reason: "The user said they had an interview tomorrow.",
suggestedText: "How did the interview go?",
dedupeKey: "interview:2026-04-30",
confidence: 0.91,
dueWindow: {
earliest: "2026-04-30T17:00:00.000Z",
latest: "2026-04-30T23:00:00.000Z",
timezone: "America/Los_Angeles",
},
...overrides,
};
}
it("parses valid candidates from JSON output with surrounding text", () => {
const parsed = parseCommitmentExtractionOutput(
`noise {"candidates":[${JSON.stringify(candidate())}]} trailing`,
);
expect(parsed.candidates).toHaveLength(1);
expect(parsed.candidates[0]).toMatchObject({
kind: "event_check_in",
suggestedText: "How did the interview go?",
});
});
it("rejects disabled, low-confidence, and non-future candidates", () => {
const cfg: OpenClawConfig = {
commitments: {
categories: { careCheckIns: "gentle" },
extraction: { confidenceThreshold: 0.8, careConfidenceThreshold: 0.9 },
},
};
const valid = validateCommitmentCandidates({
cfg,
items: [item()],
result: {
candidates: [
candidate(),
candidate({
kind: "care_check_in",
sensitivity: "care",
reason: "The user said they were tired.",
suggestedText: "Hope you got some rest.",
dedupeKey: "sleep:2026-04-29",
confidence: 0.82,
}),
candidate({
dedupeKey: "past",
dueWindow: { earliest: "2026-04-29T15:00:00.000Z" },
}),
],
},
});
expect(valid.map((entry) => entry.candidate.dedupeKey)).toEqual(["interview:2026-04-30"]);
});
it("clamps inferred due time to at least one heartbeat interval after write time", () => {
const writeMs = nowMs + 5_000;
const valid = validateCommitmentCandidates({
cfg: {
agents: {
defaults: {
heartbeat: { every: "10m" },
},
},
},
items: [item()],
result: {
candidates: [
candidate({
dedupeKey: "too-soon",
dueWindow: {
earliest: new Date(nowMs + 60_000).toISOString(),
latest: new Date(nowMs + 120_000).toISOString(),
},
}),
],
},
nowMs: writeMs,
});
expect(valid).toHaveLength(1);
expect(valid[0]?.earliestMs).toBe(writeMs + 10 * 60_000);
expect(valid[0]?.latestMs).toBe(writeMs + 10 * 60_000 + 12 * 60 * 60_000);
});
it("persists inferred commitments and dedupes by scope and dedupe key", async () => {
const cfg = await createConfig();
const created = await persistCommitmentExtractionResult({
cfg,
items: [item()],
result: { candidates: [candidate()] },
nowMs,
});
const deduped = await persistCommitmentExtractionResult({
cfg,
items: [item()],
result: {
candidates: [
candidate({
reason: "Updated reason",
confidence: 0.97,
dueWindow: { earliest: "2026-04-30T18:00:00.000Z" },
}),
],
},
nowMs: nowMs + 1_000,
});
const store = await loadCommitmentStore(cfg.commitments?.store);
expect(created).toHaveLength(1);
expect(deduped).toHaveLength(0);
expect(store.commitments).toHaveLength(1);
expect(store.commitments[0]).toMatchObject({
reason: "Updated reason",
confidence: 0.97,
status: "pending",
});
});
});

View File

@@ -0,0 +1,362 @@
import { resolveAgentConfig } from "../agents/agent-scope.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveHeartbeatIntervalMs } from "../infra/heartbeat-summary.js";
import { isRecord } from "../utils.js";
import { resolveCommitmentsConfig } from "./config.js";
import {
isCommitmentKindEnabled,
listPendingCommitmentsForScope,
upsertInferredCommitments,
} from "./store.js";
import type {
CommitmentCandidate,
CommitmentExtractionBatchResult,
CommitmentExtractionItem,
CommitmentKind,
CommitmentSensitivity,
CommitmentSource,
} from "./types.js";
const KIND_VALUES = new Set<CommitmentKind>([
"event_check_in",
"deadline_check",
"care_check_in",
"open_loop",
]);
const SENSITIVITY_VALUES = new Set<CommitmentSensitivity>(["routine", "personal", "care"]);
const SOURCE_VALUES = new Set<CommitmentSource>(["inferred_user_context", "agent_promise"]);
function asString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function asNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function parseCandidate(raw: unknown): CommitmentCandidate | undefined {
if (!isRecord(raw)) {
return undefined;
}
if (raw.action === "skip") {
return undefined;
}
const itemId = asString(raw.itemId);
const kind = asString(raw.kind);
const sensitivity = asString(raw.sensitivity);
const source = asString(raw.source) ?? "inferred_user_context";
const reason = asString(raw.reason);
const suggestedText = asString(raw.suggestedText);
const dedupeKey = asString(raw.dedupeKey);
const confidence = asNumber(raw.confidence);
const dueWindow = isRecord(raw.dueWindow) ? raw.dueWindow : undefined;
const earliest = asString(dueWindow?.earliest);
const latest = asString(dueWindow?.latest);
const timezone = asString(dueWindow?.timezone);
if (
!itemId ||
!KIND_VALUES.has(kind as CommitmentKind) ||
!SENSITIVITY_VALUES.has(sensitivity as CommitmentSensitivity) ||
!SOURCE_VALUES.has(source as CommitmentSource) ||
!reason ||
!suggestedText ||
!dedupeKey ||
confidence === undefined ||
!earliest
) {
return undefined;
}
return {
itemId,
kind: kind as CommitmentKind,
sensitivity: sensitivity as CommitmentSensitivity,
source: source as CommitmentSource,
reason,
suggestedText,
dedupeKey,
confidence,
dueWindow: {
earliest,
...(latest ? { latest } : {}),
...(timezone ? { timezone } : {}),
},
};
}
function extractJsonObjectCandidates(raw: string): string[] {
const out: string[] = [];
let depth = 0;
let start = -1;
let inString = false;
let escaped = false;
for (let idx = 0; idx < raw.length; idx += 1) {
const char = raw[idx] ?? "";
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
if (inString) {
escaped = true;
}
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (inString) {
continue;
}
if (char === "{") {
if (depth === 0) {
start = idx;
}
depth += 1;
continue;
}
if (char === "}" && depth > 0) {
depth -= 1;
if (depth === 0 && start >= 0) {
out.push(raw.slice(start, idx + 1));
start = -1;
}
}
}
return out;
}
export function parseCommitmentExtractionOutput(raw: string): CommitmentExtractionBatchResult {
const candidates: CommitmentCandidate[] = [];
const trimmed = raw.trim();
if (!trimmed) {
return { candidates };
}
const records: Record<string, unknown>[] = [];
try {
const parsed = JSON.parse(trimmed) as unknown;
if (isRecord(parsed)) {
records.push(parsed);
}
} catch {
for (const candidate of extractJsonObjectCandidates(trimmed)) {
try {
const parsed = JSON.parse(candidate) as unknown;
if (isRecord(parsed)) {
records.push(parsed);
}
} catch {
// Ignore malformed fragments.
}
}
}
for (const record of records) {
const rawCandidates = Array.isArray(record.candidates) ? record.candidates : [];
for (const candidate of rawCandidates) {
const parsed = parseCandidate(candidate);
if (parsed) {
candidates.push(parsed);
}
}
}
return { candidates };
}
export async function hydrateCommitmentExtractionItem(params: {
cfg?: OpenClawConfig;
item: Omit<CommitmentExtractionItem, "existingPending">;
}): Promise<CommitmentExtractionItem> {
const existingPending = await listPendingCommitmentsForScope({
cfg: params.cfg,
scope: params.item,
nowMs: params.item.nowMs,
limit: 8,
});
return {
...params.item,
existingPending: existingPending.map((commitment) => ({
kind: commitment.kind,
reason: commitment.reason,
dedupeKey: commitment.dedupeKey,
earliestMs: commitment.dueWindow.earliestMs,
latestMs: commitment.dueWindow.latestMs,
})),
};
}
function formatExistingPending(item: CommitmentExtractionItem) {
return item.existingPending.map((commitment) => ({
kind: commitment.kind,
reason: commitment.reason,
dedupeKey: commitment.dedupeKey,
earliest: new Date(commitment.earliestMs).toISOString(),
latest: new Date(commitment.latestMs).toISOString(),
}));
}
export function buildCommitmentExtractionPrompt(params: {
cfg?: OpenClawConfig;
items: CommitmentExtractionItem[];
}): string {
const resolved = resolveCommitmentsConfig(params.cfg);
const categoryConfig = JSON.stringify(resolved.categories);
const items = params.items.map((item) => ({
itemId: item.itemId,
now: new Date(item.nowMs).toISOString(),
timezone: item.timezone,
scope: {
agentId: item.agentId,
sessionKey: item.sessionKey,
channel: item.channel,
accountId: item.accountId,
to: item.to,
threadId: item.threadId,
},
latestUserMessage: item.userText,
assistantResponse: item.assistantText ?? "",
existingPendingCommitments: formatExistingPending(item),
}));
return `You are OpenClaw's internal commitment extractor. This is a hidden background classification run. Do not address the user.
Create inferred follow-up commitments only. Exact user requests such as "remind me tomorrow", "schedule this", or "check in at 3" belong to cron/reminders and must be skipped.
Use these categories: ${categoryConfig}
Create a candidate only when the latest exchange creates a useful future check-in opportunity that the user did not explicitly schedule. Prefer no candidate over weak candidates.
Rules:
- Output JSON only, with top-level {"candidates":[...]}.
- Each candidate must include itemId, kind, sensitivity, source, dueWindow, reason, suggestedText, confidence, and dedupeKey.
- kind is one of event_check_in, deadline_check, care_check_in, open_loop.
- sensitivity is routine, personal, or care.
- source is inferred_user_context or agent_promise.
- dueWindow.earliest and dueWindow.latest must be ISO timestamps in the future relative to that item.
- Skip explicit reminders/scheduling requests; those are cron-owned.
- Skip if the assistant already clearly says a cron reminder was scheduled.
- Skip if the topic is already resolved in the assistant response.
- Care check-ins must be gentle, rare, and high confidence. Avoid interrogating language.
- Suggested text should be short, natural, and suitable to send in the same channel.
- Dedupe keys should be stable within a session, like "interview:2026-04-29" or "sleep:2026-04-29".
Items:
${JSON.stringify(items, null, 2)}`;
}
function parseDueMs(raw: string | undefined): number | undefined {
if (!raw) {
return undefined;
}
const parsed = Date.parse(raw);
return Number.isFinite(parsed) ? parsed : undefined;
}
function resolveMinimumDueMs(params: {
cfg?: OpenClawConfig;
item: CommitmentExtractionItem;
nowMs: number;
}): number {
const cfg = params.cfg ?? {};
const defaults = cfg.agents?.defaults?.heartbeat;
const overrides = resolveAgentConfig(cfg, params.item.agentId)?.heartbeat;
const heartbeat = defaults || overrides ? { ...defaults, ...overrides } : undefined;
const intervalMs = resolveHeartbeatIntervalMs(cfg, undefined, heartbeat) ?? 0;
return params.nowMs + intervalMs;
}
export function validateCommitmentCandidates(params: {
cfg?: OpenClawConfig;
items: CommitmentExtractionItem[];
result: CommitmentExtractionBatchResult;
nowMs?: number;
}): Array<{
item: CommitmentExtractionItem;
candidate: CommitmentCandidate;
earliestMs: number;
latestMs: number;
timezone: string;
}> {
const resolved = resolveCommitmentsConfig(params.cfg);
const itemsById = new Map(params.items.map((item) => [item.itemId, item]));
const nowMs = params.nowMs ?? Date.now();
const validated: Array<{
item: CommitmentExtractionItem;
candidate: CommitmentCandidate;
earliestMs: number;
latestMs: number;
timezone: string;
}> = [];
for (const candidate of params.result.candidates) {
const item = itemsById.get(candidate.itemId);
if (!item || !isCommitmentKindEnabled(candidate.kind, resolved.categories)) {
continue;
}
const threshold =
candidate.kind === "care_check_in" || candidate.sensitivity === "care"
? resolved.extraction.careConfidenceThreshold
: resolved.extraction.confidenceThreshold;
if (candidate.confidence < threshold) {
continue;
}
const extractedEarliestMs = parseDueMs(candidate.dueWindow.earliest);
if (extractedEarliestMs === undefined || extractedEarliestMs <= item.nowMs) {
continue;
}
const earliestMs = Math.max(
extractedEarliestMs,
resolveMinimumDueMs({
cfg: params.cfg,
item,
nowMs,
}),
);
const latestRawMs = parseDueMs(candidate.dueWindow.latest);
const latestMs =
latestRawMs !== undefined && latestRawMs >= earliestMs
? latestRawMs
: earliestMs + 12 * 60 * 60 * 1000;
validated.push({
item,
candidate,
earliestMs,
latestMs,
timezone: candidate.dueWindow.timezone ?? item.timezone,
});
}
return validated;
}
export async function persistCommitmentExtractionResult(params: {
cfg?: OpenClawConfig;
items: CommitmentExtractionItem[];
result: CommitmentExtractionBatchResult;
nowMs?: number;
}) {
const valid = validateCommitmentCandidates(params);
const byItem = new Map<string, typeof valid>();
for (const entry of valid) {
const existing = byItem.get(entry.item.itemId) ?? [];
existing.push(entry);
byItem.set(entry.item.itemId, existing);
}
const created = [];
for (const entries of byItem.values()) {
const item = entries[0]?.item;
if (!item) {
continue;
}
created.push(
...(await upsertInferredCommitments({
cfg: params.cfg,
item,
candidates: entries.map((entry) => ({
candidate: entry.candidate,
earliestMs: entry.earliestMs,
latestMs: entry.latestMs,
timezone: entry.timezone,
})),
nowMs: params.nowMs,
})),
);
}
return created;
}

View File

@@ -0,0 +1,118 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
configureCommitmentExtractionRuntime,
drainCommitmentExtractionQueue,
enqueueCommitmentExtraction,
resetCommitmentExtractionRuntimeForTests,
} from "./runtime.js";
import { loadCommitmentStore } from "./store.js";
import type { CommitmentExtractionItem } from "./types.js";
describe("commitment extraction runtime", () => {
const tmpDirs: string[] = [];
const nowMs = Date.parse("2026-04-29T16:00:00.000Z");
afterEach(async () => {
resetCommitmentExtractionRuntimeForTests();
await Promise.all(tmpDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
tmpDirs.length = 0;
});
async function createConfig(): Promise<OpenClawConfig> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-commitment-runtime-"));
tmpDirs.push(tmpDir);
return {
commitments: {
store: path.join(tmpDir, "commitments.json"),
extraction: {
debounceMs: 1_000,
batchMaxItems: 8,
},
},
};
}
it("does not enqueue background extraction in test mode unless forced", async () => {
const cfg = await createConfig();
expect(
enqueueCommitmentExtraction({
cfg,
nowMs,
agentId: "main",
sessionKey: "agent:main:telegram:user-1",
channel: "telegram",
userText: "Interview tomorrow.",
assistantText: "Good luck.",
}),
).toBe(false);
});
it("micro-batches queued turns into one extractor call", async () => {
const cfg = await createConfig();
const extractBatch = vi.fn(async ({ items }: { items: CommitmentExtractionItem[] }) => ({
candidates: items.map((item, index) => ({
itemId: item.itemId,
kind: "event_check_in" as const,
sensitivity: "routine" as const,
source: "inferred_user_context" as const,
reason: `Follow up ${index + 1}`,
suggestedText: `How did item ${index + 1} go?`,
dedupeKey: `event:${index + 1}`,
confidence: 0.93,
dueWindow: {
earliest: "2026-04-30T17:00:00.000Z",
latest: "2026-04-30T23:00:00.000Z",
timezone: "America/Los_Angeles",
},
})),
}));
configureCommitmentExtractionRuntime({
forceInTests: true,
extractBatch,
setTimer: () => ({ unref() {} }) as ReturnType<typeof setTimeout>,
clearTimer: () => undefined,
});
expect(
enqueueCommitmentExtraction({
cfg,
nowMs,
agentId: "main",
sessionKey: "agent:main:telegram:user-1",
channel: "telegram",
to: "15551234567",
sourceMessageId: "m1",
userText: "I have an interview tomorrow.",
assistantText: "Good luck.",
}),
).toBe(true);
expect(
enqueueCommitmentExtraction({
cfg,
nowMs: nowMs + 1,
agentId: "main",
sessionKey: "agent:main:telegram:user-1",
channel: "telegram",
to: "15551234567",
sourceMessageId: "m2",
userText: "I have a dentist appointment tomorrow.",
assistantText: "Hope it goes smoothly.",
}),
).toBe(true);
await expect(drainCommitmentExtractionQueue()).resolves.toBe(2);
const store = await loadCommitmentStore(cfg.commitments?.store);
expect(extractBatch).toHaveBeenCalledTimes(1);
expect(extractBatch.mock.calls[0]?.[0].items).toHaveLength(2);
expect(store.commitments.map((commitment) => commitment.dedupeKey)).toEqual([
"event:1",
"event:2",
]);
});
});

258
src/commitments/runtime.ts Normal file
View File

@@ -0,0 +1,258 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import {
buildModelAliasIndex,
resolveDefaultModelForAgent,
resolveModelRefFromString,
} from "../agents/model-selection.js";
import { runEmbeddedPiAgent, type EmbeddedPiRunResult } from "../agents/pi-embedded.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { resolveCommitmentTimezone, resolveCommitmentsConfig } from "./config.js";
import {
buildCommitmentExtractionPrompt,
hydrateCommitmentExtractionItem,
parseCommitmentExtractionOutput,
persistCommitmentExtractionResult,
} from "./extraction.js";
import type {
CommitmentExtractionBatchResult,
CommitmentExtractionItem,
CommitmentScope,
} from "./types.js";
type TimerHandle = ReturnType<typeof setTimeout>;
export type CommitmentExtractionEnqueueInput = CommitmentScope & {
cfg?: OpenClawConfig;
nowMs?: number;
userText: string;
assistantText?: string;
sourceMessageId?: string;
sourceRunId?: string;
};
export type CommitmentExtractionRuntime = {
extractBatch?: (params: {
cfg?: OpenClawConfig;
items: CommitmentExtractionItem[];
}) => Promise<CommitmentExtractionBatchResult>;
setTimer?: (callback: () => void, delayMs: number) => TimerHandle;
clearTimer?: (timer: TimerHandle) => void;
forceInTests?: boolean;
};
const log = createSubsystemLogger("commitments");
let runtime: CommitmentExtractionRuntime = {};
let queue: Array<Omit<CommitmentExtractionItem, "existingPending"> & { cfg?: OpenClawConfig }> = [];
let timer: TimerHandle | null = null;
let draining = false;
function shouldDisableBackgroundExtractionForTests(): boolean {
if (runtime.forceInTests) {
return false;
}
return process.env.VITEST === "true" || process.env.NODE_ENV === "test";
}
function setTimer(callback: () => void, delayMs: number): TimerHandle {
const handle = runtime.setTimer
? runtime.setTimer(callback, delayMs)
: setTimeout(callback, delayMs);
if (typeof handle === "object" && "unref" in handle && typeof handle.unref === "function") {
handle.unref();
}
return handle;
}
function clearTimer(handle: TimerHandle): void {
(runtime.clearTimer ?? clearTimeout)(handle);
}
export function configureCommitmentExtractionRuntime(next: CommitmentExtractionRuntime): void {
runtime = next;
}
export function resetCommitmentExtractionRuntimeForTests(): void {
if (timer) {
clearTimer(timer);
}
runtime = {};
queue = [];
timer = null;
draining = false;
}
function buildItemId(params: CommitmentExtractionEnqueueInput, nowMs: number): string {
const source = normalizeOptionalString(params.sourceMessageId) ?? randomUUID();
return [
params.agentId,
params.sessionKey,
params.channel,
params.accountId ?? "",
params.to ?? "",
params.threadId ?? "",
source,
nowMs,
].join(":");
}
function isUsefulText(value: string | undefined): boolean {
return Boolean(value?.trim());
}
export function enqueueCommitmentExtraction(input: CommitmentExtractionEnqueueInput): boolean {
const resolved = resolveCommitmentsConfig(input.cfg);
if (
!resolved.enabled ||
!resolved.extraction.enabled ||
shouldDisableBackgroundExtractionForTests() ||
!isUsefulText(input.userText) ||
!isUsefulText(input.assistantText) ||
!input.agentId.trim() ||
!input.sessionKey.trim() ||
!input.channel.trim()
) {
return false;
}
const nowMs = input.nowMs ?? Date.now();
queue.push({
itemId: buildItemId(input, nowMs),
nowMs,
timezone: resolveCommitmentTimezone(input.cfg),
agentId: input.agentId.trim(),
sessionKey: input.sessionKey.trim(),
channel: input.channel.trim(),
...(input.accountId?.trim() ? { accountId: input.accountId.trim() } : {}),
...(input.to?.trim() ? { to: input.to.trim() } : {}),
...(input.threadId?.trim() ? { threadId: input.threadId.trim() } : {}),
...(input.senderId?.trim() ? { senderId: input.senderId.trim() } : {}),
userText: input.userText.trim(),
...(input.assistantText?.trim() ? { assistantText: input.assistantText.trim() } : {}),
...(input.sourceMessageId?.trim() ? { sourceMessageId: input.sourceMessageId.trim() } : {}),
...(input.sourceRunId?.trim() ? { sourceRunId: input.sourceRunId.trim() } : {}),
cfg: input.cfg,
});
if (!timer) {
timer = setTimer(() => {
timer = null;
void drainCommitmentExtractionQueue().catch((err) => {
log.warn("commitment extraction failed", { error: String(err) });
});
}, resolved.extraction.debounceMs);
}
return true;
}
function resolveExtractionSessionFile(agentId: string, runId: string): string {
return path.join(
resolveStateDir(),
"commitments",
"extractor-sessions",
agentId,
`${runId}.jsonl`,
);
}
function joinPayloadText(result: EmbeddedPiRunResult): string {
return (
result.payloads
?.map((payload) => payload.text)
.filter((text): text is string => Boolean(text?.trim()))
.join("\n")
.trim() ?? ""
);
}
async function defaultExtractBatch(params: {
cfg?: OpenClawConfig;
items: CommitmentExtractionItem[];
}): Promise<CommitmentExtractionBatchResult> {
const cfg = params.cfg ?? {};
const first = params.items[0];
if (!first) {
return { candidates: [] };
}
const resolved = resolveCommitmentsConfig(cfg);
const runId = `commitments-${randomUUID()}`;
const modelFallback = resolveDefaultModelForAgent({ cfg: cfg ?? {}, agentId: first.agentId });
const aliasIndex = buildModelAliasIndex({
cfg: cfg ?? {},
defaultProvider: modelFallback.provider,
});
const modelRef = resolved.extraction.model
? resolveModelRefFromString({
raw: resolved.extraction.model,
defaultProvider: modelFallback.provider,
aliasIndex,
})?.ref
: undefined;
const result = await runEmbeddedPiAgent({
sessionId: runId,
sessionKey: `agent:${first.agentId}:commitments:${runId}`,
agentId: first.agentId,
trigger: "manual",
sessionFile: resolveExtractionSessionFile(first.agentId, runId),
workspaceDir: resolveAgentWorkspaceDir(cfg, first.agentId),
config: cfg,
prompt: buildCommitmentExtractionPrompt({ cfg, items: params.items }),
disableTools: true,
provider: modelRef?.provider,
model: modelRef?.model,
thinkLevel: "off",
verboseLevel: "off",
reasoningLevel: "off",
fastMode: true,
timeoutMs: resolved.extraction.timeoutSeconds * 1000,
runId,
bootstrapContextMode: "lightweight",
skillsSnapshot: { prompt: "", skills: [] },
suppressToolErrorWarnings: true,
});
return parseCommitmentExtractionOutput(joinPayloadText(result));
}
async function hydrateBatch(
batch: Array<Omit<CommitmentExtractionItem, "existingPending"> & { cfg?: OpenClawConfig }>,
): Promise<CommitmentExtractionItem[]> {
return Promise.all(
batch.map(async (item) =>
hydrateCommitmentExtractionItem({
cfg: item.cfg,
item,
}),
),
);
}
export async function drainCommitmentExtractionQueue(): Promise<number> {
if (draining) {
return 0;
}
draining = true;
try {
let processed = 0;
while (queue.length > 0) {
const firstCfg = queue[0]?.cfg;
const resolved = resolveCommitmentsConfig(firstCfg);
const batch = queue.splice(0, resolved.extraction.batchMaxItems);
const items = await hydrateBatch(batch);
const extractor = runtime.extractBatch ?? defaultExtractBatch;
const result = await extractor({ cfg: firstCfg, items });
await persistCommitmentExtractionResult({
cfg: firstCfg,
items,
result,
nowMs: Date.now(),
});
processed += items.length;
}
return processed;
} finally {
draining = false;
}
}

433
src/commitments/store.ts Normal file
View File

@@ -0,0 +1,433 @@
import { randomBytes } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { expandHomePrefix } from "../infra/home-dir.js";
import { resolveCommitmentsConfig } from "./config.js";
import type {
CommitmentCandidate,
CommitmentExtractionItem,
CommitmentKind,
CommitmentRecord,
CommitmentScope,
CommitmentStatus,
CommitmentStoreFile,
} from "./types.js";
const STORE_VERSION = 1 as const;
function defaultCommitmentStorePath(): string {
return path.join(resolveStateDir(), "commitments", "commitments.json");
}
export function resolveCommitmentStorePath(storePath?: string): string {
const trimmed = storePath?.trim();
if (!trimmed) {
return defaultCommitmentStorePath();
}
if (trimmed.startsWith("~")) {
return path.resolve(expandHomePrefix(trimmed));
}
return path.resolve(trimmed);
}
function emptyStore(): CommitmentStoreFile {
return { version: STORE_VERSION, commitments: [] };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function coerceCommitment(raw: unknown): CommitmentRecord | undefined {
if (!isRecord(raw)) {
return undefined;
}
const dueWindow = isRecord(raw.dueWindow) ? raw.dueWindow : undefined;
if (!dueWindow) {
return undefined;
}
const requiredStrings = [
raw.id,
raw.agentId,
raw.sessionKey,
raw.channel,
raw.kind,
raw.sensitivity,
raw.source,
raw.status,
raw.reason,
raw.suggestedText,
raw.dedupeKey,
raw.sourceUserText,
];
if (requiredStrings.some((value) => typeof value !== "string" || !value.trim())) {
return undefined;
}
if (
typeof raw.confidence !== "number" ||
typeof raw.createdAtMs !== "number" ||
typeof raw.updatedAtMs !== "number" ||
typeof raw.attempts !== "number" ||
typeof dueWindow.earliestMs !== "number" ||
typeof dueWindow.latestMs !== "number" ||
typeof dueWindow.timezone !== "string"
) {
return undefined;
}
return raw as CommitmentRecord;
}
export async function loadCommitmentStore(storePath?: string): Promise<CommitmentStoreFile> {
const resolved = resolveCommitmentStorePath(storePath);
try {
const raw = await fs.promises.readFile(resolved, "utf-8");
const parsed = JSON.parse(raw) as unknown;
if (
!isRecord(parsed) ||
parsed.version !== STORE_VERSION ||
!Array.isArray(parsed.commitments)
) {
return emptyStore();
}
return {
version: STORE_VERSION,
commitments: parsed.commitments.flatMap((entry) => {
const coerced = coerceCommitment(entry);
return coerced ? [coerced] : [];
}),
};
} catch (err) {
if ((err as { code?: unknown })?.code === "ENOENT") {
return emptyStore();
}
throw err;
}
}
export async function saveCommitmentStore(
storePath: string | undefined,
store: CommitmentStoreFile,
): Promise<void> {
const resolved = resolveCommitmentStorePath(storePath);
const dir = path.dirname(resolved);
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
await fs.promises.chmod(dir, 0o700).catch(() => undefined);
const json = JSON.stringify(store, null, 2);
const tmp = `${resolved}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
await fs.promises.writeFile(tmp, json, { encoding: "utf-8", mode: 0o600 });
await fs.promises.chmod(tmp, 0o600).catch(() => undefined);
await fs.promises.rename(tmp, resolved);
await fs.promises.chmod(resolved, 0o600).catch(() => undefined);
}
function generateCommitmentId(nowMs: number): string {
return `cm_${nowMs.toString(36)}_${randomBytes(5).toString("hex")}`;
}
function scopeValue(value: string | undefined): string {
return value?.trim() ?? "";
}
export function buildCommitmentScopeKey(scope: CommitmentScope): string {
return [
scopeValue(scope.agentId),
scopeValue(scope.sessionKey),
scopeValue(scope.channel),
scopeValue(scope.accountId),
scopeValue(scope.to),
scopeValue(scope.threadId),
scopeValue(scope.senderId),
].join("\u001f");
}
function isActiveStatus(status: CommitmentStatus): boolean {
return status === "pending" || status === "snoozed";
}
function candidateToRecord(params: {
item: CommitmentExtractionItem;
candidate: CommitmentCandidate;
nowMs: number;
earliestMs: number;
latestMs: number;
timezone: string;
}): CommitmentRecord {
return {
id: generateCommitmentId(params.nowMs),
agentId: params.item.agentId,
sessionKey: params.item.sessionKey,
channel: params.item.channel,
...(params.item.accountId ? { accountId: params.item.accountId } : {}),
...(params.item.to ? { to: params.item.to } : {}),
...(params.item.threadId ? { threadId: params.item.threadId } : {}),
...(params.item.senderId ? { senderId: params.item.senderId } : {}),
kind: params.candidate.kind,
sensitivity: params.candidate.sensitivity,
source: params.candidate.source,
status: "pending",
reason: params.candidate.reason.trim(),
suggestedText: params.candidate.suggestedText.trim(),
dedupeKey: params.candidate.dedupeKey.trim(),
confidence: params.candidate.confidence,
dueWindow: {
earliestMs: params.earliestMs,
latestMs: params.latestMs,
timezone: params.timezone,
},
...(params.item.sourceMessageId ? { sourceMessageId: params.item.sourceMessageId } : {}),
...(params.item.sourceRunId ? { sourceRunId: params.item.sourceRunId } : {}),
sourceUserText: params.item.userText,
...(params.item.assistantText ? { sourceAssistantText: params.item.assistantText } : {}),
createdAtMs: params.nowMs,
updatedAtMs: params.nowMs,
attempts: 0,
};
}
export async function listPendingCommitmentsForScope(params: {
cfg?: OpenClawConfig;
scope: CommitmentScope;
nowMs?: number;
limit?: number;
}): Promise<CommitmentRecord[]> {
const resolved = resolveCommitmentsConfig(params.cfg);
const store = await loadCommitmentStore(resolved.store);
const scopeKey = buildCommitmentScopeKey(params.scope);
const nowMs = params.nowMs ?? Date.now();
const limit = params.limit ?? 20;
return store.commitments
.filter(
(commitment) =>
buildCommitmentScopeKey(commitment) === scopeKey &&
isActiveStatus(commitment.status) &&
(commitment.status !== "snoozed" || (commitment.snoozedUntilMs ?? 0) <= nowMs),
)
.toSorted(
(a, b) => a.dueWindow.earliestMs - b.dueWindow.earliestMs || a.createdAtMs - b.createdAtMs,
)
.slice(0, limit);
}
export async function upsertInferredCommitments(params: {
cfg?: OpenClawConfig;
item: CommitmentExtractionItem;
candidates: Array<{
candidate: CommitmentCandidate;
earliestMs: number;
latestMs: number;
timezone: string;
}>;
nowMs?: number;
}): Promise<CommitmentRecord[]> {
if (params.candidates.length === 0) {
return [];
}
const resolved = resolveCommitmentsConfig(params.cfg);
const store = await loadCommitmentStore(resolved.store);
const nowMs = params.nowMs ?? Date.now();
const created: CommitmentRecord[] = [];
const scopeKey = buildCommitmentScopeKey(params.item);
for (const entry of params.candidates) {
const dedupeKey = entry.candidate.dedupeKey.trim();
const existingIndex = store.commitments.findIndex(
(commitment) =>
buildCommitmentScopeKey(commitment) === scopeKey &&
commitment.dedupeKey === dedupeKey &&
isActiveStatus(commitment.status),
);
if (existingIndex >= 0) {
const existing = store.commitments[existingIndex];
store.commitments[existingIndex] = {
...existing,
reason: entry.candidate.reason.trim() || existing.reason,
suggestedText: entry.candidate.suggestedText.trim() || existing.suggestedText,
confidence: Math.max(existing.confidence, entry.candidate.confidence),
dueWindow: {
earliestMs: Math.min(existing.dueWindow.earliestMs, entry.earliestMs),
latestMs: Math.max(existing.dueWindow.latestMs, entry.latestMs),
timezone: entry.timezone,
},
updatedAtMs: nowMs,
};
continue;
}
const record = candidateToRecord({
item: params.item,
candidate: entry.candidate,
nowMs,
earliestMs: entry.earliestMs,
latestMs: entry.latestMs,
timezone: entry.timezone,
});
store.commitments.push(record);
created.push(record);
}
if (created.length > 0) {
await saveCommitmentStore(resolved.store, store);
} else {
await saveCommitmentStore(resolved.store, store);
}
return created;
}
export async function listDueCommitmentsForSession(params: {
cfg?: OpenClawConfig;
agentId: string;
sessionKey: string;
nowMs?: number;
limit?: number;
}): Promise<CommitmentRecord[]> {
const resolved = resolveCommitmentsConfig(params.cfg);
if (!resolved.enabled) {
return [];
}
const store = await loadCommitmentStore(resolved.store);
const nowMs = params.nowMs ?? Date.now();
const limit = params.limit ?? resolved.delivery.maxPerHeartbeat;
const expireAfterMs = resolved.delivery.expireAfterHours * 60 * 60 * 1000;
return store.commitments
.filter(
(commitment) =>
commitment.agentId === params.agentId &&
commitment.sessionKey === params.sessionKey &&
isActiveStatus(commitment.status) &&
commitment.dueWindow.earliestMs <= nowMs &&
commitment.dueWindow.latestMs + expireAfterMs >= nowMs &&
(commitment.status !== "snoozed" || (commitment.snoozedUntilMs ?? 0) <= nowMs),
)
.toSorted(
(a, b) => a.dueWindow.earliestMs - b.dueWindow.earliestMs || a.createdAtMs - b.createdAtMs,
)
.slice(0, limit);
}
export async function listDueCommitmentSessionKeys(params: {
cfg?: OpenClawConfig;
agentId: string;
nowMs?: number;
limit?: number;
}): Promise<string[]> {
const resolved = resolveCommitmentsConfig(params.cfg);
if (!resolved.enabled) {
return [];
}
const store = await loadCommitmentStore(resolved.store);
const nowMs = params.nowMs ?? Date.now();
const expireAfterMs = resolved.delivery.expireAfterHours * 60 * 60 * 1000;
const keys = new Set<string>();
for (const commitment of store.commitments) {
if (
commitment.agentId === params.agentId &&
isActiveStatus(commitment.status) &&
commitment.dueWindow.earliestMs <= nowMs &&
commitment.dueWindow.latestMs + expireAfterMs >= nowMs &&
(commitment.status !== "snoozed" || (commitment.snoozedUntilMs ?? 0) <= nowMs)
) {
keys.add(commitment.sessionKey);
}
if (params.limit && keys.size >= params.limit) {
break;
}
}
return [...keys].toSorted();
}
export async function markCommitmentsAttempted(params: {
cfg?: OpenClawConfig;
ids: string[];
nowMs?: number;
}): Promise<void> {
if (params.ids.length === 0) {
return;
}
const resolved = resolveCommitmentsConfig(params.cfg);
const idSet = new Set(params.ids);
const nowMs = params.nowMs ?? Date.now();
const store = await loadCommitmentStore(resolved.store);
let changed = false;
store.commitments = store.commitments.map((commitment) => {
if (!idSet.has(commitment.id)) {
return commitment;
}
changed = true;
return {
...commitment,
attempts: commitment.attempts + 1,
lastAttemptAtMs: nowMs,
updatedAtMs: nowMs,
};
});
if (changed) {
await saveCommitmentStore(resolved.store, store);
}
}
export async function markCommitmentsStatus(params: {
cfg?: OpenClawConfig;
ids: string[];
status: Extract<CommitmentStatus, "sent" | "dismissed" | "expired">;
nowMs?: number;
}): Promise<void> {
if (params.ids.length === 0) {
return;
}
const resolved = resolveCommitmentsConfig(params.cfg);
const idSet = new Set(params.ids);
const nowMs = params.nowMs ?? Date.now();
const store = await loadCommitmentStore(resolved.store);
let changed = false;
store.commitments = store.commitments.map((commitment) => {
if (!idSet.has(commitment.id) || !isActiveStatus(commitment.status)) {
return commitment;
}
changed = true;
return {
...commitment,
status: params.status,
updatedAtMs: nowMs,
...(params.status === "sent" ? { sentAtMs: nowMs } : {}),
...(params.status === "dismissed" ? { dismissedAtMs: nowMs } : {}),
...(params.status === "expired" ? { expiredAtMs: nowMs } : {}),
};
});
if (changed) {
await saveCommitmentStore(resolved.store, store);
}
}
export async function listCommitments(params?: {
cfg?: OpenClawConfig;
status?: CommitmentStatus;
agentId?: string;
}): Promise<CommitmentRecord[]> {
const resolved = resolveCommitmentsConfig(params?.cfg);
const store = await loadCommitmentStore(resolved.store);
return store.commitments
.filter(
(commitment) =>
(!params?.status || commitment.status === params.status) &&
(!params?.agentId || commitment.agentId === params.agentId),
)
.toSorted(
(a, b) => a.dueWindow.earliestMs - b.dueWindow.earliestMs || a.createdAtMs - b.createdAtMs,
);
}
export function isCommitmentKindEnabled(
kind: CommitmentKind,
categories: ReturnType<typeof resolveCommitmentsConfig>["categories"],
): boolean {
switch (kind) {
case "event_check_in":
return categories.eventCheckIns;
case "deadline_check":
return categories.deadlineCheckIns;
case "open_loop":
return categories.openLoops;
case "care_check_in":
return categories.careCheckIns !== false;
}
}

90
src/commitments/types.ts Normal file
View File

@@ -0,0 +1,90 @@
export type CommitmentKind = "event_check_in" | "deadline_check" | "care_check_in" | "open_loop";
export type CommitmentSensitivity = "routine" | "personal" | "care";
export type CommitmentStatus = "pending" | "sent" | "dismissed" | "snoozed" | "expired";
export type CommitmentSource = "inferred_user_context" | "agent_promise";
export type CommitmentScope = {
agentId: string;
sessionKey: string;
channel: string;
accountId?: string;
to?: string;
threadId?: string;
senderId?: string;
};
export type CommitmentDueWindow = {
earliestMs: number;
latestMs: number;
timezone: string;
};
export type CommitmentRecord = CommitmentScope & {
id: string;
kind: CommitmentKind;
sensitivity: CommitmentSensitivity;
source: CommitmentSource;
status: CommitmentStatus;
reason: string;
suggestedText: string;
dedupeKey: string;
confidence: number;
dueWindow: CommitmentDueWindow;
sourceMessageId?: string;
sourceRunId?: string;
sourceUserText: string;
sourceAssistantText?: string;
createdAtMs: number;
updatedAtMs: number;
attempts: number;
lastAttemptAtMs?: number;
sentAtMs?: number;
dismissedAtMs?: number;
snoozedUntilMs?: number;
expiredAtMs?: number;
};
export type CommitmentStoreFile = {
version: 1;
commitments: CommitmentRecord[];
};
export type CommitmentCandidate = {
itemId: string;
kind: CommitmentKind;
sensitivity: CommitmentSensitivity;
source: CommitmentSource;
reason: string;
suggestedText: string;
dedupeKey: string;
confidence: number;
dueWindow: {
earliest: string;
latest?: string;
timezone?: string;
};
};
export type CommitmentExtractionItem = CommitmentScope & {
itemId: string;
nowMs: number;
timezone: string;
userText: string;
assistantText?: string;
sourceMessageId?: string;
sourceRunId?: string;
existingPending: Array<{
kind: CommitmentKind;
reason: string;
dedupeKey: string;
earliestMs: number;
latestMs: number;
}>;
};
export type CommitmentExtractionBatchResult = {
candidates: CommitmentCandidate[];
};

View File

@@ -21066,6 +21066,153 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
description:
"Global scheduler settings for stored cron jobs, run concurrency, delivery fallback, and run-session retention. Keep defaults unless you are scaling job volume or integrating external webhook receivers.",
},
commitments: {
type: "object",
properties: {
enabled: {
type: "boolean",
title: "Commitments Enabled",
description:
"Global inferred commitment feature gate. Set false to disable background extraction, storage, and heartbeat delivery for inferred follow-ups.",
},
store: {
type: "string",
title: "Commitments Store Path",
description:
"Optional JSON store path for inferred commitments. Leave unset to use the default OpenClaw state directory store.",
},
categories: {
type: "object",
properties: {
eventCheckIns: {
type: "boolean",
title: "Event Check-ins",
description:
"Enables inferred event check-ins such as asking how an interview or appointment went. Default: true.",
},
deadlineCheckIns: {
type: "boolean",
title: "Deadline Check-ins",
description:
"Enables inferred deadline or progress check-ins for work the user expects to revisit. Default: true.",
},
openLoops: {
type: "boolean",
title: "Open-loop Check-ins",
description:
"Enables inferred open-loop check-ins when the user is waiting on an outcome or unresolved next step. Default: true.",
},
careCheckIns: {
anyOf: [
{
type: "boolean",
},
{
type: "string",
const: "gentle",
},
],
title: "Care Check-ins",
description:
'Controls personal care check-ins. Use "gentle" for conservative care follow-ups, true for normal extraction, or false to disable them.',
},
},
additionalProperties: false,
title: "Commitment Categories",
description:
"Category gates for inferred commitments such as event check-ins, deadline progress, open loops, and care check-ins. Use these to narrow what OpenClaw infers while keeping the system enabled.",
},
extraction: {
type: "object",
properties: {
enabled: {
type: "boolean",
title: "Commitment Extraction Enabled",
description:
"Enables hidden background LLM extraction for inferred commitments. Set false to keep stored commitments deliverable while preventing new inferred commitments.",
},
model: {
type: "string",
title: "Commitment Extraction Model",
description:
"Optional provider/model override for hidden commitment extraction runs. Leave unset to use the active agent model.",
},
debounceMs: {
type: "integer",
minimum: 0,
maximum: 9007199254740991,
title: "Commitment Extraction Debounce (ms)",
description:
"Milliseconds to wait before draining queued conversation turns into a batched hidden extraction run. Default: 15000.",
},
batchMaxItems: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
title: "Commitment Extraction Batch Size",
description:
"Maximum queued turn extractions sent in one hidden model call. Default: 8.",
},
confidenceThreshold: {
type: "number",
minimum: 0,
maximum: 1,
title: "Commitment Confidence Threshold",
description:
"Minimum accepted confidence from the extractor for routine inferred commitments. Default: 0.72.",
},
careConfidenceThreshold: {
type: "number",
minimum: 0,
maximum: 1,
title: "Care Commitment Confidence Threshold",
description:
"Minimum accepted confidence from the extractor for personal care check-ins. Default: 0.86.",
},
timeoutSeconds: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
title: "Commitment Extraction Timeout (sec)",
description:
"Maximum runtime in seconds for a hidden extraction pass before it is abandoned. Default: 45.",
},
},
additionalProperties: false,
title: "Commitment Extraction",
description:
"Background extraction controls for the hidden LLM pass that creates inferred commitments without adding content to the conversation transcript.",
},
delivery: {
type: "object",
properties: {
maxPerHeartbeat: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
title: "Commitments per Heartbeat",
description:
"Maximum due inferred commitments injected into one heartbeat turn. Default: 3.",
},
expireAfterHours: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
title: "Commitment Expiration (hours)",
description:
"Number of hours after the due time before a pending inferred commitment expires instead of being delivered. Default: 72.",
},
},
additionalProperties: false,
title: "Commitment Delivery",
description: "Heartbeat delivery controls for due inferred commitments.",
},
},
additionalProperties: false,
title: "Commitments",
description:
"Inferred follow-up commitment controls for automatically detecting check-ins from conversation turns and delivering them through heartbeat runs. Keep enabled for ambient follow-ups, or disable when you only want explicit reminders.",
},
hooks: {
type: "object",
properties: {
@@ -24299,6 +24446,101 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
help: "How often beta-channel checks run in hours (default: 1).",
tags: ["performance"],
},
commitments: {
label: "Commitments",
help: "Inferred follow-up commitment controls for automatically detecting check-ins from conversation turns and delivering them through heartbeat runs. Keep enabled for ambient follow-ups, or disable when you only want explicit reminders.",
tags: ["advanced"],
},
"commitments.enabled": {
label: "Commitments Enabled",
help: "Global inferred commitment feature gate. Set false to disable background extraction, storage, and heartbeat delivery for inferred follow-ups.",
tags: ["advanced"],
},
"commitments.store": {
label: "Commitments Store Path",
help: "Optional JSON store path for inferred commitments. Leave unset to use the default OpenClaw state directory store.",
tags: ["storage"],
},
"commitments.categories": {
label: "Commitment Categories",
help: "Category gates for inferred commitments such as event check-ins, deadline progress, open loops, and care check-ins. Use these to narrow what OpenClaw infers while keeping the system enabled.",
tags: ["advanced"],
},
"commitments.categories.eventCheckIns": {
label: "Event Check-ins",
help: "Enables inferred event check-ins such as asking how an interview or appointment went. Default: true.",
tags: ["advanced"],
},
"commitments.categories.deadlineCheckIns": {
label: "Deadline Check-ins",
help: "Enables inferred deadline or progress check-ins for work the user expects to revisit. Default: true.",
tags: ["advanced"],
},
"commitments.categories.openLoops": {
label: "Open-loop Check-ins",
help: "Enables inferred open-loop check-ins when the user is waiting on an outcome or unresolved next step. Default: true.",
tags: ["advanced"],
},
"commitments.categories.careCheckIns": {
label: "Care Check-ins",
help: 'Controls personal care check-ins. Use "gentle" for conservative care follow-ups, true for normal extraction, or false to disable them.',
tags: ["advanced"],
},
"commitments.extraction": {
label: "Commitment Extraction",
help: "Background extraction controls for the hidden LLM pass that creates inferred commitments without adding content to the conversation transcript.",
tags: ["advanced"],
},
"commitments.extraction.enabled": {
label: "Commitment Extraction Enabled",
help: "Enables hidden background LLM extraction for inferred commitments. Set false to keep stored commitments deliverable while preventing new inferred commitments.",
tags: ["advanced"],
},
"commitments.extraction.model": {
label: "Commitment Extraction Model",
help: "Optional provider/model override for hidden commitment extraction runs. Leave unset to use the active agent model.",
tags: ["models"],
},
"commitments.extraction.debounceMs": {
label: "Commitment Extraction Debounce (ms)",
help: "Milliseconds to wait before draining queued conversation turns into a batched hidden extraction run. Default: 15000.",
tags: ["performance"],
},
"commitments.extraction.batchMaxItems": {
label: "Commitment Extraction Batch Size",
help: "Maximum queued turn extractions sent in one hidden model call. Default: 8.",
tags: ["performance"],
},
"commitments.extraction.confidenceThreshold": {
label: "Commitment Confidence Threshold",
help: "Minimum accepted confidence from the extractor for routine inferred commitments. Default: 0.72.",
tags: ["advanced"],
},
"commitments.extraction.careConfidenceThreshold": {
label: "Care Commitment Confidence Threshold",
help: "Minimum accepted confidence from the extractor for personal care check-ins. Default: 0.86.",
tags: ["advanced"],
},
"commitments.extraction.timeoutSeconds": {
label: "Commitment Extraction Timeout (sec)",
help: "Maximum runtime in seconds for a hidden extraction pass before it is abandoned. Default: 45.",
tags: ["performance"],
},
"commitments.delivery": {
label: "Commitment Delivery",
help: "Heartbeat delivery controls for due inferred commitments.",
tags: ["advanced"],
},
"commitments.delivery.maxPerHeartbeat": {
label: "Commitments per Heartbeat",
help: "Maximum due inferred commitments injected into one heartbeat turn. Default: 3.",
tags: ["performance", "automation"],
},
"commitments.delivery.expireAfterHours": {
label: "Commitment Expiration (hours)",
help: "Number of hours after the due time before a pending inferred commitment expires instead of being delivered. Default: 72.",
tags: ["advanced"],
},
"diagnostics.enabled": {
label: "Diagnostics Enabled",
help: "Master toggle for diagnostics instrumentation output in logs and telemetry wiring paths. Defaults to enabled; set false only in tightly constrained environments.",

View File

@@ -11,6 +11,7 @@ const ROOT_SECTIONS = [
"logging",
"cli",
"update",
"commitments",
"browser",
"ui",
"auth",

View File

@@ -190,6 +190,43 @@ export const FIELD_HELP: Record<string, string> = {
"Idle runtime TTL in minutes for ACP session workers before eligible cleanup.",
"acp.runtime.installCommand":
"Optional operator install/setup command shown by `/acp install` and `/acp doctor` when ACP backend wiring is missing.",
commitments:
"Inferred follow-up commitment controls for automatically detecting check-ins from conversation turns and delivering them through heartbeat runs. Keep enabled for ambient follow-ups, or disable when you only want explicit reminders.",
"commitments.enabled":
"Global inferred commitment feature gate. Set false to disable background extraction, storage, and heartbeat delivery for inferred follow-ups.",
"commitments.store":
"Optional JSON store path for inferred commitments. Leave unset to use the default OpenClaw state directory store.",
"commitments.categories":
"Category gates for inferred commitments such as event check-ins, deadline progress, open loops, and care check-ins. Use these to narrow what OpenClaw infers while keeping the system enabled.",
"commitments.categories.eventCheckIns":
"Enables inferred event check-ins such as asking how an interview or appointment went. Default: true.",
"commitments.categories.deadlineCheckIns":
"Enables inferred deadline or progress check-ins for work the user expects to revisit. Default: true.",
"commitments.categories.openLoops":
"Enables inferred open-loop check-ins when the user is waiting on an outcome or unresolved next step. Default: true.",
"commitments.categories.careCheckIns":
'Controls personal care check-ins. Use "gentle" for conservative care follow-ups, true for normal extraction, or false to disable them.',
"commitments.extraction":
"Background extraction controls for the hidden LLM pass that creates inferred commitments without adding content to the conversation transcript.",
"commitments.extraction.enabled":
"Enables hidden background LLM extraction for inferred commitments. Set false to keep stored commitments deliverable while preventing new inferred commitments.",
"commitments.extraction.model":
"Optional provider/model override for hidden commitment extraction runs. Leave unset to use the active agent model.",
"commitments.extraction.debounceMs":
"Milliseconds to wait before draining queued conversation turns into a batched hidden extraction run. Default: 15000.",
"commitments.extraction.batchMaxItems":
"Maximum queued turn extractions sent in one hidden model call. Default: 8.",
"commitments.extraction.confidenceThreshold":
"Minimum accepted confidence from the extractor for routine inferred commitments. Default: 0.72.",
"commitments.extraction.careConfidenceThreshold":
"Minimum accepted confidence from the extractor for personal care check-ins. Default: 0.86.",
"commitments.extraction.timeoutSeconds":
"Maximum runtime in seconds for a hidden extraction pass before it is abandoned. Default: 45.",
"commitments.delivery": "Heartbeat delivery controls for due inferred commitments.",
"commitments.delivery.maxPerHeartbeat":
"Maximum due inferred commitments injected into one heartbeat turn. Default: 3.",
"commitments.delivery.expireAfterHours":
"Number of hours after the due time before a pending inferred commitment expires instead of being delivered. Default: 72.",
"agents.list.*.skills":
"Optional allowlist of skills for this agent. If omitted, the agent inherits agents.defaults.skills when set; otherwise skills stay unrestricted. Set [] for no skills. An explicit list fully replaces inherited defaults instead of merging with them.",
"agents.list[].skills":

View File

@@ -35,6 +35,25 @@ export const FIELD_LABELS: Record<string, string> = {
"update.auto.stableDelayHours": "Auto Update Stable Delay (hours)",
"update.auto.stableJitterHours": "Auto Update Stable Jitter (hours)",
"update.auto.betaCheckIntervalHours": "Auto Update Beta Check Interval (hours)",
commitments: "Commitments",
"commitments.enabled": "Commitments Enabled",
"commitments.store": "Commitments Store Path",
"commitments.categories": "Commitment Categories",
"commitments.categories.eventCheckIns": "Event Check-ins",
"commitments.categories.deadlineCheckIns": "Deadline Check-ins",
"commitments.categories.openLoops": "Open-loop Check-ins",
"commitments.categories.careCheckIns": "Care Check-ins",
"commitments.extraction": "Commitment Extraction",
"commitments.extraction.enabled": "Commitment Extraction Enabled",
"commitments.extraction.model": "Commitment Extraction Model",
"commitments.extraction.debounceMs": "Commitment Extraction Debounce (ms)",
"commitments.extraction.batchMaxItems": "Commitment Extraction Batch Size",
"commitments.extraction.confidenceThreshold": "Commitment Confidence Threshold",
"commitments.extraction.careConfidenceThreshold": "Care Commitment Confidence Threshold",
"commitments.extraction.timeoutSeconds": "Commitment Extraction Timeout (sec)",
"commitments.delivery": "Commitment Delivery",
"commitments.delivery.maxPerHeartbeat": "Commitments per Heartbeat",
"commitments.delivery.expireAfterHours": "Commitment Expiration (hours)",
"diagnostics.enabled": "Diagnostics Enabled",
"diagnostics.flags": "Diagnostics Flags",
"diagnostics.stuckSessionWarnMs": "Stuck Session Warning Threshold (ms)",

View File

@@ -0,0 +1,47 @@
export type CommitmentCategoryConfig = {
/** Enable inferred event check-ins such as "interview tomorrow". Default: true. */
eventCheckIns?: boolean;
/** Enable inferred deadline/progress check-ins. Default: true. */
deadlineCheckIns?: boolean;
/** Enable inferred open-loop check-ins such as "waiting to hear back". Default: true. */
openLoops?: boolean;
/**
* Enable personal care check-ins. "gentle" keeps conservative extraction and delivery wording.
* Default: "gentle".
*/
careCheckIns?: boolean | "gentle";
};
export type CommitmentExtractionConfig = {
/** Enable the background LLM extractor. Default: true. */
enabled?: boolean;
/** Optional model override (provider/model) for extractor runs. Defaults to the agent model. */
model?: string;
/** Debounce before draining queued extraction items. Default: 15000. */
debounceMs?: number;
/** Max extraction items per model call. Default: 8. */
batchMaxItems?: number;
/** Minimum confidence accepted for routine inferred commitments. Default: 0.72. */
confidenceThreshold?: number;
/** Minimum confidence accepted for care check-ins. Default: 0.86. */
careConfidenceThreshold?: number;
/** Extractor run timeout in seconds. Default: 45. */
timeoutSeconds?: number;
};
export type CommitmentDeliveryConfig = {
/** Max due commitments injected into one heartbeat turn. Default: 3. */
maxPerHeartbeat?: number;
/** Pending commitments older than this after latest due time are expired. Default: 72. */
expireAfterHours?: number;
};
export type CommitmentsConfig = {
/** Enable inferred commitment creation and heartbeat delivery. Default: true. */
enabled?: boolean;
/** Optional JSON store path. Defaults to ~/.openclaw/commitments/commitments.json. */
store?: string;
categories?: CommitmentCategoryConfig;
extraction?: CommitmentExtractionConfig;
delivery?: CommitmentDeliveryConfig;
};

View File

@@ -10,6 +10,7 @@ import type { DiagnosticsConfig, LoggingConfig, SessionConfig, WebConfig } from
import type { BrowserConfig } from "./types.browser.js";
import type { ChannelsConfig } from "./types.channels.js";
import type { CliConfig } from "./types.cli.js";
import type { CommitmentsConfig } from "./types.commitments.js";
import type { CrestodianConfig } from "./types.crestodian.js";
import type { CronConfig } from "./types.cron.js";
import type {
@@ -129,6 +130,7 @@ export type OpenClawConfig = {
web?: WebConfig;
channels?: ChannelsConfig;
cron?: CronConfig;
commitments?: CommitmentsConfig;
hooks?: HooksConfig;
discovery?: DiscoveryConfig;
canvasHost?: CanvasHostConfig;

View File

@@ -9,6 +9,7 @@ export * from "./types.base.js";
export * from "./types.browser.js";
export * from "./types.channels.js";
export * from "./types.cli.js";
export * from "./types.commitments.js";
export * from "./types.openclaw.js";
export * from "./types.cron.js";
export * from "./types.discord.js";

View File

@@ -254,6 +254,42 @@ const CrestodianSchema = z
.strict()
.optional();
const CommitmentsSchema = z
.object({
enabled: z.boolean().optional(),
store: z.string().optional(),
categories: z
.object({
eventCheckIns: z.boolean().optional(),
deadlineCheckIns: z.boolean().optional(),
openLoops: z.boolean().optional(),
careCheckIns: z.union([z.boolean(), z.literal("gentle")]).optional(),
})
.strict()
.optional(),
extraction: z
.object({
enabled: z.boolean().optional(),
model: z.string().optional(),
debounceMs: z.number().int().nonnegative().optional(),
batchMaxItems: z.number().int().positive().optional(),
confidenceThreshold: z.number().min(0).max(1).optional(),
careConfidenceThreshold: z.number().min(0).max(1).optional(),
timeoutSeconds: z.number().int().positive().optional(),
})
.strict()
.optional(),
delivery: z
.object({
maxPerHeartbeat: z.number().int().positive().optional(),
expireAfterHours: z.number().int().positive().optional(),
})
.strict()
.optional(),
})
.strict()
.optional();
export const OpenClawSchema = z
.object({
$schema: z.string().optional(),
@@ -648,6 +684,7 @@ export const OpenClawSchema = z
}
})
.optional(),
commitments: CommitmentsSchema,
hooks: z
.object({
enabled: z.boolean().optional(),

View File

@@ -0,0 +1,138 @@
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
import { loadCommitmentStore, saveCommitmentStore } from "../commitments/store.js";
import type { CommitmentRecord } from "../commitments/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { runHeartbeatOnce } from "./heartbeat-runner.js";
import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js";
import { seedSessionStore, withTempHeartbeatSandbox } from "./heartbeat-runner.test-utils.js";
installHeartbeatRunnerTestRuntime();
describe("runHeartbeatOnce commitments", () => {
const nowMs = Date.parse("2026-04-29T17:00:00.000Z");
function buildCommitment(params: {
id: string;
sessionKey: string;
to: string;
}): CommitmentRecord {
return {
id: params.id,
agentId: "main",
sessionKey: params.sessionKey,
channel: "telegram",
accountId: "primary",
to: params.to,
kind: "event_check_in",
sensitivity: "routine",
source: "inferred_user_context",
status: "pending",
reason: "The user said they had an interview yesterday.",
suggestedText: "How did the interview go?",
dedupeKey: "interview:2026-04-28",
confidence: 0.92,
dueWindow: {
earliestMs: nowMs - 60_000,
latestMs: nowMs + 60 * 60_000,
timezone: "America/Los_Angeles",
},
sourceUserText: "I have an interview tomorrow.",
sourceAssistantText: "Good luck, I hope it goes well.",
createdAtMs: nowMs - 24 * 60 * 60_000,
updatedAtMs: nowMs - 24 * 60 * 60_000,
attempts: 0,
};
}
async function setupCommitmentCase(params?: { replyText?: string }) {
return await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
const commitmentStorePath = path.join(tmpDir, "commitments.json");
const sessionKey = "agent:main:telegram:user-155462274";
const cfg: OpenClawConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: {
every: "5m",
target: "none",
},
},
},
channels: { telegram: { allowFrom: ["*"] } },
session: { store: storePath },
commitments: { store: commitmentStorePath },
};
await seedSessionStore(storePath, sessionKey, {
lastChannel: "telegram",
lastProvider: "telegram",
lastTo: "stale-target",
});
await saveCommitmentStore(commitmentStorePath, {
version: 1,
commitments: [buildCommitment({ id: "cm_interview", sessionKey, to: "155462274" })],
});
const sendTelegram = vi.fn().mockResolvedValue({
messageId: "m1",
chatId: "155462274",
});
replySpy.mockImplementation(
async (ctx: { Body?: string; OriginatingChannel?: string; OriginatingTo?: string }) => {
expect(ctx.Body).toContain("Due inferred follow-up commitments");
expect(ctx.Body).toContain("How did the interview go?");
expect(ctx.OriginatingChannel).toBe("telegram");
expect(ctx.OriginatingTo).toBe("155462274");
return { text: params?.replyText ?? "How did the interview go?" };
},
);
const result = await runHeartbeatOnce({
cfg,
agentId: "main",
sessionKey,
deps: {
getReplyFromConfig: replySpy,
telegram: sendTelegram,
getQueueSize: () => 0,
nowMs: () => nowMs,
},
});
return {
result,
sendTelegram,
store: await loadCommitmentStore(commitmentStorePath),
};
});
}
it("delivers due commitments to the original scope even when heartbeat target is none", async () => {
const { result, sendTelegram, store } = await setupCommitmentCase();
expect(result.status).toBe("ran");
expect(sendTelegram).toHaveBeenCalled();
expect(store.commitments[0]).toMatchObject({
id: "cm_interview",
status: "sent",
attempts: 1,
sentAtMs: nowMs,
});
});
it("dismisses a due commitment when the heartbeat model declines to send a check-in", async () => {
const { result, sendTelegram, store } = await setupCommitmentCase({
replyText: HEARTBEAT_TOKEN,
});
expect(result.status).toBe("ran");
expect(sendTelegram).not.toHaveBeenCalled();
expect(store.commitments[0]).toMatchObject({
id: "cm_interview",
status: "dismissed",
attempts: 1,
dismissedAtMs: nowMs,
});
});
});

View File

@@ -34,6 +34,13 @@ import type {
ChannelId,
ChannelPlugin,
} from "../channels/plugins/types.public.js";
import {
listDueCommitmentsForSession,
listDueCommitmentSessionKeys,
markCommitmentsAttempted,
markCommitmentsStatus,
} from "../commitments/store.js";
import type { CommitmentRecord } from "../commitments/types.js";
import { getRuntimeConfig } from "../config/config.js";
import {
canonicalizeMainSessionAlias,
@@ -576,10 +583,68 @@ type HeartbeatReasonFlags = {
type HeartbeatSkipReason = "empty-heartbeat-file";
function truncateCommitmentText(text: string | undefined, maxChars: number): string | undefined {
const trimmed = text?.trim();
if (!trimmed) {
return undefined;
}
return trimmed.length <= maxChars ? trimmed : `${trimmed.slice(0, maxChars - 1)}...`;
}
function buildCommitmentDeliveryKey(commitment: CommitmentRecord): string {
return [
commitment.channel,
commitment.accountId ?? "",
commitment.to ?? "",
commitment.threadId ?? "",
commitment.senderId ?? "",
].join("\u001f");
}
function selectCommitmentDeliveryBatch(commitments: CommitmentRecord[]): CommitmentRecord[] {
const first = commitments.toSorted(
(a, b) => a.dueWindow.earliestMs - b.dueWindow.earliestMs || a.createdAtMs - b.createdAtMs,
)[0];
if (!first) {
return [];
}
const key = buildCommitmentDeliveryKey(first);
return commitments.filter((commitment) => buildCommitmentDeliveryKey(commitment) === key);
}
function buildCommitmentHeartbeatPrompt(commitments: CommitmentRecord[]): string | null {
if (commitments.length === 0) {
return null;
}
const items = commitments.map((commitment) => ({
kind: commitment.kind,
sensitivity: commitment.sensitivity,
source: commitment.source,
reason: commitment.reason,
suggestedText: commitment.suggestedText,
due: {
earliest: new Date(commitment.dueWindow.earliestMs).toISOString(),
latest: new Date(commitment.dueWindow.latestMs).toISOString(),
timezone: commitment.dueWindow.timezone,
},
sourceUserText: truncateCommitmentText(commitment.sourceUserText, 240),
sourceAssistantText: truncateCommitmentText(commitment.sourceAssistantText, 240),
}));
return `Due inferred follow-up commitments are available for this exact agent and channel scope.
These are not exact reminders. They were inferred from prior conversation context and should feel natural, brief, and optional.
If a check-in would be useful now, send at most one concise message in this channel. If none should be sent, reply HEARTBEAT_OK. Do not mention commitments, ledgers, inference, or scheduling machinery.
Commitments:
${JSON.stringify(items, null, 2)}`;
}
type HeartbeatPreflight = HeartbeatReasonFlags & {
session: ReturnType<typeof resolveHeartbeatSession>;
pendingEventEntries: ReturnType<typeof peekSystemEventEntries>;
turnSourceDeliveryContext: ReturnType<typeof resolveSystemEventDeliveryContext>;
dueCommitments: CommitmentRecord[];
hasTaggedCronEvents: boolean;
shouldInspectPendingEvents: boolean;
skipReason?: HeartbeatSkipReason;
@@ -602,6 +667,7 @@ async function resolveHeartbeatPreflight(params: {
heartbeat?: HeartbeatConfig;
forcedSessionKey?: string;
reason?: string;
nowMs?: number;
}): Promise<HeartbeatPreflight> {
const reasonFlags = resolveHeartbeatReasonFlags(params.reason);
const session = resolveHeartbeatSession(
@@ -611,6 +677,14 @@ async function resolveHeartbeatPreflight(params: {
params.forcedSessionKey,
);
const pendingEventEntries = peekSystemEventEntries(session.sessionKey);
const dueCommitments = selectCommitmentDeliveryBatch(
await listDueCommitmentsForSession({
cfg: params.cfg,
agentId: params.agentId,
sessionKey: session.sessionKey,
nowMs: params.nowMs,
}),
);
const turnSourceDeliveryContext = resolveSystemEventDeliveryContext(pendingEventEntries);
const hasTaggedCronEvents = pendingEventEntries.some((event) =>
event.contextKey?.startsWith("cron:"),
@@ -641,12 +715,14 @@ async function resolveHeartbeatPreflight(params: {
reasonFlags.isExecEventReason ||
reasonFlags.isCronEventReason ||
reasonFlags.isWakeReason ||
hasTaggedCronEvents;
hasTaggedCronEvents ||
dueCommitments.length > 0;
const basePreflight = {
...reasonFlags,
session,
pendingEventEntries,
turnSourceDeliveryContext,
dueCommitments,
hasTaggedCronEvents,
shouldInspectPendingEvents,
} satisfies Omit<HeartbeatPreflight, "skipReason">;
@@ -693,6 +769,7 @@ type HeartbeatPromptResolution = {
hasExecCompletion: boolean;
hasRelayableExecCompletion: boolean;
hasCronEvents: boolean;
hasDueCommitments: boolean;
};
function appendHeartbeatWorkspacePathHint(prompt: string, workspaceDir: string): string {
@@ -762,6 +839,8 @@ function resolveHeartbeatRunPrompt(params: {
const hasRelayableExecCompletion =
params.canRelayToUser && execEvents.some((event) => isRelayableExecCompletionEvent(event));
const hasCronEvents = cronEvents.length > 0;
const commitmentPrompt = buildCommitmentHeartbeatPrompt(params.preflight.dueCommitments);
const hasDueCommitments = Boolean(commitmentPrompt);
if (params.preflight.tasks && params.preflight.tasks.length > 0) {
const tasks = params.preflight.tasks;
@@ -787,11 +866,24 @@ After completing all due tasks, reply HEARTBEAT_OK.`;
prompt += `\n\nAdditional context from HEARTBEAT.md:\n${directives}`;
}
}
if (commitmentPrompt) {
prompt += `\n\n${commitmentPrompt}`;
}
return {
prompt,
hasExecCompletion: false,
hasRelayableExecCompletion: false,
hasCronEvents: false,
hasDueCommitments,
};
}
if (commitmentPrompt) {
return {
prompt: commitmentPrompt,
hasExecCompletion: false,
hasRelayableExecCompletion: false,
hasCronEvents: false,
hasDueCommitments,
};
}
return {
@@ -799,6 +891,7 @@ After completing all due tasks, reply HEARTBEAT_OK.`;
hasExecCompletion: false,
hasRelayableExecCompletion: false,
hasCronEvents: false,
hasDueCommitments: false,
};
}
@@ -807,9 +900,17 @@ After completing all due tasks, reply HEARTBEAT_OK.`;
: hasCronEvents
? buildCronEventPrompt(cronEvents, { deliverToUser: params.canRelayToUser })
: resolveHeartbeatPrompt(params.cfg, params.heartbeat);
const prompt = appendHeartbeatWorkspacePathHint(basePrompt, params.workspaceDir);
const prompt = commitmentPrompt
? `${appendHeartbeatWorkspacePathHint(basePrompt, params.workspaceDir)}\n\n${commitmentPrompt}`
: appendHeartbeatWorkspacePathHint(basePrompt, params.workspaceDir);
return { prompt, hasExecCompletion, hasRelayableExecCompletion, hasCronEvents };
return {
prompt,
hasExecCompletion,
hasRelayableExecCompletion,
hasCronEvents,
hasDueCommitments,
};
}
export async function runHeartbeatOnce(opts: {
@@ -874,6 +975,7 @@ export async function runHeartbeatOnce(opts: {
heartbeat,
forcedSessionKey: opts.sessionKey,
reason: opts.reason,
nowMs: startedAt,
});
if (preflight.skipReason) {
emitHeartbeatEvent({
@@ -906,15 +1008,31 @@ export async function runHeartbeatOnce(opts: {
// sending the full conversation history (~100K tokens) to the LLM.
// Delivery routing still uses the main session entry (lastChannel, lastTo).
const useIsolatedSession = heartbeat?.isolatedSession === true;
const firstDueCommitment = preflight.dueCommitments[0];
const commitmentDeliveryContext = firstDueCommitment
? {
channel: firstDueCommitment.channel,
to: firstDueCommitment.to,
accountId: firstDueCommitment.accountId,
threadId: firstDueCommitment.threadId,
}
: undefined;
const heartbeatForDelivery = commitmentDeliveryContext
? { ...heartbeat, target: "last", to: undefined, accountId: undefined }
: heartbeat;
const delivery = resolveHeartbeatDeliveryTarget({
cfg,
entry,
heartbeat,
heartbeat: heartbeatForDelivery,
// Isolated heartbeat runs drain system events from their dedicated
// `:heartbeat` session, not from the base session we peek during preflight.
// Reusing base-session turnSource routing here can pin later isolated runs
// to stale channels/threads because that base-session event context remains queued.
turnSource: useIsolatedSession ? undefined : preflight.turnSourceDeliveryContext,
turnSource: commitmentDeliveryContext
? commitmentDeliveryContext
: useIsolatedSession
? undefined
: preflight.turnSourceDeliveryContext,
});
const heartbeatAccountId = heartbeat?.accountId?.trim();
if (delivery.reason === "unknown-account") {
@@ -947,16 +1065,24 @@ export async function runHeartbeatOnce(opts: {
delivery.channel !== "none" && delivery.to && visibility.showAlerts,
);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const { prompt, hasExecCompletion, hasRelayableExecCompletion, hasCronEvents } =
resolveHeartbeatRunPrompt({
cfg,
heartbeat,
preflight,
canRelayToUser,
workspaceDir,
startedAt,
heartbeatFileContent: preflight.heartbeatFileContent,
});
const {
prompt,
hasExecCompletion,
hasRelayableExecCompletion,
hasCronEvents,
hasDueCommitments,
} = resolveHeartbeatRunPrompt({
cfg,
heartbeat,
preflight,
canRelayToUser,
workspaceDir,
startedAt,
heartbeatFileContent: preflight.heartbeatFileContent,
});
const dueCommitmentIds = hasDueCommitments
? preflight.dueCommitments.map((commitment) => commitment.id)
: [];
// If no tasks are due, skip heartbeat entirely
if (prompt === null) {
@@ -1099,6 +1225,11 @@ export async function runHeartbeatOnce(opts: {
});
return { status: "skipped", reason: "alerts-disabled" };
}
await markCommitmentsAttempted({
cfg,
ids: dueCommitmentIds,
nowMs: startedAt,
});
const heartbeatOkText = responsePrefix ? `${responsePrefix} ${HEARTBEAT_TOKEN}` : HEARTBEAT_TOKEN;
const outboundSession = buildOutboundSessionContext({
@@ -1107,7 +1238,7 @@ export async function runHeartbeatOnce(opts: {
sessionKey,
});
const canAttemptHeartbeatOk = Boolean(
visibility.showOk && delivery.channel !== "none" && delivery.to,
!hasDueCommitments && visibility.showOk && delivery.channel !== "none" && delivery.to,
);
const hasChatDelivery = Boolean(
delivery.channel !== "none" && delivery.to && (visibility.showAlerts || visibility.showOk),
@@ -1207,6 +1338,12 @@ export async function runHeartbeatOnce(opts: {
silent: !okSent,
indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-empty") : undefined,
});
await markCommitmentsStatus({
cfg,
ids: dueCommitmentIds,
status: "dismissed",
nowMs: startedAt,
});
await updateTaskTimestamps();
consumeInspectedSystemEvents();
return { status: "ran", durationMs: Date.now() - startedAt };
@@ -1245,6 +1382,12 @@ export async function runHeartbeatOnce(opts: {
silent: !okSent,
indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-token") : undefined,
});
await markCommitmentsStatus({
cfg,
ids: dueCommitmentIds,
status: "dismissed",
nowMs: startedAt,
});
await updateTaskTimestamps();
consumeInspectedSystemEvents();
return { status: "ran", durationMs: Date.now() - startedAt };
@@ -1282,6 +1425,12 @@ export async function runHeartbeatOnce(opts: {
channel: delivery.channel !== "none" ? delivery.channel : undefined,
accountId: delivery.accountId,
});
await markCommitmentsStatus({
cfg,
ids: dueCommitmentIds,
status: "dismissed",
nowMs: startedAt,
});
await updateTaskTimestamps();
consumeInspectedSystemEvents();
return { status: "ran", durationMs: Date.now() - startedAt };
@@ -1376,6 +1525,12 @@ export async function runHeartbeatOnce(opts: {
],
deps: opts.deps,
});
await markCommitmentsStatus({
cfg,
ids: dueCommitmentIds,
status: shouldSkipMain ? "dismissed" : "sent",
nowMs: startedAt,
});
// Record last delivered heartbeat payload for dedupe.
if (!shouldSkipMain && normalized.text.trim()) {
@@ -1664,6 +1819,47 @@ export function startHeartbeatRunner(opts: {
if (res.status === "ran") {
ran = true;
}
const defaultSessionKey = resolveHeartbeatSession(
state.cfg,
agent.agentId,
agent.heartbeat,
).sessionKey;
const dueSessionKeys = await listDueCommitmentSessionKeys({
cfg: state.cfg,
agentId: agent.agentId,
nowMs: now,
limit: 10,
});
for (const dueSessionKey of dueSessionKeys) {
if (dueSessionKey === defaultSessionKey) {
continue;
}
let commitmentRes: HeartbeatRunResult;
try {
commitmentRes = await runOnce({
cfg: state.cfg,
agentId: agent.agentId,
heartbeat: agent.heartbeat,
reason: "commitment",
sessionKey: dueSessionKey,
deps: { runtime: state.runtime },
});
} catch (err) {
const errMsg = formatErrorMessage(err);
log.error(`heartbeat runner: commitment runOnce threw unexpectedly: ${errMsg}`, {
error: errMsg,
});
continue;
}
if (commitmentRes.status === "skipped" && commitmentRes.reason === "requests-in-flight") {
requestsInFlight = true;
return commitmentRes;
}
if (commitmentRes.status === "ran") {
ran = true;
}
}
}
if (ran) {