LearnNewsExamplesServices
Frontmatter
id17448
titleThe chat provider's Tier-1 leaf has no readers; its alias has them all
stateClosed
labels
bugaiarchitectureagent-ostech-debt
assigneesneo-opus-vega
createdAtAug 21, 2026, 11:10 AM
updatedAtAug 21, 2026, 9:24 PM
githubUrlhttps://github.com/neomjs/neo/issues/17448
authorneo-opus-vega
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 21, 2026, 9:24 PM

The chat provider's Tier-1 leaf has no readers; its alias has them all

Closed Backlog/active-chunk-18 bugaiarchitectureagent-ostech-debt
neo-opus-vega
neo-opus-vega commented on Aug 21, 2026, 11:10 AM

Context

Surfaced by @tobiu while scoping post-v13.2 refactoring: "agent os has embedding and chat models => gemma4:26b. If you check the chat layers, you will probably say 'doh!', similar to embeddings. Maybe even worse."

It is worse, and in a different way. The embedding lane's problem is diffusion — one authority read by many subsystems, which #17411 is consolidating. The chat lane's problem is that the declared authority has no consumers at all, while the leaf its own docblock calls a legacy alias carries the entire lane.

ADR-0019 read gate satisfied before authoring (§critical_gates #10).

Live latest-open sweep: latest 20 open issues read at 2026-08-21T09:00Z; state:all searches for chatProvider modelProvider, provider routing converges, chat provider axis alias, gemma model id namespace. Nearest prior art is a closed cluster (#12740 / #12742 / #12745, local-first provider defaults) and #12059 (Sandman graph provider routing) — all closed, none covering this. A2A claim sweep over the latest 12 messages, all read-states: no in-flight claim on this scope.

The Problem

Defect 1 — the Tier-1 leaf is dead and the alias is load-bearing

ai/configBase.mjs:705-723 declares two leaves bound to one env var:

/** @summary Deployment-wide chat / generation model provider.
 *  Tier-1 source of truth for model-consuming Agent OS lanes. Memory Core maps
 *  this into its historical `modelProvider` key until runtime provider routing
 *  converges on one canonical key. */
chatProvider : leaf('openAiCompatible', 'NEO_MODEL_PROVIDER', 'string'),

/** @summary Runtime alias for the active chat provider.
 *  Existing Memory Core consumers read `modelProvider`; keep the Tier-1
 *  template aligned with `chatProvider` until provider routing converges on
 *  one canonical key. */
modelProvider: leaf('openAiCompatible', 'NEO_MODEL_PROVIDER', 'string'),

Measured consumers outside configBase.mjs:

leaf declared role readers
chatProvider "Tier-1 source of truth" zero
modelProvider "Runtime alias" provider/buildChatModel.mjs:69,87,107,110,122, Agent.mjs:30,32,158

So the migration was declared and never performed: the canonical leaf was added and documented as Tier-1, and not one consumer moved to it. Today the prose and the code disagree about which key is authoritative, and the prose is the one a reader trusts.

This is an ADR-0019 Group B violation — indirection AROUND the SSOT (even when reading it) — sitting inside the file that defines the SSOT. The ADR's decision sentence is explicit: "Never re-implement, alias, export, pass-along, mutate, or defend against the SSOT." The instruction "keep the Tier-1 template aligned with chatProvider" is manual synchronisation between two leaves, which is the definition of two sources of truth.

The deferral is stated twice, in both docblocks, in the same words — "until provider routing converges on one canonical key." Nothing schedules that convergence.

Defect 2 — model literals live outside AiConfig, one behind a hidden default

Three live sites hardcode a chat model id rather than reading a resolved leaf:

site shape
ai/provider/Ollama.mjs:273 modelName: 'gemma4'
ai/agent/profile/QA.mjs:26 model: 'gemma4'
ai/scripts/runners/roadmapPlanner.mjs:90 Memory_Config?.data?.openAiCompatible?.model || 'gemma4'

CORRECTED 2026-08-21 — I described the wrong failure for all three, and each is a different defect. The original text called them three hardcoded literals with one shared fix. Measured at tree 3809616cdc:

site what actually happens why the original description was wrong
roadmapPlanner.mjs:90 ReferenceError on a live path. Memory_Config has no import and no declaration in the file; optional chaining does not rescue an undeclared identifier (only typeof does). plannerAgent() is called at :149, so this throws in normal use. I wrote that it "silently supplies a model the deployment never chose." It supplies nothing — it crashes. And with the import added, the cascade resolves google/gemma-4-26b-a4b, so || 'gemma4' is latent, never reached in this deployment. I described a hidden default that was doing no work and missed a hard crash.
Ollama.mjs:273 A reachable, divergent class default. Neo.create(Ollama, {}) yields modelName: 'gemma4' (measured) while aiConfig.ollama.model resolves gemma4:26b. Neo.ai.Agent:164 builds its provider as Neo.create(providerClass, this.providerConfig || {}), and Agent.providerConfig is declared null at :37 and set nowhere in the tree — so on that path the class default was the only model supplier, and it named a different tag. Not a "literal to hoist": an adapter that read aiConfig itself would be a fresh Group-B violation. The defect is the divergent default, not the absence of a leaf read.
QA.mjs:26 A dead config that reads as load-bearing. Neo.ai.Agent declares no model config (only modelProvider), and nothing anywhere reads an agent-level .model — verified with a positive control (the same grep shape finds this.modelProvider at Agent.mjs:158). The key pinned nothing. "Hoist it to a resolved leaf" would have preserved a config with no consumer, making a dead key look deliberate.

Origin of the roadmapPlanner bug, for the next reader: its sibling runSandman.mjs:4 — same directory — carries exactly the missing line, import Memory_Config from '../../mcp/server/memory-core/config.mjs'. The usage was copied; the import was not.

What is deliberately NOT a defect

Recorded because both look like defects and repairing either would be wrong.

  • Per-provider model ids legitimately differ. gemma4:26b (Ollama), google/gemma-4-26b-a4b (OpenAI-compatible) and mlx-community/gemma-4-26b-a4b-it-bf16 (MLX) are three correct identifiers for the same weights in three runtimes. Collapsing them to one string would break every provider but one. What is missing is only that nothing declares they denote one model.
  • gemma4:31b is not live. Its single occurrence is a historical comment at ai/provider/OpenAiCompatible.mjs:26 documenting a fixed bug — an Ollama-namespaced id that had been sitting in the OpenAI-compatible slot. That comment is correct practice and must stay.

The Architectural Reality

  • ai/configBase.mjs:714 / :723 — the two leaves on NEO_MODEL_PROVIDER.
  • ai/configBase.mjs:734graphProvider on NEO_GRAPH_PROVIDER. A legitimately separate axis, not part of this defect: its docblock records that graph extraction supports only native Ollama or OpenAI-compatible, while chat/summarisation may use Gemini. Leave it.
  • ai/provider/buildChatModel.mjs — the single chat dispatcher, and the alias's primary consumer. Chat dispatch is not diffuse; only its selector is.
  • ai/Agent.mjs:30-32,158 — reads modelProvider as an instance config defaulting to GeminiProvider, a second declaration of the same choice.
  • learn/agentos/decisions/0019-aiconfig-reactive-provider-ssot.md §3 Group B — the governing catalog entry.

The Fix

  1. Finish the migration or retract it — do not leave both. Either point buildChatModel.mjs and Agent.mjs at chatProvider and delete modelProvider, or delete chatProvider and stop advertising a Tier-1 key that nothing reads. Deleting the unread leaf is the smaller change; moving the readers is the one that matches the documented intent. Pick one and make the docblocks true.
  2. Hoist the three hardcoded 'gemma4' literals to resolved leaf reads, and remove the ?.-cascade plus || 'gemma4' hidden default at roadmapPlanner.mjs:90. An unresolvable model must fail loudly, matching embeddingProvider's parse hook, which "throws a named diagnostic on an unknown name at config resolution, because an unrecognized provider must never boot quietly."
  3. Declare the model-identity mapping once so the three provider-namespaced ids are visibly one model. A comment at the leaf is sufficient; no new indirection.

Contract Ledger Matrix

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
aiConfig.chatProvider configBase.mjs:714 either the sole selector, or removed no dual-leaf state survives ADR-0019, leaf docblock 0 readers measured outside configBase
aiConfig.modelProvider configBase.mjs:723 removed, or becomes the sole selector one leaf on NEO_MODEL_PROVIDER leaf docblock 8 read sites across 2 files
NEO_MODEL_PROVIDER env unchanged; one leaf binds it none bound twice today
aiConfig.graphProvider configBase.mjs:734 unchanged distinct provider set, documented
roadmapPlanner.mjs chat model + host aiConfig.openAiCompatible reads the resolved leaves directly and refuses an incomplete block by name no ?. cascade and no || default — an unnamed endpoint or model must stop the run, not be substituted ADR-0019 §3 Memory_Config had no import: the path threw ReferenceError, and the documented fallback was unreachable
Neo.ai.provider.Ollama#modelName caller-injected defaults no model; a missing id is rejected at request construction with a named diagnostic never a provider-shaped fallback — 'gemma4' resolved a different tag than the configured gemma4:26b class docblock + Base.mjs / OpenAiCompatible.mjs precedent Neo.create(Ollama, {}) measured; the two dispatchers already inject
QA.mjs model key none — it was never read removed; model selection is not declared on the profile no replacement key; Neo.ai.Agent declares no model config profile docblock positive control: the same grep shape finds this.modelProvider at Agent.mjs:158
provider-namespaced model identity openAiCompatible.model leaf comment the three runtime-specific ids are declared once as one model; the strings stay distinct unifying the strings is forbidden — it would break two of three runtimes leaf comment; MLX leaf already carried it gemma4:26b / google/gemma-4-26b-a4b / mlx-community/gemma-4-26b-a4b-it-bf16
roadmapPlanner.mjs B3 grandfather check-aiconfig-antipatterns.mjs ALLOWLIST.B3 entry retired; the guard now flags the cascade if it returns a grandfather whose hit is gone silently re-admits the regression with CI green checker JSDoc + spec measured: 0 new violations without the entry; RED control fails the build when the cascade is restored

Ledger split 2026-08-21 (@neo-gpt, PR #17465 RA-2). The single "chat model literal" row said all three sites do a "resolved leaf read". That is true of exactly one of them, and this ticket's own Defect-2 section had already been corrected to say so — the narrative was fixed and the ledger was not. Same failure as #17467 earlier today: a reader consulting a ticket to act reads the operative table, so a correction that stops at the prose leaves the wrong instruction standing where it is used.

Decision Record impact

aligned-with ADR 0019. This ticket does not amend or challenge the ADR — it removes a Group-B violation the ADR already prohibits. Read-gate satisfied before authoring.

Acceptance Criteria

  • Exactly one leaf binds NEO_MODEL_PROVIDER; a grep for the removed key returns zero live references, with the search tree and command recorded.

  • Every remaining chat-provider docblock is true of the code: no leaf claims a role its reader count contradicts. A reviewer can verify the claim by counting readers.

  • roadmapPlanner.mjs declares the Memory_Config import its usage requires, reads openAiCompatible.{host,model} directly, and carries neither an optional-chain config cascade nor a || default — an incomplete block throws a named refusal instead of synthesizing a ROADMAP against an unnamed model.

  • Neo.ai.provider.Ollama declares no model default, so no caller can silently receive a model the deployment did not configure. Its docblock records the measured divergence ('gemma4' vs gemma4:26b) rather than merely asserting the new value.

  • QA.mjs's dead model key is removed, and its docblock says the key read nothing and where model selection actually lives — so its removal cannot be re-added as a "fix".

  • An unresolvable chat model fails with a named diagnostic at the request boundary, and a fixture asserts the throw plus a control asserting a valid model still dispatches — so the guard is not a blanket reject.

    Retargeted from "at config resolution". The original wording cannot be satisfied: all three model leaves carry string defaults, so they always resolve, and a parse hook never fires on a value the env did not supply. The condition that actually reaches a provider is a model id of undefined/null arriving at dispatch — measured: Neo.create(Ollama, {modelName: undefined}) yields undefined, because Neo assigns an explicitly-undefined config rather than skipping it, so the two dispatchers that pass modelName: cfg.model from an omitted block hand the daemon no model at all. The named diagnostic belongs where that is observable. This retargets the AC to a detectable layer; it does not weaken the "must never boot quietly" requirement.

  • Both mutation diagonals are recorded, not just a green: restoring the removed default must redden the detection arms, and making the guard reject unconditionally must redden only the controls.

  • graphProvider is untouched, asserted explicitly, so the cleanup cannot quietly collapse a legitimately separate axis.

  • The three provider-namespaced gemma ids are declared once as one model; gemma4:31b's historical comment at OpenAiCompatible.mjs:26 is preserved.

Out of Scope

  • Agent.providerConfig is never set, and Agent re-implements provider selection. Surfaced while fixing Ollama.mjs: Agent.mjs:158-164 picks a provider class and constructs it with this.providerConfig || {}, duplicating what buildChatModel.mjs already does for every other caller — while providerConfig itself is null everywhere in the tree. Deliberately not fixed here: the repair is a fork (does Agent delegate to buildChatModel, which returns a Gemini-shaped wrapper rather than a provider instance, or does a shared mapper own the modelmodelName translation?), and it changes how every agent profile obtains its provider. Guessing it inside this PR would put a cross-cutting design choice in a cleanup diff. Consequence stated plainly: until that lands, the Agent path fails by name instead of silently using an unconfigured model — the intended direction, and the reason the guard is worth shipping first. Its live test is gated behind NEO_RUN_LIVE_AI_TESTS, so CI is unaffected.

  • graphProvider consolidation — a deliberate separate axis with a documented provider-set difference. Named here so a cleanup pass does not absorb it.

  • Which provider a deployment should choose — this ticket makes the selector singular, not different.

  • The embedding lane#17411 owns that consolidation; this is its chat-side sibling, deliberately not folded because the defects differ in kind (diffusion vs a dead authority).

Avoided Traps

Collapsing the three provider-namespaced model ids into one string. They are three correct ids for the same weights in three runtimes; unifying the strings would break two providers. Only the declaration that they denote one model is missing.

Deleting the gemma4:31b comment as a stale reference. It is the record of a fixed bug — an Ollama-namespaced id in an OpenAI-compatible slot — and is the reason that class is recognisable next time.

Treating this as the same defect as #17411. Embeddings diffuse one authority across many readers; chat declares an authority nobody reads. Opposite shapes, and a single fix serves neither.

Reading the docblocks as ground truth. The prose says chatProvider is Tier-1. The reader count says otherwise. This ticket exists because the prose was believed.

Related

  • #17411 — OPEN epic; the embedding-lane sibling, same "one authority" goal, different defect shape.
  • #17344 — OPEN; the same class on the transport leaf — a declared default that live deployments all override.
  • #17445 — CLOSED as superseded by #17447; it carried the same class on request-context binding.
  • ADR 0019 — the governing decision this restores.

Origin Session ID: 92274805-40ec-4eb9-b778-479c4f96896b

Retrieval Hint: chatProvider has zero readers while modelProvider alias carries the lane; two leaves one NEO_MODEL_PROVIDER env var; gemma4 hardcoded outside AiConfig with a || hidden default in roadmapPlanner

tobiu referenced in commit c07c8a1 - "fix(ai): the chat lane stops declaring an authority nothing reads (#17448) (#17465) on Aug 21, 2026, 9:24 PM
tobiu closed this issue on Aug 21, 2026, 9:24 PM