microsoft/hve-core

Public

mirrored fromhttps://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/1873-devcontainer

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

scripts/evals/Modules/AffectedAgents.psm1

373lines · modecode

1# Copyright (c) Microsoft Corporation.
2# SPDX-License-Identifier: MIT
3#Requires -Version 7.0
4
5<#
6.SYNOPSIS
7 Resolve changed artifact paths to the set of parent-agent slugs whose
8 per-agent eval surface they affect.
9
10.DESCRIPTION
11 Module form of the slug-resolution logic consumed by
12 `Get-ChangedAIArtifact.ps1` (which embeds the result as the `affectedAgents`
13 field of the artifact manifest) and downstream Vally dispatch.
14
15 Resolution rules per input path:
16 1. Parent agent (`*.agent.md` whose YAML frontmatter does NOT set
17 `user-invocable: false`) -> returns `<slug>`.
18 2. Subagent (`*.agent.md` whose frontmatter sets `user-invocable: false`)
19 -> returns every parent slug that references the subagent under the
20 dependency map `subagents[]`.
21 3. Stimulus YAML (`evals/agent-behavior/stimuli/<slug>.yml`)
22 -> returns `<slug>`.
23 4. Instruction (`.github/instructions/<...>.instructions.md`)
24 -> returns every parent slug that references the file under the
25 dependency map `instructions[]`.
26 5. Skill (`.github/skills/<...>/<...>.md`)
27 -> returns every parent slug that references the skill under the
28 dependency map `skills[]`.
29 6. Anything else -> contributes nothing.
30
31 DD-09 compliance: parent-vs-subagent classification reads the agent file's
32 frontmatter `user-invocable` key. The historical `/subagents/` path
33 convention is informational only. No hardcoded allowlist participates.
34
35 The helper silently regenerates `logs/agent-dependency-map.json` when the
36 file is missing or older than the newest `.agent.md` under `.github/agents/`.
37#>
38
39Set-StrictMode -Version Latest
40$ErrorActionPreference = 'Stop'
41
42# Module-scoped cache for frontmatter classification, keyed by absolute file path.
43$script:FrontmatterCache = @{}
44
45function Resolve-RepoRoot {
46 [CmdletBinding()]
47 [OutputType([string])]
48 param([string]$Override)
49
50 if ($Override) { return (Resolve-Path -LiteralPath $Override).Path }
51 try {
52 $root = (& git rev-parse --show-toplevel 2>$null).Trim()
53 if ($LASTEXITCODE -eq 0 -and $root) { return $root }
54 } catch {
55 Write-Verbose "git rev-parse failed: $($_.Exception.Message)"
56 }
57 return (Get-Location).Path
58}
59
60function ConvertTo-NormalizedPath {
61 [CmdletBinding()]
62 [OutputType([string])]
63 param(
64 [Parameter(Mandatory)] [string]$RepoRoot,
65 [Parameter(Mandatory)] [string]$Path
66 )
67
68 if ([string]::IsNullOrWhiteSpace($Path)) { return '' }
69 $candidate = $Path -replace '\\', '/'
70 if ([System.IO.Path]::IsPathRooted($candidate)) {
71 $rootFull = ([System.IO.Path]::GetFullPath($RepoRoot)) -replace '\\', '/'
72 $rootFull = $rootFull.TrimEnd('/')
73 $pathFull = ([System.IO.Path]::GetFullPath($candidate)) -replace '\\', '/'
74 if ($pathFull.StartsWith($rootFull + '/', [System.StringComparison]::OrdinalIgnoreCase)) {
75 return $pathFull.Substring($rootFull.Length + 1)
76 }
77 }
78 return $candidate.TrimStart('/')
79}
80
81function Test-IsAgentArtifactPath {
82 [CmdletBinding()]
83 [OutputType([bool])]
84 param([Parameter(Mandatory)] [string]$RelativePath)
85
86 return ($RelativePath -match '(?i)^\.github/agents/.+\.agent\.md$')
87}
88
89function Test-IsParentAgentByFrontmatter {
90 <#
91 .SYNOPSIS
92 Determine whether an agent file is a parent (user-invocable) under DD-09.
93
94 .DESCRIPTION
95 Reads the YAML frontmatter `user-invocable` key from the agent file on disk.
96 Returns $true when the key is absent or evaluates to anything other than
97 the boolean/string value `false`. When the file is missing on disk (for
98 example a deletion), the caller-supplied $DepMap is consulted as a
99 fallback: a slug present in the dep-map is assumed to be a parent agent
100 only when there is no subagent reverse-mapping evidence; otherwise
101 classification defers to the subagent code path.
102
103 Results are cached per absolute path to keep repeat lookups O(1).
104 #>
105 [CmdletBinding()]
106 [OutputType([bool])]
107 param(
108 [Parameter(Mandatory)] [string]$RepoRoot,
109 [Parameter(Mandatory)] [string]$RelativePath
110 )
111
112 $absPath = Join-Path -Path $RepoRoot -ChildPath $RelativePath
113 if ($script:FrontmatterCache.ContainsKey($absPath)) {
114 return [bool]$script:FrontmatterCache[$absPath]
115 }
116
117 if (-not (Test-Path -LiteralPath $absPath -PathType Leaf)) {
118 # File is missing (likely a delete-side path). Treat as parent so the
119 # eval surface remains visible; subagent reverse-lookup will run too
120 # and naturally produce no extra slugs when the file truly is a parent.
121 $script:FrontmatterCache[$absPath] = $true
122 return $true
123 }
124
125 try {
126 $raw = [System.IO.File]::ReadAllText($absPath)
127 } catch {
128 Write-Verbose "Failed to read '$absPath': $($_.Exception.Message)"
129 $script:FrontmatterCache[$absPath] = $true
130 return $true
131 }
132
133 if ($raw -notmatch '(?ms)^---\s*\r?\n(.*?)\r?\n---\s*(?:\r?\n|$)') {
134 $script:FrontmatterCache[$absPath] = $true
135 return $true
136 }
137
138 # Parse only the `user-invocable` line; avoids a full YAML dependency.
139 $block = $matches[1]
140 foreach ($line in ($block -split "\r?\n")) {
141 if ($line -match '^\s*user-invocable\s*:\s*(?<val>.+?)\s*$') {
142 $val = $matches['val'].Trim().Trim("'", '"').ToLowerInvariant()
143 $isParent = ($val -ne 'false')
144 $script:FrontmatterCache[$absPath] = $isParent
145 return $isParent
146 }
147 }
148
149 $script:FrontmatterCache[$absPath] = $true
150 return $true
151}
152
153function Get-StimulusSlug {
154 [CmdletBinding()]
155 [OutputType([string])]
156 param([Parameter(Mandatory)] [string]$RelativePath)
157
158 if ($RelativePath -match '(?i)^evals/agent-behavior/stimuli/(?<slug>[^/]+)\.ya?ml$') {
159 return $matches['slug']
160 }
161 return $null
162}
163
164function Test-IsIndirectArtifactPath {
165 [CmdletBinding()]
166 [OutputType([bool])]
167 param([Parameter(Mandatory)] [string]$RelativePath)
168
169 if ($RelativePath -match '(?i)^\.github/instructions/.+\.instructions\.md$') { return $true }
170 if ($RelativePath -match '(?i)^\.github/skills/.+\.md$') { return $true }
171 return $false
172}
173
174function Update-DepMapIfStale {
175 [CmdletBinding()]
176 [OutputType([string])]
177 param(
178 [Parameter(Mandatory)] [string]$RepoRoot,
179 [Parameter(Mandatory)] [string]$DepMapPath
180 )
181
182 $regenerate = -not (Test-Path -LiteralPath $DepMapPath -PathType Leaf)
183 if (-not $regenerate) {
184 $mapMTime = (Get-Item -LiteralPath $DepMapPath).LastWriteTimeUtc
185 $agentsRoot = Join-Path -Path $RepoRoot -ChildPath '.github/agents'
186 if (Test-Path -LiteralPath $agentsRoot -PathType Container) {
187 $newest = Get-ChildItem -Path $agentsRoot -Recurse -Filter '*.agent.md' -File -ErrorAction SilentlyContinue |
188 Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1
189 if ($newest -and $newest.LastWriteTimeUtc -gt $mapMTime) { $regenerate = $true }
190 }
191 }
192
193 if ($regenerate) {
194 $depMapScript = Join-Path -Path $RepoRoot -ChildPath 'scripts/evals/Get-AgentDependencyMap.ps1'
195 if (Test-Path -LiteralPath $depMapScript -PathType Leaf) {
196 Write-Verbose "Refreshing agent dependency map: $DepMapPath"
197 $outDir = Split-Path -Parent $DepMapPath
198 if (-not (Test-Path -LiteralPath $outDir -PathType Container)) {
199 New-Item -ItemType Directory -Path $outDir -Force | Out-Null
200 }
201 & pwsh -NoProfile -File $depMapScript -RepoRoot $RepoRoot -OutputPath $DepMapPath | Out-Null
202 }
203 }
204
205 return $DepMapPath
206}
207
208function Read-DepMap {
209 [CmdletBinding()]
210 [OutputType([pscustomobject])]
211 param([Parameter(Mandatory)] [string]$DepMapPath)
212
213 if (-not (Test-Path -LiteralPath $DepMapPath -PathType Leaf)) { return $null }
214 try {
215 return (Get-Content -LiteralPath $DepMapPath -Raw -Encoding utf8 | ConvertFrom-Json)
216 } catch {
217 Write-Verbose "Failed to parse '$DepMapPath': $($_.Exception.Message)"
218 return $null
219 }
220}
221
222function Build-ReverseIndex {
223 [CmdletBinding()]
224 [OutputType([hashtable])]
225 param(
226 [Parameter(Mandatory)] [pscustomobject]$DepMap,
227 [Parameter(Mandatory)] [ValidateSet('instructions', 'skills', 'subagents')] [string]$Field
228 )
229
230 $index = @{}
231 foreach ($prop in $DepMap.PSObject.Properties) {
232 $slug = $prop.Name
233 $entry = $prop.Value
234 if (-not $entry.PSObject.Properties.Name.Contains($Field)) { continue }
235 $refs = @($entry.$Field)
236 foreach ($ref in $refs) {
237 if ([string]::IsNullOrWhiteSpace($ref)) { continue }
238 $key = ($ref -replace '\\', '/').TrimStart('/')
239 if (-not $index.ContainsKey($key)) {
240 $index[$key] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
241 }
242 $null = $index[$key].Add($slug)
243 }
244 }
245 return $index
246}
247
248function Get-AffectedAgentSlugs {
249 <#
250 .SYNOPSIS
251 Map a set of changed file paths to the parent-agent slugs whose evals they
252 affect.
253
254 .PARAMETER ChangedFiles
255 Workspace-relative or absolute file paths to classify. Empty or
256 non-artifact paths contribute nothing.
257
258 .PARAMETER RepoRoot
259 Repository root. Defaults to `git rev-parse --show-toplevel`.
260
261 .PARAMETER DepMapPath
262 Override the dependency map location. Defaults to
263 `<RepoRoot>/logs/agent-dependency-map.json`.
264
265 .PARAMETER SkipDepMapRefresh
266 Skip the auto-refresh step when the dep-map is stale. Used by tests that
267 seed a hand-built map.
268
269 .OUTPUTS
270 [string[]] sorted, de-duplicated parent-agent slugs.
271 #>
272 [CmdletBinding()]
273 [OutputType([string[]])]
274 param(
275 [Parameter(Mandatory)]
276 [AllowEmptyCollection()]
277 [string[]]$ChangedFiles,
278
279 [string]$RepoRoot,
280 [string]$DepMapPath,
281 [switch]$SkipDepMapRefresh
282 )
283
284 $resolvedRoot = Resolve-RepoRoot -Override $RepoRoot
285 if (-not $DepMapPath) {
286 $DepMapPath = Join-Path -Path $resolvedRoot -ChildPath 'logs/agent-dependency-map.json'
287 }
288
289 $normalized = [System.Collections.Generic.List[string]]::new()
290 foreach ($p in $ChangedFiles) {
291 $rel = ConvertTo-NormalizedPath -RepoRoot $resolvedRoot -Path $p
292 if ($rel) { $normalized.Add($rel) }
293 }
294
295 if ($normalized.Count -eq 0) { return ,[string[]]@() }
296
297 $result = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
298
299 # Pass 1: direct parent agents and stimulus YAMLs. Track agent paths that
300 # are NOT parents under DD-09 so Pass 2 can expand them via subagents[].
301 $subagentCandidates = [System.Collections.Generic.List[string]]::new()
302 $needsDepMap = $false
303 foreach ($rel in $normalized) {
304 if (Test-IsAgentArtifactPath -RelativePath $rel) {
305 if (Test-IsParentAgentByFrontmatter -RepoRoot $resolvedRoot -RelativePath $rel) {
306 $slug = [System.IO.Path]::GetFileName($rel) -replace '\.agent\.md$', ''
307 [void]$result.Add($slug)
308 }
309 else {
310 $subagentCandidates.Add($rel)
311 $needsDepMap = $true
312 }
313 continue
314 }
315 $stimSlug = Get-StimulusSlug -RelativePath $rel
316 if ($stimSlug) {
317 [void]$result.Add($stimSlug)
318 continue
319 }
320 if (Test-IsIndirectArtifactPath -RelativePath $rel) {
321 $needsDepMap = $true
322 }
323 }
324
325 if (-not $needsDepMap) {
326 return ,[string[]](@($result | Sort-Object))
327 }
328
329 if (-not $SkipDepMapRefresh) {
330 Update-DepMapIfStale -RepoRoot $resolvedRoot -DepMapPath $DepMapPath | Out-Null
331 }
332
333 $depMap = Read-DepMap -DepMapPath $DepMapPath
334 if ($null -eq $depMap) { return ,[string[]](@($result | Sort-Object)) }
335
336 $instructionIndex = Build-ReverseIndex -DepMap $depMap -Field 'instructions'
337 $skillIndex = Build-ReverseIndex -DepMap $depMap -Field 'skills'
338 $subagentIndex = Build-ReverseIndex -DepMap $depMap -Field 'subagents'
339
340 foreach ($rel in $subagentCandidates) {
341 if ($subagentIndex.ContainsKey($rel)) {
342 foreach ($slug in $subagentIndex[$rel]) { [void]$result.Add($slug) }
343 }
344 }
345
346 foreach ($rel in $normalized) {
347 if ($rel -match '(?i)^\.github/instructions/.+\.instructions\.md$') {
348 if ($instructionIndex.ContainsKey($rel)) {
349 foreach ($slug in $instructionIndex[$rel]) { [void]$result.Add($slug) }
350 }
351 continue
352 }
353 if ($rel -match '(?i)^\.github/skills/.+\.md$') {
354 if ($skillIndex.ContainsKey($rel)) {
355 foreach ($slug in $skillIndex[$rel]) { [void]$result.Add($slug) }
356 }
357 }
358 }
359
360 return ,[string[]](@($result | Sort-Object))
361}
362
363function Clear-AffectedAgentsCache {
364 <#
365 .SYNOPSIS
366 Reset the frontmatter classification cache. Intended for tests.
367 #>
368 [CmdletBinding()]
369 param()
370 $script:FrontmatterCache = @{}
371}
372
373Export-ModuleMember -Function Get-AffectedAgentSlugs, Clear-AffectedAgentsCache
374