Skip to content
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
57 changes: 54 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 @@ -105,6 +105,34 @@ const FUNCTIONS = [
return data.map((c) => `${c.sha.slice(0, 7)} — ${c.commit.message.split("\n")[0]} (${c.commit.author?.name}, ${c.commit.author?.date?.slice(0, 10)})`).join("\n");
},
},
{
name: "github_search_issues",
description: "Search issues and pull requests across GitHub using GitHub's issue-search syntax (label:, is:issue, is:open, stars:>N, org:, repo:, -repo:, -org:, no:assignee, etc., combined with spaces as AND). Useful for cross-repo discovery like good-first-issue scanning -- github_read_file/github_get_file_tree only work within a single already-known repo.",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "GitHub issue-search query string, e.g. 'label:\"good first issue\" is:open is:issue no:assignee stars:>2000 -org:someorg'" },
sort: { type: "string", description: "Sort field: created, updated, or comments (default: best-match relevance)" },
order: { type: "string", description: "Sort order: asc or desc (default: desc)" },
per_page: { type: "number", description: "Number of results to return, max 100 (default 20)" },
},
required: ["query"],
},
execute: async ({ query, sort, order = "desc", per_page = 20 }) => {
let path = `/search/issues?q=${encodeURIComponent(query)}&order=${order}&per_page=${Math.min(per_page, 100)}`;
if (sort) path += `&sort=${sort}`;
const data = await githubRequest(path);
if (!data.items?.length) return "No results found.";
const lines = data.items.map((item) => {
const kind = item.pull_request ? "PR" : "Issue";
const labels = item.labels?.length ? ` [${item.labels.map((l) => l.name).join(", ")}]` : "";
const assignee = item.assignee ? ` (assigned: ${item.assignee.login})` : " (unassigned)";
return `${kind} #${item.number} [${item.state}] ${item.title}${labels}${assignee} -- ${item.repository_url.replace("https://api.github.com/repos/", "")} | created ${item.created_at.slice(0, 10)} | ${item.html_url}`;
});
const text = `Found ${data.total_count} total result(s), showing ${data.items.length}:\n${lines.join("\n")}`;
return text.length > 20000 ? text.slice(0, 20000) + "\n...[truncated]" : text;
},
},
{
name: "cf_query_logs",
description: "Query Cloudflare Workers Observability logs/traces/events for a time range.",
Expand All @@ -123,6 +151,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 +277,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