diff --git a/config.js b/config.js index 95cda32..bb9cdb0 100644 --- a/config.js +++ b/config.js @@ -88,6 +88,23 @@ export const GEMINI_API = "https://generativelanguage.googleapis.com/v1beta" // current without checking https://ai.google.dev/gemini-api/docs/models. export const GEMINI_MODEL = process.env.GEMINI_MODEL || "gemini-flash-latest"; +// Fallback model cascade for rate-limit (429) errors. Free-tier Gemini quotas +// are tracked PER MODEL, so a different model has its own separate RPM +// bucket -- on a 429 from GEMINI_MODEL, client.js retries the same request +// against the next model here instead of failing the whole call/investigation +// outright. This multiplies effective free-tier throughput without enabling +// billing. Order matters: put higher-RPM/lower-capability models later, since +// they're only used once the primary model's quota is exhausted for the +// current window. Override via env var as a comma-separated list of model +// IDs; GEMINI_MODEL is always tried first regardless of whether it's +// repeated in this list. See https://ai.google.dev/gemini-api/docs/models for +// current model IDs/limits -- these drift as Google ships new Flash/Flash-Lite +// generations. +export const GEMINI_FALLBACK_MODELS = (process.env.GEMINI_FALLBACK_MODELS || "gemini-3.5-flash-lite,gemini-3-flash") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + // Read/write isolation for the Gemini connector's Notion access (2026-07-25 // plan): Gemini tools may READ any page/database reachable via the existing // Notion connector (Memory Index, Entity Index, Job Leads, etc.), but may diff --git a/connectors/gemini/client.js b/connectors/gemini/client.js index 5d1d505..ae13958 100644 --- a/connectors/gemini/client.js +++ b/connectors/gemini/client.js @@ -4,9 +4,9 @@ // Auth header: "x-goog-api-key: " // --------------------------------------------------------------------------- -import { GEMINI_API_KEY, GEMINI_API, GEMINI_MODEL } from "../../config.js"; +import { GEMINI_API_KEY, GEMINI_API, GEMINI_MODEL, GEMINI_FALLBACK_MODELS } from "../../config.js"; -async function callGenerateContent(body, model) { +async function callGenerateContentOnce(body, model) { if (!GEMINI_API_KEY) throw new Error("GEMINI_API_KEY is not set. Add it as an environment variable on the Manufact server."); const res = await fetch(`${GEMINI_API}/models/${model}:generateContent`, { @@ -24,11 +24,45 @@ async function callGenerateContent(body, model) { if (!res.ok) { const message = (data && (data.error?.message || JSON.stringify(data))) || res.statusText; - throw new Error(`Gemini API error (${res.status}): ${message}`); + const err = new Error(`Gemini API error (${res.status}): ${message}`); + err.status = res.status; + throw err; } return data; } +// Cascades through GEMINI_MODEL + GEMINI_FALLBACK_MODELS, but ONLY on a 429 +// (rate limit exceeded) -- free-tier Gemini quotas are tracked per model, so +// a fresh model has its own separate RPM bucket, making this a legitimate +// way to keep going rather than a blind retry. Any other status (400, 500, +// etc.) is a real failure and surfaces immediately without trying other +// models, since those aren't quota problems a different model would fix. +// +// If the caller passed an explicit `model` that differs from the configured +// default (GEMINI_MODEL), that choice is honored exactly with no cascade -- +// they asked for that specific model, so silently substituting another one +// on a 429 would violate that request. +async function callGenerateContent(body, requestedModel) { + const models = requestedModel && requestedModel !== GEMINI_MODEL + ? [requestedModel] + : [GEMINI_MODEL, ...GEMINI_FALLBACK_MODELS.filter((m) => m !== GEMINI_MODEL)]; + + let lastErr; + for (let i = 0; i < models.length; i++) { + try { + const data = await callGenerateContentOnce(body, models[i]); + if (i > 0) data._fallbackModelUsed = models[i]; // surfaced for logging/debugging, not required by callers + return data; + } catch (err) { + lastErr = err; + const isLast = i === models.length - 1; + if (err.status !== 429 || isLast) throw err; + // else: rate-limited on this model -- fall through to try the next one. + } + } + throw lastErr; +} + // Single-turn text generation. Takes a plain prompt string (build any // system/user framing into it before calling) and returns the model's text // output. Used by web_fetch_and_ask -- a genuine one-shot "here's context, diff --git a/connectors/gemini/delegate.js b/connectors/gemini/delegate.js index 3665fce..8519079 100644 --- a/connectors/gemini/delegate.js +++ b/connectors/gemini/delegate.js @@ -24,10 +24,10 @@ import { geminiChat } from "./client.js"; import { githubRequest } from "../github/client.js"; import { readFileViaBlob } from "../github/helpers.js"; import { queryTelemetry } from "../cloudflare/observability.js"; -import { notionRequest, notionRichTextToString, notionPageTitle, notionDatabaseTitle } from "../notion/client.js"; +import { notionRequest, notionRichTextToString, notionPageTitle, notionDatabaseTitle, notionBlocksToText } from "../notion/client.js"; import { DEFAULT_OWNER } from "../../config.js"; -const HARD_MAX_STEPS = 10; +const HARD_MAX_STEPS = 20; // --------------------------------------------------------------------------- // Delegated function declarations -- Gemini's "tools" param (a subset of @@ -123,6 +123,29 @@ const FUNCTIONS = [ return JSON.stringify(data).slice(0, 30000); }, }, + { + name: "notion_get_page", + description: "Read a Notion page's title and text content by page ID (read-only). Use this after notion_search finds a candidate page, to actually see what's on it -- notion_search only returns titles/ids, not content.", + parameters: { + type: "object", + properties: { + page_id: { type: "string", description: "Notion page ID, e.g. from notion_search results" }, + }, + required: ["page_id"], + }, + execute: async ({ page_id }) => { + const [page, blocksData] = await Promise.all([ + notionRequest(`/pages/${page_id}`), + notionRequest(`/blocks/${page_id}/children?page_size=100`), + ]); + const title = notionPageTitle(page); + const blocks = blocksData.results || []; + const content = notionBlocksToText(blocks) || "(no content)"; + const hasMore = blocksData.has_more ? "\n[note: page has more than 100 blocks, only the first 100 are shown]" : ""; + const text = `# ${title}\n${content}${hasMore}`; + return text.length > 20000 ? text.slice(0, 20000) + "\n...[truncated]" : text; + }, + }, { name: "notion_search", description: "Search pages and databases in the Notion workspace (read-only).", @@ -226,7 +249,7 @@ export async function runInvestigation({ task, max_steps = 6 }) { transcript.push(`[step ${step}] ${name}(${JSON.stringify(args || {})}) -> ${resultText.length > 300 ? resultText.slice(0, 300) + "…" : resultText}`); responseParts.push({ functionResponse: { name, response: { result: resultText } } }); } - contents.push({ role: "function", parts: responseParts }); + contents.push({ role: "user", parts: responseParts }); } return { answer: `(Investigation stopped after reaching the step cap of ${cappedSteps} without a final answer -- the task may need to be narrowed, or more steps requested up to the hard cap of ${HARD_MAX_STEPS}.)`, steps: cappedSteps, transcript }; diff --git a/connectors/gemini/tools.js b/connectors/gemini/tools.js index 70280bd..e4d5cfa 100644 --- a/connectors/gemini/tools.js +++ b/connectors/gemini/tools.js @@ -98,7 +98,7 @@ export function register(server) { "Delegate an open-ended, multi-step READ-ONLY investigation to Gemini instead of doing it yourself one tool call at a time. Gemini runs its own loop server-side -- reading GitHub files/trees/commits, Cloudflare Workers logs, and Notion pages/databases across as many turns as it needs (bounded by max_steps) -- and returns one synthesized answer. Use this for things like \"why is CI failing on PR #42\" or \"summarize what changed in this repo over the last week\" where you'd otherwise need 5-10 separate manual tool calls. Not for anything requiring a write -- this tool is read-only by design.", { task: z.string().describe("The investigation task/question, described with enough context (repo names, time ranges, etc.) for Gemini to act without needing to ask you anything back -- it can't."), - max_steps: z.number().optional().describe("Max tool-use turns Gemini gets before being forced to answer (default 6, hard cap 10 regardless of this value)."), + max_steps: z.number().optional().describe("Max tool-use turns Gemini gets before being forced to answer (default 6, hard cap 20 regardless of this value)."), log_to_notion: z.boolean().optional().describe("Whether to log the task, step-by-step tool calls, and final answer as a page under the Gemini section of Notion (default: true). Write always targets the fixed Gemini root page."), }, async ({ task, max_steps = 6, log_to_notion = true }) => {