mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge remote-tracking branch 'origin/main' into features/ai-project-2.0.0-update-002
This commit is contained in:
@@ -98,7 +98,7 @@ internal sealed class PurviewWrapper : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
(bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, options?.ConversationId, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false);
|
||||
(bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, options?.ConversationId, Activity.DownloadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false);
|
||||
if (shouldBlockResponse)
|
||||
{
|
||||
if (this._logger.IsEnabled(LogLevel.Information))
|
||||
@@ -186,7 +186,7 @@ internal sealed class PurviewWrapper : IDisposable
|
||||
sessionIdResponse = sessionId;
|
||||
}
|
||||
}
|
||||
(bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, sessionIdResponse, Activity.UploadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false);
|
||||
(bool shouldBlockResponse, _) = await this._scopedProcessor.ProcessMessagesAsync(response.Messages, sessionIdResponse, Activity.DownloadText, this._purviewSettings, resolvedUserId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (shouldBlockResponse)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IMcpToolHandler"/> using the MCP C# SDK.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This provider supports per-server authentication via the <c>httpClientProvider</c> callback.
|
||||
/// The callback allows different MCP servers to use different authentication configurations by returning
|
||||
/// a pre-configured <see cref="HttpClient"/> for each server.
|
||||
/// </remarks>
|
||||
public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
{
|
||||
private readonly Func<string, CancellationToken, Task<HttpClient?>>? _httpClientProvider;
|
||||
private readonly Dictionary<string, McpClient> _clients = [];
|
||||
private readonly Dictionary<string, HttpClient> _ownedHttpClients = [];
|
||||
private readonly SemaphoreSlim _clientLock = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DefaultMcpToolHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="httpClientProvider">
|
||||
/// An optional callback that provides an <see cref="HttpClient"/> for each MCP server.
|
||||
/// The callback receives (serverUrl, cancellationToken) and should return an HttpClient
|
||||
/// configured with any required authentication. Return <see langword="null"/> to use a default HttpClient with no auth.
|
||||
/// </param>
|
||||
public DefaultMcpToolHandler(Func<string, CancellationToken, Task<HttpClient?>>? httpClientProvider = null)
|
||||
{
|
||||
this._httpClientProvider = httpClientProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<McpServerToolResultContent> InvokeToolAsync(
|
||||
string serverUrl,
|
||||
string? serverLabel,
|
||||
string toolName,
|
||||
IDictionary<string, object?>? arguments,
|
||||
IDictionary<string, string>? headers,
|
||||
string? connectionName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore
|
||||
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
|
||||
McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Convert IDictionary to IReadOnlyDictionary for CallToolAsync
|
||||
IReadOnlyDictionary<string, object?>? readOnlyArguments = arguments is null
|
||||
? null
|
||||
: arguments as IReadOnlyDictionary<string, object?> ?? new Dictionary<string, object?>(arguments);
|
||||
|
||||
CallToolResult result = await client.CallToolAsync(
|
||||
toolName,
|
||||
readOnlyArguments,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Map MCP content blocks to MEAI AIContent types
|
||||
PopulateResultContent(resultContent, result);
|
||||
|
||||
return resultContent;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await this._clientLock.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
foreach (McpClient client in this._clients.Values)
|
||||
{
|
||||
await client.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
this._clients.Clear();
|
||||
|
||||
// Dispose only HttpClients that the handler created (not user-provided ones)
|
||||
foreach (HttpClient httpClient in this._ownedHttpClients.Values)
|
||||
{
|
||||
httpClient.Dispose();
|
||||
}
|
||||
|
||||
this._ownedHttpClients.Clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._clientLock.Release();
|
||||
}
|
||||
|
||||
this._clientLock.Dispose();
|
||||
}
|
||||
|
||||
private async Task<McpClient> GetOrCreateClientAsync(
|
||||
string serverUrl,
|
||||
string? serverLabel,
|
||||
IDictionary<string, string>? headers,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string normalizedUrl = serverUrl.Trim().ToUpperInvariant();
|
||||
string clientCacheKey = $"{normalizedUrl}|{ComputeHeadersHash(headers)}";
|
||||
|
||||
await this._clientLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (this._clients.TryGetValue(clientCacheKey, out McpClient? existingClient))
|
||||
{
|
||||
return existingClient;
|
||||
}
|
||||
|
||||
McpClient newClient = await this.CreateClientAsync(serverUrl, serverLabel, headers, normalizedUrl, cancellationToken).ConfigureAwait(false);
|
||||
this._clients[clientCacheKey] = newClient;
|
||||
return newClient;
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._clientLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<McpClient> CreateClientAsync(
|
||||
string serverUrl,
|
||||
string? serverLabel,
|
||||
IDictionary<string, string>? headers,
|
||||
string httpClientCacheKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Get or create HttpClient (Can be shared across McpClients for the same server)
|
||||
HttpClient? httpClient = null;
|
||||
|
||||
if (this._httpClientProvider is not null)
|
||||
{
|
||||
httpClient = await this._httpClientProvider(serverUrl, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (httpClient is null && !this._ownedHttpClients.TryGetValue(httpClientCacheKey, out httpClient))
|
||||
{
|
||||
httpClient = new HttpClient();
|
||||
this._ownedHttpClients[httpClientCacheKey] = httpClient;
|
||||
}
|
||||
|
||||
HttpClientTransportOptions transportOptions = new()
|
||||
{
|
||||
Endpoint = new Uri(serverUrl),
|
||||
Name = serverLabel ?? "McpClient",
|
||||
AdditionalHeaders = headers,
|
||||
TransportMode = HttpTransportMode.AutoDetect
|
||||
};
|
||||
|
||||
HttpClientTransport transport = new(transportOptions, httpClient);
|
||||
|
||||
return await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string ComputeHeadersHash(IDictionary<string, string>? headers)
|
||||
{
|
||||
if (headers is null || headers.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Build a deterministic, sorted representation of the headers
|
||||
// Within a single process lifetime, the hashcodes are consistent.
|
||||
// This will ensure that the same set of headers always produces the same hash, regardless of order.
|
||||
SortedDictionary<string, string> sorted = new(headers.ToDictionary(h => h.Key.ToUpperInvariant(), h => h.Value.ToUpperInvariant()));
|
||||
int hashCode = 17;
|
||||
foreach (KeyValuePair<string, string> kvp in sorted)
|
||||
{
|
||||
hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Key);
|
||||
hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Value);
|
||||
}
|
||||
|
||||
return hashCode.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static void PopulateResultContent(McpServerToolResultContent resultContent, CallToolResult result)
|
||||
{
|
||||
// Ensure Output list is initialized
|
||||
resultContent.Output ??= [];
|
||||
|
||||
if (result.IsError == true)
|
||||
{
|
||||
// Collect error text from content blocks
|
||||
string? errorText = null;
|
||||
if (result.Content is not null)
|
||||
{
|
||||
foreach (ContentBlock block in result.Content)
|
||||
{
|
||||
if (block is TextContentBlock textBlock)
|
||||
{
|
||||
errorText = errorText is null ? textBlock.Text : $"{errorText}\n{textBlock.Text}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resultContent.Output.Add(new TextContent($"Error: {errorText ?? "Unknown error from MCP Server call"}"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Content is null || result.Content.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Map each MCP content block to an MEAI AIContent type
|
||||
foreach (ContentBlock block in result.Content)
|
||||
{
|
||||
AIContent content = ConvertContentBlock(block);
|
||||
if (content is not null)
|
||||
{
|
||||
resultContent.Output.Add(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static AIContent ConvertContentBlock(ContentBlock block)
|
||||
{
|
||||
return block switch
|
||||
{
|
||||
TextContentBlock text => new TextContent(text.Text),
|
||||
ImageContentBlock image => CreateDataContentFromBase64(image.Data, image.MimeType ?? "image/*"),
|
||||
AudioContentBlock audio => CreateDataContentFromBase64(audio.Data, audio.MimeType ?? "audio/*"),
|
||||
_ => new TextContent(block.ToString() ?? string.Empty),
|
||||
};
|
||||
}
|
||||
|
||||
private static DataContent CreateDataContentFromBase64(string? base64Data, string mediaType)
|
||||
{
|
||||
if (string.IsNullOrEmpty(base64Data))
|
||||
{
|
||||
return new DataContent($"data:{mediaType};base64,", mediaType);
|
||||
}
|
||||
|
||||
// If it's already a data URI, use it directly
|
||||
if (base64Data.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new DataContent(base64Data, mediaType);
|
||||
}
|
||||
|
||||
// Otherwise, construct a data URI from the base64 data
|
||||
return new DataContent($"data:{mediaType};base64,{base64Data}", mediaType);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleaseCandidate>true</IsReleaseCandidate>
|
||||
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework Declarative Workflows MCP</Title>
|
||||
<Description>Provides Microsoft Agent Framework support for MCP (Model Context Protocol) server integration in declarative workflows.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -20,6 +20,12 @@ public sealed class DeclarativeWorkflowOptions(ResponseAgentProvider agentProvid
|
||||
/// </summary>
|
||||
public ResponseAgentProvider AgentProvider { get; } = Throw.IfNull(agentProvider);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MCP tool handler for invoking MCP tools within workflows.
|
||||
/// If not set, MCP tool invocations will fail with an appropriate error message.
|
||||
/// </summary>
|
||||
public IMcpToolHandler? McpToolHandler { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Defines the configuration settings for the workflow.
|
||||
/// </summary>
|
||||
|
||||
+43
-1
@@ -42,6 +42,40 @@ internal static class JsonDocumentExtensions
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a VariableType.List with schema inferred from the first object element in the array.
|
||||
/// </summary>
|
||||
public static VariableType GetListTypeFromJson(this JsonElement arrayElement)
|
||||
{
|
||||
// Find the first object element to infer schema
|
||||
foreach (JsonElement element in arrayElement.EnumerateArray())
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
// Build schema from the object's properties
|
||||
List<(string Key, VariableType Type)> fields = [];
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
VariableType fieldType = property.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => typeof(string),
|
||||
JsonValueKind.Number => typeof(decimal),
|
||||
JsonValueKind.True or JsonValueKind.False => typeof(bool),
|
||||
JsonValueKind.Object => VariableType.RecordType,
|
||||
JsonValueKind.Array => VariableType.ListType,
|
||||
_ => typeof(string),
|
||||
};
|
||||
fields.Add((property.Name, fieldType));
|
||||
}
|
||||
|
||||
return VariableType.List(fields);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for arrays of primitives or empty arrays
|
||||
return VariableType.ListType;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ParseRecord(this JsonElement currentElement, VariableType targetType)
|
||||
{
|
||||
IEnumerable<KeyValuePair<string, object?>> keyValuePairs =
|
||||
@@ -118,6 +152,7 @@ internal static class JsonDocumentExtensions
|
||||
JsonValueKind.True => typeof(bool),
|
||||
JsonValueKind.False => typeof(bool),
|
||||
JsonValueKind.Number => typeof(decimal),
|
||||
JsonValueKind.Array => (VariableType)VariableType.ListType, // Add support for nested arrays
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -285,9 +320,16 @@ internal static class JsonDocumentExtensions
|
||||
|
||||
private static bool TryParseList(JsonElement propertyElement, VariableType? targetType, out object? value)
|
||||
{
|
||||
// Handle empty arrays without needing to determine element type
|
||||
if (propertyElement.GetArrayLength() == 0)
|
||||
{
|
||||
value = new List<object?>();
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
value = ParseTable(propertyElement, targetType ?? VariableType.ListType);
|
||||
value = ParseTable(propertyElement, targetType ?? GetListTypeFromJson(propertyElement));
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the contract for invoking MCP tools within declarative workflows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This interface allows the MCP tool invocation to be abstracted, enabling
|
||||
/// different implementations for local development, hosted workflows, and testing scenarios.
|
||||
/// </remarks>
|
||||
public interface IMcpToolHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Invokes an MCP tool on the specified server.
|
||||
/// </summary>
|
||||
/// <param name="serverUrl">The URL of the MCP server.</param>
|
||||
/// <param name="serverLabel">An optional label identifying the server connection.</param>
|
||||
/// <param name="toolName">The name of the tool to invoke.</param>
|
||||
/// <param name="arguments">Optional arguments to pass to the tool.</param>
|
||||
/// <param name="headers">Optional headers to include in the request.</param>
|
||||
/// <param name="connectionName">An optional connection name for managed connections.</param>
|
||||
/// <param name="cancellationToken">A token to observe cancellation.</param>
|
||||
/// <returns>
|
||||
/// A task representing the asynchronous operation. The result contains a <see cref="McpServerToolResultContent"/>
|
||||
/// with the tool invocation output.
|
||||
/// </returns>
|
||||
Task<McpServerToolResultContent> InvokeToolAsync(
|
||||
string serverUrl,
|
||||
string? serverLabel,
|
||||
string toolName,
|
||||
IDictionary<string, object?>? arguments,
|
||||
IDictionary<string, string>? headers,
|
||||
string? connectionName,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
+36
@@ -493,6 +493,42 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
|
||||
this.ContinueWith(new SendActivityExecutor(item, this._workflowState));
|
||||
}
|
||||
|
||||
protected override void Visit(InvokeMcpTool item)
|
||||
{
|
||||
this.Trace(item);
|
||||
|
||||
// Verify MCP handler is configured
|
||||
if (this._workflowOptions.McpToolHandler is null)
|
||||
{
|
||||
throw new DeclarativeModelException("MCP tool handler not configured. Set McpToolHandler in DeclarativeWorkflowOptions to use InvokeMcpTool actions.");
|
||||
}
|
||||
|
||||
// Entry point to invoke MCP tool - may yield for approval
|
||||
InvokeMcpToolExecutor action = new(item, this._workflowOptions.McpToolHandler, this._workflowOptions.AgentProvider, this._workflowState);
|
||||
this.ContinueWith(action);
|
||||
|
||||
// Transition to post action if no external input is required (no approval needed)
|
||||
string postId = Steps.Post(action.Id);
|
||||
this._workflowModel.AddLink(action.Id, postId, InvokeMcpToolExecutor.RequiresNothing);
|
||||
|
||||
// If approval is required, define request-port for approval flow
|
||||
string externalInputPortId = InvokeMcpToolExecutor.Steps.ExternalInput(action.Id);
|
||||
RequestPortAction externalInputPort = new(RequestPort.Create<ExternalInputRequest, ExternalInputResponse>(externalInputPortId));
|
||||
this._workflowModel.AddNode(externalInputPort, action.ParentId);
|
||||
this._workflowModel.AddLink(action.Id, externalInputPortId, InvokeMcpToolExecutor.RequiresInput);
|
||||
|
||||
// Capture response when external input is received
|
||||
string resumeId = InvokeMcpToolExecutor.Steps.Resume(action.Id);
|
||||
this._workflowModel.AddNode(new DelegateActionExecutor<ExternalInputResponse>(resumeId, this._workflowState, action.CaptureResponseAsync), action.ParentId);
|
||||
this._workflowModel.AddLink(externalInputPortId, resumeId);
|
||||
|
||||
// After resume, transition to post action
|
||||
this._workflowModel.AddLink(resumeId, postId);
|
||||
|
||||
// Define post action (completion)
|
||||
this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId);
|
||||
}
|
||||
|
||||
#region Not supported
|
||||
|
||||
protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item);
|
||||
|
||||
+2
@@ -365,6 +365,8 @@ internal sealed class WorkflowTemplateVisitor : DialogActionVisitor
|
||||
|
||||
#region Not supported
|
||||
|
||||
protected override void Visit(InvokeMcpTool item) => this.NotSupported(item);
|
||||
|
||||
protected override void Visit(InvokeFunctionTool item) => this.NotSupported(item);
|
||||
|
||||
protected override void Visit(AnswerQuestionWithAI item) => this.NotSupported(item);
|
||||
|
||||
+1
-35
@@ -204,7 +204,7 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
object? parsedValue = jsonDocument.RootElement.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType),
|
||||
JsonValueKind.Array => jsonDocument.ParseList(CreateListTypeFromJson(jsonDocument.RootElement)),
|
||||
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,
|
||||
@@ -224,40 +224,6 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
await this.AssignAsync(this.Model.Output.Result?.Path, resultValue.ToFormula(), context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a VariableType.List with schema inferred from the first object element in the array.
|
||||
/// </summary>
|
||||
private static VariableType CreateListTypeFromJson(JsonElement arrayElement)
|
||||
{
|
||||
// Find the first object element to infer schema
|
||||
foreach (JsonElement element in arrayElement.EnumerateArray())
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
// Build schema from the object's properties
|
||||
List<(string Key, VariableType Type)> fields = [];
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
VariableType fieldType = property.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => typeof(string),
|
||||
JsonValueKind.Number => typeof(decimal),
|
||||
JsonValueKind.True or JsonValueKind.False => typeof(bool),
|
||||
JsonValueKind.Object => VariableType.RecordType,
|
||||
JsonValueKind.Array => VariableType.ListType,
|
||||
_ => typeof(string),
|
||||
};
|
||||
fields.Add((property.Name, fieldType));
|
||||
}
|
||||
|
||||
return VariableType.List(fields);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for arrays of primitives or empty arrays
|
||||
return VariableType.ListType;
|
||||
}
|
||||
|
||||
private string GetFunctionName() =>
|
||||
this.Evaluator.GetValue(
|
||||
Throw.IfNull(
|
||||
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Events;
|
||||
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.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
|
||||
/// <summary>
|
||||
/// Executor for the <see cref="InvokeMcpTool"/> action.
|
||||
/// This executor invokes MCP tools on remote servers and handles approval flows.
|
||||
/// </summary>
|
||||
internal sealed class InvokeMcpToolExecutor(
|
||||
InvokeMcpTool model,
|
||||
IMcpToolHandler mcpToolHandler,
|
||||
ResponseAgentProvider agentProvider,
|
||||
WorkflowFormulaState state) :
|
||||
DeclarativeActionExecutor<InvokeMcpTool>(model, state)
|
||||
{
|
||||
/// <summary>
|
||||
/// Step identifiers for the MCP tool invocation workflow.
|
||||
/// </summary>
|
||||
public static class Steps
|
||||
{
|
||||
/// <summary>
|
||||
/// Step for waiting for external input (approval or direct response).
|
||||
/// </summary>
|
||||
public static string ExternalInput(string id) => $"{id}_{nameof(ExternalInput)}";
|
||||
|
||||
/// <summary>
|
||||
/// Step for resuming after receiving external input.
|
||||
/// </summary>
|
||||
public static string Resume(string id) => $"{id}_{nameof(Resume)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the message indicates external input is required.
|
||||
/// </summary>
|
||||
public static bool RequiresInput(object? message) => message is ExternalInputRequest;
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the message indicates no external input is required.
|
||||
/// </summary>
|
||||
public static bool RequiresNothing(object? message) => message is ActionExecutorResult;
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override bool EmitResultEvent => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override bool IsDiscreteAction => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
[SendsMessage(typeof(ExternalInputRequest))]
|
||||
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string serverUrl = this.GetServerUrl();
|
||||
string? serverLabel = this.GetServerLabel();
|
||||
string toolName = this.GetToolName();
|
||||
bool requireApproval = this.GetRequireApproval();
|
||||
Dictionary<string, object?>? arguments = this.GetArguments();
|
||||
Dictionary<string, string>? headers = this.GetHeaders();
|
||||
string? connectionName = this.GetConnectionName();
|
||||
|
||||
if (requireApproval)
|
||||
{
|
||||
// Create tool call content for approval request
|
||||
McpServerToolCallContent toolCall = new(this.Id, toolName, serverLabel ?? serverUrl)
|
||||
{
|
||||
Arguments = arguments
|
||||
};
|
||||
|
||||
if (headers != null)
|
||||
{
|
||||
toolCall.AdditionalProperties ??= [];
|
||||
toolCall.AdditionalProperties.Add(headers);
|
||||
}
|
||||
|
||||
McpServerToolApprovalRequestContent approvalRequest = new(this.Id, toolCall);
|
||||
|
||||
ChatMessage requestMessage = new(ChatRole.Assistant, [approvalRequest]);
|
||||
AgentResponse agentResponse = new([requestMessage]);
|
||||
|
||||
// Yield to the caller for approval
|
||||
ExternalInputRequest inputRequest = new(agentResponse);
|
||||
await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
// No approval required - invoke the tool directly
|
||||
McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
|
||||
serverUrl,
|
||||
serverLabel,
|
||||
toolName,
|
||||
arguments,
|
||||
headers,
|
||||
connectionName,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await this.ProcessResultAsync(context, resultContent, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Signal completion so the workflow routes via RequiresNothing
|
||||
await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the external input response and processes the MCP tool result.
|
||||
/// </summary>
|
||||
/// <param name="context">The workflow context.</param>
|
||||
/// <param name="response">The external input response.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask CaptureResponseAsync(
|
||||
IWorkflowContext context,
|
||||
ExternalInputResponse response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Check for approval response
|
||||
McpServerToolApprovalResponseContent? approvalResponse = response.Messages
|
||||
.SelectMany(m => m.Contents)
|
||||
.OfType<McpServerToolApprovalResponseContent>()
|
||||
.FirstOrDefault(r => r.Id == this.Id);
|
||||
|
||||
if (approvalResponse?.Approved != true)
|
||||
{
|
||||
// Tool call was rejected
|
||||
await this.AssignErrorAsync(context, "MCP tool invocation was not approved by user.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Approved - now invoke the tool
|
||||
string serverUrl = this.GetServerUrl();
|
||||
string? serverLabel = this.GetServerLabel();
|
||||
string toolName = this.GetToolName();
|
||||
Dictionary<string, object?>? arguments = this.GetArguments();
|
||||
Dictionary<string, string>? headers = this.GetHeaders();
|
||||
string? connectionName = this.GetConnectionName();
|
||||
|
||||
McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
|
||||
serverUrl,
|
||||
serverLabel,
|
||||
toolName,
|
||||
arguments,
|
||||
headers,
|
||||
connectionName,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await this.ProcessResultAsync(context, resultContent, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes the MCP tool invocation by raising the completion event.
|
||||
/// </summary>
|
||||
public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
|
||||
{
|
||||
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask ProcessResultAsync(IWorkflowContext context, McpServerToolResultContent resultContent, CancellationToken cancellationToken)
|
||||
{
|
||||
bool autoSend = this.GetAutoSendValue();
|
||||
string? conversationId = this.GetConversationId();
|
||||
|
||||
await this.AssignResultAsync(context, resultContent).ConfigureAwait(false);
|
||||
ChatMessage resultMessage = new(ChatRole.Tool, resultContent.Output);
|
||||
|
||||
// Store messages if output path is configured
|
||||
if (this.Model.Output?.Messages is not null)
|
||||
{
|
||||
await this.AssignAsync(this.Model.Output.Messages?.Path, resultMessage.ToFormula(), context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Auto-send the result if configured
|
||||
if (autoSend)
|
||||
{
|
||||
AgentResponse resultResponse = new([resultMessage]);
|
||||
await context.AddEventAsync(new AgentResponseEvent(this.Id, resultResponse), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Add messages to conversation if conversationId is provided
|
||||
if (conversationId is not null)
|
||||
{
|
||||
ChatMessage assistantMessage = new(ChatRole.Assistant, resultContent.Output);
|
||||
await agentProvider.CreateMessageAsync(conversationId, assistantMessage, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask AssignResultAsync(IWorkflowContext context, McpServerToolResultContent toolResult)
|
||||
{
|
||||
if (this.Model.Output?.Result is null || toolResult.Output is null || toolResult.Output.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<object?> parsedResults = [];
|
||||
foreach (AIContent resultContent in toolResult.Output)
|
||||
{
|
||||
object? resultValue = resultContent switch
|
||||
{
|
||||
TextContent text => text.Text,
|
||||
DataContent data => data.Uri,
|
||||
_ => resultContent.ToString(),
|
||||
};
|
||||
|
||||
// Convert JsonElement to its raw JSON string for processing
|
||||
if (resultValue is JsonElement jsonElement)
|
||||
{
|
||||
resultValue = jsonElement.GetRawText();
|
||||
}
|
||||
|
||||
// Attempt to parse as JSON if it's a string (or was converted from JsonElement)
|
||||
if (resultValue is string jsonString)
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument jsonDocument = JsonDocument.Parse(jsonString);
|
||||
|
||||
// Handle different JSON value kinds
|
||||
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,
|
||||
_ => jsonString,
|
||||
};
|
||||
|
||||
parsedResults.Add(parsedValue);
|
||||
continue;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not a valid JSON
|
||||
}
|
||||
}
|
||||
|
||||
parsedResults.Add(resultValue);
|
||||
}
|
||||
|
||||
await this.AssignAsync(this.Model.Output.Result?.Path, parsedResults.ToFormula(), context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask AssignErrorAsync(IWorkflowContext context, string errorMessage)
|
||||
{
|
||||
// Store error in result if configured (as a simple string)
|
||||
if (this.Model.Output?.Result is not null)
|
||||
{
|
||||
await this.AssignAsync(this.Model.Output.Result?.Path, $"Error: {errorMessage}".ToFormula(), context).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetServerUrl() =>
|
||||
this.Evaluator.GetValue(
|
||||
Throw.IfNull(
|
||||
this.Model.ServerUrl,
|
||||
$"{nameof(this.Model)}.{nameof(this.Model.ServerUrl)}")).Value;
|
||||
|
||||
private string? GetServerLabel()
|
||||
{
|
||||
if (this.Model.ServerLabel is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string value = this.Evaluator.GetValue(this.Model.ServerLabel).Value;
|
||||
return value.Length == 0 ? null : value;
|
||||
}
|
||||
|
||||
private string GetToolName() =>
|
||||
this.Evaluator.GetValue(
|
||||
Throw.IfNull(
|
||||
this.Model.ToolName,
|
||||
$"{nameof(this.Model)}.{nameof(this.Model.ToolName)}")).Value;
|
||||
|
||||
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 bool GetRequireApproval()
|
||||
{
|
||||
if (this.Model.RequireApproval is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.Evaluator.GetValue(this.Model.RequireApproval).Value;
|
||||
}
|
||||
|
||||
private bool GetAutoSendValue()
|
||||
{
|
||||
if (this.Model.Output?.AutoSend is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
|
||||
}
|
||||
|
||||
private string? GetConnectionName()
|
||||
{
|
||||
if (this.Model.Connection?.Name is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string value = this.Evaluator.GetValue(this.Model.Connection.Name).Value;
|
||||
return value.Length == 0 ? null : value;
|
||||
}
|
||||
|
||||
private Dictionary<string, object?>? GetArguments()
|
||||
{
|
||||
if (this.Model.Arguments is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, object?> result = [];
|
||||
foreach (KeyValuePair<string, ValueExpression> argument in this.Model.Arguments)
|
||||
{
|
||||
result[argument.Key] = this.Evaluator.GetValue(argument.Value).Value.ToObject();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private Dictionary<string, string>? GetHeaders()
|
||||
{
|
||||
if (this.Model.Headers is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, string> result = [];
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,9 @@ We've provided a set of [Sample Workflows](../../../workflow-samples/) within th
|
||||
|
||||
Please refer to the [README](../../../workflow-samples/README.md) for setup instructions to run the sample workflows in your environment.
|
||||
|
||||
As part of our [Getting Started with Declarative Workflows](../../samples/GettingStarted/Workflows/Declarative/README.md),
|
||||
As part of our [Getting Started with Declarative Workflows](../../samples/03-workflows/Declarative/README.md),
|
||||
we've provided a console application that is able to execute any declarative workflow.
|
||||
|
||||
Please refer to the [README](../../samples/GettingStarted/Workflows/Declarative/README.md) for configuration instructions.
|
||||
|
||||
## Actions
|
||||
|
||||
### ⚙️ Foundry Actions
|
||||
|
||||
@@ -18,6 +18,7 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
private int _isDisposed;
|
||||
|
||||
private readonly ISuperStepRunner _stepRunner;
|
||||
private Activity? _sessionActivity;
|
||||
|
||||
public ValueTask<RunStatus> GetStatusAsync(CancellationToken cancellationToken = default) => new(this.RunStatus);
|
||||
|
||||
@@ -30,7 +31,16 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
|
||||
public void Start()
|
||||
{
|
||||
// No-op for lockstep execution
|
||||
// Save and restore Activity.Current so the long-lived session activity
|
||||
// doesn't leak into caller code via AsyncLocal.
|
||||
Activity? previousActivity = Activity.Current;
|
||||
|
||||
this._sessionActivity = this._stepRunner.TelemetryContext.StartWorkflowSessionActivity();
|
||||
this._sessionActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId)
|
||||
.SetTag(Tags.SessionId, this._stepRunner.SessionId);
|
||||
this._sessionActivity?.AddEvent(new ActivityEvent(EventNames.SessionStarted));
|
||||
|
||||
Activity.Current = previousActivity;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<WorkflowEvent> TakeEventStreamAsync(bool blockOnPendingRequest, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
@@ -44,19 +54,23 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
}
|
||||
#endif
|
||||
|
||||
CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(this._stopCancellation.Token, cancellationToken);
|
||||
using CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(this._stopCancellation.Token, cancellationToken);
|
||||
|
||||
ConcurrentQueue<WorkflowEvent> eventSink = [];
|
||||
|
||||
this._stepRunner.OutgoingEvents.EventRaised += OnWorkflowEventAsync;
|
||||
|
||||
using Activity? activity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity();
|
||||
activity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.SessionId, this._stepRunner.SessionId);
|
||||
// Re-establish session as parent so the run activity nests correctly.
|
||||
Activity.Current = this._sessionActivity;
|
||||
|
||||
// Not 'using' — must dispose explicitly in finally for deterministic export.
|
||||
Activity? runActivity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity();
|
||||
runActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.SessionId, this._stepRunner.SessionId);
|
||||
|
||||
try
|
||||
{
|
||||
this.RunStatus = RunStatus.Running;
|
||||
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
|
||||
runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
|
||||
|
||||
do
|
||||
{
|
||||
@@ -65,7 +79,7 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
{
|
||||
// Because we may be yielding out of this function, we need to ensure that the Activity.Current
|
||||
// is set to our activity for the duration of this loop iteration.
|
||||
Activity.Current = activity;
|
||||
Activity.Current = runActivity;
|
||||
|
||||
// Drain SuperSteps while there are steps to run
|
||||
try
|
||||
@@ -75,13 +89,13 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex) when (activity is not null)
|
||||
catch (Exception ex) when (runActivity is not null)
|
||||
{
|
||||
activity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
|
||||
runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
|
||||
{ Tags.ErrorType, ex.GetType().FullName },
|
||||
{ Tags.BuildErrorMessage, ex.Message },
|
||||
{ Tags.ErrorMessage, ex.Message },
|
||||
}));
|
||||
activity.CaptureException(ex);
|
||||
runActivity.CaptureException(ex);
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -129,12 +143,16 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
}
|
||||
} while (!ShouldBreak());
|
||||
|
||||
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
|
||||
runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.RunStatus = this._stepRunner.HasUnservicedRequests ? RunStatus.PendingRequests : RunStatus.Idle;
|
||||
this._stepRunner.OutgoingEvents.EventRaised -= OnWorkflowEventAsync;
|
||||
|
||||
// Explicitly dispose the Activity so Activity.Stop fires deterministically,
|
||||
// regardless of how the async iterator enumerator is disposed.
|
||||
runActivity?.Dispose();
|
||||
}
|
||||
|
||||
ValueTask OnWorkflowEventAsync(object? sender, WorkflowEvent e)
|
||||
@@ -172,6 +190,14 @@ internal sealed class LockstepRunEventStream : IRunEventStream
|
||||
{
|
||||
this._stopCancellation.Cancel();
|
||||
|
||||
// Stop the session activity
|
||||
if (this._sessionActivity is not null)
|
||||
{
|
||||
this._sessionActivity.AddEvent(new ActivityEvent(EventNames.SessionCompleted));
|
||||
this._sessionActivity.Dispose();
|
||||
this._sessionActivity = null;
|
||||
}
|
||||
|
||||
this._stopCancellation.Dispose();
|
||||
this._inputWaiter.Dispose();
|
||||
}
|
||||
|
||||
@@ -55,13 +55,20 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
private async Task RunLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using CancellationTokenSource errorSource = new();
|
||||
CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(errorSource.Token, cancellationToken);
|
||||
using CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(errorSource.Token, cancellationToken);
|
||||
|
||||
// Subscribe to events - they will flow directly to the channel as they're raised
|
||||
this._stepRunner.OutgoingEvents.EventRaised += OnEventRaisedAsync;
|
||||
|
||||
using Activity? activity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity();
|
||||
activity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.SessionId, this._stepRunner.SessionId);
|
||||
// Start the session-level activity that spans the entire run loop lifetime.
|
||||
// Individual run-stage activities are nested within this session activity.
|
||||
Activity? sessionActivity = this._stepRunner.TelemetryContext.StartWorkflowSessionActivity();
|
||||
sessionActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId)
|
||||
.SetTag(Tags.SessionId, this._stepRunner.SessionId);
|
||||
|
||||
Activity? runActivity = null;
|
||||
|
||||
sessionActivity?.AddEvent(new ActivityEvent(EventNames.SessionStarted));
|
||||
|
||||
try
|
||||
{
|
||||
@@ -70,10 +77,15 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
await this._inputWaiter.WaitForInputAsync(cancellationToken: linkedSource.Token).ConfigureAwait(false);
|
||||
|
||||
this._runStatus = RunStatus.Running;
|
||||
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
|
||||
|
||||
while (!linkedSource.Token.IsCancellationRequested)
|
||||
{
|
||||
// Start a new run-stage activity for this input→processing→halt cycle
|
||||
runActivity = this._stepRunner.TelemetryContext.StartWorkflowRunActivity();
|
||||
runActivity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId)
|
||||
.SetTag(Tags.SessionId, this._stepRunner.SessionId);
|
||||
runActivity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
|
||||
|
||||
// Run all available supersteps continuously
|
||||
// Events are streamed out in real-time as they happen via the event handler
|
||||
while (this._stepRunner.HasUnprocessedMessages && !linkedSource.Token.IsCancellationRequested)
|
||||
@@ -93,6 +105,15 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
RunStatus capturedStatus = this._runStatus;
|
||||
await this._eventChannel.Writer.WriteAsync(new InternalHaltSignal(currentEpoch, capturedStatus), linkedSource.Token).ConfigureAwait(false);
|
||||
|
||||
// Close the run-stage activity when processing halts.
|
||||
// A new run activity will be created when the next input arrives.
|
||||
if (runActivity is not null)
|
||||
{
|
||||
runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
|
||||
runActivity.Dispose();
|
||||
runActivity = null;
|
||||
}
|
||||
|
||||
// Wait for next input from the consumer
|
||||
// Works for both Idle (no work) and PendingRequests (waiting for responses)
|
||||
await this._inputWaiter.WaitForInputAsync(TimeSpan.FromSeconds(1), linkedSource.Token).ConfigureAwait(false);
|
||||
@@ -107,14 +128,26 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (activity != null)
|
||||
// Record error on the run-stage activity if one is active
|
||||
if (runActivity is not null)
|
||||
{
|
||||
activity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
|
||||
runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
|
||||
{ Tags.ErrorType, ex.GetType().FullName },
|
||||
{ Tags.BuildErrorMessage, ex.Message },
|
||||
{ Tags.ErrorMessage, ex.Message },
|
||||
}));
|
||||
activity.CaptureException(ex);
|
||||
runActivity.CaptureException(ex);
|
||||
}
|
||||
|
||||
// Record error on the session activity
|
||||
if (sessionActivity is not null)
|
||||
{
|
||||
sessionActivity.AddEvent(new ActivityEvent(EventNames.SessionError, tags: new() {
|
||||
{ Tags.ErrorType, ex.GetType().FullName },
|
||||
{ Tags.ErrorMessage, ex.Message },
|
||||
}));
|
||||
sessionActivity.CaptureException(ex);
|
||||
}
|
||||
|
||||
await this._eventChannel.Writer.WriteAsync(new WorkflowErrorEvent(ex), linkedSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
@@ -124,7 +157,20 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
|
||||
// Mark as ended when run loop exits
|
||||
this._runStatus = RunStatus.Ended;
|
||||
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
|
||||
|
||||
// Stop the run-stage activity if not already stopped (e.g. on cancellation or error)
|
||||
if (runActivity is not null)
|
||||
{
|
||||
runActivity.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
|
||||
runActivity.Dispose();
|
||||
}
|
||||
|
||||
// Stop the session activity — the session always ends when the run loop exits
|
||||
if (sessionActivity is not null)
|
||||
{
|
||||
sessionActivity.AddEvent(new ActivityEvent(EventNames.SessionCompleted));
|
||||
sessionActivity.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
async ValueTask OnEventRaisedAsync(object? sender, WorkflowEvent e)
|
||||
|
||||
@@ -5,7 +5,8 @@ namespace Microsoft.Agents.AI.Workflows.Observability;
|
||||
internal static class ActivityNames
|
||||
{
|
||||
public const string WorkflowBuild = "workflow.build";
|
||||
public const string WorkflowRun = "workflow_invoke";
|
||||
public const string WorkflowSession = "workflow.session";
|
||||
public const string WorkflowInvoke = "workflow_invoke";
|
||||
public const string MessageSend = "message.send";
|
||||
public const string ExecutorProcess = "executor.process";
|
||||
public const string EdgeGroupProcess = "edge_group.process";
|
||||
|
||||
@@ -8,6 +8,9 @@ internal static class EventNames
|
||||
public const string BuildValidationCompleted = "build.validation_completed";
|
||||
public const string BuildCompleted = "build.completed";
|
||||
public const string BuildError = "build.error";
|
||||
public const string SessionStarted = "session.started";
|
||||
public const string SessionCompleted = "session.completed";
|
||||
public const string SessionError = "session.error";
|
||||
public const string WorkflowStarted = "workflow.started";
|
||||
public const string WorkflowCompleted = "workflow.completed";
|
||||
public const string WorkflowError = "workflow.error";
|
||||
|
||||
@@ -11,6 +11,7 @@ internal static class Tags
|
||||
public const string BuildErrorMessage = "build.error.message";
|
||||
public const string BuildErrorType = "build.error.type";
|
||||
public const string ErrorType = "error.type";
|
||||
public const string ErrorMessage = "error.message";
|
||||
public const string SessionId = "session.id";
|
||||
public const string ExecutorId = "executor.id";
|
||||
public const string ExecutorType = "executor.type";
|
||||
|
||||
@@ -88,7 +88,25 @@ internal sealed class WorkflowTelemetryContext
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a workflow run activity if enabled.
|
||||
/// Starts a workflow session activity if enabled. This is the outer/parent span
|
||||
/// that represents the entire lifetime of a workflow execution (from start
|
||||
/// until stop, cancellation, or error) within the current trace.
|
||||
/// Individual run stages are typically nested within it.
|
||||
/// </summary>
|
||||
/// <returns>An activity if workflow run telemetry is enabled, otherwise null.</returns>
|
||||
public Activity? StartWorkflowSessionActivity()
|
||||
{
|
||||
if (!this.IsEnabled || this.Options.DisableWorkflowRun)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.ActivitySource.StartActivity(ActivityNames.WorkflowSession);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a workflow run activity if enabled. This represents a single
|
||||
/// input-to-halt cycle within a workflow session.
|
||||
/// </summary>
|
||||
/// <returns>An activity if workflow run telemetry is enabled, otherwise null.</returns>
|
||||
public Activity? StartWorkflowRunActivity()
|
||||
@@ -98,7 +116,7 @@ internal sealed class WorkflowTelemetryContext
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.ActivitySource.StartActivity(ActivityNames.WorkflowRun);
|
||||
return this.ActivitySource.StartActivity(ActivityNames.WorkflowInvoke);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Shared.IntegrationTests;
|
||||
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
#pragma warning disable CA1812 // Internal class that is apparently never instantiated.
|
||||
|
||||
internal sealed class AnthropicConfiguration
|
||||
{
|
||||
public string? ServiceId { get; set; }
|
||||
|
||||
public string ChatModelId { get; set; }
|
||||
|
||||
public string ChatReasoningModelId { get; set; }
|
||||
|
||||
public string ApiKey { get; set; }
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Shared.IntegrationTests;
|
||||
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
#pragma warning disable CA1812 // Internal class that is apparently never instantiated.
|
||||
|
||||
internal sealed class AzureAIConfiguration
|
||||
{
|
||||
public string Endpoint { get; set; }
|
||||
|
||||
public string DeploymentName { get; set; }
|
||||
|
||||
public string BingConnectionId { get; set; }
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Shared.IntegrationTests;
|
||||
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
#pragma warning disable CA1812 // Internal class that is apparently never instantiated.
|
||||
|
||||
internal sealed class FoundryMemoryConfiguration
|
||||
{
|
||||
public string Endpoint { get; set; }
|
||||
public string MemoryStoreName { get; set; }
|
||||
public string? DeploymentName { get; set; }
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Shared.IntegrationTests;
|
||||
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
#pragma warning disable CA1812 // Internal class that is apparently never instantiated.
|
||||
|
||||
internal sealed class Mem0Configuration
|
||||
{
|
||||
public string ServiceUri { get; set; }
|
||||
public string ApiKey { get; set; }
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Shared.IntegrationTests;
|
||||
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
#pragma warning disable CA1812 // Internal class that is apparently never instantiated.
|
||||
|
||||
internal sealed class OpenAIConfiguration
|
||||
{
|
||||
public string? ServiceId { get; set; }
|
||||
|
||||
public string ChatModelId { get; set; }
|
||||
|
||||
public string ChatReasoningModelId { get; set; }
|
||||
|
||||
public string ApiKey { get; set; }
|
||||
}
|
||||
@@ -9,3 +9,30 @@ To use this in your project, add the following to your `.csproj` file:
|
||||
<InjectSharedIntegrationTestCode>true</InjectSharedIntegrationTestCode>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Integration tests use flat environment variable names for configuration.
|
||||
Use `TestConfiguration.GetValue(key)` or `TestConfiguration.GetRequiredValue(key)` to access values.
|
||||
|
||||
Available keys are defined as constants in `TestSettings.cs`:
|
||||
|
||||
| Key | Description |
|
||||
|---|---|
|
||||
| `ANTHROPIC_API_KEY` | API key for Anthropic |
|
||||
| `ANTHROPIC_CHAT_MODEL_NAME` | Anthropic chat model name |
|
||||
| `ANTHROPIC_REASONING_MODEL_NAME` | Anthropic reasoning model name |
|
||||
| `ANTHROPIC_SERVICE_ID` | Anthropic service ID |
|
||||
| `AZURE_AI_BING_CONNECTION_ID` | Azure AI Bing connection ID |
|
||||
| `AZURE_AI_MEMORY_STORE_ID` | Azure AI Memory store name |
|
||||
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Azure AI model deployment name |
|
||||
| `AZURE_AI_PROJECT_ENDPOINT` | Azure AI project endpoint |
|
||||
| `COPILOTSTUDIO_AGENT_APP_ID` | Copilot Studio agent app ID |
|
||||
| `COPILOTSTUDIO_DIRECT_CONNECT_URL` | Copilot Studio direct connect URL |
|
||||
| `COPILOTSTUDIO_TENANT_ID` | Copilot Studio tenant ID |
|
||||
| `MEM0_API_KEY` | API key for Mem0 |
|
||||
| `MEM0_ENDPOINT` | Mem0 service endpoint |
|
||||
| `OPENAI_API_KEY` | API key for OpenAI |
|
||||
| `OPENAI_CHAT_MODEL_NAME` | OpenAI chat model name |
|
||||
| `OPENAI_REASONING_MODEL_NAME` | OpenAI reasoning model name |
|
||||
| `OPENAI_SERVICE_ID` | OpenAI service ID |
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Shared.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Constants for integration test configuration keys.
|
||||
/// Values are resolved from environment variables and user secrets.
|
||||
/// </summary>
|
||||
internal static class TestSettings
|
||||
{
|
||||
// Anthropic
|
||||
public const string AnthropicApiKey = "ANTHROPIC_API_KEY";
|
||||
public const string AnthropicChatModelName = "ANTHROPIC_CHAT_MODEL_NAME";
|
||||
public const string AnthropicReasoningModelName = "ANTHROPIC_REASONING_MODEL_NAME";
|
||||
public const string AnthropicServiceId = "ANTHROPIC_SERVICE_ID";
|
||||
|
||||
// Azure AI (Foundry)
|
||||
public const string AzureAIBingConnectionId = "AZURE_AI_BING_CONNECTION_ID";
|
||||
public const string AzureAIMemoryStoreId = "AZURE_AI_MEMORY_STORE_ID";
|
||||
public const string AzureAIModelDeploymentName = "AZURE_AI_MODEL_DEPLOYMENT_NAME";
|
||||
public const string AzureAIProjectEndpoint = "AZURE_AI_PROJECT_ENDPOINT";
|
||||
|
||||
// Copilot Studio
|
||||
public const string CopilotStudioAgentAppId = "COPILOTSTUDIO_AGENT_APP_ID";
|
||||
public const string CopilotStudioDirectConnectUrl = "COPILOTSTUDIO_DIRECT_CONNECT_URL";
|
||||
public const string CopilotStudioTenantId = "COPILOTSTUDIO_TENANT_ID";
|
||||
|
||||
// Mem0
|
||||
public const string Mem0ApiKey = "MEM0_API_KEY";
|
||||
public const string Mem0Endpoint = "MEM0_ENDPOINT";
|
||||
|
||||
// OpenAI
|
||||
public const string OpenAIApiKey = "OPENAI_API_KEY";
|
||||
public const string OpenAIChatModelName = "OPENAI_CHAT_MODEL_NAME";
|
||||
public const string OpenAIReasoningModelName = "OPENAI_REASONING_MODEL_NAME";
|
||||
public const string OpenAIServiceId = "OPENAI_SERVICE_ID";
|
||||
}
|
||||
@@ -22,6 +22,9 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
|
||||
// Assign to enable logging
|
||||
public ILoggerFactory LoggerFactory { get; init; } = NullLoggerFactory.Instance;
|
||||
|
||||
// Assign to provide MCP tool capabilities
|
||||
public IMcpToolHandler? McpToolHandler { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Create the workflow from the declarative YAML. Includes definition of the
|
||||
/// <see cref="DeclarativeWorkflowOptions" /> and the associated <see cref="ResponseAgentProvider"/>.
|
||||
@@ -42,6 +45,7 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
|
||||
Configuration = this.Configuration,
|
||||
ConversationId = this.ConversationId,
|
||||
LoggerFactory = this.LoggerFactory,
|
||||
McpToolHandler = this.McpToolHandler,
|
||||
};
|
||||
|
||||
string workflowPath = Path.Combine(AppContext.BaseDirectory, workflowFile);
|
||||
|
||||
@@ -12,10 +12,9 @@ internal static class Application
|
||||
/// </summary>
|
||||
public static class Settings
|
||||
{
|
||||
public const string FoundryEndpoint = "FOUNDRY_PROJECT_ENDPOINT";
|
||||
public const string FoundryModelMini = "FOUNDRY_MODEL_DEPLOYMENT_NAME";
|
||||
public const string FoundryModelFull = "FOUNDRY_MEDIA_DEPLOYMENT_NAME";
|
||||
public const string FoundryGroundingTool = "FOUNDRY_CONNECTION_GROUNDING_TOOL";
|
||||
public const string FoundryEndpoint = "AZURE_AI_PROJECT_ENDPOINT";
|
||||
public const string FoundryModel = "AZURE_AI_MODEL_DEPLOYMENT_NAME";
|
||||
public const string FoundryGroundingTool = "AZURE_AI_BING_CONNECTION_ID";
|
||||
}
|
||||
|
||||
public static string GetInput(string[] args)
|
||||
|
||||
Reference in New Issue
Block a user