diff --git a/src/agents/tools/cron-tool-schema.ts b/src/agents/tools/cron-tool-schema.ts index a1389a156fcf..f5edcc3082ec 100644 --- a/src/agents/tools/cron-tool-schema.ts +++ b/src/agents/tools/cron-tool-schema.ts @@ -46,6 +46,7 @@ const CRON_DELIVERY_MODES = ["none", "announce", "webhook"] as const; const CRON_RUN_MODES = ["due", "force"] as const; type CronToolSchemaOptions = { + agentSessionKey?: string; /** * Whether cron.triggers.enabled is on for this deployment. When false, the * trigger-gated surfaces (job trigger, script payloads, stream @@ -278,8 +279,10 @@ function createCronFailureAlertSchema(): TSchema { ); } -function createCronJobObjectSchema(params: { triggersEnabled: boolean }): TSchema { - return Type.Optional( +// Flattened schema: runtime validates per-action requirements. +export function createCronToolSchema(options?: CronToolSchemaOptions): TSchema { + const triggersEnabled = options?.triggersEnabled !== false; + const job = Type.Optional( Type.Object( { name: Type.Optional(Type.String({ description: "Job name" })), @@ -304,18 +307,21 @@ function createCronJobObjectSchema(params: { triggersEnabled: boolean }): TSchem { additionalProperties: false }, ), ), - schedule: createCronScheduleSchema({ triggersEnabled: params.triggersEnabled }), + schedule: createCronScheduleSchema({ triggersEnabled }), pacing: createCronPacingSchema(), - ...(params.triggersEnabled ? { trigger: createCronTriggerSchema() } : {}), + ...(triggersEnabled ? { trigger: createCronTriggerSchema() } : {}), sessionTarget: Type.Optional( Type.String({ description: "main | isolated | current (agentTurn default) | session:", }), ), wakeMode: optionalStringEnum(CRON_WAKE_MODES, { description: "Wake timing" }), - payload: createCronPayloadSchema({ triggersEnabled: params.triggersEnabled }), + payload: createCronPayloadSchema({ triggersEnabled }), delivery: createCronDeliverySchema(), - agentId: nullableStringSchema("Agent id, or null to clear it"), + // Session-scoped updates reject retargeting; do not advertise it to the model. + ...(!options?.agentSessionKey?.trim() + ? { agentId: nullableStringSchema("Agent id, or null to clear it") } + : {}), description: Type.Optional(Type.String({ description: "Human description" })), enabled: Type.Optional(Type.Boolean()), deleteAfterRun: Type.Optional(Type.Boolean({ description: "Delete after first run" })), @@ -329,11 +335,6 @@ function createCronJobObjectSchema(params: { triggersEnabled: boolean }): TSchem }, ), ); -} - -// Flattened schema: runtime validates per-action requirements. -export function createCronToolSchema(options?: CronToolSchemaOptions): TSchema { - const triggersEnabled = options?.triggersEnabled !== false; return Type.Object( { action: stringEnum(CRON_ACTIONS), @@ -346,7 +347,7 @@ export function createCronToolSchema(options?: CronToolSchemaOptions): TSchema { offset: optionalNonNegativeIntegerSchema({ description: 'Job offset for action="list"; use nextOffset to load the next page', }), - job: createCronJobObjectSchema({ triggersEnabled }), + job, jobId: Type.Optional(Type.String()), id: Type.Optional(Type.String()), in: Type.Optional( diff --git a/src/agents/tools/cron-tool.schema.test.ts b/src/agents/tools/cron-tool.schema.test.ts index 667260f15028..fbbc6b842cbd 100644 --- a/src/agents/tools/cron-tool.schema.test.ts +++ b/src/agents/tools/cron-tool.schema.test.ts @@ -110,6 +110,26 @@ describe("createCronToolSchema", () => { expect(schemaRecord.properties).not.toHaveProperty("patch"); }); + it.each([undefined, "", " \t ", "agent:main:telegram:direct:alice", " agent:main:main "])( + "advertises job retargeting only without session scope (%j)", + (agentSessionKey) => { + const toolSchema = createCronTool({ agentSessionKey, agentId: "main" }).parameters; + for (const projected of [ + toolSchema, + normalizeToolParameterSchema(toolSchema, { modelProvider: "gemini" }), + normalizeToolParameterSchema(toolSchema, { + modelCompat: { toolSchemaProfile: "llamacpp" }, + }), + ]) { + const record = projected as unknown as Record; + expect(keysAt(record, "job").includes("agentId")).toBe(!agentSessionKey?.trim()); + expect(propertyAt(record, "agentId")).toMatchObject({ type: "string" }); + expect(propertyAt(record, "agentId")?.description).toContain("list"); + expect(propertyAt(record, "agentId")?.description).toContain("wake"); + } + }, + ); + it("exposes next_check with its relative duration parameter", () => { expect(Value.Check(schema, { action: "next_check", in: "15m" })).toBe(true); expect(propertyAt(schemaRecord, "in")?.description).toContain("next_check"); diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 11fc2c189f22..bda81feb74c6 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -2976,37 +2976,47 @@ describe("cron tool", () => { }); }); - it("rejects agentId retargeting on update", async () => { - const tool = createTestCronTool({ - agentSessionKey: "agent:agent-123:telegram:direct:channing", - }); + it.each(["agent-123", "worker", null])( + "rejects scoped update agentId %j in either shape", + async (agentId) => { + const tool = createTestCronTool({ + agentSessionKey: "agent:agent-123:telegram:direct:channing", + }); - await expect( - tool.execute("call-update-agent-id", { - action: "update", - id: "job-1", - job: { agentId: "worker" }, - }), - ).rejects.toThrow("automation patch agentId cannot be changed"); - expect(callGatewayMock).not.toHaveBeenCalled(); - }); + for (const fields of [{ job: { agentId, enabled: false } }, { agentId, enabled: false }]) { + await expect( + tool.execute("call-update-agent-id", { + action: "update", + id: "job-1", + ...fields, + }), + ).rejects.toThrow("automation patch agentId cannot be changed"); + } + expect(callGatewayMock).not.toHaveBeenCalled(); + }, + ); - it("allows unscoped operator cron.update agentId retargeting", async () => { + it.each([ + ["nested", "worker"], + ["flat", "worker"], + ["nested", null], + ["flat", null], + ])("allows unscoped operator %s agentId %j updates", async (shape, agentId) => { callGatewayMock.mockResolvedValueOnce({ ok: true }); const tool = createTestCronTool(); await tool.execute("call-unscoped-update-agent-id", { action: "update", id: "job-1", - job: { agentId: "worker" }, + ...(shape === "nested" ? { job: { agentId } } : { agentId }), }); const params = expectSingleGatewayCallMethod("cron.update") as - | { id?: string; patch?: { agentId?: string } } + | { id?: string; patch?: { agentId?: string | null } } | undefined; expect(params).toEqual({ id: "job-1", - patch: { agentId: "worker" }, + patch: { agentId }, }); }); diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 0d63f16d7b9b..309292f96735 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -6,7 +6,6 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { parseDurationMs } from "../../cli/parse-duration.js"; import { getRuntimeConfig } from "../../config/config.js"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveCronCreationDelivery } from "../../cron/delivery-context.js"; import { assertCronDeliveryInputNonBlankFields } from "../../cron/delivery-target-validation.js"; import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js"; @@ -211,22 +210,19 @@ FAILURE ALERTS: jobs with a failure route default to alerting after 2 consecutiv Job wakeMode (main jobs): "now"(default)|"next-heartbeat". Restricted automation-run sessions: self status/list/get/runs/remove + own next_check only. failureAlert {...}|false disables. jobId canonical (id=compat). contextMessages 0-10 embeds recent chat lines into reminder text.`; } -// Trigger-gated surfaces are advertised by default. Only an explicit false -// narrows the model-facing surface, matching the scheduler's own gate in -// cron/service/jobs-validation.ts. -function resolveCronTriggersEnabled(config?: OpenClawConfig): boolean { - return config?.cron?.triggers?.enabled !== false; -} - export function createCronTool(opts?: CronToolOptions, deps?: CronToolDeps): AnyAgentTool { const callGateway = deps?.callGatewayTool ?? callGatewayTool; - const triggersEnabled = resolveCronTriggersEnabled(opts?.config); + // Trigger-gated surfaces default on, matching cron/service/jobs-validation.ts. + const triggersEnabled = opts?.config?.cron?.triggers?.enabled !== false; const tool: AnyAgentTool = { label: "Automations", name: AUTOMATIONS_TOOL_NAME, displaySummary: CRON_TOOL_DISPLAY_SUMMARY, description: buildCronToolDescription({ triggersEnabled }), - parameters: createCronToolSchema({ triggersEnabled }), + parameters: createCronToolSchema({ + agentSessionKey: opts?.agentSessionKey, + triggersEnabled, + }), execute: async (_toolCallId, args, operationSignal) => { operationSignal?.throwIfAborted(); const params = args as Record; diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json index c5d6787154c1..b5ab5f574205 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json @@ -327,17 +327,6 @@ "additionalProperties": true, "description": "Job fields. action=\"add\": full job. action=\"update\": partial patch — only supplied fields change; null clears.", "properties": { - "agentId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Agent id, or null to clear it" - }, "declarationKey": { "description": "Idempotent declaration key (add only).", "maxLength": 200, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md.diff b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md.diff index 7e7c69317e6b..8fad74e112fe 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md.diff +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md.diff @@ -1,5 +1,5 @@ ---- telegram-direct-codex-message-tool.md sha256=0ffd165216012ff63244b6d9b39269a3a8775bdacbbc1f1bcb52c8a6dfb8b03e -+++ discord-group-codex-message-tool.md sha256=5d6e349160a987be177164637f54004e67108496f6cc4f511188165b1b917c1f +--- telegram-direct-codex-message-tool.md sha256=3a723ba690b1938399f61d2dde771a424a77a34ae2aee1c3c567de3d3100ec5f ++++ discord-group-codex-message-tool.md sha256=3a611870a62e573efb58fe34f9c88598bb06193e0599f876f3996b9f8af2466c @@ -1,1 +1,1 @@ -# Telegram Direct Codex Message Tool Turn +# Discord Group Codex Message Tool Turn @@ -29,10 +29,10 @@ - "dynamicToolsFrom": "codex-dynamic-tools.telegram-direct.json", + "dynamicToolsFrom": "codex-dynamic-tools.discord-group.json", @@ -235,2 +235,2 @@ -- "chars": 54985, -- "roughTokens": 13747 -+ "chars": 55293, -+ "roughTokens": 13824 +- "chars": 54657, +- "roughTokens": 13665 ++ "chars": 54965, ++ "roughTokens": 13742 @@ -239,2 +239,2 @@ - "chars": 3518, - "roughTokens": 880 @@ -44,10 +44,10 @@ + "chars": 28944, + "roughTokens": 7236 @@ -247,2 +247,2 @@ -- "chars": 82451, -- "roughTokens": 20613 -+ "chars": 84239, -+ "roughTokens": 21060 +- "chars": 82123, +- "roughTokens": 20531 ++ "chars": 83911, ++ "roughTokens": 20978 @@ -251,2 +251,2 @@ - "chars": 863, - "roughTokens": 216 diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md index 15a152a006b1..bd30a591291d 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md @@ -232,8 +232,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 54985, - "roughTokens": 13747 + "chars": 54657, + "roughTokens": 13665 }, "openClawDeveloperInstructions": { "chars": 3518, @@ -244,8 +244,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6866 }, "totalWithDynamicToolsJson": { - "chars": 82451, - "roughTokens": 20613 + "chars": 82123, + "roughTokens": 20531 }, "userInputText": { "chars": 863, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md.diff b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md.diff index f4108b5529dc..d72a8c5a4938 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md.diff +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md.diff @@ -1,5 +1,5 @@ ---- telegram-direct-codex-message-tool.md sha256=0ffd165216012ff63244b6d9b39269a3a8775bdacbbc1f1bcb52c8a6dfb8b03e -+++ telegram-heartbeat-codex-tool.md sha256=779e4c800001a1e8f39964284b8de70d07694fbfc60c5a17bdefe7e1dc2b25f7 +--- telegram-direct-codex-message-tool.md sha256=3a723ba690b1938399f61d2dde771a424a77a34ae2aee1c3c567de3d3100ec5f ++++ telegram-heartbeat-codex-tool.md sha256=6727aba7851d91878e03b76e03fc9642f88eca88f4ee5859ba75f47046dbeea3 @@ -1,1 +1,1 @@ -# Telegram Direct Codex Message Tool Turn +# Telegram Direct Codex Heartbeat Tool Turn @@ -32,20 +32,20 @@ - "dynamicToolsFrom": "codex-dynamic-tools.telegram-direct.json", + "dynamicToolsFrom": "codex-dynamic-tools.heartbeat-turn.json", @@ -235,2 +230,2 @@ -- "chars": 54985, -- "roughTokens": 13747 -+ "chars": 56478, -+ "roughTokens": 14120 +- "chars": 54657, +- "roughTokens": 13665 ++ "chars": 56150, ++ "roughTokens": 14038 @@ -243,2 +238,2 @@ - "chars": 27464, - "roughTokens": 6866 + "chars": 27806, + "roughTokens": 6952 @@ -247,2 +242,2 @@ -- "chars": 82451, -- "roughTokens": 20613 -+ "chars": 84286, -+ "roughTokens": 21072 +- "chars": 82123, +- "roughTokens": 20531 ++ "chars": 83958, ++ "roughTokens": 20990 @@ -251,2 +246,2 @@ - "chars": 863, - "roughTokens": 216