Skip to content
Closed
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
1 change: 1 addition & 0 deletions docs/get-started/quickstart-langchain-deepagents-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ For project-specific Python dependencies, create a separate virtual environment

Deep Agents Code state lives under `/sandbox/.deepagents`.
NemoClaw snapshot and rebuild flows preserve the app state directory, skills, and generated config when those files exist.
During a provider/model drift recreate, NemoClaw preserves the app state directories and skills but keeps the freshly generated `config.toml` from the replacement sandbox so the runtime uses the newly selected model.
Run `nemoclaw <sandbox-name> snapshot create` after active `dcode` tasks finish.
For `langchain-deepagents-code` sandboxes, NemoClaw refuses backup when it detects an active `dcode` task or cannot verify that the state tree is idle.
NemoClaw intentionally does not back up `.deepagents/.env` or the user-owned `.deepagents/.mcp.json` because users may put Tavily, LangSmith, MCP service, or provider credentials there.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/commands-nemohermes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ Existing live sandboxes are not deleted by this cancel rollback path.

If you run onboarding again with the same sandbox name and choose a different inference provider or model, NemoClaw detects the drift and recreates the sandbox so the running agent config matches your selection.
In interactive mode, the wizard asks for confirmation before delete and recreate.
In non-interactive mode, NemoClaw recreates automatically when the stored selection is readable and differs; if NemoClaw cannot read the stored selection, NemoClaw reuses by default.
In non-interactive mode, NemoClaw recreates automatically when the stored selection differs or when an existing sandbox registry entry is missing valid provider/model metadata.
Set `NEMOCLAW_RECREATE_SANDBOX=1` to force recreation even when no drift is detected.

Before deleting an existing sandbox during recreation, NemoClaw backs up the workspace state declared by the selected agent profile and restores it into the new sandbox once it is live.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ Existing live sandboxes are not deleted by this cancel rollback path.

If you run onboarding again with the same sandbox name and choose a different inference provider or model, NemoClaw detects the drift and recreates the sandbox so the running agent config matches your selection.
In interactive mode, the wizard asks for confirmation before delete and recreate.
In non-interactive mode, NemoClaw recreates automatically when the stored selection is readable and differs; if NemoClaw cannot read the stored selection, NemoClaw reuses by default.
In non-interactive mode, NemoClaw recreates automatically when the stored selection differs or when an existing sandbox registry entry is missing valid provider/model metadata.
Set `NEMOCLAW_RECREATE_SANDBOX=1` to force recreation even when no drift is detected.

Before deleting an existing sandbox during recreation, NemoClaw backs up the workspace state declared by the selected agent profile and restores it into the new sandbox once it is live.
Expand Down
11 changes: 5 additions & 6 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3053,7 +3053,11 @@ async function createSandboxWithBaseImageResolution(
? " Restoring workspace state from pre-upgrade backup..."
: " Restoring workspace state from pre-recreate backup...",
);
const restore = sandboxState.restoreSandboxState(sandboxName, restoreBackupPath);
const restore = sandboxState.restoreSandboxState(
sandboxName,
restoreBackupPath,
createIntent?.skipRestoreStateFiles,
);
if (restore.success) {
note(
` ✓ State restored (${restore.restoredDirs.length} directories, ${restore.restoredFiles.length} files)`,
Expand All @@ -3063,8 +3067,6 @@ async function createSandboxWithBaseImageResolution(
}
}

// DNS proxy — run a forwarder in the sandbox pod so the isolated
// sandbox namespace can resolve hostnames (fixes #626).
if (sandboxRuntimeFields.openshellDriver === "kubernetes") {
console.log(" Setting up sandbox DNS proxy...");
runFile("bash", [path.join(SCRIPTS, "setup-dns-proxy.sh"), GATEWAY_NAME, sandboxName], {
Expand All @@ -3077,8 +3079,6 @@ async function createSandboxWithBaseImageResolution(
sandboxRuntimeFields,
);

// Check that messaging providers exist in the gateway (sandbox attachment
// cannot be verified via CLI yet — only gateway-level existence is checked).
for (const p of messagingProviders) {
if (!providerExistsInGateway(p)) {
printMessagingProviderMissing(p);
Expand All @@ -3089,7 +3089,6 @@ async function createSandboxWithBaseImageResolution(

warnIfLandlockUnsupported({ dockerInfoFormat, runCapture });

// #4614: arm rollback only when the sandbox was not live before (never a recreate/rebuild).
if (!liveExists) sandboxCancelRollback.arm(sandboxName);
return sandboxName;
}
Expand Down
36 changes: 36 additions & 0 deletions src/lib/onboard/machine/handlers/sandbox-drift.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";

import { providerModelConfigChanged } from "./sandbox-drift";

describe("providerModelConfigChanged", () => {
it("does not force recreation when no sandbox registry entry exists", () => {
expect(providerModelConfigChanged(null, "nvidia", "nemotron")).toBe(false);
});

it("recreates when provider or model differs from recorded sandbox metadata", () => {
expect(
providerModelConfigChanged(
{ name: "alpha", provider: "nvidia", model: "old-model" } as any,
"nvidia",
"new-model",
),
).toBe(true);
});

it("fails closed when existing sandbox metadata is missing provider/model fields", () => {
expect(providerModelConfigChanged({ name: "alpha" } as any, "nvidia", "nemotron")).toBe(true);
});

it("fails closed when existing sandbox metadata has malformed provider/model fields", () => {
expect(
providerModelConfigChanged(
{ name: "alpha", provider: "", model: ["nemotron"] } as any,
"nvidia",
"nemotron",
),
).toBe(true);
});
});
20 changes: 20 additions & 0 deletions src/lib/onboard/machine/handlers/sandbox-drift.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { SandboxEntry } from "../../../state/registry";

function validRecordedValue(value: unknown): string | null {
return typeof value === "string" && value.trim() !== "" ? value : null;
}

export function providerModelConfigChanged(
existing: SandboxEntry | null,
provider: string,
model: string,
): boolean {
if (!existing) return false;
return (
validRecordedValue(existing.provider) !== provider ||
validRecordedValue(existing.model) !== model
);
}
21 changes: 21 additions & 0 deletions src/lib/onboard/machine/handlers/sandbox-resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import { decideSandboxResume, type SandboxResumeSignals } from "./sandbox-resume

function resumeSignals(overrides: Partial<SandboxResumeSignals> = {}): SandboxResumeSignals {
return {
fresh: false,
resume: true,
resumeAgentChanged: false,
sandboxStepComplete: true,
sandboxReuseState: "ready",
providerModelConfigChanged: false,
webSearchConfigChanged: false,
sandboxGpuConfigChanged: false,
messagingChannelConfigChanged: false,
Expand All @@ -28,6 +30,7 @@ describe("decideSandboxResume", () => {

it.each([
["agent", { resumeAgentChanged: true }, false],
["provider/model", { providerModelConfigChanged: true }, false],
["web search", { webSearchConfigChanged: true }, true],
["sandbox GPU", { sandboxGpuConfigChanged: true }, true],
["messaging", { messagingChannelConfigChanged: true }, true],
Expand All @@ -54,6 +57,24 @@ describe("decideSandboxResume", () => {
});
});

it("recreates fresh runs when an existing sandbox has provider/model drift", () => {
expect(
decideSandboxResume(
resumeSignals({
fresh: true,
resume: false,
sandboxStepComplete: false,
providerModelConfigChanged: true,
}),
),
).toEqual({
kind: "recreate",
note: " [fresh] Provider/model selection changed; recreating sandbox.",
removeRegistryEntry: false,
skipRestoreStateFiles: ["config.toml"],
});
});

it("repairs a recorded sandbox that is present but not ready", () => {
expect(decideSandboxResume(resumeSignals({ sandboxReuseState: "not_ready" }))).toEqual({
kind: "repair-and-recreate",
Expand Down
35 changes: 30 additions & 5 deletions src/lib/onboard/machine/handlers/sandbox-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import type { SandboxEntry } from "../../../state/registry";
import { normalizeToolDisclosure, toolDisclosureOrDefault } from "../../../tool-disclosure";

export interface SandboxResumeSignals {
readonly fresh: boolean;
readonly resume: boolean;
readonly resumeAgentChanged: boolean;
readonly sandboxStepComplete: boolean;
readonly sandboxReuseState: string;
readonly providerModelConfigChanged: boolean;
readonly webSearchConfigChanged: boolean;
readonly sandboxGpuConfigChanged: boolean;
readonly messagingChannelConfigChanged: boolean;
Expand Down Expand Up @@ -41,6 +43,7 @@ export type SandboxResumeDecision =
readonly kind: "recreate";
readonly note: string;
readonly removeRegistryEntry: boolean;
readonly skipRestoreStateFiles?: readonly string[];
}
| { readonly kind: "repair-and-recreate" };

Expand Down Expand Up @@ -92,9 +95,23 @@ function toolDisclosureResumeDecision(signals: SandboxResumeSignals): SandboxRes
return null;
}

export function decideSandboxResume(signals: SandboxResumeSignals): SandboxResumeDecision {
if (!signals.resume || !signals.sandboxStepComplete) return { kind: "create" };
if (canReuseSandbox(signals)) return { kind: "reuse" };
function providerModelDriftDecision(signals: SandboxResumeSignals): SandboxResumeDecision | null {
if (!signals.providerModelConfigChanged) return null;
return {
kind: "recreate",
note: ` [${signals.fresh ? "fresh" : signals.resume ? "resume" : "onboard"}] Provider/model selection changed; recreating sandbox.`,
// Keep the registry row until createSandbox captures any registry-only
// fidelity and runs the normal pre-recreate backup/deletion path.
removeRegistryEntry: false,
// Provider/model recreation must preserve user state, but not generated
// agent inference config from the pre-recreate sandbox.
skipRestoreStateFiles: ["config.toml"],
};
}

function configurationDriftResumeDecision(
signals: SandboxResumeSignals,
): SandboxResumeDecision | null {
if (signals.resumeAgentChanged) {
return {
kind: "recreate",
Expand Down Expand Up @@ -130,8 +147,16 @@ export function decideSandboxResume(signals: SandboxResumeSignals): SandboxResum
removeRegistryEntry: true,
};
}
const toolDisclosureDecision = toolDisclosureResumeDecision(signals);
if (toolDisclosureDecision) return toolDisclosureDecision;
return toolDisclosureResumeDecision(signals);
}

export function decideSandboxResume(signals: SandboxResumeSignals): SandboxResumeDecision {
const providerModelDecision = providerModelDriftDecision(signals);
if (providerModelDecision) return providerModelDecision;
if (!signals.resume || !signals.sandboxStepComplete) return { kind: "create" };
if (canReuseSandbox(signals)) return { kind: "reuse" };
const driftDecision = configurationDriftResumeDecision(signals);
if (driftDecision) return driftDecision;
if (signals.sandboxReuseState === "not_ready") return { kind: "repair-and-recreate" };
return {
kind: "recreate",
Expand Down
2 changes: 2 additions & 0 deletions src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ export function createDeps(
getSandboxHermesToolGateways: () => [],
getSandboxRegistryEntry: (name: string) => ({
name,
provider: "provider",
model: "model",
webSearchEnabled: false,
toolDisclosure: "progressive" as const,
fromDockerfile: null,
Expand Down
46 changes: 46 additions & 0 deletions src/lib/onboard/machine/handlers/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,48 @@ describe("handleSandboxState", () => {
expect(result.session).toBe(skippedSession);
});

it("recreates an existing ready sandbox on fresh provider/model drift", async () => {
const { deps, calls } = createDeps({
getSandboxReuseState: () => "ready",
getSandboxRegistryEntry: (name) => ({
name,
provider: "old-provider",
model: "old-model",
toolDisclosure: "progressive",
}),
});

await handleSandboxState({
...baseOptions(deps),
fresh: true,
sandboxName: "saved",
provider: "new-provider",
model: "new-model",
});

expect(calls.note).toHaveBeenCalledWith(
" [fresh] Provider/model selection changed; recreating sandbox.",
);
expect(calls.removeSandbox).not.toHaveBeenCalled();
expect(calls.createSandbox).toHaveBeenCalledWith(
{ type: "nvidia" },
"new-model",
"new-provider",
"openai-completions",
"saved",
null,
[],
null,
null,
null,
{ sandboxGpuEnabled: false, mode: "0" },
null,
[],
null,
{ recreate: true, toolDisclosure: "progressive", skipRestoreStateFiles: ["config.toml"] },
);
});

it("backfills absent rebuild fidelity after validated sandbox reuse", async () => {
const session = createSession({
sandboxName: "saved",
Expand All @@ -175,6 +217,8 @@ describe("handleSandboxState", () => {
getSandboxReuseState: () => "ready",
getSandboxRegistryEntry: (name) => ({
name,
provider: "provider",
model: "model",
nemoclawVersion: "0.1.0",
toolDisclosure: "progressive",
}),
Expand Down Expand Up @@ -404,6 +448,8 @@ describe("handleSandboxState", () => {
agentSupportsWebSearchProvider: () => true,
getSandboxRegistryEntry: (name: string) => ({
name,
provider: "provider",
model: "model",
mcp: {
bridges: {
search: {
Expand Down
17 changes: 13 additions & 4 deletions src/lib/onboard/machine/handlers/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { toolDisclosureOrDefault } from "../../../tool-disclosure";
import { withSandboxPhaseTrace } from "../../tracing";
import type { SandboxCreateIntent } from "../../types";
import { branchTo, type OnboardStateTransitionResult } from "../result";
import { providerModelConfigChanged } from "./sandbox-drift";
import { reconcileReusedSandboxMessaging, reconcileSandboxMessaging } from "./sandbox-messaging";
import {
applySandboxResumeDecision,
Expand Down Expand Up @@ -374,15 +375,21 @@ class SandboxStateFlow<
state.webSearchConfig as unknown as SharedWebSearchConfig | null,
this.options.hermesToolGateways,
);
const toolDisclosureSignals = resolveToolDisclosureResumeSignals(
state.sandboxName ? this.deps.getSandboxRegistryEntry(state.sandboxName) : null,
state.session,
);
const registryEntry = state.sandboxName
? this.deps.getSandboxRegistryEntry(state.sandboxName)
: null;
const toolDisclosureSignals = resolveToolDisclosureResumeSignals(registryEntry, state.session);
return decideSandboxResume({
fresh: this.options.fresh,
resume: this.options.resume,
resumeAgentChanged: this.options.resumeAgentChanged,
sandboxStepComplete: state.session?.steps?.sandbox?.status === "complete",
sandboxReuseState: this.deps.getSandboxReuseState(state.sandboxName),
providerModelConfigChanged: providerModelConfigChanged(
registryEntry,
this.options.provider,
this.options.model,
),
webSearchConfigChanged: state.webSearchSupportDropped || state.webSearchConfigChanged,
sandboxGpuConfigChanged: state.sandboxName
? this.deps.hasSandboxGpuDrift(state.sandboxName, this.options.sandboxGpuConfig)
Expand Down Expand Up @@ -519,6 +526,8 @@ class SandboxStateFlow<
{
recreate: decision.kind !== "create",
toolDisclosure: toolDisclosureOrDefault(state.session?.toolDisclosure),
skipRestoreStateFiles:
decision.kind === "recreate" ? decision.skipRestoreStateFiles : undefined,
},
),
);
Expand Down
1 change: 1 addition & 0 deletions src/lib/onboard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export type ModelValidationResult = ModelValidationSuccess | ModelValidationFail
export interface SandboxCreateIntent {
readonly recreate: boolean;
readonly toolDisclosure: import("../tool-disclosure").ToolDisclosure;
readonly skipRestoreStateFiles?: readonly string[];
}

export type OnboardOptions = {
Expand Down
Loading
Loading