mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 19:34:59 +00:00
fix(agents): decode HTML entities in xAI/Grok tool call arguments (#35276)
Merged via squash.
Prepared head SHA: c4445d2938
Co-authored-by: Sid-Qin <201593046+Sid-Qin@users.noreply.github.com>
Co-authored-by: shakkernerd <165377636+shakkernerd@users.noreply.github.com>
Reviewed-by: @shakkernerd
This commit is contained in:
@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
- Agents/schema cleaning: detect Venice + Grok model IDs as xAI-proxied targets so unsupported JSON Schema keywords are stripped before requests, preventing Venice/Grok `Invalid arguments` failures. (openclaw#35355) thanks @Sid-Qin.
|
- Agents/schema cleaning: detect Venice + Grok model IDs as xAI-proxied targets so unsupported JSON Schema keywords are stripped before requests, preventing Venice/Grok `Invalid arguments` failures. (openclaw#35355) thanks @Sid-Qin.
|
||||||
- Skills/native command deduplication: centralize skill command dedupe by canonical `skillName` in `listSkillCommandsForAgents` so duplicate suffixed variants (for example `_2`) are no longer surfaced across interfaces outside Discord. (#27521) thanks @shivama205.
|
- Skills/native command deduplication: centralize skill command dedupe by canonical `skillName` in `listSkillCommandsForAgents` so duplicate suffixed variants (for example `_2`) are no longer surfaced across interfaces outside Discord. (#27521) thanks @shivama205.
|
||||||
|
- Agents/xAI tool-call argument decoding: decode HTML-entity encoded xAI/Grok tool-call argument values (`&`, `"`, `<`, `>`, numeric entities) before tool execution so commands with shell operators and quotes no longer fail with parse errors. (#35276) Thanks @Sid-Qin.
|
||||||
- Feishu/streaming card delivery synthesis: unify snapshot and delta streaming merge semantics, apply overlap-aware final merge, suppress duplicate final text delivery (including text+media final packets), prefer topic-thread `message.reply` routing when a reply target exists, and tune card print cadence to avoid duplicate incremental rendering. (from #33245, #32896, #33840) Thanks @rexl2018, @kcinzgg, and @aerelune.
|
- Feishu/streaming card delivery synthesis: unify snapshot and delta streaming merge semantics, apply overlap-aware final merge, suppress duplicate final text delivery (including text+media final packets), prefer topic-thread `message.reply` routing when a reply target exists, and tune card print cadence to avoid duplicate incremental rendering. (from #33245, #32896, #33840) Thanks @rexl2018, @kcinzgg, and @aerelune.
|
||||||
- Security/dependency audit: patch transitive Hono vulnerabilities by pinning `hono` to `4.12.5` and `@hono/node-server` to `1.19.10` in production resolution paths. Thanks @shakkernerd.
|
- Security/dependency audit: patch transitive Hono vulnerabilities by pinning `hono` to `4.12.5` and `@hono/node-server` to `1.19.10` in production resolution paths. Thanks @shakkernerd.
|
||||||
- Security/dependency audit: bump `tar` to `7.5.10` (from `7.5.9`) to address the high-severity hardlink path traversal advisory (`GHSA-qffp-2rhf-9h96`). Thanks @shakkernerd.
|
- Security/dependency audit: bump `tar` to `7.5.10` (from `7.5.9`) to address the high-severity hardlink path traversal advisory (`GHSA-qffp-2rhf-9h96`). Thanks @shakkernerd.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
resolvePromptBuildHookResult,
|
resolvePromptBuildHookResult,
|
||||||
resolvePromptModeForSession,
|
resolvePromptModeForSession,
|
||||||
shouldInjectOllamaCompatNumCtx,
|
shouldInjectOllamaCompatNumCtx,
|
||||||
|
decodeHtmlEntitiesInObject,
|
||||||
wrapOllamaCompatNumCtx,
|
wrapOllamaCompatNumCtx,
|
||||||
wrapStreamFnTrimToolCallNames,
|
wrapStreamFnTrimToolCallNames,
|
||||||
} from "./attempt.js";
|
} from "./attempt.js";
|
||||||
@@ -453,3 +454,42 @@ describe("shouldInjectOllamaCompatNumCtx", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("decodeHtmlEntitiesInObject", () => {
|
||||||
|
it("decodes HTML entities in string values", () => {
|
||||||
|
const result = decodeHtmlEntitiesInObject(
|
||||||
|
"source .env && psql "$DB" -c <query>",
|
||||||
|
);
|
||||||
|
expect(result).toBe('source .env && psql "$DB" -c <query>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recursively decodes nested objects", () => {
|
||||||
|
const input = {
|
||||||
|
command: "cd ~/dev && npm run build",
|
||||||
|
args: ["--flag="value"", "<input>"],
|
||||||
|
nested: { deep: "a & b" },
|
||||||
|
};
|
||||||
|
const result = decodeHtmlEntitiesInObject(input) as Record<string, unknown>;
|
||||||
|
expect(result.command).toBe("cd ~/dev && npm run build");
|
||||||
|
expect((result.args as string[])[0]).toBe('--flag="value"');
|
||||||
|
expect((result.args as string[])[1]).toBe("<input>");
|
||||||
|
expect((result.nested as Record<string, string>).deep).toBe("a & b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through non-string primitives unchanged", () => {
|
||||||
|
expect(decodeHtmlEntitiesInObject(42)).toBe(42);
|
||||||
|
expect(decodeHtmlEntitiesInObject(null)).toBe(null);
|
||||||
|
expect(decodeHtmlEntitiesInObject(true)).toBe(true);
|
||||||
|
expect(decodeHtmlEntitiesInObject(undefined)).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns strings without entities unchanged", () => {
|
||||||
|
const input = "plain string with no entities";
|
||||||
|
expect(decodeHtmlEntitiesInObject(input)).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes numeric character references", () => {
|
||||||
|
expect(decodeHtmlEntitiesInObject("'hello'")).toBe("'hello'");
|
||||||
|
expect(decodeHtmlEntitiesInObject("'world'")).toBe("'world'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import { toClientToolDefinitions } from "../../pi-tool-definition-adapter.js";
|
|||||||
import { createOpenClawCodingTools, resolveToolLoopDetectionConfig } from "../../pi-tools.js";
|
import { createOpenClawCodingTools, resolveToolLoopDetectionConfig } from "../../pi-tools.js";
|
||||||
import { resolveSandboxContext } from "../../sandbox.js";
|
import { resolveSandboxContext } from "../../sandbox.js";
|
||||||
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
|
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
|
||||||
|
import { isXaiProvider } from "../../schema/clean-for-xai.js";
|
||||||
import { repairSessionFileIfNeeded } from "../../session-file-repair.js";
|
import { repairSessionFileIfNeeded } from "../../session-file-repair.js";
|
||||||
import { guardSessionManager } from "../../session-tool-result-guard-wrapper.js";
|
import { guardSessionManager } from "../../session-tool-result-guard-wrapper.js";
|
||||||
import { sanitizeToolUseResultPairing } from "../../session-transcript-repair.js";
|
import { sanitizeToolUseResultPairing } from "../../session-transcript-repair.js";
|
||||||
@@ -421,6 +422,110 @@ export function wrapStreamFnTrimToolCallNames(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// xAI / Grok: decode HTML entities in tool call arguments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const HTML_ENTITY_RE = /&(?:amp|lt|gt|quot|apos|#39|#x[0-9a-f]+|#\d+);/i;
|
||||||
|
|
||||||
|
function decodeHtmlEntities(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/gi, "&")
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/'/gi, "'")
|
||||||
|
.replace(/'/gi, "'")
|
||||||
|
.replace(/</gi, "<")
|
||||||
|
.replace(/>/gi, ">")
|
||||||
|
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
|
||||||
|
.replace(/&#(\d+);/gi, (_, dec) => String.fromCodePoint(Number.parseInt(dec, 10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeHtmlEntitiesInObject(obj: unknown): unknown {
|
||||||
|
if (typeof obj === "string") {
|
||||||
|
return HTML_ENTITY_RE.test(obj) ? decodeHtmlEntities(obj) : obj;
|
||||||
|
}
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map(decodeHtmlEntitiesInObject);
|
||||||
|
}
|
||||||
|
if (obj && typeof obj === "object") {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, val] of Object.entries(obj as Record<string, unknown>)) {
|
||||||
|
result[key] = decodeHtmlEntitiesInObject(val);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeXaiToolCallArgumentsInMessage(message: unknown): void {
|
||||||
|
if (!message || typeof message !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const content = (message as { content?: unknown }).content;
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const block of content) {
|
||||||
|
if (!block || typeof block !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const typedBlock = block as { type?: unknown; arguments?: unknown };
|
||||||
|
if (typedBlock.type !== "toolCall" || !typedBlock.arguments) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof typedBlock.arguments === "object") {
|
||||||
|
typedBlock.arguments = decodeHtmlEntitiesInObject(typedBlock.arguments);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapStreamDecodeXaiToolCallArguments(
|
||||||
|
stream: ReturnType<typeof streamSimple>,
|
||||||
|
): ReturnType<typeof streamSimple> {
|
||||||
|
const originalResult = stream.result.bind(stream);
|
||||||
|
stream.result = async () => {
|
||||||
|
const message = await originalResult();
|
||||||
|
decodeXaiToolCallArgumentsInMessage(message);
|
||||||
|
return message;
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalAsyncIterator = stream[Symbol.asyncIterator].bind(stream);
|
||||||
|
(stream as { [Symbol.asyncIterator]: typeof originalAsyncIterator })[Symbol.asyncIterator] =
|
||||||
|
function () {
|
||||||
|
const iterator = originalAsyncIterator();
|
||||||
|
return {
|
||||||
|
async next() {
|
||||||
|
const result = await iterator.next();
|
||||||
|
if (!result.done && result.value && typeof result.value === "object") {
|
||||||
|
const event = result.value as { partial?: unknown; message?: unknown };
|
||||||
|
decodeXaiToolCallArgumentsInMessage(event.partial);
|
||||||
|
decodeXaiToolCallArgumentsInMessage(event.message);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
async return(value?: unknown) {
|
||||||
|
return iterator.return?.(value) ?? { done: true as const, value: undefined };
|
||||||
|
},
|
||||||
|
async throw(error?: unknown) {
|
||||||
|
return iterator.throw?.(error) ?? { done: true as const, value: undefined };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapStreamFnDecodeXaiToolCallArguments(baseFn: StreamFn): StreamFn {
|
||||||
|
return (model, context, options) => {
|
||||||
|
const maybeStream = baseFn(model, context, options);
|
||||||
|
if (maybeStream && typeof maybeStream === "object" && "then" in maybeStream) {
|
||||||
|
return Promise.resolve(maybeStream).then((stream) =>
|
||||||
|
wrapStreamDecodeXaiToolCallArguments(stream),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return wrapStreamDecodeXaiToolCallArguments(maybeStream);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolvePromptBuildHookResult(params: {
|
export async function resolvePromptBuildHookResult(params: {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
messages: unknown[];
|
messages: unknown[];
|
||||||
@@ -1158,6 +1263,12 @@ export async function runEmbeddedAttempt(
|
|||||||
allowedToolNames,
|
allowedToolNames,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isXaiProvider(params.provider, params.modelId)) {
|
||||||
|
activeSession.agent.streamFn = wrapStreamFnDecodeXaiToolCallArguments(
|
||||||
|
activeSession.agent.streamFn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (anthropicPayloadLogger) {
|
if (anthropicPayloadLogger) {
|
||||||
activeSession.agent.streamFn = anthropicPayloadLogger.wrapStreamFn(
|
activeSession.agent.streamFn = anthropicPayloadLogger.wrapStreamFn(
|
||||||
activeSession.agent.streamFn,
|
activeSession.agent.streamFn,
|
||||||
|
|||||||
Reference in New Issue
Block a user