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"); + }); +});