fix(agents): prefer cron for deferred follow-ups (#60811)

* fix(agents): prefer cron for deferred follow-ups

* fix(agents): gate cron scheduling guidance

* fix(changelog): add scheduling guidance note

* fix(agents): restore exec approval agent hint
This commit is contained in:
Vincent Koc 2026-04-04 21:11:27 +09:00 committed by GitHub
parent d46eabb010
commit b742909dca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 164 additions and 25 deletions

View File

@ -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

View File

@ -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;

View File

@ -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) => {

View File

@ -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<AgentToolResult<unknown>> => {
const params = args as {

View File

@ -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();

View File

@ -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";

View File

@ -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");
});
});

View File

@ -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;
}

View File

@ -101,7 +101,7 @@ describe("buildAgentSystemPrompt", () => {
skillsPrompt:
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
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=<ms>).",
'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=<ms>).",
'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");

View File

@ -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<string, string> = {
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=<ms>).`,
...(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=<ms>).`,
]),
"If a task is more complex or takes longer, spawn a sub-agent. Completion is push-based: it will auto-announce when done.",
...(acpHarnessSpawnAllowed
? [

View File

@ -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:");
});

View File

@ -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",

View File

@ -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\`.

View File

@ -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:");
});