Files
ycode/src/main/capabilities/runtime.ts
T

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) } }
}
}
}