From 5755d85ad1e04b31070ff463cb7821a7cded730b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 19:59:45 +0100 Subject: [PATCH] fix: harden Gmail hook model defaults (#472) (thanks @koala73) --- CHANGELOG.md | 2 + docs/automation/gmail-pubsub.md | 19 ++ .../proposals/hooks-gmail-model.md | 306 ++++++++++++++++++ docs/gateway/configuration.md | 14 + src/agents/model-fallback.test.ts | 89 +++++ src/agents/model-fallback.ts | 71 ++++ src/agents/model-selection.test.ts | 73 +++++ src/agents/model-selection.ts | 25 ++ src/config/types.ts | 4 + src/config/zod-schema.ts | 10 + src/cron/isolated-agent.test.ts | 101 ++++++ src/cron/isolated-agent.ts | 28 +- src/gateway/server.ts | 42 ++- 13 files changed, 782 insertions(+), 2 deletions(-) create mode 100644 docs/experiments/proposals/hooks-gmail-model.md create mode 100644 src/agents/model-fallback.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 90128e027..6a00f41be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,9 +24,11 @@ - CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging. - Hooks: default hook agent delivery to true. (#533) — thanks @mcinteerj - Hooks: normalize hook agent providers (aliases + msteams support). +- Hooks: add Gmail hook model/thinking defaults with auth/rate-limit/timeout fallback and allowlist warnings. (#472) — thanks @koala73 - WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj - iMessage: isolate group-ish threads by chat_id. (#535) — thanks @mdahmann - Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223 +- Models: only advance fallbacks on auth/rate-limit/timeout errors. (#472) — thanks @koala73 - Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj - Agent: skip empty error assistant messages when rebuilding session context to avoid tool-chain corruption. (#561) — thanks @mukhtharcm - Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles. diff --git a/docs/automation/gmail-pubsub.md b/docs/automation/gmail-pubsub.md index 9995c9bc9..bf1333687 100644 --- a/docs/automation/gmail-pubsub.md +++ b/docs/automation/gmail-pubsub.md @@ -65,6 +65,25 @@ uses the last delivery route (falls back to WhatsApp). To force a cheaper model for Gmail runs, set `model` in the mapping (`provider/model` or alias). If you enforce `agents.defaults.models`, include it there. +To set a default model and thinking level specifically for Gmail hooks, add +`hooks.gmail.model` / `hooks.gmail.thinking` in your config: + +```json5 +{ + hooks: { + gmail: { + model: "openrouter/meta-llama/llama-3.3-70b-instruct:free", + thinking: "off" + } + } +} +``` + +Notes: +- Per-hook `model`/`thinking` in the mapping still overrides these defaults. +- Fallback order: `hooks.gmail.model` → `agents.defaults.model.fallbacks` → primary (auth/rate-limit/timeouts). +- If `agents.defaults.models` is set, the Gmail model must be in the allowlist. + To customize payload handling further, add `hooks.mappings` or a JS/TS transform module under `hooks.transformsDir` (see [`docs/webhook.md`](https://docs.clawd.bot/automation/webhook)). diff --git a/docs/experiments/proposals/hooks-gmail-model.md b/docs/experiments/proposals/hooks-gmail-model.md new file mode 100644 index 000000000..fba8ba5dc --- /dev/null +++ b/docs/experiments/proposals/hooks-gmail-model.md @@ -0,0 +1,306 @@ +--- +summary: "Spec for hooks.gmail.model - cheaper model for Gmail PubSub processing" +read_when: + - Implementing hooks.gmail.model feature + - Modifying Gmail hook processing + - Working on hook model selection +--- +# hooks.gmail.model: Cheaper Model for Gmail PubSub Processing + +## Problem + +Gmail PubSub hook processing (`/gmail-pubsub`) currently uses the session's primary model (`agents.defaults.model.primary`), which may be an expensive model like `claude-opus-4-5`. For automated email processing that doesn't require the most capable model, this wastes tokens/cost. + +## Solution + +Add `hooks.gmail.model` config option to specify an optional cheaper model for Gmail PubSub processing, with intelligent fallback to the primary model on auth/rate-limit/timeout failures. + +## Config Structure + +```json5 +{ + hooks: { + gmail: { + account: "user@gmail.com", + // ... existing gmail config ... + + // NEW: Optional model override for Gmail hook processing + model: "openrouter/meta-llama/llama-3.3-70b-instruct:free", + + // NEW: Optional thinking level override + thinking: "off" + } + } +} +``` + +### Fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `hooks.gmail.model` | `string` | (none) | Model to use for Gmail hook processing. Accepts `provider/model` refs or aliases from `agents.defaults.models`. | +| `hooks.gmail.thinking` | `string` | (inherited) | Thinking level override (`off`, `minimal`, `low`, `medium`, `high`). If unset, inherits from `agents.defaults.thinkingDefault` or model's default. | + +### Alias Support + +`hooks.gmail.model` accepts: +- Full refs: `"openrouter/meta-llama/llama-3.3-70b-instruct:free"` +- Aliases from `agents.defaults.models`: `"Opus"`, `"Sonnet"`, `"GLM"` + +Resolution uses `buildModelAliasIndex()` from `model-selection.ts`. + +## Fallback Behavior + +### Fallback Triggers + +Auth, rate-limit, and timeout errors trigger fallback: +- `401 Unauthorized` +- `403 Forbidden` +- `429 Too Many Requests` +- Timeouts (provider hangs / network timeouts) + +Other errors (500s, content errors) fail in place. + +### Fallback Chain + +``` +hooks.gmail.model (if set) + ↓ (on auth/rate-limit/timeout) +agents.defaults.model.fallbacks[0..n] + ↓ (exhausted) +agents.defaults.model.primary +``` + +### Uncatalogued Model + +If `hooks.gmail.model` is set but not found in the model catalog or allowlist: +- **Config load**: Log warning (surfaced in `clawdbot doctor`) +- **Allowlist**: If `agents.defaults.models` is set and the model isn't listed, the hook falls back to primary. + +### Cooldown Integration + +Uses existing model-failover cooldown from `model-failover.ts`: +- After auth/rate-limit failure, model enters cooldown +- Next hook invocation respects cooldown before retrying +- Integrates with auth profile rotation + +## Implementation + +### Type Changes + +```typescript +// src/config/types.ts +export type HooksGmailConfig = { + account?: string; + label?: string; + // ... existing fields ... + + /** Optional model override for Gmail hook processing (provider/model or alias). */ + model?: string; + /** Optional thinking level override for Gmail hook processing. */ + thinking?: "off" | "minimal" | "low" | "medium" | "high"; +}; +``` + +### Model Resolution + +New function in `src/cron/isolated-agent.ts` or `src/agents/model-selection.ts`: + +```typescript +export function resolveHooksGmailModel(params: { + cfg: ClawdbotConfig; + defaultProvider: string; + defaultModel: string; +}): { provider: string; model: string; isHooksOverride: boolean } | null { + const hooksModel = params.cfg.hooks?.gmail?.model; + if (!hooksModel) return null; + + const aliasIndex = buildModelAliasIndex({ + cfg: params.cfg, + defaultProvider: params.defaultProvider, + }); + + const resolved = resolveModelRefFromString({ + raw: hooksModel, + defaultProvider: params.defaultProvider, + aliasIndex, + }); + + if (!resolved) return null; + return { + provider: resolved.ref.provider, + model: resolved.ref.model, + isHooksOverride: true, + }; +} +``` + +### Processing Flow + +In `runCronIsolatedAgentTurn()` (or new wrapper for hooks): + +```typescript +// Resolve model - prefer hooks.gmail.model for Gmail hooks +const isGmailHook = params.sessionKey.startsWith("hook:gmail:"); +const hooksModelRef = isGmailHook + ? resolveHooksGmailModel({ cfg, defaultProvider, defaultModel }) + : null; + +const { provider, model } = hooksModelRef ?? resolveConfiguredModelRef({ + cfg: params.cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, +}); + +// Run with fallback - on auth/rate-limit/timeout, fall through to agents.defaults.model.fallbacks +const fallbackResult = await runWithModelFallback({ + cfg: params.cfg, + provider, + model, + hooksOverride: hooksModelRef?.isHooksOverride, + run: (providerOverride, modelOverride) => runEmbeddedPiAgent({ + // ... existing params ... + }), +}); +``` + +### Fallback Detection + +Extend `runWithModelFallback()` to detect auth/rate-limit: + +```typescript +function isAuthRateLimitError(err: unknown): boolean { + if (err instanceof ApiError) { + return [401, 403, 429].includes(err.status); + } + // Check for common patterns in error messages + const msg = String(err).toLowerCase(); + return msg.includes("unauthorized") + || msg.includes("rate limit") + || msg.includes("quota exceeded"); +} +``` + +## Validation + +### Config Load Time + +In config validation (for `clawdbot doctor`): + +```typescript +if (cfg.hooks?.gmail?.model) { + const resolved = resolveHooksGmailModel({ cfg, defaultProvider, defaultModel }); + if (!resolved) { + issues.push({ + path: "hooks.gmail.model", + message: `Model "${cfg.hooks.gmail.model}" could not be resolved`, + }); + } else { + const catalog = await loadModelCatalog({ config: cfg }); + const key = modelKey(resolved.provider, resolved.model); + const inCatalog = catalog.some(e => modelKey(e.provider, e.id) === key); + if (!inCatalog) { + issues.push({ + path: "hooks.gmail.model", + message: `Model "${key}" not found in agents.defaults.models catalog (will fall back to primary)`, + }); + } + } +} +``` + +### Runtime + +At hook invocation time, validate and fall back: +- If model not in catalog → log warning, use primary +- If model auth fails → log warning, enter cooldown, fall back + +## Observability + +### Log Messages + +``` +[hooks] Gmail hook: using model openrouter/meta-llama/llama-3.3-70b-instruct:free +[hooks] Gmail hook: model llama auth failed (429), falling back to claude-opus-4-5 +``` + +### Hook Event Summary + +Include fallback info in the hook summary sent to session: + +``` +Hook Gmail (fallback:llama→opus): +``` + +## Hot Reload + +`hooks.gmail.model` and `hooks.gmail.thinking` are hot-reloadable: +- Changes apply to the next hook invocation +- No gateway restart required +- Hooks config is already in the hot-reload matrix + +## Test Plan + +### Unit Tests + +1. **Model resolution** (`model-selection.test.ts`): + - `resolveHooksGmailModel()` with valid ref + - `resolveHooksGmailModel()` with alias + - `resolveHooksGmailModel()` with invalid input → null + +2. **Config validation** (`config.test.ts`): + - Warning on uncatalogued model + - No warning on valid model + - Graceful handling of missing hooks.gmail section + +3. **Fallback triggers** (`model-fallback.test.ts`): + - 401/403/429 → triggers fallback + - timeouts → triggers fallback + - 500/content error → no fallback + - Content error → no fallback + +### Integration Tests + +1. **Hook processing** (`server.hooks.test.ts`): + - Gmail hook uses `hooks.gmail.model` when set + - Fallback to primary on auth failure + - Thinking level override applied + +2. **Hot reload** (`config-reload.test.ts`): + - Change `hooks.gmail.model` → next hook uses new model + +## Documentation + +Update `docs/gateway/configuration.md`: + +```json5 +{ + hooks: { + gmail: { + account: "user@gmail.com", + topic: "projects/my-project/topics/gmail-watch", + // ... existing config ... + + // Optional: Use a cheaper model for Gmail processing + // Falls back to agents.defaults.model.primary on auth/rate-limit errors + model: "openrouter/meta-llama/llama-3.3-70b-instruct:free", + + // Optional: Override thinking level for Gmail processing + thinking: "off" + } + } +} +``` + +## Scope Limitation + +This PR is Gmail-specific. Future hooks (`hooks.github.model`, etc.) would follow the same pattern but are out of scope. + +## Changelog Entry + +``` +- feat: add hooks.gmail.model for cheaper Gmail PubSub processing (#XXX) + - Falls back to agents.defaults.model.primary on auth/rate-limit/timeouts (401/403/429) + - Supports aliases from agents.defaults.models + - Add hooks.gmail.thinking override +``` diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index f3f5e8ee5..af5bc860f 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1884,11 +1884,25 @@ Gmail helper config (used by `clawdbot hooks gmail setup` / `run`): renewEveryMinutes: 720, serve: { bind: "127.0.0.1", port: 8788, path: "/" }, tailscale: { mode: "funnel", path: "/gmail-pubsub" }, + + // Optional: use a cheaper model for Gmail hook processing + // Falls back to agents.defaults.model.fallbacks, then primary, on auth/rate-limit/timeout + model: "openrouter/meta-llama/llama-3.3-70b-instruct:free", + // Optional: default thinking level for Gmail hooks + thinking: "off", } } } ``` +Model override for Gmail hooks: +- `hooks.gmail.model` specifies a model to use for Gmail hook processing (defaults to session primary). +- Accepts `provider/model` refs or aliases from `agents.defaults.models`. +- Falls back to `agents.defaults.model.fallbacks`, then `agents.defaults.model.primary`, on auth/rate-limit/timeouts. +- If `agents.defaults.models` is set, include the hooks model in the allowlist. +- At startup, warns if the configured model is not in the model catalog or allowlist. +- `hooks.gmail.thinking` sets the default thinking level for Gmail hooks and is overridden by per-hook `thinking`. + Gateway auto-start: - If `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts `gog gmail watch serve` on boot and auto-renews the watch. diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts new file mode 100644 index 000000000..7bb3dda3c --- /dev/null +++ b/src/agents/model-fallback.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { runWithModelFallback } from "./model-fallback.js"; + +function makeCfg(overrides: Partial = {}): ClawdbotConfig { + return { + agents: { + defaults: { + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: ["anthropic/claude-haiku-3-5"], + }, + }, + }, + ...overrides, + } as ClawdbotConfig; +} + +describe("runWithModelFallback", () => { + it("does not fall back on non-auth errors", async () => { + const cfg = makeCfg(); + const run = vi + .fn() + .mockRejectedValueOnce(new Error("bad request")) + .mockResolvedValueOnce("ok"); + + await expect( + runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-4.1-mini", + run, + }), + ).rejects.toThrow("bad request"); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("falls back on auth errors", async () => { + const cfg = makeCfg(); + const run = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error("nope"), { status: 401 })) + .mockResolvedValueOnce("ok"); + + const result = await runWithModelFallback({ + cfg, + provider: "openai", + model: "gpt-4.1-mini", + run, + }); + + expect(result.result).toBe("ok"); + expect(run).toHaveBeenCalledTimes(2); + expect(run.mock.calls[1]?.[0]).toBe("anthropic"); + expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5"); + }); + + it("appends the configured primary as a last fallback", async () => { + const cfg = makeCfg({ + agents: { + defaults: { + model: { + primary: "openai/gpt-4.1-mini", + fallbacks: [], + }, + }, + }, + }); + const run = vi + .fn() + .mockRejectedValueOnce( + Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }), + ) + .mockResolvedValueOnce("ok"); + + const result = await runWithModelFallback({ + cfg, + provider: "openrouter", + model: "meta-llama/llama-3.3-70b:free", + run, + }); + + expect(result.result).toBe("ok"); + expect(run).toHaveBeenCalledTimes(2); + expect(result.provider).toBe("openai"); + expect(result.model).toBe("gpt-4.1-mini"); + }); +}); diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 78989a16d..bced46b5c 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -4,8 +4,13 @@ import { buildModelAliasIndex, modelKey, parseModelRef, + resolveConfiguredModelRef, resolveModelRefFromString, } from "./model-selection.js"; +import { + isAuthErrorMessage, + isRateLimitErrorMessage, +} from "./pi-embedded-helpers.js"; type ModelCandidate = { provider: string; @@ -29,6 +34,59 @@ function isAbortError(err: unknown): boolean { return message.includes("aborted"); } +function getStatusCode(err: unknown): number | null { + if (!err || typeof err !== "object") return null; + const candidate = + (err as { status?: unknown; statusCode?: unknown }).status ?? + (err as { statusCode?: unknown }).statusCode; + if (typeof candidate === "number") return candidate; + if (typeof candidate === "string" && /^\d+$/.test(candidate)) { + return Number(candidate); + } + return null; +} + +function getErrorCode(err: unknown): string { + if (!err || typeof err !== "object") return ""; + const candidate = (err as { code?: unknown }).code; + return typeof candidate === "string" ? candidate : ""; +} + +function getErrorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err ?? ""); +} + +function isTimeoutErrorMessage(raw: string): boolean { + const value = raw.toLowerCase(); + return ( + value.includes("timeout") || + value.includes("timed out") || + value.includes("deadline exceeded") || + value.includes("context deadline exceeded") + ); +} + +function shouldFallbackForError(err: unknown): boolean { + const statusCode = getStatusCode(err); + if (statusCode && [401, 403, 429].includes(statusCode)) return true; + const code = getErrorCode(err).toUpperCase(); + if ( + ["ETIMEDOUT", "ESOCKETTIMEDOUT", "ECONNRESET", "ECONNABORTED"].includes( + code, + ) + ) { + return true; + } + const message = getErrorMessage(err); + if (!message) return false; + return ( + isAuthErrorMessage(message) || + isRateLimitErrorMessage(message) || + isTimeoutErrorMessage(message) + ); +} + function buildAllowedModelKeys( cfg: ClawdbotConfig | undefined, defaultProvider: string, @@ -119,6 +177,13 @@ function resolveFallbackCandidates(params: { }): ModelCandidate[] { const provider = params.provider.trim() || DEFAULT_PROVIDER; const model = params.model.trim() || DEFAULT_MODEL; + const primary = params.cfg + ? resolveConfiguredModelRef({ + cfg: params.cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }) + : null; const aliasIndex = buildModelAliasIndex({ cfg: params.cfg ?? {}, defaultProvider: DEFAULT_PROVIDER, @@ -160,6 +225,10 @@ function resolveFallbackCandidates(params: { addCandidate(resolved.ref, true); } + if (primary?.provider && primary.model) { + addCandidate({ provider: primary.provider, model: primary.model }, false); + } + return candidates; } @@ -197,6 +266,8 @@ export async function runWithModelFallback(params: { }; } catch (err) { if (isAbortError(err)) throw err; + const shouldFallback = shouldFallbackForError(err); + if (!shouldFallback) throw err; lastError = err; attempts.push({ provider: candidate.provider, diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 25a1f06be..a36a2e139 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; import type { ClawdbotConfig } from "../config/config.js"; +import { DEFAULT_PROVIDER } from "./defaults.js"; import { buildAllowedModelSet, modelKey, parseModelRef, + resolveHooksGmailModel, } from "./model-selection.js"; const catalog = [ @@ -70,3 +72,74 @@ describe("parseModelRef", () => { }); }); }); + +describe("resolveHooksGmailModel", () => { + it("returns null when hooks.gmail.model is not set", () => { + const cfg = {} satisfies ClawdbotConfig; + const result = resolveHooksGmailModel({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + }); + expect(result).toBeNull(); + }); + + it("returns null when hooks.gmail.model is empty", () => { + const cfg = { + hooks: { gmail: { model: "" } }, + } satisfies ClawdbotConfig; + const result = resolveHooksGmailModel({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + }); + expect(result).toBeNull(); + }); + + it("parses provider/model from hooks.gmail.model", () => { + const cfg = { + hooks: { gmail: { model: "openrouter/meta-llama/llama-3.3-70b:free" } }, + } satisfies ClawdbotConfig; + const result = resolveHooksGmailModel({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + }); + expect(result).toEqual({ + provider: "openrouter", + model: "meta-llama/llama-3.3-70b:free", + }); + }); + + it("resolves alias from agent.models", () => { + const cfg = { + agents: { + defaults: { + models: { + "anthropic/claude-sonnet-4-1": { alias: "Sonnet" }, + }, + }, + }, + hooks: { gmail: { model: "Sonnet" } }, + } satisfies ClawdbotConfig; + const result = resolveHooksGmailModel({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + }); + expect(result).toEqual({ + provider: "anthropic", + model: "claude-sonnet-4-1", + }); + }); + + it("uses default provider when model omits provider", () => { + const cfg = { + hooks: { gmail: { model: "claude-haiku-3-5" } }, + } satisfies ClawdbotConfig; + const result = resolveHooksGmailModel({ + cfg, + defaultProvider: "anthropic", + }); + expect(result).toEqual({ + provider: "anthropic", + model: "claude-haiku-3-5", + }); + }); +}); diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 8d199a6c0..75b2035ad 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -211,3 +211,28 @@ export function resolveThinkingDefault(params: { if (candidate?.reasoning) return "low"; return "off"; } + +/** + * Resolve the model configured for Gmail hook processing. + * Returns null if hooks.gmail.model is not set. + */ +export function resolveHooksGmailModel(params: { + cfg: ClawdbotConfig; + defaultProvider: string; +}): ModelRef | null { + const hooksModel = params.cfg.hooks?.gmail?.model; + if (!hooksModel?.trim()) return null; + + const aliasIndex = buildModelAliasIndex({ + cfg: params.cfg, + defaultProvider: params.defaultProvider, + }); + + const resolved = resolveModelRefFromString({ + raw: hooksModel, + defaultProvider: params.defaultProvider, + aliasIndex, + }); + + return resolved?.ref ?? null; +} diff --git a/src/config/types.ts b/src/config/types.ts index 8cfdae2e6..3ff3b9a12 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -265,6 +265,10 @@ export type HooksGmailConfig = { mode?: HooksGmailTailscaleMode; path?: string; }; + /** Optional model override for Gmail hook processing (provider/model or alias). */ + model?: string; + /** Optional thinking level override for Gmail hook processing. */ + thinking?: "off" | "minimal" | "low" | "medium" | "high"; }; export type HooksConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index ae655eb41..0414689b0 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -911,6 +911,16 @@ const HooksGmailSchema = z path: z.string().optional(), }) .optional(), + model: z.string().optional(), + thinking: z + .union([ + z.literal("off"), + z.literal("minimal"), + z.literal("low"), + z.literal("medium"), + z.literal("high"), + ]) + .optional(), }) .optional(); diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index 300c0eda7..4edf10c6a 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -161,6 +161,107 @@ describe("runCronIsolatedAgentTurn", () => { }); }); + it("uses hooks.gmail.model for Gmail hook sessions", 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, { + hooks: { + gmail: { + model: "openrouter/meta-llama/llama-3.3-70b:free", + }, + }, + }), + deps, + job: makeJob({ kind: "agentTurn", message: "do it", deliver: false }), + message: "do it", + sessionKey: "hook:gmail:msg-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("openrouter"); + expect(call?.model).toBe("meta-llama/llama-3.3-70b:free"); + }); + }); + + it("ignores hooks.gmail.model when not in the allowlist", 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" }, + }, + }); + vi.mocked(loadModelCatalog).mockResolvedValueOnce([ + { + id: "claude-opus-4-5", + name: "Opus 4.5", + provider: "anthropic", + }, + ]); + + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath, { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + models: { + "anthropic/claude-opus-4-5": { alias: "Opus" }, + }, + }, + }, + hooks: { + gmail: { + model: "openrouter/meta-llama/llama-3.3-70b:free", + }, + }, + }), + deps, + job: makeJob({ kind: "agentTurn", message: "do it", deliver: false }), + message: "do it", + sessionKey: "hook:gmail:msg-2", + 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("anthropic"); + expect(call?.model).toBe("claude-opus-4-5"); + }); + }); + it("rejects invalid model override", 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 babfba271..139252a69 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -13,6 +13,7 @@ import { buildModelAliasIndex, modelKey, resolveConfiguredModelRef, + resolveHooksGmailModel, resolveModelRefFromString, resolveThinkingDefault, } from "../agents/model-selection.js"; @@ -291,6 +292,27 @@ export async function runCronIsolatedAgentTurn(params: { } return catalog; }; + // Resolve model - prefer hooks.gmail.model for Gmail hooks. + const isGmailHook = params.sessionKey.startsWith("hook:gmail:"); + const hooksGmailModelRef = isGmailHook + ? resolveHooksGmailModel({ + cfg: params.cfg, + defaultProvider: DEFAULT_PROVIDER, + }) + : null; + if (hooksGmailModelRef) { + const allowed = buildAllowedModelSet({ + cfg: params.cfg, + catalog: await loadCatalog(), + defaultProvider: resolvedDefault.provider, + defaultModel: resolvedDefault.model, + }); + const key = modelKey(hooksGmailModelRef.provider, hooksGmailModelRef.model); + if (allowed.allowAny || allowed.allowedKeys.has(key)) { + provider = hooksGmailModelRef.provider; + model = hooksGmailModelRef.model; + } + } const modelOverrideRaw = params.job.payload.kind === "agentTurn" ? params.job.payload.model @@ -340,13 +362,17 @@ export async function runCronIsolatedAgentTurn(params: { const isFirstTurnInSession = cronSession.isNewSession || !cronSession.systemSent; + // Resolve thinking level - job thinking > hooks.gmail.thinking > agent default + const hooksGmailThinking = isGmailHook + ? normalizeThinkLevel(params.cfg.hooks?.gmail?.thinking) + : undefined; const thinkOverride = normalizeThinkLevel(agentCfg?.thinkingDefault); const jobThink = normalizeThinkLevel( (params.job.payload.kind === "agentTurn" ? params.job.payload.thinking : undefined) ?? undefined, ); - let thinkLevel = jobThink ?? thinkOverride; + let thinkLevel = jobThink ?? hooksGmailThinking ?? thinkOverride; if (!thinkLevel) { thinkLevel = resolveThinkingDefault({ cfg: params.cfg, diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 9dd92c846..55587b79b 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -9,7 +9,12 @@ import { type ModelCatalogEntry, resetModelCatalogCacheForTest, } from "../agents/model-catalog.js"; -import { resolveConfiguredModelRef } from "../agents/model-selection.js"; +import { + buildAllowedModelSet, + modelKey, + resolveConfiguredModelRef, + resolveHooksGmailModel, +} from "../agents/model-selection.js"; import { resolveAnnounceTargetFromKey } from "../agents/tools/sessions-send-helpers.js"; import { CANVAS_HOST_PATH } from "../canvas-host/a2ui.js"; import { @@ -1763,6 +1768,41 @@ export async function startGatewayServer( } } + // Validate hooks.gmail.model if configured. + if (cfgAtStart.hooks?.gmail?.model) { + const hooksModelRef = resolveHooksGmailModel({ + cfg: cfgAtStart, + defaultProvider: DEFAULT_PROVIDER, + }); + if (hooksModelRef) { + const { provider: defaultProvider, model: defaultModel } = + resolveConfiguredModelRef({ + cfg: cfgAtStart, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + const catalog = await loadModelCatalog({ config: cfgAtStart }); + const key = modelKey(hooksModelRef.provider, hooksModelRef.model); + const allowed = buildAllowedModelSet({ + cfg: cfgAtStart, + catalog, + defaultProvider, + defaultModel, + }); + if (!allowed.allowAny && !allowed.allowedKeys.has(key)) { + logHooks.warn( + `hooks.gmail.model "${key}" not in agents.defaults.models allowlist (will use primary instead)`, + ); + } + const inCatalog = catalog.some((e) => modelKey(e.provider, e.id) === key); + if (!inCatalog) { + logHooks.warn( + `hooks.gmail.model "${key}" not in the model catalog (may fail at runtime)`, + ); + } + } + } + // Launch configured providers (WhatsApp Web, Discord, Slack, Telegram) so gateway replies via the // surface the message came from. Tests can opt out via CLAWDBOT_SKIP_PROVIDERS. if (process.env.CLAWDBOT_SKIP_PROVIDERS !== "1") {