diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..a0c0d3bf3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,169 @@ +# 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 sources, persisted or restored by storage, selected by application state, and connected to prompt instructions plus runtime tools for the message engine. + +## Current Architecture + +- `packages/kit/src/skills` + - Core skill toolchain modules. + - Owns skill loading, skill types, capabilities, storage, and skill tests. + - Browser-safe skill APIs are exported from `@opentiny/tiny-robot-kit/core`. + - Node-only skill loaders and storage APIs are exported from `@opentiny/tiny-robot-kit/node`. +- `packages/kit/src/message/plugins/skillPlugin.ts` + - Message runtime adapter only. + - Bridges request-level skill selection into message engine hooks. +- `packages/kit/src/message/plugins` + - Message plugins and runtime protocols. + - Must not own or re-export skill core logic, but may export message plugin APIs and plugin option types. + +## Package Manager + +This repository uses pnpm for dependency and script management. Prefer `pnpm` commands over `npm` commands. + +## Skill Layers + +- Definition + - `SkillDefinition` is the runtime contract for an AI-usable skill. + - It contains `name`, `description`, `instructions`, optional `resources`, and optional `metadata`. + - Runtime code should consume `SkillDefinition`, regardless of whether it came from a loader, storage, or in-memory state. +- Loader + - Converts platform-specific sources directly into `SkillDefinition`. + - Browser-safe loader entry lives in `packages/kit/src/skills/loader/index.ts`. + - Node-only loader entry lives in `packages/kit/src/skills/loader/node.ts`. + - Browser sources use `{ source: 'browser', fileList }` or `{ source: 'browser', directoryHandle }`. + - Node sources use `{ source: 'fs', root }` through the node subpath. + - GitHub sources use `{ source: 'github', repo, ref?, path }`. + - Source adapters inside loader may produce internal `LoadableSkillFile[]`, but `createSkillDefinition` is called by the loader entry, not by each adapter. + - Loader owns source parsing, but must not own persistence, skill collections, or selection state. +- Storage + - Persists and restores `SkillDefinition`; storage is a `SkillDefinition` provider parallel to loader. + - `storage.add(skill)` stores an already loaded complete `SkillDefinition`. + - `storage.import(options)` is a convenience composition of loader plus add; it must call loader logic rather than reimplement source parsing. + - Browser-safe storage import uses browser/core loader sources. Node-only storage capabilities should live behind node-only entry points. + - Storage may restore resources lazily through `resourceId`, `readText`, and `readBinary`. + - Loader output usually keeps full resource data in memory through `text` and `binary`. +- Capabilities + - Convert selected skills or candidate summaries into runtime tools and capability-specific instructions. + - Selection capability lives in `packages/kit/src/skills/capabilities/selection.ts` and provides `select_skills`. + - Resource capability lives in `packages/kit/src/skills/capabilities/resources.ts` and provides `list_skill_files` / `read_skill_file`. + - Command capability currently only provides command-related types; `execute_skill_command` is not implemented. +- Plugin Adapter + - Connects skill instructions and capabilities to message engine lifecycle. + - Lives in `packages/kit/src/message/plugins/skillPlugin.ts`. +- Selection + - Long-lived selection state is application-owned, typically a selected skill name array or selected `SkillDefinition[]`. + - Kit does not need a manager layer for selection; applications can combine `storage.list()` / `storage.get()` with their own selected names. + - `skillPlugin` supports request-level `manual`, `auto`, and `none` selection snapshots. + - Auto selection is a message interaction flow: candidate summaries plus `select_skills`, followed by resolving selected names into full `SkillDefinition[]`. + - Storage must not own selection state. + +## 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 a request-level `selection` snapshot and resolves skills through caller-provided `getSkillByName` / `getSkillCandidates`. +- Do not use `activeSkills` naming in the skill plugin or capabilities. Use selected/enabled skill terminology. +- Capabilities may compile capability-specific prompt instructions and runtime tools, but must not manage persistence, long-lived selection state, or storage. +- Capability factories are internal to `skillPlugin`; do not export `createSkillResource*` or `createSkillSelection*` from public skill APIs. +- Selected skill instructions are injected inside `skillPlugin`; do not reintroduce a public skill instruction compiler unless there is a clear non-plugin use case. +- Loader source adapters may read platform file sources, but must not create `SkillDefinition`; loader entries call `createSkillDefinition`. +- Loader may parse/import skill sources into a `SkillDefinition`, but must not own skill collections or persistence. +- Storage may persist and restore `SkillDefinition`, but source import paths must reuse loader logic. +- Selection state belongs to application code; kit core should not reintroduce a manager that owns skill collections or long-lived selection state. +- 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 and plugin option types; skill core APIs belong to `src/skills`. +- Skill command execution is not implemented in `skillPlugin`. Do not add PPT/PDF/browser/document backends to kit; future command execution should route tool calls to application-provided sandbox executors. + +## Current Public API Shape + +```ts +skillPlugin({ + selection: { + mode: 'manual', + skillNames: requestedSkillNames, + }, + getSkillByName: (name) => storage.get(name), +}) + +skillPlugin({ + selection: { + mode: 'auto', + preferredSkillNames, + }, + getSkillCandidates: () => storage.list(), + getSkillByName: (name) => storage.get(name), +}) +``` + +Vue adapter also accepts reactive selected skills: + +```ts +skillPlugin({ + skills: selectedSkills, +}) +``` + +`SkillDefinition` currently contains `name`, `description`, `instructions`, optional `resources`, and optional `metadata`. + +Resources can hold eager in-memory content with `text` / `binary`, lazy readers with `readText` / `readBinary`, or both. These fields do not need to be mutually exclusive; consumers should prefer eager content when available and fall back to readers. + +Skill request context uses: + +```ts +skillContext.skills +skillContext.skillNames +skillContext.requestedSkillNames +skillContext.unresolvedSkillNames +skillContext.runtimeTools +skillContext.selection +``` + +## Important Files + +- `packages/kit/src/skills/types/index.ts` +- `packages/kit/src/skills/loader/index.ts` +- `packages/kit/src/skills/loader/node.ts` +- `packages/kit/src/skills/loader/browser.ts` +- `packages/kit/src/skills/loader/fs.ts` +- `packages/kit/src/skills/loader/github.ts` +- `packages/kit/src/skills/loader/definition.ts` +- `packages/kit/src/skills/loader/type.ts` +- `packages/kit/src/skills/loader/utils.ts` +- `packages/kit/src/skills/storage/index.ts` +- `packages/kit/src/skills/storage/node.ts` +- `packages/kit/src/skills/storage/importSkill.ts` +- `packages/kit/src/skills/storage/memory.ts` +- `packages/kit/src/skills/storage/types.ts` +- `packages/kit/src/skills/capabilities/selection.ts` +- `packages/kit/src/skills/capabilities/resources.ts` +- `packages/kit/src/skills/capabilities/commands.ts` +- `packages/kit/src/skills/index.ts` +- `packages/kit/src/skills/README.md` +- `packages/kit/src/skills/test/resourceCapability.test.ts` +- `packages/kit/src/skills/test/loaderDefinition.test.ts` +- `packages/kit/src/skills/test/loaderNode.test.ts` +- `packages/kit/src/skills/test/memoryStorage.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 + +- Keep resource usage conservative: model instructions should list files before reading. Future large-file support may add `search_skill_files` for centered snippets and offsets, plus read size limits, truncation diagnostics, and optional range reads. +- Keep duplicate skill name diagnostics in storage or selection logic, not instructions or capabilities. +- Keep selection boundaries separate from storage and loader boundaries. +- Keep command execution as an application-provided sandbox concern. diff --git a/docs/.vitepress/themeConfig.ts b/docs/.vitepress/themeConfig.ts index 552102715..9db057f8a 100644 --- a/docs/.vitepress/themeConfig.ts +++ b/docs/.vitepress/themeConfig.ts @@ -37,6 +37,7 @@ const sharedSidebarItems = [ items: [ { text: 'useMessage 消息数据管理', link: 'message' }, { text: 'useConversation 会话数据管理', link: 'conversation' }, + { text: 'Skill 技能工具链', link: 'skill' }, { text: 'AIClient 模型交互工具类', link: 'ai-client' }, { text: '工具函数', link: 'utils' }, ], diff --git a/docs/demos/tools/skill/SkillInspector.css b/docs/demos/tools/skill/SkillInspector.css new file mode 100644 index 000000000..ab12fdff7 --- /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(220px, 340px) minmax(0, 1fr); + gap: 16px; +} + +.skill-inspector .panel { + min-width: 0; + 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 > div { + min-width: 0; +} + +.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; + overflow-wrap: anywhere; +} + +.skill-inspector .action-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(72px, auto); + gap: 8px; +} + +.skill-inspector button, +.skill-inspector .directory-picker { + min-width: 0; + min-height: 36px; + 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.3; + overflow-wrap: anywhere; + white-space: normal; +} + +.skill-inspector button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.skill-inspector .primary-action { + flex: none; + padding: 8px 12px; +} + +.skill-inspector .danger-action { + padding: 0 10px; +} + +.skill-inspector .primary-action:hover { + border-color: var(--vp-c-brand-1); + color: var(--vp-c-brand-1); +} + +.skill-inspector .danger-action:hover:not(:disabled) { + border-color: var(--vp-c-danger-1); + color: var(--vp-c-danger-1); +} + +.skill-inspector .directory-picker { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + padding: 0 10px; + color: var(--vp-c-text-2); + text-align: center; +} + +.skill-inspector .directory-picker span { + min-width: 0; + overflow-wrap: anywhere; +} + +.skill-inspector .directory-picker input { + display: none; +} + +.skill-inspector .error-message { + margin: 10px 0 0; + font-size: 13px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.skill-inspector .error-message { + color: var(--vp-c-danger-1); +} + +.skill-inspector .skill-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 14px; +} + +.skill-inspector .skill-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: 10px; + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + cursor: pointer; + min-width: 0; +} + +.skill-inspector .skill-item > span { + min-width: 0; +} + +.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; + overflow-wrap: anywhere; +} + +.skill-inspector .skill-item small { + margin-top: 4px; + color: var(--vp-c-text-2); + line-height: 1.5; +} + +.skill-inspector .skill-item em { + flex: none; + color: var(--vp-c-text-3); + font-size: 12px; + font-style: normal; + line-height: 20px; +} + +.skill-inspector .detail-panel { + min-width: 0; +} + +.skill-inspector .storage-viewer { + display: grid; + grid-template-rows: minmax(120px, auto) minmax(220px, 1fr); + gap: 12px; +} + +.skill-inspector .storage-viewer h4 { + margin: 0 0 8px; + font-size: 13px; + overflow-wrap: anywhere; +} + +.skill-inspector .file-tree { + min-width: 0; +} + +.skill-inspector .file-node-list { + max-height: 240px; + overflow: auto; + padding-right: 4px; +} + +.skill-inspector .file-node { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: 26px; + margin-top: 3px; + padding-right: 8px; + gap: 6px; + text-align: left; + font-size: 12px; +} + +.skill-inspector .file-node.active { + border-color: var(--vp-c-brand-1); + color: var(--vp-c-brand-1); + background: var(--vp-c-bg-soft); +} + +.skill-inspector .file-node.folder { + cursor: default; + border-color: transparent; + background: transparent; + color: var(--vp-c-text-2); + font-weight: 600; +} + +.skill-inspector .file-node.folder:hover { + color: var(--vp-c-text-2); +} + +.skill-inspector .file-node span { + min-width: 0; + overflow-wrap: anywhere; +} + +.skill-inspector .file-node em { + flex: none; + color: var(--vp-c-text-3); + font-size: 11px; + font-style: normal; +} + +.skill-inspector .resource-text { + min-width: 0; +} + +.skill-inspector .resource-text pre { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.skill-inspector pre { + min-height: 260px; + 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%; + } + + .skill-inspector .action-row { + grid-template-columns: 1fr; + } +} + +@container (max-width: 760px) { + .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..aae498bd7 --- /dev/null +++ b/docs/demos/tools/skill/SkillInspector.vue @@ -0,0 +1,92 @@ + + + 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..0332e937b --- /dev/null +++ b/docs/demos/tools/skill/VueSkillPlugin.vue @@ -0,0 +1,168 @@ + + + diff --git a/docs/demos/tools/skill/exampleSkillFiles.ts b/docs/demos/tools/skill/exampleSkillFiles.ts new file mode 100644 index 000000000..21acb77fb --- /dev/null +++ b/docs/demos/tools/skill/exampleSkillFiles.ts @@ -0,0 +1,28 @@ +import type { SkillDefinition } from '@opentiny/tiny-robot-kit' + +export const exampleSkills: SkillDefinition[] = [ + { + name: 'weather', + description: 'Answer weather questions with concise current conditions and forecast guidance.', + instructions: `# 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.`, + resources: [ + { + path: 'references/weather-format.md', + kind: 'text', + resourceId: 'references/weather-format.md', + text: 'Return current condition first, then list the next forecast point when available.', + mimeType: 'text/markdown', + }, + { + path: 'references/examples/current-weather.md', + kind: 'text', + resourceId: 'references/examples/current-weather.md', + text: 'Example: Shanghai is cloudy, 24°C. Light rain is possible tonight.', + mimeType: 'text/markdown', + }, + ], + }, +] diff --git a/docs/demos/tools/skill/useSkillInspector.ts b/docs/demos/tools/skill/useSkillInspector.ts new file mode 100644 index 000000000..d52968a92 --- /dev/null +++ b/docs/demos/tools/skill/useSkillInspector.ts @@ -0,0 +1,198 @@ +import { createMemorySkillStorage, loadSkill } from '@opentiny/tiny-robot-kit' +import type { SkillDefinition } from '@opentiny/tiny-robot-kit' +import { computed, ref, watch } from 'vue' +import { exampleSkills } from './exampleSkillFiles' + +type SkillFileNode = { + path: string + label: string + kind: 'entry' | 'folder' | 'text' | 'binary' + depth: number +} + +export const useSkillInspector = () => { + const storage = createMemorySkillStorage() + const skills = ref([]) + const inspectedSkillName = ref('') + const selectedFilePath = ref('SKILL.md') + const selectedFileText = ref('') + const errorMessage = ref('') + + const syncStorageState = async () => { + const summaries = await storage.list() + const loadedSkills = await Promise.all(summaries.map((summary) => storage.get(summary.name))) + skills.value = loadedSkills.filter((skill): skill is SkillDefinition => Boolean(skill)) + + if (!skills.value.some((skill) => skill.name === inspectedSkillName.value)) { + inspectedSkillName.value = skills.value[0]?.name ?? '' + } + + if (!fileNodes.value.some((node) => node.path === selectedFilePath.value)) { + selectedFilePath.value = 'SKILL.md' + } + } + + const runStorageAction = async (action: () => Promise) => { + errorMessage.value = '' + + try { + await action() + await syncStorageState() + } catch (error) { + errorMessage.value = error instanceof Error ? error.message : String(error) + } + } + + const resetExampleSkills = async () => { + await runStorageAction(async () => { + for (const skill of skills.value) { + await storage.delete(skill.name) + } + for (const skill of exampleSkills) { + await storage.add(skill) + } + inspectedSkillName.value = exampleSkills[0]?.name ?? '' + }) + } + + const deleteInspectedSkill = async () => { + const name = inspectedSkillName.value + if (!name) { + return + } + + await runStorageAction(async () => { + await storage.delete(name) + }) + } + + const importDirectory = async (event: Event) => { + const input = event.target as HTMLInputElement + if (!input.files?.length) { + return + } + + await runStorageAction(async () => { + const skill = await loadSkill({ + source: 'browser', + fileList: input.files, + }) + await storage.add(skill) + inspectedSkillName.value = skill.name + }) + + input.value = '' + } + + const inspectSkill = async (skillName: string) => { + await runStorageAction(async () => { + const skill = await storage.get(skillName) + inspectedSkillName.value = skill?.name ?? '' + selectedFilePath.value = 'SKILL.md' + }) + } + + const selectFile = (path: string) => { + selectedFilePath.value = path + } + + const inspectedSkill = computed(() => { + return skills.value.find((skill) => skill.name === inspectedSkillName.value) + }) + + const fileNodes = computed(() => { + const skill = inspectedSkill.value + if (!skill) { + return [] + } + + const nodes: SkillFileNode[] = [ + { + path: 'SKILL.md', + label: 'SKILL.md', + kind: 'entry', + depth: 0, + }, + ] + const folderPaths = new Set() + + for (const resource of skill.resources ?? []) { + const parts = resource.path.split('/').filter(Boolean) + + for (let index = 0; index < parts.length - 1; index += 1) { + const folderPath = parts.slice(0, index + 1).join('/') + if (folderPaths.has(folderPath)) { + continue + } + + folderPaths.add(folderPath) + nodes.push({ + path: folderPath, + label: parts[index], + kind: 'folder', + depth: index, + }) + } + + nodes.push({ + path: resource.path, + label: parts.at(-1) || resource.path, + kind: resource.kind, + depth: Math.max(0, parts.length - 1), + }) + } + + return nodes + }) + + const loadSelectedFileText = async () => { + const skill = inspectedSkill.value + if (!skill) { + selectedFileText.value = '' + return + } + + if (selectedFilePath.value === 'SKILL.md') { + selectedFileText.value = skill.instructions + return + } + + const resource = skill.resources?.find((item) => item.path === selectedFilePath.value) + if (!resource) { + selectedFileText.value = '' + return + } + + if (resource.kind === 'binary') { + selectedFileText.value = `Binary resource: ${resource.path}` + return + } + + selectedFileText.value = resource.text ?? (resource.readText ? await resource.readText() : '') + } + + watch( + [inspectedSkill, selectedFilePath], + () => { + void loadSelectedFileText() + }, + { immediate: true }, + ) + + resetExampleSkills() + + return { + deleteInspectedSkill, + errorMessage, + importDirectory, + inspectSkill, + inspectedSkill, + inspectedSkillName, + fileNodes, + resetExampleSkills, + selectFile, + selectedFilePath, + selectedFileText, + skills, + } +} 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..4a286aac1 --- /dev/null +++ b/docs/src/tools/skill.md @@ -0,0 +1,523 @@ +--- +outline: [1, 3] +--- + +# Skill 技能工具链 + +Skill 是一组可复用的能力模板。一个 skill 至少包含名称、描述和指令,也可以携带资源文件。`@opentiny/tiny-robot-kit` 面向使用者主要暴露三层: + +- **Loader**:从浏览器文件、GitHub 或 Node 文件系统来源加载 skill,输出 `SkillDefinition`。 +- **Storage**:持久化和恢复 `SkillDefinition`。storage 与 loader 平级,二者最终都提供 `SkillDefinition`。 +- **skillPlugin**:把本次请求要启用的 skill 接入对话请求。 + +Kit 只负责加载、保存和在请求中启用 skill;具体展示哪些 skill、用户选中了哪些 skill,由业务界面自己维护。 + +常见接入方式分为两条链路: + +- **Loader -> skillPlugin**:临时使用时,通过 loader 得到 `SkillDefinition` 后交给 `skillPlugin`。 +- **Storage -> skillPlugin**:需要跨会话保留 skill 时,把 storage 作为 `SkillDefinition` provider,从 storage 读取后交给 `skillPlugin`。storage 的优势是支持持久化和按需恢复资源内容;`storage.import(...)` 是把外部 source 导入 storage 的快捷入口,内部包含 loader 流程。 + +想先看如何把 skill 接入对话请求,可以直接查看 [skillPlugin](#skillplugin) 章节,那里按手动选择和自动选择给出了完整示例。 + +## 基本数据模型 + +```typescript +interface SkillDefinition { + name: string + description: string + instructions: string + resources?: SkillResourceDescriptor[] + metadata?: Record +} +``` + +- `name`:skill 名称。去重和冲突处理由 storage 或业务侧集合负责。 +- `description`:能力描述,适合 UI 展示、搜索或自动选择候选摘要。 +- `instructions`:注入模型请求的核心指令。 +- `resources`:skill 附加文件资源,可通过资源工具按需读取。 +- `metadata`:应用侧和 loader 保留的扩展信息。 + +资源可以是 eager 内容,也可以是 lazy reader: + +```typescript +type SkillResourceDescriptor = + | { + path: string + kind: 'text' + resourceId: string + text?: string + readText?: () => Promise + mimeType?: string + size?: number + lastModified?: number + } + | { + path: string + kind: 'binary' + resourceId: string + binary?: Uint8Array + readBinary?: () => Promise + mimeType?: string + size?: number + lastModified?: number + } +``` + +`text` / `readText` 至少提供一个,`binary` / `readBinary` 至少提供一个。storage 恢复资源时通常使用 lazy reader,避免把所有文件内容一次性放入内存。 + +## Loader + +Loader 的职责是把平台相关 source 直接转换为 `SkillDefinition`。加载结果是一个可取消的 job: + +```typescript +const job = loadSkill(options) +job.cancel() + +const skill = await job +console.log(skill.name) +``` + +### Browser 加载 + +浏览器安全入口从 `@opentiny/tiny-robot-kit` 导出。可以从 `` 或 `showDirectoryPicker()` 加载。 + +```typescript +import { loadSkill } from '@opentiny/tiny-robot-kit' + +async function importFromInput(input: HTMLInputElement) { + if (!input.files) { + return + } + + const skill = await loadSkill({ + source: 'browser', + fileList: input.files, + }) + + return skill +} +``` + +```typescript +import { loadSkill } from '@opentiny/tiny-robot-kit' + +const directoryHandle = await window.showDirectoryPicker() +const skill = await loadSkill({ + source: 'browser', + directoryHandle, +}) +``` + +### GitHub 加载 + +浏览器和 Node 入口都支持 GitHub source: + +```typescript +import { loadSkill } from '@opentiny/tiny-robot-kit' + +const skill = await loadSkill({ + source: 'github', + repo: 'openclaw/openclaw', + // 可选,支持 branch、tag 或 commit SHA;省略时使用仓库默认分支。 + ref: '58672075219d09495de6489ad0821d276ac84f13', + path: 'skills/weather', +}) +``` + +### Node 文件系统加载 + +Node-only loader 从 `@opentiny/tiny-robot-kit/node` 导出: + +```typescript +import { loadSkill } from '@opentiny/tiny-robot-kit/node' + +const skill = await loadSkill({ + source: 'fs', + root: '/path/to/weather-skill', +}) +``` + +### Warning 和严格模式 + +需要读取非致命问题时,使用 `loadSkillWithDetails`。启用 `strict` 后,非致命问题会直接抛出为错误。 + +```typescript +const { skill, warnings } = await loadSkillWithDetails({ + source: 'browser', + fileList, + strict: true, +}) +``` + +## Storage + +Storage 负责持久化和恢复 `SkillDefinition`。它不管理长期选择状态,也不决定本次请求启用哪些 skill。 + +```typescript +interface SkillStorage { + add(skill: SkillDefinition): Promise + get(name: string): Promise + has(name: string): Promise + delete(name: string): Promise + list(): Promise + import(options: TImportOptions): SkillImportJob +} +``` + +`add(skill)` 存储已经完整加载好的 `SkillDefinition`。`import(options)` 是 `loader + add` 的快捷组合,会复用 loader 逻辑。 + +### Browser IndexedDB Storage + +```typescript +import { createIndexedDBSkillStorage } from '@opentiny/tiny-robot-kit' + +const storage = createIndexedDBSkillStorage({ + databaseName: 'tiny-robot-skills', +}) + +const importJob = storage.import({ + source: 'browser', + fileList, +}) + +const { skill, warnings } = await importJob + +console.log(skill.name, warnings) +console.log(await storage.list()) +console.log(await storage.get(skill.name)) +``` + +IndexedDB storage 会把 resource 内容持久化到 IndexedDB。后续 `get(name)` 恢复出的 resources 会优先提供 lazy reader,适合在模型真正调用资源工具时再读取内容。 + +### Memory Storage + +Memory storage 适合测试、临时预览或业务侧已经有其他持久化方案的场景。 + +```typescript +import { createMemorySkillStorage } from '@opentiny/tiny-robot-kit' + +const storage = createMemorySkillStorage() + +await storage.add(weatherSkill) +const weather = await storage.get('weather') +``` + +### Node Fs Storage + +Node-only storage 从 `@opentiny/tiny-robot-kit/node` 导出。Fs storage 保持原生 skill 目录结构,因此一个已有的 skills 目录可以直接作为 storage root 使用。 + +```typescript +import { createFsSkillStorage } from '@opentiny/tiny-robot-kit/node' + +const storage = createFsSkillStorage({ + root: '/path/to/skills', +}) + +await storage.add(weatherSkill) +const summaries = await storage.list() +const weather = await storage.get('weather') +``` + +下面示例展示从示例或本地目录导入 skill,使用 storage 保存,再选择本次请求启用的 skill。 + + + +## skillPlugin + +`skillPlugin` 用来把本次请求要启用的 skill 接入对话。它不加载、不缓存、不持久化、不管理 skill 集合,只根据当前请求的选择配置启用对应的 skill。 + +本文档默认展示 Vue 入口的 `skillPlugin` 参数。Vue 入口支持顶层响应式配置,`mode` 默认是 `manual`。`mode`、`skills`、`skillNames`、`preferredSkillNames` 和 `maxSelectedSkills` 都可以传普通值、`ref` 或 `computed`。`selection` 是高级入口,直接返回本次请求的选择配置;如果需要响应式 selection,请传函数并在函数内读取 ref。 + +由于这些字段都可以是动态 `ref` 或 `computed`,TypeScript 不能可靠地静态判断所有组合。实际使用时按下面的属性组合传参。 + +### 手动选择 + +手动选择适合用户通过 `@skillName`、下拉选择或业务按钮明确启用 skill 的场景。 + +使用完整 skills:`mode: 'manual'` + `skills`。适合业务侧已经持有完整 `SkillDefinition[]`,不需要 `getSkillByName`。 + +```typescript +const skill = await loadSkill({ + source: 'browser', + fileList, +}) + +skillPlugin({ + mode: manualMode, // 可传 ref / computed,默认 manual 时也可以省略 + skills: [skill], // 也可传 ref / computed +}) +``` + +使用 storage:`mode: 'manual'` + `skillNames` + `getSkillByName`。`skillNames` 保存 UI 选中的 names,`getSkillByName` 按 name 从 storage 读取完整定义。 + +```typescript +skillPlugin({ + mode: manualMode, // 可传 ref / computed,默认 manual 时也可以省略 + skillNames: selectedSkillNames, // 可传 ref / computed + getSkillByName: (name) => storage.get(name), +}) +``` + +下面示例展示 Vue `skillPlugin` 如何根据响应式 selected names 启用 skill instructions 和资源读取工具。 + + + +### 自动选择 + +自动选择适合应用有多个候选 skills,但用户没有明确指定 skill 的场景。 + +使用完整 skills:`mode: 'auto'` + `skills`。适合业务侧已经持有完整 `SkillDefinition[]`,并把它作为自动选择候选集合。 + +```typescript +const weather = await loadSkill({ + source: 'browser', + fileList: weatherFileList, +}) +const docs = await loadSkill({ + source: 'browser', + fileList: docsFileList, +}) + +skillPlugin({ + mode: autoMode, // 可传 ref / computed + skills: [weather, docs], // 也可传 ref / computed + preferredSkillNames, // 可传 ref / computed + maxSelectedSkills, // 可传 ref / computed +}) +``` + +使用 storage:`mode: 'auto'` + `getSkillCandidates` + `getSkillByName`。`getSkillCandidates` 从 storage 读取候选摘要,`getSkillByName` 按 name 读取完整定义。 + +```typescript +skillPlugin({ + mode: autoMode, // 可传 ref / computed + getSkillCandidates: () => storage.list(), + getSkillByName: (name) => storage.get(name), + preferredSkillNames, // 可传 ref / computed + maxSelectedSkills, // 可传 ref / computed +}) +``` + +auto 模式会先让模型看到候选 skill 的 `name` / `description` / `metadata`,并提供 `select_skills` 工具。模型选择后,插件再解析完整 skill,并把所选 skill 的 `instructions` 和 resource tools 提供给后续请求阶段。 + +`preferredSkillNames` 是自动选择的偏好,不是最终启用结果。最终启用结果以 `select_skills` 和 `getSkillByName` 的解析结果为准。 + +### 使用 selection + +`selection` 适合在每次请求前动态返回完整选择配置。传入 `selection` 后,会覆盖顶层的 `mode`、`skills`、`skillNames`、`preferredSkillNames` 和 `maxSelectedSkills`。 + +手动选择: + +```typescript +skillPlugin({ + selection: () => ({ + mode: 'manual', + skillNames: selectedSkillNames.value, + }), + getSkillByName: (name) => storage.get(name), +}) +``` + +自动选择: + +```typescript +skillPlugin({ + selection: () => ({ + mode: 'auto', + preferredSkillNames: preferredSkillNames.value, + maxSelectedSkills: maxSelectedSkills.value, + }), + getSkillCandidates: () => storage.list(), + getSkillByName: (name) => storage.get(name), +}) +``` + +:::info 资源文件工具 +当已启用的 skill 带有 `resources` 时,`skillPlugin` 会自动提供 `list_skill_files` 和 `read_skill_file`。插件注入的 system instructions 会要求模型先调用 `list_skill_files` 查看文件列表,再根据明确的 `skillName` 和相对路径调用 `read_skill_file`;`read_skill_file` 用于读取文本资源内容。 +::: + +## SkillRequestContext + +`onSkillsResolved` 可以读取当前请求的 skill 解析结果: + +```typescript +skillPlugin({ + mode: 'manual', + skillNames: ['weather', 'missing-skill'], + getSkillByName: (name) => storage.get(name), + onSkillsResolved(skillContext) { + console.log(skillContext.skillNames) + console.log(skillContext.requestedSkillNames) + console.log(skillContext.unresolvedSkillNames) + }, +}) +``` + +```typescript +interface SkillRequestContext { + skills: SkillDefinition[] + skillNames: string[] + requestedSkillNames: string[] + unresolvedSkillNames: string[] + runtimeTools: RuntimeTool[] + selection: + | { mode: 'manual' | 'none'; phase: 'ready' } + | { + mode: 'auto' + phase: 'selecting' + candidates: SkillCandidate[] + preferredSkillNames?: string[] + } + | { + mode: 'auto' + phase: 'ready' + candidates: SkillCandidate[] + preferredSkillNames?: string[] + } +} +``` + +- `skillNames`:成功启用的 skill names。 +- `requestedSkillNames`:manual 或 auto 请求启用的 skill names。 +- `unresolvedSkillNames`:请求启用但没有成功解析的 skill names。 +- `runtimeTools`:当前请求阶段提供给模型使用的工具。 +- `selection`:当前 selection 阶段状态。 + +auto 模式下还可以监听模型的选择事件: + +```typescript +skillPlugin({ + mode: 'auto', + getSkillCandidates: () => storage.list(), + getSkillByName: (name) => storage.get(name), + onSkillSelectionResolved(event) { + console.log(event.requestedSkillNames) + }, +}) +``` + +## API + +### Loader + +```typescript +type SkillLoadJob = Promise & { + cancel(): void +} + +interface SkillLoadResult { + skill: SkillDefinition + warnings: Array<{ + code: string + message: string + path?: string + }> +} + +function loadSkill(options: BrowserSkillLoadOptions | GithubSkillLoadOptions): SkillLoadJob +function loadSkillWithDetails(options: BrowserSkillLoadOptions | GithubSkillLoadOptions): SkillLoadJob +``` + +Node 子入口额外支持 `source: 'fs'`: + +```typescript +function loadSkill(options: FsSkillLoadOptions | GithubSkillLoadOptions): SkillLoadJob +function loadSkillWithDetails(options: FsSkillLoadOptions | GithubSkillLoadOptions): SkillLoadJob +``` + +### Storage + +```typescript +interface SkillSummary { + name: string + description: string + resourceCount: number + metadata?: Record +} + +interface SkillImportResult { + name: string + skill: SkillDefinition + warnings: SkillLoadWarning[] +} + +type SkillImportJob = Promise & { + cancel(): void +} + +interface SkillStorage { + add(skill: SkillDefinition): Promise + get(name: string): Promise + has(name: string): Promise + delete(name: string): Promise + list(): Promise + import(options: TImportOptions): SkillImportJob +} +``` + +### skillPlugin + +```typescript +type MaybeRef = T | Ref | ComputedRef + +type SkillSelection = + | { mode: 'manual'; skills: SkillDefinition[] } + | { mode: 'manual'; skillNames: string[] } + | { mode: 'auto'; preferredSkillNames?: string[]; maxSelectedSkills?: number } + | { mode: 'none' } + +interface UseMessageSkillPluginOptions { + /** + * 默认 manual。支持普通值、ref 或 computed。 + */ + mode?: MaybeRef<'manual' | 'auto' | 'none' | undefined> + /** + * manual 模式下表示已选中的完整 skills。 + * auto 模式下表示候选 skill 集合,同时作为默认 getSkillByName 来源。 + */ + skills?: MaybeRef + /** + * manual 模式下的已选 skill names。使用时需要提供 getSkillByName。 + */ + skillNames?: MaybeRef + /** + * auto 模式下的选择偏好,不是最终启用结果。 + */ + preferredSkillNames?: MaybeRef + /** + * auto 模式下最多启用的 skill 数。 + */ + maxSelectedSkills?: MaybeRef + /** + * 高级入口。传入后会覆盖顶层 mode / skills / skillNames / preferredSkillNames 配置。 + * plain object 不解包 ref;需要响应式 selection 时请传函数。 + */ + selection?: SkillSelection | ((context: BasePluginContext) => MaybePromise) + getSkillCandidates?: (context: BasePluginContext) => MaybePromise + getSkillByName?: (name: string, context: BasePluginContext) => MaybePromise + onSkillsResolved?: (skillContext: SkillRequestContext, context: BasePluginContext) => MaybePromise + onSkillSelectionResolved?: ( + event: { + mode: 'auto' + candidates: SkillCandidate[] + preferredSkillNames?: string[] + requestedSkillNames: string[] + }, + context: BasePluginContext, + ) => MaybePromise +} +``` diff --git a/packages/kit/package.json b/packages/kit/package.json index 2c4ba37a8..2f54e6b24 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" }, @@ -60,6 +67,7 @@ "license": "MIT", "devDependencies": { "@types/node": "^22.13.17", + "fake-indexeddb": "^6.2.5", "openai": "^6.34.0", "tsup": "^8.0.1", "typescript": "^5.8.2", @@ -69,6 +77,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/index.ts b/packages/kit/src/index.ts index 1456bf582..7cb3d2fb8 100644 --- a/packages/kit/src/index.ts +++ b/packages/kit/src/index.ts @@ -1,6 +1,7 @@ export { AIClient } from './client' export { BaseModelProvider } from './providers/base' export { OpenAIProvider } from './providers/openai' +export * from './skills' export * from './storage' export * from './types' export { extractTextFromResponse, formatMessages, handleSSEStream, sseStreamToGenerator } from './utils' 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..455459f8e 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, SkillSelection } 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..ec96b2b87 --- /dev/null +++ b/packages/kit/src/message/plugins/skillPlugin.ts @@ -0,0 +1,462 @@ +import { + createSkillResourceInstructionsMessage, + createSkillResourceRuntimeTools, +} from '../../skills/capabilities/resources' +import { + createSkillSelectionInstructionsMessage, + createSkillSelectionRuntimeTools, +} from '../../skills/capabilities/selection' +import type { SkillCandidate, SkillDefinition } from '../../skills/types' +import type { MaybePromise } from '../../types' +import { getUniqueStringArray } from '../../utils' +import type { BasePluginContext, ChatMessage, MessageEnginePlugin } from '../types' +import type { RuntimeTool, ToolProvider } from './toolPlugin' + +type ManualSkillSelection = + | { + mode: 'manual' + /** + * Inline skill definitions. + */ + skills: SkillDefinition[] + skillNames?: never + } + | { + mode: 'manual' + /** + * Final skill names to resolve via getSkillByName. + */ + skillNames: string[] + skills?: never + } + +interface AutoSkillSelection { + mode: 'auto' + /** + * User-preferred skill names as an automatic selection preference. + * These are not final selected skills; the selector still decides the final skill set. + */ + preferredSkillNames?: string[] + /** + * Maximum number of skills the selector may enable. + */ + maxSelectedSkills?: number +} + +interface NoSkillSelection { + mode: 'none' +} + +export type SkillSelection = ManualSkillSelection | AutoSkillSelection | NoSkillSelection + +type SkillSelectionStatus = + | { + mode: 'manual' | 'none' + phase: 'ready' + } + | { + mode: 'auto' + phase: 'selecting' + candidates: SkillCandidate[] + preferredSkillNames?: string[] + } + | { + mode: 'auto' + phase: 'ready' + candidates: SkillCandidate[] + preferredSkillNames?: string[] + } + +/** + * Current request skill context. + * + * This context is written to customContext.__tiny_robot_skill so hooks and callbacks + * can read the same request-level skill state. + */ +export interface SkillRequestContext { + /** + * Successfully enabled full skill definitions. + */ + skills: SkillDefinition[] + /** + * Successfully enabled skill names. + */ + skillNames: string[] + /** + * Skill names requested by manual selection or auto select_skills. + */ + requestedSkillNames: string[] + /** + * Requested skill names that could not be resolved into enabled skills. + */ + unresolvedSkillNames: string[] + runtimeTools: RuntimeTool[] + selection: SkillSelectionStatus +} + +interface SkillResolver { + /** + * Resolve full definition by name. + */ + getSkillByName(name: string, context: BasePluginContext): MaybePromise +} + +interface SkillCandidateProvider { + /** + * Returns candidate summaries for automatic selection. + */ + getSkillCandidates(context: BasePluginContext): MaybePromise +} + +type ResolverRequiredSelection = + | AutoSkillSelection + | Extract< + ManualSkillSelection, + { + skillNames: string[] + } + > + +type RequireResolver = + Extract extends never ? Partial : SkillResolver + +type RequireCandidateProvider = + Extract extends never ? Partial : SkillCandidateProvider + +interface SkillPluginHooks extends MessageEnginePlugin { + /** + * Called after skills are resolved into their full definitions. + */ + onSkillsResolved?: (skillContext: SkillRequestContext, context: BasePluginContext) => MaybePromise + /** + * Called when the automatic selector chooses skill names. + */ + onSkillSelectionResolved?: ( + event: { + mode: 'auto' + candidates: SkillCandidate[] + preferredSkillNames?: string[] + requestedSkillNames: string[] + }, + context: BasePluginContext, + ) => MaybePromise +} + +type SelectionInput = T | ((context: BasePluginContext) => MaybePromise) + +export type SkillPluginOptions = SkillPluginHooks & + RequireResolver & + RequireCandidateProvider & { + selection: SelectionInput + } + +const skillPluginContextKey = '__tiny_robot_skill' + +const createSkillInstructionsMessage = (skills: SkillDefinition[]): ChatMessage | undefined => { + 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'), + } +} + +const appendSystemInstructions = (messages: ChatMessage[], instructions: ChatMessage[]): ChatMessage[] => { + if (instructions.length === 0) { + return messages + } + + const content = instructions + .map((message) => (typeof message.content === 'string' ? message.content : '')) + .filter((item) => item.length > 0) + .join('\n\n') + + if (!content) { + return messages + } + + const [firstMessage, ...restMessages] = messages + if (firstMessage?.role === 'system' && typeof firstMessage.content === 'string') { + return [ + { + ...firstMessage, + content: [firstMessage.content, content].filter((item) => item.trim().length > 0).join('\n\n'), + }, + ...restMessages, + ] + } + + const systemMessage: ChatMessage = { + role: 'system', + content, + } + + return [systemMessage, ...messages] +} + +const normalizeCandidates = (candidates: SkillCandidate[]) => { + const candidateMap = new Map() + + for (const candidate of candidates) { + if (candidateMap.has(candidate.name)) { + continue + } + + candidateMap.set(candidate.name, { + name: candidate.name, + description: candidate.description, + metadata: candidate.metadata, + }) + } + + return [...candidateMap.values()] +} + +const getSkillContext = (context: BasePluginContext) => { + return context.customContext[skillPluginContextKey] as SkillRequestContext | undefined +} + +const setSkillContext = (context: BasePluginContext, skillContext: SkillRequestContext) => { + context.setCustomContext({ [skillPluginContextKey]: skillContext }) +} + +const resolveSkillsByNames = async ( + skillNames: string[], + getSkillByName: SkillResolver['getSkillByName'], + context: BasePluginContext, +) => { + const results = await Promise.all( + skillNames.map(async (name) => { + try { + return { name, skill: await getSkillByName(name, context) } + } catch { + return { name, failed: true } + } + }), + ) + + const skills = results.map((result) => result.skill).filter((skill): skill is SkillDefinition => Boolean(skill)) + const resolvedSkillNameSet = new Set(skills.map((skill) => skill.name)) + + return { + skills, + unresolvedSkillNames: results + .filter((result) => result.failed || !result.skill || !resolvedSkillNameSet.has(result.name)) + .map((result) => result.name), + } +} + +const createAutoSelectionRuntimeTools = ({ + selection, + getSkillByName, + candidates, + preferredSkillNames, + onSkillsResolved, + onSkillSelectionResolved, +}: { + selection: AutoSkillSelection + getSkillByName: SkillResolver['getSkillByName'] + candidates: SkillCandidate[] + preferredSkillNames?: string[] + onSkillsResolved: SkillPluginHooks['onSkillsResolved'] + onSkillSelectionResolved: SkillPluginHooks['onSkillSelectionResolved'] +}): RuntimeTool[] => { + return createSkillSelectionRuntimeTools(candidates, { + maxSelectedSkills: selection.maxSelectedSkills, + resolveSelection: async (result, toolContext) => { + const requestedSkillNames = result.requestedSkillNames + const event = { + mode: 'auto' as const, + candidates, + preferredSkillNames, + requestedSkillNames, + } + + await onSkillSelectionResolved?.(event, toolContext) + + const { skills, unresolvedSkillNames } = await resolveSkillsByNames( + requestedSkillNames, + getSkillByName, + toolContext, + ) + + const skillContext: SkillRequestContext = { + skills, + skillNames: skills.map((skill) => skill.name), + requestedSkillNames, + unresolvedSkillNames, + runtimeTools: createSkillResourceRuntimeTools(skills), + selection: { + mode: 'auto', + phase: 'ready', + candidates, + preferredSkillNames, + }, + } + + setSkillContext(toolContext, skillContext) + await onSkillsResolved?.(skillContext, toolContext) + + return { + requestedSkillNames, + enabledSkillNames: skills.map((skill) => skill.name), + unresolvedSkillNames, + } + }, + }) +} + +export const skillPlugin = ( + options: SkillPluginOptions, +): MessageEnginePlugin & ToolProvider => { + const { selection, getSkillCandidates, getSkillByName, onSkillsResolved, onSkillSelectionResolved, ...restOptions } = + options + + return { + name: 'skill', + ...restOptions, + provideTools: async (context: BasePluginContext) => { + return getSkillContext(context)?.runtimeTools ?? [] + }, + onTurnStart: async (context) => { + const selectionOptions: SkillSelection = typeof selection === 'function' ? await selection(context) : selection + + if (selectionOptions.mode === 'none') { + setSkillContext(context, { + skills: [], + skillNames: [], + requestedSkillNames: [], + unresolvedSkillNames: [], + runtimeTools: [], + selection: { + mode: 'none', + phase: 'ready', + }, + }) + + return restOptions.onTurnStart?.(context) + } + + if (selectionOptions.mode === 'manual') { + let skills: SkillDefinition[] + let requestedSkillNames: string[] + let unresolvedSkillNames: string[] + + if (selectionOptions.skills) { + skills = selectionOptions.skills + requestedSkillNames = skills.map((skill) => skill.name) + unresolvedSkillNames = [] + } else { + requestedSkillNames = getUniqueStringArray(selectionOptions.skillNames) ?? [] + const resolveSkillByName = getSkillByName + if (!resolveSkillByName && requestedSkillNames.length > 0) { + throw new Error('getSkillByName is required when manual mode uses skillNames') + } + const resolveResult = resolveSkillByName + ? await resolveSkillsByNames(requestedSkillNames, resolveSkillByName, context) + : { skills: [], unresolvedSkillNames: [] } + skills = resolveResult.skills + unresolvedSkillNames = resolveResult.unresolvedSkillNames + } + + const skillContext: SkillRequestContext = { + skills, + skillNames: skills.map((skill) => skill.name), + requestedSkillNames, + unresolvedSkillNames, + runtimeTools: createSkillResourceRuntimeTools(skills), + selection: { + mode: 'manual', + phase: 'ready', + }, + } + + setSkillContext(context, skillContext) + await onSkillsResolved?.(skillContext, context) + + return restOptions.onTurnStart?.(context) + } + + // mode: 'auto' + const getCandidates = getSkillCandidates + if (!getCandidates) { + throw new Error('getSkillCandidates is required when auto mode is enabled') + } + const candidates = normalizeCandidates(await getCandidates(context)) + const candidateNameSet = new Set(candidates.map((candidate) => candidate.name)) + const preferredSkillNames = getUniqueStringArray(selectionOptions.preferredSkillNames)?.filter((name) => + candidateNameSet.has(name), + ) + const resolveSkillByName = getSkillByName + if (!resolveSkillByName) { + throw new Error('getSkillByName is required when auto mode is enabled') + } + const skillContext: SkillRequestContext = { + skills: [], + skillNames: [], + requestedSkillNames: [], + unresolvedSkillNames: [], + runtimeTools: createAutoSelectionRuntimeTools({ + selection: selectionOptions, + getSkillByName: resolveSkillByName, + candidates, + preferredSkillNames, + onSkillsResolved, + onSkillSelectionResolved, + }), + selection: { + mode: 'auto', + phase: 'selecting', + candidates, + preferredSkillNames, + }, + } + + setSkillContext(context, skillContext) + + return restOptions.onTurnStart?.(context) + }, + onBeforeRequest: async (context) => { + const skillContext = getSkillContext(context) + + if ( + skillContext?.selection.mode === 'auto' && + skillContext.selection.phase === 'selecting' && + skillContext.selection.candidates.length > 0 + ) { + context.requestBody.messages = appendSystemInstructions(context.requestBody.messages, [ + createSkillSelectionInstructionsMessage({ + candidates: skillContext.selection.candidates, + preferredSkillNames: skillContext.selection.preferredSkillNames, + }), + ]) + } else if (skillContext?.skills.length) { + const skillInstructions = createSkillInstructionsMessage(skillContext.skills) + const resourceInstructions = createSkillResourceInstructionsMessage(skillContext.skills) + const instructions: ChatMessage[] = [] + if (skillInstructions) { + instructions.push(skillInstructions) + } + if (resourceInstructions) { + instructions.push(resourceInstructions as ChatMessage) + } + if (instructions.length > 0) { + context.requestBody.messages = appendSystemInstructions(context.requestBody.messages, instructions) + } + } + + 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..c0245ae46 --- /dev/null +++ b/packages/kit/src/node.ts @@ -0,0 +1,9 @@ +export { loadSkill, loadSkillWithDetails } from './skills/loader/node' +export type { + FsSkillLoadOptions, + GithubSkillLoadOptions, + SkillLoadJob, + SkillLoadOptions, + SkillLoadResult, +} from './skills/loader/node' +export * from './skills/storage/node' diff --git a/packages/kit/src/skills/README.md b/packages/kit/src/skills/README.md new file mode 100644 index 000000000..c8bb5d343 --- /dev/null +++ b/packages/kit/src/skills/README.md @@ -0,0 +1,240 @@ +# Skill Toolchain Architecture + +本文档面向维护者,说明 skill 工具链的整体架构、数据流和职责边界,不展开逐文件实现或完整 API 用法。 + +## 目标 + +skill 是一组可加载、可持久化、可选择的模型能力定义。kit 将其生命周期拆成两条相互衔接的数据流: + +```text +加载与持久化:文件来源 -> Loader -> SkillDefinition -> Storage +请求组装: SkillDefinition + Selection -> skillPlugin -> Instructions + Runtime Tools +``` + +这套架构遵循以下边界: + +- Loader 只负责从外部来源构建 `SkillDefinition`。 +- Storage 只负责保存、恢复和枚举 skills。 +- `skillPlugin` 只负责单轮会话中的选择状态、instructions 和 tools。 +- 业务侧负责长期选择状态,以及把 instructions 写入具体模型供应商的请求。 + +## 核心数据模型 + +`SkillDefinition` 是三个架构组件之间传递的核心数据: + +```ts +interface SkillDefinition { + name: string + description: string + instructions: string + resources?: SkillResourceDescriptor[] + metadata?: Record +} +``` + +- `name` 是 storage 和 selection 使用的标识。 +- `description` 是展示和自动选择使用的摘要。 +- `instructions` 是 skill 的主要模型指令。 +- `resources` 是模型可按需读取的附加文件。 +- `metadata` 保留 loader 或业务侧的扩展数据。 + +resource 分为 text 和 binary。内容既可以直接保存在 definition 中,也可以由 storage 恢复为延迟读取函数,因此消费方不能假设资源内容始终已经加载。 + +## 架构组件 + +### Loader + +Loader 把不同文件来源统一转换为 `SkillDefinition`。当前支持: + +- 浏览器文件或目录。 +- GitHub 仓库中的指定目录。 +- Node 本地文件系统目录。 + +skill 入口默认是 `SKILL.md`。入口正文成为 `instructions`,其他支持的文件成为 resources。加载结果可以包含非致命 warnings;加载任务支持取消。 + +Loader 不负责: + +- 保存或覆盖 skill。 +- 管理已选择的 skill。 +- 生成 message 请求。 + +### Storage + +Storage 为业务侧提供 skill 集合,统一支持新增、读取、判断存在、删除、列举摘要和导入。当前实现包括: + +- Memory storage:进程内临时集合。 +- IndexedDB storage:浏览器持久化。 +- File-system storage:Node 本地目录持久化。 + +`import()` 串联 Loader 与 Storage:先加载外部来源,再把得到的 `SkillDefinition` 保存到当前 storage。`list()` 返回候选摘要,`get()` 返回完整 definition。 + +Storage 不负责: + +- 决定某轮请求启用哪些 skills。 +- 生成 instructions 或 runtime tools。 +- 管理 UI 的选择状态。 + +### Message Plugin + +`skillPlugin` 把业务侧提供的 selection 快照转换为本轮请求的 skill 上下文: + +```ts +interface SkillRequestContext { + skills: SkillDefinition[] + skillNames: string[] + requestedSkillNames: string[] + unresolvedSkillNames: string[] + instructions: string[] + runtimeTools: RuntimeTool[] + selection: SkillSelectionStatus +} +``` + +该上下文写入 message engine 的 `customContext`,可通过 `getSkillRequestContext()` 读取。插件通过 `provideTools` 暴露当前阶段可用的 runtime tools,但不会自行修改 `requestBody`。 + +`requestedSkillNames` 表示请求启用的名称,`skillNames` 只包含成功解析的 skills,无法解析的名称记录在 `unresolvedSkillNames`。 + +`skillPlugin` 不负责: + +- 加载或持久化 skill。 +- 维护跨会话的选择集合。 +- 决定 instructions 应写入哪个供应商字段。 + +## 运行流程 + +### Manual Selection + +manual 模式用于用户或业务逻辑已经明确选中 skills 的场景。调用方可以直接传入完整 definitions,也可以传入 names 并通过 storage 解析: + +```ts +skillPlugin({ + selection: { + mode: 'manual', + skillNames: selectedNames, + }, + getSkillByName: (name) => storage.get(name), +}) +``` + +插件在 turn 开始时解析 definitions,生成 selected skill instructions,并提供这些 skills 的 resource tools。单个名称解析失败不会中断其他 skills。 + +```mermaid +sequenceDiagram + participant App + participant Storage + participant Plugin as skillPlugin + participant Adapter as Request Adapter + participant Model + + App->>Plugin: manual selection with skill names + Plugin->>Storage: getSkillByName(name) + Storage-->>Plugin: SkillDefinition + Plugin->>Plugin: Build ready SkillRequestContext + Plugin-->>App: onSkillsResolved / onInstructionsResolved + Adapter->>Plugin: Read instructions and runtime tools + Adapter->>Model: Send request with selected skill context +``` + +### Auto Selection + +auto 模式用于存在多个候选 skills、但最终选择交给模型的场景: + +```ts +skillPlugin({ + selection: { + mode: 'auto', + preferredSkillNames, + maxSelectedSkills: 2, + }, + getSkillCandidates: () => storage.list(), + getSkillByName: (name) => storage.get(name), +}) +``` + +自动选择分为两个阶段: + +1. `selecting`:向模型提供候选摘要和 `select_skills` tool,不暴露完整 skill instructions 或 resource tools。 +2. `ready`:模型选择 names 后,插件解析完整 definitions,并为下一次请求提供 selected skill instructions 和 resource tools。 + +`preferredSkillNames` 是选择偏好,不是最终启用结果。 + +```mermaid +sequenceDiagram + participant App + participant Storage + participant Plugin as skillPlugin + participant Adapter as Request Adapter + participant Model + + App->>Plugin: auto selection + opt preferredSkillNames is provided + App->>Plugin: Include preferred skill names + end + Plugin->>Storage: getSkillCandidates() + Storage-->>Plugin: SkillCandidate[] + Plugin->>Plugin: Build selecting SkillRequestContext + Adapter->>Model: Send candidates and select_skills tool + Model->>Plugin: select_skills({ skillNames }) + Plugin->>Storage: getSkillByName(name) + Storage-->>Plugin: SkillDefinition[] + Plugin->>Plugin: Build ready SkillRequestContext + Plugin-->>App: Selection, skills and instructions callbacks + Adapter->>Model: Send next request with selected skill context +``` + +### None + +`selection: { mode: 'none' }` 表示本轮不启用 skill。插件仍写入空的 ready context,但不生成 instructions 或 runtime tools。 + +## Instructions 接入 + +`skillPlugin` 只生成 `SkillRequestContext.instructions`,不假设供应商使用 system message、独立 system 字段或其他协议。 + +当 instructions 更新时,插件触发 `onInstructionsResolved`。该回调发生在 turn 或 tool 生命周期中,收到的是基础上下文,不包含 `requestBody`。请求适配器可以在后续 `onBeforeRequest` 中读取当前 instructions: + +```ts +const plugins = [ + skillPlugin({ + selection: { mode: 'manual', skillNames: selectedNames }, + getSkillByName: (name) => storage.get(name), + }), + { + name: 'provider-skill-instructions', + onBeforeRequest(context) { + const instructions = getSkillRequestContext(context)?.instructions ?? [] + context.requestBody.system = instructions.join('\n\n') + }, + }, +] +``` + +具体注入方式属于 provider adapter,而不是 skill 工具链。 + +## Resource Tools + +当已启用 skills 包含 resources 时,插件提供两个基础工具: + +- `list_skill_files`:列出当前 skills 的资源摘要。 +- `read_skill_file`:按 skill name 和相对路径读取 text resource。 + +binary resource 不通过 `read_skill_file` 返回。资源应优先按需读取,避免把全部文件内容预先放入上下文。 + +## Vue 接入 + +Vue `skillPlugin` 复用相同的 core 生命周期,并为 `mode`、`skills`、`skillNames`、`preferredSkillNames` 和 `maxSelectedSkills` 提供 `ref` / `computed` 支持。 + +调用方也可以直接提供 core-compatible `selection`。函数形式的 selection 会在每轮读取最新状态。Vue wrapper 只负责响应式适配,不改变 Loader、Storage 或 core plugin 的职责。 + +## 环境与导出边界 + +浏览器安全的 skill 类型、Loader、Memory/IndexedDB Storage 和 message plugin 从 `@opentiny/tiny-robot-kit` 或 `@opentiny/tiny-robot-kit/core` 导出。 + +依赖 Node 文件系统的 Loader 与 File-system Storage 只从 `@opentiny/tiny-robot-kit/node` 导出。Node-only API 不应进入 root/core 导出,避免浏览器 bundle 引入 `fs`、`path` 等依赖。 + +## 当前限制与 Roadmap + +- resource tools 尚不支持范围读取、截断或全文搜索。 +- GitHub Loader 尚不支持认证配置和进度回调。 +- kit 尚不提供 skill command execution、执行沙箱或 artifact 管理。 +- Storage 不负责业务搜索、分页、选择集合和冲突诊断。 +- 新能力应保持 Loader、Storage、Message Plugin 与 provider adapter 的现有边界。 diff --git a/packages/kit/src/skills/capabilities/commands.ts b/packages/kit/src/skills/capabilities/commands.ts new file mode 100644 index 000000000..3a3c3e7b0 --- /dev/null +++ b/packages/kit/src/skills/capabilities/commands.ts @@ -0,0 +1,13 @@ +import type { MaybePromise } from '../../types' +import type { SkillDefinition } from '../types' + +export type SkillCommandRequest = { + skillName: string + command: string + args: string[] + skill: SkillDefinition +} + +export type SkillCommandResult = string | Record + +export type SkillCommandExecutor = (request: SkillCommandRequest) => MaybePromise diff --git a/packages/kit/src/skills/capabilities/resources.ts b/packages/kit/src/skills/capabilities/resources.ts new file mode 100644 index 000000000..8d21bc93d --- /dev/null +++ b/packages/kit/src/skills/capabilities/resources.ts @@ -0,0 +1,164 @@ +import type { ChatCompletionSystemMessageParam } from 'openai/resources' +import type { RuntimeTool } from '../../message/plugins/toolPlugin' +import type { SkillDefinition, SkillResourceDescriptor } from '../types' +import { parseToolArguments } from './utils' + +const skillResourceToolNames = { + listSkillFiles: 'list_skill_files', + readSkillFile: 'read_skill_file', +} as const + +const skillResourceTools: Array = [ + { + type: 'function', + function: { + name: skillResourceToolNames.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: skillResourceToolNames.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 getSkillFileSummary = (skillName: string, file: SkillResourceDescriptor) => ({ + skillName, + path: file.path, + kind: file.kind, + mimeType: file.mimeType, + size: file.size, + lastModified: file.lastModified, +}) + +const readSkillResourceText = async (resource: SkillResourceDescriptor) => { + if (resource.text !== undefined) { + return resource.text + } + + return resource.readText?.() +} + +export const hasSkillResources = (skills: SkillDefinition[]) => { + return skills.some((skill) => Boolean(skill.resources?.length)) +} + +export const createSkillResourceInstructionsMessage = ( + skills: SkillDefinition[], +): ChatCompletionSystemMessageParam | undefined => { + if (!hasSkillResources(skills)) { + return undefined + } + + return { + role: 'system', + content: [ + 'Some enabled skills include resource files.', + 'Start by calling list_skill_files before reading skill resources, unless the needed file path is already known from the current conversation.', + 'Use read_skill_file with a skillName and relative path when you need file details.', + 'For large files or unknown locations, inspect the file list first and prefer targeted reads instead of reading unrelated files.', + 'Do not guess file paths. Binary files cannot be read as text through read_skill_file.', + ].join('\n'), + } +} + +export const createSkillResourceRuntimeTools = (skills: SkillDefinition[]): RuntimeTool[] => { + if (!hasSkillResources(skills)) { + return [] + } + + const findSkill = (skillName?: unknown) => { + if (typeof skillName !== 'string' || !skillName) { + return undefined + } + + return skills.find((skill) => skill.name === skillName) + } + + return [ + { + tool: skillResourceTools[0], + handler: (toolCall) => { + const toolArguments = parseToolArguments(toolCall) + const skill = findSkill(toolArguments.skillName) + const skillList = skill ? [skill] : skills + + return { + files: skillList.flatMap((currentSkill) => + (currentSkill.resources ?? []).map((file) => getSkillFileSummary(currentSkill.name, file)), + ), + } + }, + }, + { + tool: skillResourceTools[1], + handler: async (toolCall) => { + const toolArguments = parseToolArguments(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.resources?.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), + } + } + + const content = await readSkillResourceText(file) + + if (content === undefined) { + return { + error: 'text_file_not_readable', + file: getSkillFileSummary(skill.name, file), + } + } + + return { + file: getSkillFileSummary(skill.name, file), + content, + } + }, + }, + ] +} diff --git a/packages/kit/src/skills/capabilities/selection.ts b/packages/kit/src/skills/capabilities/selection.ts new file mode 100644 index 000000000..8434dc038 --- /dev/null +++ b/packages/kit/src/skills/capabilities/selection.ts @@ -0,0 +1,124 @@ +import type { ChatCompletionSystemMessageParam } from 'openai/resources' +import type { RuntimeTool, ToolCallContext } from '../../message/plugins/toolPlugin' +import type { MaybePromise } from '../../types' +import { getUniqueStringArray } from '../../utils' +import type { SkillCandidate } from '../types' +import { parseToolArguments } from './utils' + +const skillSelectionToolName = 'select_skills' + +export const createSkillSelectionInstructionsMessage = ({ + candidates, + preferredSkillNames, +}: { + candidates: SkillCandidate[] + preferredSkillNames?: string[] +}): ChatCompletionSystemMessageParam => { + const lines = [ + 'Select the skills that should be enabled for this request.', + 'Use the select_skills tool before answering.', + 'Only choose skill names from the provided candidates.', + '', + 'Candidates:', + ...candidates.map((candidate) => { + const metadata = candidate.metadata ? ` Metadata: ${JSON.stringify(candidate.metadata)}` : '' + return `- ${candidate.name}: ${candidate.description}${metadata}` + }), + ] + + if (preferredSkillNames?.length) { + lines.push('', `Preferred skill names: ${preferredSkillNames.join(', ')}`) + } + + return { + role: 'system', + content: lines.join('\n'), + } +} + +export function createSkillSelectionRuntimeTools( + candidates: SkillCandidate[], + options: { + maxSelectedSkills?: number + resolveSelection?: ( + result: { + requestedSkillNames: string[] + }, + context: ToolCallContext, + ) => MaybePromise | void> + } = {}, +): RuntimeTool[] { + if (candidates.length === 0) { + return [] + } + + const candidateNames = candidates.map((candidate) => candidate.name) + const candidateNameSet = new Set(candidateNames) + const maxSelectedSkills = Math.max(0, options.maxSelectedSkills ?? candidateNames.length) + + return [ + { + tool: { + type: 'function', + function: { + name: skillSelectionToolName, + description: 'Select the skills that should be enabled for the next execution turn.', + parameters: { + type: 'object', + properties: { + skillNames: { + type: 'array', + description: 'Skill names to enable. Only choose from the provided candidates.', + items: { + type: 'string', + enum: candidateNames, + }, + maxItems: maxSelectedSkills, + }, + }, + required: ['skillNames'], + additionalProperties: false, + }, + }, + }, + handler: async (toolCall, context) => { + const toolArguments = parseToolArguments(toolCall) + const requestedSkillNames = getUniqueStringArray(toolArguments.skillNames) + + if (!requestedSkillNames) { + return { + error: 'skill_names_required', + candidateSkillNames: candidateNames, + } + } + + const invalidSkillNames = requestedSkillNames.filter((name) => !candidateNameSet.has(name)) + if (invalidSkillNames.length > 0) { + return { + error: 'invalid_skill_names', + invalidSkillNames, + candidateSkillNames: candidateNames, + } + } + + if (requestedSkillNames.length > maxSelectedSkills) { + return { + error: 'too_many_skills_selected', + maxSelectedSkills, + requestedSkillNames, + } + } + + const result = { + requestedSkillNames, + } + const selectionResult = await options.resolveSelection?.(result, context) + + return { + requestedSkillNames: result.requestedSkillNames, + ...selectionResult, + } + }, + }, + ] +} diff --git a/packages/kit/src/skills/capabilities/utils.ts b/packages/kit/src/skills/capabilities/utils.ts new file mode 100644 index 000000000..03b5d36e1 --- /dev/null +++ b/packages/kit/src/skills/capabilities/utils.ts @@ -0,0 +1,16 @@ +import type { RuntimeTool } from '../../message/plugins/toolPlugin' + +export const parseToolArguments = (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 {} + } +} diff --git a/packages/kit/src/skills/index.ts b/packages/kit/src/skills/index.ts new file mode 100644 index 000000000..d93e05cc8 --- /dev/null +++ b/packages/kit/src/skills/index.ts @@ -0,0 +1,18 @@ +export { loadSkill, loadSkillWithDetails } from './loader' +export type { + BrowserSkillLoadOptions, + GithubSkillLoadOptions, + SkillLoadJob, + SkillLoadOptions, + SkillLoadResult, +} from './loader' +export { + createIndexedDBSkillStorage, + createMemorySkillStorage, + IndexedDBSkillStorage, + importSkill, + MemorySkillStorage, +} from './storage' +export type { IndexedDBSkillStorageOptions, SkillStorage, SkillImportJob, SkillImportResult } from './storage' +export type { SkillCandidate, SkillDefinition, SkillResourceDescriptor } from './types' +export { getExtension, isTextSkillFilePath, normalizeSkillPath } from './utils' diff --git a/packages/kit/src/skills/loader/browser.ts b/packages/kit/src/skills/loader/browser.ts new file mode 100644 index 000000000..6ea10d103 --- /dev/null +++ b/packages/kit/src/skills/loader/browser.ts @@ -0,0 +1,66 @@ +import { isTextSkillFilePath, normalizeSkillPath, stripRootDirectory, throwIfSkillLoadCancelled } from './utils' +import type { BrowserSkillLoadOptions, LoadableSkillFile, SkillLoadContext } from './type' + +type FileWithRelativePath = File & { + webkitRelativePath?: string +} + +export async function loadBrowserSkillFiles( + options: BrowserSkillLoadOptions, + context: SkillLoadContext, +): Promise { + if ('fileList' in options && options.fileList) { + return Promise.all( + Array.from(options.fileList) + .filter((file): file is FileWithRelativePath => Boolean(file)) + .map((file) => loadBrowserFile(file, stripRootDirectory(file.webkitRelativePath || file.name), context)), + ) + } + + const result: LoadableSkillFile[] = [] + + const walk = async (directory: FileSystemDirectoryHandle, parentPath = '') => { + throwIfSkillLoadCancelled(context.signal) + const entries = ( + directory as FileSystemDirectoryHandle & { + entries(): AsyncIterable<[string, FileSystemDirectoryHandle | FileSystemFileHandle]> + } + ).entries() + + for await (const [name, handle] of entries) { + const path = parentPath ? `${parentPath}/${name}` : name + + if (handle.kind === 'directory') { + await walk(handle, path) + continue + } + + result.push(await loadBrowserFile(await handle.getFile(), path, context)) + } + } + + await walk(options.directoryHandle) + return result +} + +async function loadBrowserFile(file: File, rawPath: string, context: SkillLoadContext): Promise { + const path = normalizeSkillPath(rawPath) + + if (!path) { + throw new Error(`Invalid skill file path: ${rawPath}`) + } + + const kind = isTextSkillFilePath(path) ? 'text' : 'binary' + const content = kind === 'text' ? await file.text() : new Uint8Array(await file.arrayBuffer()) + + throwIfSkillLoadCancelled(context.signal) + + return { + path, + kind, + content, + mimeType: file.type, + size: file.size, + lastModified: file.lastModified, + } +} diff --git a/packages/kit/src/skills/loader/definition.ts b/packages/kit/src/skills/loader/definition.ts new file mode 100644 index 000000000..16f9f8661 --- /dev/null +++ b/packages/kit/src/skills/loader/definition.ts @@ -0,0 +1,130 @@ +import type { SkillResourceDescriptor } from '../types' +import type { LoadableSkillFile, SkillLoadBaseOptions, SkillLoadResult, SkillLoadWarning } from './type' +import { + getFallbackSkillName, + getRecord, + getString, + isTextSkillFilePath, + normalizeSkillPath, + parseMarkdownFrontmatter, + pushWarning, +} from './utils' + +export function createSkillDefinition(files: LoadableSkillFile[], options: SkillLoadBaseOptions): SkillLoadResult { + const warnings: SkillLoadWarning[] = [] + const entryFile = options.entryFile ?? 'SKILL.md' + const normalizedFiles = normalizeFiles(files, options, warnings) + const skillEntry = normalizedFiles.find((file) => file.path === entryFile) + + if (!skillEntry) { + throw new Error(`Skill entry file "${entryFile}" is missing.`) + } + + if (skillEntry.kind !== 'text') { + throw new Error(`Skill entry file "${entryFile}" must be a text file.`) + } + + const { frontmatter, body } = parseMarkdownFrontmatter(String(skillEntry.content)) + const instructions = body.trim() + + if (!instructions) { + throw new Error(`Skill entry file "${entryFile}" must contain instructions.`) + } + + const resources = normalizedFiles.flatMap((file) => { + if (file.path === entryFile) return [] + if (file.kind === 'text' && !isTextSkillFilePath(file.path)) { + pushWarning(warnings, options, { + code: 'unsupported-text-file-ignored', + message: 'Only markdown, text, and json files are converted to text skill files.', + path: file.path, + }) + return [] + } + + return [toSkillResource(file)] + }) + + return { + skill: { + name: getString(frontmatter.name) || getFallbackSkillName(entryFile), + description: getString(frontmatter.description) || '', + instructions, + resources: resources.length ? resources : undefined, + metadata: { + ...getRecord(frontmatter.metadata), + ...(getString(frontmatter.homepage) ? { homepage: getString(frontmatter.homepage) } : {}), + }, + }, + warnings, + } +} + +function normalizeFiles( + files: T[], + options: SkillLoadBaseOptions, + warnings: SkillLoadWarning[], +) { + const result: T[] = [] + const seenPaths = new Set() + + for (const file of files) { + const path = normalizeSkillPath(file.path) + + if (!path) { + pushWarning(warnings, options, { + code: 'invalid-path', + message: `Invalid skill file path: ${file.path}`, + path: file.path, + }) + continue + } + + if (seenPaths.has(path)) { + pushWarning(warnings, options, { + code: 'duplicate-path', + message: `Duplicate skill file path: ${path}`, + path, + }) + continue + } + + seenPaths.add(path) + result.push({ ...file, path }) + } + + return result.sort((a, b) => a.path.localeCompare(b.path)) +} + +function toSkillResource(file: LoadableSkillFile): SkillResourceDescriptor { + if (file.kind === 'text') { + const text = typeof file.content === 'string' ? file.content : new TextDecoder().decode(file.content) + + return { + path: file.path, + kind: file.kind, + resourceId: file.path, + mimeType: file.mimeType, + size: file.size, + lastModified: file.lastModified, + metadata: file.metadata, + text, + readText: async () => text, + readBinary: async () => new TextEncoder().encode(text), + } + } + + const binary = file.content instanceof Uint8Array ? file.content : new TextEncoder().encode(file.content) + + return { + path: file.path, + kind: file.kind, + resourceId: file.path, + mimeType: file.mimeType, + size: file.size, + lastModified: file.lastModified, + metadata: file.metadata, + binary, + readBinary: async () => binary, + } +} diff --git a/packages/kit/src/skills/loader/fs.ts b/packages/kit/src/skills/loader/fs.ts new file mode 100644 index 000000000..e8b27990d --- /dev/null +++ b/packages/kit/src/skills/loader/fs.ts @@ -0,0 +1,58 @@ +import { readFile, readdir, stat } from 'node:fs/promises' +import { join, relative } from 'node:path' +import type { FsSkillLoadOptions, LoadableSkillFile, SkillLoadContext } from './type' +import { isTextSkillFilePath, normalizeSkillPath, throwIfSkillLoadCancelled } from './utils' + +export async function loadFsSkillFiles( + options: FsSkillLoadOptions, + context: SkillLoadContext, +): Promise { + const ignored = new Set(options.ignoredDirectories ?? ['node_modules']) + const result: LoadableSkillFile[] = [] + + const walk = async (directory: string) => { + throwIfSkillLoadCancelled(context.signal) + const entries = await readdir(directory, { + withFileTypes: true, + }) + + for (const entry of entries) { + const fullPath = join(directory, entry.name) + + if (entry.isDirectory()) { + if (!ignored.has(entry.name) && !entry.name.startsWith('.')) { + await walk(fullPath) + } + continue + } + + if (!entry.isFile()) { + continue + } + + const path = normalizeSkillPath(relative(options.root, fullPath)) + + if (!path) { + continue + } + + const fileStat = await stat(fullPath) + const kind = isTextSkillFilePath(path) ? 'text' : 'binary' + const content = + kind === 'text' + ? await readFile(fullPath, { encoding: 'utf8', signal: context.signal }) + : new Uint8Array(await readFile(fullPath, { signal: context.signal })) + + result.push({ + path, + kind, + content, + size: fileStat.size, + lastModified: fileStat.mtimeMs, + }) + } + } + + await walk(options.root) + return result +} diff --git a/packages/kit/src/skills/loader/github.ts b/packages/kit/src/skills/loader/github.ts new file mode 100644 index 000000000..a04dfd4fc --- /dev/null +++ b/packages/kit/src/skills/loader/github.ts @@ -0,0 +1,199 @@ +import type { GithubSkillLoadOptions, LoadableSkillFile, SkillLoadContext } from './type' +import { isTextSkillFilePath, normalizeSkillPath, throwIfSkillLoadCancelled } from './utils' + +const userAgent = '@opentiny/tiny-robot-kit skill loader' +const maxGithubFetchRetries = 5 +const githubFetchRetryBaseDelay = 200 + +type GithubContentEntry = { + name: string + path: string + type: 'file' | 'dir' | 'symlink' | 'submodule' + size?: number + download_url?: string | null +} + +type GithubRepository = { + default_branch: string +} + +export async function loadGithubSkillFiles( + options: GithubSkillLoadOptions, + context: SkillLoadContext, +): Promise { + const result: LoadableSkillFile[] = [] + const ref = await resolveGithubRef(options, context) + const skillRoot = normalizeRepoPath(options.path) + + const walk = async (sourcePath: string) => { + const url = new URL(`https://api.github.com/repos/${options.repo}/contents/${sourcePath}`) + url.searchParams.set('ref', ref) + + const entries = await fetchGithubJson(url, context) + + if (!Array.isArray(entries)) { + throw new Error(`Expected directory listing for ${sourcePath}`) + } + + for (const entry of entries) { + if (entry.type === 'dir') { + if (entry.name.startsWith('.')) { + continue + } + + await walk(entry.path) + continue + } + + if (entry.type !== 'file' || !entry.download_url) { + continue + } + + const path = toSkillRelativePath(skillRoot, entry.path) + + if (!path) { + continue + } + + const kind = isTextSkillFilePath(path) ? 'text' : 'binary' + const bytes = await fetchGithubBytes(entry.download_url, context) + const content = kind === 'text' ? new TextDecoder().decode(bytes) : bytes + + result.push({ + path, + kind, + content, + size: entry.size, + }) + } + } + + await walk(skillRoot) + return result +} + +async function resolveGithubRef(options: GithubSkillLoadOptions, context: SkillLoadContext) { + if (options.ref) { + return options.ref + } + + const repository = await fetchGithubJson(`https://api.github.com/repos/${options.repo}`, context) + + if (!repository.default_branch) { + throw new Error(`Repository "${options.repo}" does not expose a default branch.`) + } + + return repository.default_branch +} + +async function fetchGithubJson(url: URL | string, context: SkillLoadContext): Promise { + const response = await fetchGithubWithRetry(url, context, { + headers: { + accept: 'application/vnd.github+json', + 'user-agent': userAgent, + }, + }) + + if (!response.ok) { + throw new Error(`Failed to fetch ${response}: ${response.status} ${response.statusText}`) + } + + return response.json() as Promise +} + +async function fetchGithubBytes(url: string, context: SkillLoadContext) { + const response = await fetchGithubWithRetry(url, context, { + headers: { + 'user-agent': userAgent, + }, + }) + + if (!response.ok) { + throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`) + } + + const bytes = new Uint8Array(await response.arrayBuffer()) + return bytes +} + +async function fetchGithubWithRetry( + url: URL | string, + context: SkillLoadContext, + init: RequestInit, +): Promise { + let lastError: unknown + + for (let retryCount = 0; retryCount <= maxGithubFetchRetries; retryCount += 1) { + try { + const response = await fetch(url, { + ...init, + signal: context.signal, + }) + + if (response.ok || !shouldRetryGithubResponse(response)) { + return response + } + + lastError = new Error(`GitHub request failed with ${response.status} ${response.statusText}`) + } catch (error) { + if (context.signal.aborted) { + throw error + } + + lastError = error + } + + if (retryCount < maxGithubFetchRetries) { + await waitForGithubFetchRetry(retryCount, context) + } + } + + throw lastError +} + +function shouldRetryGithubResponse(response: Response) { + return response.status === 429 || response.status >= 500 +} + +function waitForGithubFetchRetry(retryCount: number, context: SkillLoadContext) { + throwIfSkillLoadCancelled(context.signal) + const delay = githubFetchRetryBaseDelay * 2 ** retryCount + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + context.signal.removeEventListener('abort', onAbort) + resolve() + }, delay) + + const onAbort = () => { + clearTimeout(timeout) + reject(context.signal.reason) + } + + context.signal.addEventListener('abort', onAbort, { + once: true, + }) + }) +} + +function normalizeRepoPath(path: string) { + return path + .split('\\') + .join('/') + .replace(/^\/+|\/+$/g, '') +} + +function toSkillRelativePath(skillRoot: string, entryPath: string) { + const normalizedEntry = normalizeRepoPath(entryPath) + const prefix = `${skillRoot}/` + + if (normalizedEntry === skillRoot) { + return normalizeSkillPath(normalizedEntry.split('/').at(-1) || normalizedEntry) + } + + if (!normalizedEntry.startsWith(prefix)) { + return normalizeSkillPath(normalizedEntry) + } + + return normalizeSkillPath(normalizedEntry.slice(prefix.length)) +} diff --git a/packages/kit/src/skills/loader/index.ts b/packages/kit/src/skills/loader/index.ts new file mode 100644 index 000000000..3ffb75d43 --- /dev/null +++ b/packages/kit/src/skills/loader/index.ts @@ -0,0 +1,45 @@ +import { loadBrowserSkillFiles } from './browser' +import { createSkillDefinition } from './definition' +import { loadGithubSkillFiles } from './github' +import type { BrowserSkillLoadOptions, GithubSkillLoadOptions, SkillLoadJob, SkillLoadResult } from './type' +import { createSkillLoadJob, throwIfSkillLoadCancelled } from './utils' + +export type SkillLoadOptions = BrowserSkillLoadOptions | GithubSkillLoadOptions + +export function loadSkillWithDetails(options: SkillLoadOptions): SkillLoadJob { + return createSkillLoadJob(async (context) => { + const files = await (async () => { + switch (options.source) { + case 'browser': + return loadBrowserSkillFiles(options, context) + case 'github': + return loadGithubSkillFiles(options, context) + default: + throw new Error(`Unsupported skill source: ${(options as { source?: string }).source}`) + } + })() + + throwIfSkillLoadCancelled(context.signal) + return createSkillDefinition(files, options) + }) +} + +export function loadSkill(options: SkillLoadOptions): SkillLoadJob { + const detailsJob = loadSkillWithDetails(options) + const job = detailsJob.then((result) => result.skill) as SkillLoadJob + + job.cancel = () => { + detailsJob.cancel() + } + + return job +} + +export type { + BrowserSkillLoadOptions, + FsSkillLoadOptions, + GithubSkillLoadOptions, + SkillLoadJob, + SkillLoadProgressEvent, + SkillLoadResult, +} from './type' diff --git a/packages/kit/src/skills/loader/node.ts b/packages/kit/src/skills/loader/node.ts new file mode 100644 index 000000000..cfb059e72 --- /dev/null +++ b/packages/kit/src/skills/loader/node.ts @@ -0,0 +1,44 @@ +import { createSkillDefinition } from './definition' +import { loadFsSkillFiles } from './fs' +import { loadGithubSkillFiles } from './github' +import type { FsSkillLoadOptions, GithubSkillLoadOptions, SkillLoadJob, SkillLoadResult } from './type' +import { createSkillLoadJob, throwIfSkillLoadCancelled } from './utils' + +export type SkillLoadOptions = FsSkillLoadOptions | GithubSkillLoadOptions + +export function loadSkillWithDetails(options: SkillLoadOptions): SkillLoadJob { + return createSkillLoadJob(async (context) => { + const files = await (async () => { + switch (options.source) { + case 'fs': + return loadFsSkillFiles(options, context) + case 'github': + return loadGithubSkillFiles(options, context) + default: + throw new Error(`Unsupported skill source: ${(options as { source?: string }).source}`) + } + })() + + throwIfSkillLoadCancelled(context.signal) + return createSkillDefinition(files, options) + }) +} + +export function loadSkill(options: SkillLoadOptions): SkillLoadJob { + const detailsJob = loadSkillWithDetails(options) + const job = detailsJob.then((result) => result.skill) as SkillLoadJob + + job.cancel = () => { + detailsJob.cancel() + } + + return job +} + +export type { + FsSkillLoadOptions, + GithubSkillLoadOptions, + SkillLoadJob, + SkillLoadProgressEvent, + SkillLoadResult, +} from './type' diff --git a/packages/kit/src/skills/loader/type.ts b/packages/kit/src/skills/loader/type.ts new file mode 100644 index 000000000..101a43a29 --- /dev/null +++ b/packages/kit/src/skills/loader/type.ts @@ -0,0 +1,108 @@ +import type { SkillDefinition, SkillFileKind } from '../types' + +export type SkillLoadWarning = { + code: string + message: string + path?: string +} + +export type SkillLoadResult = { + skill: SkillDefinition + warnings: SkillLoadWarning[] +} + +export type SkillLoadJob = Promise & { + cancel(): void +} + +export type SkillLoadContext = { + signal: AbortSignal +} + +export type SkillLoadProgressPhase = 'discover' | 'read' | 'download' | 'parse' | 'store' | 'complete' + +export interface SkillLoadProgressEvent { + /** + * 当前加载阶段。不同 source 可按自身能力选择上报阶段。 + */ + phase: SkillLoadProgressPhase + /** + * 当前阶段已处理的数量。 + */ + loaded: number + /** + * 当前阶段总数量;目录遍历或远端下载时可能未知。 + */ + total?: number + /** + * 当前处理的 skill 内相对路径。 + */ + path?: string + /** + * 面向调用方展示或记录的补充说明。 + */ + message?: string +} + +export type SkillLoadBaseOptions = { + /** + * skill 入口文件名。 + */ + entryFile?: string + /** + * 启用后,非致命问题会直接抛出为错误。 + */ + strict?: boolean + /** + * @experimental + * + * 加载进度回调预留。当前 loader 暂未实现进度事件上报。 + */ + onProgress?: (event: SkillLoadProgressEvent) => void +} + +export type LoadableSkillFile = { + path: string + kind: SkillFileKind + content: string | Uint8Array + mimeType?: string + size?: number + lastModified?: number + metadata?: Record +} + +export type BrowserSkillLoadOptions = SkillLoadBaseOptions & + ( + | { + source: 'browser' + fileList: ArrayLike + directoryHandle?: never + } + | { + source: 'browser' + directoryHandle: FileSystemDirectoryHandle + fileList?: never + } + ) + +export type FsSkillLoadOptions = SkillLoadBaseOptions & { + source: 'fs' + root: string + ignoredDirectories?: string[] +} + +export type GithubSkillLoadOptions = SkillLoadBaseOptions & { + source: 'github' + /** + * GitHub 仓库,格式为 `owner/repo`。 + */ + repo: string + /** + * 分支、标签或 commit SHA。省略时使用仓库默认分支。 + */ + ref?: string + /** + * 仓库内 skill 根目录(含 SKILL.md),例如 `skills/weather`。 + */ + path: string +} diff --git a/packages/kit/src/skills/loader/utils.ts b/packages/kit/src/skills/loader/utils.ts new file mode 100644 index 000000000..9e095ed7d --- /dev/null +++ b/packages/kit/src/skills/loader/utils.ts @@ -0,0 +1,104 @@ +import { parse as parseYaml } from 'yaml' +import { getExtension, isTextSkillFilePath, normalizeSkillPath } from '../utils' +import type { SkillLoadBaseOptions, SkillLoadContext, SkillLoadJob, SkillLoadWarning } from './type' + +class SkillLoadCancelledError extends Error { + constructor() { + super('Skill load was cancelled.') + this.name = 'SkillLoadCancelledError' + } +} + +export function throwIfSkillLoadCancelled(signal: AbortSignal) { + if (!signal.aborted) { + return + } + + if (signal.reason instanceof Error) { + throw signal.reason + } + + throw new SkillLoadCancelledError() +} + +const normalizeAbortError = (error: unknown): never => { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new SkillLoadCancelledError() + } + + throw error +} + +export function createSkillLoadJob(load: (context: SkillLoadContext) => Promise): SkillLoadJob { + const controller = new AbortController() + + const job = (async () => { + try { + throwIfSkillLoadCancelled(controller.signal) + const result = await load({ signal: controller.signal }) + throwIfSkillLoadCancelled(controller.signal) + return result + } catch (error) { + normalizeAbortError(error) + } + })() as SkillLoadJob + + job.cancel = () => { + controller.abort(new SkillLoadCancelledError()) + } + + return job +} + +export { getExtension, isTextSkillFilePath, normalizeSkillPath } + +export function 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: getRecord(parseYaml(rawFrontmatter)) ?? {}, + body, + } +} + +export function stripRootDirectory(path: string) { + const normalized = path.split('\\').join('/') + const parts = normalized.split('/').filter(Boolean) + return parts.length <= 1 ? normalized : parts.slice(1).join('/') +} + +export function getFallbackSkillName(entryFile: string) { + const filename = entryFile.split('/').at(-1) || entryFile + const ext = getExtension(filename) + return ext ? filename.slice(0, -ext.length) : filename +} + +export function pushWarning(warnings: SkillLoadWarning[], options: SkillLoadBaseOptions, warning: SkillLoadWarning) { + if (options.strict) { + throw new Error(warning.path ? `${warning.path}: ${warning.message}` : warning.message) + } + + warnings.push(warning) +} + +export const getString = (value: unknown) => (typeof value === 'string' ? value : undefined) + +export const getRecord = (value: unknown) => + value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : undefined diff --git a/packages/kit/src/skills/storage/fs.ts b/packages/kit/src/skills/storage/fs.ts new file mode 100644 index 000000000..445d6c6dc --- /dev/null +++ b/packages/kit/src/skills/storage/fs.ts @@ -0,0 +1,328 @@ +import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { dirname, join, relative } from 'node:path' +import { stringify as stringifyYaml } from 'yaml' +import { loadSkillWithDetails } from '../loader/node' +import type { SkillLoadOptions } from '../loader/node' +import { + getRecord, + getString, + isTextSkillFilePath, + normalizeSkillPath, + parseMarkdownFrontmatter, +} from '../loader/utils' +import type { SkillDefinition, SkillResourceDescriptor } from '../types' +import { createImportSkill } from './importSkill' +import type { SkillStorage } from './types' + +/** 一个标准 skill 目录集合的文件系统 storage。 */ +export interface FsSkillStorageOptions { + root: string + /** 只读 storage 不允许 add/import 写入,也不允许 delete。 */ + readonly?: boolean +} + +const entryFile = 'SKILL.md' +const importSkill = createImportSkill(loadSkillWithDetails) + +export class FsSkillStorage implements SkillStorage { + readonly root: string + readonly readonly: boolean + + constructor(options: FsSkillStorageOptions) { + this.root = options.root + this.readonly = options.readonly ?? false + } + + async add(skill: SkillDefinition) { + this.assertWritable() + + const directory = this.getSkillDirectory(skill.name) + await rm(directory, { + recursive: true, + force: true, + }) + await mkdir(directory, { + recursive: true, + }) + await writeFile(join(directory, entryFile), serializeSkillEntry(skill), 'utf8') + + for (const resource of skill.resources ?? []) { + await this.writeResource(directory, resource) + } + + const storedSkill = await this.get(skill.name) + if (!storedSkill) { + throw new Error(`Failed to store skill "${skill.name}".`) + } + + return storedSkill + } + + async get(name: string) { + const directory = this.getSkillDirectory(name) + + try { + return await this.readSkillDirectory(directory) + } catch (error) { + if (isFileNotFoundError(error)) { + return undefined + } + + throw error + } + } + + async has(name: string) { + return Boolean(await this.get(name)) + } + + async delete(name: string) { + this.assertWritable() + + const directory = this.getSkillDirectory(name) + const exists = await this.has(name) + + if (!exists) { + return false + } + + await rm(directory, { + recursive: true, + force: true, + }) + return true + } + + async list() { + const entries = await readdir(this.root, { + withFileTypes: true, + }).catch((error: unknown) => { + if (isFileNotFoundError(error)) { + return [] + } + + throw error + }) + const summaries = await Promise.all( + entries + .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) + .map(async (entry) => this.get(entry.name)), + ) + + return summaries + .filter((skill): skill is SkillDefinition => Boolean(skill)) + .map((skill) => ({ + name: skill.name, + description: skill.description, + resourceCount: skill.resources?.length ?? 0, + metadata: skill.metadata, + })) + .sort((a, b) => a.name.localeCompare(b.name)) + } + + import(options: SkillLoadOptions) { + this.assertWritable() + const task = importSkill(options) + + return Object.assign( + task.then(async (result) => { + const skill = await this.add(result.skill) + return { + ...result, + name: skill.name, + skill, + } + }), + { cancel: task.cancel }, + ) + } + + private async readSkillDirectory(directory: string): Promise { + const entryPath = join(directory, entryFile) + const entryContent = await readFile(entryPath, 'utf8') + const { frontmatter, body } = parseMarkdownFrontmatter(entryContent) + const instructions = body.trim() + + if (!instructions) { + throw new Error(`Skill entry file "${entryFile}" must contain instructions.`) + } + + const resources = await this.readResourceDescriptors(directory) + + return { + name: getString(frontmatter.name) || directory.split(/[\\/]/).at(-1) || '', + description: getString(frontmatter.description) || '', + instructions, + resources: resources.length ? resources : undefined, + metadata: { + ...getRecord(frontmatter.metadata), + ...(getString(frontmatter.homepage) ? { homepage: getString(frontmatter.homepage) } : {}), + }, + } + } + + private async readResourceDescriptors(directory: string) { + const resources: SkillResourceDescriptor[] = [] + + const walk = async (currentDirectory: string) => { + const entries = await readdir(currentDirectory, { + withFileTypes: true, + }) + + for (const entry of entries) { + if (entry.name.startsWith('.')) { + continue + } + + const fullPath = join(currentDirectory, entry.name) + + if (entry.isDirectory()) { + await walk(fullPath) + continue + } + + if (!entry.isFile()) { + continue + } + + const path = normalizeSkillPath(relative(directory, fullPath)) + if (!path || path === entryFile) { + continue + } + + const fileStat = await stat(fullPath) + const kind = isTextSkillFilePath(path) ? 'text' : 'binary' + const base = { + path, + kind, + resourceId: path, + size: fileStat.size, + lastModified: fileStat.mtimeMs, + } + + resources.push( + kind === 'text' + ? { + ...base, + kind, + readText: async () => readFile(fullPath, 'utf8'), + readBinary: async () => new Uint8Array(await readFile(fullPath)), + } + : { + ...base, + kind, + readBinary: async () => new Uint8Array(await readFile(fullPath)), + readText: async () => new TextDecoder().decode(await readFile(fullPath)), + }, + ) + } + } + + await walk(directory) + return resources.sort((a, b) => a.path.localeCompare(b.path)) + } + + private async writeResource(directory: string, resource: SkillResourceDescriptor) { + const path = normalizeSkillPath(resource.path) + + if (!path || path === entryFile) { + return + } + + const fullPath = join(directory, path) + await mkdir(dirname(fullPath), { + recursive: true, + }) + + if (resource.kind === 'text') { + await writeFile(fullPath, await getResourceText(resource), 'utf8') + return + } + + await writeFile(fullPath, await getResourceBinary(resource)) + } + + private getSkillDirectory(name: string) { + const directoryName = normalizeSkillPath(name) + + if (!directoryName || directoryName.includes('/')) { + throw new Error(`Invalid skill name for file storage: ${name}`) + } + + return join(this.root, directoryName) + } + + private assertWritable() { + if (this.readonly) { + throw new Error('File system skill storage is readonly.') + } + } +} + +export function createFsSkillStorage(options: FsSkillStorageOptions) { + return new FsSkillStorage(options) +} + +function serializeSkillEntry(skill: SkillDefinition) { + const metadata = { ...skill.metadata } + const homepage = typeof metadata.homepage === 'string' ? metadata.homepage : undefined + delete metadata.homepage + const frontmatter: Record = { + name: skill.name, + description: skill.description, + } + + if (homepage) { + frontmatter.homepage = homepage + } + + if (Object.keys(metadata).length > 0) { + frontmatter.metadata = metadata + } + + return `---\n${stringifyYaml(frontmatter).trimEnd()}\n---\n\n${skill.instructions.trim()}\n` +} + +async function getResourceText(resource: SkillResourceDescriptor) { + if (resource.text !== undefined) { + return resource.text + } + + if (resource.readText) { + return resource.readText() + } + + if (resource.binary) { + return new TextDecoder().decode(resource.binary) + } + + if (resource.readBinary) { + return new TextDecoder().decode(await resource.readBinary()) + } + + throw new Error(`Skill resource "${resource.path}" has no text content.`) +} + +async function getResourceBinary(resource: SkillResourceDescriptor) { + if (resource.binary) { + return resource.binary + } + + if (resource.readBinary) { + return resource.readBinary() + } + + if (resource.text !== undefined) { + return new TextEncoder().encode(resource.text) + } + + if (resource.readText) { + return new TextEncoder().encode(await resource.readText()) + } + + throw new Error(`Skill resource "${resource.path}" has no binary content.`) +} + +function isFileNotFoundError(error: unknown) { + return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') +} diff --git a/packages/kit/src/skills/storage/importSkill.ts b/packages/kit/src/skills/storage/importSkill.ts new file mode 100644 index 000000000..88a786add --- /dev/null +++ b/packages/kit/src/skills/storage/importSkill.ts @@ -0,0 +1,26 @@ +import type { SkillLoadJob, SkillLoadResult } from '../loader/type' +import type { SkillImporter, SkillImportJob, SkillImportResult } from './types' + +export function createImportSkill( + loadSkill: (options: TImportOptions) => SkillLoadJob, +): SkillImporter { + return (options) => { + const loadJob = loadSkill(options) + + const task = (async (): Promise => { + const { skill, warnings } = await loadJob + + return { + name: skill.name, + skill, + warnings, + } + })() as SkillImportJob + + task.cancel = () => { + loadJob.cancel() + } + + return task + } +} diff --git a/packages/kit/src/skills/storage/index.ts b/packages/kit/src/skills/storage/index.ts new file mode 100644 index 000000000..fb9bcf887 --- /dev/null +++ b/packages/kit/src/skills/storage/index.ts @@ -0,0 +1,18 @@ +import { loadSkillWithDetails } from '../loader' +import type { SkillLoadOptions } from '../loader' +import type { SkillStorage as SkillStorageBase } from './types' +import { createImportSkill } from './importSkill' +import { createMemorySkillStorage as createMemorySkillStorageBase } from './memory' + +export type { SkillImportJob, SkillImportResult } from './types' +export type SkillImportOptions = SkillLoadOptions +export type SkillStorage = SkillStorageBase +export { createIndexedDBSkillStorage, IndexedDBSkillStorage } from './indexedDB' +export type { IndexedDBSkillStorageOptions } from './indexedDB' +export { MemorySkillStorage } from './memory' + +export const importSkill = createImportSkill(loadSkillWithDetails) + +export function createMemorySkillStorage() { + return createMemorySkillStorageBase(importSkill) +} diff --git a/packages/kit/src/skills/storage/indexedDB.ts b/packages/kit/src/skills/storage/indexedDB.ts new file mode 100644 index 000000000..0a9205b59 --- /dev/null +++ b/packages/kit/src/skills/storage/indexedDB.ts @@ -0,0 +1,341 @@ +import { openDB, type DBSchema, type IDBPDatabase, type IDBPTransaction } from 'idb' +import { loadSkillWithDetails } from '../loader' +import type { SkillLoadOptions } from '../loader' +import type { SkillDefinition, SkillResourceDescriptor } from '../types' +import { createImportSkill } from './importSkill' +import type { SkillImportJob, SkillImporter, SkillStorage, SkillSummary } from './types' + +const defaultVersion = 1 +const defaultSkillStoreName = 'skills' +const defaultResourceStoreName = 'resources' + +export interface IndexedDBSkillStorageOptions { + /** + * IndexedDB database name. Tests should pass a unique name to avoid cross-test state. + */ + databaseName: string +} + +interface IndexedDBSkillStorageSkillRecord { + name: string + description: string + instructions: string + metadata?: Record + resources?: IndexedDBSkillStorageResourceMetadata[] +} + +interface IndexedDBSkillStorageResourceMetadata { + path: string + kind: SkillResourceDescriptor['kind'] + resourceId: string + mimeType?: string + size?: number + lastModified?: number + metadata?: Record +} + +interface IndexedDBSkillStorageResourceRecord { + skillName: string + resourceId: string + kind: SkillResourceDescriptor['kind'] + text?: string + binary?: Uint8Array +} + +interface IndexedDBSkillStorageSchema extends DBSchema { + skills: { + key: string + value: IndexedDBSkillStorageSkillRecord + } + resources: { + key: [string, string] + value: IndexedDBSkillStorageResourceRecord + indexes: { + skillName: string + } + } +} + +type IndexedDBSkillStorageTransaction = IDBPTransaction< + IndexedDBSkillStorageSchema, + ['skills', 'resources'], + 'readwrite' +> + +type SkillImportOptions = SkillLoadOptions +const importSkill = createImportSkill(loadSkillWithDetails) + +export class IndexedDBSkillStorage implements SkillStorage { + readonly databaseName: string + readonly skillStoreName = defaultSkillStoreName + readonly resourceStoreName = defaultResourceStoreName + private dbPromise?: Promise> + + constructor( + options: IndexedDBSkillStorageOptions, + private readonly importer: SkillImporter = importSkill as SkillImporter, + ) { + this.databaseName = options.databaseName + } + + private getDB() { + this.dbPromise ??= openDB(this.databaseName, defaultVersion, { + upgrade: (db) => { + if (!db.objectStoreNames.contains(this.skillStoreName)) { + db.createObjectStore(defaultSkillStoreName, { + keyPath: 'name', + }) + } + + if (!db.objectStoreNames.contains(this.resourceStoreName)) { + const resourceStore = db.createObjectStore(defaultResourceStoreName, { + keyPath: ['skillName', 'resourceId'], + }) + resourceStore.createIndex('skillName', 'skillName') + } + }, + }) + + return this.dbPromise + } + + async add(skill: SkillDefinition) { + const db = await this.getDB() + const tx = db.transaction([defaultSkillStoreName, defaultResourceStoreName], 'readwrite') + const skillStore = tx.objectStore(defaultSkillStoreName) + const resourceStore = tx.objectStore(defaultResourceStoreName) + + await this.deleteResourceRecords(tx, skill.name) + await skillStore.put(toSkillRecord(skill)) + + for (const resource of skill.resources ?? []) { + await resourceStore.put(await toResourceRecord(skill.name, resource)) + } + + await tx.done + return this.getStoredSkill(skill.name) + } + + async get(name: string) { + const db = await this.getDB() + const record = await db.get(defaultSkillStoreName, name) + + return record ? this.toSkillDefinition(record) : undefined + } + + async has(name: string) { + const db = await this.getDB() + return (await db.count(defaultSkillStoreName, name)) > 0 + } + + async delete(name: string) { + const db = await this.getDB() + const tx = db.transaction([defaultSkillStoreName, defaultResourceStoreName], 'readwrite') + const skillStore = tx.objectStore(defaultSkillStoreName) + const existed = (await skillStore.count(name)) > 0 + + await skillStore.delete(name) + await this.deleteResourceRecords(tx, name) + await tx.done + + return existed + } + + async list(): Promise { + const db = await this.getDB() + const records = await db.getAll(defaultSkillStoreName) + + return records + .map((record) => ({ + name: record.name, + description: record.description, + resourceCount: record.resources?.length ?? 0, + metadata: record.metadata, + })) + .sort((a, b) => a.name.localeCompare(b.name)) + } + + import(options: TImportOptions): SkillImportJob { + const task = this.importer(options) + + return Object.assign( + task.then(async (result) => { + await this.add(result.skill) + return result + }), + { cancel: task.cancel }, + ) + } + + private async getStoredSkill(name: string) { + const skill = await this.get(name) + + if (!skill) { + throw new Error(`Failed to store skill "${name}".`) + } + + return skill + } + + private toSkillDefinition(record: IndexedDBSkillStorageSkillRecord): SkillDefinition { + return { + name: record.name, + description: record.description, + instructions: record.instructions, + metadata: record.metadata ? { ...record.metadata } : undefined, + resources: record.resources?.map((resource) => this.toSkillResource(record.name, resource)), + } + } + + private toSkillResource(skillName: string, resource: IndexedDBSkillStorageResourceMetadata): SkillResourceDescriptor { + const base = { + path: resource.path, + resourceId: resource.resourceId, + mimeType: resource.mimeType, + size: resource.size, + lastModified: resource.lastModified, + metadata: resource.metadata ? { ...resource.metadata } : undefined, + } + + if (resource.kind === 'text') { + return { + ...base, + kind: resource.kind, + readText: async () => this.readResourceText(skillName, resource.resourceId), + readBinary: async () => this.readResourceBinary(skillName, resource.resourceId), + } + } + + return { + ...base, + kind: resource.kind, + readBinary: async () => this.readResourceBinary(skillName, resource.resourceId), + readText: async () => this.readResourceText(skillName, resource.resourceId), + } + } + + private async readResourceText(skillName: string, resourceId: string) { + const resource = await this.getResourceRecord(skillName, resourceId) + + if (typeof resource.text === 'string') { + return resource.text + } + + if (resource.binary) { + return new TextDecoder().decode(resource.binary) + } + + throw new Error(`Skill resource "${resourceId}" has no text content.`) + } + + private async readResourceBinary(skillName: string, resourceId: string) { + const resource = await this.getResourceRecord(skillName, resourceId) + + if (resource.binary) { + return new Uint8Array(resource.binary) + } + + if (typeof resource.text === 'string') { + return new TextEncoder().encode(resource.text) + } + + throw new Error(`Skill resource "${resourceId}" has no binary content.`) + } + + private async getResourceRecord(skillName: string, resourceId: string) { + const db = await this.getDB() + const resource = await db.get(defaultResourceStoreName, [skillName, resourceId]) + + if (!resource) { + throw new Error(`Skill resource "${resourceId}" was not found.`) + } + + return resource + } + + private async deleteResourceRecords(tx: IndexedDBSkillStorageTransaction, skillName: string) { + const resourceStore = tx.objectStore(defaultResourceStoreName) + const resourceKeys = await resourceStore.index('skillName').getAllKeys(skillName) + + await Promise.all(resourceKeys.map((key) => resourceStore.delete(key))) + } +} + +export function createIndexedDBSkillStorage(options: IndexedDBSkillStorageOptions) { + return new IndexedDBSkillStorage(options) +} + +function toSkillRecord(skill: SkillDefinition): IndexedDBSkillStorageSkillRecord { + return { + name: skill.name, + description: skill.description, + instructions: skill.instructions, + metadata: skill.metadata ? { ...skill.metadata } : undefined, + resources: skill.resources?.map((resource) => ({ + path: resource.path, + kind: resource.kind, + resourceId: resource.resourceId, + mimeType: resource.mimeType, + size: resource.size, + lastModified: resource.lastModified, + metadata: resource.metadata ? { ...resource.metadata } : undefined, + })), + } +} + +async function toResourceRecord( + skillName: string, + resource: SkillResourceDescriptor, +): Promise { + if (resource.kind === 'text') { + const text = resource.text ?? (await readTextContent(resource)) + + if (typeof text !== 'string') { + throw new Error(`Skill resource "${resource.resourceId}" has no text content to store.`) + } + + return { + skillName, + resourceId: resource.resourceId, + kind: resource.kind, + text, + } + } + + const binary = resource.binary ?? (await readBinaryContent(resource)) + + if (!binary) { + throw new Error(`Skill resource "${resource.resourceId}" has no binary content to store.`) + } + + return { + skillName, + resourceId: resource.resourceId, + kind: resource.kind, + binary: new Uint8Array(binary), + } +} + +async function readTextContent(resource: SkillResourceDescriptor) { + if (resource.readText) { + return resource.readText() + } + + if (resource.binary) { + return new TextDecoder().decode(resource.binary) + } + + return undefined +} + +async function readBinaryContent(resource: SkillResourceDescriptor) { + if (resource.readBinary) { + return resource.readBinary() + } + + if (resource.text) { + return new TextEncoder().encode(resource.text) + } + + return undefined +} diff --git a/packages/kit/src/skills/storage/memory.ts b/packages/kit/src/skills/storage/memory.ts new file mode 100644 index 000000000..5ae401226 --- /dev/null +++ b/packages/kit/src/skills/storage/memory.ts @@ -0,0 +1,117 @@ +import type { SkillDefinition, SkillResourceDescriptor } from '../types' +import type { SkillImporter, SkillStorage, SkillSummary } from './types' + +const toSummary = (skill: SkillDefinition): SkillSummary => ({ + name: skill.name, + description: skill.description, + resourceCount: skill.resources?.length ?? 0, + metadata: skill.metadata, +}) + +const cloneSkill = (skill: SkillDefinition): SkillDefinition => ({ + name: skill.name, + description: skill.description, + instructions: skill.instructions, + metadata: skill.metadata ? { ...skill.metadata } : undefined, + resources: skill.resources?.map(cloneResource), +}) + +const cloneResource = (resource: SkillResourceDescriptor): SkillResourceDescriptor => { + const base = { + path: resource.path, + resourceId: resource.resourceId, + mimeType: resource.mimeType, + size: resource.size, + lastModified: resource.lastModified, + metadata: resource.metadata ? { ...resource.metadata } : undefined, + } + + if (resource.kind === 'text') { + const content = { + binary: resource.binary ? new Uint8Array(resource.binary) : undefined, + readBinary: resource.readBinary, + } + + return resource.text !== undefined + ? { + ...base, + ...content, + kind: resource.kind, + text: resource.text, + readText: resource.readText, + } + : { + ...base, + ...content, + kind: resource.kind, + readText: resource.readText!, + } + } + + const content = { + text: resource.text, + readText: resource.readText, + } + + return resource.binary + ? { + ...base, + ...content, + kind: resource.kind, + binary: new Uint8Array(resource.binary), + readBinary: resource.readBinary, + } + : { + ...base, + ...content, + kind: resource.kind, + readBinary: resource.readBinary!, + } +} + +export class MemorySkillStorage implements SkillStorage { + private skills = new Map() + + constructor(private readonly importer: SkillImporter) {} + + async add(skill: SkillDefinition) { + const saved = cloneSkill(skill) + this.skills.set(skill.name, saved) + return cloneSkill(saved) + } + + async get(name: string) { + const skill = this.skills.get(name) + return skill ? cloneSkill(skill) : undefined + } + + async has(name: string) { + return this.skills.has(name) + } + + async delete(name: string) { + return this.skills.delete(name) + } + + async list() { + return Array.from(this.skills.values()) + .map(toSummary) + .sort((a, b) => a.name.localeCompare(b.name)) + } + + import(options: TImportOptions) { + const task = this.importer(options) + + return Object.assign( + task.then(async (result) => { + await this.add(result.skill) + return result + }), + { cancel: task.cancel }, + ) + } +} + +export function createMemorySkillStorage(importer: SkillImporter) { + return new MemorySkillStorage(importer) +} diff --git a/packages/kit/src/skills/storage/node.ts b/packages/kit/src/skills/storage/node.ts new file mode 100644 index 000000000..0555270c1 --- /dev/null +++ b/packages/kit/src/skills/storage/node.ts @@ -0,0 +1,18 @@ +import { loadSkillWithDetails } from '../loader/node' +import type { SkillLoadOptions } from '../loader/node' +import type { SkillStorage as SkillStorageBase } from './types' +import { createImportSkill } from './importSkill' +import { createMemorySkillStorage as createMemorySkillStorageBase } from './memory' + +export type { SkillImportJob, SkillImportResult } from './types' +export type SkillImportOptions = SkillLoadOptions +export type SkillStorage = SkillStorageBase +export { MemorySkillStorage } from './memory' + +export const importSkill = createImportSkill(loadSkillWithDetails) + +export function createMemorySkillStorage() { + return createMemorySkillStorageBase(importSkill) +} +export { createFsSkillStorage, FsSkillStorage } from './fs' +export type { FsSkillStorageOptions } from './fs' diff --git a/packages/kit/src/skills/storage/types.ts b/packages/kit/src/skills/storage/types.ts new file mode 100644 index 000000000..c6a269bf4 --- /dev/null +++ b/packages/kit/src/skills/storage/types.ts @@ -0,0 +1,48 @@ +import type { SkillDefinition } from '../types' +import type { SkillLoadWarning } from '../loader/type' + +/** skill 摘要,用于 list()。 */ +export interface SkillSummary { + name: string + description: string + resourceCount: number + metadata?: Record +} + +/** + * skill 持久化与导入。 + * + * @example + * await storage.add(skill) + * const saved = await storage.get('weather') + * const summaries = await storage.list() + */ +export interface SkillStorage { + add(skill: SkillDefinition): Promise + get(name: string): Promise + has(name: string): Promise + delete(name: string): Promise + list(): Promise + import(options: TImportOptions): SkillImportJob +} + +export interface SkillImportResult { + name: string + skill: SkillDefinition + warnings: SkillLoadWarning[] +} + +/** + * 进行中的导入操作;await 得到 SkillImportResult。 + * + * @example + * const job = storage.import({ source: 'browser', fileList: input.files }) + * job.cancel() + * const { name, warnings } = await job + */ +export type SkillImportJob = Promise & { + /** 中止导入。 */ + cancel(): void +} + +export type SkillImporter = (options: TImportOptions) => SkillImportJob 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/fsStorage.test.ts b/packages/kit/src/skills/test/fsStorage.test.ts new file mode 100644 index 000000000..718352a06 --- /dev/null +++ b/packages/kit/src/skills/test/fsStorage.test.ts @@ -0,0 +1,106 @@ +import { cp, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { createFsSkillStorage } from '../storage/node' + +const createTempRoot = () => mkdtemp(join(tmpdir(), 'tiny-robot-skill-storage-')) + +describe('FsSkillStorage', () => { + it('adds and restores skills in native directory format with lazy resources', async () => { + const root = await createTempRoot() + const storage = createFsSkillStorage({ root }) + + await storage.add({ + name: 'demo', + description: 'Demo skill', + instructions: '# Demo\n\nUse this skill.', + metadata: { + homepage: 'https://example.com/demo', + version: '1.0.0', + }, + resources: [ + { + path: 'references/guide.md', + kind: 'text', + resourceId: 'references/guide.md', + text: '# Guide', + }, + { + path: 'assets/icon.bin', + kind: 'binary', + resourceId: 'assets/icon.bin', + binary: new Uint8Array([1, 2, 3]), + }, + ], + }) + + await expect(readFile(join(root, 'demo', 'SKILL.md'), 'utf8')).resolves.toContain( + 'homepage: https://example.com/demo', + ) + await expect(readFile(join(root, 'demo', 'references', 'guide.md'), 'utf8')).resolves.toBe('# Guide') + + const storedSkill = await storage.get('demo') + const guide = storedSkill?.resources?.find((resource) => resource.path === 'references/guide.md') + + expect(storedSkill).toMatchObject({ + name: 'demo', + description: 'Demo skill', + instructions: '# Demo\n\nUse this skill.', + metadata: { + homepage: 'https://example.com/demo', + version: '1.0.0', + }, + }) + expect(guide).toMatchObject({ + path: 'references/guide.md', + kind: 'text', + }) + expect(guide).not.toHaveProperty('text') + + await writeFile(join(root, 'demo', 'references', 'guide.md'), '# Updated', 'utf8') + await expect(guide?.readText?.()).resolves.toBe('# Updated') + }) + + it('lists existing skill directories, imports another skill, and deletes skills', async () => { + const root = await createTempRoot() + const weatherRoot = fileURLToPath(new URL('./.cache/weather', import.meta.url)) + const vueRoot = fileURLToPath(new URL('./.cache/vue-best-practices', import.meta.url)) + await cp(weatherRoot, join(root, 'weather'), { + recursive: true, + }) + + const storage = createFsSkillStorage({ root }) + + await expect(storage.list()).resolves.toEqual([ + expect.objectContaining({ + name: 'weather', + description: expect.stringContaining('weather'), + }), + ]) + const existingSkill = await storage.get('weather') + expect(existingSkill?.instructions).toContain('# Weather Skill') + + const result = await storage.import({ + source: 'fs', + root: vueRoot, + }) + + expect(result.skill.name).toBe('vue-best-practices') + await expect(storage.list()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'weather', + }), + expect.objectContaining({ + name: 'vue-best-practices', + }), + ]), + ) + expect(await storage.has('weather')).toBe(true) + expect(await storage.delete('weather')).toBe(true) + expect(await storage.get('weather')).toBeUndefined() + expect(await storage.has('vue-best-practices')).toBe(true) + }) +}) diff --git a/packages/kit/src/skills/test/indexedDBStorage.test.ts b/packages/kit/src/skills/test/indexedDBStorage.test.ts new file mode 100644 index 000000000..9e4aa0022 --- /dev/null +++ b/packages/kit/src/skills/test/indexedDBStorage.test.ts @@ -0,0 +1,180 @@ +import 'fake-indexeddb/auto' +import { describe, expect, it } from 'vitest' +import { createIndexedDBSkillStorage } from '../storage' +import type { SkillDefinition } from '../types' + +const databaseName = () => `tiny-robot-skills-test-${crypto.randomUUID()}` + +describe('IndexedDBSkillStorage', () => { + it('adds, gets, lists, checks, and deletes skills', async () => { + const storage = createIndexedDBSkillStorage({ databaseName: databaseName() }) + + const saved = await storage.add({ + name: 'demo', + description: 'Demo skill', + instructions: '# Demo', + metadata: { + homepage: 'https://example.com', + }, + }) + + expect(saved).toMatchObject({ + name: 'demo', + description: 'Demo skill', + instructions: '# Demo', + metadata: { + homepage: 'https://example.com', + }, + }) + expect(await storage.has('demo')).toBe(true) + expect(await storage.list()).toEqual([ + { + name: 'demo', + description: 'Demo skill', + resourceCount: 0, + metadata: { + homepage: 'https://example.com', + }, + }, + ]) + + expect(await storage.delete('demo')).toBe(true) + expect(await storage.delete('demo')).toBe(false) + expect(await storage.has('demo')).toBe(false) + expect(await storage.get('demo')).toBeUndefined() + }) + + it('restores resources as lazy readers without eager content', async () => { + const storage = createIndexedDBSkillStorage({ databaseName: databaseName() }) + + await storage.add({ + name: 'docs', + description: 'Docs skill', + instructions: '# Docs', + resources: [ + { + path: 'references/guide.md', + kind: 'text', + resourceId: 'references/guide.md', + text: '# Guide', + readText: async () => '# Guide', + }, + { + path: 'assets/icon.png', + kind: 'binary', + resourceId: 'assets/icon.png', + binary: new Uint8Array([1, 2, 3]), + readBinary: async () => new Uint8Array([1, 2, 3]), + mimeType: 'image/png', + }, + ], + }) + + const skill = await storage.get('docs') + const textResource = skill?.resources?.find((resource) => resource.path === 'references/guide.md') + const binaryResource = skill?.resources?.find((resource) => resource.path === 'assets/icon.png') + + expect(textResource).toMatchObject({ + path: 'references/guide.md', + kind: 'text', + resourceId: 'references/guide.md', + }) + expect(textResource).not.toHaveProperty('text') + await expect(textResource?.readText?.()).resolves.toBe('# Guide') + await expect(textResource?.readBinary?.()).resolves.toEqual(new TextEncoder().encode('# Guide')) + + expect(binaryResource).toMatchObject({ + path: 'assets/icon.png', + kind: 'binary', + resourceId: 'assets/icon.png', + mimeType: 'image/png', + }) + expect(binaryResource).not.toHaveProperty('binary') + await expect(binaryResource?.readBinary?.()).resolves.toEqual(new Uint8Array([1, 2, 3])) + await expect(binaryResource?.readText?.()).resolves.toBe(new TextDecoder().decode(new Uint8Array([1, 2, 3]))) + }) + + it('overwrites stale resource records when replacing a skill', async () => { + const storage = createIndexedDBSkillStorage({ databaseName: databaseName() }) + + await storage.add({ + name: 'docs', + description: 'Docs skill', + instructions: '# Docs', + resources: [ + { + path: 'old.md', + kind: 'text', + resourceId: 'old.md', + text: 'old', + }, + ], + }) + + await storage.add({ + name: 'docs', + description: 'Updated docs skill', + instructions: '# Updated', + resources: [ + { + path: 'new.md', + kind: 'text', + resourceId: 'new.md', + text: 'new', + }, + ], + }) + + const skill = await storage.get('docs') + + expect(skill?.description).toBe('Updated docs skill') + expect(skill?.resources?.map((resource) => resource.path)).toEqual(['new.md']) + await expect(skill?.resources?.[0]?.readText?.()).resolves.toBe('new') + }) + + it('imports skills from browser sources', async () => { + const storage = createIndexedDBSkillStorage({ databaseName: databaseName() }) + + const file = new File( + [['---', 'name: browser-docs', 'description: Browser docs skill', '---', '', '# Browser Docs'].join('\n')], + 'SKILL.md', + { type: 'text/markdown' }, + ) + + const result = await storage.import({ + source: 'browser', + fileList: [file], + }) + + expect(result.name).toBe('browser-docs') + expect(await storage.get('browser-docs')).toMatchObject({ + name: 'browser-docs', + instructions: '# Browser Docs', + }) + }) + + it('persists resources through lazy readers during add', async () => { + const storage = createIndexedDBSkillStorage({ databaseName: databaseName() }) + const skill: SkillDefinition = { + name: 'lazy', + description: 'Lazy skill', + instructions: '# Lazy', + resources: [ + { + path: 'lazy.md', + kind: 'text', + resourceId: 'lazy.md', + readText: async () => 'lazy text', + }, + ], + } + + await storage.add(skill) + + const storedSkill = await storage.get('lazy') + const resource = storedSkill?.resources?.[0] + + expect(resource).not.toHaveProperty('text') + await expect(resource?.readText?.()).resolves.toBe('lazy text') + }) +}) diff --git a/packages/kit/src/skills/test/loaderBrowser.test.ts b/packages/kit/src/skills/test/loaderBrowser.test.ts new file mode 100644 index 000000000..6c14c9e5c --- /dev/null +++ b/packages/kit/src/skills/test/loaderBrowser.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { loadSkill, loadSkillWithDetails } from '../loader' + +type TestFile = File & { + webkitRelativePath?: string +} + +const createTestFile = (path: string, content: string | Uint8Array, type = 'text/plain'): TestFile => { + const fileContent: BlobPart = typeof content === 'string' ? content : new Uint8Array(content) + const file = new File([fileContent], path.split('/').at(-1) ?? path, { + type, + lastModified: 123, + }) as TestFile + + file.webkitRelativePath = path + return file +} + +describe('browser loadSkill', () => { + it('loads fileList skills and strips the root directory from resource paths', async () => { + const loadedSkill = await loadSkill({ + source: 'browser', + fileList: [ + createTestFile( + 'weather/SKILL.md', + ['---', 'name: weather', 'description: Weather skill', '---', '', '# Weather Skill'].join('\n'), + 'text/markdown', + ), + createTestFile('weather/references/usage.md', '# Usage', 'text/markdown'), + ], + }) + + expect(loadedSkill.name).toBe('weather') + expect(loadedSkill.instructions).toContain('# Weather Skill') + expect(loadedSkill.resources?.map((resource) => resource.path)).toEqual(['references/usage.md']) + await expect(loadedSkill.resources?.[0]?.readText?.()).resolves.toBe('# Usage') + }) + + it('loads binary browser resources', async () => { + const image = new Uint8Array([1, 2, 3]) + const loadedSkill = await loadSkill({ + source: 'browser', + fileList: [ + createTestFile( + 'binary-skill/SKILL.md', + ['---', 'name: binary-skill', 'description: Binary skill', '---', '', '# Binary Skill'].join('\n'), + 'text/markdown', + ), + createTestFile('binary-skill/assets/icon.png', image, 'image/png'), + ], + }) + + expect(loadedSkill.resources).toEqual([ + expect.objectContaining({ + path: 'assets/icon.png', + kind: 'binary', + mimeType: 'image/png', + binary: image, + }), + ]) + }) + + it('uses file names when webkitRelativePath is not available', async () => { + const file = createTestFile( + 'SKILL.md', + ['---', 'name: single-file', 'description: Single file skill', '---', '', '# Single'].join('\n'), + 'text/markdown', + ) + file.webkitRelativePath = '' + + const loadedSkill = await loadSkill({ + source: 'browser', + fileList: [file], + }) + + expect(loadedSkill.name).toBe('single-file') + expect(loadedSkill.resources).toBeUndefined() + }) + + it('loads skill details with warnings', async () => { + const loadedSkill = await loadSkillWithDetails({ + source: 'browser', + fileList: [ + createTestFile( + 'weather/SKILL.md', + ['---', 'name: weather', 'description: Weather skill', '---', '', '# Weather Skill'].join('\n'), + 'text/markdown', + ), + ], + }) + + expect(loadedSkill.skill.name).toBe('weather') + expect(loadedSkill.warnings).toEqual([]) + }) + + it('returns a cancellable load job', async () => { + let releaseWait!: () => void + const waitForText = new Promise((resolve) => { + releaseWait = () => resolve('# Cancelled') + }) + const file = createTestFile( + 'cancelled/SKILL.md', + ['---', 'name: cancelled', 'description: Cancelled skill', '---', '', '# Cancelled'].join('\n'), + 'text/markdown', + ) + + file.text = async () => waitForText + + const job = loadSkill({ + source: 'browser', + fileList: [file], + }) + + job.cancel() + releaseWait() + + await expect(job).rejects.toMatchObject({ + name: 'SkillLoadCancelledError', + }) + }) +}) diff --git a/packages/kit/src/skills/test/loaderDefinition.test.ts b/packages/kit/src/skills/test/loaderDefinition.test.ts new file mode 100644 index 000000000..a2f69ba0d --- /dev/null +++ b/packages/kit/src/skills/test/loaderDefinition.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from 'vitest' +import { createSkillDefinition } from '../loader/definition' + +describe('createSkillDefinition', () => { + it('creates a SkillDefinition from skill entry frontmatter and instructions', () => { + const loadedSkill = createSkillDefinition( + [ + { + path: 'SKILL.md', + kind: 'text', + content: [ + '---', + 'name: weather', + 'description: Get weather information', + 'homepage: https://wttr.in/:help', + '---', + '', + '# Weather Skill', + ].join('\n'), + }, + ], + {}, + ) + 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('creates resources from multi-file skill references', () => { + const loadedSkill = createSkillDefinition( + [ + { + path: 'SKILL.md', + kind: 'text', + content: [ + '---', + 'name: vue-best-practices', + 'description: Vue.js tasks', + 'metadata:', + ' author: github.com/vuejs-ai', + ' version: 18.0.0', + '---', + '', + '# Vue Best Practices Workflow', + ].join('\n'), + }, + { + path: 'references/reactivity.md', + kind: 'text', + content: '# Reactivity', + }, + { + path: 'references/sfc.md', + kind: 'text', + content: '# SFC', + }, + ], + {}, + ) + 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.resources).toHaveLength(2) + expect(skill.resources?.map((file) => file.path)).toEqual(['references/reactivity.md', 'references/sfc.md']) + expect(skill.resources?.find((file) => file.path === 'references/reactivity.md')).toMatchObject({ + path: 'references/reactivity.md', + kind: 'text', + text: expect.stringContaining('# Reactivity'), + }) + expect(loadedSkill.warnings).toEqual([]) + }) + + it('keeps binary files as skill resources', () => { + const image = new Uint8Array([1, 2, 3]) + const loadedSkill = createSkillDefinition( + [ + { + 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.resources).toEqual([ + expect.objectContaining({ + path: 'assets/icon.png', + kind: 'binary', + binary: image, + mimeType: 'image/png', + size: 3, + lastModified: 123, + }), + ]) + expect(loadedSkill.warnings).toEqual([]) + }) + + it('throws when the entry file is missing', () => { + expect(() => createSkillDefinition([], {})).toThrow('Skill entry file "SKILL.md" is missing.') + }) + + it('throws when the entry file is binary', () => { + expect(() => + createSkillDefinition( + [ + { + 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(() => + createSkillDefinition( + [ + { + 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 = createSkillDefinition( + [ + { + 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.resources?.map((file) => file.path)).toEqual(['notes.md']) + }) + + it('throws warnings as errors in strict mode', () => { + expect(() => + createSkillDefinition( + [ + { + 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', + }, + ], + { strict: true }, + ), + ).toThrow('notes.md: Duplicate skill file path: notes.md') + }) + + it('keeps json files as regular skill resources', () => { + const loadedSkill = createSkillDefinition( + [ + { + 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.resources?.map((file) => file.path)).toEqual(['references/weather-format.json']) + expect(loadedSkill.warnings).toEqual([]) + }) +}) diff --git a/packages/kit/src/skills/test/loaderNode.test.ts b/packages/kit/src/skills/test/loaderNode.test.ts new file mode 100644 index 000000000..f4da63bfc --- /dev/null +++ b/packages/kit/src/skills/test/loaderNode.test.ts @@ -0,0 +1,131 @@ +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { loadSkill, loadSkillWithDetails } from '../loader/node' + +const createResponse = ( + status: number, + body: unknown, + statusText = status >= 200 && status < 300 ? 'OK' : 'Server Error', +) => + ({ + ok: status >= 200 && status < 300, + status, + statusText, + json: async () => body, + arrayBuffer: async () => { + const bytes = new TextEncoder().encode(String(body)) + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) + }, + }) as Response + +describe('node loadSkill', () => { + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('loads weather skill directory as SkillDefinition', async () => { + const root = fileURLToPath(new URL('./.cache/weather', import.meta.url)) + const loadedSkill = await loadSkill({ source: 'fs', root }) + + expect(loadedSkill.name).toBe('weather') + expect(loadedSkill.description).toContain('weather') + expect(loadedSkill.instructions).toContain('# Weather Skill') + expect(loadedSkill.metadata?.homepage).toBe('https://wttr.in/:help') + }) + + it('loads multi-file skill references as resources', async () => { + const root = fileURLToPath(new URL('./.cache/vue-best-practices', import.meta.url)) + const loadedSkill = await loadSkill({ source: 'fs', root }) + + expect(loadedSkill.name).toBe('vue-best-practices') + expect(loadedSkill.description).toContain('Vue.js tasks') + expect(loadedSkill.instructions).toContain('# Vue Best Practices Workflow') + expect(loadedSkill.metadata).toMatchObject({ + author: 'github.com/vuejs-ai', + version: '18.0.0', + }) + expect(loadedSkill.resources).toBeDefined() + expect(loadedSkill.resources?.map((file) => file.path)).toEqual( + expect.arrayContaining([ + 'references/reactivity.md', + 'references/sfc.md', + 'references/component-data-flow.md', + 'references/composables.md', + ]), + ) + expect(loadedSkill.resources?.find((file) => file.path === 'references/reactivity.md')).toMatchObject({ + path: 'references/reactivity.md', + kind: 'text', + text: expect.stringContaining('# Reactivity'), + }) + }) + + it('loads weather skill from GitHub over the network', async () => { + const expectedRoot = fileURLToPath(new URL('./.cache/weather', import.meta.url)) + const expectedSkill = await loadSkill({ source: 'fs', root: expectedRoot }) + const loadedSkill = await loadSkill({ + source: 'github', + repo: 'openclaw/openclaw', + ref: '58672075219d09495de6489ad0821d276ac84f13', + path: 'skills/weather', + }) + + expect(loadedSkill.name).toBe(expectedSkill.name) + expect(loadedSkill.description).toBe(expectedSkill.description) + expect(loadedSkill.instructions).toBe(expectedSkill.instructions) + expect(loadedSkill.metadata).toEqual(expectedSkill.metadata) + const expectedResources = expectedSkill.resources?.filter((resource) => resource.path !== '.fixture-source.json') + expect(loadedSkill.resources).toEqual(expectedResources?.length ? expectedResources : undefined) + }) + + it('retries transient GitHub fetch failures with exponential backoff', async () => { + vi.useFakeTimers() + const skillMarkdown = ['---', 'name: retry-weather', 'description: Retry weather skill', '---', '', '# Retry'].join( + '\n', + ) + const fetch = vi + .fn() + .mockResolvedValueOnce(createResponse(500, { message: 'server error' })) + .mockResolvedValueOnce( + createResponse(200, [ + { + name: 'SKILL.md', + path: 'skills/weather/SKILL.md', + type: 'file', + size: skillMarkdown.length, + download_url: 'https://raw.githubusercontent.com/openclaw/openclaw/SKILL.md', + }, + ]), + ) + .mockResolvedValueOnce(createResponse(502, 'bad gateway')) + .mockResolvedValueOnce(createResponse(503, 'unavailable')) + .mockResolvedValueOnce(createResponse(200, skillMarkdown)) + + vi.stubGlobal('fetch', fetch) + + const job = loadSkill({ + source: 'github', + repo: 'openclaw/openclaw', + ref: '58672075219d09495de6489ad0821d276ac84f13', + path: 'skills/weather', + }) + + await vi.advanceTimersByTimeAsync(200 + 200 + 400) + + await expect(job).resolves.toMatchObject({ + name: 'retry-weather', + description: 'Retry weather skill', + instructions: expect.stringContaining('# Retry'), + }) + expect(fetch).toHaveBeenCalledTimes(5) + }) + + it('loads skill details with warnings', async () => { + const root = fileURLToPath(new URL('./.cache/weather', import.meta.url)) + const loadedSkill = await loadSkillWithDetails({ source: 'fs', root }) + + expect(loadedSkill.skill.name).toBe('weather') + expect(loadedSkill.warnings).toEqual([]) + }) +}) diff --git a/packages/kit/src/skills/test/memoryStorage.test.ts b/packages/kit/src/skills/test/memoryStorage.test.ts new file mode 100644 index 000000000..6101a2494 --- /dev/null +++ b/packages/kit/src/skills/test/memoryStorage.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { createMemorySkillStorage, importSkill } from '../storage' + +type TestFile = File & { + webkitRelativePath?: string +} + +const createTestFile = (path: string, content: string): TestFile => + ({ + name: path.split('/').at(-1) ?? path, + webkitRelativePath: path, + type: 'text/markdown', + size: content.length, + lastModified: 123, + text: async () => content, + arrayBuffer: async () => { + const bytes = new TextEncoder().encode(content) + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) + }, + }) as TestFile + +describe('MemorySkillStorage', () => { + it('add, get, has, delete, and list', async () => { + const storage = createMemorySkillStorage() + + const saved = await storage.add({ + name: 'demo', + description: 'Demo skill', + instructions: '# Demo', + }) + + expect(saved.name).toBe('demo') + expect(await storage.has('demo')).toBe(true) + expect(await storage.get('demo')).toMatchObject({ + name: 'demo', + description: 'Demo skill', + instructions: '# Demo', + }) + + const summaries = await storage.list() + expect(summaries).toEqual([ + { + name: 'demo', + description: 'Demo skill', + resourceCount: 0, + metadata: undefined, + }, + ]) + + expect(await storage.delete('demo')).toBe(true) + expect(await storage.has('demo')).toBe(false) + expect(await storage.get('demo')).toBeUndefined() + }) + + it('imports skill from browser source', async () => { + const storage = createMemorySkillStorage() + + const { name, skill, warnings } = await storage.import({ + source: 'browser', + fileList: [ + createTestFile( + 'weather/SKILL.md', + ['---', 'name: weather', 'description: Weather skill', '---', '', '# Weather Skill'].join('\n'), + ), + ], + }) + + expect(name).toBe('weather') + expect(skill.name).toBe('weather') + expect(skill.instructions).toContain('# Weather Skill') + expect(warnings).toEqual([]) + + const storedSkill = await storage.get('weather') + expect(storedSkill?.instructions).toContain('# Weather Skill') + expect(storedSkill?.resources?.some((resource) => resource.path === 'SKILL.md')).toBeFalsy() + }) + + it('imports multi-file skill with readable resources', async () => { + const storage = createMemorySkillStorage() + + await storage.import({ + source: 'browser', + fileList: [ + createTestFile( + 'vue-best-practices/SKILL.md', + [ + '---', + 'name: vue-best-practices', + 'description: Vue.js tasks', + '---', + '', + '# Vue Best Practices Workflow', + ].join('\n'), + ), + createTestFile('vue-best-practices/references/reactivity.md', '# Reactivity'), + ], + }) + + const skill = await storage.get('vue-best-practices') + const resource = skill?.resources?.find((item) => item.path === 'references/reactivity.md') + + expect(resource).toBeDefined() + await expect(resource?.readText?.()).resolves.toContain('# Reactivity') + }) + + it('supports cancel on import task', async () => { + let releaseWait!: () => void + const waitForText = new Promise((resolve) => { + releaseWait = () => resolve('# Cancelled') + }) + const task = importSkill({ + source: 'browser', + fileList: [ + { + ...createTestFile( + 'cancelled/SKILL.md', + ['---', 'name: cancelled', 'description: Cancelled skill', '---', '', '# Cancelled'].join('\n'), + ), + text: async () => waitForText, + }, + ], + }) + task.cancel() + releaseWait() + + await expect(task).rejects.toMatchObject({ + name: 'SkillLoadCancelledError', + }) + }) +}) diff --git a/packages/kit/src/skills/test/resourceCapability.test.ts b/packages/kit/src/skills/test/resourceCapability.test.ts new file mode 100644 index 000000000..471806f5f --- /dev/null +++ b/packages/kit/src/skills/test/resourceCapability.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest' +import { createSkillResourceRuntimeTools } from '../capabilities/resources' + +describe('skill resource tools', () => { + it('creates file runtime tools when skills have resources', () => { + const runtimeTools = createSkillResourceRuntimeTools([ + { + name: 'docs', + description: 'Docs skill', + instructions: 'Use docs.', + resources: [ + { + path: 'guide.md', + kind: 'text', + resourceId: 'guide.md', + text: '# 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( + createSkillResourceRuntimeTools([ + { name: 'plain', description: 'Plain skill', instructions: 'Use plain skill.' }, + ]), + ).toEqual([]) + }) + + it('lists and reads files through built-in runtime tools', async () => { + const [listFiles, readFile] = createSkillResourceRuntimeTools([ + { + name: 'docs', + description: 'Docs skill', + instructions: 'Use docs.', + resources: [ + { + path: 'guide.md', + kind: 'text', + resourceId: 'guide.md', + text: '# Guide', + mimeType: 'text/markdown', + }, + { + path: 'icon.png', + kind: 'binary', + resourceId: 'icon.png', + binary: 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( + await 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( + await 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] = createSkillResourceRuntimeTools([ + { + name: 'docs', + description: 'Docs skill', + instructions: 'Use docs.', + resources: [ + { + path: 'guide.md', + kind: 'text', + resourceId: 'guide.md', + text: '# Guide', + }, + ], + }, + { + name: 'vue', + description: 'Vue skill', + instructions: 'Use Vue.', + resources: [ + { + path: 'sfc.md', + kind: 'text', + resourceId: 'sfc.md', + text: '# 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', async () => { + const [, readFile] = createSkillResourceRuntimeTools([ + { + name: 'docs', + description: 'Docs skill', + instructions: 'Use docs.', + resources: [ + { + path: 'guide.md', + kind: 'text', + resourceId: 'guide.md', + text: '# Guide', + }, + ], + }, + ]) + + await expect(readFile.handler(createToolCallWithArguments('read_skill_file', '{'), {} as never)).resolves.toEqual({ + error: 'skill_not_found', + }) + await expect( + readFile.handler(createToolCall('read_skill_file', { skillName: 'docs' }), {} as never), + ).resolves.toEqual({ + error: 'file_path_required', + skillName: 'docs', + }) + await expect( + readFile.handler(createToolCall('read_skill_file', { skillName: 'docs', path: 'missing.md' }), {} as never), + ).resolves.toEqual({ + error: 'file_not_found', + skillName: 'docs', + path: 'missing.md', + }) + }) +}) + +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/skillPlugin.test.ts b/packages/kit/src/skills/test/skillPlugin.test.ts new file mode 100644 index 000000000..b3226abb7 --- /dev/null +++ b/packages/kit/src/skills/test/skillPlugin.test.ts @@ -0,0 +1,349 @@ +import type { ChatCompletion } from 'openai/resources' +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 { CreateMessageEngineOptions, MessageRequestBody, ResponseProvider } from '../../message/types' +import { mockResponseProvider } from '../../message/test/mockResponseProvider' +import type { SkillDefinition } from '../types' + +const silentDefaultPlugins = [thinkingPlugin({ disabled: true }), lengthPlugin({ disabled: true })] + +const createTestMessageEngine = (options: CreateMessageEngineOptions) => + createMessageEngine(createNativeMessageAdapter(), options) + +const weatherSkill: SkillDefinition = { + name: 'weather', + description: 'Weather skill', + instructions: 'Use wttr.in for weather requests.', +} + +describe('skillPlugin', () => { + it('uses manual skills for instructions and runtime tools', async () => { + const vueSkill: SkillDefinition = { + name: 'vue-best-practices', + description: 'Vue skill', + instructions: 'Follow Vue best practices.', + resources: [ + { + path: 'references/reactivity.md', + kind: 'text', + resourceId: 'references/reactivity.md', + text: '# Reactivity', + mimeType: 'text/markdown', + }, + ], + } + const responseProvider = vi.fn(async (requestBody: MessageRequestBody) => { + const hasToolResult = requestBody.messages.some((message) => message.role === 'tool') + + if (!hasToolResult) { + expect(requestBody.messages[0]).toMatchObject({ + role: 'system', + content: expect.stringContaining('Follow Vue best practices.'), + }) + expect(requestBody.tools?.map((tool) => tool.function.name)).toContain('read_skill_file') + + 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', + }, + ], + } as ChatCompletion + } + + 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', + }, + ], + } as ChatCompletion + }) + + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + skillPlugin({ + selection: { + mode: 'manual', + skillNames: [vueSkill.name], + }, + getSkillByName: async (name) => (name === vueSkill.name ? vueSkill : undefined), + }), + toolPlugin({ + getTools: async () => [], + callTool: async () => { + throw new Error('fallback should not run') + }, + }), + ], + responseProvider, + }) + + await engine.sendMessage('read skill file') + + expect(responseProvider).toHaveBeenCalledTimes(2) + expect(engine.getState().messages.at(-1)).toMatchObject({ + role: 'assistant', + content: 'done', + }) + }) + + it('appends skill instructions to an existing first system message', async () => { + const responseProvider = vi.fn(mockResponseProvider('ok')) + const engine = createTestMessageEngine({ + initialMessages: [ + { + role: 'system', + content: 'Existing system instructions.', + }, + ], + plugins: [ + ...silentDefaultPlugins, + skillPlugin({ + selection: { + mode: 'manual', + skillNames: [weatherSkill.name], + }, + getSkillByName: async (name) => (name === weatherSkill.name ? weatherSkill : undefined), + }), + ], + responseProvider, + }) + + await engine.sendMessage('weather in London') + + const requestBody = responseProvider.mock.calls[0]?.[0] + expect(requestBody.messages[0]).toMatchObject({ + role: 'system', + content: expect.stringContaining('Existing system instructions.'), + }) + expect(String(requestBody.messages[0].content)).toContain('Use wttr.in for weather requests.') + expect(requestBody.messages[1]).toMatchObject({ role: 'user', content: 'weather in London' }) + }) + + it('continues when resolving a selected manual skill fails', async () => { + const onSkillsResolved = vi.fn() + const responseProvider = vi.fn(mockResponseProvider('ok')) + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + skillPlugin({ + selection: { + mode: 'manual', + skillNames: ['broken-skill'], + }, + getSkillByName: async () => { + throw new Error('storage unavailable') + }, + onSkillsResolved, + }), + ], + responseProvider, + }) + + await engine.sendMessage('hello') + + const requestBody = responseProvider.mock.calls[0]?.[0] + expect(requestBody.messages[0]).toMatchObject({ role: 'user', content: 'hello' }) + expect(onSkillsResolved).toHaveBeenCalledWith( + expect.objectContaining({ + skills: [], + skillNames: [], + requestedSkillNames: ['broken-skill'], + unresolvedSkillNames: ['broken-skill'], + }), + expect.any(Object), + ) + }) + + it('selects auto skills before injecting selected skill instructions', async () => { + const getSkillByName = vi.fn(async (name: string) => (name === weatherSkill.name ? weatherSkill : undefined)) + const onSkillSelectionResolved = vi.fn() + const onSkillsResolved = vi.fn() + const responseProvider = vi.fn(async (requestBody: MessageRequestBody) => { + const hasSelectionResult = requestBody.messages.some( + (message) => message.role === 'tool' && String(message.content).includes('requestedSkillNames'), + ) + + if (!hasSelectionResult) { + expect(requestBody.messages[0]).toMatchObject({ + role: 'system', + content: expect.stringContaining('Preferred skill names: weather'), + }) + expect(String(requestBody.messages[0].content)).toContain('weather: Weather skill') + expect(String(requestBody.messages[0].content)).not.toContain('Use wttr.in for weather requests.') + expect(requestBody.tools?.map((tool) => tool.function.name)).toEqual(['select_skills']) + + return { + id: 'select-skill', + 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: 'select_skills', + arguments: JSON.stringify({ + skillNames: ['weather'], + }), + }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + } as ChatCompletion + } + + expect(requestBody.messages[0]).toMatchObject({ + role: 'system', + content: expect.stringContaining('Use wttr.in for weather requests.'), + }) + expect(requestBody.tools).toBeUndefined() + + 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, + skillPlugin({ + selection: { + mode: 'auto', + preferredSkillNames: ['weather'], + }, + getSkillCandidates: async () => [weatherSkill], + getSkillByName, + onSkillSelectionResolved, + onSkillsResolved, + }), + toolPlugin({ + getTools: async () => [], + callTool: async () => { + throw new Error('fallback should not run') + }, + }), + ], + responseProvider, + }) + + await engine.sendMessage('weather in London') + + expect(responseProvider).toHaveBeenCalledTimes(2) + expect(getSkillByName).toHaveBeenCalledWith('weather', expect.any(Object)) + expect(onSkillSelectionResolved).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'auto', + requestedSkillNames: ['weather'], + preferredSkillNames: ['weather'], + }), + expect.any(Object), + ) + expect(onSkillsResolved).toHaveBeenCalledWith( + expect.objectContaining({ + skills: [weatherSkill], + skillNames: ['weather'], + requestedSkillNames: ['weather'], + unresolvedSkillNames: [], + selection: expect.objectContaining({ + mode: 'auto', + phase: 'ready', + }), + }), + expect.any(Object), + ) + }) + + it('does not inject instructions or tools when selection is none', async () => { + const responseProvider = vi.fn(mockResponseProvider('ok')) + const engine = createTestMessageEngine({ + plugins: [ + ...silentDefaultPlugins, + skillPlugin({ + selection: { + mode: 'none', + }, + getSkillByName: async () => 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() + }) +}) diff --git a/packages/kit/src/skills/types/index.ts b/packages/kit/src/skills/types/index.ts new file mode 100644 index 000000000..476aae458 --- /dev/null +++ b/packages/kit/src/skills/types/index.ts @@ -0,0 +1,112 @@ +export type SkillFileKind = 'text' | 'binary' + +/** + * skill 文件基础描述。 + */ +interface SkillFileDescriptor { + /** + * skill 内相对路径。 + */ + path: string + /** + * 文件类型。 + */ + kind: SkillFileKind + /** + * 文件 MIME 类型。 + */ + mimeType?: string + /** + * 文件大小(字节)。 + */ + size?: number + /** + * 最后修改时间。 + */ + lastModified?: number + /** + * 自定义元数据。 + */ + metadata?: Record +} + +interface SkillResourceBase extends Omit { + /** + * 文件类型。 + */ + kind: K + /** 资源 ID,用于在 storage 内定位文件内容。 */ + resourceId: string +} + +type SkillTextResourceContent = + | { + /** 已加载的文本内容,适合内存中的完整 skill。 */ + text: string + /** 读取文本内容。 */ + readText?: () => Promise + } + | { + /** 已加载的文本内容,适合内存中的完整 skill。 */ + text?: string + /** 读取文本内容。 */ + readText: () => Promise + } + +type SkillBinaryResourceContent = + | { + /** 已加载的二进制内容,适合内存中的完整 skill。 */ + binary: Uint8Array + /** 读取二进制内容。 */ + readBinary?: () => Promise + } + | { + /** 已加载的二进制内容,适合内存中的完整 skill。 */ + binary?: Uint8Array + /** 读取二进制内容。 */ + readBinary: () => Promise + } + +/** skill 能力定义。 */ +export interface SkillDefinition { + /** + * 唯一 skill 名称。 + */ + name: string + /** + * skill 描述。 + */ + description: string + /** + * 注入模型的 instructions。 + */ + instructions: string + /** + * skill 资源描述。 + */ + resources?: SkillResourceDescriptor[] + /** + * 自定义 metadata。 + */ + metadata?: Record +} + +/** selection 阶段暴露给模型的 skill 候选项。 */ +export type SkillCandidate = Pick + +/** skill 资源文件描述;文本资源至少包含 text/readText 之一,二进制资源至少包含 binary/readBinary 之一。 */ +export type SkillResourceDescriptor = + | (SkillResourceBase<'text'> & + SkillTextResourceContent & { + /** 已加载的二进制内容,适合内存中的完整 skill。 */ + binary?: Uint8Array + /** 读取二进制内容。 */ + readBinary?: () => Promise + }) + | (SkillResourceBase<'binary'> & + SkillBinaryResourceContent & { + /** 已加载的文本内容,适合内存中的完整 skill。 */ + text?: string + /** 读取文本内容。 */ + readText?: () => Promise + }) 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/utils.ts b/packages/kit/src/utils.ts index 71a3ec805..b9dd1e26f 100644 --- a/packages/kit/src/utils.ts +++ b/packages/kit/src/utils.ts @@ -5,6 +5,14 @@ import type { ChatMessage, ChatCompletionResponse, ChatCompletionStreamResponse, StreamHandler } from './types' +export const getUniqueStringArray = (value: unknown) => { + if (!Array.isArray(value)) { + return undefined + } + + return [...new Set(value.filter((item): item is string => typeof item === 'string'))] +} + /** * 处理SSE流式响应 * @param response fetch响应对象 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..86d76e623 --- /dev/null +++ b/packages/kit/src/vue/message/plugins/skillPlugin.ts @@ -0,0 +1,179 @@ +import type { ComputedRef, Ref } from 'vue' +import { isRef, unref } from 'vue' +import type { SkillRequestContext, SkillSelection } from '../../../message/plugins' +import { skillPlugin as createCoreSkillPlugin } from '../../../message/plugins' +import type { BasePluginContext as CoreBasePluginContext } from '../../../message/types' +import type { SkillCandidate, SkillDefinition } from '../../../skills/types' +import type { MaybePromise } from '../../../types' +import type { BasePluginContext, UseMessagePlugin } from '../types' +import type { VueMessagePluginRuntime } from '../types.internal' + +type MaybeRef = T | Ref | ComputedRef + +export type UseMessageSkillPluginOptions = UseMessagePlugin & { + /** + * 当前 skill 选择模式,默认 manual。支持普通值、ref 或 computed。 + */ + mode?: MaybeRef<'manual' | 'auto' | 'none' | undefined> + /** + * skills 支持普通数组、ref 或 computed。 + * + * manual 模式下表示已经选中的完整 skills,不建议和 skillNames 同时传。 + * auto 模式下表示候选 skill 集合,同时作为默认 getSkillByName 来源,不建议和 getSkillCandidates / getSkillByName 同时传。 + * 传入 selection 时,skills 只作为默认候选集合和 getSkillByName 来源。 + */ + skills?: MaybeRef + /** + * manual 模式下最终启用的 skill names。支持普通数组、ref 或 computed。 + * + * 只用于 manual 模式。使用 skillNames 时需要提供 getSkillByName。 + * auto 模式请使用 preferredSkillNames。 + */ + skillNames?: MaybeRef + /** + * auto 模式下的 preferred skill names。支持普通数组、ref 或 computed。 + */ + preferredSkillNames?: MaybeRef + /** + * auto 模式下最多启用的 skill 数。支持普通值、ref 或 computed。 + */ + maxSelectedSkills?: MaybeRef + /** + * 高级入口,类型与 core skillPlugin 的 selection 一致。 + * + * 传入后会覆盖顶层 mode / skillNames / preferredSkillNames / maxSelectedSkills 配置。 + * 需要响应式时请使用 getter,在函数内读取 ref;静态配置可直接传 plain object。 + */ + selection?: SkillSelection | ((context: BasePluginContext) => MaybePromise) + /** + * auto 模式下提供候选摘要。不建议和 skills 同时传。 + */ + getSkillCandidates?: (context: BasePluginContext) => MaybePromise + /** + * 根据 name 解析完整 skill。 + * + * manual + skillNames、auto + getSkillCandidates 时需要提供。 + * 如果传了 skills 且没有传 getSkillByName,会默认从 skills 中按 name 查找。 + */ + getSkillByName?: (name: string, context: BasePluginContext) => MaybePromise + /** + * skills 解析并转换为请求上下文后触发。 + */ + onSkillsResolved?: (skillContext: SkillRequestContext, context: BasePluginContext) => MaybePromise + /** + * auto 模式下,模型通过 select_skills 工具选择 skill names 后触发。 + */ + onSkillSelectionResolved?: ( + event: { + mode: 'auto' + candidates: SkillCandidate[] + preferredSkillNames?: string[] + requestedSkillNames: string[] + }, + context: BasePluginContext, + ) => MaybePromise +} + +const resolveSkillSource = (source: MaybeRef) => { + return isRef(source) ? (unref(source) as SkillDefinition[] | undefined) : source +} + +const resolveRef = (source: MaybeRef | undefined): T | undefined => { + if (source === undefined) { + return undefined + } + + return isRef(source) ? (unref(source) as T) : source +} + +const resolveTopLevelSelection = (options: { + mode: MaybeRef<'manual' | 'auto' | 'none' | undefined> | undefined + skillNames: MaybeRef | undefined + skills: MaybeRef | undefined + preferredSkillNames: MaybeRef | undefined + maxSelectedSkills: MaybeRef | undefined +}): SkillSelection => { + const resolvedMode = resolveRef(options.mode) ?? 'manual' + + if (resolvedMode === 'manual') { + const resolvedSkillNames = resolveRef(options.skillNames) + + if (resolvedSkillNames !== undefined) { + return { + mode: 'manual', + skillNames: resolvedSkillNames, + } + } + + return { + mode: 'manual', + skills: resolveSkillSource(options.skills) ?? [], + } + } + + if (resolvedMode === 'auto') { + return { + mode: 'auto', + preferredSkillNames: resolveRef(options.preferredSkillNames), + maxSelectedSkills: resolveRef(options.maxSelectedSkills), + } + } + + return { + mode: 'none', + } +} + +export const skillPlugin = (options: UseMessageSkillPluginOptions): UseMessagePlugin => { + const { + selection, + mode, + skillNames, + preferredSkillNames, + maxSelectedSkills, + skills, + getSkillCandidates, + getSkillByName, + onSkillsResolved, + onSkillSelectionResolved, + ...restOptions + } = options + + return { + name: 'skill', + __corePluginFactory(runtime: VueMessagePluginRuntime) { + const toVueContext = (context: CoreBasePluginContext) => runtime.createVueBaseContext(context) + const resolveSelection = async (context: CoreBasePluginContext): Promise => { + if (selection) { + const vueContext = toVueContext(context) + return typeof selection === 'function' ? await selection(vueContext) : selection + } + + return resolveTopLevelSelection({ + mode, + skillNames, + skills, + preferredSkillNames, + maxSelectedSkills, + }) + } + + return createCoreSkillPlugin({ + ...runtime.createCorePlugin(restOptions), + selection: resolveSelection, + getSkillCandidates: getSkillCandidates + ? (context) => getSkillCandidates(toVueContext(context)) + : () => resolveSkillSource(skills) ?? [], + getSkillByName: getSkillByName + ? (name, context) => getSkillByName(name, toVueContext(context)) + : (name) => resolveSkillSource(skills)?.find((skill) => skill.name === name), + onSkillsResolved: onSkillsResolved + ? (skillContext, context) => onSkillsResolved(skillContext, toVueContext(context)) + : undefined, + onSkillSelectionResolved: onSkillSelectionResolved + ? (event, context) => onSkillSelectionResolved(event, toVueContext(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..f8699a432 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,133 @@ describe('useMessage', () => { content: 'done', }) }) + + it('uses vue skillPlugin with reactive skills', async () => { + const skills = ref([ + { + name: 'docs', + description: 'Docs skill', + instructions: 'Use docs references.', + resources: [ + { + path: 'guide.md', + kind: 'text', + resourceId: 'guide.md', + text: '# 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']) + }) + + it('uses reactive manual vue skillPlugin skillNames', async () => { + const mode = ref<'manual'>('manual') + const skillNames = ref(['docs']) + const skills: SkillDefinition[] = [ + { + name: 'docs', + description: 'Docs skill', + instructions: 'Use docs references.', + }, + ] + const responseProvider = vi.fn(mockResponseProvider('ok')) + + const engine = useMessage({ + responseProvider, + plugins: [ + skillPlugin({ + mode, + skillNames, + getSkillByName: async (name) => skills.find((skill) => skill.name === name), + }), + ], + }) + + await engine.sendMessage('read docs') + + expect(responseProvider.mock.calls[0]?.[0].messages[0]).toMatchObject({ + role: 'system', + content: expect.stringContaining('Use docs references.'), + }) + }) + + it('uses core-compatible vue skillPlugin selection with inline skills', async () => { + const responseProvider = vi.fn(mockResponseProvider('ok')) + + const engine = useMessage({ + responseProvider, + plugins: [ + skillPlugin({ + selection: { + mode: 'manual', + skills: [ + { + name: 'docs', + description: 'Docs skill', + instructions: 'Use docs references.', + }, + ], + }, + }), + ], + }) + + await engine.sendMessage('read docs') + + expect(responseProvider.mock.calls[0]?.[0].messages[0]).toMatchObject({ + role: 'system', + content: expect.stringContaining('Use docs references.'), + }) + }) + + it('uses reactive preferred skill names in auto mode', async () => { + const preferredSkillNames = ref(['docs']) + const responseProvider = vi.fn((requestBody) => { + expect(requestBody.messages[0]).toMatchObject({ + role: 'system', + content: expect.stringContaining('Preferred skill names: docs'), + }) + return mockResponseProvider('ok')(requestBody) + }) + + const engine = useMessage({ + responseProvider, + plugins: [ + skillPlugin({ + mode: 'auto', + preferredSkillNames, + skills: [ + { + name: 'docs', + description: 'Docs skill', + instructions: 'Use docs references.', + }, + ], + }), + ], + }) + + await engine.sendMessage('read docs') + }) })