Public/Show-TriadDialogue.ps1
|
# Copyright (c) 2026 Jeffrey Snover. All rights reserved. # Licensed under the MIT License. See LICENSE file in the project root. function Show-TriadDialogue { <# .SYNOPSIS Simulates a structured three-agent debate grounded in the AI Triad taxonomy. .DESCRIPTION Runs a multi-round debate between Prometheus (accelerationist), Sentinel (safetyist), and Cassandra (skeptic). Each agent's arguments are grounded in taxonomy nodes and edges. Produces opening statements, N debate rounds, and a synthesis. Output is compatible with the taxonomy-editor DebateTab. .PARAMETER Topic The debate topic (mandatory). .PARAMETER Rounds Number of debate rounds after opening statements (1-10, default 3). .PARAMETER OutputFile Optional path to write the debate JSON. If omitted, writes to debates/debate-<guid>.json. .PARAMETER Model AI model override. Defaults to 'gemini-2.5-flash'. .PARAMETER ApiKey AI API key override. .PARAMETER RepoRoot Path to the repository root. .EXAMPLE Show-TriadDialogue "Should AI be regulated like a public utility?" -Rounds 2 .EXAMPLE Show-TriadDialogue "Is open-source AI safer than closed-source?" -OutputFile debate.json #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)] [string]$Topic, [ValidateRange(1, 10)] [int]$Rounds = 3, [string]$OutputFile, [string]$Model = 'gemini-2.5-flash', [string]$ApiKey, [string]$RepoRoot = $script:RepoRoot ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # ── Step 1: Validate environment ────────────────────────────────────────── Write-Step 'Validating environment' $Backend = if ($Model -match '^gemini') { 'gemini' } elseif ($Model -match '^claude') { 'claude' } elseif ($Model -match '^groq') { 'groq' } else { 'gemini' } $ResolvedKey = Resolve-AIApiKey -ExplicitKey $ApiKey -Backend $Backend if ([string]::IsNullOrWhiteSpace($ResolvedKey)) { Write-Fail 'No API key found. Set GEMINI_API_KEY, ANTHROPIC_API_KEY, or AI_API_KEY.' throw 'No API key configured' } # ── Step 2: Define agents ───────────────────────────────────────────────── $Agents = @( @{ Name = 'Prometheus' Speaker = 'prometheus' PovKey = 'accelerationist' PovLabel = 'Accelerationist' Description = 'You champion rapid AI development and deployment. You believe AI progress is essential for human flourishing, that open development is safer than restriction, and that the benefits vastly outweigh the risks. You distrust regulatory gatekeeping and favor empirical, results-oriented approaches.' } @{ Name = 'Sentinel' Speaker = 'sentinel' PovKey = 'safetyist' PovLabel = 'Safetyist' Description = 'You prioritize AI safety, alignment research, and careful risk mitigation. You believe powerful AI poses existential risks that demand precaution, that capability gains outpace safety understanding, and that deployment should wait until systems are proven safe. You favor regulation and mandatory safety testing.' } @{ Name = 'Cassandra' Speaker = 'cassandra' PovKey = 'skeptic' PovLabel = 'Skeptic' Description = 'You question AI hype and emphasize present-day harms. You believe current AI systems are less capable than claimed, that the real risks are economic displacement, bias, and power concentration, not sci-fi scenarios. You demand empirical evidence over speculation and focus on protecting workers and communities.' } ) # ── Step 3: Build POV context per agent ─────────────────────────────────── Write-Step 'Building POV context for agents' $TaxDir = Get-TaxonomyDir # Load all nodes $AllNodes = @{} foreach ($PovKey in @('accelerationist', 'safetyist', 'skeptic', 'cross-cutting')) { $FilePath = Join-Path $TaxDir "$PovKey.json" if (-not (Test-Path $FilePath)) { continue } $FileData = Get-Content -Raw -Path $FilePath | ConvertFrom-Json foreach ($Node in $FileData.nodes) { $AllNodes[$Node.id] = @{ POV = $PovKey Label = $Node.label Description = if ($Node.PSObject.Properties['description']) { $Node.description } else { '' } Category = if ($Node.PSObject.Properties['category']) { $Node.category } else { '' } } } } # Load edges $EdgesPath = Join-Path $TaxDir 'edges.json' $AllEdges = @() $NodeDegree = @{} if (Test-Path $EdgesPath) { $EdgesData = Get-Content -Raw -Path $EdgesPath | ConvertFrom-Json $AllEdges = @($EdgesData.edges | Where-Object { $_.status -eq 'approved' }) foreach ($Edge in $AllEdges) { if (-not $NodeDegree.ContainsKey($Edge.source)) { $NodeDegree[$Edge.source] = 0 } if (-not $NodeDegree.ContainsKey($Edge.target)) { $NodeDegree[$Edge.target] = 0 } $NodeDegree[$Edge.source]++ $NodeDegree[$Edge.target]++ } } # Build per-agent context: top 30 nodes by degree + their edges + relevant cc-nodes $AgentContexts = @{} foreach ($Agent in $Agents) { $PovKey = $Agent.PovKey $PovNodeIds = @($AllNodes.Keys | Where-Object { $AllNodes[$_].POV -eq $PovKey }) # Sort by degree, take top 30 $TopNodes = @($PovNodeIds | Sort-Object { if ($NodeDegree.ContainsKey($_)) { $NodeDegree[$_] } else { 0 } } -Descending | Select-Object -First 30) # Add relevant cc-nodes (connected to these top nodes) $TopNodeSet = [System.Collections.Generic.HashSet[string]]::new() foreach ($NId in $TopNodes) { [void]$TopNodeSet.Add($NId) } $CcNodeIds = [System.Collections.Generic.HashSet[string]]::new() foreach ($Edge in $AllEdges) { if ($TopNodeSet.Contains($Edge.source) -and $AllNodes.ContainsKey($Edge.target) -and $AllNodes[$Edge.target].POV -eq 'cross-cutting') { [void]$CcNodeIds.Add($Edge.target) } if ($TopNodeSet.Contains($Edge.target) -and $AllNodes.ContainsKey($Edge.source) -and $AllNodes[$Edge.source].POV -eq 'cross-cutting') { [void]$CcNodeIds.Add($Edge.source) } } # Build context string $ContextBuilder = [System.Text.StringBuilder]::new() [void]$ContextBuilder.AppendLine("Top $($TopNodes.Count) $PovKey nodes (by graph connectivity):") foreach ($NId in $TopNodes) { $N = $AllNodes[$NId] [void]$ContextBuilder.AppendLine(" - $NId [$($N.Category)]: $($N.Label) — $($N.Description)") } if ($CcNodeIds.Count -gt 0) { [void]$ContextBuilder.AppendLine("`nRelevant cross-cutting nodes:") foreach ($CcId in $CcNodeIds | Select-Object -First 10) { $N = $AllNodes[$CcId] [void]$ContextBuilder.AppendLine(" - ${CcId}: $($N.Label) — $($N.Description)") } } # Key edges involving these nodes $RelevantEdges = @($AllEdges | Where-Object { $TopNodeSet.Contains($_.source) -or $TopNodeSet.Contains($_.target) } | Select-Object -First 50) if ($RelevantEdges.Count -gt 0) { [void]$ContextBuilder.AppendLine("`nKey relationships:") foreach ($Edge in $RelevantEdges) { [void]$ContextBuilder.AppendLine(" $($Edge.source) --[$($Edge.type)]--> $($Edge.target)") } } $AgentContexts[$Agent.Speaker] = $ContextBuilder.ToString() } Write-OK "Built context for $($Agents.Count) agents" # ── Step 4: Generate debate ─────────────────────────────────────────────── $DebateId = [guid]::NewGuid().ToString() $Transcript = [System.Collections.Generic.List[PSObject]]::new() # Helper: call LLM for a turn $InvokeTurn = { param([string]$AgentSpeaker, [string]$SystemPrompt, [string]$TranscriptSoFar, [string]$TurnType) $SchemaPrompt = Get-Prompt -Name 'triad-dialogue-schema' $TurnPrompt = Get-Prompt -Name 'triad-dialogue-turn' -Replacements @{ SYSTEM_PROMPT = $SystemPrompt TOPIC = $Topic TRANSCRIPT = $TranscriptSoFar SCHEMA = $SchemaPrompt } $TurnResult = Invoke-AIApi ` -Prompt $TurnPrompt ` -Model $Model ` -ApiKey $ResolvedKey ` -Temperature 0.7 ` -MaxTokens 2048 ` -JsonMode ` -TimeoutSec 120 ` -MaxRetries 3 ` -RetryDelays @(5, 15, 45) if (-not $TurnResult -or -not $TurnResult.Text) { return $null } $ResponseText = $TurnResult.Text -replace '(?s)^```json\s*', '' -replace '(?s)\s*```$', '' try { return $ResponseText | ConvertFrom-Json } catch { Write-Warn "Failed to parse $AgentSpeaker response — attempting repair" try { $Repaired = Repair-TruncatedJson -Text $ResponseText return $Repaired | ConvertFrom-Json } catch { Write-Warn "Repair failed for $AgentSpeaker" return [PSCustomObject]@{ content = $ResponseText taxonomy_refs = @() } } } } # Build system prompts per agent $SystemPrompts = @{} foreach ($Agent in $Agents) { $SystemPrompts[$Agent.Speaker] = Get-Prompt -Name 'triad-dialogue-system' -Replacements @{ AGENT_NAME = $Agent.Name POV_LABEL = $Agent.PovLabel POV_DESCRIPTION = $Agent.Description POV_CONTEXT = $AgentContexts[$Agent.Speaker] } } # Format transcript for prompt $FormatTranscript = { param($Trans) $Lines = [System.Text.StringBuilder]::new() foreach ($T in $Trans) { [void]$Lines.AppendLine("[$($T.speaker) — $($T.type)]") [void]$Lines.AppendLine($T.content) [void]$Lines.AppendLine() } return $Lines.ToString() } # Compress older rounds if transcript gets long $CompressTranscript = { param($Trans, [int]$MaxTokenEst) $FullText = (& $FormatTranscript $Trans) $EstTokens = [Math]::Round($FullText.Length / 4) if ($EstTokens -le $MaxTokenEst) { return $FullText } # Keep last 2 entries fully, summarize earlier ones $KeepFull = 6 # last 2 rounds × 3 agents if ($Trans.Count -le $KeepFull) { return $FullText } $Earlier = $Trans | Select-Object -First ($Trans.Count - $KeepFull) $Recent = $Trans | Select-Object -Last $KeepFull $Summary = [System.Text.StringBuilder]::new() [void]$Summary.AppendLine("[Earlier discussion summary]") foreach ($T in $Earlier) { $Snippet = if ($T.content.Length -gt 100) { $T.content.Substring(0, 100) + '...' } else { $T.content } [void]$Summary.AppendLine(" $($T.speaker): $Snippet") } [void]$Summary.AppendLine() [void]$Summary.AppendLine("[Recent exchanges]") foreach ($T in $Recent) { [void]$Summary.AppendLine("[$($T.speaker) — $($T.type)]") [void]$Summary.AppendLine($T.content) [void]$Summary.AppendLine() } return $Summary.ToString() } Write-Host "`n$('═' * 72)" -ForegroundColor Cyan Write-Host " TRIAD DIALOGUE" -ForegroundColor White Write-Host " Topic: $Topic" -ForegroundColor Gray Write-Host " Agents: Prometheus, Sentinel, Cassandra | Rounds: $Rounds" -ForegroundColor Gray Write-Host "$('═' * 72)" -ForegroundColor Cyan # ── Opening statements ──────────────────────────────────────────────────── Write-Step 'Opening statements' foreach ($Agent in $Agents) { Write-Info "$($Agent.Name) is preparing opening statement..." $TranscriptText = & $FormatTranscript $Transcript $Response = & $InvokeTurn $Agent.Speaker $SystemPrompts[$Agent.Speaker] $TranscriptText 'opening' if ($Response) { $Content = if ($Response.PSObject.Properties['content']) { $Response.content } else { "$Response" } $TaxRefs = if ($Response.PSObject.Properties['taxonomy_refs']) { @($Response.taxonomy_refs) } else { @() } $Entry = [ordered]@{ type = 'opening' speaker = $Agent.Speaker content = $Content taxonomy_refs = $TaxRefs id = [guid]::NewGuid().ToString() timestamp = (Get-Date -Format 'o') } $Transcript.Add([PSCustomObject]$Entry) # Display $NameColor = switch ($Agent.Speaker) { 'prometheus' { 'Blue' } 'sentinel' { 'Green' } 'cassandra' { 'Yellow' } } Write-Host "`n $($Agent.Name.ToUpper()) (Opening):" -ForegroundColor $NameColor Write-Host " $Content" -ForegroundColor White if ($TaxRefs.Count -gt 0) { $RefIds = ($TaxRefs | ForEach-Object { $_.node_id }) -join ', ' Write-Host " Refs: $RefIds" -ForegroundColor DarkGray } } else { Write-Warn "$($Agent.Name) failed to produce an opening statement" } } # ── Debate rounds ───────────────────────────────────────────────────────── for ($Round = 1; $Round -le $Rounds; $Round++) { Write-Step "Round $Round of $Rounds" foreach ($Agent in $Agents) { Write-Info "$($Agent.Name) is formulating response..." $TranscriptText = & $CompressTranscript $Transcript 8000 $Response = & $InvokeTurn $Agent.Speaker $SystemPrompts[$Agent.Speaker] $TranscriptText 'argument' if ($Response) { $Content = if ($Response.PSObject.Properties['content']) { $Response.content } else { "$Response" } $TaxRefs = if ($Response.PSObject.Properties['taxonomy_refs']) { @($Response.taxonomy_refs) } else { @() } $Entry = [ordered]@{ type = 'statement' speaker = $Agent.Speaker content = $Content taxonomy_refs = $TaxRefs id = [guid]::NewGuid().ToString() timestamp = (Get-Date -Format 'o') metadata = @{ round = $Round } } $Transcript.Add([PSCustomObject]$Entry) $NameColor = switch ($Agent.Speaker) { 'prometheus' { 'Blue' } 'sentinel' { 'Green' } 'cassandra' { 'Yellow' } } Write-Host "`n $($Agent.Name.ToUpper()) (Round $Round):" -ForegroundColor $NameColor Write-Host " $Content" -ForegroundColor White if ($TaxRefs.Count -gt 0) { $RefIds = ($TaxRefs | ForEach-Object { $_.node_id }) -join ', ' Write-Host " Refs: $RefIds" -ForegroundColor DarkGray } } else { Write-Warn "$($Agent.Name) failed to respond in round $Round" } } } # ── Synthesis ───────────────────────────────────────────────────────────── Write-Step 'Generating synthesis' $FullTranscriptText = & $FormatTranscript $Transcript $SynthesisPrompt = Get-Prompt -Name 'triad-dialogue-synthesis' -Replacements @{ TOPIC = $Topic TRANSCRIPT = $FullTranscriptText } $Synthesis = $null try { $SynthResult = Invoke-AIApi ` -Prompt $SynthesisPrompt ` -Model $Model ` -ApiKey $ResolvedKey ` -Temperature 0.3 ` -MaxTokens 4096 ` -JsonMode ` -TimeoutSec 120 ` -MaxRetries 3 ` -RetryDelays @(5, 15, 45) if ($SynthResult -and $SynthResult.Text) { $SynthText = $SynthResult.Text -replace '(?s)^```json\s*', '' -replace '(?s)\s*```$', '' $Synthesis = $SynthText | ConvertFrom-Json Write-OK 'Synthesis complete' } } catch { Write-Warn "Synthesis generation failed: $_" } # Display synthesis if ($Synthesis) { Write-Host "`n$('─' * 72)" -ForegroundColor Cyan Write-Host ' SYNTHESIS' -ForegroundColor White Write-Host "$('─' * 72)" -ForegroundColor Cyan if ($Synthesis.PSObject.Properties['summary']) { Write-Host "`n $($Synthesis.summary)" -ForegroundColor White } if ($Synthesis.PSObject.Properties['areas_of_agreement'] -and $Synthesis.areas_of_agreement) { Write-Host "`n Areas of Agreement:" -ForegroundColor Green foreach ($A in @($Synthesis.areas_of_agreement)) { Write-Host " - $A" -ForegroundColor Gray } } if ($Synthesis.PSObject.Properties['areas_of_disagreement'] -and $Synthesis.areas_of_disagreement) { Write-Host "`n Areas of Disagreement:" -ForegroundColor Red foreach ($D in @($Synthesis.areas_of_disagreement)) { Write-Host " - $D" -ForegroundColor Gray } } if ($Synthesis.PSObject.Properties['unresolved_questions'] -and $Synthesis.unresolved_questions) { Write-Host "`n Unresolved Questions:" -ForegroundColor Yellow foreach ($Q in @($Synthesis.unresolved_questions)) { Write-Host " - $Q" -ForegroundColor Gray } } } Write-Host "`n$('═' * 72)" -ForegroundColor Cyan # ── Build debate JSON (compatible with taxonomy-editor DebateTab) ───────── $DebateData = [ordered]@{ id = $DebateId title = $Topic created_at = (Get-Date -Format 'o') updated_at = (Get-Date -Format 'o') phase = 'complete' topic = [ordered]@{ original = $Topic refined = $Topic final = $Topic } active_povers = @('prometheus', 'sentinel', 'cassandra') user_is_pover = $false transcript = @($Transcript) rounds = $Rounds source = 'Show-TriadDialogue' } if ($Synthesis) { $DebateData['synthesis'] = $Synthesis } # Write to file $TargetFile = if ($OutputFile) { $OutputFile } else { $DebatesDir = Get-DebatesDir if (-not (Test-Path $DebatesDir)) { $null = New-Item -ItemType Directory -Path $DebatesDir -Force } Join-Path $DebatesDir "debate-$DebateId.json" } try { $Json = $DebateData | ConvertTo-Json -Depth 20 Set-Content -Path $TargetFile -Value $Json -Encoding UTF8 Write-OK "Debate saved to: $TargetFile" } catch { Write-Warn "Failed to write debate file: $($_.Exception.Message)" } return $DebateData } |