.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
@@ -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.