claude-flow
Version:
Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration
225 lines • 9.9 kB
JavaScript
/**
* GAIA Tool: grounded_query — ADR-135 / iter-33
*
* Calls Gemini 2.5 Flash with the google_search grounding tool and returns
* a synthesised factual answer plus source citations in a single API call.
*
* Architectural advantage over web_search (Google Custom Search):
* - web_search: raw snippets → agent reads + parses → 2-3 extra turns
* - grounded_query: Gemini synthesises an answer + cites sources in 1 call
*
* This is Ruflo's cross-provider stack in action:
* Gemini grounds (google_search tool) → Sonnet/Haiku reasons → answer
*
* Credential resolution (mirrors performance-capability.ts / web_search.ts):
* 1. process.env.GOOGLE_AI_API_KEY
* 2. gcloud secrets versions access latest --secret=GOOGLE_AI_API_KEY --project=ruv-dev
* 3. Throws with clear instructions if neither is available
*
* Free tier: 1500 grounded queries/day on Gemini 2.5 Flash.
* Pricing: ~$0.075/M input tokens, ~$0.30/M output tokens, grounding free <1500/day.
*
* Regression note (iter-47): this file was absent from feat/adr-135-integrate-tracks
* because the grounded-query-gemini branch was not cherry-picked during Track integration.
* The omission caused a 36-question empty-answer failure in iter-42 (13.2% vs 49.1% baseline).
*
* Refs: ADR-133, ADR-135, iter-30 research, iter-33, iter-47, #2156
* Live-tested 2026-05-27: Mercedes Sosa GAIA L1 question → HTTP 200, 4 sources.
*/
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const GEMINI_GENERATE_CONTENT_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent';
const DEFAULT_MAX_TOKENS = 1024;
const REQUEST_TIMEOUT_MS = 30_000;
// Gemini 2.5 Flash pricing (USD per 1M tokens, grounding free under 1500/day)
const PRICE_INPUT_PER_M = 0.075;
const PRICE_OUTPUT_PER_M = 0.30;
export const groundedQueryTestHooks = {};
// ---------------------------------------------------------------------------
// Credential resolution
// ---------------------------------------------------------------------------
/**
* Resolve a Google AI API key.
*
* Order: env var → gcloud secret → throw.
* Never returns null — throws with actionable guidance if no key is found.
*/
export async function resolveGoogleAIApiKey() {
// 1. Environment variable (fastest path, used in test mocks and CI)
const envKey = process.env['GOOGLE_AI_API_KEY'];
if (envKey)
return envKey;
// 2. GCP Secrets Manager (matches resolveApiKey pattern in gaia-bench.ts)
try {
const { execSync } = await import('node:child_process');
const key = execSync('gcloud secrets versions access latest --secret=GOOGLE_AI_API_KEY --project=ruv-dev 2>/dev/null', { encoding: 'utf-8', timeout: 5_000 }).trim();
if (key)
return key;
}
catch {
// gcloud not installed, project unreachable, or secret not yet created
}
throw new Error('grounded_query: No Google AI API key found.\n' +
'Set GOOGLE_AI_API_KEY env var, or ensure `gcloud` is authenticated and\n' +
'the secret GOOGLE_AI_API_KEY exists in GCP project ruv-dev.\n' +
'Get a free key at: https://aistudio.google.com/apikey');
}
// ---------------------------------------------------------------------------
// Gemini Grounding API call
// ---------------------------------------------------------------------------
/**
* Call Gemini 2.5 Flash with google_search grounding and parse the response.
*
* Exported so smoke tests can exercise the parser without live HTTP (via
* groundedQueryTestHooks.fetchResponse).
*/
export async function callGeminiGrounded(query, maxTokens, apiKey) {
const url = `${GEMINI_GENERATE_CONTENT_URL}?key=${encodeURIComponent(apiKey)}`;
const requestBody = {
contents: [{ parts: [{ text: query }] }],
tools: [{ google_search: {} }],
generationConfig: { maxOutputTokens: maxTokens },
};
let rawBody;
if (groundedQueryTestHooks.fetchResponse) {
rawBody = await groundedQueryTestHooks.fetchResponse(query, maxTokens, apiKey);
}
else {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
body: JSON.stringify(requestBody),
});
if (!resp.ok) {
const errText = (await resp.text()).slice(0, 400);
throw new Error(`grounded_query: Gemini HTTP ${resp.status}: ${errText}`);
}
rawBody = (await resp.json());
}
return parseGeminiResponse(rawBody);
}
/**
* Parse a raw Gemini generateContent response into GroundedQueryResult.
* Exported for unit testing the parser independently.
*/
export function parseGeminiResponse(body) {
const candidate = body.candidates?.[0];
// Extract text answer
const answer = (candidate?.content?.parts ?? [])
.map((p) => p.text ?? '')
.join('');
// Extract grounding metadata
const meta = candidate?.groundingMetadata;
const chunks = meta?.groundingChunks ?? [];
const searchQueries = meta?.webSearchQueries ?? [];
// Build source list from grounding chunks (deduplicate by URI)
const seenUris = new Set();
const sources = [];
for (const chunk of chunks) {
const uri = chunk.web?.uri ?? '';
const title = chunk.web?.title ?? '';
if (uri && !seenUris.has(uri)) {
seenUris.add(uri);
sources.push({ title, uri });
}
}
// Estimate cost
const inputTokens = body.usageMetadata?.promptTokenCount ?? 0;
const outputTokens = body.usageMetadata?.candidatesTokenCount ?? 0;
const costUsd = (inputTokens / 1_000_000) * PRICE_INPUT_PER_M +
(outputTokens / 1_000_000) * PRICE_OUTPUT_PER_M;
return {
answer,
sources,
search_queries_used: searchQueries,
grounded: sources.length > 0 || searchQueries.length > 0,
model: 'gemini-2.5-flash',
cost_usd: costUsd,
};
}
// ---------------------------------------------------------------------------
// Format output for Claude (mirrors web_search formatResults pattern)
// ---------------------------------------------------------------------------
function formatResult(result) {
const lines = [];
lines.push(`[grounded_query model: ${result.model}]`);
if (result.answer) {
lines.push('');
lines.push(result.answer.trim());
}
if (result.sources.length > 0) {
lines.push('');
lines.push('Sources:');
for (let i = 0; i < result.sources.length; i++) {
const s = result.sources[i];
lines.push(`[${i + 1}] ${s.title}`);
lines.push(` ${s.uri}`);
}
}
if (result.search_queries_used.length > 0) {
lines.push('');
lines.push(`Search queries: ${result.search_queries_used.join('; ')}`);
}
if (!result.grounded) {
lines.push('');
lines.push('[Note: No grounding metadata returned — answer is unverified.]');
}
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// GaiaTool implementation
// ---------------------------------------------------------------------------
export class GroundedQueryTool {
name = 'grounded_query';
definition = {
name: 'grounded_query',
description: 'Query Google Search via Gemini 2.5 Flash grounding. ' +
'Returns a synthesised factual answer with source citations in a single call — ' +
'more efficient than web_search for factoid questions because Gemini pre-synthesises ' +
'the answer rather than returning raw snippets. ' +
'Use this for: factual lookups, "what year did X happen", "how many Y", ' +
'"who is Z", or any question requiring current web information with a clean answer. ' +
'Use web_search instead when you need to read a source page in full.',
input_schema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Question or factual lookup to ground via Google Search.',
},
max_tokens: {
type: 'number',
description: `Maximum response length in tokens (default: ${DEFAULT_MAX_TOKENS}).`,
},
},
required: ['query'],
},
};
apiKey;
constructor(opts) {
this.apiKey = opts?.apiKey;
}
async execute(input) {
const query = String(input['query'] ?? '').trim();
if (!query) {
throw new Error('grounded_query: `query` input is required and must be non-empty.');
}
const maxTokens = Math.min(Math.max(64, Number(input['max_tokens'] ?? DEFAULT_MAX_TOKENS)), 8192);
const resolveKeyFn = groundedQueryTestHooks.resolveKey ?? resolveGoogleAIApiKey;
const apiKey = this.apiKey ?? (await resolveKeyFn());
process.stderr.write(`[grounded_query] model=gemini-2.5-flash query=${JSON.stringify(query)}\n`);
const result = await callGeminiGrounded(query, maxTokens, apiKey);
process.stderr.write(`[grounded_query] grounded=${result.grounded} sources=${result.sources.length} ` +
`cost_usd=${result.cost_usd.toFixed(6)}\n`);
return formatResult(result);
}
}
// ---------------------------------------------------------------------------
// Convenience factory
// ---------------------------------------------------------------------------
export function createGroundedQueryTool(opts) {
return new GroundedQueryTool(opts);
}
//# sourceMappingURL=grounded_query.js.map