.NET Workflows - Support agent level function invocation for declarative workflow (#1442)

* Checkpoint

* Checkpoint

* Checkpoint

* Good

* Namespace

* Namespace

* Dun

* Async Test

* AgentId

* Portable pattern

* Portable2

* Portable3

* Respond to comments

* Namespace

* Function call selection

* ToHashSet

* ToHashSet

* Updated

* Parameter name

* Final

* Tests
This commit is contained in:
Chris
2025-10-14 13:42:15 -07:00
committed by GitHub
Unverified
parent a4039134de
commit a6b6937b94
25 changed files with 902 additions and 80 deletions
@@ -1,16 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
// Uncomment this to enable JSON checkpointing to the local file system.
#define CHECKPOINT_JSON
//#define CHECKPOINT_JSON
using System.Diagnostics;
using System.Reflection;
using System.Text.Json;
using Azure.AI.Agents.Persistent;
using Azure.Identity;
using Microsoft.Agents.AI.Workflows;
#if CHECKPOINT_JSON
using Microsoft.Agents.AI.Workflows.Checkpointing;
#endif
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 Microsoft.Extensions.Configuration;
@@ -63,20 +67,21 @@ internal sealed class Program
#if CHECKPOINT_JSON
// 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}"));
DirectoryInfo checkpointFolder = Directory.CreateDirectory(Path.Combine(".", $"chk-{DateTime.Now:yyMMdd-hhmmss-ff}"));
CheckpointManager checkpointManager = CheckpointManager.CreateJson(new FileSystemJsonCheckpointStore(checkpointFolder));
Checkpointed<StreamingRun> run = await InProcessExecution.StreamAsync(workflow, input, checkpointManager);
#else
// Use an in-memory checkpoint store that will not persist checkpoints beyond the lifetime of the process.
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
#endif
Checkpointed<StreamingRun> run = await InProcessExecution.StreamAsync(workflow, input, checkpointManager);
bool isComplete = false;
InputResponse? response = null;
object? response = null;
do
{
ExternalRequest? inputRequest = await this.MonitorAndDisposeWorkflowRunAsync(run, response);
if (inputRequest is not null)
ExternalRequest? externalRequest = await this.MonitorAndDisposeWorkflowRunAsync(run, response);
if (externalRequest is not null)
{
Notify("\nWORKFLOW: Yield");
@@ -86,7 +91,7 @@ internal sealed class Program
}
// Process the external request.
response = HandleExternalRequest(inputRequest);
response = await this.HandleExternalRequestAsync(externalRequest);
// Let's resume on an entirely new workflow instance to demonstrate checkpoint portability.
workflow = this.CreateWorkflow();
@@ -107,11 +112,25 @@ internal sealed class Program
Notify("\nWORKFLOW: Done!\n");
}
/// <summary>
/// Create the workflow from the declarative YAML. Includes definition of the
/// <see cref="DeclarativeWorkflowOptions" /> and the associated <see cref="WorkflowAgentProvider"/>.
/// </summary>
/// <remarks>
/// The value assigned to <see cref="IncludeFunctions" /> controls on whether the function
/// tools (<see cref="AIFunction"/>) initialized in the constructor are included for auto-invocation.
/// </remarks>
private Workflow CreateWorkflow()
{
// Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file.
AzureAgentProvider agentProvider = new(this.FoundryEndpoint, new AzureCliCredential())
{
// Functions included here will be auto-executed by the framework.
Functions = IncludeFunctions ? this.FunctionMap.Values : null,
};
DeclarativeWorkflowOptions options =
new(new AzureAgentProvider(this.FoundryEndpoint, new AzureCliCredential()))
new(agentProvider)
{
Configuration = this.Configuration,
//ConversationId = null, // Assign to continue a conversation
@@ -121,8 +140,18 @@ internal sealed class Program
return DeclarativeWorkflowBuilder.Build<string>(this.WorkflowFile, options);
}
/// <summary>
/// Configuration key used to identify the Foundry project endpoint.
/// </summary>
private const string ConfigKeyFoundryEndpoint = "FOUNDRY_PROJECT_ENDPOINT";
/// <summary>
/// Controls on whether the function tools (<see cref="AIFunction"/>) initialized
/// in the constructor are included for auto-invocation.
/// NOTE: By default, no functions exist as part of this sample.
/// </summary>
private const bool IncludeFunctions = true;
private static Dictionary<string, string> NameCache { get; } = [];
private static HashSet<string> FileCache { get; } = [];
@@ -132,6 +161,7 @@ internal sealed class Program
private PersistentAgentsClient FoundryClient { get; }
private IConfiguration Configuration { get; }
private CheckpointInfo? LastCheckpoint { get; set; }
private Dictionary<string, AIFunction> FunctionMap { get; }
private Program(string workflowFile, string? workflowInput)
{
@@ -142,12 +172,21 @@ internal sealed class Program
this.FoundryEndpoint = this.Configuration[ConfigKeyFoundryEndpoint] ?? throw new InvalidOperationException($"Undefined configuration setting: {ConfigKeyFoundryEndpoint}");
this.FoundryClient = new PersistentAgentsClient(this.FoundryEndpoint, new AzureCliCredential());
List<AIFunction> functions =
[
// Manually define any custom functions that may be required by agents within the workflow.
// By default, this sample does not include any functions.
//AIFunctionFactory.Create(),
];
this.FunctionMap = functions.ToDictionary(f => f.Name);
}
private async Task<ExternalRequest?> MonitorAndDisposeWorkflowRunAsync(Checkpointed<StreamingRun> run, InputResponse? response = null)
private async Task<ExternalRequest?> MonitorAndDisposeWorkflowRunAsync(Checkpointed<StreamingRun> run, object? response = null)
{
await using IAsyncDisposable disposeRun = run;
bool hasStreamed = false;
string? messageId = null;
await foreach (WorkflowEvent workflowEvent in run.Run.WatchStreamAsync())
@@ -211,11 +250,12 @@ internal sealed class Program
case AgentRunUpdateEvent streamEvent:
if (!string.Equals(messageId, streamEvent.Update.MessageId, StringComparison.Ordinal))
{
hasStreamed = false;
messageId = streamEvent.Update.MessageId;
if (messageId is not null)
{
string? agentId = streamEvent.Update.AuthorName;
string? agentId = streamEvent.Update.AgentId;
if (agentId is not null)
{
if (!NameCache.TryGetValue(agentId, out string? realName))
@@ -245,11 +285,18 @@ internal sealed class Program
await DownloadFileContentAsync(Path.GetFileName(messageUpdate.TextAnnotation?.TextToReplace ?? "response.png"), content);
}
break;
case RequiredActionUpdate actionUpdate:
Console.ForegroundColor = ConsoleColor.White;
Console.Write($"Calling tool: {actionUpdate.FunctionName}");
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($" [{actionUpdate.ToolCallId}]");
break;
}
try
{
Console.ResetColor();
Console.Write(streamEvent.Data);
Console.Write(streamEvent.Update.Text);
hasStreamed |= !string.IsNullOrEmpty(streamEvent.Update.Text);
}
finally
{
@@ -260,7 +307,11 @@ internal sealed class Program
case AgentRunResponseEvent messageEvent:
try
{
Console.WriteLine();
if (hasStreamed)
{
Console.WriteLine();
}
if (messageEvent.Response.Usage is not null)
{
Console.ForegroundColor = ConsoleColor.DarkGray;
@@ -277,14 +328,31 @@ internal sealed class Program
return default;
}
private static InputResponse HandleExternalRequest(ExternalRequest request)
/// <summary>
/// Handle request for external input, either from a human or a function tool invocation.
/// </summary>
private async ValueTask<object> HandleExternalRequestAsync(ExternalRequest request) =>
request.Data.TypeId.TypeName switch
{
// Request for human input
_ when request.Data.TypeId.IsMatch<InputRequest>() => HandleInputRequest(request.DataAs<InputRequest>()!),
// Request for function tool invocation. (Only active when functions are defined and IncludeFunctions is true.)
_ when request.Data.TypeId.IsMatch<AgentToolRequest>() => await this.HandleToolRequestAsync(request.DataAs<AgentToolRequest>()!),
// Unknown request type.
_ => throw new InvalidOperationException($"Unsupported external request type: {request.GetType().Name}."),
};
/// <summary>
/// Handle request for human input.
/// </summary>
private static InputResponse HandleInputRequest(InputRequest request)
{
InputRequest? message = request.Data.As<InputRequest>();
string? userInput;
do
{
Console.ForegroundColor = ConsoleColor.DarkGreen;
Console.Write($"\n{message?.Prompt ?? "INPUT:"} ");
Console.Write($"\n{request.Prompt ?? "INPUT:"} ");
Console.ForegroundColor = ConsoleColor.White;
userInput = Console.ReadLine();
}
@@ -293,6 +361,30 @@ internal sealed class Program
return new InputResponse(userInput);
}
/// <summary>
/// Handle a function tool request by invoking the specified tools and returning the results.
/// </summary>
/// <remarks>
/// This handler is only active when <see cref="IncludeFunctions"/> is set to true and
/// one or more <see cref="AIFunction"/> instances are defined in the constructor.
/// </remarks>
private async ValueTask<AgentToolResponse> HandleToolRequestAsync(AgentToolRequest request)
{
Task<FunctionResultContent>[] functionTasks = request.FunctionCalls.Select(functionCall => InvokesToolAsync(functionCall)).ToArray();
await Task.WhenAll(functionTasks);
return AgentToolResponse.Create(request, functionTasks.Select(task => task.Result));
async Task<FunctionResultContent> InvokesToolAsync(FunctionCallContent functionCall)
{
AIFunction functionTool = this.FunctionMap[functionCall.Name];
AIFunctionArguments? functionArguments = functionCall.Arguments is null ? null : new(functionCall.Arguments.NormalizePortableValues());
object? result = await functionTool.InvokeAsync(functionArguments);
return new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result));
}
}
private static string? ParseWorkflowFile(string[] args)
{
string? workflowFile = args.FirstOrDefault();
@@ -97,8 +97,41 @@ public sealed class AzureAgentProvider(string projectEndpoint, TokenCredential p
}
/// <inheritdoc/>
public override async Task<AIAgent> GetAgentAsync(string agentId, CancellationToken cancellationToken = default) =>
await this.GetAgentsClient().GetAIAgentAsync(agentId, chatOptions: null, clientFactory: null, cancellationToken).ConfigureAwait(false);
public override async Task<AIAgent> GetAgentAsync(string agentId, CancellationToken cancellationToken = default)
{
ChatClientAgent agent =
await this.GetAgentsClient().GetAIAgentAsync(
agentId,
new ChatOptions()
{
AllowMultipleToolCalls = this.AllowMultipleToolCalls,
},
clientFactory: null,
cancellationToken).ConfigureAwait(false);
FunctionInvokingChatClient? functionInvokingClient = agent.GetService<FunctionInvokingChatClient>();
if (functionInvokingClient is not null)
{
// Allow concurrent invocations if configured
functionInvokingClient.AllowConcurrentInvocation = this.AllowConcurrentInvocation;
// Allows the caller to respond with function responses
functionInvokingClient.TerminateOnUnknownCalls = true;
// Make functions available for execution. Doesn't change what tool is available for any given agent.
if (this.Functions is not null)
{
if (functionInvokingClient.AdditionalTools is null)
{
functionInvokingClient.AdditionalTools = [.. this.Functions];
}
else
{
functionInvokingClient.AdditionalTools = [.. functionInvokingClient.AdditionalTools, .. this.Functions];
}
}
}
return agent;
}
/// <inheritdoc/>
public override async Task<ChatMessage> GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default)
@@ -0,0 +1,30 @@
// 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 a request for user input.
/// </summary>
public sealed class AgentToolRequest
{
/// <summary>
/// The name of the agent associated with the tool request.
/// </summary>
public string AgentName { get; }
/// <summary>
/// A list of tool requests.
/// </summary>
public IList<FunctionCallContent> FunctionCalls { get; }
[JsonConstructor]
internal AgentToolRequest(string agentName, IList<FunctionCallContent>? functionCalls = null)
{
this.AgentName = agentName;
this.FunctionCalls = functionCalls ?? [];
}
}
@@ -0,0 +1,53 @@
// 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 a user input response.
/// </summary>
public sealed class AgentToolResponse
{
/// <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; }
/// <summary>
/// Initializes a new instance of the <see cref="InputResponse"/> class.
/// </summary>
[JsonConstructor]
internal AgentToolResponse(string agentName, IList<FunctionResultContent> functionResults)
{
this.AgentName = agentName;
this.FunctionResults = functionResults;
}
/// <summary>
/// Factory method to create an <see cref="AgentToolResponse"/> from an <see cref="AgentToolRequest"/>
/// Ensures that all function calls in the request have a corresponding result.
/// </summary>
/// <param name="toolRequest">The tool request.</param>
/// <param name="functionResults">On or more function results</param>
/// <returns>An <see cref="AgentToolResponse"/> that can be provided to the workflow.</returns>
/// <exception cref="DeclarativeActionException">Not all <see cref="AgentToolRequest.FunctionCalls"/> have a corresponding <see cref="FunctionResultContent"/>.</exception>
public static AgentToolResponse Create(AgentToolRequest 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 AgentToolResponse(toolRequest.AgentName, [.. functionResults]);
}
}
@@ -94,6 +94,9 @@ internal static class ChatMessageExtensions
public static ChatMessage ToChatMessage(this StringDataValue message) => new(ChatRole.User, message.Value);
public static ChatMessage ToChatMessage(this IEnumerable<FunctionResultContent> functionResults) =>
new(ChatRole.Tool, [.. functionResults]);
public static AdditionalPropertiesDictionary? ToMetadata(this RecordDataValue? metadata)
{
if (metadata is null)
@@ -148,9 +148,9 @@ internal static class DataValueExtensions
IEnumerable<KeyValuePair<string, DataValue>> GetFields()
{
foreach (string key in value.Keys)
foreach (DictionaryEntry entry in value)
{
yield return new KeyValuePair<string, DataValue>(key, value[key].ToDataValue());
yield return new KeyValuePair<string, DataValue>((string)entry.Key, entry.Value.ToDataValue());
}
}
}
@@ -157,9 +157,9 @@ internal static class FormulaValueExtensions
IEnumerable<NamedValue> GetFields()
{
foreach (string key in value.Keys)
foreach (DictionaryEntry entry in value)
{
yield return new NamedValue(key, value[key].ToFormula());
yield return new NamedValue((string)entry.Key, entry.Value.ToFormula());
}
}
}
@@ -76,9 +76,9 @@ internal static class ObjectExtensions
IEnumerable<KeyValuePair<string, object?>> GetEntries()
{
foreach (string key in value.Keys)
foreach (DictionaryEntry entry in value)
{
yield return new KeyValuePair<string, object?>(key, value[key]);
yield return new KeyValuePair<string, object?>((string)entry.Key, entry.Value);
}
}
}
@@ -64,28 +64,7 @@ internal static class PortableValueExtensions
FormulaValue.NewSingleColumnTable(formulaValues.OfType<PrimitiveValue<TValue>>());
}
private static RecordType ParseRecordType(this RecordValue record)
{
RecordType recordType = RecordType.Empty();
foreach (NamedValue property in record.Fields)
{
recordType = recordType.Add(property.Name, property.Value.Type);
}
return recordType;
}
private static bool IsParentType<TValue>(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue)
{
if (value.TypeId.IsMatchPolymorphic(typeof(TValue)))
{
return value.Is(out typedValue);
}
typedValue = default;
return false;
}
private static bool IsSystemType<TValue>(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue) where TValue : struct
public static bool IsSystemType<TValue>(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue) where TValue : struct
{
if (value.TypeId.IsMatch<TValue>() || value.TypeId.IsMatch(typeof(TValue).UnderlyingSystemType))
{
@@ -96,7 +75,7 @@ internal static class PortableValueExtensions
return false;
}
private static bool IsType<TValue>(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue)
public static bool IsType<TValue>(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue)
{
if (value.TypeId.IsMatch<TValue>())
{
@@ -106,4 +85,25 @@ internal static class PortableValueExtensions
typedValue = default;
return false;
}
public static bool IsParentType<TValue>(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue)
{
if (value.TypeId.IsMatchPolymorphic(typeof(TValue)))
{
return value.Is(out typedValue);
}
typedValue = default;
return false;
}
private static RecordType ParseRecordType(this RecordValue record)
{
RecordType recordType = RecordType.Empty();
foreach (NamedValue property in record.Fields)
{
recordType = recordType.Add(property.Name, property.Value.Type);
}
return recordType;
}
}
@@ -236,32 +236,29 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
{
this.Trace(item);
string parentId = GetParentId(item);
string actionId = item.GetId();
string postId = Steps.Post(actionId);
// Entry point for question
QuestionExecutor action = new(item, this._workflowState);
this.ContinueWith(action);
// Transition to post action if complete
this._workflowModel.AddLink(actionId, postId, QuestionExecutor.IsComplete);
string postId = Steps.Post(action.Id);
this._workflowModel.AddLink(action.Id, postId, QuestionExecutor.IsComplete);
// Perpare for input request if not complete
string prepareId = QuestionExecutor.Steps.Prepare(actionId);
this.ContinueWith(new DelegateActionExecutor(prepareId, this._workflowState, action.PrepareResponseAsync, emitResult: false), parentId, message => !QuestionExecutor.IsComplete(message));
string prepareId = QuestionExecutor.Steps.Prepare(action.Id);
this.ContinueWith(new DelegateActionExecutor(prepareId, this._workflowState, action.PrepareResponseAsync, emitResult: false), action.ParentId, message => !QuestionExecutor.IsComplete(message));
// Define input action
string inputId = QuestionExecutor.Steps.Input(actionId);
string inputId = QuestionExecutor.Steps.Input(action.Id);
RequestPortAction inputPort = new(RequestPort.Create<InputRequest, InputResponse>(inputId));
this._workflowModel.AddNode(inputPort, parentId);
this._workflowModel.AddLinkFromPeer(parentId, inputId);
this._workflowModel.AddNode(inputPort, action.ParentId);
this._workflowModel.AddLinkFromPeer(action.ParentId, inputId);
// Capture input response
string captureId = QuestionExecutor.Steps.Capture(actionId);
this.ContinueWith(new DelegateActionExecutor<InputResponse>(captureId, this._workflowState, action.CaptureResponseAsync, emitResult: false), parentId);
string captureId = QuestionExecutor.Steps.Capture(action.Id);
this.ContinueWith(new DelegateActionExecutor<InputResponse>(captureId, this._workflowState, action.CaptureResponseAsync, emitResult: false), action.ParentId);
// Transition to post action if complete
this.ContinueWith(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), parentId, QuestionExecutor.IsComplete);
this.ContinueWith(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId, QuestionExecutor.IsComplete);
// Transition to prepare action if not complete
this._workflowModel.AddLink(captureId, prepareId, message => !QuestionExecutor.IsComplete(message));
}
@@ -313,7 +310,30 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor
{
this.Trace(item);
this.ContinueWith(new InvokeAzureAgentExecutor(item, this._workflowOptions.AgentProvider, this._workflowState));
// Entry point to invoke agent
InvokeAzureAgentExecutor 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, result => !InvokeAzureAgentExecutor.RequiresInput(result));
// Define input action
string inputId = InvokeAzureAgentExecutor.Steps.Input(action.Id);
RequestPortAction inputPort = new(RequestPort.Create<AgentToolRequest, AgentToolResponse>(inputId));
this._workflowModel.AddNode(inputPort, action.ParentId);
this._workflowModel.AddLink(action.Id, inputId, InvokeAzureAgentExecutor.RequiresInput);
// Input port always transitions to resume
string resumeId = InvokeAzureAgentExecutor.Steps.Resume(action.Id);
this._workflowModel.AddNode(new DelegateActionExecutor<AgentToolResponse>(resumeId, this._workflowState, action.ResumeAsync), action.ParentId);
this._workflowModel.AddLink(inputId, resumeId);
// Transition to request port if more input is required
this._workflowModel.AddLink(resumeId, inputId, InvokeAzureAgentExecutor.RequiresInput);
// Transition to post action if complete
this._workflowModel.AddLink(resumeId, postId, result => !InvokeAzureAgentExecutor.RequiresInput(result));
// Define post action
this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId);
}
protected override void Visit(RetrieveConversationMessage item)
@@ -0,0 +1,105 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.PowerFx.Types;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.Kit;
/// <summary>
/// Extension helpers for converting <see cref="PortableValue"/> instances (and collections containing them)
/// into their normalized runtime representations (primarily <see cref="FormulaValue"/> primitives) ready for evaluation.
/// </summary>
public static class PortableValueExtensions
{
/// <summary>
/// Normalizes all values in the provided dictionary. Each entry whose value is a <see cref="PortableValue"/>
/// is converted to its underlying normalized representation; non-PortableValue entries are preserved as-is.
/// </summary>
/// <param name="source">The source dictionary whose values may contain <see cref="PortableValue"/> instances; may be null.</param>
/// <returns>
/// A new dictionary with normalized values, or null if <paramref name="source"/> is null.
/// Keys are copied unchanged.
/// </returns>
public static IDictionary<string, object?>? NormalizePortableValues(this IDictionary<string, object?>? source)
{
if (source is null)
{
return null;
}
return source.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.NormalizePortableValue());
}
/// <summary>
/// Normalizes an arbitrary value if it is a <see cref="PortableValue"/>; otherwise returns the value unchanged.
/// </summary>
/// <param name="value">The value to normalize; may be null or already a primitive/object.</param>
/// <returns>
/// Null if <paramref name="value"/> is null; the normalized result if it is a <see cref="PortableValue"/>;
/// otherwise the original <paramref name="value"/>.
/// </returns>
public static object? NormalizePortableValue(this object? value) =>
Throw.IfNull(value, nameof(value)) switch
{
null => null,
JsonElement jsonValue => jsonValue.GetValue(),
PortableValue portableValue => portableValue.Normalize(),
_ => value,
};
/// <summary>
/// Converts a <see cref="PortableValue"/> into a concrete representation suitable for evaluation.
/// </summary>
/// <param name="value">The portable value to normalize; cannot be null.</param>
/// <returns>
/// A <see cref="object"/> instance representing the underlying value.
/// </returns>
public static object? Normalize(this PortableValue value) =>
Throw.IfNull(value, nameof(value)).TypeId switch
{
_ when value.IsType(out string? stringValue) => stringValue,
_ when value.IsSystemType(out bool? boolValue) => boolValue.Value,
_ when value.IsSystemType(out int? intValue) => intValue.Value,
_ when value.IsSystemType(out long? longValue) => longValue.Value,
_ when value.IsSystemType(out decimal? decimalValue) => decimalValue.Value,
_ when value.IsSystemType(out float? floatValue) => floatValue.Value,
_ when value.IsSystemType(out double? doubleValue) => doubleValue.Value,
_ when value.IsParentType(out IDictionary? recordValue) => recordValue.NormalizePortableValues(),
_ when value.IsParentType(out IEnumerable? listValue) => listValue.NormalizePortableValues(),
_ => throw new DeclarativeActionException($"Unsupported portable type: {value.TypeId.TypeName}"),
};
private static Dictionary<string, object?> NormalizePortableValues(this IDictionary source)
{
return GetValues().ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
IEnumerable<KeyValuePair<string, object?>> GetValues()
{
foreach (DictionaryEntry entry in source)
{
yield return new KeyValuePair<string, object?>((string)entry.Key, entry.Value.NormalizePortableValue());
}
}
}
private static object?[] NormalizePortableValues(this IEnumerable source) =>
source.Cast<object?>().Select(NormalizePortableValue).ToArray();
private static object? GetValue(this JsonElement element) =>
element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
JsonValueKind.Number => element.TryGetInt64(out long longValue) ? longValue : element.GetDouble(),
JsonValueKind.Object => element.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetValue()),
JsonValueKind.Array => element.EnumerateArray().Select(e => e.GetValue()).ToArray(),
_ => throw new DeclarativeActionException($"Unsupported JSON value kind: {element.ValueKind}"),
};
}
@@ -1,10 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
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.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Bot.ObjectModel;
using Microsoft.Bot.ObjectModel.Abstractions;
@@ -16,23 +19,67 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state) :
DeclarativeActionExecutor<InvokeAzureAgent>(model, state)
{
public static class Steps
{
public static string Input(string id) => $"{id}_{nameof(Input)}";
public static string Resume(string id) => $"{id}_{nameof(Resume)}";
}
// Input is requested by a message other than ActionExecutorResult.
public static bool RequiresInput(object? message) => message is not ActionExecutorResult;
private AzureAgentUsage AgentUsage => Throw.IfNull(this.Model.Agent, $"{nameof(this.Model)}.{nameof(this.Model.Agent)}");
private AzureAgentInput? AgentInput => this.Model.Input;
private AzureAgentOutput? AgentOutput => this.Model.Output;
protected override bool EmitResultEvent => false;
protected override bool IsDiscreteAction => false;
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await this.InvokeAgentAsync(context, this.GetInputMessages(), cancellationToken).ConfigureAwait(false);
return default;
}
public ValueTask ResumeAsync(IWorkflowContext context, AgentToolResponse message, CancellationToken cancellationToken) =>
this.InvokeAgentAsync(context, [message.FunctionResults.ToChatMessage()], cancellationToken);
public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
{
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
}
private async ValueTask InvokeAgentAsync(IWorkflowContext context, IEnumerable<ChatMessage>? messages, CancellationToken cancellationToken)
{
string? conversationId = this.GetConversationId();
string agentName = this.GetAgentName();
string? additionalInstructions = this.GetAdditionalInstructions();
bool autoSend = this.GetAutoSendValue();
IEnumerable<ChatMessage>? inputMessages = this.GetInputMessages();
AgentRunResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, additionalInstructions, inputMessages, cancellationToken).ConfigureAwait(false);
bool isComplete = true;
AgentRunResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, additionalInstructions, messages, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(agentResponse.Text))
{
// Identify function calls that have no associated result.
List<FunctionCallContent> functionCalls = this.GetOrphanedFunctionCalls(agentResponse);
isComplete = functionCalls.Count == 0;
if (!isComplete)
{
AgentToolRequest toolRequest = new(agentName, functionCalls);
await context.SendMessageAsync(toolRequest, targetId: null, cancellationToken).ConfigureAwait(false);
}
}
if (isComplete)
{
await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false);
}
await this.AssignAsync(this.AgentOutput?.Messages?.Path, agentResponse.Messages.ToTable(), context).ConfigureAwait(false);
return default;
}
private IEnumerable<ChatMessage>? GetInputMessages()
@@ -48,6 +95,28 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA
return userInput?.ToChatMessages();
}
private List<FunctionCallContent> GetOrphanedFunctionCalls(AgentRunResponse agentResponse)
{
HashSet<string> functionResultIds =
[.. agentResponse.Messages
.SelectMany(
m =>
m.Contents
.OfType<FunctionResultContent>()
.Select(functionCall => functionCall.CallId))];
List<FunctionCallContent> functionCalls = [];
foreach (FunctionCallContent functionCall in agentResponse.Messages.SelectMany(m => m.Contents.OfType<FunctionCallContent>()))
{
if (!functionResultIds.Contains(functionCall.CallId))
{
functionCalls.Add(functionCall);
}
}
return functionCalls;
}
private string? GetConversationId()
{
if (this.Model.ConversationId is null)
@@ -3,6 +3,7 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative;
@@ -12,6 +13,49 @@ namespace Microsoft.Agents.AI.Workflows.Declarative;
/// </summary>
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="AgentToolRequest"/> that describes the function calls requested. The caller may
/// then respond with a corrsponding <see cref="AgentToolResponse"/> 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"/>.
/// </remarks>
public IEnumerable<AIFunction>? Functions { get; init; }
/// <summary>
/// Gets or sets a value indicating whether to allow concurrent invocation of functions.
/// </summary>
/// <value>
/// <see langword="true"/> if multiple function calls can execute in parallel.
/// <see langword="false"/> if function calls are processed serially.
/// The default value is <see langword="false"/>.
/// </value>
/// <remarks>
/// An individual response from the inner client might contain multiple function call requests.
/// By default, such function calls are processed serially. Set <see cref="AllowConcurrentInvocation"/> to
/// <see langword="true"/> to enable concurrent invocation such that multiple function calls can execute in parallel.
/// </remarks>
public bool AllowConcurrentInvocation { get; init; }
/// <summary>
/// Gets or sets a flag to indicate whether a single response is allowed to include multiple tool calls.
/// If <see langword="false"/>, the <see cref="IChatClient"/> is asked to return a maximum of one tool call per request.
/// If <see langword="true"/>, there is no limit.
/// If <see langword="null"/>, the provider may select its own default.
/// </summary>
/// <remarks>
/// <para>
/// When used with function calling middleware, this does not affect the ability to perform multiple function calls in sequence.
/// It only affects the number of function calls within a single iteration of the function calling loop.
/// </para>
/// <para>
/// The underlying provider is not guaranteed to support or honor this flag. For example it may choose to ignore it and return multiple tool calls regardless.
/// </para>
/// </remarks>
public bool AllowMultipleToolCalls { get; init; }
/// <summary>
/// Asynchronously retrieves an AI agent by its unique identifier.
/// </summary>
@@ -0,0 +1,91 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using Microsoft.Extensions.AI;
using Microsoft.SemanticKernel;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
public sealed class MenuPlugin
{
public IEnumerable<AIFunction> GetTools()
{
yield return AIFunctionFactory.Create(this.GetMenu, name: $"{nameof(MenuPlugin)}_{nameof(GetMenu)}");
yield return AIFunctionFactory.Create(this.GetSpecials, name: $"{nameof(MenuPlugin)}_{nameof(GetSpecials)}");
yield return AIFunctionFactory.Create(this.GetItemPrice, name: $"{nameof(MenuPlugin)}_{nameof(GetItemPrice)}");
}
[KernelFunction, Description("Provides a list items on the menu.")]
public MenuItem[] GetMenu()
{
return s_menuItems;
}
[KernelFunction, Description("Provides a list of specials from the menu.")]
public MenuItem[] GetSpecials()
{
return [.. s_menuItems.Where(i => i.IsSpecial)];
}
[KernelFunction, Description("Provides the price of the requested menu item.")]
public float? GetItemPrice(
[Description("The name of the menu item.")]
string name)
{
return s_menuItems.FirstOrDefault(i => i.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Price;
}
private static readonly MenuItem[] s_menuItems =
[
new()
{
Category = "Soup",
Name = "Clam Chowder",
Price = 4.95f,
IsSpecial = true,
},
new()
{
Category = "Soup",
Name = "Tomato Soup",
Price = 4.95f,
IsSpecial = false,
},
new()
{
Category = "Salad",
Name = "Cobb Salad",
Price = 9.99f,
},
new()
{
Category = "Salad",
Name = "House Salad",
Price = 4.95f,
},
new()
{
Category = "Drink",
Name = "Chai Tea",
Price = 2.95f,
IsSpecial = true,
},
new()
{
Category = "Drink",
Name = "Soda",
Price = 1.95f,
},
];
public sealed class MenuItem
{
public string Category { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public float Price { get; init; }
public bool IsSpecial { get; init; }
}
}
@@ -0,0 +1,15 @@
type: foundry_agent
name: ToolAgent
description: Agent with a function tool defined.
model:
id: ${FOUNDRY_MODEL_DEPLOYMENT_NAME}
tools:
- id: MenuPlugin_GetMenu
type: function
description: Provides a list items on the menu.
- id: MenuPlugin_GetSpecials
type: function
description: Provides a list of specials from the menu.
- id: MenuPlugin_GetItemPrice
type: function
description: Provides the price of the requested menu item.
@@ -26,6 +26,7 @@ internal static class AgentFactory
new()
{
["FOUNDRY_AGENT_TEST"] = "TestAgent.yaml",
["FOUNDRY_AGENT_TOOL"] = "ToolAgent.yaml",
["FOUNDRY_AGENT_ANSWER"] = "QuestionAgent.yaml",
["FOUNDRY_AGENT_STUDENT"] = "StudentAgent.yaml",
["FOUNDRY_AGENT_TEACHER"] = "TeacherAgent.yaml",
@@ -50,6 +51,7 @@ internal static class AgentFactory
IKernelBuilder kernelBuilder = Kernel.CreateBuilder();
kernelBuilder.Services.AddSingleton(clientAgents);
kernelBuilder.Services.AddSingleton(clientProjects);
kernelBuilder.Plugins.AddFromType<Agents.MenuPlugin>();
AgentCreationOptions creationOptions = new() { Kernel = kernelBuilder.Build() };
AzureAIAgentFactory factory = new();
string repoRoot = WorkflowTest.GetRepoFolder();
@@ -2,11 +2,13 @@
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Reflection;
using System.Threading.Tasks;
using Azure.Identity;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Bot.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using Shared.IntegrationTests;
using Xunit.Abstractions;
@@ -66,7 +68,7 @@ public abstract class IntegrationTest : IDisposable
internal static string FormatVariablePath(string variableName, string? scope = null) => $"{scope ?? WorkflowFormulaState.DefaultScopeName}.{variableName}";
protected async ValueTask<DeclarativeWorkflowOptions> CreateOptionsAsync(bool externalConversation = false)
protected async ValueTask<DeclarativeWorkflowOptions> CreateOptionsAsync(bool externalConversation = false, params IEnumerable<AIFunction> functionTools)
{
FrozenDictionary<string, string?> agentMap = await AgentFactory.GetAgentsAsync(this.FoundryConfiguration, this.Configuration);
@@ -75,7 +77,11 @@ public abstract class IntegrationTest : IDisposable
.AddInMemoryCollection(agentMap)
.Build();
AzureAgentProvider agentProvider = new(this.FoundryConfiguration.Endpoint, new AzureCliCredential());
AzureAgentProvider agentProvider =
new(this.FoundryConfiguration.Endpoint, new AzureCliCredential())
{
Functions = functionTools,
};
string? conversationId = null;
if (externalConversation)
@@ -8,6 +8,7 @@ using System.Reflection;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Extensions.AI;
using Shared.Code;
using Xunit.Sdk;
@@ -29,9 +30,8 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId)
Assert.NotEmpty(testcase.Setup.Responses);
string inputText = testcase.Setup.Responses[responseCount].Value;
Console.WriteLine($"INPUT: {inputText}");
InputResponse response = new(inputText);
++responseCount;
WorkflowEvents runEvents = await this.ResumeAsync(response).ConfigureAwait(false);
WorkflowEvents runEvents = await this.ResumeAsync(new InputResponse(inputText)).ConfigureAwait(false);
workflowEvents = new WorkflowEvents([.. workflowEvents.Events, .. runEvents.Events]);
requestCount = (workflowEvents.InputEvents.Count + 1) / 2;
}
@@ -48,6 +48,15 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId)
return new WorkflowEvents(workflowEvents);
}
public async Task<WorkflowEvents> ResumeAsync(object response)
{
Console.WriteLine("\nRESUMING WORKFLOW...");
Assert.NotNull(this.LastCheckpoint);
Checkpointed<StreamingRun> run = await InProcessExecution.ResumeStreamAsync(workflow, this.LastCheckpoint, this.GetCheckpointManager(), runId);
IReadOnlyList<WorkflowEvent> workflowEvents = await MonitorAndDisposeWorkflowRunAsync(run, response).ToArrayAsync();
return new WorkflowEvents(workflowEvents);
}
public static async Task<WorkflowHarness> GenerateCodeAsync<TInput>(
string runId,
string workflowProviderCode,
@@ -73,7 +82,7 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId)
{
if (useJson && this._checkpointManager is null)
{
DirectoryInfo checkpointFolder = Directory.CreateDirectory(Path.Combine(".", $"chk-{DateTime.Now:YYmmdd-hhMMss-ff}"));
DirectoryInfo checkpointFolder = Directory.CreateDirectory(Path.Combine(".", $"chk-{DateTime.Now:yyMMdd-hhmmss-ff}"));
this._checkpointManager = CheckpointManager.CreateJson(new FileSystemJsonCheckpointStore(checkpointFolder));
}
else
@@ -84,16 +93,7 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId)
return this._checkpointManager;
}
private async Task<WorkflowEvents> ResumeAsync(InputResponse response)
{
Console.WriteLine("RESUMING WORKFLOW...");
Assert.NotNull(this.LastCheckpoint);
Checkpointed<StreamingRun> run = await InProcessExecution.ResumeStreamAsync(workflow, this.LastCheckpoint, this.GetCheckpointManager(), runId);
IReadOnlyList<WorkflowEvent> workflowEvents = await MonitorAndDisposeWorkflowRunAsync(run, response).ToArrayAsync();
return new WorkflowEvents(workflowEvents);
}
private static async IAsyncEnumerable<WorkflowEvent> MonitorAndDisposeWorkflowRunAsync(Checkpointed<StreamingRun> run, InputResponse? response = null)
private static async IAsyncEnumerable<WorkflowEvent> MonitorAndDisposeWorkflowRunAsync(Checkpointed<StreamingRun> run, object? response = null)
{
await using IAsyncDisposable disposeRun = run;
@@ -128,9 +128,27 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId)
case WorkflowErrorEvent errorEvent:
throw errorEvent.Data as Exception ?? new XunitException("Unexpected failure...");
case ExecutorInvokedEvent executorInvokeEvent:
Console.WriteLine($"EXEC: {executorInvokeEvent.ExecutorId}");
break;
case DeclarativeActionInvokedEvent actionInvokeEvent:
Console.WriteLine($"ACTION: {actionInvokeEvent.ActionId} [{actionInvokeEvent.ActionType}]");
break;
case AgentRunResponseEvent responseEvent:
if (!string.IsNullOrEmpty(responseEvent.Response.Text))
{
Console.WriteLine($"AGENT: {responseEvent.Response.AgentId}: {responseEvent.Response.Text}");
}
else
{
foreach (FunctionCallContent toolCall in responseEvent.Response.Messages.SelectMany(m => m.Contents.OfType<FunctionCallContent>()))
{
Console.WriteLine($"TOOL: {toolCall.Name} [{responseEvent.Response.AgentId}]");
}
}
break;
}
yield return workflowEvent;
@@ -141,6 +159,6 @@ internal sealed class WorkflowHarness(Workflow workflow, string runId)
}
}
Console.WriteLine("SUSPENDING WORKFLOW...");
Console.WriteLine("SUSPENDING WORKFLOW...\n");
}
}
@@ -0,0 +1,99 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents;
using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests;
/// <summary>
/// Tests execution of workflow created by <see cref="DeclarativeWorkflowBuilder"/>.
/// </summary>
public sealed class ToolInputWorkflowTest(ITestOutputHelper output) : IntegrationTest(output)
{
[Fact]
public Task ValidateAutoInvokeAsync() =>
this.RunWorkflowAsync(autoInvoke: true, new MenuPlugin().GetTools());
[Fact]
public Task ValidateRequestInvokeAsync() =>
this.RunWorkflowAsync(autoInvoke: false, new MenuPlugin().GetTools());
private static string GetWorkflowPath(string workflowFileName) => Path.Combine(Environment.CurrentDirectory, "Workflows", workflowFileName);
private async Task RunWorkflowAsync(bool autoInvoke, params IEnumerable<AIFunction> functionTools)
{
string workflowPath = GetWorkflowPath("FunctionTool.yaml");
Dictionary<string, AIFunction> functionMap = autoInvoke ? [] : functionTools.ToDictionary(tool => tool.Name, tool => tool);
DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation: false, autoInvoke ? functionTools : []);
Workflow workflow = DeclarativeWorkflowBuilder.Build<string>(workflowPath, workflowOptions);
WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath));
WorkflowEvents workflowEvents = await harness.RunWorkflowAsync("hi!").ConfigureAwait(false);
int requestCount = (workflowEvents.InputEvents.Count + 1) / 2;
int responseCount = 0;
while (requestCount > responseCount)
{
Assert.False(autoInvoke);
RequestInfoEvent inputEvent = workflowEvents.InputEvents[workflowEvents.InputEvents.Count - 1];
AgentToolRequest? toolRequest = inputEvent.Request.Data.As<AgentToolRequest>();
Assert.NotNull(toolRequest);
List<(FunctionCallContent, AIFunction)> functionCalls = [];
foreach (FunctionCallContent functionCall in toolRequest.FunctionCalls)
{
this.Output.WriteLine($"TOOL REQUEST: {functionCall.Name}");
if (!functionMap.TryGetValue(functionCall.Name, out AIFunction? functionTool))
{
Assert.Fail($"TOOL FAILURE [{functionCall.Name}] - MISSING");
return;
}
functionCalls.Add((functionCall, functionTool));
}
IList<FunctionResultContent> functionResults = await InvokeToolsAsync(functionCalls);
++responseCount;
WorkflowEvents runEvents = await harness.ResumeAsync(AgentToolResponse.Create(toolRequest, functionResults)).ConfigureAwait(false);
workflowEvents = new WorkflowEvents([.. workflowEvents.Events, .. runEvents.Events]);
}
if (autoInvoke)
{
Assert.Empty(workflowEvents.InputEvents);
}
else
{
Assert.NotEmpty(workflowEvents.InputEvents);
}
Assert.Equal(autoInvoke ? 3 : 5, workflowEvents.AgentResponseEvents.Count);
Assert.All(workflowEvents.AgentResponseEvents, response => response.Response.Text.Contains("4.95"));
}
private static async ValueTask<IList<FunctionResultContent>> InvokeToolsAsync(IEnumerable<(FunctionCallContent, AIFunction)> functionCalls)
{
List<FunctionResultContent> results = [];
foreach ((FunctionCallContent functionCall, AIFunction functionTool) in functionCalls)
{
AIFunctionArguments? functionArguments = functionCall.Arguments is null ? null : new(functionCall.Arguments.NormalizePortableValues());
object? result = await functionTool.InvokeAsync(functionArguments).ConfigureAwait(false);
results.Add(new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result)));
}
return results;
}
}
@@ -0,0 +1,28 @@
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_test
actions:
- kind: InvokeAzureAgent
id: invoke_greet
conversationId: =System.ConversationId
agent:
name: =Env.FOUNDRY_AGENT_TOOL
- kind: InvokeAzureAgent
id: invoke_menu
conversationId: =System.ConversationId
agent:
name: =Env.FOUNDRY_AGENT_TOOL
input:
messages: =UserMessage("What's on today's menu?")
- kind: InvokeAzureAgent
id: invoke_item
conversationId: =System.ConversationId
agent:
name: =Env.FOUNDRY_AGENT_TOOL
input:
messages: =UserMessage("How much is the clam chowder?")
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events;
/// <summary>
/// Base class for event tests.
/// </summary>
public sealed class AgentToolRequestTest(ITestOutputHelper output) : EventTest(output)
{
[Fact]
public void VerifySerialization()
{
AgentToolRequest copy =
VerifyEventSerialization(
new AgentToolRequest(
"agent",
[
new FunctionCallContent("call1", "result1"),
new FunctionCallContent("call2", "result2", new Dictionary<string, object?>() { { "name", "Clam Chowder" } })
]));
Assert.Equal("agent", copy.AgentName);
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Extensions.AI;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events;
/// <summary>
/// Base class for event tests.
/// </summary>
public sealed class AgentToolResponseTest(ITestOutputHelper output) : EventTest(output)
{
[Fact]
public void VerifySerialization()
{
AgentToolResponse copy =
VerifyEventSerialization(
new AgentToolResponse(
"agent",
[
new FunctionResultContent("call1", "result1"),
new FunctionResultContent("call2", "result2")
]));
Assert.Equal("agent", copy.AgentName);
}
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
/// <summary>
/// Base class for event tests.
/// </summary>
public abstract class EventTest(ITestOutputHelper output) : WorkflowTest(output)
{
protected static TEvent VerifyEventSerialization<TEvent>(TEvent source)
{
string? text = JsonSerializer.Serialize(source);
Assert.NotNull(text);
TEvent? copy = JsonSerializer.Deserialize<TEvent>(text);
Assert.NotNull(copy);
return copy;
}
}
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events;
/// <summary>
/// Base class for event tests.
/// </summary>
public sealed class InputRequestTest(ITestOutputHelper output) : EventTest(output)
{
[Fact]
public void VerifySerialization()
{
InputRequest copy = VerifyEventSerialization(new InputRequest("wassup"));
Assert.Equal("wassup", copy.Prompt);
}
}
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events;
/// <summary>
/// Base class for event tests.
/// </summary>
public sealed class InputResponseTest(ITestOutputHelper output) : EventTest(output)
{
[Fact]
public void VerifySerialization()
{
InputResponse copy = VerifyEventSerialization(new InputResponse("test response"));
Assert.Equal("test response", copy.Value);
}
}