Merge pull request #711 from mjrussell/feat/cron-model-override

feat(cron): add --model flag to cron add/edit commands
This commit is contained in:
Peter Steinberger
2026-01-11 10:53:42 +00:00
committed by GitHub
4 changed files with 160 additions and 3 deletions

View File

@@ -5,6 +5,7 @@
### Changes ### Changes
- macOS: prompt to install the global `clawdbot` CLI when missing in local mode; install via `clawd.bot/install-cli.sh` (no onboarding) and use external launchd/CLI instead of the embedded gateway runtime. - macOS: prompt to install the global `clawdbot` CLI when missing in local mode; install via `clawd.bot/install-cli.sh` (no onboarding) and use external launchd/CLI instead of the embedded gateway runtime.
- Docs: add gog calendar event color IDs from `gog calendar colors`. (#715) — thanks @mjrussell. - Docs: add gog calendar event color IDs from `gog calendar colors`. (#715) — thanks @mjrussell.
- Cron/CLI: trim model overrides on cron edits and document main-session guidance. (#711) — thanks @mjrussell.
### Fixes ### Fixes
- CLI/Update: preserve base environment when passing overrides to update subprocesses. (#713) — thanks @danielz1z. - CLI/Update: preserve base environment when passing overrides to update subprocesses. (#713) — thanks @danielz1z.

View File

@@ -61,9 +61,23 @@ Key behaviors:
- `wakeMode: "now"` triggers an immediate heartbeat after posting the summary. - `wakeMode: "now"` triggers an immediate heartbeat after posting the summary.
- If `payload.deliver: true`, output is delivered to a provider; otherwise it stays internal. - If `payload.deliver: true`, output is delivered to a provider; otherwise it stays internal.
Use isolated jobs for noisy, frequent, or background chores that shouldnt spam Use isolated jobs for noisy, frequent, or "background chores" that shouldn't spam
your main chat history. your main chat history.
### Model and thinking overrides
Isolated jobs (`agentTurn`) can override the model and thinking level:
- `model`: Provider/model string (e.g., `anthropic/claude-sonnet-4-20250514`) or alias (e.g., `opus`)
- `thinking`: Thinking level (`off`, `minimal`, `low`, `medium`, `high`)
Note: You can set `model` on main-session jobs too, but it changes the shared main
session model. We recommend model overrides only for isolated jobs to avoid
unexpected context shifts.
Resolution priority:
1. Job payload override (highest)
2. Hook-specific defaults (e.g., `hooks.gmail.model`)
3. Agent config default
### Delivery (provider + target) ### Delivery (provider + target)
Isolated jobs can deliver output to a provider. The job payload can specify: Isolated jobs can deliver output to a provider. The job payload can specify:
- `provider`: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage` / `last` - `provider`: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage` / `last`
@@ -142,6 +156,21 @@ clawdbot cron add \
--to "-1001234567890:topic:123" --to "-1001234567890:topic:123"
``` ```
Isolated job with model and thinking override:
```bash
clawdbot cron add \
--name "Deep analysis" \
--cron "0 6 * * 1" \
--tz "America/Los_Angeles" \
--session isolated \
--message "Weekly deep analysis of project progress." \
--model "opus" \
--thinking high \
--deliver \
--provider whatsapp \
--to "+15551234567"
```
Manual run (debug): Manual run (debug):
```bash ```bash
clawdbot cron run <jobId> --force clawdbot cron run <jobId> --force

110
src/cli/cron-cli.test.ts Normal file
View File

@@ -0,0 +1,110 @@
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
const callGatewayFromCli = vi.fn(
async (method: string, _opts: unknown, params?: unknown) => {
if (method === "cron.status") return { enabled: true };
return { ok: true, params };
},
);
vi.mock("./gateway-rpc.js", async () => {
const actual =
await vi.importActual<typeof import("./gateway-rpc.js")>(
"./gateway-rpc.js",
);
return {
...actual,
callGatewayFromCli: (
method: string,
opts: unknown,
params?: unknown,
extra?: unknown,
) => callGatewayFromCli(method, opts, params, extra),
};
});
vi.mock("../runtime.js", () => ({
defaultRuntime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
},
}));
describe("cron cli", () => {
it("trims model and thinking on cron add", async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
const program = new Command();
program.exitOverride();
registerCronCli(program);
await program.parseAsync(
[
"cron",
"add",
"--name",
"Daily",
"--cron",
"* * * * *",
"--session",
"isolated",
"--message",
"hello",
"--model",
" opus ",
"--thinking",
" low ",
],
{ from: "user" },
);
const addCall = callGatewayFromCli.mock.calls.find(
(call) => call[0] === "cron.add",
);
const params = addCall?.[2] as {
payload?: { model?: string; thinking?: string };
};
expect(params?.payload?.model).toBe("opus");
expect(params?.payload?.thinking).toBe("low");
});
it("omits empty model and thinking on cron edit", async () => {
callGatewayFromCli.mockClear();
const { registerCronCli } = await import("./cron-cli.js");
const program = new Command();
program.exitOverride();
registerCronCli(program);
await program.parseAsync(
[
"cron",
"edit",
"job-1",
"--message",
"hello",
"--model",
" ",
"--thinking",
" ",
],
{ from: "user" },
);
const updateCall = callGatewayFromCli.mock.calls.find(
(call) => call[0] === "cron.update",
);
const patch = updateCall?.[2] as {
patch?: { payload?: { model?: string; thinking?: string } };
};
expect(patch?.patch?.payload?.model).toBeUndefined();
expect(patch?.patch?.payload?.thinking).toBeUndefined();
});
});

View File

@@ -296,6 +296,10 @@ export function registerCronCli(program: Command) {
"--thinking <level>", "--thinking <level>",
"Thinking level for agent jobs (off|minimal|low|medium|high)", "Thinking level for agent jobs (off|minimal|low|medium|high)",
) )
.option(
"--model <model>",
"Model override for agent jobs (provider/model or alias)",
)
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs") .option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
.option("--deliver", "Deliver agent output", false) .option("--deliver", "Deliver agent output", false)
.option( .option(
@@ -391,6 +395,10 @@ export function registerCronCli(program: Command) {
return { return {
kind: "agentTurn" as const, kind: "agentTurn" as const,
message, message,
model:
typeof opts.model === "string" && opts.model.trim()
? opts.model.trim()
: undefined,
thinking: thinking:
typeof opts.thinking === "string" && opts.thinking.trim() typeof opts.thinking === "string" && opts.thinking.trim()
? opts.thinking.trim() ? opts.thinking.trim()
@@ -558,6 +566,7 @@ export function registerCronCli(program: Command) {
.option("--system-event <text>", "Set systemEvent payload") .option("--system-event <text>", "Set systemEvent payload")
.option("--message <text>", "Set agentTurn payload message") .option("--message <text>", "Set agentTurn payload message")
.option("--thinking <level>", "Thinking level for agent jobs") .option("--thinking <level>", "Thinking level for agent jobs")
.option("--model <model>", "Model override for agent jobs")
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs") .option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
.option("--deliver", "Deliver agent output", false) .option("--deliver", "Deliver agent output", false)
.option( .option(
@@ -637,14 +646,22 @@ export function registerCronCli(program: Command) {
text: String(opts.systemEvent), text: String(opts.systemEvent),
}; };
} else if (opts.message) { } else if (opts.message) {
const model =
typeof opts.model === "string" && opts.model.trim()
? opts.model.trim()
: undefined;
const thinking =
typeof opts.thinking === "string" && opts.thinking.trim()
? opts.thinking.trim()
: undefined;
const timeoutSeconds = opts.timeoutSeconds const timeoutSeconds = opts.timeoutSeconds
? Number.parseInt(String(opts.timeoutSeconds), 10) ? Number.parseInt(String(opts.timeoutSeconds), 10)
: undefined; : undefined;
patch.payload = { patch.payload = {
kind: "agentTurn", kind: "agentTurn",
message: String(opts.message), message: String(opts.message),
thinking: model,
typeof opts.thinking === "string" ? opts.thinking : undefined, thinking,
timeoutSeconds: timeoutSeconds:
timeoutSeconds && Number.isFinite(timeoutSeconds) timeoutSeconds && Number.isFinite(timeoutSeconds)
? timeoutSeconds ? timeoutSeconds