diff --git a/Dockerfile b/Dockerfile index 82c65b00847..0a4e2f252f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -262,6 +262,12 @@ ARG NEMOCLAW_INFERENCE_INPUTS=text # immutable at runtime (Landlock read-only), so this can only be changed by # rebuilding via `nemoclaw onboard`. Ref: issue #2281 ARG NEMOCLAW_AGENT_TIMEOUT=600 +# Cadence for OpenClaw's periodic heartbeat +# (agents.defaults.heartbeat.every). Accepts Go-style durations like "30m", +# "5m", "1h"; "0m" disables heartbeat. Empty default preserves the OpenClaw +# built-in cadence. openclaw.json is immutable at runtime, so this can only +# change at image build time. Ref: issue #2880 +ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY= ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30= # Base64-encoded JSON list of messaging channel names to pre-configure # (e.g. ["discord","telegram"]). Channels are added with placeholder tokens @@ -314,6 +320,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_REASONING=${NEMOCLAW_REASONING} \ NEMOCLAW_INFERENCE_INPUTS=${NEMOCLAW_INFERENCE_INPUTS} \ NEMOCLAW_AGENT_TIMEOUT=${NEMOCLAW_AGENT_TIMEOUT} \ + NEMOCLAW_AGENT_HEARTBEAT_EVERY=${NEMOCLAW_AGENT_HEARTBEAT_EVERY} \ NEMOCLAW_INFERENCE_COMPAT_B64=${NEMOCLAW_INFERENCE_COMPAT_B64} \ NEMOCLAW_MESSAGING_CHANNELS_B64=${NEMOCLAW_MESSAGING_CHANNELS_B64} \ NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${NEMOCLAW_MESSAGING_ALLOWED_IDS_B64} \ diff --git a/docs/inference/switch-inference-providers.md b/docs/inference/switch-inference-providers.md index b82ea016f97..03923bf7dbc 100644 --- a/docs/inference/switch-inference-providers.md +++ b/docs/inference/switch-inference-providers.md @@ -146,6 +146,7 @@ To change these values, set the corresponding environment variables before runni | `NEMOCLAW_REASONING` | `true` or `false` | `false` | | `NEMOCLAW_INFERENCE_INPUTS` | `text` or `text,image` | `text` | | `NEMOCLAW_AGENT_TIMEOUT` | Positive integer (seconds) | `600` | +| `NEMOCLAW_AGENT_HEARTBEAT_EVERY` | Go-style duration (`30m`, `1h`, `0m` to disable) | `unset` (OpenClaw default) | Invalid values are ignored, and the default bakes into the image. Use `NEMOCLAW_INFERENCE_INPUTS=text,image` only for a model that accepts image input through the selected provider. @@ -156,6 +157,7 @@ $ export NEMOCLAW_MAX_TOKENS=8192 $ export NEMOCLAW_REASONING=true $ export NEMOCLAW_INFERENCE_INPUTS=text,image $ export NEMOCLAW_AGENT_TIMEOUT=1800 +$ export NEMOCLAW_AGENT_HEARTBEAT_EVERY=0m $ nemoclaw onboard ``` @@ -165,6 +167,15 @@ example, CPU-only Ollama or vLLM on modest hardware). `openclaw.json` is immutable at runtime, so this value can only be changed by rebuilding the sandbox via `nemoclaw onboard`. +`NEMOCLAW_AGENT_HEARTBEAT_EVERY` sets `agents.defaults.heartbeat.every`. +This controls OpenClaw's periodic main-session agent turn. +Each interval, the agent wakes up to review follow-ups and read `HEARTBEAT.md` if present in the workspace. +The OpenClaw default is 30 minutes (1 hour for Anthropic OAuth / Claude CLI reuse). +Tune the cadence with a duration string like `5m` or `2h`, or set `0m` to disable the periodic turns entirely. +Disabling also drops `HEARTBEAT.md` from normal-run bootstrap context per upstream behavior, so the model no longer sees heartbeat-only instructions. +`openclaw.json` is immutable at runtime, so the in-sandbox `openclaw config set` command cannot change this. +Rebuild the sandbox via `nemoclaw onboard --resume` to apply a new value. + These variables are build-time settings. If you change them on an existing sandbox, recreate the sandbox so the new values bake into the image: diff --git a/scripts/generate-openclaw-config.py b/scripts/generate-openclaw-config.py index 48df4187f86..ca33f9febb1 100755 --- a/scripts/generate-openclaw-config.py +++ b/scripts/generate-openclaw-config.py @@ -22,6 +22,8 @@ NEMOCLAW_MAX_TOKENS Max tokens (default: 4096) NEMOCLAW_REASONING Enable reasoning (default: false) NEMOCLAW_AGENT_TIMEOUT Per-request timeout seconds (default: 600) + NEMOCLAW_AGENT_HEARTBEAT_EVERY OpenClaw agent heartbeat cadence (e.g. "30m", "0m" to + disable). Empty/unset preserves the OpenClaw default. NEMOCLAW_INFERENCE_COMPAT_B64 Base64-encoded inference compat JSON NEMOCLAW_MESSAGING_CHANNELS_B64 Base64-encoded channel list NEMOCLAW_MESSAGING_ALLOWED_IDS_B64 Base64-encoded allowed IDs map @@ -336,6 +338,21 @@ def build_config(env: dict | None = None) -> dict: raise ValueError("NEMOCLAW_AGENT_TIMEOUT must be a positive integer") agent_timeout = int(_raw_agent_timeout) + # NemoClaw#2880: expose OpenClaw's agents.defaults.heartbeat.every so users + # can disable the periodic heartbeat (e.g. "0m") without editing + # openclaw.json by hand. Accept a Go-style duration string (digits + a + # required s/m/h suffix — OpenClaw docs always show the suffixed form). + # Empty/unset preserves the OpenClaw default. + _raw_heartbeat = (env.get("NEMOCLAW_AGENT_HEARTBEAT_EVERY") or "").strip() + if _raw_heartbeat and not re.match(r"^\d+(s|m|h)$", _raw_heartbeat): + print( + f'[SECURITY] NEMOCLAW_AGENT_HEARTBEAT_EVERY must match ^\\d+(s|m|h)$, ' + f'got "{_raw_heartbeat}" — skipping override, preserving OpenClaw default', + file=sys.stderr, + ) + _raw_heartbeat = "" + agent_heartbeat = _raw_heartbeat + model_specific_setups = _matching_model_specific_setups( "openclaw", { @@ -534,6 +551,11 @@ def _placeholder(channel: str, env_key: str) -> str: "defaults": { "model": {"primary": primary_model_ref}, "timeoutSeconds": agent_timeout, + **( + {"heartbeat": {"every": agent_heartbeat}} + if agent_heartbeat + else {} + ), # NemoClaw sandboxes are provisioned non-interactively and the # E2E CLI contract expects the first agent turn to answer the # caller's prompt. OpenClaw 2026.4.24 seeds BOOTSTRAP.md by diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 11e81710b95..9126f691946 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2469,6 +2469,16 @@ function patchStagedDockerfile( `ARG NEMOCLAW_AGENT_TIMEOUT=${agentTimeout}`, ); } + // NEMOCLAW_AGENT_HEARTBEAT_EVERY — override agents.defaults.heartbeat.every + // at build time. Accepts Go-style durations with a required s/m/h suffix + // ("30m", "1h"); "0m" disables heartbeat. Ref: issue #2880 + const agentHeartbeat = process.env.NEMOCLAW_AGENT_HEARTBEAT_EVERY; + if (agentHeartbeat && /^\d+(s|m|h)$/.test(agentHeartbeat)) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=.*$/m, + `ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=${agentHeartbeat}`, + ); + } // Honor NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT exported in the host // shell so the sandbox-side nemoclaw-start.sh sees them via $ENV at runtime. // Without this, the host export is silently dropped at image build time and diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index d4acfe49716..d0ec87880bd 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -243,6 +243,39 @@ describe("generate-openclaw-config.py: config generation", () => { expect(config.agents.defaults.timeoutSeconds).toBe(300); }); + it("omits heartbeat when NEMOCLAW_AGENT_HEARTBEAT_EVERY is unset", () => { + const config = runConfigScript(); + expect(config.agents.defaults.heartbeat).toBeUndefined(); + }); + + it("omits heartbeat when NEMOCLAW_AGENT_HEARTBEAT_EVERY is the empty string", () => { + // Docker promotes the unset ARG to an empty ENV value rather than dropping + // the variable, so the build path almost always sees "" rather than undefined. + const config = runConfigScript({ NEMOCLAW_AGENT_HEARTBEAT_EVERY: "" }); + expect(config.agents.defaults.heartbeat).toBeUndefined(); + }); + + it("propagates heartbeat cadence into agents.defaults.heartbeat.every", () => { + const config = runConfigScript({ NEMOCLAW_AGENT_HEARTBEAT_EVERY: "30m" }); + expect(config.agents.defaults.heartbeat).toEqual({ every: "30m" }); + }); + + it("disables heartbeat when set to 0m (NemoClaw#2880)", () => { + const config = runConfigScript({ NEMOCLAW_AGENT_HEARTBEAT_EVERY: "0m" }); + expect(config.agents.defaults.heartbeat).toEqual({ every: "0m" }); + }); + + it("rejects malformed heartbeat values, preserves OpenClaw default, and warns on stderr", () => { + const result = runConfigScriptRaw({ NEMOCLAW_AGENT_HEARTBEAT_EVERY: "5 minutes" }); + expect(result.status).toBe(0); + const configPath = path.join(tmpDir, ".openclaw", "openclaw.json"); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + expect(config.agents.defaults.heartbeat).toBeUndefined(); + expect(result.stderr).toMatch( + /\[SECURITY\] NEMOCLAW_AGENT_HEARTBEAT_EVERY must match \^\\d\+\(s\|m\|h\)\$, got "5 minutes"/, + ); + }); + it("disables OpenClaw first-run workspace bootstrap", () => { const config = runConfigScript(); expect(config.agents.defaults.skipBootstrap).toBe(true); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 3dfcb691c65..fc5339c414e 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -1352,6 +1352,75 @@ describe("onboard helpers", () => { } }); + it("#2880: bakes NEMOCLAW_AGENT_HEARTBEAT_EVERY env into the staged Dockerfile", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-heartbeat-")); + const dockerfilePath = path.join(tmpDir, "Dockerfile"); + const baseDockerfile = [ + "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", + "ARG CHAT_UI_URL=http://127.0.0.1:18789", + "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", + "ARG NEMOCLAW_INFERENCE_API=openai-completions", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", + "ARG NEMOCLAW_BUILD_ID=default", + "ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=", + ].join("\n"); + + const prior = process.env.NEMOCLAW_AGENT_HEARTBEAT_EVERY; + try { + // Valid duration values bake in. + for (const value of ["0m", "30m", "5m", "1h", "30s"]) { + fs.writeFileSync(dockerfilePath, baseDockerfile); + process.env.NEMOCLAW_AGENT_HEARTBEAT_EVERY = value; + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:18789", + `build-heartbeat-${value}`, + "openai-api", + ); + assert.match( + fs.readFileSync(dockerfilePath, "utf8"), + new RegExp(`^ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=${value}$`, "m"), + `value="${value}" should bake into the ARG line`, + ); + } + + // Cases that must all leave the empty default untouched (regex rejects + // these so the OpenClaw default cadence is preserved). + const rejectCases = [undefined, "", "30 minutes", "5", "5x", "fast"]; + for (const [index, value] of rejectCases.entries()) { + fs.writeFileSync(dockerfilePath, baseDockerfile); + if (value === undefined) { + delete process.env.NEMOCLAW_AGENT_HEARTBEAT_EVERY; + } else { + process.env.NEMOCLAW_AGENT_HEARTBEAT_EVERY = value; + } + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:18789", + `build-heartbeat-reject-${index}`, + "openai-api", + ); + assert.match( + fs.readFileSync(dockerfilePath, "utf8"), + /^ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY=$/m, + `value="${String(value)}" should not change the empty ARG default`, + ); + } + } finally { + if (prior === undefined) { + delete process.env.NEMOCLAW_AGENT_HEARTBEAT_EVERY; + } else { + process.env.NEMOCLAW_AGENT_HEARTBEAT_EVERY = prior; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("patches the staged Dockerfile with Brave Search config when enabled", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-web-")); const dockerfilePath = path.join(tmpDir, "Dockerfile");