Run every skill against a 3-model matrix (Opus 4.8, GPT 5.5, Sonnet 4.6) with a cross-family judge that is never the executing model (default judge = latest Opus). Adds eval-models.json as the single source of truth, resolve-judge.mjs to derive model/judge pairs, preflight-models.mjs and a ListModels command for availability checks (without exposing the full model roster), and publish-vally-data.mjs for carrying run history to skills-data. - eng/eval-models.json: model matrix + judge map (Sonnet 4.6) - eng/resolve-judge.mjs: judge/matrix/required/selftest resolver - eng/preflight-models.mjs + ListModelsCommand.cs: privacy-safe preflight - vally-evaluation.yml: multi-model fan-out; redirect verbose trajectories to per-eval vally.log + tail-on-failure; 8-token selection pool parity - .NET CLI defaults + README bumped claude-opus-4.6 -> claude-opus-4.8 - vally-adapter: adapt.mjs/run-vally-evals.sh default Sonnet 4.6 - dashboard + InvestigatingResults.md updates Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
25 KiB
Investigating Evaluation Results
This guide is intended primarily for AI agents investigating skill evaluation failures, though humans will find it useful too. It documents the results.json schema, common failure patterns, and recommended fixes.
Using this guide with an AI agent
This document is designed to be read by AI coding agents. When a skill evaluation has failures, the PR comment includes a ready-to-use prompt — just copy and paste it to your AI agent. The agent will download the artifacts, read this guide, analyze the results, and suggest fixes.
If you need to run the investigation manually, follow the Quick start below.
Quick start
- Download the results artifact:
gh run download <run-id> --repo dotnet/skills --pattern "skill-validator-results-*" --dir <path> - Read
summary.mdfirst for a quick overview of which scenarios passed/failed - Read
results.jsonfor the full metrics, agent output, assertions, and judge reasoning - Identify the failure pattern using the categories below — most failures match multiple patterns; fix them in priority order (timeouts first, then activation, then quality/rubric issues)
- Apply the fix and re-run with
/evaluate
No artifacts at all? If the workflow went red but produced no
skill-validator-results-*artifacts, the run likely failed before evaluation. Check thepreflight-modelsjob: it runsskill-validator list-models --require <ids>once, up front, and fails fast if any configured model id (agent or judge, fromeng/eval-models.json) is unavailable to the CI Copilot token. Its log names the missing id(s) only (plus an available count — the full model roster is deliberately withheld from logs). Fixeng/eval-models.json(the single source of truth for model/judge ids) and re-run — there is nothing to investigate inresults.jsonfor this class of failure because the eval matrix never started.
Finding the artifacts
Via CLI (recommended for AI agents)
Extract the workflow run ID from the Full results link in the PR eval comment (e.g., https://github.com/dotnet/skills/actions/runs/23520818616 → 23520818616), then:
gh run download <run-id> --repo dotnet/skills --pattern "skill-validator-results-*" --dir ./eval-results
This downloads all result artifacts into subdirectories, each containing results.json and summary.md.
Note: The
--patternflag is important — without it,ghwill attempt to download all workflow artifacts including non-zip files (e.g.,.tar.gz), which causes an extraction error and a non-zero exit code even though the eval results download successfully.
Via browser
From the PR comment, click the Full results link to open the GitHub Actions workflow run. Then:
- Click on any job (e.g.,
evaluate (mcp-csharp-debug)) - Expand the Upload results step
- Find the
Artifact download URLin the log output - Download and extract
Alternatively, scroll to the bottom of the workflow run summary page and download from the Artifacts section.
Understanding results.json
Each file contains a top-level object with:
| Field | Description |
|---|---|
model |
Model used for agent runs |
judgeModel |
Model used for judging |
timestamp |
When the results were written (UTC) |
verdicts[] |
Array of per-skill results |
Verdict structure
Each verdict contains:
| Field | Description |
|---|---|
skillName |
Name of the skill being evaluated |
passed |
Overall pass/fail |
scenarios[] |
Array of per-scenario comparisons |
overfittingResult |
Overfitting analysis (if enabled) |
Scenario structure
Each scenario includes two required runs (baseline + isolated). It may also include an optional plugin run, and their comparison:
| Field | Description |
|---|---|
scenarioName |
Human-readable scenario name |
baseline |
Run without the skill |
skilledIsolated |
Run with only this skill loaded |
skilledPlugin |
Optional run with the full plugin loaded (may be null when plugin runs are disabled) |
timedOut |
Whether any run hit the timeout |
isolatedImprovementScore |
Weighted improvement (isolated vs baseline) |
pluginImprovementScore |
Weighted improvement (plugin vs baseline); optional and only computed when a plugin run is present |
isolatedBreakdown |
Per-metric contribution to the score (see below) |
pluginBreakdown |
Per-metric contribution to the score (see below); optional and only populated when a plugin run is present |
pairwiseResult |
Judge's rubric-by-rubric comparison |
perRunScores |
Per-run improvement scores as a flat array of numbers (one per run); when a plugin run is present, each value is min(isolated, plugin) for that run; when no plugin run is present (skilledPlugin is null), each value is the isolated improvement score for that run |
Note: Scenarios do not have a
passedfield. To determine pass/fail for an individual scenario, check whetherimprovementScore >= 0. This is the effective score: when no plugin run is present it equalsisolatedImprovementScore; when a plugin run is present it is the min of isolated and plugin scores. Thepassedfield exists only at the verdict level (per-skill).
Reused baselines: When the run was invoked with
--baseline-from, thebaselinearm is not executed — itsmetricsandjudgeResultcome from the shared baseline file produced earlier with--baseline-out(computed once, honoring--runs). Such scenarios are reported with thebaseline-reusedsession phase and areusedbaseline status. The baseline file is keyed on--modeland--judge-modelplus, per scenario, a SHA-256 of the prompt and a composite SHA-256 over its setup inputs (copied test files, explicit setup files, and setup commands) and its evaluation criteria (rubric, assertions, expect/reject tools, and turn/token/timeout limits); reuse fails fast if the agent model, judge model, or any prompt-plus-setup-plus-criteria identity is missing, so the baseline you compare against is always identity-matched and a shared prompt across cases with different fixtures or rubrics cannot cross-contaminate. Because the baseline output is identical across every skill/agent that consumes the same file, this acts as a shared control group and removes baseline run-to-run variance from cross-skill comparisons.
Decoupled runs and judging:
evaluate --no-judgeruns the agent arms and persistssessions.dbbut performs no judging and needs no baseline file, so baseline and treatment arms can run in one parallel pool. Each persisted session row carries abaseline_keycolumn — the same prompt-SHA-plus-target-SHA identity used for baseline reuse. A laterrejudge <treatment-dir> --baseline-dir <baseline-dir>pairs each treatment run with its baseline run by that key (preferring the matching run index), runs the same judges and gates an inlineevaluatewould, and writes baseline judge/pairwise results back to the baselinesessions.dband treatment judge results to the treatmentsessions.db. Baseline and treatment must share--model; the judge model resolves to--judge-model, else the treatment DB's persisted judge model, else the baseline DB's, and a mismatch between the two persisted judge models (without an explicit override) is rejected.
Breakdown fields
The isolatedBreakdown and pluginBreakdown objects show how each metric contributed to the improvement score. Each field is a raw delta (not yet weighted). The final score is computed as a weighted sum:
| Field | Weight | Range | Meaning |
|---|---|---|---|
qualityImprovement |
0.40 | [-1, 1] | Rubric-based quality delta |
overallJudgmentImprovement |
0.30 | [-1, 1] | Holistic judge assessment delta |
taskCompletionImprovement |
0.15 | {-1, 0, 1} | Did assertions pass? |
tokenReduction |
0.05 | [-1, 1] | Positive = fewer tokens (more efficient) |
errorReduction |
0.05 | [-1, 1] | Positive = fewer errors |
toolCallReduction |
0.025 | [-1, 1] | Positive = fewer tool calls |
timeReduction |
0.025 | [-1, 1] | Positive = faster |
A tokenReduction of -1.0 means the skilled run used ≥2× the baseline's tokens. This is common when a skill is loaded (the skill content itself consumes tokens) but is only -0.05 in the final score, so it rarely causes failure on its own.
Run metrics
Each of baseline, skilledIsolated, and skilledPlugin contains a metrics object:
| Field | Description |
|---|---|
timedOut |
Whether this run hit the timeout |
wallTimeMs |
Total wall-clock time |
taskCompleted |
Whether assertions passed |
tokenEstimate |
Total tokens estimated for this run. When usage events are present, computed as inputTokens + outputTokens; if inputTokens is zero but cache activity was reported, uses cacheReadTokens + outputTokens instead. Falls back to chars÷4 when no usage events are available. |
inputTokens |
Input tokens sent to the model |
outputTokens |
Output tokens generated by the model |
cacheReadTokens |
Prompt tokens served from cache |
cacheWriteTokens |
Prompt tokens written to cache |
judgeInputTokens |
Input tokens consumed by the LLM judge for this run |
judgeOutputTokens |
Output tokens generated by the LLM judge for this run |
judgeCacheReadTokens |
Judge prompt tokens served from cache |
judgeCacheWriteTokens |
Judge prompt tokens written to cache |
turnCount |
Number of agent turns |
toolCallCount |
Number of tool calls |
toolCallBreakdown |
Tool call counts by tool name |
errorCount |
Number of errors during the run |
assertionResults[] |
Per-assertion pass/fail with messages |
agentOutput |
The agent's final text output |
Tip: To get the total input/output token cost for an eval scenario, sum the token fields across baseline and skilled runs. For example, a scenario's total input tokens are
baseline.metrics.inputTokens + skilledIsolated.metrics.inputTokens(plusskilledPlugin.metrics.inputTokensif a plugin run is present). Thejudge*fields track the judging overhead separately.
Note: The quality scores shown in the summary table (e.g., "4.0/5") come from
baseline.judgeResult.overallScore,skilledIsolated.judgeResult.overallScore, etc. — they are on the run result object, not insidemetrics. When parsingresults.json, look forjudgeResult.overallScorealongsidemetricson each run.
eval.yaml scenario options
Several scenario-level options in eval.yaml are relevant when diagnosing failures:
| Option | Description |
|---|---|
timeout |
Maximum wall-clock time per run in seconds. Default is 120 seconds if omitted. Increase when skilled runs time out. |
reject_tools |
Array of tool names that will cause the run to fail if they are used (e.g., ["bash", "edit"]). This is enforced as a post-run assertion in the validator (it does not sandbox or block the tool calls), and is useful to force the agent to explain rather than explore/build, leveling the playing field between baseline and skilled runs. |
setup.files |
Array of files to create before the run. Gives the agent concrete code to work with, reducing variance from different scaffolding strategies. |
Common failure patterns
1. Timeout with empty output
Symptoms:
timedOut: trueagentOutputis empty or just\n\n- All assertions fail
toolCallBreakdownshowsbashusage
Cause: The model spent its entire time budget running shell commands (e.g., dotnet new, dotnet add package, exploring NuGet contents) and never produced user-facing text.
Fixes:
- Increase
timeoutineval.yaml— 180s is often not enough for scenarios that involve code generation. Try 360s. - Restructure the prompt to discourage bash exploration (e.g., "Show me the code" rather than "Create a project")
- Add
reject_tools: ["bash"]if the scenario should be answerable without shell commands
2. Baseline already bad
Symptoms:
- Baseline scores are very low (1.0–2.0/5)
- Skilled scores are also low
- Quality improvement shows 0 or negative
Cause: The question is too hard for the model even without the skill. The skill can't fix what the model can't do.
Fixes:
- Simplify the scenario prompt
- Verify the baseline is working by examining
baseline.metrics.agentOutput - Consider whether the scenario is testing the right thing
3. High variance across runs
Symptoms:
perRunScorescontains both positive and negative values (e.g.,[0.07, -0.85, 0.04])- A spread greater than ~0.3 between min and max scores suggests problematic variance
- Results flip between passing and failing across eval runs
- Isolated and plugin scores disagree
Cause: LLM non-determinism. The model takes different strategies on different runs.
Fixes:
- Increase
--runsfor more statistical stability (5 is the default; consider 7–10 for noisy scenarios) - Tighten the prompt to reduce the space of valid strategies
- Add
setup.filesto give the model concrete files to work with rather than letting it scaffold from scratch
4. Quality unchanged but weighted score negative
Symptoms:
- Footnote says "Quality unchanged but weighted score is -X% due to: judgment, tokens, tool calls"
- The skilled output is roughly as good as baseline
Cause: The skill adds token overhead (the skill content itself uses tokens) but doesn't improve quality enough to offset it.
Fixes:
- Improve the skill content to produce clearly better output for this scenario
- Reduce skill size — shorter skills have less token overhead
- Check if the rubric matches what the skill actually teaches
5. Skill not activated
Symptoms:
- Skills Loaded column shows
⚠️ NOT ACTIVATED skillActivationIsolatedand/orskillActivationPluginfields in results.json showactivated: false(or the legacyskillActivationalias)detectedSkillsis empty orskillEventCountis 0- The skilled run metrics look similar to baseline (the agent ran normally but without the skill's guidance)
Cause: The agent runtime didn't select the skill for this prompt. The skill's frontmatter description didn't match.
Fixes:
- Update the skill's
descriptionin SKILL.md frontmatter to better match the scenario prompt - Make sure the description includes keywords from the scenario
- Check the scenario itself has sufficient information that the agent can reason that it needs the skill. (It should not cheat and suggest the skill.)
Plugin-arm-only non-activation (skill-menu budget overflow). If a skill activates reliably in the isolated arm but consistently fails to activate in the plugin arm (
skillActivationIsolated.activated: truebutskillActivationPlugin.activated: false, with emptydetectedSkills), the cause is usually not the description text — it may never be shown. The Copilot CLI renders the model-facing<available_skills>menu under a hard 15,000-character budget (the agent SDK'sSKILL_CHAR_BUDGET, default15e3). Skills are listed alphabetically by name and emitted with their full<description>only until the budget is exhausted; every skill past the cut-off collapses to a bare name with no description and can no longer be reliably model-activated. In a large plugin, an alphabetically-late skill (e.g.run-tests,test-*) silently loses its description in the plugin menu even though it is fine in isolation.Fixes for this case (description tuning will not help — the text is not in the menu):
- Mark reference / agent-orchestrated skills that are never meant to be model-invoked from a user prompt with
disable-model-invocation: true. The CLI drops them from the menu entirely, freeing budget for the skills that should be discoverable. (They remain invocable by explicit name.)- Reduce the plugin's aggregate skill-menu footprint so its model-invocable skills fit under the budget. The
checkcommand enforces this viaSkillProfiler.MaxRenderedSkillMenuLength(15,000), summing each model-invocable skill's rendered<skill>block (name + description + location + markup, viaSkillProfiler.RenderedSkillMenuCost) — not just the raw description — and counting only skills withoutdisable-model-invocation: true. Counting the rendered block makes passingchecka faithful proxy for "fits in the real CLI menu budget".- As a last resort, consolidate overlapping skills so the plugin exposes fewer model-invocable entries.
6. Rubric penalizes valid alternatives
Symptoms:
- Pairwise judge picks baseline over skill
- Both outputs are correct but use different approaches
pairwiseResult.rubricResultsshows the rubric criterion is too narrow
Cause: The rubric item favors one specific approach (e.g., step-by-step UI walkthrough) over an equally valid alternative (e.g., single CLI command).
Fixes:
- Broaden the rubric to explicitly accept multiple valid approaches
- Example: Instead of
"Shows step-by-step UI configuration", use"Explains how to connect — either as a single CLI command or via the UI configuration"
7. Judge regressions on close calls
Symptoms:
overallJudgmentImprovementis -0.4 even though quality scores are similar- Pairwise judge is inconsistent between position-swapped runs
Cause: When outputs are nearly equal, the judge's position bias can dominate. The position-swap mitigation defaults to "tie" on inconsistency, but the weighted scoring still penalizes.
Fixes:
- This is usually noise — re-run the eval to see if it persists
- If it consistently happens, improve the skill to produce clearly differentiated output
8. Baseline already good (no headroom)
Symptoms:
- Baseline scores are high (4.5–5.0/5)
- Skilled scores are similar or slightly lower
perRunScoresare consistently negative (e.g.,[-0.42, -0.73, -0.47])- Breakdown shows negative
tokenReductionandtoolCallReduction(skill overhead) but no quality gain
Cause: The model already knows this topic well from training data. The skill can't improve on an already-excellent answer, and the overhead of loading the skill (extra tokens, tool calls) causes a net regression.
Fixes:
- Add a
reject_toolsconstraint (e.g.,["bash", "edit"]) so the eval fails if either baseline or skilled agent uses those tools — this keeps the comparison focused on answer quality instead of tool-induced overhead - Make the scenario harder so the baseline struggles — add complexity, edge cases, or constraints that require the skill's specific knowledge
- Rewrite the prompt to be purely diagnostic (e.g., "Don't modify any files — just explain the root cause") to prevent the agent from spending time on tool calls
- Remove the scenario if the model consistently scores 5.0/5 without the skill — it isn't testing the skill's value
When multiple patterns apply
Most failing scenarios match 2–3 patterns simultaneously (e.g., timeout + token overhead + high variance). Fix them in this priority order:
- Timeouts (#1) — if the model can't finish, nothing else matters. Increase timeout first.
- Skill not activated (#5) — if the skill never loaded, fix the description before tuning anything else.
- Baseline already bad (#2) — if the baseline scores ≤2.0/5, the scenario may need simplification regardless of the skill.
- Baseline already good (#8) — if the baseline scores ≥4.5/5, consider adding
reject_tools, making the scenario harder, or removing it. - High variance (#3) — if
perRunScoresare unstable, a single eval run is unreliable. Re-run before concluding the skill is broken. - Rubric/judgment issues (#6, #7) — once the runs are stable, tune the rubric.
- Token overhead (#4) — only optimize if quality is already good but the weighted score is marginally negative.
Improving the skill vs. gaming the eval
When investigating failures, the goal is to make the skill more useful to users — not simply to make the eval score go up. Score improvement should be evidence of a better skill, not an end in itself.
Legitimate fixes (improve the skill)
- Better skill content, structure, or examples
- Better frontmatter
descriptionso the skill activates on relevant prompts - Removing a scenario where the baseline already scores perfectly (the skill genuinely adds no value)
- Adding
setup.filesso the scenario tests what was intended rather than scaffolding ability
Illegitimate fixes (game the eval)
- Relaxing rubric criteria so both runs score higher for less
- Rewriting rubric items to match what the skill happens to produce rather than what a good answer should contain
- Softening prompt expectations to avoid exposing a real skill weakness
- Adding
reject_toolsto hide behavioral divergences between baseline and skilled runs (e.g., baseline explains while skilled run edits files — constraining tools makes the scores converge but doesn't fix the underlying issue)
Gray area (use judgment)
- Tightening a prompt to reduce ambiguity is legitimate if the prompt is genuinely unclear, but illegitimate if done to steer toward the skill's strengths
- Broadening a rubric to accept multiple valid approaches (#6 above) is legitimate; broadening it to accept wrong approaches is not
- Removing a scenario because the baseline already aces it is an honest admission; removing it because the skill makes things worse is hiding a problem
When in doubt, ask: "Would this change make the skill more useful to a real user, or does it just make the number go up?"
Analyzing results with an AI agent
The results.json file is designed to be machine-readable. An AI agent can:
- Parse the JSON and extract metrics for each scenario
- Compare baseline vs skilled metrics to identify regressions
- Read
agentOutputto see what the model actually produced - Check
assertionResultsto see which assertions failed - Read
pairwiseResult.rubricResultsfor the judge's per-criterion reasoning - Examine
perRunScoresto assess variance - Look at
toolCallBreakdownto understand what the model spent time on - Cross-reference
isolatedBreakdownto see which metrics drove the score - Review
overfittingResultif present — checkrubricAssessmentsfor items classified as"technique"(the rubric enforces a specific approach rather than testing an outcome). These are candidates for broadening. Also checkcrossScenarioIssuesfor systemic concerns about the eval design.
Example analysis script
Note: Save this as a
.pyfile rather than running viapython -c "..."— the nested quotes in f-string dictionary access are difficult to escape on the command line.
import json
def analyze(path):
with open(path) as f:
data = json.load(f)
for verdict in data['verdicts']:
print(f"=== {verdict['skillName']} (passed={verdict['passed']}) ===")
for scenario in verdict['scenarios']:
name = scenario['scenarioName']
bl_metrics = scenario['baseline']['metrics']
sk_metrics = scenario['skilledIsolated']['metrics']
bl_quality = scenario['baseline'].get('judgeResult', {}).get('overallScore', '?')
sk_quality = scenario['skilledIsolated'].get('judgeResult', {}).get('overallScore', '?')
improvement = scenario.get('improvementScore', 0)
print(f"\n--- {name} ---")
print(f" Quality: baseline={bl_quality}/5, skilled={sk_quality}/5")
print(f" Baseline: timedOut={bl_metrics['timedOut']}, tokens={bl_metrics.get('tokenEstimate', 0)}")
print(f" input={bl_metrics.get('inputTokens', 0)}, output={bl_metrics.get('outputTokens', 0)}")
print(f" Skilled: timedOut={sk_metrics['timedOut']}, tokens={sk_metrics.get('tokenEstimate', 0)}")
print(f" input={sk_metrics.get('inputTokens', 0)}, output={sk_metrics.get('outputTokens', 0)}")
print(f" Improvement: {improvement:.1%}")
# perRunScores is a flat list of numbers (one per run)
per_run = scenario.get('perRunScores', [])
if per_run:
formatted = ', '.join(f'{s:.2f}' for s in per_run)
print(f" Per-run scores: [{formatted}]")
for a in sk_metrics.get('assertionResults', []):
status = 'PASS' if a['passed'] else 'FAIL'
print(f" Assertion [{status}]: {a['message']}")
analyze('results.json')
See also
- skill-validator README — CLI usage, eval file format, scoring weights
- Overfitting detection — how overfitting scores are computed