Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-trim-shorten-reductions.md
Original file line number Diff line number Diff line change
@@ -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
113 changes: 113 additions & 0 deletions packages/api/src/mcp/__tests__/queryTool.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<typeof bulkInsertLogs>[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(),
Expand Down
13 changes: 12 additions & 1 deletion packages/api/src/mcp/__tests__/sqlToolBatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
],
}),
},
],
});
Expand All @@ -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.`,
]);
}
});

Expand Down
16 changes: 14 additions & 2 deletions packages/api/src/mcp/tools/query/emergingSignals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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: [
{
Expand All @@ -352,6 +363,7 @@ export function registerEmergingSignals({
? {
...data,
note: 'Trimmed for context size — lower topN or narrow windows.',
trimNotes,
}
: data,
null,
Expand Down
7 changes: 6 additions & 1 deletion packages/api/src/mcp/tools/query/eventDeltas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
11 changes: 7 additions & 4 deletions packages/api/src/mcp/tools/query/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — trimNotes become wrong when clickstack_sql batch mode cuts more rows afterwards

formatQueryResult adds trimNotes (for example data: cut from 500 to 10 items and Response trimmed from A to B chars). Then fitToBudget in sqlBatch.ts:133-153 keeps halving result.data without updating those notes, so the agent sees row counts and sizes that are no longer true. Fix: have fitToBudget add a note of its own when it cuts rows (or replace trimNotes), so the notes match the payload the agent actually gets.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

}
: {}),
...(empty
Expand Down
33 changes: 30 additions & 3 deletions packages/api/src/mcp/tools/query/runEventPatterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 major — whereSnippet is now cut into an invalid Lucene query that the agent is told to paste into clickstack_search

whereSnippet (built at runEventPatterns.ts:376-379 as Body:"<all literal tokens>") is about as long as pattern. The reductions shorten pattern to 2,000 chars but leave whereSnippet alone. If the response still doesn't fit, the fallback's new shortenToFit now cuts it to something like Body:"java.lang… …(+4,300 chars). That is an unclosed phrase with a junk suffix, and the response tells the agent to use it as where as-is. Before this PR the value became an obvious marker object. Fix: add a reduction for patterns[].whereSnippet (drop it, or rebuild it from the shortened pattern) that runs before the fallback, or have the fallback never cut strings meant to be copied. The 30-pattern test hides this because its whereSnippet is capped at 200 chars (text.slice(0, 200)). Build it the way production does so the test covers this.

Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond

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;

Expand Down
31 changes: 24 additions & 7 deletions packages/api/src/mcp/tools/query/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -114,14 +114,22 @@ export function registerSearch({ context, registerTool }: ToolRegistrar) {
const resultText = result.content?.[0]?.text;
if (!resultText) return result;

let parsed: { result?: { data?: Record<string, unknown>[] } };
let parsed: {
result?: { data?: Record<string, unknown>[] };
trimNotes?: string[];
};
try {
parsed = JSON.parse(resultText);
} catch {
return result;
}

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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Denoise keeps first-pass trim counts that no longer match the rows it returns

Only the size line is filtered out, but denoise then removes rows. So a first-pass note like N long value(s) shortened (… chars removed) still counts values in rows that are no longer in result.data, and result.data: cut from 120 to 80 items sits next to a data array of 20 rows. Either drop long value(s) shortened along with SIZE_NOTE_PREFIX, or reword the kept notes so they say they describe the rows before denoise (they match returnedRowCountBeforeDenoise).

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing. The kept notes describe the first pass, and denoised.returnedRowCountBeforeDenoise is that pass's row count, so they read in order: 120 → 80 trimmed, then 80 → 20 denoised. Values shortened in the 20 rows left still carry their …(+N chars).

n => !n.startsWith(SIZE_NOTE_PREFIX),
);
const rows = (resultData as Record<string, unknown> | undefined)?.data as
| Record<string, unknown>[]
| undefined;
Expand Down Expand Up @@ -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: [
{
Expand All @@ -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,
}
: {}),
},
Expand All @@ -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];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — With denoise on, trimNotes can hold two conflicting 'Response trimmed from X to Y' lines, and this merge has no test

firstPassNotes (from runConfigTile/formatQueryResult) and the second trimToolResponse each start with their own sizeNote. The agent then sees two different before/after sizes for one response. Drop the stale size line from firstPassNotes before merging (or keep only the latest one), and add a test for the denoise path that checks the merged trimNotes.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond


return {
content: [
Expand All @@ -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
Expand Down
Loading
Loading