Private/AgentSession.ps1

# ══════════════════════════════════════════════════════════════════════════════
# AGENT SESSION — Dedicated runspace, object registry, guarded execution
# ══════════════════════════════════════════════════════════════════════════════

function script:Test-IsDestructive([string]$Expression) {
    $segments = $Expression -split '[|;]'
    foreach ($seg in $segments) {
        $cmd = $seg.Trim() -replace '^\s*[\(&]*\s*', ''
        if ($cmd -match $script:DestructivePattern) { return $true }
    }
    return $false
}

function script:Import-GlobalVariables {
    param(
        [System.Management.Automation.Runspaces.InitialSessionState]$ISS,
        [string[]]$Exclude = @()
    )
    $excludeSet = [System.Collections.Generic.HashSet[string]]::new(
        [string[]]$Exclude, [System.StringComparer]::OrdinalIgnoreCase)
    $count = 0
    foreach ($v in (Get-Variable -Scope Global)) {
        if ($excludeSet.Contains($v.Name)) { continue }
        if ($v.Options -band [System.Management.Automation.ScopedItemOptions]::Constant) { continue }
        try {
            $ISS.Variables.Add(
                [System.Management.Automation.Runspaces.SessionStateVariableEntry]::new(
                    $v.Name, $v.Value, $v.Description))
            $count++
        } catch {
            Write-Debug "Skipped global variable '$($v.Name)': $_"
        }
    }
    Write-Verbose "Imported $count global variable(s) into ISS"
    $count
}

function script:New-AgentSession {
    $iss = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault2()
    $mods = @(Get-Module)
    foreach ($mod in $mods) { $iss.ImportPSModule($mod.Name) }
    $envCount = 0
    foreach ($entry in [System.Environment]::GetEnvironmentVariables().GetEnumerator()) {
        $iss.EnvironmentVariables.Add(
            [System.Management.Automation.Runspaces.SessionStateVariableEntry]::new(
                $entry.Key, $entry.Value, ''))
        $envCount++
    }
    $varCount = script:Import-GlobalVariables -ISS $iss -Exclude @('refs')
    $refs = @{}
    $iss.Variables.Add(
        [System.Management.Automation.Runspaces.SessionStateVariableEntry]::new(
            'refs', $refs, 'Agent object registry'))
    $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace($iss)
    $rs.Open()
    Write-Verbose "Agent session opened: $($mods.Count) modules, $envCount env vars, $varCount global vars, runspace=$($rs.InstanceId)"
    @{ Runspace = $rs; Refs = $refs; NextId = 0 }
}

function script:Close-AgentSession([hashtable]$Session) {
    if ($Session -and $Session.Runspace) {
        Write-Verbose "Closing agent session: $($Session.Refs.Count) ref(s) collected"
        $Session.Runspace.Close()
        $Session.Runspace.Dispose()
        $Session.Runspace = $null
    }
}

function script:Format-RefSummary {
    param([int]$Id, [object]$Value, [int]$MaxLines = 15)

    if ($null -eq $Value) { return "ref:$Id → `$null" }

    $items = @($Value)
    $typeName = if ($items.Count -eq 0) { '[empty]' }
                elseif ($items.Count -gt 1) { "[$($items[0].GetType().Name)[]] $($items.Count) items" }
                else { "[$($items[0].GetType().Name)]" }

    $preview = ($Value | Out-String -Width 120).TrimEnd()
    $lines = $preview -split "`r?`n"
    if ($lines.Count -gt $MaxLines) {
        $preview = ($lines[0..($MaxLines - 1)] -join "`n") + "`n... ($($lines.Count) lines total)"
    }
    "ref:$Id -> $typeName`n$preview"
}

function script:Format-Streams([System.Management.Automation.PowerShell]$PS) {
    $parts = [System.Collections.Generic.List[string]]::new()
    foreach ($e in $PS.Streams.Error)       { $parts.Add("ERROR: $e") }
    foreach ($w in $PS.Streams.Warning)     { $parts.Add("WARNING: $w") }
    foreach ($v in $PS.Streams.Verbose)     { $parts.Add("VERBOSE: $v") }
    foreach ($d in $PS.Streams.Debug)       { $parts.Add("DEBUG: $d") }
    foreach ($i in $PS.Streams.Information) { $parts.Add("INFO: $($i.MessageData)") }
    if ($parts.Count -gt 0) { return "`n--- streams ---`n" + ($parts -join "`n") }
    return ''
}

function script:Invoke-GuardedExpression {
    param(
        [string]$Expression,
        [hashtable]$AgentSession,
        [bool]$AutoConfirm = $false,
        [int]$TimeoutSec = 30
    )

    Write-Debug "Guarded expression: $Expression"
    $isDestructive = script:Test-IsDestructive $Expression
    $confirmEnv    = [System.Environment]::GetEnvironmentVariable('LLM_CONFIRM_DANGEROUS')
    $skipConfirm   = $AutoConfirm -or ($confirmEnv -eq '0')

    if ($isDestructive -and -not $skipConfirm) {
        Write-Warning "Destructive expression detected: $($Expression.Substring(0, [math]::Min(80, $Expression.Length)))"
        script:Write-ConfirmBox -Expression $Expression
        $answer = $Host.UI.ReadLine()
        if ($answer -notmatch '^[Yy]$') {
            Write-Verbose "User denied destructive expression"
            return [PSCustomObject]@{
                Output  = 'User denied execution of destructive expression.'
                IsError = $true
                Denied  = $true
            }
        }
        Write-Verbose "User approved destructive expression"
    }

    $ps = [PowerShell]::Create()
    $ps.Runspace = $AgentSession.Runspace
    $null = $ps.AddScript($Expression, $false)

    try {
        Write-Verbose "Executing tool call (timeout=${TimeoutSec}s)"
        $async = $ps.BeginInvoke()
        if (-not $async.AsyncWaitHandle.WaitOne([TimeSpan]::FromSeconds($TimeoutSec))) {
            Write-Warning "Tool call timed out after ${TimeoutSec}s: $($Expression.Substring(0, [math]::Min(60, $Expression.Length)))"
            $ps.Stop()
            $ps.Dispose()
            return [PSCustomObject]@{
                Output  = "Tool call timed out after ${TimeoutSec}s. Try a simpler approach."
                IsError = $true
                Denied  = $false
            }
        }
        $result  = $ps.EndInvoke($async)
        $streams = script:Format-Streams $ps
        $ps.Dispose()

        $output = @($result)
        if ($output.Count -eq 0 -and -not $streams) {
            return [PSCustomObject]@{ Output = '(no output)'; IsError = $false; Denied = $false }
        }

        $summary = ''
        if ($output.Count -gt 0) {
            $AgentSession.NextId++
            $id = $AgentSession.NextId
            $val = if ($output.Count -eq 1) { $output[0] } else { $output }
            $AgentSession.Refs[$id] = $val
            $summary = script:Format-RefSummary -Id $id -Value $val
            Write-Verbose "Tool call stored ref:$id ($(if ($output.Count -eq 1) { $val.GetType().Name } else { "$($output.Count) items" }))"
        }

        [PSCustomObject]@{ Output = ($summary + $streams); IsError = $false; Denied = $false }
    }
    catch {
        Write-Error "Tool call failed: $_" -ErrorAction Continue
        $ps.Dispose()
        [PSCustomObject]@{
            Output  = "Error: $($_.ToString())"
            IsError = $true
            Denied  = $false
        }
    }
}