feat: web prompt editor + agent personalities #58
@@ -45,6 +45,8 @@ import { registerAgentChatRoutes } from './routes/agent-chat.js';
|
|||||||
import { PromptRepository } from './repositories/prompt.repository.js';
|
import { PromptRepository } from './repositories/prompt.repository.js';
|
||||||
import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
|
import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
|
||||||
import { PersonalityRepository } from './repositories/personality.repository.js';
|
import { PersonalityRepository } from './repositories/personality.repository.js';
|
||||||
|
import { PersonalityService } from './services/personality.service.js';
|
||||||
|
import { registerPersonalityRoutes } from './routes/personalities.js';
|
||||||
import { bootstrapSystemProject } from './bootstrap/system-project.js';
|
import { bootstrapSystemProject } from './bootstrap/system-project.js';
|
||||||
import {
|
import {
|
||||||
McpServerService,
|
McpServerService,
|
||||||
@@ -163,6 +165,9 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
|||||||
'mcptokens': 'mcptokens',
|
'mcptokens': 'mcptokens',
|
||||||
'llms': 'llms',
|
'llms': 'llms',
|
||||||
'agents': 'agents',
|
'agents': 'agents',
|
||||||
|
// Personalities inherit the agent's RBAC: managing a personality
|
||||||
|
// requires view/edit/create/delete on the `agents` resource.
|
||||||
|
'personalities': 'agents',
|
||||||
};
|
};
|
||||||
|
|
||||||
const resource = resourceMap[segment];
|
const resource = resourceMap[segment];
|
||||||
@@ -451,6 +456,7 @@ async function main(): Promise<void> {
|
|||||||
promptRuleRegistry.register(systemPromptVarsRule);
|
promptRuleRegistry.register(systemPromptVarsRule);
|
||||||
const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry, agentRepo);
|
const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry, agentRepo);
|
||||||
const personalityRepo = new PersonalityRepository(prisma);
|
const personalityRepo = new PersonalityRepository(prisma);
|
||||||
|
const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo);
|
||||||
const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo);
|
const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo);
|
||||||
// ChatService needs the proxy + project repo via the ChatToolDispatcher
|
// ChatService needs the proxy + project repo via the ChatToolDispatcher
|
||||||
// bridge. The dispatcher's logger references `app.log`, which is not
|
// bridge. The dispatcher's logger references `app.log`, which is not
|
||||||
@@ -579,6 +585,7 @@ async function main(): Promise<void> {
|
|||||||
registerSecretMigrateRoutes(app, secretMigrateService);
|
registerSecretMigrateRoutes(app, secretMigrateService);
|
||||||
registerLlmRoutes(app, llmService);
|
registerLlmRoutes(app, llmService);
|
||||||
registerAgentRoutes(app, agentService);
|
registerAgentRoutes(app, agentService);
|
||||||
|
registerPersonalityRoutes(app, personalityService);
|
||||||
// ChatService needs an `app.log`-aware tool dispatcher.
|
// ChatService needs an `app.log`-aware tool dispatcher.
|
||||||
const chatToolDispatcher = new ChatToolDispatcherImpl({
|
const chatToolDispatcher = new ChatToolDispatcherImpl({
|
||||||
proxy: mcpProxyService,
|
proxy: mcpProxyService,
|
||||||
@@ -592,6 +599,7 @@ async function main(): Promise<void> {
|
|||||||
chatRepo,
|
chatRepo,
|
||||||
promptRepo,
|
promptRepo,
|
||||||
chatToolDispatcher,
|
chatToolDispatcher,
|
||||||
|
personalityRepo,
|
||||||
);
|
);
|
||||||
registerAgentChatRoutes(app, chatService);
|
registerAgentChatRoutes(app, chatService);
|
||||||
registerLlmInferRoutes(app, {
|
registerLlmInferRoutes(app, {
|
||||||
@@ -627,7 +635,7 @@ async function main(): Promise<void> {
|
|||||||
registerUserRoutes(app, userService);
|
registerUserRoutes(app, userService);
|
||||||
registerGroupRoutes(app, groupService);
|
registerGroupRoutes(app, groupService);
|
||||||
registerMcpTokenRoutes(app, { tokenService: mcpTokenService, projectRepo });
|
registerMcpTokenRoutes(app, { tokenService: mcpTokenService, projectRepo });
|
||||||
registerPromptRoutes(app, promptService, projectRepo);
|
registerPromptRoutes(app, promptService, projectRepo, agentRepo);
|
||||||
|
|
||||||
// ── Git-based backup ──
|
// ── Git-based backup ──
|
||||||
const gitBackup = new GitBackupService(prisma);
|
const gitBackup = new GitBackupService(prisma);
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function registerAgentChatRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
threadId, message, messages: messagesOverride, stream,
|
threadId, message, messages: messagesOverride, stream, personality,
|
||||||
...paramsRest
|
...paramsRest
|
||||||
} = parsed;
|
} = parsed;
|
||||||
|
|
||||||
@@ -49,6 +49,7 @@ export function registerAgentChatRoutes(
|
|||||||
...(messagesOverride !== undefined
|
...(messagesOverride !== undefined
|
||||||
? { messagesOverride: messagesOverride.map((m) => ({ role: m.role, content: m.content, ...(m.tool_call_id !== undefined ? { tool_call_id: m.tool_call_id } : {}) })) }
|
? { messagesOverride: messagesOverride.map((m) => ({ role: m.role, content: m.content, ...(m.tool_call_id !== undefined ? { tool_call_id: m.tool_call_id } : {}) })) }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(personality !== undefined ? { personalityName: personality } : {}),
|
||||||
params: paramsRest,
|
params: paramsRest,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
154
src/mcpd/src/routes/personalities.ts
Normal file
154
src/mcpd/src/routes/personalities.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* /api/v1/.../personalities — CRUD for agent personalities + prompt bindings.
|
||||||
|
*
|
||||||
|
* RBAC inherits from `agents` (see `mapUrlToPermission` in main.ts) — only
|
||||||
|
* users who can `view/edit/create/delete:agents` can manage that agent's
|
||||||
|
* personalities. Personalities never escape their agent: there is no
|
||||||
|
* top-level `/api/v1/personalities` listing.
|
||||||
|
*/
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { PersonalityService } from '../services/personality.service.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../services/mcp-server.service.js';
|
||||||
|
|
||||||
|
export function registerPersonalityRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
service: PersonalityService,
|
||||||
|
): void {
|
||||||
|
app.get<{ Params: { agentName: string } }>(
|
||||||
|
'/api/v1/agents/:agentName/personalities',
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return await service.listForAgent(request.params.agentName);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { agentName: string } }>(
|
||||||
|
'/api/v1/agents/:agentName/personalities',
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const personality = await service.create(request.params.agentName, request.body);
|
||||||
|
reply.code(201);
|
||||||
|
return personality;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
if (err instanceof ConflictError) {
|
||||||
|
reply.code(409);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>(
|
||||||
|
'/api/v1/personalities/:id',
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return await service.getById(request.params.id);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string } }>(
|
||||||
|
'/api/v1/personalities/:id',
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return await service.update(request.params.id, request.body);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
'/api/v1/personalities/:id',
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
await service.delete(request.params.id);
|
||||||
|
reply.code(204);
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Prompt bindings ──
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>(
|
||||||
|
'/api/v1/personalities/:id/prompts',
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return await service.listBoundPrompts(request.params.id);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/api/v1/personalities/:id/prompts',
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const binding = await service.attachPrompt(request.params.id, request.body);
|
||||||
|
reply.code(201);
|
||||||
|
return binding;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
if (err instanceof ConflictError) {
|
||||||
|
reply.code(409);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string; promptId: string } }>(
|
||||||
|
'/api/v1/personalities/:id/prompts/:promptId',
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
await service.detachPrompt(request.params.id, request.params.promptId);
|
||||||
|
reply.code(204);
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import type { Prompt } from '@prisma/client';
|
import type { Prompt } from '@prisma/client';
|
||||||
import type { PromptService } from '../services/prompt.service.js';
|
import type { PromptService } from '../services/prompt.service.js';
|
||||||
import type { IProjectRepository, ProjectWithRelations } from '../repositories/project.repository.js';
|
import type { IProjectRepository, ProjectWithRelations } from '../repositories/project.repository.js';
|
||||||
|
import type { IAgentRepository } from '../repositories/agent.repository.js';
|
||||||
|
|
||||||
type PromptWithLinkStatus = Prompt & { linkStatus: 'alive' | 'dead' | null };
|
type PromptWithLinkStatus = Prompt & { linkStatus: 'alive' | 'dead' | null };
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ export function registerPromptRoutes(
|
|||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
service: PromptService,
|
service: PromptService,
|
||||||
projectRepo: IProjectRepository,
|
projectRepo: IProjectRepository,
|
||||||
|
agentRepo?: IAgentRepository,
|
||||||
): void {
|
): void {
|
||||||
// ── Prompts (approved) ──
|
// ── Prompts (approved) ──
|
||||||
|
|
||||||
@@ -85,7 +87,31 @@ export function registerPromptRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/v1/prompts', async (request, reply) => {
|
app.post('/api/v1/prompts', async (request, reply) => {
|
||||||
const prompt = await service.createPrompt(request.body);
|
// Resolve `agent: <name>` and `project: <name>` to FK ids before
|
||||||
|
// handing off to the service. Mirrors the existing project-name
|
||||||
|
// resolution on `/api/v1/promptrequests`.
|
||||||
|
const body = request.body as Record<string, unknown>;
|
||||||
|
const resolved: Record<string, unknown> = { ...body };
|
||||||
|
if (typeof body['project'] === 'string') {
|
||||||
|
const project = await projectRepo.findByName(body['project']);
|
||||||
|
if (!project) {
|
||||||
|
throw Object.assign(new Error(`Project not found: ${body['project']}`), { statusCode: 404 });
|
||||||
|
}
|
||||||
|
resolved['projectId'] = project.id;
|
||||||
|
delete resolved['project'];
|
||||||
|
}
|
||||||
|
if (typeof body['agent'] === 'string') {
|
||||||
|
if (!agentRepo) {
|
||||||
|
throw Object.assign(new Error('Agent-scoped prompts not enabled'), { statusCode: 500 });
|
||||||
|
}
|
||||||
|
const agent = await agentRepo.findByName(body['agent']);
|
||||||
|
if (!agent) {
|
||||||
|
throw Object.assign(new Error(`Agent not found: ${body['agent']}`), { statusCode: 404 });
|
||||||
|
}
|
||||||
|
resolved['agentId'] = agent.id;
|
||||||
|
delete resolved['agent'];
|
||||||
|
}
|
||||||
|
const prompt = await service.createPrompt(resolved);
|
||||||
reply.code(201);
|
reply.code(201);
|
||||||
return prompt;
|
return prompt;
|
||||||
});
|
});
|
||||||
@@ -209,4 +235,26 @@ export function registerPromptRoutes(
|
|||||||
return req;
|
return req;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Agent-direct prompts ──
|
||||||
|
//
|
||||||
|
// Lists prompts whose `Prompt.agentId` matches the agent. These prompts
|
||||||
|
// are *not* in any project — they're "always-on" overlays for the agent,
|
||||||
|
// injected after agent.systemPrompt and before project prompts in the
|
||||||
|
// chat system block.
|
||||||
|
app.get<{ Params: { agentName: string } }>(
|
||||||
|
'/api/v1/agents/:agentName/prompts',
|
||||||
|
async (request, reply) => {
|
||||||
|
if (!agentRepo) {
|
||||||
|
throw Object.assign(new Error('Agent-scoped prompts not enabled'), { statusCode: 500 });
|
||||||
|
}
|
||||||
|
const agent = await agentRepo.findByName(request.params.agentName);
|
||||||
|
if (!agent) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: `Agent not found: ${request.params.agentName}` };
|
||||||
|
}
|
||||||
|
const prompts = await service.listPromptsForAgent(agent.id);
|
||||||
|
return enrichWithLinkStatus(prompts, projectRepo);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import type {
|
|||||||
ChatRole,
|
ChatRole,
|
||||||
} from '../repositories/chat.repository.js';
|
} from '../repositories/chat.repository.js';
|
||||||
import type { IPromptRepository } from '../repositories/prompt.repository.js';
|
import type { IPromptRepository } from '../repositories/prompt.repository.js';
|
||||||
|
import type { IPersonalityRepository } from '../repositories/personality.repository.js';
|
||||||
import type { OpenAiChatRequest, OpenAiMessage } from './llm/types.js';
|
import type { OpenAiChatRequest, OpenAiMessage } from './llm/types.js';
|
||||||
import type { AgentChatParams } from '../validation/agent.schema.js';
|
import type { AgentChatParams } from '../validation/agent.schema.js';
|
||||||
import { NotFoundError } from './mcp-server.service.js';
|
import { NotFoundError } from './mcp-server.service.js';
|
||||||
@@ -107,6 +108,13 @@ export interface ChatRequestArgs {
|
|||||||
messagesOverride?: OpenAiMessage[];
|
messagesOverride?: OpenAiMessage[];
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
params?: AgentChatParams;
|
params?: AgentChatParams;
|
||||||
|
/**
|
||||||
|
* Personality overlay for this turn. If set, the personality's bound
|
||||||
|
* prompts are appended to the system block (additive). If unset, falls
|
||||||
|
* back to `agent.defaultPersonalityId`. If neither is present, today's
|
||||||
|
* behavior (no personality overlay) holds.
|
||||||
|
*/
|
||||||
|
personalityName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatResult {
|
export interface ChatResult {
|
||||||
@@ -123,6 +131,7 @@ export class ChatService {
|
|||||||
private readonly chatRepo: IChatRepository,
|
private readonly chatRepo: IChatRepository,
|
||||||
private readonly promptRepo: IPromptRepository,
|
private readonly promptRepo: IPromptRepository,
|
||||||
private readonly tools: ChatToolDispatcher,
|
private readonly tools: ChatToolDispatcher,
|
||||||
|
private readonly personalities?: IPersonalityRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createThread(agentName: string, ownerId: string, title?: string): Promise<{ id: string }> {
|
async createThread(agentName: string, ownerId: string, title?: string): Promise<{ id: string }> {
|
||||||
@@ -361,13 +370,28 @@ export class ChatService {
|
|||||||
const threadId = await this.resolveThreadId(args, agent.id);
|
const threadId = await this.resolveThreadId(args, agent.id);
|
||||||
const projectId = agent.project?.id ?? null;
|
const projectId = agent.project?.id ?? null;
|
||||||
|
|
||||||
|
// Project prompts (existing): only those whose projectId actually matches
|
||||||
|
// the agent's project — `findAll(projectId)` also returns globals which
|
||||||
|
// we exclude here so they don't double-count if a future change adds an
|
||||||
|
// explicit "global" injection step.
|
||||||
const projectPrompts = projectId !== null
|
const projectPrompts = projectId !== null
|
||||||
? await this.promptRepo.findAll(projectId)
|
? await this.promptRepo.findAll(projectId)
|
||||||
: [];
|
: [];
|
||||||
const sortedPrompts = [...projectPrompts]
|
const sortedProjectPrompts = [...projectPrompts]
|
||||||
.filter((p) => p.projectId === projectId)
|
.filter((p) => p.projectId === projectId)
|
||||||
.sort((a, b) => b.priority - a.priority);
|
.sort((a, b) => b.priority - a.priority);
|
||||||
|
|
||||||
|
// Agent-direct prompts: always-on overlay scoped to this specific agent.
|
||||||
|
// Ordered after agent.systemPrompt and BEFORE project prompts so
|
||||||
|
// agent-specific tone/guardrails win over project-wide context.
|
||||||
|
const agentDirectPrompts = (await this.promptRepo.findByAgent(agent.id))
|
||||||
|
.sort((a, b) => b.priority - a.priority);
|
||||||
|
|
||||||
|
// Personality overlay: chooses by request-supplied name first, falling
|
||||||
|
// back to the agent's defaultPersonalityId. Without a personality this
|
||||||
|
// path is a no-op and the resulting block matches today's behavior.
|
||||||
|
const personalityPromptContents = await this.resolvePersonalityPrompts(args, agent);
|
||||||
|
|
||||||
const mergedParams: AgentChatParams = {
|
const mergedParams: AgentChatParams = {
|
||||||
...(agent.defaultParams ?? {}),
|
...(agent.defaultParams ?? {}),
|
||||||
...(args.params ?? {}),
|
...(args.params ?? {}),
|
||||||
@@ -376,7 +400,9 @@ export class ChatService {
|
|||||||
const baseSystem = mergedParams.systemOverride ?? agent.systemPrompt;
|
const baseSystem = mergedParams.systemOverride ?? agent.systemPrompt;
|
||||||
const systemBlock = [
|
const systemBlock = [
|
||||||
baseSystem,
|
baseSystem,
|
||||||
...sortedPrompts.map((p) => p.content),
|
...agentDirectPrompts.map((p) => p.content),
|
||||||
|
...sortedProjectPrompts.map((p) => p.content),
|
||||||
|
...personalityPromptContents,
|
||||||
mergedParams.systemAppend ?? '',
|
mergedParams.systemAppend ?? '',
|
||||||
]
|
]
|
||||||
.filter((s) => s.length > 0)
|
.filter((s) => s.length > 0)
|
||||||
@@ -421,6 +447,40 @@ export class ChatService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a personality (request override → agent default) and returns
|
||||||
|
* its bound prompt contents in `PersonalityPrompt.priority` desc order.
|
||||||
|
* Returns `[]` when no personality is selected, when the personality
|
||||||
|
* repository is not wired (legacy callers), or when the named personality
|
||||||
|
* doesn't exist on this agent. The "doesn't exist" case throws — typos in
|
||||||
|
* a CLI flag should fail loudly, not silently fall back to no overlay.
|
||||||
|
*/
|
||||||
|
private async resolvePersonalityPrompts(
|
||||||
|
args: ChatRequestArgs,
|
||||||
|
agent: Awaited<ReturnType<AgentService['getByName']>>,
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (this.personalities === undefined) return [];
|
||||||
|
|
||||||
|
let personalityId: string | null = null;
|
||||||
|
if (args.personalityName !== undefined && args.personalityName !== '') {
|
||||||
|
const named = await this.personalities.findByNameAndAgent(args.personalityName, agent.id);
|
||||||
|
if (named === null) {
|
||||||
|
throw new NotFoundError(
|
||||||
|
`Personality not found on agent ${agent.name}: ${args.personalityName}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
personalityId = named.id;
|
||||||
|
} else if (agent.defaultPersonality !== null) {
|
||||||
|
personalityId = agent.defaultPersonality.id;
|
||||||
|
}
|
||||||
|
if (personalityId === null) return [];
|
||||||
|
|
||||||
|
const bindings = await this.personalities.listPrompts(personalityId);
|
||||||
|
return [...bindings]
|
||||||
|
.sort((a, b) => b.priority - a.priority)
|
||||||
|
.map((b) => b.prompt.content);
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveThreadId(args: ChatRequestArgs, agentId: string): Promise<string> {
|
private async resolveThreadId(args: ChatRequestArgs, agentId: string): Promise<string> {
|
||||||
if (args.threadId !== undefined) {
|
if (args.threadId !== undefined) {
|
||||||
const existing = await this.chatRepo.findThread(args.threadId);
|
const existing = await this.chatRepo.findThread(args.threadId);
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ export const AgentChatRequestSchema = AgentChatParamsSchema.merge(
|
|||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
stream: z.boolean().optional(),
|
stream: z.boolean().optional(),
|
||||||
|
/**
|
||||||
|
* Optional personality overlay for this turn. Looked up by name on the
|
||||||
|
* agent's own personality set (per-agent unique). Falls back to the
|
||||||
|
* agent's `defaultPersonalityId` when omitted.
|
||||||
|
*/
|
||||||
|
personality: z.string().min(1).optional(),
|
||||||
}),
|
}),
|
||||||
).strict().refine((v) => v.message !== undefined || (v.messages?.length ?? 0) > 0, {
|
).strict().refine((v) => v.message !== undefined || (v.messages?.length ?? 0) > 0, {
|
||||||
message: 'Either `message` or `messages` is required',
|
message: 'Either `message` or `messages` is required',
|
||||||
|
|||||||
@@ -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 { LlmAdapter, NonStreamingResult, InferContext } from '../src/services/llm/types.js';
|
||||||
import type { IChatRepository } from '../src/repositories/chat.repository.js';
|
import type { IChatRepository } from '../src/repositories/chat.repository.js';
|
||||||
import type { IPromptRepository } from '../src/repositories/prompt.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();
|
const NOW = new Date();
|
||||||
|
|
||||||
@@ -76,9 +77,11 @@ function mockChatRepo(): IChatRepository & { _msgs: ChatMessage[]; _threads: Cha
|
|||||||
function mockPromptRepo(rows: Prompt[] = []): IPromptRepository {
|
function mockPromptRepo(rows: Prompt[] = []): IPromptRepository {
|
||||||
return {
|
return {
|
||||||
findAll: vi.fn(async () => rows),
|
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),
|
findById: vi.fn(async (id: string) => rows.find((p) => p.id === id) ?? null),
|
||||||
findByNameAndProject: vi.fn(async () => null),
|
findByNameAndProject: vi.fn(async () => null),
|
||||||
|
findByNameAndAgent: vi.fn(async () => null),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: 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 {
|
return {
|
||||||
getByName: vi.fn(async (name: string) => ({
|
getByName: vi.fn(async (name: string) => ({
|
||||||
id: `agent-${name}`,
|
id: `agent-${name}`,
|
||||||
@@ -103,6 +106,7 @@ function mockAgents(): AgentService {
|
|||||||
project: name === 'no-project'
|
project: name === 'no-project'
|
||||||
? null
|
? null
|
||||||
: { id: 'proj-1', name: 'mcpctl-dev' },
|
: { id: 'proj-1', name: 'mcpctl-dev' },
|
||||||
|
defaultPersonality: opts.defaultPersonality ?? null,
|
||||||
proxyModelName: null,
|
proxyModelName: null,
|
||||||
defaultParams: { temperature: 0.5 },
|
defaultParams: { temperature: 0.5 },
|
||||||
extras: {},
|
extras: {},
|
||||||
@@ -567,4 +571,210 @@ describe('ChatService', () => {
|
|||||||
await expect(svc.listMessages('cnonexistent000000000000000', 'alice'))
|
await expect(svc.listMessages('cnonexistent000000000000000', 'alice'))
|
||||||
.rejects.toThrow(/not found/i);
|
.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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user