Repository navigation
Description
Summary
When a tool result is over the size budget, trimToolResponse (packages/api/src/utils/trimToolResponse.ts) keeps the first items of an array and replaces any value that is still too big with {"__hdx_trimmed": true, "originalSize": N}. It never shortens a string. Because the budget is split equally across keys, the biggest values go first, and in pattern and log results the biggest values are usually the most important ones: long stack traces.
For clickstack_table, the tool has no limit, no having and no default ORDER BY, so a large grouped result is cut to its first rows in whatever order ClickHouse returned them.
Environment
HyperDX 2-nightly image (late Oct 2026) with ClickHouse 26.10.3. Code references checked on main at d08b2fa (2026-11-03, 2.43.1).
Steps to reproduce
clickstack_event_patternswithtopN: 30on a service whose top errors are multi-line stack traces.- The response has 10 patterns, and the top ones look like
{"rank":1,"pattern":{"__hdx_trimmed":true,"originalSize":7443},"whereSnippet":{"__hdx_trimmed":true,…},"samples":[{"__hdx_trimmed":true,…}]}.
Self-contained (repro.mts below; runs the real trimToolResponse from main on an event_patterns-shaped object with 3 long and 27 short patterns):
isTrimmed=true; topN requested=30, patterns returned=10
rank 1: pattern={"__hdx_trimmed":true,"originalSize":4736} whereSnippet={"__hdx_trimmed":true,…} samples[0]={"__hdx_trimmed":true,…}
rank 2: … same
rank 3: … same
rank 4: pattern="Short INFO line 0 took <*> ms" (complete)
clickstack_tablewith agroupBythat yields a few thousand groups and noorderBy: the response holds the first rows of an unordered result (one run: 2,638 rows in, about 75 out), plus "Result was trimmed".
Expected vs actual
| Expected | Actual | |
|---|---|---|
| Over-budget pattern result | the top N patterns, with long strings shortened (…(+6,900 chars)) and fewer samples |
10 patterns; the top 3 replaced by markers |
| Large grouped table | the top rows by the first select (or by orderBy), up to a limit |
the first rows in arbitrary order |
Impact
In 4 agent investigations on production-size data: the most frequent error pattern of one service (99.5% of its ERROR lines) came back as a marker in 3 of 4 calls; topN 15–30 returned 10 every time; 66 KB and 81 KB responses still overflowed the client; and table calls returned 89–12,194 unordered groups that were then cut.
Proposed fix
- In
trimToolResponse, shorten long strings (e.g. to 500–2,000 chars with a suffix saying how much was cut) before replacing values or dropping array items. - For pattern results, drop
samples(keep 1) before dropping patterns, and keep the requestedtopNwhen it fits after that. - Add
limit(default about 50) andhavingtoclickstack_table, as dashboard tiles already have (packages/api/src/mcp/tools/dashboards/schemas.ts:839,:947), and defaultORDER BYto the first select descending whengroupByis set. - Say in the
notewhat was cut (e.g. "20 patterns dropped, 3 strings shortened").
Related
- feat(mcp): improve MCP tool quality — error hints, shared helpers, better messages #2337: shared MCP helpers and error hints
repro.mts
// Repro for trimToolResponse (packages/api/src/utils/trimToolResponse.ts) on an
// event_patterns-shaped result: long values are replaced, not shortened, and the
// array is cut to 10 items regardless of topN.
// Run from <hyperdx>/packages/api: npx tsx <this file> ./src/utils/trimToolResponse.ts
import { pathToFileURL } from 'node:url';
const { trimToolResponse } = await import(pathToFileURL(process.argv[2]).href);
const stack = (n: number) => `java.lang.NullPointerException: Cannot invoke "Catalog.items()" (case ${n})\n` +
Array.from({ length: 90 }, (_, i) => `\tat com.example.svc.Layer${i}.call(Layer${i}.java:${i + 10})`).join('\n'); // ~7.4 KB
const pattern = (rank: number, text: string, count: number) => ({
rank, id: `p${rank}`, pattern: text, estimatedCount: count, sampleCount: count, shareOfTotal: 0,
whereSnippet: `Body:"${text.slice(0, 2000)}"`, samples: Array.from({ length: 5 }, () => text),
});
const patterns = [
...[0, 1, 2].map(i => pattern(i + 1, stack(i), 9950 - i)), // the 3 most frequent: long stack traces
...Array.from({ length: 27 }, (_, i) => pattern(i + 4, `Short INFO line ${i} took <*> ms`, 30 - i)),
];
const output = { summary: { totalCount: 100000, patternsReturned: patterns.length }, patterns, usage: 'shareOfTotal is …' };
const { data, isTrimmed } = trimToolResponse(output);
console.log(`isTrimmed=${isTrimmed}; topN requested=30, patterns returned=${data.patterns.length}`);
for (const p of data.patterns.slice(0, 4)) {
const show = (v: unknown) => (typeof v === 'string' ? `"${v.slice(0, 40)}…" (${v.length} chars)` : JSON.stringify(v));
console.log(`rank ${p.rank}: pattern=${show(p.pattern)} whereSnippet=${show(p.whereSnippet)} samples[0]=${show(Array.isArray(p.samples) ? p.samples[0] : p.samples)}`);
}
Activity
brandon-pereira commentedon Nov 4, 2026
Thanks for the feedback. There are some good ideas here.
Scope
Let's please focus on
trimToolResponsein this issue. New table tool features likehavingandlimitshould move to a separate issue and PR so we can keep the scope smaller.trimToolResponseI agree. Let's do the following:
trimToolResponseoutput about stats on the trimming that occurred.trimToolResponseitself.An idea for 3 (open to alternates)
For event patterns:
How it would run:
Measuring the whole response against the whole budget also fixes the case where a response slightly over budget loses most of its patterns, because the
patternskey no longer gets capped at a third of the budget.fix(mcp): shorten instead of replacing when trimming tool responses, …