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
10 changes: 9 additions & 1 deletion nemoclaw/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,19 @@
"openclaw": {
"extensions": [
"./dist/index.js"
]
],
"compat": {
"pluginApi": ">=2026.5.10-beta.1",
"minGatewayVersion": "2026.5.10-beta.1"
},
"build": {
"openclawVersion": "2026.5.10-beta.1"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "vitest run --config vitest.config.ts",
"lint": "biome lint src",
"lint:fix": "biome lint --write src",
"format": "biome format --write src",
Expand Down
10 changes: 7 additions & 3 deletions nemoclaw/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,17 @@ export interface BeforeToolCallResult {
blockReason?: string;
}

/** Return value from a before_agent_start hook. */
export interface BeforeAgentStartResult {
/** Return value from a before_prompt_build hook. */
export interface BeforePromptBuildResult {
systemPrompt?: string;
prependContext?: string;
appendContext?: string;
prependSystemContext?: string;
appendSystemContext?: string;
}

/** Union of all hook result types. */
export type HookResult = BeforeToolCallResult | BeforeAgentStartResult | undefined;
export type HookResult = BeforeToolCallResult | BeforePromptBuildResult | undefined;

/**
* The API object injected into the plugin's register function by the OpenClaw
Expand Down
27 changes: 27 additions & 0 deletions nemoclaw/src/package-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";

const packageJson = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
) as {
openclaw?: {
compat?: {
pluginApi?: unknown;
minGatewayVersion?: unknown;
};
build?: {
openclawVersion?: unknown;
};
};
};

describe("OpenClaw package metadata", () => {
it("declares the required external plugin compatibility fields", () => {
expect(packageJson.openclaw?.compat?.pluginApi).toBe(">=2026.5.10-beta.1");
expect(packageJson.openclaw?.compat?.minGatewayVersion).toBe("2026.5.10-beta.1");
expect(packageJson.openclaw?.build?.openclawVersion).toBe("2026.5.10-beta.1");
});
});
2 changes: 1 addition & 1 deletion nemoclaw/src/register.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ describe("plugin registration", () => {
it("continues registration when the runtime context hook is unsupported", () => {
const api = createMockApi();
vi.mocked(api.on).mockImplementation((hookName: string) => {
if (hookName === "before_agent_start") {
if (hookName === "before_prompt_build") {
throw new Error("unsupported hook");
}
});
Expand Down
67 changes: 40 additions & 27 deletions nemoclaw/src/runtime-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,27 +330,32 @@ network_policies: {}

describe("registerRuntimeContext", () => {
describe("hook registration", () => {
it("registers a before_agent_start hook", () => {
it("registers a before_prompt_build hook", () => {
const { api } = makeMockApi();
const onSpy = vi.spyOn(api, "on");
registerRuntimeContext(api, defaultConfig);
expect(onSpy).toHaveBeenCalledWith("before_agent_start", expect.any(Function));
expect(onSpy).toHaveBeenCalledWith("before_prompt_build", expect.any(Function));
expect(onSpy).not.toHaveBeenCalledWith(`before_${"agent_start"}`, expect.any(Function));
});
});

describe("first call — full context injection", () => {
it("returns an object with prependContext", async () => {
const { api } = makeMockApi();
registerRuntimeContext(api, defaultConfig);
const result = await api._trigger("before_agent_start", {}, { sessionKey: nextSessionKey() });
const result = await api._trigger(
"before_prompt_build",
{},
{ sessionKey: nextSessionKey() },
);
expect(result).toHaveProperty("prependContext");
});

it("prependContext opens and closes nemoclaw-runtime tags", async () => {
const { api } = makeMockApi();
registerRuntimeContext(api, defaultConfig);
const result = (await api._trigger(
"before_agent_start",
"before_prompt_build",
{},
{ sessionKey: nextSessionKey() },
)) as {
Expand All @@ -364,7 +369,7 @@ describe("registerRuntimeContext", () => {
const { api } = makeMockApi();
registerRuntimeContext(api, defaultConfig);
const result = (await api._trigger(
"before_agent_start",
"before_prompt_build",
{},
{ sessionKey: nextSessionKey() },
)) as {
Expand All @@ -377,7 +382,7 @@ describe("registerRuntimeContext", () => {
const { api } = makeMockApi();
registerRuntimeContext(api, defaultConfig);
const result = (await api._trigger(
"before_agent_start",
"before_prompt_build",
{},
{ sessionKey: nextSessionKey() },
)) as {
Expand All @@ -390,7 +395,7 @@ describe("registerRuntimeContext", () => {
const { api } = makeMockApi();
registerRuntimeContext(api, defaultConfig);
const result = (await api._trigger(
"before_agent_start",
"before_prompt_build",
{},
{ sessionKey: nextSessionKey() },
)) as {
Expand All @@ -403,7 +408,7 @@ describe("registerRuntimeContext", () => {
const { api } = makeMockApi();
registerRuntimeContext(api, defaultConfig);
const result = (await api._trigger(
"before_agent_start",
"before_prompt_build",
{},
{ sessionKey: nextSessionKey() },
)) as {
Expand All @@ -416,7 +421,7 @@ describe("registerRuntimeContext", () => {
const { api } = makeMockApi();
registerRuntimeContext(api, defaultConfig);
const result = (await api._trigger(
"before_agent_start",
"before_prompt_build",
{},
{ sessionKey: nextSessionKey() },
)) as {
Expand All @@ -431,16 +436,20 @@ describe("registerRuntimeContext", () => {
const { api } = makeMockApi();
registerRuntimeContext(api, defaultConfig);
const key = { sessionKey: nextSessionKey() };
await api._trigger("before_agent_start", {}, key);
const second = await api._trigger("before_agent_start", {}, key);
await api._trigger("before_prompt_build", {}, key);
const second = await api._trigger("before_prompt_build", {}, key);
expect(second).toBeUndefined();
});

it("re-injects for a different session key", async () => {
const { api } = makeMockApi();
registerRuntimeContext(api, defaultConfig);
await api._trigger("before_agent_start", {}, { sessionKey: nextSessionKey() });
const result = await api._trigger("before_agent_start", {}, { sessionKey: nextSessionKey() });
await api._trigger("before_prompt_build", {}, { sessionKey: nextSessionKey() });
const result = await api._trigger(
"before_prompt_build",
{},
{ sessionKey: nextSessionKey() },
);
expect(result).toHaveProperty("prependContext");
});
});
Expand All @@ -452,7 +461,7 @@ describe("registerRuntimeContext", () => {
const key = { sessionKey: nextSessionKey() };

// First call: Running
await api._trigger("before_agent_start", {}, key);
await api._trigger("before_prompt_build", {}, key);

// Phase changes
mockExecFile({
Expand All @@ -461,7 +470,7 @@ describe("registerRuntimeContext", () => {
"policy get openclaw": POLICY_SUMMARY,
});

const result = (await api._trigger("before_agent_start", {}, key)) as {
const result = (await api._trigger("before_prompt_build", {}, key)) as {
prependContext: string;
};
expect(result.prependContext).toContain("<nemoclaw-runtime-update>");
Expand All @@ -473,15 +482,15 @@ describe("registerRuntimeContext", () => {
registerRuntimeContext(api, defaultConfig);
const key = { sessionKey: nextSessionKey() };

await api._trigger("before_agent_start", {}, key);
await api._trigger("before_prompt_build", {}, key);

mockExecFile({
"sandbox get openclaw": SANDBOX_RUNNING,
"policy get openclaw --full": POLICY_FULL_EMPTY_NETWORK,
"policy get openclaw": "Version: 1.1\nHash: newHash999\nStatus: active",
});

const result = await api._trigger("before_agent_start", {}, key);
const result = await api._trigger("before_prompt_build", {}, key);
expect(result).toHaveProperty("prependContext");
expect((result as { prependContext: string }).prependContext).toContain(
"<nemoclaw-runtime-update>",
Expand All @@ -493,15 +502,15 @@ describe("registerRuntimeContext", () => {
registerRuntimeContext(api, defaultConfig);
const key = { sessionKey: nextSessionKey() };

await api._trigger("before_agent_start", {}, key);
await api._trigger("before_prompt_build", {}, key);

mockExecFile({
"sandbox get openclaw": SANDBOX_STOPPED,
"policy get openclaw --full": POLICY_FULL_WITH_RULES,
"policy get openclaw": "Version: 2.0\nHash: new123\nStatus: active",
});

const result = (await api._trigger("before_agent_start", {}, key)) as {
const result = (await api._trigger("before_prompt_build", {}, key)) as {
prependContext: string;
};
expect(result.prependContext).toContain("<nemoclaw-runtime-update>");
Expand All @@ -518,7 +527,7 @@ describe("registerRuntimeContext", () => {
const { api, warnMessages } = makeMockApi();
registerRuntimeContext(api, defaultConfig);
const result = (await api._trigger(
"before_agent_start",
"before_prompt_build",
{},
{ sessionKey: nextSessionKey() },
)) as {
Expand All @@ -540,7 +549,7 @@ describe("registerRuntimeContext", () => {
const { api } = makeMockApi();
registerRuntimeContext(api, defaultConfig);
const result = (await api._trigger(
"before_agent_start",
"before_prompt_build",
{},
{ sessionKey: nextSessionKey() },
)) as {
Expand All @@ -558,7 +567,11 @@ describe("registerRuntimeContext", () => {

const { api } = makeMockApi();
registerRuntimeContext(api, defaultConfig);
const result = await api._trigger("before_agent_start", {}, { sessionKey: nextSessionKey() });
const result = await api._trigger(
"before_prompt_build",
{},
{ sessionKey: nextSessionKey() },
);
// Null fingerprint fields are handled gracefully — context is still injected
expect(result).toHaveProperty("prependContext");
});
Expand All @@ -571,8 +584,8 @@ describe("registerRuntimeContext", () => {

// Two separate calls with the same session key
const sessionKey = "isolated-session-key";
const first = await api._trigger("before_agent_start", {}, { sessionKey });
const second = await api._trigger("before_agent_start", {}, { sessionKey });
const first = await api._trigger("before_prompt_build", {}, { sessionKey });
const second = await api._trigger("before_prompt_build", {}, { sessionKey });

expect(first).toHaveProperty("prependContext");
expect(second).toBeUndefined();
Expand All @@ -582,8 +595,8 @@ describe("registerRuntimeContext", () => {
const { api } = makeMockApi();
registerRuntimeContext(api, defaultConfig);

const first = await api._trigger("before_agent_start", {}, {});
const second = await api._trigger("before_agent_start", {}, {});
const first = await api._trigger("before_prompt_build", {}, {});
const second = await api._trigger("before_prompt_build", {}, {});

// Without a session key caching is disabled — both calls must inject context.
expect(first).toHaveProperty("prependContext");
Expand All @@ -603,7 +616,7 @@ describe("registerRuntimeContext", () => {
const { api } = makeMockApi();
registerRuntimeContext(api, cfg);

const result = await api._trigger("before_agent_start", {}, null);
const result = await api._trigger("before_prompt_build", {}, null);
expect(result).toHaveProperty("prependContext");
});
});
Expand Down
4 changes: 2 additions & 2 deletions nemoclaw/src/runtime-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,14 +464,14 @@ export async function getRuntimeSummary(pluginConfig: NemoClawConfig): Promise<R
}

/**
* Registers a `before_agent_start` hook that prepends a `<nemoclaw-runtime>`
* Registers a `before_prompt_build` hook that prepends a `<nemoclaw-runtime>`
* context block (or a `<nemoclaw-runtime-update>` delta) to each agent turn.
*
* Falls back to a minimal static context block if openshell is unavailable or
* any internal error occurs, and logs a warning via `api.logger`.
*/
export function registerRuntimeContext(api: OpenClawPluginApi, pluginConfig: NemoClawConfig): void {
api.on("before_agent_start", async (_event: unknown, hookContext: unknown) => {
api.on("before_prompt_build", async (_event: unknown, hookContext: unknown) => {
// Initialise to the configured default; overwritten below with the live
// state value so the fallback block reflects the active sandbox name even
// when the sandbox was changed after the plugin was initialised.
Expand Down
11 changes: 11 additions & 0 deletions nemoclaw/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});
Loading