mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:50:46 +00:00
Agents: add inferred commitments
This commit is contained in:
committed by
Vignesh
parent
95a1356278
commit
8e4035d09a
@@ -2229,6 +2229,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Memory/active-memory: default QMD recall to search and surface better search-path telemetry so memory-backed recall works more predictably out of the box. (#65068) Thanks @Takhoffman.
|
||||
- Docs/providers: expand bundled provider docs with richer capability, env-var, and setup guidance across provider pages.
|
||||
- Docs/memory-wiki: add the recommended QMD + bridge-mode hybrid recipe plus zero-artifact troubleshooting guidance for `memory-wiki` bridge setups. (#63165) Thanks @sercada and @vincentkoc.
|
||||
- Agents/commitments: add default-on inferred follow-up commitments with hidden batched extraction, per-agent/per-channel scoping, heartbeat delivery, CLI management, and heartbeat-interval due-time clamping so magical check-ins do not echo immediately. (#74189) Thanks @vignesh07.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
b6640810820e0f54631e8006fa35798f84139b162ee472d150994571b730226a config-baseline.json
|
||||
d63d3aa51c0c38a315cadbff01715844b73ecc35909b6bbb6cd318af59f3d2cc config-baseline.core.json
|
||||
bc53a2242782d03e6392671c154481fb4cd8dc5b35cc41a69b056d3ead28be97 config-baseline.json
|
||||
861a230a4e66cb8986270a85f63e857077506a3bc75ec3754dfebd17a3ea9f0c config-baseline.core.json
|
||||
9f5fad66a49fa618d64a963470aa69fed9fe4b4639cc4321f9ec04bfb2f8aa50 config-baseline.channel.json
|
||||
c4231c2194206547af8ad94342dc00aadb734f43cb49cc79d4c46bdbb80c3f95 config-baseline.plugin.json
|
||||
|
||||
@@ -6,6 +6,8 @@ import { resolveModelAuthMode } from "../../agents/model-auth.js";
|
||||
import { isCliProvider } from "../../agents/model-selection.js";
|
||||
import { queueEmbeddedPiMessage } from "../../agents/pi-embedded-runner/runs.js";
|
||||
import { deriveContextPromptTokens, hasNonzeroUsage, normalizeUsage } from "../../agents/usage.js";
|
||||
import { enqueueCommitmentExtraction } from "../../commitments/runtime.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveSessionPluginStatusLines,
|
||||
@@ -792,6 +794,71 @@ function buildInlineRawTracePayload(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function joinCommitmentAssistantText(payloads: ReplyPayload[]): string {
|
||||
return payloads
|
||||
.filter((payload) => !payload.isError && !payload.isReasoning && !payload.isCompactionNotice)
|
||||
.map((payload) => payload.text?.trim())
|
||||
.filter((text): text is string => Boolean(text))
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function enqueueCommitmentExtractionForTurn(params: {
|
||||
cfg: OpenClawConfig;
|
||||
commandBody: string;
|
||||
isHeartbeat: boolean;
|
||||
followupRun: FollowupRun;
|
||||
sessionCtx: TemplateContext;
|
||||
sessionKey?: string;
|
||||
replyToChannel?: string;
|
||||
payloads: ReplyPayload[];
|
||||
runId: string;
|
||||
}): void {
|
||||
if (params.isHeartbeat) {
|
||||
return;
|
||||
}
|
||||
const userText =
|
||||
params.commandBody.trim() ||
|
||||
params.sessionCtx.BodyStripped?.trim() ||
|
||||
params.sessionCtx.BodyForCommands?.trim() ||
|
||||
params.sessionCtx.CommandBody?.trim() ||
|
||||
params.sessionCtx.RawBody?.trim() ||
|
||||
params.sessionCtx.Body?.trim() ||
|
||||
"";
|
||||
const assistantText = joinCommitmentAssistantText(params.payloads);
|
||||
const sessionKey = params.sessionKey ?? params.followupRun.run.sessionKey;
|
||||
const channel =
|
||||
params.replyToChannel ??
|
||||
params.followupRun.run.messageProvider ??
|
||||
params.sessionCtx.Surface ??
|
||||
params.sessionCtx.Provider;
|
||||
if (!userText || !assistantText || !sessionKey || !channel) {
|
||||
return;
|
||||
}
|
||||
const to = resolveOriginMessageTo({
|
||||
originatingTo: params.sessionCtx.OriginatingTo,
|
||||
to: params.sessionCtx.To,
|
||||
});
|
||||
enqueueCommitmentExtraction({
|
||||
cfg: params.cfg,
|
||||
agentId: params.followupRun.run.agentId,
|
||||
sessionKey,
|
||||
channel,
|
||||
...(params.sessionCtx.AccountId ? { accountId: params.sessionCtx.AccountId } : {}),
|
||||
...(to ? { to } : {}),
|
||||
...(params.sessionCtx.MessageThreadId !== undefined
|
||||
? { threadId: String(params.sessionCtx.MessageThreadId) }
|
||||
: {}),
|
||||
...(params.followupRun.run.senderId ? { senderId: params.followupRun.run.senderId } : {}),
|
||||
userText,
|
||||
assistantText,
|
||||
...(params.sessionCtx.MessageSidFull || params.sessionCtx.MessageSid
|
||||
? { sourceMessageId: params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid }
|
||||
: {}),
|
||||
sourceRunId: params.runId,
|
||||
});
|
||||
}
|
||||
|
||||
function refreshSessionEntryFromStore(params: {
|
||||
storePath?: string;
|
||||
sessionKey?: string;
|
||||
@@ -1378,6 +1445,18 @@ export async function runReplyAgent(params: {
|
||||
? appendUnscheduledReminderNote(replyPayloads)
|
||||
: replyPayloads;
|
||||
|
||||
enqueueCommitmentExtractionForTurn({
|
||||
cfg,
|
||||
commandBody,
|
||||
isHeartbeat,
|
||||
followupRun,
|
||||
sessionCtx,
|
||||
sessionKey,
|
||||
replyToChannel,
|
||||
payloads: replyPayloads,
|
||||
runId,
|
||||
});
|
||||
|
||||
await signalTypingIfNeeded(guardedReplyPayloads, typingSignals);
|
||||
|
||||
if (isDiagnosticsEnabled(cfg) && hasNonzeroUsage(usage)) {
|
||||
|
||||
@@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({
|
||||
sessionsCommand: vi.fn(),
|
||||
sessionsCleanupCommand: vi.fn(),
|
||||
exportTrajectoryCommand: vi.fn(),
|
||||
commitmentsListCommand: vi.fn(),
|
||||
commitmentsDismissCommand: vi.fn(),
|
||||
tasksListCommand: vi.fn(),
|
||||
tasksAuditCommand: vi.fn(),
|
||||
tasksMaintenanceCommand: vi.fn(),
|
||||
@@ -30,6 +32,8 @@ const healthCommand = mocks.healthCommand;
|
||||
const sessionsCommand = mocks.sessionsCommand;
|
||||
const sessionsCleanupCommand = mocks.sessionsCleanupCommand;
|
||||
const exportTrajectoryCommand = mocks.exportTrajectoryCommand;
|
||||
const commitmentsListCommand = mocks.commitmentsListCommand;
|
||||
const commitmentsDismissCommand = mocks.commitmentsDismissCommand;
|
||||
const tasksListCommand = mocks.tasksListCommand;
|
||||
const tasksAuditCommand = mocks.tasksAuditCommand;
|
||||
const tasksMaintenanceCommand = mocks.tasksMaintenanceCommand;
|
||||
@@ -62,6 +66,11 @@ vi.mock("../../commands/export-trajectory.js", () => ({
|
||||
exportTrajectoryCommand: mocks.exportTrajectoryCommand,
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/commitments.js", () => ({
|
||||
commitmentsListCommand: mocks.commitmentsListCommand,
|
||||
commitmentsDismissCommand: mocks.commitmentsDismissCommand,
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/tasks.js", () => ({
|
||||
tasksListCommand: mocks.tasksListCommand,
|
||||
tasksAuditCommand: mocks.tasksAuditCommand,
|
||||
@@ -100,6 +109,8 @@ describe("registerStatusHealthSessionsCommands", () => {
|
||||
sessionsCommand.mockResolvedValue(undefined);
|
||||
sessionsCleanupCommand.mockResolvedValue(undefined);
|
||||
exportTrajectoryCommand.mockResolvedValue(undefined);
|
||||
commitmentsListCommand.mockResolvedValue(undefined);
|
||||
commitmentsDismissCommand.mockResolvedValue(undefined);
|
||||
tasksListCommand.mockResolvedValue(undefined);
|
||||
tasksAuditCommand.mockResolvedValue(undefined);
|
||||
tasksMaintenanceCommand.mockResolvedValue(undefined);
|
||||
@@ -406,6 +417,31 @@ describe("registerStatusHealthSessionsCommands", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("runs commitments list with filters", async () => {
|
||||
await runCli(["commitments", "--json", "--agent", "work", "--status", "snoozed"]);
|
||||
|
||||
expect(commitmentsListCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
json: true,
|
||||
agent: "work",
|
||||
status: "snoozed",
|
||||
all: false,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("runs commitments dismiss with id forwarding", async () => {
|
||||
await runCli(["commitments", "dismiss", "cm_1", "cm_2"]);
|
||||
|
||||
expect(commitmentsDismissCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ids: ["cm_1", "cm_2"],
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not register the legacy top-level flows command", () => {
|
||||
const program = new Command();
|
||||
registerStatusHealthSessionsCommands(program);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Command } from "commander";
|
||||
import { commitmentsDismissCommand, commitmentsListCommand } from "../../commands/commitments.js";
|
||||
import { exportTrajectoryCommand } from "../../commands/export-trajectory.js";
|
||||
import { flowsCancelCommand, flowsListCommand, flowsShowCommand } from "../../commands/flows.js";
|
||||
import { healthCommand } from "../../commands/health.js";
|
||||
@@ -258,6 +259,79 @@ export function registerStatusHealthSessionsCommands(program: Command) {
|
||||
});
|
||||
});
|
||||
|
||||
const commitmentsCmd = program
|
||||
.command("commitments")
|
||||
.description("List and manage inferred follow-up commitments")
|
||||
.option("--json", "Output JSON instead of text", false)
|
||||
.option("--agent <id>", "Agent id to inspect")
|
||||
.option("--status <status>", "Filter by status (pending, sent, dismissed, snoozed, expired)")
|
||||
.option("--all", "Show all statuses", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
|
||||
["openclaw commitments", "List pending inferred follow-ups."],
|
||||
["openclaw commitments --all", "List all inferred follow-ups."],
|
||||
["openclaw commitments --agent work", "List one agent's inferred follow-ups."],
|
||||
["openclaw commitments dismiss cm_abc123", "Dismiss a follow-up."],
|
||||
])}`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
await runCommandWithRuntime(defaultRuntime, async () => {
|
||||
await commitmentsListCommand(
|
||||
{
|
||||
json: Boolean(opts.json),
|
||||
agent: opts.agent as string | undefined,
|
||||
status: opts.status as string | undefined,
|
||||
all: Boolean(opts.all),
|
||||
},
|
||||
defaultRuntime,
|
||||
);
|
||||
});
|
||||
});
|
||||
commitmentsCmd.enablePositionalOptions();
|
||||
|
||||
commitmentsCmd
|
||||
.command("list")
|
||||
.description("List inferred follow-up commitments")
|
||||
.option("--json", "Output JSON instead of text", false)
|
||||
.option("--agent <id>", "Agent id to inspect")
|
||||
.option("--status <status>", "Filter by status (pending, sent, dismissed, snoozed, expired)")
|
||||
.option("--all", "Show all statuses", false)
|
||||
.action(async (opts, command) => {
|
||||
const parentOpts = command.parent?.opts() as
|
||||
| { json?: boolean; agent?: string; status?: string; all?: boolean }
|
||||
| undefined;
|
||||
await runCommandWithRuntime(defaultRuntime, async () => {
|
||||
await commitmentsListCommand(
|
||||
{
|
||||
json: Boolean(opts.json || parentOpts?.json),
|
||||
agent: (opts.agent as string | undefined) ?? parentOpts?.agent,
|
||||
status: (opts.status as string | undefined) ?? parentOpts?.status,
|
||||
all: Boolean(opts.all || parentOpts?.all),
|
||||
},
|
||||
defaultRuntime,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
commitmentsCmd
|
||||
.command("dismiss <ids...>")
|
||||
.description("Dismiss inferred follow-up commitments")
|
||||
.option("--json", "Output JSON instead of text", false)
|
||||
.action(async (ids: string[], opts, command) => {
|
||||
const parentOpts = command.parent?.opts() as { json?: boolean } | undefined;
|
||||
await runCommandWithRuntime(defaultRuntime, async () => {
|
||||
await commitmentsDismissCommand(
|
||||
{
|
||||
ids,
|
||||
json: Boolean(opts.json || parentOpts?.json),
|
||||
},
|
||||
defaultRuntime,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const tasksCmd = program
|
||||
.command("tasks")
|
||||
.description("Inspect durable background tasks and TaskFlow state")
|
||||
|
||||
150
src/commands/commitments.ts
Normal file
150
src/commands/commitments.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
listCommitments,
|
||||
markCommitmentsStatus,
|
||||
resolveCommitmentStorePath,
|
||||
} from "../commitments/store.js";
|
||||
import type { CommitmentRecord, CommitmentStatus } from "../commitments/types.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { info } from "../globals.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import { isRich, theme } from "../terminal/theme.js";
|
||||
|
||||
const STATUS_VALUES = new Set<CommitmentStatus>([
|
||||
"pending",
|
||||
"sent",
|
||||
"dismissed",
|
||||
"snoozed",
|
||||
"expired",
|
||||
]);
|
||||
|
||||
function truncate(value: string, maxChars: number): string {
|
||||
return value.length <= maxChars ? value : `${value.slice(0, maxChars - 1)}...`;
|
||||
}
|
||||
|
||||
function parseStatus(raw: string | undefined, runtime: RuntimeEnv): CommitmentStatus | undefined {
|
||||
const status = normalizeOptionalString(raw);
|
||||
if (!status) {
|
||||
return undefined;
|
||||
}
|
||||
if (STATUS_VALUES.has(status as CommitmentStatus)) {
|
||||
return status as CommitmentStatus;
|
||||
}
|
||||
runtime.error(`Unknown commitment status: ${status}`);
|
||||
runtime.exit(1);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isActiveCommitment(commitment: CommitmentRecord): boolean {
|
||||
return commitment.status === "pending" || commitment.status === "snoozed";
|
||||
}
|
||||
|
||||
function formatDue(ms: number): string {
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
function formatRows(commitments: CommitmentRecord[], rich: boolean): string[] {
|
||||
const header = [
|
||||
"ID".padEnd(16),
|
||||
"Status".padEnd(10),
|
||||
"Kind".padEnd(16),
|
||||
"Due".padEnd(24),
|
||||
"Scope".padEnd(28),
|
||||
"Suggested text",
|
||||
].join(" ");
|
||||
const lines = [rich ? theme.heading(header) : header];
|
||||
for (const commitment of commitments) {
|
||||
const scope = truncate(
|
||||
[commitment.agentId, commitment.channel, commitment.to ?? commitment.sessionKey]
|
||||
.filter(Boolean)
|
||||
.join("/"),
|
||||
28,
|
||||
);
|
||||
lines.push(
|
||||
[
|
||||
truncate(commitment.id, 16).padEnd(16),
|
||||
commitment.status.padEnd(10),
|
||||
commitment.kind.padEnd(16),
|
||||
formatDue(commitment.dueWindow.earliestMs).padEnd(24),
|
||||
scope.padEnd(28),
|
||||
truncate(commitment.suggestedText, 90),
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export async function commitmentsListCommand(
|
||||
opts: { json?: boolean; status?: string; all?: boolean; agent?: string },
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
const status = opts.all ? undefined : parseStatus(opts.status ?? "pending", runtime);
|
||||
if (!opts.all && opts.status && !status) {
|
||||
return;
|
||||
}
|
||||
const commitments = (
|
||||
await listCommitments({
|
||||
cfg,
|
||||
status,
|
||||
agentId: normalizeOptionalString(opts.agent),
|
||||
})
|
||||
).filter((commitment) => opts.all || status || isActiveCommitment(commitment));
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
count: commitments.length,
|
||||
status: status ?? (opts.all ? null : "pending"),
|
||||
agentId: normalizeOptionalString(opts.agent) ?? null,
|
||||
store: resolveCommitmentStorePath(cfg.commitments?.store),
|
||||
commitments,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.log(info(`Commitments: ${commitments.length}`));
|
||||
runtime.log(info(`Store: ${resolveCommitmentStorePath(cfg.commitments?.store)}`));
|
||||
if (status) {
|
||||
runtime.log(info(`Status filter: ${status}`));
|
||||
}
|
||||
if (opts.agent) {
|
||||
runtime.log(info(`Agent filter: ${opts.agent}`));
|
||||
}
|
||||
if (commitments.length === 0) {
|
||||
runtime.log("No commitments found.");
|
||||
return;
|
||||
}
|
||||
for (const line of formatRows(commitments, isRich())) {
|
||||
runtime.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
export async function commitmentsDismissCommand(
|
||||
opts: { ids: string[]; json?: boolean },
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<void> {
|
||||
const ids = opts.ids.map((id) => id.trim()).filter(Boolean);
|
||||
if (ids.length === 0) {
|
||||
runtime.error("At least one commitment id is required.");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const cfg = loadConfig();
|
||||
await markCommitmentsStatus({
|
||||
cfg,
|
||||
ids,
|
||||
status: "dismissed",
|
||||
nowMs: Date.now(),
|
||||
});
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify({ dismissed: ids }, null, 2));
|
||||
return;
|
||||
}
|
||||
runtime.log(info(`Dismissed commitments: ${ids.join(", ")}`));
|
||||
}
|
||||
@@ -1615,6 +1615,46 @@ describe("doctor config flow", () => {
|
||||
expect(result.cfg.plugins?.entries?.browser?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves commitments config on repair", async () => {
|
||||
const result = await runDoctorConfigWithInput({
|
||||
repair: true,
|
||||
config: {
|
||||
commitments: {
|
||||
enabled: true,
|
||||
categories: {
|
||||
careCheckIns: "gentle",
|
||||
eventCheckIns: false,
|
||||
},
|
||||
extraction: {
|
||||
enabled: true,
|
||||
batchMaxItems: 4,
|
||||
},
|
||||
delivery: {
|
||||
maxPerHeartbeat: 2,
|
||||
expireAfterHours: 48,
|
||||
},
|
||||
},
|
||||
},
|
||||
run: loadAndMaybeMigrateDoctorConfig,
|
||||
});
|
||||
|
||||
expect(result.cfg.commitments).toEqual({
|
||||
enabled: true,
|
||||
categories: {
|
||||
careCheckIns: "gentle",
|
||||
eventCheckIns: false,
|
||||
},
|
||||
extraction: {
|
||||
enabled: true,
|
||||
batchMaxItems: 4,
|
||||
},
|
||||
delivery: {
|
||||
maxPerHeartbeat: 2,
|
||||
expireAfterHours: 48,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves discord streaming intent while stripping unsupported keys on repair", async () => {
|
||||
const result = await runDoctorConfigWithInput({
|
||||
repair: true,
|
||||
|
||||
106
src/commitments/config.ts
Normal file
106
src/commitments/config.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { resolveUserTimezone } from "../agents/date-time.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { CommitmentsConfig } from "../config/types.commitments.js";
|
||||
|
||||
export const DEFAULT_COMMITMENT_EXTRACTION_DEBOUNCE_MS = 15_000;
|
||||
export const DEFAULT_COMMITMENT_BATCH_MAX_ITEMS = 8;
|
||||
export const DEFAULT_COMMITMENT_CONFIDENCE_THRESHOLD = 0.72;
|
||||
export const DEFAULT_COMMITMENT_CARE_CONFIDENCE_THRESHOLD = 0.86;
|
||||
export const DEFAULT_COMMITMENT_EXTRACTION_TIMEOUT_SECONDS = 45;
|
||||
export const DEFAULT_COMMITMENT_MAX_PER_HEARTBEAT = 3;
|
||||
export const DEFAULT_COMMITMENT_EXPIRE_AFTER_HOURS = 72;
|
||||
|
||||
export type ResolvedCommitmentsConfig = {
|
||||
enabled: boolean;
|
||||
store?: string;
|
||||
categories: {
|
||||
eventCheckIns: boolean;
|
||||
deadlineCheckIns: boolean;
|
||||
openLoops: boolean;
|
||||
careCheckIns: false | "gentle" | true;
|
||||
};
|
||||
extraction: {
|
||||
enabled: boolean;
|
||||
model?: string;
|
||||
debounceMs: number;
|
||||
batchMaxItems: number;
|
||||
confidenceThreshold: number;
|
||||
careConfidenceThreshold: number;
|
||||
timeoutSeconds: number;
|
||||
};
|
||||
delivery: {
|
||||
maxPerHeartbeat: number;
|
||||
expireAfterHours: number;
|
||||
};
|
||||
};
|
||||
|
||||
function positiveInt(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? Math.floor(value)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function nonnegativeNumber(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function resolveCareCheckIns(
|
||||
value: CommitmentsConfig["categories"] | undefined,
|
||||
): false | "gentle" | true {
|
||||
const raw = value?.careCheckIns;
|
||||
if (raw === false) {
|
||||
return false;
|
||||
}
|
||||
if (raw === true) {
|
||||
return true;
|
||||
}
|
||||
return "gentle";
|
||||
}
|
||||
|
||||
export function resolveCommitmentsConfig(cfg?: OpenClawConfig): ResolvedCommitmentsConfig {
|
||||
const raw = cfg?.commitments;
|
||||
const extraction = raw?.extraction;
|
||||
const delivery = raw?.delivery;
|
||||
return {
|
||||
enabled: raw?.enabled !== false,
|
||||
store: raw?.store,
|
||||
categories: {
|
||||
eventCheckIns: raw?.categories?.eventCheckIns !== false,
|
||||
deadlineCheckIns: raw?.categories?.deadlineCheckIns !== false,
|
||||
openLoops: raw?.categories?.openLoops !== false,
|
||||
careCheckIns: resolveCareCheckIns(raw?.categories),
|
||||
},
|
||||
extraction: {
|
||||
enabled: extraction?.enabled !== false,
|
||||
model: extraction?.model?.trim() || undefined,
|
||||
debounceMs: nonnegativeNumber(
|
||||
extraction?.debounceMs,
|
||||
DEFAULT_COMMITMENT_EXTRACTION_DEBOUNCE_MS,
|
||||
),
|
||||
batchMaxItems: positiveInt(extraction?.batchMaxItems, DEFAULT_COMMITMENT_BATCH_MAX_ITEMS),
|
||||
confidenceThreshold: nonnegativeNumber(
|
||||
extraction?.confidenceThreshold,
|
||||
DEFAULT_COMMITMENT_CONFIDENCE_THRESHOLD,
|
||||
),
|
||||
careConfidenceThreshold: nonnegativeNumber(
|
||||
extraction?.careConfidenceThreshold,
|
||||
DEFAULT_COMMITMENT_CARE_CONFIDENCE_THRESHOLD,
|
||||
),
|
||||
timeoutSeconds: positiveInt(
|
||||
extraction?.timeoutSeconds,
|
||||
DEFAULT_COMMITMENT_EXTRACTION_TIMEOUT_SECONDS,
|
||||
),
|
||||
},
|
||||
delivery: {
|
||||
maxPerHeartbeat: positiveInt(delivery?.maxPerHeartbeat, DEFAULT_COMMITMENT_MAX_PER_HEARTBEAT),
|
||||
expireAfterHours: positiveInt(
|
||||
delivery?.expireAfterHours,
|
||||
DEFAULT_COMMITMENT_EXPIRE_AFTER_HOURS,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCommitmentTimezone(cfg?: OpenClawConfig): string {
|
||||
return resolveUserTimezone(cfg?.agents?.defaults?.userTimezone);
|
||||
}
|
||||
175
src/commitments/extraction.test.ts
Normal file
175
src/commitments/extraction.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
parseCommitmentExtractionOutput,
|
||||
persistCommitmentExtractionResult,
|
||||
validateCommitmentCandidates,
|
||||
} from "./extraction.js";
|
||||
import { loadCommitmentStore } from "./store.js";
|
||||
import type { CommitmentCandidate, CommitmentExtractionItem } from "./types.js";
|
||||
|
||||
describe("commitment extraction", () => {
|
||||
const tmpDirs: string[] = [];
|
||||
const nowMs = Date.parse("2026-04-29T16:00:00.000Z");
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tmpDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
tmpDirs.length = 0;
|
||||
});
|
||||
|
||||
async function createConfig(): Promise<OpenClawConfig> {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-commitments-"));
|
||||
tmpDirs.push(tmpDir);
|
||||
return {
|
||||
commitments: {
|
||||
store: path.join(tmpDir, "commitments.json"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function item(overrides?: Partial<CommitmentExtractionItem>): CommitmentExtractionItem {
|
||||
return {
|
||||
itemId: "turn-1",
|
||||
nowMs,
|
||||
timezone: "America/Los_Angeles",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:user-1",
|
||||
channel: "telegram",
|
||||
to: "15551234567",
|
||||
userText: "I have an interview tomorrow.",
|
||||
assistantText: "Good luck. I hope it goes well.",
|
||||
existingPending: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function candidate(overrides?: Partial<CommitmentCandidate>): CommitmentCandidate {
|
||||
return {
|
||||
itemId: "turn-1",
|
||||
kind: "event_check_in",
|
||||
sensitivity: "routine",
|
||||
source: "inferred_user_context",
|
||||
reason: "The user said they had an interview tomorrow.",
|
||||
suggestedText: "How did the interview go?",
|
||||
dedupeKey: "interview:2026-04-30",
|
||||
confidence: 0.91,
|
||||
dueWindow: {
|
||||
earliest: "2026-04-30T17:00:00.000Z",
|
||||
latest: "2026-04-30T23:00:00.000Z",
|
||||
timezone: "America/Los_Angeles",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("parses valid candidates from JSON output with surrounding text", () => {
|
||||
const parsed = parseCommitmentExtractionOutput(
|
||||
`noise {"candidates":[${JSON.stringify(candidate())}]} trailing`,
|
||||
);
|
||||
|
||||
expect(parsed.candidates).toHaveLength(1);
|
||||
expect(parsed.candidates[0]).toMatchObject({
|
||||
kind: "event_check_in",
|
||||
suggestedText: "How did the interview go?",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects disabled, low-confidence, and non-future candidates", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
commitments: {
|
||||
categories: { careCheckIns: "gentle" },
|
||||
extraction: { confidenceThreshold: 0.8, careConfidenceThreshold: 0.9 },
|
||||
},
|
||||
};
|
||||
const valid = validateCommitmentCandidates({
|
||||
cfg,
|
||||
items: [item()],
|
||||
result: {
|
||||
candidates: [
|
||||
candidate(),
|
||||
candidate({
|
||||
kind: "care_check_in",
|
||||
sensitivity: "care",
|
||||
reason: "The user said they were tired.",
|
||||
suggestedText: "Hope you got some rest.",
|
||||
dedupeKey: "sleep:2026-04-29",
|
||||
confidence: 0.82,
|
||||
}),
|
||||
candidate({
|
||||
dedupeKey: "past",
|
||||
dueWindow: { earliest: "2026-04-29T15:00:00.000Z" },
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(valid.map((entry) => entry.candidate.dedupeKey)).toEqual(["interview:2026-04-30"]);
|
||||
});
|
||||
|
||||
it("clamps inferred due time to at least one heartbeat interval after write time", () => {
|
||||
const writeMs = nowMs + 5_000;
|
||||
const valid = validateCommitmentCandidates({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "10m" },
|
||||
},
|
||||
},
|
||||
},
|
||||
items: [item()],
|
||||
result: {
|
||||
candidates: [
|
||||
candidate({
|
||||
dedupeKey: "too-soon",
|
||||
dueWindow: {
|
||||
earliest: new Date(nowMs + 60_000).toISOString(),
|
||||
latest: new Date(nowMs + 120_000).toISOString(),
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
nowMs: writeMs,
|
||||
});
|
||||
|
||||
expect(valid).toHaveLength(1);
|
||||
expect(valid[0]?.earliestMs).toBe(writeMs + 10 * 60_000);
|
||||
expect(valid[0]?.latestMs).toBe(writeMs + 10 * 60_000 + 12 * 60 * 60_000);
|
||||
});
|
||||
|
||||
it("persists inferred commitments and dedupes by scope and dedupe key", async () => {
|
||||
const cfg = await createConfig();
|
||||
const created = await persistCommitmentExtractionResult({
|
||||
cfg,
|
||||
items: [item()],
|
||||
result: { candidates: [candidate()] },
|
||||
nowMs,
|
||||
});
|
||||
const deduped = await persistCommitmentExtractionResult({
|
||||
cfg,
|
||||
items: [item()],
|
||||
result: {
|
||||
candidates: [
|
||||
candidate({
|
||||
reason: "Updated reason",
|
||||
confidence: 0.97,
|
||||
dueWindow: { earliest: "2026-04-30T18:00:00.000Z" },
|
||||
}),
|
||||
],
|
||||
},
|
||||
nowMs: nowMs + 1_000,
|
||||
});
|
||||
const store = await loadCommitmentStore(cfg.commitments?.store);
|
||||
|
||||
expect(created).toHaveLength(1);
|
||||
expect(deduped).toHaveLength(0);
|
||||
expect(store.commitments).toHaveLength(1);
|
||||
expect(store.commitments[0]).toMatchObject({
|
||||
reason: "Updated reason",
|
||||
confidence: 0.97,
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
});
|
||||
362
src/commitments/extraction.ts
Normal file
362
src/commitments/extraction.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { resolveAgentConfig } from "../agents/agent-scope.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveHeartbeatIntervalMs } from "../infra/heartbeat-summary.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { resolveCommitmentsConfig } from "./config.js";
|
||||
import {
|
||||
isCommitmentKindEnabled,
|
||||
listPendingCommitmentsForScope,
|
||||
upsertInferredCommitments,
|
||||
} from "./store.js";
|
||||
import type {
|
||||
CommitmentCandidate,
|
||||
CommitmentExtractionBatchResult,
|
||||
CommitmentExtractionItem,
|
||||
CommitmentKind,
|
||||
CommitmentSensitivity,
|
||||
CommitmentSource,
|
||||
} from "./types.js";
|
||||
|
||||
const KIND_VALUES = new Set<CommitmentKind>([
|
||||
"event_check_in",
|
||||
"deadline_check",
|
||||
"care_check_in",
|
||||
"open_loop",
|
||||
]);
|
||||
const SENSITIVITY_VALUES = new Set<CommitmentSensitivity>(["routine", "personal", "care"]);
|
||||
const SOURCE_VALUES = new Set<CommitmentSource>(["inferred_user_context", "agent_promise"]);
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function parseCandidate(raw: unknown): CommitmentCandidate | undefined {
|
||||
if (!isRecord(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
if (raw.action === "skip") {
|
||||
return undefined;
|
||||
}
|
||||
const itemId = asString(raw.itemId);
|
||||
const kind = asString(raw.kind);
|
||||
const sensitivity = asString(raw.sensitivity);
|
||||
const source = asString(raw.source) ?? "inferred_user_context";
|
||||
const reason = asString(raw.reason);
|
||||
const suggestedText = asString(raw.suggestedText);
|
||||
const dedupeKey = asString(raw.dedupeKey);
|
||||
const confidence = asNumber(raw.confidence);
|
||||
const dueWindow = isRecord(raw.dueWindow) ? raw.dueWindow : undefined;
|
||||
const earliest = asString(dueWindow?.earliest);
|
||||
const latest = asString(dueWindow?.latest);
|
||||
const timezone = asString(dueWindow?.timezone);
|
||||
if (
|
||||
!itemId ||
|
||||
!KIND_VALUES.has(kind as CommitmentKind) ||
|
||||
!SENSITIVITY_VALUES.has(sensitivity as CommitmentSensitivity) ||
|
||||
!SOURCE_VALUES.has(source as CommitmentSource) ||
|
||||
!reason ||
|
||||
!suggestedText ||
|
||||
!dedupeKey ||
|
||||
confidence === undefined ||
|
||||
!earliest
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
itemId,
|
||||
kind: kind as CommitmentKind,
|
||||
sensitivity: sensitivity as CommitmentSensitivity,
|
||||
source: source as CommitmentSource,
|
||||
reason,
|
||||
suggestedText,
|
||||
dedupeKey,
|
||||
confidence,
|
||||
dueWindow: {
|
||||
earliest,
|
||||
...(latest ? { latest } : {}),
|
||||
...(timezone ? { timezone } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractJsonObjectCandidates(raw: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let start = -1;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let idx = 0; idx < raw.length; idx += 1) {
|
||||
const char = raw[idx] ?? "";
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "\\") {
|
||||
if (inString) {
|
||||
escaped = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (inString) {
|
||||
continue;
|
||||
}
|
||||
if (char === "{") {
|
||||
if (depth === 0) {
|
||||
start = idx;
|
||||
}
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "}" && depth > 0) {
|
||||
depth -= 1;
|
||||
if (depth === 0 && start >= 0) {
|
||||
out.push(raw.slice(start, idx + 1));
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseCommitmentExtractionOutput(raw: string): CommitmentExtractionBatchResult {
|
||||
const candidates: CommitmentCandidate[] = [];
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return { candidates };
|
||||
}
|
||||
const records: Record<string, unknown>[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (isRecord(parsed)) {
|
||||
records.push(parsed);
|
||||
}
|
||||
} catch {
|
||||
for (const candidate of extractJsonObjectCandidates(trimmed)) {
|
||||
try {
|
||||
const parsed = JSON.parse(candidate) as unknown;
|
||||
if (isRecord(parsed)) {
|
||||
records.push(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed fragments.
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const record of records) {
|
||||
const rawCandidates = Array.isArray(record.candidates) ? record.candidates : [];
|
||||
for (const candidate of rawCandidates) {
|
||||
const parsed = parseCandidate(candidate);
|
||||
if (parsed) {
|
||||
candidates.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { candidates };
|
||||
}
|
||||
|
||||
export async function hydrateCommitmentExtractionItem(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
item: Omit<CommitmentExtractionItem, "existingPending">;
|
||||
}): Promise<CommitmentExtractionItem> {
|
||||
const existingPending = await listPendingCommitmentsForScope({
|
||||
cfg: params.cfg,
|
||||
scope: params.item,
|
||||
nowMs: params.item.nowMs,
|
||||
limit: 8,
|
||||
});
|
||||
return {
|
||||
...params.item,
|
||||
existingPending: existingPending.map((commitment) => ({
|
||||
kind: commitment.kind,
|
||||
reason: commitment.reason,
|
||||
dedupeKey: commitment.dedupeKey,
|
||||
earliestMs: commitment.dueWindow.earliestMs,
|
||||
latestMs: commitment.dueWindow.latestMs,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function formatExistingPending(item: CommitmentExtractionItem) {
|
||||
return item.existingPending.map((commitment) => ({
|
||||
kind: commitment.kind,
|
||||
reason: commitment.reason,
|
||||
dedupeKey: commitment.dedupeKey,
|
||||
earliest: new Date(commitment.earliestMs).toISOString(),
|
||||
latest: new Date(commitment.latestMs).toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildCommitmentExtractionPrompt(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
items: CommitmentExtractionItem[];
|
||||
}): string {
|
||||
const resolved = resolveCommitmentsConfig(params.cfg);
|
||||
const categoryConfig = JSON.stringify(resolved.categories);
|
||||
const items = params.items.map((item) => ({
|
||||
itemId: item.itemId,
|
||||
now: new Date(item.nowMs).toISOString(),
|
||||
timezone: item.timezone,
|
||||
scope: {
|
||||
agentId: item.agentId,
|
||||
sessionKey: item.sessionKey,
|
||||
channel: item.channel,
|
||||
accountId: item.accountId,
|
||||
to: item.to,
|
||||
threadId: item.threadId,
|
||||
},
|
||||
latestUserMessage: item.userText,
|
||||
assistantResponse: item.assistantText ?? "",
|
||||
existingPendingCommitments: formatExistingPending(item),
|
||||
}));
|
||||
return `You are OpenClaw's internal commitment extractor. This is a hidden background classification run. Do not address the user.
|
||||
|
||||
Create inferred follow-up commitments only. Exact user requests such as "remind me tomorrow", "schedule this", or "check in at 3" belong to cron/reminders and must be skipped.
|
||||
|
||||
Use these categories: ${categoryConfig}
|
||||
|
||||
Create a candidate only when the latest exchange creates a useful future check-in opportunity that the user did not explicitly schedule. Prefer no candidate over weak candidates.
|
||||
|
||||
Rules:
|
||||
- Output JSON only, with top-level {"candidates":[...]}.
|
||||
- Each candidate must include itemId, kind, sensitivity, source, dueWindow, reason, suggestedText, confidence, and dedupeKey.
|
||||
- kind is one of event_check_in, deadline_check, care_check_in, open_loop.
|
||||
- sensitivity is routine, personal, or care.
|
||||
- source is inferred_user_context or agent_promise.
|
||||
- dueWindow.earliest and dueWindow.latest must be ISO timestamps in the future relative to that item.
|
||||
- Skip explicit reminders/scheduling requests; those are cron-owned.
|
||||
- Skip if the assistant already clearly says a cron reminder was scheduled.
|
||||
- Skip if the topic is already resolved in the assistant response.
|
||||
- Care check-ins must be gentle, rare, and high confidence. Avoid interrogating language.
|
||||
- Suggested text should be short, natural, and suitable to send in the same channel.
|
||||
- Dedupe keys should be stable within a session, like "interview:2026-04-29" or "sleep:2026-04-29".
|
||||
|
||||
Items:
|
||||
${JSON.stringify(items, null, 2)}`;
|
||||
}
|
||||
|
||||
function parseDueMs(raw: string | undefined): number | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Date.parse(raw);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function resolveMinimumDueMs(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
item: CommitmentExtractionItem;
|
||||
nowMs: number;
|
||||
}): number {
|
||||
const cfg = params.cfg ?? {};
|
||||
const defaults = cfg.agents?.defaults?.heartbeat;
|
||||
const overrides = resolveAgentConfig(cfg, params.item.agentId)?.heartbeat;
|
||||
const heartbeat = defaults || overrides ? { ...defaults, ...overrides } : undefined;
|
||||
const intervalMs = resolveHeartbeatIntervalMs(cfg, undefined, heartbeat) ?? 0;
|
||||
return params.nowMs + intervalMs;
|
||||
}
|
||||
|
||||
export function validateCommitmentCandidates(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
items: CommitmentExtractionItem[];
|
||||
result: CommitmentExtractionBatchResult;
|
||||
nowMs?: number;
|
||||
}): Array<{
|
||||
item: CommitmentExtractionItem;
|
||||
candidate: CommitmentCandidate;
|
||||
earliestMs: number;
|
||||
latestMs: number;
|
||||
timezone: string;
|
||||
}> {
|
||||
const resolved = resolveCommitmentsConfig(params.cfg);
|
||||
const itemsById = new Map(params.items.map((item) => [item.itemId, item]));
|
||||
const nowMs = params.nowMs ?? Date.now();
|
||||
const validated: Array<{
|
||||
item: CommitmentExtractionItem;
|
||||
candidate: CommitmentCandidate;
|
||||
earliestMs: number;
|
||||
latestMs: number;
|
||||
timezone: string;
|
||||
}> = [];
|
||||
for (const candidate of params.result.candidates) {
|
||||
const item = itemsById.get(candidate.itemId);
|
||||
if (!item || !isCommitmentKindEnabled(candidate.kind, resolved.categories)) {
|
||||
continue;
|
||||
}
|
||||
const threshold =
|
||||
candidate.kind === "care_check_in" || candidate.sensitivity === "care"
|
||||
? resolved.extraction.careConfidenceThreshold
|
||||
: resolved.extraction.confidenceThreshold;
|
||||
if (candidate.confidence < threshold) {
|
||||
continue;
|
||||
}
|
||||
const extractedEarliestMs = parseDueMs(candidate.dueWindow.earliest);
|
||||
if (extractedEarliestMs === undefined || extractedEarliestMs <= item.nowMs) {
|
||||
continue;
|
||||
}
|
||||
const earliestMs = Math.max(
|
||||
extractedEarliestMs,
|
||||
resolveMinimumDueMs({
|
||||
cfg: params.cfg,
|
||||
item,
|
||||
nowMs,
|
||||
}),
|
||||
);
|
||||
const latestRawMs = parseDueMs(candidate.dueWindow.latest);
|
||||
const latestMs =
|
||||
latestRawMs !== undefined && latestRawMs >= earliestMs
|
||||
? latestRawMs
|
||||
: earliestMs + 12 * 60 * 60 * 1000;
|
||||
validated.push({
|
||||
item,
|
||||
candidate,
|
||||
earliestMs,
|
||||
latestMs,
|
||||
timezone: candidate.dueWindow.timezone ?? item.timezone,
|
||||
});
|
||||
}
|
||||
return validated;
|
||||
}
|
||||
|
||||
export async function persistCommitmentExtractionResult(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
items: CommitmentExtractionItem[];
|
||||
result: CommitmentExtractionBatchResult;
|
||||
nowMs?: number;
|
||||
}) {
|
||||
const valid = validateCommitmentCandidates(params);
|
||||
const byItem = new Map<string, typeof valid>();
|
||||
for (const entry of valid) {
|
||||
const existing = byItem.get(entry.item.itemId) ?? [];
|
||||
existing.push(entry);
|
||||
byItem.set(entry.item.itemId, existing);
|
||||
}
|
||||
const created = [];
|
||||
for (const entries of byItem.values()) {
|
||||
const item = entries[0]?.item;
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
created.push(
|
||||
...(await upsertInferredCommitments({
|
||||
cfg: params.cfg,
|
||||
item,
|
||||
candidates: entries.map((entry) => ({
|
||||
candidate: entry.candidate,
|
||||
earliestMs: entry.earliestMs,
|
||||
latestMs: entry.latestMs,
|
||||
timezone: entry.timezone,
|
||||
})),
|
||||
nowMs: params.nowMs,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
118
src/commitments/runtime.test.ts
Normal file
118
src/commitments/runtime.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
configureCommitmentExtractionRuntime,
|
||||
drainCommitmentExtractionQueue,
|
||||
enqueueCommitmentExtraction,
|
||||
resetCommitmentExtractionRuntimeForTests,
|
||||
} from "./runtime.js";
|
||||
import { loadCommitmentStore } from "./store.js";
|
||||
import type { CommitmentExtractionItem } from "./types.js";
|
||||
|
||||
describe("commitment extraction runtime", () => {
|
||||
const tmpDirs: string[] = [];
|
||||
const nowMs = Date.parse("2026-04-29T16:00:00.000Z");
|
||||
|
||||
afterEach(async () => {
|
||||
resetCommitmentExtractionRuntimeForTests();
|
||||
await Promise.all(tmpDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
tmpDirs.length = 0;
|
||||
});
|
||||
|
||||
async function createConfig(): Promise<OpenClawConfig> {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-commitment-runtime-"));
|
||||
tmpDirs.push(tmpDir);
|
||||
return {
|
||||
commitments: {
|
||||
store: path.join(tmpDir, "commitments.json"),
|
||||
extraction: {
|
||||
debounceMs: 1_000,
|
||||
batchMaxItems: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("does not enqueue background extraction in test mode unless forced", async () => {
|
||||
const cfg = await createConfig();
|
||||
|
||||
expect(
|
||||
enqueueCommitmentExtraction({
|
||||
cfg,
|
||||
nowMs,
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:user-1",
|
||||
channel: "telegram",
|
||||
userText: "Interview tomorrow.",
|
||||
assistantText: "Good luck.",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("micro-batches queued turns into one extractor call", async () => {
|
||||
const cfg = await createConfig();
|
||||
const extractBatch = vi.fn(async ({ items }: { items: CommitmentExtractionItem[] }) => ({
|
||||
candidates: items.map((item, index) => ({
|
||||
itemId: item.itemId,
|
||||
kind: "event_check_in" as const,
|
||||
sensitivity: "routine" as const,
|
||||
source: "inferred_user_context" as const,
|
||||
reason: `Follow up ${index + 1}`,
|
||||
suggestedText: `How did item ${index + 1} go?`,
|
||||
dedupeKey: `event:${index + 1}`,
|
||||
confidence: 0.93,
|
||||
dueWindow: {
|
||||
earliest: "2026-04-30T17:00:00.000Z",
|
||||
latest: "2026-04-30T23:00:00.000Z",
|
||||
timezone: "America/Los_Angeles",
|
||||
},
|
||||
})),
|
||||
}));
|
||||
configureCommitmentExtractionRuntime({
|
||||
forceInTests: true,
|
||||
extractBatch,
|
||||
setTimer: () => ({ unref() {} }) as ReturnType<typeof setTimeout>,
|
||||
clearTimer: () => undefined,
|
||||
});
|
||||
|
||||
expect(
|
||||
enqueueCommitmentExtraction({
|
||||
cfg,
|
||||
nowMs,
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:user-1",
|
||||
channel: "telegram",
|
||||
to: "15551234567",
|
||||
sourceMessageId: "m1",
|
||||
userText: "I have an interview tomorrow.",
|
||||
assistantText: "Good luck.",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
enqueueCommitmentExtraction({
|
||||
cfg,
|
||||
nowMs: nowMs + 1,
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:telegram:user-1",
|
||||
channel: "telegram",
|
||||
to: "15551234567",
|
||||
sourceMessageId: "m2",
|
||||
userText: "I have a dentist appointment tomorrow.",
|
||||
assistantText: "Hope it goes smoothly.",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await expect(drainCommitmentExtractionQueue()).resolves.toBe(2);
|
||||
const store = await loadCommitmentStore(cfg.commitments?.store);
|
||||
|
||||
expect(extractBatch).toHaveBeenCalledTimes(1);
|
||||
expect(extractBatch.mock.calls[0]?.[0].items).toHaveLength(2);
|
||||
expect(store.commitments.map((commitment) => commitment.dedupeKey)).toEqual([
|
||||
"event:1",
|
||||
"event:2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
258
src/commitments/runtime.ts
Normal file
258
src/commitments/runtime.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
resolveDefaultModelForAgent,
|
||||
resolveModelRefFromString,
|
||||
} from "../agents/model-selection.js";
|
||||
import { runEmbeddedPiAgent, type EmbeddedPiRunResult } from "../agents/pi-embedded.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import { resolveCommitmentTimezone, resolveCommitmentsConfig } from "./config.js";
|
||||
import {
|
||||
buildCommitmentExtractionPrompt,
|
||||
hydrateCommitmentExtractionItem,
|
||||
parseCommitmentExtractionOutput,
|
||||
persistCommitmentExtractionResult,
|
||||
} from "./extraction.js";
|
||||
import type {
|
||||
CommitmentExtractionBatchResult,
|
||||
CommitmentExtractionItem,
|
||||
CommitmentScope,
|
||||
} from "./types.js";
|
||||
|
||||
type TimerHandle = ReturnType<typeof setTimeout>;
|
||||
|
||||
export type CommitmentExtractionEnqueueInput = CommitmentScope & {
|
||||
cfg?: OpenClawConfig;
|
||||
nowMs?: number;
|
||||
userText: string;
|
||||
assistantText?: string;
|
||||
sourceMessageId?: string;
|
||||
sourceRunId?: string;
|
||||
};
|
||||
|
||||
export type CommitmentExtractionRuntime = {
|
||||
extractBatch?: (params: {
|
||||
cfg?: OpenClawConfig;
|
||||
items: CommitmentExtractionItem[];
|
||||
}) => Promise<CommitmentExtractionBatchResult>;
|
||||
setTimer?: (callback: () => void, delayMs: number) => TimerHandle;
|
||||
clearTimer?: (timer: TimerHandle) => void;
|
||||
forceInTests?: boolean;
|
||||
};
|
||||
|
||||
const log = createSubsystemLogger("commitments");
|
||||
|
||||
let runtime: CommitmentExtractionRuntime = {};
|
||||
let queue: Array<Omit<CommitmentExtractionItem, "existingPending"> & { cfg?: OpenClawConfig }> = [];
|
||||
let timer: TimerHandle | null = null;
|
||||
let draining = false;
|
||||
|
||||
function shouldDisableBackgroundExtractionForTests(): boolean {
|
||||
if (runtime.forceInTests) {
|
||||
return false;
|
||||
}
|
||||
return process.env.VITEST === "true" || process.env.NODE_ENV === "test";
|
||||
}
|
||||
|
||||
function setTimer(callback: () => void, delayMs: number): TimerHandle {
|
||||
const handle = runtime.setTimer
|
||||
? runtime.setTimer(callback, delayMs)
|
||||
: setTimeout(callback, delayMs);
|
||||
if (typeof handle === "object" && "unref" in handle && typeof handle.unref === "function") {
|
||||
handle.unref();
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
|
||||
function clearTimer(handle: TimerHandle): void {
|
||||
(runtime.clearTimer ?? clearTimeout)(handle);
|
||||
}
|
||||
|
||||
export function configureCommitmentExtractionRuntime(next: CommitmentExtractionRuntime): void {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function resetCommitmentExtractionRuntimeForTests(): void {
|
||||
if (timer) {
|
||||
clearTimer(timer);
|
||||
}
|
||||
runtime = {};
|
||||
queue = [];
|
||||
timer = null;
|
||||
draining = false;
|
||||
}
|
||||
|
||||
function buildItemId(params: CommitmentExtractionEnqueueInput, nowMs: number): string {
|
||||
const source = normalizeOptionalString(params.sourceMessageId) ?? randomUUID();
|
||||
return [
|
||||
params.agentId,
|
||||
params.sessionKey,
|
||||
params.channel,
|
||||
params.accountId ?? "",
|
||||
params.to ?? "",
|
||||
params.threadId ?? "",
|
||||
source,
|
||||
nowMs,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function isUsefulText(value: string | undefined): boolean {
|
||||
return Boolean(value?.trim());
|
||||
}
|
||||
|
||||
export function enqueueCommitmentExtraction(input: CommitmentExtractionEnqueueInput): boolean {
|
||||
const resolved = resolveCommitmentsConfig(input.cfg);
|
||||
if (
|
||||
!resolved.enabled ||
|
||||
!resolved.extraction.enabled ||
|
||||
shouldDisableBackgroundExtractionForTests() ||
|
||||
!isUsefulText(input.userText) ||
|
||||
!isUsefulText(input.assistantText) ||
|
||||
!input.agentId.trim() ||
|
||||
!input.sessionKey.trim() ||
|
||||
!input.channel.trim()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const nowMs = input.nowMs ?? Date.now();
|
||||
queue.push({
|
||||
itemId: buildItemId(input, nowMs),
|
||||
nowMs,
|
||||
timezone: resolveCommitmentTimezone(input.cfg),
|
||||
agentId: input.agentId.trim(),
|
||||
sessionKey: input.sessionKey.trim(),
|
||||
channel: input.channel.trim(),
|
||||
...(input.accountId?.trim() ? { accountId: input.accountId.trim() } : {}),
|
||||
...(input.to?.trim() ? { to: input.to.trim() } : {}),
|
||||
...(input.threadId?.trim() ? { threadId: input.threadId.trim() } : {}),
|
||||
...(input.senderId?.trim() ? { senderId: input.senderId.trim() } : {}),
|
||||
userText: input.userText.trim(),
|
||||
...(input.assistantText?.trim() ? { assistantText: input.assistantText.trim() } : {}),
|
||||
...(input.sourceMessageId?.trim() ? { sourceMessageId: input.sourceMessageId.trim() } : {}),
|
||||
...(input.sourceRunId?.trim() ? { sourceRunId: input.sourceRunId.trim() } : {}),
|
||||
cfg: input.cfg,
|
||||
});
|
||||
if (!timer) {
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
void drainCommitmentExtractionQueue().catch((err) => {
|
||||
log.warn("commitment extraction failed", { error: String(err) });
|
||||
});
|
||||
}, resolved.extraction.debounceMs);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveExtractionSessionFile(agentId: string, runId: string): string {
|
||||
return path.join(
|
||||
resolveStateDir(),
|
||||
"commitments",
|
||||
"extractor-sessions",
|
||||
agentId,
|
||||
`${runId}.jsonl`,
|
||||
);
|
||||
}
|
||||
|
||||
function joinPayloadText(result: EmbeddedPiRunResult): string {
|
||||
return (
|
||||
result.payloads
|
||||
?.map((payload) => payload.text)
|
||||
.filter((text): text is string => Boolean(text?.trim()))
|
||||
.join("\n")
|
||||
.trim() ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
async function defaultExtractBatch(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
items: CommitmentExtractionItem[];
|
||||
}): Promise<CommitmentExtractionBatchResult> {
|
||||
const cfg = params.cfg ?? {};
|
||||
const first = params.items[0];
|
||||
if (!first) {
|
||||
return { candidates: [] };
|
||||
}
|
||||
const resolved = resolveCommitmentsConfig(cfg);
|
||||
const runId = `commitments-${randomUUID()}`;
|
||||
const modelFallback = resolveDefaultModelForAgent({ cfg: cfg ?? {}, agentId: first.agentId });
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg: cfg ?? {},
|
||||
defaultProvider: modelFallback.provider,
|
||||
});
|
||||
const modelRef = resolved.extraction.model
|
||||
? resolveModelRefFromString({
|
||||
raw: resolved.extraction.model,
|
||||
defaultProvider: modelFallback.provider,
|
||||
aliasIndex,
|
||||
})?.ref
|
||||
: undefined;
|
||||
const result = await runEmbeddedPiAgent({
|
||||
sessionId: runId,
|
||||
sessionKey: `agent:${first.agentId}:commitments:${runId}`,
|
||||
agentId: first.agentId,
|
||||
trigger: "manual",
|
||||
sessionFile: resolveExtractionSessionFile(first.agentId, runId),
|
||||
workspaceDir: resolveAgentWorkspaceDir(cfg, first.agentId),
|
||||
config: cfg,
|
||||
prompt: buildCommitmentExtractionPrompt({ cfg, items: params.items }),
|
||||
disableTools: true,
|
||||
provider: modelRef?.provider,
|
||||
model: modelRef?.model,
|
||||
thinkLevel: "off",
|
||||
verboseLevel: "off",
|
||||
reasoningLevel: "off",
|
||||
fastMode: true,
|
||||
timeoutMs: resolved.extraction.timeoutSeconds * 1000,
|
||||
runId,
|
||||
bootstrapContextMode: "lightweight",
|
||||
skillsSnapshot: { prompt: "", skills: [] },
|
||||
suppressToolErrorWarnings: true,
|
||||
});
|
||||
return parseCommitmentExtractionOutput(joinPayloadText(result));
|
||||
}
|
||||
|
||||
async function hydrateBatch(
|
||||
batch: Array<Omit<CommitmentExtractionItem, "existingPending"> & { cfg?: OpenClawConfig }>,
|
||||
): Promise<CommitmentExtractionItem[]> {
|
||||
return Promise.all(
|
||||
batch.map(async (item) =>
|
||||
hydrateCommitmentExtractionItem({
|
||||
cfg: item.cfg,
|
||||
item,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function drainCommitmentExtractionQueue(): Promise<number> {
|
||||
if (draining) {
|
||||
return 0;
|
||||
}
|
||||
draining = true;
|
||||
try {
|
||||
let processed = 0;
|
||||
while (queue.length > 0) {
|
||||
const firstCfg = queue[0]?.cfg;
|
||||
const resolved = resolveCommitmentsConfig(firstCfg);
|
||||
const batch = queue.splice(0, resolved.extraction.batchMaxItems);
|
||||
const items = await hydrateBatch(batch);
|
||||
const extractor = runtime.extractBatch ?? defaultExtractBatch;
|
||||
const result = await extractor({ cfg: firstCfg, items });
|
||||
await persistCommitmentExtractionResult({
|
||||
cfg: firstCfg,
|
||||
items,
|
||||
result,
|
||||
nowMs: Date.now(),
|
||||
});
|
||||
processed += items.length;
|
||||
}
|
||||
return processed;
|
||||
} finally {
|
||||
draining = false;
|
||||
}
|
||||
}
|
||||
433
src/commitments/store.ts
Normal file
433
src/commitments/store.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { expandHomePrefix } from "../infra/home-dir.js";
|
||||
import { resolveCommitmentsConfig } from "./config.js";
|
||||
import type {
|
||||
CommitmentCandidate,
|
||||
CommitmentExtractionItem,
|
||||
CommitmentKind,
|
||||
CommitmentRecord,
|
||||
CommitmentScope,
|
||||
CommitmentStatus,
|
||||
CommitmentStoreFile,
|
||||
} from "./types.js";
|
||||
|
||||
const STORE_VERSION = 1 as const;
|
||||
|
||||
function defaultCommitmentStorePath(): string {
|
||||
return path.join(resolveStateDir(), "commitments", "commitments.json");
|
||||
}
|
||||
|
||||
export function resolveCommitmentStorePath(storePath?: string): string {
|
||||
const trimmed = storePath?.trim();
|
||||
if (!trimmed) {
|
||||
return defaultCommitmentStorePath();
|
||||
}
|
||||
if (trimmed.startsWith("~")) {
|
||||
return path.resolve(expandHomePrefix(trimmed));
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
function emptyStore(): CommitmentStoreFile {
|
||||
return { version: STORE_VERSION, commitments: [] };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function coerceCommitment(raw: unknown): CommitmentRecord | undefined {
|
||||
if (!isRecord(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const dueWindow = isRecord(raw.dueWindow) ? raw.dueWindow : undefined;
|
||||
if (!dueWindow) {
|
||||
return undefined;
|
||||
}
|
||||
const requiredStrings = [
|
||||
raw.id,
|
||||
raw.agentId,
|
||||
raw.sessionKey,
|
||||
raw.channel,
|
||||
raw.kind,
|
||||
raw.sensitivity,
|
||||
raw.source,
|
||||
raw.status,
|
||||
raw.reason,
|
||||
raw.suggestedText,
|
||||
raw.dedupeKey,
|
||||
raw.sourceUserText,
|
||||
];
|
||||
if (requiredStrings.some((value) => typeof value !== "string" || !value.trim())) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
typeof raw.confidence !== "number" ||
|
||||
typeof raw.createdAtMs !== "number" ||
|
||||
typeof raw.updatedAtMs !== "number" ||
|
||||
typeof raw.attempts !== "number" ||
|
||||
typeof dueWindow.earliestMs !== "number" ||
|
||||
typeof dueWindow.latestMs !== "number" ||
|
||||
typeof dueWindow.timezone !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return raw as CommitmentRecord;
|
||||
}
|
||||
|
||||
export async function loadCommitmentStore(storePath?: string): Promise<CommitmentStoreFile> {
|
||||
const resolved = resolveCommitmentStorePath(storePath);
|
||||
try {
|
||||
const raw = await fs.promises.readFile(resolved, "utf-8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
!isRecord(parsed) ||
|
||||
parsed.version !== STORE_VERSION ||
|
||||
!Array.isArray(parsed.commitments)
|
||||
) {
|
||||
return emptyStore();
|
||||
}
|
||||
return {
|
||||
version: STORE_VERSION,
|
||||
commitments: parsed.commitments.flatMap((entry) => {
|
||||
const coerced = coerceCommitment(entry);
|
||||
return coerced ? [coerced] : [];
|
||||
}),
|
||||
};
|
||||
} catch (err) {
|
||||
if ((err as { code?: unknown })?.code === "ENOENT") {
|
||||
return emptyStore();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCommitmentStore(
|
||||
storePath: string | undefined,
|
||||
store: CommitmentStoreFile,
|
||||
): Promise<void> {
|
||||
const resolved = resolveCommitmentStorePath(storePath);
|
||||
const dir = path.dirname(resolved);
|
||||
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
await fs.promises.chmod(dir, 0o700).catch(() => undefined);
|
||||
const json = JSON.stringify(store, null, 2);
|
||||
const tmp = `${resolved}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
||||
await fs.promises.writeFile(tmp, json, { encoding: "utf-8", mode: 0o600 });
|
||||
await fs.promises.chmod(tmp, 0o600).catch(() => undefined);
|
||||
await fs.promises.rename(tmp, resolved);
|
||||
await fs.promises.chmod(resolved, 0o600).catch(() => undefined);
|
||||
}
|
||||
|
||||
function generateCommitmentId(nowMs: number): string {
|
||||
return `cm_${nowMs.toString(36)}_${randomBytes(5).toString("hex")}`;
|
||||
}
|
||||
|
||||
function scopeValue(value: string | undefined): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function buildCommitmentScopeKey(scope: CommitmentScope): string {
|
||||
return [
|
||||
scopeValue(scope.agentId),
|
||||
scopeValue(scope.sessionKey),
|
||||
scopeValue(scope.channel),
|
||||
scopeValue(scope.accountId),
|
||||
scopeValue(scope.to),
|
||||
scopeValue(scope.threadId),
|
||||
scopeValue(scope.senderId),
|
||||
].join("\u001f");
|
||||
}
|
||||
|
||||
function isActiveStatus(status: CommitmentStatus): boolean {
|
||||
return status === "pending" || status === "snoozed";
|
||||
}
|
||||
|
||||
function candidateToRecord(params: {
|
||||
item: CommitmentExtractionItem;
|
||||
candidate: CommitmentCandidate;
|
||||
nowMs: number;
|
||||
earliestMs: number;
|
||||
latestMs: number;
|
||||
timezone: string;
|
||||
}): CommitmentRecord {
|
||||
return {
|
||||
id: generateCommitmentId(params.nowMs),
|
||||
agentId: params.item.agentId,
|
||||
sessionKey: params.item.sessionKey,
|
||||
channel: params.item.channel,
|
||||
...(params.item.accountId ? { accountId: params.item.accountId } : {}),
|
||||
...(params.item.to ? { to: params.item.to } : {}),
|
||||
...(params.item.threadId ? { threadId: params.item.threadId } : {}),
|
||||
...(params.item.senderId ? { senderId: params.item.senderId } : {}),
|
||||
kind: params.candidate.kind,
|
||||
sensitivity: params.candidate.sensitivity,
|
||||
source: params.candidate.source,
|
||||
status: "pending",
|
||||
reason: params.candidate.reason.trim(),
|
||||
suggestedText: params.candidate.suggestedText.trim(),
|
||||
dedupeKey: params.candidate.dedupeKey.trim(),
|
||||
confidence: params.candidate.confidence,
|
||||
dueWindow: {
|
||||
earliestMs: params.earliestMs,
|
||||
latestMs: params.latestMs,
|
||||
timezone: params.timezone,
|
||||
},
|
||||
...(params.item.sourceMessageId ? { sourceMessageId: params.item.sourceMessageId } : {}),
|
||||
...(params.item.sourceRunId ? { sourceRunId: params.item.sourceRunId } : {}),
|
||||
sourceUserText: params.item.userText,
|
||||
...(params.item.assistantText ? { sourceAssistantText: params.item.assistantText } : {}),
|
||||
createdAtMs: params.nowMs,
|
||||
updatedAtMs: params.nowMs,
|
||||
attempts: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPendingCommitmentsForScope(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
scope: CommitmentScope;
|
||||
nowMs?: number;
|
||||
limit?: number;
|
||||
}): Promise<CommitmentRecord[]> {
|
||||
const resolved = resolveCommitmentsConfig(params.cfg);
|
||||
const store = await loadCommitmentStore(resolved.store);
|
||||
const scopeKey = buildCommitmentScopeKey(params.scope);
|
||||
const nowMs = params.nowMs ?? Date.now();
|
||||
const limit = params.limit ?? 20;
|
||||
return store.commitments
|
||||
.filter(
|
||||
(commitment) =>
|
||||
buildCommitmentScopeKey(commitment) === scopeKey &&
|
||||
isActiveStatus(commitment.status) &&
|
||||
(commitment.status !== "snoozed" || (commitment.snoozedUntilMs ?? 0) <= nowMs),
|
||||
)
|
||||
.toSorted(
|
||||
(a, b) => a.dueWindow.earliestMs - b.dueWindow.earliestMs || a.createdAtMs - b.createdAtMs,
|
||||
)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export async function upsertInferredCommitments(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
item: CommitmentExtractionItem;
|
||||
candidates: Array<{
|
||||
candidate: CommitmentCandidate;
|
||||
earliestMs: number;
|
||||
latestMs: number;
|
||||
timezone: string;
|
||||
}>;
|
||||
nowMs?: number;
|
||||
}): Promise<CommitmentRecord[]> {
|
||||
if (params.candidates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const resolved = resolveCommitmentsConfig(params.cfg);
|
||||
const store = await loadCommitmentStore(resolved.store);
|
||||
const nowMs = params.nowMs ?? Date.now();
|
||||
const created: CommitmentRecord[] = [];
|
||||
const scopeKey = buildCommitmentScopeKey(params.item);
|
||||
|
||||
for (const entry of params.candidates) {
|
||||
const dedupeKey = entry.candidate.dedupeKey.trim();
|
||||
const existingIndex = store.commitments.findIndex(
|
||||
(commitment) =>
|
||||
buildCommitmentScopeKey(commitment) === scopeKey &&
|
||||
commitment.dedupeKey === dedupeKey &&
|
||||
isActiveStatus(commitment.status),
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
const existing = store.commitments[existingIndex];
|
||||
store.commitments[existingIndex] = {
|
||||
...existing,
|
||||
reason: entry.candidate.reason.trim() || existing.reason,
|
||||
suggestedText: entry.candidate.suggestedText.trim() || existing.suggestedText,
|
||||
confidence: Math.max(existing.confidence, entry.candidate.confidence),
|
||||
dueWindow: {
|
||||
earliestMs: Math.min(existing.dueWindow.earliestMs, entry.earliestMs),
|
||||
latestMs: Math.max(existing.dueWindow.latestMs, entry.latestMs),
|
||||
timezone: entry.timezone,
|
||||
},
|
||||
updatedAtMs: nowMs,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
const record = candidateToRecord({
|
||||
item: params.item,
|
||||
candidate: entry.candidate,
|
||||
nowMs,
|
||||
earliestMs: entry.earliestMs,
|
||||
latestMs: entry.latestMs,
|
||||
timezone: entry.timezone,
|
||||
});
|
||||
store.commitments.push(record);
|
||||
created.push(record);
|
||||
}
|
||||
if (created.length > 0) {
|
||||
await saveCommitmentStore(resolved.store, store);
|
||||
} else {
|
||||
await saveCommitmentStore(resolved.store, store);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function listDueCommitmentsForSession(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
nowMs?: number;
|
||||
limit?: number;
|
||||
}): Promise<CommitmentRecord[]> {
|
||||
const resolved = resolveCommitmentsConfig(params.cfg);
|
||||
if (!resolved.enabled) {
|
||||
return [];
|
||||
}
|
||||
const store = await loadCommitmentStore(resolved.store);
|
||||
const nowMs = params.nowMs ?? Date.now();
|
||||
const limit = params.limit ?? resolved.delivery.maxPerHeartbeat;
|
||||
const expireAfterMs = resolved.delivery.expireAfterHours * 60 * 60 * 1000;
|
||||
return store.commitments
|
||||
.filter(
|
||||
(commitment) =>
|
||||
commitment.agentId === params.agentId &&
|
||||
commitment.sessionKey === params.sessionKey &&
|
||||
isActiveStatus(commitment.status) &&
|
||||
commitment.dueWindow.earliestMs <= nowMs &&
|
||||
commitment.dueWindow.latestMs + expireAfterMs >= nowMs &&
|
||||
(commitment.status !== "snoozed" || (commitment.snoozedUntilMs ?? 0) <= nowMs),
|
||||
)
|
||||
.toSorted(
|
||||
(a, b) => a.dueWindow.earliestMs - b.dueWindow.earliestMs || a.createdAtMs - b.createdAtMs,
|
||||
)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export async function listDueCommitmentSessionKeys(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
agentId: string;
|
||||
nowMs?: number;
|
||||
limit?: number;
|
||||
}): Promise<string[]> {
|
||||
const resolved = resolveCommitmentsConfig(params.cfg);
|
||||
if (!resolved.enabled) {
|
||||
return [];
|
||||
}
|
||||
const store = await loadCommitmentStore(resolved.store);
|
||||
const nowMs = params.nowMs ?? Date.now();
|
||||
const expireAfterMs = resolved.delivery.expireAfterHours * 60 * 60 * 1000;
|
||||
const keys = new Set<string>();
|
||||
for (const commitment of store.commitments) {
|
||||
if (
|
||||
commitment.agentId === params.agentId &&
|
||||
isActiveStatus(commitment.status) &&
|
||||
commitment.dueWindow.earliestMs <= nowMs &&
|
||||
commitment.dueWindow.latestMs + expireAfterMs >= nowMs &&
|
||||
(commitment.status !== "snoozed" || (commitment.snoozedUntilMs ?? 0) <= nowMs)
|
||||
) {
|
||||
keys.add(commitment.sessionKey);
|
||||
}
|
||||
if (params.limit && keys.size >= params.limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [...keys].toSorted();
|
||||
}
|
||||
|
||||
export async function markCommitmentsAttempted(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
ids: string[];
|
||||
nowMs?: number;
|
||||
}): Promise<void> {
|
||||
if (params.ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
const resolved = resolveCommitmentsConfig(params.cfg);
|
||||
const idSet = new Set(params.ids);
|
||||
const nowMs = params.nowMs ?? Date.now();
|
||||
const store = await loadCommitmentStore(resolved.store);
|
||||
let changed = false;
|
||||
store.commitments = store.commitments.map((commitment) => {
|
||||
if (!idSet.has(commitment.id)) {
|
||||
return commitment;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...commitment,
|
||||
attempts: commitment.attempts + 1,
|
||||
lastAttemptAtMs: nowMs,
|
||||
updatedAtMs: nowMs,
|
||||
};
|
||||
});
|
||||
if (changed) {
|
||||
await saveCommitmentStore(resolved.store, store);
|
||||
}
|
||||
}
|
||||
|
||||
export async function markCommitmentsStatus(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
ids: string[];
|
||||
status: Extract<CommitmentStatus, "sent" | "dismissed" | "expired">;
|
||||
nowMs?: number;
|
||||
}): Promise<void> {
|
||||
if (params.ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
const resolved = resolveCommitmentsConfig(params.cfg);
|
||||
const idSet = new Set(params.ids);
|
||||
const nowMs = params.nowMs ?? Date.now();
|
||||
const store = await loadCommitmentStore(resolved.store);
|
||||
let changed = false;
|
||||
store.commitments = store.commitments.map((commitment) => {
|
||||
if (!idSet.has(commitment.id) || !isActiveStatus(commitment.status)) {
|
||||
return commitment;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...commitment,
|
||||
status: params.status,
|
||||
updatedAtMs: nowMs,
|
||||
...(params.status === "sent" ? { sentAtMs: nowMs } : {}),
|
||||
...(params.status === "dismissed" ? { dismissedAtMs: nowMs } : {}),
|
||||
...(params.status === "expired" ? { expiredAtMs: nowMs } : {}),
|
||||
};
|
||||
});
|
||||
if (changed) {
|
||||
await saveCommitmentStore(resolved.store, store);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listCommitments(params?: {
|
||||
cfg?: OpenClawConfig;
|
||||
status?: CommitmentStatus;
|
||||
agentId?: string;
|
||||
}): Promise<CommitmentRecord[]> {
|
||||
const resolved = resolveCommitmentsConfig(params?.cfg);
|
||||
const store = await loadCommitmentStore(resolved.store);
|
||||
return store.commitments
|
||||
.filter(
|
||||
(commitment) =>
|
||||
(!params?.status || commitment.status === params.status) &&
|
||||
(!params?.agentId || commitment.agentId === params.agentId),
|
||||
)
|
||||
.toSorted(
|
||||
(a, b) => a.dueWindow.earliestMs - b.dueWindow.earliestMs || a.createdAtMs - b.createdAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
export function isCommitmentKindEnabled(
|
||||
kind: CommitmentKind,
|
||||
categories: ReturnType<typeof resolveCommitmentsConfig>["categories"],
|
||||
): boolean {
|
||||
switch (kind) {
|
||||
case "event_check_in":
|
||||
return categories.eventCheckIns;
|
||||
case "deadline_check":
|
||||
return categories.deadlineCheckIns;
|
||||
case "open_loop":
|
||||
return categories.openLoops;
|
||||
case "care_check_in":
|
||||
return categories.careCheckIns !== false;
|
||||
}
|
||||
}
|
||||
90
src/commitments/types.ts
Normal file
90
src/commitments/types.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
export type CommitmentKind = "event_check_in" | "deadline_check" | "care_check_in" | "open_loop";
|
||||
|
||||
export type CommitmentSensitivity = "routine" | "personal" | "care";
|
||||
|
||||
export type CommitmentStatus = "pending" | "sent" | "dismissed" | "snoozed" | "expired";
|
||||
|
||||
export type CommitmentSource = "inferred_user_context" | "agent_promise";
|
||||
|
||||
export type CommitmentScope = {
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
to?: string;
|
||||
threadId?: string;
|
||||
senderId?: string;
|
||||
};
|
||||
|
||||
export type CommitmentDueWindow = {
|
||||
earliestMs: number;
|
||||
latestMs: number;
|
||||
timezone: string;
|
||||
};
|
||||
|
||||
export type CommitmentRecord = CommitmentScope & {
|
||||
id: string;
|
||||
kind: CommitmentKind;
|
||||
sensitivity: CommitmentSensitivity;
|
||||
source: CommitmentSource;
|
||||
status: CommitmentStatus;
|
||||
reason: string;
|
||||
suggestedText: string;
|
||||
dedupeKey: string;
|
||||
confidence: number;
|
||||
dueWindow: CommitmentDueWindow;
|
||||
sourceMessageId?: string;
|
||||
sourceRunId?: string;
|
||||
sourceUserText: string;
|
||||
sourceAssistantText?: string;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
attempts: number;
|
||||
lastAttemptAtMs?: number;
|
||||
sentAtMs?: number;
|
||||
dismissedAtMs?: number;
|
||||
snoozedUntilMs?: number;
|
||||
expiredAtMs?: number;
|
||||
};
|
||||
|
||||
export type CommitmentStoreFile = {
|
||||
version: 1;
|
||||
commitments: CommitmentRecord[];
|
||||
};
|
||||
|
||||
export type CommitmentCandidate = {
|
||||
itemId: string;
|
||||
kind: CommitmentKind;
|
||||
sensitivity: CommitmentSensitivity;
|
||||
source: CommitmentSource;
|
||||
reason: string;
|
||||
suggestedText: string;
|
||||
dedupeKey: string;
|
||||
confidence: number;
|
||||
dueWindow: {
|
||||
earliest: string;
|
||||
latest?: string;
|
||||
timezone?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type CommitmentExtractionItem = CommitmentScope & {
|
||||
itemId: string;
|
||||
nowMs: number;
|
||||
timezone: string;
|
||||
userText: string;
|
||||
assistantText?: string;
|
||||
sourceMessageId?: string;
|
||||
sourceRunId?: string;
|
||||
existingPending: Array<{
|
||||
kind: CommitmentKind;
|
||||
reason: string;
|
||||
dedupeKey: string;
|
||||
earliestMs: number;
|
||||
latestMs: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type CommitmentExtractionBatchResult = {
|
||||
candidates: CommitmentCandidate[];
|
||||
};
|
||||
@@ -21066,6 +21066,153 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
|
||||
description:
|
||||
"Global scheduler settings for stored cron jobs, run concurrency, delivery fallback, and run-session retention. Keep defaults unless you are scaling job volume or integrating external webhook receivers.",
|
||||
},
|
||||
commitments: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
title: "Commitments Enabled",
|
||||
description:
|
||||
"Global inferred commitment feature gate. Set false to disable background extraction, storage, and heartbeat delivery for inferred follow-ups.",
|
||||
},
|
||||
store: {
|
||||
type: "string",
|
||||
title: "Commitments Store Path",
|
||||
description:
|
||||
"Optional JSON store path for inferred commitments. Leave unset to use the default OpenClaw state directory store.",
|
||||
},
|
||||
categories: {
|
||||
type: "object",
|
||||
properties: {
|
||||
eventCheckIns: {
|
||||
type: "boolean",
|
||||
title: "Event Check-ins",
|
||||
description:
|
||||
"Enables inferred event check-ins such as asking how an interview or appointment went. Default: true.",
|
||||
},
|
||||
deadlineCheckIns: {
|
||||
type: "boolean",
|
||||
title: "Deadline Check-ins",
|
||||
description:
|
||||
"Enables inferred deadline or progress check-ins for work the user expects to revisit. Default: true.",
|
||||
},
|
||||
openLoops: {
|
||||
type: "boolean",
|
||||
title: "Open-loop Check-ins",
|
||||
description:
|
||||
"Enables inferred open-loop check-ins when the user is waiting on an outcome or unresolved next step. Default: true.",
|
||||
},
|
||||
careCheckIns: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "gentle",
|
||||
},
|
||||
],
|
||||
title: "Care Check-ins",
|
||||
description:
|
||||
'Controls personal care check-ins. Use "gentle" for conservative care follow-ups, true for normal extraction, or false to disable them.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
title: "Commitment Categories",
|
||||
description:
|
||||
"Category gates for inferred commitments such as event check-ins, deadline progress, open loops, and care check-ins. Use these to narrow what OpenClaw infers while keeping the system enabled.",
|
||||
},
|
||||
extraction: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
title: "Commitment Extraction Enabled",
|
||||
description:
|
||||
"Enables hidden background LLM extraction for inferred commitments. Set false to keep stored commitments deliverable while preventing new inferred commitments.",
|
||||
},
|
||||
model: {
|
||||
type: "string",
|
||||
title: "Commitment Extraction Model",
|
||||
description:
|
||||
"Optional provider/model override for hidden commitment extraction runs. Leave unset to use the active agent model.",
|
||||
},
|
||||
debounceMs: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
title: "Commitment Extraction Debounce (ms)",
|
||||
description:
|
||||
"Milliseconds to wait before draining queued conversation turns into a batched hidden extraction run. Default: 15000.",
|
||||
},
|
||||
batchMaxItems: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
title: "Commitment Extraction Batch Size",
|
||||
description:
|
||||
"Maximum queued turn extractions sent in one hidden model call. Default: 8.",
|
||||
},
|
||||
confidenceThreshold: {
|
||||
type: "number",
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
title: "Commitment Confidence Threshold",
|
||||
description:
|
||||
"Minimum accepted confidence from the extractor for routine inferred commitments. Default: 0.72.",
|
||||
},
|
||||
careConfidenceThreshold: {
|
||||
type: "number",
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
title: "Care Commitment Confidence Threshold",
|
||||
description:
|
||||
"Minimum accepted confidence from the extractor for personal care check-ins. Default: 0.86.",
|
||||
},
|
||||
timeoutSeconds: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
title: "Commitment Extraction Timeout (sec)",
|
||||
description:
|
||||
"Maximum runtime in seconds for a hidden extraction pass before it is abandoned. Default: 45.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
title: "Commitment Extraction",
|
||||
description:
|
||||
"Background extraction controls for the hidden LLM pass that creates inferred commitments without adding content to the conversation transcript.",
|
||||
},
|
||||
delivery: {
|
||||
type: "object",
|
||||
properties: {
|
||||
maxPerHeartbeat: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
title: "Commitments per Heartbeat",
|
||||
description:
|
||||
"Maximum due inferred commitments injected into one heartbeat turn. Default: 3.",
|
||||
},
|
||||
expireAfterHours: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
title: "Commitment Expiration (hours)",
|
||||
description:
|
||||
"Number of hours after the due time before a pending inferred commitment expires instead of being delivered. Default: 72.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
title: "Commitment Delivery",
|
||||
description: "Heartbeat delivery controls for due inferred commitments.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
title: "Commitments",
|
||||
description:
|
||||
"Inferred follow-up commitment controls for automatically detecting check-ins from conversation turns and delivering them through heartbeat runs. Keep enabled for ambient follow-ups, or disable when you only want explicit reminders.",
|
||||
},
|
||||
hooks: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -24299,6 +24446,101 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
|
||||
help: "How often beta-channel checks run in hours (default: 1).",
|
||||
tags: ["performance"],
|
||||
},
|
||||
commitments: {
|
||||
label: "Commitments",
|
||||
help: "Inferred follow-up commitment controls for automatically detecting check-ins from conversation turns and delivering them through heartbeat runs. Keep enabled for ambient follow-ups, or disable when you only want explicit reminders.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"commitments.enabled": {
|
||||
label: "Commitments Enabled",
|
||||
help: "Global inferred commitment feature gate. Set false to disable background extraction, storage, and heartbeat delivery for inferred follow-ups.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"commitments.store": {
|
||||
label: "Commitments Store Path",
|
||||
help: "Optional JSON store path for inferred commitments. Leave unset to use the default OpenClaw state directory store.",
|
||||
tags: ["storage"],
|
||||
},
|
||||
"commitments.categories": {
|
||||
label: "Commitment Categories",
|
||||
help: "Category gates for inferred commitments such as event check-ins, deadline progress, open loops, and care check-ins. Use these to narrow what OpenClaw infers while keeping the system enabled.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"commitments.categories.eventCheckIns": {
|
||||
label: "Event Check-ins",
|
||||
help: "Enables inferred event check-ins such as asking how an interview or appointment went. Default: true.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"commitments.categories.deadlineCheckIns": {
|
||||
label: "Deadline Check-ins",
|
||||
help: "Enables inferred deadline or progress check-ins for work the user expects to revisit. Default: true.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"commitments.categories.openLoops": {
|
||||
label: "Open-loop Check-ins",
|
||||
help: "Enables inferred open-loop check-ins when the user is waiting on an outcome or unresolved next step. Default: true.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"commitments.categories.careCheckIns": {
|
||||
label: "Care Check-ins",
|
||||
help: 'Controls personal care check-ins. Use "gentle" for conservative care follow-ups, true for normal extraction, or false to disable them.',
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"commitments.extraction": {
|
||||
label: "Commitment Extraction",
|
||||
help: "Background extraction controls for the hidden LLM pass that creates inferred commitments without adding content to the conversation transcript.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"commitments.extraction.enabled": {
|
||||
label: "Commitment Extraction Enabled",
|
||||
help: "Enables hidden background LLM extraction for inferred commitments. Set false to keep stored commitments deliverable while preventing new inferred commitments.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"commitments.extraction.model": {
|
||||
label: "Commitment Extraction Model",
|
||||
help: "Optional provider/model override for hidden commitment extraction runs. Leave unset to use the active agent model.",
|
||||
tags: ["models"],
|
||||
},
|
||||
"commitments.extraction.debounceMs": {
|
||||
label: "Commitment Extraction Debounce (ms)",
|
||||
help: "Milliseconds to wait before draining queued conversation turns into a batched hidden extraction run. Default: 15000.",
|
||||
tags: ["performance"],
|
||||
},
|
||||
"commitments.extraction.batchMaxItems": {
|
||||
label: "Commitment Extraction Batch Size",
|
||||
help: "Maximum queued turn extractions sent in one hidden model call. Default: 8.",
|
||||
tags: ["performance"],
|
||||
},
|
||||
"commitments.extraction.confidenceThreshold": {
|
||||
label: "Commitment Confidence Threshold",
|
||||
help: "Minimum accepted confidence from the extractor for routine inferred commitments. Default: 0.72.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"commitments.extraction.careConfidenceThreshold": {
|
||||
label: "Care Commitment Confidence Threshold",
|
||||
help: "Minimum accepted confidence from the extractor for personal care check-ins. Default: 0.86.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"commitments.extraction.timeoutSeconds": {
|
||||
label: "Commitment Extraction Timeout (sec)",
|
||||
help: "Maximum runtime in seconds for a hidden extraction pass before it is abandoned. Default: 45.",
|
||||
tags: ["performance"],
|
||||
},
|
||||
"commitments.delivery": {
|
||||
label: "Commitment Delivery",
|
||||
help: "Heartbeat delivery controls for due inferred commitments.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"commitments.delivery.maxPerHeartbeat": {
|
||||
label: "Commitments per Heartbeat",
|
||||
help: "Maximum due inferred commitments injected into one heartbeat turn. Default: 3.",
|
||||
tags: ["performance", "automation"],
|
||||
},
|
||||
"commitments.delivery.expireAfterHours": {
|
||||
label: "Commitment Expiration (hours)",
|
||||
help: "Number of hours after the due time before a pending inferred commitment expires instead of being delivered. Default: 72.",
|
||||
tags: ["advanced"],
|
||||
},
|
||||
"diagnostics.enabled": {
|
||||
label: "Diagnostics Enabled",
|
||||
help: "Master toggle for diagnostics instrumentation output in logs and telemetry wiring paths. Defaults to enabled; set false only in tightly constrained environments.",
|
||||
|
||||
@@ -11,6 +11,7 @@ const ROOT_SECTIONS = [
|
||||
"logging",
|
||||
"cli",
|
||||
"update",
|
||||
"commitments",
|
||||
"browser",
|
||||
"ui",
|
||||
"auth",
|
||||
|
||||
@@ -190,6 +190,43 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Idle runtime TTL in minutes for ACP session workers before eligible cleanup.",
|
||||
"acp.runtime.installCommand":
|
||||
"Optional operator install/setup command shown by `/acp install` and `/acp doctor` when ACP backend wiring is missing.",
|
||||
commitments:
|
||||
"Inferred follow-up commitment controls for automatically detecting check-ins from conversation turns and delivering them through heartbeat runs. Keep enabled for ambient follow-ups, or disable when you only want explicit reminders.",
|
||||
"commitments.enabled":
|
||||
"Global inferred commitment feature gate. Set false to disable background extraction, storage, and heartbeat delivery for inferred follow-ups.",
|
||||
"commitments.store":
|
||||
"Optional JSON store path for inferred commitments. Leave unset to use the default OpenClaw state directory store.",
|
||||
"commitments.categories":
|
||||
"Category gates for inferred commitments such as event check-ins, deadline progress, open loops, and care check-ins. Use these to narrow what OpenClaw infers while keeping the system enabled.",
|
||||
"commitments.categories.eventCheckIns":
|
||||
"Enables inferred event check-ins such as asking how an interview or appointment went. Default: true.",
|
||||
"commitments.categories.deadlineCheckIns":
|
||||
"Enables inferred deadline or progress check-ins for work the user expects to revisit. Default: true.",
|
||||
"commitments.categories.openLoops":
|
||||
"Enables inferred open-loop check-ins when the user is waiting on an outcome or unresolved next step. Default: true.",
|
||||
"commitments.categories.careCheckIns":
|
||||
'Controls personal care check-ins. Use "gentle" for conservative care follow-ups, true for normal extraction, or false to disable them.',
|
||||
"commitments.extraction":
|
||||
"Background extraction controls for the hidden LLM pass that creates inferred commitments without adding content to the conversation transcript.",
|
||||
"commitments.extraction.enabled":
|
||||
"Enables hidden background LLM extraction for inferred commitments. Set false to keep stored commitments deliverable while preventing new inferred commitments.",
|
||||
"commitments.extraction.model":
|
||||
"Optional provider/model override for hidden commitment extraction runs. Leave unset to use the active agent model.",
|
||||
"commitments.extraction.debounceMs":
|
||||
"Milliseconds to wait before draining queued conversation turns into a batched hidden extraction run. Default: 15000.",
|
||||
"commitments.extraction.batchMaxItems":
|
||||
"Maximum queued turn extractions sent in one hidden model call. Default: 8.",
|
||||
"commitments.extraction.confidenceThreshold":
|
||||
"Minimum accepted confidence from the extractor for routine inferred commitments. Default: 0.72.",
|
||||
"commitments.extraction.careConfidenceThreshold":
|
||||
"Minimum accepted confidence from the extractor for personal care check-ins. Default: 0.86.",
|
||||
"commitments.extraction.timeoutSeconds":
|
||||
"Maximum runtime in seconds for a hidden extraction pass before it is abandoned. Default: 45.",
|
||||
"commitments.delivery": "Heartbeat delivery controls for due inferred commitments.",
|
||||
"commitments.delivery.maxPerHeartbeat":
|
||||
"Maximum due inferred commitments injected into one heartbeat turn. Default: 3.",
|
||||
"commitments.delivery.expireAfterHours":
|
||||
"Number of hours after the due time before a pending inferred commitment expires instead of being delivered. Default: 72.",
|
||||
"agents.list.*.skills":
|
||||
"Optional allowlist of skills for this agent. If omitted, the agent inherits agents.defaults.skills when set; otherwise skills stay unrestricted. Set [] for no skills. An explicit list fully replaces inherited defaults instead of merging with them.",
|
||||
"agents.list[].skills":
|
||||
|
||||
@@ -35,6 +35,25 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"update.auto.stableDelayHours": "Auto Update Stable Delay (hours)",
|
||||
"update.auto.stableJitterHours": "Auto Update Stable Jitter (hours)",
|
||||
"update.auto.betaCheckIntervalHours": "Auto Update Beta Check Interval (hours)",
|
||||
commitments: "Commitments",
|
||||
"commitments.enabled": "Commitments Enabled",
|
||||
"commitments.store": "Commitments Store Path",
|
||||
"commitments.categories": "Commitment Categories",
|
||||
"commitments.categories.eventCheckIns": "Event Check-ins",
|
||||
"commitments.categories.deadlineCheckIns": "Deadline Check-ins",
|
||||
"commitments.categories.openLoops": "Open-loop Check-ins",
|
||||
"commitments.categories.careCheckIns": "Care Check-ins",
|
||||
"commitments.extraction": "Commitment Extraction",
|
||||
"commitments.extraction.enabled": "Commitment Extraction Enabled",
|
||||
"commitments.extraction.model": "Commitment Extraction Model",
|
||||
"commitments.extraction.debounceMs": "Commitment Extraction Debounce (ms)",
|
||||
"commitments.extraction.batchMaxItems": "Commitment Extraction Batch Size",
|
||||
"commitments.extraction.confidenceThreshold": "Commitment Confidence Threshold",
|
||||
"commitments.extraction.careConfidenceThreshold": "Care Commitment Confidence Threshold",
|
||||
"commitments.extraction.timeoutSeconds": "Commitment Extraction Timeout (sec)",
|
||||
"commitments.delivery": "Commitment Delivery",
|
||||
"commitments.delivery.maxPerHeartbeat": "Commitments per Heartbeat",
|
||||
"commitments.delivery.expireAfterHours": "Commitment Expiration (hours)",
|
||||
"diagnostics.enabled": "Diagnostics Enabled",
|
||||
"diagnostics.flags": "Diagnostics Flags",
|
||||
"diagnostics.stuckSessionWarnMs": "Stuck Session Warning Threshold (ms)",
|
||||
|
||||
47
src/config/types.commitments.ts
Normal file
47
src/config/types.commitments.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export type CommitmentCategoryConfig = {
|
||||
/** Enable inferred event check-ins such as "interview tomorrow". Default: true. */
|
||||
eventCheckIns?: boolean;
|
||||
/** Enable inferred deadline/progress check-ins. Default: true. */
|
||||
deadlineCheckIns?: boolean;
|
||||
/** Enable inferred open-loop check-ins such as "waiting to hear back". Default: true. */
|
||||
openLoops?: boolean;
|
||||
/**
|
||||
* Enable personal care check-ins. "gentle" keeps conservative extraction and delivery wording.
|
||||
* Default: "gentle".
|
||||
*/
|
||||
careCheckIns?: boolean | "gentle";
|
||||
};
|
||||
|
||||
export type CommitmentExtractionConfig = {
|
||||
/** Enable the background LLM extractor. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Optional model override (provider/model) for extractor runs. Defaults to the agent model. */
|
||||
model?: string;
|
||||
/** Debounce before draining queued extraction items. Default: 15000. */
|
||||
debounceMs?: number;
|
||||
/** Max extraction items per model call. Default: 8. */
|
||||
batchMaxItems?: number;
|
||||
/** Minimum confidence accepted for routine inferred commitments. Default: 0.72. */
|
||||
confidenceThreshold?: number;
|
||||
/** Minimum confidence accepted for care check-ins. Default: 0.86. */
|
||||
careConfidenceThreshold?: number;
|
||||
/** Extractor run timeout in seconds. Default: 45. */
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
|
||||
export type CommitmentDeliveryConfig = {
|
||||
/** Max due commitments injected into one heartbeat turn. Default: 3. */
|
||||
maxPerHeartbeat?: number;
|
||||
/** Pending commitments older than this after latest due time are expired. Default: 72. */
|
||||
expireAfterHours?: number;
|
||||
};
|
||||
|
||||
export type CommitmentsConfig = {
|
||||
/** Enable inferred commitment creation and heartbeat delivery. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Optional JSON store path. Defaults to ~/.openclaw/commitments/commitments.json. */
|
||||
store?: string;
|
||||
categories?: CommitmentCategoryConfig;
|
||||
extraction?: CommitmentExtractionConfig;
|
||||
delivery?: CommitmentDeliveryConfig;
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import type { DiagnosticsConfig, LoggingConfig, SessionConfig, WebConfig } from
|
||||
import type { BrowserConfig } from "./types.browser.js";
|
||||
import type { ChannelsConfig } from "./types.channels.js";
|
||||
import type { CliConfig } from "./types.cli.js";
|
||||
import type { CommitmentsConfig } from "./types.commitments.js";
|
||||
import type { CrestodianConfig } from "./types.crestodian.js";
|
||||
import type { CronConfig } from "./types.cron.js";
|
||||
import type {
|
||||
@@ -129,6 +130,7 @@ export type OpenClawConfig = {
|
||||
web?: WebConfig;
|
||||
channels?: ChannelsConfig;
|
||||
cron?: CronConfig;
|
||||
commitments?: CommitmentsConfig;
|
||||
hooks?: HooksConfig;
|
||||
discovery?: DiscoveryConfig;
|
||||
canvasHost?: CanvasHostConfig;
|
||||
|
||||
@@ -9,6 +9,7 @@ export * from "./types.base.js";
|
||||
export * from "./types.browser.js";
|
||||
export * from "./types.channels.js";
|
||||
export * from "./types.cli.js";
|
||||
export * from "./types.commitments.js";
|
||||
export * from "./types.openclaw.js";
|
||||
export * from "./types.cron.js";
|
||||
export * from "./types.discord.js";
|
||||
|
||||
@@ -254,6 +254,42 @@ const CrestodianSchema = z
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const CommitmentsSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
store: z.string().optional(),
|
||||
categories: z
|
||||
.object({
|
||||
eventCheckIns: z.boolean().optional(),
|
||||
deadlineCheckIns: z.boolean().optional(),
|
||||
openLoops: z.boolean().optional(),
|
||||
careCheckIns: z.union([z.boolean(), z.literal("gentle")]).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
extraction: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
model: z.string().optional(),
|
||||
debounceMs: z.number().int().nonnegative().optional(),
|
||||
batchMaxItems: z.number().int().positive().optional(),
|
||||
confidenceThreshold: z.number().min(0).max(1).optional(),
|
||||
careConfidenceThreshold: z.number().min(0).max(1).optional(),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
delivery: z
|
||||
.object({
|
||||
maxPerHeartbeat: z.number().int().positive().optional(),
|
||||
expireAfterHours: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
export const OpenClawSchema = z
|
||||
.object({
|
||||
$schema: z.string().optional(),
|
||||
@@ -648,6 +684,7 @@ export const OpenClawSchema = z
|
||||
}
|
||||
})
|
||||
.optional(),
|
||||
commitments: CommitmentsSchema,
|
||||
hooks: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
|
||||
138
src/infra/heartbeat-runner.commitments.test.ts
Normal file
138
src/infra/heartbeat-runner.commitments.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
|
||||
import { loadCommitmentStore, saveCommitmentStore } from "../commitments/store.js";
|
||||
import type { CommitmentRecord } from "../commitments/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { runHeartbeatOnce } from "./heartbeat-runner.js";
|
||||
import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js";
|
||||
import { seedSessionStore, withTempHeartbeatSandbox } from "./heartbeat-runner.test-utils.js";
|
||||
|
||||
installHeartbeatRunnerTestRuntime();
|
||||
|
||||
describe("runHeartbeatOnce commitments", () => {
|
||||
const nowMs = Date.parse("2026-04-29T17:00:00.000Z");
|
||||
|
||||
function buildCommitment(params: {
|
||||
id: string;
|
||||
sessionKey: string;
|
||||
to: string;
|
||||
}): CommitmentRecord {
|
||||
return {
|
||||
id: params.id,
|
||||
agentId: "main",
|
||||
sessionKey: params.sessionKey,
|
||||
channel: "telegram",
|
||||
accountId: "primary",
|
||||
to: params.to,
|
||||
kind: "event_check_in",
|
||||
sensitivity: "routine",
|
||||
source: "inferred_user_context",
|
||||
status: "pending",
|
||||
reason: "The user said they had an interview yesterday.",
|
||||
suggestedText: "How did the interview go?",
|
||||
dedupeKey: "interview:2026-04-28",
|
||||
confidence: 0.92,
|
||||
dueWindow: {
|
||||
earliestMs: nowMs - 60_000,
|
||||
latestMs: nowMs + 60 * 60_000,
|
||||
timezone: "America/Los_Angeles",
|
||||
},
|
||||
sourceUserText: "I have an interview tomorrow.",
|
||||
sourceAssistantText: "Good luck, I hope it goes well.",
|
||||
createdAtMs: nowMs - 24 * 60 * 60_000,
|
||||
updatedAtMs: nowMs - 24 * 60 * 60_000,
|
||||
attempts: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function setupCommitmentCase(params?: { replyText?: string }) {
|
||||
return await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
|
||||
const commitmentStorePath = path.join(tmpDir, "commitments.json");
|
||||
const sessionKey = "agent:main:telegram:user-155462274";
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: tmpDir,
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: { telegram: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
commitments: { store: commitmentStorePath },
|
||||
};
|
||||
await seedSessionStore(storePath, sessionKey, {
|
||||
lastChannel: "telegram",
|
||||
lastProvider: "telegram",
|
||||
lastTo: "stale-target",
|
||||
});
|
||||
await saveCommitmentStore(commitmentStorePath, {
|
||||
version: 1,
|
||||
commitments: [buildCommitment({ id: "cm_interview", sessionKey, to: "155462274" })],
|
||||
});
|
||||
|
||||
const sendTelegram = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
chatId: "155462274",
|
||||
});
|
||||
replySpy.mockImplementation(
|
||||
async (ctx: { Body?: string; OriginatingChannel?: string; OriginatingTo?: string }) => {
|
||||
expect(ctx.Body).toContain("Due inferred follow-up commitments");
|
||||
expect(ctx.Body).toContain("How did the interview go?");
|
||||
expect(ctx.OriginatingChannel).toBe("telegram");
|
||||
expect(ctx.OriginatingTo).toBe("155462274");
|
||||
return { text: params?.replyText ?? "How did the interview go?" };
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runHeartbeatOnce({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
sessionKey,
|
||||
deps: {
|
||||
getReplyFromConfig: replySpy,
|
||||
telegram: sendTelegram,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => nowMs,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
result,
|
||||
sendTelegram,
|
||||
store: await loadCommitmentStore(commitmentStorePath),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
it("delivers due commitments to the original scope even when heartbeat target is none", async () => {
|
||||
const { result, sendTelegram, store } = await setupCommitmentCase();
|
||||
|
||||
expect(result.status).toBe("ran");
|
||||
expect(sendTelegram).toHaveBeenCalled();
|
||||
expect(store.commitments[0]).toMatchObject({
|
||||
id: "cm_interview",
|
||||
status: "sent",
|
||||
attempts: 1,
|
||||
sentAtMs: nowMs,
|
||||
});
|
||||
});
|
||||
|
||||
it("dismisses a due commitment when the heartbeat model declines to send a check-in", async () => {
|
||||
const { result, sendTelegram, store } = await setupCommitmentCase({
|
||||
replyText: HEARTBEAT_TOKEN,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ran");
|
||||
expect(sendTelegram).not.toHaveBeenCalled();
|
||||
expect(store.commitments[0]).toMatchObject({
|
||||
id: "cm_interview",
|
||||
status: "dismissed",
|
||||
attempts: 1,
|
||||
dismissedAtMs: nowMs,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,13 @@ import type {
|
||||
ChannelId,
|
||||
ChannelPlugin,
|
||||
} from "../channels/plugins/types.public.js";
|
||||
import {
|
||||
listDueCommitmentsForSession,
|
||||
listDueCommitmentSessionKeys,
|
||||
markCommitmentsAttempted,
|
||||
markCommitmentsStatus,
|
||||
} from "../commitments/store.js";
|
||||
import type { CommitmentRecord } from "../commitments/types.js";
|
||||
import { getRuntimeConfig } from "../config/config.js";
|
||||
import {
|
||||
canonicalizeMainSessionAlias,
|
||||
@@ -576,10 +583,68 @@ type HeartbeatReasonFlags = {
|
||||
|
||||
type HeartbeatSkipReason = "empty-heartbeat-file";
|
||||
|
||||
function truncateCommitmentText(text: string | undefined, maxChars: number): string | undefined {
|
||||
const trimmed = text?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed.length <= maxChars ? trimmed : `${trimmed.slice(0, maxChars - 1)}...`;
|
||||
}
|
||||
|
||||
function buildCommitmentDeliveryKey(commitment: CommitmentRecord): string {
|
||||
return [
|
||||
commitment.channel,
|
||||
commitment.accountId ?? "",
|
||||
commitment.to ?? "",
|
||||
commitment.threadId ?? "",
|
||||
commitment.senderId ?? "",
|
||||
].join("\u001f");
|
||||
}
|
||||
|
||||
function selectCommitmentDeliveryBatch(commitments: CommitmentRecord[]): CommitmentRecord[] {
|
||||
const first = commitments.toSorted(
|
||||
(a, b) => a.dueWindow.earliestMs - b.dueWindow.earliestMs || a.createdAtMs - b.createdAtMs,
|
||||
)[0];
|
||||
if (!first) {
|
||||
return [];
|
||||
}
|
||||
const key = buildCommitmentDeliveryKey(first);
|
||||
return commitments.filter((commitment) => buildCommitmentDeliveryKey(commitment) === key);
|
||||
}
|
||||
|
||||
function buildCommitmentHeartbeatPrompt(commitments: CommitmentRecord[]): string | null {
|
||||
if (commitments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const items = commitments.map((commitment) => ({
|
||||
kind: commitment.kind,
|
||||
sensitivity: commitment.sensitivity,
|
||||
source: commitment.source,
|
||||
reason: commitment.reason,
|
||||
suggestedText: commitment.suggestedText,
|
||||
due: {
|
||||
earliest: new Date(commitment.dueWindow.earliestMs).toISOString(),
|
||||
latest: new Date(commitment.dueWindow.latestMs).toISOString(),
|
||||
timezone: commitment.dueWindow.timezone,
|
||||
},
|
||||
sourceUserText: truncateCommitmentText(commitment.sourceUserText, 240),
|
||||
sourceAssistantText: truncateCommitmentText(commitment.sourceAssistantText, 240),
|
||||
}));
|
||||
return `Due inferred follow-up commitments are available for this exact agent and channel scope.
|
||||
|
||||
These are not exact reminders. They were inferred from prior conversation context and should feel natural, brief, and optional.
|
||||
|
||||
If a check-in would be useful now, send at most one concise message in this channel. If none should be sent, reply HEARTBEAT_OK. Do not mention commitments, ledgers, inference, or scheduling machinery.
|
||||
|
||||
Commitments:
|
||||
${JSON.stringify(items, null, 2)}`;
|
||||
}
|
||||
|
||||
type HeartbeatPreflight = HeartbeatReasonFlags & {
|
||||
session: ReturnType<typeof resolveHeartbeatSession>;
|
||||
pendingEventEntries: ReturnType<typeof peekSystemEventEntries>;
|
||||
turnSourceDeliveryContext: ReturnType<typeof resolveSystemEventDeliveryContext>;
|
||||
dueCommitments: CommitmentRecord[];
|
||||
hasTaggedCronEvents: boolean;
|
||||
shouldInspectPendingEvents: boolean;
|
||||
skipReason?: HeartbeatSkipReason;
|
||||
@@ -602,6 +667,7 @@ async function resolveHeartbeatPreflight(params: {
|
||||
heartbeat?: HeartbeatConfig;
|
||||
forcedSessionKey?: string;
|
||||
reason?: string;
|
||||
nowMs?: number;
|
||||
}): Promise<HeartbeatPreflight> {
|
||||
const reasonFlags = resolveHeartbeatReasonFlags(params.reason);
|
||||
const session = resolveHeartbeatSession(
|
||||
@@ -611,6 +677,14 @@ async function resolveHeartbeatPreflight(params: {
|
||||
params.forcedSessionKey,
|
||||
);
|
||||
const pendingEventEntries = peekSystemEventEntries(session.sessionKey);
|
||||
const dueCommitments = selectCommitmentDeliveryBatch(
|
||||
await listDueCommitmentsForSession({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
sessionKey: session.sessionKey,
|
||||
nowMs: params.nowMs,
|
||||
}),
|
||||
);
|
||||
const turnSourceDeliveryContext = resolveSystemEventDeliveryContext(pendingEventEntries);
|
||||
const hasTaggedCronEvents = pendingEventEntries.some((event) =>
|
||||
event.contextKey?.startsWith("cron:"),
|
||||
@@ -641,12 +715,14 @@ async function resolveHeartbeatPreflight(params: {
|
||||
reasonFlags.isExecEventReason ||
|
||||
reasonFlags.isCronEventReason ||
|
||||
reasonFlags.isWakeReason ||
|
||||
hasTaggedCronEvents;
|
||||
hasTaggedCronEvents ||
|
||||
dueCommitments.length > 0;
|
||||
const basePreflight = {
|
||||
...reasonFlags,
|
||||
session,
|
||||
pendingEventEntries,
|
||||
turnSourceDeliveryContext,
|
||||
dueCommitments,
|
||||
hasTaggedCronEvents,
|
||||
shouldInspectPendingEvents,
|
||||
} satisfies Omit<HeartbeatPreflight, "skipReason">;
|
||||
@@ -693,6 +769,7 @@ type HeartbeatPromptResolution = {
|
||||
hasExecCompletion: boolean;
|
||||
hasRelayableExecCompletion: boolean;
|
||||
hasCronEvents: boolean;
|
||||
hasDueCommitments: boolean;
|
||||
};
|
||||
|
||||
function appendHeartbeatWorkspacePathHint(prompt: string, workspaceDir: string): string {
|
||||
@@ -762,6 +839,8 @@ function resolveHeartbeatRunPrompt(params: {
|
||||
const hasRelayableExecCompletion =
|
||||
params.canRelayToUser && execEvents.some((event) => isRelayableExecCompletionEvent(event));
|
||||
const hasCronEvents = cronEvents.length > 0;
|
||||
const commitmentPrompt = buildCommitmentHeartbeatPrompt(params.preflight.dueCommitments);
|
||||
const hasDueCommitments = Boolean(commitmentPrompt);
|
||||
|
||||
if (params.preflight.tasks && params.preflight.tasks.length > 0) {
|
||||
const tasks = params.preflight.tasks;
|
||||
@@ -787,11 +866,24 @@ After completing all due tasks, reply HEARTBEAT_OK.`;
|
||||
prompt += `\n\nAdditional context from HEARTBEAT.md:\n${directives}`;
|
||||
}
|
||||
}
|
||||
if (commitmentPrompt) {
|
||||
prompt += `\n\n${commitmentPrompt}`;
|
||||
}
|
||||
return {
|
||||
prompt,
|
||||
hasExecCompletion: false,
|
||||
hasRelayableExecCompletion: false,
|
||||
hasCronEvents: false,
|
||||
hasDueCommitments,
|
||||
};
|
||||
}
|
||||
if (commitmentPrompt) {
|
||||
return {
|
||||
prompt: commitmentPrompt,
|
||||
hasExecCompletion: false,
|
||||
hasRelayableExecCompletion: false,
|
||||
hasCronEvents: false,
|
||||
hasDueCommitments,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -799,6 +891,7 @@ After completing all due tasks, reply HEARTBEAT_OK.`;
|
||||
hasExecCompletion: false,
|
||||
hasRelayableExecCompletion: false,
|
||||
hasCronEvents: false,
|
||||
hasDueCommitments: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -807,9 +900,17 @@ After completing all due tasks, reply HEARTBEAT_OK.`;
|
||||
: hasCronEvents
|
||||
? buildCronEventPrompt(cronEvents, { deliverToUser: params.canRelayToUser })
|
||||
: resolveHeartbeatPrompt(params.cfg, params.heartbeat);
|
||||
const prompt = appendHeartbeatWorkspacePathHint(basePrompt, params.workspaceDir);
|
||||
const prompt = commitmentPrompt
|
||||
? `${appendHeartbeatWorkspacePathHint(basePrompt, params.workspaceDir)}\n\n${commitmentPrompt}`
|
||||
: appendHeartbeatWorkspacePathHint(basePrompt, params.workspaceDir);
|
||||
|
||||
return { prompt, hasExecCompletion, hasRelayableExecCompletion, hasCronEvents };
|
||||
return {
|
||||
prompt,
|
||||
hasExecCompletion,
|
||||
hasRelayableExecCompletion,
|
||||
hasCronEvents,
|
||||
hasDueCommitments,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runHeartbeatOnce(opts: {
|
||||
@@ -874,6 +975,7 @@ export async function runHeartbeatOnce(opts: {
|
||||
heartbeat,
|
||||
forcedSessionKey: opts.sessionKey,
|
||||
reason: opts.reason,
|
||||
nowMs: startedAt,
|
||||
});
|
||||
if (preflight.skipReason) {
|
||||
emitHeartbeatEvent({
|
||||
@@ -906,15 +1008,31 @@ export async function runHeartbeatOnce(opts: {
|
||||
// sending the full conversation history (~100K tokens) to the LLM.
|
||||
// Delivery routing still uses the main session entry (lastChannel, lastTo).
|
||||
const useIsolatedSession = heartbeat?.isolatedSession === true;
|
||||
const firstDueCommitment = preflight.dueCommitments[0];
|
||||
const commitmentDeliveryContext = firstDueCommitment
|
||||
? {
|
||||
channel: firstDueCommitment.channel,
|
||||
to: firstDueCommitment.to,
|
||||
accountId: firstDueCommitment.accountId,
|
||||
threadId: firstDueCommitment.threadId,
|
||||
}
|
||||
: undefined;
|
||||
const heartbeatForDelivery = commitmentDeliveryContext
|
||||
? { ...heartbeat, target: "last", to: undefined, accountId: undefined }
|
||||
: heartbeat;
|
||||
const delivery = resolveHeartbeatDeliveryTarget({
|
||||
cfg,
|
||||
entry,
|
||||
heartbeat,
|
||||
heartbeat: heartbeatForDelivery,
|
||||
// Isolated heartbeat runs drain system events from their dedicated
|
||||
// `:heartbeat` session, not from the base session we peek during preflight.
|
||||
// Reusing base-session turnSource routing here can pin later isolated runs
|
||||
// to stale channels/threads because that base-session event context remains queued.
|
||||
turnSource: useIsolatedSession ? undefined : preflight.turnSourceDeliveryContext,
|
||||
turnSource: commitmentDeliveryContext
|
||||
? commitmentDeliveryContext
|
||||
: useIsolatedSession
|
||||
? undefined
|
||||
: preflight.turnSourceDeliveryContext,
|
||||
});
|
||||
const heartbeatAccountId = heartbeat?.accountId?.trim();
|
||||
if (delivery.reason === "unknown-account") {
|
||||
@@ -947,16 +1065,24 @@ export async function runHeartbeatOnce(opts: {
|
||||
delivery.channel !== "none" && delivery.to && visibility.showAlerts,
|
||||
);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const { prompt, hasExecCompletion, hasRelayableExecCompletion, hasCronEvents } =
|
||||
resolveHeartbeatRunPrompt({
|
||||
cfg,
|
||||
heartbeat,
|
||||
preflight,
|
||||
canRelayToUser,
|
||||
workspaceDir,
|
||||
startedAt,
|
||||
heartbeatFileContent: preflight.heartbeatFileContent,
|
||||
});
|
||||
const {
|
||||
prompt,
|
||||
hasExecCompletion,
|
||||
hasRelayableExecCompletion,
|
||||
hasCronEvents,
|
||||
hasDueCommitments,
|
||||
} = resolveHeartbeatRunPrompt({
|
||||
cfg,
|
||||
heartbeat,
|
||||
preflight,
|
||||
canRelayToUser,
|
||||
workspaceDir,
|
||||
startedAt,
|
||||
heartbeatFileContent: preflight.heartbeatFileContent,
|
||||
});
|
||||
const dueCommitmentIds = hasDueCommitments
|
||||
? preflight.dueCommitments.map((commitment) => commitment.id)
|
||||
: [];
|
||||
|
||||
// If no tasks are due, skip heartbeat entirely
|
||||
if (prompt === null) {
|
||||
@@ -1099,6 +1225,11 @@ export async function runHeartbeatOnce(opts: {
|
||||
});
|
||||
return { status: "skipped", reason: "alerts-disabled" };
|
||||
}
|
||||
await markCommitmentsAttempted({
|
||||
cfg,
|
||||
ids: dueCommitmentIds,
|
||||
nowMs: startedAt,
|
||||
});
|
||||
|
||||
const heartbeatOkText = responsePrefix ? `${responsePrefix} ${HEARTBEAT_TOKEN}` : HEARTBEAT_TOKEN;
|
||||
const outboundSession = buildOutboundSessionContext({
|
||||
@@ -1107,7 +1238,7 @@ export async function runHeartbeatOnce(opts: {
|
||||
sessionKey,
|
||||
});
|
||||
const canAttemptHeartbeatOk = Boolean(
|
||||
visibility.showOk && delivery.channel !== "none" && delivery.to,
|
||||
!hasDueCommitments && visibility.showOk && delivery.channel !== "none" && delivery.to,
|
||||
);
|
||||
const hasChatDelivery = Boolean(
|
||||
delivery.channel !== "none" && delivery.to && (visibility.showAlerts || visibility.showOk),
|
||||
@@ -1207,6 +1338,12 @@ export async function runHeartbeatOnce(opts: {
|
||||
silent: !okSent,
|
||||
indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-empty") : undefined,
|
||||
});
|
||||
await markCommitmentsStatus({
|
||||
cfg,
|
||||
ids: dueCommitmentIds,
|
||||
status: "dismissed",
|
||||
nowMs: startedAt,
|
||||
});
|
||||
await updateTaskTimestamps();
|
||||
consumeInspectedSystemEvents();
|
||||
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||
@@ -1245,6 +1382,12 @@ export async function runHeartbeatOnce(opts: {
|
||||
silent: !okSent,
|
||||
indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-token") : undefined,
|
||||
});
|
||||
await markCommitmentsStatus({
|
||||
cfg,
|
||||
ids: dueCommitmentIds,
|
||||
status: "dismissed",
|
||||
nowMs: startedAt,
|
||||
});
|
||||
await updateTaskTimestamps();
|
||||
consumeInspectedSystemEvents();
|
||||
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||
@@ -1282,6 +1425,12 @@ export async function runHeartbeatOnce(opts: {
|
||||
channel: delivery.channel !== "none" ? delivery.channel : undefined,
|
||||
accountId: delivery.accountId,
|
||||
});
|
||||
await markCommitmentsStatus({
|
||||
cfg,
|
||||
ids: dueCommitmentIds,
|
||||
status: "dismissed",
|
||||
nowMs: startedAt,
|
||||
});
|
||||
await updateTaskTimestamps();
|
||||
consumeInspectedSystemEvents();
|
||||
return { status: "ran", durationMs: Date.now() - startedAt };
|
||||
@@ -1376,6 +1525,12 @@ export async function runHeartbeatOnce(opts: {
|
||||
],
|
||||
deps: opts.deps,
|
||||
});
|
||||
await markCommitmentsStatus({
|
||||
cfg,
|
||||
ids: dueCommitmentIds,
|
||||
status: shouldSkipMain ? "dismissed" : "sent",
|
||||
nowMs: startedAt,
|
||||
});
|
||||
|
||||
// Record last delivered heartbeat payload for dedupe.
|
||||
if (!shouldSkipMain && normalized.text.trim()) {
|
||||
@@ -1664,6 +1819,47 @@ export function startHeartbeatRunner(opts: {
|
||||
if (res.status === "ran") {
|
||||
ran = true;
|
||||
}
|
||||
|
||||
const defaultSessionKey = resolveHeartbeatSession(
|
||||
state.cfg,
|
||||
agent.agentId,
|
||||
agent.heartbeat,
|
||||
).sessionKey;
|
||||
const dueSessionKeys = await listDueCommitmentSessionKeys({
|
||||
cfg: state.cfg,
|
||||
agentId: agent.agentId,
|
||||
nowMs: now,
|
||||
limit: 10,
|
||||
});
|
||||
for (const dueSessionKey of dueSessionKeys) {
|
||||
if (dueSessionKey === defaultSessionKey) {
|
||||
continue;
|
||||
}
|
||||
let commitmentRes: HeartbeatRunResult;
|
||||
try {
|
||||
commitmentRes = await runOnce({
|
||||
cfg: state.cfg,
|
||||
agentId: agent.agentId,
|
||||
heartbeat: agent.heartbeat,
|
||||
reason: "commitment",
|
||||
sessionKey: dueSessionKey,
|
||||
deps: { runtime: state.runtime },
|
||||
});
|
||||
} catch (err) {
|
||||
const errMsg = formatErrorMessage(err);
|
||||
log.error(`heartbeat runner: commitment runOnce threw unexpectedly: ${errMsg}`, {
|
||||
error: errMsg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (commitmentRes.status === "skipped" && commitmentRes.reason === "requests-in-flight") {
|
||||
requestsInFlight = true;
|
||||
return commitmentRes;
|
||||
}
|
||||
if (commitmentRes.status === "ran") {
|
||||
ran = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ran) {
|
||||
|
||||
Reference in New Issue
Block a user