diff --git a/Dockerfile b/Dockerfile index c94f8d41a34..eba7e506abe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -565,6 +565,12 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp \ # nemoclaw onboard passes these at image build time. ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b ARG NEMOCLAW_PROVIDER_KEY=inference +# User-selected upstream provider (e.g. ollama-local, nim-local, nvidia-prod), +# carried separately from NEMOCLAW_PROVIDER_KEY which collapses managed routes to +# "inference". generate-openclaw-config.mts reads this to apply provider-specific +# config such as the Local Ollama small-context compaction policy (#5468). Empty +# default keeps prior behavior when onboard does not supply a value. +ARG NEMOCLAW_UPSTREAM_PROVIDER= ARG NEMOCLAW_PRIMARY_MODEL_REF=inference/nvidia/nemotron-3-super-120b-a12b # Default dashboard port 18789 — override at runtime via NEMOCLAW_DASHBOARD_PORT. ARG CHAT_UI_URL=http://127.0.0.1:18789 @@ -635,6 +641,7 @@ ARG NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE=1.0 # Direct ARG interpolation into inline source is a code injection vector (C-2). ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_PROVIDER_KEY=${NEMOCLAW_PROVIDER_KEY} \ + NEMOCLAW_UPSTREAM_PROVIDER=${NEMOCLAW_UPSTREAM_PROVIDER} \ NEMOCLAW_PRIMARY_MODEL_REF=${NEMOCLAW_PRIMARY_MODEL_REF} \ CHAT_UI_URL=${CHAT_UI_URL} \ NEMOCLAW_INFERENCE_BASE_URL=${NEMOCLAW_INFERENCE_BASE_URL} \ diff --git a/scripts/generate-openclaw-config.mts b/scripts/generate-openclaw-config.mts index d6b44ab8bad..6f1b698afeb 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -9,7 +9,7 @@ // // Main inputs: // CHAT_UI_URL, NEMOCLAW_DASHBOARD_PORT, NEMOCLAW_MODEL, -// NEMOCLAW_PROVIDER_KEY, NEMOCLAW_PRIMARY_MODEL_REF, +// NEMOCLAW_PROVIDER_KEY, NEMOCLAW_UPSTREAM_PROVIDER, NEMOCLAW_PRIMARY_MODEL_REF, // NEMOCLAW_INFERENCE_BASE_URL, NEMOCLAW_INFERENCE_API, // NEMOCLAW_INFERENCE_INPUTS, NEMOCLAW_CONTEXT_WINDOW, // NEMOCLAW_MAX_TOKENS, NEMOCLAW_REASONING, @@ -45,6 +45,32 @@ const MODEL_SETUP_EFFECT_KEYS: Record> = { const DEFAULT_DASHBOARD_PORT = 18789; const MIN_DASHBOARD_PORT = 1024; const MAX_DASHBOARD_PORT = 65535; + +// Local Ollama small-context compaction policy (NemoClaw #5468). +// +// OpenClaw 2026.5.x auto-compaction reserves `reserveTokensFloor` tokens at the +// tail of the context window for reply generation (default 20_000, see the +// pinned openclaw package's pi-settings), then clamps that reserve so at least +// OPENCLAW_MIN_PROMPT_BUDGET_TOKENS (8_000) of the window stays available for +// prompt content. NemoClaw floors a Local Ollama runtime window to 16_384 +// (ollama-runtime-context.ts), so the default 20k reserve is clamped down and +// the prompt budget is pinned at ~8k — too small for OpenClaw's base prompt + +// tool catalogue (~7.4k tokens). The first user turn overflows and preemptive +// compaction, with no prior history to compact, fails with +// "Auto-compaction could not recover this turn". +// +// Below SMALL_OLLAMA_CONTEXT_THRESHOLD we lower both reserveTokens and +// reserveTokensFloor to the model's own reply budget (maxTokens) so the prompt +// budget becomes `contextWindow - reserve` and the first turn fits. Above the +// threshold OpenClaw's default reserve already leaves an ample prompt budget, so +// its safeguard is left untouched. Both keys must be set: OpenClaw applies +// max(reserveTokens, reserveTokensFloor), so lowering the floor alone would let +// the 20k default pull the reserve back up. +const OPENCLAW_DEFAULT_RESERVE_TOKENS_FLOOR = 20_000; +const OPENCLAW_MIN_PROMPT_BUDGET_TOKENS = 8_000; +const SMALL_OLLAMA_CONTEXT_THRESHOLD = + OPENCLAW_DEFAULT_RESERVE_TOKENS_FLOOR + OPENCLAW_MIN_PROMPT_BUDGET_TOKENS; +const LOCAL_OLLAMA_UPSTREAM_PROVIDER = "ollama-local"; const FALSE_VALUES = new Set(["0", "false", "no", "off"]); const DEFAULT_OPENCLAW_OTEL_ENDPOINT = "http://host.openshell.internal:4318"; const DEFAULT_OPENCLAW_OTEL_SERVICE_NAME = "openclaw-gateway"; @@ -945,6 +971,27 @@ function decodeJsonEnv(env: Env, name: string, defaultValue: string): any { return JSON.parse(Buffer.from(raw, "base64").toString("utf-8")); } +// Build the agents.defaults.compaction override for a Local Ollama small-context +// window, or undefined when it does not apply. See the policy constants above. +export function buildLocalOllamaSmallContextCompaction( + upstreamProvider: string | undefined, + contextWindow: number, + maxTokens: number, +): JsonObject | undefined { + if ((upstreamProvider || "").trim() !== LOCAL_OLLAMA_UPSTREAM_PROVIDER) { + return undefined; + } + if (!Number.isFinite(contextWindow) || contextWindow > SMALL_OLLAMA_CONTEXT_THRESHOLD) { + return undefined; + } + // Reserve the model's reply budget, but never so much that the remaining + // prompt budget drops below OpenClaw's own minimum — mirrors OpenClaw's clamp + // so a pathological maxTokens cannot make the window worse than the default. + const maxReserve = Math.max(0, contextWindow - OPENCLAW_MIN_PROMPT_BUDGET_TOKENS); + const reserveTokens = Math.max(0, Math.min(maxTokens, maxReserve)); + return { reserveTokens, reserveTokensFloor: reserveTokens }; +} + export function buildConfig(env: Env = process.env): JsonObject { const proxyHost = env.NEMOCLAW_PROXY_HOST || "10.200.0.1"; const proxyPort = env.NEMOCLAW_PROXY_PORT || "3128"; @@ -1155,6 +1202,15 @@ export function buildConfig(env: Env = process.env): JsonObject { agentDefaults.subagents = extraAgentsPayload.defaults.subagents; } + const smallOllamaCompaction = buildLocalOllamaSmallContextCompaction( + env.NEMOCLAW_UPSTREAM_PROVIDER, + contextWindow, + maxTokens, + ); + if (smallOllamaCompaction) { + agentDefaults.compaction = smallOllamaCompaction; + } + const config: JsonObject = { agents: { defaults: agentDefaults, diff --git a/test/e2e/test-gpu-e2e.sh b/test/e2e/test-gpu-e2e.sh index 94c2253006a..336c8fd1cac 100755 --- a/test/e2e/test-gpu-e2e.sh +++ b/test/e2e/test-gpu-e2e.sh @@ -618,6 +618,93 @@ else fail "[LOCAL] Sandbox inference: no response from ${SANDBOX_INFERENCE_URL} inside sandbox" fi +# ══════════════════════════════════════════════════════════════════ +# Phase 5.5: OpenClaw TUI first-turn compaction guard (#5468) +# ══════════════════════════════════════════════════════════════════ +# Local Ollama small-context models (e.g. qwen2.5:0.5b) are floored to a 16k +# runtime window. With OpenClaw 2026.5.x's default 20k compaction reserve that +# leaves only ~8k of first-turn prompt budget, so the very first user turn +# overflowed and preemptive auto-compaction (no prior history to compact) failed +# with "Auto-compaction could not recover this turn". The fix bakes a +# context-aware agents.defaults.compaction reserve into openclaw.json. This phase +# guards both the baked config and the real first-turn TUI outcome. +section "Phase 5.5: OpenClaw TUI first-turn compaction guard (#5468)" + +# 5.5a: The baked openclaw.json must carry a small-context compaction reserve +# policy whenever the served window is small enough to need it (<= 28k). The +# expected reserve is recomputed from the config's own contextWindow/maxTokens +# so the assertion tracks the policy exactly (reserve = min(maxTokens, +# contextWindow - 8000)) instead of a hard-coded budget. +tui_config=$(openshell sandbox exec -n "$SANDBOX_NAME" -- sh -lc 'cat /sandbox/.openclaw/openclaw.json' 2>/dev/null) +if echo "$tui_config" | python3 -c " +import json, sys +cfg = json.load(sys.stdin) +defaults = cfg.get('agents', {}).get('defaults', {}) +comp = defaults.get('compaction') +window = max_tokens = None +for provider in cfg['models']['providers'].values(): + model = provider['models'][0] + window = model.get('contextWindow') + max_tokens = model.get('maxTokens') +# Only small windows need the policy; larger windows keep OpenClaw's default. +if window is not None and window <= 28000: + assert isinstance(comp, dict), 'missing compaction policy for small window' + expected = min(max_tokens, max(0, window - 8000)) + assert comp.get('reserveTokens') == expected, f'reserveTokens {comp.get(\"reserveTokens\")} != {expected}' + assert comp.get('reserveTokensFloor') == expected, 'reserveTokensFloor mismatch' +sys.exit(0) +" 2>/dev/null; then + pass "[#5468] Baked openclaw.json carries the small-context compaction reserve policy" +else + fail "[#5468] Baked openclaw.json missing/incorrect small-context compaction policy" +fi + +# 5.5b: Drive the real OpenClaw TUI first turn and assert preemptive +# auto-compaction does not block the reply. Requires `expect`; skip cleanly if +# it is unavailable so the rest of the GPU lane still runs. The harness waits +# for the gateway to connect before sending (so a slow host cannot drop the +# keystroke), treats a healthy reply ("streaming") as success, and fails — not +# passes — on a dropped turn, an early EOF/crash, or an inconclusive timeout, so +# a turn that never ran can never be scored as a pass. +if command -v expect >/dev/null 2>&1; then + TUI_CAPTURE="/tmp/nemoclaw-5468-tui-capture.log" + : >"$TUI_CAPTURE" + TUI_TIMEOUT_SEC="${NEMOCLAW_5468_TUI_TIMEOUT_SEC:-240}" + tui_expect_script=$(mktemp "${TMPDIR:-/tmp}/nemoclaw-5468-tui.XXXXXX") + cat >"$tui_expect_script" </dev/null 2>&1 + tui_rc=$? + rm -f "$tui_expect_script" + if grep -qiE "could not recover this turn|context limit exceeded" "$TUI_CAPTURE"; then + fail "[#5468] OpenClaw TUI first turn blocked by preemptive auto-compaction" + info "TUI capture (first 800 chars): $(tr -d '\000' <"$TUI_CAPTURE" | head -c 800)" + elif [ "$tui_rc" -eq 0 ]; then + pass "[#5468] OpenClaw TUI first turn produced a reply without auto-compaction failure" + else + fail "[#5468] OpenClaw TUI first turn did not complete (rc=$tui_rc) — see capture" + info "TUI capture (first 800 chars): $(tr -d '\000' <"$TUI_CAPTURE" | head -c 800)" + fi +else + skip "[#5468] expect not installed — TUI first-turn compaction guard not exercised" +fi + # ══════════════════════════════════════════════════════════════════ # Phase 6: Destroy and uninstall # ══════════════════════════════════════════════════════════════════ diff --git a/test/ollama-local-openclaw-config-propagation.test.ts b/test/ollama-local-openclaw-config-propagation.test.ts index 8ca1d92114c..5c3bdf28d66 100644 --- a/test/ollama-local-openclaw-config-propagation.test.ts +++ b/test/ollama-local-openclaw-config-propagation.test.ts @@ -9,7 +9,10 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { patchStagedDockerfile } from "../dist/lib/onboard/dockerfile-patch"; -import { buildConfig } from "../scripts/generate-openclaw-config.mts"; +import { + buildConfig, + buildLocalOllamaSmallContextCompaction, +} from "../scripts/generate-openclaw-config.mts"; const tmpRoots: string[] = []; @@ -96,4 +99,102 @@ describe("ollama-local OpenClaw config propagation", () => { }); expect(config.agents.defaults.model.primary).toBe("inference/qwen2.5:0.5b"); }); + + it("carries the ollama-local upstream provider through the staged Dockerfile (#5468)", () => { + const dockerfilePath = dockerfileWith( + [ + "ARG NEMOCLAW_MODEL=old", + "ARG NEMOCLAW_PROVIDER_KEY=old", + "ARG NEMOCLAW_UPSTREAM_PROVIDER=old", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=old", + "ARG CHAT_UI_URL=old", + "ARG NEMOCLAW_INFERENCE_BASE_URL=old", + "ARG NEMOCLAW_INFERENCE_API=old", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=old", + "ARG NEMOCLAW_BUILD_ID=old", + "ARG NEMOCLAW_DARWIN_VM_COMPAT=0", + ].join("\n"), + ); + + patchStagedDockerfile( + dockerfilePath, + "qwen2.5:0.5b", + "http://127.0.0.1:18789", + "build-ollama-local", + "ollama-local", + ); + + const dockerArgs = readDockerArgs(dockerfilePath); + // The managed-route key collapses to "inference", but the upstream provider + // the user actually selected is preserved for config-time decisions. + expect(dockerArgs.NEMOCLAW_PROVIDER_KEY).toBe("inference"); + expect(dockerArgs.NEMOCLAW_UPSTREAM_PROVIDER).toBe("ollama-local"); + }); +}); + +describe("ollama-local small-context compaction policy (#5468)", () => { + it("emits a lowered compaction reserve for a small Local Ollama window", () => { + const config = buildConfig({ + NEMOCLAW_MODEL: "qwen2.5:0.5b", + NEMOCLAW_PROVIDER_KEY: "inference", + NEMOCLAW_UPSTREAM_PROVIDER: "ollama-local", + NEMOCLAW_PRIMARY_MODEL_REF: "inference/qwen2.5:0.5b", + NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local/v1", + NEMOCLAW_INFERENCE_API: "openai-completions", + NEMOCLAW_CONTEXT_WINDOW: "16384", + NEMOCLAW_MAX_TOKENS: "4096", + NEMOCLAW_AGENT_TIMEOUT: "600", + }); + // Reserve exactly the reply budget so the first-turn prompt budget grows + // from ~8k (OpenClaw default) to contextWindow - maxTokens = 12288. + expect(config.agents.defaults.compaction).toEqual({ + reserveTokens: 4096, + reserveTokensFloor: 4096, + }); + }); + + it("does not touch compaction for a non-ollama upstream provider", () => { + const config = buildConfig({ + NEMOCLAW_MODEL: "nvidia/nemotron-3-super-120b-a12b", + NEMOCLAW_PROVIDER_KEY: "inference", + NEMOCLAW_UPSTREAM_PROVIDER: "nvidia-prod", + NEMOCLAW_PRIMARY_MODEL_REF: "inference/nvidia/nemotron-3-super-120b-a12b", + NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local/v1", + NEMOCLAW_INFERENCE_API: "openai-completions", + NEMOCLAW_CONTEXT_WINDOW: "16384", + NEMOCLAW_MAX_TOKENS: "4096", + NEMOCLAW_AGENT_TIMEOUT: "600", + }); + expect(config.agents.defaults.compaction).toBeUndefined(); + }); + + it("leaves OpenClaw's default reserve intact for large Local Ollama windows", () => { + const config = buildConfig({ + NEMOCLAW_MODEL: "qwen2.5:7b", + NEMOCLAW_PROVIDER_KEY: "inference", + NEMOCLAW_UPSTREAM_PROVIDER: "ollama-local", + NEMOCLAW_PRIMARY_MODEL_REF: "inference/qwen2.5:7b", + NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local/v1", + NEMOCLAW_INFERENCE_API: "openai-completions", + NEMOCLAW_CONTEXT_WINDOW: "131072", + NEMOCLAW_MAX_TOKENS: "4096", + NEMOCLAW_AGENT_TIMEOUT: "600", + }); + expect(config.agents.defaults.compaction).toBeUndefined(); + }); + + it("clamps the reserve so the prompt budget never drops below OpenClaw's 8k minimum", () => { + // A pathological maxTokens must not make the window worse than the default. + const compaction = buildLocalOllamaSmallContextCompaction("ollama-local", 16384, 99999); + expect(compaction).toEqual({ reserveTokens: 8384, reserveTokensFloor: 8384 }); + expect(16384 - 8384).toBe(8000); + }); + + it("applies at the 28k threshold boundary and not just above it", () => { + expect(buildLocalOllamaSmallContextCompaction("ollama-local", 28000, 4096)).toEqual({ + reserveTokens: 4096, + reserveTokensFloor: 4096, + }); + expect(buildLocalOllamaSmallContextCompaction("ollama-local", 28001, 4096)).toBeUndefined(); + }); });