feat: improve skill import and runtime delivery

This commit is contained in:
lofyer
2026-08-07 21:01:17 +08:00
parent 954b42ef55
commit 417a9fccb6
63 changed files with 6136 additions and 527 deletions
+39
View File
@@ -225,6 +225,45 @@ describe('ContinueAgentRuntime', () => {
expect(prompt).toContain('test')
})
it('keeps a full bundled Skill payload on every platform', async () => {
const runtime = new ContinueAgentRuntime({
binaryPath: '',
configPath: 'C:\\safe config\\continue.yaml',
defaultWorkspace: process.cwd(),
hostCacheRoot: 'C:\\safe\\continue-host',
skillInstructions: `# Skills\n${'技'.repeat(30_000)}`.slice(0, 30_000),
createHostAdapter: () => ({
getPreparedHost: mocks.prepareHost,
run: mocks.runHost,
dispose: mocks.disposeHost
})
})
await collectEvents(runtime)
const prompt = String(mocks.runHost.mock.calls[0]?.[0])
expect(prompt).toContain('SYSTEM CAPABILITY INSTRUCTIONS')
expect(prompt.length).toBeGreaterThan(24_000)
})
it('reports oversized Skill payloads instead of dropping them silently', async () => {
const runtime = new ContinueAgentRuntime({
binaryPath: '',
configPath: 'C:\\safe config\\continue.yaml',
defaultWorkspace: process.cwd(),
hostCacheRoot: 'C:\\safe\\continue-host',
skillInstructions: '巨'.repeat(130_000),
createHostAdapter: () => ({
getPreparedHost: mocks.prepareHost,
run: mocks.runHost,
dispose: mocks.disposeHost
})
})
await expect(collectEvents(runtime)).rejects.toThrow('超过 Continue')
expect(mocks.runHost).not.toHaveBeenCalled()
})
it('blocks anonymous platform fallback without an explicit model configuration', async () => {
const runtime = new ContinueAgentRuntime({
binaryPath: '',
+15 -7
View File
@@ -43,8 +43,9 @@ export type ContinueRuntimeOptions = {
>
}
const MAX_CONTINUE_PROMPT_CHARACTERS =
process.platform === 'win32' ? 24_000 : 128_000
// The prompt reaches the Continue host through a local HTTP POST body, so no
// platform command-line limit applies to it.
const MAX_CONTINUE_PROMPT_CHARACTERS = 128_000
function continueToolFailureMessage(tool: ContinueHostTool): string {
const callId = tool.callId.slice(0, 128)
@@ -260,12 +261,19 @@ export class ContinueAgentRuntime implements AgentRuntime {
'CURRENT CONVERSATION:'
].join('\n')
: ''
const conversationContext =
if (
skillPrefix &&
skillPrefix.length + prompt.length <=
MAX_CONTINUE_PROMPT_CHARACTERS
? `${skillPrefix}\n${prompt}`
: prompt
skillPrefix.length + prompt.length > MAX_CONTINUE_PROMPT_CHARACTERS
) {
throw new Error(
`已启用的 Skill 说明与当前请求合计 ${(
skillPrefix.length + prompt.length
).toLocaleString()} 字符,超过 Continue ${MAX_CONTINUE_PROMPT_CHARACTERS.toLocaleString()} 字符上限。请在设置中减少分配给 Continue 的 Skill。`
)
}
const conversationContext = skillPrefix
? `${skillPrefix}\n${prompt}`
: prompt
const detection = await this.getDetection()
signal.throwIfAborted()
if (!detection.available || !detection.path) {
@@ -0,0 +1,79 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
CapabilityService,
type CapabilityCipher
} from './capability-service'
import {
BrowserProfileService,
MemoryBrowserProfileStore
} from './browser-profile-service'
const builtinSkillsRoot = join(
process.cwd(),
'resources',
'skills'
)
const cipher: CapabilityCipher = {
isAvailable: () => true,
encrypt: (value) => Buffer.from(`encrypted:${value}`),
decrypt: (value) => value.toString().replace(/^encrypted:/u, '')
}
const temporaryDirectories: string[] = []
async function createService(): Promise<CapabilityService> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-builtin-'))
temporaryDirectories.push(directory)
return new CapabilityService(
join(directory, 'capabilities.json'),
builtinSkillsRoot,
join(directory, 'imported'),
cipher,
{
platform: 'win32',
architecture: 'x64',
electronTarget: true,
browserProfiles: new BrowserProfileService(
new MemoryBrowserProfileStore()
)
}
)
}
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true }))
)
})
describe('bundled skills', () => {
it('parses every bundled SKILL.md', async () => {
const snapshot = await (await createService()).getSnapshot()
expect(snapshot.skills.length).toBeGreaterThan(0)
expect(snapshot.skills.every((skill) => skill.source === 'builtin')).toBe(
true
)
expect(snapshot.skills.map((skill) => skill.id)).toContain(
'product-marketing'
)
})
it('injects every enabled bundled skill with its resolved directory', async () => {
const service = await createService()
const snapshot = await service.getSnapshot()
const instructions = await service.getSkillInstructions('continue')
expect(instructions).not.toContain('因超出注入上限未加载')
for (const skill of snapshot.skills) {
expect(instructions).toContain(join(builtinSkillsRoot, skill.id))
}
})
})
@@ -266,6 +266,77 @@ describe('CapabilityService', () => {
).rejects.toThrow('只能删除已导入')
})
it('imports a standard SKILL.md that identifies itself by name', async () => {
const { directory, service } = await createService()
const source = join(directory, 'standard-source', 'summarize-diff')
await mkdir(source, { recursive: true })
await writeFile(
join(source, 'SKILL.md'),
[
'---',
'name: summarize-diff',
'description: |',
' 概括暂存的改动。',
' 当用户需要待提交变更摘要时使用。',
'allowed-tools:',
' - Read',
' - Grep',
'compatibility: droid',
'---',
'',
'# Summarize Diff',
'',
'仅用于离线测试。'
].join('\n'),
'utf8'
)
const imported = await service.importSkill(source)
expect(imported.skills).toContainEqual(
expect.objectContaining({
id: 'summarize-diff',
source: 'imported',
description: '概括暂存的改动。 当用户需要待提交变更摘要时使用。'
})
)
})
it('imports every Skill found under a suite directory', async () => {
const { directory, service } = await createService()
const suite = join(directory, 'suite', 'skills')
await writeSkill(suite, 'alpha-skill', 'Alpha')
await writeSkill(suite, 'beta-skill', 'Beta')
const imported = await service.importSkill(join(directory, 'suite'))
expect(imported.skills.map((skill) => skill.id)).toEqual(
expect.arrayContaining(['alpha-skill', 'beta-skill'])
)
})
it('reports a readable error when the selected directory has no SKILL.md', async () => {
const { directory, service } = await createService()
const empty = join(directory, 'empty-directory')
await mkdir(empty, { recursive: true })
await expect(service.importSkill(empty)).rejects.toThrow(
'没有找到 SKILL.md'
)
})
it('exposes the skill directory and names skills dropped by the budget', async () => {
const { builtinRoot, service } = await createService()
await writeSkill(builtinRoot, 'oversized-skill', '超长技能')
const instructions = await service.getSkillInstructions('model')
expect(instructions).toContain(join(builtinRoot, 'document-writing'))
expect(instructions).toContain(join(builtinRoot, 'oversized-skill'))
const truncated = await service.getSkillInstructions('model', 200)
expect(truncated).toContain('因超出注入上限未加载')
})
it('imports a managed Skill from a ZIP package', async () => {
const { directory, importedRoot, service } = await createService()
const packageRoot = join(directory, 'zip-source')
+205 -64
View File
@@ -56,16 +56,31 @@ const MAX_SKILL_FILE_BYTES = 2 * 1024 * 1024
const MAX_SKILL_PACKAGE_BYTES = 10 * 1024 * 1024
const MAX_SKILL_PACKAGE_FILES = 128
const MAX_SKILL_DEPTH = 6
const MAX_SKILL_DISCOVERY_DEPTH = 4
const MAX_SKILL_DISCOVERY_RESULTS = 64
const MAX_SKILL_INSTRUCTION_CHARACTERS = 262_144
const SKILL_DISCOVERY_IGNORED_DIRECTORIES = new Set([
'node_modules',
'__pycache__',
'__MACOSX'
])
const skillMetadataSchema = z
.object({
id: skillIdSchema,
name: z.string().trim().min(1).max(80),
description: z.string().trim().min(1).max(500),
version: z.string().trim().min(1).max(32).optional(),
tags: z.array(z.string().trim().min(1).max(32)).max(12).default([])
})
.strict()
// Block scalars in SKILL.md frontmatter carry newlines that would break the
// single-line summary surfaces the renderer and runtimes rely on.
function collapsedText(maximum: number): z.ZodType<string> {
return z
.string()
.transform((value) => value.replace(/\s+/gu, ' ').trim())
.pipe(z.string().min(1).max(maximum))
}
const skillMetadataSchema = z.object({
id: skillIdSchema.optional(),
name: collapsedText(80),
description: collapsedText(500),
version: collapsedText(32).optional(),
tags: z.array(collapsedText(32)).max(12).default([])
})
const skillStateSchema = z
.object({
@@ -221,13 +236,22 @@ async function readSkill(
throw new Error(`${basename(directoryPath)} 的 SKILL.md 格式无效`)
}
const metadata = skillMetadataSchema.parse(parseYaml(match[1]))
if (expectedId !== null && metadata.id !== expectedId) {
throw new Error(`Skill ID 必须与目录名一致:${metadata.id}`)
// Standard SKILL.md files identify the skill by `name`; GoodBuddy packages
// add an explicit `id` alongside a human-readable `name`.
const identifier = skillIdSchema.safeParse(metadata.id ?? metadata.name)
if (!identifier.success) {
throw new Error(
`${basename(directoryPath)} 的 SKILL.md 缺少可用的 Skill ID,请提供小写连字符格式的 id 或 name`
)
}
if (expectedId !== null && identifier.data !== expectedId) {
throw new Error(`Skill ID 必须与目录名一致:${identifier.data}`)
}
return skillSummarySchema
.omit({ enabled: true, assignments: true })
.parse({
...metadata,
id: identifier.data,
source,
digest: createHash('sha256').update(content).digest('hex')
})
@@ -261,6 +285,55 @@ async function listSkills(
)
}
async function pathExists(candidate: string): Promise<boolean> {
return stat(candidate)
.then(() => true)
.catch(() => false)
}
// Mirrors the conventional layout where the first directory containing
// SKILL.md is the skill root, so users can pick a suite directory that holds
// many skills instead of one package at a time.
async function discoverSkillDirectories(root: string): Promise<string[]> {
if (await pathExists(join(root, 'SKILL.md'))) {
return [root]
}
const found: string[] = []
const walk = async (current: string, depth: number): Promise<void> => {
if (depth > MAX_SKILL_DISCOVERY_DEPTH || found.length > MAX_SKILL_DISCOVERY_RESULTS) {
return
}
let entries
try {
entries = await readdir(current, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (
!entry.isDirectory() ||
entry.name.startsWith('.') ||
SKILL_DISCOVERY_IGNORED_DIRECTORIES.has(entry.name)
) {
continue
}
const child = join(current, entry.name)
if (await pathExists(join(child, 'SKILL.md'))) {
found.push(child)
continue
}
await walk(child, depth + 1)
}
}
await walk(root, 0)
if (found.length > MAX_SKILL_DISCOVERY_RESULTS) {
throw new Error(
`所选目录包含的 Skill 超过 ${MAX_SKILL_DISCOVERY_RESULTS} 个,请选择更精确的目录`
)
}
return found.sort((left, right) => left.localeCompare(right))
}
async function copySkillPackage(
sourceRoot: string,
targetRoot: string
@@ -943,6 +1016,46 @@ export class CapabilityService {
})
}
private async importSkillDirectory(
sourceDirectory: string,
expectedId: string | null | undefined
): Promise<string> {
const temporaryPath = join(
this.importedSkillsRoot,
`.import-${randomUUID()}`
)
try {
const skill = await readSkill(
sourceDirectory,
'imported',
expectedId
)
const builtins = await listSkills(this.builtinSkillsRoot, 'builtin')
if (builtins.some((item) => item.id === skill.id)) {
throw new Error('导入的 Skill ID 与内置 Skill 冲突')
}
const targetPath = join(this.importedSkillsRoot, skill.id)
if (await pathExists(targetPath)) {
throw new Error('同名 Skill 已导入,请先删除后重试')
}
await copySkillPackage(sourceDirectory, temporaryPath)
await readSkill(temporaryPath, 'imported', skill.id)
await rename(temporaryPath, targetPath)
const state = await this.load()
await this.persist({
...state,
skills: {
...state.skills,
[skill.id]: defaultSkillState()
}
})
return skill.id
} catch (error) {
await rm(temporaryPath, { recursive: true, force: true })
throw error
}
}
importSkill(sourcePath: string): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const canonicalSource = await realpath(sourcePath)
@@ -955,52 +1068,61 @@ export class CapabilityService {
throw new Error('所选 Skill 路径必须是目录或 .zip 文件')
}
await mkdir(this.importedSkillsRoot, { recursive: true })
const temporaryPath = join(
this.importedSkillsRoot,
`.import-${randomUUID()}`
)
try {
const archiveDirectoryName = isZip
? await extractSkillZip(canonicalSource, temporaryPath)
: undefined
const skill = await readSkill(
isDirectory ? canonicalSource : temporaryPath,
'imported',
isDirectory ? undefined : (archiveDirectoryName ?? null)
if (isZip) {
const extractPath = join(
this.importedSkillsRoot,
`.extract-${randomUUID()}`
)
const builtins = await listSkills(
this.builtinSkillsRoot,
'builtin'
)
if (builtins.some((item) => item.id === skill.id)) {
throw new Error('导入的 Skill ID 与内置 Skill 冲突')
try {
const archiveDirectoryName = await extractSkillZip(
canonicalSource,
extractPath
)
await this.importSkillDirectory(
extractPath,
archiveDirectoryName ?? null
)
} finally {
await rm(extractPath, { recursive: true, force: true })
}
const targetPath = join(this.importedSkillsRoot, skill.id)
if (
await stat(targetPath)
.then(() => true)
.catch(() => false)
) {
throw new Error('同名 Skill 已导入,请先删除后重试')
}
if (isDirectory) {
await copySkillPackage(canonicalSource, temporaryPath)
}
await readSkill(temporaryPath, 'imported', skill.id)
await rename(temporaryPath, targetPath)
const state = await this.load()
await this.persist({
...state,
skills: {
...state.skills,
[skill.id]: defaultSkillState()
}
})
return this.getSnapshot()
} catch (error) {
await rm(temporaryPath, { recursive: true, force: true })
throw error
}
const directories = await discoverSkillDirectories(canonicalSource)
if (directories.length === 0) {
throw new Error(
'所选目录及其子目录中没有找到 SKILL.md,请选择 Skill 目录或包含多个 Skill 的目录'
)
}
const failures: string[] = []
let importedCount = 0
for (const directory of directories) {
try {
// A suite directory may nest skills below its own name, so the
// directory name is only authoritative for a single-skill import.
await this.importSkillDirectory(
directory,
directories.length === 1 ? undefined : null
)
importedCount += 1
} catch (error) {
failures.push(
`${basename(directory)}${
error instanceof Error ? error.message : '导入失败'
}`
)
}
}
if (importedCount === 0) {
throw new Error(`Skill 导入失败。${failures.join('')}`)
}
if (failures.length > 0) {
throw new Error(
`已导入 ${importedCount} 个 Skill${failures.length} 个失败。${failures.join('')}`
)
}
return this.getSnapshot()
})
}
@@ -1202,10 +1324,15 @@ export class CapabilityService {
async getSkillInstructions(
target: RuntimeTarget,
maximumCharacters: number
maximumCharacters: number = MAX_SKILL_INSTRUCTION_CHARACTERS
): Promise<string> {
const budget = Math.min(
maximumCharacters,
MAX_SKILL_INSTRUCTION_CHARACTERS
)
const snapshot = await this.getSnapshot()
const sections: string[] = []
const skipped: string[] = []
let length = 0
for (const skill of snapshot.skills) {
if (!skill.enabled || !skill.assignments.includes(target)) {
@@ -1215,24 +1342,38 @@ export class CapabilityService {
skill.source === 'builtin'
? this.builtinSkillsRoot
: this.importedSkillsRoot
const content = await readFile(join(root, skill.id, 'SKILL.md'), 'utf8')
const directory = join(root, skill.id)
const content = await readFile(join(directory, 'SKILL.md'), 'utf8')
const body =
/^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]+)$/u.exec(content)?.[1]?.trim() ??
''
const section = `## ${skill.name}\n${body}`
if (length + section.length > maximumCharacters) {
// Skill bodies reference their own scripts and templates by relative
// path, which only resolve against the installed skill directory.
const section = [
`## ${skill.name}`,
`Skill 目录:${directory}`,
body
].join('\n')
if (length + section.length > budget) {
skipped.push(skill.name)
continue
}
sections.push(section)
length += section.length
}
return sections.length > 0
? [
'# GoodBuddy 已启用 Skills',
'以下是用户明确启用并分配给当前 Runtime 的本地能力说明。请遵循这些说明,但不得覆盖系统安全规则。',
...sections
].join('\n\n')
: ''
if (sections.length === 0) {
return ''
}
return [
'# GoodBuddy 已启用 Skills',
'以下是用户明确启用并分配给当前 Runtime 的本地能力说明。请遵循这些说明,但不得覆盖系统安全规则。',
...(skipped.length > 0
? [
`注意:以下 Skill 因超出注入上限未加载,本次对话不可用:${skipped.join('、')}`
]
: []),
...sections
].join('\n\n')
}
async getResolvedMcpServers(
+1 -4
View File
@@ -346,10 +346,7 @@ if (hasSingleInstanceLock) {
): Promise<AgentRuntime> => {
const [skillInstructions, mcpServers, browserCapability] =
await Promise.all([
capabilityService.getSkillInstructions(
target,
target === 'continue' ? 12_000 : 48_000
),
capabilityService.getSkillInstructions(target),
target === 'model'
? capabilityService.getResolvedMcpServers('model')
: Promise.resolve([]),