From d423c9381a6d8de7c801c5d0f84c94e328ab071b Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 19 Aug 2026 15:34:32 +0200 Subject: [PATCH 1/4] feat(appkit): agent skills v1 (SKILL.md progressive disclosure for agents) Signed-off-by: MarioCadenas --- .../client/src/routes/agent.route.tsx | 101 ++++- .../server/agents/helper/agent.ts | 4 + .../server/agents/skills/haiku/SKILL.md | 15 + .../server/agents/skills/haiku/reference.md | 15 + .../api/appkit/Interface.AgentDefinition.md | 13 + .../appkit/Interface.AgentsPluginConfig.md | 42 ++ .../api/appkit/Interface.RegisteredAgent.md | 12 + .../api/appkit/TypeAlias.ResolvedToolEntry.md | 44 +++ docs/docs/plugins/agents.md | 67 +++- .../hooks/__tests__/use-agent-chat.test.ts | 45 +++ .../src/react/hooks/use-agent-chat.ts | 26 +- packages/appkit/src/core/agent/frontmatter.ts | 18 + packages/appkit/src/core/agent/load-agents.ts | 60 ++- .../appkit/src/core/agent/skills/index.ts | 6 + .../src/core/agent/skills/load-skills.ts | 94 +++++ .../src/core/agent/skills/parse-skill.ts | 130 ++++++ .../src/core/agent/skills/read-resource.ts | 52 +++ .../appkit/src/core/agent/skills/render.ts | 42 ++ .../src/core/agent/skills/resolve-catalog.ts | 138 +++++++ .../core/agent/skills/tests/skills.test.ts | 330 ++++++++++++++++ .../appkit/src/core/agent/skills/types.ts | 49 +++ .../src/core/agent/tests/load-agents.test.ts | 9 + packages/appkit/src/core/agent/types.ts | 48 +++ packages/appkit/src/plugins/agents/agents.ts | 370 +++++++++++++++++- .../appkit/src/plugins/agents/manifest.json | 18 + packages/appkit/src/plugins/agents/schemas.ts | 6 + .../agents/tests/dispatch-tool-call.test.ts | 157 +++++++- .../plugins/agents/tests/skill-client.test.ts | 148 +++++++ .../plugins/agents/tests/skill-volume.test.ts | 154 ++++++++ template/appkit.plugins.json | 19 + .../client/src/pages/agents/AgentChat.tsx | 28 +- template/server/agents/planner/agent.md | 6 +- .../agents/skills/tracer-bullets/SKILL.md | 29 ++ .../agents/skills/tracer-bullets/reference.md | 28 ++ 34 files changed, 2299 insertions(+), 24 deletions(-) create mode 100644 apps/dev-playground/server/agents/skills/haiku/SKILL.md create mode 100644 apps/dev-playground/server/agents/skills/haiku/reference.md create mode 100644 packages/appkit/src/core/agent/frontmatter.ts create mode 100644 packages/appkit/src/core/agent/skills/index.ts create mode 100644 packages/appkit/src/core/agent/skills/load-skills.ts create mode 100644 packages/appkit/src/core/agent/skills/parse-skill.ts create mode 100644 packages/appkit/src/core/agent/skills/read-resource.ts create mode 100644 packages/appkit/src/core/agent/skills/render.ts create mode 100644 packages/appkit/src/core/agent/skills/resolve-catalog.ts create mode 100644 packages/appkit/src/core/agent/skills/tests/skills.test.ts create mode 100644 packages/appkit/src/core/agent/skills/types.ts create mode 100644 packages/appkit/src/plugins/agents/tests/skill-client.test.ts create mode 100644 packages/appkit/src/plugins/agents/tests/skill-volume.test.ts create mode 100644 template/server/agents/skills/tracer-bullets/SKILL.md create mode 100644 template/server/agents/skills/tracer-bullets/reference.md diff --git a/apps/dev-playground/client/src/routes/agent.route.tsx b/apps/dev-playground/client/src/routes/agent.route.tsx index 2ae2a2557..01493539b 100644 --- a/apps/dev-playground/client/src/routes/agent.route.tsx +++ b/apps/dev-playground/client/src/routes/agent.route.tsx @@ -151,7 +151,7 @@ function useAutocomplete(enabled: boolean) { function AgentRoute() { const [messages, setMessages] = useState([]); - const [events, setEvents] = useState([]); + const [events, setEvents] = useState([]); const [input, setInput] = useState(""); const [isLoading, setIsLoading] = useState(false); const [threadId, setThreadId] = useState(null); @@ -159,6 +159,8 @@ function AgentRoute() { const [pendingApprovals, setPendingApprovals] = useState( [], ); + // Highlighted row in the `/skill` menu. + const [skillIndex, setSkillIndex] = useState(0); const decideApproval = useCallback( async (approvalId: string, decision: "approve" | "deny") => { @@ -191,8 +193,11 @@ function AgentRoute() { const agentConfig = getPluginClientConfig<{ agents?: string[]; defaultAgent?: string; + skills?: Record; }>("agents"); const hasAutocomplete = (agentConfig.agents ?? []).includes("autocomplete"); + // Skills visible to the selected agent, from the boot config. + const activeSkills = agentConfig.skills?.[agent] ?? []; const { suggestion, @@ -201,6 +206,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]); @@ -218,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 }), }), }); @@ -506,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(); @@ -518,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 && ( +
    + {skillMatches.map((s, i) => ( +
  • + +
  • + ))} +
+ )}