吸收数字员工技能策略开关

This commit is contained in:
baiyanyun
2026-06-09 16:28:18 +08:00
parent 9ad7881378
commit 4d8b8e4ad5
8 changed files with 249 additions and 30 deletions

View File

@@ -41,6 +41,7 @@ declare global {
getRuntimeRecords?: () => Promise<QimingclawRuntimeRecords>;
getUiState?: () => Promise<AdapterState>;
getSkillCapabilities?: () => Promise<QimingclawSkillCapabilities>;
setSkillEnabled?: (skillId: string, enabled: boolean) => Promise<{ ok?: boolean; skill_id?: string; enabled?: boolean; error?: string }>;
getPlanTemplates?: () => Promise<QimingclawPlanTemplateList>;
listMemories?: (options?: { query?: string; category?: string; limit?: number }) => Promise<QimingclawMemoryRecord[]>;
addMemory?: (input: QimingclawMemoryUpsertInput) => Promise<QimingclawMemoryRecord | null>;
@@ -697,7 +698,7 @@ export async function handleQimingclawDigitalApi<T>(
if (method === 'POST' && pathname.startsWith('/api/skill/') && pathname.endsWith('/enabled')) {
const skillId = decodeURIComponent(pathname.split('/')[3] || '');
const body = parseJsonBody<{ enabled?: boolean }>(options.body);
return { ok: true, skill_id: skillId, enabled: body.enabled ?? true } as T;
return await setBridgeSkillEnabled(skillId, body.enabled ?? true) as T;
}
return undefined;
@@ -768,7 +769,34 @@ async function readBridgeSkillCapabilities(): Promise<SkillSpec[]> {
}
async function buildSkills(): Promise<SkillSpec[]> {
return dedupeSkills([...SKILLS, ...await readBridgeSkillCapabilities()]);
const bridgeSkills = await readBridgeSkillCapabilities();
return dedupeSkills([...bridgeSkills, ...SKILLS]);
}
async function setBridgeSkillEnabled(
skillId: string,
enabled: boolean,
): Promise<{ ok: boolean; skill_id: string; enabled: boolean; error?: string }> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.setSkillEnabled) {
return { ok: false, skill_id: skillId, enabled, error: 'qimingclaw_bridge_unavailable' };
}
try {
const result = await bridge.setSkillEnabled(skillId, enabled);
return {
ok: result?.ok !== false,
skill_id: result?.skill_id ?? skillId,
enabled: result?.enabled ?? enabled,
...(result?.error ? { error: result.error } : {}),
};
} catch (error) {
return {
ok: false,
skill_id: skillId,
enabled,
error: error instanceof Error ? error.message : String(error),
};
}
}
function dedupeSkills(skills: SkillSpec[]): SkillSpec[] {
@@ -2808,12 +2836,16 @@ async function handleIngressRoute(
const idSource = body.idempotency_key || correlationId;
const contextRefs = buildIngressContextRefs(body);
const classification = classifyIngressRoute(body, contextRefs, snapshot);
const filteredCandidates = await filterRouteCandidateSkills(classification.candidateSkills);
const reasonCodes = filteredCandidates.allCandidatesDisabled
? [...classification.reasonCodes, 'candidate_skills_disabled']
: classification.reasonCodes;
const decision: RouteDecisionRecord = {
id: `route-decision-${slugifyIdPart(idSource)}`,
route_kind: classification.routeKind,
intent_kind: classification.intentKind,
reason_codes: classification.reasonCodes,
candidate_skills: classification.candidateSkills,
reason_codes: reasonCodes,
candidate_skills: filteredCandidates.candidateSkills,
context_refs: contextRefs,
created_command_id: null,
correlation_id: correlationId,
@@ -2950,12 +2982,43 @@ function routeCandidateSkills(routeKind: string, contextRefs: Record<string, unk
stringValue(payload.skill_id),
]);
if (explicit.length > 0) return explicit;
if (routeKind === 'PlanControl') return ['qimingclaw-task-agent'];
if (routeKind === 'ProjectionQuery') return ['qimingclaw-artifact-report'];
if (routeKind === 'PlanControl') return ['qimingclaw-acp-session'];
if (routeKind === 'ProjectionQuery') return ['qimingclaw-artifact-reporting'];
if (routeKind === 'PlanExecution') return ['qimingclaw-computer-control'];
return [];
}
async function filterRouteCandidateSkills(
candidateSkills: string[],
): Promise<{ candidateSkills: string[]; allCandidatesDisabled: boolean }> {
if (candidateSkills.length === 0) {
return { candidateSkills, allCandidatesDisabled: false };
}
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.getSkillCapabilities) {
return { candidateSkills, allCandidatesDisabled: false };
}
try {
const result = await bridge.getSkillCapabilities();
if (!Array.isArray(result?.skills)) {
return { candidateSkills, allCandidatesDisabled: false };
}
const enabledBySkillId = new Map<string, boolean>();
for (const skill of result.skills) {
const skillId = stringValue(skill.skill_id);
if (skillId) enabledBySkillId.set(skillId, skill.enabled !== false);
}
const enabledCandidates = candidateSkills.filter((skillId) => enabledBySkillId.get(skillId) !== false);
return {
candidateSkills: enabledCandidates,
allCandidatesDisabled: candidateSkills.length > 0 && enabledCandidates.length === 0,
};
} catch (error) {
console.warn('[qimingclawAdapter] filter route candidate skills failed:', error);
return { candidateSkills, allCandidatesDisabled: false };
}
}
function uniqueStrings(values: unknown[]): string[] {
return [...new Set(values.map(stringValue).filter(Boolean))];
}

View File

@@ -16,7 +16,7 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
<script type="module" crossorigin src="./assets/index-DG9FLGXd.js"></script>
<script type="module" crossorigin src="./assets/index-Dc0KTjwq.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CKIQdvco.css">
</head>
<body>

View File

@@ -40,7 +40,10 @@ import {
getDigitalEmployeeSyncStatus,
} from "../services/digitalEmployee/syncService";
import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService";
import { listDigitalEmployeeSkillCapabilities } from "../services/digitalEmployee/skillCatalogService";
import {
listDigitalEmployeeSkillCapabilities,
setDigitalEmployeeSkillEnabled,
} from "../services/digitalEmployee/skillCatalogService";
import { memoryService } from "../services/memory";
export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
@@ -75,6 +78,10 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
});
});
ipcMain.handle("digitalEmployee:setSkillEnabled", async (_, skillId: string, enabled: boolean) => {
return setDigitalEmployeeSkillEnabled(skillId, enabled);
});
ipcMain.handle("digitalEmployee:getPlanTemplates", async () => {
return listDigitalEmployeePlanTemplates();
});

View File

@@ -102,6 +102,22 @@ const mockAgent = vi.hoisted(() => ({
],
}));
const mockSettings = vi.hoisted(() => ({
values: new Map<string, unknown>(),
}));
vi.mock("../../db", () => ({
readSetting: (key: string) => mockSettings.values.get(key) ?? null,
writeSetting: (key: string, value: unknown) => {
if (value === null || value === undefined) {
mockSettings.values.delete(key);
} else {
mockSettings.values.set(key, value);
}
return true;
},
}));
vi.mock("../packages/mcp", () => ({
mcpProxyManager: {
getStatus: () => mockMcp.status,
@@ -140,6 +156,7 @@ vi.mock("../startupPorts", () => ({
describe("digital employee skill catalog service", () => {
beforeEach(() => {
vi.resetModules();
mockSettings.values = new Map<string, unknown>();
});
it("projects MCP servers and tools with governance and recent calls", async () => {
@@ -263,4 +280,35 @@ describe("digital employee skill catalog service", () => {
}),
]));
});
it("persists local skill enabled policy and applies it to catalog governance", async () => {
const {
listDigitalEmployeeSkillCapabilities,
readDigitalEmployeeSkillPolicies,
setDigitalEmployeeSkillEnabled,
} = await import("./skillCatalogService");
const update = setDigitalEmployeeSkillEnabled("qimingclaw-computer-control", false);
expect(update).toEqual(expect.objectContaining({
ok: true,
skill_id: "qimingclaw-computer-control",
enabled: false,
}));
expect(readDigitalEmployeeSkillPolicies().skills["qimingclaw-computer-control"]).toEqual(expect.objectContaining({
enabled: false,
updatedAt: expect.any(String),
}));
const result = listDigitalEmployeeSkillCapabilities();
expect(result.skills).toEqual(expect.arrayContaining([
expect.objectContaining({
skill_id: "qimingclaw-computer-control",
enabled: false,
governance: expect.objectContaining({
effective_policy: "disabled",
}),
}),
]));
});
});

View File

@@ -5,6 +5,7 @@ import { getGuiAgentServerStatus } from "../packages/guiAgentServer";
import { getWindowsMcpStatus } from "../packages/windowsMcp";
import { isWindows } from "../system/shellEnv";
import { getConfiguredPorts } from "../startupPorts";
import { readSetting, writeSetting } from "../../db";
import { readDigitalEmployeeRuntimeRecords } from "./stateService";
export interface DigitalEmployeeSkillRecentCall {
@@ -84,6 +85,25 @@ export interface DigitalEmployeeSkillCatalogOptions {
guiServerStatus?: DigitalEmployeeLocalServiceStatus | null;
}
export interface DigitalEmployeeSkillPolicyRecord {
enabled: boolean;
updatedAt: string;
}
export interface DigitalEmployeeSkillPolicies {
version: 1;
skills: Record<string, DigitalEmployeeSkillPolicyRecord>;
}
export interface DigitalEmployeeSkillPolicyUpdateResult {
ok: boolean;
skill_id: string;
enabled: boolean;
policies: DigitalEmployeeSkillPolicies;
}
const SKILL_POLICIES_SETTING_KEY = "digital_employee_skill_policies_v1";
const ACP_SKILLS: AcpSkillDefinition[] = [
{
skill_id: "qimingclaw-acp-session",
@@ -168,6 +188,7 @@ export function listDigitalEmployeeSkillCapabilities(
generatedAt: string;
skills: DigitalEmployeeSkillCapability[];
} {
const policies = readDigitalEmployeeSkillPolicies();
const status = mcpProxyManager.getStatus();
const config = mcpProxyManager.getConfig();
const runtime = readDigitalEmployeeRuntimeRecords(100);
@@ -259,10 +280,86 @@ export function listDigitalEmployeeSkillCapabilities(
return {
generatedAt: new Date().toISOString(),
skills,
skills: applySkillPolicies(skills, policies),
};
}
export function readDigitalEmployeeSkillPolicies(): DigitalEmployeeSkillPolicies {
return normalizeSkillPolicies(readSetting(SKILL_POLICIES_SETTING_KEY));
}
export function setDigitalEmployeeSkillEnabled(
skillId: string,
enabled: boolean,
): DigitalEmployeeSkillPolicyUpdateResult {
const normalizedSkillId = normalizeSkillId(skillId);
if (!normalizedSkillId) {
throw new Error("skill_id is required");
}
const current = readDigitalEmployeeSkillPolicies();
const next: DigitalEmployeeSkillPolicies = {
version: 1,
skills: {
...current.skills,
[normalizedSkillId]: {
enabled: enabled === true,
updatedAt: new Date().toISOString(),
},
},
};
writeSetting(SKILL_POLICIES_SETTING_KEY, next);
return {
ok: true,
skill_id: normalizedSkillId,
enabled: enabled === true,
policies: next,
};
}
function applySkillPolicies(
skills: DigitalEmployeeSkillCapability[],
policies: DigitalEmployeeSkillPolicies,
): DigitalEmployeeSkillCapability[] {
return skills.map((skill) => {
const policy = policies.skills[skill.skill_id];
if (!policy) return skill;
const enabled = skill.enabled && policy.enabled !== false;
return {
...skill,
enabled,
governance: {
...skill.governance,
effective_policy: enabled ? skill.governance.effective_policy : "disabled",
},
};
});
}
function normalizeSkillPolicies(value: unknown): DigitalEmployeeSkillPolicies {
const record = objectRecord(value);
const rawSkills = objectRecord(record?.skills) ?? record;
const skills: Record<string, DigitalEmployeeSkillPolicyRecord> = {};
for (const [rawSkillId, rawPolicy] of Object.entries(rawSkills ?? {})) {
const skillId = normalizeSkillId(rawSkillId);
if (!skillId) continue;
if (typeof rawPolicy === "boolean") {
skills[skillId] = { enabled: rawPolicy, updatedAt: "" };
continue;
}
const policy = objectRecord(rawPolicy);
if (!policy || typeof policy.enabled !== "boolean") continue;
skills[skillId] = {
enabled: policy.enabled,
updatedAt: stringValue(policy.updatedAt ?? policy.updated_at) || "",
};
}
return { version: 1, skills };
}
function normalizeSkillId(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function buildGuiAgentSkills(
events: DigitalEmployeeRuntimeEventLike[],
inputStatus?: DigitalEmployeeLocalServiceStatus | null,

View File

@@ -104,6 +104,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async getSkillCapabilities() {
return ipcRenderer.invoke("digitalEmployee:getSkillCapabilities");
},
async setSkillEnabled(skillId: string, enabled: boolean) {
return ipcRenderer.invoke("digitalEmployee:setSkillEnabled", skillId, enabled);
},
async getPlanTemplates() {
return ipcRenderer.invoke("digitalEmployee:getPlanTemplates");
},

View File

@@ -568,9 +568,9 @@ GET /api/digital-employee/approvals
- 建议:先做只允许安全服务的 restart bridge保持高风险命令不暴露。
6. **技能生命周期与策略**
- 已吸收ACP / MCP / GUI / file server 能力已投射到 Skill Catalog技能库不再使用 demo fallback。
- 缺口:技能启用/禁用当前主要是兼容返回,尚未真正持久化策略;缺少技能安装、版本、权限范围、allow/deny 生效和调用审计视图。
- 建议:新增本地 skill policy settings并让 Skill Library 的 enabled 状态影响后续任务分配和展示
- 已吸收ACP / MCP / GUI / file server 能力已投射到 Skill Catalog技能库不再使用 demo fallbackSkill Library 启用/禁用已写入 qimingclaw 本地 `digital_employee_skill_policies_v1` 设置,并回写目录 `enabled` / `governance.effective_policy`;入口路由候选技能会按本地策略过滤
- 缺口:缺少技能安装、版本管理、权限范围、真实 allow/deny 执行拦截和调用审计视图。
- 建议:下一轮围绕技能权限 scope 和调用审计,把策略从“路由候选过滤”推进到“实际工具调用前校验”
7. **产物交付闭环**
- 已吸收:运行 payload 中的 artifacts / outputs / files / uri 可提取为正式 `digital_artifacts` 并同步。
@@ -995,7 +995,7 @@ create_workspace / upload_file / download_all_files / export_project / ...
用户可以:
- 查看技能
- 启用 / 禁用
- 启用 / 禁用(本地持久化到 `digital_employee_skill_policies_v1`,并影响入口路由候选技能)
- 查看所属 MCP server
- 查看 MCP allow / deny 策略
- 查看 MCP proxy 运行状态
@@ -1009,12 +1009,13 @@ create_workspace / upload_file / download_all_files / export_project / ...
- 工具从“技术配置”变成“数字员工能力”。
- PlanTemplate 可以绑定 Skill。
- MCP server 和工具能按治理策略投射成可扫描的技能卡。
- Skill Library 的启用 / 禁用操作会通过 `digitalEmployee:setSkillEnabled` 写入 qimingclaw 本地策略,`/api/skill` 后续读取真实目录状态。
### 验收
- MCP tool 能出现在技能库。
- MCP server 能显示 allow / deny、运行状态和最近调用。
- 禁用技能后,PlanTemplate 或任务执行不会再选择它
- 禁用技能后,入口路由不会再把它作为候选技能;若候选全部被禁用,路由决策会记录 `candidate_skills_disabled`
## Phase 7Approval / Human-in-the-loop