From 3c7220b58029ae38a67fd3aaeccc42652cc51af4 Mon Sep 17 00:00:00 2001 From: Vitek Karas <10670590+vitek-karas@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:11:36 +0200 Subject: [PATCH 1/4] Add trusted KBE search eval support Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci-eval.yml | 51 +++- .../evals/kbe-candidate-reads-grader.mjs | 167 +++++++++++++ .github/workflows/evals/kbe-search.test.mjs | 226 ++++++++++++++++++ .github/workflows/evals/package.json | 3 + .github/workflows/evals/search-kbe-issues.cjs | 75 ++++++ 5 files changed, 518 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/evals/kbe-candidate-reads-grader.mjs create mode 100644 .github/workflows/evals/kbe-search.test.mjs create mode 100644 .github/workflows/evals/search-kbe-issues.cjs diff --git a/.github/workflows/ci-eval.yml b/.github/workflows/ci-eval.yml index 6967ea6b05a09b..45b42418f5a07e 100644 --- a/.github/workflows/ci-eval.yml +++ b/.github/workflows/ci-eval.yml @@ -231,7 +231,7 @@ jobs: with: persist-credentials: false - - name: Preserve the trusted eval specs + - name: Preserve the trusted eval inputs if: steps.guard.outputs.run == 'true' env: SPECS: ${{ steps.sel.outputs.specs }} @@ -240,6 +240,18 @@ jobs: for spec in $SPECS; do cp "$spec" "$RUNNER_TEMP/trusted-evals/$(basename "$spec")" done + if test -f .github/workflows/evals/search-kbe-issues.cjs; then + cp .github/workflows/evals/search-kbe-issues.cjs \ + "$RUNNER_TEMP/trusted-evals/search-kbe-issues.cjs" + fi + if test -f .github/workflows/evals/kbe-candidate-reads-grader.mjs; then + cp .github/workflows/evals/kbe-candidate-reads-grader.mjs \ + "$RUNNER_TEMP/trusted-evals/kbe-candidate-reads-grader.mjs" + fi + if test -f .github/workflows/evals/kbe-search.test.mjs; then + cp .github/workflows/evals/kbe-search.test.mjs \ + "$RUNNER_TEMP/trusted-evals/kbe-search.test.mjs" + fi if [ -f .github/workflows/shared/filter-scanner-kbes.sh ]; then cp .github/workflows/shared/filter-scanner-kbes.sh \ "$RUNNER_TEMP/trusted-evals/filter-scanner-kbes.sh" @@ -265,7 +277,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: gh pr checkout ${{ github.event.issue.number }} - - name: Restore the trusted eval specs + - name: Restore the trusted eval inputs if: steps.guard.outputs.run == 'true' env: SPECS: ${{ steps.sel.outputs.specs }} @@ -274,19 +286,45 @@ jobs: rm -f -- "$spec" cp -- "$RUNNER_TEMP/trusted-evals/$(basename "$spec")" "$spec" done + rm -f -- .github/workflows/evals/search-kbe-issues.cjs + if test -f "$RUNNER_TEMP/trusted-evals/search-kbe-issues.cjs"; then + cp -- "$RUNNER_TEMP/trusted-evals/search-kbe-issues.cjs" \ + .github/workflows/evals/search-kbe-issues.cjs + fi + rm -f -- .github/workflows/evals/kbe-candidate-reads-grader.mjs + if test -f "$RUNNER_TEMP/trusted-evals/kbe-candidate-reads-grader.mjs"; then + cp -- "$RUNNER_TEMP/trusted-evals/kbe-candidate-reads-grader.mjs" \ + .github/workflows/evals/kbe-candidate-reads-grader.mjs + fi + rm -f -- .github/workflows/evals/kbe-search.test.mjs + if test -f "$RUNNER_TEMP/trusted-evals/kbe-search.test.mjs"; then + cp -- "$RUNNER_TEMP/trusted-evals/kbe-search.test.mjs" \ + .github/workflows/evals/kbe-search.test.mjs + fi if [ -f "$RUNNER_TEMP/trusted-evals/filter-scanner-kbes.sh" ]; then mkdir -p .github/workflows/shared cp -- "$RUNNER_TEMP/trusted-evals/filter-scanner-kbes.sh" \ .github/workflows/shared/filter-scanner-kbes.sh fi + - name: Test the KBE search support + if: steps.guard.outputs.run == 'true' + run: | + if test -f .github/workflows/evals/kbe-search.test.mjs; then + node --test .github/workflows/evals/kbe-search.test.mjs + fi + - name: Lint the selected spec(s) if: steps.guard.outputs.run == 'true' env: SPECS: ${{ steps.sel.outputs.specs }} run: | + grader_args=() + if test -f .github/workflows/evals/kbe-candidate-reads-grader.mjs; then + grader_args+=(--grader-plugin ./.github/workflows/evals/kbe-candidate-reads-grader.mjs) + fi for spec in $SPECS; do - vally lint --eval-spec "$spec" --strict + vally lint --eval-spec "$spec" --strict "${grader_args[@]}" done - name: Run the eval(s) @@ -317,11 +355,16 @@ jobs: report="$workdir/report.md" : > "$report" rc=0 + grader_args=() + if test -f .github/workflows/evals/kbe-candidate-reads-grader.mjs; then + grader_args+=(--grader-plugin ./.github/workflows/evals/kbe-candidate-reads-grader.mjs) + fi for spec in $SPECS; do name="$(basename "$spec" .eval.yaml)" echo "## $name" >> "$report" if vally eval --eval-spec "$spec" --skill-dir .github/workflows \ - --workspace "$workdir/ws-$name" --output-dir "$workdir/out-$name" > "$workdir/log-$name.txt" 2>&1; then + --workspace "$workdir/ws-$name" --output-dir "$workdir/out-$name" \ + "${grader_args[@]}" > "$workdir/log-$name.txt" 2>&1; then echo "✅ passed" >> "$report" else rc=1 diff --git a/.github/workflows/evals/kbe-candidate-reads-grader.mjs b/.github/workflows/evals/kbe-candidate-reads-grader.mjs new file mode 100644 index 00000000000000..669df9b2aa0a52 --- /dev/null +++ b/.github/workflows/evals/kbe-candidate-reads-grader.mjs @@ -0,0 +1,167 @@ +function callKey(event) { + return JSON.stringify([event.agentId ?? null, event.data.toolCallId]); +} + +function resultText(result) { + if (typeof result === "string") { + return result; + } + + if (typeof result?.content === "string") { + return result.content; + } + + if (result === null || result === undefined) { + return ""; + } + + try { + return JSON.stringify(result); + } catch { + return String(result); + } +} + +function isSearchHarnessCall(event) { + if (event.type !== "tool_call" || !/^(bash|powershell)$/.test(event.data.toolName)) { + return false; + } + + const command = event.data.arguments?.command; + return typeof command === "string" && + /(?:^|[\\/])search-kbe-issues\.cjs(?:\s|$)/.test(command); +} + +function isIssueReadCall(event) { + return event.type === "tool_call" && + (event.data.toolName === "issue_read" || event.data.toolName.endsWith(".issue_read")); +} + +function parseCandidates(result) { + const text = resultText(result).trim(); + const payloads = [text, ...text.split(/\r?\n/).reverse()]; + let parsed; + for (const payload of payloads) { + try { + const value = JSON.parse(payload); + if (Array.isArray(value)) { + parsed = value; + break; + } + } catch { + // Shell tools may surround stdout with execution metadata. + } + } + + if (!parsed) { + throw new Error("search-kbe-issues returned output that is not JSON"); + } + + for (const candidate of parsed) { + if (!Number.isInteger(candidate?.number) || + typeof candidate?.user?.login !== "string" || + candidate.user.login.length === 0) { + throw new Error("search-kbe-issues returned a malformed candidate"); + } + } + + return parsed.map((candidate) => candidate.number); +} + +class KbeCandidateReadsGrader { + metadata = { + name: "kbe-candidate-reads", + description: "Verifies every issue-search candidate receives a successful, unfiltered issue_read", + behavior: {}, + determinism: "complex-static", + reference: "reference-free", + temporalScope: "trajectory-level", + costProfile: "free", + }; + + async grade(input) { + if (!input.trajectory) { + throw new Error("Missing trajectory"); + } + + const calls = new Map(); + const candidates = new Map(); + const reads = new Map(); + const errors = []; + let harnessCallCount = 0; + + for (const [index, event] of input.trajectory.events.entries()) { + if (event.type === "tool_call") { + if (isSearchHarnessCall(event)) { + harnessCallCount++; + calls.set(callKey(event), { kind: "search" }); + } else if (isIssueReadCall(event)) { + const args = event.data.arguments ?? {}; + calls.set(callKey(event), { + kind: "read", + number: Number(args.issue_number), + validScope: args.owner === "dotnet" && + args.repo === "runtime" && + args.method === "get", + }); + } + } else if (event.type === "tool_result") { + const call = calls.get(callKey(event)); + if (!call) { + continue; + } + + if (call.kind === "search") { + if (!event.data.success) { + errors.push("search-kbe-issues call failed"); + continue; + } + + try { + for (const number of parseCandidates(event.data.result)) { + if (!candidates.has(number)) { + candidates.set(number, index); + } + } + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + } else if (call.validScope && Number.isInteger(call.number) && + event.data.success && !resultText(event.data.result).includes("[Filtered]")) { + reads.set(call.number, index); + } + } + } + + if (harnessCallCount === 0) { + errors.push("search-kbe-issues was not called"); + } + + const missing = [...candidates] + .filter(([number, searchIndex]) => !reads.has(number) || reads.get(number) < searchIndex) + .map(([number]) => number) + .sort((left, right) => left - right); + const passed = errors.length === 0 && missing.length === 0; + const evidence = passed + ? `Read all ${candidates.size} candidate issue(s) returned by ${harnessCallCount} search call(s).` + : [...errors, missing.length > 0 ? `Missing successful issue_read for: ${missing.join(", ")}` : ""] + .filter(Boolean) + .join("\n"); + + return { + name: this.metadata.name, + kind: "code", + passed, + score: passed ? 1 : 0, + evidence, + metadata: { + candidates: [...candidates.keys()].sort((left, right) => left - right), + missing, + }, + }; + } +} + +export function registerGraders(registry) { + registry.register(new KbeCandidateReadsGrader()); +} diff --git a/.github/workflows/evals/kbe-search.test.mjs b/.github/workflows/evals/kbe-search.test.mjs new file mode 100644 index 00000000000000..d31f4f3bc70cff --- /dev/null +++ b/.github/workflows/evals/kbe-search.test.mjs @@ -0,0 +1,226 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import test from "node:test"; + +import { registerGraders } from "./kbe-candidate-reads-grader.mjs"; + +const require = createRequire(import.meta.url); +const { runGhApi, searchKbeIssues } = require("./search-kbe-issues.cjs"); +const testToken = "test-token"; + +async function productionScript() { + const workflow = await readFile(new URL("../ci-failure-scan.md", import.meta.url), "utf8"); + const scriptMatch = workflow.match( + /^ search-kbe-issues:\r?\n[\s\S]*?^ script: \|\r?\n(?