feat: web prompt editor + agent personalities #58

Merged
michal merged 6 commits from feat/web-prompt-editor-personalities into main 2026-04-26 20:21:54 +00:00
4 changed files with 347 additions and 86 deletions
Showing only changes of commit f60f00f1fd - Show all commits

View File

@@ -0,0 +1,59 @@
-- Add agent-direct prompts and per-agent personalities (with personality<->prompt join).
-- ── Prompt: optional agentId, mirrors projectId pattern ──
ALTER TABLE "Prompt" ADD COLUMN "agentId" TEXT;
CREATE UNIQUE INDEX "Prompt_name_agentId_key" ON "Prompt"("name", "agentId");
CREATE INDEX "Prompt_agentId_idx" ON "Prompt"("agentId");
ALTER TABLE "Prompt" ADD CONSTRAINT "Prompt_agentId_fkey"
FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- ── Agent: defaultPersonalityId (optional FK; SET NULL on delete to keep agent alive) ──
ALTER TABLE "Agent" ADD COLUMN "defaultPersonalityId" TEXT;
CREATE INDEX "Agent_defaultPersonalityId_idx" ON "Agent"("defaultPersonalityId");
-- ── Personality table ──
CREATE TABLE "Personality" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"agentId" TEXT NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 5,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Personality_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Personality_name_agentId_key" ON "Personality"("name", "agentId");
CREATE INDEX "Personality_agentId_idx" ON "Personality"("agentId");
ALTER TABLE "Personality" ADD CONSTRAINT "Personality_agentId_fkey"
FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Now that Personality exists, the Agent.defaultPersonalityId FK can point at it.
ALTER TABLE "Agent" ADD CONSTRAINT "Agent_defaultPersonalityId_fkey"
FOREIGN KEY ("defaultPersonalityId") REFERENCES "Personality"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- ── PersonalityPrompt join table ──
CREATE TABLE "PersonalityPrompt" (
"id" TEXT NOT NULL,
"personalityId" TEXT NOT NULL,
"promptId" TEXT NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 5,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PersonalityPrompt_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "PersonalityPrompt_personalityId_promptId_key" ON "PersonalityPrompt"("personalityId", "promptId");
CREATE INDEX "PersonalityPrompt_personalityId_idx" ON "PersonalityPrompt"("personalityId");
CREATE INDEX "PersonalityPrompt_promptId_idx" ON "PersonalityPrompt"("promptId");
ALTER TABLE "PersonalityPrompt" ADD CONSTRAINT "PersonalityPrompt_personalityId_fkey"
FOREIGN KEY ("personalityId") REFERENCES "Personality"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PersonalityPrompt" ADD CONSTRAINT "PersonalityPrompt_promptId_fkey"
FOREIGN KEY ("promptId") REFERENCES "Prompt"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -356,6 +356,7 @@ model Prompt {
name String name String
content String @db.Text content String @db.Text
projectId String? projectId String?
agentId String?
priority Int @default(5) priority Int @default(5)
summary String? @db.Text summary String? @db.Text
chapters Json? chapters Json?
@@ -365,9 +366,13 @@ model Prompt {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade)
personalities PersonalityPrompt[]
@@unique([name, projectId]) @@unique([name, projectId])
@@unique([name, agentId])
@@index([projectId]) @@index([projectId])
@@index([agentId])
} }
// ── Prompt Requests (pending proposals from LLM sessions) ── // ── Prompt Requests (pending proposals from LLM sessions) ──
@@ -445,6 +450,7 @@ model Agent {
systemPrompt String @default("") @db.Text // agent persona systemPrompt String @default("") @db.Text // agent persona
llmId String llmId String
projectId String? projectId String?
defaultPersonalityId String? // applied at chat time when no --personality flag
proxyModelName String? // optional informational override proxyModelName String? // optional informational override
defaultParams Json @default("{}") // LiteLLM-style: temperature, top_p, top_k, max_tokens, stop, ... defaultParams Json @default("{}") // LiteLLM-style: temperature, top_p, top_k, max_tokens, stop, ...
extras Json @default("{}") // future LoRA / tool-allowlist extras Json @default("{}") // future LoRA / tool-allowlist
@@ -457,11 +463,61 @@ model Agent {
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
threads ChatThread[] threads ChatThread[]
prompts Prompt[]
personalities Personality[] @relation("AgentPersonalities")
defaultPersonality Personality? @relation("AgentDefaultPersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull)
@@index([name]) @@index([name])
@@index([llmId]) @@index([llmId])
@@index([projectId]) @@index([projectId])
@@index([ownerId]) @@index([ownerId])
@@index([defaultPersonalityId])
}
// ── Personalities (named overlay bundles of prompts on top of an Agent) ──
//
// VLAN-on-ethernet semantics: an Agent works without a Personality (today's
// flow). Selecting a Personality adds its bound Prompts to the system block
// after the Agent's own systemPrompt, agent-direct prompts, and project
// prompts — additive, never replacing.
model Personality {
id String @id @default(cuid())
name String
description String @default("")
agentId String
priority Int @default(5)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agent Agent @relation("AgentPersonalities", fields: [agentId], references: [id], onDelete: Cascade)
prompts PersonalityPrompt[]
defaultForAgent Agent[] @relation("AgentDefaultPersonality")
@@unique([name, agentId])
@@index([agentId])
}
// ── Personality ↔ Prompt join table ──
//
// A Prompt can be bound to many Personalities; a Personality can bind many
// Prompts. The `priority` here overrides the Prompt's own priority *within
// this Personality's overlay slice* (so the same prompt can be high-priority
// in one personality and low in another).
model PersonalityPrompt {
id String @id @default(cuid())
personalityId String
promptId String
priority Int @default(5)
createdAt DateTime @default(now())
personality Personality @relation(fields: [personalityId], references: [id], onDelete: Cascade)
prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
@@unique([personalityId, promptId])
@@index([personalityId])
@@index([promptId])
} }
// ── Chat Threads (persisted conversation per Agent) ── // ── Chat Threads (persisted conversation per Agent) ──

View File

@@ -201,4 +201,146 @@ describe('agent / chat-thread / chat-message schema', () => {
}); });
expect(ordered.map((t) => t.id)).toEqual([t2.id, t1.id]); expect(ordered.map((t) => t.id)).toEqual([t2.id, t1.id]);
}); });
// ── Agent-direct prompts (Prompt.agentId) ──
it('attaches a prompt directly to an agent (no project)', async () => {
const user = await makeUser();
const llm = await makeLlm('llm-agent-prompt');
const agent = await makeAgent({ name: 'directprompt', llmId: llm.id, ownerId: user.id });
const prompt = await prisma.prompt.create({
data: { name: 'agent-only', content: 'hi from agent', agentId: agent.id },
});
expect(prompt.agentId).toBe(agent.id);
expect(prompt.projectId).toBeNull();
});
it('cascade-deletes agent-direct prompts when the agent is removed', async () => {
const user = await makeUser();
const llm = await makeLlm('llm-agent-prompt-cascade');
const agent = await makeAgent({ name: 'goingaway', llmId: llm.id, ownerId: user.id });
await prisma.prompt.create({
data: { name: 'p1', content: 'c', agentId: agent.id },
});
await prisma.agent.delete({ where: { id: agent.id } });
expect(await prisma.prompt.count({ where: { agentId: agent.id } })).toBe(0);
});
it('enforces (name, agentId) uniqueness independently of (name, projectId)', async () => {
const user = await makeUser();
const llm = await makeLlm('llm-uniq-prompt-agent');
const agent = await makeAgent({ name: 'has-prompts', llmId: llm.id, ownerId: user.id });
await prisma.prompt.create({
data: { name: 'shared', content: 'a', agentId: agent.id },
});
await expect(
prisma.prompt.create({
data: { name: 'shared', content: 'b', agentId: agent.id },
}),
).rejects.toThrow();
// Same name on a different agent is fine.
const agent2 = await makeAgent({ name: 'other-agent', llmId: llm.id, ownerId: user.id });
const ok = await prisma.prompt.create({
data: { name: 'shared', content: 'c', agentId: agent2.id },
});
expect(ok.id).toBeDefined();
});
// ── Personalities ──
it('creates a personality bound to an agent', async () => {
const user = await makeUser();
const llm = await makeLlm('llm-pers-1');
const agent = await makeAgent({ name: 'with-pers', llmId: llm.id, ownerId: user.id });
const p = await prisma.personality.create({
data: { name: 'grumpy', description: 'curmudgeonly', agentId: agent.id },
});
expect(p.id).toBeDefined();
expect(p.priority).toBe(5);
});
it('enforces (name, agentId) uniqueness on personalities', async () => {
const user = await makeUser();
const llm = await makeLlm('llm-pers-uniq');
const agent = await makeAgent({ name: 'pers-uniq', llmId: llm.id, ownerId: user.id });
await prisma.personality.create({ data: { name: 'mode', agentId: agent.id } });
await expect(
prisma.personality.create({ data: { name: 'mode', agentId: agent.id } }),
).rejects.toThrow();
});
it('cascade-deletes personalities + bindings when the agent is removed', async () => {
const user = await makeUser();
const llm = await makeLlm('llm-pers-cascade');
const agent = await makeAgent({ name: 'kaboom', llmId: llm.id, ownerId: user.id });
const personality = await prisma.personality.create({
data: { name: 'snarky', agentId: agent.id },
});
const prompt = await prisma.prompt.create({
data: { name: 'tone', content: 'be snarky', agentId: agent.id },
});
await prisma.personalityPrompt.create({
data: { personalityId: personality.id, promptId: prompt.id },
});
await prisma.agent.delete({ where: { id: agent.id } });
expect(await prisma.personality.count({ where: { id: personality.id } })).toBe(0);
expect(await prisma.personalityPrompt.count({ where: { personalityId: personality.id } })).toBe(0);
// Prompts directly attached to the agent also go via Prompt.agentId cascade.
expect(await prisma.prompt.count({ where: { id: prompt.id } })).toBe(0);
});
it('SetNull on Agent.defaultPersonalityId when the personality is deleted', async () => {
const user = await makeUser();
const llm = await makeLlm('llm-default-pers');
const agent = await makeAgent({ name: 'has-default', llmId: llm.id, ownerId: user.id });
const personality = await prisma.personality.create({
data: { name: 'def', agentId: agent.id },
});
await prisma.agent.update({
where: { id: agent.id },
data: { defaultPersonalityId: personality.id },
});
await prisma.personality.delete({ where: { id: personality.id } });
const reloaded = await prisma.agent.findUnique({ where: { id: agent.id } });
expect(reloaded?.defaultPersonalityId).toBeNull();
});
it('binds the same prompt to multiple personalities of an agent', async () => {
const user = await makeUser();
const llm = await makeLlm('llm-shared-prompt');
const agent = await makeAgent({ name: 'shared', llmId: llm.id, ownerId: user.id });
const p1 = await prisma.personality.create({ data: { name: 'a', agentId: agent.id } });
const p2 = await prisma.personality.create({ data: { name: 'b', agentId: agent.id } });
const prompt = await prisma.prompt.create({
data: { name: 'shared-text', content: 'reusable', agentId: agent.id },
});
await prisma.personalityPrompt.create({
data: { personalityId: p1.id, promptId: prompt.id, priority: 9 },
});
await prisma.personalityPrompt.create({
data: { personalityId: p2.id, promptId: prompt.id, priority: 1 },
});
expect(await prisma.personalityPrompt.count({ where: { promptId: prompt.id } })).toBe(2);
// Same (personalityId, promptId) cannot collide.
await expect(
prisma.personalityPrompt.create({
data: { personalityId: p1.id, promptId: prompt.id },
}),
).rejects.toThrow();
});
}); });

View File

@@ -32,6 +32,10 @@ export async function clearAllTables(client: PrismaClient): Promise<void> {
await client.auditLog.deleteMany(); await client.auditLog.deleteMany();
await client.chatMessage.deleteMany(); await client.chatMessage.deleteMany();
await client.chatThread.deleteMany(); await client.chatThread.deleteMany();
await client.personalityPrompt.deleteMany();
// Break Agent.defaultPersonalityId before personalities can be removed.
await client.agent.updateMany({ data: { defaultPersonalityId: null } });
await client.personality.deleteMany();
await client.agent.deleteMany(); await client.agent.deleteMany();
await client.llm.deleteMany(); await client.llm.deleteMany();
await client.mcpInstance.deleteMany(); await client.mcpInstance.deleteMany();