Context
Surfaced while fixing #17448 (PR #17465), which removed Neo.ai.provider.Ollama's divergent modelName: 'gemma4' default. Doing so exposed why that default mattered: it was the only thing supplying a model on the Neo.ai.Agent path.
Live latest-open sweep: latest 15 open issues read at 2026-08-21T13:35Z. state:all searches for providerConfig Agent provider selection, Agent provider instantiation buildChatModel, provider config never set — zero hits. Nearest live work is #17448 itself (the chat-provider SSOT cleanup), which names this as explicitly Out of Scope. No A2A claim on this scope.
ADR-0019 read gate satisfied before authoring (§critical_gates #10).
The Problem
Defect 1 — providerConfig is declared, read, and never set
ai/Agent.mjs:
providerConfig: null,
...
const provider = Neo.create(providerClass, this.providerConfig || {}); Measured: providerConfig appears at :35, :37, :164 and nowhere else in the tree — no profile, no caller, no spec sets it. So the branch taken is always {}, and every Agent-constructed provider runs entirely on class defaults.
That is how Ollama.mjs's 'gemma4' became load-bearing while looking like a placeholder: Neo.create(Ollama, {}) yields it (measured), and it names a different model than aiConfig.ollama.model's gemma4:26b — a bare Ollama name resolves to the :latest tag.
Defect 2 — the selection duplicates buildChatModel
ai/Agent.mjs:158-164 maps a provider alias to a class and constructs it:
let providerClass = this.modelProvider;
if (typeof providerClass === 'string') {
providerClass = providerClass.toLowerCase() === 'ollama' ? OllamaProvider : GeminiProvider;
}ai/provider/buildChatModel.mjs already owns exactly this decision for every other consumer, including the model → modelName translation (:194) and the openAiCompatible branch this copy silently drops — an alias of 'openAiCompatible' falls into the GeminiProvider else-arm here, because the ternary only tests 'ollama'.
LATENT, not active — corrected 2026-08-21 before any code was written. Full caller census at tree 3809616cdc: the only string aliases passed to an Agent are QA.mjs:27 ('ollama' → correct), Browser.mjs:20 and Librarian.mjs:23 ('gemini' → correct). AgentOrchestrator.createAgent() (:196) passes no modelProvider, so it takes the declared GeminiProvider class default and never enters the string branch. Nothing anywhere passes aiConfig.modelProvider into an Agent. So no current caller is mis-routed, and this ticket must not be read as reporting a live production misroute.
The two defects are coupled, which is why they are one ticket
This is the finding that matters, and it runs opposite to how I first framed it: Defect 1's most natural fix triggers Defect 2.
Wiring providerConfig from the SSOT means passing the SSOT's provider alias alongside it — and aiConfig.modelProvider's resolved default is exactly 'openAiCompatible', the one string this ternary sends to Gemini. So the obvious repair for the reachable defect converts the latent one into a live misroute on the default configuration, silently: a Gemini provider with no API key returns null from the chat path rather than erroring, so the symptom would be an agent that quietly produces nothing.
Fixing either alone is worse than fixing neither. That coupling is the ticket's actual scope.
This is the ADR-0019 Group A shape: re-implementing resolution the SSOT-adjacent helper already performs.
The Fork (why #17448 did not decide it)
Two defensible repairs, with different blast radius. Both change how every agent profile obtains its provider, which is why this is its own ticket rather than a clause in a cleanup diff.
| option |
shape |
cost |
A — delegate to buildChatModel |
Agent calls it instead of selecting a class |
buildChatModel returns a Gemini-shaped {generateContent} wrapper, but Loop consumes a provider instance (provider.stream(), provider.generate()). Needs the helper to expose the instance, or a second entry point. |
| B — a shared provider-config mapper |
extract toProviderConfig(alias, aiConfig); both Agent and buildChatModel call it |
Smaller and reversible, but leaves two selection sites — it fixes Defect 1 fully and Defect 2 only partly. |
Recommendation: B first, with A recorded as the eventual shape. Not decided here — that is the point of the ticket.
SETTLED 2026-08-21 by ADR-0019 C1, not by preference. Neither option, as posed: both put the resolution in Agent, which is not an entrypoint and therefore may not import AiConfig. The shape is entrypoint resolves, providerConfig injects, a pure mapper translates — runAgent.mjs reads the SSOT, threads the resolved provider config through AgentOrchestrator.createAgent(), and Agent keeps no config read at all. The fork was a false dichotomy created by my own incorrect premise about Agent's status.
Architectural Reality
ai/Agent.mjs:35,37,164 — the declared-read-never-set config.
ai/Agent.mjs:158-162 — the duplicated selection, missing the openAiCompatible branch.
ai/provider/buildChatModel.mjs:186-204 — the owning implementation, including modelName: cfg.model.
ai/services/graph/providerDispatch.mjs:73-91 — the second correct caller shape, for comparison.
ai/agent/profile/QA.mjs — the profile that tried to pin a model and could not, because the key it used is not a declared Agent config.
learn/agentos/decisions/0019-aiconfig-reactive-provider-ssot.md §3 Group A.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
Agent.providerConfig |
Agent.mjs:37 |
resolved from the SSOT for the selected alias when unset |
no silent {}; an unresolvable alias fails by name |
JSDoc |
3 occurrences, 0 writers, measured |
| provider alias → class |
one owning site |
openAiCompatible selects its own provider, not Gemini |
unknown alias throws, never defaults to Gemini |
JSDoc + ADR-0019 |
ternary tests only 'ollama' |
Neo.ai.provider.Ollama#modelName |
caller-injected |
unchanged by this ticket — stays defaulting none |
named diagnostic at dispatch |
shipped in #17448 |
PR #17465 guard spec |
Re-scoped 2026-08-21 — this ticket is the alias axis; the injection design is its successor
Implementation surfaced two blockers that make one ticket dishonest, both measured rather than felt:
Agent and AgentOrchestrator are not thread-entrypoints, so neither may import AiConfig (ADR-0019 C1). The resolution has to happen at ai/scripts/runners/runAgent.mjs and be threaded — which is a different change from anything this ticket proposed.
- Provider class and provider config must travel as a unit.
AiConfig.modelProvider resolves to openAiCompatible while Agent.modelProvider defaults to GeminiProvider, so injecting config alone builds a Gemini provider holding an openAiCompatible config. And the three profiles each declare a different alias ('ollama', 'gemini', 'gemini'), so a single injected config is wrong for at least one of them. Correct injection needs per-alias resolution at every agent-creation site.
Neither blocker touches the alias axis, which is complete and independently valuable: it is the defect that would have been activated by the injection work, so it lands first by design rather than by convenience.
Delivered here: the alias vocabulary. Moved to the successor: providerConfig injection, the mapping consolidation, and QA's ability to pin a model.
Acceptance Criteria
Out of Scope
- Which provider or model a deployment should choose. This makes the wiring singular and honest, not different.
Neo.ai.provider.Ollama's guard. Shipped in #17448; this ticket must not weaken it to make wiring easier.
- Gemini's own config surface. Whatever
GeminiProvider needs beyond an API key is a separate axis.
Avoided Traps
Reading providerConfig: null as "callers supply it." The declaration reads like an extension point. Nothing in the tree writes it, so the || {} arm is the only arm — the config documents an intention, not a behaviour.
Assuming the Ollama alias branch was the whole selector. The ternary's else-arm captures openAiCompatible and gemini alike. Measured: no caller passes 'openAiCompatible' today, so this is a trap rather than a live fault — and reporting it as live would have been the same error I made on #17448, where I described a runtime consequence no run had produced.
Making Agent import aiConfig and hand-map the block. That would put another copy of the model → modelName translation in the tree.
CORRECTED 2026-08-21 — and my original reason was wrong. I wrote "Agent is an entrypoint, so reading the SSOT is permitted." It is not an entrypoint. ADR-0019 C1 permits Neo / _export / AiConfig imports only in thread-entrypoints, and the ADR's own V-B-A classification correction enumerates the ai/ ones — bridge/daemon.mjs, orchestrator/daemon.mjs. Agent.mjs is a class imported by AgentOrchestrator and the three profiles; ai/scripts/runners/runAgent.mjs is the CLI entrypoint, with the process.argv[1] === fileURLToPath(import.meta.url) guard to prove it. So Agent importing AiConfig would be a C1 violation, not a permitted entrypoint read.
This resolves the fork rather than choosing inside it. Both options I posed assumed Agent does the resolving — one by delegating to buildChatModel, one via a shared mapper called from Agent. ADR-0019 says neither: the entrypoint resolves and injects, and providerConfig: null is exactly the injection point its declaration always described. The mapper stays a pure translation in the provider layer, taking an already-resolved block as a parameter, which is ordinary reuse rather than a second SSOT consumer.
Treating this as #17448 re-opened. #17448 removed a dead authority and a divergent default; this changes how providers are wired. Different surfaces, and #17448 names this Out of Scope explicitly.
Related
#17478 — the successor carrying providerConfig injection, the class/config pairing constraint, and the mapping consolidation. Filed with the ADR-0019 C1 constraint and the four measured mapping shapes.
#17448 — the parent cleanup; PR #17465 removed the default that hid Defect 1.
#17411 — OPEN epic; the embedding lane's consolidation of the same "one authority" goal.
ADR 0019 — the governing decision (Group A).
Retrieval Hint: Agent.providerConfig declared null read at :164 and never set anywhere, so every agent provider is built with an empty config; the provider alias ternary tests only ollama so openAiCompatible falls through to Gemini; duplicates buildChatModel selection and model to modelName mapping
Context
Surfaced while fixing
#17448(PR #17465), which removedNeo.ai.provider.Ollama's divergentmodelName: 'gemma4'default. Doing so exposed why that default mattered: it was the only thing supplying a model on theNeo.ai.Agentpath.Live latest-open sweep: latest 15 open issues read at 2026-08-21T13:35Z.
state:allsearches forproviderConfig Agent provider selection,Agent provider instantiation buildChatModel,provider config never set— zero hits. Nearest live work is#17448itself (the chat-provider SSOT cleanup), which names this as explicitly Out of Scope. No A2A claim on this scope.ADR-0019 read gate satisfied before authoring (
§critical_gates#10).The Problem
Defect 1 —
providerConfigis declared, read, and never setai/Agent.mjs:providerConfig: null, // :37 declared ... const provider = Neo.create(providerClass, this.providerConfig || {}); // :164 readMeasured:
providerConfigappears at:35,:37,:164and nowhere else in the tree — no profile, no caller, no spec sets it. So the branch taken is always{}, and every Agent-constructed provider runs entirely on class defaults.That is how
Ollama.mjs's'gemma4'became load-bearing while looking like a placeholder:Neo.create(Ollama, {})yields it (measured), and it names a different model thanaiConfig.ollama.model'sgemma4:26b— a bare Ollama name resolves to the:latesttag.Defect 2 — the selection duplicates
buildChatModelai/Agent.mjs:158-164maps a provider alias to a class and constructs it:let providerClass = this.modelProvider; if (typeof providerClass === 'string') { providerClass = providerClass.toLowerCase() === 'ollama' ? OllamaProvider : GeminiProvider; }ai/provider/buildChatModel.mjsalready owns exactly this decision for every other consumer, including themodel→modelNametranslation (:194) and theopenAiCompatiblebranch this copy silently drops — an alias of'openAiCompatible'falls into theGeminiProviderelse-arm here, because the ternary only tests'ollama'.LATENT, not active — corrected 2026-08-21 before any code was written. Full caller census at tree
3809616cdc: the only string aliases passed to an Agent areQA.mjs:27('ollama'→ correct),Browser.mjs:20andLibrarian.mjs:23('gemini'→ correct).AgentOrchestrator.createAgent()(:196) passes nomodelProvider, so it takes the declaredGeminiProviderclass default and never enters the string branch. Nothing anywhere passesaiConfig.modelProviderinto an Agent. So no current caller is mis-routed, and this ticket must not be read as reporting a live production misroute.The two defects are coupled, which is why they are one ticket
This is the finding that matters, and it runs opposite to how I first framed it: Defect 1's most natural fix triggers Defect 2.
Wiring
providerConfigfrom the SSOT means passing the SSOT's provider alias alongside it — andaiConfig.modelProvider's resolved default is exactly'openAiCompatible', the one string this ternary sends to Gemini. So the obvious repair for the reachable defect converts the latent one into a live misroute on the default configuration, silently: a Gemini provider with no API key returnsnullfrom the chat path rather than erroring, so the symptom would be an agent that quietly produces nothing.Fixing either alone is worse than fixing neither. That coupling is the ticket's actual scope.
This is the ADR-0019 Group A shape: re-implementing resolution the SSOT-adjacent helper already performs.
The Fork (why
#17448did not decide it)Two defensible repairs, with different blast radius. Both change how every agent profile obtains its provider, which is why this is its own ticket rather than a clause in a cleanup diff.
buildChatModelAgentcalls it instead of selecting a classbuildChatModelreturns a Gemini-shaped{generateContent}wrapper, butLoopconsumes a provider instance (provider.stream(),provider.generate()). Needs the helper to expose the instance, or a second entry point.toProviderConfig(alias, aiConfig); bothAgentandbuildChatModelcall itRecommendation: B first, with A recorded as the eventual shape. Not decided here — that is the point of the ticket.SETTLED 2026-08-21 by ADR-0019 C1, not by preference. Neither option, as posed: both put the resolution in
Agent, which is not an entrypoint and therefore may not importAiConfig. The shape is entrypoint resolves,providerConfiginjects, a pure mapper translates —runAgent.mjsreads the SSOT, threads the resolved provider config throughAgentOrchestrator.createAgent(), andAgentkeeps no config read at all. The fork was a false dichotomy created by my own incorrect premise aboutAgent's status.Architectural Reality
ai/Agent.mjs:35,37,164— the declared-read-never-set config.ai/Agent.mjs:158-162— the duplicated selection, missing theopenAiCompatiblebranch.ai/provider/buildChatModel.mjs:186-204— the owning implementation, includingmodelName: cfg.model.ai/services/graph/providerDispatch.mjs:73-91— the second correct caller shape, for comparison.ai/agent/profile/QA.mjs— the profile that tried to pin a model and could not, because the key it used is not a declaredAgentconfig.learn/agentos/decisions/0019-aiconfig-reactive-provider-ssot.md§3 Group A.Contract Ledger Matrix
Agent.providerConfigAgent.mjs:37{}; an unresolvable alias fails by nameopenAiCompatibleselects its own provider, not Gemini'ollama'Neo.ai.provider.Ollama#modelName#17448Re-scoped 2026-08-21 — this ticket is the alias axis; the injection design is its successor
Implementation surfaced two blockers that make one ticket dishonest, both measured rather than felt:
AgentandAgentOrchestratorare not thread-entrypoints, so neither may importAiConfig(ADR-0019 C1). The resolution has to happen atai/scripts/runners/runAgent.mjsand be threaded — which is a different change from anything this ticket proposed.AiConfig.modelProviderresolves toopenAiCompatiblewhileAgent.modelProviderdefaults toGeminiProvider, so injecting config alone builds a Gemini provider holding an openAiCompatible config. And the three profiles each declare a different alias ('ollama','gemini','gemini'), so a single injected config is wrong for at least one of them. Correct injection needs per-alias resolution at every agent-creation site.Neither blocker touches the alias axis, which is complete and independently valuable: it is the defect that would have been activated by the injection work, so it lands first by design rather than by convenience.
Delivered here: the alias vocabulary. Moved to the successor:
providerConfiginjection, the mapping consolidation, and QA's ability to pin a model.Acceptance Criteria
— MOVED to the successor. Blocked by ADR-0019 C1:Agentobtains a provider config from the resolved SSOT rather than{}Agentis not an entrypoint and may not read the SSOT.openAiCompatibleselects the OpenAI-compatible provider (ai/provider/OpenAiCompatible.mjsexists and is the target). A RED control proves the current code picks Gemini for that alias — a latent defect still earns a control, because the arm is what stops Defect 1's fix from silently activating it.The— MOVED to the successor, and it is not the extraction this AC assumed. The four sites have already drifted:model→modelNametranslation exists in exactly one place, cited by both callers.buildChatModelpassesembeddingModelandapiKeythrough and setskeepAliveon both branches;providerDispatchcoercesembeddingModeltonullandapiKeyto'', and omitskeepAliveon the OpenAI-compatible branch entirely. Unifying them changes what the graph-dispatch path sends, so it needs its own evidence rather than riding along.The QA profile can pin a model through a config something actually reads— MOVED to the successor; it depends on the injection mechanism above.Neo.ai.provider.Ollamastill defaults no model after the change;#17448's guard arms stay green, asserted rather than assumed.Out of Scope
Neo.ai.provider.Ollama's guard. Shipped in#17448; this ticket must not weaken it to make wiring easier.GeminiProviderneeds beyond an API key is a separate axis.Avoided Traps
Reading
providerConfig: nullas "callers supply it." The declaration reads like an extension point. Nothing in the tree writes it, so the|| {}arm is the only arm — the config documents an intention, not a behaviour.Assuming the Ollama alias branch was the whole selector. The ternary's else-arm captures
openAiCompatibleandgeminialike. Measured: no caller passes'openAiCompatible'today, so this is a trap rather than a live fault — and reporting it as live would have been the same error I made on#17448, where I described a runtime consequence no run had produced.Making
AgentimportaiConfigand hand-map the block. That would put another copy of themodel→modelNametranslation in the tree.CORRECTED 2026-08-21 — and my original reason was wrong. I wrote "
Agentis an entrypoint, so reading the SSOT is permitted." It is not an entrypoint. ADR-0019 C1 permitsNeo/_export/AiConfigimports only in thread-entrypoints, and the ADR's own V-B-A classification correction enumerates theai/ones —bridge/daemon.mjs,orchestrator/daemon.mjs.Agent.mjsis a class imported byAgentOrchestratorand the three profiles;ai/scripts/runners/runAgent.mjsis the CLI entrypoint, with theprocess.argv[1] === fileURLToPath(import.meta.url)guard to prove it. SoAgentimportingAiConfigwould be a C1 violation, not a permitted entrypoint read.This resolves the fork rather than choosing inside it. Both options I posed assumed
Agentdoes the resolving — one by delegating tobuildChatModel, one via a shared mapper called fromAgent. ADR-0019 says neither: the entrypoint resolves and injects, andproviderConfig: nullis exactly the injection point its declaration always described. The mapper stays a pure translation in the provider layer, taking an already-resolved block as a parameter, which is ordinary reuse rather than a second SSOT consumer.Treating this as
#17448re-opened.#17448removed a dead authority and a divergent default; this changes how providers are wired. Different surfaces, and#17448names this Out of Scope explicitly.Related
#17478— the successor carryingproviderConfiginjection, the class/config pairing constraint, and the mapping consolidation. Filed with the ADR-0019 C1 constraint and the four measured mapping shapes.#17448— the parent cleanup; PR #17465 removed the default that hid Defect 1.#17411— OPEN epic; the embedding lane's consolidation of the same "one authority" goal.ADR 0019 — the governing decision (Group A).
Retrieval Hint:
Agent.providerConfig declared null read at :164 and never set anywhere, so every agent provider is built with an empty config; the provider alias ternary tests only ollama so openAiCompatible falls through to Gemini; duplicates buildChatModel selection and model to modelName mapping