Skip to content

MCP response trimming replaces the largest values whole (often the top result) and ignores topN; clickstack_table has no limit, having or default order #3542

Open

Description

@bcanvural

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

  1. clickstack_event_patterns with topN: 30 on a service whose top errors are multi-line stack traces.
  2. 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)
  1. clickstack_table with a groupBy that yields a few thousand groups and no orderBy: 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

  1. 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.
  2. For pattern results, drop samples (keep 1) before dropping patterns, and keep the requested topN when it fits after that.
  3. Add limit (default about 50) and having to clickstack_table, as dashboard tiles already have (packages/api/src/mcp/tools/dashboards/schemas.ts:839, :947), and default ORDER BY to the first select descending when groupBy is set.
  4. Say in the note what was cut (e.g. "20 patterns dropped, 3 strings shortened").

Related

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

  1. brandon-pereira commented on Nov 4, 2026

    @brandon-pereira
    Member

    Thanks for the feedback. There are some good ideas here.

    Scope

    Let's please focus on trimToolResponse in this issue. New table tool features like having and limit should move to a separate issue and PR so we can keep the scope smaller.

    trimToolResponse

    I agree. Let's do the following:

    1. If trimming must occur, shorten strings with a suffix. This can be done across all tools.
    2. Send notes in the trimToolResponse output about stats on the trimming that occurred.
    3. Dropping samples before patterns is a good idea. However, please consider how we can do this from inside trimToolResponse itself.

    An idea for 3 (open to alternates)

    type Reduction =
      | { path: string; keep: number }     // shrink array elements to max of keep
      | { path: string; maxChars: number } // shorten matching strings, with "…(+6,900 chars)"
      | { path: string; drop: true };      // remove the key
    
    trimToolResponse(data, maxSize, { reductions?: Reduction[] })

    For event patterns:

    trimToolResponse(output, undefined, {
      reductions: [
        { path: 'patterns[].samples', keep: 3 },
        { path: 'patterns[].samples', keep: 1 },
        { path: 'patterns[].samples[]', maxChars: 2000 },
        { path: 'patterns[].pattern', maxChars: 2000 },
        { path: 'patterns[].trend', drop: true },
      ],
    });

    How it would run:

    1. If the response fits, return it as is (unchanged).
    2. Apply the reductions in order. Each one applies to every match (all patterns go to 3 samples, then all to 1), so the ranks stay comparable. After each step, measure the whole response and stop as soon as it fits.
    3. Only if it's still over budget, fall back to today's per-key split and array cuts.

    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 patterns key no longer gets capped at a third of the budget.

  2. bcanvural commented on Nov 13, 2026

    @bcanvural
    ContributorAuthor

    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, …; toPromqlChartRows in common-utils/src/core/promqlChart.ts). When the result is over budget, the array cut keeps the first rows of result.data, so the series at the end disappear completely. seriesLabels is not trimmed and still lists every series, and the note only says result.data: cut from 702 to 343 items. An agent then reads the remaining series as the whole answer.

    reproTileTrimming.int.test.ts (below; copy to packages/api/src/mcp/__tests__/dashboards/, main at c220e16) writes one counter for 6 series (env dev / prod, 3 pods each), one sample per minute for 2 hours:

    query_tile "sum by (env, pod) (rate(...[5m]))":
      rows returned=343 of 702; points per series: dev-pod-0=117, dev-pod-1=117, dev-pod-2=109
      seriesLabels lists 6; missing from data: prod-pod-0, prod-pod-1, prod-pod-2
      trimNotes=["Response trimmed from 77,217 to 37,734 chars (limit 50,000).","result.data: cut from 702 to 343 items"]
    

    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 in seriesLabels keeps 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_tiles counts rowCount after trimming and drops the trim note, so a trimmed tile reports status: "ok" with a plausible count (9 of 30 tiles in one run). A trimmed flag and the pre-trim row and series counts would show it (per-tile timing is in MCP: PromQL, dashboard-tile, pattern and describe_source tools return no cost statistics #3649).
    • A single-series PromQL tile without a legend uses its whole expression as series_name on 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
    // Repro: clickstack_query_tile / clickstack_query_tiles on PromQL tiles whose result is over the
    // 50,000-char response budget. Copy to packages/api/src/mcp/__tests__/dashboards/ and run:
    //   make dev-int FILE=reproTileTrimming
    import { DisplayType, SourceKind } from '@hyperdx/common-utils/dist/types';
    
    import {
      DEFAULT_DATABASE,
      dropTimeSeriesTable,
      executeTimeSeriesSqlCommand,
      seedTimeSeriesTagsTable,
    } from '@/fixtures';
    import { callTool, getFirstText } from '@/mcp/__tests__/mcpTestUtils';
    import Dashboard from '@/models/dashboard';
    import { Source } from '@/models/source';
    
    import { setupDashboardTests } from './setup';
    
    const mockFetch = jest.mocked(global.fetch);
    const realFetch: typeof fetch = globalThis.realFetch;
    
    const TABLE = 'repro_tile_trimming';
    const START = 1700000000;
    const MINUTES = 120;
    // 6 series: 3 pods in env "dev", 3 in env "prod", one sample per minute for 2 h.
    const SERIES = ['dev', 'prod'].flatMap(env =>
      [0, 1, 2].map(i => ({ env, pod: `${env}-pod-${i}` })),
    );
    // A single-series KPI expression of ~600 chars, as dashboards converted from other tools often have.
    const LONG_EXPR =
      'sum(rate(repro_requests_total{env=~"dev|prod", pod=~".+-pod-[0-9]+", job!="", instance!~"canary-.*", route!~"/(health|ready|live|metrics)z?", method=~"GET|POST|PUT|DELETE"}[5m])) ' +
      '/ ignoring() group_left() ' +
      'clamp_min(sum(rate(repro_requests_total{env=~"dev|prod", pod=~".+-pod-[0-9]+", job!="", instance!~"canary-.*", route!~"/(health|ready|live|metrics)z?"}[5m])), 1) ' +
      '* 100 + 0 * sum(rate(repro_requests_total{env=~"dev|prod", pod=~".+-pod-[0-9]+", job!="", instance!~"canary-.*", route!~"/(health|ready|live|metrics)z?", status=~"[25].."}[5m]))';
    
    describe('repro: tile results over the response budget', () => {
      const ctx = setupDashboardTests();
    
      beforeAll(async () => {
        await seedTimeSeriesTagsTable({
          table: TABLE,
          series: SERIES.map(tags => ({
            metricName: 'repro_requests_total',
            tags: { ...tags, job: 'api', route: '/v1/items', method: 'GET', status: '200' },
            startSec: START,
            endSec: START + MINUTES * 60,
          })),
          withSamples: true,
        });
        // One sample per minute per series (a counter going up by 60/min).
        await executeTimeSeriesSqlCommand(
          `INSERT INTO TABLE FUNCTION timeSeriesData('${DEFAULT_DATABASE}', '${TABLE}')
           SELECT id, toDateTime64(${START} + n * 60, 3), n * 60
           FROM timeSeriesTags('${DEFAULT_DATABASE}', '${TABLE}') ARRAY JOIN range(0, ${MINUTES + 1}) AS n`,
        );
      });
    
      afterAll(async () => {
        await dropTimeSeriesTable({ table: TABLE });
      });
    
      beforeEach(async () => {
        await ctx.reconnect({ promqlEnabled: true });
        mockFetch.mockImplementation(realFetch);
      });
      afterEach(() => mockFetch.mockReset());
    
      const createDashboard = async (tiles: { id: string; name: string; expression: string }[]) => {
        const source = await Source.create({
          kind: SourceKind.Promql,
          team: ctx.team._id,
          connection: ctx.connection._id,
          name: 'Metrics',
          from: { databaseName: DEFAULT_DATABASE, tableName: TABLE },
          timestampValueExpression: 'timestamp',
        });
        return new Dashboard({
          name: 'Repro',
          tiles: tiles.map(t => ({
            id: t.id,
            x: 0,
            y: 0,
            w: 6,
            h: 3,
            config: {
              configType: 'promql',
              name: t.name,
              displayType: DisplayType.Line,
              source: source._id.toString(),
              granularity: '1 minute',
              promqlExpression: [{ expression: t.expression }],
            },
          })),
          filters: [],
          team: ctx.team._id,
        }).save();
      };
    
      const window = {
        startTime: new Date((START + 300) * 1000).toISOString(),
        endTime: new Date((START + MINUTES * 60) * 1000).toISOString(),
      };
    
      it('query_tile drops whole series when it trims, and seriesLabels still lists them', async () => {
        const dashboard = await createDashboard([
          { id: 'per-pod', name: 'Requests per pod', expression: 'sum by (env, pod) (rate(repro_requests_total[5m]))' },
        ]);
        const result = await callTool(ctx.client!, 'clickstack_query_tile', {
          dashboardId: dashboard._id.toString(),
          tileId: 'per-pod',
          ...window,
        });
        expect(result.isError).toBeFalsy();
        const out = JSON.parse(getFirstText(result));
        const rows: { series_name: string }[] = out.result.data;
        const perSeries = new Map<string, number>();
        for (const r of rows) perSeries.set(r.series_name, (perSeries.get(r.series_name) ?? 0) + 1);
        const listed = Object.keys(out.result.seriesLabels);
        const missing = listed.filter(s => !perSeries.has(s));
        console.log(
          `REPRO query_tile: rows returned=${rows.length} of ${out.result.rows}; ` +
            `points per series=${JSON.stringify(Object.fromEntries(perSeries))}; ` +
            `seriesLabels lists ${listed.length}; missing from data: ${JSON.stringify(missing)}; ` +
            `note=${JSON.stringify(out.note)} trimNotes=${JSON.stringify(out.trimNotes)}`,
        );
        expect(listed).toHaveLength(6);
        expect(missing.length).toBeGreaterThan(0); // whole series gone
        expect(missing.every(s => s.includes('prod'))).toBe(true); // all from the later env
        expect(JSON.stringify(out.trimNotes)).not.toContain('prod'); // nothing names them
      });
    
      it('a single-series tile without a legend repeats the whole expression as series_name on every row', async () => {
        const dashboard = await createDashboard([{ id: 'kpi', name: 'Error ratio', expression: LONG_EXPR }]);
        const result = await callTool(ctx.client!, 'clickstack_query_tile', {
          dashboardId: dashboard._id.toString(),
          tileId: 'kpi',
          ...window,
        });
        expect(result.isError).toBeFalsy();
        const text = getFirstText(result);
        const out = JSON.parse(text);
        const rows: { series_name: string }[] = out.result.data;
        const nameChars = rows.reduce((n, r) => n + r.series_name.length, 0);
        console.log(
          `REPRO single-series tile: expression=${LONG_EXPR.length} chars, rows=${rows.length}, ` +
            `series_name === expression on every row: ${rows.every(r => r.series_name === LONG_EXPR)}, ` +
            `series_name chars=${nameChars} of ${text.length} response chars; trimNotes=${JSON.stringify(out.trimNotes)}; ` +
            `last row kept=${(rows.at(-1) as any)?.__hdx_time_bucket} (window ends ${window.endTime})`,
        );
        expect(rows.every(r => r.series_name === LONG_EXPR)).toBe(true);
      });
    
      it('query_tiles reports ok and the post-trim rowCount, with no trimmed flag or timing', async () => {
        const dashboard = await createDashboard([
          { id: 'per-pod', name: 'Requests per pod', expression: 'sum by (env, pod) (rate(repro_requests_total[5m]))' },
          { id: 'per-env', name: 'Requests per env', expression: 'sum by (env) (rate(repro_requests_total[5m]))' },
        ]);
        const result = await callTool(ctx.client!, 'clickstack_query_tiles', {
          dashboardId: dashboard._id.toString(),
          ...window,
        });
        const out = JSON.parse(getFirstText(result));
        console.log(`REPRO query_tiles: ${JSON.stringify(out.tiles)}`);
        const perPod = out.tiles.find((t: { tileId: string }) => t.tileId === 'per-pod');
        expect(perPod).toMatchObject({ status: 'ok', hasData: true });
        expect(perPod.rowCount).toBeLessThan(6 * (MINUTES - 4)); // the trimmed count, not the real one
        expect(Object.keys(perPod).sort()).toEqual(['displayType', 'hasData', 'name', 'rowCount', 'status', 'tileId']);
      });
    });
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions