From f9525a27c78042453a4d57e3c4722ffcafcee0ab Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Sun, 23 Aug 2026 08:45:02 -0700 Subject: [PATCH 1/8] =?UTF-8?q?feat(skills):=20add=20@tanstack/ai-skills?= =?UTF-8?q?=20=E2=80=94=20portable=20Agent=20Skills=20middleware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `@tanstack/ai-skills`: portable `SKILL.md` skills as a first-class `chat()` middleware. `withSkills(sources, options?)` renders a per-model-family catalog and a `load_skill` tool so any tool-calling model loads skills on demand, on any provider, with no server sandbox. - Sources: `inlineSkill`, `skillDirectory` (/node), build-time `staticSkills` (/static) with a Vite plugin; combinators `aggregate`/`dedupe`/`filter`/`cache`. - Tools: `load_skill` (enum-constrained names, activation dedupe, frozen result shape) and `createResourceTool` (`read_skill_resource`, path-traversal guard). - `validateSkill` for author-time native-constraint linting; conformance suite at /testing (`runSkillSourceConformance`). - Catalog renders `` XML for Anthropic, markdown elsewhere. Portable and hosted (native) skills refuse to combine in one call. Core `@tanstack/ai` now exports `SkillLimitError`; `codeExecutionTool` (ai-anthropic) frames its 8-skill cap with it, and `shellTool` (openai-base) now validates `skill_id` format. `ai-sandbox` reuses the shared skill-directory walk. Adds e2e wire coverage (portable catalog per family + co-existence refusal), docs (docs/skills/*), a per-package SKILL.md, and a `/skills` demo in testing/panel. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/portable-agent-skills.md | 13 + docs/config.json | 27 +- docs/getting-started/agent-skills.md | 8 +- docs/skills/agent-skills.md | 187 +++++++++++++ docs/skills/skill-sources.md | 135 ++++++++++ docs/skills/writing-adapters.md | 115 ++++++++ docs/tools/provider-skills.md | 25 ++ .../src/tools/code-execution-tool.ts | 10 +- .../tests/code-execution-tool.test.ts | 12 + packages/ai-sandbox/package.json | 3 +- packages/ai-sandbox/src/agents-file.ts | 43 +-- packages/ai-skills/package.json | 90 +++++++ packages/ai-skills/skills/ai-skills/SKILL.md | 111 ++++++++ packages/ai-skills/src/catalog.ts | 40 +++ packages/ai-skills/src/combinators.ts | 184 +++++++++++++ packages/ai-skills/src/errors.ts | 7 + packages/ai-skills/src/index.ts | 49 ++++ packages/ai-skills/src/middleware.ts | 215 +++++++++++++++ packages/ai-skills/src/node/index.ts | 211 +++++++++++++++ packages/ai-skills/src/parse.ts | 247 +++++++++++++++++ packages/ai-skills/src/sources/inline.ts | 59 ++++ packages/ai-skills/src/static/index.ts | 64 +++++ packages/ai-skills/src/testing/index.ts | 98 +++++++ packages/ai-skills/src/tools/load-skill.ts | 87 ++++++ packages/ai-skills/src/tools/read-resource.ts | 49 ++++ packages/ai-skills/src/types.ts | 82 ++++++ packages/ai-skills/src/util.ts | 30 +++ packages/ai-skills/src/validate.ts | 63 +++++ packages/ai-skills/src/walk.ts | 83 ++++++ packages/ai-skills/tests/catalog.test.ts | 33 +++ packages/ai-skills/tests/combinators.test.ts | 54 ++++ packages/ai-skills/tests/conformance.test.ts | 40 +++ .../tests/fixtures/skills/alpha/SKILL.md | 9 + .../fixtures/skills/alpha/references/note.md | 1 + .../fixtures/skills/alpha/scripts/run.py | 1 + .../tests/fixtures/skills/beta/SKILL.md | 8 + .../tests/fixtures/skills/gamma/SKILL.md | 8 + packages/ai-skills/tests/load-skill.test.ts | 49 ++++ packages/ai-skills/tests/parse.test.ts | 75 ++++++ packages/ai-skills/tests/validate.test.ts | 26 ++ packages/ai-skills/tests/walk.test.ts | 62 +++++ packages/ai-skills/tsconfig.json | 8 + packages/ai-skills/vite.config.ts | 45 ++++ packages/ai/src/index.ts | 2 + packages/ai/src/utilities/errors.ts | 42 +++ packages/openai-base/src/tools/shell-tool.ts | 27 ++ pnpm-lock.yaml | 26 ++ testing/e2e/package.json | 1 + testing/e2e/src/routeTree.gen.ts | 47 +++- .../src/routes/api.portable-skills-wire.ts | 199 ++++++++++++++ .../e2e/tests/portable-skills-wire.spec.ts | 68 +++++ testing/panel/package.json | 1 + .../panel/skills/emoji-storyteller/SKILL.md | 14 + testing/panel/skills/haiku/SKILL.md | 15 ++ testing/panel/skills/pirate-speak/SKILL.md | 18 ++ .../pirate-speak/references/glossary.md | 10 + testing/panel/src/components/Header.tsx | 19 ++ testing/panel/src/lib/skills-store.ts | 25 ++ testing/panel/src/routeTree.gen.ts | 63 +++++ testing/panel/src/routes/api.skills-chat.ts | 121 +++++++++ .../panel/src/routes/api.skills-inspect.ts | 29 ++ testing/panel/src/routes/skills.tsx | 252 ++++++++++++++++++ 62 files changed, 3691 insertions(+), 54 deletions(-) create mode 100644 .changeset/portable-agent-skills.md create mode 100644 docs/skills/agent-skills.md create mode 100644 docs/skills/skill-sources.md create mode 100644 docs/skills/writing-adapters.md create mode 100644 packages/ai-skills/package.json create mode 100644 packages/ai-skills/skills/ai-skills/SKILL.md create mode 100644 packages/ai-skills/src/catalog.ts create mode 100644 packages/ai-skills/src/combinators.ts create mode 100644 packages/ai-skills/src/errors.ts create mode 100644 packages/ai-skills/src/index.ts create mode 100644 packages/ai-skills/src/middleware.ts create mode 100644 packages/ai-skills/src/node/index.ts create mode 100644 packages/ai-skills/src/parse.ts create mode 100644 packages/ai-skills/src/sources/inline.ts create mode 100644 packages/ai-skills/src/static/index.ts create mode 100644 packages/ai-skills/src/testing/index.ts create mode 100644 packages/ai-skills/src/tools/load-skill.ts create mode 100644 packages/ai-skills/src/tools/read-resource.ts create mode 100644 packages/ai-skills/src/types.ts create mode 100644 packages/ai-skills/src/util.ts create mode 100644 packages/ai-skills/src/validate.ts create mode 100644 packages/ai-skills/src/walk.ts create mode 100644 packages/ai-skills/tests/catalog.test.ts create mode 100644 packages/ai-skills/tests/combinators.test.ts create mode 100644 packages/ai-skills/tests/conformance.test.ts create mode 100644 packages/ai-skills/tests/fixtures/skills/alpha/SKILL.md create mode 100644 packages/ai-skills/tests/fixtures/skills/alpha/references/note.md create mode 100644 packages/ai-skills/tests/fixtures/skills/alpha/scripts/run.py create mode 100644 packages/ai-skills/tests/fixtures/skills/beta/SKILL.md create mode 100644 packages/ai-skills/tests/fixtures/skills/gamma/SKILL.md create mode 100644 packages/ai-skills/tests/load-skill.test.ts create mode 100644 packages/ai-skills/tests/parse.test.ts create mode 100644 packages/ai-skills/tests/validate.test.ts create mode 100644 packages/ai-skills/tests/walk.test.ts create mode 100644 packages/ai-skills/tsconfig.json create mode 100644 packages/ai-skills/vite.config.ts create mode 100644 testing/e2e/src/routes/api.portable-skills-wire.ts create mode 100644 testing/e2e/tests/portable-skills-wire.spec.ts create mode 100644 testing/panel/skills/emoji-storyteller/SKILL.md create mode 100644 testing/panel/skills/haiku/SKILL.md create mode 100644 testing/panel/skills/pirate-speak/SKILL.md create mode 100644 testing/panel/skills/pirate-speak/references/glossary.md create mode 100644 testing/panel/src/lib/skills-store.ts create mode 100644 testing/panel/src/routes/api.skills-chat.ts create mode 100644 testing/panel/src/routes/api.skills-inspect.ts create mode 100644 testing/panel/src/routes/skills.tsx diff --git a/.changeset/portable-agent-skills.md b/.changeset/portable-agent-skills.md new file mode 100644 index 0000000000..e3737a9071 --- /dev/null +++ b/.changeset/portable-agent-skills.md @@ -0,0 +1,13 @@ +--- +'@tanstack/ai-skills': minor +'@tanstack/ai': minor +'@tanstack/ai-anthropic': patch +'@tanstack/openai-base': patch +'@tanstack/ai-sandbox': patch +--- + +Add `@tanstack/ai-skills`: portable Agent Skills (`SKILL.md`) as a first-class `chat()` middleware. + +`withSkills(sources, options?)` renders a skill catalog and a `load_skill` tool so any tool-calling model can load skills on demand, on any provider, with no server sandbox. Skills come from `inlineSkill`, `skillDirectory` (`/node`), or a build-time `staticSkills` bundle, and compose via `aggregate`/`dedupe`/`filter`/`cache`. `createResourceTool` exposes a skill's bundled files through `read_skill_resource`, and `runSkillSourceConformance` (`/testing`) validates custom `SkillSource` adapters. The catalog renders as `` XML for Anthropic models and markdown for others; portable and hosted (native) skills refuse to combine in one call. + +Core `@tanstack/ai` now exports `SkillLimitError`. The native factories throw it (or add validation): `codeExecutionTool` (`@tanstack/ai-anthropic`) frames its 8-skill cap, and `shellTool` (`@tanstack/openai-base`) now validates `skill_id` format instead of nothing. `@tanstack/ai-sandbox` reuses the shared skill-directory walk from `@tanstack/ai-skills`. diff --git a/docs/config.json b/docs/config.json index 18ee148510..dbd5ae5894 100644 --- a/docs/config.json +++ b/docs/config.json @@ -61,7 +61,7 @@ "label": "Agent Skills (TanStack Intent)", "to": "getting-started/agent-skills", "addedAt": "2026-04-17", - "updatedAt": "2026-07-26" + "updatedAt": "2026-08-22" } ] }, @@ -94,7 +94,8 @@ { "label": "Provider Skills", "to": "tools/provider-skills", - "addedAt": "2026-06-04" + "addedAt": "2026-06-04", + "updatedAt": "2026-08-22" }, { "label": "Tool Architecture", @@ -128,6 +129,26 @@ } ] }, + { + "label": "Skills", + "children": [ + { + "label": "Portable Agent Skills", + "to": "skills/agent-skills", + "addedAt": "2026-08-22" + }, + { + "label": "Skill Sources", + "to": "skills/skill-sources", + "addedAt": "2026-08-22" + }, + { + "label": "Write a Skill Source", + "to": "skills/writing-adapters", + "addedAt": "2026-08-22" + } + ] + }, { "label": "MCP", "children": [ @@ -834,7 +855,7 @@ "updatedAt": "2026-08-21" }, { - "label": "Sampling → modelOptions", + "label": "Sampling \u00e2\u2020\u2019 modelOptions", "to": "migration/sampling-options-to-model-options", "addedAt": "2026-06-03" } diff --git a/docs/getting-started/agent-skills.md b/docs/getting-started/agent-skills.md index 96c9ccf05a..ba4a6c9a52 100644 --- a/docs/getting-started/agent-skills.md +++ b/docs/getting-started/agent-skills.md @@ -14,7 +14,13 @@ keywords: - SKILL.md - AGENTS.md --- -> **Looking for runtime snippets inside Code Mode?** Those are a different feature — see [Code Mode with Snippets](../code-mode/code-mode-with-snippets). This page is about _agent-authoring_ skills: markdown files that teach your coding assistant how TanStack AI works. +> **Looking for runtime snippets inside Code Mode?** Those are a different feature, see [Code Mode with Snippets](../code-mode/code-mode-with-snippets). This page is about _agent-authoring_ skills: markdown files that teach your coding assistant how TanStack AI works. + +> **Want your app's model to load `SKILL.md` skills at runtime?** That is a +> different feature with a confusingly similar name. See +> [Portable Agent Skills](../skills/agent-skills): a runtime catalog plus a +> `load_skill` tool, for the model inside your app. This page is only about +> teaching your _coding assistant_ how to use TanStack AI. ## Step 1: Install TanStack AI If you haven't already, install `@tanstack/ai` plus any adapter packages you need. See the [Quick Start](./quick-start) for a full walkthrough. diff --git a/docs/skills/agent-skills.md b/docs/skills/agent-skills.md new file mode 100644 index 0000000000..4aefb3c3d2 --- /dev/null +++ b/docs/skills/agent-skills.md @@ -0,0 +1,187 @@ +--- +title: Portable Agent Skills +id: portable-agent-skills +order: 1 +description: "Give any tool-calling model a library of SKILL.md skills it can load on demand, on any provider, with the withSkills middleware from @tanstack/ai-skills." +keywords: + - tanstack ai + - agent skills + - SKILL.md + - portable skills + - withSkills + - load_skill + - skill catalog +--- + +You have a set of `SKILL.md` files: reusable instructions that teach a model how +to do one thing well (build a slide deck, follow your brand voice, fill a PDF). +You want the model to reach for the right one on its own, on whatever provider +you happen to run, without pasting every skill into the system prompt. + +`withSkills` from `@tanstack/ai-skills` does this. It renders a short catalog of +the skills you offer, and gives the model a `load_skill` tool. The model reads +the catalog, picks a skill, calls `load_skill`, and gets the full instructions +back, only when it needs them. This works with any tool-calling model. + +> This is the **portable** path: it runs on the model you already use, no server +> sandbox required. For hosted skills that run in a provider's sandbox, see +> [Provider Skills](../tools/provider-skills). The two do not mix in one call, +> see [Portable vs hosted](../tools/provider-skills#portable-vs-hosted-skills). + +## Install + +```bash +npm install @tanstack/ai-skills +``` + +## Add skills to a chat + +Define a skill inline, then pass it to `withSkills` in the `middleware` array. +The middleware handles the catalog and the `load_skill` tool for you. + +```typescript +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { anthropicText } from '@tanstack/ai-anthropic' +import { inlineSkill, withSkills } from '@tanstack/ai-skills' + +const pptx = inlineSkill({ + name: 'pptx-builder', + description: 'Build and edit PowerPoint decks with python-pptx.', + instructions: ` +# Building a deck +Use python-pptx. Open or create the presentation, edit slides, then save. +Keep one idea per slide. +`, +}) + +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: anthropicText('claude-sonnet-4-5'), + messages, + middleware: [withSkills(pptx)], + }) + + return toServerSentEventsResponse(stream) +} +``` + +That is the whole setup. The model now sees `pptx-builder` in its catalog and +can call `load_skill` to pull in the instructions when a deck-building task comes +up. + +## What the model sees + +`withSkills` adds two things to the request: + +- A catalog in the system prompt, one line per skill (name plus description). + The `name` of `load_skill` is constrained to your skill names, so the model + cannot invent one. +- A `load_skill` tool. When the model calls it, the middleware returns the + skill body (frontmatter stripped) plus a list of any bundled resources. + +Loading the same skill twice in one conversation returns a short "already +loaded" marker instead of repeating the body, so context stays lean. + +## Offer more than one skill + +Pass an array. Skills are sorted by name and deduped for you. + +```typescript +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { anthropicText } from '@tanstack/ai-anthropic' +import { inlineSkill, withSkills } from '@tanstack/ai-skills' + +const pptx = inlineSkill({ + name: 'pptx-builder', + description: 'Build and edit PowerPoint decks with python-pptx.', + instructions: '# Building a deck\nUse python-pptx. Edit slides, then save.', +}) + +const brand = inlineSkill({ + name: 'brand-voice', + description: 'Write in the company brand voice.', + instructions: '# Brand voice\nWarm, direct, no jargon.', +}) + +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: anthropicText('claude-sonnet-4-5'), + messages, + middleware: [withSkills([pptx, brand])], + }) + + return toServerSentEventsResponse(stream) +} +``` + +Inline skills are the quickest start, but you rarely keep skills in code. Read +them from a folder, a build-time bundle, or your own database. See +[Skill sources](./skill-sources). + +## Tune the catalog + +`withSkills` takes options for the common cases: + +```ts ignore +withSkills(sources, { + // Cap the catalog so a big skill library doesn't tax every request. + // Default 4000 tokens; throws if exceeded unless you supply a reducer. + maxCatalogTokens: 4000, + + // Require a human approval before load_skill runs. Default false. + requireApproval: true, +}) +``` + +The catalog is rendered per model family: Anthropic models get the +`` XML they are tuned for, everything else gets a plain +markdown list. You can override this with a `renderCatalog` function or an +`instructionTemplate` string that has a `{skills}` placeholder. + +## Read a skill's files + +Some skills bundle reference files (a style guide, a schema, an example). To let +the model read them, add `createResourceTool` to your `tools`. `withSkills` +notices it and tells the model it can call `read_skill_resource`. + +```typescript +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { anthropicText } from '@tanstack/ai-anthropic' +import { createResourceTool, inlineSkill, withSkills } from '@tanstack/ai-skills' + +const pdf = inlineSkill({ + name: 'pdf-filler', + description: 'Fill a PDF form from a data object.', + instructions: '# Fill a PDF\nSee references/fields.md for the field map.', + resources: { 'references/fields.md': 'name -> field_1\nemail -> field_2' }, +}) + +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: anthropicText('claude-sonnet-4-5'), + messages, + tools: [createResourceTool(pdf)], + middleware: [withSkills(pdf)], + }) + + return toServerSentEventsResponse(stream) +} +``` + +Without the resource tool, resources are still listed in the `load_skill` +result, but the model is told they are not loadable in this setup. + +## Where to go next + +- [Skill sources](./skill-sources) — load skills from a folder, a build-time + bundle, or your own store, and combine several sources. +- [Write a skill source](./writing-adapters) — back skills with S3, a database, + or a registry, and prove it with the conformance suite. +- [Provider Skills](../tools/provider-skills) — hosted skills that run in a + provider sandbox, and when to use them instead. diff --git a/docs/skills/skill-sources.md b/docs/skills/skill-sources.md new file mode 100644 index 0000000000..2aa7044f19 --- /dev/null +++ b/docs/skills/skill-sources.md @@ -0,0 +1,135 @@ +--- +title: Skill Sources +id: skill-sources +order: 2 +description: "Load portable Agent Skills from a folder, a build-time bundle, or your own store, and combine several sources with aggregate, dedupe, filter, and cache." +keywords: + - tanstack ai + - skill sources + - skillDirectory + - staticSkills + - inlineSkill + - skill catalog + - edge skills +--- + +Inline skills are fine for a demo, but real skills live somewhere: a folder in +your repo, a bundle baked at build time, or rows in a database. A `SkillSource` +is how `withSkills` reads them. This page covers the three built-in sources and +how to combine them. + +Every source is bytes only, so the same middleware works on the edge, in a +Worker, or on a server. Pick the source that matches where your skills live. + +## From a folder + +`skillDirectory` walks a folder for `SKILL.md` files. It lives under the `/node` +entry point because it reads the filesystem, so use it on a server, not the edge. + +```typescript +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { anthropicText } from '@tanstack/ai-anthropic' +import { withSkills } from '@tanstack/ai-skills' +import { skillDirectory } from '@tanstack/ai-skills/node' + +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: anthropicText('claude-sonnet-4-5'), + messages, + middleware: [withSkills(skillDirectory('./skills'))], + }) + + return toServerSentEventsResponse(stream) +} +``` + +A skill folder is any directory that contains a `SKILL.md`. Bundled files under +`references/` and `assets/` become the skill's resources, and files under +`scripts/` are listed too (they are inventoried, not run, in this release). + +By default `skillDirectory` is strict: a malformed `SKILL.md` is an error, so a +broken file never slips into your catalog silently. Pass `{ strict: false }` to +skip bad files and load the rest. + +## From a build-time bundle (edge-safe) + +For an edge deployment you cannot read the filesystem at request time. The Vite +plugin globs your skills at build time and bakes them into the bundle, so +`staticSkills` needs no filesystem at runtime. + +Add the plugin to your Vite config: + +```ts ignore +import { defineConfig } from 'vite' +import { skillsCatalogPlugin } from '@tanstack/ai-skills/node' + +export default defineConfig({ + plugins: [skillsCatalogPlugin({ dir: 'skills' })], +}) +``` + +Then wrap the generated catalog: + +```ts ignore +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { anthropicText } from '@tanstack/ai-anthropic' +import { withSkills } from '@tanstack/ai-skills' +import { staticSkills } from '@tanstack/ai-skills/static' +import { catalog } from 'virtual:tanstack-skills' + +const skills = staticSkills(catalog) + +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: anthropicText('claude-sonnet-4-5'), + messages, + middleware: [withSkills(skills)], + }) + + return toServerSentEventsResponse(stream) +} +``` + +Because the catalog is baked at build time, `skills.names` is a typed list of +your skill names, and the `load_skill` tool's `name` is constrained to it. + +## From your own store + +For DB, S3, or registry-backed skills, define a `SkillSource` yourself. It is a +small interface: list the skills, and load one by name. See +[Write a skill source](./writing-adapters) for the full walkthrough and the +conformance suite that proves your adapter behaves. + +## Combine sources + +Real setups layer skills: org-wide, per-project, per-tenant. Combine them with +the combinators, then pass the result to `withSkills`. + +```ts ignore +import { aggregate, dedupe } from '@tanstack/ai-skills' + +// Org skills plus this tenant's skills, org first on a name clash. +const source = dedupe(aggregate([orgSkills, tenantSkills])) +``` + +| Combinator | What it does | +|---|---| +| `aggregate([a, b])` | Concatenate sources in order. No dedupe. | +| `dedupe(source)` | First occurrence of a name wins, warns on a clash. | +| `filter(source, fn)` | Hide skills your predicate rejects. Hidden skills never reach the catalog. | +| `cache(source)` | Memoize `list()` and `load()`. Shares one fetch across concurrent calls. | + +Passing an array straight to `withSkills([a, b])` is shorthand for +`dedupe(aggregate([a, b]))`. A single source is used as-is and never wrapped, so +a tenant-scoped source is never cached into a shared bucket by accident. Reach +for `cache` yourself only when the source is safe to share. + +## Where to go next + +- [Portable Agent Skills](./agent-skills) — the middleware, the catalog, and the + `load_skill` flow. +- [Write a skill source](./writing-adapters) — back skills with your own store. diff --git a/docs/skills/writing-adapters.md b/docs/skills/writing-adapters.md new file mode 100644 index 0000000000..d932410649 --- /dev/null +++ b/docs/skills/writing-adapters.md @@ -0,0 +1,115 @@ +--- +title: Write a Skill Source +id: writing-skill-sources +order: 3 +description: "Back portable Agent Skills with your own store (S3, a database, a registry) by implementing SkillSource, and prove it with the shipped conformance suite." +keywords: + - tanstack ai + - SkillSource + - custom skill source + - conformance + - runSkillSourceConformance + - s3 skills + - database skills +--- + +Your skills live in S3, a database, or a private registry, not on disk. To feed +them to `withSkills`, implement a `SkillSource`. It is a small, bytes-only +interface, so it works anywhere, including the edge. + +This page shows the minimum source, then how to prove it is correct with the +conformance suite that ships in the package. + +## The minimum source + +A source needs two methods: `list` returns the catalog, `load` returns one +skill's `SKILL.md` body. Here is an S3-backed one. + +```ts ignore +import type { SkillSource } from '@tanstack/ai-skills' + +export function s3Skills(bucket: S3Bucket): SkillSource { + return { + async list() { + const index = await bucket.getJSON('skills/index.json') + return index.map((s) => ({ name: s.name, description: s.description })) + }, + async load(name) { + return bucket.getText(`skills/${name}/SKILL.md`) + }, + } +} +``` + +Pass it straight to `withSkills(s3Skills(bucket))`. The middleware strips the +frontmatter from what `load` returns, so return the raw `SKILL.md`. + +## Add resources and a revision + +Two optional methods make the source better: + +- `revision()` returns a stable string that changes only when content changes. + `withSkills` uses it to cache the catalog and keep prompt caching stable, so + add it whenever you can compute one cheaply (a bucket ETag, a content hash). +- `listResources` and `readResource` expose a skill's bundled files so + `read_skill_resource` can read them. + +```ts ignore +import type { SkillSource } from '@tanstack/ai-skills' + +export function s3Skills(bucket: S3Bucket): SkillSource { + return { + revision: () => bucket.getText('skills/index.etag'), + async list() { + /* as above */ + }, + async load(name) { + return bucket.getText(`skills/${name}/SKILL.md`) + }, + async listResources(name) { + return bucket.listKeys(`skills/${name}/references/`) + }, + async readResource(name, path) { + return bucket.getBytes(`skills/${name}/${path}`) + }, + } +} +``` + +The source is bytes only on purpose. There is no `path` field, so a database or +registry source is a first-class citizen, not a second-class one bolted onto a +filesystem assumption. + +## Prove it with the conformance suite + +Adapter code is easy to get subtly wrong (a missing skill that returns empty +instead of throwing, a resource path that escapes the skill root). The package +ships a conformance suite so you test behavior, not guesswork. + +Seed your source with the fixture the suite expects (a skill `alpha` with a +`references/note.md` resource whose contents are `hello`, and a skill `beta`), +then run it: + +```ts ignore +import { runSkillSourceConformance } from '@tanstack/ai-skills/testing' +import { s3Skills } from './s3-skills' + +runSkillSourceConformance(() => s3Skills(makeTestBucket()), 's3') +``` + +The suite checks the things that break in production: + +- a missing skill name throws, it does not return empty +- resources load, and a path like `../../etc/passwd` is rejected +- `revision()` is stable across identical content +- concurrent `list()` calls stay consistent +- script bytes come back correctly (so your source keeps working when script + execution lands in a later release) + +If it passes, your source is safe to hand to `withSkills`. + +## Where to go next + +- [Skill sources](./skill-sources) — the built-in sources and the combinators. +- [Portable Agent Skills](./agent-skills) — the middleware and the `load_skill` + flow your source feeds. diff --git a/docs/tools/provider-skills.md b/docs/tools/provider-skills.md index a53f0363a5..9bd0effa1f 100644 --- a/docs/tools/provider-skills.md +++ b/docs/tools/provider-skills.md @@ -35,6 +35,29 @@ the rest. --- +## Portable vs hosted skills + +There are two ways to give a model skills in TanStack AI, and they solve +different problems: + +- **Portable skills** ([`withSkills`](../skills/agent-skills)) render a catalog + and let the model call `load_skill`. They run on any tool-calling model, need + no server sandbox, and read `SKILL.md` from a folder, a bundle, or your own + store. Reach for these first. +- **Hosted (provider) skills**, this page, run inside the provider's server-side + sandbox and are referenced by ID. They are non-portable and require an + execution tool, but the provider does the running. + +Use hosted skills when you need the provider's sandbox (running code, producing +files). Use portable skills for everything else. + +The two do not mix in one `chat()` call. If you attach hosted skills to a +`code_execution` or `shell` tool and also add `withSkills`, the middleware +throws: the model would see two catalogs and two protocols. Pick one delivery +mode per call. + +--- + ## Anthropic: skills via `codeExecutionTool` ### 1. Install the package @@ -172,6 +195,8 @@ handled by `codeExecutionTool` or `shellTool`. ## Related pages +- [Portable Agent Skills](../skills/agent-skills) — the provider-agnostic + alternative: a catalog plus `load_skill`, on any tool-calling model. - [Provider Tools](./provider-tools.md) — all native provider tools and the type-level guard that prevents pairing a tool with an unsupported model. - [Anthropic adapter → `codeExecutionTool`](../adapters/anthropic.md#codeexecutiontool) diff --git a/packages/ai-anthropic/src/tools/code-execution-tool.ts b/packages/ai-anthropic/src/tools/code-execution-tool.ts index 32986c429f..e726f45c02 100644 --- a/packages/ai-anthropic/src/tools/code-execution-tool.ts +++ b/packages/ai-anthropic/src/tools/code-execution-tool.ts @@ -6,6 +6,7 @@ import type { BetaCodeExecutionTool20250522, BetaCodeExecutionTool20250825, } from '@anthropic-ai/sdk/resources/beta' +import { SkillLimitError } from '@tanstack/ai' import type { ProviderTool, Tool } from '@tanstack/ai' export type CodeExecutionToolConfig = @@ -87,7 +88,14 @@ export function codeExecutionTool( const { skills } = options if (skills) { if (skills.length > 8) { - throw new Error('code_execution supports at most 8 skills per request.') + throw new SkillLimitError({ + provider: 'anthropic', + path: 'native', + limit: 'code_execution supports at most 8 skills per request', + allowed: 8, + actual: skills.length, + offending: skills.map((s) => s.skill_id), + }) } for (const skill of skills) { if (skill.skill_id.length < 1 || skill.skill_id.length > 64) { diff --git a/packages/ai-anthropic/tests/code-execution-tool.test.ts b/packages/ai-anthropic/tests/code-execution-tool.test.ts index a480bce091..b261178f11 100644 --- a/packages/ai-anthropic/tests/code-execution-tool.test.ts +++ b/packages/ai-anthropic/tests/code-execution-tool.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { SkillLimitError } from '@tanstack/ai' import { codeExecutionTool, convertCodeExecutionToolToAdapterFormat, @@ -37,6 +38,17 @@ describe('codeExecutionTool', () => { skill_id: `s${i}`, })) expect(() => codeExecutionTool(config, { skills })).toThrow(/at most 8/i) + try { + codeExecutionTool(config, { skills }) + } catch (err) { + expect(err).toBeInstanceOf(SkillLimitError) + const e = err as SkillLimitError + expect(e.provider).toBe('anthropic') + expect(e.path).toBe('native') + expect(e.allowed).toBe(8) + expect(e.actual).toBe(9) + expect(e.offending).toHaveLength(9) + } }) it('rejects an empty skill_id', () => { diff --git a/packages/ai-sandbox/package.json b/packages/ai-sandbox/package.json index 0e6abace5e..39aea81632 100644 --- a/packages/ai-sandbox/package.json +++ b/packages/ai-sandbox/package.json @@ -65,7 +65,8 @@ } }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0" + "@modelcontextprotocol/sdk": "^1.29.0", + "@tanstack/ai-skills": "workspace:^" }, "peerDependencies": { "@ngrok/ngrok": "^1.0.0", diff --git a/packages/ai-sandbox/src/agents-file.ts b/packages/ai-sandbox/src/agents-file.ts index 5d4651eefb..ce3ea30e6f 100644 --- a/packages/ai-sandbox/src/agents-file.ts +++ b/packages/ai-sandbox/src/agents-file.ts @@ -11,6 +11,7 @@ * file by name (CLAUDE.md for Claude Code, GEMINI.md for Gemini CLI, …). * We write a single authoritative AGENTS.md and point each name at it. */ +import { walkSkillDirs } from '@tanstack/ai-skills' import type { SandboxHandle } from './contracts' import type { WorkspaceSkill } from './workspace' @@ -49,10 +50,6 @@ export interface DiscoveredSkillDir { dir: string } -const SKILL_FILE = 'SKILL.md' -const SKIP_DIR_NAMES = new Set(['.git', 'node_modules']) -const MAX_SKILL_WALK_DEPTH = 6 - function basenameOf(path: string): string { const segments = path.split('/').filter((segment) => segment !== '') return segments[segments.length - 1] ?? path @@ -66,48 +63,24 @@ function basenameOf(path: string): string { * A flat clone with `SKILL.md` at the root is returned as one entry named * after the clone. If no `SKILL.md` is found, the clone itself is returned * so existing basename projection still works. + * + * The tree walk itself is the shared `walkSkillDirs` from `@tanstack/ai-skills` + * (parameterized over an injected lister — here `handle.fs.list`). The + * empty→clone-dir fallback is kept here because it is correct for harness + * projection but wrong for a skills catalog, so it must not live in the shared + * helper. */ export async function discoverSkillDirs( handle: SandboxHandle, cloneDir: string, ): Promise> { - const found: Array = [] - await walkSkillDirs(handle, cloneDir, found, 0) + const found = await walkSkillDirs((dir) => handle.fs.list(dir), cloneDir) if (found.length === 0) { return [{ name: basenameOf(cloneDir), dir: cloneDir }] } return found } -async function walkSkillDirs( - handle: SandboxHandle, - dir: string, - found: Array, - depth: number, -): Promise { - if (depth > MAX_SKILL_WALK_DEPTH) return - let entries: Awaited> - try { - entries = await handle.fs.list(dir) - } catch { - return - } - const hasSkill = entries.some( - (entry) => - entry.type === 'file' && - entry.name.toLowerCase() === SKILL_FILE.toLowerCase(), - ) - if (hasSkill) { - found.push({ name: basenameOf(dir), dir }) - return - } - for (const entry of entries) { - if (entry.type !== 'dir') continue - if (entry.name.startsWith('.') || SKIP_DIR_NAMES.has(entry.name)) continue - await walkSkillDirs(handle, entry.path, found, depth + 1) - } -} - /** Format workspace scripts as a `## Workspace scripts` markdown section. */ export function formatWorkspaceScriptsSection( scripts: Record, diff --git a/packages/ai-skills/package.json b/packages/ai-skills/package.json new file mode 100644 index 0000000000..6b0e3166b8 --- /dev/null +++ b/packages/ai-skills/package.json @@ -0,0 +1,90 @@ +{ + "name": "@tanstack/ai-skills", + "version": "0.0.0", + "description": "Portable Agent Skills (SKILL.md) as a first-class chat() middleware for TanStack AI.", + "author": "Tanner Linsley", + "license": "MIT", + "homepage": "https://tanstack.com/ai", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-skills" + }, + "bugs": { + "url": "https://github.com/TanStack/ai/issues" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + }, + "./node": { + "types": "./dist/esm/node/index.d.ts", + "import": "./dist/esm/node/index.js" + }, + "./static": { + "types": "./dist/esm/static/index.d.ts", + "import": "./dist/esm/static/index.js" + }, + "./testing": { + "types": "./dist/esm/testing/index.d.ts", + "import": "./dist/esm/testing/index.js" + } + }, + "sideEffects": false, + "engines": { + "node": ">=18" + }, + "files": [ + "dist", + "src", + "skills" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "oxlint src --type-aware --fix", + "test:build": "publint --strict", + "test:oxlint": "oxlint src --type-aware", + "test:lib": "vitest", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "keywords": [ + "ai", + "ai-sdk", + "typescript", + "tanstack", + "tanstack-intent", + "skills", + "agent-skills", + "skill-md", + "tool-calling", + "llm" + ], + "dependencies": { + "@tanstack/ai": "workspace:^" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^", + "vitest": "^4.1.10", + "zod": "^3.0.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + }, + "devDependencies": { + "@vitest/coverage-v8": "4.1.10", + "vitest": "^4.1.10", + "zod": "^4.2.0" + } +} diff --git a/packages/ai-skills/skills/ai-skills/SKILL.md b/packages/ai-skills/skills/ai-skills/SKILL.md new file mode 100644 index 0000000000..2e2f8e2d62 --- /dev/null +++ b/packages/ai-skills/skills/ai-skills/SKILL.md @@ -0,0 +1,111 @@ +--- +name: ai-skills +description: > + Portable Agent Skills (SKILL.md) for TanStack AI with @tanstack/ai-skills. + Renders a skill catalog and a load_skill tool via the withSkills middleware so + any tool-calling model loads skills on demand, on any provider. Covers the + SkillSource interface, inlineSkill/skillDirectory/staticSkills, the aggregate/ + dedupe/filter/cache combinators, read_skill_resource, and the conformance + suite. Use for provider-agnostic runtime skills — NOT hosted provider skills + (codeExecutionTool/shellTool), which run in a provider sandbox. +type: core +library: tanstack-ai +library_version: '0.0.0' +sources: + - 'TanStack/ai:docs/skills/agent-skills.md' + - 'TanStack/ai:docs/skills/skill-sources.md' + - 'TanStack/ai:docs/skills/writing-adapters.md' + - 'TanStack/ai:docs/tools/provider-skills.md' +--- + +# TanStack AI Skills + +> Builds on the `ai-core` skill in `@tanstack/ai`. Package: `@tanstack/ai-skills`. + +Portable Agent Skills give a tool-calling model a library of `SKILL.md` skills it +can load on demand, on any provider, with no server sandbox. This is separate +from hosted **Provider Skills** (`codeExecutionTool` / `shellTool`), which run in +the provider's sandbox and are referenced by ID. + +## Two skill features, do not confuse them + +| Need | Use | +| ------------------------------------------------- | ---------------------------------------- | +| Model loads SKILL.md at runtime, any provider | `withSkills` (this package) | +| Hosted skill runs in a provider sandbox by ID | `codeExecutionTool` / `shellTool` | +| Teach a coding assistant how to use TanStack AI | Ship a `SKILL.md`, install via Intent | + +The portable and hosted paths do not mix in one `chat()` call: `withSkills` +throws if a `code_execution`/`shell` tool in the same call carries skills. + +## Add skills to a chat + +```typescript +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { anthropicText } from '@tanstack/ai-anthropic' +import { inlineSkill, withSkills } from '@tanstack/ai-skills' + +const pptx = inlineSkill({ + name: 'pptx-builder', + description: 'Build and edit PowerPoint decks with python-pptx.', + instructions: '# Building a deck\nUse python-pptx. Edit slides, then save.', +}) + +const stream = chat({ + adapter: anthropicText('claude-sonnet-4-5'), + messages, + middleware: [withSkills(pptx)], +}) +``` + +`withSkills` adds a catalog to the system prompt and a `load_skill` tool whose +`name` is constrained to your skill names. It renders `` XML +for Anthropic models and markdown for the rest. Re-loading a skill in the same +conversation returns a short "already loaded" marker. + +## Sources + +A `SkillSource` is bytes only (no filesystem assumption), so the middleware runs +on the edge too. + +- `inlineSkill({ name, description, instructions, resources? })` — one skill in + code or a DB row. Edge-safe. +- `skillDirectory(root, { strict? })` — walk a folder for `SKILL.md`. Import from + `@tanstack/ai-skills/node` (uses `node:fs`). Strict by default. +- `staticSkills(catalog)` — build-time bundle via `skillsCatalogPlugin` (Vite). + Edge-safe, and `.names` is a typed union. + +Combine with `aggregate`, `dedupe`, `filter`, `cache`. `withSkills([a, b])` is +sugar for `dedupe(aggregate([a, b]))`. A single source is never auto-wrapped, so +a tenant-scoped source is never cached into a shared bucket. + +## Resources + +To let the model read a skill's bundled files, pass `createResourceTool(source)` +in `tools`. `withSkills` detects it and advertises `read_skill_resource`. Paths +that escape the skill root are rejected. + +## Write a custom source + +Implement `SkillSource` (`list` + `load`, optional `revision`/`listResources`/ +`readResource`), then validate it with the shipped conformance suite: + +```typescript +import { runSkillSourceConformance } from '@tanstack/ai-skills/testing' + +runSkillSourceConformance(() => myS3Source(fixtures), 's3') +``` + +## Entry points + +- `@tanstack/ai-skills` — types, `inlineSkill`, combinators, `withSkills`, + `createResourceTool`, `validateSkill`, `staticSkills`, `SkillLimitError`. +- `@tanstack/ai-skills/node` — `skillDirectory`, `skillsCatalogPlugin` (`node:fs`). +- `@tanstack/ai-skills/testing` — `runSkillSourceConformance`. + +## Docs + +- Portable Agent Skills: `docs/skills/agent-skills.md` +- Skill sources: `docs/skills/skill-sources.md` +- Write a skill source: `docs/skills/writing-adapters.md` +- Provider (hosted) skills: `docs/tools/provider-skills.md` diff --git a/packages/ai-skills/src/catalog.ts b/packages/ai-skills/src/catalog.ts new file mode 100644 index 0000000000..4ab5b01fab --- /dev/null +++ b/packages/ai-skills/src/catalog.ts @@ -0,0 +1,40 @@ +/** + * Catalog rendering (spec §4.3). Deterministic order (sort by name) and a fixed + * position in the system prompt are prompt-cache requirements, not style. The + * shape is per model family because `skills-ref` documents `` + * XML as recommended specifically for Anthropic models. + */ +import type { ModelFamily, SkillMetadata } from './types' + +/** Sort skills into a stable, cache-friendly order. */ +export function sortSkills( + skills: Array, +): Array { + return [...skills].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) +} + +function escapeXml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') +} + +/** Render the skill catalog for a model family. Skills are sorted by name. */ +export function renderCatalog( + skills: Array, + family: ModelFamily, +): string { + const sorted = sortSkills(skills) + if (family === 'anthropic') { + const entries = sorted + .map( + (s) => + ` ${escapeXml(s.description)}`, + ) + .join('\n') + return `\n${entries}\n` + } + const entries = sorted.map((s) => `- **${s.name}**: ${s.description}`).join('\n') + return `## Available skills\n\n${entries}` +} diff --git a/packages/ai-skills/src/combinators.ts b/packages/ai-skills/src/combinators.ts new file mode 100644 index 0000000000..af586e70e1 --- /dev/null +++ b/packages/ai-skills/src/combinators.ts @@ -0,0 +1,184 @@ +/** + * Source combinators (spec §3.3). Sources compose in practice: org skills + + * project skills + tenant skills. `aggregate` concatenates, `dedupe` resolves + * collisions, `filter` hides, `cache` memoizes. + */ +import { stableHash } from './util' +import type { + SkillMetadata, + SkillScriptRef, + SkillSource, +} from './types' + +export type FilterContext = Record +export type FilterPredicate = ( + skill: SkillMetadata, + ctx?: FilterContext, +) => boolean + +/** Forward a source's optional `revision`, preserving `undefined` when absent. */ +function forwardRevision( + source: SkillSource, +): (() => Promise) | undefined { + const rev = source.revision + return rev ? () => rev() : undefined +} + +/** Route a delegating method to the first source that lists `name`. */ +async function ownerOf( + sources: Array, + name: string, +): Promise { + for (const source of sources) { + const list = await source.list() + if (list.some((s) => s.name === name)) return source + } + throw new Error(`no source provides a skill named "${name}"`) +} + +async function combinedRevision( + sources: Array, +): Promise { + const revs = await Promise.all( + sources.map((s) => s.revision?.() ?? Promise.resolve(undefined)), + ) + if (revs.some((r) => r === undefined)) return undefined + return stableHash(revs.join('|')) +} + +/** Concatenate sources in registration order. No dedupe. */ +export function aggregate(sources: Array): SkillSource { + // Only expose revision() when every child does — a partial revision would + // report "unchanged" while an unversioned child mutated underneath. + const allVersioned = sources.every((s) => s.revision) + return { + ...(allVersioned && { + revision: async () => (await combinedRevision(sources)) ?? '', + }), + list: async () => { + const lists = await Promise.all(sources.map((s) => s.list())) + return lists.flat() + }, + load: async (name) => (await ownerOf(sources, name)).load(name), + listResources: async (name) => { + const owner = await ownerOf(sources, name) + return owner.listResources?.(name) ?? [] + }, + readResource: async (name, path) => { + const owner = await ownerOf(sources, name) + if (!owner.readResource) { + throw new Error(`skill "${name}" does not support resources`) + } + return owner.readResource(name, path) + }, + listScripts: async (name) => { + const owner = await ownerOf(sources, name) + return (owner.listScripts?.(name) ?? []) as Array + }, + readScript: async (name, path) => { + const owner = await ownerOf(sources, name) + if (!owner.readScript) { + throw new Error(`skill "${name}" does not support scripts`) + } + return owner.readScript(name, path) + }, + } +} + +/** First occurrence of a name wins; warns on collision. */ +export function dedupe( + source: SkillSource, + onCollision: (name: string) => void = (name) => + console.warn(`[ai-skills] duplicate skill "${name}" — first one wins`), +): SkillSource { + return { + ...source, + revision: forwardRevision(source), + list: async () => { + const seen = new Set() + const out: Array = [] + for (const skill of await source.list()) { + if (seen.has(skill.name)) { + onCollision(skill.name) + continue + } + seen.add(skill.name) + out.push(skill) + } + return out + }, + } +} + +/** Hide skills the predicate rejects. Filtered skills never reach the catalog. */ +export function filter( + source: SkillSource, + predicate: FilterPredicate, + ctx?: FilterContext, +): SkillSource { + return { + ...source, + revision: forwardRevision(source), + list: async () => (await source.list()).filter((s) => predicate(s, ctx)), + } +} + +/** + * Memoize `list()`/`load()`. Concurrent `list()` calls share one underlying + * fetch. `refreshInterval` (ms) expires the memo; omit for forever. + * + * Never auto-applied by the middleware — caching a tenant-scoped source in a + * shared bucket would replay one tenant's skills for another. Opt in explicitly. + */ +export function cache( + source: SkillSource, + opts: { refreshInterval?: number } = {}, +): SkillSource { + let listPromise: Promise> | undefined + let listAt = 0 + const loads = new Map>() + + const now = () => (opts.refreshInterval ? Date.now() : 0) + const fresh = () => + opts.refreshInterval === undefined || + now() - listAt < opts.refreshInterval + + return { + ...source, + revision: forwardRevision(source), + list: () => { + if (!listPromise || !fresh()) { + listAt = now() + listPromise = source.list().catch((err) => { + listPromise = undefined // don't cache failures + throw err + }) + } + return listPromise + }, + load: (name) => { + let p = loads.get(name) + if (!p) { + p = source.load(name).catch((err) => { + loads.delete(name) + throw err + }) + loads.set(name, p) + } + return p + }, + } +} + +/** + * Combine the sources handed to the middleware. An array is deduped and + * aggregated; a single bare source is used as-is (never auto-wrapped). + */ +export function combineSources( + sources: SkillSource | Array, +): SkillSource { + if (!Array.isArray(sources)) return sources + const [first] = sources + if (sources.length === 1 && first) return first + return dedupe(aggregate(sources)) +} diff --git a/packages/ai-skills/src/errors.ts b/packages/ai-skills/src/errors.ts new file mode 100644 index 0000000000..e79616c162 --- /dev/null +++ b/packages/ai-skills/src/errors.ts @@ -0,0 +1,7 @@ +/** + * `SkillLimitError` is defined in core `@tanstack/ai` (so the native tool + * factories can throw it without depending on this package) and re-exported + * here for the portable path. + */ +export { SkillLimitError } from '@tanstack/ai' +export type { SkillLimitErrorInit } from '@tanstack/ai' diff --git a/packages/ai-skills/src/index.ts b/packages/ai-skills/src/index.ts new file mode 100644 index 0000000000..64ca49c467 --- /dev/null +++ b/packages/ai-skills/src/index.ts @@ -0,0 +1,49 @@ +/** + * `@tanstack/ai-skills` — portable Agent Skills (`SKILL.md`) as a first-class + * `chat()` middleware. Edge-safe root export; `skillDirectory` (node:fs) lives + * behind the `/node` subpath, the Vite plugin behind `/static`, and the + * conformance suite behind `/testing`. + */ +export type { + SkillSource, + SkillMetadata, + SkillScriptRef, + LoadSkillResult, + ModelFamily, +} from './types' +export { modelFamilyOf } from './types' + +export { parseSkill, stripFrontmatter, SkillParseError } from './parse' +export type { ParsedSkill, ParseWarning } from './parse' + +export { walkSkillDirs, SKILL_FILE, MAX_SKILL_WALK_DEPTH } from './walk' +export type { DiscoveredSkillDir, WalkEntry, ListDir } from './walk' + +export { inlineSkill } from './sources/inline' +export type { InlineSkillConfig } from './sources/inline' + +export { aggregate, dedupe, filter, cache, combineSources } from './combinators' +export type { FilterContext, FilterPredicate } from './combinators' + +export { renderCatalog, sortSkills } from './catalog' + +export { withSkills } from './middleware' +export type { SkillsOptions } from './middleware' + +export { createLoadSkillTool, ALREADY_LOADED } from './tools/load-skill' +export { + createResourceTool, + READ_RESOURCE_TOOL_NAME, +} from './tools/read-resource' + +export { validateSkill } from './validate' +export type { + SkillTarget, + SkillValidationIssue, + SkillValidationResult, +} from './validate' + +export { SkillLimitError } from './errors' +export type { SkillLimitErrorInit } from './errors' + +export { assertSafeResourcePath, stableHash } from './util' diff --git a/packages/ai-skills/src/middleware.ts b/packages/ai-skills/src/middleware.ts new file mode 100644 index 0000000000..d66e2e83a5 --- /dev/null +++ b/packages/ai-skills/src/middleware.ts @@ -0,0 +1,215 @@ +/** + * `withSkills` — portable Agent Skills as a chat middleware. + * + * All source resolution and catalog rendering happen once in `setup`; `onConfig` + * (which fires every agent iteration) only returns memoized values. Otherwise an + * S3-backed source would hit the network per loop turn and catalog reordering + * would break Anthropic's cache prefix mid-run. + */ +import { + createCapability, + defineChatMiddleware, +} from '@tanstack/ai' +import { combineSources } from './combinators' +import { renderCatalog } from './catalog' +import { modelFamilyOf } from './types' +import { createLoadSkillTool } from './tools/load-skill' +import { READ_RESOURCE_TOOL_NAME } from './tools/read-resource' +import type { DefinedChatMiddleware, Tool } from '@tanstack/ai' +import type { ModelFamily, SkillMetadata, SkillSource } from './types' + +export interface SkillsOptions { + /** Override catalog rendering. Receives resolved metadata + the model family. */ + renderCatalog?: (skills: Array, family: ModelFamily) => string + /** Template with a required `{skills}` placeholder. Literal braces escape as `{{`/`}}`. */ + instructionTemplate?: string + /** Hard cap on tier-1 catalog token spend. Default 4000. */ + maxCatalogTokens?: number + /** `'error'` (default) or a reducer invoked when the cap is exceeded. */ + onLimitExceeded?: + | 'error' + | ((skills: Array, limit: number) => Array) + /** Where the catalog goes. Default `'system'`. */ + catalogPlacement?: 'system' | 'tool-description' + /** Require approval before load_skill / read_skill_resource. Default false. */ + requireApproval?: boolean +} + +interface SkillsRuntime { + skills: Array + activated: Set + source: SkillSource + family: ModelFamily + catalog: string + options: SkillsOptions + /** Built on first onConfig (needs config.tools to detect the resource tool). */ + memo?: { prompt: { content: string } | undefined; tools: Array } +} + +const SkillsCapability = createCapability()('skills') + +/** ~4 chars/token — good enough to guard a runaway catalog. */ +const estimateTokens = (s: string) => Math.ceil(s.length / 4) + +function fillTemplate(template: string, catalog: string): string { + // Escape `{{`/`}}` to sentinels, substitute `{skills}`, then restore braces. + const OPEN = '\u0000OPEN\u0000' + const CLOSE = '\u0000CLOSE\u0000' + return template + .split('{{') + .join(OPEN) + .split('}}') + .join(CLOSE) + .split('{skills}') + .join(catalog) + .split(OPEN) + .join('{') + .split(CLOSE) + .join('}') +} + +/** True when a code_execution/shell tool in `tools` carries hosted skills. */ +function findNativeSkillTool(tools: Array): string | undefined { + for (const tool of tools) { + const meta = tool.metadata as + | { skills?: Array; environment?: { skills?: Array } } + | undefined + if (tool.name === 'code_execution' && (meta?.skills?.length ?? 0) > 0) { + return 'code_execution' + } + if ( + tool.name === 'shell' && + (meta?.environment?.skills?.length ?? 0) > 0 + ) { + return 'shell' + } + } + return undefined +} + +function activationInstructions( + catalog: string, + hasResourceTool: boolean, +): string { + const resourceLine = hasResourceTool + ? 'To read a skill’s bundled resource files, call `read_skill_resource` with the skill name and the resource path.' + : 'Some skills may list resource files; they are not loadable in this configuration.' + return [ + 'You have access to a library of skills. When a task matches one, call the `load_skill` tool with its name to load its full instructions before proceeding.', + catalog, + resourceLine, + ].join('\n\n') +} + +export function withSkills( + sources: SkillSource | Array, + options: SkillsOptions = {}, +): DefinedChatMiddleware { + if (options.instructionTemplate && options.renderCatalog) { + throw new Error( + '`instructionTemplate` and `renderCatalog` are mutually exclusive', + ) + } + if ( + options.instructionTemplate && + !options.instructionTemplate.includes('{skills}') + ) { + throw new Error('`instructionTemplate` must contain a `{skills}` placeholder') + } + + return defineChatMiddleware({ + name: 'skills', + provides: [SkillsCapability], + + async setup(ctx) { + const source = combineSources(sources) + let skills = await source.list() + const family = modelFamilyOf(ctx.provider) + + // Catalog token cap (spec §4.2). + const limit = options.maxCatalogTokens ?? 4000 + let catalog = (options.renderCatalog ?? renderCatalog)(skills, family) + if (estimateTokens(catalog) > limit) { + if (options.onLimitExceeded && options.onLimitExceeded !== 'error') { + skills = options.onLimitExceeded(skills, limit) + catalog = (options.renderCatalog ?? renderCatalog)(skills, family) + } else { + throw new Error( + `skills catalog (~${estimateTokens(catalog)} tokens) exceeds maxCatalogTokens (${limit})`, + ) + } + } + + ctx.provide(SkillsCapability, { + skills, + activated: new Set(), + source, + family, + catalog, + options, + }) + }, + + onConfig(ctx, config) { + const rt = ctx.get(SkillsCapability) + if (rt.skills.length === 0) return // empty catalog → no tools, no prompt + + // Native co-existence: portable + hosted skills don't compose (spec §6.1). + const native = findNativeSkillTool(config.tools) + if (native) { + throw new Error( + `withSkills (portable skills) cannot be combined with a "${native}" tool that carries hosted/native skills. ` + + 'Use one delivery mode: remove the hosted skills, or drop withSkills.', + ) + } + + if (!rt.memo) { + const hasResourceTool = config.tools.some( + (t) => t.name === READ_RESOURCE_TOOL_NAME, + ) + const body = + options.instructionTemplate !== undefined + ? fillTemplate(options.instructionTemplate, rt.catalog) + : activationInstructions(rt.catalog, hasResourceTool) + + const loadTool = createLoadSkillTool({ + source: rt.source, + skills: rt.skills, + activated: rt.activated, + requireApproval: options.requireApproval, + }) + + const placement = options.catalogPlacement ?? 'system' + if (placement === 'tool-description') { + loadTool.description = `${loadTool.description}\n\n${body}` + rt.memo = { prompt: undefined, tools: [loadTool] } + } else { + rt.memo = { prompt: { content: body }, tools: [loadTool] } + } + } + + // onConfig fires every iteration and the engine feeds the merged config + // back in — so appending must be idempotent (add our prompt/tools only + // when not already present) or a second iteration duplicates them. + const prompt = rt.memo.prompt + const promptPresent = + !prompt || + config.systemPrompts.some((p) => + typeof p === 'string' ? p === prompt.content : p.content === prompt.content, + ) + const existingNames = new Set(config.tools.map((t) => t.name)) + const toolsToAdd = rt.memo.tools.filter((t) => !existingNames.has(t.name)) + + return { + systemPrompts: + prompt && !promptPresent + ? [...config.systemPrompts, prompt] + : config.systemPrompts, + tools: + toolsToAdd.length > 0 + ? [...config.tools, ...toolsToAdd] + : config.tools, + } + }, + }) +} diff --git a/packages/ai-skills/src/node/index.ts b/packages/ai-skills/src/node/index.ts new file mode 100644 index 0000000000..698f2c030f --- /dev/null +++ b/packages/ai-skills/src/node/index.ts @@ -0,0 +1,211 @@ +/** + * `skillDirectory` — a filesystem-backed {@link SkillSource}. Lives behind the + * `/node` subpath because it imports `node:fs`; the root export stays edge-safe + * (Workers, browsers), mirroring `@tanstack/ai-code-mode-snippets/storage`. + */ +import { readFile, readdir, stat } from 'node:fs/promises' +import { basename, join, relative } from 'node:path' +import { walkSkillDirs } from '../walk' +import { parseSkill, stripFrontmatter } from '../parse' +import { assertSafeResourcePath, stableHash } from '../util' +import type { Dirent } from 'node:fs' +import type { ListDir } from '../walk' +import type { GeneratedCatalog, GeneratedSkill } from '../static/index' +import type { SkillMetadata, SkillScriptRef, SkillSource } from '../types' + +const RESOURCE_DIRS = ['references', 'assets'] +const SCRIPT_DIR = 'scripts' + +export interface SkillDirectoryOptions { + maxDepth?: number + /** default true — promote parse warnings to errors (see spec §7). */ + strict?: boolean +} + +const nodeLister: ListDir = async (dir) => { + const ents = await readdir(dir, { withFileTypes: true }) + return ents.map((e) => ({ + name: e.name, + path: join(dir, e.name), + type: e.isDirectory() ? 'dir' : 'file', + })) +} + +/** Recursively collect file paths under `dir`, relative to `root`. */ +async function collectFiles(dir: string, root: string): Promise> { + let ents: Array + try { + ents = await readdir(dir, { withFileTypes: true }) + } catch { + return [] + } + const out: Array = [] + for (const e of ents) { + const full = join(dir, e.name) + if (e.isDirectory()) out.push(...(await collectFiles(full, root))) + else out.push(relative(root, full)) + } + return out +} + +export function skillDirectory( + root: string | Array, + options: SkillDirectoryOptions = {}, +): SkillSource { + const roots = Array.isArray(root) ? root : [root] + const { maxDepth, strict = true } = options + + /** Fresh scan of every root → name → skill directory. */ + const scan = async (): Promise> => { + const map = new Map() + for (const r of roots) { + const dirs = await walkSkillDirs(nodeLister, r, { maxDepth }) + for (const d of dirs) map.set(d.name, d.dir) + } + return map + } + + const dirOf = async (name: string): Promise => { + const dir = (await scan()).get(name) + if (!dir) throw new Error(`no skill named "${name}" under ${roots.join(', ')}`) + return dir + } + + return { + revision: async () => { + const map = await scan() + const parts: Array = [] + for (const [name, dir] of [...map].sort()) { + const s = await stat(join(dir, 'SKILL.md')).catch(() => undefined) + parts.push(`${name}:${s?.mtimeMs ?? 0}:${s?.size ?? 0}`) + } + return stableHash(parts.join('|')) + }, + list: async () => { + const map = await scan() + const out: Array = [] + for (const [, dir] of map) { + const raw = await readFile(join(dir, 'SKILL.md'), 'utf8').catch( + () => undefined, + ) + if (raw === undefined) continue + try { + out.push( + parseSkill(raw, { dirName: basename(dir), strict }).metadata, + ) + } catch { + // Lenient: an unparseable skill is skipped, not fatal (spec §7). + // strict mode still throws inside parseSkill for warnings, but a + // genuinely broken file (no description) is always skipped. + } + } + return out + }, + load: async (name) => readFile(join(await dirOf(name), 'SKILL.md'), 'utf8'), + listResources: async (name) => { + const dir = await dirOf(name) + const files: Array = [] + for (const sub of RESOURCE_DIRS) { + files.push(...(await collectFiles(join(dir, sub), dir))) + } + return files + }, + readResource: async (name, path) => { + assertSafeResourcePath(path) + const dir = await dirOf(name) + return readFile(join(dir, path)) + }, + listScripts: async (name) => { + const dir = await dirOf(name) + const files = await collectFiles(join(dir, SCRIPT_DIR), dir) + return files.map( + (p): SkillScriptRef => ({ path: p, executable: false, reason: 'no-runtime' }), + ) + }, + readScript: async (name, path) => { + assertSafeResourcePath(path) + const dir = await dirOf(name) + return readFile(join(dir, path)) + }, + } +} + +/** + * Read a skill directory tree into a plain {@link GeneratedCatalog} — the shape + * `staticSkills` consumes. Used by the Vite plugin and directly available for + * custom build scripts. + */ +export async function generateCatalog( + root: string | Array, + options: SkillDirectoryOptions = {}, +): Promise { + const roots = Array.isArray(root) ? root : [root] + const skills: Array = [] + for (const r of roots) { + for (const { dir } of await walkSkillDirs(nodeLister, r, { + maxDepth: options.maxDepth, + })) { + const raw = await readFile(join(dir, 'SKILL.md'), 'utf8').catch( + () => undefined, + ) + if (raw === undefined) continue + let meta + try { + meta = parseSkill(raw, { + dirName: basename(dir), + strict: options.strict ?? true, + }).metadata + } catch { + continue + } + const resources: Record = {} + for (const sub of RESOURCE_DIRS) { + for (const rel of await collectFiles(join(dir, sub), dir)) { + resources[rel] = await readFile(join(dir, rel), 'utf8').catch(() => '') + } + } + skills.push({ + name: meta.name, + description: meta.description, + body: stripFrontmatter(raw), + ...(meta.compatibility && { compatibility: meta.compatibility }), + ...(Object.keys(resources).length && { resources }), + }) + } + } + skills.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) + const revision = stableHash( + skills.map((s) => `${s.name}:${stableHash(s.body)}`).join('|'), + ) + return { revision, skills } +} + +/** Structural Vite plugin (no `vite` type dependency). */ +export interface SkillsCatalogPlugin { + name: string + resolveId: (id: string) => string | undefined + load: (id: string) => Promise +} + +/** + * Vite plugin that globs `SKILL.md` under `dir` at build time and serves a + * virtual module (default id `virtual:tanstack-skills`) exporting the catalog + * `as const`. Consumers then wrap it with `staticSkills` for a literal-union of + * skill names. The catalog is embedded as JSON, so the bundle hash tracks it. + */ +export function skillsCatalogPlugin( + options: { dir?: string; virtualId?: string; maxDepth?: number } = {}, +): SkillsCatalogPlugin { + const virtualId = options.virtualId ?? 'virtual:tanstack-skills' + const resolved = `\0${virtualId}` + const dir = options.dir ?? 'skills' + return { + name: 'tanstack-skills-catalog', + resolveId: (id) => (id === virtualId ? resolved : undefined), + load: async (id) => { + if (id !== resolved) return undefined + const catalog = await generateCatalog(dir, { maxDepth: options.maxDepth }) + return `export const catalog = ${JSON.stringify(catalog)} as const\n` + }, + } +} diff --git a/packages/ai-skills/src/parse.ts b/packages/ai-skills/src/parse.ts new file mode 100644 index 0000000000..0381aa40ae --- /dev/null +++ b/packages/ai-skills/src/parse.ts @@ -0,0 +1,247 @@ +/** + * Lenient `SKILL.md` frontmatter parsing (spec §7). + * + * Other clients emit malformed frontmatter — most commonly unquoted colons in + * descriptions — so the default is lenient. We hand-roll a tiny parser rather + * than pull in a YAML dependency: SKILL.md frontmatter is a flat key/value + * block (plus block scalars for `description` and simple lists for + * `allowedTools`), and taking `value = rest-of-line-after-first-colon` handles + * the unquoted-colon case in a single pass — no quote-and-retry needed. + * + * | Condition | Behavior | + * |----------------------|---------------------------------| + * | name/dir mismatch | warn, load | + * | name over 64 chars | warn, load | + * | invalid name chars | warn, load | + * | missing `description`| throw (caller skips) | + * | no frontmatter | throw (caller skips) | + * + * `strict: true` promotes every warning to a thrown error. + */ +import type { SkillMetadata } from './types' + +export interface ParseWarning { + code: 'name-dir-mismatch' | 'name-too-long' | 'name-invalid-chars' + message: string +} + +export interface ParsedSkill { + metadata: SkillMetadata + /** frontmatter stripped. */ + body: string + warnings: Array +} + +export class SkillParseError extends Error { + override name = 'SkillParseError' +} + +const NAME_RE = /^[a-z0-9-]+$/ + +/** Split leading `---` frontmatter from the body. Returns null if absent. */ +function splitFrontmatter( + raw: string, +): { frontmatter: string; body: string } | null { + // Tolerate a BOM and leading blank lines before the opening fence. + const text = raw.replace(/^/, '') + const match = /^\s*---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n([\s\S]*))?$/.exec( + text, + ) + if (!match) return null + return { frontmatter: match[1] ?? '', body: match[2] ?? '' } +} + +/** Parse the flat frontmatter block into raw string/list/map values. */ +function parseBlock(frontmatter: string): Record { + const lines = frontmatter.split(/\r?\n/) + const out: Record = {} + let i = 0 + + const indentOf = (s: string) => s.length - s.trimStart().length + + while (i < lines.length) { + const line = lines[i] + if (line === undefined) break + if (line.trim() === '' || line.trimStart().startsWith('#')) { + i++ + continue + } + // Top-level keys are unindented. + if (indentOf(line) > 0) { + i++ + continue + } + const colon = line.indexOf(':') + if (colon === -1) { + i++ + continue + } + const key = line.slice(0, colon).trim() + const value = line.slice(colon + 1).trim() + + // Block scalar: `>` (folded) or `|` (literal), with optional chomp/indent. + if (value === '>' || value === '|' || /^[>|][+-]?\d*$/.test(value)) { + const folded = value.startsWith('>') + const collected: Array = [] + i++ + while (i < lines.length) { + const next = lines[i] + if (next === undefined) break + if (next.trim() !== '' && indentOf(next) === 0) break + collected.push(next) + i++ + } + // Strip the common leading indentation of the block. + const nonEmpty = collected.filter((l) => l.trim() !== '') + const minIndent = nonEmpty.length + ? Math.min(...nonEmpty.map(indentOf)) + : 0 + const stripped = collected.map((l) => l.slice(minIndent)) + out[key] = folded + ? stripped.join(' ').replace(/\s+/g, ' ').trim() + : stripped.join('\n').trim() + continue + } + + // Empty value → indented children: a block list (`- x`) or a nested map + // (`k: v`), one level deep. + if (value === '') { + const items: Array = [] + const map: Record = {} + let j = i + 1 + while (j < lines.length) { + const next = lines[j] + if (next === undefined) break + if (next.trim() === '') { + j++ + continue + } + if (indentOf(next) === 0) break + const t = next.trim() + if (t.startsWith('- ')) { + items.push(unquote(t.slice(2).trim())) + } else { + const c = t.indexOf(':') + if (c === -1) break + map[t.slice(0, c).trim()] = unquote(t.slice(c + 1).trim()) + } + j++ + } + if (items.length) out[key] = items + else if (Object.keys(map).length) out[key] = map + else out[key] = '' + i = Math.max(j, i + 1) + continue + } + + // Inline list `[a, b]`. + if (value.startsWith('[') && value.endsWith(']')) { + out[key] = value + .slice(1, -1) + .split(',') + .map((s) => unquote(s.trim())) + .filter((s) => s !== '') + i++ + continue + } + + out[key] = unquote(value) + i++ + } + return out +} + +function unquote(s: string): string { + if ( + (s.startsWith('"') && s.endsWith('"')) || + (s.startsWith("'") && s.endsWith("'")) + ) { + return s.slice(1, -1) + } + return s +} + +function asStringMap(value: unknown): Record | undefined { + if (typeof value !== 'object' || value === null) return undefined + const out: Record = {} + for (const [k, v] of Object.entries(value)) { + if (typeof v === 'string') out[k] = v + } + return Object.keys(out).length ? out : undefined +} + +/** + * Parse a `SKILL.md`. Throws {@link SkillParseError} for cases the spec says to + * skip (no frontmatter, missing description). Non-fatal issues are returned as + * `warnings`; `strict` turns them into throws. + */ +export function parseSkill( + raw: string, + opts: { dirName?: string; strict?: boolean } = {}, +): ParsedSkill { + const split = splitFrontmatter(raw) + if (!split) { + throw new SkillParseError('SKILL.md has no frontmatter block') + } + const block = parseBlock(split.frontmatter) + + const name = typeof block.name === 'string' ? block.name : undefined + const description = + typeof block.description === 'string' ? block.description : undefined + + if (!description) { + throw new SkillParseError('SKILL.md is missing a `description`') + } + + const warnings: Array = [] + const effectiveName = name ?? opts.dirName ?? '' + + if (name && opts.dirName && name !== opts.dirName) { + warnings.push({ + code: 'name-dir-mismatch', + message: `skill name "${name}" does not match directory "${opts.dirName}"`, + }) + } + if (effectiveName.length > 64) { + warnings.push({ + code: 'name-too-long', + message: `skill name "${effectiveName}" exceeds 64 characters`, + }) + } + if (effectiveName && !NAME_RE.test(effectiveName)) { + warnings.push({ + code: 'name-invalid-chars', + message: `skill name "${effectiveName}" contains characters outside [a-z0-9-]`, + }) + } + + if (opts.strict && warnings.length) { + throw new SkillParseError(warnings.map((w) => w.message).join('; ')) + } + + const metadata: SkillMetadata = { + name: effectiveName, + description, + ...(typeof block.license === 'string' && { license: block.license }), + ...(typeof block.compatibility === 'string' && { + compatibility: block.compatibility, + }), + ...(Array.isArray(block.allowedTools) && { + allowedTools: block.allowedTools.filter( + (t): t is string => typeof t === 'string', + ), + }), + ...((): { metadata?: Record } => { + const m = asStringMap(block.metadata) + return m ? { metadata: m } : {} + })(), + } + + return { metadata, body: split.body.trim(), warnings } +} + +/** Strip the frontmatter block, returning just the body. */ +export function stripFrontmatter(raw: string): string { + const split = splitFrontmatter(raw) + return split ? split.body.trim() : raw.trim() +} diff --git a/packages/ai-skills/src/sources/inline.ts b/packages/ai-skills/src/sources/inline.ts new file mode 100644 index 0000000000..e4ea3fed70 --- /dev/null +++ b/packages/ai-skills/src/sources/inline.ts @@ -0,0 +1,59 @@ +/** + * `inlineSkill` — an edge-safe {@link SkillSource} for a single skill defined + * next to app code, in a DB row, or per-session/per-tenant. Resource values may + * be thunks, evaluated at read time. + */ +import { stableHash } from '../util' +import type { SkillMetadata, SkillSource } from '../types' + +export interface InlineSkillConfig { + name: string + description: string + instructions: string + resources?: Record string | Promise)> + compatibility?: string +} + +export function inlineSkill(config: InlineSkillConfig): SkillSource { + const metadata: SkillMetadata = { + name: config.name, + description: config.description, + ...(config.compatibility && { compatibility: config.compatibility }), + } + const resourcePaths = Object.keys(config.resources ?? {}) + const revision = stableHash( + JSON.stringify({ + metadata, + instructions: config.instructions, + resources: resourcePaths, + }), + ) + + const assertName = (name: string) => { + if (name !== config.name) { + throw new Error(`inlineSkill has no skill named "${name}"`) + } + } + + return { + revision: () => Promise.resolve(revision), + list: () => Promise.resolve([metadata]), + // Async so an unknown-name throw surfaces as a rejected promise. + load: async (name) => { + assertName(name) + return config.instructions + }, + listResources: async (name) => { + assertName(name) + return resourcePaths + }, + readResource: async (name, path) => { + assertName(name) + const value = config.resources?.[path] + if (value === undefined) { + throw new Error(`skill "${name}" has no resource "${path}"`) + } + return typeof value === 'function' ? await value() : value + }, + } +} diff --git a/packages/ai-skills/src/static/index.ts b/packages/ai-skills/src/static/index.ts new file mode 100644 index 0000000000..9a634479d1 --- /dev/null +++ b/packages/ai-skills/src/static/index.ts @@ -0,0 +1,64 @@ +/** + * `staticSkills` — wrap a build-time-generated catalog as a {@link SkillSource}. + * + * Edge-safe: this file imports no `node:*` modules. The Vite plugin that GLOBS + * `skills/**​/SKILL.md` and emits the catalog lives in `@tanstack/ai-skills/node` + * (it needs `node:fs`); the emitted catalog is plain data consumed here. + * + * The generated catalog is `as const`, so `T` is the literal union of skill + * names — which flows into `load_skill`'s enum, the constraint the spec + * recommends, obtained at build time rather than runtime. + */ +import type { SkillMetadata, SkillSource } from '../types' + +export interface GeneratedSkill { + name: T + description: string + /** raw SKILL.md body, frontmatter stripped at generation time. */ + body: string + compatibility?: string + /** embedded resource files, path → utf8 contents. */ + resources?: Record +} + +export interface GeneratedCatalog { + revision: string + skills: ReadonlyArray> +} + +export function staticSkills( + catalog: GeneratedCatalog, +): SkillSource & { names: ReadonlyArray } { + const byName = new Map(catalog.skills.map((s) => [s.name, s])) + const get = (name: string): GeneratedSkill => { + const s = byName.get(name as T) + if (!s) throw new Error(`static catalog has no skill named "${name}"`) + return s + } + + return { + names: catalog.skills.map((s) => s.name), + revision: () => Promise.resolve(catalog.revision), + list: () => + Promise.resolve( + catalog.skills.map( + (s): SkillMetadata => ({ + name: s.name, + description: s.description, + ...(s.compatibility && { compatibility: s.compatibility }), + }), + ), + ), + // Methods are async so a missing-skill throw surfaces as a rejected + // promise (what callers `await`), not a synchronous throw. + load: async (name) => get(name).body, + listResources: async (name) => Object.keys(get(name).resources ?? {}), + readResource: async (name, path) => { + const value = get(name).resources?.[path] + if (value === undefined) { + throw new Error(`skill "${name}" has no resource "${path}"`) + } + return value + }, + } +} diff --git a/packages/ai-skills/src/testing/index.ts b/packages/ai-skills/src/testing/index.ts new file mode 100644 index 0000000000..916ca90027 --- /dev/null +++ b/packages/ai-skills/src/testing/index.ts @@ -0,0 +1,98 @@ +/** + * `runSkillSourceConformance` — the real deliverable for third-party adapters. + * Since adapter code is frequently LLM-generated, this suite (not prose) is what + * makes a new `SkillSource` safe to ship. + * + * The factory must return a source seeded with this fixed fixture contract: + * + * - skill `alpha`: description non-empty; resource `references/note.md` whose + * contents are exactly `hello`; script `scripts/run.py` whose bytes decode + * to `print(1)` (only if the source supports scripts). + * - skill `beta`: description non-empty; no resources required. + * + * Sources that cannot represent a tier (resources/scripts) simply omit the + * corresponding methods — those cases are skipped, not failed. + */ +import { describe, expect, it } from 'vitest' +import { assertSafeResourcePath } from '../util' +import type { SkillSource } from '../types' + +const dec = (v: string | Uint8Array) => + typeof v === 'string' ? v : new TextDecoder().decode(v) + +export function runSkillSourceConformance( + factory: () => SkillSource | Promise, + label = 'SkillSource', +): void { + describe(`conformance: ${label}`, () => { + it('lists skills with a name and description', async () => { + const source = await factory() + const skills = await source.list() + const names = skills.map((s) => s.name) + expect(names).toContain('alpha') + expect(names).toContain('beta') + for (const s of skills) { + expect(s.name).toBeTruthy() + expect(s.description).toBeTruthy() + } + }) + + it('loads a known skill body', async () => { + const source = await factory() + const body = await source.load('alpha') + expect(typeof body).toBe('string') + expect(body.length).toBeGreaterThan(0) + }) + + it('throws (not returns empty) for a missing skill name', async () => { + const source = await factory() + await expect(source.load('does-not-exist')).rejects.toThrow() + }) + + it('has a stable revision across identical content', async () => { + const source = await factory() + const rev = source.revision + if (!rev) return + const a = await rev() + const b = await rev() + expect(a).toBe(b) + const other = await factory() + if (other.revision) expect(await other.revision()).toBe(a) + }) + + it('serves concurrent list() consistently', async () => { + const source = await factory() + const [a, b] = await Promise.all([source.list(), source.list()]) + expect(a.map((s) => s.name).sort()).toEqual(b.map((s) => s.name).sort()) + }) + + it('reads a bundled resource and rejects path traversal', async () => { + const source = await factory() + const { listResources, readResource } = source + if (!listResources || !readResource) return + const resources = await listResources('alpha') + expect(resources).toContain('references/note.md') + const value = await readResource('alpha', 'references/note.md') + expect(dec(value)).toBe('hello') + // Path traversal must be rejected — by the source or the shared guard. + await expect( + (async () => { + assertSafeResourcePath('../../etc/passwd') + await readResource('alpha', '../../etc/passwd') + })(), + ).rejects.toThrow() + }) + + it('returns script bytes correctly', async () => { + const source = await factory() + if (!source.listScripts || !source.readScript) return + const scripts = await source.listScripts('alpha') + const ref = scripts.find((s) => s.path === 'scripts/run.py') + if (!ref) return + expect(ref.executable).toBe(false) + const bytes = await source.readScript('alpha', 'scripts/run.py') + expect(bytes).toBeInstanceOf(Uint8Array) + expect(dec(bytes)).toContain('print(1)') + }) + }) +} diff --git a/packages/ai-skills/src/tools/load-skill.ts b/packages/ai-skills/src/tools/load-skill.ts new file mode 100644 index 0000000000..8d885f2e75 --- /dev/null +++ b/packages/ai-skills/src/tools/load-skill.ts @@ -0,0 +1,87 @@ +/** + * `load_skill` — activates a skill by name and returns its (frontmatter-stripped) + * body plus a resource/script inventory. Result shape is frozen in phase 1; + * changing it later would churn every eval, snapshot, and devtools panel. + */ +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' +import { stripFrontmatter } from '../parse' +import type { Tool } from '@tanstack/ai' +import type { + LoadSkillResult, + SkillMetadata, + SkillScriptRef, + SkillSource, +} from '../types' + +export const ALREADY_LOADED = + '(already loaded earlier in this conversation — reuse the prior content)' + +const scriptSchema = z.object({ + path: z.string(), + executable: z.literal(false), + reason: z.string().optional(), +}) + +const resultSchema = z.object({ + skill: z.string(), + content: z.string(), + resources: z.array(z.string()), + scripts: z.array(scriptSchema), + compatibility: z.string().optional(), +}) + +export interface LoadSkillDeps { + source: SkillSource + skills: Array + /** per-conversation activation set (dedupe). */ + activated: Set + requireApproval?: boolean +} + +export function createLoadSkillTool(deps: LoadSkillDeps): Tool { + const names = deps.skills.map((s) => s.name) + const nameEnum = z.enum(names as [string, ...Array]) + const byName = new Map(deps.skills.map((s) => [s.name, s])) + + const handler = async ({ name }: { name: string }): Promise => { + if (deps.activated.has(name)) { + return { skill: name, content: ALREADY_LOADED, resources: [], scripts: [] } + } + const raw = await deps.source.load(name) + const resources = (await deps.source.listResources?.(name)) ?? [] + const scripts = ((await deps.source.listScripts?.(name)) ?? + []) as Array + deps.activated.add(name) + const compatibility = byName.get(name)?.compatibility + return { + skill: name, + content: stripFrontmatter(raw), + resources, + scripts, + ...(compatibility && { compatibility }), + } + } + + const description = + 'Activate an available skill by name. Returns its full instructions plus ' + + 'a list of any bundled resources and scripts.' + const inputSchema = z.object({ name: nameEnum }) + + if (deps.requireApproval) { + return toolDefinition({ + name: 'load_skill', + description, + inputSchema, + outputSchema: resultSchema, + needsApproval: true, + approvalSchema: z.object({ approve: z.boolean() }), + }).server(handler) + } + return toolDefinition({ + name: 'load_skill', + description, + inputSchema, + outputSchema: resultSchema, + }).server(handler) +} diff --git a/packages/ai-skills/src/tools/read-resource.ts b/packages/ai-skills/src/tools/read-resource.ts new file mode 100644 index 0000000000..2cb0b55487 --- /dev/null +++ b/packages/ai-skills/src/tools/read-resource.ts @@ -0,0 +1,49 @@ +/** + * `read_skill_resource` — reads a bundled resource (references/ or assets/) of a + * skill. Shipped but NOT auto-registered: pass it explicitly in `tools`, and + * `withSkills` phrases activation instructions based on its presence. This keeps + * DB/S3/inline sources' resources reachable — they have bytes but no file, so a + * caller-supplied file-read tool would never see them. + */ +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' +import { assertSafeResourcePath } from '../util' +import type { Tool } from '@tanstack/ai' +import type { SkillSource } from '../types' + +export const READ_RESOURCE_TOOL_NAME = 'read_skill_resource' + +function toBase64(bytes: Uint8Array): string { + // ponytail: Buffer exists on node + workers; avoids a browser-only path we + // don't need for a server-side resource read. + return Buffer.from(bytes).toString('base64') +} + +export function createResourceTool(source: SkillSource): Tool { + return toolDefinition({ + name: READ_RESOURCE_TOOL_NAME, + description: + 'Read a bundled resource file (from references/ or assets/) of an ' + + 'activated skill, by its path relative to the skill root.', + inputSchema: z.object({ + skill: z.string(), + path: z.string(), + }), + outputSchema: z.object({ + skill: z.string(), + path: z.string(), + content: z.string(), + encoding: z.enum(['utf8', 'base64']), + }), + }).server(async ({ skill, path }) => { + assertSafeResourcePath(path) + if (!source.readResource) { + throw new Error('this skill source does not support resources') + } + const value = await source.readResource(skill, path) + if (typeof value === 'string') { + return { skill, path, content: value, encoding: 'utf8' as const } + } + return { skill, path, content: toBase64(value), encoding: 'base64' as const } + }) +} diff --git a/packages/ai-skills/src/types.ts b/packages/ai-skills/src/types.ts new file mode 100644 index 0000000000..5810b9f37a --- /dev/null +++ b/packages/ai-skills/src/types.ts @@ -0,0 +1,82 @@ +/** + * Core types for portable Agent Skills. + * + * `SkillSource` is the central abstraction: bytes only, no filesystem + * assumption. A source that exposed a `path` would couple future script + * execution to fs-backed sources and permanently exclude S3/DB/registry + * sources — so there is deliberately no `path` field, ever. Path resolution + * is a `skillDirectory`-only concern. + */ + +/** Parsed `SKILL.md` frontmatter. */ +export interface SkillMetadata { + /** spec-validated: ≤64 chars, `[a-z0-9-]`. */ + name: string + /** ≤1024 chars. */ + description: string + license?: string + /** ≤500 chars, free text. */ + compatibility?: string + metadata?: Record + /** experimental in spec; parsed, not enforced. */ + allowedTools?: Array +} + +/** + * A script referenced by a skill. Inventoried in phase 1, executed in phase 2. + * `executable` flips to `true` and `reason` is dropped in phase 2. + */ +export interface SkillScriptRef { + /** relative to skill root, e.g. `scripts/extract.py`. */ + path: string + executable: false + reason?: 'no-runtime' +} + +/** A source of skills. Bytes only — no filesystem assumption. */ +export interface SkillSource { + /** Stable identity for the current content. Used as a catalog cache key. */ + revision?: () => Promise + + /** Tier 1. Called once per request; core memoizes on `revision()`. */ + list: () => Promise> + + /** Tier 2. Raw SKILL.md including frontmatter. Core strips it. */ + load: (name: string) => Promise + + /** Tier 3a. references/ and assets/ paths, relative to skill root. */ + listResources?: (name: string) => Promise> + readResource?: (name: string, path: string) => Promise + + /** Tier 3b. Inventoried in phase 1, executed in phase 2. */ + listScripts?: (name: string) => Promise> + readScript?: (name: string, path: string) => Promise +} + +/** + * Model family, derived from the middleware context's `provider` string. The + * codebase has no `ModelFamily` type of its own — `ctx.provider` is a plain + * string sourced from `adapter.name`. This is the single seam the catalog + * renderer keys on. + */ +export type ModelFamily = 'anthropic' | 'openai' | 'gemini' | 'other' + +/** Map a provider name (`ctx.provider`) to its {@link ModelFamily}. */ +export function modelFamilyOf(provider: string): ModelFamily { + const p = provider.toLowerCase() + if (p.includes('anthropic') || p.includes('claude')) return 'anthropic' + if (p.includes('openai') || p.includes('gpt')) return 'openai' + if (p.includes('gemini') || p.includes('google')) return 'gemini' + return 'other' +} + +/** Result of activating a skill via `load_skill`. Shape frozen in phase 1. */ +export interface LoadSkillResult { + skill: string + /** frontmatter stripped. */ + content: string + resources: Array + /** `[]` or `executable:false` entries in phase 1. */ + scripts: Array + compatibility?: string +} diff --git a/packages/ai-skills/src/util.ts b/packages/ai-skills/src/util.ts new file mode 100644 index 0000000000..bf49d84911 --- /dev/null +++ b/packages/ai-skills/src/util.ts @@ -0,0 +1,30 @@ +/** + * Reject a resource/script path that escapes its skill root. Pure string check + * (edge-safe, no `node:path`): no absolute paths, no `..` segments, no + * backslashes. Enforced here so both the resource tool and `skillDirectory` + * share one guard and the conformance suite can pin it. + */ +export function assertSafeResourcePath(path: string): void { + const normalized = path.replace(/\\/g, '/') + const bad = + normalized.startsWith('/') || + /^[a-zA-Z]:/.test(normalized) || + normalized + .split('/') + .some((seg) => seg === '..' || seg === '~') + if (bad) { + throw new Error(`unsafe resource path: "${path}"`) + } +} + +/** Small, edge-safe (no `node:crypto`) stable string hash for `revision()`. */ +export function stableHash(input: string): string { + // ponytail: FNV-1a; collisions don't matter here — revision only needs to + // change when content changes, not be cryptographically unique. + let h = 0x811c9dc5 + for (let i = 0; i < input.length; i++) { + h ^= input.charCodeAt(i) + h = Math.imul(h, 0x01000193) + } + return (h >>> 0).toString(16).padStart(8, '0') +} diff --git a/packages/ai-skills/src/validate.ts b/packages/ai-skills/src/validate.ts new file mode 100644 index 0000000000..2d8304c739 --- /dev/null +++ b/packages/ai-skills/src/validate.ts @@ -0,0 +1,63 @@ +/** + * `validateSkill` — author-time linting against native-delivery constraints, so + * a skill authored today can be promoted to a hosted (Anthropic/OpenAI) skill + * later without surprises. Phase 1 never uploads; this only warns. + */ +import type { SkillMetadata } from './types' + +export type SkillTarget = 'portable' | 'anthropic' | 'openai' + +export interface SkillValidationIssue { + target: SkillTarget + message: string +} + +export interface SkillValidationResult { + ok: boolean + issues: Array +} + +const XML_TAG = /<[^>]+>/ +const RESERVED_ANTHROPIC = ['anthropic', 'claude'] + +/** Lint a skill against the given delivery targets (default `['portable']`). */ +export function validateSkill( + skill: SkillMetadata, + options: { targets?: Array } = {}, +): SkillValidationResult { + const targets = options.targets ?? ['portable'] + const issues: Array = [] + + const add = (target: SkillTarget, message: string) => + issues.push({ target, message }) + + // Portable: the spec's own name/description bounds. + if (targets.includes('portable')) { + if (!/^[a-z0-9-]+$/.test(skill.name)) { + add('portable', 'name must match [a-z0-9-]') + } + if (skill.name.length > 64) add('portable', 'name exceeds 64 characters') + if (skill.description.length > 1024) { + add('portable', 'description exceeds 1024 characters') + } + } + + if (targets.includes('anthropic')) { + const lower = skill.name.toLowerCase() + if (RESERVED_ANTHROPIC.some((r) => lower.includes(r))) { + add('anthropic', 'name may not contain "anthropic" or "claude"') + } + if (XML_TAG.test(skill.name) || XML_TAG.test(skill.description)) { + add('anthropic', 'name/description may not contain XML tags') + } + } + + if (targets.includes('openai')) { + // OpenAI requires exactly one case-insensitive SKILL.md per bundle — a + // bundle-shape constraint not visible from metadata alone. Only the + // metadata-checkable rule is enforced here. + if (skill.name.trim() === '') add('openai', 'name must not be empty') + } + + return { ok: issues.length === 0, issues } +} diff --git a/packages/ai-skills/src/walk.ts b/packages/ai-skills/src/walk.ts new file mode 100644 index 0000000000..9cb41a4059 --- /dev/null +++ b/packages/ai-skills/src/walk.ts @@ -0,0 +1,83 @@ +/** + * Generic skill-directory walk, shared with `@tanstack/ai-sandbox`. + * + * The algorithm is identical to the one in `ai-sandbox/src/agents-file.ts`, but + * parameterized over an injected `list` function so it works over any backing + * store (`node:fs`, a `SandboxHandle.fs`, an in-memory tree). Taking only an + * injected function keeps this edge-safe, so it lives in the root barrel. + */ + +/** A directory that contains `SKILL.md`. */ +export interface DiscoveredSkillDir { + name: string + dir: string +} + +/** One entry as reported by an injected {@link ListDir}. */ +export interface WalkEntry { + name: string + path: string + type: 'file' | 'dir' +} + +export type ListDir = (dir: string) => Promise> + +export const SKILL_FILE = 'SKILL.md' +export const MAX_SKILL_WALK_DEPTH = 6 +const SKIP_DIR_NAMES = new Set(['.git', 'node_modules']) + +function basenameOf(path: string): string { + const segments = path.split('/').filter((segment) => segment !== '') + return segments[segments.length - 1] ?? path +} + +/** + * Find every skill folder under `root`. A skill folder is a directory that + * directly contains `SKILL.md`; the walk stops descending once found. Skips + * dot-directories, `.git`, and `node_modules`. Bounded by `maxDepth`. Errors + * from `list` are swallowed (an unreadable directory yields nothing). + * + * Unlike `ai-sandbox`'s `discoverSkillDirs`, this returns `[]` when nothing is + * found — the "fall back to the clone dir" behavior is a harness-projection + * concern and stays at that call site (it is wrong for a catalog). + */ +export async function walkSkillDirs( + list: ListDir, + root: string, + opts: { maxDepth?: number } = {}, +): Promise> { + const maxDepth = opts.maxDepth ?? MAX_SKILL_WALK_DEPTH + const found: Array = [] + await walk(list, root, found, 0, maxDepth) + return found +} + +async function walk( + list: ListDir, + dir: string, + found: Array, + depth: number, + maxDepth: number, +): Promise { + if (depth > maxDepth) return + let entries: Array + try { + entries = await list(dir) + } catch { + return + } + const hasSkill = entries.some( + (entry) => + entry.type === 'file' && + entry.name.toLowerCase() === SKILL_FILE.toLowerCase(), + ) + if (hasSkill) { + found.push({ name: basenameOf(dir), dir }) + return + } + for (const entry of entries) { + if (entry.type !== 'dir') continue + if (entry.name.startsWith('.') || SKIP_DIR_NAMES.has(entry.name)) continue + await walk(list, entry.path, found, depth + 1, maxDepth) + } +} diff --git a/packages/ai-skills/tests/catalog.test.ts b/packages/ai-skills/tests/catalog.test.ts new file mode 100644 index 0000000000..b2473168a8 --- /dev/null +++ b/packages/ai-skills/tests/catalog.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { renderCatalog, sortSkills } from '../src/catalog' +import type { SkillMetadata } from '../src/types' + +const skills: Array = [ + { name: 'zeta', description: 'last' }, + { name: 'alpha', description: 'first & ' }, +] + +describe('renderCatalog', () => { + it('sorts by name deterministically', () => { + expect(sortSkills(skills).map((s) => s.name)).toEqual(['alpha', 'zeta']) + }) + + it('renders Anthropic XML with escaped content', () => { + const out = renderCatalog(skills, 'anthropic') + expect(out.startsWith('')).toBe(true) + expect(out.indexOf('name="alpha"')).toBeLessThan(out.indexOf('name="zeta"')) + expect(out).toContain('first & <special>') + }) + + it('renders markdown for non-Anthropic families', () => { + const out = renderCatalog(skills, 'openai') + expect(out).toContain('## Available skills') + expect(out).toContain('- **alpha**: first & ') + }) + + it('is stable across calls (cache-friendly)', () => { + expect(renderCatalog(skills, 'anthropic')).toBe( + renderCatalog(skills, 'anthropic'), + ) + }) +}) diff --git a/packages/ai-skills/tests/combinators.test.ts b/packages/ai-skills/tests/combinators.test.ts new file mode 100644 index 0000000000..453e4d0cee --- /dev/null +++ b/packages/ai-skills/tests/combinators.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' +import { aggregate, cache, dedupe, filter } from '../src/combinators' +import { inlineSkill } from '../src/sources/inline' +import type { SkillMetadata, SkillSource } from '../src/types' + +const alpha = inlineSkill({ name: 'alpha', description: 'a', instructions: 'A' }) +const beta = inlineSkill({ name: 'beta', description: 'b', instructions: 'B' }) +const alpha2 = inlineSkill({ + name: 'alpha', + description: 'a2', + instructions: 'A2', +}) + +describe('aggregate', () => { + it('concatenates in order and routes load() to the owner', async () => { + const s = aggregate([alpha, beta]) + expect((await s.list()).map((x) => x.name)).toEqual(['alpha', 'beta']) + expect(await s.load('beta')).toBe('B') + }) +}) + +describe('dedupe', () => { + it('keeps the first occurrence and warns on collision', async () => { + const warn = vi.fn() + const s = dedupe(aggregate([alpha, alpha2]), warn) + const list = await s.list() + expect(list).toHaveLength(1) + expect(warn).toHaveBeenCalledWith('alpha') + }) +}) + +describe('filter', () => { + it('hides rejected skills', async () => { + const s = filter(aggregate([alpha, beta]), (m) => m.name !== 'beta') + expect((await s.list()).map((x) => x.name)).toEqual(['alpha']) + }) +}) + +describe('cache', () => { + it('serves concurrent list() from a single underlying fetch', async () => { + let calls = 0 + const underlying: SkillSource = { + list: () => { + calls++ + const meta: SkillMetadata = { name: 'alpha', description: 'a' } + return Promise.resolve([meta]) + }, + load: () => Promise.resolve('A'), + } + const s = cache(underlying) + await Promise.all([s.list(), s.list(), s.list()]) + expect(calls).toBe(1) + }) +}) diff --git a/packages/ai-skills/tests/conformance.test.ts b/packages/ai-skills/tests/conformance.test.ts new file mode 100644 index 0000000000..08df64e8b8 --- /dev/null +++ b/packages/ai-skills/tests/conformance.test.ts @@ -0,0 +1,40 @@ +import { fileURLToPath } from 'node:url' +import { aggregate } from '../src/combinators' +import { inlineSkill } from '../src/sources/inline' +import { staticSkills } from '../src/static' +import { skillDirectory } from '../src/node' +import { runSkillSourceConformance } from '../src/testing' +import type { GeneratedCatalog } from '../src/static' + +const fixtures = fileURLToPath(new URL('./fixtures/skills', import.meta.url)) + +// inlineSkill holds one skill; aggregate two to satisfy the alpha+beta contract. +runSkillSourceConformance( + () => + aggregate([ + inlineSkill({ + name: 'alpha', + description: 'does A', + instructions: 'Do A.', + resources: { 'references/note.md': 'hello' }, + }), + inlineSkill({ name: 'beta', description: 'does B', instructions: 'Do B.' }), + ]), + 'inlineSkill', +) + +const catalog: GeneratedCatalog = { + revision: 'rev-1', + skills: [ + { + name: 'alpha', + description: 'does A', + body: 'Do A.', + resources: { 'references/note.md': 'hello' }, + }, + { name: 'beta', description: 'does B', body: 'Do B.' }, + ], +} +runSkillSourceConformance(() => staticSkills(catalog), 'staticSkills') + +runSkillSourceConformance(() => skillDirectory(fixtures), 'skillDirectory') diff --git a/packages/ai-skills/tests/fixtures/skills/alpha/SKILL.md b/packages/ai-skills/tests/fixtures/skills/alpha/SKILL.md new file mode 100644 index 0000000000..d262e9ce94 --- /dev/null +++ b/packages/ai-skills/tests/fixtures/skills/alpha/SKILL.md @@ -0,0 +1,9 @@ +--- +name: alpha +description: does the alpha thing +compatibility: any runtime +--- + +# Alpha + +Do the alpha thing. diff --git a/packages/ai-skills/tests/fixtures/skills/alpha/references/note.md b/packages/ai-skills/tests/fixtures/skills/alpha/references/note.md new file mode 100644 index 0000000000..b6fc4c620b --- /dev/null +++ b/packages/ai-skills/tests/fixtures/skills/alpha/references/note.md @@ -0,0 +1 @@ +hello \ No newline at end of file diff --git a/packages/ai-skills/tests/fixtures/skills/alpha/scripts/run.py b/packages/ai-skills/tests/fixtures/skills/alpha/scripts/run.py new file mode 100644 index 0000000000..b917a726c9 --- /dev/null +++ b/packages/ai-skills/tests/fixtures/skills/alpha/scripts/run.py @@ -0,0 +1 @@ +print(1) diff --git a/packages/ai-skills/tests/fixtures/skills/beta/SKILL.md b/packages/ai-skills/tests/fixtures/skills/beta/SKILL.md new file mode 100644 index 0000000000..7f39b185e9 --- /dev/null +++ b/packages/ai-skills/tests/fixtures/skills/beta/SKILL.md @@ -0,0 +1,8 @@ +--- +name: beta +description: does the beta thing +--- + +# Beta + +Do the beta thing. diff --git a/packages/ai-skills/tests/fixtures/skills/gamma/SKILL.md b/packages/ai-skills/tests/fixtures/skills/gamma/SKILL.md new file mode 100644 index 0000000000..63569358bc --- /dev/null +++ b/packages/ai-skills/tests/fixtures/skills/gamma/SKILL.md @@ -0,0 +1,8 @@ +--- +name: gamma +description: ratio is 2:1 and it still parses +--- + +# Gamma + +Hostile-ish content: an unquoted colon in the description above. diff --git a/packages/ai-skills/tests/load-skill.test.ts b/packages/ai-skills/tests/load-skill.test.ts new file mode 100644 index 0000000000..5892065978 --- /dev/null +++ b/packages/ai-skills/tests/load-skill.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { ALREADY_LOADED, createLoadSkillTool } from '../src/tools/load-skill' +import { inlineSkill } from '../src/sources/inline' +import type { SkillMetadata } from '../src/types' + +const source = inlineSkill({ + name: 'alpha', + description: 'does A', + instructions: 'Step 1. Do A.', + resources: { 'references/note.md': 'hello' }, +}) +const skills: Array = [{ name: 'alpha', description: 'does A' }] + +/** ServerTool stores its handler on `execute`. */ +function exec(tool: unknown): (input: unknown) => Promise { + const fn = (tool as { execute?: (i: unknown) => Promise }).execute + if (!fn) throw new Error('tool has no execute') + return fn +} + +describe('createLoadSkillTool', () => { + it('returns the frozen result shape with stripped content + resources', async () => { + const tool = createLoadSkillTool({ + source, + skills, + activated: new Set(), + }) + expect(tool.name).toBe('load_skill') + const r = await exec(tool)({ name: 'alpha' }) + expect(r).toEqual({ + skill: 'alpha', + content: 'Step 1. Do A.', + resources: ['references/note.md'], + scripts: [], + }) + }) + + it('dedupes a second activation of the same skill', async () => { + const tool = createLoadSkillTool({ + source, + skills, + activated: new Set(), + }) + await exec(tool)({ name: 'alpha' }) + const second = await exec(tool)({ name: 'alpha' }) + expect(second.content).toBe(ALREADY_LOADED) + expect(second.resources).toEqual([]) + }) +}) diff --git a/packages/ai-skills/tests/parse.test.ts b/packages/ai-skills/tests/parse.test.ts new file mode 100644 index 0000000000..f839021683 --- /dev/null +++ b/packages/ai-skills/tests/parse.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { SkillParseError, parseSkill, stripFrontmatter } from '../src/parse' + +const md = (fm: string, body = '# Body\ntext') => `---\n${fm}\n---\n${body}` + +describe('parseSkill', () => { + it('parses a simple flat frontmatter', () => { + const r = parseSkill(md('name: alpha\ndescription: does a thing'), { + dirName: 'alpha', + }) + expect(r.metadata.name).toBe('alpha') + expect(r.metadata.description).toBe('does a thing') + expect(r.warnings).toEqual([]) + expect(r.body).toBe('# Body\ntext') + }) + + it('handles an unquoted colon in the description (single pass, no retry)', () => { + const r = parseSkill(md('name: a\ndescription: Ratio is 2:1 always'), { + dirName: 'a', + }) + expect(r.metadata.description).toBe('Ratio is 2:1 always') + }) + + it('parses a folded block-scalar description', () => { + const r = parseSkill( + md('name: a\ndescription: >\n line one\n line two'), + { dirName: 'a' }, + ) + expect(r.metadata.description).toBe('line one line two') + }) + + it('parses list + map frontmatter fields', () => { + const r = parseSkill( + md( + 'name: a\ndescription: d\nallowedTools: [read, write]\nmetadata:\n team: core', + ), + { dirName: 'a' }, + ) + expect(r.metadata.allowedTools).toEqual(['read', 'write']) + expect(r.metadata.metadata).toEqual({ team: 'core' }) + }) + + it('warns (but loads) on name/dir mismatch and >64 char names', () => { + const r = parseSkill(md('name: alpha\ndescription: d'), { dirName: 'beta' }) + expect(r.metadata.name).toBe('alpha') + expect(r.warnings.map((w) => w.code)).toContain('name-dir-mismatch') + }) + + it('throws in strict mode when a warning would fire', () => { + expect(() => + parseSkill(md('name: alpha\ndescription: d'), { + dirName: 'beta', + strict: true, + }), + ).toThrow(SkillParseError) + }) + + it('skips (throws) when description is missing or there is no frontmatter', () => { + expect(() => parseSkill(md('name: alpha'), { dirName: 'alpha' })).toThrow( + SkillParseError, + ) + expect(() => parseSkill('# just a heading')).toThrow(SkillParseError) + }) +}) + +describe('stripFrontmatter', () => { + it('removes the frontmatter block', () => { + expect(stripFrontmatter(md('name: a\ndescription: d', 'hello'))).toBe( + 'hello', + ) + }) + it('returns the input trimmed when there is no frontmatter', () => { + expect(stripFrontmatter(' no fm ')).toBe('no fm') + }) +}) diff --git a/packages/ai-skills/tests/validate.test.ts b/packages/ai-skills/tests/validate.test.ts new file mode 100644 index 0000000000..dba6054e96 --- /dev/null +++ b/packages/ai-skills/tests/validate.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { validateSkill } from '../src/validate' + +describe('validateSkill', () => { + it('passes a clean portable skill by default', () => { + expect( + validateSkill({ name: 'my-skill', description: 'ok' }).ok, + ).toBe(true) + }) + + it('flags invalid portable names', () => { + const r = validateSkill({ name: 'My_Skill', description: 'x' }) + expect(r.ok).toBe(false) + expect(r.issues[0]?.target).toBe('portable') + }) + + it('flags Anthropic reserved names and XML tags', () => { + const r = validateSkill( + { name: 'claude-helper', description: 'has tag' }, + { targets: ['anthropic'] }, + ) + const messages = r.issues.map((i) => i.message).join(' ') + expect(messages).toContain('anthropic') + expect(messages).toContain('XML') + }) +}) diff --git a/packages/ai-skills/tests/walk.test.ts b/packages/ai-skills/tests/walk.test.ts new file mode 100644 index 0000000000..bfc98b7dec --- /dev/null +++ b/packages/ai-skills/tests/walk.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { walkSkillDirs } from '../src/walk' +import type { ListDir, WalkEntry } from '../src/walk' + +/** Build an in-memory lister from a { dir: entries } map. */ +function memLister(tree: Record>): ListDir { + return (dir) => Promise.resolve(tree[dir] ?? []) +} + +const file = (name: string, path: string): WalkEntry => ({ + name, + path, + type: 'file', +}) +const dir = (name: string, path: string): WalkEntry => ({ + name, + path, + type: 'dir', +}) + +describe('walkSkillDirs', () => { + it('finds nested skill folders and stops descending at SKILL.md', () => { + const tree = { + '/root': [dir('skills', '/root/skills')], + '/root/skills': [dir('alpha', '/root/skills/alpha')], + '/root/skills/alpha': [ + file('SKILL.md', '/root/skills/alpha/SKILL.md'), + dir('nested', '/root/skills/alpha/nested'), + ], + } + return walkSkillDirs(memLister(tree), '/root').then((found) => { + expect(found).toEqual([{ name: 'alpha', dir: '/root/skills/alpha' }]) + }) + }) + + it('skips .git and node_modules', async () => { + const tree = { + '/root': [ + dir('.git', '/root/.git'), + dir('node_modules', '/root/node_modules'), + ], + '/root/.git': [file('SKILL.md', '/root/.git/SKILL.md')], + '/root/node_modules': [file('SKILL.md', '/root/node_modules/SKILL.md')], + } + expect(await walkSkillDirs(memLister(tree), '/root')).toEqual([]) + }) + + it('returns [] when nothing is found (no clone-dir fallback)', async () => { + expect(await walkSkillDirs(memLister({ '/root': [] }), '/root')).toEqual([]) + }) + + it('respects maxDepth', async () => { + const tree = { + '/r': [dir('a', '/r/a')], + '/r/a': [dir('b', '/r/a/b')], + '/r/a/b': [file('SKILL.md', '/r/a/b/SKILL.md')], + } + expect(await walkSkillDirs(memLister(tree), '/r', { maxDepth: 1 })).toEqual( + [], + ) + }) +}) diff --git a/packages/ai-skills/tsconfig.json b/packages/ai-skills/tsconfig.json new file mode 100644 index 0000000000..c38689f4ea --- /dev/null +++ b/packages/ai-skills/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-skills/vite.config.ts b/packages/ai-skills/vite.config.ts new file mode 100644 index 0000000000..c02dcd7697 --- /dev/null +++ b/packages/ai-skills/vite.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: [ + './src/index.ts', + './src/node/index.ts', + './src/static/index.ts', + './src/testing/index.ts', + ], + srcDir: './src', + // The testing conformance suite imports Vitest; keep it external so the + // built artifact references the consumer's Vitest at test time instead of + // bundling the runner. + externalDeps: ['vitest'], + cjs: false, + }), +) diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 39b77827cf..e55ffedfb9 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -104,6 +104,8 @@ export type { // MCP error classes (value exports — usable with instanceof) export { MCPDuplicateToolNameError } from './activities/chat/mcp/manager' export { DuplicateToolNameError } from './activities/chat/tools/unique-tool-names' +export { SkillLimitError } from './utilities/errors' +export type { SkillLimitErrorInit } from './utilities/errors' // Schema conversion (Standard JSON Schema compliant) export { diff --git a/packages/ai/src/utilities/errors.ts b/packages/ai/src/utilities/errors.ts index ad6e0f481f..24425e0f8c 100644 --- a/packages/ai/src/utilities/errors.ts +++ b/packages/ai/src/utilities/errors.ts @@ -1,5 +1,47 @@ import type { StreamChunk } from '../types' +/** + * Thrown when a skills request exceeds a provider limit. Lives in core (rather + * than `@tanstack/ai-skills`) so the native tool factories in `ai-anthropic` + * and `openai-base` can throw it without depending on the skills package; + * `@tanstack/ai-skills` re-exports it for the portable path. + * + * `path` distinguishes the native provider cap (e.g. Anthropic's 8-skill + * limit) from a portable-path limit, so a portable user isn't sent chasing a + * cap that only applies to hosted skills. + */ +export interface SkillLimitErrorInit { + provider: 'anthropic' | 'openai' + path: 'native' | 'portable' + limit: string + allowed: number + actual: number + offending: Array +} + +export class SkillLimitError extends Error { + readonly provider: 'anthropic' | 'openai' + readonly path: 'native' | 'portable' + readonly limit: string + readonly allowed: number + readonly actual: number + readonly offending: Array + + constructor(init: SkillLimitErrorInit) { + super( + `${init.provider} ${init.path} skills limit exceeded (${init.limit}): ` + + `${init.actual} > ${init.allowed}`, + ) + this.name = 'SkillLimitError' + this.provider = init.provider + this.path = init.path + this.limit = init.limit + this.allowed = init.allowed + this.actual = init.actual + this.offending = init.offending + } +} + /** * Best-effort extraction of a human-readable message from an unknown thrown * value, returning `undefined` when none can be found. diff --git a/packages/openai-base/src/tools/shell-tool.ts b/packages/openai-base/src/tools/shell-tool.ts index f739d82672..3011b21fa9 100644 --- a/packages/openai-base/src/tools/shell-tool.ts +++ b/packages/openai-base/src/tools/shell-tool.ts @@ -20,6 +20,32 @@ export interface ShellToolFactoryConfig { environment?: NonNullable } +/** + * Validate skill references carried by a shell `environment`. Previously the + * factory validated nothing, so a malformed `skill_id` surfaced as an unframed + * provider 400. Only `skill_reference` entries carry a `skill_id`; inline and + * local skills are shaped differently and left untouched. + * + * ponytail: OpenAI documents no client-checkable count cap for shell skills + * (unlike Anthropic's 8), so we validate `skill_id` format only and do not + * fabricate a `SkillLimitError` count limit. Add one here if OpenAI publishes a cap. + */ +function validateShellEnvironment( + environment: ShellToolFactoryConfig['environment'], +): void { + const skills = + environment && 'skills' in environment ? environment.skills : undefined + if (!skills) return + for (const skill of skills) { + if ('skill_id' in skill) { + const id = skill.skill_id + if (id.length < 1 || id.length > 64) { + throw new Error('skill_id must be between 1 and 64 characters.') + } + } + } +} + /** * Converts a standard Tool to OpenAI ShellTool format, preserving any * `environment` (container config + skills) stored in metadata. @@ -42,6 +68,7 @@ export function convertShellToolToAdapterFormat(tool: Tool): ShellToolConfig { * re-wrap this in their own package. */ export function shellTool(config: ShellToolFactoryConfig = {}): Tool { + validateShellEnvironment(config.environment) return openAIProviderTool( { name: 'shell', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 827b407241..665dd988cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2450,6 +2450,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0(zod@4.3.6) + '@tanstack/ai-skills': + specifier: workspace:^ + version: link:../ai-skills devDependencies: '@ngrok/ngrok': specifier: ^1.7.0 @@ -2558,6 +2561,22 @@ importers: specifier: 4.1.10 version: 4.1.10(vitest@4.1.10) + packages/ai-skills: + dependencies: + '@tanstack/ai': + specifier: workspace:^ + version: link:../ai + devDependencies: + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.10) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@27.4.0(@noble/hashes@2.3.0)(postcss@8.5.26))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + zod: + specifier: ^4.2.0 + version: 4.3.6 + packages/ai-solid: dependencies: '@tanstack/ai-client': @@ -2992,6 +3011,9 @@ importers: '@tanstack/ai-sandbox': specifier: workspace:* version: link:../../packages/ai-sandbox + '@tanstack/ai-skills': + specifier: workspace:* + version: link:../../packages/ai-skills '@tanstack/ai-vercel-gateway': specifier: workspace:* version: link:../../packages/ai-vercel-gateway @@ -3119,6 +3141,9 @@ importers: '@tanstack/ai-react-ui': specifier: workspace:* version: link:../../packages/ai-react-ui + '@tanstack/ai-skills': + specifier: workspace:* + version: link:../../packages/ai-skills '@tanstack/nitro-v2-vite-plugin': specifier: ^1.155.0 version: 1.155.0(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.2.4)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) @@ -11786,6 +11811,7 @@ packages: eslint@9.39.4: resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' diff --git a/testing/e2e/package.json b/testing/e2e/package.json index 0d100c5160..943f8342f6 100644 --- a/testing/e2e/package.json +++ b/testing/e2e/package.json @@ -40,6 +40,7 @@ "@tanstack/ai-react": "workspace:*", "@tanstack/ai-react-ui": "workspace:*", "@tanstack/ai-sandbox": "workspace:*", + "@tanstack/ai-skills": "workspace:*", "@tanstack/ai-vercel-gateway": "workspace:*", "@tanstack/ai-vertex": "workspace:*", "@tanstack/devtools-event-bus": "^0.4.1", diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 3acd134ba7..6be0c64d7b 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -40,6 +40,7 @@ import { Route as ApiSandboxToolHistoryRouteImport } from './routes/api.sandbox- import { Route as ApiSandboxFilePersistenceRouteImport } from './routes/api.sandbox-file-persistence' import { Route as ApiSandboxDurabilityRouteImport } from './routes/api.sandbox-durability' import { Route as ApiProviderToolDispatchWireRouteImport } from './routes/api.provider-tool-dispatch-wire' +import { Route as ApiPortableSkillsWireRouteImport } from './routes/api.portable-skills-wire' import { Route as ApiPersistenceDurabilityRouteImport } from './routes/api.persistence-durability' import { Route as ApiOtelUsageRouteImport } from './routes/api.otel-usage' import { Route as ApiOtelTranscriptionRouteImport } from './routes/api.otel-transcription' @@ -80,8 +81,8 @@ import { Route as ApiDurableTakeoverRouteImport } from './routes/api.durable-tak import { Route as ApiDurableDeliveryRouteImport } from './routes/api.durable-delivery' import { Route as ApiDevtoolsMemoryRouteImport } from './routes/api.devtools-memory' import { Route as ApiChatRouteImport } from './routes/api.chat' -import { Route as ApiByokChatRouteImport } from './routes/api.byok-chat' import { Route as ApiByteplusSeedance1080pWireRouteImport } from './routes/api.byteplus-seedance-1080p-wire' +import { Route as ApiByokChatRouteImport } from './routes/api.byok-chat' import { Route as ApiAudioRouteImport } from './routes/api.audio' import { Route as ApiArktypeToolWireRouteImport } from './routes/api.arktype-tool-wire' import { Route as ApiAnthropicStructuredUsageRouteImport } from './routes/api.anthropic-structured-usage' @@ -254,6 +255,11 @@ const ApiProviderToolDispatchWireRoute = path: '/api/provider-tool-dispatch-wire', getParentRoute: () => rootRouteImport, } as any) +const ApiPortableSkillsWireRoute = ApiPortableSkillsWireRouteImport.update({ + id: '/api/portable-skills-wire', + path: '/api/portable-skills-wire', + getParentRoute: () => rootRouteImport, +} as any) const ApiPersistenceDurabilityRoute = ApiPersistenceDurabilityRouteImport.update({ id: '/api/persistence-durability', @@ -466,17 +472,17 @@ const ApiChatRoute = ApiChatRouteImport.update({ path: '/api/chat', getParentRoute: () => rootRouteImport, } as any) -const ApiByokChatRoute = ApiByokChatRouteImport.update({ - id: '/api/byok-chat', - path: '/api/byok-chat', - getParentRoute: () => rootRouteImport, -} as any) const ApiByteplusSeedance1080pWireRoute = ApiByteplusSeedance1080pWireRouteImport.update({ id: '/api/byteplus-seedance-1080p-wire', path: '/api/byteplus-seedance-1080p-wire', getParentRoute: () => rootRouteImport, } as any) +const ApiByokChatRoute = ApiByokChatRouteImport.update({ + id: '/api/byok-chat', + path: '/api/byok-chat', + getParentRoute: () => rootRouteImport, +} as any) const ApiAudioRoute = ApiAudioRouteImport.update({ id: '/api/audio', path: '/api/audio', @@ -603,6 +609,7 @@ export interface FileRoutesByFullPath { '/api/otel-transcription': typeof ApiOtelTranscriptionRoute '/api/otel-usage': typeof ApiOtelUsageRoute '/api/persistence-durability': typeof ApiPersistenceDurabilityRoute + '/api/portable-skills-wire': typeof ApiPortableSkillsWireRoute '/api/provider-tool-dispatch-wire': typeof ApiProviderToolDispatchWireRoute '/api/sandbox-durability': typeof ApiSandboxDurabilityRoute '/api/sandbox-file-persistence': typeof ApiSandboxFilePersistenceRoute @@ -689,6 +696,7 @@ export interface FileRoutesByTo { '/api/otel-transcription': typeof ApiOtelTranscriptionRoute '/api/otel-usage': typeof ApiOtelUsageRoute '/api/persistence-durability': typeof ApiPersistenceDurabilityRoute + '/api/portable-skills-wire': typeof ApiPortableSkillsWireRoute '/api/provider-tool-dispatch-wire': typeof ApiProviderToolDispatchWireRoute '/api/sandbox-durability': typeof ApiSandboxDurabilityRoute '/api/sandbox-file-persistence': typeof ApiSandboxFilePersistenceRoute @@ -776,6 +784,7 @@ export interface FileRoutesById { '/api/otel-transcription': typeof ApiOtelTranscriptionRoute '/api/otel-usage': typeof ApiOtelUsageRoute '/api/persistence-durability': typeof ApiPersistenceDurabilityRoute + '/api/portable-skills-wire': typeof ApiPortableSkillsWireRoute '/api/provider-tool-dispatch-wire': typeof ApiProviderToolDispatchWireRoute '/api/sandbox-durability': typeof ApiSandboxDurabilityRoute '/api/sandbox-file-persistence': typeof ApiSandboxFilePersistenceRoute @@ -864,6 +873,7 @@ export interface FileRouteTypes { | '/api/otel-transcription' | '/api/otel-usage' | '/api/persistence-durability' + | '/api/portable-skills-wire' | '/api/provider-tool-dispatch-wire' | '/api/sandbox-durability' | '/api/sandbox-file-persistence' @@ -950,6 +960,7 @@ export interface FileRouteTypes { | '/api/otel-transcription' | '/api/otel-usage' | '/api/persistence-durability' + | '/api/portable-skills-wire' | '/api/provider-tool-dispatch-wire' | '/api/sandbox-durability' | '/api/sandbox-file-persistence' @@ -1036,6 +1047,7 @@ export interface FileRouteTypes { | '/api/otel-transcription' | '/api/otel-usage' | '/api/persistence-durability' + | '/api/portable-skills-wire' | '/api/provider-tool-dispatch-wire' | '/api/sandbox-durability' | '/api/sandbox-file-persistence' @@ -1123,6 +1135,7 @@ export interface RootRouteChildren { ApiOtelTranscriptionRoute: typeof ApiOtelTranscriptionRoute ApiOtelUsageRoute: typeof ApiOtelUsageRoute ApiPersistenceDurabilityRoute: typeof ApiPersistenceDurabilityRoute + ApiPortableSkillsWireRoute: typeof ApiPortableSkillsWireRoute ApiProviderToolDispatchWireRoute: typeof ApiProviderToolDispatchWireRoute ApiSandboxDurabilityRoute: typeof ApiSandboxDurabilityRoute ApiSandboxFilePersistenceRoute: typeof ApiSandboxFilePersistenceRoute @@ -1355,6 +1368,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiProviderToolDispatchWireRouteImport parentRoute: typeof rootRouteImport } + '/api/portable-skills-wire': { + id: '/api/portable-skills-wire' + path: '/api/portable-skills-wire' + fullPath: '/api/portable-skills-wire' + preLoaderRoute: typeof ApiPortableSkillsWireRouteImport + parentRoute: typeof rootRouteImport + } '/api/persistence-durability': { id: '/api/persistence-durability' path: '/api/persistence-durability' @@ -1635,13 +1655,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiChatRouteImport parentRoute: typeof rootRouteImport } - '/api/byok-chat': { - id: '/api/byok-chat' - path: '/api/byok-chat' - fullPath: '/api/byok-chat' - preLoaderRoute: typeof ApiByokChatRouteImport - parentRoute: typeof rootRouteImport - } '/api/byteplus-seedance-1080p-wire': { id: '/api/byteplus-seedance-1080p-wire' path: '/api/byteplus-seedance-1080p-wire' @@ -1649,6 +1662,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiByteplusSeedance1080pWireRouteImport parentRoute: typeof rootRouteImport } + '/api/byok-chat': { + id: '/api/byok-chat' + path: '/api/byok-chat' + fullPath: '/api/byok-chat' + preLoaderRoute: typeof ApiByokChatRouteImport + parentRoute: typeof rootRouteImport + } '/api/audio': { id: '/api/audio' path: '/api/audio' @@ -1856,6 +1876,7 @@ const rootRouteChildren: RootRouteChildren = { ApiOtelTranscriptionRoute: ApiOtelTranscriptionRoute, ApiOtelUsageRoute: ApiOtelUsageRoute, ApiPersistenceDurabilityRoute: ApiPersistenceDurabilityRoute, + ApiPortableSkillsWireRoute: ApiPortableSkillsWireRoute, ApiProviderToolDispatchWireRoute: ApiProviderToolDispatchWireRoute, ApiSandboxDurabilityRoute: ApiSandboxDurabilityRoute, ApiSandboxFilePersistenceRoute: ApiSandboxFilePersistenceRoute, diff --git a/testing/e2e/src/routes/api.portable-skills-wire.ts b/testing/e2e/src/routes/api.portable-skills-wire.ts new file mode 100644 index 0000000000..df77358316 --- /dev/null +++ b/testing/e2e/src/routes/api.portable-skills-wire.ts @@ -0,0 +1,199 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat, createChatOptions } from '@tanstack/ai' +import { createAnthropicChat } from '@tanstack/ai-anthropic' +import { codeExecutionTool } from '@tanstack/ai-anthropic/tools' +import { createOpenaiChat } from '@tanstack/ai-openai' +import { createResourceTool, inlineSkill, withSkills } from '@tanstack/ai-skills' + +const DUMMY_KEY = 'sk-e2e-test-dummy-key' + +/** + * Wire-format verification for the PORTABLE skills path (`withSkills`), the + * complement of the native `*-skills-wire` routes. A custom `fetch` captures + * the outgoing request so the spec can assert: + * + * - the rendered catalog reaches the model (Anthropic → `` + * XML in `system`; OpenAI → a markdown section in `instructions`); + * - the `load_skill` and `read_skill_resource` tools are advertised. + * + * `?provider=anthropic|openai` selects the family. `?mode=coexist` instead + * combines `withSkills` with a hosted-skills `code_execution` tool to prove the + * portable/native co-existence refusal fires. + */ + +const skillSource = inlineSkill({ + name: 'pptx-helper', + description: 'Build and edit PowerPoint decks', + instructions: 'Use python-pptx. Open the deck, edit slides, save.', + resources: { 'references/tips.md': 'Keep one idea per slide.' }, +}) + +function makeAnthropicStream(): ReadableStream { + const encoder = new TextEncoder() + const events = [ + { + type: 'message_start', + message: { + id: 'msg_portable', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-sonnet-4-5', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 0 }, + }, + }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'ok' } }, + { type: 'content_block_stop', index: 0 }, + { + type: 'message_delta', + delta: { stop_reason: 'end_turn', stop_sequence: null }, + usage: { output_tokens: 2 }, + }, + { type: 'message_stop' }, + ] + return new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue( + encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`), + ) + } + controller.close() + }, + }) +} + +function makeOpenAIStream(): ReadableStream { + const encoder = new TextEncoder() + const responseId = 'resp_portable' + const events = [ + { + type: 'response.created', + response: { id: responseId, object: 'response', status: 'in_progress' }, + }, + { + type: 'response.output_item.done', + response_id: responseId, + output_index: 0, + item: { + id: 'msg_wire', + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'ok' }], + }, + }, + { + type: 'response.completed', + response: { + id: responseId, + object: 'response', + status: 'completed', + output: [ + { + id: 'msg_wire', + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'ok' }], + }, + ], + usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 }, + }, + }, + ] + return new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)) + } + controller.enqueue(encoder.encode('data: [DONE]\n\n')) + controller.close() + }, + }) +} + +export const Route = createFileRoute('/api/portable-skills-wire')({ + server: { + handlers: { + POST: async ({ request }) => { + const url = new URL(request.url) + const provider = url.searchParams.get('provider') ?? 'anthropic' + const mode = url.searchParams.get('mode') ?? 'portable' + const isOpenai = provider === 'openai' + + let capturedRequest: { + headers: Record + body: unknown + } | null = null + + const capturingFetch: typeof fetch = async (input, init) => { + const req = input instanceof Request ? input : new Request(input, init) + const headers: Record = {} + req.headers.forEach((value, key) => { + headers[key] = value + }) + let body: unknown = null + try { + const raw = await req.text() + if (raw) body = JSON.parse(raw) + } catch { + // body stays null + } + capturedRequest = { headers, body } + return new Response( + isOpenai ? makeOpenAIStream() : makeAnthropicStream(), + { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + }, + }, + ) + } + + const adapter = isOpenai + ? createOpenaiChat('gpt-5.2', DUMMY_KEY, { fetch: capturingFetch }) + : createAnthropicChat('claude-sonnet-4-5', DUMMY_KEY, { + fetch: capturingFetch, + }) + + const coexistTools = + mode === 'coexist' + ? [ + codeExecutionTool( + { type: 'code_execution_20250825', name: 'code_execution' }, + { skills: [{ type: 'anthropic', skill_id: 'pptx', version: 'latest' }] }, + ), + ] + : [createResourceTool(skillSource)] + + try { + for await (const _ of chat({ + ...createChatOptions({ adapter }), + messages: [{ role: 'user', content: '[portable-skills-wire] go' }], + tools: coexistTools, + middleware: [withSkills(skillSource)], + })) { + // Drain the stream. + } + } catch (error) { + return new Response( + JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) + } + + return new Response(JSON.stringify({ ok: true, capturedRequest }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }, + }, + }, +}) diff --git a/testing/e2e/tests/portable-skills-wire.spec.ts b/testing/e2e/tests/portable-skills-wire.spec.ts new file mode 100644 index 0000000000..a9c95cb890 --- /dev/null +++ b/testing/e2e/tests/portable-skills-wire.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from './fixtures' + +/** + * Wire-format verification for the PORTABLE skills path (`withSkills`). + * + * Drives `/api/portable-skills-wire`, which intercepts the outgoing SDK request + * via a custom `fetch` and returns the captured headers + body as JSON. We + * assert the rendered catalog reaches the model and the activation tools are + * advertised — without a real API key. + */ + +type Captured = { + ok: boolean + error?: string + capturedRequest: { headers: Record; body: any } | null +} + +async function post(request: any, query: string): Promise { + const res = await request.post(`/api/portable-skills-wire${query}`) + expect(res.ok()).toBe(true) + return (await res.json()) as Captured +} + +test.describe('portable skills — withSkills wire format', () => { + test('anthropic: catalog renders as XML in system', async ({ + request, + }) => { + const { ok, error, capturedRequest } = await post( + request, + '?provider=anthropic', + ) + if (!ok) throw new Error(`Route failed: ${error}`) + const body = capturedRequest?.body + const system = JSON.stringify(body?.system) + expect(system).toContain('') + expect(system).toContain('pptx-helper') + + const toolNames = (body?.tools ?? []).map((t: any) => t.name) + expect(toolNames).toContain('load_skill') + expect(toolNames).toContain('read_skill_resource') + }) + + test('openai: catalog renders as a markdown section in instructions', async ({ + request, + }) => { + const { ok, error, capturedRequest } = await post( + request, + '?provider=openai', + ) + if (!ok) throw new Error(`Route failed: ${error}`) + const body = capturedRequest?.body + const instructions = JSON.stringify(body?.instructions) + expect(instructions).toContain('Available skills') + expect(instructions).toContain('pptx-helper') + + const toolNames = (body?.tools ?? []).map((t: any) => t.name) + expect(toolNames).toContain('load_skill') + }) + + test('refuses to combine portable withSkills with hosted native skills', async ({ + request, + }) => { + const { ok, error } = await post(request, '?provider=anthropic&mode=coexist') + expect(ok).toBe(false) + expect(error).toContain('code_execution') + expect(error).toMatch(/portable|withSkills/i) + }) +}) diff --git a/testing/panel/package.json b/testing/panel/package.json index 7b1e3d63be..d32d7aa5e7 100644 --- a/testing/panel/package.json +++ b/testing/panel/package.json @@ -24,6 +24,7 @@ "@tanstack/ai-openrouter": "workspace:*", "@tanstack/ai-react": "workspace:*", "@tanstack/ai-react-ui": "workspace:*", + "@tanstack/ai-skills": "workspace:*", "@tanstack/nitro-v2-vite-plugin": "^1.155.0", "@tanstack/react-ai-devtools": "workspace:*", "@tanstack/react-devtools": "^0.9.10", diff --git a/testing/panel/skills/emoji-storyteller/SKILL.md b/testing/panel/skills/emoji-storyteller/SKILL.md new file mode 100644 index 0000000000..0ab86c494b --- /dev/null +++ b/testing/panel/skills/emoji-storyteller/SKILL.md @@ -0,0 +1,14 @@ +--- +name: emoji-storyteller +description: Retell the answer as a short story told mostly in emoji. +compatibility: any chat model +--- + +# Emoji Storyteller + +When this skill is active, deliver the answer as a short, playful story told +mostly in emoji, with a few words to hold it together. + +- Lead with a line of emoji that sets the scene. +- Keep any necessary words short. +- Make the sequence actually track the answer, not random emoji. diff --git a/testing/panel/skills/haiku/SKILL.md b/testing/panel/skills/haiku/SKILL.md new file mode 100644 index 0000000000..10816d8224 --- /dev/null +++ b/testing/panel/skills/haiku/SKILL.md @@ -0,0 +1,15 @@ +--- +name: haiku +description: Answer only as a haiku, three lines of 5-7-5 syllables. +compatibility: any chat model +--- + +# Haiku + +When this skill is active, answer the user only as a single haiku: + +- Three lines. +- Five syllables, then seven, then five. +- Capture the essence of the answer, even if you must simplify. + +Do not add any text outside the three lines. diff --git a/testing/panel/skills/pirate-speak/SKILL.md b/testing/panel/skills/pirate-speak/SKILL.md new file mode 100644 index 0000000000..036dc047d6 --- /dev/null +++ b/testing/panel/skills/pirate-speak/SKILL.md @@ -0,0 +1,18 @@ +--- +name: pirate-speak +description: Answer entirely as a boisterous pirate, with nautical slang and "arrr". +compatibility: any chat model +--- + +# Pirate Speak + +When this skill is active, answer every message in the voice of a boisterous +pirate captain. + +- Open with a hearty greeting like "Arrr!" or "Ahoy!". +- Use nautical slang: matey, landlubber, booty, the seven seas, hoist the colors. +- Keep the actual answer correct and helpful. Only the delivery is piratical. +- End with a short sign-off like "Fair winds!". + +The file `references/glossary.md` has a slang cheat sheet. Read it with +`read_skill_resource` if you need more colour. diff --git a/testing/panel/skills/pirate-speak/references/glossary.md b/testing/panel/skills/pirate-speak/references/glossary.md new file mode 100644 index 0000000000..04998f4da2 --- /dev/null +++ b/testing/panel/skills/pirate-speak/references/glossary.md @@ -0,0 +1,10 @@ +# Pirate slang cheat sheet + +- Ahoy: hello +- Avast: stop and pay attention +- Booty: treasure, loot +- Landlubber: someone unused to the sea +- Grog: a sailor's drink +- Davy Jones' locker: the bottom of the sea +- Hoist the colors: raise the flag, get ready +- Weigh anchor: set off diff --git a/testing/panel/src/components/Header.tsx b/testing/panel/src/components/Header.tsx index b7711d91af..3189e8a90a 100644 --- a/testing/panel/src/components/Header.tsx +++ b/testing/panel/src/components/Header.tsx @@ -12,6 +12,7 @@ import { Menu, Mic, Package, + Sparkles, Video, Volume2, X, @@ -139,6 +140,24 @@ export default function Header() { + setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', + }} + > + +
+ Skills + + load_skill + +
+ +

Activities diff --git a/testing/panel/src/lib/skills-store.ts b/testing/panel/src/lib/skills-store.ts new file mode 100644 index 0000000000..e8024f79bc --- /dev/null +++ b/testing/panel/src/lib/skills-store.ts @@ -0,0 +1,25 @@ +import path from 'node:path' +import { skillDirectory } from '@tanstack/ai-skills/node' + +/** + * Shared state for the `/skills` demo. The skill source reads the demo + * `skills/` folder at the panel root, and `activatedByThread` records which + * skills the model has loaded per thread so the inspector can highlight them. + * Demo-only in-memory state, reset on server restart. + */ +export const skillsSource = skillDirectory( + path.resolve(process.cwd(), 'skills'), + { strict: false }, +) + +const activatedByThread = new Map>() + +export function recordActivation(threadId: string, skill: string): void { + const set = activatedByThread.get(threadId) ?? new Set() + set.add(skill) + activatedByThread.set(threadId, set) +} + +export function activatedFor(threadId: string): Array { + return [...(activatedByThread.get(threadId) ?? [])] +} diff --git a/testing/panel/src/routeTree.gen.ts b/testing/panel/src/routeTree.gen.ts index c9ce6fdaf3..e7ac72a89b 100644 --- a/testing/panel/src/routeTree.gen.ts +++ b/testing/panel/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as TranscriptionRouteImport } from './routes/transcription' import { Route as SummarizeRouteImport } from './routes/summarize' import { Route as StructuredRouteImport } from './routes/structured' import { Route as StreamDebuggerRouteImport } from './routes/stream-debugger' +import { Route as SkillsRouteImport } from './routes/skills' import { Route as SimulatorRouteImport } from './routes/simulator' import { Route as MemoryRouteImport } from './routes/memory' import { Route as ImageRouteImport } from './routes/image' @@ -25,6 +26,8 @@ import { Route as ApiTtsRouteImport } from './routes/api.tts' import { Route as ApiTranscriptionRouteImport } from './routes/api.transcription' import { Route as ApiSummarizeRouteImport } from './routes/api.summarize' import { Route as ApiStructuredRouteImport } from './routes/api.structured' +import { Route as ApiSkillsInspectRouteImport } from './routes/api.skills-inspect' +import { Route as ApiSkillsChatRouteImport } from './routes/api.skills-chat' import { Route as ApiSimulatorChatRouteImport } from './routes/api.simulator-chat' import { Route as ApiMemoryInspectRouteImport } from './routes/api.memory-inspect' import { Route as ApiMemoryChatRouteImport } from './routes/api.memory-chat' @@ -64,6 +67,11 @@ const StreamDebuggerRoute = StreamDebuggerRouteImport.update({ path: '/stream-debugger', getParentRoute: () => rootRouteImport, } as any) +const SkillsRoute = SkillsRouteImport.update({ + id: '/skills', + path: '/skills', + getParentRoute: () => rootRouteImport, +} as any) const SimulatorRoute = SimulatorRouteImport.update({ id: '/simulator', path: '/simulator', @@ -114,6 +122,16 @@ const ApiStructuredRoute = ApiStructuredRouteImport.update({ path: '/api/structured', getParentRoute: () => rootRouteImport, } as any) +const ApiSkillsInspectRoute = ApiSkillsInspectRouteImport.update({ + id: '/api/skills-inspect', + path: '/api/skills-inspect', + getParentRoute: () => rootRouteImport, +} as any) +const ApiSkillsChatRoute = ApiSkillsChatRouteImport.update({ + id: '/api/skills-chat', + path: '/api/skills-chat', + getParentRoute: () => rootRouteImport, +} as any) const ApiSimulatorChatRoute = ApiSimulatorChatRouteImport.update({ id: '/api/simulator-chat', path: '/api/simulator-chat', @@ -161,6 +179,7 @@ export interface FileRoutesByFullPath { '/image': typeof ImageRoute '/memory': typeof MemoryRoute '/simulator': typeof SimulatorRoute + '/skills': typeof SkillsRoute '/stream-debugger': typeof StreamDebuggerRoute '/structured': typeof StructuredRoute '/summarize': typeof SummarizeRoute @@ -175,6 +194,8 @@ export interface FileRoutesByFullPath { '/api/memory-chat': typeof ApiMemoryChatRoute '/api/memory-inspect': typeof ApiMemoryInspectRoute '/api/simulator-chat': typeof ApiSimulatorChatRoute + '/api/skills-chat': typeof ApiSkillsChatRoute + '/api/skills-inspect': typeof ApiSkillsInspectRoute '/api/structured': typeof ApiStructuredRoute '/api/summarize': typeof ApiSummarizeRoute '/api/transcription': typeof ApiTranscriptionRoute @@ -187,6 +208,7 @@ export interface FileRoutesByTo { '/image': typeof ImageRoute '/memory': typeof MemoryRoute '/simulator': typeof SimulatorRoute + '/skills': typeof SkillsRoute '/stream-debugger': typeof StreamDebuggerRoute '/structured': typeof StructuredRoute '/summarize': typeof SummarizeRoute @@ -201,6 +223,8 @@ export interface FileRoutesByTo { '/api/memory-chat': typeof ApiMemoryChatRoute '/api/memory-inspect': typeof ApiMemoryInspectRoute '/api/simulator-chat': typeof ApiSimulatorChatRoute + '/api/skills-chat': typeof ApiSkillsChatRoute + '/api/skills-inspect': typeof ApiSkillsInspectRoute '/api/structured': typeof ApiStructuredRoute '/api/summarize': typeof ApiSummarizeRoute '/api/transcription': typeof ApiTranscriptionRoute @@ -214,6 +238,7 @@ export interface FileRoutesById { '/image': typeof ImageRoute '/memory': typeof MemoryRoute '/simulator': typeof SimulatorRoute + '/skills': typeof SkillsRoute '/stream-debugger': typeof StreamDebuggerRoute '/structured': typeof StructuredRoute '/summarize': typeof SummarizeRoute @@ -228,6 +253,8 @@ export interface FileRoutesById { '/api/memory-chat': typeof ApiMemoryChatRoute '/api/memory-inspect': typeof ApiMemoryInspectRoute '/api/simulator-chat': typeof ApiSimulatorChatRoute + '/api/skills-chat': typeof ApiSkillsChatRoute + '/api/skills-inspect': typeof ApiSkillsInspectRoute '/api/structured': typeof ApiStructuredRoute '/api/summarize': typeof ApiSummarizeRoute '/api/transcription': typeof ApiTranscriptionRoute @@ -242,6 +269,7 @@ export interface FileRouteTypes { | '/image' | '/memory' | '/simulator' + | '/skills' | '/stream-debugger' | '/structured' | '/summarize' @@ -256,6 +284,8 @@ export interface FileRouteTypes { | '/api/memory-chat' | '/api/memory-inspect' | '/api/simulator-chat' + | '/api/skills-chat' + | '/api/skills-inspect' | '/api/structured' | '/api/summarize' | '/api/transcription' @@ -268,6 +298,7 @@ export interface FileRouteTypes { | '/image' | '/memory' | '/simulator' + | '/skills' | '/stream-debugger' | '/structured' | '/summarize' @@ -282,6 +313,8 @@ export interface FileRouteTypes { | '/api/memory-chat' | '/api/memory-inspect' | '/api/simulator-chat' + | '/api/skills-chat' + | '/api/skills-inspect' | '/api/structured' | '/api/summarize' | '/api/transcription' @@ -294,6 +327,7 @@ export interface FileRouteTypes { | '/image' | '/memory' | '/simulator' + | '/skills' | '/stream-debugger' | '/structured' | '/summarize' @@ -308,6 +342,8 @@ export interface FileRouteTypes { | '/api/memory-chat' | '/api/memory-inspect' | '/api/simulator-chat' + | '/api/skills-chat' + | '/api/skills-inspect' | '/api/structured' | '/api/summarize' | '/api/transcription' @@ -321,6 +357,7 @@ export interface RootRouteChildren { ImageRoute: typeof ImageRoute MemoryRoute: typeof MemoryRoute SimulatorRoute: typeof SimulatorRoute + SkillsRoute: typeof SkillsRoute StreamDebuggerRoute: typeof StreamDebuggerRoute StructuredRoute: typeof StructuredRoute SummarizeRoute: typeof SummarizeRoute @@ -335,6 +372,8 @@ export interface RootRouteChildren { ApiMemoryChatRoute: typeof ApiMemoryChatRoute ApiMemoryInspectRoute: typeof ApiMemoryInspectRoute ApiSimulatorChatRoute: typeof ApiSimulatorChatRoute + ApiSkillsChatRoute: typeof ApiSkillsChatRoute + ApiSkillsInspectRoute: typeof ApiSkillsInspectRoute ApiStructuredRoute: typeof ApiStructuredRoute ApiSummarizeRoute: typeof ApiSummarizeRoute ApiTranscriptionRoute: typeof ApiTranscriptionRoute @@ -386,6 +425,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StreamDebuggerRouteImport parentRoute: typeof rootRouteImport } + '/skills': { + id: '/skills' + path: '/skills' + fullPath: '/skills' + preLoaderRoute: typeof SkillsRouteImport + parentRoute: typeof rootRouteImport + } '/simulator': { id: '/simulator' path: '/simulator' @@ -456,6 +502,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiStructuredRouteImport parentRoute: typeof rootRouteImport } + '/api/skills-inspect': { + id: '/api/skills-inspect' + path: '/api/skills-inspect' + fullPath: '/api/skills-inspect' + preLoaderRoute: typeof ApiSkillsInspectRouteImport + parentRoute: typeof rootRouteImport + } + '/api/skills-chat': { + id: '/api/skills-chat' + path: '/api/skills-chat' + fullPath: '/api/skills-chat' + preLoaderRoute: typeof ApiSkillsChatRouteImport + parentRoute: typeof rootRouteImport + } '/api/simulator-chat': { id: '/api/simulator-chat' path: '/api/simulator-chat' @@ -521,6 +581,7 @@ const rootRouteChildren: RootRouteChildren = { ImageRoute: ImageRoute, MemoryRoute: MemoryRoute, SimulatorRoute: SimulatorRoute, + SkillsRoute: SkillsRoute, StreamDebuggerRoute: StreamDebuggerRoute, StructuredRoute: StructuredRoute, SummarizeRoute: SummarizeRoute, @@ -535,6 +596,8 @@ const rootRouteChildren: RootRouteChildren = { ApiMemoryChatRoute: ApiMemoryChatRoute, ApiMemoryInspectRoute: ApiMemoryInspectRoute, ApiSimulatorChatRoute: ApiSimulatorChatRoute, + ApiSkillsChatRoute: ApiSkillsChatRoute, + ApiSkillsInspectRoute: ApiSkillsInspectRoute, ApiStructuredRoute: ApiStructuredRoute, ApiSummarizeRoute: ApiSummarizeRoute, ApiTranscriptionRoute: ApiTranscriptionRoute, diff --git a/testing/panel/src/routes/api.skills-chat.ts b/testing/panel/src/routes/api.skills-chat.ts new file mode 100644 index 0000000000..90f90da2fc --- /dev/null +++ b/testing/panel/src/routes/api.skills-chat.ts @@ -0,0 +1,121 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + createChatOptions, + maxIterations, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { anthropicText } from '@tanstack/ai-anthropic' +import { geminiText } from '@tanstack/ai-gemini' +import { grokText } from '@tanstack/ai-grok' +import { openaiText } from '@tanstack/ai-openai' +import { ollamaText } from '@tanstack/ai-ollama' +import { openRouterText } from '@tanstack/ai-openrouter' +import { createResourceTool, withSkills } from '@tanstack/ai-skills' +import { recordActivation, skillsSource } from '@/lib/skills-store' +import type { ChatMiddleware } from '@tanstack/ai' +import type { Provider } from '@/lib/model-selection' + +const SYSTEM_PROMPT = `You are a helpful assistant with a library of skills. + +A catalog of available skills is provided. When the user's request matches a +skill, call the load_skill tool to load its instructions, then follow them for +your answer. If no skill fits, just answer normally.` + +/** + * Chat endpoint for the `/skills` demo. Wires `withSkills` over the demo + * `skills/` folder so the model can load a skill (pirate-speak, haiku, + * emoji-storyteller) on demand. A tiny observer middleware records each + * load_skill activation per thread so the inspector at /api/skills-inspect can + * highlight what was loaded. + */ +export const Route = createFileRoute('/api/skills-chat')({ + server: { + handlers: { + POST: async ({ request }) => { + if (request.signal.aborted) return new Response(null, { status: 499 }) + + const abortController = new AbortController() + const body = await request.json() + const messages = body.messages + const data = body.data || {} + + const provider: Provider = data.provider || 'openai' + const model: string | undefined = data.model + const threadId: string = + typeof data.threadId === 'string' && data.threadId.length > 0 + ? data.threadId + : 'panel-default-thread' + + try { + const adapterConfig = { + anthropic: () => + createChatOptions({ + adapter: anthropicText((model || 'claude-sonnet-4-5') as any), + }), + gemini: () => + createChatOptions({ + adapter: geminiText((model || 'gemini-2.5-flash') as any), + }), + grok: () => + createChatOptions({ + adapter: grokText((model || 'grok-build-0.1') as any), + }), + ollama: () => + createChatOptions({ + adapter: ollamaText((model || 'mistral:7b') as any), + }), + openai: () => + createChatOptions({ + adapter: openaiText((model || 'gpt-4o') as any), + }), + openrouter: () => + createChatOptions({ + adapter: openRouterText((model || 'openai/gpt-4o') as any), + }), + } + + const options = adapterConfig[provider]() + const { adapter } = options + + // Record which skills the model loads so the inspector can show them. + const observer: ChatMiddleware = { + name: 'skills-observer', + onBeforeToolCall: (_ctx, hookCtx) => { + if (hookCtx.toolName === 'load_skill') { + const args = hookCtx.args + const name = + args && typeof args === 'object' && 'name' in args + ? String((args as { name: unknown }).name) + : undefined + if (name) recordActivation(threadId, name) + } + }, + } + + const stream = chat({ + ...options, + adapter, + systemPrompts: [SYSTEM_PROMPT], + tools: [createResourceTool(skillsSource)], + middleware: [withSkills(skillsSource), observer], + agentLoopStrategy: maxIterations(5), + messages, + abortController, + }) + + return toServerSentEventsResponse(stream, { abortController }) + } catch (error: any) { + console.error('[api.skills-chat] Error:', error?.message) + if (error.name === 'AbortError' || abortController.signal.aborted) { + return new Response(null, { status: 499 }) + } + return new Response( + JSON.stringify({ error: error.message || 'An error occurred' }), + { status: 500, headers: { 'Content-Type': 'application/json' } }, + ) + } + }, + }, + }, +}) diff --git a/testing/panel/src/routes/api.skills-inspect.ts b/testing/panel/src/routes/api.skills-inspect.ts new file mode 100644 index 0000000000..fc15e1ddc3 --- /dev/null +++ b/testing/panel/src/routes/api.skills-inspect.ts @@ -0,0 +1,29 @@ +import { createFileRoute } from '@tanstack/react-router' +import { activatedFor, skillsSource } from '@/lib/skills-store' + +/** + * Read-only inspector for the `/skills` demo. Returns the catalog the model + * sees (every skill's name + description) plus the skills loaded so far on this + * thread, so the page can badge which ones are active. + */ +export const Route = createFileRoute('/api/skills-inspect')({ + server: { + handlers: { + GET: async ({ request }) => { + const url = new URL(request.url) + const threadId = url.searchParams.get('threadId') ?? '' + const catalog = await skillsSource.list() + return new Response( + JSON.stringify({ + catalog: catalog.map((s) => ({ + name: s.name, + description: s.description, + })), + activated: activatedFor(threadId), + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) + }, + }, + }, +}) diff --git a/testing/panel/src/routes/skills.tsx b/testing/panel/src/routes/skills.tsx new file mode 100644 index 0000000000..575cded67d --- /dev/null +++ b/testing/panel/src/routes/skills.tsx @@ -0,0 +1,252 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { RefreshCw, RotateCcw, Send } from 'lucide-react' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import type { UIMessage } from '@tanstack/ai-react' +import { MODEL_OPTIONS, getDefaultModelOption } from '@/lib/model-selection' +import type { ModelOption } from '@/lib/model-selection' + +const THREAD_STORAGE_KEY = 'panel-skills-thread' + +interface CatalogSkill { + name: string + description: string +} +interface InspectResponse { + catalog: Array + activated: Array +} + +function getMessageText(parts: UIMessage['parts']): string { + return parts + .filter((part) => part.type === 'text' && 'content' in part && part.content) + .map((part) => (part as { type: 'text'; content: string }).content) + .join('') +} + +function SkillsPage() { + const [selectedModel, setSelectedModel] = useState( + getDefaultModelOption(), + ) + const [threadId, setThreadId] = useState('') + const [inspect, setInspect] = useState(null) + const [input, setInput] = useState('') + + useEffect(() => { + let existing = localStorage.getItem(THREAD_STORAGE_KEY) + if (!existing) { + existing = crypto.randomUUID() + localStorage.setItem(THREAD_STORAGE_KEY, existing) + } + setThreadId(existing) + }, []) + + const body = useMemo( + () => ({ + provider: selectedModel.provider, + model: selectedModel.model, + threadId, + }), + [selectedModel.provider, selectedModel.model, threadId], + ) + + const { messages, sendMessage, isLoading } = useChat({ + connection: fetchServerSentEvents('/api/skills-chat'), + body, + devtools: { name: 'Skills' }, + }) + + const refreshInspect = useCallback(async () => { + if (!threadId) return + try { + const res = await fetch( + `/api/skills-inspect?threadId=${encodeURIComponent(threadId)}`, + ) + if (res.ok) setInspect(await res.json()) + } catch { + // Non-fatal: leave the last snapshot. + } + }, [threadId]) + + // Refresh the catalog on load and each time a turn finishes. + const wasLoading = useRef(false) + useEffect(() => { + if (wasLoading.current && !isLoading) refreshInspect() + wasLoading.current = isLoading + }, [isLoading, refreshInspect]) + useEffect(() => { + refreshInspect() + }, [refreshInspect]) + + const startNewThread = () => { + const next = crypto.randomUUID() + localStorage.setItem(THREAD_STORAGE_KEY, next) + setThreadId(next) + setInspect(null) + } + + const submit = () => { + const text = input.trim() + if (!text || isLoading) return + sendMessage(text) + setInput('') + } + + const catalog = inspect?.catalog ?? [] + const activated = new Set(inspect?.activated ?? []) + + return ( +

+ {/* Left: chat */} +
+
+ + +
+ +
+ {messages.length === 0 ? ( +

+ Try "Explain how a rainbow forms, like a pirate" or "Answer as a + haiku: what is TypeScript?" — watch the model load a skill on the + right before it answers. +

+ ) : ( + messages.map(({ id, role, parts }) => ( +
+
+ {getMessageText(parts)} +
+
+ )) + )} +
+ +
+
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + submit() + } + }} + placeholder="Type a message…" + disabled={isLoading} + className="flex-1 rounded-lg border border-cyan-500/20 bg-gray-800 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-cyan-500/50 disabled:opacity-50" + /> + +
+
+
+ + {/* Right: skill catalog */} +
+
+
+

Skill catalog

+

+ thread: {threadId ? threadId.slice(0, 8) : '…'} +

+
+
+ + +
+
+ +
+

+ These skills are read from the demo skills/ folder and + offered to the model as a catalog. A skill lights up once the model + loads it with load_skill. +

+ {catalog.length === 0 ? ( +

No skills found.

+ ) : ( +
    + {catalog.map((skill) => { + const isActive = activated.has(skill.name) + return ( +
  • +
    + + {skill.name} + + {isActive && ( + + loaded + + )} +
    +

    {skill.description}

    +
  • + ) + })} +
+ )} +
+
+
+ ) +} + +export const Route = createFileRoute('/skills')({ + component: SkillsPage, +}) From c456b7577193e7263844f286c0e7761ca66a6fdb Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Sun, 23 Aug 2026 09:00:43 -0700 Subject: [PATCH 2/8] docs(skills): make ai-skills discoverable and document skills that carry code - Route the new `ai-skills` skill from `ai-core` (sub-skills table + a companion-packages entry) and list `@tanstack/ai-skills` in the getting-started "Skills Shipped" table, so coding agents can find it. - Add a "Skills that come with code" section to the portable-skills guide and the ai-skills SKILL.md: withSkills composes with your own tools, so pass an execution tool (e.g. execute_shell) for skills that reference scripts. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/config.json | 5 +- docs/getting-started/agent-skills.md | 1 + docs/skills/agent-skills.md | 58 ++++++++++++++++++++ packages/ai-skills/skills/ai-skills/SKILL.md | 27 +++++++++ packages/ai/skills/ai-core/SKILL.md | 17 ++++++ 5 files changed, 106 insertions(+), 2 deletions(-) diff --git a/docs/config.json b/docs/config.json index dbd5ae5894..5d23a9f4a1 100644 --- a/docs/config.json +++ b/docs/config.json @@ -61,7 +61,7 @@ "label": "Agent Skills (TanStack Intent)", "to": "getting-started/agent-skills", "addedAt": "2026-04-17", - "updatedAt": "2026-08-22" + "updatedAt": "2026-08-23" } ] }, @@ -135,7 +135,8 @@ { "label": "Portable Agent Skills", "to": "skills/agent-skills", - "addedAt": "2026-08-22" + "addedAt": "2026-08-22", + "updatedAt": "2026-08-23" }, { "label": "Skill Sources", diff --git a/docs/getting-started/agent-skills.md b/docs/getting-started/agent-skills.md index ba4a6c9a52..5a896aad21 100644 --- a/docs/getting-started/agent-skills.md +++ b/docs/getting-started/agent-skills.md @@ -54,6 +54,7 @@ TanStack AI publishes skills inside its packages so the guidance travels with `n | `@tanstack/ai-mcp` | `ai-mcp` | Connecting to MCP servers, running their tools inside `chat()`, resources, prompts, and the type-generating CLI | | `@tanstack/ai-sandbox` | `ai-sandbox` | Running harness adapters inside isolated sandboxes with `defineSandbox` / `withSandbox` | | `@tanstack/ai-code-mode` | `ai-code-mode` | Setting up Code Mode with a sandbox driver and registering server tools | +| `@tanstack/ai-skills` | `ai-skills` | Portable Agent Skills at runtime: the `withSkills` middleware, `load_skill`, the `SkillSource` interface, `inlineSkill` / `skillDirectory` / `staticSkills`, and adding your own tools for skills that carry code | Skills route to each other: `ai-core` points at the companion packages' skills, and `ai-persistence` is an entry point that routes to its own diff --git a/docs/skills/agent-skills.md b/docs/skills/agent-skills.md index 4aefb3c3d2..889ee7a6cc 100644 --- a/docs/skills/agent-skills.md +++ b/docs/skills/agent-skills.md @@ -177,6 +177,64 @@ export async function POST(request: Request) { Without the resource tool, resources are still listed in the `load_skill` result, but the model is told they are not loadable in this setup. +## Skills that come with code + +Some skills ship scripts, or their instructions say "run `python3 extract.py`". +`withSkills` lists those scripts in the `load_skill` result but does not run +them. Running code is your app's job, and you wire it up by passing your own +tool. + +`withSkills` composes with whatever tools you give `chat()`. So add an execution +tool, and write the skill so it tells the model to call that tool. The skill +supplies the "how" (the command); your tool supplies the ability to run it. + +```ts ignore +import { chat, toServerSentEventsResponse, toolDefinition } from '@tanstack/ai' +import { anthropicText } from '@tanstack/ai-anthropic' +import { inlineSkill, withSkills } from '@tanstack/ai-skills' +import { z } from 'zod' + +// Your own execution tool. Run the command wherever you want: a provider +// sandbox, a local isolate, a serverless worker. Guard it in production. +const executeShell = toolDefinition({ + name: 'execute_shell', + description: 'Run a shell command and return its stdout.', + inputSchema: z.object({ command: z.string() }), + outputSchema: z.object({ stdout: z.string() }), +}).server(async ({ command }) => { + const { stdout } = await runInYourSandbox(command) + return { stdout } +}) + +const extractPdf = inlineSkill({ + name: 'pdf-extract', + description: 'Extract text from a PDF with a small Python script.', + instructions: ` +# Extract PDF text +Run this with the execute_shell tool, then return the text it prints: + python3 -c "import sys, pypdf; ..." +`, +}) + +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: anthropicText('claude-sonnet-4-5'), + messages, + tools: [executeShell], + middleware: [withSkills(extractPdf)], + }) + + return toServerSentEventsResponse(stream) +} +``` + +Swap `execute_shell` for any tool: a container runner, a Code Mode sandbox, or +a remote worker. The skill never changes, only the tool behind it. For hosted +skills that run in a provider's own sandbox instead, see +[Provider Skills](../tools/provider-skills). + ## Where to go next - [Skill sources](./skill-sources) — load skills from a folder, a build-time diff --git a/packages/ai-skills/skills/ai-skills/SKILL.md b/packages/ai-skills/skills/ai-skills/SKILL.md index 2e2f8e2d62..a43a17cb55 100644 --- a/packages/ai-skills/skills/ai-skills/SKILL.md +++ b/packages/ai-skills/skills/ai-skills/SKILL.md @@ -85,6 +85,33 @@ To let the model read a skill's bundled files, pass `createResourceTool(source)` in `tools`. `withSkills` detects it and advertises `read_skill_resource`. Paths that escape the skill root are rejected. +## Skills that carry code + +`withSkills` inventories a skill's `scripts/` in the `load_skill` result but does +NOT run them (script execution is a later phase). To let a skill run code, pass +your own execution tool to `chat({ tools })` alongside `withSkills` and write the +skill so it tells the model to call that tool. `withSkills` composes with any +tools you provide. + +```ts ignore +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +const executeShell = toolDefinition({ + name: 'execute_shell', + description: 'Run a shell command and return its stdout.', + inputSchema: z.object({ command: z.string() }), + outputSchema: z.object({ stdout: z.string() }), +}).server(async ({ command }) => ({ stdout: await runSomewhere(command) })) + +// chat({ tools: [executeShell], middleware: [withSkills(source)] }) +``` + +The skill supplies the command; your tool supplies the ability to run it. Swap in +a provider sandbox, a Code Mode isolate, or a remote worker without changing the +skill. For hosted skills that run in the provider's own sandbox, use +`codeExecutionTool` / `shellTool` instead (see provider-skills). + ## Write a custom source Implement `SkillSource` (`list` + `load`, optional `revision`/`listResources`/ diff --git a/packages/ai/skills/ai-core/SKILL.md b/packages/ai/skills/ai-core/SKILL.md index ea8ef3bb8c..41fdcd3328 100644 --- a/packages/ai/skills/ai-core/SKILL.md +++ b/packages/ai/skills/ai-core/SKILL.md @@ -37,6 +37,7 @@ Always import from the framework package on the client — never from | Turn on/off debug logging, pipe into pino/winston | ai-core/debug-logging/SKILL.md | | Persist chats server-side (history, runs) | See `@tanstack/ai-persistence` package skills | | Set up Code Mode (LLM code execution) | See `@tanstack/ai-code-mode` package skills | +| Give the model a catalog of SKILL.md skills | See `@tanstack/ai-skills` package skills | ## Companion packages @@ -84,6 +85,22 @@ framework packages, so read **ai-core/client-persistence** instead. See the `ai-code-mode` skill in that package. +### `@tanstack/ai-skills` — portable Agent Skills at runtime + +Gives the model a library of `SKILL.md` skills it can load on demand, on any +provider, via the `withSkills` middleware and a `load_skill` tool. Skills come +from `inlineSkill`, `skillDirectory`, or a build-time bundle. This is the +runtime feature for the model **inside your app**, not the coding-assistant +skills this file is part of, and not the hosted `codeExecutionTool` / +`shellTool` skills (those run in a provider sandbox). + +```bash +pnpm add @tanstack/ai-skills +npx @tanstack/intent@latest install +``` + +Entry point: `node_modules/@tanstack/ai-skills/skills/ai-skills/SKILL.md` + ## Quick Decision Tree - Setting up a chatbot? → ai-core/chat-experience From 55b532246b2dbcffb54b6f8e4b39645c2e1826b2 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:01:30 +0000 Subject: [PATCH 3/8] ci: apply automated fixes --- packages/ai-skills/skills/ai-skills/SKILL.md | 10 +++--- packages/ai-skills/src/catalog.ts | 17 +++++---- packages/ai-skills/src/combinators.ts | 9 ++--- packages/ai-skills/src/middleware.ts | 24 +++++++------ packages/ai-skills/src/node/index.ts | 17 +++++---- packages/ai-skills/src/tools/load-skill.ts | 13 +++++-- packages/ai-skills/src/tools/read-resource.ts | 7 +++- packages/ai-skills/src/util.ts | 4 +-- packages/ai-skills/tests/combinators.test.ts | 6 +++- packages/ai-skills/tests/conformance.test.ts | 6 +++- .../fixtures/skills/alpha/references/note.md | 2 +- packages/ai-skills/tests/validate.test.ts | 4 +-- packages/ai/skills/ai-core/SKILL.md | 2 +- .../src/routes/api.portable-skills-wire.ts | 35 +++++++++++++++---- .../e2e/tests/portable-skills-wire.spec.ts | 5 ++- 15 files changed, 103 insertions(+), 58 deletions(-) diff --git a/packages/ai-skills/skills/ai-skills/SKILL.md b/packages/ai-skills/skills/ai-skills/SKILL.md index a43a17cb55..bd9064c36e 100644 --- a/packages/ai-skills/skills/ai-skills/SKILL.md +++ b/packages/ai-skills/skills/ai-skills/SKILL.md @@ -29,11 +29,11 @@ the provider's sandbox and are referenced by ID. ## Two skill features, do not confuse them -| Need | Use | -| ------------------------------------------------- | ---------------------------------------- | -| Model loads SKILL.md at runtime, any provider | `withSkills` (this package) | -| Hosted skill runs in a provider sandbox by ID | `codeExecutionTool` / `shellTool` | -| Teach a coding assistant how to use TanStack AI | Ship a `SKILL.md`, install via Intent | +| Need | Use | +| ----------------------------------------------- | ------------------------------------- | +| Model loads SKILL.md at runtime, any provider | `withSkills` (this package) | +| Hosted skill runs in a provider sandbox by ID | `codeExecutionTool` / `shellTool` | +| Teach a coding assistant how to use TanStack AI | Ship a `SKILL.md`, install via Intent | The portable and hosted paths do not mix in one `chat()` call: `withSkills` throws if a `code_execution`/`shell` tool in the same call carries skills. diff --git a/packages/ai-skills/src/catalog.ts b/packages/ai-skills/src/catalog.ts index 4ab5b01fab..c330ca6bd6 100644 --- a/packages/ai-skills/src/catalog.ts +++ b/packages/ai-skills/src/catalog.ts @@ -7,17 +7,14 @@ import type { ModelFamily, SkillMetadata } from './types' /** Sort skills into a stable, cache-friendly order. */ -export function sortSkills( - skills: Array, -): Array { - return [...skills].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) +export function sortSkills(skills: Array): Array { + return [...skills].sort((a, b) => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0, + ) } function escapeXml(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') + return s.replace(/&/g, '&').replace(//g, '>') } /** Render the skill catalog for a model family. Skills are sorted by name. */ @@ -35,6 +32,8 @@ export function renderCatalog( .join('\n') return `\n${entries}\n` } - const entries = sorted.map((s) => `- **${s.name}**: ${s.description}`).join('\n') + const entries = sorted + .map((s) => `- **${s.name}**: ${s.description}`) + .join('\n') return `## Available skills\n\n${entries}` } diff --git a/packages/ai-skills/src/combinators.ts b/packages/ai-skills/src/combinators.ts index af586e70e1..00d84120a0 100644 --- a/packages/ai-skills/src/combinators.ts +++ b/packages/ai-skills/src/combinators.ts @@ -4,11 +4,7 @@ * collisions, `filter` hides, `cache` memoizes. */ import { stableHash } from './util' -import type { - SkillMetadata, - SkillScriptRef, - SkillSource, -} from './types' +import type { SkillMetadata, SkillScriptRef, SkillSource } from './types' export type FilterContext = Record export type FilterPredicate = ( @@ -140,8 +136,7 @@ export function cache( const now = () => (opts.refreshInterval ? Date.now() : 0) const fresh = () => - opts.refreshInterval === undefined || - now() - listAt < opts.refreshInterval + opts.refreshInterval === undefined || now() - listAt < opts.refreshInterval return { ...source, diff --git a/packages/ai-skills/src/middleware.ts b/packages/ai-skills/src/middleware.ts index d66e2e83a5..029e6b5ec7 100644 --- a/packages/ai-skills/src/middleware.ts +++ b/packages/ai-skills/src/middleware.ts @@ -6,10 +6,7 @@ * S3-backed source would hit the network per loop turn and catalog reordering * would break Anthropic's cache prefix mid-run. */ -import { - createCapability, - defineChatMiddleware, -} from '@tanstack/ai' +import { createCapability, defineChatMiddleware } from '@tanstack/ai' import { combineSources } from './combinators' import { renderCatalog } from './catalog' import { modelFamilyOf } from './types' @@ -77,10 +74,7 @@ function findNativeSkillTool(tools: Array): string | undefined { if (tool.name === 'code_execution' && (meta?.skills?.length ?? 0) > 0) { return 'code_execution' } - if ( - tool.name === 'shell' && - (meta?.environment?.skills?.length ?? 0) > 0 - ) { + if (tool.name === 'shell' && (meta?.environment?.skills?.length ?? 0) > 0) { return 'shell' } } @@ -104,7 +98,11 @@ function activationInstructions( export function withSkills( sources: SkillSource | Array, options: SkillsOptions = {}, -): DefinedChatMiddleware { +): DefinedChatMiddleware< + unknown, + readonly [], + readonly [typeof SkillsCapability] +> { if (options.instructionTemplate && options.renderCatalog) { throw new Error( '`instructionTemplate` and `renderCatalog` are mutually exclusive', @@ -114,7 +112,9 @@ export function withSkills( options.instructionTemplate && !options.instructionTemplate.includes('{skills}') ) { - throw new Error('`instructionTemplate` must contain a `{skills}` placeholder') + throw new Error( + '`instructionTemplate` must contain a `{skills}` placeholder', + ) } return defineChatMiddleware({ @@ -195,7 +195,9 @@ export function withSkills( const promptPresent = !prompt || config.systemPrompts.some((p) => - typeof p === 'string' ? p === prompt.content : p.content === prompt.content, + typeof p === 'string' + ? p === prompt.content + : p.content === prompt.content, ) const existingNames = new Set(config.tools.map((t) => t.name)) const toolsToAdd = rt.memo.tools.filter((t) => !existingNames.has(t.name)) diff --git a/packages/ai-skills/src/node/index.ts b/packages/ai-skills/src/node/index.ts index 698f2c030f..836941175d 100644 --- a/packages/ai-skills/src/node/index.ts +++ b/packages/ai-skills/src/node/index.ts @@ -67,7 +67,8 @@ export function skillDirectory( const dirOf = async (name: string): Promise => { const dir = (await scan()).get(name) - if (!dir) throw new Error(`no skill named "${name}" under ${roots.join(', ')}`) + if (!dir) + throw new Error(`no skill named "${name}" under ${roots.join(', ')}`) return dir } @@ -90,9 +91,7 @@ export function skillDirectory( ) if (raw === undefined) continue try { - out.push( - parseSkill(raw, { dirName: basename(dir), strict }).metadata, - ) + out.push(parseSkill(raw, { dirName: basename(dir), strict }).metadata) } catch { // Lenient: an unparseable skill is skipped, not fatal (spec §7). // strict mode still throws inside parseSkill for warnings, but a @@ -119,7 +118,11 @@ export function skillDirectory( const dir = await dirOf(name) const files = await collectFiles(join(dir, SCRIPT_DIR), dir) return files.map( - (p): SkillScriptRef => ({ path: p, executable: false, reason: 'no-runtime' }), + (p): SkillScriptRef => ({ + path: p, + executable: false, + reason: 'no-runtime', + }), ) }, readScript: async (name, path) => { @@ -161,7 +164,9 @@ export async function generateCatalog( const resources: Record = {} for (const sub of RESOURCE_DIRS) { for (const rel of await collectFiles(join(dir, sub), dir)) { - resources[rel] = await readFile(join(dir, rel), 'utf8').catch(() => '') + resources[rel] = await readFile(join(dir, rel), 'utf8').catch( + () => '', + ) } } skills.push({ diff --git a/packages/ai-skills/src/tools/load-skill.ts b/packages/ai-skills/src/tools/load-skill.ts index 8d885f2e75..7e2e730b8d 100644 --- a/packages/ai-skills/src/tools/load-skill.ts +++ b/packages/ai-skills/src/tools/load-skill.ts @@ -44,9 +44,18 @@ export function createLoadSkillTool(deps: LoadSkillDeps): Tool { const nameEnum = z.enum(names as [string, ...Array]) const byName = new Map(deps.skills.map((s) => [s.name, s])) - const handler = async ({ name }: { name: string }): Promise => { + const handler = async ({ + name, + }: { + name: string + }): Promise => { if (deps.activated.has(name)) { - return { skill: name, content: ALREADY_LOADED, resources: [], scripts: [] } + return { + skill: name, + content: ALREADY_LOADED, + resources: [], + scripts: [], + } } const raw = await deps.source.load(name) const resources = (await deps.source.listResources?.(name)) ?? [] diff --git a/packages/ai-skills/src/tools/read-resource.ts b/packages/ai-skills/src/tools/read-resource.ts index 2cb0b55487..c1091e393a 100644 --- a/packages/ai-skills/src/tools/read-resource.ts +++ b/packages/ai-skills/src/tools/read-resource.ts @@ -44,6 +44,11 @@ export function createResourceTool(source: SkillSource): Tool { if (typeof value === 'string') { return { skill, path, content: value, encoding: 'utf8' as const } } - return { skill, path, content: toBase64(value), encoding: 'base64' as const } + return { + skill, + path, + content: toBase64(value), + encoding: 'base64' as const, + } }) } diff --git a/packages/ai-skills/src/util.ts b/packages/ai-skills/src/util.ts index bf49d84911..2498efac38 100644 --- a/packages/ai-skills/src/util.ts +++ b/packages/ai-skills/src/util.ts @@ -9,9 +9,7 @@ export function assertSafeResourcePath(path: string): void { const bad = normalized.startsWith('/') || /^[a-zA-Z]:/.test(normalized) || - normalized - .split('/') - .some((seg) => seg === '..' || seg === '~') + normalized.split('/').some((seg) => seg === '..' || seg === '~') if (bad) { throw new Error(`unsafe resource path: "${path}"`) } diff --git a/packages/ai-skills/tests/combinators.test.ts b/packages/ai-skills/tests/combinators.test.ts index 453e4d0cee..59e188c46b 100644 --- a/packages/ai-skills/tests/combinators.test.ts +++ b/packages/ai-skills/tests/combinators.test.ts @@ -3,7 +3,11 @@ import { aggregate, cache, dedupe, filter } from '../src/combinators' import { inlineSkill } from '../src/sources/inline' import type { SkillMetadata, SkillSource } from '../src/types' -const alpha = inlineSkill({ name: 'alpha', description: 'a', instructions: 'A' }) +const alpha = inlineSkill({ + name: 'alpha', + description: 'a', + instructions: 'A', +}) const beta = inlineSkill({ name: 'beta', description: 'b', instructions: 'B' }) const alpha2 = inlineSkill({ name: 'alpha', diff --git a/packages/ai-skills/tests/conformance.test.ts b/packages/ai-skills/tests/conformance.test.ts index 08df64e8b8..6c8e9dd56e 100644 --- a/packages/ai-skills/tests/conformance.test.ts +++ b/packages/ai-skills/tests/conformance.test.ts @@ -18,7 +18,11 @@ runSkillSourceConformance( instructions: 'Do A.', resources: { 'references/note.md': 'hello' }, }), - inlineSkill({ name: 'beta', description: 'does B', instructions: 'Do B.' }), + inlineSkill({ + name: 'beta', + description: 'does B', + instructions: 'Do B.', + }), ]), 'inlineSkill', ) diff --git a/packages/ai-skills/tests/fixtures/skills/alpha/references/note.md b/packages/ai-skills/tests/fixtures/skills/alpha/references/note.md index b6fc4c620b..ce01362503 100644 --- a/packages/ai-skills/tests/fixtures/skills/alpha/references/note.md +++ b/packages/ai-skills/tests/fixtures/skills/alpha/references/note.md @@ -1 +1 @@ -hello \ No newline at end of file +hello diff --git a/packages/ai-skills/tests/validate.test.ts b/packages/ai-skills/tests/validate.test.ts index dba6054e96..452a78d432 100644 --- a/packages/ai-skills/tests/validate.test.ts +++ b/packages/ai-skills/tests/validate.test.ts @@ -3,9 +3,7 @@ import { validateSkill } from '../src/validate' describe('validateSkill', () => { it('passes a clean portable skill by default', () => { - expect( - validateSkill({ name: 'my-skill', description: 'ok' }).ok, - ).toBe(true) + expect(validateSkill({ name: 'my-skill', description: 'ok' }).ok).toBe(true) }) it('flags invalid portable names', () => { diff --git a/packages/ai/skills/ai-core/SKILL.md b/packages/ai/skills/ai-core/SKILL.md index 41fdcd3328..545d082e9c 100644 --- a/packages/ai/skills/ai-core/SKILL.md +++ b/packages/ai/skills/ai-core/SKILL.md @@ -37,7 +37,7 @@ Always import from the framework package on the client — never from | Turn on/off debug logging, pipe into pino/winston | ai-core/debug-logging/SKILL.md | | Persist chats server-side (history, runs) | See `@tanstack/ai-persistence` package skills | | Set up Code Mode (LLM code execution) | See `@tanstack/ai-code-mode` package skills | -| Give the model a catalog of SKILL.md skills | See `@tanstack/ai-skills` package skills | +| Give the model a catalog of SKILL.md skills | See `@tanstack/ai-skills` package skills | ## Companion packages diff --git a/testing/e2e/src/routes/api.portable-skills-wire.ts b/testing/e2e/src/routes/api.portable-skills-wire.ts index df77358316..81e2fd1eb9 100644 --- a/testing/e2e/src/routes/api.portable-skills-wire.ts +++ b/testing/e2e/src/routes/api.portable-skills-wire.ts @@ -3,7 +3,11 @@ import { chat, createChatOptions } from '@tanstack/ai' import { createAnthropicChat } from '@tanstack/ai-anthropic' import { codeExecutionTool } from '@tanstack/ai-anthropic/tools' import { createOpenaiChat } from '@tanstack/ai-openai' -import { createResourceTool, inlineSkill, withSkills } from '@tanstack/ai-skills' +import { + createResourceTool, + inlineSkill, + withSkills, +} from '@tanstack/ai-skills' const DUMMY_KEY = 'sk-e2e-test-dummy-key' @@ -44,8 +48,16 @@ function makeAnthropicStream(): ReadableStream { usage: { input_tokens: 5, output_tokens: 0 }, }, }, - { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, - { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'ok' } }, + { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'ok' }, + }, { type: 'content_block_stop', index: 0 }, { type: 'message_delta', @@ -58,7 +70,9 @@ function makeAnthropicStream(): ReadableStream { start(controller) { for (const event of events) { controller.enqueue( - encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`), + encoder.encode( + `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`, + ), ) } controller.close() @@ -129,7 +143,8 @@ export const Route = createFileRoute('/api/portable-skills-wire')({ } | null = null const capturingFetch: typeof fetch = async (input, init) => { - const req = input instanceof Request ? input : new Request(input, init) + const req = + input instanceof Request ? input : new Request(input, init) const headers: Record = {} req.headers.forEach((value, key) => { headers[key] = value @@ -165,7 +180,15 @@ export const Route = createFileRoute('/api/portable-skills-wire')({ ? [ codeExecutionTool( { type: 'code_execution_20250825', name: 'code_execution' }, - { skills: [{ type: 'anthropic', skill_id: 'pptx', version: 'latest' }] }, + { + skills: [ + { + type: 'anthropic', + skill_id: 'pptx', + version: 'latest', + }, + ], + }, ), ] : [createResourceTool(skillSource)] diff --git a/testing/e2e/tests/portable-skills-wire.spec.ts b/testing/e2e/tests/portable-skills-wire.spec.ts index a9c95cb890..a87d5a0275 100644 --- a/testing/e2e/tests/portable-skills-wire.spec.ts +++ b/testing/e2e/tests/portable-skills-wire.spec.ts @@ -60,7 +60,10 @@ test.describe('portable skills — withSkills wire format', () => { test('refuses to combine portable withSkills with hosted native skills', async ({ request, }) => { - const { ok, error } = await post(request, '?provider=anthropic&mode=coexist') + const { ok, error } = await post( + request, + '?provider=anthropic&mode=coexist', + ) expect(ok).toBe(false) expect(error).toContain('code_execution') expect(error).toMatch(/portable|withSkills/i) From 57f268bb1674d05813f01fdf41c46bc9a8c123f4 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Mon, 24 Aug 2026 18:59:23 -0700 Subject: [PATCH 4/8] fix(ai-skills): tolerate trailing newline in conformance resource check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formatters (autofix-ci) add a trailing newline to the fixture references/note.md, so a file-backed SkillSource reads 'hello\n' while the inline source returns 'hello'. The shared conformance assertion compared byte-exact and failed only for skillDirectory on CI. trimEnd the read value — the payload is what the contract cares about. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ai-skills/src/testing/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ai-skills/src/testing/index.ts b/packages/ai-skills/src/testing/index.ts index 916ca90027..9d23209462 100644 --- a/packages/ai-skills/src/testing/index.ts +++ b/packages/ai-skills/src/testing/index.ts @@ -73,7 +73,8 @@ export function runSkillSourceConformance( const resources = await listResources('alpha') expect(resources).toContain('references/note.md') const value = await readResource('alpha', 'references/note.md') - expect(dec(value)).toBe('hello') + // trimEnd: a file-backed source keeps the fixture's trailing newline (formatters add one); the payload is what matters. + expect(dec(value).trimEnd()).toBe('hello') // Path traversal must be rejected — by the source or the shared guard. await expect( (async () => { From 146b621bd13869ed5789788e91879a450341b1ae Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 27 Aug 2026 14:24:48 +0200 Subject: [PATCH 5/8] fix(ai-skills): load by parsed name, jail resources, show catalog in DevTools --- .changeset/portable-agent-skills.md | 5 + docs/skills/writing-adapters.md | 5 +- packages/ai-client/src/chat-client.ts | 3 + packages/ai-client/src/devtools-noop.ts | 1 + packages/ai-client/src/devtools.ts | 29 +++ packages/ai-client/tests/devtools.test.ts | 62 ++++++ .../src/components/hooks/HookDetails.tsx | 22 ++- .../src/components/hooks/SkillsPanel.tsx | 125 +++++++++++++ .../ai-devtools/src/components/hooks/index.ts | 1 + packages/ai-devtools/src/store/ai-context.tsx | 27 +++ .../ai-devtools/src/store/skills-registry.ts | 49 +++++ .../ai-devtools/tests/skills-registry.test.ts | 29 +++ packages/ai-event-client/src/index.ts | 7 + packages/ai-skills/src/catalog.ts | 7 +- packages/ai-skills/src/combinators.ts | 47 ++++- packages/ai-skills/src/index.ts | 4 +- packages/ai-skills/src/middleware.ts | 55 +++++- packages/ai-skills/src/node/index.ts | 83 ++++++++- packages/ai-skills/src/sources/inline.ts | 7 + packages/ai-skills/src/testing/index.ts | 7 +- packages/ai-skills/src/tools/read-resource.ts | 12 ++ packages/ai-skills/src/types.ts | 8 +- packages/ai-skills/src/walk.ts | 3 +- packages/ai-skills/tests/catalog.test.ts | 8 + packages/ai-skills/tests/combinators.test.ts | 23 +++ packages/ai-skills/tests/middleware.test.ts | 176 ++++++++++++++++++ .../ai-skills/tests/skill-directory.test.ts | 40 ++++ packages/ai-skills/tests/walk.test.ts | 13 ++ packages/ai/src/utilities/errors.ts | 4 +- 29 files changed, 825 insertions(+), 37 deletions(-) create mode 100644 packages/ai-devtools/src/components/hooks/SkillsPanel.tsx create mode 100644 packages/ai-devtools/src/store/skills-registry.ts create mode 100644 packages/ai-devtools/tests/skills-registry.test.ts create mode 100644 packages/ai-skills/tests/middleware.test.ts create mode 100644 packages/ai-skills/tests/skill-directory.test.ts diff --git a/.changeset/portable-agent-skills.md b/.changeset/portable-agent-skills.md index e3737a9071..18ac204122 100644 --- a/.changeset/portable-agent-skills.md +++ b/.changeset/portable-agent-skills.md @@ -4,6 +4,9 @@ '@tanstack/ai-anthropic': patch '@tanstack/openai-base': patch '@tanstack/ai-sandbox': patch +'@tanstack/ai-client': patch +'@tanstack/ai-event-client': patch +'@tanstack/ai-devtools-core': patch --- Add `@tanstack/ai-skills`: portable Agent Skills (`SKILL.md`) as a first-class `chat()` middleware. @@ -11,3 +14,5 @@ Add `@tanstack/ai-skills`: portable Agent Skills (`SKILL.md`) as a first-class ` `withSkills(sources, options?)` renders a skill catalog and a `load_skill` tool so any tool-calling model can load skills on demand, on any provider, with no server sandbox. Skills come from `inlineSkill`, `skillDirectory` (`/node`), or a build-time `staticSkills` bundle, and compose via `aggregate`/`dedupe`/`filter`/`cache`. `createResourceTool` exposes a skill's bundled files through `read_skill_resource`, and `runSkillSourceConformance` (`/testing`) validates custom `SkillSource` adapters. The catalog renders as `` XML for Anthropic models and markdown for others; portable and hosted (native) skills refuse to combine in one call. Core `@tanstack/ai` now exports `SkillLimitError`. The native factories throw it (or add validation): `codeExecutionTool` (`@tanstack/ai-anthropic`) frames its 8-skill cap, and `shellTool` (`@tanstack/openai-base`) now validates `skill_id` format instead of nothing. `@tanstack/ai-sandbox` reuses the shared skill-directory walk from `@tanstack/ai-skills`. + +`withSkills` sends a `skills:state` CUSTOM chunk so TanStack AI DevTools can show the catalog and which skills the model loaded. diff --git a/docs/skills/writing-adapters.md b/docs/skills/writing-adapters.md index d932410649..f055ff089e 100644 --- a/docs/skills/writing-adapters.md +++ b/docs/skills/writing-adapters.md @@ -49,8 +49,9 @@ frontmatter from what `load` returns, so return the raw `SKILL.md`. Two optional methods make the source better: - `revision()` returns a stable string that changes only when content changes. - `withSkills` uses it to cache the catalog and keep prompt caching stable, so - add it whenever you can compute one cheaply (a bucket ETag, a content hash). + `withSkills` lists once per `chat()` call and does not cache on `revision()`. + Add it so combinators, your own cache, or a later catalog memo can key on it + (a bucket ETag, a content hash). - `listResources` and `readResource` expose a skill's bundled files so `read_skill_resource` can read them. diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index ac7afe8194..305fdc375a 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -820,6 +820,9 @@ export class ChatClient< if (eventType === 'memory:state') { this.devtoolsBridge.recordMemoryState(data) } + if (eventType === 'skills:state') { + this.devtoolsBridge.recordSkillsState(data) + } this.callbacksRef.current.onCustomEvent(eventType, data, context) }, }, diff --git a/packages/ai-client/src/devtools-noop.ts b/packages/ai-client/src/devtools-noop.ts index 6a1f1ce00b..6ea536da08 100644 --- a/packages/ai-client/src/devtools-noop.ts +++ b/packages/ai-client/src/devtools-noop.ts @@ -87,6 +87,7 @@ export class NoOpChatDevtoolsBridge { } observeChunk(_chunk: StreamChunk): void {} recordMemoryState(_value: unknown): void {} + recordSkillsState(_value: unknown): void {} beginRun(_runId: string, _threadId: string): void {} getCurrentRunEventContext(): ChatClientRunEventContext | undefined { return undefined diff --git a/packages/ai-client/src/devtools.ts b/packages/ai-client/src/devtools.ts index a5bee8a6a8..4851890d67 100644 --- a/packages/ai-client/src/devtools.ts +++ b/packages/ai-client/src/devtools.ts @@ -33,6 +33,11 @@ export interface AIDevtoolsDisplayOptions { * payload of the `memory:state` CUSTOM chunk. Kept local so `ai-client` doesn't * depend on `ai-memory`; the memory middleware is the producer. */ +interface SkillsStateEventValue { + catalog?: Array<{ name: string; description: string }> + activated?: Array +} + interface MemoryStateEventValue { scope: MemoryScopeLite adapter: string @@ -712,6 +717,7 @@ export class ClientDevtoolsBridge { | 'memory:retrieve:started' | 'memory:retrieve:completed' | 'memory:snapshot' + | 'skills:snapshot' | AIDevtoolsRunEventType, visibility: AIDevtoolsEventVisibility = 'client-state', context: { runId?: string } = {}, @@ -770,6 +776,8 @@ export class ChatDevtoolsBridge extends ClientDevtoolsBridge { client.dispose() }) + it('re-emits skills:snapshot from a transported skills:state CUSTOM chunk', async () => { + const runContexts: Array = [] + const chunks: Array = [ + runStartedChunk({ threadId: 'thread-1', runId: 'run-skills' }), + { + type: EventType.CUSTOM, + metadata: { tanstack: { model: 'test' } }, + timestamp: Date.now(), + name: 'skills:state', + value: { + catalog: [{ name: 'pirate-speak', description: 'talk like a pirate' }], + activated: [], + }, + }, + textContentChunk({ + messageId: 'msg-skills', + delta: 'Ahoy', + content: 'Ahoy', + }), + runFinishedChunk({ threadId: 'thread-1', runId: 'run-skills' }), + ] + const client = createClient({ + connection: createRunTrackingAdapter([chunks], runContexts), + }) + vi.clearAllMocks() + + await client.sendMessage('talk like a pirate') + await waitForCondition( + () => eventClientMock.emitted('skills:snapshot').length > 0, + ) + + expect(eventClientMock.emitted('skills:snapshot')).toEqual([ + [ + 'skills:snapshot', + expect.objectContaining({ + catalog: [ + { name: 'pirate-speak', description: 'talk like a pirate' }, + ], + activated: [], + }), + ], + ]) + + vi.clearAllMocks() + eventClientMock.dispatch('devtools:request-state', {}) + await waitForCondition( + () => eventClientMock.emitted('skills:snapshot').length > 0, + ) + expect(eventClientMock.emitted('skills:snapshot')).toEqual([ + [ + 'skills:snapshot', + expect.objectContaining({ + catalog: [ + { name: 'pirate-speak', description: 'talk like a pirate' }, + ], + }), + ], + ]) + + client.dispose() + }) + it('batches structured output update events while preserving final state', async () => { const runContexts: Array = [] const finalObject = { title: 'Pasta', servings: 2 } diff --git a/packages/ai-devtools/src/components/hooks/HookDetails.tsx b/packages/ai-devtools/src/components/hooks/HookDetails.tsx index 8ae7860e04..520c0189fc 100644 --- a/packages/ai-devtools/src/components/hooks/HookDetails.tsx +++ b/packages/ai-devtools/src/components/hooks/HookDetails.tsx @@ -39,6 +39,7 @@ import { } from './preview-messages' import { GenerationPanel, GenerationPreview } from './GenerationPanel' import { MemoryPanel } from './MemoryPanel' +import { SkillsPanel } from './SkillsPanel' import type { HoverOrigin, HoverTarget, PreviewJsonItem } from './preview-model' import type { HookRecord, @@ -49,7 +50,7 @@ import type { import type { Conversation, Message, ToolCall } from '../../store/ai-store' import type { Component, Setter } from 'solid-js' -type DetailTab = 'conversation' | 'tools' | 'state' | 'memory' +type DetailTab = 'conversation' | 'tools' | 'state' | 'memory' | 'skills' type MessagePart = NonNullable[number] const scrollAnimations = new WeakMap() @@ -151,7 +152,9 @@ export const HookDetails: Component = () => { // while one of them is selected, fall back to the conversation view. if ( isGenerationHook() && - (activeTab() === 'tools' || activeTab() === 'memory') + (activeTab() === 'tools' || + activeTab() === 'memory' || + activeTab() === 'skills') ) { setActiveTab('conversation') } @@ -175,7 +178,11 @@ export const HookDetails: Component = () => { // Tools tab owns its own form/saved-fixtures layout that fills the // primary pane; the secondary "User View" preview squeezes the tool // detail column to zero width on narrower hookDetails widths. - if (activeTab() === 'tools' && !isGenerationHook()) return false + if ( + (activeTab() === 'tools' || activeTab() === 'skills') && + !isGenerationHook() + ) + return false return isGenerationHook() || !hasStructuredOutputPreview(previewMessages()) }) @@ -255,6 +262,12 @@ export const HookDetails: Component = () => { activeTab={activeTab()} onSelect={setActiveTab} /> + @@ -302,6 +315,9 @@ export const HookDetails: Component = () => { + + + diff --git a/packages/ai-devtools/src/components/hooks/SkillsPanel.tsx b/packages/ai-devtools/src/components/hooks/SkillsPanel.tsx new file mode 100644 index 0000000000..dcbc2ef998 --- /dev/null +++ b/packages/ai-devtools/src/components/hooks/SkillsPanel.tsx @@ -0,0 +1,125 @@ +import { For, Show, createMemo } from 'solid-js' +import { useAIStore } from '../../store/ai-context' +import { useStyles } from '../../styles/use-styles' +import type { SkillsSnapshot } from '../../store/skills-registry' +import type { Message } from '../../store/ai-store' +import type { Component } from 'solid-js' + +function parseSkillName(argumentsJson: string | undefined): string | undefined { + if (!argumentsJson) return undefined + try { + const parsed: unknown = JSON.parse(argumentsJson) + if ( + parsed && + typeof parsed === 'object' && + 'name' in parsed && + typeof parsed.name === 'string' + ) { + return parsed.name + } + } catch { + return undefined + } + return undefined +} + +function loadedFromMessages(messages: Array): Array { + const names = new Set() + for (const message of messages) { + for (const toolCall of message.toolCalls ?? []) { + if (toolCall.name === 'load_skill') { + const name = parseSkillName(toolCall.arguments) + if (name) names.add(name) + } + } + for (const part of message.parts ?? []) { + if (part.type === 'tool-call' && part.toolName === 'load_skill') { + const name = parseSkillName(part.arguments) + if (name) names.add(name) + } + } + } + return [...names] +} + +export const SkillsPanel: Component = () => { + const { state } = useAIStore() + const styles = useStyles() + + const snapshot = createMemo((): SkillsSnapshot | undefined => { + const hookId = state.hooks.activeHookId + if (hookId && state.skills.snapshots[hookId]) { + return state.skills.snapshots[hookId] + } + const first = Object.values(state.skills.snapshots)[0] + return first + }) + + const conversation = createMemo(() => { + const hookId = state.hooks.activeHookId + if (!hookId) return undefined + return state.conversations[hookId] + }) + + const loaded = createMemo(() => { + const fromSnap = new Set(snapshot()?.activated ?? []) + for (const name of loadedFromMessages(conversation()?.messages ?? [])) { + fromSnap.add(name) + } + return fromSnap + }) + + const catalog = createMemo(() => snapshot()?.catalog ?? []) + + return ( +
+ 0 || loaded().size > 0} + fallback={ +
+ No skills on this run. Add `withSkills(...)` to `chat()` and the + catalog will appear here. A skill lights up when the model calls + `load_skill`. +
+ } + > +
+
Catalog
+ 0} + fallback={ +
+ Loaded via `load_skill`, but the catalog snapshot has not + arrived yet. +
+ } + > +
    + + {(skill) => { + const isLoaded = () => loaded().has(skill.name) + return ( +
  • + + {skill.name} + + + loaded + + + {skill.description} + +
  • + ) + }} +
    +
+
+
+
+
+ ) +} diff --git a/packages/ai-devtools/src/components/hooks/index.ts b/packages/ai-devtools/src/components/hooks/index.ts index 73a7fafb70..3954b04401 100644 --- a/packages/ai-devtools/src/components/hooks/index.ts +++ b/packages/ai-devtools/src/components/hooks/index.ts @@ -2,4 +2,5 @@ export { HookDashboard } from './HookDashboard' export { HookDetails } from './HookDetails' export { GenerationPanel, GenerationPreview } from './GenerationPanel' export { MemoryPanel } from './MemoryPanel' +export { SkillsPanel } from './SkillsPanel' export { ToolFixtureForm } from './ToolFixtureForm' diff --git a/packages/ai-devtools/src/store/ai-context.tsx b/packages/ai-devtools/src/store/ai-context.tsx index 3a95463587..2dd76b86a9 100644 --- a/packages/ai-devtools/src/store/ai-context.tsx +++ b/packages/ai-devtools/src/store/ai-context.tsx @@ -20,6 +20,11 @@ import { clearMemoryRegistry, createMemoryRegistryState, } from './memory-registry' +import { + applySkillsSnapshot, + clearSkillsRegistry, + createSkillsRegistryState, +} from './skills-registry' import type { ContentPartSource, TokenUsage } from '@tanstack/ai' import type { DevtoolsToolFixtureApplyEvent, @@ -27,6 +32,7 @@ import type { } from '@tanstack/ai-event-client' import type { HookRegistryState, ToolFixtureRecord } from './hook-registry' import type { MemoryRegistryState } from './memory-registry' +import type { SkillsRegistryState } from './skills-registry' import type { ParentComponent } from 'solid-js' interface MessagePart { @@ -235,6 +241,7 @@ interface AIStoreState { activeConversationId: string | null hooks: HookRegistryState memory: MemoryRegistryState + skills: SkillsRegistryState } interface AIContextValue { @@ -243,6 +250,7 @@ interface AIContextValue { selectConversation: (id: string) => void clearHooks: () => void clearMemory: () => void + clearSkills: () => void selectHook: (id: string | null) => void saveToolFixture: (fixture: ToolFixtureRecord) => void deleteToolFixture: (fixtureId: string) => void @@ -265,6 +273,7 @@ export const AIProvider: ParentComponent = (props) => { activeConversationId: null, hooks: createHookRegistryState(), memory: createMemoryRegistryState(), + skills: createSkillsRegistryState(), }) const streamToConversation = new Map() @@ -660,6 +669,15 @@ export const AIProvider: ParentComponent = (props) => { ) } + function clearSkills() { + setState( + 'skills', + produce((skills: SkillsRegistryState) => { + clearSkillsRegistry(skills) + }), + ) + } + function selectHook(id: string | null) { setState( 'hooks', @@ -1247,6 +1265,14 @@ export const AIProvider: ParentComponent = (props) => { }), ) }), + aiEventClient.on('skills:snapshot', (e) => { + setState( + 'skills', + produce((skills: SkillsRegistryState) => { + applySkillsSnapshot(skills, e.payload) + }), + ) + }), ) const recordRunEvent = ( @@ -3464,6 +3490,7 @@ export const AIProvider: ParentComponent = (props) => { selectConversation, clearHooks, clearMemory, + clearSkills, selectHook, saveToolFixture, deleteToolFixture, diff --git a/packages/ai-devtools/src/store/skills-registry.ts b/packages/ai-devtools/src/store/skills-registry.ts new file mode 100644 index 0000000000..51bfed8ed2 --- /dev/null +++ b/packages/ai-devtools/src/store/skills-registry.ts @@ -0,0 +1,49 @@ +/** + * DevTools accumulator for the `skills:snapshot` event. Pure reducers so the + * mapping is unit-testable without a Solid store. + * + * Keyed by hookId (the chat client that received the CUSTOM chunk). The Skills + * tab resolves the active hook and reads that snapshot. + */ + +export interface SkillCatalogEntry { + name: string + description: string +} + +export interface SkillsSnapshot { + catalog: Array + activated: Array + updatedAt: number +} + +export interface SkillsRegistryState { + snapshots: Record +} + +export function createSkillsRegistryState(): SkillsRegistryState { + return { snapshots: {} } +} + +export function clearSkillsRegistry(state: SkillsRegistryState): void { + state.snapshots = {} +} + +export function applySkillsSnapshot( + state: SkillsRegistryState, + payload: { + hookId?: string + catalog?: Array + activated?: Array + timestamp?: number + }, +): void { + const key = payload.hookId && payload.hookId.length > 0 ? payload.hookId : '_default' + const catalog = Array.isArray(payload.catalog) ? payload.catalog : [] + const activated = Array.isArray(payload.activated) ? payload.activated : [] + state.snapshots[key] = { + catalog, + activated, + updatedAt: payload.timestamp ?? Date.now(), + } +} diff --git a/packages/ai-devtools/tests/skills-registry.test.ts b/packages/ai-devtools/tests/skills-registry.test.ts new file mode 100644 index 0000000000..80ff53678a --- /dev/null +++ b/packages/ai-devtools/tests/skills-registry.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { + applySkillsSnapshot, + createSkillsRegistryState, +} from '../src/store/skills-registry' + +describe('skills-registry', () => { + it('stores a catalog snapshot by hookId', () => { + const state = createSkillsRegistryState() + applySkillsSnapshot(state, { + hookId: 'hook-1', + catalog: [{ name: 'pirate-speak', description: 'talk like a pirate' }], + activated: [], + timestamp: 1, + }) + expect(state.snapshots['hook-1']?.catalog).toEqual([ + { name: 'pirate-speak', description: 'talk like a pirate' }, + ]) + }) + + it('buckets a missing hookId under _default', () => { + const state = createSkillsRegistryState() + applySkillsSnapshot(state, { + catalog: [{ name: 'haiku', description: 'short poems' }], + activated: ['haiku'], + }) + expect(state.snapshots['_default']?.activated).toEqual(['haiku']) + }) +}) diff --git a/packages/ai-event-client/src/index.ts b/packages/ai-event-client/src/index.ts index 37546bbcfe..96a10f1086 100644 --- a/packages/ai-event-client/src/index.ts +++ b/packages/ai-event-client/src/index.ts @@ -1080,6 +1080,12 @@ export interface MemorySnapshotEvent extends BaseEventContext { facts: Array } +/** Emitted when portable `withSkills` sends its catalog over the chat stream. */ +export interface SkillsSnapshotEvent extends BaseEventContext { + catalog: Array<{ name: string; description: string }> + activated: Array +} + // =========================== // Client Events // =========================== @@ -1329,6 +1335,7 @@ export interface AIDevtoolsEventMap { 'memory:persist:completed': MemoryPersistCompletedEvent 'memory:error': MemoryErrorEvent 'memory:snapshot': MemorySnapshotEvent + 'skills:snapshot': SkillsSnapshotEvent } class AiEventClient extends EventClient { diff --git a/packages/ai-skills/src/catalog.ts b/packages/ai-skills/src/catalog.ts index c330ca6bd6..487abffb2a 100644 --- a/packages/ai-skills/src/catalog.ts +++ b/packages/ai-skills/src/catalog.ts @@ -14,7 +14,12 @@ export function sortSkills(skills: Array): Array { } function escapeXml(s: string): string { - return s.replace(/&/g, '&').replace(//g, '>') + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') } /** Render the skill catalog for a model family. Skills are sorted by name. */ diff --git a/packages/ai-skills/src/combinators.ts b/packages/ai-skills/src/combinators.ts index 00d84120a0..e2c890c94c 100644 --- a/packages/ai-skills/src/combinators.ts +++ b/packages/ai-skills/src/combinators.ts @@ -112,10 +112,54 @@ export function filter( predicate: FilterPredicate, ctx?: FilterContext, ): SkillSource { + const list = async () => + (await source.list()).filter((s) => predicate(s, ctx)) + const assertVisible = async (name: string) => { + const skills = await list() + if (!skills.some((s) => s.name === name)) { + throw new Error(`no skill named "${name}"`) + } + } return { ...source, revision: forwardRevision(source), - list: async () => (await source.list()).filter((s) => predicate(s, ctx)), + list, + load: async (name) => { + await assertVisible(name) + return source.load(name) + }, + listResources: source.listResources + ? async (name) => { + await assertVisible(name) + return source.listResources?.(name) ?? [] + } + : undefined, + readResource: source.readResource + ? async (name, path) => { + await assertVisible(name) + const read = source.readResource + if (!read) { + throw new Error(`skill "${name}" does not support resources`) + } + return read(name, path) + } + : undefined, + listScripts: source.listScripts + ? async (name) => { + await assertVisible(name) + return (source.listScripts?.(name) ?? []) as Array + } + : undefined, + readScript: source.readScript + ? async (name, path) => { + await assertVisible(name) + const read = source.readScript + if (!read) { + throw new Error(`skill "${name}" does not support scripts`) + } + return read(name, path) + } + : undefined, } } @@ -144,6 +188,7 @@ export function cache( list: () => { if (!listPromise || !fresh()) { listAt = now() + loads.clear() listPromise = source.list().catch((err) => { listPromise = undefined // don't cache failures throw err diff --git a/packages/ai-skills/src/index.ts b/packages/ai-skills/src/index.ts index 64ca49c467..a214316167 100644 --- a/packages/ai-skills/src/index.ts +++ b/packages/ai-skills/src/index.ts @@ -27,8 +27,8 @@ export type { FilterContext, FilterPredicate } from './combinators' export { renderCatalog, sortSkills } from './catalog' -export { withSkills } from './middleware' -export type { SkillsOptions } from './middleware' +export { withSkills, SKILLS_STATE_EVENT } from './middleware' +export type { SkillsOptions, SkillsStateEventValue } from './middleware' export { createLoadSkillTool, ALREADY_LOADED } from './tools/load-skill' export { diff --git a/packages/ai-skills/src/middleware.ts b/packages/ai-skills/src/middleware.ts index 029e6b5ec7..7a290c51e6 100644 --- a/packages/ai-skills/src/middleware.ts +++ b/packages/ai-skills/src/middleware.ts @@ -6,15 +6,27 @@ * S3-backed source would hit the network per loop turn and catalog reordering * would break Anthropic's cache prefix mid-run. */ -import { createCapability, defineChatMiddleware } from '@tanstack/ai' +import { + createCapability, + defineChatMiddleware, + SkillLimitError, +} from '@tanstack/ai' import { combineSources } from './combinators' import { renderCatalog } from './catalog' import { modelFamilyOf } from './types' import { createLoadSkillTool } from './tools/load-skill' import { READ_RESOURCE_TOOL_NAME } from './tools/read-resource' -import type { DefinedChatMiddleware, Tool } from '@tanstack/ai' +import type { DefinedChatMiddleware, StreamChunk, Tool } from '@tanstack/ai' import type { ModelFamily, SkillMetadata, SkillSource } from './types' +/** CUSTOM stream-event name carrying the catalog to the browser DevTools. */ +export const SKILLS_STATE_EVENT = 'skills:state' + +export interface SkillsStateEventValue { + catalog: Array<{ name: string; description: string }> + activated: Array +} + export interface SkillsOptions { /** Override catalog rendering. Receives resolved metadata + the model family. */ renderCatalog?: (skills: Array, family: ModelFamily) => string @@ -41,6 +53,7 @@ interface SkillsRuntime { options: SkillsOptions /** Built on first onConfig (needs config.tools to detect the resource tool). */ memo?: { prompt: { content: string } | undefined; tools: Array } + stateChunkEmitted?: boolean } const SkillsCapability = createCapability()('skills') @@ -128,15 +141,22 @@ export function withSkills( // Catalog token cap (spec §4.2). const limit = options.maxCatalogTokens ?? 4000 - let catalog = (options.renderCatalog ?? renderCatalog)(skills, family) + const render = options.renderCatalog ?? renderCatalog + let catalog = render(skills, family) if (estimateTokens(catalog) > limit) { if (options.onLimitExceeded && options.onLimitExceeded !== 'error') { skills = options.onLimitExceeded(skills, limit) - catalog = (options.renderCatalog ?? renderCatalog)(skills, family) - } else { - throw new Error( - `skills catalog (~${estimateTokens(catalog)} tokens) exceeds maxCatalogTokens (${limit})`, - ) + catalog = render(skills, family) + } + if (estimateTokens(catalog) > limit) { + throw new SkillLimitError({ + provider: family, + path: 'portable', + limit: `maxCatalogTokens (${limit})`, + allowed: limit, + actual: estimateTokens(catalog), + offending: skills.map((s) => s.name), + }) } } @@ -213,5 +233,24 @@ export function withSkills( : config.tools, } }, + + onChunk(ctx, chunk) { + const rt = ctx.getOptional(SkillsCapability) + if (!rt || rt.stateChunkEmitted) return + rt.stateChunkEmitted = true + const custom: StreamChunk = { + type: 'CUSTOM', + name: SKILLS_STATE_EVENT, + value: { + catalog: rt.skills.map((s) => ({ + name: s.name, + description: s.description, + })), + activated: [...rt.activated], + } satisfies SkillsStateEventValue, + timestamp: Date.now(), + } + return [chunk, custom] + }, }) } diff --git a/packages/ai-skills/src/node/index.ts b/packages/ai-skills/src/node/index.ts index 836941175d..f8943fd2a2 100644 --- a/packages/ai-skills/src/node/index.ts +++ b/packages/ai-skills/src/node/index.ts @@ -3,8 +3,8 @@ * `/node` subpath because it imports `node:fs`; the root export stays edge-safe * (Workers, browsers), mirroring `@tanstack/ai-code-mode-snippets/storage`. */ -import { readFile, readdir, stat } from 'node:fs/promises' -import { basename, join, relative } from 'node:path' +import { readFile, readdir, realpath, stat } from 'node:fs/promises' +import { basename, join, relative, sep } from 'node:path' import { walkSkillDirs } from '../walk' import { parseSkill, stripFrontmatter } from '../parse' import { assertSafeResourcePath, stableHash } from '../util' @@ -43,11 +43,31 @@ async function collectFiles(dir: string, root: string): Promise> { for (const e of ents) { const full = join(dir, e.name) if (e.isDirectory()) out.push(...(await collectFiles(full, root))) - else out.push(relative(root, full)) + else out.push(relative(root, full).replace(/\\/g, '/')) } return out } +function posixRel(path: string): string { + return path.replace(/\\/g, '/') +} + +function hasPrefix(rel: string, prefixes: Array): boolean { + const n = posixRel(rel) + return prefixes.some((p) => n === p || n.startsWith(`${p}/`)) +} + +async function resolveInside(dir: string, rel: string): Promise { + const full = join(dir, rel) + const rootReal = await realpath(dir) + const fullReal = await realpath(full) + const prefix = rootReal.endsWith(sep) ? rootReal : rootReal + sep + if (fullReal !== rootReal && !fullReal.startsWith(prefix)) { + throw new Error(`unsafe resource path: "${rel}"`) + } + return fullReal +} + export function skillDirectory( root: string | Array, options: SkillDirectoryOptions = {}, @@ -55,12 +75,28 @@ export function skillDirectory( const roots = Array.isArray(root) ? root : [root] const { maxDepth, strict = true } = options - /** Fresh scan of every root → name → skill directory. */ + /** Fresh scan of every root → parsed skill name → skill directory. First wins. */ const scan = async (): Promise> => { const map = new Map() for (const r of roots) { const dirs = await walkSkillDirs(nodeLister, r, { maxDepth }) - for (const d of dirs) map.set(d.name, d.dir) + for (const d of dirs) { + const raw = await readFile(join(d.dir, 'SKILL.md'), 'utf8').catch( + () => undefined, + ) + if (raw === undefined) continue + try { + const parsed = parseSkill(raw, { + dirName: basename(d.dir), + strict, + }) + if (!map.has(parsed.metadata.name)) { + map.set(parsed.metadata.name, d.dir) + } + } catch { + // Skip unparseable skills (same policy as list()). + } + } } return map } @@ -111,8 +147,17 @@ export function skillDirectory( }, readResource: async (name, path) => { assertSafeResourcePath(path) + if (!hasPrefix(path, RESOURCE_DIRS)) { + throw new Error( + `resource path must be under references/ or assets/: "${path}"`, + ) + } const dir = await dirOf(name) - return readFile(join(dir, path)) + const fullReal = await resolveInside(dir, path) + if (hasPrefix(path, ['references'])) { + return readFile(fullReal, 'utf8') + } + return readFile(fullReal) }, listScripts: async (name) => { const dir = await dirOf(name) @@ -127,8 +172,13 @@ export function skillDirectory( }, readScript: async (name, path) => { assertSafeResourcePath(path) + if (!hasPrefix(path, [SCRIPT_DIR])) { + throw new Error(`script path must be under scripts/: "${path}"`) + } const dir = await dirOf(name) - return readFile(join(dir, path)) + const fullReal = await resolveInside(dir, path) + const bytes = await readFile(fullReal) + return new Uint8Array(bytes) }, } } @@ -144,6 +194,7 @@ export async function generateCatalog( ): Promise { const roots = Array.isArray(root) ? root : [root] const skills: Array = [] + const seen = new Set() for (const r of roots) { for (const { dir } of await walkSkillDirs(nodeLister, r, { maxDepth: options.maxDepth, @@ -161,6 +212,8 @@ export async function generateCatalog( } catch { continue } + if (seen.has(meta.name)) continue + seen.add(meta.name) const resources: Record = {} for (const sub of RESOURCE_DIRS) { for (const rel of await collectFiles(join(dir, sub), dir)) { @@ -189,7 +242,10 @@ export async function generateCatalog( export interface SkillsCatalogPlugin { name: string resolveId: (id: string) => string | undefined - load: (id: string) => Promise + load: ( + this: { addWatchFile?: (id: string) => void }, + id: string, + ) => Promise } /** @@ -207,9 +263,18 @@ export function skillsCatalogPlugin( return { name: 'tanstack-skills-catalog', resolveId: (id) => (id === virtualId ? resolved : undefined), - load: async (id) => { + async load(this: { addWatchFile?: (id: string) => void }, id: string) { if (id !== resolved) return undefined const catalog = await generateCatalog(dir, { maxDepth: options.maxDepth }) + if (typeof this.addWatchFile === 'function') { + for (const r of Array.isArray(dir) ? dir : [dir]) { + for (const d of await walkSkillDirs(nodeLister, r, { + maxDepth: options.maxDepth, + })) { + this.addWatchFile(join(d.dir, 'SKILL.md')) + } + } + } return `export const catalog = ${JSON.stringify(catalog)} as const\n` }, } diff --git a/packages/ai-skills/src/sources/inline.ts b/packages/ai-skills/src/sources/inline.ts index e4ea3fed70..ce5d612d39 100644 --- a/packages/ai-skills/src/sources/inline.ts +++ b/packages/ai-skills/src/sources/inline.ts @@ -4,6 +4,7 @@ * be thunks, evaluated at read time. */ import { stableHash } from '../util' +import { validateSkill } from '../validate' import type { SkillMetadata, SkillSource } from '../types' export interface InlineSkillConfig { @@ -20,6 +21,12 @@ export function inlineSkill(config: InlineSkillConfig): SkillSource { description: config.description, ...(config.compatibility && { compatibility: config.compatibility }), } + const lint = validateSkill(metadata) + if (!lint.ok) { + throw new Error( + `inlineSkill "${config.name}": ${lint.issues.map((i) => i.message).join('; ')}`, + ) + } const resourcePaths = Object.keys(config.resources ?? {}) const revision = stableHash( JSON.stringify({ diff --git a/packages/ai-skills/src/testing/index.ts b/packages/ai-skills/src/testing/index.ts index 9d23209462..f233715b32 100644 --- a/packages/ai-skills/src/testing/index.ts +++ b/packages/ai-skills/src/testing/index.ts @@ -14,7 +14,6 @@ * corresponding methods — those cases are skipped, not failed. */ import { describe, expect, it } from 'vitest' -import { assertSafeResourcePath } from '../util' import type { SkillSource } from '../types' const dec = (v: string | Uint8Array) => @@ -75,12 +74,8 @@ export function runSkillSourceConformance( const value = await readResource('alpha', 'references/note.md') // trimEnd: a file-backed source keeps the fixture's trailing newline (formatters add one); the payload is what matters. expect(dec(value).trimEnd()).toBe('hello') - // Path traversal must be rejected — by the source or the shared guard. await expect( - (async () => { - assertSafeResourcePath('../../etc/passwd') - await readResource('alpha', '../../etc/passwd') - })(), + readResource('alpha', '../../etc/passwd'), ).rejects.toThrow() }) diff --git a/packages/ai-skills/src/tools/read-resource.ts b/packages/ai-skills/src/tools/read-resource.ts index c1091e393a..9a14993fd9 100644 --- a/packages/ai-skills/src/tools/read-resource.ts +++ b/packages/ai-skills/src/tools/read-resource.ts @@ -37,6 +37,18 @@ export function createResourceTool(source: SkillSource): Tool { }), }).server(async ({ skill, path }) => { assertSafeResourcePath(path) + const listed = await source.list() + if (!listed.some((s) => s.name === skill)) { + throw new Error(`no skill named "${skill}"`) + } + if (source.listResources) { + const allowed = await source.listResources(skill) + if (!allowed.includes(path)) { + throw new Error( + `skill "${skill}" has no resource "${path}"`, + ) + } + } if (!source.readResource) { throw new Error('this skill source does not support resources') } diff --git a/packages/ai-skills/src/types.ts b/packages/ai-skills/src/types.ts index 5810b9f37a..52c503557f 100644 --- a/packages/ai-skills/src/types.ts +++ b/packages/ai-skills/src/types.ts @@ -35,10 +35,14 @@ export interface SkillScriptRef { /** A source of skills. Bytes only — no filesystem assumption. */ export interface SkillSource { - /** Stable identity for the current content. Used as a catalog cache key. */ + /** + * Stable identity for the current content. `cache()` does not read this + * (it is time-based, and opt-in). Callers and custom combinators can use it + * as a catalog cache key. `withSkills` lists once per `chat()` call. + */ revision?: () => Promise - /** Tier 1. Called once per request; core memoizes on `revision()`. */ + /** Tier 1. Called once per `chat()` by `withSkills` setup. */ list: () => Promise> /** Tier 2. Raw SKILL.md including frontmatter. Core strips it. */ diff --git a/packages/ai-skills/src/walk.ts b/packages/ai-skills/src/walk.ts index 9cb41a4059..84cf4163aa 100644 --- a/packages/ai-skills/src/walk.ts +++ b/packages/ai-skills/src/walk.ts @@ -27,7 +27,8 @@ export const MAX_SKILL_WALK_DEPTH = 6 const SKIP_DIR_NAMES = new Set(['.git', 'node_modules']) function basenameOf(path: string): string { - const segments = path.split('/').filter((segment) => segment !== '') + const normalized = path.replace(/\\/g, '/') + const segments = normalized.split('/').filter((segment) => segment !== '') return segments[segments.length - 1] ?? path } diff --git a/packages/ai-skills/tests/catalog.test.ts b/packages/ai-skills/tests/catalog.test.ts index b2473168a8..fb354dffbf 100644 --- a/packages/ai-skills/tests/catalog.test.ts +++ b/packages/ai-skills/tests/catalog.test.ts @@ -19,6 +19,14 @@ describe('renderCatalog', () => { expect(out).toContain('first & <special>') }) + it('escapes quotes in Anthropic XML attributes', () => { + const out = renderCatalog( + [{ name: 'alpha', description: 'say "hi" & go' }], + 'anthropic', + ) + expect(out).toContain('say "hi" & go') + }) + it('renders markdown for non-Anthropic families', () => { const out = renderCatalog(skills, 'openai') expect(out).toContain('## Available skills') diff --git a/packages/ai-skills/tests/combinators.test.ts b/packages/ai-skills/tests/combinators.test.ts index 59e188c46b..327a90e38c 100644 --- a/packages/ai-skills/tests/combinators.test.ts +++ b/packages/ai-skills/tests/combinators.test.ts @@ -38,6 +38,14 @@ describe('filter', () => { const s = filter(aggregate([alpha, beta]), (m) => m.name !== 'beta') expect((await s.list()).map((x) => x.name)).toEqual(['alpha']) }) + + it('rejects load and readResource of a hidden skill', async () => { + const s = filter(aggregate([alpha, beta]), (m) => m.name !== 'beta') + await expect(s.load('beta')).rejects.toThrow('no skill named "beta"') + await expect(s.readResource?.('beta', 'references/x.md')).rejects.toThrow( + 'no skill named "beta"', + ) + }) }) describe('cache', () => { @@ -55,4 +63,19 @@ describe('cache', () => { await Promise.all([s.list(), s.list(), s.list()]) expect(calls).toBe(1) }) + + it('clears cached load() when list() refreshes', async () => { + let body = 'A' + const underlying: SkillSource = { + list: () => Promise.resolve([{ name: 'alpha', description: 'a' }]), + load: () => Promise.resolve(body), + } + const s = cache(underlying, { refreshInterval: 1 }) + await s.list() + expect(await s.load('alpha')).toBe('A') + body = 'B' + await new Promise((r) => setTimeout(r, 5)) + await s.list() + expect(await s.load('alpha')).toBe('B') + }) }) diff --git a/packages/ai-skills/tests/middleware.test.ts b/packages/ai-skills/tests/middleware.test.ts new file mode 100644 index 0000000000..73a50ae7bc --- /dev/null +++ b/packages/ai-skills/tests/middleware.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest' +import { SkillLimitError } from '@tanstack/ai' +import { inlineSkill } from '../src/sources/inline' +import { + SKILLS_STATE_EVENT, + withSkills, +} from '../src/middleware' +import { createResourceTool } from '../src/tools/read-resource' +import type { + ChatMiddlewareConfig, + ChatMiddlewareContext, + StreamChunk, + Tool, +} from '@tanstack/ai' +import type { SkillSource } from '../src/types' + +function makeCtx(provider = 'openai'): ChatMiddlewareContext { + const bag = new Map() + return { + provider, + provide: (cap: object, value: unknown) => { + bag.set(cap, value) + }, + get: (cap: object) => { + const value = bag.get(cap) + if (value === undefined) throw new Error('missing capability') + return value + }, + getOptional: (cap: object) => bag.get(cap), + } as unknown as ChatMiddlewareContext +} + +function makeConfig(tools: Array = []): ChatMiddlewareConfig { + return { + messages: [{ role: 'user', content: 'hi' }], + systemPrompts: [], + tools, + } +} + +const alpha = inlineSkill({ + name: 'alpha', + description: 'does A', + instructions: 'Do A.', + resources: { 'references/note.md': 'hello' }, +}) + +describe('withSkills', () => { + it('injects load_skill and a catalog prompt', async () => { + const mw = withSkills(alpha) + const ctx = makeCtx() + await mw.setup?.(ctx) + const patch = await mw.onConfig?.(ctx, makeConfig()) + expect(patch?.tools?.some((t) => t.name === 'load_skill')).toBe(true) + expect( + patch?.systemPrompts?.some((p) => { + const text = typeof p === 'string' ? p : p.content + return text.includes('alpha') + }), + ).toBe(true) + }) + + it('is idempotent across onConfig iterations', async () => { + const mw = withSkills(alpha) + const ctx = makeCtx() + await mw.setup?.(ctx) + const first = await mw.onConfig?.(ctx, makeConfig()) + const second = await mw.onConfig?.(ctx, { + messages: [], + systemPrompts: first?.systemPrompts ?? [], + tools: first?.tools ?? [], + }) + expect(second?.tools?.filter((t) => t.name === 'load_skill')).toHaveLength(1) + }) + + it('refuses native hosted skills on the same call', async () => { + const mw = withSkills(alpha) + const ctx = makeCtx() + await mw.setup?.(ctx) + const native: Tool = { + name: 'code_execution', + description: 'hosted', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + metadata: { skills: [{ skill_id: 'xlsx' }] }, + } + expect(() => mw.onConfig?.(ctx, makeConfig([native]))).toThrow( + 'cannot be combined', + ) + }) + + it('throws SkillLimitError when the catalog exceeds maxCatalogTokens', async () => { + const huge: SkillSource = { + list: () => + Promise.resolve([ + { name: 'alpha', description: 'x'.repeat(20000) }, + ]), + load: () => Promise.resolve('body'), + } + const mw = withSkills(huge, { maxCatalogTokens: 10 }) + await expect(mw.setup?.(makeCtx())).rejects.toBeInstanceOf(SkillLimitError) + }) + + it('re-checks the cap after onLimitExceeded', async () => { + const huge: SkillSource = { + list: () => + Promise.resolve([ + { name: 'alpha', description: 'x'.repeat(20000) }, + ]), + load: () => Promise.resolve('body'), + } + const mw = withSkills(huge, { + maxCatalogTokens: 10, + onLimitExceeded: (skills) => skills, + }) + await expect(mw.setup?.(makeCtx())).rejects.toBeInstanceOf(SkillLimitError) + }) + + it('injects a skills:state CUSTOM chunk once', async () => { + const mw = withSkills(alpha) + const ctx = makeCtx() + await mw.setup?.(ctx) + const runStarted = { + type: 'RUN_STARTED', + threadId: 't1', + runId: 'r1', + } as unknown as StreamChunk + const out = await mw.onChunk?.(ctx, runStarted) + expect(Array.isArray(out)).toBe(true) + const chunks = out as Array + expect(chunks[0]).toBe(runStarted) + const custom = chunks[1] as Extract + expect(custom.type).toBe('CUSTOM') + expect(custom.name).toBe(SKILLS_STATE_EVENT) + expect(custom.value).toMatchObject({ + catalog: [{ name: 'alpha', description: 'does A' }], + activated: [], + }) + expect(await mw.onChunk?.(ctx, runStarted)).toBeUndefined() + }) +}) + +describe('createResourceTool', () => { + function exec(tool: unknown): (input: unknown) => Promise { + const fn = (tool as { execute?: (i: unknown) => Promise }).execute + if (!fn) throw new Error('tool has no execute') + return fn + } + + it('returns utf8 for a markdown resource', async () => { + const tool = createResourceTool(alpha) + const result = await exec(tool)({ + skill: 'alpha', + path: 'references/note.md', + }) + expect(result).toEqual({ + skill: 'alpha', + path: 'references/note.md', + content: 'hello', + encoding: 'utf8', + }) + }) + + it('rejects an unknown skill and a path outside listed resources', async () => { + const tool = createResourceTool(alpha) + await expect( + exec(tool)({ skill: 'secret', path: 'references/note.md' }), + ).rejects.toThrow('no skill named "secret"') + await expect( + exec(tool)({ skill: 'alpha', path: 'SKILL.md' }), + ).rejects.toThrow('has no resource') + }) +}) diff --git a/packages/ai-skills/tests/skill-directory.test.ts b/packages/ai-skills/tests/skill-directory.test.ts new file mode 100644 index 0000000000..906da87691 --- /dev/null +++ b/packages/ai-skills/tests/skill-directory.test.ts @@ -0,0 +1,40 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { skillDirectory } from '../src/node' + +const fixtures = fileURLToPath(new URL('./fixtures/skills', import.meta.url)) + +const tempDirs: Array = [] + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true }))) +}) + +describe('skillDirectory', () => { + it('returns utf8 text for references/ and rejects SKILL.md', async () => { + const source = skillDirectory(fixtures) + const value = await source.readResource?.('alpha', 'references/note.md') + expect(typeof value).toBe('string') + expect(String(value).trimEnd()).toBe('hello') + await expect(source.readResource?.('alpha', 'SKILL.md')).rejects.toThrow( + 'references/ or assets/', + ) + }) + + it('loads by frontmatter name when it differs from the folder', async () => { + const root = await mkdtemp(join(tmpdir(), 'ai-skills-')) + tempDirs.push(root) + await mkdir(join(root, 'foo')) + await writeFile( + join(root, 'foo', 'SKILL.md'), + '---\nname: bar\ndescription: renamed\n---\n\nBody.\n', + ) + const source = skillDirectory(root, { strict: false }) + expect((await source.list()).map((s) => s.name)).toEqual(['bar']) + expect(await source.load('bar')).toContain('Body.') + await expect(source.load('foo')).rejects.toThrow('no skill named "foo"') + }) +}) diff --git a/packages/ai-skills/tests/walk.test.ts b/packages/ai-skills/tests/walk.test.ts index bfc98b7dec..5a5b451871 100644 --- a/packages/ai-skills/tests/walk.test.ts +++ b/packages/ai-skills/tests/walk.test.ts @@ -49,6 +49,19 @@ describe('walkSkillDirs', () => { expect(await walkSkillDirs(memLister({ '/root': [] }), '/root')).toEqual([]) }) + it('parses Windows-style paths for the skill name', async () => { + const tree = { + 'C:\\proj': [dir('skills', 'C:\\proj\\skills')], + 'C:\\proj\\skills': [dir('alpha', 'C:\\proj\\skills\\alpha')], + 'C:\\proj\\skills\\alpha': [ + file('SKILL.md', 'C:\\proj\\skills\\alpha\\SKILL.md'), + ], + } + expect(await walkSkillDirs(memLister(tree), 'C:\\proj')).toEqual([ + { name: 'alpha', dir: 'C:\\proj\\skills\\alpha' }, + ]) + }) + it('respects maxDepth', async () => { const tree = { '/r': [dir('a', '/r/a')], diff --git a/packages/ai/src/utilities/errors.ts b/packages/ai/src/utilities/errors.ts index 24425e0f8c..31fc3605b6 100644 --- a/packages/ai/src/utilities/errors.ts +++ b/packages/ai/src/utilities/errors.ts @@ -11,7 +11,7 @@ import type { StreamChunk } from '../types' * cap that only applies to hosted skills. */ export interface SkillLimitErrorInit { - provider: 'anthropic' | 'openai' + provider: 'anthropic' | 'openai' | 'gemini' | 'other' path: 'native' | 'portable' limit: string allowed: number @@ -20,7 +20,7 @@ export interface SkillLimitErrorInit { } export class SkillLimitError extends Error { - readonly provider: 'anthropic' | 'openai' + readonly provider: 'anthropic' | 'openai' | 'gemini' | 'other' readonly path: 'native' | 'portable' readonly limit: string readonly allowed: number From f11fd3559a40edc4fbf95cbfdc2b0093a29761bb Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:26:29 +0000 Subject: [PATCH 6/8] ci: apply automated fixes --- packages/ai-client/tests/devtools.test.ts | 4 +++- .../ai-devtools/src/store/skills-registry.ts | 3 ++- packages/ai-skills/src/testing/index.ts | 4 +--- packages/ai-skills/src/tools/read-resource.ts | 4 +--- packages/ai-skills/tests/middleware.test.ts | 17 ++++++----------- .../ai-skills/tests/skill-directory.test.ts | 4 +++- 6 files changed, 16 insertions(+), 20 deletions(-) diff --git a/packages/ai-client/tests/devtools.test.ts b/packages/ai-client/tests/devtools.test.ts index 7e004ad8e6..a851ed0972 100644 --- a/packages/ai-client/tests/devtools.test.ts +++ b/packages/ai-client/tests/devtools.test.ts @@ -1433,7 +1433,9 @@ describe('ChatClient devtools bridge', () => { timestamp: Date.now(), name: 'skills:state', value: { - catalog: [{ name: 'pirate-speak', description: 'talk like a pirate' }], + catalog: [ + { name: 'pirate-speak', description: 'talk like a pirate' }, + ], activated: [], }, }, diff --git a/packages/ai-devtools/src/store/skills-registry.ts b/packages/ai-devtools/src/store/skills-registry.ts index 51bfed8ed2..219a37edd2 100644 --- a/packages/ai-devtools/src/store/skills-registry.ts +++ b/packages/ai-devtools/src/store/skills-registry.ts @@ -38,7 +38,8 @@ export function applySkillsSnapshot( timestamp?: number }, ): void { - const key = payload.hookId && payload.hookId.length > 0 ? payload.hookId : '_default' + const key = + payload.hookId && payload.hookId.length > 0 ? payload.hookId : '_default' const catalog = Array.isArray(payload.catalog) ? payload.catalog : [] const activated = Array.isArray(payload.activated) ? payload.activated : [] state.snapshots[key] = { diff --git a/packages/ai-skills/src/testing/index.ts b/packages/ai-skills/src/testing/index.ts index f233715b32..e9d1588ee9 100644 --- a/packages/ai-skills/src/testing/index.ts +++ b/packages/ai-skills/src/testing/index.ts @@ -74,9 +74,7 @@ export function runSkillSourceConformance( const value = await readResource('alpha', 'references/note.md') // trimEnd: a file-backed source keeps the fixture's trailing newline (formatters add one); the payload is what matters. expect(dec(value).trimEnd()).toBe('hello') - await expect( - readResource('alpha', '../../etc/passwd'), - ).rejects.toThrow() + await expect(readResource('alpha', '../../etc/passwd')).rejects.toThrow() }) it('returns script bytes correctly', async () => { diff --git a/packages/ai-skills/src/tools/read-resource.ts b/packages/ai-skills/src/tools/read-resource.ts index 9a14993fd9..1865f642f4 100644 --- a/packages/ai-skills/src/tools/read-resource.ts +++ b/packages/ai-skills/src/tools/read-resource.ts @@ -44,9 +44,7 @@ export function createResourceTool(source: SkillSource): Tool { if (source.listResources) { const allowed = await source.listResources(skill) if (!allowed.includes(path)) { - throw new Error( - `skill "${skill}" has no resource "${path}"`, - ) + throw new Error(`skill "${skill}" has no resource "${path}"`) } } if (!source.readResource) { diff --git a/packages/ai-skills/tests/middleware.test.ts b/packages/ai-skills/tests/middleware.test.ts index 73a50ae7bc..143e7add02 100644 --- a/packages/ai-skills/tests/middleware.test.ts +++ b/packages/ai-skills/tests/middleware.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from 'vitest' import { SkillLimitError } from '@tanstack/ai' import { inlineSkill } from '../src/sources/inline' -import { - SKILLS_STATE_EVENT, - withSkills, -} from '../src/middleware' +import { SKILLS_STATE_EVENT, withSkills } from '../src/middleware' import { createResourceTool } from '../src/tools/read-resource' import type { ChatMiddlewareConfig, @@ -70,7 +67,9 @@ describe('withSkills', () => { systemPrompts: first?.systemPrompts ?? [], tools: first?.tools ?? [], }) - expect(second?.tools?.filter((t) => t.name === 'load_skill')).toHaveLength(1) + expect(second?.tools?.filter((t) => t.name === 'load_skill')).toHaveLength( + 1, + ) }) it('refuses native hosted skills on the same call', async () => { @@ -95,9 +94,7 @@ describe('withSkills', () => { it('throws SkillLimitError when the catalog exceeds maxCatalogTokens', async () => { const huge: SkillSource = { list: () => - Promise.resolve([ - { name: 'alpha', description: 'x'.repeat(20000) }, - ]), + Promise.resolve([{ name: 'alpha', description: 'x'.repeat(20000) }]), load: () => Promise.resolve('body'), } const mw = withSkills(huge, { maxCatalogTokens: 10 }) @@ -107,9 +104,7 @@ describe('withSkills', () => { it('re-checks the cap after onLimitExceeded', async () => { const huge: SkillSource = { list: () => - Promise.resolve([ - { name: 'alpha', description: 'x'.repeat(20000) }, - ]), + Promise.resolve([{ name: 'alpha', description: 'x'.repeat(20000) }]), load: () => Promise.resolve('body'), } const mw = withSkills(huge, { diff --git a/packages/ai-skills/tests/skill-directory.test.ts b/packages/ai-skills/tests/skill-directory.test.ts index 906da87691..8c4dd8292d 100644 --- a/packages/ai-skills/tests/skill-directory.test.ts +++ b/packages/ai-skills/tests/skill-directory.test.ts @@ -10,7 +10,9 @@ const fixtures = fileURLToPath(new URL('./fixtures/skills', import.meta.url)) const tempDirs: Array = [] afterEach(async () => { - await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true }))) + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { recursive: true })), + ) }) describe('skillDirectory', () => { From fa120d17c117d44f153677b79571987db48667b0 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 27 Aug 2026 15:13:52 +0200 Subject: [PATCH 7/8] fix(ai-client): keep live hookId so DevTools can select useChat --- packages/ai-client/src/devtools.ts | 81 ++++++++++++++++--- packages/ai-client/tests/devtools.test.ts | 34 ++++++++ .../src/components/hooks/HookDetails.tsx | 5 +- 3 files changed, 107 insertions(+), 13 deletions(-) diff --git a/packages/ai-client/src/devtools.ts b/packages/ai-client/src/devtools.ts index 4851890d67..647ca92c66 100644 --- a/packages/ai-client/src/devtools.ts +++ b/packages/ai-client/src/devtools.ts @@ -453,6 +453,49 @@ function getActiveBridgeRegistry(): Map { return registry } +/** + * `{...options}` turns `get hookId()` into a data property. Chat/generation + * clients mint `threadId` after construct (`ensureThreadId` on mount), so a + * spread would freeze `hookId: ''` and DevTools could never select the hook. + */ +function withLiveClientIdentity( + identity: Pick< + AIDevtoolsBridgeOptions, + | 'hookId' + | 'clientId' + | 'threadId' + | 'metadata' + | 'getTools' + | 'applyToolFixture' + >, + rest: Pick, 'getSnapshot'> & + Partial< + Pick, 'getTools' | 'applyToolFixture'> + >, +): AIDevtoolsBridgeOptions { + return { + get hookId() { + return identity.hookId + }, + get clientId() { + return identity.clientId + }, + get threadId() { + return identity.threadId + }, + metadata: identity.metadata, + getSnapshot: rest.getSnapshot, + ...(rest.getTools || identity.getTools + ? { getTools: rest.getTools ?? identity.getTools } + : {}), + ...(rest.applyToolFixture || identity.applyToolFixture + ? { + applyToolFixture: rest.applyToolFixture ?? identity.applyToolFixture, + } + : {}), + } +} + export class ClientDevtoolsBridge { protected readonly options: AIDevtoolsBridgeOptions private readonly unsubscribers: Array = [] @@ -780,15 +823,20 @@ export class ChatDevtoolsBridge extends ClientDevtoolsBridge this.applyFixture(fixture), - }) + super( + withLiveClientIdentity(options, { + getSnapshot: options.getSnapshot, + // Thunk defers `this.applyFixture` lookup until after `super` returns. + applyToolFixture: (fixture) => this.applyFixture(fixture), + }), + ) this.chatOptions = options // Auto-attaches run/thread context and auto-emits a snapshot after each // event so callers can keep using `this.events.X(...)` with no context arg. - this.events = new ChatDevtoolsAwareEventEmitter(options.clientId, this) + this.events = new ChatDevtoolsAwareEventEmitter( + () => options.clientId, + this, + ) } // --- Stream / run context API ------------------------------------------- @@ -1439,10 +1487,11 @@ export class GenerationDevtoolsBridge extends ClientDevtoolsBridge< protected readonly getCoreState: () => GenerationDevtoolsCoreState constructor(options: GenerationDevtoolsBridgeOptions) { - super({ - ...options, - getSnapshot: () => this.buildSnapshot(), - }) + super( + withLiveClientIdentity(options, { + getSnapshot: () => this.buildSnapshot(), + }), + ) this.maxRuns = options.maxRuns ?? 20 this.getCoreState = options.getCoreState } @@ -1788,10 +1837,18 @@ export class VideoDevtoolsBridge< // so resolveStreamId() works without the chat client telling it. class ChatDevtoolsAwareEventEmitter extends DefaultChatClientEventEmitter { constructor( - clientId: string, + private readonly getClientId: () => string, private readonly helper: ChatDevtoolsBridge, ) { - super(clientId) + super(getClientId()) + } + + protected override emitEvent( + eventName: string, + data?: Record, + ): void { + this.clientId = this.getClientId() + super.emitEvent(eventName, data) } private afterEmit(streamId?: string): void { diff --git a/packages/ai-client/tests/devtools.test.ts b/packages/ai-client/tests/devtools.test.ts index a851ed0972..a15abdfe67 100644 --- a/packages/ai-client/tests/devtools.test.ts +++ b/packages/ai-client/tests/devtools.test.ts @@ -250,6 +250,40 @@ describe('ChatClient devtools bridge', () => { expect(eventClientMock.emitted('hook:unregistered')).toEqual([]) }) + it('registers a generated hookId when construct had no threadId', () => { + const client = new ChatClient({ + connection: createMockConnectionAdapter(), + devtools: { + framework: 'react', + hookName: 'useChat', + name: 'Skills', + }, + }) + client.mountDevtools() + + expect(eventClientMock.emitted('hook:registered')).toEqual([ + [ + 'hook:registered', + expect.objectContaining({ + hookId: expect.stringMatching(/^thread-/), + displayName: 'Skills', + lifecycle: 'mounted', + }), + ], + ]) + expect(eventClientMock.emitted('client:created')).toEqual([ + [ + 'client:created', + expect.objectContaining({ + clientId: expect.stringMatching(/^thread-/), + hookId: expect.stringMatching(/^thread-/), + }), + ], + ]) + + client.dispose() + }) + it('can register again after a mount cleanup cycle', () => { const client = createClient({ mountDevtools: false }) diff --git a/packages/ai-devtools/src/components/hooks/HookDetails.tsx b/packages/ai-devtools/src/components/hooks/HookDetails.tsx index 520c0189fc..0ca0c48f98 100644 --- a/packages/ai-devtools/src/components/hooks/HookDetails.tsx +++ b/packages/ai-devtools/src/components/hooks/HookDetails.tsx @@ -120,7 +120,10 @@ export const HookDetails: Component = () => { const hook = createMemo((): HookRecord | undefined => { const id = state.hooks.activeHookId - return id ? state.hooks.hooks[id] : undefined + // `''` is a legal registry key (a hook that mounted before it minted a + // thread id). Only `null` means "no selection". + if (id === null) return undefined + return state.hooks.hooks[id] }) const conversation = createMemo(() => { From cbae0843687cddd687c785202bf1b5411fd6805a Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 27 Aug 2026 17:58:19 +0200 Subject: [PATCH 8/8] fix(ai-devtools): size JSON cards to content, keep max-height --- packages/ai-devtools/src/styles/use-styles.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/ai-devtools/src/styles/use-styles.ts b/packages/ai-devtools/src/styles/use-styles.ts index 43e003d9ab..0dafc640cb 100644 --- a/packages/ai-devtools/src/styles/use-styles.ts +++ b/packages/ai-devtools/src/styles/use-styles.ts @@ -1695,7 +1695,6 @@ const stylesFactory = (theme: 'light' | 'dark') => { border: 1px solid ${t(colors.gray[200], colors.darkGray[600])}; border-radius: ${border.radius.sm}; background: ${t(colors.white, colors.darkGray[900])}; - min-height: 260px; max-height: 420px; overflow: auto; padding: ${size[2]} ${size[2]} ${size[2]} ${size[5]}; @@ -2570,7 +2569,7 @@ const stylesFactory = (theme: 'light' | 'dark') => { gap: ${size[3]}; align-items: start; & > * { - min-height: 280px; + max-height: 420px; border: 1px solid oklch(0.28 0.03 260); border-radius: 6px; background: oklch(0.18 0.02 260); @@ -3634,7 +3633,6 @@ const stylesFactory = (theme: 'light' | 'dark') => { `, stepJsonPanel: css` margin: ${size[1.5]} ${size[3]}; - min-height: 280px; max-height: 520px; overflow: auto; padding: ${size[2]} ${size[2]} ${size[2]} ${size[5]};