diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 7e6150676..d3a0c4d15 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -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", diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 739b3ada3..551fabb1a 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -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": "" } - "agentTurn": Runs agent with message (isolated sessions only) { "kind": "agentTurn", "message": "", "model": "", "thinking": "", "timeoutSeconds": , "deliver": , "channel": "", "to": "", "bestEffortDeliver": } +- "directMessage": Sends text directly to a channel WITHOUT AI — zero token cost. Best for simple text reminders. + { "kind": "directMessage", "text": "", "channel": "", "to": "" } 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 diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index 55e21684f..7c5446e72 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -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"; } } diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 132156a0c..6d6a86411 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -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'); } diff --git a/src/cron/service/normalize.ts b/src/cron/service/normalize.ts index 161b118fa..34ae5e9df 100644 --- a/src/cron/service/normalize.ts +++ b/src/cron/service/normalize.ts @@ -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(); } diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index ab094c20b..f8a98becd 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -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; }; diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 370f5d116..b1b968608 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -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) { diff --git a/src/cron/types.ts b/src/cron/types.ts index f3fd891d6..3f44e2aef 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -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; diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index 63ed0c209..fdad619ae 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -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), diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 46d335c39..8d303ba7c 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -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 });