fix(xdev): include truncation marker in summary byte cap

(cherry picked from commit aa2067bf7952191beab85b71002aafe812f544bc)
This commit is contained in:
can1357
2026-07-29 22:37:22 +02:00
parent 4666b1ae41
commit adad262ba9
2 changed files with 20 additions and 11 deletions
+9 -5
View File
@@ -179,8 +179,10 @@ function toolSummary(inst: Tool): string {
return firstLine?.trim() ?? inst.label ?? inst.name;
}
/** C0/C1 control characters; a summary must never smuggle escapes or line breaks into the prompt. */
const SUMMARY_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]+/g;
/** C0/C1 controls and Unicode line/paragraph separators; summaries must remain one line. */
const SUMMARY_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g;
const SUMMARY_ELLIPSIS = "…";
const SUMMARY_ELLIPSIS_BYTES = Buffer.byteLength(SUMMARY_ELLIPSIS, "utf-8");
/**
* Bound a catalog summary for prompt rendering. External summaries are
@@ -191,9 +193,11 @@ const SUMMARY_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]+/g;
*/
function sanitizeCatalogSummary(summary: string, maxBytes?: number): string {
const cleaned = summary.replace(SUMMARY_CONTROL_CHARS, " ").trim();
if (maxBytes === undefined || maxBytes <= 0) return cleaned;
if (Buffer.byteLength(cleaned, "utf-8") <= maxBytes) return cleaned;
return `${truncateHeadBytes(cleaned, maxBytes).text.trimEnd()}…`;
if (maxBytes === undefined || Buffer.byteLength(cleaned, "utf-8") <= maxBytes) return cleaned;
if (maxBytes <= 0) return "";
if (maxBytes < SUMMARY_ELLIPSIS_BYTES) return truncateHeadBytes(cleaned, maxBytes).text;
const body = truncateHeadBytes(cleaned, maxBytes - SUMMARY_ELLIPSIS_BYTES).text.trimEnd();
return `${body}${SUMMARY_ELLIPSIS}`;
}
function promptCatalogSummary(inst: Tool, maxBytes?: number): string {
@@ -302,7 +302,7 @@ describe("read and write route xd:// device URLs", () => {
name: "mcp__weather__forecast",
label: "Forecast",
description: "Weather forecast for a place.",
summary: `Napoved\u0007vremena ${multiByteTail}`,
summary: `Napoved\u0007\u2028vremena ${multiByteTail}`,
parameters: type({ query: "string" }),
async execute() {
return { content: [{ type: "text", text: "" }] };
@@ -324,16 +324,21 @@ describe("read and write route xd:// device URLs", () => {
const dynamic = entries.get("mcp__weather__forecast");
if (!dynamic) throw new Error("expected the dynamic device entry");
expect(dynamic.dynamic).toBe(true);
// Control characters collapse to a space instead of reaching the prompt.
// Control characters and Unicode line separators collapse to a space
// instead of reaching the prompt.
expect(dynamic.summary.startsWith("Napoved vremena ")).toBe(true);
expect(dynamic.summary.endsWith("…")).toBe(true);
const body = dynamic.summary.slice(0, -1);
const bodyBytes = Buffer.byteLength(body, "utf-8");
expect(bodyBytes).toBeLessThanOrEqual(XDEV_EXTERNAL_DESCRIPTION_CAP);
// The cut backs off at most one code point: the character straddling the
// budget is dropped whole rather than split.
expect(bodyBytes).toBeGreaterThan(XDEV_EXTERNAL_DESCRIPTION_CAP - 3);
const summaryBytes = Buffer.byteLength(dynamic.summary, "utf-8");
expect(summaryBytes).toBeLessThanOrEqual(XDEV_EXTERNAL_DESCRIPTION_CAP);
// The ellipsis is inside the byte budget, and the cut backs off at most
// one code point rather than splitting the character at the boundary.
expect(bodyBytes).toBeLessThanOrEqual(
XDEV_EXTERNAL_DESCRIPTION_CAP - Buffer.byteLength("…", "utf-8"),
);
expect(bodyBytes).toBeGreaterThan(XDEV_EXTERNAL_DESCRIPTION_CAP - 6);
expect(body.endsWith("あ")).toBe(true);
// A split code point would decode to U+FFFD and fail the round trip.
expect(Buffer.from(body, "utf-8").toString("utf-8")).toBe(body);