This commit is contained in:
gustavbotichelli 2026-01-30 11:55:32 +00:00 committed by GitHub
commit 8b77301fce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 105 additions and 9 deletions

View File

@ -195,7 +195,7 @@ export function buildAgentSystemPrompt(params: {
browser: "Control web browser", browser: "Control web browser",
canvas: "Present/eval/snapshot the Canvas", canvas: "Present/eval/snapshot the Canvas",
nodes: "List/describe/notify/camera/screen on paired nodes", nodes: "List/describe/notify/camera/screen on paired nodes",
cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)", cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate; for simple text reminders that don't need AI processing, use sessionTarget=\"none\" with payload.kind=\"directMessage\" — zero token cost)",
message: "Send messages and channel actions", message: "Send messages and channel actions",
gateway: "Restart, apply config, or run updates on the running OpenClaw process", gateway: "Restart, apply config, or run updates on the running OpenClaw process",
agents_list: "List agent ids allowed for sessions_spawn", agents_list: "List agent ids allowed for sessions_spawn",
@ -346,7 +346,7 @@ export function buildAgentSystemPrompt(params: {
"- browser: control openclaw's dedicated browser", "- browser: control openclaw's dedicated browser",
"- canvas: present/eval/snapshot the Canvas", "- canvas: present/eval/snapshot the Canvas",
"- nodes: list/describe/notify/camera/screen on paired nodes", "- nodes: list/describe/notify/camera/screen on paired nodes",
"- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)", "- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate; for simple text reminders that don't need AI processing, use sessionTarget=\"none\" with payload.kind=\"directMessage\" — zero token cost)",
"- sessions_list: list sessions", "- sessions_list: list sessions",
"- sessions_history: fetch session history", "- sessions_history: fetch session history",
"- sessions_send: send to another session", "- sessions_send: send to another session",

View File

@ -150,7 +150,7 @@ JOB SCHEMA (for add action):
"name": "string (optional)", "name": "string (optional)",
"schedule": { ... }, // Required: when to run "schedule": { ... }, // Required: when to run
"payload": { ... }, // Required: what to execute "payload": { ... }, // Required: what to execute
"sessionTarget": "main" | "isolated", // Required "sessionTarget": "main" | "isolated" | "none", // Required
"enabled": true | false // Optional, default true "enabled": true | false // Optional, default true
} }
@ -167,10 +167,13 @@ PAYLOAD TYPES (payload.kind):
{ "kind": "systemEvent", "text": "<message>" } { "kind": "systemEvent", "text": "<message>" }
- "agentTurn": Runs agent with message (isolated sessions only) - "agentTurn": Runs agent with message (isolated sessions only)
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional>, "deliver": <optional-bool>, "channel": "<optional>", "to": "<optional>", "bestEffortDeliver": <optional-bool> } { "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional>, "deliver": <optional-bool>, "channel": "<optional>", "to": "<optional>", "bestEffortDeliver": <optional-bool> }
- "directMessage": Sends text directly to a channel WITHOUT AI zero token cost. Best for simple text reminders.
{ "kind": "directMessage", "text": "<message>", "channel": "<optional-channel-id>", "to": "<optional-target>" }
CRITICAL CONSTRAINTS: CRITICAL CONSTRAINTS:
- sessionTarget="main" REQUIRES payload.kind="systemEvent" - sessionTarget="main" REQUIRES payload.kind="systemEvent"
- sessionTarget="isolated" REQUIRES payload.kind="agentTurn" - sessionTarget="isolated" REQUIRES payload.kind="agentTurn"
- sessionTarget="none" REQUIRES payload.kind="directMessage"
WAKE MODES (for wake action): WAKE MODES (for wake action):
- "next-heartbeat" (default): Wake on next heartbeat - "next-heartbeat" (default): Wake on next heartbeat

View File

@ -106,6 +106,7 @@ export function normalizeCronJobInput(
const kind = typeof next.payload.kind === "string" ? next.payload.kind : ""; const kind = typeof next.payload.kind === "string" ? next.payload.kind : "";
if (kind === "systemEvent") next.sessionTarget = "main"; if (kind === "systemEvent") next.sessionTarget = "main";
if (kind === "agentTurn") next.sessionTarget = "isolated"; if (kind === "agentTurn") next.sessionTarget = "isolated";
if (kind === "directMessage") next.sessionTarget = "none";
} }
} }

View File

@ -131,6 +131,16 @@ function mergeCronPayload(existing: CronPayload, patch: CronPayloadPatch): CronP
return { kind: "systemEvent", text }; return { kind: "systemEvent", text };
} }
if (patch.kind === "directMessage") {
if (existing.kind !== "directMessage") {
return buildPayloadFromPatch(patch);
}
const text = typeof patch.text === "string" ? patch.text : existing.text;
const channel = typeof patch.channel === "string" ? patch.channel : existing.channel;
const to = typeof patch.to === "string" ? patch.to : existing.to;
return { kind: "directMessage", text, channel, to };
}
if (existing.kind !== "agentTurn") { if (existing.kind !== "agentTurn") {
return buildPayloadFromPatch(patch); return buildPayloadFromPatch(patch);
} }
@ -157,6 +167,13 @@ function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload {
return { kind: "systemEvent", text: patch.text }; return { kind: "systemEvent", text: patch.text };
} }
if (patch.kind === "directMessage") {
if (typeof patch.text !== "string" || patch.text.length === 0) {
throw new Error('cron.update payload.kind="directMessage" requires text');
}
return { kind: "directMessage", text: patch.text, channel: patch.channel, to: patch.to };
}
if (typeof patch.message !== "string" || patch.message.length === 0) { if (typeof patch.message !== "string" || patch.message.length === 0) {
throw new Error('cron.update payload.kind="agentTurn" requires message'); throw new Error('cron.update payload.kind="agentTurn" requires message');
} }

View File

@ -55,5 +55,6 @@ export function inferLegacyName(job: {
export function normalizePayloadToSystemText(payload: CronPayload) { export function normalizePayloadToSystemText(payload: CronPayload) {
if (payload.kind === "systemEvent") return payload.text.trim(); if (payload.kind === "systemEvent") return payload.text.trim();
if (payload.kind === "directMessage") return payload.text.trim();
return payload.message.trim(); return payload.message.trim();
} }

View File

@ -34,6 +34,7 @@ export type CronServiceDeps = {
outputText?: string; outputText?: string;
error?: string; error?: string;
}>; }>;
sendDirectMessage?: (params: { text: string; channel?: string; to?: string }) => Promise<{ ok: boolean; error?: string }>;
onEvent?: (evt: CronEvent) => void; onEvent?: (evt: CronEvent) => void;
}; };

View File

@ -137,6 +137,37 @@ export async function executeJob(
}; };
try { try {
if (job.sessionTarget === "none") {
if (job.payload.kind !== "directMessage") {
await finish("skipped", 'sessionTarget "none" requires payload.kind="directMessage"');
return;
}
const text = job.payload.text?.trim();
if (!text) {
await finish("skipped", "directMessage requires non-empty text");
return;
}
if (!state.deps.sendDirectMessage) {
await finish("error", "directMessage not supported (sendDirectMessage not wired)");
return;
}
try {
const res = await state.deps.sendDirectMessage({
text,
channel: job.payload.channel ?? undefined,
to: job.payload.to ?? undefined,
});
if (res.ok) {
await finish("ok", undefined, text);
} else {
await finish("error", res.error ?? "directMessage send failed");
}
} catch (sendErr) {
await finish("error", String(sendErr));
}
return;
}
if (job.sessionTarget === "main") { if (job.sessionTarget === "main") {
const text = resolveJobPayloadTextForMain(job); const text = resolveJobPayloadTextForMain(job);
if (!text) { if (!text) {

View File

@ -5,7 +5,7 @@ export type CronSchedule =
| { kind: "every"; everyMs: number; anchorMs?: number } | { kind: "every"; everyMs: number; anchorMs?: number }
| { kind: "cron"; expr: string; tz?: string }; | { kind: "cron"; expr: string; tz?: string };
export type CronSessionTarget = "main" | "isolated"; export type CronSessionTarget = "main" | "isolated" | "none";
export type CronWakeMode = "next-heartbeat" | "now"; export type CronWakeMode = "next-heartbeat" | "now";
export type CronMessageChannel = ChannelId | "last"; export type CronMessageChannel = ChannelId | "last";
@ -24,7 +24,8 @@ export type CronPayload =
channel?: CronMessageChannel; channel?: CronMessageChannel;
to?: string; to?: string;
bestEffortDeliver?: boolean; bestEffortDeliver?: boolean;
}; }
| { kind: "directMessage"; text: string; channel?: CronMessageChannel; to?: string };
export type CronPayloadPatch = export type CronPayloadPatch =
| { kind: "systemEvent"; text?: string } | { kind: "systemEvent"; text?: string }
@ -39,7 +40,8 @@ export type CronPayloadPatch =
channel?: CronMessageChannel; channel?: CronMessageChannel;
to?: string; to?: string;
bestEffortDeliver?: boolean; bestEffortDeliver?: boolean;
}; }
| { kind: "directMessage"; text?: string; channel?: CronMessageChannel; to?: string };
export type CronIsolation = { export type CronIsolation = {
postToMainPrefix?: string; postToMainPrefix?: string;

View File

@ -50,6 +50,15 @@ export const CronPayloadSchema = Type.Union([
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),
Type.Object(
{
kind: Type.Literal("directMessage"),
text: NonEmptyString,
channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])),
to: Type.Optional(Type.String()),
},
{ additionalProperties: false },
),
]); ]);
export const CronPayloadPatchSchema = Type.Union([ export const CronPayloadPatchSchema = Type.Union([
@ -74,6 +83,15 @@ export const CronPayloadPatchSchema = Type.Union([
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),
Type.Object(
{
kind: Type.Literal("directMessage"),
text: Type.Optional(NonEmptyString),
channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])),
to: Type.Optional(Type.String()),
},
{ additionalProperties: false },
),
]); ]);
export const CronIsolationSchema = Type.Object( export const CronIsolationSchema = Type.Object(
@ -110,7 +128,7 @@ export const CronJobSchema = Type.Object(
createdAtMs: Type.Integer({ minimum: 0 }), createdAtMs: Type.Integer({ minimum: 0 }),
updatedAtMs: Type.Integer({ minimum: 0 }), updatedAtMs: Type.Integer({ minimum: 0 }),
schedule: CronScheduleSchema, schedule: CronScheduleSchema,
sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated")]), sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated"), Type.Literal("none")]),
wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]), wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]),
payload: CronPayloadSchema, payload: CronPayloadSchema,
isolation: Type.Optional(CronIsolationSchema), isolation: Type.Optional(CronIsolationSchema),
@ -136,7 +154,7 @@ export const CronAddParamsSchema = Type.Object(
enabled: Type.Optional(Type.Boolean()), enabled: Type.Optional(Type.Boolean()),
deleteAfterRun: Type.Optional(Type.Boolean()), deleteAfterRun: Type.Optional(Type.Boolean()),
schedule: CronScheduleSchema, schedule: CronScheduleSchema,
sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated")]), sessionTarget: Type.Union([Type.Literal("main"), Type.Literal("isolated"), Type.Literal("none")]),
wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]), wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]),
payload: CronPayloadSchema, payload: CronPayloadSchema,
isolation: Type.Optional(CronIsolationSchema), isolation: Type.Optional(CronIsolationSchema),
@ -152,7 +170,7 @@ export const CronJobPatchSchema = Type.Object(
enabled: Type.Optional(Type.Boolean()), enabled: Type.Optional(Type.Boolean()),
deleteAfterRun: Type.Optional(Type.Boolean()), deleteAfterRun: Type.Optional(Type.Boolean()),
schedule: Type.Optional(CronScheduleSchema), schedule: Type.Optional(CronScheduleSchema),
sessionTarget: Type.Optional(Type.Union([Type.Literal("main"), Type.Literal("isolated")])), sessionTarget: Type.Optional(Type.Union([Type.Literal("main"), Type.Literal("isolated"), Type.Literal("none")])),
wakeMode: Type.Optional(Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")])), wakeMode: Type.Optional(Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")])),
payload: Type.Optional(CronPayloadPatchSchema), payload: Type.Optional(CronPayloadPatchSchema),
isolation: Type.Optional(CronIsolationSchema), isolation: Type.Optional(CronIsolationSchema),

View File

@ -8,6 +8,7 @@ import { CronService } from "../cron/service.js";
import { resolveCronStorePath } from "../cron/store.js"; import { resolveCronStorePath } from "../cron/store.js";
import { runHeartbeatOnce } from "../infra/heartbeat-runner.js"; import { runHeartbeatOnce } from "../infra/heartbeat-runner.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { runMessageAction } from "../infra/outbound/message-action-runner.js";
import { enqueueSystemEvent } from "../infra/system-events.js"; import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js"; import { getChildLogger } from "../logging.js";
import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeAgentId } from "../routing/session-key.js";
@ -75,6 +76,27 @@ export function buildGatewayCronService(params: {
lane: "cron", lane: "cron",
}); });
}, },
sendDirectMessage: async ({ text, channel, to }) => {
const runtimeConfig = loadConfig();
try {
await runMessageAction({
cfg: runtimeConfig,
action: "send",
params: {
message: text,
...(channel ? { channel } : {}),
...(to ? { target: to } : {}),
},
gateway: {
clientName: "gateway-client",
mode: "backend",
},
});
return { ok: true };
} catch (err) {
return { ok: false, error: String(err) };
}
},
log: getChildLogger({ module: "cron", storePath }), log: getChildLogger({ module: "cron", storePath }),
onEvent: (evt) => { onEvent: (evt) => {
params.broadcast("cron", evt, { dropIfSlow: true }); params.broadcast("cron", evt, { dropIfSlow: true });