Skip to content

Commit 137d12a

Browse files
committed
feat(template): scaffold a working starter agent
Selecting `agents` in `databricks apps init` previously produced an app that booted, logged "No agents registered.", and rendered no UI for the plugin. Fixes that by scaffolding two starter agents (one markdown, one code-defined) and a chat surface, gated on `{{if .plugins.agents}}`. Added: - template/config/agents/assistant/agent.md — markdown agent, default, no tools. Demonstrates the declarative form. - template/server/agents/helper.ts — code-defined agent via createAgent({...}) with two inline tool({...}) definitions: current_time (returns ISO timestamp) and count_words. Tools are pure JS so the demo works regardless of which other plugins were selected at scaffold time. - template/client/src/pages/agents/AgentChat.tsx — minimal SSE consumer for /api/agents/chat with an agent picker, streaming text bubbles, and inline tool-call rows. Hand-rolled because @databricks/appkit-ui doesn't yet ship a generic agent chat primitive — replace with one when it lands. Modified: - template/server/server.ts: when {{if .plugins.agents}}, imports the helper agent and wires it as agents({ agents: { helper } }) instead of bare agents(). The markdown 'assistant' loads automatically from config/agents/. - template/client/src/App.tsx: conditional NavLink + route entry, mirroring the analytics/files/etc. blocks. End-to-end shape after init with --features agents: - GET /api/agents/info returns { agents: ['assistant', 'helper'], defaultAgent: 'assistant' } - /agents page renders chat with picker - 'what time is it?' to helper triggers a current_time tool round-trip - 'count words in: the quick brown fox' triggers count_words → 4 The serving-endpoint resource (DATABRICKS_SERVING_ENDPOINT_NAME) is already declared in template/appkit.plugins.json from PR 4, so the CLI prompts for an endpoint when agents is selected.
1 parent b8be147 commit 137d12a

5 files changed

Lines changed: 348 additions & 0 deletions

File tree

template/client/src/App.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import {
55
CardHeader,
66
CardTitle,
77
} from '@databricks/appkit-ui/react';
8+
{{- if .plugins.agents}}
9+
import { AgentChat } from './pages/agents/AgentChat';
10+
{{- end}}
811
{{- if .plugins.analytics}}
912
import { AnalyticsPage } from './pages/analytics/AnalyticsPage';
1013
{{- end}}
@@ -43,6 +46,11 @@ function Layout() {
4346
<NavLink to="/" end className={navLinkClass}>
4447
Home
4548
</NavLink>
49+
{{- if .plugins.agents}}
50+
<NavLink to="/agents" className={navLinkClass}>
51+
Agents
52+
</NavLink>
53+
{{- end}}
4654
{{- if .plugins.analytics}}
4755
<NavLink to="/analytics" className={navLinkClass}>
4856
Analytics
@@ -93,6 +101,9 @@ const router = createBrowserRouter([
93101
element: <Layout />,
94102
children: [
95103
{ path: '/', element: <HomePage /> },
104+
{{- if .plugins.agents}}
105+
{ path: '/agents', element: <AgentChat /> },
106+
{{- end}}
96107
{{- if .plugins.analytics}}
97108
{ path: '/analytics', element: <AnalyticsPage /> },
98109
{{- end}}
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
{{if .plugins.agents -}}
2+
import { useEffect, useRef, useState } from 'react';
3+
import {
4+
Button,
5+
Card,
6+
CardContent,
7+
Input,
8+
} from '@databricks/appkit-ui/react';
9+
10+
interface Message {
11+
id: string;
12+
role: 'user' | 'assistant' | 'tool';
13+
content: string;
14+
toolName?: string;
15+
}
16+
17+
interface AgentInfo {
18+
agents: string[];
19+
defaultAgent: string | null;
20+
}
21+
22+
/**
23+
* Minimal chat surface for the `agents` plugin.
24+
*
25+
* - Lists registered agents from `GET /api/agents/info` and lets the user
26+
* pick one (markdown `assistant` from `config/agents/assistant/agent.md`
27+
* and code-defined `helper` from `server/agents/helper.ts`).
28+
* - Sends turns to `POST /api/agents/chat` and consumes the SSE stream
29+
* the agents plugin emits (Responses-API shape).
30+
* - Renders streaming assistant text incrementally and surfaces tool
31+
* calls as separate inline rows.
32+
*
33+
* Replace this with `<GenieChat>`-style components when AppKit ships a
34+
* first-class agent chat primitive in `@databricks/appkit-ui/react`.
35+
*/
36+
export function AgentChat() {
37+
const [agents, setAgents] = useState<string[]>([]);
38+
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
39+
const [threadId, setThreadId] = useState<string | null>(null);
40+
const [messages, setMessages] = useState<Message[]>([]);
41+
const [input, setInput] = useState('');
42+
const [streaming, setStreaming] = useState(false);
43+
const [error, setError] = useState<string | null>(null);
44+
const scrollRef = useRef<HTMLDivElement | null>(null);
45+
46+
useEffect(() => {
47+
fetch('/api/agents/info')
48+
.then((res) => {
49+
if (!res.ok) throw new Error(`agents info failed: ${res.statusText}`);
50+
return res.json() as Promise<AgentInfo>;
51+
})
52+
.then((info) => {
53+
setAgents(info.agents);
54+
setSelectedAgent(info.defaultAgent ?? info.agents[0] ?? null);
55+
})
56+
.catch((err) =>
57+
setError(err instanceof Error ? err.message : 'Failed to load agents'),
58+
);
59+
}, []);
60+
61+
useEffect(() => {
62+
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
63+
}, []);
64+
65+
const send = async (e: React.FormEvent) => {
66+
e.preventDefault();
67+
const message = input.trim();
68+
if (!message || streaming || !selectedAgent) return;
69+
70+
setError(null);
71+
setInput('');
72+
setStreaming(true);
73+
74+
const userMsg: Message = {
75+
id: `u-${Date.now()}`,
76+
role: 'user',
77+
content: message,
78+
};
79+
const assistantId = `a-${Date.now()}`;
80+
setMessages((prev) => [
81+
...prev,
82+
userMsg,
83+
{ id: assistantId, role: 'assistant', content: '' },
84+
]);
85+
86+
try {
87+
const res = await fetch('/api/agents/chat', {
88+
method: 'POST',
89+
headers: { 'Content-Type': 'application/json' },
90+
body: JSON.stringify({
91+
message,
92+
agent: selectedAgent,
93+
threadId: threadId ?? undefined,
94+
}),
95+
});
96+
if (!res.ok || !res.body) {
97+
throw new Error(`chat failed: ${res.status} ${res.statusText}`);
98+
}
99+
100+
const reader = res.body.getReader();
101+
const decoder = new TextDecoder();
102+
let buf = '';
103+
104+
while (true) {
105+
const { done, value } = await reader.read();
106+
if (done) break;
107+
buf += decoder.decode(value, { stream: true });
108+
109+
// SSE events are blank-line separated. Drain whole events from buf.
110+
let idx;
111+
while ((idx = buf.indexOf('\n\n')) !== -1) {
112+
const raw = buf.slice(0, idx);
113+
buf = buf.slice(idx + 2);
114+
const dataLine = raw
115+
.split('\n')
116+
.find((l) => l.startsWith('data:'));
117+
if (!dataLine) continue;
118+
const json = dataLine.slice(5).trim();
119+
if (!json) continue;
120+
try {
121+
handleEvent(JSON.parse(json), assistantId);
122+
} catch {
123+
// Ignore malformed payloads; the SSE stream will recover.
124+
}
125+
}
126+
}
127+
} catch (err) {
128+
setError(err instanceof Error ? err.message : 'Chat error');
129+
} finally {
130+
setStreaming(false);
131+
}
132+
};
133+
134+
function handleEvent(ev: unknown, assistantId: string) {
135+
if (!ev || typeof ev !== 'object') return;
136+
const e = ev as Record<string, unknown>;
137+
138+
if (e.type === 'appkit.metadata') {
139+
const data = e.data as { threadId?: string } | undefined;
140+
if (data?.threadId) setThreadId(data.threadId);
141+
return;
142+
}
143+
144+
if (e.type === 'response.output_text.delta') {
145+
const delta = (e.delta as string | undefined) ?? '';
146+
setMessages((prev) =>
147+
prev.map((m) =>
148+
m.id === assistantId ? { ...m, content: m.content + delta } : m,
149+
),
150+
);
151+
return;
152+
}
153+
154+
if (e.type === 'response.output_item.added') {
155+
const item = e.item as
156+
| { type: string; name?: string; arguments?: string }
157+
| undefined;
158+
if (item?.type === 'function_call' && item.name) {
159+
setMessages((prev) => [
160+
...prev,
161+
{
162+
id: `t-${Date.now()}-${Math.random()}`,
163+
role: 'tool',
164+
toolName: item.name,
165+
content: item.arguments ?? '',
166+
},
167+
]);
168+
}
169+
}
170+
}
171+
172+
return (
173+
<div className="space-y-6 w-full max-w-4xl mx-auto">
174+
<div className="flex items-end justify-between gap-4">
175+
<div>
176+
<h2 className="text-2xl font-bold text-foreground">Agents</h2>
177+
<p className="text-sm text-muted-foreground mt-1">
178+
Chat with a registered agent. Markdown agents come from
179+
<code className="mx-1">config/agents/</code>; code-defined
180+
agents are wired in <code className="mx-1">server/server.ts</code>.
181+
</p>
182+
</div>
183+
{agents.length > 0 && (
184+
<div className="flex gap-2">
185+
{agents.map((name) => (
186+
<Button
187+
key={name}
188+
variant={selectedAgent === name ? 'default' : 'outline'}
189+
size="sm"
190+
onClick={() => {
191+
setSelectedAgent(name);
192+
setThreadId(null);
193+
setMessages([]);
194+
}}
195+
>
196+
{name}
197+
</Button>
198+
))}
199+
</div>
200+
)}
201+
</div>
202+
203+
<Card className="h-[600px] flex flex-col">
204+
<CardContent className="flex-1 overflow-y-auto p-4 space-y-3" ref={scrollRef}>
205+
{messages.length === 0 && (
206+
<p className="text-sm text-muted-foreground text-center mt-8">
207+
Start the conversation. Try asking <code>helper</code> "what
208+
time is it?" or "count the words in: the quick brown fox".
209+
</p>
210+
)}
211+
{messages.map((m) => {
212+
if (m.role === 'tool') {
213+
return (
214+
<div
215+
key={m.id}
216+
className="text-xs font-mono text-muted-foreground border-l-2 border-primary/50 pl-3"
217+
>
218+
<span className="font-semibold">tool · {m.toolName}</span>
219+
{m.content ? <span className="ml-2">{m.content}</span> : null}
220+
</div>
221+
);
222+
}
223+
return (
224+
<div
225+
key={m.id}
226+
className={`p-3 rounded-md ${
227+
m.role === 'user'
228+
? 'bg-primary/10 ml-12'
229+
: 'bg-muted mr-12'
230+
}`}
231+
>
232+
<div className="text-xs text-muted-foreground mb-1">
233+
{m.role}
234+
</div>
235+
<div className="whitespace-pre-wrap text-sm">
236+
{m.content || (streaming ? '…' : '')}
237+
</div>
238+
</div>
239+
);
240+
})}
241+
</CardContent>
242+
243+
<form onSubmit={send} className="p-3 border-t flex gap-2">
244+
<Input
245+
value={input}
246+
onChange={(e) => setInput(e.target.value)}
247+
placeholder={
248+
selectedAgent
249+
? `Message ${selectedAgent}…`
250+
: 'Loading agents…'
251+
}
252+
disabled={!selectedAgent || streaming}
253+
/>
254+
<Button
255+
type="submit"
256+
disabled={!input.trim() || !selectedAgent || streaming}
257+
>
258+
{streaming ? 'Sending…' : 'Send'}
259+
</Button>
260+
</form>
261+
</Card>
262+
263+
{error && (
264+
<div className="text-sm text-destructive">Error: {error}</div>
265+
)}
266+
</div>
267+
);
268+
}
269+
{{- end}}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{{if .plugins.agents -}}
2+
---
3+
default: true
4+
---
5+
6+
You are a helpful assistant for this Databricks application.
7+
8+
Greet the user briefly when the conversation starts. Answer questions
9+
about how to use this app, what it can do, and how the code is laid out.
10+
Keep replies short and direct. If the user asks something you don't know,
11+
say so plainly.
12+
13+
You don't have any tools beyond plain conversation. If the user asks for
14+
a calculation or a side-effect (e.g. "what time is it?", "count the
15+
words in this sentence"), tell them the `helper` agent can do that and
16+
they can switch agents from the chat picker.
17+
{{- end}}

template/server/agents/helper.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{{if .plugins.agents -}}
2+
import { createAgent, tool } from '@databricks/appkit';
3+
import { z } from 'zod';
4+
5+
/**
6+
* Code-defined agent: showcases the imperative `createAgent({...})` form
7+
* with inline `tool({...})` definitions.
8+
*
9+
* Tools here are intentionally dependency-free (no SQL warehouse, no
10+
* volumes, no external APIs) so this template demos the tool-calling
11+
* round-trip even when no other plugin is selected at scaffold time.
12+
*
13+
* The companion markdown agent at `config/agents/assistant/agent.md`
14+
* shows the declarative form for prose-only agents.
15+
*/
16+
export const helper = createAgent({
17+
name: 'helper',
18+
instructions: [
19+
'You are a tool-using helper agent.',
20+
'When the user asks about the time, call `current_time`.',
21+
'When the user asks to count words in a string, call `count_words`.',
22+
'For anything else, answer briefly in plain text.',
23+
].join(' '),
24+
tools: {
25+
current_time: tool({
26+
description: 'Returns the current server time as an ISO 8601 timestamp.',
27+
schema: z.object({}),
28+
annotations: { effect: 'read' },
29+
execute: () => ({ now: new Date().toISOString() }),
30+
}),
31+
count_words: tool({
32+
description: 'Counts the words in a string. Words are runs of non-whitespace.',
33+
schema: z.object({
34+
text: z.string().describe('The text to count words in.'),
35+
}),
36+
annotations: { effect: 'read' },
37+
execute: ({ text }) => ({
38+
text,
39+
word_count: text.trim().split(/\s+/).filter(Boolean).length,
40+
}),
41+
}),
42+
},
43+
});
44+
{{- end}}

template/server/server.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,18 @@ import { {{$betaImports}} } from '@databricks/appkit/beta';
1515
{{- if .plugins.lakebase}}
1616
import { setupSampleLakebaseRoutes } from './routes/lakebase/todo-routes';
1717
{{- end}}
18+
{{- if .plugins.agents}}
19+
import { helper } from './agents/helper';
20+
{{- end}}
1821

1922
createApp({
2023
plugins: [
2124
{{- range $name, $_ := .plugins}}
25+
{{- if eq $name "agents"}}
26+
agents({ agents: { helper } }),
27+
{{- else}}
2228
{{$name}}(),
29+
{{- end}}
2330
{{- end}}
2431
],
2532
{{- if .plugins.lakebase}}

0 commit comments

Comments
 (0)