Compare commits

...

1 Commits

Author SHA1 Message Date
Tao Chen 157f37a5a7 Add single agent AGUI sample 2026-07-29 13:23:10 -07:00
14 changed files with 1871 additions and 0 deletions
@@ -0,0 +1,85 @@
# AG-UI Single Agent Demo
The simplest possible AG-UI integration: a **single chat agent** with **no tools** and **no context providers**,
served over the AG-UI protocol and consumed by a small React client.
Use this sample as the starting point for AG-UI. For a richer, multi-agent example with tool-approval checkpoints
and human-in-the-loop resumes, see [`../ag_ui_workflow_handoff`](../ag_ui_workflow_handoff/README.md).
## Folder Layout
- `backend/server.py` - FastAPI + AG-UI endpoint wrapping a single `Agent`
- `frontend/` - Vite + React AG-UI client UI
## Prerequisites
- Python 3.10+
- Node.js 18+
- npm 9+
- Azure AI project + model deployment configured in environment variables:
- `FOUNDRY_PROJECT_ENDPOINT`
- `FOUNDRY_MODEL`
## 1) Run Backend
From the Python repo root:
```bash
cd python
uv sync
uv run python samples/05-end-to-end/ag_ui_single_agent/backend/server.py
```
Backend default URL:
- `http://127.0.0.1:8892`
- AG-UI endpoint: `POST http://127.0.0.1:8892/agent`
## 2) Install Frontend Packages (npm)
From the `python/` directory (where Step 1 left you):
```bash
cd samples/05-end-to-end/ag_ui_single_agent/frontend
npm install
```
## 3) Run Frontend Locally
```bash
npm run dev
```
Frontend default URL:
- `http://127.0.0.1:5173`
If you changed backend host/port, run with:
```bash
VITE_BACKEND_URL=http://127.0.0.1:8892 npm run dev
```
## 4) Demo Flow to Verify
1. Click one of the starter prompts (or type your own message).
2. Watch the assistant response stream in token by token.
3. Send a follow-up that depends on the previous turn (for example: "summarize what you just told me").
The client only sends the newest message plus the `thread_id`; the server replays the stored history.
4. Click **New Thread** to start a fresh conversation (a new `thread_id`).
## Conversation History
The client only ever sends the **newest message** plus a `thread_id`. The backend retains history **server-side**,
keyed by that `thread_id`, using an `InMemoryAGUIThreadSnapshotStore`. Because an AG-UI thread id is not an
authorization boundary, a `snapshot_scope_resolver` is required whenever a snapshot store is configured; this
single-tenant demo maps every request to one shared `"demo"` scope.
The in-memory store is process-local and not durable. Swap in your own `AGUIThreadSnapshotStore` implementation
(and a real scope resolver) for production.
## What This Validates
- `add_agent_framework_fastapi_endpoint(...)` with a plain `Agent` (no `AgentFrameworkWorkflow` wrapper)
- Streaming assistant text via `TEXT_MESSAGE_START` / `TEXT_MESSAGE_CONTENT` / `TEXT_MESSAGE_END` AG-UI events
- Server-side conversation history keyed by `thread_id` via a snapshot store
@@ -0,0 +1,105 @@
# Copyright (c) Microsoft. All rights reserved.
"""AG-UI single-agent demo backend.
This is the simplest possible AG-UI integration: a single chat agent with no
tools and no context providers, exposed over the AG-UI protocol.
Run this server and pair it with the frontend in `../frontend`.
"""
from __future__ import annotations
import logging
import os
import uvicorn
from agent_framework import Agent
from agent_framework.ag_ui import (
InMemoryAGUIThreadSnapshotStore,
add_agent_framework_fastapi_endpoint,
)
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
load_dotenv()
logger = logging.getLogger(__name__)
def create_agent() -> Agent:
"""Create a single chat agent with no tools and no context providers."""
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
return Agent(
id="assistant",
name="assistant",
instructions="You are a helpful, concise assistant. Answer the user's questions directly.",
client=client,
)
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
app = FastAPI(title="AG-UI Single Agent Demo")
cors_origins = [
origin.strip() for origin in os.getenv("CORS_ORIGINS", "http://127.0.0.1:5173").split(",") if origin.strip()
]
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
add_agent_framework_fastapi_endpoint(
app=app,
agent=create_agent(),
path="/agent",
# Persist conversation history server-side, keyed by thread_id, so the
# client only ever sends the newest message plus its thread_id.
snapshot_store=InMemoryAGUIThreadSnapshotStore(),
# AG-UI thread ids are not an authorization boundary, so a scope is required
# when a snapshot store is configured. This demo is single-tenant, so every
# request maps to one shared scope.
snapshot_scope_resolver=lambda _request: "demo",
)
@app.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}
return app
app = create_app()
def main() -> None:
"""Run the AG-UI single-agent demo backend."""
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
host = os.getenv("HOST", "127.0.0.1")
port = int(os.getenv("PORT", "8892"))
print(f"AG-UI single-agent demo backend running at http://{host}:{port}")
print("AG-UI endpoint: POST /agent")
uvicorn.run(app, host=host, port=port)
if __name__ == "__main__":
main()
@@ -0,0 +1,7 @@
# dependencies
/node_modules
# build artifacts
*.tsbuildinfo
vite.config.js
vite.config.d.ts
@@ -0,0 +1,13 @@
<!doctype html>
<!-- Copyright (c) Microsoft. All rights reserved. -->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AG-UI Single Agent Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
{
"name": "ag-ui-single-agent-demo-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.10.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^6.0.2",
"typescript": "^5.5.4",
"vite": "^8.0.16"
}
}
@@ -0,0 +1,281 @@
// Copyright (c) Microsoft. All rights reserved.
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
type AgUiEvent = Record<string, unknown> & { type: string };
interface ChatMessage {
id: string;
role: "assistant" | "user" | "system";
text: string;
}
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "http://127.0.0.1:8892";
const ENDPOINT = `${BACKEND_URL}/agent`;
const STARTER_PROMPTS = [
"Explain the AG-UI protocol in two sentences.",
"Give me three tips for writing clear commit messages.",
];
function randomId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `id-${Math.random().toString(16).slice(2)}`;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function safeParseJson(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return null;
}
}
export default function App() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState("");
const [isRunning, setIsRunning] = useState(false);
const [statusText, setStatusText] = useState("Ready");
const threadIdRef = useRef<string>(randomId());
const streamingMessageIdRef = useRef<string | null>(null);
const transcriptRef = useRef<HTMLDivElement | null>(null);
const canSend = useMemo(() => draft.trim().length > 0 && !isRunning, [draft, isRunning]);
useEffect(() => {
const node = transcriptRef.current;
if (node) {
node.scrollTop = node.scrollHeight;
}
}, [messages]);
const pushMessage = (message: ChatMessage): void => {
setMessages((prev) => [...prev, message]);
};
const appendToStreamingMessage = (messageId: string, delta: string): void => {
setMessages((prev) => {
const existing = prev.find((message) => message.id === messageId);
if (existing) {
return prev.map((message) =>
message.id === messageId ? { ...message, text: `${message.text}${delta}` } : message,
);
}
return [...prev, { id: messageId, role: "assistant", text: delta }];
});
};
const handleEvent = (event: AgUiEvent): void => {
switch (event.type) {
case "RUN_STARTED":
setStatusText("Thinking");
break;
case "TEXT_MESSAGE_START": {
const messageId = typeof event.message_id === "string" ? event.message_id : randomId();
streamingMessageIdRef.current = messageId;
break;
}
case "TEXT_MESSAGE_CONTENT": {
const messageId =
typeof event.message_id === "string" ? event.message_id : streamingMessageIdRef.current ?? randomId();
const delta = typeof event.delta === "string" ? event.delta : "";
if (delta.length > 0) {
setStatusText("Responding");
appendToStreamingMessage(messageId, delta);
}
break;
}
case "TEXT_MESSAGE_END":
streamingMessageIdRef.current = null;
break;
case "RUN_FINISHED":
setStatusText("Ready");
setIsRunning(false);
break;
case "RUN_ERROR": {
const errorText = typeof event.message === "string" ? event.message : "The run failed.";
pushMessage({ id: randomId(), role: "system", text: `Error: ${errorText}` });
setStatusText("Error");
setIsRunning(false);
break;
}
default:
break;
}
};
const streamRun = async (body: Record<string, unknown>): Promise<void> => {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify(body),
});
if (!response.ok || !response.body) {
throw new Error(`Request failed: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
const processSseChunk = (rawChunk: string): void => {
const dataLines = rawChunk
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim());
if (dataLines.length === 0) {
return;
}
const parsed = safeParseJson(dataLines.join("\n"));
if (isObject(parsed) && typeof parsed.type === "string") {
handleEvent(parsed as AgUiEvent);
}
};
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
while (true) {
const boundaryIndex = buffer.indexOf("\n\n");
if (boundaryIndex < 0) {
break;
}
const rawEvent = buffer.slice(0, boundaryIndex);
buffer = buffer.slice(boundaryIndex + 2);
processSseChunk(rawEvent);
}
}
const tail = buffer.trim();
if (tail.length > 0) {
processSseChunk(tail);
}
};
const sendMessage = async (text: string): Promise<void> => {
const trimmed = text.trim();
if (trimmed.length === 0 || isRunning) {
return;
}
pushMessage({ id: randomId(), role: "user", text: trimmed });
setDraft("");
setIsRunning(true);
setStatusText("Connecting");
streamingMessageIdRef.current = null;
try {
await streamRun({
thread_id: threadIdRef.current,
run_id: randomId(),
messages: [{ role: "user", content: trimmed }],
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
pushMessage({ id: randomId(), role: "system", text: `Network error: ${message}` });
setStatusText("Network error");
setIsRunning(false);
}
};
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
void sendMessage(draft);
};
const startNewThread = (): void => {
threadIdRef.current = randomId();
streamingMessageIdRef.current = null;
setMessages([]);
setDraft("");
setStatusText("Ready");
setIsRunning(false);
};
return (
<div className="page-shell">
<header className="hero">
<div>
<p className="eyebrow">Agent Framework · AG-UI</p>
<h1>Single Agent Chat</h1>
<p className="subtitle">
The simplest AG-UI integration: one chat agent with no tools and no context providers, streamed to a React
client over Server-Sent Events.
</p>
</div>
<div className="status-pill" data-running={isRunning}>
<span>Status</span>
<strong>{statusText}</strong>
</div>
</header>
<main className="card chat-card">
<div className="chat-toolbar">
<h2>Conversation</h2>
<button type="button" className="ghost-button" onClick={startNewThread} disabled={isRunning}>
New Thread
</button>
</div>
<div className="transcript" ref={transcriptRef}>
{messages.length === 0 ? (
<div className="empty-state">
<p>Start the conversation with a prompt:</p>
<div className="starter-prompts">
{STARTER_PROMPTS.map((prompt) => (
<button
key={prompt}
type="button"
className="starter-prompt"
onClick={() => void sendMessage(prompt)}
disabled={isRunning}
>
{prompt}
</button>
))}
</div>
</div>
) : (
messages.map((message) => (
<div key={message.id} className={`bubble bubble-${message.role}`}>
<span className="bubble-role">{message.role}</span>
<p>{message.text}</p>
</div>
))
)}
</div>
<form className="composer" onSubmit={handleSubmit}>
<input
type="text"
value={draft}
placeholder="Send a message..."
onChange={(event) => setDraft(event.target.value)}
disabled={isRunning}
/>
<button type="submit" className="send-button" disabled={!canSend}>
Send
</button>
</form>
</main>
</div>
);
}
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
@@ -0,0 +1,259 @@
/* Copyright (c) Microsoft. All rights reserved. */
:root {
--page-bg: #edf4f8;
--panel-bg: #fdfdfd;
--ink: #132534;
--muted: #607487;
--line: #c6d6e2;
--teal: #1f9d8b;
--teal-dark: #11756a;
--shadow: 0 20px 45px rgb(15 35 51 / 14%);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "IBM Plex Sans", "Avenir Next", "Helvetica Neue", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at 12% 8%, rgb(31 157 139 / 20%) 0%, transparent 28%),
radial-gradient(circle at 88% 18%, rgb(255 154 60 / 20%) 0%, transparent 30%),
linear-gradient(150deg, #eff6fa 0%, #dceaf3 46%, #e7f1f6 100%);
}
.page-shell {
min-height: 100vh;
max-width: 860px;
margin: 0 auto;
padding: 28px;
animation: fade-in 320ms ease-out;
}
.hero {
display: flex;
gap: 20px;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 24px;
}
.eyebrow {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.72rem;
color: var(--teal-dark);
font-weight: 700;
}
.hero h1 {
margin: 6px 0 8px;
font-size: clamp(1.6rem, 2.8vw, 2.4rem);
line-height: 1.15;
}
.subtitle {
margin: 0;
max-width: 60ch;
color: var(--muted);
line-height: 1.45;
}
.status-pill {
border: 1px solid var(--line);
border-radius: 999px;
padding: 10px 16px;
background: #fff;
display: flex;
flex-direction: column;
min-width: 150px;
box-shadow: 0 8px 20px rgb(19 37 52 / 8%);
}
.status-pill span {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.status-pill strong {
font-size: 1rem;
}
.status-pill[data-running="true"] {
border-color: var(--teal);
}
.card {
background: var(--panel-bg);
border: 1px solid var(--line);
border-radius: 18px;
box-shadow: var(--shadow);
padding: 18px;
}
.chat-card {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 60vh;
}
.chat-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
}
.chat-toolbar h2 {
margin: 0;
font-size: 1.1rem;
}
.ghost-button {
border: 1px solid var(--line);
background: #fff;
color: var(--teal-dark);
border-radius: 999px;
padding: 6px 14px;
font-weight: 600;
cursor: pointer;
}
.ghost-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.transcript {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding: 6px 2px;
max-height: 52vh;
}
.empty-state {
color: var(--muted);
display: grid;
gap: 12px;
}
.starter-prompts {
display: grid;
gap: 10px;
}
.starter-prompt {
text-align: left;
border: 1px dashed var(--line);
background: #f6fafc;
border-radius: 12px;
padding: 12px 14px;
color: var(--ink);
cursor: pointer;
}
.starter-prompt:hover:not(:disabled) {
border-color: var(--teal);
}
.starter-prompt:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.bubble {
border-radius: 14px;
padding: 10px 14px;
max-width: 82%;
border: 1px solid var(--line);
background: #fff;
}
.bubble p {
margin: 4px 0 0;
white-space: pre-wrap;
line-height: 1.45;
}
.bubble-role {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.bubble-user {
align-self: flex-end;
background: var(--teal);
border-color: var(--teal-dark);
color: #fff;
}
.bubble-user .bubble-role {
color: rgb(255 255 255 / 80%);
}
.bubble-assistant {
align-self: flex-start;
}
.bubble-system {
align-self: center;
background: #fff4e6;
border-color: #ffcf99;
color: #8a5200;
max-width: 100%;
}
.composer {
display: flex;
gap: 10px;
}
.composer input {
flex: 1;
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px 14px;
font-size: 1rem;
}
.composer input:focus {
outline: none;
border-color: var(--teal);
}
.send-button {
border: none;
background: var(--teal);
color: #fff;
border-radius: 12px;
padding: 12px 22px;
font-weight: 700;
cursor: pointer;
}
.send-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@@ -0,0 +1,3 @@
// Copyright (c) Microsoft. All rights reserved.
/// <reference types="vite/client" />
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2020",
"lib": ["ES2020"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"types": ["node"],
"skipLibCheck": true
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
host: "127.0.0.1",
port: 5173,
},
});
@@ -0,0 +1,6 @@
{
"name": "ag_ui_single_agent",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}