diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..b250fc772 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,121 @@ +# Tiny Robot Agent Guide + +## Current Focus + +The active development track is the skill toolchain in `packages/kit`. + +The goal is to make skills a standalone capability template, not a sub-feature of `message`. A skill can be loaded from files, managed by a manager, and compiled into prompt instructions plus runtime tools for the message engine. + +## Current Architecture + +- `packages/kit/src/skills` + - Core skill toolchain modules. + - Owns skill loading, skill types, compiler helpers, file adapters, manager, and skill tests. + - Browser-safe skill APIs are exported from `@opentiny/tiny-robot-kit/core`. + - Node-only file adapters are exported from `@opentiny/tiny-robot-kit/node`. +- `packages/kit/src/message/plugins/skillPlugin.ts` + - Message runtime adapter only. + - Bridges `getSkills()` into message engine hooks. +- `packages/kit/src/message/plugins` + - Message plugins and runtime protocols. + - Must not own or re-export skill core logic. + +## Package Manager + +This repository uses pnpm for dependency and script management. Prefer `pnpm` commands over `npm` commands. + +## Skill Layers + +- File Adapters + - Convert platform-specific file sources into `SkillFile[]`. + - Examples: `loadSkillFilesFromFs`, `loadSkillFilesFromFileList`, `loadSkillFilesFromDirectoryHandle`. +- Loader + - Converts `SkillFile[]` into `SkillDefinition`. + - Lives in `packages/kit/src/skills/skillLoader.ts`. +- Compiler + - Converts `SkillDefinition[]` into request instructions, built-in file runtime tools, and optional command runtime tools. + - Lives in `packages/kit/src/skills/compiler.ts`. +- Plugin Adapter + - Connects skill compiler output to message engine lifecycle. + - Lives in `packages/kit/src/message/plugins/skillPlugin.ts`. +- Manager + - Lives in `packages/kit/src/skills/manager.ts`. + - Owns write/remove/list/import/select skills. + - Must not compile request instructions or runtime tools. + +## Hard Rules + +- Do not move skill core modules back under `packages/kit/src/message`. +- `skillPlugin` must not own, cache, query, mutate, or manage skill collections. +- `skillPlugin` receives the current turn's skills through `getSkills()`. +- Do not use `activeSkills` naming in the skill plugin/compiler. The plugin receives skills that are already selected by outside logic. +- Compiler may compile instructions and runtime tools, but must not manage persistence, selection state, or storage. +- File adapters may read platform file sources, but must not parse skill semantics. +- Loader may parse/import skill files into a skill definition, but must not own skill collections. +- Manager may call loaders to import skills and may track selected skills, but must not compile request instructions or runtime tools. +- Public skill APIs should be exported from `packages/kit/src/skills/index.ts`. +- Node-only skill APIs should use dedicated subpath exports instead of the browser package root. +- `message/plugins/index.ts` must only export message plugin APIs; skill core APIs belong to `src/skills`. +- Skill command execution uses `executeSkillCommand` on `skillPlugin`. Do not add PPT/PDF/browser/document backends to kit; route command tool calls to application-provided sandbox executors. + +## Current Public API Shape + +```ts +skillPlugin({ + getSkills: () => [skill], + executeSkillCommand: async ({ skill, command, args }) => { + return sandboxExecutor.execute({ skill, command, args }) + }, +}) +``` + +Vue adapter also accepts reactive selected skills: + +```ts +skillPlugin({ + skills: selectedSkills, +}) +``` + +`SkillDefinition` currently contains `name`, `description`, `instructions`, optional `files`, and optional `metadata`. + +Skill request context uses: + +```ts +skillContext.skills +skillContext.skillNames +skillContext.runtimeTools +``` + +## Important Files + +- `packages/kit/src/skills/types.ts` +- `packages/kit/src/skills/compiler.ts` +- `packages/kit/src/skills/skillLoader.ts` +- `packages/kit/src/skills/manager.ts` +- `packages/kit/src/skills/fsSkillFiles.ts` +- `packages/kit/src/skills/browserSkillFiles.ts` +- `packages/kit/src/skills/index.ts` +- `packages/kit/src/skills/README.md` +- `packages/kit/src/skills/test/compiler.test.ts` +- `packages/kit/src/skills/test/skillLoader.test.ts` +- `packages/kit/src/skills/test/skillManager.test.ts` +- `packages/kit/src/skills/test/skillPlugin.test.ts` +- `packages/kit/src/message/plugins/skillPlugin.ts` + +## Validation + +Run from `packages/kit`: + +```bash +pnpm lint +pnpm test +pnpm build +``` + +## Near-Term Next Steps + +- Add `read_skill_file` size limits and truncation strategy. +- Decide where duplicate skill name diagnostics belong, preferably in manager or selection logic rather than compiler. +- Decide whether auto skill selection needs an independent selector layer. +- Keep manager boundaries separate from compiler boundaries. diff --git a/docs/.vitepress/themeConfig.ts b/docs/.vitepress/themeConfig.ts index b7e5049d5..3ab14a0c2 100644 --- a/docs/.vitepress/themeConfig.ts +++ b/docs/.vitepress/themeConfig.ts @@ -36,6 +36,7 @@ const sharedSidebarItems = [ items: [ { text: 'useMessage 消息数据管理', link: 'message' }, { text: 'useConversation 会话数据管理', link: 'conversation' }, + { text: 'Skill 技能工具链', link: 'skill' }, { text: 'AIClient 模型交互工具类', link: 'ai-client' }, { text: '工具函数', link: 'utils' }, { text: 'CLI 命令行工具', link: 'cli' }, diff --git a/docs/demos/tools/skill/SkillInspector.css b/docs/demos/tools/skill/SkillInspector.css new file mode 100644 index 000000000..f1be68c19 --- /dev/null +++ b/docs/demos/tools/skill/SkillInspector.css @@ -0,0 +1,278 @@ +.skill-inspector { + container-type: inline-size; + display: grid; + grid-template-columns: minmax(240px, 320px) minmax(0, 1fr); + gap: 16px; +} + +.skill-inspector .panel { + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + background: var(--vp-c-bg); + padding: 16px; +} + +.skill-inspector .panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.skill-inspector .panel-heading h3 { + margin: 0 0 4px; + font-size: 16px; +} + +.skill-inspector .panel-heading p { + margin: 0; + color: var(--vp-c-text-2); + font-size: 13px; + line-height: 1.6; +} + +.skill-inspector .right-panel { + display: flex; + flex-direction: column; + min-width: 0; + gap: 0; + padding: 0; + overflow: hidden; +} + +.skill-inspector .right-panel .right-tab-header { + display: flex; + margin: 0; + border-bottom: 1px solid var(--vp-c-divider); +} + +.skill-inspector .right-panel .right-tab-header button { + flex: 1; + padding: 10px 16px; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + background: transparent; + color: var(--vp-c-text-2); + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: color 0.15s, border-color 0.15s; +} + +.skill-inspector .right-panel .right-tab-header button:hover { + color: var(--vp-c-text-1); +} + +.skill-inspector .right-panel .right-tab-header button.active { + color: var(--vp-c-brand-1); + border-bottom-color: var(--vp-c-brand-1); +} + +.skill-inspector .right-tab-content { + display: flex; + flex-direction: column; + padding: 16px; +} + +.skill-inspector .right-tab-content pre { + flex: 1; +} + +.skill-inspector .primary-action, +.skill-inspector .tabs button { + border: 1px solid var(--vp-c-divider); + border-radius: 6px; + background: var(--vp-c-bg-soft); + color: var(--vp-c-text-1); + cursor: pointer; + font-size: 13px; + line-height: 1; +} + +.skill-inspector .primary-action { + flex: none; + padding: 8px 12px; +} + +.skill-inspector .primary-action:hover, +.skill-inspector .tabs button:hover, +.skill-inspector .tabs button.active { + border-color: var(--vp-c-brand-1); + color: var(--vp-c-brand-1); +} + +.skill-inspector .directory-picker { + display: flex; + align-items: center; + justify-content: center; + min-height: 72px; + border: 1px dashed var(--vp-c-divider); + border-radius: 8px; + color: var(--vp-c-text-2); + cursor: pointer; + font-size: 14px; +} + +.skill-inspector .directory-picker input { + display: none; +} + +.skill-inspector .error-message { + margin: 10px 0 0; + color: var(--vp-c-danger-1); + font-size: 13px; +} + +.skill-inspector .skill-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 14px; +} + +.skill-inspector .skill-item { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + cursor: pointer; +} + +.skill-inspector .skill-item input { + flex: none; + margin-top: 3px; + cursor: pointer; +} + +.skill-inspector .skill-item.active { + border-color: var(--vp-c-brand-1); + box-shadow: 0 0 0 1px var(--vp-c-brand-1) inset; + background: var(--vp-c-bg-soft); +} + +.skill-inspector .skill-item strong, +.skill-inspector .skill-item small { + display: block; +} + +.skill-inspector .skill-item small { + margin-top: 4px; + color: var(--vp-c-text-2); + line-height: 1.5; +} + +.skill-inspector .summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 8px; + margin-bottom: 14px; +} + +.skill-inspector .summary-grid div { + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + padding: 10px; +} + +.skill-inspector .summary-grid span, +.skill-inspector .summary-grid strong { + display: block; +} + +.skill-inspector .summary-grid span { + color: var(--vp-c-text-2); + font-size: 12px; +} + +.skill-inspector .summary-grid strong { + margin-top: 4px; + word-break: break-word; +} + +.skill-inspector .selected-skills { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 10px; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + padding: 10px; +} + +.skill-inspector .selected-skills span { + flex: none; + color: var(--vp-c-text-2); + font-size: 12px; + line-height: 24px; +} + +.skill-inspector .selected-skills div { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-width: 0; +} + +.skill-inspector .selected-skills strong { + border-radius: 999px; + background: var(--vp-c-bg-soft); + color: var(--vp-c-brand-1); + padding: 4px 8px; + font-size: 12px; + line-height: 16px; +} + +.skill-inspector .selected-skills em { + color: var(--vp-c-text-3); + font-size: 13px; + font-style: normal; + line-height: 24px; +} + +.skill-inspector .tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; +} + +.skill-inspector .tabs button { + padding: 7px 10px; +} + +.skill-inspector pre { + min-height: 240px; + max-height: 420px; + margin: 0; + overflow: auto; + border-radius: 8px; + background: var(--vp-code-block-bg); + padding: 14px; + color: var(--vp-code-block-color); + font-size: 13px; + line-height: 1.6; +} + +@media (max-width: 768px) { + .skill-inspector { + grid-template-columns: 1fr; + } + + .skill-inspector .panel-heading { + flex-direction: column; + } + + .skill-inspector .primary-action { + width: 100%; + } +} + +@container (max-width: 720px) { + .skill-inspector { + grid-template-columns: 1fr; + } +} diff --git a/docs/demos/tools/skill/SkillInspector.vue b/docs/demos/tools/skill/SkillInspector.vue new file mode 100644 index 000000000..3f443515e --- /dev/null +++ b/docs/demos/tools/skill/SkillInspector.vue @@ -0,0 +1,121 @@ + + + + + + 导入与管理 + 从示例或本地目录导入 skill,再用 manager 选择本次要编译的 skill。 + + 导入示例 skill + + + + + 选择本地 skill 目录 + + + {{ errorMessage }} + + + + + + {{ skill.name }} + {{ skill.description }} + + + + + + + + + + 当前 Skill + + + Compiler 输出 + + + + + + + Name + {{ inspectedSkill?.name || '-' }} + + + Files + {{ inspectedSkill?.files?.length ?? 0 }} + + + + {{ inspectedDefinitionJson }} + + + + + Selected skills + + {{ skillName }} + None + + + + + + {{ tab.label }} + + + + {{ compiledInstructionsText }} + {{ compiledToolsJson }} + + + + + + + diff --git a/docs/demos/tools/skill/VueSkillPlugin.css b/docs/demos/tools/skill/VueSkillPlugin.css new file mode 100644 index 000000000..9175e2377 --- /dev/null +++ b/docs/demos/tools/skill/VueSkillPlugin.css @@ -0,0 +1,137 @@ +.skill-chat-demo { + container-type: inline-size; + display: grid; + grid-template-columns: 1fr; + gap: 16px; +} + +.skill-chat-demo .chat-area { + display: flex; + flex-direction: column; + min-height: 400px; +} + +.skill-chat-demo .chat-area > :first-child { + flex: 1; + max-height: 480px; +} + +.skill-chat-demo .skill-sidebar { + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + background: var(--vp-c-bg); + padding: 14px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.skill-chat-demo .sidebar-section { + min-width: 0; +} + +.skill-chat-demo .sidebar-section h3 { + font-size: 14px; + margin: 0 0 4px; +} + +.skill-chat-demo .sidebar-hint { + color: var(--vp-c-text-2); + font-size: 12px; + line-height: 1.5; + margin: 0 0 10px; +} + +.skill-chat-demo .skill-options { + display: flex; + flex-direction: column; + gap: 6px; +} + +.skill-chat-demo .skill-option { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 8px; + border: 1px solid var(--vp-c-divider); + border-radius: 6px; + cursor: pointer; +} + +.skill-chat-demo .skill-option input { + flex: none; + margin-top: 2px; +} + +.skill-chat-demo .skill-option strong, +.skill-chat-demo .skill-option small { + display: block; +} + +.skill-chat-demo .skill-option strong { + font-size: 12px; +} + +.skill-chat-demo .skill-option small { + margin-top: 2px; + color: var(--vp-c-text-2); + font-size: 11px; + line-height: 1.4; +} + +.skill-chat-demo .selected-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + margin-bottom: 10px; +} + +.skill-chat-demo .selected-summary span { + color: var(--vp-c-text-2); + font-size: 11px; +} + +.skill-chat-demo .selected-summary strong { + border-radius: 999px; + background: var(--vp-c-bg-soft); + color: var(--vp-c-brand-1); + padding: 2px 6px; + font-size: 11px; + line-height: 16px; +} + +.skill-chat-demo .selected-summary em { + color: var(--vp-c-text-3); + font-size: 12px; + font-style: normal; +} + +.skill-chat-demo .subsection-title { + margin: 0 0 4px; + font-size: 12px; + color: var(--vp-c-text-2); +} + +.skill-chat-demo .sidebar-pre { + margin: 0 0 10px; + overflow: auto; + border-radius: 6px; + background: var(--vp-code-block-bg); + padding: 8px; + color: var(--vp-code-block-color); + font-size: 11px; + line-height: 1.5; + min-height: 60px; + max-height: 150px; +} + +.skill-chat-demo .sidebar-pre:last-child { + margin-bottom: 0; +} + +@container (max-width: 640px) { + .skill-chat-demo .skill-sidebar { + grid-template-columns: 1fr; + } +} diff --git a/docs/demos/tools/skill/VueSkillPlugin.vue b/docs/demos/tools/skill/VueSkillPlugin.vue new file mode 100644 index 000000000..5c6641aec --- /dev/null +++ b/docs/demos/tools/skill/VueSkillPlugin.vue @@ -0,0 +1,182 @@ + + + + + + + + + + + diff --git a/docs/demos/tools/skill/exampleSkillFiles.ts b/docs/demos/tools/skill/exampleSkillFiles.ts new file mode 100644 index 000000000..a946981e1 --- /dev/null +++ b/docs/demos/tools/skill/exampleSkillFiles.ts @@ -0,0 +1,22 @@ +import type { SkillFile } from '@opentiny/tiny-robot-kit/core' + +export const exampleSkillFiles: SkillFile[] = [ + { + path: 'SKILL.md', + kind: 'text', + content: `--- +name: weather +description: Answer weather questions with concise current conditions and forecast guidance. +--- + +# Weather Skill + +Use this skill when the user asks about weather, temperature, rain, wind, or forecast. +Always mention the target location and keep the answer concise.`, + }, + { + path: 'references/weather-format.md', + kind: 'text', + content: 'Return current condition first, then list the next forecast point when available.', + }, +] diff --git a/docs/demos/tools/skill/useSkillInspector.ts b/docs/demos/tools/skill/useSkillInspector.ts new file mode 100644 index 000000000..61232d99e --- /dev/null +++ b/docs/demos/tools/skill/useSkillInspector.ts @@ -0,0 +1,129 @@ +import { + SkillManager, + compileSkillInstructions, + createSkillRuntimeTools, + loadSkillFilesFromFileList, +} from '@opentiny/tiny-robot-kit/core' +import type { SkillDefinition, SkillFile } from '@opentiny/tiny-robot-kit/core' +import { computed, ref, watch } from 'vue' +import { exampleSkillFiles } from './exampleSkillFiles' + +export const compilerOutputTabs = [ + { label: 'Instructions', value: 'instructions' }, + { label: 'Runtime tools', value: 'tools' }, +] as const + +type CompilerOutputTab = (typeof compilerOutputTabs)[number]['value'] + +export const useSkillInspector = () => { + const manager = new SkillManager() + const skills = ref([]) + const selectedSkillNames = ref([]) + const inspectedSkillName = ref('') + const compilerTab = ref('instructions') + const rightTab = ref<'skill' | 'compiler'>('skill') + const errorMessage = ref('') + const compiledInstructionsText = ref('') + + const syncManagerState = () => { + skills.value = manager.list() + selectedSkillNames.value = manager.getSelectedSkillNames() + } + + const importSkillFiles = (files: SkillFile[]) => { + errorMessage.value = '' + + try { + const result = manager.import(files) + manager.select(result.skill.name) + inspectedSkillName.value = result.skill.name + syncManagerState() + } catch (error) { + errorMessage.value = error instanceof Error ? error.message : String(error) + } + } + + const loadExampleSkill = () => { + importSkillFiles(exampleSkillFiles) + } + + const handleDirectoryChange = async (event: Event) => { + const input = event.target as HTMLInputElement + if (!input.files?.length) { + return + } + + try { + importSkillFiles(await loadSkillFilesFromFileList(input.files)) + } catch (error) { + errorMessage.value = error instanceof Error ? error.message : String(error) + } finally { + input.value = '' + } + } + + const toggleSkill = (skillName: string, checked: boolean) => { + if (checked) { + manager.select(skillName) + } else { + manager.unselect(skillName) + } + + syncManagerState() + } + + const inspectSkill = (skillName: string) => { + inspectedSkillName.value = skillName + } + + const toggleSkillFromEvent = (skillName: string, event: Event) => { + toggleSkill(skillName, (event.target as HTMLInputElement).checked) + } + + const selectedSkills = computed(() => + selectedSkillNames.value.flatMap((skillName) => { + const skill = manager.get(skillName) + return skill ? [skill] : [] + }), + ) + + const inspectedSkill = computed(() => { + return manager.get(inspectedSkillName.value) ?? skills.value[0] + }) + + const inspectedDefinitionJson = computed(() => JSON.stringify(inspectedSkill.value ?? null, null, 2)) + + const compiledToolsJson = computed(() => { + const tools = createSkillRuntimeTools(selectedSkills.value).map((runtimeTool) => runtimeTool.tool) + return JSON.stringify(tools, null, 2) + }) + + watch( + selectedSkills, + async (currentSkills) => { + const message = await compileSkillInstructions(currentSkills) + compiledInstructionsText.value = message ? JSON.stringify(message, null, 2) : 'undefined' + }, + { immediate: true }, + ) + + loadExampleSkill() + + return { + compilerTab, + compilerTabs: compilerOutputTabs, + compiledInstructionsText, + compiledToolsJson, + errorMessage, + handleDirectoryChange, + inspectSkill, + inspectedDefinitionJson, + inspectedSkill, + inspectedSkillName, + loadExampleSkill, + rightTab, + selectedSkillNames, + skills, + toggleSkillFromEvent, + } +} diff --git a/docs/package.json b/docs/package.json index 25985e012..3e72eec86 100644 --- a/docs/package.json +++ b/docs/package.json @@ -3,7 +3,7 @@ "private": true, "scripts": { "dev": "cross-env VP_MODE=development vitepress dev", - "build": "cross-env VP_MODE=production vitepress build", + "build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 VP_MODE=production vitepress build", "preview": "vitepress preview" }, "devDependencies": { diff --git a/docs/src/tools/message.md b/docs/src/tools/message.md index 1b4c14a4e..fd448b754 100644 --- a/docs/src/tools/message.md +++ b/docs/src/tools/message.md @@ -247,10 +247,14 @@ useMessage({ 用于接入模型返回的 `tool_calls`:在请求前注入 `tools` 列表,在请求完成后解析 `tool_calls`、执行 `callTool`、追加 tool 消息并自动发起下一轮请求。支持取消/失败时补充或标记 tool 消息、下一轮是否排除 tool 消息等。**需显式添加到 `plugins` 数组才会生效**。 +`toolPlugin` 也是 message 插件体系中的工具聚合入口。除自身的 `getTools` 外,具备工具能力的插件可以通过 `ToolProvider` 协议暴露 `provideTools(context)`,让 `toolPlugin` 在 `onBeforeRequest` 阶段统一收集并写入最终发送给模型的 `requestBody.tools`。这适合让能力型插件按自己的状态提供工具,例如 skill 文件工具、运行时工具或业务上下文相关工具。 + +工具来源会写入工具调用上下文的 `toolSource` 字段,便于在 `callTool`、`onToolCallStart`、`onToolCallEnd` 中做日志、分流或调试。`toolPlugin.getTools` 提供的工具来源为 `{ type: 'toolPlugin' }`;其他插件通过 `ToolProvider.provideTools` 提供的工具来源为 `{ type: 'toolProvider', pluginName?: string }`;无法识别来源时为 `{ type: 'unknown' }`。 + | 参数 | 类型 | 必填 | 默认值 | 说明 | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `getTools` | `() => Promise` | 是 | - | 返回当前轮次要传给 API 的工具列表(OpenAI 格式)。 | -| `callTool` | `(toolCall, context) => Promise> \| AsyncGenerator>` | 是 | - | 执行单个工具调用,返回结果字符串或可流式返回的对象,结果会合并到对应 tool 消息的 `content`。 | +| `getTools` | `() => Promise>` | 是 | - | 返回当前轮次要传给 API 的工具列表。可以返回普通 OpenAI tool schema,也可以返回带执行函数的 runtime tool。 | +| `callTool` | `(toolCall, context) => Promise> \| AsyncGenerator>` | 是 | - | 执行单个工具调用,返回结果字符串或可流式返回的对象,结果会合并到对应 tool 消息的 `content`。可通过 `context.toolSource` 判断工具来源。 | | `beforeCallTools` | `(toolCalls, context) => Promise` | 否 | - | 在真正执行工具前调用,可用于统一校验、鉴权、埋点。新字段为 `context.assistantMessage`;`context.currentMessage` 继续保留,但已弃用。 | | `onToolCallStart` | `(toolCall, context) => void` | 否 | - | 单个工具开始执行时触发。此时对应的 tool 消息已经创建并追加到 `messages` 中;`context` 额外包含 `assistantMessage`、`primaryMessage`(兼容字段)和 `toolMessage`。 | | `onToolCallEnd` | `(toolCall, context) => void` | 否 | - | 单个工具执行结束时触发。`context.status` 为 `'success' \| 'failed' \| 'cancelled'`,并额外包含 `assistantMessage`、`primaryMessage`(兼容字段)和 `toolMessage`,失败或取消时可能有 `context.error`。 | @@ -263,9 +267,20 @@ useMessage({ | 回调 | 额外上下文字段 | 说明 | | ----------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `beforeCallTools` | `assistantMessage`、`currentMessage`(已弃用) | 在 `BasePluginContext` 基础上额外包含当前这条带 `tool_calls` 的 assistant 消息。推荐使用 `assistantMessage`;`currentMessage` 为兼容旧代码保留。 | -| `callTool` | `assistantMessage`、`currentMessage`(已弃用)、`toolMessage` | 在 `BasePluginContext` 基础上额外包含当前这条带 `tool_calls` 的 assistant 消息,以及当前工具对应的 `toolMessage`。推荐使用 `assistantMessage`;`currentMessage` 为兼容旧代码保留。 | -| `onToolCallStart` | `assistantMessage`、`primaryMessage`(兼容字段)、`toolMessage` | 在 `BasePluginContext` 基础上额外包含触发当前工具调用的 assistant 消息和当前 tool 消息。推荐使用 `assistantMessage`;`primaryMessage` 为兼容旧代码保留。 | -| `onToolCallEnd` | `assistantMessage`、`primaryMessage`(兼容字段)、`toolMessage`、`status`、`error?` | 在 `BasePluginContext` 基础上额外包含 assistant 消息、当前 tool 消息和执行状态;当工具执行失败或被取消时,还可能包含 `error`。推荐使用 `assistantMessage`;`primaryMessage` 为兼容旧代码保留。 | +| `callTool` | `assistantMessage`、`currentMessage`(已弃用)、`toolMessage`、`toolSource` | 在 `BasePluginContext` 基础上额外包含当前这条带 `tool_calls` 的 assistant 消息、当前工具对应的 `toolMessage` 和工具来源。推荐使用 `assistantMessage`;`currentMessage` 为兼容旧代码保留。 | +| `onToolCallStart` | `assistantMessage`、`primaryMessage`(兼容字段)、`toolMessage`、`toolSource` | 在 `BasePluginContext` 基础上额外包含触发当前工具调用的 assistant 消息、当前 tool 消息和工具来源。推荐使用 `assistantMessage`;`primaryMessage` 为兼容旧代码保留。 | +| `onToolCallEnd` | `assistantMessage`、`primaryMessage`(兼容字段)、`toolMessage`、`toolSource`、`status`、`error?` | 在 `BasePluginContext` 基础上额外包含 assistant 消息、当前 tool 消息、工具来源和执行状态;当工具执行失败或被取消时,还可能包含 `error`。推荐使用 `assistantMessage`;`primaryMessage` 为兼容旧代码保留。 | + +`toolSource` 类型: + +```typescript +type ToolSource = + | { type: 'toolPlugin' } + | { type: 'toolProvider'; pluginName?: string } + | { type: 'unknown' } +``` + +`ToolProvider` 是供插件扩展使用的高级协议。对于使用 `toolPlugin` 的业务代码,通常只需要通过 `getTools` 和 `callTool` 接入工具;当插件本身需要按内部状态向模型暴露工具时,再实现 `provideTools(context)`。 ##### 基础示例 @@ -292,8 +307,9 @@ useMessage({ }, }, ], - callTool: async (toolCall) => { + callTool: async (toolCall, context) => { const args = JSON.parse(toolCall.function?.arguments || '{}') + console.log('Tool source:', context.toolSource) return `Weather of ${args.city}: Sunny.` }, onToolCallEnd: (toolCall, { status }) => console.log('Tool end:', status), diff --git a/docs/src/tools/skill.md b/docs/src/tools/skill.md new file mode 100644 index 000000000..1ccce784d --- /dev/null +++ b/docs/src/tools/skill.md @@ -0,0 +1,487 @@ +--- +outline: [1, 3] +--- + +# Skill 技能工具链 + +Skill 是一组可复用的能力模板。一个 skill 至少包含名称、描述和指令,也可以携带文件资源。`@opentiny/tiny-robot-kit` 中的 skill 工具链分为三层: + +- **File Adapters**:把不同平台的文件来源转换为统一的 `SkillFile[]`。 +- **Loader / Manager**:把 `SkillFile[]` 解析为 `SkillDefinition`,并管理 skill 集合与选择状态。 +- **Compiler**:把已选 `SkillDefinition[]` 编译为 message engine 可消费的 instructions 和运行时文件工具。 + +`SkillManager` 是可选中间层。如果业务侧已经有自己的状态管理,可以直接把 `SkillDefinition[]` 交给 compiler 或 `skillPlugin`。 + +```text +File Adapter -> Loader -------> SkillDefinition[] -> Compiler -> message engine + | ^ + | optional | + v | + SkillManager -----------+ +``` + +## 基本数据模型 + +```typescript +interface SkillDefinition { + name: string + description: string + instructions: string + files?: SkillFile[] + metadata?: Record +} +``` + +- `name`:skill 唯一名称,用于去重、选择和读取文件资源。 +- `description`:能力描述,适合用于 UI 展示、搜索或后续自动选择 skill。 +- `instructions`:注入模型请求的核心指令,必填。 +- `files`:skill 目录中的附加文件资源,可通过基础文件工具读取。 + +## 完整示例 + +下面的示例把 loader、manager、compiler 放在同一条链路中展示:导入 skill 目录后,loader 解析出 `SkillDefinition`,manager 保存并选择 skill,compiler 输出最终会注入给模型的 system message 和基础文件工具。 + + + +## Loader + +Loader 的职责是把标准化后的 `SkillFile[]` 解析为 `SkillDefinition`。它不负责读取本地文件、浏览器文件或远程资源;这些工作由 file adapters 完成。 + +### Node.js 目录加载 + +`loadSkillFilesFromFs` 会把本地目录读取为 `SkillFile[]`,再交给 `SkillLoader` 解析。 + +```typescript +import { SkillLoader } from '@opentiny/tiny-robot-kit/core' +import { loadSkillFilesFromFs } from '@opentiny/tiny-robot-kit/node' + +const files = await loadSkillFilesFromFs('/path/to/weather-skill') +const result = new SkillLoader().load(files) + +console.log(result.skill.name) +console.log(result.skill.description) +console.log(result.skill.instructions) +console.log(result.warnings) +``` + +### Browser 文件加载 + +浏览器侧可以把 `` 选择出的文件列表转换为 `SkillFile[]`。 + +```typescript +import { SkillLoader, loadSkillFilesFromFileList } from '@opentiny/tiny-robot-kit/core' + +async function importFromInput(input: HTMLInputElement) { + if (!input.files) { + return + } + + const files = await loadSkillFilesFromFileList(input.files) + return new SkillLoader().load(files) +} +``` + +如果使用 `window.showDirectoryPicker()`,可以使用 `loadSkillFilesFromDirectoryHandle`: + +```typescript +import { SkillLoader, loadSkillFilesFromDirectoryHandle } from '@opentiny/tiny-robot-kit/core' + +const directoryHandle = await window.showDirectoryPicker() +const files = await loadSkillFilesFromDirectoryHandle(directoryHandle) +const result = new SkillLoader().load(files) +``` + +### SKILL.md 结构 + +`SkillLoader` 默认读取 `SKILL.md` 作为入口文件。frontmatter 中的 `name` 和 `description` 会写入 `SkillDefinition`,正文会作为必填 `instructions`。 + +````markdown +--- +name: weather +description: Get current weather and forecast information. +homepage: https://wttr.in/:help +--- + +# Weather Skill + +Use wttr.in when the user asks about current weather or forecasts. +Prefer concise answers and include the location in the response. +```` + +### Warning 和严格模式 + +非致命问题会放到 `SkillLoaderResult.warnings` 中,例如重复路径、无法解析工具文件等。 + +```typescript +const result = new SkillLoader().load(files) + +for (const warning of result.warnings) { + console.warn(warning.code, warning.path, warning.message) +} +``` + +如果希望 warning 直接抛出为错误,可以启用严格模式: + +```typescript +const result = new SkillLoader({ strict: true }).load(files) +``` + +## Manager + +`SkillManager` 是框架无关的 skill 集合管理工具。它只负责保存、删除、导入、选择 skills,不编译 prompt 或 tools,也不接入 message 生命周期。 + +### 管理 skill 集合 + +```typescript +import { SkillManager } from '@opentiny/tiny-robot-kit/core' + +const manager = new SkillManager() + +manager.set({ + name: 'weather', + description: 'Get current weather information.', + instructions: 'Use weather context when the user asks about weather.', +}) + +console.log(manager.has('weather')) // true +console.log(manager.get('weather')) +console.log(manager.list()) + +manager.remove('weather') +``` + +`set(skill)` 是直接写入入口:不存在时新增,同名存在时覆盖。需要从 `SkillFile[]` 解析并写入时,使用下面的 `import(files)`。 + +### 选择本次请求使用的 skills + +manager 内部可以维护选择状态。这个状态适合由 UI 或业务逻辑驱动,再交给 `skillPlugin` 读取。 + +```typescript +manager.set(weatherSkill) +manager.set(vueSkill) + +manager.select(['weather', 'vue-best-practices']) + +const selectedSkills = manager.getSelectedSkills() +const selectedSkillNames = manager.getSelectedSkillNames() + +manager.unselect('weather') +``` + +选择不存在的 skill 会抛错: + +```typescript +manager.select('missing-skill') // throws +``` + +### 导入 skill + +`SkillManager.import()` 会复用 `SkillLoader`,把 `SkillFile[]` 导入为 skill 并写入 manager。 + +```typescript +import { SkillManager } from '@opentiny/tiny-robot-kit/core' +import { loadSkillFilesFromFs } from '@opentiny/tiny-robot-kit/node' + +const manager = new SkillManager() +const files = await loadSkillFilesFromFs('/path/to/weather-skill') +const result = manager.import(files) + +console.log(result.skill.name) +console.log(manager.get(result.skill.name)) +``` + +可以透传 `SkillLoaderOptions`: + +```typescript +manager.import(files, { + entryFile: 'README.md', + strict: true, +}) +``` + +### 搭配 skillPlugin + +`SkillManager` 可以和 `skillPlugin` 一起使用。manager 负责选择,`skillPlugin` 负责把已选 skills 编译进 message engine。 + +```typescript +import { skillPlugin, useMessage } from '@opentiny/tiny-robot-kit' +import { SkillManager } from '@opentiny/tiny-robot-kit/core' + +const manager = new SkillManager({ + skills: [weatherSkill, vueSkill], + selectedSkillNames: ['weather'], +}) + +const message = useMessage({ + responseProvider, + plugins: [ + skillPlugin({ + getSkills: () => manager.getSelectedSkills(), + }), + ], +}) +``` + +在 Vue 中也可以直接传入响应式的 skills: + + + +## Compiler + +Compiler 是纯转换层:输入 `SkillDefinition[]`,输出 message engine 可消费的 instructions 和运行时文件工具。 + +### 编译 instructions + +```typescript +import { compileSkillInstructions } from '@opentiny/tiny-robot-kit/core' + +const systemMessage = await compileSkillInstructions([weatherSkill, vueSkill]) +``` + +编译后的结果是一个 system message: + +```typescript +{ + role: 'system', + content: 'Apply these skill instructions when generating the response.\n\n## weather\n\n...' +} +``` + +空白 instructions 会被忽略。如果没有任何可用 instructions,则返回 `undefined`。 + +### 创建基础文件工具 + +```typescript +import { createSkillRuntimeTools } from '@opentiny/tiny-robot-kit/core' + +const runtimeTools = createSkillRuntimeTools([docsSkill]) +``` + +当任意 skill 带有 `files` 时,会生成两个基础 runtime tools: + +- `list_skill_files`:列出当前 skills 携带的文件资源。 +- `read_skill_file`:按 `skillName` 和相对路径读取文本文件内容。 + +```typescript +import { createSkillRuntimeTools } from '@opentiny/tiny-robot-kit/core' + +const runtimeTools = createSkillRuntimeTools([docsSkill]) +const [listFiles, readFile] = runtimeTools + +// handler 的第一个参数来自模型返回的 tool_calls。 +// 这里手动构造该参数,只是为了展示工具执行效果。 +const listed = await listFiles.handler( + { + id: 'call_1', + type: 'function', + function: { + name: 'list_skill_files', + arguments: JSON.stringify({ skillName: 'docs' }), + }, + }, + {} as never, +) + +const content = await readFile.handler( + { + id: 'call_2', + type: 'function', + function: { + name: 'read_skill_file', + arguments: JSON.stringify({ + skillName: 'docs', + path: 'references/guide.md', + }), + }, + }, + {} as never, +) +``` + +二进制文件不会返回内容,只返回文件摘要和 `binary_file_not_readable` 错误。 + +## 与 message 插件体系的关系 + +`skillPlugin` 是 message runtime adapter。它不加载、不选择、不缓存、不管理 skills,只通过 `getSkills()` 接收本次请求要使用的 skills。 + +内部流程: + +1. `onTurnStart`:读取 `getSkills()`,创建基础文件工具,并把 skills 与 runtime tools 写入 `customContext.__tiny_robot_skill`。 +2. `provideTools`:从插件状态中读取 runtime tools,并暴露给 message engine。 +3. `onBeforeRequest`:调用 `compileSkillInstructions(skills)`,把 system message 插入到请求消息最前面。 + +```typescript +import { skillPlugin, useMessage } from '@opentiny/tiny-robot-kit' + +useMessage({ + responseProvider, + plugins: [ + skillPlugin({ + getSkills: () => manager.getSelectedSkills(), + }), + ], +}) +``` + +## API + +### 核心类型 + +```typescript +type SkillFileKind = 'text' | 'binary' + +interface BaseSkillFile { + path: string + mimeType?: string + size?: number + lastModified?: number + metadata?: Record +} + +interface TextSkillFile extends BaseSkillFile { + kind: 'text' + content: string +} + +interface BinarySkillFile extends BaseSkillFile { + kind: 'binary' + content: ArrayBuffer | Uint8Array +} + +type SkillFile = TextSkillFile | BinarySkillFile + +interface SkillDefinition { + name: string + description: string + instructions: string + files?: SkillFile[] + metadata?: Record +} +``` + +### 文件适配器 + +浏览器安全的文件适配器从 `@opentiny/tiny-robot-kit/core` 导出: + +```typescript +function loadSkillFilesFromFileList(fileList: ArrayLike): Promise + +function loadSkillFilesFromDirectoryHandle(directoryHandle: BrowserDirectoryHandle): Promise +``` + +Node.js 文件系统适配器从 `@opentiny/tiny-robot-kit/node` 导出: + +```typescript +interface FsSkillFilesOptions { + ignoredDirectories?: string[] +} + +function loadSkillFilesFromFs(root: string, options?: FsSkillFilesOptions): Promise +``` + +### SkillLoader + +```typescript +interface SkillLoaderOptions { + entryFile?: string + strict?: boolean +} + +interface SkillLoaderResult { + skill: SkillDefinition + warnings: Array<{ + code: string + message: string + path?: string + }> +} + +class SkillLoader { + constructor(options?: SkillLoaderOptions) + load(files: SkillFile[]): SkillLoaderResult +} +``` + +### SkillManager + +```typescript +interface SkillManagerOptions { + skills?: SkillDefinition[] + selectedSkillNames?: string[] +} + +class SkillManager { + constructor(options?: SkillManagerOptions) + + set(skill: SkillDefinition): SkillDefinition + remove(name: string): SkillDefinition | undefined + clear(): void + + get(name: string): SkillDefinition | undefined + has(name: string): boolean + list(): SkillDefinition[] + + select(names: string | string[]): void + unselect(names: string | string[]): void + getSelectedSkillNames(): string[] + getSelectedSkills(): SkillDefinition[] + + import(files: SkillFile[], options?: SkillLoaderOptions): SkillLoaderResult +} +``` + +### Compiler + +```typescript +function compileSkillInstructions( + skills: SkillDefinition[], +): Promise + +function createSkillRuntimeTools(skills: SkillDefinition[]): RuntimeTool[] +``` + +`compileSkillInstructions` 会把 skill instructions 编译成 system message。`createSkillRuntimeTools` 会根据 `files` 生成基础文件 runtime tools;如果没有任何可用文件,则返回空数组。 + +### skillPlugin + +```typescript +interface SkillRequestContext { + skills: SkillDefinition[] + skillNames: string[] + runtimeTools: RuntimeTool[] +} + +interface SkillPluginOptions extends MessageEnginePlugin { + getSkills?: (context: BasePluginContext) => MaybePromise + onSkillsResolved?: (skillContext: SkillRequestContext, context: BasePluginContext) => MaybePromise +} + +function skillPlugin(options: SkillPluginOptions): MessageEnginePlugin & ToolProvider +``` + +Vue 入口还支持直接传入响应式的 `skills`: + +```typescript +type VueSkillSource = SkillDefinition[] | undefined +type VueSkillSourceRef = VueSkillSource | Ref | ComputedRef + +interface UseMessageSkillPluginOptions extends UseMessagePlugin { + skills?: VueSkillSourceRef + getSkills?: (context: BasePluginContext) => MaybePromise + onSkillsResolved?: (skillContext: SkillRequestContext, context: BasePluginContext) => MaybePromise +} +``` diff --git a/packages/kit/package.json b/packages/kit/package.json index 2c4ba37a8..313eb3c88 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -44,6 +44,11 @@ "types": "./dist/core.d.ts", "import": "./dist/core.mjs", "require": "./dist/core.js" + }, + "./node": { + "types": "./dist/node.d.ts", + "import": "./dist/node.mjs", + "require": "./dist/node.js" } }, "files": [ @@ -51,8 +56,10 @@ ], "sideEffects": false, "scripts": { - "build": "tsup src/index.ts src/core.ts --format cjs,esm --dts --minify", - "dev": "tsup src/index.ts src/core.ts --format cjs,esm --dts --watch", + "build": "tsup src/index.ts src/core.ts src/node.ts --format cjs,esm --dts --minify", + "dev": "tsup src/index.ts src/core.ts src/node.ts --format cjs,esm --dts --watch", + "lint": "eslint src", + "pretest": "node scripts/download-skill-fixtures.mjs", "test": "vitest run", "test:watch": "vitest" }, @@ -69,6 +76,7 @@ "vue": ">=3.0.0" }, "dependencies": { - "idb": "^8.0.3" + "idb": "^8.0.3", + "yaml": "^2.8.3" } } diff --git a/packages/kit/scripts/download-skill-fixtures.mjs b/packages/kit/scripts/download-skill-fixtures.mjs new file mode 100644 index 000000000..f58cb140a --- /dev/null +++ b/packages/kit/scripts/download-skill-fixtures.mjs @@ -0,0 +1,128 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const cacheDirectory = join(__dirname, '../src/skills/test/.cache') + +const fixtures = [ + { + repo: 'openclaw/openclaw', + commit: '58672075219d09495de6489ad0821d276ac84f13', + sourcePath: 'skills/weather', + }, + { + repo: 'vuejs-ai/skills', + commit: 'b9d14d022da6a0a8bdcb824557f40bca6fbc1845', + sourcePath: 'skills/vue-best-practices', + }, +] + +const getFixtureTargetPath = (fixture) => { + const normalizedSourcePath = fixture.sourcePath.split('\\').join('/') + const targetName = normalizedSourcePath.split('/').filter(Boolean).at(-1) + + if (!targetName) { + throw new Error(`Invalid fixture source path: ${fixture.sourcePath}`) + } + + return join(cacheDirectory, targetName) +} + +const fetchJson = async (url) => { + const response = await fetch(url, { + headers: { + accept: 'application/vnd.github+json', + 'user-agent': '@opentiny/tiny-robot-kit skill fixture downloader', + }, + }) + + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`) + } + + return response.json() +} + +const fetchBytes = async (url) => { + const response = await fetch(url, { + headers: { + 'user-agent': '@opentiny/tiny-robot-kit skill fixture downloader', + }, + }) + + if (!response.ok) { + throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`) + } + + return new Uint8Array(await response.arrayBuffer()) +} + +const getMarkerPath = (targetPath) => join(targetPath, '.fixture-source.json') + +const hasCurrentFixture = async (fixture) => { + const targetPath = getFixtureTargetPath(fixture) + + try { + const marker = JSON.parse(await readFile(getMarkerPath(targetPath), 'utf8')) + return ( + marker.repo === fixture.repo && + marker.commit === fixture.commit && + marker.sourcePath === fixture.sourcePath + ) + } catch { + return false + } +} + +const downloadDirectory = async (fixture, sourcePath, targetPath) => { + const url = new URL(`https://api.github.com/repos/${fixture.repo}/contents/${sourcePath}`) + url.searchParams.set('ref', fixture.commit) + + const entries = await fetchJson(url) + if (!Array.isArray(entries)) { + throw new Error(`Expected directory listing for ${sourcePath}`) + } + + for (const entry of entries) { + const entryTargetPath = join(targetPath, entry.name) + + if (entry.type === 'dir') { + await downloadDirectory(fixture, entry.path, entryTargetPath) + continue + } + + if (entry.type !== 'file' || !entry.download_url) { + continue + } + + await mkdir(dirname(entryTargetPath), { recursive: true }) + await writeFile(entryTargetPath, await fetchBytes(entry.download_url)) + } +} + +for (const fixture of fixtures) { + const targetPath = getFixtureTargetPath(fixture) + + if (await hasCurrentFixture(fixture)) { + console.log(`Skill fixture already cached: ${fixture.sourcePath}@${fixture.commit}`) + continue + } + + console.log(`Downloading skill fixture: ${fixture.sourcePath}@${fixture.commit}`) + await rm(targetPath, { recursive: true, force: true }) + await mkdir(targetPath, { recursive: true }) + await downloadDirectory(fixture, fixture.sourcePath, targetPath) + await writeFile( + getMarkerPath(targetPath), + `${JSON.stringify( + { + repo: fixture.repo, + commit: fixture.commit, + sourcePath: fixture.sourcePath, + }, + null, + 2, + )}\n`, + ) +} diff --git a/packages/kit/src/core.ts b/packages/kit/src/core.ts index bdd87948e..6d900f71c 100644 --- a/packages/kit/src/core.ts +++ b/packages/kit/src/core.ts @@ -3,3 +3,4 @@ export * from './message/core' export * from './message/plugins' export * from './message/types' export { combineDeltaData, normalizeToAsyncGenerator } from './message/utils' +export * from './skills' diff --git a/packages/kit/src/message/core/engine.ts b/packages/kit/src/message/core/engine.ts index fcb467014..362d27c38 100644 --- a/packages/kit/src/message/core/engine.ts +++ b/packages/kit/src/message/core/engine.ts @@ -1,4 +1,4 @@ -import { ChatCompletion, ChatCompletionChunk } from 'openai/resources/index' +import { ChatCompletion, ChatCompletionChunk } from 'openai/resources' import { lengthPlugin, thinkingPlugin } from '../plugins' import { BasePluginContext, @@ -156,6 +156,7 @@ export const createMessageEngine = ( mutate, abortSignal, currentTurn: runtime.currentTurn, + plugins, customContext: runtime.customContext, setRequestState, setCustomContext, diff --git a/packages/kit/src/message/plugins/index.ts b/packages/kit/src/message/plugins/index.ts index b7efeef2c..081a9120a 100644 --- a/packages/kit/src/message/plugins/index.ts +++ b/packages/kit/src/message/plugins/index.ts @@ -1,3 +1,6 @@ export { lengthPlugin } from './lengthPlugin' +export { skillPlugin } from './skillPlugin' +export type { SkillPluginOptions, SkillRequestContext } from './skillPlugin' export { thinkingPlugin } from './thinkingPlugin' export { toolPlugin } from './toolPlugin' +export type { RuntimeTool, ToolCallContext, ToolProvider, ToolProviderItem, ToolSource } from './toolPlugin' diff --git a/packages/kit/src/message/plugins/skillPlugin.ts b/packages/kit/src/message/plugins/skillPlugin.ts new file mode 100644 index 000000000..de3fe35ce --- /dev/null +++ b/packages/kit/src/message/plugins/skillPlugin.ts @@ -0,0 +1,83 @@ +import { compileSkillInstructions, createSkillRuntimeTools } from '../../skills/compiler' +import type { SkillCommandRequest, SkillCommandResult } from '../../skills/compiler' +import type { SkillDefinition } from '../../skills/types' +import type { MaybePromise } from '../../types' +import type { BasePluginContext, MessageEnginePlugin } from '../types' +import type { RuntimeTool, ToolProvider } from './toolPlugin' + +/** + * 当前请求的 skill 上下文。 + * + * 该上下文会写入 customContext.__tiny_robot_skill,供消息钩子和插件回调读取同一份请求级数据。 + */ +export interface SkillRequestContext { + skills: SkillDefinition[] + skillNames: string[] + runtimeTools: RuntimeTool[] +} + +/** + * 将已选择的 skills 转换为消息指令和工具的配置项。 + */ +export type SkillPluginOptions = MessageEnginePlugin & { + /** + * 返回本次请求要使用的 skills。 + * + * 插件只转换返回的 skills;选择、存储和集合管理由调用方负责。 + */ + getSkills?: (context: BasePluginContext) => MaybePromise + /** + * 执行模型为某个 skill 规划的后端命令。 + * + * @experimental 该 API 仍在设计和验证中,命令协议、返回结构和安全边界后续可能调整。 + * + * 插件只负责暴露 execute_skill_command 并转发参数;沙箱、镜像和权限控制由调用方实现。 + */ + executeSkillCommand?: (request: SkillCommandRequest, context: BasePluginContext) => MaybePromise + /** + * skills 解析并规整为请求上下文后触发。 + */ + onSkillsResolved?: (skillContext: SkillRequestContext, context: BasePluginContext) => MaybePromise +} + +const skillPluginContextKey = '__tiny_robot_skill' + +export const skillPlugin = (options: SkillPluginOptions): MessageEnginePlugin & ToolProvider => { + const { getSkills, executeSkillCommand, onSkillsResolved, ...restOptions } = options + + return { + name: 'skill', + ...restOptions, + provideTools: async (context: BasePluginContext) => { + const skillContext = context.customContext[skillPluginContextKey] as SkillRequestContext | undefined + return skillContext?.runtimeTools ?? [] + }, + onTurnStart: async (context) => { + const skills = (await getSkills?.(context)) ?? [] + const skillContext: SkillRequestContext = { + skills, + skillNames: skills.map((skill) => skill.name), + runtimeTools: createSkillRuntimeTools(skills, { + executeSkillCommand: executeSkillCommand && ((request) => executeSkillCommand(request, context)), + }), + } + + context.setCustomContext({ [skillPluginContextKey]: skillContext }) + + await onSkillsResolved?.(skillContext, context) + return restOptions.onTurnStart?.(context) + }, + onBeforeRequest: async (context) => { + const skillContext = context.customContext[skillPluginContextKey] as SkillRequestContext | undefined + + if (skillContext) { + const skillInstructions = await compileSkillInstructions(skillContext.skills) + if (skillInstructions) { + context.requestBody.messages = [skillInstructions, ...context.requestBody.messages] + } + } + + return restOptions.onBeforeRequest?.(context) + }, + } satisfies MessageEnginePlugin & ToolProvider +} diff --git a/packages/kit/src/message/plugins/toolPlugin.ts b/packages/kit/src/message/plugins/toolPlugin.ts index 0748c1715..230f7d882 100644 --- a/packages/kit/src/message/plugins/toolPlugin.ts +++ b/packages/kit/src/message/plugins/toolPlugin.ts @@ -1,5 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { ChatCompletionMessageToolCall, ChatCompletionTool } from 'openai/resources/index' +import { + ChatCompletionFunctionTool, + ChatCompletionMessageFunctionToolCall, + ChatCompletionMessageToolCall, +} from 'openai/resources' +import type { MaybePromise } from '../../types' import type { BasePluginContext, ChatMessage, MessageEnginePlugin, MutateMessageStateFn } from '../types' import { combineDeltaData, normalizeToAsyncGenerator } from '../utils' @@ -8,9 +13,29 @@ type AssistantMessageWithState = ChatMessage< { toolCall?: Record> } > -type ToolCallContext = BasePluginContext & { +export type ToolSource = { type: 'toolPlugin' } | { type: 'toolProvider'; pluginName?: string } | { type: 'unknown' } + +export type ToolCallContext = BasePluginContext & { assistantMessage: AssistantMessageWithState toolMessage: ChatMessage + /** + * 当前工具的来源。 + */ + toolSource: ToolSource +} + +type ToolCallResult = string | Record +type ToolCallReturn = ToolCallResult | Promise | AsyncGenerator + +export interface RuntimeTool { + tool: ChatCompletionFunctionTool + handler: (toolCall: ChatCompletionMessageFunctionToolCall, context: ToolCallContext) => ToolCallReturn +} + +export type ToolProviderItem = ChatCompletionFunctionTool | RuntimeTool + +export interface ToolProvider { + provideTools: (context: BasePluginContext) => MaybePromise } /** @@ -99,9 +124,9 @@ function fillMissingToolMessages({ export const toolPlugin = ( options: MessageEnginePlugin & { /** - * 获取工具列表的函数。会在请求大模型前调用。 + * 获取本轮可用工具。可以返回普通 tool schema,也可以返回带执行函数的 runtime tool。 */ - getTools: () => Promise + getTools: (context: BasePluginContext) => MaybePromise /** * 在处理包含 tool_calls 的响应前调用。 */ @@ -115,7 +140,7 @@ export const toolPlugin = ( callTool: ( toolCall: ChatCompletionMessageToolCall, context: ToolCallContext, - ) => Promise> | AsyncGenerator> + ) => Promise | AsyncGenerator /** * 工具调用开始时的回调函数。 * 触发时机:工具消息已创建并追加后,调用 callTool 之前触发。 @@ -197,6 +222,86 @@ export const toolPlugin = ( onToolCallEnd?.(...args) } + const isFunctionToolCall = ( + toolCall: ChatCompletionMessageToolCall, + ): toolCall is ChatCompletionMessageFunctionToolCall => { + return toolCall.type === 'function' && 'function' in toolCall + } + + const isRuntimeTool = (tool: ToolProviderItem): tool is RuntimeTool => { + return Boolean(tool && typeof tool === 'object' && 'tool' in tool && 'handler' in tool) + } + + const getToolProvider = (plugin: MessageEnginePlugin): ToolProvider | undefined => { + const toolProvider = plugin as Partial + return typeof toolProvider.provideTools === 'function' ? (toolProvider as ToolProvider) : undefined + } + + const isPluginDisabled = (plugin: MessageEnginePlugin, context: BasePluginContext) => { + return typeof plugin.disabled === 'function' ? plugin.disabled(context) : Boolean(plugin.disabled) + } + + const resolveTools = async (context: BasePluginContext, existingTools: ChatCompletionFunctionTool[] = []) => { + const providedToolItems: Array<{ item: ToolProviderItem; source: ToolSource }> = [] + + for (const plugin of context.plugins) { + const toolProvider = getToolProvider(plugin) + if (!isPluginDisabled(plugin, context) && toolProvider) { + providedToolItems.push( + ...(await toolProvider.provideTools(context)).map((item) => ({ + item, + source: { + type: 'toolProvider' as const, + pluginName: plugin.name, + }, + })), + ) + } + } + + const toolItems = [ + ...providedToolItems, + ...(await getTools(context)).map((item) => ({ + item, + source: { type: 'toolPlugin' as const }, + })), + ] + const tools: ChatCompletionFunctionTool[] = [] + const runtimeToolMap = new Map() + const toolSourceMap = new Map() + const seenToolNames = new Set() + + const registerToolName = (tool: ChatCompletionFunctionTool) => { + const toolName = tool.function.name + + if (seenToolNames.has(toolName)) { + throw new Error( + `Duplicate tool name "${toolName}" detected. Tool names must be unique because tool calls are routed by function.name.`, + ) + } + + seenToolNames.add(toolName) + } + + existingTools.forEach(registerToolName) + + for (const { item: toolItem, source } of toolItems) { + const tool = isRuntimeTool(toolItem) ? toolItem.tool : toolItem + + registerToolName(tool) + toolSourceMap.set(tool.function.name, source) + + if (isRuntimeTool(toolItem)) { + tools.push(toolItem.tool) + runtimeToolMap.set(toolItem.tool.function.name, toolItem) + } else { + tools.push(toolItem) + } + } + + return { tools, runtimeToolMap, toolSourceMap } + } + return { name: 'tool', ...restOptions, @@ -213,9 +318,10 @@ export const toolPlugin = ( onBeforeRequest: async (context) => { const { requestBody } = context - const tools = await getTools() + const existingTools = Array.isArray(requestBody.tools) ? requestBody.tools : [] + const { tools } = await resolveTools(context, existingTools) if (tools && tools.length > 0) { - requestBody.tools = tools + requestBody.tools = existingTools.length ? [...existingTools, ...tools] : tools } return restOptions.onBeforeRequest?.(context) @@ -242,6 +348,8 @@ export const toolPlugin = ( assistantMessage: currentMessage as AssistantMessageWithState, }) + const { runtimeToolMap, toolSourceMap } = await resolveTools(context) + const toolCallPromises = currentMessage.tool_calls.map(async (toolCall) => { const now = Math.floor(Date.now() / 1000) let hasMeaningfulResult = false @@ -257,15 +365,25 @@ export const toolPlugin = ( appendMessage(toolMessage) - const contextWithToolMessage = { + const functionToolCall = isFunctionToolCall(toolCall) ? toolCall : undefined + const toolSource = functionToolCall + ? (toolSourceMap.get(functionToolCall.function.name) ?? { type: 'unknown' as const }) + : { type: 'unknown' as const } + + const contextWithToolMessage: ToolCallContext = { ...context, assistantMessage: currentMessage as AssistantMessageWithState, toolMessage, + toolSource, } toolCallStart(toolCall, contextWithToolMessage) try { - const result = callTool(toolCall, contextWithToolMessage) + const runtimeTool = functionToolCall ? runtimeToolMap.get(functionToolCall.function.name) : undefined + const result = + runtimeTool && functionToolCall + ? runtimeTool.handler(functionToolCall, contextWithToolMessage) + : callTool(toolCall, contextWithToolMessage) // 将 Promise 或异步迭代器统一转换为异步生成器 const iterator = normalizeToAsyncGenerator(result) diff --git a/packages/kit/src/message/test/mockResponseProvider.ts b/packages/kit/src/message/test/mockResponseProvider.ts index 7394521e3..3c6c56229 100644 --- a/packages/kit/src/message/test/mockResponseProvider.ts +++ b/packages/kit/src/message/test/mockResponseProvider.ts @@ -1,4 +1,4 @@ -import type { ChatCompletionChunk } from 'openai/resources/index' +import type { ChatCompletionChunk } from 'openai/resources' import type { ResponseProvider } from '../types' import { AbortError } from '../utils' diff --git a/packages/kit/src/message/test/toolPlugin.test.ts b/packages/kit/src/message/test/toolPlugin.test.ts new file mode 100644 index 000000000..fa73465bc --- /dev/null +++ b/packages/kit/src/message/test/toolPlugin.test.ts @@ -0,0 +1,293 @@ +import type { ChatCompletion } from 'openai/resources' +import { describe, expect, it, vi } from 'vitest' +import { createNativeMessageAdapter } from '../adapters/native' +import { createMessageEngine } from '../core/engine' +import { lengthPlugin, thinkingPlugin, toolPlugin, type RuntimeTool, type ToolProvider } from '../plugins' +import type { CreateMessageEngineOptions, MessageEnginePlugin, ResponseProvider } from '../types' + +const silentDefaultPlugins = [thinkingPlugin({ disabled: true }), lengthPlugin({ disabled: true })] + +const createTestMessageEngine = (options: CreateMessageEngineOptions) => + createMessageEngine(createNativeMessageAdapter(), options) + +describe('toolPlugin', () => { + it('injects and executes runtime tools before falling back to callTool', async () => { + const runtimeCall = vi.fn(() => ({ result: 'runtime-result' })) + const fallbackCall = vi.fn() + const runtimeTool: RuntimeTool = { + tool: { + type: 'function', + function: { + name: 'runtime_lookup', + description: 'Runtime lookup', + parameters: { + type: 'object', + properties: { + query: { type: 'string' }, + }, + required: ['query'], + }, + }, + }, + handler: runtimeCall, + } + const responseProvider = vi.fn(async (requestBody) => { + const hasToolResult = requestBody.messages.some((message) => message.role === 'tool') + + if (!hasToolResult) { + expect(requestBody.tools?.map((tool) => tool.function.name)).toEqual(['runtime_lookup']) + return { + id: 'tool-call', + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: 'mock', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call-1', + type: 'function', + function: { + name: 'runtime_lookup', + arguments: JSON.stringify({ query: 'vue' }), + }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + } as ChatCompletion + } + + expect(requestBody.messages.at(-1)).toMatchObject({ + role: 'tool', + tool_call_id: 'call-1', + content: JSON.stringify({ result: 'runtime-result' }), + }) + return { + id: 'final-answer', + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: 'mock', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'done', + }, + finish_reason: 'stop', + }, + ], + } as ChatCompletion + }) + + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + toolPlugin({ + getTools: async () => [runtimeTool], + callTool: fallbackCall, + }), + ], + responseProvider, + }) + + await engine.sendMessage('lookup vue') + + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'call-1', + function: expect.objectContaining({ name: 'runtime_lookup' }), + }), + expect.objectContaining({ + toolMessage: expect.objectContaining({ role: 'tool' }), + toolSource: { type: 'toolPlugin' }, + }), + ) + expect(fallbackCall).not.toHaveBeenCalled() + expect(responseProvider).toHaveBeenCalledTimes(2) + expect(engine.getState().messages.at(-1)).toMatchObject({ + role: 'assistant', + content: 'done', + }) + }) + + it('throws when tool names are duplicated', async () => { + const runtimeTool: RuntimeTool = { + tool: { + type: 'function', + function: { + name: 'duplicate_tool', + description: 'Runtime duplicate', + }, + }, + handler: () => 'runtime', + } + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + toolPlugin({ + getTools: async () => [ + { + type: 'function', + function: { + name: 'duplicate_tool', + description: 'Schema duplicate', + }, + }, + runtimeTool, + ], + callTool: async () => 'fallback', + }), + ], + responseProvider: async () => { + throw new Error('responseProvider should not be called') + }, + }) + + await expect(engine.sendMessage('trigger duplicate tools')).rejects.toThrow( + 'Duplicate tool name "duplicate_tool" detected.', + ) + }) + + it('throws when provided tools conflict with existing request tools', async () => { + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + { + name: 'existing-tools', + onBeforeRequest: (context) => { + context.requestBody.tools = [ + { + type: 'function', + function: { + name: 'duplicate_tool', + description: 'Existing request tool', + }, + }, + ] + }, + }, + toolPlugin({ + getTools: async () => [ + { + type: 'function', + function: { + name: 'duplicate_tool', + description: 'Provided tool', + }, + }, + ], + callTool: async () => 'fallback', + }), + ], + responseProvider: async () => { + throw new Error('responseProvider should not be called') + }, + }) + + await expect(engine.sendMessage('trigger duplicate existing tool')).rejects.toThrow( + 'Duplicate tool name "duplicate_tool" detected.', + ) + }) + + it('loads tools provided by other plugins and passes provider source to fallback tool calls', async () => { + const fallbackCall = vi.fn(async () => 'provider result') + const responseProvider = vi.fn(async (requestBody) => { + const hasToolResult = requestBody.messages.some((message) => message.role === 'tool') + + if (!hasToolResult) { + expect(requestBody.tools?.map((tool) => tool.function.name)).toEqual(['provided_tool']) + + return { + id: 'provider-tool-call', + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: 'mock', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call-provider', + type: 'function', + function: { + name: 'provided_tool', + arguments: '{}', + }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + } as ChatCompletion + } + + return { + id: 'final-answer', + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: 'mock', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'done', + }, + finish_reason: 'stop', + }, + ], + } as ChatCompletion + }) + + const providerPlugin: MessageEnginePlugin & ToolProvider = { + name: 'external-tool-provider', + provideTools: async () => [ + { + type: 'function', + function: { + name: 'provided_tool', + description: 'Provided by another plugin', + }, + }, + ], + } + + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + providerPlugin, + toolPlugin({ + getTools: async () => [], + callTool: fallbackCall, + }), + ], + responseProvider, + }) + + await engine.sendMessage('call provided tool') + + expect(fallbackCall).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'call-provider', + }), + expect.objectContaining({ + toolSource: { + type: 'toolProvider', + pluginName: 'external-tool-provider', + }, + }), + ) + }) +}) diff --git a/packages/kit/src/message/types.ts b/packages/kit/src/message/types.ts index 6ef9a9baa..92c34220f 100644 --- a/packages/kit/src/message/types.ts +++ b/packages/kit/src/message/types.ts @@ -2,9 +2,10 @@ import { ChatCompletion, ChatCompletionChunk, + ChatCompletionFunctionTool, ChatCompletionMessageParam, ChatCompletionMessageToolCall, -} from 'openai/resources/index' +} from 'openai/resources' import { MaybePromise } from '../types' export type DeepReadonly = T extends (...args: any[]) => any @@ -32,6 +33,7 @@ export type ChatMessage< export interface MessageRequestBody { messages: Array + tools?: Array [key: string]: any } @@ -128,6 +130,12 @@ export interface BasePluginContext { mutate: MutateMessageStateFn abortSignal: AbortSignal currentTurn: ChatMessage[] + /** + * 当前 engine 中已注册的插件列表。 + * + * 插件可基于该列表发现其他插件暴露的轻量协议,例如 toolPlugin 收集 provideTools。 + */ + plugins: readonly MessageEnginePlugin[] customContext: Record setRequestState: (state: RequestState, processingState?: RequestProcessingState) => void setCustomContext: (data: Record) => void diff --git a/packages/kit/src/message/utils.ts b/packages/kit/src/message/utils.ts index 5ab1937c2..ed0679f59 100644 --- a/packages/kit/src/message/utils.ts +++ b/packages/kit/src/message/utils.ts @@ -84,7 +84,7 @@ export function omitFields, K extends keyof T> } export async function* normalizeToAsyncGenerator( - result: Promise | AsyncGenerator | Promise>, + result: T | Promise | AsyncGenerator | Promise>, ): AsyncGenerator { // 情况 1:是 async generator 或 sync generator if (isAsyncGenerator(result)) { diff --git a/packages/kit/src/node.ts b/packages/kit/src/node.ts new file mode 100644 index 000000000..260b394bf --- /dev/null +++ b/packages/kit/src/node.ts @@ -0,0 +1,2 @@ +export { loadSkillFilesFromFs } from './skills/fsSkillFiles' +export type { FsSkillFilesOptions } from './skills/fsSkillFiles' diff --git a/packages/kit/src/skills/README.md b/packages/kit/src/skills/README.md new file mode 100644 index 000000000..bf1379d13 --- /dev/null +++ b/packages/kit/src/skills/README.md @@ -0,0 +1,364 @@ +# Skill Toolchain Architecture + +本文档面向维护者,用于说明 `packages/kit/src/skills` 中各模块的职责边界。面向使用者的 API 文档和交互示例放在 `docs/src/tools/skill.md`。 + +## 目标 + +skill 是一组可复用的能力模板。它可以从文件加载,被业务侧管理和选择,并在请求前转换为模型可消费的 instructions 和基础文件工具。 + +skill 核心能力不依赖 Vue,浏览器安全 API 从 `@opentiny/tiny-robot-kit/core` 导出;Node 文件系统能力从 `@opentiny/tiny-robot-kit/node` 导出。 + +## 核心数据 + +`SkillDefinition` 是 loader、manager、compiler 之间流转的核心数据结构: + +```ts +interface SkillDefinition { + name: string + description: string + instructions: string + files?: SkillFile[] + metadata?: Record +} +``` + +- `name`:skill 唯一名称,由 manager 负责集合层面的覆盖和选择。 +- `description`:用于展示、搜索或后续自动选择。 +- `instructions`:注入模型请求的核心指令。 +- `files`:随 skill 携带的附加文件资源,可由基础文件工具读取。 +- `metadata`:应用侧和 loader 保留的扩展信息。 + +## 模块职责 + +### `types.ts` + +定义 skill 工具链的共享类型: + +- `SkillDefinition` +- `SkillFile` +- 文本和二进制 skill 文件类型 + +该文件不包含运行逻辑。 + +### `utils.ts` + +提供 skill 文件路径和文本文件判断工具: + +- `normalizeSkillPath` +- `isTextSkillFilePath` +- `getExtension` + +这些工具只处理文件路径和扩展名,不解析 skill 语义。 + +### `browserSkillFiles.ts` + +浏览器文件适配器。负责把浏览器文件来源转换为标准 `SkillFile[]`: + +- `loadSkillFilesFromFileList` +- `loadSkillFilesFromDirectoryHandle` + +该模块只读取文件内容并标准化路径,不解析 `SKILL.md`。 + +### `fsSkillFiles.ts` + +Node 文件适配器。负责把本地目录转换为标准 `SkillFile[]`: + +- `loadSkillFilesFromFs` + +该模块依赖 Node `fs/path`,只能从 `@opentiny/tiny-robot-kit/node` 子入口导出,不能从浏览器根入口导出。 + +### `skillLoader.ts` + +loader 层。负责把 `SkillFile[]` 解析为 `SkillDefinition`: + +- 查找入口文件,默认 `SKILL.md` +- 解析 frontmatter +- 将正文转换为必填 `instructions` +- 将其他支持的文件转换为 `files` +- 收集非致命 warnings + +loader 不负责: + +- 读取文件系统或浏览器文件 +- 保存 skill 集合 +- 选择 skill +- 编译 message 请求 + +### `manager.ts` + +manager 层。负责 skill 集合和选择状态: + +- `set(skill)`:新增或覆盖同名 skill +- `remove(name)` / `clear()` +- `get(name)` / `has(name)` / `list()` +- `select(names)` / `unselect(names)` +- `getSelectedSkillNames()` / `getSelectedSkills()` +- `import(files, options)`:通过 `SkillLoader` 导入 skill + +manager 不负责编译 instructions 或 runtime tools。 + +### `compiler.ts` + +compiler 层只保留两个纯转换函数: + +- `compileSkillInstructions(skills)` +- `createSkillRuntimeTools(skills, options?)` + +`compileSkillInstructions` 将已选择的 skills 转换为 system message。 + +`createSkillRuntimeTools` 根据 `skill.files` 创建基础文件工具: + +- `list_skill_files` +- `read_skill_file` + +当传入 `options.executeSkillCommand` 时,它会额外创建命令执行工具: + +- `execute_skill_command` + +compiler 不负责: + +- skill 去重 +- 选择状态 +- 持久化 +- 集合管理 + +### `index.ts` + +skill core 的统一导出口。该入口会被 `@opentiny/tiny-robot-kit/core` 重新导出。 + +不要在这里导出 Node-only API,例如 `loadSkillFilesFromFs`。 + +## Message 接入 + +message 接入代码不放在 `src/skills` 下: + +- core message adapter:`packages/kit/src/message/plugins/skillPlugin.ts` +- Vue message adapter:`packages/kit/src/vue/message/plugins/skillPlugin.ts` + +`skillPlugin` 的职责是把调用方传入的当前 skills 接入 message 生命周期: + +1. `onTurnStart` 读取 `getSkills()` 或 Vue 侧响应式 `skills`。 +2. 创建 `runtimeTools = createSkillRuntimeTools(skills, options)`。 +3. 将 `{ skills, skillNames, runtimeTools }` 写入 `customContext.__tiny_robot_skill`。 +4. `provideTools` 暴露 `runtimeTools`。 +5. `onBeforeRequest` 调用 `compileSkillInstructions(skills)` 并 prepend system message。 + +`skillPlugin` 不加载、不缓存、不选择、不管理 skill 集合。 + +## 数据流 + +```mermaid +flowchart TD + A["File sourceFileList / DirectoryHandle / fs directory"] --> B["file adapter"] + B -->|"SkillFile[]"| C["SkillLoader"] + C -->|"SkillDefinition"| D["SkillManager"] + D -->|"selected SkillDefinition[]"| E["skillPlugin"] + E --> F["compileSkillInstructions"] + E --> G["createSkillRuntimeTools"] + F --> H["system message"] + G --> I["runtime tools"] +``` + +## Sandbox Command Execution + +部分 skill 需要专门的后端运行环境才能执行命令,例如 PPT、PDF、浏览器自动化或文档处理。`kit` 不内置这些后端能力;当前设计是让模型根据已启用 skill 的 instructions 自行规划命令和参数,再由应用侧 executor 转发到后端沙箱执行。 + +推荐工具形态: + +```ts +execute_skill_command({ + skillName: string + command: string + args: string[] +}) +``` + +该阶段不要求从 `SKILL.md` 提取命令 allowlist,也不要求 compiler 生成命令枚举。`SKILL.md` 仍然是自然语言说明,模型可以根据说明决定 `command` 和 `args`。 + +职责边界: + +- `createSkillRuntimeTools(skills, { executeSkillCommand })` 创建 `execute_skill_command` runtime tool。 +- `skillPlugin` 在传入 `executeSkillCommand` 时暴露 `execute_skill_command`。 +- 应用侧 executor 负责选择后端运行环境、鉴权、沙箱、超时、日志、产物管理和错误返回。 +- 后端必须把模型返回的 `command` / `args` 视为不可信输入。 + +后端执行约束: + +- 在隔离环境中执行,例如容器、临时 workspace、受限用户或专用任务服务。 +- 使用 argv 方式执行命令,例如 `spawn(command, args, { shell: false })`。 +- 不把 `command` 和 `args` 拼接成 shell 字符串执行。 +- 设置超时、输出大小限制和并发限制。 +- 限制可访问的文件目录和网络能力。 +- 对危险命令、高成本命令或写入性操作保留业务侧确认能力。 + +推荐返回结构: + +```ts +type SkillArtifact = { + id: string + name: string + mimeType?: string + size?: number + url?: string + textAvailable?: boolean + previewAvailable?: boolean + metadata?: Record +} + +type SkillCommandResult = { + ok: boolean + exitCode?: number + stdout?: string + stderr?: string + artifacts?: SkillArtifact[] + error?: { + code: string + message: string + } +} +``` + +### Artifact 产物模型 + +命令执行可能生成 PDF、PPTX、图片、压缩包等二进制文件。这些内容不应通过 tool message 直接传给模型,也不应以 base64 放进 `stdout`。后端沙箱应把文件写入受控的 artifact store,再在 `SkillCommandResult.artifacts` 中返回引用信息。 + +artifact store 可以是: + +- 应用后端的临时目录和下载接口。 +- 对象存储,例如 S3、OSS、MinIO。 +- 专用任务服务提供的产物访问接口。 + +artifact 必须绑定用户、会话、请求或 sandbox run,不能只依赖裸 `artifactId` 做访问控制。`url` 应由应用侧决定是内部代理地址、短期 signed URL,还是仅供前端预览使用的下载地址。 + +推荐链路: + +```mermaid +sequenceDiagram + participant Model as 大模型 + participant App as kit / 应用侧 executor + participant Sandbox as 后端沙箱 + participant Store as Artifact store + + Model->>App: execute_skill_command(skillName, command, args) + App->>Sandbox: 按 skill runtime 执行 argv 命令 + Sandbox->>Store: 写入二进制产物 + Store-->>Sandbox: artifact metadata / url + Sandbox-->>App: stdout / stderr / artifacts + App-->>Model: tool result: artifact 引用和摘要 + Model->>App: 可选:读取 artifact 文本或摘要 + App->>Store: 可选:读取已提取文本 / 预览信息 + Store-->>App: artifact text / info + App-->>Model: 可选:artifact text / info +``` + +后续如果模型需要继续理解产物内容,可以在 `createSkillRuntimeTools` 中扩展 artifact 读取能力,例如: + +- `list_skill_artifacts` +- `get_skill_artifact_info` +- `read_skill_artifact_text` + +这些工具应返回文本、摘要或元数据,不返回原始二进制内容。第一阶段可以只让 `execute_skill_command` 返回 `artifacts` 引用,由前端或应用侧负责展示、下载和预览。 + +后续如果 skill 命令逐渐稳定,可以再引入机器可读 manifest,把自由命令收敛为 command allowlist 和参数 schema。这个 manifest 属于后续增强,不影响当前基于沙箱的第一阶段设计。 + +## Auto Skill Selection + +auto skill selection 是一个未来的 selector 层能力,用于让模型根据用户问题从候选 skills 中选择本次请求要启用的 skills。它不属于 `skillPlugin`、compiler 或 manager 的当前职责。 + +推荐链路: + +```mermaid +sequenceDiagram + participant App as 用户 / 应用 + participant Model as 大模型 + + App->>Model: 用户问题 + 候选 skill descriptions + Model->>App: 调用 selectSkills(skillNames) + App->>App: 记录请求级 selected skill names + App->>App: skillPlugin 编译已选 skills + App->>Model: execution turn + instructions + 基础文件工具 + Model->>App: 基于已启用 skills 生成回答 +``` + +职责边界: + +- `SkillManager` 管理全部可用 skills。 +- `SkillSelector` 根据用户问题和候选 skill descriptions 产出本次请求的 selected skill names。 +- `skillPlugin` 只读取 selected `SkillDefinition[]`,并编译 instructions 和基础文件工具。 +- auto selection 的结果应写入请求级状态,例如 `customContext.__tiny_robot_selected_skills`,不能直接写入 manager 的长期选择状态。 + +selector 阶段只提供候选摘要,不提供完整 instructions: + +```txt +Available skills: +- weather: Get current weather information. +- vue-best-practices: Vue.js best practices workflow. +``` + +selector 工具可以设计为: + +```ts +selectSkills({ + skillNames: string[] +}) +``` + +工具 JSON schema 应使用候选 skill names 限制可选范围: + +```json +{ + "type": "object", + "properties": { + "skillNames": { + "type": "array", + "items": { + "type": "string", + "enum": ["weather", "vue-best-practices"] + } + } + }, + "required": ["skillNames"], + "additionalProperties": false +} +``` + +selector 工具返回值建议是结构化结果,便于调试和日志记录: + +```json +{ + "selectedSkillNames": ["vue-best-practices"] +} +``` + +execution 阶段再把 selected skill definitions 交给 `skillPlugin`: + +```ts +skillPlugin({ + getSkills: (context) => context.customContext.__tiny_robot_selected_skills ?? [], +}) +``` + +为了避免循环调用,selector 层应维护请求级状态,例如: + +```ts +selectionStatus: 'pending' | 'done' +``` + +- `pending` 阶段提供 `selectSkills` 工具。 +- `done` 阶段不再提供 selector 工具。 +- `selectSkills` 每个请求最多调用一次。 + +## 后续事项 + +- 为 `read_skill_file` 增加大小限制和截断策略。 +- 为重复 skill 名称增加诊断能力,优先放在 manager 或选择逻辑中。 +- 评估 auto skill selection 是否需要独立 selector 层。 +- search text tool +- 消息模型 + system skill name + description, prompt提示当前环境 + user message + llm select,直接获取skill file +- mcp沙盒 +- 手动@选择一个skill,system prompt提示优先使用当前skill +- 文件存储 storageStrategy +- 文档描述优化 diff --git a/packages/kit/src/skills/browserSkillFiles.ts b/packages/kit/src/skills/browserSkillFiles.ts new file mode 100644 index 000000000..3aeb8c004 --- /dev/null +++ b/packages/kit/src/skills/browserSkillFiles.ts @@ -0,0 +1,98 @@ +import type { SkillFile } from './types' +import { isTextSkillFilePath, normalizeSkillPath } from './utils' + +export type BrowserFile = Pick & { + webkitRelativePath?: string +} + +export type BrowserFileHandle = { + kind: 'file' + name: string + getFile: () => Promise +} + +export type BrowserDirectoryHandle = { + kind: 'directory' + name: string + entries: () => AsyncIterable<[string, BrowserFileHandle | BrowserDirectoryHandle]> +} + +/** + * Browser FileList 适配器,用于读取文件选择器选中的 skill 目录。 + */ +export const loadSkillFilesFromFileList = async (fileList: ArrayLike): Promise => { + const files = Array.from({ length: fileList.length }, (_, index) => fileList[index]).filter( + (file): file is BrowserFile => Boolean(file), + ) + + return Promise.all( + files.map((file) => { + const path = file.webkitRelativePath || file.name + return browserFileToSkillFile(file, stripRootDirectory(path)) + }), + ) +} + +/** + * Browser FileSystemDirectoryHandle 适配器,用于读取 window.showDirectoryPicker() 选中的目录。 + */ +export const loadSkillFilesFromDirectoryHandle = async ( + directoryHandle: BrowserDirectoryHandle, +): Promise => { + const result: SkillFile[] = [] + + const walk = async (directory: BrowserDirectoryHandle, parentPath = '') => { + for await (const [name, handle] of directory.entries()) { + const path = parentPath ? `${parentPath}/${name}` : name + + if (handle.kind === 'directory') { + await walk(handle, path) + continue + } + + result.push(await browserFileToSkillFile(await handle.getFile(), path)) + } + } + + await walk(directoryHandle) + return result.sort((a, b) => a.path.localeCompare(b.path)) +} + +const browserFileToSkillFile = async (file: BrowserFile, rawPath: string): Promise => { + const path = normalizeSkillPath(rawPath) + + if (!path) { + throw new Error(`Invalid skill file path: ${rawPath}`) + } + + if (isTextSkillFilePath(path)) { + return { + path, + kind: 'text', + content: await file.text(), + mimeType: file.type, + size: file.size, + lastModified: file.lastModified, + } + } + + return { + path, + kind: 'binary', + content: await file.arrayBuffer(), + mimeType: file.type, + size: file.size, + lastModified: file.lastModified, + } +} + +const stripRootDirectory = (path: string) => { + const normalized = path.split('\\').join('/') + const parts = normalized.split('/').filter(Boolean) + + if (parts.length <= 1) { + return normalized + } + + return parts.slice(1).join('/') +} diff --git a/packages/kit/src/skills/compiler.ts b/packages/kit/src/skills/compiler.ts new file mode 100644 index 000000000..026b4b7ea --- /dev/null +++ b/packages/kit/src/skills/compiler.ts @@ -0,0 +1,286 @@ +import type { ChatCompletionSystemMessageParam } from 'openai/resources' +import type { RuntimeTool } from '../message/plugins/toolPlugin' +import type { MaybePromise } from '../types' +import type { SkillDefinition, SkillFile } from './types' + +const skillFileToolNames = { + listSkillFiles: 'list_skill_files', + readSkillFile: 'read_skill_file', +} as const + +const skillCommandToolName = 'execute_skill_command' + +export type SkillCommandRequest = { + skillName: string + command: string + args: string[] + skill: SkillDefinition +} + +export type SkillCommandResult = string | Record + +export type SkillCommandExecutor = (request: SkillCommandRequest) => MaybePromise + +export type SkillRuntimeToolsOptions = { + /** + * @experimental 该 API 仍在设计和验证中,命令协议、返回结构和安全边界后续可能调整。 + */ + executeSkillCommand?: SkillCommandExecutor +} + +const skillFileTools: Array = [ + { + type: 'function', + function: { + name: skillFileToolNames.listSkillFiles, + description: 'List files available from the current skills.', + parameters: { + type: 'object', + properties: { + skillName: { + type: 'string', + description: 'Optional skill name. When omitted, files from all current skills are listed.', + }, + }, + additionalProperties: false, + }, + }, + }, + { + type: 'function', + function: { + name: skillFileToolNames.readSkillFile, + description: 'Read a file from a current skill by skill name and relative path.', + parameters: { + type: 'object', + properties: { + skillName: { + type: 'string', + description: 'Skill name that owns the file.', + }, + path: { + type: 'string', + description: 'File path relative to the skill root.', + }, + }, + required: ['skillName', 'path'], + additionalProperties: false, + }, + }, + }, +] + +const skillCommandTool: RuntimeTool['tool'] = { + type: 'function', + function: { + name: skillCommandToolName, + description: 'Execute a command in the backend runtime environment for a selected skill.', + parameters: { + type: 'object', + properties: { + skillName: { + type: 'string', + description: 'Name of the current skill that provides the command instructions.', + }, + command: { + type: 'string', + description: 'Command name to execute in the skill backend runtime.', + }, + args: { + type: 'array', + description: 'Command arguments passed as argv items.', + items: { + type: 'string', + }, + }, + }, + required: ['skillName', 'command', 'args'], + additionalProperties: false, + }, + }, +} + +const getSkillFileSummary = (skillName: string, file: SkillFile) => ({ + skillName, + path: file.path, + kind: file.kind, + mimeType: file.mimeType, + size: file.size, + lastModified: file.lastModified, +}) + +const parseSkillToolArguments = (toolCall: Parameters[0]): Record => { + const rawArguments = toolCall.function.arguments + + if (!rawArguments) { + return {} + } + + try { + const parsed = JSON.parse(rawArguments) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {} + } catch { + return {} + } +} + +const normalizeStringArray = (value: unknown) => { + return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : undefined +} + +/** + * 创建基础的 skill 文件工具,用于列出和读取 skill 携带的文件资源。 + */ +const createSkillFileRuntimeTools = (skills: SkillDefinition[]): RuntimeTool[] => { + const hasSkillFiles = skills.some((skill) => Boolean(skill.files?.length)) + + if (!hasSkillFiles) { + return [] + } + + const findSkill = (skillName?: unknown) => { + if (typeof skillName !== 'string' || !skillName) { + return undefined + } + + return skills.find((skill) => skill.name === skillName) + } + + return [ + { + tool: skillFileTools[0], + handler: (toolCall) => { + const toolArguments = parseSkillToolArguments(toolCall) + const skill = findSkill(toolArguments.skillName) + const skillList = skill ? [skill] : skills + + return { + files: skillList.flatMap((currentSkill) => + (currentSkill.files ?? []).map((file) => getSkillFileSummary(currentSkill.name, file)), + ), + } + }, + }, + { + tool: skillFileTools[1], + handler: (toolCall) => { + const toolArguments = parseSkillToolArguments(toolCall) + const skill = findSkill(toolArguments.skillName) + const path = typeof toolArguments.path === 'string' ? toolArguments.path : undefined + + if (!skill) { + return { error: 'skill_not_found' } + } + + if (!path) { + return { error: 'file_path_required', skillName: skill.name } + } + + const file = skill.files?.find((skillFile) => skillFile.path === path) + if (!file) { + return { error: 'file_not_found', skillName: skill.name, path } + } + + if (file.kind === 'binary') { + return { + error: 'binary_file_not_readable', + file: getSkillFileSummary(skill.name, file), + } + } + + return { + file: getSkillFileSummary(skill.name, file), + content: file.content, + } + }, + }, + ] +} + +/** + * 创建 skill 命令执行工具,用于把模型规划的命令转交给应用侧后端运行环境。 + */ +const createSkillCommandRuntimeTools = ( + skills: SkillDefinition[], + executeSkillCommand?: SkillCommandExecutor, +): RuntimeTool[] => { + if (!executeSkillCommand || skills.length === 0) { + return [] + } + + const findSkill = (skillName?: unknown) => { + if (typeof skillName !== 'string' || !skillName) { + return undefined + } + + return skills.find((skill) => skill.name === skillName) + } + + return [ + { + tool: skillCommandTool, + handler: async (toolCall) => { + const toolArguments = parseSkillToolArguments(toolCall) + const skill = findSkill(toolArguments.skillName) + const command = typeof toolArguments.command === 'string' ? toolArguments.command : undefined + const args = normalizeStringArray(toolArguments.args) + + if (!skill) { + return { error: 'skill_not_found' } + } + + if (!command) { + return { error: 'command_required', skillName: skill.name } + } + + if (!args) { + return { error: 'args_required', skillName: skill.name, command } + } + + return executeSkillCommand({ + skill, + skillName: skill.name, + command, + args, + }) + }, + }, + ] +} + +/** + * 创建 skill 运行时工具。 + * + * 默认只根据 skill 文件创建基础文件工具;传入 executeSkillCommand 时会额外创建命令执行工具。 + */ +export const createSkillRuntimeTools = ( + skills: SkillDefinition[], + options: SkillRuntimeToolsOptions = {}, +): RuntimeTool[] => { + return [ + ...createSkillFileRuntimeTools(skills), + ...createSkillCommandRuntimeTools(skills, options.executeSkillCommand), + ] +} + +export const compileSkillInstructions = async ( + skills: SkillDefinition[], +): Promise => { + const instructions: string[] = [] + + for (const skill of skills) { + const instruction = skill.instructions?.trim() + if (instruction) { + instructions.push(`## ${skill.name}\n\n${instruction}`) + } + } + + if (instructions.length === 0) { + return undefined + } + + return { + role: 'system', + content: ['Apply these skill instructions when generating the response.', ...instructions].join('\n\n'), + } +} diff --git a/packages/kit/src/skills/fsSkillFiles.ts b/packages/kit/src/skills/fsSkillFiles.ts new file mode 100644 index 000000000..b047060d8 --- /dev/null +++ b/packages/kit/src/skills/fsSkillFiles.ts @@ -0,0 +1,69 @@ +import { readdir, readFile, stat } from 'node:fs/promises' +import { join, relative } from 'node:path' +import type { SkillFile } from './types' +import { isTextSkillFilePath, normalizeSkillPath } from './utils' + +export interface FsSkillFilesOptions { + /** + * 遍历时排除的目录名。 + */ + ignoredDirectories?: string[] +} + +/** + * Node.js 目录适配器,将本地 skill 目录读取为 SkillFile 记录。 + */ +export const loadSkillFilesFromFs = async (root: string, options: FsSkillFilesOptions = {}): Promise => { + const ignoredDirectories = new Set(options.ignoredDirectories ?? ['.git', 'node_modules']) + const result: SkillFile[] = [] + + const walk = async (directory: string) => { + const entries = await readdir(directory, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = join(directory, entry.name) + + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) { + await walk(fullPath) + } + continue + } + + if (!entry.isFile()) { + continue + } + + const fileStat = await stat(fullPath) + if (!fileStat.isFile()) { + continue + } + + const path = normalizeSkillPath(relative(root, fullPath)) + if (!path) { + continue + } + + if (isTextSkillFilePath(path)) { + result.push({ + path, + kind: 'text', + content: await readFile(fullPath, 'utf-8'), + size: fileStat.size, + lastModified: fileStat.mtimeMs, + }) + } else { + result.push({ + path, + kind: 'binary', + content: await readFile(fullPath), + size: fileStat.size, + lastModified: fileStat.mtimeMs, + }) + } + } + } + + await walk(root) + return result.sort((a, b) => a.path.localeCompare(b.path)) +} diff --git a/packages/kit/src/skills/index.ts b/packages/kit/src/skills/index.ts new file mode 100644 index 000000000..55e363bb0 --- /dev/null +++ b/packages/kit/src/skills/index.ts @@ -0,0 +1,15 @@ +export { loadSkillFilesFromDirectoryHandle, loadSkillFilesFromFileList } from './browserSkillFiles' +export type { BrowserDirectoryHandle, BrowserFile, BrowserFileHandle } from './browserSkillFiles' +export { compileSkillInstructions, createSkillRuntimeTools } from './compiler' +export type { + SkillCommandExecutor, + SkillCommandRequest, + SkillCommandResult, + SkillRuntimeToolsOptions, +} from './compiler' +export { SkillManager } from './manager' +export type { SkillManagerOptions } from './manager' +export { SkillLoader } from './skillLoader' +export type { SkillLoaderOptions, SkillLoaderResult } from './skillLoader' +export type { BaseSkillFile, BinarySkillFile, SkillDefinition, SkillFile, SkillFileKind, TextSkillFile } from './types' +export { getExtension, isTextSkillFilePath, normalizeSkillPath } from './utils' diff --git a/packages/kit/src/skills/manager.ts b/packages/kit/src/skills/manager.ts new file mode 100644 index 000000000..08f6e7f49 --- /dev/null +++ b/packages/kit/src/skills/manager.ts @@ -0,0 +1,97 @@ +import type { SkillDefinition, SkillFile } from './types' +import { SkillLoader } from './skillLoader' +import type { SkillLoaderOptions, SkillLoaderResult } from './skillLoader' + +export type SkillManagerOptions = { + /** + * 初始化时写入 manager 的 skill 列表。 + */ + skills?: SkillDefinition[] + /** + * 初始化时选中的 skill 名称。 + */ + selectedSkillNames?: string[] +} + +/** + * 管理 skill 集合和选择状态。 + * + * manager 不编译 prompt 或 tools,也不接入 message 生命周期。 + */ +export class SkillManager { + private skills = new Map() + private selectedSkillNames = new Set() + + constructor(options: SkillManagerOptions = {}) { + for (const skill of options.skills ?? []) { + this.set(skill) + } + + this.select(options.selectedSkillNames ?? []) + } + + set(skill: SkillDefinition) { + this.skills.set(skill.name, skill) + return skill + } + + remove(name: string) { + const skill = this.get(name) + + this.skills.delete(name) + this.selectedSkillNames.delete(name) + + return skill + } + + clear() { + this.skills.clear() + this.selectedSkillNames.clear() + } + + get(name: string) { + return this.skills.get(name) + } + + has(name: string) { + return this.skills.has(name) + } + + list() { + return Array.from(this.skills.values()) + } + + select(names: string | string[]) { + for (const name of Array.isArray(names) ? names : [names]) { + if (!this.skills.has(name)) { + throw new Error(`Skill "${name}" does not exist.`) + } + + this.selectedSkillNames.add(name) + } + } + + unselect(names: string | string[]) { + for (const name of Array.isArray(names) ? names : [names]) { + this.selectedSkillNames.delete(name) + } + } + + getSelectedSkillNames() { + return Array.from(this.selectedSkillNames) + } + + getSelectedSkills() { + return this.getSelectedSkillNames().flatMap((name) => { + const skill = this.skills.get(name) + return skill ? [skill] : [] + }) + } + + import(files: SkillFile[], options: SkillLoaderOptions = {}): SkillLoaderResult { + const result = new SkillLoader(options).load(files) + + this.set(result.skill) + return result + } +} diff --git a/packages/kit/src/skills/skillLoader.ts b/packages/kit/src/skills/skillLoader.ts new file mode 100644 index 000000000..d7170323e --- /dev/null +++ b/packages/kit/src/skills/skillLoader.ts @@ -0,0 +1,197 @@ +import { parse as parseYaml } from 'yaml' +import type { SkillDefinition, SkillFile } from './types' +import { getExtension, isTextSkillFilePath, normalizeSkillPath } from './utils' + +export interface SkillLoaderResult { + /** + * 从源文件解析出的 skill 定义。 + */ + skill: SkillDefinition + /** + * 非致命的加载警告。 + */ + warnings: Array<{ + /** + * 用于 UI 和日志分类的警告编码。 + */ + code: string + /** + * 人类可读的警告信息。 + */ + message: string + /** + * 关联的 skill 文件路径。 + */ + path?: string + }> +} + +export interface SkillLoaderOptions { + /** + * skill 入口文件名。 + */ + entryFile?: string + /** + * 启用后,警告会直接抛出为错误。 + */ + strict?: boolean +} + +/** + * 将标准化后的 skill 文件转换为 SkillDefinition。 + * + * 文件来源适配器负责提供 SkillFile[];该 loader 负责解析入口文件和资源文件。 + */ +export class SkillLoader { + private entryFile: string + private strict: boolean + + constructor(options: SkillLoaderOptions = {}) { + this.entryFile = options.entryFile ?? 'SKILL.md' + this.strict = options.strict ?? false + } + + load(files: SkillFile[]): SkillLoaderResult { + const warnings: SkillLoaderResult['warnings'] = [] + const normalizedFiles = this.normalizeFiles(files, warnings) + const entryFile = normalizedFiles.find((file) => file.path === this.entryFile) + + if (!entryFile) { + throw new Error(`Skill entry file "${this.entryFile}" is missing.`) + } + + if (entryFile.kind !== 'text') { + throw new Error(`Skill entry file "${this.entryFile}" must be a text file.`) + } + + const { frontmatter, body } = parseMarkdownFrontmatter(entryFile.content) + const instructions = body.trim() + + if (!instructions) { + throw new Error(`Skill entry file "${this.entryFile}" must contain instructions.`) + } + + const frontmatterMetadata = getRecord(frontmatter.metadata) + const skillFiles: SkillFile[] = [] + + for (const file of normalizedFiles) { + if (file.path === this.entryFile) { + continue + } + + if (file.kind === 'binary') { + skillFiles.push(file) + continue + } + + if (!isTextSkillFilePath(file.path)) { + warnings.push({ + code: 'unsupported-text-file-ignored', + message: 'Only markdown, text, and json files are converted to text skill files.', + path: file.path, + }) + continue + } + + skillFiles.push(file) + } + + return { + skill: { + name: getString(frontmatter.name) || getFallbackSkillName(this.entryFile), + description: getString(frontmatter.description) || '', + instructions, + files: skillFiles.length ? skillFiles : undefined, + metadata: { + ...frontmatterMetadata, + homepage: getString(frontmatter.homepage), + frontmatter, + }, + }, + warnings, + } + } + + private normalizeFiles(files: SkillFile[], warnings: SkillLoaderResult['warnings']) { + const result: SkillFile[] = [] + const seenPaths = new Set() + + for (const file of files) { + const path = normalizeSkillPath(file.path) + + if (!path) { + this.handleWarning(warnings, { + code: 'invalid-path', + message: `Invalid skill file path: ${file.path}`, + path: file.path, + }) + continue + } + + if (seenPaths.has(path)) { + this.handleWarning(warnings, { + code: 'duplicate-path', + message: `Duplicate skill file path: ${path}`, + path, + }) + continue + } + + seenPaths.add(path) + result.push({ ...file, path } as SkillFile) + } + + return result.sort((a, b) => a.path.localeCompare(b.path)) + } + + private handleWarning(warnings: SkillLoaderResult['warnings'], warning: SkillLoaderResult['warnings'][number]) { + if (this.strict) { + throw new Error(warning.path ? `${warning.path}: ${warning.message}` : warning.message) + } + + warnings.push(warning) + } +} + +const parseMarkdownFrontmatter = (content: string) => { + if (!content.startsWith('---')) { + return { + frontmatter: {} as Record, + body: content, + } + } + + const endIndex = content.indexOf('\n---', 3) + if (endIndex === -1) { + return { + frontmatter: {} as Record, + body: content, + } + } + + const rawFrontmatter = content.slice(3, endIndex).trim() + const body = content.slice(endIndex + 4) + + return { + frontmatter: parseYamlFrontmatter(rawFrontmatter), + body, + } +} + +const parseYamlFrontmatter = (rawFrontmatter: string) => { + const parsed = parseYaml(rawFrontmatter) + return getRecord(parsed) ?? {} +} + +const getString = (value: unknown) => (typeof value === 'string' ? value : undefined) + +const getRecord = (value: unknown) => + value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : undefined + +const getResourceTitle = (path: string) => { + const filename = path.split('/').at(-1) || path + const ext = getExtension(filename) + return ext ? filename.slice(0, -ext.length) : filename +} + +const getFallbackSkillName = (entryFile: string) => getResourceTitle(entryFile) diff --git a/packages/kit/src/skills/test/.gitignore b/packages/kit/src/skills/test/.gitignore new file mode 100644 index 000000000..ceddaa37f --- /dev/null +++ b/packages/kit/src/skills/test/.gitignore @@ -0,0 +1 @@ +.cache/ diff --git a/packages/kit/src/skills/test/compiler.test.ts b/packages/kit/src/skills/test/compiler.test.ts new file mode 100644 index 000000000..0099a5af4 --- /dev/null +++ b/packages/kit/src/skills/test/compiler.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from 'vitest' +import { compileSkillInstructions, createSkillRuntimeTools } from '../compiler' + +describe('skill compiler', () => { + it('creates file runtime tools when skills have files', () => { + const runtimeTools = createSkillRuntimeTools([ + { + name: 'docs', + description: 'Docs skill', + instructions: 'Use docs.', + files: [ + { + path: 'guide.md', + kind: 'text', + content: '# Guide', + }, + ], + }, + { + name: 'plain', + description: 'Plain skill', + instructions: 'Use plain skill.', + }, + ]) + + expect(runtimeTools.map((runtimeTool) => runtimeTool.tool.function.name)).toEqual([ + 'list_skill_files', + 'read_skill_file', + ]) + }) + + it('returns no runtime file tools when skills have no files', () => { + expect( + createSkillRuntimeTools([{ name: 'plain', description: 'Plain skill', instructions: 'Use plain skill.' }]), + ).toEqual([]) + }) + + it('compiles instructions into a system message', async () => { + const message = await compileSkillInstructions([ + { + name: 'weather', + description: 'Weather skill', + instructions: 'Use wttr.in.', + }, + { + name: 'vue', + description: 'Vue skill', + instructions: 'Use Vue best practices.', + }, + { + name: 'empty', + description: 'Empty skill', + instructions: ' ', + }, + ]) + + expect(message).toMatchObject({ role: 'system' }) + expect(message?.content).toContain('Apply these skill instructions') + expect(message?.content).toContain('## weather\n\nUse wttr.in.') + expect(message?.content).toContain('## vue\n\nUse Vue best practices.') + expect(message?.content).not.toContain('## empty') + }) + + it('does not compile an instruction message when no skill has instructions', async () => { + await expect( + compileSkillInstructions([{ name: 'plain', description: 'Plain skill', instructions: ' ' }]), + ).resolves.toBeUndefined() + }) + + it('lists and reads files through built-in runtime tools', () => { + const [listFiles, readFile] = createSkillRuntimeTools([ + { + name: 'docs', + description: 'Docs skill', + instructions: 'Use docs.', + files: [ + { + path: 'guide.md', + kind: 'text', + content: '# Guide', + mimeType: 'text/markdown', + }, + { + path: 'icon.png', + kind: 'binary', + content: new Uint8Array([1, 2, 3]), + }, + ], + }, + ]) + + expect(listFiles.handler(createToolCall('list_skill_files', {}), {} as never)).toMatchObject({ + files: [ + { + skillName: 'docs', + path: 'guide.md', + kind: 'text', + }, + { + skillName: 'docs', + path: 'icon.png', + kind: 'binary', + }, + ], + }) + + expect( + readFile.handler(createToolCall('read_skill_file', { skillName: 'docs', path: 'guide.md' }), {} as never), + ).toMatchObject({ + file: { + skillName: 'docs', + path: 'guide.md', + kind: 'text', + }, + content: '# Guide', + }) + + expect( + readFile.handler(createToolCall('read_skill_file', { skillName: 'docs', path: 'icon.png' }), {} as never), + ).toMatchObject({ + error: 'binary_file_not_readable', + file: { + skillName: 'docs', + path: 'icon.png', + kind: 'binary', + }, + }) + }) + + it('filters listed files by skill name', () => { + const [listFiles] = createSkillRuntimeTools([ + { + name: 'docs', + description: 'Docs skill', + instructions: 'Use docs.', + files: [ + { + path: 'guide.md', + kind: 'text', + content: '# Guide', + }, + ], + }, + { + name: 'vue', + description: 'Vue skill', + instructions: 'Use Vue.', + files: [ + { + path: 'sfc.md', + kind: 'text', + content: '# SFC', + }, + ], + }, + ]) + + expect(listFiles.handler(createToolCall('list_skill_files', { skillName: 'vue' }), {} as never)).toMatchObject({ + files: [ + { + skillName: 'vue', + path: 'sfc.md', + }, + ], + }) + }) + + it('returns stable errors when reading skill files with invalid arguments', () => { + const [, readFile] = createSkillRuntimeTools([ + { + name: 'docs', + description: 'Docs skill', + instructions: 'Use docs.', + files: [ + { + path: 'guide.md', + kind: 'text', + content: '# Guide', + }, + ], + }, + ]) + + expect(readFile.handler(createToolCallWithArguments('read_skill_file', '{'), {} as never)).toEqual({ + error: 'skill_not_found', + }) + expect(readFile.handler(createToolCall('read_skill_file', { skillName: 'docs' }), {} as never)).toEqual({ + error: 'file_path_required', + skillName: 'docs', + }) + expect( + readFile.handler(createToolCall('read_skill_file', { skillName: 'docs', path: 'missing.md' }), {} as never), + ).toEqual({ + error: 'file_not_found', + skillName: 'docs', + path: 'missing.md', + }) + }) + + it('creates a skill command runtime tool when an executor is provided', async () => { + const [executeCommand] = createSkillRuntimeTools( + [ + { + name: 'ppt', + description: 'Presentation skill', + instructions: 'Use ppt commands.', + metadata: { + runtime: { + id: 'ppt-runtime', + }, + }, + }, + ], + { + executeSkillCommand: async (request) => ({ + ok: true, + runtimeId: request.skill.metadata?.runtime, + command: request.command, + args: request.args, + }), + }, + ) + + expect(executeCommand.tool.function.name).toBe('execute_skill_command') + await expect( + executeCommand.handler( + createToolCall('execute_skill_command', { + skillName: 'ppt', + command: 'ppt-render', + args: ['--input', 'deck.pptx'], + }), + {} as never, + ), + ).resolves.toMatchObject({ + ok: true, + command: 'ppt-render', + args: ['--input', 'deck.pptx'], + }) + }) + + it('does not create a skill command runtime tool without an executor', () => { + expect( + createSkillRuntimeTools([{ name: 'ppt', description: 'Presentation skill', instructions: 'Use ppt.' }]), + ).toEqual([]) + }) + + it('returns stable errors when executing skill commands with invalid arguments', async () => { + const [executeCommand] = createSkillRuntimeTools( + [{ name: 'ppt', description: 'Presentation skill', instructions: 'Use ppt.' }], + { + executeSkillCommand: async () => ({ ok: true }), + }, + ) + + await expect( + executeCommand.handler(createToolCall('execute_skill_command', { command: 'ppt-render', args: [] }), {} as never), + ).resolves.toEqual({ + error: 'skill_not_found', + }) + await expect( + executeCommand.handler(createToolCall('execute_skill_command', { skillName: 'ppt', args: [] }), {} as never), + ).resolves.toEqual({ + error: 'command_required', + skillName: 'ppt', + }) + await expect( + executeCommand.handler( + createToolCall('execute_skill_command', { skillName: 'ppt', command: 'ppt-render', args: '--input' }), + {} as never, + ), + ).resolves.toEqual({ + error: 'args_required', + skillName: 'ppt', + command: 'ppt-render', + }) + }) +}) + +const createToolCall = (name: string, args: Record) => ({ + ...createToolCallWithArguments(name, JSON.stringify(args)), +}) + +const createToolCallWithArguments = (name: string, args: string) => ({ + id: `call_${name}`, + type: 'function' as const, + function: { + name, + arguments: args, + }, +}) diff --git a/packages/kit/src/skills/test/skillLoader.test.ts b/packages/kit/src/skills/test/skillLoader.test.ts new file mode 100644 index 000000000..ce083deb0 --- /dev/null +++ b/packages/kit/src/skills/test/skillLoader.test.ts @@ -0,0 +1,205 @@ +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { loadSkillFilesFromFs } from '../fsSkillFiles' +import { SkillLoader } from '../skillLoader' + +describe('SkillLoader', () => { + it('loads weather skill directory as SkillDefinition', async () => { + const skillDirectory = fileURLToPath(new URL('./.cache/weather', import.meta.url)) + const files = await loadSkillFilesFromFs(skillDirectory) + const loadedSkill = new SkillLoader().load(files) + const { skill } = loadedSkill + + expect(skill.name).toBe('weather') + expect(skill.description).toContain('weather') + expect(skill.instructions).toContain('# Weather Skill') + expect(skill.metadata?.homepage).toBe('https://wttr.in/:help') + expect(loadedSkill.warnings).toEqual([]) + }) + + it('loads multi-file skill references as files', async () => { + const skillDirectory = fileURLToPath(new URL('./.cache/vue-best-practices', import.meta.url)) + const files = await loadSkillFilesFromFs(skillDirectory) + const loadedSkill = new SkillLoader().load(files) + const { skill } = loadedSkill + + expect(skill.name).toBe('vue-best-practices') + expect(skill.description).toContain('Vue.js tasks') + expect(skill.instructions).toContain('# Vue Best Practices Workflow') + expect(skill.metadata).toMatchObject({ + author: 'github.com/vuejs-ai', + version: '18.0.0', + }) + expect(skill.files).toBeDefined() + expect(skill.files).toHaveLength(files.length - 1) + expect(skill.files?.map((file) => file.path)).toEqual( + expect.arrayContaining([ + 'references/reactivity.md', + 'references/sfc.md', + 'references/component-data-flow.md', + 'references/composables.md', + ]), + ) + expect(skill.files?.find((file) => file.path === 'references/reactivity.md')).toMatchObject({ + path: 'references/reactivity.md', + kind: 'text', + content: expect.stringContaining('# Reactivity'), + }) + expect(loadedSkill.warnings).toEqual([]) + }) + + it('keeps binary files as skill files', () => { + const image = new Uint8Array([1, 2, 3]) + const loadedSkill = new SkillLoader().load([ + { + path: 'SKILL.md', + kind: 'text', + content: [ + '---', + 'name: binary-skill', + 'description: Skill with binary assets', + '---', + '', + '# Binary Skill', + ].join('\n'), + }, + { + path: 'assets/icon.png', + kind: 'binary', + content: image, + mimeType: 'image/png', + size: image.byteLength, + lastModified: 123, + }, + ]) + + expect(loadedSkill.skill.files).toEqual([ + { + path: 'assets/icon.png', + kind: 'binary', + content: image, + mimeType: 'image/png', + size: 3, + lastModified: 123, + }, + ]) + expect(loadedSkill.warnings).toEqual([]) + }) + + it('throws when the entry file is missing', () => { + expect(() => new SkillLoader().load([])).toThrow('Skill entry file "SKILL.md" is missing.') + }) + + it('throws when the entry file is binary', () => { + expect(() => + new SkillLoader().load([ + { + path: 'SKILL.md', + kind: 'binary', + content: new Uint8Array([1, 2, 3]), + }, + ]), + ).toThrow('Skill entry file "SKILL.md" must be a text file.') + }) + + it('throws when the entry file has no instructions', () => { + expect(() => + new SkillLoader().load([ + { + path: 'SKILL.md', + kind: 'text', + content: ['---', 'name: empty-skill', 'description: Empty skill', '---', ''].join('\n'), + }, + ]), + ).toThrow('Skill entry file "SKILL.md" must contain instructions.') + }) + + it('reports duplicate and unsupported file warnings', () => { + const loadedSkill = new SkillLoader().load([ + { + path: 'SKILL.md', + kind: 'text', + content: ['---', 'name: warning-skill', 'description: Warning skill', '---', '', '# Warning'].join('\n'), + }, + { + path: 'notes.md', + kind: 'text', + content: 'first', + }, + { + path: 'notes.md', + kind: 'text', + content: 'second', + }, + { + path: 'script.ts', + kind: 'text', + content: 'export {}', + }, + ]) + + expect(loadedSkill.warnings).toEqual([ + { + code: 'duplicate-path', + message: 'Duplicate skill file path: notes.md', + path: 'notes.md', + }, + { + code: 'unsupported-text-file-ignored', + message: 'Only markdown, text, and json files are converted to text skill files.', + path: 'script.ts', + }, + ]) + expect(loadedSkill.skill.files?.map((file) => file.path)).toEqual(['notes.md']) + }) + + it('throws warnings as errors in strict mode', () => { + expect(() => + new SkillLoader({ strict: true }).load([ + { + path: 'SKILL.md', + kind: 'text', + content: ['---', 'name: strict-skill', 'description: Strict skill', '---', '', '# Strict'].join('\n'), + }, + { + path: 'notes.md', + kind: 'text', + content: 'first', + }, + { + path: 'notes.md', + kind: 'text', + content: 'second', + }, + ]), + ).toThrow('notes.md: Duplicate skill file path: notes.md') + }) + + it('keeps json files as regular skill files', () => { + const loadedSkill = new SkillLoader().load([ + { + path: 'SKILL.md', + kind: 'text', + content: ['---', 'name: tool-skill', 'description: Tool skill', '---', '', '# Tool'].join('\n'), + }, + { + path: 'references/weather-format.json', + kind: 'text', + content: JSON.stringify({ + type: 'function', + function: { + name: 'run_tool', + description: 'Run tool', + parameters: { + type: 'object', + properties: {}, + }, + }, + }), + }, + ]) + + expect(loadedSkill.skill.files?.map((file) => file.path)).toEqual(['references/weather-format.json']) + expect(loadedSkill.warnings).toEqual([]) + }) +}) diff --git a/packages/kit/src/skills/test/skillManager.test.ts b/packages/kit/src/skills/test/skillManager.test.ts new file mode 100644 index 000000000..0e54e56af --- /dev/null +++ b/packages/kit/src/skills/test/skillManager.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' +import { SkillManager } from '../manager' + +const skill = (name: string, description = `${name} skill`) => ({ + name, + description, + instructions: `${name} instructions`, +}) + +describe('SkillManager', () => { + it('sets, lists, and removes skills', () => { + const manager = new SkillManager() + + manager.set(skill('weather')) + expect(manager.has('weather')).toBe(true) + expect(manager.get('weather')?.description).toBe('weather skill') + expect(manager.list().map((item) => item.name)).toEqual(['weather']) + + manager.set(skill('weather', 'Updated weather skill')) + expect(manager.get('weather')).toMatchObject({ + name: 'weather', + description: 'Updated weather skill', + }) + + expect(manager.remove('weather')?.name).toBe('weather') + expect(manager.list()).toEqual([]) + }) + + it('replaces existing skills with the same name', () => { + const manager = new SkillManager({ skills: [skill('weather')] }) + + manager.set(skill('weather', 'Replacement weather skill')) + + expect(manager.get('weather')?.description).toBe('Replacement weather skill') + }) + + it('manages selected skills in selection order', () => { + const weather = skill('weather') + const vue = skill('vue') + const manager = new SkillManager({ + skills: [weather, vue], + selectedSkillNames: ['vue'], + }) + + manager.select('weather') + expect(manager.getSelectedSkillNames()).toEqual(['vue', 'weather']) + expect(manager.getSelectedSkills()).toEqual([vue, weather]) + + manager.unselect('vue') + expect(manager.getSelectedSkillNames()).toEqual(['weather']) + }) + + it('throws when selecting missing skills', () => { + const manager = new SkillManager({ skills: [skill('weather')] }) + + expect(() => manager.select('missing')).toThrow('Skill "missing" does not exist.') + }) + + it('throws when initialized with missing selected skills', () => { + expect(() => new SkillManager({ selectedSkillNames: ['missing'] })).toThrow('Skill "missing" does not exist.') + }) + + it('removes deleted skills from the selection', () => { + const manager = new SkillManager({ + skills: [skill('weather')], + selectedSkillNames: ['weather'], + }) + + manager.remove('weather') + + expect(manager.getSelectedSkillNames()).toEqual([]) + expect(manager.getSelectedSkills()).toEqual([]) + }) + + it('clears skills and selected skills', () => { + const manager = new SkillManager({ + skills: [skill('weather')], + selectedSkillNames: ['weather'], + }) + + manager.clear() + + expect(manager.list()).toEqual([]) + expect(manager.getSelectedSkillNames()).toEqual([]) + }) + + it('imports skills through SkillLoader', () => { + const manager = new SkillManager() + const result = manager.import([ + { + path: 'SKILL.md', + kind: 'text', + content: ['---', 'name: docs', 'description: Docs skill', '---', '', '# Docs'].join('\n'), + }, + ]) + + expect(result.skill.name).toBe('docs') + expect(manager.get('docs')).toBe(result.skill) + }) + + it('replaces imported skills with the same name', () => { + const manager = new SkillManager({ skills: [skill('docs')] }) + + manager.import([ + { + path: 'SKILL.md', + kind: 'text', + content: ['---', 'name: docs', 'description: Imported docs skill', '---', '', '# Docs'].join('\n'), + }, + ]) + + expect(manager.get('docs')?.description).toBe('Imported docs skill') + }) + + it('passes loader options when importing skills', () => { + const manager = new SkillManager() + + const result = manager.import( + [ + { + path: 'README.md', + kind: 'text', + content: ['---', 'name: custom-entry', 'description: Custom entry skill', '---', '', '# Custom'].join('\n'), + }, + ], + { entryFile: 'README.md' }, + ) + + expect(result.skill.name).toBe('custom-entry') + expect(manager.get('custom-entry')).toBe(result.skill) + }) +}) diff --git a/packages/kit/src/skills/test/skillPlugin.test.ts b/packages/kit/src/skills/test/skillPlugin.test.ts new file mode 100644 index 000000000..936a48f2a --- /dev/null +++ b/packages/kit/src/skills/test/skillPlugin.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, it, vi } from 'vitest' +import { createNativeMessageAdapter } from '../../message/adapters/native' +import { createMessageEngine } from '../../message/core/engine' +import { lengthPlugin, skillPlugin, thinkingPlugin, toolPlugin } from '../../message/plugins' +import type { SkillDefinition } from '../types' +import type { CreateMessageEngineOptions, MessageRequestBody } from '../../message/types' +import { mockResponseProvider } from '../../message/test/mockResponseProvider' + +const silentDefaultPlugins = [thinkingPlugin({ disabled: true }), lengthPlugin({ disabled: true })] + +const createTestMessageEngine = (options: CreateMessageEngineOptions) => + createMessageEngine(createNativeMessageAdapter(), options) + +describe('skillPlugin', () => { + it('injects skill instructions before request', async () => { + const responseProvider = vi.fn(mockResponseProvider('ok')) + const weatherSkill: SkillDefinition = { + name: 'weather', + description: 'Weather skill', + instructions: 'Use wttr.in for weather requests.', + } + + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + skillPlugin({ + getSkills: () => [weatherSkill], + }), + toolPlugin({ + getTools: async () => [], + callTool: async () => 'fallback', + }), + ], + responseProvider, + }) + + await engine.sendMessage('weather in London') + + const requestBody = responseProvider.mock.calls[0]?.[0] + expect(requestBody.messages[0]).toMatchObject({ + role: 'system', + content: expect.stringContaining('Use wttr.in for weather requests.'), + }) + expect(requestBody.messages[1]).toMatchObject({ role: 'user', content: 'weather in London' }) + expect(requestBody.tools).toBeUndefined() + }) + + it('executes built-in skill file runtime tools from turn state', async () => { + const responseProvider = vi.fn(async (requestBody: MessageRequestBody) => { + const hasToolResult = requestBody.messages.some((message) => message.role === 'tool') + + if (!hasToolResult) { + return { + id: 'tool-call', + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: 'mock', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call-1', + type: 'function', + function: { + name: 'read_skill_file', + arguments: JSON.stringify({ + skillName: 'vue-best-practices', + path: 'references/reactivity.md', + }), + }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + } + } + + expect(JSON.parse(requestBody.messages.at(-1)?.content as string)).toMatchObject({ + file: { + skillName: 'vue-best-practices', + path: 'references/reactivity.md', + kind: 'text', + }, + content: '# Reactivity', + }) + + return { + id: 'final-answer', + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: 'mock', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'done', + }, + finish_reason: 'stop', + }, + ], + } + }) + const vueSkill: SkillDefinition = { + name: 'vue-best-practices', + description: 'Vue skill', + instructions: 'Follow Vue best practices.', + files: [ + { + path: 'references/reactivity.md', + kind: 'text', + content: '# Reactivity', + mimeType: 'text/markdown', + }, + ], + } + + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + toolPlugin({ + getTools: async () => [], + callTool: async () => { + throw new Error('fallback should not run') + }, + }), + skillPlugin({ + getSkills: () => [vueSkill], + }), + ], + responseProvider, + }) + + await engine.sendMessage('read skill file') + + expect(responseProvider).toHaveBeenCalledTimes(2) + expect(engine.getState().messages.at(-1)).toMatchObject({ + role: 'assistant', + content: 'done', + }) + }) + + it('does not inject instructions or tools when getSkills returns undefined', async () => { + const responseProvider = vi.fn(mockResponseProvider('ok')) + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + skillPlugin({ + getSkills: () => undefined, + }), + toolPlugin({ + getTools: async () => [], + callTool: async () => 'fallback', + }), + ], + responseProvider, + }) + + await engine.sendMessage('hello') + + const requestBody = responseProvider.mock.calls[0]?.[0] + expect(requestBody.messages[0]).toMatchObject({ role: 'user', content: 'hello' }) + expect(requestBody.tools).toBeUndefined() + }) + + it('executes skill command runtime tools through the provided executor', async () => { + const executeSkillCommand = vi.fn(async (request) => ({ + ok: true, + skillName: request.skillName, + command: request.command, + args: request.args, + runtime: request.skill.metadata?.runtime, + })) + const responseProvider = vi.fn(async (requestBody: MessageRequestBody) => { + const hasToolResult = requestBody.messages.some((message) => message.role === 'tool') + + if (!hasToolResult) { + expect(requestBody.tools?.map((tool) => tool.function.name)).toContain('execute_skill_command') + + return { + id: 'command-call', + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: 'mock', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call-1', + type: 'function', + function: { + name: 'execute_skill_command', + arguments: JSON.stringify({ + skillName: 'ppt', + command: 'ppt-render', + args: ['--input', 'deck.pptx'], + }), + }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + } + } + + expect(JSON.parse(requestBody.messages.at(-1)?.content as string)).toMatchObject({ + ok: true, + skillName: 'ppt', + command: 'ppt-render', + args: ['--input', 'deck.pptx'], + }) + + return { + id: 'final-answer', + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: 'mock', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'done', + }, + finish_reason: 'stop', + }, + ], + } + }) + const pptSkill: SkillDefinition = { + name: 'ppt', + description: 'Presentation skill', + instructions: 'Use ppt commands.', + metadata: { + runtime: { + id: 'ppt-runtime', + }, + }, + } + + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + toolPlugin({ + getTools: async () => [], + callTool: async () => { + throw new Error('fallback should not run') + }, + }), + skillPlugin({ + getSkills: () => [pptSkill], + executeSkillCommand, + }), + ], + responseProvider, + }) + + await engine.sendMessage('render ppt') + + expect(executeSkillCommand).toHaveBeenCalledWith( + expect.objectContaining({ + skill: pptSkill, + skillName: 'ppt', + command: 'ppt-render', + args: ['--input', 'deck.pptx'], + }), + expect.objectContaining({ + customContext: expect.any(Object), + }), + ) + expect(responseProvider).toHaveBeenCalledTimes(2) + }) + + it('resolves plugin state before running custom turn hooks', async () => { + const resolvedState = vi.fn() + const turnStart = vi.fn() + const weatherSkill: SkillDefinition = { + name: 'weather', + description: 'Weather skill', + instructions: 'Use wttr.in.', + } + const responseProvider = vi.fn(mockResponseProvider('ok')) + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + skillPlugin({ + getSkills: () => [weatherSkill], + onSkillsResolved: (skillContext) => { + resolvedState(skillContext.skillNames) + }, + onTurnStart: (context) => { + turnStart(context.customContext.__tiny_robot_skill) + }, + }), + ], + responseProvider, + }) + + await engine.sendMessage('weather') + + expect(resolvedState).toHaveBeenCalledWith(['weather']) + expect(turnStart).toHaveBeenCalledWith( + expect.objectContaining({ + skills: [weatherSkill], + skillNames: ['weather'], + }), + ) + expect(resolvedState.mock.invocationCallOrder[0]).toBeLessThan(turnStart.mock.invocationCallOrder[0]) + }) +}) diff --git a/packages/kit/src/skills/types.ts b/packages/kit/src/skills/types.ts new file mode 100644 index 000000000..1075464a5 --- /dev/null +++ b/packages/kit/src/skills/types.ts @@ -0,0 +1,67 @@ +export type SkillFileKind = 'text' | 'binary' + +/** + * skill 文件的公共元数据。 + */ +export interface BaseSkillFile { + /** + * 基于 skill 根目录的相对路径,使用 "/" 分隔,不能以 "/" 开头,也不能包含 ".." 片段。 + */ + path: string + /** + * 文件来源提供的 MIME 类型。 + */ + mimeType?: string + /** + * 文件大小,单位为字节。 + */ + size?: number + /** + * 文件来源提供的最后修改时间戳。 + */ + lastModified?: number + /** + * 应用侧自定义的来源元数据。 + */ + metadata?: Record +} + +export interface TextSkillFile extends BaseSkillFile { + kind: 'text' + content: string +} + +export interface BinarySkillFile extends BaseSkillFile { + kind: 'binary' + content: ArrayBuffer | Uint8Array +} + +export type SkillFile = TextSkillFile | BinarySkillFile + +/** + * skill 能力模板。 + * + * skill 可以提供指令和文件资源,并被编译到消息请求中。 + */ +export interface SkillDefinition { + /** + * 唯一的 skill 名称。 + */ + name: string + /** + * 用于发现、匹配或展示的能力描述。 + */ + description: string + /** + * 注入模型请求的指令。 + */ + instructions: string + /** + * 可供 skill 文件运行时工具读取的文件。 + */ + files?: SkillFile[] + /** + * 应用侧自定义元数据。 + */ + metadata?: Record +} diff --git a/packages/kit/src/skills/utils.ts b/packages/kit/src/skills/utils.ts new file mode 100644 index 000000000..044b38b0e --- /dev/null +++ b/packages/kit/src/skills/utils.ts @@ -0,0 +1,26 @@ +export const normalizeSkillPath = (path: string) => { + const normalized = path + .split('\\') + .join('/') + .replace(/^\.\/+/, '') + + if (!normalized || normalized.startsWith('/') || normalized.includes('\0')) { + return null + } + + if (normalized.split('/').some((part) => part === '..' || part === '')) { + return null + } + + return normalized +} + +export const isTextSkillFilePath = (path: string) => { + return ['.md', '.txt', '.json'].includes(getExtension(path)) +} + +export const getExtension = (path: string) => { + const filename = path.split('/').at(-1) || path + const index = filename.lastIndexOf('.') + return index === -1 ? '' : filename.slice(index).toLowerCase() +} diff --git a/packages/kit/src/vue/message/mockResponseProvider.ts b/packages/kit/src/vue/message/mockResponseProvider.ts index 6d2275032..03eb1c75d 100644 --- a/packages/kit/src/vue/message/mockResponseProvider.ts +++ b/packages/kit/src/vue/message/mockResponseProvider.ts @@ -1,4 +1,4 @@ -import type { ChatCompletionChunk } from 'openai/resources/index' +import type { ChatCompletionChunk } from 'openai/resources' import type { ToolCall } from '../../types' import type { MessageRequestBody, ResponseProvider } from './types' diff --git a/packages/kit/src/vue/message/plugins/index.ts b/packages/kit/src/vue/message/plugins/index.ts index 4cad5e41a..25639732a 100644 --- a/packages/kit/src/vue/message/plugins/index.ts +++ b/packages/kit/src/vue/message/plugins/index.ts @@ -1,3 +1,4 @@ export * from './lengthPlugin' +export * from './skillPlugin' export * from './thinkingPlugin' export * from './toolPlugin' diff --git a/packages/kit/src/vue/message/plugins/skillPlugin.ts b/packages/kit/src/vue/message/plugins/skillPlugin.ts new file mode 100644 index 000000000..accd9f0a1 --- /dev/null +++ b/packages/kit/src/vue/message/plugins/skillPlugin.ts @@ -0,0 +1,61 @@ +import type { ComputedRef, Ref } from 'vue' +import { isRef, unref } from 'vue' +import type { SkillRequestContext } from '../../../message/plugins' +import { skillPlugin as createCoreSkillPlugin } from '../../../message/plugins' +import type { SkillCommandRequest, SkillCommandResult } from '../../../skills/compiler' +import type { SkillDefinition } from '../../../skills/types' +import type { MaybePromise } from '../../../types' +import type { BasePluginContext, UseMessagePlugin } from '../types' +import type { VueMessagePluginRuntime } from '../types.internal' + +export type VueSkillSource = SkillDefinition[] | undefined +export type VueSkillSourceRef = VueSkillSource | Ref | ComputedRef + +export type UseMessageSkillPluginOptions = UseMessagePlugin & { + /** + * 当前请求要使用的 skills。支持普通数组、ref 或 computed。 + */ + skills?: VueSkillSourceRef + /** + * 动态返回当前请求要使用的 skills。 + */ + getSkills?: (context: BasePluginContext) => MaybePromise + /** + * 执行模型为某个 skill 规划的后端命令。 + * + * @experimental 该 API 仍在设计和验证中,命令协议、返回结构和安全边界后续可能调整。 + */ + executeSkillCommand?: (request: SkillCommandRequest, context: BasePluginContext) => MaybePromise + /** + * skills 解析并转换为请求上下文后触发。 + */ + onSkillsResolved?: (skillContext: SkillRequestContext, context: BasePluginContext) => MaybePromise +} + +const resolveSkillSource = (source: VueSkillSourceRef): VueSkillSource => { + return isRef(source) ? (unref(source) as VueSkillSource) : source +} + +export const skillPlugin = (options: UseMessageSkillPluginOptions): UseMessagePlugin => { + const { skills, getSkills, executeSkillCommand, onSkillsResolved, ...restOptions } = options + + return { + name: 'skill', + __corePluginFactory(runtime: VueMessagePluginRuntime) { + return createCoreSkillPlugin({ + ...runtime.createCorePlugin(restOptions), + getSkills: async (context) => { + const vueContext = runtime.createVueBaseContext(context) + const skillSource = getSkills ? await getSkills(vueContext) : skills + return resolveSkillSource(skillSource) + }, + executeSkillCommand: executeSkillCommand + ? (request, context) => executeSkillCommand(request, runtime.createVueBaseContext(context)) + : undefined, + onSkillsResolved: onSkillsResolved + ? (skillContext, context) => onSkillsResolved(skillContext, runtime.createVueBaseContext(context)) + : undefined, + }) + }, + } as UseMessagePlugin +} diff --git a/packages/kit/src/vue/message/plugins/toolPlugin.ts b/packages/kit/src/vue/message/plugins/toolPlugin.ts index 564bfb62e..07cfac2ec 100644 --- a/packages/kit/src/vue/message/plugins/toolPlugin.ts +++ b/packages/kit/src/vue/message/plugins/toolPlugin.ts @@ -1,12 +1,17 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { toolPlugin as createCoreToolPlugin } from '../../../message/plugins' +import type { ToolProviderItem, ToolSource } from '../../../message/plugins' import { normalizeToAsyncGenerator } from '../../../message/utils' import { ChatMessage, ToolCall } from '../../../types' import type { VueMessagePluginRuntime } from '../types.internal' -import { BasePluginContext, Tool, UseMessagePlugin } from '../types' +import { BasePluginContext, UseMessagePlugin } from '../types' export interface UseMessageToolActionContext extends BasePluginContext { assistantMessage: ChatMessage + /** + * 当前工具的来源。 + */ + toolSource?: ToolSource /** * @deprecated use `assistantMessage` instead */ @@ -19,6 +24,10 @@ export interface UseMessageCallToolContext extends UseMessageToolActionContext { export interface UseMessageToolCallContext extends BasePluginContext { assistantMessage: ChatMessage + /** + * 当前工具的来源。 + */ + toolSource: ToolSource /** * @deprecated use `assistantMessage` instead */ @@ -31,7 +40,7 @@ export const toolPlugin = ( /** * 获取工具列表的函数。 */ - getTools: () => Promise + getTools: (context: BasePluginContext) => Promise /** * 在处理包含 tool_calls 的响应前调用。 */ @@ -100,7 +109,7 @@ export const toolPlugin = ( return createCoreToolPlugin({ ...wrappedRestOptions, - getTools: async () => (await getTools()) as any, + getTools: async (context) => getTools(runtime.createVueBaseContext(context)), beforeCallTools: beforeCallTools ? async (toolCalls, context) => { const assistantMessage = runtime.resolveReactiveMessage(context.assistantMessage as ChatMessage) @@ -123,6 +132,7 @@ export const toolPlugin = ( assistantMessage, currentMessage: assistantMessage, toolMessage, + toolSource: context.toolSource, } as UseMessageCallToolContext, ) @@ -140,6 +150,7 @@ export const toolPlugin = ( assistantMessage, primaryMessage: assistantMessage, toolMessage, + toolSource: context.toolSource, }) } : undefined, @@ -153,6 +164,7 @@ export const toolPlugin = ( assistantMessage, primaryMessage: assistantMessage, toolMessage, + toolSource: context.toolSource, status: context.status, error: context.error, }) diff --git a/packages/kit/src/vue/message/useMessage.test.ts b/packages/kit/src/vue/message/useMessage.test.ts index 4a05871ba..ab8a96559 100644 --- a/packages/kit/src/vue/message/useMessage.test.ts +++ b/packages/kit/src/vue/message/useMessage.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import type { SkillDefinition } from '../../skills/types' import type { ChatMessage } from '../../types' import { mockResponseProvider, mockSequentialResponseProvider } from './mockResponseProvider' import { lengthPlugin } from './plugins/lengthPlugin' +import { skillPlugin } from './plugins/skillPlugin' import { toolPlugin } from './plugins/toolPlugin' import type { ResponseProvider } from './types' import { useMessage } from './useMessage' @@ -191,4 +194,43 @@ describe('useMessage', () => { content: 'done', }) }) + + it('uses vue skillPlugin with reactive skills', async () => { + const skills = ref([ + { + name: 'docs', + description: 'Docs skill', + instructions: 'Use docs references.', + files: [ + { + id: 'guide.md', + path: 'guide.md', + kind: 'text', + content: '# Guide', + }, + ], + }, + ]) + const responseProvider = vi.fn(mockResponseProvider('ok')) + + const engine = useMessage({ + responseProvider, + plugins: [ + skillPlugin({ skills }), + toolPlugin({ + getTools: async () => [], + callTool: async () => 'fallback', + }), + ], + }) + + await engine.sendMessage('read docs') + + const requestBody = responseProvider.mock.calls[0]?.[0] + expect(requestBody.messages[0]).toMatchObject({ + role: 'system', + content: expect.stringContaining('Use docs references.'), + }) + expect(requestBody.tools?.map((tool) => tool.function.name)).toEqual(['list_skill_files', 'read_skill_file']) + }) })
从示例或本地目录导入 skill,再用 manager 选择本次要编译的 skill。
{{ errorMessage }}
{{ inspectedDefinitionJson }}
{{ compiledInstructionsText }}
{{ compiledToolsJson }}