Functions/GenXdev.AI/Invoke-CommandFromToolCall.ps1

################################################################################
<#
.SYNOPSIS
Executes a tool call function with validation and parameter filtering.
 
.DESCRIPTION
This function processes tool calls by validating arguments, filtering parameters,
and executing callbacks with proper confirmation handling. It supports both script
block and command info callbacks.
 
.PARAMETER ToolCall
A hashtable containing the function details and arguments to be executed.
 
.PARAMETER Functions
Array of function definitions that can be called as tools.
 
.PARAMETER ExposedCmdLets
Array of PowerShell command definitions available as tools.
 
.PARAMETER NoConfirmationToolFunctionNames
Array of command names that can execute without user confirmation.
 
.PARAMETER ForceAsText
Forces the output to be formatted as text.
 
.EXAMPLE
Invoke-CommandFromToolCall -ToolCall $toolCall -Functions $functions `
    -ExposedCmdLets $exposedCmdlets
 
.EXAMPLE
$result = Invoke-CommandFromToolCall $toolCall $functions -ForceAsText
#>

function Invoke-CommandFromToolCall {

    [CmdletBinding()]
    param(
        ########################################################################
        [Parameter(
            Mandatory = $true,
            Position = 0,
            HelpMessage = "Tool call object containing function details and args"
        )]
        [ValidateNotNull()]
        [hashtable]
        $ToolCall,
        ########################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = "Array of function definitions"
        )]
        [hashtable[]]
        $Functions = @(),
        ########################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = "Array of PowerShell command definitions to use as tools"
        )]
        [GenXdev.Helpers.ExposedCmdletDefinition[]]
        $ExposedCmdLets = @(),
        ########################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = "Array of command names that don't require confirmation"
        )]
        [string[]]
        [Alias("NoConfirmationFor")]
        $NoConfirmationToolFunctionNames = @(),
        ########################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = "Force output as text"
        )]
        [switch]
        $ForceAsText
    )

    begin {

        # initialize the result object with default values
        $result = [GenXdev.Helpers.ExposedToolCallInvocationResult] @{}
        $result.CommandExposed = $false
        $result.Reason = "Function not found, check spelling and availability"
        $result.Output = $null
        $result.OutputType = $null
        $result.FullName = $null

        # extract and convert arguments from the tool call
        $result.UnfilteredArguments = $ToolCall.function.arguments |
        ConvertFrom-Json -ErrorAction SilentlyContinue |
        ConvertTo-HashTable |
        Select-Object -First 1

        Write-Verbose "Processing tool call: $($ToolCall.function.name)"
        Write-Verbose "Unfiltered arguments: $($result.UnfilteredArguments |
            ConvertTo-Json)"


        $result.FilteredArguments = [hashtable] @{}
        $result.ExposedCmdLet = $null
        $result.Error = $null
    }

    process {

        $fullToolFunction = $ToolCall.function.name
        $toolFunction = $fullToolFunction.Split("\")[1];

        # find all exising predefined function definitions that match the tool call
        $matchedFunctions = @(
            $Functions.function | ForEach-Object {

                $fullFunction = $PSItem.Name
                $function = $fullFunction.Split("\")[1];
                if ([string]::IsNullOrWhiteSpace($function) -or [string]::IsNullOrWhiteSpace($toolFunction)) {

                    if ([string]::IsNullOrWhiteSpace($function) -and [string]::IsNullOrWhiteSpace($toolFunction)) {

                        if ($fullFunction -eq $fullToolFunction) {

                            $PSItem
                        }
                    }
                    elseif ([string]::IsNullOrWhiteSpace($toolFunction)) {

                        if ($function -eq $fullToolFunction) {

                            $PSItem
                        }
                    }
                    else {

                        if ($fullFunction -eq $toolFunction) {

                            $PSItem
                        }
                    }
                }
                else {

                    if ($function -eq $toolFunction) {

                        $PSItem
                    }
                }
            }
        )

        # process each matched function
        foreach ($matchedFunction in $matchedFunctions) {

            # start optimistic
            $result.CommandExposed = $true

            # start by checking if all required parameters are present
            $matchedFunction.parameters.required | ForEach-Object {

                # reference next required parameter's name
                $definedParamName = $_

                $foundArguments = @(
                    $result.UnfilteredArguments.GetEnumerator() | ForEach-Object {
                        if ($PSItem.Name -EQ $definedParamName) { $PSItem } }
                );

                if ($foundArguments.Count -eq 0) {

                    $result.CommandExposed = $false
                    $result.Reason = "Missing required parameter: $definedParamName"
                    $result.Output = $null
                    $result.OutputType = $null
                    $result.FullName = $null
                    $result.UnfilteredArguments = $ToolCall.function.arguments | ConvertFrom-Json -ErrorAction SilentlyContinue | ConvertTo-HashTable | Select-Object -First 1
                    $result.FilteredArguments = [hashtable] @{}
                    $result.ExposedCmdLet = $null
                    $result.Error = $null

                    # maybe there is another function that matches
                    break;
                }
            }

            if (-not $result.CommandExposed) {

                # maybe there is another function that matches
                break;
            }

            # check if all parameters are valid
            [Hashtable] $properies = $matchedFunction.parameters.properties

            foreach ($unfilteredArgument in $result.UnfilteredArguments.GetEnumerator()) {

                $unfilteredArgumentName = $unfilteredArgument.Name

                if ($unfilteredArgumentName -notin $properies.Keys) {

                    $result.CommandExposed = $false
                    $result.Reason = "Function found, but provided argument with name $unfilteredArgumentName not found in advertised tool function parameters"
                    $result.Output = $null
                    $result.OutputType = $null
                    $result.FullName = $null
                    $result.UnfilteredArguments = $ToolCall.function.arguments | ConvertFrom-Json -ErrorAction SilentlyContinue | ConvertTo-HashTable | Select-Object -First 1
                    $result.FilteredArguments = [hashtable] @{}
                    $result.ExposedCmdLet = $null
                    $result.Error = $null

                    # maybe there is another function that matches
                    break;
                }
            }

            if (-not $result.CommandExposed) {

                # maybe there is another function that matches
                break;
            }

            # add all properties and their values from the unfiltered to the filtered arguments
            $result.FilteredArguments = [hashtable] @{}
            foreach ($unfilteredArgument in $result.UnfilteredArguments.GetEnumerator()) {

                $unfilteredArgumentName = $unfilteredArgument.Name

                $result.FilteredArguments."$unfilteredArgumentName" = $unfilteredArgument.Value
            }

            # check if there are any forced parameters
            $foundCmdlets = @(
                $ExposedCmdLets |
                Sort-Object -Property Name -Descending |
                ForEach-Object {
                    if (
                        ($_.Name -EQ ($matchedFunction.name)) -or
                        ($_.Name -like "*\$($matchedFunction.name)") -or
                        ($matchedFunction.name -like "*\$($_.Name)")
                    ) { $_ }
                }
            );

            foreach ($exposedCmdLet in $foundCmdlets) {

                $exposedCmdLetParamNames = @($exposedCmdLet.AllowedParams | ForEach-Object { "$_".Split("=")[0] }) + @($exposedCmdLet.ForcedParams)

                $foundUnmatchingParam = $false;
                foreach ($filteredArgument in $result.FilteredArguments.GetEnumerator()) {

                    $filteredArgumentName = $filteredArgument.Name;

                    if ($filteredArgumentName -notin $exposedCmdLetParamNames) {

                        $foundUnmatchingParam = $true
                        break;
                    }
                }

                if ($foundUnmatchingParam) {

                    $result.CommandExposed = $false
                    $result.Reason = "Function found, but provided argument with name $filteredArgument. Name not found in advertised tool function parameters"
                    $result.Output = $null
                    $result.OutputType = $null
                    $result.FullName = $null
                    $result.UnfilteredArguments = $ToolCall.function.arguments | ConvertFrom-Json -ErrorAction SilentlyContinue | ConvertTo-HashTable | Select-Object -First 1
                    $result.FilteredArguments = [hashtable] @{}
                    $result.ExposedCmdLet = $null
                    $result.Error = $null

                    # maybe there is another function that matches
                    continue
                }

                foreach ($forcedParam in $exposedCmdLet.ForcedParams) {

                    $result.FilteredArguments."$($forcedParam.Name)" = $forcedParam.Value
                }

                $result.ExposedCmdLet = $exposedCmdLet
            }

            if (-not $result.CommandExposed) {

                # maybe there is another function that matches
                break;
            }

            $result.Reason = $null
            $result.Output = $null
            $result.FullName = $ToolCall.function.name

            $cb = $matchedFunction.callback;
            if ($cb -isnot [System.Management.Automation.ScriptBlock] -and
                $cb -isnot [System.Management.Automation.CommandInfo]) {

                throw "Callback is not a script block or command info, type: $(($cb.GetType().FullName))"
            }

            $tmpResult = $null

            try {
                # Execute callback
                # Add confirmation prompt for tool functions that require it
                if (($NoConfirmationToolFunctionNames -and $NoConfirmationToolFunctionNames.IndexOf($toolCall.function.name) -ge 0) -or
                    ($result.ExposedCmdLet -and (-not $result.ExposedCmdLet.Confirm))) {

                    $filteredArguments = $result.FilteredArguments;
                    $tmpResult = &$cb @filteredArguments
                }
                else {

                    $location = (Get-Location).Path
                    $functionName = $toolCall.function.Name
                    $filteredArguments = $result.FilteredArguments;
                    $parametersLine = $filteredArguments.GetEnumerator() | ForEach-Object {

                        $skip = $false;
                        $filteredArgumentName = $_.Name

                        if ($result.ExposedCmdlet) {

                            foreach ($name in $result.ExposedCmdLet.DontShowDuringConfirmationParamNames) {

                                if ($filteredArgumentName -like $name) {

                                    $skip = $true;
                                    break;
                                }
                            }
                        }

                        if (-not $skip) {

                            "-$($_.Name) ($($_.Value | ConvertTo-Json -Compress -Depth 10 -WarningAction SilentlyContinue))"
                        }
                        else {

                            "-$($_.Name) [value]"
                        }
                    } | ForEach-Object {

                        $_ -join " "
                    }

                    # Add confirmation prompt for tool functions that require it
                    switch ($host.ui.PromptForChoice(
                            "Confirm",
                            "Are you sure you want to ALLOW the LLM to execute: `r`nPS $location> $functionName $parametersLine",
                            @(
                                "&Allow",
                                "&Disallow, reject"), 0)) {
                        0 {
                            $tmpResult = &$cb @filteredArguments
                            break;
                        }

                        1 {
                            throw "User cancelled execution"
                            break;
                        }
                    }
                }

                if ($null -eq $tmpResult) {

                    $tmpResult = "null # No output (success, void return)"
                }
                else {

                    $result.OutputType = "string"
                }

                if ($tmpResult -isnot [string]) {

                    $result.OutputType = "application/json"
                    $jsonDepth = 2;
                    if ($result.ExposedCmdLet -and $result.ExposedCmdLet.JsonDepth) {

                        $jsonDepth = $result.ExposedCmdLet.JsonDepth;
                    }
                    $asText = $ForceAsText -or ($result.ExposedCmdLet -and ($result.ExposedCmdLet.OutputText -eq $true));
                    if ($asText) {

                        $tmpResult = (@($tmpResult) | ForEach-Object { $_ | Out-String }) | ConvertTo-Json -Depth $jsonDepth -WarningAction SilentlyContinue
                    }
                    else {

                        if ($tmpResult -is [System.ValueType]) {

                            $tmpResult = $tmpResult | ConvertTo-Json -Depth $jsonDepth -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
                        }
                        else {

                            $tmpResult = $tmpResult | ConvertTo-HashTable | ConvertTo-Json -Depth $jsonDepth -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
                        }
                    }
                }

                $result.Output = $tmpResult
            }
            catch {
                $result.Error = [PSCustomObject]@{
                    error           = $_.Exception.Message
                    exceptionThrown = $true
                    exceptionClass  = $_.Exception.GetType().FullName
                } | ConvertTo-Json -Compress -Depth 3 -WarningAction SilentlyContinue
            }

            # we only execute the first matching function
            break;
        }
    }

    end {

        Write-Output $result
    }
}
################################################################################