Successor to #17466, which delivered the alias axis and was re-scoped when implementation surfaced two blockers this ticket owns. Filed by the same author; #17466 is not reopened.
Live latest-open sweep: latest 15 open issues read at 2026-08-21T14:55Z. state:all searches for providerConfig injection agent, agent provider config entrypoint, model modelName mapping consolidation — nearest prior art is #17466 itself and #17448 (the divergent Ollama default this removes the need for). No A2A claim on this scope.
ADR-0019 read in full before authoring, per §critical_gates#10.
The Problem
Defect 1 — providerConfig is declared, read, and never written
ai/Agent.mjs declares providerConfig: null, reads it at the single construction site as this.providerConfig || {}, and nothing in the tree assigns it. So every agent-built provider runs on its class's own defaults.
That is why #17448 mattered: Neo.ai.provider.Ollama defaulted modelName: 'gemma4' while the resolved leaf is gemma4:26b, and this was the path where the class default was the only model supplier. #17448 removed the default, so today an agent whose provider needs a model gets a named failure instead of a silently wrong one. That is the intended intermediate state, not a regression — but it is intermediate, and this ticket is what ends it.
Defect 2 — the config and the class are chosen in different places
AiConfig.modelProvider resolves to openAiCompatible. Agent.modelProvider defaults to GeminiProvider. So injecting a resolved config without also settling the class builds a Gemini provider holding an OpenAI-compatible config — and a keyless Gemini returns null from its chat path rather than throwing, so the mismatch would not announce itself.
The three profiles each pin their own alias (QA → 'ollama', Browser and Librarian → 'gemini'), so one injected config is wrong for at least one of them by construction. Provider class and provider config have to be resolved together, per agent-creation site.
Defect 3 — the mapping this would share has already drifted four ways
#17466 AC-4 assumed the model → modelName translation was one shape open-coded in several places. Measured at 4defb88ad0, it is four shapes:
site
embeddingModel
apiKey
keepAlive
buildChatModel ollama
passed through
—
set when defined
buildChatModel openAiCompatible
—
passed through
set when defined
providerDispatch ollama
coerced || null
—
set when defined
providerDispatch openAiCompatible
—
coerced || ''
absent
So consolidation is a behaviour-changing refactor, not an extraction: the graph-dispatch path would begin sending keepAlive where it does not today, and would stop coercing two fields. That needs its own evidence, which is why it is a defect here rather than a cleanup rider.
The constraint that shapes the answer
Agent and AgentOrchestrator are not thread-entrypoints. ADR-0019 C1 permits Neo / _export / AiConfig imports only in entrypoints, and the ADR's own V-B-A classification correction enumerates the ai/ ones. ai/scripts/runners/runAgent.mjs is the CLI entrypoint (process.argv[1] === fileURLToPath(import.meta.url)); Agent.mjs is a class imported by the orchestrator and three profiles.
So the resolution must happen at the entrypoint and reach the agent by injection. providerConfig: null is exactly the injection point its own declaration always described — it was simply never wired.
Candidate shapes, none chosen here
option
shape
cost
A — resolved value threaded
entrypoint resolves one provider config and passes it down
wrong for any agent whose alias differs from the deployment's; needs a per-site value, so the thread widens with every creation site
B — resolver closure injected
entrypoint injects alias => resolveProviderConfig(alias, AiConfig); each creation site resolves for the alias it knows
keeps AiConfig out of every non-entrypoint and pairs class with config by construction. ADR-0019 §5 allows an entrypoint-injected value at "a narrow, explicitly-named bootstrap boundary" — this must be argued to be that, not generic pass-along plumbing
C — alias travels, config resolved at construction
agents carry only their alias; a single provider-factory seam owns both lookups
fewest moving parts, but the factory becomes a new authority and has to live somewhere that may read the SSOT
B is where I would start, and I am explicitly not deciding it in the ticket body — the pass-along boundary question is the part that deserves a second family's read.
The Architectural Reality
ai/Agent.mjs — providerConfig declared, read once, written nowhere; modelProvider defaults to a class.
ai/agent/AgentOrchestrator.mjs:191-199 — createAgent(), the production creation site; passes no provider config.
ai/scripts/runners/runAgent.mjs — the entrypoint, and the only place on this path permitted to read the SSOT.
ai/agent/profile/{QA,Browser,Librarian}.mjs — three pinned aliases, three different providers.
ai/provider/resolveProviderClass.mjs — the alias authority #17466 landed; a config mapper belongs beside it and must stay pure (config passed in, never read).
ai/provider/buildChatModel.mjs, ai/services/graph/providerDispatch.mjs — the four drifted mapping sites.
learn/agentos/decisions/0019-aiconfig-reactive-provider-ssot.md — C1, and §5 on entrypoint-injected values.
Contract Ledger Matrix
Target Surface
Source of Authority
Proposed Behavior
Fallback
Docs
Evidence
Agent.providerConfig
injected by the entrypoint
carries a config resolved for this agent's own alias
absent injection keeps today's fail-by-name; never a plausible default
JSDoc
3 occurrences, 0 writers, measured
provider class ↔ config pairing
one resolution per creation site
class and config always derived from the same alias
a mismatch must be impossible to express, not merely unlikely
JSDoc
openAiCompatible config + Gemini class is constructible today
model → modelName mapping
one pure mapper
single shape, callers migrated deliberately
—
JSDoc + ADR-0019
four measured shapes, two coercion differences, one missing field
AiConfig import sites
ADR-0019 C1
unchanged outside entrypoints
—
ADR-0019
Agent/AgentOrchestrator are classes
Acceptance Criteria
No agent-creation path constructs a provider with an empty config. A grep records the tree and command, and a spec asserts the production path receives a resolved config rather than {}.
A provider class and its config cannot come from different aliases. Demonstrated by a RED control that fails when a config is paired with a mismatched class — the arm must fail against a deliberately mismatched pairing, not merely pass on a matched one.
Each of the three profiles resolves the config for its own alias, asserted per profile rather than once.
No non-entrypoint gains an exported resolver or resolved literal for something a leaf already binds.AiConfig is imported by no additional non-entrypoint.Re-pointed 2026-08-21:#17481's AC-1 established that C1's operative harm is the exported-resolver shape, not the import location — the ADR's own remediation of its only recorded C1 violation added an AiConfig import to a non-entrypoint, and an import-location guard would flag 124 files plus that very file. So this AC no longer waits on an import-location assertion, which should not be built; it asserts the shape C1 actually governs.
The model → modelName mapping exists once. The two providerDispatch behaviour changes it causes — keepAlive newly sent on the OpenAI-compatible branch, and the embeddingModel/apiKey coercions dropped — are each named and covered, or the migration is deliberately scoped out with the divergence recorded.
QA can pin a model through a config something reads, and a spec asserts the pinned id reaches the provider request.
#17466's alias arms and #17448's no-default guard both stay green, asserted rather than assumed.
An unresolvable pairing fails by name at construction; a control asserts every supported alias still constructs.
Out of Scope
Which provider a deployment should choose. This makes the wiring correct and singular, not different.
The alias vocabulary.#17466 owns it and it is landed.
Neo.ai.provider.Ollama's no-default guard.#17448 owns it; this ticket must not weaken it to make injection easier.
Changing buildChatModel's return contract. If option A is chosen it stays untouched.
Avoided Traps
Reading providerConfig: null as "callers supply it." Nothing writes it, so the || {} arm is the only arm; the declaration documents an intention, not a behaviour.
Assuming Agent may read the SSOT because it is "close to the entry." It is a class imported by four consumers. ADR-0019 C1 is about thread-entrypoints, and the ADR names them. I asserted the opposite in #17466 and corrected it there.
Treating the mapping consolidation as an extraction. Four sites, three fields, two coercion differences and one absent field. A "pure refactor" here would silently change what the graph-dispatch path sends.
Injecting one resolved config and calling it done. That is the mismatch in Defect 2, and it fails quietly because a keyless Gemini returns null instead of throwing.
Related
#17466 — predecessor; delivered the alias axis, re-scoped rather than reopened.
#17448 / PR #17465 — removed the divergent Ollama default that made this path silently wrong; the fail-by-name state this ticket ends.
#17481 — C1 has no mechanical guard and its boundary is unsettled; AC-4 here depends on it.
Retrieval Hint: Agent.providerConfig declared null read once written nowhere so every agent provider runs on class defaults; AiConfig.modelProvider is openAiCompatible while Agent.modelProvider defaults to GeminiProvider so injecting config alone pairs a Gemini class with an openAiCompatible config; Agent and AgentOrchestrator are not thread-entrypoints under ADR-0019 C1; four drifted model-to-modelName mapping shapes across buildChatModel and providerDispatch
Context
Successor to
#17466, which delivered the alias axis and was re-scoped when implementation surfaced two blockers this ticket owns. Filed by the same author;#17466is not reopened.Live latest-open sweep: latest 15 open issues read at 2026-08-21T14:55Z.
state:allsearches forproviderConfig injection agent,agent provider config entrypoint,model modelName mapping consolidation— nearest prior art is#17466itself and#17448(the divergent Ollama default this removes the need for). No A2A claim on this scope.ADR-0019 read in full before authoring, per
§critical_gates#10.The Problem
Defect 1 —
providerConfigis declared, read, and never writtenai/Agent.mjsdeclaresproviderConfig: null, reads it at the single construction site asthis.providerConfig || {}, and nothing in the tree assigns it. So every agent-built provider runs on its class's own defaults.That is why
#17448mattered:Neo.ai.provider.OllamadefaultedmodelName: 'gemma4'while the resolved leaf isgemma4:26b, and this was the path where the class default was the only model supplier.#17448removed the default, so today an agent whose provider needs a model gets a named failure instead of a silently wrong one. That is the intended intermediate state, not a regression — but it is intermediate, and this ticket is what ends it.Defect 2 — the config and the class are chosen in different places
AiConfig.modelProviderresolves toopenAiCompatible.Agent.modelProviderdefaults toGeminiProvider. So injecting a resolved config without also settling the class builds a Gemini provider holding an OpenAI-compatible config — and a keyless Gemini returnsnullfrom its chat path rather than throwing, so the mismatch would not announce itself.The three profiles each pin their own alias (
QA→'ollama',BrowserandLibrarian→'gemini'), so one injected config is wrong for at least one of them by construction. Provider class and provider config have to be resolved together, per agent-creation site.Defect 3 — the mapping this would share has already drifted four ways
#17466AC-4 assumed themodel→modelNametranslation was one shape open-coded in several places. Measured at4defb88ad0, it is four shapes:embeddingModelapiKeykeepAlivebuildChatModelollamabuildChatModelopenAiCompatibleproviderDispatchollama|| nullproviderDispatchopenAiCompatible|| ''So consolidation is a behaviour-changing refactor, not an extraction: the graph-dispatch path would begin sending
keepAlivewhere it does not today, and would stop coercing two fields. That needs its own evidence, which is why it is a defect here rather than a cleanup rider.The constraint that shapes the answer
AgentandAgentOrchestratorare not thread-entrypoints. ADR-0019 C1 permitsNeo/_export/AiConfigimports only in entrypoints, and the ADR's own V-B-A classification correction enumerates theai/ones.ai/scripts/runners/runAgent.mjsis the CLI entrypoint (process.argv[1] === fileURLToPath(import.meta.url));Agent.mjsis a class imported by the orchestrator and three profiles.So the resolution must happen at the entrypoint and reach the agent by injection.
providerConfig: nullis exactly the injection point its own declaration always described — it was simply never wired.Candidate shapes, none chosen here
alias => resolveProviderConfig(alias, AiConfig); each creation site resolves for the alias it knowsAiConfigout of every non-entrypoint and pairs class with config by construction. ADR-0019 §5 allows an entrypoint-injected value at "a narrow, explicitly-named bootstrap boundary" — this must be argued to be that, not generic pass-along plumbingB is where I would start, and I am explicitly not deciding it in the ticket body — the pass-along boundary question is the part that deserves a second family's read.
The Architectural Reality
ai/Agent.mjs—providerConfigdeclared, read once, written nowhere;modelProviderdefaults to a class.ai/agent/AgentOrchestrator.mjs:191-199—createAgent(), the production creation site; passes no provider config.ai/scripts/runners/runAgent.mjs— the entrypoint, and the only place on this path permitted to read the SSOT.ai/agent/profile/{QA,Browser,Librarian}.mjs— three pinned aliases, three different providers.ai/provider/resolveProviderClass.mjs— the alias authority#17466landed; a config mapper belongs beside it and must stay pure (config passed in, never read).ai/provider/buildChatModel.mjs,ai/services/graph/providerDispatch.mjs— the four drifted mapping sites.learn/agentos/decisions/0019-aiconfig-reactive-provider-ssot.md— C1, and §5 on entrypoint-injected values.Contract Ledger Matrix
Agent.providerConfigopenAiCompatibleconfig + Gemini class is constructible todaymodel→modelNamemappingAiConfigimport sitesAgent/AgentOrchestratorare classesAcceptance Criteria
{}.Re-pointed 2026-08-21: #17481's AC-1 established that C1's operative harm is the exported-resolver shape, not the import location — the ADR's own remediation of its only recorded C1 violation added anAiConfigis imported by no additional non-entrypoint.AiConfigimport to a non-entrypoint, and an import-location guard would flag 124 files plus that very file. So this AC no longer waits on an import-location assertion, which should not be built; it asserts the shape C1 actually governs.model→modelNamemapping exists once. The twoproviderDispatchbehaviour changes it causes —keepAlivenewly sent on the OpenAI-compatible branch, and theembeddingModel/apiKeycoercions dropped — are each named and covered, or the migration is deliberately scoped out with the divergence recorded.QAcan pin a model through a config something reads, and a spec asserts the pinned id reaches the provider request.#17466's alias arms and#17448's no-default guard both stay green, asserted rather than assumed.Out of Scope
#17466owns it and it is landed.Neo.ai.provider.Ollama's no-default guard.#17448owns it; this ticket must not weaken it to make injection easier.buildChatModel's return contract. If option A is chosen it stays untouched.Avoided Traps
Reading
providerConfig: nullas "callers supply it." Nothing writes it, so the|| {}arm is the only arm; the declaration documents an intention, not a behaviour.Assuming
Agentmay read the SSOT because it is "close to the entry." It is a class imported by four consumers. ADR-0019 C1 is about thread-entrypoints, and the ADR names them. I asserted the opposite in#17466and corrected it there.Treating the mapping consolidation as an extraction. Four sites, three fields, two coercion differences and one absent field. A "pure refactor" here would silently change what the graph-dispatch path sends.
Injecting one resolved config and calling it done. That is the mismatch in Defect 2, and it fails quietly because a keyless Gemini returns
nullinstead of throwing.Related
#17466— predecessor; delivered the alias axis, re-scoped rather than reopened.#17448/ PR #17465 — removed the divergent Ollama default that made this path silently wrong; the fail-by-name state this ticket ends.#17481— C1 has no mechanical guard and its boundary is unsettled; AC-4 here depends on it.Retrieval Hint:
Agent.providerConfig declared null read once written nowhere so every agent provider runs on class defaults; AiConfig.modelProvider is openAiCompatible while Agent.modelProvider defaults to GeminiProvider so injecting config alone pairs a Gemini class with an openAiCompatible config; Agent and AgentOrchestrator are not thread-entrypoints under ADR-0019 C1; four drifted model-to-modelName mapping shapes across buildChatModel and providerDispatch