From 85aeebbde28860b30125525c00205dac20786e27 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 21:32:03 -0500 Subject: [PATCH] feat(gateway): PreferencesService + /preferences REST + /system Valkey override (P8-011) - PreferencesService: platform defaults, user overrides, IMMUTABLE_KEYS enforcement - PreferencesController: GET /api/preferences, POST /api/preferences, DELETE /api/preferences/:key - PreferencesModule: global module exporting PreferencesService and SystemOverrideService - SystemOverrideService: Valkey-backed session-scoped system prompt override with 5-min TTL + renew - CommandRegistryService: register /system command (socket execution) - CommandExecutorService: handle /system command via SystemOverrideService - AgentService: inject system override before each prompt turn, renew TTL; store userId in session - ChatGateway: pass userId when creating agent sessions - PreferencesService unit tests: 11 tests covering defaults, overrides, enforcement wins, immutable key errors Co-Authored-By: Claude Sonnet 4.6 --- apps/gateway/src/agent/agent.service.ts | 29 ++- apps/gateway/src/app.module.ts | 2 + apps/gateway/src/chat/chat.gateway.ts | 2 + .../src/commands/command-executor.service.ts | 28 +++ .../src/commands/command-registry.service.ts | 16 ++ .../src/preferences/preferences.controller.ts | 44 +++++ .../src/preferences/preferences.module.ts | 12 ++ .../preferences/preferences.service.spec.ts | 167 ++++++++++++++++++ .../src/preferences/preferences.service.ts | 119 +++++++++++++ .../preferences/system-override.service.ts | 33 ++++ 10 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 apps/gateway/src/preferences/preferences.controller.ts create mode 100644 apps/gateway/src/preferences/preferences.module.ts create mode 100644 apps/gateway/src/preferences/preferences.service.spec.ts create mode 100644 apps/gateway/src/preferences/preferences.service.ts create mode 100644 apps/gateway/src/preferences/system-override.service.ts diff --git a/apps/gateway/src/agent/agent.service.ts b/apps/gateway/src/agent/agent.service.ts index 73b60e8..18efdab 100644 --- a/apps/gateway/src/agent/agent.service.ts +++ b/apps/gateway/src/agent/agent.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common'; +import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common'; import { createAgentSession, DefaultResourceLoader, @@ -24,6 +24,8 @@ import { createGitTools } from './tools/git-tools.js'; import { createShellTools } from './tools/shell-tools.js'; import { createWebTools } from './tools/web-tools.js'; import type { SessionInfoDto } from './session.dto.js'; +import { SystemOverrideService } from '../preferences/system-override.service.js'; +import { PreferencesService } from '../preferences/preferences.service.js'; export interface AgentSessionOptions { provider?: string; @@ -55,6 +57,8 @@ export interface AgentSessionOptions { * take precedence over config values. */ agentConfigId?: string; + /** ID of the user who owns this session. Used for preferences and system override lookups. */ + userId?: string; } export interface AgentSession { @@ -73,6 +77,8 @@ export interface AgentSession { sandboxDir: string; /** Tool names available in this session, or null when all tools are available. */ allowedTools: string[] | null; + /** User ID that owns this session, used for preference lookups. */ + userId?: string; } @Injectable() @@ -89,6 +95,12 @@ export class AgentService implements OnModuleDestroy { @Inject(CoordService) private readonly coordService: CoordService, @Inject(McpClientService) private readonly mcpClientService: McpClientService, @Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService, + @Optional() + @Inject(SystemOverrideService) + private readonly systemOverride: SystemOverrideService | null, + @Optional() + @Inject(PreferencesService) + private readonly preferencesService: PreferencesService | null, ) {} /** @@ -285,6 +297,7 @@ export class AgentService implements OnModuleDestroy { skillPromptAdditions: promptAdditions, sandboxDir, allowedTools, + userId: mergedOptions?.userId, }; this.sessions.set(sessionId, session); @@ -368,8 +381,20 @@ export class AgentService implements OnModuleDestroy { throw new Error(`No agent session found: ${sessionId}`); } session.promptCount += 1; + + // Prepend session-scoped system override if present (renew TTL on each turn) + let effectiveMessage = message; + if (this.systemOverride) { + const override = await this.systemOverride.get(sessionId); + if (override) { + effectiveMessage = `[System Override]\n${override}\n\n${message}`; + await this.systemOverride.renew(sessionId); + this.logger.debug(`Applied system override for session ${sessionId}`); + } + } + try { - await session.piSession.prompt(message); + await session.piSession.prompt(effectiveMessage); } catch (err) { this.logger.error( `Prompt failed for session=${sessionId}, messageLength=${message.length}`, diff --git a/apps/gateway/src/app.module.ts b/apps/gateway/src/app.module.ts index bfa24f8..00a1139 100644 --- a/apps/gateway/src/app.module.ts +++ b/apps/gateway/src/app.module.ts @@ -18,6 +18,7 @@ import { PluginModule } from './plugin/plugin.module.js'; import { McpModule } from './mcp/mcp.module.js'; import { AdminModule } from './admin/admin.module.js'; import { CommandsModule } from './commands/commands.module.js'; +import { PreferencesModule } from './preferences/preferences.module.js'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; @Module({ @@ -39,6 +40,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; PluginModule, McpModule, AdminModule, + PreferencesModule, CommandsModule, ], controllers: [HealthController], diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index e206c5c..1b1b3f5 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -87,10 +87,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa try { let agentSession = this.agentService.getSession(conversationId); if (!agentSession) { + const userId = (client.data.user as { id: string } | undefined)?.id; agentSession = await this.agentService.createSession(conversationId, { provider: data.provider, modelId: data.modelId, agentConfigId: data.agentId, + userId, }); } } catch (err) { diff --git a/apps/gateway/src/commands/command-executor.service.ts b/apps/gateway/src/commands/command-executor.service.ts index 5b97a99..0d9c5af 100644 --- a/apps/gateway/src/commands/command-executor.service.ts +++ b/apps/gateway/src/commands/command-executor.service.ts @@ -2,6 +2,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaic/types'; import { AgentService } from '../agent/agent.service.js'; import { CommandRegistryService } from './command-registry.service.js'; +import { SystemOverrideService } from '../preferences/system-override.service.js'; @Injectable() export class CommandExecutorService { @@ -10,6 +11,7 @@ export class CommandExecutorService { constructor( @Inject(CommandRegistryService) private readonly registry: CommandRegistryService, @Inject(AgentService) private readonly agentService: AgentService, + @Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService, ) {} async execute(payload: SlashCommandPayload, _userId: string): Promise { @@ -31,6 +33,8 @@ export class CommandExecutorService { return await this.handleModel(args ?? null, conversationId); case 'thinking': return await this.handleThinking(args ?? null, conversationId); + case 'system': + return await this.handleSystem(args ?? null, conversationId); case 'new': return { command, @@ -124,4 +128,28 @@ export class CommandExecutorService { message: `Thinking level set to "${level}".`, }; } + + private async handleSystem( + args: string | null, + conversationId: string, + ): Promise { + if (!args || args.trim().length === 0) { + // Clear the override when called with no args + await this.systemOverride.clear(conversationId); + return { + command: 'system', + conversationId, + success: true, + message: 'Session system prompt override cleared.', + }; + } + + await this.systemOverride.set(conversationId, args.trim()); + return { + command: 'system', + conversationId, + success: true, + message: `Session system prompt override set (expires in 5 minutes of inactivity).`, + }; + } } diff --git a/apps/gateway/src/commands/command-registry.service.ts b/apps/gateway/src/commands/command-registry.service.ts index 53b9cfc..e286e51 100644 --- a/apps/gateway/src/commands/command-registry.service.ts +++ b/apps/gateway/src/commands/command-registry.service.ts @@ -156,6 +156,22 @@ export class CommandRegistryService implements OnModuleInit { execution: 'rest', available: true, }, + { + name: 'system', + description: 'Set session-scoped system prompt override', + aliases: [], + args: [ + { + name: 'override', + type: 'string', + optional: false, + description: 'System prompt text to inject for this session', + }, + ], + scope: 'core', + execution: 'socket', + available: true, + }, { name: 'status', description: 'Show session and connection status', diff --git a/apps/gateway/src/preferences/preferences.controller.ts b/apps/gateway/src/preferences/preferences.controller.ts new file mode 100644 index 0000000..9b01231 --- /dev/null +++ b/apps/gateway/src/preferences/preferences.controller.ts @@ -0,0 +1,44 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Inject, + Param, + Post, + UseGuards, +} from '@nestjs/common'; +import { PreferencesService } from './preferences.service.js'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { CurrentUser } from '../auth/current-user.decorator.js'; + +@Controller('api/preferences') +@UseGuards(AuthGuard) +export class PreferencesController { + constructor(@Inject(PreferencesService) private readonly preferences: PreferencesService) {} + + @Get() + async show(@CurrentUser() user: { id: string }): Promise> { + return this.preferences.getEffective(user.id); + } + + @Post() + @HttpCode(HttpStatus.OK) + async set( + @CurrentUser() user: { id: string }, + @Body() body: { key: string; value: unknown }, + ): Promise<{ success: boolean; message: string }> { + return this.preferences.set(user.id, body.key, body.value); + } + + @Delete(':key') + @HttpCode(HttpStatus.OK) + async reset( + @CurrentUser() user: { id: string }, + @Param('key') key: string, + ): Promise<{ success: boolean; message: string }> { + return this.preferences.reset(user.id, key); + } +} diff --git a/apps/gateway/src/preferences/preferences.module.ts b/apps/gateway/src/preferences/preferences.module.ts new file mode 100644 index 0000000..20b4db9 --- /dev/null +++ b/apps/gateway/src/preferences/preferences.module.ts @@ -0,0 +1,12 @@ +import { Global, Module } from '@nestjs/common'; +import { PreferencesService } from './preferences.service.js'; +import { PreferencesController } from './preferences.controller.js'; +import { SystemOverrideService } from './system-override.service.js'; + +@Global() +@Module({ + controllers: [PreferencesController], + providers: [PreferencesService, SystemOverrideService], + exports: [PreferencesService, SystemOverrideService], +}) +export class PreferencesModule {} diff --git a/apps/gateway/src/preferences/preferences.service.spec.ts b/apps/gateway/src/preferences/preferences.service.spec.ts new file mode 100644 index 0000000..9a46665 --- /dev/null +++ b/apps/gateway/src/preferences/preferences.service.spec.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi } from 'vitest'; +import { PreferencesService, PLATFORM_DEFAULTS, IMMUTABLE_KEYS } from './preferences.service.js'; +import type { Db } from '@mosaic/db'; + +/** + * Build a mock Drizzle DB where the select chain supports: + * db.select().from().where() → resolves to `listRows` + * db.select().from().where().limit(n) → resolves to `singleRow` + */ +function makeMockDb( + listRows: Array<{ key: string; value: unknown }> = [], + singleRow: Array<{ id: string }> = [], +): Db { + const chainWithLimit = { + limit: vi.fn().mockResolvedValue(singleRow), + then: (resolve: (v: typeof listRows) => unknown) => Promise.resolve(listRows).then(resolve), + }; + const selectFrom = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnValue(chainWithLimit), + }; + const updateResult = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([]), + }; + const deleteResult = { + where: vi.fn().mockResolvedValue([]), + }; + const insertResult = { + values: vi.fn().mockResolvedValue([]), + }; + + return { + select: vi.fn().mockReturnValue(selectFrom), + update: vi.fn().mockReturnValue(updateResult), + delete: vi.fn().mockReturnValue(deleteResult), + insert: vi.fn().mockReturnValue(insertResult), + } as unknown as Db; +} + +describe('PreferencesService', () => { + describe('getEffective', () => { + it('returns platform defaults when user has no overrides', async () => { + const db = makeMockDb([]); + const service = new PreferencesService(db); + const result = await service.getEffective('user-1'); + + expect(result['agent.thinkingLevel']).toBe('auto'); + expect(result['agent.streamingEnabled']).toBe(true); + expect(result['session.autoCompactEnabled']).toBe(true); + expect(result['session.autoCompactThreshold']).toBe(0.8); + }); + + it('applies user overrides for mutable keys', async () => { + const db = makeMockDb([ + { key: 'agent.thinkingLevel', value: 'high' }, + { key: 'response.language', value: 'es' }, + ]); + + const service = new PreferencesService(db); + const result = await service.getEffective('user-1'); + + expect(result['agent.thinkingLevel']).toBe('high'); + expect(result['response.language']).toBe('es'); + }); + + it('ignores user overrides for immutable keys — enforcement always wins', async () => { + const db = makeMockDb([ + { key: 'limits.maxThinkingLevel', value: 'high' }, + { key: 'limits.rateLimit', value: 9999 }, + ]); + + const service = new PreferencesService(db); + const result = await service.getEffective('user-1'); + + // Should still be null (platform default), not the user-supplied values + expect(result['limits.maxThinkingLevel']).toBeNull(); + expect(result['limits.rateLimit']).toBeNull(); + }); + }); + + describe('set', () => { + it('returns error when attempting to override an immutable key', async () => { + const db = makeMockDb(); + const service = new PreferencesService(db); + + const result = await service.set('user-1', 'limits.maxThinkingLevel', 'high'); + expect(result.success).toBe(false); + expect(result.message).toContain('platform enforcement'); + }); + + it('returns error when attempting to override limits.rateLimit', async () => { + const db = makeMockDb(); + const service = new PreferencesService(db); + + const result = await service.set('user-1', 'limits.rateLimit', 100); + expect(result.success).toBe(false); + expect(result.message).toContain('platform enforcement'); + }); + + it('upserts a mutable preference and returns success — insert path', async () => { + // singleRow=[] → no existing row → insert path + const db = makeMockDb([], []); + const service = new PreferencesService(db); + const result = await service.set('user-1', 'agent.thinkingLevel', 'high'); + expect(result.success).toBe(true); + expect(result.message).toContain('"agent.thinkingLevel"'); + }); + + it('upserts a mutable preference and returns success — update path', async () => { + // singleRow has an id → existing row → update path + const db = makeMockDb([], [{ id: 'existing-id' }]); + const service = new PreferencesService(db); + const result = await service.set('user-1', 'agent.thinkingLevel', 'low'); + expect(result.success).toBe(true); + expect(result.message).toContain('"agent.thinkingLevel"'); + }); + }); + + describe('reset', () => { + it('returns error when attempting to reset an immutable key', async () => { + const db = makeMockDb(); + const service = new PreferencesService(db); + + const result = await service.reset('user-1', 'limits.rateLimit'); + expect(result.success).toBe(false); + expect(result.message).toContain('platform enforcement'); + }); + + it('deletes user override and returns default value in message', async () => { + const db = makeMockDb(); + const service = new PreferencesService(db); + const result = await service.reset('user-1', 'agent.thinkingLevel'); + + expect(result.success).toBe(true); + expect(result.message).toContain('"auto"'); // platform default for agent.thinkingLevel + }); + }); + + describe('IMMUTABLE_KEYS', () => { + it('contains only the enforcement keys', () => { + expect(IMMUTABLE_KEYS.has('limits.maxThinkingLevel')).toBe(true); + expect(IMMUTABLE_KEYS.has('limits.rateLimit')).toBe(true); + expect(IMMUTABLE_KEYS.has('agent.thinkingLevel')).toBe(false); + }); + }); + + describe('PLATFORM_DEFAULTS', () => { + it('has all expected keys', () => { + const expectedKeys = [ + 'agent.defaultModel', + 'agent.thinkingLevel', + 'agent.streamingEnabled', + 'response.language', + 'response.codeAnnotations', + 'safety.confirmDestructiveTools', + 'session.autoCompactThreshold', + 'session.autoCompactEnabled', + 'limits.maxThinkingLevel', + 'limits.rateLimit', + ]; + for (const key of expectedKeys) { + expect(Object.prototype.hasOwnProperty.call(PLATFORM_DEFAULTS, key)).toBe(true); + } + }); + }); +}); diff --git a/apps/gateway/src/preferences/preferences.service.ts b/apps/gateway/src/preferences/preferences.service.ts new file mode 100644 index 0000000..9c08bd6 --- /dev/null +++ b/apps/gateway/src/preferences/preferences.service.ts @@ -0,0 +1,119 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { eq, and, type Db, preferences as preferencesTable } from '@mosaic/db'; +import { DB } from '../database/database.module.js'; + +export const PLATFORM_DEFAULTS: Record = { + 'agent.defaultModel': null, + 'agent.thinkingLevel': 'auto', + 'agent.streamingEnabled': true, + 'response.language': 'auto', + 'response.codeAnnotations': true, + 'safety.confirmDestructiveTools': true, + 'session.autoCompactThreshold': 0.8, + 'session.autoCompactEnabled': true, + 'limits.maxThinkingLevel': null, + 'limits.rateLimit': null, +}; + +export const IMMUTABLE_KEYS = new Set(['limits.maxThinkingLevel', 'limits.rateLimit']); + +@Injectable() +export class PreferencesService { + private readonly logger = new Logger(PreferencesService.name); + + constructor(@Inject(DB) private readonly db: Db) {} + + /** + * Returns the effective preference set for a user: + * Platform defaults → user overrides (mutable keys only) → enforcements re-applied last + */ + async getEffective(userId: string): Promise> { + const userPrefs = await this.getUserPrefs(userId); + const result: Record = { ...PLATFORM_DEFAULTS }; + + for (const [key, value] of Object.entries(userPrefs)) { + if (!IMMUTABLE_KEYS.has(key)) { + result[key] = value; + } + } + + // Re-apply immutable keys (enforcements always win) + for (const key of IMMUTABLE_KEYS) { + result[key] = PLATFORM_DEFAULTS[key]; + } + + return result; + } + + async set( + userId: string, + key: string, + value: unknown, + ): Promise<{ success: boolean; message: string }> { + if (IMMUTABLE_KEYS.has(key)) { + return { + success: false, + message: `Cannot override "${key}" — this is a platform enforcement. Contact your admin.`, + }; + } + + await this.upsertPref(userId, key, value); + return { success: true, message: `Preference "${key}" set to ${JSON.stringify(value)}.` }; + } + + async reset(userId: string, key: string): Promise<{ success: boolean; message: string }> { + if (IMMUTABLE_KEYS.has(key)) { + return { success: false, message: `Cannot reset "${key}" — it is a platform enforcement.` }; + } + + await this.deletePref(userId, key); + const defaultVal = PLATFORM_DEFAULTS[key]; + return { + success: true, + message: `Preference "${key}" reset to default: ${JSON.stringify(defaultVal)}.`, + }; + } + + private async getUserPrefs(userId: string): Promise> { + const rows = await this.db + .select({ key: preferencesTable.key, value: preferencesTable.value }) + .from(preferencesTable) + .where(eq(preferencesTable.userId, userId)); + + const result: Record = {}; + for (const row of rows) { + result[row.key] = row.value; + } + return result; + } + + private async upsertPref(userId: string, key: string, value: unknown): Promise { + const existing = await this.db + .select({ id: preferencesTable.id }) + .from(preferencesTable) + .where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key))) + .limit(1); + + if (existing.length > 0) { + await this.db + .update(preferencesTable) + .set({ value: value as never, updatedAt: new Date() }) + .where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key))); + } else { + await this.db.insert(preferencesTable).values({ + userId, + key, + value: value as never, + mutable: true, + }); + } + this.logger.debug(`Upserted preference "${key}" for user ${userId}`); + } + + private async deletePref(userId: string, key: string): Promise { + await this.db + .delete(preferencesTable) + .where(and(eq(preferencesTable.userId, userId), eq(preferencesTable.key, key))); + this.logger.debug(`Deleted preference "${key}" for user ${userId}`); + } +} diff --git a/apps/gateway/src/preferences/system-override.service.ts b/apps/gateway/src/preferences/system-override.service.ts new file mode 100644 index 0000000..97a44c9 --- /dev/null +++ b/apps/gateway/src/preferences/system-override.service.ts @@ -0,0 +1,33 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { createQueue, type QueueHandle } from '@mosaic/queue'; + +const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`; +const TTL_SECONDS = 5 * 60; // 5 minutes, renewed on each turn + +@Injectable() +export class SystemOverrideService { + private readonly logger = new Logger(SystemOverrideService.name); + private readonly handle: QueueHandle; + + constructor() { + this.handle = createQueue(); + } + + async set(sessionId: string, override: string): Promise { + await this.handle.redis.setex(SESSION_SYSTEM_KEY(sessionId), TTL_SECONDS, override); + this.logger.debug(`Set system override for session ${sessionId} (TTL=${TTL_SECONDS}s)`); + } + + async get(sessionId: string): Promise { + return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId)); + } + + async renew(sessionId: string): Promise { + await this.handle.redis.expire(SESSION_SYSTEM_KEY(sessionId), TTL_SECONDS); + } + + async clear(sessionId: string): Promise { + await this.handle.redis.del(SESSION_SYSTEM_KEY(sessionId)); + this.logger.debug(`Cleared system override for session ${sessionId}`); + } +}