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

@@ -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;
}
}