Skip to content

Commit a6b6937

Browse files
authored
.NET Workflows - Support agent level function invocation for declarative workflow (#1442)
* Checkpoint * Checkpoint * Checkpoint * Good * Namespace * Namespace * Dun * Async Test * AgentId * Portable pattern * Portable2 * Portable3 * Respond to comments * Namespace * Function call selection * ToHashSet * ToHashSet * Updated * Parameter name * Final * Tests
1 parent a403913 commit a6b6937

File tree

25 files changed

+894
-72
lines changed

25 files changed

+894
-72
lines changed

dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs

Lines changed: 107 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
// Copyright (c) Microsoft. All rights reserved.
22

33
// Uncomment this to enable JSON checkpointing to the local file system.
4-
#define CHECKPOINT_JSON
4+
//#define CHECKPOINT_JSON
55

66
using System.Diagnostics;
77
using System.Reflection;
8+
using System.Text.Json;
89
using Azure.AI.Agents.Persistent;
910
using Azure.Identity;
1011
using Microsoft.Agents.AI.Workflows;
12+
#if CHECKPOINT_JSON
1113
using Microsoft.Agents.AI.Workflows.Checkpointing;
14+
#endif
1215
using Microsoft.Agents.AI.Workflows.Declarative;
1316
using Microsoft.Agents.AI.Workflows.Declarative.Events;
17+
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
1418
using Microsoft.Extensions.AI;
1519
using Microsoft.Extensions.Configuration;
1620

@@ -63,20 +67,21 @@ private async Task ExecuteAsync()
6367

6468
#if CHECKPOINT_JSON
6569
// Use a file-system based JSON checkpoint store to persist checkpoints to disk.
66-
DirectoryInfo checkpointFolder = Directory.CreateDirectory(Path.Combine(".", $"chk-{DateTime.Now:YYmmdd-hhMMss-ff}"));
70+
DirectoryInfo checkpointFolder = Directory.CreateDirectory(Path.Combine(".", $"chk-{DateTime.Now:yyMMdd-hhmmss-ff}"));
6771
CheckpointManager checkpointManager = CheckpointManager.CreateJson(new FileSystemJsonCheckpointStore(checkpointFolder));
68-
Checkpointed<StreamingRun> run = await InProcessExecution.StreamAsync(workflow, input, checkpointManager);
6972
#else
7073
// Use an in-memory checkpoint store that will not persist checkpoints beyond the lifetime of the process.
7174
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
7275
#endif
7376

77+
Checkpointed<StreamingRun> run = await InProcessExecution.StreamAsync(workflow, input, checkpointManager);
78+
7479
bool isComplete = false;
75-
InputResponse? response = null;
80+
object? response = null;
7681
do
7782
{
78-
ExternalRequest? inputRequest = await this.MonitorAndDisposeWorkflowRunAsync(run, response);
79-
if (inputRequest is not null)
83+
ExternalRequest? externalRequest = await this.MonitorAndDisposeWorkflowRunAsync(run, response);
84+
if (externalRequest is not null)
8085
{
8186
Notify("\nWORKFLOW: Yield");
8287

@@ -86,7 +91,7 @@ private async Task ExecuteAsync()
8691
}
8792

8893
// Process the external request.
89-
response = HandleExternalRequest(inputRequest);
94+
response = await this.HandleExternalRequestAsync(externalRequest);
9095

9196
// Let's resume on an entirely new workflow instance to demonstrate checkpoint portability.
9297
workflow = this.CreateWorkflow();
@@ -107,11 +112,25 @@ private async Task ExecuteAsync()
107112
Notify("\nWORKFLOW: Done!\n");
108113
}
109114

115+
/// <summary>
116+
/// Create the workflow from the declarative YAML. Includes definition of the
117+
/// <see cref="DeclarativeWorkflowOptions" /> and the associated <see cref="WorkflowAgentProvider"/>.
118+
/// </summary>
119+
/// <remarks>
120+
/// The value assigned to <see cref="IncludeFunctions" /> controls on whether the function
121+
/// tools (<see cref="AIFunction"/>) initialized in the constructor are included for auto-invocation.
122+
/// </remarks>
110123
private Workflow CreateWorkflow()
111124
{
112125
// Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file.
126+
AzureAgentProvider agentProvider = new(this.FoundryEndpoint, new AzureCliCredential())
127+
{
128+
// Functions included here will be auto-executed by the framework.
129+
Functions = IncludeFunctions ? this.FunctionMap.Values : null,
130+
};
131+
113132
DeclarativeWorkflowOptions options =
114-
new(new AzureAgentProvider(this.FoundryEndpoint, new AzureCliCredential()))
133+
new(agentProvider)
115134
{
116135
Configuration = this.Configuration,
117136
//ConversationId = null, // Assign to continue a conversation
@@ -121,8 +140,18 @@ private Workflow CreateWorkflow()
121140
return DeclarativeWorkflowBuilder.Build<string>(this.WorkflowFile, options);
122141
}
123142

143+
/// <summary>
144+
/// Configuration key used to identify the Foundry project endpoint.
145+
/// </summary>
124146
private const string ConfigKeyFoundryEndpoint = "FOUNDRY_PROJECT_ENDPOINT";
125147

148+
/// <summary>
149+
/// Controls on whether the function tools (<see cref="AIFunction"/>) initialized
150+
/// in the constructor are included for auto-invocation.
151+
/// NOTE: By default, no functions exist as part of this sample.
152+
/// </summary>
153+
private const bool IncludeFunctions = true;
154+
126155
private static Dictionary<string, string> NameCache { get; } = [];
127156
private static HashSet<string> FileCache { get; } = [];
128157

@@ -132,6 +161,7 @@ private Workflow CreateWorkflow()
132161
private PersistentAgentsClient FoundryClient { get; }
133162
private IConfiguration Configuration { get; }
134163
private CheckpointInfo? LastCheckpoint { get; set; }
164+
private Dictionary<string, AIFunction> FunctionMap { get; }
135165

136166
private Program(string workflowFile, string? workflowInput)
137167
{
@@ -142,12 +172,21 @@ private Program(string workflowFile, string? workflowInput)
142172

143173
this.FoundryEndpoint = this.Configuration[ConfigKeyFoundryEndpoint] ?? throw new InvalidOperationException($"Undefined configuration setting: {ConfigKeyFoundryEndpoint}");
144174
this.FoundryClient = new PersistentAgentsClient(this.FoundryEndpoint, new AzureCliCredential());
175+
176+
List<AIFunction> functions =
177+
[
178+
// Manually define any custom functions that may be required by agents within the workflow.
179+
// By default, this sample does not include any functions.
180+
//AIFunctionFactory.Create(),
181+
];
182+
this.FunctionMap = functions.ToDictionary(f => f.Name);
145183
}
146184

147-
private async Task<ExternalRequest?> MonitorAndDisposeWorkflowRunAsync(Checkpointed<StreamingRun> run, InputResponse? response = null)
185+
private async Task<ExternalRequest?> MonitorAndDisposeWorkflowRunAsync(Checkpointed<StreamingRun> run, object? response = null)
148186
{
149187
await using IAsyncDisposable disposeRun = run;
150188

189+
bool hasStreamed = false;
151190
string? messageId = null;
152191

153192
await foreach (WorkflowEvent workflowEvent in run.Run.WatchStreamAsync())
@@ -211,11 +250,12 @@ private Program(string workflowFile, string? workflowInput)
211250
case AgentRunUpdateEvent streamEvent:
212251
if (!string.Equals(messageId, streamEvent.Update.MessageId, StringComparison.Ordinal))
213252
{
253+
hasStreamed = false;
214254
messageId = streamEvent.Update.MessageId;
215255

216256
if (messageId is not null)
217257
{
218-
string? agentId = streamEvent.Update.AuthorName;
258+
string? agentId = streamEvent.Update.AgentId;
219259
if (agentId is not null)
220260
{
221261
if (!NameCache.TryGetValue(agentId, out string? realName))
@@ -245,11 +285,18 @@ private Program(string workflowFile, string? workflowInput)
245285
await DownloadFileContentAsync(Path.GetFileName(messageUpdate.TextAnnotation?.TextToReplace ?? "response.png"), content);
246286
}
247287
break;
288+
case RequiredActionUpdate actionUpdate:
289+
Console.ForegroundColor = ConsoleColor.White;
290+
Console.Write($"Calling tool: {actionUpdate.FunctionName}");
291+
Console.ForegroundColor = ConsoleColor.DarkGray;
292+
Console.WriteLine($" [{actionUpdate.ToolCallId}]");
293+
break;
248294
}
249295
try
250296
{
251297
Console.ResetColor();
252-
Console.Write(streamEvent.Data);
298+
Console.Write(streamEvent.Update.Text);
299+
hasStreamed |= !string.IsNullOrEmpty(streamEvent.Update.Text);
253300
}
254301
finally
255302
{
@@ -260,7 +307,11 @@ private Program(string workflowFile, string? workflowInput)
260307
case AgentRunResponseEvent messageEvent:
261308
try
262309
{
263-
Console.WriteLine();
310+
if (hasStreamed)
311+
{
312+
Console.WriteLine();
313+
}
314+
264315
if (messageEvent.Response.Usage is not null)
265316
{
266317
Console.ForegroundColor = ConsoleColor.DarkGray;
@@ -277,14 +328,31 @@ private Program(string workflowFile, string? workflowInput)
277328

278329
return default;
279330
}
280-
private static InputResponse HandleExternalRequest(ExternalRequest request)
331+
332+
/// <summary>
333+
/// Handle request for external input, either from a human or a function tool invocation.
334+
/// </summary>
335+
private async ValueTask<object> HandleExternalRequestAsync(ExternalRequest request) =>
336+
request.Data.TypeId.TypeName switch
337+
{
338+
// Request for human input
339+
_ when request.Data.TypeId.IsMatch<InputRequest>() => HandleInputRequest(request.DataAs<InputRequest>()!),
340+
// Request for function tool invocation. (Only active when functions are defined and IncludeFunctions is true.)
341+
_ when request.Data.TypeId.IsMatch<AgentToolRequest>() => await this.HandleToolRequestAsync(request.DataAs<AgentToolRequest>()!),
342+
// Unknown request type.
343+
_ => throw new InvalidOperationException($"Unsupported external request type: {request.GetType().Name}."),
344+
};
345+
346+
/// <summary>
347+
/// Handle request for human input.
348+
/// </summary>
349+
private static InputResponse HandleInputRequest(InputRequest request)
281350
{
282-
InputRequest? message = request.Data.As<InputRequest>();
283351
string? userInput;
284352
do
285353
{
286354
Console.ForegroundColor = ConsoleColor.DarkGreen;
287-
Console.Write($"\n{message?.Prompt ?? "INPUT:"} ");
355+
Console.Write($"\n{request.Prompt ?? "INPUT:"} ");
288356
Console.ForegroundColor = ConsoleColor.White;
289357
userInput = Console.ReadLine();
290358
}
@@ -293,6 +361,30 @@ private static InputResponse HandleExternalRequest(ExternalRequest request)
293361
return new InputResponse(userInput);
294362
}
295363

364+
/// <summary>
365+
/// Handle a function tool request by invoking the specified tools and returning the results.
366+
/// </summary>
367+
/// <remarks>
368+
/// This handler is only active when <see cref="IncludeFunctions"/> is set to true and
369+
/// one or more <see cref="AIFunction"/> instances are defined in the constructor.
370+
/// </remarks>
371+
private async ValueTask<AgentToolResponse> HandleToolRequestAsync(AgentToolRequest request)
372+
{
373+
Task<FunctionResultContent>[] functionTasks = request.FunctionCalls.Select(functionCall => InvokesToolAsync(functionCall)).ToArray();
374+
375+
await Task.WhenAll(functionTasks);
376+
377+
return AgentToolResponse.Create(request, functionTasks.Select(task => task.Result));
378+
379+
async Task<FunctionResultContent> InvokesToolAsync(FunctionCallContent functionCall)
380+
{
381+
AIFunction functionTool = this.FunctionMap[functionCall.Name];
382+
AIFunctionArguments? functionArguments = functionCall.Arguments is null ? null : new(functionCall.Arguments.NormalizePortableValues());
383+
object? result = await functionTool.InvokeAsync(functionArguments);
384+
return new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result));
385+
}
386+
}
387+
296388
private static string? ParseWorkflowFile(string[] args)
297389
{
298390
string? workflowFile = args.FirstOrDefault();

dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,41 @@ IEnumerable<MessageInputContentBlock> GetContent()
9797
}
9898

9999
/// <inheritdoc/>
100-
public override async Task<AIAgent> GetAgentAsync(string agentId, CancellationToken cancellationToken = default) =>
101-
await this.GetAgentsClient().GetAIAgentAsync(agentId, chatOptions: null, clientFactory: null, cancellationToken).ConfigureAwait(false);
100+
public override async Task<AIAgent> GetAgentAsync(string agentId, CancellationToken cancellationToken = default)
101+
{
102+
ChatClientAgent agent =
103+
await this.GetAgentsClient().GetAIAgentAsync(
104+
agentId,
105+
new ChatOptions()
106+
{
107+
AllowMultipleToolCalls = this.AllowMultipleToolCalls,
108+
},
109+
clientFactory: null,
110+
cancellationToken).ConfigureAwait(false);
111+
112+
FunctionInvokingChatClient? functionInvokingClient = agent.GetService<FunctionInvokingChatClient>();
113+
if (functionInvokingClient is not null)
114+
{
115+
// Allow concurrent invocations if configured
116+
functionInvokingClient.AllowConcurrentInvocation = this.AllowConcurrentInvocation;
117+
// Allows the caller to respond with function responses
118+
functionInvokingClient.TerminateOnUnknownCalls = true;
119+
// Make functions available for execution. Doesn't change what tool is available for any given agent.
120+
if (this.Functions is not null)
121+
{
122+
if (functionInvokingClient.AdditionalTools is null)
123+
{
124+
functionInvokingClient.AdditionalTools = [.. this.Functions];
125+
}
126+
else
127+
{
128+
functionInvokingClient.AdditionalTools = [.. functionInvokingClient.AdditionalTools, .. this.Functions];
129+
}
130+
}
131+
}
132+
133+
return agent;
134+
}
102135

103136
/// <inheritdoc/>
104137
public override async Task<ChatMessage> GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Collections.Generic;
4+
using System.Text.Json.Serialization;
5+
using Microsoft.Extensions.AI;
6+
7+
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
8+
9+
/// <summary>
10+
/// Represents a request for user input.
11+
/// </summary>
12+
public sealed class AgentToolRequest
13+
{
14+
/// <summary>
15+
/// The name of the agent associated with the tool request.
16+
/// </summary>
17+
public string AgentName { get; }
18+
19+
/// <summary>
20+
/// A list of tool requests.
21+
/// </summary>
22+
public IList<FunctionCallContent> FunctionCalls { get; }
23+
24+
[JsonConstructor]
25+
internal AgentToolRequest(string agentName, IList<FunctionCallContent>? functionCalls = null)
26+
{
27+
this.AgentName = agentName;
28+
this.FunctionCalls = functionCalls ?? [];
29+
}
30+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Collections.Generic;
4+
using System.Linq;
5+
using System.Text.Json.Serialization;
6+
using Microsoft.Extensions.AI;
7+
8+
namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
9+
10+
/// <summary>
11+
/// Represents a user input response.
12+
/// </summary>
13+
public sealed class AgentToolResponse
14+
{
15+
/// <summary>
16+
/// The name of the agent associated with the tool response.
17+
/// </summary>
18+
public string AgentName { get; }
19+
20+
/// <summary>
21+
/// A list of tool responses.
22+
/// </summary>
23+
public IList<FunctionResultContent> FunctionResults { get; }
24+
25+
/// <summary>
26+
/// Initializes a new instance of the <see cref="InputResponse"/> class.
27+
/// </summary>
28+
[JsonConstructor]
29+
internal AgentToolResponse(string agentName, IList<FunctionResultContent> functionResults)
30+
{
31+
this.AgentName = agentName;
32+
this.FunctionResults = functionResults;
33+
}
34+
35+
/// <summary>
36+
/// Factory method to create an <see cref="AgentToolResponse"/> from an <see cref="AgentToolRequest"/>
37+
/// Ensures that all function calls in the request have a corresponding result.
38+
/// </summary>
39+
/// <param name="toolRequest">The tool request.</param>
40+
/// <param name="functionResults">On or more function results</param>
41+
/// <returns>An <see cref="AgentToolResponse"/> that can be provided to the workflow.</returns>
42+
/// <exception cref="DeclarativeActionException">Not all <see cref="AgentToolRequest.FunctionCalls"/> have a corresponding <see cref="FunctionResultContent"/>.</exception>
43+
public static AgentToolResponse Create(AgentToolRequest toolRequest, params IEnumerable<FunctionResultContent> functionResults)
44+
{
45+
HashSet<string> callIds = [.. toolRequest.FunctionCalls.Select(call => call.CallId)];
46+
HashSet<string> resultIds = [.. functionResults.Select(call => call.CallId)];
47+
if (!callIds.SetEquals(resultIds))
48+
{
49+
throw new DeclarativeActionException($"Missing results for: {string.Join(",", callIds.Except(resultIds))}");
50+
}
51+
return new AgentToolResponse(toolRequest.AgentName, [.. functionResults]);
52+
}
53+
}

dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ public static ChatMessage ToChatMessage(this RecordDataValue message) =>
9494

9595
public static ChatMessage ToChatMessage(this StringDataValue message) => new(ChatRole.User, message.Value);
9696

97+
public static ChatMessage ToChatMessage(this IEnumerable<FunctionResultContent> functionResults) =>
98+
new(ChatRole.Tool, [.. functionResults]);
99+
97100
public static AdditionalPropertiesDictionary? ToMetadata(this RecordDataValue? metadata)
98101
{
99102
if (metadata is null)

dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/DataValueExtensions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,9 @@ public static RecordDataValue ToRecordValue(this IDictionary value)
148148

149149
IEnumerable<KeyValuePair<string, DataValue>> GetFields()
150150
{
151-
foreach (string key in value.Keys)
151+
foreach (DictionaryEntry entry in value)
152152
{
153-
yield return new KeyValuePair<string, DataValue>(key, value[key].ToDataValue());
153+
yield return new KeyValuePair<string, DataValue>((string)entry.Key, entry.Value.ToDataValue());
154154
}
155155
}
156156
}

0 commit comments

Comments
 (0)