mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
Agents: simplify inferred commitment config (#74189)
This commit is contained in:
committed by
Vignesh
parent
11771ec172
commit
aecde2b3ac
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -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}`));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
92
src/commitments/store.test.ts
Normal file
92
src/commitments/store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user