UNPKG

@tanstack/ai-skills

Version:

Portable Agent Skills (SKILL.md) as a first-class chat() middleware for TanStack AI.

133 lines (132 loc) • 5.67 kB
import { modelFamilyOf } from "./types.js"; import { combineSources } from "./combinators.js"; import { renderCatalog } from "./catalog.js"; import { createLoadSkillTool } from "./tools/load-skill.js"; import { READ_RESOURCE_TOOL_NAME } from "./tools/read-resource.js"; import { SkillLimitError, createCapability, defineChatMiddleware } from "@tanstack/ai"; //#region src/middleware.ts /** * `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. */ /** CUSTOM stream-event name carrying the catalog to the browser DevTools. */ var SKILLS_STATE_EVENT = "skills:state"; var SkillsCapability = createCapability()("skills"); /** ~4 chars/token — good enough to guard a runaway catalog. */ var estimateTokens = (s) => Math.ceil(s.length / 4); function fillTemplate(template, catalog) { const OPEN = "\0OPEN\0"; const CLOSE = "\0CLOSE\0"; 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) { for (const tool of tools) { const meta = tool.metadata; 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"; } } function activationInstructions(catalog, hasResourceTool) { 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, 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." ].join("\n\n"); } function withSkills(sources, options = {}) { 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); const limit = options.maxCatalogTokens ?? 4e3; 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 = 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) }); } ctx.provide(SkillsCapability, { skills, activated: /* @__PURE__ */ new Set(), source, family, catalog, options }); }, onConfig(ctx, config) { const rt = ctx.get(SkillsCapability); if (rt.skills.length === 0) return; 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 !== void 0 ? fillTemplate(options.instructionTemplate, rt.catalog) : activationInstructions(rt.catalog, hasResourceTool); const loadTool = createLoadSkillTool({ source: rt.source, skills: rt.skills, activated: rt.activated, requireApproval: options.requireApproval }); if ((options.catalogPlacement ?? "system") === "tool-description") { loadTool.description = `${loadTool.description}\n\n${body}`; rt.memo = { prompt: void 0, tools: [loadTool] }; } else rt.memo = { prompt: { content: body }, tools: [loadTool] }; } 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 }; }, onChunk(ctx, chunk) { const rt = ctx.getOptional(SkillsCapability); if (!rt || rt.stateChunkEmitted) return; rt.stateChunkEmitted = true; return [chunk, { type: "CUSTOM", name: SKILLS_STATE_EVENT, value: { catalog: rt.skills.map((s) => ({ name: s.name, description: s.description })), activated: [...rt.activated] }, timestamp: Date.now() }]; } }); } //#endregion export { SKILLS_STATE_EVENT, withSkills }; //# sourceMappingURL=middleware.js.map