Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/fix-captext-budget-overrun.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/mcp": patch
---

Fix a truncation helper that could return output slightly longer than its configured character budget.
66 changes: 66 additions & 0 deletions packages/mcp/src/tools/shared.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
38 changes: 30 additions & 8 deletions packages/mcp/src/tools/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,22 +31,44 @@ 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,
budget: number,
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 {
Expand Down
Loading