Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
19 changes: 19 additions & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,25 @@ reviews:
gh workflow run nightly-e2e.yaml --ref <branch> -f jobs=messaging-compatible-endpoint-e2e
```

- path: "test/e2e/test-kimi-inference-compat.sh"
instructions: &e2e-kimi-inference-compat |
This script validates Kimi K2.6 managed inference.local
compatibility with a hermetic OpenAI-compatible mock endpoint.
Changes here affect OpenClaw tool-call splitting and trajectory
proof for the Kimi compatibility path.

**E2E test recommendation:**
- `kimi-inference-compat-e2e` — Kimi managed inference.local
compatibility and safe exec splitting

To run selectively:
```
gh workflow run nightly-e2e.yaml --ref <branch> -f jobs=kimi-inference-compat-e2e
```

- path: "nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/**"
instructions: *e2e-kimi-inference-compat

- path: ".github/workflows/nightly-e2e.yaml"
instructions: |
This is the nightly E2E workflow definition. Changes here affect
Expand Down
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ This repo ships agent skills under `.agents/skills/`, organized into three audie
| `nemoclaw/src/commands/` | TypeScript | Slash commands, migration state |
| `nemoclaw/src/onboard/` | TypeScript | Onboarding config |
| `nemoclaw-blueprint/` | YAML | Blueprint definition and network policies |
| `nemoclaw-blueprint/model-specific-setup/` | JSON | Agent-scoped model/provider compatibility registry |
| `scripts/` | Bash/JS/TS | Install helpers, setup, automation, E2E tooling |
| `test/` | JavaScript (ESM) | Root-level integration tests (Vitest) |
| `test/e2e/` | Bash/JS | End-to-end tests (Brev cloud instances) |
Expand Down Expand Up @@ -166,6 +167,14 @@ All hooks managed by [prek](https://prek.j178.dev/) (installed via `npm install`
- Add YAML to `nemoclaw-blueprint/policies/presets/`
- Follow existing preset structure (see `slack.yaml`, `discord.yaml`)

**Adding model-specific sandbox compatibility:**

- Add a declarative manifest under `nemoclaw-blueprint/model-specific-setup/<agent>/`
- Use one exact `agent` per manifest (`openclaw`, `hermes`, etc.); do not make shared multi-agent manifests
- Put OpenClaw executable wrappers under `nemoclaw-blueprint/openclaw-plugins/`
- Put Hermes executable wrappers under `agents/hermes/`
- Do not add Hermes behavior for an OpenClaw issue without a Hermes-specific repro or acceptance test

### Gotchas

- `npm install` at root triggers `prek install` which sets up git hooks. If hooks fail, check that `core.hooksPath` is unset: `git config --unset core.hooksPath`
Expand Down
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -230,15 +230,15 @@ COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start
COPY nemoclaw-blueprint/scripts/ws-proxy-fix.js /usr/local/lib/nemoclaw/ws-proxy-fix.js
COPY scripts/codex-acp-wrapper.sh /usr/local/bin/nemoclaw-codex-acp
COPY scripts/generate-openclaw-config.py /usr/local/lib/nemoclaw/generate-openclaw-config.py
COPY nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/ /usr/local/share/nemoclaw/openclaw-plugins/kimi-inference-compat/
COPY nemoclaw-blueprint/openclaw-plugins/ /usr/local/share/nemoclaw/openclaw-plugins/
RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp \
/usr/local/lib/nemoclaw/sandbox-init.sh \
/usr/local/lib/nemoclaw/generate-openclaw-config.py \
&& chmod 644 /usr/local/lib/nemoclaw/ws-proxy-fix.js \
&& chmod 755 /usr/local/share/nemoclaw \
/usr/local/share/nemoclaw/openclaw-plugins \
/usr/local/share/nemoclaw/openclaw-plugins/kimi-inference-compat \
&& chmod 644 /usr/local/share/nemoclaw/openclaw-plugins/kimi-inference-compat/*
&& find /usr/local/share/nemoclaw/openclaw-plugins -type d -exec chmod 755 {} + \
&& find /usr/local/share/nemoclaw/openclaw-plugins -type f -exec chmod 644 {} +

# Build args for config that varies per deployment.
# nemoclaw onboard passes these at image build time.
Expand Down
207 changes: 206 additions & 1 deletion agents/hermes/generate-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@
// - Messaging platform tokens (if configured during onboard)
// - Agent defaults (terminal, memory, skills, display)

import { writeFileSync, chmodSync } from "node:fs";
import {
chmodSync,
existsSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";

Expand All @@ -23,9 +30,43 @@ const CHANNEL_TOKEN_ENVS: Record<string, string[]> = {
slack: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"],
};

const KNOWN_MODEL_SETUP_AGENTS = new Set(["openclaw", "hermes"]);
const MODEL_SETUP_EFFECT_KEYS: Record<string, Set<string>> = {
openclaw: new Set(["openclawCompat", "openclawPlugins"]),
hermes: new Set(["hermesCompat"]),
};

type ModelSetupManifest = {
id: string;
agent: string;
description: string;
match: {
modelIds?: string[];
providerKey?: string;
inferenceApi?: string;
baseUrl?: string;
};
effects: Record<string, unknown>;
};

type ModelSetupContext = {
model: string;
providerKey: string;
inferenceApi: string;
baseUrl: string;
};

function main(): void {
const model = process.env.NEMOCLAW_MODEL!;
const baseUrl = process.env.NEMOCLAW_INFERENCE_BASE_URL!;
const providerKey = process.env.NEMOCLAW_PROVIDER_KEY || "custom";

discoverModelSpecificSetups("hermes", {
model,
providerKey,
inferenceApi: process.env.NEMOCLAW_INFERENCE_API || "",
baseUrl,
});

const channelsB64 = process.env.NEMOCLAW_MESSAGING_CHANNELS_B64 || "W10=";
const allowedIdsB64 = process.env.NEMOCLAW_MESSAGING_ALLOWED_IDS_B64 || "e30=";
Expand Down Expand Up @@ -132,6 +173,170 @@ function main(): void {
console.log(`[config] Wrote ${envPath} (${envLines.length} entries)`);
}

function discoverModelSpecificSetups(
agent: "hermes",
context: ModelSetupContext,
): ModelSetupManifest[] {
const registryRoot = findRegistryRoot();
if (!registryRoot) return [];

const manifests: ModelSetupManifest[] = [];
for (const manifestPath of listJsonFiles(registryRoot)) {
if (manifestPath.endsWith("/schema.json")) continue;
const payload = validateManifestPayload(
JSON.parse(readFileSync(manifestPath, "utf-8")),
manifestPath,
);
if (payload.agent !== agent) continue;
validateSelectedAgentEffects(payload, manifestPath);
if (modelSetupMatches(payload, context)) {
manifests.push(payload);
}
}
return manifests;
}

function findRegistryRoot(): string | null {
const explicit = process.env.NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR;
if (explicit) {
if (!existsSync(explicit) || !statSync(explicit).isDirectory()) {
throw new Error(
`NEMOCLAW_MODEL_SPECIFIC_SETUP_DIR must point to an existing directory: ${explicit}`,
);
}
return explicit;
}

const roots = [
"/opt/nemoclaw-blueprint/model-specific-setup",
"/sandbox/.nemoclaw/blueprints/0.1.0/model-specific-setup",
join(import.meta.dirname, "..", "..", "nemoclaw-blueprint", "model-specific-setup"),
join(process.cwd(), "nemoclaw-blueprint", "model-specific-setup"),
].filter((entry): entry is string => Boolean(entry));

for (const root of [...new Set(roots)]) {
if (existsSync(root) && statSync(root).isDirectory()) return root;
}
return null;
}

function listJsonFiles(root: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(root, { withFileTypes: true })) {
const entryPath = join(root, entry.name);
if (entry.isDirectory()) {
files.push(...listJsonFiles(entryPath));
} else if (entry.isFile() && entry.name.endsWith(".json")) {
files.push(entryPath);
}
}
return files.sort();
}

function validateManifestPayload(payload: unknown, manifestPath: string): ModelSetupManifest {
if (!isRecord(payload)) {
throw new Error(`${manifestPath}: manifest must be a JSON object`);
}
if (!isNonEmptyString(payload.id)) {
throw new Error(`${manifestPath}: field 'id' must be a non-empty string`);
}
if (!isNonEmptyString(payload.agent)) {
throw new Error(`${manifestPath}: field 'agent' is required`);
}
if (!KNOWN_MODEL_SETUP_AGENTS.has(payload.agent)) {
throw new Error(`${manifestPath}: unknown agent '${payload.agent}'`);
}
if (!isNonEmptyString(payload.description)) {
throw new Error(`${manifestPath}: field 'description' must be a non-empty string`);
}
if (!isRecord(payload.match)) {
throw new Error(`${manifestPath}: field 'match' must be an object`);
}
validateMatch(payload.match, manifestPath);
if (!isRecord(payload.effects) || Object.keys(payload.effects).length === 0) {
throw new Error(`${manifestPath}: field 'effects' must be a non-empty object`);
}
return payload as ModelSetupManifest;
}

function validateMatch(match: Record<string, unknown>, manifestPath: string): void {
if (Object.keys(match).length === 0) {
throw new Error(`${manifestPath}: field 'match' must be a non-empty object`);
}

const allowedKeys = new Set(["modelIds", "providerKey", "inferenceApi", "baseUrl"]);
const unknownKeys = Object.keys(match).filter((key) => !allowedKeys.has(key));
if (unknownKeys.length > 0) {
throw new Error(`${manifestPath}: unknown match keys: ${unknownKeys.join(", ")}`);
}

if (
match.modelIds !== undefined &&
(!Array.isArray(match.modelIds) ||
match.modelIds.length === 0 ||
!match.modelIds.every(isNonEmptyString))
) {
throw new Error(`${manifestPath}: match.modelIds must be a non-empty string array`);
}
for (const key of ["providerKey", "inferenceApi", "baseUrl"]) {
const value = match[key];
if (value !== undefined && !isNonEmptyString(value)) {
throw new Error(`${manifestPath}: match.${key} must be a non-empty string`);
}
}
}

function validateSelectedAgentEffects(payload: ModelSetupManifest, manifestPath: string): void {
const allowedEffectKeys = MODEL_SETUP_EFFECT_KEYS[payload.agent];
const unknownEffectKeys = Object.keys(payload.effects).filter(
(key) => !allowedEffectKeys.has(key),
);
if (unknownEffectKeys.length > 0) {
throw new Error(
`${manifestPath}: unknown effects for agent '${payload.agent}': ${
unknownEffectKeys.join(", ")
}`,
);
}

if (payload.agent === "hermes") {
const compat = payload.effects.hermesCompat;
if (compat !== undefined && !isRecord(compat)) {
throw new Error(`${manifestPath}: effects.hermesCompat must be an object`);
}
}
}

function modelSetupMatches(payload: ModelSetupManifest, context: ModelSetupContext): boolean {
const match = payload.match;
if (
match.modelIds &&
!new Set(match.modelIds.map((modelId) => modelId.trim().toLowerCase())).has(
context.model.trim().toLowerCase(),
)
) {
return false;
}
if (match.providerKey && context.providerKey !== match.providerKey) return false;
if (match.inferenceApi && context.inferenceApi !== match.inferenceApi) return false;
if (match.baseUrl && trimTrailingSlash(context.baseUrl) !== trimTrailingSlash(match.baseUrl)) {
return false;
}
return true;
}

function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, "");
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}

function buildDiscordConfig(
discordGuilds: Record<string, { requireMention?: boolean; users?: (string | number)[] }>,
): Record<string, unknown> {
Expand Down
34 changes: 34 additions & 0 deletions nemoclaw-blueprint/model-specific-setup/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<!-- SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -->
<!-- SPDX-License-Identifier: Apache-2.0 -->

# Model-Specific Setup Registry

This registry is the ownership boundary for model/provider compatibility work that has to differ by agent runtime. Every manifest declares exactly one `agent` and lives under the matching directory:

- `model-specific-setup/openclaw/` for OpenClaw compatibility.
- `model-specific-setup/hermes/` for Hermes compatibility.

Do not add shared multi-agent manifests in v1. OpenClaw and Hermes have different config files, plugin systems, replay behavior, and E2E paths, so a fix should be proven and reviewed for one agent at a time.

## Manifest Shape

Manifests follow `schema.json`:

- `id`: stable registry id.
- `agent`: exact agent id, for example `openclaw` or `hermes`.
- `description`: human-readable reason for the setup.
- `match`: model/provider route predicates.
- `effects`: declarative, agent-scoped effects.

The first OpenClaw entry is `openclaw/kimi-k2.6-managed-inference.json`. It preserves the Kimi K2.6 managed `inference.local` compatibility behavior from PR #3046.

## Contributor Guidance

Put model-specific sandbox compatibility here, not directly in generator conditionals:

- Match logic belongs in a manifest.
- OpenClaw executable wrappers belong under `nemoclaw-blueprint/openclaw-plugins/`.
- Hermes executable wrappers belong under `agents/hermes/`.
- Runtime transformations stay in agent-owned code or plugins; registry manifests stay declarative.

Only add Hermes-specific Kimi behavior after a Hermes-specific failure or acceptance test proves it is needed.
8 changes: 8 additions & 0 deletions nemoclaw-blueprint/model-specific-setup/hermes/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<!-- SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -->
<!-- SPDX-License-Identifier: Apache-2.0 -->

# Hermes Model-Specific Setup

Hermes-specific model/provider compatibility manifests belong in this directory and must declare `"agent": "hermes"`.

This directory intentionally has no Kimi K2.6 manifest yet. Add one only after a Hermes-specific failure or acceptance test proves Hermes needs a compatibility layer. Hermes executable wrappers and runtime code belong under `agents/hermes/`; registry manifests should stay declarative.
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"$schema": "../schema.json",
"id": "kimi-k2.6-managed-inference",
"agent": "openclaw",
"description": "Preserves OpenClaw request and tool-call compatibility for moonshotai/kimi-k2.6 through NemoClaw managed inference.local chat completions.",
"match": {
"modelIds": ["moonshotai/kimi-k2.6"],
"providerKey": "inference",
"inferenceApi": "openai-completions",
"baseUrl": "https://inference.local/v1"
},
"effects": {
"openclawCompat": {
"requiresStringContent": true,
"maxTokensField": "max_tokens",
"requiresToolResultName": true
},
"openclawPlugins": [
{
"id": "nemoclaw-kimi-inference-compat",
"path": "openclaw-plugins/kimi-inference-compat",
"loadPath": "/usr/local/share/nemoclaw/openclaw-plugins/kimi-inference-compat"
}
]
}
}
Loading
Loading