Skip to content
Closed
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
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.

67 changes: 66 additions & 1 deletion docs/docs/plugins/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ That alone gives you a live HTTP server with `POST /invocations` (and its alias

## Level 1: drop a markdown agent package

Each agent lives in its own folder under `server/agents/` with entry file `agent.md`. A folder is an agent only if it holds an entry file (`agent.md` or `agent.ts`); a folder without one is skipped, so per-agent asset folders like `skills/` sit beside the entry.
Each agent lives in its own folder under `server/agents/` with entry file `agent.md`. A folder is an agent only if it holds an entry file (`agent.md` or `agent.ts`); a folder without one is skipped, so per-agent asset folders sit beside the entry — notably a `skills/` folder holding [Skills](#skills) (on-demand instruction packs the agent loads by name). A shared `server/agents/skills/` folder holds skills available to any agent.

```
my-app/
Expand Down Expand Up @@ -206,6 +206,67 @@ await createApp({

Put `supervisor`, `researcher`, and `writer` in their own `server/agents/<id>/agent.ts` folders (default export each) — a markdown parent can also delegate to a code child in a sibling folder via `agents: [helper]` frontmatter. Each key in `agents: {...}` on an `AgentDefinition` becomes an `agent-<key>` tool on the parent. When invoked, the agents plugin runs the child's adapter with a fresh message list (no shared thread state) and returns the aggregated text. Cycles in a code agent's inline `agents: {}` graph are rejected at load (`createAgent`); markdown `agents:` delegation rejects self-references at load and bounds deeper cycles at runtime via `limits.maxSubAgentDepth`.

## Skills

Skills are on-demand instruction packs — the same `SKILL.md` format Claude Code and Cursor use. Only each skill's `name` + `description` sit in the system prompt (always-on, cheap); the full body loads on demand when the agent (or the user) invokes it. This works on any Databricks-served model — AppKit implements the disclosure itself, so it doesn't depend on a provider-native skills feature.

A skill is a directory with a `SKILL.md` plus any bundled reference files:

```
config/agents/
skills/ # shared pool — any agent can opt in
pdf-forms/
SKILL.md
reference.md
planner/
agent.md
skills/ # private to the `planner` agent
house-style/
SKILL.md
```

```md
---
name: pdf-forms
description: Fill and validate PDF form fields from a data record.
---

To fill a PDF form:

1. Read `reference.md` for the field-name conventions.
2. ...
```

`name` and `description` are required; `license`, `allowed-tools`, and `metadata` are accepted for compatibility with skills authored elsewhere. Unknown keys warn and are ignored.

### Visibility

- **Per-agent skills** (`config/agents/<id>/skills/`) are always visible to that agent.
- **Global skills** (`config/agents/skills/`, and catalog-volume skills) are **opt-in**: list them in the agent's frontmatter, `skills: [pdf-forms]`. Set `autoInheritSkills: true` (or `{ file, code }`) on the plugin to make every global skill visible without listing — off by default so each agent's always-on catalog stays lean.

### How the agent uses a skill

Two read-only built-in tools are injected into any agent that has a visible catalog:

- `load_skill(skill)` — returns the skill's full instructions plus a manifest of its bundled files.
- `read_skill_file(skill, path)` — returns the contents of one of those bundled files.

The model calls `load_skill` on its own when a task matches a skill's description. A **user** can force a specific skill for a turn with the `/skill-name` prefix in chat (or the `send(message, { skill })` option on `useAgentChat`); the skill's instructions are injected into that turn deterministically, and `load_skill` remains available for auto-selection. The client reads the per-agent catalog from the plugin's `clientConfig()` payload to power a picker.

### Catalog skills (Unity Catalog Volume)

Point `skillsVolume` (or the `DATABRICKS_VOLUME_AGENT_SKILLS` env var) at a UC Volume laid out the same way — `<volume>/<name>/SKILL.md`. Catalog skills are discovered at boot and on `reload()`, merged into the shared global pool, and read as the **service principal** (`skillCredentialMode` defaults to `"sp"`). They're intended as a shared, curated pool; per-user (OBO) skill volumes are not wired yet. Declaring the optional `volume` resource in the manifest lets the scaffolder grant the SP read access.

### Name collisions

Skill names are addressed bare. If two sources provide the same name, each becomes a qualified `<scope>:name` (`agent:`, `bundle:`, `volume:`) and the bare name is rejected as ambiguous with the alternatives listed. Two skills with the same name from the *same* source is a boot-time error.

### v1 caveats

- **Scripts are not executed.** A skill may reference `scripts/foo.py`; v1 loads prose and reference docs only.
- **`allowed-tools` is advisory.** It's surfaced as a hint in the loaded skill, not enforced — loading a skill does not restrict the agent's callable tools. It is not a sandbox.
- **Skill bodies are not per-user access-controlled** (they read as the SP). Keep user-sensitive content out of skill bodies.

## Level 5: standalone (no `createApp`)

```ts
Expand Down Expand Up @@ -414,6 +475,9 @@ agents({
defaultModel?: AgentAdapter | Promise<AgentAdapter> | string,
tools?: Record<string, AgentTool>,
autoInheritTools?: boolean | { file?: boolean, code?: boolean },
autoInheritSkills?: boolean | { file?: boolean, code?: boolean }, // default off
skillsVolume?: string, // UC Volume for catalog skills; falls back to DATABRICKS_VOLUME_AGENT_SKILLS
skillCredentialMode?: "sp" | "obo", // default "sp" (see Skills)
threadStore?: ThreadStore, // default in-memory
baseSystemPrompt?: false | string | (ctx: PromptContext) => string,
mcp?: {
Expand Down Expand Up @@ -603,6 +667,7 @@ appkit.agents.getThreads(userId); // list user's threads
| `endpoint` | string | Model serving endpoint name. Shortcut for `model`. |
| `model` | string | Same as `endpoint`; either works. |
| `tools` | array | Unified tool list. Entries are `plugin:<name>` / `plugin:<name>: [t1, t2]` / `plugin:<name>: { only, except, rename, prefix }` for plugin tools, or a bare `<key>` resolved against `agents({ tools: {...} })` for ambient tools. See "Level 2: scope tools in frontmatter" above for examples. |
| `skills` | array | Names of global skills (shared `skills/` pool or catalog volume) to make visible to this agent. Per-agent skills under `<id>/skills/` are always visible. See [Skills](#skills). |
| `default` | boolean | First agent id (sorted order) with `default: true` becomes the default agent. |
| `agents` | array | Sub-agent ids (sibling folders) to delegate to; each becomes an `agent-<id>` tool. Resolves against other markdown and code agents. |
| `maxSteps` | number | Adapter max-step hint. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
} = {};

let resolveStream: (() => void) | null = null;
let rejectStream: ((err: Error) => void) | null = null;

Check warning on line 14 in packages/appkit-ui/src/react/hooks/__tests__/use-agent-chat.test.ts

View workflow job for this annotation

GitHub Actions / Lint & Type Check

eslint(no-unused-vars)

packages/appkit-ui/src/react/hooks/__tests__/use-agent-chat.test.ts:14:5: Variable 'rejectStream' is assigned a value but never used. Unused variables should start with a '_'.

const mockConnectSSE = vi.fn().mockImplementation((opts: any) => {
capturedCallbacks = {
Expand Down Expand Up @@ -77,6 +77,51 @@
expect(capturedCallbacks.maxRetries).toBe(0);
});

test("send(message, { skill }) includes the skill in the payload", async () => {
const { result } = renderHook(() => useAgentChat({ agent: "helper" }));

act(() => {
void result.current.send("summarize", { skill: "pdf" });
});

await waitFor(() => expect(mockConnectSSE).toHaveBeenCalled());
expect(capturedCallbacks.payload).toEqual({
message: "summarize",
agent: "helper",
skill: "pdf",
});
});

test("parses a leading /skill-name token off the message", async () => {
const { result } = renderHook(() => useAgentChat({ agent: "helper" }));

act(() => {
void result.current.send("/pdf extract the tables");
});

await waitFor(() => expect(mockConnectSSE).toHaveBeenCalled());
expect(capturedCallbacks.payload).toEqual({
message: "extract the tables",
agent: "helper",
skill: "pdf",
});
});

test("/skill-name with no text falls back to a non-empty message", async () => {
const { result } = renderHook(() => useAgentChat({ agent: "helper" }));

act(() => {
void result.current.send("/pdf");
});

await waitFor(() => expect(mockConnectSSE).toHaveBeenCalled());
expect(capturedCallbacks.payload).toEqual({
message: "Use the pdf skill.",
agent: "helper",
skill: "pdf",
});
});

test("custom endpoint is forwarded to connectSSE", async () => {
const { result } = renderHook(() =>
useAgentChat({ agent: "helper", endpoint: "/v2/chat" }),
Expand Down
28 changes: 25 additions & 3 deletions packages/appkit-ui/src/react/hooks/use-agent-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,13 @@ export interface UseAgentChatResult {
/**
* Send a user turn and stream the response. Aborts any in-flight
* stream. Resolves when the stream completes (success or error).
*
* Pass `opts.skill` to force-load a skill for this turn, or prefix the
* message with `/skill-name` as sugar (the leading token is parsed off and
* sent as the skill; the rest becomes the message). An explicit
* `opts.skill` wins over a `/`-prefix.
*/
send: (message: string) => Promise<void>;
send: (message: string, opts?: { skill?: string }) => Promise<void>;
/**
* Discard accumulated content, events, and threadId. Aborts any
* in-flight stream. Use when switching agents or starting a fresh
Expand All @@ -95,6 +100,20 @@ export interface UseAgentChatResult {
reset: () => void;
}

/** `forced` (opts.skill) wins; otherwise parse a leading `/skill-name` token off the message. */
function resolveSkill(
message: string,
forced?: string,
): { skill?: string; text: string } {
if (forced) return { skill: forced, text: message };
const match = message.match(/^\/([A-Za-z0-9][\w.:-]*)\s*/);
if (!match) return { text: message };
const skill = match[1];
const rest = message.slice(match[0].length);
// When the message is only the token, fall back to a minimal instruction.
return { skill, text: rest.trim() === "" ? `Use the ${skill} skill.` : rest };
}

/**
* React hook for chatting with an agent registered via the `agents()`
* plugin. Wraps {@link connectSSE} (which owns the buffer cap, abort
Expand Down Expand Up @@ -158,7 +177,7 @@ export function useAgentChat({
}, []);

const send = useCallback(
async (message: string) => {
async (message: string, opts?: { skill?: string }) => {
// Abort any previous stream — only one chat turn in flight per hook.
abortControllerRef.current?.abort();
const controller = new AbortController();
Expand All @@ -170,9 +189,12 @@ export function useAgentChat({
setError(null);
setIsStreaming(true);

const { skill, text } = resolveSkill(message, opts?.skill);

const payload = {
message,
message: text,
agent,
...(skill ? { skill } : {}),
...(threadIdRef.current ? { threadId: threadIdRef.current } : {}),
};

Expand Down
18 changes: 18 additions & 0 deletions packages/appkit/src/core/agent/frontmatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;

/**
* Splits a `--- yaml ---\nbody` markdown string into its raw YAML block and
* trimmed body. Returns `yaml: null` when there is no leading frontmatter
* fence. Shared by the agent loader ({@link parseFrontmatter}) and the skill
* parser so the fence regex lives in one place.
*/
export function splitFrontmatter(raw: string): {
yaml: string | null;
body: string;
} {
const match = raw.match(FRONTMATTER_RE);
if (!match) {
return { yaml: null, body: raw.trim() };
}
return { yaml: match[1], body: match[2].trim() };
}
Loading
Loading