feat(agents): add Agent + ChatThread + ChatMessage schema (Stage 1)

Introduces the persistence layer for the upcoming Agent feature: an LLM
persona pinned to a specific Llm, optionally attached to a Project, with
persisted chat threads/messages so conversations survive REPL exits.

Constraint shape:
- Agent.llm uses ON DELETE RESTRICT — deleting an Llm in active use fails.
- Agent.project uses ON DELETE SET NULL — agents survive project deletion.
- ChatThread → ChatMessage cascade so deleting an agent purges its history.
- ChatMessage @@unique([threadId, turnIndex]) gives append ordering even
  under racing writers (services retry on collision).

LiteLLM-style per-call overrides will live in Agent.defaultParams (Json);
the loose extras Json field is reserved for future LoRA/tool-allowlist work.

Pinned vitest fileParallelism=false in @mcpctl/db: all suites share the
same Postgres, and adding a second suite exposed FK contention between a
clearAllTables in one file and a create in another. Per-test isolation
still comes from beforeEach.

Tests: 8/8 green in src/db/tests/agent-schema.test.ts (defaults, name
uniqueness, llm-in-use Restrict, project-delete SetNull, agent-delete
cascade, duplicate (threadId, turnIndex) blocked, tool-call payload
round-trip, lastTurnAt DESC ordering).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-25 16:29:55 +01:00
parent 6ac79de8a4
commit 3726a65f53
5 changed files with 383 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
-- CreateTable
CREATE TABLE "Agent" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"systemPrompt" TEXT NOT NULL DEFAULT '',
"llmId" TEXT NOT NULL,
"projectId" TEXT,
"proxyModelName" TEXT,
"defaultParams" JSONB NOT NULL DEFAULT '{}',
"extras" JSONB NOT NULL DEFAULT '{}',
"ownerId" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Agent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatThread" (
"id" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"title" TEXT NOT NULL DEFAULT '',
"lastTurnAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ChatThread_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatMessage" (
"id" TEXT NOT NULL,
"threadId" TEXT NOT NULL,
"turnIndex" INTEGER NOT NULL,
"role" TEXT NOT NULL,
"content" TEXT NOT NULL,
"toolCalls" JSONB,
"toolCallId" TEXT,
"status" TEXT NOT NULL DEFAULT 'complete',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ChatMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Agent_name_key" ON "Agent"("name");
-- CreateIndex
CREATE INDEX "Agent_name_idx" ON "Agent"("name");
-- CreateIndex
CREATE INDEX "Agent_llmId_idx" ON "Agent"("llmId");
-- CreateIndex
CREATE INDEX "Agent_projectId_idx" ON "Agent"("projectId");
-- CreateIndex
CREATE INDEX "Agent_ownerId_idx" ON "Agent"("ownerId");
-- CreateIndex
CREATE INDEX "ChatThread_agentId_lastTurnAt_idx" ON "ChatThread"("agentId", "lastTurnAt" DESC);
-- CreateIndex
CREATE INDEX "ChatThread_ownerId_idx" ON "ChatThread"("ownerId");
-- CreateIndex
CREATE INDEX "ChatMessage_threadId_createdAt_idx" ON "ChatMessage"("threadId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "ChatMessage_threadId_turnIndex_key" ON "ChatMessage"("threadId", "turnIndex");
-- AddForeignKey
ALTER TABLE "Agent" ADD CONSTRAINT "Agent_llmId_fkey" FOREIGN KEY ("llmId") REFERENCES "Llm"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Agent" ADD CONSTRAINT "Agent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Agent" ADD CONSTRAINT "Agent_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatThread" ADD CONSTRAINT "ChatThread_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatThread" ADD CONSTRAINT "ChatThread_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "ChatThread"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -26,6 +26,8 @@ model User {
ownedProjects Project[] ownedProjects Project[]
groupMemberships GroupMember[] groupMemberships GroupMember[]
mcpTokens McpToken[] mcpTokens McpToken[]
ownedAgents Agent[]
chatThreads ChatThread[]
@@index([email]) @@index([email])
} }
@@ -197,6 +199,7 @@ model Llm {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
apiKeySecret Secret? @relation(fields: [apiKeySecretId], references: [id], onDelete: SetNull) apiKeySecret Secret? @relation(fields: [apiKeySecretId], references: [id], onDelete: SetNull)
agents Agent[]
@@index([name]) @@index([name])
@@index([tier]) @@index([tier])
@@ -268,6 +271,7 @@ model Project {
prompts Prompt[] prompts Prompt[]
promptRequests PromptRequest[] promptRequests PromptRequest[]
mcpTokens McpToken[] mcpTokens McpToken[]
agents Agent[]
@@index([name]) @@index([name])
@@index([ownerId]) @@index([ownerId])
@@ -427,6 +431,81 @@ model BackupPending {
@@index([createdAt]) @@index([createdAt])
} }
// ── Agents (LLM personas pinned to a specific Llm) ──
//
// Agents combine a system prompt, a pinned LLM, and (optionally) a project to
// inherit Prompts from. Each Agent is also exposed by mcplocal as a virtual
// MCP server (`agent-<name>/chat`), so other clients can consult it as a tool.
// 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
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[]
@@index([name])
@@index([llmId])
@@index([projectId])
@@index([ownerId])
}
// ── Chat Threads (persisted conversation per Agent) ──
model ChatThread {
id String @id @default(cuid())
agentId String
ownerId String
title String @default("")
lastTurnAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
messages ChatMessage[]
@@index([agentId, lastTurnAt(sort: Desc)])
@@index([ownerId])
}
// ── Chat Messages ──
//
// `turnIndex` is monotonic per thread; the @@unique enforces ordering even
// under racing appends (callers retry on collision). `status` stays `pending`
// until the orchestrator confirms the turn completed successfully.
model ChatMessage {
id String @id @default(cuid())
threadId String
turnIndex Int
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'
createdAt DateTime @default(now())
thread ChatThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
@@unique([threadId, turnIndex])
@@index([threadId, createdAt])
}
// ── Audit Logs ── // ── Audit Logs ──
model AuditLog { model AuditLog {

View File

@@ -0,0 +1,204 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js';
describe('agent / chat-thread / chat-message schema', () => {
let prisma: PrismaClient;
beforeAll(async () => {
prisma = await setupTestDb();
}, 30_000);
afterAll(async () => {
await cleanupTestDb();
});
beforeEach(async () => {
await clearAllTables(prisma);
});
async function makeUser(suffix = '') {
return prisma.user.create({
data: {
email: `agent-test-${Date.now()}${suffix}@example.com`,
name: 'Agent Tester',
passwordHash: 'x',
},
});
}
async function makeLlm(name: string) {
return prisma.llm.create({
data: { name, type: 'openai', model: 'qwen3-thinking' },
});
}
async function makeProject(ownerId: string, name: string) {
return prisma.project.create({ data: { name, ownerId } });
}
async function makeAgent(opts: {
name: string;
llmId: string;
ownerId: string;
projectId?: string;
}) {
return prisma.agent.create({
data: {
name: opts.name,
llmId: opts.llmId,
ownerId: opts.ownerId,
projectId: opts.projectId ?? null,
},
});
}
it('creates an agent with required fields and JSON defaults', async () => {
const user = await makeUser();
const llm = await makeLlm('llm-default-fields');
const agent = await makeAgent({ name: 'a1', llmId: llm.id, ownerId: user.id });
expect(agent.id).toBeDefined();
expect(agent.description).toBe('');
expect(agent.systemPrompt).toBe('');
expect(agent.defaultParams).toEqual({});
expect(agent.extras).toEqual({});
expect(agent.version).toBe(1);
});
it('enforces unique agent name', async () => {
const user = await makeUser();
const llm = await makeLlm('llm-uniq');
await makeAgent({ name: 'dup', llmId: llm.id, ownerId: user.id });
await expect(
makeAgent({ name: 'dup', llmId: llm.id, ownerId: user.id }),
).rejects.toThrow();
});
it('blocks deleting an Llm referenced by an agent (Restrict)', async () => {
const user = await makeUser();
const llm = await makeLlm('llm-in-use');
await makeAgent({ name: 'pinned', llmId: llm.id, ownerId: user.id });
await expect(prisma.llm.delete({ where: { id: llm.id } })).rejects.toThrow();
});
it('sets agent.projectId NULL when its Project is deleted (SetNull)', async () => {
const user = await makeUser();
const llm = await makeLlm('llm-setnull');
const project = await makeProject(user.id, 'proj-detach');
const agent = await makeAgent({
name: 'detachable',
llmId: llm.id,
ownerId: user.id,
projectId: project.id,
});
expect(agent.projectId).toBe(project.id);
await prisma.project.delete({ where: { id: project.id } });
const reloaded = await prisma.agent.findUnique({ where: { id: agent.id } });
expect(reloaded?.projectId).toBeNull();
});
it('cascades thread + message delete when an Agent is deleted', async () => {
const user = await makeUser();
const llm = await makeLlm('llm-cascade');
const agent = await makeAgent({ name: 'doomed', llmId: llm.id, ownerId: user.id });
const thread = await prisma.chatThread.create({
data: { agentId: agent.id, ownerId: user.id, title: 't' },
});
await prisma.chatMessage.create({
data: {
threadId: thread.id,
turnIndex: 0,
role: 'user',
content: 'hello',
},
});
await prisma.agent.delete({ where: { id: agent.id } });
expect(await prisma.chatThread.findUnique({ where: { id: thread.id } })).toBeNull();
expect(await prisma.chatMessage.count({ where: { threadId: thread.id } })).toBe(0);
});
it('blocks duplicate (threadId, turnIndex)', async () => {
const user = await makeUser();
const llm = await makeLlm('llm-turn-uniq');
const agent = await makeAgent({ name: 'orderly', llmId: llm.id, ownerId: user.id });
const thread = await prisma.chatThread.create({
data: { agentId: agent.id, ownerId: user.id },
});
await prisma.chatMessage.create({
data: { threadId: thread.id, turnIndex: 0, role: 'user', content: 'a' },
});
await expect(
prisma.chatMessage.create({
data: { threadId: thread.id, turnIndex: 0, role: 'assistant', content: 'b' },
}),
).rejects.toThrow();
});
it('persists tool-call shape on assistant + tool turns', async () => {
const user = await makeUser();
const llm = await makeLlm('llm-tools');
const agent = await makeAgent({ name: 'toolish', llmId: llm.id, ownerId: user.id });
const thread = await prisma.chatThread.create({
data: { agentId: agent.id, ownerId: user.id },
});
await prisma.chatMessage.create({
data: { threadId: thread.id, turnIndex: 0, role: 'user', content: 'do x' },
});
await prisma.chatMessage.create({
data: {
threadId: thread.id,
turnIndex: 1,
role: 'assistant',
content: '',
toolCalls: [
{ id: 'call_1', name: 'do_thing', arguments: { x: 1 } },
],
status: 'pending',
},
});
await prisma.chatMessage.create({
data: {
threadId: thread.id,
turnIndex: 2,
role: 'tool',
content: 'ok',
toolCallId: 'call_1',
},
});
const messages = await prisma.chatMessage.findMany({
where: { threadId: thread.id },
orderBy: { turnIndex: 'asc' },
});
expect(messages).toHaveLength(3);
expect(messages[1]?.toolCalls).toEqual([
{ id: 'call_1', name: 'do_thing', arguments: { x: 1 } },
]);
expect(messages[2]?.toolCallId).toBe('call_1');
});
it('orders threads by lastTurnAt DESC for an agent', async () => {
const user = await makeUser();
const llm = await makeLlm('llm-order');
const agent = await makeAgent({ name: 'history', llmId: llm.id, ownerId: user.id });
const t1 = await prisma.chatThread.create({
data: { agentId: agent.id, ownerId: user.id, lastTurnAt: new Date(2000, 0, 1) },
});
const t2 = await prisma.chatThread.create({
data: { agentId: agent.id, ownerId: user.id, lastTurnAt: new Date(2030, 0, 1) },
});
const ordered = await prisma.chatThread.findMany({
where: { agentId: agent.id },
orderBy: { lastTurnAt: 'desc' },
});
expect(ordered.map((t) => t.id)).toEqual([t2.id, t1.id]);
});
});

View File

@@ -30,6 +30,10 @@ export async function clearAllTables(client: PrismaClient): Promise<void> {
// Delete in order respecting foreign keys // Delete in order respecting foreign keys
await client.auditEvent.deleteMany(); await client.auditEvent.deleteMany();
await client.auditLog.deleteMany(); await client.auditLog.deleteMany();
await client.chatMessage.deleteMany();
await client.chatThread.deleteMany();
await client.agent.deleteMany();
await client.llm.deleteMany();
await client.mcpInstance.deleteMany(); await client.mcpInstance.deleteMany();
await client.promptRequest.deleteMany(); await client.promptRequest.deleteMany();
await client.prompt.deleteMany(); await client.prompt.deleteMany();

View File

@@ -6,5 +6,10 @@ export default defineConfig({
include: ['tests/**/*.test.ts'], include: ['tests/**/*.test.ts'],
// Schema pushed once by globalSetup before any tests. // Schema pushed once by globalSetup before any tests.
globalSetup: ['tests/global-setup.ts'], globalSetup: ['tests/global-setup.ts'],
// All test files share one Postgres database. Running them in parallel
// causes cross-file FK contention (e.g. one file's beforeEach clears Llm
// while another file is mid-create). Per-test isolation still comes from
// beforeEach; this just serializes files.
fileParallelism: false,
}, },
}); });