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.

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