feat(ai-chat): add reusable agent roles (persona + optional model)

Roles are workspace-admin presets that customize the AI agent's system-
prompt persona and, optionally, the model, attached to a chat at creation
time. Examples: a 'Proofreader' that only touches grammar, a 'Fact-
checker' that cites web sources. A role changes ONLY instructions and
( optional ) the model; the toolset stays full, so the security boundary
(CASL via the per-user loopback token) is unchanged.

Backend:
- Migration 20260620T150000-ai-agent-roles: ai_agent_roles table
  (workspace-scoped, soft-delete, model_config jsonb) + ai_chats.role_id
  (ON DELETE SET NULL).
- AiAgentRoleRepo / AiAgentRolesService / AiAgentRolesController at
  /workspace/ai-agent-roles. LIST (picker view) is open to all workspace
  members; create/update/delete are admin-only. The picker view omits
  instructions and model_config so they never leak to non-admins.
- buildSystemPrompt: optional roleInstructions REPLACES the admin persona
  (priority order: role > admin > default). The non-removable
  SAFETY_FRAMEWORK is always appended - a role cannot strip it.
- AiChatService.stream: persists roleId on first turn; subsequent turns
  read role_id from the chat row, never from the request body. The role's
  instructions are applied even if it was later disabled or soft-deleted
  (existing chats keep their persona).
- AiService.getChatModel accepts an optional override. Same-driver
  overrides reuse the workspace key; cross-driver (openai/gemini) loads
  alternate creds from ai_provider_credentials and throws a clean 503 if
  they are missing (no silent fallback). Cross-driver ollama is rejected
  with a clear message (no per-driver ollama base URL exists yet).
- Controller resolves the role model BEFORE res.hijack so misconfigured
  overrides return JSON 503, not a broken stream.

Client:
- New chat picker (Mantine Select) lists enabled roles, default
  'Universal assistant' (roleId null). The roleId is sent only when
  starting a new chat; existing chats show the role as a fixed badge.
- Role badge in the chat window header and conversation list.
- Settings -> AI: new 'Agent roles' management section mirrors the
  external MCP servers UI (add/edit/delete + enable toggle + optional
  model override). Form fields: name, emoji, description, instructions,
  model override (driver + chatModel), with a reminder that the safety
  framework is always appended.

Hardening after review:
- Empty-string roleId coerced to null on both client and server (picker
  'Universal assistant' option used to crash the uuid INSERT).
- New-chat insert validates picker-eligibility (enabled + not soft-deleted
  + workspace-scoped); ineligible ids silently fall back to null.
- findByCreator's role JOIN is workspace-scoped and every column ref is
  table-qualified (avoids Postgres ambiguous-column errors).
- getChatModelForRole applies the same picker-eligibility gate as stream
  on the new-chat path, so model and persona resolve from one source.
This commit is contained in:
glm5.2 agent 180
2026-06-20 15:54:23 +03:00
parent 2936d16a43
commit 24bf0ab18f
31 changed files with 1845 additions and 37 deletions
@@ -144,8 +144,14 @@ export class AiChatController {
// Resolve the model BEFORE hijack so an unconfigured provider returns a
// clean JSON 503 (AiNotConfiguredException is a 503 HttpException; letting
// it propagate here yields a normal response, not a broken stream).
const model = await this.aiChatService.getChatModel(workspace.id);
// it propagate here yields a normal response, not a broken stream). This
// also resolves a role's optional model override: a misconfigured alternate
// driver throws a clear 503 here (never a silent fallback mid-stream).
const model = await this.aiChatService.getChatModelForRole(
workspace.id,
body.roleId,
body.chatId,
);
// Abort the agent loop when the client disconnects. `close` also fires on
// normal completion, so only abort when the response has not finished
+10 -1
View File
@@ -7,6 +7,7 @@ import { AiTranscriptionService } from './ai-transcription.service';
import { AiChatToolsService } from './tools/ai-chat-tools.service';
import { EmbeddingModule } from './embedding/embedding.module';
import { ExternalMcpModule } from './external-mcp/external-mcp.module';
import { AiAgentRolesModule } from './roles/ai-agent-roles.module';
/**
* Per-user AI chat module (§6.1).
@@ -18,9 +19,17 @@ import { ExternalMcpModule } from './external-mcp/external-mcp.module';
* + AI_CHAT throttler come from the global ThrottleModule registered in
* AppModule. EmbeddingModule hosts the vector-RAG indexer + AI_QUEUE consumer
* (§6.7 stage D); importing it here boots the processor with the app.
* AiAgentRolesModule supplies the role resolve path used during streaming
* (persona + optional model override).
*/
@Module({
imports: [AiModule, TokenModule, EmbeddingModule, ExternalMcpModule],
imports: [
AiModule,
TokenModule,
EmbeddingModule,
ExternalMcpModule,
AiAgentRolesModule,
],
controllers: [AiChatController],
providers: [AiChatService, AiTranscriptionService, AiChatToolsService],
})
+23 -8
View File
@@ -61,6 +61,14 @@ export interface BuildSystemPromptInput {
* used instead.
*/
adminPrompt?: string | null;
/**
* Role-specific persona fragment (from `ai_agent_roles.instructions` for the
* chat's bound role, if any). When this is a non-blank string it REPLACES the
* admin persona base (a role like "Proofreader" must dominate, not compete
* with, the workspace prompt). When blank/absent the admin prompt (or default)
* is used. The non-removable SAFETY_FRAMEWORK is appended AFTER in all cases.
*/
roleInstructions?: string | null;
/**
* The page the user is currently viewing (client-supplied), if any. When it
* has an id, a CONTEXT line is added so the agent can resolve "this page" /
@@ -71,19 +79,26 @@ export interface BuildSystemPromptInput {
}
/**
* Compose the agent's system prompt: the admin's configured text (or a default
* when empty), then ALWAYS the non-removable safety framework. The admin text
* can shape the persona but cannot strip the safety rules.
* Compose the agent's system prompt: the persona (role instructions, else the
* admin's configured text, else a default when empty), then ALWAYS the
* non-removable safety framework. Neither the role nor the admin can strip the
* safety rules — SAFETY_FRAMEWORK is appended unconditionally.
*/
export function buildSystemPrompt({
workspace,
adminPrompt,
roleInstructions,
openedPage,
}: BuildSystemPromptInput): string {
const base =
typeof adminPrompt === 'string' && adminPrompt.trim().length > 0
? adminPrompt.trim()
: DEFAULT_PROMPT;
// Persona priority: role instructions (when non-blank) REPLACE the admin
// persona base; otherwise the admin prompt (or the default) is used. A role
// is a narrow persona like "Proofreader" — its instructions must dominate.
const persona =
typeof roleInstructions === 'string' && roleInstructions.trim().length > 0
? roleInstructions.trim()
: typeof adminPrompt === 'string' && adminPrompt.trim().length > 0
? adminPrompt.trim()
: DEFAULT_PROMPT;
let context = workspace?.name ? `\n\nWorkspace: ${workspace.name}.` : '';
@@ -100,5 +115,5 @@ export function buildSystemPrompt({
context += `\nThe user is currently viewing the page "${title}" (pageId: ${pageId.trim()}). When they refer to "this page", "the current page", or similar, operate on that pageId — use the read/write page tools with it.`;
}
return `${base}${context}\n${SAFETY_FRAMEWORK}`;
return `${persona}${context}\n${SAFETY_FRAMEWORK}`;
}
@@ -15,15 +15,23 @@ import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.rep
import { User, Workspace, AiChatMessage } from '@docmost/db/types/entity.types';
import { AiChatToolsService } from './tools/ai-chat-tools.service';
import { McpClientsService } from './external-mcp/mcp-clients.service';
import { AiAgentRolesService } from './roles/ai-agent-roles.service';
import { buildSystemPrompt } from './ai-chat.prompt';
/**
* Payload accepted from the client `useChat` POST body. We do NOT bind a strict
* DTO (the global ValidationPipe whitelist would strip the useChat-specific
* fields), so this is a loose shape parsed straight off `req.body`.
*
* `roleId` is consumed ONLY on the first turn of a brand-new chat (chatId is
* null) — the server persists it onto the chat row. Subsequent turns read the
* role from the chat row, never from the body, so the role cannot be swapped
* per-turn.
*/
export interface AiChatStreamBody {
chatId?: string;
// Bound ONCE at chat creation; ignored on later turns.
roleId?: string | null;
// The page the user is currently viewing (client-supplied), or null on a
// non-page route. Used ONLY as prompt context so the agent knows what "this
// page" refers to; the page itself is never fetched server-side here. The id
@@ -70,6 +78,7 @@ export class AiChatService {
private readonly aiSettings: AiSettingsService,
private readonly tools: AiChatToolsService,
private readonly mcpClients: McpClientsService,
private readonly rolesService: AiAgentRolesService,
) {}
/**
@@ -81,6 +90,73 @@ export class AiChatService {
return this.ai.getChatModel(workspaceId);
}
/**
* Resolve the chat language model for a turn, honouring a role's optional
* model override. Resolves the role for THIS turn from the chat row's roleId
* (existing chat) or the request body's roleId (new chat). The driver-switch
* path throws AiNotConfiguredException (→ 503) with a clear, role-specific
* message when the alternate driver is unconfigured — never a silent fallback.
*
* Exposed so the controller can resolve the model BEFORE res.hijack(), so a
* misconfigured role surfaces as clean JSON 503 rather than a broken stream.
*/
async getChatModelForRole(
workspaceId: string,
roleIdFromBody: string | null | undefined,
chatId: string | null | undefined,
): Promise<LanguageModel> {
let effectiveRoleId: string | null = null;
if (chatId) {
const chat = await this.aiChatRepo.findById(chatId, workspaceId);
if (chat) {
// For an existing chat, the role is fixed on the chat row — read it
// there, never from the request body (the role cannot be swapped
// per-turn). findByIdForResolve returns the role even if disabled or
// soft-deleted, so existing chats keep their persona/model.
effectiveRoleId = chat.roleId ?? null;
if (effectiveRoleId) {
const role = await this.rolesService.findByIdForResolve(
effectiveRoleId,
workspaceId,
);
if (!role?.modelConfig) return this.ai.getChatModel(workspaceId);
return this.ai.getChatModel(workspaceId, role.modelConfig);
}
return this.ai.getChatModel(workspaceId);
}
// Stale chatId: stream() will create a new chat, so fall through to the
// new-chat resolution path using body.roleId.
}
// New chat (or stale chatId): mirror stream()'s picker-eligibility gate so
// a stale disabled/deleted role id is silently ignored here too — otherwise
// the controller would throw 503 on an alt-driver override while stream()
// proceeds without the role, diverging model and persona.
effectiveRoleId = roleIdFromBody || null;
if (effectiveRoleId) {
const eligible = await this.rolesService.findByIdForPicker(
effectiveRoleId,
workspaceId,
);
if (!eligible) effectiveRoleId = null;
}
if (!effectiveRoleId) {
return this.ai.getChatModel(workspaceId);
}
// Once gated as picker-eligible, use the full resolve view: the role is
// enabled and live, so findByIdForResolve === findByIdForPicker here, but
// findByIdForResolve returns the model_config-bearing row.
const role = await this.rolesService.findByIdForResolve(
effectiveRoleId,
workspaceId,
);
if (!role?.modelConfig) {
return this.ai.getChatModel(workspaceId);
}
return this.ai.getChatModel(workspaceId, role.modelConfig);
}
async stream({
user,
workspace,
@@ -101,9 +177,27 @@ export class AiChatService {
}
}
if (!chatId) {
// roleId is bound ONCE at chat creation (client sends it on the first
// turn of a new chat). Subsequent turns read it from the chat row below.
// Normalize with || (not ??) so a stray empty string also maps to null
// (the uuid column rejects ""; belt-and-braces with the client coercion).
let effectiveRoleId: string | null = body.roleId || null;
// Verify the role is picker-eligible (enabled, not soft-deleted, in this
// workspace). A stray id is either a stale client or a malicious one; the
// picker would never have offered a disabled/deleted role, so silently
// fall back to the universal assistant rather than throwing (avoids
// erroring an otherwise-legitimate new chat).
if (effectiveRoleId) {
const eligible = await this.rolesService.findByIdForPicker(
effectiveRoleId,
workspace.id,
);
if (!eligible) effectiveRoleId = null;
}
const chat = await this.aiChatRepo.insert({
creatorId: user.id,
workspaceId: workspace.id,
roleId: effectiveRoleId,
});
chatId = chat.id;
isNewChat = true;
@@ -143,9 +237,29 @@ export class AiChatService {
// The model is resolved by the controller before hijack (clean 503 path).
// Here we only need the admin-configured system prompt.
const resolved = await this.aiSettings.resolve(workspace.id);
// Resolve the role's persona for THIS turn. For an existing chat the role is
// fixed on the chat row (read there, never from the request body, so it
// cannot be swapped per-turn); for a brand-new chat the role was just bound
// above from body.roleId. findByIdForResolve returns the role even if
// disabled or soft-deleted, so existing chats keep their persona.
let roleInstructions: string | null = null;
{
const chat = await this.aiChatRepo.findById(chatId, workspace.id);
const roleId = chat?.roleId ?? null;
if (roleId) {
const role = await this.rolesService.findByIdForResolve(
roleId,
workspace.id,
);
if (role) roleInstructions = role.instructions;
}
}
const system = buildSystemPrompt({
workspace,
adminPrompt: resolved?.systemPrompt,
roleInstructions,
openedPage: body.openPage,
});
@@ -0,0 +1,109 @@
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../../casl/interfaces/workspace-ability.type';
import { AiAgentRolesService } from './ai-agent-roles.service';
import { CreateAgentRoleDto } from './dto/create-agent-role.dto';
import { UpdateAgentRoleDto } from './dto/update-agent-role.dto';
import { AgentRoleIdDto } from './dto/agent-role-id.dto';
/**
* Agent roles management (admin CRUD + member-readable picker list). Mounted at
* `/workspace/ai-agent-roles` alongside `ai-mcp-servers` (both are admin AI
* settings). Routes are POST to match this codebase's convention.
*
* CRITICAL access asymmetry:
* - `list` (the base POST) returns the PICKER view and is open to ANY workspace
* member — otherwise a non-admin could not pick a role when starting a chat.
* The picker view deliberately omits `instructions` (admin-authored trusted
* content) so it is safe to expose to non-admins.
* - `admin-list` / `create` / `update` / `delete` are admin-only (the same gate
* as `/workspace/update` and the AI provider settings).
*/
@UseGuards(JwtAuthGuard)
@Controller('workspace/ai-agent-roles')
export class AiAgentRolesController {
constructor(
private readonly rolesService: AiAgentRolesService,
private readonly workspaceAbility: WorkspaceAbilityFactory,
) {}
private assertAdmin(user: User, workspace: Workspace): void {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
}
/**
* Picker list — open to ANY workspace member (no assertAdmin). Returns only
* id/name/emoji/description; never `instructions` (so it is safe for
* non-admins to read).
*/
@HttpCode(HttpStatus.OK)
@Post()
async list(@AuthWorkspace() workspace: Workspace) {
return this.rolesService.listForPicker(workspace.id);
}
/** Admin list — full view including `instructions` + `modelConfig`. */
@HttpCode(HttpStatus.OK)
@Post('admin-list')
async adminList(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.listForAdmin(workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('create')
async create(
@Body() dto: CreateAgentRoleDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.create(workspace.id, user.id, dto);
}
@HttpCode(HttpStatus.OK)
@Post('update')
async update(
@Body() idDto: AgentRoleIdDto,
@Body() dto: UpdateAgentRoleDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.update(workspace.id, idDto.id, dto);
}
@HttpCode(HttpStatus.OK)
@Post('delete')
async remove(
@Body() idDto: AgentRoleIdDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.assertAdmin(user, workspace);
return this.rolesService.remove(workspace.id, idDto.id);
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { AiAgentRolesController } from './ai-agent-roles.controller';
import { AiAgentRolesService } from './ai-agent-roles.service';
/**
* Agent roles unit: reusable personas that customize the agent's system-prompt
* persona (and optionally the model) for chats bound to a role.
*
* AiAgentRoleRepo (DatabaseModule, global) and WorkspaceAbilityFactory
* (CaslModule, global) are resolved without explicit imports. The service is
* exported so AiChatService can resolve a role's instructions/model during
* streaming.
*/
@Module({
controllers: [AiAgentRolesController],
providers: [AiAgentRolesService],
exports: [AiAgentRolesService],
})
export class AiAgentRolesModule {}
@@ -0,0 +1,196 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-chat/ai-agent-role.repo';
import { AiAgentRole } from '@docmost/db/types/entity.types';
import { RoleModelConfig } from '@docmost/db/types/ai-agent-roles.types';
import { CreateAgentRoleDto } from './dto/create-agent-role.dto';
import { UpdateAgentRoleDto } from './dto/update-agent-role.dto';
/**
* Public (picker) view of a role. SECURITY: this shape intentionally excludes
* `instructions` and `modelConfig` — it is the only shape reachable by non-admin
* workspace members (the chat-start picker). A non-admin must be able to list
* roles to pick one when starting a chat, but must not read the instructions.
*/
export interface AgentRolePickerView {
id: string;
name: string;
emoji: string | null;
description: string | null;
}
/**
* Admin-facing view of a role. Includes `instructions` and `modelConfig` (the
* fields only an admin should read/edit). Admin-only.
*/
export interface AgentRoleAdminView {
id: string;
name: string;
emoji: string | null;
description: string | null;
instructions: string;
modelConfig: RoleModelConfig | null;
enabled: boolean;
createdAt: Date;
updatedAt: Date;
}
/**
* Admin business logic for agent roles: CRUD plus the resolve path used by the
* stream. Roles are workspace-scoped; soft-deleted roles stay readable for the
* resolve path so existing chats keep their persona.
*/
@Injectable()
export class AiAgentRolesService {
constructor(private readonly repo: AiAgentRoleRepo) {}
/**
* Picker list (all workspace members). Returns only enabled, non-deleted
* roles, projected to the picker view (NO instructions).
*/
async listForPicker(workspaceId: string): Promise<AgentRolePickerView[]> {
const rows = await this.repo.listForPicker(workspaceId);
return rows.map((r) => this.toPickerView(r));
}
/**
* Admin list. Returns all non-deleted roles (including disabled), projected to
* the full admin view (with instructions + modelConfig).
*/
async listForAdmin(workspaceId: string): Promise<AgentRoleAdminView[]> {
const rows = await this.repo.listForAdmin(workspaceId);
return rows.map((r) => this.toAdminView(r));
}
/**
* Resolve a role for the stream path: returns the role INCLUDING soft-deleted
* and disabled rows, so existing chats keep their persona. Returns undefined
* only when the row is gone (hard delete already nulled the chat's roleId via
* ON DELETE SET NULL).
*/
async findByIdForResolve(
id: string,
workspaceId: string,
): Promise<AiAgentRole | undefined> {
return this.repo.findByIdForResolve(id, workspaceId);
}
/**
* Picker-eligibility check: returns the role ONLY when enabled, not
* soft-deleted, and in this workspace. Used to validate a client-supplied
* roleId at new-chat creation so a disabled/soft-deleted role cannot be bound
* to a fresh chat. Returns undefined otherwise.
*/
async findByIdForPicker(
id: string,
workspaceId: string,
): Promise<AiAgentRole | undefined> {
return this.repo.findByIdEnabled(id, workspaceId);
}
async create(
workspaceId: string,
creatorId: string,
dto: CreateAgentRoleDto,
): Promise<AgentRoleAdminView> {
const row = await this.repo.insert({
workspaceId,
creatorId,
name: dto.name.trim(),
emoji: dto.emoji?.trim() || null,
description: dto.description?.trim() || null,
instructions: dto.instructions.trim(),
modelConfig: normalizeModelConfig(dto.modelConfig),
enabled: dto.enabled ?? true,
});
return this.toAdminView(row);
}
async update(
workspaceId: string,
id: string,
dto: UpdateAgentRoleDto,
): Promise<AgentRoleAdminView> {
const existing = await this.repo.findById(id, workspaceId);
if (!existing) {
throw new BadRequestException('Agent role not found');
}
await this.repo.update(id, workspaceId, {
name: dto.name !== undefined ? dto.name.trim() : undefined,
emoji: dto.emoji !== undefined ? dto.emoji.trim() || null : undefined,
description:
dto.description !== undefined
? dto.description.trim() || null
: undefined,
instructions:
dto.instructions !== undefined ? dto.instructions.trim() : undefined,
// `modelConfig` is a nested object on the DTO; normalize it only when the
// key was present in the patch. The DTO loader turns a raw null/undefined
// into undefined here, so we distinguish "leave unchanged" (key absent)
// from "clear" (null). ValidationPipe delivers the validated DTO value
// as-is for present keys.
modelConfig:
dto.modelConfig === undefined
? undefined
: normalizeModelConfig(dto.modelConfig),
enabled: dto.enabled,
});
const updated = await this.repo.findById(id, workspaceId);
return this.toAdminView(updated as AiAgentRole);
}
async remove(workspaceId: string, id: string): Promise<{ success: true }> {
await this.repo.softDelete(id, workspaceId);
return { success: true };
}
// --- internals ---
private toPickerView(row: AiAgentRole): AgentRolePickerView {
return {
id: row.id,
name: row.name,
emoji: row.emoji,
description: row.description,
};
}
private toAdminView(row: AiAgentRole): AgentRoleAdminView {
return {
id: row.id,
name: row.name,
emoji: row.emoji,
description: row.description,
instructions: row.instructions,
modelConfig: row.modelConfig,
enabled: row.enabled,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
}
/**
* Normalize the model config for storage. Returns null when there is no override
* (so the column reads as the workspace default). When a config object is
* present, `chatModel` is required (enforced by the DTO) and `driver` is kept
* only when set.
*/
function normalizeModelConfig(
config:
| { driver?: string; chatModel?: string }
| null
| undefined,
): RoleModelConfig | null {
if (!config) return null;
const chatModel = typeof config.chatModel === 'string' ? config.chatModel.trim() : '';
if (!chatModel) return null;
const out: RoleModelConfig = { chatModel };
const driver =
typeof config.driver === 'string' ? config.driver.trim() : '';
if (driver === 'openai' || driver === 'gemini' || driver === 'ollama') {
out.driver = driver;
}
return out;
}
@@ -0,0 +1,7 @@
import { IsString } from 'class-validator';
/** Path param for the per-role routes (update/delete). */
export class AgentRoleIdDto {
@IsString()
id: string;
}
@@ -0,0 +1,68 @@
import {
IsBoolean,
IsIn,
IsObject,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
/** Allowed model override drivers (mirrors the workspace provider drivers). */
export const ROLE_DRIVERS = ['openai', 'gemini', 'ollama'] as const;
export type RoleDriver = (typeof ROLE_DRIVERS)[number];
/**
* Optional model override on a role. When `driver` differs from the workspace's
* configured driver, the role's chats use that driver's credentials (loaded
* from ai_provider_credentials). `chatModel` is always required when
* `modelConfig` is provided.
*/
export class RoleModelConfigDto {
@IsOptional()
@IsIn(ROLE_DRIVERS)
driver?: RoleDriver;
@IsString()
@MaxLength(200)
chatModel: string;
}
/**
* Admin create payload for an agent role. The global ValidationPipe runs with
* `whitelist: true`, so unknown fields are stripped.
*
* SECURITY: `instructions` is admin-authored trusted content that only enters
* the system prompt of chats in this workspace. It is never returned to
* non-admin (picker) clients.
*/
export class CreateAgentRoleDto {
@IsString()
@MaxLength(100)
name: string;
@IsOptional()
@IsString()
@MaxLength(32)
emoji?: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@IsString()
@MaxLength(8000)
instructions: string;
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => RoleModelConfigDto)
modelConfig?: RoleModelConfigDto;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}
@@ -0,0 +1,63 @@
import {
IsBoolean,
IsIn,
IsObject,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ROLE_DRIVERS, RoleDriver } from './create-agent-role.dto';
/**
* Optional model override update payload. Every field is optional (partial
* update). Passing `modelConfig: null` clears the override; omitting it leaves
* the stored override unchanged.
*/
export class UpdateRoleModelConfigDto {
@IsOptional()
@IsIn(ROLE_DRIVERS)
driver?: RoleDriver;
@IsOptional()
@IsString()
@MaxLength(200)
chatModel?: string;
}
/**
* Admin update payload for an agent role. Every field is optional (partial
* update). The global ValidationPipe runs with `whitelist: true`.
*/
export class UpdateAgentRoleDto {
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@IsOptional()
@IsString()
@MaxLength(32)
emoji?: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@IsOptional()
@IsString()
@MaxLength(8000)
instructions?: string;
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => UpdateRoleModelConfigDto)
modelConfig?: UpdateRoleModelConfigDto;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}