diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index a73fb6916b..8c74f21130 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -369,6 +369,53 @@ jobs: path: ./python/pytest.xml if-no-files-found: ignore + # Foundry Hosting integration tests + python-tests-foundry-hosting: + name: Python Integration Tests - Foundry Hosting + runs-on: ubuntu-latest + environment: integration + timeout-minutes: 60 + env: + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.checkout-ref }} + persist-credentials: false + - name: Set up python and install the project + id: python-setup + uses: ./.github/actions/python-setup + with: + python-version: ${{ env.UV_PYTHON }} + os: ${{ runner.os }} + - name: Azure CLI Login + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Test with pytest (Foundry Hosting integration) + timeout-minutes: 15 + run: > + uv run pytest --import-mode=importlib + packages/foundry_hosting/tests + -m integration + -n logical --dist worksteal + --timeout=120 --session-timeout=900 --timeout_method thread + --retries 2 --retry-delay 5 + --junitxml=pytest.xml + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results-foundry-hosting + path: ./python/pytest.xml + if-no-files-found: ignore + # Azure Cosmos integration tests python-tests-cosmos: name: Python Integration Tests - Cosmos @@ -435,6 +482,7 @@ jobs: python-tests-misc-integration, python-tests-functions, python-tests-foundry, + python-tests-foundry-hosting, python-tests-cosmos, ] runs-on: ubuntu-latest @@ -498,6 +546,7 @@ jobs: python-tests-misc-integration, python-tests-functions, python-tests-foundry, + python-tests-foundry-hosting, python-tests-cosmos ] steps: diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 0513f47a2e..1ad5019951 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -38,6 +38,7 @@ jobs: miscChanged: ${{ steps.filter.outputs.misc }} functionsChanged: ${{ steps.filter.outputs.functions }} foundryChanged: ${{ steps.filter.outputs.foundry }} + foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }} cosmosChanged: ${{ steps.filter.outputs.cosmos }} steps: - uses: actions/checkout@v6 @@ -80,6 +81,8 @@ jobs: - 'python/packages/foundry/**' - 'python/samples/**/providers/foundry/**' - 'python/samples/02-agents/embeddings/foundry_embeddings.py' + foundry_hosting: + - 'python/packages/foundry_hosting/**' cosmos: - 'python/packages/azure-cosmos/**' # run only if 'python' files were changed @@ -521,6 +524,67 @@ jobs: path: ./python/pytest.xml if-no-files-found: ignore + # Foundry Hosting integration tests + python-tests-foundry-hosting: + name: Python Tests - Foundry Hosting Integration + needs: paths-filter + if: > + github.event_name != 'pull_request' && + needs.paths-filter.outputs.pythonChanges == 'true' && + (github.event_name != 'merge_group' || + needs.paths-filter.outputs.foundryHostingChanged == 'true' || + needs.paths-filter.outputs.coreChanged == 'true') + runs-on: ubuntu-latest + environment: integration + env: + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v6 + - name: Set up python and install the project + id: python-setup + uses: ./.github/actions/python-setup + with: + python-version: ${{ env.UV_PYTHON }} + os: ${{ runner.os }} + - name: Azure CLI Login + if: github.event_name != 'pull_request' + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Test with pytest (Foundry Hosting integration) + timeout-minutes: 15 + run: > + uv run pytest --import-mode=importlib + packages/foundry_hosting/tests + -m integration + -n logical --dist worksteal + --timeout=120 --session-timeout=900 --timeout_method thread + --retries 2 --retry-delay 5 + --junitxml=pytest.xml + working-directory: ./python + - name: Surface failing tests + if: always() + uses: pmeier/pytest-results-action@v0.7.2 + with: + path: ./python/pytest.xml + summary: true + display-options: fEX + fail-on-empty: false + title: Foundry Hosting integration test results + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results-foundry-hosting + path: ./python/pytest.xml + if-no-files-found: ignore + # TODO: Add python-tests-lab # Azure Cosmos integration tests @@ -602,6 +666,7 @@ jobs: python-tests-misc-integration, python-tests-functions, python-tests-foundry, + python-tests-foundry-hosting, python-tests-cosmos, ] runs-on: ubuntu-latest @@ -662,6 +727,7 @@ jobs: python-tests-misc-integration, python-tests-functions, python-tests-foundry, + python-tests-foundry-hosting, python-tests-cosmos, ] steps: diff --git a/.gitignore b/.gitignore index da436cd090..283f626207 100644 --- a/.gitignore +++ b/.gitignore @@ -242,3 +242,7 @@ python/dotnet-ref # Generated filtered solution files (created by eng/scripts/New-FilteredSolution.ps1) dotnet/filtered-*.slnx **/*.lscache + +# Local tool state +.omc/ +.omx/ diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index fff7b6a7d4..c57af55ef9 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -56,15 +56,15 @@ - - - - - - - - - + + + + + + + + + diff --git a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs index e95bde61df..b2068c4c0b 100644 --- a/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs +++ b/dotnet/samples/02-agents/AgentSkills/SubprocessScriptRunner.cs @@ -5,16 +5,16 @@ // This is provided for demonstration purposes only. using System.Diagnostics; +using System.Text.Json; using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; /// /// Executes file-based skill scripts as local subprocesses. /// /// -/// This runner uses the script's absolute path, converts the arguments -/// to CLI flags, and returns captured output. It is intended for -/// demonstration purposes only. +/// This runner uses the script's absolute path and converts the arguments +/// to CLI arguments. When the LLM sends a JSON array, each element is used +/// as a positional argument. It is intended for demonstration purposes only. /// internal static class SubprocessScriptRunner { @@ -24,7 +24,8 @@ internal static class SubprocessScriptRunner public static async Task RunAsync( AgentFileSkill skill, AgentFileSkillScript script, - AIFunctionArguments arguments, + JsonElement? arguments, + IServiceProvider? serviceProvider, CancellationToken cancellationToken) { if (!File.Exists(script.FullPath)) @@ -61,24 +62,27 @@ internal static class SubprocessScriptRunner startInfo.FileName = script.FullPath; } - if (arguments is not null) + if (arguments is { ValueKind: JsonValueKind.Array } json) { - foreach (var (key, value) in arguments) + // Positional CLI arguments + foreach (var element in json.EnumerateArray()) { - if (value is bool boolValue) + if (element.ValueKind != JsonValueKind.String) { - if (boolValue) - { - startInfo.ArgumentList.Add(NormalizeKey(key)); - } - } - else if (value is not null) - { - startInfo.ArgumentList.Add(NormalizeKey(key)); - startInfo.ArgumentList.Add(value.ToString()!); + throw new InvalidOperationException( + $"File-based skill scripts only accept string CLI arguments but received a JSON element of kind '{element.ValueKind}'. " + + "All array elements must be JSON strings."); } + + startInfo.ArgumentList.Add(element.GetString()!); } } + else if (arguments is not null && arguments.Value.ValueKind != JsonValueKind.Null && arguments.Value.ValueKind != JsonValueKind.Undefined) + { + throw new InvalidOperationException( + $"Expected a JSON array of CLI arguments but received {arguments.Value.ValueKind}. " + + "File-based skill scripts expect positional arguments as a JSON array of strings."); + } Process? process = null; try @@ -128,10 +132,4 @@ internal static class SubprocessScriptRunner process?.Dispose(); } } - - /// - /// Normalizes a parameter key to a consistent --flag format. - /// Models may return keys with or without leading dashes (e.g., "value" vs "--value"). - /// - private static string NormalizeKey(string key) => "--" + key.TrimStart('-'); } diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md index 384fd358a7..4f455b3dec 100644 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/README.md @@ -65,6 +65,53 @@ Workflow orchestration started for CancelOrder. Orchestration runId: abc123def45 > > If not provided, a unique run ID is auto-generated. +### Wait for the Workflow Result + +By default, the HTTP endpoint returns `202 Accepted` immediately with the run ID. If you want to wait for the workflow to complete and get the result in the response, add the `x-ms-wait-for-response: true` header: + +Bash (Linux/macOS/WSL): + +```bash +curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \ + -H "Content-Type: text/plain" \ + -H "x-ms-wait-for-response: true" \ + -d "12345" +``` + +PowerShell: + +```powershell +Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/workflows/CancelOrder/run ` + -ContentType text/plain ` + -Headers @{ "x-ms-wait-for-response" = "true" } ` + -Body "12345" +``` + +The response will contain the workflow result as plain text (200 OK): + +```text +Cancellation email sent for order 12345 to jerry@example.com. +``` + +To get the result as JSON, also include the `Accept: application/json` header: + +```bash +curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \ + -H "Content-Type: text/plain" \ + -H "x-ms-wait-for-response: true" \ + -H "Accept: application/json" \ + -d "12345" +``` + +```json +{ + "runId": "abc123def456", + "workflowStatus": "Completed", + "result": "Cancellation email sent for order 12345 to jerry@example.com." +} +``` + In the function app logs, you will see the sequential execution of each executor: ```text diff --git a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http index 8366216a6c..fb9793f449 100644 --- a/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http +++ b/dotnet/samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/demo.http @@ -7,6 +7,21 @@ Content-Type: text/plain 12345 +### Cancel an order and wait for the result +POST {{authority}}/api/workflows/CancelOrder/run +Content-Type: text/plain +x-ms-wait-for-response: true + +12345 + +### Cancel an order and wait for the result (JSON response) +POST {{authority}}/api/workflows/CancelOrder/run +Content-Type: text/plain +Accept: application/json +x-ms-wait-for-response: true + +12345 + ### Cancel an order with a custom run ID POST {{authority}}/api/workflows/CancelOrder/run?runId=my-custom-id-123 Content-Type: text/plain @@ -19,6 +34,13 @@ Content-Type: text/plain 12345 +### Get order status and wait for the result +POST {{authority}}/api/workflows/OrderStatus/run +Content-Type: text/plain +x-ms-wait-for-response: true + +12345 + ### Batch cancel orders with a complex JSON input POST {{authority}}/api/workflows/BatchCancelOrders/run Content-Type: application/json diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj index d925172007..a0b9e2e0d8 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj @@ -13,6 +13,8 @@ + + diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj index 99ecde93ca..af02c44aad 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj @@ -34,6 +34,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs index e6c94347a1..376f2fa2ca 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctions.cs @@ -21,6 +21,8 @@ internal static class BuiltInFunctions internal const string HttpPrefix = "http-"; internal const string McpToolPrefix = "mcptool-"; + private const string WaitForResponseHeaderName = "x-ms-wait-for-response"; + internal static readonly string RunAgentHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunAgentHttpAsync)}"; internal static readonly string RunAgentEntityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeAgentAsync)}"; internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}"; @@ -62,6 +64,11 @@ internal static class BuiltInFunctions StartOrchestrationOptions? options = instanceId is not null ? new StartOrchestrationOptions(instanceId) : null; string resolvedInstanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, orchestrationInput, options); + if (ShouldWaitForResponse(req, defaultValue: false)) + { + return await WaitForWorkflowCompletionAsync(req, client, context, resolvedInstanceId); + } + HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted); await response.WriteStringAsync($"Workflow orchestration started for {workflowName}. Orchestration runId: {resolvedInstanceId}"); return response; @@ -304,15 +311,7 @@ internal static class BuiltInFunctions } // Check if we should wait for response (default is true) - bool waitForResponse = true; - if (req.Headers.TryGetValues("x-ms-wait-for-response", out IEnumerable? waitForResponseValues)) - { - string? waitForResponseValue = waitForResponseValues.FirstOrDefault(); - if (!string.IsNullOrEmpty(waitForResponseValue) && bool.TryParse(waitForResponseValue, out bool parsedValue)) - { - waitForResponse = parsedValue; - } - } + bool waitForResponse = ShouldWaitForResponse(req, defaultValue: true); AIAgent agentProxy = client.AsDurableAgentProxy(context, agentName); @@ -428,6 +427,95 @@ internal static class BuiltInFunctions return metadata.ReadOutputAs()?.Result; } + /// + /// Waits for a workflow orchestration to complete and returns an appropriate HTTP response. + /// + private static async Task WaitForWorkflowCompletionAsync( + HttpRequestData req, + DurableTaskClient client, + FunctionContext context, + string instanceId) + { + bool acceptsJson = AcceptsJson(req); + + OrchestrationMetadata? metadata = await client.WaitForInstanceCompletionAsync( + instanceId, + getInputsAndOutputs: true, + cancellation: context.CancellationToken); + + if (metadata is null) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, + $"No workflow orchestration with ID '{instanceId}' was found.", acceptsJson); + } + + if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Failed) + { + string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Unknown error"; + HttpResponseData failedResponse = req.CreateResponse(HttpStatusCode.OK); + + if (acceptsJson) + { + await failedResponse.WriteAsJsonAsync( + new WorkflowRunResponse(instanceId, metadata.RuntimeStatus.ToString(), Result: null, Error: errorMessage), + context.CancellationToken); + } + else + { + failedResponse.Headers.Add("Content-Type", "text/plain"); + await failedResponse.WriteStringAsync(errorMessage, context.CancellationToken); + } + + return failedResponse; + } + + if (metadata.RuntimeStatus is not OrchestrationRuntimeStatus.Completed) + { + return await CreateErrorResponseAsync(req, context, HttpStatusCode.InternalServerError, + $"Workflow orchestration '{instanceId}' ended with unexpected status '{metadata.RuntimeStatus}'.", acceptsJson); + } + + string? result = metadata.ReadOutputAs()?.Result; + + HttpResponseData response = req.CreateResponse(HttpStatusCode.OK); + + if (acceptsJson) + { + JsonElement? resultElement = null; + if (!string.IsNullOrEmpty(result)) + { + try + { + using JsonDocument doc = JsonDocument.Parse(result); + resultElement = doc.RootElement.Clone(); + } + catch (JsonException) + { + // Result is a plain string (not valid JSON) — serialize it as a JSON string element. + var buffer = new System.Buffers.ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStringValue(result); + } + + using JsonDocument fallbackDoc = JsonDocument.Parse(buffer.WrittenMemory); + resultElement = fallbackDoc.RootElement.Clone(); + } + } + + await response.WriteAsJsonAsync( + new WorkflowRunResponse(instanceId, metadata.RuntimeStatus.ToString(), resultElement), + context.CancellationToken); + } + else + { + response.Headers.Add("Content-Type", "text/plain"); + await response.WriteStringAsync(result ?? string.Empty, context.CancellationToken); + } + + return response; + } + /// /// Creates an error response with the specified status code and error message. /// @@ -435,18 +523,18 @@ internal static class BuiltInFunctions /// The function context. /// The HTTP status code. /// The error message. + /// Optional pre-computed value indicating whether the client accepts JSON. When , the value is determined from the request's Accept header. /// The HTTP response data containing the error. private static async Task CreateErrorResponseAsync( HttpRequestData req, FunctionContext context, HttpStatusCode statusCode, - string errorMessage) + string errorMessage, + bool? acceptsJson = null) { HttpResponseData response = req.CreateResponse(statusCode); - bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable? acceptValues) && - acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase); - if (acceptsJson) + if (acceptsJson ?? AcceptsJson(req)) { ErrorResponse errorResponse = new((int)statusCode, errorMessage); await response.WriteAsJsonAsync(errorResponse, context.CancellationToken); @@ -479,10 +567,7 @@ internal static class BuiltInFunctions HttpResponseData response = req.CreateResponse(statusCode); response.Headers.Add("x-ms-thread-id", sessionId); - bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable? acceptValues) && - acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase); - - if (acceptsJson) + if (AcceptsJson(req)) { AgentRunSuccessResponse successResponse = new((int)statusCode, sessionId, agentResponse); await response.WriteAsJsonAsync(successResponse, context.CancellationToken); @@ -511,10 +596,7 @@ internal static class BuiltInFunctions HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted); response.Headers.Add("x-ms-thread-id", sessionId); - bool acceptsJson = req.Headers.TryGetValues("Accept", out IEnumerable? acceptValues) && - acceptValues.Contains("application/json", StringComparer.OrdinalIgnoreCase); - - if (acceptsJson) + if (AcceptsJson(req)) { AgentRunAcceptedResponse acceptedResponse = new((int)HttpStatusCode.Accepted, sessionId); await response.WriteAsJsonAsync(acceptedResponse, context.CancellationToken); @@ -528,6 +610,34 @@ internal static class BuiltInFunctions return response; } + /// + /// Returns when the caller has requested waiting for the workflow/agent to complete, + /// as indicated by the x-ms-wait-for-response header. Falls back to + /// when the header is absent or not a valid boolean. + /// + private static bool ShouldWaitForResponse(HttpRequestData req, bool defaultValue) + { + if (req.Headers.TryGetValues(WaitForResponseHeaderName, out IEnumerable? values) && + bool.TryParse(values.FirstOrDefault(), out bool parsed)) + { + return parsed; + } + + return defaultValue; + } + + /// + /// Returns when the request accepts the application/json media type. + /// + private static bool AcceptsJson(HttpRequestData req) + { + return req.Headers.TryGetValues("Accept", out IEnumerable? acceptValues) && + acceptValues + .SelectMany(v => v.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Select(v => v.Split(';', 2)[0].Trim()) + .Contains("application/json", StringComparer.OrdinalIgnoreCase); + } + private static string GetAgentName(FunctionContext context) { // Check if the function name starts with the HttpPrefix @@ -591,6 +701,19 @@ internal static class BuiltInFunctions [property: JsonPropertyName("eventName")] string? EventName, [property: JsonPropertyName("response")] JsonElement Response); + /// + /// Represents a workflow run response when waiting for completion. + /// + /// The orchestration run ID. + /// The orchestration runtime status (e.g., "Completed", "Failed"). + /// The workflow result as a JSON element so POCOs serialize as nested objects rather than escaped strings. + /// An optional error message when the workflow has failed. + private sealed record WorkflowRunResponse( + [property: JsonPropertyName("runId")] string RunId, + [property: JsonPropertyName("workflowStatus")] string WorkflowStatus, + [property: JsonPropertyName("result")] JsonElement? Result, + [property: JsonPropertyName("error"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Error = null); + /// /// A service provider that combines the original service provider with an additional DurableTaskClient instance. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md index 2c188757d5..f8f59c89d1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Support returning workflow results from HTTP trigger endpoint ([#5321](https://github.com/microsoft/agent-framework/pull/5321)) - Added MCP tool trigger support for durable workflows ([#4768](https://github.com/microsoft/agent-framework/pull/4768)) - Added Azure Functions hosting support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436)) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs index 9e421832d4..90439402db 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowOptions.cs @@ -26,6 +26,12 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid /// public IMcpToolHandler? McpToolHandler { get; init; } + /// + /// Gets or sets the HTTP request handler for executing HttpRequestAction actions within workflows. + /// If not set, HTTP request actions will fail with an appropriate error message. + /// + public IHttpRequestHandler? HttpRequestHandler { get; init; } + /// /// Defines the configuration settings for the workflow. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs new file mode 100644 index 0000000000..606a716c20 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -0,0 +1,289 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Workflows.Declarative; + +/// +/// Default implementation of built on . +/// +/// +/// +/// This handler supports per-request authentication via an optional httpClientProvider callback that +/// returns a pre-configured for a given request (e.g. authenticated, custom handler). +/// When the provider returns , or no provider is supplied, a shared internal +/// is used. +/// +/// +/// The handler applies the per-request using a linked +/// so it does not mutate on shared instances. +/// +/// +public sealed class DefaultHttpRequestHandler : IHttpRequestHandler, IAsyncDisposable +{ + private readonly Func>? _httpClientProvider; + private readonly Lazy _ownedHttpClient; + + /// + /// Initializes a new instance of the class that uses an + /// internally owned for all requests. The internal client is disposed + /// when is called. + /// + public DefaultHttpRequestHandler() + : this(httpClientProvider: null) + { + } + + /// + /// Initializes a new instance of the class that uses the + /// supplied for all requests. + /// + /// + /// The to use for all requests. The caller retains ownership of this + /// instance; it is not disposed by . + /// + /// is . + public DefaultHttpRequestHandler(HttpClient httpClient) + : this(CreateSingleClientProvider(httpClient)) + { + } + + /// + /// Initializes a new instance of the class that selects + /// an per request via a caller-supplied callback — for example, to route + /// different URLs through differently authenticated clients. + /// + /// + /// An optional callback invoked for each request. The callback receives the + /// and should return a pre-configured (e.g. with authentication or a custom + /// transport). Return to fall back to the handler's shared internal + /// . + /// + /// + /// + /// Ownership: the caller is solely responsible for the lifetime of clients returned by this + /// callback. will not dispose provider-returned + /// clients; only the handler's internally owned fallback client is disposed by . + /// + /// + /// Reuse: callers are expected to cache and reuse clients (for example, keyed by base URL or + /// auth scope) across requests. Returning a newly allocated on every + /// invocation will leak sockets and handler resources. + /// + /// + public DefaultHttpRequestHandler(Func>? httpClientProvider) + { + this._httpClientProvider = httpClientProvider; + this._ownedHttpClient = new Lazy(() => new HttpClient(), LazyThreadSafetyMode.ExecutionAndPublication); + } + + private static Func> CreateSingleClientProvider(HttpClient httpClient) + { + if (httpClient is null) + { + throw new ArgumentNullException(nameof(httpClient)); + } + + return (_, _) => Task.FromResult(httpClient); + } + + /// + public async Task SendAsync(HttpRequestInfo request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (string.IsNullOrWhiteSpace(request.Url)) + { + throw new ArgumentException("Request URL must be provided.", nameof(request)); + } + + if (string.IsNullOrWhiteSpace(request.Method)) + { + throw new ArgumentException("Request method must be provided.", nameof(request)); + } + + HttpClient? providedClient = null; + if (this._httpClientProvider is not null) + { + providedClient = await this._httpClientProvider(request, cancellationToken).ConfigureAwait(false); + } + + HttpClient client = providedClient ?? this._ownedHttpClient.Value; + + using HttpRequestMessage httpRequest = BuildHttpRequestMessage(request); + + using CancellationTokenSource? timeoutCts = request.Timeout is { } timeout && timeout > TimeSpan.Zero + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) + : null; + + timeoutCts?.CancelAfter(request.Timeout!.Value); + + CancellationToken effectiveToken = timeoutCts?.Token ?? cancellationToken; + + using HttpResponseMessage httpResponse = await client + .SendAsync(httpRequest, HttpCompletionOption.ResponseContentRead, effectiveToken) + .ConfigureAwait(false); + + string? body = httpResponse.Content is null + ? null +#if NET + : await httpResponse.Content.ReadAsStringAsync(effectiveToken).ConfigureAwait(false); +#else + : await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); +#endif + + Dictionary> headers = new(StringComparer.OrdinalIgnoreCase); + AppendHeaders(headers, httpResponse.Headers); + if (httpResponse.Content is not null) + { + AppendHeaders(headers, httpResponse.Content.Headers); + } + + return new HttpRequestResult + { + StatusCode = (int)httpResponse.StatusCode, + IsSuccessStatusCode = httpResponse.IsSuccessStatusCode, + Body = body, + Headers = headers, + }; + } + + /// + public ValueTask DisposeAsync() + { + if (this._ownedHttpClient.IsValueCreated) + { + this._ownedHttpClient.Value.Dispose(); + } + + return default; + } + + private static HttpRequestMessage BuildHttpRequestMessage(HttpRequestInfo request) + { + HttpMethod method = ResolveMethod(request.Method); + string requestUri = ResolveRequestUri(request); + HttpRequestMessage httpRequest = new(method, requestUri); + + if (request.Body is not null) + { + string contentType = string.IsNullOrWhiteSpace(request.BodyContentType) + ? "text/plain" + : request.BodyContentType!; + + httpRequest.Content = new StringContent(request.Body, Encoding.UTF8); + // Replace the default content-type header (including charset) with the declared type. + httpRequest.Content.Headers.Remove("Content-Type"); + httpRequest.Content.Headers.TryAddWithoutValidation("Content-Type", contentType); + } + + if (request.Headers is not null) + { + foreach (KeyValuePair header in request.Headers) + { + if (string.IsNullOrEmpty(header.Key)) + { + continue; + } + + // Content-* headers belong on HttpContent; all others belong on the request. + if (header.Key.StartsWith("Content-", StringComparison.OrdinalIgnoreCase) && httpRequest.Content is not null) + { + httpRequest.Content.Headers.Remove(header.Key); + httpRequest.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); + continue; + } + + if (!httpRequest.Headers.TryAddWithoutValidation(header.Key, header.Value)) + { + httpRequest.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + } + + return httpRequest; + } + + private static HttpMethod ResolveMethod(string method) + { + string normalized = method.Trim().ToUpperInvariant(); + return normalized switch + { + "GET" => HttpMethod.Get, + "POST" => HttpMethod.Post, + "PUT" => HttpMethod.Put, + "DELETE" => HttpMethod.Delete, +#if NET + "PATCH" => HttpMethod.Patch, +#else + "PATCH" => new HttpMethod("PATCH"), +#endif + _ => new HttpMethod(normalized), + }; + } + + private static string ResolveRequestUri(HttpRequestInfo request) + { + string baseUrl = request.Url; + if (request.QueryParameters is null || request.QueryParameters.Count == 0) + { + return baseUrl; + } + + StringBuilder queryBuilder = new(); + foreach (KeyValuePair parameter in request.QueryParameters) + { + if (string.IsNullOrEmpty(parameter.Key)) + { + continue; + } + + if (queryBuilder.Length > 0) + { + queryBuilder.Append('&'); + } + + queryBuilder.Append(Uri.EscapeDataString(parameter.Key)) + .Append('=') + .Append(Uri.EscapeDataString(parameter.Value ?? string.Empty)); + } + + if (queryBuilder.Length == 0) + { + return baseUrl; + } + + char separator = baseUrl.Contains('?') ? '&' : '?'; + return string.Concat(baseUrl, separator.ToString(), queryBuilder.ToString()); + } + + private static void AppendHeaders( + Dictionary> target, + System.Net.Http.Headers.HttpHeaders source) + { + foreach (KeyValuePair> header in source) + { + string[] values = header.Value.ToArray(); + + if (target.TryGetValue(header.Key, out IReadOnlyList? existing)) + { + List combined = new(existing); + combined.AddRange(values); + target[header.Key] = combined; + } + else + { + target[header.Key] = values; + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IHttpRequestHandler.cs new file mode 100644 index 0000000000..df80433d41 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/IHttpRequestHandler.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Workflows.Declarative; + +/// +/// Defines the contract for executing HTTP requests emitted by HttpRequestAction within declarative workflows. +/// +/// +/// This interface allows the HTTP request dispatch to be abstracted, enabling different implementations +/// for local development, hosted workflows, authenticated scenarios, and testing. +/// +public interface IHttpRequestHandler +{ + /// + /// Sends an HTTP request and returns the response. + /// + /// The HTTP request to send. + /// A token to observe cancellation. + /// The describing the HTTP response. + Task SendAsync( + HttpRequestInfo request, + CancellationToken cancellationToken = default); +} + +/// +/// Describes an HTTP request to be sent by an . +/// +[SuppressMessage("Design", "CA1056:URI-like properties should not be strings", Justification = "URL is carried as a string to preserve the declarative expression result and to avoid forcing handler implementations to construct a Uri eagerly.")] +public sealed class HttpRequestInfo +{ + /// + /// Gets the HTTP method to use (GET, POST, PUT, PATCH, DELETE). + /// + public string Method { get; init; } = "GET"; + + /// + /// Gets the absolute URL to send the request to. + /// + public string Url { get; init; } = string.Empty; + + /// + /// Gets the headers to include on the request, excluding the Content-Type header (which is supplied via ). + /// + public IReadOnlyDictionary? Headers { get; init; } + + /// + /// Gets the Content-Type of the request body, or if no body is sent. + /// + public string? BodyContentType { get; init; } + + /// + /// Gets the serialized request body, or if no body is sent. + /// + public string? Body { get; init; } + + /// + /// Gets the maximum amount of time to wait for the request to complete, or to use the handler default. + /// + public TimeSpan? Timeout { get; init; } + + /// + /// Gets the query parameters to append to the request URL, with values already formatted as strings. + /// + public IReadOnlyDictionary? QueryParameters { get; init; } + + /// + /// Gets the name of the declared remote connection, or if no connection is declared. + /// This maps to the Foundry project connection Id and is only used when running in foundry service. + /// + public string? ConnectionName { get; init; } +} + +/// +/// Represents the result of an HTTP request executed by an . +/// +public sealed class HttpRequestResult +{ + /// + /// Gets the HTTP status code returned by the server. + /// + public int StatusCode { get; init; } + + /// + /// Gets a value indicating whether the status code is in the range 200-299. + /// + public bool IsSuccessStatusCode { get; init; } + + /// + /// Gets the response body, or if no body was returned. + /// + public string? Body { get; init; } + + /// + /// Gets the response headers keyed by header name. Each header may have multiple values. + /// + public IReadOnlyDictionary>? Headers { get; init; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index fd818672dd..1cd1b2bc94 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -529,6 +529,18 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId); } + protected override void Visit(HttpRequestAction item) + { + this.Trace(item); + + if (this._workflowOptions.HttpRequestHandler is null) + { + throw new DeclarativeModelException("HTTP request handler not configured. Set HttpRequestHandler in DeclarativeWorkflowOptions to use HttpRequestAction actions."); + } + + this.ContinueWith(new HttpRequestExecutor(item, this._workflowOptions.HttpRequestHandler, this._workflowOptions.AgentProvider, this._workflowState)); + } + #region Not supported protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item); @@ -573,8 +585,6 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor protected override void Visit(GetConversationMembers item) => this.NotSupported(item); - protected override void Visit(HttpRequestAction item) => this.NotSupported(item); - protected override void Visit(RecognizeIntent item) => this.NotSupported(item); protected override void Visit(TransferConversation item) => this.NotSupported(item); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/HttpRequestExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/HttpRequestExecutor.cs new file mode 100644 index 0000000000..6bdddbf4e5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/HttpRequestExecutor.cs @@ -0,0 +1,346 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Agents.ObjectModel; +using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; + +/// +/// Executor for the action. +/// Dispatches the request through the configured and assigns +/// the response body and headers to the declared property paths. +/// +internal sealed class HttpRequestExecutor( + HttpRequestAction model, + IHttpRequestHandler httpRequestHandler, + ResponseAgentProvider agentProvider, + WorkflowFormulaState state) : + DeclarativeActionExecutor(model, state) +{ + /// + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + string method = this.GetMethod(); + string url = this.GetUrl(); + Dictionary? headers = this.GetHeaders(); + Dictionary? queryParameters = this.GetQueryParameters(); + (string? body, string? contentType) = this.GetBody(); + TimeSpan? timeout = this.GetTimeout(); + string? conversationId = this.GetConversationId(); + string? connectionName = this.GetConnectionName(); + + HttpRequestInfo requestInfo = new() + { + Method = method, + Url = url, + Headers = headers, + QueryParameters = queryParameters, + Body = body, + BodyContentType = contentType, + Timeout = timeout, + ConnectionName = connectionName, + }; + + HttpRequestResult result; + try + { + result = await httpRequestHandler.SendAsync(requestInfo, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw this.Exception($"HTTP request to '{url}' timed out."); + } + catch (Exception exception) when (exception is not DeclarativeActionException) + { + throw this.Exception($"HTTP request to '{url}' failed: {exception.Message}", exception); + } + + if (result.IsSuccessStatusCode) + { + await this.AssignResponseAsync(context, result.Body).ConfigureAwait(false); + await this.AssignResponseHeadersAsync(context, result.Headers).ConfigureAwait(false); + await this.AddResponseToConversationAsync(conversationId, result.Body, cancellationToken).ConfigureAwait(false); + return default; + } + + // Non-success status code - throw. + // Also publish response headers for diagnostic purposes. + await this.AssignResponseHeadersAsync(context, result.Headers).ConfigureAwait(false); + + string bodyPreview = FormatBodyForDiagnostics(result.Body); + string message = bodyPreview.Length == 0 + ? $"HTTP request to '{url}' failed with status code {result.StatusCode}." + : $"HTTP request to '{url}' failed with status code {result.StatusCode}. Body: '{bodyPreview}'"; + + throw this.Exception(message); + } + + // Response bodies can echo secrets (tokens, PII) and may be very large (multi-MB HTML error pages). + // Exception messages are often logged and persisted, so we clip the body to bound both exposure + // and message size. Full bodies are still available via the success path (assigned to Response). + private const int MaxBodyDiagnosticLength = 256; + private const string BodyTruncationSuffix = " \u2026 [truncated]"; + + private static string FormatBodyForDiagnostics(string? body) + { + if (string.IsNullOrEmpty(body)) + { + return string.Empty; + } + + int sourceLen = body!.Length; + bool truncated = sourceLen > MaxBodyDiagnosticLength; + int copyLen = truncated ? MaxBodyDiagnosticLength : sourceLen; + int finalLen = copyLen + (truncated ? BodyTruncationSuffix.Length : 0); + + // Size the buffer for the final string so we only allocate once for the chars + // and once for the string itself. For a 10 KB error body we touch 256 chars instead of 10,000. + char[] buffer = new char[finalLen]; + for (int i = 0; i < copyLen; i++) + { + char c = body[i]; + buffer[i] = c is '\r' or '\n' or '\t' ? ' ' : c; + } + + if (truncated) + { + BodyTruncationSuffix.CopyTo(0, buffer, copyLen, BodyTruncationSuffix.Length); + } + + return new string(buffer); + } + + private async ValueTask AddResponseToConversationAsync(string? conversationId, string? responseBody, CancellationToken cancellationToken) + { + if (conversationId is null || string.IsNullOrEmpty(responseBody)) + { + return; + } + + ChatMessage message = new(ChatRole.Assistant, responseBody); + await agentProvider.CreateMessageAsync(conversationId, message, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask AssignResponseAsync(IWorkflowContext context, string? responseBody) + { + if (this.Model.Response is not { Path: { } responsePath }) + { + return; + } + + await this.AssignAsync(responsePath, ParseResponseBody(responseBody), context).ConfigureAwait(false); + } + + private async ValueTask AssignResponseHeadersAsync(IWorkflowContext context, IReadOnlyDictionary>? responseHeaders) + { + if (this.Model.ResponseHeaders is not { Path: { } headersPath }) + { + return; + } + + if (responseHeaders is null || responseHeaders.Count == 0) + { + await this.AssignAsync(headersPath, FormulaValue.NewBlank(), context).ConfigureAwait(false); + return; + } + + // Flatten multi-value headers by joining with commas (standard HTTP header folding). + Dictionary flattened = new(StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair> header in responseHeaders) + { + flattened[header.Key] = string.Join(",", header.Value); + } + + await this.AssignAsync(headersPath, flattened.ToFormula(), context).ConfigureAwait(false); + } + + private static FormulaValue ParseResponseBody(string? responseBody) + { + if (string.IsNullOrEmpty(responseBody)) + { + return FormulaValue.NewBlank(); + } + + // Attempt to parse as JSON so records/tables are exposed naturally to the workflow. + try + { + using JsonDocument jsonDocument = JsonDocument.Parse(responseBody); + + object? parsedValue = jsonDocument.RootElement.ValueKind switch + { + JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType), + JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()), + JsonValueKind.String => jsonDocument.RootElement.GetString(), + JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l) + ? l + : jsonDocument.RootElement.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => responseBody, + }; + + return parsedValue.ToFormula(); + } + catch (JsonException) + { + // Not valid JSON — return the raw string. + return FormulaValue.New(responseBody); + } + } + + private string GetMethod() + { + EnumExpression? methodExpression = this.Model.Method; + if (methodExpression is null) + { + return "GET"; + } + + HttpMethodTypeWrapper wrapper = this.Evaluator.GetValue(methodExpression).Value; + return !string.IsNullOrEmpty(wrapper.UnknownValue) ? wrapper.UnknownValue! : wrapper.Value.ToString().ToUpperInvariant(); + } + + private string GetUrl() => + this.Evaluator.GetValue( + Throw.IfNull( + this.Model.Url, + $"{nameof(this.Model)}.{nameof(this.Model.Url)}")).Value; + + private Dictionary? GetHeaders() + { + if (this.Model.Headers is null || this.Model.Headers.Count == 0) + { + return null; + } + + Dictionary result = new(StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair header in this.Model.Headers) + { + string value = this.Evaluator.GetValue(header.Value).Value; + if (!string.IsNullOrEmpty(value)) + { + result[header.Key] = value; + } + } + + return result.Count == 0 ? null : result; + } + + private (string? Body, string? ContentType) GetBody() + { + switch (this.Model.Body) + { + case null: + case NoRequestContent: + return (null, null); + + case JsonRequestContent jsonContent when jsonContent.Content is not null: + { + FormulaValue formula = this.Evaluator.GetValue(jsonContent.Content).Value.ToFormula(); + string json = formula.ToJson().ToJsonString(); + return (json, "application/json"); + } + + case RawRequestContent rawContent: + { + string? content = rawContent.Content is null + ? null + : this.Evaluator.GetValue(rawContent.Content).Value; + + string? contentType = rawContent.ContentType is null + ? null + : this.Evaluator.GetValue(rawContent.ContentType).Value; + + return (content, string.IsNullOrEmpty(contentType) ? null : contentType); + } + + default: + return (null, null); + } + } + + private TimeSpan? GetTimeout() + { + if (this.Model.RequestTimeoutInMilliseconds is null || this.Model.RequestTimeoutInMillisecondsIsDefaultValue) + { + return null; + } + + long value = this.Evaluator.GetValue(this.Model.RequestTimeoutInMilliseconds).Value; + return value > 0 ? TimeSpan.FromMilliseconds(value) : null; + } + + private Dictionary? GetQueryParameters() + { + if (this.Model.QueryParameters is null || this.Model.QueryParameters.Count == 0) + { + return null; + } + + Dictionary result = new(StringComparer.Ordinal); + foreach (KeyValuePair parameter in this.Model.QueryParameters) + { + if (string.IsNullOrEmpty(parameter.Key) || parameter.Value is null) + { + continue; + } + + object? rawValue = this.Evaluator.GetValue(parameter.Value).Value.ToObject(); + string? formatted = FormatQueryValue(rawValue); + if (formatted is not null) + { + result[parameter.Key] = formatted; + } + } + + return result.Count == 0 ? null : result; + } + + private static string? FormatQueryValue(object? value) => + value switch + { + null => null, + string s => s, + bool b => b ? "true" : "false", + IFormattable formattable => formattable.ToString(null, System.Globalization.CultureInfo.InvariantCulture), + _ => value.ToString(), + }; + + private string? GetConversationId() + { + if (this.Model.ConversationId is null) + { + return null; + } + + string value = this.Evaluator.GetValue(this.Model.ConversationId).Value; + return value.Length == 0 ? null : value; + } + + private string? GetConnectionName() + { + RemoteConnection? connection = this.Model.Connection; + if (connection is null) + { + return null; + } + + string? name = connection.Name is null + ? null + : this.Evaluator.GetValue(connection.Name).Value; + + return string.IsNullOrEmpty(name) ? null : name; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml b/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml index a8268863bd..6a2c790f22 100644 --- a/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml +++ b/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml @@ -1,6 +1,20 @@  + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object) + lib/net10.0/Microsoft.Agents.AI.dll + lib/net10.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net10.0/Microsoft.Agents.AI.dll + lib/net10.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -29,6 +43,13 @@ lib/net10.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net10.0/Microsoft.Agents.AI.dll + lib/net10.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) @@ -43,6 +64,20 @@ lib/net10.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object) + lib/net472/Microsoft.Agents.AI.dll + lib/net472/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net472/Microsoft.Agents.AI.dll + lib/net472/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -71,6 +106,13 @@ lib/net472/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net472/Microsoft.Agents.AI.dll + lib/net472/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) @@ -85,6 +127,20 @@ lib/net472/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object) + lib/net8.0/Microsoft.Agents.AI.dll + lib/net8.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net8.0/Microsoft.Agents.AI.dll + lib/net8.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -113,6 +169,13 @@ lib/net8.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net8.0/Microsoft.Agents.AI.dll + lib/net8.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) @@ -127,6 +190,20 @@ lib/net8.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object) + lib/net9.0/Microsoft.Agents.AI.dll + lib/net9.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net9.0/Microsoft.Agents.AI.dll + lib/net9.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -155,6 +232,13 @@ lib/net9.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/net9.0/Microsoft.Agents.AI.dll + lib/net9.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) @@ -169,6 +253,20 @@ lib/net9.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.BeginInvoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken,System.AsyncCallback,System.Object) + lib/netstandard2.0/Microsoft.Agents.AI.dll + lib/netstandard2.0/Microsoft.Agents.AI.dll + true + + + CP0002 + M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/netstandard2.0/Microsoft.Agents.AI.dll + lib/netstandard2.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -197,6 +295,13 @@ lib/netstandard2.0/Microsoft.Agents.AI.dll true + + CP0002 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken) + lib/netstandard2.0/Microsoft.Agents.AI.dll + lib/netstandard2.0/Microsoft.Agents.AI.dll + true + CP0002 M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[]) @@ -211,4 +316,39 @@ lib/netstandard2.0/Microsoft.Agents.AI.dll true + + CP0005 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken) + lib/net10.0/Microsoft.Agents.AI.dll + lib/net10.0/Microsoft.Agents.AI.dll + true + + + CP0005 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken) + lib/net472/Microsoft.Agents.AI.dll + lib/net472/Microsoft.Agents.AI.dll + true + + + CP0005 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken) + lib/net8.0/Microsoft.Agents.AI.dll + lib/net8.0/Microsoft.Agents.AI.dll + true + + + CP0005 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken) + lib/net9.0/Microsoft.Agents.AI.dll + lib/net9.0/Microsoft.Agents.AI.dll + true + + + CP0005 + M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken) + lib/netstandard2.0/Microsoft.Agents.AI.dll + lib/netstandard2.0/Microsoft.Agents.AI.dll + true + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs index 4c5ca8dbc3..6f549301d0 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkill.cs @@ -35,7 +35,8 @@ public abstract class AgentSkill /// Gets the full skill content. /// /// - /// For file-based skills this is the raw SKILL.md file content. + /// For file-based skills this is the raw SKILL.md file content, optionally + /// augmented with a synthesized scripts block when scripts are present. /// For code-defined skills this is a synthesized XML document /// containing name, description, and body (instructions, resources, scripts). /// diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs index 1ac44bfac8..bbfbcb8616 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillScript.cs @@ -1,10 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -46,8 +46,9 @@ public abstract class AgentSkillScript /// Runs the script with the given arguments. /// /// The skill that owns this script. - /// Arguments for script execution. + /// Raw JSON arguments for script execution, preserving the original format (object or array) sent by the caller. + /// Optional service provider for dependency injection. /// Cancellation token. /// The script execution result. - public abstract Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default); + public abstract Task RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs index b5598e19d3..af1225c9df 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProvider.cs @@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Security; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -243,7 +244,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider } AIFunction scriptFunction = AIFunctionFactory.Create( - (string skillName, string scriptName, IDictionary? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) => + (string skillName, string scriptName, JsonElement? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) => this.RunSkillScriptAsync(skills, skillName, scriptName, arguments, serviceProvider, cancellationToken), name: "run_skill_script", description: "Runs a script associated with a skill."); @@ -340,7 +341,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider } } - private async Task RunSkillScriptAsync(IList skills, string skillName, string scriptName, IDictionary? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) + private async Task RunSkillScriptAsync(IList skills, string skillName, string scriptName, JsonElement? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(skillName)) { @@ -366,7 +367,7 @@ public sealed partial class AgentSkillsProvider : AIContextProvider try { - return await script.RunAsync(skill, new AIFunctionArguments(arguments) { Services = serviceProvider }, cancellationToken).ConfigureAwait(false); + return await script.RunAsync(skill, arguments, serviceProvider, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs index 4bb62e99a8..3e10557968 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkill.cs @@ -15,6 +15,8 @@ public sealed class AgentFileSkill : AgentSkill { private readonly IReadOnlyList _resources; private readonly IReadOnlyList _scripts; + private readonly string _originalContent; + private string? _content; /// /// Initializes a new instance of the class. @@ -32,7 +34,7 @@ public sealed class AgentFileSkill : AgentSkill IReadOnlyList? scripts = null) { this.Frontmatter = Throw.IfNull(frontmatter); - this.Content = Throw.IfNull(content); + this._originalContent = Throw.IfNull(content); this.Path = Throw.IfNullOrWhitespace(path); this._resources = resources ?? []; this._scripts = scripts ?? []; @@ -42,7 +44,18 @@ public sealed class AgentFileSkill : AgentSkill public override AgentSkillFrontmatter Frontmatter { get; } /// - public override string Content { get; } + /// + /// Returns the raw SKILL.md content. When the skill has scripts, a + /// <scripts><script name="..."><parameters_schema>...</parameters_schema></script></scripts> + /// block is appended with a per-script entry describing the expected argument format. + /// The result is cached after the first access. + /// + public override string Content + { + get => this._content ??= this._scripts is { Count: > 0 } + ? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptsBlock(this._scripts) + : this._originalContent; + } /// /// Gets the directory path where the skill was discovered. diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs index 116847126f..74c0cd2f01 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScript.cs @@ -2,9 +2,9 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -16,6 +16,11 @@ namespace Microsoft.Agents.AI; [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class AgentFileSkillScript : AgentSkillScript { + /// + /// Cached JSON schema element describing the expected argument format: a string array of CLI arguments. + /// + private static readonly JsonElement s_defaultSchema = CreateDefaultSchema(); + private readonly AgentFileSkillScriptRunner? _runner; /// @@ -37,7 +42,14 @@ public sealed class AgentFileSkillScript : AgentSkillScript public string FullPath { get; } /// - public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) + /// + /// Returns a fixed schema describing a string array of CLI arguments: + /// {"type":"array","items":{"type":"string"}}. + /// + public override JsonElement? ParametersSchema => s_defaultSchema; + + /// + public override async Task RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) { if (skill is not AgentFileSkill fileSkill) { @@ -51,6 +63,12 @@ public sealed class AgentFileSkillScript : AgentSkillScript $"Supply a script runner when constructing {nameof(AgentFileSkillsSource)} to enable script execution."); } - return await this._runner(fileSkill, this, arguments, cancellationToken).ConfigureAwait(false); + return await this._runner(fileSkill, this, arguments, serviceProvider, cancellationToken).ConfigureAwait(false); + } + + private static JsonElement CreateDefaultSchema() + { + using JsonDocument document = JsonDocument.Parse("""{"type":"array","items":{"type":"string"}}"""); + return document.RootElement.Clone(); } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs index c19d19e056..1746150ca2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillScriptRunner.cs @@ -1,9 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI; @@ -13,15 +14,19 @@ namespace Microsoft.Agents.AI; /// /// /// Implementations determine the execution strategy (e.g., local subprocess, hosted code execution environment). +/// The parameter preserves the raw JSON sent by the caller, in the shape +/// described by . /// /// The skill that owns the script. /// The file-based script to run. -/// Optional arguments for the script, provided by the agent/LLM. +/// Raw JSON arguments for the script, in the shape described by . +/// Optional service provider for dependency injection. /// Cancellation token. /// The script execution result. [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public delegate Task AgentFileSkillScriptRunner( AgentFileSkill skill, AgentFileSkillScript script, - AIFunctionArguments arguments, + JsonElement? arguments, + IServiceProvider? serviceProvider, CancellationToken cancellationToken); diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs index f90d67dd1d..dabf75fa1a 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillContentBuilder.cs @@ -59,36 +59,60 @@ internal static class AgentInlineSkillContentBuilder if (scripts is { Count: > 0 }) { - sb.Append("\n\n\n"); - foreach (var script in scripts) - { - var parametersSchema = script.ParametersSchema; - - if (script.Description is null && parametersSchema is null) - { - sb.Append($" \n"); - } - } - - sb.Append(""); + sb.Append('\n'); + sb.Append(BuildScriptsBlock(scripts)); } return sb.ToString(); } + /// + /// Builds a <scripts>...</scripts> XML block for the given scripts. + /// Each script is emitted as a <script name="..."> element with optional + /// description attribute and <parameters_schema> child element. + /// + /// The scripts to include in the block. + /// An XML string starting with \n<scripts>, or an empty string if the list is empty. + public static string BuildScriptsBlock(IReadOnlyList scripts) + { + _ = Throw.IfNull(scripts); + + if (scripts.Count == 0) + { + return string.Empty; + } + + var sb = new StringBuilder(); + sb.Append("\n\n"); + + foreach (var script in scripts) + { + var parametersSchema = script.ParametersSchema; + + if (script.Description is null && parametersSchema is null) + { + sb.Append($" \n"); + } + } + + sb.Append(""); + + return sb.ToString(); + } + /// /// Escapes XML special characters: always escapes &, <, >, /// ", and '. When is , diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs index 1e3041aafc..c0abc73252 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.Json; @@ -67,8 +68,42 @@ internal sealed class AgentInlineSkillScript : AgentSkillScript public override JsonElement? ParametersSchema => this._function.JsonSchema; /// - public override async Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) + public override async Task RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) { - return await this._function.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false); + var funcArgs = ConvertToFunctionArguments(arguments); + funcArgs.Services = serviceProvider; + + return await this._function.InvokeAsync(funcArgs, cancellationToken).ConfigureAwait(false); + } + + /// + /// Converts a raw to for delegate invocation. + /// + /// + /// Thrown when is provided but is not a JSON object. + /// Inline skill scripts expect arguments as a JSON object whose properties map to the delegate's parameters. + /// + private static AIFunctionArguments ConvertToFunctionArguments(JsonElement? arguments) + { + if (arguments is null || + arguments.Value.ValueKind == JsonValueKind.Null || + arguments.Value.ValueKind == JsonValueKind.Undefined) + { + return []; + } + + if (arguments.Value.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + $"Inline skill scripts expect arguments as a JSON object but received a JSON element of kind '{arguments.Value.ValueKind}'."); + } + + var dict = new Dictionary(); + foreach (var property in arguments.Value.EnumerateObject()) + { + dict[property.Name] = property.Value; + } + + return new AIFunctionArguments(dict); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs index a7f2f51156..2eba009c67 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Reflection; using System.Text; +using System.Text.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using ModelContextProtocol.Client; @@ -125,6 +126,45 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : }, message: "OrderStatus workflow completed", timeout: s_orchestrationTimeout); + + // Test the CancelOrder workflow with x-ms-wait-for-response header + this._outputHelper.WriteLine("Starting CancelOrder workflow with x-ms-wait-for-response: true..."); + + using HttpRequestMessage waitRequest = new(HttpMethod.Post, cancelOrderUri); + waitRequest.Content = new StringContent("55555", Encoding.UTF8, "text/plain"); + waitRequest.Headers.Add("x-ms-wait-for-response", "true"); + using HttpResponseMessage waitResponse = await s_sharedHttpClient.SendAsync(waitRequest); + + Assert.True(waitResponse.IsSuccessStatusCode, $"CancelOrder wait-for-response request failed with status: {waitResponse.StatusCode}"); + string waitResponseText = await waitResponse.Content.ReadAsStringAsync(); + this._outputHelper.WriteLine($"CancelOrder wait-for-response result: {waitResponseText}"); + + // The response should contain the workflow result (not just "started for CancelOrder") + Assert.DoesNotContain("Workflow orchestration started", waitResponseText); + Assert.Contains("55555", waitResponseText); + + // Test the wait-for-response with Accept: application/json header + this._outputHelper.WriteLine("Starting CancelOrder workflow with x-ms-wait-for-response and Accept: application/json..."); + + using HttpRequestMessage jsonWaitRequest = new(HttpMethod.Post, cancelOrderUri); + jsonWaitRequest.Content = new StringContent("77777", Encoding.UTF8, "text/plain"); + jsonWaitRequest.Headers.Add("x-ms-wait-for-response", "true"); + jsonWaitRequest.Headers.Add("Accept", "application/json"); + + using CancellationTokenSource jsonWaitCts = new(s_orchestrationTimeout); + using HttpResponseMessage jsonWaitResponse = await s_sharedHttpClient.SendAsync(jsonWaitRequest, jsonWaitCts.Token); + + Assert.True(jsonWaitResponse.IsSuccessStatusCode, $"CancelOrder JSON wait-for-response request failed with status: {jsonWaitResponse.StatusCode}"); + string jsonWaitResponseText = await jsonWaitResponse.Content.ReadAsStringAsync(); + this._outputHelper.WriteLine($"CancelOrder JSON wait-for-response result: {jsonWaitResponseText}"); + + using JsonDocument jsonDoc = JsonDocument.Parse(jsonWaitResponseText); + JsonElement root = jsonDoc.RootElement; + Assert.True(root.TryGetProperty("runId", out _), "JSON response missing 'runId' property"); + Assert.True(root.TryGetProperty("workflowStatus", out JsonElement statusEl), "JSON response missing 'workflowStatus' property"); + Assert.Equal("Completed", statusEl.GetString()); + Assert.True(root.TryGetProperty("result", out JsonElement resultEl), "JSON response missing 'result' property"); + Assert.Contains("77777", resultEl.GetString()); }); } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs index 1dc7d5b3f9..dc83fad119 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs @@ -8,7 +8,6 @@ using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Agents.AI.UnitTests.AgentSkills; @@ -128,8 +127,9 @@ public sealed class AgentClassSkillTests // Act — script with custom type deserialization var script = skill.Scripts![0]; var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 5 }, jso); - var args = new AIFunctionArguments { ["request"] = inputJson }; - var scriptResult = await script.RunAsync(skill, args, CancellationToken.None); + using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }"""); + var args = argsDoc.RootElement; + var scriptResult = await script.RunAsync(skill, args, null, CancellationToken.None); // Assert Assert.NotNull(scriptResult); @@ -173,12 +173,14 @@ public sealed class AgentClassSkillTests // Act & Assert — static method var doWorkScript = skill.Scripts!.First(s => s.Name == "do-work"); - var doWorkResult = await doWorkScript.RunAsync(skill, new AIFunctionArguments { ["input"] = "hello" }, CancellationToken.None); + using var doWorkDoc = JsonDocument.Parse("""{"input":"hello"}"""); + var doWorkResult = await doWorkScript.RunAsync(skill, doWorkDoc.RootElement, null, CancellationToken.None); Assert.Equal("HELLO", doWorkResult?.ToString()); // Act & Assert — instance method var appendScript = skill.Scripts!.First(s => s.Name == "append"); - var appendResult = await appendScript.RunAsync(skill, new AIFunctionArguments { ["input"] = "test" }, CancellationToken.None); + using var appendDoc = JsonDocument.Parse("""{"input":"test"}"""); + var appendResult = await appendScript.RunAsync(skill, appendDoc.RootElement, null, CancellationToken.None); Assert.Equal("test-suffix", appendResult?.ToString()); } @@ -367,7 +369,7 @@ public sealed class AgentClassSkillTests // Act & Assert — all scripts produce values foreach (var script in skill.Scripts!) { - var result = await script.RunAsync(skill, new AIFunctionArguments(), CancellationToken.None); + var result = await script.RunAsync(skill, null, null, CancellationToken.None); Assert.NotNull(result); } } @@ -382,8 +384,9 @@ public sealed class AgentClassSkillTests // Act & Assert — script with custom JSO var script = skill.Scripts![0]; var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 3 }, jso); - var args = new AIFunctionArguments { ["request"] = inputJson }; - var scriptResult = await script.RunAsync(skill, args, CancellationToken.None); + using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }"""); + var args = argsDoc.RootElement; + var scriptResult = await script.RunAsync(skill, args, null, CancellationToken.None); Assert.NotNull(scriptResult); Assert.Contains("test", scriptResult!.ToString()!); Assert.Contains("3", scriptResult!.ToString()!); @@ -497,8 +500,9 @@ public sealed class AgentClassSkillTests var script = skill.Scripts!.First(s => s.Name == "Lookup"); var jso = SkillTestJsonContext.Default.Options; var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "fallback", MaxResults = 7 }, jso); - var args = new AIFunctionArguments { ["request"] = inputJson }; - var result = await script.RunAsync(skill, args, CancellationToken.None); + using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }"""); + var args = argsDoc.RootElement; + var result = await script.RunAsync(skill, args, null, CancellationToken.None); // Assert Assert.NotNull(result); @@ -531,8 +535,9 @@ public sealed class AgentClassSkillTests var script = skill.Scripts!.First(s => s.Name == "Lookup"); var jso = SkillTestJsonContext.Default.Options; var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "explicit", MaxResults = 2 }, jso); - var args = new AIFunctionArguments { ["request"] = inputJson }; - var result = await script.RunAsync(skill, args, CancellationToken.None); + using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }"""); + var args = argsDoc.RootElement; + var result = await script.RunAsync(skill, args, null, CancellationToken.None); // Assert Assert.NotNull(result); diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs index eb4f706f30..e638380019 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillScriptTests.cs @@ -1,9 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.UnitTests.AgentSkills; @@ -16,13 +16,13 @@ public sealed class AgentFileSkillScriptTests public async Task RunAsync_SkillIsNotAgentFileSkill_ThrowsInvalidOperationExceptionAsync() { // Arrange - static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult("result"); + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult("result"); var script = CreateScript("test-script", "/path/to/script.py", RunnerAsync); var nonFileSkill = new TestAgentSkill("my-skill", "A skill", "Instructions."); // Act & Assert await Assert.ThrowsAsync( - () => script.RunAsync(nonFileSkill, new AIFunctionArguments(), CancellationToken.None)); + () => script.RunAsync(nonFileSkill, null, null, CancellationToken.None)); } [Fact] @@ -30,7 +30,7 @@ public sealed class AgentFileSkillScriptTests { // Arrange var runnerCalled = false; - Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) + Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, JsonElement? args, IServiceProvider? sp, CancellationToken ct) { runnerCalled = true; return Task.FromResult("executed"); @@ -42,7 +42,7 @@ public sealed class AgentFileSkillScriptTests "/skills/my-skill"); // Act - var result = await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); + var result = await script.RunAsync(fileSkill, null, null, CancellationToken.None); // Assert Assert.True(runnerCalled); @@ -55,7 +55,7 @@ public sealed class AgentFileSkillScriptTests // Arrange AgentFileSkill? capturedSkill = null; AgentFileSkillScript? capturedScript = null; - Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct) + Task runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, JsonElement? args, IServiceProvider? sp, CancellationToken ct) { capturedSkill = skill; capturedScript = scriptArg; @@ -68,7 +68,7 @@ public sealed class AgentFileSkillScriptTests "/skills/owner-skill"); // Act - await script.RunAsync(fileSkill, new AIFunctionArguments(), CancellationToken.None); + await script.RunAsync(fileSkill, null, null, CancellationToken.None); // Assert Assert.Same(fileSkill, capturedSkill); @@ -79,7 +79,7 @@ public sealed class AgentFileSkillScriptTests public void Script_HasCorrectNameAndPath() { // Arrange & Act - static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult(null); + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult(null); var script = CreateScript("my-script", "/path/to/my-script.py", RunnerAsync); // Assert @@ -87,10 +87,173 @@ public sealed class AgentFileSkillScriptTests Assert.Equal("/path/to/my-script.py", script.FullPath); } + [Fact] + public void ParametersSchema_ReturnsExpectedArraySchema() + { + // Arrange + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult(null); + var script = CreateScript("my-script", "/path/to/script.py", RunnerAsync); + + // Act + var schema = script.ParametersSchema; + + // Assert + Assert.NotNull(schema); + var raw = schema!.Value.GetRawText(); + Assert.Contains("\"type\":\"array\"", raw); + Assert.Contains("\"items\":{\"type\":\"string\"}", raw); + } + + [Fact] + public void Content_WithScripts_AppendsPerScriptEntries() + { + // Arrange + static Task RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult(null); + var script1 = CreateScript("build", "/scripts/build.sh", RunnerAsync); + var script2 = CreateScript("deploy", "/scripts/deploy.sh", RunnerAsync); + var fileSkill = new AgentFileSkill( + new AgentSkillFrontmatter("my-skill", "A skill"), + "Original content", + "/skills/my-skill", + scripts: [script1, script2]); + + // Act + var content = fileSkill.Content; + + // Assert — content starts with original and appends per-script entries + Assert.StartsWith("Original content", content); + Assert.Contains("", content); + Assert.Contains("