---
id: 12117
title: 'loadLmsModel: thread --context-length from localModels.chat.contextLimitTokens (close L4 silent-context-mismatch gap exposed by post-merge Sandman validation)'
state: CLOSED
labels:
  - enhancement
  - ai
  - architecture
  - model-experience
assignees:
  - neo-opus-ada
createdAt: '2026-05-27T22:51:07Z'
updatedAt: '2026-05-27T23:59:55Z'
githubUrl: 'https://github.com/neomjs/neo/issues/12117'
author: neo-opus-ada
commentsCount: 0
parentIssue: 12101
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-05-27T23:59:55Z'
---
# loadLmsModel: thread --context-length from localModels.chat.contextLimitTokens (close L4 silent-context-mismatch gap exposed by post-merge Sandman validation)

## Premise

PR #12112 landed proactive `lms load <model>` invocation on orchestrator-managed lms task spawn via `ai/services/graph/ProviderReadinessHelper.mjs:loadLmsModel`. The helper invokes `execFile('lms', ['load', model], ...)` WITHOUT passing `--context-length`. This is the L4 validation gap surfaced in PR #12112's `## Post-Merge Validation` and during the actual post-merge Sandman run (2026-05-27):

**Empirical anchor** (post-PR-#12115-merge orchestrator restart + ai:run-sandman):
- LM Studio with `gemma-4-31b-it` loaded via `lms load` (default modelfile context ~4K-8K)
- Sandman REM cycle: all 10 sessions returned silent empty-response → PR #12113 detection correctly fired `context-overflow` friction for all 10 sessions
- Direct V-B-A: `curl -sN /v1/chat/completions` with tiny prompts to the same LM Studio endpoint returns content cleanly → confirms LM Studio + model alive
- Root cause: loaded-context-cap < composed-prompt-size; even 18K-char (~4.5K-token) sessions overflow the modelfile-default context window

Operator manually ran `lms load gemma-4-31b-it --context-length 262144` to recover — that command is exactly what `loadLmsModel` SHOULD have done automatically per the substrate intent of #12090.

## Substrate shape

The neo-side `aiConfig.localModels.chat.contextLimitTokens` (PR #12115's role-keyed split — default 262144) is the operator's declared chat-context-window threshold. The lms-side loaded-model context-cap MUST be ≥ this value or every chat invocation silently fails the loaded-cap pre-check. These two layers are currently INDEPENDENT.

`loadLmsModel` is the substrate boundary where they SHOULD be tied together: the orchestrator already knows the neo-side threshold (`localModels.chat.contextLimitTokens` flows through `Orchestrator.lmsModels` getter + `TaskDefinitions` postSpawn hook), so it can pass it directly to `lms load --context-length <N>`.

## Prescription

1. **`ProviderReadinessHelper.loadLmsModel`**: accept new `contextLength` param; when present, append `'--context-length', String(contextLength)` to the execFile args.
2. **`ProviderReadinessHelper.ensureLmsModelsLoaded`**: accept `contextLength` param; thread it through to each `loadLmsModel(model, {contextLength})` call.
3. **`TaskDefinitions.mjs` lms task `postSpawn` hook**: pass `contextLength: aiConfig.localModels.chat.contextLimitTokens` (chat-role since LM Studio's loaded gemma is the chat consumer; embedding context-cap for the embedding model would be a parallel threading).
4. **Sibling defense — embedding context**: pass `contextLength: aiConfig.localModels.embedding.contextLimitTokens` for the embedding-model load invocation in the same `ensureLmsModelsLoaded` orchestration (per PR #12112's design which loads BOTH chat + embedding models).

## Contract Ledger

| Surface | Before | After | Consumer |
|---|---|---|---|
| `loadLmsModel(model, options)` | `execFile('lms', ['load', model], ...)` — modelfile-default context (~4K-8K for gemma-4-31b-it) | `execFile('lms', ['load', model, '--context-length', String(contextLength)], ...)` when `contextLength` provided | Orchestrator lms task `postSpawn` hook |
| `ensureLmsModelsLoaded(options)` | No context-length awareness | New `contextLength` param (or per-model object keyed by role) | TaskDefinitions chat + embedding model load invocations |
| Loaded-cap / neo-threshold alignment | Independent — operator must manually align | Orchestrator-owned via threading from `aiConfig.localModels.{chat,embedding}.contextLimitTokens` | Eliminates the L4 silent-context-mismatch failure mode |

## Acceptance Criteria

- [ ] AC1: `loadLmsModel` accepts `contextLength` option; when provided, appends `'--context-length', String(contextLength)` to execFile args; when omitted (legacy callers), behavior unchanged
- [ ] AC2: `ensureLmsModelsLoaded` accepts per-model context-length config (or a single value if treating chat+embedding uniformly); passes to each `loadLmsModel` invocation
- [ ] AC3: `TaskDefinitions.mjs` lms task `postSpawn` hook reads `aiConfig.localModels.chat.contextLimitTokens` for the chat model load and `aiConfig.localModels.embedding.contextLimitTokens` for the embedding model load
- [ ] AC4: Unit tests: `loadLmsModel` with contextLength asserts execFile args contain `'--context-length'` + numeric value; ensureLmsModelsLoaded threading covered via stubbed loadModel injection
- [ ] AC5: Orchestrator restart with `lmsEnabled=true` + neo's chat context-limit 262144 loads gemma chat model with --context-length 262144 → Sandman REM cycle no longer silent-empty on context-mismatch grounds

## Related

- Parent: Epic #12101 (greenfield aiConfig substrate redesign — `localModels` role-keyed split lives here)
- Source: PR #12112 (#12090 lms proactive load — established the postSpawn hook + ensureLmsModelsLoaded substrate; this ticket completes the threading)
- Sibling: #12114 (role-keyed context-limits — established the localModels.{chat,embedding}.contextLimitTokens source-of-truth this ticket threads from)
- Sibling: #12116 (ConsumerFrictionHelper loud-fail — same defense-in-depth class for the consumer-side telemetry)
- Empirical anchor: post-merge 2026-05-27 Sandman validation surfaced 10 silent-empty sessions before operator manually `lms load --context-length 262144` recovered

## Discipline anchor

PR #12112 review cycle-1 flagged the loadLmsModel no-timeout concern as a depth-floor challenge ("Not a merge-blocker — the failure mode is 'boot stalls visibly' rather than 'boots into wrong state'"). The no-context-length is the same class of L4 gap, just with a DIFFERENT failure mode — "boots into context-mismatched state with silent downstream failure" rather than "boots into wrong state". Both are post-merge-validation-grade gaps that the operator surfaced via dogfooding. Closing this ticket completes the L4 lms-proactive-load substrate intent of #12090.

## Timeline

- 2026-05-27T22:51:08Z @neo-opus-ada added the `enhancement` label
- 2026-05-27T22:51:09Z @neo-opus-ada added the `ai` label
- 2026-05-27T22:51:09Z @neo-opus-ada added the `architecture` label
- 2026-05-27T22:51:09Z @neo-opus-ada added the `model-experience` label
- 2026-05-27T22:51:15Z @neo-opus-ada added parent issue #12101
- 2026-05-27T23:01:22Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-05-27T23:05:56Z @neo-gpt cross-referenced by PR #12118
- 2026-05-27T23:07:12Z @neo-opus-ada cross-referenced by PR #12119
- 2026-05-27T23:18:21Z @neo-opus-ada referenced in commit `9e2e2a1` - "refactor(ai): move lmsContextLengths map-builder from Orchestrator to ProviderReadinessHelper (#12117)

Cycle-1 challenge from @tobiu on PR #12119: lmsContextLengths getter in
Orchestrator.mjs accreted LM-Studio domain logic (8 lines composition +
defensive Number.isFinite checks) that doesn't match the other lms getters
(1-liner AiConfig reads). Per `feedback_config_service_boundary`, the
map-builder belongs in the same module as its consumer (ensureLmsModelsLoaded).

Refactored:
- New `buildLmsContextLengthsMap({chatModel, embeddingModel, chatContextLength,
  embeddingContextLength})` exported from ProviderReadinessHelper.mjs — pure
  helper, takes resolved values rather than coupling to AiConfig shape (matches
  the existing module's idiom: fetchOpenAiCompatibleModelIds, loadLmsModel etc.
  also take resolved values).
- Orchestrator.mjs imports + calls inline at the buildTaskDefinitions site;
  removed the lmsContextLengths getter entirely.
- Moved unit test from Orchestrator.invariants.spec.mjs (required AiConfig fixture
  machinery + restore-on-finally) to runSandman.spec.mjs as pure-function
  assertions (no fixture machinery). Net 3 tests covering happy path, empty
  map on missing/non-finite inputs, partial map on single-role config.

Net: 73 tests pass (19 runSandman + 20 invariants + 34 Orchestrator)."
- 2026-05-27T23:24:28Z @neo-opus-ada referenced in commit `81c35fd` - "fix(ai): force-reload context-configured residents + Math.max on same-model (#12117 cycle-2)

@neo-gpt cycle-1 RA1: ensureLmsModelsLoaded short-circuited when all required
models appeared in /v1/models, skipping lms load even when context-length was
configured. /v1/models reports presence only — does NOT expose loaded context
window. So the exact #12117 regression scenario (model loaded with modelfile-
default ~4K-8K cap) survived orchestrator restart because the early-return
treated id-presence as full readiness.

Fix: force-include context-configured models in the load set regardless of
resident state. Filter requiredModels (preserves declared input order) by
either "in initialMissing" or "has finite contextLengths[model]" predicate.
New test covers the exact regression: fetchModelIds returns ['chat-model'],
contextLengths={'chat-model': 262144} → loadModel IS invoked.

@neo-gpt cycle-1 RA2: buildLmsContextLengthsMap let second assignment
overwrite first when chatModel === embeddingModel. The smaller embedding cap
(typically 8K) would overwrite the larger chat cap (262K), silently capping
chat invocations.

Fix: shared setMax(modelId, value) helper preserves the larger cap regardless
of declaration order. New test covers both orderings + idempotent same-value.

25/25 runSandman + 20/20 Orchestrator.invariants pass; no regression on
existing Orchestrator/daemon specs."
- 2026-05-27T23:27:56Z @neo-opus-ada referenced in commit `f354cc8` - "style(ai): use Neo.isNumber instead of inline typeof+isFinite checks (#12117)

Operator-direction: Neo.isNumber is the codified primitive for finite-number
predicate (typeof === 'number' && isFinite(value)). Per
feedback_check_neo_primitives_before_authoring_utils — grep src/Neo.mjs first
before authoring custom utilities. 4 sites in ProviderReadinessHelper.mjs
collapsed to the named primitive:

- loadLmsModel context-length args guard
- buildLmsContextLengthsMap setMax helper guard
- ensureLmsModelsLoaded context-configured filter predicate
- ensureLmsModelsLoaded log-message context-suffix guard

Behavior identical (Neo.isNumber === typeof number + isFinite). Reads cleaner.
25/25 runSandman tests still pass."
- 2026-05-27T23:47:21Z @neo-opus-ada referenced in commit `3b236d3` - "feat(ai): thread --context-length through loadLmsModel from localModels role-keyed limits (#12117)

PR #12112 landed proactive lms load <model> on orchestrator-managed lms task
spawn, but loadLmsModel invokes execFile without --context-length. LM Studio
falls back to the modelfile-default context window (typically 4K-8K for
gemma-4-31b-it), which silently overflows on any meaningful chat prompt and
produces empty downstream body. PR #12113's detection now classifies this
loudly as context-overflow friction, but the upstream substrate gap remained.

This closes the gap by threading per-model context-length from
aiConfig.localModels.{chat,embedding}.contextLimitTokens (PR #12115's
role-keyed split) through the Orchestrator.lmsContextLengths getter into
TaskDefinitions.lms.postSpawn into ensureLmsModelsLoaded into loadLmsModel's
execFile args. The orchestrator now loads chat + embedding models with the
operator-declared context window so the loaded cap matches the neo-side
consumer-friction threshold.

Backward compat: contextLengths optional throughout; loadLmsModel omits
--context-length when contextLength is absent / non-finite; existing callers
unaffected.

Empirical anchor: 2026-05-27 post-PR-#12115-merge orchestrator restart +
ai:run-sandman → 10 sessions silent-empty across both extractor paths;
operator manually ran lms load gemma-4-31b-it --context-length 262144 to
recover. This PR makes that recovery step automatic."
- 2026-05-27T23:47:21Z @neo-opus-ada referenced in commit `06b248d` - "refactor(ai): move lmsContextLengths map-builder from Orchestrator to ProviderReadinessHelper (#12117)

Cycle-1 challenge from @tobiu on PR #12119: lmsContextLengths getter in
Orchestrator.mjs accreted LM-Studio domain logic (8 lines composition +
defensive Number.isFinite checks) that doesn't match the other lms getters
(1-liner AiConfig reads). Per `feedback_config_service_boundary`, the
map-builder belongs in the same module as its consumer (ensureLmsModelsLoaded).

Refactored:
- New `buildLmsContextLengthsMap({chatModel, embeddingModel, chatContextLength,
  embeddingContextLength})` exported from ProviderReadinessHelper.mjs — pure
  helper, takes resolved values rather than coupling to AiConfig shape (matches
  the existing module's idiom: fetchOpenAiCompatibleModelIds, loadLmsModel etc.
  also take resolved values).
- Orchestrator.mjs imports + calls inline at the buildTaskDefinitions site;
  removed the lmsContextLengths getter entirely.
- Moved unit test from Orchestrator.invariants.spec.mjs (required AiConfig fixture
  machinery + restore-on-finally) to runSandman.spec.mjs as pure-function
  assertions (no fixture machinery). Net 3 tests covering happy path, empty
  map on missing/non-finite inputs, partial map on single-role config.

Net: 73 tests pass (19 runSandman + 20 invariants + 34 Orchestrator)."
- 2026-05-27T23:47:21Z @neo-opus-ada referenced in commit `9e1ef4d` - "fix(ai): force-reload context-configured residents + Math.max on same-model (#12117 cycle-2)

@neo-gpt cycle-1 RA1: ensureLmsModelsLoaded short-circuited when all required
models appeared in /v1/models, skipping lms load even when context-length was
configured. /v1/models reports presence only — does NOT expose loaded context
window. So the exact #12117 regression scenario (model loaded with modelfile-
default ~4K-8K cap) survived orchestrator restart because the early-return
treated id-presence as full readiness.

Fix: force-include context-configured models in the load set regardless of
resident state. Filter requiredModels (preserves declared input order) by
either "in initialMissing" or "has finite contextLengths[model]" predicate.
New test covers the exact regression: fetchModelIds returns ['chat-model'],
contextLengths={'chat-model': 262144} → loadModel IS invoked.

@neo-gpt cycle-1 RA2: buildLmsContextLengthsMap let second assignment
overwrite first when chatModel === embeddingModel. The smaller embedding cap
(typically 8K) would overwrite the larger chat cap (262K), silently capping
chat invocations.

Fix: shared setMax(modelId, value) helper preserves the larger cap regardless
of declaration order. New test covers both orderings + idempotent same-value.

25/25 runSandman + 20/20 Orchestrator.invariants pass; no regression on
existing Orchestrator/daemon specs."
- 2026-05-27T23:47:22Z @neo-opus-ada referenced in commit `4a0ea3e` - "style(ai): use Neo.isNumber instead of inline typeof+isFinite checks (#12117)

Operator-direction: Neo.isNumber is the codified primitive for finite-number
predicate (typeof === 'number' && isFinite(value)). Per
feedback_check_neo_primitives_before_authoring_utils — grep src/Neo.mjs first
before authoring custom utilities. 4 sites in ProviderReadinessHelper.mjs
collapsed to the named primitive:

- loadLmsModel context-length args guard
- buildLmsContextLengthsMap setMax helper guard
- ensureLmsModelsLoaded context-configured filter predicate
- ensureLmsModelsLoaded log-message context-suffix guard

Behavior identical (Neo.isNumber === typeof number + isFinite). Reads cleaner.
25/25 runSandman tests still pass."
- 2026-05-27T23:53:17Z @neo-opus-ada referenced in commit `b672c51` - "fix(ai): remove import Neo from consumed-class helper (#12117 cycle-4)

Operator V-B-A escalation: import Neo violates the entry-point-only invariant
codified in ai/daemons/orchestrator/Orchestrator.mjs lines 1-5 — Neo +
core/_export + InstanceManager bootstrap belongs to the daemon entry point
(e.g. ai/daemons/orchestrator/daemon.mjs), NOT to consumed-class files. Class
files rely on globalThis.Neo populated by the entry-point bootstrap; importing
Neo here risks partial-namespace damage if the class were ever loaded outside
its entry-point's chain.

The import was introduced by PR #12118 (commit 4e2646c1dd) as part of GPT's
Neo.isNumber polish + transited into this PR via rebase. Reverting in this
PR removes the violation from dev once this merges. Neo.isNumber() calls
continue to work via globalThis (31/31 tests pass)."
- 2026-05-27T23:59:55Z @tobiu referenced in commit `6906a97` - "feat(ai): thread --context-length through loadLmsModel from localModels role-keyed limits (#12117) (#12119)

* feat(ai): thread --context-length through loadLmsModel from localModels role-keyed limits (#12117)

PR #12112 landed proactive lms load <model> on orchestrator-managed lms task
spawn, but loadLmsModel invokes execFile without --context-length. LM Studio
falls back to the modelfile-default context window (typically 4K-8K for
gemma-4-31b-it), which silently overflows on any meaningful chat prompt and
produces empty downstream body. PR #12113's detection now classifies this
loudly as context-overflow friction, but the upstream substrate gap remained.

This closes the gap by threading per-model context-length from
aiConfig.localModels.{chat,embedding}.contextLimitTokens (PR #12115's
role-keyed split) through the Orchestrator.lmsContextLengths getter into
TaskDefinitions.lms.postSpawn into ensureLmsModelsLoaded into loadLmsModel's
execFile args. The orchestrator now loads chat + embedding models with the
operator-declared context window so the loaded cap matches the neo-side
consumer-friction threshold.

Backward compat: contextLengths optional throughout; loadLmsModel omits
--context-length when contextLength is absent / non-finite; existing callers
unaffected.

Empirical anchor: 2026-05-27 post-PR-#12115-merge orchestrator restart +
ai:run-sandman → 10 sessions silent-empty across both extractor paths;
operator manually ran lms load gemma-4-31b-it --context-length 262144 to
recover. This PR makes that recovery step automatic.

* refactor(ai): move lmsContextLengths map-builder from Orchestrator to ProviderReadinessHelper (#12117)

Cycle-1 challenge from @tobiu on PR #12119: lmsContextLengths getter in
Orchestrator.mjs accreted LM-Studio domain logic (8 lines composition +
defensive Number.isFinite checks) that doesn't match the other lms getters
(1-liner AiConfig reads). Per `feedback_config_service_boundary`, the
map-builder belongs in the same module as its consumer (ensureLmsModelsLoaded).

Refactored:
- New `buildLmsContextLengthsMap({chatModel, embeddingModel, chatContextLength,
  embeddingContextLength})` exported from ProviderReadinessHelper.mjs — pure
  helper, takes resolved values rather than coupling to AiConfig shape (matches
  the existing module's idiom: fetchOpenAiCompatibleModelIds, loadLmsModel etc.
  also take resolved values).
- Orchestrator.mjs imports + calls inline at the buildTaskDefinitions site;
  removed the lmsContextLengths getter entirely.
- Moved unit test from Orchestrator.invariants.spec.mjs (required AiConfig fixture
  machinery + restore-on-finally) to runSandman.spec.mjs as pure-function
  assertions (no fixture machinery). Net 3 tests covering happy path, empty
  map on missing/non-finite inputs, partial map on single-role config.

Net: 73 tests pass (19 runSandman + 20 invariants + 34 Orchestrator).

* fix(ai): force-reload context-configured residents + Math.max on same-model (#12117 cycle-2)

@neo-gpt cycle-1 RA1: ensureLmsModelsLoaded short-circuited when all required
models appeared in /v1/models, skipping lms load even when context-length was
configured. /v1/models reports presence only — does NOT expose loaded context
window. So the exact #12117 regression scenario (model loaded with modelfile-
default ~4K-8K cap) survived orchestrator restart because the early-return
treated id-presence as full readiness.

Fix: force-include context-configured models in the load set regardless of
resident state. Filter requiredModels (preserves declared input order) by
either "in initialMissing" or "has finite contextLengths[model]" predicate.
New test covers the exact regression: fetchModelIds returns ['chat-model'],
contextLengths={'chat-model': 262144} → loadModel IS invoked.

@neo-gpt cycle-1 RA2: buildLmsContextLengthsMap let second assignment
overwrite first when chatModel === embeddingModel. The smaller embedding cap
(typically 8K) would overwrite the larger chat cap (262K), silently capping
chat invocations.

Fix: shared setMax(modelId, value) helper preserves the larger cap regardless
of declaration order. New test covers both orderings + idempotent same-value.

25/25 runSandman + 20/20 Orchestrator.invariants pass; no regression on
existing Orchestrator/daemon specs.

* style(ai): use Neo.isNumber instead of inline typeof+isFinite checks (#12117)

Operator-direction: Neo.isNumber is the codified primitive for finite-number
predicate (typeof === 'number' && isFinite(value)). Per
feedback_check_neo_primitives_before_authoring_utils — grep src/Neo.mjs first
before authoring custom utilities. 4 sites in ProviderReadinessHelper.mjs
collapsed to the named primitive:

- loadLmsModel context-length args guard
- buildLmsContextLengthsMap setMax helper guard
- ensureLmsModelsLoaded context-configured filter predicate
- ensureLmsModelsLoaded log-message context-suffix guard

Behavior identical (Neo.isNumber === typeof number + isFinite). Reads cleaner.
25/25 runSandman tests still pass.

* fix(ai): remove import Neo from consumed-class helper (#12117 cycle-4)

Operator V-B-A escalation: import Neo violates the entry-point-only invariant
codified in ai/daemons/orchestrator/Orchestrator.mjs lines 1-5 — Neo +
core/_export + InstanceManager bootstrap belongs to the daemon entry point
(e.g. ai/daemons/orchestrator/daemon.mjs), NOT to consumed-class files. Class
files rely on globalThis.Neo populated by the entry-point bootstrap; importing
Neo here risks partial-namespace damage if the class were ever loaded outside
its entry-point's chain.

The import was introduced by PR #12118 (commit 4e2646c1dd) as part of GPT's
Neo.isNumber polish + transited into this PR via rebase. Reverting in this
PR removes the violation from dev once this merges. Neo.isNumber() calls
continue to work via globalThis (31/31 tests pass).

---------

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-27T23:59:56Z @tobiu closed this issue
- 2026-05-29T01:44:54Z @neo-opus-ada cross-referenced by #12101

