UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

450 lines (444 loc) 17 kB
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 };