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
85 changes: 85 additions & 0 deletions src/agents/google-transport-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,25 @@ type MutableAssistantOutput = {
timestamp: number;
responseId?: string;
errorMessage?: string;
webGrounding?: TransportWebGrounding;
};

type GoogleGroundingMetadata = {
webSearchQueries?: string[];
groundingChunks?: Array<{
web?: { title?: string; uri?: string; domain?: string };
}>;
groundingSupports?: Array<{ groundingChunkIndices?: number[] }>;
};

/**
* Normalized Google Search grounding payload attached to the assistant
* output message. Consumers (gateway plugins, the OpenAI-compat endpoint)
* read it as `(message as { webGrounding?: TransportWebGrounding })`.
*/
export type TransportWebGrounding = {
queries: string[];
sources: Array<{ title: string; url: string; cited?: number }>;
};

type GoogleSseChunk = {
Expand All @@ -99,6 +118,7 @@ type GoogleSseChunk = {
}>;
};
finishReason?: string;
groundingMetadata?: GoogleGroundingMetadata;
}>;
usageMetadata?: {
promptTokenCount?: number;
Expand Down Expand Up @@ -408,6 +428,59 @@ function convertGoogleMessages(model: GoogleTransportModel, context: Context) {
return contents;
}

/**
* Server-side Google Search grounding (opt-in via env). When enabled the
* request carries the `google_search` built-in tool; mixing it with
* `functionDeclarations` additionally requires
* `toolConfig.includeServerSideToolInvocations` (the API 400s without it).
* The resulting `groundingMetadata` is normalized onto the assistant output
* as `webGrounding`.
*/
function isGoogleSearchGroundingEnabled(): boolean {
const raw = process.env.OPENCLAW_GOOGLE_GROUNDING?.trim().toLowerCase();
return raw === "1" || raw === "true" || raw === "on";
}

function mergeWebGrounding(
output: MutableAssistantOutput,
metadata: GoogleGroundingMetadata,
): void {
const grounding = (output.webGrounding ??= { queries: [], sources: [] });
for (const query of metadata.webSearchQueries ?? []) {
if (typeof query === "string" && query && !grounding.queries.includes(query)) {
grounding.queries.push(query);
}
}
// Citation counts per chunk index (`groundingSupports`) let consumers rank
// sources by how often the answer actually cited them.
const citeCounts = new Map<number, number>();
for (const support of metadata.groundingSupports ?? []) {
for (const idx of support?.groundingChunkIndices ?? []) {
if (typeof idx === "number") {
citeCounts.set(idx, (citeCounts.get(idx) ?? 0) + 1);
}
}
}
const chunks = metadata.groundingChunks ?? [];
for (let idx = 0; idx < chunks.length; idx++) {
const chunk = chunks[idx];
const url = chunk?.web?.uri;
if (typeof url !== "string" || !url) {
continue;
}
const existing = grounding.sources.find((source) => source.url === url);
if (existing) {
existing.cited = (existing.cited ?? 0) + (citeCounts.get(idx) ?? 0);
continue;
}
grounding.sources.push({
title: chunk.web?.title || chunk.web?.domain || url,
url,
cited: citeCounts.get(idx) ?? 0,
});
}
}

function convertGoogleTools(tools: NonNullable<Context["tools"]>) {
if (tools.length === 0) {
return undefined;
Expand Down Expand Up @@ -467,6 +540,15 @@ export function buildGoogleGenerativeAiParams(
};
}
}
if (isGoogleSearchGroundingEnabled()) {
params.tools = [...(params.tools ?? []), { google_search: {} }];
if (context.tools?.length) {
params.toolConfig = {
...params.toolConfig,
includeServerSideToolInvocations: true,
};
}
}
return params;
}

Expand Down Expand Up @@ -625,6 +707,9 @@ export function createGoogleGenerativeAiTransportStreamFn(): StreamFn {
output.responseId ||= chunk.responseId;
updateUsage(output, model, chunk);
const candidate = chunk.candidates?.[0];
if (candidate?.groundingMetadata) {
mergeWebGrounding(output, candidate.groundingMetadata);
}
if (candidate?.content?.parts) {
for (const part of candidate.content.parts) {
if (typeof part.text === "string") {
Expand Down
20 changes: 20 additions & 0 deletions src/agents/pi-embedded-subscribe.handlers.messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,26 @@ export function handleMessageEnd(
ctx.state.lastStreamedAssistantCleaned = cleanedText;
}

// Server-side Google Search grounding (OPENCLAW_GOOGLE_GROUNDING): the
// transport normalizes `groundingMetadata` onto the assistant message as
// `webGrounding`. Surface it as its own event stream so channel consumers
// (e.g. the OpenAI-compat endpoint) can forward the cited sources.
const webGrounding = (
assistantMessage as { webGrounding?: { queries: string[]; sources: unknown[] } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 P3 (minor) — Use a defensive array check on webGrounding.sources before evaluating .length. This prevents any potential runtime TypeError crashes if a provider or plugin initializes webGrounding with missing or malformed properties in the future.

[pass 1]

).webGrounding;
if (webGrounding && webGrounding.sources.length > 0) {
const groundingData = webGrounding as unknown as Record<string, unknown>;
emitAgentEvent({
runId: ctx.params.runId,
stream: "grounding",
data: groundingData,
});
void ctx.params.onAgentEvent?.({
stream: "grounding",
data: groundingData,
});
}

const silentExpectedWithoutSentinel =
ctx.params.silentExpected && !isSilentReplyText(trimmedText, SILENT_REPLY_TOKEN);
const finalAssistantText = silentExpectedWithoutSentinel ? "" : text;
Expand Down
28 changes: 28 additions & 0 deletions src/gateway/openai-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,25 @@ function writeAssistantContentChunk(
});
}

/**
* Content-free chunk carrying Google Search grounding sources (queries +
* cited URLs) emitted by the `grounding` agent-event stream. Standard OpenAI
* clients ignore the unknown `dojo_grounding` delta field; the Dojo web-chat
* proxy consumes it to render a cited-sources card.
*/
function writeGroundingChunk(
res: ServerResponse,
params: { runId: string; model: string; grounding: Record<string, unknown> },
) {
writeSse(res, {
id: params.runId,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: params.model,
choices: [{ index: 0, delta: { dojo_grounding: params.grounding } }],
});
}

function asMessages(val: unknown): OpenAiChatMessage[] {
return Array.isArray(val) ? (val as OpenAiChatMessage[]) : [];
}
Expand Down Expand Up @@ -564,6 +583,15 @@ export async function handleOpenAiHttpRequest(
return;
}

if (evt.stream === "grounding") {
if (!wroteRole) {
wroteRole = true;
writeAssistantRoleChunk(res, { runId, model });
}
writeGroundingChunk(res, { runId, model, grounding: evt.data ?? {} });
return;
}

if (evt.stream === "assistant") {
const content = resolveAssistantStreamDeltaText(evt) ?? "";
if (!content) {
Expand Down
Loading