LearnNewsExamplesServices
Frontmatter
id17466
titleAgent builds every provider with an empty config, and re-implements the selection buildChatModel already owns
stateClosed
labels[]
assigneesneo-opus-vega
createdAtAug 21, 2026, 3:31 PM
updatedAtAug 21, 2026, 9:14 PM
githubUrlhttps://github.com/neomjs/neo/issues/17466
authorneo-opus-vega
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 21, 2026, 9:14 PM

Agent builds every provider with an empty config, and re-implements the selection buildChatModel already owns

neo-opus-vega
neo-opus-vega commented on Aug 21, 2026, 3:31 PM

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,                                            // :37  declared
...
const provider = Neo.create(providerClass, this.providerConfig || {});   // :164 read

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 modelmodelName 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 translatesrunAgent.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:

  1. 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.
  2. 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

  • Agent obtains a provider config from the resolved SSOT rather than {}MOVED to the successor. Blocked by ADR-0019 C1: Agent is not an entrypoint and may not read the SSOT.
  • An alias of openAiCompatible selects the OpenAI-compatible provider (ai/provider/OpenAiCompatible.mjs exists 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.
  • An unknown provider alias fails with a named diagnostic instead of silently becoming Gemini. A control asserts each supported alias still resolves — so the guard is not a blanket reject.
  • The modelmodelName translation exists in exactly one place, cited by both callers.MOVED to the successor, and it is not the extraction this AC assumed. The four sites have already drifted: buildChatModel passes embeddingModel and apiKey through and sets keepAlive on both branches; providerDispatch coerces embeddingModel to null and apiKey to '', and omits keepAlive on 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 readsMOVED to the successor; it depends on the injection mechanism above.
  • Neo.ai.provider.Ollama still defaults no model after the change; #17448's guard arms stay green, asserted rather than assumed.

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 modelmodelName 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

  • #17478the 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

tobiu referenced in commit 9842a7f - "fix(ai): the agent's provider alias set had two values for a three-value axis (#17466) (#17479) on Aug 21, 2026, 9:14 PM
tobiu closed this issue on Aug 21, 2026, 9:14 PM