feat: make agent roles and skills dynamic

This commit is contained in:
2026-07-11 12:55:05 +08:00
parent 17c0536958
commit d36b00bbec
48 changed files with 1389 additions and 1749 deletions
+39 -9
View File
@@ -1,8 +1,9 @@
import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runAgent } from "../../src/agent/runner.js";
import { importSkillDirectory } from "../../src/agent/skillStore.js";
const queryMock = vi.hoisted(() => vi.fn());
@@ -76,7 +77,7 @@ describe("runAgent", () => {
workspaceRoot = await realpath(workspaceRoot);
workspace = await realpath(workspace);
previousSecrets = Object.fromEntries(
["DATABASE_URL", "FEISHU_APP_SECRET", "HUB_SESSION_SECRET"].map((name) => [name, process.env[name]]),
["DATABASE_URL", "FEISHU_APP_SECRET", "HUB_SESSION_SECRET", "HUB_SKILL_STORE_ROOT"].map((name) => [name, process.env[name]]),
);
});
@@ -113,8 +114,7 @@ describe("runAgent", () => {
allowDangerouslySkipPermissions: true,
settingSources: [],
settings: { disableBundledSkills: true },
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
skills: ["cph-curated:outline", "cph-curated:lesson-project", "cph-curated:data-processing-spec"],
skills: [],
strictMcpConfig: true,
sandbox: expect.objectContaining({
enabled: true,
@@ -149,7 +149,7 @@ describe("runAgent", () => {
it("returns the skills actually reported by SDK initialization", async () => {
queryMock.mockReturnValue(messages(
initMessage(["cph-curated:outline"]),
initMessage(["cph-runtime:outline"]),
assistantMessage("fresh"),
resultMessage("sdk-session-1"),
));
@@ -164,7 +164,7 @@ describe("runAgent", () => {
prisma: stubPrisma,
});
expect(result.initializedSkillIds).toEqual(["cph-curated:outline"]);
expect(result.initializedSkillIds).toEqual(["cph-runtime:outline"]);
});
it("maps role tool ids to the Claude SDK tool whitelist", async () => {
@@ -183,13 +183,13 @@ describe("runAgent", () => {
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: ["Read", "Bash", "Skill"],
tools: ["Read", "Bash"],
allowedTools: ["Read", "Bash", "mcp__cph_hub__send_file"],
},
});
});
it("keeps only the curated Skill dispatcher for an empty role tool whitelist", async () => {
it("disables SDK tools for an empty role tool and skill selection", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
@@ -205,12 +205,42 @@ describe("runAgent", () => {
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: ["Skill"],
tools: [],
allowedTools: [],
},
});
});
it("loads only the dynamic skills selected by the role", async () => {
const source = join(root, "skill-source");
const storeRoot = join(root, "skill-store");
await mkdir(source);
await writeFile(join(source, "SKILL.md"), "---\nname: typst\ndescription: Typst\n---\n");
const installed = await importSkillDirectory({ sourceDir: source, storeRoot });
process.env["HUB_SKILL_STORE_ROOT"] = storeRoot;
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
prompt: "排版",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined,
tools: [],
skills: [{ name: "typst", version: "0.15.0", contentDigest: installed.contentDigest }],
runId: "run-skill",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: ["Skill"],
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
skills: ["cph-runtime:typst"],
},
});
});
it("returns SDK-reported cost when present", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1", 0.0042)));