From 8e4035d09a88a016321310681074750cbb556e93 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Wed, 29 Apr 2026 00:29:49 -0700 Subject: [PATCH] Agents: add inferred commitments --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 4 +- src/auto-reply/reply/agent-runner.ts | 79 ++++ .../register.status-health-sessions.test.ts | 36 ++ .../register.status-health-sessions.ts | 74 +++ src/commands/commitments.ts | 150 ++++++ src/commands/doctor-config-flow.test.ts | 40 ++ src/commitments/config.ts | 106 +++++ src/commitments/extraction.test.ts | 175 +++++++ src/commitments/extraction.ts | 362 +++++++++++++++ src/commitments/runtime.test.ts | 118 +++++ src/commitments/runtime.ts | 258 +++++++++++ src/commitments/store.ts | 433 ++++++++++++++++++ src/commitments/types.ts | 90 ++++ src/config/schema.base.generated.ts | 242 ++++++++++ src/config/schema.help.quality.test.ts | 1 + src/config/schema.help.ts | 37 ++ src/config/schema.labels.ts | 19 + src/config/types.commitments.ts | 47 ++ src/config/types.openclaw.ts | 2 + src/config/types.ts | 1 + src/config/zod-schema.ts | 37 ++ .../heartbeat-runner.commitments.test.ts | 138 ++++++ src/infra/heartbeat-runner.ts | 228 ++++++++- 24 files changed, 2660 insertions(+), 18 deletions(-) create mode 100644 src/commands/commitments.ts create mode 100644 src/commitments/config.ts create mode 100644 src/commitments/extraction.test.ts create mode 100644 src/commitments/extraction.ts create mode 100644 src/commitments/runtime.test.ts create mode 100644 src/commitments/runtime.ts create mode 100644 src/commitments/store.ts create mode 100644 src/commitments/types.ts create mode 100644 src/config/types.commitments.ts create mode 100644 src/infra/heartbeat-runner.commitments.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e135ede3164..7624c4e06ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 9903f0ecc6f..4cdcfecdc71 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -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 diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 16802e5c83d..22128a731fa 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -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)) { diff --git a/src/cli/program/register.status-health-sessions.test.ts b/src/cli/program/register.status-health-sessions.test.ts index 195fc809206..b4d62771182 100644 --- a/src/cli/program/register.status-health-sessions.test.ts +++ b/src/cli/program/register.status-health-sessions.test.ts @@ -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); diff --git a/src/cli/program/register.status-health-sessions.ts b/src/cli/program/register.status-health-sessions.ts index cfb1c731e2f..1014626e73f 100644 --- a/src/cli/program/register.status-health-sessions.ts +++ b/src/cli/program/register.status-health-sessions.ts @@ -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 ", "Agent id to inspect") + .option("--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 ", "Agent id to inspect") + .option("--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 ") + .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") diff --git a/src/commands/commitments.ts b/src/commands/commitments.ts new file mode 100644 index 00000000000..3b0e1b5a08d --- /dev/null +++ b/src/commands/commitments.ts @@ -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([ + "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 { + 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 { + 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(", ")}`)); +} diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index c42895b5e63..e7f961a85c1 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -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, diff --git a/src/commitments/config.ts b/src/commitments/config.ts new file mode 100644 index 00000000000..8fea8a202b8 --- /dev/null +++ b/src/commitments/config.ts @@ -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); +} diff --git a/src/commitments/extraction.test.ts b/src/commitments/extraction.test.ts new file mode 100644 index 00000000000..b18786afc3f --- /dev/null +++ b/src/commitments/extraction.test.ts @@ -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 { + 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 { + 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 { + 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", + }); + }); +}); diff --git a/src/commitments/extraction.ts b/src/commitments/extraction.ts new file mode 100644 index 00000000000..ab38548eb0c --- /dev/null +++ b/src/commitments/extraction.ts @@ -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([ + "event_check_in", + "deadline_check", + "care_check_in", + "open_loop", +]); +const SENSITIVITY_VALUES = new Set(["routine", "personal", "care"]); +const SOURCE_VALUES = new Set(["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[] = []; + 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; +}): Promise { + 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(); + 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; +} diff --git a/src/commitments/runtime.test.ts b/src/commitments/runtime.test.ts new file mode 100644 index 00000000000..6e330512b06 --- /dev/null +++ b/src/commitments/runtime.test.ts @@ -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 { + 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, + 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", + ]); + }); +}); diff --git a/src/commitments/runtime.ts b/src/commitments/runtime.ts new file mode 100644 index 00000000000..c3ceec6a064 --- /dev/null +++ b/src/commitments/runtime.ts @@ -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; + +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; + setTimer?: (callback: () => void, delayMs: number) => TimerHandle; + clearTimer?: (timer: TimerHandle) => void; + forceInTests?: boolean; +}; + +const log = createSubsystemLogger("commitments"); + +let runtime: CommitmentExtractionRuntime = {}; +let queue: Array & { 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 { + 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 & { cfg?: OpenClawConfig }>, +): Promise { + return Promise.all( + batch.map(async (item) => + hydrateCommitmentExtractionItem({ + cfg: item.cfg, + item, + }), + ), + ); +} + +export async function drainCommitmentExtractionQueue(): Promise { + 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; + } +} diff --git a/src/commitments/store.ts b/src/commitments/store.ts new file mode 100644 index 00000000000..af0b7aeec3c --- /dev/null +++ b/src/commitments/store.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + 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 { + 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; + nowMs?: number; +}): Promise { + 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 { + 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["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; + } +} diff --git a/src/commitments/types.ts b/src/commitments/types.ts new file mode 100644 index 00000000000..3830bd77444 --- /dev/null +++ b/src/commitments/types.ts @@ -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[]; +}; diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index f19ce1959d7..c7691ca557a 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -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.", diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 6d910d1f402..6c0add4dcd3 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -11,6 +11,7 @@ const ROOT_SECTIONS = [ "logging", "cli", "update", + "commitments", "browser", "ui", "auth", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 2438d4e3872..332cc2f3ad0 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -190,6 +190,43 @@ export const FIELD_HELP: Record = { "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": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 5a1f3451ec4..e421a3879c6 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -35,6 +35,25 @@ export const FIELD_LABELS: Record = { "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)", diff --git a/src/config/types.commitments.ts b/src/config/types.commitments.ts new file mode 100644 index 00000000000..8406e4def88 --- /dev/null +++ b/src/config/types.commitments.ts @@ -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; +}; diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 756bc7095fb..f6852855b32 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -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; diff --git a/src/config/types.ts b/src/config/types.ts index 524b9e91397..e508d7998fc 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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"; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index a2a8ac55924..b191979ef59 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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(), diff --git a/src/infra/heartbeat-runner.commitments.test.ts b/src/infra/heartbeat-runner.commitments.test.ts new file mode 100644 index 00000000000..ce15ad42b01 --- /dev/null +++ b/src/infra/heartbeat-runner.commitments.test.ts @@ -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, + }); + }); +}); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 6203018f167..e7baa1b8162 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -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; pendingEventEntries: ReturnType; turnSourceDeliveryContext: ReturnType; + 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 { 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; @@ -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) {