Skip to content
Merged
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/sequentialthinking-5433-bounded-thought-box.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@modelcontextprotocol/server-sequential-thinking": patch
---

Bound the thought box logged to stderr. Since the multi-line box, every row was padded to the thought's longest line, so a thought with one long line and many short ones logged their product: a 60 KB thought wrote 400 million characters and could exhaust the server's memory. The box is now at most 100 characters wide (longer lines, and a long branch ID in the header, wrap) and draws at most 1000 rows of thought text (it then says how many lines it left out), so the log no longer grows with the thought's line count times its longest line. The thought kept in the server's history and the tool result are unchanged.
2 changes: 2 additions & 0 deletions src/sequentialthinking/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ On Windows, use `cmd /c` to launch `npx`:

To disable logging of thought information set env var: `DISABLE_THOUGHT_LOGGING` to `true`.

Each thought is logged to stderr as a box, one row per line of the thought. The box is at most 100 characters wide, with longer lines (and a long branch ID in the header) wrapped, and draws at most 1000 rows of thought text; past that it ends with a row saying how many lines it left out. Only the log is shortened: the server keeps the whole thought in its history, and the tool result is unchanged.

To bound memory use, the server retains at most `MAX_THOUGHT_HISTORY` thoughts per connection (a positive integer; default `1000`). Past the cap the oldest thought is dropped, from its branch too, so `thoughtHistoryLength` in the result stops at the cap and a branch whose thoughts have all been dropped is no longer listed in `branches`.

### Usage with VS Code
Expand Down
117 changes: 115 additions & 2 deletions src/sequentialthinking/__tests__/thought-logging.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// The server's other observable output: unless DISABLE_THOUGHT_LOGGING is
// "true" (any case), each accepted thought is drawn as a box on stderr, with
// a header that differs for plain thoughts, revisions and branches, and one
// row per line of the thought. Thoughts
// row per line of the thought, wrapped at MAX_BOX_TEXT_WIDTH and cut off
// after MAX_BOX_ROWS. Thoughts
// are driven through the in-process client and stderr is captured by spying
// on console.error, which also keeps the boxes off the test output. chalk's
// colour level depends on the terminal the tests run in, so it is pinned to 0
Expand All @@ -18,6 +19,7 @@ import {
type MockInstance,
} from "vitest";
import chalk from "chalk";
import { MAX_BOX_ROWS, MAX_BOX_TEXT_WIDTH } from "../lib.js";
import { connect, thought, type Connected } from "./helpers.js";

describe("sequentialthinking thought logging", () => {
Expand Down Expand Up @@ -229,11 +231,122 @@ ${rows.join("\n")}
expect(new Set(rows.map((row) => row.length)).size).toBe(1);
});

// #5433: past MAX_BOX_ROWS the box stops drawing and says how many lines
// it left out; the call itself is unaffected.
it("draws a thought with very many lines", async () => {
const lines = Array.from({ length: 200_000 }, (_, i) => `${i % 10}`);
const result = await conn!.think(thought({ thought: lines.join("\n") }));
expect(result.isError).toBeFalsy();
expect(logged()[0].split("\n")).toHaveLength(200_000 + 5);
const out = logged()[0].split("\n");
expect(out).toHaveLength(MAX_BOX_ROWS + 1 + 5);
expect(out.at(-2)).toContain(
`… ${200_000 - MAX_BOX_ROWS} more lines not shown`,
);
// The summary is wider than every one-character line and the header,
// so it sets the box's width; it must not stick out of it.
expect(new Set(out.slice(1).map((r) => r.length)).size).toBe(1);
});
});

// #5433: the box is never wider than MAX_BOX_TEXT_WIDTH. A longer line, or
// a longer header, wraps onto rows of that width, so the box grows linearly
// with the thought instead of as its longest line times its line count.
describe("a thought wider than the box", () => {
const W = MAX_BOX_TEXT_WIDTH;
const border = "─".repeat(W + 4);
const row = (text: string) => `│ ${text.padEnd(border.length - 2)} │`;

beforeEach(async () => {
conn = await connect({ disableThoughtLogging: undefined });
});

it("wraps a long line onto rows of the capped width", async () => {
const text = `${"a".repeat(W)}${"b".repeat(W)}${"c".repeat(5)}`;
await conn!.think(thought({ thought: `${text}\nshort` }));
expect(logged()).toEqual([
`
┌${border}┐
${row("💭 Thought 1/3")}
├${border}┤
${row("a".repeat(W))}
${row("b".repeat(W))}
${row("c".repeat(5))}
${row("short")}
└${border}┘`,
]);
});

it("stays bounded for one long line and many short ones", async () => {
// The case from the review of #5430: before the cap, every one of the
// 19,999 short rows was padded to 20,000 characters, 400 million in all.
const n = 20_000;
const text = `${"x".repeat(n)}${"\ny".repeat(n - 1)}`;
const result = await conn!.think(thought({ thought: text }));
expect(result.isError).toBeFalsy();
const out = logged()[0];
// 200 wrapped rows for the long line, short lines up to the row cap,
// then the summary row; each row is W + 6 characters and a newline.
const shortShown = MAX_BOX_ROWS - n / W;
expect(out.split("\n")).toHaveLength(MAX_BOX_ROWS + 1 + 5);
expect(out).toContain(`… ${n - 1 - shortShown} more lines not shown`);
expect(out.length).toBeLessThan((MAX_BOX_ROWS + 10) * (W + 7));
});

it("counts a line the row cap cuts off as not shown", async () => {
const text = `${"s\n".repeat(MAX_BOX_ROWS - 1)}${"L".repeat(2 * W)}`;
await conn!.think(thought({ thought: text }));
const rows = logged()[0].split("\n");
expect(rows).toHaveLength(MAX_BOX_ROWS + 1 + 5);
expect(rows.at(-3)).toBe(row("L".repeat(W)));
expect(rows.at(-2)).toBe(row("… 1 more line not shown"));
});

it("draws a thought that exactly fills the cap with no summary row", async () => {
const text = Array.from({ length: MAX_BOX_ROWS }, () => "s").join("\n");
await conn!.think(thought({ thought: text }));
const rows = logged()[0].split("\n");
expect(rows).toHaveLength(MAX_BOX_ROWS + 5);
expect(logged()[0]).not.toContain("not shown");
});

it("draws every row the same width", async () => {
await conn!.think(thought({ thought: `${"z".repeat(3 * W + 7)}\nend` }));
const rows = logged()[0].split("\n").slice(1);
expect(new Set(rows.map((r) => r.length))).toEqual(new Set([W + 6]));
});

it("wraps a header longer than the cap, and keeps the box linear", async () => {
const branchId = "b".repeat(2 * W);
const lines = 1_000;
await conn!.think(
thought({
thought: Array.from({ length: lines }, () => "s").join("\n"),
branchFromThought: 1,
branchId,
}),
);
const out = logged()[0];
const rows = out.split("\n").slice(1);
expect(new Set(rows.map((r) => r.length))).toEqual(new Set([W + 6]));
const header = `🌿 Branch 1/3 (from thought 1, ID: ${branchId})`;
const headerRows = rows.slice(1, rows.indexOf(`├${border}┤`));
expect(headerRows.map((r) => r.slice(2, -2).trimEnd()).join("")).toBe(
header,
);
// Each row is W + 6 characters and a newline. Uncapped, the header would
// have set every row's width to its own length of more than 2 × W.
expect(out.length).toBeLessThan((lines + 10) * (W + 7));
});

it("never splits an emoji across rows", async () => {
// The emoji's two UTF-16 halves would straddle the cut at W.
const text = `${"a".repeat(W - 1)}😀tail`;
await conn!.think(thought({ thought: text }));
const rows = logged()[0].split("\n").slice(4, -1);
expect(rows.map((r) => r.slice(2, -2).trimEnd())).toEqual([
"a".repeat(W - 1),
"😀tail",
]);
});
});

Expand Down
99 changes: 86 additions & 13 deletions src/sequentialthinking/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,50 @@ export interface ThoughtData {
nextThoughtNeeded: boolean;
}

/**
* The widest the text inside a thought box gets (#5433). A longer line, or a
* longer header, wraps onto further rows of this width. Without the cap every
* row was padded to the longest line, so one long line and many short ones
* made the box grow as their product: a 60 KB thought logged 400 million
* characters. Capped, the box grows linearly with the thought.
*/
export const MAX_BOX_TEXT_WIDTH = 100;

/**
* The most rows of thought text a box draws (#5433). Each row is padded to
* the box's width, so a thought of many short lines would still log about a
* hundred times its own size; past this many rows the box ends with one row
* saying how many lines it left out. Header rows (a long branchId) are not
* counted. Only stderr is cut: the thought kept in thoughtHistory is whole,
* and the tool result, which carries no thought text, is unchanged.
*/
export const MAX_BOX_ROWS = 1000;

/**
* `text` cut into rows of at most `width` UTF-16 code units, never between
* the two halves of a surrogate pair, so an emoji is never split across rows.
* An empty string is one empty row.
*/
function wrapRow(text: string, width: number): string[] {
const rows: string[] = [];
let start = 0;
do {
let end = Math.min(start + width, text.length);
const last = text.charCodeAt(end - 1);
if (
end < text.length &&
end - start > 1 &&
last >= 0xd800 &&
last <= 0xdbff
) {
end -= 1;
}
rows.push(text.slice(start, end));
start = end;
} while (start < text.length);
return rows;
}

/**
* How many thoughts one server instance retains when MAX_THOUGHT_HISTORY is
* unset (#2912). A server lives as long as its client's connection, so without
Expand Down Expand Up @@ -104,25 +148,54 @@ export class SequentialThinkingServer {
const lines = thought.split(/\r\n|\r|\n/);
// A reduce rather than Math.max(...lengths): spreading one argument per
// line would overflow the call stack on a thought with very many lines.
const width = lines.reduce(
(widest, line) => Math.max(widest, line.length),
visibleHeader.length,
// #5433: never wider than MAX_BOX_TEXT_WIDTH; longer lines wrap below.
const wrapWidth = Math.min(
MAX_BOX_TEXT_WIDTH,
lines.reduce(
(widest, line) => Math.max(widest, line.length),
visibleHeader.length,
),
);
// A header longer than the cap (a long branchId) wraps like a thought
// line; only its first row, which starts with the label, is coloured.
const headerTexts = wrapRow(visibleHeader, wrapWidth);
// Rows are drawn up to MAX_BOX_ROWS; the lines left over are counted, not
// drawn, so a huge thought costs no more than the cap.
const texts: string[] = [];
// Lines drawn in full; a line cut off by the cap counts as hidden.
let whole = 0;
for (const line of lines) {
const wrapped = wrapRow(line, wrapWidth);
const room = MAX_BOX_ROWS - texts.length;
texts.push(...wrapped.slice(0, room));
if (wrapped.length > room) break;
whole++;
}
const hidden = lines.length - whole;
const summary =
hidden > 0
? `… ${hidden} more line${hidden === 1 ? "" : "s"} not shown`
: "";
if (summary) texts.push(summary);
// The summary row can be wider than every line it follows, so the box is
// sized after it. Widening cannot change the wrapping above: below the cap
// no line wrapped, and at the cap the summary (under 40 characters) fits.
const width = Math.max(wrapWidth, summary.length);
const border = "─".repeat(width + 4);
// The header row is padded to the border's width like the thought rows, by
// the visible text's length. Each label's emoji is one surrogate pair, so
// its length of 2 is also its display width in a terminal.
const padding = " ".repeat(border.length - 2 - visibleHeader.length);
const header = `${colour(label)}${counts}${padding}`;
const rows = lines
.map((line) => `│ ${line.padEnd(border.length - 2)} │`)
.join("\n");
// Every row, header rows included, is padded to the border's width by the
// visible text's length. Each label's emoji is one surrogate pair, so its
// length of 2 is also its display width in a terminal.
const row = (text: string) => `│ ${text.padEnd(border.length - 2)} │`;
const headerRows = headerTexts.map((text, i) =>
i === 0 ? row(text).replace(label, colour(label)) : row(text),
);
const rows = texts.map(row);

return `
┌${border}┐
│ ${header} │
${headerRows.join("\n")}
├${border}┤
${rows}
${rows.join("\n")}
└${border}┘`;
}

Expand Down
Loading