fix(agents): decode HTML entities in xAI/Grok tool call arguments

xAI/Grok models sometimes return tool call arguments with HTML-entity
encoded strings (e.g. & < > "). This causes downstream
JSON parse errors when the arguments contain shell commands or URLs.

Add a conditional stream wrapper that detects xAI providers and decodes
HTML entities in tool_call argument deltas before they reach the runner.

- Use String.fromCodePoint instead of String.fromCharCode to correctly
  handle supplementary Unicode codepoints (U+10000+)
- Add ' to the named entity list for XML/HTML5 compatibility

Closes #35173
This commit is contained in:
SidQin-cyber 2026-03-05 12:25:13 +08:00 committed by Shakker
parent 987e473364
commit a5f891ab4c
No known key found for this signature in database
2 changed files with 151 additions and 0 deletions

View File

@ -8,6 +8,7 @@ import {
resolvePromptBuildHookResult,
resolvePromptModeForSession,
shouldInjectOllamaCompatNumCtx,
decodeHtmlEntitiesInObject,
wrapOllamaCompatNumCtx,
wrapStreamFnTrimToolCallNames,
} from "./attempt.js";
@ -453,3 +454,42 @@ describe("shouldInjectOllamaCompatNumCtx", () => {
).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 &amp;&amp; npm run build",
args: ["--flag=&quot;value&quot;", "&lt;input&gt;"],
nested: { deep: "a &amp; 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("&#39;hello&#39;")).toBe("'hello'");
expect(decodeHtmlEntitiesInObject("&#x27;world&#x27;")).toBe("'world'");
});
});

View File

@ -102,6 +102,7 @@ import {
type EmbeddedPiQueueHandle,
setActiveEmbeddedRun,
} from "../runs.js";
import { isXaiProvider } from "../../schema/clean-for-xai.js";
import { buildEmbeddedSandboxInfo } from "../sandbox-info.js";
import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manager-cache.js";
import { prepareSessionManagerForRun } from "../session-manager-init.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(/&amp;/gi, "&")
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/&apos;/gi, "'")
.replace(/&lt;/gi, "<")
.replace(/&gt;/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: {
prompt: string;
messages: unknown[];
@ -1158,6 +1263,12 @@ export async function runEmbeddedAttempt(
allowedToolNames,
);
if (isXaiProvider(params.provider, params.modelId)) {
activeSession.agent.streamFn = wrapStreamFnDecodeXaiToolCallArguments(
activeSession.agent.streamFn,
);
}
if (anthropicPayloadLogger) {
activeSession.agent.streamFn = anthropicPayloadLogger.wrapStreamFn(
activeSession.agent.streamFn,