diff --git a/src/config/types.ts b/src/config/types.ts index 1040d51a1..cfbf37bd7 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -216,6 +216,8 @@ export type HookMappingConfig = { | "signal" | "imessage"; to?: string; + /** Override model for this hook (provider/model or alias). */ + model?: string; thinking?: string; timeoutSeconds?: number; transform?: HookMappingTransform; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 00b3ebb7f..77a1b92ee 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -743,6 +743,7 @@ const HookMappingSchema = z ]) .optional(), to: z.string().optional(), + model: z.string().optional(), thinking: z.string().optional(), timeoutSeconds: z.number().int().positive().optional(), transform: z diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index 85afb10e8..ff71c7fe4 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -126,6 +126,78 @@ describe("runCronIsolatedAgentTurn", () => { }); }); + it("uses model override when provided", async () => { + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath), + deps, + job: makeJob({ + kind: "agentTurn", + message: "do it", + model: "openai/gpt-4.1-mini", + }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("ok"); + const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0] as { + provider?: string; + model?: string; + }; + expect(call?.provider).toBe("openai"); + expect(call?.model).toBe("gpt-4.1-mini"); + }); + }); + + it("rejects invalid model override", async () => { + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; + vi.mocked(runEmbeddedPiAgent).mockReset(); + + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath), + deps, + job: makeJob({ + kind: "agentTurn", + message: "do it", + model: "openai/", + }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("error"); + expect(res.error).toMatch("invalid model"); + expect(vi.mocked(runEmbeddedPiAgent)).not.toHaveBeenCalled(); + }); + }); + it("defaults thinking to low for reasoning-capable models", async () => { await withTempHome(async (home) => { const storePath = await writeSessionStore(home); diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index 156e7407a..a7d756a1a 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -8,7 +8,11 @@ import { import { loadModelCatalog } from "../agents/model-catalog.js"; import { runWithModelFallback } from "../agents/model-fallback.js"; import { + buildAllowedModelSet, + buildModelAliasIndex, + modelKey, resolveConfiguredModelRef, + resolveModelRefFromString, resolveThinkingDefault, } from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; @@ -212,11 +216,59 @@ export async function runCronIsolatedAgentTurn(params: { }); const workspaceDir = workspace.dir; - const { provider, model } = resolveConfiguredModelRef({ + const resolvedDefault = resolveConfiguredModelRef({ cfg: params.cfg, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, }); + let provider = resolvedDefault.provider; + let model = resolvedDefault.model; + let catalog: Awaited> | undefined; + const loadCatalog = async () => { + if (!catalog) { + catalog = await loadModelCatalog({ config: params.cfg }); + } + return catalog; + }; + const modelOverrideRaw = + params.job.payload.kind === "agentTurn" + ? params.job.payload.model + : undefined; + if (modelOverrideRaw !== undefined) { + if (typeof modelOverrideRaw !== "string") { + return { status: "error", error: "invalid model: expected string" }; + } + const trimmed = modelOverrideRaw.trim(); + if (!trimmed) { + return { status: "error", error: "invalid model: empty" }; + } + const aliasIndex = buildModelAliasIndex({ + cfg: params.cfg, + defaultProvider: resolvedDefault.provider, + }); + const resolvedOverride = resolveModelRefFromString({ + raw: trimmed, + defaultProvider: resolvedDefault.provider, + aliasIndex, + }); + if (!resolvedOverride) { + return { status: "error", error: `invalid model: ${trimmed}` }; + } + const allowed = buildAllowedModelSet({ + cfg: params.cfg, + catalog: await loadCatalog(), + defaultProvider: resolvedDefault.provider, + }); + const key = modelKey( + resolvedOverride.ref.provider, + resolvedOverride.ref.model, + ); + if (!allowed.allowAny && !allowed.allowedKeys.has(key)) { + return { status: "error", error: `model not allowed: ${key}` }; + } + provider = resolvedOverride.ref.provider; + model = resolvedOverride.ref.model; + } const now = Date.now(); const cronSession = resolveCronSession({ cfg: params.cfg, @@ -234,12 +286,11 @@ export async function runCronIsolatedAgentTurn(params: { ); let thinkLevel = jobThink ?? thinkOverride; if (!thinkLevel) { - const catalog = await loadModelCatalog({ config: params.cfg }); thinkLevel = resolveThinkingDefault({ cfg: params.cfg, provider, model, - catalog, + catalog: await loadCatalog(), }); } diff --git a/src/cron/types.ts b/src/cron/types.ts index 0ac709f8f..7a0f1009a 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -11,6 +11,8 @@ export type CronPayload = | { kind: "agentTurn"; message: string; + /** Optional model override (provider/model or alias). */ + model?: string; thinking?: string; timeoutSeconds?: number; deliver?: boolean; diff --git a/src/gateway/hooks-mapping.test.ts b/src/gateway/hooks-mapping.test.ts index fd2d9a598..2a013e0fb 100644 --- a/src/gateway/hooks-mapping.test.ts +++ b/src/gateway/hooks-mapping.test.ts @@ -38,6 +38,30 @@ describe("hooks mapping", () => { } }); + it("passes model override from mapping", async () => { + const mappings = resolveHookMappings({ + mappings: [ + { + id: "demo", + match: { path: "gmail" }, + action: "agent", + messageTemplate: "Subject: {{messages[0].subject}}", + model: "openai/gpt-4.1-mini", + }, + ], + }); + const result = await applyHookMappings(mappings, { + payload: { messages: [{ subject: "Hello" }] }, + headers: {}, + url: baseUrl, + path: "gmail", + }); + expect(result?.ok).toBe(true); + if (result?.ok && result.action.kind === "agent") { + expect(result.action.model).toBe("openai/gpt-4.1-mini"); + } + }); + it("runs transform module", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-hooks-")); const modPath = path.join(dir, "transform.mjs"); diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index eab89c6d3..3216abadd 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -27,6 +27,7 @@ export type HookMappingResolved = { | "signal" | "imessage"; to?: string; + model?: string; thinking?: string; timeoutSeconds?: number; transform?: HookMappingTransformResolved; @@ -66,6 +67,7 @@ export type HookAction = | "signal" | "imessage"; to?: string; + model?: string; thinking?: string; timeoutSeconds?: number; }; @@ -110,6 +112,7 @@ type HookTransformResult = Partial<{ | "signal" | "imessage"; to: string; + model: string; thinking: string; timeoutSeconds: number; }> | null; @@ -198,6 +201,7 @@ function normalizeHookMapping( deliver: mapping.deliver, provider: mapping.provider, to: mapping.to, + model: mapping.model, thinking: mapping.thinking, timeoutSeconds: mapping.timeoutSeconds, transform, @@ -243,6 +247,7 @@ function buildActionFromMapping( deliver: mapping.deliver, provider: mapping.provider, to: renderOptional(mapping.to, ctx), + model: renderOptional(mapping.model, ctx), thinking: renderOptional(mapping.thinking, ctx), timeoutSeconds: mapping.timeoutSeconds, }, @@ -293,6 +298,7 @@ function mergeAction( : baseAgent?.deliver, provider: override.provider ?? baseAgent?.provider, to: override.to ?? baseAgent?.to, + model: override.model ?? baseAgent?.model, thinking: override.thinking ?? baseAgent?.thinking, timeoutSeconds: override.timeoutSeconds ?? baseAgent?.timeoutSeconds, }); diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index 542d53a17..b0cd50708 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -147,6 +147,7 @@ export type HookAgentPayload = { | "signal" | "imessage"; to?: string; + model?: string; thinking?: string; timeoutSeconds?: number; }; @@ -201,6 +202,14 @@ export function normalizeAgentPayload( const toRaw = payload.to; const to = typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined; + const modelRaw = payload.model; + const model = + typeof modelRaw === "string" && modelRaw.trim() + ? modelRaw.trim() + : undefined; + if (modelRaw !== undefined && !model) { + return { ok: false, error: "model required" }; + } const deliver = payload.deliver === true; const thinkingRaw = payload.thinking; const thinking = @@ -224,6 +233,7 @@ export function normalizeAgentPayload( deliver, provider, to, + model, thinking, timeoutSeconds, }, diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index eb384e060..eccb05ffe 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -664,6 +664,7 @@ export const CronPayloadSchema = Type.Union([ { kind: Type.Literal("agentTurn"), message: NonEmptyString, + model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })), deliver: Type.Optional(Type.Boolean()), diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index e3524391c..5f6f1ddbf 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -41,6 +41,7 @@ type HookDispatchers = { | "signal" | "imessage"; to?: string; + model?: string; thinking?: string; timeoutSeconds?: number; }) => string; @@ -177,6 +178,7 @@ export function createHooksRequestHandler( deliver: mapped.action.deliver === true, provider: mapped.action.provider ?? "last", to: mapped.action.to, + model: mapped.action.model, thinking: mapped.action.thinking, timeoutSeconds: mapped.action.timeoutSeconds, }); diff --git a/src/gateway/server.hooks.test.ts b/src/gateway/server.hooks.test.ts index c68744089..a3afebda0 100644 --- a/src/gateway/server.hooks.test.ts +++ b/src/gateway/server.hooks.test.ts @@ -67,6 +67,37 @@ describe("gateway server hooks", () => { await server.close(); }); + test("hooks agent forwards model override", async () => { + testState.hooksConfig = { enabled: true, token: "hook-secret" }; + cronIsolatedRun.mockClear(); + cronIsolatedRun.mockResolvedValueOnce({ + status: "ok", + summary: "done", + }); + const port = await getFreePort(); + const server = await startGatewayServer(port); + const res = await fetch(`http://127.0.0.1:${port}/hooks/agent`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer hook-secret", + }, + body: JSON.stringify({ + message: "Do it", + name: "Email", + model: "openai/gpt-4.1-mini", + }), + }); + expect(res.status).toBe(202); + await waitForSystemEvent(); + const call = cronIsolatedRun.mock.calls[0]?.[0] as { + job?: { payload?: { model?: string } }; + }; + expect(call?.job?.payload?.model).toBe("openai/gpt-4.1-mini"); + drainSystemEvents(); + await server.close(); + }); + test("hooks wake accepts query token", async () => { testState.hooksConfig = { enabled: true, token: "hook-secret" }; const port = await getFreePort(); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index d2ea0ce0b..caf5696b2 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -502,6 +502,7 @@ export async function startGatewayServer( | "signal" | "imessage"; to?: string; + model?: string; thinking?: string; timeoutSeconds?: number; }) => { @@ -522,6 +523,7 @@ export async function startGatewayServer( payload: { kind: "agentTurn", message: value.message, + model: value.model, thinking: value.thinking, timeoutSeconds: value.timeoutSeconds, deliver: value.deliver,