Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/inference/set-up-ollama.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ When either the Ollama CLI or running daemon is below `0.32.9`, the wizard displ
Older versions can return tool calls as message text instead of structured tool calls, which causes onboarding validation to stop.
The wizard checks `ollama --version` and `/api/version` on port `11434` independently, so the entry appears when either side is stale.
If NemoClaw detects an installed CLI or local running daemon but cannot read its version, onboarding uses the upgrade path instead of reusing it.
On WSL with mirrored networking, Docker Desktop can expose a Windows-host Ollama daemon on the WSL loopback address.
When NemoClaw confirms this topology, it reuses that daemon and does not offer the WSL Linux upgrade, which cannot replace a Windows install.
Upgrade Ollama on Windows instead.

On macOS, the wizard uses `brew upgrade ollama` for the platform upgrade path.
On Linux, the wizard uses the official `https://ollama.com/install.sh` path and asks it for `0.32.9` by name when the installed binary is stale, because the version the installer calls latest is below the minimum on some hosts.
Expand Down
2 changes: 1 addition & 1 deletion src/lib/inference/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ export function probeVllmModels(
// `{ "models": [...] }`. An empty array is fine — that just means no models
// pulled yet — but a body that doesn't parse as JSON-with-array-`models` did
// not come from Ollama and the probe should not call it healthy. (#4275)
function isValidOllamaTagsResponseBody(body: string): boolean {
export function isValidOllamaTagsResponseBody(body: string): boolean {
if (!body) return false;
try {
const parsed = JSON.parse(body);
Expand Down
2 changes: 2 additions & 0 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ const {
} = require("./onboard/ollama-install-menu");
const {
detectInferenceProviderHostState,
detectWindowsDaemonOnWslLoopback,
}: typeof import("./onboard/provider-host-state") = require("./onboard/provider-host-state");
const {
ensureOllamaAuthProxy,
Expand Down Expand Up @@ -3424,6 +3425,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
...reasoningMode.compatibleEndpointReasoningConfigureDeps,
...reasoningMode.compatibleEndpointReasoningClearDeps,
repairLocalInferenceSystemdOverrideOrExit,
detectWindowsDaemonOnWslLoopback,
isNonInteractive,
getOpenshellBinary,
needsBedrockRuntimeAdapter: (providerName, url) => providerName === "compatible-anthropic-endpoint" && bedrockRuntimeOnboard.needsBedrockRuntimeAdapter(url),
Expand Down
20 changes: 20 additions & 0 deletions src/lib/onboard/local-inference-topology.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,26 @@ describe("repairLocalInferenceSystemdOverrideOrExit (#6760)", () => {
});
}

it("skips Linux service repair for a mirrored Windows daemon on WSL loopback (#9300)", () => {
mockedFindReachableHost.mockReturnValue("127.0.0.1");
mockedValidateModel.mockReturnValue({ ok: true });
mockedApplyRuntimeContext.mockReturnValue({ ok: true });

repairLocalInferenceSystemdOverrideOrExit({
provider: "ollama-local",
model: recordedModel,
contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
isNonInteractive,
detectWindowsDaemonOnWslLoopbackImpl: () => true,
});

// The recorded route points at the Windows daemon, which has no Linux
// service to repair. Model warm-up and validation still run.
expect(mockedEnsureSystemdOverride).not.toHaveBeenCalled();
expect(mockedValidateModel).toHaveBeenCalledWith(recordedModel);
expect(mockedApplyRuntimeContext).toHaveBeenCalled();
});

function expectFailure(run: () => void, message: string): void {
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
const exit = vi.spyOn(process, "exit").mockImplementation((code) => {
Expand Down
51 changes: 46 additions & 5 deletions src/lib/onboard/local-inference-topology.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,37 @@ import {
import { type ContainerRuntime, containerCanReachHostLoopback } from "../platform";
import { ensureOllamaLoopbackSystemdOverride } from "./ollama-systemd";

type TopologyRunCapture = (args: string[], options?: { ignoreError?: boolean }) => string;

/**
* True when the daemon answering WSL loopback is the Windows host's own
* Ollama.
*
* Mirrored WSL networking shares one loopback interface between Windows and
* the distro, so a single process owns `:11434` for both. `127.0.0.1` and
* `host.docker.internal` therefore cannot reach two different daemons, and a
* valid Ollama answer through the Windows-side probe identifies the process
* that loopback discovery already selected. Neither the Linux installer nor
* Linux service management applies to that daemon (#9300).
*/
export function isWindowsDaemonOnWslLoopback(input: {
isWsl: boolean;
ollamaHost: string | null;
windowsOllamaReachable: boolean;
runCapture: TopologyRunCapture;
}): boolean {
if (!input.isWsl || input.ollamaHost !== "127.0.0.1" || !input.windowsOllamaReachable) {
return false;
}
return (
input
.runCapture(["wslinfo", "--networking-mode"], {
ignoreError: true,
})
.trim() === "mirrored"
);
}

export function getContainerRuntime(): ContainerRuntime {
return detectContainerRuntimeFromDockerInfo();
}
Expand Down Expand Up @@ -156,6 +187,9 @@ export interface RepairLocalInferenceSystemdOverrideOptions {
model: string | null | undefined;
contextWindowFloor: number;
isNonInteractive: () => boolean;
/** Resolve the recorded route's daemon topology. Defaults to false, which
* keeps Linux service repair; `onboard.ts` wires the real detector. */
detectWindowsDaemonOnWslLoopbackImpl?: () => boolean;
}

function failOllamaResumeRepair(message: string): never {
Expand All @@ -173,11 +207,18 @@ export function repairLocalInferenceSystemdOverrideOrExit(
const { provider, model, isNonInteractive } = options;
if (provider !== "ollama-local") return;
const contextWindowFloor = resolveOllamaContextWindowFloor(options.contextWindowFloor);
const state = ensureOllamaLoopbackSystemdOverride({ isNonInteractive, contextWindowFloor });
if (state === "failed") {
failOllamaResumeRepair(
"Ollama systemd restart did not recover after applying the loopback override.",
);
// A recorded `ollama-local` route carries no topology, so re-detect it. The
// Windows daemon that mirrored WSL networking exposes on loopback has no
// Linux service to repair, and touching a residual `ollama.service` would
// ask for sudo and could take the port from the recorded route (#9300).
const detectTopology = options.detectWindowsDaemonOnWslLoopbackImpl ?? (() => false);
if (!detectTopology()) {
const state = ensureOllamaLoopbackSystemdOverride({ isNonInteractive, contextWindowFloor });
if (state === "failed") {
failOllamaResumeRepair(
"Ollama systemd restart did not recover after applying the loopback override.",
);
}
}
if (contextWindowFloor <= MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW) return;
if (!model) {
Expand Down
1 change: 1 addition & 0 deletions src/lib/onboard/machine/core-flow-phases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ function createPhases(
},
deps: {
checkGatewayRouteCompatibility: () => ({ ok: true }),
detectWindowsDaemonOnWslLoopback: () => false,
preflightGatewayRouteDiscovery: () => ({
ok: true,
requiredModel: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe("handleProviderInferenceState Ollama context resume (#6760)", () => {
model: "qwen3.5:35b",
contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
isNonInteractive: deps.isNonInteractive,
detectWindowsDaemonOnWslLoopbackImpl: deps.detectWindowsDaemonOnWslLoopback,
});
expect(calls.setupNim).not.toHaveBeenCalled();
expect(routeReady).toHaveBeenCalledWith("nemoclaw", "ollama-local", "qwen3.5:35b");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ function createDeps() {
};
const deps: Options["deps"] = {
checkGatewayRouteCompatibility: calls.checkGatewayRouteCompatibility,
detectWindowsDaemonOnWslLoopback: () => false,
preflightGatewayRouteDiscovery: calls.preflightGatewayRouteDiscovery,
getSandboxRecoveryAuthority: (): "missing" => "missing",
withGatewayRouteMutationLock: async (_gatewayName, operation) => await operation(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export function createDeps(
},
clearCompatibleEndpointReasoningEffort: () => null,
repairLocalInferenceSystemdOverrideOrExit: calls.repair,
detectWindowsDaemonOnWslLoopback: () => false,
isNonInteractive: () => true,
getOpenshellBinary: () => "/usr/bin/openshell",
needsBedrockRuntimeAdapter: () => false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,7 @@ describe("handleProviderInferenceState", () => {
model: "llama3.1",
contextWindowFloor: 16_384,
isNonInteractive: deps.isNonInteractive,
detectWindowsDaemonOnWslLoopbackImpl: deps.detectWindowsDaemonOnWslLoopback,
});
expect(calls.repairEvent).toHaveBeenCalledWith("state.repair.completed", {
state: "provider_selection",
Expand Down
8 changes: 7 additions & 1 deletion src/lib/onboard/machine/handlers/provider-inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ export interface ProviderInferenceStateOptions<Gpu, Agent, Host> {
repairLocalInferenceSystemdOverrideOrExit(
options: RepairLocalInferenceSystemdOverrideOptions,
): void;
detectWindowsDaemonOnWslLoopback(): boolean;
isNonInteractive(): boolean;
getOpenshellBinary(): string;
needsBedrockRuntimeAdapter(provider: string, endpointUrl: string | null): boolean;
Expand Down Expand Up @@ -846,7 +847,9 @@ async function configureResumeReasoning(

type LocalInferenceRepairDeps = Pick<
ProviderInferenceStateOptions<unknown, unknown, unknown>["deps"],
"recordRepairEvent" | "repairLocalInferenceSystemdOverrideOrExit"
| "recordRepairEvent"
| "repairLocalInferenceSystemdOverrideOrExit"
| "detectWindowsDaemonOnWslLoopback"
>;

async function repairResumedLocalInference(
Expand All @@ -860,6 +863,9 @@ async function repairResumedLocalInference(
model,
contextWindowFloor: getOllamaContextWindowFloorForAgent(agentName(agent)),
isNonInteractive: () => false,
// A recorded route carries no daemon topology, so resume re-detects it and
// skips Linux service repair for a mirrored Windows-host daemon (#9300).
detectWindowsDaemonOnWslLoopbackImpl: deps.detectWindowsDaemonOnWslLoopback,
};
if (provider !== "ollama-local") {
deps.repairLocalInferenceSystemdOverrideOrExit(options);
Expand Down
37 changes: 37 additions & 0 deletions src/lib/onboard/ollama-install-menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,43 @@ describe("resolveOllamaInstallMenuEntry", () => {
expect(result.entry).toBeNull();
});

it("does not flag the daemon as upgradable when mirrored WSL networking answers the Windows host on loopback (#9300)", () => {
const result = resolveOllamaInstallMenuEntry({
hasOllama: true,
ollamaRunning: true,
hasWindowsOllama: true,
windowsHostOllamaSupported: true,
ollamaHost: "127.0.0.1",
windowsDaemonOnWslLoopback: true,
installedOllamaVersion: "0.32.5",
runningOllamaVersion: "0.32.5",
platform: "linux",
isWsl: true,
});
expect(result.hasUpgradableOllama).toBe(false);
expect(result.binaryNeedsUpgrade).toBe(false);
expect(result.entry).toBeNull();
});

it("still flags a stale WSL-local daemon on loopback as upgradable (#9300)", () => {
const result = resolveOllamaInstallMenuEntry({
hasOllama: true,
ollamaRunning: true,
hasWindowsOllama: true,
windowsHostOllamaSupported: true,
ollamaHost: "127.0.0.1",
windowsDaemonOnWslLoopback: false,
installedOllamaVersion: "0.32.5",
runningOllamaVersion: "0.32.5",
platform: "linux",
isWsl: true,
});
expect(result.hasUpgradableOllama).toBe(true);
expect(result.entry?.label).toBe(
`Upgrade Ollama (WSL Linux) — upgrade running daemon 0.32.5 to ≥ ${MIN_OLLAMA_VERSION}`,
);
});

it("omits the entry when only Windows-host Ollama is present", () => {
const result = resolveOllamaInstallMenuEntry({
hasOllama: false,
Expand Down
18 changes: 16 additions & 2 deletions src/lib/onboard/ollama-install-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export interface OllamaInstallMenuInput {
* helper skips the daemon-version gate.
* Null when no daemon is running locally. */
ollamaHost?: string | null;
/** Whether the daemon answering WSL loopback is the Windows host's own
* Ollama. Mirrored WSL networking shares the loopback interface, so that
* daemon resolves as `127.0.0.1` rather than `host.docker.internal`
* (#9300). Defaults to false. */
windowsDaemonOnWslLoopback?: boolean;
/** Override for tests. Defaults to a live `ollama --version` probe. */
installedOllamaVersion?: string | null;
/** Override for tests. Defaults to a live `/api/version` probe on the
Expand Down Expand Up @@ -138,7 +143,14 @@ export function resolveOllamaInstallMenuEntry(
// 127.0.0.1/localhost. A Windows-host daemon reached via
// `host.docker.internal` is handled by separate menu entries
// (`install-windows-ollama` / `start-windows-ollama`).
const daemonProbeApplies = input.ollamaRunning && isLocalOllamaHost(input.ollamaHost);
// Mirrored WSL networking answers the Windows host's daemon on
// `127.0.0.1`, so the `host.docker.internal` check above does not recognize
// it. The Linux installer can replace neither that daemon nor the Windows
// `ollama` the WSL PATH exposes through interop, so both version gates stay
// off for this topology (#9300).
const windowsDaemonOnWslLoopback = input.windowsDaemonOnWslLoopback === true;
const daemonProbeApplies =
input.ollamaRunning && isLocalOllamaHost(input.ollamaHost) && !windowsDaemonOnWslLoopback;
const runningOllamaVersion =
input.runningOllamaVersion !== undefined
? input.runningOllamaVersion
Expand All @@ -157,7 +169,9 @@ export function resolveOllamaInstallMenuEntry(
// installed binary meets the floor. A stale daemon without a local binary
// still needs the installer to provide one.
const binaryNeedsUpgrade =
!installedBinaryMeetsMinimum && (input.hasOllama || daemonNeedsUpgrade);
!windowsDaemonOnWslLoopback &&
!installedBinaryMeetsMinimum &&
(input.hasOllama || daemonNeedsUpgrade);
const hasUpgradableOllama = binaryNeedsUpgrade || daemonNeedsUpgrade;
// A Windows-host install only covers the local-inference need when the
// sandbox can route to it. Under a container runtime without that routing,
Expand Down
Loading
Loading