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();