diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index 48d15bceda..32c25efdbf 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -336,6 +336,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 @@ -402,6 +449,7 @@ jobs: python-tests-misc-integration, python-tests-functions, python-tests-foundry, + python-tests-foundry-hosting, python-tests-cosmos, ] runs-on: ubuntu-latest @@ -465,6 +513,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 843253e788..9529e54d97 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 @@ -488,6 +491,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 @@ -569,6 +633,7 @@ jobs: python-tests-misc-integration, python-tests-functions, python-tests-foundry, + python-tests-foundry-hosting, python-tests-cosmos, ] runs-on: ubuntu-latest @@ -629,6 +694,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/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index fa4f515de1..c3f6f974fc 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -169,10 +169,10 @@ - + @@ -232,6 +232,7 @@ + @@ -353,17 +354,17 @@ - + + - @@ -549,8 +550,8 @@ - + 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/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj b/dotnet/samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj new file mode 100644 index 0000000000..afc2e0afab --- /dev/null +++ b/dotnet/samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj @@ -0,0 +1,38 @@ + + + + Exe + net10.0 + enable + enable + + + + true + true + true + true + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/dotnet/samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.yaml b/dotnet/samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.yaml new file mode 100644 index 0000000000..b903bb92ea --- /dev/null +++ b/dotnet/samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.yaml @@ -0,0 +1,76 @@ +# +# This workflow demonstrates using HttpRequestAction to call a REST API directly +# from the workflow without going through an AI agent first. +# +# HttpRequestAction allows workflows to: +# - Fetch data from external HTTP endpoints +# - Store the parsed response in workflow variables for later use +# - Add the response body to the conversation so a downstream agent can +# answer questions based on it +# +# This sample fetches public metadata for the dotnet/runtime repository from +# the GitHub REST API (no authentication required) and uses an agent to +# answer follow-up questions about it. +# +# Example input: +# How many subscribers does the repository have? +# +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_invoke_http_request_demo + actions: + + # Capture the original user message for input to the follow-up agent. + - kind: SetVariable + id: set_user_message + variable: Local.InputMessage + value: =System.LastMessage + + # Set the repository org/name used to form the request URL. + - kind: SetVariable + id: set_repo_name + variable: Local.RepoName + value: microsoft/agent-framework + + # Invoke the GitHub repo API. The response body is parsed into Local.RepoInfo + # and also added to the conversation (via conversationId) so the agent below + # can answer questions based on it. + - kind: HttpRequestAction + id: fetch_repo_info + conversationId: =System.ConversationId + method: GET + url: =Concatenate("https://api.github.com/repos/", Local.RepoName) + headers: + Accept: application/vnd.github+json + User-Agent: agent-framework-sample + response: Local.RepoInfo + + # Display a confirmation message showing key fields from the parsed response. + - kind: SendMessage + id: show_repo_summary + message: "Fetched repo: visibility={Local.RepoInfo.visibility}, description={Local.RepoInfo.description}" + + # Use the agent to summarize the repo using the conversation context. + - kind: InvokeAzureAgent + id: summarize_repo + conversationId: =System.ConversationId + agent: + name: GitHubRepoInfoAgent + input: + messages: =UserMessage("Please provide a brief summary of this GitHub repository based on the data already in the conversation.") + output: + autoSend: true + messages: Local.AgentResponse + + # Allow the user to ask follow-up questions about the repo in a loop. + - kind: InvokeAzureAgent + id: invoke_followup + conversationId: =System.ConversationId + agent: + name: GitHubRepoInfoAgent + input: + messages: =Local.InputMessage + externalLoop: + when: =Upper(System.LastMessage.Text) <> "EXIT" diff --git a/dotnet/samples/03-workflows/Declarative/InvokeHttpRequest/Program.cs b/dotnet/samples/03-workflows/Declarative/InvokeHttpRequest/Program.cs new file mode 100644 index 0000000000..a27226847e --- /dev/null +++ b/dotnet/samples/03-workflows/Declarative/InvokeHttpRequest/Program.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Azure.Identity; +using Microsoft.Agents.AI.Workflows.Declarative; +using Microsoft.Extensions.Configuration; +using Shared.Foundry; +using Shared.Workflows; + +namespace Demo.Workflows.Declarative.InvokeHttpRequest; + +/// +/// Demonstrates a workflow that uses HttpRequestAction to call a REST API +/// directly from the workflow. +/// +/// +/// +/// The HttpRequestAction allows workflows to issue HTTP requests and: +/// +/// +/// Fetch data from external REST endpoints +/// Store the parsed response in workflow variables +/// Add the response body to the conversation so an agent can answer +/// questions based on it +/// +/// +/// This sample fetches public metadata for the dotnet/runtime repository from +/// the GitHub REST API (no authentication required) and uses a Foundry agent +/// to answer follow-up questions about it. Type "EXIT" to end the conversation. +/// +/// +/// See the README.md file in the parent folder (../README.md) for detailed +/// information about the configuration required to run this sample. +/// +/// +internal sealed class Program +{ + public static async Task Main(string[] args) + { + // Initialize configuration + IConfiguration configuration = Application.InitializeConfig(); + Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint)); + + // Ensure sample agent exists in Foundry. The agent has no tools - it answers + // questions about the GitHub repository using only the JSON data that the + // HttpRequestAction adds to the conversation. + await CreateAgentAsync(foundryEndpoint, configuration); + + // Get input from command line or console + string workflowInput = Application.GetInput(args); + + // The default HttpRequestHandler is sufficient for this sample because the + // GitHub REST endpoint used here does not require authentication. For + // authenticated endpoints, supply a custom Func + // to DefaultHttpRequestHandler so each request can be routed through a + // pre-configured (cached) HttpClient with the appropriate credentials. + await using DefaultHttpRequestHandler httpRequestHandler = new(); + + // Create the workflow factory with the HTTP request handler + WorkflowFactory workflowFactory = new("InvokeHttpRequest.yaml", foundryEndpoint) + { + HttpRequestHandler = httpRequestHandler + }; + + // Execute the workflow + WorkflowRunner runner = new() { UseJsonCheckpoints = true }; + await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput); + } + + private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration) + { + // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. + AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential()); + + await aiProjectClient.CreateAgentAsync( + agentName: "GitHubRepoInfoAgent", + agentDefinition: DefineAgent(configuration), + agentDescription: "Answers questions about a GitHub repository using HTTP response data in the conversation"); + } + + private static DeclarativeAgentDefinition DefineAgent(IConfiguration configuration) + { + return new DeclarativeAgentDefinition(configuration.GetValue(Application.Settings.FoundryModel)) + { + Instructions = + """ + Answer the user's questions about the GitHub repository using only the + JSON data already present in the conversation history. + If the answer is not contained in the conversation, say so plainly + rather than guessing. Be concise and helpful. + """ + }; + } +} 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/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs index 75a87fb8ee..47b4efc5c7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs @@ -16,6 +16,60 @@ internal static class ChatMessageExtensions public static RecordValue ToRecord(this ChatMessage message) => FormulaValue.NewRecordFromFields(message.GetMessageFields()); + /// + /// Merges the user-authored with the round-tripped + /// returned by AgentProvider.CreateMessageAsync + /// to produce the value stored in System.LastMessage. + /// + /// + /// The agent service often strips or alters on round-trip, + /// while replacing inline media (, ) + /// with server-side references (typically ). + /// We want both: the original text (so =System.LastMessage.Text works) and + /// the server's media references (so subsequent actions don't re-upload large blobs). + /// + /// Strategy: keep as the base — it has the server-generated + /// and any provider-augmented metadata, and is forward- + /// compatible with new properties added on in the abstractions + /// layer. Only the list is mutated to substitute + /// original items in place (and append any extras the round-trip + /// dropped). Non-text content items returned by the service are left untouched so + /// server-side references survive. + /// + /// + public static ChatMessage MergeForLastMessage(this ChatMessage input, ChatMessage? inputMessage) + { + if (inputMessage is null) + { + return input; + } + + // Build a queue of the original text items, in order. Fall back to ChatMessage.Text + // if the input has no explicit TextContent entries. + Queue originalTexts = new(input.Contents.OfType()); + if (originalTexts.Count == 0 && !string.IsNullOrEmpty(input.Text)) + { + originalTexts.Enqueue(new TextContent(input.Text)); + } + + // Replace TextContent items in inputMessage.Contents with the originals, in order. + for (int i = 0; i < inputMessage.Contents.Count && originalTexts.Count > 0; i++) + { + if (inputMessage.Contents[i] is TextContent) + { + inputMessage.Contents[i] = originalTexts.Dequeue(); + } + } + + // Append any remaining original text items that the round-trip dropped entirely. + while (originalTexts.Count > 0) + { + inputMessage.Contents.Add(originalTexts.Dequeue()); + } + + return inputMessage; + } + public static TableValue ToTable(this IEnumerable messages) => FormulaValue.NewTable(TypeSchema.Message.RecordType, messages.Select(message => message.ToRecord())); 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/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs index 9c6f7f3e6f..52f20a859a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs @@ -43,7 +43,11 @@ internal sealed class DeclarativeWorkflowExecutor( await declarativeContext.QueueConversationUpdateAsync(conversationId, isExternal: true, cancellationToken).ConfigureAwait(false); ChatMessage inputMessage = await options.AgentProvider.CreateMessageAsync(conversationId, input, cancellationToken).ConfigureAwait(false); - await declarativeContext.SetLastMessageAsync(inputMessage).ConfigureAwait(false); + + // Use the original input for System.LastMessage to ensure Text is preserved (the + // service may strip text on round-trip), but substitute server-side media references + // (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs. + await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false); await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false); } 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/Kit/RootExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs index ff643510df..80f6e69b60 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs @@ -58,7 +58,6 @@ public abstract class RootExecutor : Executor, IResettableExecut public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken) { DeclarativeWorkflowContext declarativeContext = new(context, this._state); - await this.ExecuteAsync(message, declarativeContext, cancellationToken).ConfigureAwait(false); ChatMessage input = (this._inputTransform ?? DefaultInputTransform).Invoke(message); @@ -69,7 +68,13 @@ public abstract class RootExecutor : Executor, IResettableExecut await declarativeContext.QueueConversationUpdateAsync(this._conversationId, isExternal: true, cancellationToken).ConfigureAwait(false); ChatMessage inputMessage = await this._agentProvider.CreateMessageAsync(this._conversationId, input, cancellationToken).ConfigureAwait(false); - await declarativeContext.SetLastMessageAsync(inputMessage).ConfigureAwait(false); + + // Use the original input for System.LastMessage to ensure Text is preserved (the + // service may strip text on round-trip), but substitute server-side media references + // (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs. + await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false); + + await this.ExecuteAsync(message, declarativeContext, cancellationToken).ConfigureAwait(false); await declarativeContext.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false); } 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/src/Shared/Workflows/Execution/WorkflowFactory.cs b/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs index a36c388e73..68e4af2878 100644 --- a/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs +++ b/dotnet/src/Shared/Workflows/Execution/WorkflowFactory.cs @@ -25,6 +25,9 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint) // Assign to provide MCP tool capabilities public IMcpToolHandler? McpToolHandler { get; init; } + // Assign to enable HttpRequestAction support + public IHttpRequestHandler? HttpRequestHandler { get; init; } + /// /// Create the workflow from the declarative YAML. Includes definition of the /// and the associated . @@ -46,6 +49,7 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint) ConversationId = this.ConversationId, LoggerFactory = this.LoggerFactory, McpToolHandler = this.McpToolHandler, + HttpRequestHandler = this.HttpRequestHandler, }; string workflowPath = Path.Combine(AppContext.BaseDirectory, workflowFile); diff --git a/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs b/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs index 8135c25570..0d2e30ceeb 100644 --- a/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs +++ b/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs @@ -162,7 +162,10 @@ internal sealed class WorkflowRunner case RequestInfoEvent requestInfo: Debug.WriteLine($"REQUEST #{requestInfo.Request.RequestId}"); - externalResponse = requestInfo.Request; + if (response is null || !string.Equals(requestInfo.Request.RequestId, response.RequestId, StringComparison.Ordinal)) + { + externalResponse = requestInfo.Request; + } break; case ConversationUpdateEvent invokeEvent: 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("