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
98 changes: 96 additions & 2 deletions apps/dev-playground/client/src/routes/agent.route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ function AgentRoute() {
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 +193,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 +206,28 @@ 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" });
Expand All @@ -219,13 +246,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 @@ -507,6 +546,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 @@ -519,11 +586,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
15 changes: 15 additions & 0 deletions apps/dev-playground/config/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/config/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.
4 changes: 4 additions & 0 deletions apps/dev-playground/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ const helper = createAgent({
instructions:
"You are a demo helper. Use analytics tools to answer data questions, " +
"or get_weather for light small-talk.",
// Opts into the global `haiku` skill (config/agents/skills/haiku/SKILL.md).
// The model auto-loads it when a request matches, or the user can force it
// with `/haiku …` in the chat box.
skills: ["haiku"],
tools(plugins) {
return {
...plugins.analytics.toolkit(),
Expand Down
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.

44 changes: 44 additions & 0 deletions docs/docs/api/appkit/TypeAlias.ResolvedToolEntry.md

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

Loading
Loading