Skip to content

Commit ef384ce

Browse files
committed
Address Copilot review comments (round 4)
- DESIGN doc: mark Targeted Skill Activation TODO as implemented - ValidateCommand: fix misleading --plugin-dir comment - Comparator: set PluginScore to null when no plugin runs exist - ComparatorTests: copy missing fields in rehydrated ScenarioComparison - AgentRunner: fix GetPluginClient docstring, route RunAgentCore through it - AgentRunner: stage isolated skill to temp dir for true single-skill isolation - dashboard.js: fix triple chart comment to match actual series names - RunnerTests: update assertions for new isolated skill staging behavior
1 parent f70d7be commit ef384ce

7 files changed

Lines changed: 197 additions & 21 deletions

File tree

eng/dashboard/dashboard.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,7 @@
523523
}
524524
}
525525

526-
// Helper: create a triple line chart (baseline, isolated, plugin)
526+
// Helper: create a triple line chart with three series (e.g., Skill / Plugin / Vanilla quality)
527527
function createTripleChart(container, title, entries, nameA, nameB, nameC, labelA, labelB, labelC, colorA, colorB, colorC) {
528528
const div = document.createElement('div');
529529
div.className = 'chart-container';
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# Design: Load Whole Plugin Instead of Single Skill
2+
3+
## Problem Statement
4+
5+
The skill validator evaluates whether individual skills improve agent performance. Previously, it loaded **only the skill under test** via `SessionConfig.SkillDirectories`. In production, users load entire **plugins** (all skills, agents, MCP servers via `plugin.json`). This meant the validator evaluated skills in unrealistic isolation — cross-skill interactions were never tested.
6+
7+
## Three-Run Evaluation Model
8+
9+
Every skill evaluation performs **three concurrent agent runs**:
10+
11+
| Run Type | Skills Loaded | Client | Purpose |
12+
|----------|--------------|--------|---------|
13+
| **Baseline** | None (`SkillDirectories = []`) | No-plugin client | Vanilla agent reference |
14+
| **Skilled-Isolated** | Only the skill under test (`SkillDirectories = [skillParent]`) | No-plugin client | Isolated skill improvement (original behavior) |
15+
| **Skilled-Plugin** | Entire plugin via `--plugin-dir` | Per-plugin client | Production-realistic — skill within full plugin context |
16+
17+
The **final verdict** uses `min(skilled-isolated, skilled-plugin)` — a skill passes only if it performs well both in isolation and within its plugin context.
18+
19+
## Architecture
20+
21+
> All file paths below are relative to `eng/skill-validator/src/` unless noted otherwise.
22+
23+
### Plugin Loading via `SkillDirectories` (Manual Enumeration)
24+
25+
~~Plugins were originally loaded via the CLI's `--plugin-dir` flag through `CopilotClientOptions.CliArgs`. However, `--plugin-dir` is **not honored by the SDK**.~~
26+
27+
Instead, plugin skills are loaded **manually**: `BuildSessionConfig` reads `plugin.json`, resolves the plugin's `skills` path, and passes that directory via `SessionConfig.SkillDirectories`. The SDK scans it for subdirectories containing `SKILL.md` files — the same code path used for isolated skill loading, just pointing at the entire plugin's skills directory instead of a single skill's parent.
28+
29+
MCP servers from `plugin.json` are also passed through `SessionConfig.McpServers` (resolved during skill discovery via `FindPluginMcpServers`).
30+
31+
### Shared CopilotClient
32+
33+
Since `--plugin-dir` is not used, **all runs share the same `CopilotClient`**. There is no per-plugin client pool. Baseline, skilled-isolated, and skilled-plugin runs all use `GetSharedClient()`. The difference between run types is entirely in `SessionConfig` (what `SkillDirectories` and `McpServers` are set to).
34+
35+
**File**: `Services/AgentRunner.cs`
36+
- `GetSharedClient(bool verbose)`: Returns the shared no-plugin client; used by all runs
37+
- `ResolvePluginSkillDirectories(string pluginRoot)`: Reads `plugin.json`, resolves skills path, returns it for `SkillDirectories`
38+
- `StopAllClients()`: Stops all clients at shutdown
39+
- `CaptureGitHubToken()`: Captures token once at startup, passes to all clients via `CopilotClientOptions.GitHubToken`
40+
41+
### Session Config (`BuildSessionConfig`)
42+
43+
**File**: `Services/AgentRunner.cs`
44+
45+
Signature: `BuildSessionConfig(SkillInfo? skill, string? pluginRoot, string model, string workDir, ...)`
46+
47+
| Parameter combination | Run type | `SkillDirectories` | `McpServers` |
48+
|----------------------|----------|-------------------|-------------|
49+
| `skill=null, pluginRoot=null` | Baseline | `[]` | `null` |
50+
| `skill=X, pluginRoot=null` | Skilled-isolated | `[skillPath]` | From skill's `McpServers` |
51+
| `skill=X, pluginRoot=Y` | Skilled-plugin | `[resolvedSkillsDir]` | From skill's `McpServers` |
52+
53+
`resolvedSkillsDir` is computed by `ResolvePluginSkillDirectories`: reads `plugin.json`, resolves the `skills` path relative to the plugin root, and returns it. The SDK scans this directory for subdirectories containing `SKILL.md`.
54+
55+
Each session gets a unique `ConfigDir` (temp directory) for isolation.
56+
57+
### Permission Checking
58+
59+
**File**: `Services/AgentRunner.cs``CheckPermission(request, workDir, skillPath, pluginRoot)`
60+
61+
Allows access to `workDir`, `skillPath`, and `pluginRoot` directories. The `pluginRoot` parameter was added so the agent can read sibling skills from the plugin during plugin runs.
62+
63+
### Orchestration
64+
65+
**File**: `Commands/ValidateCommand.cs`
66+
67+
1. **Discovery**: `SkillDiscovery.GroupSkillsByPlugin(allSkills)` groups skills by plugin root. Skills without a `plugin.json` ancestor are errors.
68+
2. **Client creation**: Pre-creates a `CopilotClient` per plugin with `--plugin-dir`.
69+
3. **Execution** (`ExecuteRun`): Launches 3 concurrent `RunAgent` calls, evaluates assertions/constraints on all 3, judges all 3 independently, runs pairwise judge on baseline vs. worse-scoring skilled run.
70+
4. **Aggregation** (`ExecuteScenario`): Averages N runs per type, computes `min(isolated, plugin)` per-run scores for confidence intervals.
71+
5. **Verdict** (`EvaluateSkill`): Checks activation in both skilled runs independently — fails if either is not activated.
72+
73+
### Scoring
74+
75+
**File**: `Services/Comparator.cs`
76+
77+
- `CompareScenario` is unchanged — called twice (baseline vs. isolated, baseline vs. plugin)
78+
- `ComputeVerdict` exposes both `IsolatedScore` and `PluginScore` on `SkillVerdict`
79+
- Effective `ImprovementScore` is `min(isolated, plugin)` per scenario
80+
- `ComputeNormalizedGain` uses `min(isolatedQuality, pluginQuality)` for the final gain calculation
81+
82+
### Models
83+
84+
**File**: `Models/Models.cs`
85+
86+
```csharp
87+
public sealed class ScenarioComparison
88+
{
89+
public required RunResult Baseline { get; init; }
90+
public required RunResult SkilledIsolated { get; init; }
91+
public RunResult? SkilledPlugin { get; init; } // nullable for CompareScenario output
92+
public double ImprovementScore { get; init; } // min(isolated, plugin)
93+
public double IsolatedImprovementScore { get; init; }
94+
public double PluginImprovementScore { get; init; }
95+
public SkillActivationInfo? SkillActivationIsolated { get; set; }
96+
public SkillActivationInfo? SkillActivationPlugin { get; set; }
97+
// ... other fields
98+
}
99+
100+
public sealed class SkillVerdict
101+
{
102+
public double? IsolatedScore { get; set; } // avg isolated improvement
103+
public double? PluginScore { get; set; } // avg plugin improvement
104+
// ... other fields
105+
}
106+
```
107+
108+
### Reporting & Dashboard
109+
110+
**File**: `Services/Reporter.cs` — Console output shows three columns (baseline, isolated, plugin) with an "Effective score: min(...)" summary line.
111+
112+
**File**: `eng/dashboard/dashboard.js``createTripleChart` renders 3 lines per skill (gray=baseline, blue=isolated, green=plugin).
113+
114+
**File**: `eng/dashboard/generate-benchmark-data.ps1` — Emits three data series per skill.
115+
116+
## Targeted Skill Activation Detection (Implemented)
117+
118+
**File**: `Services/MetricsCollector.cs`
119+
120+
`ExtractSkillActivation` accepts an optional `targetSkillName` parameter. When set (as it is for both isolated and plugin runs), only events matching the target skill count towards activation — preventing sibling-skill false positives in plugin runs. Callers in `ValidateCommand.ExecuteRun` pass `skill.Name` for both the isolated and plugin activation checks. The `ExtraTools` heuristic remains as a fallback for skills that don't emit `SkillInvokedEvent`.
121+
122+
## Execution Flow
123+
124+
```
125+
ValidateCommand.Run
126+
├── DiscoverSkills
127+
├── GroupSkillsByPlugin → { pluginRoot → (plugin, [skills]) }
128+
├── GetSharedClient() → used by judge & baseline runs
129+
130+
├── for each skill (parallel by --parallel-skills):
131+
│ └── EvaluateSkill
132+
│ └── for each scenario (parallel by --parallel-scenarios):
133+
│ └── ExecuteScenario
134+
│ └── for each run (parallel by --parallel-runs):
135+
│ ├── RunAgent(baseline) → shared client, SkillDirectories=[]
136+
│ ├── RunAgent(skilled-isolated) → shared client, SkillDirectories=[skillParent]
137+
│ ├── RunAgent(skilled-plugin) → shared client, SkillDirectories=[pluginSkillsDir]
138+
│ ├── Judge all 3 independently
139+
│ ├── PairwiseJudge: baseline vs. worse-scoring skilled
140+
│ └── Activation check on both skilled runs
141+
142+
└── StopAllClients + CleanupWorkDirs
143+
```
144+
145+
## Edge Cases
146+
147+
1. **Standalone skills (no plugin)**: Treated as errors by `GroupSkillsByPlugin`. All skills in this repository are under `plugins/*/skills/`.
148+
149+
2. **Multiple plugins**: Each plugin's skills are resolved independently via `ResolvePluginSkillDirectories`. All share the same client.
150+
151+
3. **Cost**: 4 judge calls per scenario run (baseline + isolated + plugin + pairwise) — 2× the previous cost.
152+
153+
4. **Resource overhead**: Single CLI process shared by all runs.

eng/skill-validator/src/Commands/ValidateCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -701,7 +701,7 @@ private static async Task<RunExecutionResult> ExecuteRun(
701701
// 2. Skilled-isolated: single skill only (current behavior)
702702
AgentRunner.RunAgent(new RunOptions(scenario, skill, skill.EvalPath, config.Model, config.Verbose,
703703
PluginRoot: null, Log: runLog)),
704-
// 3. Skilled-plugin: entire plugin loaded via --plugin-dir
704+
// 3. Skilled-plugin: load entire plugin from plugin root directory
705705
AgentRunner.RunAgent(new RunOptions(scenario, skill, skill.EvalPath, config.Model, config.Verbose,
706706
PluginRoot: pluginRoot, Log: runLog)));
707707
var baselineMetrics = agentTasks[0];

eng/skill-validator/src/Services/AgentRunner.cs

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,10 @@ public static void CaptureGitHubToken()
4040
}
4141

4242
/// <summary>
43-
/// Returns a CopilotClient configured for the given plugin.
44-
/// The client is created once per plugin root and reused.
45-
/// Note: --plugin-dir is NOT honored by the SDK, so all clients share
46-
/// the same configuration. Plugin skills are loaded manually via
47-
/// SkillDirectories in BuildSessionConfig instead.
43+
/// Returns a shared CopilotClient, keyed by plugin root for future
44+
/// per-plugin configuration. Currently all clients share the same
45+
/// options because --plugin-dir is NOT honored by the SDK; plugin
46+
/// skills are loaded via SkillDirectories in BuildSessionConfig.
4847
/// </summary>
4948
public static async Task<CopilotClient> GetPluginClient(
5049
string? pluginRoot, bool verbose)
@@ -91,10 +90,10 @@ public static Task<CopilotClient> GetSharedClient(bool verbose)
9190
/// <summary>Stop all plugin clients (including the no-plugin client).</summary>
9291
public static async Task StopAllClients()
9392
{
94-
foreach (var (_, client) in _pluginClients)
93+
foreach (var (key, client) in _pluginClients)
9594
{
9695
try { await client.StopAsync(); }
97-
catch { /* best effort */ }
96+
catch (Exception ex) { Console.Error.WriteLine($"Warning: failed to stop client '{key}': {ex.Message}"); }
9897
}
9998
_pluginClients.Clear();
10099
}
@@ -290,8 +289,10 @@ internal static SessionConfig BuildSessionConfig(
290289

291290
// Three run types:
292291
// 1. Baseline (skill == null, pluginRoot == null): no skills, no MCP.
293-
// 2. Skilled-isolated (skill != null, pluginRoot == null): single skill via SkillDirectories.
294-
// 3. Skilled-plugin (skill != null, pluginRoot != null): entire plugin loaded manually
292+
// 2. Skilled-isolated (skill != null, pluginRoot == null): ONLY the target skill
293+
// is loaded — we stage it into a temp directory so the SDK doesn't
294+
// discover sibling skills from the same parent.
295+
// 3. Skilled-plugin (skill != null, pluginRoot != null): entire plugin loaded
295296
// via SkillDirectories (--plugin-dir is NOT honored by SDK).
296297
//
297298
// For skilled-plugin, we enumerate all skill directories from plugin.json
@@ -303,7 +304,17 @@ internal static SessionConfig BuildSessionConfig(
303304
}
304305
else if (skill is not null)
305306
{
306-
skillDirs = [skillPath!];
307+
// Stage the single skill into a temp directory so the SDK discovers
308+
// only this skill — not every sibling that shares the same parent.
309+
var isoStageDir = Path.Combine(Path.GetTempPath(), $"sv-iso-{Guid.NewGuid():N}");
310+
Directory.CreateDirectory(isoStageDir);
311+
_workDirs.Add(isoStageDir);
312+
313+
var stagedSkillDir = Path.Combine(isoStageDir, Path.GetFileName(skill.Path));
314+
Directory.CreateDirectory(stagedSkillDir);
315+
File.WriteAllText(Path.Combine(stagedSkillDir, "SKILL.md"), skill.SkillMdContent);
316+
317+
skillDirs = [isoStageDir];
307318
}
308319
else
309320
{
@@ -390,9 +401,9 @@ private static async Task<RunMetrics> RunAgentCore(RunOptions options, Cancellat
390401

391402
try
392403
{
393-
// All runs use the shared client — plugin skills are loaded manually
404+
// All runs use the same client — plugin skills are loaded manually
394405
// via SkillDirectories (--plugin-dir is not honored by SDK).
395-
var client = await GetSharedClient(options.Verbose);
406+
var client = await GetPluginClient(options.PluginRoot, options.Verbose);
396407

397408
await using var session = await client.CreateSessionAsync(
398409
BuildSessionConfig(options.Skill, options.PluginRoot, options.Model, workDir, options.Skill?.McpServers, options.AdditionalSkills, options.Log, options.Verbose));

eng/skill-validator/src/Services/Comparator.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,9 @@ public static SkillVerdict ComputeVerdict(
138138
ConfidenceInterval = ci,
139139
IsSignificant = significant,
140140
IsolatedScore = comparisons.Average(c => c.IsolatedImprovementScore),
141-
PluginScore = comparisons.Average(c => c.PluginImprovementScore),
141+
PluginScore = comparisons.Any(c => c.SkilledPlugin != null)
142+
? comparisons.Where(c => c.SkilledPlugin != null).Average(c => c.PluginImprovementScore)
143+
: null,
142144
Reason = reason,
143145
FailureKind = passed ? null : "threshold",
144146
};

eng/skill-validator/tests/ComparatorTests.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,10 @@ public void FailsWhenPluginRunRegressesTaskCompletion()
216216
SkilledPlugin = MakeRunResult(taskCompleted: false, tokenEstimate: 100, overallScore: 5),
217217
ImprovementScore = comparison.ImprovementScore,
218218
Breakdown = comparison.Breakdown,
219+
IsolatedImprovementScore = comparison.IsolatedImprovementScore,
220+
PluginImprovementScore = comparison.PluginImprovementScore,
221+
IsolatedBreakdown = comparison.IsolatedBreakdown,
222+
PluginBreakdown = comparison.PluginBreakdown,
219223
};
220224

221225
var verdict = Comparator.ComputeVerdict(MockSkill, [comparison], 0.0, true);

eng/skill-validator/tests/RunnerTests.cs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,16 @@ public class BuildSessionConfigTests
1919
McpServers: null);
2020

2121
[Fact]
22-
public void SetsSkillDirectoriesToParentOfSkillPath()
22+
public void SetsSkillDirectoriesToStagedIsolationDir()
2323
{
2424
var config = AgentRunner.BuildSessionConfig(MockSkill, null, "gpt-4.1", "C:\\tmp\\work");
2525
Assert.Single(config.SkillDirectories!);
26-
Assert.Equal(Path.GetDirectoryName(MockSkill.Path), config.SkillDirectories![0]);
26+
// Isolated skills are now staged into a temp directory so the SDK
27+
// discovers only the target skill, not siblings.
28+
var stageDir = config.SkillDirectories![0];
29+
Assert.StartsWith(Path.GetTempPath(), stageDir);
30+
var stagedSkillDir = Path.Combine(stageDir, Path.GetFileName(MockSkill.Path));
31+
Assert.True(File.Exists(Path.Combine(stagedSkillDir, "SKILL.md")));
2732
}
2833

2934
[Fact]
@@ -53,9 +58,10 @@ public async Task AdditionalSkillsStageOnlyVerifiedSkillDirs()
5358
var config = AgentRunner.BuildSessionConfig(MockSkill, pluginRoot: null, "gpt-4.1", "C:\\tmp\\work",
5459
additionalSkills: additionalSkills);
5560

56-
// Primary skill parent + one staging directory for additional skills
61+
// Primary skill staged dir + one staging directory for additional skills
5762
Assert.Equal(2, config.SkillDirectories!.Count);
58-
Assert.Equal(Path.GetDirectoryName(MockSkill.Path), config.SkillDirectories[0]);
63+
// First dir is the isolated skill staging directory
64+
Assert.StartsWith(Path.GetTempPath(), config.SkillDirectories[0]);
5965

6066
var stageDir = config.SkillDirectories[1];
6167
Assert.StartsWith(Path.GetTempPath(), stageDir);
@@ -311,9 +317,9 @@ public void PluginRootWithPluginJsonResolvesSkillDirectories()
311317
public void PluginRootNullPreservesSkillDirectories()
312318
{
313319
var config = AgentRunner.BuildSessionConfig(MockSkill, null, "gpt-4.1", "C:\\tmp\\work");
314-
// Without pluginRoot, SkillDirectories should contain the skill's parent
320+
// Without pluginRoot, SkillDirectories should contain the staged isolation dir
315321
Assert.Single(config.SkillDirectories!);
316-
Assert.Equal(Path.GetDirectoryName(MockSkill.Path), config.SkillDirectories![0]);
322+
Assert.StartsWith(Path.GetTempPath(), config.SkillDirectories![0]);
317323
}
318324
}
319325

0 commit comments

Comments
 (0)