Skip to content

Commit 73a4806

Browse files
committed
feat(inference): map Pi model tuning through the managed startup profile
The startup profile declares each agent's capabilities in one table whose contract is that an unadvertised field is rejected rather than silently dropped, but tuning fields, the upstream endpoint, the messaging plan, and the agent-specific input check each enforced that contract with a hardcoded agent name. Pi matched none of them, so it accepted an effort value it has no surface for, accepted an upstream endpoint its mapper discards, bypassed the messaging null rule, and could not be built at all through the shared onboarding builder, which demanded approval state that belongs to Deep Agents Code. Each check now reads the capability table. Pi's context window, output limit, and reasoning support now travel from the environment through the profile to the sandbox model catalog. The generator writes only the fields the pinned Pi release documents for a catalog entry, and omits each one the host leaves unset so the release default applies. Signed-off-by: Tinson Lai <tinsonl@nvidia.com>
1 parent 3707b8e commit 73a4806

11 files changed

Lines changed: 562 additions & 42 deletions

agents/pi/Dockerfile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,10 @@ ARG NEMOCLAW_INFERENCE_PROVIDER_ID=inference
176176
ARG NEMOCLAW_UPSTREAM_PROVIDER=nvidia
177177
ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1
178178
ARG NEMOCLAW_INFERENCE_API=openai-completions
179+
ARG NEMOCLAW_CONTEXT_WINDOW=
180+
# hadolint ignore=DL3064
181+
ARG NEMOCLAW_MAX_TOKENS=
182+
ARG NEMOCLAW_REASONING=
179183
# Pi installs no optional package. The Pi image still declares the managed-image
180184
# capability contract used by OpenClaw, Hermes, and Deep Agents Code.
181185
ARG NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=0
@@ -197,13 +201,17 @@ RUN install -d -m 0755 /usr/local/share/nemoclaw \
197201
&& chown root:root /usr/local/share/nemoclaw/pi-proxy-host /usr/local/share/nemoclaw/pi-proxy-port \
198202
&& chmod 0444 /usr/local/share/nemoclaw/pi-proxy-host /usr/local/share/nemoclaw/pi-proxy-port
199203

204+
# hadolint ignore=DL3064
200205
ENV HOME=/sandbox \
201206
PATH="/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin" \
202207
NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \
203208
NEMOCLAW_INFERENCE_PROVIDER_ID=${NEMOCLAW_INFERENCE_PROVIDER_ID} \
204209
NEMOCLAW_UPSTREAM_PROVIDER=${NEMOCLAW_UPSTREAM_PROVIDER} \
205210
NEMOCLAW_INFERENCE_BASE_URL=${NEMOCLAW_INFERENCE_BASE_URL} \
206211
NEMOCLAW_INFERENCE_API=${NEMOCLAW_INFERENCE_API} \
212+
NEMOCLAW_CONTEXT_WINDOW=${NEMOCLAW_CONTEXT_WINDOW} \
213+
NEMOCLAW_MAX_TOKENS=${NEMOCLAW_MAX_TOKENS} \
214+
NEMOCLAW_REASONING=${NEMOCLAW_REASONING} \
207215
NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION=${NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION} \
208216
NEMOCLAW_BUILD_ID=${NEMOCLAW_BUILD_ID} \
209217
PI_OFFLINE=1 \

agents/pi/generate-config.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ type Settings = {
2121
providerKey: string;
2222
upstreamProvider: string;
2323
inferenceApi: string;
24+
contextWindow: number | null;
25+
maxTokens: number | null;
26+
reasoning: boolean | null;
2427
};
2528

2629
type ManagedPiConfig = {
@@ -75,6 +78,27 @@ function normalizeInferenceBaseUrl(value: string): string {
7578
return text;
7679
}
7780

81+
function normalizePositiveInteger(value: string | undefined, name: string): number | null {
82+
const text = (value ?? "").trim();
83+
if (!text) return null;
84+
if (!/^\d+$/u.test(text)) {
85+
throw new Error(`${name} must be a positive integer.`);
86+
}
87+
const parsed = Number(text);
88+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
89+
throw new Error(`${name} must be a positive integer.`);
90+
}
91+
return parsed;
92+
}
93+
94+
function normalizeReasoning(value: string | undefined): boolean | null {
95+
const text = (value ?? "").trim();
96+
if (!text) return null;
97+
if (text === "true") return true;
98+
if (text === "false") return false;
99+
throw new Error('NEMOCLAW_REASONING must be "true" or "false".');
100+
}
101+
78102
function readSettings(env: NodeJS.ProcessEnv): Settings {
79103
const providerKey = normalizeMetadata(
80104
env.NEMOCLAW_INFERENCE_PROVIDER_ID || env.NEMOCLAW_PROVIDER_KEY || "inference",
@@ -91,9 +115,20 @@ function readSettings(env: NodeJS.ProcessEnv): Settings {
91115
"NEMOCLAW_UPSTREAM_PROVIDER",
92116
),
93117
inferenceApi: normalizeInferenceApi(env.NEMOCLAW_INFERENCE_API),
118+
contextWindow: normalizePositiveInteger(env.NEMOCLAW_CONTEXT_WINDOW, "NEMOCLAW_CONTEXT_WINDOW"),
119+
maxTokens: normalizePositiveInteger(env.NEMOCLAW_MAX_TOKENS, "NEMOCLAW_MAX_TOKENS"),
120+
reasoning: normalizeReasoning(env.NEMOCLAW_REASONING),
94121
};
95122
}
96123

124+
function buildModel(settings: Settings): Record<string, unknown> {
125+
const model: Record<string, unknown> = { id: settings.model };
126+
if (settings.contextWindow !== null) model.contextWindow = settings.contextWindow;
127+
if (settings.maxTokens !== null) model.maxTokens = settings.maxTokens;
128+
if (settings.reasoning !== null) model.reasoning = settings.reasoning;
129+
return model;
130+
}
131+
97132
function buildConfig(settings: Settings): ManagedPiConfig {
98133
const config = {
99134
$comment: `Generated by NemoClaw. This file contains no provider secrets. NemoClaw provider route: ${settings.providerKey}; upstream provider: ${settings.upstreamProvider}; API: ${settings.inferenceApi}.`,
@@ -103,7 +138,7 @@ function buildConfig(settings: Settings): ManagedPiConfig {
103138
api: settings.inferenceApi,
104139
apiKey: MANAGED_PROVIDER_API_KEY,
105140
baseUrl: settings.baseUrl,
106-
models: [{ id: settings.model }],
141+
models: [buildModel(settings)],
107142
},
108143
},
109144
};

src/lib/onboard/managed-startup-agent-environment.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,140 @@ describe("managed startup agent environment", () => {
687687
]);
688688
});
689689

690+
it("keeps the Pi managed route credential-free and confined to root-owned proxy files (#7930)", () => {
691+
const result = mapManagedStartupProfileToAgentEnvironment(piProfile());
692+
693+
expect(result.configurationEnvironment).toEqual({
694+
HTTP_PROXY: "",
695+
HTTPS_PROXY: "",
696+
NEMOCLAW_CONTEXT_WINDOW: "",
697+
NEMOCLAW_INFERENCE_API: "openai-completions",
698+
NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local/v1",
699+
NEMOCLAW_INFERENCE_PROVIDER_ID: "inference",
700+
NEMOCLAW_MAX_TOKENS: "",
701+
NEMOCLAW_MODEL: "nvidia/nemotron-3-super-120b-a12b",
702+
NEMOCLAW_REASONING: "",
703+
NEMOCLAW_TOOL_DISCLOSURE: "progressive",
704+
NEMOCLAW_UPSTREAM_PROVIDER: "nvidia",
705+
NO_PROXY: "",
706+
http_proxy: "",
707+
https_proxy: "",
708+
no_proxy: "",
709+
});
710+
const expectedPiRuntime = { ...result.configurationEnvironment };
711+
delete expectedPiRuntime.NEMOCLAW_INFERENCE_BASE_URL;
712+
delete expectedPiRuntime.NEMOCLAW_CONTEXT_WINDOW;
713+
delete expectedPiRuntime.NEMOCLAW_MAX_TOKENS;
714+
delete expectedPiRuntime.NEMOCLAW_REASONING;
715+
for (const name of [
716+
"HTTP_PROXY",
717+
"HTTPS_PROXY",
718+
"NO_PROXY",
719+
"http_proxy",
720+
"https_proxy",
721+
"no_proxy",
722+
]) {
723+
delete expectedPiRuntime[name];
724+
}
725+
expect(result.runtimeEnvironment).toEqual(expectedPiRuntime);
726+
expect(result.materials).toEqual([
727+
{
728+
kind: "corporate-ca-handoff",
729+
legacyInput: "NEMOCLAW_CORPORATE_CA_B64",
730+
expectedSha256: CA_SHA256,
731+
},
732+
{
733+
kind: "root-owned-file",
734+
legacyInput: "NEMOCLAW_PROXY_HOST",
735+
path: "/usr/local/share/nemoclaw/pi-proxy-host",
736+
contents: "10.200.0.1\n",
737+
owner: "root",
738+
group: "root",
739+
mode: 0o444,
740+
},
741+
{
742+
kind: "root-owned-file",
743+
legacyInput: "NEMOCLAW_PROXY_PORT",
744+
path: "/usr/local/share/nemoclaw/pi-proxy-port",
745+
contents: "3128\n",
746+
owner: "root",
747+
group: "root",
748+
mode: 0o444,
749+
},
750+
]);
751+
expect(result.actions).toEqual([
752+
{ kind: "generate-agent-config", agent: "pi", runAs: "sandbox" },
753+
{ kind: "configure-dashboard", dashboard: { agent: "pi", mode: "disabled" } },
754+
]);
755+
756+
const serialized = JSON.stringify(result);
757+
expect(serialized).not.toContain("nvapi-");
758+
expect(serialized).not.toContain("NVIDIA_API_KEY");
759+
expect(serialized).not.toContain("BEGIN CERTIFICATE");
760+
expect(serialized).toContain(CA_SHA256);
761+
});
762+
763+
it("hands Pi model tuning to its config generator and keeps it out of the long-running runtime (#7930)", () => {
764+
const base = piProfile();
765+
const result = mapManagedStartupProfileToAgentEnvironment({
766+
...base,
767+
tuning: { contextWindow: 262_144, maxTokens: 32_000, reasoning: true, reasoningEffort: null },
768+
});
769+
770+
expect(result.configurationEnvironment).toMatchObject({
771+
NEMOCLAW_CONTEXT_WINDOW: "262144",
772+
NEMOCLAW_MAX_TOKENS: "32000",
773+
NEMOCLAW_REASONING: "1",
774+
});
775+
for (const name of ["NEMOCLAW_CONTEXT_WINDOW", "NEMOCLAW_MAX_TOKENS", "NEMOCLAW_REASONING"]) {
776+
expect(result.runtimeEnvironment).not.toHaveProperty(name);
777+
}
778+
expect(
779+
mapManagedStartupProfileToAgentEnvironment({
780+
...base,
781+
tuning: { ...base.tuning, reasoning: false },
782+
}).configurationEnvironment.NEMOCLAW_REASONING,
783+
).toBe("0");
784+
});
785+
786+
it("rebuilds the Pi generator inputs from the current route without retaining the previous one (#7930)", () => {
787+
const base = piProfile();
788+
const before = mapManagedStartupProfileToAgentEnvironment(base);
789+
const after = mapManagedStartupProfileToAgentEnvironment({
790+
...base,
791+
inference: {
792+
...base.inference,
793+
routeProvider: "rebuilt-inference",
794+
upstreamProvider: "openrouter",
795+
model: "openai/gpt-5.4",
796+
routedBaseUrl: "https://rebuilt.inference.local/v1",
797+
},
798+
proxy: { ...base.proxy, managedHost: "10.200.0.9", managedPort: 3129 },
799+
});
800+
801+
expect(after.configurationEnvironment).toMatchObject({
802+
NEMOCLAW_INFERENCE_BASE_URL: "https://rebuilt.inference.local/v1",
803+
NEMOCLAW_INFERENCE_PROVIDER_ID: "rebuilt-inference",
804+
NEMOCLAW_MODEL: "openai/gpt-5.4",
805+
NEMOCLAW_UPSTREAM_PROVIDER: "openrouter",
806+
});
807+
expect(after.materials).toEqual([
808+
before.materials[0],
809+
{ ...before.materials[1], contents: "10.200.0.9\n" },
810+
{ ...before.materials[2], contents: "3129\n" },
811+
]);
812+
const serialized = JSON.stringify(after);
813+
for (const stale of [
814+
"https://inference.local/v1",
815+
"nvidia/nemotron-3-super-120b-a12b",
816+
"10.200.0.1",
817+
"3128",
818+
]) {
819+
expect(serialized).not.toContain(stale);
820+
}
821+
expect(after.actions).toEqual(before.actions);
822+
});
823+
690824
it("feeds the existing OpenClaw and Hermes config consumers without translation", () => {
691825
const openclaw = mapManagedStartupProfileToAgentEnvironment(openClawProfile());
692826
const openclawConfig = buildOpenClawConfig({

src/lib/onboard/managed-startup-onboard-profile.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,85 @@ function dcodeInput(
136136
};
137137
}
138138

139+
function piInput(
140+
overrides: Partial<ManagedStartupOnboardProfileInput> = {},
141+
): ManagedStartupOnboardProfileInput {
142+
return {
143+
agentName: "pi",
144+
inference: {
145+
routeProvider: "inference",
146+
upstreamProvider: "nvidia",
147+
model: "nvidia/nemotron-3-super-120b-a12b",
148+
routedBaseUrl: "https://inference.local/v1",
149+
upstreamEndpointUrl: null,
150+
api: "openai-completions",
151+
primaryModelRef: null,
152+
compatibility: null,
153+
},
154+
chatUiUrl: "",
155+
effectiveDashboardPort: 0,
156+
manageDashboard: false,
157+
dashboardBindAddress: undefined,
158+
wslExposure: false,
159+
hermesDashboardState: { config: null, enabled: false },
160+
webSearch: null,
161+
toolDisclosure: "progressive",
162+
hermesToolGateways: [],
163+
messagingPlan: null,
164+
dcodeAutoApprovalMode: "disabled",
165+
observabilityEnabled: false,
166+
environment: EMPTY_ENVIRONMENT,
167+
corporateCa: null,
168+
...overrides,
169+
};
170+
}
171+
139172
describe("buildManagedStartupOnboardProfile", () => {
173+
it("builds Pi without requiring state another agent owns (#7930)", () => {
174+
const built = buildManagedStartupOnboardProfile(
175+
piInput({
176+
environment: {
177+
NEMOCLAW_CONTEXT_WINDOW: "262144",
178+
NEMOCLAW_MAX_TOKENS: "32000",
179+
NEMOCLAW_REASONING: "true",
180+
},
181+
}),
182+
);
183+
184+
expect(built.profile).toMatchObject({
185+
agent: "pi",
186+
agentConfig: { agent: "pi" },
187+
dashboard: { agent: "pi", mode: "disabled" },
188+
messaging: { plan: null },
189+
tuning: {
190+
contextWindow: 262_144,
191+
maxTokens: 32_000,
192+
reasoning: true,
193+
reasoningEffort: null,
194+
},
195+
});
196+
});
197+
198+
it("rejects Pi web-search intent instead of carrying it into the profile (#7930)", () => {
199+
expect(
200+
buildManagedStartupOnboardProfile(
201+
piInput({ webSearch: { fetchEnabled: true, provider: "tavily" } }),
202+
).profile.agentConfig,
203+
).toEqual({ agent: "pi" });
204+
});
205+
206+
it("rejects Pi messaging intent instead of silently discarding it (#7930)", () => {
207+
expect(() =>
208+
buildManagedStartupOnboardProfile(piInput({ messagingPlan: messagingPlan("openclaw") })),
209+
).toThrow(/pi does not support messaging/);
210+
});
211+
212+
it("rejects a Pi dashboard request (#7930)", () => {
213+
expect(() =>
214+
buildManagedStartupOnboardProfile(piInput({ manageDashboard: true })),
215+
).toThrow(/Pi must not enable a dashboard/);
216+
});
217+
140218
it("maps a remote OpenClaw dashboard and its complete agent-owned state", () => {
141219
const plan = messagingPlan("openclaw");
142220
const built = buildManagedStartupOnboardProfile(

0 commit comments

Comments
 (0)