Skip to content

Commit cfeaf68

Browse files
849261680vincentkoc
authored andcommitted
fix(cron): clear payload model overrides
(cherry picked from commit 87af108)
1 parent 7a602c7 commit cfeaf68

13 files changed

Lines changed: 201 additions & 10 deletions

File tree

docs/automation/cron-jobs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,7 @@ Model override note:
470470
- `openclaw cron add|edit --model ...` changes the job's selected model.
471471
- If the model is allowed, that exact provider/model reaches the isolated agent run.
472472
- If it is not allowed or cannot be resolved, cron fails the run with an explicit validation error.
473+
- API `cron.update` payload patches can set `model: null` to clear a stored job model override.
473474
- Configured fallback chains still apply because cron `--model` is a job primary, not a session `/model` override.
474475
- Payload `fallbacks` replaces configured fallbacks for that job; `fallbacks: []` disables fallback and makes the run strict.
475476
- A plain `--model` with no explicit or configured fallback list does not fall through to the agent primary as a silent extra retry target.

packages/gateway-protocol/src/cron-validators.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,30 @@ describe("cron protocol validators", () => {
9494
expect(validateCronUpdateParams({ jobId: "job-2", patch: { enabled: true } })).toBe(true);
9595
});
9696

97+
it("accepts nullable model clears only on update payload patches", () => {
98+
expect(
99+
validateCronUpdateParams({
100+
id: "job-1",
101+
patch: {
102+
payload: {
103+
kind: "agentTurn",
104+
model: null,
105+
},
106+
},
107+
}),
108+
).toBe(true);
109+
expect(
110+
validateCronAddParams({
111+
...minimalAddParams,
112+
payload: {
113+
kind: "agentTurn",
114+
message: "tick",
115+
model: null,
116+
},
117+
}),
118+
).toBe(false);
119+
});
120+
97121
it("accepts get params for id and jobId selectors", () => {
98122
expect(validateCronGetParams({ id: "job-1" })).toBe(true);
99123
expect(validateCronGetParams({ jobId: "job-2" })).toBe(true);

packages/gateway-protocol/src/schema/cron.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@ import { NonEmptyString } from "./primitives.js";
1010
*/
1111

1212
/** Builds create/patch payload variants while preserving per-call field optionality. */
13-
function cronAgentTurnPayloadSchema(params: { message: TSchema; toolsAllow: TSchema }) {
13+
function cronAgentTurnPayloadSchema(params: {
14+
message: TSchema;
15+
model: TSchema;
16+
toolsAllow: TSchema;
17+
}) {
1418
return Type.Object(
1519
{
1620
kind: Type.Literal("agentTurn"),
1721
message: params.message,
18-
model: Type.Optional(Type.String()),
22+
model: Type.Optional(params.model),
1923
fallbacks: Type.Optional(Type.Array(Type.String())),
2024
thinking: Type.Optional(Type.String()),
2125
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
@@ -227,6 +231,7 @@ export const CronPayloadSchema = Type.Union([
227231
),
228232
cronAgentTurnPayloadSchema({
229233
message: NonEmptyString,
234+
model: Type.String(),
230235
toolsAllow: Type.Array(Type.String()),
231236
}),
232237
cronCommandPayloadSchema({
@@ -245,6 +250,7 @@ export const CronPayloadPatchSchema = Type.Union([
245250
),
246251
cronAgentTurnPayloadSchema({
247252
message: Type.Optional(NonEmptyString),
253+
model: Type.Union([Type.String(), Type.Null()]),
248254
toolsAllow: Type.Union([Type.Array(Type.String()), Type.Null()]),
249255
}),
250256
cronCommandPayloadSchema({

src/agents/tools/cron-tool-canonicalize.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ function canonicalizeCronToolPayload(value: Record<string, unknown>): void {
224224
const hasAgentTurnSignal =
225225
isNonEmptyString(payload.message) ||
226226
isNonEmptyString(payload.model) ||
227+
payload.model === null ||
227228
isNonEmptyString(payload.thinking) ||
228229
typeof payload.timeoutSeconds === "number" ||
229230
typeof payload.lightContext === "boolean" ||

src/agents/tools/cron-tool.schema.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,8 @@ describe("CronToolSchema", () => {
250250
// unions so OpenAPI 3.0 subset validators accept them.
251251
expect(patchProps?.payload?.properties?.toolsAllow?.type).toBe("array");
252252
expect(patchProps?.payload?.properties?.toolsAllow?.description).toMatch(/null to clear/i);
253+
expect(patchProps?.payload?.properties?.model?.type).toBe("string");
254+
expect(patchProps?.payload?.properties?.model?.description).toMatch(/null to clear/i);
253255
});
254256

255257
// Regression guard: ensure no OpenAPI 3.0 incompatible keywords leak into the

src/agents/tools/cron-tool.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1769,4 +1769,36 @@ describe("cron tool", () => {
17691769
toolsAllow: null,
17701770
});
17711771
});
1772+
1773+
it("preserves null model payload patches on update", async () => {
1774+
callGatewayMock.mockResolvedValueOnce({ ok: true });
1775+
1776+
const tool = createTestCronTool();
1777+
await tool.execute("call-update-clear-model", {
1778+
action: "update",
1779+
id: "job-9",
1780+
patch: {
1781+
payload: {
1782+
model: null,
1783+
},
1784+
},
1785+
});
1786+
1787+
const params = expectSingleGatewayCallMethod("cron.update") as
1788+
| {
1789+
id?: string;
1790+
patch?: {
1791+
payload?: {
1792+
kind?: string;
1793+
model?: string | null;
1794+
};
1795+
};
1796+
}
1797+
| undefined;
1798+
expect(params?.id).toBe("job-9");
1799+
expect(params?.patch?.payload).toEqual({
1800+
kind: "agentTurn",
1801+
model: null,
1802+
});
1803+
});
17721804
});

src/agents/tools/cron-tool.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,13 @@ function failureDestinationModeSchema(params: { nullableClears: boolean }) {
9797
return Type.Optional(Type.Union(variants));
9898
}
9999

100-
function cronPayloadObjectSchema(params: { toolsAllow: TSchema }) {
100+
function cronPayloadObjectSchema(params: { model: TSchema; toolsAllow: TSchema }) {
101101
return Type.Object(
102102
{
103103
kind: optionalStringEnum(CRON_PAYLOAD_KINDS, { description: "Payload kind" }),
104104
text: Type.Optional(Type.String({ description: "systemEvent text" })),
105105
message: Type.Optional(Type.String({ description: "agentTurn prompt" })),
106-
model: Type.Optional(Type.String({ description: "Model override" })),
106+
model: params.model,
107107
thinking: Type.Optional(Type.String({ description: "Thinking override" })),
108108
timeoutSeconds: optionalFiniteNumberSchema({ minimum: 0 }),
109109
lightContext: Type.Optional(Type.Boolean()),
@@ -147,6 +147,7 @@ function createCronScheduleSchema(): TSchema {
147147
function createCronPayloadSchema(): TSchema {
148148
return Type.Optional(
149149
cronPayloadObjectSchema({
150+
model: Type.Optional(Type.String({ description: "Model override" })),
150151
toolsAllow: Type.Optional(Type.Array(Type.String(), { description: "Allowed tools" })),
151152
}),
152153
);
@@ -273,6 +274,7 @@ function createCronPatchObjectSchema(): TSchema {
273274
wakeMode: optionalStringEnum(CRON_WAKE_MODES),
274275
payload: Type.Optional(
275276
cronPayloadObjectSchema({
277+
model: nullableStringSchema("Model override, or null to clear"),
276278
toolsAllow: nullableStringArraySchema("Allowed tool ids, or null to clear"),
277279
}),
278280
),

src/cron/normalize.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,17 @@ describe("normalizeCronJobCreate", () => {
142142
expectAnnounceDeliveryTarget(delivery, { channel: "telegram", to: "7200373102" });
143143
});
144144

145+
it("preserves explicit null model clear in payload patches", () => {
146+
const normalized = normalizeCronJobPatch({
147+
payload: {
148+
kind: "agentTurn",
149+
model: null,
150+
},
151+
}) as unknown as Record<string, { model?: unknown }>;
152+
153+
expect(normalized.payload?.model).toBeNull();
154+
});
155+
145156
it("coerces ISO schedule.at to normalized ISO (UTC)", () => {
146157
expectNormalizedAtSchedule({ kind: "at", at: "2026-01-12T18:00:00" });
147158
});

src/cron/normalize.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,11 +170,15 @@ function coercePayload(payload: UnknownRecord) {
170170
}
171171
}
172172
if ("model" in next) {
173-
const model = parseOptionalField(TrimmedNonEmptyStringFieldSchema, next.model);
174-
if (model !== undefined) {
175-
next.model = model;
173+
if (next.model === null) {
174+
next.model = null;
176175
} else {
177-
delete next.model;
176+
const model = parseOptionalField(TrimmedNonEmptyStringFieldSchema, next.model);
177+
if (model !== undefined) {
178+
next.model = model;
179+
} else {
180+
delete next.model;
181+
}
178182
}
179183
}
180184
if ("thinking" in next) {

src/cron/service.jobs.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,50 @@ describe("applyJobPatch", () => {
369369
}
370370
});
371371

372+
it("clears agentTurn payload.model when patch requests null", () => {
373+
const job = createIsolatedAgentTurnJob("job-model-clear", {
374+
mode: "announce",
375+
channel: "telegram",
376+
});
377+
job.payload = {
378+
kind: "agentTurn",
379+
message: "do it",
380+
model: "openai/gpt-5.5",
381+
};
382+
383+
applyJobPatch(job, {
384+
payload: {
385+
kind: "agentTurn",
386+
model: null,
387+
},
388+
});
389+
390+
expect(job.payload.kind).toBe("agentTurn");
391+
if (job.payload.kind === "agentTurn") {
392+
expect(job.payload.message).toBe("do it");
393+
expect(job.payload.model).toBeUndefined();
394+
}
395+
});
396+
397+
it("omits null model when patch builds a replacement agentTurn payload", () => {
398+
const job = createMainSystemEventJob("job-model-replace", { mode: "none" });
399+
400+
applyJobPatch(job, {
401+
sessionTarget: "isolated",
402+
payload: {
403+
kind: "agentTurn",
404+
message: "do it",
405+
model: null,
406+
},
407+
});
408+
409+
expect(job.payload.kind).toBe("agentTurn");
410+
if (job.payload.kind === "agentTurn") {
411+
expect(job.payload.message).toBe("do it");
412+
expect(job.payload.model).toBeUndefined();
413+
}
414+
});
415+
372416
it("applies payload.lightContext when replacing payload kind via patch", () => {
373417
const job = createIsolatedAgentTurnJob("job-light-context-switch", {
374418
mode: "announce",

0 commit comments

Comments
 (0)