mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,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>;
|
||||
|
||||
-11
@@ -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,
|
||||
|
||||
+10
-10
@@ -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
|
||||
|
||||
test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md
Vendored
+4
-4
@@ -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,
|
||||
|
||||
test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md.diff
Vendored
+10
-10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user