From fc75fdf7b8cd8435832aa4d8a0d90e883d558ead Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Fri, 10 Oct 2025 12:17:59 -0700 Subject: [PATCH 01/21] Checkpoint --- .../Events/AgentToolRequest.cs | 29 ++++++ .../Events/AgentToolResponse.cs | 37 ++++++++ .../Interpreter/WorkflowActionVisitor.cs | 48 +++++++--- .../ObjectModel/InvokeAzureAgentExecutor.cs | 62 +++++++++++++ .../Agents/MenuPlugin.cs | 91 +++++++++++++++++++ .../Agents/ToolAgent.yaml | 15 +++ .../Framework/AgentFactory.cs | 2 + .../Framework/WorkflowHarness.cs | 26 +++++- .../ToolInputWorkflowTest.cs | 69 ++++++++++++++ .../Workflows/FunctionTool.yaml | 28 ++++++ 10 files changed, 389 insertions(+), 18 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MenuPlugin.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/ToolAgent.yaml create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/FunctionTool.yaml 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..0cbee87244 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.Declarative.Events; + +/// +/// Represents a request for user input. +/// +public sealed class AgentToolRequest +{ + /// + /// %%% COMMENT + /// + public string AgentName { get; } + + /// + /// %%% COMMENT + /// + public IReadOnlyList FunctionCalls { get; } + + internal AgentToolRequest(string agentName, IEnumerable functionCalls) + { + this.AgentName = agentName; + this.FunctionCalls = functionCalls.ToImmutableArray(); + } +} 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..5d1df29c40 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.Declarative.Events; + +/// +/// Represents a user input response. +/// +public sealed class AgentToolResponse +{ + /// + /// %%% COMMENT + /// + public string AgentName { get; } + + /// + /// %%% COMMENT + /// + public IReadOnlyList FunctionResults { get; } + + /// + /// Initializes a new instance of the class. + /// + public AgentToolResponse(string agentName, params IEnumerable functionResults) + { + this.AgentName = agentName; + this.FunctionResults = functionResults.ToImmutableArray(); + } + + internal static AgentToolResponse Create(AgentToolRequest toolRequest, params IEnumerable functionResults) => + // %%% TOOL: VERIFY MATCH + new(toolRequest.AgentName, functionResults); +} 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/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs index 9d0c2eb3d5..c12d3c7e10 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -1,25 +1,42 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.IO; +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; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; +using static Microsoft.Agents.AI.Workflows.Declarative.PowerFx.TypeSchema; 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) { string? conversationId = this.GetConversationId(); @@ -30,11 +47,56 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA AgentRunResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, additionalInstructions, inputMessages, cancellationToken).ConfigureAwait(false); + bool isComplete = true; + if (string.IsNullOrEmpty(agentResponse.Text)) + { + Dictionary toolCalls = agentResponse.Messages.SelectMany(m => m.Contents.OfType()).ToDictionary(tool => tool.CallId); + HashSet pendingToolCalls = toolCalls.Keys.Except(agentResponse.Messages.SelectMany(m => m.Contents.OfType()).Select(tool => tool.CallId)).ToHashSet(); + if (pendingToolCalls.Count > 0) + { + AgentToolRequest toolRequest = + new(agentName, + toolCalls + .Where(toolCall => pendingToolCalls.Contains(toolCall.Value.CallId)) + .Select(toolCall => toolCall.Value)); + await context.SendMessageAsync(toolRequest, targetId: null, cancellationToken).ConfigureAwait(false); + isComplete = 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; } + public async ValueTask ResumeAsync(IWorkflowContext context, AgentToolResponse message, CancellationToken cancellationToken) + { + string? conversationId = this.GetConversationId(); + string agentName = this.GetAgentName(); + string? additionalInstructions = this.GetAdditionalInstructions(); + bool autoSend = this.GetAutoSendValue(); + ChatMessage resultMessage = new(ChatRole.Tool, [.. message.FunctionResults]); + + //await agentProvider.CreateMessageAsync(conversationId!, resultMessage, cancellationToken).ConfigureAwait(false); // %%% HAXX + + // %%% TOOL: CONVERGE + AgentRunResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, additionalInstructions, [resultMessage], cancellationToken).ConfigureAwait(false); + + await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false); + + await this.AssignAsync(this.AgentOutput?.Messages?.Path, agentResponse.Messages.ToTable(), context).ConfigureAwait(false); + } + + public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken) + { + await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); + } + private IEnumerable? GetInputMessages() { DataValue? userInput = null; 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..1a243fe004 --- /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); + yield return AIFunctionFactory.Create(this.GetSpecials); + yield return AIFunctionFactory.Create(this.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 menuItem) + { + return s_menuItems.FirstOrDefault(i => i.Name.Equals(menuItem, 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/WorkflowHarness.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs index 8ad1def744..59cc9ba82f 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 @@ -6,6 +6,7 @@ using System.Reflection; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Microsoft.Extensions.AI; using Shared.Code; using Xunit.Sdk; @@ -27,9 +28,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; } @@ -46,7 +46,7 @@ public async Task RunWorkflowAsync(TInput input) where T return new WorkflowEvents(workflowEvents); } - private async Task ResumeAsync(InputResponse response) + public async Task ResumeAsync(object response) { Console.WriteLine("RESUMING WORKFLOW..."); Assert.NotNull(this.LastCheckpoint); @@ -76,7 +76,7 @@ public static async Task GenerateCodeAsync( return new WorkflowHarness(workflow, runId); } - 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; @@ -107,9 +107,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; 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..1ed6503497 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs @@ -0,0 +1,69 @@ +// 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.IntegrationTests.Framework; +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 ValidateAutoInvoke() => + this.RunWorkflowAsync(); + + [Fact] + public Task ValidateRequestInvoke() => + this.RunWorkflowAsync(autoInvoke: false); + + private static string GetWorkflowPath(string workflowFileName) => Path.Combine(Environment.CurrentDirectory, "Workflows", workflowFileName); + + private async Task RunWorkflowAsync(bool autoInvoke = true) + { + MenuPlugin menuTools = new(); + string workflowPath = GetWorkflowPath("FunctionTool.yaml"); + DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(); // %%% TOOL: OPTIONS WITH AIFUNCTION + Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); + + WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath)); + WorkflowEvents workflowEvents = await harness.RunWorkflowAsync("hi!").ConfigureAwait(false); + int requestCount = 0; + while (workflowEvents.InputEvents.Count > requestCount) + { + RequestInfoEvent inputEvent = workflowEvents.InputEvents[workflowEvents.InputEvents.Count - 1]; + AgentToolRequest? toolRequest = inputEvent.Request.Data.As(); + Assert.NotNull(toolRequest); + + List functionResults = []; + foreach (FunctionCallContent functionCall in toolRequest.FunctionCalls) + { + this.Output.WriteLine($"TOOL REQUEST: {functionCall.Name}"); + // %%% TOOL: INVOKE WHEN AUTOINVOKE FALSE + Assert.False(autoInvoke); + AIFunction menuTool = menuTools.GetTools().First(); + object? result = await menuTool.InvokeAsync(new AIFunctionArguments(functionCall.Arguments)); + functionResults.Add(new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result))); // %%% JSON CONVERSION + } + WorkflowEvents runEvents = await harness.ResumeAsync(AgentToolResponse.Create(toolRequest, functionResults)).ConfigureAwait(false); + workflowEvents = new WorkflowEvents([.. workflowEvents.Events, .. runEvents.Events]); + requestCount = workflowEvents.InputEvents.Count; + if (requestCount > 0) // TOOL: HAXX + { + break; + } + } + + Assert.NotEmpty(workflowEvents.InputEvents); + // %%% TOOL: MORE VALIDATION + } +} 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?") From 6491ab7feb43360689cbf0ab2711e768da2f21ad Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 13 Oct 2025 08:23:34 -0700 Subject: [PATCH 02/21] Checkpoint --- .../Events/AgentToolRequest.cs | 4 ++-- .../Events/AgentToolResponse.cs | 5 ++--- .../Agents/MenuPlugin.cs | 6 +++--- .../Framework/WorkflowHarness.cs | 3 +-- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs index 0cbee87244..bb7ff2a81c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs @@ -12,12 +12,12 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Events; public sealed class AgentToolRequest { /// - /// %%% COMMENT + /// The name of the agent associated with the tool request. /// public string AgentName { get; } /// - /// %%% COMMENT + /// A list of tool requests. /// public IReadOnlyList FunctionCalls { get; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs index 5d1df29c40..9014dc90a9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Declarative.Events; @@ -13,12 +12,12 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Events; public sealed class AgentToolResponse { /// - /// %%% COMMENT + /// The name of the agent associated with the tool response. /// public string AgentName { get; } /// - /// %%% COMMENT + /// A list of tool responses. /// public IReadOnlyList FunctionResults { get; } 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 index 1a243fe004..5a06ae4523 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MenuPlugin.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MenuPlugin.cs @@ -13,9 +13,9 @@ public sealed class MenuPlugin { public IEnumerable GetTools() { - yield return AIFunctionFactory.Create(this.GetMenu); - yield return AIFunctionFactory.Create(this.GetSpecials); - yield return AIFunctionFactory.Create(this.GetItemPrice); + 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.")] 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 8b4a4652f1..16eecf1a78 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 @@ -48,8 +48,7 @@ public async Task RunWorkflowAsync(TInput input, bool us return new WorkflowEvents(workflowEvents); } - - private async Task ResumeAsync(object response) + public async Task ResumeAsync(object response) { Console.WriteLine("RESUMING WORKFLOW..."); Assert.NotNull(this.LastCheckpoint); From ca2207e7d33bb8e3a5db5b27223d46a8cdd49111 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 13 Oct 2025 11:19:39 -0700 Subject: [PATCH 03/21] Checkpoint --- .../AzureAgentProvider.cs | 96 +------------- .../Events/AgentToolRequest.cs | 2 + .../Events/AgentToolResponse.cs | 18 ++- .../ObjectModel/InvokeAzureAgentExecutor.cs | 120 ++++++++++++------ .../WorkflowAgentProvider.cs | 38 ++++++ .../Framework/IntegrationTest.cs | 10 +- .../Framework/WorkflowHarness.cs | 4 +- .../ToolInputWorkflowTest.cs | 50 +++++--- 8 files changed, 181 insertions(+), 157 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs index 35da64887c..b43f1e8e1d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Collections.Generic; -using System.ComponentModel; using System.Linq; using System.Net.Http; using System.Runtime.CompilerServices; @@ -107,24 +105,17 @@ public override async Task GetAgentAsync(string agentId, CancellationTo ChatOptions = new ChatOptions() { - AllowMultipleToolCalls = true, + AllowMultipleToolCalls = true, // %%% CONFIG }, }; PersistentAgentsClient foundryClient = this.GetAgentsClient(); - PersistentAgent foundryAgent = await foundryClient.Administration.GetAgentAsync(agentId, cancellationToken).ConfigureAwait(false); - if (foundryAgent.Tools.Any(tool => tool is FunctionToolDefinition)) - { - agentOptions.ChatOptions.Tools = [.. new MenuPlugin().GetTools().Select(t => t.AsDeclarationOnly())]; // %%% TOOL: HAXX - REMOVE - // %%% TOOL: DESCRIBE - //IEnumerable tools = foundryAgent.Tools.OfType().Select(tool => tool.AsAITool()); - //IEnumerable tools = []; - } - IChatClient chatClient = foundryClient.AsIChatClient(agentId, defaultThreadId: null); // %%% TOOL: REDUNDANT ROUNDTRIP + IChatClient chatClient = foundryClient.AsIChatClient(agentId, defaultThreadId: null); ChatClientAgent agent = new(chatClient, agentOptions, loggerFactory: null, services: null); FunctionInvokingChatClient? functionInvokingClient = agent.GetService(); if (functionInvokingClient is not null) { + // Allows the caller to respond with function responses functionInvokingClient.TerminateOnUnknownCalls = true; functionInvokingClient.AllowConcurrentInvocation = true; } @@ -213,84 +204,3 @@ IEnumerable GetContent() } } } - -internal sealed class MenuPlugin // %%% TOOL: HAXX - REMOVE -{ - public IEnumerable GetTools() - { - yield return AIFunctionFactory.Create(this.GetMenu); // %%% , $"{nameof(MenuPlugin)}_{nameof(GetMenu)}"); - yield return AIFunctionFactory.Create(this.GetSpecials); // %%% , $"{nameof(MenuPlugin)}_{nameof(GetSpecials)}"); - yield return AIFunctionFactory.Create(this.GetItemPrice); // %%% , $"{nameof(MenuPlugin)}_{nameof(GetItemPrice)}"); - } - - [Description("Provides a list items on the menu.")] - public MenuItem[] GetMenu() - { - return s_menuItems; - } - - [Description("Provides a list of specials from the menu.")] - public MenuItem[] GetSpecials() - { - return [.. s_menuItems.Where(i => i.IsSpecial)]; - } - - [Description("Provides the price of the requested menu item.")] - public float? GetItemPrice( - [Description("The name of the menu item.")] - string menuItem) - { - return s_menuItems.FirstOrDefault(i => i.Name.Equals(menuItem, 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/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs index bb7ff2a81c..c879e129a6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.Immutable; +using System.Text.Json.Serialization; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Declarative.Events; @@ -21,6 +22,7 @@ public sealed class AgentToolRequest /// public IReadOnlyList FunctionCalls { get; } + [JsonConstructor] internal AgentToolRequest(string agentName, IEnumerable functionCalls) { this.AgentName = agentName; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs index 9014dc90a9..aa15b76152 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; +using System.Text.Json.Serialization; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Declarative.Events; @@ -24,13 +26,21 @@ public sealed class AgentToolResponse /// /// Initializes a new instance of the class. /// - public AgentToolResponse(string agentName, params IEnumerable functionResults) + [JsonConstructor] + internal AgentToolResponse(string agentName, params IEnumerable functionResults) { this.AgentName = agentName; this.FunctionResults = functionResults.ToImmutableArray(); } - internal static AgentToolResponse Create(AgentToolRequest toolRequest, params IEnumerable functionResults) => - // %%% TOOL: VERIFY MATCH - new(toolRequest.AgentName, functionResults); + internal static AgentToolResponse Create(AgentToolRequest toolRequest, params IEnumerable functionResults) + { + HashSet callIds = toolRequest.FunctionCalls.Select(call => call.CallId).ToHashSet(); + HashSet resultIds = functionResults.Select(call => call.CallId).ToHashSet(); + if (!callIds.SetEquals(resultIds)) + { + throw new DeclarativeActionException("Mismatched function call IDs between request and results."); // %%% EXECEPTION MESSAGE + } + return new AgentToolResponse(toolRequest.AgentName, functionResults); + } } 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 c12d3c7e10..0d2c1ca346 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -1,8 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; -using System.IO; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Events; @@ -14,7 +14,6 @@ using Microsoft.Bot.ObjectModel.Abstractions; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; -using static Microsoft.Agents.AI.Workflows.Declarative.PowerFx.TypeSchema; namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; @@ -38,31 +37,79 @@ public static class Steps 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) => + // %%% FUNCTION: AUTO EXECUTE EXISTING FUNCTIONS + this.InvokeAgentAsync(context, [new ChatMessage(ChatRole.Tool, [.. message.FunctionResults])], 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; - if (string.IsNullOrEmpty(agentResponse.Text)) + bool isComplete; + AgentRunResponse agentResponse; + do { - Dictionary toolCalls = agentResponse.Messages.SelectMany(m => m.Contents.OfType()).ToDictionary(tool => tool.CallId); - HashSet pendingToolCalls = toolCalls.Keys.Except(agentResponse.Messages.SelectMany(m => m.Contents.OfType()).Select(tool => tool.CallId)).ToHashSet(); - if (pendingToolCalls.Count > 0) + agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, additionalInstructions, messages, cancellationToken).ConfigureAwait(false); + + isComplete = true; + if (string.IsNullOrEmpty(agentResponse.Text)) { - AgentToolRequest toolRequest = - new(agentName, - toolCalls - .Where(toolCall => pendingToolCalls.Contains(toolCall.Value.CallId)) - .Select(toolCall => toolCall.Value)); - await context.SendMessageAsync(toolRequest, targetId: null, cancellationToken).ConfigureAwait(false); - isComplete = false; + IEnumerable toolCallsSequential = agentResponse.Messages.SelectMany(m => m.Contents.OfType()); + HashSet pendingToolCalls = []; + List<(FunctionCallContent, AIFunction)> availableTools = []; +#pragma warning disable CA1851 // %%% PRAGMA COLLECTION: Possible multiple enumerations of 'IEnumerable' collection + foreach (FunctionCallContent functionCall in toolCallsSequential) + { + if (agentProvider.TryGetFunctionTool(functionCall.Name, out AIFunction? functionTool)) + { + availableTools.Add((functionCall, functionTool)); + } + else + { + pendingToolCalls.Add(functionCall.CallId); + } + } + + isComplete = pendingToolCalls.Count == 0; + + if (isComplete && availableTools.Count > 0) // %%% FUNCTION: isComplete = false => INVOKE LATER WHEN RESULTS RETURNED + { + // All tools are available, invoke them. + IList functionResults = await InvokeToolsAsync(availableTools, cancellationToken).ConfigureAwait(false); + messages = [new ChatMessage(ChatRole.Tool, [.. functionResults])]; // %%% FUNCTION: DRY !!! + isComplete = false; + } + + if (pendingToolCalls.Count > 0) + { + Dictionary toolCalls = toolCallsSequential.ToDictionary(tool => tool.CallId); + AgentToolRequest toolRequest = + new(agentName, + toolCalls + .Where(toolCall => pendingToolCalls.Contains(toolCall.Value.CallId)) + .Select(toolCall => toolCall.Value)); + await context.SendMessageAsync(toolRequest, targetId: null, cancellationToken).ConfigureAwait(false); + isComplete = false; + break; + } +#pragma warning restore CA1851 } } + while (!isComplete); if (isComplete) { @@ -70,31 +117,26 @@ public static class Steps } await this.AssignAsync(this.AgentOutput?.Messages?.Path, agentResponse.Messages.ToTable(), context).ConfigureAwait(false); - - return default; } - public async ValueTask ResumeAsync(IWorkflowContext context, AgentToolResponse message, CancellationToken cancellationToken) + private static async ValueTask> InvokeToolsAsync(IEnumerable<(FunctionCallContent, AIFunction)> functionCalls, CancellationToken cancellationToken) // %%% FUNCTION: DRY !!! { - string? conversationId = this.GetConversationId(); - string agentName = this.GetAgentName(); - string? additionalInstructions = this.GetAdditionalInstructions(); - bool autoSend = this.GetAutoSendValue(); - ChatMessage resultMessage = new(ChatRole.Tool, [.. message.FunctionResults]); - - //await agentProvider.CreateMessageAsync(conversationId!, resultMessage, cancellationToken).ConfigureAwait(false); // %%% HAXX - - // %%% TOOL: CONVERGE - AgentRunResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, additionalInstructions, [resultMessage], cancellationToken).ConfigureAwait(false); - - await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false); - - await this.AssignAsync(this.AgentOutput?.Messages?.Path, agentResponse.Messages.ToTable(), context).ConfigureAwait(false); - } - - public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken) - { - await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); + List results = []; + foreach ((FunctionCallContent functionCall, AIFunction functionTool) in functionCalls) // %%% PARALLEL + { + AIFunctionArguments functionArguments = new(functionCall.Arguments); // %%% FUNCTION: PORTABLE + if (functionArguments.Count > 0) + { + functionArguments = new(new Dictionary() { { "menuItem", "Clam Chowder" } }); + } + object? result = await functionTool.InvokeAsync(functionArguments, cancellationToken).ConfigureAwait(false); // %%% MEAI COMMON ??? +#pragma warning disable IL2026 // %%% PRAGMA JSON: Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code +#pragma warning disable IL3050 // %%% PRAGMA JSON: Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling. + results.Add(new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result))); // %%% JSON CONVERSION +#pragma warning restore IL3050 +#pragma warning restore IL2026 + } + return results; } private IEnumerable? GetInputMessages() diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs index 4245d828b2..2b8f38498f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -12,6 +14,19 @@ namespace Microsoft.Agents.AI.Workflows.Declarative; /// public abstract class WorkflowAgentProvider { + /// + /// Functions that can be used by the AI agents based on their definition. + /// + /// + /// %%% COMMENT + /// A instance will be automatically executed, when: + /// CASE 1 - AIAgent.ChatOptions.Tools (AIFunction) => AUTO + /// CASE 2 - AzureAgent Tools OR AIAgent.ChatOptions.Tools (AIFunctionDeclaration) LOOKUP => AUTO + /// Or else: + /// CASE 3 - AzureAgent Tools OR AIAgent.ChatOptions.Tools (AIFunctionDeclaration) MISSING => REQUEST + /// + public IEnumerable? Functions { get; init; } + /// /// Asynchronously retrieves an AI agent by its unique identifier. /// @@ -61,4 +76,27 @@ public abstract IAsyncEnumerable GetMessagesAsync( string? before = null, bool newestFirst = false, CancellationToken cancellationToken = default); + + /// + /// Retrieves the requested function tool by name. + /// + /// Name of the function tool + /// // %%% COMMENT + /// The requested function tool declaration. + /// If function tool is not defined + public bool TryGetFunctionTool(string name, [NotNullWhen(true)] out AIFunction? functionTool) // %%% FUNCTION PROVIDER (OPTIONS: FUNCTIONS/FUNCTION PROVIDER(DEFAULT)) + { + if (this.Functions is not null) + { + this._functionMap ??= this.Functions.ToDictionary(tool => tool.Name, tool => tool); + } + else + { + this._functionMap ??= []; + } + + return this._functionMap.TryGetValue(name, out functionTool); + } + + private Dictionary? _functionMap; } 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 16eecf1a78..c7be52c9a1 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 @@ -50,7 +50,7 @@ public async Task RunWorkflowAsync(TInput input, bool us public async Task ResumeAsync(object response) { - Console.WriteLine("RESUMING WORKFLOW..."); + 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(); @@ -159,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 index 1ed6503497..c72752bd77 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs @@ -7,6 +7,8 @@ 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.Extensions.AI; using Xunit.Abstractions; @@ -20,25 +22,26 @@ public sealed class ToolInputWorkflowTest(ITestOutputHelper output) : Integratio { [Fact] public Task ValidateAutoInvoke() => - this.RunWorkflowAsync(); + this.RunWorkflowAsync(autoInvoke: true, new MenuPlugin().GetTools()); [Fact] public Task ValidateRequestInvoke() => - this.RunWorkflowAsync(autoInvoke: false); + 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 = true) + private async Task RunWorkflowAsync(bool autoInvoke, params IEnumerable functionTools) { - MenuPlugin menuTools = new(); string workflowPath = GetWorkflowPath("FunctionTool.yaml"); - DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(); // %%% TOOL: OPTIONS WITH AIFUNCTION + 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 = 0; - while (workflowEvents.InputEvents.Count > requestCount) + int requestCount = (workflowEvents.InputEvents.Count + 1) / 2; + int responseCount = 0; + while (requestCount > responseCount) { RequestInfoEvent inputEvent = workflowEvents.InputEvents[workflowEvents.InputEvents.Count - 1]; AgentToolRequest? toolRequest = inputEvent.Request.Data.As(); @@ -48,22 +51,35 @@ private async Task RunWorkflowAsync(bool autoInvoke = true) foreach (FunctionCallContent functionCall in toolRequest.FunctionCalls) { this.Output.WriteLine($"TOOL REQUEST: {functionCall.Name}"); - // %%% TOOL: INVOKE WHEN AUTOINVOKE FALSE Assert.False(autoInvoke); - AIFunction menuTool = menuTools.GetTools().First(); - object? result = await menuTool.InvokeAsync(new AIFunctionArguments(functionCall.Arguments)); - functionResults.Add(new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result))); // %%% JSON CONVERSION + if (!functionMap.TryGetValue(functionCall.Name, out AIFunction? functionTool)) + { + Assert.Fail($"TOOL FAILURE [{functionCall.Name}] - MISSING"); + return; + } + AIFunctionArguments functionArguments = new(functionCall.Arguments); // %%% PORTABLE + if (functionArguments.Count > 0) // %%% HAXX + { + functionArguments = new(new Dictionary() { { "menuItem", "Clam Chowder" } }); + } + object? result = await functionTool.InvokeAsync(functionArguments); + functionResults.Add(new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result))); // %%% FUNCTION: JSON CONVERSION } + + ++responseCount; + WorkflowEvents runEvents = await harness.ResumeAsync(AgentToolResponse.Create(toolRequest, functionResults)).ConfigureAwait(false); workflowEvents = new WorkflowEvents([.. workflowEvents.Events, .. runEvents.Events]); - requestCount = workflowEvents.InputEvents.Count; - if (requestCount > 0) // TOOL: HAXX - { - break; - } } - Assert.NotEmpty(workflowEvents.InputEvents); + if (autoInvoke) + { + Assert.Empty(workflowEvents.InputEvents); + } + else + { + Assert.NotEmpty(workflowEvents.InputEvents); + } // %%% TOOL: MORE VALIDATION } } From c5e8c5c739c9a25694dc03ec5d76640f1a40864d Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 13 Oct 2025 14:58:54 -0700 Subject: [PATCH 04/21] Good --- .../AzureAgentProvider.cs | 27 ++++--- .../Events/AgentToolResponse.cs | 2 +- .../Extensions/ChatMessageExtensions.cs | 3 + .../ObjectModel/InvokeAzureAgentExecutor.cs | 80 +++---------------- .../WorkflowAgentProvider.cs | 68 +++++++++------- .../ToolInputWorkflowTest.cs | 37 ++++++--- 6 files changed, 95 insertions(+), 122 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs index b43f1e8e1d..8545249c3b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs @@ -99,26 +99,27 @@ IEnumerable GetContent() /// public override async Task GetAgentAsync(string agentId, CancellationToken cancellationToken = default) { - ChatClientAgentOptions agentOptions = - new() - { - ChatOptions = - new ChatOptions() - { - AllowMultipleToolCalls = true, // %%% CONFIG - }, - }; + ChatClientAgent agent = + await this.GetAgentsClient().GetAIAgentAsync( + agentId, + new ChatOptions() + { + AllowMultipleToolCalls = this.AllowMultipleToolCalls, + }, + clientFactory: null, + cancellationToken).ConfigureAwait(false); - PersistentAgentsClient foundryClient = this.GetAgentsClient(); - IChatClient chatClient = foundryClient.AsIChatClient(agentId, defaultThreadId: null); - ChatClientAgent agent = new(chatClient, agentOptions, loggerFactory: null, services: null); FunctionInvokingChatClient? functionInvokingClient = agent.GetService(); if (functionInvokingClient is not null) { + // Make functions available for execution. Doesn't change what tool is available for any given agent. + functionInvokingClient.AdditionalTools = this.Functions is null ? null : [.. this.Functions.OfType()]; + // Allow concurrent invocations if configured + functionInvokingClient.AllowConcurrentInvocation = this.AllowConcurrentInvocation; // Allows the caller to respond with function responses functionInvokingClient.TerminateOnUnknownCalls = true; - functionInvokingClient.AllowConcurrentInvocation = true; } + return agent; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs index aa15b76152..e21b68aec4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs @@ -39,7 +39,7 @@ internal static AgentToolResponse Create(AgentToolRequest toolRequest, params IE HashSet resultIds = functionResults.Select(call => call.CallId).ToHashSet(); if (!callIds.SetEquals(resultIds)) { - throw new DeclarativeActionException("Mismatched function call IDs between request and results."); // %%% EXECEPTION MESSAGE + 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/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs index 0d2c1ca346..2c2afa07bc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -44,8 +44,7 @@ public static class Steps } public ValueTask ResumeAsync(IWorkflowContext context, AgentToolResponse message, CancellationToken cancellationToken) => - // %%% FUNCTION: AUTO EXECUTE EXISTING FUNCTIONS - this.InvokeAgentAsync(context, [new ChatMessage(ChatRole.Tool, [.. message.FunctionResults])], cancellationToken); + this.InvokeAgentAsync(context, [message.FunctionResults.ToChatMessage()], cancellationToken); public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken) { @@ -59,57 +58,22 @@ private async ValueTask InvokeAgentAsync(IWorkflowContext context, IEnumerable toolCalls = agentResponse.Messages.SelectMany(m => m.Contents.OfType()).ToHashSet(); + + isComplete = toolCalls.Count == 0; - isComplete = true; - if (string.IsNullOrEmpty(agentResponse.Text)) + if (!isComplete) { - IEnumerable toolCallsSequential = agentResponse.Messages.SelectMany(m => m.Contents.OfType()); - HashSet pendingToolCalls = []; - List<(FunctionCallContent, AIFunction)> availableTools = []; -#pragma warning disable CA1851 // %%% PRAGMA COLLECTION: Possible multiple enumerations of 'IEnumerable' collection - foreach (FunctionCallContent functionCall in toolCallsSequential) - { - if (agentProvider.TryGetFunctionTool(functionCall.Name, out AIFunction? functionTool)) - { - availableTools.Add((functionCall, functionTool)); - } - else - { - pendingToolCalls.Add(functionCall.CallId); - } - } - - isComplete = pendingToolCalls.Count == 0; - - if (isComplete && availableTools.Count > 0) // %%% FUNCTION: isComplete = false => INVOKE LATER WHEN RESULTS RETURNED - { - // All tools are available, invoke them. - IList functionResults = await InvokeToolsAsync(availableTools, cancellationToken).ConfigureAwait(false); - messages = [new ChatMessage(ChatRole.Tool, [.. functionResults])]; // %%% FUNCTION: DRY !!! - isComplete = false; - } - - if (pendingToolCalls.Count > 0) - { - Dictionary toolCalls = toolCallsSequential.ToDictionary(tool => tool.CallId); - AgentToolRequest toolRequest = - new(agentName, - toolCalls - .Where(toolCall => pendingToolCalls.Contains(toolCall.Value.CallId)) - .Select(toolCall => toolCall.Value)); - await context.SendMessageAsync(toolRequest, targetId: null, cancellationToken).ConfigureAwait(false); - isComplete = false; - break; - } -#pragma warning restore CA1851 + AgentToolRequest toolRequest = new(agentName, toolCalls); + await context.SendMessageAsync(toolRequest, targetId: null, cancellationToken).ConfigureAwait(false); } } - while (!isComplete); if (isComplete) { @@ -119,26 +83,6 @@ private async ValueTask InvokeAgentAsync(IWorkflowContext context, IEnumerable> InvokeToolsAsync(IEnumerable<(FunctionCallContent, AIFunction)> functionCalls, CancellationToken cancellationToken) // %%% FUNCTION: DRY !!! - { - List results = []; - foreach ((FunctionCallContent functionCall, AIFunction functionTool) in functionCalls) // %%% PARALLEL - { - AIFunctionArguments functionArguments = new(functionCall.Arguments); // %%% FUNCTION: PORTABLE - if (functionArguments.Count > 0) - { - functionArguments = new(new Dictionary() { { "menuItem", "Clam Chowder" } }); - } - object? result = await functionTool.InvokeAsync(functionArguments, cancellationToken).ConfigureAwait(false); // %%% MEAI COMMON ??? -#pragma warning disable IL2026 // %%% PRAGMA JSON: Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code -#pragma warning disable IL3050 // %%% PRAGMA JSON: Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling. - results.Add(new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result))); // %%% JSON CONVERSION -#pragma warning restore IL3050 -#pragma warning restore IL2026 - } - return results; - } - private IEnumerable? GetInputMessages() { DataValue? userInput = null; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs index 2b8f38498f..6ed7b3fa54 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Events; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Declarative; @@ -15,18 +16,48 @@ namespace Microsoft.Agents.AI.Workflows.Declarative; public abstract class WorkflowAgentProvider { /// - /// Functions that can be used by the AI agents based on their definition. + /// 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. /// /// - /// %%% COMMENT - /// A instance will be automatically executed, when: - /// CASE 1 - AIAgent.ChatOptions.Tools (AIFunction) => AUTO - /// CASE 2 - AzureAgent Tools OR AIAgent.ChatOptions.Tools (AIFunctionDeclaration) LOOKUP => AUTO - /// Or else: - /// CASE 3 - AzureAgent Tools OR AIAgent.ChatOptions.Tools (AIFunctionDeclaration) MISSING => REQUEST + /// 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. /// @@ -76,27 +107,4 @@ public abstract IAsyncEnumerable GetMessagesAsync( string? before = null, bool newestFirst = false, CancellationToken cancellationToken = default); - - /// - /// Retrieves the requested function tool by name. - /// - /// Name of the function tool - /// // %%% COMMENT - /// The requested function tool declaration. - /// If function tool is not defined - public bool TryGetFunctionTool(string name, [NotNullWhen(true)] out AIFunction? functionTool) // %%% FUNCTION PROVIDER (OPTIONS: FUNCTIONS/FUNCTION PROVIDER(DEFAULT)) - { - if (this.Functions is not null) - { - this._functionMap ??= this.Functions.ToDictionary(tool => tool.Name, tool => tool); - } - else - { - this._functionMap ??= []; - } - - return this._functionMap.TryGetValue(name, out functionTool); - } - - private Dictionary? _functionMap; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs index c72752bd77..df2f4e0505 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs @@ -5,11 +5,13 @@ using System.IO; using System.Linq; using System.Text.Json; +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.IntegrationTests.Agents; using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; +using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Extensions.AI; using Xunit.Abstractions; @@ -43,29 +45,26 @@ private async Task RunWorkflowAsync(bool autoInvoke, params IEnumerable responseCount) { + Assert.False(autoInvoke); + RequestInfoEvent inputEvent = workflowEvents.InputEvents[workflowEvents.InputEvents.Count - 1]; AgentToolRequest? toolRequest = inputEvent.Request.Data.As(); Assert.NotNull(toolRequest); - List functionResults = []; + List<(FunctionCallContent, AIFunction)> functionCalls = []; foreach (FunctionCallContent functionCall in toolRequest.FunctionCalls) { this.Output.WriteLine($"TOOL REQUEST: {functionCall.Name}"); - Assert.False(autoInvoke); if (!functionMap.TryGetValue(functionCall.Name, out AIFunction? functionTool)) { Assert.Fail($"TOOL FAILURE [{functionCall.Name}] - MISSING"); return; } - AIFunctionArguments functionArguments = new(functionCall.Arguments); // %%% PORTABLE - if (functionArguments.Count > 0) // %%% HAXX - { - functionArguments = new(new Dictionary() { { "menuItem", "Clam Chowder" } }); - } - object? result = await functionTool.InvokeAsync(functionArguments); - functionResults.Add(new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result))); // %%% FUNCTION: JSON CONVERSION + functionCalls.Add((functionCall, functionTool)); } + IList functionResults = await InvokeToolsAsync(functionCalls); + ++responseCount; WorkflowEvents runEvents = await harness.ResumeAsync(AgentToolResponse.Create(toolRequest, functionResults)).ConfigureAwait(false); @@ -80,6 +79,24 @@ private async Task RunWorkflowAsync(bool autoInvoke, params IEnumerable 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 = new(functionCall.Arguments); // %%% FUNCTION: PORTABLE + if (functionArguments.Count > 0) + { + functionArguments = new(new Dictionary() { { "menuItem", "Clam Chowder" } }); + } + object? result = await functionTool.InvokeAsync(functionArguments).ConfigureAwait(false); + results.Add(new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result))); + } + return results; } } From bcc13dacf85fc23a6c9c44596ae81615ced67e2d Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 13 Oct 2025 15:04:47 -0700 Subject: [PATCH 05/21] Namespace --- .../ToolInputWorkflowTest.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs index df2f4e0505..ad6c9d451c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs @@ -5,13 +5,11 @@ using System.IO; using System.Linq; using System.Text.Json; -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.IntegrationTests.Agents; using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; -using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; using Microsoft.Extensions.AI; using Xunit.Abstractions; From 45e9f521de3f95947adf27519d2f5f3be17a86cd Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 13 Oct 2025 15:11:56 -0700 Subject: [PATCH 06/21] Namespace --- .../ObjectModel/InvokeAzureAgentExecutor.cs | 1 - .../WorkflowAgentProvider.cs | 2 -- 2 files changed, 3 deletions(-) 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 2c2afa07bc..de1eaf25dc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Events; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs index 6ed7b3fa54..9db57aba35 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Events; From 407ecbf0db77bdd8e4c34934e6ffd41610d22971 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 13 Oct 2025 18:15:23 -0700 Subject: [PATCH 07/21] Dun --- .../Declarative/ExecuteWorkflow/Program.cs | 65 +++++++++-- .../Events/AgentToolResponse.cs | 10 +- .../Extensions/PortableValueExtensions.cs | 30 +++--- .../Kit/PortableValueExtensions.cs | 102 ++++++++++++++++++ .../ToolInputWorkflowTest.cs | 9 +- 5 files changed, 184 insertions(+), 32 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/PortableValueExtensions.cs diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs index d0a5f6275f..3bfa9d7fad 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; @@ -65,18 +69,19 @@ private async Task ExecuteAsync() // 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}")); 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(); @@ -110,8 +115,13 @@ private async Task ExecuteAsync() private Workflow CreateWorkflow() { // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. + AzureAgentProvider agentProvider = new(this.FoundryEndpoint, new AzureCliCredential()) + { + Functions = this.FunctionMap.Values, + }; + DeclarativeWorkflowOptions options = - new(new AzureAgentProvider(this.FoundryEndpoint, new AzureCliCredential())) + new(agentProvider) { Configuration = this.Configuration, //ConversationId = null, // Assign to continue a conversation @@ -132,6 +142,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,9 +153,16 @@ 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 = + [ + // Define any custom functions that may be required by agents within the workflow. + //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; @@ -277,14 +295,22 @@ private Program(string workflowFile, string? workflowInput) return default; } - private static InputResponse HandleExternalRequest(ExternalRequest request) + + private async ValueTask HandleExternalRequestAsync(ExternalRequest request) => + request.Data.TypeId.TypeName switch + { + _ when request.Data.TypeId.IsMatch() => HandleInputRequest(request.DataAs()!), + _ when request.Data.TypeId.IsMatch() => await this.HandleToolRequestAsync(request.DataAs()!), + _ => throw new InvalidOperationException($"Unsupported external request type: {request.GetType().Name}."), + }; + + 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 +319,23 @@ private static InputResponse HandleExternalRequest(ExternalRequest request) return new InputResponse(userInput); } + 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/Events/AgentToolResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs index e21b68aec4..cf8ff96f64 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs @@ -33,7 +33,15 @@ internal AgentToolResponse(string agentName, params IEnumerable 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).ToHashSet(); HashSet resultIds = functionResults.Select(call => call.CallId).ToHashSet(); 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/Kit/PortableValueExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/PortableValueExtensions.cs new file mode 100644 index 0000000000..0d63c80a95 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/PortableValueExtensions.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections; +using System.Collections.Generic; +using System.Linq; +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, + 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 (string key in source.Keys) + { + object? value = source[key]; + yield return new KeyValuePair(key, value.NormalizePortableValue()); + } + } + } + + private static object?[] NormalizePortableValues(this IEnumerable source) + { + return GetValues().ToArray(); + + IEnumerable GetValues() + { + IEnumerator enumerator = source.GetEnumerator(); + while (enumerator.MoveNext()) + { + yield return enumerator.Current.NormalizePortableValue(); + } + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs index ad6c9d451c..11a4774366 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs @@ -10,6 +10,7 @@ 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; @@ -85,16 +86,14 @@ private async Task RunWorkflowAsync(bool autoInvoke, params IEnumerable> InvokeToolsAsync(IEnumerable<(FunctionCallContent, AIFunction)> functionCalls) { List results = []; + foreach ((FunctionCallContent functionCall, AIFunction functionTool) in functionCalls) { - AIFunctionArguments functionArguments = new(functionCall.Arguments); // %%% FUNCTION: PORTABLE - if (functionArguments.Count > 0) - { - functionArguments = new(new Dictionary() { { "menuItem", "Clam Chowder" } }); - } + 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; } } From e2ed2f0ae409d469296290f75fc228404df14000 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 13 Oct 2025 18:24:16 -0700 Subject: [PATCH 08/21] Async Test --- .../ToolInputWorkflowTest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs index 11a4774366..995a92bfc3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs @@ -22,11 +22,11 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests; public sealed class ToolInputWorkflowTest(ITestOutputHelper output) : IntegrationTest(output) { [Fact] - public Task ValidateAutoInvoke() => + public Task ValidateAutoInvokeAsync() => this.RunWorkflowAsync(autoInvoke: true, new MenuPlugin().GetTools()); [Fact] - public Task ValidateRequestInvoke() => + public Task ValidateRequestInvokeAsync() => this.RunWorkflowAsync(autoInvoke: false, new MenuPlugin().GetTools()); private static string GetWorkflowPath(string workflowFileName) => Path.Combine(Environment.CurrentDirectory, "Workflows", workflowFileName); From 7bddf1fc1c98eba0d0acbebc97b50f324e15cb6c Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 13 Oct 2025 18:28:15 -0700 Subject: [PATCH 09/21] AgentId --- .../Workflows/Declarative/ExecuteWorkflow/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs index 3bfa9d7fad..8fa034ab3f 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs @@ -233,7 +233,7 @@ private Program(string workflowFile, string? workflowInput) 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)) From 97246abeb7d85952d872feb27aee97e3f7559d17 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 13 Oct 2025 18:34:43 -0700 Subject: [PATCH 10/21] Portable pattern --- .../Events/AgentToolResponse.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs index cf8ff96f64..1867602ae3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs @@ -44,7 +44,7 @@ internal AgentToolResponse(string agentName, params IEnumerable functionResults) { HashSet callIds = toolRequest.FunctionCalls.Select(call => call.CallId).ToHashSet(); - HashSet resultIds = functionResults.Select(call => call.CallId).ToHashSet(); + HashSet resultIds = new(functionResults.Select(call => call.CallId)); if (!callIds.SetEquals(resultIds)) { throw new DeclarativeActionException($"Missing results for: {string.Join(",", callIds.Except(resultIds))}"); From 7b4560437dac500826d06d1a0a029bc12ba16157 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 13 Oct 2025 18:36:05 -0700 Subject: [PATCH 11/21] Portable2 --- .../Events/AgentToolResponse.cs | 2 +- .../ObjectModel/InvokeAzureAgentExecutor.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs index 1867602ae3..d1dbc4fa0c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs @@ -44,7 +44,7 @@ internal AgentToolResponse(string agentName, params IEnumerable functionResults) { HashSet callIds = toolRequest.FunctionCalls.Select(call => call.CallId).ToHashSet(); - HashSet resultIds = new(functionResults.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))}"); 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 de1eaf25dc..20936b8796 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -63,7 +63,7 @@ private async ValueTask InvokeAgentAsync(IWorkflowContext context, IEnumerable toolCalls = agentResponse.Messages.SelectMany(m => m.Contents.OfType()).ToHashSet(); + HashSet toolCalls = [.. agentResponse.Messages.SelectMany(m => m.Contents.OfType())]; isComplete = toolCalls.Count == 0; From 3fa3a541abf438cdcd1d3837de60e6ea45976810 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 13 Oct 2025 18:41:43 -0700 Subject: [PATCH 12/21] Portable3 --- .../Events/AgentToolResponse.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs index d1dbc4fa0c..5bf5fd6149 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs @@ -43,7 +43,7 @@ internal AgentToolResponse(string agentName, params IEnumerableNot all have a corresponding . public static AgentToolResponse Create(AgentToolRequest toolRequest, params IEnumerable functionResults) { - HashSet callIds = toolRequest.FunctionCalls.Select(call => call.CallId).ToHashSet(); + HashSet callIds = [.. toolRequest.FunctionCalls.Select(call => call.CallId)]; HashSet resultIds = [.. functionResults.Select(call => call.CallId)]; if (!callIds.SetEquals(resultIds)) { From f0a28a7ce663601470e179f50f6c74eceb1a3a32 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 14 Oct 2025 08:40:19 -0700 Subject: [PATCH 13/21] Respond to comments --- .../AzureAgentProvider.cs | 14 +++++++++++-- .../Extensions/DataValueExtensions.cs | 4 ++-- .../Extensions/FormulaValueExtensions.cs | 5 +++-- .../Extensions/ObjectExtensions.cs | 4 ++-- .../Kit/PortableValueExtensions.cs | 20 ++++--------------- 5 files changed, 23 insertions(+), 24 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs index 8545249c3b..8ad65c3523 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs @@ -112,12 +112,22 @@ await this.GetAgentsClient().GetAIAgentAsync( FunctionInvokingChatClient? functionInvokingClient = agent.GetService(); if (functionInvokingClient is not null) { - // Make functions available for execution. Doesn't change what tool is available for any given agent. - functionInvokingClient.AdditionalTools = this.Functions is null ? null : [.. this.Functions.OfType()]; // 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; 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..501ade16a3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Dynamic; +using System.IdentityModel.Protocols.WSTrust; using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; @@ -157,9 +158,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/Kit/PortableValueExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/PortableValueExtensions.cs index 0d63c80a95..f6bc8ef0ff 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/PortableValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/PortableValueExtensions.cs @@ -78,25 +78,13 @@ _ when value.IsParentType(out IEnumerable? listValue) => listValue.NormalizePort IEnumerable> GetValues() { - foreach (string key in source.Keys) + foreach (DictionaryEntry entry in source) { - object? value = source[key]; - yield return new KeyValuePair(key, value.NormalizePortableValue()); + yield return new KeyValuePair((string)entry.Key, entry.Value.NormalizePortableValue()); } } } - private static object?[] NormalizePortableValues(this IEnumerable source) - { - return GetValues().ToArray(); - - IEnumerable GetValues() - { - IEnumerator enumerator = source.GetEnumerator(); - while (enumerator.MoveNext()) - { - yield return enumerator.Current.NormalizePortableValue(); - } - } - } + private static object?[] NormalizePortableValues(this IEnumerable source) => + source.Cast().Select(NormalizePortableValue).ToArray(); } From 960580e73e2fa41e7b8c2d8b87c5fb1c87a72dff Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 14 Oct 2025 08:52:47 -0700 Subject: [PATCH 14/21] Namespace --- .../Extensions/FormulaValueExtensions.cs | 1 - 1 file changed, 1 deletion(-) 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 501ade16a3..e0425bfbec 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Dynamic; -using System.IdentityModel.Protocols.WSTrust; using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; From c853906193036ac9021ec58ae6d48d68384877ef Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 14 Oct 2025 09:10:29 -0700 Subject: [PATCH 15/21] Function call selection --- .../ObjectModel/InvokeAzureAgentExecutor.cs | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) 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 20936b8796..aac0b4b395 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -63,13 +63,13 @@ private async ValueTask InvokeAgentAsync(IWorkflowContext context, IEnumerable toolCalls = [.. agentResponse.Messages.SelectMany(m => m.Contents.OfType())]; - - isComplete = toolCalls.Count == 0; + // Identify function calls that have no associated result. + List functionCalls = this.GetOrphanedFunctionCalls(agentResponse); + isComplete = functionCalls.Count == 0; if (!isComplete) { - AgentToolRequest toolRequest = new(agentName, toolCalls); + AgentToolRequest toolRequest = new(agentName, functionCalls); await context.SendMessageAsync(toolRequest, targetId: null, cancellationToken).ConfigureAwait(false); } } @@ -95,6 +95,29 @@ private async ValueTask InvokeAgentAsync(IWorkflowContext context, IEnumerable GetOrphanedFunctionCalls(AgentRunResponse agentResponse) + { + HashSet functionResultIds = + agentResponse.Messages + .SelectMany( + m => + m.Contents + .OfType() + .Select(functionCall => functionCall.CallId)) + .ToHashSet(); + + 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) From 8dc95d50a06d5665c76098be0c853e54802a2401 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 14 Oct 2025 09:45:40 -0700 Subject: [PATCH 16/21] ToHashSet --- .../Declarative/ExecuteWorkflow/Program.cs | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs index 8fa034ab3f..bcbd9beacc 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs @@ -112,12 +112,21 @@ 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 = this.FunctionMap.Values, + // Functions included here will be auto-executed by the framework. + Functions = IncludeFunctions ? this.FunctionMap.Values : null, }; DeclarativeWorkflowOptions options = @@ -131,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; } = []; @@ -156,7 +175,8 @@ private Program(string workflowFile, string? workflowInput) List functions = [ - // Define any custom functions that may be required by agents within the workflow. + // 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); @@ -296,14 +316,23 @@ private Program(string workflowFile, string? workflowInput) return default; } + /// + /// 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) { string? userInput; @@ -319,6 +348,13 @@ private static InputResponse HandleInputRequest(InputRequest 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(); From 920198f7998b47f6028716121724fe5f01aeb17d Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 14 Oct 2025 09:47:24 -0700 Subject: [PATCH 17/21] ToHashSet --- .../ObjectModel/InvokeAzureAgentExecutor.cs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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 aac0b4b395..b26add9f81 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -98,13 +98,12 @@ private async ValueTask InvokeAgentAsync(IWorkflowContext context, IEnumerable GetOrphanedFunctionCalls(AgentRunResponse agentResponse) { HashSet functionResultIds = - agentResponse.Messages - .SelectMany( - m => - m.Contents - .OfType() - .Select(functionCall => functionCall.CallId)) - .ToHashSet(); + [.. agentResponse.Messages + .SelectMany( + m => + m.Contents + .OfType() + .Select(functionCall => functionCall.CallId))]; List functionCalls = []; foreach (FunctionCallContent functionCall in agentResponse.Messages.SelectMany(m => m.Contents.OfType())) From fa9303d13a50f8798b8735f114b9afe3cd334840 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 14 Oct 2025 11:09:46 -0700 Subject: [PATCH 18/21] Updated --- .../Kit/PortableValueExtensions.cs | 15 +++++++++++++++ .../Agents/MenuPlugin.cs | 4 ++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/PortableValueExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/PortableValueExtensions.cs index f6bc8ef0ff..ab9a196091 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/PortableValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/PortableValueExtensions.cs @@ -3,6 +3,7 @@ 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; @@ -46,6 +47,7 @@ public static class PortableValueExtensions Throw.IfNull(value, nameof(value)) switch { null => null, + JsonElement jsonValue => jsonValue.GetValue(), PortableValue portableValue => portableValue.Normalize(), _ => value, }; @@ -87,4 +89,17 @@ _ when value.IsParentType(out IEnumerable? listValue) => listValue.NormalizePort 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/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MenuPlugin.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MenuPlugin.cs index 5a06ae4523..0645f08d4b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MenuPlugin.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MenuPlugin.cs @@ -33,9 +33,9 @@ public MenuItem[] GetSpecials() [KernelFunction, Description("Provides the price of the requested menu item.")] public float? GetItemPrice( [Description("The name of the menu item.")] - string menuItem) + string itemName) { - return s_menuItems.FirstOrDefault(i => i.Name.Equals(menuItem, StringComparison.OrdinalIgnoreCase))?.Price; + return s_menuItems.FirstOrDefault(i => i.Name.Equals(itemName, StringComparison.OrdinalIgnoreCase))?.Price; } private static readonly MenuItem[] s_menuItems = From aba5feff4ecdabf979f2d0e2eb7205cac84adce4 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 14 Oct 2025 11:21:15 -0700 Subject: [PATCH 19/21] Parameter name --- .../Agents/MenuPlugin.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 0645f08d4b..d69e856af8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MenuPlugin.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MenuPlugin.cs @@ -33,9 +33,9 @@ public MenuItem[] GetSpecials() [KernelFunction, Description("Provides the price of the requested menu item.")] public float? GetItemPrice( [Description("The name of the menu item.")] - string itemName) + string name) { - return s_menuItems.FirstOrDefault(i => i.Name.Equals(itemName, StringComparison.OrdinalIgnoreCase))?.Price; + return s_menuItems.FirstOrDefault(i => i.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Price; } private static readonly MenuItem[] s_menuItems = From f3aa3f731bd2ff7b3c5d1354f5838ac1c87b404d Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 14 Oct 2025 11:34:01 -0700 Subject: [PATCH 20/21] Final --- .../Workflows/Declarative/ExecuteWorkflow/Program.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs index bcbd9beacc..1a9d9cdff0 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs @@ -283,11 +283,17 @@ 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); } finally { From 7c8b950e5688728520093965ba39d4f75b5565d4 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Tue, 14 Oct 2025 13:15:58 -0700 Subject: [PATCH 21/21] Tests --- .../Declarative/ExecuteWorkflow/Program.cs | 13 +++++++-- .../Events/AgentToolRequest.cs | 7 ++--- .../Events/AgentToolResponse.cs | 9 +++--- .../Framework/WorkflowHarness.cs | 2 +- .../Events/AgentToolRequestTest.cs | 28 +++++++++++++++++++ .../Events/AgentToolResponseTest.cs | 27 ++++++++++++++++++ .../Events/EventTest.cs | 21 ++++++++++++++ .../Events/InputRequest.cs | 19 +++++++++++++ .../Events/InputResponse.cs | 19 +++++++++++++ 9 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentToolRequestTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentToolResponseTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/EventTest.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/InputRequest.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/InputResponse.cs diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs index 1a9d9cdff0..774ca560f7 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs @@ -1,7 +1,7 @@ // 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; @@ -67,7 +67,7 @@ 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)); #else // Use an in-memory checkpoint store that will not persist checkpoints beyond the lifetime of the process. @@ -186,6 +186,7 @@ private Program(string workflowFile, string? workflowInput) { await using IAsyncDisposable disposeRun = run; + bool hasStreamed = false; string? messageId = null; await foreach (WorkflowEvent workflowEvent in run.Run.WatchStreamAsync().ConfigureAwait(false)) @@ -249,6 +250,7 @@ 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) @@ -294,6 +296,7 @@ private Program(string workflowFile, string? workflowInput) { Console.ResetColor(); Console.Write(streamEvent.Update.Text); + hasStreamed |= !string.IsNullOrEmpty(streamEvent.Update.Text); } finally { @@ -304,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; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs index c879e129a6..b1fe34eda1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; -using System.Collections.Immutable; using System.Text.Json.Serialization; using Microsoft.Extensions.AI; @@ -20,12 +19,12 @@ public sealed class AgentToolRequest /// /// A list of tool requests. /// - public IReadOnlyList FunctionCalls { get; } + public IList FunctionCalls { get; } [JsonConstructor] - internal AgentToolRequest(string agentName, IEnumerable functionCalls) + internal AgentToolRequest(string agentName, IList? functionCalls = null) { this.AgentName = agentName; - this.FunctionCalls = functionCalls.ToImmutableArray(); + 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 index 5bf5fd6149..29a7f98954 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; using System.Text.Json.Serialization; using Microsoft.Extensions.AI; @@ -21,16 +20,16 @@ public sealed class AgentToolResponse /// /// A list of tool responses. /// - public IReadOnlyList FunctionResults { get; } + public IList FunctionResults { get; } /// /// Initializes a new instance of the class. /// [JsonConstructor] - internal AgentToolResponse(string agentName, params IEnumerable functionResults) + internal AgentToolResponse(string agentName, IList functionResults) { this.AgentName = agentName; - this.FunctionResults = functionResults.ToImmutableArray(); + this.FunctionResults = functionResults; } /// @@ -49,6 +48,6 @@ public static AgentToolResponse Create(AgentToolRequest toolRequest, params IEnu { throw new DeclarativeActionException($"Missing results for: {string.Join(",", callIds.Except(resultIds))}"); } - return new AgentToolResponse(toolRequest.AgentName, functionResults); + return new AgentToolResponse(toolRequest.AgentName, [.. functionResults]); } } 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 c7be52c9a1..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 @@ -82,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 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); + } +}