forked from EduCraft/curriculum-project-hub
feat: make agent roles and skills dynamic
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { assertSupportedRoleTools } from "./roleTools.js";
|
||||
import { importSkillDirectory } from "./skillStore.js";
|
||||
|
||||
const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||
|
||||
/**
|
||||
* Deep module for controlled host-console Agent configuration. It owns the
|
||||
* filesystem/DB ordering, Organization checks and role-skill composition so
|
||||
* callers never manipulate registry rows or content paths independently.
|
||||
*/
|
||||
export class OrganizationAgentConfiguration {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly skillStoreRoot: string,
|
||||
) {}
|
||||
|
||||
async installSkill(input: {
|
||||
readonly organizationId: string;
|
||||
readonly sourceDir: string;
|
||||
readonly version: string;
|
||||
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
const version = nonEmpty(input.version, "skill version");
|
||||
const imported = await importSkillDirectory({
|
||||
sourceDir: input.sourceDir,
|
||||
storeRoot: this.skillStoreRoot,
|
||||
});
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const previous = await tx.organizationAgentSkill.findUnique({
|
||||
where: { organizationId_name: { organizationId: input.organizationId, name: imported.name } },
|
||||
select: { contentDigest: true },
|
||||
});
|
||||
const skill = await tx.organizationAgentSkill.upsert({
|
||||
where: {
|
||||
organizationId_name: {
|
||||
organizationId: input.organizationId,
|
||||
name: imported.name,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
organizationId: input.organizationId,
|
||||
name: imported.name,
|
||||
version,
|
||||
description: imported.description ?? null,
|
||||
contentDigest: imported.contentDigest,
|
||||
},
|
||||
update: {
|
||||
version,
|
||||
description: imported.description ?? null,
|
||||
contentDigest: imported.contentDigest,
|
||||
disabledAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
contentDigest: true,
|
||||
roleBindings: { select: { role: { select: { roleId: true } } } },
|
||||
},
|
||||
});
|
||||
if (previous !== null && previous.contentDigest !== skill.contentDigest) {
|
||||
await archiveRoleSessions(
|
||||
tx,
|
||||
input.organizationId,
|
||||
skill.roleBindings.map((binding) => binding.role.roleId),
|
||||
);
|
||||
}
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_skill.installed",
|
||||
metadata: {
|
||||
name: skill.name,
|
||||
version,
|
||||
contentDigest: skill.contentDigest,
|
||||
},
|
||||
},
|
||||
});
|
||||
return { id: skill.id, name: skill.name, contentDigest: skill.contentDigest };
|
||||
});
|
||||
}
|
||||
|
||||
async upsertRole(input: {
|
||||
readonly organizationId: string;
|
||||
readonly roleId: string;
|
||||
readonly label: string;
|
||||
readonly defaultModel?: string | null | undefined;
|
||||
readonly systemPrompt?: string | null | undefined;
|
||||
readonly tools?: readonly string[] | null | undefined;
|
||||
readonly sortOrder?: number | undefined;
|
||||
}): Promise<{ readonly id: string; readonly roleId: string }> {
|
||||
await this.requireActiveOrganization(input.organizationId);
|
||||
if (!ROLE_ID_PATTERN.test(input.roleId)) throw new Error(`invalid role id: ${input.roleId}`);
|
||||
const label = nonEmpty(input.label, "role label");
|
||||
if (input.tools !== undefined && input.tools !== null) assertSupportedRoleTools([...input.tools]);
|
||||
const sortOrder = input.sortOrder ?? 0;
|
||||
if (!Number.isSafeInteger(sortOrder)) throw new Error("role sortOrder must be an integer");
|
||||
const createTools = input.tools === undefined || input.tools === null
|
||||
? Prisma.DbNull
|
||||
: [...input.tools];
|
||||
const updateTools = input.tools === undefined
|
||||
? undefined
|
||||
: input.tools === null
|
||||
? Prisma.DbNull
|
||||
: [...input.tools];
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const previous = await tx.organizationAgentRole.findUnique({
|
||||
where: { organizationId_roleId: { organizationId: input.organizationId, roleId: input.roleId } },
|
||||
select: { defaultModel: true, systemPrompt: true, tools: true },
|
||||
});
|
||||
const role = await tx.organizationAgentRole.upsert({
|
||||
where: {
|
||||
organizationId_roleId: {
|
||||
organizationId: input.organizationId,
|
||||
roleId: input.roleId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
organizationId: input.organizationId,
|
||||
roleId: input.roleId,
|
||||
label,
|
||||
defaultModel: normalizeOptionalText(input.defaultModel),
|
||||
systemPrompt: normalizeOptionalText(input.systemPrompt),
|
||||
tools: createTools,
|
||||
sortOrder,
|
||||
},
|
||||
update: {
|
||||
label,
|
||||
...(input.defaultModel !== undefined ? { defaultModel: normalizeOptionalText(input.defaultModel) } : {}),
|
||||
...(input.systemPrompt !== undefined ? { systemPrompt: normalizeOptionalText(input.systemPrompt) } : {}),
|
||||
...(updateTools !== undefined ? { tools: updateTools } : {}),
|
||||
sortOrder,
|
||||
disabledAt: null,
|
||||
},
|
||||
select: { id: true, roleId: true },
|
||||
});
|
||||
const executionSurfaceChanged = previous !== null && (
|
||||
(input.defaultModel !== undefined && normalizeOptionalText(input.defaultModel) !== previous.defaultModel) ||
|
||||
(input.systemPrompt !== undefined && normalizeOptionalText(input.systemPrompt) !== previous.systemPrompt) ||
|
||||
(input.tools !== undefined && JSON.stringify(input.tools) !== JSON.stringify(previous.tools))
|
||||
);
|
||||
if (executionSurfaceChanged) await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_role.upserted",
|
||||
metadata: {
|
||||
roleId: input.roleId,
|
||||
label,
|
||||
defaultModel: input.defaultModel === undefined ? "unchanged" : normalizeOptionalText(input.defaultModel),
|
||||
systemPromptConfigured: input.systemPrompt === undefined
|
||||
? "unchanged"
|
||||
: normalizeOptionalText(input.systemPrompt) !== null,
|
||||
tools: input.tools === undefined ? "unchanged" : input.tools === null ? "all" : [...input.tools],
|
||||
sortOrder,
|
||||
},
|
||||
},
|
||||
});
|
||||
return role;
|
||||
});
|
||||
}
|
||||
|
||||
async setRoleSkills(input: {
|
||||
readonly organizationId: string;
|
||||
readonly roleId: string;
|
||||
readonly skillNames: readonly string[];
|
||||
}): Promise<void> {
|
||||
const uniqueNames = new Set(input.skillNames);
|
||||
if (uniqueNames.size !== input.skillNames.length) throw new Error("role skill names must be unique");
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const role = await tx.organizationAgentRole.findUnique({
|
||||
where: {
|
||||
organizationId_roleId: {
|
||||
organizationId: input.organizationId,
|
||||
roleId: input.roleId,
|
||||
},
|
||||
},
|
||||
select: { id: true, disabledAt: true },
|
||||
});
|
||||
if (role === null || role.disabledAt !== null) {
|
||||
throw new Error(`active role not found in organization: ${input.roleId}`);
|
||||
}
|
||||
const skills = await tx.organizationAgentSkill.findMany({
|
||||
where: {
|
||||
organizationId: input.organizationId,
|
||||
name: { in: [...input.skillNames] },
|
||||
disabledAt: null,
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (skills.length !== input.skillNames.length) {
|
||||
const found = new Set(skills.map((skill) => skill.name));
|
||||
const missing = input.skillNames.filter((name) => !found.has(name));
|
||||
throw new Error(`active skills not found in organization: ${missing.join(", ")}`);
|
||||
}
|
||||
const byName = new Map(skills.map((skill) => [skill.name, skill.id]));
|
||||
await tx.organizationAgentRoleSkill.deleteMany({
|
||||
where: { organizationId: input.organizationId, agentRoleId: role.id },
|
||||
});
|
||||
if (input.skillNames.length > 0) {
|
||||
await tx.organizationAgentRoleSkill.createMany({
|
||||
data: input.skillNames.map((name, index) => ({
|
||||
organizationId: input.organizationId,
|
||||
agentRoleId: role.id,
|
||||
agentSkillId: byName.get(name)!,
|
||||
sortOrder: index,
|
||||
})),
|
||||
});
|
||||
}
|
||||
await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
action: "agent_role.skills_set",
|
||||
metadata: { roleId: input.roleId, skillNames: [...input.skillNames] },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async requireActiveOrganization(organizationId: string): Promise<void> {
|
||||
const organization = await this.prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
select: { status: true },
|
||||
});
|
||||
if (organization === null) throw new Error(`organization not found: ${organizationId}`);
|
||||
if (organization.status !== "ACTIVE") {
|
||||
throw new Error(`organization ${organizationId} is ${organization.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveRoleSessions(
|
||||
tx: Prisma.TransactionClient,
|
||||
organizationId: string,
|
||||
roleIds: readonly string[],
|
||||
): Promise<void> {
|
||||
if (roleIds.length === 0) return;
|
||||
await tx.agentSession.updateMany({
|
||||
where: {
|
||||
roleId: { in: [...new Set(roleIds)] },
|
||||
archivedAt: null,
|
||||
project: { organizationId },
|
||||
},
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
function nonEmpty(value: string, label: string): string {
|
||||
const normalized = value.trim();
|
||||
if (normalized === "") throw new Error(`${label} is required`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeOptionalText(value: string | null | undefined): string | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
const normalized = value.trim();
|
||||
return normalized === "" ? null : normalized;
|
||||
}
|
||||
Reference in New Issue
Block a user