Private/New-ActionableError.ps1
|
# Copyright (c) 2026 Jeffrey Snover. All rights reserved. # Licensed under the MIT License. See LICENSE file in the project root. # Standardized actionable error helper. # Produces structured error messages for both humans and AI agents. # Dot-sourced by AITriad.psm1 — do NOT export. function New-ActionableError { <# .SYNOPSIS Creates a structured, actionable error message for humans and AI agents. .DESCRIPTION Generates a formatted error that includes what was being attempted, what went wrong, where it happened, and specific steps to resolve. Outputs via Write-Error by default, or returns a string with -PassThru, or throws with -Throw. .EXAMPLE New-ActionableError -Goal 'Importing document' -Problem 'File not found' ` -Location 'Import-AITriadDocument' ` -NextSteps @('Verify the file path exists', 'Check file permissions') .EXAMPLE New-ActionableError -Goal 'Calling Gemini API' -Problem 'Authentication failed (401)' ` -Location 'AIEnrich.psm1:Invoke-AICompletion' ` -NextSteps @('Run: $env:GEMINI_API_KEY to verify the key is set', 'Regenerate key at https://aistudio.google.com/apikey') ` -Throw #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Goal, [Parameter(Mandatory)] [string]$Problem, [Parameter(Mandatory)] [string]$Location, [Parameter(Mandatory)] [string[]]$NextSteps, [System.Management.Automation.ErrorRecord]$InnerError, [switch]$Throw, [switch]$PassThru ) $StepList = ($NextSteps | ForEach-Object { $i = [int]($NextSteps.IndexOf($_)) + 1; " $i. $_" }) -join "`n" if ($InnerError) { $InnerDetail = "`n Inner error: $($InnerError.Exception.Message)" } else { $InnerDetail = '' } $Message = @" Goal: $Goal Error: $Problem$InnerDetail Location: $Location Resolve: $StepList "@ if ($PassThru) { return $Message } elseif ($Throw) { throw $Message } else { Write-Error $Message } } function Invoke-WithRecovery { <# .SYNOPSIS Executes an action with optional retry and fallback, producing actionable errors on final failure. .DESCRIPTION Tries the primary action up to MaxRetries times. If all retries fail and a Fallback scriptblock is provided, executes the fallback. If everything fails, emits an actionable error via New-ActionableError. .EXAMPLE Invoke-WithRecovery -Goal 'Calling Gemini API' -Location 'Invoke-POVSummary' ` -Action { Invoke-GeminiCompletion $Prompt } ` -MaxRetries 2 -RetryDelaySeconds 3 ` -NextSteps @('Check your GEMINI_API_KEY', 'Verify network connectivity') #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Goal, [Parameter(Mandatory)] [string]$Location, [Parameter(Mandatory)] [scriptblock]$Action, [scriptblock]$Fallback, [int]$MaxRetries = 0, [int]$RetryDelaySeconds = 2, [string[]]$NextSteps = @('Check the error details above and retry'), [switch]$Throw ) $LastError = $null for ($attempt = 0; $attempt -le $MaxRetries; $attempt++) { try { return (& $Action) } catch { $LastError = $_ if ($attempt -lt $MaxRetries) { Write-Warn "$Goal — attempt $($attempt + 1)/$($MaxRetries + 1) failed: $($_.Exception.Message). Retrying in ${RetryDelaySeconds}s..." Start-Sleep -Seconds $RetryDelaySeconds } } } # Primary action exhausted — try fallback if ($Fallback) { try { Write-Warn "$Goal — primary action failed, trying fallback..." return (& $Fallback) } catch { $LastError = $_ } } # Everything failed — produce actionable error New-ActionableError -Goal $Goal -Problem $LastError.Exception.Message ` -Location $Location -NextSteps $NextSteps -InnerError $LastError -Throw:$Throw } |