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",
canvas: "Present/eval/snapshot the Canvas",
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",
gateway: "Restart, apply config, or run updates on the running OpenClaw process",
agents_list: "List agent ids allowed for sessions_spawn",
@ -346,7 +346,7 @@ export function buildAgentSystemPrompt(params: {
"- browser: control openclaw's dedicated browser",
"- canvas: present/eval/snapshot the Canvas",
"- 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_history: fetch session history",
"- sessions_send: send to another session",

View File

@ -150,7 +150,7 @@ JOB SCHEMA (for add action):
"name": "string (optional)",
"schedule": { ... }, // Required: when to run
"payload": { ... }, // Required: what to execute
"sessionTarget": "main" | "isolated", // Required
"sessionTarget": "main" | "isolated" | "none", // Required
"enabled": true | false // Optional, default true
}
@ -167,10 +167,13 @@ PAYLOAD TYPES (payload.kind):
{ "kind": "systemEvent", "text": "<message>" }
- "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> }
- "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:
- sessionTarget="main" REQUIRES payload.kind="systemEvent"
- sessionTarget="isolated" REQUIRES payload.kind="agentTurn"
- sessionTarget="none" REQUIRES payload.kind="directMessage"
WAKE MODES (for wake action):
- "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 : "";
if (kind === "systemEvent") next.sessionTarget = "main";
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 };
}
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") {
return buildPayloadFromPatch(patch);
}
@ -157,6 +167,13 @@ function buildPayloadFromPatch(patch: CronPayloadPatch): CronPayload {
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) {
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) {
if (payload.kind === "systemEvent") return payload.text.trim();
if (payload.kind === "directMessage") return payload.text.trim();
return payload.message.trim();
}

View File

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

View File

@ -137,6 +137,37 @@ export async function executeJob(
};
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") {
const text = resolveJobPayloadTextForMain(job);
if (!text) {

View File

@ -5,7 +5,7 @@ export type CronSchedule =
| { kind: "every"; everyMs: number; anchorMs?: number }
| { 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 CronMessageChannel = ChannelId | "last";
@ -24,7 +24,8 @@ export type CronPayload =
channel?: CronMessageChannel;
to?: string;
bestEffortDeliver?: boolean;
};
}
| { kind: "directMessage"; text: string; channel?: CronMessageChannel; to?: string };
export type CronPayloadPatch =
| { kind: "systemEvent"; text?: string }
@ -39,7 +40,8 @@ export type CronPayloadPatch =
channel?: CronMessageChannel;
to?: string;
bestEffortDeliver?: boolean;
};
}
| { kind: "directMessage"; text?: string; channel?: CronMessageChannel; to?: string };
export type CronIsolation = {
postToMainPrefix?: string;

View File

@ -50,6 +50,15 @@ export const CronPayloadSchema = Type.Union([
},
{ 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([
@ -74,6 +83,15 @@ export const CronPayloadPatchSchema = Type.Union([
},
{ 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(
@ -110,7 +128,7 @@ export const CronJobSchema = Type.Object(
createdAtMs: Type.Integer({ minimum: 0 }),
updatedAtMs: Type.Integer({ minimum: 0 }),
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")]),
payload: CronPayloadSchema,
isolation: Type.Optional(CronIsolationSchema),
@ -136,7 +154,7 @@ export const CronAddParamsSchema = Type.Object(
enabled: Type.Optional(Type.Boolean()),
deleteAfterRun: Type.Optional(Type.Boolean()),
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")]),
payload: CronPayloadSchema,
isolation: Type.Optional(CronIsolationSchema),
@ -152,7 +170,7 @@ export const CronJobPatchSchema = Type.Object(
enabled: Type.Optional(Type.Boolean()),
deleteAfterRun: Type.Optional(Type.Boolean()),
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")])),
payload: Type.Optional(CronPayloadPatchSchema),
isolation: Type.Optional(CronIsolationSchema),

View File

@ -8,6 +8,7 @@ import { CronService } from "../cron/service.js";
import { resolveCronStorePath } from "../cron/store.js";
import { runHeartbeatOnce } from "../infra/heartbeat-runner.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 { getChildLogger } from "../logging.js";
import { normalizeAgentId } from "../routing/session-key.js";
@ -75,6 +76,27 @@ export function buildGatewayCronService(params: {
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 }),
onEvent: (evt) => {
params.broadcast("cron", evt, { dropIfSlow: true });