Merge branch 'main' into feature-harness

This commit is contained in:
westey
2026-04-30 14:05:25 +01:00
committed by GitHub
Unverified
227 changed files with 15803 additions and 2569 deletions
+5 -4
View File
@@ -169,10 +169,10 @@
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/Evaluation/">
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
<Project Path="samples/02-agents/Evaluation/Evaluation_ExpectedOutputs/Evaluation_ExpectedOutputs.csproj" />
<Project Path="samples/02-agents/Evaluation/Evaluation_Multimodal/Evaluation_Multimodal.csproj" />
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AgentWithMemory/">
<File Path="samples/02-agents/AgentWithMemory/README.md" />
@@ -232,6 +232,7 @@
<Project Path="samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj" />
<Project Path="samples/03-workflows/Declarative/InputArguments/InputArguments.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
<Project Path="samples/03-workflows/Declarative/Marketing/Marketing.csproj" />
<Project Path="samples/03-workflows/Declarative/StudentTeacher/StudentTeacher.csproj" />
@@ -353,17 +354,17 @@
<File Path="samples/02-agents/A2A/README.md" />
<Project Path="samples/02-agents/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/">
<Project Path="samples/05-end-to-end/AgentWithPurview/AgentWithPurview.csproj" />
<Project Path="samples/05-end-to-end/M365Agent/M365Agent.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/Evaluation/">
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/Evaluation_ConversationSplits.csproj" />
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_FoundryQuality/Evaluation_FoundryQuality.csproj" />
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_MixedProviders/Evaluation_MixedProviders.csproj" />
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/Evaluation_ConversationSplits.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/A2AClientServer/">
<File Path="samples/05-end-to-end/A2AClientServer/README.md" />
@@ -549,8 +550,8 @@
<Project Path="src/Microsoft.Agents.AI.Declarative/Microsoft.Agents.AI.Declarative.csproj" />
<Project Path="src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj" />
<Project Path="src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj" />
<Project Path="src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj" />
<Project Path="src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj" />
<Project Path="src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
@@ -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;
/// <summary>
/// Executes file-based skill scripts as local subprocesses.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal static class SubprocessScriptRunner
{
@@ -24,7 +24,8 @@ internal static class SubprocessScriptRunner
public static async Task<object?> 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();
}
}
/// <summary>
/// Normalizes a parameter key to a consistent --flag format.
/// Models may return keys with or without leading dashes (e.g., "value" vs "--value").
/// </summary>
private static string NormalizeKey(string key) => "--" + key.TrimStart('-');
}
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Foundry\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="InvokeHttpRequest.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -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"
@@ -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;
/// <summary>
/// Demonstrates a workflow that uses HttpRequestAction to call a REST API
/// directly from the workflow.
/// </summary>
/// <remarks>
/// <para>
/// The HttpRequestAction allows workflows to issue HTTP requests and:
/// </para>
/// <list type="bullet">
/// <item>Fetch data from external REST endpoints</item>
/// <item>Store the parsed response in workflow variables</item>
/// <item>Add the response body to the conversation so an agent can answer
/// questions based on it</item>
/// </list>
/// <para>
/// 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.
/// </para>
/// <para>
/// See the README.md file in the parent folder (../README.md) for detailed
/// information about the configuration required to run this sample.
/// </para>
/// </remarks>
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<HttpRequestInfo, ..., HttpClient?>
// 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.
"""
};
}
}
@@ -26,6 +26,12 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid
/// </summary>
public IMcpToolHandler? McpToolHandler { get; init; }
/// <summary>
/// Gets or sets the HTTP request handler for executing <c>HttpRequestAction</c> actions within workflows.
/// If not set, HTTP request actions will fail with an appropriate error message.
/// </summary>
public IHttpRequestHandler? HttpRequestHandler { get; init; }
/// <summary>
/// Defines the configuration settings for the workflow.
/// </summary>
@@ -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;
/// <summary>
/// Default implementation of <see cref="IHttpRequestHandler"/> built on <see cref="HttpClient"/>.
/// </summary>
/// <remarks>
/// <para>
/// This handler supports per-request authentication via an optional <c>httpClientProvider</c> callback that
/// returns a pre-configured <see cref="HttpClient"/> for a given request (e.g. authenticated, custom handler).
/// When the provider returns <see langword="null"/>, or no provider is supplied, a shared internal <see cref="HttpClient"/>
/// is used.
/// </para>
/// <para>
/// The handler applies the per-request <see cref="HttpRequestInfo.Timeout"/> using a linked <see cref="CancellationTokenSource"/>
/// so it does not mutate <see cref="HttpClient.Timeout"/> on shared instances.
/// </para>
/// </remarks>
public sealed class DefaultHttpRequestHandler : IHttpRequestHandler, IAsyncDisposable
{
private readonly Func<HttpRequestInfo, CancellationToken, Task<HttpClient?>>? _httpClientProvider;
private readonly Lazy<HttpClient> _ownedHttpClient;
/// <summary>
/// Initializes a new instance of the <see cref="DefaultHttpRequestHandler"/> class that uses an
/// internally owned <see cref="HttpClient"/> for all requests. The internal client is disposed
/// when <see cref="DisposeAsync"/> is called.
/// </summary>
public DefaultHttpRequestHandler()
: this(httpClientProvider: null)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="DefaultHttpRequestHandler"/> class that uses the
/// supplied <see cref="HttpClient"/> for all requests.
/// </summary>
/// <param name="httpClient">
/// The <see cref="HttpClient"/> to use for all requests. The caller retains ownership of this
/// instance; it is not disposed by <see cref="DisposeAsync"/>.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="httpClient"/> is <see langword="null"/>.</exception>
public DefaultHttpRequestHandler(HttpClient httpClient)
: this(CreateSingleClientProvider(httpClient))
{
}
/// <summary>
/// Initializes a new instance of the <see cref="DefaultHttpRequestHandler"/> class that selects
/// an <see cref="HttpClient"/> per request via a caller-supplied callback — for example, to route
/// different URLs through differently authenticated clients.
/// </summary>
/// <param name="httpClientProvider">
/// An optional callback invoked for each request. The callback receives the <see cref="HttpRequestInfo"/>
/// and should return a pre-configured <see cref="HttpClient"/> (e.g. with authentication or a custom
/// transport). Return <see langword="null"/> to fall back to the handler's shared internal
/// <see cref="HttpClient"/>.
/// </param>
/// <remarks>
/// <para>
/// <b>Ownership</b>: the caller is solely responsible for the lifetime of clients returned by this
/// callback. <see cref="DefaultHttpRequestHandler"/> will <b>not</b> dispose provider-returned
/// clients; only the handler's internally owned fallback client is disposed by <see cref="DisposeAsync"/>.
/// </para>
/// <para>
/// <b>Reuse</b>: callers are expected to cache and reuse clients (for example, keyed by base URL or
/// auth scope) across requests. Returning a newly allocated <see cref="HttpClient"/> on every
/// invocation will leak sockets and handler resources.
/// </para>
/// </remarks>
public DefaultHttpRequestHandler(Func<HttpRequestInfo, CancellationToken, Task<HttpClient?>>? httpClientProvider)
{
this._httpClientProvider = httpClientProvider;
this._ownedHttpClient = new Lazy<HttpClient>(() => new HttpClient(), LazyThreadSafetyMode.ExecutionAndPublication);
}
private static Func<HttpRequestInfo, CancellationToken, Task<HttpClient?>> CreateSingleClientProvider(HttpClient httpClient)
{
if (httpClient is null)
{
throw new ArgumentNullException(nameof(httpClient));
}
return (_, _) => Task.FromResult<HttpClient?>(httpClient);
}
/// <inheritdoc/>
public async Task<HttpRequestResult> 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<string, IReadOnlyList<string>> 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,
};
}
/// <inheritdoc/>
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<string, string> 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<string, string> 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<string, IReadOnlyList<string>> target,
System.Net.Http.Headers.HttpHeaders source)
{
foreach (KeyValuePair<string, IEnumerable<string>> header in source)
{
string[] values = header.Value.ToArray();
if (target.TryGetValue(header.Key, out IReadOnlyList<string>? existing))
{
List<string> combined = new(existing);
combined.AddRange(values);
target[header.Key] = combined;
}
else
{
target[header.Key] = values;
}
}
}
}
@@ -16,6 +16,60 @@ internal static class ChatMessageExtensions
public static RecordValue ToRecord(this ChatMessage message) =>
FormulaValue.NewRecordFromFields(message.GetMessageFields());
/// <summary>
/// Merges the user-authored <paramref name="input"/> with the round-tripped
/// <paramref name="inputMessage"/> returned by <c>AgentProvider.CreateMessageAsync</c>
/// to produce the value stored in <c>System.LastMessage</c>.
/// </summary>
/// <remarks>
/// The agent service often strips or alters <see cref="TextContent"/> on round-trip,
/// while replacing inline media (<see cref="DataContent"/>, <see cref="UriContent"/>)
/// with server-side references (typically <see cref="HostedFileContent"/>).
/// We want both: the original text (so <c>=System.LastMessage.Text</c> works) and
/// the server's media references (so subsequent actions don't re-upload large blobs).
/// <para>
/// Strategy: keep <paramref name="inputMessage"/> as the base — it has the server-generated
/// <see cref="ChatMessage.MessageId"/> and any provider-augmented metadata, and is forward-
/// compatible with new properties added on <see cref="ChatMessage"/> in the abstractions
/// layer. Only the <see cref="ChatMessage.Contents"/> list is mutated to substitute
/// original <see cref="TextContent"/> 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.
/// </para>
/// </remarks>
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<TextContent> originalTexts = new(input.Contents.OfType<TextContent>());
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<ChatMessage> messages) =>
FormulaValue.NewTable(TypeSchema.Message.RecordType, messages.Select(message => message.ToRecord()));
@@ -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;
/// <summary>
/// Defines the contract for executing HTTP requests emitted by <c>HttpRequestAction</c> within declarative workflows.
/// </summary>
/// <remarks>
/// This interface allows the HTTP request dispatch to be abstracted, enabling different implementations
/// for local development, hosted workflows, authenticated scenarios, and testing.
/// </remarks>
public interface IHttpRequestHandler
{
/// <summary>
/// Sends an HTTP request and returns the response.
/// </summary>
/// <param name="request">The HTTP request to send.</param>
/// <param name="cancellationToken">A token to observe cancellation.</param>
/// <returns>The <see cref="HttpRequestResult"/> describing the HTTP response.</returns>
Task<HttpRequestResult> SendAsync(
HttpRequestInfo request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Describes an HTTP request to be sent by an <see cref="IHttpRequestHandler"/>.
/// </summary>
[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
{
/// <summary>
/// Gets the HTTP method to use (GET, POST, PUT, PATCH, DELETE).
/// </summary>
public string Method { get; init; } = "GET";
/// <summary>
/// Gets the absolute URL to send the request to.
/// </summary>
public string Url { get; init; } = string.Empty;
/// <summary>
/// Gets the headers to include on the request, excluding the <c>Content-Type</c> header (which is supplied via <see cref="BodyContentType"/>).
/// </summary>
public IReadOnlyDictionary<string, string>? Headers { get; init; }
/// <summary>
/// Gets the <c>Content-Type</c> of the request body, or <see langword="null"/> if no body is sent.
/// </summary>
public string? BodyContentType { get; init; }
/// <summary>
/// Gets the serialized request body, or <see langword="null"/> if no body is sent.
/// </summary>
public string? Body { get; init; }
/// <summary>
/// Gets the maximum amount of time to wait for the request to complete, or <see langword="null"/> to use the handler default.
/// </summary>
public TimeSpan? Timeout { get; init; }
/// <summary>
/// Gets the query parameters to append to the request URL, with values already formatted as strings.
/// </summary>
public IReadOnlyDictionary<string, string>? QueryParameters { get; init; }
/// <summary>
/// Gets the name of the declared remote connection, or <see langword="null"/> if no connection is declared.
/// This maps to the Foundry project connection Id and is only used when running in foundry service.
/// </summary>
public string? ConnectionName { get; init; }
}
/// <summary>
/// Represents the result of an HTTP request executed by an <see cref="IHttpRequestHandler"/>.
/// </summary>
public sealed class HttpRequestResult
{
/// <summary>
/// Gets the HTTP status code returned by the server.
/// </summary>
public int StatusCode { get; init; }
/// <summary>
/// Gets a value indicating whether the status code is in the range 200-299.
/// </summary>
public bool IsSuccessStatusCode { get; init; }
/// <summary>
/// Gets the response body, or <see langword="null"/> if no body was returned.
/// </summary>
public string? Body { get; init; }
/// <summary>
/// Gets the response headers keyed by header name. Each header may have multiple values.
/// </summary>
public IReadOnlyDictionary<string, IReadOnlyList<string>>? Headers { get; init; }
}
@@ -43,7 +43,11 @@ internal sealed class DeclarativeWorkflowExecutor<TInput>(
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);
}
@@ -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);
@@ -58,7 +58,6 @@ public abstract class RootExecutor<TInput> : Executor<TInput>, 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<TInput> : Executor<TInput>, 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);
}
@@ -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;
/// <summary>
/// Executor for the <see cref="HttpRequestAction"/> action.
/// Dispatches the request through the configured <see cref="IHttpRequestHandler"/> and assigns
/// the response body and headers to the declared property paths.
/// </summary>
internal sealed class HttpRequestExecutor(
HttpRequestAction model,
IHttpRequestHandler httpRequestHandler,
ResponseAgentProvider agentProvider,
WorkflowFormulaState state) :
DeclarativeActionExecutor<HttpRequestAction>(model, state)
{
/// <inheritdoc/>
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
string method = this.GetMethod();
string url = this.GetUrl();
Dictionary<string, string>? headers = this.GetHeaders();
Dictionary<string, string>? 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<string, IReadOnlyList<string>>? 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<string, object?> flattened = new(StringComparer.OrdinalIgnoreCase);
foreach (KeyValuePair<string, IReadOnlyList<string>> 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<HttpMethodTypeWrapper>? 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<string, string>? GetHeaders()
{
if (this.Model.Headers is null || this.Model.Headers.Count == 0)
{
return null;
}
Dictionary<string, string> result = new(StringComparer.OrdinalIgnoreCase);
foreach (KeyValuePair<string, StringExpression> 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<string, string>? GetQueryParameters()
{
if (this.Model.QueryParameters is null || this.Model.QueryParameters.Count == 0)
{
return null;
}
Dictionary<string, string> result = new(StringComparer.Ordinal);
foreach (KeyValuePair<string, ValueExpression> 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;
}
}
@@ -1,6 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>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)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
@@ -29,6 +43,13 @@
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[])</Target>
@@ -43,6 +64,20 @@
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>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)</Target>
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
@@ -71,6 +106,13 @@
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[])</Target>
@@ -85,6 +127,20 @@
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>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)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
@@ -113,6 +169,13 @@
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[])</Target>
@@ -127,6 +190,20 @@
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>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)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
@@ -155,6 +232,13 @@
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[])</Target>
@@ -169,6 +253,20 @@
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>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)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentFileSkillScriptRunner.Invoke(Microsoft.Agents.AI.AgentFileSkill,Microsoft.Agents.AI.AgentFileSkillScript,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
@@ -197,6 +295,13 @@
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,Microsoft.Extensions.AI.AIFunctionArguments,System.Threading.CancellationToken)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillsProvider.#ctor(Microsoft.Agents.AI.AgentInlineSkill[])</Target>
@@ -211,4 +316,39 @@
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0005</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0005</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0005</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0005</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0005</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentSkillScript.RunAsync(Microsoft.Agents.AI.AgentSkill,System.Nullable{System.Text.Json.JsonElement},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>
@@ -35,7 +35,8 @@ public abstract class AgentSkill
/// Gets the full skill content.
/// </summary>
/// <remarks>
/// 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).
/// </remarks>
@@ -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.
/// </summary>
/// <param name="skill">The skill that owns this script.</param>
/// <param name="arguments">Arguments for script execution.</param>
/// <param name="arguments">Raw JSON arguments for script execution, preserving the original format (object or array) sent by the caller.</param>
/// <param name="serviceProvider">Optional service provider for dependency injection.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The script execution result.</returns>
public abstract Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default);
public abstract Task<object?> RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default);
}
@@ -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<string, object?>? 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<object?> RunSkillScriptAsync(IList<AgentSkill> skills, string skillName, string scriptName, IDictionary<string, object?>? arguments = null, IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
private async Task<object?> RunSkillScriptAsync(IList<AgentSkill> 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)
{
@@ -15,6 +15,8 @@ public sealed class AgentFileSkill : AgentSkill
{
private readonly IReadOnlyList<AgentSkillResource> _resources;
private readonly IReadOnlyList<AgentSkillScript> _scripts;
private readonly string _originalContent;
private string? _content;
/// <summary>
/// Initializes a new instance of the <see cref="AgentFileSkill"/> class.
@@ -32,7 +34,7 @@ public sealed class AgentFileSkill : AgentSkill
IReadOnlyList<AgentSkillScript>? 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; }
/// <inheritdoc/>
public override string Content { get; }
/// <remarks>
/// Returns the raw SKILL.md content. When the skill has scripts, a
/// <c>&lt;scripts&gt;&lt;script name="..."&gt;&lt;parameters_schema&gt;...&lt;/parameters_schema&gt;&lt;/script&gt;&lt;/scripts&gt;</c>
/// block is appended with a per-script entry describing the expected argument format.
/// The result is cached after the first access.
/// </remarks>
public override string Content
{
get => this._content ??= this._scripts is { Count: > 0 }
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptsBlock(this._scripts)
: this._originalContent;
}
/// <summary>
/// Gets the directory path where the skill was discovered.
@@ -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
{
/// <summary>
/// Cached JSON schema element describing the expected argument format: a string array of CLI arguments.
/// </summary>
private static readonly JsonElement s_defaultSchema = CreateDefaultSchema();
private readonly AgentFileSkillScriptRunner? _runner;
/// <summary>
@@ -37,7 +42,14 @@ public sealed class AgentFileSkillScript : AgentSkillScript
public string FullPath { get; }
/// <inheritdoc/>
public override async Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default)
/// <remarks>
/// Returns a fixed schema describing a string array of CLI arguments:
/// <c>{"type":"array","items":{"type":"string"}}</c>.
/// </remarks>
public override JsonElement? ParametersSchema => s_defaultSchema;
/// <inheritdoc/>
public override async Task<object?> 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();
}
}
@@ -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;
/// </summary>
/// <remarks>
/// Implementations determine the execution strategy (e.g., local subprocess, hosted code execution environment).
/// The <paramref name="arguments"/> parameter preserves the raw JSON sent by the caller, in the shape
/// described by <see cref="AgentFileSkillScript.ParametersSchema"/>.
/// </remarks>
/// <param name="skill">The skill that owns the script.</param>
/// <param name="script">The file-based script to run.</param>
/// <param name="arguments">Optional arguments for the script, provided by the agent/LLM.</param>
/// <param name="arguments">Raw JSON arguments for the script, in the shape described by <see cref="AgentFileSkillScript.ParametersSchema"/>.</param>
/// <param name="serviceProvider">Optional service provider for dependency injection.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The script execution result.</returns>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public delegate Task<object?> AgentFileSkillScriptRunner(
AgentFileSkill skill,
AgentFileSkillScript script,
AIFunctionArguments arguments,
JsonElement? arguments,
IServiceProvider? serviceProvider,
CancellationToken cancellationToken);
@@ -59,36 +59,60 @@ internal static class AgentInlineSkillContentBuilder
if (scripts is { Count: > 0 })
{
sb.Append("\n\n<scripts>\n");
foreach (var script in scripts)
{
var parametersSchema = script.ParametersSchema;
if (script.Description is null && parametersSchema is null)
{
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
}
else
{
sb.Append(script.Description is not null
? $" <script name=\"{EscapeXmlString(script.Name)}\" description=\"{EscapeXmlString(script.Description)}\">\n"
: $" <script name=\"{EscapeXmlString(script.Name)}\">\n");
if (parametersSchema is not null)
{
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
}
sb.Append(" </script>\n");
}
}
sb.Append("</scripts>");
sb.Append('\n');
sb.Append(BuildScriptsBlock(scripts));
}
return sb.ToString();
}
/// <summary>
/// Builds a <c>&lt;scripts&gt;...&lt;/scripts&gt;</c> XML block for the given scripts.
/// Each script is emitted as a <c>&lt;script name="..."&gt;</c> element with optional
/// <c>description</c> attribute and <c>&lt;parameters_schema&gt;</c> child element.
/// </summary>
/// <param name="scripts">The scripts to include in the block.</param>
/// <returns>An XML string starting with <c>\n&lt;scripts&gt;</c>, or an empty string if the list is empty.</returns>
public static string BuildScriptsBlock(IReadOnlyList<AgentSkillScript> scripts)
{
_ = Throw.IfNull(scripts);
if (scripts.Count == 0)
{
return string.Empty;
}
var sb = new StringBuilder();
sb.Append("\n<scripts>\n");
foreach (var script in scripts)
{
var parametersSchema = script.ParametersSchema;
if (script.Description is null && parametersSchema is null)
{
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
}
else
{
sb.Append(script.Description is not null
? $" <script name=\"{EscapeXmlString(script.Name)}\" description=\"{EscapeXmlString(script.Description)}\">\n"
: $" <script name=\"{EscapeXmlString(script.Name)}\">\n");
if (parametersSchema is not null)
{
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
}
sb.Append(" </script>\n");
}
}
sb.Append("</scripts>");
return sb.ToString();
}
/// <summary>
/// Escapes XML special characters: always escapes <c>&amp;</c>, <c>&lt;</c>, <c>&gt;</c>,
/// <c>&quot;</c>, and <c>&apos;</c>. When <paramref name="preserveQuotes"/> is <see langword="true"/>,
@@ -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;
/// <inheritdoc/>
public override async Task<object?> RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default)
public override async Task<object?> 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);
}
/// <summary>
/// Converts a raw <see cref="JsonElement"/> to <see cref="AIFunctionArguments"/> for delegate invocation.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when <paramref name="arguments"/> 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.
/// </exception>
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<string, object?>();
foreach (var property in arguments.Value.EnumerateObject())
{
dict[property.Name] = property.Value;
}
return new AIFunctionArguments(dict);
}
}
@@ -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; }
/// <summary>
/// Create the workflow from the declarative YAML. Includes definition of the
/// <see cref="DeclarativeWorkflowOptions" /> and the associated <see cref="ResponseAgentProvider"/>.
@@ -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);
@@ -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:
@@ -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);
@@ -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<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult<object?>("result");
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>("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<InvalidOperationException>(
() => 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<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct)
Task<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, JsonElement? args, IServiceProvider? sp, CancellationToken ct)
{
runnerCalled = true;
return Task.FromResult<object?>("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<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, AIFunctionArguments args, CancellationToken ct)
Task<object?> 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<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, AIFunctionArguments a, CancellationToken ct) => Task.FromResult<object?>(null);
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(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<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(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<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(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("<scripts>", content);
Assert.Contains("<script name=\"build\">", content);
Assert.Contains("<script name=\"deploy\">", content);
Assert.Contains("<parameters_schema>", content);
Assert.Contains("</scripts>", content);
}
[Fact]
public void Content_WithoutScripts_ReturnsOriginalContent()
{
// Arrange
var fileSkill = new AgentFileSkill(
new AgentSkillFrontmatter("my-skill", "A skill"),
"Original content only",
"/skills/my-skill");
// Act
var content = fileSkill.Content;
// Assert
Assert.Equal("Original content only", content);
}
[Fact]
public void Content_WithScripts_IsCached()
{
// Arrange
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
var script = CreateScript("test", "/scripts/test.sh", RunnerAsync);
var fileSkill = new AgentFileSkill(
new AgentSkillFrontmatter("my-skill", "A skill"),
"Content",
"/skills/my-skill",
scripts: [script]);
// Act
var content1 = fileSkill.Content;
var content2 = fileSkill.Content;
// Assert
Assert.Same(content1, content2);
}
[Fact]
public async Task RunAsync_ForwardsJsonArrayArgumentsToRunnerAsync()
{
// Arrange
JsonElement? capturedArgs = null;
Task<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, JsonElement? args, IServiceProvider? sp, CancellationToken ct)
{
capturedArgs = args;
return Task.FromResult<object?>("done");
}
var script = CreateScript("array-test", "/scripts/test.sh", runnerAsync);
var fileSkill = new AgentFileSkill(
new AgentSkillFrontmatter("my-skill", "A skill"),
"Content",
"/skills/my-skill");
using var arrayArgsDoc = JsonDocument.Parse("""["arg1","arg2","arg3"]""");
var arrayArgs = arrayArgsDoc.RootElement;
// Act
await script.RunAsync(fileSkill, arrayArgs, null, CancellationToken.None);
// Assert — the raw JSON array is forwarded unchanged
Assert.NotNull(capturedArgs);
Assert.Equal(JsonValueKind.Array, capturedArgs!.Value.ValueKind);
Assert.Equal("""["arg1","arg2","arg3"]""", capturedArgs.Value.GetRawText());
}
[Fact]
public async Task RunAsync_ForwardsServiceProviderToRunnerAsync()
{
// Arrange
IServiceProvider? capturedProvider = null;
Task<object?> runnerAsync(AgentFileSkill skill, AgentFileSkillScript scriptArg, JsonElement? args, IServiceProvider? sp, CancellationToken ct)
{
capturedProvider = sp;
return Task.FromResult<object?>("done");
}
var script = CreateScript("sp-test", "/scripts/test.sh", runnerAsync);
var fileSkill = new AgentFileSkill(
new AgentSkillFrontmatter("my-skill", "A skill"),
"Content",
"/skills/my-skill");
var mockProvider = new TestServiceProvider();
// Act
await script.RunAsync(fileSkill, null, mockProvider, CancellationToken.None);
// Assert
Assert.Same(mockProvider, capturedProvider);
}
[Fact]
public async Task RunAsync_NoRunner_ThrowsInvalidOperationExceptionAsync()
{
// Arrange — create script without a runner
var script = CreateScript("no-runner", "/scripts/test.sh", runner: null);
var fileSkill = new AgentFileSkill(
new AgentSkillFrontmatter("my-skill", "A skill"),
"Content",
"/skills/my-skill");
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => script.RunAsync(fileSkill, null, null, CancellationToken.None));
}
[Fact]
public void Content_WithScripts_ContainsDefaultParametersSchema()
{
// Arrange
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
var script = CreateScript("test", "/scripts/test.sh", RunnerAsync);
var fileSkill = new AgentFileSkill(
new AgentSkillFrontmatter("my-skill", "A skill"),
"Original content",
"/skills/my-skill",
scripts: [script]);
// Act
var content = fileSkill.Content;
// Assert — the appended block contains the actual default schema from AgentFileSkillScript
Assert.Contains("""{"type":"array","items":{"type":"string"}}""", content);
}
/// <summary>
/// Helper to create an <see cref="AgentFileSkillScript"/> via reflection since the constructor is internal.
/// </summary>
private static AgentFileSkillScript CreateScript(string name, string fullPath, AgentFileSkillScriptRunner executor)
private static AgentFileSkillScript CreateScript(string name, string fullPath, AgentFileSkillScriptRunner? runner)
{
var ctor = typeof(AgentFileSkillScript).GetConstructor(
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance,
@@ -98,6 +261,14 @@ public sealed class AgentFileSkillScriptTests
[typeof(string), typeof(string), typeof(AgentFileSkillScriptRunner)],
null) ?? throw new InvalidOperationException("Could not find internal constructor.");
return (AgentFileSkillScript)ctor.Invoke([name, fullPath, executor]);
return (AgentFileSkillScript)ctor.Invoke([name, fullPath, runner]);
}
/// <summary>
/// Minimal <see cref="IServiceProvider"/> for testing service forwarding.
/// </summary>
private sealed class TestServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
}
@@ -3,9 +3,9 @@
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
@@ -15,7 +15,7 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
public sealed class AgentFileSkillsSourceScriptTests : IDisposable
{
private static readonly string[] s_rubyExtension = new[] { ".rb" };
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult<object?>(null);
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, sp, ct) => Task.FromResult<object?>(null);
private readonly string _testRoot;
@@ -139,7 +139,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
var executorCalled = false;
var source = new AgentFileSkillsSource(
this._testRoot,
(skill, script, args, ct) =>
(skill, script, args, sp, ct) =>
{
executorCalled = true;
Assert.Equal("exec-skill", skill.Frontmatter.Name);
@@ -150,7 +150,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
var scriptResult = await skills[0].Scripts![0].RunAsync(skills[0], new AIFunctionArguments(), CancellationToken.None);
var scriptResult = await skills[0].Scripts![0].RunAsync(skills[0], null, null, CancellationToken.None);
// Assert
Assert.True(executorCalled);
@@ -178,7 +178,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
var script = skills[0].Scripts![0];
// Assert — running the script throws because no runner was provided
await Assert.ThrowsAsync<InvalidOperationException>(() => script.RunAsync(skills[0], new AIFunctionArguments(), CancellationToken.None));
await Assert.ThrowsAsync<InvalidOperationException>(() => script.RunAsync(skills[0], null, null, CancellationToken.None));
}
[Fact]
@@ -204,10 +204,10 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
{
// Arrange
CreateSkillWithScript(this._testRoot, "args-skill", "Args test", "Body.", "scripts/test.py", "print('ok')");
AIFunctionArguments? capturedArgs = null;
JsonElement? capturedArgs = null;
var source = new AgentFileSkillsSource(
this._testRoot,
(skill, script, args, ct) =>
(skill, script, args, sp, ct) =>
{
capturedArgs = args;
return Task.FromResult<object?>("done");
@@ -215,17 +215,15 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
var arguments = new AIFunctionArguments
{
["value"] = 26.2,
["factor"] = 1.60934
};
await skills[0].Scripts![0].RunAsync(skills[0], arguments, CancellationToken.None);
using var argumentsDoc = JsonDocument.Parse("""{"value":26.2,"factor":1.60934}""");
var arguments = argumentsDoc.RootElement;
await skills[0].Scripts![0].RunAsync(skills[0], arguments, null, CancellationToken.None);
// Assert
Assert.NotNull(capturedArgs);
Assert.Equal(26.2, capturedArgs["value"]);
Assert.Equal(1.60934, capturedArgs["factor"]);
Assert.Equal(JsonValueKind.Object, capturedArgs!.Value.ValueKind);
Assert.Equal(26.2, capturedArgs.Value.GetProperty("value").GetDouble());
Assert.Equal(1.60934, capturedArgs.Value.GetProperty("factor").GetDouble());
}
[Fact]
@@ -5,7 +5,6 @@ using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
@@ -22,7 +21,7 @@ public sealed class AgentInlineSkillScriptTests
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
// Act
var result = await script.RunAsync(skill, new AIFunctionArguments(), CancellationToken.None);
var result = await script.RunAsync(skill, null, null, CancellationToken.None);
// Assert
Assert.Equal("hello", result?.ToString());
@@ -34,10 +33,11 @@ public sealed class AgentInlineSkillScriptTests
// Arrange
var script = new AgentInlineSkillScript("add", (int a, int b) => a + b);
var skill = new AgentInlineSkill("calc-skill", "Calc.", "Instructions.");
var args = new AIFunctionArguments { ["a"] = 3, ["b"] = 7 };
using var argsDoc = JsonDocument.Parse("""{"a":3,"b":7}""");
var args = argsDoc.RootElement;
// Act
var result = await script.RunAsync(skill, args, CancellationToken.None);
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
// Assert
Assert.Equal(10, int.Parse(result?.ToString()!));
@@ -129,10 +129,11 @@ public sealed class AgentInlineSkillScriptTests
}, serializerOptions: jso);
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 5 }, jso);
var args = new AIFunctionArguments { ["request"] = inputJson };
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
var args = argsDoc.RootElement;
// Act
var result = await script.RunAsync(skill, args, CancellationToken.None);
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
// Assert — the custom input type was deserialized and the response was produced
Assert.NotNull(result);
@@ -145,10 +146,11 @@ public sealed class AgentInlineSkillScriptTests
// Arrange
var script = new AgentInlineSkillScript("echo", (string message) => message);
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
var args = new AIFunctionArguments { ["message"] = "hello world" };
using var argsDoc = JsonDocument.Parse("""{"message":"hello world"}""");
var args = argsDoc.RootElement;
// Act
var result = await script.RunAsync(skill, args, CancellationToken.None);
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
// Assert
Assert.Equal("hello world", result?.ToString());
@@ -175,10 +177,11 @@ public sealed class AgentInlineSkillScriptTests
var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(StaticScriptHelper), BindingFlags.NonPublic | BindingFlags.Static)!;
var script = new AgentInlineSkillScript("static-method-script", method, target: null);
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
var args = new AIFunctionArguments { ["input"] = "hello" };
using var argsDoc = JsonDocument.Parse("""{"input":"hello"}""");
var args = argsDoc.RootElement;
// Act
var result = await script.RunAsync(skill, args, CancellationToken.None);
var result = await script.RunAsync(skill, args, null, CancellationToken.None);
// Assert
Assert.Equal("HELLO", result?.ToString());
@@ -191,10 +194,11 @@ public sealed class AgentInlineSkillScriptTests
var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(InstanceScriptHelper), BindingFlags.NonPublic | BindingFlags.Instance)!;
var script = new AgentInlineSkillScript("instance-method-script", method, target: this);
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
var args = new AIFunctionArguments { ["input"] = "test" };
using var argsDoc2 = JsonDocument.Parse("""{"input":"test"}""");
var args2 = argsDoc2.RootElement;
// Act
var result = await script.RunAsync(skill, args, CancellationToken.None);
var result = await script.RunAsync(skill, args2, null, CancellationToken.None);
// Assert
Assert.Equal("test-suffix", result?.ToString());
@@ -223,7 +227,63 @@ public sealed class AgentInlineSkillScriptTests
Assert.Contains("input", schema!.Value.GetRawText());
}
[Fact]
public async Task RunAsync_WithNonObjectArguments_ThrowsInvalidOperationExceptionAsync()
{
// Arrange — inline scripts require a JSON object for arguments
var script = new AgentInlineSkillScript("noop", () => "ok");
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
using var arrayArgsDoc = JsonDocument.Parse("""["a","b"]""");
var arrayArgs = arrayArgsDoc.RootElement;
// Act & Assert — non-object JSON should fail fast rather than silently dropping arguments
await Assert.ThrowsAsync<InvalidOperationException>(
() => script.RunAsync(skill, arrayArgs, null, CancellationToken.None));
}
[Fact]
public async Task RunAsync_WithNullArguments_TreatsAsNoArgumentsAsync()
{
// Arrange — a parameterless delegate should succeed when given null arguments
var script = new AgentInlineSkillScript("noop", () => "ok");
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
// Act
var result = await script.RunAsync(skill, null, null, CancellationToken.None);
// Assert
Assert.Equal("ok", result?.ToString());
}
[Fact]
public async Task RunAsync_ServiceProviderIsForwardedAsync()
{
// Arrange — delegate that resolves a service from the IServiceProvider
IServiceProvider? capturedProvider = null;
var script = new AgentInlineSkillScript("svc-test", (IServiceProvider sp) =>
{
capturedProvider = sp;
return "done";
});
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
var mockProvider = new TestServiceProvider();
// Act
await script.RunAsync(skill, null, mockProvider, CancellationToken.None);
// Assert
Assert.Same(mockProvider, capturedProvider);
}
private static string StaticScriptHelper(string input) => input.ToUpperInvariant();
private string InstanceScriptHelper(string input) => input + "-suffix";
/// <summary>
/// Minimal <see cref="IServiceProvider"/> for testing service forwarding.
/// </summary>
private sealed class TestServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
}
@@ -433,10 +433,11 @@ public sealed class AgentInlineSkillTests
TotalCount = request.MaxResults,
});
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 3 }, jso);
var args = new AIFunctionArguments { ["request"] = inputJson };
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
var args = argsDoc.RootElement;
// Act
var result = await skill.Scripts![0].RunAsync(skill, args, CancellationToken.None);
var result = await skill.Scripts![0].RunAsync(skill, args, null, CancellationToken.None);
// Assert — the custom input was deserialized via skill-level JSO and response was produced
Assert.NotNull(result);
@@ -456,10 +457,11 @@ public sealed class AgentInlineSkillTests
TotalCount = request.MaxResults,
}, serializerOptions: scriptJso);
var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "override", MaxResults = 7 }, scriptJso);
var args = new AIFunctionArguments { ["request"] = inputJson };
using var argsDoc = JsonDocument.Parse($$"""{ "request": {{inputJson.GetRawText()}} }""");
var args = argsDoc.RootElement;
// Act
var result = await skill.Scripts![0].RunAsync(skill, args, CancellationToken.None);
var result = await skill.Scripts![0].RunAsync(skill, args, null, CancellationToken.None);
// Assert — per-script JSO takes effect and custom types are properly marshaled
Assert.NotNull(result);
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -15,7 +16,7 @@ namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// </summary>
public sealed class AgentSkillsProviderTests : IDisposable
{
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult<object?>(null);
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, sp, ct) => Task.FromResult<object?>(null);
private readonly string _testRoot;
private readonly TestAIAgent _agent = new();
@@ -462,7 +463,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
// Act — call UseFileScriptRunner AFTER UseFileSkill (the bug scenario)
var provider = new AgentSkillsProviderBuilder()
.UseFileSkill(this._testRoot)
.UseFileScriptRunner((skill, script, args, ct) =>
.UseFileScriptRunner((skill, script, args, sp, ct) =>
{
executorCalled = true;
return Task.FromResult<object?>("executed");
@@ -487,6 +488,62 @@ public sealed class AgentSkillsProviderTests : IDisposable
Assert.True(executorCalled);
}
[Fact]
public async Task RunSkillScript_ForwardsJsonArgumentsAndServiceProviderToRunnerAsync()
{
// Arrange — create a skill with a script file
string skillDir = Path.Combine(this._testRoot, "fwd-skill");
Directory.CreateDirectory(Path.Combine(skillDir, "scripts"));
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: fwd-skill\ndescription: Forwarding test\n---\nBody.");
File.WriteAllText(
Path.Combine(skillDir, "scripts", "run.py"),
"print('ok')");
JsonElement? capturedArgs = null;
IServiceProvider? capturedServiceProvider = null;
var provider = new AgentSkillsProviderBuilder()
.UseFileSkill(this._testRoot)
.UseFileScriptRunner((skill, script, args, sp, ct) =>
{
capturedArgs = args;
capturedServiceProvider = sp;
return Task.FromResult<object?>("executed");
})
.Build();
var mockServiceProvider = new TestServiceProvider();
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
var runScriptTool = result.Tools!.First(t => t.Name == "run_skill_script") as AIFunction;
// Act — invoke with JsonElement arguments and a service provider
using var argsJsonDoc = JsonDocument.Parse("""["arg1","arg2"]""");
var argsJson = argsJsonDoc.RootElement;
await runScriptTool!.InvokeAsync(new AIFunctionArguments(new Dictionary<string, object?>
{
["skillName"] = "fwd-skill",
["scriptName"] = "scripts/run.py",
["arguments"] = argsJson,
})
{
Services = mockServiceProvider,
});
// Assert — JsonElement arguments and service provider are forwarded to the runner
Assert.NotNull(capturedArgs);
Assert.Equal(JsonValueKind.Array, capturedArgs!.Value.ValueKind);
Assert.Equal("""["arg1","arg2"]""", capturedArgs.Value.GetRawText());
Assert.Same(mockServiceProvider, capturedServiceProvider);
}
private sealed class TestServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
private static void CreateSkillIn(string root, string name, string description, string body)
{
string skillDir = Path.Combine(root, name);
@@ -15,7 +15,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
private static readonly string[] s_customExtensions = [".custom"];
private static readonly string[] s_validExtensions = [".md", ".json", ".custom"];
private static readonly string[] s_mixedValidInvalidExtensions = [".md", "json"];
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, ct) => Task.FromResult<object?>(null);
private static readonly AgentFileSkillScriptRunner s_noOpExecutor = (skill, script, args, sp, ct) => Task.FromResult<object?>(null);
private readonly string _testRoot;
@@ -60,10 +60,20 @@ public abstract class IntegrationTest : IDisposable
protected async ValueTask<DeclarativeWorkflowOptions> CreateOptionsAsync(bool externalConversation = false, params IEnumerable<AIFunction> functionTools)
{
return await this.CreateOptionsAsync(externalConversation, mcpToolProvider: null, functionTools).ConfigureAwait(false);
return await this.CreateOptionsAsync(externalConversation, mcpToolProvider: null, httpRequestHandler: null, functionTools).ConfigureAwait(false);
}
protected async ValueTask<DeclarativeWorkflowOptions> CreateOptionsAsync(bool externalConversation, IMcpToolHandler? mcpToolProvider, params IEnumerable<AIFunction> functionTools)
{
return await this.CreateOptionsAsync(externalConversation, mcpToolProvider, httpRequestHandler: null, functionTools).ConfigureAwait(false);
}
protected async ValueTask<DeclarativeWorkflowOptions> CreateOptionsAsync(bool externalConversation, IHttpRequestHandler? httpRequestHandler, params IEnumerable<AIFunction> functionTools)
{
return await this.CreateOptionsAsync(externalConversation, mcpToolProvider: null, httpRequestHandler, functionTools).ConfigureAwait(false);
}
protected async ValueTask<DeclarativeWorkflowOptions> CreateOptionsAsync(bool externalConversation, IMcpToolHandler? mcpToolProvider, IHttpRequestHandler? httpRequestHandler, params IEnumerable<AIFunction> functionTools)
{
AzureAgentProvider agentProvider =
new(this.TestEndpoint, TestAzureCliCredentials.CreateAzureCliCredential())
@@ -82,7 +92,8 @@ public abstract class IntegrationTest : IDisposable
{
ConversationId = conversationId,
LoggerFactory = this.Output,
McpToolHandler = mcpToolProvider
McpToolHandler = mcpToolProvider,
HttpRequestHandler = httpRequestHandler,
};
}
@@ -45,6 +45,15 @@ public sealed class InvokeToolWorkflowTest(ITestOutputHelper output) : Integrati
#endregion
#region InvokeHttpRequest Tests
[RetryTheory(3, 5000)]
[InlineData("HttpRequest.yaml", "visibility: public")]
public Task ValidateHttpRequestAsync(string workflowFileName, string? expectedResultContains) =>
this.RunHttpRequestTestAsync(workflowFileName, expectedResultContains);
#endregion
#region InvokeFunctionTool Test Helpers
/// <summary>
@@ -250,6 +259,40 @@ public sealed class InvokeToolWorkflowTest(ITestOutputHelper output) : Integrati
#endregion
#region InvokeHttpRequest Test Helpers
/// <summary>
/// Runs an HttpRequestAction workflow test with the specified configuration.
/// </summary>
private async Task RunHttpRequestTestAsync(
string workflowFileName,
string? expectedResultContains = null)
{
// Arrange
string workflowPath = GetWorkflowPath(workflowFileName);
await using DefaultHttpRequestHandler httpRequestHandler = new();
DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(
externalConversation: false,
httpRequestHandler: httpRequestHandler);
Workflow workflow = DeclarativeWorkflowBuilder.Build<string>(workflowPath, workflowOptions);
WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath));
// Act
WorkflowEvents workflowEvents = await harness.RunWorkflowAsync("start").ConfigureAwait(false);
// Assert - Verify executor and action events
AssertWorkflowEventsEmitted(workflowEvents);
// Assert - Verify expected result if specified
if (expectedResultContains is not null)
{
AssertResultContains(workflowEvents, expectedResultContains);
}
}
#endregion
#region Shared Helpers
private static void AssertWorkflowEventsEmitted(WorkflowEvents workflowEvents)
@@ -0,0 +1,32 @@
#
# This workflow tests invoking HttpRequestAction end-to-end.
# Uses the public GitHub API (unauthenticated) to fetch repo metadata.
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_http_request_test
actions:
# Set the repo owner used to form the request URL.
- kind: SetVariable
id: set_repo_owner
variable: Local.RepoOwner
value: dotnet
# Invoke the GitHub repo API.
- kind: HttpRequestAction
id: fetch_repo_info
conversationId: =System.ConversationId
method: GET
url: =Concatenate("https://api.github.com/repos/", Local.RepoOwner, "/runtime")
headers:
Accept: application/vnd.github+json
User-Agent: agent-framework-integration-test
response: Local.RepoInfo
# Surface the Repo visibility field from the parsed JSON response.
- kind: SendMessage
id: show_visibility
message: "visibility: {Local.RepoInfo.visibility}"
@@ -181,6 +181,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
[InlineData("ResetVariable.yaml", 2, "clear_var")]
[InlineData("MixedScopes.yaml", 2, "activity_input")]
[InlineData("CaseInsensitive.yaml", 6, "end_when_match")]
[InlineData("HttpRequest.yaml", 1, "http_request")]
public async Task ExecuteActionAsync(string workflowFile, int expectedCount, string expectedId)
{
await this.RunWorkflowAsync(workflowFile);
@@ -200,7 +201,6 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
[InlineData(typeof(EmitEvent.Builder))]
[InlineData(typeof(GetActivityMembers.Builder))]
[InlineData(typeof(GetConversationMembers.Builder))]
[InlineData(typeof(HttpRequestAction.Builder))]
[InlineData(typeof(InvokeAIBuilderModelAction.Builder))]
[InlineData(typeof(InvokeConnectorAction.Builder))]
[InlineData(typeof(InvokeCustomModelAction.Builder))]
@@ -266,6 +266,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
[InlineData("SendActivity.yaml", "activity_input")]
[InlineData("SetVariable.yaml", "set_var")]
[InlineData("SetTextVariable.yaml", "set_text")]
[InlineData("HttpRequest.yaml", "http_request")]
public async Task CancelRunAsync(string workflowPath, string expectedExecutedId)
{
// Arrange
@@ -374,7 +375,12 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
{
using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath));
Mock<ResponseAgentProvider> mockAgentProvider = CreateMockProvider($"{workflowInput}");
DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object) { LoggerFactory = this.Output };
DeclarativeWorkflowOptions workflowContext =
new(mockAgentProvider.Object)
{
LoggerFactory = this.Output,
HttpRequestHandler = CreateMockHttpRequestHandler().Object,
};
return DeclarativeWorkflowBuilder.Build<TInput>(yamlReader, workflowContext);
}
@@ -385,4 +391,18 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
mockAgentProvider.Setup(provider => provider.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(new ChatMessage(ChatRole.Assistant, input)));
return mockAgentProvider;
}
private static Mock<IHttpRequestHandler> CreateMockHttpRequestHandler()
{
Mock<IHttpRequestHandler> mockHandler = new(MockBehavior.Loose);
mockHandler
.Setup(handler => handler.SendAsync(It.IsAny<HttpRequestInfo>(), It.IsAny<CancellationToken>()))
.Returns(() => Task.FromResult(new HttpRequestResult
{
StatusCode = 200,
IsSuccessStatusCode = true,
Body = "{\"ok\":true}",
}));
return mockHandler;
}
}
@@ -0,0 +1,510 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
/// <summary>
/// Unit tests for <see cref="DefaultHttpRequestHandler"/>.
/// </summary>
public sealed class DefaultHttpRequestHandlerTests
{
private static readonly string[] s_setCookieValues = ["a=1", "b=2"];
private const string TestUrl = "https://api.example.test/resource";
#region Constructor Tests
[Fact]
public async Task ConstructorWithNoParametersCreatesInstanceAsync()
{
// Act
await using DefaultHttpRequestHandler handler = new();
// Assert
handler.Should().NotBeNull();
}
[Fact]
public async Task ConstructorWithNullProviderCreatesInstanceAsync()
{
// Act
await using DefaultHttpRequestHandler handler = new(httpClientProvider: null);
// Assert
handler.Should().NotBeNull();
}
[Fact]
public void ConstructorWithNullHttpClientThrows()
{
// Act
Action act = () => _ = new DefaultHttpRequestHandler((HttpClient)null!);
// Assert
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public async Task ConstructorWithHttpClientUsesSuppliedClientForAllRequestsAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("ok", Encoding.UTF8, "text/plain"),
}));
using HttpClient suppliedClient = new(messageHandler);
await using DefaultHttpRequestHandler handler = new(suppliedClient);
HttpRequestInfo request = new() { Method = "GET", Url = TestUrl };
// Act
HttpRequestResult result = await handler.SendAsync(request);
// Assert - the supplied HttpClient's underlying handler saw the request
messageHandler.LastRequest.Should().NotBeNull();
messageHandler.LastRequest!.RequestUri!.ToString().Should().Be(TestUrl);
result.Body.Should().Be("ok");
}
[Fact]
public async Task DisposeAsyncDoesNotDisposeCallerSuppliedHttpClientAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
using HttpClient suppliedClient = new(messageHandler);
// Act
DefaultHttpRequestHandler handler = new(suppliedClient);
await handler.DisposeAsync();
// Assert - supplied client remains usable (not disposed)
Func<Task> act = async () => await suppliedClient.GetAsync(new Uri(TestUrl));
await act.Should().NotThrowAsync<ObjectDisposedException>();
}
#endregion
#region Argument Validation Tests
[Fact]
public async Task SendAsyncWithNullRequestThrowsAsync()
{
// Arrange
await using DefaultHttpRequestHandler handler = new();
// Act
Func<Task> act = async () => await handler.SendAsync(null!);
// Assert
await act.Should().ThrowAsync<ArgumentNullException>();
}
[Fact]
public async Task SendAsyncWithEmptyUrlThrowsAsync()
{
// Arrange
await using DefaultHttpRequestHandler handler = new();
HttpRequestInfo request = new() { Method = "GET", Url = "" };
// Act
Func<Task> act = async () => await handler.SendAsync(request);
// Assert
await act.Should().ThrowAsync<ArgumentException>();
}
[Fact]
public async Task SendAsyncWithEmptyMethodThrowsAsync()
{
// Arrange
await using DefaultHttpRequestHandler handler = new();
HttpRequestInfo request = new() { Method = "", Url = TestUrl };
// Act
Func<Task> act = async () => await handler.SendAsync(request);
// Assert
await act.Should().ThrowAsync<ArgumentException>();
}
#endregion
#region Send Behavior Tests
[Fact]
public async Task SendAsyncUsesProvidedHttpClientAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("hello", Encoding.UTF8, "text/plain"),
}));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
HttpRequestInfo request = new() { Method = "GET", Url = TestUrl };
// Act
HttpRequestResult result = await handler.SendAsync(request);
// Assert
messageHandler.LastRequest.Should().NotBeNull();
messageHandler.LastRequest!.Method.Should().Be(HttpMethod.Get);
messageHandler.LastRequest.RequestUri!.ToString().Should().Be(TestUrl);
result.StatusCode.Should().Be(200);
result.IsSuccessStatusCode.Should().BeTrue();
result.Body.Should().Be("hello");
}
[Fact]
public async Task SendAsyncMapsAllKnownMethodsAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
foreach (string method in new[] { "GET", "POST", "PUT", "PATCH", "DELETE", "CUSTOM" })
{
HttpRequestInfo request = new() { Method = method, Url = TestUrl };
// Act
await handler.SendAsync(request);
// Assert
messageHandler.LastRequest!.Method.Method.Should().Be(method);
}
}
[Fact]
public async Task SendAsyncNormalizesWhitespaceAroundCustomMethodAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
HttpRequestInfo request = new() { Method = " custom ", Url = TestUrl };
// Act
await handler.SendAsync(request);
// Assert - fallback path should apply the same Trim/ToUpperInvariant normalization.
messageHandler.LastRequest!.Method.Method.Should().Be("CUSTOM");
}
[Fact]
public async Task SendAsyncAppliesBodyAndContentTypeAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
HttpRequestInfo request = new()
{
Method = "POST",
Url = TestUrl,
Body = "{\"hello\":\"world\"}",
BodyContentType = "application/json",
};
// Act
await handler.SendAsync(request);
// Assert
messageHandler.LastRequestBody.Should().Be("{\"hello\":\"world\"}");
messageHandler.LastRequestContentType.Should().Be("application/json");
}
[Fact]
public async Task SendAsyncAppliesRequestHeadersAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
HttpRequestInfo request = new()
{
Method = "GET",
Url = TestUrl,
Headers = new Dictionary<string, string>
{
["Authorization"] = "Bearer secret",
["Accept"] = "application/json",
},
};
// Act
await handler.SendAsync(request);
// Assert
messageHandler.LastRequest!.Headers.Authorization!.ToString().Should().Be("Bearer secret");
messageHandler.LastRequest.Headers.Accept.Should().Contain(mediaType => mediaType.MediaType == "application/json");
}
[Fact]
public async Task SendAsyncRoutesContentHeadersToBodyAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
HttpRequestInfo request = new()
{
Method = "POST",
Url = TestUrl,
Body = "raw",
BodyContentType = "text/plain",
Headers = new Dictionary<string, string>
{
["Content-Language"] = "en-US",
},
};
// Act
await handler.SendAsync(request);
// Assert
messageHandler.LastRequest!.Content!.Headers.ContentLanguage.Should().Contain("en-US");
}
[Fact]
public async Task SendAsyncCapturesResponseHeadersAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
{
#pragma warning disable CA2025
HttpResponseMessage response = new(HttpStatusCode.OK)
{
Content = new StringContent("ok", Encoding.UTF8, "text/plain"),
};
response.Headers.Add("X-Request-Id", "request-1");
response.Headers.Add("Set-Cookie", s_setCookieValues);
return Task.FromResult(response);
#pragma warning restore CA2025
});
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
HttpRequestInfo request = new() { Method = "GET", Url = TestUrl };
// Act
HttpRequestResult result = await handler.SendAsync(request);
// Assert
result.Headers.Should().NotBeNull();
result.Headers!.Should().ContainKey("X-Request-Id");
result.Headers!["Set-Cookie"].Should().BeEquivalentTo(s_setCookieValues);
// Content headers also flattened in.
result.Headers!.Should().ContainKey("Content-Type");
}
[Fact]
public async Task SendAsyncReturnsFailureStatusWithoutThrowingAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new((req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("bad request", Encoding.UTF8, "text/plain"),
}));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
HttpRequestInfo request = new() { Method = "GET", Url = TestUrl };
// Act
HttpRequestResult result = await handler.SendAsync(request);
// Assert
result.IsSuccessStatusCode.Should().BeFalse();
result.StatusCode.Should().Be(400);
result.Body.Should().Be("bad request");
}
[Fact]
public async Task SendAsyncTimeoutCancelsRequestAsync()
{
// Arrange
TestHttpMessageHandler messageHandler = new(async (req, ct) =>
{
await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false);
return new HttpResponseMessage(HttpStatusCode.OK);
});
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(messageHandler)));
HttpRequestInfo request = new()
{
Method = "GET",
Url = TestUrl,
Timeout = TimeSpan.FromMilliseconds(50),
};
// Act
Func<Task> act = async () => await handler.SendAsync(request);
// Assert
await act.Should().ThrowAsync<OperationCanceledException>();
}
[Fact]
public async Task SendAsyncFallsBackToOwnedClientWhenProviderReturnsNullAsync()
{
// Arrange
int providerCallCount = 0;
await using DefaultHttpRequestHandler handler = new((_, _) =>
{
providerCallCount++;
return Task.FromResult<HttpClient?>(null);
});
HttpRequestInfo request = new() { Method = "GET", Url = "http://127.0.0.1:1/" };
// Act - owned client will attempt real network and fail, but provider path should have been consulted first.
Func<Task> act = async () => await handler.SendAsync(request);
// Assert
await act.Should().ThrowAsync<Exception>();
providerCallCount.Should().Be(1);
}
#endregion
#region DisposeAsync
[Fact]
public async Task DisposeAsyncCompletesAsync()
{
// Arrange
DefaultHttpRequestHandler handler = new();
// Act
Func<Task> act = async () => await handler.DisposeAsync();
// Assert
await act.Should().NotThrowAsync();
}
[Fact]
public async Task DisposeAsyncCalledMultipleTimesSucceedsAsync()
{
// Arrange
DefaultHttpRequestHandler handler = new();
// Act
await handler.DisposeAsync();
Func<Task> second = async () => await handler.DisposeAsync();
// Assert
await second.Should().NotThrowAsync();
}
#endregion
#region Query Parameters and Connection Tests
[Fact]
public async Task QueryParametersAreAppendedToUrlAsync()
{
// Arrange
TestHttpMessageHandler fake = new(static (req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) }));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(fake)));
HttpRequestInfo info = new()
{
Method = "GET",
Url = TestUrl,
QueryParameters = new Dictionary<string, string>
{
["filter"] = "active items",
["ids"] = "1,2,3",
},
};
// Act
await handler.SendAsync(info);
// Assert
fake.LastRequest.Should().NotBeNull();
string? query = fake.LastRequest!.RequestUri!.Query;
query.Should().Contain("filter=active%20items");
query.Should().Contain("ids=1%2C2%2C3");
}
[Fact]
public async Task QueryParametersPreserveExistingQueryStringAsync()
{
// Arrange
TestHttpMessageHandler fake = new(static (req, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) }));
await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult<HttpClient?>(new HttpClient(fake)));
HttpRequestInfo info = new()
{
Method = "GET",
Url = TestUrl + "?existing=yes",
QueryParameters = new Dictionary<string, string>
{
["added"] = "true",
},
};
// Act
await handler.SendAsync(info);
// Assert
fake.LastRequest!.RequestUri!.Query.Should().Be("?existing=yes&added=true");
}
#endregion
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _responseFactory;
public TestHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responseFactory)
{
this._responseFactory = responseFactory;
}
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastRequestBody { get; private set; }
public string? LastRequestContentType { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
this.LastRequest = request;
if (request.Content is not null)
{
#if NET
this.LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
#else
this.LastRequestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false);
#endif
this.LastRequestContentType = request.Content.Headers.ContentType?.MediaType;
}
return await this._responseFactory(request, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -769,4 +769,165 @@ public sealed class ChatMessageExtensionsTests
break;
}
}
[Fact]
public void MergeForLastMessageReturnsInputWhenInputMessageIsNull()
{
// Arrange
ChatMessage input = new(ChatRole.User, "hello") { MessageId = "local" };
// Act
ChatMessage result = input.MergeForLastMessage(null);
// Assert
Assert.Same(input, result);
}
[Fact]
public void MergeForLastMessageReturnsSameInstanceAsRoundTripped()
{
// Arrange: returning the round-tripped instance keeps the merge forward-compatible
// with future ChatMessage properties (e.g., new metadata fields) without explicit copies.
ChatMessage input = new(ChatRole.User, "original");
ChatMessage roundTripped = new(ChatRole.User, "stripped") { MessageId = "server" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert
Assert.Same(roundTripped, result);
}
[Fact]
public void MergeForLastMessagePrefersOriginalTextOverRoundTrippedText()
{
// Arrange
ChatMessage input = new(ChatRole.User, "original text");
ChatMessage roundTripped = new(ChatRole.User, "stripped") { MessageId = "server-id" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert
Assert.Equal("server-id", result.MessageId);
Assert.Equal("original text", result.Text);
TextContent text = Assert.IsType<TextContent>(Assert.Single(result.Contents));
Assert.Equal("original text", text.Text);
}
[Fact]
public void MergeForLastMessageReplacesTextInPlaceAndKeepsServerMedia()
{
// Arrange
HostedFileContent serverRef = new("file-abc");
ChatMessage input = new(ChatRole.User, [new TextContent("look at this:"), new DataContent("data:image/jpeg;base64,QUJD", "image/jpeg")]);
ChatMessage roundTripped = new(ChatRole.User, [new TextContent("stripped"), serverRef]) { MessageId = "server-id" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert: server's text slot is replaced with original text; server's media reference is preserved.
Assert.Equal("server-id", result.MessageId);
Assert.Collection(result.Contents,
c => Assert.Equal("look at this:", Assert.IsType<TextContent>(c).Text),
c => Assert.Same(serverRef, c));
}
[Fact]
public void MergeForLastMessageAppendsOriginalTextWhenRoundTripHasNoTextSlot()
{
// Arrange: round-tripped message has only media (no text slot to replace).
HostedFileContent serverRef = new("file-1");
ChatMessage input = new(ChatRole.User, [new TextContent("middle"), new DataContent("data:image/jpeg;base64,QUE=", "image/jpeg")]);
ChatMessage roundTripped = new(ChatRole.User, [serverRef]) { MessageId = "id" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert: media kept; original text appended at end.
Assert.Collection(result.Contents,
c => Assert.Same(serverRef, c),
c => Assert.Equal("middle", Assert.IsType<TextContent>(c).Text));
}
[Fact]
public void MergeForLastMessageReplacesMultipleTextSlotsInOrder()
{
// Arrange: input has two text items; round-tripped has two text slots interleaved with media.
HostedFileContent firstRef = new("file-1");
HostedFileContent secondRef = new("file-2");
ChatMessage input = new(ChatRole.User, [new TextContent("first"), new TextContent("second")]);
ChatMessage roundTripped = new(ChatRole.User, [firstRef, new TextContent("a"), secondRef, new TextContent("b")]) { MessageId = "id" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert
Assert.Collection(result.Contents,
c => Assert.Same(firstRef, c),
c => Assert.Equal("first", Assert.IsType<TextContent>(c).Text),
c => Assert.Same(secondRef, c),
c => Assert.Equal("second", Assert.IsType<TextContent>(c).Text));
}
[Fact]
public void MergeForLastMessageFallsBackToInputTextWhenInputHasNoTextContent()
{
// Arrange: ChatMessage(role, "string") populates Text but no explicit TextContent
// when Contents is initially empty in some construction paths. Verify we still
// recover the original Text via input.Text.
ChatMessage input = new(ChatRole.User, "fallback text");
ChatMessage roundTripped = new(ChatRole.User, [new TextContent("stripped")]) { MessageId = "id" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert
Assert.Equal("fallback text", Assert.IsType<TextContent>(Assert.Single(result.Contents)).Text);
}
[Fact]
public void MergeForLastMessagePreservesServerAuthoredProperties()
{
// Arrange: server (round-trip) is authoritative for metadata. Returning the
// round-tripped instance means any future ChatMessage property is automatically
// preserved without code changes here.
ChatMessage input = new(ChatRole.User, "hi")
{
AuthorName = "client-side",
AdditionalProperties = new AdditionalPropertiesDictionary { ["client"] = "value" },
};
ChatMessage roundTripped = new(ChatRole.User, [new TextContent("stripped")])
{
MessageId = "server",
AuthorName = "server-side",
AdditionalProperties = new AdditionalPropertiesDictionary { ["server"] = "value" },
};
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert
Assert.Equal("server", result.MessageId);
Assert.Equal("server-side", result.AuthorName);
Assert.NotNull(result.AdditionalProperties);
Assert.True(result.AdditionalProperties.ContainsKey("server"));
Assert.False(result.AdditionalProperties.ContainsKey("client"));
}
[Fact]
public void MergeForLastMessageHandlesEmptyInputContents()
{
// Arrange
ChatMessage input = new(ChatRole.User, new List<AIContent>());
HostedFileContent serverRef = new("file-only");
ChatMessage roundTripped = new(ChatRole.User, [serverRef]) { MessageId = "id" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert: nothing to splice; round-tripped returned unchanged.
Assert.Same(roundTripped, result);
Assert.Equal("file-only", Assert.IsType<HostedFileContent>(Assert.Single(result.Contents)).FileId);
}
}
@@ -0,0 +1,759 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
using Moq;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
/// <summary>
/// Tests for <see cref="HttpRequestExecutor"/>.
/// </summary>
public sealed class HttpRequestExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output)
{
private const string TestUrl = "https://api.example.com/data";
private readonly Mock<ResponseAgentProvider> _agentProvider = new(MockBehavior.Loose);
[Fact]
public void InvalidModel()
{
// Arrange
Mock<IHttpRequestHandler> mockHandler = new();
// Act & Assert
Assert.Throws<DeclarativeModelException>(() => new HttpRequestExecutor(
new HttpRequestAction(),
mockHandler.Object,
this._agentProvider.Object,
this.State));
}
[Fact]
public void HttpRequestIsDiscreteAction()
{
// Arrange
Mock<IHttpRequestHandler> mockHandler = new();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestIsDiscreteAction),
url: TestUrl,
method: HttpMethodType.Get);
HttpRequestExecutor action = new(model, mockHandler.Object, this._agentProvider.Object, this.State);
// Act & Assert — IsDiscreteAction should be true for HttpRequest (single-step action).
VerifyIsDiscrete(action, isDiscrete: true);
}
[Fact]
public async Task HttpGetReturnsJsonObjectAsync()
{
// Arrange
this.State.InitializeSystem();
const string ResponseVar = "Result";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetReturnsJsonObjectAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseVariable: ResponseVar);
MockHttpRequestHandler handler = new(HttpRequestResult("{\"key\":\"value\",\"number\":42}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
Assert.IsType<RecordValue>(this.State.Get(ResponseVar), exactMatch: false);
handler.VerifySent(info => info.Method == "GET" && info.Url == TestUrl);
}
[Fact]
public async Task HttpGetReturnsPlainStringAsync()
{
// Arrange
this.State.InitializeSystem();
const string ResponseVar = "Result";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetReturnsPlainStringAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseVariable: ResponseVar);
MockHttpRequestHandler handler = new(HttpRequestResult("not-json content"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyState(ResponseVar, FormulaValue.New("not-json content"));
}
[Fact]
public async Task HttpGetWithEmptyBodyYieldsBlankAsync()
{
// Arrange
this.State.InitializeSystem();
const string ResponseVar = "Result";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetWithEmptyBodyYieldsBlankAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseVariable: ResponseVar);
MockHttpRequestHandler handler = new(HttpRequestResult(null));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this.VerifyUndefined(ResponseVar);
}
[Fact]
public async Task HttpGetForwardsHeadersAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetForwardsHeadersAsync),
url: TestUrl,
method: HttpMethodType.Get,
headers: new Dictionary<string, string>
{
["Authorization"] = "Bearer token",
["Accept"] = "application/json",
});
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.Headers?["Authorization"] == "Bearer token" &&
info.Headers?["Accept"] == "application/json");
}
[Fact]
public async Task HttpPostWithJsonBodyAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpPostWithJsonBodyAsync),
url: TestUrl,
method: HttpMethodType.Post,
jsonBody: new StringDataValue("hello"));
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.Method == "POST" &&
info.BodyContentType == "application/json" &&
info.Body == "\"hello\"");
}
[Fact]
public async Task HttpPostWithRawBodyAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpPostWithRawBodyAsync),
url: TestUrl,
method: HttpMethodType.Post,
rawBody: "raw body content",
rawContentType: "text/plain");
MockHttpRequestHandler handler = new(HttpRequestResult(""));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.BodyContentType == "text/plain" &&
info.Body == "raw body content");
}
[Fact]
public async Task HttpRequestRaisesOnErrorByDefaultAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestRaisesOnErrorByDefaultAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(HttpRequestResult("server error", statusCode: 500, isSuccess: false));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act & Assert
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
}
[Fact]
public async Task HttpRequestFailureExceptionTruncatesLongBodyAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestFailureExceptionTruncatesLongBodyAsync),
url: TestUrl,
method: HttpMethodType.Get);
string longBody = new('x', 10_000);
MockHttpRequestHandler handler = new(HttpRequestResult(longBody, statusCode: 500, isSuccess: false));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
DeclarativeActionException exception =
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
// Assert - message contains status and truncation marker, bounded in length, never the full body.
Assert.Contains("500", exception.Message);
Assert.Contains("[truncated]", exception.Message);
Assert.DoesNotContain(longBody, exception.Message);
Assert.True(exception.Message.Length < 512, $"Exception message too long: {exception.Message.Length} chars.");
}
[Fact]
public async Task HttpRequestFailureExceptionOmitsEmptyBodyAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestFailureExceptionOmitsEmptyBodyAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(HttpRequestResult(body: null, statusCode: 404, isSuccess: false));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
DeclarativeActionException exception =
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
// Assert - status present, no stray "Body: ''" noise.
Assert.Contains("404", exception.Message);
Assert.DoesNotContain("Body:", exception.Message);
}
[Fact]
public async Task HttpRequestFailureExceptionSanitizesControlCharsAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestFailureExceptionSanitizesControlCharsAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(HttpRequestResult("line1\r\nline2\tend", statusCode: 400, isSuccess: false));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
DeclarativeActionException exception =
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
// Assert - CR/LF/TAB collapsed to spaces so the message stays on one line.
Assert.DoesNotContain("\r", exception.Message);
Assert.DoesNotContain("\n", exception.Message);
Assert.DoesNotContain("\t", exception.Message);
Assert.Contains("line1", exception.Message);
Assert.Contains("line2", exception.Message);
}
[Fact]
public async Task HttpRequestPassesTimeoutToHandlerAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestPassesTimeoutToHandlerAsync),
url: TestUrl,
method: HttpMethodType.Get,
timeoutMilliseconds: 1500);
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.Timeout is not null &&
info.Timeout.Value == TimeSpan.FromMilliseconds(1500));
}
[Fact]
public async Task HttpRequestTimeoutRaisesDeclarativeExceptionAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestTimeoutRaisesDeclarativeExceptionAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(
HttpRequestResult("{}"),
throwOnSend: new OperationCanceledException());
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act & Assert
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
}
[Fact]
public async Task HttpRequestTransportFailureRaisesDeclarativeExceptionAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestTransportFailureRaisesDeclarativeExceptionAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(
HttpRequestResult("{}"),
throwOnSend: new InvalidOperationException("transport failure"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act & Assert
await Assert.ThrowsAsync<DeclarativeActionException>(() => this.ExecuteAsync(action));
}
[Fact]
public async Task HttpRequestStoresResponseHeadersAsync()
{
// Arrange
this.State.InitializeSystem();
const string HeaderVar = "Headers";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestStoresResponseHeadersAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseHeadersVariable: HeaderVar);
Dictionary<string, IReadOnlyList<string>> responseHeaders = new(StringComparer.OrdinalIgnoreCase)
{
["X-Request-Id"] = ["abc-123"],
["Set-Cookie"] = ["a=1", "b=2"],
};
MockHttpRequestHandler handler = new(HttpRequestResult("{}", headers: responseHeaders));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
FormulaValue storedHeaders = this.State.Get(HeaderVar);
Assert.IsType<RecordValue>(storedHeaders, exactMatch: false);
}
[Fact]
public async Task HttpRequestForwardsQueryParametersAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestForwardsQueryParametersAsync),
url: TestUrl,
method: HttpMethodType.Get,
queryParameters: new Dictionary<string, DataValue>
{
["filter"] = StringDataValue.Create("active"),
["limit"] = NumberDataValue.Create(10),
["includeDeleted"] = BooleanDataValue.Create(false),
});
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.QueryParameters?.Count == 3 &&
info.QueryParameters["filter"] == "active" &&
info.QueryParameters["limit"] == "10" &&
info.QueryParameters["includeDeleted"] == "false");
}
[Fact]
public async Task HttpRequestAddsResponseToConversationAsync()
{
// Arrange
this.State.InitializeSystem();
const string ConversationId = "conv-12345";
const string ResponseBody = "response-text";
this._agentProvider
.Setup(p => p.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>()))
.Returns<string, ChatMessage, CancellationToken>((_, message, _) => Task.FromResult(message));
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestAddsResponseToConversationAsync),
url: TestUrl,
method: HttpMethodType.Get,
conversationId: ConversationId);
MockHttpRequestHandler handler = new(HttpRequestResult(ResponseBody));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this._agentProvider.Verify(
p => p.CreateMessageAsync(
ConversationId,
It.Is<ChatMessage>(m => m.Role == ChatRole.Assistant && m.Text == ResponseBody),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HttpRequestWithoutConversationIdSkipsConversationAsync()
{
// Arrange
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestWithoutConversationIdSkipsConversationAsync),
url: TestUrl,
method: HttpMethodType.Get);
MockHttpRequestHandler handler = new(HttpRequestResult("response"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this._agentProvider.Verify(
p => p.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task HttpRequestForwardsConnectionNameAsync()
{
// Arrange
this.State.InitializeSystem();
const string ConnectionName = "my-connection";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestForwardsConnectionNameAsync),
url: TestUrl,
method: HttpMethodType.Get,
connectionName: ConnectionName);
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info => info.ConnectionName == ConnectionName);
}
[Fact]
public async Task HttpRequestEmptyConversationIdSkipsConversationAsync()
{
// Arrange - empty-string conversationId should be treated as unset.
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestEmptyConversationIdSkipsConversationAsync),
url: TestUrl,
method: HttpMethodType.Get,
conversationId: "");
MockHttpRequestHandler handler = new(HttpRequestResult("response"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this._agentProvider.Verify(
p => p.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task HttpRequestEmptyResponseBodySkipsConversationAsync()
{
// Arrange - conversationId set, but empty body should not produce a conversation message.
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestEmptyResponseBodySkipsConversationAsync),
url: TestUrl,
method: HttpMethodType.Get,
conversationId: "conv-1");
MockHttpRequestHandler handler = new(HttpRequestResult(""));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
this._agentProvider.Verify(
p => p.CreateMessageAsync(It.IsAny<string>(), It.IsAny<ChatMessage>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task HttpGetReturnsJsonArrayAsync()
{
// Arrange - exercises JsonValueKind.Array branch of ParseResponseBody.
this.State.InitializeSystem();
const string ResponseVar = "Result";
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetReturnsJsonArrayAsync),
url: TestUrl,
method: HttpMethodType.Get,
responseVariable: ResponseVar);
MockHttpRequestHandler handler = new(HttpRequestResult("[1, 2, 3]"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
FormulaValue stored = this.State.Get(ResponseVar);
Assert.IsType<TableValue>(stored, exactMatch: false);
}
[Fact]
public async Task HttpGetWithEmptyHeaderValueDropsHeaderAsync()
{
// Arrange - empty header values should be filtered out (matches GetHeaders guard).
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpGetWithEmptyHeaderValueDropsHeaderAsync),
url: TestUrl,
method: HttpMethodType.Get,
headers: new Dictionary<string, string>
{
["X-Trace"] = "trace-1",
["X-Empty"] = "",
});
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info =>
info.Headers?.ContainsKey("X-Trace") == true &&
info.Headers?.ContainsKey("X-Empty") == false);
}
[Fact]
public async Task HttpRequestZeroTimeoutNotForwardedAsync()
{
// Arrange - non-positive timeouts should not be forwarded (handler default applies).
this.State.InitializeSystem();
HttpRequestAction model = this.CreateModel(
displayName: nameof(HttpRequestZeroTimeoutNotForwardedAsync),
url: TestUrl,
method: HttpMethodType.Get,
timeoutMilliseconds: 0);
MockHttpRequestHandler handler = new(HttpRequestResult("{}"));
HttpRequestExecutor action = new(model, handler.Object, this._agentProvider.Object, this.State);
// Act
await this.ExecuteAsync(action);
// Assert
VerifyModel(model, action);
handler.VerifySent(info => info.Timeout is null);
}
private static HttpRequestResult HttpRequestResult(
string? body,
int statusCode = 200,
bool isSuccess = true,
IReadOnlyDictionary<string, IReadOnlyList<string>>? headers = null) =>
new()
{
StatusCode = statusCode,
IsSuccessStatusCode = isSuccess,
Body = body,
Headers = headers,
};
private HttpRequestAction CreateModel(
string displayName,
string url,
HttpMethodType method,
string? responseVariable = null,
string? responseHeadersVariable = null,
IReadOnlyDictionary<string, string>? headers = null,
IReadOnlyDictionary<string, DataValue>? queryParameters = null,
string? conversationId = null,
string? connectionName = null,
DataValue? jsonBody = null,
string? rawBody = null,
string? rawContentType = null,
long? timeoutMilliseconds = null,
string? continueOnErrorStatusVariable = null,
string? continueOnErrorBodyVariable = null)
{
HttpRequestAction.Builder builder = new()
{
Id = this.CreateActionId(),
DisplayName = this.FormatDisplayName(displayName),
Url = new StringExpression.Builder(StringExpression.Literal(url)),
Method = new EnumExpression<HttpMethodTypeWrapper>.Builder(
EnumExpression<HttpMethodTypeWrapper>.Literal(HttpMethodTypeWrapper.Get(method))),
};
if (responseVariable is not null)
{
builder.Response = PropertyPath.Create(FormatVariablePath(responseVariable));
}
if (responseHeadersVariable is not null)
{
builder.ResponseHeaders = PropertyPath.Create(FormatVariablePath(responseHeadersVariable));
}
if (headers is not null)
{
foreach (KeyValuePair<string, string> header in headers)
{
builder.Headers.Add(header.Key, new StringExpression.Builder(StringExpression.Literal(header.Value)));
}
}
if (queryParameters is not null)
{
foreach (KeyValuePair<string, DataValue> parameter in queryParameters)
{
builder.QueryParameters.Add(parameter.Key, new ValueExpression.Builder(ValueExpression.Literal(parameter.Value)));
}
}
if (conversationId is not null)
{
builder.ConversationId = new StringExpression.Builder(StringExpression.Literal(conversationId));
}
if (connectionName is not null)
{
builder.Connection = new RemoteConnection.Builder
{
Name = new StringExpression.Builder(StringExpression.Literal(connectionName)),
};
}
if (jsonBody is not null)
{
builder.Body = new JsonRequestContent.Builder()
{
Content = new ValueExpression.Builder(ValueExpression.Literal(jsonBody)),
};
}
else if (rawBody is not null)
{
RawRequestContent.Builder rawBuilder = new()
{
Content = new StringExpression.Builder(StringExpression.Literal(rawBody)),
};
if (rawContentType is not null)
{
rawBuilder.ContentType = new StringExpression.Builder(StringExpression.Literal(rawContentType));
}
builder.Body = rawBuilder;
}
if (timeoutMilliseconds is not null)
{
builder.RequestTimeoutInMilliseconds = new IntExpression.Builder(IntExpression.Literal(timeoutMilliseconds.Value));
}
if (continueOnErrorStatusVariable is not null || continueOnErrorBodyVariable is not null)
{
ContinueOnErrorBehavior.Builder continueBuilder = new();
if (continueOnErrorStatusVariable is not null)
{
continueBuilder.StatusCode = PropertyPath.Create(FormatVariablePath(continueOnErrorStatusVariable));
}
if (continueOnErrorBodyVariable is not null)
{
continueBuilder.ErrorResponseBody = PropertyPath.Create(FormatVariablePath(continueOnErrorBodyVariable));
}
builder.ErrorHandling = continueBuilder;
}
return AssignParent<HttpRequestAction>(builder);
}
private sealed class MockHttpRequestHandler : Mock<IHttpRequestHandler>
{
private HttpRequestInfo? _lastRequest;
public MockHttpRequestHandler(HttpRequestResult result, Exception? throwOnSend = null)
{
this.Setup(handler => handler.SendAsync(It.IsAny<HttpRequestInfo>(), It.IsAny<CancellationToken>()))
.Returns<HttpRequestInfo, CancellationToken>((info, _) =>
{
this._lastRequest = info;
if (throwOnSend is not null)
{
throw throwOnSend;
}
return Task.FromResult(result);
});
}
public void VerifySent(Func<HttpRequestInfo, bool> predicate)
{
Assert.NotNull(this._lastRequest);
Assert.True(predicate(this._lastRequest!), "Sent HTTP request did not match expected predicate.");
}
}
}
@@ -0,0 +1,15 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: my_workflow
actions:
- kind: HttpRequestAction
id: http_request
method: GET
url: =Concatenate("https://api.example.test/items/", System.LastMessageText)
headers:
Accept: application/json
response: Local.HttpResult
responseHeaders: Local.HttpHeaders