-
Notifications
You must be signed in to change notification settings - Fork 864
feat(web-search): live gemini executor on the Antigravity CCA transport (#2188 L8) #2243
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fb6d3fe
38a5065
0780c63
ef551e8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| /** | ||
| * Execute ONE web search via Gemini google_search grounding on the Antigravity | ||
| * Cloud Code Assist transport (#2188 L8). Live-probed 2026-08-20/21 (devlog 002): | ||
| * the CCA envelope with tools [{google_search:{}}] returns a grounded answer | ||
| * plus groundingMetadata; a non-IDE User-Agent gets 404, so the request reuses | ||
| * the adapter's fingerprint constants. The OAuth bearer only ever travels to | ||
| * the REGISTRY-pinned endpoint — a config-level baseUrl override is never | ||
| * trusted for token transmission (same rule as src/server/images.ts). | ||
| * Never throws — returns {error} so the caller injects a graceful tool result. | ||
| */ | ||
| import type { OcxProviderConfig } from "../types"; | ||
| import { getValidAccessTokenSnapshot, publicOAuthAuthenticationErrorMessage } from "../oauth"; | ||
| import { fetchWithResetRetry } from "../lib/upstream-retry"; | ||
| import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort"; | ||
| import { readBoundedResponseBytes } from "../lib/bounded-body"; | ||
| import { sidecarEnter } from "../lib/sidecar-tracker"; | ||
| import { redactSecretString } from "../lib/redact"; | ||
| import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire"; | ||
| import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models"; | ||
| import { getProviderRegistryEntry } from "../providers/registry"; | ||
| import { MAX_SIDECAR_RESPONSE_BYTES, type WebSearchSource } from "./parse"; | ||
| import { BASE_INSTRUCTION, IMAGE_INSTRUCTION, type SidecarOutcome, type SidecarSettings } from "./executor"; | ||
|
|
||
| const CCA_FALLBACK_BASE = "https://daily-cloudcode-pa.googleapis.com"; | ||
|
|
||
| function isRec(v: unknown): v is Record<string, unknown> { | ||
| return !!v && typeof v === "object" && !Array.isArray(v); | ||
| } | ||
|
|
||
| export async function runGeminiWebSearch( | ||
| query: string, | ||
| providerName: string, | ||
| _provider: OcxProviderConfig, | ||
| settings: SidecarSettings, | ||
| abortSignal?: AbortSignal, | ||
| ): Promise<SidecarOutcome> { | ||
| let token: string; | ||
| let project: string | undefined; | ||
| try { | ||
| const snapshot = await getValidAccessTokenSnapshot(providerName); | ||
| token = snapshot.accessToken; | ||
| project = snapshot.projectId; | ||
| } catch (e) { | ||
| return { text: "", sources: [], error: `gemini sidecar auth failed: ${publicOAuthAuthenticationErrorMessage(e)}` }; | ||
| } | ||
| if (!project) { | ||
| return { text: "", sources: [], error: "gemini sidecar missing Cloud Code Assist project id — re-run ocx login google-antigravity" }; | ||
| } | ||
| // Destination pinned to the registry endpoint (see module doc). | ||
| const base = getProviderRegistryEntry("google-antigravity")?.baseUrl ?? CCA_FALLBACK_BASE; | ||
| const { wireModelId, thinkingLevel } = resolveAntigravityEffortWireModel(settings.model, settings.reasoning, base); | ||
| const instruction = settings.describeImages ? BASE_INSTRUCTION + IMAGE_INSTRUCTION : BASE_INSTRUCTION; | ||
| const envelope = { | ||
| model: wireModelId, | ||
| userAgent: "antigravity", | ||
| requestType: "agent", | ||
| project, | ||
| requestId: `agent-${crypto.randomUUID()}`, | ||
| request: { | ||
| systemInstruction: { role: "user", parts: [{ text: instruction }] }, | ||
| contents: [{ role: "user", parts: [{ text: query }] }], | ||
| tools: [{ google_search: {} }], | ||
| sessionId: crypto.randomUUID(), | ||
| ...(thinkingLevel ? { generationConfig: { thinkingConfig: { thinkingLevel } } } : {}), | ||
| }, | ||
| }; | ||
| const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal); | ||
| const sidecarExit = sidecarEnter("web-search"); | ||
| const t0 = Date.now(); | ||
| try { | ||
| const res = await fetchWithResetRetry( | ||
| () => fetch(`${base}/v1internal:generateContent`, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| "Authorization": `Bearer ${token}`, | ||
| "User-Agent": ANTIGRAVITY_REQUEST_UA, | ||
| }, | ||
| body: JSON.stringify(envelope), | ||
| signal: linkedSignal.signal, | ||
| redirect: "manual", | ||
| }), | ||
| { abortSignal: linkedSignal.signal, label: "gemini-web-search-sidecar" }, | ||
| ); | ||
| const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); | ||
| try { | ||
| const bounded = await readBoundedResponseBytes(res, { | ||
| maxBytes: MAX_SIDECAR_RESPONSE_BYTES, | ||
| signal: linkedSignal.signal, | ||
| }); | ||
| if (bounded.oversized) { | ||
| const prefix = res.ok ? "gemini sidecar response" : `gemini sidecar HTTP ${res.status} response`; | ||
| return { text: "", sources: [], error: `${prefix} exceeded byte bound` }; | ||
| } | ||
| const text = new TextDecoder("utf-8", { fatal: true }).decode(bounded.bytes); | ||
| if (!res.ok) { | ||
| return { text: "", sources: [], error: `gemini sidecar HTTP ${res.status}: ${redactSecretString(text.slice(0, 200))}` }; | ||
| } | ||
| let payload: unknown = null; | ||
| try { | ||
| payload = JSON.parse(text); | ||
| } catch { | ||
| // The mapper owns the stable malformed/empty JSON outcome. | ||
| } | ||
| return mapCcaGroundedResponse(payload); | ||
| } finally { | ||
| detachBodyGuard(); | ||
| } | ||
| } catch (e) { | ||
| const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"; | ||
| console.warn(`[web-search] gemini sidecar ${kind} (${Date.now() - t0}ms)`); | ||
| return { text: "", sources: [], error: redactSecretString(e instanceof Error ? e.message : String(e)) }; | ||
| } finally { | ||
| sidecarExit(); | ||
| linkedSignal.cleanup(); | ||
| } | ||
| } | ||
|
|
||
| /** Map a CCA generateContent payload (possibly wrapped in {response}) to text + grounding sources. */ | ||
| export function mapCcaGroundedResponse(payload: unknown): SidecarOutcome { | ||
| const root = isRec(payload) && isRec(payload.response) ? payload.response : payload; | ||
| if (!isRec(root)) return { text: "", sources: [], error: "gemini sidecar returned a non-JSON or empty body" }; | ||
| const candidate = Array.isArray(root.candidates) && isRec(root.candidates[0]) ? root.candidates[0] : undefined; | ||
| if (!candidate) return { text: "", sources: [], error: "gemini sidecar returned no candidates" }; | ||
| const parts = isRec(candidate.content) && Array.isArray(candidate.content.parts) ? candidate.content.parts : []; | ||
| const text = parts.map(p => (isRec(p) && typeof p.text === "string" ? p.text : "")).join(""); | ||
| const sources: WebSearchSource[] = []; | ||
| const seen = new Set<string>(); | ||
| const gm = isRec(candidate.groundingMetadata) ? candidate.groundingMetadata : undefined; | ||
| if (gm && Array.isArray(gm.groundingChunks)) { | ||
| for (const chunk of gm.groundingChunks) { | ||
| const web = isRec(chunk) && isRec(chunk.web) ? chunk.web : undefined; | ||
| const uri = web && typeof web.uri === "string" ? web.uri : undefined; | ||
| if (!uri || seen.has(uri)) continue; | ||
| seen.add(uri); | ||
| sources.push({ url: uri, ...(typeof web?.title === "string" && web.title.length > 0 ? { title: web.title } : {}) }); | ||
| } | ||
| } | ||
| if (text.length === 0) return { text: "", sources, error: "gemini sidecar returned no text" }; | ||
| return { text, sources }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,12 +14,15 @@ export { runWithWebSearch } from "./loop"; | |
| export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME }; | ||
| export { runAnthropicWebSearch, parseAnthropicSidecarSSE } from "./anthropic-executor"; | ||
| export { runXaiWebSearch, parseXaiResponsesSSE, validateXaiSearchOptions, type XaiSearchOptions } from "./xai-executor"; | ||
| export { runGeminiWebSearch, mapCcaGroundedResponse } from "./gemini-executor"; | ||
|
|
||
| const DEFAULT_SIDECAR_MODEL = "gpt-5.6-luna"; | ||
| // Default Claude model for the anthropic-backed sidecar (used when cfg.model is unset). | ||
| const DEFAULT_ANTHROPIC_SIDECAR_MODEL = "claude-sonnet-5"; | ||
| // Default Grok model for the xai-backed sidecar (probe-verified with hosted tools, devlog 003). | ||
| const DEFAULT_XAI_SIDECAR_MODEL = "grok-4.6"; | ||
| // Default Gemini model for the gemini-backed sidecar (CCA grounding probe, devlog 002). | ||
| const DEFAULT_GEMINI_SIDECAR_MODEL = "gemini-3.7-flash"; | ||
|
Comment on lines
+24
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This introduces a user-selectable backend and default model without updating the user documentation: AGENTS.md reference: AGENTS.md:L279-L280 Useful? React with 👍 / 👎. |
||
| // "low" is the lightest effort the ChatGPT backend allows with web_search ("minimal" is rejected: | ||
| // "tools cannot be used with reasoning.effort 'minimal'") — keeps the sidecar fast/cheap. | ||
| const DEFAULT_SIDECAR_REASONING = "low"; | ||
|
|
@@ -114,6 +117,23 @@ export function findXaiSidecarProvider(config: OcxConfig): { providerName: strin | |
| return undefined; | ||
| } | ||
|
|
||
| /** | ||
| * First usable Antigravity credential holder: the "google-antigravity" provider | ||
| * (registry id = OAuth store key, same narrowing as findXaiSidecarProvider) whose | ||
| * active stored account is healthy AND carries a discovered CCA projectId — the | ||
| * executor cannot form the envelope without it. | ||
| */ | ||
| export function findGeminiSidecarProvider(config: OcxConfig): { providerName: string; provider: OcxProviderConfig } | undefined { | ||
| const provider = config.providers["google-antigravity"]; | ||
| if (!provider || provider.disabled === true || provider.authMode !== "oauth") return undefined; | ||
| const set = getAccountSet("google-antigravity"); | ||
| const active = set?.accounts.find(account => account.id === set.activeAccountId); | ||
| if (!active || active.needsReauth === true) return undefined; | ||
| const projectId = (active.credential as { projectId?: string } | undefined)?.projectId; | ||
| if (!projectId) return undefined; | ||
| return { providerName: "google-antigravity", provider }; | ||
| } | ||
|
|
||
| /** Lift the persisted xSearch config block into executor options (absent block = web_search only). */ | ||
| export function xaiSearchOptionsFromConfig(cfg: Pick<OcxWebSearchSidecarConfig, "xSearch">): XaiSearchOptions { | ||
| const x = cfg.xSearch; | ||
|
|
@@ -154,6 +174,8 @@ export interface SidecarPlan { | |
| anthropicSidecar?: AnthropicSidecarProvider; | ||
| /** Present for the xai backend (stored Grok OAuth /v1/responses path). */ | ||
| xaiSidecar?: { providerName: string; provider: OcxProviderConfig }; | ||
| /** Present for the gemini backend (Antigravity CCA grounding path). */ | ||
| geminiSidecar?: { providerName: string; provider: OcxProviderConfig }; | ||
| /** Opt-in x_search options for the xai backend (validated at the management layer and again in the executor). */ | ||
| xaiSearchOptions?: XaiSearchOptions; | ||
| hostedTool: Record<string, unknown>; | ||
|
|
@@ -206,9 +228,9 @@ export function planWebSearch( | |
| ? { providerName: auth.anthropicProviderName, provider: auth.anthropicProvider } | ||
| : undefined; | ||
| const backend = resolveSidecarBackend(cfg.backend); | ||
| // Inert arms (roadmap 060): gemini/exa stay fail-closed until their executor | ||
| // layers land. The xai arm went live in L7 below. | ||
| if (backend === "gemini" || backend === "exa") return undefined; | ||
| // Inert arm (roadmap 060): exa stays fail-closed until its executor layer lands. | ||
| // xai went live in L7; gemini in L8 below. | ||
| if (backend === "exa") return undefined; | ||
| const maxSearches = cfg.maxSearchesPerTurn ?? DEFAULT_MAX_SEARCHES; | ||
| const stallTimeoutSec = webSearchStallTimeoutSec( | ||
| config.stallTimeoutSec, | ||
|
|
@@ -260,6 +282,23 @@ export function planWebSearch( | |
| }; | ||
| } | ||
|
|
||
| // Gemini backend (L8): explicit-only, authenticated by the stored Antigravity CCA | ||
| // OAuth credential; requires the discovered projectId. Fail-closed like the others. | ||
| if (backend === "gemini") { | ||
| const geminiSidecar = findGeminiSidecarProvider(config); | ||
| if (!geminiSidecar) return undefined; | ||
| return { | ||
| backend: "gemini", | ||
| geminiSidecar, | ||
| hostedTool: parsed._webSearch, | ||
| settings: { model: cfg.model ?? DEFAULT_GEMINI_SIDECAR_MODEL, reasoning, timeoutMs, describeImages }, | ||
| maxSearches, | ||
| routedModelStallTimeoutMs, | ||
| stallTimeoutSec, | ||
| streamRoutedModelOutput, | ||
| }; | ||
| } | ||
|
|
||
| // OpenAI backend: needs a ChatGPT login (main) and a forward provider to reach server-side web_search. | ||
| if (!openAiSidecar) return undefined; | ||
| return { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When Antigravity OAuth is active, this descriptor adds its models to the Dashboard picker, but
gui/src/pages/dashboard-shared.ts:350-352maps every non-Anthropic model toopenai, and the picker saves that inferred backend indashboard-overview-sections.tsx:515-519. Selecting a Gemini row therefore persistsbackend: "openai"and sends the Gemini model ID to the ChatGPT sidecar instead of invoking this executor; return backend metadata with each option and teach the picker to preservegemini.Useful? React with 👍 / 👎.