fix: harden Gmail hook model defaults (#472) (thanks @koala73)

This commit is contained in:
Peter Steinberger
2026-01-09 19:59:45 +01:00
parent fd15704c77
commit 5755d85ad1
13 changed files with 782 additions and 2 deletions

View File

@@ -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.

View File

@@ -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)).

View File

@@ -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): <summary>
```
## 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
```

View File

@@ -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.

View File

@@ -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> = {}): 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");
});
});

View File

@@ -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<T>(params: {
};
} catch (err) {
if (isAbortError(err)) throw err;
const shouldFallback = shouldFallbackForError(err);
if (!shouldFallback) throw err;
lastError = err;
attempts.push({
provider: candidate.provider,

View File

@@ -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",
});
});
});

View File

@@ -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;
}

View File

@@ -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 = {

View File

@@ -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();

View File

@@ -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);

View File

@@ -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,

View File

@@ -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") {