97 lines
8.2 KiB
TypeScript
97 lines
8.2 KiB
TypeScript
import type { ToolSpec } from '@shared/types'
|
|
import type { ConversationToolContext, ConversationToolResult } from '../agent/conversation-tools'
|
|
import { listSkills, buildSkillContext, loadSkillContext, readSkillResource } from './skills'
|
|
import { snapshotMcpTools } from './mcp'
|
|
import { expertManagerDocument, expertManagerAdapter, expertManagerResource, expertPackageOperation, expertTurnContext } from './experts'
|
|
import { agentMode, agentModeInstructions, modeToolDenial, modeToolSpecs } from '../agent/mode-policy'
|
|
import type { AgentMode } from '@shared/conversation'
|
|
|
|
export interface CapabilityToolSnapshot {
|
|
specs: ToolSpec[]
|
|
/** Host-enforced subsets, e.g. a bounded child worker. Applied to built-in tools too. */
|
|
filterSpecs?(specs: ToolSpec[]): ToolSpec[]
|
|
aroundExecute?(name: string, args: Record<string, unknown>, ctx: ConversationToolContext,
|
|
execute: () => Promise<ConversationToolResult>): Promise<ConversationToolResult>
|
|
metadata?(name: string): Record<string, unknown> | undefined
|
|
execute(name: string, args: Record<string, unknown>, ctx: ConversationToolContext): Promise<ConversationToolResult | undefined>
|
|
}
|
|
export interface CapabilitySnapshot extends CapabilityToolSnapshot {
|
|
context: string
|
|
references: Array<{ id: string; name: string; uri: string }>
|
|
assistantDisplay?: import('@shared/capabilities').ExpertBinding
|
|
}
|
|
|
|
/** A turn retains stable capability IDs and MCP connection generations. */
|
|
export async function createCapabilitySnapshot(ids: string[], cwd: string, options: { sessionId?: string; expertId?: string | null; agentMode?: AgentMode } = {}): Promise<CapabilitySnapshot> {
|
|
const mode = agentMode(options.agentMode)
|
|
if (!Array.isArray(ids) || ids.length > 32 || ids.some(id => typeof id !== 'string')) throw new Error('技能引用无效')
|
|
const selected = [...new Set(ids)]
|
|
const installed = await listSkills(cwd)
|
|
const references = selected.map(id => {
|
|
if (id === 'builtin:expert-manager') return { id, name: 'expert-manager', uri: 'skill://expert-manager' }
|
|
const skill = installed.find(item => item.id === id)
|
|
if (!skill?.enabled || !skill.userInvocable) throw new Error('引用的技能已禁用或不可手动调用')
|
|
return { id, name: skill.name, uri: `skill://${encodeURIComponent(skill.name)}` }
|
|
})
|
|
const managerId = 'builtin:expert-manager'
|
|
let managerLoaded = selected.includes(managerId)
|
|
const managerContext = (): string => `<skill id="${managerId}" name="expert-manager">\n${expertManagerDocument().content}\n</skill>${expertManagerAdapter(options.sessionId ?? '')}`
|
|
const skillContext = [await buildSkillContext(selected.filter(id => id !== managerId), cwd), managerLoaded ? managerContext() : ''].filter(Boolean).join('\n\n')
|
|
const available = installed.filter(item => item.enabled && !item.disableModelInvocation)
|
|
const allowedIds = new Set([...selected, ...available.map(item => item.id), managerId])
|
|
const mcp = await snapshotMcpTools()
|
|
// Snapshot first: a concurrent MCP reconfiguration invalidates these generations,
|
|
// so dependency checks can never authorize a newly substituted connection.
|
|
const activeExpert = options.expertId ? await expertTurnContext(options.expertId) : undefined
|
|
const specs: ToolSpec[] = [...mcp.specs]
|
|
if (options.sessionId) specs.push({ type: 'function', function: { name: 'ycode_expert_package', description: '创建或编辑专家包:read 读取当前包;write 增量保存包内文件;validate 校验;register 原子注册到我的专家。冲突后使用 read(refresh:true) 重新读取最新包。必须先加载 expert-manager 技能。', parameters: { type: 'object', properties: { id: { type: 'string' }, action: { type: 'string', enum: ['read', 'write', 'validate', 'register'] }, refresh: { type: 'boolean' }, files: { type: 'object', additionalProperties: { anyOf: [{ type: 'string' }, { type: 'null' }] } } }, required: ['id', 'action'] } } })
|
|
if (activeExpert) specs.push({ type: 'function', function: { name: 'ycode_expert_resource', description: '读取当前激活专家包内的 UTF-8 文本资源。', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } } })
|
|
if (allowedIds.size) {
|
|
specs.push({ type: 'function', function: { name: 'ycode_load_skill', description: '加载可用技能的完整指令。开始对应任务前先加载技能,遵循其流程。', parameters: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } } })
|
|
specs.push({ type: 'function', function: { name: 'ycode_read_skill_resource', description: '读取技能包中的文本资源。path 是包内相对路径。', parameters: { type: 'object', properties: { id: { type: 'string' }, path: { type: 'string' } }, required: ['id', 'path'] } } })
|
|
}
|
|
const names = new Set(mcp.specs.map(spec => spec.function.name))
|
|
return {
|
|
specs: modeToolSpecs(specs, mode),
|
|
references,
|
|
assistantDisplay: activeExpert?.display,
|
|
metadata: name => { const identity = mcp.describe(name); return identity ? { capability: 'mcp', ...identity } : undefined },
|
|
context: [activeExpert?.context ?? (options.expertId === null ? '<active-expert disabled="true">当前会话已移除专家,后续回复使用默认助手身份,不再沿用之前专家的角色指令。</active-expert>' : ''), skillContext, `可自动调用的技能(先用 ycode_load_skill 加载):\n${[...available.map(item => ({ id: item.id, name: item.name, description: item.description })), { id: managerId, name: 'expert-manager', description: '创建、转换、修改、校验本地专家包。' }].map(item => JSON.stringify(item)).join('\n')}`, agentModeInstructions(mode)].filter(Boolean).join('\n\n'),
|
|
async execute(name, args, ctx) {
|
|
if (ctx.signal.aborted) throw Object.assign(new Error('回合已取消'), { name: 'AbortError' })
|
|
const denial = modeToolDenial(mode, name)
|
|
if (denial) return denial
|
|
if (name === 'ycode_expert_package') {
|
|
if (!managerLoaded || !options.sessionId) return { ok: false, content: '请先加载完整 expert-manager 技能' }
|
|
try { return { ok: true, content: JSON.stringify(await expertPackageOperation(options.sessionId, args)), metadata: { capability: 'expert-package', action: args.action, expertId: args.id } } } catch (e) { return { ok: false, content: e instanceof Error ? e.message : String(e) } }
|
|
}
|
|
if (name === 'ycode_expert_resource' && options.expertId) {
|
|
const resource = typeof args.path === 'string' ? args.path.replace(/^\.\//, '') : ''
|
|
const content = activeExpert && Object.hasOwn(activeExpert.resources, resource) ? activeExpert.resources[resource] : undefined
|
|
return typeof content !== 'string' ? { ok: false, content: '当前专家包中没有此文本资源' } : { ok: true, content }
|
|
}
|
|
if (name === 'ycode_load_skill' || name === 'ycode_read_skill_resource') {
|
|
if (typeof args.id !== 'string' || !allowedIds.has(args.id)) return { ok: false, content: '技能不在当前回合可用范围内' }
|
|
if (args.id === managerId) {
|
|
if (name === 'ycode_load_skill') { managerLoaded = true; return { ok: true, content: managerContext() } }
|
|
return { ok: true, content: expertManagerResource(String(args.path ?? '')) }
|
|
}
|
|
const content = name === 'ycode_load_skill' ? await loadSkillContext(args.id, cwd) : await readSkillResource(args.id, String(args.path ?? ''), cwd)
|
|
return { ok: true, content }
|
|
}
|
|
if (!names.has(name)) return undefined
|
|
if (ctx.permissionMode !== 'full-access') {
|
|
if (!ctx.interactions) return { ok: false, content: 'MCP 调用需要用户确认' }
|
|
const identity = mcp.describe(name)
|
|
const decision = await ctx.interactions.approval(ctx.toolCallId, identity ? `调用 ${identity.serverName} 的 ${identity.toolName} 工具` : `调用连接器工具 ${name}`, [
|
|
{ optionId: 'allow-once', name: '允许一次', kind: 'allow_once' },
|
|
{ optionId: 'reject-once', name: '拒绝', kind: 'reject_once' }
|
|
])
|
|
if (decision.optionId !== 'allow-once') return { ok: false, content: '用户拒绝了连接器调用' }
|
|
}
|
|
const result = await mcp.execute(name, args, ctx.signal)
|
|
return { ...result, metadata: { capability: 'mcp', ...mcp.describe(name) } }
|
|
}
|
|
}
|
|
}
|