feat(ai-chat): agent roles (admin-defined persona + optional model)

Reusable, workspace-shared agent roles for the built-in AI chat. A role is
a named persona (system-prompt instructions) + optional model override; a
chat is bound to a role at creation and applies it every turn.

Backend:
- migration 20260620T120000: ai_agent_roles table + ai_chats.role_id
  (FK ON DELETE SET NULL); hand-merged types into db.d.ts/entity.types.ts
  (db.d.ts is hand-curated here, full codegen would clobber it).
- core/ai-chat/roles: CRUD module. list = any workspace member; create/
  update/delete = admin (Manage Settings ability, like ai-settings/mcp).
  All repo queries scoped by workspace_id; soft-delete (deleted_at).
- buildSystemPrompt gains roleInstructions: role REPLACES the persona base
  (admin prompt / DEFAULT_PROMPT) but SAFETY_FRAMEWORK + context are always
  still appended.
- stream(): role resolved from ai_chats.role_id for existing chats (never
  the request body -> no per-turn role swap); body.roleId only on creation.
  Disabled (enabled=false) and soft-deleted roles fall back to universal.
- getChatModel(workspaceId, override): role model_config can swap model id /
  driver; a driver without configured creds throws 503 with a clear message
  naming the driver+role, resolved BEFORE response hijack.

Client:
- new-chat role picker (enabled roles only, default Universal assistant),
  roleId sent only on the first message; role badge (emoji+name) in the chat
  header and conversation list; admin Agent-roles management section in
  Settings -> AI (add/edit/delete, MCP-form pattern).

Tests: ai-chat.prompt.spec (role layering + safety always present, incl.
jailbreak); ai.service.spec (override on unconfigured driver -> 503).

Implements docs/ai-agent-roles-plan.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-20 06:30:06 +03:00
parent c8af637654
commit 30c3189220
30 changed files with 1674 additions and 40 deletions

View File

@@ -5,7 +5,7 @@ import { ServiceUnavailableException } from '@nestjs/common';
* driver / chat model / API key). Maps to HTTP 503 (§6.2/§6.4).
*/
export class AiNotConfiguredException extends ServiceUnavailableException {
constructor() {
super('AI provider not configured');
constructor(message = 'AI provider not configured') {
super(message);
}
}

View File

@@ -0,0 +1,87 @@
import { AiService } from './ai.service';
import { AiNotConfiguredException } from './ai-not-configured.exception';
/**
* Unit test for the role model-override 503 path of AiService.getChatModel.
*
* AiService's constructor body is trivial (it only stores its deps), so it can
* be unit-constructed with stubbed collaborators — no Nest module graph, which
* the src-rooted jest setup cannot fully resolve for the heavier specs. We stub:
* - aiSettings.resolve -> a workspace configured for openai (so cfg.driver is
* set and we pass the first guard),
* - aiProviderCredentialsRepo.find -> undefined (the override driver has NO
* configured credentials),
* - secretBox -> unused on this path (no creds to decrypt).
*
* With a role override pointing at a DIFFERENT driver ('gemini') that has no
* creds, getChatModel must throw AiNotConfiguredException (503) and the message
* must name the override driver (and the role) so an admin can fix it.
*/
describe('AiService.getChatModel role model override', () => {
function makeService(opts: {
workspaceDriver: string;
credsApiKeyEnc?: string;
}) {
const aiSettings = {
resolve: jest.fn().mockResolvedValue({
driver: opts.workspaceDriver,
chatModel: 'gpt-4o-mini',
apiKey: 'workspace-key',
baseUrl: undefined,
}),
};
const aiProviderCredentialsRepo = {
find: jest.fn().mockResolvedValue(
opts.credsApiKeyEnc ? { apiKeyEnc: opts.credsApiKeyEnc } : undefined,
),
};
const secretBox = {
decryptSecret: jest.fn().mockReturnValue('decrypted'),
};
const service = new AiService(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiSettings as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aiProviderCredentialsRepo as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
secretBox as any,
);
return { service, aiSettings, aiProviderCredentialsRepo, secretBox };
}
it('throws AiNotConfiguredException (503) naming the override driver when its creds are missing', async () => {
const { service, aiProviderCredentialsRepo } = makeService({
workspaceDriver: 'openai',
});
await expect(
service.getChatModel('ws-1', {
driver: 'gemini',
chatModel: 'gemini-2.0-flash',
roleName: 'Researcher',
}),
).rejects.toBeInstanceOf(AiNotConfiguredException);
// Re-run to assert the message names the driver (and role) for the admin.
await service
.getChatModel('ws-1', {
driver: 'gemini',
chatModel: 'gemini-2.0-flash',
roleName: 'Researcher',
})
.then(
() => {
throw new Error('expected getChatModel to throw');
},
(err: unknown) => {
expect(err).toBeInstanceOf(AiNotConfiguredException);
const message = (err as AiNotConfiguredException).message;
expect(message).toContain('gemini');
expect(message).toContain('Researcher');
},
);
// The override driver's creds were looked up for the right driver.
expect(aiProviderCredentialsRepo.find).toHaveBeenCalledWith('ws-1', 'gemini');
});
});

View File

@@ -14,6 +14,22 @@ import { AiNotConfiguredException } from './ai-not-configured.exception';
import { AiEmbeddingNotConfiguredException } from './ai-embedding-not-configured.exception';
import { AiSttNotConfiguredException } from './ai-stt-not-configured.exception';
import { describeProviderError } from './ai-error.util';
import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo';
import { SecretBoxService } from '../crypto/secret-box';
import { AiDriver } from './ai.types';
/**
* Optional chat-model override carried by an agent role (`ai_agent_roles.
* model_config`). `chatModel` swaps the model id; `driver` (optional) switches
* the whole provider, in which case its creds come from `ai_provider_credentials`
* for that driver. `roleName` is only used to produce a clear 503 message when
* the chosen driver is not configured.
*/
export interface ChatModelOverride {
driver?: AiDriver;
chatModel?: string;
roleName?: string;
}
/**
* Builds AI SDK language models from per-workspace config and runs cheap
@@ -27,23 +43,80 @@ import { describeProviderError } from './ai-error.util';
export class AiService {
private readonly logger = new Logger(AiService.name);
constructor(private readonly aiSettings: AiSettingsService) {}
constructor(
private readonly aiSettings: AiSettingsService,
private readonly aiProviderCredentialsRepo: AiProviderCredentialsRepo,
private readonly secretBox: SecretBoxService,
) {}
/**
* Resolve the workspace config and build the chat language model.
* Throws AiNotConfiguredException (→ 503) when the config is incomplete.
*
* `override` (from an agent role's `model_config`) optionally swaps the model
* id and/or the whole provider:
* - `override.chatModel` replaces the workspace chat model id;
* - `override.driver` (when it differs from the workspace driver) switches the
* provider, pulling that driver's creds from `ai_provider_credentials`. When
* those creds are missing the call throws a 503 naming the role's driver — a
* deliberate, explicit failure rather than a silent fallback. Resolved
* BEFORE the stream starts so the 503 surfaces as clean JSON.
*/
async getChatModel(workspaceId: string): Promise<LanguageModel> {
async getChatModel(
workspaceId: string,
override?: ChatModelOverride,
): Promise<LanguageModel> {
const cfg = await this.aiSettings.resolve(workspaceId);
if (
!cfg?.driver ||
!cfg?.chatModel ||
(cfg.driver !== 'ollama' && !cfg.apiKey)
) {
if (!cfg?.driver) {
throw new AiNotConfiguredException();
}
switch (cfg.driver) {
// Determine the effective driver + model + creds, applying the override.
const overrideDriver = override?.driver;
const driver: AiDriver = overrideDriver ?? cfg.driver;
const chatModel = override?.chatModel?.trim() || cfg.chatModel;
let apiKey = cfg.apiKey;
let baseUrl = cfg.baseUrl;
// A driver override that differs from the workspace driver needs that
// driver's own creds (the workspace driver's key would be wrong/absent).
if (overrideDriver && overrideDriver !== cfg.driver) {
if (overrideDriver === 'ollama') {
// Ollama needs no key; baseUrl is taken from the workspace config (it is
// the only configurable endpoint for a local model).
apiKey = undefined;
} else {
const creds = await this.aiProviderCredentialsRepo.find(
workspaceId,
overrideDriver,
);
apiKey = creds?.apiKeyEnc
? this.secretBox.decryptSecret(creds.apiKeyEnc)
: undefined;
if (!apiKey) {
// Explicit 503: the role chose a provider that is not set up. Name the
// driver (and role, when known) so the admin can fix it — no silent
// fallback to the workspace model (error-handling convention).
const who = override?.roleName ? ` for role "${override.roleName}"` : '';
throw new AiNotConfiguredException(
`The model provider "${overrideDriver}"${who} is selected but not ` +
`configured (no API key). Configure ${overrideDriver} in AI ` +
`settings or change the role's model.`,
);
}
// A cross-driver override does not carry the workspace baseUrl (that URL
// belongs to the workspace driver); use the provider default for the
// overridden driver.
baseUrl = undefined;
}
}
if (!chatModel || (driver !== 'ollama' && !apiKey)) {
throw new AiNotConfiguredException();
}
switch (driver) {
case 'openai':
// baseURL (when set) covers openai-compatible endpoints. Use Chat
// Completions (/chat/completions) — the portable OpenAI-compatible
@@ -51,14 +124,12 @@ export class AiService {
// Responses API (/responses), which OpenAI-compatible gateways
// (OpenRouter, etc.) reject on multi-turn requests (history with
// assistant messages) → 400.
return createOpenAI({ apiKey: cfg.apiKey, baseURL: cfg.baseUrl }).chat(
cfg.chatModel,
);
return createOpenAI({ apiKey, baseURL: baseUrl }).chat(chatModel);
case 'gemini':
return createGoogleGenerativeAI({ apiKey: cfg.apiKey })(cfg.chatModel);
return createGoogleGenerativeAI({ apiKey })(chatModel);
case 'ollama':
// Ollama needs no API key.
return createOllama({ baseURL: cfg.baseUrl })(cfg.chatModel);
return createOllama({ baseURL: baseUrl })(chatModel);
default:
throw new AiNotConfiguredException();
}