diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index 4ff11a9d1..28e71bf19 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -75,7 +75,9 @@ export function createClawdbotTools(options?: { }), createCanvasTool(), createNodesTool(), - createCronTool(), + createCronTool({ + agentSessionKey: options?.agentSessionKey, + }), createMessageTool({ agentAccountId: options?.agentAccountId, config: options?.config, diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 03c636b96..7c53e501d 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -173,7 +173,7 @@ export function buildAgentSystemPrompt(params: { 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)", + cron: "Manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)", message: "Send messages and channel actions", gateway: "Restart, apply config, or run updates on the running Clawdbot process", agents_list: "List agent ids allowed for sessions_spawn", @@ -319,7 +319,7 @@ export function buildAgentSystemPrompt(params: { "- browser: control clawd'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)", + "- cron: manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)", "- sessions_list: list sessions", "- sessions_history: fetch session history", "- sessions_send: send to another session", diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 6e65acb83..b162ce41a 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -84,4 +84,47 @@ describe("cron tool", () => { payload: { kind: "systemEvent", text: "hello" }, }); }); + + it("adds recent context for systemEvent reminders when session key is available", async () => { + callGatewayMock + .mockResolvedValueOnce({ + messages: [ + { role: "user", content: [{ type: "text", text: "Discussed Q2 budget" }] }, + { + role: "assistant", + content: [{ type: "text", text: "We agreed to review on Tuesday." }], + }, + { role: "user", content: [{ type: "text", text: "Remind me about the thing at 2pm" }] }, + ], + }) + .mockResolvedValueOnce({ ok: true }); + + const tool = createCronTool({ agentSessionKey: "main" }); + await tool.execute("call3", { + action: "add", + job: { + name: "reminder", + schedule: { atMs: 123 }, + payload: { text: "Reminder: the thing." }, + }, + }); + + expect(callGatewayMock).toHaveBeenCalledTimes(2); + const historyCall = callGatewayMock.mock.calls[0]?.[0] as { + method?: string; + params?: unknown; + }; + expect(historyCall.method).toBe("chat.history"); + + const cronCall = callGatewayMock.mock.calls[1]?.[0] as { + method?: string; + params?: { payload?: { text?: string } }; + }; + expect(cronCall.method).toBe("cron.add"); + const text = cronCall.params?.payload?.text ?? ""; + expect(text).toContain("Recent context:"); + expect(text).toContain("User: Discussed Q2 budget"); + expect(text).toContain("Assistant: We agreed to review on Tuesday."); + expect(text).toContain("User: Remind me about the thing at 2pm"); + }); }); diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index cee9aed27..75af0becd 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -1,8 +1,11 @@ import { Type } from "@sinclair/typebox"; import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js"; +import { loadConfig } from "../../config/config.js"; +import { truncateUtf16Safe } from "../../utils.js"; import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; +import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js"; // NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch // instead of CronAddParamsSchema/CronJobPatchSchema because the gateway schemas @@ -13,6 +16,11 @@ 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_PER_MESSAGE_MAX = 220; +const REMINDER_CONTEXT_TOTAL_MAX = 700; +const REMINDER_CONTEXT_MARKER = "\n\nRecent context:\n"; + // Flattened schema: runtime validates per-action requirements. const CronToolSchema = Type.Object({ action: stringEnum(CRON_ACTIONS), @@ -28,7 +36,90 @@ const CronToolSchema = Type.Object({ mode: optionalStringEnum(CRON_WAKE_MODES), }); -export function createCronTool(): AnyAgentTool { +type CronToolOptions = { + agentSessionKey?: string; +}; + +type ChatMessage = { + role?: unknown; + content?: unknown; +}; + +function stripExistingContext(text: string) { + const index = text.indexOf(REMINDER_CONTEXT_MARKER); + if (index === -1) return text; + return text.slice(0, index).trim(); +} + +function truncateText(input: string, maxLen: number) { + if (input.length <= maxLen) return input; + const truncated = truncateUtf16Safe(input, Math.max(0, maxLen - 3)).trimEnd(); + return `${truncated}...`; +} + +function normalizeContextText(raw: string) { + return raw.replace(/\s+/g, " ").trim(); +} + +function extractMessageText(message: ChatMessage): { role: string; text: string } | null { + const role = typeof message.role === "string" ? message.role : ""; + if (role !== "user" && role !== "assistant") return null; + const content = message.content; + if (typeof content === "string") { + const normalized = normalizeContextText(content); + return normalized ? { role, text: normalized } : null; + } + if (!Array.isArray(content)) return null; + const chunks: string[] = []; + for (const block of content) { + if (!block || typeof block !== "object") continue; + if ((block as { type?: unknown }).type !== "text") continue; + const text = (block as { text?: unknown }).text; + if (typeof text === "string" && text.trim()) { + chunks.push(text); + } + } + const joined = normalizeContextText(chunks.join(" ")); + return joined ? { role, text: joined } : null; +} + +async function buildReminderContextLines(params: { + agentSessionKey?: string; + gatewayOpts: GatewayCallOptions; +}) { + const sessionKey = params.agentSessionKey?.trim(); + if (!sessionKey) return []; + const cfg = loadConfig(); + const { mainKey, alias } = resolveMainSessionAlias(cfg); + const resolvedKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey }); + try { + const res = (await callGatewayTool("chat.history", params.gatewayOpts, { + sessionKey: resolvedKey, + limit: 12, + })) 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); + if (recent.length === 0) return []; + const lines: string[] = []; + let total = 0; + for (const entry of recent) { + const label = entry.role === "user" ? "User" : "Assistant"; + const text = truncateText(entry.text, REMINDER_CONTEXT_PER_MESSAGE_MAX); + const line = `- ${label}: ${text}`; + total += line.length; + if (total > REMINDER_CONTEXT_TOTAL_MAX) break; + lines.push(line); + } + return lines; + } catch { + return []; + } +} + +export function createCronTool(opts?: CronToolOptions): AnyAgentTool { return { label: "Cron", name: "cron", @@ -58,6 +149,24 @@ export function createCronTool(): AnyAgentTool { throw new Error("job required"); } const job = normalizeCronJobCreate(params.job) ?? params.job; + if ( + job && + typeof job === "object" && + "payload" in job && + (job as { payload?: { kind?: string; text?: string } }).payload?.kind === "systemEvent" + ) { + const payload = (job as { payload: { kind: string; text: string } }).payload; + if (typeof payload.text === "string" && payload.text.trim()) { + const contextLines = await buildReminderContextLines({ + agentSessionKey: opts?.agentSessionKey, + gatewayOpts, + }); + if (contextLines.length > 0) { + const baseText = stripExistingContext(payload.text); + payload.text = `${baseText}${REMINDER_CONTEXT_MARKER}${contextLines.join("\n")}`; + } + } + } return jsonResult(await callGatewayTool("cron.add", gatewayOpts, job)); } case "update": {