.NET Workflows - WIP Declarative action update (#1761)

* WIP

* Fixed build errors (#1638)

Comment and nullable type alignment

* Sync to SDK update

* Checkpoint

* Checkpoint: Tests passing

* Checkpoint: EndWorkflow

* Add trace

* .NET: Azure.AI.Agents Package Split + Initial Extensions (#1657)

* Move packages

* Update nuget.config

* Address Xmldoc

* Remove format from branches checks

* Address Xmldocs

* Add more details to the implementation

* Moving Agent logic to ChatClient

* Adding Name and Id overrides to AzureAIAgent

* Updating extensions

* Add GetAiAgent extensions

* Adding support for version as name can conflict 409 using the Agents API with same name

* Addressing more updates to the extensions

* More improvements

* Remove debugging code from sample

* Address copilot feedback

* Apply suggestions from co-pilot code review

* Checkpoint

* Update Directory.Packages.props

Fix package version rollback:

Azure.AI.Agents.Persistent (beta-6 => beta-7)

* Add project reference

* .NET: Add comprehensive unit tests for Microsoft.Agents.AI.AzureAIAgents extension methods (#1786)

* Initial plan

* Add comprehensive unit test project for Microsoft.Agents.AI.AzureAIAgents

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Add README documenting test project and package dependency requirements

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Fix documentation URL to use learn.microsoft.com

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Bump back AAAP 1.2.0-beta.7

* Address AI generated UT's

* Remove UT Readme

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* .NET: Change model to be required just for prompt agent definition specific extensions (#1812)

* Remove unneeded model from extensions

* Add noop justification

* Update Package Nameing: V1 -> AzureAI.Persistent / V2 -> AzureAI (#1829)

* Checkpoint for merge

* No build errors

* .NET: Update Extensions for Strict Agent Definitions + Improvements (#1892)

* Update Package Nameing: V1 -> AzureAI.Persistent / V2 -> AzureAI

* Update agents and extensions to comply with strict agent definitions

* More static updates

* Address UT, and ResponseTool support

* Improving reusability extensions

* Addressing ResponseTools Unit Tests and extension setup

* Adapted workaround on breaking AAA with OpenAI 2.6.0

* Small updates

* Remove strictness when retrieving agents, improved XmlDocs

* Improve sample comments

* Update dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AgentsClientExtensionsTests.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Address PR comments

* Address UT failing

* Address Copilot feedback

* Address Copilot feedback

* Address comment typo

* Address PR feedback

* Address typo

* Add missing Extensions with ChatClientAgentOptions

* Address comments

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Updated package version (#1897)

* Version update (#1901)

* Checkpoint

* Updated package version (#1906)

* Checkpoint

* Checkpoint

* Checkpoint

* Align with azure ai agent

* Update dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/samples/GettingStarted/Workflows/Declarative/MCPToolApproval/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Refactored external input

* Update dotnet/samples/GettingStarted/Workflows/Declarative/MCPToolApproval/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Agent tools patch

* Demos validated

* Checkpoint

* Hygiene

* Checkpoint - Samples

* Update dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Checkpoint

* Checkpoint - Deep Research

* Update baseline

* Update

* Typo

* Checkpoint

* Typos

* Sample cleanup

* Update dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/src/Microsoft.Agents.AI.AzureAI/AgentsClientExtensions.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/samples/GettingStarted/Workflows/Declarative/FunctionTools/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/samples/GettingStarted/Workflows/Declarative/ToolApproval/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dotnet/samples/GettingStarted/Workflows/Declarative/DeepResearch/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Typo

* Typo

* Fix input loop

* Sample - Function Calling / External Input

* Typo

* Finessed

* Checkpoint

* Fix feed

* Checkpoint - so close

* Ding dong!

* "there" ***

* Fixup comments

* Fix sample

* Code analysis

* Header

* Typo (variableName)

* Remove dead code

* Skip test (agent api ratchet)

* Comment

* Update dotnet/samples/GettingStarted/Workflows/Declarative/StudentTeacher/Program.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Typo

---------

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
This commit is contained in:
Chris
2025-11-07 11:37:21 -08:00
committed by GitHub
Unverified
parent 7c3d4fcf30
commit 2b869c2396
125 changed files with 3192 additions and 3712 deletions
@@ -1,15 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Agents.Persistent;
using Azure.AI.Agents;
using Azure.Core;
using Azure.Core.Pipeline;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
namespace Microsoft.Agents.AI.Workflows.Declarative;
@@ -18,31 +21,23 @@ namespace Microsoft.Agents.AI.Workflows.Declarative;
/// </summary>
/// <remarks>This class is used to retrieve and manage AI agents associated with a Foundry project. It requires a
/// project endpoint and credentials to authenticate requests.</remarks>
/// <param name="projectEndpoint">The endpoint URL of the Foundry project. This must be a valid, non-null URI pointing to the project.</param>
/// <param name="projectEndpoint">A <see cref="Uri"/> instance representing the endpoint URL of the Foundry project. This must be a valid, non-null URI pointing to the project.</param>
/// <param name="projectCredentials">The credentials used to authenticate with the Foundry project. This must be a valid instance of <see cref="TokenCredential"/>.</param>
/// <param name="httpClient">An optional <see cref="HttpClient"/> instance to be used for making HTTP requests. If not provided, a default client will be used.</param>
public sealed class AzureAgentProvider(string projectEndpoint, TokenCredential projectCredentials, HttpClient? httpClient = null) : WorkflowAgentProvider
public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential projectCredentials, HttpClient? httpClient = null) : WorkflowAgentProvider
{
private static readonly Dictionary<string, MessageRole> s_roleMap =
new()
{
[ChatRole.User.Value.ToUpperInvariant()] = MessageRole.User,
[ChatRole.Assistant.Value.ToUpperInvariant()] = MessageRole.Agent,
[ChatRole.System.Value.ToUpperInvariant()] = new MessageRole(ChatRole.System.Value),
[ChatRole.Tool.Value.ToUpperInvariant()] = new MessageRole(ChatRole.Tool.Value),
};
private readonly Dictionary<string, AgentVersion> _versionCache = [];
private readonly Dictionary<string, AIAgent> _agentCache = [];
private PersistentAgentsClient? _agentsClient;
private AgentsClient? _agentsClient;
private ConversationClient? _conversationClient;
/// <inheritdoc/>
public override async Task<string> CreateConversationAsync(CancellationToken cancellationToken = default)
{
PersistentAgentThread conversation =
await this.GetAgentsClient().Threads.CreateThreadAsync(
messages: null,
toolResources: null,
metadata: null,
cancellationToken).ConfigureAwait(false);
AgentConversation conversation =
await this.GetConversationClient()
.CreateConversationAsync(options: null, cancellationToken).ConfigureAwait(false);
return conversation.Id;
}
@@ -50,61 +45,117 @@ public sealed class AzureAgentProvider(string projectEndpoint, TokenCredential p
/// <inheritdoc/>
public override async Task<ChatMessage> CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default)
{
PersistentThreadMessage newMessage =
await this.GetAgentsClient().Messages.CreateMessageAsync(
ReadOnlyCollection<ResponseItem> newItems =
await this.GetConversationClient().CreateConversationItemsAsync(
conversationId,
role: s_roleMap[conversationMessage.Role.Value.ToUpperInvariant()],
contentBlocks: GetContent(),
attachments: null,
metadata: GetMetadata(),
items: GetResponseItems(),
include: null,
cancellationToken).ConfigureAwait(false);
return ToChatMessage(newMessage);
return newItems.AsChatMessages().Single();
Dictionary<string, string>? GetMetadata()
IEnumerable<ResponseItem> GetResponseItems()
{
if (conversationMessage.AdditionalProperties is null)
IEnumerable<ChatMessage> messages = [conversationMessage];
foreach (ResponseItem item in messages.AsOpenAIResponseItems())
{
return null;
}
return conversationMessage.AdditionalProperties.ToDictionary(prop => prop.Key, prop => prop.Value?.ToString() ?? string.Empty);
}
IEnumerable<MessageInputContentBlock> GetContent()
{
foreach (AIContent content in conversationMessage.Contents)
{
MessageInputContentBlock? contentBlock =
content switch
{
TextContent textContent => new MessageInputTextBlock(textContent.Text),
HostedFileContent fileContent => new MessageInputImageFileBlock(new MessageImageFileParam(fileContent.FileId)),
UriContent uriContent when uriContent.Uri is not null => new MessageInputImageUriBlock(new MessageImageUriParam(uriContent.Uri.ToString())),
DataContent dataContent when dataContent.Uri is not null => new MessageInputImageUriBlock(new MessageImageUriParam(dataContent.Uri)),
_ => null // Unsupported content type
};
if (contentBlock is not null)
if (string.IsNullOrEmpty(item.Id))
{
yield return contentBlock;
yield return item;
}
else
{
yield return new ReferenceResponseItem(item.Id);
}
}
}
}
/// <inheritdoc/>
public override async Task<AIAgent> GetAgentAsync(string agentId, CancellationToken cancellationToken = default)
public override async IAsyncEnumerable<AgentRunResponseUpdate> InvokeAgentAsync(
string agentId,
string? agentVersion,
string? conversationId,
IEnumerable<ChatMessage>? messages,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ChatClientAgent agent =
await this.GetAgentsClient().GetAIAgentAsync(
agentId,
new ChatOptions()
{
AllowMultipleToolCalls = this.AllowMultipleToolCalls,
},
clientFactory: null,
cancellationToken).ConfigureAwait(false);
AgentVersion agentDefinition = await this.QueryAgentAsync(agentId, agentVersion, cancellationToken).ConfigureAwait(false);
AIAgent agent = await this.GetAgentAsync(agentDefinition, cancellationToken).ConfigureAwait(false);
ChatOptions chatOptions =
new()
{
ConversationId = conversationId,
AllowMultipleToolCalls = this.AllowMultipleToolCalls,
};
ChatClientAgentRunOptions runOptions = new(chatOptions);
IAsyncEnumerable<AgentRunResponseUpdate> agentResponse =
messages is not null ?
agent.RunStreamingAsync([.. messages], null, runOptions, cancellationToken) :
agent.RunStreamingAsync([new ChatMessage(ChatRole.User, string.Empty)], null, runOptions, cancellationToken);
await foreach (AgentRunResponseUpdate update in agentResponse.ConfigureAwait(false))
{
update.AuthorName = agentDefinition.Name;
yield return update;
}
}
private async Task<AgentVersion> QueryAgentAsync(string agentName, string? agentVersion, CancellationToken cancellationToken = default)
{
string agentKey = $"{agentName}:{agentVersion}";
if (this._versionCache.TryGetValue(agentKey, out AgentVersion? targetAgent))
{
return targetAgent;
}
AgentsClient client = this.GetAgentsClient();
if (string.IsNullOrEmpty(agentVersion))
{
AgentRecord agentRecord =
await client.GetAgentAsync(
agentName,
cancellationToken).ConfigureAwait(false);
targetAgent = agentRecord.Versions.Latest;
}
else
{
targetAgent =
await client.GetAgentVersionAsync(
agentName,
agentVersion,
cancellationToken).ConfigureAwait(false);
}
this._versionCache[agentKey] = targetAgent;
return targetAgent;
}
private async Task<AIAgent> GetAgentAsync(AgentVersion agentDefinition, CancellationToken cancellationToken = default)
{
if (this._agentCache.TryGetValue(agentDefinition.Id, out AIAgent? agent))
{
return agent;
}
AgentsClient client = this.GetAgentsClient();
IList<AITool>? tools = null;
if (agentDefinition.Definition is PromptAgentDefinition promptAgent)
{
tools =
promptAgent.Tools
.Select(tool => tool.AsAITool())
.ToArray();
}
agent = client.GetAIAgent(agentDefinition, tools, clientFactory: null, openAIClientOptions: null, requireInvocableTools: false, cancellationToken);
FunctionInvokingChatClient? functionInvokingClient = agent.GetService<FunctionInvokingChatClient>();
if (functionInvokingClient is not null)
@@ -127,14 +178,17 @@ public sealed class AzureAgentProvider(string projectEndpoint, TokenCredential p
}
}
this._agentCache[agentDefinition.Id] = agent;
return agent;
}
/// <inheritdoc/>
public override async Task<ChatMessage> GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default)
{
PersistentThreadMessage message = await this.GetAgentsClient().Messages.GetMessageAsync(conversationId, messageId, cancellationToken).ConfigureAwait(false);
return ToChatMessage(message);
AgentResponseItem responseItem = await this.GetConversationClient().GetConversationItemAsync(conversationId, messageId, cancellationToken).ConfigureAwait(false);
ResponseItem[] items = [responseItem.AsOpenAIResponseItem()];
return items.AsChatMessages().Single();
}
/// <inheritdoc/>
@@ -146,25 +200,29 @@ public sealed class AzureAgentProvider(string projectEndpoint, TokenCredential p
bool newestFirst = false,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ListSortOrder order = newestFirst ? ListSortOrder.Ascending : ListSortOrder.Descending;
await foreach (PersistentThreadMessage message in this.GetAgentsClient().Messages.GetMessagesAsync(conversationId, runId: null, limit, order, after, before, cancellationToken).ConfigureAwait(false))
AgentsListOrder order = newestFirst ? AgentsListOrder.Asc : AgentsListOrder.Desc;
await foreach (AgentResponseItem responseItem in this.GetConversationClient().GetConversationItemsAsync(conversationId, limit, order, after, before, itemType: null, cancellationToken).ConfigureAwait(false))
{
yield return ToChatMessage(message);
ResponseItem[] items = [responseItem.AsOpenAIResponseItem()];
foreach (ChatMessage message in items.AsChatMessages())
{
yield return message;
}
}
}
private PersistentAgentsClient GetAgentsClient()
private AgentsClient GetAgentsClient()
{
if (this._agentsClient is null)
{
PersistentAgentsAdministrationClientOptions clientOptions = new();
AgentsClientOptions clientOptions = new();
if (httpClient is not null)
{
clientOptions.Transport = new HttpClientTransport(httpClient);
clientOptions.Transport = new HttpClientPipelineTransport(httpClient);
}
PersistentAgentsClient newClient = new(projectEndpoint, projectCredentials, clientOptions);
AgentsClient newClient = new(projectEndpoint, projectCredentials, clientOptions);
Interlocked.CompareExchange(ref this._agentsClient, newClient, null);
}
@@ -172,43 +230,15 @@ public sealed class AzureAgentProvider(string projectEndpoint, TokenCredential p
return this._agentsClient;
}
private static ChatMessage ToChatMessage(PersistentThreadMessage message)
private ConversationClient GetConversationClient()
{
return
new ChatMessage(new ChatRole(message.Role.ToString()), [.. GetContent()])
{
MessageId = message.Id,
CreatedAt = message.CreatedAt,
AdditionalProperties = GetMetadata()
};
IEnumerable<AIContent> GetContent()
if (this._conversationClient is null)
{
foreach (MessageContent contentItem in message.ContentItems)
{
AIContent? content =
contentItem switch
{
MessageTextContent textContent => new TextContent(textContent.Text),
MessageImageFileContent imageContent => new HostedFileContent(imageContent.FileId),
_ => null // Unsupported content type
};
ConversationClient conversationClient = this.GetAgentsClient().GetConversationClient();
if (content is not null)
{
yield return content;
}
}
Interlocked.CompareExchange(ref this._conversationClient, conversationClient, null);
}
AdditionalPropertiesDictionary? GetMetadata()
{
if (message.Metadata is null)
{
return null;
}
return new AdditionalPropertiesDictionary(message.Metadata.Select(m => new KeyValuePair<string, object?>(m.Key, m.Value)));
}
return this._conversationClient;
}
}
@@ -61,7 +61,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen
EvaluateStringExpression(this.Model.ConversationId, "conversationId", isNullable: true);
EvaluateBoolExpression(this.Model.Output?.AutoSend, "autoSend", defaultValue: true);
EvaluateMessageTemplate(this.Model.Input?.AdditionalInstructions, "additionalInstructions");
EvaluateListExpression<ChatMessage>(this.Model.Input?.Messages, "inputMessages");
this.Write(@"
@@ -71,7 +70,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.CodeGen
agentName,
conversationId,
autoSend,
additionalInstructions,
inputMessages,
cancellationToken).ConfigureAwait(false);
@@ -19,7 +19,6 @@ internal sealed class <#= this.Name #>Executor(FormulaSession session, WorkflowA
<#
EvaluateStringExpression(this.Model.ConversationId, "conversationId", isNullable: true);
EvaluateBoolExpression(this.Model.Output?.AutoSend, "autoSend", defaultValue: true);
EvaluateMessageTemplate(this.Model.Input?.AdditionalInstructions, "additionalInstructions");
EvaluateListExpression<ChatMessage>(this.Model.Input?.Messages, "inputMessages");#>
AgentRunResponse agentResponse =
@@ -28,7 +27,6 @@ internal sealed class <#= this.Name #>Executor(FormulaSession session, WorkflowA
agentName,
conversationId,
autoSend,
additionalInstructions,
inputMessages,
cancellationToken).ConfigureAwait(false);
@@ -1,30 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
/// <summary>
/// Represents one or more function tool requests.
/// </summary>
public sealed class AgentFunctionToolRequest
{
/// <summary>
/// The name of the agent associated with the tool request.
/// </summary>
public string AgentName { get; }
/// <summary>
/// A list of function tool requests.
/// </summary>
public IList<FunctionCallContent> FunctionCalls { get; }
[JsonConstructor]
internal AgentFunctionToolRequest(string agentName, IList<FunctionCallContent> functionCalls)
{
this.AgentName = agentName;
this.FunctionCalls = functionCalls;
}
}
@@ -1,52 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
/// <summary>
/// Represents one or more function tool responses.
/// </summary>
public sealed class AgentFunctionToolResponse
{
/// <summary>
/// The name of the agent associated with the tool response.
/// </summary>
public string AgentName { get; }
/// <summary>
/// A list of tool responses.
/// </summary>
public IList<FunctionResultContent> FunctionResults { get; }
[JsonConstructor]
internal AgentFunctionToolResponse(string agentName, IList<FunctionResultContent> functionResults)
{
this.AgentName = agentName;
this.FunctionResults = functionResults;
}
/// <summary>
/// Factory method to create an <see cref="AgentFunctionToolResponse"/> from an <see cref="AgentFunctionToolRequest"/>
/// Ensures that all function calls in the request have a corresponding result.
/// </summary>
/// <param name="toolRequest">The tool request.</param>
/// <param name="functionResults">One or more function results</param>
/// <returns>An <see cref="AgentFunctionToolResponse"/> that can be provided to the workflow.</returns>
/// <exception cref="DeclarativeActionException">Not all <see cref="AgentFunctionToolRequest.FunctionCalls"/> have a corresponding <see cref="FunctionResultContent"/>.</exception>
public static AgentFunctionToolResponse Create(AgentFunctionToolRequest toolRequest, params IEnumerable<FunctionResultContent> functionResults)
{
HashSet<string> callIds = [.. toolRequest.FunctionCalls.Select(call => call.CallId)];
HashSet<string> resultIds = [.. functionResults.Select(call => call.CallId)];
if (!callIds.SetEquals(resultIds))
{
throw new DeclarativeActionException($"Missing results for: {string.Join(",", callIds.Except(resultIds))}");
}
return new AgentFunctionToolResponse(toolRequest.AgentName, [.. functionResults]);
}
}
@@ -1,27 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
/// <summary>
/// Represents a request for user input in response to a `Question` action.
/// </summary>
public sealed class AnswerRequest
{
/// <summary>
/// An optional prompt for the user.
/// </summary>
/// <remarks>
/// This prompt is utilized for the "Question" action type in the Declarative Workflow,
/// but is redundant when the user is responding to an agent since the agent's message
/// is the implicit prompt.
/// </remarks>
public string? Prompt { get; }
[JsonConstructor]
internal AnswerRequest(string? prompt = null)
{
this.Prompt = prompt;
}
}
@@ -1,36 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
/// <summary>
/// Represents a user input response.
/// </summary>
public sealed class AnswerResponse
{
/// <summary>
/// The response value.
/// </summary>
public ChatMessage Value { get; }
/// <summary>
/// Initializes a new instance of the <see cref="AnswerResponse"/> class.
/// </summary>
/// <param name="value">The response value.</param>
[JsonConstructor]
public AnswerResponse(ChatMessage value)
{
this.Value = value;
}
/// <summary>
/// Initializes a new instance of the <see cref="AnswerResponse"/> class.
/// </summary>
/// <param name="value">The response value.</param>
public AnswerResponse(string value)
{
this.Value = new ChatMessage(ChatRole.User, value);
}
}
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
/// <summary>
/// Represents a request for external input.
/// </summary>
public sealed class ExternalInputRequest
{
/// <summary>
/// The source message that triggered the request for external input.
/// </summary>
public AgentRunResponse AgentResponse { get; }
[JsonConstructor]
internal ExternalInputRequest(AgentRunResponse agentResponse)
{
this.AgentResponse = agentResponse;
}
internal ExternalInputRequest(ChatMessage message)
{
this.AgentResponse = new AgentRunResponse(message);
}
internal ExternalInputRequest(string text)
{
this.AgentResponse = new AgentRunResponse(new ChatMessage(ChatRole.User, text));
}
}
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
/// <summary>
/// Represents the response to a <see cref="ExternalInputRequest"/>.
/// </summary>
public sealed class ExternalInputResponse
{
/// <summary>
/// The message being provided as external input to the workflow.
/// </summary>
public IList<ChatMessage> Messages { get; }
internal bool HasMessages => this.Messages?.Count > 0;
/// <summary>
/// Initializes a new instance of <see cref="ExternalInputResponse"/>.
/// </summary>
/// <param name="message">The external input message being provided to the workflow.</param>
public ExternalInputResponse(ChatMessage message)
{
this.Messages = [message];
}
/// <summary>
/// Initializes a new instance of <see cref="ExternalInputResponse"/>.
/// </summary>
/// <param name="messages">The external input messages being provided to the workflow.</param>
[JsonConstructor]
public ExternalInputResponse(IList<ChatMessage> messages)
{
this.Messages = messages;
}
}
@@ -1,30 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
/// <summary>
/// Represents one or more user-input requests.
/// </summary>
public sealed class UserInputRequest
{
/// <summary>
/// The name of the agent associated with the tool request.
/// </summary>
public string AgentName { get; }
/// <summary>
/// A list of user input requests.
/// </summary>
public IList<AIContent> InputRequests { get; }
[JsonConstructor]
internal UserInputRequest(string agentName, IList<AIContent> inputRequests)
{
this.AgentName = agentName;
this.InputRequests = inputRequests;
}
}
@@ -1,52 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
/// <summary>
/// Represents one or more user-input responses.
/// </summary>
public sealed class UserInputResponse
{
/// <summary>
/// The name of the agent associated with the tool request.
/// </summary>
public string AgentName { get; }
/// <summary>
/// A list of approval responses.
/// </summary>
public IList<AIContent> InputResponses { get; }
[JsonConstructor]
internal UserInputResponse(string agentName, IList<AIContent> inputResponses)
{
this.AgentName = agentName;
this.InputResponses = inputResponses;
}
/// <summary>
/// Factory method to create an <see cref="UserInputResponse"/> from a <see cref="UserInputRequest"/>
/// Ensures that all requests have a corresponding result.
/// </summary>
/// <param name="inputRequest">The input request.</param>
/// <param name="inputResponses">One or more responses</param>
/// <returns>An <see cref="UserInputResponse"/> that can be provided to the workflow.</returns>
/// <exception cref="DeclarativeActionException">Not all <see cref="AgentFunctionToolRequest.FunctionCalls"/> have a corresponding <see cref="FunctionResultContent"/>.</exception>
public static UserInputResponse Create(UserInputRequest inputRequest, params IEnumerable<UserInputResponseContent> inputResponses)
{
HashSet<string> callIds = [.. inputRequest.InputRequests.OfType<UserInputRequestContent>().Select(call => call.Id)];
HashSet<string> resultIds = [.. inputResponses.Select(call => call.Id)];
if (!callIds.SetEquals(resultIds))
{
throw new DeclarativeActionException($"Missing responses for: {string.Join(",", callIds.Except(resultIds))}");
}
return new UserInputResponse(inputRequest.AgentName, [.. inputResponses]);
}
}
@@ -1,24 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Agents.Persistent;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions;
internal static class AgentProviderExtensions
{
private static readonly HashSet<Azure.AI.Agents.Persistent.RunStatus> s_failureStatus =
[
Azure.AI.Agents.Persistent.RunStatus.Failed,
Azure.AI.Agents.Persistent.RunStatus.Cancelled,
Azure.AI.Agents.Persistent.RunStatus.Cancelling,
Azure.AI.Agents.Persistent.RunStatus.Expired,
];
public static async ValueTask<AgentRunResponse> InvokeAgentAsync(
this WorkflowAgentProvider agentProvider,
string executorId,
@@ -26,27 +16,10 @@ internal static class AgentProviderExtensions
string agentName,
string? conversationId,
bool autoSend,
string? additionalInstructions = null,
IEnumerable<ChatMessage>? inputMessages = null,
CancellationToken cancellationToken = default)
{
// Get the specified agent.
AIAgent agent = await agentProvider.GetAgentAsync(agentName, cancellationToken).ConfigureAwait(false);
// Prepare the run options.
ChatClientAgentRunOptions options =
new(
new ChatOptions()
{
ConversationId = conversationId,
Instructions = additionalInstructions,
});
// Initialize the agent thread.
IAsyncEnumerable<AgentRunResponseUpdate> agentUpdates =
inputMessages is not null ?
agent.RunStreamingAsync([.. inputMessages], null, options, cancellationToken) :
agent.RunStreamingAsync(null, options, cancellationToken);
IAsyncEnumerable<AgentRunResponseUpdate> agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, cancellationToken);
// Enable "autoSend" behavior if this is the workflow conversation.
bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? workflowConversationId);
@@ -60,13 +33,6 @@ internal static class AgentProviderExtensions
updates.Add(update);
if (update.RawRepresentation is ChatResponseUpdate chatUpdate &&
chatUpdate.RawRepresentation is RunUpdate runUpdate &&
s_failureStatus.Contains(runUpdate.Value.Status))
{
throw new DeclarativeActionException($"Unexpected failure invoking agent, run {runUpdate.Value.Status}: {agent.Name ?? agent.Id} [{runUpdate.Value.Id}/{conversationId}]");
}
if (autoSend)
{
await context.AddEventAsync(new AgentRunUpdateEvent(executorId, update), cancellationToken).ConfigureAwait(false);
@@ -80,16 +46,10 @@ internal static class AgentProviderExtensions
await context.AddEventAsync(new AgentRunResponseEvent(executorId, response), cancellationToken).ConfigureAwait(false);
}
// If autoSend is enabled and this is not the workflow conversation, copy messages to the workflow conversation.
if (autoSend && !isWorkflowConversation && workflowConversationId is not null)
{
// Copy messages with content that aren't function calls or results.
IEnumerable<ChatMessage> messages =
response.Messages.Where(
message =>
!string.IsNullOrEmpty(message.Text) &&
!message.Contents.OfType<FunctionCallContent>().Any() &&
!message.Contents.OfType<FunctionResultContent>().Any());
foreach (ChatMessage message in messages)
foreach (ChatMessage message in response.Messages)
{
await agentProvider.CreateMessageAsync(workflowConversationId, message, cancellationToken).ConfigureAwait(false);
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -43,16 +44,28 @@ internal static class JsonDocumentExtensions
private static Dictionary<string, object?> ParseRecord(this JsonElement currentElement, VariableType targetType)
{
if (targetType.Schema is null)
{
throw new DeclarativeActionException($"Object schema not defined for. {targetType.Type.Name}.");
}
IEnumerable<KeyValuePair<string, object?>> keyValuePairs =
targetType.Schema is null ?
ParseValues() :
ParseSchema(targetType.Schema);
return ParseValues().ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
return keyValuePairs.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
IEnumerable<KeyValuePair<string, object?>> ParseValues()
{
foreach (KeyValuePair<string, VariableType> property in targetType.Schema)
foreach (JsonProperty objectProperty in currentElement.EnumerateObject())
{
if (!objectProperty.Value.TryParseValue(targetType: null, out object? parsedValue))
{
throw new DeclarativeActionException($"Unsupported data type '{objectProperty.Value.ValueKind}' for property '{objectProperty.Name}'");
}
yield return new KeyValuePair<string, object?>(objectProperty.Name, parsedValue);
}
}
IEnumerable<KeyValuePair<string, object?>> ParseSchema(FrozenDictionary<string, VariableType> schema)
{
foreach (KeyValuePair<string, VariableType> property in schema)
{
object? parsedValue = null;
if (!currentElement.TryGetProperty(property.Key, out JsonElement propertyElement))
@@ -131,22 +144,22 @@ internal static class JsonDocumentExtensions
return value;
}
private static bool TryParseValue(this JsonElement propertyElement, VariableType targetType, out object? value) =>
private static bool TryParseValue(this JsonElement propertyElement, VariableType? targetType, out object? value) =>
propertyElement.ValueKind switch
{
JsonValueKind.String => TryParseString(propertyElement, targetType.Type, out value),
JsonValueKind.Number => TryParseNumber(propertyElement, targetType.Type, out value),
JsonValueKind.String => TryParseString(propertyElement, targetType?.Type, out value),
JsonValueKind.Number => TryParseNumber(propertyElement, targetType?.Type, out value),
JsonValueKind.True or JsonValueKind.False => TryParseBoolean(propertyElement, out value),
JsonValueKind.Object => TryParseObject(propertyElement, targetType, out value),
JsonValueKind.Array => TryParseList(propertyElement, targetType, out value),
JsonValueKind.Null => TryParseNull(targetType.Type, out value),
JsonValueKind.Null => TryParseNull(targetType?.Type, out value),
_ => throw new DeclarativeActionException($"JSON element of type {propertyElement.ValueKind} is not supported."),
};
private static bool TryParseNull(Type valueType, out object? value)
private static bool TryParseNull(Type? valueType, out object? value)
{
// If the target type is not nullable, we cannot assign null to it
if (!valueType.IsNullable())
if (valueType?.IsNullable() == false)
{
value = null;
return false;
@@ -170,7 +183,7 @@ internal static class JsonDocumentExtensions
}
}
private static bool TryParseString(JsonElement propertyElement, Type valueType, out object? value)
private static bool TryParseString(JsonElement propertyElement, Type? valueType, out object? value)
{
try
{
@@ -178,23 +191,30 @@ internal static class JsonDocumentExtensions
if (propertyValue is null)
{
value = null;
return valueType.IsNullable(); // Parse fails if value is null and requested type is not.
return valueType?.IsNullable() ?? false; // Parse fails if value is null and requested type is not.
}
switch (valueType)
if (valueType is null)
{
case Type targetType when targetType == typeof(string):
value = propertyValue;
break;
case Type targetType when targetType == typeof(DateTime):
value = DateTime.Parse(propertyValue, provider: null, styles: DateTimeStyles.RoundtripKind);
break;
case Type targetType when targetType == typeof(TimeSpan):
value = TimeSpan.Parse(propertyValue);
break;
default:
value = null;
return false;
value = propertyValue;
}
else
{
switch (valueType)
{
case Type targetType when targetType == typeof(string):
value = propertyValue;
break;
case Type targetType when targetType == typeof(DateTime):
value = DateTime.Parse(propertyValue, provider: null, styles: DateTimeStyles.RoundtripKind);
break;
case Type targetType when targetType == typeof(TimeSpan):
value = TimeSpan.Parse(propertyValue);
break;
default:
value = null;
return false;
}
}
return true;
@@ -206,7 +226,7 @@ internal static class JsonDocumentExtensions
}
}
private static bool TryParseNumber(JsonElement element, Type valueType, out object? value)
private static bool TryParseNumber(JsonElement element, Type? valueType, out object? value)
{
// Try parsing as integer types first (most precise representation)
if (element.TryGetInt32(out int intValue))
@@ -234,8 +254,14 @@ internal static class JsonDocumentExtensions
value = null;
return false;
static bool ConvertToExpectedType(Type valueType, object sourceValue, out object? value)
static bool ConvertToExpectedType(Type? valueType, object sourceValue, out object? value)
{
if (valueType is null)
{
value = sourceValue;
return true;
}
try
{
value = Convert.ChangeType(sourceValue, valueType);
@@ -249,23 +275,17 @@ internal static class JsonDocumentExtensions
}
}
private static bool TryParseObject(JsonElement propertyElement, VariableType targetType, out object? value)
private static bool TryParseObject(JsonElement propertyElement, VariableType? targetType, out object? value)
{
if (!targetType.HasSchema)
{
value = null;
return false;
}
value = propertyElement.ParseRecord(targetType);
value = propertyElement.ParseRecord(targetType ?? VariableType.RecordType);
return true;
}
private static bool TryParseList(JsonElement propertyElement, VariableType targetType, out object? value)
private static bool TryParseList(JsonElement propertyElement, VariableType? targetType, out object? value)
{
try
{
value = ParseTable(propertyElement, targetType);
value = ParseTable(propertyElement, targetType ?? VariableType.ListType);
return true;
}
catch
@@ -77,7 +77,7 @@ internal sealed class DeclarativeWorkflowContext : IWorkflowContext
this.State.Bind();
}
private bool IsManagedScope(string? scopeName) => scopeName is not null && VariableScopeNames.IsValidName(scopeName);
private static bool IsManagedScope(string? scopeName) => scopeName is not null && VariableScopeNames.IsValidName(scopeName);
/// <inheritdoc/>
public async ValueTask<TValue?> ReadStateAsync<TValue>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
@@ -86,7 +86,7 @@ internal sealed class DeclarativeWorkflowContext : IWorkflowContext
{
// Not a managed scope, just pass through. This is valid when a declarative
// workflow has been ejected to code (where DeclarativeWorkflowContext is also utilized).
_ when !this.IsManagedScope(scopeName) => await this.Source.ReadStateAsync<TValue>(key, scopeName, cancellationToken).ConfigureAwait(false),
_ when !IsManagedScope(scopeName) => await this.Source.ReadStateAsync<TValue>(key, scopeName, cancellationToken).ConfigureAwait(false),
// Retrieve formula values directly from the managed state to avoid conversion.
_ when typeof(TValue) == typeof(FormulaValue) => (TValue?)(object?)this.State.Get(key, scopeName),
// Retrieve native types from the source context to avoid conversion.
@@ -100,7 +100,7 @@ internal sealed class DeclarativeWorkflowContext : IWorkflowContext
{
// Not a managed scope, just pass through. This is valid when a declarative
// workflow has been ejected to code (where DeclarativeWorkflowContext is also utilized).
_ when !this.IsManagedScope(scopeName) => await this.Source.ReadOrInitStateAsync(key, initialStateFactory, scopeName, cancellationToken).ConfigureAwait(false),
_ when !IsManagedScope(scopeName) => await this.Source.ReadOrInitStateAsync(key, initialStateFactory, scopeName, cancellationToken).ConfigureAwait(false),
// Retrieve formula values directly from the managed state to avoid conversion.
_ when typeof(TValue) == typeof(FormulaValue) => await EnsureFormulaValueAsync().ConfigureAwait(false),
// Retrieve native types from the source context to avoid conversion.
@@ -239,6 +239,7 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
// Entry point for question
QuestionExecutor action = new(item, this._workflowOptions.AgentProvider, this._workflowState);
this.ContinueWith(action);
// Transition to post action if complete
string postId = Steps.Post(action.Id);
this._workflowModel.AddLink(action.Id, postId, QuestionExecutor.IsComplete);
@@ -249,13 +250,13 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
// Define input action
string inputId = QuestionExecutor.Steps.Input(action.Id);
RequestPortAction inputPort = new(RequestPort.Create<AnswerRequest, AnswerResponse>(inputId));
RequestPortAction inputPort = new(RequestPort.Create<ExternalInputRequest, ExternalInputResponse>(inputId));
this._workflowModel.AddNode(inputPort, action.ParentId);
this._workflowModel.AddLinkFromPeer(action.ParentId, inputId);
// Capture input response
string captureId = QuestionExecutor.Steps.Capture(action.Id);
this.ContinueWith(new DelegateActionExecutor<AnswerResponse>(captureId, this._workflowState, action.CaptureResponseAsync, emitResult: false), action.ParentId);
this.ContinueWith(new DelegateActionExecutor<ExternalInputResponse>(captureId, this._workflowState, action.CaptureResponseAsync, emitResult: false), action.ParentId);
// Transition to post action if complete
this.ContinueWith(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId, QuestionExecutor.IsComplete);
@@ -263,6 +264,24 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
this._workflowModel.AddLink(captureId, prepareId, message => !QuestionExecutor.IsComplete(message));
}
protected override void Visit(RequestExternalInput item)
{
this.Trace(item);
RequestExternalInputExecutor action = new(item, this._workflowOptions.AgentProvider, this._workflowState);
this.ContinueWith(action);
// Define input action
string inputId = RequestExternalInputExecutor.Steps.Input(action.Id);
RequestPortAction inputPort = new(RequestPort.Create<ExternalInputRequest, ExternalInputResponse>(inputId));
this._workflowModel.AddNode(inputPort, action.ParentId);
this._workflowModel.AddLinkFromPeer(action.ParentId, inputId);
// Capture input response
string captureId = RequestExternalInputExecutor.Steps.Capture(action.Id);
this.ContinueWith(new DelegateActionExecutor<ExternalInputResponse>(captureId, this._workflowState, action.CaptureResponseAsync), action.ParentId);
}
protected override void Visit(EndDialog item)
{
this.Trace(item);
@@ -285,6 +304,28 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
this.RestartAfter(action.Id, action.ParentId);
}
protected override void Visit(CancelAllDialogs item)
{
this.Trace(item);
// Represent action with default executor
DefaultActionExecutor action = new(item, this._workflowState);
this.ContinueWith(action);
// Define a clean-start to ensure "end" is not a source for any edge
this.RestartAfter(item.Id.Value, action.ParentId);
}
protected override void Visit(CancelDialog item)
{
this.Trace(item);
// Represent action with default executor
DefaultActionExecutor action = new(item, this._workflowState);
this.ContinueWith(action);
// Define a clean-start to ensure "end" is not a source for any edge
this.RestartAfter(action.Id, action.ParentId);
}
protected override void Visit(CreateConversation item)
{
this.Trace(item);
@@ -318,33 +359,29 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
this._workflowModel.AddLink(action.Id, postId, InvokeAzureAgentExecutor.RequiresNothing);
// Define request-port for function calling action
string functionCallingPortId = InvokeAzureAgentExecutor.Steps.FunctionTool(action.Id);
RequestPortAction functionCallingPort = new(RequestPort.Create<AgentFunctionToolRequest, AgentFunctionToolResponse>(functionCallingPortId));
this._workflowModel.AddNode(functionCallingPort, action.ParentId);
this._workflowModel.AddLink(action.Id, functionCallingPort.Id, InvokeAzureAgentExecutor.RequiresFunctionCall);
// Define request-port for user input, such as: mcp tool & function tool approval
string userInputPortId = InvokeAzureAgentExecutor.Steps.UserInput(action.Id);
RequestPortAction userInputPort = new(RequestPort.Create<UserInputRequest, UserInputResponse>(userInputPortId));
this._workflowModel.AddNode(userInputPort, action.ParentId);
this._workflowModel.AddLink(action.Id, userInputPortId, InvokeAzureAgentExecutor.RequiresUserInput);
string externalInputPortId = InvokeAzureAgentExecutor.Steps.ExternalInput(action.Id);
RequestPortAction externalInputPort = new(RequestPort.Create<ExternalInputRequest, ExternalInputResponse>(externalInputPortId));
this._workflowModel.AddNode(externalInputPort, action.ParentId);
this._workflowModel.AddLink(action.Id, externalInputPortId, InvokeAzureAgentExecutor.RequiresInput);
// Request ports always transitions to resume
string resumeId = InvokeAzureAgentExecutor.Steps.Resume(action.Id);
this._workflowModel.AddNode(new DelegateActionExecutor<AgentFunctionToolResponse>(resumeId, this._workflowState, action.ResumeAsync), action.ParentId);
this._workflowModel.AddLink(functionCallingPortId, resumeId);
this._workflowModel.AddLink(userInputPortId, resumeId);
// Transition to appropriate request port if more function calling is requested
this._workflowModel.AddLink(resumeId, functionCallingPortId, InvokeAzureAgentExecutor.RequiresFunctionCall);
// Transition to appropriate request port if more user input is requested
this._workflowModel.AddLink(resumeId, userInputPortId, InvokeAzureAgentExecutor.RequiresUserInput);
this._workflowModel.AddNode(new DelegateActionExecutor<ExternalInputResponse>(resumeId, this._workflowState, action.ResumeAsync, emitResult: false), action.ParentId);
this._workflowModel.AddLink(externalInputPortId, resumeId);
// Transition to post action if complete
this._workflowModel.AddLink(resumeId, postId, InvokeAzureAgentExecutor.RequiresNothing);
// Transition to request port if more input is required
this._workflowModel.AddLink(resumeId, externalInputPortId, InvokeAzureAgentExecutor.RequiresInput);
// Define post action
this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId);
}
protected override void Visit(InvokeAzureResponse item)
{
this.NotSupported(item);
}
protected override void Visit(RetrieveConversationMessage item)
{
this.Trace(item);
@@ -462,10 +499,6 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
protected override void Visit(ReplaceDialog item) => this.NotSupported(item);
protected override void Visit(CancelAllDialogs item) => this.NotSupported(item);
protected override void Visit(CancelDialog item) => this.NotSupported(item);
protected override void Visit(EmitEvent item) => this.NotSupported(item);
protected override void Visit(GetConversationMembers item) => this.NotSupported(item);
@@ -210,9 +210,11 @@ internal sealed class WorkflowTemplateVisitor : DialogActionVisitor
protected override void Visit(Question item)
{
this.NotSupported(item);
//this.Trace(item);
}
//this.ContinueWith(new QuestionTemplate(item));
protected override void Visit(RequestExternalInput item)
{
this.NotSupported(item);
}
protected override void Visit(EndDialog item)
@@ -237,6 +239,24 @@ internal sealed class WorkflowTemplateVisitor : DialogActionVisitor
this.RestartAfter(action.Id, action.ParentId);
}
protected override void Visit(CancelAllDialogs item)
{
// Represent action with default executor
DefaultTemplate action = new(item, this._rootId);
this.ContinueWith(action);
// Define a clean-start to ensure "end" is not a source for any edge
this.RestartAfter(action.Id, action.ParentId);
}
protected override void Visit(CancelDialog item)
{
// Represent action with default executor
DefaultTemplate action = new(item, this._rootId);
this.ContinueWith(action);
// Define a clean-start to ensure "end" is not a source for any edge
this.RestartAfter(action.Id, action.ParentId);
}
protected override void Visit(CreateConversation item)
{
this.Trace(item);
@@ -265,6 +285,11 @@ internal sealed class WorkflowTemplateVisitor : DialogActionVisitor
this.ContinueWith(new InvokeAzureAgentTemplate(item));
}
protected override void Visit(InvokeAzureResponse item)
{
this.NotSupported(item);
}
protected override void Visit(RetrieveConversationMessage item)
{
this.Trace(item);
@@ -317,17 +342,11 @@ internal sealed class WorkflowTemplateVisitor : DialogActionVisitor
protected override void Visit(EditTable item)
{
this.NotSupported(item);
//this.Trace(item);
//this.ContinueWith(new EditTableTemplate(item));
}
protected override void Visit(EditTableV2 item)
{
this.NotSupported(item);
//this.Trace(item);
//this.ContinueWith(new EditTableV2Template(item));
}
protected override void Visit(ParseValue item)
@@ -384,10 +403,6 @@ internal sealed class WorkflowTemplateVisitor : DialogActionVisitor
protected override void Visit(ReplaceDialog item) => this.NotSupported(item);
protected override void Visit(CancelAllDialogs item) => this.NotSupported(item);
protected override void Visit(CancelDialog item) => this.NotSupported(item);
protected override void Visit(EmitEvent item) => this.NotSupported(item);
protected override void Visit(GetConversationMembers item) => this.NotSupported(item);
@@ -23,7 +23,6 @@ public abstract class AgentExecutor(string id, FormulaSession session, WorkflowA
/// <param name="agentName">The name or identifier of the agent.</param>
/// <param name="conversationId">The identifier of the conversation.</param>
/// <param name="autoSend">Send the agent's response as workflow output. (default: true).</param>
/// <param name="additionalInstructions">Optional additional instructions to the agent.</param>
/// <param name="inputMessages">Optional messages to add to the conversation prior to invocation.</param>
/// <param name="cancellationToken">A token that can be used to observe cancellation.</param>
/// <returns></returns>
@@ -32,8 +31,7 @@ public abstract class AgentExecutor(string id, FormulaSession session, WorkflowA
string agentName,
string? conversationId,
bool autoSend,
string? additionalInstructions = null,
IEnumerable<ChatMessage>? inputMessages = null,
CancellationToken cancellationToken = default)
=> agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, additionalInstructions, inputMessages, cancellationToken);
=> agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, inputMessages, cancellationToken);
}
@@ -4,7 +4,7 @@
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<VersionSuffix>preview</VersionSuffix>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
<NoWarn>$(NoWarn);MEAI001;OPENAI001</NoWarn>
</PropertyGroup>
<PropertyGroup>
@@ -34,8 +34,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI.AzureAI.Persistent\Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
</ItemGroup>
<ItemGroup>
@@ -2,6 +2,7 @@
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;
@@ -21,14 +22,11 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA
{
public static class Steps
{
public static string UserInput(string id) => $"{id}_{nameof(UserInput)}";
public static string FunctionTool(string id) => $"{id}_{nameof(FunctionTool)}";
public static string ExternalInput(string id) => $"{id}_{nameof(ExternalInput)}";
public static string Resume(string id) => $"{id}_{nameof(Resume)}";
}
public static bool RequiresFunctionCall(object? message) => message is AgentFunctionToolRequest;
public static bool RequiresUserInput(object? message) => message is UserInputRequest;
public static bool RequiresInput(object? message) => message is ExternalInputRequest;
public static bool RequiresNothing(object? message) => message is ActionExecutorResult;
@@ -46,8 +44,11 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA
return default;
}
public ValueTask ResumeAsync(IWorkflowContext context, AgentFunctionToolResponse message, CancellationToken cancellationToken) =>
this.InvokeAgentAsync(context, [message.FunctionResults.ToChatMessage()], cancellationToken);
public async ValueTask ResumeAsync(IWorkflowContext context, ExternalInputResponse response, CancellationToken cancellationToken)
{
await context.SetLastMessageAsync(response.Messages.Last()).ConfigureAwait(false);
await this.InvokeAgentAsync(context, response.Messages, cancellationToken).ConfigureAwait(false);
}
public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
{
@@ -58,40 +59,52 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA
{
string? conversationId = this.GetConversationId();
string agentName = this.GetAgentName();
string? additionalInstructions = this.GetAdditionalInstructions();
bool autoSend = this.GetAutoSendValue();
bool isComplete = true;
AgentRunResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, messages, cancellationToken).ConfigureAwait(false);
AgentRunResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, additionalInstructions, messages, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(agentResponse.Text))
ChatMessage[] actionableMessages = FilterActionableContent(agentResponse).ToArray();
if (actionableMessages.Length > 0)
{
// Identify function calls that have no associated result.
List<UserInputRequestContent> inputRequests = GetUserInputRequests(agentResponse);
if (inputRequests.Count > 0)
{
isComplete = false;
UserInputRequest approvalRequest = new(agentName, inputRequests.OfType<AIContent>().ToArray());
await context.SendMessageAsync(approvalRequest, cancellationToken).ConfigureAwait(false);
}
// Identify function calls that have no associated result.
List<FunctionCallContent> functionCalls = GetOrphanedFunctionCalls(agentResponse);
if (functionCalls.Count > 0)
{
isComplete = false;
AgentFunctionToolRequest toolRequest = new(agentName, functionCalls);
await context.SendMessageAsync(toolRequest, cancellationToken).ConfigureAwait(false);
}
}
if (isComplete)
{
await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false);
AgentRunResponse filteredResponse =
new(actionableMessages)
{
AdditionalProperties = agentResponse.AdditionalProperties,
AgentId = agentResponse.AgentId,
CreatedAt = agentResponse.CreatedAt,
ResponseId = agentResponse.ResponseId,
Usage = agentResponse.Usage,
};
await context.SendMessageAsync(new ExternalInputRequest(filteredResponse), cancellationToken).ConfigureAwait(false);
return;
}
await this.AssignAsync(this.AgentOutput?.Messages?.Path, agentResponse.Messages.ToTable(), context).ConfigureAwait(false);
// Attempt to parse the last message as JSON and assign to the response object variable.
try
{
JsonDocument jsonDocument = JsonDocument.Parse(agentResponse.Messages.Last().Text);
Dictionary<string, object?> objectProperties = jsonDocument.ParseRecord(VariableType.RecordType);
await this.AssignAsync(this.AgentOutput?.ResponseObject?.Path, objectProperties.ToFormula(), context).ConfigureAwait(false);
}
catch
{
// Not valid json, skip assignment.
}
if (this.Model.Input?.ExternalLoop?.When is not null)
{
bool requestInput = this.Evaluator.GetValue(this.Model.Input.ExternalLoop.When).Value;
if (requestInput)
{
ExternalInputRequest inputRequest = new(agentResponse);
await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false);
return;
}
}
await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false);
}
private IEnumerable<ChatMessage>? GetInputMessages()
@@ -107,7 +120,7 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA
return userInput?.ToChatMessages();
}
private static List<FunctionCallContent> GetOrphanedFunctionCalls(AgentRunResponse agentResponse)
private static IEnumerable<ChatMessage> FilterActionableContent(AgentRunResponse agentResponse)
{
HashSet<string> functionResultIds =
[.. agentResponse.Messages
@@ -117,21 +130,21 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA
.OfType<FunctionResultContent>()
.Select(functionCall => functionCall.CallId))];
List<FunctionCallContent> functionCalls = [];
foreach (FunctionCallContent functionCall in agentResponse.Messages.SelectMany(m => m.Contents.OfType<FunctionCallContent>()))
foreach (ChatMessage responseMessage in agentResponse.Messages)
{
if (!functionResultIds.Contains(functionCall.CallId))
if (responseMessage.Contents.Any(content => content is UserInputRequestContent))
{
functionCalls.Add(functionCall);
yield return responseMessage;
continue;
}
if (responseMessage.Contents.OfType<FunctionCallContent>().Any(functionCall => !functionResultIds.Contains(functionCall.CallId)))
{
yield return responseMessage;
}
}
return functionCalls;
}
private static List<UserInputRequestContent> GetUserInputRequests(AgentRunResponse agentResponse) =>
agentResponse.Messages.SelectMany(m => m.Contents.OfType<UserInputRequestContent>()).ToList();
private string? GetConversationId()
{
if (this.Model.ConversationId is null)
@@ -149,18 +162,6 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA
this.AgentUsage.Name,
$"{nameof(this.Model)}.{nameof(this.Model.Agent)}.{nameof(this.Model.Agent.Name)}")).Value;
private string? GetAdditionalInstructions()
{
string? additionalInstructions = null;
if (this.AgentInput?.AdditionalInstructions is not null)
{
additionalInstructions = this.Engine.Format(this.AgentInput.AdditionalInstructions);
}
return additionalInstructions;
}
private bool GetAutoSendValue()
{
if (this.AgentOutput?.AutoSend is null)
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Entities;
@@ -75,22 +76,22 @@ internal sealed class QuestionExecutor(Question model, WorkflowAgentProvider age
public async ValueTask PrepareResponseAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
{
int count = await this._promptCount.ReadAsync(context).ConfigureAwait(false);
AnswerRequest inputRequest = new(this.FormatPrompt(this.Model.Prompt));
ExternalInputRequest inputRequest = new(this.FormatPrompt(this.Model.Prompt));
await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false);
await this._promptCount.WriteAsync(context, count + 1).ConfigureAwait(false);
}
public async ValueTask CaptureResponseAsync(IWorkflowContext context, AnswerResponse message, CancellationToken cancellationToken)
public async ValueTask CaptureResponseAsync(IWorkflowContext context, ExternalInputResponse response, CancellationToken cancellationToken)
{
FormulaValue? extractedValue = null;
if (message.Value is null)
if (!response.HasMessages)
{
string unrecognizedResponse = this.FormatPrompt(this.Model.UnrecognizedPrompt);
await context.AddEventAsync(new MessageActivityEvent(unrecognizedResponse.Trim()), cancellationToken).ConfigureAwait(false);
}
else
{
EntityExtractionResult entityResult = EntityExtractor.Parse(this.Model.Entity, message.Value.Text);
EntityExtractionResult entityResult = EntityExtractor.Parse(this.Model.Entity, string.Concat(response.Messages.Select(message => message.Text)));
if (entityResult.IsValid)
{
extractedValue = entityResult.Value;
@@ -121,7 +122,7 @@ internal sealed class QuestionExecutor(Question model, WorkflowAgentProvider age
if (workflowConversationId is not null)
{
// Input message always defined if values has been extracted.
ChatMessage input = message.Value!;
ChatMessage input = response.Messages.Last();
await agentProvider.CreateMessageAsync(workflowConversationId, input, cancellationToken).ConfigureAwait(false);
await context.SetLastMessageAsync(input).ConfigureAwait(false);
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
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.PowerFx;
using Microsoft.Bot.ObjectModel;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
internal sealed class RequestExternalInputExecutor(RequestExternalInput model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state)
: DeclarativeActionExecutor<RequestExternalInput>(model, state)
{
public static class Steps
{
public static string Input(string id) => $"{id}_{nameof(Input)}";
public static string Capture(string id) => $"{id}_{nameof(Capture)}";
}
protected override bool IsDiscreteAction => false;
protected override bool EmitResultEvent => false;
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
ExternalInputRequest inputRequest = new(new AgentRunResponse());
await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false);
return default;
}
public async ValueTask CaptureResponseAsync(IWorkflowContext context, ExternalInputResponse response, CancellationToken cancellationToken)
{
string? workflowConversationId = context.GetWorkflowConversation();
if (workflowConversationId is not null)
{
foreach (ChatMessage inputMessage in response.Messages)
{
await agentProvider.CreateMessageAsync(workflowConversationId, inputMessage, cancellationToken).ConfigureAwait(false);
}
}
await context.SetLastMessageAsync(response.Messages.Last()).ConfigureAwait(false);
await this.AssignAsync(this.Model.Variable?.Path, response.Messages.ToFormula(), context).ConfigureAwait(false);
}
}
@@ -55,7 +55,7 @@ Please refer to the [README](../../samples/GettingStarted/Workflows/Declarative/
|**ConditionItem**|Represents a single conditional statement within a group. It evaluates a specific logical condition and determines the next step in the flow.
|**ContinueLoop**|Skips the remaining steps in the current iteration and continues with the next loop cycle. Commonly used to bypass specific cases without exiting the loop entirely.
|**EndConversation**|Terminates the current conversation session. It ensures any necessary cleanup or final actions are performed before closing.
|**EndDialog**|Ends the current dialog or sub-dialog within a broader conversation flow. This helps modularize complex interactions.
|**EndWorkflow**|Ends the current workflow or sub-workflow within a broader conversation flow. This helps modularize complex interactions.
|**Foreach**|Iterates through a collection of items, executing a set of actions for each. Ideal for processing lists or batch operations.
|**GotoAction**|Jumps directly to a specified action within the workflow. Enables non-linear navigation in the logic flow.
@@ -16,8 +16,8 @@ public abstract class WorkflowAgentProvider
/// <summary>
/// Gets or sets a collection of additional tools an agent is able to automatically invoke.
/// If an agent is configured with a function tool that is not available, a <see cref="RequestPort"/> is executed
/// that provides an <see cref="AgentFunctionToolRequest"/> that describes the function calls requested. The caller may
/// then respond with a corrsponding <see cref="AgentFunctionToolResponse"/> that includes the results of the function calls.
/// that provides an <see cref="ExternalInputRequest"/> that describes the function calls requested. The caller may
/// then respond with a corrsponding <see cref="ExternalInputResponse"/> that includes the results of the function calls.
/// </summary>
/// <remarks>
/// These will not impact the requests sent to the model by the <see cref="FunctionInvokingChatClient"/>.
@@ -56,14 +56,6 @@ public abstract class WorkflowAgentProvider
/// </remarks>
public bool AllowMultipleToolCalls { get; init; }
/// <summary>
/// Asynchronously retrieves an AI agent by its unique identifier.
/// </summary>
/// <param name="agentId">The unique identifier of the AI agent to retrieve. Cannot be null or empty.</param>
/// <param name="cancellationToken">A token that propagates notification when operation should be canceled.</param>
/// <returns>The task result contains the <see cref="AIAgent"/> associated.</returns>
public abstract Task<AIAgent> GetAgentAsync(string agentId, CancellationToken cancellationToken = default);
/// <summary>
/// Asynchronously creates a new conversation and returns its unique identifier.
/// </summary>
@@ -88,6 +80,17 @@ public abstract class WorkflowAgentProvider
/// <returns>The requested message</returns>
public abstract Task<ChatMessage> GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default);
/// <summary>
/// Asynchronously retrieves an AI agent by its unique identifier.
/// </summary>
/// <param name="agentId">The unique identifier of the AI agent to retrieve. Cannot be null or empty.</param>
/// <param name="agentVersion">An optional agent version.</param>
/// <param name="conversationId">Optional identifier of the target conversation.</param>
/// <param name="messages">The messages to include in the invocation.</param>
/// <param name="cancellationToken">A token that propagates notification when operation should be canceled.</param>
/// <returns>Asynchronous set of <see cref="AgentRunResponseUpdate"/>.</returns>
public abstract IAsyncEnumerable<AgentRunResponseUpdate> InvokeAgentAsync(string agentId, string? agentVersion, string? conversationId, IEnumerable<ChatMessage>? messages, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a set of messages from a conversation.
/// </summary>
+11
View File
@@ -0,0 +1,11 @@
# Build Code
Re-usable utility for building C# code in tests.
To use this in your project, add the following to your `.csproj` file:
```xml
<PropertyGroup>
<InjectSharedBuildTestCode>true</InjectSharedBuildTestCode>
</PropertyGroup>
```
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable IDE0005
using System;
using System.Threading.Tasks;
using Azure.AI.Agents;
namespace Shared.Foundry;
internal static class AgentFactory
{
public static async ValueTask<AgentVersion> CreateAgentAsync(
this AgentsClient agentsClient,
string agentName,
PromptAgentDefinition agentDefinition,
string agentDescription)
{
AgentVersionCreationOptions options =
new()
{
Description = agentDescription,
Metadata =
{
{ "deleteme", bool.TrueString },
{ "test", bool.TrueString },
},
};
AgentVersion agentVersion = await agentsClient.CreateAgentVersionAsync(agentName, agentDefinition, options).ConfigureAwait(false);
Console.ForegroundColor = ConsoleColor.Cyan;
try
{
Console.WriteLine($"PROMPT AGENT: {agentVersion.Name}:{agentVersion.Version}");
}
finally
{
Console.ResetColor();
}
return agentVersion;
}
}
@@ -0,0 +1,11 @@
# Foundry Agents
Shared patterns for creating and utilizing Foundry agents.
To use this in your project, add the following to your `.csproj` file:
```xml
<PropertyGroup>
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
</PropertyGroup>
```
@@ -0,0 +1,11 @@
# Workflow Execution
Common support for workflow execution.
To use this in your project, add the following to your `.csproj` file:
```xml
<PropertyGroup>
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
</PropertyGroup>
```
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure.Identity;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Shared.Workflows;
internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
{
public IList<AIFunction> Functions { get; init; } = [];
public IConfiguration? Configuration { get; init; }
// Assign to continue an existing conversation
public string? ConversationId { get; init; }
// Assign to enable logging
public ILoggerFactory LoggerFactory { get; init; } = NullLoggerFactory.Instance;
/// <summary>
/// Create the workflow from the declarative YAML. Includes definition of the
/// <see cref="DeclarativeWorkflowOptions" /> and the associated <see cref="WorkflowAgentProvider"/>.
/// </summary>
public Workflow CreateWorkflow()
{
// Create the agent provider that will service agent requests within the workflow.
AzureAgentProvider agentProvider = new(foundryEndpoint, new AzureCliCredential())
{
// Functions included here will be auto-executed by the framework.
Functions = this.Functions
};
// Define the workflow options.
DeclarativeWorkflowOptions options =
new(agentProvider)
{
Configuration = this.Configuration,
ConversationId = this.ConversationId,
LoggerFactory = this.LoggerFactory,
};
string workflowPath = Path.Combine(AppContext.BaseDirectory, workflowFile);
// Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file.
return DeclarativeWorkflowBuilder.Build<string>(workflowPath, options);
}
}
@@ -0,0 +1,378 @@
// Copyright (c) Microsoft. All rights reserved.
// Uncomment to output unknown content types for debugging.
//#define DEBUG_OUTPUT
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
namespace Shared.Workflows;
// Types are for evaluation purposes only and is subject to change or removal in future updates.
#pragma warning disable OPENAI001
#pragma warning disable OPENAICUA001
#pragma warning disable MEAI001
internal sealed class WorkflowRunner
{
private Dictionary<string, AIFunction> FunctionMap { get; }
private CheckpointInfo? LastCheckpoint { get; set; }
public static void Notify(string message, ConsoleColor? color = null)
{
Console.ForegroundColor = color ?? ConsoleColor.Cyan;
try
{
Console.WriteLine(message);
}
finally
{
Console.ResetColor();
}
}
/// <summary>
/// When enabled, checkpoints will be persisted to disk as JSON files.
/// Otherwise an in-memory checkpoint store that will not persist checkpoints
/// beyond the lifetime of the process.
/// </summary>
public bool UseJsonCheckpoints { get; init; }
public WorkflowRunner(params IEnumerable<AIFunction> functions)
{
this.FunctionMap = functions.ToDictionary(f => f.Name);
}
public async Task ExecuteAsync(Func<Workflow> workflowProvider, string input)
{
Workflow workflow = workflowProvider.Invoke();
CheckpointManager checkpointManager;
if (this.UseJsonCheckpoints)
{
// Use a file-system based JSON checkpoint store to persist checkpoints to disk.
DirectoryInfo checkpointFolder = Directory.CreateDirectory(Path.Combine(".", $"chk-{DateTime.Now:yyMMdd-hhmmss-ff}"));
checkpointManager = CheckpointManager.CreateJson(new FileSystemJsonCheckpointStore(checkpointFolder));
}
else
{
// Use an in-memory checkpoint store that will not persist checkpoints beyond the lifetime of the process.
checkpointManager = CheckpointManager.CreateInMemory();
}
Checkpointed<StreamingRun> run = await InProcessExecution.StreamAsync(workflow, input, checkpointManager).ConfigureAwait(false);
bool isComplete = false;
ExternalResponse? requestResponse = null;
do
{
ExternalRequest? externalRequest = await this.MonitorAndDisposeWorkflowRunAsync(run, requestResponse).ConfigureAwait(false);
if (externalRequest is not null)
{
Notify("\nWORKFLOW: Yield\n", ConsoleColor.DarkYellow);
if (this.LastCheckpoint is null)
{
throw new InvalidOperationException("Checkpoint information missing after external request.");
}
// Process the external request.
object response = await this.HandleExternalRequestAsync(externalRequest).ConfigureAwait(false);
requestResponse = externalRequest.CreateResponse(response);
// Let's resume on an entirely new workflow instance to demonstrate checkpoint portability.
workflow = workflowProvider.Invoke();
// Restore the latest checkpoint.
Debug.WriteLine($"RESTORE #{this.LastCheckpoint.CheckpointId}");
Notify("WORKFLOW: Restore", ConsoleColor.DarkYellow);
run = await InProcessExecution.ResumeStreamAsync(workflow, this.LastCheckpoint, checkpointManager, run.Run.RunId).ConfigureAwait(false);
}
else
{
isComplete = true;
}
}
while (!isComplete);
Notify("\nWORKFLOW: Done!\n");
}
public async Task<ExternalRequest?> MonitorAndDisposeWorkflowRunAsync(Checkpointed<StreamingRun> run, ExternalResponse? response = null)
{
#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task
await using IAsyncDisposable disposeRun = run;
#pragma warning restore CA2007 // Consider calling ConfigureAwait on the awaited task
bool hasStreamed = false;
string? messageId = null;
bool shouldExit = false;
ExternalRequest? externalResponse = null;
if (response is not null)
{
await run.Run.SendResponseAsync(response).ConfigureAwait(false);
}
await foreach (WorkflowEvent workflowEvent in run.Run.WatchStreamAsync().ConfigureAwait(false))
{
switch (workflowEvent)
{
case ExecutorInvokedEvent executorInvoked:
Debug.WriteLine($"EXECUTOR ENTER #{executorInvoked.ExecutorId}");
break;
case ExecutorCompletedEvent executorCompleted:
Debug.WriteLine($"EXECUTOR EXIT #{executorCompleted.ExecutorId}");
break;
case DeclarativeActionInvokedEvent actionInvoked:
Debug.WriteLine($"ACTION ENTER #{actionInvoked.ActionId} [{actionInvoked.ActionType}]");
break;
case DeclarativeActionCompletedEvent actionComplete:
Debug.WriteLine($"ACTION EXIT #{actionComplete.ActionId} [{actionComplete.ActionType}]");
break;
case ExecutorFailedEvent executorFailure:
Debug.WriteLine($"STEP ERROR #{executorFailure.ExecutorId}: {executorFailure.Data?.Message ?? "Unknown"}");
break;
case WorkflowErrorEvent workflowError:
throw workflowError.Data as Exception ?? new InvalidOperationException("Unexpected failure...");
case SuperStepCompletedEvent checkpointCompleted:
this.LastCheckpoint = checkpointCompleted.CompletionInfo?.Checkpoint;
Debug.WriteLine($"CHECKPOINT x{checkpointCompleted.StepNumber} [{this.LastCheckpoint?.CheckpointId ?? "(none)"}]");
if (externalResponse is not null)
{
shouldExit = true;
}
break;
case RequestInfoEvent requestInfo:
Debug.WriteLine($"REQUEST #{requestInfo.Request.RequestId}");
externalResponse = requestInfo.Request;
break;
case ConversationUpdateEvent invokeEvent:
Debug.WriteLine($"CONVERSATION: {invokeEvent.Data}");
break;
case MessageActivityEvent activityEvent:
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("\nACTIVITY:");
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine(activityEvent.Message.Trim());
break;
case AgentRunUpdateEvent streamEvent:
if (!string.Equals(messageId, streamEvent.Update.MessageId, StringComparison.Ordinal))
{
hasStreamed = false;
messageId = streamEvent.Update.MessageId;
if (messageId is not null)
{
string? agentName = streamEvent.Update.AuthorName ?? streamEvent.Update.AgentId ?? nameof(ChatRole.Assistant);
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write($"\n{agentName.ToUpperInvariant()}:");
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($" [{messageId}]");
}
}
ChatResponseUpdate? chatUpdate = streamEvent.Update.RawRepresentation as ChatResponseUpdate;
switch (chatUpdate?.RawRepresentation)
{
case ImageGenerationCallResponseItem messageUpdate:
await DownloadFileContentAsync(Path.GetFileName("response.png"), messageUpdate.ImageResultBytes).ConfigureAwait(false);
break;
case FunctionCallResponseItem actionUpdate:
Console.ForegroundColor = ConsoleColor.White;
Console.Write($"Calling tool: {actionUpdate.FunctionName}");
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($" [{actionUpdate.CallId}]");
break;
case McpToolCallItem actionUpdate:
Console.ForegroundColor = ConsoleColor.White;
Console.Write($"Calling tool: {actionUpdate.ToolName}");
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($" [{actionUpdate.Id}]");
break;
}
try
{
Console.ResetColor();
Console.Write(streamEvent.Update.Text);
hasStreamed |= !string.IsNullOrEmpty(streamEvent.Update.Text);
}
finally
{
Console.ResetColor();
}
break;
case AgentRunResponseEvent messageEvent:
try
{
if (hasStreamed)
{
Console.WriteLine();
}
if (messageEvent.Response.Usage is not null)
{
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($"[Tokens Total: {messageEvent.Response.Usage.TotalTokenCount}, Input: {messageEvent.Response.Usage.InputTokenCount}, Output: {messageEvent.Response.Usage.OutputTokenCount}]");
}
}
finally
{
Console.ResetColor();
}
break;
default:
#if DEBUG_OUTPUT
Debug.WriteLine($"UNHANDLED: {workflowEvent.GetType().Name}");
#endif
break;
}
if (shouldExit)
{
break;
}
}
return externalResponse;
}
/// <summary>
/// Handle request for external input.
/// </summary>
private async ValueTask<ExternalInputResponse> HandleExternalRequestAsync(ExternalRequest request)
{
ExternalInputRequest inputRequest =
request.DataAs<ExternalInputRequest>() ??
throw new InvalidOperationException($"Expected external request type: {request.GetType().Name}.");
List<ChatMessage> responseMessages = [];
foreach (ChatMessage message in inputRequest.AgentResponse.Messages)
{
await foreach (ChatMessage responseMessage in this.ProcessInputMessageAsync(message).ConfigureAwait(false))
{
responseMessages.Add(responseMessage);
}
}
if (responseMessages.Count == 0)
{
// Must be request for user input.
responseMessages.Add(HandleUserInputRequest(inputRequest));
}
Console.WriteLine();
return new ExternalInputResponse(responseMessages);
}
private async IAsyncEnumerable<ChatMessage> ProcessInputMessageAsync(ChatMessage message)
{
foreach (AIContent requestItem in message.Contents)
{
ChatMessage? responseMessage =
requestItem switch
{
FunctionCallContent functionCall => await InvokeFunctionAsync(functionCall).ConfigureAwait(false),
FunctionApprovalRequestContent functionApprovalRequest => ApproveFunction(functionApprovalRequest),
McpServerToolApprovalRequestContent mcpApprovalRequest => ApproveMCP(mcpApprovalRequest),
_ => HandleUnknown(requestItem),
};
if (responseMessage is not null)
{
yield return responseMessage;
}
}
ChatMessage? HandleUnknown(AIContent request)
{
#if DEBUG_OUTPUT
Notify($"INPUT - Unknown: {request.GetType().Name} [{request.RawRepresentation?.GetType().Name ?? "*"}]");
#endif
return null;
}
ChatMessage ApproveFunction(FunctionApprovalRequestContent functionApprovalRequest)
{
Notify($"INPUT - Approving Function: {functionApprovalRequest.FunctionCall.Name}");
return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(approved: true)]);
}
ChatMessage ApproveMCP(McpServerToolApprovalRequestContent mcpApprovalRequest)
{
Notify($"INPUT - Approving MCP: {mcpApprovalRequest.ToolCall.ToolName}");
return new ChatMessage(ChatRole.User, [mcpApprovalRequest.CreateResponse(approved: true)]);
}
async Task<ChatMessage> InvokeFunctionAsync(FunctionCallContent functionCall)
{
Notify($"INPUT - Executing Function: {functionCall.Name}");
AIFunction functionTool = this.FunctionMap[functionCall.Name];
AIFunctionArguments? functionArguments = functionCall.Arguments is null ? null : new(functionCall.Arguments.NormalizePortableValues());
object? result = await functionTool.InvokeAsync(functionArguments).ConfigureAwait(false);
return new ChatMessage(ChatRole.Tool, [new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result))]);
}
}
private static ChatMessage HandleUserInputRequest(ExternalInputRequest request)
{
string prompt =
string.IsNullOrWhiteSpace(request.AgentResponse.Text) || request.AgentResponse.ResponseId is not null ?
"INPUT:" :
request.AgentResponse.Text;
string? userInput;
do
{
Console.ForegroundColor = ConsoleColor.DarkGreen;
Console.Write($"{prompt} ");
Console.ForegroundColor = ConsoleColor.White;
userInput = Console.ReadLine();
}
while (string.IsNullOrWhiteSpace(userInput));
return new ChatMessage(ChatRole.User, userInput);
}
private static async ValueTask DownloadFileContentAsync(string filename, BinaryData content)
{
string filePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(filename));
filePath = Path.ChangeExtension(filePath, ".png");
await File.WriteAllBytesAsync(filePath, content.ToArray()).ConfigureAwait(false);
Process.Start(
new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/C start {filePath}"
});
}
}
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Reflection;
using Microsoft.Extensions.Configuration;
namespace Shared.Workflows;
internal static class Application
{
/// <summary>
/// Configuration key used to identify the Foundry project endpoint.
/// </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 static string GetInput(string[] args)
{
string? input = args.FirstOrDefault();
try
{
Console.ForegroundColor = ConsoleColor.DarkGreen;
Console.Write("\nINPUT: ");
Console.ForegroundColor = ConsoleColor.White;
if (!string.IsNullOrWhiteSpace(input))
{
Console.WriteLine(input);
return input;
}
while (string.IsNullOrWhiteSpace(input))
{
input = Console.ReadLine();
}
return input.Trim();
}
finally
{
Console.ResetColor();
}
}
public static string? GetRepoFolder()
{
DirectoryInfo? current = new(Directory.GetCurrentDirectory());
while (current is not null)
{
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
{
return current.FullName;
}
current = current.Parent;
}
return null;
}
public static string GetValue(this IConfiguration configuration, string settingName) =>
configuration[settingName] ??
throw new InvalidOperationException($"Undefined configuration setting: {settingName}");
/// <summary>
/// Initialize configuration and environment
/// </summary>
public static IConfigurationRoot InitializeConfig() =>
new ConfigurationBuilder()
.AddUserSecrets(Assembly.GetExecutingAssembly())
.AddEnvironmentVariables()
.Build();
}
@@ -0,0 +1,11 @@
# Workflow Settings
Common support configuration and environment used in workflow samples.
To use this in your project, add the following to your `.csproj` file:
```xml
<PropertyGroup>
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
</PropertyGroup>
```