diff --git a/CHANGELOG.md b/CHANGELOG.md index 83c818ba70e..1a2d152c8a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -198,6 +198,7 @@ Docs: https://docs.openclaw.ai - Browser/profiles: reject remote browser profile `cdpUrl` values that violate strict SSRF policy before saving config, with clearer validation errors for blocked endpoints. (#60477) Thanks @eleqtrizit. - Browser/screenshots: stop sending `fromSurface: false` on CDP screenshots so managed Chrome 146+ browsers can capture images again. (#60682) Thanks @mvanhorn. - Mattermost/slash commands: harden native slash-command callback token validation to use constant-time secret comparison, matching the existing interaction-token path. +- Agents/scheduling: route delayed follow-up requests toward cron only when cron is actually available, while keeping background `exec`/`process` guidance scoped to work that starts now. (#60811) Thanks @vincentkoc. - Cron/security: reject unsafe custom `sessionTarget: "session:..."` IDs earlier during cron add, update, and execution so malformed custom session keys fail closed with clear errors. ## 2026.4.1 diff --git a/src/agents/bash-tools.exec-types.ts b/src/agents/bash-tools.exec-types.ts index 349b7ef0e8f..1398186d088 100644 --- a/src/agents/bash-tools.exec-types.ts +++ b/src/agents/bash-tools.exec-types.ts @@ -4,6 +4,7 @@ import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; export type ExecToolDefaults = { + hasCronTool?: boolean; host?: ExecTarget; security?: ExecSecurity; ask?: ExecAsk; diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index aa794f77f44..9ab3cf07bae 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -1115,9 +1115,17 @@ function deriveExecShortName(fullPath: string): string { return base.replace(/\.exe$/i, "") || base; } -function buildExecToolDescription(agentId?: string): string { - const base = - "Execute shell commands with background continuation. Use yieldMs/background to continue later via process tool. Use pty=true for TTY-required commands (terminal UIs, coding agents)."; +export function describeExecTool(params?: { agentId?: string; hasCronTool?: boolean }): string { + const base = [ + "Execute shell commands with background continuation for work that starts now.", + "Use yieldMs/background to continue later via process tool.", + params?.hasCronTool + ? "Do not use exec sleep or delay loops for reminders or deferred follow-ups; use cron instead." + : undefined, + "Use pty=true for TTY-required commands (terminal UIs, coding agents).", + ] + .filter(Boolean) + .join(" "); if (process.platform !== "win32") { return base; } @@ -1127,7 +1135,10 @@ function buildExecToolDescription(agentId?: string): string { ); try { const approvalsFile = loadExecApprovals(); - const approvals = resolveExecApprovalsFromFile({ file: approvalsFile, agentId }); + const approvals = resolveExecApprovalsFromFile({ + file: approvalsFile, + agentId: params?.agentId, + }); const allowlist = approvals.allowlist.filter((entry) => { const pattern = entry.pattern?.trim() ?? ""; return ( @@ -1208,7 +1219,7 @@ export function createExecTool( name: "exec", label: "exec", get description() { - return buildExecToolDescription(agentId); + return describeExecTool({ agentId, hasCronTool: defaults?.hasCronTool === true }); }, parameters: execSchema, execute: async (_toolCallId, args, signal, onUpdate) => { diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index 2f00fbe54cd..0945d6f7c8d 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -21,6 +21,7 @@ import { encodeKeySequence, encodePaste, hasCursorModeSensitiveKeys } from "./pt export type ProcessToolDefaults = { cleanupMs?: number; + hasCronTool?: boolean; scopeKey?: string; }; @@ -116,6 +117,17 @@ function resetPollRetrySuggestion(sessionId: string): void { } } +export function describeProcessTool(params?: { hasCronTool?: boolean }): string { + return [ + "Manage running exec sessions for commands already started: list, poll, log, write, send-keys, submit, paste, kill.", + params?.hasCronTool + ? "Do not use process polling to emulate timers or reminders; use cron for scheduled follow-ups." + : undefined, + ] + .filter(Boolean) + .join(" "); +} + export function createProcessTool( defaults?: ProcessToolDefaults, // oxlint-disable-next-line typescript/no-explicit-any @@ -149,8 +161,7 @@ export function createProcessTool( return { name: "process", label: "process", - description: - "Manage running exec sessions: list, poll, log, write, send-keys, submit, paste, kill.", + description: describeProcessTool({ hasCronTool: defaults?.hasCronTool === true }), parameters: processSchema, execute: async (_toolCallId, args, _signal, _onUpdate): Promise> => { const params = args as { diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts index bd34c1ff457..93f03a6975d 100644 --- a/src/agents/bash-tools.test.ts +++ b/src/agents/bash-tools.test.ts @@ -410,6 +410,22 @@ const runNotifyNoopCase = async ({ label, notifyOnExitEmptySuccess }: NotifyNoop expectNotifyNoopEvents(events, notifyOnExitEmptySuccess, label); }; +describe("tool descriptions", () => { + it("adds cron-specific deferred follow-up guidance only when cron is available", () => { + const execWithCron = createTestExecTool({ hasCronTool: true }); + const processWithCron = createProcessTool({ hasCronTool: true }); + + expect(execWithCron.description).toContain( + "Do not use exec sleep or delay loops for reminders or deferred follow-ups; use cron instead.", + ); + expect(processWithCron.description).toContain( + "Do not use process polling to emulate timers or reminders; use cron for scheduled follow-ups.", + ); + expect(execTool.description).not.toContain("use cron instead"); + expect(processTool.description).not.toContain("scheduled follow-ups"); + }); +}); + beforeEach(() => { callIdCounter = 0; resetProcessRegistryForTests(); diff --git a/src/agents/bash-tools.ts b/src/agents/bash-tools.ts index 13086b49ee6..f8801afcfe6 100644 --- a/src/agents/bash-tools.ts +++ b/src/agents/bash-tools.ts @@ -4,6 +4,6 @@ export type { ExecToolDefaults, ExecToolDetails, } from "./bash-tools.exec.js"; -export { createExecTool, execTool } from "./bash-tools.exec.js"; +export { createExecTool, describeExecTool, execTool } from "./bash-tools.exec.js"; export type { ProcessToolDefaults } from "./bash-tools.process.js"; -export { createProcessTool, processTool } from "./bash-tools.process.js"; +export { createProcessTool, describeProcessTool, processTool } from "./bash-tools.process.js"; diff --git a/src/agents/pi-tools.deferred-followup-guidance.test.ts b/src/agents/pi-tools.deferred-followup-guidance.test.ts new file mode 100644 index 00000000000..bfbfdde31d2 --- /dev/null +++ b/src/agents/pi-tools.deferred-followup-guidance.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { createOpenClawCodingTools } from "./pi-tools.js"; + +function findToolDescription(toolName: string, senderIsOwner: boolean) { + const tools = createOpenClawCodingTools({ senderIsOwner }); + const tool = tools.find((entry) => entry.name === toolName); + return { + toolNames: tools.map((entry) => entry.name), + description: tool?.description ?? "", + }; +} + +describe("createOpenClawCodingTools deferred follow-up guidance", () => { + it("keeps cron-specific guidance when cron survives filtering", () => { + const exec = findToolDescription("exec", true); + const process = findToolDescription("process", true); + + expect(exec.toolNames).toContain("cron"); + expect(exec.description).toContain("use cron instead"); + expect(process.description).toContain("use cron for scheduled follow-ups"); + }); + + it("drops cron-specific guidance when cron is unavailable", () => { + const exec = findToolDescription("exec", false); + const process = findToolDescription("process", false); + + expect(exec.toolNames).not.toContain("cron"); + expect(exec.description).not.toContain("use cron instead"); + expect(process.description).not.toContain("use cron for scheduled follow-ups"); + }); +}); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index e1189566e04..e423abad9ea 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -12,6 +12,8 @@ import { createApplyPatchTool } from "./apply-patch.js"; import { createExecTool, createProcessTool, + describeExecTool, + describeProcessTool, type ExecToolDefaults, type ProcessToolDefaults, } from "./bash-tools.js"; @@ -123,6 +125,28 @@ function applyModelProviderToolPolicy( return tools; } +function applyDeferredFollowupToolDescriptions( + tools: AnyAgentTool[], + params?: { agentId?: string }, +): AnyAgentTool[] { + const hasCronTool = tools.some((tool) => tool.name === "cron"); + return tools.map((tool) => { + if (tool.name === "exec") { + return { + ...tool, + description: describeExecTool({ agentId: params?.agentId, hasCronTool }), + }; + } + if (tool.name === "process") { + return { + ...tool, + description: describeProcessTool({ hasCronTool }), + }; + } + return tool; + }); +} + function isApplyPatchAllowedForModel(params: { modelProvider?: string; modelId?: string; @@ -654,9 +678,12 @@ export function createOpenClawCodingTools(options?: { const withAbort = options?.abortSignal ? withHooks.map((tool) => wrapToolWithAbortSignal(tool, options.abortSignal)) : withHooks; + const withDeferredFollowupDescriptions = applyDeferredFollowupToolDescriptions(withAbort, { + agentId, + }); // NOTE: Keep canonical (lowercase) tool names here. // pi-ai's Anthropic OAuth transport remaps tool names to Claude Code-style names // on the wire and maps them back for tool dispatch. - return withAbort; + return withDeferredFollowupDescriptions; } diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 32ad8d24b5d..813662081a9 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -101,7 +101,7 @@ describe("buildAgentSystemPrompt", () => { skillsPrompt: "\n \n demo\n \n", heartbeatPrompt: "ping", - toolNames: ["message", "memory_search"], + toolNames: ["message", "memory_search", "cron"], docsPath: "/tmp/openclaw/docs", extraSystemPrompt: "Subagent details", ttsHint: "Voice (TTS) is enabled.", @@ -119,7 +119,13 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).not.toContain("## Heartbeats"); expect(prompt).toContain("## Safety"); expect(prompt).toContain( - "For long waits, avoid rapid poll loops: use exec with enough yieldMs or process(action=poll, timeout=).", + 'For follow-up at a future time (for example "check back in 10 minutes", reminders, run-later work, or recurring tasks), use cron instead of exec sleep, yieldMs delays, or process polling.', + ); + expect(prompt).toContain( + "Use exec/process only for commands that start now and continue running in the background.", + ); + expect(prompt).toContain( + "Do not emulate scheduling with sleep loops, timeout loops, or repeated polling.", ); expect(prompt).toContain("You have no independent goals"); expect(prompt).toContain("Prioritize safety and human oversight"); @@ -287,7 +293,10 @@ describe("buildAgentSystemPrompt", () => { }); expect(prompt).toContain( - "For long waits, avoid rapid poll loops: use exec with enough yieldMs or process(action=poll, timeout=).", + 'For follow-up at a future time (for example "check back in 10 minutes", reminders, run-later work, or recurring tasks), use cron instead of exec sleep, yieldMs delays, or process polling.', + ); + expect(prompt).toContain( + "Use exec/process only for commands that start now and continue running in the background.", ); expect(prompt).toContain("Completion is push-based: it will auto-announce when done."); expect(prompt).toContain("Do not poll `subagents list` / `sessions_list` in a loop"); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 14d05df5c32..3385d3fa599 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -243,6 +243,12 @@ export function buildAgentSystemPrompt(params: { const acpEnabled = params.acpEnabled !== false; const sandboxedRuntime = params.sandboxInfo?.enabled === true; const acpSpawnRuntimeEnabled = acpEnabled && !sandboxedRuntime; + const execToolSummary = + "Run shell commands (pty available for TTY-required CLIs; use for work that starts now, not delayed follow-ups)"; + const processToolSummary = + "Manage background exec sessions for commands already started"; + const cronToolSummary = + "Manage cron jobs and wake events (use for reminders, delayed follow-ups, and recurring tasks; for requests like 'check back in 10 minutes' or 'remind me later', use cron instead of exec sleep, yieldMs delays, or process polling; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)"; const coreToolSummaries: Record = { read: "Read file contents", write: "Create or overwrite files", @@ -251,15 +257,15 @@ export function buildAgentSystemPrompt(params: { grep: "Search file contents for patterns", find: "Find files by glob pattern", ls: "List directory contents", - exec: "Run shell commands (pty available for TTY-required CLIs)", - process: "Manage background exec sessions", + exec: execToolSummary, + process: processToolSummary, web_search: "Search the web", web_fetch: "Fetch and extract readable content from a URL", // Channel docking: add login tools here when a channel needs interactive linking. browser: "Control web browser", canvas: "Present/eval/snapshot the Canvas", nodes: "List/describe/notify/camera/screen on paired nodes", - cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)", + cron: cronToolSummary, message: "Send messages and channel actions", gateway: "Restart, apply config, or run updates on the running OpenClaw process", agents_list: acpSpawnRuntimeEnabled @@ -347,7 +353,9 @@ export function buildAgentSystemPrompt(params: { toolLines.push(summary ? `- ${name}: ${summary}` : `- ${name}`); } + const usingDefaultToolFallback = toolLines.length === 0; const hasGateway = availableTools.has("gateway"); + const hasCronTool = availableTools.has("cron") || usingDefaultToolFallback; const readToolName = resolveToolName("read"); const execToolName = resolveToolName("exec"); const processToolName = resolveToolName("process"); @@ -453,12 +461,12 @@ export function buildAgentSystemPrompt(params: { "- find: find files by glob pattern", "- ls: list directory contents", "- apply_patch: apply multi-file patches", - `- ${execToolName}: run shell commands (supports background via yieldMs/background)`, - `- ${processToolName}: manage background exec sessions`, + `- ${execToolName}: ${execToolSummary.toLowerCase()}`, + `- ${processToolName}: ${processToolSummary.toLowerCase()}`, "- browser: control OpenClaw's dedicated browser", "- canvas: present/eval/snapshot the Canvas", "- nodes: list/describe/notify/camera/screen on paired nodes", - "- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)", + `- cron: ${cronToolSummary.toLowerCase()}`, "- sessions_list: list sessions", "- sessions_history: fetch session history", "- sessions_send: send to another session", @@ -466,7 +474,15 @@ export function buildAgentSystemPrompt(params: { '- session_status: show usage/time/model state and answer "what model are we using?"', ].join("\n"), "TOOLS.md does not control tool availability; it is user guidance for how to use external tools.", - `For long waits, avoid rapid poll loops: use ${execToolName} with enough yieldMs or ${processToolName}(action=poll, timeout=).`, + ...(hasCronTool + ? [ + `For follow-up at a future time (for example "check back in 10 minutes", reminders, run-later work, or recurring tasks), use cron instead of ${execToolName} sleep, yieldMs delays, or ${processToolName} polling.`, + `Use ${execToolName}/${processToolName} only for commands that start now and continue running in the background.`, + "Do not emulate scheduling with sleep loops, timeout loops, or repeated polling.", + ] + : [ + `For long waits, avoid rapid poll loops: use ${execToolName} with enough yieldMs or ${processToolName}(action=poll, timeout=).`, + ]), "If a task is more complex or takes longer, spawn a sub-agent. Completion is push-based: it will auto-announce when done.", ...(acpHarnessSpawnAllowed ? [ diff --git a/src/agents/tools-effective-inventory.test.ts b/src/agents/tools-effective-inventory.test.ts index 5758c33d237..bce5829067c 100644 --- a/src/agents/tools-effective-inventory.test.ts +++ b/src/agents/tools-effective-inventory.test.ts @@ -163,16 +163,20 @@ describe("resolveEffectiveToolInventory", () => { name: "cron", label: "Cron", description: - "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events.\n\nACTIONS:\n- status: Check cron scheduler status\nJOB SCHEMA:\n{ ... }", + 'Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use this for reminders, "check back later" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nACTIONS:\n- status: Check cron scheduler status\nJOB SCHEMA:\n{ ... }', }, ], }); const result = resolveEffectiveToolInventory({ cfg: {} }); - expect(result.groups[0]?.tools[0]?.description).toBe( + const description = result.groups[0]?.tools[0]?.description ?? ""; + expect(description).toContain( "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events.", ); + expect(description).toContain("Use this for reminders"); + expect(description.endsWith("...")).toBe(true); + expect(description.length).toBeLessThanOrEqual(120); expect(result.groups[0]?.tools[0]?.rawDescription).toContain("ACTIONS:"); }); diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 879333ce6c2..ae304d2f6b6 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -121,6 +121,16 @@ describe("cron tool", () => { expect(tool.ownerOnly).toBe(true); }); + it("documents deferred follow-up guidance in the tool description", () => { + const tool = createTestCronTool(); + expect(tool.description).toContain( + 'Use this for reminders, "check back later" requests, delayed follow-ups, and recurring tasks.', + ); + expect(tool.description).toContain( + "Do not emulate scheduling with exec sleep or process polling.", + ); + }); + it.each([ [ "update", diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 36b4f8ee33a..fe28c635936 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -433,7 +433,7 @@ export function createCronTool(opts?: CronToolOptions, deps?: CronToolDeps): Any name: "cron", ownerOnly: true, displaySummary: "Schedule and manage cron jobs and wake events.", - description: `Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. + description: `Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use this for reminders, "check back later" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling. Main-session cron jobs enqueue system events for heartbeat handling. Isolated cron jobs create background task runs that appear in \`openclaw tasks\`. diff --git a/src/auto-reply/status.tools.test.ts b/src/auto-reply/status.tools.test.ts index c513baa9588..169e4ddf28d 100644 --- a/src/auto-reply/status.tools.test.ts +++ b/src/auto-reply/status.tools.test.ts @@ -120,7 +120,7 @@ describe("tools product copy", () => { label: "Cron", description: "Schedule and manage cron jobs.", rawDescription: - "Manage Gateway cron jobs and send wake events.\n\nACTIONS:\n- status: Check cron scheduler status\nJOB SCHEMA:\n{ ... }", + 'Manage Gateway cron jobs and send wake events. Use this for reminders, "check back later" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.\n\nACTIONS:\n- status: Check cron scheduler status\nJOB SCHEMA:\n{ ... }', source: "core", }, ], @@ -130,7 +130,9 @@ describe("tools product copy", () => { { verbose: true }, ); - expect(text).toContain("Cron - Manage Gateway cron jobs and send wake events."); + expect(text).toContain( + 'Cron - Manage Gateway cron jobs and send wake events. Use this for reminders, "check back later" requests, delayed follow-ups, and recurring tasks. Do not emulate scheduling with exec sleep or process polling.', + ); expect(text).not.toContain("ACTIONS:"); expect(text).not.toContain("JOB SCHEMA:"); });