From f60f00f1fd08d29f1374679f235bd27ca7761ac2 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 26 Apr 2026 19:12:22 +0100 Subject: [PATCH] feat(db): add personalities + agent-direct prompts schema (Stage 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Personality is a named overlay on top of an Agent — same agent, same LLM, but a different bundle of prompts injected into the system block at chat time. VLAN-on-ethernet semantics: ethernet still works without VLAN; with a VLAN tag, frames are segmented but still ethernet. Schema additions: - Prompt.agentId (nullable FK + index, cascade on delete) so prompts can attach directly to an agent without going through a project. - Personality { id, name, description, agentId, priority } with unique (name, agentId). - PersonalityPrompt join table with per-binding priority override. - Agent.defaultPersonalityId (SetNull on delete) so an agent can pick one personality as the default when no --personality flag is passed. Backwards-compatible by construction: every new column is nullable; existing rows are valid as-is; the chat.service systemBlock changes land in Stage 3. 8 new prisma-level assertions in agent-schema.test.ts cover unique constraints, cascade behavior, the SetNull on defaultPersonalityId, and shared-prompt-across-personalities. All 16 db tests pass; mcpd typecheck + 777 mcpd unit tests still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migration.sql | 59 +++++ src/db/prisma/schema.prisma | 228 +++++++++++------- src/db/tests/agent-schema.test.ts | 142 +++++++++++ src/db/tests/helpers.ts | 4 + 4 files changed, 347 insertions(+), 86 deletions(-) create mode 100644 src/db/prisma/migrations/20260426180927_add_personalities_and_agent_prompts/migration.sql diff --git a/src/db/prisma/migrations/20260426180927_add_personalities_and_agent_prompts/migration.sql b/src/db/prisma/migrations/20260426180927_add_personalities_and_agent_prompts/migration.sql new file mode 100644 index 0000000..bdb244b --- /dev/null +++ b/src/db/prisma/migrations/20260426180927_add_personalities_and_agent_prompts/migration.sql @@ -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; diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 4089f87..a52f1ae 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -21,13 +21,13 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - sessions Session[] - auditLogs AuditLog[] - ownedProjects Project[] - groupMemberships GroupMember[] - mcpTokens McpToken[] - ownedAgents Agent[] - chatThreads ChatThread[] + sessions Session[] + auditLogs AuditLog[] + ownedProjects Project[] + groupMemberships GroupMember[] + mcpTokens McpToken[] + ownedAgents Agent[] + chatThreads ChatThread[] @@index([email]) } @@ -56,23 +56,23 @@ model Session { // ── MCP Servers ── model McpServer { - id String @id @default(cuid()) - name String @unique - description String @default("") + id String @id @default(cuid()) + name String @unique + description String @default("") packageName String? runtime String? dockerImage String? - transport Transport @default(STDIO) + transport Transport @default(STDIO) repositoryUrl String? externalUrl String? command Json? containerPort Int? - replicas Int @default(1) - env Json @default("[]") + replicas Int @default(1) + env Json @default("[]") healthCheck Json? - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt templateName String? templateVersion String? @@ -125,15 +125,15 @@ model McpTemplate { model SecretBackend { id String @id @default(cuid()) name String @unique - type String // plaintext | openbao | (future: vault, aws-sm, ...) - config Json @default("{}") // type-specific: url, mount, namespace, tokenSecretRef + type String // plaintext | openbao | (future: vault, aws-sm, ...) + config Json @default("{}") // type-specific: url, mount, namespace, tokenSecretRef // Runtime metadata for auto-rotating backend credentials (openbao token // auth). Fields: generatedAt, nextRenewalAt, validUntil, lastRotationAt, // lastRotationError, rotatable (true only for wizard-provisioned tokens). // Empty object for backends that don't use rotation (plaintext, kubernetes // auth, or static tokens). Managed entirely by the rotator service. tokenMeta Json @default("{}") - isDefault Boolean @default(false) // exactly one row has isDefault=true + isDefault Boolean @default(false) // exactly one row has isDefault=true description String @default("") version Int @default(1) createdAt DateTime @default(now()) @@ -156,8 +156,8 @@ model Secret { // on next mcpd startup. New rows written by SecretService always carry a // valid FK immediately. backendId String @default("") - data Json @default("{}") // populated by plaintext backend only - externalRef String @default("") // populated by non-plaintext backends (e.g. "mount/path#v3") + data Json @default("{}") // populated by plaintext backend only + externalRef String @default("") // populated by non-plaintext backends (e.g. "mount/path#v3") // Sorted list of the secret's data keys WITHOUT their values. Populated on // every create/update/migrate so list views and describe-without-reveal can // show "this secret has GRAFANA_URL + GRAFANA_TOKEN" without fetching the @@ -186,14 +186,14 @@ model Secret { model Llm { id String @id @default(cuid()) name String @unique - type String // anthropic | openai | deepseek | vllm | ollama | gemini-cli - model String // e.g. claude-3-5-sonnet-20241022 - url String @default("") // endpoint (empty for provider default) - tier String @default("fast") // fast | heavy + type String // anthropic | openai | deepseek | vllm | ollama | gemini-cli + model String // e.g. claude-3-5-sonnet-20241022 + url String @default("") // endpoint (empty for provider default) + tier String @default("fast") // fast | heavy description String @default("") - apiKeySecretId String? // FK to Secret - apiKeySecretKey String? // key inside the Secret's data - extraConfig Json @default("{}") // per-type extras + apiKeySecretId String? // FK to Secret + apiKeySecretKey String? // key inside the Secret's data + extraConfig Json @default("{}") // per-type extras version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -252,19 +252,19 @@ model RbacDefinition { // ── Projects ── model Project { - id String @id @default(cuid()) - name String @unique - description String @default("") - prompt String @default("") - proxyModel String @default("") - gated Boolean @default(true) + id String @id @default(cuid()) + name String @unique + description String @default("") + prompt String @default("") + proxyModel String @default("") + gated Boolean @default(true) llmProvider String? llmModel String? serverOverrides Json? ownerId String - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) servers ProjectServer[] @@ -322,11 +322,11 @@ model McpToken { // ── MCP Instances (running containers) ── model McpInstance { - id String @id @default(cuid()) - serverId String - containerId String? - status InstanceStatus @default(STOPPED) - port Int? + id String @id @default(cuid()) + serverId String + containerId String? + status InstanceStatus @default(STOPPED) + port Int? metadata Json @default("{}") healthStatus String? lastHealthCheck DateTime? @@ -356,6 +356,7 @@ model Prompt { name String content String @db.Text projectId String? + agentId String? priority Int @default(5) summary String? @db.Text chapters Json? @@ -364,10 +365,14 @@ model Prompt { createdAt DateTime @default(now()) 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, agentId]) @@index([projectId]) + @@index([agentId]) } // ── Prompt Requests (pending proposals from LLM sessions) ── @@ -392,21 +397,21 @@ model PromptRequest { // ── Audit Events (pipeline/gate/tool trace from mcplocal) ── model AuditEvent { - id String @id @default(cuid()) - timestamp DateTime - sessionId String - projectName String - eventKind String - source String - verified Boolean @default(false) - serverName String? - correlationId String? - parentEventId String? - userName String? - tokenName String? - tokenSha String? - payload Json - createdAt DateTime @default(now()) + id String @id @default(cuid()) + timestamp DateTime + sessionId String + projectName String + eventKind String + source String + verified Boolean @default(false) + serverName String? + correlationId String? + parentEventId String? + userName String? + tokenName String? + tokenSha String? + payload Json + createdAt DateTime @default(now()) @@index([sessionId]) @@index([projectName]) @@ -423,7 +428,7 @@ model BackupPending { id String @id @default(cuid()) resourceKind String resourceName String - action String // 'create' | 'update' | 'delete' + action String // 'create' | 'update' | 'delete' userName String yamlContent String? @db.Text createdAt DateTime @default(now()) @@ -439,29 +444,80 @@ model BackupPending { // Per-call LiteLLM-style overrides stack on top of `defaultParams`. model Agent { - id String @id @default(cuid()) - name String @unique - description String @default("") // shown in MCP tools/list - systemPrompt String @default("") @db.Text // agent persona - llmId String - projectId String? - proxyModelName String? // optional informational override - defaultParams Json @default("{}") // LiteLLM-style: temperature, top_p, top_k, max_tokens, stop, ... - extras Json @default("{}") // future LoRA / tool-allowlist - ownerId String - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String @unique + description String @default("") // shown in MCP tools/list + systemPrompt String @default("") @db.Text // agent persona + llmId String + projectId String? + defaultPersonalityId String? // applied at chat time when no --personality flag + proxyModelName String? // optional informational override + defaultParams Json @default("{}") // LiteLLM-style: temperature, top_p, top_k, max_tokens, stop, ... + extras Json @default("{}") // future LoRA / tool-allowlist + ownerId String + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - llm Llm @relation(fields: [llmId], references: [id], onDelete: Restrict) - project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) - threads ChatThread[] + llm Llm @relation(fields: [llmId], references: [id], onDelete: Restrict) + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + threads ChatThread[] + prompts Prompt[] + personalities Personality[] @relation("AgentPersonalities") + defaultPersonality Personality? @relation("AgentDefaultPersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull) @@index([name]) @@index([llmId]) @@index([projectId]) @@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) ── @@ -493,11 +549,11 @@ model ChatMessage { id String @id @default(cuid()) threadId String turnIndex Int - role String // 'system' | 'user' | 'assistant' | 'tool' + role String // 'system' | 'user' | 'assistant' | 'tool' content String @db.Text - toolCalls Json? // assistant turn: [{id,name,arguments}] - toolCallId String? // tool turn: which call this answers - status String @default("complete") // 'pending' | 'complete' | 'error' + toolCalls Json? // assistant turn: [{id,name,arguments}] + toolCallId String? // tool turn: which call this answers + status String @default("complete") // 'pending' | 'complete' | 'error' createdAt DateTime @default(now()) thread ChatThread @relation(fields: [threadId], references: [id], onDelete: Cascade) @@ -509,13 +565,13 @@ model ChatMessage { // ── Audit Logs ── model AuditLog { - id String @id @default(cuid()) - userId String - action String - resource String + id String @id @default(cuid()) + userId String + action String + resource String resourceId String? - details Json @default("{}") - createdAt DateTime @default(now()) + details Json @default("{}") + createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/db/tests/agent-schema.test.ts b/src/db/tests/agent-schema.test.ts index 0761735..4fa3b0c 100644 --- a/src/db/tests/agent-schema.test.ts +++ b/src/db/tests/agent-schema.test.ts @@ -201,4 +201,146 @@ describe('agent / chat-thread / chat-message schema', () => { }); 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(); + }); }); diff --git a/src/db/tests/helpers.ts b/src/db/tests/helpers.ts index ed742a2..7083b75 100644 --- a/src/db/tests/helpers.ts +++ b/src/db/tests/helpers.ts @@ -32,6 +32,10 @@ export async function clearAllTables(client: PrismaClient): Promise { await client.auditLog.deleteMany(); await client.chatMessage.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.llm.deleteMany(); await client.mcpInstance.deleteMany();