Skip to content

Commit eda3c17

Browse files
committed
feat(skillPlugin): enhance skill loading and selection capabilities with new utility functions and type definitions
1 parent 39ec07c commit eda3c17

7 files changed

Lines changed: 239 additions & 10 deletions

File tree

‎packages/kit/src/index.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,12 @@ export * from './storage'
55
export * from './types'
66
export { extractTextFromResponse, formatMessages, handleSSEStream, sseStreamToGenerator } from './utils'
77
export * from './vue'
8+
export { loadSkill, loadSkillWithDetails } from './skills/loader'
9+
export type {
10+
BrowserSkillLoadOptions,
11+
GithubSkillLoadOptions,
12+
SkillLoadJob,
13+
SkillLoadOptions,
14+
SkillLoadResult,
15+
} from './skills/loader'
16+
export type { SkillCandidate, SkillDefinition } from './skills/types'

‎packages/kit/src/message/plugins/skillPlugin.ts‎

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,15 @@ interface SkillPluginHooks extends MessageEnginePlugin {
146146

147147
type SelectionInput<T extends SkillSelection> = T | ((context: BasePluginContext) => MaybePromise<T>)
148148

149-
export type SkillPluginOptions<S extends SkillSelection = SkillSelection> = SkillPluginHooks &
150-
RequireResolver<S> &
151-
RequireCandidateProvider<S> & {
152-
selection: SelectionInput<S>
153-
}
149+
type SkillPluginOptionsFor<S extends SkillSelection> = S extends SkillSelection
150+
? SkillPluginHooks &
151+
RequireResolver<S> &
152+
RequireCandidateProvider<S> & {
153+
selection: SelectionInput<S>
154+
}
155+
: never
156+
157+
export type SkillPluginOptions<S extends SkillSelection = SkillSelection> = SkillPluginOptionsFor<S>
154158

155159
const skillPluginContextKey = '__tiny_robot_skill'
156160

@@ -186,6 +190,18 @@ const createResolvedSkillInstructions = (skills: SkillDefinition[]): string[] =>
186190
return instructions
187191
}
188192

193+
const normalizeSkills = (skills: SkillDefinition[]) => {
194+
const skillMap = new Map<string, SkillDefinition>()
195+
196+
for (const skill of skills) {
197+
if (!skillMap.has(skill.name)) {
198+
skillMap.set(skill.name, skill)
199+
}
200+
}
201+
202+
return [...skillMap.values()]
203+
}
204+
189205
const normalizeCandidates = (candidates: SkillCandidate[]) => {
190206
const candidateMap = new Map<string, SkillCandidate>()
191207

@@ -213,6 +229,16 @@ const setSkillContext = (context: BasePluginContext, skillContext: SkillRequestC
213229
context.setCustomContext({ [skillPluginContextKey]: skillContext })
214230
}
215231

232+
const hasEnabledToolPlugin = (context: BasePluginContext) => {
233+
return context.plugins.some((plugin) => {
234+
if (plugin.name !== 'tool') {
235+
return false
236+
}
237+
238+
return typeof plugin.disabled === 'function' ? !plugin.disabled(context) : !plugin.disabled
239+
})
240+
}
241+
216242
const resolveSkillsByNames = async (
217243
skillNames: string[],
218244
getSkillByName: SkillResolver['getSkillByName'],
@@ -228,11 +254,23 @@ const resolveSkillsByNames = async (
228254
}),
229255
)
230256

231-
const skills = results.map((result) => result.skill).filter((skill): skill is SkillDefinition => Boolean(skill))
257+
const skillMap = new Map<string, SkillDefinition>()
258+
const unresolvedSkillNames: string[] = []
259+
260+
for (const result of results) {
261+
if (result.failed || !result.skill || result.skill.name !== result.name) {
262+
unresolvedSkillNames.push(result.name)
263+
continue
264+
}
265+
266+
if (!skillMap.has(result.skill.name)) {
267+
skillMap.set(result.skill.name, result.skill)
268+
}
269+
}
232270

233271
return {
234-
skills,
235-
unresolvedSkillNames: results.filter((result) => result.failed || !result.skill).map((result) => result.name),
272+
skills: [...skillMap.values()],
273+
unresolvedSkillNames,
236274
}
237275
}
238276

@@ -346,7 +384,7 @@ export const skillPlugin = <S extends SkillSelection = SkillSelection>(
346384
let unresolvedSkillNames: string[]
347385

348386
if (selectionOptions.skills) {
349-
skills = selectionOptions.skills
387+
skills = normalizeSkills(selectionOptions.skills)
350388
requestedSkillNames = skills.map((skill) => skill.name)
351389
unresolvedSkillNames = []
352390
} else {
@@ -384,6 +422,10 @@ export const skillPlugin = <S extends SkillSelection = SkillSelection>(
384422
}
385423

386424
// mode: 'auto'
425+
if (!hasEnabledToolPlugin(context)) {
426+
throw new Error('skillPlugin auto mode requires an enabled toolPlugin')
427+
}
428+
387429
const getCandidates = getSkillCandidates
388430
if (!getCandidates) {
389431
throw new Error('getSkillCandidates is required when auto mode is enabled')

‎packages/kit/src/node.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export type {
66
SkillLoadOptions,
77
SkillLoadResult,
88
} from './skills/loader/node'
9+
export type { SkillCandidate, SkillDefinition } from './skills/types'

‎packages/kit/src/skills/capabilities/selection.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import { parseToolArguments } from './utils'
66

77
const skillSelectionToolName = 'select_skills'
88

9+
const normalizeMaxSelectedSkills = (value: number | undefined, candidateCount: number) => {
10+
const integerValue = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : candidateCount
11+
12+
return Math.min(candidateCount, Math.max(0, integerValue))
13+
}
14+
915
export const createSkillSelectionInstructions = ({
1016
candidates,
1117
preferredSkillNames,
@@ -50,7 +56,8 @@ export function createSkillSelectionRuntimeTools(
5056

5157
const candidateNames = candidates.map((candidate) => candidate.name)
5258
const candidateNameSet = new Set(candidateNames)
53-
const maxSelectedSkills = Math.max(0, options.maxSelectedSkills ?? candidateNames.length)
59+
const maxSelectedSkills = normalizeMaxSelectedSkills(options.maxSelectedSkills, candidateNames.length)
60+
let selectionStarted = false
5461

5562
return [
5663
{
@@ -105,6 +112,14 @@ export function createSkillSelectionRuntimeTools(
105112
}
106113
}
107114

115+
if (selectionStarted) {
116+
return {
117+
error: 'skill_selection_already_resolved',
118+
}
119+
}
120+
121+
selectionStarted = true
122+
108123
const result = {
109124
requestedSkillNames,
110125
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { loadSkill as loadBrowserSkillFromRoot } from '../../index'
3+
import { loadSkill as loadNodeSkillFromEntry } from '../../node'
4+
import { loadSkill as loadBrowserSkill } from '../loader'
5+
import { loadSkill as loadNodeSkill } from '../loader/node'
6+
7+
describe('skill public exports', () => {
8+
it('exports environment-specific loaders from the root and node entrypoints', () => {
9+
expect(loadBrowserSkillFromRoot).toBe(loadBrowserSkill)
10+
expect(loadNodeSkillFromEntry).toBe(loadNodeSkill)
11+
})
12+
})
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { createSkillSelectionRuntimeTools } from '../capabilities/selection'
3+
4+
const candidates = [{ name: 'weather', description: 'Weather skill' }]
5+
6+
describe('skill selection capability', () => {
7+
it.each([
8+
[-1, 0],
9+
[0, 0],
10+
[0.9, 0],
11+
[1.9, 1],
12+
[2, 1],
13+
[Number.NaN, 1],
14+
[Number.POSITIVE_INFINITY, 1],
15+
])('normalizes maxSelectedSkills value %s to %s', (maxSelectedSkills, expectedMaxSelectedSkills) => {
16+
const [runtimeTool] = createSkillSelectionRuntimeTools(candidates, { maxSelectedSkills })
17+
const parameters = runtimeTool.tool.function.parameters as {
18+
properties: { skillNames: { maxItems: number } }
19+
}
20+
21+
expect(parameters.properties.skillNames.maxItems).toBe(expectedMaxSelectedSkills)
22+
})
23+
24+
it('resolves only the first concurrent valid select_skills call', async () => {
25+
let releaseSelection: (() => void) | undefined
26+
const selectionBlocked = new Promise<void>((resolve) => {
27+
releaseSelection = resolve
28+
})
29+
const resolveSelection = vi.fn(async () => {
30+
await selectionBlocked
31+
return { enabledSkillNames: ['weather'] }
32+
})
33+
const [runtimeTool] = createSkillSelectionRuntimeTools(candidates, { resolveSelection })
34+
35+
const firstResult = runtimeTool.handler(createToolCall(['weather'], 'call-1'), {} as never)
36+
const secondResult = runtimeTool.handler(createToolCall(['weather'], 'call-2'), {} as never)
37+
38+
await Promise.resolve()
39+
expect(resolveSelection).toHaveBeenCalledTimes(1)
40+
41+
releaseSelection?.()
42+
await expect(firstResult).resolves.toMatchObject({ enabledSkillNames: ['weather'] })
43+
await expect(secondResult).resolves.toEqual({ error: 'skill_selection_already_resolved' })
44+
})
45+
})
46+
47+
const createToolCall = (skillNames: string[], id: string) => ({
48+
id,
49+
type: 'function' as const,
50+
function: {
51+
name: 'select_skills',
52+
arguments: JSON.stringify({ skillNames }),
53+
},
54+
})

‎packages/kit/src/skills/test/skillPlugin.test.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
33
import { createNativeMessageAdapter } from '../../message/adapters/native'
44
import { createMessageEngine } from '../../message/core/engine'
55
import { getSkillRequestContext, lengthPlugin, skillPlugin, thinkingPlugin, toolPlugin } from '../../message/plugins'
6+
import type { SkillPluginOptions } from '../../message/plugins'
67
import type { CreateMessageEngineOptions, MessageRequestBody, ResponseProvider } from '../../message/types'
78
import { mockResponseProvider } from '../../message/test/mockResponseProvider'
89
import type { SkillDefinition } from '../types'
@@ -18,7 +19,25 @@ const weatherSkill: SkillDefinition = {
1819
instructions: 'Use wttr.in for weather requests.',
1920
}
2021

22+
const skillPluginOptionTypeChecks = [
23+
{ selection: { mode: 'none' } } satisfies SkillPluginOptions,
24+
{ selection: { mode: 'manual', skills: [weatherSkill] } } satisfies SkillPluginOptions,
25+
{
26+
selection: { mode: 'manual', skillNames: ['weather'] },
27+
getSkillByName: async () => weatherSkill,
28+
} satisfies SkillPluginOptions,
29+
{
30+
selection: { mode: 'auto' },
31+
getSkillCandidates: async () => [weatherSkill],
32+
getSkillByName: async () => weatherSkill,
33+
} satisfies SkillPluginOptions,
34+
]
35+
2136
describe('skillPlugin', () => {
37+
it('accepts selection-specific default option types', () => {
38+
expect(skillPluginOptionTypeChecks).toHaveLength(4)
39+
})
40+
2241
it('uses manual skills for instructions and runtime tools', async () => {
2342
const vueSkill: SkillDefinition = {
2443
name: 'vue-best-practices',
@@ -274,6 +293,63 @@ describe('skillPlugin', () => {
274293
)
275294
})
276295

296+
it('rejects mismatched resolver results and deduplicates enabled skills', async () => {
297+
const onSkillsResolved = vi.fn()
298+
const engine = createTestMessageEngine({
299+
plugins: [
300+
...silentDefaultPlugins,
301+
skillPlugin({
302+
selection: {
303+
mode: 'manual',
304+
skillNames: ['weather', 'weather-alias'],
305+
},
306+
getSkillByName: async () => weatherSkill,
307+
onSkillsResolved,
308+
}),
309+
],
310+
responseProvider: mockResponseProvider('ok'),
311+
})
312+
313+
await engine.sendMessage('weather in London')
314+
315+
expect(onSkillsResolved).toHaveBeenCalledWith(
316+
expect.objectContaining({
317+
skills: [weatherSkill],
318+
skillNames: ['weather'],
319+
requestedSkillNames: ['weather', 'weather-alias'],
320+
unresolvedSkillNames: ['weather-alias'],
321+
}),
322+
expect.any(Object),
323+
)
324+
})
325+
326+
it('deduplicates inline manual skills by name', async () => {
327+
const onSkillsResolved = vi.fn()
328+
const engine = createTestMessageEngine({
329+
plugins: [
330+
...silentDefaultPlugins,
331+
skillPlugin({
332+
selection: {
333+
mode: 'manual',
334+
skills: [weatherSkill, weatherSkill],
335+
},
336+
onSkillsResolved,
337+
}),
338+
],
339+
responseProvider: mockResponseProvider('ok'),
340+
})
341+
342+
await engine.sendMessage('weather in London')
343+
344+
expect(onSkillsResolved).toHaveBeenCalledWith(
345+
expect.objectContaining({
346+
skills: [weatherSkill],
347+
skillNames: ['weather'],
348+
}),
349+
expect.any(Object),
350+
)
351+
})
352+
277353
it('selects auto skills before injecting selected skill instructions', async () => {
278354
const instructionContents: string[] = []
279355
const getSkillByName = vi.fn(async (name: string) => (name === weatherSkill.name ? weatherSkill : undefined))
@@ -410,6 +486,26 @@ describe('skillPlugin', () => {
410486
)
411487
})
412488

489+
it('fails before requesting when auto mode is missing toolPlugin', async () => {
490+
const responseProvider = vi.fn(mockResponseProvider('unexpected'))
491+
const engine = createTestMessageEngine({
492+
plugins: [
493+
...silentDefaultPlugins,
494+
skillPlugin({
495+
selection: { mode: 'auto' },
496+
getSkillCandidates: async () => [weatherSkill],
497+
getSkillByName: async () => weatherSkill,
498+
}),
499+
],
500+
responseProvider,
501+
})
502+
503+
await expect(engine.sendMessage('weather in London')).rejects.toThrow(
504+
'skillPlugin auto mode requires an enabled toolPlugin',
505+
)
506+
expect(responseProvider).not.toHaveBeenCalled()
507+
})
508+
413509
it('does not inject instructions or tools when selection is none', async () => {
414510
const responseProvider = vi.fn(mockResponseProvider('ok'))
415511
const onInstructionsResolved = vi.fn()

0 commit comments

Comments
 (0)