Repository navigation
fix(mcp): shorten instead of replacing when trimming tool responses, ordered reductions, trim notes #3554
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
fix(mcp): shorten instead of replacing when trimming tool responses, ordered reductions, trim notes #3554
Changes from all commits
2c24915
0bdf98f
9d5ba8f
d5d690b
022a787
d320f6c
f33b35f
f9789fc
d11c848
d16ee64
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not changing. The kept notes describe the first pass, and |
||
| n => !n.startsWith(SIZE_NOTE_PREFIX), | ||
| ); | ||
| const rows = (resultData as Record<string, unknown> | undefined)?.data as | ||
| | Record<string, unknown>[] | ||
| | 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]; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — With denoise on,
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: [ | ||
|
|
@@ -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 | ||
|
|
||
There was a problem hiding this comment.
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
formatQueryResultaddstrimNotes(for exampledata: cut from 500 to 10 itemsandResponse trimmed from A to B chars). ThenfitToBudgetin sqlBatch.ts:133-153 keeps halvingresult.datawithout updating those notes, so the agent sees row counts and sizes that are no longer true. Fix: havefitToBudgetadd a note of its own when it cuts rows (or replacetrimNotes), 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