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
126 changes: 123 additions & 3 deletions .github/workflows/integration-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ on:
branches: [main]
push:
branches: [main]
schedule:
- cron: "17 8 * * *"
workflow_dispatch:
inputs:
dynamo_ref:
Expand All @@ -30,6 +32,127 @@ 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: 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: 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

- 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
Expand Down Expand Up @@ -66,9 +189,6 @@ jobs:
with:
python-version: "3.11"

- name: Test Hermes plugin
run: python3 -m unittest discover -s hermes-plugin/tests

- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions hermes-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions hermes-plugin/tests/upstream_smoke.py
Original file line number Diff line number Diff line change
@@ -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()
59 changes: 59 additions & 0 deletions openclaw-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# 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. 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 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

```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
```
40 changes: 40 additions & 0 deletions openclaw-plugin/headers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// 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";
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 withDynamoSessionHeaders(options, parentSessionId) {
const sessionId = options?.sessionId?.trim();
if (!sessionId) return options;

const headers = { ...options.headers };
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, resolveParentSessionId) {
if (!streamFn) return undefined;
return (model, context, options) => {
const sessionId = options?.sessionId?.trim();
return streamFn(
model,
context,
withDynamoSessionHeaders(options, sessionId && resolveParentSessionId?.(sessionId)),
);
};
}
55 changes: 55 additions & 0 deletions openclaw-plugin/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// 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 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",
wrapStreamFn,
wrapSimpleCompletionStreamFn: wrapStreamFn,
});
},
};
17 changes: 17 additions & 0 deletions openclaw-plugin/openclaw.plugin.json
Original file line number Diff line number Diff line change
@@ -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": {}
}
}
Loading
Loading