mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-22 15:31:07 +00:00
Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: charojo <4084797+charojo@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
527 lines
20 KiB
TypeScript
527 lines
20 KiB
TypeScript
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 }),
|
|
),
|
|
},
|
|
{ additionalProperties: true },
|
|
);
|
|
|
|
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<unknown> }>(
|
|
"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:<peerId>
|
|
// - <channel>:direct:<peerId>
|
|
// - <channel>:<accountId>:direct:<peerId>
|
|
// - <channel>:group:<peerId>
|
|
// - <channel>:channel:<peerId>
|
|
// Note: legacy keys may use "dm" instead of "direct".
|
|
// Threaded sessions append :thread:<id>, which we strip so delivery targets the parent peer.
|
|
// NOTE: Telegram forum topics encode as <chatId>:topic:<topicId> 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": "<ISO-8601 timestamp>" }
|
|
- "every": Recurring interval
|
|
{ "kind": "every", "everyMs": <interval-ms>, "anchorMs": <optional-start-ms> }
|
|
- "cron": Cron expression
|
|
{ "kind": "cron", "expr": "<cron-expression>", "tz": "<optional-timezone>" }
|
|
|
|
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": "<message>" }
|
|
- "agentTurn": Runs agent with message (isolated sessions only)
|
|
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional, 0 means no timeout> }
|
|
|
|
DELIVERY (top-level):
|
|
{ "mode": "none|announce|webhook", "channel": "<optional>", "to": "<optional>", "bestEffort": <optional-bool> }
|
|
- 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<string, unknown>;
|
|
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<string, unknown>).length === 0)
|
|
) {
|
|
const JOB_KEYS: ReadonlySet<string> = new Set([
|
|
"name",
|
|
"schedule",
|
|
"sessionTarget",
|
|
"wakeMode",
|
|
"payload",
|
|
"delivery",
|
|
"enabled",
|
|
"description",
|
|
"deleteAfterRun",
|
|
"agentId",
|
|
"sessionKey",
|
|
"message",
|
|
"text",
|
|
"model",
|
|
"thinking",
|
|
"timeoutSeconds",
|
|
"allowUnsafeExternalContent",
|
|
]);
|
|
const synthetic: Record<string, unknown> = {};
|
|
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)");
|
|
}
|
|
|
|
// Flat-params recovery for patch
|
|
if (
|
|
!params.patch ||
|
|
(typeof params.patch === "object" &&
|
|
params.patch !== null &&
|
|
Object.keys(params.patch as Record<string, unknown>).length === 0)
|
|
) {
|
|
const PATCH_KEYS: ReadonlySet<string> = new Set([
|
|
"name",
|
|
"schedule",
|
|
"payload",
|
|
"delivery",
|
|
"enabled",
|
|
"description",
|
|
"deleteAfterRun",
|
|
"agentId",
|
|
"sessionKey",
|
|
"sessionTarget",
|
|
"wakeMode",
|
|
"failureAlert",
|
|
"allowUnsafeExternalContent",
|
|
]);
|
|
const synthetic: Record<string, unknown> = {};
|
|
let found = false;
|
|
for (const key of Object.keys(params)) {
|
|
if (PATCH_KEYS.has(key) && params[key] !== undefined) {
|
|
synthetic[key] = params[key];
|
|
found = true;
|
|
}
|
|
}
|
|
if (found) {
|
|
params.patch = synthetic;
|
|
}
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
},
|
|
};
|
|
}
|