From 48c2af3bfc085788d1ffe0c45f39cfe656afc55d Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Thu, 26 Mar 2026 12:38:14 +0100 Subject: [PATCH 1/2] feat: agent plugin --- .../client/src/routeTree.gen.ts | 21 + .../client/src/routes/__root.tsx | 8 + .../client/src/routes/agent.route.tsx | 434 ++++++++++++ .../client/src/routes/index.tsx | 19 + apps/dev-playground/package.json | 2 + apps/dev-playground/server/index.ts | 37 +- .../docs/api/appkit/Interface.AgentAdapter.md | 20 + docs/docs/api/appkit/Interface.AgentInput.md | 33 + .../api/appkit/Interface.AgentRunContext.md | 28 + .../appkit/Interface.AgentToolDefinition.md | 33 + docs/docs/api/appkit/Interface.Message.md | 49 ++ docs/docs/api/appkit/Interface.Thread.md | 41 ++ docs/docs/api/appkit/Interface.ThreadStore.md | 98 +++ .../docs/api/appkit/Interface.ToolProvider.md | 36 + docs/docs/api/appkit/TypeAlias.AgentEvent.md | 38 ++ docs/docs/api/appkit/index.md | 9 + docs/docs/api/appkit/typedoc-sidebar.ts | 45 ++ packages/appkit/package.json | 31 + packages/appkit/src/agents/databricks.ts | 632 ++++++++++++++++++ packages/appkit/src/agents/langchain.ts | 197 ++++++ .../src/agents/tests/databricks.test.ts | 406 +++++++++++ .../appkit/src/agents/tests/langchain.test.ts | 176 +++++ .../appkit/src/agents/tests/vercel-ai.test.ts | 190 ++++++ packages/appkit/src/agents/vercel-ai.ts | 129 ++++ packages/appkit/src/index.ts | 11 +- packages/appkit/src/plugins/agent/agent.ts | 398 +++++++++++ packages/appkit/src/plugins/agent/defaults.ts | 12 + packages/appkit/src/plugins/agent/index.ts | 3 + .../appkit/src/plugins/agent/manifest.json | 10 + .../src/plugins/agent/tests/agent.test.ts | 149 +++++ .../plugins/agent/tests/thread-store.test.ts | 138 ++++ .../appkit/src/plugins/agent/thread-store.ts | 59 ++ packages/appkit/src/plugins/agent/types.ts | 34 + .../appkit/src/plugins/analytics/analytics.ts | 38 +- packages/appkit/src/plugins/files/plugin.ts | 140 +++- packages/appkit/src/plugins/genie/genie.ts | 93 ++- packages/appkit/src/plugins/index.ts | 1 + .../appkit/src/plugins/lakebase/lakebase.ts | 41 +- packages/appkit/tsdown.config.ts | 7 +- packages/shared/src/agent.ts | 112 ++++ packages/shared/src/index.ts | 1 + template/appkit.plugins.json | 10 + 42 files changed, 3960 insertions(+), 9 deletions(-) create mode 100644 apps/dev-playground/client/src/routes/agent.route.tsx create mode 100644 docs/docs/api/appkit/Interface.AgentAdapter.md create mode 100644 docs/docs/api/appkit/Interface.AgentInput.md create mode 100644 docs/docs/api/appkit/Interface.AgentRunContext.md create mode 100644 docs/docs/api/appkit/Interface.AgentToolDefinition.md create mode 100644 docs/docs/api/appkit/Interface.Message.md create mode 100644 docs/docs/api/appkit/Interface.Thread.md create mode 100644 docs/docs/api/appkit/Interface.ThreadStore.md create mode 100644 docs/docs/api/appkit/Interface.ToolProvider.md create mode 100644 docs/docs/api/appkit/TypeAlias.AgentEvent.md create mode 100644 packages/appkit/src/agents/databricks.ts create mode 100644 packages/appkit/src/agents/langchain.ts create mode 100644 packages/appkit/src/agents/tests/databricks.test.ts create mode 100644 packages/appkit/src/agents/tests/langchain.test.ts create mode 100644 packages/appkit/src/agents/tests/vercel-ai.test.ts create mode 100644 packages/appkit/src/agents/vercel-ai.ts create mode 100644 packages/appkit/src/plugins/agent/agent.ts create mode 100644 packages/appkit/src/plugins/agent/defaults.ts create mode 100644 packages/appkit/src/plugins/agent/index.ts create mode 100644 packages/appkit/src/plugins/agent/manifest.json create mode 100644 packages/appkit/src/plugins/agent/tests/agent.test.ts create mode 100644 packages/appkit/src/plugins/agent/tests/thread-store.test.ts create mode 100644 packages/appkit/src/plugins/agent/thread-store.ts create mode 100644 packages/appkit/src/plugins/agent/types.ts create mode 100644 packages/shared/src/agent.ts diff --git a/apps/dev-playground/client/src/routeTree.gen.ts b/apps/dev-playground/client/src/routeTree.gen.ts index c4c38d14..c3b807f5 100644 --- a/apps/dev-playground/client/src/routeTree.gen.ts +++ b/apps/dev-playground/client/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as DataVisualizationRouteRouteImport } from './routes/data-visual import { Route as ChartInferenceRouteRouteImport } from './routes/chart-inference.route' import { Route as ArrowAnalyticsRouteRouteImport } from './routes/arrow-analytics.route' import { Route as AnalyticsRouteRouteImport } from './routes/analytics.route' +import { Route as AgentRouteRouteImport } from './routes/agent.route' import { Route as IndexRouteImport } from './routes/index' const TypeSafetyRouteRoute = TypeSafetyRouteRouteImport.update({ @@ -77,6 +78,11 @@ const AnalyticsRouteRoute = AnalyticsRouteRouteImport.update({ path: '/analytics', getParentRoute: () => rootRouteImport, } as any) +const AgentRouteRoute = AgentRouteRouteImport.update({ + id: '/agent', + path: '/agent', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -85,6 +91,7 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/agent': typeof AgentRouteRoute '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/chart-inference': typeof ChartInferenceRouteRoute @@ -99,6 +106,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/agent': typeof AgentRouteRoute '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/chart-inference': typeof ChartInferenceRouteRoute @@ -114,6 +122,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/agent': typeof AgentRouteRoute '/analytics': typeof AnalyticsRouteRoute '/arrow-analytics': typeof ArrowAnalyticsRouteRoute '/chart-inference': typeof ChartInferenceRouteRoute @@ -130,6 +139,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/agent' | '/analytics' | '/arrow-analytics' | '/chart-inference' @@ -144,6 +154,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/agent' | '/analytics' | '/arrow-analytics' | '/chart-inference' @@ -158,6 +169,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/agent' | '/analytics' | '/arrow-analytics' | '/chart-inference' @@ -173,6 +185,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + AgentRouteRoute: typeof AgentRouteRoute AnalyticsRouteRoute: typeof AnalyticsRouteRoute ArrowAnalyticsRouteRoute: typeof ArrowAnalyticsRouteRoute ChartInferenceRouteRoute: typeof ChartInferenceRouteRoute @@ -265,6 +278,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AnalyticsRouteRouteImport parentRoute: typeof rootRouteImport } + '/agent': { + id: '/agent' + path: '/agent' + fullPath: '/agent' + preLoaderRoute: typeof AgentRouteRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -277,6 +297,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + AgentRouteRoute: AgentRouteRoute, AnalyticsRouteRoute: AnalyticsRouteRoute, ArrowAnalyticsRouteRoute: ArrowAnalyticsRouteRoute, ChartInferenceRouteRoute: ChartInferenceRouteRoute, diff --git a/apps/dev-playground/client/src/routes/__root.tsx b/apps/dev-playground/client/src/routes/__root.tsx index 5cf74ce3..0cfee693 100644 --- a/apps/dev-playground/client/src/routes/__root.tsx +++ b/apps/dev-playground/client/src/routes/__root.tsx @@ -104,6 +104,14 @@ function RootComponent() { Files + + + diff --git a/apps/dev-playground/client/src/routes/agent.route.tsx b/apps/dev-playground/client/src/routes/agent.route.tsx new file mode 100644 index 00000000..cdebfc54 --- /dev/null +++ b/apps/dev-playground/client/src/routes/agent.route.tsx @@ -0,0 +1,434 @@ +import { Button } from "@databricks/appkit-ui/react"; +import { createFileRoute } from "@tanstack/react-router"; +import { useCallback, useEffect, useRef, useState } from "react"; + +export const Route = createFileRoute("/agent")({ + component: AgentRoute, +}); + +interface AgentEvent { + type: string; + content?: string; + callId?: string; + name?: string; + args?: unknown; + result?: unknown; + error?: string; + status?: string; + data?: Record; +} + +interface ChatMessage { + id: number; + role: "user" | "assistant"; + content: string; +} + +function useAutocomplete(enabled: boolean) { + const [suggestion, setSuggestion] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const abortRef = useRef(null); + const timerRef = useRef | null>(null); + + const requestSuggestion = useCallback( + (text: string) => { + setSuggestion(""); + + if (timerRef.current) clearTimeout(timerRef.current); + if (abortRef.current) abortRef.current.abort(); + + if (!text.trim() || text.length < 3 || !enabled) { + return; + } + + timerRef.current = setTimeout(async () => { + const controller = new AbortController(); + abortRef.current = controller; + setIsLoading(true); + + try { + const response = await fetch("/api/agent/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: text, agent: "autocomplete" }), + signal: controller.signal, + }); + + if (!response.ok || !response.body) return; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let result = ""; + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (!data || data === "[DONE]") continue; + try { + const event = JSON.parse(data); + if (event.type === "message_delta" && event.content) { + result += event.content; + setSuggestion(result); + } + } catch { + /* skip */ + } + } + } + } catch { + /* aborted or failed */ + } finally { + setIsLoading(false); + } + }, 500); + }, + [enabled], + ); + + const clear = useCallback(() => { + setSuggestion(""); + if (timerRef.current) clearTimeout(timerRef.current); + if (abortRef.current) abortRef.current.abort(); + }, []); + + return { + suggestion, + isLoading: isLoading && !suggestion, + requestSuggestion, + clear, + }; +} + +function AgentRoute() { + const [messages, setMessages] = useState([]); + const [events, setEvents] = useState([]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [threadId, setThreadId] = useState(null); + const [hasAutocomplete, setHasAutocomplete] = useState(false); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const msgIdCounter = useRef(0); + + const { + suggestion, + isLoading: isAutocompleting, + requestSuggestion, + clear: clearSuggestion, + } = useAutocomplete(hasAutocomplete); + + useEffect(() => { + fetch("/api/agent/agents") + .then((r) => r.json()) + .then((data) => { + setHasAutocomplete((data.agents ?? []).includes("autocomplete")); + }) + .catch(() => {}); + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const sendMessage = useCallback(async () => { + if (!input.trim() || isLoading) return; + + clearSuggestion(); + const userMessage = input.trim(); + setInput(""); + setMessages((prev) => [ + ...prev, + { id: ++msgIdCounter.current, role: "user", content: userMessage }, + ]); + setEvents([]); + setIsLoading(true); + + try { + const response = await fetch("/api/agent/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: userMessage, + ...(threadId && { threadId }), + }), + }); + + if (!response.ok) { + const error = await response.json(); + setMessages((prev) => [ + ...prev, + { + id: ++msgIdCounter.current, + role: "assistant", + content: `Error: ${error.error}`, + }, + ]); + return; + } + + const reader = response.body?.getReader(); + if (!reader) return; + + const decoder = new TextDecoder(); + let assistantContent = ""; + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (!data || data === "[DONE]") continue; + + try { + const event: AgentEvent = JSON.parse(data); + setEvents((prev) => [...prev, event]); + + if (event.type === "metadata" && event.data?.threadId) { + setThreadId(event.data.threadId as string); + } + + if (event.type === "message_delta" && event.content) { + assistantContent += event.content; + setMessages((prev) => { + const updated = [...prev]; + const last = updated[updated.length - 1]; + if (last?.role === "assistant") { + updated[updated.length - 1] = { + ...last, + content: assistantContent, + }; + } else { + updated.push({ + id: ++msgIdCounter.current, + role: "assistant", + content: assistantContent, + }); + } + return updated; + }); + } + } catch { + // skip malformed events + } + } + } + } catch (err) { + setMessages((prev) => [ + ...prev, + { + id: ++msgIdCounter.current, + role: "assistant", + content: `Error: ${err instanceof Error ? err.message : "Unknown error"}`, + }, + ]); + } finally { + setIsLoading(false); + } + }, [input, isLoading, threadId, clearSuggestion]); + + const handleInputChange = (value: string) => { + setInput(value); + requestSuggestion(value); + }; + + const acceptSuggestion = () => { + if (!suggestion) return; + const newValue = input + suggestion; + setInput(newValue); + clearSuggestion(); + inputRef.current?.focus(); + }; + + return ( +
+
+
+
+

Agent Chat

+

+ AI agent with auto-discovered tools from all AppKit plugins. + {threadId && ( + + Thread: {threadId.slice(0, 8)}... + + )} +

+
+ {hasAutocomplete && ( + + Autocomplete enabled + + )} +
+ +
+
+
+ {messages.length === 0 && ( +
+

+ Send a message to start a conversation +

+

+ The agent can use analytics, files, genie, and lakebase + tools. + {hasAutocomplete && " Start typing for inline suggestions."} +

+
+ )} + + {messages.map((msg) => ( +
+
+

{msg.content}

+
+
+ ))} + + {isLoading && messages[messages.length - 1]?.role === "user" && ( +
+
+

+ Thinking... +

+
+
+ )} + +
+
+ +
+ {hasAutocomplete && (suggestion || isAutocompleting) && ( +
+ {isAutocompleting && ( + Thinking... + )} + {suggestion && ( + + Press{" "} + + Tab + {" "} + to accept suggestion + + )} +
+ )} +
{ + e.preventDefault(); + sendMessage(); + }} + className="flex gap-2" + > +
+
+ {input} + + {suggestion} + +
+