diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b672b9..1cd7f8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,4 +27,4 @@ jobs: # Install-Module -Name PwshSpectreConsole -Force cd ./__tests__ - Invoke-Pester -Output Detailed + Invoke-Pester -Path ., ../Tests -Output Detailed -CI diff --git a/PSAI.psd1 b/PSAI.psd1 index 379dcac..b76007f 100644 --- a/PSAI.psd1 +++ b/PSAI.psd1 @@ -34,6 +34,8 @@ PSAI brings OpenAI ChatGPT to PowerShell, leveraging advanced AI capabilities in # 'Get-YouTubeTranscript' # Public + 'Search-ParallelWeb' + 'Get-ParallelWebContent' 'Out-BoxedText' 'Invoke-QuickPrompt' 'New-Agent' diff --git a/PSAI.psm1 b/PSAI.psm1 index 8aa64ac..d25f114 100644 --- a/PSAI.psm1 +++ b/PSAI.psm1 @@ -3,6 +3,7 @@ . $PSScriptRoot/Private/Invoke-OAIBeta.ps1 . $PSScriptRoot/Private/Get-MultipartFormData.ps1 . $PSScriptRoot/Private/Get-ToolProperty.ps1 +. $PSScriptRoot/Private/Invoke-ParallelMcp.ps1 # # Agent Tools # Import-Module $PSScriptRoot/Public/Tools/CalculatorTool.psm1 @@ -14,6 +15,8 @@ # Import-Module $PSScriptRoot/Public/Tools/YouTubeAssistant.psm1 # Public Functions +. $PSScriptRoot/Public/Search-ParallelWeb.ps1 +. $PSScriptRoot/Public/Get-ParallelWebContent.ps1 . $PSScriptRoot/Public/Invoke-QuickPrompt.ps1 . $PSScriptRoot/Public/New-Agent.ps1 . $PSScriptRoot/Public/Get-AgentResponse.ps1 diff --git a/Private/Invoke-ParallelMcp.ps1 b/Private/Invoke-ParallelMcp.ps1 new file mode 100644 index 0000000..063b06f --- /dev/null +++ b/Private/Invoke-ParallelMcp.ps1 @@ -0,0 +1,95 @@ +# Reuse one anonymous conversation identifier for this module instance. +$script:ParallelSessionId = [guid]::NewGuid().ToString() + +function Invoke-ParallelMcpRequest { + [CmdletBinding()] + param( + [hashtable]$Message, + [hashtable]$Headers, + [int]$TimeoutSec + ) + + try { + $timeoutOptions = @{ TimeoutSec = $TimeoutSec } + # Newer PowerShell versions split connection and operation timeouts. + if ((Get-Command Invoke-WebRequest).Parameters.ContainsKey('OperationTimeoutSeconds')) { + $timeoutOptions['OperationTimeoutSeconds'] = $TimeoutSec + } + $response = Invoke-WebRequest -Uri 'https://search.parallel.ai/mcp' -Method Post ` + -ContentType 'application/json; charset=utf-8' -Headers $Headers ` + -UserAgent "PSAI/$($ExecutionContext.SessionState.Module.Version) (https://github.com/dfinke/PSAI)" ` + -Body ($Message | ConvertTo-Json -Depth 20 -Compress) @timeoutOptions -ErrorAction Stop + + # Notifications have no JSON-RPC response body. + if (-not $Message.ContainsKey('id')) { + return + } + + $contentType = $response.Headers['Content-Type'] -join ',' + if ($contentType -like 'text/event-stream*') { + $replies = @( + foreach ($sseEvent in ($response.Content -split '\r?\n\r?\n')) { + $data = @($sseEvent -split '\r?\n' | Where-Object { $_ -like 'data:*' } | + ForEach-Object { $_.Substring(5).TrimStart(' ') }) -join "`n" + if ($data) { $data | ConvertFrom-Json -Depth 30 } + } + ) + } + else { + $replies = @($response.Content | ConvertFrom-Json -Depth 30) + } + + $reply = $replies | Where-Object { $_.id -eq $Message.id } | Select-Object -First 1 + if (-not $reply -or $reply.jsonrpc -ne '2.0') { + throw 'Missing matching MCP response.' + } + if ($reply.error) { + throw "MCP error $($reply.error.code): $($reply.error.message)" + } + if ($null -eq $reply.result) { + throw 'Missing MCP result.' + } + + [pscustomobject]@{ Result = $reply.result; Headers = $response.Headers } + } + catch { + throw "Parallel MCP $($Message.method) failed: $_" + } +} + +function Invoke-ParallelMcp { + [CmdletBinding()] + param( + [string]$ToolName, + [hashtable]$Arguments, + [int]$TimeoutSec + ) + + $headers = @{ Accept = 'application/json, text/event-stream' } + $initialized = Invoke-ParallelMcpRequest -Headers $headers -TimeoutSec $TimeoutSec -Message @{ + jsonrpc = '2.0'; id = 1; method = 'initialize' + params = @{ + protocolVersion = '2025-03-26' + capabilities = @{} + clientInfo = @{ name = 'PSAI'; version = "$($ExecutionContext.SessionState.Module.Version)" } + } + } + $headers['MCP-Protocol-Version'] = $initialized.Result.protocolVersion + $sessionId = $initialized.Headers['Mcp-Session-Id'] -join '' + if ($sessionId) { $headers['Mcp-Session-Id'] = $sessionId } + + Invoke-ParallelMcpRequest -Headers $headers -TimeoutSec $TimeoutSec -Message @{ + jsonrpc = '2.0'; method = 'notifications/initialized' + } + $Arguments['session_id'] = $script:ParallelSessionId + $called = Invoke-ParallelMcpRequest -Headers $headers -TimeoutSec $TimeoutSec -Message @{ + jsonrpc = '2.0'; id = 2; method = 'tools/call' + params = @{ name = $ToolName; arguments = $Arguments } + } + + $text = @($called.Result.content | Where-Object { $_.type -eq 'text' } | + ForEach-Object { $_.text }) -join "`n" + if ($called.Result.isError) { throw "Parallel MCP $ToolName failed: $text" } + if (-not $text) { throw "Parallel MCP $ToolName returned no text content." } + $text +} diff --git a/Public/Get-ParallelWebContent.ps1 b/Public/Get-ParallelWebContent.ps1 new file mode 100644 index 0000000..6875d1f --- /dev/null +++ b/Public/Get-ParallelWebContent.ps1 @@ -0,0 +1,38 @@ +function Get-ParallelWebContent { + <# + .SYNOPSIS + Fetches relevant excerpts from web pages through the anonymous Parallel Search MCP. + + .DESCRIPTION + Returns excerpts as text. Use when a specific page is requested or search + excerpts are insufficient. No Parallel API key is required; free-tier rate + limits apply. Partial fetch failures remain in the returned tool text. + + .PARAMETER Urls + One to twenty HTTP or HTTPS URLs sharing the same objective. + + .PARAMETER Objective + Information to extract, up to 200 characters. Omit to use the server default. + + .PARAMETER TimeoutSec + Timeout in seconds for each MCP HTTP request. Defaults to 60. + + .EXAMPLE + Get-ParallelWebContent -Urls 'https://learn.microsoft.com/powershell/' -Objective 'What is PowerShell?' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateCount(1, 20)] + [ValidateScript({ [uri]::IsWellFormedUriString($_, [UriKind]::Absolute) -and ([uri]$_).Scheme -in 'http', 'https' })] + [string[]]$Urls, + [ValidateLength(0, 200)] + [string]$Objective, + [ValidateRange(1, 300)] + [int]$TimeoutSec = 60 + ) + + $arguments = @{ urls = @($Urls) } + if ($Objective) { $arguments['objective'] = $Objective } + Invoke-ParallelMcp -ToolName 'web_fetch' -Arguments $arguments -TimeoutSec $TimeoutSec +} diff --git a/Public/Search-ParallelWeb.ps1 b/Public/Search-ParallelWeb.ps1 new file mode 100644 index 0000000..fd43009 --- /dev/null +++ b/Public/Search-ParallelWeb.ps1 @@ -0,0 +1,39 @@ +function Search-ParallelWeb { + <# + .SYNOPSIS + Searches the web through the anonymous Parallel Search MCP. + + .DESCRIPTION + Returns source URLs and relevant excerpts as text for an agent to use directly. + No Parallel API key is required. The free endpoint is intended for light use + and has rate limits. Model inference is separate. + + .PARAMETER Objective + A specific description of the information to find, including freshness or source preferences. + + .PARAMETER SearchQueries + One or more related keyword queries for the objective, usually three to six words each. + + .PARAMETER TimeoutSec + Timeout in seconds for each MCP HTTP request. Defaults to 60. + + .EXAMPLE + Search-ParallelWeb -Objective 'Find the current PowerShell release' -SearchQueries 'PowerShell latest stable release' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$Objective, + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string[]]$SearchQueries, + [ValidateRange(1, 300)] + [int]$TimeoutSec = 60 + ) + + Invoke-ParallelMcp -ToolName 'web_search' -TimeoutSec $TimeoutSec -Arguments @{ + objective = $Objective + search_queries = @($SearchQueries) + } +} diff --git a/README.md b/README.md index 8d9e559..08e55d7 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,40 @@ $agent = New-Agent -Tools $tools -ShowToolCalls $agent | Get-AgentResponse 'What did Microsoft close at and the latest news for them?' ``` --> +## Parallel web search and fetch + +Register the optional Parallel tools with an agent: + +```powershell +$agent = New-Agent -Tools 'Search-ParallelWeb', 'Get-ParallelWebContent' +$agent | Get-AgentResponse 'Find the current stable PowerShell release and cite sources.' +``` + +These functions use [Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp) +over Streamable HTTP at `https://search.parallel.ai/mcp`. Search and fetch need +no Parallel API key and do not read saved credentials or environment keys. +The anonymous tier is intended for exploration and light use, with rate limits. +Your configured model still needs its own credentials and may incur inference costs. +Existing agents and provider defaults are unchanged. + +You can also run the tools directly without a model: + +```powershell +Search-ParallelWeb -Objective 'Find official PowerShell documentation' -SearchQueries 'Microsoft PowerShell documentation' +Get-ParallelWebContent -Urls 'https://learn.microsoft.com/powershell/' -Objective 'What is PowerShell?' +``` + +Both return source excerpts as text, including any server warnings or partial fetch +errors. HTTP and MCP failures raise errors that PSAI's agent loop adds to the tool +response. Each HTTP request times out after 60 seconds by default; use `-TimeoutSec` +to change it. Search and fetch share an anonymous conversation identifier until +the module is reimported. Reimport with `-Force` to start a separate conversation. + +For a checkout, run `Import-Module ./PSAI.psd1 -Force` in PowerShell 7.1 or newer; +no extra modules are needed. The [Parallel quickstart](examples/agent-quickstarts/06-ParallelWebSearch.ps1) +uses the same agent path. Run its tests with Pester 5: +`Invoke-Pester ./Tests/ParallelWeb.tests.ps1`. + ## Slash Commands PSAI Agents now support slash commands in interactive sessions, allowing you to perform quick actions without leaving the conversation flow. diff --git a/Tests/ParallelWeb.tests.ps1 b/Tests/ParallelWeb.tests.ps1 new file mode 100644 index 0000000..1bab4db --- /dev/null +++ b/Tests/ParallelWeb.tests.ps1 @@ -0,0 +1,250 @@ +BeforeDiscovery { + Import-Module "$PSScriptRoot/../PSAI.psd1" -Force +} + +Describe 'Parallel MCP HTTP encoding' { + InModuleScope PSAI { + It 'sends Unicode objectives and queries as UTF-8 bytes over HTTP' { + # Redirect only the destination; the real cmdlet serializes the body. + $portReservation = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $portReservation.Start() + $script:loopbackUri = "http://127.0.0.1:$($portReservation.LocalEndpoint.Port)/" + $portReservation.Stop() + $readyPath = Join-Path $TestDrive 'listener-ready' + $server = Start-Job -ArgumentList $script:loopbackUri, $readyPath -ScriptBlock { + param($Uri, $ReadyPath) + $listener = [System.Net.HttpListener]::new() + $listener.Prefixes.Add($Uri) + try { + $listener.Start() + Set-Content -Path $ReadyPath -Value 'ready' + foreach ($requestNumber in 1..3) { + $pending = $listener.BeginGetContext($null, $null) + if (-not $pending.AsyncWaitHandle.WaitOne(20000)) { throw 'Local HTTP request timed out.' } + $context = $listener.EndGetContext($pending) + $buffer = [System.IO.MemoryStream]::new() + try { + $context.Request.InputStream.CopyTo($buffer) + [pscustomobject]@{ + BodyBase64 = [Convert]::ToBase64String($buffer.ToArray()) + ContentType = $context.Request.ContentType + UserAgent = $context.Request.UserAgent + Authorization = $context.Request.Headers['Authorization'] + ApiKey = $context.Request.Headers['x-api-key'] + } + } + finally { $buffer.Dispose() } + $reply = switch ($requestNumber) { + 1 { '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26"}}' } + 2 { '' } + 3 { '{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"Unicode search received"}]}}' } + } + $responseBytes = [System.Text.Encoding]::UTF8.GetBytes($reply) + $context.Response.ContentType = 'application/json; charset=utf-8' + $context.Response.ContentLength64 = $responseBytes.Length + $context.Response.OutputStream.Write($responseBytes, 0, $responseBytes.Length) + $context.Response.Close() + } + } + finally { $listener.Close() } + } + try { + $deadline = [DateTime]::UtcNow.AddSeconds(10) + while (-not (Test-Path $readyPath) -and [DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 50 + } + Test-Path $readyPath | Should -BeTrue + $script:realWebRequest = Get-Command Microsoft.PowerShell.Utility\Invoke-WebRequest + Mock Invoke-WebRequest { + param($Uri, $Method, $ContentType, $Headers, $UserAgent, $Body, $TimeoutSec, $ConnectionTimeoutSeconds, $OperationTimeoutSeconds) + $forward = @{} + $PSBoundParameters + $forward['Uri'] = $script:loopbackUri + & $script:realWebRequest @forward + } + Search-ParallelWeb -Objective '東京 café' -SearchQueries '東京 café' | Should -Be 'Unicode search received' + $null = Wait-Job $server -Timeout 10 + $server.State | Should -Be 'Completed' + $requests = @(Receive-Job $server -ErrorAction Stop) + $requests.Count | Should -Be 3 + $bytes = [Convert]::FromBase64String($requests[2].BodyBase64) + # Assert the wire bytes, then decode strictly before parsing JSON. + ([BitConverter]::ToString($bytes)) | Should -Match 'E6-9D-B1-E4-BA-AC-20-63-61-66-C3-A9' + $utf8 = [System.Text.UTF8Encoding]::new($false, $true) + $arguments = ($utf8.GetString($bytes) | ConvertFrom-Json).params.arguments + $arguments.objective | Should -Be '東京 café' + $arguments.search_queries[0] | Should -Be '東京 café' + foreach ($request in $requests) { + $request.ContentType | Should -Be 'application/json; charset=utf-8' + $request.UserAgent | Should -Be "PSAI/$((Get-Module PSAI).Version) (https://github.com/dfinke/PSAI)" + $request.Authorization | Should -BeNullOrEmpty + $request.ApiKey | Should -BeNullOrEmpty + } + + } + finally { + Stop-Job $server + Remove-Job $server -Force + } + } + } +} + +Describe 'Parallel MCP tools' { + InModuleScope PSAI { + BeforeEach { + $script:requests = [System.Collections.Generic.List[object]]::new() + $script:replyMode = 'json' + Mock Invoke-WebRequest { + param($Uri, $Method, $ContentType, $Headers, $UserAgent, $Body, $TimeoutSec, $ConnectionTimeoutSeconds, $OperationTimeoutSeconds) + $message = $Body | ConvertFrom-Json -Depth 20 + $script:requests.Add(@{ + Uri = $Uri; Method = $Method; ContentType = $ContentType + Headers = $Headers.Clone(); UserAgent = $UserAgent + Message = $message + TimeoutSec = if ($ConnectionTimeoutSeconds) { $ConnectionTimeoutSeconds } else { $TimeoutSec } + OperationTimeoutSeconds = $OperationTimeoutSeconds + }) + if ($message.method -eq 'initialize') { + return @{ + Headers = @{ 'Content-Type' = 'application/json'; 'Mcp-Session-Id' = 'fixture-session' } + Content = '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26"}}' + } + } + if ($message.method -eq 'notifications/initialized') { + return @{ Headers = @{}; Content = '' } + } + $reply = @{ + jsonrpc = '2.0'; id = 2 + result = @{ content = @(@{ type = 'text'; text = '{"results":[{"url":"https://learn.microsoft.com/powershell/","excerpts":["PowerShell is a task automation solution."]}],"warnings":[],"errors":[]}' }) } + } + switch ($script:replyMode) { + 'tool-error' { $reply.result.isError = $true; $reply.result.content[0].text = 'Free-tier limit reached' } + 'rpc-error' { $reply.Remove('result'); $reply.error = @{ code = -32602; message = 'Invalid tool arguments' } } + 'wrong-id' { $reply.id = 99 } + 'empty' { $reply.result.content = @() } + 'http-error' { throw 'HTTP 429 Too Many Requests; Retry-After: 60' } + } + $json = $reply | ConvertTo-Json -Depth 20 -Compress + if ($script:replyMode -eq 'sse') { + return @{ + Headers = @{ 'Content-Type' = 'text/event-stream; charset=utf-8' } + Content = "event: message`r`ndata: {`"jsonrpc`":`"2.0`",`"method`":`"notifications/progress`"}`r`n`r`nevent: message`r`ndata: $json`r`n`r`n" + } + } + @{ Headers = @{ 'Content-Type' = 'application/json' }; Content = $json } + } + } + + It 'initializes anonymous HTTP MCP and preserves queries, headers, timeout and useful output' { + $text = Search-ParallelWeb -Objective 'Find PowerShell docs' -SearchQueries 'PowerShell docs', 'Microsoft PowerShell guide' -TimeoutSec 17 + ($text | ConvertFrom-Json).results[0].url | Should -Be 'https://learn.microsoft.com/powershell/' + $script:requests.Count | Should -Be 3 + ($script:requests.Message.method -join ',') | Should -Be 'initialize,notifications/initialized,tools/call' + foreach ($request in $script:requests) { + $request.Uri | Should -Be 'https://search.parallel.ai/mcp' + $request.Method | Should -Be 'Post' + $request.ContentType | Should -Be 'application/json; charset=utf-8' + $request.UserAgent | Should -Be "PSAI/$((Get-Module PSAI).Version) (https://github.com/dfinke/PSAI)" + $request.TimeoutSec | Should -Be 17 + if ((Get-Command Microsoft.PowerShell.Utility\Invoke-WebRequest).Parameters.ContainsKey('OperationTimeoutSeconds')) { + $request.OperationTimeoutSeconds | Should -Be 17 + } + $request.Headers.Accept | Should -Be 'application/json, text/event-stream' + $request.Headers.ContainsKey('Authorization') | Should -BeFalse + $request.Headers.ContainsKey('x-api-key') | Should -BeFalse + } + $script:requests[0].Headers.ContainsKey('Mcp-Session-Id') | Should -BeFalse + $script:requests[1].Headers['Mcp-Session-Id'] | Should -Be 'fixture-session' + $script:requests[2].Headers['MCP-Protocol-Version'] | Should -Be '2025-03-26' + $argsSent = $script:requests[2].Message.params.arguments + $argsSent.objective | Should -Be 'Find PowerShell docs' + $argsSent.search_queries.Count | Should -Be 2 + $argsSent.search_queries[1] | Should -Be 'Microsoft PowerShell guide' + $argsSent.session_id | Should -Be $script:ParallelSessionId + } + + It 'fetches excerpts and shares the anonymous conversation identifier with search' { + $null = Search-ParallelWeb -Objective 'Find docs' -SearchQueries 'PowerShell docs' + $text = Get-ParallelWebContent -Urls 'https://learn.microsoft.com/powershell/' -Objective 'What is PowerShell?' + $text | Should -Match 'task automation' + $call = $script:requests[5].Message.params + $call.name | Should -Be 'web_fetch' + $call.arguments.urls.Count | Should -Be 1 + $call.arguments.urls[0] | Should -Be 'https://learn.microsoft.com/powershell/' + $call.arguments.objective | Should -Be 'What is PowerShell?' + $call.arguments.session_id | Should -Be $script:requests[2].Message.params.arguments.session_id + $call.arguments.PSObject.Properties.Name | Should -Not -Contain 'full_content' + } + + It 'omits an unspecified fetch objective' { + $null = Get-ParallelWebContent -Urls 'https://learn.microsoft.com/powershell/' + $script:requests[2].Message.params.arguments.PSObject.Properties.Name | Should -Not -Contain 'objective' + } + + It 'accepts a Streamable HTTP SSE response with a preceding notification' { + $script:replyMode = 'sse' + Search-ParallelWeb -Objective 'Find docs' -SearchQueries 'PowerShell docs' | Should -Match 'task automation' + } + + It 'reports to the caller' -TestCases @( + @{ Mode = 'tool-error'; Expected = '*Free-tier limit reached*' } + @{ Mode = 'rpc-error'; Expected = '*MCP error -32602: Invalid tool arguments*' } + @{ Mode = 'wrong-id'; Expected = '*Missing matching MCP response*' } + @{ Mode = 'empty'; Expected = '*returned no text content*' } + @{ Mode = 'http-error'; Expected = '*429*Retry-After: 60*' } + ) { + param($Mode, $Expected) + $script:replyMode = $Mode + { Search-ParallelWeb -Objective 'Find docs' -SearchQueries 'PowerShell docs' } | Should -Throw $Expected + } + + It 'rejects unsupported URL schemes and out-of-range timeouts before HTTP' { + { Get-ParallelWebContent -Urls 'file:///etc/passwd' } | Should -Throw + { Search-ParallelWeb -Objective 'Find docs' -SearchQueries 'PowerShell docs' -TimeoutSec 0 } | Should -Throw + $script:requests.Count | Should -Be 0 + } + + It 'registers both functions and dispatches search and fetch through the PSAI agent loop' { + $script:modelTurn = 0 + Mock Invoke-OAIChatCompletion { + param($Messages, $Tools) + $Tools.function.name | Should -Contain 'Search-ParallelWeb' + $Tools.function.name | Should -Contain 'Get-ParallelWebContent' + $script:modelTurn++ + if ($script:modelTurn -le 2) { + $name = if ($script:modelTurn -eq 1) { 'Search-ParallelWeb' } else { 'Get-ParallelWebContent' } + $arguments = if ($script:modelTurn -eq 1) { + '{"Objective":"Find PowerShell docs","SearchQueries":["PowerShell documentation"]}' + } + else { '{"Urls":["https://learn.microsoft.com/powershell/"],"Objective":"What is PowerShell?"}' } + return @{ choices = @(@{ finish_reason = 'tool_calls'; message = @{ + role = 'assistant'; content = $null; tool_calls = @(@{ + id = "call-$script:modelTurn"; type = 'function'; function = @{ name = $name; arguments = $arguments } + }) + } }) } + } + $toolMessages = @($Messages | Where-Object role -EQ 'tool') + $toolMessages.Count | Should -Be 2 + foreach ($message in $toolMessages) { $message.content | Should -Match 'task automation' } + @{ choices = @(@{ finish_reason = 'stop'; message = @{ role = 'assistant'; content = 'PowerShell automates tasks: https://learn.microsoft.com/powershell/' } }) } + } + $providerBefore = Get-OAIProvider + $agent = New-Agent -Tools 'Search-ParallelWeb', 'Get-ParallelWebContent' + $agent | Get-AgentResponse 'Find PowerShell docs and read them.' | Should -Match 'https://learn.microsoft.com/powershell/' + $script:modelTurn | Should -Be 3 + $script:requests.Count | Should -Be 6 + Get-OAIProvider | Should -Be $providerBefore + } + + It 'adds MCP failures to agent tool messages' { + $script:replyMode = 'tool-error' + $response = @{ choices = @(@{ message = @{ tool_calls = @(@{ + id = 'failed-call'; function = @{ name = 'Search-ParallelWeb'; arguments = '{"Objective":"Find docs","SearchQueries":["PowerShell docs"]}' } + }) } }) } + $message = Invoke-OAIFunctionCall $response + $message.role | Should -Be 'tool' + $message.tool_call_id | Should -Be 'failed-call' + $message.content | Should -Match 'Free-tier limit reached' + } + } +} diff --git a/examples/agent-quickstarts/06-ParallelWebSearch.ps1 b/examples/agent-quickstarts/06-ParallelWebSearch.ps1 new file mode 100644 index 0000000..32ef599 --- /dev/null +++ b/examples/agent-quickstarts/06-ParallelWebSearch.ps1 @@ -0,0 +1,10 @@ +param( + [string]$Prompt = 'Find the current stable PowerShell release and cite your sources.', + [switch]$ShowToolCalls +) + +Import-Module "$PSScriptRoot/../../PSAI.psd1" -Force +$agent = New-Agent -Tools 'Search-ParallelWeb', 'Get-ParallelWebContent' ` + -Instructions 'Search for current facts. Fetch only when excerpts are insufficient. Cite source URLs.' ` + -ShowToolCalls:$ShowToolCalls +$agent | Get-AgentResponse -Prompt $Prompt