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, …
bcanvural commentedon Nov 13, 2026
Thanks for #3554, the pattern reductions work well for us. One more case for
trimToolResponse, from PromQL dashboard tiles (clickstack_query_tile):Trimming drops whole series. PromQL tile rows are series-major (all points of series 1, then series 2, …;
toPromqlChartRowsincommon-utils/src/core/promqlChart.ts). When the result is over budget, the array cut keeps the first rows ofresult.data, so the series at the end disappear completely.seriesLabelsis not trimmed and still lists every series, and the note only saysresult.data: cut from 702 to 343 items. An agent then reads the remaining series as the whole answer.reproTileTrimming.int.test.ts(below; copy topackages/api/src/mcp/__tests__/dashboards/,mainat c220e16) writes one counter for 6 series (envdev/prod, 3 pods each), one sample per minute for 2 hours:In agent investigations on real dashboards this hid every production series of an unfiltered "requests per workload" tile (143 of 600 rows kept), dropped one of two production pods from a "job failures" tile, and kept the full timelines of 3 of ~261 series in a per-customer tile.
Proposal, in the reductions framework: for tile results, a reduction that thins points per series before any array cut, e.g.
{ path: 'result.data', perSeries: 'series_name', keepEvery: k }(every k-th point plus the last one), so every series inseriesLabelskeeps data. If series still have to go, drop the smallest and name them in the trim note (3 series dropped: …).Two related tile issues are outside
trimToolResponse; happy to open them separately if you prefer:clickstack_query_tilescountsrowCountafter trimming and drops the trim note, so a trimmed tile reportsstatus: "ok"with a plausible count (9 of 30 tiles in one run). Atrimmedflag and the pre-trim row and series counts would show it (per-tile timing is in MCP: PromQL, dashboard-tile, pattern anddescribe_sourcetools return no cost statistics #3649).series_nameon every row (promqlSeriesNameInputs); with a 544-char expression that is 75% of the response and the reason it gets trimmed at all.reproTileTrimming.int.test.ts