From 8bcf3ac6972a1377b69e3adeeb1fa13763042606 Mon Sep 17 00:00:00 2001 From: Visharad Kashyap <154831195+vishxrad@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:19:07 +0530 Subject: [PATCH 1/2] feat: add Agno AgentOS adapter package --- README.md | 5 + packages/agno/README.md | 101 +++++++ packages/agno/eslint.config.cjs | 58 ++++ packages/agno/package.json | 71 +++++ packages/agno/src/__tests__/adapter.test.ts | 85 ++++++ packages/agno/src/__tests__/llm.test.ts | 54 ++++ packages/agno/src/__tests__/storage.test.ts | 176 ++++++++++++ packages/agno/src/adapter.ts | 86 ++++++ packages/agno/src/index.ts | 5 + packages/agno/src/llm.ts | 51 ++++ packages/agno/src/storage.ts | 301 ++++++++++++++++++++ packages/agno/tsconfig.json | 17 ++ packages/agno/tsconfig.test.json | 9 + packages/agno/tsdown.config.ts | 14 + packages/agno/vitest.config.ts | 14 + pnpm-lock.yaml | 12 + 16 files changed, 1059 insertions(+) create mode 100644 packages/agno/README.md create mode 100644 packages/agno/eslint.config.cjs create mode 100644 packages/agno/package.json create mode 100644 packages/agno/src/__tests__/adapter.test.ts create mode 100644 packages/agno/src/__tests__/llm.test.ts create mode 100644 packages/agno/src/__tests__/storage.test.ts create mode 100644 packages/agno/src/adapter.ts create mode 100644 packages/agno/src/index.ts create mode 100644 packages/agno/src/llm.ts create mode 100644 packages/agno/src/storage.ts create mode 100644 packages/agno/tsconfig.json create mode 100644 packages/agno/tsconfig.test.json create mode 100644 packages/agno/tsdown.config.ts create mode 100644 packages/agno/vitest.config.ts diff --git a/README.md b/README.md index 0a5bcd439..6df7daac6 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ Try it yourself in the [Playground](https://www.openui.com/playground): generate | :--------------------------------------------------------------------------------------------------------- | :----------------------------------------------- | :----------------------------------------------------------------------------------------------------------- | | [`@openuidev/lang-core`](./packages/lang-core) | Framework-agnostic parsing and prompt generation | Core parser, prompt-generation, runtime-evaluation, and type layer with no React, Vue, or Svelte dependency | | [`@openuidev/langchain`](./packages/langchain) | LangChain and LangGraph agents | Agent transformer and server helpers that stream OpenUI through AG-UI | +| [`@openuidev/agno`](./packages/agno) | Agno AgentOS agents and teams | AG-UI streaming and AgentOS session adapters for OpenUI chat interfaces | | [`@openuidev/react-lang`](./packages/react-lang) | React rendering runtimes | Define component libraries, generate prompts, and render streamed OpenUI Lang in React | | [`@openuidev/react-headless`](./packages/react-headless) | Bring-your-own React chat UI | Headless chat state, streaming adapters, and message format converters | | [`@openuidev/react-ui`](./packages/react-ui) | Fastest path to a full React chat experience | Prebuilt chat layouts, standalone UI primitives, and two built-in component libraries | @@ -117,6 +118,9 @@ npm install @openuidev/lang-core # LangChain/LangGraph agent and server integration npm install @openuidev/langchain @langchain/langgraph +# Agno AgentOS integration +npm install @openuidev/agno @openuidev/react-ui + # Vue or Svelte runtime npm install @openuidev/vue-lang npm install @openuidev/svelte-lang @@ -163,6 +167,7 @@ openui/ │ ├── react-email/ # React Email component library for generated emails │ ├── lang-core/ # Framework-agnostic parser, prompt, and runtime layer │ ├── langchain/ # LangChain/LangGraph streaming integration +│ ├── agno/ # Agno AgentOS streaming and session integration │ ├── vue-lang/ # Vue runtime bindings for OpenUI Lang │ ├── svelte-lang/ # Svelte runtime bindings for OpenUI Lang │ ├── browser-bundle/ # Script-tag bundle for CDN / iframe / no-build embeds diff --git a/packages/agno/README.md b/packages/agno/README.md new file mode 100644 index 000000000..fb68afd65 --- /dev/null +++ b/packages/agno/README.md @@ -0,0 +1,101 @@ +# `@openuidev/agno` + +Connect an Agno AgentOS to OpenUI without copying transport or persistence glue. + +The integration is intentionally complementary: + +- **AgentOS handles everything behind the UI:** agents, teams, models, tools, + memory, knowledge, sessions, authorization, execution, and deployment. +- **OpenUI handles the UI:** component instructions, streamed OpenUI Lang, + rendering, interactions, forms, charts, theming, and the chat surface. +- **AG-UI is the boundary** between the two systems. + +## Install + +```bash +pnpm add @openuidev/agno @openuidev/react-ui +``` + +Import OpenUI styles once: + +```css +@import "@openuidev/react-ui/layered/styles/index.css"; +``` + +## Connect AgentInterface to AgentOS + +```tsx +import { createAgnoLLM, agnoStorage } from "@openuidev/agno"; +import { AgentInterface } from "@openuidev/react-ui"; + +const llm = createAgnoLLM({ + url: "http://localhost:7777/agui", + forwardedProps: { user_id: "demo-user" }, +}); + +const storage = agnoStorage({ + baseUrl: "http://localhost:7777", + entityType: "agent", + entityId: "openui-assistant", + userId: "demo-user", +}); + +export function Chat() { + return ; +} +``` + +For an authenticated AgentOS, pass a scoped bearer token through `token` and +omit `userId`/`forwardedProps.user_id`; AgentOS derives identity from the token. + +## What the package owns + +- `createAgnoLLM()` adds AgentOS's AG-UI extension containers and configures + the Agno-aware stream adapter. +- `agnoAGUIAdapter()` removes non-chat lifecycle/state events and Agno's empty + tool-parent text envelope while retaining streamed text, tools, and errors. +- `agnoStorage()` maps OpenUI threads to AgentOS `/sessions` APIs and reloads + messages from AgentOS `chat_history`. +- The mapping is deliberately 1:1: the OpenUI `thread.id` is the AgentOS + `session_id`, so chat, persistence, inspection, and operational tooling all + address the same conversation without a translation table. +- `agnoHistoryToMessages()` exposes the tolerant history conversion separately + for custom storage implementations. + +The package does not run an agent, proxy model keys, or create a second source +of conversation truth. + +## AgentOS backend + +The backend remains normal Agno Python code. Generate the OpenUI component +prompt from the frontend library, then include it in the agent instructions: + +```python +from agno.agent import Agent +from agno.db.sqlite import SqliteDb +from agno.models.openai import OpenAIResponses +from agno.os import AgentOS +from agno.os.interfaces.agui import AGUI + +agent = Agent( + id="openui-assistant", + model=OpenAIResponses(id="gpt-5.5"), + db=SqliteDb(id="openui", db_file="tmp/openui.db"), + instructions=[openui_system_prompt], + add_history_to_context=True, +) + +agent_os = AgentOS(agents=[agent], interfaces=[AGUI(agent=agent)]) +app = agent_os.get_app() +``` + +The local `examples/agno-chat` workspace contains a runnable client, a real +AgentOS server, and a deterministic no-key development harness. + +## Current scope + +This first local implementation supports streamed OpenUI responses, backend +tool timelines, authentication headers, agents/teams, and AgentOS-backed +conversation persistence. Native rendering and resumption of Agno HITL/client +tools is the next integration layer rather than something this package claims +to support already. diff --git a/packages/agno/eslint.config.cjs b/packages/agno/eslint.config.cjs new file mode 100644 index 000000000..87cd87c88 --- /dev/null +++ b/packages/agno/eslint.config.cjs @@ -0,0 +1,58 @@ +const tseslint = require("@typescript-eslint/eslint-plugin"); +const typescript = require("@typescript-eslint/parser"); +const prettier = require("eslint-config-prettier"); +const unusedImports = require("eslint-plugin-unused-imports"); +const eslintPluginPrettier = require("eslint-plugin-prettier"); + +module.exports = [ + { + files: ["**/__tests__/**/*.{ts,tsx}", "**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"], + languageOptions: { + parser: typescript, + parserOptions: { + project: "./tsconfig.test.json", + sourceType: "module", + }, + }, + }, + { + files: ["**/*.{ts,tsx}"], + ignores: [ + "**/__tests__/**/*.{ts,tsx}", + "**/*.test.{ts,tsx}", + "**/*.spec.{ts,tsx}", + "*.config.ts", + ], + languageOptions: { + parser: typescript, + parserOptions: { + project: "./tsconfig.json", + sourceType: "module", + }, + }, + plugins: { + "@typescript-eslint": tseslint, + "unused-imports": unusedImports, + prettier: eslintPluginPrettier, + }, + rules: { + "@typescript-eslint/interface-name-prefix": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-explicit-any": "off", + "no-undefined": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + vars: "all", + varsIgnorePattern: "^_", + args: "after-used", + argsIgnorePattern: "^_", + }, + ], + "unused-imports/no-unused-imports": "error", + ...eslintPluginPrettier.configs.recommended.rules, + }, + }, + prettier, +]; diff --git a/packages/agno/package.json b/packages/agno/package.json new file mode 100644 index 000000000..e85c94eca --- /dev/null +++ b/packages/agno/package.json @@ -0,0 +1,71 @@ +{ + "name": "@openuidev/agno", + "version": "0.0.1", + "description": "OpenUI client and AgentOS session adapters for Agno", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "types": "dist/index.d.cts", + "sideEffects": false, + "files": [ + "dist", + "README.md" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "scripts": { + "test": "vitest run", + "build": "tsdown", + "watch": "tsdown --watch", + "typecheck": "tsc --noEmit", + "lint:check": "eslint ./src", + "lint:fix": "eslint ./src --fix", + "format:fix": "prettier --write ./src README.md", + "format:check": "prettier --check ./src README.md", + "check:publint": "publint", + "check:attw": "attw --pack .", + "prepare": "pnpm run build", + "prepublishOnly": "pnpm run check:publint && pnpm run check:attw", + "ci": "pnpm run typecheck && pnpm run test && pnpm run lint:check && pnpm run format:check" + }, + "keywords": [ + "openui", + "agno", + "agentos", + "ag-ui", + "generative-ui", + "streaming" + ], + "homepage": "https://openui.com", + "repository": { + "type": "git", + "url": "https://github.com/thesysdev/openui.git", + "directory": "packages/agno" + }, + "bugs": { + "url": "https://github.com/thesysdev/openui/issues" + }, + "author": "engineering@thesys.dev", + "peerDependencies": { + "@openuidev/react-headless": "workspace:^" + }, + "devDependencies": { + "@openuidev/react-headless": "workspace:^", + "typescript": "catalog:", + "vitest": "^4.1.0" + } +} diff --git a/packages/agno/src/__tests__/adapter.test.ts b/packages/agno/src/__tests__/adapter.test.ts new file mode 100644 index 000000000..7142ee49e --- /dev/null +++ b/packages/agno/src/__tests__/adapter.test.ts @@ -0,0 +1,85 @@ +import { EventType } from "@openuidev/react-headless"; +import { describe, expect, it } from "vitest"; +import { agnoAGUIAdapter } from "../adapter"; + +function aguiResponse(events: object[]) { + return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { + headers: { "Content-Type": "text/event-stream" }, + }); +} + +async function parse(events: object[]) { + const parsed = []; + for await (const event of agnoAGUIAdapter().parse(aguiResponse(events))) parsed.push(event); + return parsed; +} + +describe("agnoAGUIAdapter", () => { + it("removes AgentOS lifecycle, state, raw events, and an empty tool parent", async () => { + const parsed = await parse([ + { type: EventType.RUN_STARTED, threadId: "thread-1", runId: "run-1" }, + { type: EventType.STATE_SNAPSHOT, snapshot: {} }, + { type: EventType.TEXT_MESSAGE_START, messageId: "empty", role: "assistant" }, + { type: EventType.TEXT_MESSAGE_END, messageId: "empty" }, + { + type: EventType.TOOL_CALL_START, + toolCallId: "call-1", + toolCallName: "get_quarterly_revenue", + parentMessageId: "empty", + }, + { type: EventType.TOOL_CALL_ARGS, toolCallId: "call-1", delta: "{}" }, + { type: EventType.TOOL_CALL_END, toolCallId: "call-1" }, + { + type: EventType.TOOL_CALL_RESULT, + toolCallId: "call-1", + messageId: "call-1", + role: "tool", + content: '{"quarters":[]}', + }, + { type: EventType.TEXT_MESSAGE_START, messageId: "answer", role: "assistant" }, + { type: EventType.TEXT_MESSAGE_CONTENT, messageId: "answer", delta: "root = Card([])" }, + { type: EventType.TEXT_MESSAGE_END, messageId: "answer" }, + { type: EventType.RUN_FINISHED, threadId: "thread-1", runId: "run-1" }, + ]); + + expect(parsed.map((event) => event.type)).toEqual([ + EventType.TOOL_CALL_START, + EventType.TOOL_CALL_ARGS, + EventType.TOOL_CALL_END, + EventType.TOOL_CALL_RESULT, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + ]); + }); + + it("keeps non-text events buffered inside an empty envelope", async () => { + const parsed = await parse([ + { type: EventType.TEXT_MESSAGE_START, messageId: "empty", role: "assistant" }, + { + type: EventType.TOOL_CALL_START, + toolCallId: "call-1", + toolCallName: "lookup", + }, + { type: EventType.TEXT_MESSAGE_END, messageId: "empty" }, + ]); + + expect(parsed).toEqual([ + expect.objectContaining({ type: EventType.TOOL_CALL_START, toolCallId: "call-1" }), + ]); + }); + + it("keeps a text envelope once meaningful content arrives", async () => { + const parsed = await parse([ + { type: EventType.TEXT_MESSAGE_START, messageId: "answer", role: "assistant" }, + { type: EventType.TEXT_MESSAGE_CONTENT, messageId: "answer", delta: "hello" }, + { type: EventType.TEXT_MESSAGE_END, messageId: "answer" }, + ]); + + expect(parsed.map((event) => event.type)).toEqual([ + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + ]); + }); +}); diff --git a/packages/agno/src/__tests__/llm.test.ts b/packages/agno/src/__tests__/llm.test.ts new file mode 100644 index 000000000..bb10a0a9f --- /dev/null +++ b/packages/agno/src/__tests__/llm.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import { createAgnoLLM } from "../llm"; + +describe("createAgnoLLM", () => { + it("sends AgentOS extension containers and bearer authentication", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + const llm = createAgnoLLM({ + url: "/agui", + token: "test-token", + forwardedProps: { user_id: "user-1" }, + fetch: fetchMock, + }); + + await llm.send({ + threadId: "thread-1", + messages: [{ id: "message-1", role: "user", content: "Hello" }], + signal: new AbortController().signal, + }); + + const [url, request] = fetchMock.mock.calls[0]!; + expect(url).toBe("/agui"); + expect(request?.headers).toMatchObject({ + Authorization: "Bearer test-token", + "Content-Type": "application/json", + }); + expect(JSON.parse(request?.body as string)).toMatchObject({ + threadId: "thread-1", + state: {}, + forwardedProps: { user_id: "user-1" }, + tools: [], + context: [], + }); + }); + + it("lets an explicit Authorization header override the token", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + const llm = createAgnoLLM({ + url: "/agui", + token: "ignored", + headers: { Authorization: "Bearer custom" }, + fetch: fetchMock, + }); + + await llm.send({ + threadId: "thread-1", + messages: [{ id: "message-1", role: "user", content: "Hello" }], + signal: new AbortController().signal, + }); + + expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({ + Authorization: "Bearer custom", + }); + }); +}); diff --git a/packages/agno/src/__tests__/storage.test.ts b/packages/agno/src/__tests__/storage.test.ts new file mode 100644 index 000000000..652074437 --- /dev/null +++ b/packages/agno/src/__tests__/storage.test.ts @@ -0,0 +1,176 @@ +import type { UserMessage } from "@openuidev/react-headless"; +import { describe, expect, it, vi } from "vitest"; +import { agnoHistoryToMessages, agnoStorage } from "../storage"; + +function jsonResponse(value: unknown, status = 200) { + return Response.json(value, { status }); +} + +describe("agnoHistoryToMessages", () => { + it("maps AgentOS history and tool calls to canonical AG-UI messages", () => { + expect( + agnoHistoryToMessages( + [ + { role: "system", content: "system" }, + { role: "user", content: "hello" }, + { + role: "assistant", + content: "", + tool_calls: [{ id: "call-1", name: "lookup", args: { city: "Delhi" } }], + }, + { role: "tool", tool_call_id: "call-1", content: { temperature: 31 } }, + { role: "assistant", content: "root = Card([])" }, + ], + "thread-1", + ), + ).toEqual([ + { id: "thread-1-message-0", role: "system", content: "system" }, + { id: "thread-1-message-1", role: "user", content: "hello" }, + { + id: "thread-1-message-2", + role: "assistant", + content: "", + toolCalls: [ + { + id: "call-1", + type: "function", + function: { name: "lookup", arguments: '{"city":"Delhi"}' }, + }, + ], + }, + { + id: "thread-1-message-3", + role: "tool", + toolCallId: "call-1", + content: '{"temperature":31}', + }, + { + id: "thread-1-message-4", + role: "assistant", + content: "root = Card([])", + }, + ]); + }); +}); + +describe("agnoStorage", () => { + it("maps AgentOS session CRUD and pagination to OpenUI threads", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const fetchMock = vi.fn(async (input, init) => { + const url = String(input); + requests.push({ url, init }); + + if (url.includes("page=2")) { + return jsonResponse({ + data: [ + { + session_id: "session-2", + session_name: "Existing session", + created_at: "2026-08-24T00:00:00Z", + }, + ], + meta: { page: 2, total_pages: 3 }, + }); + } + if (init?.method === "POST" && url.endsWith("/sessions?type=agent&user_id=user-1")) { + return jsonResponse( + { + session_id: "session-new", + session_name: "Build a revenue dashboard", + created_at: "2026-08-24T00:00:00Z", + }, + 201, + ); + } + if (url.includes("/sessions/session-2/rename")) { + return jsonResponse({ + session_id: "session-2", + session_name: "Renamed", + created_at: "2026-08-24T00:00:00Z", + }); + } + if (url.includes("/sessions/session-2") && init?.method !== "DELETE") { + return jsonResponse({ + session_id: "session-2", + session_name: "Existing session", + chat_history: [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "root = Card([])" }, + ], + }); + } + return new Response(null, { status: 204 }); + }); + + const storage = agnoStorage({ + baseUrl: "https://agent.example", + entityType: "agent", + entityId: "openui-agent", + userId: "user-1", + pageSize: 10, + token: "test-token", + fetch: fetchMock, + }); + + await expect(storage.thread.listThreads("2")).resolves.toEqual({ + threads: [ + { + id: "session-2", + title: "Existing session", + createdAt: "2026-08-24T00:00:00Z", + }, + ], + nextCursor: "3", + }); + + await expect( + storage.thread.createThread({ + id: "message-1", + role: "user", + content: " Build a revenue dashboard ", + } as UserMessage), + ).resolves.toMatchObject({ id: "session-new", title: "Build a revenue dashboard" }); + + await expect(storage.thread.getMessages("session-2")).resolves.toEqual([ + { id: "session-2-message-0", role: "user", content: "Hello" }, + { id: "session-2-message-1", role: "assistant", content: "root = Card([])" }, + ]); + + await expect( + storage.thread.updateThread({ + id: "session-2", + title: "Renamed", + createdAt: "2026-08-24T00:00:00Z", + }), + ).resolves.toMatchObject({ id: "session-2", title: "Renamed" }); + + await storage.thread.deleteThread("session-2"); + + expect(requests[0]?.url).toContain( + "/sessions?type=agent&user_id=user-1&component_id=openui-agent&limit=10&page=2", + ); + expect(requests[1]?.init?.body).toBe( + JSON.stringify({ + session_name: "Build a revenue dashboard", + user_id: "user-1", + agent_id: "openui-agent", + }), + ); + expect(requests[3]?.init?.body).toBe(JSON.stringify({ session_name: "Renamed" })); + expect(requests[4]?.init?.method).toBe("DELETE"); + expect(requests.every(({ init }) => init?.headers)).toBe(true); + }); + + it("surfaces AgentOS error details", async () => { + const storage = agnoStorage({ + baseUrl: "https://agent.example", + entityType: "team", + entityId: "research-team", + fetch: vi + .fn() + .mockResolvedValue(jsonResponse({ detail: "Session database is unavailable" }, 503)), + }); + + await expect(storage.thread.listThreads()).rejects.toThrow("Session database is unavailable"); + }); +}); diff --git a/packages/agno/src/adapter.ts b/packages/agno/src/adapter.ts new file mode 100644 index 000000000..9fa0d4d9e --- /dev/null +++ b/packages/agno/src/adapter.ts @@ -0,0 +1,86 @@ +import { + agUIAdapter, + EventType, + type AGUIEvent, + type StreamProtocolAdapter, +} from "@openuidev/react-headless"; + +const OPENUI_CHAT_EVENT_TYPES = new Set([ + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CHUNK, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.TOOL_CALL_START, + EventType.TOOL_CALL_CHUNK, + EventType.TOOL_CALL_ARGS, + EventType.TOOL_CALL_END, + EventType.TOOL_CALL_RESULT, + EventType.RUN_ERROR, +]); + +/** + * Normalize Agno's AG-UI stream for OpenUI's current chat message processor. + * + * AgentOS emits lifecycle, state, and raw events that are valid AG-UI but are + * not chat messages. It also emits an empty assistant message as the required + * parent of a backend tool call. Forwarding those events to OpenUI currently + * materializes empty messages and can race a fast tool result. This adapter + * keeps the supported text/tool/error events and removes only empty text + * envelopes; AgentOS remains the owner of the run and tool lifecycle. + */ +export function agnoAGUIAdapter(): StreamProtocolAdapter { + const adapter = agUIAdapter(); + + return { + async *parse(response) { + let pendingTextStart: AGUIEvent | undefined; + let pendingEvents: AGUIEvent[] = []; + + const flushBufferedEvents = function* () { + yield* pendingEvents; + pendingEvents = []; + }; + + for await (const event of adapter.parse(response)) { + if (!OPENUI_CHAT_EVENT_TYPES.has(event.type)) continue; + + if (event.type === EventType.TEXT_MESSAGE_START) { + if (pendingTextStart) { + yield* flushBufferedEvents(); + } + pendingTextStart = event; + pendingEvents = []; + continue; + } + + if (!pendingTextStart) { + yield event; + continue; + } + + if ( + event.type === EventType.TEXT_MESSAGE_CHUNK || + event.type === EventType.TEXT_MESSAGE_CONTENT + ) { + yield pendingTextStart; + pendingTextStart = undefined; + yield* flushBufferedEvents(); + yield event; + continue; + } + + if (event.type === EventType.TEXT_MESSAGE_END) { + pendingTextStart = undefined; + yield* flushBufferedEvents(); + continue; + } + + pendingEvents.push(event); + } + + // Never discard tool/error events merely because AgentOS ended the + // transport before closing an empty parent text envelope. + yield* flushBufferedEvents(); + }, + }; +} diff --git a/packages/agno/src/index.ts b/packages/agno/src/index.ts new file mode 100644 index 000000000..99497cb9b --- /dev/null +++ b/packages/agno/src/index.ts @@ -0,0 +1,5 @@ +export { agnoAGUIAdapter } from "./adapter"; +export { createAgnoLLM } from "./llm"; +export type { CreateAgnoLLMOptions } from "./llm"; +export { agnoHistoryToMessages, agnoStorage, createAgnoStorage } from "./storage"; +export type { AgnoEntityType, AgnoStorageOptions } from "./storage"; diff --git a/packages/agno/src/llm.ts b/packages/agno/src/llm.ts new file mode 100644 index 000000000..2a9dc3304 --- /dev/null +++ b/packages/agno/src/llm.ts @@ -0,0 +1,51 @@ +import { + fetchLLM, + type ChatLLM, + type FetchLLMOptions, + type StreamProtocolAdapter, +} from "@openuidev/react-headless"; +import { agnoAGUIAdapter } from "./adapter"; + +export interface CreateAgnoLLMOptions extends Omit { + /** Bearer token for an authenticated AgentOS. Explicit Authorization headers win. */ + token?: string; + /** Initial AG-UI state sent to AgentOS. Defaults to an empty object. */ + state?: Record; + /** AgentOS forwarding fields such as user_id. Defaults to an empty object. */ + forwardedProps?: Record; + /** Additional RunAgentInput fields merged before the required Agno fields. */ + body?: Record; + /** Override the Agno-aware stream adapter. */ + streamAdapter?: StreamProtocolAdapter; +} + +/** + * Create an OpenUI ChatLLM backed by an Agno AgentOS AG-UI endpoint. + * + * The helper supplies the extension containers required by AgentOS and uses + * the Agno-aware AG-UI adapter by default. AgentOS still owns model execution, + * tools, sessions, and authorization; OpenUI owns the browser UI. + */ +export function createAgnoLLM({ + token, + state = {}, + forwardedProps = {}, + body, + headers, + streamAdapter = agnoAGUIAdapter(), + ...options +}: CreateAgnoLLMOptions): ChatLLM { + return fetchLLM({ + ...options, + streamAdapter, + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...headers, + }, + body: { + ...body, + state, + forwardedProps, + }, + }); +} diff --git a/packages/agno/src/storage.ts b/packages/agno/src/storage.ts new file mode 100644 index 000000000..968e01739 --- /dev/null +++ b/packages/agno/src/storage.ts @@ -0,0 +1,301 @@ +import type { + ChatStorage, + Message, + Thread, + ToolCall, + UserMessage, +} from "@openuidev/react-headless"; + +export type AgnoEntityType = "agent" | "team"; + +export interface AgnoStorageOptions { + /** AgentOS origin, optionally including a path prefix. */ + baseUrl: string; + /** Whether sessions belong to an Agno agent or team. */ + entityType: AgnoEntityType; + /** Agent or team id used to filter and create sessions. */ + entityId: string; + /** Anonymous AgentOS user id. Authenticated AgentOS instances derive it from the token. */ + userId?: string; + /** Optional AgentOS database id. */ + dbId?: string; + /** Optional AgentOS session table name. */ + table?: string; + /** Session page size. Defaults to 20. */ + pageSize?: number; + /** Bearer token for AgentOS. Explicit Authorization headers win. */ + token?: string; + /** Extra headers merged into every AgentOS session request. */ + headers?: Record; + /** Override fetch for tests, proxies, or custom authentication. */ + fetch?: typeof fetch; +} + +interface AgnoSessionSummary { + session_id: string; + session_name?: string; + created_at?: string | number; + updated_at?: string | number; +} + +interface AgnoSessionDetail extends AgnoSessionSummary { + chat_history?: unknown[]; +} + +interface AgnoSessionPage { + data?: AgnoSessionSummary[]; + meta?: { + page?: number; + total_pages?: number; + }; +} + +type UnknownRecord = Record; + +function isRecord(value: unknown): value is UnknownRecord { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function joinUrl(baseUrl: string, path: string): string { + return `${baseUrl.replace(/\/+$/, "")}${path}`; +} + +function withQuery(url: string, values: Record): string { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(values)) { + if (value !== undefined && value !== "") query.set(key, String(value)); + } + const encoded = query.toString(); + return encoded ? `${url}?${encoded}` : url; +} + +function sessionToThread(session: AgnoSessionSummary): Thread { + return { + id: session.session_id, + title: session.session_name?.trim() || "New conversation", + createdAt: session.created_at ?? session.updated_at ?? Date.now(), + }; +} + +function contentToString(value: unknown): string { + if (typeof value === "string") return value; + if (value === undefined || value === null) return ""; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function messageTitle(message: UserMessage): string { + const content = message.content; + const text = + typeof content === "string" + ? content + : (content.find((part) => part.type === "text")?.text ?? "New conversation"); + const title = text.replace(/\s+/g, " ").trim(); + return title ? title.slice(0, 80) : "New conversation"; +} + +function normalizeToolCalls(value: unknown): ToolCall[] | undefined { + if (!Array.isArray(value)) return undefined; + const calls = value.flatMap((item, index): ToolCall[] => { + if (!isRecord(item)) return []; + const fn = isRecord(item["function"]) ? item["function"] : undefined; + const name = + typeof fn?.["name"] === "string" + ? fn["name"] + : typeof item["name"] === "string" + ? item["name"] + : "tool"; + const rawArguments = fn?.["arguments"] ?? item["arguments"] ?? item["args"] ?? {}; + const args = typeof rawArguments === "string" ? rawArguments : contentToString(rawArguments); + return [ + { + id: typeof item["id"] === "string" ? item["id"] : `tool-call-${index}`, + type: "function", + function: { name, arguments: args }, + }, + ]; + }); + return calls.length > 0 ? calls : undefined; +} + +/** Convert AgentOS chat_history records into OpenUI's canonical AG-UI messages. */ +export function agnoHistoryToMessages(history: unknown, sessionId = "session"): Message[] { + if (!Array.isArray(history)) return []; + + return history.flatMap((entry, index): Message[] => { + if (!isRecord(entry) || typeof entry["role"] !== "string") return []; + const id = + typeof entry["id"] === "string" + ? entry["id"] + : typeof entry["message_id"] === "string" + ? entry["message_id"] + : `${sessionId}-message-${index}`; + const content = contentToString(entry["content"]); + + switch (entry["role"]) { + case "human": + case "user": + return [{ id, role: "user", content }]; + case "ai": + case "assistant": { + const toolCalls = normalizeToolCalls(entry["tool_calls"]); + return [ + { + id, + role: "assistant", + content, + ...(toolCalls ? { toolCalls } : {}), + }, + ]; + } + case "tool": + return [ + { + id, + role: "tool", + toolCallId: + typeof entry["tool_call_id"] === "string" + ? entry["tool_call_id"] + : `tool-call-${index}`, + content, + }, + ]; + case "system": + return [{ id, role: "system", content }]; + case "developer": + return [{ id, role: "developer", content }]; + default: + return []; + } + }); +} + +/** + * Store OpenUI conversations in AgentOS rather than in a second chat database. + * + * The adapter maps OpenUI threads to AgentOS sessions and reloads canonical + * messages from each session's chat_history. AgentOS remains authoritative for + * ownership, persistence, session history, and authorization. + */ +export function agnoStorage({ + baseUrl, + entityType, + entityId, + userId, + dbId, + table, + pageSize = 20, + token, + headers, + fetch: customFetch, +}: AgnoStorageOptions): ChatStorage { + const fetchImpl = customFetch ?? globalThis.fetch.bind(globalThis); + const entityKey = entityType === "agent" ? "agent_id" : "team_id"; + + const queryValues = (extra: Record = {}) => ({ + type: entityType, + user_id: userId, + db_id: dbId, + table, + ...extra, + }); + + const request = async (url: string, init?: RequestInit): Promise => { + const response = await fetchImpl(url, { + ...init, + headers: { + ...(init?.body ? { "Content-Type": "application/json" } : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...headers, + ...init?.headers, + }, + }); + if (!response.ok) { + const detail = await response + .clone() + .json() + .then((body: unknown) => (isRecord(body) ? body["detail"] : undefined)) + .catch(() => undefined); + throw new Error( + `agnoStorage: ${init?.method ?? "GET"} ${url} failed: ${response.status}${ + typeof detail === "string" ? ` ${detail}` : "" + }`, + ); + } + return response; + }; + + return { + thread: { + async listThreads(cursor) { + const page = cursor && Number.isFinite(Number(cursor)) ? Math.max(1, Number(cursor)) : 1; + const url = withQuery( + joinUrl(baseUrl, "/sessions"), + queryValues({ + component_id: entityId, + limit: pageSize, + page, + sort_by: "updated_at", + sort_order: "desc", + }), + ); + const response = await request(url); + const payload = (await response.json()) as AgnoSessionPage; + const currentPage = payload.meta?.page ?? page; + const totalPages = payload.meta?.total_pages ?? currentPage; + return { + threads: (payload.data ?? []).map(sessionToThread), + ...(currentPage < totalPages ? { nextCursor: String(currentPage + 1) } : {}), + }; + }, + + async createThread(firstMessage) { + const url = withQuery(joinUrl(baseUrl, "/sessions"), queryValues()); + const response = await request(url, { + method: "POST", + body: JSON.stringify({ + session_name: messageTitle(firstMessage), + ...(userId ? { user_id: userId } : {}), + [entityKey]: entityId, + }), + }); + return sessionToThread((await response.json()) as AgnoSessionDetail); + }, + + async getMessages(threadId) { + const url = withQuery( + joinUrl(baseUrl, `/sessions/${encodeURIComponent(threadId)}`), + queryValues(), + ); + const response = await request(url); + const session = (await response.json()) as AgnoSessionDetail; + return agnoHistoryToMessages(session.chat_history, threadId); + }, + + async updateThread(thread) { + const url = withQuery( + joinUrl(baseUrl, `/sessions/${encodeURIComponent(thread.id)}/rename`), + queryValues(), + ); + const response = await request(url, { + method: "POST", + body: JSON.stringify({ session_name: thread.title }), + }); + return sessionToThread((await response.json()) as AgnoSessionDetail); + }, + + async deleteThread(threadId) { + const url = withQuery( + joinUrl(baseUrl, `/sessions/${encodeURIComponent(threadId)}`), + queryValues(), + ); + await request(url, { method: "DELETE" }); + }, + }, + }; +} + +export const createAgnoStorage = agnoStorage; diff --git a/packages/agno/tsconfig.json b/packages/agno/tsconfig.json new file mode 100644 index 000000000..8645f69b9 --- /dev/null +++ b/packages/agno/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.json", + "include": ["src/**/*"], + "exclude": ["src/**/__tests__/**", "src/**/*.test.ts"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@openuidev/react-headless": ["../react-headless/dist/index.d.mts"] + }, + "moduleResolution": "bundler", + "module": "ESNext", + "outDir": "./dist", + "rootDir": "./src", + "noEmit": true + } +} diff --git a/packages/agno/tsconfig.test.json b/packages/agno/tsconfig.test.json new file mode 100644 index 000000000..60b3001d5 --- /dev/null +++ b/packages/agno/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/agno/tsdown.config.ts b/packages/agno/tsdown.config.ts new file mode 100644 index 000000000..bd5f1a3e1 --- /dev/null +++ b/packages/agno/tsdown.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + sourcemap: true, + target: "es2022", + outDir: "dist", + clean: true, + deps: { + neverBundle: [/^(?![./]|[A-Za-z]:[/\\])/], + }, +}); diff --git a/packages/agno/vitest.config.ts b/packages/agno/vitest.config.ts new file mode 100644 index 000000000..2900ab354 --- /dev/null +++ b/packages/agno/vitest.config.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from "node:url"; + +export default { + resolve: { + alias: { + "@openuidev/react-headless": fileURLToPath( + new URL("../react-headless/src/index.ts", import.meta.url), + ), + }, + }, + test: { + environment: "node", + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d64fbef46..167060393 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1360,6 +1360,18 @@ importers: specifier: ^5 version: 5.9.3 + packages/agno: + devDependencies: + '@openuidev/react-headless': + specifier: workspace:^ + version: link:../react-headless + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + packages/assistant-ui: dependencies: '@openuidev/react-headless': From ba3e79e2d3b33c2c91f5487d3f8620a5951d372a Mon Sep 17 00:00:00 2001 From: Visharad Kashyap <154831195+vishxrad@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:39:47 +0530 Subject: [PATCH 2/2] feat: add Agno OpenUI streaming and HITL support --- packages/agno/README.md | 74 +++- packages/agno/package.json | 9 +- packages/agno/src/__tests__/adapter.test.ts | 41 +++ packages/agno/src/__tests__/llm.test.ts | 3 +- .../agno/src/__tests__/prompt-openui.test.ts | 50 +++ packages/agno/src/__tests__/storage.test.ts | 43 +++ packages/agno/src/adapter.ts | 71 ++-- packages/agno/src/index.ts | 11 + packages/agno/src/openui-fence.ts | 69 ++++ packages/agno/src/prompt-openui.tsx | 154 ++++++++ packages/agno/src/storage.ts | 9 +- packages/agno/tsconfig.json | 2 +- .../react-headless/src/adapters/fetchLLM.ts | 5 +- .../react-headless/src/hooks/useThread.ts | 1 + packages/react-headless/src/index.ts | 1 + .../store/__tests__/createChatStore.test.ts | 66 ++++ .../src/store/artifactRendererTypes.ts | 4 + .../src/store/createChatStore.ts | 334 ++++++++++-------- packages/react-headless/src/store/types.ts | 5 +- .../tool-renderer/ToolActivityRenderer.tsx | 2 + pnpm-lock.yaml | 12 + 21 files changed, 773 insertions(+), 193 deletions(-) create mode 100644 packages/agno/src/__tests__/prompt-openui.test.ts create mode 100644 packages/agno/src/openui-fence.ts create mode 100644 packages/agno/src/prompt-openui.tsx diff --git a/packages/agno/README.md b/packages/agno/README.md index fb68afd65..20e3dd286 100644 --- a/packages/agno/README.md +++ b/packages/agno/README.md @@ -13,7 +13,7 @@ The integration is intentionally complementary: ## Install ```bash -pnpm add @openuidev/agno @openuidev/react-ui +pnpm add @openuidev/agno @openuidev/react-headless @openuidev/react-lang @openuidev/react-ui react ``` Import OpenUI styles once: @@ -25,12 +25,13 @@ Import OpenUI styles once: ## Connect AgentInterface to AgentOS ```tsx -import { createAgnoLLM, agnoStorage } from "@openuidev/agno"; -import { AgentInterface } from "@openuidev/react-ui"; +import { agnoOpenUIPromptRenderer, createAgnoLLM, agnoStorage } from "@openuidev/agno"; +import { AgentInterface, openuiChatLibrary } from "@openuidev/react-ui"; const llm = createAgnoLLM({ url: "http://localhost:7777/agui", forwardedProps: { user_id: "demo-user" }, + context: [{ description: "openui_client", value: "true" }], }); const storage = agnoStorage({ @@ -41,7 +42,14 @@ const storage = agnoStorage({ }); export function Chat() { - return ; + return ( + + ); } ``` @@ -54,13 +62,18 @@ omit `userId`/`forwardedProps.user_id`; AgentOS derives identity from the token. the Agno-aware stream adapter. - `agnoAGUIAdapter()` removes non-chat lifecycle/state events and Agno's empty tool-parent text envelope while retaining streamed text, tools, and errors. + When assistant text is fenced as `openui`, it removes only that wrapper + incrementally so OpenUI receives the inner language as progressive deltas. - `agnoStorage()` maps OpenUI threads to AgentOS `/sessions` APIs and reloads - messages from AgentOS `chat_history`. + messages from AgentOS `chat_history`. It removes the same optional wrapper + during history conversion, so a reloaded thread follows the live render path. - The mapping is deliberately 1:1: the OpenUI `thread.id` is the AgentOS `session_id`, so chat, persistence, inspection, and operational tooling all address the same conversation without a translation table. - `agnoHistoryToMessages()` exposes the tolerant history conversion separately for custom storage implementations. +- `agnoOpenUIPromptRenderer` renders the Agno `prompt_openui` HITL tool. + Submissions become AG-UI tool results that resume the paused AgentOS run. The package does not run an agent, proxy model keys, or create a second source of conversation truth. @@ -76,12 +89,31 @@ from agno.db.sqlite import SqliteDb from agno.models.openai import OpenAIResponses from agno.os import AgentOS from agno.os.interfaces.agui import AGUI +from agno.tools import tool + +@tool(external_execution=True, external_execution_silent=True) +def prompt_openui(ui: str, fallback_markdown: str) -> str: + """Render a form or choice and wait for its submission.""" + return fallback_markdown + +def instructions(run_context=None): + dependencies = getattr(run_context, "dependencies", None) or {} + if dependencies.get("openui_client") is True: + return [ + openui_system_prompt, + """For complete visual answers, return exactly one Markdown fence labeled + openui. Put root first and do not add text outside the fence. Use + prompt_openui only when a user action must pause and resume the run; + its ui argument is raw OpenUI Lang without a fence.""", + ] + return ["Respond in ordinary text or Markdown. Do not call prompt_openui."] agent = Agent( id="openui-assistant", model=OpenAIResponses(id="gpt-5.5"), db=SqliteDb(id="openui", db_file="tmp/openui.db"), - instructions=[openui_system_prompt], + tools=[prompt_openui], + instructions=instructions, add_history_to_context=True, ) @@ -89,13 +121,33 @@ agent_os = AgentOS(agents=[agent], interfaces=[AGUI(agent=agent)]) app = agent_os.get_app() ``` +The fence is a single, lossless payload. AgentOS stores and displays it as a +readable Markdown code block; it does not need to render the interface. The +Agno adapter drops only the opening and closing fence as bytes arrive, so the +same payload progressively renders in OpenUI and reloads from session history. + +`prompt_openui` is reserved for a real human-in-the-loop boundary: AgentOS +persists a paused run, OpenUI collects the required action and form state, and +the package submits a trailing tool message to resume that same run. A database +is required for AgentOS to reload and resume paused runs. Current AgentOS emits +the prompt tool arguments after the call is complete, so the prompt interface +appears as one tool payload rather than progressively. Its subsequent assistant +answer uses the normal fenced streaming path. + +For one agent shared by OpenUI and native AgentOS chat, use a callable Agno +instruction function. The example marks AG-UI requests with the transient +`openui_client` context dependency: those runs receive the generated component +prompt, fenced output rule, and HITL tool rule; native AgentOS runs receive +ordinary Markdown rules and do not call the UI tool. + The local `examples/agno-chat` workspace contains a runnable client, a real AgentOS server, and a deterministic no-key development harness. ## Current scope -This first local implementation supports streamed OpenUI responses, backend -tool timelines, authentication headers, agents/teams, and AgentOS-backed -conversation persistence. Native rendering and resumption of Agno HITL/client -tools is the next integration layer rather than something this package claims -to support already. +This local implementation supports progressively streamed assistant OpenUI +Lang, paused human tools and resumption, backend tool timelines, +authentication headers, agents/teams, and +AgentOS-backed conversation persistence. The stock AgentOS web app does not render +OpenUI Lang; it can inspect the fenced source while native AgentOS chat stays on +the ordinary text/Markdown path. diff --git a/packages/agno/package.json b/packages/agno/package.json index e85c94eca..0cd4106e3 100644 --- a/packages/agno/package.json +++ b/packages/agno/package.json @@ -61,10 +61,17 @@ }, "author": "engineering@thesys.dev", "peerDependencies": { - "@openuidev/react-headless": "workspace:^" + "@openuidev/react-headless": "workspace:^", + "@openuidev/react-lang": "workspace:^", + "@openuidev/react-ui": "workspace:^", + "react": "catalog:" }, "devDependencies": { "@openuidev/react-headless": "workspace:^", + "@openuidev/react-lang": "workspace:^", + "@openuidev/react-ui": "workspace:^", + "@types/react": "catalog:", + "react": "catalog:", "typescript": "catalog:", "vitest": "^4.1.0" } diff --git a/packages/agno/src/__tests__/adapter.test.ts b/packages/agno/src/__tests__/adapter.test.ts index 7142ee49e..00defcfc1 100644 --- a/packages/agno/src/__tests__/adapter.test.ts +++ b/packages/agno/src/__tests__/adapter.test.ts @@ -82,4 +82,45 @@ describe("agnoAGUIAdapter", () => { EventType.TEXT_MESSAGE_END, ]); }); + + it("incrementally unwraps fenced OpenUI Lang across arbitrary delta boundaries", async () => { + const content = 'root = Card([title])\ntitle = TextContent("Streaming")'; + const fenced = `\`\`\`openui\n${content}\n\`\`\``; + const chunks = [fenced.slice(0, 2), fenced.slice(2, 9), ...fenced.slice(9).match(/.{1,7}/gs)!]; + const parsed = await parse([ + { type: EventType.TEXT_MESSAGE_START, messageId: "answer", role: "assistant" }, + ...chunks.map((delta) => ({ + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: "answer", + delta, + })), + { type: EventType.TEXT_MESSAGE_END, messageId: "answer" }, + ]); + + expect( + parsed + .filter((event) => event.type === EventType.TEXT_MESSAGE_CONTENT) + .map((event) => event.delta) + .join(""), + ).toBe(content); + expect( + parsed.filter((event) => event.type === EventType.TEXT_MESSAGE_CONTENT).length, + ).toBeGreaterThan(1); + }); + + it("leaves ordinary streamed assistant text unchanged", async () => { + const parsed = await parse([ + { type: EventType.TEXT_MESSAGE_START, messageId: "answer", role: "assistant" }, + { type: EventType.TEXT_MESSAGE_CONTENT, messageId: "answer", delta: "Regular " }, + { type: EventType.TEXT_MESSAGE_CONTENT, messageId: "answer", delta: "Markdown" }, + { type: EventType.TEXT_MESSAGE_END, messageId: "answer" }, + ]); + + expect( + parsed + .filter((event) => event.type === EventType.TEXT_MESSAGE_CONTENT) + .map((event) => event.delta) + .join(""), + ).toBe("Regular Markdown"); + }); }); diff --git a/packages/agno/src/__tests__/llm.test.ts b/packages/agno/src/__tests__/llm.test.ts index bb10a0a9f..4bd312f11 100644 --- a/packages/agno/src/__tests__/llm.test.ts +++ b/packages/agno/src/__tests__/llm.test.ts @@ -8,6 +8,7 @@ describe("createAgnoLLM", () => { url: "/agui", token: "test-token", forwardedProps: { user_id: "user-1" }, + context: [{ description: "openui_client", value: "true" }], fetch: fetchMock, }); @@ -28,7 +29,7 @@ describe("createAgnoLLM", () => { state: {}, forwardedProps: { user_id: "user-1" }, tools: [], - context: [], + context: [{ description: "openui_client", value: "true" }], }); }); diff --git a/packages/agno/src/__tests__/prompt-openui.test.ts b/packages/agno/src/__tests__/prompt-openui.test.ts new file mode 100644 index 000000000..a946a1cc5 --- /dev/null +++ b/packages/agno/src/__tests__/prompt-openui.test.ts @@ -0,0 +1,50 @@ +import { BuiltinActionType } from "@openuidev/react-lang"; +import { describe, expect, it } from "vitest"; +import { + AGNO_OPENUI_PROMPT_TOOL_NAME, + createAgnoOpenUIPromptRenderer, + parseAgnoOpenUIPrompt, +} from "../prompt-openui"; + +describe("Agno OpenUI prompt renderer", () => { + it("matches the conventional AgentOS tool name", () => { + expect(createAgnoOpenUIPromptRenderer().toolName).toBe(AGNO_OPENUI_PROMPT_TOOL_NAME); + }); + + it("recovers a streamed ui argument", () => { + expect( + parseAgnoOpenUIPrompt({ + args: '{"ui":"root = Card([title])\\ntitle = TextContent(\\"Project', + response: null, + }), + ).toMatchObject({ ui: 'root = Card([title])\ntitle = TextContent("Project' }); + }); + + it("hydrates submitted form state from the AgentOS tool result", () => { + expect( + parseAgnoOpenUIPrompt({ + args: JSON.stringify({ ui: "root = Card([])" }), + response: JSON.stringify({ + type: BuiltinActionType.ContinueConversation, + message: "Submit project estimate", + params: {}, + formName: "project_estimate", + formState: { project_estimate: { project_name: { value: "Aurora" } } }, + }), + }), + ).toEqual({ + ui: "root = Card([])", + result: { + type: BuiltinActionType.ContinueConversation, + message: "Submit project estimate", + params: {}, + formName: "project_estimate", + formState: { project_estimate: { project_name: { value: "Aurora" } } }, + }, + }); + }); + + it("rejects calls without an OpenUI program", () => { + expect(parseAgnoOpenUIPrompt({ args: "{}", response: null })).toBeNull(); + }); +}); diff --git a/packages/agno/src/__tests__/storage.test.ts b/packages/agno/src/__tests__/storage.test.ts index 652074437..d02a9e8b8 100644 --- a/packages/agno/src/__tests__/storage.test.ts +++ b/packages/agno/src/__tests__/storage.test.ts @@ -1,5 +1,6 @@ import type { UserMessage } from "@openuidev/react-headless"; import { describe, expect, it, vi } from "vitest"; +import { stripOpenUIFence } from "../openui-fence"; import { agnoHistoryToMessages, agnoStorage } from "../storage"; function jsonResponse(value: unknown, status = 200) { @@ -51,6 +52,48 @@ describe("agnoHistoryToMessages", () => { }, ]); }); + + it("unwraps AgentOS Markdown source for OpenUI history reloads", () => { + expect( + agnoHistoryToMessages( + [ + { + role: "assistant", + content: '```openui\nroot = Card([])\ntitle = TextContent("Saved")\n```', + }, + ], + "thread-1", + ), + ).toEqual([ + { + id: "thread-1-message-0", + role: "assistant", + content: 'root = Card([])\ntitle = TextContent("Saved")', + }, + ]); + }); + + it("hides AgentOS dependency context appended to stored user messages", () => { + expect( + agnoHistoryToMessages( + [ + { + role: "user", + content: + 'Build a chart. \n{ "openui_client": true }\n', + }, + ], + "thread-1", + ), + ).toEqual([{ id: "thread-1-message-0", role: "user", content: "Build a chart." }]); + }); +}); + +describe("stripOpenUIFence", () => { + it("is lossless for ordinary content and tolerant of an interrupted fenced stream", () => { + expect(stripOpenUIFence("root = Card([])")).toBe("root = Card([])"); + expect(stripOpenUIFence("```openui\nroot = Card([])")).toBe("root = Card([])"); + }); }); describe("agnoStorage", () => { diff --git a/packages/agno/src/adapter.ts b/packages/agno/src/adapter.ts index 9fa0d4d9e..a1d7f3443 100644 --- a/packages/agno/src/adapter.ts +++ b/packages/agno/src/adapter.ts @@ -4,6 +4,7 @@ import { type AGUIEvent, type StreamProtocolAdapter, } from "@openuidev/react-headless"; +import { OpenUIFenceStream } from "./openui-fence"; const OPENUI_CHAT_EVENT_TYPES = new Set([ EventType.TEXT_MESSAGE_START, @@ -25,35 +26,49 @@ const OPENUI_CHAT_EVENT_TYPES = new Set([ * not chat messages. It also emits an empty assistant message as the required * parent of a backend tool call. Forwarding those events to OpenUI currently * materializes empty messages and can race a fast tool result. This adapter - * keeps the supported text/tool/error events and removes only empty text - * envelopes; AgentOS remains the owner of the run and tool lifecycle. + * keeps the supported text/tool/error events, removes empty text envelopes, + * and incrementally unwraps fenced OpenUI Lang. AgentOS can therefore retain + * readable Markdown source while OpenUI receives the raw language deltas. */ export function agnoAGUIAdapter(): StreamProtocolAdapter { const adapter = agUIAdapter(); return { async *parse(response) { - let pendingTextStart: AGUIEvent | undefined; - let pendingEvents: AGUIEvent[] = []; + let textState: + | { + start: AGUIEvent; + started: boolean; + pendingEvents: AGUIEvent[]; + fence: OpenUIFenceStream; + } + | undefined; - const flushBufferedEvents = function* () { - yield* pendingEvents; - pendingEvents = []; + const startText = function* () { + if (!textState || textState.started) return; + yield textState.start; + yield* textState.pendingEvents; + textState.pendingEvents = []; + textState.started = true; }; for await (const event of adapter.parse(response)) { if (!OPENUI_CHAT_EVENT_TYPES.has(event.type)) continue; if (event.type === EventType.TEXT_MESSAGE_START) { - if (pendingTextStart) { - yield* flushBufferedEvents(); + if (textState) { + yield* textState.pendingEvents; } - pendingTextStart = event; - pendingEvents = []; + textState = { + start: event, + started: false, + pendingEvents: [], + fence: new OpenUIFenceStream(), + }; continue; } - if (!pendingTextStart) { + if (!textState) { yield event; continue; } @@ -62,25 +77,41 @@ export function agnoAGUIAdapter(): StreamProtocolAdapter { event.type === EventType.TEXT_MESSAGE_CHUNK || event.type === EventType.TEXT_MESSAGE_CONTENT ) { - yield pendingTextStart; - pendingTextStart = undefined; - yield* flushBufferedEvents(); - yield event; + const delta = textState.fence.push(event.delta ?? ""); + if (delta) { + yield* startText(); + yield { ...event, delta }; + } continue; } if (event.type === EventType.TEXT_MESSAGE_END) { - pendingTextStart = undefined; - yield* flushBufferedEvents(); + const delta = textState.fence.finish(); + if (delta) { + yield* startText(); + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId: event.messageId, + delta, + }; + } + if (textState.started) { + yield* textState.pendingEvents; + yield event; + } else { + yield* textState.pendingEvents; + } + textState = undefined; continue; } - pendingEvents.push(event); + if (textState.started) yield event; + else textState.pendingEvents.push(event); } // Never discard tool/error events merely because AgentOS ended the // transport before closing an empty parent text envelope. - yield* flushBufferedEvents(); + if (textState) yield* textState.pendingEvents; }, }; } diff --git a/packages/agno/src/index.ts b/packages/agno/src/index.ts index 99497cb9b..0056f8782 100644 --- a/packages/agno/src/index.ts +++ b/packages/agno/src/index.ts @@ -1,5 +1,16 @@ export { agnoAGUIAdapter } from "./adapter"; export { createAgnoLLM } from "./llm"; export type { CreateAgnoLLMOptions } from "./llm"; +export { stripOpenUIFence } from "./openui-fence"; +export { + AGNO_OPENUI_PROMPT_TOOL_NAME, + agnoOpenUIPromptRenderer, + createAgnoOpenUIPromptRenderer, + parseAgnoOpenUIPrompt, +} from "./prompt-openui"; +export type { + AgnoOpenUIActionResult, + CreateAgnoOpenUIPromptRendererOptions, +} from "./prompt-openui"; export { agnoHistoryToMessages, agnoStorage, createAgnoStorage } from "./storage"; export type { AgnoEntityType, AgnoStorageOptions } from "./storage"; diff --git a/packages/agno/src/openui-fence.ts b/packages/agno/src/openui-fence.ts new file mode 100644 index 000000000..34845266f --- /dev/null +++ b/packages/agno/src/openui-fence.ts @@ -0,0 +1,69 @@ +const OPENUI_FENCE_OPENERS = [ + "```openui\n", + "```openui\r\n", + "```openui-lang\n", + "```openui-lang\r\n", +] as const; + +const MAX_CLOSING_FENCE_LENGTH = "\r\n```\r\n".length; + +/** + * Remove an optional Markdown fence used to make OpenUI Lang readable in + * AgentOS. Non-fenced content is returned byte-for-byte unchanged. + */ +export function stripOpenUIFence(content: string): string { + const opener = OPENUI_FENCE_OPENERS.find((candidate) => content.startsWith(candidate)); + if (!opener) return content; + + const body = content.slice(opener.length); + const closingFence = body.match(/\r?\n```[\t ]*(?:\r?\n)?$/); + return closingFence ? body.slice(0, -closingFence[0].length) : body; +} + +/** Incrementally remove the AgentOS Markdown wrapper without buffering the UI. */ +export class OpenUIFenceStream { + private mode: "detect" | "fenced" | "passthrough" = "detect"; + private buffer = ""; + + push(delta: string): string { + if (this.mode === "passthrough") return delta; + + this.buffer += delta; + + if (this.mode === "detect") { + const opener = OPENUI_FENCE_OPENERS.find((candidate) => this.buffer.startsWith(candidate)); + if (opener) { + this.mode = "fenced"; + this.buffer = this.buffer.slice(opener.length); + } else if (OPENUI_FENCE_OPENERS.some((candidate) => candidate.startsWith(this.buffer))) { + return ""; + } else { + this.mode = "passthrough"; + const content = this.buffer; + this.buffer = ""; + return content; + } + } + + if (this.buffer.length <= MAX_CLOSING_FENCE_LENGTH) return ""; + + const safeLength = this.buffer.length - MAX_CLOSING_FENCE_LENGTH; + const content = this.buffer.slice(0, safeLength); + this.buffer = this.buffer.slice(safeLength); + return content; + } + + finish(): string { + if (this.mode === "detect") { + const content = this.buffer; + this.buffer = ""; + return content; + } + + if (this.mode === "passthrough") return ""; + + const content = this.buffer.replace(/\r?\n```[\t ]*(?:\r?\n)?$/, ""); + this.buffer = ""; + return content; + } +} diff --git a/packages/agno/src/prompt-openui.tsx b/packages/agno/src/prompt-openui.tsx new file mode 100644 index 000000000..a295f2ea3 --- /dev/null +++ b/packages/agno/src/prompt-openui.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { + defineArtifactRenderer, + partialJSONParse, + useThread, + type ArtifactRendererConfig, + type CreateToolResult, +} from "@openuidev/react-headless"; +import { BuiltinActionType, Renderer, type ActionEvent, type Library } from "@openuidev/react-lang"; +import { openuiChatLibrary } from "@openuidev/react-ui"; +import { useCallback, useMemo, useRef } from "react"; + +export const AGNO_OPENUI_PROMPT_TOOL_NAME = "prompt_openui"; + +export interface AgnoOpenUIActionResult { + type: BuiltinActionType.ContinueConversation; + message: string; + params: Record; + formState?: Record; + formName?: string; +} + +interface OpenUIToolProps { + ui: string; + result?: AgnoOpenUIActionResult; +} + +export interface CreateAgnoOpenUIPromptRendererOptions { + /** Exact library described to the AgentOS model. Defaults to openuiChatLibrary. */ + library?: Library; + /** Backend external-execution tool name. Defaults to prompt_openui. */ + toolName?: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseActionResult(value: unknown): AgnoOpenUIActionResult | undefined { + let parsed = value; + if (typeof value === "string") { + try { + parsed = JSON.parse(value); + } catch { + return undefined; + } + } + if (!isRecord(parsed) || typeof parsed["message"] !== "string") return undefined; + + return { + type: BuiltinActionType.ContinueConversation, + message: parsed["message"], + params: isRecord(parsed["params"]) ? parsed["params"] : {}, + ...(isRecord(parsed["formState"]) ? { formState: parsed["formState"] } : {}), + ...(typeof parsed["formName"] === "string" ? { formName: parsed["formName"] } : {}), + }; +} + +export function parseAgnoOpenUIPrompt(raw: { + args: unknown; + response: unknown; +}): OpenUIToolProps | null { + const parsedArgs = typeof raw.args === "string" ? partialJSONParse(raw.args) : raw.args; + if (!isRecord(parsedArgs) || typeof parsedArgs["ui"] !== "string") return null; + + const ui = parsedArgs["ui"]; + if (ui.length === 0) return null; + + const result = parseActionResult(raw.response); + return { ui, ...(result ? { result } : {}) }; +} + +function PromptOpenUI({ + ui, + result, + library, + toolCallId, +}: OpenUIToolProps & { library: Library; toolCallId?: string }) { + const isRunning = useThread((state) => state.isRunning); + const processToolResult = useThread((state) => state.processToolResult); + const completed = useRef(result !== undefined); + + const initialState = useMemo(() => result?.formState, [result?.formState]); + + const handleAction = useCallback( + (event: ActionEvent) => { + if ( + !toolCallId || + result !== undefined || + completed.current || + event.type !== BuiltinActionType.ContinueConversation + ) { + if (event.type === BuiltinActionType.OpenUrl) { + const url = event.params?.["url"]; + if (typeof window !== "undefined" && typeof url === "string") { + window.open(url, "_blank", "noopener,noreferrer"); + } + } + return; + } + + completed.current = true; + const actionResult: AgnoOpenUIActionResult = { + type: BuiltinActionType.ContinueConversation, + message: event.humanFriendlyMessage, + params: event.params, + ...(event.formState !== undefined && { formState: event.formState }), + ...(event.formName !== undefined && { formName: event.formName }), + }; + const toolResult: CreateToolResult = { + toolCallId, + content: JSON.stringify(actionResult), + }; + void processToolResult(toolResult); + }, + [processToolResult, result, toolCallId], + ); + + return ( + + ); +} + +/** + * Render Agno's external-execution prompt_openui tool inside AgentInterface. + * An @ToAssistant action becomes a trailing AG-UI ToolMessage, which resumes + * the paused AgentOS run in the same thread/session. + */ +export function createAgnoOpenUIPromptRenderer({ + library = openuiChatLibrary, + toolName = AGNO_OPENUI_PROMPT_TOOL_NAME, +}: CreateAgnoOpenUIPromptRendererOptions = {}): ArtifactRendererConfig { + return defineArtifactRenderer({ + type: "openui_prompt", + toolName, + parser: (raw) => { + const props = parseAgnoOpenUIPrompt(raw); + return props ? { props, meta: null } : null; + }, + preview: (props, controls) => ( + + ), + actual: () => null, + }); +} + +export const agnoOpenUIPromptRenderer = createAgnoOpenUIPromptRenderer(); diff --git a/packages/agno/src/storage.ts b/packages/agno/src/storage.ts index 968e01739..dd73dd5b1 100644 --- a/packages/agno/src/storage.ts +++ b/packages/agno/src/storage.ts @@ -5,6 +5,7 @@ import type { ToolCall, UserMessage, } from "@openuidev/react-headless"; +import { stripOpenUIFence } from "./openui-fence"; export type AgnoEntityType = "agent" | "team"; @@ -87,6 +88,10 @@ function contentToString(value: unknown): string { } } +function stripAgnoAdditionalContext(content: string): string { + return content.replace(/\s*\s*[\s\S]*?\s*<\/additional context>\s*$/i, ""); +} + function messageTitle(message: UserMessage): string { const content = message.content; const text = @@ -138,7 +143,7 @@ export function agnoHistoryToMessages(history: unknown, sessionId = "session"): switch (entry["role"]) { case "human": case "user": - return [{ id, role: "user", content }]; + return [{ id, role: "user", content: stripAgnoAdditionalContext(content) }]; case "ai": case "assistant": { const toolCalls = normalizeToolCalls(entry["tool_calls"]); @@ -146,7 +151,7 @@ export function agnoHistoryToMessages(history: unknown, sessionId = "session"): { id, role: "assistant", - content, + content: stripOpenUIFence(content), ...(toolCalls ? { toolCalls } : {}), }, ]; diff --git a/packages/agno/tsconfig.json b/packages/agno/tsconfig.json index 8645f69b9..51f9c53a3 100644 --- a/packages/agno/tsconfig.json +++ b/packages/agno/tsconfig.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "../../tsconfig.json", "include": ["src/**/*"], - "exclude": ["src/**/__tests__/**", "src/**/*.test.ts"], + "exclude": ["src/**/__tests__/**", "src/**/*.test.ts", "src/**/*.test.tsx"], "compilerOptions": { "baseUrl": ".", "paths": { diff --git a/packages/react-headless/src/adapters/fetchLLM.ts b/packages/react-headless/src/adapters/fetchLLM.ts index e5cbb45a8..8eac37ca3 100644 --- a/packages/react-headless/src/adapters/fetchLLM.ts +++ b/packages/react-headless/src/adapters/fetchLLM.ts @@ -15,6 +15,8 @@ export interface FetchLLMOptions { fetch?: typeof fetch; /** Extra fields merged into the request body (e.g. `model`) */ body?: Record; + /** AG-UI context entries sent with every run. */ + context?: Array<{ description: string; value: string }>; } /** @@ -29,6 +31,7 @@ export function fetchLLM({ headers, fetch: customFetch, body, + context = [], }: FetchLLMOptions): ChatLLM { const fetchImpl = customFetch ?? globalThis.fetch.bind(globalThis); return { @@ -43,7 +46,7 @@ export function fetchLLM({ body: JSON.stringify({ ...body, tools: [], - context: [], + context, threadId, runId, messages: messageFormat.toApi(messages), diff --git a/packages/react-headless/src/hooks/useThread.ts b/packages/react-headless/src/hooks/useThread.ts index 55c601250..5a39b90c4 100644 --- a/packages/react-headless/src/hooks/useThread.ts +++ b/packages/react-headless/src/hooks/useThread.ts @@ -19,6 +19,7 @@ const threadSelector = (s: ChatStore): ThreadSlice => ({ threadError: s.threadError, executingToolCallIds: s.executingToolCallIds, processMessage: s.processMessage, + processToolResult: s.processToolResult, appendMessages: s.appendMessages, updateMessage: s.updateMessage, setMessages: s.setMessages, diff --git a/packages/react-headless/src/index.ts b/packages/react-headless/src/index.ts index d8e5079e9..336edd003 100644 --- a/packages/react-headless/src/index.ts +++ b/packages/react-headless/src/index.ts @@ -76,6 +76,7 @@ export type { ChatProviderProps, ChatStore, CreateMessage, + CreateToolResult, Thread, ThreadActions, ThreadListActions, diff --git a/packages/react-headless/src/store/__tests__/createChatStore.test.ts b/packages/react-headless/src/store/__tests__/createChatStore.test.ts index 1d28dfbab..5e01e6148 100644 --- a/packages/react-headless/src/store/__tests__/createChatStore.test.ts +++ b/packages/react-headless/src/store/__tests__/createChatStore.test.ts @@ -355,6 +355,72 @@ describe("createChatStore", () => { }); }); + describe("processToolResult", () => { + it("appends a trailing tool message and continues the active thread", async () => { + const send = vi.fn().mockResolvedValue(new Response("", { status: 200 })); + const store = makeStore({ + send, + streamProtocol: { parse: async function* () {} }, + }); + store.setState({ + selectedThreadId: "t1", + messages: [ + { + id: "assistant-1", + role: "assistant", + content: "", + toolCalls: [ + { + id: "call-1", + type: "function", + function: { name: "prompt_openui", arguments: '{"ui":"root = Card([])"}' }, + }, + ], + }, + ], + }); + + await store.getState().processToolResult({ + toolCallId: "call-1", + content: '{"message":"Submitted"}', + }); + + expect(send).toHaveBeenCalledOnce(); + expect(send.mock.calls[0]?.[0]).toMatchObject({ + threadId: "t1", + messages: [ + expect.objectContaining({ role: "assistant" }), + expect.objectContaining({ + role: "tool", + toolCallId: "call-1", + content: '{"message":"Submitted"}', + }), + ], + }); + expect(store.getState().messages.at(-1)).toMatchObject({ + role: "tool", + toolCallId: "call-1", + }); + expect(store.getState().isRunning).toBe(false); + }); + + it("rejects a tool result when there is no active thread", async () => { + const send = vi.fn(); + const store = makeStore({ send }); + + await store.getState().processToolResult({ + toolCallId: "call-1", + content: "submitted", + }); + + expect(send).not.toHaveBeenCalled(); + expect(store.getState().messages).toEqual([]); + expect(store.getState().threadError?.message).toBe( + "Cannot submit a tool result without an active thread.", + ); + }); + }); + // ──────────────────────────────────────────── // cancelMessage // ──────────────────────────────────────────── diff --git a/packages/react-headless/src/store/artifactRendererTypes.ts b/packages/react-headless/src/store/artifactRendererTypes.ts index 117e011de..ebd2701e9 100644 --- a/packages/react-headless/src/store/artifactRendererTypes.ts +++ b/packages/react-headless/src/store/artifactRendererTypes.ts @@ -8,6 +8,10 @@ import type { ReactNode } from "react"; export interface ArtifactRendererControls { /** Whether this renderer's detailed view is the currently active one. */ isActive: boolean; + /** Tool call being rendered. Undefined when rendering a stored artifact. */ + toolCallId?: string; + /** Tool name being rendered. Undefined when rendering a stored artifact. */ + toolName?: string; /** * `true` while the tool call is still streaming — i.e. its arguments are * arriving incrementally and no tool result has been paired in yet. Becomes diff --git a/packages/react-headless/src/store/createChatStore.ts b/packages/react-headless/src/store/createChatStore.ts index eed9fd96c..70a5ab1dc 100644 --- a/packages/react-headless/src/store/createChatStore.ts +++ b/packages/react-headless/src/store/createChatStore.ts @@ -4,6 +4,7 @@ import { subscribeWithSelector } from "zustand/middleware"; import { getResponseErrorMessage } from "../adapters/httpError"; import type { ChatLLM, ChatStorage } from "../adapters/types"; import { processStreamedMessage } from "../stream/processStreamedMessage"; +import type { ToolMessage } from "../types/message"; import { buildObservabilityErrorDetail, levelForStatus } from "./observability"; import type { ChatStore, Message, Thread, UserMessage } from "./types"; @@ -24,134 +25,17 @@ export const createChatStore = (configRef: React.RefObject()( - subscribeWithSelector((set, get) => ({ - // Thread List State - threads: [], - isLoadingThreads: false, - threadListError: null, - selectedThreadId: null, - hasMoreThreads: false, - _nextCursor: undefined, - - // Thread State - messages: [], - isRunning: false, - isLoadingMessages: false, - threadError: null, - executingToolCallIds: new Set(), - _abortController: null, - - // ── Thread List Actions ── - - loadThreads: () => { - set({ isLoadingThreads: true, threadListError: null }); - threadStorage - .listThreads(undefined) - .then(({ threads = [], nextCursor }) => { - set({ - threads, - isLoadingThreads: false, - _nextCursor: nextCursor, - hasMoreThreads: nextCursor !== undefined, - }); - }) - .catch((e) => { - set({ isLoadingThreads: false, threadListError: e }); - }); - }, - - loadMoreThreads: () => { - const cursor = get()._nextCursor; - if (cursor === undefined) return; - threadStorage - .listThreads(cursor) - .then(({ threads = [], nextCursor }) => { - set((s) => ({ - threads: mergeThreadList(s.threads, threads), - _nextCursor: nextCursor, - hasMoreThreads: nextCursor !== undefined, - })); - }) - .catch((e) => { - set({ threadListError: e }); - }); - }, - - switchToNewThread: () => { - get().cancelMessage(); - set({ - selectedThreadId: null, - messages: [], - threadError: null, - executingToolCallIds: new Set(), - }); - }, - - createThread: async (firstMessage: UserMessage) => { - const thread = await threadStorage.createThread(firstMessage); - set((s) => ({ threads: mergeThreadList(s.threads, [thread]) })); - return thread; - }, - - selectThread: (threadId: string) => { - // Re-selecting the active thread is a no-op — don't wipe and refetch. - if (get().selectedThreadId === threadId) return; - get().cancelMessage(); - set({ - selectedThreadId: threadId, - messages: [], - isLoadingMessages: true, - threadError: null, - executingToolCallIds: new Set(), - }); - threadStorage - .getMessages(threadId) - .then((messages) => set({ messages, isLoadingMessages: false })) - .catch((e) => set({ threadError: e, isLoadingMessages: false })); - }, - - updateThread: (thread: Thread) => { - const setPending = (id: string, isPending: boolean) => - set((s) => ({ threads: s.threads.map((t) => (t.id === id ? { ...t, isPending } : t)) })); - setPending(thread.id, true); - threadStorage - .updateThread(thread) - .then((updated) => { - set((s) => ({ - threads: s.threads.map((t) => (t.id === updated.id ? updated : t)), - })); - }) - .catch(() => setPending(thread.id, false)); - }, - - deleteThread: (threadId: string) => { - const setPending = (id: string, isPending: boolean) => - set((s) => ({ threads: s.threads.map((t) => (t.id === id ? { ...t, isPending } : t)) })); - setPending(threadId, true); - threadStorage - .deleteThread(threadId) - .then(() => { - const state = get(); - set({ threads: state.threads.filter((t) => t.id !== threadId) }); - if (state.selectedThreadId === threadId) { - state.switchToNewThread(); - } - }) - .catch(() => setPending(threadId, false)); - }, - - // ── Thread Actions ── - - processMessage: async (message) => { + subscribeWithSelector((set, get) => { + const processInput = async (optimisticMessage: UserMessage | ToolMessage) => { const state = get(); if (state.isRunning) return; + if (optimisticMessage.role === "tool" && !state.selectedThreadId) { + set({ threadError: new Error("Cannot submit a tool result without an active thread.") }); + return; + } + const abortController = new AbortController(); - const optimisticMessage: UserMessage = { - ...message, - id: crypto.randomUUID(), - role: "user", - }; set({ _abortController: abortController, @@ -169,7 +53,9 @@ export const createChatStore = (configRef: React.RefObject(), }); } - }, - - appendMessages: (...newMessages: Message[]) => { - set((s) => ({ messages: [...s.messages, ...newMessages] })); - }, - - updateMessage: (message: Message) => { - set((s) => ({ - messages: s.messages.map((m) => (m.id === message.id ? message : m)), - })); - }, - - setMessages: (messages: Message[]) => { - set({ messages }); - }, - - deleteMessage: (messageId: string) => { - set((s) => ({ messages: s.messages.filter((m) => m.id !== messageId) })); - }, - - cancelMessage: () => { - get()._abortController?.abort(); - }, - })), + }; + + return { + // Thread List State + threads: [], + isLoadingThreads: false, + threadListError: null, + selectedThreadId: null, + hasMoreThreads: false, + _nextCursor: undefined, + + // Thread State + messages: [], + isRunning: false, + isLoadingMessages: false, + threadError: null, + executingToolCallIds: new Set(), + _abortController: null, + + // ── Thread List Actions ── + + loadThreads: () => { + set({ isLoadingThreads: true, threadListError: null }); + threadStorage + .listThreads(undefined) + .then(({ threads = [], nextCursor }) => { + set({ + threads, + isLoadingThreads: false, + _nextCursor: nextCursor, + hasMoreThreads: nextCursor !== undefined, + }); + }) + .catch((e) => { + set({ isLoadingThreads: false, threadListError: e }); + }); + }, + + loadMoreThreads: () => { + const cursor = get()._nextCursor; + if (cursor === undefined) return; + threadStorage + .listThreads(cursor) + .then(({ threads = [], nextCursor }) => { + set((s) => ({ + threads: mergeThreadList(s.threads, threads), + _nextCursor: nextCursor, + hasMoreThreads: nextCursor !== undefined, + })); + }) + .catch((e) => { + set({ threadListError: e }); + }); + }, + + switchToNewThread: () => { + get().cancelMessage(); + set({ + selectedThreadId: null, + messages: [], + threadError: null, + executingToolCallIds: new Set(), + }); + }, + + createThread: async (firstMessage: UserMessage) => { + const thread = await threadStorage.createThread(firstMessage); + set((s) => ({ threads: mergeThreadList(s.threads, [thread]) })); + return thread; + }, + + selectThread: (threadId: string) => { + // Re-selecting the active thread is a no-op — don't wipe and refetch. + if (get().selectedThreadId === threadId) return; + get().cancelMessage(); + set({ + selectedThreadId: threadId, + messages: [], + isLoadingMessages: true, + threadError: null, + executingToolCallIds: new Set(), + }); + threadStorage + .getMessages(threadId) + .then((messages) => set({ messages, isLoadingMessages: false })) + .catch((e) => set({ threadError: e, isLoadingMessages: false })); + }, + + updateThread: (thread: Thread) => { + const setPending = (id: string, isPending: boolean) => + set((s) => ({ + threads: s.threads.map((t) => (t.id === id ? { ...t, isPending } : t)), + })); + setPending(thread.id, true); + threadStorage + .updateThread(thread) + .then((updated) => { + set((s) => ({ + threads: s.threads.map((t) => (t.id === updated.id ? updated : t)), + })); + }) + .catch(() => setPending(thread.id, false)); + }, + + deleteThread: (threadId: string) => { + const setPending = (id: string, isPending: boolean) => + set((s) => ({ + threads: s.threads.map((t) => (t.id === id ? { ...t, isPending } : t)), + })); + setPending(threadId, true); + threadStorage + .deleteThread(threadId) + .then(() => { + const state = get(); + set({ threads: state.threads.filter((t) => t.id !== threadId) }); + if (state.selectedThreadId === threadId) { + state.switchToNewThread(); + } + }) + .catch(() => setPending(threadId, false)); + }, + + // ── Thread Actions ── + + processMessage: (message) => + processInput({ + ...message, + id: crypto.randomUUID(), + role: "user", + }), + + processToolResult: (result) => + processInput({ + ...result, + id: crypto.randomUUID(), + role: "tool", + }), + + appendMessages: (...newMessages: Message[]) => { + set((s) => ({ messages: [...s.messages, ...newMessages] })); + }, + + updateMessage: (message: Message) => { + set((s) => ({ + messages: s.messages.map((m) => (m.id === message.id ? message : m)), + })); + }, + + setMessages: (messages: Message[]) => { + set({ messages }); + }, + + deleteMessage: (messageId: string) => { + set((s) => ({ messages: s.messages.filter((m) => m.id !== messageId) })); + }, + + cancelMessage: () => { + get()._abortController?.abort(); + }, + }; + }), ); return store; diff --git a/packages/react-headless/src/store/types.ts b/packages/react-headless/src/store/types.ts index 8f673ecad..6b8aef36c 100644 --- a/packages/react-headless/src/store/types.ts +++ b/packages/react-headless/src/store/types.ts @@ -1,9 +1,10 @@ import type { ArtifactCategory, ChatLLM, ChatStorage } from "../adapters/types"; -import type { Message, UserMessage } from "../types/message"; +import type { Message, ToolMessage, UserMessage } from "../types/message"; import type { ArtifactRendererConfig } from "./artifactRendererTypes"; export type { Message, UserMessage } from "../types/message"; export type CreateMessage = Omit; +export type CreateToolResult = Omit; export type Thread = { id: string; @@ -52,6 +53,8 @@ export type ThreadState = { export type ThreadActions = { processMessage: (message: CreateMessage) => Promise; + /** Append a frontend tool result and continue the same model run. */ + processToolResult: (result: CreateToolResult) => Promise; appendMessages: (...messages: Message[]) => void; updateMessage: (message: Message) => void; setMessages: (messages: Message[]) => void; diff --git a/packages/react-ui/src/components/_shared/tool-renderer/ToolActivityRenderer.tsx b/packages/react-ui/src/components/_shared/tool-renderer/ToolActivityRenderer.tsx index 778d278a1..61d15c050 100644 --- a/packages/react-ui/src/components/_shared/tool-renderer/ToolActivityRenderer.tsx +++ b/packages/react-ui/src/components/_shared/tool-renderer/ToolActivityRenderer.tsx @@ -153,6 +153,8 @@ export function ToolActivityRenderer({ const controls: ArtifactRendererControls = { isActive, isStreaming, + toolCallId: activity.toolCall.id, + toolName: activity.toolName, open, close, toggle, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 167060393..bb88a6810 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1365,6 +1365,18 @@ importers: '@openuidev/react-headless': specifier: workspace:^ version: link:../react-headless + '@openuidev/react-lang': + specifier: workspace:^ + version: link:../react-lang + '@openuidev/react-ui': + specifier: workspace:^ + version: link:../react-ui + '@types/react': + specifier: 'catalog:' + version: 19.2.17 + react: + specifier: 'catalog:' + version: 19.2.4 typescript: specifier: 'catalog:' version: 5.9.3