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

Merged
Ghost merged 5 commits from feat/ai-agent-roles into develop 2026-06-20 18:31:11 +03:00

Implements docs/ai-agent-roles-plan.md (v1 scope).

What

Reusable, workspace-shared agent roles for the built-in AI chat. A role = a named persona (system-prompt instructions) + emoji/description + optional model override. A new chat is bound to a role at creation; the role applies on every turn (persona + optional model), with a badge in the UI.

How

Backend

  • Migration 20260620T120000-ai-agent-roles.ts: adds ai_agent_roles (workspace-scoped, soft-delete) + ai_chats.role_id (ON DELETE SET NULL). Additive only. db.d.ts/entity.types.ts are hand-merged (this repo keeps db.d.ts hand-curated; a full migration:codegen regenerates all 41 tables and clobbers the partial hand-maintained types — so I ran codegen to confirm the emitted shape, then surgically added AiAgentRoles + AiChats.roleId + the DB map entry).
  • core/ai-chat/roles/ CRUD module. CASL: list = any workspace member (for the picker); create/update/delete = admin (Manage Settings, mirroring ai-settings/mcp-servers). Every repo query scoped by workspace_id; validation: non-empty name+instructions, override driver ∈ openai|gemini|ollama.
  • buildSystemPrompt(roleInstructions?): role replaces the persona base (admin prompt / DEFAULT_PROMPT), but workspace context + SAFETY_FRAMEWORK are always appended (a role can't drop the safety layer).
  • stream() resolves the role from ai_chats.role_id for existing chats (never the request body → no per-turn role swap); body.roleId is honored only at chat creation. Disabled (enabled=false) and soft-deleted roles fall back to the universal assistant.
  • getChatModel(workspaceId, override): a role model_config can swap the model id / driver; a driver without configured credentials throws 503 with a clear message naming the driver + role, resolved before response hijack (never mid-stream).

Client

  • New-chat role picker (lists enabled roles only, default "Universal assistant"); roleId sent only on the first message. Role badge (emoji+name) in the chat header + conversation list. Admin "Agent roles" management section in Settings → AI (add/edit/delete via the MCP-form modal pattern). New query hooks + IAiRole types; 21 i18n keys.

Reasoning / decisions (resolved the plan's open questions)

  • CRUD under /api/ai-chat/roles; role replaces persona (so a narrow role like "Proofreader" dominates) while safety stays; soft-delete; 503 (not silent fallback) on a misconfigured override driver — explicitness over surprise; no seed presets and no tool gating (kept v1 tight); badge via JOIN (no denormalization).

Review findings & fixes

Ran an adversarial backend review. It explicitly confirmed the three critical scenarios are closed: a non-admin cannot create/edit/delete roles; cross-workspace role access is blocked (all queries workspace_id-scoped, foreign roleId resolves to null at bind); and an existing chat's role cannot be swapped per-request (resolved from the stored row). Migration/safety/503 verified correct. Findings fixed:

  • WARNING — disabled roles still applied: resolveRoleForRequest filtered only deleted_at, so a disabled role kept applying to existing chats. Fixed server-authoritatively (if (!role || !role.enabled) return null), and the chat picker now filters to enabled roles (settings list still shows all for management).
  • SUGGESTION — missing 503 test: added ai.service.spec.ts asserting an override on an unconfigured driver throws AiNotConfiguredException with the driver+role name.

Verification

  • pnpm --filter server build + pnpm --filter client build — clean.
  • pnpm --filter server test -- ai-chat (17) + ai.service (1) — pass. Prompt spec covers role layering + safety-always-present (incl. a jailbreak instruction).
  • Browser (headless Chromium, live z.ai): as admin created a "Pirate" role in Settings → AI; new-chat picker listed it (enabled-only, default Universal); selecting it + sending a message produced an in-character pirate reply (proves instructions reached the system prompt) while tool boundaries stayed intact; role badge shown in header + conversation list; picker gone for the existing chat (role fixed). No app errors. Screenshots captured.

🤖 Generated with Claude Code

Implements `docs/ai-agent-roles-plan.md` (v1 scope). ## What Reusable, workspace-shared **agent roles** for the built-in AI chat. A role = a named persona (system-prompt `instructions`) + emoji/description + optional model override. A new chat is bound to a role at creation; the role applies on every turn (persona + optional model), with a badge in the UI. ## How **Backend** - Migration `20260620T120000-ai-agent-roles.ts`: adds `ai_agent_roles` (workspace-scoped, soft-delete) + `ai_chats.role_id` (`ON DELETE SET NULL`). Additive only. `db.d.ts`/`entity.types.ts` are **hand-merged** (this repo keeps `db.d.ts` hand-curated; a full `migration:codegen` regenerates all 41 tables and clobbers the partial hand-maintained types — so I ran codegen to confirm the emitted shape, then surgically added `AiAgentRoles` + `AiChats.roleId` + the `DB` map entry). - `core/ai-chat/roles/` CRUD module. **CASL:** `list` = any workspace member (for the picker); `create/update/delete` = admin (`Manage Settings`, mirroring `ai-settings`/`mcp-servers`). Every repo query scoped by `workspace_id`; validation: non-empty name+instructions, override driver ∈ `openai|gemini|ollama`. - `buildSystemPrompt(roleInstructions?)`: role **replaces** the persona base (admin prompt / `DEFAULT_PROMPT`), but workspace context + `SAFETY_FRAMEWORK` are **always** appended (a role can't drop the safety layer). - `stream()` resolves the role from `ai_chats.role_id` for existing chats (never the request body → no per-turn role swap); `body.roleId` is honored only at chat creation. Disabled (`enabled=false`) and soft-deleted roles fall back to the universal assistant. - `getChatModel(workspaceId, override)`: a role `model_config` can swap the model id / driver; a driver without configured credentials throws **503** with a clear message naming the driver + role, resolved **before** response hijack (never mid-stream). **Client** - New-chat role picker (lists **enabled** roles only, default "Universal assistant"); `roleId` sent only on the first message. Role badge (emoji+name) in the chat header + conversation list. Admin "Agent roles" management section in Settings → AI (add/edit/delete via the MCP-form modal pattern). New query hooks + `IAiRole` types; 21 i18n keys. ## Reasoning / decisions (resolved the plan's open questions) - CRUD under `/api/ai-chat/roles`; role **replaces** persona (so a narrow role like "Proofreader" dominates) while safety stays; **soft-delete**; **503** (not silent fallback) on a misconfigured override driver — explicitness over surprise; **no seed presets** and **no tool gating** (kept v1 tight); badge via JOIN (no denormalization). ## Review findings & fixes Ran an adversarial backend review. It explicitly confirmed the three critical scenarios are **closed**: a non-admin cannot create/edit/delete roles; cross-workspace role access is blocked (all queries `workspace_id`-scoped, foreign `roleId` resolves to null at bind); and an existing chat's role cannot be swapped per-request (resolved from the stored row). Migration/safety/503 verified correct. Findings fixed: - **WARNING — disabled roles still applied:** `resolveRoleForRequest` filtered only `deleted_at`, so a disabled role kept applying to existing chats. Fixed server-authoritatively (`if (!role || !role.enabled) return null`), and the chat picker now filters to enabled roles (settings list still shows all for management). - **SUGGESTION — missing 503 test:** added `ai.service.spec.ts` asserting an override on an unconfigured driver throws `AiNotConfiguredException` with the driver+role name. ## Verification - `pnpm --filter server build` + `pnpm --filter client build` — clean. - `pnpm --filter server test -- ai-chat` (17) + `ai.service` (1) — pass. Prompt spec covers role layering + safety-always-present (incl. a jailbreak instruction). - Browser (headless Chromium, live z.ai): as admin created a "Pirate" role in Settings → AI; new-chat picker listed it (enabled-only, default Universal); selecting it + sending a message produced an in-character pirate reply (**proves instructions reached the system prompt**) while tool boundaries stayed intact; role badge shown in header + conversation list; picker gone for the existing chat (role fixed). No app errors. Screenshots captured. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ghost added 2 commits 2026-06-20 06:30:42 +03:00
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>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-20 14:03:15 +03:00
Release-cycle review: update() re-read the role via findById (filters
deleted_at IS NULL) and passed it straight to toView(updated as AiAgentRole).
A concurrent soft-delete between the UPDATE and the re-fetch makes findById
return undefined, and toView(undefined) dereferences row.id -> opaque 500. Add
the same 'Role not found' guard remove() already uses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost added 1 commit 2026-06-20 14:20:19 +03:00
Release-cycle test audit found the role feature's security-critical paths
untested. Adds real unit tests (against the actual functions):
- resolveRoleForRequest invariants: role comes from chat.roleId not body.roleId
  (no per-turn swap), lookup scoped to workspace.id, disabled/soft-deleted role
  -> null, new-chat uses body.roleId, stale chatId falls back.
- CASL admin gate: non-admin create/update/delete -> Forbidden and service not
  called; admin delegates with workspace.id; list() is member-reachable.
- roleModelOverride: unknown driver dropped (never reaches getChatModel's
  throwing default), valid override passes through, blanks ignored.
- getChatModel override success path (cross-driver fetch + decrypt; chatModel-
  only reuse), and service update/remove cross-workspace 'not found' guards +
  modelConfig tri-state.
Tiny fix: findByCreator badge left-join now also requires enabled=true, so a
disabled role (downgraded to universal by resolveRoleForRequest) no longer shows
a misleading chat-list badge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost force-pushed feat/ai-agent-roles from 20a1780977 to 24bf0ab18f 2026-06-20 15:54:45 +03:00 Compare
Ghost force-pushed feat/ai-agent-roles from 24bf0ab18f to 20a1780977 2026-06-20 16:02:58 +03:00 Compare
vvzvlad added 1 commit 2026-06-20 18:30:59 +03:00
Follow-up fixes on the agent-roles feature:

- ai.service: a cross-driver override to the ollama driver (when the
  workspace driver is not ollama) now fails with an explicit 503 instead
  of silently reusing the workspace base URL, which belongs to a different
  provider. Same-driver ollama and openai/gemini overrides are unchanged.
- migration: add a partial unique index on (workspace_id, name) WHERE
  deleted_at IS NULL so role names are unique per workspace without
  soft-deleted rows blocking re-creation; map Postgres 23505 to a 409
  ConflictException on create/update.
- dto: validate the role id as @IsUUID instead of @IsString.
- roles list: do not expose instructions/modelConfig to non-admin members.
  The list endpoint now returns a picker view (id/name/emoji/description/
  enabled) to members and the full view only to admins (same gate as the
  CRUD endpoints). Client IAiRole fields made optional accordingly.

Adds tests for the cross-driver-ollama throw, the 23505->409 mapping, and
the non-admin picker-view security invariant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost merged commit 4c1d1aa2ee into develop 2026-06-20 18:31:11 +03:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#11