forked from EduCraft/curriculum-project-hub
feat: make agent roles and skills dynamic
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { OrganizationAgentConfiguration } from "../../src/agent/configuration.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js";
|
||||
|
||||
describe("Organization Agent configuration management", () => {
|
||||
let root: string;
|
||||
let configuration: OrganizationAgentConfiguration;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
root = await mkdtemp(join(tmpdir(), "cph-agent-config-"));
|
||||
configuration = new OrganizationAgentConfiguration(prisma, join(root, "store"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("installs versioned skills and selects them as part of a dynamic role bundle", async () => {
|
||||
const typst = await makeSkill(root, "typst");
|
||||
const outline = await makeSkill(root, "outline");
|
||||
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "0.15.0" });
|
||||
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: outline, version: "1" });
|
||||
await configuration.upsertRole({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
roleId: "draft",
|
||||
label: "课程草稿",
|
||||
defaultModel: "anthropic/claude-sonnet-5",
|
||||
systemPrompt: "write carefully",
|
||||
tools: ["read_file", "write_file", "cph_build"],
|
||||
sortOrder: 10,
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
|
||||
});
|
||||
await prisma.agentSession.create({
|
||||
data: {
|
||||
id: "session-old-role-config",
|
||||
projectId: "project-a",
|
||||
provider: "openrouter",
|
||||
roleId: "draft",
|
||||
model: "anthropic/claude-sonnet-5",
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
await configuration.setRoleSkills({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
roleId: "draft",
|
||||
skillNames: ["outline", "typst"],
|
||||
});
|
||||
|
||||
const role = await prisma.organizationAgentRole.findUniqueOrThrow({
|
||||
where: { organizationId_roleId: { organizationId: DEFAULT_ORG_ID, roleId: "draft" } },
|
||||
include: { skillBindings: { orderBy: { sortOrder: "asc" }, include: { skill: true } } },
|
||||
});
|
||||
expect(role).toMatchObject({ label: "课程草稿", systemPrompt: "write carefully" });
|
||||
expect(role.tools).toEqual(["read_file", "write_file", "cph_build"]);
|
||||
expect(role.skillBindings.map((binding) => binding.skill.name)).toEqual(["outline", "typst"]);
|
||||
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } }))
|
||||
.resolves.toMatchObject({ archivedAt: expect.any(Date) });
|
||||
});
|
||||
|
||||
it("rejects unknown, disabled and cross-Organization skills", async () => {
|
||||
await seedTestOrganization("org_other", "other");
|
||||
const typst = await makeSkill(root, "typst");
|
||||
await configuration.installSkill({ organizationId: "org_other", sourceDir: typst, version: "1" });
|
||||
await configuration.upsertRole({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
roleId: "draft",
|
||||
label: "Draft",
|
||||
tools: [],
|
||||
});
|
||||
|
||||
await expect(configuration.setRoleSkills({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
roleId: "draft",
|
||||
skillNames: ["typst"],
|
||||
})).rejects.toThrow("active skills not found in organization");
|
||||
});
|
||||
|
||||
async function makeSkill(parent: string, name: string): Promise<string> {
|
||||
const source = join(parent, "sources", name);
|
||||
await mkdir(source, { recursive: true });
|
||||
await writeFile(join(source, "SKILL.md"), `---\nname: ${name}\ndescription: ${name} skill\n---\n# ${name}\n`);
|
||||
return source;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DatabaseRuntimeSettings } from "../../src/settings/runtime.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization, testSecretEnvelope } from "./helpers.js";
|
||||
|
||||
describe("Organization-scoped Agent runtime configuration", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("resolves role prompt, model, tools and skills from the project Organization", async () => {
|
||||
await seedTestOrganization("org_other", "other");
|
||||
await Promise.all([
|
||||
prisma.project.create({
|
||||
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
|
||||
}),
|
||||
prisma.project.create({
|
||||
data: { id: "project-b", organizationId: "org_other", name: "B", workspaceDir: "/tmp/b" },
|
||||
}),
|
||||
]);
|
||||
const [skillA, skillB] = await Promise.all([
|
||||
prisma.organizationAgentSkill.create({
|
||||
data: {
|
||||
id: "skill-a",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: "typst",
|
||||
version: "0.15.0",
|
||||
contentDigest: "a".repeat(64),
|
||||
},
|
||||
}),
|
||||
prisma.organizationAgentSkill.create({
|
||||
data: {
|
||||
id: "skill-b",
|
||||
organizationId: "org_other",
|
||||
name: "typst",
|
||||
version: "other",
|
||||
contentDigest: "b".repeat(64),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const [roleA, roleB] = await Promise.all([
|
||||
prisma.organizationAgentRole.create({
|
||||
data: {
|
||||
id: "role-a",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
roleId: "draft",
|
||||
label: "A Draft",
|
||||
defaultModel: "anthropic/claude-sonnet-5",
|
||||
systemPrompt: "prompt-a",
|
||||
tools: ["read_file", "cph_build"],
|
||||
},
|
||||
}),
|
||||
prisma.organizationAgentRole.create({
|
||||
data: {
|
||||
id: "role-b",
|
||||
organizationId: "org_other",
|
||||
roleId: "draft",
|
||||
label: "B Draft",
|
||||
systemPrompt: "prompt-b",
|
||||
tools: [],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
await Promise.all([
|
||||
prisma.organizationAgentRoleSkill.create({
|
||||
data: { organizationId: DEFAULT_ORG_ID, agentRoleId: roleA.id, agentSkillId: skillA.id },
|
||||
}),
|
||||
prisma.organizationAgentRoleSkill.create({
|
||||
data: { organizationId: "org_other", agentRoleId: roleB.id, agentSkillId: skillB.id },
|
||||
}),
|
||||
]);
|
||||
const settings = new DatabaseRuntimeSettings(prisma, testSecretEnvelope, {});
|
||||
|
||||
const registryA = await settings.modelRegistry({ projectId: "project-a" });
|
||||
const registryB = await settings.modelRegistry({ projectId: "project-b" });
|
||||
|
||||
expect(registryA.role("draft")).toMatchObject({
|
||||
label: "A Draft",
|
||||
systemPrompt: "prompt-a",
|
||||
tools: ["read_file", "cph_build"],
|
||||
skills: [{ name: "typst", version: "0.15.0", contentDigest: "a".repeat(64) }],
|
||||
});
|
||||
expect(registryB.role("draft")).toMatchObject({
|
||||
label: "B Draft",
|
||||
systemPrompt: "prompt-b",
|
||||
tools: [],
|
||||
skills: [{ name: "typst", version: "other", contentDigest: "b".repeat(64) }],
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed for missing scope and disabled role skills", async () => {
|
||||
await prisma.project.create({
|
||||
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
|
||||
});
|
||||
const skill = await prisma.organizationAgentSkill.create({
|
||||
data: {
|
||||
id: "skill-disabled",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: "typst",
|
||||
version: "0.15.0",
|
||||
contentDigest: "c".repeat(64),
|
||||
disabledAt: new Date(),
|
||||
},
|
||||
});
|
||||
const role = await prisma.organizationAgentRole.create({
|
||||
data: { id: "role-a", organizationId: DEFAULT_ORG_ID, roleId: "draft", label: "Draft" },
|
||||
});
|
||||
await prisma.organizationAgentRoleSkill.create({
|
||||
data: { organizationId: DEFAULT_ORG_ID, agentRoleId: role.id, agentSkillId: skill.id },
|
||||
});
|
||||
const settings = new DatabaseRuntimeSettings(prisma, testSecretEnvelope, {});
|
||||
|
||||
await expect(settings.modelRegistry()).rejects.toThrow("projectId is required");
|
||||
await expect(settings.modelRegistry({ projectId: "project-a" })).rejects.toThrow(
|
||||
"role draft selects disabled skill typst",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { runAgent, type StreamEvent } from "../../src/agent/runner.js";
|
||||
import { importSkillDirectory } from "../../src/agent/skillStore.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const originalEnv = new Map<string, string | undefined>();
|
||||
@@ -52,7 +53,9 @@ describe("real Claude SDK sandbox boundary", () => {
|
||||
const workspace = join(workspaceRoot, "a", `p_${nonce.slice(0, 8)}`);
|
||||
const sibling = join(workspaceRoot, "b", `p_${nonce.slice(8, 16)}`);
|
||||
const serviceSecret = join(root, `s_${nonce.slice(16, 24)}`);
|
||||
roots.push(workspace, sibling, serviceSecret);
|
||||
const skillSource = join(root, `k_${nonce.slice(24, 28)}`);
|
||||
const skillStore = join(root, `ks_${nonce.slice(28, 32)}`);
|
||||
roots.push(workspace, sibling, serviceSecret, skillSource, skillStore);
|
||||
await Promise.all([
|
||||
mkdir(workspace, { recursive: true }),
|
||||
mkdir(sibling, { recursive: true }),
|
||||
@@ -62,6 +65,9 @@ describe("real Claude SDK sandbox boundary", () => {
|
||||
writeFile(join(sibling, "secret.txt"), "sibling-secret\n"),
|
||||
writeFile(serviceSecret, "platform-secret\n"),
|
||||
]);
|
||||
await mkdir(skillSource, { recursive: true });
|
||||
await writeFile(join(skillSource, "SKILL.md"), "---\nname: outline\ndescription: Outline\n---\n");
|
||||
const installedSkill = await importSkillDirectory({ sourceDir: skillSource, storeRoot: skillStore });
|
||||
const untrustedSkill = join(workspace, ".claude", "skills", "untrusted");
|
||||
await mkdir(untrustedSkill, { recursive: true });
|
||||
await writeFile(join(untrustedSkill, "SKILL.md"), "---\nname: untrusted\ndescription: must never load\n---\n");
|
||||
@@ -86,6 +92,7 @@ describe("real Claude SDK sandbox boundary", () => {
|
||||
DATABASE_URL: "postgresql://platform-secret",
|
||||
FEISHU_APP_SECRET: "feishu-secret",
|
||||
HUB_SESSION_SECRET: "session-secret",
|
||||
HUB_SKILL_STORE_ROOT: skillStore,
|
||||
});
|
||||
|
||||
const bashCommand = [
|
||||
@@ -133,6 +140,7 @@ describe("real Claude SDK sandbox boundary", () => {
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
tools: ["bash"],
|
||||
skills: [{ name: "outline", version: "1", contentDigest: installedSkill.contentDigest }],
|
||||
maxTurns: 3,
|
||||
runId: "sandbox-run",
|
||||
sessionId: "sandbox-session",
|
||||
@@ -150,9 +158,7 @@ describe("real Claude SDK sandbox boundary", () => {
|
||||
).toBe("completed");
|
||||
expect(stub.requestCount()).toBeGreaterThanOrEqual(3);
|
||||
expect(new Set(result.initializedSkillIds)).toEqual(new Set([
|
||||
"cph-curated:outline",
|
||||
"cph-curated:lesson-project",
|
||||
"cph-curated:data-processing-spec",
|
||||
"cph-runtime:outline",
|
||||
]));
|
||||
const toolResults = streamEvents.filter((event) => event.type === "tool-result");
|
||||
expect(toolResults).toHaveLength(2);
|
||||
|
||||
@@ -35,6 +35,9 @@ export const prisma = new PrismaClient({
|
||||
/** Truncate all tables before each test for isolation. */
|
||||
export async function resetDb(): Promise<void> {
|
||||
const tables = [
|
||||
"OrganizationAgentRoleSkill",
|
||||
"OrganizationAgentRole",
|
||||
"OrganizationAgentSkill",
|
||||
"FeishuEventReceipt",
|
||||
"FeishuUserIdentity",
|
||||
"FeishuApplicationCredentialVersion",
|
||||
|
||||
@@ -53,6 +53,14 @@ describe("Alpha Silo bootstrap", () => {
|
||||
expect(await prisma.team.count({ where: { slug: "teachers", archivedAt: null } })).toBe(1);
|
||||
expect(await prisma.teamMembership.count({ where: { revokedAt: null } })).toBe(1);
|
||||
expect(await prisma.organizationProviderConnection.count({ where: { status: "ACTIVE" } })).toBe(1);
|
||||
await expect(prisma.organizationAgentRole.findMany({
|
||||
where: { organizationId: "org_alpha", disabledAt: null },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { roleId: true, label: true },
|
||||
})).resolves.toEqual([
|
||||
{ roleId: "draft", label: "草稿" },
|
||||
{ roleId: "review", label: "审校" },
|
||||
]);
|
||||
|
||||
const persisted = JSON.stringify({
|
||||
feishu: await prisma.feishuApplicationCredentialVersion.findMany(),
|
||||
|
||||
Reference in New Issue
Block a user