diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d44b3343..d7aa73616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.clawd.bot - Config: avoid stack traces for invalid configs and log the config path. - Doctor: warn when gateway.mode is unset with configure/config guidance. - macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362) +- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr. - UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai. ## 2026.1.21 diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 6fa452e08..4b7cd6615 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -85,7 +85,7 @@ describe("cron tool", () => { }); }); - it("adds recent context for systemEvent reminders when session key is available", async () => { + it("adds recent context for systemEvent reminders when contextMessages > 0", async () => { callGatewayMock .mockResolvedValueOnce({ messages: [ @@ -102,6 +102,7 @@ describe("cron tool", () => { const tool = createCronTool({ agentSessionKey: "main" }); await tool.execute("call3", { action: "add", + contextMessages: 3, job: { name: "reminder", schedule: { atMs: 123 }, @@ -127,4 +128,64 @@ describe("cron tool", () => { expect(text).toContain("Assistant: We agreed to review on Tuesday."); expect(text).toContain("User: Remind me about the thing at 2pm"); }); + + it("caps contextMessages at 10", async () => { + const messages = Array.from({ length: 12 }, (_, idx) => ({ + role: "user", + content: [{ type: "text", text: `Message ${idx + 1}` }], + })); + callGatewayMock.mockResolvedValueOnce({ messages }).mockResolvedValueOnce({ ok: true }); + + const tool = createCronTool({ agentSessionKey: "main" }); + await tool.execute("call5", { + action: "add", + contextMessages: 20, + job: { + name: "reminder", + schedule: { atMs: 123 }, + payload: { kind: "systemEvent", text: "Reminder: the thing." }, + }, + }); + + expect(callGatewayMock).toHaveBeenCalledTimes(2); + const historyCall = callGatewayMock.mock.calls[0]?.[0] as { + method?: string; + params?: { limit?: number }; + }; + expect(historyCall.method).toBe("chat.history"); + expect(historyCall.params?.limit).toBe(10); + + const cronCall = callGatewayMock.mock.calls[1]?.[0] as { + params?: { payload?: { text?: string } }; + }; + const text = cronCall.params?.payload?.text ?? ""; + expect(text).not.toMatch(/Message 1\\b/); + expect(text).not.toMatch(/Message 2\\b/); + expect(text).toContain("Message 3"); + expect(text).toContain("Message 12"); + }); + + it("does not add context when contextMessages is 0 (default)", async () => { + callGatewayMock.mockResolvedValueOnce({ ok: true }); + + const tool = createCronTool({ agentSessionKey: "main" }); + await tool.execute("call4", { + action: "add", + job: { + name: "reminder", + schedule: { atMs: 123 }, + payload: { text: "Reminder: the thing." }, + }, + }); + + // Should only call cron.add, not chat.history + expect(callGatewayMock).toHaveBeenCalledTimes(1); + const cronCall = callGatewayMock.mock.calls[0]?.[0] as { + method?: string; + params?: { payload?: { text?: string } }; + }; + expect(cronCall.method).toBe("cron.add"); + const text = cronCall.params?.payload?.text ?? ""; + expect(text).not.toContain("Recent context:"); + }); }); diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 75af0becd..e8995a0b9 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -16,7 +16,7 @@ const CRON_ACTIONS = ["status", "list", "add", "update", "remove", "run", "runs" const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const; -const REMINDER_CONTEXT_MESSAGES = 3; +const REMINDER_CONTEXT_MESSAGES_MAX = 10; const REMINDER_CONTEXT_PER_MESSAGE_MAX = 220; const REMINDER_CONTEXT_TOTAL_MAX = 700; const REMINDER_CONTEXT_MARKER = "\n\nRecent context:\n"; @@ -34,6 +34,9 @@ const CronToolSchema = Type.Object({ patch: Type.Optional(Type.Object({}, { additionalProperties: true })), text: Type.Optional(Type.String()), mode: optionalStringEnum(CRON_WAKE_MODES), + contextMessages: Type.Optional( + Type.Number({ minimum: 0, maximum: REMINDER_CONTEXT_MESSAGES_MAX }), + ), }); type CronToolOptions = { @@ -86,7 +89,13 @@ function extractMessageText(message: ChatMessage): { role: string; text: string async function buildReminderContextLines(params: { agentSessionKey?: string; gatewayOpts: GatewayCallOptions; + contextMessages: number; }) { + const maxMessages = Math.min( + REMINDER_CONTEXT_MESSAGES_MAX, + Math.max(0, Math.floor(params.contextMessages)), + ); + if (maxMessages <= 0) return []; const sessionKey = params.agentSessionKey?.trim(); if (!sessionKey) return []; const cfg = loadConfig(); @@ -95,13 +104,13 @@ async function buildReminderContextLines(params: { try { const res = (await callGatewayTool("chat.history", params.gatewayOpts, { sessionKey: resolvedKey, - limit: 12, + limit: maxMessages, })) as { messages?: unknown[] }; const messages = Array.isArray(res?.messages) ? res.messages : []; const parsed = messages .map((msg) => extractMessageText(msg as ChatMessage)) .filter((msg): msg is { role: string; text: string } => Boolean(msg)); - const recent = parsed.slice(-REMINDER_CONTEXT_MESSAGES); + const recent = parsed.slice(-maxMessages); if (recent.length === 0) return []; const lines: string[] = []; let total = 0; @@ -124,7 +133,7 @@ export function createCronTool(opts?: CronToolOptions): AnyAgentTool { label: "Cron", name: "cron", description: - "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use `jobId` as the canonical identifier; `id` is accepted for compatibility.", + "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use `jobId` as the canonical identifier; `id` is accepted for compatibility. Use `contextMessages` (0-10) to add previous messages as context to the job text.", parameters: CronToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; @@ -149,6 +158,10 @@ export function createCronTool(opts?: CronToolOptions): AnyAgentTool { throw new Error("job required"); } const job = normalizeCronJobCreate(params.job) ?? params.job; + const contextMessages = + typeof params.contextMessages === "number" && Number.isFinite(params.contextMessages) + ? params.contextMessages + : 0; if ( job && typeof job === "object" && @@ -160,6 +173,7 @@ export function createCronTool(opts?: CronToolOptions): AnyAgentTool { const contextLines = await buildReminderContextLines({ agentSessionKey: opts?.agentSessionKey, gatewayOpts, + contextMessages, }); if (contextLines.length > 0) { const baseText = stripExistingContext(payload.text);