From 187d849487c82a9fcba8325f0a2958c3569cfe24 Mon Sep 17 00:00:00 2001 From: Ishan Dhanani Date: Wed, 24 Jun 2026 18:21:03 +0000 Subject: [PATCH 1/5] feat: add OpenClaw Dynamo provider Signed-off-by: Ishan Dhanani --- .github/workflows/integration-smoke.yml | 3 ++ README.md | 1 + openclaw-plugin/README.md | 54 ++++++++++++++++++++++++ openclaw-plugin/headers.js | 26 ++++++++++++ openclaw-plugin/index.js | 19 +++++++++ openclaw-plugin/openclaw.plugin.json | 17 ++++++++ openclaw-plugin/package.json | 18 ++++++++ openclaw-plugin/test/plugin.test.js | 56 +++++++++++++++++++++++++ 8 files changed, 194 insertions(+) create mode 100644 openclaw-plugin/README.md create mode 100644 openclaw-plugin/headers.js create mode 100644 openclaw-plugin/index.js create mode 100644 openclaw-plugin/openclaw.plugin.json create mode 100644 openclaw-plugin/package.json create mode 100644 openclaw-plugin/test/plugin.test.js diff --git a/.github/workflows/integration-smoke.yml b/.github/workflows/integration-smoke.yml index 345a2a4..9759217 100644 --- a/.github/workflows/integration-smoke.yml +++ b/.github/workflows/integration-smoke.yml @@ -69,6 +69,9 @@ jobs: - name: Test Hermes plugin run: python3 -m unittest discover -s hermes-plugin/tests + - name: Test OpenClaw plugin + run: npm test --prefix openclaw-plugin + - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@master with: diff --git a/README.md b/README.md index 5a28730..fb9b28d 100644 --- a/README.md +++ b/README.md @@ -6,5 +6,6 @@ Small agent integrations for Dynamo request tracing. - `pi-plugin/` - Pi provider plugin for Dynamo's OpenAI-compatible endpoint. - `hermes-plugin/` - Hermes middleware plugin that maps Hermes `session_id` to `x-dynamo-session-id`. +- `openclaw-plugin/` - OpenClaw provider plugin that maps OpenClaw `sessionId` to `x-dynamo-session-id`. Each plugin owns its own tests and install instructions. diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md new file mode 100644 index 0000000..24abb23 --- /dev/null +++ b/openclaw-plugin/README.md @@ -0,0 +1,54 @@ +# OpenClaw Dynamo Provider + +OpenClaw provider plugin that copies the current OpenClaw `sessionId` into +`x-dynamo-session-id` on each request sent through the `dynamo` provider. + +## Install + +```bash +git clone https://github.com/ai-dynamo/agent-plugins.git ~/agent-plugins +openclaw plugins install --link ~/agent-plugins/openclaw-plugin +openclaw plugins enable dynamo +``` + +## Configure + +Add a Dynamo-backed model to `~/.openclaw/openclaw.json`: + +```json5 +{ + models: { + providers: { + dynamo: { + baseUrl: "http://127.0.0.1:8000/v1", + apiKey: "dynamo-local", + api: "openai-responses", + models: [ + { + id: "zai-org/GLM-4.7-Flash", + name: "Dynamo GLM 4.7 Flash", + reasoning: true, + contextWindow: 128000, + maxTokens: 8192, + }, + ], + }, + }, + }, + agents: { + defaults: { + model: { primary: "dynamo/zai-org/GLM-4.7-Flash" }, + }, + }, +} +``` + +The plugin preserves an explicitly supplied `x-dynamo-session-id`. Session +headers carry identity only; they do not enable sticky routing. + +## Validate + +```bash +cd openclaw-plugin +npm test +``` diff --git a/openclaw-plugin/headers.js b/openclaw-plugin/headers.js new file mode 100644 index 0000000..8c0a269 --- /dev/null +++ b/openclaw-plugin/headers.js @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const DYNAMO_SESSION_HEADER = "x-dynamo-session-id"; + +function hasHeader(headers, name) { + const lowerName = name.toLowerCase(); + return Object.keys(headers).some((key) => key.toLowerCase() === lowerName); +} + +export function withDynamoSessionHeader(options) { + const sessionId = options?.sessionId?.trim(); + if (!sessionId) return options; + + const headers = { ...options.headers }; + if (!hasHeader(headers, DYNAMO_SESSION_HEADER)) { + headers[DYNAMO_SESSION_HEADER] = sessionId; + } + return { ...options, headers }; +} + +export function wrapDynamoStreamFn(streamFn) { + if (!streamFn) return undefined; + return (model, context, options) => + streamFn(model, context, withDynamoSessionHeader(options)); +} diff --git a/openclaw-plugin/index.js b/openclaw-plugin/index.js new file mode 100644 index 0000000..caea09c --- /dev/null +++ b/openclaw-plugin/index.js @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { wrapDynamoStreamFn } from "./headers.js"; + +export default { + id: "dynamo", + name: "Dynamo Provider", + description: "Send OpenClaw session identity to NVIDIA Dynamo.", + register(api) { + const wrapStreamFn = ({ streamFn }) => wrapDynamoStreamFn(streamFn); + api.registerProvider({ + id: "dynamo", + label: "Dynamo", + wrapStreamFn, + wrapSimpleCompletionStreamFn: wrapStreamFn, + }); + }, +}; diff --git a/openclaw-plugin/openclaw.plugin.json b/openclaw-plugin/openclaw.plugin.json new file mode 100644 index 0000000..1fb39c9 --- /dev/null +++ b/openclaw-plugin/openclaw.plugin.json @@ -0,0 +1,17 @@ +{ + "id": "dynamo", + "name": "Dynamo Provider", + "description": "Adds OpenClaw session identity to requests sent through a Dynamo provider.", + "version": "0.1.0", + "activation": { + "onStartup": false + }, + "providers": [ + "dynamo" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/openclaw-plugin/package.json b/openclaw-plugin/package.json new file mode 100644 index 0000000..e2ec013 --- /dev/null +++ b/openclaw-plugin/package.json @@ -0,0 +1,18 @@ +{ + "name": "@ai-dynamo/openclaw-provider", + "version": "0.1.0", + "private": true, + "description": "OpenClaw provider plugin for NVIDIA Dynamo", + "type": "module", + "scripts": { + "test": "node --test" + }, + "openclaw": { + "extensions": [ + "./index.js" + ], + "compat": { + "pluginApi": ">=2026.6.8" + } + } +} diff --git a/openclaw-plugin/test/plugin.test.js b/openclaw-plugin/test/plugin.test.js new file mode 100644 index 0000000..018f951 --- /dev/null +++ b/openclaw-plugin/test/plugin.test.js @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import plugin from "../index.js"; + +function registeredProvider() { + let provider; + plugin.register({ + registerProvider(value) { + provider = value; + }, + }); + return provider; +} + +test("registers a Dynamo provider that adds the OpenClaw session ID", () => { + const provider = registeredProvider(); + const stream = provider.wrapStreamFn({ streamFn: (_model, _context, options) => options }); + + assert.equal(provider.id, "dynamo"); + assert.deepEqual(stream({}, {}, { sessionId: " openclaw-session ", headers: { "x-test": "1" } }), { + sessionId: " openclaw-session ", + headers: { + "x-test": "1", + "x-dynamo-session-id": "openclaw-session", + }, + }); +}); + +test("preserves an explicit Dynamo session header case-insensitively", () => { + const provider = registeredProvider(); + const stream = provider.wrapSimpleCompletionStreamFn({ + streamFn: (_model, _context, options) => options, + }); + + assert.deepEqual( + stream({}, {}, { + sessionId: "runtime-session", + headers: { "X-Dynamo-Session-ID": "explicit-session" }, + }), + { + sessionId: "runtime-session", + headers: { "X-Dynamo-Session-ID": "explicit-session" }, + }, + ); +}); + +test("leaves requests without a session ID unchanged", () => { + const provider = registeredProvider(); + const options = { headers: { "x-test": "1" } }; + const stream = provider.wrapStreamFn({ streamFn: (_model, _context, value) => value }); + + assert.equal(stream({}, {}, options), options); +}); From 2047d650833f6f8bf0707916e9af8684eaa15e18 Mon Sep 17 00:00:00 2001 From: Ishan Dhanani Date: Wed, 24 Jun 2026 19:25:08 +0000 Subject: [PATCH 2/5] openclaw: propagate subagent parent sessions Signed-off-by: Ishan Dhanani --- openclaw-plugin/README.md | 3 +- openclaw-plugin/headers.js | 22 +++++++++--- openclaw-plugin/index.js | 38 +++++++++++++++++++- openclaw-plugin/test/plugin.test.js | 54 +++++++++++++++++++++++++++-- 4 files changed, 109 insertions(+), 8 deletions(-) diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md index 24abb23..27f618a 100644 --- a/openclaw-plugin/README.md +++ b/openclaw-plugin/README.md @@ -1,7 +1,8 @@ # OpenClaw Dynamo Provider OpenClaw provider plugin that copies the current OpenClaw `sessionId` into -`x-dynamo-session-id` on each request sent through the `dynamo` provider. +`x-dynamo-session-id` on each request sent through the `dynamo` provider. Native +subagents also send their immediate parent's ID in `x-dynamo-parent-session-id`. ## Install diff --git a/openclaw-plugin/headers.js b/openclaw-plugin/headers.js index 8c0a269..810c562 100644 --- a/openclaw-plugin/headers.js +++ b/openclaw-plugin/headers.js @@ -2,13 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 export const DYNAMO_SESSION_HEADER = "x-dynamo-session-id"; +export const DYNAMO_PARENT_SESSION_HEADER = "x-dynamo-parent-session-id"; function hasHeader(headers, name) { const lowerName = name.toLowerCase(); return Object.keys(headers).some((key) => key.toLowerCase() === lowerName); } -export function withDynamoSessionHeader(options) { +export function withDynamoSessionHeaders(options, parentSessionId) { const sessionId = options?.sessionId?.trim(); if (!sessionId) return options; @@ -16,11 +17,24 @@ export function withDynamoSessionHeader(options) { if (!hasHeader(headers, DYNAMO_SESSION_HEADER)) { headers[DYNAMO_SESSION_HEADER] = sessionId; } + if ( + parentSessionId && + parentSessionId !== sessionId && + !hasHeader(headers, DYNAMO_PARENT_SESSION_HEADER) + ) { + headers[DYNAMO_PARENT_SESSION_HEADER] = parentSessionId; + } return { ...options, headers }; } -export function wrapDynamoStreamFn(streamFn) { +export function wrapDynamoStreamFn(streamFn, resolveParentSessionId) { if (!streamFn) return undefined; - return (model, context, options) => - streamFn(model, context, withDynamoSessionHeader(options)); + return (model, context, options) => { + const sessionId = options?.sessionId?.trim(); + return streamFn( + model, + context, + withDynamoSessionHeaders(options, sessionId && resolveParentSessionId?.(sessionId)), + ); + }; } diff --git a/openclaw-plugin/index.js b/openclaw-plugin/index.js index caea09c..ad91f27 100644 --- a/openclaw-plugin/index.js +++ b/openclaw-plugin/index.js @@ -8,7 +8,43 @@ export default { name: "Dynamo Provider", description: "Send OpenClaw session identity to NVIDIA Dynamo.", register(api) { - const wrapStreamFn = ({ streamFn }) => wrapDynamoStreamFn(streamFn); + const parentSessionIds = new Map(); + const rememberParentSession = (sessionId, sessionKey) => { + const session = api.runtime.agent.session; + const entry = session.getSessionEntry({ sessionKey }); + const parentKey = entry?.parentSessionKey?.trim() || entry?.spawnedBy?.trim(); + const parentSessionId = parentKey + ? session.getSessionEntry({ sessionKey: parentKey })?.sessionId?.trim() + : undefined; + parentSessionIds.set( + sessionId, + parentSessionId && parentSessionId !== sessionId ? parentSessionId : undefined, + ); + return parentSessionIds.get(sessionId); + }; + + api.on("before_model_resolve", (_event, context) => { + const sessionId = context.sessionId?.trim(); + const sessionKey = context.sessionKey?.trim(); + if (sessionId && sessionKey) rememberParentSession(sessionId, sessionKey); + }); + api.on("session_end", (event) => { + const sessionId = event.sessionId.trim(); + const nextSessionId = event.nextSessionId?.trim(); + if (nextSessionId && parentSessionIds.has(sessionId)) { + parentSessionIds.set(nextSessionId, parentSessionIds.get(sessionId)); + } + parentSessionIds.delete(sessionId); + }); + + const wrapStreamFn = ({ streamFn, agentId }) => + wrapDynamoStreamFn(streamFn, (sessionId) => { + if (parentSessionIds.has(sessionId)) return parentSessionIds.get(sessionId); + const match = api.runtime.agent.session + .listSessionEntries({ agentId }) + .find(({ entry }) => entry.sessionId === sessionId); + return match ? rememberParentSession(sessionId, match.sessionKey) : undefined; + }); api.registerProvider({ id: "dynamo", label: "Dynamo", diff --git a/openclaw-plugin/test/plugin.test.js b/openclaw-plugin/test/plugin.test.js index 018f951..faacd84 100644 --- a/openclaw-plugin/test/plugin.test.js +++ b/openclaw-plugin/test/plugin.test.js @@ -5,14 +5,34 @@ import assert from "node:assert/strict"; import test from "node:test"; import plugin from "../index.js"; -function registeredProvider() { +function registerPlugin(entries = {}) { let provider; + const hooks = {}; plugin.register({ + on(name, handler) { + hooks[name] = handler; + }, registerProvider(value) { provider = value; }, + runtime: { + agent: { + session: { + getSessionEntry({ sessionKey }) { + return entries[sessionKey]; + }, + listSessionEntries() { + return Object.entries(entries).map(([sessionKey, entry]) => ({ sessionKey, entry })); + }, + }, + }, + }, }); - return provider; + return { hooks, provider }; +} + +function registeredProvider() { + return registerPlugin().provider; } test("registers a Dynamo provider that adds the OpenClaw session ID", () => { @@ -54,3 +74,33 @@ test("leaves requests without a session ID unchanged", () => { assert.equal(stream({}, {}, options), options); }); + +test("adds immediate parent identity for OpenClaw subagents", () => { + const { hooks, provider } = registerPlugin({ + "agent:main:main": { sessionId: "parent", updatedAt: 1 }, + "agent:main:subagent:child": { + sessionId: "child", + spawnedBy: "agent:main:main", + updatedAt: 2, + }, + }); + hooks.before_model_resolve({}, { + sessionId: "child", + sessionKey: "agent:main:subagent:child", + }); + const stream = provider.wrapStreamFn({ + agentId: "main", + streamFn: (_model, _context, options) => options, + }); + + assert.deepEqual(stream({}, {}, { sessionId: "child" }).headers, { + "x-dynamo-session-id": "child", + "x-dynamo-parent-session-id": "parent", + }); + + hooks.session_end({ sessionId: "child", nextSessionId: "child-after-compaction" }); + assert.equal( + stream({}, {}, { sessionId: "child-after-compaction" }).headers["x-dynamo-parent-session-id"], + "parent", + ); +}); From fcb6cdf62b1812023d1151dfeb487511db01ba66 Mon Sep 17 00:00:00 2001 From: Ishan Dhanani Date: Wed, 24 Jun 2026 20:00:49 +0000 Subject: [PATCH 3/5] ci: test OpenClaw plugin against latest Signed-off-by: Ishan Dhanani --- .github/workflows/integration-smoke.yml | 9 ++++++++- openclaw-plugin/README.md | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/integration-smoke.yml b/.github/workflows/integration-smoke.yml index 9759217..e881b7a 100644 --- a/.github/workflows/integration-smoke.yml +++ b/.github/workflows/integration-smoke.yml @@ -70,7 +70,14 @@ jobs: run: python3 -m unittest discover -s hermes-plugin/tests - name: Test OpenClaw plugin - run: npm test --prefix openclaw-plugin + run: | + npm test --prefix openclaw-plugin + npm install --global openclaw@latest + state_dir="$(mktemp -d)" + OPENCLAW_STATE_DIR="$state_dir" openclaw plugins install --link "$GITHUB_WORKSPACE/openclaw-plugin" + OPENCLAW_STATE_DIR="$state_dir" openclaw plugins enable dynamo + OPENCLAW_STATE_DIR="$state_dir" openclaw plugins inspect dynamo --json | + jq -e '.plugin.status == "loaded" and (.plugin.providerIds | index("dynamo") != null)' - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@master diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md index 27f618a..27cd003 100644 --- a/openclaw-plugin/README.md +++ b/openclaw-plugin/README.md @@ -4,6 +4,10 @@ OpenClaw provider plugin that copies the current OpenClaw `sessionId` into `x-dynamo-session-id` on each request sent through the `dynamo` provider. Native subagents also send their immediate parent's ID in `x-dynamo-parent-session-id`. +Live Dynamo integration is tested with OpenClaw `2026.6.8`. CI also installs +OpenClaw's latest release and verifies that the plugin loads and registers the +`dynamo` provider. + ## Install ```bash From 0dd38621aae99684dd9ebf0387cbee5d72c1f05b Mon Sep 17 00:00:00 2001 From: Ishan Dhanani Date: Wed, 24 Jun 2026 20:20:14 +0000 Subject: [PATCH 4/5] ci: add agent compatibility regression coverage Signed-off-by: Ishan Dhanani --- .github/workflows/integration-smoke.yml | 135 +++++++++++-- hermes-plugin/README.md | 3 + hermes-plugin/tests/upstream_smoke.py | 48 +++++ openclaw-plugin/README.md | 6 +- openclaw-plugin/package.json | 3 +- openclaw-plugin/test/runtime-smoke.mjs | 246 ++++++++++++++++++++++++ pi-plugin/README.md | 3 + 7 files changed, 427 insertions(+), 17 deletions(-) create mode 100644 hermes-plugin/tests/upstream_smoke.py create mode 100644 openclaw-plugin/test/runtime-smoke.mjs diff --git a/.github/workflows/integration-smoke.yml b/.github/workflows/integration-smoke.yml index e881b7a..6944695 100644 --- a/.github/workflows/integration-smoke.yml +++ b/.github/workflows/integration-smoke.yml @@ -15,6 +15,8 @@ on: branches: [main] push: branches: [main] + schedule: + - cron: "17 8 * * *" workflow_dispatch: inputs: dynamo_ref: @@ -30,6 +32,126 @@ env: DYNAMO_TEST_MODEL_ID: "Qwen/Qwen3-0.6B" jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: pi-plugin/package-lock.json + + - name: Test Pi plugin + run: | + npm ci --prefix pi-plugin + npm run check --prefix pi-plugin + npm test --prefix pi-plugin + npm run build --prefix pi-plugin + + - name: Test Hermes plugin + run: python3 -m unittest discover -s hermes-plugin/tests -p 'test_*.py' + + - name: Test OpenClaw plugin + run: npm test --prefix openclaw-plugin + + pi-latest: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: pi-plugin/package-lock.json + + - name: Test latest Pi API + run: | + npm ci --prefix pi-plugin + npm install --prefix pi-plugin --no-save \ + @mariozechner/pi-ai@latest \ + @mariozechner/pi-coding-agent@latest + node -e 'for (const name of ["pi-ai", "pi-coding-agent"]) { const p = require(`./pi-plugin/node_modules/@mariozechner/${name}/package.json`); console.log(`${name} ${p.version}`) }' + npm run check --prefix pi-plugin + npm test --prefix pi-plugin + npm run build --prefix pi-plugin + + hermes: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + hermes: ["a7983d5ad768551508667e8c708e13def7ee28ab", "main"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Checkout Hermes ${{ matrix.hermes }} + run: | + git clone --filter=blob:none --no-checkout https://github.com/NousResearch/hermes-agent.git hermes-agent + git -C hermes-agent fetch --depth 1 origin "${{ matrix.hermes }}" + git -C hermes-agent checkout --detach FETCH_HEAD + git -C hermes-agent log -1 --oneline + + - name: Install Hermes + run: uv sync --project hermes-agent --no-dev + + - name: Test real plugin loader and AIAgent contract + run: | + state_dir=$(mktemp -d) + mkdir -p "$state_dir/plugins" + ln -s "$GITHUB_WORKSPACE/hermes-plugin" "$state_dir/plugins/dynamo_session" + HERMES_HOME="$state_dir" uv run --project hermes-agent --no-sync \ + hermes plugins enable dynamo_session + HERMES_HOME="$state_dir" uv run --project hermes-agent --no-sync \ + hermes plugins list --enabled --no-bundled --plain | grep -q dynamo_session + HERMES_HOME="$state_dir" uv run --project hermes-agent --no-sync \ + python "$GITHUB_WORKSPACE/hermes-plugin/tests/upstream_smoke.py" + + openclaw: + runs-on: ubuntu-latest + timeout-minutes: 5 + strategy: + fail-fast: false + matrix: + openclaw: ["2026.6.8", "latest"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install OpenClaw ${{ matrix.openclaw }} + run: npm install --global openclaw@${{ matrix.openclaw }} + + - name: Test plugin + env: + OPENCLAW_TEST_VERSION: ${{ matrix.openclaw }} + run: | + openclaw --version + npm run test:runtime --prefix openclaw-plugin + smoke: runs-on: ubuntu-latest timeout-minutes: 30 @@ -66,19 +188,6 @@ jobs: with: python-version: "3.11" - - name: Test Hermes plugin - run: python3 -m unittest discover -s hermes-plugin/tests - - - name: Test OpenClaw plugin - run: | - npm test --prefix openclaw-plugin - npm install --global openclaw@latest - state_dir="$(mktemp -d)" - OPENCLAW_STATE_DIR="$state_dir" openclaw plugins install --link "$GITHUB_WORKSPACE/openclaw-plugin" - OPENCLAW_STATE_DIR="$state_dir" openclaw plugins enable dynamo - OPENCLAW_STATE_DIR="$state_dir" openclaw plugins inspect dynamo --json | - jq -e '.plugin.status == "loaded" and (.plugin.providerIds | index("dynamo") != null)' - - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@master with: diff --git a/hermes-plugin/README.md b/hermes-plugin/README.md index c3f61a1..c2bc4e5 100644 --- a/hermes-plugin/README.md +++ b/hermes-plugin/README.md @@ -2,6 +2,9 @@ Hermes plugin that copies the current Hermes `session_id` into Dynamo's `x-dynamo-session-id` request header. +Tested with Hermes `0.17.0` at `a7983d5`. CI also validates the plugin against +the latest Hermes `main` using the real plugin loader and `AIAgent` class. + ## Install ```bash diff --git a/hermes-plugin/tests/upstream_smoke.py b/hermes-plugin/tests/upstream_smoke.py new file mode 100644 index 0000000..426ec7d --- /dev/null +++ b/hermes-plugin/tests/upstream_smoke.py @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Exercise the plugin against Hermes' real AIAgent class.""" + +import importlib.util +import pathlib + +from run_agent import AIAgent + + +PLUGIN_PATH = pathlib.Path(__file__).resolve().parents[1] / "__init__.py" + + +def main() -> None: + original = AIAgent._create_openai_client + + def sentinel(self, client_kwargs, *, reason, shared): + return client_kwargs + + try: + AIAgent._create_openai_client = sentinel + spec = importlib.util.spec_from_file_location("dynamo_session_plugin", PLUGIN_PATH) + assert spec and spec.loader + plugin = importlib.util.module_from_spec(spec) + spec.loader.exec_module(plugin) + + hooks = {} + plugin.register(type("Context", (), {"register_hook": lambda _, name, fn: hooks.setdefault(name, fn)})()) + hooks["pre_api_request"]() + + agent = object.__new__(AIAgent) + agent.session_id = "hermes-upstream-smoke" + result = agent._create_openai_client( + {"default_headers": {"x-test": "1"}}, + reason="ci", + shared=False, + ) + assert result["default_headers"] == { + "x-test": "1", + "x-dynamo-session-id": "hermes-upstream-smoke", + } + finally: + AIAgent._create_openai_client = original + + +if __name__ == "__main__": + main() diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md index 27cd003..d601136 100644 --- a/openclaw-plugin/README.md +++ b/openclaw-plugin/README.md @@ -4,9 +4,9 @@ OpenClaw provider plugin that copies the current OpenClaw `sessionId` into `x-dynamo-session-id` on each request sent through the `dynamo` provider. Native subagents also send their immediate parent's ID in `x-dynamo-parent-session-id`. -Live Dynamo integration is tested with OpenClaw `2026.6.8`. CI also installs -OpenClaw's latest release and verifies that the plugin loads and registers the -`dynamo` provider. +Live Dynamo integration is tested with OpenClaw `2026.6.8`. CI runs the real +OpenClaw agent loop on both `2026.6.8` and the latest release, forcing a native +subagent and asserting stable root plus exact child/parent request headers. ## Install diff --git a/openclaw-plugin/package.json b/openclaw-plugin/package.json index e2ec013..f84f3a2 100644 --- a/openclaw-plugin/package.json +++ b/openclaw-plugin/package.json @@ -5,7 +5,8 @@ "description": "OpenClaw provider plugin for NVIDIA Dynamo", "type": "module", "scripts": { - "test": "node --test" + "test": "node --test test/plugin.test.js", + "test:runtime": "node test/runtime-smoke.mjs" }, "openclaw": { "extensions": [ diff --git a/openclaw-plugin/test/runtime-smoke.mjs b/openclaw-plugin/test/runtime-smoke.mjs new file mode 100644 index 0000000..3f74e9a --- /dev/null +++ b/openclaw-plugin/test/runtime-smoke.mjs @@ -0,0 +1,246 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; + +const pluginRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const runRoot = await mkdtemp(path.join(tmpdir(), "openclaw-dynamo-smoke-")); +const stateDir = path.join(runRoot, "state"); +const workspace = path.join(runRoot, "workspace"); +const rootSessionId = "openclaw-ci-root"; +const requests = []; + +function responseCompleted(output) { + return { + type: "response.completed", + response: { + id: "resp_openclaw_ci", + status: "completed", + output, + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }; +} + +function textEvents(text) { + const item = { + type: "message", + id: "msg_openclaw_ci", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text, annotations: [] }], + }; + return [ + { + type: "response.output_item.added", + item: { ...item, status: "in_progress", content: [] }, + }, + { + type: "response.output_text.delta", + item_id: item.id, + output_index: 0, + content_index: 0, + delta: text, + }, + { + type: "response.output_text.done", + item_id: item.id, + output_index: 0, + content_index: 0, + text, + }, + { type: "response.output_item.done", item }, + responseCompleted([item]), + ]; +} + +function toolEvents(name, argumentsValue) { + const argumentsJson = JSON.stringify(argumentsValue); + const item = { + type: "function_call", + id: `fc_${name}`, + call_id: `call_${name}`, + name, + arguments: argumentsJson, + }; + return [ + { + type: "response.output_item.added", + item: { ...item, arguments: "" }, + }, + { + type: "response.function_call_arguments.delta", + item_id: item.id, + output_index: 0, + delta: argumentsJson, + }, + { type: "response.output_item.done", item }, + responseCompleted([item]), + ]; +} + +function writeJson(response, body) { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); +} + +function writeSse(response, events) { + const body = `${events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")}data: [DONE]\n\n`; + response.writeHead(200, { + "content-type": "text/event-stream", + "content-length": Buffer.byteLength(body), + }); + response.end(body); +} + +let rootRequestCount = 0; +const server = createServer((request, response) => { + void (async () => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (request.method === "GET" && url.pathname === "/v1/models") { + writeJson(response, { object: "list", data: [{ id: "openclaw-ci-model" }] }); + return; + } + if (request.method !== "POST" || url.pathname !== "/v1/responses") { + response.writeHead(404).end(); + return; + } + + for await (const _chunk of request) { + // Drain the request before replying so OpenClaw can reuse the connection. + } + const sessionId = String(request.headers["x-dynamo-session-id"] ?? ""); + const parentSessionId = String(request.headers["x-dynamo-parent-session-id"] ?? ""); + requests.push({ sessionId, parentSessionId, path: url.pathname }); + + if (parentSessionId) { + writeSse(response, textEvents("CHILD_OK")); + return; + } + + rootRequestCount += 1; + if (rootRequestCount === 1) { + writeSse( + response, + toolEvents("sessions_spawn", { + runtime: "subagent", + mode: "run", + taskName: "ci_child", + task: "Reply exactly CHILD_OK", + }), + ); + } else if (rootRequestCount === 2) { + writeSse(response, toolEvents("sessions_yield", {})); + } else { + writeSse(response, textEvents("PARENT_OK")); + } + })().catch((error) => { + response.writeHead(500).end(String(error)); + }); +}); + +async function runOpenClaw(args, env, timeoutMs = 90_000) { + const child = spawn("openclaw", args, { env, cwd: workspace }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs); + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", resolve); + }); + clearTimeout(timer); + assert.equal(code, 0, `openclaw ${args.join(" ")} failed\n${stdout}\n${stderr}`); + return { stdout, stderr }; +} + +await mkdir(stateDir, { recursive: true }); +await mkdir(workspace, { recursive: true }); +await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); +}); + +try { + const address = server.address(); + assert(address && typeof address !== "string"); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + + await runOpenClaw(["plugins", "install", "--link", pluginRoot], env); + await runOpenClaw(["plugins", "enable", "dynamo"], env); + + const configPath = path.join(stateDir, "openclaw.json"); + const config = JSON.parse(await readFile(configPath, "utf8")); + config.gateway = { auth: { mode: "token", token: "openclaw-ci-token" } }; + config.tools = { profile: "coding" }; + config.models = { + providers: { + dynamo: { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + apiKey: "openclaw-ci", + api: "openai-responses", + models: [ + { + id: "openclaw-ci-model", + name: "OpenClaw CI Model", + contextWindow: 128000, + maxTokens: 4096, + }, + ], + }, + }, + }; + config.agents = { + defaults: { + workspace, + model: { primary: "dynamo/openclaw-ci-model" }, + }, + }; + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`); + + await runOpenClaw( + [ + "agent", + "--local", + "--session-id", + rootSessionId, + "--thinking", + "off", + "--timeout", + "60", + "--json", + "--message", + "Spawn one child and wait for it.", + ], + env, + ); + + const rootRequests = requests.filter(({ parentSessionId }) => !parentSessionId); + const childRequests = requests.filter(({ parentSessionId }) => parentSessionId); + assert(rootRequests.length >= 2, `expected at least 2 root requests, got ${requests.length}`); + assert(childRequests.length >= 1, `expected a child request, got ${JSON.stringify(requests)}`); + assert.deepEqual(new Set(rootRequests.map(({ sessionId }) => sessionId)), new Set([rootSessionId])); + for (const request of childRequests) { + assert(request.sessionId && request.sessionId !== rootSessionId); + assert.equal(request.parentSessionId, rootSessionId); + assert.equal(request.path, "/v1/responses"); + } + console.log( + JSON.stringify({ + openclaw: process.env.OPENCLAW_TEST_VERSION ?? "unknown", + rootRequests: rootRequests.length, + childRequests: childRequests.length, + childSessionIds: [...new Set(childRequests.map(({ sessionId }) => sessionId))], + }), + ); +} finally { + await new Promise((resolve) => server.close(resolve)); + await rm(runRoot, { recursive: true, force: true }); +} diff --git a/pi-plugin/README.md b/pi-plugin/README.md index b720491..9c39d32 100644 --- a/pi-plugin/README.md +++ b/pi-plugin/README.md @@ -8,6 +8,9 @@ pi --model dynamo/ With one switch (`DYN_REQUEST_TRACE=1`) it also stamps Dynamo session headers, gives each pi-subagent its own session id, and can relay Pi tool events into the trace — all without patching `pi-mono`. +Tested with Pi `0.72.1`. CI also type-checks, tests, and builds against the +latest published Pi packages. + ## What it does - **Model provider** — registers `dynamo`, discovers models from `/v1/models` (falls back to `dynamo/default`), and streams via Pi's OpenAI-compatible path. From d65facfdb3ee109f17b587fc52493b39aa7325b3 Mon Sep 17 00:00:00 2001 From: Ishan Dhanani Date: Wed, 24 Jun 2026 20:21:24 +0000 Subject: [PATCH 5/5] ci: key Hermes cache from upstream lockfile Signed-off-by: Ishan Dhanani --- .github/workflows/integration-smoke.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/integration-smoke.yml b/.github/workflows/integration-smoke.yml index 6944695..352ca09 100644 --- a/.github/workflows/integration-smoke.yml +++ b/.github/workflows/integration-smoke.yml @@ -98,11 +98,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup uv - uses: astral-sh/setup-uv@v6 - with: - enable-cache: true - - name: Checkout Hermes ${{ matrix.hermes }} run: | git clone --filter=blob:none --no-checkout https://github.com/NousResearch/hermes-agent.git hermes-agent @@ -110,6 +105,12 @@ jobs: git -C hermes-agent checkout --detach FETCH_HEAD git -C hermes-agent log -1 --oneline + - name: Setup uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + cache-dependency-glob: hermes-agent/uv.lock + - name: Install Hermes run: uv sync --project hermes-agent --no-dev