Public/Invoke-LLMAgent.ps1
|
function Invoke-LLMAgent { <# .SYNOPSIS Run an agentic completion loop where the LLM can call back into PowerShell. .DESCRIPTION The LLM is given one tool: invoke_powershell, which accepts any PS expression. Expressions execute in a dedicated Runspace with a live $refs object registry. Results are stored as in-memory objects; the LLM receives a compact summary and can chain previous results via $refs[id]. The loop continues until the LLM stops issuing tool calls (stop_reason = end_turn) or MaxTurns is reached. After the agent completes, all collected objects are returned on the response's .Result property as a flat array of live .NET objects. Pipeline them directly: $r = Invoke-LLMAgent "top 3 processes by memory" -Provider Anthropic -Quiet $r.Result | Select-Object -First 1 | Stop-Process -WhatIf Destructive expressions (Remove-, Stop-, Format- etc.) require interactive confirmation unless -AutoConfirm is set or LLM_CONFIRM_DANGEROUS=0. All loaded module claude.md directives are injected automatically, giving the LLM a complete picture of what it can invoke. .PARAMETER Prompt The task to accomplish. Accepts pipeline input. .PARAMETER Provider Anthropic or OpenAI. .PARAMETER Model Model override. .PARAMETER SystemPrompt Additional system instructions appended after environment context. .PARAMETER MaxTokens Max tokens per completion turn. Default 2048. .PARAMETER MaxTurns Maximum tool-call iterations before stopping. Default 10. .PARAMETER ToolTimeoutSec Per-tool-call timeout in seconds. Default 30. .PARAMETER AutoConfirm Skip destructive-verb confirmation prompts. Use with caution. .PARAMETER Quiet Suppress per-call console rendering. .EXAMPLE Invoke-LLMAgent "What process is consuming the most memory? Show name and MB." -Provider Anthropic .EXAMPLE Invoke-LLMAgent "List all loaded modules that have a claude.md directive" -Provider Anthropic #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory, ValueFromPipeline, Position=0)] [string]$Prompt, [ValidateSet('Anthropic','OpenAI')] [string]$Provider, [string]$Model, [string]$SystemPrompt = '', [ValidateRange(1,32768)] [int]$MaxTokens = 2048, [ValidateRange(1,50)] [int]$MaxTurns = 10, [ValidateRange(5,600)] [int]$ToolTimeoutSec = 30, [switch]$AutoConfirm, [switch]$Quiet, [Parameter()] [object[]]$InputObject ) begin { if (-not $Provider) { $Provider = $env:LLM_DEFAULT_PROVIDER ?? 'Anthropic' } if (-not $Model) { $Model = $script:Providers[$Provider].DefaultModel } } process { script:Push-Preferences $script:VerbosePreference = $VerbosePreference $script:DebugPreference = $DebugPreference $agentSession = script:New-AgentSession try { # ── Pre-seed $refs with pipeline input if provided ────────────────── if ($InputObject -and $InputObject.Count -gt 0) { $agentSession.NextId++ $refId = $agentSession.NextId $refVal = if ($InputObject.Count -eq 1) { $InputObject[0] } else { $InputObject } $agentSession.Refs[$refId] = $refVal $inputSummary = script:Format-RefSummary -Id $refId -Value $refVal $Prompt = "Input data pre-loaded as `$refs[$refId]:`n$inputSummary`n`nTask: $Prompt" Write-Verbose "Pre-seeded `$refs[$refId] with $($InputObject.Count) input object(s)" } Write-Verbose "Invoke-LLMAgent: $Provider/$Model, maxTurns=$MaxTurns, toolTimeout=${ToolTimeoutSec}s" $sys = script:Build-SystemPrompt -UserSystemPrompt $SystemPrompt -IncludeEnv $true $messages = [System.Collections.Generic.List[object]]::new() $messages.Add(@{role='user';content=$Prompt}) $allToolCalls = [System.Collections.Generic.List[PSCustomObject]]::new() $totalIn = 0; $totalOut = 0; $totalSec = 0.0 $turns = 0; $finalText = '' if (-not $Quiet) { Write-Host "" script:Write-Rule -Label "AGENT $($script:Box.Gear) $Prompt" -Color $script:C.Cyan Write-Host "" } do { $p = @{ Model=$Model; SystemPrompt=$sys; Messages=$messages.ToArray(); MaxTokens=$MaxTokens } $raw = switch ($Provider) { 'Anthropic' { script:Invoke-AnthropicRaw @p -Tools @($script:AgentTool) } 'OpenAI' { script:Invoke-OpenAIRaw @p -Tools @($script:AgentTool) } } $r = $raw.Response $totalSec += $raw.ElapsedSec $turns++ Write-Verbose "Agent turn ${turns}: $([math]::Round($raw.ElapsedSec,2))s" switch ($Provider) { 'Anthropic' { $totalIn += $r.usage.input_tokens $totalOut += $r.usage.output_tokens $stopReason = $r.stop_reason $toolCalls = script:Extract-AnthropicToolCalls $r.content $textNow = script:Extract-AnthropicText $r.content $messages.Add(@{role='assistant';content=$r.content}) if ($toolCalls) { $toolResults = [System.Collections.Generic.List[object]]::new() foreach ($tc in $toolCalls) { $expr = $tc.input.expression $guarded = script:Invoke-GuardedExpression -Expression $expr -AgentSession $agentSession -AutoConfirm $AutoConfirm.IsPresent -TimeoutSec $ToolTimeoutSec $tcObj = [PSCustomObject]@{ PSTypeName = 'LLMToolCall' CallNum = $allToolCalls.Count + 1 Expression = $expr Output = $guarded.Output IsError = $guarded.IsError Denied = $guarded.Denied } $allToolCalls.Add($tcObj) if (-not $Quiet) { script:Write-ToolCallBox -Expression $expr -Result $guarded.Output ` -IsError $guarded.IsError -CallNum $tcObj.CallNum } $toolResults.Add((script:Build-AnthropicToolResult $tc.id $guarded.Output)) } $messages.Add(@{role='user';content=$toolResults.ToArray()}) } if ($textNow) { $finalText = $textNow } } 'OpenAI' { $totalIn += $r.usage.prompt_tokens $totalOut += $r.usage.completion_tokens $choice = $r.choices[0] $stopReason = $choice.finish_reason $toolCalls = script:Extract-OpenAIToolCalls $choice $textNow = $choice.message.content $messages.Add(@{role='assistant'; content=$choice.message.content; tool_calls=$choice.message.tool_calls}) if ($toolCalls) { foreach ($tc in $toolCalls) { $expr = ($tc.function.arguments | ConvertFrom-Json).expression $guarded = script:Invoke-GuardedExpression -Expression $expr -AgentSession $agentSession -AutoConfirm $AutoConfirm.IsPresent -TimeoutSec $ToolTimeoutSec $tcObj = [PSCustomObject]@{ PSTypeName = 'LLMToolCall' CallNum = $allToolCalls.Count + 1 Expression = $expr Output = $guarded.Output IsError = $guarded.IsError Denied = $guarded.Denied } $allToolCalls.Add($tcObj) if (-not $Quiet) { script:Write-ToolCallBox -Expression $expr -Result $guarded.Output ` -IsError $guarded.IsError -CallNum $tcObj.CallNum } $messages.Add((script:Build-OpenAIToolResult $tc.id $guarded.Output)) } } if ($textNow) { $finalText = $textNow } } } } while ($stopReason -eq 'tool_use' -and $turns -lt $MaxTurns) if ($stopReason -eq 'tool_use' -and $turns -ge $MaxTurns) { Write-Warning "Agent reached MaxTurns limit ($MaxTurns) — stopping with pending tool calls" } $result = $null if ($agentSession.Refs.Count -gt 0) { $all = @(foreach ($key in ($agentSession.Refs.Keys | Sort-Object)) { $agentSession.Refs[$key] }) $result = if ($all.Count -eq 1) { $all[0] } else { $all } } $resp = script:New-ResponseObj -Provider $Provider -Model $Model -Content $finalText ` -InputTokens $totalIn -OutputTokens $totalOut -StopReason $stopReason ` -ResponseId '' -ElapsedSec $totalSec -Raw $null -ToolCalls $allToolCalls.ToArray() ` -Result $result $globalValue = if ($null -ne $resp.Result) { $resp.Result } else { $resp } $globalName = script:Save-GlobalResult -Type 'agent' -Prompt $Prompt -Result $globalValue $resp | Add-Member -NotePropertyName GlobalName -NotePropertyValue $globalName if (-not $Quiet) { script:Write-ResponseBox -Content $finalText -Provider $Provider -Model $Model ` -InputTokens $totalIn -OutputTokens $totalOut -StopReason $stopReason ` -ElapsedSec $totalSec script:Write-Status "Agent completed · $turns turn(s) · $($allToolCalls.Count) tool call(s) · $($totalIn+$totalOut) tokens" 'ok' Write-Host "" } Write-Verbose "Agent done: $turns turn(s), $($allToolCalls.Count) tool call(s), $($totalIn+$totalOut) tokens, $($agentSession.Refs.Count) ref(s)" $resp } finally { script:Close-AgentSession $agentSession script:Pop-Preferences } } } |