openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
450 lines (444 loc) • 17 kB
JavaScript
import { c as normalizeOptionalString } from "./string-coerce-mnp54Vah.js";
import { C as resolveExpiresAtMsFromDurationMs, P as timestampMsToIsoString, s as asFiniteNumber } from "./number-coercion-CJQ8TR--.js";
import { y as resolveStateDir } from "./paths-mvMm5bYV.js";
import { c as isRecord } from "./utils-CCC-BEJH.js";
import { t as createSubsystemLogger } from "./subsystem-BzXSmsuh.js";
import "./agent-scope-MrLta7Pq.js";
import { o as resolveAgentWorkspaceDir, r as resolveAgentConfig } from "./agent-scope-config-CgCYpZfK.js";
import { n as resolveHeartbeatIntervalMs } from "./heartbeat-summary-B73O--Iv.js";
import { d as resolveCommitmentTimezone, f as resolveCommitmentsConfig, i as listPendingCommitmentsForScope, u as upsertInferredCommitments } from "./store-Bdr470Sd.js";
import path from "node:path";
import { randomUUID } from "node:crypto";
//#region src/commitments/extraction.ts
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 asNumber(value) {
return asFiniteNumber(value);
}
function parseCandidate(raw) {
if (!isRecord(raw)) return;
if (raw.action === "skip") return;
const itemId = normalizeOptionalString(raw.itemId);
const kind = normalizeOptionalString(raw.kind);
const sensitivity = normalizeOptionalString(raw.sensitivity);
const source = normalizeOptionalString(raw.source) ?? "inferred_user_context";
const reason = normalizeOptionalString(raw.reason);
const suggestedText = normalizeOptionalString(raw.suggestedText);
const dedupeKey = normalizeOptionalString(raw.dedupeKey);
const confidence = asNumber(raw.confidence);
const dueWindow = isRecord(raw.dueWindow) ? raw.dueWindow : void 0;
const earliest = normalizeOptionalString(dueWindow?.earliest);
const latest = normalizeOptionalString(dueWindow?.latest);
const timezone = normalizeOptionalString(dueWindow?.timezone);
if (!itemId || !KIND_VALUES.has(kind) || !SENSITIVITY_VALUES.has(sensitivity) || !SOURCE_VALUES.has(source) || !reason || !suggestedText || !dedupeKey || confidence === void 0 || !earliest) return;
return {
itemId,
kind,
sensitivity,
source,
reason,
suggestedText,
dedupeKey,
confidence,
dueWindow: {
earliest,
...latest ? { latest } : {},
...timezone ? { timezone } : {}
}
};
}
function extractJsonObjectCandidates(raw) {
const out = [];
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;
}
function parseCommitmentExtractionOutput(raw) {
const candidates = [];
const trimmed = raw.trim();
if (!trimmed) return { candidates };
const records = [];
try {
const parsed = JSON.parse(trimmed);
if (isRecord(parsed)) records.push(parsed);
} catch {
for (const candidate of extractJsonObjectCandidates(trimmed)) try {
const parsed = JSON.parse(candidate);
if (isRecord(parsed)) records.push(parsed);
} catch {}
}
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 };
}
async function hydrateCommitmentExtractionItem(params) {
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) {
return item.existingPending.flatMap((commitment) => {
const earliest = timestampMsToIsoString(commitment.earliestMs);
const latest = timestampMsToIsoString(commitment.latestMs);
if (!earliest || !latest) return [];
return [{
kind: commitment.kind,
reason: commitment.reason,
dedupeKey: commitment.dedupeKey,
earliest,
latest
}];
});
}
function formatExtractionNow(valueMs) {
return timestampMsToIsoString(valueMs) ?? timestampMsToIsoString(Date.now()) ?? "1970-01-01T00:00:00.000Z";
}
function buildCommitmentExtractionPrompt(params) {
const items = params.items.map((item) => ({
itemId: item.itemId,
now: formatExtractionNow(item.nowMs),
timezone: item.timezone,
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: event_check_in, deadline_check, care_check_in, open_loop.
Create a candidate only when the latest exchange creates a useful future check-in opportunity that the user did not explicitly schedule. Prefer no candidate over weak candidates.
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) {
if (!raw) return;
const parsed = Date.parse(raw);
return Number.isFinite(parsed) ? parsed : void 0;
}
function resolveMinimumDueMs(params) {
const cfg = params.cfg ?? {};
const defaults = cfg.agents?.defaults?.heartbeat;
const overrides = resolveAgentConfig(cfg, params.item.agentId)?.heartbeat;
const intervalMs = resolveHeartbeatIntervalMs(cfg, void 0, defaults || overrides ? {
...defaults,
...overrides
} : void 0) ?? 0;
return params.nowMs + intervalMs;
}
function validateCommitmentCandidates(params) {
const resolved = resolveCommitmentsConfig(params.cfg);
const itemsById = new Map(params.items.map((item) => [item.itemId, item]));
const nowMs = params.nowMs ?? Date.now();
const validated = [];
for (const candidate of params.result.candidates) {
const item = itemsById.get(candidate.itemId);
if (!item) 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 === void 0 || extractedEarliestMs <= item.nowMs) continue;
const earliestMs = Math.max(extractedEarliestMs, resolveMinimumDueMs({
cfg: params.cfg,
item,
nowMs
}));
const latestRawMs = parseDueMs(candidate.dueWindow.latest);
const latestMs = latestRawMs !== void 0 && latestRawMs >= earliestMs ? latestRawMs : earliestMs + 720 * 60 * 1e3;
validated.push({
item,
candidate,
earliestMs,
latestMs,
timezone: candidate.dueWindow.timezone ?? item.timezone
});
}
return validated;
}
async function persistCommitmentExtractionResult(params) {
const valid = validateCommitmentCandidates(params);
const byItem = /* @__PURE__ */ 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;
}
//#endregion
//#region src/commitments/runtime.ts
const log = createSubsystemLogger("commitments");
const TERMINAL_EXTRACTION_FAILURE_COOLDOWN_MS = 15 * 6e4;
let runtime = {};
let queue = [];
let timer = null;
let draining = false;
let queueOverflowWarned = false;
let terminalFailureCooldownUntilByAgent = /* @__PURE__ */ new Map();
function shouldDisableBackgroundExtractionForTests() {
if (runtime.forceInTests) return false;
return process.env.VITEST === "true" || false;
}
function setTimer(callback, delayMs) {
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) {
(runtime.clearTimer ?? clearTimeout)(handle);
}
/** Installs runtime hooks for extraction tests or alternate batch extraction. */
function configureCommitmentExtractionRuntime(next) {
runtime = next;
}
/** Clears queued work, timers, and injected hooks for isolated tests. */
function resetCommitmentExtractionRuntimeForTests() {
if (timer) clearTimer(timer);
runtime = {};
queue = [];
timer = null;
draining = false;
queueOverflowWarned = false;
terminalFailureCooldownUntilByAgent = /* @__PURE__ */ new Map();
}
function buildItemId(params, nowMs) {
return `${normalizeOptionalString(params.sourceMessageId) ? "message" : "turn"}:${nowMs.toString(36)}:${randomUUID()}`;
}
function isUsefulText(value) {
return Boolean(value?.trim());
}
/** Enqueues one completed turn for delayed commitment extraction. */
function enqueueCommitmentExtraction(input) {
const resolved = resolveCommitmentsConfig(input.cfg);
const nowMs = input.nowMs ?? Date.now();
const agentId = normalizeOptionalString(input.agentId) ?? "";
const sessionKey = normalizeOptionalString(input.sessionKey) ?? "";
const channel = normalizeOptionalString(input.channel) ?? "";
if (!resolved.enabled || shouldDisableBackgroundExtractionForTests() || (agentId ? nowMs < (terminalFailureCooldownUntilByAgent.get(agentId) ?? 0) : false) || !isUsefulText(input.userText) || !isUsefulText(input.assistantText) || !agentId || !sessionKey || !channel) return false;
if (queue.length >= resolved.extraction.queueMaxItems) {
if (!queueOverflowWarned) {
log.warn("commitment extraction queue full; dropping hidden extraction request", {
queued: queue.length,
max: resolved.extraction.queueMaxItems
});
queueOverflowWarned = true;
}
return false;
}
queue.push({
itemId: buildItemId(input, nowMs),
nowMs,
timezone: resolveCommitmentTimezone(input.cfg),
agentId,
sessionKey,
channel,
...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;
drainCommitmentExtractionQueue().catch((err) => {
log.warn("commitment extraction failed", { error: String(err) });
});
}, resolved.extraction.debounceMs);
return true;
}
function isTerminalExtractionError(error) {
const message = error instanceof Error ? error.message : String(error);
return /\bNo API key found\b/i.test(message) || /\bUnknown model\b/i.test(message) || /\bAuth profile credentials are missing or expired\b/i.test(message) || /\bOAuth token refresh failed\b/i.test(message) || /\bmissing credential\b/i.test(message) || /\bmissing credentials\b/i.test(message) || /\bmissing_api_key\b/i.test(message) || /\binvalid_grant\b/i.test(message);
}
function openTerminalFailureCooldown(agentId, error, nowMs, fallbackNowMs) {
const cooldownUntil = resolveExpiresAtMsFromDurationMs(TERMINAL_EXTRACTION_FAILURE_COOLDOWN_MS, { nowMs }) ?? resolveExpiresAtMsFromDurationMs(TERMINAL_EXTRACTION_FAILURE_COOLDOWN_MS, { nowMs: fallbackNowMs });
if (cooldownUntil !== void 0) terminalFailureCooldownUntilByAgent.set(agentId, cooldownUntil);
queue = queue.filter((item) => item.agentId !== agentId);
log.warn("commitment extraction disabled temporarily after terminal model/auth failure", {
agentId,
cooldownMs: TERMINAL_EXTRACTION_FAILURE_COOLDOWN_MS,
error: String(error)
});
}
function resolveExtractionSessionFile(agentId, runId) {
return path.join(resolveStateDir(), "commitments", "extractor-sessions", agentId, `${runId}.jsonl`);
}
function joinPayloadText(result) {
return result.payloads?.map((payload) => payload.text).filter((text) => Boolean(text?.trim())).join("\n").trim() ?? "";
}
async function resolveDefaultModel(params) {
if (runtime.resolveDefaultModel) return runtime.resolveDefaultModel(params);
const { resolveCommitmentDefaultModelRef } = await import("./model-selection.runtime.js");
return resolveCommitmentDefaultModelRef(params);
}
async function defaultExtractBatch(params) {
const cfg = params.cfg ?? {};
const first = params.items[0];
if (!first) return { candidates: [] };
const resolved = resolveCommitmentsConfig(cfg);
const runId = `commitments-${randomUUID()}`;
const modelRef = await resolveDefaultModel({
cfg,
agentId: first.agentId
});
const { runEmbeddedAgent } = await import("./embedded-agent-By7LMHwp.js");
return parseCommitmentExtractionOutput(joinPayloadText(await runEmbeddedAgent({
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,
provider: modelRef.provider,
model: modelRef.model,
prompt: buildCommitmentExtractionPrompt({
cfg,
items: params.items
}),
disableTools: true,
thinkLevel: "off",
verboseLevel: "off",
reasoningLevel: "off",
fastMode: true,
timeoutMs: resolved.extraction.timeoutSeconds * 1e3,
runId,
bootstrapContextMode: "lightweight",
skillsSnapshot: {
prompt: "",
skills: []
},
suppressToolErrorWarnings: true
})));
}
async function hydrateBatch(batch) {
return Promise.all(batch.map(async (item) => hydrateCommitmentExtractionItem({
cfg: item.cfg,
item
})));
}
/** Drains queued extraction work in batches and returns processed item count. */
async function drainCommitmentExtractionQueue() {
if (draining) return 0;
draining = true;
try {
let processed = 0;
while (queue.length > 0) {
const firstCfg = queue[0]?.cfg;
const resolved = resolveCommitmentsConfig(firstCfg);
const items = await hydrateBatch(queue.splice(0, resolved.extraction.batchMaxItems));
const extractor = runtime.extractBatch ?? defaultExtractBatch;
let result;
try {
result = await extractor({
cfg: firstCfg,
items
});
} catch (error) {
if (isTerminalExtractionError(error)) openTerminalFailureCooldown(items[0]?.agentId ?? "", error, Date.now(), items[0]?.nowMs ?? Date.now());
throw error;
}
await persistCommitmentExtractionResult({
cfg: firstCfg,
items,
result,
nowMs: Date.now()
});
processed += items.length;
}
return processed;
} finally {
draining = false;
}
}
//#endregion
export { resetCommitmentExtractionRuntimeForTests as i, drainCommitmentExtractionQueue as n, enqueueCommitmentExtraction as r, configureCommitmentExtractionRuntime as t };