-
Notifications
You must be signed in to change notification settings - Fork 8.6k
[kbn-evals] Fix CI LLM triage silently dropping suites: forced tool call, truncation detection, raw-output logging #290495
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
Merged
arturoliduena
merged 5 commits into
elastic:main
from
arturoliduena:kbn-evals-6094-bug-LLM-Evals-Triage-Returns-Non-JSON
Sep 12, 2026
+318
−24
Merged
Changes from 4 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
74fdadc
[kbn-evals] Report CI triage through a forced tool call
arturoliduena 831e7b9
Simplify CI triage to two paths: tool call parses, or generic failure
arturoliduena 423f74f
Merge branch 'main' into kbn-evals-6094-bug-LLM-Evals-Triage-Returns-…
arturoliduena 7673a9f
clean-up dead code
arturoliduena 7fbf136
replaced the try/catch block with an explicit args === undefined guard.
arturoliduena File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,21 +15,69 @@ const MAX_CONTEXT_JSON_BYTES = 30 * 1024; | |
| // Triage/summary text is always generated with a small, low-cost OpenRouter model. | ||
| const TRIAGE_OPENROUTER_CONNECTOR_ID = `openrouter-${slugifyId(TRIAGE_OPENROUTER_MODEL)}`; | ||
|
|
||
| // Output budget for the per-suite structured triage. | ||
| const TRIAGE_MAX_TOKENS = 4000; | ||
| const TRIAGE_MAX_ERROR_CHARS = 200; | ||
|
|
||
| const TRIAGE_SYSTEM_PROMPT = | ||
| 'You are an SRE assistant triaging failed LLM evaluation CI runs. Be concise and factual, and base every statement on the provided context.'; | ||
|
|
||
| const TRIAGE_TOOL_NAME = 'report_triage'; | ||
|
|
||
| const TRIAGE_TOOL = { | ||
| type: 'function', | ||
| function: { | ||
| name: TRIAGE_TOOL_NAME, | ||
| description: | ||
| 'Report the triage of a failed LLM evaluation CI suite as a list of distinct error groups.', | ||
| parameters: { | ||
| type: 'object', | ||
| properties: { | ||
| groups: { | ||
| type: 'array', | ||
| description: 'One entry per distinct error. Empty when the excerpts show no clear error.', | ||
| items: { | ||
| type: 'object', | ||
| properties: { | ||
| error: { | ||
| type: 'string', | ||
| description: `Most relevant error line, verbatim from the excerpts, one line, at most ${TRIAGE_MAX_ERROR_CHARS} characters.`, | ||
| }, | ||
| location: { | ||
| type: 'string', | ||
| description: 'file:line, test, or scenario if shown in the excerpts; else empty.', | ||
| }, | ||
| models: { | ||
| type: 'array', | ||
| items: { type: 'string' }, | ||
| description: 'Ids of every failing model that hit this error.', | ||
| }, | ||
| rootCause: { | ||
| type: 'string', | ||
| description: 'One short sentence with the likely cause plus one short action.', | ||
| }, | ||
| }, | ||
| required: ['error', 'location', 'models', 'rootCause'], | ||
| }, | ||
| }, | ||
| }, | ||
| required: ['groups'], | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| const TRIAGE_TOOL_CHOICE = { type: 'function', function: { name: TRIAGE_TOOL_NAME } }; | ||
|
|
||
| // Static output contract for the per-suite structured triage prompt. | ||
| const TRIAGE_OUTPUT_INSTRUCTIONS = `Return ONLY a JSON object of this shape (no prose, no markdown, no code fences): | ||
| {"groups":[{"error":"<most relevant error line, verbatim from the excerpts, one line>","location":"<file:line / test or scenario if shown, else empty>","models":["<affected model id>"],"rootCause":"<short cause + one short action>"}]} | ||
| const TRIAGE_OUTPUT_INSTRUCTIONS = `Report the result by calling the \`${TRIAGE_TOOL_NAME}\` tool with {"groups":[{"error":"<most relevant error line, verbatim from the excerpts, one line>","location":"<file:line / test or scenario if shown, else empty>","models":["<affected model id>"],"rootCause":"<short cause + one short action>"}]}. Do not answer in prose. | ||
|
|
||
| Rules: | ||
| - One group per distinct error. Merge the same failure (same message/location) into one group and list all its affected models. | ||
| - Quote "error" verbatim from the excerpts; do not invent errors, file names, line numbers, or causes. | ||
| - Quote "error" verbatim from the excerpts, trimmed to a single line of at most ${TRIAGE_MAX_ERROR_CHARS} characters; do not invent errors, file names, line numbers, or causes. | ||
| - Set "location" only if the excerpts show it; otherwise use an empty string. | ||
| - Keep "rootCause" to one short sentence plus one short action. | ||
| - Never reproduce secrets: if a line contains an API key, token, password, or other credential, replace that value with [REDACTED]. | ||
| - If the excerpts show no clear error, return {"groups": []}. | ||
| - Output valid JSON only: no prose before or after, no comments, no trailing commas, no code fences.`; | ||
| - If the excerpts show no clear error, call the tool with {"groups": []}.`; | ||
|
|
||
| // Static output contract for the weekly cross-suite rollup prompt. | ||
| const WEEKLY_ROLLUP_OUTPUT_INSTRUCTIONS = `Output exactly these bullets, in this order, and nothing else: | ||
|
|
@@ -233,7 +281,7 @@ function buildWeeklyRollupUserPrompt(suites, meta = {}) { | |
| return lines.join('\n'); | ||
| } | ||
|
|
||
| function buildOpenrouterChatRequest(connector, messages) { | ||
| function buildOpenrouterChatRequest(connector, messages, options = {}) { | ||
| const config = connector.config && typeof connector.config === 'object' ? connector.config : {}; | ||
| const secrets = | ||
| connector.secrets && typeof connector.secrets === 'object' ? connector.secrets : {}; | ||
|
|
@@ -246,6 +294,8 @@ function buildOpenrouterChatRequest(connector, messages) { | |
| throw new Error('OpenRouter connector is missing apiUrl, defaultModel, or apiKey'); | ||
| } | ||
|
|
||
| const { maxTokens = 800, tools, toolChoice } = options; | ||
|
|
||
| return { | ||
| url: apiUrl, | ||
| headers: { | ||
|
|
@@ -256,7 +306,8 @@ function buildOpenrouterChatRequest(connector, messages) { | |
| model: defaultModel, | ||
| messages, | ||
| temperature: 0.2, | ||
| max_tokens: 800, | ||
| max_tokens: maxTokens, | ||
| ...(tools ? { tools, tool_choice: toolChoice } : {}), | ||
| }, | ||
| }; | ||
| } | ||
|
|
@@ -266,9 +317,7 @@ function parseOpenrouterChatContent(responseJson) { | |
| throw new Error('OpenRouter response was not JSON'); | ||
| } | ||
|
|
||
| const choices = /** @type {{ choices?: Array<{ message?: { content?: string } }> }} */ ( | ||
| responseJson | ||
| ).choices; | ||
| const choices = responseJson.choices; | ||
|
|
||
| const content = choices?.[0]?.message?.content; | ||
| if (typeof content !== 'string' || !content.trim()) { | ||
|
|
@@ -279,9 +328,9 @@ function parseOpenrouterChatContent(responseJson) { | |
| } | ||
|
|
||
| /** | ||
| * POST a built OpenRouter chat request and return the message content. | ||
| * POST a built OpenRouter chat request and return the parsed response body. | ||
| */ | ||
| async function postOpenrouterChatRequest({ url, headers, body }) { | ||
| async function postOpenrouterChat({ url, headers, body }) { | ||
| const response = await fetch(url, { | ||
| method: 'POST', | ||
| headers, | ||
|
|
@@ -302,14 +351,18 @@ async function postOpenrouterChatRequest({ url, headers, body }) { | |
| throw new Error(`Inference request failed (${response.status}): ${detail}`); | ||
| } | ||
|
|
||
| let json = null; | ||
| try { | ||
| json = text ? JSON.parse(text) : null; | ||
| return text ? JSON.parse(text) : null; | ||
| } catch { | ||
| throw new Error('Inference response was not JSON'); | ||
| } | ||
| } | ||
|
|
||
| return parseOpenrouterChatContent(json); | ||
| /** | ||
| * POST a built OpenRouter chat request and return the message content. | ||
| */ | ||
| async function postOpenrouterChatRequest(request) { | ||
| return parseOpenrouterChatContent(await postOpenrouterChat(request)); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -329,14 +382,10 @@ function resolveTriageConnector() { | |
| */ | ||
| function parseTriageGroups(rawText) { | ||
| const text = String(rawText ?? '').trim(); | ||
| const unfenced = text | ||
| .replace(/^```(?:json)?\s*/i, '') | ||
| .replace(/\s*```$/i, '') | ||
| .trim(); | ||
|
|
||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(unfenced); | ||
| parsed = JSON.parse(text); | ||
| } catch { | ||
| throw new Error('Triage model did not return valid JSON'); | ||
| } | ||
|
|
@@ -377,8 +426,9 @@ async function runTriageModel(userPrompt, { maxChars = 1500 } = {}) { | |
|
|
||
| /** | ||
| * Resolve the OpenRouter connector, send the shared system prompt + the given user | ||
| * prompt, and return the parsed structured triage groups and the model id used. | ||
| * Used by the per-suite triage, which renders the message deterministically. | ||
| * prompt as a forced `report_triage` tool call, and return the parsed groups and | ||
| * the model id used. Used by the per-suite triage, which renders the message | ||
| * deterministically. | ||
| */ | ||
| async function runTriageModelStructured(userPrompt) { | ||
| const { connector, modelId } = resolveTriageConnector(); | ||
|
|
@@ -388,16 +438,39 @@ async function runTriageModelStructured(userPrompt) { | |
| { role: 'user', content: userPrompt }, | ||
| ]; | ||
|
|
||
| const raw = await postOpenrouterChatRequest(buildOpenrouterChatRequest(connector, messages)); | ||
| const responseJson = await postOpenrouterChat( | ||
| buildOpenrouterChatRequest(connector, messages, { | ||
| maxTokens: TRIAGE_MAX_TOKENS, | ||
| tools: [TRIAGE_TOOL], | ||
| toolChoice: TRIAGE_TOOL_CHOICE, | ||
| }) | ||
| ); | ||
|
|
||
| return { groups: parseTriageGroups(raw), modelId }; | ||
| const message = responseJson?.choices?.[0]?.message; | ||
| try { | ||
| return { groups: parseTriageGroups(message?.tool_calls?.[0]?.function?.arguments), modelId }; | ||
| } catch (error) { | ||
| // The Buildkite step log is the only place the raw reply is visible; the | ||
| // Slack/GitHub fallback line stays generic. | ||
| console.error( | ||
| `Raw triage model reply: ${redactSecrets(JSON.stringify(message ?? responseJson)).slice( | ||
| 0, | ||
| 500 | ||
|
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. Should we consider 1000 here? 500 is a bit too low I feel and may lose some information.
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. done |
||
| )}` | ||
| ); | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| module.exports = { | ||
| MAX_LOG_EXCERPT_CHARS, | ||
| MAX_CONTEXT_JSON_BYTES, | ||
| TRIAGE_MAX_TOKENS, | ||
| TRIAGE_SYSTEM_PROMPT, | ||
| TRIAGE_OPENROUTER_CONNECTOR_ID, | ||
| TRIAGE_TOOL_NAME, | ||
| TRIAGE_TOOL, | ||
| TRIAGE_TOOL_CHOICE, | ||
| failureLogMetadataKey, | ||
| failureLogMetadataKeysForProject, | ||
| truncateText, | ||
|
|
@@ -408,6 +481,7 @@ module.exports = { | |
| extractSuiteRootCauseLine, | ||
| buildOpenrouterChatRequest, | ||
| parseOpenrouterChatContent, | ||
| postOpenrouterChat, | ||
| postOpenrouterChatRequest, | ||
| resolveTriageConnector, | ||
| parseTriageGroups, | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
When the model returns a text response instead of a tool call,
message?.tool_calls?.[0]?.function?.argumentsisundefined.parseTriageGroups(undefined)then throws "Triage model did not return valid JSON", which is accurate for the JSON parse failure but misleading about why. The issue is that there was no tool call at all, not that the arguments were malformed JSON. Distinguishing these two cases in the logged output would make CI debugging faster.Something like:
This also removes the need for a try/catch around the normal path, making the code cleaner.
WDYT?
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.
great suggestion, applied: 7fbf136