Merge remote-tracking branch 'origin/main' into features/ai-project-2.0.0-update-002

This commit is contained in:
Roger Barreto
2026-02-26 12:11:52 +00:00
Unverified
847 changed files with 5687 additions and 1650 deletions
@@ -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);
}
}
@@ -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>
@@ -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);
}
@@ -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);
@@ -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);
@@ -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(
@@ -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)