From 2c24915e908e351d65bc3fac535792c695762d29 Mon Sep 17 00:00:00 2001 From: Baris Can Vural Date: Wed, 4 Nov 2026 19:15:06 +0300 Subject: [PATCH 1/7] fix(mcp): shorten instead of replacing when trimming tool responses, add ordered reductions and trim notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trimToolResponse replaced any value over its budget with {"__hdx_trimmed":true}, so the longest values (often the top result, such as a stack-trace pattern) disappeared, and event_patterns lost most of its topN when the response was a little over budget. Strings are now shortened with a "…(+N chars)" suffix, the result lists what was trimmed (trimNotes), and an ordered reductions option (keep / maxChars / drop per path) is applied to every match, measuring the whole response after each step, before the per-key fallback. event_patterns and emerging_signals use it. Refs #3542. --- .changeset/mcp-trim-shorten-reductions.md | 5 + .../src/mcp/tools/query/emergingSignals.ts | 12 +- .../api/src/mcp/tools/query/eventDeltas.ts | 7 +- packages/api/src/mcp/tools/query/helpers.ts | 10 +- .../src/mcp/tools/query/runEventPatterns.ts | 17 +- packages/api/src/mcp/tools/query/search.ts | 15 +- .../mcp/tools/query/serviceHealth/index.ts | 3 +- .../src/mcp/tools/sources/describeMetric.ts | 7 +- .../src/mcp/tools/sources/describeSource.ts | 7 +- .../utils/__tests__/trimToolResponse.test.ts | 163 ++++++++++- packages/api/src/utils/trimToolResponse.ts | 261 +++++++++++++++--- 11 files changed, 453 insertions(+), 54 deletions(-) create mode 100644 .changeset/mcp-trim-shorten-reductions.md diff --git a/.changeset/mcp-trim-shorten-reductions.md b/.changeset/mcp-trim-shorten-reductions.md new file mode 100644 index 0000000000..4f319e8280 --- /dev/null +++ b/.changeset/mcp-trim-shorten-reductions.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/api': patch +--- + +fix: MCP tool responses that are over the size budget now shorten long strings with a `…(+N chars)` suffix instead of replacing them, say what was trimmed in `trimNotes`, and `clickstack_event_patterns` / `clickstack_emerging_signals` drop samples and shorten long patterns before cutting whole patterns, so the requested `topN` survives when it can diff --git a/packages/api/src/mcp/tools/query/emergingSignals.ts b/packages/api/src/mcp/tools/query/emergingSignals.ts index a29decf503..1fa09817ee 100644 --- a/packages/api/src/mcp/tools/query/emergingSignals.ts +++ b/packages/api/src/mcp/tools/query/emergingSignals.ts @@ -342,7 +342,16 @@ export function registerEmergingSignals({ 'the threshold — a valid finding.', }; - const { data, isTrimmed } = trimToolResponse(output); + const { + data, + isTrimmed, + notes: trimNotes, + } = trimToolResponse(output, undefined, { + reductions: [ + { path: 'emerging[].pattern', maxChars: 2000 }, + { path: 'disappeared[].pattern', maxChars: 2000 }, + ], + }); return { content: [ { @@ -352,6 +361,7 @@ export function registerEmergingSignals({ ? { ...data, note: 'Trimmed for context size — lower topN or narrow windows.', + trimNotes, } : data, null, diff --git a/packages/api/src/mcp/tools/query/eventDeltas.ts b/packages/api/src/mcp/tools/query/eventDeltas.ts index 95824415ef..bed6345f9c 100644 --- a/packages/api/src/mcp/tools/query/eventDeltas.ts +++ b/packages/api/src/mcp/tools/query/eventDeltas.ts @@ -463,12 +463,17 @@ export function registerEventDeltas({ context, registerTool }: ToolRegistrar) { 'are dropped by default — set includeHidden:true to inspect them.', }; - const { data: trimmedOutput, isTrimmed } = trimToolResponse(output); + const { + data: trimmedOutput, + isTrimmed, + notes: trimNotes, + } = trimToolResponse(output); const finalOutput = isTrimmed ? { ...trimmedOutput, note: 'Result was trimmed for context size. Narrow the time range, add filters, or reduce topN/topValuesPerProperty to reduce data.', + trimNotes, } : trimmedOutput; diff --git a/packages/api/src/mcp/tools/query/helpers.ts b/packages/api/src/mcp/tools/query/helpers.ts index dd3dde0d4e..cc7e2c1617 100644 --- a/packages/api/src/mcp/tools/query/helpers.ts +++ b/packages/api/src/mcp/tools/query/helpers.ts @@ -297,10 +297,11 @@ function isEmptyResult(result: unknown): boolean { } function formatQueryResult(result: unknown, maxResponseChars?: number) { - const { data: trimmedResult, isTrimmed } = trimToolResponse( - result, - maxResponseChars, - ); + const { + data: trimmedResult, + isTrimmed, + notes: trimNotes, + } = trimToolResponse(result, maxResponseChars); const empty = isEmptyResult(result); return { content: [ @@ -312,6 +313,7 @@ function formatQueryResult(result: unknown, maxResponseChars?: number) { ...(isTrimmed ? { note: 'Result was trimmed for context size. Narrow the time range or add filters to reduce data.', + trimNotes, } : {}), ...(empty diff --git a/packages/api/src/mcp/tools/query/runEventPatterns.ts b/packages/api/src/mcp/tools/query/runEventPatterns.ts index b9a2464c4d..e3ecab1cd0 100644 --- a/packages/api/src/mcp/tools/query/runEventPatterns.ts +++ b/packages/api/src/mcp/tools/query/runEventPatterns.ts @@ -427,12 +427,27 @@ export async function runEventPatterns( 'Use whereSnippet as the "where" parameter in a clickstack_search call to browse matching raw events.', }; - const { data: trimmedOutput, isTrimmed } = trimToolResponse(output); + // Shrink samples, then long text, before the generic trim cuts whole patterns, + // so topN patterns survive when they can. + const { + data: trimmedOutput, + isTrimmed, + notes: trimNotes, + } = 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 }, + ], + }); const finalOutput = isTrimmed ? { ...trimmedOutput, note: 'Result was trimmed for context size. Narrow the time range, add filters, or reduce topN to reduce data.', + trimNotes, } : trimmedOutput; diff --git a/packages/api/src/mcp/tools/query/search.ts b/packages/api/src/mcp/tools/query/search.ts index ca78c83215..efe568fa52 100644 --- a/packages/api/src/mcp/tools/query/search.ts +++ b/packages/api/src/mcp/tools/query/search.ts @@ -150,7 +150,11 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { 'denoiseSearchResults failed; returning raw results', ); - const { data: trimmedResult, isTrimmed } = trimToolResponse(resultData); + const { + data: trimmedResult, + isTrimmed, + notes: trimNotes, + } = trimToolResponse(resultData); return { content: [ { @@ -167,6 +171,7 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { ...(isTrimmed ? { note: 'Result was trimmed for context size. Narrow the time range or add filters to reduce data.', + trimNotes, } : {}), }, @@ -185,8 +190,11 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { ...resultData, data: denoised.rows, }; - const { data: trimmedResult, isTrimmed } = - trimToolResponse(denoisedResult); + const { + data: trimmedResult, + isTrimmed, + notes: trimNotes, + } = trimToolResponse(denoisedResult); return { content: [ @@ -206,6 +214,7 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { ...(isTrimmed ? { note: 'Result was trimmed for context size. Narrow the time range or add filters to reduce data.', + trimNotes, } : {}), ...(denoised.rows.length === 0 && !denoised.skipped diff --git a/packages/api/src/mcp/tools/query/serviceHealth/index.ts b/packages/api/src/mcp/tools/query/serviceHealth/index.ts index d0d9e72128..ae68c7c650 100644 --- a/packages/api/src/mcp/tools/query/serviceHealth/index.ts +++ b/packages/api/src/mcp/tools/query/serviceHealth/index.ts @@ -178,7 +178,7 @@ export function registerServiceHealth({ report.summary.statusCounts.degraded, }); - const { data, isTrimmed } = trimToolResponse(report); + const { data, isTrimmed, notes: trimNotes } = trimToolResponse(report); return { content: [ { @@ -188,6 +188,7 @@ export function registerServiceHealth({ ? { ...data, note: 'Trimmed for context size. Lower topN or topErrorsPerService.', + trimNotes, } : data, null, diff --git a/packages/api/src/mcp/tools/sources/describeMetric.ts b/packages/api/src/mcp/tools/sources/describeMetric.ts index 36ccec9ca8..b4818f51af 100644 --- a/packages/api/src/mcp/tools/sources/describeMetric.ts +++ b/packages/api/src/mcp/tools/sources/describeMetric.ts @@ -682,7 +682,11 @@ async function describeMetricImpl( }, }; - const { data: trimmed, isTrimmed } = trimToolResponse(responseObj); + const { + data: trimmed, + isTrimmed, + notes: trimNotes, + } = trimToolResponse(responseObj); return { content: [ { @@ -692,6 +696,7 @@ async function describeMetricImpl( ? { ...trimmed, note: 'Result was trimmed for context size. Set sampleValues:false or narrow startTime/endTime.', + trimNotes, } : trimmed, null, diff --git a/packages/api/src/mcp/tools/sources/describeSource.ts b/packages/api/src/mcp/tools/sources/describeSource.ts index aeda0f3bd8..881a75ed80 100644 --- a/packages/api/src/mcp/tools/sources/describeSource.ts +++ b/packages/api/src/mcp/tools/sources/describeSource.ts @@ -514,7 +514,11 @@ function formatDescribeResult({ ? `For more metric names than the sample above, call clickstack_list_metrics with sourceId "${sourceId}". For per-metric attribute keys + sampled values, call clickstack_describe_metric with sourceId and metricName.` : undefined; - const { data: output, isTrimmed } = trimToolResponse({ + const { + data: output, + isTrimmed, + notes: trimNotes, + } = trimToolResponse({ source, usage: { topLevelColumns: @@ -542,6 +546,7 @@ function formatDescribeResult({ ? { ...output, note: 'Result was trimmed for context size. Some columns or sampled values may be omitted.', + trimNotes, } : output; diff --git a/packages/api/src/utils/__tests__/trimToolResponse.test.ts b/packages/api/src/utils/__tests__/trimToolResponse.test.ts index b94ad84c8a..57ae4bad63 100644 --- a/packages/api/src/utils/__tests__/trimToolResponse.test.ts +++ b/packages/api/src/utils/__tests__/trimToolResponse.test.ts @@ -1,4 +1,4 @@ -import { trimToolResponse } from '@/utils/trimToolResponse'; +import { shortenString, trimToolResponse } from '@/utils/trimToolResponse'; describe('trimToolResponse', () => { describe('return shape', () => { @@ -137,4 +137,165 @@ describe('trimToolResponse', () => { expect(resultDefault.data.length).toBe(resultExplicit.data.length); }); }); + + describe('shortening instead of replacing', () => { + it('shortens an oversized string with a "…(+N chars)" suffix', () => { + expect(shortenString('a'.repeat(10), 4)).toBe('aaaa…(+6 chars)'); + expect(shortenString('a'.repeat(8000), 1000)).toBe( + `${'a'.repeat(1000)}…(+7,000 chars)`, + ); + expect(shortenString('short', 10)).toBe('short'); + }); + + it('shortens long string values in the fallback and says so in notes', () => { + const data = { big: 'x'.repeat(5000), small: 'ok' }; + const { data: result, isTrimmed, notes } = trimToolResponse(data, 2000); + expect(isTrimmed).toBe(true); + expect(typeof result.big).toBe('string'); + expect(result.big).toMatch(/^x+…\(\+[\d,]+ chars\)$/); + expect(result.small).toBe('ok'); + expect(JSON.stringify(result).length).toBeLessThanOrEqual(2000); + expect(notes[0]).toMatch( + /^Response trimmed from 5,0\d\d to [\d,]+ chars \(limit 2,000\)\.$/, + ); + expect(notes.some(n => /long value\(s\) shortened/.test(n))).toBe(true); + }); + + it('notes array cuts with their path', () => { + const data = { rows: Array.from({ length: 100 }, () => 'y'.repeat(100)) }; + const { notes } = trimToolResponse(data, 3000); + expect(notes).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^rows: cut from 100 to \d+ items$/), + ]), + ); + }); + + it('returns no notes when nothing is trimmed', () => { + expect(trimToolResponse({ a: 1 }).notes).toEqual([]); + }); + }); + + describe('ordered reductions', () => { + const big = (n: number) => 'z'.repeat(n); + const shape = () => ({ + patterns: Array.from({ length: 4 }, (_, i) => ({ + rank: i + 1, + pattern: big(3000), + trend: Array.from({ length: 50 }, (_, t) => ({ ts: t, count: t })), + samples: Array.from({ length: 5 }, () => big(1000)), + })), + }); + + it('applies each kind to every match', () => { + const { data, notes } = trimToolResponse(shape(), 10, { + reductions: [ + { path: 'patterns[].samples', keep: 2 }, + { path: 'patterns[].samples[]', maxChars: 10 }, + { path: 'patterns[].pattern', maxChars: 20 }, + { path: 'patterns[].trend', drop: true }, + ], + }); + for (const p of data.patterns) { + expect(p).not.toHaveProperty('trend'); + expect(p.samples.length).toBeLessThanOrEqual(2); + } + expect(notes).toEqual( + expect.arrayContaining([ + 'patterns[].samples: kept the first 2 (12 elements removed from 4 items)', + 'patterns[].samples[]: 8 value(s) shortened to 10 chars (7,920 chars removed)', + 'patterns[].pattern: 4 value(s) shortened to 20 chars (11,920 chars removed)', + 'patterns[].trend: removed (4 items)', + ]), + ); + }); + + it('stops as soon as the whole response fits', () => { + const input = shape(); + const after1 = JSON.stringify({ + patterns: input.patterns.map(p => ({ + ...p, + samples: p.samples.slice(0, 1), + })), + }).length; + const { data, notes } = trimToolResponse(input, after1, { + reductions: [ + { path: 'patterns[].samples', keep: 1 }, + { path: 'patterns[].pattern', maxChars: 20 }, + ], + }); + expect(data.patterns[0].pattern).toHaveLength(3000); // second step not needed + expect(data.patterns[0].samples).toHaveLength(1); + expect(notes).toHaveLength(2); // size note + the one reduction + }); + + it('returns the input unchanged when it fits', () => { + const input = shape(); + const result = trimToolResponse(input, 1_000_000, { + reductions: [{ path: 'patterns[].trend', drop: true }], + }); + expect(result.isTrimmed).toBe(false); + expect(result.data).toBe(input); + }); + + it('does not mutate the input', () => { + const input = shape(); + const before = JSON.stringify(input); + trimToolResponse(input, 10, { + reductions: [{ path: 'patterns[].trend', drop: true }], + }); + expect(JSON.stringify(input)).toBe(before); + }); + + it('falls back to the generic trim when reductions are not enough', () => { + const { data, notes } = trimToolResponse(shape(), 2000, { + reductions: [{ path: 'patterns[].trend', drop: true }], + }); + expect(JSON.stringify(data).length).toBeLessThanOrEqual(2000); + expect(notes[1]).toBe('patterns[].trend: removed (4 items)'); + expect(notes.length).toBeGreaterThan(2); + }); + + it('keeps all 30 event patterns instead of replacing the long ones (topN honoured)', () => { + const stack = (n: number) => + `java.lang.NullPointerException: case ${n}\n` + + Array.from( + { length: 90 }, + (_, i) => + `\tat com.example.svc.Layer${i}.call(Layer${i}.java:${i + 10})`, + ).join('\n'); + const pattern = (rank: number, text: string) => ({ + rank, + pattern: text, + whereSnippet: `Body:"${text.slice(0, 200)}"`, + trend: Array.from({ length: 24 }, (_, t) => ({ ts: t, count: rank })), + samples: Array.from({ length: 5 }, () => text), + }); + const output = { + summary: { patternsReturned: 30 }, + patterns: [ + ...[0, 1, 2].map(i => pattern(i + 1, stack(i))), + ...Array.from({ length: 27 }, (_, i) => + pattern(i + 4, `Short INFO line ${i} took <*> ms`), + ), + ], + }; + const { data } = trimToolResponse(output, 50000, { + 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 }, + ], + }); + expect(data.patterns).toHaveLength(30); + expect(data.patterns.every(p => p.samples.length >= 1)).toBe(true); + expect(data.patterns[0].pattern).toMatch( + /^java\.lang\.NullPointerException/, + ); + expect(JSON.stringify(data)).not.toContain('__hdx_trimmed'); + expect(JSON.stringify(data).length).toBeLessThanOrEqual(50000); + }); + }); }); diff --git a/packages/api/src/utils/trimToolResponse.ts b/packages/api/src/utils/trimToolResponse.ts index 3ab9377abb..74c2e68d72 100644 --- a/packages/api/src/utils/trimToolResponse.ts +++ b/packages/api/src/utils/trimToolResponse.ts @@ -3,49 +3,207 @@ import logger from '@/utils/logger'; export interface TrimResult { data: T; isTrimmed: boolean; + /** What was trimmed and how much, for the tool result. Empty when nothing was trimmed. */ + notes: string[]; +} + +/** + * One step of an ordered reduction. `path` uses dot segments, with `[]` for + * "every element of this array": `patterns[].samples` is the samples array of + * every pattern, `patterns[].samples[]` every sample in it. + */ +export type Reduction = + | { path: string; keep: number } // keep the first `keep` elements of the array + | { path: string; maxChars: number } // shorten longer strings, with "…(+N chars)" + | { path: string; drop: true }; // remove the key + +export interface TrimOptions { + /** + * Applied in order, each to every match, before the generic fallback. The + * whole response is measured after each step, and trimming stops as soon as + * it fits. + */ + reductions?: Reduction[]; } /** * Trims large data structures to prevent "Request Entity Too Large" errors * when multiple tool calls accumulate data in the conversation history. * - * Returns `{ data, isTrimmed }` so callers can cheaply check whether the - * response was truncated without re-serializing. + * Returns `{ data, isTrimmed, notes }` so callers can cheaply check whether the + * response was truncated without re-serializing, and tell the caller what was cut. */ export function trimToolResponse( data: T, maxSize: number = 50000, + options: TrimOptions = {}, ): TrimResult { - const serialized = JSON.stringify(data); + const originalSize = JSON.stringify(data).length; // If data is within acceptable size, return as-is - if (serialized.length <= maxSize) { - return { data, isTrimmed: false }; + if (originalSize <= maxSize) { + return { data, isTrimmed: false, notes: [] }; } logger.warn( - `Tool response too large, trimming data. Original Size: ${serialized.length}, Max Size: ${maxSize}`, + `Tool response too large, trimming data. Original Size: ${originalSize}, Max Size: ${maxSize}`, ); - // Handle different data structures - if (Array.isArray(data)) { - const trimmed = - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - trimArray(data, maxSize) as T; - return { data: trimmed, isTrimmed: true }; + const notes: string[] = []; + let current: T = data; + const reductions = options.reductions ?? []; + if (reductions.length > 0) { + current = structuredClone(data); + for (const reduction of reductions) { + const note = applyReduction(current, reduction); + if (note) notes.push(note); + const size = JSON.stringify(current).length; + if (size <= maxSize) { + return { + data: current, + isTrimmed: true, + notes: [sizeNote(originalSize, size, maxSize), ...notes], + }; + } + } + } + + // Fallback: per-key budget split and array cuts. + const stats: FallbackStats = { arrays: [], strings: 0, charsRemoved: 0 }; + let trimmed: unknown = current; + if (Array.isArray(current)) { + trimmed = trimArray(current, maxSize, '', stats); + } else if (typeof current === 'object' && current !== null) { + trimmed = trimObject(current, maxSize, '', stats); + } else if (typeof current === 'string') { + trimmed = shortenToFit(current, maxSize, stats); + } else { + return { data: current, isTrimmed: notes.length > 0, notes }; } - if (typeof data === 'object' && data !== null) { - const trimmed = - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - trimObject(data, maxSize) as T; - return { data: trimmed, isTrimmed: true }; + for (const a of stats.arrays) { + notes.push( + `${a.path || 'result'}: cut from ${fmt(a.from)} to ${fmt(a.to)} items`, + ); } + if (stats.strings > 0) { + notes.push( + `${fmt(stats.strings)} long value(s) shortened (${fmt(stats.charsRemoved)} chars removed)`, + ); + } + const finalSize = JSON.stringify(trimmed).length; + return { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + data: trimmed as T, + isTrimmed: true, + notes: [sizeNote(originalSize, finalSize, maxSize), ...notes], + }; +} - return { data, isTrimmed: false }; +interface FallbackStats { + arrays: { path: string; from: number; to: number }[]; + strings: number; + charsRemoved: number; } -function trimArray(arr: any[], maxSize: number): any[] { +const fmt = (n: number) => n.toLocaleString('en-US'); + +const sizeNote = (from: number, to: number, max: number) => + `Response trimmed from ${fmt(from)} to ${fmt(to)} chars (limit ${fmt(max)}).`; + +/** `s` cut to `maxChars`, followed by "…(+N chars)". */ +export function shortenString(s: string, maxChars: number): string { + if (s.length <= maxChars) return s; + return `${s.slice(0, Math.max(0, maxChars))}…(+${fmt(s.length - Math.max(0, maxChars))} chars)`; +} + +// Shorten so that the JSON-encoded string fits `budget` (escaping can make it +// longer than the raw string, so a couple of passes may be needed). +function shortenToFit(s: string, budget: number, stats: FallbackStats): string { + let keep = Math.min(s.length, budget); + let out = shortenString(s, keep); + for (let i = 0; i < 4 && keep > 0; i++) { + const excess = JSON.stringify(out).length - budget; + if (excess <= 0) break; + keep = Math.max(0, keep - excess); + out = shortenString(s, keep); + } + if (out !== s) { + stats.strings++; + stats.charsRemoved += s.length - keep; + } + return out; +} + +// ─── Ordered reductions ────────────────────────────────────────────────────── + +// Calls `onTarget(container, key)` for every value the path points at. +function visit( + node: any, + segments: string[], + onTarget: (container: any, key: string | number) => void, +): void { + if (node == null || typeof node !== 'object' || segments.length === 0) return; + const [segment, ...rest] = segments; + const each = segment.endsWith('[]'); + const name = each ? segment.slice(0, -2) : segment; + + if (!each) { + if (!(name in node)) return; + if (rest.length === 0) onTarget(node, name); + else visit(node[name], rest, onTarget); // eslint-disable-line security/detect-object-injection + return; + } + + const arr = name ? node[name] : node; // eslint-disable-line security/detect-object-injection + if (!Array.isArray(arr)) return; + arr.forEach((item, i) => { + if (rest.length === 0) onTarget(arr, i); + else visit(item, rest, onTarget); + }); +} + +// Applies one reduction in place; returns a note when it changed something. +function applyReduction(root: any, reduction: Reduction): string | undefined { + const segments = reduction.path.split('.'); + let items = 0; + let amount = 0; + + visit(root, segments, (container, key) => { + const value = container[key]; // eslint-disable-line security/detect-object-injection + if ('drop' in reduction) { + if (Array.isArray(container)) return; + delete container[key]; // eslint-disable-line security/detect-object-injection + items++; + } else if ('keep' in reduction) { + if (Array.isArray(value) && value.length > reduction.keep) { + amount += value.length - reduction.keep; + container[key] = value.slice(0, reduction.keep); // eslint-disable-line security/detect-object-injection + items++; + } + } else if (typeof value === 'string' && value.length > reduction.maxChars) { + amount += value.length - reduction.maxChars; + container[key] = shortenString(value, reduction.maxChars); // eslint-disable-line security/detect-object-injection + items++; + } + }); + + if (items === 0) return undefined; + if ('drop' in reduction) + return `${reduction.path}: removed (${fmt(items)} items)`; + if ('keep' in reduction) + return `${reduction.path}: kept the first ${fmt(reduction.keep)} (${fmt(amount)} elements removed from ${fmt(items)} items)`; + return `${reduction.path}: ${fmt(items)} value(s) shortened to ${fmt(reduction.maxChars)} chars (${fmt(amount)} chars removed)`; +} + +// ─── Generic fallback ──────────────────────────────────────────────────────── + +function trimArray( + arr: any[], + maxSize: number, + path: string, + stats: FallbackStats, +): any[] { // Keep reducing array size until it fits let result = [...arr]; let resultSize = JSON.stringify(result).length; @@ -57,36 +215,56 @@ function trimArray(arr: any[], maxSize: number): any[] { resultSize = JSON.stringify(result).length; } - // If we're still over budget (e.g. a single item exceeds maxSize), truncate - // individual oversized items so the array itself stays within the limit. + if (result.length < arr.length) { + stats.arrays.push({ path, from: arr.length, to: result.length }); + logger.info(`Trimmed array from ${arr.length} to ${result.length} items`); + } + + // If we're still over budget (e.g. some items are large), trim the large + // items. Items within an equal share stay whole; the large ones split what is + // left, so the shortened items add up to the array's budget. if (resultSize > maxSize) { - result = result.map(item => { - const itemStr = JSON.stringify(item); - if (itemStr.length > maxSize) { - logger.info( - `Trimming oversized array item (${itemStr.length} bytes > ${maxSize} limit)`, - ); - if (typeof item === 'object' && item !== null) { - return trimObject(item, maxSize); - } - // Scalar that is itself too large — return a truncation marker - return { __hdx_trimmed: true, originalSize: itemStr.length }; + const sizes = result.map(item => JSON.stringify(item).length); + const share = Math.floor(maxSize / result.length); + const smallTotal = sizes.reduce( + (sum, s) => (s <= share ? sum + s : sum), + 0, + ); + const largeCount = sizes.filter(s => s > share).length; + const itemBudget = Math.max( + share, + Math.floor((maxSize - smallTotal - result.length) / largeCount), + ); + result = result.map((item, i) => { + if (sizes[i] <= itemBudget) return item; // eslint-disable-line security/detect-object-injection + logger.info( + `Trimming oversized array item (${sizes[i]} bytes > ${itemBudget} limit)`, // eslint-disable-line security/detect-object-injection + ); + if (typeof item === 'string') { + return shortenToFit(item, itemBudget, stats); } - return item; + if (typeof item === 'object' && item !== null) { + return Array.isArray(item) + ? trimArray(item, itemBudget, `${path}[]`, stats) + : trimObject(item, itemBudget, `${path}[]`, stats); + } + // Non-string scalar that is itself too large — return a truncation marker + return { __hdx_trimmed: true, originalSize: sizes[i] }; // eslint-disable-line security/detect-object-injection }); } - if (result.length < arr.length) { - logger.info(`Trimmed array from ${arr.length} to ${result.length} items`); - } - return result; } // Keys in trimObject come exclusively from Object.entries() on internal tool // response data — never from user-supplied HTTP input — so bracket-notation // writes are not an injection risk; see inline eslint-disable comments below. -function trimObject(obj: any, maxSize: number): any { +function trimObject( + obj: any, + maxSize: number, + path: string, + stats: FallbackStats, +): any { const entries = Object.entries(obj); if (entries.length === 0) return obj; @@ -100,6 +278,7 @@ function trimObject(obj: any, maxSize: number): any { for (const [key, value] of entries) { const valueStr = JSON.stringify(value); + const keyPath = path ? `${path}.${key}` : key; if (valueStr.length <= perKeyBudget) { result[key] = value; // eslint-disable-line security/detect-object-injection @@ -108,9 +287,11 @@ function trimObject(obj: any, maxSize: number): any { `Trimming oversized object value at key "${key}" (${valueStr.length} bytes > ${perKeyBudget} per-key budget)`, ); if (Array.isArray(value)) { - result[key] = trimArray(value, perKeyBudget); // eslint-disable-line security/detect-object-injection + result[key] = trimArray(value, perKeyBudget, keyPath, stats); // eslint-disable-line security/detect-object-injection } else if (typeof value === 'object' && value !== null) { - result[key] = trimObject(value, perKeyBudget); // eslint-disable-line security/detect-object-injection + result[key] = trimObject(value, perKeyBudget, keyPath, stats); // eslint-disable-line security/detect-object-injection + } else if (typeof value === 'string') { + result[key] = shortenToFit(value, perKeyBudget, stats); // eslint-disable-line security/detect-object-injection } else { result[key] = { __hdx_trimmed: true, originalSize: valueStr.length }; // eslint-disable-line security/detect-object-injection } From 9d5ba8ff8fead9fe43aa71bc65e6bb24faf8c9ad Mon Sep 17 00:00:00 2001 From: Baris Can Vural Date: Thu, 5 Nov 2026 10:45:28 +0300 Subject: [PATCH 2/7] fix(mcp): never cut whereSnippet, keep trimmed values shorter and trim notes accurate - event_patterns drops whereSnippet as the last reduction instead of letting the fallback shorten it (a cut snippet is invalid SQL); the note says so. - shortenString never makes a value longer and doesn't split a surrogate pair; removed-char counts include the suffix. - trimArray counts separators in the item budget and returns the array as-is when no single item is large. - Notes: array cuts grouped by path, size markers noted, reduction notes say 'cut to the first N chars'; skip re-measuring after a no-op step. - Note paths match the response (trimmed under 'result' in formatQueryResult and search); search denoise keeps the first pass's notes; clickstack_sql batch appends its row cut to the per-query notes. - Remove section banner comments. --- .../src/mcp/__tests__/queryTool.int.test.ts | 49 ++++++++++ .../src/mcp/__tests__/sqlToolBatch.test.ts | 10 +- packages/api/src/mcp/tools/query/helpers.ts | 5 +- .../src/mcp/tools/query/runEventPatterns.ts | 14 ++- packages/api/src/mcp/tools/query/search.ts | 25 +++-- packages/api/src/mcp/tools/query/sqlBatch.ts | 9 ++ .../utils/__tests__/trimToolResponse.test.ts | 92 ++++++++++++++++++- packages/api/src/utils/trimToolResponse.ts | 71 ++++++++++---- 8 files changed, 240 insertions(+), 35 deletions(-) diff --git a/packages/api/src/mcp/__tests__/queryTool.int.test.ts b/packages/api/src/mcp/__tests__/queryTool.int.test.ts index 7b7cec82a6..8e3992fa44 100644 --- a/packages/api/src/mcp/__tests__/queryTool.int.test.ts +++ b/packages/api/src/mcp/__tests__/queryTool.int.test.ts @@ -645,6 +645,55 @@ describe('MCP Query Tools', () => { // ─── clickstack_event_patterns ───────────────────────────────────────────────── describe('clickstack_event_patterns', () => { + describe('when the response is over the size limit', () => { + const now = new Date(); + + beforeEach(async () => { + // 30 unrelated patterns of ~60 tokens, 4 rows each: the samples and + // drill-down predicates push the response well past 50,000 chars. + const logs: Parameters[0] = []; + for (let p = 0; p < 30; p++) { + const words = Array.from({ length: 60 }, (_, k) => `f${p}x${k}`); + for (let r = 0; r < 4; r++) { + logs.push({ + Body: `Failure${p} ${words.join(' ')} id${p}_${r}`, + ServiceName: 'big-service', + SeverityText: 'ERROR', + Timestamp: new Date(now.getTime() - (p * 4 + r + 1) * 1000), + }); + } + } + await bulkInsertLogs(logs); + }); + + it('keeps all topN patterns and drops, never cuts, whereSnippet', async () => { + const result = await callTool(client, 'clickstack_event_patterns', { + sourceId: logSource._id.toString(), + topN: 30, + startTime: new Date(now.getTime() - 10 * 60 * 1000).toISOString(), + endTime: new Date(now.getTime() + 60 * 1000).toISOString(), + }); + + expect(result.isError).toBeFalsy(); + const text = getFirstText(result); + const output = JSON.parse(text); + expect(output.patterns).toHaveLength(30); + expect(text).not.toContain('__hdx_trimmed'); + expect(output.trimNotes).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^Response trimmed from /), + expect.stringMatching(/^patterns\[\]\.samples: kept the first/), + expect.stringMatching(/^patterns\[\]\.whereSnippet: removed/), + ]), + ); + expect(output.note).toContain('whereSnippet was removed'); + for (const p of output.patterns) { + expect(p).not.toHaveProperty('whereSnippet'); + expect(p.pattern).toMatch(/^Failure\d+ /); + } + }); + }); + it('should execute an event_patterns query on a log source', async () => { const result = await callTool(client, 'clickstack_event_patterns', { sourceId: logSource._id.toString(), diff --git a/packages/api/src/mcp/__tests__/sqlToolBatch.test.ts b/packages/api/src/mcp/__tests__/sqlToolBatch.test.ts index 0b3be1b1e7..3f332cd3b7 100644 --- a/packages/api/src/mcp/__tests__/sqlToolBatch.test.ts +++ b/packages/api/src/mcp/__tests__/sqlToolBatch.test.ts @@ -320,7 +320,10 @@ describe('clickstack_sql batch mode', () => { content: [ { type: 'text' as const, - text: JSON.stringify({ result: { data: Array(10).fill(bigRow) } }), + text: JSON.stringify({ + result: { data: Array(10).fill(bigRow) }, + trimNotes: ['data: cut from 500 to 10 items'], + }), }, ], }); @@ -338,6 +341,11 @@ describe('clickstack_sql batch mode', () => { expect(entry.result.data.length).toBeGreaterThan(0); expect(entry.result.data.length).toBeLessThan(10); expect(entry.note).toContain('batch response size budget'); + // The batch cut is appended to the per-query notes. + expect(entry.trimNotes).toEqual([ + 'data: cut from 500 to 10 items', + `Rows cut to ${entry.result.data.length} to fit the batch response size budget.`, + ]); } }); diff --git a/packages/api/src/mcp/tools/query/helpers.ts b/packages/api/src/mcp/tools/query/helpers.ts index b34eae0213..778555f868 100644 --- a/packages/api/src/mcp/tools/query/helpers.ts +++ b/packages/api/src/mcp/tools/query/helpers.ts @@ -355,11 +355,12 @@ function isEmptyResult(result: unknown): boolean { } function formatQueryResult(result: unknown, maxResponseChars?: number) { + // Trimmed under its response key, so note paths read `result.data…`. const { - data: trimmedResult, + data: { result: trimmedResult }, isTrimmed, notes: trimNotes, - } = trimToolResponse(result, maxResponseChars); + } = trimToolResponse({ result }, maxResponseChars); const empty = isEmptyResult(result); return { content: [ diff --git a/packages/api/src/mcp/tools/query/runEventPatterns.ts b/packages/api/src/mcp/tools/query/runEventPatterns.ts index 7be6112412..7aeaf71749 100644 --- a/packages/api/src/mcp/tools/query/runEventPatterns.ts +++ b/packages/api/src/mcp/tools/query/runEventPatterns.ts @@ -555,7 +555,9 @@ export async function runEventPatterns( }; // Shrink samples, then long text, before the generic trim cuts whole patterns, - // so topN patterns survive when they can. + // so topN patterns survive when they can. whereSnippet is dropped, never + // shortened: a cut snippet is invalid SQL. Dropping it is the last step, so + // the generic fallback (which shortens strings) never sees one. const { data: trimmedOutput, isTrimmed, @@ -567,13 +569,21 @@ export async function runEventPatterns( { path: 'patterns[].samples[]', maxChars: 2000 }, { path: 'patterns[].pattern', maxChars: 2000 }, { path: 'patterns[].trend', drop: true }, + { path: 'patterns[].whereSnippet', drop: true }, ], }); + const snippetsDropped = trimmedOutput.patterns.some( + p => !('whereSnippet' in p), + ); const finalOutput = isTrimmed ? { ...trimmedOutput, - note: 'Result was trimmed for context size. Narrow the time range, add filters, or reduce topN to reduce data.', + note: + 'Result was trimmed for context size. Narrow the time range, add filters, or reduce topN to reduce data.' + + (snippetsDropped + ? ' whereSnippet was removed from the patterns to fit, so the usage steps for it do not apply; narrow the time range or filters, or lower topN, to get it back.' + : ''), trimNotes, } : trimmedOutput; diff --git a/packages/api/src/mcp/tools/query/search.ts b/packages/api/src/mcp/tools/query/search.ts index efe568fa52..e00d17b116 100644 --- a/packages/api/src/mcp/tools/query/search.ts +++ b/packages/api/src/mcp/tools/query/search.ts @@ -114,7 +114,10 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { const resultText = result.content?.[0]?.text; if (!resultText) return result; - let parsed: { result?: { data?: Record[] } }; + let parsed: { + result?: { data?: Record[] }; + trimNotes?: string[]; + }; try { parsed = JSON.parse(resultText); } catch { @@ -122,6 +125,8 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { } const resultData = parsed.result; + // runConfigTile may already have trimmed the rows; keep its notes. + const firstPassNotes = parsed.trimNotes ?? []; const rows = (resultData as Record | undefined)?.data as | Record[] | undefined; @@ -151,10 +156,11 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { ); const { - data: trimmedResult, + data: { result: trimmedResult }, isTrimmed, - notes: trimNotes, - } = trimToolResponse(resultData); + notes, + } = trimToolResponse({ result: resultData }); + const trimNotes = [...firstPassNotes, ...notes]; return { content: [ { @@ -168,7 +174,7 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { filteredRowCount: rows.length, skipped: 'denoise_failed', }, - ...(isTrimmed + ...(isTrimmed || trimNotes.length > 0 ? { note: 'Result was trimmed for context size. Narrow the time range or add filters to reduce data.', trimNotes, @@ -191,10 +197,11 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { data: denoised.rows, }; const { - data: trimmedResult, + data: { result: trimmedResult }, isTrimmed, - notes: trimNotes, - } = trimToolResponse(denoisedResult); + notes, + } = trimToolResponse({ result: denoisedResult }); + const trimNotes = [...firstPassNotes, ...notes]; return { content: [ @@ -211,7 +218,7 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { filteredRowCount: denoised.rows.length, ...(denoised.skipped ? { skipped: denoised.skipped } : {}), }, - ...(isTrimmed + ...(isTrimmed || trimNotes.length > 0 ? { note: 'Result was trimmed for context size. Narrow the time range or add filters to reduce data.', trimNotes, diff --git a/packages/api/src/mcp/tools/query/sqlBatch.ts b/packages/api/src/mcp/tools/query/sqlBatch.ts index 83c449d5fe..c1f429a93f 100644 --- a/packages/api/src/mcp/tools/query/sqlBatch.ts +++ b/packages/api/src/mcp/tools/query/sqlBatch.ts @@ -34,6 +34,8 @@ const batchQueryCounter = getCounter('hyperdx.mcp.sql.batch.queries', { description: 'Queries run by clickstack_sql in batch mode, by outcome.', }); +const BATCH_CUT_PREFIX = 'Rows cut to '; + const BATCH_TRIM_NOTE = 'Rows were dropped to fit the batch response size budget. Narrow the time ' + 'range, add filters, or run fewer queries per call.'; @@ -124,6 +126,13 @@ function setRows(entry: SqlBatchEntry, rows: unknown[]) { ? { ...result, data: rows } : rows; entry.note = BATCH_TRIM_NOTE; + // Appended to the per-query notes, which are still true; a later halving + // replaces this line rather than adding another. + const earlier = Array.isArray(entry.trimNotes) ? entry.trimNotes : []; + entry.trimNotes = [ + ...earlier.filter(n => !String(n).startsWith(BATCH_CUT_PREFIX)), + `${BATCH_CUT_PREFIX}${rows.length} to fit the batch response size budget.`, + ]; } /** diff --git a/packages/api/src/utils/__tests__/trimToolResponse.test.ts b/packages/api/src/utils/__tests__/trimToolResponse.test.ts index 57ae4bad63..ed3e7669c0 100644 --- a/packages/api/src/utils/__tests__/trimToolResponse.test.ts +++ b/packages/api/src/utils/__tests__/trimToolResponse.test.ts @@ -140,7 +140,7 @@ describe('trimToolResponse', () => { describe('shortening instead of replacing', () => { it('shortens an oversized string with a "…(+N chars)" suffix', () => { - expect(shortenString('a'.repeat(10), 4)).toBe('aaaa…(+6 chars)'); + expect(shortenString('a'.repeat(100), 4)).toBe('aaaa…(+96 chars)'); expect(shortenString('a'.repeat(8000), 1000)).toBe( `${'a'.repeat(1000)}…(+7,000 chars)`, ); @@ -171,6 +171,83 @@ describe('trimToolResponse', () => { ); }); + it('never makes a value longer, and keeps surrogate pairs whole', () => { + // Cutting 5 chars would add a 13-char suffix. + const s = 'x'.repeat(105); + expect(shortenString(s, 100)).toBe(s); + const emoji = `${'a'.repeat(9)}😀${'b'.repeat(100)}`; + expect(shortenString(emoji, 10)).toBe(`${'a'.repeat(9)}…(+102 chars)`); + }); + + it('counts the suffix when reporting removed chars', () => { + const { notes } = trimToolResponse({ a: 'q'.repeat(3000) }, 50_000, { + reductions: [{ path: 'a', maxChars: 1000 }], + }); + expect(notes).toHaveLength(0); // fits as-is, nothing applied + const r = trimToolResponse({ a: 'q'.repeat(3000) }, 2000, { + reductions: [{ path: 'a', maxChars: 1000 }], + }); + const out = r.data.a as string; + expect(r.notes[1]).toBe( + `a: 1 value(s) cut to the first 1,000 chars (${(3000 - out.length).toLocaleString('en-US')} chars removed)`, + ); + }); + + it.each([ + ['quotes', '"'], + ['control chars', '\u0001'], + ['non-ASCII', 'ä😀'], + ['the … marker', '…'], + ])( + 'stays within the budget with %s that expand in JSON', + (_label, unit) => { + const big = unit.repeat(4000); + for (const max of [500, 2000, 8000]) { + const asObject = trimToolResponse({ a: big, b: big, c: 'ok' }, max); + expect(JSON.stringify(asObject.data).length).toBeLessThanOrEqual(max); + const asArray = trimToolResponse(Array(12).fill(big), max); + expect(JSON.stringify(asArray.data).length).toBeLessThanOrEqual(max); + } + }, + ); + + it('returns an array unchanged when only separators push it over', () => { + // 10 items of 10 JSON chars: share 11, so none is large; 111 > 110. + const arr = Array(10).fill('12345678'); + const { data } = trimToolResponse(arr, 110); + expect(data).toEqual(arr); + }); + + it('notes a nested array cut once per path', () => { + const data = { + patterns: Array.from({ length: 10 }, () => ({ + samples: Array.from({ length: 50 }, () => 's'.repeat(100)), + })), + }; + const { notes } = trimToolResponse(data, 8000); + const sampleNotes = notes.filter(n => n.startsWith('patterns[].samples')); + expect(sampleNotes).toHaveLength(1); + expect(sampleNotes[0]).toMatch( + /^patterns\[\]\.samples: cut in 10 items, from 500 to \d+ elements in total$/, + ); + }); + + it('notes values replaced by a size marker', () => { + // A number can't be shortened: over its budget it becomes a marker. + const { data, notes } = trimToolResponse( + { n: 1e300, s: 'p'.repeat(500) }, + 8, + ); + expect(data.n).toEqual({ __hdx_trimmed: true, originalSize: 6 }); + expect(notes).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /^1 value\(s\) too large to shorten replaced by/, + ), + ]), + ); + }); + it('returns no notes when nothing is trimmed', () => { expect(trimToolResponse({ a: 1 }).notes).toEqual([]); }); @@ -203,8 +280,8 @@ describe('trimToolResponse', () => { expect(notes).toEqual( expect.arrayContaining([ 'patterns[].samples: kept the first 2 (12 elements removed from 4 items)', - 'patterns[].samples[]: 8 value(s) shortened to 10 chars (7,920 chars removed)', - 'patterns[].pattern: 4 value(s) shortened to 20 chars (11,920 chars removed)', + 'patterns[].samples[]: 8 value(s) cut to the first 10 chars (7,816 chars removed)', + 'patterns[].pattern: 4 value(s) cut to the first 20 chars (11,860 chars removed)', 'patterns[].trend: removed (4 items)', ]), ); @@ -229,6 +306,15 @@ describe('trimToolResponse', () => { expect(notes).toHaveLength(2); // size note + the one reduction }); + it('ignores drop on array elements (only object keys are removed)', () => { + const data = { a: Array(50).fill('w'.repeat(100)) }; + const { data: out, notes } = trimToolResponse(data, 3000, { + reductions: [{ path: 'a[]', drop: true }], + }); + expect(notes.some(n => n.startsWith('a[]'))).toBe(false); + expect(Array.isArray(out.a)).toBe(true); + }); + it('returns the input unchanged when it fits', () => { const input = shape(); const result = trimToolResponse(input, 1_000_000, { diff --git a/packages/api/src/utils/trimToolResponse.ts b/packages/api/src/utils/trimToolResponse.ts index 74c2e68d72..5e81d2c474 100644 --- a/packages/api/src/utils/trimToolResponse.ts +++ b/packages/api/src/utils/trimToolResponse.ts @@ -56,7 +56,8 @@ export function trimToolResponse( current = structuredClone(data); for (const reduction of reductions) { const note = applyReduction(current, reduction); - if (note) notes.push(note); + if (!note) continue; // nothing changed, so the size didn't either + notes.push(note); const size = JSON.stringify(current).length; if (size <= maxSize) { return { @@ -69,7 +70,12 @@ export function trimToolResponse( } // Fallback: per-key budget split and array cuts. - const stats: FallbackStats = { arrays: [], strings: 0, charsRemoved: 0 }; + const stats: FallbackStats = { + arrays: [], + strings: 0, + charsRemoved: 0, + markers: 0, + }; let trimmed: unknown = current; if (Array.isArray(current)) { trimmed = trimArray(current, maxSize, '', stats); @@ -81,9 +87,21 @@ export function trimToolResponse( return { data: current, isTrimmed: notes.length > 0, notes }; } + // One line per path: a nested array is cut once per parent item. + const cuts = new Map(); for (const a of stats.arrays) { + const c = cuts.get(a.path) ?? { items: 0, from: 0, to: 0 }; + cuts.set(a.path, { + items: c.items + 1, + from: c.from + a.from, + to: c.to + a.to, + }); + } + for (const [path, c] of cuts) { notes.push( - `${a.path || 'result'}: cut from ${fmt(a.from)} to ${fmt(a.to)} items`, + c.items === 1 + ? `${path || 'result'}: cut from ${fmt(c.from)} to ${fmt(c.to)} items` + : `${path}: cut in ${fmt(c.items)} items, from ${fmt(c.from)} to ${fmt(c.to)} elements in total`, ); } if (stats.strings > 0) { @@ -91,6 +109,11 @@ export function trimToolResponse( `${fmt(stats.strings)} long value(s) shortened (${fmt(stats.charsRemoved)} chars removed)`, ); } + if (stats.markers > 0) { + notes.push( + `${fmt(stats.markers)} value(s) too large to shorten replaced by {"__hdx_trimmed":true,"originalSize":N}`, + ); + } const finalSize = JSON.stringify(trimmed).length; return { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion @@ -104,6 +127,7 @@ interface FallbackStats { arrays: { path: string; from: number; to: number }[]; strings: number; charsRemoved: number; + markers: number; } const fmt = (n: number) => n.toLocaleString('en-US'); @@ -111,10 +135,18 @@ const fmt = (n: number) => n.toLocaleString('en-US'); const sizeNote = (from: number, to: number, max: number) => `Response trimmed from ${fmt(from)} to ${fmt(to)} chars (limit ${fmt(max)}).`; -/** `s` cut to `maxChars`, followed by "…(+N chars)". */ +/** + * `s` cut to `maxChars`, followed by "…(+N chars)". Returned unchanged when the + * suffix would make it no shorter. Unlike `truncateString` (utils/common), the + * suffix says how much was cut, which the agent needs to judge the value. + */ export function shortenString(s: string, maxChars: number): string { if (s.length <= maxChars) return s; - return `${s.slice(0, Math.max(0, maxChars))}…(+${fmt(s.length - Math.max(0, maxChars))} chars)`; + let keep = Math.max(0, maxChars); + // Don't split a surrogate pair (JSON would carry a lone \udXXX escape). + if (/[\uD800-\uDBFF]/.test(s.charAt(keep - 1))) keep--; + const out = `${s.slice(0, keep)}…(+${fmt(s.length - keep)} chars)`; + return out.length < s.length ? out : s; } // Shorten so that the JSON-encoded string fits `budget` (escaping can make it @@ -130,13 +162,11 @@ function shortenToFit(s: string, budget: number, stats: FallbackStats): string { } if (out !== s) { stats.strings++; - stats.charsRemoved += s.length - keep; + stats.charsRemoved += s.length - out.length; } return out; } -// ─── Ordered reductions ────────────────────────────────────────────────────── - // Calls `onTarget(container, key)` for every value the path points at. function visit( node: any, @@ -181,10 +211,13 @@ function applyReduction(root: any, reduction: Reduction): string | undefined { container[key] = value.slice(0, reduction.keep); // eslint-disable-line security/detect-object-injection items++; } - } else if (typeof value === 'string' && value.length > reduction.maxChars) { - amount += value.length - reduction.maxChars; - container[key] = shortenString(value, reduction.maxChars); // eslint-disable-line security/detect-object-injection - items++; + } else if (typeof value === 'string') { + const out = shortenString(value, reduction.maxChars); + if (out !== value) { + amount += value.length - out.length; + container[key] = out; // eslint-disable-line security/detect-object-injection + items++; + } } }); @@ -193,11 +226,9 @@ function applyReduction(root: any, reduction: Reduction): string | undefined { return `${reduction.path}: removed (${fmt(items)} items)`; if ('keep' in reduction) return `${reduction.path}: kept the first ${fmt(reduction.keep)} (${fmt(amount)} elements removed from ${fmt(items)} items)`; - return `${reduction.path}: ${fmt(items)} value(s) shortened to ${fmt(reduction.maxChars)} chars (${fmt(amount)} chars removed)`; + return `${reduction.path}: ${fmt(items)} value(s) cut to the first ${fmt(reduction.maxChars)} chars (${fmt(amount)} chars removed)`; } -// ─── Generic fallback ──────────────────────────────────────────────────────── - function trimArray( arr: any[], maxSize: number, @@ -231,9 +262,11 @@ function trimArray( 0, ); const largeCount = sizes.filter(s => s > share).length; - const itemBudget = Math.max( - share, - Math.floor((maxSize - smallTotal - result.length) / largeCount), + // Over budget only through separators: no single item to shorten. + if (largeCount === 0) return result; + // n-1 commas and 2 brackets + const itemBudget = Math.floor( + (maxSize - smallTotal - (result.length + 1)) / largeCount, ); result = result.map((item, i) => { if (sizes[i] <= itemBudget) return item; // eslint-disable-line security/detect-object-injection @@ -249,6 +282,7 @@ function trimArray( : trimObject(item, itemBudget, `${path}[]`, stats); } // Non-string scalar that is itself too large — return a truncation marker + stats.markers++; return { __hdx_trimmed: true, originalSize: sizes[i] }; // eslint-disable-line security/detect-object-injection }); } @@ -293,6 +327,7 @@ function trimObject( } else if (typeof value === 'string') { result[key] = shortenToFit(value, perKeyBudget, stats); // eslint-disable-line security/detect-object-injection } else { + stats.markers++; result[key] = { __hdx_trimmed: true, originalSize: valueStr.length }; // eslint-disable-line security/detect-object-injection } trimmed = true; From d5d690b98145d3f72b4a0dc7a6505658a8a65a74 Mon Sep 17 00:00:00 2001 From: Baris Can Vural Date: Thu, 5 Nov 2026 11:10:54 +0300 Subject: [PATCH 3/7] fix(mcp): keep trimmed objects within the budget, drop a redundant check trimObject split the whole budget across values, but braces, quoted keys, colons, commas and the __hdx_trimmed flag also count, so a trimmed object could end over maxSize. Subtract them before splitting. search.ts: trimNotes is never empty when isTrimmed is true, so the extra check is redundant. --- packages/api/src/mcp/tools/query/search.ts | 6 ++-- .../utils/__tests__/trimToolResponse.test.ts | 28 +++++++++++++++++++ packages/api/src/utils/trimToolResponse.ts | 15 ++++++++-- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/packages/api/src/mcp/tools/query/search.ts b/packages/api/src/mcp/tools/query/search.ts index e00d17b116..e6de81719c 100644 --- a/packages/api/src/mcp/tools/query/search.ts +++ b/packages/api/src/mcp/tools/query/search.ts @@ -157,7 +157,6 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { const { data: { result: trimmedResult }, - isTrimmed, notes, } = trimToolResponse({ result: resultData }); const trimNotes = [...firstPassNotes, ...notes]; @@ -174,7 +173,7 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { filteredRowCount: rows.length, skipped: 'denoise_failed', }, - ...(isTrimmed || trimNotes.length > 0 + ...(trimNotes.length > 0 ? { note: 'Result was trimmed for context size. Narrow the time range or add filters to reduce data.', trimNotes, @@ -198,7 +197,6 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { }; const { data: { result: trimmedResult }, - isTrimmed, notes, } = trimToolResponse({ result: denoisedResult }); const trimNotes = [...firstPassNotes, ...notes]; @@ -218,7 +216,7 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { filteredRowCount: denoised.rows.length, ...(denoised.skipped ? { skipped: denoised.skipped } : {}), }, - ...(isTrimmed || trimNotes.length > 0 + ...(trimNotes.length > 0 ? { note: 'Result was trimmed for context size. Narrow the time range or add filters to reduce data.', trimNotes, diff --git a/packages/api/src/utils/__tests__/trimToolResponse.test.ts b/packages/api/src/utils/__tests__/trimToolResponse.test.ts index ed3e7669c0..d246c3611d 100644 --- a/packages/api/src/utils/__tests__/trimToolResponse.test.ts +++ b/packages/api/src/utils/__tests__/trimToolResponse.test.ts @@ -211,6 +211,34 @@ describe('trimToolResponse', () => { }, ); + it('counts key names, separators and the trim flag in an object budget', () => { + const longKeys = { + first_long_column_name_aaaaaaaaaa: 'x'.repeat(5000), + second_long_column_name_bbbbbbbbb: 'y'.repeat(5000), + }; + const wide = Object.fromEntries( + Array.from({ length: 40 }, (_, i) => [ + `column_with_a_long_name_${i}`, + 'v'.repeat(300), + ]), + ); + // Narrow enough that the 10 rows trimArray always keeps can fit. + const rows = Array.from({ length: 30 }, () => + Object.fromEntries( + Array.from({ length: 4 }, (_, i) => [ + `column_with_a_long_name_${i}`, + 'v'.repeat(300), + ]), + ), + ); + for (const max of [2000, 3000, 5000, 8000]) { + for (const data of [longKeys, wide, rows]) { + const { data: out } = trimToolResponse(data, max); + expect(JSON.stringify(out).length).toBeLessThanOrEqual(max); + } + } + }); + it('returns an array unchanged when only separators push it over', () => { // 10 items of 10 JSON chars: share 11, so none is large; 111 > 110. const arr = Array(10).fill('12345678'); diff --git a/packages/api/src/utils/trimToolResponse.ts b/packages/api/src/utils/trimToolResponse.ts index 5e81d2c474..f6b12d60ac 100644 --- a/packages/api/src/utils/trimToolResponse.ts +++ b/packages/api/src/utils/trimToolResponse.ts @@ -290,6 +290,8 @@ function trimArray( return result; } +const TRIMMED_FLAG_LENGTH = '"__hdx_trimmed":true'.length; + // Keys in trimObject come exclusively from Object.entries() on internal tool // response data — never from user-supplied HTTP input — so bracket-notation // writes are not an injection risk; see inline eslint-disable comments below. @@ -306,8 +308,17 @@ function trimObject( // Give each key an equal share of the budget so that no single large value // crowds out the rest (e.g. a large array at key[0] eating all the budget - // before key[1] gets a chance to appear). - const perKeyBudget = Math.floor(maxSize / entries.length); + // before key[1] gets a chance to appear). The shares exclude what the + // values don't control: braces, quoted keys, colons, commas and the + // `"__hdx_trimmed":true` flag added below. + const overhead = entries.reduce( + (sum, [key]) => sum + JSON.stringify(key).length + 2, + 2 + TRIMMED_FLAG_LENGTH, + ); + const perKeyBudget = Math.max( + 0, + Math.floor((maxSize - overhead) / entries.length), + ); let trimmed = false; for (const [key, value] of entries) { From 022a7873b1ebe0497e95f06a370d36402bc4e1cc Mon Sep 17 00:00:00 2001 From: Baris Can Vural Date: Fri, 6 Nov 2026 08:56:33 +0300 Subject: [PATCH 4/7] fix(mcp): keep scalars whole, give large values the unused budget, keep trim notes true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - trimObject: values within an equal share stay whole and the large ones split the rest, as trimArray does for items, so one large value (such as event_patterns' patterns) gets the room the small ones don't use. - A number, boolean or null is never replaced by a size marker, which is always longer. A string is shortened to the longest cut that fits, and only when that saves at least twice the "…(+N chars)" suffix. - clickstack_sql batch: halving a result's rows replaces its per-query notes, whose sizes and counts no longer hold. search with denoise drops the first trim's size line. - The event_patterns and emerging_signals reduction lists are exported and the tests use them. --- .../src/mcp/__tests__/queryTool.int.test.ts | 43 ++++++ .../src/mcp/__tests__/sqlToolBatch.test.ts | 9 +- .../src/mcp/tools/query/emergingSignals.ts | 12 +- .../src/mcp/tools/query/runEventPatterns.ts | 28 ++-- packages/api/src/mcp/tools/query/search.ts | 9 +- packages/api/src/mcp/tools/query/sqlBatch.ts | 10 +- .../utils/__tests__/trimToolResponse.test.ts | 128 +++++++++++++++--- packages/api/src/utils/trimToolResponse.ts | 94 ++++++------- 8 files changed, 231 insertions(+), 102 deletions(-) diff --git a/packages/api/src/mcp/__tests__/queryTool.int.test.ts b/packages/api/src/mcp/__tests__/queryTool.int.test.ts index 8e3992fa44..82836cf107 100644 --- a/packages/api/src/mcp/__tests__/queryTool.int.test.ts +++ b/packages/api/src/mcp/__tests__/queryTool.int.test.ts @@ -640,6 +640,49 @@ describe('MCP Query Tools', () => { expect(output).not.toHaveProperty('denoised'); }); }); + + describe('denoise on a trimmed result', () => { + const now = new Date(); + + beforeEach(async () => { + const pad = ' payload'.repeat(150); + await bulkInsertLogs( + Array.from({ length: 120 }, (_, i) => ({ + Body: + i < 110 + ? `Health check OK from 10.0.0.${i}${pad}` + : `Rare event ${i} in subsystem${pad}`, + ServiceName: 'loadbalancer', + SeverityText: 'INFO', + Timestamp: new Date(now.getTime() - (i + 1) * 1000), + })), + ); + }); + + it("keeps the first trim's row cut but not its stale size line", async () => { + const result = await callTool(client, 'clickstack_search', { + sourceId: logSource._id.toString(), + denoise: true, + maxResults: 200, + startTime: new Date(now.getTime() - 10 * 60 * 1000).toISOString(), + endTime: new Date(now.getTime() + 60 * 1000).toISOString(), + }); + + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output).toHaveProperty('denoised'); + expect(output.note).toContain('trimmed'); + const sizeLines = output.trimNotes.filter((n: string) => + n.startsWith('Response trimmed from '), + ); + expect(sizeLines).toHaveLength(0); // denoise didn't need a second trim + expect(output.trimNotes).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^result\.data: cut from 120 to \d+ items$/), + ]), + ); + }); + }); }); // ─── clickstack_event_patterns ───────────────────────────────────────────────── diff --git a/packages/api/src/mcp/__tests__/sqlToolBatch.test.ts b/packages/api/src/mcp/__tests__/sqlToolBatch.test.ts index 3f332cd3b7..a70f9a0870 100644 --- a/packages/api/src/mcp/__tests__/sqlToolBatch.test.ts +++ b/packages/api/src/mcp/__tests__/sqlToolBatch.test.ts @@ -322,7 +322,11 @@ describe('clickstack_sql batch mode', () => { type: 'text' as const, text: JSON.stringify({ result: { data: Array(10).fill(bigRow) }, - trimNotes: ['data: cut from 500 to 10 items'], + trimNotes: [ + 'Response trimmed from 90,000 to 50,000 chars (limit 50,000).', + 'result.data: cut from 500 to 10 items', + '10 long value(s) shortened (4,000 chars removed)', + ], }), }, ], @@ -341,9 +345,8 @@ describe('clickstack_sql batch mode', () => { expect(entry.result.data.length).toBeGreaterThan(0); expect(entry.result.data.length).toBeLessThan(10); expect(entry.note).toContain('batch response size budget'); - // The batch cut is appended to the per-query notes. + // The batch cut replaces the per-query notes it made stale. expect(entry.trimNotes).toEqual([ - 'data: cut from 500 to 10 items', `Rows cut to ${entry.result.data.length} to fit the batch response size budget.`, ]); } diff --git a/packages/api/src/mcp/tools/query/emergingSignals.ts b/packages/api/src/mcp/tools/query/emergingSignals.ts index 1fa09817ee..be00d96371 100644 --- a/packages/api/src/mcp/tools/query/emergingSignals.ts +++ b/packages/api/src/mcp/tools/query/emergingSignals.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { READ_ONLY_TOOL } from '@/mcp/tools/annotations'; import type { ToolRegistrar } from '@/mcp/tools/types'; import { mcpUserError } from '@/mcp/utils/errors'; -import { trimToolResponse } from '@/utils/trimToolResponse'; +import { type Reduction, trimToolResponse } from '@/utils/trimToolResponse'; import { parseTimeRange } from './helpers'; import { mineWindowPatterns, normalizeTemplate } from './runEventPatterns'; @@ -130,6 +130,11 @@ const emergingSignalsSchema = z.object({ // ─── Tool registration ─────────────────────────────────────────────────────── +export const EMERGING_SIGNALS_REDUCTIONS: Reduction[] = [ + { path: 'emerging[].pattern', maxChars: 2000 }, + { path: 'disappeared[].pattern', maxChars: 2000 }, +]; + export function registerEmergingSignals({ context, registerTool, @@ -347,10 +352,7 @@ export function registerEmergingSignals({ isTrimmed, notes: trimNotes, } = trimToolResponse(output, undefined, { - reductions: [ - { path: 'emerging[].pattern', maxChars: 2000 }, - { path: 'disappeared[].pattern', maxChars: 2000 }, - ], + reductions: EMERGING_SIGNALS_REDUCTIONS, }); return { content: [ diff --git a/packages/api/src/mcp/tools/query/runEventPatterns.ts b/packages/api/src/mcp/tools/query/runEventPatterns.ts index 7aeaf71749..f92e423956 100644 --- a/packages/api/src/mcp/tools/query/runEventPatterns.ts +++ b/packages/api/src/mcp/tools/query/runEventPatterns.ts @@ -25,7 +25,7 @@ import { import { MCP_QUERY_MAX_EXECUTION_SEC } from '@/mcp/utils/timeout'; import { getCounter } from '@/utils/instrumentation'; import logger from '@/utils/logger'; -import { trimToolResponse } from '@/utils/trimToolResponse'; +import { type Reduction, trimToolResponse } from '@/utils/trimToolResponse'; import { clickHouseErrorResult, @@ -362,6 +362,19 @@ export function normalizeTemplate(pattern: string): string { // ─── Event pattern mining ──────────────────────────────────────────────────── +// Shrink samples, then long text, before the generic trim cuts whole patterns, +// so topN patterns survive when they can. whereSnippet is dropped, never +// shortened: a cut snippet is invalid SQL. Dropping it is the last step, so +// the generic fallback (which shortens strings) never sees one. +export const EVENT_PATTERNS_REDUCTIONS: Reduction[] = [ + { 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 }, + { path: 'patterns[].whereSnippet', drop: true }, +]; + export async function runEventPatterns( teamId: string, sourceId: string, @@ -554,23 +567,12 @@ export async function runEventPatterns( 'add them again. Skip patterns that have drilldownUnavailable.', }; - // Shrink samples, then long text, before the generic trim cuts whole patterns, - // so topN patterns survive when they can. whereSnippet is dropped, never - // shortened: a cut snippet is invalid SQL. Dropping it is the last step, so - // the generic fallback (which shortens strings) never sees one. const { data: trimmedOutput, isTrimmed, notes: trimNotes, } = 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 }, - { path: 'patterns[].whereSnippet', drop: true }, - ], + reductions: EVENT_PATTERNS_REDUCTIONS, }); const snippetsDropped = trimmedOutput.patterns.some( diff --git a/packages/api/src/mcp/tools/query/search.ts b/packages/api/src/mcp/tools/query/search.ts index e6de81719c..8b77927fe1 100644 --- a/packages/api/src/mcp/tools/query/search.ts +++ b/packages/api/src/mcp/tools/query/search.ts @@ -4,7 +4,7 @@ import { READ_ONLY_TOOL } from '@/mcp/tools/annotations'; import type { ToolRegistrar } from '@/mcp/tools/types'; import { mcpUserError } from '@/mcp/utils/errors'; import logger from '@/utils/logger'; -import { trimToolResponse } from '@/utils/trimToolResponse'; +import { SIZE_NOTE_PREFIX, trimToolResponse } from '@/utils/trimToolResponse'; import { PREFER_BUILDER_OVER_SQL_NUDGE } from './builderCatalog'; import { denoiseSearchResults } from './denoise'; @@ -125,8 +125,11 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { } const resultData = parsed.result; - // runConfigTile may already have trimmed the rows; keep its notes. - const firstPassNotes = parsed.trimNotes ?? []; + // runConfigTile may already have trimmed the rows; keep its notes, except + // the size line, which denoise makes stale. + const firstPassNotes = (parsed.trimNotes ?? []).filter( + n => !n.startsWith(SIZE_NOTE_PREFIX), + ); const rows = (resultData as Record | undefined)?.data as | Record[] | undefined; diff --git a/packages/api/src/mcp/tools/query/sqlBatch.ts b/packages/api/src/mcp/tools/query/sqlBatch.ts index c1f429a93f..9c68c6966b 100644 --- a/packages/api/src/mcp/tools/query/sqlBatch.ts +++ b/packages/api/src/mcp/tools/query/sqlBatch.ts @@ -34,8 +34,6 @@ const batchQueryCounter = getCounter('hyperdx.mcp.sql.batch.queries', { description: 'Queries run by clickstack_sql in batch mode, by outcome.', }); -const BATCH_CUT_PREFIX = 'Rows cut to '; - const BATCH_TRIM_NOTE = 'Rows were dropped to fit the batch response size budget. Narrow the time ' + 'range, add filters, or run fewer queries per call.'; @@ -126,12 +124,10 @@ function setRows(entry: SqlBatchEntry, rows: unknown[]) { ? { ...result, data: rows } : rows; entry.note = BATCH_TRIM_NOTE; - // Appended to the per-query notes, which are still true; a later halving - // replaces this line rather than adding another. - const earlier = Array.isArray(entry.trimNotes) ? entry.trimNotes : []; + // Replaces the per-query notes: their sizes and counts no longer match the + // rows left. Shortened values still carry their own "…(+N chars)". entry.trimNotes = [ - ...earlier.filter(n => !String(n).startsWith(BATCH_CUT_PREFIX)), - `${BATCH_CUT_PREFIX}${rows.length} to fit the batch response size budget.`, + `Rows cut to ${rows.length} to fit the batch response size budget.`, ]; } diff --git a/packages/api/src/utils/__tests__/trimToolResponse.test.ts b/packages/api/src/utils/__tests__/trimToolResponse.test.ts index d246c3611d..888f42181b 100644 --- a/packages/api/src/utils/__tests__/trimToolResponse.test.ts +++ b/packages/api/src/utils/__tests__/trimToolResponse.test.ts @@ -1,3 +1,5 @@ +import { EMERGING_SIGNALS_REDUCTIONS } from '@/mcp/tools/query/emergingSignals'; +import { EVENT_PATTERNS_REDUCTIONS } from '@/mcp/tools/query/runEventPatterns'; import { shortenString, trimToolResponse } from '@/utils/trimToolResponse'; describe('trimToolResponse', () => { @@ -175,6 +177,9 @@ describe('trimToolResponse', () => { // Cutting 5 chars would add a 13-char suffix. const s = 'x'.repeat(105); expect(shortenString(s, 100)).toBe(s); + // Saving 14 chars isn't worth losing a 24-char value. + const ts = '2026-01-01T00:00:00.000Z'; + expect(shortenString(ts, 0)).toBe(ts); const emoji = `${'a'.repeat(9)}😀${'b'.repeat(100)}`; expect(shortenString(emoji, 10)).toBe(`${'a'.repeat(9)}…(+102 chars)`); }); @@ -260,20 +265,54 @@ describe('trimToolResponse', () => { ); }); - it('notes values replaced by a size marker', () => { - // A number can't be shortened: over its budget it becomes a marker. - const { data, notes } = trimToolResponse( - { n: 1e300, s: 'p'.repeat(500) }, - 8, - ); - expect(data.n).toEqual({ __hdx_trimmed: true, originalSize: 6 }); - expect(notes).toEqual( - expect.arrayContaining([ - expect.stringMatching( - /^1 value\(s\) too large to shorten replaced by/, - ), - ]), + it('keeps numbers whole and never makes the output larger than main did', () => { + const one = trimToolResponse( + { ts: '2026-01-01T00:00:00.000Z', count: 1 }, + 20, + ).data; + expect(one).toEqual({ ts: '2026-01-01T00:00:00.000Z', count: 1 }); + const rows = Array.from({ length: 12 }, () => ({ + a: 1, + b: 2, + c: 3, + d: 4, + e: 5, + f: 6, + })); + const { data } = trimToolResponse(rows, 100); + expect(JSON.stringify(data)).not.toContain('__hdx_trimmed'); + expect(JSON.stringify(data).length).toBeLessThanOrEqual(381); // main + }); + + it('fits a string that is just over its budget', () => { + const { data } = trimToolResponse( + ['s'.repeat(100), ...Array(9).fill(1)], + 120, ); + expect(JSON.stringify(data).length).toBeLessThanOrEqual(120); + expect(data[0]).toMatch(/^s+…\(\+\d+ chars\)$/); + }); + + it('keeps most of an escape-heavy string that partly fits', () => { + const { data } = trimToolResponse({ a: '"'.repeat(4000) }, 1000); + expect(JSON.stringify(data).length).toBeLessThanOrEqual(1000); + expect(data.a).toMatch(/^"{400,}…\(\+/); + }); + + it('gives a large value the room that small values leave', () => { + const data = { + summary: { totalCount: 5 }, + usage: 'u'.repeat(1000), + rows: Array.from({ length: 100 }, (_, i) => ({ + i, + text: 't'.repeat(900), + })), + }; + const { data: out } = trimToolResponse(data, 50000); + const size = JSON.stringify(out).length; + expect(size).toBeLessThanOrEqual(50000); + expect(size).toBeGreaterThan(40000); + expect(out.usage).toBe(data.usage); }); it('returns no notes when nothing is trimmed', () => { @@ -395,13 +434,7 @@ describe('trimToolResponse', () => { ], }; const { data } = trimToolResponse(output, 50000, { - 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 }, - ], + reductions: EVENT_PATTERNS_REDUCTIONS, }); expect(data.patterns).toHaveLength(30); expect(data.patterns.every(p => p.samples.length >= 1)).toBe(true); @@ -411,5 +444,60 @@ describe('trimToolResponse', () => { expect(JSON.stringify(data)).not.toContain('__hdx_trimmed'); expect(JSON.stringify(data).length).toBeLessThanOrEqual(50000); }); + + it('uses most of the budget when the reductions are not enough', () => { + // 30 patterns of ~10 KB stack traces with 5 samples: ~2 MB. + const stack = (n: number) => + `java.lang.IllegalStateException: case ${n}\n` + + Array.from( + { length: 200 }, + (_, i) => `\tat com.example.svc.L${n}x${i}.call(L${i}.java:${i})`, + ).join('\n'); + const output = { + summary: { totalCount: 1_000_000, patternsReturned: 30 }, + patterns: Array.from({ length: 30 }, (_, i) => ({ + rank: i + 1, + pattern: stack(i), + whereSnippet: '', + drilldownUnavailable: 'Template is longer than 4000 characters.', + trend: Array.from({ length: 24 }, (_, t) => ({ ts: t, count: 1 })), + samples: Array.from({ length: 5 }, () => stack(i)), + })), + usage: 'u'.repeat(1500), + }; + const { data } = trimToolResponse(output, 50000, { + reductions: EVENT_PATTERNS_REDUCTIONS, + }); + const size = JSON.stringify(data).length; + expect(size).toBeLessThanOrEqual(50000); + expect(size).toBeGreaterThan(40000); + expect(data.patterns.length).toBeGreaterThanOrEqual(10); + expect(data.patterns[0].pattern).toMatch(/^java\.lang\./); + }); + + it('shortens long emerging and disappeared patterns and keeps every entry', () => { + const entry = (i: number) => ({ + pattern: `Exception ${i} ` + 'e'.repeat(5000), + currentCount: i, + }); + const output = { + emerging: Array.from({ length: 12 }, (_, i) => entry(i)), + disappeared: Array.from({ length: 8 }, (_, i) => entry(i)), + }; + const { data, notes } = trimToolResponse(output, 50000, { + reductions: EMERGING_SIGNALS_REDUCTIONS, + }); + expect(data.emerging).toHaveLength(12); + expect(data.disappeared).toHaveLength(8); + for (const e of [...data.emerging, ...data.disappeared]) { + expect(e.pattern).toMatch(/^Exception \d+ e+…\(\+[\d,]+ chars\)$/); + } + expect(notes).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^emerging\[\]\.pattern: 12 value\(s\) cut/), + expect.stringMatching(/^disappeared\[\]\.pattern: 8 value\(s\) cut/), + ]), + ); + }); }); }); diff --git a/packages/api/src/utils/trimToolResponse.ts b/packages/api/src/utils/trimToolResponse.ts index f6b12d60ac..6534c61753 100644 --- a/packages/api/src/utils/trimToolResponse.ts +++ b/packages/api/src/utils/trimToolResponse.ts @@ -70,12 +70,7 @@ export function trimToolResponse( } // Fallback: per-key budget split and array cuts. - const stats: FallbackStats = { - arrays: [], - strings: 0, - charsRemoved: 0, - markers: 0, - }; + const stats: FallbackStats = { arrays: [], strings: 0, charsRemoved: 0 }; let trimmed: unknown = current; if (Array.isArray(current)) { trimmed = trimArray(current, maxSize, '', stats); @@ -109,11 +104,6 @@ export function trimToolResponse( `${fmt(stats.strings)} long value(s) shortened (${fmt(stats.charsRemoved)} chars removed)`, ); } - if (stats.markers > 0) { - notes.push( - `${fmt(stats.markers)} value(s) too large to shorten replaced by {"__hdx_trimmed":true,"originalSize":N}`, - ); - } const finalSize = JSON.stringify(trimmed).length; return { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion @@ -127,39 +117,43 @@ interface FallbackStats { arrays: { path: string; from: number; to: number }[]; strings: number; charsRemoved: number; - markers: number; } const fmt = (n: number) => n.toLocaleString('en-US'); +export const SIZE_NOTE_PREFIX = 'Response trimmed from '; + const sizeNote = (from: number, to: number, max: number) => - `Response trimmed from ${fmt(from)} to ${fmt(to)} chars (limit ${fmt(max)}).`; + `${SIZE_NOTE_PREFIX}${fmt(from)} to ${fmt(to)} chars (limit ${fmt(max)}).`; /** * `s` cut to `maxChars`, followed by "…(+N chars)". Returned unchanged when the - * suffix would make it no shorter. Unlike `truncateString` (utils/common), the - * suffix says how much was cut, which the agent needs to judge the value. + * cut would save less than twice the suffix: a few chars of room aren't worth + * the value. Unlike `truncateString` (utils/common), the suffix says how much + * was cut, which the agent needs to judge the value. */ export function shortenString(s: string, maxChars: number): string { if (s.length <= maxChars) return s; let keep = Math.max(0, maxChars); // Don't split a surrogate pair (JSON would carry a lone \udXXX escape). if (/[\uD800-\uDBFF]/.test(s.charAt(keep - 1))) keep--; - const out = `${s.slice(0, keep)}…(+${fmt(s.length - keep)} chars)`; - return out.length < s.length ? out : s; + const suffix = `…(+${fmt(s.length - keep)} chars)`; + const saved = s.length - keep - suffix.length; + return saved >= 2 * suffix.length ? s.slice(0, keep) + suffix : s; } -// Shorten so that the JSON-encoded string fits `budget` (escaping can make it -// longer than the raw string, so a couple of passes may be needed). +// Shorten to the longest cut whose JSON fits `budget` (escapes can make it +// longer than the raw string). Only called when `s` is over budget, so a cut +// that shortenString refuses doesn't fit either, and the search stays valid. function shortenToFit(s: string, budget: number, stats: FallbackStats): string { - let keep = Math.min(s.length, budget); - let out = shortenString(s, keep); - for (let i = 0; i < 4 && keep > 0; i++) { - const excess = JSON.stringify(out).length - budget; - if (excess <= 0) break; - keep = Math.max(0, keep - excess); - out = shortenString(s, keep); + let lo = 0; + let hi = Math.min(s.length, budget); + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (JSON.stringify(shortenString(s, mid)).length <= budget) lo = mid; + else hi = mid - 1; } + const out = shortenString(s, lo); if (out !== s) { stats.strings++; stats.charsRemoved += s.length - out.length; @@ -281,9 +275,7 @@ function trimArray( ? trimArray(item, itemBudget, `${path}[]`, stats) : trimObject(item, itemBudget, `${path}[]`, stats); } - // Non-string scalar that is itself too large — return a truncation marker - stats.markers++; - return { __hdx_trimmed: true, originalSize: sizes[i] }; // eslint-disable-line security/detect-object-injection + return item; // a number, boolean or null is shorter than any marker }); } @@ -306,44 +298,44 @@ function trimObject( const result: any = {}; - // Give each key an equal share of the budget so that no single large value - // crowds out the rest (e.g. a large array at key[0] eating all the budget - // before key[1] gets a chance to appear). The shares exclude what the - // values don't control: braces, quoted keys, colons, commas and the - // `"__hdx_trimmed":true` flag added below. + // Values within an equal share stay whole; the large ones split what is left, + // so one large value (e.g. `patterns`) gets the room the small ones don't + // use. The budget excludes what the values don't control: braces, quoted + // keys, colons, commas and the `"__hdx_trimmed":true` flag added below. const overhead = entries.reduce( (sum, [key]) => sum + JSON.stringify(key).length + 2, 2 + TRIMMED_FLAG_LENGTH, ); + const available = maxSize - overhead; + const sizes = entries.map(([, value]) => JSON.stringify(value).length); + const share = Math.floor(available / entries.length); + const smallTotal = sizes.reduce((sum, n) => (n <= share ? sum + n : sum), 0); + const largeCount = sizes.filter(n => n > share).length; const perKeyBudget = Math.max( 0, - Math.floor((maxSize - overhead) / entries.length), + Math.floor((available - smallTotal) / Math.max(1, largeCount)), ); let trimmed = false; - for (const [key, value] of entries) { - const valueStr = JSON.stringify(value); + entries.forEach(([key, value], i) => { + const size = sizes[i]; // eslint-disable-line security/detect-object-injection const keyPath = path ? `${path}.${key}` : key; - - if (valueStr.length <= perKeyBudget) { - result[key] = value; // eslint-disable-line security/detect-object-injection - } else { + let out = value; + if (size > perKeyBudget) { logger.info( - `Trimming oversized object value at key "${key}" (${valueStr.length} bytes > ${perKeyBudget} per-key budget)`, + `Trimming oversized object value at key "${key}" (${size} bytes > ${perKeyBudget} per-key budget)`, ); if (Array.isArray(value)) { - result[key] = trimArray(value, perKeyBudget, keyPath, stats); // eslint-disable-line security/detect-object-injection + out = trimArray(value, perKeyBudget, keyPath, stats); } else if (typeof value === 'object' && value !== null) { - result[key] = trimObject(value, perKeyBudget, keyPath, stats); // eslint-disable-line security/detect-object-injection + out = trimObject(value, perKeyBudget, keyPath, stats); } else if (typeof value === 'string') { - result[key] = shortenToFit(value, perKeyBudget, stats); // eslint-disable-line security/detect-object-injection - } else { - stats.markers++; - result[key] = { __hdx_trimmed: true, originalSize: valueStr.length }; // eslint-disable-line security/detect-object-injection - } - trimmed = true; + out = shortenToFit(value, perKeyBudget, stats); + } // a number, boolean or null is shorter than any marker: kept whole } - } + result[key] = out; // eslint-disable-line security/detect-object-injection + if (out !== value) trimmed = true; + }); if (trimmed) { result.__hdx_trimmed = true; From d320f6c387845fb1fc354a39a6e1594dbe020190 Mon Sep 17 00:00:00 2001 From: Baris Can Vural Date: Fri, 6 Nov 2026 10:38:50 +0300 Subject: [PATCH 5/7] test(mcp): cover a failed denoise on a trimmed search result --- .../src/mcp/__tests__/queryTool.int.test.ts | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/api/src/mcp/__tests__/queryTool.int.test.ts b/packages/api/src/mcp/__tests__/queryTool.int.test.ts index 82836cf107..ec654379e6 100644 --- a/packages/api/src/mcp/__tests__/queryTool.int.test.ts +++ b/packages/api/src/mcp/__tests__/queryTool.int.test.ts @@ -18,6 +18,7 @@ import { getServer, seedExponentialHistogramMetric, } from '@/fixtures'; +import * as denoise from '@/mcp/tools/query/denoise'; import * as patternDrilldown from '@/mcp/tools/query/patternDrilldown'; import { McpContext } from '@/mcp/tools/types'; import Connection from '@/models/connection'; @@ -659,8 +660,8 @@ describe('MCP Query Tools', () => { ); }); - it("keeps the first trim's row cut but not its stale size line", async () => { - const result = await callTool(client, 'clickstack_search', { + const search = () => + callTool(client, 'clickstack_search', { sourceId: logSource._id.toString(), denoise: true, maxResults: 200, @@ -668,6 +669,9 @@ describe('MCP Query Tools', () => { endTime: new Date(now.getTime() + 60 * 1000).toISOString(), }); + it("keeps the first trim's row cut but not its stale size line", async () => { + const result = await search(); + expect(result.isError).toBeFalsy(); const output = JSON.parse(getFirstText(result)); expect(output).toHaveProperty('denoised'); @@ -682,6 +686,23 @@ describe('MCP Query Tools', () => { ]), ); }); + + it('keeps the same notes when denoise fails', async () => { + const spy = jest + .spyOn(denoise, 'denoiseSearchResults') + .mockRejectedValueOnce(new Error('denoise down')); + try { + const result = await search(); + expect(result.isError).toBeFalsy(); + const output = JSON.parse(getFirstText(result)); + expect(output.denoised.skipped).toBe('denoise_failed'); + expect(output.trimNotes).toEqual([ + `result.data: cut from 120 to ${output.result.data.length} items`, + ]); + } finally { + spy.mockRestore(); + } + }); }); }); From f33b35f353e193095786cfd6cbd69627d7ac9350 Mon Sep 17 00:00:00 2001 From: Baris Can Vural Date: Fri, 6 Nov 2026 10:51:23 +0300 Subject: [PATCH 6/7] test(mcp): cover a top-level string and whereSnippet kept when samples suffice --- .../src/utils/__tests__/trimToolResponse.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/api/src/utils/__tests__/trimToolResponse.test.ts b/packages/api/src/utils/__tests__/trimToolResponse.test.ts index 888f42181b..0b4f67fd00 100644 --- a/packages/api/src/utils/__tests__/trimToolResponse.test.ts +++ b/packages/api/src/utils/__tests__/trimToolResponse.test.ts @@ -149,6 +149,19 @@ describe('trimToolResponse', () => { expect(shortenString('short', 10)).toBe('short'); }); + it('shortens a top-level string', () => { + const { data, isTrimmed, notes } = trimToolResponse('a'.repeat(60000)); + expect(isTrimmed).toBe(true); + expect(data).toMatch(/^a+…\(\+[\d,]+ chars\)$/); + expect(JSON.stringify(data).length).toBeLessThanOrEqual(50000); + expect(JSON.stringify(data).length).toBeGreaterThan(49000); + expect(notes).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^1 long value\(s\) shortened/), + ]), + ); + }); + it('shortens long string values in the fallback and says so in notes', () => { const data = { big: 'x'.repeat(5000), small: 'ok' }; const { data: result, isTrimmed, notes } = trimToolResponse(data, 2000); @@ -438,6 +451,7 @@ describe('trimToolResponse', () => { }); expect(data.patterns).toHaveLength(30); expect(data.patterns.every(p => p.samples.length >= 1)).toBe(true); + expect(data.patterns.every(p => 'whereSnippet' in p)).toBe(true); expect(data.patterns[0].pattern).toMatch( /^java\.lang\.NullPointerException/, ); From f9789fce125732c79ffa6a5362564f1721476809 Mon Sep 17 00:00:00 2001 From: Baris Can Vural Date: Fri, 6 Nov 2026 20:21:52 +0300 Subject: [PATCH 7/7] refactor(api): trim tool responses without eslint-disable comments Main's ratchet fails when a package gains eslint-disable comments. Reach paths through Reflect, pair values with their sizes, and build objects with Object.fromEntries, so the trimming code needs none. --- packages/api/src/utils/trimToolResponse.ts | 86 ++++++++++++---------- 1 file changed, 46 insertions(+), 40 deletions(-) diff --git a/packages/api/src/utils/trimToolResponse.ts b/packages/api/src/utils/trimToolResponse.ts index 6534c61753..8288c8bc97 100644 --- a/packages/api/src/utils/trimToolResponse.ts +++ b/packages/api/src/utils/trimToolResponse.ts @@ -175,11 +175,11 @@ function visit( if (!each) { if (!(name in node)) return; if (rest.length === 0) onTarget(node, name); - else visit(node[name], rest, onTarget); // eslint-disable-line security/detect-object-injection + else visit(Reflect.get(node, name), rest, onTarget); return; } - const arr = name ? node[name] : node; // eslint-disable-line security/detect-object-injection + const arr = name ? Reflect.get(node, name) : node; if (!Array.isArray(arr)) return; arr.forEach((item, i) => { if (rest.length === 0) onTarget(arr, i); @@ -194,22 +194,22 @@ function applyReduction(root: any, reduction: Reduction): string | undefined { let amount = 0; visit(root, segments, (container, key) => { - const value = container[key]; // eslint-disable-line security/detect-object-injection + const value = Reflect.get(container, key); if ('drop' in reduction) { if (Array.isArray(container)) return; - delete container[key]; // eslint-disable-line security/detect-object-injection + Reflect.deleteProperty(container, key); items++; } else if ('keep' in reduction) { if (Array.isArray(value) && value.length > reduction.keep) { amount += value.length - reduction.keep; - container[key] = value.slice(0, reduction.keep); // eslint-disable-line security/detect-object-injection + Reflect.set(container, key, value.slice(0, reduction.keep)); items++; } } else if (typeof value === 'string') { const out = shortenString(value, reduction.maxChars); if (out !== value) { amount += value.length - out.length; - container[key] = out; // eslint-disable-line security/detect-object-injection + Reflect.set(container, key, out); items++; } } @@ -249,23 +249,26 @@ function trimArray( // items. Items within an equal share stay whole; the large ones split what is // left, so the shortened items add up to the array's budget. if (resultSize > maxSize) { - const sizes = result.map(item => JSON.stringify(item).length); + const sized = result.map(item => ({ + item, + size: JSON.stringify(item).length, + })); const share = Math.floor(maxSize / result.length); - const smallTotal = sizes.reduce( - (sum, s) => (s <= share ? sum + s : sum), + const smallTotal = sized.reduce( + (sum, { size }) => (size <= share ? sum + size : sum), 0, ); - const largeCount = sizes.filter(s => s > share).length; + const largeCount = sized.filter(({ size }) => size > share).length; // Over budget only through separators: no single item to shorten. if (largeCount === 0) return result; // n-1 commas and 2 brackets const itemBudget = Math.floor( (maxSize - smallTotal - (result.length + 1)) / largeCount, ); - result = result.map((item, i) => { - if (sizes[i] <= itemBudget) return item; // eslint-disable-line security/detect-object-injection + result = sized.map(({ item, size }) => { + if (size <= itemBudget) return item; logger.info( - `Trimming oversized array item (${sizes[i]} bytes > ${itemBudget} limit)`, // eslint-disable-line security/detect-object-injection + `Trimming oversized array item (${size} bytes > ${itemBudget} limit)`, ); if (typeof item === 'string') { return shortenToFit(item, itemBudget, stats); @@ -284,9 +287,6 @@ function trimArray( const TRIMMED_FLAG_LENGTH = '"__hdx_trimmed":true'.length; -// Keys in trimObject come exclusively from Object.entries() on internal tool -// response data — never from user-supplied HTTP input — so bracket-notation -// writes are not an injection risk; see inline eslint-disable comments below. function trimObject( obj: any, maxSize: number, @@ -296,8 +296,6 @@ function trimObject( const entries = Object.entries(obj); if (entries.length === 0) return obj; - const result: any = {}; - // Values within an equal share stay whole; the large ones split what is left, // so one large value (e.g. `patterns`) gets the room the small ones don't // use. The budget excludes what the values don't control: braces, quoted @@ -307,35 +305,43 @@ function trimObject( 2 + TRIMMED_FLAG_LENGTH, ); const available = maxSize - overhead; - const sizes = entries.map(([, value]) => JSON.stringify(value).length); + const sized = entries.map(([key, value]) => ({ + key, + value, + size: JSON.stringify(value).length, + })); const share = Math.floor(available / entries.length); - const smallTotal = sizes.reduce((sum, n) => (n <= share ? sum + n : sum), 0); - const largeCount = sizes.filter(n => n > share).length; + const smallTotal = sized.reduce( + (sum, { size }) => (size <= share ? sum + size : sum), + 0, + ); + const largeCount = sized.filter(({ size }) => size > share).length; const perKeyBudget = Math.max( 0, Math.floor((available - smallTotal) / Math.max(1, largeCount)), ); let trimmed = false; - entries.forEach(([key, value], i) => { - const size = sizes[i]; // eslint-disable-line security/detect-object-injection - const keyPath = path ? `${path}.${key}` : key; - let out = value; - if (size > perKeyBudget) { - logger.info( - `Trimming oversized object value at key "${key}" (${size} bytes > ${perKeyBudget} per-key budget)`, - ); - if (Array.isArray(value)) { - out = trimArray(value, perKeyBudget, keyPath, stats); - } else if (typeof value === 'object' && value !== null) { - out = trimObject(value, perKeyBudget, keyPath, stats); - } else if (typeof value === 'string') { - out = shortenToFit(value, perKeyBudget, stats); - } // a number, boolean or null is shorter than any marker: kept whole - } - result[key] = out; // eslint-disable-line security/detect-object-injection - if (out !== value) trimmed = true; - }); + const result: any = Object.fromEntries( + sized.map(({ key, value, size }) => { + const keyPath = path ? `${path}.${key}` : key; + let out = value; + if (size > perKeyBudget) { + logger.info( + `Trimming oversized object value at key "${key}" (${size} bytes > ${perKeyBudget} per-key budget)`, + ); + if (Array.isArray(value)) { + out = trimArray(value, perKeyBudget, keyPath, stats); + } else if (typeof value === 'object' && value !== null) { + out = trimObject(value, perKeyBudget, keyPath, stats); + } else if (typeof value === 'string') { + out = shortenToFit(value, perKeyBudget, stats); + } // a number, boolean or null is shorter than any marker: kept whole + } + if (out !== value) trimmed = true; + return [key, out]; + }), + ); if (trimmed) { result.__hdx_trimmed = true;