import { Type } from "@sinclair/typebox"; import { loadConfig } from "../../config/config.js"; import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js"; import type { CronDelivery, CronMessageChannel } from "../../cron/types.js"; import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { extractTextFromChatContent } from "../../shared/chat-content.js"; import { isRecord, truncateUtf16Safe } from "../../utils.js"; import { resolveSessionAgentId } from "../agent-scope.js"; import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool, readGatewayCallOptions, type GatewayCallOptions } from "./gateway.js"; import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js"; // NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch // instead of CronAddParamsSchema/CronJobPatchSchema because the gateway schemas // contain nested unions. Tool schemas need to stay provider-friendly, so we // accept "any object" here and validate at runtime. const CRON_ACTIONS = ["status", "list", "add", "update", "remove", "run", "runs", "wake"] as const; const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const; const CRON_RUN_MODES = ["due", "force"] as const; const REMINDER_CONTEXT_MESSAGES_MAX = 10; const REMINDER_CONTEXT_PER_MESSAGE_MAX = 220; const REMINDER_CONTEXT_TOTAL_MAX = 700; const REMINDER_CONTEXT_MARKER = "\n\nRecent context:\n"; // Flattened schema: runtime validates per-action requirements. const CronToolSchema = Type.Object({ action: stringEnum(CRON_ACTIONS), gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), includeDisabled: Type.Optional(Type.Boolean()), job: Type.Optional(Type.Object({}, { additionalProperties: true })), jobId: Type.Optional(Type.String()), id: Type.Optional(Type.String()), patch: Type.Optional(Type.Object({}, { additionalProperties: true })), text: Type.Optional(Type.String()), mode: optionalStringEnum(CRON_WAKE_MODES), runMode: optionalStringEnum(CRON_RUN_MODES), contextMessages: Type.Optional( Type.Number({ minimum: 0, maximum: REMINDER_CONTEXT_MESSAGES_MAX }), ), }); type CronToolOptions = { agentSessionKey?: string; }; type GatewayToolCaller = typeof callGatewayTool; type CronToolDeps = { callGatewayTool?: GatewayToolCaller; }; type ChatMessage = { role?: unknown; content?: unknown; }; function stripExistingContext(text: string) { const index = text.indexOf(REMINDER_CONTEXT_MARKER); if (index === -1) { return text; } return text.slice(0, index).trim(); } function truncateText(input: string, maxLen: number) { if (input.length <= maxLen) { return input; } const truncated = truncateUtf16Safe(input, Math.max(0, maxLen - 3)).trimEnd(); return `${truncated}...`; } function extractMessageText(message: ChatMessage): { role: string; text: string } | null { const role = typeof message.role === "string" ? message.role : ""; if (role !== "user" && role !== "assistant") { return null; } const text = extractTextFromChatContent(message.content); return text ? { role, text } : null; } async function buildReminderContextLines(params: { agentSessionKey?: string; gatewayOpts: GatewayCallOptions; contextMessages: number; callGatewayTool: GatewayToolCaller; }) { const maxMessages = Math.min( REMINDER_CONTEXT_MESSAGES_MAX, Math.max(0, Math.floor(params.contextMessages)), ); if (maxMessages <= 0) { return []; } const sessionKey = params.agentSessionKey?.trim(); if (!sessionKey) { return []; } const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); const resolvedKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey }); try { const res = await params.callGatewayTool<{ messages: Array }>( "chat.history", params.gatewayOpts, { sessionKey: resolvedKey, limit: maxMessages, }, ); const messages = Array.isArray(res?.messages) ? res.messages : []; const parsed = messages .map((msg) => extractMessageText(msg as ChatMessage)) .filter((msg): msg is { role: string; text: string } => Boolean(msg)); const recent = parsed.slice(-maxMessages); if (recent.length === 0) { return []; } const lines: string[] = []; let total = 0; for (const entry of recent) { const label = entry.role === "user" ? "User" : "Assistant"; const text = truncateText(entry.text, REMINDER_CONTEXT_PER_MESSAGE_MAX); const line = `- ${label}: ${text}`; total += line.length; if (total > REMINDER_CONTEXT_TOTAL_MAX) { break; } lines.push(line); } return lines; } catch { return []; } } function stripThreadSuffixFromSessionKey(sessionKey: string): string { const normalized = sessionKey.toLowerCase(); const idx = normalized.lastIndexOf(":thread:"); if (idx <= 0) { return sessionKey; } const parent = sessionKey.slice(0, idx).trim(); return parent ? parent : sessionKey; } function inferDeliveryFromSessionKey(agentSessionKey?: string): CronDelivery | null { const rawSessionKey = agentSessionKey?.trim(); if (!rawSessionKey) { return null; } const parsed = parseAgentSessionKey(stripThreadSuffixFromSessionKey(rawSessionKey)); if (!parsed || !parsed.rest) { return null; } const parts = parsed.rest.split(":").filter(Boolean); if (parts.length === 0) { return null; } const head = parts[0]?.trim().toLowerCase(); if (!head || head === "main" || head === "subagent" || head === "acp") { return null; } // buildAgentPeerSessionKey encodes peers as: // - direct: // - :direct: // - ::direct: // - :group: // - :channel: // Note: legacy keys may use "dm" instead of "direct". // Threaded sessions append :thread:, which we strip so delivery targets the parent peer. // NOTE: Telegram forum topics encode as :topic: and should be preserved. const markerIndex = parts.findIndex( (part) => part === "direct" || part === "dm" || part === "group" || part === "channel", ); if (markerIndex === -1) { return null; } const peerId = parts .slice(markerIndex + 1) .join(":") .trim(); if (!peerId) { return null; } let channel: CronMessageChannel | undefined; if (markerIndex >= 1) { channel = parts[0]?.trim().toLowerCase() as CronMessageChannel; } const delivery: CronDelivery = { mode: "announce", to: peerId }; if (channel) { delivery.channel = channel; } return delivery; } export function createCronTool(opts?: CronToolOptions, deps?: CronToolDeps): AnyAgentTool { const callGateway = deps?.callGatewayTool ?? callGatewayTool; return { label: "Cron", name: "cron", ownerOnly: true, description: `Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. ACTIONS: - status: Check cron scheduler status - list: List jobs (use includeDisabled:true to include disabled) - add: Create job (requires job object, see schema below) - update: Modify job (requires jobId + patch object) - remove: Delete job (requires jobId) - run: Trigger job immediately (requires jobId) - runs: Get job run history (requires jobId) - wake: Send wake event (requires text, optional mode) JOB SCHEMA (for add action): { "name": "string (optional)", "schedule": { ... }, // Required: when to run "payload": { ... }, // Required: what to execute "delivery": { ... }, // Optional: announce summary or webhook POST "sessionTarget": "main" | "isolated", // Required "enabled": true | false // Optional, default true } SCHEDULE TYPES (schedule.kind): - "at": One-shot at absolute time { "kind": "at", "at": "" } - "every": Recurring interval { "kind": "every", "everyMs": , "anchorMs": } - "cron": Cron expression { "kind": "cron", "expr": "", "tz": "" } ISO timestamps without an explicit timezone are treated as UTC. PAYLOAD TYPES (payload.kind): - "systemEvent": Injects text as system event into session { "kind": "systemEvent", "text": "" } - "agentTurn": Runs agent with message (isolated sessions only) { "kind": "agentTurn", "message": "", "model": "", "thinking": "", "timeoutSeconds": } DELIVERY (top-level): { "mode": "none|announce|webhook", "channel": "", "to": "", "bestEffort": } - Default for isolated agentTurn jobs (when delivery omitted): "announce" - announce: send to chat channel (optional channel/to target) - webhook: send finished-run event as HTTP POST to delivery.to (URL required) - If the task needs to send to a specific chat/recipient, set announce delivery.channel/to; do not call messaging tools inside the run. CRITICAL CONSTRAINTS: - sessionTarget="main" REQUIRES payload.kind="systemEvent" - sessionTarget="isolated" REQUIRES payload.kind="agentTurn" - For webhook callbacks, use delivery.mode="webhook" with delivery.to set to a URL. Default: prefer isolated agentTurn jobs unless the user explicitly wants a main-session system event. WAKE MODES (for wake action): - "next-heartbeat" (default): Wake on next heartbeat - "now": Wake immediately Use jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.`, parameters: CronToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; const action = readStringParam(params, "action", { required: true }); const gatewayOpts: GatewayCallOptions = { ...readGatewayCallOptions(params), timeoutMs: typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) ? params.timeoutMs : 60_000, }; switch (action) { case "status": return jsonResult(await callGateway("cron.status", gatewayOpts, {})); case "list": return jsonResult( await callGateway("cron.list", gatewayOpts, { includeDisabled: Boolean(params.includeDisabled), }), ); case "add": { // Flat-params recovery: non-frontier models (e.g. Grok) sometimes flatten // job properties to the top level alongside `action` instead of nesting // them inside `job`. When `params.job` is missing or empty, reconstruct // a synthetic job object from any recognised top-level job fields. // See: https://github.com/openclaw/openclaw/issues/11310 if ( !params.job || (typeof params.job === "object" && params.job !== null && Object.keys(params.job as Record).length === 0) ) { const JOB_KEYS: ReadonlySet = new Set([ "name", "schedule", "sessionTarget", "wakeMode", "payload", "delivery", "enabled", "description", "deleteAfterRun", "agentId", "sessionKey", "message", "text", "model", "thinking", "timeoutSeconds", "allowUnsafeExternalContent", ]); const synthetic: Record = {}; let found = false; for (const key of Object.keys(params)) { if (JOB_KEYS.has(key) && params[key] !== undefined) { synthetic[key] = params[key]; found = true; } } // Only use the synthetic job if at least one meaningful field is present // (schedule, payload, message, or text are the minimum signals that the // LLM intended to create a job). if ( found && (synthetic.schedule !== undefined || synthetic.payload !== undefined || synthetic.message !== undefined || synthetic.text !== undefined) ) { params.job = synthetic; } } if (!params.job || typeof params.job !== "object") { throw new Error("job required"); } const job = normalizeCronJobCreate(params.job) ?? params.job; if (job && typeof job === "object") { const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); const resolvedSessionKey = opts?.agentSessionKey ? resolveInternalSessionKey({ key: opts.agentSessionKey, alias, mainKey }) : undefined; if (!("agentId" in job)) { const agentId = opts?.agentSessionKey ? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg }) : undefined; if (agentId) { (job as { agentId?: string }).agentId = agentId; } } if (!("sessionKey" in job) && resolvedSessionKey) { (job as { sessionKey?: string }).sessionKey = resolvedSessionKey; } } if ( opts?.agentSessionKey && job && typeof job === "object" && "payload" in job && (job as { payload?: { kind?: string } }).payload?.kind === "agentTurn" ) { const deliveryValue = (job as { delivery?: unknown }).delivery; const delivery = isRecord(deliveryValue) ? deliveryValue : undefined; const modeRaw = typeof delivery?.mode === "string" ? delivery.mode : ""; const mode = modeRaw.trim().toLowerCase(); if (mode === "webhook") { const webhookUrl = normalizeHttpWebhookUrl(delivery?.to); if (!webhookUrl) { throw new Error( 'delivery.mode="webhook" requires delivery.to to be a valid http(s) URL', ); } if (delivery) { delivery.to = webhookUrl; } } const hasTarget = (typeof delivery?.channel === "string" && delivery.channel.trim()) || (typeof delivery?.to === "string" && delivery.to.trim()); const shouldInfer = (deliveryValue == null || delivery) && (mode === "" || mode === "announce") && !hasTarget; if (shouldInfer) { const inferred = inferDeliveryFromSessionKey(opts.agentSessionKey); if (inferred) { (job as { delivery?: unknown }).delivery = { ...delivery, ...inferred, } satisfies CronDelivery; } } } const contextMessages = typeof params.contextMessages === "number" && Number.isFinite(params.contextMessages) ? params.contextMessages : 0; if ( job && typeof job === "object" && "payload" in job && (job as { payload?: { kind?: string; text?: string } }).payload?.kind === "systemEvent" ) { const payload = (job as { payload: { kind: string; text: string } }).payload; if (typeof payload.text === "string" && payload.text.trim()) { const contextLines = await buildReminderContextLines({ agentSessionKey: opts?.agentSessionKey, gatewayOpts, contextMessages, callGatewayTool: callGateway, }); if (contextLines.length > 0) { const baseText = stripExistingContext(payload.text); payload.text = `${baseText}${REMINDER_CONTEXT_MARKER}${contextLines.join("\n")}`; } } } return jsonResult(await callGateway("cron.add", gatewayOpts, job)); } case "update": { const id = readStringParam(params, "jobId") ?? readStringParam(params, "id"); if (!id) { throw new Error("jobId required (id accepted for backward compatibility)"); } if (!params.patch || typeof params.patch !== "object") { throw new Error("patch required"); } const patch = normalizeCronJobPatch(params.patch) ?? params.patch; return jsonResult( await callGateway("cron.update", gatewayOpts, { id, patch, }), ); } case "remove": { const id = readStringParam(params, "jobId") ?? readStringParam(params, "id"); if (!id) { throw new Error("jobId required (id accepted for backward compatibility)"); } return jsonResult(await callGateway("cron.remove", gatewayOpts, { id })); } case "run": { const id = readStringParam(params, "jobId") ?? readStringParam(params, "id"); if (!id) { throw new Error("jobId required (id accepted for backward compatibility)"); } const runMode = params.runMode === "due" || params.runMode === "force" ? params.runMode : "force"; return jsonResult(await callGateway("cron.run", gatewayOpts, { id, mode: runMode })); } case "runs": { const id = readStringParam(params, "jobId") ?? readStringParam(params, "id"); if (!id) { throw new Error("jobId required (id accepted for backward compatibility)"); } return jsonResult(await callGateway("cron.runs", gatewayOpts, { id })); } case "wake": { const text = readStringParam(params, "text", { required: true }); const mode = params.mode === "now" || params.mode === "next-heartbeat" ? params.mode : "next-heartbeat"; return jsonResult( await callGateway("wake", gatewayOpts, { mode, text }, { expectFinal: false }), ); } default: throw new Error(`Unknown action: ${action}`); } }, }; }