|
| 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. |
0 commit comments