feat(cron): default agent turns to the current conversation, add /loop, tighten cron tool surface (#114328)

* feat(cron): bind agent turns to current sessions

* feat(commands): add loop chat command

* test(cron): cover session defaults and loop commands

* docs(cron): document current defaults and loops

* fix(commands): scope /loop status and stop by conversation name tag

* fix(commands): widen /loop conversation tag to 48 bits

* fix(commands): include disabled jobs in /loop status and stop

* docs(cron): regenerate docs map

* test(cron): split session-target default tests to satisfy max-lines
This commit is contained in:
Peter Steinberger
2026-07-27 01:44:38 -04:00
committed by GitHub
parent e154330a42
commit b5137604ef
25 changed files with 591 additions and 103 deletions
+7 -1
View File
@@ -110,10 +110,14 @@ When a stream job also has `trigger.script`, the gate runs once per closed batch
Recurring jobs can set `pacing.min` and/or `pacing.max` to duration strings such as `15m` or `4h`; at least one bound is required. Use `--pacing-min` and `--pacing-max` with `cron add|edit` (`--clear-pacing` removes both bounds).
During an isolated run, a paced job can call the `cron` tool with `action: "next_check"` and `in: "30m"`. The proposal applies only to that currently running job and is measured from successful run completion. OpenClaw silently clamps it to the configured bounds.
During an agent-turn run, a paced job can call the `cron` tool with `action: "next_check"` and `in: "30m"`. The proposal applies only to that currently running job and is measured from successful run completion. OpenClaw silently clamps it to the configured bounds.
Pacing without a proposal leaves the normal schedule unchanged. Failed, timed-out, and skipped runs discard the proposal, so existing retry and error-backoff behavior takes precedence. Manually forcing a recurring job is out-of-band and preserves its pending natural or paced slot. For condition-triggered jobs, the built-in minimum interval remains a lower bound even when a proposal requests an earlier check.
### `/loop` chat shortcut
In chat, the owner-only `/loop [interval] <prompt>` command creates a recurring agent-turn job bound to that conversation. Give an interval such as `5m` for fixed cadence, or omit it to let the loop self-pace between 1 minute and 1 hour with `next_check`. Use `/loop status` to list conversation-bound loops and `/loop stop [name]` to remove them.
### Day-of-month and day-of-week use OR logic
Cron expressions are parsed by [croner](https://github.com/Hexagon/croner). When both the day-of-month and day-of-week fields are non-wildcard, croner matches when **either** field matches, not both. This is standard Vixie cron behavior.
@@ -284,6 +288,8 @@ Throws, timeouts, exhausted tool budgets, invalid results, and `nextCheck` witho
| Current session | `current` | Bound at creation time | Context-aware recurring work |
| Custom session | `session:custom-id` | Persistent named session | Workflows that build on history |
Agent-turn jobs default to the creating conversation when the create request carries session context. Callers without a session key, including CLI and API callers that do not supply one, fall back to `isolated`. System events and heartbeats still default to `main`; command and script payloads still default to `isolated`.
<AccordionGroup>
<Accordion title="Main session vs isolated vs custom">
**Main session** jobs enqueue a system event into a cron-owned run lane and optionally wake the heartbeat (`--wake now` or `--wake next-heartbeat`). They can use the target main session's last delivery context for replies, but do not append routine cron turns to the human chat lane and do not extend daily/idle reset freshness for the target session. **Isolated** jobs run a dedicated agent turn with a fresh session. **Custom sessions** (`session:xxx`) persist context across runs, enabling workflows like daily standups that build on previous summaries.
+2
View File
@@ -56,6 +56,8 @@ openclaw cron create "*/15 * * * *" \
`--session` accepts `main`, `isolated`, `current`, or `session:<id>`.
Agent-turn jobs default to the creating conversation when session context is available. Without a session key, including ordinary CLI calls and API calls that omit one, the target falls back to `isolated`.
<AccordionGroup>
<Accordion title="Session keys">
- `main` binds to the agent's main session.
+2
View File
@@ -69,6 +69,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H3: Heartbeat task migration
- H3: Stream sources
- H3: Dynamic cadence (pacing)
- H3: /loop chat shortcut
- H3: Day-of-month and day-of-week use OR logic
- H2: Event triggers (condition watchers)
- H2: Payloads
@@ -10477,6 +10478,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H3: Bundled plugin commands
- H3: Skill commands
- H2: /tools: what the agent can use now
- H2: /loop: recurring conversation work
- H2: /model: model selection
- H2: /config: on-disk config writes
- H2: /mcp: MCP server config
+7
View File
@@ -248,6 +248,9 @@ plugins.
| --- | --- |
| `/skill <name> [input]` | Run a skill by name |
| `/learn [request]` | Draft one reviewable skill from the current conversation or named sources through [Skill Workshop](/tools/skill-workshop) |
| `/loop [interval] <prompt>` | Owner-only. Repeat a prompt in this conversation; omit the interval for self-paced checks |
| `/loop status` | Owner-only. List loops bound to this conversation |
| `/loop stop [name]` | Owner-only. Stop matching loops bound to this conversation |
| `/allowlist [list\|add\|remove] ...` | Manage allowlist entries. Text-only |
| `/approve <id> <decision>` | Resolve exec or plugin approval prompts |
| `/btw <question>` | Ask a side question without changing session context. Alias: `/side`. See [BTW](/tools/btw) |
@@ -354,6 +357,10 @@ Results are session-scoped. Changing agent, channel, thread, sender
authorization, or model can change the output. For profile and override editing,
use the Control UI Tools panel or config surfaces.
## `/loop`: recurring conversation work
`/loop` is owner-only because it uses the cron control-plane tool. `/loop 5m check deploy status` asks the agent to create a fixed-cadence cron job in the current conversation. Without an interval, `/loop watch for new issues` creates a self-paced loop that checks more often while active and backs off toward 1 hour while quiet. `/loop status` lists the conversation's loop jobs; `/loop stop [name]` removes them.
## `/model`: model selection
```text
@@ -73,7 +73,7 @@ describe("cron tool flat-params", () => {
).toBeUndefined();
});
it("preserves explicit top-level sessionKey during flat-params recovery", async () => {
it("binds recovered agentTurn jobs to the creating conversation by default", async () => {
const tool = createCronTool(
{ agentSessionKey: "agent:main:discord:channel:ops" },
{ callGatewayTool: callGatewayToolMock },
@@ -85,9 +85,13 @@ describe("cron tool flat-params", () => {
message: "do stuff",
});
const [method, _gatewayOpts, params] = firstGatewayToolCall<{ sessionKey?: string }>();
const [method, _gatewayOpts, params] = firstGatewayToolCall<{
sessionKey?: string;
sessionTarget?: string;
}>();
expect(method).toBe("cron.add");
expect(params.sessionKey).toBe("agent:main:telegram:group:-100123:topic:99");
expect(params.sessionTarget).toBe("current");
expect(params.sessionKey).toBe("agent:main:discord:channel:ops");
});
it("recovers flat cron schedule shorthand for add", async () => {
+23
View File
@@ -128,6 +128,29 @@ describe("createCronToolSchema", () => {
"tz",
].toSorted(),
);
expect(propertyAt(schemaRecord, "job.schedule.kind")?.enum).toContain("stream");
});
it("documents wake, context, and session-target fields", () => {
expect(propertyAt(schemaRecord, "text")?.description).toBe(
'systemEvent text for action="wake"',
);
expect(propertyAt(schemaRecord, "mode")?.description).toBe(
'Wake mode for action="wake" (default next-heartbeat)',
);
for (const path of ["job.sessionTarget", "patch.sessionTarget"]) {
expect(propertyAt(schemaRecord, path)?.description).toBe(
"main | isolated | current (agentTurn default) | session:<id>",
);
}
for (const path of ["job.payload", "patch.payload"]) {
expect(propertyAt(schemaRecord, `${path}.lightContext`)?.description).toBe(
"Lightweight bootstrap context (skip full workspace context)",
);
expect(propertyAt(schemaRecord, `${path}.allowUnsafeExternalContent`)?.description).toBe(
"Allow untrusted external content in prompt",
);
}
});
it("marks staggerMs as cron-only in both job and patch schedule schemas", () => {
+34 -20
View File
@@ -88,10 +88,10 @@ describe("cron tool", () => {
it("tells models to keep cron expressions in local wall-clock time for tz", () => {
const tool = createTestCronTool();
expect(tool.description).toContain("requested local wall time");
expect(tool.description).toContain("expr is wall time in tz");
expect(tool.description).toContain("never pre-convert to UTC");
expect(tool.description).toContain("Missing tz = Gateway host local");
expect(tool.description).toContain("timezone-less = UTC");
expect(tool.description).toContain("no tz=gateway host local");
expect(tool.description).toContain("no tz=UTC");
expect(tool.description).toContain('expr:"0 18 * * *"');
expect(tool.description).toContain('tz:"Asia/Shanghai"');
});
@@ -690,29 +690,27 @@ describe("cron tool", () => {
it("documents deferred follow-up guidance in the tool description", () => {
const tool = createTestCronTool();
expect(tool.description).toContain("reminders, later checks/follow-ups, recurring work");
expect(tool.description).toContain("Never exec sleep/process-poll as timer.");
expect(tool.description).toContain("reminders, delayed self-wakeups, loops, recurring work");
expect(tool.description).toContain("Never exec sleep/poll as timer.");
});
it("documents the event-trigger authoring contract", () => {
const tool = createTestCronTool();
expect(tool.description).toContain("Requires cron.triggers.enabled");
expect(tool.description).toContain("quiet check has no model");
expect(tool.description).toContain("trigger.state");
expect(tool.description).toContain("fire:false saves state only; no payload/history");
expect(tool.description).toContain("fired state saves only after payload success");
expect(tool.description).toContain("every actionable state, including failures/timeouts");
expect(tool.description).toContain("success-only watchers go silent when broken");
expect(tool.description).toContain(
"Dedupe by comparing trigger.state and returning new state, never memory",
"needs cron.triggers.enabled — if off, say so; never model-poll instead",
);
expect(tool.description).toContain("scripts read-only; actions belong in payload");
expect(tool.description).toContain("message must be self-contained");
expect(tool.description).toContain("the fired run's entire context");
expect(tool.description).toContain('Silent watcher: top-level delivery.mode="none"');
expect(tool.description).toContain("missing route may fail");
expect(tool.description).toContain("once:true disables after first successful fire");
expect(tool.description).toContain("Quiet headless check, no model");
expect(tool.description).toContain("trigger.state");
expect(tool.description).toContain("fire:false saves state only");
expect(tool.description).toContain("fire:true runs payload");
expect(tool.description).toContain("Fire on failures/timeouts too");
expect(tool.description).toContain("success-only watchers look healthy when broken");
expect(tool.description).toContain("dedupe via state, never memory");
expect(tool.description).toContain("Script stays read-only; actions belong in payload");
expect(tool.description).toContain("message is that run's entire context — self-contained");
expect(tool.description).toContain('Silent watcher=>mode:"none"');
expect(tool.description).toContain("once:true disables after first fire");
expect(tool.description).toContain('await tools.call("exec"');
});
@@ -721,7 +719,7 @@ describe("cron tool", () => {
const parameters = tool.parameters as SchemaLike;
const runMode = parameters.properties?.runMode;
expect(tool.description).toContain('run jobId (due only; runMode="force" now)');
expect(tool.description).toContain('run jobId (runMode "force"=now)');
expect(runMode?.description).toContain('omitted defaults to "due"');
expect(runMode?.description).toContain('use "force" to trigger now');
});
@@ -1552,6 +1550,22 @@ describe("cron tool", () => {
expect(sessionKey).toBe(callerSessionKey);
});
it("defaults scoped agentTurn adds to the creating conversation", async () => {
const callerSessionKey = "agent:main:discord:channel:ops";
const tool = createTestCronTool({ agentSessionKey: callerSessionKey });
await tool.execute("call-current-default", {
action: "add",
job: buildReminderAgentTurnJob(),
});
expect(expectSingleGatewayCallMethod("cron.add")).toMatchObject({
sessionTarget: "current",
sessionKey: callerSessionKey,
delivery: { mode: "announce" },
});
});
it("forwards authenticated source account separately from delivery account", async () => {
let identity: ReturnType<typeof getGatewayToolCallerIdentity> = undefined;
const tool = createCronTool(
+39 -38
View File
@@ -75,7 +75,7 @@ const CRON_ACTIONS = [
"wake",
] as const;
const CRON_SCHEDULE_KINDS = ["at", "every", "cron"] as const;
const CRON_SCHEDULE_KINDS = ["at", "every", "cron", "stream"] as const;
const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const;
const CRON_PAYLOAD_KINDS = ["systemEvent", "agentTurn", "script"] as const;
const CRON_DELIVERY_MODES = ["none", "announce", "webhook"] as const;
@@ -133,8 +133,14 @@ function cronPayloadObjectSchema(params: {
thinking: Type.Optional(Type.String({ description: "Thinking override" })),
timeoutSeconds: optionalFiniteNumberSchema({ minimum: 0 }),
toolBudget: optionalPositiveIntegerSchema({ description: "Maximum script tool calls" }),
lightContext: Type.Optional(Type.Boolean()),
allowUnsafeExternalContent: Type.Optional(Type.Boolean()),
lightContext: Type.Optional(
Type.Boolean({
description: "Lightweight bootstrap context (skip full workspace context)",
}),
),
allowUnsafeExternalContent: Type.Optional(
Type.Boolean({ description: "Allow untrusted external content in prompt" }),
),
fallbacks: params.fallbacks,
toolsAllow: params.toolsAllow,
},
@@ -339,7 +345,7 @@ function createCronJobObjectSchema(): TSchema {
trigger: createCronTriggerSchema({ nullableClears: false }),
sessionTarget: Type.Optional(
Type.String({
description: "main | isolated | current | session:<id>",
description: "main | isolated | current (agentTurn default) | session:<id>",
}),
),
wakeMode: optionalStringEnum(CRON_WAKE_MODES, { description: "Wake timing" }),
@@ -370,7 +376,11 @@ function createCronPatchObjectSchema(): TSchema {
schedule: createCronScheduleSchema(),
pacing: createCronPacingSchema({ nullableClears: true }),
trigger: createCronTriggerSchema({ nullableClears: true }),
sessionTarget: Type.Optional(Type.String({ description: "Session target" })),
sessionTarget: Type.Optional(
Type.String({
description: "main | isolated | current (agentTurn default) | session:<id>",
}),
),
wakeMode: optionalStringEnum(CRON_WAKE_MODES),
payload: Type.Optional(
cronPayloadObjectSchema({
@@ -408,8 +418,10 @@ function createCronToolSchema(): TSchema {
description: 'Relative duration for action="next_check" (for example, "15m")',
}),
),
text: Type.Optional(Type.String()),
mode: optionalStringEnum(CRON_WAKE_MODES),
text: Type.Optional(Type.String({ description: 'systemEvent text for action="wake"' })),
mode: optionalStringEnum(CRON_WAKE_MODES, {
description: 'Wake mode for action="wake" (default next-heartbeat)',
}),
runMode: optionalStringEnum(CRON_RUN_MODES, {
description:
'Run mode for action="run": omitted defaults to "due"; use "force" to trigger now.',
@@ -740,44 +752,33 @@ export function createCronTool(opts?: CronToolOptions, deps?: CronToolDeps): Any
label: "Cron",
name: "cron",
displaySummary: CRON_TOOL_DISPLAY_SUMMARY,
description: `Gateway schedules/wakes: reminders, later checks/follow-ups, recurring work. Never exec sleep/process-poll as timer. Main job => heartbeat system event; isolated => background task in \`openclaw tasks\`.
description: `Gateway scheduler: reminders, delayed self-wakeups, loops, recurring work, event watchers. Never exec sleep/poll as timer.
ACTIONS:
- status scheduler; list compact summaries (includeDisabled, session agentId auto-filter; get for full); get jobId
- add job; update jobId+patch; remove jobId
- run jobId (due only; runMode="force" now); runs jobId history; next_check in (current paced job only)
- wake text (+ optional mode). Default caller lane; top-level sessionKey/agentId selects another caller-owned lane.
ACTIONS: status | list [includeDisabled] | get jobId | add job | update jobId patch | remove jobId | run jobId (runMode "force"=now) | runs jobId = history | next_check in:"30m" (own paced run only) | wake text mode?:"now"|"next-heartbeat"(default) nudges a caller-owned lane (sessionKey/agentId to pick another).
ADD JOB:
{ "name":"...", "schedule":{...}, "pacing":{ "min":"15m", "max":"4h" }, "trigger":{ "script":"...", "once":false }, "payload":{...}, "delivery":{...}, "sessionTarget":"main|isolated|current|session:<id>", "enabled":true }
Required: schedule,payload. enabled default true. trigger only every/cron.
TARGET/PAYLOAD:
- main => systemEvent {kind:"systemEvent",text:"..."} or script; systemEvent defaults main.
- isolated/current/session:<id> => agentTurn {kind:"agentTurn",message:"...",model?,thinking?,timeoutSeconds?}; agentTurn defaults isolated. timeoutSeconds=0 means none.
- script {kind:"script",script:"...",timeoutSeconds?,toolBudget?} supports main or isolated only and requires cron.triggers.enabled.
- current binds caller session at creation. session:<id> is persistent. Prefer isolated unless user explicitly wants current binding.
ADD: {name?,schedule,payload,sessionTarget?,pacing?,trigger?,delivery?,enabled?}. Required: schedule+payload.
SCHEDULE:
- at: {kind:"at",at:"ISO-8601"}; timezone-less = UTC.
- every: {kind:"every",everyMs:<ms>,anchorMs?}.
- cron: {kind:"cron",expr:"...",tz?:"IANA"}. Expr is requested local wall time; never pre-convert to UTC. Missing tz = Gateway host local, not UTC. Shanghai 18:00: {kind:"cron",expr:"0 18 * * *",tz:"Asia/Shanghai"}.
- {kind:"at",at:"ISO-8601"} one-shot; no tz=UTC; auto-deletes after run.
- {kind:"every",everyMs}.
- {kind:"cron",expr,tz?:"IANA"}: expr is wall time in tz; never pre-convert to UTC; no tz=gateway host local. 18:00 Shanghai => {expr:"0 18 * * *",tz:"Asia/Shanghai"}.
- {kind:"stream",command:[argv],mode?:"line"|"match",match?}: fires on supervised process output; needs cron.triggers.enabled.
TRIGGER SCRIPT:
- Requires cron.triggers.enabled; if off, explain and never model-poll fallback.
- Headless owner allowlist; quiet check has no model. Prior trigger.state is frozen JSON. Return/json({fire:boolean,message?:string,state?:JSONValue}); create new state, never mutate prior.
- fire:false saves state only; no payload/history. fire:true runs payload and appends message; fired state saves only after payload success.
- Fire on every actionable state, including failures/timeouts; success-only watchers go silent when broken, which looks healthy. Dedupe by comparing trigger.state and returning new state, never memory.
- Keep scripts read-only; actions belong in payload. message must be self-contained: it is the fired run's entire context.
- Silent watcher: top-level delivery.mode="none". Omitted delivery on isolated agentTurn announces and missing route may fail.
- once:true disables after first successful fire. Per check: 30s, 5 tool calls, 16KB state.
- Hidden Code Mode tools: await tools.call("exec", {command:"..."}); unknown id => search/describe.
TARGET+PAYLOAD:
- "current" (agentTurn default) = this conversation: run carries this chat's context, result lands here. Self-wakeup/"continue later"/loop = at|every + agentTurn + current.
- "isolated" = fresh detached session (shows in \`openclaw tasks\`); standalone background work.
- "main" = heartbeat lane; payload {kind:"systemEvent",text} (systemEvent default target).
- "session:<key>" = named session.
- agentTurn {kind:"agentTurn",message,model?,thinking?,timeoutSeconds?}; timeoutSeconds 0=none.
- script {kind:"script",script,timeoutSeconds?,toolBudget?}: main|isolated only; needs cron.triggers.enabled.
DELIVERY top-level: {mode:"none|announce|webhook",channel?,to?,threadId?,bestEffort?}
- Isolated agentTurn omitted delivery => announce. announce only isolated/current/session; channel/to optional; threadId chat topic. Specific chat: set channel/to; no messaging tool inside run.
- webhook posts finished-run event to URL in to.
PACED LOOP: recurring job + pacing{min?,max?} durations ("15m","4h"; at least one). Inside its run, job calls next_check in:"<dur>" to set the next delay (clamped to bounds, measured from run end; failed runs keep normal backoff). Adaptive polling: tighten when active, back off when quiet.
Restricted isolated runs may only self status/list, current get/runs/remove, and next_check for their own paced job. wake mode: next-heartbeat default | now. jobId canonical; id compat. contextMessages 0-10 adds prior messages.`,
TRIGGER (condition watcher on every/cron): {script,once?}; needs cron.triggers.enabled if off, say so; never model-poll instead. Quiet headless check, no model; 30s/5 tool calls/16KB state. Read frozen trigger.state, return json({fire,message?,state?}) with NEW state; dedupe via state, never memory. fire:false saves state only. fire:true runs payload; message is that run's entire context self-contained. Fire on failures/timeouts too; success-only watchers look healthy when broken. Script stays read-only; actions belong in payload. once:true disables after first fire. Code Mode: await tools.call("exec",{command:"..."}).
DELIVERY {mode:"none"|"announce"|"webhook",channel?,to?,threadId?,bestEffort?}: where detached run output goes. Omitted=announce (current=>this chat; isolated=>last route; set channel/to for a specific chat no messaging tool inside the run). Silent watcher=>mode:"none". webhook posts finished-run event to URL in \`to\`.
Job wakeMode (main jobs): "now"(default)|"next-heartbeat". Restricted cron-run sessions: self status/list/get/runs/remove + own next_check only. failureAlert {...}|false disables. jobId canonical (id=compat). contextMessages 0-10 embeds recent chat lines into reminder text.`,
parameters: createCronToolSchema(),
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
@@ -250,6 +250,23 @@ export function buildBuiltinChatCommands(
},
],
}),
defineChatCommand({
key: "loop",
nativeName: "loop",
description: "Loop a prompt: /loop [interval] <prompt> | /loop status | /loop stop [name]",
textAlias: "/loop",
category: "tools",
tier: "standard",
args: [
{
name: "spec",
description: "[interval] prompt, or status/stop",
type: "string",
required: false,
captureRemaining: true,
},
],
}),
defineChatCommand({
key: "status",
nativeName: "status",
+12
View File
@@ -306,6 +306,17 @@ describe("commands registry", () => {
);
});
it("registers /loop as a standard tools command with an optional spec", () => {
const loop = requireChatCommand("loop");
expect(loop.nativeName).toBe("loop");
expect(loop.textAliases).toEqual(["/loop"]);
expect(loop.category).toBe("tools");
expect(loop.tier).toBe("standard");
expect(loop.acceptsArgs).toBe(true);
expect(requireCommandArg(loop, "spec").required).not.toBe(true);
expect(resolveTextCommand("/loop 5m check ci")?.args).toBe("5m check ci");
});
it("preserves multiline payloads for direct skill slash aliases only when unregistered", () => {
expect(normalizeCommandBody("/demo_skill first line\nsecond line")).toBe(
"/demo_skill first line\nsecond line",
@@ -525,6 +536,7 @@ describe("commands registry", () => {
expect(detection.exact.has("/commands")).toBe(true);
expect(detection.exact.has("/skill")).toBe(true);
expect(detection.exact.has("/learn")).toBe(true);
expect(detection.exact.has("/loop")).toBe(true);
expect(detection.exact.has("/compact")).toBe(true);
expect(detection.exact.has("/whoami")).toBe(true);
expect(detection.exact.has("/id")).toBe(true);
@@ -22,6 +22,7 @@ export const commandHandlerOrder = [
"status",
"goal",
"learn",
"loop",
"name",
"diagnostics",
"tasks",
@@ -22,6 +22,7 @@ import {
} from "./commands-info.js";
import { handleLearnCommand } from "./commands-learn.js";
import { handleLoginCommand } from "./commands-login.js";
import { handleLoopCommand } from "./commands-loop.js";
import { handleMcpCommand } from "./commands-mcp.js";
import { handleModelsCommand } from "./commands-models.js";
import { handleNameCommand } from "./commands-name.js";
@@ -66,6 +67,7 @@ const commandHandlersById = {
goal: handleGoalCommand,
help: handleHelpCommand,
learn: handleLearnCommand,
loop: handleLoopCommand,
login: handleLoginCommand,
mcp: handleMcpCommand,
models: handleModelsCommand,
+137
View File
@@ -0,0 +1,137 @@
// Tests /loop recognition, work-order rewriting, authorization, and interval guards.
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import { handleLoopCommand } from "./commands-loop.js";
import type { HandleCommandsParams } from "./commands-types.js";
function buildLoopParams(commandBodyNormalized: string): HandleCommandsParams {
return {
cfg: {},
ctx: {
Provider: INTERNAL_MESSAGE_CHANNEL,
Surface: INTERNAL_MESSAGE_CHANNEL,
CommandSource: "text",
Body: commandBodyNormalized,
RawBody: commandBodyNormalized,
CommandBody: commandBodyNormalized,
BodyForCommands: commandBodyNormalized,
BodyForAgent: commandBodyNormalized,
BodyStripped: commandBodyNormalized,
},
command: {
commandBodyNormalized,
isAuthorizedSender: true,
senderIsOwner: true,
senderId: "tester",
channel: INTERNAL_MESSAGE_CHANNEL,
channelId: INTERNAL_MESSAGE_CHANNEL,
surface: INTERNAL_MESSAGE_CHANNEL,
ownerList: [],
rawBodyNormalized: commandBodyNormalized,
},
directives: {},
elevated: { enabled: true, allowed: true, failures: [] },
sessionKey: "agent:main:webchat:test",
workspaceDir: "/tmp",
provider: "openai",
model: "gpt-5.6",
contextTokens: 0,
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolveDefaultThinkingLevel: async () => undefined,
isGroup: false,
} as unknown as HandleCommandsParams;
}
function rewrittenBody(params: HandleCommandsParams): string {
return (params.ctx as { BodyForAgent?: string }).BodyForAgent ?? "";
}
// Mirrors the handler's conversation tag for the fixture sessionKey.
const LOOP_PREFIX = `loop[${createHash("sha256").update("agent:main:webchat:test").digest("hex").slice(0, 12)}]`;
describe("loop command", () => {
it("ignores non-loop text", async () => {
expect(await handleLoopCommand(buildLoopParams("check ci"), true)).toBeNull();
});
it("returns usage for bare /loop without continuing", async () => {
const result = await handleLoopCommand(buildLoopParams("/loop"), true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toBe(
"Usage: /loop [interval] <prompt> — repeat a prompt in this chat (e.g. /loop 5m check deploy status). Without interval the loop self-paces between 1m and 1h. /loop status lists loops; /loop stop [name] stops.",
);
});
it("rewrites a fixed interval loop as a current-session cron job", async () => {
const params = buildLoopParams("/loop 5m check ci");
expect(await handleLoopCommand(params, true)).toEqual({ shouldContinue: true });
expect(rewrittenBody(params)).toContain('schedule:{kind:"every",everyMs:300000}');
expect(rewrittenBody(params)).toContain('sessionTarget:"current"');
expect(rewrittenBody(params)).toContain(`"${LOOP_PREFIX} check ci"`);
expect(rewrittenBody(params)).toContain("do not use the message tool");
});
it("preserves parseDurationMs unitless millisecond intervals", async () => {
const params = buildLoopParams("/loop 30000 check ci");
expect(await handleLoopCommand(params, true)).toEqual({ shouldContinue: true });
expect(rewrittenBody(params)).toContain('schedule:{kind:"every",everyMs:30000}');
});
it("rewrites an interval-free loop with pacing and next_check guidance", async () => {
const params = buildLoopParams("/loop watch for new github issues");
expect(await handleLoopCommand(params, true)).toEqual({ shouldContinue: true });
expect(rewrittenBody(params)).toContain('pacing:{min:"1m",max:"1h"}');
expect(rewrittenBody(params)).toContain('action:\\"next_check\\"');
expect(rewrittenBody(params)).toContain('in:\\"<duration>\\"');
});
it("rewrites /loop status as a conversation-scoped list work order", async () => {
const params = buildLoopParams("/loop status");
expect(await handleLoopCommand(params, true)).toEqual({ shouldContinue: true });
expect(rewrittenBody(params)).toContain('action:"list", includeDisabled:true');
expect(rewrittenBody(params)).toContain(`name starts with "${LOOP_PREFIX}"`);
});
it("rewrites /loop stop as a scoped cron removal work order", async () => {
const params = buildLoopParams("/loop stop");
expect(await handleLoopCommand(params, true)).toEqual({ shouldContinue: true });
expect(rewrittenBody(params)).toContain('action:"list", includeDisabled:true');
expect(rewrittenBody(params)).toContain(`name starts with "${LOOP_PREFIX}"`);
expect(rewrittenBody(params)).toContain('action:"remove"');
expect(rewrittenBody(params)).toContain(
`Never remove a job whose name does not start with "${LOOP_PREFIX}"`,
);
});
it("rejects unauthorized senders", async () => {
const params = buildLoopParams("/loop 5m check ci");
params.command.isAuthorizedSender = false;
expect(await handleLoopCommand(params, true)).toEqual({ shouldContinue: false });
expect(rewrittenBody(params)).toBe("/loop 5m check ci");
});
it("rejects authorized non-owner senders before starting an agent turn", async () => {
const params = buildLoopParams("/loop 5m check ci");
params.command.senderIsOwner = false;
expect(await handleLoopCommand(params, true)).toEqual({ shouldContinue: false });
expect(rewrittenBody(params)).toBe("/loop 5m check ci");
});
it("rejects intervals below 30 seconds", async () => {
const result = await handleLoopCommand(buildLoopParams("/loop 5s x"), true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("Minimum interval 30s");
});
});
+178
View File
@@ -0,0 +1,178 @@
// Handles /loop by rewriting chat sugar into a cron-tool work order.
import { createHash } from "node:crypto";
import { parseDurationMs } from "../../cli/parse-duration.js";
import { truncateUtf16Safe } from "../../utils.js";
import { rejectNonOwnerCommand, rejectUnauthorizedCommand } from "./command-gates.js";
import type {
CommandHandler,
CommandHandlerResult,
HandleCommandsParams,
} from "./commands-types.js";
const LOOP_COMMAND_PREFIX = "/loop";
const LOOP_MIN_INTERVAL_MS = 30_000;
const LOOP_DEFAULT_INTERVAL_MS = 15 * 60_000;
const LOOP_NAME_MAX_LENGTH = 40;
const LOOP_USAGE =
"Usage: /loop [interval] <prompt> — repeat a prompt in this chat (e.g. /loop 5m check deploy status). Without interval the loop self-paces between 1m and 1h. /loop status lists loops; /loop stop [name] stops.";
function directReply(text: string): CommandHandlerResult {
return { shouldContinue: false, reply: { text } };
}
function applyLoopWorkOrderToContext(ctx: HandleCommandsParams["ctx"], instruction: string): void {
const mutableCtx = ctx as HandleCommandsParams["ctx"] & {
Body?: string;
RawBody?: string;
CommandBody?: string;
BodyForCommands?: string;
BodyForAgent?: string;
BodyStripped?: string;
commandText?: string;
agentText?: string;
rawText?: string;
};
mutableCtx.commandText = instruction;
mutableCtx.agentText = instruction;
mutableCtx.rawText = instruction;
mutableCtx.Body = instruction;
mutableCtx.RawBody = instruction;
mutableCtx.CommandBody = instruction;
mutableCtx.BodyForCommands = instruction;
mutableCtx.BodyForAgent = instruction;
mutableCtx.BodyStripped = instruction;
}
function applyLoopWorkOrder(params: HandleCommandsParams, instruction: string): void {
applyLoopWorkOrderToContext(params.ctx, instruction);
if (params.rootCtx && params.rootCtx !== params.ctx) {
applyLoopWorkOrderToContext(params.rootCtx, instruction);
}
params.command.rawBodyNormalized = instruction;
params.command.commandBodyNormalized = instruction;
}
function loopShortName(prompt: string): string {
return truncateUtf16Safe(prompt.trim(), LOOP_NAME_MAX_LENGTH).trimEnd();
}
// Conversation tag baked into the job name at create time. Status/stop match by
// this same tag, so loop discovery never depends on how the cron side resolves
// session keys (which can differ from the command pipeline's sessionKey and
// made stored-binding checks misfire in live testing). 48 bits keeps accidental
// cross-conversation prefix collisions negligible; all matched jobs are still
// the owner's own agent jobs.
function loopConversationTag(sessionKey: string): string {
return createHash("sha256").update(sessionKey).digest("hex").slice(0, 12);
}
function loopNamePrefix(sessionKey: string): string {
return `loop[${loopConversationTag(sessionKey)}]`;
}
const LOOP_FINAL_REPLY_ONLY =
"Reply with your normal final message only; do not use the message tool.";
function buildLoopPayloadMessage(params: {
prompt: string;
shortName: string;
selfPaced: boolean;
}): string {
const lines = [
`[loop ${params.shortName}] ${params.prompt}`,
"Do the task and reply concisely. If nothing changed since the last run, reply briefly.",
];
if (params.selfPaced) {
lines.push(
'Before replying, ALWAYS call the cron tool action:"next_check" with in:"<duration>" — pick the next check interval from how active the task is; back off toward 1h when quiet.',
);
}
return lines.join("\n");
}
function buildFixedLoopWorkOrder(prompt: string, everyMs: number, sessionKey: string): string {
const shortName = loopShortName(prompt);
const jobName = `${loopNamePrefix(sessionKey)} ${shortName}`;
const message = buildLoopPayloadMessage({ prompt, shortName, selfPaced: false });
return `Create a recurring loop with the cron tool, then confirm in one short line (name + cadence + '/loop stop' hint). ${LOOP_FINAL_REPLY_ONLY} action:"add", job:{name:${JSON.stringify(jobName)},schedule:{kind:"every",everyMs:${everyMs}},sessionTarget:"current",payload:{kind:"agentTurn",message:${JSON.stringify(message)}}}.`;
}
function buildSelfPacedLoopWorkOrder(prompt: string, sessionKey: string): string {
const shortName = loopShortName(prompt);
const jobName = `${loopNamePrefix(sessionKey)} ${shortName}`;
const message = buildLoopPayloadMessage({ prompt, shortName, selfPaced: true });
return `Create a recurring loop with the cron tool, then confirm in one short line (name + cadence + '/loop stop' hint). ${LOOP_FINAL_REPLY_ONLY} action:"add", job:{name:${JSON.stringify(jobName)},schedule:{kind:"every",everyMs:${LOOP_DEFAULT_INTERVAL_MS}},pacing:{min:"1m",max:"1h"},sessionTarget:"current",payload:{kind:"agentTurn",message:${JSON.stringify(message)}}}.`;
}
function buildLoopStatusWorkOrder(sessionKey: string): string {
const prefix = loopNamePrefix(sessionKey);
return `Use the cron tool (action:"list", includeDisabled:true) and report this conversation's loop jobs — exactly those whose name starts with ${JSON.stringify(prefix)}: name, schedule/pacing, enabled, last run, next run. If none, say so. ${LOOP_FINAL_REPLY_ONLY}`;
}
function buildLoopStopWorkOrder(name: string, sessionKey: string): string {
const prefix = loopNamePrefix(sessionKey);
const matchInstruction = name
? ` Among those, match ${JSON.stringify(name)} against the job name.`
: "";
return `List cron jobs (action:"list", includeDisabled:true) and find this conversation's loops — exactly those whose name starts with ${JSON.stringify(prefix)}.${matchInstruction} Remove the matching jobs with action:"remove" and confirm the removed names. If none matched, say so and list this conversation's active loop names. Never remove a job whose name does not start with ${JSON.stringify(prefix)}. ${LOOP_FINAL_REPLY_ONLY}`;
}
/** Command handler for conversation-bound recurring loops. */
export const handleLoopCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
const trimmed = params.command.commandBodyNormalized.trim();
const commandEnd = trimmed.search(/\s/u);
const commandToken = commandEnd === -1 ? trimmed : trimmed.slice(0, commandEnd);
if (commandToken.toLowerCase() !== LOOP_COMMAND_PREFIX) {
return null;
}
const unauthorized = rejectUnauthorizedCommand(params, LOOP_COMMAND_PREFIX);
if (unauthorized) {
return unauthorized;
}
const nonOwner = rejectNonOwnerCommand(params, LOOP_COMMAND_PREFIX);
if (nonOwner) {
return nonOwner;
}
const spec = commandEnd === -1 ? "" : trimmed.slice(commandEnd).trim();
if (!spec || spec.toLowerCase() === "help") {
return directReply(LOOP_USAGE);
}
if (spec.toLowerCase() === "status") {
applyLoopWorkOrder(params, buildLoopStatusWorkOrder(params.sessionKey));
return { shouldContinue: true };
}
const [firstToken = ""] = spec.split(/\s+/u);
if (firstToken.toLowerCase() === "stop") {
const name = spec.slice(firstToken.length).trim();
applyLoopWorkOrder(params, buildLoopStopWorkOrder(name, params.sessionKey));
return { shouldContinue: true };
}
let everyMs: number | undefined;
// Preserve parseDurationMs semantics: bare numbers are milliseconds.
// The 30s floor rejects hot-loop values instead of reinterpreting them as prompt text.
try {
everyMs = parseDurationMs(firstToken);
} catch {
everyMs = undefined;
}
if (everyMs !== undefined) {
if (everyMs < LOOP_MIN_INTERVAL_MS) {
return directReply(`${LOOP_USAGE} Minimum interval 30s.`);
}
const prompt = spec.slice(firstToken.length).trim();
if (!prompt) {
return directReply(LOOP_USAGE);
}
applyLoopWorkOrder(params, buildFixedLoopWorkOrder(prompt, everyMs, params.sessionKey));
return { shouldContinue: true };
}
applyLoopWorkOrder(params, buildSelfPacedLoopWorkOrder(spec, params.sessionKey));
return { shouldContinue: true };
};
@@ -0,0 +1,33 @@
// Create-time sessionTarget defaulting: agentTurn binds to the creating conversation.
import { describe, expect, it } from "vitest";
import { normalizeCronJobCreate } from "./normalize.js";
describe("normalizeCronJobCreate sessionTarget defaults", () => {
it("defaults agentTurn jobs with session context to current announce jobs", () => {
const normalized = normalizeCronJobCreate(
{
name: "agent turn current default",
schedule: { kind: "every", everyMs: 60_000 },
payload: { kind: "agentTurn", message: "hello" },
},
{ sessionContext: { sessionKey: "agent:main:discord:channel:ops" } },
);
expect(normalized).toMatchObject({
sessionTarget: "current",
sessionKey: "agent:main:discord:channel:ops",
delivery: { mode: "announce" },
});
});
it("downgrades the agentTurn current default to isolated without session context", () => {
const normalized = normalizeCronJobCreate({
name: "agent turn isolated fallback",
schedule: { kind: "every", everyMs: 60_000 },
payload: { kind: "agentTurn", message: "hello" },
});
expect(normalized).toMatchObject({
sessionTarget: "isolated",
delivery: { mode: "announce" },
});
});
});
-9
View File
@@ -433,15 +433,6 @@ describe("normalizeCronJobCreate", () => {
expect(delivery.mode).toBe("announce");
});
it("defaults omitted agentTurn targets to isolated announce jobs", () => {
const normalized = normalizeCronJobCreate({
name: "agent turn default",
schedule: { kind: "every", everyMs: 60_000 },
payload: { kind: "agentTurn", message: "hello" },
});
expect(normalized).toMatchObject({ sessionTarget: "isolated", delivery: { mode: "announce" } });
});
it("defaults command payloads to isolated announce jobs", () => {
const normalized = normalizeCronJobCreate({
name: "command default",
+8 -5
View File
@@ -501,11 +501,14 @@ export function normalizeCronJobInput(
}
if (!next.sessionTarget && isRecord(next.payload)) {
const kind = typeof next.payload.kind === "string" ? next.payload.kind : "";
// Keep create-time defaults explicit: system events join main, while agent
// turns isolate by default to avoid unbounded token accumulation.
// Agent turns bind to the creating conversation by default: the run carries
// that chat's context and announces its result there. Callers without session
// context are downgraded to isolated by resolveCronCurrentSessionTarget.
if (kind === "systemEvent" || kind === "heartbeat") {
next.sessionTarget = "main";
} else if (kind === "agentTurn" || kind === "command" || kind === "script") {
} else if (kind === "agentTurn") {
next.sessionTarget = "current";
} else if (kind === "command" || kind === "script") {
next.sessionTarget = "isolated";
}
}
@@ -555,8 +558,8 @@ export function normalizeCronJobInput(
const payload = isRecord(next.payload) ? next.payload : null;
const payloadKind = payload && typeof payload.kind === "string" ? payload.kind : "";
const sessionTarget = typeof next.sessionTarget === "string" ? next.sessionTarget : "";
// Omitted output targets were canonicalized to "isolated" above. Resolved
// "current" and custom session ids share those announce semantics.
// Agent turns resolve to current with context and isolated without it.
// Current and custom session ids share isolated announce semantics.
const hasDelivery = "delivery" in next && next.delivery !== undefined;
if (!hasDelivery && shouldDefaultCronDeliveryToAnnounce({ payloadKind, sessionTarget })) {
next.delivery = { mode: "announce" };
+36
View File
@@ -453,6 +453,42 @@ describe("gateway server cron", () => {
closeTrackedBrowserTabsForSessionsMock.mockClear();
});
test("defaults cron.add agentTurn targets from available session context", async () => {
const { prevSkipCron } = await setupCronTestRun({
tempPrefix: "openclaw-gw-cron-agent-turn-default-",
cronEnabled: false,
});
const cronState = await createDirectCronState();
try {
const withContext = await directCronReq(cronState, "cron.add", {
name: "conversation loop",
schedule: { kind: "every", everyMs: 60_000 },
sessionKey: "agent:main:webchat:loop",
payload: { kind: "agentTurn", message: "check status" },
});
expect(withContext.ok).toBe(true);
expect(withContext.payload).toMatchObject({
sessionTarget: "current",
sessionKey: "agent:main:webchat:loop",
delivery: { mode: "announce" },
});
const withoutContext = await directCronReq(cronState, "cron.add", {
name: "detached loop",
schedule: { kind: "every", everyMs: 60_000 },
payload: { kind: "agentTurn", message: "check status" },
});
expect(withoutContext.ok).toBe(true);
expect(withoutContext.payload).toMatchObject({
sessionTarget: "isolated",
delivery: { mode: "announce" },
});
} finally {
await cleanupCronTestRun({ cronState, prevSkipCron });
}
});
test("handles cron CRUD, normalization, and patch semantics", { timeout: 45_000 }, async () => {
const { prevSkipCron } = await setupCronTestRun({
tempPrefix: "openclaw-gw-cron-",
+1
View File
@@ -52,6 +52,7 @@ function getReservedCommands(): Set<string> {
"activation",
"skill",
"learn",
"loop",
"subagents",
"kill",
"steer",
@@ -434,7 +434,7 @@
},
{
"deferLoading": true,
"description": "Gateway schedules/wakes: reminders, later checks/follow-ups, recurring work. Never exec sleep/process-poll as timer. Main job => heartbeat system event; isolated => background task in `openclaw tasks`.\n\nACTIONS:\n- status scheduler; list compact summaries (includeDisabled, session agentId auto-filter; get for full); get jobId\n- add job; update jobId+patch; remove jobId\n- run jobId (due only; runMode=\"force\" now); runs jobId history; next_check in (current paced job only)\n- wake text (+ optional mode). Default caller lane; top-level sessionKey/agentId selects another caller-owned lane.\n\nADD JOB:\n{ \"name\":\"...\", \"schedule\":{...}, \"pacing\":{ \"min\":\"15m\", \"max\":\"4h\" }, \"trigger\":{ \"script\":\"...\", \"once\":false }, \"payload\":{...}, \"delivery\":{...}, \"sessionTarget\":\"main|isolated|current|session:<id>\", \"enabled\":true }\nRequired: schedule,payload. enabled default true. trigger only every/cron.\n\nTARGET/PAYLOAD:\n- main => systemEvent {kind:\"systemEvent\",text:\"...\"} or script; systemEvent defaults main.\n- isolated/current/session:<id> => agentTurn {kind:\"agentTurn\",message:\"...\",model?,thinking?,timeoutSeconds?}; agentTurn defaults isolated. timeoutSeconds=0 means none.\n- script {kind:\"script\",script:\"...\",timeoutSeconds?,toolBudget?} supports main or isolated only and requires cron.triggers.enabled.\n- current binds caller session at creation. session:<id> is persistent. Prefer isolated unless user explicitly wants current binding.\n\nSCHEDULE:\n- at: {kind:\"at\",at:\"ISO-8601\"}; timezone-less = UTC.\n- every: {kind:\"every\",everyMs:<ms>,anchorMs?}.\n- cron: {kind:\"cron\",expr:\"...\",tz?:\"IANA\"}. Expr is requested local wall time; never pre-convert to UTC. Missing tz = Gateway host local, not UTC. Shanghai 18:00: {kind:\"cron\",expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n\nTRIGGER SCRIPT:\n- Requires cron.triggers.enabled; if off, explain and never model-poll fallback.\n- Headless owner allowlist; quiet check has no model. Prior trigger.state is frozen JSON. Return/json({fire:boolean,message?:string,state?:JSONValue}); create new state, never mutate prior.\n- fire:false saves state only; no payload/history. fire:true runs payload and appends message; fired state saves only after payload success.\n- Fire on every actionable state, including failures/timeouts; success-only watchers go silent when broken, which looks healthy. Dedupe by comparing trigger.state and returning new state, never memory.\n- Keep scripts read-only; actions belong in payload. message must be self-contained: it is the fired run's entire context.\n- Silent watcher: top-level delivery.mode=\"none\". Omitted delivery on isolated agentTurn announces and missing route may fail.\n- once:true disables after first successful fire. Per check: 30s, 5 tool calls, 16KB state.\n- Hidden Code Mode tools: await tools.call(\"exec\", {command:\"...\"}); unknown id => search/describe.\n\nDELIVERY top-level: {mode:\"none|announce|webhook\",channel?,to?,threadId?,bestEffort?}\n- Isolated agentTurn omitted delivery => announce. announce only isolated/current/session; channel/to optional; threadId chat topic. Specific chat: set channel/to; no messaging tool inside run.\n- webhook posts finished-run event to URL in to.\n\nRestricted isolated runs may only self status/list, current get/runs/remove, and next_check for their own paced job. wake mode: next-heartbeat default | now. jobId canonical; id compat. contextMessages 0-10 adds prior messages.",
"description": "Gateway scheduler: reminders, delayed self-wakeups, loops, recurring work, event watchers. Never exec sleep/poll as timer.\n\nACTIONS: status | list [includeDisabled] | get jobId | add job | update jobId patch | remove jobId | run jobId (runMode \"force\"=now) | runs jobId = history | next_check in:\"30m\" (own paced run only) | wake text mode?:\"now\"|\"next-heartbeat\"(default) nudges a caller-owned lane (sessionKey/agentId to pick another).\n\nADD: {name?,schedule,payload,sessionTarget?,pacing?,trigger?,delivery?,enabled?}. Required: schedule+payload.\n\nSCHEDULE:\n- {kind:\"at\",at:\"ISO-8601\"} one-shot; no tz=UTC; auto-deletes after run.\n- {kind:\"every\",everyMs}.\n- {kind:\"cron\",expr,tz?:\"IANA\"}: expr is wall time in tz; never pre-convert to UTC; no tz=gateway host local. 18:00 Shanghai => {expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n- {kind:\"stream\",command:[argv],mode?:\"line\"|\"match\",match?}: fires on supervised process output; needs cron.triggers.enabled.\n\nTARGET+PAYLOAD:\n- \"current\" (agentTurn default) = this conversation: run carries this chat's context, result lands here. Self-wakeup/\"continue later\"/loop = at|every + agentTurn + current.\n- \"isolated\" = fresh detached session (shows in `openclaw tasks`); standalone background work.\n- \"main\" = heartbeat lane; payload {kind:\"systemEvent\",text} (systemEvent default target).\n- \"session:<key>\" = named session.\n- agentTurn {kind:\"agentTurn\",message,model?,thinking?,timeoutSeconds?}; timeoutSeconds 0=none.\n- script {kind:\"script\",script,timeoutSeconds?,toolBudget?}: main|isolated only; needs cron.triggers.enabled.\n\nPACED LOOP: recurring job + pacing{min?,max?} durations (\"15m\",\"4h\"; at least one). Inside its run, job calls next_check in:\"<dur>\" to set the next delay (clamped to bounds, measured from run end; failed runs keep normal backoff). Adaptive polling: tighten when active, back off when quiet.\n\nTRIGGER (condition watcher on every/cron): {script,once?}; needs cron.triggers.enabled if off, say so; never model-poll instead. Quiet headless check, no model; 30s/5 tool calls/16KB state. Read frozen trigger.state, return json({fire,message?,state?}) with NEW state; dedupe via state, never memory. fire:false saves state only. fire:true runs payload; message is that run's entire context — self-contained. Fire on failures/timeouts too; success-only watchers look healthy when broken. Script stays read-only; actions belong in payload. once:true disables after first fire. Code Mode: await tools.call(\"exec\",{command:\"...\"}).\n\nDELIVERY {mode:\"none\"|\"announce\"|\"webhook\",channel?,to?,threadId?,bestEffort?}: where detached run output goes. Omitted=announce (current=>this chat; isolated=>last route; set channel/to for a specific chat — no messaging tool inside the run). Silent watcher=>mode:\"none\". webhook posts finished-run event to URL in `to`.\n\nJob wakeMode (main jobs): \"now\"(default)|\"next-heartbeat\". Restricted cron-run sessions: self status/list/get/runs/remove + own next_check only. failureAlert {...}|false disables. jobId canonical (id=compat). contextMessages 0-10 embeds recent chat lines into reminder text.",
"inputSchema": {
"additionalProperties": true,
"properties": {
@@ -652,6 +652,7 @@
"additionalProperties": true,
"properties": {
"allowUnsafeExternalContent": {
"description": "Allow untrusted external content in prompt",
"type": "boolean"
},
"fallbacks": {
@@ -667,6 +668,7 @@
"type": "string"
},
"lightContext": {
"description": "Lightweight bootstrap context (skip full workspace context)",
"type": "boolean"
},
"message": {
@@ -748,7 +750,7 @@
},
"kind": {
"description": "Schedule kind",
"enum": ["at", "every", "cron"],
"enum": ["at", "every", "cron", "stream"],
"type": "string"
},
"match": {
@@ -787,7 +789,7 @@
"description": "Explicit session key, or null to clear it"
},
"sessionTarget": {
"description": "main | isolated | current | session:<id>",
"description": "main | isolated | current (agentTurn default) | session:<id>",
"type": "string"
},
"trigger": {
@@ -817,6 +819,7 @@
"type": "string"
},
"mode": {
"description": "Wake mode for action=\"wake\" (default next-heartbeat)",
"enum": ["now", "next-heartbeat"],
"type": "string"
},
@@ -1044,6 +1047,7 @@
"additionalProperties": true,
"properties": {
"allowUnsafeExternalContent": {
"description": "Allow untrusted external content in prompt",
"type": "boolean"
},
"fallbacks": {
@@ -1066,6 +1070,7 @@
"type": "string"
},
"lightContext": {
"description": "Lightweight bootstrap context (skip full workspace context)",
"type": "boolean"
},
"message": {
@@ -1161,7 +1166,7 @@
},
"kind": {
"description": "Schedule kind",
"enum": ["at", "every", "cron"],
"enum": ["at", "every", "cron", "stream"],
"type": "string"
},
"match": {
@@ -1200,7 +1205,7 @@
"description": "Explicit session key, or null to clear it"
},
"sessionTarget": {
"description": "Session target",
"description": "main | isolated | current (agentTurn default) | session:<id>",
"type": "string"
},
"trigger": {
@@ -1242,6 +1247,7 @@
"type": "string"
},
"text": {
"description": "systemEvent text for action=\"wake\"",
"type": "string"
},
"timeoutMs": {
@@ -430,7 +430,7 @@
},
{
"deferLoading": true,
"description": "Gateway schedules/wakes: reminders, later checks/follow-ups, recurring work. Never exec sleep/process-poll as timer. Main job => heartbeat system event; isolated => background task in `openclaw tasks`.\n\nACTIONS:\n- status scheduler; list compact summaries (includeDisabled, session agentId auto-filter; get for full); get jobId\n- add job; update jobId+patch; remove jobId\n- run jobId (due only; runMode=\"force\" now); runs jobId history; next_check in (current paced job only)\n- wake text (+ optional mode). Default caller lane; top-level sessionKey/agentId selects another caller-owned lane.\n\nADD JOB:\n{ \"name\":\"...\", \"schedule\":{...}, \"pacing\":{ \"min\":\"15m\", \"max\":\"4h\" }, \"trigger\":{ \"script\":\"...\", \"once\":false }, \"payload\":{...}, \"delivery\":{...}, \"sessionTarget\":\"main|isolated|current|session:<id>\", \"enabled\":true }\nRequired: schedule,payload. enabled default true. trigger only every/cron.\n\nTARGET/PAYLOAD:\n- main => systemEvent {kind:\"systemEvent\",text:\"...\"} or script; systemEvent defaults main.\n- isolated/current/session:<id> => agentTurn {kind:\"agentTurn\",message:\"...\",model?,thinking?,timeoutSeconds?}; agentTurn defaults isolated. timeoutSeconds=0 means none.\n- script {kind:\"script\",script:\"...\",timeoutSeconds?,toolBudget?} supports main or isolated only and requires cron.triggers.enabled.\n- current binds caller session at creation. session:<id> is persistent. Prefer isolated unless user explicitly wants current binding.\n\nSCHEDULE:\n- at: {kind:\"at\",at:\"ISO-8601\"}; timezone-less = UTC.\n- every: {kind:\"every\",everyMs:<ms>,anchorMs?}.\n- cron: {kind:\"cron\",expr:\"...\",tz?:\"IANA\"}. Expr is requested local wall time; never pre-convert to UTC. Missing tz = Gateway host local, not UTC. Shanghai 18:00: {kind:\"cron\",expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n\nTRIGGER SCRIPT:\n- Requires cron.triggers.enabled; if off, explain and never model-poll fallback.\n- Headless owner allowlist; quiet check has no model. Prior trigger.state is frozen JSON. Return/json({fire:boolean,message?:string,state?:JSONValue}); create new state, never mutate prior.\n- fire:false saves state only; no payload/history. fire:true runs payload and appends message; fired state saves only after payload success.\n- Fire on every actionable state, including failures/timeouts; success-only watchers go silent when broken, which looks healthy. Dedupe by comparing trigger.state and returning new state, never memory.\n- Keep scripts read-only; actions belong in payload. message must be self-contained: it is the fired run's entire context.\n- Silent watcher: top-level delivery.mode=\"none\". Omitted delivery on isolated agentTurn announces and missing route may fail.\n- once:true disables after first successful fire. Per check: 30s, 5 tool calls, 16KB state.\n- Hidden Code Mode tools: await tools.call(\"exec\", {command:\"...\"}); unknown id => search/describe.\n\nDELIVERY top-level: {mode:\"none|announce|webhook\",channel?,to?,threadId?,bestEffort?}\n- Isolated agentTurn omitted delivery => announce. announce only isolated/current/session; channel/to optional; threadId chat topic. Specific chat: set channel/to; no messaging tool inside run.\n- webhook posts finished-run event to URL in to.\n\nRestricted isolated runs may only self status/list, current get/runs/remove, and next_check for their own paced job. wake mode: next-heartbeat default | now. jobId canonical; id compat. contextMessages 0-10 adds prior messages.",
"description": "Gateway scheduler: reminders, delayed self-wakeups, loops, recurring work, event watchers. Never exec sleep/poll as timer.\n\nACTIONS: status | list [includeDisabled] | get jobId | add job | update jobId patch | remove jobId | run jobId (runMode \"force\"=now) | runs jobId = history | next_check in:\"30m\" (own paced run only) | wake text mode?:\"now\"|\"next-heartbeat\"(default) nudges a caller-owned lane (sessionKey/agentId to pick another).\n\nADD: {name?,schedule,payload,sessionTarget?,pacing?,trigger?,delivery?,enabled?}. Required: schedule+payload.\n\nSCHEDULE:\n- {kind:\"at\",at:\"ISO-8601\"} one-shot; no tz=UTC; auto-deletes after run.\n- {kind:\"every\",everyMs}.\n- {kind:\"cron\",expr,tz?:\"IANA\"}: expr is wall time in tz; never pre-convert to UTC; no tz=gateway host local. 18:00 Shanghai => {expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n- {kind:\"stream\",command:[argv],mode?:\"line\"|\"match\",match?}: fires on supervised process output; needs cron.triggers.enabled.\n\nTARGET+PAYLOAD:\n- \"current\" (agentTurn default) = this conversation: run carries this chat's context, result lands here. Self-wakeup/\"continue later\"/loop = at|every + agentTurn + current.\n- \"isolated\" = fresh detached session (shows in `openclaw tasks`); standalone background work.\n- \"main\" = heartbeat lane; payload {kind:\"systemEvent\",text} (systemEvent default target).\n- \"session:<key>\" = named session.\n- agentTurn {kind:\"agentTurn\",message,model?,thinking?,timeoutSeconds?}; timeoutSeconds 0=none.\n- script {kind:\"script\",script,timeoutSeconds?,toolBudget?}: main|isolated only; needs cron.triggers.enabled.\n\nPACED LOOP: recurring job + pacing{min?,max?} durations (\"15m\",\"4h\"; at least one). Inside its run, job calls next_check in:\"<dur>\" to set the next delay (clamped to bounds, measured from run end; failed runs keep normal backoff). Adaptive polling: tighten when active, back off when quiet.\n\nTRIGGER (condition watcher on every/cron): {script,once?}; needs cron.triggers.enabled if off, say so; never model-poll instead. Quiet headless check, no model; 30s/5 tool calls/16KB state. Read frozen trigger.state, return json({fire,message?,state?}) with NEW state; dedupe via state, never memory. fire:false saves state only. fire:true runs payload; message is that run's entire context — self-contained. Fire on failures/timeouts too; success-only watchers look healthy when broken. Script stays read-only; actions belong in payload. once:true disables after first fire. Code Mode: await tools.call(\"exec\",{command:\"...\"}).\n\nDELIVERY {mode:\"none\"|\"announce\"|\"webhook\",channel?,to?,threadId?,bestEffort?}: where detached run output goes. Omitted=announce (current=>this chat; isolated=>last route; set channel/to for a specific chat — no messaging tool inside the run). Silent watcher=>mode:\"none\". webhook posts finished-run event to URL in `to`.\n\nJob wakeMode (main jobs): \"now\"(default)|\"next-heartbeat\". Restricted cron-run sessions: self status/list/get/runs/remove + own next_check only. failureAlert {...}|false disables. jobId canonical (id=compat). contextMessages 0-10 embeds recent chat lines into reminder text.",
"inputSchema": {
"additionalProperties": true,
"properties": {
@@ -648,6 +648,7 @@
"additionalProperties": true,
"properties": {
"allowUnsafeExternalContent": {
"description": "Allow untrusted external content in prompt",
"type": "boolean"
},
"fallbacks": {
@@ -663,6 +664,7 @@
"type": "string"
},
"lightContext": {
"description": "Lightweight bootstrap context (skip full workspace context)",
"type": "boolean"
},
"message": {
@@ -744,7 +746,7 @@
},
"kind": {
"description": "Schedule kind",
"enum": ["at", "every", "cron"],
"enum": ["at", "every", "cron", "stream"],
"type": "string"
},
"match": {
@@ -783,7 +785,7 @@
"description": "Explicit session key, or null to clear it"
},
"sessionTarget": {
"description": "main | isolated | current | session:<id>",
"description": "main | isolated | current (agentTurn default) | session:<id>",
"type": "string"
},
"trigger": {
@@ -813,6 +815,7 @@
"type": "string"
},
"mode": {
"description": "Wake mode for action=\"wake\" (default next-heartbeat)",
"enum": ["now", "next-heartbeat"],
"type": "string"
},
@@ -1040,6 +1043,7 @@
"additionalProperties": true,
"properties": {
"allowUnsafeExternalContent": {
"description": "Allow untrusted external content in prompt",
"type": "boolean"
},
"fallbacks": {
@@ -1062,6 +1066,7 @@
"type": "string"
},
"lightContext": {
"description": "Lightweight bootstrap context (skip full workspace context)",
"type": "boolean"
},
"message": {
@@ -1157,7 +1162,7 @@
},
"kind": {
"description": "Schedule kind",
"enum": ["at", "every", "cron"],
"enum": ["at", "every", "cron", "stream"],
"type": "string"
},
"match": {
@@ -1196,7 +1201,7 @@
"description": "Explicit session key, or null to clear it"
},
"sessionTarget": {
"description": "Session target",
"description": "main | isolated | current (agentTurn default) | session:<id>",
"type": "string"
},
"trigger": {
@@ -1238,6 +1243,7 @@
"type": "string"
},
"text": {
"description": "systemEvent text for action=\"wake\"",
"type": "string"
},
"timeoutMs": {
@@ -430,7 +430,7 @@
},
{
"deferLoading": true,
"description": "Gateway schedules/wakes: reminders, later checks/follow-ups, recurring work. Never exec sleep/process-poll as timer. Main job => heartbeat system event; isolated => background task in `openclaw tasks`.\n\nACTIONS:\n- status scheduler; list compact summaries (includeDisabled, session agentId auto-filter; get for full); get jobId\n- add job; update jobId+patch; remove jobId\n- run jobId (due only; runMode=\"force\" now); runs jobId history; next_check in (current paced job only)\n- wake text (+ optional mode). Default caller lane; top-level sessionKey/agentId selects another caller-owned lane.\n\nADD JOB:\n{ \"name\":\"...\", \"schedule\":{...}, \"pacing\":{ \"min\":\"15m\", \"max\":\"4h\" }, \"trigger\":{ \"script\":\"...\", \"once\":false }, \"payload\":{...}, \"delivery\":{...}, \"sessionTarget\":\"main|isolated|current|session:<id>\", \"enabled\":true }\nRequired: schedule,payload. enabled default true. trigger only every/cron.\n\nTARGET/PAYLOAD:\n- main => systemEvent {kind:\"systemEvent\",text:\"...\"} or script; systemEvent defaults main.\n- isolated/current/session:<id> => agentTurn {kind:\"agentTurn\",message:\"...\",model?,thinking?,timeoutSeconds?}; agentTurn defaults isolated. timeoutSeconds=0 means none.\n- script {kind:\"script\",script:\"...\",timeoutSeconds?,toolBudget?} supports main or isolated only and requires cron.triggers.enabled.\n- current binds caller session at creation. session:<id> is persistent. Prefer isolated unless user explicitly wants current binding.\n\nSCHEDULE:\n- at: {kind:\"at\",at:\"ISO-8601\"}; timezone-less = UTC.\n- every: {kind:\"every\",everyMs:<ms>,anchorMs?}.\n- cron: {kind:\"cron\",expr:\"...\",tz?:\"IANA\"}. Expr is requested local wall time; never pre-convert to UTC. Missing tz = Gateway host local, not UTC. Shanghai 18:00: {kind:\"cron\",expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n\nTRIGGER SCRIPT:\n- Requires cron.triggers.enabled; if off, explain and never model-poll fallback.\n- Headless owner allowlist; quiet check has no model. Prior trigger.state is frozen JSON. Return/json({fire:boolean,message?:string,state?:JSONValue}); create new state, never mutate prior.\n- fire:false saves state only; no payload/history. fire:true runs payload and appends message; fired state saves only after payload success.\n- Fire on every actionable state, including failures/timeouts; success-only watchers go silent when broken, which looks healthy. Dedupe by comparing trigger.state and returning new state, never memory.\n- Keep scripts read-only; actions belong in payload. message must be self-contained: it is the fired run's entire context.\n- Silent watcher: top-level delivery.mode=\"none\". Omitted delivery on isolated agentTurn announces and missing route may fail.\n- once:true disables after first successful fire. Per check: 30s, 5 tool calls, 16KB state.\n- Hidden Code Mode tools: await tools.call(\"exec\", {command:\"...\"}); unknown id => search/describe.\n\nDELIVERY top-level: {mode:\"none|announce|webhook\",channel?,to?,threadId?,bestEffort?}\n- Isolated agentTurn omitted delivery => announce. announce only isolated/current/session; channel/to optional; threadId chat topic. Specific chat: set channel/to; no messaging tool inside run.\n- webhook posts finished-run event to URL in to.\n\nRestricted isolated runs may only self status/list, current get/runs/remove, and next_check for their own paced job. wake mode: next-heartbeat default | now. jobId canonical; id compat. contextMessages 0-10 adds prior messages.",
"description": "Gateway scheduler: reminders, delayed self-wakeups, loops, recurring work, event watchers. Never exec sleep/poll as timer.\n\nACTIONS: status | list [includeDisabled] | get jobId | add job | update jobId patch | remove jobId | run jobId (runMode \"force\"=now) | runs jobId = history | next_check in:\"30m\" (own paced run only) | wake text mode?:\"now\"|\"next-heartbeat\"(default) nudges a caller-owned lane (sessionKey/agentId to pick another).\n\nADD: {name?,schedule,payload,sessionTarget?,pacing?,trigger?,delivery?,enabled?}. Required: schedule+payload.\n\nSCHEDULE:\n- {kind:\"at\",at:\"ISO-8601\"} one-shot; no tz=UTC; auto-deletes after run.\n- {kind:\"every\",everyMs}.\n- {kind:\"cron\",expr,tz?:\"IANA\"}: expr is wall time in tz; never pre-convert to UTC; no tz=gateway host local. 18:00 Shanghai => {expr:\"0 18 * * *\",tz:\"Asia/Shanghai\"}.\n- {kind:\"stream\",command:[argv],mode?:\"line\"|\"match\",match?}: fires on supervised process output; needs cron.triggers.enabled.\n\nTARGET+PAYLOAD:\n- \"current\" (agentTurn default) = this conversation: run carries this chat's context, result lands here. Self-wakeup/\"continue later\"/loop = at|every + agentTurn + current.\n- \"isolated\" = fresh detached session (shows in `openclaw tasks`); standalone background work.\n- \"main\" = heartbeat lane; payload {kind:\"systemEvent\",text} (systemEvent default target).\n- \"session:<key>\" = named session.\n- agentTurn {kind:\"agentTurn\",message,model?,thinking?,timeoutSeconds?}; timeoutSeconds 0=none.\n- script {kind:\"script\",script,timeoutSeconds?,toolBudget?}: main|isolated only; needs cron.triggers.enabled.\n\nPACED LOOP: recurring job + pacing{min?,max?} durations (\"15m\",\"4h\"; at least one). Inside its run, job calls next_check in:\"<dur>\" to set the next delay (clamped to bounds, measured from run end; failed runs keep normal backoff). Adaptive polling: tighten when active, back off when quiet.\n\nTRIGGER (condition watcher on every/cron): {script,once?}; needs cron.triggers.enabled if off, say so; never model-poll instead. Quiet headless check, no model; 30s/5 tool calls/16KB state. Read frozen trigger.state, return json({fire,message?,state?}) with NEW state; dedupe via state, never memory. fire:false saves state only. fire:true runs payload; message is that run's entire context — self-contained. Fire on failures/timeouts too; success-only watchers look healthy when broken. Script stays read-only; actions belong in payload. once:true disables after first fire. Code Mode: await tools.call(\"exec\",{command:\"...\"}).\n\nDELIVERY {mode:\"none\"|\"announce\"|\"webhook\",channel?,to?,threadId?,bestEffort?}: where detached run output goes. Omitted=announce (current=>this chat; isolated=>last route; set channel/to for a specific chat — no messaging tool inside the run). Silent watcher=>mode:\"none\". webhook posts finished-run event to URL in `to`.\n\nJob wakeMode (main jobs): \"now\"(default)|\"next-heartbeat\". Restricted cron-run sessions: self status/list/get/runs/remove + own next_check only. failureAlert {...}|false disables. jobId canonical (id=compat). contextMessages 0-10 embeds recent chat lines into reminder text.",
"inputSchema": {
"additionalProperties": true,
"properties": {
@@ -648,6 +648,7 @@
"additionalProperties": true,
"properties": {
"allowUnsafeExternalContent": {
"description": "Allow untrusted external content in prompt",
"type": "boolean"
},
"fallbacks": {
@@ -663,6 +664,7 @@
"type": "string"
},
"lightContext": {
"description": "Lightweight bootstrap context (skip full workspace context)",
"type": "boolean"
},
"message": {
@@ -744,7 +746,7 @@
},
"kind": {
"description": "Schedule kind",
"enum": ["at", "every", "cron"],
"enum": ["at", "every", "cron", "stream"],
"type": "string"
},
"match": {
@@ -783,7 +785,7 @@
"description": "Explicit session key, or null to clear it"
},
"sessionTarget": {
"description": "main | isolated | current | session:<id>",
"description": "main | isolated | current (agentTurn default) | session:<id>",
"type": "string"
},
"trigger": {
@@ -813,6 +815,7 @@
"type": "string"
},
"mode": {
"description": "Wake mode for action=\"wake\" (default next-heartbeat)",
"enum": ["now", "next-heartbeat"],
"type": "string"
},
@@ -1040,6 +1043,7 @@
"additionalProperties": true,
"properties": {
"allowUnsafeExternalContent": {
"description": "Allow untrusted external content in prompt",
"type": "boolean"
},
"fallbacks": {
@@ -1062,6 +1066,7 @@
"type": "string"
},
"lightContext": {
"description": "Lightweight bootstrap context (skip full workspace context)",
"type": "boolean"
},
"message": {
@@ -1157,7 +1162,7 @@
},
"kind": {
"description": "Schedule kind",
"enum": ["at", "every", "cron"],
"enum": ["at", "every", "cron", "stream"],
"type": "string"
},
"match": {
@@ -1196,7 +1201,7 @@
"description": "Explicit session key, or null to clear it"
},
"sessionTarget": {
"description": "Session target",
"description": "main | isolated | current (agentTurn default) | session:<id>",
"type": "string"
},
"trigger": {
@@ -1238,6 +1243,7 @@
"type": "string"
},
"text": {
"description": "systemEvent text for action=\"wake\"",
"type": "string"
},
"timeoutMs": {
@@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 59298,
"roughTokens": 14825
"chars": 59624,
"roughTokens": 14906
},
"openClawDeveloperInstructions": {
"chars": 3715,
@@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 7041
},
"totalWithDynamicToolsJson": {
"chars": 87462,
"roughTokens": 21866
"chars": 87788,
"roughTokens": 21947
},
"userInputText": {
"chars": 1364,
@@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 58990,
"roughTokens": 14748
"chars": 59316,
"roughTokens": 14829
},
"openClawDeveloperInstructions": {
"chars": 2606,
@@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6671
},
"totalWithDynamicToolsJson": {
"chars": 85674,
"roughTokens": 21419
"chars": 86000,
"roughTokens": 21500
},
"userInputText": {
"chars": 993,
@@ -222,8 +222,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 60552,
"roughTokens": 15138
"chars": 60878,
"roughTokens": 15220
},
"openClawDeveloperInstructions": {
"chars": 2625,
@@ -234,8 +234,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6783
},
"totalWithDynamicToolsJson": {
"chars": 87684,
"roughTokens": 21921
"chars": 88010,
"roughTokens": 22003
},
"userInputText": {
"chars": 1348,