fix(automations): stop offering rejected agent retargets (#121100)

* fix(automations): hide immutable agentId patch field

* test(automations): refresh scoped prompt snapshots

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Arham Amin
2026-08-27 09:07:27 +05:30
committed by GitHub
parent 37c74f6ce1
commit e593842b8a
8 changed files with 90 additions and 74 deletions
+13 -12
View File
@@ -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:<id>",
}),
),
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(
+20
View File
@@ -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<string, unknown>;
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");
+27 -17
View File
@@ -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 },
});
});
+6 -10
View File
@@ -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<string, unknown>;
@@ -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,
@@ -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
@@ -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,
@@ -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