Agents: simplify inferred commitment config (#74189)

This commit is contained in:
Vignesh Natarajan
2026-04-29 14:13:11 -07:00
committed by Vignesh
parent 11771ec172
commit aecde2b3ac
18 changed files with 234 additions and 534 deletions

View File

@@ -2229,7 +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.
- Agents/commitments: add opt-in inferred follow-up commitments with hidden batched extraction, per-agent/per-channel scoping, heartbeat delivery, CLI management, a simple `commitments.enabled`/`commitments.maxPerDay` config, and heartbeat-interval due-time clamping so magical check-ins do not echo immediately. (#74189) Thanks @vignesh07.
### Fixes

View File

@@ -1,4 +1,4 @@
bc53a2242782d03e6392671c154481fb4cd8dc5b35cc41a69b056d3ead28be97 config-baseline.json
861a230a4e66cb8986270a85f63e857077506a3bc75ec3754dfebd17a3ea9f0c config-baseline.core.json
0316c2ceef9a2da29a8860ba8c8e5218249bc561c5b44202ac78faf16b56029f config-baseline.json
d6f6410e05b623412f086ba59d8caea82e691e2f1367090ec2ddabfc189381ed config-baseline.core.json
9f5fad66a49fa618d64a963470aa69fed9fe4b4639cc4321f9ec04bfb2f8aa50 config-baseline.channel.json
c4231c2194206547af8ad94342dc00aadb734f43cb49cc79d4c46bdbb80c3f95 config-baseline.plugin.json

View File

@@ -9,7 +9,7 @@ const mocks = vi.hoisted(() => ({
resolveCommitmentStorePath: vi.fn(() => "/tmp/openclaw-commitments.json"),
getRuntimeConfig: vi.fn(() => ({
commitments: {
store: "/tmp/openclaw-commitments.json",
enabled: true,
},
})),
}));

View File

@@ -107,7 +107,7 @@ export async function commitmentsListCommand(
count: commitments.length,
status: status ?? (opts.all ? null : "pending"),
agentId: normalizeOptionalString(opts.agent) ?? null,
store: resolveCommitmentStorePath(cfg.commitments?.store),
store: resolveCommitmentStorePath(),
commitments,
},
null,
@@ -118,7 +118,7 @@ export async function commitmentsListCommand(
}
runtime.log(info(`Commitments: ${commitments.length}`));
runtime.log(info(`Store: ${resolveCommitmentStorePath(cfg.commitments?.store)}`));
runtime.log(info(`Store: ${resolveCommitmentStorePath()}`));
if (status) {
runtime.log(info(`Status filter: ${status}`));
}

View File

@@ -1621,18 +1621,7 @@ describe("doctor config flow", () => {
config: {
commitments: {
enabled: true,
categories: {
careCheckIns: "gentle",
eventCheckIns: false,
},
extraction: {
enabled: true,
batchMaxItems: 4,
},
delivery: {
maxPerHeartbeat: 2,
expireAfterHours: 48,
},
maxPerDay: 2,
},
},
run: loadAndMaybeMigrateDoctorConfig,
@@ -1640,18 +1629,7 @@ describe("doctor config flow", () => {
expect(result.cfg.commitments).toEqual({
enabled: true,
categories: {
careCheckIns: "gentle",
eventCheckIns: false,
},
extraction: {
enabled: true,
batchMaxItems: 4,
},
delivery: {
maxPerHeartbeat: 2,
expireAfterHours: 48,
},
maxPerDay: 2,
});
});

View File

@@ -1,6 +1,5 @@
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;
@@ -9,29 +8,18 @@ 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 const DEFAULT_COMMITMENT_MAX_PER_DAY = 3;
export type ResolvedCommitmentsConfig = {
enabled: boolean;
store?: string;
categories: {
eventCheckIns: boolean;
deadlineCheckIns: boolean;
openLoops: boolean;
careCheckIns: false | "gentle" | true;
};
maxPerDay: number;
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 {
@@ -40,63 +28,17 @@ function positiveInt(value: unknown, fallback: number): number {
: 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),
},
enabled: raw?.enabled === true,
maxPerDay: positiveInt(raw?.maxPerDay, DEFAULT_COMMITMENT_MAX_PER_DAY),
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,
),
debounceMs: DEFAULT_COMMITMENT_EXTRACTION_DEBOUNCE_MS,
batchMaxItems: DEFAULT_COMMITMENT_BATCH_MAX_ITEMS,
confidenceThreshold: DEFAULT_COMMITMENT_CONFIDENCE_THRESHOLD,
careConfidenceThreshold: DEFAULT_COMMITMENT_CARE_CONFIDENCE_THRESHOLD,
timeoutSeconds: DEFAULT_COMMITMENT_EXTRACTION_TIMEOUT_SECONDS,
},
};
}

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
buildCommitmentExtractionPrompt,
@@ -17,6 +17,7 @@ describe("commitment extraction", () => {
const nowMs = Date.parse("2026-04-29T16:00:00.000Z");
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(tmpDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
tmpDirs.length = 0;
});
@@ -24,9 +25,10 @@ describe("commitment extraction", () => {
async function createConfig(): Promise<OpenClawConfig> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-commitments-"));
tmpDirs.push(tmpDir);
vi.stubEnv("OPENCLAW_STATE_DIR", tmpDir);
return {
commitments: {
store: path.join(tmpDir, "commitments.json"),
enabled: true,
},
};
}
@@ -103,26 +105,14 @@ describe("commitment extraction", () => {
});
it("rejects disabled, low-confidence, and non-future candidates", () => {
const cfg: OpenClawConfig = {
commitments: {
categories: { careCheckIns: "gentle" },
extraction: { confidenceThreshold: 0.8, careConfidenceThreshold: 0.9 },
},
};
const cfg: OpenClawConfig = { commitments: { enabled: true } };
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: "low-confidence", confidence: 0.5 }),
candidate({
dedupeKey: "past",
dueWindow: { earliest: "2026-04-29T15:00:00.000Z" },
@@ -186,7 +176,7 @@ describe("commitment extraction", () => {
},
nowMs: nowMs + 1_000,
});
const store = await loadCommitmentStore(cfg.commitments?.store);
const store = await loadCommitmentStore();
expect(created).toHaveLength(1);
expect(deduped).toHaveLength(0);

View File

@@ -3,11 +3,7 @@ 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 { listPendingCommitmentsForScope, upsertInferredCommitments } from "./store.js";
import type {
CommitmentCandidate,
CommitmentExtractionBatchResult,
@@ -198,8 +194,6 @@ 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(),
@@ -212,7 +206,7 @@ export function buildCommitmentExtractionPrompt(params: {
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}
Use these categories: event_check_in, deadline_check, care_check_in, open_loop.
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.
@@ -279,7 +273,7 @@ export function validateCommitmentCandidates(params: {
}> = [];
for (const candidate of params.result.candidates) {
const item = itemsById.get(candidate.itemId);
if (!item || !isCommitmentKindEnabled(candidate.kind, resolved.categories)) {
if (!item) {
continue;
}
const threshold =

View File

@@ -18,6 +18,7 @@ describe("commitment extraction runtime", () => {
afterEach(async () => {
resetCommitmentExtractionRuntimeForTests();
vi.unstubAllEnvs();
await Promise.all(tmpDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
tmpDirs.length = 0;
});
@@ -25,13 +26,10 @@ describe("commitment extraction runtime", () => {
async function createConfig(): Promise<OpenClawConfig> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-commitment-runtime-"));
tmpDirs.push(tmpDir);
vi.stubEnv("OPENCLAW_STATE_DIR", tmpDir);
return {
commitments: {
store: path.join(tmpDir, "commitments.json"),
extraction: {
debounceMs: 1_000,
batchMaxItems: 8,
},
enabled: true,
},
};
}
@@ -52,6 +50,29 @@ describe("commitment extraction runtime", () => {
).toBe(false);
});
it("keeps hidden extraction opt-in by default", () => {
const cfg: OpenClawConfig = {
commitments: {},
};
configureCommitmentExtractionRuntime({
forceInTests: true,
setTimer: () => ({ unref() {} }) as ReturnType<typeof setTimeout>,
clearTimer: () => undefined,
});
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[] }) => ({
@@ -106,7 +127,7 @@ describe("commitment extraction runtime", () => {
).toBe(true);
await expect(drainCommitmentExtractionQueue()).resolves.toBe(2);
const store = await loadCommitmentStore(cfg.commitments?.store);
const store = await loadCommitmentStore();
expect(extractBatch).toHaveBeenCalledTimes(1);
const batchItems = extractBatch.mock.calls[0]?.[0].items;

View File

@@ -1,11 +1,6 @@
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";
@@ -100,7 +95,6 @@ export function enqueueCommitmentExtraction(input: CommitmentExtractionEnqueueIn
const resolved = resolveCommitmentsConfig(input.cfg);
if (
!resolved.enabled ||
!resolved.extraction.enabled ||
shouldDisableBackgroundExtractionForTests() ||
!isUsefulText(input.userText) ||
!isUsefulText(input.assistantText) ||
@@ -170,18 +164,6 @@ async function defaultExtractBatch(params: {
}
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}`,
@@ -192,8 +174,6 @@ async function defaultExtractBatch(params: {
config: cfg,
prompt: buildCommitmentExtractionPrompt({ cfg, items: params.items }),
disableTools: true,
provider: modelRef?.provider,
model: modelRef?.model,
thinkLevel: "off",
verboseLevel: "off",
reasoningLevel: "off",

View File

@@ -0,0 +1,92 @@
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 { listDueCommitmentsForSession, loadCommitmentStore, saveCommitmentStore } from "./store.js";
import type { CommitmentRecord } from "./types.js";
describe("commitment store delivery selection", () => {
const tmpDirs: string[] = [];
const nowMs = Date.parse("2026-04-29T17:00:00.000Z");
const sessionKey = "agent:main:telegram:user-155462274";
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(tmpDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
tmpDirs.length = 0;
});
async function useTempStateDir(): Promise<void> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-commitments-store-"));
tmpDirs.push(tmpDir);
vi.stubEnv("OPENCLAW_STATE_DIR", tmpDir);
}
function commitment(overrides?: Partial<CommitmentRecord>): CommitmentRecord {
return {
id: "cm_interview",
agentId: "main",
sessionKey,
channel: "telegram",
to: "155462274",
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.",
createdAtMs: nowMs - 24 * 60 * 60_000,
updatedAtMs: nowMs - 24 * 60 * 60_000,
attempts: 0,
...overrides,
};
}
it("does not surface due commitments unless inferred commitments are enabled", async () => {
await useTempStateDir();
await saveCommitmentStore(undefined, {
version: 1,
commitments: [commitment()],
});
await expect(
listDueCommitmentsForSession({
cfg: {},
agentId: "main",
sessionKey,
nowMs,
}),
).resolves.toEqual([]);
});
it("limits delivered commitments per agent session in a rolling day", async () => {
await useTempStateDir();
await saveCommitmentStore(undefined, {
version: 1,
commitments: [
commitment({ id: "cm_sent", status: "sent", sentAtMs: nowMs - 60_000 }),
commitment({ id: "cm_pending", dedupeKey: "interview:followup" }),
],
});
await expect(
listDueCommitmentsForSession({
cfg: { commitments: { enabled: true, maxPerDay: 1 } },
agentId: "main",
sessionKey,
nowMs,
}),
).resolves.toEqual([]);
const store = await loadCommitmentStore();
expect(store.commitments).toHaveLength(2);
});
});

View File

@@ -4,11 +4,14 @@ 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 {
DEFAULT_COMMITMENT_EXPIRE_AFTER_HOURS,
DEFAULT_COMMITMENT_MAX_PER_HEARTBEAT,
resolveCommitmentsConfig,
} from "./config.js";
import type {
CommitmentCandidate,
CommitmentExtractionItem,
CommitmentKind,
CommitmentRecord,
CommitmentScope,
CommitmentStatus,
@@ -16,6 +19,7 @@ import type {
} from "./types.js";
const STORE_VERSION = 1 as const;
const ROLLING_DAY_MS = 24 * 60 * 60 * 1000;
function defaultCommitmentStorePath(): string {
return path.join(resolveStateDir(), "commitments", "commitments.json");
@@ -192,8 +196,7 @@ export async function listPendingCommitmentsForScope(params: {
nowMs?: number;
limit?: number;
}): Promise<CommitmentRecord[]> {
const resolved = resolveCommitmentsConfig(params.cfg);
const store = await loadCommitmentStore(resolved.store);
const store = await loadCommitmentStore();
const scopeKey = buildCommitmentScopeKey(params.scope);
const nowMs = params.nowMs ?? Date.now();
const limit = params.limit ?? 20;
@@ -224,8 +227,7 @@ export async function upsertInferredCommitments(params: {
if (params.candidates.length === 0) {
return [];
}
const resolved = resolveCommitmentsConfig(params.cfg);
const store = await loadCommitmentStore(resolved.store);
const store = await loadCommitmentStore();
const nowMs = params.nowMs ?? Date.now();
const created: CommitmentRecord[] = [];
const scopeKey = buildCommitmentScopeKey(params.item);
@@ -265,10 +267,26 @@ export async function upsertInferredCommitments(params: {
store.commitments.push(record);
created.push(record);
}
await saveCommitmentStore(resolved.store, store);
await saveCommitmentStore(undefined, store);
return created;
}
function countSentCommitmentsForSession(params: {
store: CommitmentStoreFile;
agentId: string;
sessionKey: string;
nowMs: number;
}): number {
const sinceMs = params.nowMs - ROLLING_DAY_MS;
return params.store.commitments.filter(
(commitment) =>
commitment.agentId === params.agentId &&
commitment.sessionKey === params.sessionKey &&
commitment.status === "sent" &&
(commitment.sentAtMs ?? 0) >= sinceMs,
).length;
}
export async function listDueCommitmentsForSession(params: {
cfg?: OpenClawConfig;
agentId: string;
@@ -280,10 +298,25 @@ export async function listDueCommitmentsForSession(params: {
if (!resolved.enabled) {
return [];
}
const store = await loadCommitmentStore(resolved.store);
const store = await loadCommitmentStore();
const nowMs = params.nowMs ?? Date.now();
const limit = params.limit ?? resolved.delivery.maxPerHeartbeat;
const expireAfterMs = resolved.delivery.expireAfterHours * 60 * 60 * 1000;
const remainingToday =
resolved.maxPerDay -
countSentCommitmentsForSession({
store,
agentId: params.agentId,
sessionKey: params.sessionKey,
nowMs,
});
if (remainingToday <= 0) {
return [];
}
const limit = Math.min(
params.limit ?? DEFAULT_COMMITMENT_MAX_PER_HEARTBEAT,
remainingToday,
DEFAULT_COMMITMENT_MAX_PER_HEARTBEAT,
);
const expireAfterMs = DEFAULT_COMMITMENT_EXPIRE_AFTER_HOURS * 60 * 60 * 1000;
return store.commitments
.filter(
(commitment) =>
@@ -310,9 +343,9 @@ export async function listDueCommitmentSessionKeys(params: {
if (!resolved.enabled) {
return [];
}
const store = await loadCommitmentStore(resolved.store);
const store = await loadCommitmentStore();
const nowMs = params.nowMs ?? Date.now();
const expireAfterMs = resolved.delivery.expireAfterHours * 60 * 60 * 1000;
const expireAfterMs = DEFAULT_COMMITMENT_EXPIRE_AFTER_HOURS * 60 * 60 * 1000;
const keys = new Set<string>();
for (const commitment of store.commitments) {
if (
@@ -320,7 +353,13 @@ export async function listDueCommitmentSessionKeys(params: {
isActiveStatus(commitment.status) &&
commitment.dueWindow.earliestMs <= nowMs &&
commitment.dueWindow.latestMs + expireAfterMs >= nowMs &&
(commitment.status !== "snoozed" || (commitment.snoozedUntilMs ?? 0) <= nowMs)
(commitment.status !== "snoozed" || (commitment.snoozedUntilMs ?? 0) <= nowMs) &&
countSentCommitmentsForSession({
store,
agentId: params.agentId,
sessionKey: commitment.sessionKey,
nowMs,
}) < resolved.maxPerDay
) {
keys.add(commitment.sessionKey);
}
@@ -339,10 +378,9 @@ export async function markCommitmentsAttempted(params: {
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);
const store = await loadCommitmentStore();
let changed = false;
store.commitments = store.commitments.map((commitment) => {
if (!idSet.has(commitment.id)) {
@@ -357,7 +395,7 @@ export async function markCommitmentsAttempted(params: {
};
});
if (changed) {
await saveCommitmentStore(resolved.store, store);
await saveCommitmentStore(undefined, store);
}
}
@@ -370,10 +408,9 @@ export async function markCommitmentsStatus(params: {
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);
const store = await loadCommitmentStore();
let changed = false;
store.commitments = store.commitments.map((commitment) => {
if (!idSet.has(commitment.id) || !isActiveStatus(commitment.status)) {
@@ -390,7 +427,7 @@ export async function markCommitmentsStatus(params: {
};
});
if (changed) {
await saveCommitmentStore(resolved.store, store);
await saveCommitmentStore(undefined, store);
}
}
@@ -399,8 +436,7 @@ export async function listCommitments(params?: {
status?: CommitmentStatus;
agentId?: string;
}): Promise<CommitmentRecord[]> {
const resolved = resolveCommitmentsConfig(params?.cfg);
const store = await loadCommitmentStore(resolved.store);
const store = await loadCommitmentStore();
return store.commitments
.filter(
(commitment) =>
@@ -411,19 +447,3 @@ export async function listCommitments(params?: {
(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;
}
}

View File

@@ -21073,145 +21073,21 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
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.",
"Enable hidden LLM extraction, storage, and heartbeat delivery for inferred follow-up commitments. Default: false.",
},
store: {
type: "string",
title: "Commitments Store Path",
maxPerDay: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
title: "Commitments per Day",
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.",
"Maximum inferred follow-up commitments delivered per agent session in a rolling day. Default: 3.",
},
},
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.",
"Inferred follow-up commitment controls for automatically detecting check-ins from conversation turns and delivering them through heartbeat runs.",
},
hooks: {
type: "object",
@@ -24448,99 +24324,19 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
},
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.",
help: "Inferred follow-up commitment controls for automatically detecting check-ins from conversation turns and delivering them through heartbeat runs.",
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.",
help: "Enable hidden LLM extraction, storage, and heartbeat delivery for inferred follow-up commitments. Default: false.",
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.",
"commitments.maxPerDay": {
label: "Commitments per Day",
help: "Maximum inferred follow-up commitments delivered per agent session in a rolling day. Default: 3.",
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

@@ -191,42 +191,11 @@ export const FIELD_HELP: Record<string, string> = {
"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.",
"Inferred follow-up commitment controls for automatically detecting check-ins from conversation turns and delivering them through heartbeat runs.",
"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.",
"Enable hidden LLM extraction, storage, and heartbeat delivery for inferred follow-up commitments. Default: false.",
"commitments.maxPerDay":
"Maximum inferred follow-up commitments delivered per agent session in a rolling day. Default: 3.",
"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

@@ -37,23 +37,7 @@ export const FIELD_LABELS: Record<string, string> = {
"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)",
"commitments.maxPerDay": "Commitments per Day",
"diagnostics.enabled": "Diagnostics Enabled",
"diagnostics.flags": "Diagnostics Flags",
"diagnostics.stuckSessionWarnMs": "Stuck Session Warning Threshold (ms)",

View File

@@ -1,47 +1,6 @@
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. */
/** Enable inferred follow-up extraction, storage, and heartbeat delivery. Default: false. */
enabled?: boolean;
/** Optional JSON store path. Defaults to ~/.openclaw/commitments/commitments.json. */
store?: string;
categories?: CommitmentCategoryConfig;
extraction?: CommitmentExtractionConfig;
delivery?: CommitmentDeliveryConfig;
/** Maximum inferred follow-up commitments delivered per agent session in a rolling day. Default: 3. */
maxPerDay?: number;
};

View File

@@ -257,35 +257,7 @@ const CrestodianSchema = z
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(),
maxPerDay: z.number().int().positive().optional(),
})
.strict()
.optional();

View File

@@ -1,5 +1,4 @@
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { afterEach, 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";
@@ -13,6 +12,10 @@ installHeartbeatRunnerTestRuntime();
describe("runHeartbeatOnce commitments", () => {
const nowMs = Date.parse("2026-04-29T17:00:00.000Z");
afterEach(() => {
vi.unstubAllEnvs();
});
function buildCommitment(params: {
id: string;
sessionKey: string;
@@ -48,7 +51,7 @@ describe("runHeartbeatOnce commitments", () => {
async function setupCommitmentCase(params?: { replyText?: string }) {
return await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
const commitmentStorePath = path.join(tmpDir, "commitments.json");
vi.stubEnv("OPENCLAW_STATE_DIR", tmpDir);
const sessionKey = "agent:main:telegram:user-155462274";
const cfg: OpenClawConfig = {
agents: {
@@ -62,14 +65,14 @@ describe("runHeartbeatOnce commitments", () => {
},
channels: { telegram: { allowFrom: ["*"] } },
session: { store: storePath },
commitments: { store: commitmentStorePath },
commitments: { enabled: true },
};
await seedSessionStore(storePath, sessionKey, {
lastChannel: "telegram",
lastProvider: "telegram",
lastTo: "stale-target",
});
await saveCommitmentStore(commitmentStorePath, {
await saveCommitmentStore(undefined, {
version: 1,
commitments: [buildCommitment({ id: "cm_interview", sessionKey, to: "155462274" })],
});
@@ -103,7 +106,7 @@ describe("runHeartbeatOnce commitments", () => {
return {
result,
sendTelegram,
store: await loadCommitmentStore(commitmentStorePath),
store: await loadCommitmentStore(),
};
});
}