From 71c740f65e09b73f9a48dcae6a2739e4bad3194d Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 23 Apr 2026 00:39:30 -0700 Subject: [PATCH 1/3] feat(config): per-request config_override on memory routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land the per-request config-override abstraction described in `atomicmemory-research/docs/core-repo/design/per-request-config-override.md`. Callers may now pass a flat `config_override` body field on all four memory routes (search, search/fast, ingest, ingest/quick) to overlay a shallow subset on top of the startup RuntimeConfig for the duration of a single request. No server-side mutation; no global state is touched. Wire contract ------------- - `config_override` is a flat object keyed one-for-one on `RuntimeConfig` field names (camelCase, matching the schema field list below). - Validation is a Zod `.strict().partial()` — unknown keys reject with a 400 and never reach the service layer; every listed key is optional. - Unknown-key typos surface immediately (e.g. snake_case spelling of a camelCase field), so silent no-ops are impossible. Observability headers (emitted only when an override is present) ---------------------------------------------------------------- - `X-Atomicmem-Config-Override-Applied: true` - `X-Atomicmem-Effective-Config-Hash: sha256:` (sorted-key canonical JSON fingerprint of the full effective config — lets callers correlate traces to a specific config snapshot). - `X-Atomicmem-Config-Override-Keys: ` Implementation -------------- - `src/schemas/memories.ts` adds `ConfigOverrideSchema` (44 fields, grouped retrieval/ingest for readability only — the overlay merges flat). `IngestBodySchema` and `SearchBodySchema` gain `config_override: ConfigOverrideSchema.optional()`, re-emitted as `configOverride` after the camelCase transform. - `src/services/retrieval-config-overlay.ts` exports the three route-side primitives: `applyConfigOverride` (one-line shallow merge), `hashEffectiveConfig`, `summarizeOverrideKeys`. - `src/services/memory-service.ts` accepts an optional `effectiveConfig` at every public method. A private `depsFor()` helper swaps it into `MemoryServiceDeps.config` for the call's duration; absent override returns the shared `this.deps` (zero allocation). All downstream pipeline code already reads `deps.config` so no leaf-module edits are needed. - `src/routes/memories.ts` adds `applyRequestConfigOverride(res, override)` which validates-then-emits the three headers and returns the effective config (or undefined for the no-op path). - `RuntimeConfig` is exported from `src/config.ts` so the overlay and schema can reference its field shape. Coverage scope (intentionally narrower than RuntimeConfig) ---------------------------------------------------------- ConfigOverrideSchema covers exactly the fields threaded through `MemoryServiceDeps.config` (`CoreRuntimeConfig + IngestRuntimeConfig`). Fields read from the module-level `config` singleton by leaf modules — scoring weights (`db/query-helpers.ts`), nested `retrievalProfileSettings.*` (rerankDepth, lexicalWeight, the repair-weight pair) — are deliberately omitted. Accepting overrides for those would silently no-op, violating the workspace rule against degraded modes. Future landings lift those fields onto top-level `RuntimeConfig` as they become overlay-eligible. Fields accepted (44 total, sorted): adaptiveRetrievalEnabled, agenticRetrievalEnabled, auditLoggingEnabled, audnCandidateThreshold, chunkedExtractionEnabled, compositeGroupingEnabled, compositeMinClusterSize, consensusExtractionEnabled, consensusExtractionRuns, consensusMinMemories, consensusValidationEnabled, crossEncoderEnabled, entityGraphEnabled, entitySearchMinSimilarity, entropyGateAlpha, entropyGateEnabled, entropyGateThreshold, fastAudnDuplicateThreshold, fastAudnEnabled, hybridSearchEnabled, iterativeRetrievalEnabled, lessonsEnabled, linkExpansionBeforeMMR, linkExpansionEnabled, linkExpansionMax, linkSimilarityThreshold, maxSearchResults, mmrEnabled, mmrLambda, namespaceClassificationEnabled, pprDamping, pprEnabled, queryAugmentationEnabled, queryAugmentationMaxEntities, queryAugmentationMinSimilarity, queryExpansionEnabled, queryExpansionMinSimilarity, repairConfidenceFloor, repairDeltaThreshold, repairLoopEnabled, repairLoopMinSimilarity, rerankSkipMinGap, rerankSkipTopSimilarity, trustScoreMinThreshold, trustScoringEnabled. Tests ----- - `src/services/__tests__/retrieval-config-overlay.test.ts` (9 tests) — merge semantics, hash stability under key reorder, sorted key summary. - `src/schemas/__tests__/config-override.test.ts` (10 tests) — strict mode, empty-object accept, snake_case typo rejection, threading through IngestBodySchema + SearchBodySchema. - `src/__tests__/memory-route-config-override.test.ts` (7 tests) — no-override zero-header path, three-header emission on override, effectiveConfig threading into scopedSearch / ingest / quickIngest, 400 on unknown keys, empty-object treated as no-override. `npm test` → 109 files · 1067 tests pass. `npm run build` → clean. OpenAPI spec regenerated (44 new fields surfaced on the four route schemas). --- openapi.json | 696 ++++++++++++++++++ openapi.yaml | 508 +++++++++++++ .../memory-route-config-override.test.ts | 192 +++++ src/config.ts | 2 +- src/routes/memories.ts | 48 +- src/schemas/__tests__/config-override.test.ts | 111 +++ src/schemas/memories.ts | 90 +++ .../retrieval-config-overlay.test.ts | 92 +++ src/services/memory-service-types.ts | 7 + src/services/memory-service.ts | 36 +- src/services/retrieval-config-overlay.ts | 52 ++ 11 files changed, 1815 insertions(+), 19 deletions(-) create mode 100644 src/__tests__/memory-route-config-override.test.ts create mode 100644 src/schemas/__tests__/config-override.test.ts create mode 100644 src/services/__tests__/retrieval-config-overlay.test.ts create mode 100644 src/services/retrieval-config-overlay.ts diff --git a/openapi.json b/openapi.json index 973621a..66219ab 100644 --- a/openapi.json +++ b/openapi.json @@ -2001,6 +2001,180 @@ "description": "Optional agent identifier. Silently dropped if empty / non-string.", "type": "string" }, + "config_override": { + "additionalProperties": false, + "description": "Optional per-request overlay on RuntimeConfig. Unknown keys reject with 400; absent keys fall through to the startup config. Scope: just this request — no server mutation.", + "properties": { + "adaptiveRetrievalEnabled": { + "type": "boolean" + }, + "agenticRetrievalEnabled": { + "type": "boolean" + }, + "auditLoggingEnabled": { + "type": "boolean" + }, + "audnCandidateThreshold": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "chunkedExtractionEnabled": { + "type": "boolean" + }, + "compositeGroupingEnabled": { + "type": "boolean" + }, + "compositeMinClusterSize": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "consensusExtractionEnabled": { + "type": "boolean" + }, + "consensusExtractionRuns": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "consensusMinMemories": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "consensusValidationEnabled": { + "type": "boolean" + }, + "crossEncoderEnabled": { + "type": "boolean" + }, + "entityGraphEnabled": { + "type": "boolean" + }, + "entitySearchMinSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "entropyGateAlpha": { + "type": "number" + }, + "entropyGateEnabled": { + "type": "boolean" + }, + "entropyGateThreshold": { + "type": "number" + }, + "fastAudnDuplicateThreshold": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "fastAudnEnabled": { + "type": "boolean" + }, + "hybridSearchEnabled": { + "type": "boolean" + }, + "iterativeRetrievalEnabled": { + "type": "boolean" + }, + "lessonsEnabled": { + "type": "boolean" + }, + "linkExpansionBeforeMMR": { + "type": "boolean" + }, + "linkExpansionEnabled": { + "type": "boolean" + }, + "linkExpansionMax": { + "minimum": 0, + "type": "integer" + }, + "linkSimilarityThreshold": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "maxSearchResults": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "mmrEnabled": { + "type": "boolean" + }, + "mmrLambda": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "namespaceClassificationEnabled": { + "type": "boolean" + }, + "pprDamping": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "pprEnabled": { + "type": "boolean" + }, + "queryAugmentationEnabled": { + "type": "boolean" + }, + "queryAugmentationMaxEntities": { + "minimum": 0, + "type": "integer" + }, + "queryAugmentationMinSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "queryExpansionEnabled": { + "type": "boolean" + }, + "queryExpansionMinSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "repairConfidenceFloor": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "repairDeltaThreshold": { + "type": "number" + }, + "repairLoopEnabled": { + "type": "boolean" + }, + "repairLoopMinSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "rerankSkipMinGap": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "rerankSkipTopSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "trustScoreMinThreshold": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "trustScoringEnabled": { + "type": "boolean" + } + }, + "type": "object" + }, "conversation": { "description": "Required. conversation.", "minLength": 1, @@ -2173,6 +2347,180 @@ "description": "Optional agent identifier. Silently dropped if empty / non-string.", "type": "string" }, + "config_override": { + "additionalProperties": false, + "description": "Optional per-request overlay on RuntimeConfig. Unknown keys reject with 400; absent keys fall through to the startup config. Scope: just this request — no server mutation.", + "properties": { + "adaptiveRetrievalEnabled": { + "type": "boolean" + }, + "agenticRetrievalEnabled": { + "type": "boolean" + }, + "auditLoggingEnabled": { + "type": "boolean" + }, + "audnCandidateThreshold": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "chunkedExtractionEnabled": { + "type": "boolean" + }, + "compositeGroupingEnabled": { + "type": "boolean" + }, + "compositeMinClusterSize": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "consensusExtractionEnabled": { + "type": "boolean" + }, + "consensusExtractionRuns": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "consensusMinMemories": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "consensusValidationEnabled": { + "type": "boolean" + }, + "crossEncoderEnabled": { + "type": "boolean" + }, + "entityGraphEnabled": { + "type": "boolean" + }, + "entitySearchMinSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "entropyGateAlpha": { + "type": "number" + }, + "entropyGateEnabled": { + "type": "boolean" + }, + "entropyGateThreshold": { + "type": "number" + }, + "fastAudnDuplicateThreshold": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "fastAudnEnabled": { + "type": "boolean" + }, + "hybridSearchEnabled": { + "type": "boolean" + }, + "iterativeRetrievalEnabled": { + "type": "boolean" + }, + "lessonsEnabled": { + "type": "boolean" + }, + "linkExpansionBeforeMMR": { + "type": "boolean" + }, + "linkExpansionEnabled": { + "type": "boolean" + }, + "linkExpansionMax": { + "minimum": 0, + "type": "integer" + }, + "linkSimilarityThreshold": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "maxSearchResults": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "mmrEnabled": { + "type": "boolean" + }, + "mmrLambda": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "namespaceClassificationEnabled": { + "type": "boolean" + }, + "pprDamping": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "pprEnabled": { + "type": "boolean" + }, + "queryAugmentationEnabled": { + "type": "boolean" + }, + "queryAugmentationMaxEntities": { + "minimum": 0, + "type": "integer" + }, + "queryAugmentationMinSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "queryExpansionEnabled": { + "type": "boolean" + }, + "queryExpansionMinSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "repairConfidenceFloor": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "repairDeltaThreshold": { + "type": "number" + }, + "repairLoopEnabled": { + "type": "boolean" + }, + "repairLoopMinSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "rerankSkipMinGap": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "rerankSkipTopSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "trustScoreMinThreshold": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "trustScoringEnabled": { + "type": "boolean" + } + }, + "type": "object" + }, "conversation": { "description": "Required. conversation.", "minLength": 1, @@ -3439,6 +3787,180 @@ "format": "date-time", "type": "string" }, + "config_override": { + "additionalProperties": false, + "description": "Optional per-request overlay on RuntimeConfig. Unknown keys reject with 400; absent keys fall through to the startup config. Scope: just this request — no server mutation.", + "properties": { + "adaptiveRetrievalEnabled": { + "type": "boolean" + }, + "agenticRetrievalEnabled": { + "type": "boolean" + }, + "auditLoggingEnabled": { + "type": "boolean" + }, + "audnCandidateThreshold": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "chunkedExtractionEnabled": { + "type": "boolean" + }, + "compositeGroupingEnabled": { + "type": "boolean" + }, + "compositeMinClusterSize": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "consensusExtractionEnabled": { + "type": "boolean" + }, + "consensusExtractionRuns": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "consensusMinMemories": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "consensusValidationEnabled": { + "type": "boolean" + }, + "crossEncoderEnabled": { + "type": "boolean" + }, + "entityGraphEnabled": { + "type": "boolean" + }, + "entitySearchMinSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "entropyGateAlpha": { + "type": "number" + }, + "entropyGateEnabled": { + "type": "boolean" + }, + "entropyGateThreshold": { + "type": "number" + }, + "fastAudnDuplicateThreshold": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "fastAudnEnabled": { + "type": "boolean" + }, + "hybridSearchEnabled": { + "type": "boolean" + }, + "iterativeRetrievalEnabled": { + "type": "boolean" + }, + "lessonsEnabled": { + "type": "boolean" + }, + "linkExpansionBeforeMMR": { + "type": "boolean" + }, + "linkExpansionEnabled": { + "type": "boolean" + }, + "linkExpansionMax": { + "minimum": 0, + "type": "integer" + }, + "linkSimilarityThreshold": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "maxSearchResults": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "mmrEnabled": { + "type": "boolean" + }, + "mmrLambda": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "namespaceClassificationEnabled": { + "type": "boolean" + }, + "pprDamping": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "pprEnabled": { + "type": "boolean" + }, + "queryAugmentationEnabled": { + "type": "boolean" + }, + "queryAugmentationMaxEntities": { + "minimum": 0, + "type": "integer" + }, + "queryAugmentationMinSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "queryExpansionEnabled": { + "type": "boolean" + }, + "queryExpansionMinSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "repairConfidenceFloor": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "repairDeltaThreshold": { + "type": "number" + }, + "repairLoopEnabled": { + "type": "boolean" + }, + "repairLoopMinSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "rerankSkipMinGap": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "rerankSkipTopSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "trustScoreMinThreshold": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "trustScoringEnabled": { + "type": "boolean" + } + }, + "type": "object" + }, "limit": { "maximum": 100, "minimum": 1, @@ -3933,6 +4455,180 @@ "format": "date-time", "type": "string" }, + "config_override": { + "additionalProperties": false, + "description": "Optional per-request overlay on RuntimeConfig. Unknown keys reject with 400; absent keys fall through to the startup config. Scope: just this request — no server mutation.", + "properties": { + "adaptiveRetrievalEnabled": { + "type": "boolean" + }, + "agenticRetrievalEnabled": { + "type": "boolean" + }, + "auditLoggingEnabled": { + "type": "boolean" + }, + "audnCandidateThreshold": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "chunkedExtractionEnabled": { + "type": "boolean" + }, + "compositeGroupingEnabled": { + "type": "boolean" + }, + "compositeMinClusterSize": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "consensusExtractionEnabled": { + "type": "boolean" + }, + "consensusExtractionRuns": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "consensusMinMemories": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "consensusValidationEnabled": { + "type": "boolean" + }, + "crossEncoderEnabled": { + "type": "boolean" + }, + "entityGraphEnabled": { + "type": "boolean" + }, + "entitySearchMinSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "entropyGateAlpha": { + "type": "number" + }, + "entropyGateEnabled": { + "type": "boolean" + }, + "entropyGateThreshold": { + "type": "number" + }, + "fastAudnDuplicateThreshold": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "fastAudnEnabled": { + "type": "boolean" + }, + "hybridSearchEnabled": { + "type": "boolean" + }, + "iterativeRetrievalEnabled": { + "type": "boolean" + }, + "lessonsEnabled": { + "type": "boolean" + }, + "linkExpansionBeforeMMR": { + "type": "boolean" + }, + "linkExpansionEnabled": { + "type": "boolean" + }, + "linkExpansionMax": { + "minimum": 0, + "type": "integer" + }, + "linkSimilarityThreshold": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "maxSearchResults": { + "exclusiveMinimum": 0, + "type": "integer" + }, + "mmrEnabled": { + "type": "boolean" + }, + "mmrLambda": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "namespaceClassificationEnabled": { + "type": "boolean" + }, + "pprDamping": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "pprEnabled": { + "type": "boolean" + }, + "queryAugmentationEnabled": { + "type": "boolean" + }, + "queryAugmentationMaxEntities": { + "minimum": 0, + "type": "integer" + }, + "queryAugmentationMinSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "queryExpansionEnabled": { + "type": "boolean" + }, + "queryExpansionMinSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "repairConfidenceFloor": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "repairDeltaThreshold": { + "type": "number" + }, + "repairLoopEnabled": { + "type": "boolean" + }, + "repairLoopMinSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "rerankSkipMinGap": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "rerankSkipTopSimilarity": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "trustScoreMinThreshold": { + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "trustScoringEnabled": { + "type": "boolean" + } + }, + "type": "object" + }, "limit": { "maximum": 100, "minimum": 1, diff --git a/openapi.yaml b/openapi.yaml index 2ceaa04..4a644aa 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1350,6 +1350,133 @@ paths: agent_id: description: Optional agent identifier. Silently dropped if empty / non-string. type: string + config_override: + additionalProperties: false + description: "Optional per-request overlay on RuntimeConfig. Unknown keys reject with 400; absent keys fall through to the startup config. Scope: just this request — no server mutation." + properties: + adaptiveRetrievalEnabled: + type: boolean + agenticRetrievalEnabled: + type: boolean + auditLoggingEnabled: + type: boolean + audnCandidateThreshold: + maximum: 1 + minimum: 0 + type: number + chunkedExtractionEnabled: + type: boolean + compositeGroupingEnabled: + type: boolean + compositeMinClusterSize: + exclusiveMinimum: 0 + type: integer + consensusExtractionEnabled: + type: boolean + consensusExtractionRuns: + exclusiveMinimum: 0 + type: integer + consensusMinMemories: + exclusiveMinimum: 0 + type: integer + consensusValidationEnabled: + type: boolean + crossEncoderEnabled: + type: boolean + entityGraphEnabled: + type: boolean + entitySearchMinSimilarity: + maximum: 1 + minimum: 0 + type: number + entropyGateAlpha: + type: number + entropyGateEnabled: + type: boolean + entropyGateThreshold: + type: number + fastAudnDuplicateThreshold: + maximum: 1 + minimum: 0 + type: number + fastAudnEnabled: + type: boolean + hybridSearchEnabled: + type: boolean + iterativeRetrievalEnabled: + type: boolean + lessonsEnabled: + type: boolean + linkExpansionBeforeMMR: + type: boolean + linkExpansionEnabled: + type: boolean + linkExpansionMax: + minimum: 0 + type: integer + linkSimilarityThreshold: + maximum: 1 + minimum: 0 + type: number + maxSearchResults: + exclusiveMinimum: 0 + type: integer + mmrEnabled: + type: boolean + mmrLambda: + maximum: 1 + minimum: 0 + type: number + namespaceClassificationEnabled: + type: boolean + pprDamping: + maximum: 1 + minimum: 0 + type: number + pprEnabled: + type: boolean + queryAugmentationEnabled: + type: boolean + queryAugmentationMaxEntities: + minimum: 0 + type: integer + queryAugmentationMinSimilarity: + maximum: 1 + minimum: 0 + type: number + queryExpansionEnabled: + type: boolean + queryExpansionMinSimilarity: + maximum: 1 + minimum: 0 + type: number + repairConfidenceFloor: + maximum: 1 + minimum: 0 + type: number + repairDeltaThreshold: + type: number + repairLoopEnabled: + type: boolean + repairLoopMinSimilarity: + maximum: 1 + minimum: 0 + type: number + rerankSkipMinGap: + maximum: 1 + minimum: 0 + type: number + rerankSkipTopSimilarity: + maximum: 1 + minimum: 0 + type: number + trustScoreMinThreshold: + maximum: 1 + minimum: 0 + type: number + trustScoringEnabled: + type: boolean + type: object conversation: description: Required. conversation. minLength: 1 @@ -1469,6 +1596,133 @@ paths: agent_id: description: Optional agent identifier. Silently dropped if empty / non-string. type: string + config_override: + additionalProperties: false + description: "Optional per-request overlay on RuntimeConfig. Unknown keys reject with 400; absent keys fall through to the startup config. Scope: just this request — no server mutation." + properties: + adaptiveRetrievalEnabled: + type: boolean + agenticRetrievalEnabled: + type: boolean + auditLoggingEnabled: + type: boolean + audnCandidateThreshold: + maximum: 1 + minimum: 0 + type: number + chunkedExtractionEnabled: + type: boolean + compositeGroupingEnabled: + type: boolean + compositeMinClusterSize: + exclusiveMinimum: 0 + type: integer + consensusExtractionEnabled: + type: boolean + consensusExtractionRuns: + exclusiveMinimum: 0 + type: integer + consensusMinMemories: + exclusiveMinimum: 0 + type: integer + consensusValidationEnabled: + type: boolean + crossEncoderEnabled: + type: boolean + entityGraphEnabled: + type: boolean + entitySearchMinSimilarity: + maximum: 1 + minimum: 0 + type: number + entropyGateAlpha: + type: number + entropyGateEnabled: + type: boolean + entropyGateThreshold: + type: number + fastAudnDuplicateThreshold: + maximum: 1 + minimum: 0 + type: number + fastAudnEnabled: + type: boolean + hybridSearchEnabled: + type: boolean + iterativeRetrievalEnabled: + type: boolean + lessonsEnabled: + type: boolean + linkExpansionBeforeMMR: + type: boolean + linkExpansionEnabled: + type: boolean + linkExpansionMax: + minimum: 0 + type: integer + linkSimilarityThreshold: + maximum: 1 + minimum: 0 + type: number + maxSearchResults: + exclusiveMinimum: 0 + type: integer + mmrEnabled: + type: boolean + mmrLambda: + maximum: 1 + minimum: 0 + type: number + namespaceClassificationEnabled: + type: boolean + pprDamping: + maximum: 1 + minimum: 0 + type: number + pprEnabled: + type: boolean + queryAugmentationEnabled: + type: boolean + queryAugmentationMaxEntities: + minimum: 0 + type: integer + queryAugmentationMinSimilarity: + maximum: 1 + minimum: 0 + type: number + queryExpansionEnabled: + type: boolean + queryExpansionMinSimilarity: + maximum: 1 + minimum: 0 + type: number + repairConfidenceFloor: + maximum: 1 + minimum: 0 + type: number + repairDeltaThreshold: + type: number + repairLoopEnabled: + type: boolean + repairLoopMinSimilarity: + maximum: 1 + minimum: 0 + type: number + rerankSkipMinGap: + maximum: 1 + minimum: 0 + type: number + rerankSkipTopSimilarity: + maximum: 1 + minimum: 0 + type: number + trustScoreMinThreshold: + maximum: 1 + minimum: 0 + type: number + trustScoringEnabled: + type: boolean + type: object conversation: description: Required. conversation. minLength: 1 @@ -2315,6 +2569,133 @@ paths: example: 2026-01-15T12:00:00Z format: date-time type: string + config_override: + additionalProperties: false + description: "Optional per-request overlay on RuntimeConfig. Unknown keys reject with 400; absent keys fall through to the startup config. Scope: just this request — no server mutation." + properties: + adaptiveRetrievalEnabled: + type: boolean + agenticRetrievalEnabled: + type: boolean + auditLoggingEnabled: + type: boolean + audnCandidateThreshold: + maximum: 1 + minimum: 0 + type: number + chunkedExtractionEnabled: + type: boolean + compositeGroupingEnabled: + type: boolean + compositeMinClusterSize: + exclusiveMinimum: 0 + type: integer + consensusExtractionEnabled: + type: boolean + consensusExtractionRuns: + exclusiveMinimum: 0 + type: integer + consensusMinMemories: + exclusiveMinimum: 0 + type: integer + consensusValidationEnabled: + type: boolean + crossEncoderEnabled: + type: boolean + entityGraphEnabled: + type: boolean + entitySearchMinSimilarity: + maximum: 1 + minimum: 0 + type: number + entropyGateAlpha: + type: number + entropyGateEnabled: + type: boolean + entropyGateThreshold: + type: number + fastAudnDuplicateThreshold: + maximum: 1 + minimum: 0 + type: number + fastAudnEnabled: + type: boolean + hybridSearchEnabled: + type: boolean + iterativeRetrievalEnabled: + type: boolean + lessonsEnabled: + type: boolean + linkExpansionBeforeMMR: + type: boolean + linkExpansionEnabled: + type: boolean + linkExpansionMax: + minimum: 0 + type: integer + linkSimilarityThreshold: + maximum: 1 + minimum: 0 + type: number + maxSearchResults: + exclusiveMinimum: 0 + type: integer + mmrEnabled: + type: boolean + mmrLambda: + maximum: 1 + minimum: 0 + type: number + namespaceClassificationEnabled: + type: boolean + pprDamping: + maximum: 1 + minimum: 0 + type: number + pprEnabled: + type: boolean + queryAugmentationEnabled: + type: boolean + queryAugmentationMaxEntities: + minimum: 0 + type: integer + queryAugmentationMinSimilarity: + maximum: 1 + minimum: 0 + type: number + queryExpansionEnabled: + type: boolean + queryExpansionMinSimilarity: + maximum: 1 + minimum: 0 + type: number + repairConfidenceFloor: + maximum: 1 + minimum: 0 + type: number + repairDeltaThreshold: + type: number + repairLoopEnabled: + type: boolean + repairLoopMinSimilarity: + maximum: 1 + minimum: 0 + type: number + rerankSkipMinGap: + maximum: 1 + minimum: 0 + type: number + rerankSkipTopSimilarity: + maximum: 1 + minimum: 0 + type: number + trustScoreMinThreshold: + maximum: 1 + minimum: 0 + type: number + trustScoringEnabled: + type: boolean + type: object limit: maximum: 100 minimum: 1 @@ -2654,6 +3035,133 @@ paths: example: 2026-01-15T12:00:00Z format: date-time type: string + config_override: + additionalProperties: false + description: "Optional per-request overlay on RuntimeConfig. Unknown keys reject with 400; absent keys fall through to the startup config. Scope: just this request — no server mutation." + properties: + adaptiveRetrievalEnabled: + type: boolean + agenticRetrievalEnabled: + type: boolean + auditLoggingEnabled: + type: boolean + audnCandidateThreshold: + maximum: 1 + minimum: 0 + type: number + chunkedExtractionEnabled: + type: boolean + compositeGroupingEnabled: + type: boolean + compositeMinClusterSize: + exclusiveMinimum: 0 + type: integer + consensusExtractionEnabled: + type: boolean + consensusExtractionRuns: + exclusiveMinimum: 0 + type: integer + consensusMinMemories: + exclusiveMinimum: 0 + type: integer + consensusValidationEnabled: + type: boolean + crossEncoderEnabled: + type: boolean + entityGraphEnabled: + type: boolean + entitySearchMinSimilarity: + maximum: 1 + minimum: 0 + type: number + entropyGateAlpha: + type: number + entropyGateEnabled: + type: boolean + entropyGateThreshold: + type: number + fastAudnDuplicateThreshold: + maximum: 1 + minimum: 0 + type: number + fastAudnEnabled: + type: boolean + hybridSearchEnabled: + type: boolean + iterativeRetrievalEnabled: + type: boolean + lessonsEnabled: + type: boolean + linkExpansionBeforeMMR: + type: boolean + linkExpansionEnabled: + type: boolean + linkExpansionMax: + minimum: 0 + type: integer + linkSimilarityThreshold: + maximum: 1 + minimum: 0 + type: number + maxSearchResults: + exclusiveMinimum: 0 + type: integer + mmrEnabled: + type: boolean + mmrLambda: + maximum: 1 + minimum: 0 + type: number + namespaceClassificationEnabled: + type: boolean + pprDamping: + maximum: 1 + minimum: 0 + type: number + pprEnabled: + type: boolean + queryAugmentationEnabled: + type: boolean + queryAugmentationMaxEntities: + minimum: 0 + type: integer + queryAugmentationMinSimilarity: + maximum: 1 + minimum: 0 + type: number + queryExpansionEnabled: + type: boolean + queryExpansionMinSimilarity: + maximum: 1 + minimum: 0 + type: number + repairConfidenceFloor: + maximum: 1 + minimum: 0 + type: number + repairDeltaThreshold: + type: number + repairLoopEnabled: + type: boolean + repairLoopMinSimilarity: + maximum: 1 + minimum: 0 + type: number + rerankSkipMinGap: + maximum: 1 + minimum: 0 + type: number + rerankSkipTopSimilarity: + maximum: 1 + minimum: 0 + type: number + trustScoreMinThreshold: + maximum: 1 + minimum: 0 + type: number + trustScoringEnabled: + type: boolean + 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..9b097ae --- /dev/null +++ b/src/__tests__/memory-route-config-override.test.ts @@ -0,0 +1,192 @@ +/** + * 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 surface as 400 via Zod `.strict()` and do + * NOT reach the service layer. + */ + +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 → 400 and service is not invoked', 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: { bogusFlag: true }, + }), + }); + expect(res.status).toBe(400); + expect(scopedSearch).not.toHaveBeenCalled(); + }); + + 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..20a70f7 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); @@ -192,6 +199,7 @@ function registerSearchRoute( try { const body = req.body as SearchBody; const { scope, requestLimit } = resolveSearchPreamble(body, configRouteAdapter); + const effectiveConfig = applyRequestConfigOverride(res, body.configOverride); const retrievalOptions: { retrievalMode?: SearchBody['retrievalMode']; tokenBudget?: SearchBody['tokenBudget']; skipRepairLoop?: boolean } = { retrievalMode: body.retrievalMode, tokenBudget: body.tokenBudget, @@ -203,6 +211,7 @@ function registerSearchRoute( asOf: body.asOf, namespaceScope: body.namespaceScope, retrievalOptions, + effectiveConfig, }); res.json(formatSearchResponse(result, scope)); } catch (err) { @@ -224,11 +233,13 @@ function registerFastSearchRoute( try { const body = req.body as SearchBody; const { scope, requestLimit } = resolveSearchPreamble(body, configRouteAdapter); + const effectiveConfig = applyRequestConfigOverride(res, body.configOverride); 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) { @@ -594,6 +605,29 @@ 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 three 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 + */ +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)); + 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..0664e9e --- /dev/null +++ b/src/schemas/__tests__/config-override.test.ts @@ -0,0 +1,111 @@ +/** + * @file Wire-contract tests for the per-request `config_override` field + * threaded onto `IngestBodySchema` and `SearchBodySchema`. + * + * Locks in the two invariants that matter for API callers: + * 1. Unknown keys reject with 400 (Zod `.strict()` → "Unrecognized key(s)") + * so client-side typos surface immediately instead of silently + * no-op'ing downstream. + * 2. Every listed key is optional — callers may send any subset, + * including the empty object, and parsing succeeds. + * + * The handler-side behavior (three `X-Atomicmem-*` response headers, + * scope-of-request propagation) is covered in the route integration + * tests; this file is schema-only. + */ + +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 — strictness', () => { + it('accepts an empty object', () => { + const r = ConfigOverrideSchema.safeParse({}); + expect(r.success).toBe(true); + }); + + it('accepts a subset of known fields', () => { + 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('rejects unknown keys with a Zod .strict() error', () => { + const r = ConfigOverrideSchema.safeParse({ bogusFlag: true }); + expect(r.success).toBe(false); + if (!r.success) { + const msg = r.error.issues[0]?.message ?? ''; + expect(msg.toLowerCase()).toMatch(/unrecognized/); + } + }); + + it('rejects snake_case typos of otherwise-valid fields', () => { + const r = ConfigOverrideSchema.safeParse({ hybrid_search_enabled: true }); + 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('rejects unknown keys inside config_override', () => { + const r = IngestBodySchema.safeParse({ + ...INGEST_BASE, + config_override: { bogus: 1 }, + }); + expect(r.success).toBe(false); + }); +}); + +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('rejects unknown keys inside config_override', () => { + const r = SearchBodySchema.safeParse({ + ...SEARCH_BASE, + config_override: { totallyMadeUp: 7 }, + }); + expect(r.success).toBe(false); + }); +}); diff --git a/src/schemas/memories.ts b/src/schemas/memories.ts index ff2221b..c8fdaec 100644 --- a/src/schemas/memories.ts +++ b/src/schemas/memories.ts @@ -179,6 +179,92 @@ const RetrievalModeField = z enum: ['flat', 'tiered', 'abstract-aware'], }); +// --------------------------------------------------------------------------- +// Per-request config override +// --------------------------------------------------------------------------- + +/** + * Flat, partial override on the startup RuntimeConfig. Keys match + * `RuntimeConfig` field names one-for-one; the handler applies the + * validated object as a shallow merge (`{ ...startup, ...override }`) + * onto the effective request-scope config. + * + * - `.strict()` rejects unknown keys with a 400 so typos don't silently + * no-op. + * - `.partial()` keeps every field optional — absent fields fall through + * to the startup default. + * + * The grouping below (retrieval-side vs ingest-side) is documentation + * for human readers only; the overlay merges flat regardless of group. + * + * Coverage is intentionally limited to fields that are threaded through + * `MemoryServiceDeps.config` today (i.e. `CoreRuntimeConfig` + + * `IngestRuntimeConfig`). Fields read from the module-level config + * singleton by leaf modules (e.g. scoring weights in `db/query-helpers.ts`, + * `retrievalProfileSettings.*` nested values like `rerankDepth`) are + * deliberately omitted — including them would accept overrides that + * silently no-op. Future landings lift those fields onto top-level + * `RuntimeConfig` as they become overlay-eligible. + */ +export const ConfigOverrideSchema = z + .object({ + // Retrieval-side (threaded via MemoryServiceDeps.config → search-pipeline) + adaptiveRetrievalEnabled: z.boolean().optional(), + agenticRetrievalEnabled: z.boolean().optional(), + auditLoggingEnabled: z.boolean().optional(), + consensusMinMemories: z.number().int().positive().optional(), + consensusValidationEnabled: z.boolean().optional(), + crossEncoderEnabled: z.boolean().optional(), + entityGraphEnabled: z.boolean().optional(), + entitySearchMinSimilarity: z.number().min(0).max(1).optional(), + hybridSearchEnabled: z.boolean().optional(), + iterativeRetrievalEnabled: z.boolean().optional(), + lessonsEnabled: z.boolean().optional(), + linkExpansionBeforeMMR: z.boolean().optional(), + linkExpansionEnabled: z.boolean().optional(), + linkExpansionMax: z.number().int().nonnegative().optional(), + linkSimilarityThreshold: z.number().min(0).max(1).optional(), + maxSearchResults: z.number().int().positive().optional(), + mmrEnabled: z.boolean().optional(), + mmrLambda: z.number().min(0).max(1).optional(), + namespaceClassificationEnabled: z.boolean().optional(), + pprDamping: z.number().min(0).max(1).optional(), + pprEnabled: z.boolean().optional(), + queryAugmentationEnabled: z.boolean().optional(), + queryAugmentationMaxEntities: z.number().int().nonnegative().optional(), + queryAugmentationMinSimilarity: z.number().min(0).max(1).optional(), + queryExpansionEnabled: z.boolean().optional(), + queryExpansionMinSimilarity: z.number().min(0).max(1).optional(), + repairConfidenceFloor: z.number().min(0).max(1).optional(), + repairDeltaThreshold: z.number().optional(), + repairLoopEnabled: z.boolean().optional(), + repairLoopMinSimilarity: z.number().min(0).max(1).optional(), + rerankSkipMinGap: z.number().min(0).max(1).optional(), + rerankSkipTopSimilarity: z.number().min(0).max(1).optional(), + + // Ingest-side (threaded via MemoryServiceDeps.config → ingest-fact-pipeline) + audnCandidateThreshold: z.number().min(0).max(1).optional(), + chunkedExtractionEnabled: z.boolean().optional(), + compositeGroupingEnabled: z.boolean().optional(), + compositeMinClusterSize: z.number().int().positive().optional(), + consensusExtractionEnabled: z.boolean().optional(), + consensusExtractionRuns: z.number().int().positive().optional(), + entropyGateAlpha: z.number().optional(), + entropyGateEnabled: z.boolean().optional(), + entropyGateThreshold: z.number().optional(), + fastAudnDuplicateThreshold: z.number().min(0).max(1).optional(), + fastAudnEnabled: z.boolean().optional(), + trustScoreMinThreshold: z.number().min(0).max(1).optional(), + trustScoringEnabled: z.boolean().optional(), + }) + .strict() + .openapi({ + description: + 'Optional per-request overlay on RuntimeConfig. Unknown keys reject with 400; absent keys fall through to the startup config. Scope: just this request — no server mutation.', + }); + +export type ConfigOverride = z.infer; + // --------------------------------------------------------------------------- // Ingest // --------------------------------------------------------------------------- @@ -197,6 +283,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 +292,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 +322,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 +336,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(','); +} From 36e877556468754d081856e35f3d2fddfc7d7792 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 23 Apr 2026 02:13:02 -0700 Subject: [PATCH 2/3] feat(config): permissive ConfigOverrideSchema + unknown-key warning header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relaxes the per-request config_override schema from an enumerated `.strict().partial()` list of 45 fields to a permissive `z.record(z.union([z.boolean(), z.number(), z.string(), z.null()]))`. Adds a runtime unknown-key detector in the route handler that emits `X-Atomicmem-Unknown-Override-Keys` and logs a server-side warning when any override key doesn't match a known RuntimeConfig field. **Why this change.** The enumerated-schema version coupled every new overlay-eligible RuntimeConfig field to a core release. An experimenter tuning a newly-added field would have to wait for core to land a schema update before their config override could address it. That defeats the purpose of a per-request config mechanism — configuration should be decoupled from schema releases. **New contract.** - `config_override` accepts any object whose values are primitives (boolean, number, string, null). Keys are not enumerated. - Unknown keys are carried through on the effective config merge AND surfaced on the response as `X-Atomicmem-Unknown-Override-Keys: ` + a warning log. - Typos don't 400 — they no-op visibly. Experimenters see the warning header and fix the typo. - Nested objects and arrays still reject (override is flat by contract). - Field-level type validation (e.g. "mmrLambda must be 0–1") is no longer enforced by the schema — that's the consumer's job at the point where it reads the value. A value that looks wrong silently misbehaves or throws downstream rather than 400-ing at the edge. **Backward compatibility.** - Requests that previously passed (all fields matched the enumerated set) still pass. - Requests that previously 400'd on unknown keys now 200 with the warning header. - Responses without override behavior unchanged. - OpenAPI regenerated (`config_override` is now `additionalProperties` with primitive-or-null values). **Tests updated.** - `src/schemas/__tests__/config-override.test.ts`: strictness assertions replaced with permissive-shape assertions. New coverage for forward-compat (unknown keys accepted), primitive-value boundaries (objects/arrays rejected). - `src/__tests__/memory-route-config-override.test.ts`: the "unknown key → 400" test became "unknown key → 200 + warning header + log"; added new tests for "mix of known + unknown" and "all-known → no warning header". - Full suite: 1071 tests pass (net +4 from new coverage). **Design follow-up.** The schema is still the "static config override" shape; genuine plugin-style runtime-loadable experiment modules (code that participates in pipeline stages, not just knob values) remain a future architectural choice. This landing unblocks Phase-1 experiments without pre-committing to the enumeration anti-pattern on the public API. Updates PR #41 before merge / initial public release. --- openapi.json | 744 ++---------------- openapi.yaml | 528 +------------ .../memory-route-config-override.test.ts | 48 +- src/routes/memories.ts | 25 +- src/schemas/__tests__/config-override.test.ts | 74 +- src/schemas/memories.ts | 100 +-- 6 files changed, 237 insertions(+), 1282 deletions(-) diff --git a/openapi.json b/openapi.json index 66219ab..276f54a 100644 --- a/openapi.json +++ b/openapi.json @@ -2002,177 +2002,23 @@ "type": "string" }, "config_override": { - "additionalProperties": false, - "description": "Optional per-request overlay on RuntimeConfig. Unknown keys reject with 400; absent keys fall through to the startup config. Scope: just this request — no server mutation.", - "properties": { - "adaptiveRetrievalEnabled": { - "type": "boolean" - }, - "agenticRetrievalEnabled": { - "type": "boolean" - }, - "auditLoggingEnabled": { - "type": "boolean" - }, - "audnCandidateThreshold": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "chunkedExtractionEnabled": { - "type": "boolean" - }, - "compositeGroupingEnabled": { - "type": "boolean" - }, - "compositeMinClusterSize": { - "exclusiveMinimum": 0, - "type": "integer" - }, - "consensusExtractionEnabled": { - "type": "boolean" - }, - "consensusExtractionRuns": { - "exclusiveMinimum": 0, - "type": "integer" - }, - "consensusMinMemories": { - "exclusiveMinimum": 0, - "type": "integer" - }, - "consensusValidationEnabled": { - "type": "boolean" - }, - "crossEncoderEnabled": { - "type": "boolean" - }, - "entityGraphEnabled": { - "type": "boolean" - }, - "entitySearchMinSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "entropyGateAlpha": { - "type": "number" - }, - "entropyGateEnabled": { - "type": "boolean" - }, - "entropyGateThreshold": { - "type": "number" - }, - "fastAudnDuplicateThreshold": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "fastAudnEnabled": { - "type": "boolean" - }, - "hybridSearchEnabled": { - "type": "boolean" - }, - "iterativeRetrievalEnabled": { - "type": "boolean" - }, - "lessonsEnabled": { - "type": "boolean" - }, - "linkExpansionBeforeMMR": { - "type": "boolean" - }, - "linkExpansionEnabled": { - "type": "boolean" - }, - "linkExpansionMax": { - "minimum": 0, - "type": "integer" - }, - "linkSimilarityThreshold": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "maxSearchResults": { - "exclusiveMinimum": 0, - "type": "integer" - }, - "mmrEnabled": { - "type": "boolean" - }, - "mmrLambda": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "namespaceClassificationEnabled": { - "type": "boolean" - }, - "pprDamping": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "pprEnabled": { - "type": "boolean" - }, - "queryAugmentationEnabled": { - "type": "boolean" - }, - "queryAugmentationMaxEntities": { - "minimum": 0, - "type": "integer" - }, - "queryAugmentationMinSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "queryExpansionEnabled": { - "type": "boolean" - }, - "queryExpansionMinSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "repairConfidenceFloor": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "repairDeltaThreshold": { - "type": "number" - }, - "repairLoopEnabled": { - "type": "boolean" - }, - "repairLoopMinSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "rerankSkipMinGap": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "rerankSkipTopSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "trustScoreMinThreshold": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "trustScoringEnabled": { - "type": "boolean" - } + "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": { @@ -2348,177 +2194,23 @@ "type": "string" }, "config_override": { - "additionalProperties": false, - "description": "Optional per-request overlay on RuntimeConfig. Unknown keys reject with 400; absent keys fall through to the startup config. Scope: just this request — no server mutation.", - "properties": { - "adaptiveRetrievalEnabled": { - "type": "boolean" - }, - "agenticRetrievalEnabled": { - "type": "boolean" - }, - "auditLoggingEnabled": { - "type": "boolean" - }, - "audnCandidateThreshold": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "chunkedExtractionEnabled": { - "type": "boolean" - }, - "compositeGroupingEnabled": { - "type": "boolean" - }, - "compositeMinClusterSize": { - "exclusiveMinimum": 0, - "type": "integer" - }, - "consensusExtractionEnabled": { - "type": "boolean" - }, - "consensusExtractionRuns": { - "exclusiveMinimum": 0, - "type": "integer" - }, - "consensusMinMemories": { - "exclusiveMinimum": 0, - "type": "integer" - }, - "consensusValidationEnabled": { - "type": "boolean" - }, - "crossEncoderEnabled": { - "type": "boolean" - }, - "entityGraphEnabled": { - "type": "boolean" - }, - "entitySearchMinSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "entropyGateAlpha": { - "type": "number" - }, - "entropyGateEnabled": { - "type": "boolean" - }, - "entropyGateThreshold": { - "type": "number" - }, - "fastAudnDuplicateThreshold": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "fastAudnEnabled": { - "type": "boolean" - }, - "hybridSearchEnabled": { - "type": "boolean" - }, - "iterativeRetrievalEnabled": { - "type": "boolean" - }, - "lessonsEnabled": { - "type": "boolean" - }, - "linkExpansionBeforeMMR": { - "type": "boolean" - }, - "linkExpansionEnabled": { - "type": "boolean" - }, - "linkExpansionMax": { - "minimum": 0, - "type": "integer" - }, - "linkSimilarityThreshold": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "maxSearchResults": { - "exclusiveMinimum": 0, - "type": "integer" - }, - "mmrEnabled": { - "type": "boolean" - }, - "mmrLambda": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "namespaceClassificationEnabled": { - "type": "boolean" - }, - "pprDamping": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "pprEnabled": { - "type": "boolean" - }, - "queryAugmentationEnabled": { - "type": "boolean" - }, - "queryAugmentationMaxEntities": { - "minimum": 0, - "type": "integer" - }, - "queryAugmentationMinSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "queryExpansionEnabled": { - "type": "boolean" - }, - "queryExpansionMinSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "repairConfidenceFloor": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "repairDeltaThreshold": { - "type": "number" - }, - "repairLoopEnabled": { - "type": "boolean" - }, - "repairLoopMinSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "rerankSkipMinGap": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "rerankSkipTopSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "trustScoreMinThreshold": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "trustScoringEnabled": { - "type": "boolean" - } + "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": { @@ -3788,177 +3480,23 @@ "type": "string" }, "config_override": { - "additionalProperties": false, - "description": "Optional per-request overlay on RuntimeConfig. Unknown keys reject with 400; absent keys fall through to the startup config. Scope: just this request — no server mutation.", - "properties": { - "adaptiveRetrievalEnabled": { - "type": "boolean" - }, - "agenticRetrievalEnabled": { - "type": "boolean" - }, - "auditLoggingEnabled": { - "type": "boolean" - }, - "audnCandidateThreshold": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "chunkedExtractionEnabled": { - "type": "boolean" - }, - "compositeGroupingEnabled": { - "type": "boolean" - }, - "compositeMinClusterSize": { - "exclusiveMinimum": 0, - "type": "integer" - }, - "consensusExtractionEnabled": { - "type": "boolean" - }, - "consensusExtractionRuns": { - "exclusiveMinimum": 0, - "type": "integer" - }, - "consensusMinMemories": { - "exclusiveMinimum": 0, - "type": "integer" - }, - "consensusValidationEnabled": { - "type": "boolean" - }, - "crossEncoderEnabled": { - "type": "boolean" - }, - "entityGraphEnabled": { - "type": "boolean" - }, - "entitySearchMinSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "entropyGateAlpha": { - "type": "number" - }, - "entropyGateEnabled": { - "type": "boolean" - }, - "entropyGateThreshold": { - "type": "number" - }, - "fastAudnDuplicateThreshold": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "fastAudnEnabled": { - "type": "boolean" - }, - "hybridSearchEnabled": { - "type": "boolean" - }, - "iterativeRetrievalEnabled": { - "type": "boolean" - }, - "lessonsEnabled": { - "type": "boolean" - }, - "linkExpansionBeforeMMR": { - "type": "boolean" - }, - "linkExpansionEnabled": { - "type": "boolean" - }, - "linkExpansionMax": { - "minimum": 0, - "type": "integer" - }, - "linkSimilarityThreshold": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "maxSearchResults": { - "exclusiveMinimum": 0, - "type": "integer" - }, - "mmrEnabled": { - "type": "boolean" - }, - "mmrLambda": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "namespaceClassificationEnabled": { - "type": "boolean" - }, - "pprDamping": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "pprEnabled": { - "type": "boolean" - }, - "queryAugmentationEnabled": { - "type": "boolean" - }, - "queryAugmentationMaxEntities": { - "minimum": 0, - "type": "integer" - }, - "queryAugmentationMinSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "queryExpansionEnabled": { - "type": "boolean" - }, - "queryExpansionMinSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "repairConfidenceFloor": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "repairDeltaThreshold": { - "type": "number" - }, - "repairLoopEnabled": { - "type": "boolean" - }, - "repairLoopMinSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "rerankSkipMinGap": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "rerankSkipTopSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "trustScoreMinThreshold": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "trustScoringEnabled": { - "type": "boolean" - } + "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": { @@ -4456,177 +3994,23 @@ "type": "string" }, "config_override": { - "additionalProperties": false, - "description": "Optional per-request overlay on RuntimeConfig. Unknown keys reject with 400; absent keys fall through to the startup config. Scope: just this request — no server mutation.", - "properties": { - "adaptiveRetrievalEnabled": { - "type": "boolean" - }, - "agenticRetrievalEnabled": { - "type": "boolean" - }, - "auditLoggingEnabled": { - "type": "boolean" - }, - "audnCandidateThreshold": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "chunkedExtractionEnabled": { - "type": "boolean" - }, - "compositeGroupingEnabled": { - "type": "boolean" - }, - "compositeMinClusterSize": { - "exclusiveMinimum": 0, - "type": "integer" - }, - "consensusExtractionEnabled": { - "type": "boolean" - }, - "consensusExtractionRuns": { - "exclusiveMinimum": 0, - "type": "integer" - }, - "consensusMinMemories": { - "exclusiveMinimum": 0, - "type": "integer" - }, - "consensusValidationEnabled": { - "type": "boolean" - }, - "crossEncoderEnabled": { - "type": "boolean" - }, - "entityGraphEnabled": { - "type": "boolean" - }, - "entitySearchMinSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "entropyGateAlpha": { - "type": "number" - }, - "entropyGateEnabled": { - "type": "boolean" - }, - "entropyGateThreshold": { - "type": "number" - }, - "fastAudnDuplicateThreshold": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "fastAudnEnabled": { - "type": "boolean" - }, - "hybridSearchEnabled": { - "type": "boolean" - }, - "iterativeRetrievalEnabled": { - "type": "boolean" - }, - "lessonsEnabled": { - "type": "boolean" - }, - "linkExpansionBeforeMMR": { - "type": "boolean" - }, - "linkExpansionEnabled": { - "type": "boolean" - }, - "linkExpansionMax": { - "minimum": 0, - "type": "integer" - }, - "linkSimilarityThreshold": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "maxSearchResults": { - "exclusiveMinimum": 0, - "type": "integer" - }, - "mmrEnabled": { - "type": "boolean" - }, - "mmrLambda": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "namespaceClassificationEnabled": { - "type": "boolean" - }, - "pprDamping": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "pprEnabled": { - "type": "boolean" - }, - "queryAugmentationEnabled": { - "type": "boolean" - }, - "queryAugmentationMaxEntities": { - "minimum": 0, - "type": "integer" - }, - "queryAugmentationMinSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "queryExpansionEnabled": { - "type": "boolean" - }, - "queryExpansionMinSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "repairConfidenceFloor": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "repairDeltaThreshold": { - "type": "number" - }, - "repairLoopEnabled": { - "type": "boolean" - }, - "repairLoopMinSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "rerankSkipMinGap": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "rerankSkipTopSimilarity": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "trustScoreMinThreshold": { - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "trustScoringEnabled": { - "type": "boolean" - } + "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": { diff --git a/openapi.yaml b/openapi.yaml index 4a644aa..7e32d0b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1351,131 +1351,13 @@ paths: description: Optional agent identifier. Silently dropped if empty / non-string. type: string config_override: - additionalProperties: false - description: "Optional per-request overlay on RuntimeConfig. Unknown keys reject with 400; absent keys fall through to the startup config. Scope: just this request — no server mutation." - properties: - adaptiveRetrievalEnabled: - type: boolean - agenticRetrievalEnabled: - type: boolean - auditLoggingEnabled: - type: boolean - audnCandidateThreshold: - maximum: 1 - minimum: 0 - type: number - chunkedExtractionEnabled: - type: boolean - compositeGroupingEnabled: - type: boolean - compositeMinClusterSize: - exclusiveMinimum: 0 - type: integer - consensusExtractionEnabled: - type: boolean - consensusExtractionRuns: - exclusiveMinimum: 0 - type: integer - consensusMinMemories: - exclusiveMinimum: 0 - type: integer - consensusValidationEnabled: - type: boolean - crossEncoderEnabled: - type: boolean - entityGraphEnabled: - type: boolean - entitySearchMinSimilarity: - maximum: 1 - minimum: 0 - type: number - entropyGateAlpha: - type: number - entropyGateEnabled: - type: boolean - entropyGateThreshold: - type: number - fastAudnDuplicateThreshold: - maximum: 1 - minimum: 0 - type: number - fastAudnEnabled: - type: boolean - hybridSearchEnabled: - type: boolean - iterativeRetrievalEnabled: - type: boolean - lessonsEnabled: - type: boolean - linkExpansionBeforeMMR: - type: boolean - linkExpansionEnabled: - type: boolean - linkExpansionMax: - minimum: 0 - type: integer - linkSimilarityThreshold: - maximum: 1 - minimum: 0 - type: number - maxSearchResults: - exclusiveMinimum: 0 - type: integer - mmrEnabled: - type: boolean - mmrLambda: - maximum: 1 - minimum: 0 - type: number - namespaceClassificationEnabled: - type: boolean - pprDamping: - maximum: 1 - minimum: 0 - type: number - pprEnabled: - type: boolean - queryAugmentationEnabled: - type: boolean - queryAugmentationMaxEntities: - minimum: 0 - type: integer - queryAugmentationMinSimilarity: - maximum: 1 - minimum: 0 - type: number - queryExpansionEnabled: - type: boolean - queryExpansionMinSimilarity: - maximum: 1 - minimum: 0 - type: number - repairConfidenceFloor: - maximum: 1 - minimum: 0 - type: number - repairDeltaThreshold: - type: number - repairLoopEnabled: - type: boolean - repairLoopMinSimilarity: - maximum: 1 - minimum: 0 - type: number - rerankSkipMinGap: - maximum: 1 - minimum: 0 - type: number - rerankSkipTopSimilarity: - maximum: 1 - minimum: 0 - type: number - trustScoreMinThreshold: - maximum: 1 - minimum: 0 - type: number - trustScoringEnabled: - type: boolean + 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. @@ -1597,131 +1479,13 @@ paths: description: Optional agent identifier. Silently dropped if empty / non-string. type: string config_override: - additionalProperties: false - description: "Optional per-request overlay on RuntimeConfig. Unknown keys reject with 400; absent keys fall through to the startup config. Scope: just this request — no server mutation." - properties: - adaptiveRetrievalEnabled: - type: boolean - agenticRetrievalEnabled: - type: boolean - auditLoggingEnabled: - type: boolean - audnCandidateThreshold: - maximum: 1 - minimum: 0 - type: number - chunkedExtractionEnabled: - type: boolean - compositeGroupingEnabled: - type: boolean - compositeMinClusterSize: - exclusiveMinimum: 0 - type: integer - consensusExtractionEnabled: - type: boolean - consensusExtractionRuns: - exclusiveMinimum: 0 - type: integer - consensusMinMemories: - exclusiveMinimum: 0 - type: integer - consensusValidationEnabled: - type: boolean - crossEncoderEnabled: - type: boolean - entityGraphEnabled: - type: boolean - entitySearchMinSimilarity: - maximum: 1 - minimum: 0 - type: number - entropyGateAlpha: - type: number - entropyGateEnabled: - type: boolean - entropyGateThreshold: - type: number - fastAudnDuplicateThreshold: - maximum: 1 - minimum: 0 - type: number - fastAudnEnabled: - type: boolean - hybridSearchEnabled: - type: boolean - iterativeRetrievalEnabled: - type: boolean - lessonsEnabled: - type: boolean - linkExpansionBeforeMMR: - type: boolean - linkExpansionEnabled: - type: boolean - linkExpansionMax: - minimum: 0 - type: integer - linkSimilarityThreshold: - maximum: 1 - minimum: 0 - type: number - maxSearchResults: - exclusiveMinimum: 0 - type: integer - mmrEnabled: - type: boolean - mmrLambda: - maximum: 1 - minimum: 0 - type: number - namespaceClassificationEnabled: - type: boolean - pprDamping: - maximum: 1 - minimum: 0 - type: number - pprEnabled: - type: boolean - queryAugmentationEnabled: - type: boolean - queryAugmentationMaxEntities: - minimum: 0 - type: integer - queryAugmentationMinSimilarity: - maximum: 1 - minimum: 0 - type: number - queryExpansionEnabled: - type: boolean - queryExpansionMinSimilarity: - maximum: 1 - minimum: 0 - type: number - repairConfidenceFloor: - maximum: 1 - minimum: 0 - type: number - repairDeltaThreshold: - type: number - repairLoopEnabled: - type: boolean - repairLoopMinSimilarity: - maximum: 1 - minimum: 0 - type: number - rerankSkipMinGap: - maximum: 1 - minimum: 0 - type: number - rerankSkipTopSimilarity: - maximum: 1 - minimum: 0 - type: number - trustScoreMinThreshold: - maximum: 1 - minimum: 0 - type: number - trustScoringEnabled: - type: boolean + 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. @@ -2570,131 +2334,13 @@ paths: format: date-time type: string config_override: - additionalProperties: false - description: "Optional per-request overlay on RuntimeConfig. Unknown keys reject with 400; absent keys fall through to the startup config. Scope: just this request — no server mutation." - properties: - adaptiveRetrievalEnabled: - type: boolean - agenticRetrievalEnabled: - type: boolean - auditLoggingEnabled: - type: boolean - audnCandidateThreshold: - maximum: 1 - minimum: 0 - type: number - chunkedExtractionEnabled: - type: boolean - compositeGroupingEnabled: - type: boolean - compositeMinClusterSize: - exclusiveMinimum: 0 - type: integer - consensusExtractionEnabled: - type: boolean - consensusExtractionRuns: - exclusiveMinimum: 0 - type: integer - consensusMinMemories: - exclusiveMinimum: 0 - type: integer - consensusValidationEnabled: - type: boolean - crossEncoderEnabled: - type: boolean - entityGraphEnabled: - type: boolean - entitySearchMinSimilarity: - maximum: 1 - minimum: 0 - type: number - entropyGateAlpha: - type: number - entropyGateEnabled: - type: boolean - entropyGateThreshold: - type: number - fastAudnDuplicateThreshold: - maximum: 1 - minimum: 0 - type: number - fastAudnEnabled: - type: boolean - hybridSearchEnabled: - type: boolean - iterativeRetrievalEnabled: - type: boolean - lessonsEnabled: - type: boolean - linkExpansionBeforeMMR: - type: boolean - linkExpansionEnabled: - type: boolean - linkExpansionMax: - minimum: 0 - type: integer - linkSimilarityThreshold: - maximum: 1 - minimum: 0 - type: number - maxSearchResults: - exclusiveMinimum: 0 - type: integer - mmrEnabled: - type: boolean - mmrLambda: - maximum: 1 - minimum: 0 - type: number - namespaceClassificationEnabled: - type: boolean - pprDamping: - maximum: 1 - minimum: 0 - type: number - pprEnabled: - type: boolean - queryAugmentationEnabled: - type: boolean - queryAugmentationMaxEntities: - minimum: 0 - type: integer - queryAugmentationMinSimilarity: - maximum: 1 - minimum: 0 - type: number - queryExpansionEnabled: - type: boolean - queryExpansionMinSimilarity: - maximum: 1 - minimum: 0 - type: number - repairConfidenceFloor: - maximum: 1 - minimum: 0 - type: number - repairDeltaThreshold: - type: number - repairLoopEnabled: - type: boolean - repairLoopMinSimilarity: - maximum: 1 - minimum: 0 - type: number - rerankSkipMinGap: - maximum: 1 - minimum: 0 - type: number - rerankSkipTopSimilarity: - maximum: 1 - minimum: 0 - type: number - trustScoreMinThreshold: - maximum: 1 - minimum: 0 - type: number - trustScoringEnabled: - type: boolean + 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 @@ -3036,131 +2682,13 @@ paths: format: date-time type: string config_override: - additionalProperties: false - description: "Optional per-request overlay on RuntimeConfig. Unknown keys reject with 400; absent keys fall through to the startup config. Scope: just this request — no server mutation." - properties: - adaptiveRetrievalEnabled: - type: boolean - agenticRetrievalEnabled: - type: boolean - auditLoggingEnabled: - type: boolean - audnCandidateThreshold: - maximum: 1 - minimum: 0 - type: number - chunkedExtractionEnabled: - type: boolean - compositeGroupingEnabled: - type: boolean - compositeMinClusterSize: - exclusiveMinimum: 0 - type: integer - consensusExtractionEnabled: - type: boolean - consensusExtractionRuns: - exclusiveMinimum: 0 - type: integer - consensusMinMemories: - exclusiveMinimum: 0 - type: integer - consensusValidationEnabled: - type: boolean - crossEncoderEnabled: - type: boolean - entityGraphEnabled: - type: boolean - entitySearchMinSimilarity: - maximum: 1 - minimum: 0 - type: number - entropyGateAlpha: - type: number - entropyGateEnabled: - type: boolean - entropyGateThreshold: - type: number - fastAudnDuplicateThreshold: - maximum: 1 - minimum: 0 - type: number - fastAudnEnabled: - type: boolean - hybridSearchEnabled: - type: boolean - iterativeRetrievalEnabled: - type: boolean - lessonsEnabled: - type: boolean - linkExpansionBeforeMMR: - type: boolean - linkExpansionEnabled: - type: boolean - linkExpansionMax: - minimum: 0 - type: integer - linkSimilarityThreshold: - maximum: 1 - minimum: 0 - type: number - maxSearchResults: - exclusiveMinimum: 0 - type: integer - mmrEnabled: - type: boolean - mmrLambda: - maximum: 1 - minimum: 0 - type: number - namespaceClassificationEnabled: - type: boolean - pprDamping: - maximum: 1 - minimum: 0 - type: number - pprEnabled: - type: boolean - queryAugmentationEnabled: - type: boolean - queryAugmentationMaxEntities: - minimum: 0 - type: integer - queryAugmentationMinSimilarity: - maximum: 1 - minimum: 0 - type: number - queryExpansionEnabled: - type: boolean - queryExpansionMinSimilarity: - maximum: 1 - minimum: 0 - type: number - repairConfidenceFloor: - maximum: 1 - minimum: 0 - type: number - repairDeltaThreshold: - type: number - repairLoopEnabled: - type: boolean - repairLoopMinSimilarity: - maximum: 1 - minimum: 0 - type: number - rerankSkipMinGap: - maximum: 1 - minimum: 0 - type: number - rerankSkipTopSimilarity: - maximum: 1 - minimum: 0 - type: number - trustScoreMinThreshold: - maximum: 1 - minimum: 0 - type: number - trustScoringEnabled: - type: boolean + 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 diff --git a/src/__tests__/memory-route-config-override.test.ts b/src/__tests__/memory-route-config-override.test.ts index 9b097ae..d9df579 100644 --- a/src/__tests__/memory-route-config-override.test.ts +++ b/src/__tests__/memory-route-config-override.test.ts @@ -10,8 +10,11 @@ * (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 surface as 400 via Zod `.strict()` and do - * NOT reach the service layer. + * 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'; @@ -166,16 +169,49 @@ describe('POST /memories/* — per-request config_override', () => { expect(effectiveConfig.fastAudnEnabled).toBe(true); }); - it('unknown override key → 400 and service is not invoked', async () => { + 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 }, + config_override: { bogusFlag: true, alsoBogus: 'nope' }, }), }); - expect(res.status).toBe(400); - expect(scopedSearch).not.toHaveBeenCalled(); + 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('empty override object → treated as no override (no headers)', async () => { diff --git a/src/routes/memories.ts b/src/routes/memories.ts index 20a70f7..450e8b7 100644 --- a/src/routes/memories.ts +++ b/src/routes/memories.ts @@ -607,14 +607,23 @@ function toMemoryScope( /** * Overlay a validated body-level config_override onto the startup - * singleton and emit the three observability response headers. Returns - * the EffectiveConfig to hand to MemoryService (or undefined when no + * 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, @@ -625,6 +634,18 @@ function applyRequestConfigOverride( 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; } diff --git a/src/schemas/__tests__/config-override.test.ts b/src/schemas/__tests__/config-override.test.ts index 0664e9e..bac2696 100644 --- a/src/schemas/__tests__/config-override.test.ts +++ b/src/schemas/__tests__/config-override.test.ts @@ -2,16 +2,20 @@ * @file Wire-contract tests for the per-request `config_override` field * threaded onto `IngestBodySchema` and `SearchBodySchema`. * - * Locks in the two invariants that matter for API callers: - * 1. Unknown keys reject with 400 (Zod `.strict()` → "Unrecognized key(s)") - * so client-side typos surface immediately instead of silently - * no-op'ing downstream. - * 2. Every listed key is optional — callers may send any subset, - * including the empty object, and parsing succeeds. + * 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. * - * The handler-side behavior (three `X-Atomicmem-*` response headers, - * scope-of-request propagation) is covered in the route integration - * tests; this file is schema-only. + * 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'; @@ -20,13 +24,13 @@ import { IngestBodySchema, SearchBodySchema, ConfigOverrideSchema } from '../mem const INGEST_BASE = { user_id: 'u', conversation: 'hi', source_site: 's' }; const SEARCH_BASE = { user_id: 'u', query: 'q' }; -describe('ConfigOverrideSchema — strictness', () => { +describe('ConfigOverrideSchema — permissive shape', () => { it('accepts an empty object', () => { const r = ConfigOverrideSchema.safeParse({}); expect(r.success).toBe(true); }); - it('accepts a subset of known fields', () => { + it('accepts a subset of known RuntimeConfig keys', () => { const r = ConfigOverrideSchema.safeParse({ hybridSearchEnabled: true, mmrLambda: 0.8, @@ -39,17 +43,37 @@ describe('ConfigOverrideSchema — strictness', () => { } }); - it('rejects unknown keys with a Zod .strict() error', () => { - const r = ConfigOverrideSchema.safeParse({ bogusFlag: true }); + 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); - if (!r.success) { - const msg = r.error.issues[0]?.message ?? ''; - expect(msg.toLowerCase()).toMatch(/unrecognized/); - } }); - it('rejects snake_case typos of otherwise-valid fields', () => { - const r = ConfigOverrideSchema.safeParse({ hybrid_search_enabled: true }); + it('rejects array values (overlay is flat)', () => { + const r = ConfigOverrideSchema.safeParse({ + list: [1, 2, 3], + }); expect(r.success).toBe(false); }); }); @@ -73,12 +97,13 @@ describe('IngestBodySchema — config_override threading', () => { } }); - it('rejects unknown keys inside config_override', () => { + it('carries unknown keys through the schema layer', () => { const r = IngestBodySchema.safeParse({ ...INGEST_BASE, - config_override: { bogus: 1 }, + config_override: { futureFlag: true }, }); - expect(r.success).toBe(false); + expect(r.success).toBe(true); + if (r.success) expect(r.data.configOverride?.futureFlag).toBe(true); }); }); @@ -101,11 +126,12 @@ describe('SearchBodySchema — config_override threading', () => { } }); - it('rejects unknown keys inside config_override', () => { + it('carries unknown keys through the schema layer', () => { const r = SearchBodySchema.safeParse({ ...SEARCH_BASE, config_override: { totallyMadeUp: 7 }, }); - expect(r.success).toBe(false); + 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 c8fdaec..f382bb1 100644 --- a/src/schemas/memories.ts +++ b/src/schemas/memories.ts @@ -184,85 +184,45 @@ const RetrievalModeField = z // --------------------------------------------------------------------------- /** - * Flat, partial override on the startup RuntimeConfig. Keys match - * `RuntimeConfig` field names one-for-one; the handler applies the - * validated object as a shallow merge (`{ ...startup, ...override }`) - * onto the effective request-scope config. + * Per-request overlay on the startup RuntimeConfig. Applied as a shallow + * merge (`{ ...startup, ...override }`) onto the effective request-scope + * config. * - * - `.strict()` rejects unknown keys with a 400 so typos don't silently - * no-op. - * - `.partial()` keeps every field optional — absent fields fall through - * to the startup default. + * **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. * - * The grouping below (retrieval-side vs ingest-side) is documentation - * for human readers only; the overlay merges flat regardless of group. + * **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. * - * Coverage is intentionally limited to fields that are threaded through - * `MemoryServiceDeps.config` today (i.e. `CoreRuntimeConfig` + - * `IngestRuntimeConfig`). Fields read from the module-level config - * singleton by leaf modules (e.g. scoring weights in `db/query-helpers.ts`, - * `retrievalProfileSettings.*` nested values like `rerankDepth`) are - * deliberately omitted — including them would accept overrides that - * silently no-op. Future landings lift those fields onto top-level - * `RuntimeConfig` as they become overlay-eligible. + * **`config_override` absent →** zero-cost path, no headers emitted, + * startup config used as-is. */ export const ConfigOverrideSchema = z - .object({ - // Retrieval-side (threaded via MemoryServiceDeps.config → search-pipeline) - adaptiveRetrievalEnabled: z.boolean().optional(), - agenticRetrievalEnabled: z.boolean().optional(), - auditLoggingEnabled: z.boolean().optional(), - consensusMinMemories: z.number().int().positive().optional(), - consensusValidationEnabled: z.boolean().optional(), - crossEncoderEnabled: z.boolean().optional(), - entityGraphEnabled: z.boolean().optional(), - entitySearchMinSimilarity: z.number().min(0).max(1).optional(), - hybridSearchEnabled: z.boolean().optional(), - iterativeRetrievalEnabled: z.boolean().optional(), - lessonsEnabled: z.boolean().optional(), - linkExpansionBeforeMMR: z.boolean().optional(), - linkExpansionEnabled: z.boolean().optional(), - linkExpansionMax: z.number().int().nonnegative().optional(), - linkSimilarityThreshold: z.number().min(0).max(1).optional(), - maxSearchResults: z.number().int().positive().optional(), - mmrEnabled: z.boolean().optional(), - mmrLambda: z.number().min(0).max(1).optional(), - namespaceClassificationEnabled: z.boolean().optional(), - pprDamping: z.number().min(0).max(1).optional(), - pprEnabled: z.boolean().optional(), - queryAugmentationEnabled: z.boolean().optional(), - queryAugmentationMaxEntities: z.number().int().nonnegative().optional(), - queryAugmentationMinSimilarity: z.number().min(0).max(1).optional(), - queryExpansionEnabled: z.boolean().optional(), - queryExpansionMinSimilarity: z.number().min(0).max(1).optional(), - repairConfidenceFloor: z.number().min(0).max(1).optional(), - repairDeltaThreshold: z.number().optional(), - repairLoopEnabled: z.boolean().optional(), - repairLoopMinSimilarity: z.number().min(0).max(1).optional(), - rerankSkipMinGap: z.number().min(0).max(1).optional(), - rerankSkipTopSimilarity: z.number().min(0).max(1).optional(), - - // Ingest-side (threaded via MemoryServiceDeps.config → ingest-fact-pipeline) - audnCandidateThreshold: z.number().min(0).max(1).optional(), - chunkedExtractionEnabled: z.boolean().optional(), - compositeGroupingEnabled: z.boolean().optional(), - compositeMinClusterSize: z.number().int().positive().optional(), - consensusExtractionEnabled: z.boolean().optional(), - consensusExtractionRuns: z.number().int().positive().optional(), - entropyGateAlpha: z.number().optional(), - entropyGateEnabled: z.boolean().optional(), - entropyGateThreshold: z.number().optional(), - fastAudnDuplicateThreshold: z.number().min(0).max(1).optional(), - fastAudnEnabled: z.boolean().optional(), - trustScoreMinThreshold: z.number().min(0).max(1).optional(), - trustScoringEnabled: z.boolean().optional(), - }) - .strict() + .record( + z.string(), + z.union([z.boolean(), z.number(), z.string(), z.null()]), + ) .openapi({ description: - 'Optional per-request overlay on RuntimeConfig. Unknown keys reject with 400; absent keys fall through to the startup config. Scope: just this request — no server mutation.', + '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; // --------------------------------------------------------------------------- From c33cba36fdd12eee654097ba9921c53b8770c416 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 23 Apr 2026 02:22:55 -0700 Subject: [PATCH 3/3] fix(routes): clamp search limit against effective (post-override) maxSearchResults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two search routes (/search, /search/fast) called resolveSearchPreamble() before applyRequestConfigOverride(). resolveSearchPreamble() clamped body.limit against configRouteAdapter.current().maxSearchResults — i.e. the startup snapshot, computed BEFORE the per-request override merged in. A request that raised maxSearchResults in its config_override would have its effective-config-hash header advertise the new cap while the service actually received a limit clamped to the startup cap. Reorder: merge the override first, derive the effective maxSearchResults (override ?? startup), then compute the clamped request limit from that. Regression test added: override bumps max 20 → 50, request asks for 40, service must receive 40, not 20. Reported by Codex review. --- .../memory-route-config-override.test.ts | 16 +++++++++++++ src/routes/memories.ts | 24 ++++++++++++------- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/__tests__/memory-route-config-override.test.ts b/src/__tests__/memory-route-config-override.test.ts index d9df579..2fe672e 100644 --- a/src/__tests__/memory-route-config-override.test.ts +++ b/src/__tests__/memory-route-config-override.test.ts @@ -214,6 +214,22 @@ describe('POST /memories/* — per-request config_override', () => { 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' }, diff --git a/src/routes/memories.ts b/src/routes/memories.ts index 450e8b7..2fefb23 100644 --- a/src/routes/memories.ts +++ b/src/routes/memories.ts @@ -182,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 }; } @@ -198,8 +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, @@ -232,8 +240,9 @@ 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, @@ -589,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(