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

@@ -26,6 +26,8 @@ model User {
ownedProjects Project[]
groupMemberships GroupMember[]
mcpTokens McpToken[]
ownedAgents Agent[]
chatThreads ChatThread[]
@@index([email])
}
@@ -197,6 +199,7 @@ model Llm {
updatedAt DateTime @updatedAt
apiKeySecret Secret? @relation(fields: [apiKeySecretId], references: [id], onDelete: SetNull)
agents Agent[]
@@index([name])
@@index([tier])
@@ -268,6 +271,7 @@ model Project {
prompts Prompt[]
promptRequests PromptRequest[]
mcpTokens McpToken[]
agents Agent[]
@@index([name])
@@index([ownerId])
@@ -427,6 +431,81 @@ model BackupPending {
@@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 ──
model AuditLog {