From 843893f66f7b57b50fe04f43c90803f1fb9330b8 Mon Sep 17 00:00:00 2001 From: Steven Mendez Date: Thu, 2 Jul 2026 22:00:03 -0600 Subject: [PATCH 1/3] ci: one-shot workflow to build+pack+release the fork tarball on a runner --- .github/workflows/cut-dojo-tarball.yml | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/cut-dojo-tarball.yml diff --git a/.github/workflows/cut-dojo-tarball.yml b/.github/workflows/cut-dojo-tarball.yml new file mode 100644 index 0000000000000..66cf911742e7f --- /dev/null +++ b/.github/workflows/cut-dojo-tarball.yml @@ -0,0 +1,43 @@ +name: Cut Dojo fork tarball + +# One-shot: build the fork on a CI runner (16GB) and publish the tarball as a +# GitHub Release, so the DojoOS gateway Dockerfile can pin it. Replaces the +# manual `npm pack` that OOMs on low-RAM laptops. + +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag (e.g. v2026.5.22-dojo.3)' + required: true + +jobs: + cut: + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + - name: Build fork + Control UI + run: | + node scripts/build-all.mjs + node scripts/ui.js build + - name: Pack tarball + id: pack + run: | + npm pack + TGZ="$(ls openclaw-*.tgz)" + echo "tgz=$TGZ" >> "$GITHUB_OUTPUT" + echo "SHA256:"; sha256sum "$TGZ" + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${{ inputs.tag }}" "${{ steps.pack.outputs.tgz }}" \ + --title "${{ inputs.tag }}" \ + --notes "AGT-051 — route retry steers through the system channel (nudge leak fix)." + echo "SHA256 for the plugin Dockerfile ARG:" + sha256sum "${{ steps.pack.outputs.tgz }}" From 50385696426855cecc35b8718bec7f5c755094c2 Mon Sep 17 00:00:00 2001 From: Steven Mendez Date: Fri, 24 Jul 2026 12:25:18 -0600 Subject: [PATCH 2/3] =?UTF-8?q?ci:=20fix=20tarball=20workflow=20=E2=80=94?= =?UTF-8?q?=20use=20real=20build=20scripts,=20parametrize=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-shot release workflow referenced scripts/build-all.mjs, which does not exist (run 30116666722 failed on it); the fork builds with pnpm build + pnpm ui:build. Release notes were also hardcoded to an old fix — now a dispatch input. Co-Authored-By: Claude Fable 5 --- .github/workflows/cut-dojo-tarball.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cut-dojo-tarball.yml b/.github/workflows/cut-dojo-tarball.yml index 66cf911742e7f..360a6d46a1fa2 100644 --- a/.github/workflows/cut-dojo-tarball.yml +++ b/.github/workflows/cut-dojo-tarball.yml @@ -10,6 +10,10 @@ on: tag: description: 'Release tag (e.g. v2026.5.22-dojo.3)' required: true + notes: + description: 'Release notes (one line)' + required: false + default: 'Dojo fork tarball' jobs: cut: @@ -23,8 +27,8 @@ jobs: install-bun: "false" - name: Build fork + Control UI run: | - node scripts/build-all.mjs - node scripts/ui.js build + pnpm build + pnpm ui:build - name: Pack tarball id: pack run: | @@ -38,6 +42,6 @@ jobs: run: | gh release create "${{ inputs.tag }}" "${{ steps.pack.outputs.tgz }}" \ --title "${{ inputs.tag }}" \ - --notes "AGT-051 — route retry steers through the system channel (nudge leak fix)." + --notes "${{ inputs.notes }}" echo "SHA256 for the plugin Dockerfile ARG:" sha256sum "${{ steps.pack.outputs.tgz }}" From 567dfaaaa4cc1df08506d53986579aad0d31a8ed Mon Sep 17 00:00:00 2001 From: Steven Mendez Date: Fri, 24 Jul 2026 12:53:41 -0600 Subject: [PATCH 3/3] feat(google): native Google Search grounding behind OPENCLAW_GOOGLE_GROUNDING Port of main's PR #4 onto the v2026.5.22 release train (the transport lives in extensions/google here). Env-gated google_search injection with includeServerSideToolInvocations, groundingMetadata normalized onto the assistant message as webGrounding (sources ranked by citation count), a 'grounding' agent event at message end, and a content-free delta.dojo_grounding chunk on the OpenAI-compat endpoint. Co-Authored-By: Claude Fable 5 --- extensions/google/transport-stream.ts | 85 +++++++++++++++++++ ...pi-embedded-subscribe.handlers.messages.ts | 20 +++++ src/gateway/openai-http.ts | 28 ++++++ 3 files changed, 133 insertions(+) diff --git a/extensions/google/transport-stream.ts b/extensions/google/transport-stream.ts index e3641cb3521d1..98fcc26fcea24 100644 --- a/extensions/google/transport-stream.ts +++ b/extensions/google/transport-stream.ts @@ -107,10 +107,29 @@ type MutableAssistantOutput = { timestamp: number; responseId?: string; errorMessage?: string; + webGrounding?: TransportWebGrounding; }; const GOOGLE_VERTEX_DEFAULT_API_VERSION = "v1"; +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 = { responseId?: string; candidates?: Array<{ @@ -127,6 +146,7 @@ type GoogleSseChunk = { }>; }; finishReason?: string; + groundingMetadata?: GoogleGroundingMetadata; }>; usageMetadata?: { promptTokenCount?: number; @@ -690,6 +710,59 @@ function convertGoogleTools(tools: NonNullable) { ]; } +/** + * 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(); + 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, + }); + } +} + export function buildGoogleGenerativeAiParams( model: GoogleTransportModel, context: Context, @@ -734,6 +807,15 @@ export function buildGoogleGenerativeAiParams( }; } } + if (isGoogleSearchGroundingEnabled()) { + params.tools = [...(params.tools ?? []), { google_search: {} }]; + if (context.tools?.length) { + params.toolConfig = { + ...params.toolConfig, + includeServerSideToolInvocations: true, + }; + } + } return params; } @@ -1241,6 +1323,9 @@ function createGoogleTransportStreamFn(kind: CanonicalGoogleTransportApi): Strea 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) { const hasThoughtSignature = diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index 5f1122d1081a4..5ece9425b4a72 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -753,6 +753,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[] } } + ).webGrounding; + if (webGrounding && webGrounding.sources.length > 0) { + const groundingData = webGrounding as unknown as Record; + 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; diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 53eee76070d8b..441b20528c0fb 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -363,6 +363,25 @@ function writeUsageChunk( }); } +/** + * 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 }, +) { + 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[]) : []; } @@ -1105,6 +1124,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) {