From 22884e820a2034ec09aba890d4c7507d2384d5b0 Mon Sep 17 00:00:00 2001 From: Visharad Kashyap <154831195+vishxrad@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:14:07 +0530 Subject: [PATCH] fix(google-adk): handle OpenUI actions correctly --- examples/google-adk/README.md | 7 +- examples/google-adk/src/agent.ts | 43 +++--- examples/google-adk/src/app/api/chat/route.ts | 132 ++++++++++++++++-- 3 files changed, 151 insertions(+), 31 deletions(-) diff --git a/examples/google-adk/README.md b/examples/google-adk/README.md index f96120d4e..751041dc2 100644 --- a/examples/google-adk/README.md +++ b/examples/google-adk/README.md @@ -44,10 +44,11 @@ Open [http://localhost:3000](http://localhost:3000) and try a starter such as ## How it works - `src/agent.ts` defines the `get_weather` tool and a `createAgent()` builder - that appends OpenUI's generated system prompt to the agent's instruction. + that combines OpenUI's generated system prompt with weather-specific behavior. - `src/app/api/chat/route.ts` runs the agent with a `Runner`, keys ADK sessions - by chat `threadId` (so multi-turn history is preserved), and streams the - assistant text as OpenAI chat-completion SSE chunks. + by chat `threadId` (so multi-turn history is preserved), normalizes OpenUI + actions and submitted form values, and streams the assistant text as OpenAI + chat-completion SSE chunks. - `src/app/page.tsx` renders ``, sending `{ messages, threadId }` to `/api/chat` and parsing the stream with `openAIAdapter()`. diff --git a/examples/google-adk/src/agent.ts b/examples/google-adk/src/agent.ts index fb569eb08..e92cf86b2 100644 --- a/examples/google-adk/src/agent.ts +++ b/examples/google-adk/src/agent.ts @@ -7,11 +7,15 @@ import { z } from "zod"; */ export const getWeather = new FunctionTool({ name: "get_weather", - description: "Get the current weather for a given city.", + description: + "Get the current weather for one or more cities. Include every city the user asks about in one call.", parameters: z.object({ - city: z.string().describe('The city to get the weather for, e.g. "Tokyo".'), + cities: z + .array(z.string()) + .min(1) + .describe('Every city to get the weather for, e.g. ["Tokyo", "London"].'), }), - execute: ({ city }) => { + execute: ({ cities }) => { // Hard-coded so the demo runs without any external weather API. const table: Record = { tokyo: { condition: "Sunny", temperature_celsius: 24 }, @@ -21,19 +25,20 @@ export const getWeather = new FunctionTool({ paris: { condition: "Clear", temperature_celsius: 19 }, sydney: { condition: "Sunny", temperature_celsius: 27 }, }; - const key = city.toLowerCase(); - const data = table[key]; - if (!data) { - return { city, error: "No weather data for this city." }; - } - return { city, ...data }; + + return { + weather: cities.map((city) => { + const data = table[city.toLowerCase()]; + return data ? { city, ...data } : { city, error: "No weather data for this city." }; + }), + }; }, }); /** - * Builds the weather assistant. The generated OpenUI system prompt is appended - * to the base instruction so the model replies with OpenUI Lang that the - * frontend renders as generative UI. + * Builds the weather assistant. Domain rules follow the generated OpenUI + * system prompt so the model replies with OpenUI Lang while prioritizing the + * user's actual weather request. */ export function createAgent(genUISystemPrompt: string) { return new Agent({ @@ -41,10 +46,16 @@ export function createAgent(genUISystemPrompt: string) { model: process.env.GEMINI_MODEL || "gemini-flash-latest", description: "A helpful assistant that can report the weather.", instruction: - "You are a friendly assistant. When the user asks about the weather, " + - "use the get_weather tool before answering. Help the user with any other " + - "requests too.\n\n" + - genUISystemPrompt, + genUISystemPrompt + + "\n\n## Weather assistant behavior\n" + + "These application rules override any general UI guidance above. Answer the user's latest " + + "request directly and never show an onboarding screen. Never invent plausible weather data. " + + "For every weather request, call get_weather exactly once with every city mentioned, then " + + "present only the returned data. Use a table when comparing multiple cities. If a weather " + + "request has no city, ask which city in a brief response. Do not generate a form for weather " + + "requests or greetings; only generate a form when the user explicitly asks you to build one. " + + "For a greeting, return one short TextContent saying you can check weather. Do not add follow-up " + + "suggestions unless the user asks for them. Help with other requests too.", tools: [getWeather], }); } diff --git a/examples/google-adk/src/app/api/chat/route.ts b/examples/google-adk/src/app/api/chat/route.ts index b3e43f9af..8e195efa3 100644 --- a/examples/google-adk/src/app/api/chat/route.ts +++ b/examples/google-adk/src/app/api/chat/route.ts @@ -1,8 +1,8 @@ +import { createAgent } from "@/agent"; import { InMemorySessionService, Runner, StreamingMode } from "@google/adk"; import { readFileSync } from "fs"; import { NextRequest } from "next/server"; import { join } from "path"; -import { createAgent } from "@/agent"; // @google/adk relies on Node APIs, so pin this route to the Node.js runtime. export const runtime = "nodejs"; @@ -10,10 +10,7 @@ export const runtime = "nodejs"; const APP_NAME = "openui-adk-chat"; const USER_ID = "demo-user"; -const systemPrompt = readFileSync( - join(process.cwd(), "src/generated/system-prompt.txt"), - "utf-8", -); +const systemPrompt = readFileSync(join(process.cwd(), "src/generated/system-prompt.txt"), "utf-8"); // A single Runner + in-memory session store, shared across requests. Sessions // are keyed by the chat threadId so multi-turn history is preserved for the @@ -32,12 +29,91 @@ interface AGUIMessage { content?: string | Array<{ type?: string; text?: string }>; } +const CONTENT_MARKER = "]]>openui:content"; +const CONTEXT_MARKER = "]]>openui:context"; +const END_MARKER = "]]>openui:end"; + +interface SubmittedField { + path: string; + value: unknown; +} + +function collectSubmittedFields( + value: unknown, + path: string[] = [], + fields: SubmittedField[] = [], +): SubmittedField[] { + if (Array.isArray(value)) { + for (const item of value) { + if (item !== null && typeof item === "object") { + collectSubmittedFields(item, path, fields); + } + } + return fields; + } + + if (value === null || typeof value !== "object") return fields; + + const record = value as Record; + if (path.length > 0 && Object.prototype.hasOwnProperty.call(record, "value")) { + fields.push({ path: path.join("."), value: record.value }); + return fields; + } + + for (const [key, child] of Object.entries(record)) { + collectSubmittedFields(child, [...path, key], fields); + } + return fields; +} + +function markerBody(value: string, markerIndex: number): string { + const lineEnd = value.indexOf("\n", markerIndex); + return lineEnd === -1 ? "" : value.slice(lineEnd + 1); +} + +function normalizeOpenUIMessage(raw: string): string { + const contextIndex = raw.lastIndexOf(CONTEXT_MARKER); + const contentIndex = raw.lastIndexOf(CONTENT_MARKER); + const contentEnd = contextIndex === -1 ? raw.length : contextIndex; + + let humanText = raw.slice(0, contentEnd); + if (contentIndex !== -1 && contentIndex < contentEnd) { + humanText = markerBody(raw.slice(0, contentEnd), contentIndex); + } + humanText = humanText + .split(/\r?\n/) + .filter((line) => !line.startsWith(END_MARKER)) + .join("\n") + .trim(); + + if (contextIndex === -1) return humanText; + + try { + const context = JSON.parse(markerBody(raw, contextIndex)) as unknown; + const fields = collectSubmittedFields(context); + if (fields.length === 0) return humanText; + + const formValues = fields + .map(({ path, value }) => { + const formatted = typeof value === "string" ? value : JSON.stringify(value); + return `- ${path}: ${formatted}`; + }) + .join("\n"); + + return `${humanText}\n\nSubmitted form values:\n${formValues}`.trim(); + } catch { + // Malformed context should not hide the user-visible action label. + return humanText; + } +} + function messageText(message: AGUIMessage | undefined): string { if (!message?.content) return ""; - if (typeof message.content === "string") return message.content; - return message.content - .map((part) => (typeof part.text === "string" ? part.text : "")) - .join(""); + const raw = + typeof message.content === "string" + ? message.content + : message.content.map((part) => (typeof part.text === "string" ? part.text : "")).join(""); + return normalizeOpenUIMessage(raw); } async function ensureSession(threadId: string): Promise { @@ -67,10 +143,24 @@ function stopChunk(id: string): string { return `data: ${JSON.stringify(payload)}\n\n`; } +function modelErrorProgram(code?: string): string { + let description = "Gemini could not complete this request. Check the server logs and try again."; + if (code === "429") { + description = + "The Gemini API quota has been reached. Wait for the quota window to reset, or use a billed API key or another model."; + } else if (code === "401" || code === "403") { + description = "Gemini rejected the API key. Check GEMINI_API_KEY and its project permissions."; + } + + return [ + "root = Card([error])", + `error = TextCallout("danger", "Gemini request failed", ${JSON.stringify(description)})`, + ].join("\n"); +} + export async function POST(req: NextRequest) { try { - const { messages, threadId }: { messages: AGUIMessage[]; threadId: string } = - await req.json(); + const { messages, threadId }: { messages: AGUIMessage[]; threadId: string } = await req.json(); const lastUser = [...(messages ?? [])].reverse().find((m) => m.role === "user"); const prompt = messageText(lastUser); @@ -88,6 +178,7 @@ export async function POST(req: NextRequest) { const readable = new ReadableStream({ async start(controller) { let closed = false; + let sentText = false; const close = () => { if (closed) return; closed = true; @@ -107,10 +198,21 @@ export async function POST(req: NextRequest) { for await (const event of runner.runAsync({ userId: USER_ID, sessionId, - newMessage: { parts: [{ text: prompt }] }, + newMessage: { role: "user", parts: [{ text: prompt }] }, runConfig: { streamingMode: StreamingMode.SSE }, abortSignal: req.signal, })) { + if (event.errorCode) { + if (!sentText) { + controller.enqueue( + encoder.encode(contentChunk(responseId, modelErrorProgram(event.errorCode))), + ); + sentText = true; + } + console.error(`ADK model error ${event.errorCode}:`, event.errorMessage); + break; + } + const parts = event.content?.parts ?? []; const text = parts .map((part) => (typeof part.text === "string" ? part.text : "")) @@ -121,13 +223,19 @@ export async function POST(req: NextRequest) { if (event.partial) { sawPartial = true; controller.enqueue(encoder.encode(contentChunk(responseId, text))); + sentText = true; } else if (!sawPartial) { controller.enqueue(encoder.encode(contentChunk(responseId, text))); + sentText = true; } } } catch (error) { if (!req.signal.aborted) { console.error("ADK stream error:", error); + if (!sentText) { + controller.enqueue(encoder.encode(contentChunk(responseId, modelErrorProgram()))); + sentText = true; + } } } finally { close();