From 70646de30588f713b784d8009998a3b2fa4a1c69 Mon Sep 17 00:00:00 2001 From: Paul Robertson Date: Fri, 14 Aug 2026 01:21:04 +0000 Subject: [PATCH 1/4] fix(plugin): declare llm.maxTokens and llm.headers as first-class config keys llm.maxTokens and llm.headers are read at runtime (client.ts reads config.maxTokens, providers spread config.headers) but were absent from DEFAULT_CONFIG and LlmSchema/SkillEvolverSchema, so every boot logged "unknown config key 'llm.maxTokens'" and "unknown config key 'llm.headers.'" (pruneUnknown recursed into the empty headers slot and warned per user key). Add both to defaults + schema, and teach pruneUnknown that an empty-object default slot is a free-form map that must be kept as-is, eliminating the per-key warnings. Adds a regression test covering acceptance, defaults, range validation and the free-form-map warning suppression. --- .../core/config/defaults.ts | 3 + apps/memos-local-plugin/core/config/index.ts | 7 +++ apps/memos-local-plugin/core/config/schema.ts | 6 ++ .../config/llm-max-tokens-headers.test.ts | 62 +++++++++++++++++++ 4 files changed, 78 insertions(+) create mode 100644 apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts diff --git a/apps/memos-local-plugin/core/config/defaults.ts b/apps/memos-local-plugin/core/config/defaults.ts index 6f06210d0..a52678453 100644 --- a/apps/memos-local-plugin/core/config/defaults.ts +++ b/apps/memos-local-plugin/core/config/defaults.ts @@ -57,6 +57,8 @@ export const DEFAULT_CONFIG: ResolvedConfig = { providerIgnore: [], providerOrder: [], openRouter: false, + maxTokens: 1024, + headers: {}, }, l3Llm: { // Empty by default — falls back to the shared `llm` settings. @@ -87,6 +89,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { providerIgnore: [], providerOrder: [], openRouter: false, + maxTokens: 1024, }, storage: { ftsTokenizer: "trigram", diff --git a/apps/memos-local-plugin/core/config/index.ts b/apps/memos-local-plugin/core/config/index.ts index 6d529d960..64370f5ad 100644 --- a/apps/memos-local-plugin/core/config/index.ts +++ b/apps/memos-local-plugin/core/config/index.ts @@ -165,6 +165,13 @@ function pruneUnknown( continue; } if (isPlainObject(v) && isPlainObject((defaults as Record)[k])) { + if (Object.keys((defaults as Record)[k] as Record).length === 0) { + // Empty-object default slot = free-form map (e.g. llm.headers, a + // Record). Keep the whole user object as-is; recursing + // would warn on every user key. + out[k] = v; + continue; + } out[k] = pruneUnknown(v, (defaults as Record)[k], path, warnings); } else { out[k] = v; diff --git a/apps/memos-local-plugin/core/config/schema.ts b/apps/memos-local-plugin/core/config/schema.ts index 8566f90f3..5fdadc740 100644 --- a/apps/memos-local-plugin/core/config/schema.ts +++ b/apps/memos-local-plugin/core/config/schema.ts @@ -99,6 +99,10 @@ const LlmSchema = Type.Object({ openRouter: Type.Optional(Bool(false)), /** Optional reasoning control (see ReasoningSchema). Omit = model default. */ reasoning: Type.Optional(ReasoningSchema), + /** Max output tokens per completion (deepseek-v4-flash needs >= 100). */ + maxTokens: NumberInRange(1024, 16, 131072), + /** Extra HTTP headers for the provider request. */ + headers: Type.Optional(Type.Record(Type.String(), Type.String(), { default: {} })), }, { default: {} }); /** @@ -131,6 +135,8 @@ const SkillEvolverSchema = Type.Object({ openRouter: Type.Optional(Bool(false)), /** Optional reasoning control (see ReasoningSchema). Omit = model default. */ reasoning: Type.Optional(ReasoningSchema), + /** Max output tokens per completion. */ + maxTokens: NumberInRange(1024, 16, 131072), }, { default: {} }); const StorageSchema = Type.Object({ diff --git a/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts b/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts new file mode 100644 index 000000000..daae23de6 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_CONFIG, resolveConfig } from "../../../core/config/index.js"; + +describe("resolveConfig llm.maxTokens + llm.headers", () => { + it("accepts llm.maxTokens and llm.headers without unknown-key warnings", () => { + const warnings: string[] = []; + const cfg = resolveConfig( + { + llm: { + maxTokens: 2048, + headers: { "User-Agent": "hermes-test", "X-Custom": "v1" }, + }, + }, + warnings, + ); + expect(cfg.llm.maxTokens).toBe(2048); + expect(cfg.llm.headers).toEqual({ "User-Agent": "hermes-test", "X-Custom": "v1" }); + // The free-form-map special case must not warn per header key. + expect(warnings).toEqual([]); + }); + + it("declares llm.maxTokens with a sane default of 1024", () => { + expect(DEFAULT_CONFIG.llm.maxTokens).toBe(1024); + const cfg = resolveConfig({}); + expect(cfg.llm.maxTokens).toBe(1024); + }); + + it("declares llm.headers defaulting to an empty map", () => { + expect(DEFAULT_CONFIG.llm.headers).toEqual({}); + const cfg = resolveConfig({}); + expect(cfg.llm.headers).toEqual({}); + }); + + it("declares skillEvolver.maxTokens (default 1024) for the crystallizer LLM slot", () => { + expect(DEFAULT_CONFIG.skillEvolver.maxTokens).toBe(1024); + const cfg = resolveConfig({ skillEvolver: { maxTokens: 4096 } }); + expect(cfg.skillEvolver.maxTokens).toBe(4096); + }); + + it("rejects out-of-range maxTokens with config_invalid", () => { + expect(() => resolveConfig({ llm: { maxTokens: 8 } })).toThrow(/config failed schema validation/); + }); + + it("rejects non-string header values", () => { + expect(() => resolveConfig({ llm: { headers: { "X-Bad": 42 } } })).toThrow( + /config failed schema validation/, + ); + }); + + it("keeps unrelated llm fields untouched when maxTokens/headers are set", () => { + const cfg = resolveConfig({ + llm: { provider: "openai_compatible", model: "deepseek-v4-flash", maxTokens: 2048 }, + }); + expect(cfg.llm.provider).toBe("openai_compatible"); + expect(cfg.llm.model).toBe("deepseek-v4-flash"); + expect(cfg.llm.temperature).toBe(0); + expect(cfg.llm.fallbackToHost).toBe(true); + expect(cfg.llm.timeoutMs).toBe(45_000); + expect(cfg.llm.maxRetries).toBe(3); + }); +}); From d59fe83afeee1f03f1bb68199c26a975280ed19e Mon Sep 17 00:00:00 2001 From: Paul Robertson Date: Fri, 14 Aug 2026 01:22:12 +0000 Subject: [PATCH 2/4] fix(plugin): add l3Llm.maxTokens default (shares SkillEvolverSchema) --- apps/memos-local-plugin/core/config/defaults.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/memos-local-plugin/core/config/defaults.ts b/apps/memos-local-plugin/core/config/defaults.ts index a52678453..52ba2a430 100644 --- a/apps/memos-local-plugin/core/config/defaults.ts +++ b/apps/memos-local-plugin/core/config/defaults.ts @@ -75,6 +75,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { providerIgnore: [], providerOrder: [], openRouter: false, + maxTokens: 1024, }, skillEvolver: { // Empty by default — falls back to the shared `llm` settings. From 10786401441a8300341aae191c494d7f70460552 Mon Sep 17 00:00:00 2001 From: Paul Robertson Date: Sat, 15 Aug 2026 08:00:24 +1200 Subject: [PATCH 3/4] fix(plugin): wire l3Llm/skillEvolver maxTokens+headers; raise dedicated-slot defaults to 4096 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses OpenCodeReview feedback on #2248: - The l3Llm and skillEvolver client builders constructed their clients with explicit field picks that dropped maxTokens and headers — the config keys declared by the previous commit were inert at runtime (effective cap was the hard-coded DEFAULT_MAX_TOKENS=1024 in client.ts regardless of config). - Add maxTokens+headers to DedicatedLlmConfig and pass both through in the reflectLlm (skillEvolver) and l3Llm builders so configured values actually reach the provider request. - Add headers to SkillEvolverSchema (l3Llm/skillEvolver slots) so custom HTTP headers are accepted on those slots, mirroring the llm slot. - Raise l3Llm/skillEvolver maxTokens defaults from 1024 to 4096: L3 world- model bodies span multiple L2 policies/evidence traces, and crystallized skill bodies include invocation guides + procedure steps — 1024 tokens risks silent truncation on both workloads (both slots already assume 60s timeouts, implying heavier calls). - Update config tests: default assertions now pin 4096, plus new coverage for l3Llm.maxTokens and headers on both dedicated slots. --- .../core/config/defaults.ts | 4 ++-- apps/memos-local-plugin/core/config/schema.ts | 2 ++ .../core/pipeline/memory-core.ts | 8 +++++++ .../config/llm-max-tokens-headers.test.ts | 24 +++++++++++++++---- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/memos-local-plugin/core/config/defaults.ts b/apps/memos-local-plugin/core/config/defaults.ts index 52ba2a430..a043b4cd4 100644 --- a/apps/memos-local-plugin/core/config/defaults.ts +++ b/apps/memos-local-plugin/core/config/defaults.ts @@ -75,7 +75,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { providerIgnore: [], providerOrder: [], openRouter: false, - maxTokens: 1024, + maxTokens: 4096, }, skillEvolver: { // Empty by default — falls back to the shared `llm` settings. @@ -90,7 +90,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { providerIgnore: [], providerOrder: [], openRouter: false, - maxTokens: 1024, + maxTokens: 4096, }, storage: { ftsTokenizer: "trigram", diff --git a/apps/memos-local-plugin/core/config/schema.ts b/apps/memos-local-plugin/core/config/schema.ts index 5fdadc740..ff9c1f319 100644 --- a/apps/memos-local-plugin/core/config/schema.ts +++ b/apps/memos-local-plugin/core/config/schema.ts @@ -137,6 +137,8 @@ const SkillEvolverSchema = Type.Object({ reasoning: Type.Optional(ReasoningSchema), /** Max output tokens per completion. */ maxTokens: NumberInRange(1024, 16, 131072), + /** Extra HTTP headers for the provider request. */ + headers: Type.Optional(Type.Record(Type.String(), Type.String(), { default: {} })), }, { default: {} }); const StorageSchema = Type.Object({ diff --git a/apps/memos-local-plugin/core/pipeline/memory-core.ts b/apps/memos-local-plugin/core/pipeline/memory-core.ts index 01402a86b..76e65d700 100644 --- a/apps/memos-local-plugin/core/pipeline/memory-core.ts +++ b/apps/memos-local-plugin/core/pipeline/memory-core.ts @@ -137,6 +137,10 @@ type DedicatedLlmConfig = { providerOrder?: string[]; openRouter?: boolean; reasoning?: ReasoningConfig; + /** Max output tokens per completion. */ + maxTokens?: number; + /** Extra HTTP headers for the provider request. */ + headers?: Record; }; export interface BootstrapOptions { @@ -437,6 +441,8 @@ export async function bootstrapMemoryCoreFull( providerOrder: evolver?.providerOrder, openRouter: evolver?.openRouter ?? false, reasoning: evolver?.reasoning, + maxTokens: evolver?.maxTokens, + headers: evolver?.headers, maxRetries: 3, // V7 §0.x — when the user's dedicated skill-evolver model is // down (auth, model name typo, server outage), prefer falling @@ -499,6 +505,8 @@ export async function bootstrapMemoryCoreFull( providerOrder: l3c?.providerOrder, openRouter: l3c?.openRouter ?? false, reasoning: l3c?.reasoning, + maxTokens: l3c?.maxTokens, + headers: l3c?.headers, maxRetries: 3, fallbackToHost: true, onError: (d: { provider: string; model: string; message: string; code?: string; at?: number }) => diff --git a/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts b/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts index daae23de6..40e929a78 100644 --- a/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts +++ b/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts @@ -32,10 +32,26 @@ describe("resolveConfig llm.maxTokens + llm.headers", () => { expect(cfg.llm.headers).toEqual({}); }); - it("declares skillEvolver.maxTokens (default 1024) for the crystallizer LLM slot", () => { - expect(DEFAULT_CONFIG.skillEvolver.maxTokens).toBe(1024); - const cfg = resolveConfig({ skillEvolver: { maxTokens: 4096 } }); - expect(cfg.skillEvolver.maxTokens).toBe(4096); + it("declares skillEvolver.maxTokens (default 4096) for the crystallizer LLM slot", () => { + expect(DEFAULT_CONFIG.skillEvolver.maxTokens).toBe(4096); + const cfg = resolveConfig({ skillEvolver: { maxTokens: 8192 } }); + expect(cfg.skillEvolver.maxTokens).toBe(8192); + }); + + it("declares l3Llm.maxTokens (default 4096) sharing the SkillEvolver schema", () => { + expect(DEFAULT_CONFIG.l3Llm.maxTokens).toBe(4096); + const cfg = resolveConfig({ l3Llm: { maxTokens: 8192 } }); + expect(cfg.l3Llm.maxTokens).toBe(8192); + }); + + it("accepts headers on skillEvolver/l3Llm slots (shared SkillEvolverSchema)", () => { + const cfg = resolveConfig({ + skillEvolver: { headers: { "X-Evolver": "v1" } }, + l3Llm: { headers: { "X-L3": "v2" } }, + }); + expect(cfg.skillEvolver.headers).toEqual({ "X-Evolver": "v1" }); + expect(cfg.l3Llm.headers).toEqual({ "X-L3": "v2" }); + expect(cfg.l3Llm.maxTokens).toBe(4096); }); it("rejects out-of-range maxTokens with config_invalid", () => { From 2b7269857ad74f1dce3823b60045a261cd91cca0 Mon Sep 17 00:00:00 2001 From: Paul Robertson Date: Sat, 15 Aug 2026 05:05:16 +0000 Subject: [PATCH 4/4] fix(plugin): default headers {} on l3Llm/skillEvolver slots; maxTokens floor 100 Addresses remaining OpenCodeReview feedback on #2248: headers was declared on SkillEvolverSchema but absent from the l3Llm/skillEvolver defaults, so setting those keys in YAML still warned unknown config key and bypassed the pruneUnknown free-form-map shortcut; maxTokens floor raised 16 to 100 to match the documented deepseek-v4-flash constraint; dedicated-slot headers now asserted warning-free, defaults pinned, out-of-range regression pinned at 50. --- .../core/config/defaults.ts | 2 ++ apps/memos-local-plugin/core/config/schema.ts | 4 ++-- .../config/llm-max-tokens-headers.test.ts | 22 ++++++++++++++----- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/apps/memos-local-plugin/core/config/defaults.ts b/apps/memos-local-plugin/core/config/defaults.ts index a043b4cd4..f30b00cfb 100644 --- a/apps/memos-local-plugin/core/config/defaults.ts +++ b/apps/memos-local-plugin/core/config/defaults.ts @@ -76,6 +76,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { providerOrder: [], openRouter: false, maxTokens: 4096, + headers: {}, }, skillEvolver: { // Empty by default — falls back to the shared `llm` settings. @@ -91,6 +92,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { providerOrder: [], openRouter: false, maxTokens: 4096, + headers: {}, }, storage: { ftsTokenizer: "trigram", diff --git a/apps/memos-local-plugin/core/config/schema.ts b/apps/memos-local-plugin/core/config/schema.ts index ff9c1f319..edbc6b40c 100644 --- a/apps/memos-local-plugin/core/config/schema.ts +++ b/apps/memos-local-plugin/core/config/schema.ts @@ -100,7 +100,7 @@ const LlmSchema = Type.Object({ /** Optional reasoning control (see ReasoningSchema). Omit = model default. */ reasoning: Type.Optional(ReasoningSchema), /** Max output tokens per completion (deepseek-v4-flash needs >= 100). */ - maxTokens: NumberInRange(1024, 16, 131072), + maxTokens: NumberInRange(1024, 100, 131072), /** Extra HTTP headers for the provider request. */ headers: Type.Optional(Type.Record(Type.String(), Type.String(), { default: {} })), }, { default: {} }); @@ -136,7 +136,7 @@ const SkillEvolverSchema = Type.Object({ /** Optional reasoning control (see ReasoningSchema). Omit = model default. */ reasoning: Type.Optional(ReasoningSchema), /** Max output tokens per completion. */ - maxTokens: NumberInRange(1024, 16, 131072), + maxTokens: NumberInRange(1024, 100, 131072), /** Extra HTTP headers for the provider request. */ headers: Type.Optional(Type.Record(Type.String(), Type.String(), { default: {} })), }, { default: {} }); diff --git a/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts b/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts index 40e929a78..19f424856 100644 --- a/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts +++ b/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts @@ -44,18 +44,28 @@ describe("resolveConfig llm.maxTokens + llm.headers", () => { expect(cfg.l3Llm.maxTokens).toBe(8192); }); - it("accepts headers on skillEvolver/l3Llm slots (shared SkillEvolverSchema)", () => { - const cfg = resolveConfig({ - skillEvolver: { headers: { "X-Evolver": "v1" } }, - l3Llm: { headers: { "X-L3": "v2" } }, - }); + it("accepts headers on skillEvolver/l3Llm slots without unknown-key warnings", () => { + const warnings: string[] = []; + const cfg = resolveConfig( + { + skillEvolver: { headers: { "X-Evolver": "v1" } }, + l3Llm: { headers: { "X-L3": "v2" } }, + }, + warnings, + ); expect(cfg.skillEvolver.headers).toEqual({ "X-Evolver": "v1" }); expect(cfg.l3Llm.headers).toEqual({ "X-L3": "v2" }); expect(cfg.l3Llm.maxTokens).toBe(4096); + expect(warnings).toEqual([]); + }); + + it("declares headers defaulting to empty on the dedicated slots", () => { + expect(DEFAULT_CONFIG.skillEvolver.headers).toEqual({}); + expect(DEFAULT_CONFIG.l3Llm.headers).toEqual({}); }); it("rejects out-of-range maxTokens with config_invalid", () => { - expect(() => resolveConfig({ llm: { maxTokens: 8 } })).toThrow(/config failed schema validation/); + expect(() => resolveConfig({ llm: { maxTokens: 50 } })).toThrow(/config failed schema validation/); }); it("rejects non-string header values", () => {