Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 37 additions & 3 deletions connectors/gemini/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
// Auth header: "x-goog-api-key: <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`, {
Expand All @@ -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,
Expand Down
29 changes: 26 additions & 3 deletions connectors/gemini/delegate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).",
Expand Down Expand Up @@ -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 };
Expand Down
2 changes: 1 addition & 1 deletion connectors/gemini/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
Loading