mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
@@ -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]);
|
||||
}
|
||||
}
|
||||
+3
@@ -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)
|
||||
|
||||
+2
-2
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-23
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+34
-14
@@ -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}"),
|
||||
};
|
||||
}
|
||||
+73
-4
@@ -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>
|
||||
|
||||
+91
@@ -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; }
|
||||
}
|
||||
}
|
||||
+15
@@ -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.
|
||||
+2
@@ -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();
|
||||
|
||||
+8
-2
@@ -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)
|
||||
|
||||
+32
-14
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+99
@@ -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;
|
||||
}
|
||||
}
|
||||
+28
@@ -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?")
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user