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 src/lib/agent/defs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ describe("agent definitions", () => {
"wechat",
"whatsapp",
]);
expect(openclaw.inferenceProviderOptions).toEqual([]);
expect(openclaw.legacyPaths?.startScript).toContain("scripts/nemoclaw-start.sh");
});

Expand Down Expand Up @@ -95,6 +96,12 @@ describe("agent definitions", () => {
);
});

it("treats an explicit agent flag as overriding NEMOCLAW_AGENT", () => {
process.env.NEMOCLAW_AGENT = "hermes";

expect(resolveAgentName({ agentFlag: "openclaw" })).toBe("openclaw");
});

it("rejects non-object manifest payloads", () => {
const agentName = `invalid-top-level-manifest-${String(Date.now())}`;
writeTempAgentManifest(agentName, ["- not", "- an", "- object"].join("\n"));
Expand Down
122 changes: 54 additions & 68 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ const {
hasWechatConfigDrift,
toSessionWechatConfig,
} = require("./onboard/wechat-config") as typeof import("./onboard/wechat-config");
const {
clearAgentScopedResumeState,
}: typeof import("./onboard/agent-resume-state") = require("./onboard/agent-resume-state");
const {
setupSelectedMessagingChannels,
} = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup");
Expand All @@ -90,6 +93,12 @@ const { buildVllmMenuEntries }: typeof import("./onboard/vllm-menu") = require("
const {
prepareModelRouterVenv,
}: typeof import("./onboard/model-router-python") = require("./onboard/model-router-python");
const {
isProcessRunning,
isRouterHealthy,
stopModelRouterProcess,
stopTrackedModelRouterForAgentChange,
}: typeof import("./onboard/model-router-process") = require("./onboard/model-router-process");
const crypto = require("node:crypto");
const fs = require("fs");
const os = require("os");
Expand Down Expand Up @@ -380,6 +389,7 @@ import { decidePolicyCarryForward } from "./onboard/policy-carryforward";
import { getSuggestedPolicyPresets } from "./onboard/policy-presets";
import {
computeSetupPresetSuggestions as computeSetupPresetSuggestionsImpl,
isStaleBuiltinBravePolicyPreset,
setupPoliciesWithSelection as setupPoliciesWithSelectionImpl,
type SetupPolicySelectionOptions,
type SetupPresetSuggestionOptions,
Expand Down Expand Up @@ -738,7 +748,6 @@ function loadBlueprintProfile(

const ROUTER_HEALTH_RETRIES = 15;
const ROUTER_HEALTH_INTERVAL_MS = 2000;
const ROUTER_HEALTH_TIMEOUT_MS = 3000;
const MODEL_ROUTER_RELATIVE_DIR = path.join("nemoclaw-blueprint", "router", "llm-router");
const MODEL_ROUTER_VENV_DIR = path.join(os.homedir(), ".nemoclaw", "model-router-venv");
const MODEL_ROUTER_FINGERPRINT_FILE = ".nemoclaw-source-fingerprint";
Expand All @@ -758,59 +767,6 @@ const MODEL_ROUTER_FINGERPRINT_IGNORED_NAMES = new Set([
]);
const DEFAULT_MODEL_ROUTER_CREDENTIAL_ENV = "NVIDIA_API_KEY";

async function isRouterHealthy(port: number, timeoutMs = ROUTER_HEALTH_TIMEOUT_MS): Promise<boolean> {
const http = require("http");
return new Promise<boolean>((resolve) => {
let settled = false;
const settle = (healthy: boolean) => {
if (settled) return;
settled = true;
resolve(healthy);
};
const request = http
.get(`http://127.0.0.1:${port}/health`, (res: import("node:http").IncomingMessage) => {
res.resume();
settle((res.statusCode || 0) >= 200 && (res.statusCode || 0) < 300);
})
.on("error", () => settle(false));
request.setTimeout(timeoutMs, () => {
request.destroy();
settle(false);
});
});
}

function isProcessRunning(pid: number | null | undefined): boolean {
if (!Number.isInteger(pid) || Number(pid) <= 0) return false;
try {
process.kill(Number(pid), 0);
return true;
} catch {
return false;
}
}

async function stopModelRouterProcess(pid: number, port: number): Promise<void> {
try {
process.kill(pid, "SIGTERM");
} catch {
return;
}
for (let attempt = 0; attempt < 10; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 500));
if (!isProcessRunning(pid) && !(await isRouterHealthy(port, 1000))) return;
}
try {
process.kill(pid, "SIGKILL");
} catch {
// already stopped
}
for (let attempt = 0; attempt < 5; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 500));
if (!isProcessRunning(pid) && !(await isRouterHealthy(port, 1000))) return;
}
}

function resolveHostCommandPath(commandName: string): string | null {
const result = runCapture(["sh", "-c", 'command -v "$1"', "--", commandName], {
ignoreError: true,
Expand Down Expand Up @@ -2443,16 +2399,6 @@ function getResumeConfigConflicts(
});
}

const requestedAgent = opts.agent || process.env.NEMOCLAW_AGENT || null;
const recordedAgent = session?.agent || null;
if (requestedAgent && recordedAgent && requestedAgent !== recordedAgent) {
conflicts.push({
field: "agent",
requested: requestedAgent,
recorded: recordedAgent,
});
}

return conflicts;
}

Expand Down Expand Up @@ -5138,7 +5084,15 @@ async function createSandbox(
}

const previousEntry: SandboxEntry | null = registry.getSandbox(sandboxName);
const decision = decidePolicyCarryForward(previousEntry?.policies, process.env, isNonInteractive());
const previousPoliciesForCarryForward = recreateForAgentDrift ? null : previousEntry?.policies;
if (recreateForAgentDrift && previousEntry?.policies && previousEntry.policies.length > 0) {
note(" Agent type changed; refreshing policy presets instead of carrying them forward.");
}
const decision = decidePolicyCarryForward(
previousPoliciesForCarryForward,
process.env,
isNonInteractive(),
);
onboardSession.updateSession((c: Session) => {
c.policyPresets = decision.newPresets;
return c;
Expand Down Expand Up @@ -9355,8 +9309,27 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
resume,
canPrompt: !cannotPrompt,
});
const selectedAgentName = normalizeSandboxAgentName(agent?.name);
const recordedAgentName = normalizeSandboxAgentName(session?.agent);
let resumeAgentChanged = false;
let forceProviderSelectionForAgentChange = false;
if (resume && session && recordedAgentName !== selectedAgentName) {
resumeAgentChanged = true;
forceProviderSelectionForAgentChange = true;
note(
` Agent changed from ${formatSandboxAgentName(recordedAgentName)} to ${formatSandboxAgentName(selectedAgentName)}; refreshing provider selection.`,
);
// Agent changes are recoverable resume drift: refresh scoped state instead of rejecting --resume.
await stopTrackedModelRouterForAgentChange(
session,
loadBlueprintProfile("routed")?.router.port || 4000,
);
onboardSession.updateSession((current: Session) =>
clearAgentScopedResumeState(current, selectedAgentName),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
setOnboardBrandingAgent(agent?.name || "openclaw");
onboardSession.updateSession((s: Session) => {
session = onboardSession.updateSession((s: Session) => {
s.agent = agent?.name ?? null;
return s;
});
Expand Down Expand Up @@ -9601,7 +9574,7 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
let preferredInferenceApi = session?.preferredInferenceApi || null;
let nimContainer = session?.nimContainer || null;
let webSearchConfig = session?.webSearchConfig || null;
let forceProviderSelection = false;
let forceProviderSelection = forceProviderSelectionForAgentChange;
while (true) {
const resumeProviderSelection =
!forceProviderSelection &&
Expand Down Expand Up @@ -9824,6 +9797,7 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
);
const resumeSandbox =
resume &&
!resumeAgentChanged &&
!webSearchConfigChanged &&
!telegramConfigChanged &&
!sandboxGpuConfigChanged &&
Expand All @@ -9840,7 +9814,11 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
skippedStepMessage("sandbox", sandboxName);
} else {
if (resume && session?.steps?.sandbox?.status === "complete") {
if (webSearchConfigChanged) {
if (resumeAgentChanged) {
note(
" [resume] Agent selection changed; revalidating sandbox compatibility.",
);
} else if (webSearchConfigChanged) {
note(" [resume] Web Search configuration changed; recreating sandbox.");
if (sandboxName) {
registry.removeSandbox(sandboxName);
Expand Down Expand Up @@ -10036,6 +10014,13 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
policyPresetSupportOptions,
customPolicyPresetNames,
);
recordedPolicyPresetsForSupport = recordedPolicyPresetsForSupport.filter(
(name) =>
!isStaleBuiltinBravePolicyPreset(name, {
webSearchConfig,
customPresetNames: customPolicyPresetNames,
}),
);
if (recordedPolicyPresets) {
recordedPolicyPresetsForSupport = mergeRequiredHermesToolGatewayPolicyPresets(
recordedPolicyPresetsForSupport,
Expand Down Expand Up @@ -10241,6 +10226,7 @@ module.exports = {
hasStaleGateway,
getRequestedSandboxNameHint,
getResumeSandboxConflict,
clearAgentScopedResumeState,
getSandboxReuseState,
getSandboxStateFromOutputs,
getPortConflictServiceHints,
Expand Down
59 changes: 59 additions & 0 deletions src/lib/onboard/agent-resume-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { Session } from "../state/onboard-session";

export function normalizeAgentNameForResumeState(agentName: string | null | undefined): string {
const trimmed = typeof agentName === "string" ? agentName.trim() : "";
return trimmed && trimmed !== "openclaw" ? trimmed : "openclaw";
}

export function resetStepForAgentChange(session: Session, stepName: string): void {
const stepState = session.steps[stepName];
if (!stepState) return;
stepState.status = "pending";
stepState.startedAt = null;
stepState.completedAt = null;
stepState.error = null;
}

export function clearAgentScopedResumeState(
session: Session,
selectedAgentName: string,
): Session {
const normalizedAgentName = normalizeAgentNameForResumeState(selectedAgentName);
session.agent = normalizedAgentName === "openclaw" ? null : normalizedAgentName;
session.provider = null;
session.model = null;
session.endpointUrl = null;
session.credentialEnv = null;
session.hermesAuthMethod = null;
session.hermesToolGateways = null;
session.preferredInferenceApi = null;
session.nimContainer = null;
session.routerPid = null;
session.routerCredentialHash = null;
session.policyPresets = null;

const resetSteps = [
"provider_selection",
"inference",
"sandbox",
"openclaw",
"agent_setup",
"policies",
];
for (const stepName of resetSteps) resetStepForAgentChange(session, stepName);
if (session.lastCompletedStep && resetSteps.includes(session.lastCompletedStep)) {
session.lastCompletedStep =
session.steps.gateway?.status === "complete"
? "gateway"
: session.steps.preflight?.status === "complete"
? "preflight"
: null;
}
if (session.lastStepStarted && resetSteps.includes(session.lastStepStarted)) {
session.lastStepStarted = null;
}
return session;
}
71 changes: 71 additions & 0 deletions src/lib/onboard/model-router-process.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import * as http from "node:http";
import type { Session } from "../state/onboard-session";

export const ROUTER_HEALTH_TIMEOUT_MS = 3000;

export async function isRouterHealthy(
port: number,
timeoutMs = ROUTER_HEALTH_TIMEOUT_MS,
): Promise<boolean> {
return new Promise<boolean>((resolve) => {
let settled = false;
const settle = (healthy: boolean) => {
if (settled) return;
settled = true;
resolve(healthy);
};
const request = http
.get(`http://127.0.0.1:${port}/health`, (res: http.IncomingMessage) => {
res.resume();
settle((res.statusCode || 0) >= 200 && (res.statusCode || 0) < 300);
})
.on("error", () => settle(false));
request.setTimeout(timeoutMs, () => {
request.destroy();
settle(false);
});
});
}

export function isProcessRunning(pid: number | null | undefined): boolean {
if (!Number.isInteger(pid) || Number(pid) <= 0) return false;
try {
process.kill(Number(pid), 0);
return true;
} catch {
return false;
}
}

export async function stopModelRouterProcess(pid: number, port: number): Promise<void> {
try {
process.kill(pid, "SIGTERM");
} catch {
return;
}
for (let _attempt = 0; _attempt < 10; _attempt++) {
await new Promise((resolve) => setTimeout(resolve, 500));
if (!isProcessRunning(pid) && !(await isRouterHealthy(port, 1000))) return;
}
try {
process.kill(pid, "SIGKILL");
} catch {
// already stopped
}
for (let _attempt = 0; _attempt < 5; _attempt++) {
await new Promise((resolve) => setTimeout(resolve, 500));
if (!isProcessRunning(pid) && !(await isRouterHealthy(port, 1000))) return;
}
}

export async function stopTrackedModelRouterForAgentChange(
session: Pick<Session, "routerPid"> | null,
port: number,
): Promise<void> {
const recordedPid = session?.routerPid ?? null;
if (!recordedPid) return;
await stopModelRouterProcess(recordedPid, port);
}
Loading
Loading