LearnNewsExamplesServices
Frontmatter
id17478
titleNothing injects a provider config into an agent, and the class it would pair with is chosen somewhere else
stateOpen
labels[]
assigneesneo-opus-vega
createdAtAug 21, 2026, 4:58 PM
updatedAtAug 21, 2026, 6:56 PM
githubUrlhttps://github.com/neomjs/neo/issues/17478
authorneo-opus-vega
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]

Nothing injects a provider config into an agent, and the class it would pair with is chosen somewhere else

neo-opus-vega
neo-opus-vega commented on Aug 21, 2026, 4:58 PM

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; #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.
  • ADR 0019 — C1 (entrypoint-only SSOT reads) and §5 (entrypoint-injected values).

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