From 9a39eabc41e0628f75a51fa3320dfc0c73e2bb69 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 21 Apr 2026 19:58:04 +0200 Subject: [PATCH 1/2] feat(appkit): reference agent-app, dev-playground chat UI, docs, and template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final layer of the agents feature stack. Everything needed to exercise, demonstrate, and learn the feature. `apps/agent-app/` — a standalone app purpose-built around the agents feature. Ships with: - `server.ts` — full example of code-defined agents via `fromPlugin`: ```ts const support = createAgent({ instructions: "…", tools: { ...fromPlugin(analytics), ...fromPlugin(files), get_weather, "mcp.vector-search": mcpServer("vector-search", "https://…"), }, }); await createApp({ plugins: [server({ port }), analytics(), files(), agents({ agents: { support } })], }); ``` - `config/agents/assistant.md` — markdown-driven agent alongside the code-defined one, showing the asymmetric auto-inherit default. - Vite + React 19 + TailwindCSS frontend with a chat UI. - Databricks deployment config (`databricks.yml`, `app.yaml`) and deploy scripts. `apps/dev-playground/client/src/routes/agent.route.tsx` — chat UI with inline autocomplete (hits the `autocomplete` markdown agent) and a full threaded conversation panel (hits the default agent). `apps/dev-playground/server/index.ts` — adds a code-defined `helper` agent using `fromPlugin(analytics)` alongside the markdown-driven `autocomplete` agent in `config/agents/`. Exercises the mixed-style setup (markdown + code) against the same plugin list. `apps/dev-playground/config/agents/*.md` — both agents defined with valid YAML frontmatter. `docs/docs/plugins/agents.md` — progressive five-level guide: 1. Drop a markdown file → it just works. 2. Scope tools via `toolkits:` / `tools:` frontmatter. 3. Code-defined agents with `fromPlugin()`. 4. Sub-agents. 5. Standalone `runAgent()` (no `createApp` or HTTP). Plus a configuration reference, runtime API reference, and frontmatter schema table. `docs/docs/api/appkit/` — regenerated typedoc for the new public surface (fromPlugin, runAgent, AgentDefinition, AgentsPluginConfig, ToolkitEntry, ToolkitOptions, all adapter types, and the agents plugin factory). `template/appkit.plugins.json` — adds the `agent` plugin entry so `npx @databricks/appkit init --features agent` scaffolds the plugin correctly. - Full appkit vitest suite: 1311 tests passing - Typecheck clean across all 8 workspace projects - `pnpm docs:build` clean (no broken links) - `pnpm --filter=@databricks/appkit build:package` clean, publint clean Signed-off-by: MarioCadenas Documents the new `mcp` configuration block and the rules it enforces: same-origin-only by default, explicit `trustedHosts` for external MCP servers, plaintext `http://` refused outside localhost-in-dev, and DNS-level blocking of private / link-local IP ranges (covers cloud metadata services). See PR #302 for the policy implementation and PR #304 for the `AgentsPluginConfig.mcp` wiring. Signed-off-by: MarioCadenas - `docs/docs/plugins/agents.md`: new "SQL agent tools" subsection covering `analytics.query` readOnly enforcement, `lakebase.query` opt-in via `exposeAsAgentTool`, and the approval flow. New "Human-in-the-loop approval for destructive tools" subsection documents the config, SSE event shape, and `POST /chat/approve` contract. - `apps/agent-app`: approval-card component rendered inline in the chat stream whenever an `appkit.approval_pending` event arrives. Destructive badge + Approve/Deny buttons POST to `/api/agent/approve` with the carried `streamId`/`approvalId`. - `apps/dev-playground/client`: matching approval-card on the agent route, using the existing appkit-ui `Button` component and Tailwind utility classes. Signed-off-by: MarioCadenas Updates `docs/docs/plugins/agents.md` to document the new two-key auto-inherit model introduced in PR #302 (per-tool `autoInheritable` flag) and PR #304 (safe-by-default `autoInheritTools: { file: false, code: false }`). Adds an "Auto-inherit posture" subsection explaining that the developer must opt into `autoInheritTools` AND the plugin author must mark each tool `autoInheritable: true` for a tool to spread without explicit wiring. Includes a table documenting the `autoInheritable` marking on each core plugin tool, plus an example of the setup-time audit log so operators can see exactly what's inherited vs. skipped. Signed-off-by: MarioCadenas - **Reference app no longer ships hardcoded dogfood URLs.** The three `https://e2-dogfood.staging.cloud.databricks.com/...` and `https://mario-mcp-hello-*.staging.aws.databricksapps.com/...` MCP URLs in `apps/agent-app/server.ts` are replaced with optional env-driven `VECTOR_SEARCH_MCP_URL` / `CUSTOM_MCP_URL` config. When set, their hostnames are auto-added to `agents({ mcp: { trustedHosts } })`. `.env.example` uses placeholder values the reader can replace instead of another team's workspace. - **`appkit.agent` → `appkit.agents` in the reference app.** The prior `appkit.agent as { list, getDefault }` cast papered over the plugin-name mismatch fixed in PR #304. The runtime key now matches the docs, the manifest, and the factory name; the cast is gone. - **Auto-inherit opt-in added to the reference config.** Since the defaults flipped to `{ file: false, code: false }` (PR #304, S-3), the reference now explicitly enables `autoInheritTools: { file: true }` so the markdown agents that ship alongside the code-defined one still pick up the analytics / files read-only tools. This is the pattern a real deployment should follow — opt in deliberately. Signed-off-by: MarioCadenas - `apps/dev-playground/config/agents/autocomplete.md` sets `ephemeral: true`. Each debounced autocomplete keystroke no longer leaves an orphan thread in `InMemoryThreadStore` — the server now deletes the thread in the stream's `finally` (PR #304). Closes R1 from the MVP re-review. - `docs/docs/plugins/agents.md` documents the new `ephemeral` frontmatter key alongside the other AgentDefinition knobs. Signed-off-by: MarioCadenas Documents the MVP resource caps landed in PR #304: the static request-body caps (enforced by the Zod schemas) and the three configurable runtime limits (`maxConcurrentStreamsPerUser`, `maxToolCalls`, `maxSubAgentDepth`). Includes the config-block shape in the main reference and a new "Resource limits" subsection under the Configuration section explaining the intent and per-user semantics of each cap. Signed-off-by: MarioCadenas --- apps/agent-app/.env.example | 16 + apps/agent-app/.gitignore | 3 + apps/agent-app/app.yaml | 8 + apps/agent-app/config/agents/assistant.md | 12 + apps/agent-app/databricks.yml | 50 ++ apps/agent-app/index.html | 12 + apps/agent-app/package.json | 40 ++ apps/agent-app/postcss.config.js | 6 + apps/agent-app/server.ts | 92 +++ apps/agent-app/src/App.css | 440 ++++++++++++++ apps/agent-app/src/App.tsx | 405 +++++++++++++ .../src/components/theme-selector.tsx | 135 +++++ apps/agent-app/src/index.css | 1 + apps/agent-app/src/main.tsx | 15 + apps/agent-app/tailwind.config.ts | 11 + apps/agent-app/tsconfig.app.json | 24 + apps/agent-app/tsconfig.json | 7 + apps/agent-app/tsconfig.node.json | 22 + apps/agent-app/vite.config.ts | 31 + .../client/src/routes/__root.tsx | 8 + .../client/src/routes/agent.route.tsx | 567 ++++++++++++++++++ .../client/src/routes/index.tsx | 11 +- .../dev-playground/config/agents/assistant.md | 6 + .../config/agents/autocomplete.md | 7 + apps/dev-playground/server/index.ts | 25 +- docs/docs/api/appkit/Class.Plugin.md | 44 ++ docs/docs/api/appkit/Function.createAgent.md | 35 ++ docs/docs/api/appkit/Function.fromPlugin.md | 50 ++ .../api/appkit/Function.isFromPluginMarker.md | 17 + .../api/appkit/Function.isFunctionTool.md | 15 + docs/docs/api/appkit/Function.isHostedTool.md | 15 + .../api/appkit/Function.isToolkitEntry.md | 18 + .../api/appkit/Function.loadAgentFromFile.md | 19 + .../api/appkit/Function.loadAgentsFromDir.md | 20 + docs/docs/api/appkit/Function.mcpServer.md | 26 + docs/docs/api/appkit/Function.runAgent.md | 29 + docs/docs/api/appkit/Function.tool.md | 29 + .../docs/api/appkit/Interface.AgentAdapter.md | 20 + .../api/appkit/Interface.AgentDefinition.md | 82 +++ docs/docs/api/appkit/Interface.AgentInput.md | 33 + .../api/appkit/Interface.AgentRunContext.md | 28 + .../appkit/Interface.AgentToolDefinition.md | 33 + .../appkit/Interface.AgentsPluginConfig.md | 132 ++++ .../api/appkit/Interface.BasePluginConfig.md | 4 + .../api/appkit/Interface.FromPluginMarker.md | 32 + .../docs/api/appkit/Interface.FunctionTool.md | 59 ++ docs/docs/api/appkit/Interface.Message.md | 49 ++ .../api/appkit/Interface.PromptContext.md | 27 + .../api/appkit/Interface.RunAgentInput.md | 35 ++ .../api/appkit/Interface.RunAgentResult.md | 21 + docs/docs/api/appkit/Interface.Thread.md | 41 ++ docs/docs/api/appkit/Interface.ThreadStore.md | 98 +++ docs/docs/api/appkit/Interface.ToolConfig.md | 49 ++ .../docs/api/appkit/Interface.ToolProvider.md | 36 ++ .../docs/api/appkit/Interface.ToolkitEntry.md | 46 ++ .../api/appkit/Interface.ToolkitOptions.md | 41 ++ docs/docs/api/appkit/TypeAlias.AgentEvent.md | 38 ++ docs/docs/api/appkit/TypeAlias.AgentTool.md | 12 + docs/docs/api/appkit/TypeAlias.AgentTools.md | 14 + .../TypeAlias.BaseSystemPromptOption.md | 8 + docs/docs/api/appkit/TypeAlias.HostedTool.md | 9 + docs/docs/api/appkit/Variable.agents.md | 19 + docs/docs/api/appkit/index.md | 35 ++ docs/docs/api/appkit/typedoc-sidebar.ts | 175 ++++++ docs/docs/plugins/agents.md | 398 ++++++++++++ pnpm-lock.yaml | 336 ++++++++++- template/appkit.plugins.json | 10 + 67 files changed, 4155 insertions(+), 36 deletions(-) create mode 100644 apps/agent-app/.env.example create mode 100644 apps/agent-app/.gitignore create mode 100644 apps/agent-app/app.yaml create mode 100644 apps/agent-app/config/agents/assistant.md create mode 100644 apps/agent-app/databricks.yml create mode 100644 apps/agent-app/index.html create mode 100644 apps/agent-app/package.json create mode 100644 apps/agent-app/postcss.config.js create mode 100644 apps/agent-app/server.ts create mode 100644 apps/agent-app/src/App.css create mode 100644 apps/agent-app/src/App.tsx create mode 100644 apps/agent-app/src/components/theme-selector.tsx create mode 100644 apps/agent-app/src/index.css create mode 100644 apps/agent-app/src/main.tsx create mode 100644 apps/agent-app/tailwind.config.ts create mode 100644 apps/agent-app/tsconfig.app.json create mode 100644 apps/agent-app/tsconfig.json create mode 100644 apps/agent-app/tsconfig.node.json create mode 100644 apps/agent-app/vite.config.ts create mode 100644 apps/dev-playground/client/src/routes/agent.route.tsx create mode 100644 apps/dev-playground/config/agents/assistant.md create mode 100644 apps/dev-playground/config/agents/autocomplete.md create mode 100644 docs/docs/api/appkit/Function.createAgent.md create mode 100644 docs/docs/api/appkit/Function.fromPlugin.md create mode 100644 docs/docs/api/appkit/Function.isFromPluginMarker.md create mode 100644 docs/docs/api/appkit/Function.isFunctionTool.md create mode 100644 docs/docs/api/appkit/Function.isHostedTool.md create mode 100644 docs/docs/api/appkit/Function.isToolkitEntry.md create mode 100644 docs/docs/api/appkit/Function.loadAgentFromFile.md create mode 100644 docs/docs/api/appkit/Function.loadAgentsFromDir.md create mode 100644 docs/docs/api/appkit/Function.mcpServer.md create mode 100644 docs/docs/api/appkit/Function.runAgent.md create mode 100644 docs/docs/api/appkit/Function.tool.md create mode 100644 docs/docs/api/appkit/Interface.AgentAdapter.md create mode 100644 docs/docs/api/appkit/Interface.AgentDefinition.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.AgentsPluginConfig.md create mode 100644 docs/docs/api/appkit/Interface.FromPluginMarker.md create mode 100644 docs/docs/api/appkit/Interface.FunctionTool.md create mode 100644 docs/docs/api/appkit/Interface.Message.md create mode 100644 docs/docs/api/appkit/Interface.PromptContext.md create mode 100644 docs/docs/api/appkit/Interface.RunAgentInput.md create mode 100644 docs/docs/api/appkit/Interface.RunAgentResult.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.ToolConfig.md create mode 100644 docs/docs/api/appkit/Interface.ToolProvider.md create mode 100644 docs/docs/api/appkit/Interface.ToolkitEntry.md create mode 100644 docs/docs/api/appkit/Interface.ToolkitOptions.md create mode 100644 docs/docs/api/appkit/TypeAlias.AgentEvent.md create mode 100644 docs/docs/api/appkit/TypeAlias.AgentTool.md create mode 100644 docs/docs/api/appkit/TypeAlias.AgentTools.md create mode 100644 docs/docs/api/appkit/TypeAlias.BaseSystemPromptOption.md create mode 100644 docs/docs/api/appkit/TypeAlias.HostedTool.md create mode 100644 docs/docs/api/appkit/Variable.agents.md create mode 100644 docs/docs/plugins/agents.md diff --git a/apps/agent-app/.env.example b/apps/agent-app/.env.example new file mode 100644 index 00000000..055bb94c --- /dev/null +++ b/apps/agent-app/.env.example @@ -0,0 +1,16 @@ +# Databricks workspace (auto-injected by platform on deploy) +DATABRICKS_HOST=https://your-workspace.cloud.databricks.com + +# Agent LLM endpoint (Model Serving endpoint name) +DATABRICKS_AGENT_ENDPOINT=databricks-claude-sonnet-4-5 + +# Analytics plugin — SQL warehouse ID +DATABRICKS_WAREHOUSE_ID=your-warehouse-id + +# Files plugin — Volume path (catalog.schema.volume) +DATABRICKS_VOLUME_FILES=/Volumes/your-catalog/your-schema/your-volume + +# Optional: Custom MCP servers the agent can call. When set, the hostname +# is automatically added to agents({ mcp: { trustedHosts } }). +# VECTOR_SEARCH_MCP_URL=https:///api/2.0/mcp/vector-search/// +# CUSTOM_MCP_URL=https:///mcp diff --git a/apps/agent-app/.gitignore b/apps/agent-app/.gitignore new file mode 100644 index 00000000..9c97bbd4 --- /dev/null +++ b/apps/agent-app/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.env diff --git a/apps/agent-app/app.yaml b/apps/agent-app/app.yaml new file mode 100644 index 00000000..215b89ec --- /dev/null +++ b/apps/agent-app/app.yaml @@ -0,0 +1,8 @@ +command: ['node', '--import', 'tsx', 'server.ts'] +env: + - name: DATABRICKS_WAREHOUSE_ID + valueFrom: sql-warehouse + - name: DATABRICKS_AGENT_ENDPOINT + valueFrom: serving-endpoint + - name: DATABRICKS_VOLUME_FILES + valueFrom: volume diff --git a/apps/agent-app/config/agents/assistant.md b/apps/agent-app/config/agents/assistant.md new file mode 100644 index 00000000..bd6e9b7e --- /dev/null +++ b/apps/agent-app/config/agents/assistant.md @@ -0,0 +1,12 @@ +--- +endpoint: databricks-claude-sonnet-4-5 +default: true +--- + +You are a helpful data assistant running on Databricks. + +Use the available tools to query data, browse files, and help users with their analysis. + +When using `analytics.query`, write Databricks SQL. When results are large, summarize the key findings rather than dumping raw data. + +You also have access to additional tools from MCP servers — use them when relevant. diff --git a/apps/agent-app/databricks.yml b/apps/agent-app/databricks.yml new file mode 100644 index 00000000..3ed6e50a --- /dev/null +++ b/apps/agent-app/databricks.yml @@ -0,0 +1,50 @@ +bundle: + name: appkit-agent-app + +variables: + sql_warehouse_id: + description: SQL Warehouse ID for analytics queries + serving_endpoint_name: + description: Model Serving endpoint name for the agent LLM + volume_full_name: + description: "UC Volume full name (e.g. catalog.schema.volume_name)" + +resources: + apps: + agent_app: + name: "appkit-agent-app" + description: "AppKit agent with auto-discovered tools from analytics, files, and genie plugins" + source_code_path: ./ + + user_api_scopes: + - sql + - files.files + - dashboards.genie + + resources: + - name: sql-warehouse + sql_warehouse: + id: ${var.sql_warehouse_id} + permission: CAN_USE + + - name: serving-endpoint + serving_endpoint: + name: ${var.serving_endpoint_name} + permission: CAN_QUERY + + - name: volume + uc_securable: + securable_type: VOLUME + securable_full_name: ${var.volume_full_name} + permission: WRITE_VOLUME + +targets: + dogfood: + default: true + workspace: + host: https://e2-dogfood.staging.cloud.databricks.com + + variables: + sql_warehouse_id: dd43ee29fedd958d + serving_endpoint_name: databricks-claude-sonnet-4-5 + volume_full_name: main.mario.mario-vol diff --git a/apps/agent-app/index.html b/apps/agent-app/index.html new file mode 100644 index 00000000..80e54faf --- /dev/null +++ b/apps/agent-app/index.html @@ -0,0 +1,12 @@ + + + + + + AppKit Agent + + +
+ + + diff --git a/apps/agent-app/package.json b/apps/agent-app/package.json new file mode 100644 index 00000000..ed159ca8 --- /dev/null +++ b/apps/agent-app/package.json @@ -0,0 +1,40 @@ +{ + "name": "agent-app", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "NODE_ENV=development tsx watch server.ts", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@databricks/appkit": "workspace:*", + "@databricks/appkit-ui": "workspace:*", + "@databricks/sdk-experimental": "^0.16.0", + "dotenv": "^16.6.1", + "lucide-react": "^0.511.0", + "react": "19.2.0", + "react-dom": "19.2.0", + "marked": "^15.0.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "4.1.17", + "@types/node": "24.10.1", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.1.1", + "autoprefixer": "10.4.21", + "postcss": "8.5.6", + "tailwindcss": "4.1.17", + "tailwindcss-animate": "1.0.7", + "tw-animate-css": "1.4.0", + "tsx": "4.20.6", + "typescript": "5.9.3", + "vite": "npm:rolldown-vite@7.1.14" + }, + "overrides": { + "vite": "npm:rolldown-vite@7.1.14" + } +} diff --git a/apps/agent-app/postcss.config.js b/apps/agent-app/postcss.config.js new file mode 100644 index 00000000..f69c5d41 --- /dev/null +++ b/apps/agent-app/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + autoprefixer: {}, + }, +}; diff --git a/apps/agent-app/server.ts b/apps/agent-app/server.ts new file mode 100644 index 00000000..b992530c --- /dev/null +++ b/apps/agent-app/server.ts @@ -0,0 +1,92 @@ +import { + agents, + analytics, + createAgent, + createApp, + files, + fromPlugin, + mcpServer, + server, + tool, +} from "@databricks/appkit"; +import { z } from "zod"; + +const port = Number(process.env.DATABRICKS_APP_PORT) || 8003; + +// Shared tool available to any agent that declares `tools: [get_weather]` in +// its markdown frontmatter. +const get_weather = tool({ + name: "get_weather", + description: "Get the current weather for a city", + schema: z.object({ + city: z.string().describe("City name"), + }), + execute: async ({ city }) => `The weather in ${city} is sunny, 22°C`, +}); + +// Code-defined agent. Overrides config/agents/support.md if a file with that +// name exists. Tools here are explicit; defaults are strict (no auto-inherit +// for code-defined agents), so we pull analytics + files in via fromPlugin. +// +// Optional custom MCP servers can be configured by setting env vars and are +// added to the agent only when present. Any https URL configured here must +// also be allowlisted via `agents({ mcp: { trustedHosts: [...] } })` below. +const customMcpServers: Record> = {}; +if (process.env.VECTOR_SEARCH_MCP_URL) { + customMcpServers["mcp.vector-search"] = mcpServer( + "vector-search", + process.env.VECTOR_SEARCH_MCP_URL, + ); +} +if (process.env.CUSTOM_MCP_URL) { + customMcpServers["mcp.custom"] = mcpServer( + "custom", + process.env.CUSTOM_MCP_URL, + ); +} + +const support = createAgent({ + instructions: + "You help customers with data analysis, file browsing, and general questions. " + + "Use the available tools as needed and summarize results concisely.", + tools: { + ...fromPlugin(analytics), + ...fromPlugin(files), + get_weather, + ...customMcpServers, + }, +}); + +const trustedMcpHosts = [ + process.env.VECTOR_SEARCH_MCP_URL, + process.env.CUSTOM_MCP_URL, +] + .filter((u): u is string => typeof u === "string" && u.length > 0) + .map((u) => new URL(u).hostname); + +const appkit = await createApp({ + plugins: [ + server({ port }), + analytics(), + files(), + agents({ + // Ambient tool library referenced by markdown frontmatter `tools: [...]`. + tools: { get_weather }, + // Code-defined agents are merged with markdown agents; code wins on key + // collision. Enable auto-inherit for markdown agents so they pick up + // the read-only tools declared on analytics/files; every auto-inherited + // tool must also carry `autoInheritable: true` on its plugin side. + autoInheritTools: { file: true }, + agents: { support }, + mcp: { + trustedHosts: trustedMcpHosts, + }, + }), + ], +}); + +console.log( + `Agent app running on port ${port}. ` + + `Agents: ${appkit.agents.list().join(", ") || "(none)"}. ` + + `Default: ${appkit.agents.getDefault() ?? "(none)"}.`, +); diff --git a/apps/agent-app/src/App.css b/apps/agent-app/src/App.css new file mode 100644 index 00000000..545b438c --- /dev/null +++ b/apps/agent-app/src/App.css @@ -0,0 +1,440 @@ +:root { + --bg: #fafafa; + --card: #ffffff; + --border: #e5e5e5; + --text: #171717; + --text-muted: #737373; + --text-faint: #a3a3a3; + --primary: #2563eb; + --primary-fg: #ffffff; + --muted: #f5f5f5; + --ring: #93c5fd; + --radius: 10px; + --font: system-ui, -apple-system, sans-serif; + --mono: "SF Mono", "Cascadia Code", "Fira Code", monospace; +} + +:root.dark { + --bg: #0a0a0a; + --card: #171717; + --border: #262626; + --text: #fafafa; + --text-muted: #a3a3a3; + --text-faint: #525252; + --primary: #3b82f6; + --primary-fg: #ffffff; + --muted: #262626; + --ring: #1d4ed8; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font); + background: var(--bg); + color: var(--text); + -webkit-font-smoothing: antialiased; +} + +.app { + min-height: 100vh; +} + +.container { + max-width: 1100px; + margin: 0 auto; + padding: 2.5rem 1.5rem; +} + +.header { + margin-bottom: 1.5rem; + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.header h1 { + font-size: 1.75rem; + font-weight: 700; + letter-spacing: -0.025em; +} + +.subtitle { + color: var(--text-muted); + font-size: 0.875rem; + margin-top: 0.25rem; +} + +.thread-id { + font-family: var(--mono); + font-size: 0.75rem; + opacity: 0.6; +} + +.main-layout { + display: flex; + gap: 1.25rem; + height: 700px; +} + +.chat-panel { + flex: 1; + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--card); + min-width: 0; + overflow: hidden; +} + +.messages { + flex: 1; + overflow-y: auto; + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.empty-state { + text-align: center; + padding: 5rem 1rem; + color: var(--text-muted); +} + +.empty-title { + font-size: 1.1rem; + font-weight: 500; +} + +.empty-sub { + font-size: 0.85rem; + margin-top: 0.5rem; + color: var(--text-faint); +} + +.message-row { + display: flex; +} + +.message-row.user { + justify-content: flex-end; +} + +.message-row.assistant { + justify-content: flex-start; +} + +.bubble { + max-width: 80%; + padding: 0.625rem 0.875rem; + border-radius: var(--radius); + font-size: 0.875rem; + line-height: 1.5; + word-break: break-word; +} + +.bubble.user { + white-space: pre-wrap; + background: var(--primary); + color: var(--primary-fg); + border-bottom-right-radius: 3px; +} + +.bubble.assistant { + background: var(--muted); + color: var(--text); + border-bottom-left-radius: 3px; +} + +.bubble.thinking { + color: var(--text-muted); + animation: pulse 1.5s ease-in-out infinite; +} + +.bubble.approval-card { + border: 1px solid #d96b3a; + background: color-mix(in srgb, #d96b3a 10%, var(--muted)); +} + +.approval-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.approval-badge { + display: inline-block; + padding: 2px 8px; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: #fff; + background: #d96b3a; + border-radius: 3px; +} + +.approval-body { + font-size: 0.9rem; +} + +.approval-args { + margin: 6px 0 0; + padding: 8px; + font-size: 0.8rem; + max-height: 220px; + overflow: auto; + background: var(--bg); + border-radius: 4px; + white-space: pre-wrap; + word-break: break-word; +} + +.approval-actions { + display: flex; + gap: 8px; + margin-top: 10px; + justify-content: flex-end; +} + +.approval-actions button { + padding: 6px 14px; + font-size: 0.85rem; + font-weight: 500; + border-radius: 4px; + border: 1px solid transparent; + cursor: pointer; + transition: + background 0.15s, + border-color 0.15s; +} + +.approval-deny { + background: transparent; + color: var(--text); + border-color: var(--border); +} + +.approval-deny:hover { + background: var(--muted); +} + +.approval-approve { + background: #d96b3a; + color: #fff; +} + +.approval-approve:hover { + background: #c35a2b; +} + +.bubble.assistant > * + * { + margin-top: 0.5em; +} + +.bubble.assistant p { + margin: 0; +} + +.bubble.assistant p + p { + margin-top: 0.4em; +} + +.bubble.assistant code { + font-family: var(--mono); + font-size: 0.8em; + background: color-mix(in srgb, var(--text) 8%, transparent); + padding: 0.15em 0.35em; + border-radius: 4px; +} + +.bubble.assistant pre { + margin: 0.5em 0; + padding: 0.75em; + border-radius: 6px; + background: color-mix(in srgb, var(--text) 6%, transparent); + overflow-x: auto; +} + +.bubble.assistant pre code { + background: none; + padding: 0; + font-size: 0.8em; +} + +.bubble.assistant ul, +.bubble.assistant ol { + margin: 0.4em 0; + padding-left: 1.5em; +} + +.bubble.assistant li { + margin: 0.15em 0; +} + +.bubble.assistant h1, +.bubble.assistant h2, +.bubble.assistant h3 { + font-weight: 600; +} + +.bubble.assistant h1 { + font-size: 1.1em; +} +.bubble.assistant h2 { + font-size: 1em; +} +.bubble.assistant h3 { + font-size: 0.95em; +} + +.bubble.assistant blockquote { + margin: 0.4em 0; + padding-left: 0.75em; + border-left: 3px solid var(--border); + color: var(--text-muted); +} + +.bubble.assistant table { + border-collapse: collapse; + margin: 0.5em 0; + font-size: 0.85em; +} + +.bubble.assistant th, +.bubble.assistant td { + border: 1px solid var(--border); + padding: 0.35em 0.6em; +} + +.bubble.assistant th { + background: color-mix(in srgb, var(--text) 4%, transparent); + font-weight: 600; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.input-bar { + display: flex; + gap: 0.5rem; + padding: 0.875rem 1rem; + border-top: 1px solid var(--border); +} + +.input-bar textarea { + flex: 1; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 0.875rem; + resize: none; + outline: none; + transition: border-color 0.15s; +} + +.input-bar textarea:focus { + border-color: var(--ring); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--ring) 25%, transparent); +} + +.input-bar textarea:disabled { + opacity: 0.5; +} + +.input-bar button { + padding: 0.5rem 1rem; + border: none; + border-radius: 8px; + background: var(--primary); + color: var(--primary-fg); + font-family: var(--font); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: opacity 0.15s; + align-self: flex-end; +} + +.input-bar button:hover:not(:disabled) { + opacity: 0.9; +} + +.input-bar button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.event-panel { + width: 300px; + flex-shrink: 0; + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--card); + overflow: hidden; +} + +.event-header { + padding: 0.625rem 0.875rem; + border-bottom: 1px solid var(--border); + font-size: 0.8rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.event-list { + flex: 1; + overflow-y: auto; + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.event-empty { + text-align: center; + padding: 2.5rem 0; + font-size: 0.75rem; + color: var(--text-faint); +} + +.event-row { + font-family: var(--mono); + font-size: 0.7rem; + line-height: 1.4; + display: flex; + gap: 0.5rem; +} + +.event-type { + flex-shrink: 0; + width: 90px; + text-align: right; + color: var(--text-faint); +} + +.event-detail { + color: var(--text-muted); + word-break: break-all; +} diff --git a/apps/agent-app/src/App.tsx b/apps/agent-app/src/App.tsx new file mode 100644 index 00000000..1de373c9 --- /dev/null +++ b/apps/agent-app/src/App.tsx @@ -0,0 +1,405 @@ +import { TooltipProvider } from "@databricks/appkit-ui/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import "./App.css"; +import { ThemeSelector } from "./components/theme-selector"; + +interface SSEEvent { + type: string; + delta?: string; + item_id?: string; + item?: { + type?: string; + id?: string; + call_id?: string; + name?: string; + arguments?: string; + output?: string; + status?: string; + }; + content?: string; + data?: Record; + error?: string; + sequence_number?: number; + output_index?: number; + approval_id?: string; + stream_id?: string; + tool_name?: string; + args?: unknown; + annotations?: { + readOnly?: boolean; + destructive?: boolean; + idempotent?: boolean; + }; +} + +interface ChatMessage { + id: number; + role: "user" | "assistant"; + content: string; +} + +interface PendingApproval { + approvalId: string; + streamId: string; + toolName: string; + args: unknown; + annotations?: { + readOnly?: boolean; + destructive?: boolean; + idempotent?: boolean; + }; +} + +export default function App() { + const [messages, setMessages] = useState([]); + const [events, setEvents] = useState([]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [threadId, setThreadId] = useState(null); + const [pendingApprovals, setPendingApprovals] = useState( + [], + ); + const currentStreamIdRef = useRef(null); + const messagesEndRef = useRef(null); + const idRef = useRef(0); + + const [toolCount, setToolCount] = useState(0); + + const decideApproval = useCallback( + async (approvalId: string, decision: "approve" | "deny") => { + const approval = pendingApprovals.find( + (a) => a.approvalId === approvalId, + ); + if (!approval) return; + try { + await fetch("/api/agent/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + streamId: approval.streamId, + approvalId, + decision, + }), + }); + } finally { + setPendingApprovals((prev) => + prev.filter((a) => a.approvalId !== approvalId), + ); + } + }, + [pendingApprovals], + ); + + useEffect(() => { + const timer = setTimeout(() => { + fetch("/api/agent/info") + .then((r) => r.json()) + .then((data) => setToolCount(data.toolCount ?? 0)) + .catch(() => {}); + }, 500); + return () => clearTimeout(timer); + }, []); + + // 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; + + const text = input.trim(); + setInput(""); + setMessages((prev) => [ + ...prev, + { id: ++idRef.current, role: "user", content: text }, + ]); + setEvents([]); + setIsLoading(true); + + try { + const res = await fetch("/api/agent/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: text, + ...(threadId && { threadId }), + }), + }); + + if (!res.ok) { + const err = await res.json(); + setMessages((prev) => [ + ...prev, + { + id: ++idRef.current, + role: "assistant", + content: `Error: ${err.error}`, + }, + ]); + return; + } + + const reader = res.body?.getReader(); + if (!reader) return; + + const decoder = new TextDecoder(); + let content = ""; + 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: SSEEvent = JSON.parse(data); + if (!event.type) continue; + setEvents((prev) => [...prev, event]); + + if (event.type === "appkit.metadata" && event.data?.threadId) { + setThreadId(event.data.threadId as string); + if (typeof event.data.streamId === "string") { + currentStreamIdRef.current = event.data.streamId; + } + } + if ( + event.type === "appkit.approval_pending" && + event.approval_id && + event.stream_id && + event.tool_name + ) { + currentStreamIdRef.current = event.stream_id; + setPendingApprovals((prev) => [ + ...prev, + { + approvalId: event.approval_id as string, + streamId: event.stream_id as string, + toolName: event.tool_name as string, + args: event.args, + annotations: event.annotations, + }, + ]); + } + if (event.type === "response.output_text.delta" && event.delta) { + content += event.delta; + setMessages((prev) => { + const updated = [...prev]; + const last = updated[updated.length - 1]; + if (last?.role === "assistant") { + updated[updated.length - 1] = { ...last, content }; + } else { + updated.push({ + id: ++idRef.current, + role: "assistant", + content, + }); + } + return updated; + }); + } + } catch { + /* skip */ + } + } + } + } catch (err) { + setMessages((prev) => [ + ...prev, + { + id: ++idRef.current, + role: "assistant", + content: `Error: ${err instanceof Error ? err.message : "Unknown error"}`, + }, + ]); + } finally { + setIsLoading(false); + } + }, [input, isLoading, threadId]); + + return ( + +
+
+
+
+

Agent Chat

+

+ AI agent with {toolCount} auto-discovered tools + {threadId && ( + + {" "} + · Thread {threadId.slice(0, 8)} + + )} +

+
+ +
+ +
+
+
+ {messages.length === 0 && ( +
+

+ Send a message to start a conversation +

+

+ The agent can query data, browse files, and more +

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

{msg.content}

+
+
+ ))} + + {pendingApprovals.map((approval) => ( +
+
+
+ + Destructive tool — approval required + +
+
+ {approval.toolName} +
+                          {JSON.stringify(approval.args, null, 2)}
+                        
+
+
+ + +
+
+
+ ))} + + {isLoading && + pendingApprovals.length === 0 && + messages[messages.length - 1]?.role === "user" && ( +
+
+ Thinking... +
+
+ )} + +
+
+ +
{ + e.preventDefault(); + sendMessage(); + }} + > +