// Gemini native tool-calling gather. Non-streaming :generateContent loop using // functionDeclarations + functionCall / functionResponse parts. import { PROVIDERS } from "../askProvider"; import { abortError, EMPTY_PARAMS, FETCH_BUDGET, FETCH_PARAMS, FETCH_TOOL_DESCRIPTION, FETCH_TOOL_NAME, FINISH_TOOL_DESCRIPTION, FINISH_TOOL_NAME, type ParsedToolCall, postJson, REPORT_BUDGET, REPORT_PARAMS, REPORT_TOOL_DESCRIPTION, REPORT_TOOL_NAME, SEARCH_PARAMS, SEARCH_TOOL_DESCRIPTION, SEARCH_TOOL_NAME, type NativeGatherContext, } from "./shared"; function buildTools(includeFetch: boolean, includeReport: boolean) { const functionDeclarations: { name: string; description: string; parameters: unknown; }[] = [ { name: SEARCH_TOOL_NAME, description: SEARCH_TOOL_DESCRIPTION, parameters: SEARCH_PARAMS, }, ]; if (includeFetch) { functionDeclarations.push({ name: FETCH_TOOL_NAME, description: FETCH_TOOL_DESCRIPTION, parameters: FETCH_PARAMS, }); } if (includeReport) { functionDeclarations.push({ name: REPORT_TOOL_NAME, description: REPORT_TOOL_DESCRIPTION, parameters: REPORT_PARAMS, }); } functionDeclarations.push({ name: FINISH_TOOL_NAME, description: FINISH_TOOL_DESCRIPTION, parameters: EMPTY_PARAMS, }); return [{ functionDeclarations }]; } // Pure: extract functionCall parts from a Gemini generateContent response. export function parseGeminiFunctionCalls(json: unknown): ParsedToolCall[] { const parts = ( json as { candidates?: { content?: { parts?: unknown[] } }[]; } ).candidates?.[0]?.content?.parts ?? []; const out: ParsedToolCall[] = []; for (const part of parts) { const fc = ( part as { functionCall?: { name?: string; args?: { query?: unknown; video?: unknown; aroundSeconds?: unknown; section?: unknown; content?: unknown; }; }; } ).functionCall; if (fc?.name) { out.push({ id: "", name: fc.name, query: typeof fc.args?.query === "string" ? fc.args.query : "", video: typeof fc.args?.video === "string" ? fc.args.video : "", aroundSeconds: typeof fc.args?.aroundSeconds === "number" ? fc.args.aroundSeconds : undefined, ...(typeof fc.args?.section === "string" ? { section: fc.args.section } : {}), ...(typeof fc.args?.content === "string" ? { content: fc.args.content } : {}), }); } } return out; } export async function geminiGather(ctx: NativeGatherContext): Promise { const canFetch = !!ctx.runFetchContext; const canReport = !!ctx.runUpdateReport; const tools = buildTools(canFetch, canReport); const model = ctx.model || PROVIDERS.gemini.defaultModel; const url = `https://generativelanguage.googleapis.com/v1beta/models/` + `${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(ctx.apiKey)}`; const contents: unknown[] = [ ...ctx.history.map((m) => ({ role: m.role === "assistant" ? "model" : "user", parts: [{ text: m.content }], })), { role: "user", parts: [{ text: ctx.question }] }, ]; let searchesRun = 0; let fetchesRun = 0; let reportsRun = 0; const maxRounds = ctx.budget + 1 + (canFetch ? FETCH_BUDGET : 0) + (canReport ? REPORT_BUDGET : 0); for (let round = 0; round <= maxRounds; round++) { if (ctx.signal?.aborted) throw abortError(); const json = await postJson( url, {}, { system_instruction: { parts: [{ text: ctx.system }] }, tools, contents, }, "Gemini", ctx.signal, { onDebug: ctx.onDebug, model: ctx.model }, ); const calls = parseGeminiFunctionCalls(json); const wantsSearch = calls.some( (c) => c.name === SEARCH_TOOL_NAME && c.query.trim() !== "", ); const wantsFetch = canFetch && calls.some((c) => c.name === FETCH_TOOL_NAME && c.video.trim() !== ""); const wantsReport = canReport && calls.some( (c) => c.name === REPORT_TOOL_NAME && (c.section ?? "").trim() !== "", ); if (!wantsSearch && !wantsFetch && !wantsReport) return; // finish/answer → done // Replay the model's functionCall turn, then send functionResponse parts. const modelParts = calls.map((c) => ({ functionCall: { name: c.name, args: c.name === SEARCH_TOOL_NAME ? { query: c.query } : c.name === FETCH_TOOL_NAME ? { video: c.video, ...(c.aroundSeconds != null ? { aroundSeconds: c.aroundSeconds } : {}), } : c.name === REPORT_TOOL_NAME ? { section: c.section ?? "", content: c.content ?? "" } : {}, }, })); contents.push({ role: "model", parts: modelParts }); const responseParts: unknown[] = []; for (const call of calls) { let result: string; if (call.name === SEARCH_TOOL_NAME && call.query.trim() !== "") { if (searchesRun < ctx.budget) { searchesRun += 1; result = await ctx.runSearch(call.query); } else { result = "Search budget reached — answer with the excerpts gathered so far."; } } else if ( canFetch && call.name === FETCH_TOOL_NAME && call.video.trim() !== "" ) { if (fetchesRun < FETCH_BUDGET) { fetchesRun += 1; result = await ctx.runFetchContext!(call.video, call.aroundSeconds); } else { result = "Reached the limit on transcript reads this turn."; } } else if ( canReport && call.name === REPORT_TOOL_NAME && (call.section ?? "").trim() !== "" ) { if (reportsRun < REPORT_BUDGET) { reportsRun += 1; result = await ctx.runUpdateReport!(call.section!, call.content ?? ""); } else { result = "Reached the limit on report updates this turn."; } } else { result = "Acknowledged."; } responseParts.push({ functionResponse: { name: call.name, response: { result } }, }); } // Function responses are sent back as a user-role turn. contents.push({ role: "user", parts: responseParts }); } }