feat: add natural language configuration tools

This commit is contained in:
lofyer
2026-08-14 01:23:32 +08:00
parent 445ebbc002
commit 7a86c96623
19 changed files with 3284 additions and 119 deletions
+603
View File
@@ -0,0 +1,603 @@
import { createHash, randomUUID } from 'node:crypto'
import { isAbsolute, resolve } from 'node:path'
import {
goodbuddyConfigApplyInputSchema,
goodbuddyConfigApplyOutputSchema,
goodbuddyConfigCapabilities,
goodbuddyConfigCapabilitiesInputSchema,
goodbuddyConfigGetInputSchema,
goodbuddyConfigGetOutputSchema,
goodbuddyConfigOperationRegistry,
goodbuddyConfigPlanInputSchema,
goodbuddyConfigPlanOutputSchema,
type GoodBuddyConfigApplyOutput,
type GoodBuddyConfigOperation,
type GoodBuddyConfigPlanOutput,
type GoodBuddyConfigPlanStep,
type GoodBuddyConfigReload,
type GoodBuddyConfigRisk,
type GoodBuddyConfigSnapshot
} from '../shared/goodbuddy-config-contracts'
import type { ApplicationSettingsStore } from './application-settings-store'
import type {
CapabilityService,
SkillImportInspection
} from './capabilities/capability-service'
import {
getCanonicalWorkspace,
isPathInside
} from './workspace-file-access'
const DEFAULT_PLAN_TTL_MS = 5 * 60_000
const MAX_PLAN_TTL_MS = 10 * 60_000
const MAX_ACTIVE_PLANS = 32
type Plan = {
requestId: string
expiresAt: number
output: GoodBuddyConfigPlanOutput
operations: GoodBuddyConfigOperation[]
skillImports: Map<number, SkillImportInspection>
stateDigest: string
}
export type GoodBuddyConfigApplyEvent = {
requestId: string
planId: string
summary: string
risk: GoodBuddyConfigRisk
reload: GoodBuddyConfigReload
destructive: boolean
}
export type GoodBuddyConfigServiceOptions = {
now?: () => number
planTtlMs?: number
}
export type GoodBuddyConfigApplyAuthorizer = (
event: GoodBuddyConfigApplyEvent,
signal: AbortSignal
) => Promise<boolean>
function maximumRisk(
risks: readonly GoodBuddyConfigRisk[]
): GoodBuddyConfigRisk {
if (risks.includes('high')) {
return 'high'
}
return risks.includes('medium') ? 'medium' : 'low'
}
function maximumReload(
reloads: readonly GoodBuddyConfigReload[]
): GoodBuddyConfigReload {
return reloads.includes('after-current-request')
? 'after-current-request'
: 'none'
}
function toMcpInput(
operation: Extract<
GoodBuddyConfigOperation,
{ operation: 'mcp.add' | 'mcp.update' }
>,
current?: GoodBuddyConfigSnapshot['mcpServers'][number]
) {
return {
...operation.connection,
enabled:
operation.operation === 'mcp.add'
? operation.enabled
: current?.enabled ?? false,
assignments:
operation.operation === 'mcp.add'
? operation.assignments
: current?.assignments ?? [],
secret: { action: 'keep' as const }
}
}
function stableSnapshotDigest(
snapshot: GoodBuddyConfigSnapshot,
capabilityDigest: string
): string {
return createHash('sha256')
.update(JSON.stringify({ snapshot, capabilityDigest }))
.digest('hex')
}
function quoteApprovalValue(value: string, maximum = 1_000): string {
return JSON.stringify(value.slice(0, maximum))
}
function boundedApplyError(error: unknown): string {
return (
(error instanceof Error ? error.message : '配置操作失败')
.trim()
.slice(0, 2_000) || '配置操作失败'
)
}
export class GoodBuddyConfigService {
private readonly now: () => number
private readonly planTtlMs: number
private readonly plans = new Map<string, Plan>()
private readonly pendingReloads = new Map<
string,
GoodBuddyConfigReload
>()
private applyQueue: Promise<void> = Promise.resolve()
constructor(
private readonly applicationSettingsStore: ApplicationSettingsStore,
private readonly capabilityService: CapabilityService,
options: GoodBuddyConfigServiceOptions = {}
) {
this.now = options.now ?? Date.now
const ttl = options.planTtlMs ?? DEFAULT_PLAN_TTL_MS
if (
!Number.isSafeInteger(ttl) ||
ttl < 1 ||
ttl > MAX_PLAN_TTL_MS
) {
throw new RangeError('GoodBuddy 配置计划有效期无效')
}
this.planTtlMs = ttl
}
getCapabilities(input: unknown = {}): typeof goodbuddyConfigCapabilities {
goodbuddyConfigCapabilitiesInputSchema.parse(input)
return goodbuddyConfigCapabilities
}
async getSnapshot(input: unknown = {}): Promise<GoodBuddyConfigSnapshot> {
goodbuddyConfigGetInputSchema.parse(input)
const [application, capabilities] = await Promise.all([
this.applicationSettingsStore.get(),
this.capabilityService.getSnapshot()
])
return goodbuddyConfigGetOutputSchema.parse({
application,
skills: capabilities.skills,
mcpServers: capabilities.mcpServers.map((server) => ({
id: server.id,
name: server.name,
description: server.description,
enabled: server.enabled,
allowDynamicTools: server.allowDynamicTools,
assignments: server.assignments,
secretConfigured: server.secretConfigured,
transport: server.transport
}))
})
}
private prunePlans(): void {
const now = this.now()
for (const [planId, plan] of this.plans) {
if (plan.expiresAt <= now) {
this.plans.delete(planId)
}
}
while (this.plans.size >= MAX_ACTIVE_PLANS) {
const oldestPlanId = this.plans.keys().next().value
if (typeof oldestPlanId !== 'string') {
break
}
this.plans.delete(oldestPlanId)
}
}
private async resolveSkillPath(
workspacePath: string,
inputPath: string
): Promise<string> {
const workspace = await getCanonicalWorkspace(
workspacePath,
'GoodBuddy 配置工作区不是目录'
)
const candidate = isAbsolute(inputPath)
? resolve(inputPath)
: resolve(workspace, inputPath)
if (!isPathInside(workspace, candidate)) {
throw new Error('Skill 导入路径必须位于当前工作区')
}
const inspection =
await this.capabilityService.inspectSkillImport(candidate)
if (!isPathInside(workspace, inspection.sourcePath)) {
throw new Error('Skill 导入路径不能通过符号链接超出当前工作区')
}
return inspection.sourcePath
}
private ensureSkill(
snapshot: GoodBuddyConfigSnapshot,
skillId: string,
removable = false
): void {
const skill = snapshot.skills.find((item) => item.id === skillId)
if (!skill) {
throw new Error(`Skill 不存在:${skillId}`)
}
if (removable && skill.source !== 'imported') {
throw new Error(`只能删除已导入的 Skill:${skillId}`)
}
}
private ensureMcp(
snapshot: GoodBuddyConfigSnapshot,
serverId: string
): GoodBuddyConfigSnapshot['mcpServers'][number] {
const server = snapshot.mcpServers.find((item) => item.id === serverId)
if (!server) {
throw new Error(`MCP Server 不存在:${serverId}`)
}
return server
}
async plan(
requestId: string,
workspacePath: string,
input: unknown
): Promise<GoodBuddyConfigPlanOutput> {
const parsed = goodbuddyConfigPlanInputSchema.parse(input)
const snapshot = await this.getSnapshot()
const capabilityDigest =
await this.capabilityService.getConfigurationDigest()
const skillImports = new Map<number, SkillImportInspection>()
const steps: GoodBuddyConfigPlanStep[] = []
const normalizedOperations: GoodBuddyConfigOperation[] = []
const introducedSkillIds = new Set<string>()
for (const [index, operation] of parsed.operations.entries()) {
let normalized = operation
let summary: string =
goodbuddyConfigOperationRegistry[operation.operation].summary
switch (operation.operation) {
case 'application.update':
summary = `更新应用偏好:${Object.keys(operation.updates).join('、')}`
break
case 'skill.import': {
const sourcePath = await this.resolveSkillPath(
workspacePath,
operation.sourcePath
)
const inspection =
await this.capabilityService.inspectSkillImport(sourcePath)
for (const skill of inspection.skills) {
if (introducedSkillIds.has(skill.id)) {
throw new Error(`计划包含重复的 Skill:${skill.id}`)
}
introducedSkillIds.add(skill.id)
}
normalized = { ...operation, sourcePath }
skillImports.set(index, inspection)
summary = `从 ${quoteApprovalValue(operation.sourcePath)} 导入 ${inspection.skills
.map((skill) => skill.name)
.join('、')},${
operation.enabled ? '启用' : '保持禁用'
},分配给 ${
operation.assignments.length > 0
? operation.assignments.join('、')
: '无 Runtime'
}`
break
}
case 'skill.setEnabled':
this.ensureSkill(snapshot, operation.skillId)
summary = `${
operation.enabled ? '启用' : '禁用'
} Skill「${operation.skillId}」`
break
case 'skill.setAssignments':
this.ensureSkill(snapshot, operation.skillId)
summary = `设置 Skill「${operation.skillId}」的 Runtime 分配`
break
case 'skill.remove':
this.ensureSkill(snapshot, operation.skillId, true)
summary = `永久删除已导入 Skill「${operation.skillId}」`
break
case 'mcp.add':
summary = `添加${
operation.connection.transport === 'stdio'
? '可启动本地程序的'
: '远程'
} MCP Server「${operation.connection.name}」:${
operation.connection.transport === 'stdio'
? `命令 ${[
quoteApprovalValue(operation.connection.command),
...operation.connection.args.map((argument) =>
quoteApprovalValue(argument)
)
].join(' ')}`
: `地址 ${quoteApprovalValue(operation.connection.url, 2_048)}`
}`
break
case 'mcp.update': {
const current = this.ensureMcp(snapshot, operation.serverId)
if (
current.secretConfigured &&
current.transport !== 'stdio' &&
operation.connection.transport !== 'stdio'
) {
throw new Error(
'带访问令牌的 MCP 连接不能通过自然语言修改,请使用原生设置界面'
)
}
summary = `修改 MCP Server「${current.name}」的公开连接设置:${
operation.connection.transport === 'stdio'
? `命令 ${[
quoteApprovalValue(operation.connection.command),
...operation.connection.args.map((argument) =>
quoteApprovalValue(argument)
)
].join(' ')}`
: `地址 ${quoteApprovalValue(operation.connection.url, 2_048)}`
}`
break
}
case 'mcp.setEnabled': {
const current = this.ensureMcp(snapshot, operation.serverId)
summary = `${
operation.enabled ? '启用' : '禁用'
} MCP Server「${current.name}」`
break
}
case 'mcp.setAssignments': {
const current = this.ensureMcp(snapshot, operation.serverId)
summary = `设置 MCP Server「${current.name}」的 Runtime 分配`
break
}
case 'mcp.remove': {
const current = this.ensureMcp(snapshot, operation.serverId)
summary = `永久删除 MCP Server「${current.name}」`
break
}
}
normalizedOperations.push(normalized)
const descriptor =
goodbuddyConfigOperationRegistry[operation.operation]
steps.push({
index,
operation: operation.operation,
summary,
risk: descriptor.risk,
reload: descriptor.reload,
destructive: descriptor.destructive
})
}
this.prunePlans()
const planId = randomUUID()
const expiresAt = this.now() + this.planTtlMs
const output = goodbuddyConfigPlanOutputSchema.parse({
planId,
expiresAt: new Date(expiresAt).toISOString(),
operations: parsed.operations,
steps,
overallRisk: maximumRisk(steps.map((step) => step.risk)),
reload: maximumReload(steps.map((step) => step.reload)),
requiresApproval: true
})
this.plans.set(planId, {
requestId,
expiresAt,
output,
operations: normalizedOperations,
skillImports,
stateDigest: stableSnapshotDigest(snapshot, capabilityDigest)
})
return output
}
private async applyOperation(
operation: GoodBuddyConfigOperation,
skillInspection: SkillImportInspection | undefined
): Promise<void> {
switch (operation.operation) {
case 'application.update':
await this.applicationSettingsStore.update(operation.updates)
return
case 'skill.import': {
if (!skillInspection) {
throw new Error('Skill 导入计划缺少校验信息')
}
await this.capabilityService.importSkill(
operation.sourcePath,
skillInspection.digest,
{
enabled: operation.enabled,
assignments: operation.assignments
}
)
return
}
case 'skill.setEnabled':
await this.capabilityService.setSkillEnabled(
operation.skillId,
operation.enabled
)
return
case 'skill.setAssignments':
await this.capabilityService.setSkillAssignments(
operation.skillId,
operation.assignments
)
return
case 'skill.remove':
await this.capabilityService.removeSkill(operation.skillId)
return
case 'mcp.add':
await this.capabilityService.saveMcpServer(
undefined,
toMcpInput(operation)
)
return
case 'mcp.update': {
const current = (await this.getSnapshot()).mcpServers.find(
(server) => server.id === operation.serverId
)
if (!current) {
throw new Error('MCP Server 不存在')
}
await this.capabilityService.saveMcpServer(
operation.serverId,
toMcpInput(operation, current)
)
return
}
case 'mcp.setEnabled':
case 'mcp.setAssignments': {
const current = (await this.capabilityService.getSnapshot()).mcpServers
.find((server) => server.id === operation.serverId)
if (!current) {
throw new Error('MCP Server 不存在')
}
await this.capabilityService.saveMcpServer(operation.serverId, {
...('url' in current
? { transport: current.transport, url: current.url }
: {
transport: 'stdio' as const,
command: current.command,
args: current.args
}),
name: current.name,
description: current.description,
allowDynamicTools: current.allowDynamicTools,
enabled:
operation.operation === 'mcp.setEnabled'
? operation.enabled
: current.enabled,
assignments:
operation.operation === 'mcp.setAssignments'
? operation.assignments
: current.assignments,
secret: { action: 'keep' }
})
return
}
case 'mcp.remove':
await this.capabilityService.removeMcpServer(operation.serverId)
}
}
apply(
requestId: string,
input: unknown,
signal: AbortSignal,
authorize: GoodBuddyConfigApplyAuthorizer | undefined
): Promise<GoodBuddyConfigApplyOutput> {
const parsed = goodbuddyConfigApplyInputSchema.parse(input)
const operation = this.applyQueue.then(async () => {
this.prunePlans()
const plan = this.plans.get(parsed.planId)
if (
!plan ||
plan.requestId !== requestId ||
plan.expiresAt <= this.now()
) {
this.plans.delete(parsed.planId)
throw new Error('GoodBuddy 配置计划不存在、已过期或不属于当前请求')
}
this.plans.delete(parsed.planId)
signal.throwIfAborted()
const approved =
(await authorize?.(
{
requestId,
planId: parsed.planId,
summary: plan.output.steps
.map((step) => `${step.index + 1}. ${step.summary}`)
.join('\n'),
risk: plan.output.overallRisk,
reload: plan.output.reload,
destructive: plan.output.steps.some(
(step) => step.destructive
)
},
signal
)) ?? false
if (!approved) {
throw new Error('用户拒绝了 GoodBuddy 配置变更')
}
signal.throwIfAborted()
if (plan.expiresAt <= this.now()) {
throw new Error('GoodBuddy 配置计划在确认期间已过期,请重新生成计划')
}
const currentSnapshot = await this.getSnapshot()
const currentCapabilityDigest =
await this.capabilityService.getConfigurationDigest()
if (
stableSnapshotDigest(
currentSnapshot,
currentCapabilityDigest
) !== plan.stateDigest
) {
throw new Error('GoodBuddy 配置在确认前已发生变化,请重新生成计划')
}
let appliedOperations = 0
for (const [index, plannedOperation] of plan.operations.entries()) {
try {
signal.throwIfAborted()
await this.applyOperation(
plannedOperation,
plan.skillImports.get(index)
)
appliedOperations += 1
} catch (error) {
if (appliedOperations === 0) {
throw error
}
this.pendingReloads.set(requestId, 'after-current-request')
return goodbuddyConfigApplyOutputSchema.parse({
planId: parsed.planId,
status: 'partially-applied',
appliedOperations,
reload: plan.output.reload,
snapshot: await this.getSnapshot(),
error: boundedApplyError(error)
})
}
}
const output = goodbuddyConfigApplyOutputSchema.parse({
planId: parsed.planId,
status: 'applied',
appliedOperations,
reload: plan.output.reload,
snapshot: await this.getSnapshot()
})
this.pendingReloads.set(
requestId,
appliedOperations > 0
? 'after-current-request'
: plan.output.reload
)
return output
})
this.applyQueue = operation.then(
() => undefined,
() => undefined
)
return operation
}
revokeRequest(requestId: string): void {
for (const [planId, plan] of this.plans) {
if (plan.requestId === requestId) {
this.plans.delete(planId)
}
}
}
takePendingReload(requestId: string): GoodBuddyConfigReload {
const reload = this.pendingReloads.get(requestId) ?? 'none'
this.pendingReloads.delete(requestId)
return reload
}
clear(): void {
this.plans.clear()
this.pendingReloads.clear()
}
}