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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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} \
Expand Down
58 changes: 57 additions & 1 deletion scripts/generate-openclaw-config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -45,6 +45,32 @@ const MODEL_SETUP_EFFECT_KEYS: Record<string, Set<string>> = {
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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
87 changes: 87 additions & 0 deletions test/e2e/test-gpu-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" <<EXPECT
set timeout ${TUI_TIMEOUT_SEC}
log_file -a ${TUI_CAPTURE}
spawn openshell sandbox exec --name ${SANDBOX_NAME} --tty -- sh -lc {export TERM=xterm-256color; cd /sandbox; openclaw tui}
expect {
-nocase "connected" {}
timeout { send "\003"; sleep 1; send "\003"; exit 23 }
eof { exit 22 }
}
sleep 2
send -- "hello\r"
expect {
-nocase -re {could not recover this turn|context limit exceeded} { sleep 1; send "\003"; sleep 1; send "\003"; exit 30 }
-nocase -re {streaming} { sleep 2; send "\003"; sleep 1; send "\003"; exit 0 }
timeout { send "\003"; sleep 1; send "\003"; exit 20 }
eof { exit 22 }
}
EXPECT
expect "$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
# ══════════════════════════════════════════════════════════════════
Expand Down
103 changes: 102 additions & 1 deletion test/ollama-local-openclaw-config-propagation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -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();
});
});
Loading