diff --git a/openapi.json b/openapi.json index 973621a..276f54a 100644 --- a/openapi.json +++ b/openapi.json @@ -2001,6 +2001,26 @@ "description": "Optional agent identifier. Silently dropped if empty / non-string.", "type": "string" }, + "config_override": { + "additionalProperties": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "description": "Optional per-request overlay on RuntimeConfig. Keys correspond to RuntimeConfig field names; values must be primitives (boolean / number / string / null). Unknown keys are accepted but surfaced via the X-Atomicmem-Unknown-Override-Keys response header and a server-side warning log — they do not cause a 400. Scope: just this request — no server mutation.", + "type": "object" + }, "conversation": { "description": "Required. conversation.", "minLength": 1, @@ -2173,6 +2193,26 @@ "description": "Optional agent identifier. Silently dropped if empty / non-string.", "type": "string" }, + "config_override": { + "additionalProperties": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "description": "Optional per-request overlay on RuntimeConfig. Keys correspond to RuntimeConfig field names; values must be primitives (boolean / number / string / null). Unknown keys are accepted but surfaced via the X-Atomicmem-Unknown-Override-Keys response header and a server-side warning log — they do not cause a 400. Scope: just this request — no server mutation.", + "type": "object" + }, "conversation": { "description": "Required. conversation.", "minLength": 1, @@ -3439,6 +3479,26 @@ "format": "date-time", "type": "string" }, + "config_override": { + "additionalProperties": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "description": "Optional per-request overlay on RuntimeConfig. Keys correspond to RuntimeConfig field names; values must be primitives (boolean / number / string / null). Unknown keys are accepted but surfaced via the X-Atomicmem-Unknown-Override-Keys response header and a server-side warning log — they do not cause a 400. Scope: just this request — no server mutation.", + "type": "object" + }, "limit": { "maximum": 100, "minimum": 1, @@ -3933,6 +3993,26 @@ "format": "date-time", "type": "string" }, + "config_override": { + "additionalProperties": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "description": "Optional per-request overlay on RuntimeConfig. Keys correspond to RuntimeConfig field names; values must be primitives (boolean / number / string / null). Unknown keys are accepted but surfaced via the X-Atomicmem-Unknown-Override-Keys response header and a server-side warning log — they do not cause a 400. Scope: just this request — no server mutation.", + "type": "object" + }, "limit": { "maximum": 100, "minimum": 1, diff --git a/openapi.yaml b/openapi.yaml index 2ceaa04..7e32d0b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1350,6 +1350,15 @@ paths: agent_id: description: Optional agent identifier. Silently dropped if empty / non-string. type: string + config_override: + additionalProperties: + anyOf: + - type: boolean + - type: number + - type: string + - type: "null" + description: "Optional per-request overlay on RuntimeConfig. Keys correspond to RuntimeConfig field names; values must be primitives (boolean / number / string / null). Unknown keys are accepted but surfaced via the X-Atomicmem-Unknown-Override-Keys response header and a server-side warning log — they do not cause a 400. Scope: just this request — no server mutation." + type: object conversation: description: Required. conversation. minLength: 1 @@ -1469,6 +1478,15 @@ paths: agent_id: description: Optional agent identifier. Silently dropped if empty / non-string. type: string + config_override: + additionalProperties: + anyOf: + - type: boolean + - type: number + - type: string + - type: "null" + description: "Optional per-request overlay on RuntimeConfig. Keys correspond to RuntimeConfig field names; values must be primitives (boolean / number / string / null). Unknown keys are accepted but surfaced via the X-Atomicmem-Unknown-Override-Keys response header and a server-side warning log — they do not cause a 400. Scope: just this request — no server mutation." + type: object conversation: description: Required. conversation. minLength: 1 @@ -2315,6 +2333,15 @@ paths: example: 2026-01-15T12:00:00Z format: date-time type: string + config_override: + additionalProperties: + anyOf: + - type: boolean + - type: number + - type: string + - type: "null" + description: "Optional per-request overlay on RuntimeConfig. Keys correspond to RuntimeConfig field names; values must be primitives (boolean / number / string / null). Unknown keys are accepted but surfaced via the X-Atomicmem-Unknown-Override-Keys response header and a server-side warning log — they do not cause a 400. Scope: just this request — no server mutation." + type: object limit: maximum: 100 minimum: 1 @@ -2654,6 +2681,15 @@ paths: example: 2026-01-15T12:00:00Z format: date-time type: string + config_override: + additionalProperties: + anyOf: + - type: boolean + - type: number + - type: string + - type: "null" + description: "Optional per-request overlay on RuntimeConfig. Keys correspond to RuntimeConfig field names; values must be primitives (boolean / number / string / null). Unknown keys are accepted but surfaced via the X-Atomicmem-Unknown-Override-Keys response header and a server-side warning log — they do not cause a 400. Scope: just this request — no server mutation." + type: object limit: maximum: 100 minimum: 1 diff --git a/src/__tests__/memory-route-config-override.test.ts b/src/__tests__/memory-route-config-override.test.ts new file mode 100644 index 0000000..2fe672e --- /dev/null +++ b/src/__tests__/memory-route-config-override.test.ts @@ -0,0 +1,244 @@ +/** + * Route-level integration tests for the `config_override` body field + * on POST /memories/{search, search/fast, ingest, ingest/quick}. + * + * Pins four observable behaviors: + * 1. Absent override → no `X-Atomicmem-Config-Override-*` headers + * (zero-cost path) and the service receives the startup config + * (effectiveConfig undefined). + * 2. Present override → all three headers emitted + * (applied=true, hash=sha256:, keys=sorted csv). + * 3. Search routes forward `effectiveConfig` via the scopedSearch + * options bag; ingest routes forward it as the trailing arg. + * 4. Unknown override keys do NOT 400 (the schema is permissive so + * new RuntimeConfig fields don't require a schema landing to be + * usable). They are carried through on the effective config AND + * surfaced via the `X-Atomicmem-Unknown-Override-Keys` response + * header + a server-side warning log. + */ + +import express from 'express'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMemoryRouter } from '../routes/memories.js'; +import type { MemoryService } from '../services/memory-service.js'; +import { type BootedApp, bindEphemeral } from '../app/bind-ephemeral.js'; + +const ROUTE_CONFIG = { + retrievalProfile: 'override-test-profile', + embeddingProvider: 'openai' as const, + embeddingModel: 'm', + llmProvider: 'openai' as const, + llmModel: 'm', + clarificationConflictThreshold: 0.9, + maxSearchResults: 20, + hybridSearchEnabled: false, + iterativeRetrievalEnabled: false, + entityGraphEnabled: false, + crossEncoderEnabled: false, + agenticRetrievalEnabled: false, + repairLoopEnabled: false, + runtimeConfigMutationEnabled: true, +}; + +describe('POST /memories/* — per-request config_override', () => { + let booted: BootedApp; + const scopedSearch = vi.fn(); + const ingest = vi.fn(); + const quickIngest = vi.fn(); + + beforeAll(async () => { + scopedSearch.mockResolvedValue({ + memories: [], injectionText: '', citations: [], retrievalMode: 'flat', + }); + ingest.mockResolvedValue({ + episodeId: 'ep', factsExtracted: 0, memoriesStored: 0, memoriesUpdated: 0, + memoriesDeleted: 0, memoriesSkipped: 0, storedMemoryIds: [], updatedMemoryIds: [], + memoryIds: [], linksCreated: 0, compositesCreated: 0, + }); + quickIngest.mockResolvedValue({ + episodeId: 'ep', factsExtracted: 0, memoriesStored: 0, memoriesUpdated: 0, + memoriesDeleted: 0, memoriesSkipped: 0, storedMemoryIds: [], updatedMemoryIds: [], + memoryIds: [], linksCreated: 0, compositesCreated: 0, + }); + + const service = { + scopedSearch, ingest, quickIngest, + storeVerbatim: vi.fn(), workspaceIngest: vi.fn(), + scopedExpand: vi.fn(), scopedList: vi.fn(), scopedGet: vi.fn(), scopedDelete: vi.fn(), + list: vi.fn(), get: vi.fn(), delete: vi.fn(), expand: vi.fn(), resetBySource: vi.fn(), + getStats: vi.fn(), consolidate: vi.fn(), executeConsolidation: vi.fn(), + reconcileDeferred: vi.fn(), reconcileDeferredAll: vi.fn(), getDeferredStatus: vi.fn(), + evaluateDecay: vi.fn(), archiveDecayed: vi.fn(), checkCap: vi.fn(), + getAuditTrail: vi.fn(), getMutationSummary: vi.fn(), getRecentMutations: vi.fn(), + getLessons: vi.fn(), getLessonStats: vi.fn(), reportLesson: vi.fn(), deactivateLesson: vi.fn(), + } as unknown as MemoryService; + + const adapter = { current: () => ({ ...ROUTE_CONFIG }), update: () => [] }; + const app = express(); + app.use(express.json()); + app.use('/memories', createMemoryRouter(service, adapter)); + booted = await bindEphemeral(app); + }); + + beforeEach(() => { + scopedSearch.mockClear(); + ingest.mockClear(); + quickIngest.mockClear(); + }); + + afterAll(async () => { await booted.close(); }); + + it('POST /search with no override → no headers, effectiveConfig undefined', async () => { + const res = await fetch(`${booted.baseUrl}/memories/search`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: 'u', query: 'q' }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('X-Atomicmem-Config-Override-Applied')).toBeNull(); + expect(res.headers.get('X-Atomicmem-Effective-Config-Hash')).toBeNull(); + expect(res.headers.get('X-Atomicmem-Config-Override-Keys')).toBeNull(); + expect(scopedSearch).toHaveBeenCalledWith( + expect.anything(), 'q', + expect.objectContaining({ effectiveConfig: undefined }), + ); + }); + + it('POST /search with override → three headers + effectiveConfig threaded', async () => { + const res = await fetch(`${booted.baseUrl}/memories/search`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: 'u', query: 'q', + config_override: { hybridSearchEnabled: true, mmrLambda: 0.8 }, + }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('X-Atomicmem-Config-Override-Applied')).toBe('true'); + expect(res.headers.get('X-Atomicmem-Effective-Config-Hash')).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(res.headers.get('X-Atomicmem-Config-Override-Keys')).toBe('hybridSearchEnabled,mmrLambda'); + const call = scopedSearch.mock.calls[0]!; + const options = call[2] as { effectiveConfig: { hybridSearchEnabled: boolean; mmrLambda: number } }; + expect(options.effectiveConfig.hybridSearchEnabled).toBe(true); + expect(options.effectiveConfig.mmrLambda).toBe(0.8); + }); + + it('POST /search/fast with override → headers and fast:true both set', async () => { + const res = await fetch(`${booted.baseUrl}/memories/search/fast`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: 'u', query: 'q', + config_override: { crossEncoderEnabled: true }, + }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('X-Atomicmem-Config-Override-Applied')).toBe('true'); + const call = scopedSearch.mock.calls[0]!; + const options = call[2] as { fast: boolean; effectiveConfig: { crossEncoderEnabled: boolean } }; + expect(options.fast).toBe(true); + expect(options.effectiveConfig.crossEncoderEnabled).toBe(true); + }); + + it('POST /ingest with override → headers + trailing effectiveConfig arg', async () => { + const res = await fetch(`${booted.baseUrl}/memories/ingest`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: 'u', conversation: 'hi', source_site: 's', + config_override: { chunkedExtractionEnabled: true }, + }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('X-Atomicmem-Config-Override-Applied')).toBe('true'); + expect(res.headers.get('X-Atomicmem-Config-Override-Keys')).toBe('chunkedExtractionEnabled'); + const call = ingest.mock.calls[0]!; + const effectiveConfig = call[5] as { chunkedExtractionEnabled: boolean }; + expect(effectiveConfig.chunkedExtractionEnabled).toBe(true); + }); + + it('POST /ingest/quick with override → headers + trailing effectiveConfig arg', async () => { + const res = await fetch(`${booted.baseUrl}/memories/ingest/quick`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: 'u', conversation: 'hi', source_site: 's', + config_override: { entropyGateEnabled: false, fastAudnEnabled: true }, + }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('X-Atomicmem-Config-Override-Keys')).toBe('entropyGateEnabled,fastAudnEnabled'); + const call = quickIngest.mock.calls[0]!; + const effectiveConfig = call[5] as { entropyGateEnabled: boolean; fastAudnEnabled: boolean }; + expect(effectiveConfig.entropyGateEnabled).toBe(false); + expect(effectiveConfig.fastAudnEnabled).toBe(true); + }); + + it('unknown override key → 200, service invoked, warning header set', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const res = await fetch(`${booted.baseUrl}/memories/search`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: 'u', query: 'q', + config_override: { bogusFlag: true, alsoBogus: 'nope' }, + }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('X-Atomicmem-Config-Override-Applied')).toBe('true'); + expect(res.headers.get('X-Atomicmem-Unknown-Override-Keys')).toBe('alsoBogus,bogusFlag'); + expect(warnSpy).toHaveBeenCalled(); + expect(scopedSearch).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('mix of known and unknown keys → only unknown ones in warning header', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const res = await fetch(`${booted.baseUrl}/memories/search`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: 'u', query: 'q', + config_override: { hybridSearchEnabled: true, futureFieldX: 42 }, + }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('X-Atomicmem-Config-Override-Keys')).toBe('futureFieldX,hybridSearchEnabled'); + expect(res.headers.get('X-Atomicmem-Unknown-Override-Keys')).toBe('futureFieldX'); + warnSpy.mockRestore(); + }); + + it('all-known keys → no X-Atomicmem-Unknown-Override-Keys header', async () => { + const res = await fetch(`${booted.baseUrl}/memories/search`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: 'u', query: 'q', + config_override: { hybridSearchEnabled: true }, + }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('X-Atomicmem-Config-Override-Applied')).toBe('true'); + expect(res.headers.get('X-Atomicmem-Unknown-Override-Keys')).toBeNull(); + }); + + it('override raises maxSearchResults → request limit clamped to override, not startup cap', async () => { + // Startup cap is 20 (ROUTE_CONFIG.maxSearchResults). Override raises to 50, + // request asks for 40 — must reach the service as 40, not clamped to 20. + const res = await fetch(`${booted.baseUrl}/memories/search`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: 'u', query: 'q', limit: 40, + config_override: { maxSearchResults: 50 }, + }), + }); + expect(res.status).toBe(200); + const call = scopedSearch.mock.calls[0]!; + const options = call[2] as { limit?: number }; + expect(options.limit).toBe(40); + }); + + it('empty override object → treated as no override (no headers)', async () => { + const res = await fetch(`${booted.baseUrl}/memories/search`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: 'u', query: 'q', config_override: {} }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('X-Atomicmem-Config-Override-Applied')).toBeNull(); + const call = scopedSearch.mock.calls[0]!; + const options = call[2] as { effectiveConfig: unknown }; + expect(options.effectiveConfig).toBeUndefined(); + }); +}); diff --git a/src/config.ts b/src/config.ts index 1dc388d..57f639c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,7 +16,7 @@ export type LLMProviderName = EmbeddingProviderName | 'groq' | 'anthropic' | 'go export type VectorBackendName = 'pgvector' | 'ruvector-mock' | 'zvec-mock'; export type CrossEncoderDtype = 'auto' | 'fp32' | 'fp16' | 'q8' | 'int8' | 'uint8' | 'q4' | 'bnb4' | 'q4f16'; -interface RuntimeConfig { +export interface RuntimeConfig { databaseUrl: string; openaiApiKey: string; port: number; diff --git a/src/routes/memories.ts b/src/routes/memories.ts index 480dfca..2fefb23 100644 --- a/src/routes/memories.ts +++ b/src/routes/memories.ts @@ -12,9 +12,14 @@ */ import { Router, type Request, type Response } from 'express'; -import { config, updateRuntimeConfig, type EmbeddingProviderName, type LLMProviderName } from '../config.js'; +import { config, updateRuntimeConfig, type EmbeddingProviderName, type LLMProviderName, type RuntimeConfig } from '../config.js'; import { MemoryService, type RetrievalResult } from '../services/memory-service.js'; -import type { MemoryScope, RetrievalObservability } from '../services/memory-service-types.js'; +import type { MemoryScope, MemoryServiceDeps, RetrievalObservability } from '../services/memory-service-types.js'; +import { + applyConfigOverride, + hashEffectiveConfig, + summarizeOverrideKeys, +} from '../services/retrieval-config-overlay.js'; import { formatIngestResponse, formatScope, @@ -149,9 +154,10 @@ function registerIngestRoute(router: Router, service: MemoryService): void { router.post('/ingest', validateBody(IngestBodySchema), async (req: Request, res: Response) => { try { const body = req.body as IngestBody; + const effectiveConfig = applyRequestConfigOverride(res, body.configOverride); const result = body.workspace - ? await service.workspaceIngest(body.userId, body.conversation, body.sourceSite, body.sourceUrl, body.workspace) - : await service.ingest(body.userId, body.conversation, body.sourceSite, body.sourceUrl); + ? await service.workspaceIngest(body.userId, body.conversation, body.sourceSite, body.sourceUrl, body.workspace, undefined, effectiveConfig) + : await service.ingest(body.userId, body.conversation, body.sourceSite, body.sourceUrl, undefined, effectiveConfig); res.json(formatIngestResponse(result)); } catch (err) { handleRouteError(res, 'POST /v1/memories/ingest', err); @@ -163,11 +169,12 @@ function registerQuickIngestRoute(router: Router, service: MemoryService): void router.post('/ingest/quick', validateBody(IngestBodySchema), async (req: Request, res: Response) => { try { const body = req.body as IngestBody; + const effectiveConfig = applyRequestConfigOverride(res, body.configOverride); const result = body.workspace - ? await service.workspaceIngest(body.userId, body.conversation, body.sourceSite, body.sourceUrl, body.workspace) + ? await service.workspaceIngest(body.userId, body.conversation, body.sourceSite, body.sourceUrl, body.workspace, undefined, effectiveConfig) : body.skipExtraction - ? await service.storeVerbatim(body.userId, body.conversation, body.sourceSite, body.sourceUrl) - : await service.quickIngest(body.userId, body.conversation, body.sourceSite, body.sourceUrl); + ? await service.storeVerbatim(body.userId, body.conversation, body.sourceSite, body.sourceUrl, effectiveConfig) + : await service.quickIngest(body.userId, body.conversation, body.sourceSite, body.sourceUrl, undefined, effectiveConfig); res.json(formatIngestResponse(result)); } catch (err) { handleRouteError(res, 'POST /v1/memories/ingest/quick', err); @@ -175,11 +182,18 @@ function registerQuickIngestRoute(router: Router, service: MemoryService): void }); } -function resolveSearchPreamble(body: SearchBody, configRouteAdapter: RuntimeConfigRouteAdapter) { +/** + * Resolve scope + clamped limit using the *effective* maxSearchResults — i.e. + * the post-override value when a `config_override` was carried, or the startup + * snapshot otherwise. Clamping against the startup snapshot when an override + * is present would silently pin requests to the old cap even though + * `X-Atomicmem-Effective-Config-Hash` advertises the new one. + */ +function resolveSearchPreamble(body: SearchBody, maxSearchResults: number) { const scope = toMemoryScope(body.userId, body.workspace, body.agentScope as AgentScope | undefined); const requestLimit = body.limit === undefined ? undefined - : resolveEffectiveSearchLimit(body.limit, configRouteAdapter.current()); + : resolveEffectiveSearchLimit(body.limit, maxSearchResults); return { scope, requestLimit }; } @@ -191,7 +205,9 @@ function registerSearchRoute( router.post('/search', validateBody(SearchBodySchema), async (req: Request, res: Response) => { try { const body = req.body as SearchBody; - const { scope, requestLimit } = resolveSearchPreamble(body, configRouteAdapter); + const effectiveConfig = applyRequestConfigOverride(res, body.configOverride); + const maxSearchResults = effectiveConfig?.maxSearchResults ?? configRouteAdapter.current().maxSearchResults; + const { scope, requestLimit } = resolveSearchPreamble(body, maxSearchResults); const retrievalOptions: { retrievalMode?: SearchBody['retrievalMode']; tokenBudget?: SearchBody['tokenBudget']; skipRepairLoop?: boolean } = { retrievalMode: body.retrievalMode, tokenBudget: body.tokenBudget, @@ -203,6 +219,7 @@ function registerSearchRoute( asOf: body.asOf, namespaceScope: body.namespaceScope, retrievalOptions, + effectiveConfig, }); res.json(formatSearchResponse(result, scope)); } catch (err) { @@ -223,12 +240,15 @@ function registerFastSearchRoute( router.post('/search/fast', validateBody(SearchBodySchema), async (req: Request, res: Response) => { try { const body = req.body as SearchBody; - const { scope, requestLimit } = resolveSearchPreamble(body, configRouteAdapter); + const effectiveConfig = applyRequestConfigOverride(res, body.configOverride); + const maxSearchResults = effectiveConfig?.maxSearchResults ?? configRouteAdapter.current().maxSearchResults; + const { scope, requestLimit } = resolveSearchPreamble(body, maxSearchResults); const result = await service.scopedSearch(scope, body.query, { fast: true, sourceSite: body.sourceSite, limit: requestLimit, namespaceScope: body.namespaceScope, + effectiveConfig, }); res.json(formatSearchResponse(result, scope)); } catch (err) { @@ -578,11 +598,10 @@ function registerAuditTrailRoute(router: Router, service: MemoryService): void { function resolveEffectiveSearchLimit( requestedLimit: number | undefined, - runtimeConfig: Pick, + maxSearchResults: number, ): number { - const maxLimit = runtimeConfig.maxSearchResults; - if (requestedLimit === undefined) return maxLimit; - return Math.min(requestedLimit, maxLimit); + if (requestedLimit === undefined) return maxSearchResults; + return Math.min(requestedLimit, maxSearchResults); } function toMemoryScope( @@ -594,6 +613,50 @@ function toMemoryScope( return { kind: 'workspace', userId, workspaceId: workspace.workspaceId, agentId: workspace.agentId, agentScope }; } +/** + * Overlay a validated body-level config_override onto the startup + * singleton and emit the observability response headers. Returns the + * EffectiveConfig to hand to MemoryService (or undefined when no + * override was present — the zero-cost no-headers path). + * + * Headers emitted when an override is applied: + * X-Atomicmem-Config-Override-Applied: true + * X-Atomicmem-Effective-Config-Hash: sha256: + * X-Atomicmem-Config-Override-Keys: comma-joined sorted key list + * + * Additional header, emitted only when one or more override keys do + * not correspond to a known RuntimeConfig field on this build: + * X-Atomicmem-Unknown-Override-Keys: comma-joined sorted key list + * + * Unknown keys are NOT rejected — the permissive schema is deliberate + * so adding a new RuntimeConfig field in a future release doesn't + * require a matching schema landing before experiments can set it. + * Typos surface via this header + a server-side warning log. + */ +function applyRequestConfigOverride( + res: Response, + override: Partial | undefined, +): MemoryServiceDeps['config'] | undefined { + if (!override || Object.keys(override).length === 0) return undefined; + const effective = applyConfigOverride(config, override); + res.setHeader('X-Atomicmem-Config-Override-Applied', 'true'); + res.setHeader('X-Atomicmem-Effective-Config-Hash', hashEffectiveConfig(effective)); + res.setHeader('X-Atomicmem-Config-Override-Keys', summarizeOverrideKeys(override)); + + const knownKeys = new Set(Object.keys(config)); + const unknownKeys = Object.keys(override) + .filter((k) => !knownKeys.has(k)) + .sort(); + if (unknownKeys.length > 0) { + res.setHeader('X-Atomicmem-Unknown-Override-Keys', unknownKeys.join(',')); + console.warn( + `[config_override] request carried ${unknownKeys.length} unknown key(s): ${unknownKeys.join(', ')} — carried through on effective config but nothing currently reads them`, + ); + } + + return effective; +} + function buildRetrievalObservability(result: RetrievalResult): RetrievalObservability | undefined { const observability: RetrievalObservability = { ...(result.retrievalSummary ? { retrieval: result.retrievalSummary } : {}), diff --git a/src/schemas/__tests__/config-override.test.ts b/src/schemas/__tests__/config-override.test.ts new file mode 100644 index 0000000..bac2696 --- /dev/null +++ b/src/schemas/__tests__/config-override.test.ts @@ -0,0 +1,137 @@ +/** + * @file Wire-contract tests for the per-request `config_override` field + * threaded onto `IngestBodySchema` and `SearchBodySchema`. + * + * The schema is intentionally permissive — any object whose values are + * primitives (boolean / number / string / null) passes. Unknown-key + * detection happens at the route-handler layer (surfaces via the + * `X-Atomicmem-Unknown-Override-Keys` response header and a warning + * log), not here. Strictness at the schema layer would couple every + * new RuntimeConfig field to a core release, which defeats the point + * of a per-request mechanism. + * + * Invariants locked in here: + * 1. Empty object parses successfully (no-op overlay). + * 2. Known and unknown keys both pass the schema; differentiation is + * the handler's job. + * 3. Non-primitive values (objects, arrays) reject — overlay is + * flat by contract. + */ + +import { describe, expect, it } from 'vitest'; +import { IngestBodySchema, SearchBodySchema, ConfigOverrideSchema } from '../memories'; + +const INGEST_BASE = { user_id: 'u', conversation: 'hi', source_site: 's' }; +const SEARCH_BASE = { user_id: 'u', query: 'q' }; + +describe('ConfigOverrideSchema — permissive shape', () => { + it('accepts an empty object', () => { + const r = ConfigOverrideSchema.safeParse({}); + expect(r.success).toBe(true); + }); + + it('accepts a subset of known RuntimeConfig keys', () => { + const r = ConfigOverrideSchema.safeParse({ + hybridSearchEnabled: true, + mmrLambda: 0.8, + audnCandidateThreshold: 0.9, + }); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data.hybridSearchEnabled).toBe(true); + expect(r.data.mmrLambda).toBe(0.8); + } + }); + + it('accepts keys not yet defined on RuntimeConfig (forward-compat)', () => { + const r = ConfigOverrideSchema.safeParse({ + futureExperimentalFlag: true, + someNewTunable: 42, + }); + expect(r.success).toBe(true); + // Runtime warning surfaces via X-Atomicmem-Unknown-Override-Keys; + // not a schema concern. + }); + + it('accepts primitive values (boolean / number / string / null)', () => { + const r = ConfigOverrideSchema.safeParse({ + flagA: true, + numberA: 0.5, + stringA: 'balanced', + nullA: null, + }); + expect(r.success).toBe(true); + }); + + it('rejects object values (overlay is flat)', () => { + const r = ConfigOverrideSchema.safeParse({ + nested: { deep: true }, + }); + expect(r.success).toBe(false); + }); + + it('rejects array values (overlay is flat)', () => { + const r = ConfigOverrideSchema.safeParse({ + list: [1, 2, 3], + }); + expect(r.success).toBe(false); + }); +}); + +describe('IngestBodySchema — config_override threading', () => { + it('parses without config_override', () => { + const r = IngestBodySchema.safeParse(INGEST_BASE); + expect(r.success).toBe(true); + if (r.success) expect(r.data.configOverride).toBeUndefined(); + }); + + it('accepts a valid config_override and emits it as configOverride', () => { + const r = IngestBodySchema.safeParse({ + ...INGEST_BASE, + config_override: { chunkedExtractionEnabled: true, audnCandidateThreshold: 0.95 }, + }); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data.configOverride?.chunkedExtractionEnabled).toBe(true); + expect(r.data.configOverride?.audnCandidateThreshold).toBe(0.95); + } + }); + + it('carries unknown keys through the schema layer', () => { + const r = IngestBodySchema.safeParse({ + ...INGEST_BASE, + config_override: { futureFlag: true }, + }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.configOverride?.futureFlag).toBe(true); + }); +}); + +describe('SearchBodySchema — config_override threading', () => { + it('parses without config_override', () => { + const r = SearchBodySchema.safeParse(SEARCH_BASE); + expect(r.success).toBe(true); + if (r.success) expect(r.data.configOverride).toBeUndefined(); + }); + + it('accepts a valid config_override and emits it as configOverride', () => { + const r = SearchBodySchema.safeParse({ + ...SEARCH_BASE, + config_override: { hybridSearchEnabled: true, mmrEnabled: false }, + }); + expect(r.success).toBe(true); + if (r.success) { + expect(r.data.configOverride?.hybridSearchEnabled).toBe(true); + expect(r.data.configOverride?.mmrEnabled).toBe(false); + } + }); + + it('carries unknown keys through the schema layer', () => { + const r = SearchBodySchema.safeParse({ + ...SEARCH_BASE, + config_override: { totallyMadeUp: 7 }, + }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.configOverride?.totallyMadeUp).toBe(7); + }); +}); diff --git a/src/schemas/memories.ts b/src/schemas/memories.ts index ff2221b..f382bb1 100644 --- a/src/schemas/memories.ts +++ b/src/schemas/memories.ts @@ -179,6 +179,52 @@ const RetrievalModeField = z enum: ['flat', 'tiered', 'abstract-aware'], }); +// --------------------------------------------------------------------------- +// Per-request config override +// --------------------------------------------------------------------------- + +/** + * Per-request overlay on the startup RuntimeConfig. Applied as a shallow + * merge (`{ ...startup, ...override }`) onto the effective request-scope + * config. + * + * **Shape is permissive by design.** The schema accepts any object whose + * values are primitives (boolean, number, string, null) — no + * enumerated field list. This is deliberate: enumerating fields would + * couple every new overlay-eligible RuntimeConfig field to a core + * release, which defeats the purpose of a per-request config mechanism. + * + * **Unknown-key handling is soft**, not a 400: + * - If an override key doesn't match a `RuntimeConfig` field at + * request-handling time, the merge is still performed (the key rides + * along on the effective config object), but the route handler emits + * a `X-Atomicmem-Unknown-Override-Keys` response header listing the + * unmatched keys and logs a warning. This catches typos without + * rejecting a request that would otherwise be valid once the field + * lands in a future release. + * - If you want a typed, IDE-autocompleted experience, import + * `RuntimeConfig` from `src/config.ts` and type your override as + * `Partial` on the caller side. + * + * **`config_override` absent →** zero-cost path, no headers emitted, + * startup config used as-is. + */ +export const ConfigOverrideSchema = z + .record( + z.string(), + z.union([z.boolean(), z.number(), z.string(), z.null()]), + ) + .openapi({ + description: + 'Optional per-request overlay on RuntimeConfig. Keys correspond to RuntimeConfig field names; values must be primitives (boolean / number / string / null). Unknown keys are accepted but surfaced via the X-Atomicmem-Unknown-Override-Keys response header and a server-side warning log — they do not cause a 400. Scope: just this request — no server mutation.', + }); + +/** + * Runtime type of the validated override object. For IDE-assisted + * editing, prefer `Partial` from `src/config.ts`. + */ +export type ConfigOverride = z.infer; + // --------------------------------------------------------------------------- // Ingest // --------------------------------------------------------------------------- @@ -197,6 +243,7 @@ export const IngestBodySchema = z visibility: VisibilityField, /** Only POST /ingest/quick reads this — safely ignored elsewhere. */ skip_extraction: OptionalBooleanField(), + config_override: ConfigOverrideSchema.optional(), }) .transform(b => ({ userId: b.user_id, @@ -205,6 +252,7 @@ export const IngestBodySchema = z sourceUrl: b.source_url ?? '', workspace: buildWorkspaceContext(b.workspace_id, b.agent_id, b.visibility), skipExtraction: b.skip_extraction === true, + configOverride: b.config_override, })) .openapi({ description: @@ -234,6 +282,7 @@ export const SearchBodySchema = z agent_id: AgentIdField, visibility: VisibilityField, agent_scope: AgentScopeSchema, + config_override: ConfigOverrideSchema.optional(), }) .transform(b => ({ userId: b.user_id, @@ -247,6 +296,7 @@ export const SearchBodySchema = z skipRepair: b.skip_repair === true, workspace: buildWorkspaceContext(b.workspace_id, b.agent_id, b.visibility), agentScope: b.agent_scope, + configOverride: b.config_override, })) .openapi({ description: diff --git a/src/services/__tests__/retrieval-config-overlay.test.ts b/src/services/__tests__/retrieval-config-overlay.test.ts new file mode 100644 index 0000000..a2a2787 --- /dev/null +++ b/src/services/__tests__/retrieval-config-overlay.test.ts @@ -0,0 +1,92 @@ +/** + * @file Unit tests for the per-request config overlay utility. + * + * Verifies the three primitives consumed by memory routes: + * - applyConfigOverride: shallow merge, unchanged base, overridden target + * - hashEffectiveConfig: deterministic under key reordering, sensitive + * to value changes, shape `sha256:<64-hex>` + * - summarizeOverrideKeys: sorted comma-joined key list + */ + +import { describe, expect, it } from 'vitest'; +import { + applyConfigOverride, + hashEffectiveConfig, + summarizeOverrideKeys, +} from '../retrieval-config-overlay.js'; +import type { RuntimeConfig } from '../../config.js'; + +function makeConfig(partial: Partial = {}): RuntimeConfig { + return { + hybridSearchEnabled: false, + mmrEnabled: true, + mmrLambda: 0.5, + maxSearchResults: 10, + audnCandidateThreshold: 0.85, + ...partial, + } as RuntimeConfig; +} + +describe('applyConfigOverride', () => { + it('returns the base untouched when override is an empty object', () => { + const base = makeConfig(); + const result = applyConfigOverride(base, {}); + expect(result).toEqual(base); + expect(result).not.toBe(base); + }); + + it('shallow-merges override fields on top of the base', () => { + const base = makeConfig({ hybridSearchEnabled: false, mmrLambda: 0.5 }); + const result = applyConfigOverride(base, { + hybridSearchEnabled: true, + mmrLambda: 0.8, + }); + expect(result.hybridSearchEnabled).toBe(true); + expect(result.mmrLambda).toBe(0.8); + expect(result.maxSearchResults).toBe(base.maxSearchResults); + }); + + it('does not mutate the base config', () => { + const base = makeConfig({ hybridSearchEnabled: false }); + applyConfigOverride(base, { hybridSearchEnabled: true }); + expect(base.hybridSearchEnabled).toBe(false); + }); +}); + +describe('hashEffectiveConfig', () => { + it('returns a sha256: fingerprint', () => { + const hash = hashEffectiveConfig(makeConfig()); + expect(hash).toMatch(/^sha256:[0-9a-f]{64}$/); + }); + + it('is stable across insertion order of the same logical config', () => { + const a = makeConfig({ hybridSearchEnabled: true, mmrLambda: 0.7 }); + const b = makeConfig({ mmrLambda: 0.7, hybridSearchEnabled: true }); + expect(hashEffectiveConfig(a)).toBe(hashEffectiveConfig(b)); + }); + + it('changes when any field value changes', () => { + const a = hashEffectiveConfig(makeConfig({ hybridSearchEnabled: false })); + const b = hashEffectiveConfig(makeConfig({ hybridSearchEnabled: true })); + expect(a).not.toBe(b); + }); +}); + +describe('summarizeOverrideKeys', () => { + it('returns an empty string for an empty override', () => { + expect(summarizeOverrideKeys({})).toBe(''); + }); + + it('returns a single key for a single-field override', () => { + expect(summarizeOverrideKeys({ hybridSearchEnabled: true })).toBe('hybridSearchEnabled'); + }); + + it('returns keys sorted alphabetically regardless of insertion order', () => { + const joined = summarizeOverrideKeys({ + mmrEnabled: true, + hybridSearchEnabled: false, + audnCandidateThreshold: 0.9, + }); + expect(joined).toBe('audnCandidateThreshold,hybridSearchEnabled,mmrEnabled'); + }); +}); diff --git a/src/services/memory-service-types.ts b/src/services/memory-service-types.ts index d2dd49f..188f372 100644 --- a/src/services/memory-service-types.ts +++ b/src/services/memory-service-types.ts @@ -168,6 +168,13 @@ export interface ScopedSearchOptions { retrievalOptions?: RetrievalOptions; /** When true, skips the LLM repair loop (used by /search/fast). */ fast?: boolean; + /** + * Request-scoped effective config overlaying the startup singleton. + * When provided, replaces `deps.config` for the duration of the call. + * Populated by the route layer after merging a validated body-level + * `config_override`. Absent → startup config flows through unchanged. + */ + effectiveConfig?: MemoryServiceDeps['config']; } /** Supported observability payload for retrieval responses. */ diff --git a/src/services/memory-service.ts b/src/services/memory-service.ts index f6a2985..584ef20 100644 --- a/src/services/memory-service.ts +++ b/src/services/memory-service.ts @@ -61,14 +61,27 @@ export class MemoryService { }; } + /** + * Build a request-scoped deps bundle that swaps in the effective config + * for the duration of a single call. Returns the shared `this.deps` + * unchanged when no override is supplied (zero-allocation fast path). + * All per-fact/per-pipeline helpers already read `deps.config`, so + * replacing it at the entry point propagates through the service layer + * without mutating shared state. + */ + private depsFor(effectiveConfig?: MemoryServiceDeps['config']): MemoryServiceDeps { + if (!effectiveConfig) return this.deps; + return { ...this.deps, config: effectiveConfig }; + } + // --- Ingest --- - async ingest(userId: string, conversationText: string, sourceSite: string, sourceUrl: string = '', sessionTimestamp?: Date): Promise { - return performIngest(this.deps, userId, conversationText, sourceSite, sourceUrl, sessionTimestamp); + async ingest(userId: string, conversationText: string, sourceSite: string, sourceUrl: string = '', sessionTimestamp?: Date, effectiveConfig?: MemoryServiceDeps['config']): Promise { + return performIngest(this.depsFor(effectiveConfig), userId, conversationText, sourceSite, sourceUrl, sessionTimestamp); } - async quickIngest(userId: string, conversationText: string, sourceSite: string, sourceUrl: string = '', sessionTimestamp?: Date): Promise { - return performQuickIngest(this.deps, userId, conversationText, sourceSite, sourceUrl, sessionTimestamp); + async quickIngest(userId: string, conversationText: string, sourceSite: string, sourceUrl: string = '', sessionTimestamp?: Date, effectiveConfig?: MemoryServiceDeps['config']): Promise { + return performQuickIngest(this.depsFor(effectiveConfig), userId, conversationText, sourceSite, sourceUrl, sessionTimestamp); } /** @@ -76,21 +89,22 @@ export class MemoryService { * Used for user-created contexts (text/file uploads) where * the content should remain as one canonical memory record. */ - async storeVerbatim(userId: string, content: string, sourceSite: string, sourceUrl: string = ''): Promise { - return performStoreVerbatim(this.deps, userId, content, sourceSite, sourceUrl); + async storeVerbatim(userId: string, content: string, sourceSite: string, sourceUrl: string = '', effectiveConfig?: MemoryServiceDeps['config']): Promise { + return performStoreVerbatim(this.depsFor(effectiveConfig), userId, content, sourceSite, sourceUrl); } - async workspaceIngest(userId: string, conversationText: string, sourceSite: string, sourceUrl: string = '', workspace: WorkspaceContext, sessionTimestamp?: Date): Promise { - return performWorkspaceIngest(this.deps, userId, conversationText, sourceSite, sourceUrl, workspace, sessionTimestamp); + async workspaceIngest(userId: string, conversationText: string, sourceSite: string, sourceUrl: string = '', workspace: WorkspaceContext, sessionTimestamp?: Date, effectiveConfig?: MemoryServiceDeps['config']): Promise { + return performWorkspaceIngest(this.depsFor(effectiveConfig), userId, conversationText, sourceSite, sourceUrl, workspace, sessionTimestamp); } // --- Search (scope-dispatching) --- /** Scope-dispatching search: routes to user or workspace search based on scope.kind. */ async scopedSearch(scope: MemoryScope, query: string, options: ScopedSearchOptions = {}): Promise { + const deps = this.depsFor(options.effectiveConfig); if (scope.kind === 'workspace') { const ws: WorkspaceContext = { workspaceId: scope.workspaceId, agentId: scope.agentId }; - return performWorkspaceSearch(this.deps, scope.userId, query, ws, { + return performWorkspaceSearch(deps, scope.userId, query, ws, { agentScope: scope.agentScope, limit: options.limit, referenceTime: options.referenceTime, @@ -98,9 +112,9 @@ export class MemoryService { }); } if (options.fast) { - return performFastSearch(this.deps, scope.userId, query, options.sourceSite, options.limit, options.namespaceScope); + return performFastSearch(deps, scope.userId, query, options.sourceSite, options.limit, options.namespaceScope); } - return performSearch(this.deps, scope.userId, query, options.sourceSite, options.limit, options.asOf, options.referenceTime, options.namespaceScope, options.retrievalOptions); + return performSearch(deps, scope.userId, query, options.sourceSite, options.limit, options.asOf, options.referenceTime, options.namespaceScope, options.retrievalOptions); } /** Scope-dispatching expand with agent visibility enforcement for workspace operations. */ diff --git a/src/services/retrieval-config-overlay.ts b/src/services/retrieval-config-overlay.ts new file mode 100644 index 0000000..64b74a5 --- /dev/null +++ b/src/services/retrieval-config-overlay.ts @@ -0,0 +1,52 @@ +/** + * @file Per-request config overlay primitives. + * + * Supports the `config_override` request-body field on memory ingest + * and search routes. Three responsibilities: + * + * 1. `applyConfigOverride` — shallow-merge a validated flat override + * onto the startup `RuntimeConfig`. Because the override shape + * matches the flat `RuntimeConfig` field names one-for-one, this + * is a genuine single-line `{ ...base, ...override }` — no + * mapping layer, no nested traversal. + * + * 2. `hashEffectiveConfig` — stable SHA-256 over the effective config, + * serialized with deterministically-sorted keys, returned as + * `sha256:`. Emitted via the `X-Atomicmem-Effective-Config-Hash` + * response header so callers can link traces to a canonical config + * fingerprint. + * + * 3. `summarizeOverrideKeys` — comma-separated list of top-level keys + * present in the override object, for the + * `X-Atomicmem-Config-Override-Keys` header. + * + * Design reference: atomicmemory-research/docs/core-repo/design/ + * per-request-config-override.md §2.3, §2.5. + */ + +import { createHash } from 'node:crypto'; +import type { RuntimeConfig } from '../config.js'; + +/** Merge a validated override on top of the startup runtime config. */ +export function applyConfigOverride( + base: RuntimeConfig, + override: Partial, +): RuntimeConfig { + return { ...base, ...override }; +} + +/** + * SHA-256 fingerprint of the effective config. Keys are sorted before + * serialization so the hash is stable regardless of construction order. + * Returned in the `sha256:` form emitted on the response header. + */ +export function hashEffectiveConfig(cfg: RuntimeConfig): string { + const canonical = JSON.stringify(cfg, Object.keys(cfg).sort()); + const hex = createHash('sha256').update(canonical).digest('hex'); + return `sha256:${hex}`; +} + +/** Comma-separated list of keys present in the override object. */ +export function summarizeOverrideKeys(override: Partial): string { + return Object.keys(override).sort().join(','); +}