.NET Workflows - Support "structured inputs" feature for declarative workflows (#2053)

This commit is contained in:
Chris
2025-11-11 10:36:18 -08:00
committed by GitHub
Unverified
parent d1009845c9
commit 39598741e4
34 changed files with 749 additions and 76 deletions
+1
View File
@@ -116,6 +116,7 @@
<Project Path="samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/ExecuteWorkflow.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/FunctionTools/FunctionTools.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/GenerateCode/GenerateCode.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/InputArguments/InputArguments.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/Marketing/Marketing.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/StudentTeacher/StudentTeacher.csproj" />
<Project Path="samples/GettingStarted/Workflows/Declarative/ToolApproval/ToolApproval.csproj" />
@@ -10,7 +10,7 @@ namespace Demo.Workflows.Declarative.ConfirmInput;
/// and confirm it matches the original input.
/// </summary>
/// <remarks>
/// See the README.md file in the parent folder (../Declarative/README.md) for detailed
/// See the README.md file in the parent folder (../README.md) for detailed
/// information the configuration required to run this sample.
/// </remarks>
internal sealed class Program
@@ -14,7 +14,7 @@ namespace Demo.Workflows.Declarative.DeepResearch;
/// using the Magentic orchestration pattern developed by AutoGen.
/// </summary>
/// <remarks>
/// See the README.md file in the parent folder (../Declarative/README.md) for detailed
/// See the README.md file in the parent folder (../README.md) for detailed
/// information the configuration required to run this sample.
/// </remarks>
internal sealed class Program
@@ -15,7 +15,7 @@ namespace Demo.Workflows.Declarative.FunctionTools;
/// with function tools assigned. Exits the loop when the user enters "exit".
/// </summary>
/// <remarks>
/// See the README.md file in the parent folder (../Declarative/README.md) for detailed
/// See the README.md file in the parent folder (../README.md) for detailed
/// information the configuration required to run this sample.
/// </remarks>
internal sealed class Program
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ProjectsDebugTargetFrameworks>net9.0</ProjectsDebugTargetFrameworks>
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="InputArguments.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,97 @@
#
# This workflow demonstrates providing input arguments to an agent.
#
# Example input:
# I'd like to go on vacation.
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_demo
actions:
# Capture the original user message for input to the location-aware agent
- kind: SetVariable
id: set_count_increment
variable: Local.InputMessage
value: =System.LastMessage
# Invoke the triage agent to determine location requirements
- kind: InvokeAzureAgent
id: solicit_input
conversationId: =System.ConversationId
agent:
name: LocationTriageAgent
input:
messages: =Local.ActionMessage
output:
messages: Local.TriageResponse
# Request input from the user based on the triage response
- kind: RequestExternalInput
id: request_requirements
variable: Local.NextInput
# Capture the most recent interaction for evaluation
- kind: SetTextVariable
id: set_status_message
variable: Local.LocationStatusInput
value: |-
AGENT - {MessageText(Local.TriageResponse)}
USER - {MessageText(Local.NextInput)}
# Evaluate the status of the location triage
- kind: InvokeAzureAgent
id: evaluate_location
agent:
name: LocationCaptureAgent
input:
messages: =UserMessage(Local.LocationStatusInput)
output:
responseObject: Local.LocationResponse
# Determine if the location information is complete
- kind: ConditionGroup
id: check_completion
conditions:
- condition: |-
=Local.LocationResponse.is_location_defined = false Or
Local.LocationResponse.is_location_confirmed = false
id: check_done
actions:
# Capture the action message for input to the triage agent
- kind: SetVariable
id: set_next_message
variable: Local.ActionMessage
value: =AgentMessage(Local.LocationResponse.action)
- kind: GotoAction
id: goto_solicit_input
actionId: solicit_input
elseActions:
# Create a new conversation so the prior context does not interfere
- kind: CreateConversation
id: conversation_location
conversationId: Local.LocationConversationId
# Invoke the location-aware agent with the location argument
# and loop until the user types "EXIT"
- kind: InvokeAzureAgent
id: location_response
conversationId: =Local.LocationConversationId
agent:
name: LocationAwareAgent
input:
messages: =Local.InputMessage
arguments:
location: =Local.LocationResponse.place
externalLoop:
when: =Upper(System.LastMessage.Text) <> "EXIT"
output:
autoSend: true
@@ -0,0 +1,147 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure.AI.Agents;
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using OpenAI.Responses;
using Shared.Foundry;
using Shared.Workflows;
namespace Demo.Workflows.Declarative.InputArguments;
/// <summary>
/// Demonstrate a workflow that consumes input arguments to dynamically enhance the agent
/// instructions. Exits the loop when the user enters "exit".
/// </summary>
/// <remarks>
/// See the README.md file in the parent folder (../README.md) for detailed
/// information the configuration required to run this sample.
/// </remarks>
internal sealed class Program
{
public static async Task Main(string[] args)
{
// Initialize configuration
IConfiguration configuration = Application.InitializeConfig();
Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint));
// Ensure sample agents exist in Foundry.
await CreateAgentAsync(foundryEndpoint, configuration);
// Get input from command line or console
string workflowInput = Application.GetInput(args);
// Create the workflow factory. This class demonstrates how to initialize a
// declarative workflow from a YAML file. Once the workflow is created, it
// can be executed just like any regular workflow.
WorkflowFactory workflowFactory = new("InputArguments.yaml", foundryEndpoint);
// Execute the workflow: The WorkflowRunner demonstrates how to execute
// a workflow, handle the workflow events, and providing external input.
// This also includes the ability to checkpoint workflow state and how to
// resume execution.
WorkflowRunner runner = new();
await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput);
}
private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration)
{
AgentClient agentsClient = new(foundryEndpoint, new AzureCliCredential());
await agentsClient.CreateAgentAsync(
agentName: "LocationTriageAgent",
agentDefinition: DefineLocationTriageAgent(configuration),
agentDescription: "Chats with the user to solicit a location of interest.");
await agentsClient.CreateAgentAsync(
agentName: "LocationCaptureAgent",
agentDefinition: DefineLocationCaptureAgent(configuration),
agentDescription: "Evaluate the status of soliciting the location.");
await agentsClient.CreateAgentAsync(
agentName: "LocationAwareAgent",
agentDefinition: DefineLocationAwareAgent(configuration),
agentDescription: "Chats with the user with location awareness.");
}
private static PromptAgentDefinition DefineLocationTriageAgent(IConfiguration configuration) =>
new(configuration.GetValue(Application.Settings.FoundryModelMini))
{
Instructions =
"""
Your only job is to solicit a location from the user.
Always repeat back the location when addressing the user, except when it is not known.
"""
};
private static PromptAgentDefinition DefineLocationCaptureAgent(IConfiguration configuration) =>
new(configuration.GetValue(Application.Settings.FoundryModelMini))
{
Instructions =
"""
Request a location from the user. This location could be their own location
or perhaps a location they are interested in.
City level precision is sufficient.
If extrapolating region and country, confirm you have it right.
""",
TextOptions =
new ResponseTextOptions
{
TextFormat =
ResponseTextFormat.CreateJsonSchemaFormat(
"TaskEvaluation",
BinaryData.FromString(
"""
{
"type": "object",
"properties": {
"place": {
"type": "string",
"description": "Captures only your understanding of the location specified by the user without explanation, or 'unknown' if not yet defined."
},
"action": {
"type": "string",
"description": "The instruction for the next action to take regarding the need for additional detail or confirmation."
},
"is_location_defined": {
"type": "boolean",
"description": "True if the user location is understood."
},
"is_location_confirmed": {
"type": "boolean",
"description": "True if the user location is confirmed. An unambiguous location may be implicitly confirmed without explicit user confirmation."
}
},
"required": ["place", "action", "is_location_defined", "is_location_confirmed"],
"additionalProperties": false
}
"""),
jsonSchemaFormatDescription: null,
jsonSchemaIsStrict: true),
}
};
private static PromptAgentDefinition DefineLocationAwareAgent(IConfiguration configuration) =>
new(configuration.GetValue(Application.Settings.FoundryModelMini))
{
// Parameterized instructions reference the "location" input argument.
Instructions =
"""
Talk to the user about their request.
Their request is related to a specific location: {{location}}.
""",
StructuredInputs =
{
["location"] =
new StructuredInputDefinition
{
IsRequired = false,
DefaultValue = BinaryData.FromString(@"""unknown"""),
Description = "The user's location",
}
}
};
}
@@ -13,7 +13,7 @@ namespace Demo.Workflows.Declarative.Marketing;
/// sequentially engaging in a task.
/// </summary>
/// <remarks>
/// See the README.md file in the parent folder (../Declarative/README.md) for detailed
/// See the README.md file in the parent folder (../README.md) for detailed
/// information the configuration required to run this sample.
/// </remarks>
internal sealed class Program
@@ -13,7 +13,7 @@ namespace Demo.Workflows.Declarative.StudentTeacher;
/// in an iterative conversation.
/// </summary>
/// <remarks>
/// See the README.md file in the parent folder (../Declarative/README.md) for detailed
/// See the README.md file in the parent folder (../README.md) for detailed
/// information the configuration required to run this sample.
/// </remarks>
internal sealed class Program
@@ -14,7 +14,7 @@ namespace Demo.Workflows.Declarative.ToolApproval;
/// has an MCP tool that requires approval. Exits the loop when the user enters "exit".
/// </summary>
/// <remarks>
/// See the README.md file in the parent folder (../Declarative/README.md) for detailed
/// See the README.md file in the parent folder (../README.md) for detailed
/// information the configuration required to run this sample.
/// </remarks>
internal sealed class Program
@@ -7,10 +7,12 @@ using System.Collections.ObjectModel;
using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Agents;
using Azure.Core;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
@@ -32,6 +34,11 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
private AgentClient? _agentClient;
private ConversationClient? _conversationClient;
/// <summary>
/// Optional options used when creating the <see cref="AgentClient"/>.
/// </summary>
public AgentClientOptions? ClientOptions { get; init; }
/// <inheritdoc/>
public override async Task<string> CreateConversationAsync(CancellationToken cancellationToken = default)
{
@@ -78,6 +85,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
string? agentVersion,
string? conversationId,
IEnumerable<ChatMessage>? messages,
IDictionary<string, object?>? inputArguments,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
AgentVersion agentVersionResult = await this.QueryAgentAsync(agentId, agentVersion, cancellationToken).ConfigureAwait(false);
@@ -90,6 +98,14 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
AllowMultipleToolCalls = this.AllowMultipleToolCalls,
};
if (inputArguments is not null)
{
JsonNode jsonNode = inputArguments.ToFormula().ToJson();
ResponseCreationOptions responseCreationOptions = new();
responseCreationOptions.SetStructuredInputs(BinaryData.FromString(jsonNode.ToJsonString()));
chatOptions.RawRepresentationFactory = (_) => responseCreationOptions;
}
ChatClientAgentRunOptions runOptions = new(chatOptions);
IAsyncEnumerable<AgentRunResponseUpdate> agentResponse =
@@ -206,7 +222,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
{
if (this._agentClient is null)
{
AgentClientOptions clientOptions = new();
AgentClientOptions clientOptions = this.ClientOptions ?? new();
if (httpClient is not null)
{
@@ -17,9 +17,10 @@ internal static class AgentProviderExtensions
string? conversationId,
bool autoSend,
IEnumerable<ChatMessage>? inputMessages = null,
IDictionary<string, object?>? inputArguments = null,
CancellationToken cancellationToken = default)
{
IAsyncEnumerable<AgentRunResponseUpdate> agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, cancellationToken);
IAsyncEnumerable<AgentRunResponseUpdate> agentUpdates = agentProvider.InvokeAgentAsync(agentName, null, conversationId, inputMessages, inputArguments, cancellationToken);
// Enable "autoSend" behavior if this is the workflow conversation.
bool isWorkflowConversation = context.IsWorkflowConversation(conversationId, out string? workflowConversationId);
@@ -163,6 +163,24 @@ internal static class FormulaValueExtensions
}
}
}
public static JsonNode ToJson(this FormulaValue value) =>
value switch
{
BooleanValue booleanValue => JsonValue.Create(booleanValue.Value),
DecimalValue decimalValue => JsonValue.Create(decimalValue.Value),
NumberValue numberValue => JsonValue.Create(numberValue.Value),
DateValue dateValue => JsonValue.Create(dateValue.GetConvertedValue(TimeZoneInfo.Utc)),
DateTimeValue datetimeValue => JsonValue.Create(datetimeValue.GetConvertedValue(TimeZoneInfo.Utc)),
TimeValue timeValue => JsonValue.Create($"{timeValue.Value}"),
StringValue stringValue => JsonValue.Create(stringValue.Value),
GuidValue guidValue => JsonValue.Create(guidValue.Value),
RecordValue recordValue => recordValue.ToJson(),
TableValue tableValue => tableValue.ToJson(),
BlankValue => JsonValue.Create(string.Empty),
_ => $"[{value.GetType().Name}]",
};
public static RecordValue ToRecord(this Dictionary<string, PortableValue> value) =>
FormulaValue.NewRecordFromFields(
value.Select(
@@ -256,23 +274,6 @@ internal static class FormulaValueExtensions
private static KeyValuePair<string, DataValue> GetKeyValuePair(this NamedValue value) => new(value.Name, value.Value.ToDataValue());
private static JsonNode ToJson(this FormulaValue value) =>
value switch
{
BooleanValue booleanValue => JsonValue.Create(booleanValue.Value),
DecimalValue decimalValue => JsonValue.Create(decimalValue.Value),
NumberValue numberValue => JsonValue.Create(numberValue.Value),
DateValue dateValue => JsonValue.Create(dateValue.GetConvertedValue(TimeZoneInfo.Utc)),
DateTimeValue datetimeValue => JsonValue.Create(datetimeValue.GetConvertedValue(TimeZoneInfo.Utc)),
TimeValue timeValue => JsonValue.Create($"{timeValue.Value}"),
StringValue stringValue => JsonValue.Create(stringValue.Value),
GuidValue guidValue => JsonValue.Create(guidValue.Value),
RecordValue recordValue => recordValue.ToJson(),
TableValue tableValue => tableValue.ToJson(),
BlankValue => JsonValue.Create(string.Empty),
_ => $"[{value.GetType().Name}]",
};
private static JsonArray ToJson(this TableValue value)
{
return new([.. GetJsonElements()]);
@@ -33,5 +33,5 @@ public abstract class AgentExecutor(string id, FormulaSession session, WorkflowA
bool autoSend,
IEnumerable<ChatMessage>? inputMessages = null,
CancellationToken cancellationToken = default)
=> agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, inputMessages, cancellationToken);
=> agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, inputMessages, inputArguments: null, cancellationToken);
}
@@ -60,8 +60,8 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA
string? conversationId = this.GetConversationId();
string agentName = this.GetAgentName();
bool autoSend = this.GetAutoSendValue();
AgentRunResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, messages, cancellationToken).ConfigureAwait(false);
Dictionary<string, object?>? inputParameters = this.GetStructuredInputs();
AgentRunResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, messages, inputParameters, cancellationToken).ConfigureAwait(false);
ChatMessage[] actionableMessages = FilterActionableContent(agentResponse).ToArray();
if (actionableMessages.Length > 0)
@@ -107,6 +107,23 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA
await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false);
}
private Dictionary<string, object?>? GetStructuredInputs()
{
Dictionary<string, object?>? inputs = null;
if (this.AgentInput?.Arguments is not null)
{
inputs = [];
foreach (KeyValuePair<string, ValueExpression> argument in this.AgentInput.Arguments)
{
inputs[argument.Key] = this.Evaluator.GetValue(argument.Value).Value.ToObject();
}
}
return inputs;
}
private IEnumerable<ChatMessage>? GetInputMessages()
{
DataValue? userInput = null;
@@ -8,7 +8,6 @@ using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Bot.ObjectModel;
using Microsoft.Bot.ObjectModel.Abstractions;
using Microsoft.PowerFx.Types;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
@@ -17,17 +16,15 @@ internal sealed class SetVariableExecutor(SetVariable model, WorkflowFormulaStat
{
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
PropertyPath variablePath = Throw.IfNull(this.Model.Variable?.Path, $"{nameof(this.Model)}.{nameof(model.Variable)}");
if (this.Model.Value is null)
{
await this.AssignAsync(variablePath, FormulaValue.NewBlank(), context).ConfigureAwait(false);
await this.AssignAsync(this.Model.Variable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false);
}
else
{
EvaluationResult<DataValue> expressionResult = this.Evaluator.GetValue(this.Model.Value);
await this.AssignAsync(variablePath, expressionResult.Value.ToFormula(), context).ConfigureAwait(false);
await this.AssignAsync(this.Model.Variable?.Path, expressionResult.Value.ToFormula(), context).ConfigureAwait(false);
}
return default;
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions;
internal sealed class AgentMessage : MessageFunction
{
public const string FunctionName = nameof(AgentMessage);
public AgentMessage() : base(FunctionName) { }
public static FormulaValue Execute(StringValue input) => Create(ChatRole.Assistant, input);
}
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions;
internal abstract class MessageFunction : ReflectionFunction
{
protected MessageFunction(string functionName)
: base(functionName, FormulaType.String, FormulaType.String)
{ }
protected static FormulaValue Create(ChatRole role, StringValue input) =>
string.IsNullOrEmpty(input.Value) ?
FormulaValue.NewBlank(RecordType.Empty()) :
FormulaValue.NewRecordFromFields(
new NamedValue(TypeSchema.Discriminator, nameof(ChatMessage).ToFormula()),
new NamedValue(TypeSchema.Message.Fields.Role, FormulaValue.New(role.Value)),
new NamedValue(
TypeSchema.Message.Fields.Content,
FormulaValue.NewTable(
RecordType.Empty()
.Add(TypeSchema.Message.Fields.ContentType, FormulaType.String)
.Add(TypeSchema.Message.Fields.ContentValue, FormulaType.String),
[
FormulaValue.NewRecordFromFields(
new NamedValue(TypeSchema.Message.Fields.ContentType, FormulaValue.New(TypeSchema.Message.ContentTypes.Text)),
new NamedValue(TypeSchema.Message.Fields.ContentValue, input))
]
)
)
);
}
@@ -0,0 +1,54 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.PowerFx;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions;
internal static class MessageText
{
public const string FunctionName = nameof(MessageText);
public sealed class StringInput()
: ReflectionFunction(FunctionName, FormulaType.String, FormulaType.String)
{
public static FormulaValue Execute(StringValue input) => input;
}
public sealed class RecordInput() : ReflectionFunction(FunctionName, FormulaType.String, RecordType.Empty())
{
public static FormulaValue Execute(RecordValue input) => FormulaValue.New(GetTextFromRecord(input));
}
public sealed class TableInput() : ReflectionFunction(FunctionName, FormulaType.String, TableType.Empty())
{
public static FormulaValue Execute(TableValue tableValue)
{
return FormulaValue.New(string.Join("\n", GetText()));
IEnumerable<string> GetText()
{
foreach (DValue<RecordValue> row in tableValue.Rows)
{
string text = GetTextFromRecord(row.Value);
if (!string.IsNullOrWhiteSpace(text))
{
yield return text;
}
}
}
}
}
private static string GetTextFromRecord(RecordValue recordValue)
{
FormulaValue textValue = recordValue.GetField(TypeSchema.Message.Fields.Text);
return textValue switch
{
StringValue stringValue => stringValue.Value.Trim(),
_ => string.Empty,
};
}
}
@@ -1,38 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions;
internal sealed class UserMessage : ReflectionFunction
internal sealed class UserMessage : MessageFunction
{
public const string FunctionName = nameof(UserMessage);
public UserMessage()
: base(FunctionName, FormulaType.String, FormulaType.String)
{ }
public UserMessage() : base(FunctionName) { }
public static FormulaValue Execute(StringValue input) =>
string.IsNullOrEmpty(input.Value) ?
FormulaValue.NewBlank(RecordType.Empty()) :
FormulaValue.NewRecordFromFields(
new NamedValue(TypeSchema.Discriminator, nameof(ChatMessage).ToFormula()),
new NamedValue(TypeSchema.Message.Fields.Role, FormulaValue.New(ChatRole.User.Value)),
new NamedValue(
TypeSchema.Message.Fields.Content,
FormulaValue.NewTable(
RecordType.Empty()
.Add(TypeSchema.Message.Fields.ContentType, FormulaType.String)
.Add(TypeSchema.Message.Fields.ContentValue, FormulaType.String),
[
FormulaValue.NewRecordFromFields(
new NamedValue(TypeSchema.Message.Fields.ContentType, FormulaValue.New(TypeSchema.Message.ContentTypes.Text)),
new NamedValue(TypeSchema.Message.Fields.ContentValue, input))
]
)
)
);
public static FormulaValue Execute(StringValue input) => Create(ChatRole.User, input);
}
@@ -38,7 +38,11 @@ internal static class RecalcEngineFactory
}
config.EnableSetFunction();
config.AddFunction(new AgentMessage());
config.AddFunction(new UserMessage());
config.AddFunction(new MessageText.StringInput());
config.AddFunction(new MessageText.RecordInput());
config.AddFunction(new MessageText.TableInput());
return config;
}
@@ -87,9 +87,16 @@ public abstract class WorkflowAgentProvider
/// <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="inputArguments">Optional input arguments for agents that provide support.</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);
public abstract IAsyncEnumerable<AgentRunResponseUpdate> InvokeAgentAsync(
string agentId,
string? agentVersion,
string? conversationId,
IEnumerable<ChatMessage>? messages,
IDictionary<string, object?>? inputArguments,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a set of messages from a conversation.
@@ -15,6 +15,7 @@ internal abstract class AgentProvider(IConfiguration configuration)
public const string FunctionTool = "FUNCTIONTOOL";
public const string Marketing = "MARKETING";
public const string MathChat = "MATHCHAT";
public const string InputArguments = "INPUTARGUMENTS";
}
public static class Settings
@@ -31,6 +32,7 @@ internal abstract class AgentProvider(IConfiguration configuration)
Names.FunctionTool => new FunctionToolAgentProvider(configuration),
Names.Marketing => new MarketingAgentProvider(configuration),
Names.MathChat => new MathChatAgentProvider(configuration),
Names.InputArguments => new PoemAgentProvider(configuration),
_ => new TestAgentProvider(configuration),
};
@@ -40,7 +42,7 @@ internal abstract class AgentProvider(IConfiguration configuration)
await foreach (AgentVersion agent in this.CreateAgentsAsync(foundryEndpoint))
{
Console.WriteLine($"Created agent: {agent.Name}:{agent.Version})");
Console.WriteLine($"Created agent: {agent.Name}:{agent.Version}");
}
}
@@ -0,0 +1,43 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using Azure.AI.Agents;
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Shared.Foundry;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
internal sealed class PoemAgentProvider(IConfiguration configuration) : AgentProvider(configuration)
{
protected override async IAsyncEnumerable<AgentVersion> CreateAgentsAsync(Uri foundryEndpoint)
{
AgentClient agentClient = new(foundryEndpoint, new AzureCliCredential());
yield return
await agentClient.CreateAgentAsync(
agentName: "PoemAgent",
agentDefinition: this.DefinePoemAgent(),
agentDescription: "Authors original poems");
}
private PromptAgentDefinition DefinePoemAgent() =>
new(this.GetSetting(Settings.FoundryModelMini))
{
Instructions =
"""
Write a one verse poem on the requested topic in the style of: {{style}}.
""",
StructuredInputs =
{
["style"] =
new StructuredInputDefinition
{
IsRequired = false,
DefaultValue = BinaryData.FromString(@"""haiku"""),
Description = "The style of poem to write",
}
}
};
}
@@ -17,11 +17,12 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
{
[Theory]
[InlineData("CheckSystem.yaml", "CheckSystem.json")]
[InlineData("SendActivity.yaml", "SendActivity.json")]
[InlineData("InvokeAgent.yaml", "InvokeAgent.json")]
[InlineData("InvokeAgent.yaml", "InvokeAgent.json", true)]
[InlineData("ConversationMessages.yaml", "ConversationMessages.json")]
[InlineData("ConversationMessages.yaml", "ConversationMessages.json", true)]
[InlineData("InputArguments.yaml", "InputArguments.json")]
[InlineData("InvokeAgent.yaml", "InvokeAgent.json")]
[InlineData("InvokeAgent.yaml", "InvokeAgent.json", true)]
[InlineData("SendActivity.yaml", "SendActivity.json")]
public Task ValidateCaseAsync(string workflowFileName, string testcaseFileName, bool externalConveration = false) =>
this.RunWorkflowAsync(GetWorkflowPath(workflowFileName, isSample: false), testcaseFileName, externalConveration);
@@ -0,0 +1,23 @@
{
"description": "Authors a poem in the style specified by the input argument.",
"setup": {
"input": {
"type": "String",
"value": "Why is the sky blue?"
}
},
"validation": {
"conversation_count": 1,
"min_action_count": 1,
"min_response_count": 1,
"min_message_count": 3,
"actions": {
"start": [
"invoke_poem"
],
"final": [
"invoke_poem"
]
}
}
}
@@ -13,7 +13,7 @@
"min_response_count": 2,
"max_response_count": 8,
"min_message_count": 4,
"max_message_count": 17,
"max_message_count": -1,
"actions": {
"start": [
],
@@ -0,0 +1,15 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_test
actions:
- kind: InvokeAzureAgent
id: invoke_poem
conversationId: =System.ConversationId
agent:
name: PoemAgent
input:
arguments:
style: "ee cummings"
@@ -0,0 +1,68 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.PowerFx.Functions;
public sealed class AgentMessageTests
{
[Fact]
public void Construct_Function()
{
AgentMessage function = new();
Assert.NotNull(function);
}
[Fact]
public void Execute_ReturnsBlank_ForEmptyInput()
{
// Arrange
StringValue sourceValue = FormulaValue.New(string.Empty);
// Act
FormulaValue result = AgentMessage.Execute(sourceValue);
// Assert
Assert.IsType<BlankValue>(result);
}
[Fact]
public void Execute_ReturnsExpectedRecord_ForNonEmptyInput()
{
const string Text = "Hello";
FormulaValue sourceValue = FormulaValue.New(Text);
StringValue stringValue = Assert.IsType<StringValue>(sourceValue);
FormulaValue result = AgentMessage.Execute(stringValue);
RecordValue recordResult = Assert.IsType<RecordValue>(result, exactMatch: false);
// Discriminator
FormulaValue discriminator = recordResult.GetField(TypeSchema.Discriminator);
StringValue discriminatorValue = Assert.IsType<StringValue>(discriminator);
Assert.Equal(nameof(ChatMessage), discriminatorValue.Value);
// Role
FormulaValue role = recordResult.GetField(TypeSchema.Message.Fields.Role);
StringValue roleValue = Assert.IsType<StringValue>(role);
Assert.Equal(ChatRole.Assistant.Value, roleValue.Value);
// Content table
FormulaValue content = recordResult.GetField(TypeSchema.Message.Fields.Content);
TableValue table = Assert.IsType<TableValue>(content, exactMatch: false);
List<RecordValue> rows = table.Rows.Select(value => value.Value).ToList();
Assert.Single(rows);
StringValue contentType = Assert.IsType<StringValue>(rows[0].GetField(TypeSchema.Message.Fields.ContentType));
Assert.Equal(TypeSchema.Message.ContentTypes.Text, contentType.Value);
StringValue contentValue = Assert.IsType<StringValue>(rows[0].GetField(TypeSchema.Message.Fields.ContentValue));
Assert.Equal(Text, contentValue.Value);
}
}
@@ -0,0 +1,113 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx.Functions;
using Microsoft.Extensions.AI;
using Microsoft.PowerFx.Types;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.PowerFx.Functions;
public sealed class MessageTextTests
{
[Fact]
public void Construct_Function()
{
MessageText.StringInput function1 = new();
Assert.NotNull(function1);
MessageText.RecordInput function2 = new();
Assert.NotNull(function2);
MessageText.TableInput function3 = new();
Assert.NotNull(function3);
}
[Fact]
public void Execute_ReturnsEmpty_ForEmptyInput()
{
// Arrange
StringValue sourceValue = FormulaValue.New(string.Empty);
// Act
FormulaValue result = MessageText.StringInput.Execute(sourceValue);
// Assert
StringValue stringResult = Assert.IsType<StringValue>(result);
Assert.Empty(stringResult.Value);
}
[Fact]
public void Execute_ReturnsText_ForStringInput()
{
// Arrange
StringValue sourceValue = FormulaValue.New("wowsie");
// Act
FormulaValue result = MessageText.StringInput.Execute(sourceValue);
// Assert
StringValue stringResult = Assert.IsType<StringValue>(result);
Assert.Equal(sourceValue.Value, stringResult.Value);
}
[Fact]
public void Execute_ReturnsText_ForMessageInput()
{
// Arrange
RecordValue sourceValue = new ChatMessage(ChatRole.User, "test message").ToRecord();
// Act
FormulaValue result = MessageText.RecordInput.Execute(sourceValue);
// Assert
StringValue stringResult = Assert.IsType<StringValue>(result);
Assert.Equal("test message", stringResult.Value);
}
[Fact]
public void Execute_ReturnsEmpty_ForUnknownInput()
{
// Arrange
RecordValue sourceValue = FormulaValue.NewRecordFromFields(new NamedValue("Anything", FormulaValue.New(333)));
// Act
FormulaValue result = MessageText.RecordInput.Execute(sourceValue);
// Assert
StringValue stringResult = Assert.IsType<StringValue>(result);
Assert.Empty(stringResult.Value);
}
[Fact]
public void Execute_ReturnsText_ForMessagesInput()
{
// Arrange
TableValue sourceValue = new ChatMessage[]
{
new(ChatRole.User, "test message 1"),
new(ChatRole.User, "test message 2"),
}.ToTable();
// Act
FormulaValue result = MessageText.TableInput.Execute(sourceValue);
// Assert
StringValue stringResult = Assert.IsType<StringValue>(result);
Assert.Equal("test message 1\ntest message 2", stringResult.Value);
}
[Fact]
public void Execute_ReturnsEmpty_ForEmptyList()
{
// Arrange
TableValue sourceValue = Array.Empty<ChatMessage>().ToTable();
// Act
FormulaValue result = MessageText.TableInput.Execute(sourceValue);
// Assert
StringValue stringResult = Assert.IsType<StringValue>(result);
Assert.Empty(stringResult.Value);
}
}
@@ -22,11 +22,10 @@ public class UserMessageTests
public void Execute_ReturnsBlank_ForEmptyInput()
{
// Arrange
FormulaValue sourceValue = FormulaValue.New(string.Empty);
StringValue stringValue = Assert.IsType<StringValue>(sourceValue);
StringValue sourceValue = FormulaValue.New(string.Empty);
// Act
FormulaValue result = UserMessage.Execute(stringValue);
FormulaValue result = UserMessage.Execute(sourceValue);
// Assert
Assert.IsType<BlankValue>(result);
+6 -6
View File
@@ -119,13 +119,13 @@ trigger:
# FACTS
Consider this initial fact sheet:
{Trim(Last(Local.TaskFacts).Text)}
{MessageText(Local.TaskFacts)}
# PLAN
Here is the plan to follow as best as possible:
{Last(Local.Plan).Text}
{MessageText(Local.Plan)}
- kind: SendActivity
id: sendActivity_bwNZiM
@@ -247,7 +247,7 @@ trigger:
Here is the old fact sheet:
{Local.TaskFacts}"
{MessageText(Local.TaskFacts)}"
- kind: SendActivity
id: sendActivity_dsBaJU
@@ -291,13 +291,13 @@ trigger:
# FACTS
Consider this initial fact sheet:
{Local.TaskFacts.Text}
{MessageText(Local.TaskFacts)}
# PLAN
Here is the plan to follow as best as possible:
{Local.Plan.Text}
{MessageText(Local.Plan)}
- kind: SetVariable
id: setVariable_6J2snP
@@ -356,7 +356,7 @@ trigger:
- kind: SetVariable
id: setVariable_XzNrdM
variable: Local.AgentResponseText
value: =Last(Local.AgentResponse).Text
value: =MessageText(Local.AgentResponse)
- kind: ResetVariable
id: setVariable_8eIx2A
+1 -1
View File
@@ -35,7 +35,7 @@ trigger:
id: check_completion
conditions:
- condition: =!IsBlank(Find("CONGRATULATIONS", Upper(Last(Local.TeacherResponse).Text)))
- condition: =!IsBlank(Find("CONGRATULATIONS", Upper(MessageText(Local.TeacherResponse))))
id: check_turn_done
actions:
+1 -1
View File
@@ -6,7 +6,7 @@ may be executed locally no different from any regular `Workflow` that is defined
The difference is that the workflow definition is loaded from a YAML file instead of being defined in code:
```c#
Workflow<string> workflow = DeclarativeWorkflowBuilder.Build<string>("Marketing.yaml", options);
Workflow workflow = DeclarativeWorkflowBuilder.Build("Marketing.yaml", options);
```
These example workflows may be executed by the workflow