feat(mcpd): personality routes + chat system block overlay (Stage 3)

End-to-end backend wiring for the agents-feature evolution. After
this stage you can curl all the endpoints; CLI + Web UI follow.

Routes (new):
  GET    /api/v1/agents/:agentName/personalities
  POST   /api/v1/agents/:agentName/personalities
  GET    /api/v1/personalities/:id
  PUT    /api/v1/personalities/:id
  DELETE /api/v1/personalities/:id
  GET    /api/v1/personalities/:id/prompts
  POST   /api/v1/personalities/:id/prompts
  DELETE /api/v1/personalities/:id/prompts/:promptId
  GET    /api/v1/agents/:agentName/prompts            (agent-direct)

Routes (extended):
  POST /api/v1/prompts now resolves `agent: <name>` like `project: <name>`
  POST /api/v1/agents/:name/chat accepts `personality: <name>`

RBAC: `personalities` segment maps to the `agents` resource so
view/edit/create/delete on the parent agent governs personality access.
No new RBAC roles — piggybacking keeps the surface flat.

System block (chat.service.ts):
  agent.systemPrompt
  + agent-direct prompts (Prompt.agentId === agent.id, priority desc)
  + project prompts        (existing behavior, priority desc)
  + personality prompts    (PersonalityPrompt[chosen], priority desc)
  + systemAppend

Personality is selected by request body `personality: <name>`, falling
back to `agent.defaultPersonalityId` if unset. A typo'd flag throws
404 rather than silently dropping back to no overlay — failing loudly
on misconfiguration is the only way users learn it didn't apply.

Backwards-compatible by construction: when no agent-direct prompts
exist and no personality is selected, the resulting block is byte-
identical to the old layout (verified by a regression test).

Tests: 5 new chat-service.test cases cover ordering, default-
personality fallback, missing-personality 404, and the regression
guard. mcpd suite: 801/801 (was 796). Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-26 19:27:59 +01:00
parent 6b5bd78cfa
commit faef1e732d
7 changed files with 495 additions and 8 deletions

View File

@@ -6,7 +6,8 @@ import type { LlmAdapterRegistry } from '../src/services/llm/dispatcher.js';
import type { LlmAdapter, NonStreamingResult, InferContext } from '../src/services/llm/types.js';
import type { IChatRepository } from '../src/repositories/chat.repository.js';
import type { IPromptRepository } from '../src/repositories/prompt.repository.js';
import type { ChatMessage, ChatThread, Prompt } from '@prisma/client';
import type { IPersonalityRepository } from '../src/repositories/personality.repository.js';
import type { ChatMessage, ChatThread, Prompt, Personality, PersonalityPrompt } from '@prisma/client';
const NOW = new Date();
@@ -76,9 +77,11 @@ function mockChatRepo(): IChatRepository & { _msgs: ChatMessage[]; _threads: Cha
function mockPromptRepo(rows: Prompt[] = []): IPromptRepository {
return {
findAll: vi.fn(async () => rows),
findGlobal: vi.fn(async () => rows.filter((p) => p.projectId === null)),
findGlobal: vi.fn(async () => rows.filter((p) => p.projectId === null && p.agentId === null)),
findByAgent: vi.fn(async (agentId: string) => rows.filter((p) => p.agentId === agentId)),
findById: vi.fn(async (id: string) => rows.find((p) => p.id === id) ?? null),
findByNameAndProject: vi.fn(async () => null),
findByNameAndAgent: vi.fn(async () => null),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
@@ -92,7 +95,7 @@ function mockTools(impl: Partial<ChatToolDispatcher> = {}): ChatToolDispatcher {
};
}
function mockAgents(): AgentService {
function mockAgents(opts: { defaultPersonality?: { id: string; name: string } | null } = {}): AgentService {
return {
getByName: vi.fn(async (name: string) => ({
id: `agent-${name}`,
@@ -103,6 +106,7 @@ function mockAgents(): AgentService {
project: name === 'no-project'
? null
: { id: 'proj-1', name: 'mcpctl-dev' },
defaultPersonality: opts.defaultPersonality ?? null,
proxyModelName: null,
defaultParams: { temperature: 0.5 },
extras: {},
@@ -567,4 +571,210 @@ describe('ChatService', () => {
await expect(svc.listMessages('cnonexistent000000000000000', 'alice'))
.rejects.toThrow(/not found/i);
});
// ── Agent-direct prompts + personality overlay (Stage 3 system block) ──
it('injects agent-direct prompts BETWEEN agent.systemPrompt and project prompts', async () => {
const chatRepo = mockChatRepo();
const adapter = scriptedAdapter([chatCompletion('ok')]);
const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
const prompts: Prompt[] = [
// Project prompt
{
id: 'p-proj', name: 'proj', content: 'PROJECT_TEXT',
projectId: 'proj-1', agentId: null, priority: 5, summary: null,
chapters: null, linkTarget: null, version: 1,
createdAt: NOW, updatedAt: NOW,
},
// Agent-direct prompt
{
id: 'p-direct', name: 'direct', content: 'AGENT_DIRECT_TEXT',
projectId: null, agentId: 'agent-reviewer', priority: 5, summary: null,
chapters: null, linkTarget: null, version: 1,
createdAt: NOW, updatedAt: NOW,
},
];
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(prompts), mockTools(),
);
await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system');
const text = sys?.content as string;
expect(text.indexOf('You are a helpful agent.')).toBeLessThan(text.indexOf('AGENT_DIRECT_TEXT'));
expect(text.indexOf('AGENT_DIRECT_TEXT')).toBeLessThan(text.indexOf('PROJECT_TEXT'));
});
it('appends personality-bound prompts after project prompts when --personality is passed', async () => {
const chatRepo = mockChatRepo();
const adapter = scriptedAdapter([chatCompletion('ok')]);
const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
const projectPrompt: Prompt = {
id: 'p-proj', name: 'proj', content: 'PROJECT_TEXT',
projectId: 'proj-1', agentId: null, priority: 5, summary: null,
chapters: null, linkTarget: null, version: 1,
createdAt: NOW, updatedAt: NOW,
};
const personalityPrompt: Prompt = {
id: 'p-pers', name: 'pers', content: 'PERSONALITY_TEXT',
projectId: null, agentId: null, priority: 5, summary: null,
chapters: null, linkTarget: null, version: 1,
createdAt: NOW, updatedAt: NOW,
};
const personalities = mockPersonalityRepo({
'pers-grumpy': {
personality: makePersonality({ id: 'pers-grumpy', name: 'grumpy', agentId: 'agent-reviewer' }),
bindings: [{ promptId: personalityPrompt.id, priority: 5 }],
},
}, [projectPrompt, personalityPrompt]);
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo([projectPrompt, personalityPrompt]), mockTools(),
personalities,
);
await svc.chat({
agentName: 'reviewer',
userMessage: 'hi',
ownerId: 'owner-1',
personalityName: 'grumpy',
});
const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system');
const text = sys?.content as string;
expect(text.indexOf('PROJECT_TEXT')).toBeLessThan(text.indexOf('PERSONALITY_TEXT'));
});
it('falls back to agent.defaultPersonality when --personality is omitted', async () => {
const chatRepo = mockChatRepo();
const adapter = scriptedAdapter([chatCompletion('ok')]);
const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
const personalityPrompt: Prompt = {
id: 'p-pers', name: 'pers', content: 'DEFAULT_PERSONALITY_TEXT',
projectId: null, agentId: null, priority: 5, summary: null,
chapters: null, linkTarget: null, version: 1,
createdAt: NOW, updatedAt: NOW,
};
const personalities = mockPersonalityRepo({
'pers-default': {
personality: makePersonality({ id: 'pers-default', name: 'default', agentId: 'agent-reviewer' }),
bindings: [{ promptId: personalityPrompt.id, priority: 5 }],
},
}, [personalityPrompt]);
const svc = new ChatService(
mockAgents({ defaultPersonality: { id: 'pers-default', name: 'default' } }),
mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo([personalityPrompt]), mockTools(),
personalities,
);
await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system');
expect(sys?.content as string).toContain('DEFAULT_PERSONALITY_TEXT');
});
it('throws when --personality references a name the agent does not own', async () => {
const chatRepo = mockChatRepo();
const adapter = scriptedAdapter([chatCompletion('ok')]);
const personalities = mockPersonalityRepo({});
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo(), mockTools(),
personalities,
);
await expect(svc.chat({
agentName: 'reviewer',
userMessage: 'hi',
ownerId: 'owner-1',
personalityName: 'ghost',
})).rejects.toThrow(/Personality not found/);
});
it('preserves today\'s system block when no personality and no agent-direct prompts exist', async () => {
// Regression guard: backwards-compatible by construction.
const chatRepo = mockChatRepo();
const adapter = scriptedAdapter([chatCompletion('ok')]);
const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
const projectPrompt: Prompt = {
id: 'p-proj', name: 'proj', content: 'ONLY_PROJECT_TEXT',
projectId: 'proj-1', agentId: null, priority: 5, summary: null,
chapters: null, linkTarget: null, version: 1,
createdAt: NOW, updatedAt: NOW,
};
const svc = new ChatService(
mockAgents(), mockLlms(), adapterRegistry(adapter),
chatRepo, mockPromptRepo([projectPrompt]), mockTools(),
);
await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system');
const text = sys?.content as string;
expect(text).toContain('You are a helpful agent.');
expect(text).toContain('ONLY_PROJECT_TEXT');
});
});
// ── Helpers for personality-overlay tests ──
function makePersonality(overrides: Partial<Personality> = {}): Personality {
return {
id: `pers-${Math.random().toString(36).slice(2, 8)}`,
name: 'p',
description: '',
agentId: 'agent-reviewer',
priority: 5,
createdAt: NOW,
updatedAt: NOW,
...overrides,
};
}
interface MockPersonalityFixture {
personality: Personality;
bindings: Array<{ promptId: string; priority: number }>;
}
function mockPersonalityRepo(
fixtures: Record<string, MockPersonalityFixture>,
prompts: Prompt[] = [],
): IPersonalityRepository {
const byId = new Map<string, MockPersonalityFixture>(Object.entries(fixtures));
const promptsById = new Map<string, Prompt>(prompts.map((p) => [p.id, p]));
return {
findAll: vi.fn(async () => [...byId.values()].map((f) => f.personality)),
findByAgent: vi.fn(async (agentId: string) =>
[...byId.values()].filter((f) => f.personality.agentId === agentId).map((f) => f.personality)),
findById: vi.fn(async (id: string) => byId.get(id)?.personality ?? null),
findByNameAndAgent: vi.fn(async (name: string, agentId: string) => {
for (const f of byId.values()) {
if (f.personality.name === name && f.personality.agentId === agentId) {
return f.personality;
}
}
return null;
}),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
listPrompts: vi.fn(async (personalityId: string) => {
const fixture = byId.get(personalityId);
if (!fixture) return [];
return fixture.bindings.map<PersonalityPrompt & { prompt: Prompt }>((b) => ({
id: `bind-${b.promptId}`,
personalityId,
promptId: b.promptId,
priority: b.priority,
createdAt: NOW,
prompt: promptsById.get(b.promptId) ?? ({
id: b.promptId, name: 'p', content: '',
projectId: null, agentId: null, priority: b.priority,
summary: null, chapters: null, linkTarget: null, version: 1,
createdAt: NOW, updatedAt: NOW,
} as Prompt),
}));
}),
attachPrompt: vi.fn(),
detachPrompt: vi.fn(),
findBinding: vi.fn(async () => null),
};
}