Private/start-virtualdeveloper-session.ps1
|
<#
.SYNOPSIS Orchestrator for the Virtual Developer Agent. .DESCRIPTION Polls a work item, checks for updates, and invokes the agent reasoning logic to reply or update the work item. Copyright (c) Microsoft Corporation. Licensed under the MIT License. #> [CmdletBinding(SupportsShouldProcess=$true)] param( [Parameter(Mandatory=$true)][int]$WorkItemId, [Parameter(Mandatory=$false)][string]$AgentIdentity = "Virtual Developer", [Parameter(Mandatory=$false)][switch]$MockAgent ) # Dot-source dependencies . (Join-Path $PSScriptRoot 'env-constants.ps1') . (Join-Path $PSScriptRoot 'check-prerequisites.ps1') . (Join-Path $PSScriptRoot 'azuredevops-core.ps1') # Ensure temp folder exists $script:TempFolder = Join-Path $env:TEMP 'VirtualDeveloper' if (-not (Test-Path $script:TempFolder)) { New-Item -ItemType Directory -Path $script:TempFolder -Force | Out-Null } # Helper functions (included for self-contained execution) function Select-UserComment { param( [Parameter(Mandatory=$false)] [array] $Comments, [Parameter(Mandatory=$true)] [string] $AgentIdentity ) if (-not $Comments) { return @() } return $Comments | Where-Object { $_.CreatedBy.UniqueName -ne $AgentIdentity } } function Test-IsFrozen { param( [Parameter(Mandatory=$false)] [array] $Comments ) if (-not $Comments) { return $false } foreach ($c in $Comments) { if ($c.Text -match '#frozen') { return $true } } return $false } function Update-ParentEstimatesRecursive { param([int]$WorkItemId, [double]$Delta) if ($Delta -eq 0) { return } # Get Parent $wi = Get-AdoWorkItemCore -WorkItemId $WorkItemId -ExpandRelations $parentRel = $wi.relations | Where-Object { $_.rel -eq 'System.LinkTypes.Hierarchy-Reverse' } if (-not $parentRel) { return } $parentId = $null if ($parentRel.url -match '/(\d+)$') { $parentId = [int]$matches[1] } if ($parentId) { Write-Host "Propagating estimate delta ($Delta) to Parent $parentId..." -ForegroundColor Cyan # Get Parent to check type and current value try { $parent = Get-AdoWorkItemCore -WorkItemId $parentId $type = $parent.fields.'System.WorkItemType' # Determine field $field = if ($type -eq 'Task') { 'Microsoft.VSTS.Scheduling.RemainingWork' } else { 'Microsoft.VSTS.Scheduling.Effort' } $currentVal = if ($parent.fields.$field) { $parent.fields.$field } else { 0 } $newVal = $currentVal + $Delta # Update if ($type -eq 'Task') { Update-AdoWorkItemFieldsCore -WorkItemId $parentId -RemainingWork $newVal | Out-Null } else { Update-AdoWorkItemFieldsCore -WorkItemId $parentId -Effort $newVal | Out-Null } # Recurse Update-ParentEstimatesRecursive -WorkItemId $parentId -Delta $Delta } catch { Write-Warning "Failed to propagate estimate to Parent $($parentId): $_" } } } function Write-Trace { param($Message) $logFile = Join-Path $env:TEMP "VirtualDeveloper\session_trace_$(Get-Date -Format 'yyyyMMdd').log" "$(Get-Date -Format 'HH:mm:ss') - $Message" | Out-File -FilePath $logFile -Encoding utf8 -Append } function Invoke-NodeCommand { param($ScriptPath, $Arguments, $StdinInput) $pInfo = New-Object System.Diagnostics.ProcessStartInfo $pInfo.FileName = "node" $pInfo.ArgumentList.Add($ScriptPath) foreach ($arg in $Arguments) { $pInfo.ArgumentList.Add($arg) } $pInfo.RedirectStandardOutput = $true $pInfo.RedirectStandardError = $true $pInfo.RedirectStandardInput = ($null -ne $StdinInput) $pInfo.UseShellExecute = $false $pInfo.StandardOutputEncoding = [System.Text.Encoding]::UTF8 $p = New-Object System.Diagnostics.Process $p.StartInfo = $pInfo $p.Start() | Out-Null # Write to stdin if provided if ($StdinInput) { $p.StandardInput.Write($StdinInput) $p.StandardInput.Close() } $stdout = $p.StandardOutput.ReadToEnd() $stderr = $p.StandardError.ReadToEnd() $p.WaitForExit() return [pscustomobject]@{ Output = $stdout Error = $stderr ExitCode = $p.ExitCode } } function Invoke-CopilotCli { <# .SYNOPSIS Invokes the Copilot CLI with a prompt, using stdin to avoid command line length limits. #> param( [string]$Prompt, [string]$Model, [switch]$Silent, [switch]$AllowAllTools, [switch]$AllowAllPaths ) # Find copilot command $copilotCmd = Get-Command copilot -ErrorAction SilentlyContinue if (-not $copilotCmd) { throw "Copilot CLI not found. Please ensure it's installed and in PATH." } # Build arguments (without the prompt - that goes via stdin) $copilotArgs = @('--stream', 'off') if ($Silent) { $copilotArgs += '-s' } if ($Model) { $copilotArgs += @('--model', $Model) } if ($AllowAllTools) { $copilotArgs += '--allow-all-tools' } if ($AllowAllPaths) { $copilotArgs += '--allow-all-paths' } # Use ProcessStartInfo to pipe prompt via stdin # Since copilot is a PowerShell script, we need to run it through pwsh $pInfo = New-Object System.Diagnostics.ProcessStartInfo $pInfo.FileName = "pwsh" $pInfo.ArgumentList.Add("-NoProfile") $pInfo.ArgumentList.Add("-Command") # Build the command to pipe stdin to copilot $cmdString = "`$input | & '$($copilotCmd.Source)' $($copilotArgs -join ' ')" $pInfo.ArgumentList.Add($cmdString) $pInfo.RedirectStandardInput = $true $pInfo.RedirectStandardOutput = $true $pInfo.RedirectStandardError = $true $pInfo.UseShellExecute = $false $pInfo.StandardOutputEncoding = [System.Text.Encoding]::UTF8 $pInfo.StandardInputEncoding = [System.Text.Encoding]::UTF8 $p = New-Object System.Diagnostics.Process $p.StartInfo = $pInfo $p.Start() | Out-Null # Write prompt to stdin and close $p.StandardInput.Write($Prompt) $p.StandardInput.Close() $stdout = $p.StandardOutput.ReadToEnd() $stderr = $p.StandardError.ReadToEnd() $p.WaitForExit() return [pscustomobject]@{ Output = $stdout Error = $stderr ExitCode = $p.ExitCode } } function Invoke-AgentReasoning { param($Context, [switch]$MockAgent) Write-Trace "Invoke-AgentReasoning called for WI $($Context.Id)" if ($MockAgent) { Write-Host "MockAgent: Returning canned response." -ForegroundColor Yellow # For Tasks in New/To Do state, return Update action (description expansion) if ($Context.Type -eq 'Task' -and ($Context.State -eq 'New' -or $Context.State -eq 'To Do')) { # Build expanded description from existing Description + AC field $expandedDesc = $Context.Description if (-not $expandedDesc) { $expandedDesc = "Task: $($Context.Title)" } # Append AC from the AC field if it exists and not already in Description if ($Context.AC -and $expandedDesc -notmatch 'Acceptance Criteria') { $expandedDesc += "`n`n### Acceptance Criteria`n$($Context.AC)" } return [pscustomobject]@{ Action = "Update" Description = $expandedDesc } } return [pscustomobject]@{ Action = "Reply" Text = "This is a mock response from the Virtual Developer Agent." } } # Load System Prompt $configPath = Join-Path $PSScriptRoot 'config\system-prompt.txt' if (Test-Path $configPath) { $systemPrompt = Get-Content $configPath -Raw } else { Write-Warning "System prompt not found at $configPath. Using default." $systemPrompt = "You are a helpful assistant. Output JSON." } # Prepare Download Path $downloadPath = Join-Path $env:TEMP "VirtualDeveloper\downloads\$($Context.Id)" if (-not (Test-Path $downloadPath)) { New-Item -ItemType Directory -Path $downloadPath -Force | Out-Null } # Placeholder for downloading files # TODO: Implement file download logic here if needed. # Inject Path into Prompt $systemPrompt = $systemPrompt -replace '{{DOWNLOAD_PATH}}', $downloadPath $systemPrompt = $systemPrompt -replace '{{REPO_ROOT}}', $env:AzDevOpsRepoRoot $systemPrompt = $systemPrompt -replace '{{CURRENT_DATETIME}}', (Get-Date).ToString("yyyy-MM-ddTHH:mm:ssZ") # Build User Prompt with Context $userPrompt = @" Work Item ID: $($Context.Id) Title: $($Context.Title) Description: $($Context.Description) Acceptance Criteria: $($Context.AC) History: $($Context.History | ForEach-Object { "$($_.CreatedBy.DisplayName): $($_.Text)" } | Out-String) Respond with JSON. "@ $fullPrompt = "$systemPrompt`n`n$userPrompt" # Debug: Log Prompt $debugFile = Join-Path $env:TEMP "VirtualDeveloper\debug_prompt_$(Get-Date -Format 'yyyyMMdd-HHmmss').txt" $fullPrompt | Out-File -FilePath $debugFile -Encoding utf8 # Call Copilot CLI using wrapper to handle long prompts try { try { Push-Location $env:AzDevOpsRepoRoot $result = Invoke-CopilotCli -Prompt $fullPrompt -Model $env:CopilotModel -Silent -AllowAllTools -AllowAllPaths if ($result.ExitCode -ne 0) { throw "Copilot CLI failed with exit code $($result.ExitCode). Stderr: $($result.Error)" } # Combine output if needed, but usually we just want stdout for JSON $jsonOutput = $result.Output if (-not $jsonOutput) { # If stdout is empty, maybe it went to stderr? $jsonOutput = $result.Error } } finally { Pop-Location } # Fix encoding: PowerShell reads UTF-8 output as Console Output Encoding (often CP437) # We convert it back to bytes using Console Encoding, then interpret as UTF-8. if ($jsonOutput) { $consoleEnc = [Console]::OutputEncoding if ($consoleEnc.CodePage -ne 65001) { # 65001 is UTF-8 $bytes = $consoleEnc.GetBytes($jsonOutput) $jsonOutput = [System.Text.Encoding]::UTF8.GetString($bytes) } } # Attempt to parse JSON # Copilot might return markdown code blocks ```json ... ``` if ($jsonOutput -match '(?ms)```json\s*(.*?)\s*```') { $jsonOutput = $matches[1] } elseif ($jsonOutput -match '(?ms)```\s*(.*?)\s*```') { $jsonOutput = $matches[1] } elseif ($jsonOutput -match '(?ms)\{.*\}') { # Fallback: Try to find JSON object { ... } $jsonOutput = $matches[0] } else { # No JSON block found - log the raw output for debugging $errorLogFile = Join-Path $env:TEMP "VirtualDeveloper\copilot_raw_$(Get-Date -Format 'yyyyMMddHHmmss').log" "Raw Copilot output (length: $($jsonOutput.Length)):`n$jsonOutput" | Out-File -FilePath $errorLogFile -Encoding utf8 throw "No JSON content found in Copilot output. Raw output saved to $errorLogFile" } $decision = $jsonOutput | ConvertFrom-Json Write-Trace "Agent decision: $($decision.Action)" # Debug: Log Output $debugOutFile = Join-Path $env:TEMP "VirtualDeveloper\debug_output_$(Get-Date -Format 'yyyyMMdd-HHmmss').txt" $jsonOutput | Out-File -FilePath $debugOutFile -Encoding utf8 return $decision } catch { $logFile = Join-Path $env:TEMP "VirtualDeveloper\copilot_error_$(Get-Date -Format 'yyyyMMdd-HHmmss').log" "Error: $_`n`nRaw output:`n$jsonOutput" | Out-File -FilePath $logFile -Encoding utf8 Write-Error "Failed to invoke Copilot or parse response: $_. Raw output saved to $logFile" return $null } } function Invoke-BreakdownSession { param($WorkItemId, $WorkItem, $Comments, $Tags, [switch]$MockAgent, [switch]$ForceBreakdown) Write-Trace "Invoke-BreakdownSession called for WI $WorkItemId (ForceBreakdown: $ForceBreakdown)" Write-Host "Starting Breakdown Session for WI $WorkItemId..." -ForegroundColor Cyan try { # Idempotency: Add processing tag try { Add-AdoWorkItemTagCore -WorkItemId $WorkItemId -Tag 'processing-virtual-dev' | Out-Null } catch { Write-Warning "Failed to add processing tag: $_" } # Determine State $isContinue = $Tags -contains 'continue' $isGoForIt = $Tags -contains 'go-for-it' -or $ForceBreakdown # Fetch Existing Children $children = Get-AdoWorkItemChildrenCore -WorkItemId $WorkItemId $childIds = if ($children) { $children.id } else { @() } $childrenContext = if ($children) { $children | ForEach-Object { "ID: $($_.id) - Title: $($_.fields.'System.Title') - Desc: $($_.fields.'System.Description')" } | Out-String } else { "None" } # Load Prompt $configPath = Join-Path $PSScriptRoot 'config\system-prompt-breakdown.txt' if (Test-Path $configPath) { $systemPrompt = Get-Content $configPath -Raw } else { $systemPrompt = "You are a helpful assistant. Output JSON." } $systemPrompt = $systemPrompt -replace '{{REPO_ROOT}}', $env:AzDevOpsRepoRoot $systemPrompt = $systemPrompt -replace '{{CURRENT_DATETIME}}', (Get-Date).ToString("yyyy-MM-ddTHH:mm:ssZ") # Build History String $historyStr = $Comments | ForEach-Object { "$($_.createdBy.displayName): $($_.text)" } | Out-String if ($historyStr.Length -gt 15000) { $historyStr = "...(truncated)...`n" + $historyStr.Substring($historyStr.Length - 15000) } # Build User Prompt $userPrompt = @" Work Item ID: $($WorkItem.id) Title: $($WorkItem.fields.'System.Title') Description: $($WorkItem.fields.'System.Description') Acceptance Criteria: $($WorkItem.fields.'Microsoft.VSTS.Common.AcceptanceCriteria') History: $historyStr Existing Sub-tasks: $childrenContext State: Continue: $isContinue GoForIt: $isGoForIt Respond with JSON. "@ if ($isGoForIt) { $userPrompt += "`n`nIMPORTANT: The user has approved the plan. Please extract the 'Proposed Implementation Plan' from the history and output the corresponding tasks in the JSON response." } $fullPrompt = "$systemPrompt`n`n$userPrompt" Write-Host "Prompt length: $($fullPrompt.Length)" -ForegroundColor Cyan # Call Copilot if ($MockAgent) { Write-Host "MockAgent: Returning canned breakdown." -ForegroundColor Yellow $decision = [pscustomobject]@{ Action = "Plan" Summary = "Mock Plan" Tasks = @(@{ Title = "Task 1"; Description = "Desc 1" }) } } else { try { Write-Host "Calling Copilot..." -ForegroundColor Cyan Push-Location $env:AzDevOpsRepoRoot $result = Invoke-CopilotCli -Prompt $fullPrompt -Model $env:CopilotModel -Silent -AllowAllTools -AllowAllPaths if ($result.ExitCode -ne 0) { throw "Copilot CLI failed with exit code $($result.ExitCode). Stderr: $($result.Error)" } # Combine output if needed, but usually we just want stdout for JSON $jsonOutput = $result.Output if (-not $jsonOutput) { # If stdout is empty, maybe it went to stderr? $jsonOutput = $result.Error } Write-Host "Copilot returned. Output length: $($jsonOutput.Length)" -ForegroundColor Cyan # Save raw output for debugging $rawOutputFile = Join-Path $env:TEMP "VirtualDeveloper\copilot_raw_$(Get-Date -Format 'yyyyMMddHHmmss').txt" $jsonOutput | Out-File -FilePath $rawOutputFile -Encoding utf8 } finally { Pop-Location } # Fix encoding if ($jsonOutput) { Write-Host "Fixing encoding..." -ForegroundColor Cyan $consoleEnc = [Console]::OutputEncoding if ($consoleEnc.CodePage -ne 65001) { $bytes = $consoleEnc.GetBytes($jsonOutput) $jsonOutput = [System.Text.Encoding]::UTF8.GetString($bytes) } } try { # Try to extract JSON from various formats $extractedJson = $null # Method 1: ```json ... ``` block if ($jsonOutput -match '(?ms)```json\s*([\s\S]*?)\s*```') { $extractedJson = $matches[1] } # Method 2: ``` ... ``` block (generic code block) elseif ($jsonOutput -match '(?ms)```\s*([\s\S]*?)\s*```') { $extractedJson = $matches[1] } # Method 3: Find JSON object anywhere in text (greedy match from first { to last }) elseif ($jsonOutput -match '(?s)\{[\s\S]*\}') { $extractedJson = $matches[0] } if (-not $extractedJson) { $errorFile = Join-Path $env:TEMP "VirtualDeveloper\no_json_$(Get-Date -Format 'yyyyMMddHHmmss').log" "Copilot output did not contain JSON:`n$jsonOutput" | Out-File -FilePath $errorFile -Encoding utf8 throw "No JSON content found in Copilot output. Raw output saved to $errorFile" } $jsonOutput = $extractedJson $decision = $jsonOutput | ConvertFrom-Json } catch { throw $_ } } # Handle Decision if ($decision.Action -eq 'Clarify') { $questions = @() foreach ($q in $decision.ClarificationQuestions) { if ($q -is [string]) { $questions += $q } elseif ($q.Question) { $agent = if ($q.Agent) { "**$($q.Agent)**: " } else { "" } $questions += "$agent$($q.Question)" } else { $questions += $q.ToString() } } $qList = $questions -join "`n- " $comment = "virtual-dev: I need clarification on the following:`n- $qList" Add-AdoWorkItemCommentCore -WorkItemId $WorkItemId -Text $comment | Out-Null # Wait for user to reply and tag 'continue' } elseif ($decision.Action -eq 'Plan') { if ($isGoForIt) { # Execute Plan Write-Host "Generating/Updating tasks..." -ForegroundColor Green $totalEstimate = 0 $processedTaskIds = @() foreach ($task in $decision.Tasks) { if (-not $task.Title) { Write-Warning "Task missing title. Skipping." continue } $est = if ($task.Estimate) { $task.Estimate } else { 0 } $totalEstimate += $est # Prepare Description with AC $descStr = if ($task.Description -is [array]) { $task.Description -join "`n" } else { $task.Description } if ($task.AcceptanceCriteria) { $acText = if ($task.AcceptanceCriteria -is [array]) { "- " + ($task.AcceptanceCriteria -join "`n- ") } else { $task.AcceptanceCriteria } $descStr += "`n`n### Acceptance Criteria`n$acText" } $validId = $null if ($task.Id) { if ($childIds -contains $task.Id) { $validId = $task.Id } else { Write-Warning "Agent returned Task ID $($task.Id) which is not a known child of Work Item $WorkItemId. Treating as new task." } } if ($validId) { Write-Host "Updating Task $($validId)..." -ForegroundColor Cyan try { # Check and apply prefix if needed $newTitle = $null if (-not [string]::IsNullOrEmpty($env:VirtualDeveloperTaskPrefix)) { $existingTask = Get-AdoWorkItemCore -WorkItemId $validId $currentTitle = $existingTask.fields.'System.Title' if (-not $currentTitle.StartsWith($env:VirtualDeveloperTaskPrefix)) { $newTitle = "$env:VirtualDeveloperTaskPrefix $currentTitle" } } Update-AdoWorkItemFieldsCore -WorkItemId $validId -Title $newTitle -Description $descStr $processedTaskIds += $validId } catch { Write-Warning "Failed to update Task $($validId): $_" } } else { # Logic to determine intended title $prefixStr = "" if (-not [string]::IsNullOrEmpty($env:VirtualDeveloperTaskPrefix)) { $prefixStr = "$env:VirtualDeveloperTaskPrefix " } $intendedTitle = $task.Title.Trim() if (-not [string]::IsNullOrEmpty($prefixStr) -and -not $intendedTitle.StartsWith($env:VirtualDeveloperTaskPrefix)) { $intendedTitle = "$prefixStr$($task.Title.Trim())" } # Fallback: If no ID, check if title matches an existing child $existingChild = $children | Where-Object { $_.fields.'System.Title' -and $_.fields.'System.Title'.Trim() -eq $intendedTitle } | Select-Object -First 1 # If not found, and we added a prefix, check for the raw title (unprefixed existing task) if (-not $existingChild -and $intendedTitle -ne $task.Title.Trim()) { $existingChild = $children | Where-Object { $_.fields.'System.Title' -and $_.fields.'System.Title'.Trim() -eq $task.Title.Trim() } | Select-Object -First 1 } if ($existingChild) { $validId = $existingChild.id Write-Host "Found existing task by title: '$($existingChild.fields.'System.Title')' (ID: $validId). Updating instead of creating." -ForegroundColor Yellow # Update the found task try { $newTitle = $null if ($existingChild.fields.'System.Title' -ne $intendedTitle) { $newTitle = $intendedTitle } Update-AdoWorkItemFieldsCore -WorkItemId $validId -Title $newTitle -Description $descStr $processedTaskIds += $validId } catch { Write-Warning "Failed to update Task $($validId): $_" } } else { $project = $WorkItem.fields.'System.TeamProject' $areaPath = $WorkItem.fields.'System.AreaPath' $iterationPath = $WorkItem.fields.'System.IterationPath' if (-not $project) { Write-Warning "Parent Work Item $WorkItemId does not have a Team Project set. Cannot create child task '$intendedTitle'." continue } Write-Host "Creating Task '$intendedTitle'..." -ForegroundColor Cyan # Use RemainingWork as it is more commonly available on Tasks across process templates (Scrum, Agile, CMMI) try { $newTask = New-AdoWorkItemCore -Project $project -Type 'Task' -Title $intendedTitle -Description $descStr -ParentId $WorkItemId -ParentUrl $WorkItem.url -RemainingWork $est -AreaPath $areaPath -IterationPath $iterationPath if ($newTask -and $newTask.id) { $processedTaskIds += $newTask.id } } catch { $logFile = Join-Path $env:TEMP "VirtualDeveloper\breakdown_error_$(Get-Date -Format 'yyyyMMdd-HHmmss').log" "Failed to create Task '$intendedTitle'`n$_" | Out-File -FilePath $logFile -Encoding utf8 -Append Write-Warning "Failed to create Task '$intendedTitle': $_" } } } } # Calculate Final Total (Decision Tasks + Untouched Existing Children) $finalTotal = $totalEstimate if ($children) { foreach ($child in $children) { if ($processedTaskIds -notcontains $child.id) { $childEst = 0 if ($child.fields.'Microsoft.VSTS.Scheduling.RemainingWork') { $childEst = $child.fields.'Microsoft.VSTS.Scheduling.RemainingWork' } $finalTotal += $childEst } } } # Calculate Delta $oldParentVal = 0 $parentType = $WorkItem.fields.'System.WorkItemType' $field = if ($parentType -eq 'Task') { 'Microsoft.VSTS.Scheduling.RemainingWork' } else { 'Microsoft.VSTS.Scheduling.Effort' } if ($WorkItem.fields.$field) { $oldParentVal = $WorkItem.fields.$field } $delta = $finalTotal - $oldParentVal # Update Parent Effort if ($delta -ne 0) { Write-Host "Updating Parent Work Item Effort/RemainingWork to $finalTotal (Delta: $delta)..." -ForegroundColor Cyan try { if ($parentType -eq 'Task') { Update-AdoWorkItemFieldsCore -WorkItemId $WorkItemId -RemainingWork $finalTotal | Out-Null } else { Update-AdoWorkItemFieldsCore -WorkItemId $WorkItemId -Effort $finalTotal | Out-Null } # Propagate Update-ParentEstimatesRecursive -WorkItemId $WorkItemId -Delta $delta } catch { Write-Warning "Failed to update parent effort: $_" } } # Attach Docs (Summary as comment for now) $summaryText = if ($decision.Summary -is [array]) { $decision.Summary -join "`n" } else { $decision.Summary } Add-AdoWorkItemCommentCore -WorkItemId $WorkItemId -Text "## Implementation Plan Generated`n`n$summaryText" | Out-Null # Finalize Tags try { Remove-AdoWorkItemTagCore -WorkItemId $WorkItemId -Tag 'actionable' | Out-Null Remove-AdoWorkItemTagCore -WorkItemId $WorkItemId -Tag 'go-for-it' | Out-Null Remove-AdoWorkItemTagCore -WorkItemId $WorkItemId -Tag 'continue' | Out-Null Add-AdoWorkItemTagCore -WorkItemId $WorkItemId -Tag 'processed-virtual-dev' | Out-Null } catch { Write-Warning "Failed to update tags: $_" } # Deep Expand if ($Tags -contains 'deep-expand') { Write-Trace "Deep Expand enabled. Processing $($processedTaskIds.Count) tasks." Write-Host "Deep Expand enabled. Triggering agent for child tasks..." -ForegroundColor Magenta foreach ($childId in $processedTaskIds) { Write-Host "Expanding Task $childId..." -ForegroundColor Magenta try { # Do NOT pass ForceBreakdown - we want description expansion via Invoke-AgentReasoning, not breakdown Start-VirtualDeveloperSession -WorkItemId $childId -AgentIdentity $AgentIdentity -MockAgent:$MockAgent } catch { $logFile = Join-Path $env:TEMP "VirtualDeveloper\breakdown_error_$(Get-Date -Format 'yyyyMMdd-HHmmss').log" "Failed to expand Task $childId`n$_" | Out-File -FilePath $logFile -Encoding utf8 -Append Write-Warning "Failed to expand Task $($childId): $_" } } } } else { # Propose Plan $summaryText = if ($decision.Summary -is [array]) { $decision.Summary -join "`n" } else { $decision.Summary } $summary = "## Proposed Implementation Plan`n`n$summaryText`n`n### Tasks`n" foreach ($t in $decision.Tasks) { $est = if ($t.Estimate) { " ($($t.Estimate)h)" } else { "" } $desc = if ($t.Description -is [array]) { $t.Description -join "<br/>" } else { $t.Description } $summary += "- **$($t.Title)**${est}: $desc`n" } $summary += "`n`nTag this work item with **go-for-it** to proceed with generation." Add-AdoWorkItemCommentCore -WorkItemId $WorkItemId -Text $summary | Out-Null } } } catch { $logFile = Join-Path $env:TEMP "VirtualDeveloper\breakdown_error_$(Get-Date -Format 'yyyyMMdd-HHmmss').log" $errorDetails = @" === BREAKDOWN ERROR === Timestamp: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') WorkItemId: $WorkItemId Error: $_ Exception Type: $($_.Exception.GetType().FullName) Stack Trace: $($_.ScriptStackTrace) === RAW COPILOT OUTPUT === Length: $(if ($jsonOutput) { $jsonOutput.Length } else { 'N/A' }) Content: $jsonOutput === PROMPT INFO === Prompt Length: $(if ($fullPrompt) { $fullPrompt.Length } else { 'N/A' }) "@ $errorDetails | Out-File -FilePath $logFile -Encoding utf8 Write-Host "Breakdown failed. See log at $logFile" -ForegroundColor Red Write-Error "Breakdown failed: $_" } finally { # Always remove processing tag try { Remove-AdoWorkItemTagCore -WorkItemId $WorkItemId -Tag 'processing-virtual-dev' | Out-Null } catch { Write-Warning "Failed to remove processing tag: $_" } } } function Start-VirtualDeveloperSession { [CmdletBinding(SupportsShouldProcess=$true)] param( [int]$WorkItemId, [string]$AgentIdentity = "Virtual Developer", [switch]$MockAgent, [switch]$ForceBreakdown ) Write-Trace "Start-VirtualDeveloperSession called for WI $WorkItemId (ForceBreakdown: $ForceBreakdown)" # Pre-flight check try { Write-Host "Checking prerequisites..." -ForegroundColor Cyan $null = Invoke-CheckPrerequisites Write-Host "Prerequisites checked." -ForegroundColor Green # Cache token to avoid repeated az calls if (-not $env:AzDevOpsAccessToken) { Write-Host "Acquiring Access Token..." -ForegroundColor Cyan $env:AzDevOpsAccessToken = Get-AzAccessTokenCore $env:AzDevOpsAadAppId } } catch { Write-Error "Prerequisite check failed: $_" return } Write-Host "Fetching Work Item $WorkItemId..." -ForegroundColor Cyan try { $wi = Get-AdoWorkItemCore -WorkItemId $WorkItemId $commentsRaw = Get-AdoWorkItemCommentsCore -WorkItemId $WorkItemId } catch { $logFile = Join-Path $env:TEMP "VirtualDeveloper\session_error_$(Get-Date -Format 'yyyyMMdd-HHmmss').log" "Failed to fetch Work Item $WorkItemId`n$_" | Out-File -FilePath $logFile -Encoding utf8 Write-Error "Failed to fetch Work Item: $_" return } # Check State $state = $wi.fields.'System.State' if ($state -ne 'New' -and $state -ne 'To Do') { Write-Host "Work Item is in state '$state'. Skipping processing (only 'New' or 'To Do' is processed)." -ForegroundColor Yellow return } $comments = @() if ($commentsRaw -and $commentsRaw.comments) { $comments = $commentsRaw.comments } # Filter history $history = Select-UserComment -Comments $comments -AgentIdentity $AgentIdentity # Check Frozen if (Test-IsFrozen -Comments $comments) { Write-Verbose "Work Item is frozen. Triggering breakdown (TODO)." return } # Check Breakdown Trigger $tags = if ($wi.fields.'System.Tags') { $wi.fields.'System.Tags' -split ';' | ForEach-Object { $_.Trim() } } else { @() } $type = $wi.fields.'System.WorkItemType' if (($tags -contains 'actionable' -or $ForceBreakdown) -and $tags -notcontains 'children-done' -and ($type -in 'Product Backlog Item','Bug','Task','Feature')) { if ($tags -contains 'processing-virtual-dev') { Write-Host "Work Item is already being processed (tag 'processing-virtual-dev'). Skipping." -ForegroundColor Yellow return } try { Invoke-BreakdownSession -WorkItemId $WorkItemId -WorkItem $wi -Comments $comments -Tags $tags -MockAgent:$MockAgent -ForceBreakdown:$ForceBreakdown } catch { $logFile = Join-Path $env:TEMP "VirtualDeveloper\breakdown_error_$(Get-Date -Format 'yyyyMMdd-HHmmss').log" $errorDetails = @" === BREAKDOWN SESSION ERROR === Timestamp: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') WorkItemId: $WorkItemId Error: $_ Exception Type: $($_.Exception.GetType().FullName) Stack Trace: $($_.ScriptStackTrace) "@ $errorDetails | Out-File -FilePath $logFile -Encoding utf8 Write-Error "Breakdown session failed: $_. Details saved to $logFile" } return } # Build Context $context = @{ Id = $WorkItemId Title = $wi.fields.'System.Title' Description = $wi.fields.'System.Description' AC = $wi.fields.'Microsoft.VSTS.Common.AcceptanceCriteria' History = $history Type = $type State = $state } Write-Host "Invoking Agent Reasoning..." -ForegroundColor Cyan Write-Host "Agent is thinking (this may take a while)..." -ForegroundColor DarkGray # Post status comment try { Add-AdoWorkItemCommentCore -WorkItemId $WorkItemId -Text "Agent is analyzing the request..." | Out-Null } catch { Write-Warning "Failed to post status comment: $_" } # Reason $decision = Invoke-AgentReasoning -Context $context -MockAgent:$MockAgent if ($decision) { try { if ($decision.Action -eq 'Reply') { if ($PSCmdlet.ShouldProcess("WorkItem $WorkItemId", "Reply: $($decision.Text)")) { Write-Verbose "Agent replying..." Add-AdoWorkItemCommentCore -WorkItemId $WorkItemId -Text $decision.Text } } elseif ($decision.Action -eq 'Update') { if ($PSCmdlet.ShouldProcess("WorkItem $WorkItemId", "Update Description/AC")) { Write-Verbose "Agent updating..." # Append AC to Description if present $descStr = $decision.Description if ($decision.AcceptanceCriteria) { $acText = if ($decision.AcceptanceCriteria -is [array]) { "- " + ($decision.AcceptanceCriteria -join "`n- ") } else { $decision.AcceptanceCriteria } $descStr += "`n`n### Acceptance Criteria`n$acText" } Update-AdoWorkItemFieldsCore -WorkItemId $WorkItemId -Description $descStr # Post completion comment try { Add-AdoWorkItemCommentCore -WorkItemId $WorkItemId -Text "I have updated the Description and Acceptance Criteria based on my analysis." | Out-Null } catch { Write-Warning "Failed to post completion comment: $_" } } } } catch { $logFile = Join-Path $env:TEMP "VirtualDeveloper\session_error_$(Get-Date -Format 'yyyyMMdd-HHmmss').log" $errInfo = @{ Error = $_.ToString() Decision = $decision } $errInfo | ConvertTo-Json -Depth 10 | Out-File -FilePath $logFile -Encoding utf8 Write-Error "Session processing failed: $_. Details saved to $logFile" } } else { Write-Verbose "No action required." } } # Entry point if ($MyInvocation.InvocationName -ne '.') { Start-VirtualDeveloperSession -WorkItemId $WorkItemId -AgentIdentity $AgentIdentity -MockAgent:$MockAgent } |