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/__tests__/queryTool.int.test.ts b/packages/api/src/mcp/__tests__/queryTool.int.test.ts index 7b7cec82a6..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'; @@ -640,11 +641,123 @@ 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), + })), + ); + }); + + const search = () => + 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(), + }); + + 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'); + 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$/), + ]), + ); + }); + + 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(); + } + }); + }); }); // ─── 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..a70f9a0870 100644 --- a/packages/api/src/mcp/__tests__/sqlToolBatch.test.ts +++ b/packages/api/src/mcp/__tests__/sqlToolBatch.test.ts @@ -320,7 +320,14 @@ 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: [ + '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)', + ], + }), }, ], }); @@ -338,6 +345,10 @@ 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 replaces the per-query notes it made stale. + expect(entry.trimNotes).toEqual([ + `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 a29decf503..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, @@ -342,7 +347,13 @@ export function registerEmergingSignals({ 'the threshold — a valid finding.', }; - const { data, isTrimmed } = trimToolResponse(output); + const { + data, + isTrimmed, + notes: trimNotes, + } = trimToolResponse(output, undefined, { + reductions: EMERGING_SIGNALS_REDUCTIONS, + }); return { content: [ { @@ -352,6 +363,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 ec94c8f950..778555f868 100644 --- a/packages/api/src/mcp/tools/query/helpers.ts +++ b/packages/api/src/mcp/tools/query/helpers.ts @@ -355,10 +355,12 @@ function isEmptyResult(result: unknown): boolean { } function formatQueryResult(result: unknown, maxResponseChars?: number) { - const { data: trimmedResult, isTrimmed } = trimToolResponse( - result, - maxResponseChars, - ); + // Trimmed under its response key, so note paths read `result.data…`. + const { + data: { result: trimmedResult }, + isTrimmed, + notes: trimNotes, + } = trimToolResponse({ result }, maxResponseChars); const empty = isEmptyResult(result); return { content: [ @@ -370,6 +372,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 cd2ad6f304..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,12 +567,26 @@ export async function runEventPatterns( 'add them again. Skip patterns that have drilldownUnavailable.', }; - const { data: trimmedOutput, isTrimmed } = trimToolResponse(output); + const { + data: trimmedOutput, + isTrimmed, + notes: trimNotes, + } = trimToolResponse(output, undefined, { + reductions: EVENT_PATTERNS_REDUCTIONS, + }); + 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 ca78c83215..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'; @@ -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,11 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { } const resultData = parsed.result; + // 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; @@ -150,7 +158,11 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { 'denoiseSearchResults failed; returning raw results', ); - const { data: trimmedResult, isTrimmed } = trimToolResponse(resultData); + const { + data: { result: trimmedResult }, + notes, + } = trimToolResponse({ result: resultData }); + const trimNotes = [...firstPassNotes, ...notes]; return { content: [ { @@ -164,9 +176,10 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { filteredRowCount: rows.length, skipped: 'denoise_failed', }, - ...(isTrimmed + ...(trimNotes.length > 0 ? { note: 'Result was trimmed for context size. Narrow the time range or add filters to reduce data.', + trimNotes, } : {}), }, @@ -185,8 +198,11 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { ...resultData, data: denoised.rows, }; - const { data: trimmedResult, isTrimmed } = - trimToolResponse(denoisedResult); + const { + data: { result: trimmedResult }, + notes, + } = trimToolResponse({ result: denoisedResult }); + const trimNotes = [...firstPassNotes, ...notes]; return { content: [ @@ -203,9 +219,10 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) { filteredRowCount: denoised.rows.length, ...(denoised.skipped ? { skipped: denoised.skipped } : {}), }, - ...(isTrimmed + ...(trimNotes.length > 0 ? { 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/query/sqlBatch.ts b/packages/api/src/mcp/tools/query/sqlBatch.ts index 83c449d5fe..9c68c6966b 100644 --- a/packages/api/src/mcp/tools/query/sqlBatch.ts +++ b/packages/api/src/mcp/tools/query/sqlBatch.ts @@ -124,6 +124,11 @@ function setRows(entry: SqlBatchEntry, rows: unknown[]) { ? { ...result, data: rows } : rows; entry.note = BATCH_TRIM_NOTE; + // 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 = [ + `Rows cut to ${rows.length} to fit the batch response size budget.`, + ]; } /** 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..0b4f67fd00 100644 --- a/packages/api/src/utils/__tests__/trimToolResponse.test.ts +++ b/packages/api/src/utils/__tests__/trimToolResponse.test.ts @@ -1,4 +1,6 @@ -import { trimToolResponse } from '@/utils/trimToolResponse'; +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', () => { describe('return shape', () => { @@ -137,4 +139,379 @@ 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(100), 4)).toBe('aaaa…(+96 chars)'); + expect(shortenString('a'.repeat(8000), 1000)).toBe( + `${'a'.repeat(1000)}…(+7,000 chars)`, + ); + 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); + 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('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); + // 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)`); + }); + + 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('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'); + 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('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', () => { + 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) 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)', + ]), + ); + }); + + 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('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, { + 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: EVENT_PATTERNS_REDUCTIONS, + }); + 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/, + ); + 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 3ab9377abb..8288c8bc97 100644 --- a/packages/api/src/utils/trimToolResponse.ts +++ b/packages/api/src/utils/trimToolResponse.ts @@ -3,49 +3,232 @@ 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) continue; // nothing changed, so the size didn't either + 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 }; + // 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( + 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) { + 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], + }; +} + +interface FallbackStats { + arrays: { path: string; from: number; to: number }[]; + strings: number; + charsRemoved: 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) => + `${SIZE_NOTE_PREFIX}${fmt(from)} to ${fmt(to)} chars (limit ${fmt(max)}).`; + +/** + * `s` cut to `maxChars`, followed by "…(+N chars)". Returned unchanged when the + * 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 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 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 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; + } + return out; +} + +// 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(Reflect.get(node, name), rest, onTarget); + return; + } + + const arr = name ? Reflect.get(node, name) : node; + if (!Array.isArray(arr)) return; + arr.forEach((item, i) => { + if (rest.length === 0) onTarget(arr, i); + else visit(item, rest, onTarget); + }); +} - return { data, isTrimmed: false }; +// 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 = Reflect.get(container, key); + if ('drop' in reduction) { + if (Array.isArray(container)) return; + Reflect.deleteProperty(container, key); + items++; + } else if ('keep' in reduction) { + if (Array.isArray(value) && value.length > reduction.keep) { + amount += value.length - reduction.keep; + 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; + Reflect.set(container, key, out); + 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) cut to the first ${fmt(reduction.maxChars)} chars (${fmt(amount)} chars removed)`; } -function trimArray(arr: any[], maxSize: number): any[] { +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,66 +240,108 @@ 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 sized = result.map(item => ({ + item, + size: JSON.stringify(item).length, + })); + const share = Math.floor(maxSize / result.length); + const smallTotal = sized.reduce( + (sum, { size }) => (size <= share ? sum + size : sum), + 0, + ); + 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 = sized.map(({ item, size }) => { + if (size <= itemBudget) return item; + logger.info( + `Trimming oversized array item (${size} bytes > ${itemBudget} limit)`, + ); + if (typeof item === 'string') { + return shortenToFit(item, itemBudget, stats); + } + if (typeof item === 'object' && item !== null) { + return Array.isArray(item) + ? trimArray(item, itemBudget, `${path}[]`, stats) + : trimObject(item, itemBudget, `${path}[]`, stats); } - return item; + return item; // a number, boolean or null is shorter than any marker }); } - 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 { +const TRIMMED_FLAG_LENGTH = '"__hdx_trimmed":true'.length; + +function trimObject( + obj: any, + maxSize: number, + path: string, + stats: FallbackStats, +): any { const entries = Object.entries(obj); if (entries.length === 0) return obj; - 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). - const perKeyBudget = Math.floor(maxSize / entries.length); + // 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 sized = entries.map(([key, value]) => ({ + key, + value, + size: JSON.stringify(value).length, + })); + const share = Math.floor(available / entries.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; - for (const [key, value] of entries) { - const valueStr = JSON.stringify(value); - - if (valueStr.length <= perKeyBudget) { - result[key] = value; // eslint-disable-line security/detect-object-injection - } else { - logger.info( - `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 - } else if (typeof value === 'object' && value !== null) { - result[key] = trimObject(value, perKeyBudget); // eslint-disable-line security/detect-object-injection - } else { - result[key] = { __hdx_trimmed: true, originalSize: valueStr.length }; // eslint-disable-line security/detect-object-injection + 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 } - trimmed = true; - } - } + if (out !== value) trimmed = true; + return [key, out]; + }), + ); if (trimmed) { result.__hdx_trimmed = true;