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 modelmodelName 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.mjsproviderConfig declared, read once, written nowhere; modelProvider defaults to a class.
  • ai/agent/AgentOrchestrator.mjs:191-199createAgent(), 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
modelmodelName 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 modelmodelName 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