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
55 changes: 55 additions & 0 deletions .github/workflows/nightly-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
# messaging-compatible-endpoint-e2e
# Validates Telegram + OpenAI-compatible endpoint inference routing
# through inference.local with a hermetic local mock (#2766).
# kimi-inference-compat-e2e
# Validates Kimi K2.6 safe exec splitting through OpenClaw trajectories
# with a hermetic OpenAI-compatible mock (#2620).
# token-rotation-e2e Validates that rotating a messaging token and re-running onboard
# propagates the new credential to the sandbox. Combined Telegram +
# Discord + Slack coverage with cross-talk assertions. See issue #1903.
Expand Down Expand Up @@ -49,6 +52,7 @@ on:
Valid: cloud-e2e, cloud-onboard-e2e, cloud-inference-e2e,
skill-agent-e2e, docs-validation-e2e, messaging-providers-e2e,
messaging-compatible-endpoint-e2e,
kimi-inference-compat-e2e,
token-rotation-e2e, sandbox-survival-e2e,
issue-2478-crash-loop-recovery-e2e, hermes-e2e,
sandbox-operations-e2e, inference-routing-e2e,
Expand Down Expand Up @@ -308,6 +312,54 @@ jobs:
path: /tmp/nemoclaw-e2e-messaging-compatible-endpoint-install.log
if-no-files-found: ignore

# ── Kimi inference compatibility regression (#2620) ───────────
# Hermetic OpenAI-compatible endpoint path. The mock emits one combined
# Kimi exec tool call (`hostname; date; uptime`) and the test asserts the
# sandbox trajectory records three split exec calls with clean completion.
kimi-inference-compat-e2e:
if: >-
github.repository == 'NVIDIA/NemoClaw' &&
(github.event_name != 'workflow_dispatch' ||
inputs.jobs == '' ||
contains(format(',{0},', inputs.jobs), ',kimi-inference-compat-e2e,'))
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Run Kimi inference compatibility E2E test
env:
NEMOCLAW_NON_INTERACTIVE: "1"
NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1"
NEMOCLAW_SANDBOX_NAME: "e2e-kimi-compat"
GITHUB_TOKEN: ${{ github.token }}
run: bash test/e2e/test-kimi-inference-compat.sh

- name: Upload onboard log on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: install-log-kimi-inference-compat
path: /tmp/nemoclaw-e2e-kimi-inference-compat-onboard.log
if-no-files-found: ignore

- name: Upload build/setup log on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: build-log-kimi-inference-compat
path: /tmp/nemoclaw-e2e-kimi-inference-compat-build.log
if-no-files-found: ignore

- name: Upload agent log on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: agent-log-kimi-inference-compat
path: /tmp/nemoclaw-e2e-kimi-inference-compat-agent.log
if-no-files-found: ignore
Comment thread
ericksoa marked this conversation as resolved.
Resolved

# ── Token rotation (credential propagation to L7 proxy) ─────
# Validates that rotating a messaging token and re-running onboard
# propagates the new credential to the sandbox. Uses two fake tokens
Expand Down Expand Up @@ -1466,6 +1518,7 @@ jobs:
docs-validation-e2e,
messaging-providers-e2e,
messaging-compatible-endpoint-e2e,
kimi-inference-compat-e2e,
token-rotation-e2e,
sandbox-survival-e2e,
hermes-e2e,
Expand Down Expand Up @@ -1547,6 +1600,7 @@ jobs:
docs-validation-e2e,
messaging-providers-e2e,
messaging-compatible-endpoint-e2e,
kimi-inference-compat-e2e,
token-rotation-e2e,
sandbox-survival-e2e,
hermes-e2e,
Expand Down Expand Up @@ -1643,6 +1697,7 @@ jobs:
docs-validation-e2e,
messaging-providers-e2e,
messaging-compatible-endpoint-e2e,
kimi-inference-compat-e2e,
token-rotation-e2e,
sandbox-survival-e2e,
hermes-e2e,
Expand Down
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,11 @@ 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/
RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp /usr/local/lib/nemoclaw/sandbox-init.sh \
&& chmod 644 /usr/local/lib/nemoclaw/ws-proxy-fix.js
&& 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/*

# Build args for config that varies per deployment.
# nemoclaw onboard passes these at image build time.
Expand Down
226 changes: 226 additions & 0 deletions nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

function normalize(value) {
return String(value || "").trim().toLowerCase();
}

function normalizeBaseUrl(value) {
return String(value || "").trim().replace(/\/+$/, "");
}

function isManagedKimi(ctx) {
const model = ctx && ctx.model ? ctx.model : {};
return (
normalize(ctx && ctx.provider) === "inference" &&
normalize(ctx && ctx.modelId) === "moonshotai/kimi-k2.6" &&
normalize((ctx && ctx.modelApi) || model.api) === "openai-completions" &&
normalizeBaseUrl(model.baseUrl) === "https://inference.local/v1"
);
}

function emptyParameters() {
return { type: "object", properties: {} };
}

const SAFE_SPLIT_EXEC_COMMANDS = new Set(["hostname", "date", "uptime"]);

function decodeToolCallArguments(value) {
if (value && typeof value === "object" && !Array.isArray(value)) return value;
if (typeof value !== "string") return null;
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
} catch {
return null;
}
return null;
}

function splitSafeExecCommand(command) {
if (typeof command !== "string") return null;
if (!command.includes(";")) return null;
const parts = command.split(";").map((part) => part.trim());
if (parts.length < 2) return null;
if (parts.some((part) => !SAFE_SPLIT_EXEC_COMMANDS.has(part))) return null;
return parts;
}

function buildSplitToolCallId(originalId, index, command) {
const rawId = typeof originalId === "string" && originalId.trim() ? originalId.trim() : "kimi_exec";
const baseId = rawId.replace(/_split_\d+_(hostname|date|uptime)$/, "");
return `${baseId}_split_${index + 1}_${command}`;
}

function getSafeCombinedExecToolCall(message) {
if (!message || typeof message !== "object") return null;
const content = message.content;
if (!Array.isArray(content) || content.length !== 1) return null;

const toolCall = content[0];
if (!toolCall || typeof toolCall !== "object" || Array.isArray(toolCall)) return null;
if (toolCall.type !== "toolCall" || toolCall.name !== "exec") return null;

const args = decodeToolCallArguments(toolCall.arguments);
if (!args) return null;
const argKeys = Object.keys(args);
if (argKeys.length !== 1 || argKeys[0] !== "command" || typeof args.command !== "string") {
return null;
}

const commands = splitSafeExecCommand(args.command);
if (!commands) return null;

return { commands, toolCall };
}

function applySafeExecSplitToMessage(message, split) {
if (!message || typeof message !== "object" || !split) return false;
const { commands, toolCall } = split;

message.content = commands.map((command, index) => ({
type: "toolCall",
id: buildSplitToolCallId(toolCall.id, index, command),
name: "exec",
arguments: { command },
}));
if (message.stopReason === "stop") message.stopReason = "toolUse";
return true;
}

function rewriteSafeCombinedExecToolCallInMessage(message) {
return applySafeExecSplitToMessage(message, getSafeCombinedExecToolCall(message));
}

function getSafeCombinedExecToolCallFromEventDelta(event) {
if (!event || typeof event !== "object") return null;
if (event.type !== "toolcall_delta" || typeof event.delta !== "string") return null;
const partial = event.partial;
if (!partial || typeof partial !== "object" || !Array.isArray(partial.content)) return null;
const index = Number.isInteger(event.contentIndex) ? event.contentIndex : 0;
const toolCall = partial.content[index];
if (!toolCall || typeof toolCall !== "object" || Array.isArray(toolCall)) return null;
if (toolCall.type !== "toolCall" || toolCall.name !== "exec") return null;

const args = decodeToolCallArguments(event.delta);
if (!args) return null;
const argKeys = Object.keys(args);
if (argKeys.length !== 1 || argKeys[0] !== "command" || typeof args.command !== "string") {
return null;
}

const commands = splitSafeExecCommand(args.command);
if (!commands) return null;
return { commands, toolCall };
}

function rewriteSafeCombinedExecToolCallInEvent(event) {
if (!event || typeof event !== "object") return false;
const split =
getSafeCombinedExecToolCall(event.partial) ||
getSafeCombinedExecToolCall(event.message) ||
getSafeCombinedExecToolCallFromEventDelta(event);
if (!split) return false;

applySafeExecSplitToMessage(event.partial, split);
applySafeExecSplitToMessage(event.message, split);

if (event.type === "toolcall_delta" && typeof event.delta === "string") {
event.delta = JSON.stringify({ command: split.commands[0] });
}
if (event.toolCall && typeof event.toolCall === "object" && !Array.isArray(event.toolCall)) {
event.toolCall = {
type: "toolCall",
id: buildSplitToolCallId(split.toolCall.id, 0, split.commands[0]),
name: "exec",
arguments: { command: split.commands[0] },
};
}

return true;
}

function wrapStreamFinalMessages(stream) {
if (!stream || typeof stream !== "object") return stream;

if (typeof stream.result === "function") {
const originalResult = stream.result.bind(stream);
stream.result = async () => {
const message = await originalResult();
rewriteSafeCombinedExecToolCallInMessage(message);
return message;
};
}

if (typeof stream[Symbol.asyncIterator] === "function") {
const originalAsyncIterator = stream[Symbol.asyncIterator].bind(stream);
stream[Symbol.asyncIterator] = function () {
const iterator = originalAsyncIterator();
return {
async next() {
const result = await iterator.next();
if (!result.done && result.value && typeof result.value === "object") {
rewriteSafeCombinedExecToolCallInEvent(result.value);
}
return result;
},
async return(value) {
if (typeof iterator.return === "function") return iterator.return(value);
return { done: true, value: undefined };
},
async throw(error) {
if (typeof iterator.throw === "function") return iterator.throw(error);
throw error;
},
};
};
}

return stream;
}

function createSafeExecSplitterWrapper(baseStreamFn) {
if (typeof baseStreamFn !== "function") return undefined;
return (model, context, options) => {
const maybeStream = baseStreamFn(model, context, options);
if (maybeStream && typeof maybeStream === "object" && "then" in maybeStream) {
return Promise.resolve(maybeStream).then((stream) => wrapStreamFinalMessages(stream));
}
return wrapStreamFinalMessages(maybeStream);
};
}

module.exports = {
id: "nemoclaw-kimi-inference-compat",
name: "NemoClaw Kimi Inference Compatibility",
version: "0.1.0",
description: "Normalizes managed inference.local Kimi tool schemas for NemoClaw sandboxes.",
register(api) {
api.registerProvider({
id: "inference",
label: "NemoClaw managed inference",
auth: [],
normalizeToolSchemas(ctx) {
if (!isManagedKimi(ctx)) return null;
const tools = Array.isArray(ctx.tools) ? ctx.tools : [];
return tools.map((tool) => {
if (!tool || tool.name === "exec") return tool;
return { ...tool, parameters: emptyParameters() };
});
},
wrapStreamFn(ctx) {
if (!isManagedKimi(ctx)) return undefined;
return createSafeExecSplitterWrapper(ctx && ctx.streamFn);
},
inspectToolSchemas() {
return [];
},
});
},
__testing: {
createSafeExecSplitterWrapper,
rewriteSafeCombinedExecToolCallInEvent,
rewriteSafeCombinedExecToolCallInMessage,
splitSafeExecCommand,
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"id": "nemoclaw-kimi-inference-compat",
"name": "NemoClaw Kimi Inference Compatibility",
"version": "0.1.0",
"description": "Normalizes managed inference.local Kimi tool schemas for NemoClaw sandboxes.",
"providers": ["inference"],
"configSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
}
Loading
Loading