LearnNewsExamplesServices
Frontmatter
id16850
titleFive of six ollama env vars never reach the containers, so ollama-native embedding cannot be expressed through the canonical profile
stateClosed
labels
bugaiarchitecture
assigneesneo-opus-grace
createdAtAug 10, 2026, 2:37 AM
updatedAtAug 10, 2026, 1:17 PM
githubUrlhttps://github.com/neomjs/neo/issues/16850
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 10, 2026, 9:58 AM

Five of six ollama env vars never reach the containers, so ollama-native embedding cannot be expressed through the canonical profile

Closed Backlog/active-chunk-14 bugaiarchitecture
neo-opus-grace
neo-opus-grace commented on Aug 10, 2026, 2:37 AM

Context

Found by re-framing the reproduction: instead of scripting against a provider, configure our own containers the way an external plane configures theirs and exercise the real MCP tools. The change turned out to be impossible to express through the canonical profile — which is the finding.

The Problem

ai/configBase.mjs declares six ollama env leaves. ai/deploy/docker-compose.yml passes one.

declared leaf reaches the containers?
NEO_OLLAMA_KEEP_ALIVE
NEO_OLLAMA_HOST
NEO_OLLAMA_MODEL
NEO_OLLAMA_EMBEDDING_MODEL
NEO_OLLAMA_EMBEDDING_TIMEOUT_MS
NEO_OLLAMA_REQUIRE_PARALLEL_MODELS

NEO_OLLAMA_HOST appears zero times across every compose variant in ai/deploy/ (docker-compose.yml, .dev, .local-agent-os, .parity-capture, .parity-ci, .test).

So a deployment that selects NEO_EMBEDDING_PROVIDER=ollama through the canonical profile gets ollama.host resolved to its leaf default, http://127.0.0.1:11434. Inside the kb-server container that address is the kb-server itself. Nothing listens on it. Every native ollama embed fails with connection-refused, permanently, on any plane configured through this profile. The model service is reachable at http://local-model:11434 on the compose network.

Bounded correctly, because the first wording overclaimed: a hand-written or out-of-band Compose could always supply the variable, and a deployment profile we maintain does exactly that.

Attribution corrected 2026-08-10 11:1xZ. An earlier wording said an external plane's own Compose hand-writes the variable, and used that as field evidence of cost borne downstream. Git blame says otherwise: @tobiu wrote those lines himself, on 2026-05-26, in a Compose file our side maintains, in a commit titled "feat(compose): R1 MCP stack mirroring neo canonical ai/deploy" — 41 of that file's commits are his. So the re-derivation cost was ours, not an external operator's, and presenting it as the latter inflated the defect's reach.

It remains evidence, and arguably better evidence: the person mirroring the canonical profile could not express the composition through it either, and silently hand-wired around the gap. A profile that its own maintainers must work around is incomplete on the clearest possible witness. But it is a statement about us, and it was attributed to someone else. The defect is not that the composition is impossible — it is that the canonical profile cannot express it, so everyone who wants it must independently discover a missing variable and inject it by a route no committed artifact records.

The other four missing leaves compound it: the operator's chosen chat model, embedding model and timeout are all silently ignored in favour of leaf defaults.

The contrast is what makes this an omission rather than a policy. The sibling provider is fully wired — NEO_OPENAI_COMPATIBLE_HOST, _MODEL, _EMBEDDING_MODEL, _API_KEY, _KEEP_ALIVE all pass through. And NEO_OLLAMA_KEEP_ALIVE is passed: nobody wires a keep-alive for a provider they intend to exclude. The intent to support ollama is in the file; the host never followed.

Why it is worse than a missing variable

embeddingProvider: leaf('openAiCompatible', 'NEO_EMBEDDING_PROVIDER', 'string') presents ollama as a first-class choice, and the code backs that up completely: ai/provider/Ollama.mjs is a full provider, TextEmbeddingService has a native embed path, providerReadinessHelper carries a whole ollama branch keyed on role === 'embedding' ? '/api/embed' : '/api/chat'.

Everything an operator can read says ollama is supported. The canonical deployment layer cannot deliver it. Someone reading our config and setting the obvious value gets a plane whose corpus never fills, with no error naming the cause — the failure is a connection refused to localhost, which reads as "the provider is down" rather than "the host was never configured".

The Architectural Reality

  • ai/deploy/docker-compose.yml — the three service env blocks (kb-server ~`:88, mc-server ~:210, orchestrator ~:308). Each passes NEO_EMBEDDING_PROVIDERand the fullNEO_OPENAI_COMPATIBLE_*set, plusNEO_OLLAMA_KEEP_ALIVE` alone.
  • Same file, local-model service — ollama/ollama:latest, OLLAMA_HOST=0.0.0.0:11434, reachable on the network as local-model:11434, gated behind --profile local-model. The target exists; nothing can be pointed at it.
  • ai/configBase.mjs:654host: leaf('http://127.0.0.1:11434', 'NEO_OLLAMA_HOST', 'string').

The Fix

Pass each leaf through to the services that actually consume it — which is not the same set for all six — and keep every value operator-overridable.

- NEO_OLLAMA_HOST=${NEO_OLLAMA_HOST:-}
- NEO_OLLAMA_MODEL=${NEO_OLLAMA_MODEL:-}
- NEO_OLLAMA_EMBEDDING_MODEL=${NEO_OLLAMA_EMBEDDING_MODEL:-}
- NEO_OLLAMA_EMBEDDING_TIMEOUT_MS=${NEO_OLLAMA_EMBEDDING_TIMEOUT_MS:-}

Contract Ledger Matrix

Corrected after @neo-gpt's review on PR #16851, which falsified the original ledger: it specified a file-wide declared-leaf subset while the production claim is per consuming service, and it treated all six leaves as symmetric. The unit of the contract is a (service, leaf) coordinate.

Coordinate Source of Authority Proposed Behavior Fallback Evidence
kb-server × HOST, MODEL, EMBEDDING_MODEL, KEEP_ALIVE, EMBEDDING_TIMEOUT_MS docker-compose.yml passed through as ${VAR:-} unset ⇒ leaf default, identical to today TextEmbeddingService (#getOllamaProvider, #getOllamaEmbeddingTimeoutMs) + the dispatch seam, reachable from the kb entrypoint
mc-server × the same five docker-compose.yml passed through as ${VAR:-} unset ⇒ leaf default memory embedding + graph extractors run in-process
orchestrator × the same five plus REQUIRE_PARALLEL_MODELS docker-compose.yml passed through as ${VAR:-} unset ⇒ leaf default buildOllamaReadinessConfig, the configured serve task, DreamService, the residency repair
kb-server / mc-server × REQUIRE_PARALLEL_MODELS deliberately NOT passed n/a sole read is buildOllamaReadinessConfig; all three callers are under ai/daemons/orchestrator/. The module is in both import graphs, but only because TextEmbeddingService dynamic-imports fetchLmsLoadedModels from it — module reachability is not consumption
NEO_OLLAMA_HOST on a --profile local-model plane operator .env can be set to http://local-model:11434 and take effect unset ⇒ 127.0.0.1:11434 leaf default (today's behaviour) plane check: native embed reaches the model service

Decision Record impact: aligned-with ADR 0019 — the leaves already own env-override-with-default; this is the deployment layer failing to deliver the env, not a config-resolution change. No leaf definitions are touched.

Acceptance Criteria

  • Every declared NEO_OLLAMA_* leaf reaches each service that consumes it, and no service carries one it does not. Receipt: kb-server and mc-server carry 5; orchestrator carries 6. The original wording of this AC ("all six … to every service") was wrong, and the review that caught the guard caught the ledger with it: shipping REQUIRE_PARALLEL_MODELS to kb/mc advertises an orchestrator knob as theirs.

  • A mechanical check asserts declared-NEO_OLLAMA_*-leaves ⊆ compose-passed-vars … the same check applied to NEO_OPENAI_COMPATIBLE_* must pass on the current tree. This AC was WRONG and implementing it is what proved so. The invariant is false by design: 8 of 14 declared NEO_OPENAI_COMPATIBLE_* leaves legitimately never reach Compose (BATCH_EMBEDDING_*, CONTENTION_*, UNLOAD_RETRY_*, REQUIRE_PARALLEL_MODELS), because most leaves are tunables whose defaults are authoritative. The required positive control would have failed.

    Replaced by the invariant that is true and that catches this defect: the contract is a set of (service, leaf) coordinates. Receipt: test/playwright/unit/ai/deploy/OllamaProviderEnvCoordinates.spec.mjs asserts each required pair independently, requires each value to be a ${NAME:-} interpolation of its own variable, and treats a NEO_OLLAMA_* entry with no consumer behind it as a violation rather than a harmless extra.

    Mutation-convicted, and the mutations are permanent tests rather than a one-off claimcoordinateViolations() is a pure function of a compose document, so the suite runs it against deliberately broken clones. All four go red: dropping a required leaf from one service; hardcoding the value to a wrong target; interpolating a different variable; adding a coordinate by symmetry. The first two are @neo-gpt's exact-head falsifiers against the predecessor guard, which passed both.

    Every required coordinate carries a receipt — the consuming module plus the literal source lines that perform the read, matched with whitespace normalised and checked against that service's import graph — so a coordinate cannot be added by assertion alone. Every deliberate absence carries a written reason. A coverage test requires both tables to span the full (service × declared-leaf) product, so a newly declared leaf reddens the suite until its disposition is decided per service.

    The weaker family-agnostic floor (a loopback-default leaf must be overridable somewhere in the profile) remains in LoopbackDefaultReachability.spec.mjs, now stating in its own header that it cannot see service scope and must not be read as "wired correctly".

  • Defaults unchanged: a deployment setting none of them behaves exactly as today. Receipt: every entry is ${VAR:-}, so an unset var yields an empty value and the leaf default stands; 417 passed across ai/deploy/ and ai/scripts/lint/.

  • On a --profile local-model plane with NEO_EMBEDDING_PROVIDER=ollama and NEO_OLLAMA_HOST=http://local-model:11434, a native embed reaches the model service instead of container-localhost. [L3-deferred — operator handoff needed] — a running compose plane is not reachable from unit CI, so this is carried as Post-Merge Validation on the PR rather than gating merge.

    Partial receipt already in hand, stated as partial: a CPU-only ollama/ollama:0.23.1 container mounted on the canonical local-model-data volume served POST /api/embed for qwen3-embeddinghttp=200, 4096 dims, runner CPU returning to 0.00 % after the call. That proves the model and the wire work in a container. It does not prove the env reaches kb-server, which is the actual claim here and needs the plane.

Out of Scope

  • Changing the default provider. openAiCompatible stays the default.
  • Whether ollama-native or openAiCompatible-against-ollama is the recommended path — this ticket makes the declared choice work; it does not re-open which choice to prefer.
  • #16849 (Ollama.stream() timeout), #16846 (embedding batch env overrides) — same neighbourhood, separate defects.

Avoided Traps

  • Reading the omission as policy. The wired NEO_OLLAMA_KEEP_ALIVE and the fully-implemented provider, readiness-probe and embed paths all say ollama is meant to work. One passed variable out of six is an oversight with a long blast radius, not a decision.
  • A one-sided guard. An assertion that only checks the ollama arm would pass trivially once this ships and never protect the sibling.
  • A guard that restates the fix instead of testing it. The first version checked a union of env names across services and stayed green under both of the mutations it existed to catch. A name appearing somewhere in a file is not a deployment contract.
  • Deriving the consumer set from an import graph. The convenient instrument says all six leaves have readers in all three graphs, which would have restored the symmetry copy with machinery behind it. Module presence is the coarse signal; the call path is the finer one and it was right there.
  • Claiming this explains a specific plane's empty corpus. It explains any plane configured through this profile with NEO_EMBEDDING_PROVIDER=ollama. Whether a given deployment is in that state is a one-command check (docker exec <kb-container> printenv NEO_EMBEDDING_PROVIDER NEO_OLLAMA_HOST), not something this ticket may assume.

Related

  • #16706 — deployment-stability epic; the operator runbook gains this as a discriminator.
  • #16830 — stuck-runner detection on containerized planes, which probes the same native surface.
  • #16846, #16849 — adjacent provider/embedding defects found in the same sweep.

Live latest-open sweep: latest 12 open issues plus a scoped title sweep for ollama/compose/env duplicates at 2026-08-10T00:5xZ; no equivalent found.

Structure map: N/A — modifies ai/deploy/docker-compose.yml in place.

Origin Session ID: d8332b13-5d97-4839-ac11-d2de4602a989

Retrieval Hint: query_raw_memories("NEO_OLLAMA_HOST not in docker-compose native embed resolves container localhost per-service coordinate guard")

tobiu closed this issue on Aug 10, 2026, 9:58 AM
tobiu referenced in commit 992b659 - "fix(deploy): the ollama provider's env reaches the containers (#16850) (#16851) on Aug 10, 2026, 9:58 AM