feat(db): add personalities + agent-direct prompts schema (Stage 1)

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) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-26 19:12:22 +01:00
parent 9389ffff3c
commit f60f00f1fd
4 changed files with 347 additions and 86 deletions

View File

@@ -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)