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 @@ -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
Expand Down Expand Up @@ -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} \
Expand Down
11 changes: 11 additions & 0 deletions docs/inference/switch-inference-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
```

Expand All @@ -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:

Expand Down
22 changes: 22 additions & 0 deletions scripts/generate-openclaw-config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions test/generate-openclaw-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
69 changes: 69 additions & 0 deletions test/onboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading