Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 102 additions & 3 deletions apps/dev-playground/client/src/routes/agent.route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ const AGENT_OPTIONS = [
{ value: "helper", label: "Helper — general assistant" },
{ value: "sql_analyst", label: "SQL Analyst — NYC taxi queries" },
{ value: "supervisor", label: "Supervisor — Databricks-hosted tools" },
{
value: "query",
label: "Query — dispatcher (per-agent + collision skills)",
},
] as const;

interface SSEEvent {
Expand Down Expand Up @@ -151,14 +155,16 @@ function useAutocomplete(enabled: boolean) {

function AgentRoute() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [events, setEvents] = useState<AgentEvent[]>([]);
const [events, setEvents] = useState<SSEEvent[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [threadId, setThreadId] = useState<string | null>(null);
const [agent, setAgent] = useState<string>(AGENT_OPTIONS[0].value);
const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>(
[],
);
// Highlighted row in the `/skill` menu.
const [skillIndex, setSkillIndex] = useState(0);

const decideApproval = useCallback(
async (approvalId: string, decision: "approve" | "deny") => {
Expand Down Expand Up @@ -191,8 +197,11 @@ function AgentRoute() {
const agentConfig = getPluginClientConfig<{
agents?: string[];
defaultAgent?: string;
skills?: Record<string, { name: string; description: string }[]>;
}>("agents");
const hasAutocomplete = (agentConfig.agents ?? []).includes("autocomplete");
// Skills visible to the selected agent, from the boot config.
const activeSkills = agentConfig.skills?.[agent] ?? [];

const {
suggestion,
Expand All @@ -201,6 +210,29 @@ function AgentRoute() {
clear: clearSuggestion,
} = useAutocomplete(hasAutocomplete);

// Slash-command menu: when the input is a leading `/token` (no space yet),
// surface matching skills for the active agent.
const slashQuery = input.match(/^\/([^\s]*)$/)?.[1] ?? null;
const skillMatches =
slashQuery !== null && activeSkills.length > 0
? activeSkills.filter((s) =>
s.name.toLowerCase().includes(slashQuery.toLowerCase()),
)
: [];
const skillMenuOpen = skillMatches.length > 0;

const pickSkill = (name: string) => {
setInput(`/${name} `);
clearSuggestion();
inputRef.current?.focus();
};

// biome-ignore lint/correctness/useExhaustiveDependencies: reset highlight as the query changes
useEffect(() => {
setSkillIndex(0);
}, [input, agent]);

// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
Expand All @@ -218,13 +250,25 @@ function AgentRoute() {
setEvents([]);
setIsLoading(true);

// `/skill-name …` forces a skill for this turn (the agents plugin injects
// its instructions); the model can still auto-load others via load_skill.
let messageBody = userMessage;
let skill: string | undefined;
const skillMatch = messageBody.match(/^\/([A-Za-z0-9][\w.:-]*)\s*/);
if (skillMatch) {
skill = skillMatch[1];
messageBody = messageBody.slice(skillMatch[0].length);
if (messageBody.trim() === "") messageBody = `Use the ${skill} skill.`;
}

try {
const response = await fetch("/api/agents/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: userMessage,
message: messageBody,
agent,
...(skill && { skill }),
...(threadId && { threadId }),
}),
});
Expand Down Expand Up @@ -506,6 +550,34 @@ function AgentRoute() {
value={input}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={(e) => {
if (skillMenuOpen) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSkillIndex((i) => (i + 1) % skillMatches.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSkillIndex(
(i) =>
(i - 1 + skillMatches.length) %
skillMatches.length,
);
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
pickSkill(
(skillMatches[skillIndex] ?? skillMatches[0]).name,
);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setInput("");
return;
}
}
if (e.key === "Tab" && suggestion) {
e.preventDefault();
acceptSuggestion();
Expand All @@ -518,11 +590,38 @@ function AgentRoute() {
sendMessage();
}
}}
placeholder="Ask a question..."
placeholder={
activeSkills.length > 0
? "Ask a question… (type / for skills)"
: "Ask a question..."
}
disabled={isLoading}
rows={1}
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 resize-none"
/>
{skillMenuOpen && (
<ul className="absolute bottom-full left-0 mb-1 w-full max-h-48 overflow-y-auto rounded-md border bg-card shadow-md z-10 py-1">
{skillMatches.map((s, i) => (
<li key={s.name}>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault();
pickSkill(s.name);
}}
className={`block w-full text-left px-3 py-1.5 text-sm ${
i === skillIndex ? "bg-muted" : ""
}`}
>
<span className="font-mono">/{s.name}</span>
<span className="ml-2 text-xs text-muted-foreground">
{s.description}
</span>
</button>
</li>
))}
</ul>
)}
</div>
<Button
type="submit"
Expand Down
5 changes: 5 additions & 0 deletions apps/dev-playground/server/agents/helper/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ export default createAgent({
instructions:
"You are a demo helper. Use analytics tools to answer data questions, " +
"or get_weather for light small-talk.",
// Opts into two global skills (server/agents/skills/<name>/SKILL.md). A code
// agent has no per-agent skills/ folder, so opting in is the only way it
// reaches the shared pool. The model auto-loads a skill when a request
// matches, or the user can force one with `/haiku …` / `/bullet-brief …`.
skills: ["haiku", "bullet-brief"],
tools(plugins) {
return {
...plugins.analytics.toolkit(),
Expand Down
5 changes: 5 additions & 0 deletions apps/dev-playground/server/agents/query/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ agents:
- dashboard_pilot
tools:
- plugin:files: [files.read, files.list, files.metadata]
# Per-agent skills (query/skills/) are always visible. Opting into the global
# `haiku` here deliberately collides with the per-agent `haiku`, so both must
# be addressed qualified: `agent:haiku` (per-agent) and `bundle:haiku` (global).
skills:
- haiku
---

You are the dispatcher for the Smart Dashboard — NYC taxi analytics
Expand Down
10 changes: 10 additions & 0 deletions apps/dev-playground/server/agents/query/skills/haiku/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
name: haiku
description: Dispatcher's haiku — summarize the routing outcome as a 5-7-5 haiku. Per-agent variant; collides with the global `haiku`, so address it as `agent:haiku`.
---

When this skill is active, once the work is done, distill the outcome into a
single 5 / 7 / 5 haiku about what was routed and what came back.

- Three lines, 5 / 7 / 5 syllables. No title, no preamble.
- Base it on the specialists' real results — don't invent numbers.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
name: routing-brief
description: How the dispatcher writes a one-line handoff and merges specialist replies. Per-agent skill — always visible to the query agent without opting in.
allowed-tools: [agent-sql_analyst, agent-dashboard_pilot]
---

You are routing a request to a specialist. Keep your own words to a minimum:

- Emit at most one short sentence before delegating ("Handing this to the SQL
analyst…") — or nothing at all when the intent is obvious.
- Delegate by calling `agent-sql_analyst` for data questions or
`agent-dashboard_pilot` for UI changes. Never answer a data question
yourself.
- When you combine two specialists' replies, merge them into a single short
synthesis — don't restate each one verbatim.

See `reference.md` for worked handoff examples.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Routing brief — worked examples

## Single specialist

User: "How many trips last Friday?"

→ Say "Handing this to the SQL analyst…", then call `agent-sql_analyst` with
the question. Let its answer stand; add nothing.

## Two specialists

User: "Filter to Friday and tell me the top pickup zone."

→ Call `agent-dashboard_pilot` to apply the date filter and `agent-sql_analyst`
for the top zone, then merge into one line:
"Filtered to Fri; top pickup zone was Midtown (12,481 trips)."
12 changes: 12 additions & 0 deletions apps/dev-playground/server/agents/skills/bullet-brief/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
name: bullet-brief
description: Format the final answer as a tight markdown bullet list — one idea per bullet, no preamble. Use when the user asks for a summary, a rundown, or to "keep it brief".
---

When this skill is active, deliver your final answer as a compact markdown
bullet list:

- One idea per bullet; lead with the number or noun that matters.
- No opening sentence and no closing summary — just the bullets.
- Five bullets max. If a data question was asked, call the tools you need and
put the real numbers in the bullets. Don't pad to reach five.
15 changes: 15 additions & 0 deletions apps/dev-playground/server/agents/skills/haiku/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
name: haiku
description: Format the final answer as a traditional 5-7-5 haiku. Use when the user asks for a haiku or a poetic reply.
---

When this skill is active, deliver your final answer as a single haiku:

- Three lines, following a 5 / 7 / 5 syllable pattern.
- Capture the essence of the answer — if the user asked a data question,
answer it truthfully first (call any tools you need), then distill the
result into the poem. Don't invent facts to fit the meter.
- No title, no preamble, no explanation after the poem. Just the three lines.

See `reference.md` for a worked example, including how to fold a real tool
result into the poem.
15 changes: 15 additions & 0 deletions apps/dev-playground/server/agents/skills/haiku/reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Haiku skill: worked example

**User:** what's the weather in Paris?

**Wrong** (explains, then poem):
> The weather in Paris is sunny and 22°C. Here's your haiku:
> Sunlight over Seine / ...

**Right** (call the tool, then answer as the poem alone):
> Sun warms the Seine's banks
> Twenty-two degrees of calm
> Paris wears the light

Fold the real tool result (sunny, 22°C) into the imagery. Never bend the
facts to fit the syllables — bend the words instead.
13 changes: 13 additions & 0 deletions docs/docs/api/appkit/Interface.AgentDefinition.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 42 additions & 0 deletions docs/docs/api/appkit/Interface.AgentsPluginConfig.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions docs/docs/api/appkit/Interface.RegisteredAgent.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading