mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
e154330a42
commit
b5137604ef
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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-",
|
||||
|
||||
@@ -52,6 +52,7 @@ function getReservedCommands(): Set<string> {
|
||||
"activation",
|
||||
"skill",
|
||||
"learn",
|
||||
"loop",
|
||||
"subagents",
|
||||
"kill",
|
||||
"steer",
|
||||
|
||||
+11
-5
@@ -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": {
|
||||
|
||||
+11
-5
@@ -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": {
|
||||
|
||||
+11
-5
@@ -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": {
|
||||
|
||||
Vendored
+4
-4
@@ -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,
|
||||
|
||||
test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md
Vendored
+4
-4
@@ -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,
|
||||
|
||||
Vendored
+4
-4
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user