diff --git a/.changeset/fix-captext-budget-overrun.md b/.changeset/fix-captext-budget-overrun.md new file mode 100644 index 000000000..c68c52142 --- /dev/null +++ b/.changeset/fix-captext-budget-overrun.md @@ -0,0 +1,5 @@ +--- +"@sapiom/mcp": patch +--- + +Fix a truncation helper that could return output slightly longer than its configured character budget. diff --git a/packages/mcp/src/tools/shared.test.ts b/packages/mcp/src/tools/shared.test.ts new file mode 100644 index 000000000..0be965351 --- /dev/null +++ b/packages/mcp/src/tools/shared.test.ts @@ -0,0 +1,66 @@ +/** + * `capText` — the hard char-budget primitive execution-projection.ts relies on + * to keep every field bounded "for ANY argument combination" (see that + * module's doc comment). Regression coverage for a bug where the truncation + * marker appended after slicing pushed the result past `budget`, since the + * marker's own length was never subtracted from the content slice. + */ +import { describe, it, expect } from "vitest"; +import { capText } from "./shared.js"; + +describe("capText", () => { + it("returns text unchanged when it already fits the budget", () => { + expect(capText("hello", 10)).toBe("hello"); + expect(capText("hello", 5)).toBe("hello"); + }); + + it("never returns a string longer than budget, with a webappUrl", () => { + const text = "x".repeat(50_000); + const budget = 2_000; + const result = capText(text, budget, "https://app.sapiom.ai/runs/exec-1"); + expect(result.length).toBeLessThanOrEqual(budget); + expect(result).toContain("truncated"); + expect(result).toContain("app.sapiom.ai"); + }); + + it("never returns a string longer than budget, without a webappUrl", () => { + const text = "x".repeat(50_000); + const budget = 2_000; + const result = capText(text, budget); + expect(result.length).toBeLessThanOrEqual(budget); + expect(result).toContain("truncated"); + }); + + it("reports an accurate dropped-char count consistent with the actual slice", () => { + const text = "x".repeat(50_000); + const budget = 2_000; + const result = capText(text, budget, "https://app.sapiom.ai/runs/exec-1"); + const match = result.match(/truncated (\d+) chars/); + expect(match).not.toBeNull(); + const dropped = Number(match![1]); + // The content actually kept plus what's reported dropped must equal the + // original length — this is only true if `dropped` is computed from the + // real (marker-adjusted) slice point, not the naive `text.length - budget`. + const keptLen = result.length - result.slice(result.indexOf("…")).length; + expect(keptLen + dropped).toBe(text.length); + }); + + it("stays within budget across a range of budgets, including budgets too small for the marker itself", () => { + const text = "x".repeat(10_000); + for (const budget of [0, 1, 5, 10, 20, 50, 100, 500, 2_000, 32_000]) { + const withUrl = capText(text, budget, "https://app.sapiom.ai/x"); + const withoutUrl = capText(text, budget); + expect(withUrl.length).toBeLessThanOrEqual(budget); + expect(withoutUrl.length).toBeLessThanOrEqual(budget); + } + }); + + it("stays within budget for inputs just over the limit", () => { + for (const over of [1, 10, 100]) { + const budget = 2_000; + const text = "x".repeat(budget + over); + const result = capText(text, budget, "https://app.sapiom.ai/x"); + expect(result.length).toBeLessThanOrEqual(budget); + } + }); +}); diff --git a/packages/mcp/src/tools/shared.ts b/packages/mcp/src/tools/shared.ts index 992748175..457f72518 100644 --- a/packages/mcp/src/tools/shared.ts +++ b/packages/mcp/src/tools/shared.ts @@ -31,12 +31,23 @@ export type ToolResult = { }; /** - * Truncate `text` to `budget` characters, appending an honest marker that - * records how many characters were dropped and — when a `webappUrl` is given — - * where the full value can be read. Returns the input unchanged when it already - * fits. This is the primitive the execution projection uses to guarantee a tool - * result can never exceed its char budget regardless of how large the underlying - * step input/output/logs were (a single step output can be multiple MB). + * Truncate `text` to fit within `budget` characters TOTAL (content + marker), + * appending an honest marker that records how many characters were dropped + * and — when a `webappUrl` is given — where the full value can be read. + * Returns the input unchanged when it already fits. This is the primitive + * the execution projection uses to guarantee a tool result can never exceed + * its char budget regardless of how large the underlying step + * input/output/logs were (a single step output can be multiple MB). + * + * The marker's own length depends on the dropped-char count (more digits as + * more is dropped), which depends on where we slice, which depends on the + * marker's length — so getting `result.length <= budget` exactly right takes + * a few passes: each pass's marker can only grow as the slice point shrinks + * to make room for it, so `sliceLen` is non-increasing and this always + * converges (in practice within one or two iterations; the loop bound below + * is a generous safety margin, not an expected iteration count). The final + * `.slice(0, budget)` is a hard backstop for the degenerate case where + * `budget` is too small to fit the marker at all. */ export function capText( text: string, @@ -44,9 +55,20 @@ export function capText( webappUrl?: string, ): string { if (text.length <= budget) return text; - const dropped = text.length - budget; const where = webappUrl ? ` — open ${webappUrl} for the full value` : ""; - return `${text.slice(0, budget)}…[truncated ${dropped} chars${where}]`; + let sliceLen = budget; + for (let i = 0; i < 5; i++) { + const dropped = text.length - sliceLen; + const marker = `…[truncated ${dropped} chars${where}]`; + const nextSliceLen = Math.max(0, budget - marker.length); + if (nextSliceLen === sliceLen) { + return `${text.slice(0, sliceLen)}${marker}`.slice(0, budget); + } + sliceLen = nextSliceLen; + } + const dropped = text.length - sliceLen; + const marker = `…[truncated ${dropped} chars${where}]`; + return `${text.slice(0, sliceLen)}${marker}`.slice(0, budget); } export function ok(data: unknown): ToolResult {