mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-06 06:41:08 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
31
src/agents/pi-tools.deferred-followup-guidance.test.ts
Normal file
31
src/agents/pi-tools.deferred-followup-guidance.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
|
||||
@@ -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:");
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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\`.
|
||||
|
||||
|
||||
@@ -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:");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user