diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs index d0a5f6275f..774ca560f7 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs @@ -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 @@ private async Task ExecuteAsync() #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 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 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 @@ private async Task ExecuteAsync() } // 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 @@ private async Task ExecuteAsync() Notify("\nWORKFLOW: Done!\n"); } + /// + /// Create the workflow from the declarative YAML. Includes definition of the + /// and the associated . + /// + /// + /// The value assigned to controls on whether the function + /// tools () initialized in the constructor are included for auto-invocation. + /// 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 @@ private Workflow CreateWorkflow() return DeclarativeWorkflowBuilder.Build(this.WorkflowFile, options); } + /// + /// Configuration key used to identify the Foundry project endpoint. + /// private const string ConfigKeyFoundryEndpoint = "FOUNDRY_PROJECT_ENDPOINT"; + /// + /// Controls on whether the function tools () initialized + /// in the constructor are included for auto-invocation. + /// NOTE: By default, no functions exist as part of this sample. + /// + private const bool IncludeFunctions = true; + private static Dictionary NameCache { get; } = []; private static HashSet FileCache { get; } = []; @@ -132,6 +161,7 @@ private Workflow CreateWorkflow() private PersistentAgentsClient FoundryClient { get; } private IConfiguration Configuration { get; } private CheckpointInfo? LastCheckpoint { get; set; } + private Dictionary FunctionMap { get; } private Program(string workflowFile, string? workflowInput) { @@ -142,12 +172,21 @@ private Program(string workflowFile, string? workflowInput) this.FoundryEndpoint = this.Configuration[ConfigKeyFoundryEndpoint] ?? throw new InvalidOperationException($"Undefined configuration setting: {ConfigKeyFoundryEndpoint}"); this.FoundryClient = new PersistentAgentsClient(this.FoundryEndpoint, new AzureCliCredential()); + + List 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 MonitorAndDisposeWorkflowRunAsync(Checkpointed run, InputResponse? response = null) + private async Task MonitorAndDisposeWorkflowRunAsync(Checkpointed run, object? response = null) { await using IAsyncDisposable disposeRun = run; + bool hasStreamed = false; string? messageId = null; await foreach (WorkflowEvent workflowEvent in run.Run.WatchStreamAsync().ConfigureAwait(false)) @@ -211,11 +250,12 @@ private Program(string workflowFile, string? workflowInput) 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 @@ private Program(string workflowFile, string? workflowInput) 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 @@ private Program(string workflowFile, string? workflowInput) 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 @@ private Program(string workflowFile, string? workflowInput) return default; } - private static InputResponse HandleExternalRequest(ExternalRequest request) + + /// + /// Handle request for external input, either from a human or a function tool invocation. + /// + private async ValueTask HandleExternalRequestAsync(ExternalRequest request) => + request.Data.TypeId.TypeName switch + { + // Request for human input + _ when request.Data.TypeId.IsMatch() => HandleInputRequest(request.DataAs()!), + // Request for function tool invocation. (Only active when functions are defined and IncludeFunctions is true.) + _ when request.Data.TypeId.IsMatch() => await this.HandleToolRequestAsync(request.DataAs()!), + // Unknown request type. + _ => throw new InvalidOperationException($"Unsupported external request type: {request.GetType().Name}."), + }; + + /// + /// Handle request for human input. + /// + private static InputResponse HandleInputRequest(InputRequest request) { - InputRequest? message = request.Data.As(); 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 @@ private static InputResponse HandleExternalRequest(ExternalRequest request) return new InputResponse(userInput); } + /// + /// Handle a function tool request by invoking the specified tools and returning the results. + /// + /// + /// This handler is only active when is set to true and + /// one or more instances are defined in the constructor. + /// + private async ValueTask HandleToolRequestAsync(AgentToolRequest request) + { + Task[] functionTasks = request.FunctionCalls.Select(functionCall => InvokesToolAsync(functionCall)).ToArray(); + + await Task.WhenAll(functionTasks); + + return AgentToolResponse.Create(request, functionTasks.Select(task => task.Result)); + + async Task 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(); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs index 0ff167d50d..8ad65c3523 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs @@ -97,8 +97,41 @@ IEnumerable GetContent() } /// - public override async Task GetAgentAsync(string agentId, CancellationToken cancellationToken = default) => - await this.GetAgentsClient().GetAIAgentAsync(agentId, chatOptions: null, clientFactory: null, cancellationToken).ConfigureAwait(false); + public override async Task 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(); + 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; + } /// public override async Task GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs new file mode 100644 index 0000000000..b1fe34eda1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs @@ -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; + +/// +/// Represents a request for user input. +/// +public sealed class AgentToolRequest +{ + /// + /// The name of the agent associated with the tool request. + /// + public string AgentName { get; } + + /// + /// A list of tool requests. + /// + public IList FunctionCalls { get; } + + [JsonConstructor] + internal AgentToolRequest(string agentName, IList? functionCalls = null) + { + this.AgentName = agentName; + this.FunctionCalls = functionCalls ?? []; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs new file mode 100644 index 0000000000..29a7f98954 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs @@ -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; + +/// +/// Represents a user input response. +/// +public sealed class AgentToolResponse +{ + /// + /// The name of the agent associated with the tool response. + /// + public string AgentName { get; } + + /// + /// A list of tool responses. + /// + public IList FunctionResults { get; } + + /// + /// Initializes a new instance of the class. + /// + [JsonConstructor] + internal AgentToolResponse(string agentName, IList functionResults) + { + this.AgentName = agentName; + this.FunctionResults = functionResults; + } + + /// + /// Factory method to create an from an + /// Ensures that all function calls in the request have a corresponding result. + /// + /// The tool request. + /// On or more function results + /// An that can be provided to the workflow. + /// Not all have a corresponding . + public static AgentToolResponse Create(AgentToolRequest toolRequest, params IEnumerable functionResults) + { + HashSet callIds = [.. toolRequest.FunctionCalls.Select(call => call.CallId)]; + HashSet 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]); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs index f4f3bb65af..e841bd4bbb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs @@ -94,6 +94,9 @@ public static ChatMessage ToChatMessage(this RecordDataValue message) => public static ChatMessage ToChatMessage(this StringDataValue message) => new(ChatRole.User, message.Value); + public static ChatMessage ToChatMessage(this IEnumerable functionResults) => + new(ChatRole.Tool, [.. functionResults]); + public static AdditionalPropertiesDictionary? ToMetadata(this RecordDataValue? metadata) { if (metadata is null) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/DataValueExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/DataValueExtensions.cs index 0fd64bd787..a520593144 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/DataValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/DataValueExtensions.cs @@ -148,9 +148,9 @@ public static RecordDataValue ToRecordValue(this IDictionary value) IEnumerable> GetFields() { - foreach (string key in value.Keys) + foreach (DictionaryEntry entry in value) { - yield return new KeyValuePair(key, value[key].ToDataValue()); + yield return new KeyValuePair((string)entry.Key, entry.Value.ToDataValue()); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs index 123170a8a6..e0425bfbec 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -157,9 +157,9 @@ public static RecordValue ToRecord(this IDictionary value) IEnumerable 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()); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ObjectExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ObjectExtensions.cs index 7040af4018..0ce1e2a28e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ObjectExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ObjectExtensions.cs @@ -76,9 +76,9 @@ public static object AsPortable(this IDictionary value) IEnumerable> GetEntries() { - foreach (string key in value.Keys) + foreach (DictionaryEntry entry in value) { - yield return new KeyValuePair(key, value[key]); + yield return new KeyValuePair((string)entry.Key, entry.Value); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PortableValueExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PortableValueExtensions.cs index 7d041f8a2b..17e7579d9f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PortableValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PortableValueExtensions.cs @@ -64,19 +64,20 @@ TableValue NewSingleColumnTable() => FormulaValue.NewSingleColumnTable(formulaValues.OfType>()); } - private static RecordType ParseRecordType(this RecordValue record) + public static bool IsSystemType(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue) where TValue : struct { - RecordType recordType = RecordType.Empty(); - foreach (NamedValue property in record.Fields) + if (value.TypeId.IsMatch() || value.TypeId.IsMatch(typeof(TValue).UnderlyingSystemType)) { - recordType = recordType.Add(property.Name, property.Value.Type); + return value.Is(out typedValue); } - return recordType; + + typedValue = default; + return false; } - private static bool IsParentType(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue) + public static bool IsType(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue) { - if (value.TypeId.IsMatchPolymorphic(typeof(TValue))) + if (value.TypeId.IsMatch()) { return value.Is(out typedValue); } @@ -85,9 +86,9 @@ private static bool IsParentType(this PortableValue value, [NotNullWhen( return false; } - private static bool IsSystemType(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue) where TValue : struct + public static bool IsParentType(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue) { - if (value.TypeId.IsMatch() || value.TypeId.IsMatch(typeof(TValue).UnderlyingSystemType)) + if (value.TypeId.IsMatchPolymorphic(typeof(TValue))) { return value.Is(out typedValue); } @@ -96,14 +97,13 @@ private static bool IsSystemType(this PortableValue value, [NotNullWhen( return false; } - private static bool IsType(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue) + private static RecordType ParseRecordType(this RecordValue record) { - if (value.TypeId.IsMatch()) + RecordType recordType = RecordType.Empty(); + foreach (NamedValue property in record.Fields) { - return value.Is(out typedValue); + recordType = recordType.Add(property.Name, property.Value.Type); } - - typedValue = default; - return false; + return recordType; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 33cd833811..377d65c99b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -236,32 +236,29 @@ protected override void Visit(Question item) { 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(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(captureId, this._workflowState, action.CaptureResponseAsync, emitResult: false), parentId); + string captureId = QuestionExecutor.Steps.Capture(action.Id); + this.ContinueWith(new DelegateActionExecutor(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 @@ protected override void Visit(InvokeAzureAgent item) { 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(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(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) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/PortableValueExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/PortableValueExtensions.cs new file mode 100644 index 0000000000..ab9a196091 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/PortableValueExtensions.cs @@ -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; + +/// +/// Extension helpers for converting instances (and collections containing them) +/// into their normalized runtime representations (primarily primitives) ready for evaluation. +/// +public static class PortableValueExtensions +{ + /// + /// Normalizes all values in the provided dictionary. Each entry whose value is a + /// is converted to its underlying normalized representation; non-PortableValue entries are preserved as-is. + /// + /// The source dictionary whose values may contain instances; may be null. + /// + /// A new dictionary with normalized values, or null if is null. + /// Keys are copied unchanged. + /// + public static IDictionary? NormalizePortableValues(this IDictionary? source) + { + if (source is null) + { + return null; + } + + return source.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.NormalizePortableValue()); + } + + /// + /// Normalizes an arbitrary value if it is a ; otherwise returns the value unchanged. + /// + /// The value to normalize; may be null or already a primitive/object. + /// + /// Null if is null; the normalized result if it is a ; + /// otherwise the original . + /// + public static object? NormalizePortableValue(this object? value) => + Throw.IfNull(value, nameof(value)) switch + { + null => null, + JsonElement jsonValue => jsonValue.GetValue(), + PortableValue portableValue => portableValue.Normalize(), + _ => value, + }; + + /// + /// Converts a into a concrete representation suitable for evaluation. + /// + /// The portable value to normalize; cannot be null. + /// + /// A instance representing the underlying value. + /// + 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 NormalizePortableValues(this IDictionary source) + { + return GetValues().ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + IEnumerable> GetValues() + { + foreach (DictionaryEntry entry in source) + { + yield return new KeyValuePair((string)entry.Key, entry.Value.NormalizePortableValue()); + } + } + } + + private static object?[] NormalizePortableValues(this IEnumerable source) => + source.Cast().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}"), + }; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs index 9d0c2eb3d5..b26add9f81 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -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(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 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? messages, CancellationToken cancellationToken) { string? conversationId = this.GetConversationId(); string agentName = this.GetAgentName(); string? additionalInstructions = this.GetAdditionalInstructions(); bool autoSend = this.GetAutoSendValue(); - IEnumerable? inputMessages = this.GetInputMessages(); - AgentRunResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, additionalInstructions, inputMessages, cancellationToken).ConfigureAwait(false); + bool isComplete = true; - await this.AssignAsync(this.AgentOutput?.Messages?.Path, agentResponse.Messages.ToTable(), context).ConfigureAwait(false); + AgentRunResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, additionalInstructions, messages, cancellationToken).ConfigureAwait(false); - return default; + if (string.IsNullOrEmpty(agentResponse.Text)) + { + // Identify function calls that have no associated result. + List 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); } private IEnumerable? GetInputMessages() @@ -48,6 +95,28 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA return userInput?.ToChatMessages(); } + private List GetOrphanedFunctionCalls(AgentRunResponse agentResponse) + { + HashSet functionResultIds = + [.. agentResponse.Messages + .SelectMany( + m => + m.Contents + .OfType() + .Select(functionCall => functionCall.CallId))]; + + List functionCalls = []; + foreach (FunctionCallContent functionCall in agentResponse.Messages.SelectMany(m => m.Contents.OfType())) + { + if (!functionResultIds.Contains(functionCall.CallId)) + { + functionCalls.Add(functionCall); + } + } + + return functionCalls; + } + private string? GetConversationId() { if (this.Model.ConversationId is null) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs index 4245d828b2..9db57aba35 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs @@ -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; /// public abstract class WorkflowAgentProvider { + /// + /// 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 is executed + /// that provides an that describes the function calls requested. The caller may + /// then respond with a corrsponding that includes the results of the function calls. + /// + /// + /// These will not impact the requests sent to the model by the . + /// + public IEnumerable? Functions { get; init; } + + /// + /// Gets or sets a value indicating whether to allow concurrent invocation of functions. + /// + /// + /// if multiple function calls can execute in parallel. + /// if function calls are processed serially. + /// The default value is . + /// + /// + /// An individual response from the inner client might contain multiple function call requests. + /// By default, such function calls are processed serially. Set to + /// to enable concurrent invocation such that multiple function calls can execute in parallel. + /// + public bool AllowConcurrentInvocation { get; init; } + + /// + /// Gets or sets a flag to indicate whether a single response is allowed to include multiple tool calls. + /// If , the is asked to return a maximum of one tool call per request. + /// If , there is no limit. + /// If , the provider may select its own default. + /// + /// + /// + /// 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. + /// + /// + /// 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. + /// + /// + public bool AllowMultipleToolCalls { get; init; } + /// /// Asynchronously retrieves an AI agent by its unique identifier. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MenuPlugin.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MenuPlugin.cs new file mode 100644 index 0000000000..d69e856af8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MenuPlugin.cs @@ -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 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; } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/ToolAgent.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/ToolAgent.yaml new file mode 100644 index 0000000000..17cc84b010 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/ToolAgent.yaml @@ -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. diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/AgentFactory.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/AgentFactory.cs index 6cd29a0854..4ceee44c6b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/AgentFactory.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/AgentFactory.cs @@ -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(); AgentCreationOptions creationOptions = new() { Kernel = kernelBuilder.Build() }; AzureAIAgentFactory factory = new(); string repoRoot = WorkflowTest.GetRepoFolder(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs index deccff658d..de83afd723 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs @@ -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 @@ protected static void SetProduct() internal static string FormatVariablePath(string variableName, string? scope = null) => $"{scope ?? WorkflowFormulaState.DefaultScopeName}.{variableName}"; - protected async ValueTask CreateOptionsAsync(bool externalConversation = false) + protected async ValueTask CreateOptionsAsync(bool externalConversation = false, params IEnumerable functionTools) { FrozenDictionary agentMap = await AgentFactory.GetAgentsAsync(this.FoundryConfiguration, this.Configuration); @@ -75,7 +77,11 @@ protected async ValueTask CreateOptionsAsync(bool ex .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) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs index 73be852931..4e5e775b92 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs @@ -8,6 +8,7 @@ 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 @@ public async Task RunTestcaseAsync(Testcase testcase, TI 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 @@ public async Task RunWorkflowAsync(TInput input, bool us return new WorkflowEvents(workflowEvents); } + public async Task ResumeAsync(object response) + { + Console.WriteLine("\nRESUMING WORKFLOW..."); + Assert.NotNull(this.LastCheckpoint); + Checkpointed run = await InProcessExecution.ResumeStreamAsync(workflow, this.LastCheckpoint, this.GetCheckpointManager(), runId); + IReadOnlyList workflowEvents = await MonitorAndDisposeWorkflowRunAsync(run, response).ToArrayAsync(); + return new WorkflowEvents(workflowEvents); + } + public static async Task GenerateCodeAsync( string runId, string workflowProviderCode, @@ -73,7 +82,7 @@ private CheckpointManager GetCheckpointManager(bool useJson = false) { 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 @@ private CheckpointManager GetCheckpointManager(bool useJson = false) return this._checkpointManager; } - private async Task ResumeAsync(InputResponse response) - { - Console.WriteLine("RESUMING WORKFLOW..."); - Assert.NotNull(this.LastCheckpoint); - Checkpointed run = await InProcessExecution.ResumeStreamAsync(workflow, this.LastCheckpoint, this.GetCheckpointManager(), runId); - IReadOnlyList workflowEvents = await MonitorAndDisposeWorkflowRunAsync(run, response).ToArrayAsync(); - return new WorkflowEvents(workflowEvents); - } - - private static async IAsyncEnumerable MonitorAndDisposeWorkflowRunAsync(Checkpointed run, InputResponse? response = null) + private static async IAsyncEnumerable MonitorAndDisposeWorkflowRunAsync(Checkpointed run, object? response = null) { await using IAsyncDisposable disposeRun = run; @@ -128,9 +128,27 @@ private static async IAsyncEnumerable MonitorAndDisposeWorkflowRu 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())) + { + Console.WriteLine($"TOOL: {toolCall.Name} [{responseEvent.Response.AgentId}]"); + } + } + break; } yield return workflowEvent; @@ -141,6 +159,6 @@ private static async IAsyncEnumerable MonitorAndDisposeWorkflowRu } } - Console.WriteLine("SUSPENDING WORKFLOW..."); + Console.WriteLine("SUSPENDING WORKFLOW...\n"); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs new file mode 100644 index 0000000000..995a92bfc3 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs @@ -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; + +/// +/// Tests execution of workflow created by . +/// +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 functionTools) + { + string workflowPath = GetWorkflowPath("FunctionTool.yaml"); + Dictionary functionMap = autoInvoke ? [] : functionTools.ToDictionary(tool => tool.Name, tool => tool); + DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation: false, autoInvoke ? functionTools : []); + Workflow workflow = DeclarativeWorkflowBuilder.Build(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(); + 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 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> InvokeToolsAsync(IEnumerable<(FunctionCallContent, AIFunction)> functionCalls) + { + List 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; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/FunctionTool.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/FunctionTool.yaml new file mode 100644 index 0000000000..8cb65db8d6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/FunctionTool.yaml @@ -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?") diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentToolRequestTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentToolRequestTest.cs new file mode 100644 index 0000000000..61be304bd4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentToolRequestTest.cs @@ -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; + +/// +/// Base class for event tests. +/// +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() { { "name", "Clam Chowder" } }) + ])); + Assert.Equal("agent", copy.AgentName); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentToolResponseTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentToolResponseTest.cs new file mode 100644 index 0000000000..f6258cb33e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentToolResponseTest.cs @@ -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; + +/// +/// Base class for event tests. +/// +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); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/EventTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/EventTest.cs new file mode 100644 index 0000000000..0f573aba7e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/EventTest.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests; + +/// +/// Base class for event tests. +/// +public abstract class EventTest(ITestOutputHelper output) : WorkflowTest(output) +{ + protected static TEvent VerifyEventSerialization(TEvent source) + { + string? text = JsonSerializer.Serialize(source); + Assert.NotNull(text); + TEvent? copy = JsonSerializer.Deserialize(text); + Assert.NotNull(copy); + return copy; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/InputRequest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/InputRequest.cs new file mode 100644 index 0000000000..d9242307dc --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/InputRequest.cs @@ -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; + +/// +/// Base class for event tests. +/// +public sealed class InputRequestTest(ITestOutputHelper output) : EventTest(output) +{ + [Fact] + public void VerifySerialization() + { + InputRequest copy = VerifyEventSerialization(new InputRequest("wassup")); + Assert.Equal("wassup", copy.Prompt); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/InputResponse.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/InputResponse.cs new file mode 100644 index 0000000000..6304aef69b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/InputResponse.cs @@ -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; + +/// +/// Base class for event tests. +/// +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); + } +}