吸收数字员工技能策略开关
This commit is contained in:
@@ -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))];
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
@@ -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 fallback;Skill 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 7:Approval / Human-in-the-loop
|
||||
|
||||
|
||||
Reference in New Issue
Block a user