diff --git a/agents/hermes/config/build-env.ts b/agents/hermes/config/build-env.ts
index 14f06a0e832..806ec436885 100644
--- a/agents/hermes/config/build-env.ts
+++ b/agents/hermes/config/build-env.ts
@@ -9,6 +9,9 @@ import { isObjectRecord } from "./object-record.ts";
export type HermesWebSearchProvider = "tavily";
+/** Minimum context window Hermes accepts for generated configuration. */
+export const MIN_HERMES_CONTEXT_WINDOW = 64_000;
+
export type HermesBuildSettings = {
model: string;
baseUrl: string;
@@ -29,6 +32,7 @@ export type HermesBuildSettings = {
};
};
+/** Read and validate the environment consumed by the Hermes config generator. */
export function readHermesBuildSettings(env: NodeJS.ProcessEnv): HermesBuildSettings {
const model = readRequiredEnv(env, "NEMOCLAW_MODEL");
const baseUrl = readRequiredEnv(env, "NEMOCLAW_INFERENCE_BASE_URL");
@@ -50,14 +54,24 @@ export function readHermesBuildSettings(env: NodeJS.ProcessEnv): HermesBuildSett
};
}
-// Parse NEMOCLAW_CONTEXT_WINDOW as a positive integer of tokens. Empty, absent,
-// or malformed values return null so the generated config omits context_length
-// and Hermes keeps auto-detecting from the endpoint's /v1/models. See #6177.
+/**
+ * Parse `NEMOCLAW_CONTEXT_WINDOW` for Hermes config generation.
+ *
+ * Empty, absent, or malformed values return null so Hermes keeps auto-detecting
+ * from the endpoint's `/v1/models`; explicit values below the Hermes floor fail
+ * before writing an unusable config. See #6177.
+ */
function readContextWindow(env: NodeJS.ProcessEnv): number | null {
const raw = (env.NEMOCLAW_CONTEXT_WINDOW || "").trim();
if (!/^[1-9][0-9]*$/.test(raw)) return null;
const parsed = Number(raw);
- return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) return null;
+ if (parsed < MIN_HERMES_CONTEXT_WINDOW) {
+ throw new Error(
+ `Hermes NEMOCLAW_CONTEXT_WINDOW must be at least ${MIN_HERMES_CONTEXT_WINDOW} tokens, got ${parsed}`,
+ );
+ }
+ return parsed;
}
function readWebSearchProvider(env: NodeJS.ProcessEnv): HermesWebSearchProvider | null {
diff --git a/docs/inference/configure-model-limits.mdx b/docs/inference/configure-model-limits.mdx
index d10fa761870..9f18cbdb699 100644
--- a/docs/inference/configure-model-limits.mdx
+++ b/docs/inference/configure-model-limits.mdx
@@ -45,7 +45,7 @@ Hermes accepts `NEMOCLAW_CONTEXT_WINDOW` as its model-limit override.
| Variable | Values | Default |
|---|---|---|
-| `NEMOCLAW_CONTEXT_WINDOW` | Positive integer in tokens | Unset so Hermes auto-detects |
+| `NEMOCLAW_CONTEXT_WINDOW` | Positive integer, at least `64000` tokens | Unset so Hermes auto-detects |
```bash
export NEMOCLAW_CONTEXT_WINDOW=65536
@@ -53,7 +53,13 @@ $$nemoclaw onboard
```
When onboarding resolves a valid value, NemoClaw writes it as `model.context_length` in `/sandbox/.hermes/config.yaml`.
-If no explicit or probed value is available, the field remains unset so Hermes can auto-detect it from the endpoint.
+For non-Ollama endpoints, the field remains unset when no explicit or probed value is available so Hermes can auto-detect it from the endpoint.
+When NemoClaw starts Local Ollama on macOS or Linux, it requests at least `64000` tokens.
+Fresh onboarding then verifies the loaded model's actual `context_length` through `/api/ps`.
+Resumed onboarding and sandbox rebuilds warm the exact recorded Ollama model and repeat this verification before reusing its route.
+When `NEMOCLAW_CONTEXT_WINDOW` is larger than `64000`, the Ollama runtime must provide at least that larger value.
+Onboarding stops before building the sandbox when Ollama reports a smaller, missing, or malformed value and shows the required `OLLAMA_CONTEXT_LENGTH` value for restarting Ollama.
+Setting `NEMOCLAW_CONTEXT_WINDOW` does not raise the Ollama daemon's runtime context or bypass this check.
diff --git a/docs/inference/set-up-ollama.mdx b/docs/inference/set-up-ollama.mdx
index b9d82f71e14..8dba0446a7c 100644
--- a/docs/inference/set-up-ollama.mdx
+++ b/docs/inference/set-up-ollama.mdx
@@ -85,6 +85,15 @@ If the validation probe times out, NemoClaw retries with a larger timeout before
Each Ollama-backed OpenClaw passthrough checks whether the selected model is still loaded and sends a bounded warm-up request when necessary.
+
+Hermes requires at least `64000` tokens, so NemoClaw requests that host-side context length when it starts Ollama on macOS or Linux.
+Fresh onboarding verifies after model warm-up that `/api/ps` reports at least `64000` for the loaded model.
+Resumed onboarding and sandbox rebuilds warm the exact recorded Ollama model and repeat this verification before reusing its route.
+When `NEMOCLAW_CONTEXT_WINDOW` is unset, NemoClaw writes the verified runtime value as `model.context_length` in `/sandbox/.hermes/config.yaml`.
+An explicit `NEMOCLAW_CONTEXT_WINDOW` must be at least `64000`; NemoClaw writes that value only when the loaded model reports at least the same context length.
+If an existing or unmanaged daemon reports less, omits the value, or returns a malformed value, onboarding stops before building the sandbox and shows the required `OLLAMA_CONTEXT_LENGTH` value for restarting Ollama.
+
+
## Use Windows-Host Ollama from WSL
When NemoClaw runs in WSL, the provider menu can offer these Windows-host actions:
@@ -201,6 +210,12 @@ It does not make the larger models usable on N1X, reject an explicitly selected
When Ollama reports a context length below `16384` and `NEMOCLAW_CONTEXT_WINDOW` is unset, NemoClaw writes a `contextWindow` of `16384` so the agent prompt and tool definitions fit better than the stock daemon default.
+
+When `NEMOCLAW_CONTEXT_WINDOW` is unset, NemoClaw writes a valid loaded-model context length of at least `64000` as `model.context_length`.
+When you set a larger value explicitly, the loaded model must report at least that value before NemoClaw writes the explicit value.
+When the runtime value is lower or cannot be verified, onboarding stops and tells you which `OLLAMA_CONTEXT_LENGTH` value to use when restarting the host daemon before retrying.
+
+
If the initial validation times out during a cold load, NemoClaw retries once with a 300-second probe budget.
This retry also applies to tight-VRAM hosts where model warm-up can spill from GPU to CPU.
diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx
index 26f092983ee..b03ee45c2dd 100644
--- a/docs/reference/commands.mdx
+++ b/docs/reference/commands.mdx
@@ -3320,6 +3320,7 @@ Hermes-specific onboarding configuration:
| `NEMOCLAW_NOUS_AUTH_METHOD` | same as `NEMOCLAW_HERMES_AUTH_METHOD` | Nous-specific alias for Hermes Provider authentication selection. |
| `NEMOCLAW_HERMES_TOOL_GATEWAYS` | comma-separated list | Selects managed Hermes tool gateways in non-interactive onboarding. Valid values are `nous-web`, `nous-image`, `nous-audio`, `nous-browser`, and `nous-code`; the `nous-` prefix is optional. Unknown values fail before sandbox creation. |
| `NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS` | comma-separated list | Back-compatible alias for `NEMOCLAW_HERMES_TOOL_GATEWAYS`. |
+| `NEMOCLAW_CONTEXT_WINDOW` | positive integer, at least `64000` tokens | Overrides `model.context_length` in the built Hermes config. Fresh and resumed Local Ollama onboarding, including sandbox rebuilds, must verify a loaded runtime context at least as large as this value. |
| `NEMOCLAW_EXTRA_PLACEHOLDER_KEYS` | whitespace- or comma-separated list of upper-snake env keys | Adds operator-supplied OpenShell provider rows so per-profile credentials such as `TELEGRAM_BOT_TOKEN_AGENT_A` flow through the same out-of-process placeholder injection that the canonical channel tokens use, instead of being baked into each Hermes profile `.env` as raw text. Refer to [Extra placeholder keys](#extra-placeholder-keys) for the entry shape and validation rules. |
@@ -3432,7 +3433,7 @@ Set them before running `$$nemoclaw onboard`.
| Variable | Format | Effect |
|----------|--------|--------|
| `NEMOCLAW_YES` | `1` to enable | Auto-accepts confirmation prompts (`--yes` equivalent) including in helpers like the Ollama proxy auth setup, but does not accept managed-vLLM storage warnings. |
-| `NEMOCLAW_OLLAMA_NO_AUTOSTART` | `1` to enable | Skips the wizard's eager Ollama auto-start during inference-provider selection (equivalent to passing `--no-ollama-autostart`). When set and Ollama is not running on `localhost:11434`, the `$$nemoclaw onboard` Local Ollama path prints a warning and selects the default fallback model instead of spawning `ollama serve`. The flag covers only the provider-selection step; later setup steps (auth proxy, validation, model warm) still expect a reachable Ollama. On Linux hosts with a systemd Ollama unit, the loopback-override path may still restart the daemon before this gate runs. |
+| `NEMOCLAW_OLLAMA_NO_AUTOSTART` | `1` to enable | Skips the wizard's eager Ollama auto-start during inference-provider selection (equivalent to passing `--no-ollama-autostart`). When set and Ollama is not running on `localhost:11434`, an agent that uses the legacy `16384`-token context floor, currently OpenClaw, prints a warning and selects the default fallback model instead of spawning `ollama serve`. An agent that requires a larger verified runtime context, currently Hermes at `64000` tokens, returns to interactive provider selection or exits when the Ollama provider is pinned or onboarding is non-interactive. The flag covers only the provider-selection step; later setup steps (auth proxy, validation, model warm) still expect a reachable Ollama. On Linux hosts with a systemd Ollama unit, the loopback-override path may still restart the daemon before this gate runs. |
| `NEMOCLAW_NON_INTERACTIVE_SUDO_MODE` | `prompt` or empty/unset | When set to `prompt`, allows non-interactive onboarding to use prompt-capable `sudo` for host setup steps that require elevation, which can ask for a password. Empty/unset is the default and uses `sudo -n`, which fails instead of asking for a password. Any other value is rejected. |
| `NEMOCLAW_NO_EXPRESS` | `1` to enable | Installer-only. Skips the DGX Spark, DGX Station, and Windows WSL express install prompt and continues with the normal interactive onboarding flow. |
| `NEMOCLAW_EXPERIMENTAL` | `1` to enable | Surfaces experimental providers and flows in onboarding. |
diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx
index 07cf413a28d..09fb2a348d6 100644
--- a/docs/reference/troubleshooting.mdx
+++ b/docs/reference/troubleshooting.mdx
@@ -2444,6 +2444,9 @@ If the process exists but the endpoint is unreachable, use the restart action wh
### Ollama inference fails or hangs in WSL
Ollama configures context length based on your hardware.
+
+
+
On some GPUs (for example RTX 3500), the default context length is not sufficient for OpenClaw.
During onboarding, NemoClaw raises loaded-model context lengths below `16384` to `16384` when `NEMOCLAW_CONTEXT_WINDOW` is unset.
Set the variable manually when you need a different value or when you run Ollama outside the managed onboarding path.
@@ -2454,6 +2457,26 @@ pkill -f 'ollama serve'
OLLAMA_CONTEXT_LENGTH=16384 ollama serve
```
+
+
+
+
+Hermes requires at least `64000` tokens.
+During onboarding, NemoClaw verifies the loaded model's actual `context_length` through Ollama's `/api/ps` endpoint.
+Resumed onboarding and sandbox rebuilds warm the exact recorded Ollama model and repeat this check before reusing its route.
+Resume stops when that recorded model is missing, Ollama is unreachable, model warm-up fails, or the runtime context cannot be verified.
+If you set `NEMOCLAW_CONTEXT_WINDOW` above `64000`, the loaded model must provide at least that larger value.
+If the runtime value is too small, missing, or malformed, onboarding stops before sandbox creation and asks you to restart the host daemon with the required context length.
+`NEMOCLAW_CONTEXT_WINDOW` controls Hermes prompt budgeting; it does not change the Ollama daemon or bypass this runtime check.
+Force a larger context length:
+
+```bash
+pkill -f 'ollama serve'
+OLLAMA_CONTEXT_LENGTH=64000 ollama serve
+```
+
+
+
Verify that Ollama inference works:
```bash
@@ -2470,11 +2493,24 @@ sudo systemctl status ollama
If it is active, stop it first, then start with the custom context length:
+
+
```bash
sudo systemctl stop ollama
OLLAMA_CONTEXT_LENGTH=16384 ollama serve
```
+
+
+
+
+```bash
+sudo systemctl stop ollama
+OLLAMA_CONTEXT_LENGTH=64000 ollama serve
+```
+
+
+
For additional troubleshooting, refer to the [Windows Setup](../get-started/prerequisites/windows-preparation) page.
For first-time OpenClaw setup, refer to the [Quickstart](../get-started/quickstart).
diff --git a/src/lib/inference/local.ts b/src/lib/inference/local.ts
index 1b5df502622..b7529ca5614 100644
--- a/src/lib/inference/local.ts
+++ b/src/lib/inference/local.ts
@@ -29,10 +29,16 @@ import {
OLLAMA_MODEL_REGISTRY,
SMALLEST_OLLAMA_MODEL_TAG,
} from "./ollama-model-registry";
-import type { OllamaRuntimeModelStatus } from "./ollama-runtime-context";
+import type {
+ ApplyOllamaRuntimeContextWindowOptions,
+ ApplyOllamaRuntimeContextWindowResult,
+ OllamaRuntimeModelStatus,
+} from "./ollama-runtime-context";
import {
applyOllamaRuntimeContextWindow as applyOllamaRuntimeContextWindowWithHost,
+ getOllamaContextWindowFloorForAgent,
MAX_AUTODETECTED_OLLAMA_CONTEXT_WINDOW,
+ MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
parsePositiveInteger,
probeOllamaRuntimeModelStatus as probeOllamaRuntimeModelStatusWithHost,
resetOllamaRuntimeContextWindowAutoState,
@@ -876,7 +882,12 @@ export function parseOllamaTags(output: string | null | undefined): string[] {
}
}
-export { MAX_AUTODETECTED_OLLAMA_CONTEXT_WINDOW, parsePositiveInteger };
+export {
+ getOllamaContextWindowFloorForAgent,
+ MAX_AUTODETECTED_OLLAMA_CONTEXT_WINDOW,
+ MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ parsePositiveInteger,
+};
export function probeOllamaRuntimeModelStatus(
model: string,
@@ -900,8 +911,12 @@ export function resolveOllamaRuntimeContextWindow(
export { resetOllamaRuntimeContextWindowAutoState };
-export function applyOllamaRuntimeContextWindow(selectedModel: string): void {
- applyOllamaRuntimeContextWindowWithHost(selectedModel, getResolvedOllamaHost);
+/** Apply Ollama runtime context-window adoption using the resolved local host. */
+export function applyOllamaRuntimeContextWindow(
+ selectedModel: string,
+ options: Pick = {},
+): ApplyOllamaRuntimeContextWindowResult {
+ return applyOllamaRuntimeContextWindowWithHost(selectedModel, getResolvedOllamaHost, options);
}
export function applyVllmRuntimeContextWindow(
diff --git a/src/lib/inference/ollama-runtime-context.test.ts b/src/lib/inference/ollama-runtime-context.test.ts
index bd14efb69de..1443534eb01 100644
--- a/src/lib/inference/ollama-runtime-context.test.ts
+++ b/src/lib/inference/ollama-runtime-context.test.ts
@@ -3,9 +3,12 @@
import { afterEach, describe, expect, it } from "vitest";
+import { OLLAMA_PORT } from "../core/ports";
import {
applyOllamaRuntimeContextWindow,
+ getOllamaContextWindowFloorForAgent,
MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW,
+ MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
parseOllamaRuntimeContextLength,
probeOllamaRuntimeModelStatus,
resetOllamaRuntimeContextWindowAutoState,
@@ -14,6 +17,18 @@ import {
const getOllamaHost = () => "127.0.0.1";
+type OllamaRuntimeContextFailure = Extract<
+ ReturnType,
+ { ok: false }
+>;
+
+function expectOllamaRuntimeContextFailure(
+ result: ReturnType,
+): OllamaRuntimeContextFailure {
+ expect(result.ok).toBe(false);
+ return result as OllamaRuntimeContextFailure;
+}
+
describe("Ollama runtime context helpers", () => {
afterEach(() => {
resetOllamaRuntimeContextWindowAutoState();
@@ -121,6 +136,156 @@ describe("Ollama runtime context helpers", () => {
expect(messages.some((m) => m.includes("Raising Ollama runtime context window"))).toBe(true);
});
+ it("keeps the OpenClaw Ollama floor at 16384 and requires 64000 for Hermes", () => {
+ expect(getOllamaContextWindowFloorForAgent(null)).toBe(MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW);
+ expect(getOllamaContextWindowFloorForAgent("openclaw")).toBe(
+ MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW,
+ );
+ expect(getOllamaContextWindowFloorForAgent("hermes")).toBe(MIN_HERMES_OLLAMA_CONTEXT_WINDOW);
+
+ const env: NodeJS.ProcessEnv = {};
+ const messages: string[] = [];
+ const result = applyOllamaRuntimeContextWindow("llama3.2:1b", getOllamaHost, {
+ env,
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ logger: {
+ log: (message: string) => messages.push(message),
+ warn: (message: string) => messages.push(message),
+ },
+ runCaptureImpl: () =>
+ JSON.stringify({
+ models: [{ name: "llama3.2:1b", context_length: 16_384, processor: "100% GPU" }],
+ }),
+ });
+
+ const failure = expectOllamaRuntimeContextFailure(result);
+ expect(failure.message).toContain("context_length=16384");
+ expect(failure.message).toContain("'llama3.2:1b'");
+ expect(failure.message).toContain("required 64000-token window");
+ expect(failure.message).toContain("OLLAMA_CONTEXT_LENGTH=64000");
+ expect(env.NEMOCLAW_CONTEXT_WINDOW).toBeUndefined();
+ expect(messages.some((m) => m.includes("Raising Ollama runtime context window"))).toBe(false);
+ });
+
+ it("does not let an explicit prompt budget hide a below-floor Hermes daemon", () => {
+ const env: NodeJS.ProcessEnv = { NEMOCLAW_CONTEXT_WINDOW: "64000" };
+ const result = applyOllamaRuntimeContextWindow("llama3.2:1b", getOllamaHost, {
+ env,
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ logger: { log: () => {}, warn: () => {} },
+ runCaptureImpl: () =>
+ JSON.stringify({
+ models: [{ name: "llama3.2:1b", context_length: 16_384 }],
+ }),
+ });
+
+ const failure = expectOllamaRuntimeContextFailure(result);
+ expect(failure.message).toContain("context_length=16384");
+ expect(failure.message).toContain("OLLAMA_CONTEXT_LENGTH=64000");
+ expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("64000");
+ });
+
+ it.each([
+ ["no runtime response", ""],
+ ["an unloaded model", JSON.stringify({ models: [] })],
+ [
+ "a missing context length",
+ JSON.stringify({ models: [{ name: "llama3.2:1b", processor: "100% GPU" }] }),
+ ],
+ [
+ "a malformed context length",
+ JSON.stringify({ models: [{ name: "llama3.2:1b", context_length: "bogus" }] }),
+ ],
+ ])("fails closed for Hermes with %s", (_caseName, output) => {
+ const env: NodeJS.ProcessEnv = {};
+ const result = applyOllamaRuntimeContextWindow("llama3.2:1b", getOllamaHost, {
+ env,
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ logger: { log: () => {}, warn: () => {} },
+ runCaptureImpl: () => output,
+ });
+
+ const failure = expectOllamaRuntimeContextFailure(result);
+ expect(failure.message).toContain("did not report a valid runtime context_length");
+ expect(failure.message).toContain("OLLAMA_CONTEXT_LENGTH=64000");
+ expect(env.NEMOCLAW_CONTEXT_WINDOW).toBeUndefined();
+ });
+
+ it("fails closed against Windows-host Ollama when runtime context is missing (#6760)", () => {
+ const env: NodeJS.ProcessEnv = {};
+ let probeCommand: readonly string[] = [];
+ const result = applyOllamaRuntimeContextWindow("llama3.2:1b", () => "host.docker.internal", {
+ env,
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ logger: { log: () => {}, warn: () => {} },
+ runCaptureImpl: (command) => {
+ probeCommand = command;
+ return JSON.stringify({ models: [{ name: "llama3.2:1b" }] });
+ },
+ });
+
+ expect(probeCommand).toContain(`http://host.docker.internal:${OLLAMA_PORT}/api/ps`);
+ const failure = expectOllamaRuntimeContextFailure(result);
+ expect(failure.message).toContain("did not report a valid runtime context_length");
+ expect(failure.message).toContain("OLLAMA_CONTEXT_LENGTH=64000");
+ expect(env.NEMOCLAW_CONTEXT_WINDOW).toBeUndefined();
+ });
+
+ it.each([64_000, 131_072])("accepts a Hermes daemon reporting context_length=%i", (context) => {
+ const env: NodeJS.ProcessEnv = {};
+ const result = applyOllamaRuntimeContextWindow("llama3.2:1b", getOllamaHost, {
+ env,
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ logger: { log: () => {}, warn: () => {} },
+ runCaptureImpl: () =>
+ JSON.stringify({ models: [{ name: "llama3.2:1b", context_length: context }] }),
+ });
+
+ expect(result).toEqual({ ok: true });
+ expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe(String(context));
+ });
+
+ it("requires the daemon to satisfy an explicit Hermes context above the agent floor", () => {
+ const env: NodeJS.ProcessEnv = { NEMOCLAW_CONTEXT_WINDOW: "131072" };
+ const result = applyOllamaRuntimeContextWindow("llama3.2:1b", getOllamaHost, {
+ env,
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ logger: { log: () => {}, warn: () => {} },
+ runCaptureImpl: () =>
+ JSON.stringify({ models: [{ name: "llama3.2:1b", context_length: 64_000 }] }),
+ });
+
+ const failure = expectOllamaRuntimeContextFailure(result);
+ expect(failure.message).toContain("required 131072-token window");
+ expect(failure.message).toContain("OLLAMA_CONTEXT_LENGTH=131072");
+ expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("131072");
+ });
+
+ it("clears only stale auto-detected state when strict Hermes validation fails", () => {
+ const env: NodeJS.ProcessEnv = {};
+ const logger = { log: () => {}, warn: () => {} };
+
+ expect(
+ applyOllamaRuntimeContextWindow("llama3.2:1b", getOllamaHost, {
+ env,
+ logger,
+ runCaptureImpl: () =>
+ JSON.stringify({ models: [{ name: "llama3.2:1b", context_length: 32_768 }] }),
+ }),
+ ).toEqual({ ok: true });
+ expect(env.NEMOCLAW_CONTEXT_WINDOW).toBe("32768");
+
+ const result = applyOllamaRuntimeContextWindow("llama3.2:1b", getOllamaHost, {
+ env,
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ logger,
+ runCaptureImpl: () => JSON.stringify({ models: [] }),
+ });
+
+ expect(result.ok).toBe(false);
+ expect(env.NEMOCLAW_CONTEXT_WINDOW).toBeUndefined();
+ });
+
it("preserves a daemon-reported context window above the agent floor", () => {
const env: NodeJS.ProcessEnv = {};
const messages: string[] = [];
diff --git a/src/lib/inference/ollama-runtime-context.ts b/src/lib/inference/ollama-runtime-context.ts
index 884cea752d7..1202ad9067a 100644
--- a/src/lib/inference/ollama-runtime-context.ts
+++ b/src/lib/inference/ollama-runtime-context.ts
@@ -30,10 +30,14 @@ export interface OllamaRuntimeModelStatus {
export interface ApplyOllamaRuntimeContextWindowOptions {
env?: NodeJS.ProcessEnv;
+ /** Minimum usable context window for the selected agent. */
+ contextWindowFloor?: number;
logger?: Pick;
runCaptureImpl?: OllamaRuntimeRunCaptureFn;
}
+export type ApplyOllamaRuntimeContextWindowResult = { ok: true } | { ok: false; message: string };
+
// Four million tokens is intentionally above today's practical local-model
// context windows while still rejecting obviously broken daemon responses.
export const MAX_AUTODETECTED_OLLAMA_CONTEXT_WINDOW = 4_194_304;
@@ -41,11 +45,18 @@ export const MAX_AUTODETECTED_OLLAMA_CONTEXT_WINDOW = 4_194_304;
// Floor for auto-adopted runtime context windows. Ollama's stock daemon serves
// `num_ctx=4096` until OLLAMA_CONTEXT_LENGTH is set host-side, which cannot fit
// an agent base prompt + tool catalogue (~7.4 k tokens) plus a single user turn.
-// When the probed runtime length is below this floor and the user has not set
-// an explicit override, NemoClaw raises NEMOCLAW_CONTEXT_WINDOW to the floor so
-// downstream prompt budgeting reflects a workable window.
+// OpenClaw preserves the legacy prompt-budgeting fallback at this floor. Agents
+// with a higher floor must prove that the loaded daemon actually provides it.
export const MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW = 16_384;
+/**
+ * Hermes-specific Ollama floor.
+ *
+ * Keep this consumer-specific so OpenClaw's Local Ollama defaults stay
+ * unchanged while Hermes rejects model context windows below 64,000 tokens.
+ */
+export const MIN_HERMES_OLLAMA_CONTEXT_WINDOW = 64_000;
+
function normalizeOllamaModelName(value: unknown): string {
return String(value || "").trim();
}
@@ -64,6 +75,23 @@ export function hasExplicitContextWindow(value: unknown): boolean {
return String(value ?? "").trim() !== "";
}
+/** Resolve the minimum Ollama context window required by an agent name. */
+export function getOllamaContextWindowFloorForAgent(agentName: string | null | undefined): number {
+ return String(agentName ?? "")
+ .trim()
+ .toLowerCase() === "hermes"
+ ? MIN_HERMES_OLLAMA_CONTEXT_WINDOW
+ : MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW;
+}
+
+/** Normalize an optional agent floor, never returning less than the OpenClaw floor. */
+export function resolveOllamaContextWindowFloor(value: unknown): number {
+ const parsed = parsePositiveInteger(value);
+ return parsed && parsed > MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW
+ ? parsed
+ : MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW;
+}
+
/**
* Parse Ollama `/api/ps` `context_length` defensively.
*
@@ -185,13 +213,20 @@ export function resetOllamaRuntimeContextWindowAutoState(): void {
autoDetectedOllamaContextWindow = null;
}
+/**
+ * Adopt the loaded Ollama model's runtime context length. Agent floors above
+ * the legacy minimum are strict: the loaded daemon must report at least the
+ * required length even when `NEMOCLAW_CONTEXT_WINDOW` is explicitly set.
+ */
export function applyOllamaRuntimeContextWindow(
selectedModel: string,
getOllamaHost: () => string,
options: ApplyOllamaRuntimeContextWindowOptions = {},
-): void {
+): ApplyOllamaRuntimeContextWindowResult {
const env = options.env ?? process.env;
const logger = options.logger ?? console;
+ const contextWindowFloor = resolveOllamaContextWindowFloor(options.contextWindowFloor);
+ const strictRuntimeFloor = contextWindowFloor > MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW;
const currentContextWindow = env.NEMOCLAW_CONTEXT_WINDOW;
const currentIsPreviousAuto =
!!currentContextWindow &&
@@ -199,9 +234,9 @@ export function applyOllamaRuntimeContextWindow(
currentContextWindow === autoDetectedOllamaContextWindow;
const userContextWindow = currentIsPreviousAuto ? null : currentContextWindow;
- if (hasExplicitContextWindow(userContextWindow)) {
+ if (!strictRuntimeFloor && hasExplicitContextWindow(userContextWindow)) {
logger.log(` ℹ Keeping configured context window: ${userContextWindow} tokens`);
- return;
+ return { ok: true };
}
const runtimeStatus = probeOllamaRuntimeModelStatus(
@@ -212,26 +247,89 @@ export function applyOllamaRuntimeContextWindow(
if (runtimeStatus.contextLengthWarning) {
logger.warn(` ⚠ ${runtimeStatus.contextLengthWarning}`);
}
+
+ if (strictRuntimeFloor) {
+ const configuredContextWindow = hasExplicitContextWindow(userContextWindow)
+ ? parsePositiveInteger(userContextWindow)
+ : null;
+ const requiredContextWindow = Math.max(
+ contextWindowFloor,
+ configuredContextWindow ?? contextWindowFloor,
+ );
+ const clearPreviousAuto = () => {
+ if (!currentIsPreviousAuto) return;
+ delete env.NEMOCLAW_CONTEXT_WINDOW;
+ autoDetectedOllamaContextWindow = null;
+ };
+ const remediation =
+ `Configure or restart the host Ollama daemon with OLLAMA_CONTEXT_LENGTH=${requiredContextWindow}, ` +
+ "then rerun onboarding.";
+
+ if (hasExplicitContextWindow(userContextWindow) && !configuredContextWindow) {
+ clearPreviousAuto();
+ return {
+ ok: false,
+ message:
+ `NEMOCLAW_CONTEXT_WINDOW must be a positive integer at least ${contextWindowFloor} ` +
+ `for this agent. ${remediation}`,
+ };
+ }
+ if (configuredContextWindow !== null && configuredContextWindow < contextWindowFloor) {
+ clearPreviousAuto();
+ return {
+ ok: false,
+ message:
+ `NEMOCLAW_CONTEXT_WINDOW=${configuredContextWindow} is below this agent's required ` +
+ `${contextWindowFloor}-token floor. ${remediation}`,
+ };
+ }
+ if (!runtimeStatus.loaded || !runtimeStatus.contextLength) {
+ clearPreviousAuto();
+ return {
+ ok: false,
+ message:
+ `Ollama did not report a valid runtime context_length for loaded model ` +
+ `'${selectedModel}', so NemoClaw cannot verify the required ${requiredContextWindow}-token ` +
+ `window. ${remediation}`,
+ };
+ }
+ if (runtimeStatus.contextLength < requiredContextWindow) {
+ clearPreviousAuto();
+ return {
+ ok: false,
+ message:
+ `Ollama reports context_length=${runtimeStatus.contextLength} for loaded model ` +
+ `'${selectedModel}', below the required ${requiredContextWindow}-token window. ` +
+ remediation,
+ };
+ }
+ if (hasExplicitContextWindow(userContextWindow)) {
+ logger.log(` ℹ Keeping configured context window: ${userContextWindow} tokens`);
+ return { ok: true };
+ }
+ }
+
if (runtimeStatus.loaded && runtimeStatus.contextLength) {
const detected = runtimeStatus.contextLength;
- const adopted = Math.max(detected, MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW);
+ const adopted = Math.max(detected, contextWindowFloor);
const value = String(adopted);
env.NEMOCLAW_CONTEXT_WINDOW = value;
autoDetectedOllamaContextWindow = value;
if (adopted > detected) {
logger.log(
` ✓ Raising Ollama runtime context window to ${adopted} tokens ` +
- `(daemon reported ${detected}, below the ${MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW}-token agent floor). ` +
+ `(daemon reported ${detected}, below the ${contextWindowFloor}-token agent floor). ` +
`Set OLLAMA_CONTEXT_LENGTH host-side to raise the daemon default and silence this autoset.`,
);
} else {
logger.log(` ✓ Using Ollama runtime context length: ${value} tokens`);
}
- return;
+ return { ok: true };
}
if (currentIsPreviousAuto) {
delete env.NEMOCLAW_CONTEXT_WINDOW;
autoDetectedOllamaContextWindow = null;
}
+ return { ok: true };
}
diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts
index 7b0c7de17a1..907a63f99f4 100644
--- a/src/lib/onboard.ts
+++ b/src/lib/onboard.ts
@@ -588,7 +588,7 @@ import {
import { mergePolicyMessagingChannels } from "./onboard/messaging-policy-presets";
import { filterEnabledChannelsByAgent } from "./onboard/messaging-state";
import { getValidatedMessagingTokenByEnvKey } from "./onboard/messaging-token";
-import { handleOllamaProbeFailure } from "./onboard/ollama-probe-failure";
+import * as ollamaFlow from "./onboard/ollama-probe-failure";
import { runOllamaStartupOrGate } from "./onboard/ollama-startup";
import type {
DockerDriverBinaryOverrides,
@@ -2982,16 +2982,12 @@ type RebuildRouteHandoff = import("./onboard/rebuild-route-handoff").RebuildRout
const { readRecordedProvider, readRecordedNimContainer, readRecordedModel, readRecordedEndpointUrl,
readRecordedInferenceRoute, readRecordedProviderEndpoints } = providerRecovery.createProviderRecoveryHelpers({ parseGatewayInference, runCaptureOpenshell, warn: (message) => console.warn(message) });
-type OllamaModelSelectionOutcome =
- | { outcome: "selected"; model: string; allowToolsIncompatible: boolean }
- | { outcome: "back-to-selection" };
async function selectAndValidateOllamaModel(
gpu: ReturnType,
provider: string,
- // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
- defaults: { requestedModel: string | null; recoveredModel: string | null; lockedModel?: string | null; promptDefaultModel?: string | null },
+ defaults: OllamaModelSelectionDefaults,
onModelSelected?: (model: string) => void,
-): Promise {
+): Promise {
const { requestedModel, recoveredModel, lockedModel, promptDefaultModel } = defaults;
const probeFailures = new OllamaProbeFailureTracker();
const confirm = (question: string, defaultIsYes: boolean) =>
@@ -3046,7 +3042,7 @@ async function selectAndValidateOllamaModel(
const probe = await prepareOllamaModel(selectedModel, installedModels, interaction);
if (!probe.ok) {
const probeFailureLimitReached = probeFailures.recordFailure(selectedModel);
- const action = handleOllamaProbeFailure(probe, selectedModel, isNonInteractive);
+ const action = ollamaFlow.handleOllamaProbeFailure(probe, selectedModel, isNonInteractive);
if (action === "back-to-selection") return { outcome: "back-to-selection" };
if (probeFailureLimitReached) {
console.error(probeFailures.formatLimitMessage(selectedModel));
@@ -3079,13 +3075,15 @@ async function selectAndValidateOllamaModel(
" ℹ Using chat completions API (Ollama tool calls require /v1/chat/completions)",
);
}
- localInference.applyOllamaRuntimeContextWindow(selectedModel);
- return { outcome: "selected", model: selectedModel, allowToolsIncompatible };
+ // biome-ignore format: keep src/lib/onboard.ts under the growth guardrail.
+ return ollamaFlow.completeOllamaRuntimeContextSelection(localInference.applyOllamaRuntimeContextWindow(selectedModel, defaults), { outcome: "selected", model: selectedModel, allowToolsIncompatible }, isNonInteractive);
}
}
type SetupNimSelectionState =
import("./onboard/setup-nim-selection").SetupNimSelectionState;
+type OllamaModelSelectionDefaults =
+ import("./onboard/setup-nim-selection").OllamaModelSelectionDefaults;
type SetupNimSelectionResult = "selected" | "retry-selection";
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
diff --git a/src/lib/onboard/install-ollama-linux.test.ts b/src/lib/onboard/install-ollama-linux.test.ts
index fad151e61ed..4f31ad14a66 100644
--- a/src/lib/onboard/install-ollama-linux.test.ts
+++ b/src/lib/onboard/install-ollama-linux.test.ts
@@ -3,6 +3,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { MIN_HERMES_OLLAMA_CONTEXT_WINDOW } from "../inference/ollama-runtime-context";
import {
decideInstallOllamaLinuxMode,
type InstallOllamaLinuxOptions,
@@ -169,9 +170,27 @@ describe("installOllamaOnLinux (user-local)", () => {
const startCall = findRunShellCall(runShellImpl, "nohup '/home/test/.local/bin/ollama'");
expect(startCall).toBeDefined();
expect(startCall).toContain(`OLLAMA_HOST=127.0.0.1:`);
+ expect(startCall).not.toContain("OLLAMA_CONTEXT_LENGTH=");
expect(startCall).toContain(" serve ");
});
+ it("starts user-local Ollama with the requested Hermes context floor", () => {
+ const runShellImpl = vi
+ .fn()
+ .mockReturnValue({ status: 0, stdout: "", stderr: "", error: null });
+ const opts = makeOpts({
+ modeOverride: "user-local",
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ runCaptureImpl: vi.fn().mockReturnValue("/usr/bin/zstd"),
+ runShellImpl,
+ });
+ const result = installOllamaOnLinux(opts);
+ expect(result.ok).toBe(true);
+ const startCall = findRunShellCall(runShellImpl, "nohup");
+ expect(startCall).toBeDefined();
+ expect(startCall).toContain(`OLLAMA_CONTEXT_LENGTH=${MIN_HERMES_OLLAMA_CONTEXT_WINDOW}`);
+ });
+
it("uses the amd64 tarball on x64 hosts", () => {
const runShellImpl = vi
.fn()
@@ -352,6 +371,22 @@ describe("installOllamaOnLinux (system)", () => {
expect(ensureOverride).toHaveBeenCalled();
});
+ it("passes the requested Hermes context floor to the systemd override", () => {
+ const ensureOverride = vi.fn().mockReturnValue("ready");
+ const opts = makeOpts({
+ modeOverride: "system",
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ runCaptureImpl: vi.fn().mockReturnValue("/usr/bin/zstd"),
+ ensureManagedOllamaLoopbackSystemdOverrideImpl: ensureOverride,
+ });
+ const result = installOllamaOnLinux(opts);
+ expect(result.ok).toBe(true);
+ expect(ensureOverride).toHaveBeenCalledWith({
+ isNonInteractive: opts.isNonInteractive,
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ });
+ });
+
it("returns ok:false when the systemd override fails to recover", () => {
const errorLog = vi.fn();
const opts = makeOpts({
diff --git a/src/lib/onboard/install-ollama-linux.ts b/src/lib/onboard/install-ollama-linux.ts
index 68e8860dc56..bd5b51ca198 100644
--- a/src/lib/onboard/install-ollama-linux.ts
+++ b/src/lib/onboard/install-ollama-linux.ts
@@ -7,6 +7,10 @@ import nodePath from "node:path";
import { OLLAMA_PORT } from "../core/ports";
import { sleepSeconds, waitForHttp } from "../core/wait";
+import {
+ MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW,
+ resolveOllamaContextWindowFloor,
+} from "../inference/ollama-runtime-context";
import { cliName } from "./branding";
import {
decideInstallOllamaLinuxMode,
@@ -55,6 +59,8 @@ export type InstallOllamaLinuxOptions = InstallOllamaLinuxModeOptions & {
runShellImpl?: typeof runShell;
/** Test seam: override systemd loopback override. */
ensureManagedOllamaLoopbackSystemdOverrideImpl?: typeof ensureManagedOllamaLoopbackSystemdOverride;
+ /** Minimum daemon context length to request for the selected agent. */
+ contextWindowFloor?: number;
/** Test seam: override `waitForHttp`. */
waitForHttpImpl?: typeof waitForHttp;
/** Test seam: override `sleepSeconds`. */
@@ -249,18 +255,27 @@ function installOllamaUserLocal(opts: InstallOllamaLinuxOptions): InstallOllamaL
return { ok: true, mode: "user-local", binPath };
}
+/** Start the user-local Ollama daemon with the selected agent context floor. */
function startUserLocalOllamaDaemon(binPath: string, opts: InstallOllamaLinuxOptions): boolean {
const log = opts.log ?? ((m: string) => console.log(m));
const runShellImpl = opts.runShellImpl ?? runShell;
const waitForHttpImpl = opts.waitForHttpImpl ?? waitForHttp;
log(" Starting Ollama...");
runShellImpl(
- `OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT} nohup ${shellQuote(binPath)} serve > /dev/null 2>&1 &`,
+ `${ollamaContextLengthEnvPrefix(opts)}OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT} nohup ${shellQuote(binPath)} serve > /dev/null 2>&1 &`,
{ ignoreError: true },
);
return waitForHttpImpl(`http://127.0.0.1:${OLLAMA_PORT}/`, 10);
}
+/** Return the `OLLAMA_CONTEXT_LENGTH` prefix only when the agent needs a higher floor. */
+function ollamaContextLengthEnvPrefix(
+ opts: Pick,
+): string {
+ const floor = resolveOllamaContextWindowFloor(opts.contextWindowFloor);
+ return floor > MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW ? `OLLAMA_CONTEXT_LENGTH=${floor} ` : "";
+}
+
/**
* Emit a one-line PATH hint when `~/.local/bin` is missing from `PATH`. We
* intentionally do not edit shell rc files here: that is the operator's
@@ -298,6 +313,7 @@ function installOllamaSystem(opts: InstallOllamaLinuxOptions): InstallOllamaLinu
const overrideState: OllamaLoopbackSystemdOverrideState = ensureOverrideImpl({
isNonInteractive: opts.isNonInteractive,
+ contextWindowFloor: opts.contextWindowFloor,
});
if (overrideState === "failed") {
errorLog(" Ollama systemd restart did not recover after applying the loopback override.");
@@ -322,9 +338,12 @@ function installOllamaSystem(opts: InstallOllamaLinuxOptions): InstallOllamaLinu
!opts.isUpgrade && waitForHttpImpl(`http://127.0.0.1:${OLLAMA_PORT}/`, 1);
if (!localDaemonReachable) {
log(" Starting Ollama...");
- runShellImpl(`OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT} ollama serve > /dev/null 2>&1 &`, {
- ignoreError: true,
- });
+ runShellImpl(
+ `${ollamaContextLengthEnvPrefix(opts)}OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT} ollama serve > /dev/null 2>&1 &`,
+ {
+ ignoreError: true,
+ },
+ );
if (!waitForHttpImpl(`http://127.0.0.1:${OLLAMA_PORT}/`, 10)) {
errorLog(` Ollama did not become ready on :${OLLAMA_PORT} within timeout.`);
return { ok: false, mode: "system", binPath: "/usr/local/bin/ollama" };
diff --git a/src/lib/onboard/install-ollama-macos.test.ts b/src/lib/onboard/install-ollama-macos.test.ts
index 4a302b746f2..808d00792c0 100644
--- a/src/lib/onboard/install-ollama-macos.test.ts
+++ b/src/lib/onboard/install-ollama-macos.test.ts
@@ -3,6 +3,7 @@
import { describe, expect, it, vi } from "vitest";
+import { MIN_HERMES_OLLAMA_CONTEXT_WINDOW } from "../inference/ollama-runtime-context";
import { type InstallOllamaMacOSOptions, installOllamaOnMacOS } from "./install-ollama-macos";
function makeOpts(overrides: Partial): InstallOllamaMacOSOptions {
@@ -51,9 +52,26 @@ describe("installOllamaOnMacOS", () => {
(call) => typeof call[0] === "string" && call[0].includes("ollama serve"),
);
expect(serveCall).toBeDefined();
+ expect(serveCall?.[0]).not.toContain("OLLAMA_CONTEXT_LENGTH=");
expect(sleepSecondsImpl).toHaveBeenCalled();
});
+ it("starts Ollama with the requested Hermes context floor", () => {
+ const runShellImpl = vi.fn();
+ const result = installOllamaOnMacOS(
+ makeOpts({
+ runShellImpl,
+ isUpgrade: false,
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ }),
+ );
+ expect(result.ok).toBe(true);
+ const serveCall = runShellImpl.mock.calls.find(
+ (call) => typeof call[0] === "string" && call[0].includes("ollama serve"),
+ );
+ expect(serveCall?.[0]).toContain(`OLLAMA_CONTEXT_LENGTH=${MIN_HERMES_OLLAMA_CONTEXT_WINDOW}`);
+ });
+
it("does not stop a daemon on a fresh install", () => {
const runImpl = vi.fn();
installOllamaOnMacOS(makeOpts({ runImpl, isUpgrade: false }));
diff --git a/src/lib/onboard/install-ollama-macos.ts b/src/lib/onboard/install-ollama-macos.ts
index 8acfbf85a82..f5d3cfcaec0 100644
--- a/src/lib/onboard/install-ollama-macos.ts
+++ b/src/lib/onboard/install-ollama-macos.ts
@@ -3,6 +3,10 @@
import { OLLAMA_PORT } from "../core/ports";
import { sleepSeconds, waitForHttp } from "../core/wait";
+import {
+ MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW,
+ resolveOllamaContextWindowFloor,
+} from "../inference/ollama-runtime-context";
const { run, runShell }: typeof import("../runner") = require("../runner");
const {
@@ -14,6 +18,8 @@ export interface InstallOllamaMacOSOptions {
/** When true the running daemon is the upgrade target — pick `brew upgrade`
* and refuse to mask its failure with `ignoreError`. */
isUpgrade?: boolean;
+ /** Minimum daemon context length to request for the selected agent. */
+ contextWindowFloor?: number;
runImpl?: typeof run;
runShellImpl?: typeof runShell;
waitForHttpImpl?: typeof waitForHttp;
@@ -60,9 +66,12 @@ export function installOllamaOnMacOS(opts: InstallOllamaMacOSOptions): InstallOl
}
log(" Starting Ollama...");
- runShellImpl(`OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT} ollama serve > /dev/null 2>&1 &`, {
- ignoreError: true,
- });
+ runShellImpl(
+ `${ollamaContextLengthEnvPrefix(opts)}OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT} ollama serve > /dev/null 2>&1 &`,
+ {
+ ignoreError: true,
+ },
+ );
if (!waitForHttpImpl(`http://127.0.0.1:${OLLAMA_PORT}/`, 10)) {
errorLog(` Ollama did not become ready on :${OLLAMA_PORT} within timeout.`);
return { ok: false };
@@ -71,3 +80,11 @@ export function installOllamaOnMacOS(opts: InstallOllamaMacOSOptions): InstallOl
setResolvedOllamaHost("127.0.0.1");
return { ok: true };
}
+
+/** Return the `OLLAMA_CONTEXT_LENGTH` prefix only when the agent needs a higher floor. */
+function ollamaContextLengthEnvPrefix(
+ opts: Pick,
+): string {
+ const floor = resolveOllamaContextWindowFloor(opts.contextWindowFloor);
+ return floor > MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW ? `OLLAMA_CONTEXT_LENGTH=${floor} ` : "";
+}
diff --git a/src/lib/onboard/local-inference-topology.test.ts b/src/lib/onboard/local-inference-topology.test.ts
index b7cac6a28f5..14e2d475432 100644
--- a/src/lib/onboard/local-inference-topology.test.ts
+++ b/src/lib/onboard/local-inference-topology.test.ts
@@ -1,12 +1,17 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
-import { describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// The helper under test takes its collaborators via injected deps, so these
// mocks only keep the transitive module graph from loading the real inference
// stack — the default deps object references them but the tests never use it.
-vi.mock("../inference/local", () => ({ isLocalProviderHostHealthy: vi.fn() }));
+vi.mock("../inference/local", () => ({
+ applyOllamaRuntimeContextWindow: vi.fn(),
+ findReachableOllamaHost: vi.fn(),
+ isLocalProviderHostHealthy: vi.fn(),
+ validateOllamaModel: vi.fn(),
+}));
vi.mock("../inference/ollama/proxy", () => ({
ensureOllamaAuthProxy: vi.fn(),
isProxyHealthy: vi.fn(),
@@ -14,10 +19,38 @@ vi.mock("../inference/ollama/proxy", () => ({
vi.mock("../adapters/docker/runtime", () => ({ detectContainerRuntimeFromDockerInfo: vi.fn() }));
vi.mock("./ollama-systemd", () => ({ ensureOllamaLoopbackSystemdOverride: vi.fn() }));
+import {
+ applyOllamaRuntimeContextWindow,
+ findReachableOllamaHost,
+ validateOllamaModel,
+} from "../inference/local";
+import {
+ MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW,
+ MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+} from "../inference/ollama-runtime-context";
import {
ensureLocalProviderReachable,
type LocalProviderReachabilityDeps,
+ repairLocalInferenceSystemdOverrideOrExit,
} from "./local-inference-topology";
+import { ensureOllamaLoopbackSystemdOverride } from "./ollama-systemd";
+
+const mockedApplyRuntimeContext = vi.mocked(applyOllamaRuntimeContextWindow);
+const mockedFindReachableHost = vi.mocked(findReachableOllamaHost);
+const mockedValidateModel = vi.mocked(validateOllamaModel);
+const mockedEnsureSystemdOverride = vi.mocked(ensureOllamaLoopbackSystemdOverride);
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockedEnsureSystemdOverride.mockReturnValue("ready");
+ mockedFindReachableHost.mockReturnValue("127.0.0.1");
+ mockedValidateModel.mockReturnValue({ ok: true });
+ mockedApplyRuntimeContext.mockReturnValue({ ok: true });
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
function makeDeps(
over: Partial = {},
@@ -77,3 +110,120 @@ describe("ensureLocalProviderReachable", () => {
expect(deps.ensureOllamaAuthProxy).not.toHaveBeenCalled();
});
});
+
+describe("repairLocalInferenceSystemdOverrideOrExit (#6760)", () => {
+ const recordedModel = "qwen3.5:35b";
+ const isNonInteractive = vi.fn(() => true);
+
+ function repairHermesResume(): void {
+ repairLocalInferenceSystemdOverrideOrExit({
+ provider: "ollama-local",
+ model: recordedModel,
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ isNonInteractive,
+ });
+ }
+
+ function expectFailure(run: () => void, message: string): void {
+ const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ const exit = vi.spyOn(process, "exit").mockImplementation((code) => {
+ throw new Error(`unexpected process.exit(${code})`);
+ });
+
+ expect(run).toThrow(message);
+ expect(error).toHaveBeenCalledWith(` ${message}`);
+ expect(exit).not.toHaveBeenCalled();
+ }
+
+ it("warms the recorded Hermes model before verifying its runtime context", () => {
+ const callOrder: string[] = [];
+ mockedEnsureSystemdOverride.mockImplementation(() => {
+ callOrder.push("systemd");
+ return "ready";
+ });
+ mockedFindReachableHost.mockImplementation(() => {
+ callOrder.push("host");
+ return "127.0.0.1";
+ });
+ mockedValidateModel.mockImplementation(() => {
+ callOrder.push("warm");
+ return { ok: true };
+ });
+ mockedApplyRuntimeContext.mockImplementation(() => {
+ callOrder.push("runtime-context");
+ return { ok: true };
+ });
+
+ repairHermesResume();
+
+ expect(mockedEnsureSystemdOverride).toHaveBeenCalledWith({
+ isNonInteractive,
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ });
+ expect(mockedValidateModel).toHaveBeenCalledWith(recordedModel);
+ expect(mockedApplyRuntimeContext).toHaveBeenCalledWith(recordedModel, {
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ });
+ expect(callOrder).toEqual(["systemd", "host", "warm", "runtime-context"]);
+ });
+
+ it("preserves the legacy OpenClaw loopback-only repair", () => {
+ repairLocalInferenceSystemdOverrideOrExit({
+ provider: "ollama-local",
+ model: recordedModel,
+ contextWindowFloor: MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW,
+ isNonInteractive,
+ });
+
+ expect(mockedEnsureSystemdOverride).toHaveBeenCalledWith({
+ isNonInteractive,
+ contextWindowFloor: MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW,
+ });
+ expect(mockedFindReachableHost).not.toHaveBeenCalled();
+ expect(mockedValidateModel).not.toHaveBeenCalled();
+ expect(mockedApplyRuntimeContext).not.toHaveBeenCalled();
+ });
+
+ it("rejects a strict resume when the recorded model is missing", () => {
+ expectFailure(
+ () =>
+ repairLocalInferenceSystemdOverrideOrExit({
+ provider: "ollama-local",
+ model: null,
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ isNonInteractive,
+ }),
+ "The recorded Ollama model is missing, so its runtime context window cannot be verified.",
+ );
+ expect(mockedFindReachableHost).not.toHaveBeenCalled();
+ });
+
+ it("rejects a strict resume when Ollama is unreachable", () => {
+ mockedFindReachableHost.mockReturnValue(null);
+
+ expectFailure(
+ repairHermesResume,
+ "Ollama is not reachable, so the recorded model's runtime context window cannot be verified.",
+ );
+ expect(mockedValidateModel).not.toHaveBeenCalled();
+ expect(mockedApplyRuntimeContext).not.toHaveBeenCalled();
+ });
+
+ it("rejects a strict resume when the recorded model cannot be warmed", () => {
+ mockedValidateModel.mockReturnValue({ ok: false, message: "recorded model did not answer" });
+
+ expectFailure(repairHermesResume, "recorded model did not answer");
+ expect(mockedApplyRuntimeContext).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ "The recorded model is not loaded.",
+ "Ollama did not report a runtime context window.",
+ "Ollama reported a malformed runtime context window.",
+ "Ollama loaded the recorded model below 64000 tokens.",
+ ])("rejects an incomplete strict runtime proof: %s", (message) => {
+ mockedApplyRuntimeContext.mockReturnValue({ ok: false, message });
+
+ expectFailure(repairHermesResume, message);
+ });
+});
diff --git a/src/lib/onboard/local-inference-topology.ts b/src/lib/onboard/local-inference-topology.ts
index e6f60286964..a42fc4a842a 100644
--- a/src/lib/onboard/local-inference-topology.ts
+++ b/src/lib/onboard/local-inference-topology.ts
@@ -2,8 +2,17 @@
// SPDX-License-Identifier: Apache-2.0
import { detectContainerRuntimeFromDockerInfo } from "../adapters/docker/runtime";
-import { isLocalProviderHostHealthy } from "../inference/local";
+import {
+ applyOllamaRuntimeContextWindow,
+ findReachableOllamaHost,
+ isLocalProviderHostHealthy,
+ validateOllamaModel,
+} from "../inference/local";
import { ensureOllamaAuthProxy, isProxyHealthy } from "../inference/ollama/proxy";
+import {
+ MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW,
+ resolveOllamaContextWindowFloor,
+} from "../inference/ollama-runtime-context";
import { type ContainerRuntime, containerCanReachHostLoopback } from "../platform";
import { ensureOllamaLoopbackSystemdOverride } from "./ollama-systemd";
@@ -142,19 +151,53 @@ export function shouldFrontOllamaWithProxy(): boolean {
return !containerCanReachHostLoopback(getContainerRuntime());
}
-// Repair the Ollama systemd loopback override for ollama-local providers.
-// No-ops for any other provider. Exits non-zero when the restart fails to
-// recover, matching the existing fail-closed posture in setupNim. (#3342)
+export interface RepairLocalInferenceSystemdOverrideOptions {
+ provider: string | null | undefined;
+ model: string | null | undefined;
+ contextWindowFloor: number;
+ isNonInteractive: () => boolean;
+}
+
+function failOllamaResumeRepair(message: string): never {
+ console.error(` ${message}`);
+ throw new Error(message);
+}
+
+// Repair the Ollama systemd loopback override for recorded ollama-local
+// providers. Agents with a strict context floor also warm the exact recorded
+// model and verify the running daemon before resume can skip provider setup.
+// No-ops for any other provider and fails closed on incomplete runtime proof.
export function repairLocalInferenceSystemdOverrideOrExit(
- provider: string | null | undefined,
- isNonInteractive: () => boolean,
+ options: RepairLocalInferenceSystemdOverrideOptions,
): void {
+ const { provider, model, isNonInteractive } = options;
if (provider !== "ollama-local") return;
- const state = ensureOllamaLoopbackSystemdOverride({ isNonInteractive });
+ const contextWindowFloor = resolveOllamaContextWindowFloor(options.contextWindowFloor);
+ const state = ensureOllamaLoopbackSystemdOverride({ isNonInteractive, contextWindowFloor });
if (state === "failed") {
- console.error(" Ollama systemd restart did not recover after applying the loopback override.");
- process.exit(1);
+ failOllamaResumeRepair(
+ "Ollama systemd restart did not recover after applying the loopback override.",
+ );
+ }
+ if (contextWindowFloor <= MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW) return;
+ if (!model) {
+ failOllamaResumeRepair(
+ "The recorded Ollama model is missing, so its runtime context window cannot be verified.",
+ );
+ }
+ if (!findReachableOllamaHost()) {
+ failOllamaResumeRepair(
+ "Ollama is not reachable, so the recorded model's runtime context window cannot be verified.",
+ );
+ }
+ const validation = validateOllamaModel(model);
+ if (!validation.ok) {
+ failOllamaResumeRepair(
+ validation.message || `Selected Ollama model '${model}' failed runtime validation.`,
+ );
}
+ const runtimeContext = applyOllamaRuntimeContextWindow(model, { contextWindowFloor });
+ if (!runtimeContext.ok) failOllamaResumeRepair(runtimeContext.message);
}
export interface LocalProviderReachabilityDeps {
diff --git a/src/lib/onboard/machine/handlers/provider-inference-ollama-context.test.ts b/src/lib/onboard/machine/handlers/provider-inference-ollama-context.test.ts
new file mode 100644
index 00000000000..0a1addd8781
--- /dev/null
+++ b/src/lib/onboard/machine/handlers/provider-inference-ollama-context.test.ts
@@ -0,0 +1,79 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, it, vi } from "vitest";
+
+import { MIN_HERMES_OLLAMA_CONTEXT_WINDOW } from "../../../inference/ollama-runtime-context";
+import { createSession } from "../../../state/onboard-session";
+import { handleProviderInferenceState } from "./provider-inference";
+import { baseOptions, createDeps } from "./provider-inference.test-support";
+
+describe("handleProviderInferenceState Ollama context resume (#6760)", () => {
+ it("verifies the exact recorded Hermes model before using resume shortcuts", async () => {
+ const session = createSession({
+ agent: "hermes",
+ provider: "ollama-local",
+ model: "qwen3.5:35b",
+ });
+ session.steps.provider_selection.status = "complete";
+ const routeReady = vi.fn(() => true);
+ const { deps, calls } = createDeps({ isInferenceRouteReady: routeReady });
+
+ await handleProviderInferenceState({
+ ...baseOptions(deps, session),
+ resume: true,
+ sandboxName: "hermes-local",
+ agent: { name: "hermes" },
+ });
+
+ expect(calls.repair).toHaveBeenCalledWith({
+ provider: "ollama-local",
+ model: "qwen3.5:35b",
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ isNonInteractive: deps.isNonInteractive,
+ });
+ expect(calls.setupNim).not.toHaveBeenCalled();
+ expect(routeReady).toHaveBeenCalledWith("nemoclaw", "ollama-local", "qwen3.5:35b");
+ expect(calls.setupInference).not.toHaveBeenCalled();
+ });
+
+ it("records a strict context repair failure and stops resume shortcuts", async () => {
+ const failure = "Ollama loaded the recorded model below 64000 tokens.";
+ const session = createSession({
+ agent: "hermes",
+ provider: "ollama-local",
+ model: "qwen3.5:35b",
+ });
+ session.steps.provider_selection.status = "complete";
+ const routeReady = vi.fn(() => true);
+ const { deps, calls } = createDeps({ isInferenceRouteReady: routeReady });
+ calls.repair.mockImplementation(() => {
+ throw new Error(failure);
+ });
+
+ await expect(
+ handleProviderInferenceState({
+ ...baseOptions(deps, session),
+ resume: true,
+ sandboxName: "hermes-local",
+ agent: { name: "hermes" },
+ }),
+ ).rejects.toThrow(failure);
+
+ const repairMetadata = { repair: "ollama-systemd-loopback" };
+ expect(calls.repairEvent).toHaveBeenCalledWith("state.repair.started", {
+ state: "provider_selection",
+ metadata: repairMetadata,
+ });
+ expect(calls.repairEvent).toHaveBeenCalledWith("state.repair.failed", {
+ state: "provider_selection",
+ error: failure,
+ metadata: repairMetadata,
+ });
+ expect(calls.repairEvent).not.toHaveBeenCalledWith("state.repair.completed", expect.anything());
+ expect(routeReady).not.toHaveBeenCalled();
+ expect(calls.setupNim).not.toHaveBeenCalled();
+ expect(calls.setupInference).not.toHaveBeenCalled();
+ expect(calls.skipped).not.toHaveBeenCalledWith("inference", expect.anything());
+ });
+});
diff --git a/src/lib/onboard/machine/handlers/provider-inference.test.ts b/src/lib/onboard/machine/handlers/provider-inference.test.ts
index 00e04257f00..9c6b857a27f 100644
--- a/src/lib/onboard/machine/handlers/provider-inference.test.ts
+++ b/src/lib/onboard/machine/handlers/provider-inference.test.ts
@@ -481,7 +481,12 @@ describe("handleProviderInferenceState", () => {
state: "provider_selection",
metadata: { repair: "ollama-systemd-loopback" },
});
- expect(calls.repair).toHaveBeenCalledWith("ollama-local", deps.isNonInteractive);
+ expect(calls.repair).toHaveBeenCalledWith({
+ provider: "ollama-local",
+ model: "llama3.1",
+ contextWindowFloor: 16_384,
+ isNonInteractive: deps.isNonInteractive,
+ });
expect(calls.repairEvent).toHaveBeenCalledWith("state.repair.completed", {
state: "provider_selection",
metadata: { repair: "ollama-systemd-loopback" },
diff --git a/src/lib/onboard/machine/handlers/provider-inference.ts b/src/lib/onboard/machine/handlers/provider-inference.ts
index 96fc5d6215a..8e5b42c03ce 100644
--- a/src/lib/onboard/machine/handlers/provider-inference.ts
+++ b/src/lib/onboard/machine/handlers/provider-inference.ts
@@ -10,9 +10,11 @@ import {
type GatewayRouteDiscoveryConstraints,
isAdvisoryGatewayRouteConflict,
} from "../../../inference/gateway-route-compatibility";
+import { getOllamaContextWindowFloorForAgent } from "../../../inference/ollama-runtime-context";
import type { WebSearchConfig } from "../../../inference/web-search";
import type { HermesAuthMethod, Session, SessionUpdates } from "../../../state/onboard-session";
import type { OnboardInferenceCapabilityCache } from "../../inference-capability-cache";
+import type { RepairLocalInferenceSystemdOverrideOptions } from "../../local-inference-topology";
import type {
createProviderRecoveryReceiptLedger,
ProviderRecoveryReceipt,
@@ -175,8 +177,7 @@ export interface ProviderInferenceStateOptions {
configureCompatibleEndpointReasoning(storedValue?: string | null): Promise<"true" | "false">;
clearCompatibleEndpointReasoning(): null;
repairLocalInferenceSystemdOverrideOrExit(
- provider: string | null,
- isNonInteractive: () => boolean,
+ options: RepairLocalInferenceSystemdOverrideOptions,
): void;
isNonInteractive(): boolean;
getOpenshellBinary(): string;
@@ -444,9 +445,9 @@ export async function handleProviderInferenceState({
deps.log(" [resume] Refreshing compatible-endpoint inference route for messaging.");
}
deps.skippedStepMessage("provider_selection", `${provider} / ${model}`);
- const agentName = (agent as { name?: string } | null)?.name;
+ const selectedAgentName = (agent as { name?: string } | null)?.name;
if (
- (!agentName || agentName === "openclaw") &&
+ (!selectedAgentName || selectedAgentName === "openclaw") &&
sandboxName &&
session?.sandboxPromptProgress?.sandboxName === true &&
session.sandboxName === sandboxName
@@ -462,6 +463,12 @@ export async function handleProviderInferenceState({
provider === "compatible-endpoint"
? await deps.configureCompatibleEndpointReasoning(compatibleEndpointReasoning)
: deps.clearCompatibleEndpointReasoning();
+ const localInferenceRepairOptions = {
+ provider,
+ model,
+ contextWindowFloor: getOllamaContextWindowFloorForAgent(agentName(agent)),
+ isNonInteractive: deps.isNonInteractive,
+ };
if (provider === "ollama-local") {
const repairMetadata = { repair: "ollama-systemd-loopback" };
await deps.recordRepairEvent("state.repair.started", {
@@ -469,7 +476,7 @@ export async function handleProviderInferenceState({
metadata: repairMetadata,
});
try {
- deps.repairLocalInferenceSystemdOverrideOrExit(provider, deps.isNonInteractive);
+ deps.repairLocalInferenceSystemdOverrideOrExit(localInferenceRepairOptions);
} catch (err) {
await deps.recordRepairEvent("state.repair.failed", {
state: "provider_selection",
@@ -483,7 +490,7 @@ export async function handleProviderInferenceState({
metadata: repairMetadata,
});
} else {
- deps.repairLocalInferenceSystemdOverrideOrExit(provider, deps.isNonInteractive);
+ deps.repairLocalInferenceSystemdOverrideOrExit(localInferenceRepairOptions);
}
} else {
await deps.startRecordedStep("provider_selection");
diff --git a/src/lib/onboard/ollama-probe-failure.test.ts b/src/lib/onboard/ollama-probe-failure.test.ts
index 94ef4d86715..e4c3225da82 100644
--- a/src/lib/onboard/ollama-probe-failure.test.ts
+++ b/src/lib/onboard/ollama-probe-failure.test.ts
@@ -8,7 +8,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { handleOllamaProbeFailure } from "./ollama-probe-failure";
+import {
+ completeOllamaRuntimeContextSelection,
+ handleOllamaProbeFailure,
+} from "./ollama-probe-failure";
describe("handleOllamaProbeFailure (#4365)", () => {
let originalProvider: string | undefined;
@@ -161,3 +164,86 @@ describe("handleOllamaProbeFailure (#4365)", () => {
}
});
});
+
+describe("completeOllamaRuntimeContextSelection (#6760)", () => {
+ const selected = {
+ outcome: "selected" as const,
+ model: "qwen3.5:9b",
+ allowToolsIncompatible: false,
+ };
+
+ it("preserves a selected model when the runtime context is valid", () => {
+ expect(completeOllamaRuntimeContextSelection({ ok: true }, selected, () => false)).toEqual(
+ selected,
+ );
+ });
+
+ it("returns to provider selection after an interactive runtime context failure", () => {
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+ try {
+ expect(
+ completeOllamaRuntimeContextSelection(
+ { ok: false, message: "restart Ollama with OLLAMA_CONTEXT_LENGTH=64000" },
+ selected,
+ () => false,
+ ),
+ ).toEqual({ outcome: "back-to-selection" });
+ expect(errSpy).toHaveBeenCalledWith(" restart Ollama with OLLAMA_CONTEXT_LENGTH=64000");
+ expect(logSpy).toHaveBeenCalledWith(" Returning to provider selection.");
+ } finally {
+ errSpy.mockRestore();
+ logSpy.mockRestore();
+ }
+ });
+
+ it("aborts non-interactive runs after a runtime context failure", () => {
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
+ throw new Error(`process.exit:${code ?? 0}`);
+ }) as never);
+
+ try {
+ expect(() =>
+ completeOllamaRuntimeContextSelection(
+ { ok: false, message: "restart Ollama with OLLAMA_CONTEXT_LENGTH=64000" },
+ selected,
+ () => true,
+ ),
+ ).toThrow(/process\.exit:1/);
+ expect(errSpy).toHaveBeenCalledWith(
+ " [non-interactive] Aborting: restart Ollama with OLLAMA_CONTEXT_LENGTH=64000",
+ );
+ } finally {
+ errSpy.mockRestore();
+ exitSpy.mockRestore();
+ }
+ });
+
+ it("exits pinned interactive runs after a runtime context failure", () => {
+ vi.stubEnv("NEMOCLAW_PROVIDER", "ollama");
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
+ throw new Error(`process.exit:${code ?? 0}`);
+ }) as never);
+
+ try {
+ expect(() =>
+ completeOllamaRuntimeContextSelection(
+ { ok: false, message: "restart Ollama with OLLAMA_CONTEXT_LENGTH=64000" },
+ selected,
+ () => false,
+ ),
+ ).toThrow(/process\.exit:1/);
+ expect(errSpy).toHaveBeenCalledWith(" restart Ollama with OLLAMA_CONTEXT_LENGTH=64000");
+ expect(logSpy).not.toHaveBeenCalledWith(" Returning to provider selection.");
+ } finally {
+ errSpy.mockRestore();
+ logSpy.mockRestore();
+ exitSpy.mockRestore();
+ vi.unstubAllEnvs();
+ }
+ });
+});
diff --git a/src/lib/onboard/ollama-probe-failure.ts b/src/lib/onboard/ollama-probe-failure.ts
index 4f32297b808..1c7b9d5d3a5 100644
--- a/src/lib/onboard/ollama-probe-failure.ts
+++ b/src/lib/onboard/ollama-probe-failure.ts
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
+import type { ApplyOllamaRuntimeContextWindowResult } from "../inference/ollama-runtime-context";
import { abortNonInteractive } from "./non-interactive-abort";
import { isOllamaProviderPinned } from "./ollama-startup";
@@ -12,6 +13,29 @@ export interface OllamaProbeFailureInput {
export type OllamaProbeFailureAction = "back-to-selection" | "continue";
+type SelectedOllamaModel = {
+ outcome: "selected";
+ model: string;
+ allowToolsIncompatible: boolean;
+};
+
+export type OllamaModelSelectionOutcome = SelectedOllamaModel | { outcome: "back-to-selection" };
+
+/** Finish Ollama selection, returning to provider selection on an interactive context failure. */
+export function completeOllamaRuntimeContextSelection(
+ result: ApplyOllamaRuntimeContextWindowResult,
+ selected: SelectedOllamaModel,
+ isNonInteractive: () => boolean,
+): OllamaModelSelectionOutcome {
+ if (result.ok) return selected;
+ if (isNonInteractive()) abortNonInteractive(result.message);
+ console.error(` ${result.message}`);
+ if (isOllamaProviderPinned()) process.exit(1);
+ console.log(" Returning to provider selection.");
+ console.log("");
+ return { outcome: "back-to-selection" };
+}
+
/**
* Centralizes selectAndValidateOllamaModel's reaction to a failed Ollama
* probe. Lives outside onboard.ts so the codebase growth guardrail stays
diff --git a/src/lib/onboard/ollama-startup.test.ts b/src/lib/onboard/ollama-startup.test.ts
index a745a36e997..907d5ab746c 100644
--- a/src/lib/onboard/ollama-startup.test.ts
+++ b/src/lib/onboard/ollama-startup.test.ts
@@ -7,6 +7,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { MIN_HERMES_OLLAMA_CONTEXT_WINDOW } from "../inference/ollama-runtime-context";
import {
isOllamaProviderPinned,
runOllamaStartupOrGate,
@@ -36,6 +37,7 @@ describe("runOllamaStartupOrGate steer hint (#4365)", () => {
});
function restore() {
+ setOllamaAutostartDisabled(false);
wait.waitForHttp = originalWaitForHttp;
runner.runShell = originalRunShell;
if (originalProviderEnv === undefined) delete process.env.NEMOCLAW_PROVIDER;
@@ -172,4 +174,109 @@ describe("runOllamaStartupOrGate steer hint (#4365)", () => {
restore();
}
});
+
+ it("starts a NemoClaw-owned Hermes daemon with its required context length", () => {
+ delete process.env.NEMOCLAW_PROVIDER;
+ wait.waitForHttp = () => true;
+ let command = "";
+ runner.runShell = (value: string) => {
+ command = value;
+ return { status: 0 };
+ };
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
+
+ try {
+ const outcome = runOllamaStartupOrGate({
+ ollamaReady: false,
+ ollamaPort: 11434,
+ getLocalProviderBaseUrl: () => "http://host.openshell.internal:11435/v1",
+ isNonInteractive: () => false,
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ });
+
+ expect(outcome).toEqual({ kind: "ready" });
+ expect(command).toBe(
+ "OLLAMA_CONTEXT_LENGTH=64000 OLLAMA_HOST=127.0.0.1:11434 ollama serve > /dev/null 2>&1 &",
+ );
+ } finally {
+ logSpy.mockRestore();
+ restore();
+ }
+ });
+
+ const providerSetups = {
+ pinned: () => {
+ process.env.NEMOCLAW_PROVIDER = "ollama";
+ },
+ unpinned: () => {
+ delete process.env.NEMOCLAW_PROVIDER;
+ },
+ };
+ const outcomeAssertions = {
+ continue: (invoke: () => ReturnType) => {
+ expect(invoke()).toEqual({ kind: "continue" });
+ },
+ exit: (invoke: () => ReturnType) => {
+ expect(invoke).toThrow(/process\.exit:1/);
+ },
+ };
+
+ it.each([
+ {
+ name: "returns to provider selection for an interactive unpinned run",
+ nonInteractive: false,
+ outcome: "continue",
+ providerSetup: "unpinned",
+ expectedExitCalls: 0,
+ },
+ {
+ name: "exits an interactive provider-pinned run instead of looping",
+ nonInteractive: false,
+ outcome: "exit",
+ providerSetup: "pinned",
+ expectedExitCalls: 1,
+ },
+ {
+ name: "exits a non-interactive run",
+ nonInteractive: true,
+ outcome: "exit",
+ providerSetup: "unpinned",
+ expectedExitCalls: 1,
+ },
+ ] as const)("refuses an unavailable Hermes fallback and $name (#6760)", (testCase) => {
+ providerSetups[testCase.providerSetup]();
+ setOllamaAutostartDisabled(true);
+ let shellCalled = false;
+ runner.runShell = () => {
+ shellCalled = true;
+ return { status: 0 };
+ };
+ const getLocalProviderBaseUrl = vi.fn(() => "http://host.openshell.internal:11435/v1");
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
+ throw new Error(`process.exit:${code ?? 0}`);
+ }) as never);
+ const invoke = () =>
+ runOllamaStartupOrGate({
+ ollamaReady: false,
+ ollamaPort: 11434,
+ getLocalProviderBaseUrl,
+ isNonInteractive: () => testCase.nonInteractive,
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ });
+
+ try {
+ outcomeAssertions[testCase.outcome](invoke);
+ expect(exitSpy).toHaveBeenCalledTimes(testCase.expectedExitCalls);
+ expect(shellCalled).toBe(false);
+ expect(getLocalProviderBaseUrl).not.toHaveBeenCalled();
+ expect(errSpy).toHaveBeenCalledWith(
+ " Ollama is not running on localhost:11434 and --no-ollama-autostart is set; cannot verify the required 64000-token context window.",
+ );
+ } finally {
+ errSpy.mockRestore();
+ exitSpy.mockRestore();
+ restore();
+ }
+ });
});
diff --git a/src/lib/onboard/ollama-startup.ts b/src/lib/onboard/ollama-startup.ts
index 0a0352f5992..de65869e6dc 100644
--- a/src/lib/onboard/ollama-startup.ts
+++ b/src/lib/onboard/ollama-startup.ts
@@ -4,6 +4,7 @@
const runner: typeof import("../runner") = require("../runner");
const wait: typeof import("../core/wait") = require("../core/wait");
const localInference: typeof import("../inference/local") = require("../inference/local");
+const runtimeContext: typeof import("../inference/ollama-runtime-context") = require("../inference/ollama-runtime-context");
let NO_OLLAMA_AUTOSTART = false;
@@ -56,10 +57,22 @@ export function runOllamaStartupOrGate(args: {
ollamaPort: number;
getLocalProviderBaseUrl: (provider: "ollama-local") => string | null;
isNonInteractive: () => boolean;
+ contextWindowFloor?: number;
}): OllamaStartupOutcome {
- const { ollamaReady, ollamaPort, getLocalProviderBaseUrl, isNonInteractive } = args;
+ const { ollamaReady, ollamaPort, getLocalProviderBaseUrl, isNonInteractive, contextWindowFloor } =
+ args;
if (ollamaReady) return { kind: "ready" };
+ const resolvedContextFloor = runtimeContext.resolveOllamaContextWindowFloor(contextWindowFloor);
if (isOllamaAutostartDisabled()) {
+ if (resolvedContextFloor > runtimeContext.MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW) {
+ console.error(
+ " Ollama is not running on localhost:" +
+ `${ollamaPort} and --no-ollama-autostart is set; ` +
+ `cannot verify the required ${resolvedContextFloor}-token context window.`,
+ );
+ if (isNonInteractive() || isOllamaProviderPinned()) process.exit(1);
+ return { kind: "continue" };
+ }
console.log(
" ⚠ Ollama is not running on localhost:" +
`${ollamaPort} and --no-ollama-autostart is set; ` +
@@ -82,9 +95,16 @@ export function runOllamaStartupOrGate(args: {
};
}
console.log(" Starting Ollama...");
- runner.runShell(`OLLAMA_HOST=127.0.0.1:${ollamaPort} ollama serve > /dev/null 2>&1 &`, {
- ignoreError: true,
- });
+ const contextLengthPrefix =
+ resolvedContextFloor > runtimeContext.MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW
+ ? `OLLAMA_CONTEXT_LENGTH=${resolvedContextFloor} `
+ : "";
+ runner.runShell(
+ `${contextLengthPrefix}OLLAMA_HOST=127.0.0.1:${ollamaPort} ollama serve > /dev/null 2>&1 &`,
+ {
+ ignoreError: true,
+ },
+ );
if (!wait.waitForHttp(`http://127.0.0.1:${ollamaPort}/`, 10)) {
console.error(` Ollama did not become ready on :${ollamaPort} within timeout.`);
const providerPinned = isOllamaProviderPinned();
diff --git a/src/lib/onboard/ollama-systemd.test.ts b/src/lib/onboard/ollama-systemd.test.ts
index 34394a83f06..c6bc0ba5dd1 100644
--- a/src/lib/onboard/ollama-systemd.test.ts
+++ b/src/lib/onboard/ollama-systemd.test.ts
@@ -4,7 +4,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OLLAMA_PORT } from "../core/ports";
-import { MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW } from "../inference/ollama-runtime-context";
+import {
+ MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW,
+ MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+} from "../inference/ollama-runtime-context";
import {
ensureOllamaLoopbackSystemdOverride,
mergeOllamaLoopbackSystemdOverride,
@@ -62,6 +65,24 @@ describe("mergeOllamaLoopbackSystemdOverride", () => {
expect(out).not.toContain('Environment="OLLAMA_CONTEXT_LENGTH=4096"');
});
+ it("uses the caller-supplied context floor when repairing the Ollama systemd override", () => {
+ const existing = [
+ "[Service]",
+ 'Environment="OLLAMA_HOST=127.0.0.1:11434"',
+ `Environment="OLLAMA_CONTEXT_LENGTH=${MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW}"`,
+ "",
+ ].join("\n");
+ const out = mergeOllamaLoopbackSystemdOverride(existing, {
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ });
+ expect(out).toContain(
+ `Environment="OLLAMA_CONTEXT_LENGTH=${MIN_HERMES_OLLAMA_CONTEXT_WINDOW}"`,
+ );
+ expect(out).not.toContain(
+ `Environment="OLLAMA_CONTEXT_LENGTH=${MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW}"`,
+ );
+ });
+
it("preserves unrelated variables sharing an Environment line with managed Ollama settings", () => {
const existing = [
"[Service]",
diff --git a/src/lib/onboard/ollama-systemd.ts b/src/lib/onboard/ollama-systemd.ts
index cdff805291d..0854483d231 100644
--- a/src/lib/onboard/ollama-systemd.ts
+++ b/src/lib/onboard/ollama-systemd.ts
@@ -6,7 +6,7 @@ import nodePath from "node:path";
import { OLLAMA_PORT } from "../core/ports";
import { sleepSeconds } from "../core/wait";
-import { MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW } from "../inference/ollama-runtime-context";
+import { resolveOllamaContextWindowFloor } from "../inference/ollama-runtime-context";
import { cleanupTempDir, secureTempFile } from "./temp-files";
const { runCapture, runShell, shellQuote }: typeof import("../runner") = require("../runner");
@@ -24,6 +24,8 @@ export type OllamaLoopbackSystemdOverrideState = "not-applicable" | "ready" | "f
type OllamaLoopbackSystemdOverrideOptions = {
isNonInteractive?: () => boolean;
enableService?: boolean;
+ /** Minimum daemon context length to preserve or apply in the systemd override. */
+ contextWindowFloor?: number;
detectNvidiaPlatformImpl?: () => string;
hasOllamaCudaV13LibraryImpl?: () => boolean;
/**
@@ -172,6 +174,7 @@ function resolveOllamaLibraryOverride(
return hasCudaV13 ? "cuda_v13" : null;
}
+/** Ensure the Ollama systemd service is loopback-bound with the required context floor. */
export function ensureOllamaLoopbackSystemdOverride(
options: OllamaLoopbackSystemdOverrideOptions = {},
): OllamaLoopbackSystemdOverrideState {
@@ -257,6 +260,7 @@ export function ensureOllamaLoopbackSystemdOverride(
console.log(` Configuring Ollama ${libraryOverride} backend override for DGX Spark...`);
}
const dropInBody = mergeOllamaLoopbackSystemdOverride(existingDropIn, {
+ contextWindowFloor: options.contextWindowFloor,
libraryOverride,
});
const tmpDropIn = secureTempFile("nemoclaw-ollama-override", ".conf");
@@ -307,6 +311,7 @@ export function ensureOllamaLoopbackSystemdOverride(
return "failed";
}
+/** Ensure the managed Ollama systemd service is enabled and repaired for onboarding. */
export function ensureManagedOllamaLoopbackSystemdOverride(
options: Omit = {},
): OllamaLoopbackSystemdOverrideState {
@@ -364,12 +369,19 @@ function rewriteEnvironmentLineWithoutManagedAssignments(
return kept.length > 0 ? [`${match[1]}${kept.join(" ")}`] : [];
}
+/**
+ * Merge NemoClaw's managed Ollama loopback and context settings into a drop-in.
+ *
+ * Existing operator context values are preserved when they already meet or
+ * exceed the selected agent floor; otherwise the managed floor is written.
+ */
export function mergeOllamaLoopbackSystemdOverride(
existingDropIn: string,
- options: { libraryOverride?: string | null } = {},
+ options: { contextWindowFloor?: number; libraryOverride?: string | null } = {},
): string {
const desiredLine = `Environment="OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT}"`;
- const desiredContextLine = `Environment="OLLAMA_CONTEXT_LENGTH=${MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW}"`;
+ const contextWindowFloor = resolveOllamaContextWindowFloor(options.contextWindowFloor);
+ const desiredContextLine = `Environment="OLLAMA_CONTEXT_LENGTH=${contextWindowFloor}"`;
const desiredLibraryLine = options.libraryOverride
? `Environment="OLLAMA_LLM_LIBRARY=${options.libraryOverride}"`
: null;
@@ -409,7 +421,7 @@ export function mergeOllamaLoopbackSystemdOverride(
const existingHigherContext = serviceBody
.filter((line) => !/^\s*[#;]/.test(line) && /\bOLLAMA_CONTEXT_LENGTH=/.test(line))
.map(parseContextValue)
- .filter((v): v is number => v !== null && v > MIN_AUTODETECTED_OLLAMA_CONTEXT_WINDOW)
+ .filter((v): v is number => v !== null && v >= contextWindowFloor)
.sort((a, b) => b - a)[0];
const contextLine = existingHigherContext
? `Environment="OLLAMA_CONTEXT_LENGTH=${existingHigherContext}"`
diff --git a/src/lib/onboard/setup-nim-flow.test.ts b/src/lib/onboard/setup-nim-flow.test.ts
index 125d507d1ee..2baa5afaaa3 100644
--- a/src/lib/onboard/setup-nim-flow.test.ts
+++ b/src/lib/onboard/setup-nim-flow.test.ts
@@ -4,6 +4,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentDefinition } from "../agent/defs";
+import { MIN_HERMES_OLLAMA_CONTEXT_WINDOW } from "../inference/ollama-runtime-context";
import type { VllmProfile } from "../inference/vllm";
import { OnboardInferenceCapabilityCache } from "./inference-capability-cache";
import { getWindowsHostOllamaDockerRequirement } from "./local-inference-topology";
@@ -412,6 +413,35 @@ describe("createSetupNim", () => {
expect(detectInferenceProviderHostState).not.toHaveBeenCalled();
});
+ it("passes the Hermes Ollama context floor into local Ollama selection state (#6760)", async () => {
+ const handleRunningOllamaSelection = vi.fn(
+ async (_gpu, _requestedModel, _recoveredModel, _ollamaRunning, state) => {
+ expect(state.ollamaContextWindowFloor).toBe(MIN_HERMES_OLLAMA_CONTEXT_WINDOW);
+ state.model = "llama3.2:1b";
+ state.provider = "ollama-local";
+ state.endpointUrl = "http://127.0.0.1:11434/v1";
+ state.credentialEnv = null;
+ state.preferredInferenceApi = "openai-completions";
+ return "selected";
+ },
+ );
+ const setupNim = createSetupNim(
+ makeDeps({
+ isNonInteractive: () => true,
+ getNonInteractiveProvider: () => "ollama",
+ getNonInteractiveModel: () => "llama3.2:1b",
+ detectInferenceProviderHostState: () =>
+ makeHostState({ hasOllama: true, ollamaHost: "127.0.0.1", ollamaRunning: true }),
+ handleRunningOllamaSelection,
+ }),
+ );
+
+ const result = await setupNim(null, null, { name: "hermes" } as AgentDefinition);
+
+ expect(result.provider).toBe("ollama-local");
+ expect(handleRunningOllamaSelection).toHaveBeenCalledTimes(1);
+ });
+
it("applies same-gateway discovery constraints before a provider probe (#6315)", async () => {
const providerProbe = vi.fn();
const routeGuard = vi.fn(
diff --git a/src/lib/onboard/setup-nim-flow.ts b/src/lib/onboard/setup-nim-flow.ts
index 4b377c96d02..6495aec12e3 100644
--- a/src/lib/onboard/setup-nim-flow.ts
+++ b/src/lib/onboard/setup-nim-flow.ts
@@ -6,8 +6,9 @@ import {
resolveAgentDefaultCloudModel,
resolveAgentProviderInferenceApi,
} from "../inference/config";
-import type { GatewayRouteDiscoveryConstraints } from "../inference/gateway-route-compatibility";
import type { TrustedPrivateEndpointCapability } from "../inference/endpoint-ssrf-preflight";
+import type { GatewayRouteDiscoveryConstraints } from "../inference/gateway-route-compatibility";
+import { getOllamaContextWindowFloorForAgent } from "../inference/ollama-runtime-context";
import type { VllmProfile } from "../inference/vllm";
import { isBackToSelection } from "../navigation";
import type { HermesAuthMethod } from "./hermes-auth";
@@ -221,6 +222,7 @@ function applyGatewayRouteDiscoveryConstraints(
}
}
+/** Create the provider-selection flow and seed agent-specific Ollama defaults. */
export function createSetupNim(
defaults: SetupNimFlowDeps,
overrides: Partial = {},
@@ -273,6 +275,7 @@ export function createSetupNim(
compatibleEndpointReasoning,
nimContainer,
allowToolsIncompatible,
+ ollamaContextWindowFloor: getOllamaContextWindowFloorForAgent(agent?.name ?? null),
...(endpointPinnedAddresses ? { endpointPinnedAddresses } : {}),
...(endpointTrustedPrivateCapability ? { endpointTrustedPrivateCapability } : {}),
inferenceCapabilityCache,
diff --git a/src/lib/onboard/setup-nim-ollama.test.ts b/src/lib/onboard/setup-nim-ollama.test.ts
index b3d7ac2b116..68ae3578973 100644
--- a/src/lib/onboard/setup-nim-ollama.test.ts
+++ b/src/lib/onboard/setup-nim-ollama.test.ts
@@ -5,6 +5,7 @@ import assert from "node:assert/strict";
import { describe, expect, it, vi } from "vitest";
+import { MIN_HERMES_OLLAMA_CONTEXT_WINDOW } from "../inference/ollama-runtime-context";
import { createSetupNimOllamaHandlers } from "./setup-nim-ollama";
import type { SetupNimSelectionState } from "./setup-nim-selection";
@@ -214,6 +215,40 @@ describe("createSetupNimOllamaHandlers", () => {
assert.equal(state.allowToolsIncompatible, true);
});
+ it("passes the Hermes Ollama context floor to systemd repair and model validation", async () => {
+ const state = makeState();
+ state.ollamaContextWindowFloor = MIN_HERMES_OLLAMA_CONTEXT_WINDOW;
+ const ensureOverride = vi.fn(() => "unchanged");
+ const runStartup = vi.fn(() => ({ kind: "ready" as const }));
+ const selectModel = vi.fn(async (_gpu, _provider, args) => {
+ expect(args.contextWindowFloor).toBe(MIN_HERMES_OLLAMA_CONTEXT_WINDOW);
+ return { outcome: "selected" as const, model: "llama3.2:1b", allowToolsIncompatible: false };
+ });
+ const { handleRunningOllamaSelection } = createSetupNimOllamaHandlers(
+ makeDeps({
+ ensureOllamaLoopbackSystemdOverride: ensureOverride,
+ runOllamaStartupOrGate: runStartup,
+ selectAndValidateOllamaModel: selectModel,
+ }),
+ );
+
+ const result = await handleRunningOllamaSelection(null, "llama3.2:1b", null, true, state);
+
+ expect(result).toBe("selected");
+ expect(ensureOverride).toHaveBeenCalledWith({
+ isNonInteractive: expect.any(Function),
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ });
+ expect(runStartup).toHaveBeenCalledWith({
+ ollamaReady: true,
+ ollamaPort: 11434,
+ getLocalProviderBaseUrl: expect.any(Function),
+ isNonInteractive: expect.any(Function),
+ contextWindowFloor: MIN_HERMES_OLLAMA_CONTEXT_WINDOW,
+ });
+ expect(selectModel).toHaveBeenCalledTimes(1);
+ });
+
it("preserves accepted tools-incompatible state for Windows-host Ollama", async () => {
const state = makeState();
const { handleWindowsHostOllamaSelection } = createSetupNimOllamaHandlers(makeDeps());
diff --git a/src/lib/onboard/setup-nim-ollama.ts b/src/lib/onboard/setup-nim-ollama.ts
index 490dbe53aff..466f1a26eb1 100644
--- a/src/lib/onboard/setup-nim-ollama.ts
+++ b/src/lib/onboard/setup-nim-ollama.ts
@@ -19,12 +19,16 @@ type SetupNimOllamaDeps = {
isNonInteractive: () => boolean;
prompt: (message: string) => Promise;
checkOllamaPortsOrWarn: (args: { isNonInteractive: () => boolean }) => boolean;
- ensureOllamaLoopbackSystemdOverride: (args: { isNonInteractive: () => boolean }) => string;
+ ensureOllamaLoopbackSystemdOverride: (args: {
+ isNonInteractive: () => boolean;
+ contextWindowFloor?: number;
+ }) => string;
runOllamaStartupOrGate: (args: {
ollamaReady: boolean;
ollamaPort: number;
getLocalProviderBaseUrl: (provider: string) => string | null;
isNonInteractive: () => boolean;
+ contextWindowFloor?: number;
}) => OllamaStartupOutcome;
shouldFrontOllamaWithProxy: () => boolean;
startOllamaAuthProxy: () => boolean;
@@ -36,6 +40,7 @@ type SetupNimOllamaDeps = {
requestedModel: string | null;
recoveredModel: string | null;
lockedModel?: string | null;
+ contextWindowFloor?: number;
promptDefaultModel?: string | null;
},
onModelSelected?: (model: string) => void,
@@ -53,18 +58,23 @@ type SetupNimOllamaDeps = {
}) => boolean;
printWindowsOllamaTimeoutDiagnostics: () => void;
resetOllamaHostCache: () => void;
- installOllamaOnMacOS: (args: { isNonInteractive: () => boolean; isUpgrade: boolean }) => {
- ok: boolean;
- };
- installOllamaOnLinux: (args: { isNonInteractive: () => boolean; isUpgrade: boolean }) => {
- ok: boolean;
- };
+ installOllamaOnMacOS: (args: {
+ isNonInteractive: () => boolean;
+ isUpgrade: boolean;
+ contextWindowFloor?: number;
+ }) => { ok: boolean };
+ installOllamaOnLinux: (args: {
+ isNonInteractive: () => boolean;
+ isUpgrade: boolean;
+ contextWindowFloor?: number;
+ }) => { ok: boolean };
abortNonInteractive: (message: string) => never;
assertOllamaUpgradeApplied: (menu: {
hasUpgradableOllama: boolean;
}) => { ok: true } | { ok: false; message: string };
};
+/** Create Ollama onboarding handlers that propagate agent-specific context floors. */
export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): {
handleWindowsHostOllamaSelection: (
gpu: any,
@@ -107,6 +117,7 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): {
requestedModel: constrainedModel,
recoveredModel,
lockedModel,
+ contextWindowFloor: state.ollamaContextWindowFloor,
promptDefaultModel,
},
(model) => {
@@ -251,6 +262,7 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): {
let ollamaReady = ollamaRunning;
const overrideState = deps.ensureOllamaLoopbackSystemdOverride({
isNonInteractive: deps.isNonInteractive,
+ contextWindowFloor: state.ollamaContextWindowFloor,
});
if (overrideState === "ready") {
ollamaReady = true;
@@ -265,6 +277,7 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): {
ollamaPort: deps.OLLAMA_PORT,
getLocalProviderBaseUrl: deps.getLocalProviderBaseUrl,
isNonInteractive: deps.isNonInteractive,
+ contextWindowFloor: state.ollamaContextWindowFloor,
});
// Source boundary: ollama-startup owns this closed outcome contract. If a
// stale package or test double presents an unknown kind, fail closed before
@@ -304,8 +317,16 @@ export function createSetupNimOllamaHandlers(deps: SetupNimOllamaDeps): {
const isUpgrade = ollamaInstallMenu.hasUpgradableOllama;
const installResult =
deps.process.platform === "darwin"
- ? deps.installOllamaOnMacOS({ isNonInteractive: deps.isNonInteractive, isUpgrade })
- : deps.installOllamaOnLinux({ isNonInteractive: deps.isNonInteractive, isUpgrade });
+ ? deps.installOllamaOnMacOS({
+ isNonInteractive: deps.isNonInteractive,
+ isUpgrade,
+ contextWindowFloor: state.ollamaContextWindowFloor,
+ })
+ : deps.installOllamaOnLinux({
+ isNonInteractive: deps.isNonInteractive,
+ isUpgrade,
+ contextWindowFloor: state.ollamaContextWindowFloor,
+ });
if (!installResult.ok) {
if (deps.isNonInteractive())
deps.abortNonInteractive("Ollama install failed. See errors above.");
diff --git a/src/lib/onboard/setup-nim-selection.ts b/src/lib/onboard/setup-nim-selection.ts
index cf14e60bf0e..eca30cb5f1b 100644
--- a/src/lib/onboard/setup-nim-selection.ts
+++ b/src/lib/onboard/setup-nim-selection.ts
@@ -12,6 +12,17 @@ export { createNvidiaFeaturedModelSession } from "./nvidia-featured-model-select
export type SetupNimSelectionBackNavigation = Readonly<{ kind: "NEMOCLAW_BACK_TO_SELECTION" }>;
+/** Defaults passed into Ollama model selection and runtime context adoption. */
+export type OllamaModelSelectionDefaults = {
+ requestedModel: string | null;
+ recoveredModel: string | null;
+ lockedModel?: string | null;
+ /** Minimum runtime context window required by the selected agent. */
+ contextWindowFloor?: number;
+ /** Interactive prompt default from provider/model environment variables. */
+ promptDefaultModel?: string | null;
+};
+
export type SetupNimSelectionState = {
model: string | SetupNimSelectionBackNavigation | null;
provider: string;
@@ -23,6 +34,8 @@ export type SetupNimSelectionState = {
compatibleEndpointReasoning?: string | null;
nimContainer: string | null;
allowToolsIncompatible: boolean;
+ /** Minimum Ollama daemon context length to request for this agent. */
+ ollamaContextWindowFloor?: number;
skipHostInferenceSmoke?: boolean;
/** Public addresses approved for the selected custom endpoint. */
endpointPinnedAddresses?: string[];
diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts
index 0a629e2b4f8..7c0d30da46d 100644
--- a/test/generate-hermes-config.test.ts
+++ b/test/generate-hermes-config.test.ts
@@ -7,7 +7,12 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import YAML from "yaml";
+import {
+ MIN_HERMES_CONTEXT_WINDOW,
+ readHermesBuildSettings,
+} from "../agents/hermes/config/build-env.ts";
import { generateHermesConfig } from "../agents/hermes/config/generate.ts";
+import { buildHermesConfig } from "../agents/hermes/config/hermes-config.ts";
import { discoverModelSpecificSetups } from "../agents/hermes/config/model-specific-setup.ts";
import { HERMES_PROXY_API_KEY_PLACEHOLDER } from "../src/lib/hermes-proxy-api-key";
import {
@@ -524,6 +529,25 @@ describe("agents/hermes/generate-config.ts", () => {
expect(config.model.context_window).toBeUndefined();
});
+ it("accepts Hermes' minimum context window as model.context_length (#6760)", () => {
+ const settings = readHermesBuildSettings(
+ buildHermesTestEnv({ NEMOCLAW_CONTEXT_WINDOW: String(MIN_HERMES_CONTEXT_WINDOW) }),
+ );
+ const config = buildHermesConfig(settings);
+
+ expect((config.model as Record).context_length).toBe(
+ MIN_HERMES_CONTEXT_WINDOW,
+ );
+ });
+
+ it("rejects a configured context window below Hermes' minimum (#6760)", () => {
+ expect(() =>
+ readHermesBuildSettings(buildHermesTestEnv({ NEMOCLAW_CONTEXT_WINDOW: "16384" })),
+ ).toThrow(
+ `Hermes NEMOCLAW_CONTEXT_WINDOW must be at least ${MIN_HERMES_CONTEXT_WINDOW} tokens, got 16384`,
+ );
+ });
+
it("chains the endpoint probe through to model.context_length in the generated config (#6177)", async () => {
// Source-level regression across the boundary: the same probe onboarding
// calls resolves a compatible endpoint's max_model_len into
diff --git a/test/onboard-ollama-context-floor.test.ts b/test/onboard-ollama-context-floor.test.ts
new file mode 100644
index 00000000000..cdbfebcd923
--- /dev/null
+++ b/test/onboard-ollama-context-floor.test.ts
@@ -0,0 +1,211 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import assert from "node:assert/strict";
+import { type SpawnSyncReturns, spawnSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { describe, it } from "vitest";
+
+const OLLAMA_MODEL = "nemotron-3-nano:30b";
+const OLLAMA_CHAT_COMPLETIONS_TOOL_CALL_RESPONSE =
+ '{"choices":[{"message":{"role":"assistant","content":"","tool_calls":[{"type":"function","function":{"name":"emit_ok","arguments":"{\\"ok\\":true}"}}]}}]}';
+
+type OnboardResult = SpawnSyncReturns;
+
+function writeFakeCurl(fakeBin: string): void {
+ fs.writeFileSync(
+ path.join(fakeBin, "curl"),
+ `#!/usr/bin/env bash
+body='${OLLAMA_CHAT_COMPLETIONS_TOOL_CALL_RESPONSE}'
+status="200"
+outfile=""
+url=""
+has_config=0
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ -o) outfile="$2"; shift 2 ;;
+ --config) has_config=1; shift 2 ;;
+ http://*|https://*) url="$1"; shift ;;
+ *) shift ;;
+ esac
+done
+if [ "$has_config" -eq 0 ] && [[ "$url" == *:11435/* ]]; then
+ status="401"
+fi
+if [ -n "$outfile" ]; then
+ printf '%s' "$body" > "$outfile"
+fi
+printf '%s' "$status"
+`,
+ { mode: 0o755 },
+ );
+}
+
+function runHermesOllamaOnboard(
+ runtimeContextLength: number,
+ configuredContextWindow = "",
+): OnboardResult {
+ const repoRoot = path.join(import.meta.dirname, "..");
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-ollama-context-"));
+ const fakeBin = path.join(tmpDir, "bin");
+ const scriptPath = path.join(tmpDir, "onboard.js");
+ const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts"));
+ const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts"));
+ const agentDefsPath = JSON.stringify(path.join(repoRoot, "src", "lib", "agent", "defs.ts"));
+ const httpProbePath = JSON.stringify(
+ path.join(repoRoot, "src", "lib", "adapters", "http", "probe.ts"),
+ );
+ const ollamaProxyPath = JSON.stringify(
+ path.join(repoRoot, "src", "lib", "inference", "ollama", "proxy.ts"),
+ );
+ const localInferenceTopologyPath = JSON.stringify(
+ path.join(repoRoot, "src", "lib", "onboard", "local-inference-topology.ts"),
+ );
+
+ fs.mkdirSync(fakeBin, { recursive: true });
+ writeFakeCurl(fakeBin);
+
+ const script = String.raw`
+const runner = require(${runnerPath});
+const childProcess = require("child_process");
+const nodeChildProcess = require("node:child_process");
+
+const fakeSpawn = () => ({ pid: 99999, unref() {}, on() {} });
+childProcess.spawn = fakeSpawn;
+nodeChildProcess.spawn = fakeSpawn;
+const originalSpawnSync = nodeChildProcess.spawnSync;
+const fakeSpawnSync = (command, args, options) => {
+ if (command === "nc" && args?.includes("11435")) {
+ return { status: 0, stdout: "", stderr: "", signal: null };
+ }
+ return originalSpawnSync(command, args, options);
+};
+childProcess.spawnSync = fakeSpawnSync;
+nodeChildProcess.spawnSync = fakeSpawnSync;
+
+runner.run = () => ({ status: 0 });
+runner.runCapture = (command) => {
+ const normalized = Array.isArray(command) ? command.join(" ") : command;
+ if (normalized.includes("command -v ollama")) return "/usr/bin/ollama";
+ if (normalized.includes("127.0.0.1:11434/api/tags")) {
+ return JSON.stringify({ models: [{ name: "${OLLAMA_MODEL}" }] });
+ }
+ if (normalized.includes("ollama list")) return "${OLLAMA_MODEL} abc 24 GB now";
+ if (normalized.includes("127.0.0.1:8000/v1/models")) return "";
+ if (normalized.includes("127.0.0.1:11434/api/ps")) {
+ return JSON.stringify({
+ models: [{ name: "${OLLAMA_MODEL}", context_length: ${runtimeContextLength} }],
+ });
+ }
+ if (normalized.includes("api/generate")) return '{"response":"hello"}';
+ if (normalized.includes("-o args=") || normalized.includes(" ps ")) {
+ return "node ollama-auth-proxy.js";
+ }
+ return "";
+};
+runner.runCaptureEx = (command) => {
+ const normalized = Array.isArray(command) ? command.join(" ") : command;
+ if (normalized.includes("api/generate")) {
+ return { stdout: '{"response":"hello"}', stderr: "", exitCode: 0, timedOut: false };
+ }
+ return { stdout: runner.runCapture(command), stderr: "", exitCode: 0, timedOut: false };
+};
+
+const ollamaProxy = require(${ollamaProxyPath});
+ollamaProxy.startOllamaAuthProxy = () => true;
+ollamaProxy.ensureOllamaAuthProxy = () => {};
+ollamaProxy.isProxyHealthy = () => true;
+const localInferenceTopology = require(${localInferenceTopologyPath});
+localInferenceTopology.shouldFrontOllamaWithProxy = () => false;
+
+const httpProbe = require(${httpProbePath});
+const successfulOpenAiProbe = () => ({
+ ok: true,
+ httpStatus: 200,
+ curlStatus: 0,
+ body: ${JSON.stringify(OLLAMA_CHAT_COMPLETIONS_TOOL_CALL_RESPONSE)},
+ stderr: "",
+ message: "HTTP 200",
+});
+httpProbe.runCurlProbe = successfulOpenAiProbe;
+httpProbe.runChatCompletionsStreamingProbe = successfulOpenAiProbe;
+httpProbe.runStreamingEventProbe = () => ({ ok: true, missingEvents: [], message: "" });
+
+const { loadAgent } = require(${agentDefsPath});
+const { setupNim } = require(${onboardPath});
+
+setupNim(null, null, loadAgent("hermes"))
+ .then((result) => {
+ console.log(JSON.stringify({
+ result,
+ contextWindow: process.env.NEMOCLAW_CONTEXT_WINDOW,
+ }));
+ })
+ .catch((error) => {
+ console.error(error?.stack || String(error));
+ process.exit(1);
+ });
+`;
+
+ try {
+ fs.writeFileSync(scriptPath, script);
+ const env: NodeJS.ProcessEnv = {
+ ...process.env,
+ HOME: tmpDir,
+ PATH: `${fakeBin}:${process.env.PATH || ""}`,
+ NEMOCLAW_NON_INTERACTIVE: "1",
+ NEMOCLAW_PROVIDER: "ollama",
+ NEMOCLAW_MODEL: OLLAMA_MODEL,
+ NEMOCLAW_YES: "1",
+ NEMOCLAW_CONTEXT_WINDOW: configuredContextWindow,
+ NEMOCLAW_OLLAMA_PORT: "11434",
+ NEMOCLAW_OLLAMA_PROXY_PORT: "11435",
+ };
+ delete env.OLLAMA_HOST;
+
+ return spawnSync(process.execPath, [scriptPath], {
+ cwd: repoRoot,
+ encoding: "utf-8",
+ env,
+ });
+ } finally {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+}
+
+describe("Hermes Ollama runtime context floor", () => {
+ it("stops onboarding when the loaded model reports only 16384 tokens", () => {
+ const result = runHermesOllamaOnboard(16_384);
+ const output = `${result.stdout}\n${result.stderr}`;
+
+ assert.equal(result.status, 1, output);
+ assert.match(output, /nemotron-3-nano:30b/);
+ assert.match(output, /context_length=16384/);
+ assert.match(output, /required 64000-token window/);
+ assert.match(output, /OLLAMA_CONTEXT_LENGTH=64000/);
+ assert.doesNotMatch(output, /"provider":"ollama-local"/);
+ });
+
+ it("does not let an explicit 64000-token prompt budget mask a 16384-token daemon", () => {
+ const result = runHermesOllamaOnboard(16_384, "64000");
+ const output = `${result.stdout}\n${result.stderr}`;
+
+ assert.equal(result.status, 1, output);
+ assert.match(output, /context_length=16384/);
+ assert.match(output, /required 64000-token window/);
+ assert.match(output, /OLLAMA_CONTEXT_LENGTH=64000/);
+ assert.doesNotMatch(output, /"provider":"ollama-local"/);
+ });
+
+ it("finishes onboarding when the loaded model reports the 64000-token floor", () => {
+ const result = runHermesOllamaOnboard(64_000);
+
+ assert.equal(result.status, 0, result.stderr);
+ const payload = JSON.parse(result.stdout.trim().split("\n").at(-1) || "");
+ assert.equal(payload.result.provider, "ollama-local");
+ assert.equal(payload.result.model, OLLAMA_MODEL);
+ assert.equal(payload.contextWindow, "64000");
+ });
+});