diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 7c26fa4eb48a..f06885848776 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -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] ` 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`. + **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. diff --git a/docs/cli/cron.md b/docs/cli/cron.md index 88297ea7376f..5b246eade26b 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -56,6 +56,8 @@ openclaw cron create "*/15 * * * *" \ `--session` accepts `main`, `isolated`, `current`, or `session:`. +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`. + - `main` binds to the agent's main session. diff --git a/docs/docs_map.md b/docs/docs_map.md index d84dfaec39ef..ac37a2dbf9eb 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 7427088ac511..153e54b1e547 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -248,6 +248,9 @@ plugins. | --- | --- | | `/skill [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] ` | 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 ` | Resolve exec or plugin approval prompts | | `/btw ` | 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 diff --git a/src/agents/tools/cron-tool.flat-params.test.ts b/src/agents/tools/cron-tool.flat-params.test.ts index 3a42ce7b4ebd..f2d194b55f16 100644 --- a/src/agents/tools/cron-tool.flat-params.test.ts +++ b/src/agents/tools/cron-tool.flat-params.test.ts @@ -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 () => { diff --git a/src/agents/tools/cron-tool.schema.test.ts b/src/agents/tools/cron-tool.schema.test.ts index 262981fa8983..a85fead2a208 100644 --- a/src/agents/tools/cron-tool.schema.test.ts +++ b/src/agents/tools/cron-tool.schema.test.ts @@ -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:", + ); + } + 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", () => { diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 710552407ec9..bc81061f6082 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -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 = undefined; const tool = createCronTool( diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index db5a7ac5000a..06598668d10d 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -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:", + description: "main | isolated | current (agentTurn default) | session:", }), ), 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:", + }), + ), 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:", "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: => 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: 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:,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:" = 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:"" 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; diff --git a/src/auto-reply/commands-registry.shared.ts b/src/auto-reply/commands-registry.shared.ts index 92f3ce47166e..a413e74ab512 100644 --- a/src/auto-reply/commands-registry.shared.ts +++ b/src/auto-reply/commands-registry.shared.ts @@ -250,6 +250,23 @@ export function buildBuiltinChatCommands( }, ], }), + defineChatCommand({ + key: "loop", + nativeName: "loop", + description: "Loop a prompt: /loop [interval] | /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", diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index fe69d0db8511..d9c55be5378c 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -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); diff --git a/src/auto-reply/reply/commands-handlers.order.ts b/src/auto-reply/reply/commands-handlers.order.ts index 8adc7033d540..a0e449c59c6f 100644 --- a/src/auto-reply/reply/commands-handlers.order.ts +++ b/src/auto-reply/reply/commands-handlers.order.ts @@ -22,6 +22,7 @@ export const commandHandlerOrder = [ "status", "goal", "learn", + "loop", "name", "diagnostics", "tasks", diff --git a/src/auto-reply/reply/commands-handlers.runtime.ts b/src/auto-reply/reply/commands-handlers.runtime.ts index f293f449a400..04ed7e4d0dec 100644 --- a/src/auto-reply/reply/commands-handlers.runtime.ts +++ b/src/auto-reply/reply/commands-handlers.runtime.ts @@ -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, diff --git a/src/auto-reply/reply/commands-loop.test.ts b/src/auto-reply/reply/commands-loop.test.ts new file mode 100644 index 000000000000..bbd30cf43a53 --- /dev/null +++ b/src/auto-reply/reply/commands-loop.test.ts @@ -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] — 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:\\"\\"'); + }); + + 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"); + }); +}); diff --git a/src/auto-reply/reply/commands-loop.ts b/src/auto-reply/reply/commands-loop.ts new file mode 100644 index 000000000000..a3a978506902 --- /dev/null +++ b/src/auto-reply/reply/commands-loop.ts @@ -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] — 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:"" — 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 }; +}; diff --git a/src/cron/normalize.session-target-default.test.ts b/src/cron/normalize.session-target-default.test.ts new file mode 100644 index 000000000000..d4d19398cc7c --- /dev/null +++ b/src/cron/normalize.session-target-default.test.ts @@ -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" }, + }); + }); +}); diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts index 7793f1f2ddc8..f449f76d412a 100644 --- a/src/cron/normalize.test.ts +++ b/src/cron/normalize.test.ts @@ -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", diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index 6fda66eeec21..ef8345a52526 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -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" }; diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index cbb3bf028f0d..96456d3e4e46 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -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-", diff --git a/src/plugins/command-registration.ts b/src/plugins/command-registration.ts index 5d428c3d6385..586243ac2bfb 100644 --- a/src/plugins/command-registration.ts +++ b/src/plugins/command-registration.ts @@ -52,6 +52,7 @@ function getReservedCommands(): Set { "activation", "skill", "learn", + "loop", "subagents", "kill", "steer", diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json index 2518da0409b0..070a98644301 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json @@ -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:\", \"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: => 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: 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:,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:\" = 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:\"\" 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:", + "description": "main | isolated | current (agentTurn default) | session:", "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:", "type": "string" }, "trigger": { @@ -1242,6 +1247,7 @@ "type": "string" }, "text": { + "description": "systemEvent text for action=\"wake\"", "type": "string" }, "timeoutMs": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json index 79fc0ad5fbc6..0b069209df1a 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json @@ -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:\", \"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: => 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: 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:,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:\" = 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:\"\" 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:", + "description": "main | isolated | current (agentTurn default) | session:", "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:", "type": "string" }, "trigger": { @@ -1238,6 +1243,7 @@ "type": "string" }, "text": { + "description": "systemEvent text for action=\"wake\"", "type": "string" }, "timeoutMs": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json index bf1bb8ae3250..35527d0e7df8 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json @@ -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:\", \"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: => 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: 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:,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:\" = 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:\"\" 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:", + "description": "main | isolated | current (agentTurn default) | session:", "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:", "type": "string" }, "trigger": { @@ -1238,6 +1243,7 @@ "type": "string" }, "text": { + "description": "systemEvent text for action=\"wake\"", "type": "string" }, "timeoutMs": { diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md index 4558c3fd7b2b..8dba76ef7183 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md @@ -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, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md index 89519b4bb36f..6008f9a14ccc 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md @@ -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, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md index ecfcd9fa2134..25ab4c1c977c 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md @@ -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,